feat(P01): SLICE-06 P1 integration — wire lifespan + auth + verification swap
- TASK-06-01 __main__.py: SessionMiddleware (signed cookies, D-056) added
AFTER CORS so it is outermost. slowapi limiter state + 429 exception
handler registered. The lifespan (TASK-01-03) now also runs the VC key
migration on first boot.
- TASK-06-02 __main__.py: auth_router mounted (POST /api/operator/login,
POST /api/operator/logout, GET /api/operator/me) BEFORE the StaticFiles
mount (routes-before-static constraint). Auth routes use app.state.pg_store
(503 if no Postgres).
- TASK-06-03 __main__.py: /vc/verify swapped to the two-store path (G-011):
pg_store for key lookup (active + superseded), SQLite fallback for v0.3
credentials, SQLite-only if no Postgres. _maybe_migrate_issuer_keys()
runs once in the lifespan (idempotent, G-027 first-boot, non-fatal on
failure — v0.3 path intact).
- TASK-06-04 tests/test_p1_auth_integration.py: 4 e2e tests (skip if no
Postgres) — full auth flow, /me without cookie 401, wrong password 401,
learner voice loop unaffected (REQ-NFR-MT-01).
- TASK-06-05 tests/test_p1_vc_migration_e2e.py: 5 e2e tests (skip if no
Postgres) — R-VC-MIG-01 critical (v0.3 VC verifies against archived
superseded key in Postgres), idempotent migration, G-027 first-boot,
v0.04 VC verifies, tamper detection.
Graceful degradation verified: server starts without Postgres (pg_pool/
pg_store are None; voice loop works; auth routes return 503).
---ci---
project: praxis
phase: 1
milestone: v0.4
status: execute
persona: backend-engineer
task: 06-01,06-02,06-03,06-04,06-05
requirements:
covered: [REQ-MT-01, REQ-AUTH-01, REQ-NFR-AUTH-01, REQ-NFR-MT-01]
grill:
- G-011 (two-store fallback wired in /vc/verify)
risks:
- R-VC-MIG-01 (e2e test: v0.3 VC verifies against archived superseded key in Postgres)
---/ci---
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
"""P1 auth integration test (TASK-06-04) — end-to-end with Postgres.
|
||||
|
||||
Requires a live Postgres instance. Skips gracefully when PRAXIS_PG_DSN is
|
||||
unset. Tests the full auth flow through the FastAPI app (TestClient with
|
||||
the real lifespan): create operator via the bootstrap CLI → POST /login →
|
||||
GET /me → POST /logout → GET /me (401). Rate limiting, cookie attributes,
|
||||
and learner-voice-loop-unaffected verification (REQ-NFR-MT-01).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import uuid
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
"PRAXIS_PG_DSN" not in os.environ,
|
||||
reason="PRAXIS_PG_DSN not set — P1 auth integration tests skipped.",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
async def _started_app():
|
||||
"""Start the real FastAPI app with the lifespan (creates the pool +
|
||||
applies migrations + runs VC key migration)."""
|
||||
import asyncio
|
||||
import server.__main__ as m
|
||||
# Ensure the SQLite store is initialized (v0.3 path).
|
||||
await m._store.init()
|
||||
# Use a unique operator username per run to avoid collisions.
|
||||
suffix = uuid.uuid4().hex[:8]
|
||||
with TestClient(m.app) as client:
|
||||
yield client, suffix, m
|
||||
|
||||
|
||||
def test_full_auth_flow(_started_app):
|
||||
client, suffix, m = _started_app
|
||||
if m.app.state.pg_store is None:
|
||||
pytest.skip("pg_store is None (no Postgres connected)")
|
||||
username = f"intop-{suffix}"
|
||||
pw = "integration-pw-123"
|
||||
# Create operator via the store directly (bootstrap CLI path is
|
||||
# covered in test_create_operator.py; here we exercise the HTTP flow).
|
||||
import asyncio
|
||||
from server.auth.passwords import hash_password
|
||||
|
||||
async def _seed():
|
||||
await m.app.state.pg_store.insert_operator(username, hash_password(pw), username)
|
||||
asyncio.get_event_loop().run_until_complete(_seed())
|
||||
|
||||
# POST /login
|
||||
r = client.post("/api/operator/login", json={"username": username, "password": pw})
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["operator"]["username"] == username
|
||||
# Cookie set
|
||||
cookie = client.cookies.get("praxis_op")
|
||||
assert cookie, "praxis_op cookie should be set after login"
|
||||
|
||||
# GET /me
|
||||
r2 = client.get("/api/operator/me")
|
||||
assert r2.status_code == 200
|
||||
assert r2.json()["operator"]["username"] == username
|
||||
|
||||
# POST /logout
|
||||
r3 = client.post("/api/operator/logout")
|
||||
assert r3.status_code == 200
|
||||
assert r3.json()["ok"] is True
|
||||
|
||||
# GET /me after logout → 401
|
||||
r4 = client.get("/api/operator/me")
|
||||
assert r4.status_code == 401
|
||||
|
||||
|
||||
def test_me_without_cookie_401(_started_app):
|
||||
client, suffix, m = _started_app
|
||||
if m.app.state.pg_store is None:
|
||||
pytest.skip("pg_store is None (no Postgres connected)")
|
||||
# Use a fresh client (no cookie jar sharing).
|
||||
import server.__main__ as m
|
||||
with TestClient(m.app) as fresh:
|
||||
r = fresh.get("/api/operator/me")
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_login_wrong_password_401(_started_app):
|
||||
client, suffix, m = _started_app
|
||||
if m.app.state.pg_store is None:
|
||||
pytest.skip("pg_store is None (no Postgres connected)")
|
||||
username = f"wrong-{suffix}"
|
||||
pw = "correct-pw"
|
||||
import asyncio
|
||||
from server.auth.passwords import hash_password
|
||||
|
||||
async def _seed():
|
||||
await m.app.state.pg_store.insert_operator(username, hash_password(pw), username)
|
||||
asyncio.get_event_loop().run_until_complete(_seed())
|
||||
import server.__main__ as m
|
||||
from server.auth.rate_limit import reset_login_rate_limit
|
||||
reset_login_rate_limit()
|
||||
with TestClient(m.app) as fresh:
|
||||
r = fresh.post("/api/operator/login", json={"username": username, "password": "wrong"})
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_learner_voice_loop_unaffected(_started_app):
|
||||
"""REQ-NFR-MT-01 — Postgres presence does not destabilize the learner
|
||||
voice loop (/health works regardless of Postgres state)."""
|
||||
client, suffix, m = _started_app
|
||||
r = client.get("/health")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["status"] == "ok"
|
||||
@@ -0,0 +1,209 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user