import pytest from gti_discovery.catalog import FirebirdCatalog from gti_discovery.firebird_queries import _ADAPTER_REGISTRATION_TOKEN, _register_approved_adapter from gti_discovery.collector import ReadOnlyCatalogReader, _CATALOG_QUERIES, _FIXTURE_REGISTRATION_TOKEN, _approved_fixture_reader, _fetch_rows, _is_optional_unsupported, _safe_value, collect_metadata from gti_discovery.fingerprint import build_fingerprint from gti_discovery.read_only import validate_read_only @_approved_fixture_reader(token=_FIXTURE_REGISTRATION_TOKEN) class CatalogFixture(ReadOnlyCatalogReader): def __init__(self, failures=None, missing=None): self.failures = failures or {} self.missing = missing or set() self.calls = [] def _rows(self, name): self.calls.append(name) if name in self.failures: raise self.failures[name] return [{"NAME": name, "POSITION": 0}] def __getattribute__(self, name): if name.startswith("list_") and name.removeprefix("list_") in object.__getattribute__(self, "missing"): raise AttributeError(name) return object.__getattribute__(self, name) def list_database(self): return self._rows("database") def list_relations(self): return self._rows("relations") def list_relation_fields(self): return self._rows("fields") def list_keys(self): return self._rows("keys") def list_indexes(self): return self._rows("indexes") def list_foreign_keys(self): return self._rows("foreign_keys") def list_triggers(self): return self._rows("triggers") def list_procedures(self): return self._rows("procedures") def list_procedure_parameters(self): return self._rows("procedure_parameters") def list_packages(self): return self._rows("packages") def list_generators(self): return self._rows("generators") def list_dependencies(self): return self._rows("dependencies") def list_monitoring(self): return self._rows("monitoring") def test_optional_catalogs_are_collected_independently(): metadata = collect_metadata(CatalogFixture()) assert metadata["generators"] == [{"NAME": "generators", "POSITION": 0}] assert metadata["dependencies"] == [{"NAME": "dependencies", "POSITION": 0}] assert metadata["monitoring"] == [{"NAME": "monitoring", "POSITION": 0}] def test_optional_unsupported_catalog_is_unavailable_but_permission_is_an_error(): reader = CatalogFixture( { "packages": RuntimeError("Table unknown RDB$PACKAGES"), "generators": RuntimeError("no permission for SELECT"), } ) metadata = collect_metadata(reader) assert "packages" in metadata["unavailable"] assert {error["kind"] for error in metadata["errors"]} == {"optional_unsupported", "query_error"} assert {error["query"] for error in metadata["errors"]} == {"generators", "packages"} @pytest.mark.parametrize("message", [ "permission denied: catalog unsupported", "insufficient privilege; feature not supported", "access denied: table unknown", ]) def test_permission_failures_never_count_as_optional_unsupported(message): assert not _is_optional_unsupported("packages", RuntimeError(message)) @pytest.mark.parametrize("error", [PermissionError(), PermissionError("catalog unsupported")]) def test_permission_error_type_is_never_optional_unsupported(error): assert not _is_optional_unsupported("packages", error) def test_empty_permission_error_is_recorded_as_query_error(): metadata = collect_metadata(CatalogFixture({"packages": PermissionError()})) assert metadata["errors"] == [{"query": "packages", "kind": "query_error", "error": "PermissionError"}] def test_missing_optional_reader_method_does_not_raise_during_collection(): metadata = collect_metadata(CatalogFixture(missing={"packages"})) assert "packages" in metadata["unavailable"] def test_firebird_catalog_implements_closed_interface_explicitly(): class Adapter: read_only = True def execute(self, sql, params): return [] _register_approved_adapter(Adapter, _ADAPTER_REGISTRATION_TOKEN) reader = FirebirdCatalog(Adapter()) assert isinstance(reader, ReadOnlyCatalogReader) assert reader.list_packages() == [] def test_firebird_catalog_exposes_no_arbitrary_sql_method(): """Mission 00D: the closed API must never offer execute/query/raw_execute.""" class Adapter: read_only = True def execute(self, sql, params): return [] _register_approved_adapter(Adapter, _ADAPTER_REGISTRATION_TOKEN) reader = FirebirdCatalog(Adapter()) for forbidden in ("execute", "query", "raw_execute", "execute_statement", "run_sql"): assert not hasattr(reader, forbidden), f"FirebirdCatalog must not expose {forbidden!r}" def test_firebird_catalog_rejects_adapter_without_proven_read_only(): class Unproven: read_only = False def execute(self, sql, params): return [] _register_approved_adapter(Unproven, _ADAPTER_REGISTRATION_TOKEN) with pytest.raises(ValueError): FirebirdCatalog(Unproven()) def test_firebird_catalog_hides_raw_handle_from_attribute_access(): class Adapter: read_only = True def execute(self, sql, params): return [] _register_approved_adapter(Adapter, _ADAPTER_REGISTRATION_TOKEN) reader = FirebirdCatalog(Adapter()) with pytest.raises(AttributeError): reader._handle @pytest.mark.parametrize("failure", [NotImplementedError(), RuntimeError("catalog unsupported")]) def test_explicit_optional_capability_failures_are_unavailable(failure): metadata = collect_metadata(CatalogFixture({"monitoring": failure})) assert "monitoring" in metadata["unavailable"] assert metadata["errors"][0]["kind"] == "optional_unsupported" def test_tuple_row_values_are_sanitized_field_by_field(): class UnknownValue: pass row = ("PRODUCT", 0, b"binary", "password=hidden", "x" * 2048, UnknownValue()) assert _safe_value(row) == ["PRODUCT", 0, "", "", "", ""] def test_db_api_tuple_rows_use_cursor_description_for_metadata_names(): class Cursor: description = [("RELATION_NAME",), ("FIELD_POSITION",)] def fetchall(self): return [("PRODUCT", 0)] assert _fetch_rows(Cursor()) == [{"RELATION_NAME": "PRODUCT", "FIELD_POSITION": 0}] def test_all_catalog_queries_are_select_only_and_source_free(): for query in _CATALOG_QUERIES.values(): validate_read_only(query) assert not any(token in query.upper() for token in ("RDB$TRIGGER_SOURCE", "RDB$PROCEDURE_SOURCE", "RDB$PACKAGE_HEADER_SOURCE", "RDB$PACKAGE_BODY_SOURCE", "RDB$DEFAULT_SOURCE")) def test_missing_optional_reader_is_recorded_as_unavailable(): reader = CatalogFixture(missing={"packages"}) metadata = collect_metadata(reader) assert "packages" in metadata["unavailable"] def test_collect_metadata_rejects_duck_typed_non_reader(): """Mission 00D Phase 9: collect_metadata must not accept an arbitrary object just because it happens to expose the same method names -- ReadOnlyCatalogReader is an ABC and this is a runtime check (isinstance), not merely a type hint a caller could ignore.""" class DuckTyped: def get_database_info(self): return [] def list_relations(self): return [] def list_relation_fields(self): return [] def list_keys(self): return [] def list_indexes(self): return [] def list_foreign_keys(self): return [] def list_triggers(self): return [] def list_procedures(self): return [] def list_procedure_parameters(self): return [] def list_packages(self): return [] with pytest.raises(TypeError, match="reader must be ReadOnlyCatalogReader"): collect_metadata(DuckTyped()) def test_collect_metadata_accepts_real_firebird_catalog(): class Adapter: read_only = True def execute(self, sql, params=()): return [] _register_approved_adapter(Adapter, _ADAPTER_REGISTRATION_TOKEN) metadata = collect_metadata(FirebirdCatalog(Adapter())) assert metadata["database"] == [] def test_collection_row_order_is_deterministic_for_reversed_reader_rows(): @_approved_fixture_reader(token=_FIXTURE_REGISTRATION_TOKEN) class ReorderedReader(ReadOnlyCatalogReader): def __init__(self, reverse): self.reverse = reverse def _rows(self, name): rows = { "database": [{"MON$DATABASE_NAME": "DB", "MON$ODS_MAJOR": 13}], "relations": [{"RELATION_NAME": "B", "RELATION_ID": 2}, {"RELATION_NAME": "A", "RELATION_ID": 1}], "fields": [ {"RELATION_NAME": "B", "FIELD_NAME": "Z", "FIELD_POSITION": 1, "TYPE_NAME": "INTEGER"}, {"RELATION_NAME": "A", "FIELD_NAME": "ID", "FIELD_POSITION": 0, "TYPE_NAME": "INTEGER"}, ], "keys": [{"RELATION_NAME": "B", "CONSTRAINT_TYPE": "PRIMARY KEY", "FIELD_NAME": "ID"}], "indexes": [{"RELATION_NAME": "A", "INDEX_NAME": "IX_A", "FIELD_NAME": "ID"}], "foreign_keys": [{"CHILD_RELATION_NAME": "B", "REFERENCED_RELATION_NAME": "A", "CHILD_FIELD_NAME": "A_ID"}], "triggers": [{"TRIGGER_NAME": "TR_B", "RELATION_NAME": "B", "TRIGGER_TYPE": 1}], "procedures": [{"PROCEDURE_NAME": "P_B", "PROCEDURE_ID": 2}, {"PROCEDURE_NAME": "P_A", "PROCEDURE_ID": 1}], "procedure_parameters": [{"PROCEDURE_NAME": "P_B", "PARAMETER_NUMBER": 1}], "packages": [{"PACKAGE_NAME": "PKG_B", "PACKAGE_ID": 2}], "generators": [{"GENERATOR_NAME": "GEN_B", "GENERATOR_ID": 2}], "dependencies": [{"DEPENDENT_NAME": "P_B", "DEPENDED_ON_NAME": "B"}], "monitoring": [(2, "running"), (1, "idle")], }[name] return list(reversed(rows)) if self.reverse else rows def list_database(self): return self._rows("database") def list_relations(self): return self._rows("relations") def list_relation_fields(self): return self._rows("fields") def list_keys(self): return self._rows("keys") def list_indexes(self): return self._rows("indexes") def list_foreign_keys(self): return self._rows("foreign_keys") def list_triggers(self): return self._rows("triggers") def list_procedures(self): return self._rows("procedures") def list_procedure_parameters(self): return self._rows("procedure_parameters") def list_packages(self): return self._rows("packages") def list_generators(self): return self._rows("generators") def list_dependencies(self): return self._rows("dependencies") def list_monitoring(self): return self._rows("monitoring") assert collect_metadata(ReorderedReader(False)) == collect_metadata(ReorderedReader(True)) def test_collect_metadata_rejects_structured_operation_overrides(): class OverriddenReader(ReadOnlyCatalogReader): def get_database_info(self): return [] with pytest.raises(TypeError, match="sealed structured operations"): collect_metadata(OverriddenReader()) def test_approved_fixture_registration_does_not_authorize_subclasses(): class DerivedFixture(CatalogFixture): pass with pytest.raises(TypeError, match="sealed structured operations"): collect_metadata(DerivedFixture()) def test_catalog_query_map_is_not_exported_from_collector(): import gti_discovery.collector as collector assert not hasattr(collector, "METADATA_QUERIES") def test_engine_context_changes_fingerprint(): base = {"database": [{"ODS_MAJOR": 12}], "engine": {"dialect": 3}} changed = {"database": [{"ODS_MAJOR": 12}], "engine": {"dialect": 1}} assert build_fingerprint(base) != build_fingerprint(changed)