chore: establish project baseline
This commit is contained in:
@@ -0,0 +1,291 @@
|
||||
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, "<redacted>", "<redacted>", "<redacted>", "<redacted>"]
|
||||
|
||||
|
||||
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)
|
||||
Reference in New Issue
Block a user