feat(auth): single-admin password login (signed cookie) — gate tuning + Sources catalog, keep chat and document viewer public

This commit is contained in:
2026-08-23 19:58:39 -04:00
parent fc0d9a2d5c
commit cbc263a4b2
46 changed files with 1555 additions and 691 deletions
+153
View File
@@ -0,0 +1,153 @@
"""Unit tests: single-admin auth (phase 16; A10 revised).
Covers the config gate (fail-loud, including via ``create_app``), the
constant-time password check, the ``require_admin`` dependency, the
whoami payload shape, and the sign_in/sign_out session semantics.
"""
from __future__ import annotations
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,
sign_in,
sign_out,
)
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"
# ---------- whoami payload shape ----------
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")
# ---------- 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