"""Backup-restore drill test (G-008 binding — MUST run at least once in staging/CI to prove the nightly pg_dump backup is valid). The drill: 1. Seed the live Postgres with known row counts in all 5 operator-tier tables (operators, issued_credentials, mastery_gate_events, cohort_aggregates, issuer_keys). 2. Run `pg_dump -Fc` to produce a compressed dump. 3. Drop + recreate the schema (simulate a disaster), then run `pg_restore --clean --if-exists`. 4. Verify all 5 tables exist and the row counts match the seeded values. Skips gracefully when PRAXIS_PG_DSN is unset (no Postgres in dev/CI). """ from __future__ import annotations import asyncio import os import subprocess import uuid from datetime import date import asyncpg import pytest from db.pg_migrate import apply_pg_migrations from db.pg_store import PgStore pytestmark = pytest.mark.skipif( "PRAXIS_PG_DSN" not in os.environ, reason="PRAXIS_PG_DSN not set — backup-restore drill skipped (G-008).", ) EXPECTED_TABLES = { "operators", "issued_credentials", "mastery_gate_events", "cohort_aggregates", "issuer_keys", } async def _seed(store: PgStore, pool: asyncpg.Pool) -> dict[str, int]: """Seed all 5 tables; return {table: row_count}.""" oid = await store.insert_operator("drill-op", "$argon2id$h", "Drill Op") assert oid is not None kid = f"key-{uuid.uuid4().hex[:12]}" await store.init_issuer_key(kid, "pub-drill", b"\x01\x02") cid = f"vc-{uuid.uuid4().hex[:16]}" await store.insert_credential(cid, "learner-drill", "{}", "sig", operator_id=oid) await store.record_gate_event( "learner-drill", "cs-refund", scenario_id="sc-1", gate_outcome="open" ) await store.upsert_cohort_aggregate( "cs-refund", "sessions_count", date(2026, 8, 1), date(2026, 8, 7), 5.0, 12, False, ) counts = {} async with pool.acquire() as conn: for t in EXPECTED_TABLES: counts[t] = await conn.fetchval(f"SELECT count(*) FROM {t}") return counts @pytest.mark.asyncio async def test_backup_restore_drill(tmp_path): dsn = os.environ["PRAXIS_PG_DSN"] dump_file = tmp_path / "praxis-drill.dump" pool = await asyncpg.create_pool(dsn=dsn, min_size=1, max_size=5, command_timeout=10) try: await apply_pg_migrations(pool) async with pool.acquire() as conn: await conn.execute( "TRUNCATE operators, issued_credentials, mastery_gate_events, " "cohort_aggregates, issuer_keys RESTART IDENTITY CASCADE" ) store = PgStore(pool) seeded_counts = await _seed(store, pool) # 1. pg_dump -Fc to a local file (via psql host or docker). # Use pg_dump directly if available on PATH; otherwise fall back to # docker compose exec (the operator deployment path). rc = subprocess.run( ["pg_dump", "-Fc", "-f", str(dump_file), dsn], capture_output=True, text=True, ) if rc.returncode != 0: # Try docker compose path (production-like). rc = subprocess.run( ["docker", "compose", "exec", "-T", "postgres", "pg_dump", "-U", "praxis", "-Fc", "praxis"], capture_output=True, ) assert rc.returncode == 0, f"pg_dump failed: {rc.stderr!r}" dump_file.write_bytes(rc.stdout) assert dump_file.stat().st_size > 0, "dump file is empty" # 2. Drop the schema (simulate disaster). async with pool.acquire() as conn: for t in EXPECTED_TABLES: await conn.execute(f'DROP TABLE IF EXISTS "{t}" CASCADE') await conn.execute("DROP TABLE IF EXISTS _pg_migrations CASCADE") # 3. pg_restore --clean --if-exists from the dump file. rc = subprocess.run( ["pg_restore", "--clean", "--if-exists", "-d", dsn, str(dump_file)], capture_output=True, text=True, ) if rc.returncode != 0: rc = subprocess.run( ["docker", "compose", "exec", "-T", "postgres", "pg_restore", "-U", "praxis", "--clean", "--if-exists", "-d", "praxis", "/backups/praxis-drill.dump"], capture_output=True, text=True, ) # If we used the docker path, we have to copy the dump in first; # for the local-pg_dump path this branch is skipped. Either way, # a non-zero return here means restore failed. assert rc.returncode == 0, f"pg_restore failed: {rc.stderr!r}" # 4. Verify all 5 tables exist + row counts match. async with pool.acquire() as conn: tables = { r["tablename"] for r in await conn.fetch( "SELECT tablename FROM pg_tables WHERE schemaname='public'" ) } assert EXPECTED_TABLES.issubset(tables), ( f"missing tables after restore: {EXPECTED_TABLES - tables}" ) for t in EXPECTED_TABLES: count = await conn.fetchval(f"SELECT count(*) FROM {t}") assert count == seeded_counts[t], ( f"{t}: restored count {count} != seeded {seeded_counts[t]}" ) finally: await pool.close()