"""Unit tests for passkey (WebAuthn) helpers and the challenge store.""" from __future__ import annotations import datetime as dt import uuid from collections.abc import Callable import pytest from contract_check.core.config import get_settings from contract_check.core.passkeys import ( PasskeyChallengeStore, authentication_key, build_authentication_options, build_registration_options, registration_key, ) @pytest.fixture(autouse=True) def _clear_settings_cache(monkeypatch: pytest.MonkeyPatch) -> None: get_settings.cache_clear() monkeypatch.setenv("JWT_SECRET", "unit-test-secret-do-not-use-in-prod") monkeypatch.setenv("PASSKEY_RP_ID", "localhost") monkeypatch.setenv("PASSKEY_RP_NAME", "Contract-check") monkeypatch.setenv("PASSKEY_RP_ORIGINS", "http://localhost:5173,https://app.example.com") get_settings.cache_clear() yield get_settings.cache_clear() class _FakeRedis: """In-memory async stand-in for redis.asyncio.Redis (SET/GET/DELETE/EXISTS).""" def __init__(self) -> None: self._data: dict[str, str] = {} self._ttls: dict[str, float] = {} async def set(self, key: str, value: str, ex: int | None = None) -> None: self._data[key] = value if ex is not None: self._ttls[key] = dt.datetime.now(tz=dt.UTC).timestamp() + ex async def get(self, key: str) -> str | None: if key in self._data and self._unexpired(key): return self._data[key] return None async def exists(self, key: str) -> int: return 1 if key in self._data and self._unexpired(key) else 0 async def delete(self, *keys: str) -> int: removed = 0 for k in keys: if k in self._data: del self._data[k] self._ttls.pop(k, None) removed += 1 return removed def _unexpired(self, key: str) -> bool: if key not in self._ttls: return True return dt.datetime.now(tz=dt.UTC).timestamp() < self._ttls[key] # ── options builders ────────────────────────────────────────────────────────── def test_build_registration_options_matches_frontend_schema() -> None: user_id = uuid.uuid4() existing = "Zo8eBlCJ-fK9xM3vQw7yTg" options, challenge = build_registration_options( settings=get_settings(), user_id=user_id, user_name="user@example.com", existing_credential_ids=[existing], ) assert options["challenge"] == challenge assert options["rp"] == { "id": "localhost", "name": "Contract-check", "origin": "http://localhost:5173", } assert options["user"]["id"] and options["user"]["name"] == "user@example.com" assert options["user"]["displayName"] == "user@example.com" assert options["timeout"] == get_settings().passkey_challenge_ttl_seconds * 1000 assert options["attestation"] == "none" assert options["authenticatorSelection"] == { "residentKey": "required", "userVerification": "required", } assert all(p["type"] == "public-key" for p in options["pubKeyCredParams"]) assert {p["alg"] for p in options["pubKeyCredParams"]} >= {-7, -257} # The previously stored credential is excluded (base64url roundtrip). assert options["excludeCredentials"] == [{"id": existing, "type": "public-key"}] # Roundtrips through the API response model without coercion errors. from contract_check.api.routes.auth import PasskeyRegistrationOptions assert PasskeyRegistrationOptions(**options).challenge == challenge def test_build_authentication_options_matches_frontend_schema() -> None: options, challenge = build_authentication_options(settings=get_settings()) assert options["challenge"] == challenge assert options["rpId"] == "localhost" assert options["timeout"] == get_settings().passkey_challenge_ttl_seconds * 1000 assert options["allowCredentials"] == [] assert options["userVerification"] == "required" from contract_check.api.routes.auth import PasskeyAuthenticationOptions assert PasskeyAuthenticationOptions(**options).rpId == "localhost" # ── challenge store ─────────────────────────────────────────────────────────── @pytest.fixture def store_factory() -> Callable[[], tuple[PasskeyChallengeStore, _FakeRedis]]: def _make() -> tuple[PasskeyChallengeStore, _FakeRedis]: redis = _FakeRedis() return PasskeyChallengeStore(redis, ttl_seconds=120), redis return _make async def test_registration_challenge_roundtrip( store_factory: Callable[[], tuple[PasskeyChallengeStore, _FakeRedis]], ) -> None: store, redis = store_factory() user_id = uuid.uuid4() await store.store_registration(user_id, challenge="c-1", device_name="YubiKey 5") assert registration_key(user_id) in redis._data stored = await store.consume_registration(user_id) assert stored == {"challenge": "c-1", "device_name": "YubiKey 5"} # Single use: second consume finds nothing. assert await store.consume_registration(user_id) is None async def test_registration_challenge_overwrites( store_factory: Callable[[], tuple[PasskeyChallengeStore, _FakeRedis]], ) -> None: store, _ = store_factory() user_id = uuid.uuid4() await store.store_registration(user_id, challenge="first") await store.store_registration(user_id, challenge="second", device_name="iPhone") stored = await store.consume_registration(user_id) assert stored is not None assert stored["challenge"] == "second" assert stored["device_name"] == "iPhone" async def test_authentication_challenge_single_use( store_factory: Callable[[], tuple[PasskeyChallengeStore, _FakeRedis]], ) -> None: store, redis = store_factory() await store.store_authentication("cha-llenge") assert authentication_key("cha-llenge") in redis._data assert await store.consume_authentication("cha-llenge") is True assert await store.consume_authentication("cha-llenge") is False assert authentication_key("cha-llenge") not in redis._data async def test_consume_missing_challenges_returns_falsy( store_factory: Callable[[], tuple[PasskeyChallengeStore, _FakeRedis]], ) -> None: store, _ = store_factory() assert await store.consume_registration(uuid.uuid4()) is None assert await store.consume_authentication("never-issued") is False async def test_expired_authentication_challenge_rejected( store_factory: Callable[[], tuple[PasskeyChallengeStore, _FakeRedis]], ) -> None: store, redis = store_factory() await store.store_authentication("short-lived") # Force the stored TTL into the past (as if the challenge had expired). redis._ttls[authentication_key("short-lived")] = dt.datetime.now(tz=dt.UTC).timestamp() - 1 assert await store.consume_authentication("short-lived") is False