194 lines
7 KiB
Python
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
|