porchlight/tests/test_auth_routes/test_last_credential_guard.py
Johan Lundberg e15dcc4745
feat: add authentication routes with session login, WebAuthn, and credential management
Implement Phase 4 auth routes: password login/logout, WebAuthn
registration and authentication, magic link registration, and
credential management pages with HTMX. Includes session middleware,
Jinja2 templates, vendored HTMX, and last-credential guardrails.

120 tests passing.
2026-02-16 11:39:50 +01:00

69 lines
2.6 KiB
Python

from base64 import urlsafe_b64encode
from datetime import UTC, datetime
from argon2 import PasswordHasher
from httpx import AsyncClient
from fastapi_oidc_op.authn.password import PasswordService
from fastapi_oidc_op.models import PasswordCredential, User, WebAuthnCredential
async def _create_user_and_login(client: AsyncClient) -> str:
"""Create user with password credential, log in, return userid."""
app = client._transport.app # type: ignore[union-attr]
user_repo = app.state.user_repo
cred_repo = app.state.credential_repo
user = User(userid="lusab-bansen", username="alice", created_at=datetime.now(UTC), updated_at=datetime.now(UTC))
await user_repo.create(user)
svc = PasswordService(hasher=PasswordHasher(time_cost=1, memory_cost=8192))
await cred_repo.create_password(PasswordCredential(user_id=user.userid, password_hash=svc.hash("testpass")))
await client.post(
"/login/password",
data={"username": "alice", "password": "testpass"},
headers={"HX-Request": "true"},
)
return user.userid
async def test_cannot_delete_last_password_credential(client: AsyncClient) -> None:
"""User has only a password — cannot delete it."""
await _create_user_and_login(client)
res = await client.delete(
"/manage/credentials/password",
headers={"HX-Request": "true"},
)
assert res.status_code == 200
assert 'role="alert"' in res.text
assert "last credential" in res.text.lower() or "Cannot remove" in res.text
# Password should still exist
app = client._transport.app # type: ignore[union-attr]
cred = await app.state.credential_repo.get_password_by_user("lusab-bansen")
assert cred is not None
async def test_cannot_delete_last_webauthn_credential(client: AsyncClient) -> None:
"""User has only one webauthn credential (password was removed) — cannot delete it."""
userid = await _create_user_and_login(client)
app = client._transport.app # type: ignore[union-attr]
cred_repo = app.state.credential_repo
# Add webauthn, then delete password (so webauthn is the only credential)
await cred_repo.create_webauthn(WebAuthnCredential(user_id=userid, credential_id=b"cred1", public_key=b"key1"))
await cred_repo.delete_password(userid)
cred_id_b64 = urlsafe_b64encode(b"cred1").decode().rstrip("=")
res = await client.delete(
f"/manage/credentials/webauthn/{cred_id_b64}",
headers={"HX-Request": "true"},
)
assert res.status_code == 200
assert 'role="alert"' in res.text
# Credential should still exist
creds = await cred_repo.get_webauthn_by_user(userid)
assert len(creds) == 1