git-subtree-dir: fiscal git-subtree-mainline:d95165fc8fgit-subtree-split:102ba84500
292 lines
12 KiB
Python
292 lines
12 KiB
Python
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)
|