File
Blob: tests/worker/routes/invite-accept.workers.test.ts
| 1 | import { and, eq, inArray } from "drizzle-orm"; |
| 2 | import { beforeEach, describe, expect, it } from "vitest"; |
| 3 | |
| 4 | import { invites, memberships } from "@/worker/db/d1/schema"; |
| 5 | import { getDb, resetD1Tables } from "@tests/worker/helpers/db"; |
| 6 | import { apiRequest, PROD_ORIGIN } from "@tests/worker/helpers/request"; |
| 7 | import { seedInvite, seedMembership, seedTesseraIdentity, seedUser, seedWorkspace } from "@tests/worker/helpers/seeds"; |
| 8 | |
| 9 | interface AcceptResponse { |
| 10 | workspace_id: string; |
| 11 | accessToken: string; |
| 12 | already_member?: boolean; |
| 13 | } |
| 14 | |
| 15 | describe("POST /invite/:token/accept - conditional acceptance gate", () => { |
| 16 | beforeEach(async () => { |
| 17 | await resetD1Tables(); |
| 18 | }); |
| 19 | |
| 20 | it("only one of two concurrent acceptances writes a membership row", async () => { |
| 21 | const inviter = await seedUser(); |
| 22 | const userA = await seedUser(); |
| 23 | const userB = await seedUser(); |
| 24 | await seedTesseraIdentity({ sub: "sub-a", user_id: userA.id }); |
| 25 | await seedTesseraIdentity({ sub: "sub-b", user_id: userB.id }); |
| 26 | const ws = await seedWorkspace({ owner_id: inviter.id }); |
| 27 | const invite = await seedInvite({ |
| 28 | workspace_id: ws.id, |
| 29 | invited_by: inviter.id, |
| 30 | role: "member", |
| 31 | email: null, |
| 32 | accepted_at: null, |
| 33 | revoked_at: null, |
| 34 | }); |
| 35 | |
| 36 | const [resA, resB] = await Promise.all([ |
| 37 | apiRequest(`/api/v1/invite/${invite.token}/accept`, { |
| 38 | method: "POST", |
| 39 | userId: userA.id, |
| 40 | body: {}, |
| 41 | }), |
| 42 | apiRequest(`/api/v1/invite/${invite.token}/accept`, { |
| 43 | method: "POST", |
| 44 | userId: userB.id, |
| 45 | body: {}, |
| 46 | }), |
| 47 | ]); |
| 48 | |
| 49 | const statuses = [resA.status, resB.status].sort(); |
| 50 | expect(statuses).toEqual([200, 410]); |
| 51 | |
| 52 | const memberRows = await getDb() |
| 53 | .select() |
| 54 | .from(memberships) |
| 55 | .where(and(eq(memberships.workspace_id, ws.id), inArray(memberships.user_id, [userA.id, userB.id]))) |
| 56 | .all(); |
| 57 | expect(memberRows).toHaveLength(1); |
| 58 | |
| 59 | const finalInvite = await getDb().select().from(invites).where(eq(invites.id, invite.id)).get(); |
| 60 | expect(finalInvite?.accepted_by).toBe(memberRows[0].user_id); |
| 61 | expect(finalInvite?.accepted_at).not.toBeNull(); |
| 62 | }); |
| 63 | |
| 64 | it("returns 200 already_member when an existing member accepts an open invite", async () => { |
| 65 | const inviter = await seedUser(); |
| 66 | const member = await seedUser(); |
| 67 | const ws = await seedWorkspace({ owner_id: inviter.id }); |
| 68 | await seedMembership({ user_id: member.id, workspace_id: ws.id, role: "member" }); |
| 69 | const invite = await seedInvite({ |
| 70 | workspace_id: ws.id, |
| 71 | invited_by: inviter.id, |
| 72 | role: "member", |
| 73 | email: null, |
| 74 | }); |
| 75 | |
| 76 | const res = await apiRequest(`/api/v1/invite/${invite.token}/accept`, { |
| 77 | method: "POST", |
| 78 | userId: member.id, |
| 79 | body: {}, |
| 80 | }); |
| 81 | |
| 82 | expect(res.status).toBe(200); |
| 83 | const body = (await res.json()) as AcceptResponse; |
| 84 | expect(body.already_member).toBe(true); |
| 85 | |
| 86 | const memberRows = await getDb() |
| 87 | .select() |
| 88 | .from(memberships) |
| 89 | .where(and(eq(memberships.workspace_id, ws.id), eq(memberships.user_id, member.id))) |
| 90 | .all(); |
| 91 | expect(memberRows).toHaveLength(1); |
| 92 | }); |
| 93 | |
| 94 | it("returns 410 gone for an already-accepted invite without overwriting accepted_by", async () => { |
| 95 | const inviter = await seedUser(); |
| 96 | const winner = await seedUser(); |
| 97 | const loser = await seedUser(); |
| 98 | const ws = await seedWorkspace({ owner_id: inviter.id }); |
| 99 | await seedMembership({ user_id: winner.id, workspace_id: ws.id, role: "member" }); |
| 100 | const invite = await seedInvite({ |
| 101 | workspace_id: ws.id, |
| 102 | invited_by: inviter.id, |
| 103 | role: "member", |
| 104 | email: null, |
| 105 | accepted_at: "2026-04-23T00:00:00.000Z", |
| 106 | accepted_by: winner.id, |
| 107 | }); |
| 108 | |
| 109 | const res = await apiRequest(`/api/v1/invite/${invite.token}/accept`, { |
| 110 | method: "POST", |
| 111 | userId: loser.id, |
| 112 | body: {}, |
| 113 | }); |
| 114 | |
| 115 | expect(res.status).toBe(410); |
| 116 | }); |
| 117 | |
| 118 | it("returns 410 for a revoked invite without writing membership or accepted state", async () => { |
| 119 | const inviter = await seedUser(); |
| 120 | const caller = await seedUser(); |
| 121 | const ws = await seedWorkspace({ owner_id: inviter.id }); |
| 122 | const invite = await seedInvite({ |
| 123 | workspace_id: ws.id, |
| 124 | invited_by: inviter.id, |
| 125 | role: "member", |
| 126 | email: null, |
| 127 | revoked_at: "2026-04-22T00:00:00.000Z", |
| 128 | }); |
| 129 | |
| 130 | const res = await apiRequest(`/api/v1/invite/${invite.token}/accept`, { |
| 131 | method: "POST", |
| 132 | userId: caller.id, |
| 133 | body: {}, |
| 134 | }); |
| 135 | |
| 136 | expect(res.status).toBe(410); |
| 137 | }); |
| 138 | |
| 139 | it("returns 410 for an expired invite", async () => { |
| 140 | const inviter = await seedUser(); |
| 141 | const caller = await seedUser(); |
| 142 | const ws = await seedWorkspace({ owner_id: inviter.id }); |
| 143 | const invite = await seedInvite({ |
| 144 | workspace_id: ws.id, |
| 145 | invited_by: inviter.id, |
| 146 | role: "member", |
| 147 | email: null, |
| 148 | expires_at: "2020-01-01T00:00:00.000Z", |
| 149 | }); |
| 150 | |
| 151 | const res = await apiRequest(`/api/v1/invite/${invite.token}/accept`, { |
| 152 | method: "POST", |
| 153 | userId: caller.id, |
| 154 | body: {}, |
| 155 | }); |
| 156 | |
| 157 | expect(res.status).toBe(410); |
| 158 | }); |
| 159 | |
| 160 | it("rejects unauthenticated callers under production origin", async () => { |
| 161 | const inviter = await seedUser(); |
| 162 | const ws = await seedWorkspace({ owner_id: inviter.id }); |
| 163 | const invite = await seedInvite({ workspace_id: ws.id, invited_by: inviter.id, role: "member", email: null }); |
| 164 | |
| 165 | const res = await apiRequest(`/api/v1/invite/${invite.token}/accept`, { |
| 166 | method: "POST", |
| 167 | origin: PROD_ORIGIN, |
| 168 | body: {}, |
| 169 | }); |
| 170 | |
| 171 | expect(res.status).toBe(401); |
| 172 | }); |
| 173 | |
| 174 | it("rejects an email-pinned invite when the authenticated email does not match", async () => { |
| 175 | const inviter = await seedUser(); |
| 176 | const caller = await seedUser({ email: "other@example.com" }); |
| 177 | const ws = await seedWorkspace({ owner_id: inviter.id }); |
| 178 | const invite = await seedInvite({ |
| 179 | workspace_id: ws.id, |
| 180 | invited_by: inviter.id, |
| 181 | role: "member", |
| 182 | email: "pinned@example.com", |
| 183 | }); |
| 184 | |
| 185 | const res = await apiRequest(`/api/v1/invite/${invite.token}/accept`, { |
| 186 | method: "POST", |
| 187 | userId: caller.id, |
| 188 | body: {}, |
| 189 | }); |
| 190 | |
| 191 | expect(res.status).toBe(403); |
| 192 | }); |
| 193 | }); |