- 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>
188 lines
7.6 KiB
Python
188 lines
7.6 KiB
Python
"""Phase 5: rate limits, security headers, moderation, the admin's second factor, email."""
|
|
|
|
import io
|
|
|
|
import pytest
|
|
from django.core.exceptions import ValidationError
|
|
from django.core.management import CommandError, call_command
|
|
from django.urls import reverse
|
|
from django_otp.oath import totp
|
|
from django_otp.plugins.otp_totp.models import TOTPDevice
|
|
|
|
from intent.accounts.admin import IntentUserAdmin, orgs_left_without_owner
|
|
from intent.accounts.models import User
|
|
from intent.comments import services as comment_services
|
|
from intent.core import throttle as limits
|
|
from intent.features.services import create_feature
|
|
from intent.issues.services import create_issue
|
|
|
|
|
|
def test_comment_rate_limit(open_project, owner, outsider, monkeypatch):
|
|
monkeypatch.setattr(limits, "COMMENTS", (2, 60))
|
|
feature = create_feature(open_project, owner, title="F")
|
|
thread = comment_services.start_feature_thread(feature, outsider, body="1")
|
|
comment_services.reply(thread, outsider, body="2")
|
|
with pytest.raises(ValidationError, match="too often"):
|
|
comment_services.reply(thread, outsider, body="3")
|
|
comment_services.reply(thread, owner, body="Others aren't affected")
|
|
|
|
|
|
def test_issue_rate_limit(open_project, outsider, monkeypatch):
|
|
monkeypatch.setattr(limits, "ISSUES", (1, 60))
|
|
create_issue(open_project, outsider, title="One")
|
|
with pytest.raises(ValidationError, match="too often"):
|
|
create_issue(open_project, outsider, title="Two")
|
|
|
|
|
|
def test_rate_limited_reply_keeps_the_draft(client, open_project, owner, outsider, monkeypatch):
|
|
monkeypatch.setattr(limits, "COMMENTS", (1, 60))
|
|
feature = create_feature(open_project, owner, title="F")
|
|
thread = comment_services.start_feature_thread(feature, outsider, body="1")
|
|
client.force_login(outsider)
|
|
url = reverse(
|
|
"comments:reply",
|
|
kwargs={"org_slug": "catbriar", "project_slug": "intent", "thread_id": thread.pk},
|
|
)
|
|
response = client.post(url, {"body": "My long reply"}, headers={"HX-Request": "true"})
|
|
assert b"too often" in response.content and b"My long reply" in response.content
|
|
|
|
|
|
def test_security_headers(client, db):
|
|
response = client.get(reverse("home"))
|
|
csp = response["Content-Security-Policy"]
|
|
assert "script-src 'self'" in csp and "'unsafe-inline'" not in csp
|
|
assert "object-src 'none'" in csp and "frame-ancestors 'self'" in csp
|
|
assert "camera=()" in response["Permissions-Policy"]
|
|
assert response["X-Content-Type-Options"] == "nosniff"
|
|
assert response["Referrer-Policy"] == "same-origin"
|
|
|
|
|
|
def test_bucket_origin_is_allowed_for_images_and_frames(settings, client, db):
|
|
from intent.core.middleware import _content_security_policy
|
|
|
|
settings.CSP_BUCKET_ORIGIN = "https://intent-files.fly.storage.tigris.dev"
|
|
csp = _content_security_policy()
|
|
assert "img-src 'self' data: https://intent-files.fly.storage.tigris.dev" in csp
|
|
assert "frame-src 'self' https://intent-files.fly.storage.tigris.dev" in csp
|
|
|
|
|
|
# Moderation
|
|
|
|
|
|
def test_deactivated_user_is_signed_out_and_content_stays(client, open_project, owner, outsider):
|
|
issue = create_issue(open_project, outsider, title="Mine")
|
|
client.force_login(outsider)
|
|
assert client.get(reverse("invites:list")).status_code == 200
|
|
outsider.is_active = False
|
|
outsider.save()
|
|
assert client.get(reverse("invites:list")).status_code == 302 # Treated as signed out.
|
|
issue.refresh_from_db()
|
|
assert issue.author == outsider
|
|
|
|
|
|
def test_deleted_users_content_shows_as_ghost(client, open_project, owner, outsider):
|
|
issue = create_issue(open_project, outsider, title="Orphan")
|
|
outsider.delete()
|
|
client.force_login(owner)
|
|
page = client.get(
|
|
reverse(
|
|
"issues:detail",
|
|
kwargs={"org_slug": "catbriar", "project_slug": "intent", "number": issue.number},
|
|
)
|
|
).content.decode()
|
|
assert "Opened by ghost" in page
|
|
|
|
|
|
def test_ghost_and_official_names_are_reserved(client, db):
|
|
response = client.post(
|
|
reverse("account_signup"), {"email": "g@example.com", "username": "Ghost"}
|
|
)
|
|
assert b"reserved" in response.content
|
|
assert not User.objects.filter(username__iexact="ghost").exists()
|
|
|
|
|
|
def test_last_owner_cannot_be_deleted_in_admin(rf, owner, org, make_user):
|
|
admin_user = User.objects.create_superuser("sam", "sam@example.com", "pw-long-enough-1")
|
|
request = rf.get("/")
|
|
request.user = admin_user
|
|
from django.contrib import admin
|
|
|
|
model_admin = IntentUserAdmin(User, admin.site)
|
|
assert [o.slug for o in orgs_left_without_owner([owner])] == ["catbriar"]
|
|
assert not model_admin.has_delete_permission(request, owner)
|
|
assert "delete_selected" not in model_admin.get_actions(request)
|
|
second = make_user("otto")
|
|
org.memberships.create(user=second, role="owner")
|
|
assert model_admin.has_delete_permission(request, owner)
|
|
|
|
|
|
def test_deactivate_action_ends_sessions(client, outsider, rf):
|
|
from django.contrib.sessions.models import Session
|
|
|
|
client.force_login(outsider)
|
|
assert Session.objects.count() == 1
|
|
admin_user = User.objects.create_superuser("sam", "sam@example.com", "pw-long-enough-1")
|
|
request = rf.post("/")
|
|
request.user = admin_user
|
|
request._messages = type("M", (), {"add": lambda *a, **k: None})()
|
|
IntentUserAdmin(User, None).deactivate(request, User.objects.filter(pk=outsider.pk))
|
|
outsider.refresh_from_db()
|
|
assert not outsider.is_active and Session.objects.count() == 0
|
|
|
|
|
|
# Admin second factor
|
|
|
|
|
|
def test_admin_needs_a_verified_device(client, db):
|
|
staff = User.objects.create_superuser("sam", "sam@example.com", "pw-long-enough-1")
|
|
client.force_login(staff) # Password alone, no device.
|
|
response = client.get(reverse("admin:index"))
|
|
assert response.status_code == 302 and "/admin/login/" in response["Location"]
|
|
|
|
|
|
def test_admin_login_with_password_and_code(client, db):
|
|
staff = User.objects.create_superuser("sam", "sam@example.com", "pw-long-enough-1")
|
|
device = TOTPDevice.objects.create(user=staff, name="Phone", confirmed=True)
|
|
login = reverse("admin:login")
|
|
bad = client.post(
|
|
login, {"username": "sam", "password": "pw-long-enough-1", "otp_token": "000000"}
|
|
)
|
|
assert bad.status_code == 200 and not client.get(reverse("admin:index")).status_code == 200
|
|
code = f"{totp(device.bin_key, device.step, device.t0, device.digits):06d}"
|
|
client.post(
|
|
login,
|
|
{"username": "sam", "password": "pw-long-enough-1", "otp_device": device.persistent_id,
|
|
"otp_token": code},
|
|
) # fmt: skip
|
|
assert client.get(reverse("admin:index")).status_code == 200
|
|
|
|
|
|
def test_add_admin_totp_command(db, make_user):
|
|
staff = User.objects.create_superuser("sam", "sam@example.com", "pw-long-enough-1")
|
|
out = io.StringIO()
|
|
call_command("add_admin_totp", "sam", stdout=out)
|
|
assert "otpauth://totp/" in out.getvalue()
|
|
assert TOTPDevice.objects.filter(user=staff, confirmed=True).count() == 1
|
|
with pytest.raises(CommandError, match="already"):
|
|
call_command("add_admin_totp", "sam", stdout=io.StringIO())
|
|
call_command("add_admin_totp", "sam", "--replace", stdout=io.StringIO())
|
|
assert TOTPDevice.objects.filter(user=staff).count() == 1
|
|
with pytest.raises(CommandError, match="isn't staff"):
|
|
call_command("add_admin_totp", make_user("ruth").username, stdout=io.StringIO())
|
|
|
|
|
|
# Email
|
|
|
|
|
|
def test_anymail_settings_come_from_the_environment(monkeypatch):
|
|
import importlib
|
|
|
|
import config.settings.base as base
|
|
|
|
monkeypatch.setenv("ANYMAIL_POSTMARK_SERVER_TOKEN", "token-123")
|
|
reloaded = importlib.reload(base)
|
|
try:
|
|
assert reloaded.ANYMAIL == {"POSTMARK_SERVER_TOKEN": "token-123"}
|
|
finally:
|
|
monkeypatch.delenv("ANYMAIL_POSTMARK_SERVER_TOKEN")
|
|
importlib.reload(base)
|