intent/tests/browser/conftest.py
Alec Perkins 604c2bbf91
Some checks failed
ci / test (push) Failing after 1m6s
ci / deploy (push) Failing after 0s
Harden for launch: limits, CSP, moderation, admin 2FA (Phase 5)
- Rate limits in the shared cache for comments, issues, uploads and
  invites; a limited HTMX reply keeps the draft.
- Content-Security-Policy (self only, no inline script or style, bucket
  origin for images and frames) and Permissions-Policy on every page.
  htmx configured not to inject styles or eval.
- Moderation: deactivating a user ends their sessions and keeps their
  content; deleted accounts show as "ghost"; reserved usernames; the
  admin won't delete an org's last owner.
- Admin requires a TOTP code (django-otp), with add_admin_totp to enrol.
- Email provider chosen by environment through django-anymail.
- Playwright browser tests: axe WCAG 2.1 AA on every page in light and
  dark, no CSP violations, keyboard walkthrough, theme toggle. Explicit
  Canvas/CanvasText on :root fixes dark-mode contrast detection.
- bin/start creates the data dir if missing; docs/operations.md runbook.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 10:51:06 -04:00

100 lines
3 KiB
Python

"""Real-browser tests: Playwright drives Chromium against Django's live server.
Needs the browser once per machine: `uv run playwright install chromium`. Without it these
tests skip, so the rest of the suite still runs anywhere.
"""
import os
from pathlib import Path
import pytest
from django.conf import settings
# Playwright's sync API runs an event loop; the ORM calls in fixtures are still synchronous.
os.environ.setdefault("DJANGO_ALLOW_ASYNC_UNSAFE", "true")
from playwright.sync_api import Error as PlaywrightError # noqa: E402
from playwright.sync_api import sync_playwright # noqa: E402
AXE = (Path(__file__).parent.parent / "vendor" / "axe.min.js").read_text()
@pytest.fixture(scope="session")
def browser():
with sync_playwright() as p:
try:
browser = p.chromium.launch()
except PlaywrightError as exc:
pytest.skip(f"Chromium isn't installed (uv run playwright install chromium): {exc}")
yield browser
browser.close()
@pytest.fixture
def page(browser, live_server):
"""A fresh page. `page.problems` collects script errors and CSP violations.
The CSP is enforced, except on `axe_page`, where axe has to be injected inline.
"""
context = browser.new_context(base_url=live_server.url)
page = context.new_page()
page.problems = []
page.on("pageerror", lambda e: page.problems.append(f"script error: {e}"))
page.on(
"console",
lambda m: (
page.problems.append(f"console: {m.text}")
if m.type == "error" and "Content Security Policy" in m.text
else None
),
)
yield page
context.close()
@pytest.fixture
def axe_page(browser, live_server):
context = browser.new_context(base_url=live_server.url, bypass_csp=True)
page = context.new_page()
yield page
context.close()
def share_session(client, page, live_server, user):
client.force_login(user)
page.context.add_cookies(
[
{
"name": settings.SESSION_COOKIE_NAME,
"value": client.cookies[settings.SESSION_COOKIE_NAME].value,
"url": live_server.url,
}
]
)
@pytest.fixture
def sign_in(client, page, live_server):
"""Sign the browser in as `user` by sharing a test-client session."""
def as_user(user):
share_session(client, page, live_server, user)
return as_user
def axe_violations(page, scheme: str) -> list[str]:
"""Run axe on the current page with WCAG 2.1 A and AA rules."""
page.emulate_media(color_scheme=scheme)
page.add_script_tag(content=AXE)
result = page.evaluate(
"""async () => {
const r = await axe.run(document, {
runOnly: {type: "tag", values: ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]},
});
return r.violations.map(v =>
`${v.id} (${v.impact}): ${v.help} — ${v.nodes.slice(0, 3)
.map(n => n.target.join(" ")).join(", ")}`);
}"""
)
return result