from __future__ import annotations import json import os import unittest from uuid import uuid4 from sqlalchemy import create_engine, event, inspect, text from sqlalchemy.engine import Connection, Engine from sqlalchemy.exc import DBAPIError from sqlalchemy.orm import Session from sqlalchemy.schema import CreateSchema, DropSchema from govoplan_core.core.access import CAPABILITY_AUDIT_RECORDER from govoplan_core.core.modules import MigrationRetirementPlan, ModuleContext from govoplan_core.core.runtime import clear_runtime, configure_runtime, get_runtime_context from govoplan_core.db.base import Base _CREDENTIAL_ID = "retirement-credential" _TENANT_ID = "retirement-tenant" _PRIVATE_FIXTURE_VALUES = ( "opaque-password-ciphertext", "opaque-token-ciphertext", "vault:test-only:credential", "private-test-metadata", ) _ROW_COUNT_QUERIES = { "audit_log": text("SELECT count(*) FROM audit_log"), "core_change_sequence": text("SELECT count(*) FROM core_change_sequence"), "retirement_scrub_probe": text("SELECT count(*) FROM retirement_scrub_probe"), } def _postgres_database_url() -> str: candidate = ( os.environ.get("GOVOPLAN_POSTGRES_DATABASE_URL") or os.environ.get("DATABASE_URL") or "" ).strip() return candidate if candidate.startswith("postgresql") else "" def _proof_is_required() -> bool: return os.environ.get("GOVOPLAN_REQUIRE_POSTGRES_RETIREMENT_PROOF", "").strip().casefold() in { "1", "true", "yes", "on", } class _AuditCapabilityRegistry: def __init__(self, recorder: object) -> None: self._recorder = recorder def has_capability(self, name: str) -> bool: return name == CAPABILITY_AUDIT_RECORDER def require_capability(self, name: str) -> object: if not self.has_capability(name): raise LookupError(name) return self._recorder class PostgreSQLRetirementAtomicityTests(unittest.TestCase): """Production-reference proof for destructive module retirement. Every test owns a random PostgreSQL schema and removes it afterward. The fixture stores only synthetic credential material. Assertions and audit inspection expose booleans and allow-listed metadata, never stored values or the database URL. """ def setUp(self) -> None: database_url = _postgres_database_url() if not database_url: if _proof_is_required(): self.fail("the required PostgreSQL retirement proof has no PostgreSQL database") self.skipTest("a PostgreSQL integration database is not configured") try: from govoplan_access.backend.db import models as access_models # noqa: F401 from govoplan_audit.backend.db.models import AuditLog from govoplan_audit.backend.recording import SqlAuditRecorder from govoplan_files.backend.db.models import FileConnectorCredential, FileConnectorProfile from govoplan_files.backend.manifest import manifest as files_manifest except ModuleNotFoundError as exc: if _proof_is_required(): self.fail(f"the required PostgreSQL retirement proof is missing package: {exc.name}") self.skipTest(f"the full-stack integration packages are not installed: {exc.name}") self.audit_log_model = AuditLog self.credential_model = FileConnectorCredential self.profile_model = FileConnectorProfile self.files_manifest = files_manifest self.previous_runtime = get_runtime_context() self.admin_engine: Engine | None = None self.engine: Engine | None = None self.schema_name = f"govoplan_retirement_{uuid4().hex}" self.capture_retirement_ddl = False self.retirement_ddl_backend_pids: list[int] = [] self.ddl_listener_registered = False try: self.admin_engine = create_engine(database_url, pool_pre_ping=True) with self.admin_engine.begin() as connection: if connection.dialect.name != "postgresql": self.skipTest("the configured integration database is not PostgreSQL") connection.execute(CreateSchema(self.schema_name)) self.engine = create_engine( database_url, pool_pre_ping=True, pool_size=5, max_overflow=0, connect_args={ "options": ( f"-csearch_path={self.schema_name},pg_catalog " "-clock_timeout=500ms -cstatement_timeout=10s " "-capplication_name=govoplan-retirement-atomicity" ), }, ) event.listen(self.engine, "before_cursor_execute", self._capture_ddl_connection) self.ddl_listener_registered = True self._create_fixture_schema() self._seed_credential() recorder = SqlAuditRecorder() configure_runtime(ModuleContext(registry=_AuditCapabilityRegistry(recorder), settings=object())) except Exception: self._cleanup() raise def tearDown(self) -> None: self._cleanup() def test_scrub_audit_and_table_retirement_commit_together(self) -> None: assert self.engine is not None with Session(self.engine) as session: installer_backend_pid = self._session_backend_pid(session) plan = self._retirement_plan(session) self._execute_retirement(session, plan) session.commit() self.assertTrue(self.retirement_ddl_backend_pids, "the retirement provider emitted no DROP TABLE statement") self.assertEqual( {installer_backend_pid}, set(self.retirement_ddl_backend_pids), "destructive DDL did not use the installer Session connection", ) with self.engine.connect() as connection: self.assertFalse(inspect(connection).has_table(self.credential_model.__tablename__)) self.assertFalse(inspect(connection).has_table(self.profile_model.__tablename__)) probe = connection.execute(text( "SELECT password_removed, token_removed, username_removed, " "secret_reference_removed, metadata_removed " "FROM retirement_scrub_probe" )).mappings().one() self.assertTrue(all(probe.values()), "the credential scrub probe did not observe every removal") audit_rows = connection.execute(text( "SELECT action, object_type, object_id, details " "FROM audit_log ORDER BY created_at, id" )).mappings().all() self.assertEqual(1, len(audit_rows)) audit_row = audit_rows[0] self.assertEqual("files.connector_credential_deleted", audit_row["action"]) self.assertEqual("file_connector_credential", audit_row["object_type"]) self.assertEqual(_CREDENTIAL_ID, audit_row["object_id"]) self._assert_non_secret_audit_details(dict(audit_row["details"] or {})) self.assertEqual(1, self._row_count(connection, "core_change_sequence")) def test_database_audit_failure_rolls_back_scrub_and_audit_state(self) -> None: assert self.engine is not None with self.engine.begin() as connection: connection.exec_driver_sql( """ CREATE FUNCTION reject_retirement_audit() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.action = 'files.connector_credential_deleted' THEN RAISE EXCEPTION 'injected retirement audit failure'; END IF; RETURN NEW; END; $$ """ ) connection.exec_driver_sql( """ CREATE TRIGGER reject_retirement_audit_insert BEFORE INSERT ON audit_log FOR EACH ROW EXECUTE FUNCTION reject_retirement_audit() """ ) with Session(self.engine) as session: self._session_backend_pid(session) plan = self._retirement_plan(session) with self.assertRaises(DBAPIError) as caught: self._execute_retirement(session, plan) self.assertEqual("P0001", getattr(caught.exception.orig, "sqlstate", None)) session.rollback() self.assertEqual([], self.retirement_ddl_backend_pids, "DDL ran after the injected audit failure") self._assert_failed_retirement_rolled_back() def test_database_ddl_failure_rolls_back_scrub_audit_and_prior_drop(self) -> None: assert self.engine is not None with self.engine.begin() as connection: connection.exec_driver_sql( """ CREATE TABLE retirement_drop_guard ( id bigint PRIMARY KEY, credential_id varchar(255) NOT NULL REFERENCES file_connector_credentials(id) ) """ ) connection.execute( text("INSERT INTO retirement_drop_guard (id, credential_id) VALUES (1, :credential_id)"), {"credential_id": _CREDENTIAL_ID}, ) with Session(self.engine) as session: installer_backend_pid = self._session_backend_pid(session) plan = self._retirement_plan(session) with self.assertRaises(DBAPIError) as caught: self._execute_retirement(session, plan) self.assertEqual("2BP01", getattr(caught.exception.orig, "sqlstate", None)) session.rollback() self.assertTrue(self.retirement_ddl_backend_pids, "the injected DDL failure was not reached") self.assertEqual( {installer_backend_pid}, set(self.retirement_ddl_backend_pids), "destructive DDL did not use the installer Session connection", ) self._assert_failed_retirement_rolled_back() def _create_fixture_schema(self) -> None: assert self.engine is not None from govoplan_core.core.change_sequence import ChangeSequenceEntry with self.engine.begin() as connection: # Audit and Files reference Access actors. The proof does not need # actor rows, so small FK targets keep its schema bounded. connection.exec_driver_sql("CREATE TABLE access_users (id varchar(36) PRIMARY KEY)") connection.exec_driver_sql("CREATE TABLE access_api_keys (id varchar(36) PRIMARY KEY)") Base.metadata.create_all( bind=connection, tables=[ ChangeSequenceEntry.__table__, self.audit_log_model.__table__, self.credential_model.__table__, self.profile_model.__table__, ], ) connection.exec_driver_sql( """ CREATE TABLE retirement_scrub_probe ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, password_removed boolean NOT NULL, token_removed boolean NOT NULL, username_removed boolean NOT NULL, secret_reference_removed boolean NOT NULL, metadata_removed boolean NOT NULL ) """ ) connection.exec_driver_sql( """ CREATE FUNCTION record_retirement_scrub() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN INSERT INTO retirement_scrub_probe ( password_removed, token_removed, username_removed, secret_reference_removed, metadata_removed ) VALUES ( OLD.password_encrypted IS NOT NULL AND NEW.password_encrypted IS NULL, OLD.token_encrypted IS NOT NULL AND NEW.token_encrypted IS NULL, OLD.username IS NOT NULL AND NEW.username IS NULL, OLD.secret_ref IS NOT NULL AND NEW.secret_ref IS NULL, OLD.metadata IS NOT NULL AND NEW.metadata::jsonb = '{}'::jsonb ); RETURN NEW; END; $$ """ ) connection.exec_driver_sql( """ CREATE TRIGGER record_retirement_credential_scrub BEFORE UPDATE ON file_connector_credentials FOR EACH ROW EXECUTE FUNCTION record_retirement_scrub() """ ) def _seed_credential(self) -> None: assert self.engine is not None with Session(self.engine) as session: session.add(self.credential_model( id=_CREDENTIAL_ID, tenant_id=_TENANT_ID, scope_type="tenant", scope_id=_TENANT_ID, label="Retirement integration credential", provider="webdav", enabled=True, credential_mode="basic", username="integration-user", password_encrypted=_PRIVATE_FIXTURE_VALUES[0], token_encrypted=_PRIVATE_FIXTURE_VALUES[1], password_env="INTEGRATION_PASSWORD", token_env="INTEGRATION_TOKEN", secret_ref=_PRIVATE_FIXTURE_VALUES[2], policy={}, metadata_={"private_hint": _PRIVATE_FIXTURE_VALUES[3]}, )) session.commit() def _retirement_plan(self, session: Session) -> MigrationRetirementPlan: migration = self.files_manifest.migration_spec self.assertIsNotNone(migration) assert migration is not None self.assertIsNotNone(migration.retirement_provider) assert migration.retirement_provider is not None plan = migration.retirement_provider(session, "files") self.assertTrue(plan.destroy_data_supported) self.assertIsNotNone(plan.destroy_data_executor) return plan def _execute_retirement(self, session: Session, plan: MigrationRetirementPlan) -> None: executor = plan.destroy_data_executor self.assertIsNotNone(executor) self.capture_retirement_ddl = True try: executor(session, "files") finally: self.capture_retirement_ddl = False def _capture_ddl_connection( self, connection: Connection, _cursor: object, statement: str, _parameters: object, _context: object, _executemany: bool, ) -> None: if not self.capture_retirement_ddl or not statement.lstrip().upper().startswith("DROP TABLE"): return self.retirement_ddl_backend_pids.append(self._connection_backend_pid(connection)) def _session_backend_pid(self, session: Session) -> int: connection = session.connection() sql_pid = int(connection.execute(text("SELECT pg_backend_pid()")).scalar_one()) driver_pid = self._connection_backend_pid(connection) self.assertEqual(sql_pid, driver_pid) return sql_pid @staticmethod def _connection_backend_pid(connection: Connection) -> int: driver_connection = connection.connection.driver_connection return int(driver_connection.info.backend_pid) def _assert_failed_retirement_rolled_back(self) -> None: assert self.engine is not None with self.engine.connect() as connection: inspector = inspect(connection) self.assertTrue(inspector.has_table(self.credential_model.__tablename__)) self.assertTrue(inspector.has_table(self.profile_model.__tablename__)) state = connection.execute( text( "SELECT enabled, password_encrypted IS NOT NULL AS has_password, " "token_encrypted IS NOT NULL AS has_token, username IS NOT NULL AS has_username, " "secret_ref IS NOT NULL AS has_secret_reference, metadata::jsonb <> '{}'::jsonb AS has_metadata " "FROM file_connector_credentials WHERE id = :credential_id" ), {"credential_id": _CREDENTIAL_ID}, ).mappings().one() self.assertTrue(all(state.values()), "credential state was not fully restored") self.assertEqual(0, self._row_count(connection, "retirement_scrub_probe")) self.assertEqual(0, self._row_count(connection, "audit_log")) self.assertEqual(0, self._row_count(connection, "core_change_sequence")) def _assert_non_secret_audit_details(self, details: dict[str, object]) -> None: self.assertEqual("module_data_retired", details.get("deletion_reason")) self.assertEqual("unowned_external_reference_detached", details.get("storage_backend")) self.assertEqual("", details.get("deleted_secret_kinds")) self.assertEqual( ["password_env", "token_env", "unowned_external_secret_ref"], details.get("removed_reference_kinds"), ) self.assertTrue(details.get("removed_metadata")) serialized = json.dumps(details, sort_keys=True) self.assertFalse( any(private_value in serialized for private_value in _PRIVATE_FIXTURE_VALUES), "audit diagnostics contain fixture-private material", ) @staticmethod def _row_count(connection: Connection, table_name: str) -> int: query = _ROW_COUNT_QUERIES.get(table_name) if query is None: raise ValueError("unsupported retirement proof table") return int(connection.execute(query).scalar_one()) def _cleanup(self) -> None: if getattr(self, "previous_runtime", None) is None: clear_runtime() else: configure_runtime(self.previous_runtime) if self.engine is not None: if self.ddl_listener_registered: event.remove(self.engine, "before_cursor_execute", self._capture_ddl_connection) self.engine.dispose() self.engine = None if self.admin_engine is not None: try: with self.admin_engine.begin() as connection: connection.execute(DropSchema(self.schema_name, cascade=True, if_exists=True)) finally: self.admin_engine.dispose() self.admin_engine = None if __name__ == "__main__": unittest.main()