- 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>
100 lines
3 KiB
Python
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
|