"""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"