"""Invite redemption: the link an admin hands out must actually work. Covers the full loop — create the invite, preview it as the invitee, redeem it, and the three ways redemption can legitimately fail. Also pins the absolute invite URL, since a bare path is not clickable for the person receiving it. """ from __future__ import annotations from httpx import AsyncClient from tests.conftest import auth_header, signup async def _owner_with_org(client: AsyncClient, name: str = "Acme") -> tuple[str, str]: """``(token, org_id)`` for a freshly signed-up owner.""" body = await signup(client, "owner@example.com", name="Owner") headers = auth_header(body["access_token"]) org = await client.post("/api/organizations", json={"name": name}, headers=headers) assert org.status_code == 201, org.text return body["access_token"], org.json()["id"] async def _invite(client: AsyncClient, token: str, org_id: str, email: str, role: str) -> dict: response = await client.post( f"/api/organizations/{org_id}/invites", json={"email": email, "role": role}, headers=auth_header(token), ) assert response.status_code == 201, response.text return response.json() async def test_invite_url_is_absolute_and_points_at_the_accept_screen(client: AsyncClient): token, org_id = await _owner_with_org(client) invite = await _invite(client, token, org_id, "newbie@example.com", "member") # Relative would resolve against whatever origin the link is pasted on. assert invite["invite_url"].startswith("http") assert invite["invite_url"].endswith(f"/accept-invite?token={invite['invite_token']}") async def test_invitee_can_preview_then_accept(client: AsyncClient): owner_token, org_id = await _owner_with_org(client) invite = await _invite(client, owner_token, org_id, "newbie@example.com", "manager") raw = invite["invite_token"] invitee = await signup(client, "newbie@example.com", name="Newbie") headers = auth_header(invitee["access_token"]) preview = await client.get(f"/api/invites/{raw}", headers=headers) assert preview.status_code == 200, preview.text assert preview.json()["organization_name"] == "Acme" assert preview.json()["role"] == "manager" assert preview.json()["already_member"] is False accepted = await client.post("/api/invites/accept", json={"token": raw}, headers=headers) assert accepted.status_code == 200, accepted.text assert accepted.json()["organization_id"] == org_id me = await client.get("/api/auth/me", headers=headers) joined = [o for o in me.json()["organizations"] if o["id"] == org_id] assert joined and joined[0]["role"] == "manager" async def test_invite_token_is_single_use(client: AsyncClient): owner_token, org_id = await _owner_with_org(client) raw = (await _invite(client, owner_token, org_id, "newbie@example.com", "member"))[ "invite_token" ] invitee = await signup(client, "newbie@example.com") headers = auth_header(invitee["access_token"]) first = await client.post("/api/invites/accept", json={"token": raw}, headers=headers) assert first.status_code == 200 second = await client.post("/api/invites/accept", json={"token": raw}, headers=headers) assert second.status_code == 409 assert second.json()["error"]["code"] == "conflict" async def test_invite_cannot_be_redeemed_by_a_different_account(client: AsyncClient): owner_token, org_id = await _owner_with_org(client) raw = (await _invite(client, owner_token, org_id, "newbie@example.com", "member"))[ "invite_token" ] intruder = await signup(client, "intruder@example.com") headers = auth_header(intruder["access_token"]) response = await client.get(f"/api/invites/{raw}", headers=headers) assert response.status_code == 403 assert response.json()["error"]["details"]["invite_email"] == "newbie@example.com" # The token is not burned by the failed attempt — the real invitee can # still use it. invitee = await signup(client, "newbie@example.com") ok = await client.post( "/api/invites/accept", json={"token": raw}, headers=auth_header(invitee["access_token"]), ) assert ok.status_code == 200 async def test_unknown_invite_token_is_not_found(client: AsyncClient): user = await signup(client, "nobody@example.com") response = await client.get( "/api/invites/definitely-not-a-real-token", headers=auth_header(user["access_token"]), ) assert response.status_code == 404 async def test_accepting_an_invite_requires_authentication(client: AsyncClient): owner_token, org_id = await _owner_with_org(client) raw = (await _invite(client, owner_token, org_id, "newbie@example.com", "member"))[ "invite_token" ] response = await client.post("/api/invites/accept", json={"token": raw}) assert response.status_code == 401