|
1 | 1 | import { describe, expect, test } from "bun:test"; |
2 | | -import type { ApiCall, ApiResult } from "@corbits/hub-api-client"; |
3 | 2 |
|
4 | | -import { checkSignupGate, resolvePendingInviteOnLogin } from "./gate"; |
| 3 | +import { checkSignupGate } from "./gate"; |
5 | 4 | import { createInMemoryAccessPolicyStore } from "./store"; |
6 | 5 |
|
7 | 6 | const verified = { emailVerified: true, allowUnverifiedEmails: false }; |
@@ -104,223 +103,3 @@ describe("checkSignupGate", () => { |
104 | 103 | expect(result).toEqual({ allowed: true, reason: "policy_open" }); |
105 | 104 | }); |
106 | 105 | }); |
107 | | - |
108 | | -function fakeApi( |
109 | | - handler: (method: string, path: string, body: unknown) => ApiResult, |
110 | | -): ApiCall { |
111 | | - return async (method, path, body) => handler(method, path, body); |
112 | | -} |
113 | | - |
114 | | -describe("resolvePendingInviteOnLogin", () => { |
115 | | - test("no matching invite -> undefined, no native calls made", async () => { |
116 | | - const store = createInMemoryAccessPolicyStore(); |
117 | | - let calls = 0; |
118 | | - const api = fakeApi(() => { |
119 | | - calls += 1; |
120 | | - throw new Error("should not be called"); |
121 | | - }); |
122 | | - const result = await resolvePendingInviteOnLogin({ |
123 | | - store, |
124 | | - api, |
125 | | - cookies: [], |
126 | | - email: "nobody@acme.example", |
127 | | - ...verified, |
128 | | - }); |
129 | | - expect(result).toBeUndefined(); |
130 | | - expect(calls).toBe(0); |
131 | | - }); |
132 | | - |
133 | | - test("exploit: an unverified email cannot hijack a pending invite for someone else's address", async () => { |
134 | | - const store = createInMemoryAccessPolicyStore(); |
135 | | - await store.createPendingInvite("tnt_acme", { |
136 | | - matchType: "email", |
137 | | - value: "victim@acme.example", |
138 | | - }); |
139 | | - let calls = 0; |
140 | | - const api = fakeApi(() => { |
141 | | - calls += 1; |
142 | | - throw new Error("should not be called"); |
143 | | - }); |
144 | | - |
145 | | - const result = await resolvePendingInviteOnLogin({ |
146 | | - store, |
147 | | - api, |
148 | | - cookies: [], |
149 | | - email: "victim@acme.example", |
150 | | - emailVerified: false, |
151 | | - allowUnverifiedEmails: false, |
152 | | - }); |
153 | | - |
154 | | - expect(result).toBeUndefined(); |
155 | | - expect(calls).toBe(0); |
156 | | - // The invite survives untouched — an unverified claim never even |
157 | | - // looks it up, let alone consumes it. |
158 | | - const stillPending = await store.findMatchingPendingInvite( |
159 | | - "victim@acme.example", |
160 | | - ); |
161 | | - expect(stillPending).toBeDefined(); |
162 | | - }); |
163 | | - |
164 | | - test("an exact-email match invites, activates, and is consumed", async () => { |
165 | | - const store = createInMemoryAccessPolicyStore(); |
166 | | - const invite = await store.createPendingInvite("tnt_acme", { |
167 | | - matchType: "email", |
168 | | - value: "Person@Acme.example", |
169 | | - roleId: "rol_member", |
170 | | - }); |
171 | | - |
172 | | - const calls: { method: string; path: string; body: unknown }[] = []; |
173 | | - const api = fakeApi((method, path, body) => { |
174 | | - calls.push({ method, path, body }); |
175 | | - if (method === "POST" && path.endsWith("/members/invite")) { |
176 | | - return { status: 201, data: { id: "prn_new" }, cookies: [] }; |
177 | | - } |
178 | | - if (method === "PATCH" && path.endsWith("/prn_new")) { |
179 | | - return { status: 200, data: { id: "prn_new" }, cookies: [] }; |
180 | | - } |
181 | | - throw new Error(`unexpected call ${method} ${path}`); |
182 | | - }); |
183 | | - |
184 | | - const result = await resolvePendingInviteOnLogin({ |
185 | | - store, |
186 | | - api, |
187 | | - cookies: ["session=abc"], |
188 | | - email: "person@acme.example", |
189 | | - ...verified, |
190 | | - }); |
191 | | - |
192 | | - expect(result).toEqual({ tenantId: "tnt_acme", principalId: "prn_new" }); |
193 | | - expect(calls).toEqual([ |
194 | | - { |
195 | | - method: "POST", |
196 | | - path: "/api/tenants/tnt_acme/members/invite", |
197 | | - body: { email: "person@acme.example", roleId: "rol_member" }, |
198 | | - }, |
199 | | - { |
200 | | - method: "PATCH", |
201 | | - path: "/api/tenants/tnt_acme/principals/prn_new", |
202 | | - body: { status: "active" }, |
203 | | - }, |
204 | | - ]); |
205 | | - |
206 | | - const stillMatches = await store.findMatchingPendingInvite( |
207 | | - "person@acme.example", |
208 | | - ); |
209 | | - expect(stillMatches).toBeUndefined(); |
210 | | - void invite; |
211 | | - }); |
212 | | - |
213 | | - test("a domain-wildcard match resolves but is never consumed (a standing rule)", async () => { |
214 | | - const store = createInMemoryAccessPolicyStore(); |
215 | | - await store.createPendingInvite("tnt_acme", { |
216 | | - matchType: "domain", |
217 | | - value: "acme.example", |
218 | | - }); |
219 | | - |
220 | | - const api = fakeApi((method, path) => { |
221 | | - if (method === "POST" && path.endsWith("/members/invite")) { |
222 | | - return { status: 201, data: { id: "prn_new" }, cookies: [] }; |
223 | | - } |
224 | | - if (method === "PATCH") { |
225 | | - return { status: 200, data: { id: "prn_new" }, cookies: [] }; |
226 | | - } |
227 | | - throw new Error(`unexpected call ${method} ${path}`); |
228 | | - }); |
229 | | - |
230 | | - const first = await resolvePendingInviteOnLogin({ |
231 | | - store, |
232 | | - api, |
233 | | - cookies: [], |
234 | | - email: "anyone@acme.example", |
235 | | - ...verified, |
236 | | - }); |
237 | | - expect(first).toEqual({ tenantId: "tnt_acme", principalId: "prn_new" }); |
238 | | - |
239 | | - const second = await resolvePendingInviteOnLogin({ |
240 | | - store, |
241 | | - api, |
242 | | - cookies: [], |
243 | | - email: "someone-else@acme.example", |
244 | | - ...verified, |
245 | | - }); |
246 | | - expect(second).toEqual({ tenantId: "tnt_acme", principalId: "prn_new" }); |
247 | | - }); |
248 | | - |
249 | | - test("a redemption failure at the native invite route throws; the invite is already spent (consume-before-redeem)", async () => { |
250 | | - const store = createInMemoryAccessPolicyStore(); |
251 | | - await store.createPendingInvite("tnt_acme", { |
252 | | - matchType: "email", |
253 | | - value: "person@acme.example", |
254 | | - }); |
255 | | - const api = fakeApi(() => ({ |
256 | | - status: 409, |
257 | | - data: { error: { code: "conflict" } }, |
258 | | - cookies: [], |
259 | | - })); |
260 | | - |
261 | | - await expect( |
262 | | - resolvePendingInviteOnLogin({ |
263 | | - store, |
264 | | - api, |
265 | | - cookies: [], |
266 | | - email: "person@acme.example", |
267 | | - ...verified, |
268 | | - }), |
269 | | - ).rejects.toThrow(/could not be redeemed/); |
270 | | - |
271 | | - // Consumption happens before the native call specifically so a |
272 | | - // race never lets two callers both redeem — the tradeoff is that a |
273 | | - // downstream failure (network blip, native route down) leaves this |
274 | | - // invite spent with no member added. That is deliberate: fail |
275 | | - // closed on the race, not on the rare native-route failure. |
276 | | - const stillPending = await store.findMatchingPendingInvite( |
277 | | - "person@acme.example", |
278 | | - ); |
279 | | - expect(stillPending).toBeUndefined(); |
280 | | - }); |
281 | | - |
282 | | - test("TOCTOU: two concurrent redemptions of the same exact-email invite — exactly one wins", async () => { |
283 | | - const store = createInMemoryAccessPolicyStore(); |
284 | | - await store.createPendingInvite("tnt_acme", { |
285 | | - matchType: "email", |
286 | | - value: "person@acme.example", |
287 | | - }); |
288 | | - |
289 | | - let inviteCalls = 0; |
290 | | - const api = fakeApi((method, path) => { |
291 | | - if (method === "POST" && path.endsWith("/members/invite")) { |
292 | | - inviteCalls += 1; |
293 | | - return { status: 201, data: { id: "prn_new" }, cookies: [] }; |
294 | | - } |
295 | | - if (method === "PATCH") { |
296 | | - return { status: 200, data: { id: "prn_new" }, cookies: [] }; |
297 | | - } |
298 | | - throw new Error(`unexpected call ${method} ${path}`); |
299 | | - }); |
300 | | - |
301 | | - const [first, second] = await Promise.all([ |
302 | | - resolvePendingInviteOnLogin({ |
303 | | - store, |
304 | | - api, |
305 | | - cookies: [], |
306 | | - email: "person@acme.example", |
307 | | - ...verified, |
308 | | - }), |
309 | | - resolvePendingInviteOnLogin({ |
310 | | - store, |
311 | | - api, |
312 | | - cookies: [], |
313 | | - email: "person@acme.example", |
314 | | - ...verified, |
315 | | - }), |
316 | | - ]); |
317 | | - |
318 | | - const winners = [first, second].filter((r) => r !== undefined); |
319 | | - const losers = [first, second].filter((r) => r === undefined); |
320 | | - expect(winners).toHaveLength(1); |
321 | | - expect(losers).toHaveLength(1); |
322 | | - // The loser never reaches the native invite route at all — it |
323 | | - // loses at the atomic consume step, before any redemption call. |
324 | | - expect(inviteCalls).toBe(1); |
325 | | - }); |
326 | | -}); |
0 commit comments