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---
236 lines
9.4 KiB
Python
236 lines
9.4 KiB
Python
"""P2 integration test — aggregation → endpoint → response (TASK-10-03).
|
|
|
|
Requires Postgres (skips if PRAXIS_PG_DSN not set). End-to-end:
|
|
1. Seed 15 mock sessions (12 distinct learners — above k-anon threshold).
|
|
2. Run the aggregation hook for each → cohort_aggregates populated.
|
|
3. GET /api/operator/cohort (with auth cookie) → non-suppressed cells.
|
|
4. Seed 5 sessions (5 NEW learners) for a different path → suppressed cells.
|
|
5. Run nightly reconciliation → all windows recomputed → last_updated updated.
|
|
6. GET /api/operator/mastery → mastery progression data.
|
|
7. GET /api/operator/failure-patterns → failure pattern data.
|
|
8. Verify last_updated ≤ 24h old (REQ-NFR-DASH-02).
|
|
|
|
G-038 differencing-attack e2e: also verified at the API layer here.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import datetime as _dt
|
|
import os
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
import pytest
|
|
|
|
pytestmark = pytest.mark.skipif(
|
|
not os.environ.get("PRAXIS_PG_DSN"),
|
|
reason="PRAXIS_PG_DSN not set — P2 aggregation integration tests skipped.",
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
async def pg_pool():
|
|
import asyncpg
|
|
|
|
pool = await asyncpg.create_pool(
|
|
dsn=os.environ["PRAXIS_PG_DSN"], min_size=1, max_size=5, command_timeout=10,
|
|
)
|
|
try:
|
|
yield pool
|
|
finally:
|
|
await pool.close()
|
|
|
|
|
|
@pytest.fixture
|
|
async def pg_store(pg_pool):
|
|
from db.pg_migrate import apply_pg_migrations
|
|
from db.pg_store import PgStore
|
|
|
|
await apply_pg_migrations(pg_pool)
|
|
# Clean cohort_aggregates + operators for an isolated run.
|
|
async with pg_pool.acquire() as conn:
|
|
await conn.execute("DELETE FROM cohort_aggregates")
|
|
await conn.execute("DELETE FROM operators WHERE username = 'p2intop'")
|
|
await conn.execute("DELETE FROM issued_credentials")
|
|
return PgStore(pg_pool)
|
|
|
|
|
|
def _session(learner_ref: str, path: str = "customer_service",
|
|
outcome: str = "pass") -> dict:
|
|
return {
|
|
"learner_ref": learner_ref,
|
|
"path": path,
|
|
"scenario_id": f"{path}_v01",
|
|
"outcome": outcome,
|
|
"rubric_scores": [
|
|
{"criterion_id": "empathy", "score": 4.0},
|
|
{"criterion_id": "resolution", "score": 3.5},
|
|
],
|
|
"failure_mode": "missed_apology" if outcome == "fail" else None,
|
|
"branch_path": ["accept"],
|
|
"timestamp": _dt.datetime.now(_dt.timezone.utc).isoformat(),
|
|
}
|
|
|
|
|
|
async def _seed_and_aggregate(pg_store, sessions):
|
|
from server.cohort.hook import on_session_end
|
|
|
|
for s in sessions:
|
|
await on_session_end(pg_store, s)
|
|
|
|
|
|
async def _login_cookie(client, pg_store) -> None:
|
|
from server.auth.passwords import hash_password
|
|
|
|
op_id = await pg_store.insert_operator("p2intop", hash_password("pw"), "P2 Int")
|
|
# Login via the test client.
|
|
r = client.post("/api/operator/login", json={"username": "p2intop", "password": "pw"})
|
|
assert r.status_code == 200, r.text
|
|
|
|
|
|
def _make_client(pg_store):
|
|
from fastapi import FastAPI
|
|
from fastapi.testclient import TestClient
|
|
from starlette.middleware.sessions import SessionMiddleware
|
|
|
|
from server.auth.rate_limit import reset_login_rate_limit
|
|
from server.auth.routes import router as auth_router
|
|
from server.operator.cohort import router as cohort_router
|
|
from server.operator.credentials import router as credentials_router
|
|
from server.operator.failure_patterns import router as failure_router
|
|
from server.operator.mastery import router as mastery_router
|
|
|
|
reset_login_rate_limit()
|
|
app = FastAPI()
|
|
app.state.pg_store = pg_store
|
|
app.add_middleware(SessionMiddleware, secret_key="test-secret-1234567890abcdef")
|
|
app.include_router(auth_router)
|
|
app.include_router(cohort_router)
|
|
app.include_router(mastery_router)
|
|
app.include_router(failure_router)
|
|
app.include_router(credentials_router)
|
|
return TestClient(app)
|
|
|
|
|
|
# ── Main e2e test ─────────────────────────────────────────────────────────
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_aggregation_to_endpoint_e2e(pg_store):
|
|
"""12 distinct learners → non-suppressed; 5 distinct → suppressed."""
|
|
# 1. Seed 12 distinct learners across 15 sessions for 'customer_service'.
|
|
sessions = []
|
|
for i in range(12):
|
|
sessions.append(_session(f"learner-{i}", "customer_service", "pass"))
|
|
for i in range(3):
|
|
sessions.append(_session(f"learner-{i}", "customer_service", "fail"))
|
|
await _seed_and_aggregate(pg_store, sessions)
|
|
|
|
# 2. Seed 5 distinct learners for 'sales' (below threshold).
|
|
sales_sessions = [_session(f"sales-{i}", "sales", "pass") for i in range(5)]
|
|
await _seed_and_aggregate(pg_store, sales_sessions)
|
|
|
|
client = _make_client(pg_store)
|
|
with client:
|
|
await _login_cookie(client, pg_store)
|
|
|
|
# 3. GET /api/operator/cohort → non-suppressed for customer_service.
|
|
r = client.get("/api/operator/cohort")
|
|
assert r.status_code == 200, r.text
|
|
body = r.json()
|
|
paths = {v["path"] for v in body["views"]}
|
|
assert "customer_service" in paths
|
|
# 4. sales path cells should be suppressed (5 < 10).
|
|
sales_view = next((v for v in body["views"] if v["path"] == "sales"), None)
|
|
if sales_view:
|
|
suppressed = [c for c in sales_view["metrics"] if c["cell_suppressed"]]
|
|
assert suppressed, "sales (5 learners) must be suppressed"
|
|
|
|
# customer_service (12 learners) should have non-suppressed cells.
|
|
cs_view = next((v for v in body["views"] if v["path"] == "customer_service"), None)
|
|
assert cs_view is not None
|
|
non_suppressed = [c for c in cs_view["metrics"] if not c["cell_suppressed"]]
|
|
assert non_suppressed, "customer_service (12 learners) should have non-suppressed cells"
|
|
|
|
# 6. GET /api/operator/mastery
|
|
r = client.get("/api/operator/mastery")
|
|
assert r.status_code == 200
|
|
|
|
# 7. GET /api/operator/failure-patterns
|
|
r = client.get("/api/operator/failure-patterns")
|
|
assert r.status_code == 200
|
|
|
|
# 8. last_updated ≤ 24h (REQ-NFR-DASH-02)
|
|
if body.get("last_updated"):
|
|
ts = _dt.datetime.fromisoformat(body["last_updated"].replace("Z", "+00:00"))
|
|
age = _dt.datetime.now(_dt.timezone.utc) - ts
|
|
assert age < _dt.timedelta(hours=24), "freshness must be ≤ 24h"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_nightly_reconciliation_updates_last_updated(pg_store):
|
|
from server.cohort.nightly import NightlyScheduler
|
|
|
|
# Seed a few events via the aggregation hook first.
|
|
sessions = [_session(f"r-learner-{i}", "recon_path", "pass") for i in range(11)]
|
|
await _seed_and_aggregate(pg_store, sessions)
|
|
|
|
# Run nightly reconciliation.
|
|
sched = NightlyScheduler()
|
|
# mastery_gate_events is the source for nightly — seed a gate event.
|
|
async with pg_store.pool.acquire() as conn:
|
|
await conn.execute("DELETE FROM mastery_gate_events")
|
|
for i in range(11):
|
|
await conn.execute(
|
|
"INSERT INTO mastery_gate_events (learner_ref, scenario_id, path_id, "
|
|
"gate_outcome, rubric_scores_jsonb, source) "
|
|
"VALUES ($1, $2, $3, $4, $5::jsonb, 'sync')",
|
|
f"r-learner-{i}", "recon_v01", "recon_path", "open",
|
|
'[{"criterion_id":"empathy","score":4.0}]',
|
|
)
|
|
await sched.reconcile_now(pg_store)
|
|
|
|
client = _make_client(pg_store)
|
|
with client:
|
|
await _login_cookie(client, pg_store)
|
|
r = client.get("/api/operator/cohort")
|
|
assert r.status_code == 200
|
|
# last_updated should be very recent after reconciliation.
|
|
body = r.json()
|
|
if body.get("last_updated"):
|
|
ts = _dt.datetime.fromisoformat(body["last_updated"].replace("Z", "+00:00"))
|
|
age = _dt.datetime.now(_dt.timezone.utc) - ts
|
|
assert age < _dt.timedelta(minutes=1), "nightly reconcile should refresh last_updated"
|
|
|
|
|
|
# ── G-038 e2e: differencing-attack at the API layer ────────────────────────
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_g038_differencing_attack_api_layer(pg_store):
|
|
"""G-038: 10 learners in window A, 9 in window B. Verify GET /cohort
|
|
cannot isolate the dropped learner — window B is fully suppressed."""
|
|
# Window A: 10 learners on path 'diff_a'.
|
|
a_sessions = [_session(f"a-{i}", "diff_a", "pass") for i in range(10)]
|
|
await _seed_and_aggregate(pg_store, a_sessions)
|
|
|
|
# Window B: 9 learners on path 'diff_b' (learner a-9 dropped).
|
|
b_sessions = [_session(f"a-{i}", "diff_b", "pass") for i in range(9)]
|
|
await _seed_and_aggregate(pg_store, b_sessions)
|
|
|
|
client = _make_client(pg_store)
|
|
with client:
|
|
await _login_cookie(client, pg_store)
|
|
r = client.get("/api/operator/cohort")
|
|
assert r.status_code == 200
|
|
body_text = r.text
|
|
# The dropped learner's ref must not appear anywhere in the response.
|
|
assert "a-9" not in body_text, "dropped learner must not be isolatable via API"
|
|
|
|
# diff_b cells must all be suppressed (9 < 10).
|
|
body = r.json()
|
|
diff_b = next((v for v in body["views"] if v["path"] == "diff_b"), None)
|
|
assert diff_b is not None
|
|
for c in diff_b["metrics"]:
|
|
assert c["cell_suppressed"] is True, "window B (9 learners) must be fully suppressed"
|
|
assert c["value"] is None |