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