Files
Metamorph/backend/tests/test_schema.py

235 lines
7.6 KiB
Python
Raw Normal View History

2026-05-11 06:05:27 +02:00
"""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_<table>_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})