1
11import { afterAll, beforeAll, describe, expect, it } from "vitest";
12import { createTestApp, type TestAppHandle } from "./helpers/api";
13import { authHeaders, createUser, randomPassword, uniqueEmail, countAuditEvents } from "./helpers/seed";
14
15
16import { sha256hex } from "@factory/api/src/lib/crypto";
17
18let app: TestAppHandle;
19
20beforeAll(async () => {
21 app = await createTestApp();
22}, 180_000);
23afterAll(async () => {
24 await app?.close();
25});
26
27const json = async (res: Response): Promise<Record<string, unknown>> =>
28 (await res.json().catch(() => ({}))) as Record<string, unknown>;
29
30describe("Auth integration", () => {
31 it("register: creates user, hashes password, marks email unverified", async () => {
32 const email = uniqueEmail("reg");
33 const password = randomPassword();
34 const res = await app.request("POST", "/api/v1/auth/register", {
35 body: { email, password, name: "Reg Test" },
36 });
37 expect(res.status).toBe(201);
38 const body = await json(res);
39 const user = body.user as { id: string; email: string } | undefined;
40 expect(user?.email).toBe(email);
41
42
43 const row = await app.prisma.user.findUnique({ where: { email } });
44 expect(row).not.toBeNull();
45 expect(row?.passwordHash).toBeTruthy();
46 expect(row?.passwordHash).not.toContain(password);
47 expect(row?.passwordHash).toMatch(/^\$2[aby]\$/);
48 expect(row?.emailVerification).toBe("UNVERIFIED");
49 });
50
51 it("register: duplicate email rejected", async () => {
52 const email = uniqueEmail("dup");
53 await app.request("POST", "/api/v1/auth/register", {
54 body: { email, password: randomPassword(), name: "Dup" },
55 });
56 const dup = await app.request("POST", "/api/v1/auth/register", {
57 body: { email, password: randomPassword(), name: "Dup 2" },
58 });
59 expect([400, 409]).toContain(dup.status);
60 });
61
62 it("login: returns access + refresh tokens; wrong password → 401", async () => {
63 const { email, password } = await createUser(app.request, { name: "Login Test" });
64 const ok = await app.request("POST", "/api/v1/auth/login", {
65 body: { email, password },
66 });
67 expect(ok.status).toBe(200);
68 const body = await json(ok);
69 expect(body.accessToken).toBeTruthy();
70 expect(body.refreshToken).toBeTruthy();
71
72 const bad = await app.request("POST", "/api/v1/auth/login", {
73 body: { email, password: "wrong-password-xyz" },
74 });
75 expect(bad.status).toBe(401);
76 });
77
78 it("me: requires auth; returns the user when authenticated", async () => {
79 const anon = await app.request("GET", "/api/v1/auth/me");
80 expect(anon.status).toBe(401);
81
82 const { accessToken } = await createUser(app.request, { name: "Me Test" });
83 expect(accessToken).toBeTruthy();
84 const me = await app.request("GET", "/api/v1/auth/me", {
85 headers: authHeaders(accessToken!),
86 });
87 expect(me.status).toBe(200);
88 const body = await json(me);
89 expect((body.user as { id: string }).id).toBeTruthy();
90 });
91
92 it("refresh rotation: old token invalidated, new token works", async () => {
93 const user = await createUser(app.request, { name: "Rotation Test" });
94 const login = await app.request("POST", "/api/v1/auth/login", {
95 body: { email: user.email, password: user.password },
96 });
97 const first = (await json(login)).refreshToken as string;
98
99
100 const r1 = await app.request("POST", "/api/v1/auth/refresh", {
101 body: { refreshToken: first },
102 });
103 expect(r1.status).toBe(200);
104 const r1Body = await json(r1);
105 const second = r1Body.refreshToken as string;
106 expect(second).toBeTruthy();
107 expect(second).not.toBe(first);
108
109 expect(r1Body.accessToken).toBeTruthy();
110
111
112 const firstRow = await app.prisma.refreshToken.findFirst({
113 where: { userId: user.userId },
114 orderBy: { createdAt: "desc" },
115 });
116 expect(firstRow).not.toBeNull();
117 const anyRevoked = await app.prisma.refreshToken.count({
118 where: { userId: user.userId, revokedAt: { not: null } },
119 });
120 expect(anyRevoked).toBeGreaterThanOrEqual(1);
121 });
122
123 it("refresh stale retry: rotated token with a LIVE successor → 401, no family kill", async () => {
124 const user = await createUser(app.request, { name: "Stale Retry Test" });
125 const login = await app.request("POST", "/api/v1/auth/login", {
126 body: { email: user.email, password: user.password },
127 });
128 const first = (await json(login)).refreshToken as string;
129
130
131 const r1 = await app.request("POST", "/api/v1/auth/refresh", {
132 body: { refreshToken: first },
133 });
134 expect(r1.status).toBe(200);
135 const second = (await json(r1)).refreshToken as string;
136
137 const staleBefore = await countAuditEvents(app.prisma, { action: "auth.refresh_stale_retry" });
138
139
140
141
142 const stale = await app.request("POST", "/api/v1/auth/refresh", {
143 body: { refreshToken: first },
144 });
145 expect(stale.status).toBe(401);
146 const staleBody = await json(stale);
147 const staleReason = (staleBody.error as { details?: { reason?: string }[] } | undefined)?.details?.find(
148 (d) => d.reason
149 )?.reason;
150 expect(staleReason).toBe("refresh_token_stale");
151
152
153
154
155
156
157 const setCookie = stale.headers.get("set-cookie") ?? "";
158 expect(setCookie, "stale 401 must clear the factory_refresh cookie").toMatch(/factory_refresh=;/);
159
160
161 const after = await app.request("POST", "/api/v1/auth/refresh", {
162 body: { refreshToken: second },
163 });
164 expect(after.status).toBe(200);
165
166
167 const staleAfter = await countAuditEvents(app.prisma, { action: "auth.refresh_stale_retry" });
168 expect(staleAfter).toBe(staleBefore + 1);
169 const reuseCount = await countAuditEvents(app.prisma, { action: "auth.refresh_reuse_detected" });
170 expect(reuseCount).toBe(0);
171 });
172
173 it("refresh reuse: rotated token with a DEAD successor → family kill (reuse attack)", async () => {
174 const user = await createUser(app.request, { name: "Reuse Attack Test" });
175 const login = await app.request("POST", "/api/v1/auth/login", {
176 body: { email: user.email, password: user.password },
177 });
178 const first = (await json(login)).refreshToken as string;
179
180
181
182 const r1 = await app.request("POST", "/api/v1/auth/refresh", {
183 body: { refreshToken: first },
184 });
185 expect(r1.status).toBe(200);
186 const second = (await json(r1)).refreshToken as string;
187 const r2 = await app.request("POST", "/api/v1/auth/refresh", {
188 body: { refreshToken: second },
189 });
190 expect(r2.status).toBe(200);
191 const third = (await json(r2)).refreshToken as string;
192
193 const reuseBefore = await countAuditEvents(app.prisma, { action: "auth.refresh_reuse_detected" });
194
195
196
197
198 const reuse = await app.request("POST", "/api/v1/auth/refresh", {
199 body: { refreshToken: first },
200 });
201 expect(reuse.status).toBe(401);
202 const reuseBody = await json(reuse);
203 const reuseReason = (reuseBody.error as { details?: { reason?: string }[] } | undefined)?.details?.find(
204 (d) => d.reason
205 )?.reason;
206 expect(reuseReason).toBe("refresh_token_reused");
207
208
209
210 const after = await app.request("POST", "/api/v1/auth/refresh", {
211 body: { refreshToken: third },
212 });
213 expect(after.status).toBe(401);
214
215
216 const liveLeft = await app.prisma.refreshToken.count({
217 where: { userId: user.userId, revokedAt: null },
218 });
219 expect(liveLeft).toBe(0);
220 const session = await app.prisma.session.findFirst({
221 where: { userId: user.userId },
222 orderBy: { createdAt: "desc" },
223 });
224 expect(session?.status).toBe("REVOKED");
225
226
227
228
229 const reuseAfter = await countAuditEvents(app.prisma, { action: "auth.refresh_reuse_detected" });
230 expect(reuseAfter).toBeGreaterThan(reuseBefore);
231 });
232
233 it("refresh concurrency: two in-flight rotations of one token → one live successor, no orphan", async () => {
234 const user = await createUser(app.request, { name: "Rotation Race Test" });
235 const login = await app.request("POST", "/api/v1/auth/login", {
236 body: { email: user.email, password: user.password },
237 });
238 const first = (await json(login)).refreshToken as string;
239
240
241
242
243
244 const [rA, rB] = await Promise.all([
245 app.request("POST", "/api/v1/auth/refresh", { body: { refreshToken: first } }),
246 app.request("POST", "/api/v1/auth/refresh", { body: { refreshToken: first } }),
247 ]);
248 const statuses = [rA.status, rB.status].sort();
249 expect(statuses).toEqual([200, 401]);
250 const loserBody = await json(rA.status === 401 ? rA : rB);
251 const loserReason = (loserBody.error as { details?: { reason?: string }[] } | undefined)?.details?.find(
252 (d) => d.reason
253 )?.reason;
254 expect(loserReason).toBe("refresh_token_stale");
255
256
257
258
259
260 const winner = (await json(rA.status === 200 ? rA : rB)).refreshToken as string;
261 const winnerLive = await app.prisma.refreshToken.findUnique({
262 where: { tokenHash: sha256hex(winner) },
263 select: { id: true, revokedAt: true, sessionAgent: true, replacedBy: true },
264 });
265 expect(winnerLive).not.toBeNull();
266 expect(winnerLive?.revokedAt).toBeNull();
267
268
269 const firstSessionLive = await app.prisma.refreshToken.count({
270 where: { userId: user.userId, revokedAt: null, sessionAgent: winnerLive!.sessionAgent ?? "" },
271 });
272 expect(firstSessionLive).toBe(1);
273
274
275
276 const firstRow = await app.prisma.refreshToken.findUnique({
277 where: { tokenHash: sha256hex(first) },
278 });
279 expect(firstRow?.revokedAt).not.toBeNull();
280 expect(firstRow?.replacedBy).toBe(winnerLive!.id);
281
282
283 const winnerNext = await app.request("POST", "/api/v1/auth/refresh", {
284 body: { refreshToken: winner },
285 });
286 expect(winnerNext.status).toBe(200);
287 });
288
289 it("rate limit: sustained login failures from one client → 429", async () => {
290 const email = uniqueEmail("rl");
291 await app.request("POST", "/api/v1/auth/register", {
292 body: { email, password: randomPassword(), name: "RL" },
293 });
294
295 let got429 = false;
296 for (let i = 0; i < 20; i++) {
297 const res = await app.request("POST", "/api/v1/auth/login", {
298 body: { email, password: `wrong-${i}` },
299 });
300 if (res.status === 429) {
301 got429 = true;
302 break;
303 }
304 }
305 expect(got429, "expected 429 after sustained login failures (RATE_LIMIT_* config)").toBe(true);
306 });
307
308 it("audit: login is recorded as an audit event", async () => {
309 const user = await createUser(app.request, { name: "Audit Login Test" });
310 const before = await countAuditEvents(app.prisma, { action: "user.login" });
311
312 await app.request("POST", "/api/v1/auth/login", {
313 body: { email: user.email, password: user.password },
314 });
315
316 const after = await countAuditEvents(app.prisma, { action: "user.login" });
317 expect(after).toBe(before + 1);
318 });
319});
320