"""Migration round-trip test for 0007_c2_task_mapping_applied. Verifies that upgrade() adds the mapping_applied column and downgrade() removes it. Uses the resolved-path pattern per lessons.md Sprint 4. """ from __future__ import annotations import importlib.util from pathlib import Path from alembic.operations import Operations from alembic.runtime.migration import MigrationContext from sqlalchemy import create_engine, inspect, text def _load_migration(name: str): versions_dir = Path(__file__).resolve().parent.parent / "migrations" / "versions" path = versions_dir / name spec = importlib.util.spec_from_file_location(name.removesuffix(".py"), path) assert spec and spec.loader mod = importlib.util.module_from_spec(spec) spec.loader.exec_module(mod) # type: ignore[union-attr] return mod def _fresh_engine_with_c2_task(): """In-memory SQLite with c2_task already created (as left by 0006 upgrade).""" engine = create_engine("sqlite:///:memory:") with engine.begin() as conn: conn.execute(text(""" CREATE TABLE c2_task ( id INTEGER PRIMARY KEY, simulation_id INTEGER NOT NULL, mythic_task_display_id INTEGER NOT NULL, callback_display_id INTEGER NOT NULL, command TEXT NOT NULL, params TEXT, status TEXT NOT NULL, completed BOOLEAN NOT NULL DEFAULT 0, output TEXT, source TEXT NOT NULL, created_at DATETIME NOT NULL, completed_at DATETIME ) """)) return engine def _run_upgrade(engine, migration_mod): with engine.begin() as conn: ctx = MigrationContext.configure(conn) ops = Operations(ctx) ops._install_proxy() # type: ignore[attr-defined] try: migration_mod.upgrade() finally: ops._remove_proxy() # type: ignore[attr-defined] def _run_downgrade(engine, migration_mod): with engine.begin() as conn: ctx = MigrationContext.configure(conn) ops = Operations(ctx) ops._install_proxy() # type: ignore[attr-defined] try: migration_mod.downgrade() finally: ops._remove_proxy() # type: ignore[attr-defined] class TestMigration0007Upgrade: def test_mapping_applied_column_added(self): engine = _fresh_engine_with_c2_task() mod = _load_migration("0007_c2_task_mapping_applied.py") _run_upgrade(engine, mod) insp = inspect(engine) cols = {c["name"] for c in insp.get_columns("c2_task")} assert "mapping_applied" in cols def test_mapping_applied_defaults_to_false(self): engine = _fresh_engine_with_c2_task() mod = _load_migration("0007_c2_task_mapping_applied.py") # Insert a row before upgrading (no mapping_applied column yet). with engine.begin() as conn: conn.execute(text( "INSERT INTO c2_task " "(simulation_id, mythic_task_display_id, callback_display_id, " "command, status, completed, source, created_at) " "VALUES (1, 1000, 1, 'whoami', 'submitted', 0, 'mimic', '2026-01-01')" )) _run_upgrade(engine, mod) with engine.begin() as conn: row = conn.execute( text("SELECT mapping_applied FROM c2_task WHERE id = 1") ).fetchone() assert row is not None # SQLite stores booleans as 0/1. assert row[0] == 0 or row[0] is False class TestMigration0007Downgrade: def test_downgrade_removes_mapping_applied(self): engine = _fresh_engine_with_c2_task() mod = _load_migration("0007_c2_task_mapping_applied.py") _run_upgrade(engine, mod) _run_downgrade(engine, mod) insp = inspect(engine) cols = {c["name"] for c in insp.get_columns("c2_task")} assert "mapping_applied" not in cols def test_downgrade_does_not_drop_other_columns(self): engine = _fresh_engine_with_c2_task() mod = _load_migration("0007_c2_task_mapping_applied.py") _run_upgrade(engine, mod) _run_downgrade(engine, mod) insp = inspect(engine) cols = {c["name"] for c in insp.get_columns("c2_task")} assert {"id", "simulation_id", "command", "status", "completed"} <= cols