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---
217 lines
6.9 KiB
Python
217 lines
6.9 KiB
Python
"""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$") |