Skip to content

Commit b1934c3

Browse files
test(e2e): real-browser Playwright tests for the CSRF server-rendered flow
Four end-to-end assertions that prove the rewrite works in an actual browser, not just at the middleware unit level: 1. test_csrf_cookie_is_httponly: cookie set by the server is HttpOnly -- BrowserContext sees it, document.cookie does not. 2. test_wizard_form_renders_hx_headers_with_csrf_token: the wizard <form hx-ext='json-enc'> carries an hx-headers attribute whose X-CSRF-Token value equals the cookie value the browser holds. 3. test_wizard_step_submission_sends_csrf_header: when the form is submitted via htmx the actual XHR request carries an X-CSRF-Token header matching the cookie -- end-to-end proof that hx-headers reaches the network layer. 4. test_post_without_csrf_token_is_rejected: a same-origin fetch from inside the page context that omits both header and form field gets 403 from the middleware -- proves enforcement, not just rendering. Plus test_invite_register_has_hidden_csrf_field for the classic forms path; skipped in the test app because the invite key is not configured in the test fixture. 4 passed + 1 skipped in 6.6s.
1 parent f6bb465 commit b1934c3

1 file changed

Lines changed: 176 additions & 0 deletions

File tree

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
"""End-to-end CSRF tests with a real browser.
2+
3+
Verifies the server-rendered CSRF protection actually works the way the
4+
unit tests claim: token gets rendered into the page from the same request
5+
that mints the cookie, htmx submissions carry the header, classic forms
6+
carry the hidden field, the cookie is HttpOnly (no JS access).
7+
"""
8+
9+
from __future__ import annotations
10+
11+
from typing import TYPE_CHECKING
12+
13+
import pytest
14+
15+
if TYPE_CHECKING:
16+
from playwright.sync_api import Page
17+
18+
pytestmark = pytest.mark.e2e
19+
20+
21+
CSRF_COOKIE_NAME = "csrf_token"
22+
CSRF_HEADER_NAME = "x-csrf-token"
23+
24+
25+
# ---------------------------------------------------------------------------
26+
# Cookie is HttpOnly
27+
# ---------------------------------------------------------------------------
28+
29+
30+
def test_csrf_cookie_is_httponly(app_server: str, auth_page: Page) -> None:
31+
"""The csrf_token cookie must be HttpOnly so JavaScript cannot read it.
32+
33+
Templates render the token server-side via {{ request.state.csrf_token }};
34+
no client-side code needs to read it from the cookie.
35+
"""
36+
auth_page.goto(app_server)
37+
38+
# Read all cookies set on this origin via the BrowserContext (which sees
39+
# HttpOnly cookies); then via document.cookie (which does NOT see them).
40+
server_visible = {c["name"]: c for c in auth_page.context.cookies()}
41+
js_visible = auth_page.evaluate("document.cookie")
42+
43+
assert CSRF_COOKIE_NAME in server_visible, "CSRF cookie was not set by the server"
44+
cookie = server_visible[CSRF_COOKIE_NAME]
45+
assert cookie["httpOnly"] is True, f"CSRF cookie must be HttpOnly, got {cookie}"
46+
assert CSRF_COOKIE_NAME not in js_visible, f"CSRF cookie must NOT appear in document.cookie; got: {js_visible}"
47+
48+
49+
# ---------------------------------------------------------------------------
50+
# Wizard form carries hx-headers with the same token as the cookie
51+
# ---------------------------------------------------------------------------
52+
53+
54+
def test_wizard_form_renders_hx_headers_with_csrf_token(app_server: str, auth_page: Page) -> None:
55+
"""The wizard's <form hx-ext='json-enc'> must carry an hx-headers
56+
attribute containing X-CSRF-Token equal to the cookie value, so the
57+
next htmx POST passes the middleware's double-submit check.
58+
"""
59+
auth_page.goto(f"{app_server}/forms/wizard/create-project")
60+
auth_page.wait_for_load_state("networkidle")
61+
62+
# Pull the cookie value the browser holds (BrowserContext sees HttpOnly).
63+
cookie_value = next(c["value"] for c in auth_page.context.cookies() if c["name"] == CSRF_COOKIE_NAME)
64+
assert cookie_value, "CSRF cookie value empty"
65+
66+
# Read the hx-headers attribute on the wizard form.
67+
hx_headers = auth_page.locator("#wizard-step-form").get_attribute("hx-headers")
68+
assert hx_headers is not None, "wizard form is missing hx-headers"
69+
assert "X-CSRF-Token" in hx_headers, f"hx-headers does not name X-CSRF-Token: {hx_headers}"
70+
assert cookie_value in hx_headers, (
71+
f"hx-headers token does not match cookie value; hx-headers={hx_headers!r} cookie={cookie_value!r}"
72+
)
73+
74+
75+
def test_wizard_step_submission_sends_csrf_header(app_server: str, auth_page: Page) -> None:
76+
"""When the wizard form is submitted via htmx, the actual XHR request
77+
must carry an X-CSRF-Token header that matches the cookie. This is the
78+
end-to-end verification that hx-headers reaches the network layer.
79+
"""
80+
auth_page.goto(f"{app_server}/forms/wizard/create-project")
81+
auth_page.wait_for_load_state("networkidle")
82+
83+
captured: dict[str, str | None] = {"header": None}
84+
85+
def _on_request(request) -> None:
86+
if request.method == "POST" and "/forms/wizard/" in request.url:
87+
captured["header"] = request.headers.get(CSRF_HEADER_NAME)
88+
89+
auth_page.on("request", _on_request)
90+
91+
# Fill the project name minimally and submit the first step. Whether or
92+
# not validation passes is irrelevant -- we only need the request to fire
93+
# so we can inspect the header it carried.
94+
name_field = auth_page.locator("[name='name']").first
95+
if name_field.count() > 0:
96+
name_field.fill("e2e-csrf-test")
97+
auth_page.locator("button[type='submit']").first.click()
98+
auth_page.wait_for_load_state("networkidle")
99+
100+
assert captured["header"] is not None, "wizard step POST did NOT include X-CSRF-Token header"
101+
102+
cookie_value = next(c["value"] for c in auth_page.context.cookies() if c["name"] == CSRF_COOKIE_NAME)
103+
assert captured["header"] == cookie_value, (
104+
f"X-CSRF-Token header does not match cookie; header={captured['header']!r} cookie={cookie_value!r}"
105+
)
106+
107+
108+
# ---------------------------------------------------------------------------
109+
# Classic <form method=POST> carries a hidden csrf_token input
110+
# ---------------------------------------------------------------------------
111+
112+
113+
def test_invite_register_has_hidden_csrf_field(app_server: str, page: Page) -> None:
114+
"""The invite-register form must include a hidden csrf_token input that
115+
matches the cookie. This is the form-encoded path -- no JS, no headers.
116+
117+
Uses the unauthenticated `page` fixture because invite-register is the
118+
pre-login flow. We need an invite key that the server will accept; in
119+
the test app this is unconfigured, so this test verifies the rendering
120+
contract on whichever response we get back.
121+
"""
122+
# In the test app, the invite key is invalid -- but we still want to see
123+
# what's rendered. If we get the form back, the hidden field must be
124+
# there with the cookie value.
125+
page.goto(f"{app_server}/invite/test-invite-key", wait_until="networkidle")
126+
127+
# If we got the register form (not an error page), check the hidden field.
128+
form = page.locator("form[action*='/invite/'][action*='/register']")
129+
if form.count() == 0:
130+
pytest.skip("Invite key not configured in test app; cannot exercise the form path here.")
131+
132+
hidden = form.locator("input[name='csrf_token']")
133+
assert hidden.count() == 1, "invite-register form is missing hidden csrf_token input"
134+
rendered = hidden.get_attribute("value")
135+
assert rendered, "hidden csrf_token has empty value"
136+
137+
cookie_value = next(c["value"] for c in page.context.cookies() if c["name"] == CSRF_COOKIE_NAME)
138+
assert rendered == cookie_value, (
139+
f"hidden field value does not match cookie; field={rendered!r} cookie={cookie_value!r}"
140+
)
141+
142+
143+
# ---------------------------------------------------------------------------
144+
# Negative: a forged POST without the token is rejected (proves the
145+
# middleware actually rejects, not just that the server sends the header)
146+
# ---------------------------------------------------------------------------
147+
148+
149+
def test_post_without_csrf_token_is_rejected(app_server: str, auth_page: Page) -> None:
150+
"""A POST that omits both the X-CSRF-Token header and the csrf_token
151+
form field must be rejected with 403, even when the session cookie is
152+
present.
153+
154+
This is the proof that the middleware actually enforces -- without it,
155+
all the rendering work is theatre. Done via page.evaluate so the fetch
156+
runs inside the browser context and inherits the session cookie that
157+
auth_page already established.
158+
"""
159+
auth_page.goto(app_server)
160+
161+
# Issue a same-origin POST from the page itself so the session cookie
162+
# is included. Deliberately omit BOTH the X-CSRF-Token header and the
163+
# csrf_token form field; the middleware must reject it.
164+
status = auth_page.evaluate(
165+
"""async () => {
166+
const r = await fetch('/forms/wizard/create-project/step/identity', {
167+
method: 'POST',
168+
headers: {'Content-Type': 'application/json'},
169+
body: '{}',
170+
credentials: 'same-origin',
171+
});
172+
return r.status;
173+
}"""
174+
)
175+
176+
assert status == 403, f"POST without CSRF token must be rejected; got status={status}"

0 commit comments

Comments
 (0)