"""Tests for /me/debug self-diagnostic page. The page must: - Be 404 (not 403) when ``AGNES_DEBUG_AUTH`` is unset / falsy. 404 makes the route's existence undetectable in production. - Be 200 for any authenticated user when the flag is on; 401 when no session cookie is presented. - Never echo the raw JWT — only decoded claims and a sha256 prefix. - Refetch endpoint must return the diff shape and perform zero database writes (snapshot user_group_members before/after). """ from __future__ import annotations import tempfile import uuid import pytest @pytest.fixture def fresh_db(monkeypatch): """Per-test DATA_DIR + JWT secret so the system DB is fresh.""" with tempfile.TemporaryDirectory() as tmp: monkeypatch.setenv("DATA_DIR", tmp) monkeypatch.setenv("TESTING", "1") monkeypatch.setenv("JWT_SECRET_KEY", "test-jwt-secret-key-minimum-32-chars!!") yield tmp def _make_user_and_session(conn, email: str = "u@example.com"): """Create a non-admin user, return (user_id, session_jwt).""" from src.repositories.users import UserRepository from app.auth.jwt import create_access_token uid = str(uuid.uuid4()) UserRepository(conn).create( id=uid, email=email, name=email.split("@")[0] ) token = create_access_token(user_id=uid, email=email) return uid, token def _client(): from fastapi.testclient import TestClient from app.main import app return TestClient(app) # --------------------------------------------------------------------------- # Gating # --------------------------------------------------------------------------- class TestGating: @pytest.mark.parametrize("flag_value", ["", "0", "false", "False", "no", "off"]) def test_returns_404_when_flag_off(self, fresh_db, monkeypatch, flag_value): """Falsy / unset flag must yield 404 (not 403).""" if flag_value == "": monkeypatch.delenv("AGNES_DEBUG_AUTH", raising=False) else: monkeypatch.setenv("AGNES_DEBUG_AUTH", flag_value) from src.db import get_system_db, close_system_db conn = get_system_db() try: _, sess = _make_user_and_session(conn) finally: conn.close() close_system_db() c = _client() resp = c.get("/me/debug", cookies={"access_token": sess}) assert resp.status_code == 404 @pytest.mark.parametrize("flag_value", ["1", "true", "TRUE", "yes"]) def test_returns_200_for_authed_user_when_flag_on(self, fresh_db, monkeypatch, flag_value): monkeypatch.setenv("AGNES_DEBUG_AUTH", flag_value) from src.db import get_system_db, close_system_db conn = get_system_db() try: _, sess = _make_user_and_session(conn) finally: conn.close() close_system_db() c = _client() resp = c.get("/me/debug", cookies={"access_token": sess}) assert resp.status_code == 200, resp.text assert "Auth debug" in resp.text def test_redirects_to_login_when_unauthenticated(self, fresh_db, monkeypatch): """Flag on, no cookie → get_current_user raises 401, the app's global exception handler redirects HTML GETs to /login. Important: the response must NOT be 404 (which would prove the gate runs before auth and could leak existence to scanners) — it's 302 to /login, same as any other authenticated page.""" monkeypatch.setenv("AGNES_DEBUG_AUTH", "true") from fastapi.testclient import TestClient from app.main import app c = TestClient(app, follow_redirects=False) resp = c.get("/me/debug") assert resp.status_code == 302 assert "/login" in resp.headers.get("location", "") # --------------------------------------------------------------------------- # Data leakage guards # --------------------------------------------------------------------------- class TestNoSensitiveLeakage: def test_raw_jwt_not_in_body(self, fresh_db, monkeypatch): """The full session JWT must never appear in the rendered page — only its decoded claims and a short fingerprint.""" monkeypatch.setenv("AGNES_DEBUG_AUTH", "true") from src.db import get_system_db, close_system_db conn = get_system_db() try: _, sess = _make_user_and_session(conn) finally: conn.close() close_system_db() c = _client() resp = c.get("/me/debug", cookies={"access_token": sess}) assert resp.status_code == 200 assert sess not in resp.text, "raw JWT leaked into page body" # --------------------------------------------------------------------------- # Refetch endpoint — dry-run, zero DB writes # --------------------------------------------------------------------------- class TestRefetchDryRun: def test_404_when_flag_off(self, fresh_db, monkeypatch): monkeypatch.delenv("AGNES_DEBUG_AUTH", raising=False) from src.db import get_system_db, close_system_db conn = get_system_db() try: _, sess = _make_user_and_session(conn) finally: conn.close() close_system_db() c = _client() resp = c.post("/me/debug/refetch-groups", cookies={"access_token": sess}) assert resp.status_code == 404 def test_returns_diff_shape_and_does_not_write(self, fresh_db, monkeypatch): """Mocked Google response, refetch must return the documented shape AND not change any user_group_members rows.""" monkeypatch.setenv("AGNES_DEBUG_AUTH", "true") # Mock fetch to return a deterministic list (no real Google call). monkeypatch.setenv( "GOOGLE_ADMIN_SDK_MOCK_GROUPS", "grp_admin@example.com,grp_finance@example.com", ) from src.db import get_system_db, close_system_db conn = get_system_db() try: uid, sess = _make_user_and_session(conn, email="m@example.com") before_rows = conn.execute( "SELECT user_id, group_id, source FROM user_group_members " "WHERE user_id = ?", [uid], ).fetchall() finally: conn.close() close_system_db() c = _client() resp = c.post("/me/debug/refetch-groups", cookies={"access_token": sess}) assert resp.status_code == 200, resp.text data = resp.json() # Documented shape — keys present, types right. for key in ( "soft_failed", "prefix", "fetched", "fetched_relevant", "current_names", "current_external_ids", "would_add", "would_remove", "applied", ): assert key in data, f"missing key {key!r}" assert data["applied"] is False assert data["soft_failed"] is False assert isinstance(data["fetched"], list) assert isinstance(data["would_add"], list) # Zero DB writes — snapshot before/after must match exactly. conn = get_system_db() try: after_rows = conn.execute( "SELECT user_id, group_id, source FROM user_group_members " "WHERE user_id = ?", [uid], ).fetchall() finally: conn.close() close_system_db() assert before_rows == after_rows def test_soft_fail_marker_when_mock_unset_and_real_path_unconfigured( self, fresh_db, monkeypatch ): """Without the mock env and without GOOGLE_ADMIN_SDK_SUBJECT, the real path returns soft-fail; the endpoint reports it as such.""" monkeypatch.setenv("AGNES_DEBUG_AUTH", "true") monkeypatch.delenv("GOOGLE_ADMIN_SDK_MOCK_GROUPS", raising=False) monkeypatch.delenv("GOOGLE_ADMIN_SDK_SUBJECT", raising=False) from src.db import get_system_db, close_system_db conn = get_system_db() try: _, sess = _make_user_and_session(conn, email="sf@example.com") finally: conn.close() close_system_db() c = _client() resp = c.post("/me/debug/refetch-groups", cookies={"access_token": sess}) assert resp.status_code == 200, resp.text data = resp.json() # On the keyless-DWD branch, fetch_user_groups returns [] on missing # subject (legacy fail-soft as empty list); on the prefix-mapping # branch it returns None. Tolerate either — endpoint reports # soft_failed=True when None, False+empty list when []. if data["soft_failed"]: assert data["fetched"] == [] else: # Real path returned [] — also a valid shape; assert no writes # happened by virtue of applied=False + DB snapshot below. assert data["fetched"] == [] assert data["applied"] is False