test: update all tests to include CSRF tokens

This commit is contained in:
Johan Lundberg 2026-02-19 14:19:47 +01:00
parent 9e5773f52f
commit f648422227
No known key found for this signature in database
GPG key ID: A6C152738D03C7D1
12 changed files with 105 additions and 26 deletions

View file

@ -4,6 +4,7 @@ from datetime import UTC, datetime
from argon2 import PasswordHasher
from httpx import AsyncClient
from tests.conftest import get_csrf_token
from porchlight.authn.password import PasswordService
from porchlight.models import PasswordCredential, User, WebAuthnCredential
@ -20,10 +21,11 @@ async def _create_user_and_login(client: AsyncClient) -> str:
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")))
token = await get_csrf_token(client)
await client.post(
"/login/password",
data={"username": "alice", "password": "testpass"},
headers={"HX-Request": "true"},
headers={"HX-Request": "true", "X-CSRF-Token": token},
)
return user.userid
@ -32,9 +34,10 @@ async def test_cannot_delete_last_password_credential(client: AsyncClient) -> No
"""User has only a password — cannot delete it."""
await _create_user_and_login(client)
token = await get_csrf_token(client)
res = await client.delete(
"/manage/credentials/password",
headers={"HX-Request": "true"},
headers={"HX-Request": "true", "X-CSRF-Token": token},
)
assert res.status_code == 200
assert 'role="alert"' in res.text
@ -57,9 +60,10 @@ async def test_cannot_delete_last_webauthn_credential(client: AsyncClient) -> No
await cred_repo.delete_password(userid)
cred_id_b64 = urlsafe_b64encode(b"cred1").decode().rstrip("=")
token = await get_csrf_token(client)
res = await client.delete(
f"/manage/credentials/webauthn/{cred_id_b64}",
headers={"HX-Request": "true"},
headers={"HX-Request": "true", "X-CSRF-Token": token},
)
assert res.status_code == 200
assert 'role="alert"' in res.text

View file

@ -3,6 +3,7 @@ from datetime import UTC, datetime
from argon2 import PasswordHasher
from httpx import AsyncClient
from tests.conftest import get_csrf_token
from porchlight.authn.password import PasswordService
from porchlight.models import PasswordCredential, User
@ -25,10 +26,11 @@ async def _login(client: AsyncClient, username: str = "alice", password: str = "
if existing is None:
await cred_repo.create_password(PasswordCredential(user_id=user.userid, password_hash=svc.hash(password)))
token = await get_csrf_token(client)
await client.post(
"/login/password",
data={"username": username, "password": password},
headers={"HX-Request": "true"},
headers={"HX-Request": "true", "X-CSRF-Token": token},
)

View file

@ -3,6 +3,7 @@ from datetime import UTC, datetime
from argon2 import PasswordHasher
from httpx import AsyncClient
from tests.conftest import get_csrf_token
from porchlight.authn.password import PasswordService
from porchlight.models import PasswordCredential, User, WebAuthnCredential
@ -19,18 +20,21 @@ async def _create_user_and_login(client: AsyncClient) -> str:
svc = PasswordService(hasher=PasswordHasher(time_cost=1, memory_cost=8192))
await cred_repo.create_password(PasswordCredential(user_id=user.userid, password_hash=svc.hash("old")))
token = await get_csrf_token(client)
await client.post(
"/login/password",
data={"username": "alice", "password": "old"},
headers={"HX-Request": "true"},
headers={"HX-Request": "true", "X-CSRF-Token": token},
)
return user.userid
async def test_set_password_requires_session(client: AsyncClient) -> None:
token = await get_csrf_token(client)
res = await client.post(
"/manage/credentials/password",
data={"password": "x", "confirm": "x"},
headers={"X-CSRF-Token": token},
follow_redirects=False,
)
assert res.status_code in (302, 303)
@ -39,10 +43,11 @@ async def test_set_password_requires_session(client: AsyncClient) -> None:
async def test_set_password_mismatch_returns_error(client: AsyncClient) -> None:
await _create_user_and_login(client)
token = await get_csrf_token(client)
res = await client.post(
"/manage/credentials/password",
data={"password": "newpassword", "confirm": "different"},
headers={"HX-Request": "true"},
headers={"HX-Request": "true", "X-CSRF-Token": token},
)
assert res.status_code == 200
assert 'role="alert"' in res.text
@ -51,10 +56,11 @@ async def test_set_password_mismatch_returns_error(client: AsyncClient) -> None:
async def test_set_password_too_short_returns_error(client: AsyncClient) -> None:
await _create_user_and_login(client)
token = await get_csrf_token(client)
res = await client.post(
"/manage/credentials/password",
data={"password": "short", "confirm": "short"},
headers={"HX-Request": "true"},
headers={"HX-Request": "true", "X-CSRF-Token": token},
)
assert res.status_code == 200
assert 'role="alert"' in res.text
@ -65,10 +71,11 @@ async def test_set_password_creates_or_replaces_password(client: AsyncClient) ->
app = client._transport.app # type: ignore[union-attr]
cred_repo = app.state.credential_repo
token = await get_csrf_token(client)
res = await client.post(
"/manage/credentials/password",
data={"password": "newpassword123", "confirm": "newpassword123"},
headers={"HX-Request": "true"},
headers={"HX-Request": "true", "X-CSRF-Token": token},
)
assert res.status_code == 200
assert 'role="status"' in res.text or "Password" in res.text
@ -80,7 +87,12 @@ async def test_set_password_creates_or_replaces_password(client: AsyncClient) ->
async def test_delete_password_requires_session(client: AsyncClient) -> None:
res = await client.delete("/manage/credentials/password", follow_redirects=False)
token = await get_csrf_token(client)
res = await client.delete(
"/manage/credentials/password",
headers={"X-CSRF-Token": token},
follow_redirects=False,
)
assert res.status_code in (302, 303)
@ -93,9 +105,10 @@ async def test_delete_password_with_other_credential(client: AsyncClient) -> Non
# Add a webauthn credential so password is not the last one
await cred_repo.create_webauthn(WebAuthnCredential(user_id=userid, credential_id=b"cred1", public_key=b"key1"))
token = await get_csrf_token(client)
res = await client.delete(
"/manage/credentials/password",
headers={"HX-Request": "true"},
headers={"HX-Request": "true", "X-CSRF-Token": token},
)
assert res.status_code == 200

View file

@ -17,6 +17,7 @@ from fido2.webauthn import (
)
from httpx import AsyncClient
from tests.conftest import get_csrf_token
from porchlight.authn.password import PasswordService
from porchlight.models import PasswordCredential, User, WebAuthnCredential
@ -36,10 +37,11 @@ async def _create_user_and_login(client: AsyncClient) -> str:
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")))
token = await get_csrf_token(client)
await client.post(
"/login/password",
data={"username": "alice", "password": "testpass"},
headers={"HX-Request": "true"},
headers={"HX-Request": "true", "X-CSRF-Token": token},
)
return user.userid
@ -70,14 +72,23 @@ def _build_registration_response(
async def test_webauthn_begin_requires_session(client: AsyncClient) -> None:
res = await client.post("/manage/credentials/webauthn/begin", follow_redirects=False)
token = await get_csrf_token(client)
res = await client.post(
"/manage/credentials/webauthn/begin",
headers={"X-CSRF-Token": token},
follow_redirects=False,
)
assert res.status_code in (302, 303, 401)
async def test_webauthn_begin_returns_options(client: AsyncClient) -> None:
await _create_user_and_login(client)
res = await client.post("/manage/credentials/webauthn/begin")
token = await get_csrf_token(client)
res = await client.post(
"/manage/credentials/webauthn/begin",
headers={"X-CSRF-Token": token},
)
assert res.status_code == 200
data = res.json()
assert "publicKey" in data
@ -122,9 +133,10 @@ async def test_delete_webauthn_credential(client: AsyncClient) -> None:
cred_id_b64 = urlsafe_b64encode(b"cred1").decode().rstrip("=")
token = await get_csrf_token(client)
res = await client.delete(
f"/manage/credentials/webauthn/{cred_id_b64}",
headers={"HX-Request": "true"},
headers={"HX-Request": "true", "X-CSRF-Token": token},
)
assert res.status_code == 200

View file

@ -3,15 +3,17 @@ from datetime import UTC, datetime
from argon2 import PasswordHasher
from httpx import AsyncClient
from tests.conftest import get_csrf_token
from porchlight.authn.password import PasswordService
from porchlight.models import PasswordCredential, User
async def test_password_login_unknown_user_returns_error_fragment(client: AsyncClient) -> None:
token = await get_csrf_token(client)
res = await client.post(
"/login/password",
data={"username": "nobody", "password": "wrong"},
headers={"HX-Request": "true"},
headers={"HX-Request": "true", "X-CSRF-Token": token},
)
assert res.status_code == 200
assert "Invalid username or password" in res.text
@ -29,10 +31,11 @@ async def test_password_login_wrong_password_returns_error_fragment(client: Asyn
svc = PasswordService(hasher=PasswordHasher(time_cost=1, memory_cost=8192))
await cred_repo.create_password(PasswordCredential(user_id=user.userid, password_hash=svc.hash("correct")))
token = await get_csrf_token(client)
res = await client.post(
"/login/password",
data={"username": "alice", "password": "wrong"},
headers={"HX-Request": "true"},
headers={"HX-Request": "true", "X-CSRF-Token": token},
)
assert res.status_code == 200
assert "Invalid username or password" in res.text
@ -49,16 +52,18 @@ async def test_password_login_success_sets_session_and_hx_redirect(client: Async
svc = PasswordService(hasher=PasswordHasher(time_cost=1, memory_cost=8192))
await cred_repo.create_password(PasswordCredential(user_id=user.userid, password_hash=svc.hash("correct")))
token = await get_csrf_token(client)
res = await client.post(
"/login/password",
data={"username": "alice", "password": "correct"},
headers={"HX-Request": "true"},
headers={"HX-Request": "true", "X-CSRF-Token": token},
)
assert res.status_code == 200
assert res.headers.get("HX-Redirect") == "/manage/credentials"
async def test_logout_clears_session_and_redirects(client: AsyncClient) -> None:
res = await client.post("/logout", headers={"HX-Request": "true"})
token = await get_csrf_token(client)
res = await client.post("/logout", headers={"HX-Request": "true", "X-CSRF-Token": token})
assert res.status_code == 200
assert res.headers.get("HX-Redirect") == "/login"

View file

@ -6,6 +6,7 @@ from fido2.cose import ES256
from fido2.webauthn import Aaguid, AttestedCredentialData
from httpx import AsyncClient
from tests.conftest import get_csrf_token
from porchlight.models import User, WebAuthnCredential
RP_ID = "localhost"
@ -66,9 +67,11 @@ async def test_webauthn_login_begin_has_user_verification_preferred(client: Asyn
async def test_webauthn_login_complete_without_state_returns_400(client: AsyncClient) -> None:
"""Complete without prior begin should fail."""
token = await get_csrf_token(client)
res = await client.post(
"/login/webauthn/complete",
json={"id": "fake", "rawId": "fake", "type": "public-key", "response": {}},
headers={"X-CSRF-Token": token},
)
assert res.status_code == 400
@ -81,11 +84,13 @@ async def test_webauthn_login_complete_returns_json_redirect(client: AsyncClient
res1 = await client.get("/login/webauthn/begin")
assert res1.status_code == 200
token = await get_csrf_token(client)
# We can't easily complete the full assertion without browser interaction,
# but we verify the endpoint returns 400 JSON (not HTML) for bad assertions
res2 = await client.post(
"/login/webauthn/complete",
json={"id": "fake", "rawId": "fake", "type": "public-key", "response": {}},
headers={"X-CSRF-Token": token},
)
# Should fail verification but not crash — returns error HTML for now
assert res2.status_code in (200, 400)