Repository: schemathesis/schemathesis
Issue: [BUG] Missing components from remote schemas

**Describe the bug**
If the tested schema consists of multiple files, then Schemathesis will inline components only from the root schema, which is insufficient if parameter schemas contain references to other files. Originally it was working in ~`2.5` version, but then the implementation was changed to avoid inlining too much (in some cases, it was OOM)

**To Reproduce**
Steps to reproduce the behavior:

Run

```
schemathesis run https://raw.githubusercontent.com/radiantearth/stac-api-spec/master/item-search/openapi.yaml --base-url=http://127.0.0.1 --dry-run --show-errors-tracebacks -M POST
```

Schema - https://raw.githubusercontent.com/radiantearth/stac-api-spec/master/item-search/openapi.yaml

See `jsonschema.exceptions.RefResolutionError: Unresolvable JSON pointer: 'components/schemas/geometryGeoJSON'`

**Expected behavior**
All data is generated without errors


**Additional context**
Originally reported in [gitter](https://gitter.im/schemathesis/schemathesis?at=60abaa9745c2185a5ee8a65d)

Related to #986 


Changed files:
test/specs/openapi/test_hypothesis.py, src/schemathesis/specs/openapi/schemas.py

diff --git a/src/schemathesis/specs/openapi/schemas.py b/src/schemathesis/specs/openapi/schemas.py
index a22a4525..ced3bb0d 100644
--- a/src/schemathesis/specs/openapi/schemas.py
+++ b/src/schemathesis/specs/openapi/schemas.py
@@ -464,11 +464,13 @@ class BaseOpenAPISchema(BaseSchema):
         Inlining components helps `hypothesis-jsonschema` generate data that involves non-resolved references.
         """
         schema = deepcopy(schema)
-        schema = traverse_schema(schema, lambda s: self._rewrite_references(s, self.resolver))
+        schema = traverse_schema(schema, lambda s: self._rewrite_references(s, self.resolver, True, set()))
+
+        seen = set()
 
         def callback(_schema: Dict[str, Any], nullable_name: str) -> Dict[str, Any]:
             _schema = to_json_schema(_schema, nullable_name)
-            return self._rewrite_references(_schema, self.resolver)
+            return self._rewrite_references(_schema, self.resolver, True, seen)
 
         # Different spec versions allow different keywords to store possible reference targets
         for key in self.component_locations:
@@ -480,22 +482,31 @@ class BaseOpenAPISchema(BaseSchema):
             schema[INLINED_REFERENCES_KEY] = self._inline_reference_cache
         return schema
 
-    def _rewrite_references(self, schema: Dict[str, Any], resolver: InliningResolver) -> Dict[str, Any]:
+    def _rewrite_references(
+        self, schema: Dict[str, Any], resolver: InliningResolver, only_remote: bool, seen
+    ) -> Dict[str, Any]:
         """Rewrite references present in the schema.
 
         The idea is to resolve references, cache the result and replace these references with new ones
         that point to a local path which is populated from this cache later on.
+
+        It is used for moving remote schemas & components to the root schema to avoid passing unresolvable references
+        to `hypothesis-jsonschema`.
         """
+        schema_id = id(schema)
+        if schema_id in seen:
+            return schema
+        seen.add(schema_id)
         reference = schema.get("$ref")
         # If `$ref` is not a property name and should be processed
-        if reference is not None and isinstance(reference, str) and not reference.startswith("#/"):
+        if reference is not None and isinstance(reference, str) and not (only_remote and reference.startswith("#/")):
             key = _make_reference_key(resolver._scopes_stack, reference)
             with self._inline_reference_cache_lock:
                 if key not in self._inline_reference_cache:
                     with resolver.resolving(reference) as resolved:
                         # Resolved object also may have references
                         self._inline_reference_cache[key] = traverse_schema(
-                            resolved, lambda s: self._rewrite_references(s, resolver)
+                            resolved, lambda s: self._rewrite_references(s, resolver, False, seen)
                         )
             # Rewrite the reference with the new location
             schema["$ref"] = f"#/{INLINED_REFERENCES_KEY}/{key}"
diff --git a/test/specs/openapi/test_hypothesis.py b/test/specs/openapi/test_hypothesis.py
index 11d4bf92..a72aa242 100644
--- a/test/specs/openapi/test_hypothesis.py
+++ b/test/specs/openapi/test_hypothesis.py
@@ -2,6 +2,7 @@ import json
 
 import pytest
 from hypothesis import assume, given, settings
+from hypothesis_jsonschema._canonicalise import HypothesisRefResolutionError
 
 import schemathesis
 from schemathesis.specs.openapi._hypothesis import get_case_strategy, is_valid_header, make_positive_strategy
@@ -208,6 +209,16 @@ def _colliding_remote_schema(testdir):
     testdir.makefile(".json", c='{"d": {"type": "string"}}')
 
 
+def _remote_with_local_schema(testdir):
+    # Remote reference contains a local reference
+    testdir.makefile(".json", bar='{"bar": {"$ref": "#/spam"}, "spam": {"type": "integer"}}')
+
+
+def _remote_with_recursive_local_schema(testdir):
+    # Remote reference contains a local reference
+    testdir.makefile(".json", bar='{"bar": {"$ref": "#/spam"}, "spam": {"items": {"$ref": "#/spam"}}}')
+
+
 def _back_reference_remote_schema(testdir):
     # Remote reference (1) contains a remote reference (2) that points back to 1
     testdir.makefile(".json", bar='{"bar": {"$ref": "spam.json#/spam"}, "baz": {"type": "integer"}}')
@@ -230,6 +241,7 @@ def _scoped_remote_schema(testdir):
     (
         (_remote_schema, lambda v: isinstance(v, int)),
         (_nested_remote_schema, lambda v: isinstance(v, int)),
+        (_remote_with_local_schema, lambda v: isinstance(v, int)),
         (_deep_nested_remote_schema, lambda v: isinstance(v["a"], int)),
         (_colliding_remote_schema, lambda v: isinstance(v["a"], int) and isinstance(v["b"], str)),
         (_back_reference_remote_schema, lambda v: isinstance(v, int)),
@@ -255,3 +267,63 @@ def test_inline_remote_refs(testdir, deeply_nested_schema, setup, check):
 
     # And the original schema is not mutated
     assert json.dumps(deeply_nested_schema, sort_keys=True, ensure_ascii=True) == original
+
+
+@pytest.mark.usefixtures("clear_caches")
+@pytest.mark.parametrize(
+    "setup",
+    (_remote_with_recursive_local_schema,),
+)
+def test_inline_recursive_refs(testdir, deeply_nested_schema, setup):
+    # When a remote schema contains a recursive reference
+    setup(testdir)
+    deeply_nested_schema["components"]["schemas"]["foo6"] = {"$ref": "bar.json#/bar"}
+
+    schema = schemathesis.from_dict(deeply_nested_schema)
+
+    # Then it should not cause internal errors
+    @given(schema["/data"]["GET"].as_strategy())
+    @settings(max_examples=1)
+    def test(_):
+        pass
+
+    # And `hypothesis-jsonschema` should indicate the issue
+    with pytest.raises(HypothesisRefResolutionError):
+        test()
+
+
+def test_inline_xx(testdir, empty_open_api_3_schema):
+    empty_open_api_3_schema["paths"] = {
+        "/data": {
+            "get": {
+                "parameters": [
+                    {
+                        "name": "foo",
+                        "in": "query",
+                        "schema": {"$ref": "./commons.json#/Foo"},
+                    },
+                ],
+                "responses": {
+                    "default": {"description": "OK"},
+                },
+            },
+        }
+    }
+    common = {
+        "Foo": {"$ref": "#/Bar"},
+        "Bar": {"$ref": "#/Foo"},
+    }
+    schema_path = testdir.makefile(".json", root=json.dumps(empty_open_api_3_schema))
+    testdir.makefile(".json", commons=json.dumps(common))
+
+    schema = schemathesis.from_path(schema_path)
+
+    # Then it should not cause internal errors
+    @given(schema["/data"]["GET"].as_strategy())
+    @settings(max_examples=1)
+    def test(_):
+        pass
+
+    # And `hypothesis-jsonschema` should indicate the issue
+    with pytest.raises(HypothesisRefResolutionError):
+        test()