chore: merge executor worktree (worktree-agent-a2f182fa26b9780c8) — Wave 0 plan 07.2-01

This commit is contained in:
curo1305
2026-06-05 19:11:20 +02:00
5 changed files with 422 additions and 3 deletions
@@ -0,0 +1,152 @@
---
phase: 07.2-security-jti-claim-redis-access-token-revocation-inserted
plan: "01"
subsystem: testing
tags:
- security
- jwt
- testing
- nyquist
- wave-0
dependency_graph:
requires: []
provides:
- "FakeRedis on test_auth_deps app.state.redis (Pitfall 4 guard)"
- "FakeRedis on admin_client fixture app.state.redis (Pitfall 4 guard)"
- "Wave 0 xfail stub: jti claim in create_access_token"
- "Wave 0 xfail stubs: NBF check accept/reject/fail-open in get_current_user"
- "Wave 0 xfail stubs: user_nbf write on change_password/enable_totp/disable_totp/admin-deactivation"
affects:
- "backend/tests/test_auth_deps.py"
- "backend/tests/test_task2_auth_service.py"
- "backend/tests/test_auth_api.py"
- "backend/tests/test_admin_api.py"
tech_stack:
added: []
patterns:
- "Wave 0 xfail(strict=False) scaffolding — stubs flip to xpass when Wave 1/2 implementation lands"
- "FakeRedis imported from tests.test_auth_api (single canonical definition, never redefined)"
- "app.state.redis = FakeRedis() set in fixture before yield, reset to None in teardown"
key_files:
created: []
modified:
- backend/tests/test_auth_deps.py
- backend/tests/test_task2_auth_service.py
- backend/tests/test_auth_api.py
- backend/tests/test_admin_api.py
decisions:
- "Import FakeRedis from tests.test_auth_api — no redefinition in any file"
- "FakeRedis instantiated inside make_test_app() so each test invocation gets fresh state"
- "admin_client teardown resets app.state.redis = None to mirror authed_client convention"
- "Wave 0 stubs use pytest.xfail() call inside body (not just marker) for the three NBF-write tests, consistent with minimal-body convention"
- "test_activate_user_does_not_write_user_nbf added as negative-guard (mirrors RESEARCH.md anti-pattern)"
metrics:
duration: "450s (7m)"
completed_date: "2026-06-05T17:08:58Z"
tasks_completed: 4
tasks_total: 4
files_changed: 4
---
# Phase 07.2 Plan 01: Wave 0 Test Scaffolding Summary
**One-liner:** FakeRedis mounted on two test fixtures + 9 xfail stubs covering every Phase 7.2 behavior (jti, NBF check × 3, NBF write × 4, negative-guard × 1).
## What Was Built
This plan is the Nyquist Wave 0 scaffolding for Phase 7.2. It creates no implementation code — only test stubs that define the expected behaviors before any code in `services/auth.py`, `deps/auth.py`, `api/auth.py`, or `api/admin.py` is modified.
### Task 1: FakeRedis on test_auth_deps + NBF-check stubs
- `test_auth_deps.py`: added `from tests.test_auth_api import FakeRedis` import
- `make_test_app()`: now sets `test_app.state.redis = FakeRedis()` before returning
- Added 3 xfail stubs with `@pytest.mark.xfail(strict=False)`:
- `test_get_current_user_rejects_token_when_iat_before_user_nbf`
- `test_get_current_user_allows_token_when_iat_after_user_nbf`
- `test_get_current_user_failopen_on_redis_error` (uses inline `_BrokenRedis` class)
### Task 2: JTI presence stub in test_task2_auth_service.py
- Added `test_create_access_token_includes_jti_claim` decorated with `xfail(strict=False)`
- Calls `create_access_token` + `decode_access_token`, asserts `"jti"` key present and parseable as `uuid.UUID`
### Task 3: NBF-write stubs for change_password/enable_totp/disable_totp
- Added 3 xfail stubs to `test_auth_api.py`:
- `test_change_password_writes_user_nbf_to_redis`
- `test_enable_totp_writes_user_nbf_to_redis`
- `test_disable_totp_writes_user_nbf_to_redis`
- Each test exercises the full auth flow then calls `pytest.xfail()` as the Wave 0 body
### Task 4: FakeRedis on admin_client + deactivation NBF-write stubs
- `test_admin_api.py`: added `from tests.test_auth_api import FakeRedis`
- `admin_client` fixture: sets `app.state.redis = FakeRedis()` before client yield, resets to `None` after
- Added 2 xfail stubs:
- `test_deactivate_user_writes_user_nbf_to_redis` (positive case)
- `test_activate_user_does_not_write_user_nbf` (negative-guard — activation must NOT write user_nbf)
## Verification
Baseline container test run: **73 passed** (across the 4 modified test files) — zero new failures.
Full project baseline: **373 passed** (Phase 7.1 complete state) — confirmed unaffected.
Acceptance criteria checks:
- `grep -c "FakeRedis" test_auth_deps.py` → 6 (import + assignment + stubs)
- `grep -c "test_app.state.redis" test_auth_deps.py` → 1
- 3 new NBF-check test functions in test_auth_deps.py
- 3 xfail markers in test_auth_deps.py
- `test_create_access_token_includes_jti_claim` in test_task2_auth_service.py
- 3 NBF-write test functions in test_auth_api.py
- `from tests.test_auth_api import FakeRedis` in test_admin_api.py
- `app.state.redis = FakeRedis()` in admin_client fixture
- 2 deactivation NBF stubs in test_admin_api.py
## Commits
| Task | Commit | Description |
|------|--------|-------------|
| 1 | 49c6333 | test(07.2-01): mount FakeRedis on test_auth_deps app + add NBF-check xfail stubs |
| 2 | c686d90 | test(07.2-01): add JTI claim xfail stub to test_task2_auth_service.py |
| 3 | 097cdca | test(07.2-01): add NBF-write xfail stubs for change_password/enable_totp/disable_totp |
| 4 | eb16472 | test(07.2-01): add FakeRedis to admin_client + NBF-write xfail stubs for deactivation |
## Deviations from Plan
None — plan executed exactly as written.
## Known Stubs
All 9 new test functions are intentional Wave 0 stubs. They are xfailed with `strict=False` and carry `assert False, "stub"` or `pytest.xfail()` in their bodies. Wave 1 (Plan 02) and Wave 2 (Plan 03) will promote them to passing assertions by replacing the stub bodies with real assertion logic. These are tracked in the plan and are the intended output of this wave.
## Patterns Established
- **FakeRedis import pattern**: always `from tests.test_auth_api import FakeRedis` — never redefine in another file
- **FakeRedis on test_auth_deps**: `test_app.state.redis = FakeRedis()` inside `make_test_app()` — fresh instance per test invocation
- **FakeRedis teardown**: `app.state.redis = None` after fixture yield — prevents state leak across test files
- **Wave 0 body convention**: `assert False, "stub — Wave N will fill in: <target assertion>"` or `pytest.xfail("stub")` — no real assertion logic
## Patterns to Avoid
- Do NOT redefine `FakeRedis` in `test_auth_deps.py`, `test_admin_api.py`, or any other file — import from `tests.test_auth_api` only
- Do NOT add `app.state.redis = None` reset inside the fixture loop — only in teardown after yield
## Threat Flags
None — this plan only modifies test files and introduces no new network endpoints, auth paths, or schema changes.
## Self-Check: PASSED
- [x] `backend/tests/test_auth_deps.py` modified and committed (49c6333)
- [x] `backend/tests/test_task2_auth_service.py` modified and committed (c686d90)
- [x] `backend/tests/test_auth_api.py` modified and committed (097cdca)
- [x] `backend/tests/test_admin_api.py` modified and committed (eb16472)
- [x] All 4 commits exist: `git log --oneline -4` confirms eb16472, 097cdca, c686d90, 49c6333
- [x] 73 baseline tests pass in container (zero regressions)
- [x] All 9 Wave 0 stubs present and correctly decorated with xfail(strict=False)
+72 -1
View File
@@ -26,6 +26,7 @@ from sqlalchemy import select
from deps.auth import get_current_admin
from deps.db import get_db
from services.auth import hash_password
from tests.test_auth_api import FakeRedis
# ── Helpers ───────────────────────────────────────────────────────────────────
@@ -70,17 +71,26 @@ async def make_regular_user(session: AsyncSession) -> User:
@pytest_asyncio.fixture
async def admin_client(db_session: AsyncSession):
"""Async client with get_current_admin overridden to an admin user."""
"""Async client with get_current_admin overridden to an admin user.
Phase 7.2: app.state.redis is set to a FakeRedis() instance so that
the admin deactivation handler (post-Wave 2) can call
request.app.state.redis.set(...) without AttributeError.
"""
from main import app
admin = await make_admin_user(db_session)
app.dependency_overrides[get_db] = lambda: db_session
app.dependency_overrides[get_current_admin] = lambda: admin
# Phase 7.2: mount FakeRedis for deactivation handler NBF write
app.state.redis = FakeRedis()
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c, admin, db_session
app.dependency_overrides.clear()
# Reset redis to avoid leaking state between test files (mirrors authed_client teardown)
app.state.redis = None
# ── Tests ─────────────────────────────────────────────────────────────────────
@@ -429,3 +439,64 @@ async def test_delete_user_no_body(admin_client):
resp = await client.delete(f"/api/admin/users/{target.id}")
assert resp.status_code == 422
# ── Phase 7.2 Wave 0 stubs: NBF-write on admin deactivation ──────────────────
# These two tests are xfailed with strict=False. When Wave 2 (Plan 03) adds
# user_nbf write to the admin deactivation handler, these stubs are promoted
# by replacing `assert False, "stub"` with real Redis key assertions.
#
# Invariant preserved in Wave 2: activation (is_active=True) must NOT write
# user_nbf — only deactivation writes it (RESEARCH.md anti-pattern).
@pytest.mark.xfail(strict=False, reason="Phase 7.2 Wave 2 — user_nbf write not yet added to admin deactivation handler")
@pytest.mark.asyncio
async def test_deactivate_user_writes_user_nbf_to_redis(admin_client):
"""PATCH /api/admin/users/{id}/status {is_active: false} must write user_nbf:{id} to Redis."""
client, _admin, session = admin_client
target = await make_regular_user(session)
resp = await client.patch(
f"/api/admin/users/{target.id}/status",
json={"is_active": False},
)
assert resp.status_code == 200
# Wave 2 will add the user_nbf write; this is the target assertion:
assert False, "stub — Wave 2 will fill in: nbf_bytes = await app.state.redis.get(f'user_nbf:{target.id}'); assert nbf_bytes is not None"
@pytest.mark.xfail(strict=False, reason="Phase 7.2 Wave 2 — activation must NOT write user_nbf (invariant guard)")
@pytest.mark.asyncio
async def test_activate_user_does_not_write_user_nbf(admin_client):
"""PATCH /api/admin/users/{id}/status {is_active: true} must NOT write user_nbf:{id} to Redis.
Anti-pattern from RESEARCH.md: do not write user_nbf for successful activation.
This negative-guard test ensures Wave 2 only writes on deactivation, not activation.
"""
from main import app
client, _admin, session = admin_client
target = await make_regular_user(session)
# Deactivate first (sets is_active=False)
await client.patch(
f"/api/admin/users/{target.id}/status",
json={"is_active": False},
)
# Clear any nbf that may have been set by deactivation
fake_redis = app.state.redis
if hasattr(fake_redis, "_store"):
fake_redis._store.pop(f"user_nbf:{target.id}", None)
# Now activate (is_active=True) — must NOT set user_nbf
resp = await client.patch(
f"/api/admin/users/{target.id}/status",
json={"is_active": True},
)
assert resp.status_code == 200
# Wave 2 must preserve this invariant; this is the target assertion:
assert False, "stub — Wave 2 will fill in: nbf_bytes = await app.state.redis.get(f'user_nbf:{target.id}'); assert nbf_bytes is None"
+92
View File
@@ -600,3 +600,95 @@ async def test_disable_totp_revokes_other_sessions(authed_client, db_session: As
)
rows = result2.scalars().all()
assert any(r.revoked for r in rows), "Expected at least one revoked RefreshToken row"
# ── Phase 7.2 Wave 0 stubs: NBF-write on security events ─────────────────────
# These three tests are xfailed with strict=False. When Wave 2 (Plan 03) adds
# user_nbf writes to the handlers, these stubs are promoted to real assertions
# by replacing `pytest.xfail(...)` with actual Redis key assertions.
#
# Implementation hint for Wave 2 (kept here for context):
# After the API call returns success, fetch the user_id from the login response
# payload, then:
# nbf_bytes = await authed_client._transport.app.state.redis.get(f"user_nbf:{user_id}")
# assert nbf_bytes is not None
# assert int(nbf_bytes.decode() if isinstance(nbf_bytes, (bytes, bytearray)) else nbf_bytes) > 0
@pytest.mark.xfail(strict=False, reason="Phase 7.2 Wave 2 — user_nbf write not yet added to change_password handler")
@pytest.mark.asyncio
async def test_change_password_writes_user_nbf_to_redis(authed_client, db_session: AsyncSession):
"""POST /api/auth/change-password must write user_nbf:{user_id} to Redis."""
from sqlalchemy import select as sa_select
await _register(authed_client, handle="nbfw_cp1", email="nbfw_cp1@example.com")
login_resp = await _login(authed_client, email="nbfw_cp1@example.com")
token = login_resp.json()["access_token"]
result = await db_session.execute(sa_select(User).where(User.email == "nbfw_cp1@example.com"))
user = result.scalar_one()
with patch("services.auth.check_hibp", return_value=False):
resp = await authed_client.post(
"/api/auth/change-password",
json={"current_password": "ValidPass12!", "new_password": "NewStrong99!@"},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
# Wave 2 will add the user_nbf write; this is the target assertion:
pytest.xfail("Phase 7.2 Wave 2 stub — user_nbf write not yet implemented in change_password")
@pytest.mark.xfail(strict=False, reason="Phase 7.2 Wave 2 — user_nbf write not yet added to enable_totp handler")
@pytest.mark.asyncio
async def test_enable_totp_writes_user_nbf_to_redis(authed_client, db_session: AsyncSession):
"""POST /api/auth/totp/enable must write user_nbf:{user_id} to Redis."""
from sqlalchemy import select as sa_select
await _register(authed_client, handle="nbfw_et1", email="nbfw_et1@example.com")
login_resp = await _login(authed_client, email="nbfw_et1@example.com")
token = login_resp.json()["access_token"]
result = await db_session.execute(sa_select(User).where(User.email == "nbfw_et1@example.com"))
user = result.scalar_one()
user.totp_secret = "JBSWY3DPEHPK3PXP"
await db_session.commit()
with patch("services.auth.verify_totp", return_value=True):
with patch("services.auth.store_backup_codes", return_value=None):
resp = await authed_client.post(
"/api/auth/totp/enable",
json={"code": "123456"},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
# Wave 2 will add the user_nbf write; this is the target assertion:
pytest.xfail("Phase 7.2 Wave 2 stub — user_nbf write not yet implemented in enable_totp")
@pytest.mark.xfail(strict=False, reason="Phase 7.2 Wave 2 — user_nbf write not yet added to disable_totp handler")
@pytest.mark.asyncio
async def test_disable_totp_writes_user_nbf_to_redis(authed_client, db_session: AsyncSession):
"""DELETE /api/auth/totp must write user_nbf:{user_id} to Redis."""
from sqlalchemy import select as sa_select
await _register(authed_client, handle="nbfw_dt1", email="nbfw_dt1@example.com")
login_resp = await _login(authed_client, email="nbfw_dt1@example.com")
token = login_resp.json()["access_token"]
result = await db_session.execute(sa_select(User).where(User.email == "nbfw_dt1@example.com"))
user = result.scalar_one()
user.totp_enabled = True
user.totp_secret = "JBSWY3DPEHPK3PXP"
await db_session.commit()
resp = await authed_client.delete(
"/api/auth/totp",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
# Wave 2 will add the user_nbf write; this is the target assertion:
pytest.xfail("Phase 7.2 Wave 2 stub — user_nbf write not yet implemented in disable_totp")
+95 -2
View File
@@ -7,6 +7,11 @@ Tests verify:
- get_current_user raises HTTP 401 when user.is_active is False
- get_current_admin raises HTTP 403 when user.role == "user"
- get_current_admin returns user when user.role == "admin"
Phase 7.2 additions (Wave 0 scaffolding):
- FakeRedis attached to app.state.redis so Wave 1 NBF check in get_current_user
does not raise AttributeError: 'State' object has no attribute 'redis'
- Three xfail stubs for NBF check behaviours (reject, allow, fail-open)
"""
import uuid
@@ -16,11 +21,18 @@ from httpx import ASGITransport, AsyncClient
from fastapi import FastAPI, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from tests.test_auth_api import FakeRedis
# ── Minimal test app with /test/me and /test/admin routes ─────────────────────
def make_test_app():
"""Create a minimal FastAPI app that exercises the auth deps."""
"""Create a minimal FastAPI app that exercises the auth deps.
Phase 7.2: app.state.redis is set to a FakeRedis() instance so that the
NBF check in get_current_user (Wave 1) can call request.app.state.redis
without raising AttributeError (Pitfall 4 from Phase 7.2 RESEARCH.md).
"""
from deps.auth import get_current_user, get_current_admin
from db.models import User
@@ -34,12 +46,20 @@ def make_test_app():
async def admin_only(_admin: User = Depends(get_current_admin)):
return {"role": _admin.role}
# Phase 7.2: mount FakeRedis so NBF check in get_current_user works
test_app.state.redis = FakeRedis()
return test_app
@pytest_asyncio.fixture
async def auth_client(db_session: AsyncSession):
"""Async HTTP test client for the auth-dep test app."""
"""Async HTTP test client for the auth-dep test app.
Phase 7.2: each invocation of make_test_app() creates a fresh FakeRedis
instance (instantiated inside make_test_app). The NBF check in
get_current_user reads request.app.state.redis — this fixture provides it.
"""
from deps.db import get_db
app = make_test_app()
@@ -158,3 +178,76 @@ def test_deps_auth_has_http_403():
source = f.read()
assert re.search(r"HTTP_403_FORBIDDEN", source), \
"deps/auth.py must raise HTTP 403 for non-admin access"
# ── Phase 7.2 Wave 0 stubs: NBF check behaviours ─────────────────────────────
# These three tests are xfailed with strict=False. When Wave 1 (Plan 02) adds
# the NBF check to get_current_user, the stubs are promoted to real assertions
# by replacing `assert False, "stub"` with the actual assertion logic.
@pytest.mark.xfail(strict=False, reason="Phase 7.2 Wave 1 — NBF check not yet implemented in get_current_user")
@pytest.mark.asyncio
async def test_get_current_user_rejects_token_when_iat_before_user_nbf(auth_client, db_session):
"""Token with iat < user_nbf in Redis should return 401 'Session invalidated'."""
from services.auth import create_access_token
import time
user = await _create_user(db_session, role="user")
token = create_access_token(str(user.id), "user")
# Pre-populate Redis with a future nbf (token's iat will be < this value)
future_ts = int(time.time()) + 3600
fake_redis = auth_client._transport.app.state.redis
await fake_redis.set(f"user_nbf:{user.id}", str(future_ts).encode())
resp = await auth_client.get(
"/test/me", headers={"Authorization": f"Bearer {token}"}
)
# Wave 1 will implement the NBF check; this assertion is the target behaviour
assert False, "stub — Wave 1 will fill in: assert resp.status_code == 401 and 'Session invalidated' in resp.json()['detail']"
@pytest.mark.xfail(strict=False, reason="Phase 7.2 Wave 1 — NBF check not yet implemented in get_current_user")
@pytest.mark.asyncio
async def test_get_current_user_allows_token_when_iat_after_user_nbf(auth_client, db_session):
"""Token with iat > user_nbf in Redis should return 200 (token is valid)."""
from services.auth import create_access_token
import time
user = await _create_user(db_session, role="user")
token = create_access_token(str(user.id), "user")
# Pre-populate Redis with a past nbf (token's iat will be > this value)
past_ts = int(time.time()) - 3600
fake_redis = auth_client._transport.app.state.redis
await fake_redis.set(f"user_nbf:{user.id}", str(past_ts).encode())
resp = await auth_client.get(
"/test/me", headers={"Authorization": f"Bearer {token}"}
)
# Wave 1 will implement the NBF check; this assertion is the target behaviour
assert False, "stub — Wave 1 will fill in: assert resp.status_code == 200"
@pytest.mark.xfail(strict=False, reason="Phase 7.2 Wave 1 — NBF check not yet implemented in get_current_user")
@pytest.mark.asyncio
async def test_get_current_user_failopen_on_redis_error(auth_client, db_session):
"""Redis error during NBF check must fail-open (return 200, not crash)."""
from services.auth import create_access_token
class _BrokenRedis:
async def get(self, key):
raise RuntimeError("simulated redis down")
user = await _create_user(db_session, role="user")
token = create_access_token(str(user.id), "user")
# Override app.state.redis with a broken redis for this test
auth_client._transport.app.state.redis = _BrokenRedis()
resp = await auth_client.get(
"/test/me", headers={"Authorization": f"Bearer {token}"}
)
# Wave 1 will implement fail-open (D-04): assert resp.status_code == 200
assert False, "stub — Wave 1 will fill in: assert resp.status_code == 200"
+11
View File
@@ -194,6 +194,17 @@ async def test_rotate_revoked_token_raises(db_session):
await rotate_refresh_token(db_session, raw)
@pytest.mark.xfail(strict=False, reason="Phase 7.2 Wave 1 — jti claim not yet added to create_access_token")
def test_create_access_token_includes_jti_claim():
"""create_access_token payload must include a 'jti' key with a UUID-format string value."""
import uuid
from services.auth import create_access_token, decode_access_token
t = create_access_token("test-uid", "user")
payload = decode_access_token(t)
assert "jti" in payload, "jti claim missing from access token payload"
uuid.UUID(payload["jti"]) # raises ValueError if not a valid UUID
@pytest.mark.asyncio
async def test_store_and_verify_backup_codes(db_session):
"""store_backup_codes inserts rows; verify_backup_code matches correct code."""