""" TDD scaffold for Phase 7.3: ES256 algorithm upgrade, startup token rotation, and remember_me session TTL — all stubs xfail strict=True until promoted. """ import base64 import hashlib import json import time import uuid from datetime import datetime, timedelta, timezone import pytest import pytest_asyncio from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import ec @pytest.fixture(autouse=True) def es256_keys(monkeypatch): """Patch settings with a freshly generated P-256 key pair for each test.""" k = ec.generate_private_key(ec.SECP256R1()) priv = base64.b64encode( k.private_bytes( serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption(), ) ).decode() pub = base64.b64encode( k.public_key().public_bytes( serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo, ) ).decode() monkeypatch.setattr("config.settings.jwt_private_key", priv, raising=False) monkeypatch.setattr("config.settings.jwt_public_key", pub, raising=False) # ── ES256 algorithm tests ───────────────────────────────────────────────────── def test_access_token_uses_es256(): """ES256-01: access token header must declare alg=ES256.""" from services.auth import create_access_token token = create_access_token("u1", "user") # Decode the header (first segment of the JWT) header_b64 = token.split(".")[0] # Add padding to make it valid base64 padding = "=" * (4 - len(header_b64) % 4) header = json.loads(base64.urlsafe_b64decode(header_b64 + padding)) assert header["alg"] == "ES256" def test_hs256_token_rejected(): """ES256-02: an HS256 token must raise ValueError when decoded.""" import jwt as _jwt from services.auth import decode_access_token hs256_token = _jwt.encode( { "sub": "u1", "typ": "access", "exp": int(time.time()) + 60, "iat": int(time.time()), }, "any-hs256-secret", algorithm="HS256", ) with pytest.raises(ValueError): decode_access_token(hs256_token) def test_reset_token_uses_es256(): """ES256-03: password-reset token header must declare alg=ES256 and round-trips.""" from services.auth import create_password_reset_token, decode_password_reset_token token = create_password_reset_token("u1") header_b64 = token.split(".")[0] padding = "=" * (4 - len(header_b64) % 4) header = json.loads(base64.urlsafe_b64decode(header_b64 + padding)) assert header["alg"] == "ES256" assert decode_password_reset_token(token) == "u1" # ── Startup token rotation stubs ────────────────────────────────────────────── @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") @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") # ── Remember-me TTL stubs ───────────────────────────────────────────────────── @pytest.mark.xfail(strict=True, reason="RM-01: not yet implemented") @pytest.mark.asyncio async def test_default_ttl_16_hours(async_client, db_session, auth_user): pytest.xfail("not yet implemented") @pytest.mark.xfail(strict=True, reason="RM-02: not yet implemented") @pytest.mark.asyncio async def test_remember_me_ttl_30_days(async_client, db_session, auth_user): pytest.xfail("not yet implemented") @pytest.mark.xfail(strict=True, reason="RM-03: not yet implemented") @pytest.mark.asyncio async def test_remember_me_cookie_max_age(async_client, auth_user): pytest.xfail("not yet implemented") # ── Config field tests ──────────────────────────────────────────────────────── def test_settings_has_jwt_keys(): """CFG-01: Settings must expose jwt_private_key, jwt_public_key, and refresh_token_expire_hours=16.""" from config import settings assert hasattr(settings, "jwt_private_key") assert hasattr(settings, "jwt_public_key") assert hasattr(settings, "refresh_token_expire_hours") assert settings.refresh_token_expire_hours == 16