Phase 0 milestone 0.3: auth + JWT + iam.assurance_level claim

Lands local password auth with JWT access + refresh tokens, and starts
carrying YPMS-specific claims (`uid`, `roles`, `iam.assurance_level`,
`mfa_verified`) so downstream authorization can gate high-risk actions
above base RBAC without a DB round-trip. Follows the OWC auth pattern
verbatim except where noted.

Added:

- src/ypms/auth/security.py    hash_password / verify_password
                               (pbkdf2_sha256), create_access_token
                               with YPMS-specific claims,
                               create_refresh_token, decode_token
- src/ypms/auth/rate_limit.py  SlidingWindow (vendored from OWC),
                               login_limiter tuned to NIST AC-7 /
                               source RFP C.5.7.10: 5 failed attempts
                               per IP per 15 minutes → 429 lockout
- src/ypms/auth/schemas.py     LoginResponse, RefreshRequest,
                               RefreshResponse, ChangePasswordRequest,
                               UserRead
- src/ypms/auth/deps.py        get_current_user replaces the milestone
                               0.2 stub; rejects refresh tokens on
                               resource endpoints; refuses inactive users
- src/ypms/auth/router.py      POST /auth/login  (OAuth2 form; audit-logs
                               successful auth; DOES_SYSTEM_ADMIN role
                               upgrades assurance_level to `admin` in
                               the token)
                               POST /auth/refresh (issues fresh access;
                               refuses non-refresh tokens)
                               GET  /auth/me
                               POST /auth/change-password (self-service;
                               ≥12 char floor; audit-logged)
- src/ypms/main.py             Registers auth_router

Tests (43 passing, 93.47% coverage — up from 26 / 93.10%):

- tests/auth/test_security.py  Token claims shape, refresh-vs-access
                               separation, invalid-token handling
                               (5 tests)
- tests/auth/test_router.py    End-to-end via TestClient with a
                               dependency-overridden DB: login happy
                               path + wrong password + inactive user,
                               admin assurance upgrade, /me auth-gate,
                               refresh happy path + reject access token,
                               change-password full flow + length gate,
                               login lockout after 5 attempts (12 tests)

Test-infra fixes:

- tests/conftest.py            Env defaults moved from a session-scoped
                               fixture to module-import time so
                               `ypms.main` (which builds the app at
                               import) can be transitively imported by
                               test modules without ValidationError on
                               Settings.
                               `db` fixture switched to StaticPool +
                               check_same_thread=False so TestClient's
                               worker thread sees the same in-memory
                               SQLite DB the fixture set up.

Notes:

- OWC includes an /auth/register for external users; that's out of scope
  here — Phase 1 lands parental co-consent and youth/host registration,
  which needs its own PIA-driven flow. Keeping /auth minimal in Phase 0.
- MFA is captured in `User.mfa_verified` and JWT claim but not enforced
  yet; enforcement lands as an assurance-level gate in a later phase.
- Refresh flow returns only a new access token; refresh-token rotation
  is a later hardening step.

Advances YPMS-POAM-040 and unblocks milestone 0.4 (RBAC seed) and 0.5
(Registrant vertical slice).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris TonjesandClaude Opus 4.7 committed 2026-09-30 18:30:39 -04:00
1 parent 7379bb4cf1
commit 0501850a9c
10 files changed
+688 -32

No files matched your search

+33 -16
View File
@@ -1,28 +1,45 @@
"""Authentication dependencies. """Authentication dependencies.
Phase 0 milestone 0.2: placeholder `get_current_user` that always raises 401. Milestone 0.3: real `get_current_user` — validates the JWT, refuses refresh
Milestone 0.3 replaces this with a JWT-verifying implementation that also tokens on resource endpoints, loads the User and refuses if inactive.
issues the `iam.assurance_level` claim on the token.
Kept as its own module so the RBAC engine and router factory can import it now Downstream authorization (RBAC and assurance-level gating) reads user.roles
and milestone 0.3's swap is a single-file change. and user.assurance_level, so the returned User carries live values from the
DB rather than the token snapshot — a role revocation or account
deactivation takes effect on the next request, not on token expiry.
""" """
from __future__ import annotations from __future__ import annotations
from fastapi import HTTPException, status from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.orm import Session
from ypms.auth.security import decode_token
from ypms.db import get_db
from ypms.users.models import User from ypms.users.models import User
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login", auto_error=False)
def get_current_user() -> User:
"""Placeholder. Milestone 0.3 lands real JWT verification.
Raises 401 for any route that goes through it. Tests that need to exercise def get_current_user(
RBAC or routes without a real auth stack override this dependency with a token: str | None = Depends(oauth2_scheme),
fixture-provided User via `app.dependency_overrides`. db: Session = Depends(get_db),
""" ) -> User:
raise HTTPException( if not token:
status.HTTP_401_UNAUTHORIZED, raise HTTPException(status.HTTP_401_UNAUTHORIZED, "not authenticated")
detail={"error": "unauthenticated", "phase": "milestone 0.3 lands JWT auth"}, try:
) payload = decode_token(token)
except ValueError as e:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, str(e)) from e
if payload.get("type") == "refresh":
raise HTTPException(
status.HTTP_401_UNAUTHORIZED, "refresh token cannot access resources"
)
username = payload.get("sub")
if not username:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "invalid token")
user = db.query(User).filter(User.username == username).one_or_none()
if not user or not user.is_active:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "user not found or inactive")
return user
+47
View File
@@ -0,0 +1,47 @@
"""Tiny in-memory rate limiter.
Vendored from OWC `auth/rate_limit.py` (snapshot 2026-04-20); unchanged.
Not suitable for multi-process prod. Swap to Redis-backed once we're
horizontally scaled (Phase 9+ / Azure deploy). Used today to cap login
attempts per IP (NIST AC-7 / source RFP C.5.7.10).
"""
from __future__ import annotations
import time
from collections import deque
from threading import Lock
class SlidingWindow:
def __init__(self, limit: int, window_seconds: int):
self.limit = limit
self.window = window_seconds
self._hits: dict[str, deque[float]] = {}
self._lock = Lock()
def allow(self, key: str) -> bool:
now = time.time()
with self._lock:
q = self._hits.setdefault(key, deque())
# Evict entries outside the window.
while q and now - q[0] > self.window:
q.popleft()
if len(q) >= self.limit:
return False
q.append(now)
return True
def reset(self, key: str | None = None) -> None:
"""Test helper: clear a single key or the whole window."""
with self._lock:
if key is None:
self._hits.clear()
else:
self._hits.pop(key, None)
# NIST AC-7 / source RFP `C.5.7.10`: 5 failed login attempts triggers lockout.
# We track by IP and use a longer window than OWC to keep the block honest.
login_limiter = SlidingWindow(limit=5, window_seconds=900) # 15 minutes
+145
View File
@@ -0,0 +1,145 @@
"""Auth surface: /auth/login, /auth/refresh, /auth/me, /auth/change-password.
Source RFP requirements addressed here:
- C.5.7.1 role-based authN/authZ (roles baked into JWT claim)
- C.5.7.3 session timeout — enforced by access token TTL
- C.5.7.7 min 12-alphanumeric identifier — pydantic constraint on register
lands in Phase 1; here we only accept credentials, not create them
- C.5.7.8 user-established and changeable passwords → /change-password
- C.5.7.9 FIPS 140-2/3 crypto — pbkdf2_sha256 + Key Vault (0.3 uses env-var
key; Key Vault swap-in is Phase 1)
- C.5.7.10 5 failed attempts → lockout — enforced by login_limiter
- C.5.7.38 unique user ID per user — enforced by User.username uniqueness
NIST controls: IA-2, IA-5, IA-5(1), IA-8 (organizational side), AC-7, AC-11.
"""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.orm import Session
from ypms.audit import service as audit
from ypms.auth.deps import get_current_user
from ypms.auth.rate_limit import login_limiter
from ypms.auth.schemas import (
ChangePasswordRequest,
LoginResponse,
RefreshRequest,
RefreshResponse,
UserRead,
)
from ypms.auth.security import (
create_access_token,
create_refresh_token,
decode_token,
hash_password,
verify_password,
)
from ypms.db import get_db
from ypms.security.rbac import ADMIN_ROLE
from ypms.users.models import (
ASSURANCE_ADMIN,
User,
)
router = APIRouter(prefix="/auth", tags=["auth"])
def _issue_access_token(user: User) -> str:
role_codes = [r.code for r in user.roles]
# DOES_SYSTEM_ADMIN implies admin assurance regardless of the persisted
# value — the super-role bypass already grants blanket authority and the
# token should reflect that so downstream assurance-level gates behave.
level = ASSURANCE_ADMIN if ADMIN_ROLE in role_codes else user.assurance_level
return create_access_token(
sub=user.username,
uid=user.id,
roles=role_codes,
assurance_level=level,
mfa_verified=user.mfa_verified,
)
@router.post("/login", response_model=LoginResponse)
def login(
request: Request,
form: OAuth2PasswordRequestForm = Depends(),
db: Session = Depends(get_db),
) -> LoginResponse:
ip = request.client.host if request.client else "unknown"
if not login_limiter.allow(ip):
# NIST AC-7: cap unsuccessful logon attempts.
raise HTTPException(
status.HTTP_429_TOO_MANY_REQUESTS,
"too many login attempts; try again shortly",
headers={"Retry-After": "900"},
)
user = db.query(User).filter(User.username == form.username).one_or_none()
if not user or not verify_password(form.password, user.password_hash):
# Deliberately identical error surface so we don't leak whether the
# username exists (IA-6 authenticator feedback discipline).
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "invalid credentials")
if not user.is_active:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "user inactive")
audit.record(
db, actor=user, action="login", entity="User", entity_id=user.id, ip=ip
)
db.commit()
return LoginResponse(
access_token=_issue_access_token(user),
refresh_token=create_refresh_token(sub=user.username, uid=user.id),
)
@router.post("/refresh", response_model=RefreshResponse)
def refresh(
body: RefreshRequest,
db: Session = Depends(get_db),
) -> RefreshResponse:
try:
payload = decode_token(body.refresh_token)
except ValueError as e:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, str(e)) from e
if payload.get("type") != "refresh":
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "not a refresh token")
username = payload.get("sub")
user = db.query(User).filter(User.username == username).one_or_none()
if not user or not user.is_active:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "user not found or inactive")
return RefreshResponse(access_token=_issue_access_token(user))
@router.get("/me", response_model=UserRead)
def me(current: User = Depends(get_current_user)) -> User:
return current
@router.post("/change-password", status_code=status.HTTP_204_NO_CONTENT)
def change_password(
body: ChangePasswordRequest,
request: Request,
db: Session = Depends(get_db),
current: User = Depends(get_current_user),
) -> None:
if not verify_password(body.current_password, current.password_hash):
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "current password incorrect")
if len(body.new_password) < 12:
# Source RFP C.5.7.45 leaves the exact policy to the org. 12 chars is
# the floor from C.5.7.7 (min identifier length) — treat the same for
# secrets. Complexity + rotation policy lands with the admin UI.
raise HTTPException(status.HTTP_400_BAD_REQUEST, "new password must be at least 12 characters")
current.password_hash = hash_password(body.new_password)
db.flush()
ip = request.client.host if request.client else "unknown"
audit.record(
db, actor=current, action="change_password",
entity="User", entity_id=current.id, ip=ip,
)
db.commit()
return None
+40
View File
@@ -0,0 +1,40 @@
"""Pydantic schemas for the auth surface."""
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, ConfigDict
class LoginResponse(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
class RefreshRequest(BaseModel):
refresh_token: str
class RefreshResponse(BaseModel):
access_token: str
token_type: str = "bearer"
class ChangePasswordRequest(BaseModel):
current_password: str
new_password: str
class UserRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
username: str
email: str | None = None
full_name: str | None = None
is_active: bool
mfa_verified: bool
assurance_level: str
created_at: datetime
+82
View File
@@ -0,0 +1,82 @@
"""Password hashing + JWT create/verify.
Vendored from OWC `auth/security.py` (snapshot 2026-04-20). YPMS additions:
`create_access_token()` bakes in YPMS-specific claims — `uid`, `roles`,
`iam.assurance_level`, `mfa_verified` — so downstream authorization can gate
high-risk actions above base RBAC (see ARCHITECTURE.md §4.5, §8.2).
`iam.assurance_level` values are defined in `ypms.users.models` and follow
NIST 800-63 IAL/AAL semantics: `pre_application` < `standard` <
`payroll_authorized` < `staff` < `admin`. Payroll-touching endpoints will
require `payroll_authorized` or higher (Phase 7+).
pbkdf2_sha256 not bcrypt — see ARCHITECTURE.md §4.6.
"""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from typing import Any
from jose import JWTError, jwt
from passlib.context import CryptContext
from ypms.settings import get_settings
_pwd = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
def hash_password(pw: str) -> str:
h: str = _pwd.hash(pw)
return h
def verify_password(pw: str, hashed: str) -> bool:
return bool(_pwd.verify(pw, hashed))
def create_access_token(
*,
sub: str,
uid: int,
roles: list[str],
assurance_level: str,
mfa_verified: bool,
) -> str:
s = get_settings()
now = datetime.now(UTC)
payload: dict[str, Any] = {
"sub": sub,
"uid": uid,
"roles": roles,
"iam.assurance_level": assurance_level,
"mfa_verified": mfa_verified,
"iat": int(now.timestamp()),
"exp": int((now + timedelta(minutes=s.access_token_ttl_minutes)).timestamp()),
"type": "access",
}
token: str = jwt.encode(payload, s.jwt_secret, algorithm=s.jwt_alg)
return token
def create_refresh_token(*, sub: str, uid: int) -> str:
s = get_settings()
now = datetime.now(UTC)
payload: dict[str, Any] = {
"sub": sub,
"uid": uid,
"iat": int(now.timestamp()),
"exp": int((now + timedelta(days=s.refresh_token_ttl_days)).timestamp()),
"type": "refresh",
}
token: str = jwt.encode(payload, s.jwt_secret, algorithm=s.jwt_alg)
return token
def decode_token(token: str) -> dict[str, Any]:
s = get_settings()
try:
payload: dict[str, Any] = jwt.decode(token, s.jwt_secret, algorithms=[s.jwt_alg])
except JWTError as e:
raise ValueError("invalid token") from e
return payload
+3
View File
@@ -12,6 +12,7 @@ from fastapi import FastAPI, status
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy import text from sqlalchemy import text
from ypms.auth.router import router as auth_router
from ypms.db import engine from ypms.db import engine
from ypms.logging import configure_logging, get_logger from ypms.logging import configure_logging, get_logger
from ypms.middleware import RequestIdMiddleware from ypms.middleware import RequestIdMiddleware
@@ -40,6 +41,8 @@ def create_app() -> FastAPI:
expose_headers=["X-Request-ID"], expose_headers=["X-Request-ID"],
) )
app.include_router(auth_router)
@app.get("/healthz", tags=["system"]) @app.get("/healthz", tags=["system"])
def healthz() -> dict[str, str]: def healthz() -> dict[str, str]:
"""Liveness — process is up. No dependency checks.""" """Liveness — process is up. No dependency checks."""
View File
Whitespace-only changes.
+253
View File
@@ -0,0 +1,253 @@
"""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"
+59
View File
@@ -0,0 +1,59 @@
"""Password + JWT primitives."""
from __future__ import annotations
import time
import pytest
from ypms.auth.security import (
create_access_token,
create_refresh_token,
decode_token,
hash_password,
verify_password,
)
def test_hash_password_returns_pbkdf2_prefix() -> None:
h = hash_password("hunter2hunter2")
assert h.startswith("$pbkdf2-sha256$")
def test_verify_password_true_on_match_false_on_mismatch() -> None:
h = hash_password("correcthorsebattery")
assert verify_password("correcthorsebattery", h) is True
assert verify_password("wrong", h) is False
def test_access_token_carries_ypms_claims() -> None:
tok = create_access_token(
sub="chris",
uid=1,
roles=["DOES_SYSTEM_ADMIN"],
assurance_level="admin",
mfa_verified=False,
)
claims = decode_token(tok)
assert claims["sub"] == "chris"
assert claims["uid"] == 1
assert claims["roles"] == ["DOES_SYSTEM_ADMIN"]
assert claims["iam.assurance_level"] == "admin"
assert claims["mfa_verified"] is False
assert claims["type"] == "access"
assert claims["exp"] > claims["iat"] >= int(time.time()) - 5
def test_refresh_token_has_type_refresh_and_carries_uid() -> None:
tok = create_refresh_token(sub="chris", uid=42)
claims = decode_token(tok)
assert claims["type"] == "refresh"
assert claims["sub"] == "chris"
assert claims["uid"] == 42
assert "roles" not in claims
assert "iam.assurance_level" not in claims
def test_decode_invalid_token_raises() -> None:
with pytest.raises(ValueError):
decode_token("not-a-real-jwt")
+26 -16
View File
@@ -3,6 +3,10 @@
Uses SQLite for speed (see ARCHITECTURE.md §4.8). Tests exercise the framework Uses SQLite for speed (see ARCHITECTURE.md §4.8). Tests exercise the framework
against an in-memory Postgres-shaped SQLAlchemy Base — no Alembic migrations against an in-memory Postgres-shaped SQLAlchemy Base — no Alembic migrations
in the test path; models create their tables from metadata. in the test path; models create their tables from metadata.
Environment defaults are set at *module import* time (not via fixture) so
they are in place before pytest collects any test module that transitively
imports `ypms.main` (which instantiates the FastAPI app at import time).
""" """
from __future__ import annotations from __future__ import annotations
@@ -10,22 +14,18 @@ from __future__ import annotations
import os import os
from collections.abc import Iterator from collections.abc import Iterator
import pytest os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:")
from fastapi.testclient import TestClient os.environ.setdefault("JWT_SECRET", "test-secret")
from sqlalchemy import create_engine os.environ.setdefault(
from sqlalchemy.orm import Session, sessionmaker "COLUMN_ENCRYPTION_KEY", "test-col-enc-key-32bytes-______________"
)
os.environ.setdefault("ENV", "test")
import pytest # noqa: E402
@pytest.fixture(scope="session", autouse=True) from fastapi.testclient import TestClient # noqa: E402
def _test_env() -> Iterator[None]: from sqlalchemy import create_engine # noqa: E402
"""Set minimum env vars required for `Settings()` to construct in tests.""" from sqlalchemy.orm import Session, sessionmaker # noqa: E402
os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:") from sqlalchemy.pool import StaticPool # noqa: E402
os.environ.setdefault("JWT_SECRET", "test-secret")
os.environ.setdefault(
"COLUMN_ENCRYPTION_KEY", "test-col-enc-key-32bytes-______________"
)
os.environ.setdefault("ENV", "test")
yield
@pytest.fixture @pytest.fixture
@@ -41,7 +41,17 @@ def db() -> Iterator[Session]:
import ypms.users.models # noqa: F401 import ypms.users.models # noqa: F401
from ypms.db import Base from ypms.db import Base
engine = create_engine("sqlite:///:memory:", future=True) # StaticPool + check_same_thread=False share the same in-memory DB across
# threads. TestClient runs handlers in a worker thread, so without this
# the fixture-scoped session and the request-scoped session see different
# empty databases (SADeprecationWarning: SQLite objects created in a
# thread can only be used in that same thread).
engine = create_engine(
"sqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
future=True,
)
Base.metadata.create_all(engine) Base.metadata.create_all(engine)
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True) SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
session = SessionLocal() session = SessionLocal()