Milestone 3
This commit is contained in:
234
backend/tests/test_schema.py
Normal file
234
backend/tests/test_schema.py
Normal file
@@ -0,0 +1,234 @@
|
||||
"""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})
|
||||
Reference in New Issue
Block a user