f2a12f9fed
v0.4 (Operator Tier — Cohort Dashboard + Auth + Postgres) milestone complete. Phases: ✓ P0 pre-execution (planning) → v0.1.6 ✓ P1 operator foundation (Postgres+auth+VC migration) → v0.1.7 ✓ P2 cohort dashboard + aggregation → v0.1.8 ✓ P3 final review + ship → v0.1.9 (= v0.4 milestone release) Requirements covered (8/8): REQ-MT-01 (Postgres store), REQ-MT-02 (aggregation pipeline), REQ-AUTH-01 (operator auth), REQ-DASH-01 (cohort dashboard), REQ-NFR-AUTH-01 (auth NFRs), REQ-NFR-MT-01 (Postgres-in-LXC), REQ-NFR-DASH-01 (k-anonymity ≥10), REQ-NFR-DASH-02 (freshness ≤24h) Grill MUSTs honored (6/6): G-008, G-011, G-027, G-031, G-038, G-041 Tests: 317 pytest pass, 36 skip (Postgres-requiring), 0 fail; 17/17 vitest pass Review: APPROVE_WITH_NOTES (6/6 personas, 0 P0, 8 P1+ carry-forward) Audit: HEALTHY (reconstruction PASS, 8/8 REQ, 6/6 grill) ---ci--- project: praxis phase: 3 milestone: v0.4 status: complete phase_role: final milestone_complete: true milestone_merged_to_main: true tag: v0.1.9 requirements: covered: [REQ-MT-01, REQ-MT-02, REQ-AUTH-01, REQ-DASH-01, REQ-NFR-AUTH-01, REQ-NFR-MT-01, REQ-NFR-DASH-01, REQ-NFR-DASH-02] partial: [] ---/ci---
209 lines
7.5 KiB
Python
209 lines
7.5 KiB
Python
"""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 |