security(07.3-02): add _rotate_tokens_on_algorithm_change lifespan hook + promote tests

- backend/main.py: add import logging, select; add _rotate_tokens_on_algorithm_change helper above lifespan; call from lifespan with try/except wrap
- backend/tests/test_auth_es256.py: promote ES256-04 and ES256-05 startup rotation tests from xfail to passing; suite reports 6 PASSED + 3 XFAILED
This commit is contained in:
curo1305
2026-06-06 17:02:45 +02:00
parent fd3f611546
commit 8d261b0509
2 changed files with 187 additions and 6 deletions
+130 -5
View File
@@ -79,18 +79,143 @@ def test_reset_token_uses_es256():
assert decode_password_reset_token(token) == "u1"
# ── Startup token rotation stubs ──────────────────────────────────────────────
# ── Startup token rotation tests ──────────────────────────────────────────────
@pytest.mark.xfail(strict=True, reason="ES256-04: not yet implemented")
@pytest.mark.asyncio
async def test_startup_rotation_revokes_tokens(db_session):
pytest.xfail("not yet implemented")
"""ES256-04: on first run, all active refresh tokens are bulk-revoked and jwt_algorithm row is upserted."""
import secrets as _secrets
from main import _rotate_tokens_on_algorithm_change
from db.models import RefreshToken, SystemSettings, User
from sqlalchemy import select
# Create a minimal user row (required by RefreshToken FK)
user_id = uuid.uuid4()
user = User(
id=user_id,
handle=f"testrotate_{user_id.hex[:8]}",
email=f"testrotate_{user_id.hex[:8]}@example.com",
password_hash="fakehash",
role="user",
is_active=True,
password_must_change=False,
)
db_session.add(user)
await db_session.flush()
# Insert two active refresh tokens
now = datetime.now(timezone.utc)
tok1_id = uuid.uuid4()
tok2_id = uuid.uuid4()
tok1 = RefreshToken(
id=tok1_id,
user_id=user_id,
token_hash=hashlib.sha256(_secrets.token_urlsafe(32).encode()).hexdigest(),
expires_at=now + timedelta(days=1),
revoked=False,
)
tok2 = RefreshToken(
id=tok2_id,
user_id=user_id,
token_hash=hashlib.sha256(_secrets.token_urlsafe(32).encode()).hexdigest(),
expires_at=now + timedelta(days=1),
revoked=False,
)
db_session.add(tok1)
db_session.add(tok2)
await db_session.flush()
# Act: no jwt_algorithm row exists yet
await _rotate_tokens_on_algorithm_change(db_session)
# expire_all() required: raw SQL UPDATE bypasses ORM identity map; expire_on_commit=False
# (set in conftest.py) means SQLAlchemy won't auto-reload stale objects after commit.
db_session.expire_all()
# Assert: both tokens are now revoked
result = await db_session.execute(
select(RefreshToken).where(RefreshToken.id.in_([tok1_id, tok2_id]))
)
rows = result.scalars().all()
assert len(rows) == 2
assert all(r.revoked is True for r in rows)
# Assert: jwt_algorithm row was created correctly
ss_result = await db_session.execute(
select(SystemSettings).where(SystemSettings.provider_id == "jwt_algorithm")
)
ss_row = ss_result.scalar_one_or_none()
assert ss_row is not None
assert ss_row.model_name == "ES256"
assert ss_row.is_active is False
assert ss_row.context_chars == 0
@pytest.mark.xfail(strict=True, reason="ES256-05: not yet implemented")
@pytest.mark.asyncio
async def test_startup_rotation_idempotent(db_session):
pytest.xfail("not yet implemented")
"""ES256-05: when jwt_algorithm row already has model_name=ES256, no tokens are revoked."""
import secrets as _secrets
from main import _rotate_tokens_on_algorithm_change
from db.models import RefreshToken, SystemSettings, User
from sqlalchemy import select, func
# Create a user
user_id = uuid.uuid4()
user = User(
id=user_id,
handle=f"testidempotent_{user_id.hex[:8]}",
email=f"testidempotent_{user_id.hex[:8]}@example.com",
password_hash="fakehash",
role="user",
is_active=True,
password_must_change=False,
)
db_session.add(user)
await db_session.flush()
# Seed the jwt_algorithm row as already migrated
existing_marker = SystemSettings(
id=uuid.uuid4(),
provider_id="jwt_algorithm",
model_name="ES256",
context_chars=0,
is_active=False,
api_key_enc=None,
base_url=None,
)
db_session.add(existing_marker)
# Insert one fresh active token
now = datetime.now(timezone.utc)
tok_id = uuid.uuid4()
tok = RefreshToken(
id=tok_id,
user_id=user_id,
token_hash=hashlib.sha256(_secrets.token_urlsafe(32).encode()).hexdigest(),
expires_at=now + timedelta(days=1),
revoked=False,
)
db_session.add(tok)
await db_session.flush()
# Act
await _rotate_tokens_on_algorithm_change(db_session)
# expire_all() to clear identity map cache (same reason as test_startup_rotation_revokes_tokens)
db_session.expire_all()
# Assert: the fresh token was NOT revoked (bulk update did not fire)
result = await db_session.execute(
select(RefreshToken).where(RefreshToken.id == tok_id)
)
fresh_tok = result.scalar_one_or_none()
assert fresh_tok is not None
assert fresh_tok.revoked is False
# Assert: still exactly one jwt_algorithm row (no duplicate upsert)
count_result = await db_session.execute(
select(func.count()).where(SystemSettings.provider_id == "jwt_algorithm")
)
count = count_result.scalar_one()
assert count == 1
# ── Remember-me TTL stubs ─────────────────────────────────────────────────────