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