Single consolidated commit for four completed, validated phases (77, 78, 79, 80). The pipeline run left all work uncommitted because the harness commits only with PHASE_COMMIT=1 while child executors are forbidden from committing; the phases themselves all passed validation and moved to .agents/phases/complete/. Phase 77 — navbar view refresh - router.js dispatches bor:view-refresh on re-show / active re-click / popstate (gated on wasMounted; first show and boot exempt) - History / RAG / Sources / Tuning re-fetch on refresh (admin branch); Chat deliberately excluded (stream survival) - History "Refresh" button (admin-only, in-flight disable + status line) - New story suite tests/e2e/test_navbar_refresh.py (7 tests) Phase 78 — static background - Removed the animated glow layers; static 44px grid over the flat --bg canvas; default and reduced-motion renders byte-identical - Updated background/theme E2E suites; removed bg-glow test pins Phase 79 — API tokens - api_tokens model + migration 0012; hash-only token service - Admin tokens API + Tokens admin view; POST /api/token-auth; live-revoking require_user on chat / suggestions / document content - Frontend token gate with localStorage cache; anonymous E2E suites migrated to token login - New story suite tests/e2e/test_api_tokens.py (9 tests) Phase 80 — history suggestion chips - last_questions() endpoint with SEED fallback; startNewChat() refetch - Seed-semantics docs (config.py, .env.example, README) - Integration state matrix + E2E suite rewritten to the 4 chip states Also included: phase-76 report artifacts and the repo restore-test-db skill (previously untracked), scripts/* ruff fixes from phase 77. Final gate state (phase 80 final pass, covers everything above): - uv run pytest --cov=app → 1637 passed, 0 failed, app/ coverage 99% - uv run ruff check . && uv run pyright → clean, 0 errors - Per-phase story E2E suites green in isolation
287 lines
9.8 KiB
Python
287 lines
9.8 KiB
Python
"""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
|