46b46479ca
- TASK-06-01 __main__.py: SessionMiddleware (signed cookies, D-056) added
AFTER CORS so it is outermost. slowapi limiter state + 429 exception
handler registered. The lifespan (TASK-01-03) now also runs the VC key
migration on first boot.
- TASK-06-02 __main__.py: auth_router mounted (POST /api/operator/login,
POST /api/operator/logout, GET /api/operator/me) BEFORE the StaticFiles
mount (routes-before-static constraint). Auth routes use app.state.pg_store
(503 if no Postgres).
- TASK-06-03 __main__.py: /vc/verify swapped to the two-store path (G-011):
pg_store for key lookup (active + superseded), SQLite fallback for v0.3
credentials, SQLite-only if no Postgres. _maybe_migrate_issuer_keys()
runs once in the lifespan (idempotent, G-027 first-boot, non-fatal on
failure — v0.3 path intact).
- TASK-06-04 tests/test_p1_auth_integration.py: 4 e2e tests (skip if no
Postgres) — full auth flow, /me without cookie 401, wrong password 401,
learner voice loop unaffected (REQ-NFR-MT-01).
- TASK-06-05 tests/test_p1_vc_migration_e2e.py: 5 e2e tests (skip if no
Postgres) — R-VC-MIG-01 critical (v0.3 VC verifies against archived
superseded key in Postgres), idempotent migration, G-027 first-boot,
v0.04 VC verifies, tamper detection.
Graceful degradation verified: server starts without Postgres (pg_pool/
pg_store are None; voice loop works; auth routes return 503).
---ci---
project: praxis
phase: 1
milestone: v0.4
status: execute
persona: backend-engineer
task: 06-01,06-02,06-03,06-04,06-05
requirements:
covered: [REQ-MT-01, REQ-AUTH-01, REQ-NFR-AUTH-01, REQ-NFR-MT-01]
grill:
- G-011 (two-store fallback wired in /vc/verify)
risks:
- R-VC-MIG-01 (e2e test: v0.3 VC verifies against archived superseded key in Postgres)
---/ci---
115 lines
4.0 KiB
Python
115 lines
4.0 KiB
Python
"""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" |