intent/tests/test_org_views.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

142 lines
5.5 KiB
Python

import re
import pytest
from django.core import mail
from django.core.exceptions import ValidationError
from django.urls import reverse
from intent.orgs.models import InviteStatus, OrgInvite, Role
def members_url(org):
return reverse("orgs:members", kwargs={"org_slug": org.slug})
def test_org_page_is_public(client, org):
response = client.get(reverse("orgs:detail", kwargs={"org_slug": org.slug}))
assert response.status_code == 200
assert b"Catbriar" in response.content
def test_members_page_requires_membership(client, make_user, org):
assert client.get(members_url(org)).status_code == 403
client.force_login(make_user())
assert client.get(members_url(org)).status_code == 403
def test_collaborator_sees_members_but_cannot_invite(client, collaborator, org):
client.force_login(collaborator)
response = client.get(members_url(org))
assert response.status_code == 200
assert b"Invite someone" not in response.content
response = client.post(members_url(org), {"recipient": "x@example.com", "role": "owner"})
assert response.status_code == 403
def test_invite_by_username_then_accept_from_invitations_page(client, owner, make_user, org):
invitee = make_user("ivan")
client.force_login(owner)
response = client.post(members_url(org), {"recipient": "IVAN", "role": "collaborator"})
assert response.status_code == 302
invite = OrgInvite.objects.get(user=invitee)
assert mail.outbox == [] # in-app only; no notifications in V1
client.force_login(invitee)
assert b"Catbriar" in client.get(reverse("invites:list")).content
response = client.post(
reverse("invites:respond", kwargs={"invite_id": invite.pk}), {"action": "accept"}
)
assert response.status_code == 302
assert org.memberships.filter(user=invitee, role=Role.COLLABORATOR).exists()
def test_cannot_respond_to_someone_elses_invite(client, owner, make_user, org):
invite = OrgInvite.objects.create(org=org, user=make_user(), invited_by=owner, token_hash="x")
client.force_login(make_user())
response = client.post(
reverse("invites:respond", kwargs={"invite_id": invite.pk}), {"action": "accept"}
)
assert response.status_code == 403
def test_email_invite_link_flow(client, owner, make_user, org):
client.force_login(owner)
client.post(members_url(org), {"recipient": "new@example.com", "role": "collaborator"})
assert len(mail.outbox) == 1
path = re.search(r"https?://[^/]+(/invites/t/\S+/)", mail.outbox[0].body).group(1)
client.logout()
response = client.get(path)
assert response.status_code == 302
assert response["Location"].startswith(reverse("account_login"))
# Bearer link: any signed-in account can claim it, whatever its email.
joiner = make_user("june", email="june@elsewhere.example")
client.force_login(joiner)
invite = OrgInvite.objects.get(email="new@example.com")
show = reverse("invites:show", kwargs={"invite_id": invite.pk})
response = client.get(path)
assert response.status_code == 302 and response["Location"] == show
assert client.get(show).status_code == 200
assert invite in list(client.get(reverse("invites:list")).context["invites"])
# Once claimed, the link is dead for everyone else, even someone with the invited email.
other = make_user("newbie", email="new@example.com")
client.force_login(other)
assert client.get(path).status_code == 404
assert client.get(show).status_code == 403
assert client.get(reverse("invites:list")).context["invites"].count() == 0
client.force_login(joiner)
assert client.get(path).status_code == 404 # Token was rotated on claim.
client.post(reverse("invites:respond", kwargs={"invite_id": invite.pk}), {"action": "accept"})
assert org.memberships.filter(user=joiner).exists()
invite.refresh_from_db()
assert invite.status == InviteStatus.ACCEPTED and invite.accepted_by == joiner
def test_invite_sending_is_throttled(owner, org):
from intent.orgs import services
for _ in range(3):
services.invite(org, owner, email="same@example.com")
with pytest.raises(ValidationError, match="Too many"):
services.invite(org, owner, email="same@example.com")
# A different address still works; the per-sender cap is higher.
services.invite(org, owner, email="other@example.com")
def test_invalid_token_is_404(client, make_user):
client.force_login(make_user())
assert client.get(reverse("invites:by_token", kwargs={"token": "nope"})).status_code == 404
def test_owner_updates_role_and_grant(client, owner, collaborator, org):
membership = org.memberships.get(user=collaborator)
client.force_login(owner)
client.post(
reverse(
"orgs:member_update", kwargs={"org_slug": org.slug, "membership_id": membership.pk}
),
{"role": "collaborator", "can_create_projects": "on"},
)
membership.refresh_from_db()
assert membership.can_create_projects
def test_last_owner_leaving_shows_error(client, owner, org):
membership = org.memberships.get(user=owner)
client.force_login(owner)
response = client.post(
reverse(
"orgs:member_remove", kwargs={"org_slug": org.slug, "membership_id": membership.pk}
),
follow=True,
)
assert b"at least one owner" in response.content
assert org.memberships.filter(user=owner).exists()
def test_home_lists_orgs_and_invites(client, owner, make_user, org):
client.force_login(owner)
assert b"Catbriar" in client.get("/").content