Files
YPMS/tests/auth/test_router.py
T
Chris TonjesandClaude Opus 5.5 99a50dc86c Audit failed logins (NIST AU-2, source RFP C.5.7.39)
Only successful logins wrote an audit row. Every unsuccessful attempt now
writes `login_failed` with reason bad_credentials, unknown_user,
inactive, or throttled, plus the client IP. The attempted username is
recorded only when it matches a real account (as the actor); unknown-user
attempts store no username, since people sometimes type a password into
that field.

Found while preparing Phase 0 control evidence (milestone 0.9).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 14:37:40 -04:00

301 lines
9.3 KiB
Python

"""End-to-end auth flow via the FastAPI TestClient.
Uses the shared SQLite in-memory DB by wiring the `get_db` dependency to the
per-test session, then seeding a user and driving login/refresh/me/
change-password.
"""
from __future__ import annotations
from collections.abc import Iterator
import pytest
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
from ypms.auth.rate_limit import login_limiter
from ypms.auth.security import (
create_access_token,
create_refresh_token,
hash_password,
)
from ypms.db import get_db
from ypms.main import create_app
from ypms.security.rbac import ADMIN_ROLE
from ypms.users.models import (
ASSURANCE_ADMIN,
ASSURANCE_STANDARD,
Role,
User,
UserRole,
)
@pytest.fixture(autouse=True)
def _reset_rate_limits() -> Iterator[None]:
login_limiter.reset()
yield
login_limiter.reset()
@pytest.fixture
def staff_user(db: Session) -> User:
user = User(
username="staff.user",
email="staff@dc.gov",
full_name="Staff User",
password_hash=hash_password("correct-horse-battery"),
is_active=True,
assurance_level=ASSURANCE_STANDARD,
)
db.add(user)
db.flush()
db.refresh(user)
return user
@pytest.fixture
def admin_user(db: Session) -> User:
role = Role(code=ADMIN_ROLE, description="super-role")
db.add(role)
db.flush()
user = User(
username="chris",
email="chris@dc.gov",
full_name="Chris Tonjes",
password_hash=hash_password("correct-horse-battery"),
is_active=True,
assurance_level=ASSURANCE_STANDARD, # role bump upgrades to admin
)
db.add(user)
db.flush()
db.add(UserRole(user_id=user.id, role_id=role.id))
db.flush()
db.refresh(user)
return user
@pytest.fixture
def app_client(db: Session) -> Iterator[TestClient]:
"""A TestClient wired to the per-test SQLite session via dep override."""
app = create_app()
def _override_get_db() -> Iterator[Session]:
yield db
app.dependency_overrides[get_db] = _override_get_db
with TestClient(app) as c:
yield c
app.dependency_overrides.clear()
def test_login_wrong_password_returns_401(app_client: TestClient, staff_user: User) -> None:
r = app_client.post(
"/auth/login",
data={"username": staff_user.username, "password": "wrong"},
)
assert r.status_code == 401
assert r.json()["detail"] == "invalid credentials"
def test_login_success_returns_tokens(app_client: TestClient, staff_user: User) -> None:
r = app_client.post(
"/auth/login",
data={"username": staff_user.username, "password": "correct-horse-battery"},
)
assert r.status_code == 200
body = r.json()
assert body["token_type"] == "bearer"
assert body["access_token"]
assert body["refresh_token"]
def test_admin_login_upgrades_assurance_level(
app_client: TestClient, admin_user: User
) -> None:
from ypms.auth.security import decode_token
r = app_client.post(
"/auth/login",
data={"username": admin_user.username, "password": "correct-horse-battery"},
)
assert r.status_code == 200
claims = decode_token(r.json()["access_token"])
assert claims["iam.assurance_level"] == ASSURANCE_ADMIN
assert ADMIN_ROLE in claims["roles"]
def test_me_requires_authentication(app_client: TestClient) -> None:
r = app_client.get("/auth/me")
assert r.status_code == 401
def test_me_returns_current_user(app_client: TestClient, staff_user: User) -> None:
login = app_client.post(
"/auth/login",
data={"username": staff_user.username, "password": "correct-horse-battery"},
)
access = login.json()["access_token"]
r = app_client.get("/auth/me", headers={"Authorization": f"Bearer {access}"})
assert r.status_code == 200
assert r.json()["username"] == staff_user.username
assert r.json()["email"] == "staff@dc.gov"
def test_refresh_returns_new_access_token(app_client: TestClient, staff_user: User) -> None:
login = app_client.post(
"/auth/login",
data={"username": staff_user.username, "password": "correct-horse-battery"},
)
refresh = login.json()["refresh_token"]
r = app_client.post("/auth/refresh", json={"refresh_token": refresh})
assert r.status_code == 200
assert r.json()["access_token"]
assert "refresh_token" not in r.json()
def test_refresh_rejects_access_token(app_client: TestClient, staff_user: User) -> None:
access = create_access_token(
sub=staff_user.username,
uid=staff_user.id,
roles=[],
assurance_level=ASSURANCE_STANDARD,
mfa_verified=False,
)
r = app_client.post("/auth/refresh", json={"refresh_token": access})
assert r.status_code == 401
def test_me_rejects_refresh_token(app_client: TestClient, staff_user: User) -> None:
refresh = create_refresh_token(sub=staff_user.username, uid=staff_user.id)
r = app_client.get("/auth/me", headers={"Authorization": f"Bearer {refresh}"})
assert r.status_code == 401
def test_change_password_flow(app_client: TestClient, staff_user: User) -> None:
login = app_client.post(
"/auth/login",
data={"username": staff_user.username, "password": "correct-horse-battery"},
)
access = login.json()["access_token"]
r = app_client.post(
"/auth/change-password",
headers={"Authorization": f"Bearer {access}"},
json={
"current_password": "correct-horse-battery",
"new_password": "totally-different-passphrase",
},
)
assert r.status_code == 204
# Old password no longer works.
r2 = app_client.post(
"/auth/login",
data={"username": staff_user.username, "password": "correct-horse-battery"},
)
assert r2.status_code == 401
# New password does.
r3 = app_client.post(
"/auth/login",
data={"username": staff_user.username, "password": "totally-different-passphrase"},
)
assert r3.status_code == 200
def test_change_password_rejects_short_new_password(
app_client: TestClient, staff_user: User
) -> None:
login = app_client.post(
"/auth/login",
data={"username": staff_user.username, "password": "correct-horse-battery"},
)
access = login.json()["access_token"]
r = app_client.post(
"/auth/change-password",
headers={"Authorization": f"Bearer {access}"},
json={"current_password": "correct-horse-battery", "new_password": "short"},
)
assert r.status_code == 400
def test_change_password_rejects_wrong_current(
app_client: TestClient, staff_user: User
) -> None:
login = app_client.post(
"/auth/login",
data={"username": staff_user.username, "password": "correct-horse-battery"},
)
access = login.json()["access_token"]
r = app_client.post(
"/auth/change-password",
headers={"Authorization": f"Bearer {access}"},
json={"current_password": "wrong", "new_password": "long-enough-password"},
)
assert r.status_code == 401
def test_login_lockout_after_five_failed_attempts(
app_client: TestClient, staff_user: User
) -> None:
for _ in range(5):
r = app_client.post(
"/auth/login",
data={"username": staff_user.username, "password": "wrong"},
)
assert r.status_code == 401
# 6th attempt (any credentials) is rate-limited.
r = app_client.post(
"/auth/login",
data={"username": staff_user.username, "password": "correct-horse-battery"},
)
assert r.status_code == 429
assert r.headers["Retry-After"] == "900"
# ---------------- failed-login audit (NIST AU-2, C.5.7.39) ----------------
def _failures(db: Session) -> list[tuple[str | None, int | None, dict[str, str] | None]]:
from sqlalchemy import select
from ypms.audit.models import AuditLog
rows = db.execute(select(AuditLog).where(AuditLog.action == "login_failed")).scalars()
return [(r.actor_username, r.entity_id, r.after) for r in rows]
def test_bad_password_is_audited_against_the_account(
app_client: TestClient, db: Session, staff_user: User
) -> None:
app_client.post("/auth/login", data={"username": staff_user.username, "password": "nope"})
assert _failures(db) == [(staff_user.username, staff_user.id, {"reason": "bad_credentials"})]
def test_unknown_user_is_audited_without_the_typed_username(
app_client: TestClient, db: Session
) -> None:
app_client.post("/auth/login", data={"username": "my-actual-password!", "password": "x"})
assert _failures(db) == [(None, None, {"reason": "unknown_user"})]
def test_inactive_user_login_is_audited(
app_client: TestClient, db: Session, staff_user: User
) -> None:
staff_user.is_active = False
db.flush()
r = app_client.post(
"/auth/login",
data={"username": staff_user.username, "password": "correct-horse-battery"},
)
assert r.status_code == 401
assert _failures(db) == [(staff_user.username, staff_user.id, {"reason": "inactive"})]
def test_throttled_login_is_audited(app_client: TestClient, db: Session) -> None:
for _ in range(6):
r = app_client.post("/auth/login", data={"username": "ghost", "password": "x"})
assert r.status_code == 429
reasons = [after["reason"] for _, _, after in _failures(db) if after]
assert reasons == ["unknown_user"] * 5 + ["throttled"]