"""M1 schema integration test. Asserts that the migration has produced the expected tables, FK relations, CHECK constraints, partial indexes, and that the alembic_version row is at head. Skips automatically when Postgres is unreachable. """ from __future__ import annotations import pytest from sqlalchemy import inspect, text EXPECTED_TABLES = { # Auth / RBAC "users", "groups", "permissions", "user_groups", "group_permissions", "invitations", "invitation_groups", "refresh_tokens", # MITRE "mitre_tactics", "mitre_techniques", "mitre_subtechniques", "mitre_technique_tactics", # Templates "test_templates", "test_template_mitre_tags", "scenario_templates", "scenario_template_tests", # Missions "missions", "mission_members", "mission_scenarios", "mission_tests", "mission_test_mitre_tags", "mission_categories", # Evidence / settings / notifications "evidence_files", "settings", "detection_levels", "notifications", # Alembic bookkeeping "alembic_version", } # Tables that MUST carry a `deleted_at` column (soft delete). SOFT_DELETE_TABLES = { "users", "groups", "test_templates", "scenario_templates", "missions", "mission_scenarios", "mission_tests", "mission_categories", "evidence_files", } # Tables that MUST carry the standard `created_at` + `updated_at` pair. TIMESTAMP_TABLES = { "users", "groups", "test_templates", "scenario_templates", "missions", "mission_scenarios", "mission_tests", "mission_categories", "evidence_files", } # Spot-checked FK pairs (child_table, child_col, parent_table). EXPECTED_FKS = { ("evidence_files", "mission_test_id", "mission_tests"), ("evidence_files", "uploaded_by_user_id", "users"), ("mission_members", "mission_id", "missions"), ("mission_members", "user_id", "users"), ("mission_scenarios", "mission_id", "missions"), ("mission_tests", "scenario_id", "mission_scenarios"), ("mission_tests", "detection_level_id", "detection_levels"), ("group_permissions", "group_id", "groups"), ("group_permissions", "permission_id", "permissions"), ("user_groups", "user_id", "users"), ("user_groups", "group_id", "groups"), ("refresh_tokens", "user_id", "users"), ("notifications", "user_id", "users"), ("mitre_subtechniques", "technique_id", "mitre_techniques"), } # CHECK constraint names we expect to see (namespace 'public' only). # `mission_test_mitre_tags` deliberately lacks the exactly_one_mitre_fk check # because it is denormalised — see app/models/mission.py docstring. EXPECTED_CHECKS = { "ck_missions_status_valid", "ck_missions_visibility_mode_valid", "ck_mission_tests_state_valid", "ck_mission_tests_snapshot_opsec_level_valid", "ck_test_templates_opsec_level_valid", "ck_mission_members_role_hint_valid", "ck_test_template_mitre_tags_mitre_kind_valid", "ck_test_template_mitre_tags_exactly_one_mitre_fk", "ck_mission_test_mitre_tags_mitre_kind_valid", } @pytest.fixture(scope="module") def insp(db_engine_or_skip): return inspect(db_engine_or_skip) def test_all_expected_tables_exist(insp): actual = set(insp.get_table_names(schema="public")) missing = EXPECTED_TABLES - actual assert not missing, f"missing tables: {sorted(missing)}" def test_soft_delete_columns_present(insp): for tbl in sorted(SOFT_DELETE_TABLES): cols = {c["name"] for c in insp.get_columns(tbl)} assert "deleted_at" in cols, f"{tbl} missing deleted_at" def test_standard_timestamp_columns_present(insp): for tbl in sorted(TIMESTAMP_TABLES): cols = {c["name"] for c in insp.get_columns(tbl)} assert "created_at" in cols, f"{tbl} missing created_at" assert "updated_at" in cols, f"{tbl} missing updated_at" def test_partial_index_for_soft_delete(db_engine_or_skip): """Each soft-delete table must carry an `ix__active` partial index.""" with db_engine_or_skip.connect() as conn: rows = conn.execute( text( "SELECT indexname FROM pg_indexes " "WHERE schemaname='public' AND indexdef ILIKE '%deleted_at IS NULL%'" ) ).all() names = {r[0] for r in rows} for tbl in SOFT_DELETE_TABLES: assert f"ix_{tbl}_active" in names, f"{tbl}: partial index missing — got {names}" def test_expected_foreign_keys(insp): all_fks = set() for tbl in EXPECTED_TABLES: if tbl == "alembic_version": continue for fk in insp.get_foreign_keys(tbl): for col in fk["constrained_columns"]: all_fks.add((tbl, col, fk["referred_table"])) for triple in EXPECTED_FKS: assert triple in all_fks, f"missing FK: {triple}" def test_expected_check_constraints(db_engine_or_skip): with db_engine_or_skip.connect() as conn: rows = conn.execute( text( "SELECT conname FROM pg_constraint " "WHERE contype='c' AND connamespace = 'public'::regnamespace" ) ).all() names = {r[0] for r in rows} missing = EXPECTED_CHECKS - names assert not missing, f"missing CHECK constraints: {sorted(missing)}" def test_alembic_at_head(db_engine_or_skip): """The DB must be at the latest migration after `make migrate`.""" with db_engine_or_skip.connect() as conn: rev = conn.execute(text("SELECT version_num FROM alembic_version")).scalar_one() assert rev, "alembic_version is empty — migrate didn't run" assert len(rev) >= 8, f"unexpected alembic version: {rev}" def test_exactly_one_mitre_fk_check_enforced(db_engine_or_skip): """Inserting a tag with two non-null FKs must raise (CHECK constraint).""" import uuid from sqlalchemy.exc import IntegrityError with db_engine_or_skip.begin() as conn: # Seed a minimal test_template + tactic + technique to reference. tmpl_id = uuid.uuid4() tactic_id = uuid.uuid4() technique_id = uuid.uuid4() conn.execute( text( "INSERT INTO test_templates (id, name, opsec_level) " "VALUES (:id, 'tmp', 'low')" ), {"id": tmpl_id}, ) conn.execute( text( "INSERT INTO mitre_tactics (id, external_id, short_name, name) " "VALUES (:id, 'TA0099', 'tmp', 'tmp')" ), {"id": tactic_id}, ) conn.execute( text( "INSERT INTO mitre_techniques (id, external_id, name) " "VALUES (:id, 'T9999', 'tmp')" ), {"id": technique_id}, ) # Now try to insert a violating row — must fail. with pytest.raises(IntegrityError): with db_engine_or_skip.begin() as conn: conn.execute( text( "INSERT INTO test_template_mitre_tags " "(id, test_template_id, mitre_kind, tactic_id, technique_id) " "VALUES (:id, :tmpl, 'tactic', :tac, :tech)" ), { "id": uuid.uuid4(), "tmpl": tmpl_id, "tac": tactic_id, "tech": technique_id, }, ) # Cleanup so the test is rerunnable. with db_engine_or_skip.begin() as conn: conn.execute(text("DELETE FROM mitre_techniques WHERE id = :id"), {"id": technique_id}) conn.execute(text("DELETE FROM mitre_tactics WHERE id = :id"), {"id": tactic_id}) conn.execute(text("DELETE FROM test_templates WHERE id = :id"), {"id": tmpl_id})