All gates verified. The phase is complete — everything was already implemented by prior task runs; this pass verified each completion criterion end-to-end and found no defects. **Phase 81 final verification report** (tasks 01–03 all present in `complete/`; no code changes needed this pass) - Verified: `app/core/rate_limit.py` (stdlib sliding window, fail-open) + both login routes' 429 pre-check/record/reset wiring + all unit/integration pins present (11th-429, blocked-success-still-429, clean-counter reset, shared counter, autouse `clean_rate_limit` fixture documented). - Live check (task 03): dev server + 11 rapid wrong logins → `401 ×10, 429`; 429 body carries `retry-after: 900` + generic detail; server restarted (per-process counter cleared by design). - `uv run pytest tests/unit/test_rate_limit.py -v --no-cov` → 10 passed - `uv run pytest --cov=app --cov-report=term-missing` → 1652 passed, **TOTAL 99%** (>90%; rate_limit.py 100%, auth.py 100%) - `uv run pytest tests/e2e/test_smoke.py -v --no-cov` (isolation) → 3 passed - `uv run ruff check . && uv run pyright` → All checks passed / 0 errors, 0 warnings - Completion criteria: all met, except commit + phase-dir move — per harness rules I left all changes uncommitted in the working tree (harness commits atomically and moves the phase). - Diff scope: exactly `app/core/rate_limit.py`, `app/api/auth.py`, `tests/unit/test_rate_limit.py`, `tests/integration/test_auth_api.py` + phase files; `pyproject.toml` / `uv.lock` / `frontend/` untouched. - Deviations: none in code; commit/move deferred to harness as instructed. - Next pending phase: `82_security_headers`.
524 lines
21 KiB
Python
524 lines
21 KiB
Python
"""Integration: the auth surface (phase 16 admin; phase 79 token users).
|
|
|
|
Covers the full admin lifecycle against the real app (TestClient keeps
|
|
the cookie jar): wrong password → 401 + still-gated; correct → 204 +
|
|
cookie → admin everywhere gated; logout → 403 again. And the phase-79
|
|
token flows: ``POST /api/token-auth`` (valid → 204 + the ``user`` role;
|
|
invalid / malformed / revoked / empty → ONE generic 401), the
|
|
three-role ``whoami``, logout clearing the token session, and the
|
|
LIVE revocation check (a revoked token is refused on the next request
|
|
and the dead session's keys are dropped — whoami falls to anonymous).
|
|
|
|
The phase-16 anonymous pins are SUPERSEDED by the phase-79 contract:
|
|
the ONLY anonymous content is the shared chats (plus the login/infra
|
|
endpoints the gate itself needs) — anonymous chat / suggestions /
|
|
document content now 401 ``authentication required``.
|
|
|
|
Rate limiting (phase 81, audit SEC-03): the sign-in failure counter is
|
|
PROCESS state (in-memory per IP), and TestClient's
|
|
``request.client.host`` is the fixed value ``"testclient"`` — so every
|
|
client in this module (including ``_admin_client``'s separate
|
|
TestClient) shares ONE limiter entry. The ``clean_rate_limit`` autouse
|
|
fixture resets that entry around every test so the deliberate 429 pins
|
|
can't poison the other tests (and the table TRUNCATEs can't help: the
|
|
counter is not row state).
|
|
|
|
Requires: podman compose up -d db
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from collections.abc import Iterator
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
from sqlalchemy import text
|
|
from test_chat_api import FakeRagLLM, _stream_chat
|
|
|
|
from app.api import chat as chat_api
|
|
from app.api.auth import TOO_MANY_DETAIL
|
|
from app.core import rate_limit
|
|
from app.core.rate_limit import WINDOW_SECONDS
|
|
from app.main import app as fastapi_app
|
|
from app.models import Document
|
|
from app.rag.importer import import_sources
|
|
from tests.conftest import ADMIN_PASSWORD
|
|
|
|
FIXTURES = Path(__file__).resolve().parents[1] / "fixtures" / "docs"
|
|
QUESTION = "How is my Kubernetes cluster set up?"
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def clean_rate_limit() -> Iterator[None]:
|
|
"""Reset the in-memory sign-in-failure counter around every test.
|
|
|
|
TestClient's ``request.client.host`` is the fixed value
|
|
``"testclient"``, so every client in this module — including the
|
|
``_admin_client`` helper's separate TestClient — shares ONE limiter
|
|
entry, and the counter is process state the table TRUNCATEs cannot
|
|
clear. Without this reset, the deliberate 429 tests below (which
|
|
block the shared IP on purpose) would make every later test in the
|
|
process hit the throttled 429 path instead of the 401/204 contract
|
|
it pins.
|
|
"""
|
|
rate_limit.reset("testclient")
|
|
yield
|
|
rate_limit.reset("testclient")
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def clean_tables(db) -> Iterator[None]:
|
|
"""Docs + steering + query log + tokens are global state: reset
|
|
around tests."""
|
|
db.execute(
|
|
text("TRUNCATE chunks, documents, query_log, steering_notes, api_tokens")
|
|
)
|
|
db.commit()
|
|
yield
|
|
db.execute(
|
|
text("TRUNCATE chunks, documents, query_log, steering_notes, api_tokens")
|
|
)
|
|
db.commit()
|
|
|
|
|
|
@pytest.fixture()
|
|
def seeded_kb(db) -> Iterator[FakeRagLLM]:
|
|
"""Fresh Postgres with the fixture docs imported (real pipeline)."""
|
|
db.execute(text("TRUNCATE chunks, documents, query_log"))
|
|
db.commit()
|
|
llm = FakeRagLLM()
|
|
summary = asyncio.run(import_sources([FIXTURES], llm, session=db))
|
|
assert summary.added == 13 # A9 formats (phase 47 added quadlet+j2); .hidden/ skipped
|
|
yield llm
|
|
db.execute(text("TRUNCATE chunks, documents, query_log"))
|
|
db.commit()
|
|
|
|
|
|
def _seed_one_doc(db) -> Document:
|
|
"""One minimal document (for the user-gated document-content endpoint)."""
|
|
doc = Document(
|
|
source="docs",
|
|
path="homelab/kubernetes.md",
|
|
full_path="/tmp/kubernetes.md",
|
|
title="Kubernetes Homelab Cluster",
|
|
content="# Kubernetes Homelab Cluster\n\nTalos on 3 nodes.",
|
|
content_hash="c" * 64,
|
|
)
|
|
db.add(doc)
|
|
db.commit()
|
|
db.refresh(doc)
|
|
return doc
|
|
|
|
|
|
def _admin_client() -> TestClient:
|
|
"""A SEPARATE client signed in as the admin — for tests that need an
|
|
admin and a token user at the same time (the shared ``client``
|
|
fixture is the token holder in those)."""
|
|
admin = TestClient(fastapi_app)
|
|
r = admin.post("/api/login", json={"password": ADMIN_PASSWORD})
|
|
assert r.status_code == 204, f"admin login failed: {r.status_code} {r.text}"
|
|
return admin
|
|
|
|
|
|
def _create_token(admin: TestClient, label: str = "alice") -> tuple[str, str]:
|
|
"""Admin issues one token; returns ``(token_id, plaintext)`` — the
|
|
plaintext is the 201 body's one-and-only wire moment (task 02)."""
|
|
r = admin.post("/api/tokens", json={"label": label})
|
|
assert r.status_code == 201, r.text
|
|
body = r.json()
|
|
return body["id"], body["token"]
|
|
|
|
|
|
# ---------- phase 16: the admin lifecycle (unchanged contract) ----------
|
|
|
|
|
|
def test_wrong_password_401_and_still_gated(client: TestClient) -> None:
|
|
r = client.post("/api/login", json={"password": "not-the-password"})
|
|
assert r.status_code == 401
|
|
assert r.json() == {"detail": "invalid password"}
|
|
# No session state was created by the failed attempt.
|
|
assert "bor_session" not in client.cookies
|
|
|
|
r = client.post("/api/login", json={"password": ""}) # empty → same 401
|
|
assert r.status_code == 401
|
|
assert r.json() == {"detail": "invalid password"}
|
|
|
|
# The gated surface stays closed (anonymous).
|
|
assert client.get("/api/docs").status_code == 403
|
|
r = client.get("/api/steering")
|
|
assert r.status_code == 403
|
|
assert r.json() == {"detail": "admin only"}
|
|
assert client.post("/api/steering", json={"note": "x"}).status_code == 403
|
|
assert client.get("/api/whoami").json() == {
|
|
"authenticated": False,
|
|
"role": "anonymous",
|
|
}
|
|
|
|
|
|
def test_login_logout_lifecycle(client: TestClient) -> None:
|
|
# Anonymous shape before anything (phase 79: the anonymous whoami
|
|
# gains the explicit role — the UI keys its admin surfaces off it).
|
|
who = client.get("/api/whoami").json()
|
|
assert who == {"authenticated": False, "role": "anonymous"}
|
|
|
|
# Wrong first, right second — one generic 401, then success.
|
|
assert client.post("/api/login", json={"password": "nope"}).status_code == 401
|
|
r = client.post("/api/login", json={"password": ADMIN_PASSWORD})
|
|
assert r.status_code == 204
|
|
assert "bor_session" in client.cookies # the signed session cookie
|
|
|
|
# Admin: whoami + the gated endpoints all open up.
|
|
assert client.get("/api/whoami").json() == {
|
|
"authenticated": True,
|
|
"role": "admin",
|
|
}
|
|
assert client.get("/api/docs").status_code == 200
|
|
|
|
created = client.post("/api/steering", json={"note": " be terse "})
|
|
assert created.status_code == 201
|
|
note = created.json()
|
|
assert note["note"] == "be terse"
|
|
listing = client.get("/api/steering")
|
|
assert listing.status_code == 200
|
|
assert [n["note"] for n in listing.json()["notes"]] == ["be terse"]
|
|
assert client.delete(f"/api/steering/{note['id']}").status_code == 204
|
|
assert client.get("/api/steering").json() == {"notes": []}
|
|
|
|
# Logout: 204, cookie gone, gated again.
|
|
assert client.post("/api/logout").status_code == 204
|
|
assert "bor_session" not in client.cookies
|
|
assert client.get("/api/whoami").json() == {
|
|
"authenticated": False,
|
|
"role": "anonymous",
|
|
}
|
|
assert client.get("/api/docs").status_code == 403
|
|
r = client.get("/api/steering")
|
|
assert r.status_code == 403
|
|
assert r.json() == {"detail": "admin only"}
|
|
# Logout is idempotent (anonymous logout is still a clean 204).
|
|
assert client.post("/api/logout").status_code == 204
|
|
assert client.get("/api/whoami").json()["authenticated"] is False
|
|
|
|
|
|
def test_forged_cookie_is_rejected(client: TestClient) -> None:
|
|
client.cookies.set("bor_session", "tampered-session-blob")
|
|
assert client.get("/api/whoami").json() == {
|
|
"authenticated": False,
|
|
"role": "anonymous",
|
|
}
|
|
assert client.get("/api/docs").status_code == 403
|
|
|
|
|
|
# ---------- phase 79: the token login + the user role ----------
|
|
|
|
|
|
def test_token_auth_valid_signs_in_token_user(client: TestClient) -> None:
|
|
"""A valid token → 204 + session cookie → whoami reports the THIRD
|
|
role: ``{authenticated: true, role: "user"}``."""
|
|
admin = _admin_client()
|
|
_token_id, token = _create_token(admin)
|
|
|
|
r = client.post("/api/token-auth", json={"token": token})
|
|
assert r.status_code == 204
|
|
assert "bor_session" in client.cookies # the signed session cookie
|
|
|
|
assert client.get("/api/whoami").json() == {
|
|
"authenticated": True,
|
|
"role": "user",
|
|
}
|
|
|
|
|
|
def test_token_auth_all_failures_share_one_generic_401(client: TestClient) -> None:
|
|
"""Malformed / unknown / revoked / empty are INDISTINGUISHABLE — one
|
|
401 ``{"detail": "invalid token"}`` for every failure shape (the
|
|
phase-16 no-enumeration pattern; a 422 would hint at input-shape
|
|
differences on a credential endpoint)."""
|
|
admin = _admin_client()
|
|
_token_id, token = _create_token(admin)
|
|
|
|
failures = [
|
|
token + "0", # unknown (no row for this hash)
|
|
"bor_" + "0" * 32, # well-shaped, never issued
|
|
"short", # malformed: too short
|
|
"wrongprefix_" + "0" * 32, # malformed: wrong prefix
|
|
"", # empty
|
|
" ", # whitespace only
|
|
]
|
|
for bad in failures:
|
|
r = client.post("/api/token-auth", json={"token": bad})
|
|
assert r.status_code == 401, bad
|
|
assert r.json() == {"detail": "invalid token"}, bad
|
|
assert "bor_session" not in client.cookies # no session on failure
|
|
|
|
# A failed attempt must not sign anyone in.
|
|
assert client.get("/api/whoami").json() == {
|
|
"authenticated": False,
|
|
"role": "anonymous",
|
|
}
|
|
|
|
# Revoked → the SAME 401 (not a different "revoked" message).
|
|
_bob_id, bob_token = _create_token(admin, "bob")
|
|
bob_id = next(
|
|
t["id"] for t in admin.get("/api/tokens").json()["tokens"] if t["label"] == "bob"
|
|
)
|
|
assert admin.post(f"/api/tokens/{bob_id}/revoke").status_code == 204
|
|
r = client.post("/api/token-auth", json={"token": bob_token})
|
|
assert r.status_code == 401
|
|
assert r.json() == {"detail": "invalid token"}
|
|
|
|
|
|
def test_token_user_surface_matrix(client: TestClient, db, seeded_kb: FakeRagLLM) -> None:
|
|
"""The owner's sentence (phase 79): a token user gets the app surface
|
|
— chat streams, suggestion chips, cited document content — and NO
|
|
admin surface (tokens, docs list, saved chats, steering, git
|
|
sources stay 403 for them; saved chats have no per-user
|
|
attribution)."""
|
|
admin = _admin_client()
|
|
_token_id, token = _create_token(admin)
|
|
assert client.post("/api/token-auth", json={"token": token}).status_code == 204
|
|
|
|
# Chat streams (the mocked LLM — the real turn pipeline).
|
|
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: seeded_kb
|
|
try:
|
|
status, content_type, frames = _stream_chat(client, QUESTION)
|
|
finally:
|
|
fastapi_app.dependency_overrides.clear()
|
|
assert status == 200
|
|
assert content_type.startswith("text/event-stream")
|
|
assert frames[-1]["type"] == "done"
|
|
assert frames[-1]["deflected"] is False
|
|
assert frames[-1]["sources"][0]["path"] == "homelab/kubernetes.md"
|
|
|
|
# Suggestion chips + the cited document's content (the viewer).
|
|
assert client.get("/api/suggestions").status_code == 200
|
|
r = client.get(
|
|
"/api/documents/content", params={"source": "docs", "path": "homelab/kubernetes.md"}
|
|
)
|
|
assert r.status_code == 200
|
|
assert r.json()["title"] == "Kubernetes Homelab Cluster"
|
|
|
|
# The admin surface stays admin-only for a token user (A3 scope).
|
|
for path in ("/api/tokens", "/api/docs", "/api/chats", "/api/steering", "/api/git-sources"):
|
|
r = client.get(path)
|
|
assert r.status_code == 403, path
|
|
assert r.json() == {"detail": "admin only"}, path
|
|
|
|
|
|
def test_token_user_cannot_create_or_list_tokens(client: TestClient) -> None:
|
|
"""The token ADMIN surface is closed to token holders on every
|
|
method, not just GET (A3: only the admin manages tokens)."""
|
|
admin = _admin_client()
|
|
_token_id, token = _create_token(admin)
|
|
assert client.post("/api/token-auth", json={"token": token}).status_code == 204
|
|
|
|
assert client.post("/api/tokens", json={"label": "evil"}).status_code == 403
|
|
assert client.get("/api/tokens").status_code == 403
|
|
assert client.post("/api/tokens/00000000-0000-0000-0000-000000000000/revoke").status_code == 403
|
|
# The admin's own token list is untouched.
|
|
assert [t["label"] for t in admin.get("/api/tokens").json()["tokens"]] == ["alice"]
|
|
|
|
|
|
def test_logout_clears_token_session(client: TestClient) -> None:
|
|
"""One logout, one session dict: a token holder's logout drops the
|
|
``user`` role — whoami anonymous and the gated surface 401s again."""
|
|
admin = _admin_client()
|
|
_token_id, token = _create_token(admin)
|
|
assert client.post("/api/token-auth", json={"token": token}).status_code == 204
|
|
assert client.get("/api/whoami").json()["role"] == "user"
|
|
|
|
assert client.post("/api/logout").status_code == 204
|
|
assert "bor_session" not in client.cookies
|
|
assert client.get("/api/whoami").json() == {
|
|
"authenticated": False,
|
|
"role": "anonymous",
|
|
}
|
|
assert client.get("/api/suggestions").status_code == 401
|
|
assert client.post("/api/chat", json={"message": "hi"}).status_code == 401
|
|
|
|
|
|
def test_revocation_mid_session_enforced_live(
|
|
client: TestClient, db, seeded_kb: FakeRagLLM
|
|
) -> None:
|
|
"""The live check (A4): a token revoked MID-SESSION is refused on
|
|
the holder's next request — chat 401 — and the dead session's keys
|
|
are dropped right there (whoami falls to anonymous without a
|
|
logout)."""
|
|
admin = _admin_client()
|
|
token_id, token = _create_token(admin)
|
|
assert client.post("/api/token-auth", json={"token": token}).status_code == 204
|
|
assert client.get("/api/whoami").json()["role"] == "user"
|
|
|
|
# The turn BEFORE the revocation still streams.
|
|
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: seeded_kb
|
|
try:
|
|
status, _ct, frames = _stream_chat(client, QUESTION)
|
|
finally:
|
|
fastapi_app.dependency_overrides.clear()
|
|
assert status == 200
|
|
assert frames[-1]["type"] == "done"
|
|
|
|
# The admin revokes (idempotent 204 from task 02).
|
|
assert admin.post(f"/api/tokens/{token_id}/revoke").status_code == 204
|
|
|
|
# The NEXT request is refused — 401, not a stream — and the session
|
|
# is dead (the live check popped both user keys).
|
|
r = client.post("/api/chat", json={"message": "hello"})
|
|
assert r.status_code == 401
|
|
assert r.json() == {"detail": "authentication required"}
|
|
assert client.get("/api/whoami").json() == {
|
|
"authenticated": False,
|
|
"role": "anonymous",
|
|
}
|
|
|
|
# …and a FRESH login attempt with the revoked token fails too (the
|
|
# token itself is dead, not just this session).
|
|
r = client.post("/api/token-auth", json={"token": token})
|
|
assert r.status_code == 401
|
|
assert r.json() == {"detail": "invalid token"}
|
|
|
|
|
|
# ---------- phase 79: the enforcement matrix (the 401 contract) ----------
|
|
|
|
|
|
def test_anonymous_chat_now_401s(client: TestClient, db) -> None:
|
|
"""The phase-16 "chat still streams anonymously" pin is SUPERSEDED:
|
|
anonymous chat gets a plain 401 ``authentication required`` (401,
|
|
not 403 — no higher privilege would unblock them)."""
|
|
r = client.post("/api/chat", json={"message": "hello"})
|
|
assert r.status_code == 401
|
|
assert r.json() == {"detail": "authentication required"}
|
|
|
|
|
|
def test_anonymous_suggestions_and_document_content_401(client: TestClient, db) -> None:
|
|
"""The other two gated endpoints share the same 401 contract."""
|
|
r = client.get("/api/suggestions")
|
|
assert r.status_code == 401
|
|
assert r.json() == {"detail": "authentication required"}
|
|
|
|
_seed_one_doc(db)
|
|
r = client.get(
|
|
"/api/documents/content", params={"source": "docs", "path": "homelab/kubernetes.md"}
|
|
)
|
|
assert r.status_code == 401
|
|
assert r.json() == {"detail": "authentication required"}
|
|
|
|
|
|
def test_document_content_admin_contract(client: TestClient, db) -> None:
|
|
"""The phase-16 soft rule (viewer public) is superseded: the viewer
|
|
content is token-or-admin — the admin gets the full DocContent
|
|
shape, unknown pairs still 404 (no enumeration of titles)."""
|
|
client.post("/api/login", json={"password": ADMIN_PASSWORD})
|
|
_seed_one_doc(db)
|
|
|
|
r = client.get(
|
|
"/api/documents/content", params={"source": "docs", "path": "homelab/kubernetes.md"}
|
|
)
|
|
assert r.status_code == 200
|
|
body = r.json()
|
|
assert body["title"] == "Kubernetes Homelab Cluster"
|
|
assert "Talos" in body["content"]
|
|
assert set(body) == {
|
|
"source",
|
|
"path",
|
|
"title",
|
|
"format",
|
|
"summary", # nullable field added in phase 36 (null here — markdown)
|
|
"content",
|
|
"indexed_at",
|
|
"chunks",
|
|
}
|
|
assert body["summary"] is None
|
|
|
|
# Unknown docs still 404 (same shape as the phase-16 pin).
|
|
r = client.get("/api/documents/content", params={"source": "docs", "path": "nope.md"})
|
|
assert r.status_code == 404
|
|
assert r.json() == {"detail": "document not found"}
|
|
|
|
|
|
def test_admin_chat_still_streams(client: TestClient, db, seeded_kb: FakeRagLLM) -> None:
|
|
"""The streaming assertions of the old anonymous pin survive under a
|
|
signed-in (admin) client — sign-in locked out strangers, not the
|
|
owner."""
|
|
assert client.post("/api/login", json={"password": ADMIN_PASSWORD}).status_code == 204
|
|
|
|
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: seeded_kb
|
|
try:
|
|
status, content_type, frames = _stream_chat(client, QUESTION)
|
|
finally:
|
|
fastapi_app.dependency_overrides.clear()
|
|
|
|
assert status == 200
|
|
assert content_type.startswith("text/event-stream")
|
|
deltas = [f for f in frames if f.get("type") == "delta"]
|
|
assert len(deltas) >= 2 # genuinely streamed
|
|
assert frames[-1]["type"] == "done"
|
|
assert frames[-1]["deflected"] is False
|
|
assert frames[-1]["sources"][0]["path"] == "homelab/kubernetes.md"
|
|
|
|
|
|
# ---------- phase 81: rate-limited failed sign-ins (audit SEC-03) ----------
|
|
|
|
|
|
def test_eleventh_failed_login_429_with_retry_after(client: TestClient) -> None:
|
|
"""10 wrong passwords → 401 (the phase-16 contract, unchanged
|
|
detail); the 11th attempt is the FIRST 429 — one generic detail + a
|
|
``Retry-After`` header carrying whole-window seconds."""
|
|
for _ in range(10):
|
|
r = client.post("/api/login", json={"password": "nope"})
|
|
assert r.status_code == 401
|
|
assert r.json() == {"detail": "invalid password"}
|
|
r = client.post("/api/login", json={"password": "nope"})
|
|
assert r.status_code == 429
|
|
assert r.json()["detail"] == TOO_MANY_DETAIL
|
|
retry_after = int(r.headers["Retry-After"])
|
|
assert retry_after > 0
|
|
assert retry_after <= WINDOW_SECONDS # the whole-window wait, never more
|
|
|
|
|
|
def test_correct_password_while_blocked_still_429(client: TestClient) -> None:
|
|
"""An exhausted window stays exhausted until it slides: the pre-check
|
|
fires BEFORE the password is compared, so even a CORRECT password
|
|
gets the 429 while blocked (success only resets a CLEAN counter —
|
|
this is the intended semantics, pinned)."""
|
|
for _ in range(10):
|
|
assert client.post("/api/login", json={"password": "nope"}).status_code == 401
|
|
r = client.post("/api/login", json={"password": ADMIN_PASSWORD})
|
|
assert r.status_code == 429
|
|
assert r.json()["detail"] == TOO_MANY_DETAIL
|
|
assert "bor_session" not in client.cookies # not signed in while throttled
|
|
|
|
|
|
def test_success_on_clean_counter_resets_it(client: TestClient) -> None:
|
|
"""9 failures + 1 success (204) → the counter is back to 0: the next
|
|
9 failures are all plain 401s (a fresh 10 would be needed to block
|
|
again). A fat-fingered owner is not poisoned by earlier misses."""
|
|
for _ in range(9):
|
|
assert client.post("/api/login", json={"password": "nope"}).status_code == 401
|
|
assert client.post("/api/login", json={"password": ADMIN_PASSWORD}).status_code == 204
|
|
for _ in range(9):
|
|
r = client.post("/api/login", json={"password": "nope"})
|
|
assert r.status_code == 401
|
|
assert r.json() == {"detail": "invalid password"}
|
|
|
|
|
|
def test_login_and_token_failures_share_the_counter(client: TestClient) -> None:
|
|
"""The counter is SHARED by both routes (a token-spraying attack must
|
|
not dodge the window by alternating routes): 5 login failures + 5
|
|
token failures exhaust it, so the 11th attempt of EITHER shape — even
|
|
with a VALID token — is a 429."""
|
|
# Admin + token FIRST: the successful admin login resets the shared
|
|
# counter, so it must not happen after failures have accumulated.
|
|
admin = _admin_client()
|
|
_token_id, token = _create_token(admin)
|
|
for _ in range(5):
|
|
assert client.post("/api/login", json={"password": "nope"}).status_code == 401
|
|
for _ in range(5):
|
|
r = client.post("/api/token-auth", json={"token": "bor_" + "0" * 32})
|
|
assert r.status_code == 401
|
|
assert r.json() == {"detail": "invalid token"}
|
|
r = client.post("/api/token-auth", json={"token": token})
|
|
assert r.status_code == 429
|
|
assert r.json()["detail"] == TOO_MANY_DETAIL
|
|
assert int(r.headers["Retry-After"]) > 0
|