feat(auth): single-admin password login (signed cookie) — gate tuning + Sources catalog, keep chat and document viewer public
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user