"""Integration tests for passkey (WebAuthn) + magic-link auth endpoints. Run against the Docker Compose infrastructure (`docker compose up -d`). Real WebAuthn ceremonies need a hardware/platform authenticator, so the finish endpoints are only exercised on their error paths; the happy paths are covered by unit tests on core/passkeys.py plus the webauthn library itself. Magic-link verify is tested end-to-end by seeding a token hash directly (the raw token never touches the DB). """ from __future__ import annotations import hashlib import uuid import httpx import pytest from sqlalchemy import text pytestmark = pytest.mark.integration _TG_ID = 915_001 def _hash(token: str) -> str: return hashlib.sha256(token.encode("utf-8")).hexdigest() async def _register_email_user(client: httpx.AsyncClient, email: str) -> str: """Register an email/password user via the API and return the access JWT.""" r = await client.post( "/api/v1/auth/register", json={"email": email, "name": "Passkey Tester", "password": "supersecret123"}, ) assert r.status_code == 201, f"register failed: {r.status_code} {r.text}" return r.json()["access_token"] async def test_passkey_register_start_returns_options( client: httpx.AsyncClient, infra: dict[str, str], ) -> None: from tests.integration.conftest import user_token token = await user_token(client, infra, telegram_id=_TG_ID) r = await client.post( "/api/v1/auth/passkeys/register/start", json={"device_name": "YubiKey 5"}, headers={"Authorization": f"Bearer {token}"}, ) assert r.status_code == 200, r.text body = r.json() assert body["rp"]["id"] assert body["rp"]["name"] assert body["rp"]["origin"].startswith("http") assert body["user"]["id"] assert body["timeout"] > 0 assert body["attestation"] == "none" assert body["authenticatorSelection"] == { "residentKey": "required", "userVerification": "required", } assert all(p["type"] == "public-key" for p in body["pubKeyCredParams"]) assert body["excludeCredentials"] == [] assert len(body["challenge"]) >= 16 async def test_passkey_register_start_requires_user_jwt( client: httpx.AsyncClient, ) -> None: r = await client.post("/api/v1/auth/passkeys/register/start", json={}) assert r.status_code == 401 async def test_passkey_register_finish_rejects_garbage_attestation( client: httpx.AsyncClient, infra: dict[str, str], ) -> None: from tests.integration.conftest import user_token token = await user_token(client, infra, telegram_id=_TG_ID + 1) start = await client.post( "/api/v1/auth/passkeys/register/start", json={}, headers={"Authorization": f"Bearer {token}"}, ) assert start.status_code == 200 r = await client.post( "/api/v1/auth/passkeys/register/finish", json={ "credential": { "id": "garbage", "rawId": "garbage", "type": "public-key", "response": {"clientDataJSON": "e30", "attestationObject": "e30"}, } }, headers={"Authorization": f"Bearer {token}"}, ) assert r.status_code == 400 assert "invalid registration" in r.json()["detail"] async def test_passkey_register_finish_without_challenge_fails( client: httpx.AsyncClient, infra: dict[str, str], ) -> None: from tests.integration.conftest import user_token token = await user_token(client, infra, telegram_id=_TG_ID + 2) r = await client.post( "/api/v1/auth/passkeys/register/finish", json={ "credential": { "id": "garbage", "rawId": "garbage", "type": "public-key", "response": {"clientDataJSON": "e30", "attestationObject": "e30"}, } }, headers={"Authorization": f"Bearer {token}"}, ) assert r.status_code == 400 assert "no pending registration challenge" in r.json()["detail"] async def test_passkey_authenticate_start_returns_options( client: httpx.AsyncClient, ) -> None: r = await client.post("/api/v1/auth/passkeys/authenticate/start") assert r.status_code == 200, r.text body = r.json() assert body["rpId"] assert body["allowCredentials"] == [] assert body["userVerification"] == "required" assert len(body["challenge"]) >= 16 async def test_passkey_authenticate_finish_rejects_unknown_challenge( client: httpx.AsyncClient, ) -> None: r = await client.post( "/api/v1/auth/passkeys/authenticate/finish", json={ "challenge": "dGhpcy1pcy1ub3QtYS1yZWFsLWNoYWxsZW5nZQ", "credential": { "id": "unknown", "rawId": "unknown", "type": "public-key", "response": { "clientDataJSON": "e30", "authenticatorData": "e30", "signature": "e30", }, }, }, ) assert r.status_code == 400 assert "unknown or expired challenge" in r.json()["detail"] async def test_passkey_list_and_delete( client: httpx.AsyncClient, infra: dict[str, str], db_session, ) -> None: from tests.integration.conftest import user_token token = await user_token(client, infra, telegram_id=_TG_ID + 3) headers = {"Authorization": f"Bearer {token}"} # Fetch the user id behind the JWT. me = await client.get("/api/v1/auth/me", headers=headers) assert me.status_code == 200 user_id = me.json()["sub"] empty = await client.get("/api/v1/auth/passkeys", headers=headers) assert empty.status_code == 200 assert empty.json() == [] # Seed one credential row directly (full attestation needs an authenticator). cred_id = "itestcred" + uuid.uuid4().hex result = await db_session.execute( text( "INSERT INTO passkey_credentials " "(user_id, credential_id, public_key, sign_count, device_name) " "VALUES (:u, :c, :k, 0, 'Integration key') RETURNING id" ), {"u": user_id, "c": cred_id, "k": "itestpubkey"}, ) row = result.first() assert row is not None await db_session.commit() passkey_row_id = str(row[0]) listed = await client.get("/api/v1/auth/passkeys", headers=headers) assert listed.status_code == 200 items = listed.json() assert len(items) == 1 assert items[0]["credentialID"] == cred_id assert items[0]["deviceName"] == "Integration key" assert items[0]["counter"] == 0 assert items[0]["userId"] == user_id deleted = await client.delete(f"/api/v1/auth/passkeys/{passkey_row_id}", headers=headers) assert deleted.status_code == 200 assert deleted.json()["ok"] is True gone = await client.delete(f"/api/v1/auth/passkeys/{passkey_row_id}", headers=headers) assert gone.status_code == 404 final = await client.get("/api/v1/auth/passkeys", headers=headers) assert final.json() == [] async def test_magic_link_request_is_generic_for_unknown_email( client: httpx.AsyncClient, ) -> None: r = await client.post( "/api/v1/auth/magic-link/request", json={"email": "nobody-here@example.com"}, ) assert r.status_code == 200 body = r.json() assert body["success"] is True assert body["message"] assert body["expires_in"] > 0 async def test_magic_link_verify_roundtrip( client: httpx.AsyncClient, db_session, ) -> None: email = f"magic-{uuid.uuid4().hex[:8]}@example.com" token = await _register_email_user(client, email) me = await client.get("/api/v1/auth/me", headers={"Authorization": f"Bearer {token}"}) user_id = me.json()["sub"] # Seed a magic-link token the way magic-link/request would (raw token # never touches the DB — only its SHA-256 hash). raw = "itest-magic-token-" + uuid.uuid4().hex await db_session.execute( text( "UPDATE users SET magic_link_token_hash = :h, " "magic_link_expires_at = now() + interval '15 minutes' " "WHERE id = :u" ), {"h": _hash(raw), "u": user_id}, ) await db_session.commit() r = await client.post("/api/v1/auth/magic-link/verify", json={"token": raw}) assert r.status_code == 200, r.text body = r.json() assert body["token_type"] == "bearer" assert body["expires_in"] > 0 assert body["user_id"] == user_id assert body["email"] == email assert body["access_token"] # The issued JWT must introspect as the same user. me2 = await client.get( "/api/v1/auth/me", headers={"Authorization": f"Bearer {body['access_token']}"} ) assert me2.status_code == 200 assert me2.json()["sub"] == user_id # The link is one-time: replaying it fails. replay = await client.post("/api/v1/auth/magic-link/verify", json={"token": raw}) assert replay.status_code == 400 assert "invalid magic-link token" in replay.json()["detail"] async def test_magic_link_verify_rejects_expired_token( client: httpx.AsyncClient, db_session, ) -> None: email = f"magic-exp-{uuid.uuid4().hex[:8]}@example.com" token = await _register_email_user(client, email) me = await client.get("/api/v1/auth/me", headers={"Authorization": f"Bearer {token}"}) user_id = me.json()["sub"] raw = "expired-magic-token-" + uuid.uuid4().hex await db_session.execute( text( "UPDATE users SET magic_link_token_hash = :h, " "magic_link_expires_at = now() - interval '1 minute' " "WHERE id = :u" ), {"h": _hash(raw), "u": user_id}, ) await db_session.commit() r = await client.post("/api/v1/auth/magic-link/verify", json={"token": raw}) assert r.status_code == 400 assert "expired" in r.json()["detail"]