docs(milestone): complete v0.4-operator-tier — v0.1.9 tagged, milestone release, merged to main
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---
This commit is contained in:
@@ -0,0 +1,236 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user