intent/tests/test_accounts.py
Alec Perkins 99f810d043 Harden auth, invites and deployment after security review
- Signed-out visitors get the same login redirect for missing and
  private projects, so the redirect no longer reveals private ones.
- Production refuses the console email backend; sign-in codes must
  never reach the logs.
- Database cache shared by all workers for allauth rate limits; invite
  sending throttled per sender and per recipient.
- Email invite links are claimed on first use and the token rotated,
  so a token copied from request logs is worthless.
- allauth password URLs removed; adding an email requires a fresh code
  and notifies existing addresses.
- Markdown drops remote images until uploads exist.
- Project and org slugs lowercased; only superusers can grant staff
  status or permissions in the admin.
- Container runs as a non-root user; CI actions pinned to commits.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-27 08:42:19 -04:00

77 lines
2.7 KiB
Python

import re
import pytest
from django.core import mail
from django.core.exceptions import ValidationError
from django.urls import NoReverseMatch, reverse
from intent.accounts.validators import username_validators
def _sign_in_with_code(client, email):
client.post(reverse("account_request_login_code"), {"email": email})
code = re.search(r"^\s*([A-Z0-9-]{6,})\s*$", mail.outbox[-1].body, re.MULTILINE).group(1)
return client.post(reverse("account_confirm_login_code"), {"code": code})
def test_login_page_offers_code_not_password(client, db):
html = client.get(reverse("account_login")).content.decode()
assert 'type="password"' not in html
assert reverse("account_request_login_code") in html
def test_signup_has_no_password(client, db):
html = client.get(reverse("account_signup")).content.decode()
assert 'type="password"' not in html
def test_sign_in_with_emailed_code(client, make_user):
user = make_user("ada")
response = _sign_in_with_code(client, "ada@example.com")
assert response.status_code == 302
assert client.session["_auth_user_id"] == user.pk
def test_staff_cannot_sign_in_through_allauth(client, make_user):
make_user("root", is_staff=True)
response = _sign_in_with_code(client, "root@example.com")
assert response["Location"].startswith(reverse("admin:login"))
assert "_auth_user_id" not in client.session
@pytest.mark.parametrize("value", ["ada", "ada-lovelace", "a_1"])
def test_valid_usernames(value):
for validator in username_validators:
validator(value)
@pytest.mark.parametrize("value", ["ada@example.com", "-ada", "ada.l", "x" * 40])
def test_invalid_usernames(value):
with pytest.raises(ValidationError):
for validator in username_validators:
validator(value)
@pytest.mark.parametrize(
"name", ["account_set_password", "account_change_password", "account_reset_password"]
)
def test_password_urls_do_not_exist(name):
with pytest.raises(NoReverseMatch):
reverse(name)
def test_signed_in_pages_without_passwords_render(client, owner):
client.force_login(owner)
assert client.get(reverse("account_email")).status_code == 200
assert client.get("/accounts/password/set/").status_code == 404
def test_staff_cannot_grant_staff_without_superuser(client, make_user):
from django.contrib.auth.models import Permission
staff = make_user("stan", is_staff=True)
staff.user_permissions.add(*Permission.objects.filter(content_type__app_label="accounts"))
client.force_login(staff)
target = make_user("tina")
page = client.get(reverse("admin:accounts_user_change", args=[target.pk])).content.decode()
assert 'name="is_superuser"' not in page and 'name="is_staff"' not in page