Skip to content
File

Blob: tests/worker/routes/invite-accept.workers.test.ts

typescript194 lines
1import { and, eq, inArray } from "drizzle-orm";
2import { beforeEach, describe, expect, it } from "vitest";
3 
4import { invites, memberships } from "@/worker/db/d1/schema";
5import { getDb, resetD1Tables } from "@tests/worker/helpers/db";
6import { apiRequest, PROD_ORIGIN } from "@tests/worker/helpers/request";
7import { seedInvite, seedMembership, seedTesseraIdentity, seedUser, seedWorkspace } from "@tests/worker/helpers/seeds";
8 
9interface AcceptResponse {
10 workspace_id: string;
11 accessToken: string;
12 already_member?: boolean;
13}
14 
15describe("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});