"""M7 — per-test execution, evidence upload, activity polling. Fixture stack mirrors `test_missions.py` so we can reuse the test_template/ scenario_template catalogue and the red/blue/reader user invitations. M7 adds the assumption that detection_levels are seeded (boot does this for the live API; we re-seed inside the module fixture to cover the truncated state). """ from __future__ import annotations import hashlib import io import json import secrets import urllib.parse import uuid from datetime import datetime, timedelta, timezone import pytest from sqlalchemy import text from app.core.install_token import regenerate_install_token from app.main import create_app from app.services import detection_levels as detection_svc from app.services import mitre_seed as mitre_svc _MINIMAL_BUNDLE = { "type": "bundle", "id": "bundle--00000000-0000-0000-0000-000000000007", "spec_version": "2.1", "objects": [ { "type": "x-mitre-tactic", "id": "x-mitre-tactic--ta0002", "name": "Execution", "x_mitre_shortname": "execution", "external_references": [ {"source_name": "mitre-attack", "external_id": "TA0002"} ], }, { "type": "attack-pattern", "id": "attack-pattern--t1059", "name": "Command and Scripting Interpreter", "kill_chain_phases": [ {"kill_chain_name": "mitre-attack", "phase_name": "execution"} ], "external_references": [ {"source_name": "mitre-attack", "external_id": "T1059"} ], }, ], } def _truncate_all(engine): with engine.begin() as conn: conn.execute( text( "TRUNCATE mission_test_mitre_tags, mission_tests, " "mission_scenarios, mission_categories, mission_members, " "missions RESTART IDENTITY CASCADE" ) ) conn.execute( text( "TRUNCATE scenario_template_tests, scenario_templates, " "test_template_mitre_tags, test_templates " "RESTART IDENTITY CASCADE" ) ) conn.execute( text( "TRUNCATE users, refresh_tokens, invitations, invitation_groups, " "user_groups, group_permissions, permissions, settings, groups " "RESTART IDENTITY CASCADE" ) ) conn.execute( text( "TRUNCATE mitre_technique_tactics, mitre_subtechniques, " "mitre_techniques, mitre_tactics RESTART IDENTITY CASCADE" ) ) @pytest.fixture(scope="module") def app(db_engine_or_skip, tmp_path_factory, monkeypatch_module): _truncate_all(db_engine_or_skip) # Re-seed catalogues that boot/seed handles in production but `_truncate_all` # has just wiped. bundle_path = tmp_path_factory.mktemp("m7") / "stix.json" bundle_path.write_text(json.dumps(_MINIMAL_BUNDLE)) mitre_svc.seed_mitre(source=bundle_path, expected_sha256=None) detection_svc.seed_detection_levels() # Point the evidence dir at a tmp location so test uploads don't pollute /data. evidence_root = tmp_path_factory.mktemp("evidence") monkeypatch_module.setattr( "app.core.config.settings.EVIDENCE_DIR", str(evidence_root) ) flask_app = create_app() flask_app.config.update(TESTING=True) flask_app.config["EVIDENCE_ROOT"] = str(evidence_root) return flask_app @pytest.fixture(scope="module") def monkeypatch_module(): """Module-scoped monkeypatch — pytest's built-in is function-scoped only.""" from _pytest.monkeypatch import MonkeyPatch # noqa: PLC0415 mp = MonkeyPatch() yield mp mp.undo() @pytest.fixture() def client(app): return app.test_client() def _unique_email(prefix: str) -> str: return f"{prefix}-{secrets.token_hex(4)}@metamorph.local" def _bearer(token: str) -> dict[str, str]: return {"Authorization": f"Bearer {token}"} def _login(client, email: str, password: str) -> str: r = client.post("/api/v1/auth/login", json={"email": email, "password": password}) assert r.status_code == 200, r.get_data(as_text=True) return r.get_json()["access_token"] @pytest.fixture(scope="module") def admin(app): token = regenerate_install_token() email = _unique_email("admin") password = "AdminPass1234!" with app.test_client() as c: r = c.post( "/api/v1/setup", json={"install_token": token, "email": email, "password": password}, ) assert r.status_code == 201, r.get_data(as_text=True) return {"email": email, "password": password} @pytest.fixture() def admin_token(client, admin) -> str: return _login(client, admin["email"], admin["password"]) # --------------------------------------------------------------- catalogue -- def _make_test_template(client, admin_token: str, name: str): body = { "name": name, "description": "auto", "objective": "do thing", "procedure_md": f"# {name}", "expected_result_red_md": "red expectation", "expected_detection_blue_md": "blue expectation", "opsec_level": "medium", "tags": [], "expected_iocs": [], "mitre_tags": [{"kind": "technique", "external_id": "T1059"}], } r = client.post("/api/v1/test-templates", headers=_bearer(admin_token), json=body) assert r.status_code == 201, r.get_data(as_text=True) return r.get_json() def _make_scenario(client, admin_token: str, name: str, test_ids: list[str]): r = client.post( "/api/v1/scenario-templates", headers=_bearer(admin_token), json={"name": name, "description": None, "test_template_ids": test_ids}, ) assert r.status_code == 201, r.get_data(as_text=True) return r.get_json() @pytest.fixture(scope="module") def catalogue(app, admin): with app.test_client() as c: tok = _login(c, admin["email"], admin["password"]) t1 = _make_test_template(c, tok, "exec-test") sc = _make_scenario(c, tok, "exec-scenario", [t1["id"]]) return {"test": t1, "scenario": sc} # ----------------------------------------------------------------- users -- def _invite_user(client, admin_token: str, prefix: str, group_codes: list[str]) -> dict: grp = client.post( "/api/v1/groups", headers=_bearer(admin_token), json={"name": f"{prefix}-grp-{secrets.token_hex(2)}"}, ).get_json() r_set = client.put( f"/api/v1/groups/{grp['id']}/permissions", headers=_bearer(admin_token), json={"codes": group_codes}, ) assert r_set.status_code == 200, r_set.get_data(as_text=True) email = _unique_email(prefix) password = "Pass1234!" inv = client.post( "/api/v1/invitations", headers=_bearer(admin_token), json={"email_hint": email, "group_ids": [grp["id"]]}, ) assert inv.status_code == 201, inv.get_data(as_text=True) accept_token = inv.get_json()["token"] r = client.post( f"/api/v1/invitations/accept/{accept_token}", json={"email": email, "password": password}, ) assert r.status_code == 201, r.get_data(as_text=True) tok = _login(client, email, password) me = client.get("/api/v1/auth/me", headers=_bearer(tok)).get_json() return {"email": email, "password": password, "token": tok, "id": me["id"]} @pytest.fixture() def red_user(client, admin_token): return _invite_user( client, admin_token, "red", [ "mission.read", "mission.create", "mission.update", "mission.write_red_fields", "detection_level.read", ], ) @pytest.fixture() def blue_user(client, admin_token): return _invite_user( client, admin_token, "blue", ["mission.read", "mission.write_blue_fields", "detection_level.read"], ) @pytest.fixture() def reader_user(client, admin_token): return _invite_user(client, admin_token, "reader", ["mission.read"]) # Helper: bootstrap a mission with red+blue assigned and snapshot the catalogue. def _make_mission(client, admin_token: str, *, name: str, scenario_id: str, red_id: str | None = None, blue_id: str | None = None) -> dict: members = [] if red_id: members.append({"user_id": red_id, "role_hint": "red"}) if blue_id: members.append({"user_id": blue_id, "role_hint": "blue"}) r = client.post( "/api/v1/missions", headers=_bearer(admin_token), json={ "name": name, "client_target": "Acme", "scenario_template_ids": [scenario_id], "members": members, }, ) assert r.status_code == 201, r.get_data(as_text=True) return r.get_json() def _first_test_id(mission: dict) -> str: return mission["scenarios"][0]["tests"][0]["id"] # ================================================================ detection == def test_detection_levels_seeded_and_listed(client, admin_token): r = client.get("/api/v1/detection-levels", headers=_bearer(admin_token)) assert r.status_code == 200 body = r.get_json() keys = [it["key"] for it in body["items"]] # All four defaults must be present, in position order. assert keys == ["detected_blocked", "detected_alert", "logged_only", "not_detected"] # The default flag is on `not_detected` per the seed. defaults = [it for it in body["items"] if it["is_default"]] assert [d["key"] for d in defaults] == ["not_detected"] # All are flagged system so M8 CRUD can distinguish operator-added levels. assert all(it["is_system"] for it in body["items"]) def test_detection_levels_requires_perm(client, admin_token, reader_user): # The reader_user fixture has mission.read only — no detection_level.read. r = client.get( "/api/v1/detection-levels", headers=_bearer(reader_user["token"]) ) assert r.status_code == 403 # ===================================================================== test == def test_get_mission_test_returns_snapshot_state( client, admin_token, catalogue, red_user ): mission = _make_mission( client, admin_token, name="m7-get", scenario_id=catalogue["scenario"]["id"], red_id=red_user["id"], ) test_id = _first_test_id(mission) r = client.get( f"/api/v1/missions/{mission['id']}/tests/{test_id}", headers=_bearer(red_user["token"]), ) assert r.status_code == 200, r.get_data(as_text=True) body = r.get_json() assert body["state"] == "pending" assert body["red_command"] is None assert body["blue_comment_md"] is None assert body["evidence"] == [] assert body["mission_id"] == mission["id"] def test_red_user_writes_red_fields(client, admin_token, catalogue, red_user): mission = _make_mission( client, admin_token, name="m7-red-write", scenario_id=catalogue["scenario"]["id"], red_id=red_user["id"], ) tid = _first_test_id(mission) r = client.put( f"/api/v1/missions/{mission['id']}/tests/{tid}", headers=_bearer(red_user["token"]), json={ "red_command": "powershell -enc ZAB1AG0AeQA=", "red_output": "{stdout}", "red_comment_md": "executed via SYSTEM", }, ) assert r.status_code == 200, r.get_data(as_text=True) body = r.get_json() assert body["red_command"] == "powershell -enc ZAB1AG0AeQA=" assert body["red_comment_md"] == "executed via SYSTEM" assert body["last_actor_email"] == red_user["email"] def test_red_user_cannot_write_blue_fields(client, admin_token, catalogue, red_user): mission = _make_mission( client, admin_token, name="m7-red-blocked-blue", scenario_id=catalogue["scenario"]["id"], red_id=red_user["id"], ) tid = _first_test_id(mission) r = client.put( f"/api/v1/missions/{mission['id']}/tests/{tid}", headers=_bearer(red_user["token"]), json={"blue_comment_md": "should be blocked"}, ) assert r.status_code == 403, r.get_data(as_text=True) def test_blue_user_cannot_write_red_fields(client, admin_token, catalogue, blue_user): mission = _make_mission( client, admin_token, name="m7-blue-blocked-red", scenario_id=catalogue["scenario"]["id"], blue_id=blue_user["id"], ) tid = _first_test_id(mission) r = client.put( f"/api/v1/missions/{mission['id']}/tests/{tid}", headers=_bearer(blue_user["token"]), json={"red_command": "echo nope"}, ) assert r.status_code == 403 def test_blue_user_writes_new_blue_review_fields( client, admin_token, catalogue, blue_user ): """Post-M7 feedback fields: log source, SIEM excerpt, cyber-incident sub-record. All are blue-side, gated by `mission.write_blue_fields`.""" mission = _make_mission( client, admin_token, name="m7-blue-extras", scenario_id=catalogue["scenario"]["id"], blue_id=blue_user["id"], ) tid = _first_test_id(mission) body = { "blue_log_source": "EDR · CrowdStrike Falcon", "blue_siem_logs": "2026-05-15 10:30:42 WIN-DC01 evt=4688 cmd=powershell -enc ...", "blue_incident_at": "2026-05-15T11:00:00+00:00", "blue_incident_number": "INC-2026-1234", "blue_incident_recipient_email": "soc-night@metamorph.local", } r = client.put( f"/api/v1/missions/{mission['id']}/tests/{tid}", headers=_bearer(blue_user["token"]), json=body, ) assert r.status_code == 200, r.get_data(as_text=True) out = r.get_json() assert out["blue_log_source"] == body["blue_log_source"] assert out["blue_siem_logs"] == body["blue_siem_logs"] assert out["blue_incident_at"].startswith("2026-05-15T11:00:00") assert out["blue_incident_number"] == body["blue_incident_number"] assert out["blue_incident_recipient_email"] == body["blue_incident_recipient_email"] def test_red_user_cannot_write_new_blue_review_fields( client, admin_token, catalogue, red_user ): """Each of the five new fields is classified as blue-side; a red-only caller must receive 403 individually for each one.""" mission = _make_mission( client, admin_token, name="m7-blue-extras-perm", scenario_id=catalogue["scenario"]["id"], red_id=red_user["id"], ) tid = _first_test_id(mission) bad_bodies = [ {"blue_log_source": "Firewall"}, {"blue_siem_logs": "log line"}, {"blue_incident_at": "2026-05-15T11:00:00+00:00"}, {"blue_incident_number": "INC-1"}, {"blue_incident_recipient_email": "x@y.test"}, ] for body in bad_bodies: r = client.put( f"/api/v1/missions/{mission['id']}/tests/{tid}", headers=_bearer(red_user["token"]), json=body, ) assert r.status_code == 403, (body, r.get_data(as_text=True)) def test_blue_incident_at_rejects_naive_datetime( client, admin_token, catalogue, blue_user ): """A naïve datetime (no TZ offset) is rejected with 400 — Postgres would otherwise interpret it in the session TZ, defeating the M7 verbatim-time contract. Same rule applies to executed_at (covered by a separate red test below).""" mission = _make_mission( client, admin_token, name="m7-naive-incident", scenario_id=catalogue["scenario"]["id"], blue_id=blue_user["id"], ) tid = _first_test_id(mission) r = client.put( f"/api/v1/missions/{mission['id']}/tests/{tid}", headers=_bearer(blue_user["token"]), json={"blue_incident_at": "2026-05-15T11:00:00"}, # no offset ) assert r.status_code == 400, r.get_data(as_text=True) def test_blue_incident_recipient_email_validates_shape( client, admin_token, catalogue, blue_user ): """Bad-shape email is rejected; well-formed internal address (`.local`, `.corp`) is accepted — we deliberately don't use Pydantic EmailStr because email-validator rejects internal TLDs.""" mission = _make_mission( client, admin_token, name="m7-email-shape", scenario_id=catalogue["scenario"]["id"], blue_id=blue_user["id"], ) tid = _first_test_id(mission) bad = client.put( f"/api/v1/missions/{mission['id']}/tests/{tid}", headers=_bearer(blue_user["token"]), json={"blue_incident_recipient_email": "not-an-email"}, ) assert bad.status_code == 400 ok = client.put( f"/api/v1/missions/{mission['id']}/tests/{tid}", headers=_bearer(blue_user["token"]), json={"blue_incident_recipient_email": "soc@internal.local"}, ) assert ok.status_code == 200 assert ok.get_json()["blue_incident_recipient_email"] == "soc@internal.local" def test_blue_review_fields_survive_round_trip_via_get( client, admin_token, catalogue, blue_user ): """After a PUT the same values must come back on a fresh GET — guards against a future serializer drift that would silently drop one of the new columns from the response.""" mission = _make_mission( client, admin_token, name="m7-blue-extras-rt", scenario_id=catalogue["scenario"]["id"], blue_id=blue_user["id"], ) tid = _first_test_id(mission) body = { "blue_log_source": "Proxy", "blue_siem_logs": "raw\n indented\nthird", "blue_incident_number": "INC-rt", } put_r = client.put( f"/api/v1/missions/{mission['id']}/tests/{tid}", headers=_bearer(blue_user["token"]), json=body, ) assert put_r.status_code == 200 get_r = client.get( f"/api/v1/missions/{mission['id']}/tests/{tid}", headers=_bearer(blue_user["token"]), ) assert get_r.status_code == 200 after = get_r.get_json() for k, v in body.items(): assert after[k] == v, k def test_blue_user_writes_blue_fields_and_picks_detection_level( client, admin_token, catalogue, blue_user ): mission = _make_mission( client, admin_token, name="m7-blue-write", scenario_id=catalogue["scenario"]["id"], blue_id=blue_user["id"], ) tid = _first_test_id(mission) # First fetch the detection levels. levels = client.get( "/api/v1/detection-levels", headers=_bearer(blue_user["token"]) ).get_json()["items"] not_detected = next(l for l in levels if l["key"] == "not_detected") r = client.put( f"/api/v1/missions/{mission['id']}/tests/{tid}", headers=_bearer(blue_user["token"]), json={ "blue_comment_md": "no detection on SOC", "detection_level_id": not_detected["id"], }, ) assert r.status_code == 200, r.get_data(as_text=True) body = r.get_json() assert body["blue_comment_md"] == "no detection on SOC" assert body["detection_level_id"] == not_detected["id"] assert body["detection_level_key"] == "not_detected" def test_mark_executed_stamps_executed_at( client, admin_token, catalogue, red_user ): mission = _make_mission( client, admin_token, name="m7-exec", scenario_id=catalogue["scenario"]["id"], red_id=red_user["id"], ) tid = _first_test_id(mission) r = client.post( f"/api/v1/missions/{mission['id']}/tests/{tid}/transition", headers=_bearer(red_user["token"]), json={"target_state": "executed"}, ) assert r.status_code == 200, r.get_data(as_text=True) body = r.get_json() assert body["state"] == "executed" assert body["executed_at"] is not None assert body["executed_at_overridden"] is False def test_red_setting_executed_at_on_pending_auto_transitions_to_executed( client, admin_token, catalogue, red_user, blue_user ): """Post-amendement (2026-05-15): the red team should be able to stamp executed_at inline without first POST-ing /transition. The service auto-flips state pending→executed in the same write so the persisted row stays internally consistent. Blue still cannot touch executed_at (red-side field) — perm gating is unchanged. """ mission = _make_mission( client, admin_token, name="m7-exec-at-pending", scenario_id=catalogue["scenario"]["id"], red_id=red_user["id"], blue_id=blue_user["id"], ) tid = _first_test_id(mission) # Sanity: starts as pending. detail = client.get( f"/api/v1/missions/{mission['id']}/tests/{tid}", headers=_bearer(red_user["token"]), ) assert detail.get_json()["state"] == "pending" # Red stamps executed_at directly — must succeed and bump the state. stamped = client.put( f"/api/v1/missions/{mission['id']}/tests/{tid}", headers=_bearer(red_user["token"]), json={ "executed_at": "2026-05-14T10:00:00+00:00", "executed_at_overridden": True, }, ) assert stamped.status_code == 200, stamped.get_data(as_text=True) body = stamped.get_json() assert body["state"] == "executed" assert body["executed_at"].startswith("2026-05-14T10:00:00") assert body["executed_at_overridden"] is True # Blue cannot touch executed_at — red-side field. forbidden = client.put( f"/api/v1/missions/{mission['id']}/tests/{tid}", headers=_bearer(blue_user["token"]), json={"executed_at": "2026-05-14T11:00:00+00:00"}, ) assert forbidden.status_code == 403 def test_red_setting_executed_at_from_skipped_state_auto_transitions( client, admin_token, catalogue, red_user ): """Skipped → executed by stamping a timestamp: same implicit transition so the operator who marked the test skipped by mistake can simply type the actual execution time.""" mission = _make_mission( client, admin_token, name="m7-exec-at-skipped", scenario_id=catalogue["scenario"]["id"], red_id=red_user["id"], ) tid = _first_test_id(mission) # Move to skipped first. skip = client.post( f"/api/v1/missions/{mission['id']}/tests/{tid}/transition", headers=_bearer(red_user["token"]), json={"target_state": "skipped"}, ) assert skip.status_code == 200 assert skip.get_json()["state"] == "skipped" # Stamp executed_at inline → should land in executed. stamped = client.put( f"/api/v1/missions/{mission['id']}/tests/{tid}", headers=_bearer(red_user["token"]), json={"executed_at": "2026-05-14T12:00:00+00:00"}, ) assert stamped.status_code == 200, stamped.get_data(as_text=True) assert stamped.get_json()["state"] == "executed" def test_state_machine_rejects_invalid_transitions( client, admin_token, catalogue, red_user ): mission = _make_mission( client, admin_token, name="m7-state", scenario_id=catalogue["scenario"]["id"], red_id=red_user["id"], ) tid = _first_test_id(mission) # pending → reviewed_by_blue is not allowed (must go through executed first). r = client.post( f"/api/v1/missions/{mission['id']}/tests/{tid}/transition", headers=_bearer(red_user["token"]), json={"target_state": "reviewed_by_blue"}, ) assert r.status_code == 409 def test_review_by_blue_requires_blue_perm( client, admin_token, catalogue, red_user, blue_user ): mission = _make_mission( client, admin_token, name="m7-review", scenario_id=catalogue["scenario"]["id"], red_id=red_user["id"], blue_id=blue_user["id"], ) tid = _first_test_id(mission) # red marks executed r1 = client.post( f"/api/v1/missions/{mission['id']}/tests/{tid}/transition", headers=_bearer(red_user["token"]), json={"target_state": "executed"}, ) assert r1.status_code == 200 # red tries to mark reviewed_by_blue — denied (blue side) r2 = client.post( f"/api/v1/missions/{mission['id']}/tests/{tid}/transition", headers=_bearer(red_user["token"]), json={"target_state": "reviewed_by_blue"}, ) assert r2.status_code == 403 # blue does it — OK r3 = client.post( f"/api/v1/missions/{mission['id']}/tests/{tid}/transition", headers=_bearer(blue_user["token"]), json={"target_state": "reviewed_by_blue"}, ) assert r3.status_code == 200 assert r3.get_json()["state"] == "reviewed_by_blue" def test_member_visibility_returns_404_for_outsiders( client, admin_token, catalogue, red_user, reader_user ): mission = _make_mission( client, admin_token, name="m7-secret", scenario_id=catalogue["scenario"]["id"], red_id=red_user["id"], ) tid = _first_test_id(mission) r = client.get( f"/api/v1/missions/{mission['id']}/tests/{tid}", headers=_bearer(reader_user["token"]), ) assert r.status_code == 404 def test_admin_bypasses_membership(client, admin_token, catalogue, red_user): mission = _make_mission( client, admin_token, name="m7-admin-sees-all", scenario_id=catalogue["scenario"]["id"], red_id=red_user["id"], ) tid = _first_test_id(mission) # Admin is not a member; sees the test anyway. r = client.get( f"/api/v1/missions/{mission['id']}/tests/{tid}", headers=_bearer(admin_token), ) assert r.status_code == 200 # ================================================================ evidence == def _png_bytes(n: int) -> bytes: """Return n bytes prefixed with a valid PNG magic so MIME sniffers cooperate.""" return b"\x89PNG\r\n\x1a\n" + b"A" * max(0, n - 8) def _upload(client, mission_id: str, test_id: str, token: str, *, filename: str, content: bytes, mime: str = "image/png"): return client.post( f"/api/v1/missions/{mission_id}/tests/{test_id}/evidence", headers=_bearer(token), data={"file": (io.BytesIO(content), filename, mime)}, content_type="multipart/form-data", ) def test_evidence_upload_small_succeeds_and_records_sha256( client, admin_token, catalogue, blue_user ): mission = _make_mission( client, admin_token, name="m7-ev-small", scenario_id=catalogue["scenario"]["id"], blue_id=blue_user["id"], ) tid = _first_test_id(mission) content = _png_bytes(1024) expected = hashlib.sha256(content).hexdigest() r = _upload(client, mission["id"], tid, blue_user["token"], filename="screenshot.png", content=content, mime="image/png") assert r.status_code == 201, r.get_data(as_text=True) body = r.get_json() assert body["sha256"] == expected assert body["size_bytes"] == len(content) assert body["original_filename"] == "screenshot.png" assert body["mime"] == "image/png" def test_evidence_upload_24mb_succeeds_26mb_rejected( client, admin_token, catalogue, blue_user ): mission = _make_mission( client, admin_token, name="m7-ev-boundaries", scenario_id=catalogue["scenario"]["id"], blue_id=blue_user["id"], ) tid = _first_test_id(mission) twenty_four = _png_bytes(24 * 1024 * 1024) ok = _upload( client, mission["id"], tid, blue_user["token"], filename="lab.evtx", content=twenty_four, mime="application/octet-stream", ) assert ok.status_code == 201, ok.get_data(as_text=True)[:200] twenty_six = _png_bytes(26 * 1024 * 1024) too_big = _upload( client, mission["id"], tid, blue_user["token"], filename="huge.evtx", content=twenty_six, mime="application/octet-stream", ) assert too_big.status_code == 400 assert too_big.get_json()["error"] == "too_large" def test_evidence_upload_rejects_unsupported_extension( client, admin_token, catalogue, blue_user ): mission = _make_mission( client, admin_token, name="m7-ev-ext", scenario_id=catalogue["scenario"]["id"], blue_id=blue_user["id"], ) tid = _first_test_id(mission) r = _upload( client, mission["id"], tid, blue_user["token"], filename="evil.exe", content=b"\x4d\x5a", mime="application/octet-stream", ) assert r.status_code == 400 assert r.get_json()["error"] == "unsupported_extension" def test_evidence_upload_requires_blue_perm( client, admin_token, catalogue, red_user, blue_user ): mission = _make_mission( client, admin_token, name="m7-ev-perm", scenario_id=catalogue["scenario"]["id"], red_id=red_user["id"], blue_id=blue_user["id"], ) tid = _first_test_id(mission) r = _upload( client, mission["id"], tid, red_user["token"], filename="note.txt", content=b"hi", mime="text/plain", ) assert r.status_code == 403 def test_evidence_download_returns_bytes(client, admin_token, catalogue, blue_user): mission = _make_mission( client, admin_token, name="m7-ev-dl", scenario_id=catalogue["scenario"]["id"], blue_id=blue_user["id"], ) tid = _first_test_id(mission) content = b"hello evidence\n" upl = _upload( client, mission["id"], tid, blue_user["token"], filename="note.txt", content=content, mime="text/plain", ).get_json() eid = upl["id"] meta = client.get( f"/api/v1/evidence/{eid}", headers=_bearer(blue_user["token"]) ) assert meta.status_code == 200 assert meta.get_json()["sha256"] == hashlib.sha256(content).hexdigest() dl = client.get( f"/api/v1/evidence/{eid}?download=true", headers=_bearer(blue_user["token"]), ) assert dl.status_code == 200 assert dl.data == content def test_evidence_soft_delete_hides_it_from_test_detail( client, admin_token, catalogue, blue_user ): mission = _make_mission( client, admin_token, name="m7-ev-del", scenario_id=catalogue["scenario"]["id"], blue_id=blue_user["id"], ) tid = _first_test_id(mission) upl = _upload( client, mission["id"], tid, blue_user["token"], filename="evidence.json", content=b'{"ok":true}', mime="application/json", ).get_json() eid = upl["id"] detail_before = client.get( f"/api/v1/missions/{mission['id']}/tests/{tid}", headers=_bearer(blue_user["token"]), ).get_json() assert len(detail_before["evidence"]) == 1 r = client.delete( f"/api/v1/evidence/{eid}", headers=_bearer(blue_user["token"]) ) assert r.status_code == 200 detail_after = client.get( f"/api/v1/missions/{mission['id']}/tests/{tid}", headers=_bearer(blue_user["token"]), ).get_json() assert detail_after["evidence"] == [] def test_idempotent_transition_still_checks_side_perm( client, admin_token, catalogue, red_user, blue_user ): """A blue-only user re-POSTing target_state=executed on an already-executed test must NOT receive 200 — even though no write happens, returning success falsely implies they hold the red-side perm. See post-review fix C1.""" mission = _make_mission( client, admin_token, name="m7-idemp-side", scenario_id=catalogue["scenario"]["id"], red_id=red_user["id"], blue_id=blue_user["id"], ) tid = _first_test_id(mission) # Red marks executed. r1 = client.post( f"/api/v1/missions/{mission['id']}/tests/{tid}/transition", headers=_bearer(red_user["token"]), json={"target_state": "executed"}, ) assert r1.status_code == 200 # Blue replays the same transition — must be 403, not 200. r2 = client.post( f"/api/v1/missions/{mission['id']}/tests/{tid}/transition", headers=_bearer(blue_user["token"]), json={"target_state": "executed"}, ) assert r2.status_code == 403 def test_evidence_member_of_other_mission_gets_404( client, admin_token, catalogue, blue_user ): """A user who is a blue member of mission B must NOT be able to read an evidence row belonging to mission A — the chain walk must collapse to 404.""" mission_a = _make_mission( client, admin_token, name="m7-ev-cross-a", scenario_id=catalogue["scenario"]["id"], # blue_user is NOT a member of A ) tid_a = _first_test_id(mission_a) # Admin uploads on mission A. upl = _upload( client, mission_a["id"], tid_a, admin_token, filename="a.txt", content=b"secret", mime="text/plain", ).get_json() eid = upl["id"] # blue_user joins mission B but tries to read mission A's evidence. _make_mission( client, admin_token, name="m7-ev-cross-b", scenario_id=catalogue["scenario"]["id"], blue_id=blue_user["id"], ) r = client.get(f"/api/v1/evidence/{eid}", headers=_bearer(blue_user["token"])) assert r.status_code == 404 def test_evidence_non_member_gets_404(client, admin_token, catalogue, blue_user, reader_user): mission = _make_mission( client, admin_token, name="m7-ev-leak", scenario_id=catalogue["scenario"]["id"], blue_id=blue_user["id"], ) tid = _first_test_id(mission) upl = _upload( client, mission["id"], tid, blue_user["token"], filename="a.txt", content=b"x", mime="text/plain", ).get_json() eid = upl["id"] r = client.get(f"/api/v1/evidence/{eid}", headers=_bearer(reader_user["token"])) assert r.status_code == 404 # ================================================================ activity == def test_activity_polling_returns_recent_changes( client, admin_token, catalogue, red_user ): mission = _make_mission( client, admin_token, name="m7-activity", scenario_id=catalogue["scenario"]["id"], red_id=red_user["id"], ) tid = _first_test_id(mission) # Baseline timestamp from server, then a write 'after' it should appear. now = client.get( f"/api/v1/missions/{mission['id']}/activity", headers=_bearer(red_user["token"]), ) assert now.status_code == 200 server_t = now.get_json()["server_time"] # Mutate via PUT to bump updated_at. client.put( f"/api/v1/missions/{mission['id']}/tests/{tid}", headers=_bearer(red_user["token"]), json={"red_comment_md": "kicked off"}, ) # `since` must be URL-encoded — its `+` and `:` would otherwise be mangled. since_q = urllib.parse.quote(server_t) fresh = client.get( f"/api/v1/missions/{mission['id']}/activity?since={since_q}", headers=_bearer(red_user["token"]), ) assert fresh.status_code == 200 items = fresh.get_json()["items"] assert len(items) >= 1 assert items[0]["test_id"] == tid assert items[0]["last_actor_email"] == red_user["email"] def test_activity_invalid_since_returns_400(client, admin_token, catalogue, red_user): mission = _make_mission( client, admin_token, name="m7-activity-bad", scenario_id=catalogue["scenario"]["id"], red_id=red_user["id"], ) r = client.get( f"/api/v1/missions/{mission['id']}/activity?since=not-a-date", headers=_bearer(red_user["token"]), ) assert r.status_code == 400 def test_activity_404_for_non_member(client, admin_token, catalogue, red_user, reader_user): mission = _make_mission( client, admin_token, name="m7-activity-leak", scenario_id=catalogue["scenario"]["id"], red_id=red_user["id"], ) r = client.get( f"/api/v1/missions/{mission['id']}/activity", headers=_bearer(reader_user["token"]), ) assert r.status_code == 404 def test_activity_since_in_future_returns_empty( client, admin_token, catalogue, red_user ): mission = _make_mission( client, admin_token, name="m7-activity-future", scenario_id=catalogue["scenario"]["id"], red_id=red_user["id"], ) future = (datetime.now(tz=timezone.utc) + timedelta(hours=1)).isoformat() since_q = urllib.parse.quote(future) r = client.get( f"/api/v1/missions/{mission['id']}/activity?since={since_q}", headers=_bearer(red_user["token"]), ) assert r.status_code == 200 assert r.get_json()["items"] == [] def test_unknown_test_id_returns_404(client, admin_token, catalogue, red_user): mission = _make_mission( client, admin_token, name="m7-unknown-test", scenario_id=catalogue["scenario"]["id"], red_id=red_user["id"], ) fake = str(uuid.uuid4()) r = client.get( f"/api/v1/missions/{mission['id']}/tests/{fake}", headers=_bearer(red_user["token"]), ) assert r.status_code == 404