2026-10-27 07:10:27 -04:00
|
|
|
import re
|
|
|
|
|
|
2026-10-27 08:42:19 -04:00
|
|
|
import pytest
|
2026-10-27 07:10:27 -04:00
|
|
|
from django.core import mail
|
2026-10-27 08:42:19 -04:00
|
|
|
from django.core.exceptions import ValidationError
|
2026-10-27 07:10:27 -04:00
|
|
|
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"))
|
|
|
|
|
|
2026-10-27 08:42:19 -04:00
|
|
|
# Bearer link: any signed-in account can claim it, whatever its email.
|
2026-10-27 07:10:27 -04:00
|
|
|
joiner = make_user("june", email="june@elsewhere.example")
|
|
|
|
|
client.force_login(joiner)
|
2026-10-27 08:42:19 -04:00
|
|
|
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"})
|
2026-10-27 07:10:27 -04:00
|
|
|
assert org.memberships.filter(user=joiner).exists()
|
2026-10-27 08:42:19 -04:00
|
|
|
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")
|
2026-10-27 07:10:27 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|