"""Unit tests: auth (phase 16 single-admin; phase 79 token users). Covers the config gate (fail-loud, including via ``create_app``), the constant-time password check, the ``require_admin`` dependency, the phase-79 ``require_user`` matrix (admin pass, live token pass, revoked / missing-row 401 + session keys popped, anonymous 401, admin+user coexistence), the three-role whoami payload shape, and the sign_in/sign_out session semantics. """ from __future__ import annotations import uuid from datetime import UTC, datetime import pytest from fastapi import HTTPException from starlette.middleware.sessions import Session from starlette.requests import Request import app.main as main_mod from app.api.auth import whoami from app.config import Settings from app.core.auth import ( check_password, ensure_admin_configured, require_admin, require_user, sign_in, sign_out, ) from app.models import ApiToken from app.schemas import WhoamiResponse def _settings(**kwargs: object) -> Settings: return Settings(_env_file=None, **kwargs) # pyright: ignore[reportCallIssue] # ---------- ensure_admin_configured (fail-loud) ---------- def test_configured_passes() -> None: ensure_admin_configured(_settings(admin_password="pw", session_secret="s")) @pytest.mark.parametrize( ("password", "secret", "named"), [ ("", "s", "BOR_ADMIN_PASSWORD"), ("pw", "", "BOR_SESSION_SECRET"), ("", "", "BOR_ADMIN_PASSWORD"), ("", "", "BOR_SESSION_SECRET"), ], ) def test_missing_vars_raise_naming_them(password: str, secret: str, named: str) -> None: with pytest.raises(RuntimeError) as exc: ensure_admin_configured(_settings(admin_password=password, session_secret=secret)) assert named in str(exc.value) # Both vars are named when both are missing. if not password and not secret: assert "BOR_ADMIN_PASSWORD" in str(exc.value) assert "BOR_SESSION_SECRET" in str(exc.value) def test_whitespace_only_counts_as_missing() -> None: with pytest.raises(RuntimeError, match="BOR_ADMIN_PASSWORD"): ensure_admin_configured(_settings(admin_password=" ", session_secret="s")) def test_create_app_raises_when_admin_password_missing(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(main_mod.settings, "admin_password", "") with pytest.raises(RuntimeError, match="BOR_ADMIN_PASSWORD"): main_mod.create_app() def test_create_app_raises_when_session_secret_missing(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(main_mod.settings, "session_secret", "") with pytest.raises(RuntimeError, match="BOR_SESSION_SECRET"): main_mod.create_app() def test_create_app_boots_when_configured(monkeypatch: pytest.MonkeyPatch) -> None: # conftest set both env vars before app.main imported — the factory # with valid config returns an app (and no static-dir warning fires # here: the frontend dir exists in the repo). monkeypatch.setattr(main_mod.settings, "admin_password", "pw") monkeypatch.setattr(main_mod.settings, "session_secret", "s") app2 = main_mod.create_app() assert app2 is not None # ---------- check_password (constant-time, one generic result) ---------- def test_check_password_match() -> None: assert check_password("hunter2", "hunter2") is True def test_check_password_mismatch() -> None: assert check_password("hunter2", "hunter3") is False def test_check_password_empty_candidate() -> None: assert check_password("", "hunter2") is False def test_check_password_unicode() -> None: assert check_password("pässwörd", "pässwörd") is True assert check_password("pässwörd", "pässwörX") is False # ---------- require_admin (dependency: 403 for anonymous) ---------- def _request_with_session(**session: object) -> Request: # request.session is a scope-backed property (SessionMiddleware puts # the Session into the scope) — build the scope the same way. return Request({"type": "http", "session": Session(dict(session))}) def test_require_admin_passes_for_admin_session() -> None: require_admin(_request_with_session(admin=True)) # no exception @pytest.mark.parametrize("session", [{}, {"admin": False}, {"admin": None}]) def test_require_admin_403s_anonymous(session: dict) -> None: with pytest.raises(HTTPException) as exc: require_admin(_request_with_session(**session)) assert exc.value.status_code == 403 assert exc.value.detail == "admin only" # ---------- require_user (phase 79: admin OR live token, else 401) ---------- class _FakeTokenResult: """``execute()`` result for the fake db: ``.scalars().first()`` yields the one fixed row (or ``None``).""" def __init__(self, row: ApiToken | None) -> None: self._row = row def scalars(self) -> _FakeTokenResult: return self def first(self) -> ApiToken | None: return self._row class _FakeTokenDb: """Session stand-in for ``require_user``'s PK lookup: counts the queries it is given and always returns the one fixed row — enough to pin the matrix without a database (the admin path must NOT query at all).""" def __init__(self, row: ApiToken | None) -> None: self._row = row self.queries = 0 def execute(self, _stmt: object) -> _FakeTokenResult: self.queries += 1 return _FakeTokenResult(self._row) def _token_row(**kwargs: object) -> ApiToken: base: dict[str, object] = { "id": uuid.uuid4(), "label": "alice", "token_hash": "0" * 64, "created_at": datetime.now(UTC), } base.update(kwargs) return ApiToken(**base) # pyright: ignore[reportCallIssue] def test_require_user_admin_session_passes_without_any_db_lookup() -> None: """An admin ALWAYS passes — token state irrelevant, no row fetched (the admin+user coexistence case: admin wins outright).""" db = _FakeTokenDb(None) # would be a dead row if it were ever consulted require_user( _request_with_session( admin=True, user=True, user_token_id=str(uuid.uuid4()) ), db, # pyright: ignore[reportArgumentType] ) # no exception assert db.queries == 0 def test_require_user_active_token_session_passes() -> None: row = _token_row() db = _FakeTokenDb(row) require_user( _request_with_session(user=True, user_token_id=str(row.id)), db, # pyright: ignore[reportArgumentType] ) # no exception assert db.queries == 1 # the live PK lookup ran @pytest.mark.parametrize( ("row", "token_id"), [ (None, str(uuid.uuid4())), # the row is gone (deleted out-of-band) (_token_row(revoked_at=datetime.now(UTC)), str(uuid.uuid4())), # revoked (None, "not-a-uuid"), # corrupt session — no valid row id at all ], ids=["missing-row", "revoked", "corrupt-token-id"], ) def test_require_user_dead_token_session_401s_and_pops_both_keys( row: ApiToken | None, token_id: str ) -> None: """Row missing / revoked / unresolvable → 401 ``authentication required`` AND the dead session is dropped NOW (both user keys popped, so the next whoami is anonymous).""" request = _request_with_session(user=True, user_token_id=token_id) with pytest.raises(HTTPException) as exc: require_user(request, _FakeTokenDb(row)) # pyright: ignore[reportArgumentType] assert exc.value.status_code == 401 assert exc.value.detail == "authentication required" assert "user" not in request.session assert "user_token_id" not in request.session def test_require_user_anonymous_401s() -> None: with pytest.raises(HTTPException) as exc: require_user( _request_with_session(), _FakeTokenDb(None) # pyright: ignore[reportArgumentType] ) assert exc.value.status_code == 401 assert exc.value.detail == "authentication required" def test_require_user_user_key_without_token_id_401s_and_pops() -> None: """A ``user`` key with no ``user_token_id`` at all is a dead session too — same 401, both keys dropped.""" request = _request_with_session(user=True) with pytest.raises(HTTPException) as exc: require_user(request, _FakeTokenDb(None)) # pyright: ignore[reportArgumentType] assert exc.value.status_code == 401 assert exc.value.detail == "authentication required" assert "user" not in request.session # ---------- whoami payload shape (three roles, phase 79) ---------- def test_whoami_anonymous_payload() -> None: body = whoami(_request_with_session()) assert body == WhoamiResponse(authenticated=False, role="anonymous") assert set(body.model_dump()) == {"authenticated", "role"} def test_whoami_admin_payload() -> None: body = whoami(_request_with_session(admin=True)) assert body == WhoamiResponse(authenticated=True, role="admin") def test_whoami_token_user_payload() -> None: body = whoami(_request_with_session(user=True, user_token_id=str(uuid.uuid4()))) assert body == WhoamiResponse(authenticated=True, role="user") def test_whoami_admin_wins_when_both_roles_are_set() -> None: """Coexistence is deliberate: a browser holding BOTH an admin and a token session reports admin (the UI keys off role, the admin surface stays open).""" body = whoami( _request_with_session(admin=True, user=True, user_token_id=str(uuid.uuid4())) ) assert body == WhoamiResponse(authenticated=True, role="admin") # ---------- sign_in / sign_out session semantics ---------- def test_sign_in_marks_session_admin_and_modified() -> None: session = Session({}) sign_in(session) assert session["admin"] is True assert session.modified is True # the middleware will persist the cookie def test_sign_out_clears_session() -> None: session = Session({"admin": True, "stray": "x"}) sign_out(session) assert dict(session) == {} assert "admin" not in session