DealDocumentScreening/tests/unit/test_passkeys.py
febux 2fe54ffd86 Phase 2 - Raw SQL migration, pytest-cov coverage gate, CI branch
triggers + coverage,  Dead tooling & config drift
2026-08-24 00:45:39 +03:00

194 lines
7 KiB
Python

"""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.schemas 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.schemas 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