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>
72 lines
2.4 KiB
Python
72 lines
2.4 KiB
Python
"""Shared pytest fixtures.
|
|
|
|
Uses SQLite for speed (see ARCHITECTURE.md §4.8). Tests exercise the framework
|
|
against an in-memory Postgres-shaped SQLAlchemy Base — no Alembic migrations
|
|
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
|
|
|
|
import os
|
|
from collections.abc import Iterator
|
|
|
|
os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:")
|
|
os.environ.setdefault("JWT_SECRET", "test-secret")
|
|
os.environ.setdefault(
|
|
"COLUMN_ENCRYPTION_KEY", "test-col-enc-key-32bytes-______________"
|
|
)
|
|
os.environ.setdefault("ENV", "test")
|
|
|
|
import pytest # noqa: E402
|
|
from fastapi.testclient import TestClient # noqa: E402
|
|
from sqlalchemy import create_engine # noqa: E402
|
|
from sqlalchemy.orm import Session, sessionmaker # noqa: E402
|
|
from sqlalchemy.pool import StaticPool # noqa: E402
|
|
|
|
|
|
@pytest.fixture
|
|
def db() -> Iterator[Session]:
|
|
"""Fresh in-memory SQLite DB per test with all model tables created.
|
|
|
|
Import happens inside the fixture so the model modules register themselves
|
|
on Base.metadata before create_all runs.
|
|
"""
|
|
import ypms.audit.models # noqa: F401
|
|
import ypms.framework.version_history # noqa: F401
|
|
import ypms.security.models # noqa: F401
|
|
import ypms.users.models # noqa: F401
|
|
from ypms.db import Base
|
|
|
|
# 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)
|
|
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
|
|
session = SessionLocal()
|
|
try:
|
|
yield session
|
|
finally:
|
|
session.close()
|
|
engine.dispose()
|
|
|
|
|
|
@pytest.fixture
|
|
def client() -> Iterator[TestClient]:
|
|
from ypms.main import create_app
|
|
|
|
app = create_app()
|
|
with TestClient(app) as c:
|
|
yield c
|