"""VC migration e2e test (TASK-06-05, R-VC-MIG-01 — CRITICAL). The highest-severity v0.4 risk: a v0.3 VC MUST verify against a Postgres store with the v0.3 public key archived as superseded. This test seeds SQLite with a v0.3 issuer key + credential, runs the migration, and verifies through the HTTP endpoint. Requires a live Postgres instance. Skips gracefully when PRAXIS_PG_DSN is unset. """ from __future__ import annotations import asyncio import json import os import uuid from pathlib import Path import pytest from fastapi.testclient import TestClient pytestmark = pytest.mark.skipif( "PRAXIS_PG_DSN" not in os.environ, reason="PRAXIS_PG_DSN not set — VC migration e2e test skipped (R-VC-MIG-01).", ) @pytest.fixture async def _e2e_env(tmp_path, monkeypatch): """Set up a fresh SQLite store + Postgres pool + run migration.""" import server.__main__ as m from db.store import PraxisStore from db.pg_migrate import apply_pg_migrations from db.pg_store import PgStore from server.vc.issuer import build_vc_payload, sign, issue_credential from server.vc.issuer_keys import init_issuer_key, _load_root_key from server.vc.migrate_keys import migrate_issuer_keys # Fresh SQLite store in a temp dir. sqlite_path = tmp_path / "praxis-e2e.db" monkeypatch.setenv("PRAXIS_DB_PATH", str(sqlite_path)) sqlite_store = PraxisStore(str(sqlite_path)) await sqlite_store.init() # Seed SQLite with a v0.3 issuer key + a v0.3-issued credential. root_key = _load_root_key() v03_kp = await init_issuer_key(sqlite_store, root_key) v03_cred_id = await issue_credential( sqlite_store, signing_key=v03_kp.signing_key, key_id=v03_kp.key_id, learner_id="learner-e2e-v03", path="cs-refund", scenarios_passed=["sc-1"], rubric_score=4.0, completed_weeks=6, evidence=[], ) # Connect to Postgres + apply migrations + clean tables. import asyncpg pool = await asyncpg.create_pool( dsn=os.environ["PRAXIS_PG_DSN"], min_size=1, max_size=3, command_timeout=10 ) 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" ) pg_store = PgStore(pool) yield { "sqlite_store": sqlite_store, "pg_store": pg_store, "pool": pool, "v03_kp": v03_kp, "v03_cred_id": v03_cred_id, "root_key": root_key, } await pool.close() @pytest.mark.asyncio async def test_v03_vc_verifies_after_migration(_e2e_env): """R-VC-MIG-01: v0.3 VC verifies against Postgres with archived key.""" env = _e2e_env from server.vc.migrate_keys import migrate_issuer_keys from server.vc.verification import verify_credential # Run the migration. result = await migrate_issuer_keys( env["sqlite_store"], env["pg_store"], env["root_key"] ) assert result["archived_key_id"] == env["v03_kp"].key_id assert result["new_key_id"] is not None # Verify Postgres has 1 superseded + 1 active key. active = await env["pg_store"].get_active_signing_key_row() assert active is not None assert active["id"] == result["new_key_id"] archived = await env["pg_store"].get_public_key_row(env["v03_kp"].key_id) assert archived is not None assert archived["status"] == "superseded" # R-VC-MIG-01 CRITICAL: verify the v0.3 credential through the # two-store path (G-011: credential in SQLite, key in Postgres). res = await verify_credential( env["sqlite_store"], env["v03_cred_id"], pg_store=env["pg_store"], sqlite_store=env["sqlite_store"], ) assert res is not None assert res["valid"] is True, ( "R-VC-MIG-01 FAIL: v0.3 VC did not verify against archived superseded key" ) assert res["status"] == "active" @pytest.mark.asyncio async def test_migration_idempotent_e2e(_e2e_env): """Re-running the migration is a no-op.""" env = _e2e_env from server.vc.migrate_keys import migrate_issuer_keys await migrate_issuer_keys(env["sqlite_store"], env["pg_store"], env["root_key"]) result = await migrate_issuer_keys(env["sqlite_store"], env["pg_store"], env["root_key"]) assert result["archived_key_id"] is None assert result["new_key_id"] is None @pytest.mark.asyncio async def test_g027_first_boot_no_v03_key(_e2e_env): """G-027: fresh deploy with no v0.3 key → skip archive, fresh key only.""" env = _e2e_env # Use a fresh SQLite store with NO v0.3 key. from db.store import PraxisStore from server.vc.migrate_keys import migrate_issuer_keys import tempfile fresh_path = Path(tempfile.mkdtemp()) / "fresh.db" fresh_store = PraxisStore(str(fresh_path)) await fresh_store.init() result = await migrate_issuer_keys(fresh_store, env["pg_store"], env["root_key"]) assert result["archived_key_id"] is None assert result["new_key_id"] is not None @pytest.mark.asyncio async def test_v04_vc_verifies_after_migration(_e2e_env): """A newly-issued v0.4 VC verifies against the active key in Postgres.""" env = _e2e_env from server.vc.migrate_keys import migrate_issuer_keys from server.vc.verification import verify_credential from server.vc.issuer import issue_credential from server.vc.issuer_keys import get_active_signing_key await migrate_issuer_keys(env["sqlite_store"], env["pg_store"], env["root_key"]) # Issue a v0.4 credential using the active Postgres key. kp, _enc = await get_active_signing_key(env["pg_store"], env["root_key"]) v04_cred_id = await issue_credential( env["sqlite_store"], signing_key=kp.signing_key, key_id=kp.key_id, learner_id="learner-e2e-v04", path="cs-refund", scenarios_passed=["sc-1", "sc-2"], rubric_score=4.5, completed_weeks=6, evidence=[], ) # The credential is in SQLite; the key is in Postgres. Verify via the # two-store path. res = await verify_credential( env["sqlite_store"], v04_cred_id, pg_store=env["pg_store"], sqlite_store=env["sqlite_store"], ) assert res is not None assert res["valid"] is True @pytest.mark.asyncio async def test_tampered_v03_vc_fails_e2e(_e2e_env): """Tamper detection: a modified v0.3 credential fails verification.""" env = _e2e_env from server.vc.migrate_keys import migrate_issuer_keys from server.vc.verification import verify_credential await migrate_issuer_keys(env["sqlite_store"], env["pg_store"], env["root_key"]) # Fetch the v0.3 credential and tamper with its payload. row = await env["sqlite_store"].get_credential(env["v03_cred_id"]) assert row is not None doc = json.loads(row["vc_payload_json"]) doc["credentialSubject"]["rubricScore"] = 1.0 # tamper await env["sqlite_store"].set_credential_status(env["v03_cred_id"], "active") # Overwrite the payload in SQLite with the tampered version. import aiosqlite async with aiosqlite.connect(env["sqlite_store"].db_path) as db: await db.execute( "UPDATE issued_credentials SET vc_payload_json = ? WHERE id = ?", (json.dumps(doc, sort_keys=True, separators=(",", ":")), env["v03_cred_id"]), ) await db.commit() res = await verify_credential( env["sqlite_store"], env["v03_cred_id"], pg_store=env["pg_store"], sqlite_store=env["sqlite_store"], ) assert res is not None assert res["valid"] is False