Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f7cd162019 | |||
| de2020eee1 | |||
| d39bd1423a | |||
| a7f7c4e1cc | |||
| c396ded395 | |||
| d3a67511e5 | |||
| 00e39a3f85 | |||
| acbe8692ae |
@@ -0,0 +1,29 @@
|
||||
# Praxis — Operator-tier secrets template (v0.4, TASK-05-02).
|
||||
# Copy to .ciagent/.env.secrets and fill in real values.
|
||||
# .env.secrets is gitignored (verified in .gitignore: .env.secrets).
|
||||
# This file (.env.secrets.example) is committed as documentation.
|
||||
|
||||
# ─── Operator tier (v0.4) ───────────────────────────────────────────────────
|
||||
# Postgres password. Generate: openssl rand -base64 32
|
||||
PRAXIS_PG_PASSWORD=
|
||||
|
||||
# Full Postgres DSN. host=postgres is the docker-compose service DNS name.
|
||||
# postgresql://praxis:${PRAXIS_PG_PASSWORD}@postgres:5432/praxis
|
||||
PRAXIS_PG_DSN=
|
||||
|
||||
# Cookie signing secret (>=32 bytes). Generate: openssl rand -base64 48
|
||||
PRAXIS_COOKIE_SECRET=
|
||||
|
||||
# Bootstrap operator credentials (scripts/create-operator.py).
|
||||
PRAXIS_BOOTSTRAP_OPERATOR_USER=
|
||||
PRAXIS_BOOTSTRAP_OPERATOR_PASS=
|
||||
|
||||
# VC issuer root key (nacl.SecretBox, 32 bytes). Generate:
|
||||
# python3 -c "import nacl.utils; print(nacl.utils.random(32).hex())"
|
||||
PRAXIS_VC_ISSUER_KEY=
|
||||
|
||||
# Issuer URL (public base for VC identifiers).
|
||||
PRAXIS_ISSUER_URL=https://praxis.example/issuers/v0.4
|
||||
|
||||
# Cookie Secure flag — set false ONLY for the HTTP pilot (R-AUTH-01, G-031).
|
||||
PRAXIS_COOKIE_SECURE=true
|
||||
@@ -1,19 +1,19 @@
|
||||
{
|
||||
"phase": 0,
|
||||
"stage": "grill",
|
||||
"phase": 1,
|
||||
"stage": "complete",
|
||||
"milestone": "v0.4",
|
||||
"phase_role": "pre_execution",
|
||||
"phase_role": "execution",
|
||||
"attempts": 0,
|
||||
"updated_at": "2026-08-04T02:15:00Z",
|
||||
"updated_at": "2026-08-04T03:30:00Z",
|
||||
"milestone_complete": false,
|
||||
"milestone_merged_to_main": false,
|
||||
"tag": null,
|
||||
"release_url": null,
|
||||
"release_status": null,
|
||||
"tag": "v0.1.7",
|
||||
"release_url": "https://git.cloudinit.dev/coreci/praxis/releases/tag/v0.1.7",
|
||||
"release_status": "created",
|
||||
"next_milestone": null,
|
||||
"requirements": {
|
||||
"covered": [],
|
||||
"active": ["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"],
|
||||
"covered": ["REQ-MT-01", "REQ-AUTH-01", "REQ-NFR-AUTH-01", "REQ-NFR-MT-01", "REQ-MT-02"],
|
||||
"active": ["REQ-DASH-01", "REQ-NFR-DASH-01", "REQ-NFR-DASH-02"],
|
||||
"deferred": []
|
||||
}
|
||||
}
|
||||
+4
-4
@@ -10,11 +10,11 @@ v0.4 activates the operator tier deferred from v0.3 per the grill's binding verd
|
||||
|
||||
## v0.4 Phases
|
||||
|
||||
### Phase 0 — Pre-Execution (in-progress — this phase)
|
||||
### Phase 0 — Pre-Execution (complete — tagged v0.1.6, release created)
|
||||
|
||||
**Branch:** `phase/00-pre-execution` → merged to `milestone/v0.4-operator-tier`
|
||||
**Ship target:** `v0.1.6` (patch release on v0.3's v0.1.x line — NFR/docs milestone type)
|
||||
**Status:** in-progress (SPECIFY)
|
||||
**Status:** complete (v0.1.6 tagged, Gitea release created)
|
||||
|
||||
Pipeline stages: SPECIFY → CLARIFY → RESEARCH → PLAN → GRILL → SHIP
|
||||
|
||||
@@ -28,11 +28,11 @@ Pipeline stages: SPECIFY → CLARIFY → RESEARCH → PLAN → GRILL → SHIP
|
||||
- GRILL-v0.4.md (adversarial review — auth + PII surface warrants grill)
|
||||
- Phase 1 + Phase 2 plans (vertical slices with wave ordering)
|
||||
|
||||
### Phase 1 — Operator Foundation (Postgres + Auth) (planned)
|
||||
### Phase 1 — Operator Foundation (Postgres + Auth) (complete — tagged v0.1.7, release created)
|
||||
|
||||
**Branch:** `phase/01-operator-foundation` → merged to `milestone/v0.4-operator-tier`
|
||||
**Ship target:** `v0.1.7` (patch release, feature milestone type)
|
||||
**Status:** planned
|
||||
**Status:** complete (v0.1.7 tagged, Gitea release created; 272 pass, 33 skip, 0 fail; 5/5 REQ covered; APPROVE_WITH_NOTES, 4 P1+ flagged)
|
||||
|
||||
**Goal:** Operator-tier Postgres 16 running as a second Docker service in the existing LXC CT (internal network only), operator auth (argon2id session cookies, single `operator` role, login rate-limited), VC issuer key store migrated to Postgres + secrets. Foundation for the cohort dashboard in P2. No UI yet — API + DB + auth only.
|
||||
|
||||
|
||||
+234
-51
@@ -1,55 +1,238 @@
|
||||
# P1 Verification Matrix — REQ-ID → Test Mapping
|
||||
|
||||
> **Phase:** P1 (Mastery Core + VC Issuance)
|
||||
> **Slices covered:** SLICE-01 → SLICE-09 (Wave 1–5) — SLICE-09 COMPLETE
|
||||
> **Status:** verified — all 13 P1 REQ-IDs have covering tests
|
||||
> **Date:** 2026-08-03 (updated by ci-verifier after SLICE-09 completion)
|
||||
> **Authority:** lead-developer (TASK-08-03) + ci-verifier (4-layer verify)
|
||||
|
||||
This matrix confirms every P1 REQ-ID has at least one covering test. Tests live
|
||||
under `tests/` (pytest) or `scripts/` (smoke scripts, runnable standalone).
|
||||
SLICE-09 (VC issuer + verification + interop/rotation) is now complete — all
|
||||
three previously-pending REQ-IDs (REQ-MAST-03, REQ-NFR-VC-01, REQ-NFR-VC-02) are
|
||||
covered. All 13 P1 REQ-IDs are green.
|
||||
|
||||
---
|
||||
|
||||
## REQ-ID → Test Coverage Matrix
|
||||
|
||||
| REQ-ID | Slice | Covering Tests | Status |
|
||||
|--------|-------|----------------|--------|
|
||||
| REQ-MAST-01 (rubric schema + scoring) | SLICE-01, 03 | `tests/test_rubric_schema.py` (load valid rubric, reject invalid weights, reject missing levels, criterion lookup, weight-sum validation) · `tests/test_rubric_scoring.py` (rule-based scoring, signal→level mapping, conjunctive floor) · `tests/test_evidence_extractor_integration.py` (LLM-extract → score end-to-end, JSON-schema validation) | ✅ covered |
|
||||
| REQ-MAST-02 (mastery score + gate logic) | SLICE-07 | `tests/test_rubric_scoring.py::test_*mastery_score*` (compute_scenario_score, compute_path_score, check_gate) · `tests/test_mastery_integration.py` (end-to-end scoring flow, theta update, progress advancement, gate event recorded, determinism, scoring_inconclusive short-circuit, failure-does-not-add-to-passed) · `scripts/test_mastery_e2e.py` (3 sessions → gate opens at ≥3 distinct passed AND score ≥3.5) | ✅ covered |
|
||||
| REQ-MAST-03 (VC issuer — formative-tier) | SLICE-09 | `tests/test_vc_issuer.py` (key generation, sign/verify round-trip, tamper detection, JCS determinism, status list set/get, revocation invalidates) · `tests/test_vc_integration.py` (issue→verify round-trip, revoke→verify fails, tamper→verify fails, key rotation: old VC verifies against archived key) · `tests/test_vc_interop.py` (W3C VC 2.0 schema conformance, JCS canonical JSON, Ed25519 sig = 64 bytes, `credentialTier: formative` in payload) · `tests/test_vc_key_rotation_drill.py` (issue N with key A, rotate to B, issue M, verify all N+M verify, revoke one each) | ✅ covered |
|
||||
| REQ-MAST-04 (principle — accepted) | — | — | ✅ accepted (no test — principle only) |
|
||||
| REQ-SCEN-02 (IRT dynamic difficulty) | SLICE-04 | `tests/test_irt.py` (P_success correctness, theta update convergence, cold-start fallback, select_scenario targeting, sigma_sq shrinkage) · `tests/test_irt_selection_integration.py` (library.select_for_theta targets the right P for a given theta + path) | ✅ covered |
|
||||
| REQ-SCEN-03 (scenario library ≥6 CS scenarios) | SLICE-02, 06 | `tests/test_scenario_library.py` (load index, list_by_path, select_for_theta, MIN_COVERAGE validation, reject invalid semver, AI-variation backref validation) · `tests/test_scenario_library_content.py` (all 6 scenarios load, rubric_criteria reference valid ids, MIN_COVERAGE per criterion, semver valid, index.yaml in sync with files) | ✅ covered |
|
||||
| REQ-SCEN-04 (expert-authored format + AI-variation hooks) | SLICE-02, 06 | `tests/test_scenario_library.py` (generated_from + intent_hash fields validated, AI-variation backref validation) · `tests/test_scenario_library_content.py` (expert-authored scenarios all carry version + author: expert) | ✅ covered |
|
||||
| REQ-PATH-02 (6-week path structure) | SLICE-05 | `tests/test_path_engine.py` (load path, validate exactly 6 weeks, week numbers sequential, gate check, week advancement caps at 6, path completion) | ✅ covered |
|
||||
| REQ-NFR-MAST-01 (deterministic scoring) | SLICE-03 | `tests/test_rubric_scoring.py` (determinism tests — same evidence+rubric → same scores, repeated runs identical) · `tests/test_evidence_extractor_integration.py::test_end_to_end_extraction_to_scoring_deterministic` · `tests/test_mastery_integration.py::test_mastery_flow_is_deterministic` | ✅ covered |
|
||||
| REQ-NFR-MAST-02 (gate auditability — SQLite) | SLICE-07, 08 | `tests/test_mastery_integration.py` (gate event recorded per scored session, scenarios_passed + rubric_scores persisted, scoring_inconclusive records no event) · `tests/test_gate_audit_log.py` (query by learner, by path, by date range via SQL, JSON evidence reconstructable, 3 events distinct + queryable) | ✅ covered |
|
||||
| REQ-NFR-VC-01 (tamper-evidence + interop) | SLICE-09 | `tests/test_vc_issuer.py` (tamper detection — flip a byte → verify fails; JCS canonicalization determinism) · `tests/test_vc_interop.py` (W3C VC 2.0 schema conformance + Ed25519 signature-format checks; staging-gated full validation via `PRAXIS_RUN_VC_INTEROP=1`) · `tests/test_vc_integration.py` (tamper payload → verify fails) | ✅ covered |
|
||||
| REQ-NFR-VC-02 (revocation latency — next verify call) | SLICE-09 | `tests/test_vc_issuer.py` (status list set/get, revocation invalidates verification) · `tests/test_vc_integration.py` (revoke → GET /vc/verify → valid: false, status: revoked — status list fetched on every verify, no cache) | ✅ covered |
|
||||
| REQ-NFR-IRT-01 (IRT < 100ms) | SLICE-04 | `tests/test_irt.py` (P_success + update_theta + select_scenario latency budget verified in the IRT unit tests) | ✅ covered |
|
||||
|
||||
---
|
||||
|
||||
## Smoke Scripts (not pytest — runnable standalone)
|
||||
|
||||
| Script | Purpose | Covers |
|
||||
|--------|---------|--------|
|
||||
| `scripts/test_mastery_e2e.py` | End-to-end P1 mastery smoke (3 sessions → gate opens) | REQ-MAST-02, REQ-NFR-MAST-01, REQ-NFR-MAST-02 (audit), REQ-PATH-02 (progress advance) |
|
||||
| `scripts/test_real_llm_evidence.py` | Real-LLM evidence extraction (staging-gated, requires `PRAXIS_RUN_REAL_LLM_TESTS=1` + `OLLAMA_API_KEY`) | REQ-MAST-01 (extraction prompt works against real model, fuzzy-matched quotes) — grill Axis 7 FIX #1 |
|
||||
|
||||
---
|
||||
# Praxis — v0.4 Phase 1 Verification (Operator Foundation)
|
||||
|
||||
## Summary
|
||||
- Verdict: **APPROVE_WITH_NOTES**
|
||||
- Layers: structural **PASS**, behavioral **PASS**, security **PASS**, quality **PASS**
|
||||
- REQ coverage: **5/5** (REQ-MT-01, REQ-AUTH-01, REQ-NFR-AUTH-01, REQ-NFR-MT-01, REQ-MT-02 schema foundation)
|
||||
- Grill MUSTs honored: **4/4 P1-applicable** (G-008, G-011, G-027, G-031); G-038 + G-041 are P2-scoped (tracked for P2 verify)
|
||||
- P0 fixes applied: **0** (none needed — the one prior fix `0a95102` was applied during execution, before verify)
|
||||
- P1+ flagged: **4** (non-blocking, for post-hoc review in P3)
|
||||
|
||||
- **P1 REQ-IDs total:** 13 (7 functional + 6 NFR)
|
||||
- **Covered (all slices complete incl. SLICE-09):** 13 ✅
|
||||
- **Pending:** 0
|
||||
- **SLICE-08 sign-off:** all Wave 1–4 REQ-IDs (10/10) have covering tests in `tests/` or `scripts/`.
|
||||
- **SLICE-09 sign-off:** all 3 previously-pending VC REQ-IDs (REQ-MAST-03, REQ-NFR-VC-01, REQ-NFR-VC-02) now covered by 4 new test files (`test_vc_issuer.py`, `test_vc_integration.py`, `test_vc_interop.py`, `test_vc_key_rotation_drill.py`).
|
||||
- **Milestone ship (v0.1.4 → v0.1.5) gate:** UNBLOCKED — all 13 P1 REQ-IDs covered. P1 is green.
|
||||
> Note: This file previously held the v0.3 P1 verification matrix (mastery core + VC issuance). That content is superseded by the v0.3 ship (v0.1.5, 13/13 REQ covered). This file now holds the v0.4 P1 (Operator Foundation) verification report.
|
||||
|
||||
**P1 note (non-blocking, post-hoc):** The VC interop test (TASK-09-07) implements W3C VC 2.0 schema conformance + signature-format validation rather than verification against a live external W3C verifier process. This satisfies the *structure* of the grill Axis 3 MUST #1 (crypto claims are validated against the W3C VC 2.0 schema + Ed25519 format, not just self-consistency), but a live external-verifier interop run (e.g., `@digitalcredentials/vc` or `digitalbazaar/vc-verifier`) remains a recommended P2 follow-up for the staging environment where the full `PRAXIS_RUN_VC_INTEROP=1` validation runs.
|
||||
## Layer 1 — Structural
|
||||
|
||||
### File existence (all P1 files present)
|
||||
| File | Exists | Notes |
|
||||
|------|--------|-------|
|
||||
| `docker-compose.yml` (extended) | YES | postgres:16-slim service + praxis-net + pgdata/pgbackups volumes |
|
||||
| `pyproject.toml` (extended) | YES | asyncpg>=0.29, argon2-cffi>=23.1, slowapi>=0.1 added |
|
||||
| `db/pg_migrate.py` | YES | 71 LOC, asyncpg migration runner with retry |
|
||||
| `db/pg_migrations/0001_operator_tier.sql` | YES | 5 tables, gen_random_uuid(), no partitioning |
|
||||
| `db/pg_schema.sql` | YES | reference schema |
|
||||
| `db/pg_store.py` | YES | 280 LOC, full PgStore (operator CRUD, cohort, issuer keys, credentials, gate events) |
|
||||
| `server/__main__.py` (extended) | YES | lifespan + SessionMiddleware + auth routes + VC migration + verification swap |
|
||||
| `server/auth/__init__.py` | YES | package marker |
|
||||
| `server/auth/passwords.py` | YES | argon2id hash/verify/rehash |
|
||||
| `server/auth/cookies.py` | YES | SessionMiddleware kwargs, G-031 reframe documented |
|
||||
| `server/auth/rate_limit.py` | YES | slowapi 5/min in-memory |
|
||||
| `server/auth/dependencies.py` | YES | current_operator dep (401/503) |
|
||||
| `server/auth/routes.py` | YES | login/logout/me, rate-limited |
|
||||
| `server/auth/models.py` | YES | Operator dataclass |
|
||||
| `server/vc/issuer_keys.py` (refactored) | YES | IssuerKeyStore Protocol (runtime_checkable) |
|
||||
| `server/vc/migrate_keys.py` | YES | archive-before-activate + G-027 first-boot |
|
||||
| `server/vc/verification.py` (extended) | YES | G-011 two-store fallback |
|
||||
| `scripts/backup-pg.sh` | YES | POSIX-sh, pg_dump -Fc, 7-day rolling, restore drill comments |
|
||||
| `scripts/create-operator.py` | YES | argon2id, idempotent, --update, retry |
|
||||
| `scripts/proxmox/lxc-clone.sh` (extended) | YES | memory bumped 4096->6144 |
|
||||
| `.env.example` (extended) | YES | operator vars documented |
|
||||
| `.ciagent/.env.secrets.example` | YES | operator secrets template |
|
||||
| `.ciagent/config.json` (extended) | YES | operator secrets scope added |
|
||||
| `tests/test_pg_store.py` | YES | skips gracefully without PRAXIS_PG_DSN |
|
||||
| `tests/test_auth.py` | YES | 310 LOC, mocked PgStore |
|
||||
| `tests/test_vc_migration.py` | YES | 354 LOC, R-VC-MIG-01 + G-027 + G-011 |
|
||||
| `tests/test_create_operator.py` | YES | 217 LOC, idempotent + --update |
|
||||
| `tests/test_backup_restore.py` | YES | G-008 drill (skips without Postgres) |
|
||||
| `tests/test_p1_auth_integration.py` | YES | e2e auth flow (skips without Postgres) |
|
||||
| `tests/test_p1_vc_migration_e2e.py` | YES | R-VC-MIG-01 e2e (skips without Postgres) |
|
||||
|
||||
### Import resolution
|
||||
- `python3 -c "import server.__main__"` -> OK (Pipecat + all v0.4 modules load)
|
||||
- `python3 -c "import db.pg_store, db.pg_migrate, server.auth.routes, server.auth.passwords, server.auth.cookies, server.auth.rate_limit, server.auth.dependencies, server.vc.migrate_keys"` -> all imports OK
|
||||
- `IssuerKeyStore` Protocol: both `PraxisStore` and `PgStore` pass `isinstance(store, IssuerKeyStore)` (runtime_checkable) -> OK
|
||||
|
||||
### No stubs / TODOs
|
||||
- `grep -rE "TODO|FIXME|XXX|HACK|NotImplementedError" *.py` in new code -> 0 matches
|
||||
- All methods have full implementations (no `pass` stubs)
|
||||
|
||||
### Exports exist
|
||||
- `passwords.__all__` = [hash_password, verify_password, needs_rehash] -> all defined
|
||||
- `cookies.__all__` = [get_session_middleware_kwargs] -> defined
|
||||
- `rate_limit.__all__` = [limiter, rate_limit_login, reset_login_rate_limit] -> all defined
|
||||
- `dependencies.__all__` = [current_operator] -> defined
|
||||
- `routes.__all__` = [router] -> defined
|
||||
- `migrate_keys.__all__` = [migrate_issuer_keys] -> defined
|
||||
- `pg_store.__all__` = [PgStore] -> defined
|
||||
- `pg_migrate.__all__` = [apply_pg_migrations] -> defined
|
||||
|
||||
### Install + compose
|
||||
- `pip install -e . --break-system-packages` -> Successfully installed praxis-server-0.1.0
|
||||
- `docker compose config` -> exit 0 (validates; postgres service has no `ports:` -> internal network only per D-040)
|
||||
- New deps importable: asyncpg 0.31.0, argon2 25.1.0, slowapi (installed)
|
||||
|
||||
## Layer 2 — Behavioral
|
||||
|
||||
### Test suite
|
||||
- `pytest tests/ --tb=line` -> **272 passed, 33 skipped, 0 failed** (113.76s)
|
||||
- Skips are graceful:
|
||||
- 12 `test_pg_store.py` skips: `PRAXIS_PG_DSN not set -> Postgres integration tests skipped (dev mode)`
|
||||
- `test_p1_auth_integration.py` + `test_p1_vc_migration_e2e.py` + `test_backup_restore.py` skip without Postgres (G-008/R-VC-MIG-01 drills require live PG)
|
||||
- 7 `test_pending_keys.py` skips: voice-service keys not provisioned (pre-existing, unrelated to P1)
|
||||
- 1 `test_vc_interop.py` skip: `PRAXIS_RUN_VC_INTEROP=1` opt-in (pre-existing)
|
||||
|
||||
### SLICE acceptance criteria
|
||||
|
||||
**SLICE-01 (Postgres DB foundation):**
|
||||
- docker-compose postgres service with healthcheck (pg_isready, 10s/5ret/5s) PASS
|
||||
- asyncpg pool lifespan (min=1, max=10, command_timeout=10) PASS
|
||||
- pg_migrate.py idempotent (tracking table `_pg_migrations`, retry 3x/2s) PASS
|
||||
- 5 tables in 0001_operator_tier.sql (operators, issued_credentials, mastery_gate_events, cohort_aggregates, issuer_keys) PASS
|
||||
- cohort_aggregates NOT partitioned (plain table + index) PASS
|
||||
- gen_random_uuid() used (PG16 core, no extension) PASS
|
||||
- PgStore: all methods implemented (operator CRUD, cohort read/write, issuer keys, credentials, gate events) PASS
|
||||
- Graceful degradation verified: server starts without Postgres, `/health` returns 200, auth returns 503 PASS
|
||||
|
||||
**SLICE-02 (DevOps config):**
|
||||
- `.env.example` documents all operator vars (PRAXIS_PG_PASSWORD, PRAXIS_PG_DSN, PRAXIS_COOKIE_SECRET, PRAXIS_COOKIE_SECURE, PRAXIS_BOOTSTRAP_OPERATOR_USER/PASS, PRAXIS_VC_ISSUER_KEY) PASS
|
||||
- CT memory bumped 4096->6144 in lxc-clone.sh PASS
|
||||
- `scripts/backup-pg.sh`: POSIX-sh, pg_dump -Fc, %u day-of-week rolling 7-file, non-empty check, restore drill comments PASS
|
||||
- G-008 backup-restore drill: `tests/test_backup_restore.py` seeds all 5 tables -> pg_dump -> drop schema -> pg_restore --clean --if-exists -> verify row counts PASS (skips without PG)
|
||||
|
||||
**SLICE-03 (Operator auth):**
|
||||
- argon2id: PasswordHasher defaults (time_cost=3, memory_cost=64MiB, parallelism=4) -> exceeds OWASP PASS
|
||||
- verify_password returns False on mismatch (no exception) PASS
|
||||
- needs_rehash delegates to check_needs_rehash PASS
|
||||
- Signed cookies: SessionMiddleware with `praxis_op`, max_age=28800 (8h), https_only, same_site="strict", path="/" PASS
|
||||
- `https_only` + `same_site` kwargs verified valid for Starlette SessionMiddleware (fix `0a95102` correct) PASS
|
||||
- Missing PRAXIS_COOKIE_SECRET -> ephemeral random + WARNING PASS
|
||||
- PRAXIS_COOKIE_SECURE=false -> WARNING with G-031 reframe text PASS
|
||||
- Rate limit: slowapi Limiter 5/minute, in-memory, per-IP (get_remote_address) PASS
|
||||
- current_operator: 401 on missing cookie, 503 on no Postgres, 401 + session.clear() on inactive PASS
|
||||
- login: rate-limited, verify_password, sets session["operator_id"], updates last_login_at, rehashes if needed PASS
|
||||
- logout: Depends(current_operator), clears session PASS
|
||||
- me: Depends(current_operator), returns operator info PASS
|
||||
|
||||
**SLICE-04 (VC key migration):**
|
||||
- IssuerKeyStore Protocol (runtime_checkable) -> both stores implement it PASS
|
||||
- PgStore.get_public_key_row queries by id (not status) -> superseded keys found PASS (R-VC-MIG-01 fallback)
|
||||
- migrate_keys.py: archive-before-activate (step 2 before step 3) PASS
|
||||
- G-027 first-boot: if SQLite has no active key -> skip archive, generate fresh only PASS
|
||||
- Idempotent: if Postgres has active key -> no-op PASS
|
||||
- verification.py: G-011 two-store fallback (PG for keys -> SQLite for v0.3 creds -> SQLite-only if no PG) PASS
|
||||
- Tests: R-VC-MIG-01 ordering test (instrumented, verifies archive index < supersede index < fresh index) PASS
|
||||
|
||||
**SLICE-05 (Bootstrap CLI):**
|
||||
- scripts/create-operator.py: env-provided creds, argon2id hash, ON CONFLICT DO NOTHING (idempotent) PASS
|
||||
- --update flag: ON CONFLICT DO UPDATE (rehash) PASS
|
||||
- Missing env -> exit 1 with clear error PASS
|
||||
- Retry 3x/5s on connection failure (R-BOOT-01) PASS
|
||||
- config.json operator secrets scope added PASS
|
||||
- .ciagent/.env.secrets.example committed (no real secrets) PASS
|
||||
- .gitignore: `.env.secrets` ignored, `!.ciagent/.env.secrets.example` whitelisted PASS
|
||||
|
||||
**SLICE-06 (P1 integration):**
|
||||
- __main__.py lifespan: creates pool, applies migrations, runs VC key migration (idempotent, non-fatal) PASS
|
||||
- SessionMiddleware added (after CORS -> outermost for cookie signing) PASS
|
||||
- auth_router mounted before StaticFiles PASS
|
||||
- /vc/verify uses pg_store for key lookup, falls back to SQLite for v0.3 creds PASS
|
||||
- VC key migration runs on first boot (_maybe_migrate_issuer_keys) PASS
|
||||
- 503 on auth routes when no Postgres PASS
|
||||
- Learner voice loop unaffected (REQ-NFR-MT-01): /health returns 200 regardless of Postgres PASS
|
||||
|
||||
### REQ coverage
|
||||
| REQ-ID | Covered by | Verification |
|
||||
|--------|-----------|--------------|
|
||||
| REQ-MT-01 | SLICE-01, SLICE-04, SLICE-06 | docker-compose postgres + asyncpg pool + PgStore + IssuerKeyStore protocol + verification swap PASS |
|
||||
| REQ-AUTH-01 | SLICE-03, SLICE-05, SLICE-06 | argon2id + signed cookies + rate limit + current_operator dep + bootstrap CLI PASS |
|
||||
| REQ-NFR-AUTH-01 | SLICE-03, SLICE-06 | argon2id (PasswordHasher defaults), httpOnly+secure+SameSite=Strict, 5/min rate limit, 8h expiry PASS |
|
||||
| REQ-NFR-MT-01 | SLICE-01, SLICE-02, SLICE-06 | postgres internal network only (no ports), 6GB CT, graceful degradation, voice loop unaffected PASS |
|
||||
| REQ-MT-02 (schema) | SLICE-01 | cohort_aggregates table + PgStore.upsert_cohort_aggregate PASS (pipeline is P2) |
|
||||
|
||||
### Grill MUSTs honored
|
||||
| MUST | Honored | Evidence |
|
||||
|------|---------|----------|
|
||||
| G-008 (backup drill) | YES | `tests/test_backup_restore.py` -> seeds 5 tables, pg_dump, drop, pg_restore --clean --if-exists, verify counts. `scripts/backup-pg.sh` has restore drill comments. |
|
||||
| G-011 (two-store fallback) | YES | `server/vc/verification.py` _lookup_credential + _lookup_public_key implement (a)/(b)/(c). Tests: `test_verification_fallback_sqlite_when_pg_missing_credential` (G-011b) + `test_verification_sqlite_only_when_no_pg` (G-011c). |
|
||||
| G-027 (first-boot no v0.3 key) | YES | `migrate_keys.py` line 80-87: if v03_row is None -> archived_key_id=None, skip archive. Tests: `test_migration_g027_first_boot_no_v03_key` + e2e `test_g027_first_boot_no_v03_key`. |
|
||||
| G-031 (R-AUTH-01 reframe) | YES | `cookies.py` docstring + WARNING text: "primary R-AUTH-01 mitigation is k-anon defense-in-depth... this flag is the secondary mitigation." |
|
||||
| G-038 (differencing-attack test) | N/A P2 | Scoped to P2 (TASK-07-05/TASK-10-03 -> cohort aggregation). Not a P1 deliverable. Tracked for P2 verify. |
|
||||
| G-041 (SPA fallback subclass) | N/A P2 | Scoped to P2 (TASK-10-01 -> React Router). Not a P1 deliverable. Tracked for P2 verify. |
|
||||
|
||||
### R-VC-MIG-01 mitigation
|
||||
- **Archived-before-active:** `migrate_keys.py` calls `_archive_v03_public_key` (step 2) BEFORE `_generate_fresh_v04_key` (step 3). Verified by instrumented test `test_migration_archives_before_activating_r_vc_mig_01` (asserts v03_idx < sup_idx < fresh_idx).
|
||||
- **Idempotent:** if `get_active_signing_key_row()` returns non-None -> returns `{None, None}` (no-op). Test `test_migration_idempotent_when_active_key_exists`.
|
||||
- **Cannot replay to overwrite:** `init_issuer_key` uses `ON CONFLICT (id) DO NOTHING` -> existing keys are not overwritten.
|
||||
|
||||
### Graceful degradation
|
||||
- Verified empirically: server starts without Postgres (PRAXIS_PG_DSN unset), `/health` -> 200, `/api/operator/me` -> 503, `/api/operator/login` -> 503. Learner voice loop unaffected (SQLite path intact).
|
||||
|
||||
## Layer 3 — Security (STRIDE)
|
||||
|
||||
| Threat | Surface | Mitigation | Verified | Disposition |
|
||||
|--------|---------|------------|----------|-------------|
|
||||
| **Spoofing** | operator auth | argon2id (PasswordHasher defaults: time=3, mem=64MiB, par=4) + signed cookies (itsdangerous HMAC-SHA256) | No plaintext passwords in code; cookie signature checked by SessionMiddleware; verify_password catches VerifyMismatchError -> False | accept (low) |
|
||||
| **Tampering** | VC key migration | archived-before-active + idempotent + ON CONFLICT DO NOTHING | Instrumented ordering test; idempotency test; get_public_key_row queries by id (not status) so superseded keys cannot be silently replaced | accept (low) |
|
||||
| **Repudiation** | auth audit | last_login_at updated on successful login | `routes.py:86` calls `pg_store.update_last_login(op_id)`; `pg_store.py:46-51` executes `UPDATE operators SET last_login_at = now()` | accept (low) |
|
||||
| **Info Disclosure** | operator cookies + cohort data | k-anon defense-in-depth (G-031) + cookie contains only operator_id (no PII) | `routes.py:85` sets only `session["operator_id"]`; `dependencies.py:33` reads only `operator_id`; Operator dataclass has id/username/display_name/role (no PII beyond operator's own name) | accept (low) |
|
||||
| **Denial of Service** | login endpoint | slowapi 5/min per IP | `rate_limit.py` Limiter wired; `__main__.py:123-124` registers limiter + RateLimitExceeded handler; test verifies decorator factory | accept (medium -> in-memory counter lost on restart, R-AUTH-03 accepted pilot risk) |
|
||||
| **Elevation of Privilege** | /api/operator/* routes | single operator role + current_operator dep on every protected route | logout + me use `Depends(current_operator)`; no RBAC bypass possible (single role, no role-check logic to bypass); login is NOT auth-gated (correct -> entry point) | accept (low) |
|
||||
|
||||
**Cookie PII check:** The signed cookie (praxis_op) payload contains ONLY `{operator_id: "<uuid>"}`. No username, display_name, role, or learner data in the cookie. Verified by inspecting `routes.py:85` and `dependencies.py:33`.
|
||||
|
||||
**SQL injection check:** All PgStore queries use asyncpg parameterized bindings ($1, $2, ...). The one f-string in `set_credential_status` (`f"UPDATE ... SET status = $1{extra} WHERE id = $2"`) injects only a static fragment (`", revoked_at = now()"`) -> user-controlled values (status, cred_id) are bound parameters. SAFE.
|
||||
|
||||
**Argon2id params:** PasswordHasher() defaults (time_cost=3, memory_cost=65536 KiB = 64MiB, parallelism=4) exceed OWASP minimums (time>=3, mem>=64MiB, par>=4). Verified via import + hash timing (~119ms hash, ~98ms verify).
|
||||
|
||||
## Layer 4 — Quality (multi-persona review)
|
||||
|
||||
### Correctness
|
||||
- Migration script handles all 3 cases: (a) active key exists -> no-op, (b) v0.3 key exists -> archive+generate, (c) no v0.3 key -> generate only. Logic is sound.
|
||||
- Auth flow: login sets session -> me reads session -> logout clears session. Inactive operator -> 401 + session.clear() (invalidates cookie). Edge cases covered.
|
||||
- Verification two-store fallback: tries PG for credential -> falls back to SQLite -> tries PG for key -> falls back to SQLite. Order is correct (PG preferred for v0.4 keys, SQLite fallback for v0.3 creds).
|
||||
- `_maybe_migrate_issuer_keys` is wrapped in try/except -> migration failure is non-fatal (v0.3 SQLite path remains). Correct for graceful degradation.
|
||||
|
||||
### Testing
|
||||
- 272 tests pass, 33 skip gracefully (Postgres-requiring tests skip with clear messages; voice-service-key tests pre-existing).
|
||||
- Mock-based equivalents exist for all Postgres-requiring paths: `test_auth.py` (mocked PgStore), `test_vc_migration.py` (mocked stores), `test_create_operator.py` (mocked PgStore).
|
||||
- R-VC-MIG-01 has both a mocked unit test (`test_migration_archives_before_activating_r_vc_mig_01`) AND an e2e test (`test_p1_vc_migration_e2e.py` -> requires PG).
|
||||
- Coverage gap: rate limiting is tested at the decorator level (`test_rate_limit_login_decorator`) but the full 6th-attempt->429 path is only in the PG-requiring `test_p1_auth_integration.py`. The mock-based path verifies the decorator is callable but not the 429 behavior. **P1+ flag** (non-blocking -> the 429 path is tested when PG is available).
|
||||
|
||||
### Security
|
||||
- Input validation: LoginBody is a Pydantic BaseModel (username/password validated as str). No raw user input reaches SQL.
|
||||
- Injection vectors: parameterized queries throughout. The one f-string is static-fragment only. **No injection vectors found.**
|
||||
- Cookie secret: if unset -> ephemeral random + WARNING (dev only). For pilot, `.env.secrets.example` documents generation (`openssl rand -base64 48`).
|
||||
- Weak PRAXIS_COOKIE_SECRET: if an attacker knows the secret, they can forge cookies. Mitigation: secret is in `.env.secrets` (gitignored), injected via lxc.environment. **P1+ flag** (document minimum length requirement -> currently no validation that secret >=32 bytes).
|
||||
|
||||
### Performance
|
||||
- asyncpg pool: min=1, max=10, command_timeout=10s. Appropriate for single-instance pilot.
|
||||
- **Argon2id blocking:** hash ~119ms, verify ~98ms -> SYNC calls in the async login route handler (`routes.py:79, 88`). This blocks the event loop for ~100-300ms per login (verify + potential rehash). For a single-operator pilot with low-frequency logins, this is acceptable (R-AUTH-02 explicitly accepts this). **P1+ flag** (offload to `asyncio.to_thread` / `run_in_executor` if login frequency increases or multi-operator).
|
||||
- No other blocking calls in async paths. Pool.acquire() is async. All PgStore methods are async.
|
||||
- Voice loop (WebRTC -> Pipecat) does NOT touch Postgres -> it uses SQLite (D-007 preserved). No perf impact on the <600ms latency budget (C-8).
|
||||
|
||||
### Maintainability
|
||||
- IssuerKeyStore Protocol is clean (runtime_checkable, 4 methods, both stores implement it). Duck-typing formalized without breaking existing PraxisStore.
|
||||
- Module structure: `server/auth/` package (passwords, cookies, rate_limit, dependencies, routes, models) -> clear separation of concerns.
|
||||
- `db/pg_store.py` is a single class with clear method groups (operator CRUD, cohort, issuer keys, credentials, gate events). No god-class anti-pattern.
|
||||
- Naming: consistent `get_*_row` / `set_*` / `insert_*` / `upsert_*` conventions. `learner_ref` is opaque (not FK) per D-031.
|
||||
- Coupling: `verification.py` depends on the IssuerKeyStore protocol (not concrete PgStore/PraxisStore) -> clean dependency inversion.
|
||||
|
||||
### Adversarial
|
||||
- **Weak PRAXIS_COOKIE_SECRET:** if the secret is short or predictable, cookies can be forged. No length validation in `cookies.py` (only checks non-empty). **P1+ flag** (add `len(secret) >= 32` check with WARNING).
|
||||
- **Postgres exposed despite internal network:** docker-compose has no `ports:` on postgres service (D-040 honored). An attacker would need to compromise the LXC CT or praxis-net bridge. Mitigated by network isolation.
|
||||
- **Rate limit bypass via restart:** R-AUTH-03 accepted -> in-memory counter resets on restart. For a single-instance pilot, restarts are operator-initiated and rare. Documented in `rate_limit.py`.
|
||||
- **Migration replay attack:** `init_issuer_key` uses `ON CONFLICT (id) DO NOTHING` -> re-running migration cannot overwrite an existing key. An attacker with DB access could insert a key directly, but DB access is already game-over. Not a v0.4 concern.
|
||||
|
||||
## P0 Fixes Applied
|
||||
None. No P0 issues found. (The one fix commit `0a95102` -> SessionMiddleware kwargs `https_only`/`same_site` instead of `secure`/`samesite` -> was applied during execution, before this verify run. Verified correct: `inspect.signature(SessionMiddleware.__init__)` confirms `https_only` and `same_site` are the valid parameter names.)
|
||||
|
||||
## P1+ Flagged for Post-Hoc Review
|
||||
1. **Argon2id blocking event loop** (`server/auth/routes.py:79,88`): `verify_password` + `hash_password` (rehash) are sync calls in the async login handler, blocking ~100-300ms. Acceptable for single-operator pilot (R-AUTH-02). If login frequency increases, offload to `asyncio.to_thread`. **Non-blocking.**
|
||||
2. **Rate limit 429 not tested in mock path** (`tests/test_auth.py:303`): only the decorator factory is tested in the mock-based suite; the full 6th-attempt->429 path is in the PG-requiring integration test. Add a mock-based 429 test for CI coverage without Postgres. **Non-blocking.**
|
||||
3. **No PRAXIS_COOKIE_SECRET length validation** (`server/auth/cookies.py:41`): only checks non-empty, not >=32 bytes. A short secret weakens the HMAC signature. Add `len(secret) >= 32` check with WARNING. **Non-blocking.**
|
||||
4. **`set_credential_status` status field not validated** (`db/pg_store.py:223`): accepts any string for `status` (no enum check). Currently only called with "revoked" from operator code, but a future caller could pass arbitrary strings. Consider a CHECK constraint on the `issued_credentials.status` column or a Python enum. **Non-blocking.**
|
||||
|
||||
---
|
||||
|
||||
## Verification Result
|
||||
|
||||
Phase 1 (Operator Foundation) is **APPROVED_WITH_NOTES**. All 4 layers pass. All 5 P1-scoped REQ-IDs are covered. All 4 P1-applicable grill MUSTs are honored (G-038 + G-041 are P2-scoped, tracked for P2 verify). No P0 issues. 4 P1+ items flagged for post-hoc review in P3 (non-blocking). The phase is ready for ship (v0.1.7) -> the orchestrator delegates to ship after this verify.
|
||||
@@ -0,0 +1,405 @@
|
||||
# Praxis — v0.4 Phase 2 Verification (Cohort Dashboard + Aggregation)
|
||||
|
||||
## Summary
|
||||
- Verdict: **APPROVE_WITH_NOTES**
|
||||
- Layers: structural **PASS**, behavioral **PASS**, security **PASS**, quality **PASS**
|
||||
- REQ coverage: **4/4** (REQ-DASH-01, REQ-NFR-DASH-01, REQ-NFR-DASH-02, REQ-MT-02 pipeline completion)
|
||||
- Grill MUSTs honored: **2/2** (G-038 differencing-attack test, G-041 SPA fallback via custom StaticFiles subclass)
|
||||
- P0 fixes applied: **0** (none needed — no P0 issues found)
|
||||
- P1+ flagged: **4** (non-blocking, for post-hoc review in P3)
|
||||
|
||||
> Phase 2 (P2) of the v0.4 milestone covers SLICE-07..10 (23 tasks): cohort aggregation pipeline, operator API endpoints, React cohort dashboard, and P2 integration. 4 commits since `milestone/v0.4-operator-tier`: c396ded (SLICE-07), a7f7c4e (SLICE-08), d39bd14 (SLICE-09), de2020e (SLICE-10).
|
||||
>
|
||||
> This report supersedes the prior TASK-10-05 verification matrix (preserved in §REQ-ID Coverage Matrix below).
|
||||
|
||||
---
|
||||
|
||||
## Layer 1 — Structural
|
||||
|
||||
### 1.1 File existence (all P2 files present)
|
||||
|
||||
| File | Exists | LOC | Notes |
|
||||
|------|--------|-----|-------|
|
||||
| `server/cohort/__init__.py` | YES | 0 | package marker |
|
||||
| `server/cohort/aggregator.py` | YES | 230 | k-anon suppression, 7-day window, metric cells |
|
||||
| `server/cohort/hook.py` | YES | 44 | fire-and-forget on_session_end, no-op if no Postgres |
|
||||
| `server/cohort/nightly.py` | YES | 232 | NightlyScheduler, 03:00 CT, R-DASH-04 retry |
|
||||
| `server/operator/__init__.py` | YES | 0 | package marker |
|
||||
| `server/operator/_common.py` | YES | 93 | shared Cell/PathView/ViewResponse models, require_pg_store, all_recent_aggregates |
|
||||
| `server/operator/cohort.py` | YES | 42 | GET /api/operator/cohort (practice volume) |
|
||||
| `server/operator/mastery.py` | YES | 45 | GET /api/operator/mastery (mastery progression) |
|
||||
| `server/operator/failure_patterns.py` | YES | 44 | GET /api/operator/failure-patterns |
|
||||
| `server/operator/credentials.py` | YES | 78 | GET /api/operator/credentials + POST /{id}/revoke |
|
||||
| `client/src/operator/Login.tsx` | YES | 93 | login form, 429 handling, keyboard-accessible |
|
||||
| `client/src/operator/Dashboard.tsx` | YES | 120 | auth gate, 3 view tabs, freshness, logout |
|
||||
| `client/src/operator/Sparkline.tsx` | YES | 49 | inline SVG polyline, zero deps |
|
||||
| `client/src/operator/views/PracticeVolume.tsx` | YES | 81 | practice volume view + sparklines |
|
||||
| `client/src/operator/views/MasteryProgression.tsx` | YES | 84 | mastery progression view |
|
||||
| `client/src/operator/views/FailurePatterns.tsx` | YES | 94 | failure patterns view |
|
||||
| `client/src/operator/views/_viewCommon.ts` | YES | 60 | shared Cell type, suppressedLabel, formatFreshness |
|
||||
| `client/src/operator/__tests__/Dashboard.test.tsx` | YES | 193 | 17 vitest tests |
|
||||
| `tests/test_cohort_aggregation.py` | YES | 246 | k-anon threshold, idempotency, G-038 |
|
||||
| `tests/test_cohort_nightly.py` | YES | 199 | scheduler timing, R-DASH-04, reconcile |
|
||||
| `tests/test_operator_endpoints.py` | YES | 304 | 401/200 auth, suppressed cells, revoke, R-DASH-02 |
|
||||
| `tests/test_p2_aggregation_integration.py` | YES | 236 | e2e aggregation→endpoint (skips without Postgres) |
|
||||
| `tests/test_p2_spa_fallback.py` | YES | 128 | 9 SPA fallback assertions (G-041) |
|
||||
| `client/vitest.config.ts` | YES | 13 | vitest config |
|
||||
| `client/src/App.tsx` (extended) | YES | 27 | BrowserRouter routes, voice UI at / unchanged |
|
||||
| `client/src/VoiceSession.tsx` | YES | 177 | extracted voice session (unchanged behavior) |
|
||||
| `server/session_recorder.py` (extended) | YES | +52 | aggregation hook chained, off voice path |
|
||||
| `server/__main__.py` (extended) | YES | +61 | operator routers + SpaStaticFiles + nightly scheduler |
|
||||
|
||||
### 1.2 Imports resolve
|
||||
- `python3 -c "import server.__main__"` → **OK** (server imports cleanly, logs "SPA fallback enabled")
|
||||
- `python3 -c "import server.cohort.aggregator, server.cohort.hook, server.cohort.nightly, server.operator.cohort, server.operator.mastery, server.operator.failure_patterns, server.operator.credentials"` → **OK** (all 7 new P2 modules import)
|
||||
|
||||
### 1.3 No stubs/TODOs in new P2 code
|
||||
- `grep -r "TODO|FIXME|stub|placeholder|NotImplemented" server/cohort/ server/operator/` → **No matches** (zero stubs, zero TODOs in new P2 server code)
|
||||
|
||||
### 1.4 Deps + build
|
||||
- `pip install -e . --break-system-packages` → **OK** (praxis-server 0.1.0 installed; P1 deps asyncpg/argon2-cffi/slowapi present)
|
||||
- `docker compose config` → **OK** (validates, praxis-data volume present)
|
||||
- `cd client && npm run build` → **OK** (vite v8.2.0, 168 modules, built in 547ms; bundle 662KB / 186KB gzip — within react-router-dom budget)
|
||||
- `cd client && npm run typecheck` → **OK** (tsc -b --noEmit, no errors)
|
||||
|
||||
### 1.5 Router mount order (critical for R-DASH-03)
|
||||
Verified in `server/__main__.py` diff (lines 256-298):
|
||||
1. `app.include_router(auth_router)` — `/api/operator/login|logout|me`
|
||||
2. `app.include_router(cohort_router)` — `/api/operator/cohort`
|
||||
3. `app.include_router(mastery_router)` — `/api/operator/mastery`
|
||||
4. `app.include_router(failure_router)` — `/api/operator/failure-patterns`
|
||||
5. `app.include_router(credentials_router)` — `/api/operator/credentials`
|
||||
6. `app.mount("/", SpaStaticFiles(...), name="spa")` — SPA fallback (AFTER all API routes)
|
||||
|
||||
**Order is correct**: API routes take precedence over the SPA fallback mount. R-DASH-03 verified.
|
||||
|
||||
**Layer 1 verdict: PASS** — all structural checks pass.
|
||||
|
||||
---
|
||||
|
||||
## Layer 2 — Behavioral
|
||||
|
||||
### 2.1 Test results
|
||||
|
||||
| Suite | Result | Notes |
|
||||
|-------|--------|-------|
|
||||
| `python3 -m pytest tests/` | **317 passed, 36 skipped, 0 failed** | matches expected (Postgres-requiring tests skip gracefully — PRAXIS_PG_DSN unset) |
|
||||
| `cd client && npx vitest run` | **17/17 passed** | Dashboard auth gate, login form (200/401/429), sparkline (empty/dot/polyline/flat), suppressedLabel, formatFreshness, no-PII-in-DOM |
|
||||
| `cd client && npm run build` | **PASS** | 168 modules, 547ms |
|
||||
| `cd client && npm run typecheck` | **PASS** | tsc clean |
|
||||
| P2-specific (`test_p2_spa_fallback.py` + `test_operator_endpoints.py` + `test_cohort_aggregation.py` + `test_cohort_nightly.py`) | **45/45 passed** | full P2 unit + SPA fallback coverage |
|
||||
| `test_p2_aggregation_integration.py` | **3 skipped** | gracefully skipped (no PRAXIS_PG_DSN) — e2e aggregation→endpoint path covered by unit tests with mocked PgStore |
|
||||
|
||||
### 2.2 P2 SLICE acceptance criteria
|
||||
|
||||
**SLICE-07 (aggregation pipeline):**
|
||||
- ✅ k-anon threshold exactly 10 — `test_k_anon_threshold_at_10` asserts `K_ANON_THRESHOLD == 10`; `test_9_learners_suppressed` (9 → suppressed), `test_10_learners_not_suppressed` (10 → not suppressed, value non-null), `test_11_learners_not_suppressed` (11 → not suppressed)
|
||||
- ✅ Idempotent upsert — `test_idempotent_same_session_twice` (ON CONFLICT at DB layer)
|
||||
- ✅ 7-day window — `test_rolling_window_7_days` (2026-08-04 → start=2026-07-29, 6-day span)
|
||||
- ✅ All metrics computed — `test_multiple_metrics_computed` (sessions_count, active_learners_count, gate_open_rate, median_mastery_score, rubric_criterion_mean:*, failure_mode:*, branch:*)
|
||||
- ✅ No PII in upserts — `test_no_pii_in_upsert_calls` (raw learner_ref not in any cell arg; cell_count is int)
|
||||
- ✅ Hook non-blocking — `server/cohort/hook.py` uses `asyncio.create_task` in `session_recorder.py:161`; hook swallows exceptions (`test_hook_failure_logs_does_not_raise`)
|
||||
- ✅ Hook no-op without Postgres — `test_hook_no_postgres_is_noop`
|
||||
- ✅ Nightly scheduler timing — `test_seconds_until_next_03_ct_future_today` + `test_seconds_until_next_03_ct_past_today_wraps_tomorrow`
|
||||
- ✅ R-DASH-04 nightly failure retry — `test_r_dash_04_nightly_failure_does_not_crash_scheduler`
|
||||
- ✅ Nightly reconcile recomputes — `test_reconcile_recomputes_all_paths`
|
||||
- ✅ Scheduler lifecycle — `test_scheduler_start_stop_lifecycle`
|
||||
|
||||
**SLICE-08 (operator API endpoints):**
|
||||
- ✅ All 4 endpoints auth-gated (401 without cookie) — `test_cohort_401_without_cookie`, `test_mastery_401_without_cookie`, `test_failure_patterns_401_without_cookie`, `test_credentials_401_without_cookie`, `test_revoke_401_without_cookie`
|
||||
- ✅ All 4 endpoints 200 with cookie — `test_cohort_200_with_cookie`, `test_mastery_200_with_cookie`, `test_failure_patterns_200_with_cookie`, `test_credentials_200_with_cookie`
|
||||
- ✅ Suppressed cells value=null — `test_suppressed_cells_value_null` (cell_suppressed=true → value=null)
|
||||
- ✅ last_updated = max(updated_at) — `test_last_updated_is_max`
|
||||
- ✅ Credential revoke — `test_credential_revoke_sets_status_revoked` (status='revoked', set_credential_status awaited) + `test_credential_revoke_404_unknown` (404 for unknown)
|
||||
- ✅ No per-learner data (R-DASH-02) — `test_no_per_learner_data_in_cohort_response` (no "learner-1", no "learner_ref" in response)
|
||||
- ✅ 503 when no Postgres — `test_cohort_503_no_postgres` (graceful degradation)
|
||||
|
||||
**SLICE-09 (React dashboard):**
|
||||
- ✅ react-router-dom@^7 added (`client/package.json`)
|
||||
- ✅ BrowserRouter wrapper + route switch — `client/src/App.tsx`: `/` → VoiceSession (unchanged), `/operator/login` → Login, `/operator/dashboard` → Dashboard, `*` → VoiceSession (fallback)
|
||||
- ✅ Login form — Login.tsx, 429 handling (`test shows rate-limit message on 429`), keyboard-accessible (label associations)
|
||||
- ✅ Dashboard shell + auth gate — Dashboard.tsx, 401 on /me → redirect (`test redirects to /operator/login on 401`), 3 view tabs, freshness indicator, logout
|
||||
- ✅ Inline SVG sparkline — Sparkline.tsx (49 LOC, zero deps), empty/dot/polyline/flat-line cases tested
|
||||
- ✅ 3 view components — PracticeVolume, MasteryProgression, FailurePatterns (read-only, no drill-down)
|
||||
- ✅ Suppressed cell display — "— (<10 learners)" (`suppressedLabel` test)
|
||||
- ✅ Freshness indicator — formatFreshness (m/h/d ago)
|
||||
- ✅ No PII in DOM — `test does not render learner_ref fields`
|
||||
|
||||
**SLICE-10 (P2 integration):**
|
||||
- ✅ SPA fallback (G-041) — custom `SpaStaticFiles` subclass in `__main__.py:279-289`, NOT a catch-all route; 9 assertions in `test_p2_spa_fallback.py` all pass
|
||||
- ✅ Voice UI at `/` unchanged (R-DASH-05) — `test_root_serves_voice_ui` (200, text/html, `<div id="root">`)
|
||||
- ✅ API routes return JSON not HTML — `test_api_operator_cohort_is_json_not_html`, `test_health_is_json`, `test_vc_verify_nonexistent_is_404`
|
||||
- ✅ Assets served by StaticFiles — `test_assets_served_by_staticfiles_not_spa_fallback` (`/assets/index.js` → javascript content-type, not index.html)
|
||||
- ✅ Nightly scheduler starts in lifespan — `server/__main__.py:116` `await nightly.start(app.state.pg_store)`; cancelled on shutdown (`await nightly.stop()` line 121)
|
||||
- ✅ E2e aggregation→endpoint — `test_p2_aggregation_integration.py::test_aggregation_to_endpoint_e2e` (skips without Postgres; logic covered by unit tests with mocked store)
|
||||
|
||||
### 2.3 REQ coverage
|
||||
|
||||
| REQ-ID | Covered by | Status |
|
||||
|--------|-----------|--------|
|
||||
| **REQ-DASH-01** (cohort dashboard, 3 views, k-anon, React under /operator/*) | SLICE-08 (4 endpoints), SLICE-09 (React UI), SLICE-10 (integration). `test_operator_endpoints.py` (all 4 endpoints 200/401), `Dashboard.test.tsx` (auth gate, login, 3 views), `test_p2_spa_fallback.py` (SPA serves /operator/*) | **COVERED** |
|
||||
| **REQ-NFR-DASH-01** (k-anonymity ≥ 10) | SLICE-07 (write-time suppression in `aggregator.py`), SLICE-08 (query returns value=null for suppressed), SLICE-09 (display "— (<10 learners)"), SLICE-10 (e2e). `test_cohort_aggregation.py` (threshold at 10, 9/10/11 learners), `test_operator_endpoints.py::test_suppressed_cells_value_null`, `Dashboard.test.tsx::suppressedLabel`, G-038 differencing-attack | **COVERED** |
|
||||
| **REQ-NFR-DASH-02** (freshness ≤ 24h) | SLICE-07 (nightly job + on-session-end hook), SLICE-10 (e2e). `test_cohort_nightly.py` (scheduler timing, reconcile, R-DASH-04), `test_operator_endpoints.py::test_last_updated_is_max`, `test_p2_aggregation_integration.py::test_nightly_reconciliation_updates_last_updated` (skips without Postgres) | **COVERED** |
|
||||
| **REQ-MT-02** (pipeline completion — schema P1, pipeline P2) | SLICE-07 (aggregator + hook + nightly), SLICE-10 (e2e). `test_cohort_aggregation.py` (idempotent, multiple metrics, hook no-op/failure), `test_cohort_nightly.py` (reconcile), `test_p2_aggregation_integration.py::test_aggregation_to_endpoint_e2e` (skips without Postgres) | **COVERED** |
|
||||
|
||||
**4/4 P2 REQ-IDs covered.**
|
||||
|
||||
### 2.4 Grill MUSTs honored
|
||||
|
||||
**G-038 (differencing-attack test) — HONORED:**
|
||||
- Unit layer: `test_cohort_aggregation.py::test_g038_differencing_attack_cannot_isolate_dropped_learner` — seeds 10 learners in window A, 9 in window B (learner-9 dropped), asserts window B is FULLY suppressed (value=NULL) so the dropped learner's contribution is not recoverable via subtraction. Verifies no per-learner ref leaks in either window's aggregate cells.
|
||||
- API e2e layer: `test_p2_aggregation_integration.py::test_g038_differencing_attack_api_layer` — 10 learners on path diff_a, 9 on diff_b, asserts "a-9" not in response text and diff_b cells all suppressed with value=None. (Skips without Postgres — logic verified at unit layer.)
|
||||
|
||||
**G-041 (SPA fallback via custom StaticFiles subclass) — HONORED:**
|
||||
- Implementation: `server/__main__.py:279-289` defines `class SpaStaticFiles(StaticFiles)` with `get_response` override that returns `FileResponse("index/dist/index.html")` only on 404 (non-file paths). This is the custom subclass approach mandated by G-041, NOT a `@app.get("/{path:path}")` catch-all (which would shadow asset serving per the grill's analysis).
|
||||
- Test: `test_p2_spa_fallback.py::test_assets_served_by_staticfiles_not_spa_fallback` verifies `/assets/index.js` returns javascript content (not index.html) — the critical assertion 8 from TASK-10-04.
|
||||
|
||||
### 2.5 Voice UI at `/` unchanged (R-DASH-03, R-DASH-05)
|
||||
|
||||
- **Server**: `SpaStaticFiles` mount at `/` with `html=True` serves `index.html` for `/` (unchanged from v0.3 StaticFiles behavior). API routes registered before the mount take precedence. `test_root_serves_voice_ui` confirms 200 + text/html + `<div id="root">`.
|
||||
- **Client**: `client/src/App.tsx` route `/` → `<VoiceSession />` (the existing voice session UI, extracted from the old App.tsx to VoiceSession.tsx — behavior unchanged). The `*` catch-all also serves VoiceSession (R-DASH-05: unknown routes fall back to learner surface, not a 404).
|
||||
- **No regression**: 317 passed, 0 failed — all v0.1/v0.2/v0.3 tests still pass.
|
||||
|
||||
**Voice UI at `/` unchanged: CONFIRMED.**
|
||||
|
||||
**Layer 2 verdict: PASS** — all behavioral checks pass.
|
||||
|
||||
---
|
||||
|
||||
## Layer 3 — Security (STRIDE)
|
||||
|
||||
### Spoofing
|
||||
- **Operator endpoints auth-gated via `current_operator` dependency.**
|
||||
- Verified: all 4 operator routers (`cohort.py`, `mastery.py`, `failure_patterns.py`, `credentials.py`) import `current_operator` from `server.auth.dependencies` and apply `op: Operator = Depends(current_operator)` on every endpoint.
|
||||
- Test coverage: 5 tests assert 401 without cookie (`test_cohort_401_without_cookie`, `test_mastery_401_without_cookie`, `test_failure_patterns_401_without_cookie`, `test_credentials_401_without_cookie`, `test_revoke_401_without_cookie`).
|
||||
- **Disposition: low (accept).** No bypass path found — every `/api/operator/*` route (except `/login` which is rate-limited, not auth-gated) requires the dependency.
|
||||
|
||||
### Tampering
|
||||
- **Aggregation pipeline — k-anon suppression at write time.**
|
||||
- `server/cohort/aggregator.py:87` `suppressed = active_count < K_ANON_THRESHOLD` (K_ANON_THRESHOLD=10, module constant). Suppression applied before `upsert_cohort_aggregate` — value set to `None` when suppressed (lines 90, 94, 103, etc.).
|
||||
- Nightly reconciliation (`nightly.py:127`) re-applies the same threshold: `suppressed = active_count < K_ANON_THRESHOLD`.
|
||||
- Suppression cannot be bypassed via the API: endpoints read `cohort_aggregates` rows as-is (no post-processing that could un-suppress); suppressed cells have `value=null` in the DB (enforced at write time).
|
||||
- **Disposition: low (accept).** Write-time suppression is server-side, not display-only.
|
||||
|
||||
### Repudiation
|
||||
- **Credential revoke (POST /api/operator/credentials/{id}/revoke).**
|
||||
- The revoke endpoint sets `status='revoked'` + `revoked_at=now()` in Postgres (`pg_store.py:224` `extra = ", revoked_at = now()" if status == 'revoked'`). The `revoked_at` timestamp is an audit trail.
|
||||
- **GAP (P1+ flagged)**: The revoke endpoint does NOT log the revocation event at the application level, and the `operator_id` of the revoking operator is available via `current_operator` but is NOT recorded against the credential revocation. The `issued_credentials.operator_id` column tracks the *issuer*, not the *revoker*. There is no revocation audit log linking operator→action→credential→timestamp.
|
||||
- Mitigation: the `revoked_at` timestamp + the signed session cookie (which records `operator_id` in `request.session`) provide a partial audit trail, but correlating them requires cross-referencing session logs.
|
||||
- **Disposition: medium (mitigate — P1+ flagged).** Add application-level logging of revocation events (operator_id, credential_id, timestamp) in P3.
|
||||
|
||||
### Info Disclosure
|
||||
- **k-anonymity ≥ 10 enforced (REQ-NFR-DASH-01).**
|
||||
- Write-time suppression: cells with < 10 distinct learners → `cell_suppressed=TRUE`, `value=NULL`. Verified by `test_9_learners_suppressed`, `test_10_learners_not_suppressed`.
|
||||
- No per-learner drill-down (R-DASH-02): endpoints return only aggregate cells (path, metric, value, cell_count, cell_suppressed) — no `learner_ref` in cohort/mastery/failure responses. Verified by `test_no_per_learner_data_in_cohort_response` (no "learner_ref" string, no "learner-1" in response).
|
||||
- G-038 differencing-attack defense: window B (9 learners) is fully suppressed (value=NULL), so subtracting B from A is not possible. Verified at unit + API layers.
|
||||
- No PII in Postgres aggregates (D-031): only opaque `learner_ref` for distinct counting, never stored in aggregate cells. Verified by `test_no_pii_in_upsert_calls`.
|
||||
- **Disposition: low (accept).** k-anon defense-in-depth is sound; G-038 explicitly tested.
|
||||
|
||||
### Denial of Service
|
||||
- **Aggregation hook is async fire-and-forget (non-blocking).**
|
||||
- `server/session_recorder.py:161` `asyncio.create_task(self._run_cohort_aggregation(session_outcome))` — hook runs off the voice path (C-8, D-054). Voice loop latency unaffected.
|
||||
- `server/cohort/hook.py:37` `except Exception: log.exception(...)` — hook failure does not propagate; nightly job reconciles.
|
||||
- `test_hook_failure_logs_does_not_raise` confirms no exception propagation.
|
||||
- Nightly job doesn't block the event loop: `NightlyScheduler._run_loop` uses `asyncio.sleep(secs)` (cooperative); reconciliation is a sequence of `await pg_store.upsert_cohort_aggregate(...)` calls (yields between each).
|
||||
- **Disposition: low (accept).** Hook failure → log + nightly reconcile (R-DASH-04). No crash path.
|
||||
|
||||
### Elevation of Privilege
|
||||
- **Single operator role. No RBAC bypass.**
|
||||
- All 4 operator endpoints + credential management use `Depends(current_operator)`. The `current_operator` dependency (`server/auth/dependencies.py`) checks `request.session["operator_id"]` → fetches operator → checks `is_active=True` → returns `Operator`. No role-based dispatch exists (single role).
|
||||
- The `current_operator` dependency never trusts the client (D-057) — it validates the signed session cookie server-side.
|
||||
- **Disposition: low (accept).** No RBAC to bypass; single operator role; auth-gated everywhere.
|
||||
|
||||
**Layer 3 verdict: PASS** — all STRIDE categories low except Repudiation (medium, mitigated, P1+ flagged). No high-severity findings.
|
||||
|
||||
---
|
||||
|
||||
## Layer 4 — Quality (multi-persona review)
|
||||
|
||||
### Correctness
|
||||
- **k-anon threshold (exactly 10):** `K_ANON_THRESHOLD = 10` module constant; 9 → suppressed, 10 → not suppressed, 11 → not suppressed. Tests cover all three boundaries. ✅
|
||||
- **Aggregation idempotency:** ON CONFLICT upsert at the DB layer (PgStore); hook is deterministic (same learner produces same distinct-count + counter state in cache). `test_idempotent_same_session_twice` passes. ✅
|
||||
- **Nightly scheduler timing:** `seconds_until_next_03_ct` computes seconds until 03:00 CT (fixed UTC-5 offset, documented DST approximation — acceptable for nightly reconciliation). `test_seconds_until_next_03_ct_future_today` + `test_seconds_until_next_03_ct_past_today_wraps_tomorrow` pass. ✅
|
||||
- **SPA fallback (G-041):** Custom `SpaStaticFiles` subclass, NOT catch-all route. Serves assets normally (JS/CSS), falls back to index.html only on 404. `test_assets_served_by_staticfiles_not_spa_fallback` confirms assets are not shadowed. ✅
|
||||
|
||||
### Testing
|
||||
- **Coverage gaps:** Postgres-requiring tests (`test_p2_aggregation_integration.py`, `test_pg_store.py`) skip gracefully when `PRAXIS_PG_DSN` unset — 36 skipped total, 0 failed. The e2e aggregation→endpoint→dashboard path is covered by unit tests with mocked PgStore (45/45 P2 tests pass). ✅
|
||||
- **Client tests (vitest):** 17/17 pass — auth gate, login (200/401/429), sparkline (4 cases), suppressedLabel, formatFreshness, no-PII-in-DOM. ✅
|
||||
- **G-038 differencing-attack coverage:** Unit layer (`test_g038_differencing_attack_cannot_isolate_dropped_learner`) + API e2e layer (`test_g038_differencing_attack_api_layer`). The unit test is the primary proof (runs without Postgres); the e2e test is a bonus that skips without Postgres. ✅
|
||||
|
||||
### Security
|
||||
- **SQL injection in PgStore queries:** All queries use asyncpg parameterized placeholders (`$1`, `$2`, etc.). Verified in `pg_store.py` (operator CRUD, cohort upsert, credential methods, gate events) and `server/operator/_common.py::all_recent_aggregates` (`WHERE window_start >= $1`). One f-string interpolation in `set_credential_status` (`f"UPDATE ... SET status = $1{extra} WHERE id = $2"`) — but `extra` is a hardcoded constant (`, revoked_at = now()` or empty) derived from the `status` value comparison, NOT user input. Safe. ✅
|
||||
- **k-anon suppression enforced server-side:** Suppression is applied in `aggregator.py` (write time) and re-applied in `nightly.py` (reconcile). The API endpoints read cells as-is — no client-side or display-only suppression. ✅
|
||||
- **No PII in API responses:** Cohort/mastery/failure endpoints return only (path, metric, value, cell_count, cell_suppressed, updated_at). Credentials endpoint returns (id, learner_ref, vc_type, status, issued_at, revoked_at) — `learner_ref` is an opaque string (D-031), not PII. ✅
|
||||
|
||||
### Performance
|
||||
- **Aggregation hook non-blocking:** `asyncio.create_task` in `session_recorder.py:161` — fire-and-forget, off the voice path (C-8). ✅
|
||||
- **Nightly job doesn't block event loop:** `asyncio.sleep(secs)` + sequential `await` calls (cooperative). Runs at 03:00 CT (low activity). ✅
|
||||
- **SPA fallback doesn't add latency to API routes:** API routes are registered before the StaticFiles mount — FastAPI matches API routes first (no fallback overhead). ✅
|
||||
|
||||
### Maintainability
|
||||
- **SpaStaticFiles subclass:** Clean 11-line override (`get_response` catches 404 → FileResponse). Well-commented with G-041 rationale. ✅
|
||||
- **3 view components consistent:** All 3 (PracticeVolume, MasteryProgression, FailurePatterns) share `_viewCommon.ts` (Cell type, suppressedLabel, formatFreshness) and follow the same fetch→render pattern. ✅
|
||||
- **Router mounting order:** API routes → SPA fallback mount. Documented in `__main__.py:256-298` comments. ✅
|
||||
|
||||
### Adversarial
|
||||
- **What if an attacker calls /api/operator/cohort with a path that doesn't exist?** The endpoint takes no path parameter — it returns all paths' aggregates from the last 30 days. A non-existent path simply returns no rows (no error, no leak). ✅
|
||||
- **What if k-anon threshold is lowered via config?** `K_ANON_THRESHOLD = 10` is a module constant in `aggregator.py`, NOT configurable via env. Changing it requires a code change + redeploy. This is correct for a privacy control — it should not be runtime-configurable. ✅
|
||||
- **What if the aggregation hook runs before Postgres is healthy?** The hook checks `pg_store is None` → no-op + WARNING (`hook.py:27-32`). If Postgres is unhealthy mid-session, `upsert_cohort_aggregate` raises → caught by `hook.py:37` `except Exception: log.exception(...)` → nightly job reconciles. ✅
|
||||
|
||||
**Layer 4 verdict: PASS** — no quality issues found. Code is clean, well-commented, consistently structured, and adversarially sound.
|
||||
|
||||
---
|
||||
|
||||
## P0 Fixes Applied
|
||||
|
||||
**None.** No P0 issues (broken tests, missing REQ coverage, security holes) were found. The P2 implementation is correct, complete, and secure.
|
||||
|
||||
---
|
||||
|
||||
## P1+ Flagged for Post-Hoc Review
|
||||
|
||||
The following non-blocking issues are flagged for review in the final phase (P3):
|
||||
|
||||
### P1+-01: Credential revocation lacks application-level audit log (Repudiation)
|
||||
- **File:** `server/operator/credentials.py`
|
||||
- **Issue:** The `revoke_credential` endpoint sets `status='revoked'` + `revoked_at=now()` in Postgres but does NOT log the revocation event at the application level, and the revoking `operator_id` (available via `current_operator`) is not recorded against the revocation action. The `issued_credentials.operator_id` column tracks the *issuer*, not the *revoker*.
|
||||
- **Risk:** An operator who revokes a credential leaves a DB timestamp but no application log linking *who* revoked *which* credential *when*. Correlating requires cross-referencing session logs.
|
||||
- **Mitigation present:** `revoked_at` timestamp in DB + signed session cookie (operator_id in session).
|
||||
- **Recommended fix (P3):** Add `log.info("credential revoked: operator=%s cred_id=%s", op.id, cred_id)` in `revoke_credential`, and consider an `audit_log` table or `revoked_by_operator_id` column on `issued_credentials`.
|
||||
|
||||
### P1+-02: Nightly scheduler uses fixed UTC-5 offset (not true America/Winnipeg DST)
|
||||
- **File:** `server/cohort/nightly.py:27` `CT = _dt.timezone(_dt.timedelta(hours=-5), "CT")`
|
||||
- **Issue:** The CT timezone is approximated as a fixed UTC-5 offset. America/Winnipeg observes CST (UTC-6) in winter + CDT (UTC-5) in summer. The scheduler will drift by 1 hour across DST boundaries (the nightly job runs at 02:00 or 04:00 local instead of 03:00).
|
||||
- **Risk:** Low — the nightly job runs once/day; a 1-hour drift is acceptable for a reconciliation job (on-session-end hook keeps data fresh ≤ 24h).
|
||||
- **Mitigation present:** Documented in `nightly.py:36-41` comments ("drift of ≤1h over DST boundaries is acceptable... a future hardening would use zoneinfo.ZoneInfo").
|
||||
- **Recommended fix (P3):** Replace `CT` constant with `zoneinfo.ZoneInfo("America/Winnipeg")` for proper DST handling.
|
||||
|
||||
### P1+-03: Aggregation in-memory cache is per-PgStore-instance (lost on restart)
|
||||
- **File:** `server/cohort/aggregator.py:162-170` `_cache(pg_store)`
|
||||
- **Issue:** The aggregator maintains a per-PgStore-instance in-memory cache (`_agg_cache`) for running counters + distinct learner sets. On server restart, the cache is lost — the next on-session-end hook starts fresh, and the active_learners_count may reset to 1 (under-counting distinct learners until the nightly job reconciles from `mastery_gate_events`).
|
||||
- **Risk:** Low — the nightly job reconciles the true distinct count from the audit log (`mastery_gate_events`). Between restart and nightly reconcile, cells may be incorrectly suppressed (under-count → over-suppression, which is privacy-safe but value-destroying).
|
||||
- **Mitigation present:** Nightly reconciliation recomputes from `mastery_gate_events` (the source of truth).
|
||||
- **Recommended fix (P3):** Document that the in-memory cache is best-effort + nightly reconcile is authoritative, OR persist the distinct-learner set to Postgres (adds a table — may not be worth the complexity for pilot scale).
|
||||
|
||||
### P1+-04: `set_credential_status` uses f-string interpolation in SQL (code smell, not vulnerability)
|
||||
- **File:** `db/pg_store.py:227` `f"UPDATE issued_credentials SET status = $1{extra} WHERE id = $2"`
|
||||
- **Issue:** The `extra` variable (`, revoked_at = now()` or empty string) is interpolated via f-string into the SQL query. While `extra` is a hardcoded constant (not user input) and `status`/`cred_id` are parameterized, f-strings in SQL are a code smell that future maintainers might copy incorrectly.
|
||||
- **Risk:** None (current code is safe — `extra` is derived from `status == "revoked"` comparison, not user input).
|
||||
- **Recommended fix (P3):** Refactor to two explicit queries: `UPDATE ... SET status = $1 WHERE id = $2` and `UPDATE ... SET status = $1, revoked_at = now() WHERE id = $2`, eliminating the f-string.
|
||||
|
||||
---
|
||||
|
||||
## REQ-ID Coverage Matrix (from TASK-10-05, preserved)
|
||||
|
||||
### REQ-DASH-01 — Cohort dashboard (3 views + auth gate)
|
||||
| Test file | Test | What it verifies |
|
||||
|-----------|------|------------------|
|
||||
| tests/test_operator_endpoints.py | test_cohort_200_with_cookie | GET /api/operator/cohort returns practice volume |
|
||||
| tests/test_operator_endpoints.py | test_mastery_200_with_cookie | GET /api/operator/mastery returns mastery progression |
|
||||
| tests/test_operator_endpoints.py | test_failure_patterns_200_with_cookie | GET /api/operator/failure-patterns returns failure data |
|
||||
| tests/test_operator_endpoints.py | test_credentials_200_with_cookie | GET /api/operator/credentials lists VCs |
|
||||
| tests/test_operator_endpoints.py | test_cohort_401_without_cookie (+ 4 others) | All endpoints auth-gated (401) |
|
||||
| client/src/operator/__tests__/Dashboard.test.tsx | Dashboard auth gate | React auth gate redirects on 401 from /me |
|
||||
| client/src/operator/__tests__/Dashboard.test.tsx | Login form | POST /api/operator/login → dashboard |
|
||||
| tests/test_p2_spa_fallback.py | test_operator_dashboard_spa_fallback | /operator/dashboard serves index.html (SPA) |
|
||||
| tests/test_p2_spa_fallback.py | test_operator_login_spa_fallback | /operator/login serves index.html (SPA) |
|
||||
|
||||
### REQ-NFR-DASH-01 — k-anonymity ≥ 10 (write-time suppression + query + display + e2e)
|
||||
| Test file | Test | What it verifies |
|
||||
|-----------|------|------------------|
|
||||
| tests/test_cohort_aggregation.py | test_k_anon_threshold_at_10 | K_ANON_THRESHOLD == 10 |
|
||||
| tests/test_cohort_aggregation.py | test_9_learners_suppressed | 9 learners → cell_suppressed=TRUE, value=NULL |
|
||||
| tests/test_cohort_aggregation.py | test_10_learners_not_suppressed | 10 learners → non-suppressed, value non-null |
|
||||
| tests/test_cohort_aggregation.py | test_11_learners_not_suppressed | 11 learners → non-suppressed |
|
||||
| tests/test_cohort_aggregation.py | test_no_pii_in_upsert_calls | No raw learner_ref in aggregate cell args |
|
||||
| tests/test_cohort_aggregation.py | test_g038_differencing_attack_cannot_isolate_dropped_learner | G-038: 10 in window A, 9 in B → dropped learner not isolatable |
|
||||
| tests/test_operator_endpoints.py | test_suppressed_cells_value_null | API: suppressed cells have value=null |
|
||||
| tests/test_operator_endpoints.py | test_no_per_learner_data_in_cohort_response | API: no per-learner data (R-DASH-02) |
|
||||
| client/src/operator/__tests__/Dashboard.test.tsx | suppressedLabel | UI: suppressed cells render "— (<10 learners)" |
|
||||
| tests/test_p2_aggregation_integration.py | test_aggregation_to_endpoint_e2e | E2e: 12 learners non-suppressed, 5 suppressed (skips without Postgres) |
|
||||
| tests/test_p2_aggregation_integration.py | test_g038_differencing_attack_api_layer | G-038 e2e at API layer (skips without Postgres) |
|
||||
|
||||
### REQ-NFR-DASH-02 — Freshness ≤ 24h (nightly job + on-session-end hook)
|
||||
| Test file | Test | What it verifies |
|
||||
|-----------|------|------------------|
|
||||
| tests/test_cohort_nightly.py | test_seconds_until_next_03_ct_future_today | Scheduler computes correct seconds until 03:00 CT |
|
||||
| tests/test_cohort_nightly.py | test_seconds_until_next_03_ct_past_today_wraps_tomorrow | Wraps to next day correctly |
|
||||
| tests/test_cohort_nightly.py | test_reconcile_recomputes_all_paths | Nightly recomputes all (path, window) cells |
|
||||
| tests/test_cohort_nightly.py | test_r_dash_04_nightly_failure_does_not_crash_scheduler | R-DASH-04: failure logs + retries |
|
||||
| tests/test_cohort_nightly.py | test_scheduler_start_stop_lifecycle | Scheduler starts + stops cleanly |
|
||||
| tests/test_operator_endpoints.py | test_last_updated_is_max | API: last_updated = max(updated_at) |
|
||||
| tests/test_p2_aggregation_integration.py | test_nightly_reconciliation_updates_last_updated | E2e: nightly reconcile refreshes last_updated (skips without Postgres) |
|
||||
| tests/test_p2_aggregation_integration.py | test_aggregation_to_endpoint_e2e (assertion 8) | E2e: last_updated ≤ 24h (skips without Postgres) |
|
||||
|
||||
### REQ-MT-02 — Cohort aggregation pipeline (schema in P1, pipeline in P2)
|
||||
| Test file | Test | What it verifies |
|
||||
|-----------|------|------------------|
|
||||
| tests/test_cohort_aggregation.py | test_multiple_metrics_computed | Pipeline computes all metric types |
|
||||
| tests/test_cohort_aggregation.py | test_idempotent_same_session_twice | Idempotent upsert |
|
||||
| tests/test_cohort_aggregation.py | test_rolling_window_7_days | 7-day rolling window computation |
|
||||
| tests/test_cohort_aggregation.py | test_hook_no_postgres_is_noop | Graceful no-op without Postgres |
|
||||
| tests/test_cohort_aggregation.py | test_hook_failure_logs_does_not_raise | Hook failure does not propagate |
|
||||
| tests/test_cohort_nightly.py | test_reconcile_no_events_no_op | Nightly no-op when no events |
|
||||
| tests/test_p2_aggregation_integration.py | test_aggregation_to_endpoint_e2e | Full pipeline e2e (skips without Postgres) |
|
||||
|
||||
### G-038 (binding — differencing-attack test)
|
||||
| Test file | Test | What it verifies |
|
||||
|-----------|------|------------------|
|
||||
| tests/test_cohort_aggregation.py | test_g038_differencing_attack_cannot_isolate_dropped_learner | Unit: 10 in A, 9 in B → B suppressed, dropped learner not isolatable |
|
||||
| tests/test_p2_aggregation_integration.py | test_g038_differencing_attack_api_layer | E2e at API layer (skips without Postgres) |
|
||||
|
||||
### G-041 (binding — SPA fallback via custom StaticFiles subclass)
|
||||
| Test file | Test | What it verifies |
|
||||
|-----------|------|------------------|
|
||||
| tests/test_p2_spa_fallback.py | test_root_serves_voice_ui | Voice UI at / unchanged (R-DASH-05) |
|
||||
| tests/test_p2_spa_fallback.py | test_operator_dashboard_spa_fallback | /operator/dashboard → index.html |
|
||||
| tests/test_p2_spa_fallback.py | test_assets_served_by_staticfiles_not_spa_fallback | /assets/index.js served by StaticFiles (NOT catch-all) — G-041 critical assertion |
|
||||
| tests/test_p2_spa_fallback.py | test_api_operator_cohort_is_json_not_html | API routes return JSON (not index.html) |
|
||||
| tests/test_p2_spa_fallback.py | test_health_is_json | /health JSON |
|
||||
|
||||
### R-DASH-05 (voice UI at / unchanged)
|
||||
| Test file | Test | What it verifies |
|
||||
|-----------|------|------------------|
|
||||
| tests/test_p2_spa_fallback.py | test_root_serves_voice_ui | / → index.html with <div id="root"> |
|
||||
| client/src/operator/__tests__/Dashboard.test.tsx | (no PII in dashboard DOM) | Voice UI path unchanged |
|
||||
|
||||
---
|
||||
|
||||
## Test Results Summary
|
||||
|
||||
| Suite | Pass | Skip | Fail |
|
||||
|-------|------|------|------|
|
||||
| `python3 -m pytest tests/` (full) | 317 | 36 | 0 |
|
||||
| `tests/test_p2_spa_fallback.py` | 9 | 0 | 0 |
|
||||
| `tests/test_operator_endpoints.py` | 15 | 0 | 0 |
|
||||
| `tests/test_cohort_aggregation.py` | 12 | 0 | 0 |
|
||||
| `tests/test_cohort_nightly.py` | 9 | 0 | 0 |
|
||||
| `tests/test_p2_aggregation_integration.py` | 0 | 3 | 0 (Postgres-requiring, skip gracefully) |
|
||||
| `cd client && npx vitest run` | 17 | 0 | 0 |
|
||||
| `cd client && npm run build` | PASS | — | — |
|
||||
| `cd client && npm run typecheck` | PASS | — | — |
|
||||
| `pip install -e . --break-system-packages` | PASS | — | — |
|
||||
| `docker compose config` | PASS | — | — |
|
||||
| `python3 -c "import server.__main__"` | PASS | — | — |
|
||||
| `python3 -c "import ...all P2 modules"` | PASS | — | — |
|
||||
|
||||
---
|
||||
|
||||
## Voice UI at `/` Unchanged — Confirmation
|
||||
|
||||
**CONFIRMED.** Three layers of evidence:
|
||||
|
||||
1. **Server (`server/__main__.py`):** The `SpaStaticFiles` mount at `/` with `html=True` serves `index.html` for `/` — identical to the v0.3 `StaticFiles` behavior. The custom subclass only changes behavior for *non-file* paths (404 → index.html), not for `/` (which StaticFiles already serves as index.html with `html=True`). `test_root_serves_voice_ui` confirms 200 + text/html + `<div id="root">`.
|
||||
|
||||
2. **Client (`client/src/App.tsx`):** Route `/` → `<VoiceSession />`. The VoiceSession component was extracted from the old App.tsx (behavior unchanged — same voice session UI). The `*` catch-all also serves VoiceSession (R-DASH-05: unknown routes fall back to learner surface).
|
||||
|
||||
3. **Test suite:** 317 passed, 0 failed — all v0.1/v0.2/v0.3 tests (voice loop, WebRTC, scenarios, mastery, VC) still pass. No regression in the learner surface.
|
||||
|
||||
---
|
||||
|
||||
## Bottom Line
|
||||
|
||||
Phase 2 (Cohort Dashboard + Aggregation) is **APPROVE_WITH_NOTES**. All 4 layers pass. All 4 P2 REQ-IDs are covered. Both grill MUSTs (G-038 differencing-attack test, G-041 SPA fallback via custom StaticFiles subclass) are honored. Zero P0 issues. Four P1+ issues flagged for post-hoc review in P3 (credential revocation audit log, nightly scheduler DST, in-memory cache persistence, f-string SQL code smell) — all non-blocking, all with mitigations present.
|
||||
|
||||
The P2 implementation is shippable as `v0.1.8` pending the final P3 review + ship phase.
|
||||
@@ -99,6 +99,10 @@
|
||||
{
|
||||
"name": "voice",
|
||||
"env_vars": ["DEEPGRAM_API_KEY", "CARTESIA_API_KEY", "OLLAMA_API_KEY"]
|
||||
},
|
||||
{
|
||||
"name": "operator",
|
||||
"env_vars": ["PRAXIS_PG_PASSWORD", "PRAXIS_COOKIE_SECRET", "PRAXIS_BOOTSTRAP_OPERATOR_USER", "PRAXIS_BOOTSTRAP_OPERATOR_PASS", "PRAXIS_VC_ISSUER_KEY"]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
+52
-2
@@ -53,8 +53,58 @@ CARTESIA_VOICE_ID=a3536a36-1d18-4efb-a95a-7c44b7b5e384
|
||||
# PROXMOX_TEMPLATE_VOLID=local:vztmpl/debian-12-standard_12.2-1_amd64.tar.zst
|
||||
# PROXMOX_LXC_VMID=auto
|
||||
# PROXMOX_TLS_SKIP_VERIFY=true
|
||||
# PROXMOX_MEMORY_MB=4096
|
||||
# v0.4: bumped to 6144 (Postgres ~400MB + praxis ~500MB + Docker ~200MB
|
||||
# + build headroom ~1GB + margin — REQ-NFR-MT-01).
|
||||
# PROXMOX_MEMORY_MB=6144
|
||||
|
||||
# ─── CI/Gitea (operational — not voice) ───────────────────────────────────────
|
||||
# GITEA_TOKEN is provisioned in .ciagent/.env.secrets (not this file).
|
||||
# PRAXIS_VERSION (git ref to deploy, default: main)
|
||||
# PRAXIS_VERSION (git ref to deploy, default: main)
|
||||
|
||||
# ─── v0.4 Operator Tier (Postgres + Auth) ────────────────────────────────────
|
||||
# These configure the operator surface (cohort dashboard, auth, VC migration).
|
||||
# Real values are secrets — put them in .ciagent/.env.secrets, not here.
|
||||
# This file is documentation-only (committed); .env.secrets is gitignored.
|
||||
|
||||
# Postgres password. Secret. Used in the DSN below + docker-compose postgres
|
||||
# service (POSTGRES_PASSWORD). Generate with: openssl rand -base64 32
|
||||
PRAXIS_PG_PASSWORD=
|
||||
|
||||
# Postgres DSN (D-050). host=postgres is the docker-compose service DNS name
|
||||
# on the praxis-net bridge. Format:
|
||||
# postgresql://praxis:${PRAXIS_PG_PASSWORD}@postgres:5432/praxis
|
||||
# When unset/empty, the server starts in graceful no-pool mode (learner voice
|
||||
# loop works; operator auth + cohort endpoints return 503).
|
||||
PRAXIS_PG_DSN=
|
||||
|
||||
# Cookie signing secret (D-056, R-AUTH-01). >=32 random bytes, base64 or hex.
|
||||
# Secret. Generate with: openssl rand -base64 48
|
||||
# When unset, the server generates an ephemeral random secret (dev ONLY —
|
||||
# sessions won't survive a restart; NOT for pilot/production).
|
||||
PRAXIS_COOKIE_SECRET=
|
||||
|
||||
# Cookie Secure flag (D-041, R-AUTH-01, G-031). Default true (HTTPS).
|
||||
# Set to false ONLY for the HTTP pilot (no TLS in the LXC pilot — D-030).
|
||||
# NOTE (G-031): the PRIMARY mitigation for a sniffed cookie is the k-anon
|
||||
# defense-in-depth (the cohort dashboard reads only k-anonymized aggregates,
|
||||
# so a sniffed operator cookie leaks NO learner PII). This flag is the
|
||||
# SECONDARY mitigation (operational convenience for when TLS arrives).
|
||||
PRAXIS_COOKIE_SECURE=true
|
||||
|
||||
# Bootstrap operator credentials (D-052). Secret. Used by
|
||||
# scripts/create-operator.py on first run to create the initial operator.
|
||||
# If either is missing, the CLI exits 1 (R-BOOT-02).
|
||||
PRAXIS_BOOTSTRAP_OPERATOR_USER=
|
||||
PRAXIS_BOOTSTRAP_OPERATOR_PASS=
|
||||
|
||||
# VC issuer root key (v0.3 + v0.4). Secret. Used by nacl.SecretBox to encrypt
|
||||
# Ed25519 private keys at rest (D-042). In v0.4 the migration script
|
||||
# (server/vc/migrate_keys.py) uses this to encrypt the fresh v0.4 keypair;
|
||||
# the v0.3 root key is kept for the v0.3 SQLite verification path (R-VC-MIG-02).
|
||||
# Generate with: python3 -c "import nacl.utils; print(nacl.utils.random(32).hex())"
|
||||
PRAXIS_VC_ISSUER_KEY=
|
||||
|
||||
# Issuer URL (D-042). The public base URL for VC issuer + key identifiers.
|
||||
# v0.4 changes the default to /issuers/v0.4 (v0.3 VCs keep their v0.3 URLs
|
||||
# embedded in their proofs — verification fetches keys by id, not by URL).
|
||||
PRAXIS_ISSUER_URL=https://praxis.example/issuers/v0.4
|
||||
@@ -12,6 +12,8 @@ venv/
|
||||
.env.secrets
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.secrets.example
|
||||
!.ciagent/.env.secrets.example
|
||||
|
||||
# SQLite
|
||||
*.db
|
||||
|
||||
Generated
+2674
-2
File diff suppressed because it is too large
Load Diff
+9
-3
@@ -9,21 +9,27 @@
|
||||
"typecheck": "tsc -b --noEmit",
|
||||
"lint": "oxlint",
|
||||
"preview": "vite preview",
|
||||
"test": "echo 'client: no unit tests yet (v0.1 uses e2e smoke via server tests)' && exit 0"
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@pipecat-ai/client-js": "^1.13.0",
|
||||
"@pipecat-ai/small-webrtc-transport": "^1.10.6",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8"
|
||||
"react-dom": "^19.2.8",
|
||||
"react-router-dom": "^7.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/node": "^24.13.3",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.4",
|
||||
"jsdom": "^25.0.1",
|
||||
"oxlint": "^1.75.0",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.2.0"
|
||||
"vite": "^8.2.0",
|
||||
"vitest": "^3.2.7"
|
||||
}
|
||||
}
|
||||
|
||||
+21
-175
@@ -1,181 +1,27 @@
|
||||
/**
|
||||
* Praxis v0.1 — full session UX (SLICE-05 TASK-05-04).
|
||||
* Praxis — top-level route switch (SLICE-09 TASK-09-02, D-044, R-DASH-05).
|
||||
*
|
||||
* Three views: start → live → debrief. Replaces the SLICE-02 minimal page.
|
||||
* - Start: scenario title + disclaimer acknowledgement + Start button
|
||||
* - Live: turn indicators (learner/AI), interrupt feedback, latency readout
|
||||
* - Debrief: debrief text + audio replay control + latency/cost summary
|
||||
* Routes:
|
||||
* / → existing voice session UI (unchanged)
|
||||
* /operator/login → operator Login form
|
||||
* /operator/dashboard → operator Dashboard (auth-gated)
|
||||
* * → voice session UI (SPA fallback for unknown routes)
|
||||
*
|
||||
* R-DASH-05: the voice UI at `/` is unchanged. The catch-all serves the
|
||||
* voice UI (not a 404) so unknown routes fall back to the learner surface.
|
||||
*/
|
||||
import { useVoiceSession } from './useVoiceSession'
|
||||
import { useEffect, useState } from 'react'
|
||||
import './App.css'
|
||||
|
||||
type View = 'start' | 'live' | 'debrief'
|
||||
|
||||
function App() {
|
||||
const { state, error, transcripts, latency, start, stop } = useVoiceSession()
|
||||
const [view, setView] = useState<View>('start')
|
||||
const [acknowledged, setAcknowledged] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (state === 'connected' && view === 'start') {
|
||||
setView('live')
|
||||
}
|
||||
if (state === 'idle' && view === 'live') {
|
||||
setView('debrief')
|
||||
}
|
||||
}, [state, view])
|
||||
|
||||
const handleStart = async () => {
|
||||
await start()
|
||||
}
|
||||
|
||||
const handleEnd = async () => {
|
||||
await stop()
|
||||
setView('debrief')
|
||||
}
|
||||
|
||||
const handleRestart = () => {
|
||||
setView('start')
|
||||
setAcknowledged(false)
|
||||
}
|
||||
import { Routes, Route } from 'react-router-dom'
|
||||
import VoiceSession from './VoiceSession'
|
||||
import Login from './operator/Login'
|
||||
import Dashboard from './operator/Dashboard'
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<section id="praxis-session">
|
||||
<header>
|
||||
<h1>Praxis</h1>
|
||||
<p className="subtitle">Customer Service role-play — v0.1</p>
|
||||
</header>
|
||||
|
||||
{view === 'start' && (
|
||||
<div className="view view--start">
|
||||
<div className="scenario-card">
|
||||
<h2>Angry customer requesting refund on a damaged product</h2>
|
||||
<p className="scenario-desc">
|
||||
You are a customer service agent. An angry customer (Jordan) is
|
||||
demanding a refund for a cracked product. Handle the
|
||||
conversation. You'll receive a coaching debrief at the end.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="disclaimer">
|
||||
<label className="disclaimer-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={acknowledged}
|
||||
onChange={(e) => setAcknowledged(e.target.checked)}
|
||||
/>
|
||||
<span>
|
||||
This is an AI practice session for training purposes. It is
|
||||
not a real conversation and no real company is involved.
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="controls">
|
||||
<button
|
||||
type="button"
|
||||
className="start"
|
||||
disabled={!acknowledged || state === 'connecting'}
|
||||
onClick={() => void handleStart()}
|
||||
>
|
||||
{state === 'connecting' ? 'Connecting…' : 'Start session'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <div className="error">{error}</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view === 'live' && (
|
||||
<div className="view view--live">
|
||||
<div className="status">
|
||||
<span className={`badge badge--${state}`}>{state}</span>
|
||||
{latency && (
|
||||
<span className="latency">
|
||||
<span className="latency-label">{latency.label}:</span>{' '}
|
||||
<span className={latency.e2eMs !== null && latency.e2eMs <= 600 ? 'ok' : 'over'}>
|
||||
{latency.e2eMs !== null ? `${latency.e2eMs.toFixed(0)} ms` : '—'}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="controls">
|
||||
<button type="button" className="stop" onClick={() => void handleEnd()}>
|
||||
End session
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="transcript">
|
||||
<h2>Live transcript</h2>
|
||||
{transcripts.length === 0 ? (
|
||||
<p className="muted">Speak to the AI customer…</p>
|
||||
) : (
|
||||
<ul>
|
||||
{transcripts.map((t, i) => (
|
||||
<li key={i} className={`turn turn--${t.role}`}>
|
||||
<span className="role">{t.role === 'user' ? 'You' : 'AI'}</span>
|
||||
<span className="text">{t.text}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <div className="error">{error}</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view === 'debrief' && (
|
||||
<div className="view view--debrief">
|
||||
<h2>Session debrief</h2>
|
||||
<p className="muted">
|
||||
Your coaching debrief would appear here, generated from your turns
|
||||
+ the branch outcome. In a live run (with API keys), the debrief
|
||||
is spoken in the same voice as the role-play.
|
||||
</p>
|
||||
|
||||
{latency && (
|
||||
<div className="summary">
|
||||
<h3>Latency summary</h3>
|
||||
<p>
|
||||
{latency.label}:{' '}
|
||||
<span className={latency.e2eMs !== null && latency.e2eMs <= 600 ? 'ok' : 'over'}>
|
||||
{latency.e2eMs !== null ? `${latency.e2eMs.toFixed(0)} ms` : '—'}
|
||||
</span>
|
||||
{latency.e2eMs !== null && (
|
||||
<span className="budget">
|
||||
{' '}(budget 600ms — {latency.e2eMs <= 600 ? 'within' : 'over'})
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{transcripts.length > 0 && (
|
||||
<div className="transcript">
|
||||
<h3>Turns this session</h3>
|
||||
<ul>
|
||||
{transcripts.map((t, i) => (
|
||||
<li key={i} className={`turn turn--${t.role}`}>
|
||||
<span className="role">{t.role === 'user' ? 'You' : 'AI'}</span>
|
||||
<span className="text">{t.text}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="controls">
|
||||
<button type="button" className="start" onClick={handleRestart}>
|
||||
Start a new session
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
<Routes>
|
||||
<Route path="/" element={<VoiceSession />} />
|
||||
<Route path="/operator/login" element={<Login />} />
|
||||
<Route path="/operator/dashboard" element={<Dashboard />} />
|
||||
<Route path="*" element={<VoiceSession />} />
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* Praxis v0.1 — voice session UX (extracted for React Router, SLICE-09 TASK-09-02).
|
||||
*
|
||||
* Three views: start → live → debrief. Reuses useVoiceSession. This is the
|
||||
* existing voice UI, now mounted at `/` and as the catch-all fallback.
|
||||
*/
|
||||
import { useVoiceSession } from './useVoiceSession'
|
||||
import { useEffect, useState } from 'react'
|
||||
import './App.css'
|
||||
|
||||
type View = 'start' | 'live' | 'debrief'
|
||||
|
||||
export default function VoiceSession() {
|
||||
const { state, error, transcripts, latency, start, stop } = useVoiceSession()
|
||||
const [view, setView] = useState<View>('start')
|
||||
const [acknowledged, setAcknowledged] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (state === 'connected' && view === 'start') {
|
||||
setView('live')
|
||||
}
|
||||
if (state === 'idle' && view === 'live') {
|
||||
setView('debrief')
|
||||
}
|
||||
}, [state, view])
|
||||
|
||||
const handleStart = async () => {
|
||||
await start()
|
||||
}
|
||||
|
||||
const handleEnd = async () => {
|
||||
await stop()
|
||||
setView('debrief')
|
||||
}
|
||||
|
||||
const handleRestart = () => {
|
||||
setView('start')
|
||||
setAcknowledged(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<section id="praxis-session">
|
||||
<header>
|
||||
<h1>Praxis</h1>
|
||||
<p className="subtitle">Customer Service role-play — v0.1</p>
|
||||
</header>
|
||||
|
||||
{view === 'start' && (
|
||||
<div className="view view--start">
|
||||
<div className="scenario-card">
|
||||
<h2>Angry customer requesting refund on a damaged product</h2>
|
||||
<p className="scenario-desc">
|
||||
You are a customer service agent. An angry customer (Jordan) is
|
||||
demanding a refund for a cracked product. Handle the
|
||||
conversation. You'll receive a coaching debrief at the end.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="disclaimer">
|
||||
<label className="disclaimer-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={acknowledged}
|
||||
onChange={(e) => setAcknowledged(e.target.checked)}
|
||||
/>
|
||||
<span>
|
||||
This is an AI practice session for training purposes. It is
|
||||
not a real conversation and no real company is involved.
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="controls">
|
||||
<button
|
||||
type="button"
|
||||
className="start"
|
||||
disabled={!acknowledged || state === 'connecting'}
|
||||
onClick={() => void handleStart()}
|
||||
>
|
||||
{state === 'connecting' ? 'Connecting…' : 'Start session'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <div className="error">{error}</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view === 'live' && (
|
||||
<div className="view view--live">
|
||||
<div className="status">
|
||||
<span className={`badge badge--${state}`}>{state}</span>
|
||||
{latency && (
|
||||
<span className="latency">
|
||||
<span className="latency-label">{latency.label}:</span>{' '}
|
||||
<span className={latency.e2eMs !== null && latency.e2eMs <= 600 ? 'ok' : 'over'}>
|
||||
{latency.e2eMs !== null ? `${latency.e2eMs.toFixed(0)} ms` : '—'}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="controls">
|
||||
<button type="button" className="stop" onClick={() => void handleEnd()}>
|
||||
End session
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="transcript">
|
||||
<h2>Live transcript</h2>
|
||||
{transcripts.length === 0 ? (
|
||||
<p className="muted">Speak to the AI customer…</p>
|
||||
) : (
|
||||
<ul>
|
||||
{transcripts.map((t, i) => (
|
||||
<li key={i} className={`turn turn--${t.role}`}>
|
||||
<span className="role">{t.role === 'user' ? 'You' : 'AI'}</span>
|
||||
<span className="text">{t.text}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <div className="error">{error}</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view === 'debrief' && (
|
||||
<div className="view view--debrief">
|
||||
<h2>Session debrief</h2>
|
||||
<p className="muted">
|
||||
Your coaching debrief would appear here, generated from your turns
|
||||
+ the branch outcome. In a live run (with API keys), the debrief
|
||||
is spoken in the same voice as the role-play.
|
||||
</p>
|
||||
|
||||
{latency && (
|
||||
<div className="summary">
|
||||
<h3>Latency summary</h3>
|
||||
<p>
|
||||
{latency.label}:{' '}
|
||||
<span className={latency.e2eMs !== null && latency.e2eMs <= 600 ? 'ok' : 'over'}>
|
||||
{latency.e2eMs !== null ? `${latency.e2eMs.toFixed(0)} ms` : '—'}
|
||||
</span>
|
||||
{latency.e2eMs !== null && (
|
||||
<span className="budget">
|
||||
{' '}(budget 600ms — {latency.e2eMs <= 600 ? 'within' : 'over'})
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{transcripts.length > 0 && (
|
||||
<div className="transcript">
|
||||
<h3>Turns this session</h3>
|
||||
<ul>
|
||||
{transcripts.map((t, i) => (
|
||||
<li key={i} className={`turn turn--${t.role}`}>
|
||||
<span className="role">{t.role === 'user' ? 'You' : 'AI'}</span>
|
||||
<span className="text">{t.text}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="controls">
|
||||
<button type="button" className="start" onClick={handleRestart}>
|
||||
Start a new session
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
+5
-2
@@ -1,10 +1,13 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Operator Dashboard shell + auth gate (SLICE-09 TASK-09-04, D-057, D-053).
|
||||
*
|
||||
* On mount: GET /api/operator/me. 401 → redirect to /operator/login (UX-only
|
||||
* route guard — the server is the authority per D-057). 200 → render the
|
||||
* dashboard with operator name, 3 view tabs, freshness indicator, logout.
|
||||
*/
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import PracticeVolume from './views/PracticeVolume'
|
||||
import MasteryProgression from './views/MasteryProgression'
|
||||
import FailurePatterns from './views/FailurePatterns'
|
||||
import '../App.css'
|
||||
|
||||
type Tab = 'practice' | 'mastery' | 'failure'
|
||||
|
||||
interface OperatorInfo {
|
||||
id: string
|
||||
username: string
|
||||
display_name: string | null
|
||||
role: string
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const [op, setOp] = useState<OperatorInfo | null>(null)
|
||||
const [tab, setTab] = useState<Tab>('practice')
|
||||
const [authed, setAuthed] = useState<boolean | null>(null)
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
try {
|
||||
const r = await fetch('/api/operator/me', { credentials: 'include' })
|
||||
if (cancelled) return
|
||||
if (r.status === 200) {
|
||||
const body = await r.json()
|
||||
setOp(body.operator)
|
||||
setAuthed(true)
|
||||
} else {
|
||||
setAuthed(false)
|
||||
navigate('/operator/login', { replace: true })
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setAuthed(false)
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [navigate])
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await fetch('/api/operator/logout', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
})
|
||||
} catch {
|
||||
// best-effort — navigate to login regardless
|
||||
}
|
||||
navigate('/operator/login', { replace: true })
|
||||
}
|
||||
|
||||
if (authed === false) return null
|
||||
if (authed === null || !op) {
|
||||
return (
|
||||
<section id="praxis-dashboard">
|
||||
<p className="muted">Loading dashboard…</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section id="praxis-dashboard">
|
||||
<header>
|
||||
<h1>Praxis Operator Dashboard</h1>
|
||||
<p className="subtitle">
|
||||
Signed in as {op.display_name || op.username}
|
||||
</p>
|
||||
<div className="controls">
|
||||
<button type="button" className="stop" onClick={handleLogout}>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav className="view-tabs" role="tablist">
|
||||
<button
|
||||
role="tab"
|
||||
aria-selected={tab === 'practice'}
|
||||
className={tab === 'practice' ? 'tab active' : 'tab'}
|
||||
onClick={() => setTab('practice')}
|
||||
>
|
||||
Practice Volume
|
||||
</button>
|
||||
<button
|
||||
role="tab"
|
||||
aria-selected={tab === 'mastery'}
|
||||
className={tab === 'mastery' ? 'tab active' : 'tab'}
|
||||
onClick={() => setTab('mastery')}
|
||||
>
|
||||
Mastery Progression
|
||||
</button>
|
||||
<button
|
||||
role="tab"
|
||||
aria-selected={tab === 'failure'}
|
||||
className={tab === 'failure' ? 'tab active' : 'tab'}
|
||||
onClick={() => setTab('failure')}
|
||||
>
|
||||
Failure Patterns
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
{tab === 'practice' && <PracticeVolume />}
|
||||
{tab === 'mastery' && <MasteryProgression />}
|
||||
{tab === 'failure' && <FailurePatterns />}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Operator Login form (SLICE-09 TASK-09-03, D-041, D-057).
|
||||
*
|
||||
* POST /api/operator/login on submit. On success → navigate to
|
||||
* /operator/dashboard. On 401 → show error. On 429 → show rate-limit retry
|
||||
* message. Keyboard-accessible (label associations, focus management).
|
||||
*/
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
export default function Login() {
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
const userRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
userRef.current?.focus()
|
||||
}, [])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const r = await fetch('/api/operator/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ username, password }),
|
||||
})
|
||||
if (r.status === 200) {
|
||||
navigate('/operator/dashboard')
|
||||
return
|
||||
}
|
||||
if (r.status === 401) {
|
||||
setError('Invalid username or password.')
|
||||
} else if (r.status === 429) {
|
||||
setError('Too many attempts. Try again in a minute.')
|
||||
} else if (r.status === 503) {
|
||||
setError('Operator sign-in is unavailable right now.')
|
||||
} else {
|
||||
setError(`Login failed (HTTP ${r.status}).`)
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Network error — unable to reach the server.')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section id="praxis-login">
|
||||
<header>
|
||||
<h1>Praxis Operator</h1>
|
||||
<p className="subtitle">Sign in to view the cohort dashboard</p>
|
||||
</header>
|
||||
|
||||
<form className="login-form" onSubmit={handleSubmit}>
|
||||
<label htmlFor="login-username">Username</label>
|
||||
<input
|
||||
id="login-username"
|
||||
ref={userRef}
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
disabled={submitting}
|
||||
/>
|
||||
|
||||
<label htmlFor="login-password">Password</label>
|
||||
<input
|
||||
id="login-password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
disabled={submitting}
|
||||
/>
|
||||
|
||||
<button type="submit" className="start" disabled={submitting}>
|
||||
{submitting ? 'Signing in…' : 'Sign in'}
|
||||
</button>
|
||||
|
||||
{error && <div className="error" role="alert">{error}</div>}
|
||||
</form>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Inline SVG sparkline (SLICE-09 TASK-09-05, RESEARCH-v0.4 §4.3).
|
||||
*
|
||||
* Zero-dep ~50 LOC. Renders a polyline from `data`. Handles empty (renders
|
||||
* nothing), single point (dot), all-same (flat line). stroke=currentColor.
|
||||
* No axes/tooltips — sparklines are compact trend indicators.
|
||||
*/
|
||||
interface SparklineProps {
|
||||
data: number[]
|
||||
width?: number
|
||||
height?: number
|
||||
}
|
||||
|
||||
export default function Sparkline({ data, width = 60, height = 20 }: SparklineProps) {
|
||||
if (!data || data.length === 0) {
|
||||
return null
|
||||
}
|
||||
if (data.length === 1) {
|
||||
return (
|
||||
<svg width={width} height={height} viewBox={`0 0 ${width} ${height}`} aria-hidden="true">
|
||||
<circle cx={width / 2} cy={height / 2} r={1.5} fill="currentColor" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
const min = Math.min(...data)
|
||||
const max = Math.max(...data)
|
||||
const span = max - min || 1
|
||||
const pad = 2
|
||||
const w = width - pad * 2
|
||||
const h = height - pad * 2
|
||||
const stepX = w / (data.length - 1)
|
||||
const points = data.map((v, i) => {
|
||||
const x = pad + i * stepX
|
||||
const y = pad + h - ((v - min) / span) * h
|
||||
return `${x.toFixed(2)},${y.toFixed(2)}`
|
||||
})
|
||||
return (
|
||||
<svg width={width} height={height} viewBox={`0 0 ${width} ${height}`} aria-hidden="true">
|
||||
<polyline
|
||||
points={points.join(' ')}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.25}
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* Operator dashboard unit tests (SLICE-09 TASK-09-07).
|
||||
*
|
||||
* Covers: auth gate (401 on /me → redirect to /operator/login), login form
|
||||
* (submit → POST /login → navigate to dashboard), suppressed cell display
|
||||
* ("— (<10 learners)"), sparkline renders SVG polyline, freshness indicator,
|
||||
* no PII in rendered DOM.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { render, screen, waitFor, fireEvent } from '@testing-library/react'
|
||||
import { MemoryRouter, Routes, Route } from 'react-router-dom'
|
||||
import Login from '../Login'
|
||||
import Dashboard from '../Dashboard'
|
||||
import Sparkline from '../Sparkline'
|
||||
import { suppressedLabel, formatFreshness } from '../views/_viewCommon'
|
||||
import type { Cell } from '../views/_viewCommon'
|
||||
|
||||
function renderAt(path: string) {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<Routes>
|
||||
<Route path="/operator/login" element={<Login />} />
|
||||
<Route path="/operator/dashboard" element={<Dashboard />} />
|
||||
<Route path="*" element={<div data-testid="fallback" />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('fetch', vi.fn())
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
// ── Auth gate ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Dashboard auth gate', () => {
|
||||
it('redirects to /operator/login on 401 from /me', async () => {
|
||||
;(global.fetch as any).mockResolvedValue({ status: 401 })
|
||||
renderAt('/operator/dashboard')
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(/Praxis Operator Dashboard/i)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('renders dashboard on 200 from /me', async () => {
|
||||
;(global.fetch as any).mockResolvedValue({
|
||||
status: 200,
|
||||
json: async () => ({ operator: { id: '1', username: 'alice', display_name: 'Alice', role: 'operator' } }),
|
||||
})
|
||||
renderAt('/operator/dashboard')
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Praxis Operator Dashboard/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/Signed in as Alice/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ── Login form ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Login form', () => {
|
||||
it('renders username + password fields + submit', () => {
|
||||
renderAt('/operator/login')
|
||||
expect(screen.getByLabelText(/Username/i)).toBeInTheDocument()
|
||||
expect(screen.getByLabelText(/Password/i)).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /Sign in/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('submits POST /api/operator/login and navigates on success', async () => {
|
||||
;(global.fetch as any).mockResolvedValue({ status: 200 })
|
||||
renderAt('/operator/login')
|
||||
fireEvent.change(screen.getByLabelText(/Username/i), { target: { value: 'alice' } })
|
||||
fireEvent.change(screen.getByLabelText(/Password/i), { target: { value: 'pw' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /Sign in/i }))
|
||||
await waitFor(() => {
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'/api/operator/login',
|
||||
expect.objectContaining({ method: 'POST' }),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('shows error on 401', async () => {
|
||||
;(global.fetch as any).mockResolvedValue({ status: 401 })
|
||||
renderAt('/operator/login')
|
||||
fireEvent.change(screen.getByLabelText(/Username/i), { target: { value: 'a' } })
|
||||
fireEvent.change(screen.getByLabelText(/Password/i), { target: { value: 'b' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /Sign in/i }))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Invalid username or password/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows rate-limit message on 429', async () => {
|
||||
;(global.fetch as any).mockResolvedValue({ status: 429 })
|
||||
renderAt('/operator/login')
|
||||
fireEvent.change(screen.getByLabelText(/Username/i), { target: { value: 'a' } })
|
||||
fireEvent.change(screen.getByLabelText(/Password/i), { target: { value: 'b' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /Sign in/i }))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Too many attempts/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ── Sparkline ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Sparkline', () => {
|
||||
it('renders nothing for empty data', () => {
|
||||
const { container } = render(<Sparkline data={[]} />)
|
||||
expect(container.querySelector('svg')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders a dot for single point', () => {
|
||||
const { container } = render(<Sparkline data={[5]} />)
|
||||
expect(container.querySelector('circle')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('renders a polyline for multiple points', () => {
|
||||
const { container } = render(<Sparkline data={[1, 2, 3, 4, 5]} />)
|
||||
const poly = container.querySelector('polyline')
|
||||
expect(poly).not.toBeNull()
|
||||
expect(poly?.getAttribute('points')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders a flat line for all-same values', () => {
|
||||
const { container } = render(<Sparkline data={[3, 3, 3, 3]} />)
|
||||
expect(container.querySelector('polyline')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// ── Suppressed cell display + freshness ──────────────────────────────────
|
||||
|
||||
describe('suppressedLabel', () => {
|
||||
it('shows "— (<10 learners)" for suppressed cells', () => {
|
||||
const cell: Cell = {
|
||||
metric: 'sessions_count', window_start: null, window_end: null,
|
||||
value: null, cell_count: 5, cell_suppressed: true, updated_at: null,
|
||||
}
|
||||
expect(suppressedLabel(cell)).toBe('— (<10 learners)')
|
||||
})
|
||||
|
||||
it('shows the value for non-suppressed cells', () => {
|
||||
const cell: Cell = {
|
||||
metric: 'sessions_count', window_start: null, window_end: null,
|
||||
value: 12, cell_count: 12, cell_suppressed: false, updated_at: null,
|
||||
}
|
||||
expect(suppressedLabel(cell)).toBe('12')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatFreshness', () => {
|
||||
it('shows — for null lastUpdated', () => {
|
||||
expect(formatFreshness(null)).toBe('—')
|
||||
})
|
||||
|
||||
it('shows minutes ago for < 1h', () => {
|
||||
const thirtyMinAgo = new Date(Date.now() - 30 * 60_000).toISOString()
|
||||
expect(formatFreshness(thirtyMinAgo)).toMatch(/m ago/)
|
||||
})
|
||||
|
||||
it('shows hours ago for 1-24h', () => {
|
||||
const twoHoursAgo = new Date(Date.now() - 2 * 3_600_000).toISOString()
|
||||
expect(formatFreshness(twoHoursAgo)).toMatch(/h ago/)
|
||||
})
|
||||
|
||||
it('shows days ago for > 24h', () => {
|
||||
const twoDaysAgo = new Date(Date.now() - 48 * 3_600_000).toISOString()
|
||||
expect(formatFreshness(twoDaysAgo)).toMatch(/d ago/)
|
||||
})
|
||||
})
|
||||
|
||||
// ── No PII in rendered DOM ────────────────────────────────────────────────
|
||||
|
||||
describe('No PII in dashboard DOM', () => {
|
||||
it('does not render learner_ref fields', async () => {
|
||||
;(global.fetch as any).mockResolvedValue({
|
||||
status: 200,
|
||||
json: async () => ({ operator: { id: '1', username: 'alice', display_name: 'Alice', role: 'operator' } }),
|
||||
})
|
||||
const { container } = renderAt('/operator/dashboard')
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Praxis Operator Dashboard/i)).toBeInTheDocument()
|
||||
})
|
||||
// No learner-ref label or per-learner data should appear in the dashboard shell.
|
||||
expect(container.textContent).not.toMatch(/learner_ref/i)
|
||||
expect(container.textContent).not.toMatch(/learner-1/i)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Failure Patterns view (SLICE-09 TASK-09-06, D-053, REQ-NFR-DASH-01).
|
||||
*
|
||||
* Top failure_modes by frequency (sorted table), rubric criteria with
|
||||
* mean < 3.0 (highlighted weak-spots), branch outcome distribution.
|
||||
* Suppressed cells → "— (<10 learners)".
|
||||
*/
|
||||
import { useEffect, useState } from 'react'
|
||||
import { fetchView, formatFreshness, suppressedLabel } from './_viewCommon'
|
||||
import type { ViewResponse } from './_viewCommon'
|
||||
|
||||
export default function FailurePatterns() {
|
||||
const [data, setData] = useState<ViewResponse | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
try {
|
||||
const r = await fetchView('/api/operator/failure-patterns')
|
||||
if (!cancelled) setData(r)
|
||||
} catch (e) {
|
||||
if (!cancelled) setError(String(e))
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (loading) return <p className="muted">Loading failure patterns…</p>
|
||||
if (error) return <div className="error">Failed to load: {error}</div>
|
||||
if (!data || data.views.length === 0) {
|
||||
return (
|
||||
<div className="view view--failure">
|
||||
<p className="muted">No failure-pattern data available yet.</p>
|
||||
<p className="muted">Last updated: {formatFreshness(data?.last_updated ?? null)}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="view view--failure">
|
||||
<p className="muted">Last updated: {formatFreshness(data.last_updated)}</p>
|
||||
{data.views.map((v) => {
|
||||
const modes = v.metrics
|
||||
.filter((c) => c.metric.startsWith('failure_mode:'))
|
||||
.sort((a, b) => (b.value ?? 0) - (a.value ?? 0))
|
||||
const branches = v.metrics.filter((c) => c.metric.startsWith('branch:'))
|
||||
return (
|
||||
<div key={v.path} className="cohort-section">
|
||||
<h3>{v.path}</h3>
|
||||
<h4>Failure modes by frequency</h4>
|
||||
<table className="cohort-table">
|
||||
<thead><tr><th>Mode</th><th>Frequency</th></tr></thead>
|
||||
<tbody>
|
||||
{modes.length === 0 ? (
|
||||
<tr><td colSpan={2} className="muted">No failure modes recorded.</td></tr>
|
||||
) : (
|
||||
modes.map((c) => (
|
||||
<tr key={c.metric}>
|
||||
<td>{c.metric.replace('failure_mode:', '')}</td>
|
||||
<td>{suppressedLabel(c)}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h4>Branch outcome distribution</h4>
|
||||
<table className="cohort-table">
|
||||
<thead><tr><th>Branch</th><th>Count</th></tr></thead>
|
||||
<tbody>
|
||||
{branches.length === 0 ? (
|
||||
<tr><td colSpan={2} className="muted">No branch data recorded.</td></tr>
|
||||
) : (
|
||||
branches.map((c) => (
|
||||
<tr key={c.metric}>
|
||||
<td>{c.metric.replace('branch:', '')}</td>
|
||||
<td>{suppressedLabel(c)}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Mastery Progression view (SLICE-09 TASK-09-06, D-053, REQ-NFR-DASH-01).
|
||||
*
|
||||
* Gate-open rate, median mastery score, rubric criterion means (table +
|
||||
* sparkline). Suppressed cells → "— (<10 learners)".
|
||||
*/
|
||||
import { useEffect, useState } from 'react'
|
||||
import Sparkline from '../Sparkline'
|
||||
import { fetchView, formatFreshness, suppressedLabel, valuesForSparkline } from './_viewCommon'
|
||||
import type { ViewResponse } from './_viewCommon'
|
||||
|
||||
export default function MasteryProgression() {
|
||||
const [data, setData] = useState<ViewResponse | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
try {
|
||||
const r = await fetchView('/api/operator/mastery')
|
||||
if (!cancelled) setData(r)
|
||||
} catch (e) {
|
||||
if (!cancelled) setError(String(e))
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (loading) return <p className="muted">Loading mastery progression…</p>
|
||||
if (error) return <div className="error">Failed to load: {error}</div>
|
||||
if (!data || data.views.length === 0) {
|
||||
return (
|
||||
<div className="view view--mastery">
|
||||
<p className="muted">No mastery data available yet.</p>
|
||||
<p className="muted">Last updated: {formatFreshness(data?.last_updated ?? null)}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="view view--mastery">
|
||||
<p className="muted">Last updated: {formatFreshness(data.last_updated)}</p>
|
||||
{data.views.map((v) => {
|
||||
const gate = v.metrics.find((c) => c.metric === 'gate_open_rate')
|
||||
const median = v.metrics.find((c) => c.metric === 'median_mastery_score')
|
||||
const critMeans = v.metrics.filter((c) => c.metric.startsWith('rubric_criterion_mean:'))
|
||||
return (
|
||||
<div key={v.path} className="cohort-section">
|
||||
<h3>{v.path}</h3>
|
||||
<table className="cohort-table">
|
||||
<thead>
|
||||
<tr><th>Metric</th><th>Value</th><th>Trend</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Gate-open rate</td>
|
||||
<td>{gate ? suppressedLabel(gate) : '—'}</td>
|
||||
<td><Sparkline data={valuesForSparkline(v.metrics, 'gate_open_rate')} /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Median mastery score</td>
|
||||
<td>{median ? suppressedLabel(median) : '—'}</td>
|
||||
<td><Sparkline data={valuesForSparkline(v.metrics, 'median_mastery_score')} /></td>
|
||||
</tr>
|
||||
{critMeans.map((c) => (
|
||||
<tr key={c.metric}>
|
||||
<td>{c.metric.replace('rubric_criterion_mean:', '')}</td>
|
||||
<td>{suppressedLabel(c)}</td>
|
||||
<td><Sparkline data={valuesForSparkline(v.metrics, c.metric)} /></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Practice Volume view (SLICE-09 TASK-09-06, D-053, REQ-NFR-DASH-01).
|
||||
*
|
||||
* Read-only table of sessions/day per path + active learners, with sparklines.
|
||||
* Suppressed cells → "— (<10 learners)". No per-learner drill-down (R-DASH-02).
|
||||
*/
|
||||
import { useEffect, useState } from 'react'
|
||||
import Sparkline from '../Sparkline'
|
||||
import { fetchView, formatFreshness, suppressedLabel, valuesForSparkline } from './_viewCommon'
|
||||
import type { Cell, ViewResponse } from './_viewCommon'
|
||||
|
||||
const SUPPRESSED_PLACEHOLDER: Cell = {
|
||||
metric: '', window_start: null, window_end: null,
|
||||
value: null, cell_count: 0, cell_suppressed: true, updated_at: null,
|
||||
}
|
||||
|
||||
export default function PracticeVolume() {
|
||||
const [data, setData] = useState<ViewResponse | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
try {
|
||||
const r = await fetchView('/api/operator/cohort')
|
||||
if (!cancelled) setData(r)
|
||||
} catch (e) {
|
||||
if (!cancelled) setError(String(e))
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (loading) return <p className="muted">Loading practice volume…</p>
|
||||
if (error) return <div className="error">Failed to load: {error}</div>
|
||||
if (!data || data.views.length === 0) {
|
||||
return (
|
||||
<div className="view view--practice">
|
||||
<p className="muted">No practice data available yet.</p>
|
||||
<p className="muted">Last updated: {formatFreshness(data?.last_updated ?? null)}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="view view--practice">
|
||||
<p className="muted">Last updated: {formatFreshness(data.last_updated)}</p>
|
||||
<table className="cohort-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Path</th>
|
||||
<th>Sessions (trend)</th>
|
||||
<th>Active learners</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.views.map((v) => {
|
||||
const sessions = v.metrics.filter((c) => c.metric === 'sessions_count')
|
||||
const active = v.metrics.find((c) => c.metric === 'active_learners_count')
|
||||
return (
|
||||
<tr key={v.path}>
|
||||
<td>{v.path}</td>
|
||||
<td>
|
||||
{suppressedLabel(sessions[sessions.length - 1] ?? SUPPRESSED_PLACEHOLDER)}
|
||||
{' '}
|
||||
<Sparkline data={valuesForSparkline(v.metrics, 'sessions_count')} />
|
||||
</td>
|
||||
<td>{active ? suppressedLabel(active) : '—'}</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Shared types + helpers for operator dashboard views (SLICE-09 TASK-09-06).
|
||||
*/
|
||||
|
||||
export interface Cell {
|
||||
metric: string
|
||||
window_start: string | null
|
||||
window_end: string | null
|
||||
value: number | null
|
||||
cell_count: number
|
||||
cell_suppressed: boolean
|
||||
updated_at: string | null
|
||||
}
|
||||
|
||||
export interface PathView {
|
||||
path: string
|
||||
metrics: Cell[]
|
||||
}
|
||||
|
||||
export interface ViewResponse {
|
||||
views: PathView[]
|
||||
last_updated: string | null
|
||||
}
|
||||
|
||||
export async function fetchView(endpoint: string): Promise<ViewResponse> {
|
||||
const r = await fetch(endpoint, { credentials: 'include' })
|
||||
if (!r.ok) {
|
||||
throw new Error(`HTTP ${r.status}`)
|
||||
}
|
||||
return (await r.json()) as ViewResponse
|
||||
}
|
||||
|
||||
export function formatFreshness(lastUpdated: string | null): string {
|
||||
if (!lastUpdated) return '—'
|
||||
const ts = Date.parse(lastUpdated)
|
||||
if (Number.isNaN(ts)) return '—'
|
||||
const hoursAgo = (Date.now() - ts) / 3_600_000
|
||||
if (hoursAgo < 1) return `${Math.round(hoursAgo * 60)}m ago`
|
||||
if (hoursAgo < 24) return `${hoursAgo.toFixed(1)}h ago`
|
||||
return `${(hoursAgo / 24).toFixed(1)}d ago`
|
||||
}
|
||||
|
||||
export function suppressedLabel(cell: Cell): string {
|
||||
return cell.cell_suppressed ? '— (<10 learners)' : String(cell.value ?? '—')
|
||||
}
|
||||
|
||||
export function groupMetricsByPath(views: PathView[]): Map<string, Cell[]> {
|
||||
const m = new Map<string, Cell[]>()
|
||||
for (const v of views) {
|
||||
m.set(v.path, v.metrics)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
export function valuesForSparkline(cells: Cell[] | undefined, metric: string): number[] {
|
||||
if (!cells) return []
|
||||
return cells
|
||||
.filter((c) => c.metric === metric && c.value !== null)
|
||||
.map((c) => c.value as number)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
@@ -0,0 +1,13 @@
|
||||
/// <reference types="vitest" />
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
setupFiles: ['./src/test-setup.ts'],
|
||||
include: ['src/**/*.test.{ts,tsx}'],
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Postgres migration runner — applies db/pg_migrations/*.sql in order.
|
||||
|
||||
Mirrors db/migrate.py: ordered .sql files tracked in a `_pg_migrations`
|
||||
table so re-running is idempotent. Uses an asyncpg pool. Retries on
|
||||
connection failure (3 attempts, 2s backoff — R-MT-02 mitigation).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import datetime as _dt
|
||||
from pathlib import Path
|
||||
|
||||
import asyncpg
|
||||
|
||||
_DEFAULT_MIGRATIONS_DIR = Path(__file__).resolve().parent / "pg_migrations"
|
||||
_RETRY_ATTEMPTS = 3
|
||||
_RETRY_BACKOFF_S = 2.0
|
||||
|
||||
|
||||
async def apply_pg_migrations(
|
||||
pool: asyncpg.Pool,
|
||||
migrations_dir: Path | None = None,
|
||||
) -> list[str]:
|
||||
"""Apply all pending Postgres migrations in order. Returns applied names.
|
||||
|
||||
Idempotent — no-op if all migrations are already applied. Each migration
|
||||
runs within a transaction; the `_pg_migrations` tracking row is inserted
|
||||
in the same transaction so a failure rolls back cleanly.
|
||||
"""
|
||||
mdir = migrations_dir or _DEFAULT_MIGRATIONS_DIR
|
||||
if not mdir.exists():
|
||||
return []
|
||||
|
||||
async def _run() -> list[str]:
|
||||
async with pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS _pg_migrations ("
|
||||
"id TEXT PRIMARY KEY, applied_at TIMESTAMPTZ NOT NULL DEFAULT now()"
|
||||
")"
|
||||
)
|
||||
rows = await conn.fetch("SELECT id FROM _pg_migrations")
|
||||
applied_ids = {r["id"] for r in rows}
|
||||
applied: list[str] = []
|
||||
for sql_path in sorted(mdir.glob("*.sql")):
|
||||
mid = sql_path.stem
|
||||
if mid in applied_ids:
|
||||
continue
|
||||
sql = sql_path.read_text(encoding="utf-8")
|
||||
async with conn.transaction():
|
||||
await conn.execute(sql)
|
||||
await conn.execute(
|
||||
"INSERT INTO _pg_migrations (id) VALUES ($1)", mid
|
||||
)
|
||||
applied.append(mid)
|
||||
return applied
|
||||
|
||||
last_exc: Exception | None = None
|
||||
for attempt in range(1, _RETRY_ATTEMPTS + 1):
|
||||
try:
|
||||
return await _run()
|
||||
except (asyncpg.PostgresConnectionError, ConnectionError, OSError) as exc:
|
||||
last_exc = exc
|
||||
if attempt < _RETRY_ATTEMPTS:
|
||||
await asyncio.sleep(_RETRY_BACKOFF_S)
|
||||
continue
|
||||
assert last_exc is not None
|
||||
raise last_exc
|
||||
|
||||
|
||||
__all__ = ["apply_pg_migrations"]
|
||||
@@ -0,0 +1,59 @@
|
||||
-- Praxis v0.4 operator-tier schema migration 0001.
|
||||
-- Creates the 5 operator-tier tables. Uses gen_random_uuid() (PG16 core).
|
||||
-- Idempotent via IF NOT EXISTS (also safe through pg_migrate tracking).
|
||||
|
||||
CREATE TABLE IF NOT EXISTS operators (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
display_name TEXT,
|
||||
role TEXT NOT NULL DEFAULT 'operator',
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
last_login_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS issued_credentials (
|
||||
id UUID PRIMARY KEY,
|
||||
operator_id UUID REFERENCES operators(id),
|
||||
learner_ref TEXT NOT NULL,
|
||||
vc_type TEXT,
|
||||
payload_jsonb JSONB NOT NULL,
|
||||
signature_b64 TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
issued_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
revoked_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS mastery_gate_events (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
learner_ref TEXT NOT NULL,
|
||||
scenario_id TEXT,
|
||||
path_id TEXT NOT NULL,
|
||||
gate_outcome TEXT,
|
||||
rubric_scores_jsonb JSONB,
|
||||
recorded_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
source TEXT NOT NULL DEFAULT 'sync'
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS cohort_aggregates (
|
||||
path TEXT NOT NULL,
|
||||
metric TEXT NOT NULL,
|
||||
window_start DATE NOT NULL,
|
||||
window_end DATE NOT NULL,
|
||||
value NUMERIC,
|
||||
cell_count INTEGER NOT NULL DEFAULT 0,
|
||||
cell_suppressed BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (path, metric, window_start)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS cohort_aggregates_path_window_idx
|
||||
ON cohort_aggregates (path, window_start);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS issuer_keys (
|
||||
id TEXT PRIMARY KEY,
|
||||
public_key TEXT NOT NULL,
|
||||
private_key_enc BYTEA,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
@@ -0,0 +1,71 @@
|
||||
-- Praxis v0.4 operator-tier Postgres schema (reference).
|
||||
-- Applied in order by db/pg_migrate.py via db/pg_migrations/*.sql.
|
||||
-- The canonical migration is 0001_operator_tier.sql; this file is the
|
||||
-- human-readable reference (kept in sync). Uses gen_random_uuid() which
|
||||
-- is in PG16 core (no extension needed — R-MT-05 verified).
|
||||
--
|
||||
-- Tables:
|
||||
-- operators — operator accounts (argon2id password hash)
|
||||
-- issued_credentials — VC issuance log (learner_ref is opaque, no FK)
|
||||
-- mastery_gate_events — mastery gate audit log (REQ-NFR-MAST-02)
|
||||
-- cohort_aggregates — k-anonymized cohort metrics (plain table, D-050)
|
||||
-- issuer_keys — Ed25519 issuer key lifecycle (active/superseded)
|
||||
--
|
||||
-- No cross-DB FKs (D-031). learner_ref is an opaque string in Postgres.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS operators (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
display_name TEXT,
|
||||
role TEXT NOT NULL DEFAULT 'operator',
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
last_login_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS issued_credentials (
|
||||
id UUID PRIMARY KEY,
|
||||
operator_id UUID REFERENCES operators(id),
|
||||
learner_ref TEXT NOT NULL,
|
||||
vc_type TEXT,
|
||||
payload_jsonb JSONB NOT NULL,
|
||||
signature_b64 TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
issued_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
revoked_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS mastery_gate_events (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
learner_ref TEXT NOT NULL,
|
||||
scenario_id TEXT,
|
||||
path_id TEXT NOT NULL,
|
||||
gate_outcome TEXT,
|
||||
rubric_scores_jsonb JSONB,
|
||||
recorded_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
source TEXT NOT NULL DEFAULT 'sync'
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS cohort_aggregates (
|
||||
path TEXT NOT NULL,
|
||||
metric TEXT NOT NULL,
|
||||
window_start DATE NOT NULL,
|
||||
window_end DATE NOT NULL,
|
||||
value NUMERIC,
|
||||
cell_count INTEGER NOT NULL DEFAULT 0,
|
||||
cell_suppressed BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (path, metric, window_start)
|
||||
);
|
||||
-- Plain table, NOT partitioned (D-050..D-053; add partitioning post-pilot).
|
||||
CREATE INDEX IF NOT EXISTS cohort_aggregates_path_window_idx
|
||||
ON cohort_aggregates (path, window_start);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS issuer_keys (
|
||||
id TEXT PRIMARY KEY,
|
||||
public_key TEXT NOT NULL,
|
||||
private_key_enc BYTEA,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
+280
@@ -0,0 +1,280 @@
|
||||
"""Postgres store — operator-tier access layer (D-040, D-050, TASK-01-06).
|
||||
|
||||
Async access via an asyncpg.Pool. Implements the IssuerKeyStore protocol
|
||||
(server/vc/issuer_keys.py) so VC verification can use either PraxisStore
|
||||
(SQLite, v0.3) or PgStore (Postgres, v0.4). No cross-DB joins (D-031);
|
||||
`learner_ref` is an opaque string in Postgres (not a FK to SQLite).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
import asyncpg
|
||||
|
||||
|
||||
class PgStore:
|
||||
"""Async Postgres store for the v0.4 operator tier."""
|
||||
|
||||
def __init__(self, pool: asyncpg.Pool) -> None:
|
||||
self.pool = pool
|
||||
|
||||
# ── Operator CRUD ────────────────────────────────────────────────────
|
||||
|
||||
async def get_operator_by_username(self, username: str) -> dict | None:
|
||||
async with self.pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"SELECT id, username, password_hash, display_name, role, "
|
||||
"is_active, created_at, last_login_at "
|
||||
"FROM operators WHERE username = $1",
|
||||
username,
|
||||
)
|
||||
return dict(row) if row else None
|
||||
|
||||
async def get_operator_by_id(self, operator_id: str) -> dict | None:
|
||||
async with self.pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"SELECT id, username, password_hash, display_name, role, "
|
||||
"is_active, created_at, last_login_at "
|
||||
"FROM operators WHERE id = $1",
|
||||
operator_id,
|
||||
)
|
||||
return dict(row) if row else None
|
||||
|
||||
async def update_last_login(self, operator_id: str) -> None:
|
||||
async with self.pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"UPDATE operators SET last_login_at = now() WHERE id = $1",
|
||||
operator_id,
|
||||
)
|
||||
|
||||
async def insert_operator(
|
||||
self,
|
||||
username: str,
|
||||
password_hash: str,
|
||||
display_name: str | None = None,
|
||||
*,
|
||||
on_conflict_update: bool = False,
|
||||
) -> str | None:
|
||||
"""Insert an operator (idempotent on username). Returns the id, or
|
||||
None if the row already existed and on_conflict_update is False."""
|
||||
async with self.pool.acquire() as conn:
|
||||
if on_conflict_update:
|
||||
row = await conn.fetchrow(
|
||||
"INSERT INTO operators (username, password_hash, display_name) "
|
||||
"VALUES ($1, $2, $3) "
|
||||
"ON CONFLICT (username) DO UPDATE SET "
|
||||
"password_hash = excluded.password_hash, "
|
||||
"display_name = excluded.display_name "
|
||||
"RETURNING id",
|
||||
username,
|
||||
password_hash,
|
||||
display_name,
|
||||
)
|
||||
return str(row["id"]) if row else None
|
||||
row = await conn.fetchrow(
|
||||
"INSERT INTO operators (username, password_hash, display_name) "
|
||||
"VALUES ($1, $2, $3) "
|
||||
"ON CONFLICT (username) DO NOTHING "
|
||||
"RETURNING id",
|
||||
username,
|
||||
password_hash,
|
||||
display_name,
|
||||
)
|
||||
return str(row["id"]) if row else None
|
||||
|
||||
# ── Cohort aggregate read/write ──────────────────────────────────────
|
||||
|
||||
async def get_cohort_aggregates(
|
||||
self,
|
||||
path: str,
|
||||
metric: str,
|
||||
since_date: Any,
|
||||
) -> list[dict]:
|
||||
async with self.pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
"SELECT path, metric, window_start, window_end, value, "
|
||||
"cell_count, cell_suppressed, updated_at "
|
||||
"FROM cohort_aggregates "
|
||||
"WHERE path = $1 AND metric = $2 AND window_start >= $3 "
|
||||
"ORDER BY window_start",
|
||||
path,
|
||||
metric,
|
||||
since_date,
|
||||
)
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
async def upsert_cohort_aggregate(
|
||||
self,
|
||||
path: str,
|
||||
metric: str,
|
||||
window_start: Any,
|
||||
window_end: Any,
|
||||
value: float | None,
|
||||
cell_count: int,
|
||||
cell_suppressed: bool,
|
||||
) -> None:
|
||||
async with self.pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"INSERT INTO cohort_aggregates "
|
||||
"(path, metric, window_start, window_end, value, cell_count, "
|
||||
"cell_suppressed, updated_at) "
|
||||
"VALUES ($1, $2, $3, $4, $5, $6, $7, now()) "
|
||||
"ON CONFLICT (path, metric, window_start) DO UPDATE SET "
|
||||
"window_end = excluded.window_end, value = excluded.value, "
|
||||
"cell_count = excluded.cell_count, "
|
||||
"cell_suppressed = excluded.cell_suppressed, "
|
||||
"updated_at = now()",
|
||||
path,
|
||||
metric,
|
||||
window_start,
|
||||
window_end,
|
||||
value,
|
||||
cell_count,
|
||||
cell_suppressed,
|
||||
)
|
||||
|
||||
# ── IssuerKeyStore protocol (D-051, TASK-04-02) ──────────────────────
|
||||
|
||||
async def init_issuer_key(
|
||||
self,
|
||||
key_id: str,
|
||||
public_key: str,
|
||||
private_key_enc: bytes | None,
|
||||
) -> None:
|
||||
async with self.pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"INSERT INTO issuer_keys (id, public_key, private_key_enc, status) "
|
||||
"VALUES ($1, $2, $3, 'active') "
|
||||
"ON CONFLICT (id) DO NOTHING",
|
||||
key_id,
|
||||
public_key,
|
||||
private_key_enc if private_key_enc is not None else b"",
|
||||
)
|
||||
|
||||
async def get_active_signing_key_row(self) -> dict | None:
|
||||
async with self.pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"SELECT id, public_key, private_key_enc, status, created_at "
|
||||
"FROM issuer_keys WHERE status = 'active' "
|
||||
"ORDER BY created_at DESC LIMIT 1"
|
||||
)
|
||||
return dict(row) if row else None
|
||||
|
||||
async def get_public_key_row(self, key_id: str) -> dict | None:
|
||||
# Queries by id (NOT status) so superseded keys are found too —
|
||||
# this is the R-VC-MIG-01 verification fallback (D-051).
|
||||
async with self.pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"SELECT id, public_key, private_key_enc, status, created_at "
|
||||
"FROM issuer_keys WHERE id = $1",
|
||||
key_id,
|
||||
)
|
||||
return dict(row) if row else None
|
||||
|
||||
async def set_issuer_key_superseded(self, key_id: str) -> None:
|
||||
async with self.pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"UPDATE issuer_keys SET status = 'superseded' WHERE id = $1",
|
||||
key_id,
|
||||
)
|
||||
|
||||
# ── Credential methods ───────────────────────────────────────────────
|
||||
|
||||
async def insert_credential(
|
||||
self,
|
||||
cred_id: str,
|
||||
learner_ref: str,
|
||||
payload_json: str,
|
||||
signature_b64: str,
|
||||
*,
|
||||
operator_id: str | None = None,
|
||||
vc_type: str = "MasteryCredential",
|
||||
) -> None:
|
||||
async with self.pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"INSERT INTO issued_credentials "
|
||||
"(id, operator_id, learner_ref, vc_type, payload_jsonb, "
|
||||
"signature_b64, status) "
|
||||
"VALUES ($1, $2, $3, $4, $5::jsonb, $6, 'active')",
|
||||
cred_id,
|
||||
operator_id,
|
||||
learner_ref,
|
||||
vc_type,
|
||||
payload_json,
|
||||
signature_b64,
|
||||
)
|
||||
|
||||
async def get_credential(self, cred_id: str) -> dict | None:
|
||||
# Returns a row shaped like PraxisStore.get_credential so the
|
||||
# verification code can use either store interchangeably.
|
||||
async with self.pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"SELECT id, learner_ref, "
|
||||
"payload_jsonb::text AS vc_payload_json, signature_b64, "
|
||||
"status, issued_at "
|
||||
"FROM issued_credentials WHERE id = $1",
|
||||
cred_id,
|
||||
)
|
||||
return dict(row) if row else None
|
||||
|
||||
async def set_credential_status(self, cred_id: str, status: str) -> None:
|
||||
extra = ", revoked_at = now()" if status == "revoked" else ""
|
||||
async with self.pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
f"UPDATE issued_credentials SET status = $1{extra} WHERE id = $2",
|
||||
status,
|
||||
cred_id,
|
||||
)
|
||||
|
||||
async def list_credentials(self, operator_id: str | None = None) -> list[dict]:
|
||||
async with self.pool.acquire() as conn:
|
||||
if operator_id is None:
|
||||
rows = await conn.fetch(
|
||||
"SELECT id, learner_ref, vc_type, status, issued_at, "
|
||||
"revoked_at FROM issued_credentials ORDER BY issued_at DESC"
|
||||
)
|
||||
else:
|
||||
rows = await conn.fetch(
|
||||
"SELECT id, learner_ref, vc_type, status, issued_at, "
|
||||
"revoked_at FROM issued_credentials "
|
||||
"WHERE operator_id = $1 ORDER BY issued_at DESC",
|
||||
operator_id,
|
||||
)
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
# ── Mastery gate event ───────────────────────────────────────────────
|
||||
|
||||
async def record_gate_event(
|
||||
self,
|
||||
learner_ref: str,
|
||||
path_id: str,
|
||||
scenario_id: str | None = None,
|
||||
gate_outcome: str | None = None,
|
||||
rubric_scores_jsonb: Any | None = None,
|
||||
) -> str:
|
||||
event_id = str(uuid.uuid4())
|
||||
scores_json = (
|
||||
rubric_scores_jsonb
|
||||
if isinstance(rubric_scores_jsonb, str)
|
||||
else (json.dumps(rubric_scores_jsonb) if rubric_scores_jsonb is not None else None)
|
||||
)
|
||||
async with self.pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"INSERT INTO mastery_gate_events "
|
||||
"(id, learner_ref, scenario_id, path_id, gate_outcome, "
|
||||
"rubric_scores_jsonb, source) "
|
||||
"VALUES ($1, $2, $3, $4, $5, $6::jsonb, 'sync')",
|
||||
event_id,
|
||||
learner_ref,
|
||||
scenario_id,
|
||||
path_id,
|
||||
gate_outcome,
|
||||
scores_json,
|
||||
)
|
||||
return event_id
|
||||
|
||||
|
||||
__all__ = ["PgStore"]
|
||||
+1
-1
@@ -338,7 +338,7 @@ class PraxisStore:
|
||||
async with self._connect() as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
cur = await db.execute(
|
||||
"SELECT id, public_key, status, created_at "
|
||||
"SELECT id, public_key, private_key_enc, status, created_at "
|
||||
"FROM issuer_keys WHERE id = ?",
|
||||
(key_id,),
|
||||
)
|
||||
|
||||
+49
-4
@@ -1,6 +1,6 @@
|
||||
# Praxis v0.2 — Docker Compose service definition
|
||||
# Runs the praxis server inside a Docker container (inside an LXC CT).
|
||||
# Per RESEARCH.md Q4/Q8 / ARCHITECTURE.md §v0.2 Deployment Architecture.
|
||||
# Praxis — Docker Compose service definition (v0.2 + v0.4 Postgres).
|
||||
# Runs the praxis server + a Postgres 16 service inside a Docker-in-LXC CT.
|
||||
# Per ARCHITECTURE.md §v0.2 Deployment + §v0.4 Operator-Tier Architecture.
|
||||
|
||||
services:
|
||||
praxis:
|
||||
@@ -34,6 +34,13 @@ services:
|
||||
DEEPGRAM_REGION: "${DEEPGRAM_REGION:-na}"
|
||||
# Cartesia (D-014)
|
||||
CARTESIA_VOICE_ID: "${CARTESIA_VOICE_ID:-a3536a36-1d18-4efb-a95a-7c44b7b5e384}"
|
||||
# v0.4 operator tier — Postgres DSN (D-050). Empty → graceful no-pool mode.
|
||||
PRAXIS_PG_DSN: "${PRAXIS_PG_DSN:-}"
|
||||
# v0.4 auth (D-041, D-056). Empty → server generates ephemeral secret (dev only).
|
||||
PRAXIS_COOKIE_SECRET: "${PRAXIS_COOKIE_SECRET:-}"
|
||||
PRAXIS_COOKIE_SECURE: "${PRAXIS_COOKIE_SECURE:-true}"
|
||||
PRAXIS_VC_ISSUER_KEY: "${PRAXIS_VC_ISSUER_KEY:-}"
|
||||
PRAXIS_ISSUER_URL: "${PRAXIS_ISSUER_URL:-https://praxis.example/issuers/v0.4}"
|
||||
env_file:
|
||||
# /etc/praxis/server.env is written by install-service.sh with
|
||||
# secrets injected via lxc.environment (G-101 fix: GITEA_TOKEN baked
|
||||
@@ -43,7 +50,45 @@ services:
|
||||
# `docker compose up` in production (so secrets are present at runtime).
|
||||
- path: /etc/praxis/server.env
|
||||
required: false
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- praxis-net
|
||||
|
||||
postgres:
|
||||
image: postgres:16-slim
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: praxis
|
||||
POSTGRES_PASSWORD: "${PRAXIS_PG_PASSWORD:-}"
|
||||
POSTGRES_DB: praxis
|
||||
PGDATA: /var/lib/postgresql/data/pgdata
|
||||
env_file:
|
||||
- path: /etc/praxis/server.env
|
||||
required: false
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
- pgbackups:/backups
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U praxis -d praxis"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- praxis-net
|
||||
# No `ports:` — Postgres is NOT exposed to the LXC host bridge (D-040).
|
||||
# The praxis service reaches it via the praxis-net bridge using the
|
||||
# service-DNS name `postgres`.
|
||||
|
||||
volumes:
|
||||
praxis-data:
|
||||
driver: local
|
||||
driver: local
|
||||
pgdata:
|
||||
driver: local
|
||||
pgbackups:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
praxis-net:
|
||||
driver: bridge
|
||||
@@ -38,6 +38,13 @@ dependencies = [
|
||||
"pynacl>=1.5",
|
||||
"canonicaljson>=2.0",
|
||||
"base58>=2.1",
|
||||
# v0.4 operator tier — Postgres pool (D-050), argon2id passwords (D-041),
|
||||
# slowapi rate limiting (D-041). RESEARCH-v0.4 §new-deps.
|
||||
"asyncpg>=0.29",
|
||||
"argon2-cffi>=23.1",
|
||||
"slowapi>=0.1",
|
||||
# SessionMiddleware uses itsdangerous for signed cookies (D-056).
|
||||
"itsdangerous>=2.1",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
Executable
+50
@@ -0,0 +1,50 @@
|
||||
#!/bin/sh
|
||||
# Praxis v0.4 — Nightly Postgres backup (D-055, G-008).
|
||||
#
|
||||
# Host-side cron script (decoupled from praxis service uptime —
|
||||
# RESEARCH-v0.4 §1.5). Runs pg_dump inside the postgres container and
|
||||
# writes a compressed custom-format dump to the pgbackups volume.
|
||||
#
|
||||
# The %u date format = day-of-week 1..7 (Monday=1, Sunday=7) → rolling
|
||||
# 7-file retention with zero cleanup logic (D-055). Re-running overwrites
|
||||
# the same day-of-week file.
|
||||
#
|
||||
# Cron entry (host, 03:30 CT nightly):
|
||||
# 30 3 * * * /opt/praxis/scripts/backup-pg.sh
|
||||
#
|
||||
# Restore drill (G-008 — run at least once in staging to prove the backup
|
||||
# is valid; NEVER restore into a live DB without stopping praxis first):
|
||||
# docker compose stop praxis
|
||||
# docker compose exec postgres pg_restore -U praxis -d praxis \
|
||||
# --clean --if-exists /backups/praxis-3.dump
|
||||
# # verify: \d operators; SELECT count(*) FROM operators; (etc. for all 5 tables)
|
||||
# docker compose start praxis
|
||||
#
|
||||
# POSIX-sh compatible (no bashisms). Exit 0 on success, 1 on failure.
|
||||
# Args: none. Env: COMPOSE_PROJECT_DIR (default: current dir).
|
||||
|
||||
set -eu
|
||||
|
||||
PROJECT_DIR="${COMPOSE_PROJECT_DIR:-$(pwd)}"
|
||||
cd "$PROJECT_DIR"
|
||||
|
||||
DOW="$(date +%u)"
|
||||
DUMP_FILE="/backups/praxis-${DOW}.dump"
|
||||
|
||||
echo "backup-pg: dumping praxis DB → ${DUMP_FILE} (day-of-week ${DOW})"
|
||||
|
||||
# -Fc = custom compressed format (works with pg_restore --clean --if-exists).
|
||||
# -T stops the container from streaming while dumping? No — pg_dump is
|
||||
# consistent within a transaction; the praxis service can stay up.
|
||||
docker compose exec -T postgres pg_dump -U praxis -Fc praxis -f "$DUMP_FILE"
|
||||
|
||||
# Verify the dump is non-empty (sanity — a 0-byte dump means failure).
|
||||
SIZE=$(docker compose exec -T postgres stat -c '%s' "$DUMP_FILE" 2>/dev/null || echo 0)
|
||||
if [ "$SIZE" -le 0 ]; then
|
||||
echo "backup-pg: ERROR — dump file is empty (${DUMP_FILE})" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "backup-pg: OK — ${DUMP_FILE} is ${SIZE} bytes"
|
||||
echo "backup-pg: restore drill (G-008): docker compose exec postgres pg_restore -U praxis -d praxis --clean --if-exists ${DUMP_FILE}"
|
||||
exit 0
|
||||
Executable
+106
@@ -0,0 +1,106 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Praxis v0.4 — Operator bootstrap CLI (TASK-05-01, D-052).
|
||||
|
||||
Creates the initial operator from env-provided credentials. Idempotent
|
||||
(ON CONFLICT DO NOTHING). The --update flag forces a rehash + update.
|
||||
|
||||
Env:
|
||||
PRAXIS_BOOTSTRAP_OPERATOR_USER — operator username (required)
|
||||
PRAXIS_BOOTSTRAP_OPERATOR_PASS — operator password (required)
|
||||
PRAXIS_PG_DSN — Postgres DSN (required)
|
||||
|
||||
Exit: 0 on success (created or already-exists), 1 on missing env / DB error.
|
||||
Retries on connection failure (3 attempts, 5s backoff — R-BOOT-01).
|
||||
|
||||
Run:
|
||||
PRAXIS_BOOTSTRAP_OPERATOR_USER=admin PRAXIS_BOOTSTRAP_OPERATOR_PASS=... \
|
||||
PRAXIS_PG_DSN=postgresql://praxis:...@postgres:5432/praxis \
|
||||
python3 scripts/create-operator.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
|
||||
from argon2 import PasswordHasher
|
||||
|
||||
_ph = PasswordHasher()
|
||||
_RETRY_ATTEMPTS = 3
|
||||
_RETRY_BACKOFF_S = 5.0
|
||||
|
||||
|
||||
async def create_operator(update: bool = False) -> int:
|
||||
user = os.environ.get("PRAXIS_BOOTSTRAP_OPERATOR_USER", "").strip()
|
||||
pw = os.environ.get("PRAXIS_BOOTSTRAP_OPERATOR_PASS", "")
|
||||
dsn = os.environ.get("PRAXIS_PG_DSN", "").strip()
|
||||
if not user or not pw:
|
||||
print(
|
||||
"create-operator: ERROR — PRAXIS_BOOTSTRAP_OPERATOR_USER and "
|
||||
"PRAXIS_BOOTSTRAP_OPERATOR_PASS must be set (R-BOOT-02).",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
if not dsn:
|
||||
print(
|
||||
"create-operator: ERROR — PRAXIS_PG_DSN must be set.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
import asyncpg
|
||||
from db.pg_migrate import apply_pg_migrations
|
||||
from db.pg_store import PgStore
|
||||
|
||||
last_exc: Exception | None = None
|
||||
for attempt in range(1, _RETRY_ATTEMPTS + 1):
|
||||
try:
|
||||
pool = await asyncpg.create_pool(
|
||||
dsn=dsn, min_size=1, max_size=3, command_timeout=10
|
||||
)
|
||||
try:
|
||||
await apply_pg_migrations(pool)
|
||||
store = PgStore(pool)
|
||||
pw_hash = _ph.hash(pw)
|
||||
display = user
|
||||
oid = await store.insert_operator(
|
||||
user, pw_hash, display, on_conflict_update=update
|
||||
)
|
||||
if update:
|
||||
print(f"create-operator: updated operator {user!r} (id={oid})")
|
||||
elif oid is not None:
|
||||
print(f"create-operator: created operator {user!r} (id={oid})")
|
||||
else:
|
||||
print(f"create-operator: operator {user!r} already exists (no change)")
|
||||
return 0
|
||||
finally:
|
||||
await pool.close()
|
||||
except (asyncpg.PostgresConnectionError, ConnectionError, OSError) as exc:
|
||||
last_exc = exc
|
||||
if attempt < _RETRY_ATTEMPTS:
|
||||
print(
|
||||
f"create-operator: connection attempt {attempt} failed "
|
||||
f"({exc}); retrying in {_RETRY_BACKOFF_S}s (R-BOOT-01)...",
|
||||
file=sys.stderr,
|
||||
)
|
||||
await asyncio.sleep(_RETRY_BACKOFF_S)
|
||||
continue
|
||||
print(f"create-operator: ERROR — could not connect after {_RETRY_ATTEMPTS} "
|
||||
f"attempts: {last_exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Create the initial Praxis operator.")
|
||||
parser.add_argument(
|
||||
"--update", action="store_true",
|
||||
help="Force rehash + update if the operator already exists.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
return asyncio.run(create_operator(update=args.update))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -40,7 +40,10 @@ upid=$(pve_curl POST "$create_path" \
|
||||
"hostname=${hostname}" \
|
||||
"storage=${storage}" \
|
||||
"rootfs=${storage}:16" \
|
||||
"memory=${PROXMOX_MEMORY_MB:-4096}" \
|
||||
# v0.4: 6144MB default (was 4096 in v0.2). Postgres ~400MB + praxis
|
||||
# ~500MB + Docker daemon ~200MB + build headroom ~1GB + margin
|
||||
# (REQ-NFR-MT-01). Override with PROXMOX_MEMORY_MB if needed.
|
||||
"memory=${PROXMOX_MEMORY_MB:-6144}" \
|
||||
"net0=name=eth0,bridge=vmbr0,ip=dhcp" \
|
||||
"arch=amd64" \
|
||||
"features=nesting=1")
|
||||
|
||||
+166
-15
@@ -14,6 +14,7 @@ no audio/no tokens at runtime, not a crash.
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any
|
||||
|
||||
from loguru import logger
|
||||
@@ -27,14 +28,32 @@ try:
|
||||
except ImportError: # pragma: no cover
|
||||
pass
|
||||
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import JSONResponse
|
||||
from pipecat.transports.smallwebrtc.connection import SmallWebRTCConnection
|
||||
from slowapi.errors import RateLimitExceeded
|
||||
from slowapi import _rate_limit_exceeded_handler
|
||||
|
||||
from db.pg_migrate import apply_pg_migrations
|
||||
from db.pg_store import PgStore
|
||||
from db.store import PraxisStore
|
||||
from server.auth.cookies import get_session_middleware_kwargs
|
||||
from server.auth.rate_limit import limiter
|
||||
from server.auth.routes import router as auth_router
|
||||
from server.cohort.nightly import NightlyScheduler
|
||||
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
|
||||
from server.pipeline import build_pipeline
|
||||
from server.vc.issuer_keys import _load_root_key
|
||||
from server.vc.migrate_keys import migrate_issuer_keys
|
||||
from server.vc.verification import verify_credential
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
from starlette.responses import FileResponse
|
||||
from starlette.staticfiles import StaticFiles
|
||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||
|
||||
_store = PraxisStore()
|
||||
|
||||
@@ -47,6 +66,64 @@ HOST = _env("PRAXIS_HOST", "0.0.0.0")
|
||||
PORT = int(_env("PRAXIS_PORT", "8789"))
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""v0.4 — create the asyncpg Postgres pool on startup, close on shutdown.
|
||||
|
||||
Graceful degradation (D-050, REQ-NFR-MT-01): if PRAXIS_PG_DSN is unset,
|
||||
the server starts without Postgres — the learner voice loop (SQLite)
|
||||
is unaffected. app.state.pg_pool / app.state.pg_store are None in that
|
||||
case and auth/operator routes return 503.
|
||||
"""
|
||||
dsn = os.environ.get("PRAXIS_PG_DSN", "").strip()
|
||||
if not dsn:
|
||||
logger.warning(
|
||||
"PRAXIS_PG_DSN not set — starting without Postgres (dev/no-pool mode). "
|
||||
"Operator auth + cohort endpoints will be unavailable (503). "
|
||||
"Learner voice loop (SQLite) is unaffected."
|
||||
)
|
||||
app.state.pg_pool = None
|
||||
app.state.pg_store = None
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
return
|
||||
import asyncpg
|
||||
|
||||
logger.info("Creating asyncpg Postgres pool (min=1, max=10, D-050)")
|
||||
pool = await asyncpg.create_pool(
|
||||
dsn=dsn,
|
||||
min_size=1,
|
||||
max_size=10,
|
||||
command_timeout=10,
|
||||
)
|
||||
app.state.pg_pool = pool
|
||||
app.state.pg_store = PgStore(pool)
|
||||
nightly = NightlyScheduler()
|
||||
app.state.nightly_scheduler = nightly
|
||||
try:
|
||||
applied = await apply_pg_migrations(pool)
|
||||
if applied:
|
||||
logger.info(f"Postgres migrations applied: {applied}")
|
||||
else:
|
||||
logger.info("Postgres migrations up to date")
|
||||
# VC key migration (TASK-06-03, R-VC-MIG-01, G-027) — runs once on
|
||||
# first boot, idempotent. Non-fatal on failure (v0.3 SQLite path
|
||||
# remains intact for verification).
|
||||
await _maybe_migrate_issuer_keys()
|
||||
# v0.4 P2 (D-054, REQ-NFR-DASH-02): start the nightly reconciliation
|
||||
# scheduler at 03:00 CT. Cancelled on shutdown.
|
||||
await nightly.start(app.state.pg_store)
|
||||
logger.info("Nightly cohort reconciliation scheduler started (03:00 CT)")
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
await nightly.stop()
|
||||
finally:
|
||||
await pool.close()
|
||||
logger.info("Postgres pool closed")
|
||||
|
||||
|
||||
class WebRTCOffer(BaseModel):
|
||||
"""Client→server WebRTC offer (SDP + type)."""
|
||||
|
||||
@@ -54,13 +131,19 @@ class WebRTCOffer(BaseModel):
|
||||
type: str = "offer"
|
||||
|
||||
|
||||
app = FastAPI(title="Praxis v0.1 voice server", version="0.1.0")
|
||||
app = FastAPI(title="Praxis v0.1 voice server", version="0.1.0", lifespan=lifespan)
|
||||
# slowapi rate-limit state + 429 handler (D-041, TASK-03-03).
|
||||
app.state.limiter = limiter
|
||||
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"], # dev — the client is a separate Vite origin
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
# SessionMiddleware (signed cookies, D-056) — added AFTER CORS so it is
|
||||
# the outermost middleware (signs cookies before CORS headers are added).
|
||||
app.add_middleware(SessionMiddleware, **get_session_middleware_kwargs())
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
@@ -123,28 +206,96 @@ async def webrtc_offer(offer: WebRTCOffer) -> dict[str, str]:
|
||||
|
||||
@app.get("/vc/verify/{credential_id}")
|
||||
async def vc_verify(credential_id: str) -> dict[str, Any]:
|
||||
"""Public, unauthenticated VC verification endpoint (D-043).
|
||||
"""Public, unauthenticated VC verification endpoint (D-043, G-011).
|
||||
|
||||
Returns {valid, status, issuer, credential, mastery, credentialTier,
|
||||
verifiedAt}. 404 if the credential id is not found. No PII beyond what
|
||||
the credential asserts.
|
||||
Two-store fallback (G-011, binding contract):
|
||||
(a) If Postgres is available (app.state.pg_store), use it for issuer
|
||||
key lookup (active + superseded keys).
|
||||
(b) If the credential is not in Postgres issued_credentials, fall back
|
||||
to SQLite (v0.3 credentials remain in SQLite — D-051).
|
||||
(c) If Postgres is NOT available, use the v0.3 SQLite path for both.
|
||||
The VC key migration (TASK-04-03) runs once on first boot (idempotent)
|
||||
inside the lifespan — see _maybe_migrate_issuer_keys.
|
||||
"""
|
||||
await _store.init()
|
||||
result = await verify_credential(_store, credential_id)
|
||||
pg_store = getattr(app.state, "pg_store", None)
|
||||
result = await verify_credential(
|
||||
_store, credential_id,
|
||||
pg_store=pg_store, sqlite_store=_store,
|
||||
)
|
||||
if result is None:
|
||||
raise HTTPException(status_code=404, detail="credential not found")
|
||||
return result
|
||||
|
||||
|
||||
# ── Static client serving (D-023, REQ-DEPLOY-13) ────────────────────
|
||||
# Mount client/dist as StaticFiles at "/" AFTER all API routes so they
|
||||
# take precedence. html=True serves index.html for "/" (SPA root).
|
||||
# The client has no React Router (single-view state machine: start→live
|
||||
# →debrief), so no SPA fallback fallback route is needed per RESEARCH.md Q3.
|
||||
async def _maybe_migrate_issuer_keys() -> None:
|
||||
"""Run the VC key migration on first boot (TASK-06-03, R-VC-MIG-01).
|
||||
|
||||
Idempotent — no-op if Postgres already has an active issuer key. G-027:
|
||||
if SQLite has no v0.3 active key (fresh deploy), skips archive and only
|
||||
generates a fresh v0.4 keypair.
|
||||
"""
|
||||
pg_store = getattr(app.state, "pg_store", None)
|
||||
if pg_store is None:
|
||||
return
|
||||
try:
|
||||
await _store.init()
|
||||
root_key = _load_root_key()
|
||||
result = await migrate_issuer_keys(_store, pg_store, root_key)
|
||||
if result["new_key_id"] is not None:
|
||||
logger.info(
|
||||
f"VC key migration: archived v0.3 key={result['archived_key_id']}, "
|
||||
f"generated fresh v0.4 key={result['new_key_id']}"
|
||||
)
|
||||
else:
|
||||
logger.info("VC key migration: active key already present (no-op)")
|
||||
except Exception as exc:
|
||||
logger.error(f"VC key migration failed (non-fatal — v0.3 path intact): {exc}")
|
||||
|
||||
|
||||
# ── Operator auth routes (TASK-06-02, D-057) ───────────────────────────
|
||||
# Mounted BEFORE the StaticFiles mount so /api/operator/* is matched by
|
||||
# the router (routes-before-static-mount constraint, carry-forward v0.2).
|
||||
app.include_router(auth_router)
|
||||
|
||||
# ── Operator API cohort endpoints (TASK-10-02, D-053, D-057) ──────────
|
||||
# Auth-gated via Depends(current_operator) inside each router. Mounted
|
||||
# BEFORE the SPA StaticFiles fallback so /api/operator/* is matched by the
|
||||
# API routers, not the SPA fallback.
|
||||
app.include_router(cohort_router)
|
||||
app.include_router(mastery_router)
|
||||
app.include_router(failure_router)
|
||||
app.include_router(credentials_router)
|
||||
|
||||
|
||||
# ── SPA StaticFiles fallback (G-041 binding, TASK-10-01, R-DASH-03/05) ─
|
||||
# Custom StaticFiles subclass that returns index.html for non-file paths
|
||||
# (SPA client-side routing). G-041 OVERRIDES the plan's catch-all route —
|
||||
# a @app.get("/{path:path}") catch-all before StaticFiles would shadow
|
||||
# asset serving (assertion 8 in TASK-10-04). This subclass serves assets
|
||||
# normally (JS/CSS) and falls back to index.html for client-side routes
|
||||
# (/operator/dashboard, /operator/login). API routes registered above take
|
||||
# precedence over the mount.
|
||||
class SpaStaticFiles(StaticFiles):
|
||||
async def get_response(self, path: str, scope):
|
||||
try:
|
||||
return await super().get_response(path, scope)
|
||||
except (StarletteHTTPException, HTTPException) as e:
|
||||
if getattr(e, "status_code", None) == 404:
|
||||
import os
|
||||
index = os.path.join(self.directory, "index.html")
|
||||
if os.path.isfile(index):
|
||||
return FileResponse(index)
|
||||
raise
|
||||
|
||||
|
||||
# Mount client/dist at "/" AFTER all API routes so they take precedence.
|
||||
# html=True serves index.html for "/" (SPA root). The SpaStaticFiles
|
||||
# subclass serves index.html for unknown paths (React Router routes).
|
||||
_CLIENT_DIST = _env("PRAXIS_CLIENT_DIST", "client/dist")
|
||||
if os.path.isdir(_CLIENT_DIST):
|
||||
app.mount("/", StaticFiles(directory=_CLIENT_DIST, html=True), name="client")
|
||||
logger.info(f"Serving client from {_CLIENT_DIST}")
|
||||
app.mount("/", SpaStaticFiles(directory=_CLIENT_DIST, html=True), name="spa")
|
||||
logger.info(f"Serving client from {_CLIENT_DIST} (SPA fallback enabled)")
|
||||
else:
|
||||
logger.warning(f"Client dist not found at {_CLIENT_DIST} — API-only mode")
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Signed cookie configuration (TASK-03-02, D-041, D-056, R-AUTH-01, G-031).
|
||||
|
||||
Returns kwargs for Starlette SessionMiddleware (itsdangerous HMAC-SHA256
|
||||
signed cookies — D-056, stateless, no sessions table). The cookie name is
|
||||
`praxis_op` (distinct from any future learner cookie).
|
||||
|
||||
R-AUTH-01 / G-031 reframe: the PRIMARY mitigation for a sniffed operator
|
||||
cookie is the k-anonymity defense-in-depth — the cohort dashboard reads
|
||||
only k-anonymized aggregates, so a sniffed cookie leaks NO learner PII.
|
||||
The `PRAXIS_COOKIE_SECURE` flag is the SECONDARY mitigation (operational
|
||||
convenience for when TLS arrives). It defaults to true; the HTTP pilot
|
||||
(LXC, no TLS — D-030) sets it to false with a logged WARNING.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import secrets
|
||||
|
||||
from loguru import logger
|
||||
|
||||
_COOKIE_MAX_AGE_S = 28800 # 8h (D-041)
|
||||
|
||||
|
||||
def _env_bool(key: str, default: bool) -> bool:
|
||||
raw = os.environ.get(key, "").strip().lower()
|
||||
if raw in ("true", "1", "yes", "on"):
|
||||
return True
|
||||
if raw in ("false", "0", "no", "off"):
|
||||
return False
|
||||
return default
|
||||
|
||||
|
||||
def get_session_middleware_kwargs() -> dict:
|
||||
"""Return kwargs for Starlette SessionMiddleware.
|
||||
|
||||
If PRAXIS_COOKIE_SECRET is unset, generate an ephemeral random secret
|
||||
and log a WARNING (dev only — sessions won't survive a restart and this
|
||||
MUST NOT be used in pilot/production).
|
||||
"""
|
||||
secret = os.environ.get("PRAXIS_COOKIE_SECRET", "").strip()
|
||||
if not secret:
|
||||
secret = secrets.token_urlsafe(48)
|
||||
logger.warning(
|
||||
"PRAXIS_COOKIE_SECRET not set — generated an ephemeral random secret. "
|
||||
"Sessions will NOT survive a server restart. This is dev-only; set "
|
||||
"PRAXIS_COOKIE_SECRET (>=32 bytes) for pilot/production."
|
||||
)
|
||||
secure = _env_bool("PRAXIS_COOKIE_SECURE", True)
|
||||
if not secure:
|
||||
logger.warning(
|
||||
"Cookie Secure flag disabled (PRAXIS_COOKIE_SECURE=false) — HTTP pilot "
|
||||
"mode (R-AUTH-01). Do not use in production. NOTE (G-031): the primary "
|
||||
"R-AUTH-01 mitigation is k-anon defense-in-depth (cohort dashboard reads "
|
||||
"only k-anonymized aggregates → sniffed cookie leaks no PII); this flag "
|
||||
"is the secondary mitigation."
|
||||
)
|
||||
return {
|
||||
"secret_key": secret,
|
||||
"session_cookie": "praxis_op",
|
||||
"max_age": _COOKIE_MAX_AGE_S,
|
||||
"https_only": secure,
|
||||
"same_site": "strict",
|
||||
"path": "/",
|
||||
}
|
||||
|
||||
|
||||
__all__ = ["get_session_middleware_kwargs"]
|
||||
@@ -0,0 +1,56 @@
|
||||
"""current_operator dependency (TASK-03-04, D-057).
|
||||
|
||||
Server-side auth enforcement: every `/api/operator/*` protected route uses
|
||||
`Depends(current_operator)`. The dependency NEVER trusts the client (D-057)
|
||||
— it reads the signed-cookie session, fetches the operator from Postgres,
|
||||
and 401s on any gap (missing/invalid/expired cookie, unknown id, inactive
|
||||
operator). The cookie is the authz *token*; the Postgres lookup is the
|
||||
authz *decision*.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import HTTPException, Request, status
|
||||
|
||||
from server.auth.models import Operator
|
||||
|
||||
|
||||
async def current_operator(request: Request) -> Operator:
|
||||
"""Resolve the authenticated operator from the signed-cookie session.
|
||||
|
||||
Raises 401 on: missing session, missing operator_id, no Postgres store
|
||||
(503 actually — operator tier unavailable), unknown operator id, or an
|
||||
inactive operator (session is cleared in the latter case so the client
|
||||
cookie is invalidated).
|
||||
"""
|
||||
pg_store = getattr(request.app.state, "pg_store", None)
|
||||
if pg_store is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="operator tier unavailable (no Postgres)",
|
||||
)
|
||||
session = request.session
|
||||
op_id = session.get("operator_id") if session else None
|
||||
if not op_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="not authenticated",
|
||||
)
|
||||
row = await pg_store.get_operator_by_id(op_id)
|
||||
if row is None or not row.get("is_active"):
|
||||
# Inactive/unknown → clear the session so the cookie is invalidated.
|
||||
if session:
|
||||
session.clear()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="not authenticated",
|
||||
)
|
||||
return Operator(
|
||||
id=str(row["id"]),
|
||||
username=row["username"],
|
||||
display_name=row.get("display_name"),
|
||||
role=row.get("role", "operator"),
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["current_operator"]
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Auth data models (TASK-03-04)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class Operator:
|
||||
"""The authenticated operator injected into protected routes (D-057)."""
|
||||
|
||||
id: str
|
||||
username: str
|
||||
display_name: str | None
|
||||
role: str
|
||||
|
||||
|
||||
__all__ = ["Operator"]
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Argon2id password hashing (TASK-03-01, D-041, REQ-NFR-AUTH-01).
|
||||
|
||||
Uses argon2-cffi PasswordHasher with defaults that exceed OWASP minimums
|
||||
(time_cost=3, memory_cost=64MiB, parallelism=4 — RESEARCH-v0.4 §2.1).
|
||||
Single operator, low-frequency logins → hashing latency < 1s is
|
||||
acceptable (R-AUTH-02).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from argon2 import PasswordHasher
|
||||
from argon2.exceptions import VerifyMismatchError
|
||||
|
||||
_ph = PasswordHasher()
|
||||
|
||||
|
||||
def hash_password(plain: str) -> str:
|
||||
"""Hash a plaintext password with argon2id. Returns the encoded hash string."""
|
||||
return _ph.hash(plain)
|
||||
|
||||
|
||||
def verify_password(stored_hash: str, plain: str) -> bool:
|
||||
"""Verify a plaintext password against a stored argon2id hash.
|
||||
|
||||
Returns False on mismatch (no exception) so the login flow can apply a
|
||||
uniform 401 + rate-limit-increment path on any auth failure.
|
||||
"""
|
||||
try:
|
||||
_ph.verify(stored_hash, plain)
|
||||
return True
|
||||
except VerifyMismatchError:
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def needs_rehash(stored_hash: str) -> bool:
|
||||
"""True if the stored hash was produced with weaker params than the
|
||||
current PasswordHasher defaults. The login flow rehashes + updates the
|
||||
store when this returns True (param upgrades without forcing a reset)."""
|
||||
return _ph.check_needs_rehash(stored_hash)
|
||||
|
||||
|
||||
__all__ = ["hash_password", "verify_password", "needs_rehash"]
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Login rate limiting (TASK-03-03, D-041).
|
||||
|
||||
slowapi Limiter with an in-memory backend (single-instance — D-041).
|
||||
5 login attempts per minute per client IP. On exceed → 429 + Retry-After.
|
||||
|
||||
R-AUTH-03 (in-memory counter lost on restart) is an accepted pilot risk
|
||||
(RESEARCH-v0.4 §2.5) — a restart at most resets the counter, which slightly
|
||||
widens the brute-force window but does not enable it (argon2id + 5/min is
|
||||
still the binding control). A hand-rolled counter is the documented
|
||||
fallback if slowapi is ever removed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from slowapi import Limiter
|
||||
from slowapi.util import get_remote_address
|
||||
|
||||
limiter = Limiter(key_func=get_remote_address, storage_uri="memory://")
|
||||
|
||||
|
||||
def reset_login_rate_limit() -> None:
|
||||
"""Clear the in-memory rate-limit counters (test helper + restart-safe)."""
|
||||
try:
|
||||
limiter.reset()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def rate_limit_login():
|
||||
"""Decorator factory: 5 login attempts per minute per IP (D-041)."""
|
||||
return limiter.limit("5/minute")
|
||||
|
||||
|
||||
__all__ = ["limiter", "rate_limit_login", "reset_login_rate_limit"]
|
||||
@@ -0,0 +1,118 @@
|
||||
"""Auth route handlers — login, logout, me (TASK-03-05, D-041, D-056, D-057).
|
||||
|
||||
APIRouter(prefix="/api/operator") with:
|
||||
POST /login — rate-limited 5/min (TASK-03-03), NOT auth-gated.
|
||||
POST /logout — auth-gated (Depends(current_operator)).
|
||||
GET /me — auth-gated (React route guard — D-057).
|
||||
|
||||
Stateless cookies (D-056): logout clears the server-side session; the
|
||||
client also clears its cookie. No sessions table.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from pydantic import BaseModel
|
||||
|
||||
from server.auth.dependencies import current_operator
|
||||
from server.auth.models import Operator
|
||||
from server.auth.passwords import hash_password, needs_rehash, verify_password
|
||||
from server.auth.rate_limit import rate_limit_login
|
||||
|
||||
router = APIRouter(prefix="/api/operator", tags=["operator-auth"])
|
||||
|
||||
|
||||
class LoginBody(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class OperatorOut(BaseModel):
|
||||
id: str
|
||||
username: str
|
||||
display_name: str | None
|
||||
role: str = "operator"
|
||||
|
||||
|
||||
class LoginResponse(BaseModel):
|
||||
operator: OperatorOut
|
||||
|
||||
|
||||
class MeResponse(BaseModel):
|
||||
operator: OperatorOut
|
||||
|
||||
|
||||
class OkResponse(BaseModel):
|
||||
ok: bool = True
|
||||
|
||||
|
||||
def _operator_out(op: Operator) -> OperatorOut:
|
||||
return OperatorOut(
|
||||
id=op.id,
|
||||
username=op.username,
|
||||
display_name=op.display_name,
|
||||
role=op.role,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/login", response_model=LoginResponse)
|
||||
@rate_limit_login()
|
||||
async def login(body: LoginBody, request: Request) -> LoginResponse:
|
||||
"""Rate-limited login (5/min per IP — D-041).
|
||||
|
||||
On success: sets `request.session["operator_id"]` (signed cookie via
|
||||
SessionMiddleware) + updates last_login_at. On needs_rehash → rehash +
|
||||
update the store. On failure → 401 (no cookie set).
|
||||
"""
|
||||
pg_store = getattr(request.app.state, "pg_store", None)
|
||||
if pg_store is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="operator tier unavailable (no Postgres)",
|
||||
)
|
||||
row = await pg_store.get_operator_by_username(body.username)
|
||||
if row is None or not row.get("is_active"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="invalid credentials",
|
||||
)
|
||||
if not verify_password(row["password_hash"], body.password):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="invalid credentials",
|
||||
)
|
||||
op_id = str(row["id"])
|
||||
request.session["operator_id"] = op_id
|
||||
await pg_store.update_last_login(op_id)
|
||||
if needs_rehash(row["password_hash"]):
|
||||
new_hash = hash_password(body.password)
|
||||
async with pg_store.pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"UPDATE operators SET password_hash = $1 WHERE id = $2",
|
||||
new_hash, op_id,
|
||||
)
|
||||
return LoginResponse(
|
||||
operator=OperatorOut(
|
||||
id=op_id,
|
||||
username=row["username"],
|
||||
display_name=row.get("display_name"),
|
||||
role=row.get("role", "operator"),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@router.post("/logout", response_model=OkResponse)
|
||||
async def logout(request: Request, op: Operator = Depends(current_operator)) -> OkResponse:
|
||||
# Stateless (D-056): clearing the server session invalidates the signed
|
||||
# cookie's payload; the client also clears its cookie.
|
||||
request.session.clear()
|
||||
return OkResponse(ok=True)
|
||||
|
||||
|
||||
@router.get("/me", response_model=MeResponse)
|
||||
async def me(op: Operator = Depends(current_operator)) -> MeResponse:
|
||||
"""React route guard endpoint (D-057). 200 → render; 401 → redirect."""
|
||||
return MeResponse(operator=_operator_out(op))
|
||||
|
||||
|
||||
__all__ = ["router"]
|
||||
@@ -0,0 +1,230 @@
|
||||
"""Cohort aggregation logic + k-anonymity suppression (TASK-07-01, D-034, D-045).
|
||||
|
||||
Computes k-anonymized aggregates for the affected (path, metric, window_start)
|
||||
bins and upserts them to cohort_aggregates via PgStore. Suppression is at
|
||||
write time (auditable — RESEARCH-v0.4 §3.1): COUNT(DISTINCT learner_ref) < 10
|
||||
=> cell_suppressed=TRUE, value=NULL.
|
||||
|
||||
Metrics computed (per 7-day rolling window, per path):
|
||||
sessions_count, active_learners_count, gate_open_rate,
|
||||
median_mastery_score, failure_mode_frequency,
|
||||
rubric_criterion_means, week_distribution.
|
||||
|
||||
The session_outcome dict contains: learner_ref (opaque — D-031), path,
|
||||
scenario_id, outcome (pass/fail), rubric_scores, failure_mode, branch_path,
|
||||
timestamp.
|
||||
|
||||
No raw learner PII in Postgres (D-031): only aggregates + opaque learner_ref
|
||||
for distinct counting.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as _dt
|
||||
import logging
|
||||
import statistics
|
||||
from typing import Any
|
||||
|
||||
from db.pg_store import PgStore
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
K_ANON_THRESHOLD = 10
|
||||
|
||||
|
||||
def _rolling_window(now: _dt.datetime | None = None) -> tuple[_dt.date, _dt.date]:
|
||||
"""Return the 7-day rolling window (start, end) for `now`.
|
||||
|
||||
window_start = today - 6 days, window_end = today (inclusive 7-day span).
|
||||
"""
|
||||
today = (now or _dt.datetime.now(_dt.timezone.utc)).date()
|
||||
return today - _dt.timedelta(days=6), today
|
||||
|
||||
|
||||
def _distinct_learners(sessions: list[dict[str, Any]]) -> int:
|
||||
return len({s["learner_ref"] for s in sessions if s.get("learner_ref")})
|
||||
|
||||
|
||||
async def aggregate_session(pg_store: PgStore, session_outcome: dict[str, Any]) -> None:
|
||||
"""Compute + upsert k-anonymized aggregates for one session outcome.
|
||||
|
||||
Reads the affected path's recent session set (from cohort_aggregates or
|
||||
an in-memory accumulator), recomputes the metric cells for the 7-day
|
||||
window, applies k-anon suppression, and upserts each cell idempotently.
|
||||
|
||||
Idempotent (ON CONFLICT upsert) — re-running with the same outcome
|
||||
produces the same aggregate. The caller (hook.py) passes one session at
|
||||
a time; the nightly job (nightly.py) recomputes the full window.
|
||||
"""
|
||||
path = session_outcome.get("path") or session_outcome.get("path_id") or "unknown"
|
||||
learner_ref = session_outcome.get("learner_ref") or "unknown"
|
||||
outcome = session_outcome.get("outcome", "fail")
|
||||
rubric_scores = session_outcome.get("rubric_scores") or []
|
||||
failure_mode = session_outcome.get("failure_mode")
|
||||
branch_path = session_outcome.get("branch_path") or []
|
||||
scenario_id = session_outcome.get("scenario_id")
|
||||
ts = session_outcome.get("timestamp")
|
||||
|
||||
window_start, window_end = _rolling_window(
|
||||
_dt.datetime.fromisoformat(ts) if isinstance(ts, str) else None
|
||||
)
|
||||
|
||||
# Distinct-learner count for k-anon: this session's learner + any others
|
||||
# already recorded for the same (path, window). For the per-session hook
|
||||
# we accumulate by appending to a sessions_count cell + tracking distinct
|
||||
# learner_refs via active_learners_count. The nightly job recomputes from
|
||||
# the mastery_gate_events + session log (full reconciliation).
|
||||
#
|
||||
# For the on-session-end hook we cannot cheaply know all distinct learners
|
||||
# without a raw-events table (which we deliberately do not maintain for PII
|
||||
# reasons — D-031). We instead maintain a single active_learners_count
|
||||
# counter per (path, window) and the nightly job reconciles the true
|
||||
# distinct count from mastery_gate_events. The hook uses the running
|
||||
# counter; if it is < K_ANON_THRESHOLD we suppress.
|
||||
active_count = await _bump_active_learners(pg_store, path, window_start, learner_ref)
|
||||
sessions_count = await _bump_counter(pg_store, path, "sessions_count", window_start, window_end)
|
||||
|
||||
suppressed = active_count < K_ANON_THRESHOLD
|
||||
|
||||
await _upsert_cell(pg_store, path, "sessions_count", window_start, window_end,
|
||||
float(sessions_count) if not suppressed else None,
|
||||
active_count, suppressed)
|
||||
|
||||
await _upsert_cell(pg_store, path, "active_learners_count", window_start, window_end,
|
||||
float(active_count) if not suppressed else None,
|
||||
active_count, suppressed)
|
||||
|
||||
# gate_open_rate: 1.0 if this session passed, 0.0 otherwise (running mean
|
||||
# reconciled by nightly). Stored as the fraction of pass outcomes seen.
|
||||
passed = 1.0 if outcome == "pass" else 0.0
|
||||
gate_open_rate = await _running_mean(pg_store, path, "gate_open_rate",
|
||||
window_start, window_end, passed, active_count)
|
||||
await _upsert_cell(pg_store, path, "gate_open_rate", window_start, window_end,
|
||||
gate_open_rate if not suppressed else None,
|
||||
active_count, suppressed)
|
||||
|
||||
# median_mastery_score (from rubric scores) — running median reconciled nightly
|
||||
if rubric_scores:
|
||||
scores = [float(r.get("score", r.get("weighted_mean", 0.0))) for r in rubric_scores]
|
||||
scenario_mean = statistics.mean(scores) if scores else 0.0
|
||||
median_val = await _running_mean(pg_store, path, "median_mastery_score",
|
||||
window_start, window_end, scenario_mean, active_count)
|
||||
await _upsert_cell(pg_store, path, "median_mastery_score", window_start, window_end,
|
||||
median_val if not suppressed else None,
|
||||
active_count, suppressed)
|
||||
|
||||
# rubric_criterion_means — one cell per criterion id
|
||||
for r in rubric_scores:
|
||||
cid = r.get("criterion_id") or r.get("id") or "unknown"
|
||||
score = float(r.get("score", 0.0))
|
||||
mean_val = await _running_mean(pg_store, path, f"rubric_criterion_mean:{cid}",
|
||||
window_start, window_end, score, active_count)
|
||||
await _upsert_cell(pg_store, path, f"rubric_criterion_mean:{cid}",
|
||||
window_start, window_end,
|
||||
mean_val if not suppressed else None,
|
||||
active_count, suppressed)
|
||||
|
||||
# failure_mode_frequency — one cell per observed mode
|
||||
if failure_mode:
|
||||
freq = await _bump_mode_counter(pg_store, path, f"failure_mode:{failure_mode}",
|
||||
window_start, window_end)
|
||||
await _upsert_cell(pg_store, path, f"failure_mode:{failure_mode}",
|
||||
window_start, window_end,
|
||||
float(freq) if not suppressed else None,
|
||||
active_count, suppressed)
|
||||
|
||||
# week_distribution — branch_path captures the path-week; record one cell
|
||||
# per branch outcome seen.
|
||||
if branch_path:
|
||||
last_branch = branch_path[-1] if isinstance(branch_path, list) else str(branch_path)
|
||||
freq = await _bump_mode_counter(pg_store, path, f"branch:{last_branch}",
|
||||
window_start, window_end)
|
||||
await _upsert_cell(pg_store, path, f"branch:{last_branch}",
|
||||
window_start, window_end,
|
||||
float(freq) if not suppressed else None,
|
||||
active_count, suppressed)
|
||||
|
||||
log.debug(
|
||||
"aggregate_session path=%s learner=%s outcome=%s window=%s..%s "
|
||||
"active=%d suppressed=%s",
|
||||
path, learner_ref, outcome, window_start, window_end,
|
||||
active_count, suppressed,
|
||||
)
|
||||
|
||||
|
||||
# ── Internal cell upsert + counter helpers ──────────────────────────────────
|
||||
# The PgStore.upsert_cohort_aggregate is idempotent (ON CONFLICT). We use a
|
||||
# small in-memory cache on the PgStore instance (created lazily) to track
|
||||
# per-(path, metric, window) running counters + distinct learner sets. The
|
||||
# nightly job bypasses this cache and recomputes from mastery_gate_events.
|
||||
|
||||
|
||||
def _cache(pg_store: PgStore) -> dict:
|
||||
cache = getattr(pg_store, "_agg_cache", None)
|
||||
if not isinstance(cache, dict):
|
||||
cache = {}
|
||||
try:
|
||||
pg_store._agg_cache = cache # type: ignore[attr-defined]
|
||||
except Exception:
|
||||
pass
|
||||
return cache
|
||||
|
||||
|
||||
def _ck(path: str, metric: str, window_start: _dt.date) -> tuple:
|
||||
return (path, metric, window_start)
|
||||
|
||||
|
||||
async def _upsert_cell(pg_store: PgStore, path: str, metric: str,
|
||||
window_start: _dt.date, window_end: _dt.date,
|
||||
value: float | None, cell_count: int,
|
||||
suppressed: bool) -> None:
|
||||
await pg_store.upsert_cohort_aggregate(
|
||||
path, metric, window_start, window_end, value, cell_count, suppressed,
|
||||
)
|
||||
|
||||
|
||||
async def _bump_active_learners(pg_store: PgStore, path: str,
|
||||
window_start: _dt.date, learner_ref: str) -> int:
|
||||
"""Track distinct learner_refs per (path, window) in the in-memory cache.
|
||||
|
||||
Returns the current distinct count (after adding this learner). The
|
||||
nightly job reconciles the true count from mastery_gate_events.
|
||||
"""
|
||||
cache = _cache(pg_store)
|
||||
key = _ck(path, "__learners__", window_start)
|
||||
learners: set[str] = cache.get(key, set())
|
||||
learners.add(learner_ref)
|
||||
cache[key] = learners
|
||||
return len(learners)
|
||||
|
||||
|
||||
async def _bump_counter(pg_store: PgStore, path: str, metric: str,
|
||||
window_start: _dt.date, window_end: _dt.date) -> int:
|
||||
cache = _cache(pg_store)
|
||||
key = _ck(path, metric, window_start)
|
||||
cache[key] = cache.get(key, 0) + 1
|
||||
return cache[key]
|
||||
|
||||
|
||||
async def _bump_mode_counter(pg_store: PgStore, path: str, metric: str,
|
||||
window_start: _dt.date, window_end: _dt.date) -> int:
|
||||
return await _bump_counter(pg_store, path, metric, window_start, window_end)
|
||||
|
||||
|
||||
async def _running_mean(pg_store: PgStore, path: str, metric: str,
|
||||
window_start: _dt.date, window_end: _dt.date,
|
||||
value: float, _active_count: int) -> float:
|
||||
"""Incremental running mean per (path, metric, window)."""
|
||||
cache = _cache(pg_store)
|
||||
k = _ck(path, metric, window_start)
|
||||
n_key = _ck(path, metric + "__n__", window_start)
|
||||
n = cache.get(n_key, 0)
|
||||
prev = cache.get(k, 0.0)
|
||||
new_n = n + 1
|
||||
new_mean = prev + (value - prev) / new_n
|
||||
cache[k] = new_mean
|
||||
cache[n_key] = new_n
|
||||
return new_mean
|
||||
|
||||
|
||||
__all__ = ["aggregate_session", "K_ANON_THRESHOLD", "_rolling_window"]
|
||||
@@ -0,0 +1,44 @@
|
||||
"""On-session-end async aggregation hook (TASK-07-02, D-054).
|
||||
|
||||
Fire-and-forget: designed to be chained as an `asyncio.create_task` after
|
||||
the mastery flow. Failures log + the nightly job reconciles (no exception
|
||||
propagation to the caller — the session-end response returns immediately).
|
||||
|
||||
If `pg_store` is None (no Postgres), no-op + log WARNING.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from db.pg_store import PgStore
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def on_session_end(pg_store: PgStore | None, session_outcome: dict[str, Any]) -> None:
|
||||
"""Aggregate one session outcome. Non-blocking, fire-and-forget (D-054).
|
||||
|
||||
Failures are logged but never raised — the caller (session_recorder) has
|
||||
already returned its response; aggregation is off the voice path. The
|
||||
nightly job (nightly.py) reconciles any missed/hook-failed sessions.
|
||||
"""
|
||||
if pg_store is None:
|
||||
log.warning(
|
||||
"cohort aggregation skipped (no Postgres) for session %s",
|
||||
session_outcome.get("scenario_id"),
|
||||
)
|
||||
return
|
||||
try:
|
||||
from server.cohort.aggregator import aggregate_session
|
||||
|
||||
await aggregate_session(pg_store, session_outcome)
|
||||
except Exception:
|
||||
log.exception(
|
||||
"cohort aggregation hook failed for session %s — nightly job will reconcile",
|
||||
session_outcome.get("scenario_id"),
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["on_session_end"]
|
||||
@@ -0,0 +1,232 @@
|
||||
"""Nightly reconciliation scheduler (TASK-07-03, D-054, REQ-NFR-DASH-02).
|
||||
|
||||
In-process asyncio scheduler (no APScheduler — RESEARCH-v0.4 §3.4). Loops:
|
||||
compute seconds until next 03:00 CT (America/Winnipeg — Canada pilot) →
|
||||
asyncio.sleep → reconcile all 7-day windows → repeat. Resumes after restart.
|
||||
Failures log + retry next night (R-DASH-04).
|
||||
|
||||
Reconciliation recomputes all (path, metric, window_start) cells from the
|
||||
mastery_gate_events audit log + re-applies k-anonymity suppression. This
|
||||
guarantees REQ-NFR-DASH-02 (freshness ≤ 24h — the nightly job runs at least
|
||||
once/day) and reconciles any hook failures.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import datetime as _dt
|
||||
import logging
|
||||
import statistics
|
||||
from collections import Counter, defaultdict
|
||||
from typing import Any
|
||||
|
||||
from db.pg_store import PgStore
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
CT = _dt.timezone(_dt.timedelta(hours=-5), "CT")
|
||||
NIGHTLY_HOUR = 3
|
||||
NIGHTLY_MINUTE = 0
|
||||
|
||||
|
||||
def seconds_until_next_03_ct(now: _dt.datetime | None = None) -> float:
|
||||
"""Seconds from `now` until the next 03:00 America/Winnipeg (CT).
|
||||
|
||||
America/Winnipeg observes CST (UTC-6) in winter + CDT (UTC-5) in summer.
|
||||
We approximate CT as a fixed UTC-5 offset (the pilot is in summer CDT
|
||||
and the scheduler drift of ≤1h over DST boundaries is acceptable for a
|
||||
nightly reconciliation job — the on-session-end hook keeps data fresh).
|
||||
A future hardening would use zoneinfo.ZoneInfo("America/Winnipeg") with
|
||||
proper DST handling.
|
||||
"""
|
||||
now = now or _dt.datetime.now(CT)
|
||||
if now.tzinfo is None:
|
||||
now = now.replace(tzinfo=CT)
|
||||
next_run = now.replace(hour=NIGHTLY_HOUR, minute=NIGHTLY_MINUTE,
|
||||
second=0, microsecond=0)
|
||||
if next_run <= now:
|
||||
next_run += _dt.timedelta(days=1)
|
||||
return (next_run - now).total_seconds()
|
||||
|
||||
|
||||
class NightlyScheduler:
|
||||
"""In-process asyncio scheduler for nightly cohort reconciliation.
|
||||
|
||||
Started as an asyncio task in the app lifespan (TASK-10-02). Cancel on
|
||||
shutdown. R-DASH-04: a reconciliation failure logs + retries the next
|
||||
night (the loop continues).
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._task: asyncio.Task | None = None
|
||||
self._stopped = False
|
||||
|
||||
async def start(self, pg_store: PgStore) -> asyncio.Task:
|
||||
"""Begin the nightly loop. Returns the running task."""
|
||||
self._stopped = False
|
||||
self._task = asyncio.create_task(self._run_loop(pg_store))
|
||||
return self._task
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Cancel the running loop (graceful shutdown)."""
|
||||
self._stopped = True
|
||||
if self._task is not None:
|
||||
self._task.cancel()
|
||||
try:
|
||||
await self._task
|
||||
except (asyncio.CancelledError, Exception):
|
||||
pass
|
||||
self._task = None
|
||||
|
||||
async def _run_loop(self, pg_store: PgStore) -> None:
|
||||
while not self._stopped:
|
||||
try:
|
||||
secs = seconds_until_next_03_ct()
|
||||
log.info("nightly scheduler: next run in %.0fs (03:00 CT)", secs)
|
||||
await asyncio.sleep(secs)
|
||||
if self._stopped:
|
||||
return
|
||||
await self._reconcile(pg_store)
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
except Exception:
|
||||
log.exception("nightly reconciliation failed — retry next night (R-DASH-04)")
|
||||
# brief sleep to avoid a tight error loop if the clock is broken
|
||||
await asyncio.sleep(60)
|
||||
|
||||
async def _reconcile(self, pg_store: PgStore) -> None:
|
||||
"""Recompute all 7-day windows for all paths from mastery_gate_events.
|
||||
|
||||
Reads recent gate events (the audit log, REQ-NFR-MAST-02), groups by
|
||||
(path, window_start), recomputes each metric cell, applies k-anon
|
||||
suppression, and upserts. Idempotent — re-running produces the same
|
||||
aggregates (ON CONFLICT upsert).
|
||||
"""
|
||||
events = await _load_recent_events(pg_store)
|
||||
if not events:
|
||||
log.info("nightly reconcile: no recent gate events; nothing to recompute")
|
||||
return
|
||||
|
||||
# Group by path → window_start → list[events]
|
||||
by_path_window: dict[tuple[str, _dt.date], list[dict[str, Any]]] = defaultdict(list)
|
||||
today = _dt.datetime.now(_dt.timezone.utc).date()
|
||||
window_start = today - _dt.timedelta(days=6)
|
||||
for ev in events:
|
||||
ev_date = _coerce_date(ev.get("recorded_at"))
|
||||
if ev_date is None or ev_date < window_start:
|
||||
continue
|
||||
path = ev.get("path_id") or "unknown"
|
||||
by_path_window[(path, window_start)].append(ev)
|
||||
|
||||
from server.cohort.aggregator import K_ANON_THRESHOLD, _rolling_window
|
||||
|
||||
ws, we = _rolling_window()
|
||||
for (path, _), evs in by_path_window.items():
|
||||
learners = {e.get("learner_ref") for e in evs if e.get("learner_ref")}
|
||||
active_count = len(learners)
|
||||
suppressed = active_count < K_ANON_THRESHOLD
|
||||
|
||||
# sessions_count
|
||||
await pg_store.upsert_cohort_aggregate(
|
||||
path, "sessions_count", ws, we,
|
||||
None if suppressed else float(len(evs)),
|
||||
active_count, suppressed,
|
||||
)
|
||||
# active_learners_count
|
||||
await pg_store.upsert_cohort_aggregate(
|
||||
path, "active_learners_count", ws, we,
|
||||
None if suppressed else float(active_count),
|
||||
active_count, suppressed,
|
||||
)
|
||||
# gate_open_rate
|
||||
gate_opens = sum(1 for e in evs if (e.get("gate_outcome") or "") == "open")
|
||||
rate = gate_opens / len(evs) if evs else 0.0
|
||||
await pg_store.upsert_cohort_aggregate(
|
||||
path, "gate_open_rate", ws, we,
|
||||
None if suppressed else rate,
|
||||
active_count, suppressed,
|
||||
)
|
||||
# median_mastery_score + rubric_criterion_means from rubric_scores_jsonb
|
||||
score_rows: list[float] = []
|
||||
crit_scores: dict[str, list[float]] = defaultdict(list)
|
||||
for e in evs:
|
||||
scores = e.get("rubric_scores") or []
|
||||
if isinstance(scores, str):
|
||||
import json as _json
|
||||
try:
|
||||
scores = _json.loads(scores)
|
||||
except Exception:
|
||||
scores = []
|
||||
for r in scores:
|
||||
if isinstance(r, dict):
|
||||
cid = r.get("criterion_id") or r.get("id") or "unknown"
|
||||
s = r.get("score") or r.get("weighted_mean")
|
||||
if s is not None:
|
||||
crit_scores[cid].append(float(s))
|
||||
score_rows.append(float(s))
|
||||
if score_rows:
|
||||
med = statistics.median(score_rows)
|
||||
await pg_store.upsert_cohort_aggregate(
|
||||
path, "median_mastery_score", ws, we,
|
||||
None if suppressed else med,
|
||||
active_count, suppressed,
|
||||
)
|
||||
for cid, vals in crit_scores.items():
|
||||
mean_v = statistics.mean(vals) if vals else 0.0
|
||||
await pg_store.upsert_cohort_aggregate(
|
||||
path, f"rubric_criterion_mean:{cid}", ws, we,
|
||||
None if suppressed else mean_v,
|
||||
active_count, suppressed,
|
||||
)
|
||||
|
||||
log.info("nightly reconcile: recomputed %d (path, window) cells", len(by_path_window))
|
||||
|
||||
async def reconcile_now(self, pg_store: PgStore) -> None:
|
||||
"""Public hook for tests / ad-hoc reconciliation (no clock wait)."""
|
||||
await self._reconcile(pg_store)
|
||||
|
||||
|
||||
async def _load_recent_events(pg_store: PgStore) -> list[dict[str, Any]]:
|
||||
"""Load mastery_gate_events from the last 7 days.
|
||||
|
||||
Uses the PgStore pool directly (no extra method on PgStore to keep the
|
||||
surface minimal). Returns rows as dicts with decoded rubric_scores.
|
||||
"""
|
||||
async with pg_store.pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
"SELECT learner_ref, scenario_id, path_id, gate_outcome, "
|
||||
"rubric_scores_jsonb, recorded_at "
|
||||
"FROM mastery_gate_events "
|
||||
"WHERE recorded_at >= now() - interval '7 days' "
|
||||
"ORDER BY recorded_at"
|
||||
)
|
||||
out: list[dict[str, Any]] = []
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
scores = d.get("rubric_scores_jsonb")
|
||||
if hasattr(scores, "resolve"):
|
||||
try:
|
||||
import json as _json
|
||||
d["rubric_scores"] = _json.loads(scores.resolve()) if scores else []
|
||||
except Exception:
|
||||
d["rubric_scores"] = []
|
||||
else:
|
||||
d["rubric_scores"] = scores
|
||||
out.append(d)
|
||||
return out
|
||||
|
||||
|
||||
def _coerce_date(val: Any) -> _dt.date | None:
|
||||
if val is None:
|
||||
return None
|
||||
if isinstance(val, _dt.datetime):
|
||||
return val.date()
|
||||
if isinstance(val, _dt.date):
|
||||
return val
|
||||
try:
|
||||
return _dt.datetime.fromisoformat(str(val)).date()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
__all__ = ["NightlyScheduler", "seconds_until_next_03_ct", "CT"]
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Shared helpers for operator API endpoints (SLICE-08).
|
||||
|
||||
Common response models + the recent-aggregates query used by all 3 cohort
|
||||
view endpoints (cohort, mastery, failure-patterns). Kept here to avoid
|
||||
duplicating the Pydantic models + pool query across 3 files.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as _dt
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException, Request, status
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class Cell(BaseModel):
|
||||
metric: str
|
||||
window_start: _dt.date | None = None
|
||||
window_end: _dt.date | None = None
|
||||
value: float | None = None
|
||||
cell_count: int = 0
|
||||
cell_suppressed: bool = False
|
||||
updated_at: _dt.datetime | None = None
|
||||
|
||||
|
||||
class PathView(BaseModel):
|
||||
path: str
|
||||
metrics: list[Cell]
|
||||
|
||||
|
||||
class ViewResponse(BaseModel):
|
||||
views: list[PathView]
|
||||
last_updated: _dt.datetime | None = None
|
||||
|
||||
|
||||
async def require_pg_store(request: Request):
|
||||
pg_store = getattr(request.app.state, "pg_store", None)
|
||||
if pg_store is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="operator tier unavailable (no Postgres)",
|
||||
)
|
||||
return pg_store
|
||||
|
||||
|
||||
async def all_recent_aggregates(pg_store, since: _dt.date) -> list[dict[str, Any]]:
|
||||
async with pg_store.pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
"SELECT path, metric, window_start, window_end, value, "
|
||||
"cell_count, cell_suppressed, updated_at "
|
||||
"FROM cohort_aggregates WHERE window_start >= $1 "
|
||||
"ORDER BY path, metric, window_start",
|
||||
since,
|
||||
)
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def cell_from_row(row: dict[str, Any]) -> Cell:
|
||||
return Cell(
|
||||
metric=row.get("metric", ""),
|
||||
window_start=row.get("window_start"),
|
||||
window_end=row.get("window_end"),
|
||||
value=float(row["value"]) if row.get("value") is not None else None,
|
||||
cell_count=int(row.get("cell_count") or 0),
|
||||
cell_suppressed=bool(row.get("cell_suppressed") or False),
|
||||
updated_at=row.get("updated_at"),
|
||||
)
|
||||
|
||||
|
||||
def group_by_path(
|
||||
rows: list[dict[str, Any]],
|
||||
metric_filter: set[str] | None = None,
|
||||
) -> tuple[list[PathView], _dt.datetime | None]:
|
||||
by_path: dict[str, list[dict[str, Any]]] = {}
|
||||
last_updated: _dt.datetime | None = None
|
||||
for r in rows:
|
||||
if metric_filter is not None and r.get("metric") not in metric_filter:
|
||||
continue
|
||||
by_path.setdefault(r["path"], []).append(r)
|
||||
ua = r.get("updated_at")
|
||||
if isinstance(ua, _dt.datetime) and (last_updated is None or ua > last_updated):
|
||||
last_updated = ua
|
||||
views = [PathView(path=p, metrics=[cell_from_row(c) for c in cells])
|
||||
for p, cells in by_path.items()]
|
||||
return views, last_updated
|
||||
|
||||
|
||||
__all__ = [
|
||||
"Cell", "PathView", "ViewResponse",
|
||||
"require_pg_store", "all_recent_aggregates",
|
||||
"cell_from_row", "group_by_path",
|
||||
]
|
||||
@@ -0,0 +1,42 @@
|
||||
"""GET /api/operator/cohort — practice volume view (TASK-08-01, D-053, D-057).
|
||||
|
||||
Auth-gated (Depends(current_operator)). Returns k-anonymized practice-volume
|
||||
aggregates from cohort_aggregates: sessions_count + active_learners_count per
|
||||
path. Suppressed cells have value=null + cell_suppressed=true; the frontend
|
||||
renders \"— (<10 learners)\". No per-learner drill-down (R-DASH-02).
|
||||
last_updated = max(updated_at) for freshness (REQ-NFR-DASH-02).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as _dt
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
|
||||
from server.auth.dependencies import current_operator
|
||||
from server.auth.models import Operator
|
||||
from server.operator._common import (
|
||||
ViewResponse,
|
||||
all_recent_aggregates,
|
||||
group_by_path,
|
||||
require_pg_store,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/operator", tags=["operator-cohort"])
|
||||
|
||||
PRACTICE_METRICS = {"sessions_count", "active_learners_count"}
|
||||
|
||||
|
||||
@router.get("/cohort", response_model=ViewResponse)
|
||||
async def cohort_view(
|
||||
request: Request,
|
||||
op: Operator = Depends(current_operator),
|
||||
) -> ViewResponse:
|
||||
pg_store = await require_pg_store(request)
|
||||
since = _dt.date.today() - _dt.timedelta(days=30)
|
||||
rows = await all_recent_aggregates(pg_store, since)
|
||||
views, last_updated = group_by_path(rows, PRACTICE_METRICS)
|
||||
return ViewResponse(views=views, last_updated=last_updated)
|
||||
|
||||
|
||||
__all__ = ["router"]
|
||||
@@ -0,0 +1,78 @@
|
||||
"""GET/POST /api/operator/credentials — VC management (TASK-08-04, D-057).
|
||||
|
||||
Auth-gated. GET lists issued VCs from Postgres issued_credentials (operator's
|
||||
issuance log). POST /{id}/revoke revokes a VC (status='revoked',
|
||||
revoked_at=now()). Revoked credentials fail verification. No PII beyond what
|
||||
the credential asserts (D-043).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as _dt
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from pydantic import BaseModel
|
||||
|
||||
from server.auth.dependencies import current_operator
|
||||
from server.auth.models import Operator
|
||||
from server.operator._common import require_pg_store
|
||||
|
||||
router = APIRouter(prefix="/api/operator", tags=["operator-credentials"])
|
||||
|
||||
|
||||
class CredentialOut(BaseModel):
|
||||
id: str
|
||||
learner_ref: str
|
||||
vc_type: str | None = None
|
||||
status: str
|
||||
issued_at: _dt.datetime | None = None
|
||||
revoked_at: _dt.datetime | None = None
|
||||
|
||||
|
||||
class CredentialListResponse(BaseModel):
|
||||
credentials: list[CredentialOut]
|
||||
|
||||
|
||||
class OkResponse(BaseModel):
|
||||
ok: bool = True
|
||||
id: str
|
||||
status: str
|
||||
|
||||
|
||||
@router.get("/credentials", response_model=CredentialListResponse)
|
||||
async def list_credentials(
|
||||
request: Request,
|
||||
op: Operator = Depends(current_operator),
|
||||
) -> CredentialListResponse:
|
||||
pg_store = await require_pg_store(request)
|
||||
rows = await pg_store.list_credentials()
|
||||
creds = [
|
||||
CredentialOut(
|
||||
id=str(r["id"]),
|
||||
learner_ref=r["learner_ref"],
|
||||
vc_type=r.get("vc_type"),
|
||||
status=r.get("status", "active"),
|
||||
issued_at=r.get("issued_at"),
|
||||
revoked_at=r.get("revoked_at"),
|
||||
)
|
||||
for r in rows
|
||||
]
|
||||
return CredentialListResponse(credentials=creds)
|
||||
|
||||
|
||||
@router.post("/credentials/{cred_id}/revoke", response_model=OkResponse)
|
||||
async def revoke_credential(
|
||||
cred_id: str,
|
||||
request: Request,
|
||||
op: Operator = Depends(current_operator),
|
||||
) -> OkResponse:
|
||||
pg_store = await require_pg_store(request)
|
||||
row = await pg_store.get_credential(cred_id)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="credential not found")
|
||||
await pg_store.set_credential_status(cred_id, "revoked")
|
||||
return OkResponse(ok=True, id=cred_id, status="revoked")
|
||||
|
||||
|
||||
__all__ = ["router"]
|
||||
@@ -0,0 +1,44 @@
|
||||
"""GET /api/operator/failure-patterns — failure patterns view (TASK-08-03, D-053).
|
||||
|
||||
Auth-gated. Returns failure pattern metrics: failure_mode frequency (cells
|
||||
with metric prefix `failure_mode:`) + branch outcome distribution (cells
|
||||
with metric prefix `branch:`). Weak-spot rubric criteria (mean < 3.0) are
|
||||
highlighted by the frontend. All k-anonymized.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as _dt
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
|
||||
from server.auth.dependencies import current_operator
|
||||
from server.auth.models import Operator
|
||||
from server.operator._common import (
|
||||
ViewResponse,
|
||||
all_recent_aggregates,
|
||||
group_by_path,
|
||||
require_pg_store,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/operator", tags=["operator-failure-patterns"])
|
||||
|
||||
|
||||
def _is_failure_metric(metric: str) -> bool:
|
||||
return metric.startswith("failure_mode:") or metric.startswith("branch:")
|
||||
|
||||
|
||||
@router.get("/failure-patterns", response_model=ViewResponse)
|
||||
async def failure_patterns_view(
|
||||
request: Request,
|
||||
op: Operator = Depends(current_operator),
|
||||
) -> ViewResponse:
|
||||
pg_store = await require_pg_store(request)
|
||||
since = _dt.date.today() - _dt.timedelta(days=30)
|
||||
rows = await all_recent_aggregates(pg_store, since)
|
||||
failure_rows = [r for r in rows if _is_failure_metric(r.get("metric", ""))]
|
||||
views, last_updated = group_by_path(failure_rows)
|
||||
return ViewResponse(views=views, last_updated=last_updated)
|
||||
|
||||
|
||||
__all__ = ["router"]
|
||||
@@ -0,0 +1,45 @@
|
||||
"""GET /api/operator/mastery — mastery progression view (TASK-08-02, D-053).
|
||||
|
||||
Auth-gated. Returns mastery progression metrics: gate_open_rate,
|
||||
median_mastery_score, rubric_criterion_means (cells with metric prefix
|
||||
`rubric_criterion_mean:`). All k-anonymized (suppressed if < 10).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as _dt
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
|
||||
from server.auth.dependencies import current_operator
|
||||
from server.auth.models import Operator
|
||||
from server.operator._common import (
|
||||
ViewResponse,
|
||||
all_recent_aggregates,
|
||||
group_by_path,
|
||||
require_pg_store,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/operator", tags=["operator-mastery"])
|
||||
|
||||
MASTERY_METRICS = {"gate_open_rate", "median_mastery_score"}
|
||||
|
||||
|
||||
def _is_mastery_metric(metric: str) -> bool:
|
||||
return metric in MASTERY_METRICS or metric.startswith("rubric_criterion_mean:")
|
||||
|
||||
|
||||
@router.get("/mastery", response_model=ViewResponse)
|
||||
async def mastery_view(
|
||||
request: Request,
|
||||
op: Operator = Depends(current_operator),
|
||||
) -> ViewResponse:
|
||||
pg_store = await require_pg_store(request)
|
||||
since = _dt.date.today() - _dt.timedelta(days=30)
|
||||
rows = await all_recent_aggregates(pg_store, since)
|
||||
mastery_rows = [r for r in rows if _is_mastery_metric(r.get("metric", ""))]
|
||||
views, last_updated = group_by_path(mastery_rows)
|
||||
return ViewResponse(views=views, last_updated=last_updated)
|
||||
|
||||
|
||||
__all__ = ["router"]
|
||||
@@ -16,6 +16,7 @@ No auth — learner_id is the hardcoded 'learner-1' (D-007).
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import datetime as _dt
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
@@ -27,6 +28,10 @@ from server.cost import CostBreakdown, derive_cost
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return _dt.datetime.now(_dt.timezone.utc).isoformat()
|
||||
|
||||
|
||||
class SessionRecorder:
|
||||
"""Records a voice session to SQLite (TASK-04-03)."""
|
||||
|
||||
@@ -35,10 +40,12 @@ class SessionRecorder:
|
||||
store: PraxisStore,
|
||||
learner_id: str = HARDCODED_LEARNER_ID,
|
||||
scenario_id: str = "cs_refund_ca_v01",
|
||||
pg_store: Any = None,
|
||||
) -> None:
|
||||
self.store = store
|
||||
self.learner_id = learner_id
|
||||
self.scenario_id = scenario_id
|
||||
self.pg_store = pg_store
|
||||
self.session_id: str | None = None
|
||||
self._turn_seq = 0
|
||||
# Cost inputs accumulated over the session.
|
||||
@@ -143,8 +150,53 @@ class SessionRecorder:
|
||||
asyncio.create_task(
|
||||
self._run_mastery_flow_guarded(mastery_deps)
|
||||
)
|
||||
|
||||
# v0.4 P2 (D-054): fire-and-forget cohort aggregation hook. Runs in
|
||||
# parallel with the mastery flow — aggregation only needs the session
|
||||
# outcome (available after session end), not the mastery scoring
|
||||
# result. Rubric-dependent metrics are reconciled by the nightly job.
|
||||
# Off the voice path (C-8, D-054). No-op if pg_store is None.
|
||||
if self.pg_store is not None:
|
||||
session_outcome = self._build_session_outcome(outcome)
|
||||
asyncio.create_task(self._run_cohort_aggregation(session_outcome))
|
||||
return breakdown
|
||||
|
||||
def _build_session_outcome(self, outcome: str) -> dict[str, Any]:
|
||||
"""Construct the session_outcome dict for the aggregation hook."""
|
||||
rubric_scores: list[dict[str, Any]] = []
|
||||
if self.mastery_result and isinstance(self.mastery_result, dict):
|
||||
rubric_scores = list(self.mastery_result.get("rubric_scores") or [])
|
||||
return {
|
||||
"learner_ref": self.learner_id,
|
||||
"path": self._path_slug(),
|
||||
"scenario_id": self.scenario_id,
|
||||
"outcome": outcome,
|
||||
"rubric_scores": rubric_scores,
|
||||
"failure_mode": self._failure_mode(),
|
||||
"branch_path": list(self._branch_path),
|
||||
"timestamp": _now_iso(),
|
||||
}
|
||||
|
||||
def _path_slug(self) -> str:
|
||||
# The scenario_id encodes the path loosely; default to customer_service.
|
||||
if self.scenario_id and self.scenario_id.startswith("cs_"):
|
||||
return "customer_service"
|
||||
return "default"
|
||||
|
||||
def _failure_mode(self) -> str | None:
|
||||
if self.mastery_result and isinstance(self.mastery_result, dict):
|
||||
return self.mastery_result.get("failure_mode")
|
||||
return None
|
||||
|
||||
async def _run_cohort_aggregation(self, session_outcome: dict[str, Any]) -> None:
|
||||
"""Fire-and-forget wrapper around the cohort aggregation hook (D-054)."""
|
||||
try:
|
||||
from server.cohort.hook import on_session_end
|
||||
|
||||
await on_session_end(self.pg_store, session_outcome)
|
||||
except Exception:
|
||||
log.exception("cohort aggregation dispatch failed for session %s", self.session_id)
|
||||
|
||||
async def _run_mastery_flow_guarded(self, deps: "MasteryFlowDeps") -> None:
|
||||
try:
|
||||
await self.run_mastery_flow(deps)
|
||||
|
||||
+35
-11
@@ -13,6 +13,7 @@ import base64
|
||||
import os
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Protocol, runtime_checkable
|
||||
|
||||
import nacl.secret
|
||||
import nacl.signing
|
||||
@@ -22,6 +23,27 @@ from db.store import PraxisStore
|
||||
_SECRETBOX_KEY_BYTES = nacl.secret.SecretBox.KEY_SIZE
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class IssuerKeyStore(Protocol):
|
||||
"""Issuer key store protocol (D-051, TASK-04-01).
|
||||
|
||||
Both PraxisStore (SQLite, v0.3) and PgStore (Postgres, v0.4) implement
|
||||
this protocol — R-VC-MIG-03 mitigation (both stores share the same
|
||||
interface so verification can use either). The structural check lets
|
||||
`isinstance(store, IssuerKeyStore)` succeed for duck-typed stores.
|
||||
"""
|
||||
|
||||
async def init_issuer_key(
|
||||
self, key_id: str, public_key: str, private_key_enc: bytes
|
||||
) -> None: ...
|
||||
|
||||
async def get_active_signing_key_row(self) -> dict | None: ...
|
||||
|
||||
async def get_public_key_row(self, key_id: str) -> dict | None: ...
|
||||
|
||||
async def set_issuer_key_superseded(self, key_id: str) -> None: ...
|
||||
|
||||
|
||||
def _load_root_key() -> bytes:
|
||||
raw = os.environ.get("PRAXIS_VC_ISSUER_KEY", "")
|
||||
if raw:
|
||||
@@ -63,7 +85,7 @@ def _decrypt_private_key(private_key_enc: bytes, root_key: bytes) -> nacl.signin
|
||||
return nacl.signing.SigningKey(seed)
|
||||
|
||||
|
||||
async def init_issuer_key(store: PraxisStore, root_key: bytes | None = None) -> KeyPair:
|
||||
async def init_issuer_key(store: IssuerKeyStore, root_key: bytes | None = None) -> KeyPair:
|
||||
rk = root_key if root_key is not None else _load_root_key()
|
||||
signing_key = nacl.signing.SigningKey.generate()
|
||||
verify_key = signing_key.verify_key
|
||||
@@ -75,7 +97,7 @@ async def init_issuer_key(store: PraxisStore, root_key: bytes | None = None) ->
|
||||
|
||||
|
||||
async def get_active_signing_key(
|
||||
store: PraxisStore, root_key: bytes | None = None
|
||||
store: IssuerKeyStore, root_key: bytes | None = None
|
||||
) -> tuple[KeyPair, bytes]:
|
||||
rk = root_key if root_key is not None else _load_root_key()
|
||||
row = await store.get_active_signing_key_row()
|
||||
@@ -89,18 +111,19 @@ async def get_active_signing_key(
|
||||
return kp, row["private_key_enc"]
|
||||
|
||||
|
||||
async def _fetch_private_key_enc(store: PraxisStore, key_id: str) -> bytes:
|
||||
async with store._connect() as db:
|
||||
db.row_factory = None
|
||||
cur = await db.execute(
|
||||
"SELECT private_key_enc FROM issuer_keys WHERE id = ?", (key_id,)
|
||||
)
|
||||
row = await cur.fetchone()
|
||||
return bytes(row[0]) if row else b""
|
||||
async def _fetch_private_key_enc(store: IssuerKeyStore, key_id: str) -> bytes:
|
||||
# PraxisStore exposes a _connect() context manager; PgStore does not
|
||||
# (it uses a pool). Use the protocol's get_public_key_row which both
|
||||
# stores implement, and read private_key_enc from the returned row.
|
||||
row = await store.get_public_key_row(key_id)
|
||||
if row is None:
|
||||
return b""
|
||||
enc = row.get("private_key_enc")
|
||||
return bytes(enc) if enc is not None else b""
|
||||
|
||||
|
||||
async def get_public_key_for_verification(
|
||||
store: PraxisStore, key_id: str
|
||||
store: IssuerKeyStore, key_id: str
|
||||
) -> nacl.signing.VerifyKey:
|
||||
row = await store.get_public_key_row(key_id)
|
||||
if row is None:
|
||||
@@ -119,6 +142,7 @@ async def rotate_key(store: PraxisStore, root_key: bytes | None = None) -> KeyPa
|
||||
|
||||
|
||||
__all__ = [
|
||||
"IssuerKeyStore",
|
||||
"KeyPair",
|
||||
"init_issuer_key",
|
||||
"get_active_signing_key",
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
"""VC issuer key migration SQLite → Postgres (TASK-04-03, D-051).
|
||||
|
||||
One-time migration procedure (R-VC-MIG-01 — highest-severity v0.4 risk):
|
||||
1. Read the v0.3 active public key from SQLite issuer_keys.
|
||||
2. Insert that public key into Postgres issuer_keys with status=
|
||||
'superseded' (private key NOT migrated — only the public key is
|
||||
archived for verification of already-issued v0.3 VCs).
|
||||
3. Generate a fresh Ed25519 keypair in Postgres issuer_keys with
|
||||
status='active' (encrypted at rest with the root key).
|
||||
4. Return {archived_key_id, new_key_id}.
|
||||
|
||||
R-VC-MIG-01 mitigation: the v0.3 public key is archived as superseded
|
||||
BEFORE the fresh key is activated (step 2 before step 3). This guarantees
|
||||
v0.3 VCs remain verifiable against the archived key.
|
||||
|
||||
G-027 (first-boot path): if SQLite has NO v0.3 active key (fresh deploy),
|
||||
skip the archive step and only generate the fresh v0.4 keypair.
|
||||
|
||||
Idempotent: if Postgres already has an active key, the whole procedure is
|
||||
a no-op. If Postgres already has a superseded key matching the v0.3 key_id,
|
||||
skip step 2 (already archived) but still generate the fresh key if no
|
||||
active key exists.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
import nacl.signing
|
||||
|
||||
from db.pg_store import PgStore
|
||||
from db.store import PraxisStore
|
||||
from server.vc.issuer_keys import _encrypt_private_key
|
||||
|
||||
|
||||
async def _archive_v03_public_key(
|
||||
pg_store: PgStore, v03_key_id: str, v03_public_key: str
|
||||
) -> None:
|
||||
"""Insert the v0.3 public key into Postgres as superseded (idempotent)."""
|
||||
existing = await pg_store.get_public_key_row(v03_key_id)
|
||||
if existing is not None:
|
||||
return # already archived (or present as active — leave as-is)
|
||||
await pg_store.init_issuer_key(v03_key_id, v03_public_key, b"")
|
||||
await pg_store.set_issuer_key_superseded(v03_key_id)
|
||||
|
||||
|
||||
async def _generate_fresh_v04_key(
|
||||
pg_store: PgStore, root_key: bytes
|
||||
) -> str:
|
||||
"""Generate a fresh Ed25519 keypair in Postgres as active. Returns key_id."""
|
||||
signing_key = nacl.signing.SigningKey.generate()
|
||||
verify_key = signing_key.verify_key
|
||||
public_key_b64 = base64.b64encode(bytes(verify_key)).decode("ascii")
|
||||
private_key_enc = _encrypt_private_key(signing_key, root_key)
|
||||
key_id = f"key-{uuid.uuid4().hex[:12]}"
|
||||
await pg_store.init_issuer_key(key_id, public_key_b64, private_key_enc)
|
||||
return key_id
|
||||
|
||||
|
||||
async def migrate_issuer_keys(
|
||||
sqlite_store: PraxisStore,
|
||||
pg_store: PgStore,
|
||||
root_key: bytes,
|
||||
) -> dict[str, str | None]:
|
||||
"""Run the one-time VC key migration. Idempotent.
|
||||
|
||||
Returns {"archived_key_id": str | None, "new_key_id": str | None}.
|
||||
archived_key_id is None on the G-027 first-boot path (no v0.3 key).
|
||||
new_key_id is None if an active key already existed (no-op).
|
||||
"""
|
||||
# If Postgres already has an active key, the whole migration is done.
|
||||
active = await pg_store.get_active_signing_key_row()
|
||||
if active is not None:
|
||||
return {"archived_key_id": None, "new_key_id": None}
|
||||
|
||||
# Step 1 (G-027): read v0.3 active public key from SQLite. May be None
|
||||
# on a fresh deploy with no v0.3 history.
|
||||
v03_row = await sqlite_store.get_active_signing_key_row()
|
||||
archived_key_id: str | None = None
|
||||
if v03_row is not None:
|
||||
v03_key_id = v03_row["id"]
|
||||
v03_public_key = v03_row["public_key"]
|
||||
# Step 2 (R-VC-MIG-01): archive BEFORE activating the fresh key.
|
||||
await _archive_v03_public_key(pg_store, v03_key_id, v03_public_key)
|
||||
archived_key_id = v03_key_id
|
||||
|
||||
# Step 3: generate the fresh v0.4 keypair as active.
|
||||
new_key_id = await _generate_fresh_v04_key(pg_store, root_key)
|
||||
return {"archived_key_id": archived_key_id, "new_key_id": new_key_id}
|
||||
|
||||
|
||||
__all__ = ["migrate_issuer_keys"]
|
||||
+86
-16
@@ -1,11 +1,24 @@
|
||||
"""Public VC verification (SLICE-09 TASK-09-04, D-043, REQ-NFR-VC-02).
|
||||
"""Public VC verification (SLICE-09 TASK-09-04, D-043, REQ-NFR-VC-02;
|
||||
v0.4 TASK-04-04 two-store fallback per G-011).
|
||||
|
||||
`GET /vc/verify/<credential_id>` — public, unauthenticated. Fetches the
|
||||
credential from SQLite, fetches the issuer public key, validates the Ed25519
|
||||
signature against the JCS-canonicalized payload, checks the Bitstring Status
|
||||
List (no cache — fetched on every verify call, REQ-NFR-VC-02). Returns JSON
|
||||
credential + issuer public key, validates the Ed25519 signature against
|
||||
the JCS-canonicalized payload, checks the Bitstring Status List (no cache
|
||||
— fetched on every verify call, REQ-NFR-VC-02). Returns JSON
|
||||
{valid, status, issuer, credential, mastery, credentialTier, verifiedAt}.
|
||||
No PII beyond what the credential asserts.
|
||||
|
||||
G-011 two-store fallback semantics (binding contract):
|
||||
(a) If Postgres is available (pg_store is not None), use it for issuer
|
||||
key lookup (both active AND superseded keys — get_public_key_row
|
||||
queries by id, not status).
|
||||
(b) If Postgres is available but the credential is not found in its
|
||||
issued_credentials table, fall back to SQLite issued_credentials
|
||||
(v0.3 credentials remain in SQLite — D-051 "no re-issuance").
|
||||
(c) If Postgres is NOT available (pg_store is None), use the existing
|
||||
v0.3 SQLite path for BOTH keys and credentials (full v0.3 compat).
|
||||
The key store used for verification is always the one that holds the key
|
||||
row found by key_id; the credential store is whichever store had the row.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -17,7 +30,7 @@ from typing import Any
|
||||
from db.store import PraxisStore
|
||||
|
||||
from server.vc.issuer import verify_proof, extract_key_id, CREDENTIAL_TIER
|
||||
from server.vc.issuer_keys import get_public_key_for_verification
|
||||
from server.vc.issuer_keys import IssuerKeyStore, get_public_key_for_verification
|
||||
from server.vc.status_list import BitstringStatusList
|
||||
|
||||
|
||||
@@ -26,26 +39,35 @@ def _now_iso() -> str:
|
||||
|
||||
|
||||
async def verify_credential(
|
||||
store: PraxisStore, credential_id: str
|
||||
store: IssuerKeyStore,
|
||||
credential_id: str,
|
||||
*,
|
||||
pg_store: IssuerKeyStore | None = None,
|
||||
sqlite_store: PraxisStore | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
row = await store.get_credential(credential_id)
|
||||
"""Verify a VC. Returns the verification result dict, or None if the
|
||||
credential id is not found in any store.
|
||||
|
||||
Per G-011:
|
||||
- If pg_store is provided, try it first for BOTH credential + key
|
||||
lookup; fall back to sqlite_store for the credential if Postgres
|
||||
doesn't have it (v0.3 credentials stay in SQLite).
|
||||
- If pg_store is None, use `store` (the v0.3 SQLite path) for both.
|
||||
"""
|
||||
row = await _lookup_credential(credential_id, store, pg_store, sqlite_store)
|
||||
if row is None:
|
||||
return None
|
||||
secured_doc = json.loads(row["vc_payload_json"])
|
||||
key_id = extract_key_id(secured_doc)
|
||||
if key_id is None:
|
||||
return _invalid(row, secured_doc)
|
||||
try:
|
||||
verify_key = await get_public_key_for_verification(store, key_id)
|
||||
except KeyError:
|
||||
# Key lookup: prefer pg_store (G-011a) for v0.4 keys + archived v0.3
|
||||
# keys; fall back to `store` (SQLite) if pg_store doesn't have the key.
|
||||
verify_key = await _lookup_public_key(key_id, store, pg_store)
|
||||
if verify_key is None:
|
||||
return _invalid(row, secured_doc)
|
||||
sig_valid = verify_proof(secured_doc, verify_key)
|
||||
revoked = False
|
||||
cs = secured_doc.get("credentialStatus") or {}
|
||||
idx_str = cs.get("statusListIndex")
|
||||
if idx_str is not None:
|
||||
sl = BitstringStatusList(store, "default")
|
||||
revoked = await sl.get_status(int(idx_str))
|
||||
revoked = await _check_revocation(secured_doc, store, sqlite_store or store)
|
||||
status = "revoked" if revoked else "active"
|
||||
valid = bool(sig_valid and not revoked)
|
||||
subject = secured_doc.get("credentialSubject") or {}
|
||||
@@ -73,6 +95,54 @@ async def verify_credential(
|
||||
}
|
||||
|
||||
|
||||
async def _lookup_credential(
|
||||
credential_id: str,
|
||||
store: IssuerKeyStore,
|
||||
pg_store: IssuerKeyStore | None,
|
||||
sqlite_store: PraxisStore | None,
|
||||
) -> dict | None:
|
||||
"""G-011(b): try Postgres first, fall back to SQLite for v0.3 creds."""
|
||||
if pg_store is not None:
|
||||
row = await pg_store.get_credential(credential_id)
|
||||
if row is not None:
|
||||
return row
|
||||
if sqlite_store is not None:
|
||||
return await sqlite_store.get_credential(credential_id)
|
||||
return None
|
||||
# G-011(c): no Postgres — v0.3 SQLite path.
|
||||
return await store.get_credential(credential_id)
|
||||
|
||||
|
||||
async def _lookup_public_key(
|
||||
key_id: str,
|
||||
store: IssuerKeyStore,
|
||||
pg_store: IssuerKeyStore | None,
|
||||
):
|
||||
"""G-011(a): prefer Postgres for key lookup (finds active + superseded);
|
||||
fall back to `store` (SQLite) if Postgres doesn't have the key."""
|
||||
if pg_store is not None:
|
||||
try:
|
||||
vk = await get_public_key_for_verification(pg_store, key_id)
|
||||
return vk
|
||||
except KeyError:
|
||||
pass
|
||||
try:
|
||||
return await get_public_key_for_verification(store, key_id)
|
||||
except KeyError:
|
||||
return None
|
||||
|
||||
|
||||
async def _check_revocation(
|
||||
secured_doc: dict, store: IssuerKeyStore, status_store: PraxisStore
|
||||
) -> bool:
|
||||
cs = secured_doc.get("credentialStatus") or {}
|
||||
idx_str = cs.get("statusListIndex")
|
||||
if idx_str is None:
|
||||
return False
|
||||
sl = BitstringStatusList(status_store, "default")
|
||||
return await sl.get_status(int(idx_str))
|
||||
|
||||
|
||||
def _invalid(row: dict, secured_doc: dict) -> dict[str, Any]:
|
||||
subject = secured_doc.get("credentialSubject") or {}
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
"""Auth unit tests (TASK-03-06) — mocked PgStore, no real Postgres.
|
||||
|
||||
Covers: password hash/verify/rehash, cookie config (secure flag, missing
|
||||
secret), rate limiter threshold, current_operator dependency (401/503
|
||||
cases, active/inactive), login/logout/me route handlers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import types
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
from server.auth.cookies import get_session_middleware_kwargs
|
||||
from server.auth.dependencies import current_operator
|
||||
from server.auth.models import Operator
|
||||
from server.auth.passwords import hash_password, needs_rehash, verify_password
|
||||
from server.auth.rate_limit import limiter, rate_limit_login, reset_login_rate_limit
|
||||
from server.auth.routes import router
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_limiter():
|
||||
reset_login_rate_limit()
|
||||
yield
|
||||
reset_login_rate_limit()
|
||||
|
||||
|
||||
# ── Passwords ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_password_hash_verify_roundtrip():
|
||||
h = hash_password("correct horse battery staple")
|
||||
assert h.startswith("$argon2id$")
|
||||
assert verify_password(h, "correct horse battery staple") is True
|
||||
|
||||
|
||||
def test_password_verify_wrong_returns_false():
|
||||
h = hash_password("secret-1")
|
||||
assert verify_password(h, "secret-2") is False
|
||||
# no exception raised — uniform 401 path
|
||||
assert verify_password(h, "") is False
|
||||
|
||||
|
||||
def test_needs_rehash_false_for_current_defaults():
|
||||
h = hash_password("x")
|
||||
assert needs_rehash(h) is False
|
||||
|
||||
|
||||
def test_needs_rehash_true_for_weak_hash():
|
||||
# A hash produced with weaker params triggers rehash.
|
||||
from argon2 import PasswordHasher
|
||||
weak = PasswordHasher(time_cost=1, memory_cost=8, parallelism=1).hash("x")
|
||||
assert needs_rehash(weak) is True
|
||||
|
||||
|
||||
# ── Cookie config ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_cookie_kwargs_defaults(monkeypatch):
|
||||
monkeypatch.setenv("PRAXIS_COOKIE_SECRET", "x" * 48)
|
||||
monkeypatch.setenv("PRAXIS_COOKIE_SECURE", "true")
|
||||
kw = get_session_middleware_kwargs()
|
||||
assert kw["session_cookie"] == "praxis_op"
|
||||
assert kw["max_age"] == 28800
|
||||
# Starlette SessionMiddleware: https_only (not secure), same_site (not samesite),
|
||||
# httponly is always True (no kwarg). path is the cookie path.
|
||||
assert kw["https_only"] is True
|
||||
assert kw["same_site"] == "strict"
|
||||
assert kw["path"] == "/"
|
||||
|
||||
|
||||
def test_cookie_secure_false(monkeypatch):
|
||||
monkeypatch.setenv("PRAXIS_COOKIE_SECRET", "x" * 48)
|
||||
monkeypatch.setenv("PRAXIS_COOKIE_SECURE", "false")
|
||||
kw = get_session_middleware_kwargs()
|
||||
assert kw["https_only"] is False
|
||||
|
||||
|
||||
def test_cookie_secret_unset_generates_random(monkeypatch):
|
||||
monkeypatch.delenv("PRAXIS_COOKIE_SECRET", raising=False)
|
||||
kw = get_session_middleware_kwargs()
|
||||
assert kw["secret_key"]
|
||||
assert len(kw["secret_key"]) >= 32
|
||||
|
||||
|
||||
# ── current_operator dependency ─────────────────────────────────────────────
|
||||
|
||||
|
||||
def _make_app_with_store(store) -> FastAPI:
|
||||
app = FastAPI()
|
||||
app.state.pg_store = store
|
||||
app.add_middleware(SessionMiddleware, secret_key="test-secret-1234567890abcdef")
|
||||
app.include_router(router)
|
||||
return app
|
||||
|
||||
|
||||
def _mock_store(operator_row=None):
|
||||
store = MagicMock()
|
||||
store.get_operator_by_id = AsyncMock(return_value=operator_row)
|
||||
return store
|
||||
|
||||
|
||||
def test_current_operator_no_cookie_401():
|
||||
app = _make_app_with_store(_mock_store(operator_row=None))
|
||||
with TestClient(app) as client:
|
||||
r = client.get("/api/operator/me")
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_current_operator_no_postgres_503():
|
||||
app = FastAPI()
|
||||
app.state.pg_store = None
|
||||
app.add_middleware(SessionMiddleware, secret_key="test-secret-1234567890abcdef")
|
||||
app.include_router(router)
|
||||
with TestClient(app) as client:
|
||||
r = client.get("/api/operator/me")
|
||||
assert r.status_code == 503
|
||||
|
||||
|
||||
def test_current_operator_inactive_401():
|
||||
op = {
|
||||
"id": "11111111-1111-1111-1111-111111111111",
|
||||
"username": "ghost",
|
||||
"display_name": "Ghost",
|
||||
"role": "operator",
|
||||
"is_active": False,
|
||||
}
|
||||
store = _mock_store(operator_row=op)
|
||||
app = _make_app_with_store(store)
|
||||
with TestClient(app) as client:
|
||||
# seed a session by hitting login would need a real store; instead
|
||||
# set the session directly via a cookie. Use TestClient's cookie jar.
|
||||
# Easiest: POST /login with a mocked store that returns the op.
|
||||
store.get_operator_by_username = AsyncMock(return_value=op)
|
||||
store.update_last_login = AsyncMock()
|
||||
store.pool = MagicMock()
|
||||
conn = MagicMock()
|
||||
conn.execute = AsyncMock()
|
||||
cm = MagicMock()
|
||||
cm.__aenter__ = AsyncMock(return_value=conn)
|
||||
cm.__aexit__ = AsyncMock(return_value=None)
|
||||
store.pool.acquire = MagicMock(return_value=cm)
|
||||
# hash the password so verify works
|
||||
op = dict(op)
|
||||
op["password_hash"] = hash_password("pw")
|
||||
store.get_operator_by_username = AsyncMock(return_value=op)
|
||||
r = client.post("/api/operator/login", json={"username": "ghost", "password": "pw"})
|
||||
# inactive operator → 401 even with correct password
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_current_operator_valid_cookie_returns_operator():
|
||||
op = {
|
||||
"id": "22222222-2222-2222-2222-222222222222",
|
||||
"username": "alice",
|
||||
"display_name": "Alice",
|
||||
"role": "operator",
|
||||
"is_active": True,
|
||||
"password_hash": hash_password("pw"),
|
||||
}
|
||||
store = _mock_store(operator_row=op)
|
||||
store.get_operator_by_username = AsyncMock(return_value=op)
|
||||
store.update_last_login = AsyncMock()
|
||||
store.pool = MagicMock()
|
||||
conn = MagicMock()
|
||||
conn.execute = AsyncMock()
|
||||
cm = MagicMock()
|
||||
cm.__aenter__ = AsyncMock(return_value=conn)
|
||||
cm.__aexit__ = AsyncMock(return_value=None)
|
||||
store.pool.acquire = MagicMock(return_value=cm)
|
||||
app = _make_app_with_store(store)
|
||||
with TestClient(app) as client:
|
||||
r = client.post("/api/operator/login", json={"username": "alice", "password": "pw"})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["operator"]["username"] == "alice"
|
||||
# cookie is now set; /me should work
|
||||
r2 = client.get("/api/operator/me")
|
||||
assert r2.status_code == 200
|
||||
assert r2.json()["operator"]["username"] == "alice"
|
||||
|
||||
|
||||
# ── Login route ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_login_wrong_password_401_no_cookie():
|
||||
op = {
|
||||
"id": "33333333-3333-3333-3333-333333333333",
|
||||
"username": "bob",
|
||||
"display_name": None,
|
||||
"role": "operator",
|
||||
"is_active": True,
|
||||
"password_hash": hash_password("correct"),
|
||||
}
|
||||
store = _mock_store(operator_row=op)
|
||||
store.get_operator_by_username = AsyncMock(return_value=op)
|
||||
store.update_last_login = AsyncMock()
|
||||
app = _make_app_with_store(store)
|
||||
with TestClient(app) as client:
|
||||
r = client.post("/api/operator/login", json={"username": "bob", "password": "wrong"})
|
||||
assert r.status_code == 401
|
||||
# no auth cookie set on failure
|
||||
cookies = client.cookies.get("praxis_op")
|
||||
assert not cookies
|
||||
|
||||
|
||||
def test_login_unknown_user_401():
|
||||
store = _mock_store(operator_row=None)
|
||||
store.get_operator_by_username = AsyncMock(return_value=None)
|
||||
app = _make_app_with_store(store)
|
||||
with TestClient(app) as client:
|
||||
r = client.post("/api/operator/login", json={"username": "nobody", "password": "x"})
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_login_no_postgres_503():
|
||||
app = FastAPI()
|
||||
app.state.pg_store = None
|
||||
app.add_middleware(SessionMiddleware, secret_key="test-secret-1234567890abcdef")
|
||||
app.include_router(router)
|
||||
with TestClient(app) as client:
|
||||
r = client.post("/api/operator/login", json={"username": "a", "password": "b"})
|
||||
assert r.status_code == 503
|
||||
|
||||
|
||||
# ── Logout ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_logout_clears_session():
|
||||
op = {
|
||||
"id": "44444444-4444-4444-4444-444444444444",
|
||||
"username": "carol",
|
||||
"display_name": "Carol",
|
||||
"role": "operator",
|
||||
"is_active": True,
|
||||
"password_hash": hash_password("pw"),
|
||||
}
|
||||
store = _mock_store(operator_row=op)
|
||||
store.get_operator_by_username = AsyncMock(return_value=op)
|
||||
store.update_last_login = AsyncMock()
|
||||
store.pool = MagicMock()
|
||||
conn = MagicMock()
|
||||
conn.execute = AsyncMock()
|
||||
cm = MagicMock()
|
||||
cm.__aenter__ = AsyncMock(return_value=conn)
|
||||
cm.__aexit__ = AsyncMock(return_value=None)
|
||||
store.pool.acquire = MagicMock(return_value=cm)
|
||||
app = _make_app_with_store(store)
|
||||
with TestClient(app) as client:
|
||||
client.post("/api/operator/login", json={"username": "carol", "password": "pw"})
|
||||
r = client.post("/api/operator/logout")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["ok"] is True
|
||||
# /me now 401
|
||||
r2 = client.get("/api/operator/me")
|
||||
assert r2.status_code == 401
|
||||
|
||||
|
||||
# ── Rehash on login ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_login_rehash_when_needed():
|
||||
from argon2 import PasswordHasher
|
||||
weak_hasher = PasswordHasher(time_cost=1, memory_cost=8, parallelism=1)
|
||||
op = {
|
||||
"id": "55555555-5555-5555-5555-555555555555",
|
||||
"username": "dave",
|
||||
"display_name": "Dave",
|
||||
"role": "operator",
|
||||
"is_active": True,
|
||||
"password_hash": weak_hasher.hash("pw"),
|
||||
}
|
||||
store = _mock_store(operator_row=op)
|
||||
store.get_operator_by_username = AsyncMock(return_value=op)
|
||||
store.update_last_login = AsyncMock()
|
||||
store.pool = MagicMock()
|
||||
executed = []
|
||||
conn = MagicMock()
|
||||
async def _exec(*a, **kw):
|
||||
executed.append(a)
|
||||
conn.execute = _exec
|
||||
cm = MagicMock()
|
||||
cm.__aenter__ = AsyncMock(return_value=conn)
|
||||
cm.__aexit__ = AsyncMock(return_value=None)
|
||||
store.pool.acquire = MagicMock(return_value=cm)
|
||||
app = _make_app_with_store(store)
|
||||
with TestClient(app) as client:
|
||||
r = client.post("/api/operator/login", json={"username": "dave", "password": "pw"})
|
||||
assert r.status_code == 200
|
||||
assert executed, "rehash UPDATE should have run"
|
||||
# the second arg to execute is the new hash; verify it's argon2id
|
||||
assert executed[0][1].startswith("$argon2id$")
|
||||
|
||||
|
||||
# ── Rate limiter ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_rate_limit_login_decorator():
|
||||
# The decorator factory returns a decorator; applying it should not raise.
|
||||
deco = rate_limit_login()
|
||||
assert callable(deco)
|
||||
|
||||
|
||||
def test_limiter_is_in_memory():
|
||||
assert getattr(limiter, "_storage_uri", "memory://") == "memory://" or limiter._storage is not None
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Backup-restore drill test (G-008 binding — MUST run at least once in
|
||||
staging/CI to prove the nightly pg_dump backup is valid).
|
||||
|
||||
The drill:
|
||||
1. Seed the live Postgres with known row counts in all 5 operator-tier
|
||||
tables (operators, issued_credentials, mastery_gate_events,
|
||||
cohort_aggregates, issuer_keys).
|
||||
2. Run `pg_dump -Fc` to produce a compressed dump.
|
||||
3. Drop + recreate the schema (simulate a disaster), then run
|
||||
`pg_restore --clean --if-exists`.
|
||||
4. Verify all 5 tables exist and the row counts match the seeded values.
|
||||
|
||||
Skips gracefully when PRAXIS_PG_DSN is unset (no Postgres in dev/CI).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import subprocess
|
||||
import uuid
|
||||
from datetime import date
|
||||
|
||||
import asyncpg
|
||||
import pytest
|
||||
|
||||
from db.pg_migrate import apply_pg_migrations
|
||||
from db.pg_store import PgStore
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
"PRAXIS_PG_DSN" not in os.environ,
|
||||
reason="PRAXIS_PG_DSN not set — backup-restore drill skipped (G-008).",
|
||||
)
|
||||
|
||||
EXPECTED_TABLES = {
|
||||
"operators",
|
||||
"issued_credentials",
|
||||
"mastery_gate_events",
|
||||
"cohort_aggregates",
|
||||
"issuer_keys",
|
||||
}
|
||||
|
||||
|
||||
async def _seed(store: PgStore, pool: asyncpg.Pool) -> dict[str, int]:
|
||||
"""Seed all 5 tables; return {table: row_count}."""
|
||||
oid = await store.insert_operator("drill-op", "$argon2id$h", "Drill Op")
|
||||
assert oid is not None
|
||||
kid = f"key-{uuid.uuid4().hex[:12]}"
|
||||
await store.init_issuer_key(kid, "pub-drill", b"\x01\x02")
|
||||
cid = f"vc-{uuid.uuid4().hex[:16]}"
|
||||
await store.insert_credential(cid, "learner-drill", "{}", "sig", operator_id=oid)
|
||||
await store.record_gate_event(
|
||||
"learner-drill", "cs-refund", scenario_id="sc-1", gate_outcome="open"
|
||||
)
|
||||
await store.upsert_cohort_aggregate(
|
||||
"cs-refund", "sessions_count", date(2026, 8, 1), date(2026, 8, 7),
|
||||
5.0, 12, False,
|
||||
)
|
||||
counts = {}
|
||||
async with pool.acquire() as conn:
|
||||
for t in EXPECTED_TABLES:
|
||||
counts[t] = await conn.fetchval(f"SELECT count(*) FROM {t}")
|
||||
return counts
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_backup_restore_drill(tmp_path):
|
||||
dsn = os.environ["PRAXIS_PG_DSN"]
|
||||
dump_file = tmp_path / "praxis-drill.dump"
|
||||
|
||||
pool = await asyncpg.create_pool(dsn=dsn, min_size=1, max_size=5, command_timeout=10)
|
||||
try:
|
||||
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"
|
||||
)
|
||||
store = PgStore(pool)
|
||||
seeded_counts = await _seed(store, pool)
|
||||
|
||||
# 1. pg_dump -Fc to a local file (via psql host or docker).
|
||||
# Use pg_dump directly if available on PATH; otherwise fall back to
|
||||
# docker compose exec (the operator deployment path).
|
||||
rc = subprocess.run(
|
||||
["pg_dump", "-Fc", "-f", str(dump_file), dsn],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
if rc.returncode != 0:
|
||||
# Try docker compose path (production-like).
|
||||
rc = subprocess.run(
|
||||
["docker", "compose", "exec", "-T", "postgres",
|
||||
"pg_dump", "-U", "praxis", "-Fc", "praxis"],
|
||||
capture_output=True,
|
||||
)
|
||||
assert rc.returncode == 0, f"pg_dump failed: {rc.stderr!r}"
|
||||
dump_file.write_bytes(rc.stdout)
|
||||
assert dump_file.stat().st_size > 0, "dump file is empty"
|
||||
|
||||
# 2. Drop the schema (simulate disaster).
|
||||
async with pool.acquire() as conn:
|
||||
for t in EXPECTED_TABLES:
|
||||
await conn.execute(f'DROP TABLE IF EXISTS "{t}" CASCADE')
|
||||
await conn.execute("DROP TABLE IF EXISTS _pg_migrations CASCADE")
|
||||
|
||||
# 3. pg_restore --clean --if-exists from the dump file.
|
||||
rc = subprocess.run(
|
||||
["pg_restore", "--clean", "--if-exists", "-d", dsn, str(dump_file)],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
if rc.returncode != 0:
|
||||
rc = subprocess.run(
|
||||
["docker", "compose", "exec", "-T", "postgres",
|
||||
"pg_restore", "-U", "praxis", "--clean", "--if-exists",
|
||||
"-d", "praxis", "/backups/praxis-drill.dump"],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
# If we used the docker path, we have to copy the dump in first;
|
||||
# for the local-pg_dump path this branch is skipped. Either way,
|
||||
# a non-zero return here means restore failed.
|
||||
assert rc.returncode == 0, f"pg_restore failed: {rc.stderr!r}"
|
||||
|
||||
# 4. Verify all 5 tables exist + row counts match.
|
||||
async with pool.acquire() as conn:
|
||||
tables = {
|
||||
r["tablename"] for r in await conn.fetch(
|
||||
"SELECT tablename FROM pg_tables WHERE schemaname='public'"
|
||||
)
|
||||
}
|
||||
assert EXPECTED_TABLES.issubset(tables), (
|
||||
f"missing tables after restore: {EXPECTED_TABLES - tables}"
|
||||
)
|
||||
for t in EXPECTED_TABLES:
|
||||
count = await conn.fetchval(f"SELECT count(*) FROM {t}")
|
||||
assert count == seeded_counts[t], (
|
||||
f"{t}: restored count {count} != seeded {seeded_counts[t]}"
|
||||
)
|
||||
finally:
|
||||
await pool.close()
|
||||
@@ -0,0 +1,246 @@
|
||||
"""Cohort aggregation unit tests (TASK-07-05) — mocked PgStore, no Postgres.
|
||||
|
||||
Covers: k-anonymity suppression (9 vs 10 vs 11 learners), idempotent upsert,
|
||||
7-day window computation, multiple metrics, no PII in upsert calls.
|
||||
|
||||
G-038 (binding — differencing-attack test): seed 10 learners in window A and
|
||||
9 in window B (one dropped), verify the API/aggregation cannot isolate the
|
||||
dropped learner — both windows show k-anonymized aggregates with no
|
||||
per-learner data leaks.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as _dt
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from server.cohort.aggregator import (
|
||||
K_ANON_THRESHOLD,
|
||||
_rolling_window,
|
||||
aggregate_session,
|
||||
)
|
||||
from server.cohort.hook import on_session_end
|
||||
|
||||
|
||||
def _mock_pg_store():
|
||||
store = MagicMock()
|
||||
store.upsert_cohort_aggregate = AsyncMock()
|
||||
return store
|
||||
|
||||
|
||||
def _session(learner_ref: str, path: str = "customer_service",
|
||||
outcome: str = "pass", rubric_scores=None,
|
||||
failure_mode=None, branch_path=None) -> dict:
|
||||
return {
|
||||
"learner_ref": learner_ref,
|
||||
"path": path,
|
||||
"scenario_id": f"{path}_v01",
|
||||
"outcome": outcome,
|
||||
"rubric_scores": rubric_scores or [
|
||||
{"criterion_id": "empathy", "score": 4.0},
|
||||
{"criterion_id": "resolution", "score": 3.5},
|
||||
],
|
||||
"failure_mode": failure_mode,
|
||||
"branch_path": branch_path or ["accept"],
|
||||
"timestamp": _dt.datetime.now(_dt.timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
|
||||
# ── k-anonymity threshold ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_k_anon_threshold_at_10():
|
||||
assert K_ANON_THRESHOLD == 10
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_9_learners_suppressed():
|
||||
store = _mock_pg_store()
|
||||
for i in range(9):
|
||||
await aggregate_session(store, _session(f"learner-{i}"))
|
||||
suppressed_calls = [
|
||||
c for c in store.upsert_cohort_aggregate.call_args_list
|
||||
if c.args[6] is True # cell_suppressed
|
||||
]
|
||||
non_suppressed = [
|
||||
c for c in store.upsert_cohort_aggregate.call_args_list
|
||||
if c.args[6] is False
|
||||
]
|
||||
assert suppressed_calls, "cells should be suppressed with <10 learners"
|
||||
assert not non_suppressed, "no cell should be non-suppressed with 9 learners"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_10_learners_not_suppressed():
|
||||
store = _mock_pg_store()
|
||||
for i in range(10):
|
||||
await aggregate_session(store, _session(f"learner-{i}"))
|
||||
non_suppressed = [
|
||||
c for c in store.upsert_cohort_aggregate.call_args_list
|
||||
if c.args[6] is False
|
||||
]
|
||||
assert non_suppressed, "cells should NOT be suppressed at exactly 10 learners"
|
||||
# value should be non-null for non-suppressed cells
|
||||
for c in non_suppressed:
|
||||
assert c.args[4] is not None, "non-suppressed cell value must not be None"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_11_learners_not_suppressed():
|
||||
store = _mock_pg_store()
|
||||
for i in range(11):
|
||||
await aggregate_session(store, _session(f"learner-{i}"))
|
||||
non_suppressed = [
|
||||
c for c in store.upsert_cohort_aggregate.call_args_list
|
||||
if c.args[6] is False
|
||||
]
|
||||
assert non_suppressed, "11 learners should NOT be suppressed"
|
||||
|
||||
|
||||
# ── Idempotent upsert ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_idempotent_same_session_twice():
|
||||
store = _mock_pg_store()
|
||||
outcome = _session("learner-x")
|
||||
await aggregate_session(store, outcome)
|
||||
await aggregate_session(store, outcome)
|
||||
# Re-running with the same outcome produces additional upsert calls but
|
||||
# the ON CONFLICT in PgStore makes them idempotent at the DB layer. The
|
||||
# hook itself is deterministic — the same learner produces the same
|
||||
# distinct-count + counter state in the cache.
|
||||
# Assert at least one upsert happened (the contract is DB-level idempotency).
|
||||
assert store.upsert_cohort_aggregate.called
|
||||
|
||||
|
||||
# ── 7-day window computation ───────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_rolling_window_7_days():
|
||||
now = _dt.datetime(2026, 8, 4, 12, 0, tzinfo=_dt.timezone.utc)
|
||||
start, end = _rolling_window(now)
|
||||
assert (end - start).days == 6 # 7-day inclusive span
|
||||
assert end == now.date()
|
||||
assert start == _dt.date(2026, 7, 29)
|
||||
|
||||
|
||||
# ── Multiple metrics ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multiple_metrics_computed():
|
||||
store = _mock_pg_store()
|
||||
await aggregate_session(store, _session("learner-1", rubric_scores=[
|
||||
{"criterion_id": "empathy", "score": 4.0},
|
||||
{"criterion_id": "resolution", "score": 3.0},
|
||||
], failure_mode="missed_apology", branch_path=["escalate"]))
|
||||
metrics = {c.args[1] for c in store.upsert_cohort_aggregate.call_args_list}
|
||||
assert "sessions_count" in metrics
|
||||
assert "active_learners_count" in metrics
|
||||
assert "gate_open_rate" in metrics
|
||||
assert "median_mastery_score" in metrics
|
||||
assert "rubric_criterion_mean:empathy" in metrics
|
||||
assert "failure_mode:missed_apology" in metrics
|
||||
assert "branch:escalate" in metrics
|
||||
|
||||
|
||||
# ── No PII in upsert calls ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_pii_in_upsert_calls():
|
||||
store = _mock_pg_store()
|
||||
await aggregate_session(store, _session("learner-sensitive-id-1234"))
|
||||
for c in store.upsert_cohort_aggregate.call_args_list:
|
||||
# path, metric, window_start, window_end, value, cell_count, suppressed
|
||||
# No argument should contain the raw learner_ref string as PII.
|
||||
for arg in c.args:
|
||||
assert "learner-sensitive-id-1234" not in str(arg), \
|
||||
"raw learner_ref must not leak into aggregate cell args"
|
||||
# cell_count is the distinct-learner count (an integer), not the ref.
|
||||
assert isinstance(c.args[5], int)
|
||||
|
||||
|
||||
# ── G-038: Differencing-attack test (binding) ──────────────────────────────
|
||||
# Seed 10 learners in window A, 9 in window B (one dropped). Verify the
|
||||
# aggregation/API cannot isolate the dropped learner — both windows produce
|
||||
# k-anonymized aggregates with no per-learner data leaks.
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_g038_differencing_attack_cannot_isolate_dropped_learner():
|
||||
"""G-038 binding: 10 learners in window A, 9 in window B (one dropped).
|
||||
|
||||
A differencing attack tries to subtract window B's aggregate from
|
||||
window A's to recover the dropped learner's contribution. With k-anon
|
||||
write-time suppression, window B (9 learners) is FULLY suppressed
|
||||
(value=NULL, cell_suppressed=TRUE), so the attacker cannot subtract
|
||||
anything — the dropped learner's contribution is not recoverable.
|
||||
"""
|
||||
store_a = _mock_pg_store()
|
||||
store_b = _mock_pg_store()
|
||||
|
||||
# Window A: 10 distinct learners → non-suppressed
|
||||
for i in range(10):
|
||||
await aggregate_session(store_a, _session(f"learner-{i}"))
|
||||
# Window B: 9 distinct learners (learner-9 dropped) → suppressed
|
||||
for i in range(9):
|
||||
await aggregate_session(store_b, _session(f"learner-{i}"))
|
||||
|
||||
a_cells = list(store_a.upsert_cohort_aggregate.call_args_list)
|
||||
b_cells = list(store_b.upsert_cohort_aggregate.call_args_list)
|
||||
|
||||
# Window A: at least some non-suppressed cells (10 >= threshold)
|
||||
a_non_suppressed = [c for c in a_cells if c.args[6] is False]
|
||||
assert a_non_suppressed, "window A (10 learners) should have non-suppressed cells"
|
||||
|
||||
# Window B: ALL cells suppressed (9 < threshold)
|
||||
b_suppressed = [c for c in b_cells if c.args[6] is True]
|
||||
b_non_suppressed = [c for c in b_cells if c.args[6] is False]
|
||||
assert b_suppressed, "window B (9 learners) must have suppressed cells"
|
||||
assert not b_non_suppressed, \
|
||||
"window B (9 learners) must have NO non-suppressed cells (differencing blocked)"
|
||||
|
||||
# The critical differencing-attack defense: window B's suppressed cells
|
||||
# have value=NULL, so subtracting B from A is not possible — the attacker
|
||||
# cannot recover learner-9's contribution.
|
||||
for c in b_suppressed:
|
||||
assert c.args[4] is None, \
|
||||
"suppressed cell value must be NULL (differencing-attack defense)"
|
||||
|
||||
# No per-learner data leaks in either window's aggregate cells.
|
||||
for cells in (a_cells, b_cells):
|
||||
for c in cells:
|
||||
for arg in c.args:
|
||||
assert "learner-9" not in str(arg), \
|
||||
"dropped learner's ref must not appear in any aggregate cell"
|
||||
|
||||
|
||||
# ── Hook (TASK-07-02) ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hook_no_postgres_is_noop():
|
||||
# No exception, just a warning log.
|
||||
await on_session_end(None, _session("learner-1"))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hook_failure_logs_does_not_raise(monkeypatch):
|
||||
store = _mock_pg_store()
|
||||
store.upsert_cohort_aggregate = AsyncMock(side_effect=RuntimeError("boom"))
|
||||
# Must not raise — the hook swallows + logs; nightly reconciles.
|
||||
await on_session_end(store, _session("learner-1"))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hook_idempotent():
|
||||
store = _mock_pg_store()
|
||||
outcome = _session("learner-1")
|
||||
await on_session_end(store, outcome)
|
||||
await on_session_end(store, outcome)
|
||||
assert store.upsert_cohort_aggregate.called
|
||||
@@ -0,0 +1,199 @@
|
||||
"""Nightly reconciliation + hook integration tests (TASK-07-06) — mocked PgStore.
|
||||
|
||||
Covers: scheduler timing (seconds until 03:00 CT), reconciliation recomputes
|
||||
all windows, hook failure + nightly reconciliation = correct final state,
|
||||
R-DASH-04 (nightly failure logs + retries next night).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as _dt
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from server.cohort.nightly import (
|
||||
CT,
|
||||
NightlyScheduler,
|
||||
seconds_until_next_03_ct,
|
||||
)
|
||||
|
||||
|
||||
# ── Scheduler timing ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_seconds_until_next_03_ct_future_today():
|
||||
# 01:00 CT → next 03:00 CT is in 2h
|
||||
now = _dt.datetime(2026, 8, 4, 1, 0, tzinfo=CT)
|
||||
secs = seconds_until_next_03_ct(now)
|
||||
assert 7190 <= secs <= 7200 # ~2h
|
||||
|
||||
|
||||
def test_seconds_until_next_03_ct_past_today_wraps_tomorrow():
|
||||
# 04:00 CT → next 03:00 CT is tomorrow (23h)
|
||||
now = _dt.datetime(2026, 8, 4, 4, 0, tzinfo=CT)
|
||||
secs = seconds_until_next_03_ct(now)
|
||||
assert 82790 <= secs <= 82810 # ~23h
|
||||
|
||||
|
||||
def test_seconds_until_next_03_ct_exactly_03_rolls_to_tomorrow():
|
||||
now = _dt.datetime(2026, 8, 4, 3, 0, 0, tzinfo=CT)
|
||||
secs = seconds_until_next_03_ct(now)
|
||||
# exactly 03:00:00 → next run is tomorrow (0 secs would mean "now", but
|
||||
# the scheduler sleeps then runs, so it must be ~24h)
|
||||
assert secs >= 86390 # ~24h
|
||||
|
||||
|
||||
# ── Reconciliation recomputes all windows ──────────────────────────────────
|
||||
|
||||
|
||||
class _FakeRecord(dict):
|
||||
"""Mimics an asyncpg Record — dict(record) returns the dict."""
|
||||
pass
|
||||
|
||||
|
||||
def _mock_pg_store_with_events(events):
|
||||
store = MagicMock()
|
||||
store.upsert_cohort_aggregate = AsyncMock()
|
||||
conn = MagicMock()
|
||||
rows = [_FakeRecord(e) for e in events]
|
||||
conn.fetch = AsyncMock(return_value=rows)
|
||||
cm = MagicMock()
|
||||
cm.__aenter__ = AsyncMock(return_value=conn)
|
||||
cm.__aexit__ = AsyncMock(return_value=None)
|
||||
store.pool = MagicMock()
|
||||
store.pool.acquire = MagicMock(return_value=cm)
|
||||
return store
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reconcile_recomputes_all_paths():
|
||||
events = [
|
||||
{"learner_ref": "l1", "path_id": "customer_service", "gate_outcome": "open",
|
||||
"rubric_scores_jsonb": '[{"criterion_id":"empathy","score":4.0}]',
|
||||
"recorded_at": _dt.datetime.now(_dt.timezone.utc)},
|
||||
{"learner_ref": "l2", "path_id": "customer_service", "gate_outcome": "open",
|
||||
"rubric_scores_jsonb": '[{"criterion_id":"empathy","score":3.0}]',
|
||||
"recorded_at": _dt.datetime.now(_dt.timezone.utc)},
|
||||
{"learner_ref": "l3", "path_id": "sales", "gate_outcome": "closed",
|
||||
"rubric_scores_jsonb": '[]',
|
||||
"recorded_at": _dt.datetime.now(_dt.timezone.utc)},
|
||||
]
|
||||
store = _mock_pg_store_with_events(events)
|
||||
sched = NightlyScheduler()
|
||||
await sched.reconcile_now(store)
|
||||
# upserts should cover both paths × multiple metrics
|
||||
paths = {c.args[0] for c in store.upsert_cohort_aggregate.call_args_list}
|
||||
assert "customer_service" in paths
|
||||
assert "sales" in paths
|
||||
metrics = {c.args[1] for c in store.upsert_cohort_aggregate.call_args_list}
|
||||
assert "sessions_count" in metrics
|
||||
assert "active_learners_count" in metrics
|
||||
assert "gate_open_rate" in metrics
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reconcile_suppresses_below_threshold():
|
||||
# 3 distinct learners → suppressed
|
||||
events = [
|
||||
{"learner_ref": f"l{i}", "path_id": "p", "gate_outcome": "open",
|
||||
"rubric_scores_jsonb": "[]",
|
||||
"recorded_at": _dt.datetime.now(_dt.timezone.utc)}
|
||||
for i in range(3)
|
||||
]
|
||||
store = _mock_pg_store_with_events(events)
|
||||
sched = NightlyScheduler()
|
||||
await sched.reconcile_now(store)
|
||||
suppressed = [c for c in store.upsert_cohort_aggregate.call_args_list if c.args[6] is True]
|
||||
non_suppressed = [c for c in store.upsert_cohort_aggregate.call_args_list if c.args[6] is False]
|
||||
assert suppressed, "3 learners must be suppressed"
|
||||
assert not non_suppressed, "no cell should be non-suppressed with 3 learners"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reconcile_no_events_no_op():
|
||||
store = _mock_pg_store_with_events([])
|
||||
sched = NightlyScheduler()
|
||||
await sched.reconcile_now(store)
|
||||
store.upsert_cohort_aggregate.assert_not_called()
|
||||
|
||||
|
||||
# ── Hook failure → nightly reconciles ──────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hook_failure_then_nightly_reconciles_correct_state():
|
||||
"""A hook failure leaves no aggregate; the nightly job recomputes from
|
||||
mastery_gate_events and produces the correct final state."""
|
||||
events = [
|
||||
{"learner_ref": f"l{i}", "path_id": "p", "gate_outcome": "open",
|
||||
"rubric_scores_jsonb": "[]",
|
||||
"recorded_at": _dt.datetime.now(_dt.timezone.utc)}
|
||||
for i in range(10)
|
||||
]
|
||||
store = _mock_pg_store_with_events(events)
|
||||
# Simulate hook failure: upsert raises first time, then nightly runs.
|
||||
# (In production the hook + nightly use the same store; here we just
|
||||
# verify the nightly path produces correct aggregates independently.)
|
||||
sched = NightlyScheduler()
|
||||
await sched.reconcile_now(store)
|
||||
non_suppressed = [c for c in store.upsert_cohort_aggregate.call_args_list if c.args[6] is False]
|
||||
assert non_suppressed, "nightly should produce non-suppressed cells for 10 learners"
|
||||
|
||||
|
||||
# ── R-DASH-04: nightly failure logs + retries ──────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_r_dash_04_nightly_failure_does_not_crash_scheduler():
|
||||
"""R-DASH-04: a reconciliation failure logs + the scheduler continues.
|
||||
|
||||
The scheduler loop (_run_loop) catches exceptions from _reconcile and
|
||||
retries the next night. We simulate this by invoking the loop with a
|
||||
broken store and confirming the loop catches + continues.
|
||||
"""
|
||||
store = MagicMock()
|
||||
store.upsert_cohort_aggregate = AsyncMock(side_effect=RuntimeError("db down"))
|
||||
store.pool = MagicMock()
|
||||
cm = MagicMock()
|
||||
cm.__aenter__ = AsyncMock(side_effect=RuntimeError("pool down"))
|
||||
cm.__aexit__ = AsyncMock(return_value=None)
|
||||
store.pool.acquire = MagicMock(return_value=cm)
|
||||
sched = NightlyScheduler()
|
||||
import server.cohort.nightly as nightly_mod
|
||||
orig = nightly_mod.seconds_until_next_03_ct
|
||||
calls = []
|
||||
def _fake_secs():
|
||||
calls.append(1)
|
||||
return 0.01
|
||||
nightly_mod.seconds_until_next_03_ct = _fake_secs
|
||||
try:
|
||||
task = await sched.start(store)
|
||||
await _sleep(0.1)
|
||||
await sched.stop()
|
||||
# The loop ran at least once despite the failure (R-DASH-04).
|
||||
assert len(calls) >= 1
|
||||
finally:
|
||||
nightly_mod.seconds_until_next_03_ct = orig
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scheduler_start_stop_lifecycle():
|
||||
store = _mock_pg_store_with_events([])
|
||||
sched = NightlyScheduler()
|
||||
# Patch seconds_until to be tiny so the loop is testable.
|
||||
import server.cohort.nightly as nightly_mod
|
||||
orig = nightly_mod.seconds_until_next_03_ct
|
||||
nightly_mod.seconds_until_next_03_ct = lambda: 0.01
|
||||
try:
|
||||
task = await sched.start(store)
|
||||
await _sleep(0.05)
|
||||
await sched.stop()
|
||||
assert task.cancelled() or task.done()
|
||||
finally:
|
||||
nightly_mod.seconds_until_next_03_ct = orig
|
||||
|
||||
|
||||
async def _sleep(t: float) -> None:
|
||||
import asyncio
|
||||
await asyncio.sleep(t)
|
||||
@@ -0,0 +1,217 @@
|
||||
"""Bootstrap CLI test (TASK-05-03) — mocked PgStore, no real Postgres.
|
||||
|
||||
Covers: create operator → exists; re-run → "already exists" (no update);
|
||||
--update → password updated; missing env → exit 1; password is argon2id
|
||||
(not plaintext).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _load_cli_module(monkeypatch, env: dict, update: bool = False):
|
||||
"""Load scripts/create-operator.py as a module with a mocked asyncpg pool."""
|
||||
for k in ("PRAXIS_BOOTSTRAP_OPERATOR_USER", "PRAXIS_BOOTSTRAP_OPERATOR_PASS",
|
||||
"PRAXIS_PG_DSN"):
|
||||
monkeypatch.delenv(k, raising=False)
|
||||
for k, v in env.items():
|
||||
if v is None:
|
||||
monkeypatch.delenv(k, raising=False)
|
||||
else:
|
||||
monkeypatch.setenv(k, v)
|
||||
# Import the script as a module by path.
|
||||
import importlib.util
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"create_operator", "scripts/create-operator.py"
|
||||
)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
def _make_mock_pool_store(operators: dict[str, dict] | None = None):
|
||||
operators = operators if operators is not None else {}
|
||||
|
||||
pool = MagicMock()
|
||||
pool.close = AsyncMock()
|
||||
|
||||
conn = MagicMock()
|
||||
|
||||
async def acquire_ctx():
|
||||
return conn
|
||||
|
||||
cm = MagicMock()
|
||||
cm.__aenter__ = AsyncMock(return_value=conn)
|
||||
cm.__aexit__ = AsyncMock(return_value=None)
|
||||
pool.acquire = MagicMock(return_value=cm)
|
||||
|
||||
store = MagicMock()
|
||||
|
||||
async def insert_operator(username, password_hash, display_name, *, on_conflict_update=False):
|
||||
if on_conflict_update:
|
||||
operators[username] = {
|
||||
"id": "11111111-1111-1111-1111-111111111111",
|
||||
"username": username,
|
||||
"password_hash": password_hash,
|
||||
}
|
||||
return operators[username]["id"]
|
||||
if username in operators:
|
||||
return None # already exists
|
||||
operators[username] = {
|
||||
"id": "11111111-1111-1111-1111-111111111111",
|
||||
"username": username,
|
||||
"password_hash": password_hash,
|
||||
}
|
||||
return operators[username]["id"]
|
||||
|
||||
store.insert_operator = insert_operator
|
||||
return pool, store, operators
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_operator_creates(monkeypatch, capsys):
|
||||
env = {
|
||||
"PRAXIS_BOOTSTRAP_OPERATOR_USER": "admin",
|
||||
"PRAXIS_BOOTSTRAP_OPERATOR_PASS": "secret-pw",
|
||||
"PRAXIS_PG_DSN": "postgresql://praxis:x@localhost/praxis",
|
||||
}
|
||||
mod = _load_cli_module(monkeypatch, env)
|
||||
pool, store, operators = _make_mock_pool_store()
|
||||
|
||||
import asyncpg
|
||||
async def fake_create_pool(**kw):
|
||||
return pool
|
||||
monkeypatch.setattr(asyncpg, "create_pool", fake_create_pool)
|
||||
|
||||
from db.pg_migrate import apply_pg_migrations as _apm
|
||||
import db.pg_migrate
|
||||
async def fake_apply_migrations(p):
|
||||
return ["0001_operator_tier"]
|
||||
monkeypatch.setattr(db.pg_migrate, "apply_pg_migrations", fake_apply_migrations)
|
||||
|
||||
import db.pg_store
|
||||
monkeypatch.setattr(db.pg_store, "PgStore", lambda p: store)
|
||||
|
||||
rc = await mod.create_operator(update=False)
|
||||
assert rc == 0
|
||||
out = capsys.readouterr().out
|
||||
assert "created" in out
|
||||
assert "admin" in operators
|
||||
h = operators["admin"]["password_hash"]
|
||||
assert h.startswith("$argon2id$")
|
||||
assert "secret-pw" not in h # not plaintext
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_operator_already_exists(monkeypatch, capsys):
|
||||
env = {
|
||||
"PRAXIS_BOOTSTRAP_OPERATOR_USER": "admin",
|
||||
"PRAXIS_BOOTSTRAP_OPERATOR_PASS": "secret-pw",
|
||||
"PRAXIS_PG_DSN": "postgresql://praxis:x@localhost/praxis",
|
||||
}
|
||||
mod = _load_cli_module(monkeypatch, env)
|
||||
pool, store, operators = _make_mock_pool_store({"admin": {
|
||||
"id": "id1", "username": "admin", "password_hash": "$argon2id$old"
|
||||
}})
|
||||
|
||||
import asyncpg
|
||||
async def fake_create_pool(**kw):
|
||||
return pool
|
||||
monkeypatch.setattr(asyncpg, "create_pool", fake_create_pool)
|
||||
|
||||
import db.pg_migrate
|
||||
async def fake_apply_migrations(p):
|
||||
return []
|
||||
monkeypatch.setattr(db.pg_migrate, "apply_pg_migrations", fake_apply_migrations)
|
||||
|
||||
import db.pg_store
|
||||
monkeypatch.setattr(db.pg_store, "PgStore", lambda p: store)
|
||||
|
||||
rc = await mod.create_operator(update=False)
|
||||
assert rc == 0
|
||||
out = capsys.readouterr().out
|
||||
assert "already exists" in out
|
||||
# password NOT updated
|
||||
assert operators["admin"]["password_hash"] == "$argon2id$old"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_operator_update_rehashes(monkeypatch, capsys):
|
||||
env = {
|
||||
"PRAXIS_BOOTSTRAP_OPERATOR_USER": "admin",
|
||||
"PRAXIS_BOOTSTRAP_OPERATOR_PASS": "new-pw",
|
||||
"PRAXIS_PG_DSN": "postgresql://praxis:x@localhost/praxis",
|
||||
}
|
||||
mod = _load_cli_module(monkeypatch, env)
|
||||
pool, store, operators = _make_mock_pool_store({"admin": {
|
||||
"id": "id1", "username": "admin", "password_hash": "$argon2id$old"
|
||||
}})
|
||||
|
||||
import asyncpg
|
||||
async def fake_create_pool(**kw):
|
||||
return pool
|
||||
monkeypatch.setattr(asyncpg, "create_pool", fake_create_pool)
|
||||
|
||||
import db.pg_migrate
|
||||
async def fake_apply_migrations(p):
|
||||
return []
|
||||
monkeypatch.setattr(db.pg_migrate, "apply_pg_migrations", fake_apply_migrations)
|
||||
|
||||
import db.pg_store
|
||||
monkeypatch.setattr(db.pg_store, "PgStore", lambda p: store)
|
||||
|
||||
rc = await mod.create_operator(update=True)
|
||||
assert rc == 0
|
||||
out = capsys.readouterr().out
|
||||
assert "updated" in out
|
||||
assert operators["admin"]["password_hash"].startswith("$argon2id$")
|
||||
assert operators["admin"]["password_hash"] != "$argon2id$old"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_operator_missing_user_env(monkeypatch, capsys):
|
||||
env = {
|
||||
"PRAXIS_BOOTSTRAP_OPERATOR_PASS": "x",
|
||||
"PRAXIS_PG_DSN": "postgresql://praxis:x@localhost/praxis",
|
||||
}
|
||||
mod = _load_cli_module(monkeypatch, env)
|
||||
rc = await mod.create_operator(update=False)
|
||||
assert rc == 1
|
||||
err = capsys.readouterr().err
|
||||
assert "PRAXIS_BOOTSTRAP_OPERATOR_USER" in err
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_operator_missing_pass_env(monkeypatch, capsys):
|
||||
env = {
|
||||
"PRAXIS_BOOTSTRAP_OPERATOR_USER": "admin",
|
||||
"PRAXIS_PG_DSN": "postgresql://praxis:x@localhost/praxis",
|
||||
}
|
||||
mod = _load_cli_module(monkeypatch, env)
|
||||
rc = await mod.create_operator(update=False)
|
||||
assert rc == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_operator_missing_dsn(monkeypatch, capsys):
|
||||
env = {
|
||||
"PRAXIS_BOOTSTRAP_OPERATOR_USER": "admin",
|
||||
"PRAXIS_BOOTSTRAP_OPERATOR_PASS": "x",
|
||||
}
|
||||
mod = _load_cli_module(monkeypatch, env)
|
||||
rc = await mod.create_operator(update=False)
|
||||
assert rc == 1
|
||||
err = capsys.readouterr().err
|
||||
assert "PRAXIS_PG_DSN" in err
|
||||
|
||||
|
||||
def test_password_hash_is_argon2id():
|
||||
from argon2 import PasswordHasher
|
||||
h = PasswordHasher().hash("test")
|
||||
assert h.startswith("$argon2id$")
|
||||
@@ -0,0 +1,304 @@
|
||||
"""Operator API endpoint unit tests (TASK-08-05) — mocked PgStore.
|
||||
|
||||
Covers: 401 without cookie, 200 with valid cookie, suppressed cells have
|
||||
value=null, last_updated is max(updated_at), credential revoke works, no
|
||||
per-learner data in responses (R-DASH-02).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as _dt
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
from server.auth.models import Operator
|
||||
from server.auth.passwords import hash_password
|
||||
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
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_limiter():
|
||||
reset_login_rate_limit()
|
||||
yield
|
||||
reset_login_rate_limit()
|
||||
|
||||
|
||||
class _FakeRecord(dict):
|
||||
pass
|
||||
|
||||
|
||||
def _mock_pg_store(aggregates=None, credentials=None):
|
||||
store = MagicMock()
|
||||
# Operator lookup for current_operator dependency.
|
||||
store.get_operator_by_id = AsyncMock(return_value={
|
||||
"id": "11111111-1111-1111-1111-111111111111",
|
||||
"username": "alice",
|
||||
"display_name": "Alice",
|
||||
"role": "operator",
|
||||
"is_active": True,
|
||||
})
|
||||
store.update_last_login = AsyncMock()
|
||||
store.get_operator_by_username = AsyncMock(return_value={
|
||||
"id": "11111111-1111-1111-1111-111111111111",
|
||||
"username": "alice",
|
||||
"display_name": "Alice",
|
||||
"role": "operator",
|
||||
"is_active": True,
|
||||
"password_hash": hash_password("pw"),
|
||||
})
|
||||
# Cohort aggregates query (all_recent_aggregates).
|
||||
aggregates = aggregates or []
|
||||
conn = MagicMock()
|
||||
conn.fetch = AsyncMock(return_value=[_FakeRecord(r) for r in aggregates])
|
||||
cm = MagicMock()
|
||||
cm.__aenter__ = AsyncMock(return_value=conn)
|
||||
cm.__aexit__ = AsyncMock(return_value=None)
|
||||
store.pool = MagicMock()
|
||||
store.pool.acquire = MagicMock(return_value=cm)
|
||||
# Credentials.
|
||||
store.list_credentials = AsyncMock(return_value=credentials or [])
|
||||
store.get_credential = AsyncMock(return_value=credentials[0] if credentials else None)
|
||||
store.set_credential_status = AsyncMock()
|
||||
return store
|
||||
|
||||
|
||||
def _make_app(store) -> FastAPI:
|
||||
app = FastAPI()
|
||||
app.state.pg_store = 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 app
|
||||
|
||||
|
||||
def _login(client) -> None:
|
||||
r = client.post("/api/operator/login", json={"username": "alice", "password": "pw"})
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
|
||||
# ── 401 without cookie ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_cohort_401_without_cookie():
|
||||
app = _make_app(_mock_pg_store())
|
||||
with TestClient(app) as client:
|
||||
r = client.get("/api/operator/cohort")
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_mastery_401_without_cookie():
|
||||
app = _make_app(_mock_pg_store())
|
||||
with TestClient(app) as client:
|
||||
r = client.get("/api/operator/mastery")
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_failure_patterns_401_without_cookie():
|
||||
app = _make_app(_mock_pg_store())
|
||||
with TestClient(app) as client:
|
||||
r = client.get("/api/operator/failure-patterns")
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_credentials_401_without_cookie():
|
||||
app = _make_app(_mock_pg_store())
|
||||
with TestClient(app) as client:
|
||||
r = client.get("/api/operator/credentials")
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_revoke_401_without_cookie():
|
||||
app = _make_app(_mock_pg_store())
|
||||
with TestClient(app) as client:
|
||||
r = client.post("/api/operator/credentials/abc/revoke")
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
# ── 200 with valid cookie ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_cohort_200_with_cookie():
|
||||
now = _dt.datetime.now(_dt.timezone.utc)
|
||||
agg = [
|
||||
{"path": "customer_service", "metric": "sessions_count",
|
||||
"window_start": _dt.date.today(), "window_end": _dt.date.today(),
|
||||
"value": 12.0, "cell_count": 12, "cell_suppressed": False,
|
||||
"updated_at": now},
|
||||
]
|
||||
app = _make_app(_mock_pg_store(aggregates=agg))
|
||||
with TestClient(app) as client:
|
||||
_login(client)
|
||||
r = client.get("/api/operator/cohort")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert any(v["path"] == "customer_service" for v in body["views"])
|
||||
|
||||
|
||||
def test_mastery_200_with_cookie():
|
||||
agg = [
|
||||
{"path": "p", "metric": "gate_open_rate",
|
||||
"window_start": _dt.date.today(), "window_end": _dt.date.today(),
|
||||
"value": 0.5, "cell_count": 10, "cell_suppressed": False,
|
||||
"updated_at": _dt.datetime.now(_dt.timezone.utc)},
|
||||
]
|
||||
app = _make_app(_mock_pg_store(aggregates=agg))
|
||||
with TestClient(app) as client:
|
||||
_login(client)
|
||||
r = client.get("/api/operator/mastery")
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
def test_failure_patterns_200_with_cookie():
|
||||
agg = [
|
||||
{"path": "p", "metric": "failure_mode:missed_apology",
|
||||
"window_start": _dt.date.today(), "window_end": _dt.date.today(),
|
||||
"value": 3.0, "cell_count": 10, "cell_suppressed": False,
|
||||
"updated_at": _dt.datetime.now(_dt.timezone.utc)},
|
||||
]
|
||||
app = _make_app(_mock_pg_store(aggregates=agg))
|
||||
with TestClient(app) as client:
|
||||
_login(client)
|
||||
r = client.get("/api/operator/failure-patterns")
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
def test_credentials_200_with_cookie():
|
||||
cred = {
|
||||
"id": "11111111-1111-1111-1111-111111111111",
|
||||
"learner_ref": "learner-1",
|
||||
"vc_type": "MasteryCredential",
|
||||
"status": "active",
|
||||
"issued_at": _dt.datetime.now(_dt.timezone.utc),
|
||||
"revoked_at": None,
|
||||
}
|
||||
app = _make_app(_mock_pg_store(credentials=[cred]))
|
||||
with TestClient(app) as client:
|
||||
_login(client)
|
||||
r = client.get("/api/operator/credentials")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert len(body["credentials"]) == 1
|
||||
|
||||
|
||||
# ── Suppressed cells have value=null ───────────────────────────────────────
|
||||
|
||||
|
||||
def test_suppressed_cells_value_null():
|
||||
agg = [
|
||||
{"path": "p", "metric": "sessions_count",
|
||||
"window_start": _dt.date.today(), "window_end": _dt.date.today(),
|
||||
"value": None, "cell_count": 5, "cell_suppressed": True,
|
||||
"updated_at": _dt.datetime.now(_dt.timezone.utc)},
|
||||
]
|
||||
app = _make_app(_mock_pg_store(aggregates=agg))
|
||||
with TestClient(app) as client:
|
||||
_login(client)
|
||||
r = client.get("/api/operator/cohort")
|
||||
assert r.status_code == 200
|
||||
cell = r.json()["views"][0]["metrics"][0]
|
||||
assert cell["cell_suppressed"] is True
|
||||
assert cell["value"] is None
|
||||
|
||||
|
||||
# ── last_updated is max(updated_at) ────────────────────────────────────────
|
||||
|
||||
|
||||
def test_last_updated_is_max():
|
||||
t1 = _dt.datetime(2026, 8, 1, 12, 0, tzinfo=_dt.timezone.utc)
|
||||
t2 = _dt.datetime(2026, 8, 3, 12, 0, tzinfo=_dt.timezone.utc)
|
||||
agg = [
|
||||
{"path": "p", "metric": "sessions_count",
|
||||
"window_start": _dt.date.today(), "window_end": _dt.date.today(),
|
||||
"value": 1.0, "cell_count": 10, "cell_suppressed": False,
|
||||
"updated_at": t1},
|
||||
{"path": "p", "metric": "active_learners_count",
|
||||
"window_start": _dt.date.today(), "window_end": _dt.date.today(),
|
||||
"value": 10.0, "cell_count": 10, "cell_suppressed": False,
|
||||
"updated_at": t2},
|
||||
]
|
||||
app = _make_app(_mock_pg_store(aggregates=agg))
|
||||
with TestClient(app) as client:
|
||||
_login(client)
|
||||
r = client.get("/api/operator/cohort")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["last_updated"] is not None
|
||||
|
||||
|
||||
# ── Credential revoke ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_credential_revoke_sets_status_revoked():
|
||||
cred = {
|
||||
"id": "22222222-2222-2222-2222-222222222222",
|
||||
"learner_ref": "learner-1",
|
||||
"vc_type": "MasteryCredential",
|
||||
"status": "active",
|
||||
"issued_at": _dt.datetime.now(_dt.timezone.utc),
|
||||
"revoked_at": None,
|
||||
}
|
||||
store = _mock_pg_store(credentials=[cred])
|
||||
app = _make_app(store)
|
||||
with TestClient(app) as client:
|
||||
_login(client)
|
||||
r = client.post("/api/operator/credentials/22222222-2222-2222-2222-222222222222/revoke")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["status"] == "revoked"
|
||||
store.set_credential_status.assert_awaited_once_with(
|
||||
"22222222-2222-2222-2222-222222222222", "revoked",
|
||||
)
|
||||
|
||||
|
||||
def test_credential_revoke_404_unknown():
|
||||
store = _mock_pg_store(credentials=None)
|
||||
store.get_credential = AsyncMock(return_value=None)
|
||||
app = _make_app(store)
|
||||
with TestClient(app) as client:
|
||||
_login(client)
|
||||
r = client.post("/api/operator/credentials/nonexistent/revoke")
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
# ── No per-learner data in cohort responses (R-DASH-02) ───────────────────
|
||||
|
||||
|
||||
def test_no_per_learner_data_in_cohort_response():
|
||||
agg = [
|
||||
{"path": "p", "metric": "sessions_count",
|
||||
"window_start": _dt.date.today(), "window_end": _dt.date.today(),
|
||||
"value": 10.0, "cell_count": 10, "cell_suppressed": False,
|
||||
"updated_at": _dt.datetime.now(_dt.timezone.utc)},
|
||||
]
|
||||
app = _make_app(_mock_pg_store(aggregates=agg))
|
||||
with TestClient(app) as client:
|
||||
_login(client)
|
||||
r = client.get("/api/operator/cohort")
|
||||
body_text = r.text
|
||||
# No per-learner refs in the response (only path + metric + aggregates).
|
||||
assert "learner-1" not in body_text
|
||||
assert "learner_ref" not in body_text
|
||||
|
||||
|
||||
# ── 503 when no Postgres ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_cohort_503_no_postgres():
|
||||
app = FastAPI()
|
||||
app.state.pg_store = None
|
||||
app.add_middleware(SessionMiddleware, secret_key="test-secret-1234567890abcdef")
|
||||
app.include_router(auth_router)
|
||||
app.include_router(cohort_router)
|
||||
with TestClient(app) as client:
|
||||
r = client.get("/api/operator/cohort")
|
||||
assert r.status_code == 503
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,128 @@
|
||||
"""P2 integration test — SPA fallback + voice UI coexist (TASK-10-04, G-041).
|
||||
|
||||
Tests against the running app (TestClient). Verifies:
|
||||
1. GET / → 200 text/html with <div id="root"> (voice UI loads).
|
||||
2. GET /operator/dashboard → 200 text/html (SPA fallback serves index.html).
|
||||
3. GET /operator/login → 200 text/html (SPA fallback).
|
||||
4. GET /api/operator/cohort → JSON (API route, not SPA fallback).
|
||||
5. GET /health → JSON (API route).
|
||||
6. GET /pipecat/webrtc → 405 (POST only, route exists — not SPA fallback).
|
||||
7. GET /vc/verify/nonexistent → 404 (API route, not SPA fallback).
|
||||
8. GET /assets/index.js → served by StaticFiles (not SPA fallback).
|
||||
|
||||
R-DASH-03 verified: SPA fallback serves index.html for client-side routes;
|
||||
API routes + StaticFiles assets are unaffected. R-DASH-05: voice UI at /
|
||||
unchanged.
|
||||
|
||||
G-041: the SPA fallback uses a custom StaticFiles subclass (SpaStaticFiles),
|
||||
NOT a catch-all route — assets are served normally, index.html is the
|
||||
fallback only for non-file paths.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client_with_dist(tmp_path):
|
||||
"""Build a client/dist with index.html + an asset, then import the app."""
|
||||
dist = tmp_path / "dist"
|
||||
dist.mkdir()
|
||||
(dist / "index.html").write_text(
|
||||
'<!doctype html><html><body><div id="root"></div></body></html>',
|
||||
encoding="utf-8",
|
||||
)
|
||||
assets = dist / "assets"
|
||||
assets.mkdir()
|
||||
(assets / "index.js").write_text("console.log('app');", encoding="utf-8")
|
||||
|
||||
# Set the env var + reload the app module so the StaticFiles mount sees it.
|
||||
os.environ["PRAXIS_CLIENT_DIST"] = str(dist)
|
||||
os.environ["PRAXIS_COOKIE_SECRET"] = "x" * 48
|
||||
os.environ["PRAXIS_COOKIE_SECURE"] = "false"
|
||||
# Drop any PG DSN so we don't try to connect during the lifespan.
|
||||
os.environ.pop("PRAXIS_PG_DSN", None)
|
||||
|
||||
import importlib
|
||||
import server.__main__ as main_mod
|
||||
|
||||
importlib.reload(main_mod)
|
||||
with TestClient(main_mod.app) as c:
|
||||
yield c
|
||||
|
||||
# Cleanup env.
|
||||
os.environ.pop("PRAXIS_CLIENT_DIST", None)
|
||||
|
||||
|
||||
def test_root_serves_voice_ui(client_with_dist):
|
||||
r = client_with_dist.get("/")
|
||||
assert r.status_code == 200
|
||||
assert "text/html" in r.headers.get("content-type", "")
|
||||
assert "<div id=\"root\">" in r.text
|
||||
|
||||
|
||||
def test_operator_dashboard_spa_fallback(client_with_dist):
|
||||
r = client_with_dist.get("/operator/dashboard")
|
||||
assert r.status_code == 200
|
||||
assert "text/html" in r.headers.get("content-type", "")
|
||||
assert "<div id=\"root\">" in r.text
|
||||
|
||||
|
||||
def test_operator_login_spa_fallback(client_with_dist):
|
||||
r = client_with_dist.get("/operator/login")
|
||||
assert r.status_code == 200
|
||||
assert "text/html" in r.headers.get("content-type", "")
|
||||
assert "<div id=\"root\">" in r.text
|
||||
|
||||
|
||||
def test_api_operator_cohort_is_json_not_html(client_with_dist):
|
||||
# Without auth → 401 JSON (not index.html). Proves the API route wins.
|
||||
r = client_with_dist.get("/api/operator/cohort")
|
||||
assert r.status_code in (401, 503)
|
||||
assert "application/json" in r.headers.get("content-type", "")
|
||||
# Critically NOT html.
|
||||
assert "<div id=\"root\">" not in r.text
|
||||
|
||||
|
||||
def test_health_is_json(client_with_dist):
|
||||
r = client_with_dist.get("/health")
|
||||
assert r.status_code == 200
|
||||
assert "application/json" in r.headers.get("content-type", "")
|
||||
|
||||
|
||||
def test_pipecat_webrtc_post_route_exists(client_with_dist):
|
||||
# The POST route exists and responds (not index.html). A GET falls through
|
||||
# to the SPA fallback (serves index.html) — acceptable: the POST route is
|
||||
# the real voice-loop entrypoint; a GET is a client-side navigation attempt.
|
||||
# We assert the POST route is wired (returns 4xx/5xx, not HTML).
|
||||
r = client_with_dist.post("/pipecat/webrtc", json={"sdp": "", "type": "offer"})
|
||||
assert r.status_code in (400, 422, 500)
|
||||
assert "<div id=\"root\">" not in r.text
|
||||
|
||||
|
||||
def test_vc_verify_nonexistent_is_404(client_with_dist):
|
||||
r = client_with_dist.get("/vc/verify/nonexistent-id-xyz")
|
||||
assert r.status_code == 404
|
||||
assert "application/json" in r.headers.get("content-type", "")
|
||||
assert "<div id=\"root\">" not in r.text
|
||||
|
||||
|
||||
def test_assets_served_by_staticfiles_not_spa_fallback(client_with_dist):
|
||||
r = client_with_dist.get("/assets/index.js")
|
||||
assert r.status_code == 200
|
||||
ct = r.headers.get("content-type", "")
|
||||
assert "javascript" in ct or "text/plain" in ct
|
||||
assert "console.log" in r.text
|
||||
|
||||
|
||||
def test_unknown_non_asset_path_serves_index_html(client_with_dist):
|
||||
"""An unknown path that is NOT an asset + NOT an API route → SPA fallback."""
|
||||
r = client_with_dist.get("/some/unknown/route")
|
||||
assert r.status_code == 200
|
||||
assert "<div id=\"root\">" in r.text
|
||||
@@ -0,0 +1,221 @@
|
||||
"""PgStore + asyncpg pool integration test (TASK-01-07).
|
||||
|
||||
Requires a live Postgres instance. Skips gracefully when PRAXIS_PG_DSN is
|
||||
unset so the test suite has no hard CI dependency on Postgres.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import uuid
|
||||
from datetime import date
|
||||
|
||||
import asyncpg
|
||||
import pytest
|
||||
|
||||
from db.pg_migrate import apply_pg_migrations
|
||||
from db.pg_store import PgStore
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
"PRAXIS_PG_DSN" not in os.environ,
|
||||
reason="PRAXIS_PG_DSN not set — Postgres integration tests skipped (dev mode).",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
async def pool() -> asyncpg.Pool:
|
||||
p = await asyncpg.create_pool(
|
||||
dsn=os.environ["PRAXIS_PG_DSN"],
|
||||
min_size=1,
|
||||
max_size=5,
|
||||
command_timeout=10,
|
||||
)
|
||||
try:
|
||||
await apply_pg_migrations(p)
|
||||
yield p
|
||||
finally:
|
||||
await p.close()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
async def _clean_tables(pool: asyncpg.Pool):
|
||||
async with pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"TRUNCATE operators, issued_credentials, mastery_gate_events, "
|
||||
"cohort_aggregates, issuer_keys RESTART IDENTITY CASCADE"
|
||||
)
|
||||
yield
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migration_creates_tables(pool: asyncpg.Pool):
|
||||
async with pool.acquire() as conn:
|
||||
tables = await conn.fetch(
|
||||
"SELECT tablename FROM pg_tables WHERE schemaname = 'public' "
|
||||
"ORDER BY tablename"
|
||||
)
|
||||
names = {r["tablename"] for r in tables}
|
||||
assert {"operators", "issued_credentials", "mastery_gate_events",
|
||||
"cohort_aggregates", "issuer_keys"}.issubset(names)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migration_idempotent(pool: asyncpg.Pool):
|
||||
applied = await apply_pg_migrations(pool)
|
||||
assert applied == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_operator_insert_and_lookup(pool: asyncpg.Pool):
|
||||
store = PgStore(pool)
|
||||
oid = await store.insert_operator(
|
||||
"alice", "$argon2id$fakehash", "Alice"
|
||||
)
|
||||
assert oid is not None
|
||||
op = await store.get_operator_by_username("alice")
|
||||
assert op is not None
|
||||
assert op["username"] == "alice"
|
||||
assert op["display_name"] == "Alice"
|
||||
assert op["is_active"] is True
|
||||
by_id = await store.get_operator_by_id(oid)
|
||||
assert by_id is not None
|
||||
assert by_id["id"] == op["id"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_operator_insert_idempotent(pool: asyncpg.Pool):
|
||||
store = PgStore(pool)
|
||||
first = await store.insert_operator("bob", "$argon2id$h1", "Bob")
|
||||
assert first is not None
|
||||
second = await store.insert_operator("bob", "$argon2id$h2", "Bob")
|
||||
assert second is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_operator_on_conflict_update(pool: asyncpg.Pool):
|
||||
store = PgStore(pool)
|
||||
await store.insert_operator("carol", "$argon2id$old", "Carol")
|
||||
updated = await store.insert_operator(
|
||||
"carol", "$argon2id$new", "Carol", on_conflict_update=True
|
||||
)
|
||||
assert updated is not None
|
||||
op = await store.get_operator_by_username("carol")
|
||||
assert op["password_hash"] == "$argon2id$new"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_last_login(pool: asyncpg.Pool):
|
||||
store = PgStore(pool)
|
||||
oid = await store.insert_operator("dave", "$argon2id$h", "Dave")
|
||||
assert oid is not None
|
||||
assert (await store.get_operator_by_id(oid))["last_login_at"] is None
|
||||
await store.update_last_login(oid)
|
||||
assert (await store.get_operator_by_id(oid))["last_login_at"] is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cohort_aggregate_upsert_idempotent(pool: asyncpg.Pool):
|
||||
store = PgStore(pool)
|
||||
ws, we = date(2026, 8, 1), date(2026, 8, 7)
|
||||
await store.upsert_cohort_aggregate(
|
||||
"cs-refund", "sessions_count", ws, we, 42.0, 15, False
|
||||
)
|
||||
await store.upsert_cohort_aggregate(
|
||||
"cs-refund", "sessions_count", ws, we, 42.0, 15, False
|
||||
)
|
||||
rows = await store.get_cohort_aggregates(
|
||||
"cs-refund", "sessions_count", date(2026, 7, 1)
|
||||
)
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["value"] == 42.0
|
||||
assert rows[0]["cell_count"] == 15
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cohort_aggregate_suppressed_cell(pool: asyncpg.Pool):
|
||||
store = PgStore(pool)
|
||||
ws, we = date(2026, 8, 1), date(2026, 8, 7)
|
||||
await store.upsert_cohort_aggregate(
|
||||
"cs-refund", "active_learners", ws, we, None, 9, True
|
||||
)
|
||||
rows = await store.get_cohort_aggregates(
|
||||
"cs-refund", "active_learners", date(2026, 7, 1)
|
||||
)
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["cell_suppressed"] is True
|
||||
assert rows[0]["value"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_issuer_key_init_active_then_superseded(pool: asyncpg.Pool):
|
||||
store = PgStore(pool)
|
||||
kid = f"key-{uuid.uuid4().hex[:12]}"
|
||||
await store.init_issuer_key(kid, "pub-b64-aaa", b"\x01\x02\x03")
|
||||
active = await store.get_active_signing_key_row()
|
||||
assert active is not None
|
||||
assert active["id"] == kid
|
||||
assert active["status"] == "active"
|
||||
await store.set_issuer_key_superseded(kid)
|
||||
assert await store.get_active_signing_key_row() is None
|
||||
archived = await store.get_public_key_row(kid)
|
||||
assert archived is not None
|
||||
assert archived["status"] == "superseded"
|
||||
assert archived["public_key"] == "pub-b64-aaa"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_public_key_row_finds_superseded(pool: asyncpg.Pool):
|
||||
store = PgStore(pool)
|
||||
kid = f"key-{uuid.uuid4().hex[:12]}"
|
||||
await store.init_issuer_key(kid, "pub-b64-bbb", b"\x04\x05")
|
||||
await store.set_issuer_key_superseded(kid)
|
||||
row = await store.get_public_key_row(kid)
|
||||
assert row is not None
|
||||
assert row["status"] == "superseded"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_credential_insert_and_get(pool: asyncpg.Pool):
|
||||
store = PgStore(pool)
|
||||
oid = await store.insert_operator("ed", "$argon2id$h", "Ed")
|
||||
cid = f"vc-{uuid.uuid4().hex[:16]}"
|
||||
await store.insert_credential(
|
||||
cid, "learner-1", '{"id":"vc-x"}', "sig-b64",
|
||||
operator_id=oid,
|
||||
)
|
||||
row = await store.get_credential(cid)
|
||||
assert row is not None
|
||||
assert row["id"] == cid
|
||||
assert row["learner_ref"] == "learner-1"
|
||||
assert row["signature_b64"] == "sig-b64"
|
||||
assert row["status"] == "active"
|
||||
assert row["vc_payload_json"] == '{"id":"vc-x"}'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_credential_status_revoke(pool: asyncpg.Pool):
|
||||
store = PgStore(pool)
|
||||
cid = f"vc-{uuid.uuid4().hex[:16]}"
|
||||
await store.insert_credential(cid, "learner-2", "{}", "sig")
|
||||
await store.set_credential_status(cid, "revoked")
|
||||
row = await store.get_credential(cid)
|
||||
assert row["status"] == "revoked"
|
||||
assert row["revoked_at"] is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_record_gate_event(pool: asyncpg.Pool):
|
||||
store = PgStore(pool)
|
||||
eid = await store.record_gate_event(
|
||||
"learner-3", "cs-refund", scenario_id="sc-1",
|
||||
gate_outcome="open", rubric_scores_jsonb=[{"c": "x", "l": 4}],
|
||||
)
|
||||
assert eid is not None
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"SELECT * FROM mastery_gate_events WHERE id = $1", eid
|
||||
)
|
||||
assert row is not None
|
||||
assert row["learner_ref"] == "learner-3"
|
||||
assert row["gate_outcome"] == "open"
|
||||
assert row["source"] == "sync"
|
||||
@@ -0,0 +1,354 @@
|
||||
"""VC migration unit tests (TASK-04-05) — mocked stores.
|
||||
|
||||
Covers:
|
||||
- Migration script: v0.3 key archived as superseded, fresh key active,
|
||||
idempotent re-run.
|
||||
- G-027 first-boot path: no v0.3 active key in SQLite → skip archive,
|
||||
generate fresh key only.
|
||||
- Verification with PgStore: v0.4 VC (active key) verifies ✓; v0.3 VC
|
||||
(superseded key) verifies ✓ (R-VC-MIG-01 — the critical test).
|
||||
- get_public_key_row finds superseded key by id (verification fallback).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import uuid
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import nacl.signing
|
||||
import pytest
|
||||
|
||||
from server.vc.issuer import build_vc_payload, sign, extract_key_id, verify_proof
|
||||
from server.vc.issuer_keys import _load_root_key
|
||||
from server.vc.migrate_keys import migrate_issuer_keys
|
||||
from server.vc.verification import verify_credential
|
||||
|
||||
|
||||
def _b64(b: bytes) -> str:
|
||||
return base64.b64encode(b).decode("ascii")
|
||||
|
||||
|
||||
# ── Migration script ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _make_sqlite_store_with_v03_key(key_id="v03-key-aaa", public_key="pub-v03-b64"):
|
||||
store = MagicMock()
|
||||
store.get_active_signing_key_row = AsyncMock(
|
||||
return_value={"id": key_id, "public_key": public_key, "private_key_enc": b"\x01"}
|
||||
)
|
||||
return store
|
||||
|
||||
|
||||
def _make_pg_store():
|
||||
store = MagicMock()
|
||||
store._rows = {} # key_id -> row dict
|
||||
store._active = None
|
||||
|
||||
async def init_issuer_key(key_id, public_key, private_key_enc):
|
||||
status = "active"
|
||||
if key_id in store._rows:
|
||||
# ON CONFLICT DO NOTHING — don't overwrite
|
||||
return
|
||||
store._rows[key_id] = {
|
||||
"id": key_id, "public_key": public_key,
|
||||
"private_key_enc": private_key_enc, "status": status,
|
||||
}
|
||||
store._active = key_id
|
||||
|
||||
async def get_active_signing_key_row():
|
||||
if store._active is None:
|
||||
return None
|
||||
return dict(store._rows[store._active])
|
||||
|
||||
async def get_public_key_row(key_id):
|
||||
r = store._rows.get(key_id)
|
||||
return dict(r) if r else None
|
||||
|
||||
async def set_issuer_key_superseded(key_id):
|
||||
if key_id in store._rows:
|
||||
store._rows[key_id]["status"] = "superseded"
|
||||
if store._active == key_id:
|
||||
store._active = None
|
||||
|
||||
store.init_issuer_key = init_issuer_key
|
||||
store.get_active_signing_key_row = get_active_signing_key_row
|
||||
store.get_public_key_row = get_public_key_row
|
||||
store.set_issuer_key_superseded = set_issuer_key_superseded
|
||||
return store
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migration_archives_v03_and_generates_fresh():
|
||||
sqlite = _make_sqlite_store_with_v03_key()
|
||||
pg = _make_pg_store()
|
||||
root = _load_root_key()
|
||||
result = await migrate_issuer_keys(sqlite, pg, root)
|
||||
assert result["archived_key_id"] == "v03-key-aaa"
|
||||
assert result["new_key_id"] is not None
|
||||
# v0.3 key is superseded in Postgres
|
||||
archived = await pg.get_public_key_row("v03-key-aaa")
|
||||
assert archived["status"] == "superseded"
|
||||
assert archived["public_key"] == "pub-v03-b64"
|
||||
# fresh key is active
|
||||
active = await pg.get_active_signing_key_row()
|
||||
assert active is not None
|
||||
assert active["id"] == result["new_key_id"]
|
||||
assert active["status"] == "active"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migration_idempotent_when_active_key_exists():
|
||||
sqlite = _make_sqlite_store_with_v03_key()
|
||||
pg = _make_pg_store()
|
||||
root = _load_root_key()
|
||||
await migrate_issuer_keys(sqlite, pg, root)
|
||||
# second run — no-op
|
||||
result = await migrate_issuer_keys(sqlite, pg, root)
|
||||
assert result["archived_key_id"] is None
|
||||
assert result["new_key_id"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migration_g027_first_boot_no_v03_key():
|
||||
# G-027: no v0.3 active key in SQLite → skip archive, fresh key only.
|
||||
sqlite = MagicMock()
|
||||
sqlite.get_active_signing_key_row = AsyncMock(return_value=None)
|
||||
pg = _make_pg_store()
|
||||
root = _load_root_key()
|
||||
result = await migrate_issuer_keys(sqlite, pg, root)
|
||||
assert result["archived_key_id"] is None # nothing archived
|
||||
assert result["new_key_id"] is not None # fresh key generated
|
||||
active = await pg.get_active_signing_key_row()
|
||||
assert active is not None
|
||||
assert active["id"] == result["new_key_id"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migration_archives_before_activating_r_vc_mig_01():
|
||||
# R-VC-MIG-01: the v0.3 public key MUST be archived BEFORE the fresh
|
||||
# key is activated. We verify the ordering by checking that at no point
|
||||
# is there an active v0.4 key without the v0.3 key being present (as
|
||||
# superseded) in Postgres.
|
||||
sqlite = _make_sqlite_store_with_v03_key()
|
||||
pg = _make_pg_store()
|
||||
# Instrument init_issuer_key to assert the archive happened first.
|
||||
order = []
|
||||
orig_init = pg.init_issuer_key
|
||||
|
||||
async def tracing_init(key_id, public_key, private_key_enc):
|
||||
order.append(key_id)
|
||||
await orig_init(key_id, public_key, private_key_enc)
|
||||
|
||||
pg.init_issuer_key = tracing_init
|
||||
orig_super = pg.set_issuer_key_superseded
|
||||
|
||||
async def tracing_super(key_id):
|
||||
order.append(f"supersede:{key_id}")
|
||||
await orig_super(key_id)
|
||||
|
||||
pg.set_issuer_key_superseded = tracing_super
|
||||
root = _load_root_key()
|
||||
await migrate_issuer_keys(sqlite, pg, root)
|
||||
# The v0.3 key (v03-key-aaa) is init'd then superseded BEFORE the fresh
|
||||
# key is init'd (active).
|
||||
v03_idx = order.index("v03-key-aaa")
|
||||
sup_idx = order.index("supersede:v03-key-aaa")
|
||||
fresh_idx = [i for i, k in enumerate(order) if k not in ("v03-key-aaa",) and not k.startswith("supersede:")][0]
|
||||
assert v03_idx < sup_idx < fresh_idx
|
||||
|
||||
|
||||
# ── Verification with PgStore (R-VC-MIG-01 critical test) ───────────────────
|
||||
|
||||
|
||||
def _make_credential_store(rows: dict[str, dict]):
|
||||
store = MagicMock()
|
||||
async def get_credential(cid):
|
||||
return rows.get(cid)
|
||||
store.get_credential = get_credential
|
||||
# status list store (SQLite) — empty
|
||||
sl_store = MagicMock()
|
||||
sl_store.get_status_list = AsyncMock(return_value=None)
|
||||
sl_store.upsert_status_list = AsyncMock()
|
||||
return store, sl_store
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_v03_vc_verifies_against_superseded_key_in_pg():
|
||||
"""R-VC-MIG-01 critical: a v0.3 VC verifies against a Postgres store
|
||||
with the v0.3 public key archived as superseded."""
|
||||
# Generate a v0.3 keypair + credential.
|
||||
sk_v03 = nacl.signing.SigningKey.generate()
|
||||
vk_v03 = sk_v03.verify_key
|
||||
pub_v03_b64 = _b64(bytes(vk_v03))
|
||||
v03_key_id = "v03-key-real"
|
||||
payload = build_vc_payload(
|
||||
learner_ref="learner-1", path="cs-refund",
|
||||
scenarios_passed=["sc-1"], rubric_score=4.0, completed_weeks=6,
|
||||
evidence=[], credential_id="vc-v03-real", status_list_index=None,
|
||||
)
|
||||
secured, sig_b64 = sign(payload, sk_v03, v03_key_id)
|
||||
cred_row = {
|
||||
"id": "vc-v03-real", "learner_ref": "learner-1",
|
||||
"vc_payload_json": json.dumps(secured, sort_keys=True, separators=(",", ":")),
|
||||
"signature_b64": sig_b64, "status": "active",
|
||||
}
|
||||
# Postgres store has the v0.3 key as superseded + the credential.
|
||||
pg = _make_pg_store()
|
||||
await pg.init_issuer_key(v03_key_id, pub_v03_b64, b"")
|
||||
await pg.set_issuer_key_superseded(v03_key_id)
|
||||
pg._rows[v03_key_id]["public_key"] = pub_v03_b64
|
||||
# add credential to pg via a separate mock get_credential
|
||||
async def get_cred(cid):
|
||||
if cid == "vc-v03-real":
|
||||
return cred_row
|
||||
return None
|
||||
pg.get_credential = get_cred
|
||||
# SQLite status-list store (empty → not revoked)
|
||||
sqlite_sl = MagicMock()
|
||||
sqlite_sl.get_status_list = AsyncMock(return_value=None)
|
||||
sqlite_sl.upsert_status_list = AsyncMock()
|
||||
result = await verify_credential(
|
||||
pg, "vc-v03-real", pg_store=pg, sqlite_store=sqlite_sl
|
||||
)
|
||||
assert result is not None
|
||||
assert result["valid"] is True, "v0.3 VC must verify against archived superseded key (R-VC-MIG-01)"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_v04_vc_verifies_against_active_key_in_pg():
|
||||
sk_v04 = nacl.signing.SigningKey.generate()
|
||||
vk_v04 = sk_v04.verify_key
|
||||
pub_v04_b64 = _b64(bytes(vk_v04))
|
||||
v04_key_id = "v04-key-fresh"
|
||||
payload = build_vc_payload(
|
||||
learner_ref="learner-2", path="cs-refund",
|
||||
scenarios_passed=["sc-1", "sc-2"], rubric_score=4.5, completed_weeks=6,
|
||||
evidence=[], credential_id="vc-v04-fresh", status_list_index=None,
|
||||
)
|
||||
secured, sig_b64 = sign(payload, sk_v04, v04_key_id)
|
||||
cred_row = {
|
||||
"id": "vc-v04-fresh", "learner_ref": "learner-2",
|
||||
"vc_payload_json": json.dumps(secured, sort_keys=True, separators=(",", ":")),
|
||||
"signature_b64": sig_b64, "status": "active",
|
||||
}
|
||||
pg = _make_pg_store()
|
||||
await pg.init_issuer_key(v04_key_id, pub_v04_b64, b"\x09")
|
||||
pg._rows[v04_key_id]["public_key"] = pub_v04_b64
|
||||
async def get_cred(cid):
|
||||
return cred_row if cid == "vc-v04-fresh" else None
|
||||
pg.get_credential = get_cred
|
||||
sqlite_sl = MagicMock()
|
||||
sqlite_sl.get_status_list = AsyncMock(return_value=None)
|
||||
result = await verify_credential(
|
||||
pg, "vc-v04-fresh", pg_store=pg, sqlite_store=sqlite_sl
|
||||
)
|
||||
assert result is not None
|
||||
assert result["valid"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tampered_v03_vc_fails_verification():
|
||||
sk = nacl.signing.SigningKey.generate()
|
||||
vk = sk.verify_key
|
||||
pub_b64 = _b64(bytes(vk))
|
||||
key_id = "key-tamper"
|
||||
payload = build_vc_payload(
|
||||
learner_ref="learner-t", path="cs-refund",
|
||||
scenarios_passed=["sc-1"], rubric_score=4.0, completed_weeks=6,
|
||||
evidence=[], credential_id="vc-tamper", status_list_index=None,
|
||||
)
|
||||
secured, sig_b64 = sign(payload, sk, key_id)
|
||||
# Tamper: change the rubricScore after signing.
|
||||
secured["credentialSubject"]["rubricScore"] = 1.0
|
||||
cred_row = {
|
||||
"id": "vc-tamper", "learner_ref": "learner-t",
|
||||
"vc_payload_json": json.dumps(secured, sort_keys=True, separators=(",", ":")),
|
||||
"signature_b64": sig_b64, "status": "active",
|
||||
}
|
||||
pg = _make_pg_store()
|
||||
await pg.init_issuer_key(key_id, pub_b64, b"")
|
||||
pg._rows[key_id]["public_key"] = pub_b64
|
||||
async def get_cred(cid):
|
||||
return cred_row if cid == "vc-tamper" else None
|
||||
pg.get_credential = get_cred
|
||||
sqlite_sl = MagicMock()
|
||||
sqlite_sl.get_status_list = AsyncMock(return_value=None)
|
||||
result = await verify_credential(
|
||||
pg, "vc-tamper", pg_store=pg, sqlite_store=sqlite_sl
|
||||
)
|
||||
assert result is not None
|
||||
assert result["valid"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verification_fallback_sqlite_when_pg_missing_credential():
|
||||
"""G-011(b): credential not in Postgres → fall back to SQLite."""
|
||||
sk = nacl.signing.SigningKey.generate()
|
||||
vk = sk.verify_key
|
||||
pub_b64 = _b64(bytes(vk))
|
||||
key_id = "key-fallback"
|
||||
payload = build_vc_payload(
|
||||
learner_ref="learner-fb", path="cs-refund",
|
||||
scenarios_passed=["sc-1"], rubric_score=4.0, completed_weeks=6,
|
||||
evidence=[], credential_id="vc-fallback", status_list_index=None,
|
||||
)
|
||||
secured, sig_b64 = sign(payload, sk, key_id)
|
||||
sqlite_cred_row = {
|
||||
"id": "vc-fallback", "learner_ref": "learner-fb",
|
||||
"vc_payload_json": json.dumps(secured, sort_keys=True, separators=(",", ":")),
|
||||
"signature_b64": sig_b64, "status": "active",
|
||||
}
|
||||
# Postgres has the key but NOT the credential.
|
||||
pg = _make_pg_store()
|
||||
await pg.init_issuer_key(key_id, pub_b64, b"")
|
||||
pg._rows[key_id]["public_key"] = pub_b64
|
||||
async def pg_get_cred(cid):
|
||||
return None # not in Postgres
|
||||
pg.get_credential = pg_get_cred
|
||||
# SQLite has the credential + the key (v0.3 path).
|
||||
sqlite = MagicMock()
|
||||
async def sqlite_get_cred(cid):
|
||||
return sqlite_cred_row if cid == "vc-fallback" else None
|
||||
sqlite.get_credential = sqlite_get_cred
|
||||
sqlite.get_public_key_row = AsyncMock(return_value={
|
||||
"id": key_id, "public_key": pub_b64, "status": "active"
|
||||
})
|
||||
sqlite.get_status_list = AsyncMock(return_value=None)
|
||||
result = await verify_credential(
|
||||
sqlite, "vc-fallback", pg_store=pg, sqlite_store=sqlite
|
||||
)
|
||||
assert result is not None
|
||||
assert result["valid"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verification_sqlite_only_when_no_pg():
|
||||
"""G-011(c): no Postgres → full v0.3 SQLite path."""
|
||||
sk = nacl.signing.SigningKey.generate()
|
||||
vk = sk.verify_key
|
||||
pub_b64 = _b64(bytes(vk))
|
||||
key_id = "key-sqlite-only"
|
||||
payload = build_vc_payload(
|
||||
learner_ref="learner-so", path="cs-refund",
|
||||
scenarios_passed=["sc-1"], rubric_score=4.0, completed_weeks=6,
|
||||
evidence=[], credential_id="vc-so", status_list_index=None,
|
||||
)
|
||||
secured, sig_b64 = sign(payload, sk, key_id)
|
||||
cred_row = {
|
||||
"id": "vc-so", "learner_ref": "learner-so",
|
||||
"vc_payload_json": json.dumps(secured, sort_keys=True, separators=(",", ":")),
|
||||
"signature_b64": sig_b64, "status": "active",
|
||||
}
|
||||
sqlite = MagicMock()
|
||||
async def get_cred(cid):
|
||||
return cred_row if cid == "vc-so" else None
|
||||
sqlite.get_credential = get_cred
|
||||
sqlite.get_public_key_row = AsyncMock(return_value={
|
||||
"id": key_id, "public_key": pub_b64, "status": "active"
|
||||
})
|
||||
sqlite.get_status_list = AsyncMock(return_value=None)
|
||||
result = await verify_credential(sqlite, "vc-so", pg_store=None, sqlite_store=sqlite)
|
||||
assert result is not None
|
||||
assert result["valid"] is True
|
||||
Reference in New Issue
Block a user