@tulipes/cli 0.1.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +46 -0
- package/dist/app-core.d.ts +33 -0
- package/dist/app-core.js +58 -0
- package/dist/app-core.js.map +1 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +7 -0
- package/dist/bin.js.map +1 -0
- package/dist/dev.d.ts +8 -0
- package/dist/dev.js +22 -0
- package/dist/dev.js.map +1 -0
- package/dist/env-check.d.ts +6 -0
- package/dist/env-check.js +28 -0
- package/dist/env-check.js.map +1 -0
- package/dist/init.d.ts +25 -0
- package/dist/init.js +6416 -0
- package/dist/init.js.map +1 -0
- package/dist/inspection.d.ts +8 -0
- package/dist/inspection.js +15 -0
- package/dist/inspection.js.map +1 -0
- package/dist/main.d.ts +1 -0
- package/dist/main.js +166 -0
- package/dist/main.js.map +1 -0
- package/dist/minimal.d.ts +2 -0
- package/dist/minimal.js +2200 -0
- package/dist/minimal.js.map +1 -0
- package/dist/new-module.d.ts +19 -0
- package/dist/new-module.js +348 -0
- package/dist/new-module.js.map +1 -0
- package/dist/package-info.d.ts +18 -0
- package/dist/package-info.js +53 -0
- package/dist/package-info.js.map +1 -0
- package/dist/routes.d.ts +8 -0
- package/dist/routes.js +54 -0
- package/dist/routes.js.map +1 -0
- package/dist/spec.d.ts +19 -0
- package/dist/spec.js +101 -0
- package/dist/spec.js.map +1 -0
- package/dist/sync.d.ts +12 -0
- package/dist/sync.js +117 -0
- package/dist/sync.js.map +1 -0
- package/dist/update.d.ts +20 -0
- package/dist/update.js +240 -0
- package/dist/update.js.map +1 -0
- package/package.json +59 -0
- package/templates/CLAUDE.md +120 -0
- package/templates/browser-auth.md +120 -0
- package/templates/claude/skills/tulipes-boot-errors/SKILL.md +74 -0
- package/templates/claude/skills/tulipes-endpoint/SKILL.md +132 -0
- package/templates/claude/skills/tulipes-env-variable/SKILL.md +78 -0
- package/templates/claude/skills/tulipes-i18n/SKILL.md +98 -0
- package/templates/claude/skills/tulipes-model/SKILL.md +107 -0
- package/templates/claude/skills/tulipes-module/SKILL.md +67 -0
- package/templates/claude/skills/tulipes-permissions/SKILL.md +105 -0
- package/templates/claude/skills/tulipes-queue/SKILL.md +68 -0
- package/templates/claude/skills/tulipes-response/SKILL.md +118 -0
- package/templates/claude/skills/tulipes-settings/SKILL.md +111 -0
- package/templates/claude/skills/tulipes-socket/SKILL.md +60 -0
- package/templates/claude/skills/tulipes-spec/SKILL.md +101 -0
package/dist/minimal.js
ADDED
|
@@ -0,0 +1,2200 @@
|
|
|
1
|
+
/** Small, infrastructure-free starter. The production renderer remains separate. */
|
|
2
|
+
const json = (value) => JSON.stringify(value, null, 2) + "\n";
|
|
3
|
+
export function starterTests(production) {
|
|
4
|
+
return production
|
|
5
|
+
? `import { randomBytes } from "node:crypto";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { z } from "zod/v4";
|
|
8
|
+
import { afterAll, beforeAll, expect, test, vi } from "vitest";
|
|
9
|
+
import { boot, inspectRoutes, type BootHandle } from "@tulipes/core/boot";
|
|
10
|
+
import { extract, toOpenApi, toPostman } from "@tulipes/spec";
|
|
11
|
+
|
|
12
|
+
let handle: BootHandle;
|
|
13
|
+
let base: string;
|
|
14
|
+
|
|
15
|
+
beforeAll(async () => {
|
|
16
|
+
vi.stubEnv("PORT", "0");
|
|
17
|
+
vi.stubEnv("TRUST_PROXY_CIDRS", "127.0.0.1/32,::1/128");
|
|
18
|
+
vi.stubEnv("CORS_ORIGINS", "https://console.example.test");
|
|
19
|
+
vi.stubEnv("CORS_CREDENTIALS", "false");
|
|
20
|
+
|
|
21
|
+
if (!process.env.TEST_MONGO_URI || !process.env.TEST_REDIS_URL) {
|
|
22
|
+
throw new Error(
|
|
23
|
+
"Tests require dedicated TEST_MONGO_URI and TEST_REDIS_URL services",
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
vi.stubEnv("MONGO_URI", process.env.TEST_MONGO_URI);
|
|
28
|
+
vi.stubEnv("REDIS_URL", process.env.TEST_REDIS_URL);
|
|
29
|
+
vi.stubEnv("RATE_LIMIT_NAMESPACE", "test-" + randomBytes(12).toString("hex"));
|
|
30
|
+
vi.stubEnv("AUTH_RATE_LIMIT", "200");
|
|
31
|
+
vi.stubEnv("AUTH_SESSION_RATE_LIMIT", "1000");
|
|
32
|
+
vi.stubEnv("SETTINGS_RATE_LIMIT", "200");
|
|
33
|
+
vi.stubEnv("JWT_ACCESS_SECRET", randomBytes(32).toString("hex"));
|
|
34
|
+
vi.stubEnv("JWT_REFRESH_SECRET", randomBytes(32).toString("hex"));
|
|
35
|
+
vi.stubEnv("AUTH_ADMIN_EMAIL", "test-admin@example.test");
|
|
36
|
+
vi.stubEnv("AUTH_ADMIN_PASSWORD", randomBytes(32).toString("hex"));
|
|
37
|
+
vi.stubEnv("LOG_LEVEL", "fatal");
|
|
38
|
+
vi.stubEnv("LOG_FILE", "false");
|
|
39
|
+
vi.stubEnv("LOG_CONSOLE", "false");
|
|
40
|
+
|
|
41
|
+
handle = await boot({
|
|
42
|
+
rootDir: fileURLToPath(new URL("../", import.meta.url)),
|
|
43
|
+
appEnv: "test",
|
|
44
|
+
handleSignals: false,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const address = handle.server?.address();
|
|
48
|
+
|
|
49
|
+
if (!address || typeof address === "string")
|
|
50
|
+
throw new Error("No HTTP listener");
|
|
51
|
+
|
|
52
|
+
base = "http://127.0.0.1:" + address.port;
|
|
53
|
+
}, 15_000);
|
|
54
|
+
|
|
55
|
+
afterAll(async () => {
|
|
56
|
+
await handle?.shutdown();
|
|
57
|
+
|
|
58
|
+
vi.unstubAllEnvs();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("hello responds through the declared public endpoint", async () => {
|
|
62
|
+
const response = await fetch(base + "/api/v1/hello");
|
|
63
|
+
|
|
64
|
+
expect(response.status).toBe(200);
|
|
65
|
+
expect(await response.json()).toMatchObject({ success: true, errors: [] });
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("unknown endpoints return the error envelope", async () => {
|
|
69
|
+
const response = await fetch(base + "/not-a-route");
|
|
70
|
+
|
|
71
|
+
expect(response.status).toBe(404);
|
|
72
|
+
expect(await response.json()).toMatchObject({ success: false, data: null });
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("offline declarations describe the mounted runtime routes and schemas", async () => {
|
|
76
|
+
const context = await inspectRoutes({
|
|
77
|
+
rootDir: fileURLToPath(new URL("../", import.meta.url)),
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const offline = { ...extract(context), app: { name: "parity" } };
|
|
81
|
+
const runtime = { ...extract(handle.ctx), app: { name: "parity" } };
|
|
82
|
+
|
|
83
|
+
expect(await toOpenApi(offline)).toEqual(await toOpenApi(runtime));
|
|
84
|
+
expect(await toPostman(offline)).toEqual(await toPostman(runtime));
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const tokens = z.object({
|
|
88
|
+
data: z.object({ access_token: z.string(), refresh_token: z.string() }),
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
async function post(path: string, body: unknown, token?: string) {
|
|
92
|
+
return fetch(base + path, {
|
|
93
|
+
method: "POST",
|
|
94
|
+
headers: {
|
|
95
|
+
"content-type": "application/json",
|
|
96
|
+
...(token ? { authorization: "Bearer " + token } : {}),
|
|
97
|
+
},
|
|
98
|
+
body: JSON.stringify(body),
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
test("concurrent refresh has one winner but reuse invalidates its family", async () => {
|
|
103
|
+
const initial = await account();
|
|
104
|
+
|
|
105
|
+
const attempts = await Promise.all([
|
|
106
|
+
post("/api/v1/auth/refresh", { refresh_token: initial.refresh_token }),
|
|
107
|
+
post("/api/v1/auth/refresh", { refresh_token: initial.refresh_token }),
|
|
108
|
+
]);
|
|
109
|
+
|
|
110
|
+
expect(attempts.map((response) => response.status).sort()).toEqual([
|
|
111
|
+
200, 401,
|
|
112
|
+
]);
|
|
113
|
+
|
|
114
|
+
const winner = attempts.find((response) => response.status === 200)!;
|
|
115
|
+
const rotated = tokens.parse(await winner.json()).data;
|
|
116
|
+
|
|
117
|
+
// The server cannot tell a duplicate client request from a stolen token.
|
|
118
|
+
await assertRejected(initial);
|
|
119
|
+
|
|
120
|
+
await assertRejected(rotated);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
async function account() {
|
|
124
|
+
const credentials = {
|
|
125
|
+
email: randomBytes(12).toString("hex") + "@example.test",
|
|
126
|
+
password: randomBytes(24).toString("hex"),
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const response = await post("/api/v1/auth/register", credentials);
|
|
130
|
+
|
|
131
|
+
expect(response.status).toBe(201);
|
|
132
|
+
|
|
133
|
+
const body = await response.json();
|
|
134
|
+
|
|
135
|
+
const { user } = z
|
|
136
|
+
.object({ data: z.object({ user: z.object({ id: z.string() }) }) })
|
|
137
|
+
.parse(body).data;
|
|
138
|
+
|
|
139
|
+
return { ...tokens.parse(body).data, ...credentials, userId: user.id };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function get(path: string, token: string) {
|
|
143
|
+
return fetch(base + path, { headers: { authorization: "Bearer " + token } });
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function assertRejected(pair: {
|
|
147
|
+
access_token: string;
|
|
148
|
+
refresh_token: string;
|
|
149
|
+
}) {
|
|
150
|
+
expect((await get("/api/v1/auth/me", pair.access_token)).status).toBe(401);
|
|
151
|
+
|
|
152
|
+
expect(
|
|
153
|
+
(await post("/api/v1/auth/refresh", { refresh_token: pair.refresh_token }))
|
|
154
|
+
.status,
|
|
155
|
+
).toBe(401);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
test("refresh reuse revokes its whole device family while other devices survive", async () => {
|
|
159
|
+
const initial = await account();
|
|
160
|
+
|
|
161
|
+
const login = await post("/api/v1/auth/login", {
|
|
162
|
+
email: initial.email,
|
|
163
|
+
password: initial.password,
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const other = tokens.parse(await login.json()).data;
|
|
167
|
+
|
|
168
|
+
// Reuse an ancestor after two rotations, not just the immediately prior token.
|
|
169
|
+
const first = await post("/api/v1/auth/refresh", {
|
|
170
|
+
refresh_token: initial.refresh_token,
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
const firstPair = tokens.parse(await first.json()).data;
|
|
174
|
+
|
|
175
|
+
const second = await post("/api/v1/auth/refresh", {
|
|
176
|
+
refresh_token: firstPair.refresh_token,
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
const latest = tokens.parse(await second.json()).data;
|
|
180
|
+
|
|
181
|
+
const replay = await post("/api/v1/auth/refresh", {
|
|
182
|
+
refresh_token: initial.refresh_token,
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
expect(replay.status).toBe(401);
|
|
186
|
+
|
|
187
|
+
for (const pair of [initial, firstPair, latest]) {
|
|
188
|
+
await assertRejected(pair);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
expect((await get("/api/v1/auth/me", other.access_token)).status).toBe(200);
|
|
192
|
+
|
|
193
|
+
const devices = await get("/api/v1/auth/sessions", other.access_token);
|
|
194
|
+
|
|
195
|
+
expect(
|
|
196
|
+
z.object({ data: z.array(z.unknown()) }).parse(await devices.json()).data,
|
|
197
|
+
).toHaveLength(1);
|
|
198
|
+
|
|
199
|
+
expect(
|
|
200
|
+
(
|
|
201
|
+
await post("/api/v1/auth/refresh", {
|
|
202
|
+
refresh_token: other.refresh_token,
|
|
203
|
+
})
|
|
204
|
+
).status,
|
|
205
|
+
).toBe(200);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("a delayed successful rotation cannot resurrect a family revoked by reuse", async () => {
|
|
209
|
+
const initial = await account();
|
|
210
|
+
const collection = handle.ctx.models!.get("Session").collection;
|
|
211
|
+
const update = collection.findOneAndUpdate.bind(collection);
|
|
212
|
+
const committed = barrier();
|
|
213
|
+
const release = barrier();
|
|
214
|
+
|
|
215
|
+
// Hold the winner after its database write, before the HTTP response returns.
|
|
216
|
+
const spy = vi
|
|
217
|
+
.spyOn(collection, "findOneAndUpdate")
|
|
218
|
+
.mockImplementationOnce(async (...args) => {
|
|
219
|
+
const result = await update(...args);
|
|
220
|
+
|
|
221
|
+
committed.resolve();
|
|
222
|
+
|
|
223
|
+
await release.promise;
|
|
224
|
+
|
|
225
|
+
return result;
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
const pending = post("/api/v1/auth/refresh", {
|
|
229
|
+
refresh_token: initial.refresh_token,
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
try {
|
|
233
|
+
await Promise.race([
|
|
234
|
+
committed.promise,
|
|
235
|
+
pending.then(() => {
|
|
236
|
+
throw new Error("Rotation did not reach the write barrier");
|
|
237
|
+
}),
|
|
238
|
+
]);
|
|
239
|
+
|
|
240
|
+
const replay = await post("/api/v1/auth/refresh", {
|
|
241
|
+
refresh_token: initial.refresh_token,
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
expect(replay.status).toBe(401);
|
|
245
|
+
|
|
246
|
+
release.resolve();
|
|
247
|
+
|
|
248
|
+
const winner = await pending;
|
|
249
|
+
|
|
250
|
+
expect(winner.status).toBe(200);
|
|
251
|
+
|
|
252
|
+
await assertRejected(tokens.parse(await winner.json()).data);
|
|
253
|
+
|
|
254
|
+
await assertRejected(initial);
|
|
255
|
+
} finally {
|
|
256
|
+
release.resolve();
|
|
257
|
+
|
|
258
|
+
await pending;
|
|
259
|
+
|
|
260
|
+
spy.mockRestore();
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test("unacknowledged refresh writes return a sanitized failure without claiming revocation", async () => {
|
|
265
|
+
const initial = await account();
|
|
266
|
+
|
|
267
|
+
const first = await post("/api/v1/auth/refresh", {
|
|
268
|
+
refresh_token: initial.refresh_token,
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
const latest = tokens.parse(await first.json()).data;
|
|
272
|
+
const canary = "private refresh database error";
|
|
273
|
+
|
|
274
|
+
const spy = vi
|
|
275
|
+
.spyOn(handle.ctx.models!.get("Session").collection, "findOneAndUpdate")
|
|
276
|
+
.mockRejectedValueOnce(new Error(canary));
|
|
277
|
+
|
|
278
|
+
try {
|
|
279
|
+
const failed = await post("/api/v1/auth/refresh", {
|
|
280
|
+
refresh_token: initial.refresh_token,
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
expect(failed.status).toBe(503);
|
|
284
|
+
|
|
285
|
+
expect(await failed.json()).toMatchObject({
|
|
286
|
+
errors: [{ code: "REFRESH_UNAVAILABLE" }],
|
|
287
|
+
});
|
|
288
|
+
} finally {
|
|
289
|
+
spy.mockRestore();
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// The failed write did not revoke the family. A later replay must still do so.
|
|
293
|
+
expect((await get("/api/v1/auth/me", latest.access_token)).status).toBe(200);
|
|
294
|
+
|
|
295
|
+
expect(
|
|
296
|
+
(
|
|
297
|
+
await post("/api/v1/auth/refresh", {
|
|
298
|
+
refresh_token: initial.refresh_token,
|
|
299
|
+
})
|
|
300
|
+
).status,
|
|
301
|
+
).toBe(401);
|
|
302
|
+
|
|
303
|
+
await assertRejected(latest);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
test("invalid refresh credentials cannot revoke a live family", async () => {
|
|
307
|
+
const { default: jwt } = await import("jsonwebtoken");
|
|
308
|
+
const initial = await account();
|
|
309
|
+
const other = await account();
|
|
310
|
+
const original = jwt.decode(initial.refresh_token) as Record<string, unknown>;
|
|
311
|
+
const key = String(handle.ctx.Environment.get("JWT_REFRESH_SECRET"));
|
|
312
|
+
|
|
313
|
+
const sign = (changes: Record<string, unknown>) =>
|
|
314
|
+
jwt.sign({ ...original, ...changes }, key);
|
|
315
|
+
|
|
316
|
+
const missingJti = { ...original };
|
|
317
|
+
|
|
318
|
+
delete missingJti.jti;
|
|
319
|
+
|
|
320
|
+
const invalid = [
|
|
321
|
+
jwt.sign(original, "different-signing-key"),
|
|
322
|
+
sign({ typ: "access" }),
|
|
323
|
+
sign({ exp: Math.floor(Date.now() / 1000) - 1 }),
|
|
324
|
+
jwt.sign(missingJti, key),
|
|
325
|
+
sign({ jti: { $ne: null } }),
|
|
326
|
+
sign({ sid: { $ne: null } }),
|
|
327
|
+
sign({ sub: { $ne: null } }),
|
|
328
|
+
];
|
|
329
|
+
|
|
330
|
+
// Signed malformed claims still cannot become filters or replay evidence.
|
|
331
|
+
for (const token of invalid) {
|
|
332
|
+
const response = await post("/api/v1/auth/refresh", {
|
|
333
|
+
refresh_token: token,
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
expect(response.status).toBe(401);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Valid shape and signature alone do not establish ownership of another sid.
|
|
340
|
+
const wrongOwner = await post("/api/v1/auth/refresh", {
|
|
341
|
+
refresh_token: sign({ sub: other.userId }),
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
expect(wrongOwner.status).toBe(401);
|
|
345
|
+
expect((await get("/api/v1/auth/me", initial.access_token)).status).toBe(200);
|
|
346
|
+
expect((await get("/api/v1/auth/me", other.access_token)).status).toBe(200);
|
|
347
|
+
|
|
348
|
+
expect(
|
|
349
|
+
(
|
|
350
|
+
await post("/api/v1/auth/refresh", {
|
|
351
|
+
refresh_token: initial.refresh_token,
|
|
352
|
+
})
|
|
353
|
+
).status,
|
|
354
|
+
).toBe(200);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
test("an expired ancestor is rejected without revoking its still-live descendants", async () => {
|
|
358
|
+
const initial = await account();
|
|
359
|
+
const { default: jwt } = await import("jsonwebtoken");
|
|
360
|
+
const ancestor = jwt.decode(initial.refresh_token) as { exp: number };
|
|
361
|
+
let latest!: { access_token: string; refresh_token: string };
|
|
362
|
+
|
|
363
|
+
// Move only the app clock so the ancestor expires while the rotated pair does not.
|
|
364
|
+
vi.useFakeTimers({ toFake: ["Date"] });
|
|
365
|
+
|
|
366
|
+
try {
|
|
367
|
+
vi.setSystemTime(new Date((ancestor.exp - 60) * 1000));
|
|
368
|
+
|
|
369
|
+
const rotated = await post("/api/v1/auth/refresh", {
|
|
370
|
+
refresh_token: initial.refresh_token,
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
expect(rotated.status).toBe(200);
|
|
374
|
+
|
|
375
|
+
latest = tokens.parse(await rotated.json()).data;
|
|
376
|
+
|
|
377
|
+
vi.setSystemTime(new Date(ancestor.exp * 1000));
|
|
378
|
+
|
|
379
|
+
const expired = await post("/api/v1/auth/refresh", {
|
|
380
|
+
refresh_token: initial.refresh_token,
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
expect(expired.status).toBe(401);
|
|
384
|
+
|
|
385
|
+
expect((await get("/api/v1/auth/me", latest.access_token)).status).toBe(
|
|
386
|
+
200,
|
|
387
|
+
);
|
|
388
|
+
} finally {
|
|
389
|
+
vi.useRealTimers();
|
|
390
|
+
}
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
test("a lost acknowledgment after replay revocation never restores the family", async () => {
|
|
394
|
+
const initial = await account();
|
|
395
|
+
|
|
396
|
+
const first = await post("/api/v1/auth/refresh", {
|
|
397
|
+
refresh_token: initial.refresh_token,
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
const latest = tokens.parse(await first.json()).data;
|
|
401
|
+
const collection = handle.ctx.models!.get("Session").collection;
|
|
402
|
+
const update = collection.findOneAndUpdate.bind(collection);
|
|
403
|
+
|
|
404
|
+
// Simulate MongoDB committing the update before the reply is lost in transit.
|
|
405
|
+
const spy = vi
|
|
406
|
+
.spyOn(collection, "findOneAndUpdate")
|
|
407
|
+
.mockImplementationOnce(async (...args) => {
|
|
408
|
+
await update(...args);
|
|
409
|
+
|
|
410
|
+
throw new Error("private lost acknowledgment");
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
try {
|
|
414
|
+
const failed = await post("/api/v1/auth/refresh", {
|
|
415
|
+
refresh_token: initial.refresh_token,
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
expect(spy).toHaveBeenCalledWith(
|
|
419
|
+
expect.anything(),
|
|
420
|
+
expect.anything(),
|
|
421
|
+
expect.objectContaining({
|
|
422
|
+
writeConcern: { w: "majority", j: true, wtimeout: 5000 },
|
|
423
|
+
}),
|
|
424
|
+
);
|
|
425
|
+
|
|
426
|
+
expect(failed.status).toBe(503);
|
|
427
|
+
|
|
428
|
+
expect(await failed.json()).toMatchObject({
|
|
429
|
+
errors: [{ code: "REFRESH_UNAVAILABLE" }],
|
|
430
|
+
});
|
|
431
|
+
} finally {
|
|
432
|
+
spy.mockRestore();
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
await assertRejected(latest);
|
|
436
|
+
|
|
437
|
+
await assertRejected(initial);
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
test("browser credentials use headers and JSON without issuing cookies", async () => {
|
|
441
|
+
const credentials = {
|
|
442
|
+
email: randomBytes(12).toString("hex") + "@example.test",
|
|
443
|
+
password: randomBytes(24).toString("hex"),
|
|
444
|
+
};
|
|
445
|
+
|
|
446
|
+
const registered = await post("/api/v1/auth/register", credentials);
|
|
447
|
+
|
|
448
|
+
expect(registered.status).toBe(201);
|
|
449
|
+
expect(registered.headers.has("set-cookie")).toBe(false);
|
|
450
|
+
|
|
451
|
+
const login = await post("/api/v1/auth/login", credentials);
|
|
452
|
+
|
|
453
|
+
expect(login.status).toBe(200);
|
|
454
|
+
expect(login.headers.has("set-cookie")).toBe(false);
|
|
455
|
+
|
|
456
|
+
const initial = tokens.parse(await login.json()).data;
|
|
457
|
+
const cookie = "access_token=" + initial.access_token;
|
|
458
|
+
|
|
459
|
+
// A valid token in an ambient cookie or URL must not establish identity.
|
|
460
|
+
const cookieOnly = await fetch(base + "/api/v1/auth/me", {
|
|
461
|
+
headers: { cookie },
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
const queryOnly = await fetch(
|
|
465
|
+
base + "/api/v1/auth/me?access_token=" + initial.access_token,
|
|
466
|
+
);
|
|
467
|
+
|
|
468
|
+
expect(cookieOnly.status).toBe(401);
|
|
469
|
+
expect(queryOnly.status).toBe(401);
|
|
470
|
+
expect((await get("/api/v1/auth/me", initial.access_token)).status).toBe(200);
|
|
471
|
+
|
|
472
|
+
// Cookie-only logout must also leave the real session usable.
|
|
473
|
+
const logoutWithCookie = await fetch(base + "/api/v1/auth/logout", {
|
|
474
|
+
method: "POST",
|
|
475
|
+
headers: { "content-type": "application/json", cookie },
|
|
476
|
+
body: "{}",
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
expect(logoutWithCookie.status).toBe(401);
|
|
480
|
+
|
|
481
|
+
const refreshed = await post("/api/v1/auth/refresh", {
|
|
482
|
+
refresh_token: initial.refresh_token,
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
expect(refreshed.status).toBe(200);
|
|
486
|
+
expect(refreshed.headers.has("set-cookie")).toBe(false);
|
|
487
|
+
|
|
488
|
+
const rotated = tokens.parse(await refreshed.json()).data;
|
|
489
|
+
const logout = await post("/api/v1/auth/logout", {}, rotated.access_token);
|
|
490
|
+
|
|
491
|
+
expect(logout.status).toBe(200);
|
|
492
|
+
expect(logout.headers.has("set-cookie")).toBe(false);
|
|
493
|
+
|
|
494
|
+
await assertRejected(rotated);
|
|
495
|
+
|
|
496
|
+
await assertRejected(initial);
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
test("refresh ignores cookie and URL credentials and does not parse simple form bodies", async () => {
|
|
500
|
+
const initial = await account();
|
|
501
|
+
const endpoint = base + "/api/v1/auth/refresh";
|
|
502
|
+
const refresh = initial.refresh_token;
|
|
503
|
+
|
|
504
|
+
const attempts = [
|
|
505
|
+
await fetch(endpoint, {
|
|
506
|
+
method: "POST",
|
|
507
|
+
headers: {
|
|
508
|
+
"content-type": "application/json",
|
|
509
|
+
cookie: "refresh_token=" + refresh,
|
|
510
|
+
},
|
|
511
|
+
body: "{}",
|
|
512
|
+
}),
|
|
513
|
+
await fetch(endpoint + "?refresh_token=" + refresh, {
|
|
514
|
+
method: "POST",
|
|
515
|
+
headers: { "content-type": "application/json" },
|
|
516
|
+
body: "{}",
|
|
517
|
+
}),
|
|
518
|
+
await fetch(endpoint, {
|
|
519
|
+
method: "POST",
|
|
520
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
521
|
+
body: new URLSearchParams({ refresh_token: refresh }),
|
|
522
|
+
}),
|
|
523
|
+
await fetch(endpoint, {
|
|
524
|
+
method: "POST",
|
|
525
|
+
headers: { "content-type": "text/plain" },
|
|
526
|
+
body: JSON.stringify({ refresh_token: refresh }),
|
|
527
|
+
}),
|
|
528
|
+
];
|
|
529
|
+
|
|
530
|
+
const form = new FormData();
|
|
531
|
+
|
|
532
|
+
form.set("refresh_token", refresh);
|
|
533
|
+
|
|
534
|
+
attempts.push(await fetch(endpoint, { method: "POST", body: form }));
|
|
535
|
+
|
|
536
|
+
for (const response of attempts) {
|
|
537
|
+
expect(response.status).toBe(422);
|
|
538
|
+
expect(response.headers.has("set-cookie")).toBe(false);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// Rejections must not consume the credential that was carried incorrectly.
|
|
542
|
+
const accepted = await post("/api/v1/auth/refresh", {
|
|
543
|
+
refresh_token: refresh,
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
expect(accepted.status).toBe(200);
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
test("CORS permits explicit Bearer headers without enabling cookie credentials", async () => {
|
|
550
|
+
const origin = "https://console.example.test";
|
|
551
|
+
const endpoint = base + "/api/v1/auth/me";
|
|
552
|
+
|
|
553
|
+
const preflight = await fetch(endpoint, {
|
|
554
|
+
method: "OPTIONS",
|
|
555
|
+
headers: {
|
|
556
|
+
origin,
|
|
557
|
+
"access-control-request-method": "GET",
|
|
558
|
+
"access-control-request-headers": "authorization",
|
|
559
|
+
},
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
expect(preflight.status).toBe(204);
|
|
563
|
+
expect(preflight.headers.get("access-control-allow-origin")).toBe(origin);
|
|
564
|
+
|
|
565
|
+
expect(preflight.headers.get("access-control-allow-headers")).toBe(
|
|
566
|
+
"authorization",
|
|
567
|
+
);
|
|
568
|
+
|
|
569
|
+
expect(preflight.headers.has("access-control-allow-credentials")).toBe(false);
|
|
570
|
+
|
|
571
|
+
const initial = await account();
|
|
572
|
+
|
|
573
|
+
const allowed = await fetch(endpoint, {
|
|
574
|
+
headers: { origin, authorization: "Bearer " + initial.access_token },
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
expect(allowed.status).toBe(200);
|
|
578
|
+
expect(allowed.headers.get("access-control-allow-origin")).toBe(origin);
|
|
579
|
+
expect(allowed.headers.has("access-control-allow-credentials")).toBe(false);
|
|
580
|
+
|
|
581
|
+
// Node does not enforce CORS. This proves CORS is not server authorization:
|
|
582
|
+
// a valid Bearer token still works, while the origin gets no read permission.
|
|
583
|
+
const unlisted = await fetch(endpoint, {
|
|
584
|
+
headers: {
|
|
585
|
+
origin: "https://unlisted.example.test",
|
|
586
|
+
authorization: "Bearer " + initial.access_token,
|
|
587
|
+
},
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
expect(unlisted.status).toBe(200);
|
|
591
|
+
expect(unlisted.headers.has("access-control-allow-origin")).toBe(false);
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
test("session expiry rejects access and refresh before TTL cleanup and hides the device", async () => {
|
|
595
|
+
const initial = await account();
|
|
596
|
+
const Session = handle.ctx.models!.get("Session");
|
|
597
|
+
|
|
598
|
+
// Keep the row in Mongo's future; advance only the app clock to the exact
|
|
599
|
+
// boundary so asynchronous TTL deletion cannot make this regression pass.
|
|
600
|
+
const expires = new Date(Date.now() + 120_000);
|
|
601
|
+
|
|
602
|
+
await Session.updateOne({ user: initial.userId }, { expires_at: expires });
|
|
603
|
+
|
|
604
|
+
const login = await post("/api/v1/auth/login", {
|
|
605
|
+
email: initial.email,
|
|
606
|
+
password: initial.password,
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
expect(login.status).toBe(200);
|
|
610
|
+
|
|
611
|
+
const other = tokens.parse(await login.json()).data;
|
|
612
|
+
|
|
613
|
+
vi.useFakeTimers({ toFake: ["Date"] });
|
|
614
|
+
|
|
615
|
+
vi.setSystemTime(expires);
|
|
616
|
+
|
|
617
|
+
try {
|
|
618
|
+
const sessions = await get("/api/v1/auth/sessions", other.access_token);
|
|
619
|
+
|
|
620
|
+
expect(sessions.status).toBe(200);
|
|
621
|
+
|
|
622
|
+
expect
|
|
623
|
+
.soft(
|
|
624
|
+
z.object({ data: z.array(z.unknown()) }).parse(await sessions.json())
|
|
625
|
+
.data,
|
|
626
|
+
)
|
|
627
|
+
.toHaveLength(1);
|
|
628
|
+
|
|
629
|
+
// Check both independently so a failed access assertion cannot hide refresh.
|
|
630
|
+
expect
|
|
631
|
+
.soft((await get("/api/v1/auth/me", initial.access_token)).status)
|
|
632
|
+
.toBe(401);
|
|
633
|
+
|
|
634
|
+
expect
|
|
635
|
+
.soft(
|
|
636
|
+
(
|
|
637
|
+
await post("/api/v1/auth/refresh", {
|
|
638
|
+
refresh_token: initial.refresh_token,
|
|
639
|
+
})
|
|
640
|
+
).status,
|
|
641
|
+
)
|
|
642
|
+
.toBe(401);
|
|
643
|
+
|
|
644
|
+
expect(await Session.countDocuments({ user: initial.userId })).toBe(2);
|
|
645
|
+
} finally {
|
|
646
|
+
vi.useRealTimers();
|
|
647
|
+
}
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
test("a session reassigned to another account cannot authenticate or refresh its old tokens", async () => {
|
|
651
|
+
const initial = await account();
|
|
652
|
+
const other = await account();
|
|
653
|
+
|
|
654
|
+
await handle.ctx
|
|
655
|
+
.models!.get("Session")
|
|
656
|
+
.updateOne({ user: initial.userId }, { user: other.userId });
|
|
657
|
+
|
|
658
|
+
expect
|
|
659
|
+
.soft((await get("/api/v1/auth/me", initial.access_token)).status)
|
|
660
|
+
.toBe(401);
|
|
661
|
+
|
|
662
|
+
expect
|
|
663
|
+
.soft(
|
|
664
|
+
(
|
|
665
|
+
await post("/api/v1/auth/refresh", {
|
|
666
|
+
refresh_token: initial.refresh_token,
|
|
667
|
+
})
|
|
668
|
+
).status,
|
|
669
|
+
)
|
|
670
|
+
.toBe(401);
|
|
671
|
+
|
|
672
|
+
expect((await get("/api/v1/auth/me", other.access_token)).status).toBe(200);
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
test("password change in the same JWT second revokes both devices and keeps the replacement usable", async () => {
|
|
676
|
+
// Freeze only Date, leaving HTTP, Mongo and bcrypt timers operational.
|
|
677
|
+
vi.useFakeTimers({ toFake: ["Date"] });
|
|
678
|
+
|
|
679
|
+
vi.setSystemTime(new Date(Math.floor(Date.now() / 1000) * 1000 + 100));
|
|
680
|
+
|
|
681
|
+
try {
|
|
682
|
+
const initial = await account();
|
|
683
|
+
|
|
684
|
+
const login = await post("/api/v1/auth/login", {
|
|
685
|
+
email: initial.email,
|
|
686
|
+
password: initial.password,
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
expect(login.status).toBe(200);
|
|
690
|
+
|
|
691
|
+
const other = tokens.parse(await login.json()).data;
|
|
692
|
+
|
|
693
|
+
vi.setSystemTime(new Date(Date.now() + 500));
|
|
694
|
+
|
|
695
|
+
const password = randomBytes(24).toString("hex");
|
|
696
|
+
|
|
697
|
+
const changed = await post(
|
|
698
|
+
"/api/v1/auth/password",
|
|
699
|
+
{ password },
|
|
700
|
+
initial.access_token,
|
|
701
|
+
);
|
|
702
|
+
|
|
703
|
+
expect(changed.status).toBe(200);
|
|
704
|
+
|
|
705
|
+
const replacement = tokens.parse(await changed.json()).data;
|
|
706
|
+
|
|
707
|
+
await assertRejected(initial);
|
|
708
|
+
|
|
709
|
+
await assertRejected(other);
|
|
710
|
+
|
|
711
|
+
expect(
|
|
712
|
+
(await get("/api/v1/auth/me", replacement.access_token)).status,
|
|
713
|
+
).toBe(200);
|
|
714
|
+
|
|
715
|
+
expect(
|
|
716
|
+
(
|
|
717
|
+
await post("/api/v1/auth/refresh", {
|
|
718
|
+
refresh_token: replacement.refresh_token,
|
|
719
|
+
})
|
|
720
|
+
).status,
|
|
721
|
+
).toBe(200);
|
|
722
|
+
|
|
723
|
+
expect(
|
|
724
|
+
(
|
|
725
|
+
await post("/api/v1/auth/login", {
|
|
726
|
+
email: initial.email,
|
|
727
|
+
password: initial.password,
|
|
728
|
+
})
|
|
729
|
+
).status,
|
|
730
|
+
).toBe(401);
|
|
731
|
+
|
|
732
|
+
expect(
|
|
733
|
+
(await post("/api/v1/auth/login", { email: initial.email, password }))
|
|
734
|
+
.status,
|
|
735
|
+
).toBe(200);
|
|
736
|
+
} finally {
|
|
737
|
+
vi.useRealTimers();
|
|
738
|
+
}
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
test("refresh racing with device revocation cannot leave a usable token pair", async () => {
|
|
742
|
+
const initial = await account();
|
|
743
|
+
|
|
744
|
+
const login = await post("/api/v1/auth/login", {
|
|
745
|
+
email: initial.email,
|
|
746
|
+
password: initial.password,
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
expect(login.status).toBe(200);
|
|
750
|
+
|
|
751
|
+
const other = tokens.parse(await login.json()).data;
|
|
752
|
+
const identity = await get("/api/v1/auth/me", other.access_token);
|
|
753
|
+
|
|
754
|
+
const { session } = z
|
|
755
|
+
.object({ data: z.object({ session: z.object({ sid: z.string() }) }) })
|
|
756
|
+
.parse(await identity.json()).data;
|
|
757
|
+
|
|
758
|
+
const [revoked, refreshed] = await Promise.all([
|
|
759
|
+
fetch(base + "/api/v1/auth/sessions/" + session.sid, {
|
|
760
|
+
method: "DELETE",
|
|
761
|
+
headers: { authorization: "Bearer " + initial.access_token },
|
|
762
|
+
}),
|
|
763
|
+
post("/api/v1/auth/refresh", { refresh_token: other.refresh_token }),
|
|
764
|
+
]);
|
|
765
|
+
|
|
766
|
+
expect(revoked.status).toBe(200);
|
|
767
|
+
expect([200, 401]).toContain(refreshed.status);
|
|
768
|
+
|
|
769
|
+
await assertRejected(other);
|
|
770
|
+
|
|
771
|
+
if (refreshed.status === 200)
|
|
772
|
+
await assertRejected(tokens.parse(await refreshed.json()).data);
|
|
773
|
+
|
|
774
|
+
expect((await get("/api/v1/auth/me", initial.access_token)).status).toBe(200);
|
|
775
|
+
});
|
|
776
|
+
|
|
777
|
+
function barrier() {
|
|
778
|
+
let resolve!: () => void;
|
|
779
|
+
|
|
780
|
+
const promise = new Promise<void>((done) => {
|
|
781
|
+
resolve = done;
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
return { promise, resolve };
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
test.each(["login", "password", "cleanup", "credential-write"])(
|
|
788
|
+
"a paused %s cannot create a valid session after a later password change",
|
|
789
|
+
async (operation) => {
|
|
790
|
+
const initial = await account();
|
|
791
|
+
const firstPassword = randomBytes(24).toString("hex");
|
|
792
|
+
const latestPassword = randomBytes(24).toString("hex");
|
|
793
|
+
const collection = handle.ctx.models!.get("Session").collection;
|
|
794
|
+
const insert = collection.insertOne.bind(collection);
|
|
795
|
+
const remove = collection.deleteMany.bind(collection);
|
|
796
|
+
const credentials = handle.ctx.models!.get("Credential").collection;
|
|
797
|
+
const replace = credentials.findOneAndUpdate.bind(credentials);
|
|
798
|
+
const entered = barrier();
|
|
799
|
+
const release = barrier();
|
|
800
|
+
|
|
801
|
+
const spy =
|
|
802
|
+
operation === "credential-write"
|
|
803
|
+
? vi
|
|
804
|
+
.spyOn(credentials, "findOneAndUpdate")
|
|
805
|
+
.mockImplementationOnce(async (...args) => {
|
|
806
|
+
entered.resolve();
|
|
807
|
+
|
|
808
|
+
await release.promise;
|
|
809
|
+
|
|
810
|
+
return replace(...args);
|
|
811
|
+
})
|
|
812
|
+
: operation === "cleanup"
|
|
813
|
+
? vi
|
|
814
|
+
.spyOn(collection, "deleteMany")
|
|
815
|
+
.mockImplementationOnce(async (...args) => {
|
|
816
|
+
entered.resolve();
|
|
817
|
+
|
|
818
|
+
await release.promise;
|
|
819
|
+
|
|
820
|
+
return remove(...args);
|
|
821
|
+
})
|
|
822
|
+
: vi
|
|
823
|
+
.spyOn(collection, "insertOne")
|
|
824
|
+
.mockImplementationOnce(async (...args) => {
|
|
825
|
+
entered.resolve();
|
|
826
|
+
|
|
827
|
+
await release.promise;
|
|
828
|
+
|
|
829
|
+
return insert(...args);
|
|
830
|
+
});
|
|
831
|
+
|
|
832
|
+
const pending =
|
|
833
|
+
operation === "login"
|
|
834
|
+
? post("/api/v1/auth/login", {
|
|
835
|
+
email: initial.email,
|
|
836
|
+
password: initial.password,
|
|
837
|
+
})
|
|
838
|
+
: post(
|
|
839
|
+
"/api/v1/auth/password",
|
|
840
|
+
{ password: firstPassword },
|
|
841
|
+
initial.access_token,
|
|
842
|
+
);
|
|
843
|
+
|
|
844
|
+
try {
|
|
845
|
+
// The real request has verified/written its password and reached session
|
|
846
|
+
// insertion or cleanup. Complete a later change before releasing that write.
|
|
847
|
+
await Promise.race([
|
|
848
|
+
entered.promise,
|
|
849
|
+
pending.then(() => {
|
|
850
|
+
throw new Error("Request never reached the session barrier");
|
|
851
|
+
}),
|
|
852
|
+
]);
|
|
853
|
+
|
|
854
|
+
let access = initial.access_token;
|
|
855
|
+
|
|
856
|
+
if (operation === "password" || operation === "cleanup") {
|
|
857
|
+
const login = await post("/api/v1/auth/login", {
|
|
858
|
+
email: initial.email,
|
|
859
|
+
password: firstPassword,
|
|
860
|
+
});
|
|
861
|
+
|
|
862
|
+
expect(login.status).toBe(200);
|
|
863
|
+
|
|
864
|
+
access = tokens.parse(await login.json()).data.access_token;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
const changed = await post(
|
|
868
|
+
"/api/v1/auth/password",
|
|
869
|
+
{ password: latestPassword },
|
|
870
|
+
access,
|
|
871
|
+
);
|
|
872
|
+
|
|
873
|
+
expect(changed.status).toBe(200);
|
|
874
|
+
|
|
875
|
+
const latest = tokens.parse(await changed.json()).data;
|
|
876
|
+
|
|
877
|
+
release.resolve();
|
|
878
|
+
|
|
879
|
+
const stale = await pending;
|
|
880
|
+
|
|
881
|
+
expect.soft(stale.status).toBe(401);
|
|
882
|
+
|
|
883
|
+
if (stale.status === 200)
|
|
884
|
+
await assertRejected(tokens.parse(await stale.json()).data);
|
|
885
|
+
|
|
886
|
+
expect((await get("/api/v1/auth/me", latest.access_token)).status).toBe(
|
|
887
|
+
200,
|
|
888
|
+
);
|
|
889
|
+
|
|
890
|
+
expect(
|
|
891
|
+
(
|
|
892
|
+
await post("/api/v1/auth/login", {
|
|
893
|
+
email: initial.email,
|
|
894
|
+
password: latestPassword,
|
|
895
|
+
})
|
|
896
|
+
).status,
|
|
897
|
+
).toBe(200);
|
|
898
|
+
} finally {
|
|
899
|
+
release.resolve();
|
|
900
|
+
|
|
901
|
+
await pending;
|
|
902
|
+
|
|
903
|
+
spy.mockRestore();
|
|
904
|
+
}
|
|
905
|
+
},
|
|
906
|
+
);
|
|
907
|
+
|
|
908
|
+
test("failed session cleanup after password replacement cannot preserve old sessions", async () => {
|
|
909
|
+
vi.useFakeTimers({ toFake: ["Date"] });
|
|
910
|
+
|
|
911
|
+
vi.setSystemTime(new Date(Math.floor(Date.now() / 1000) * 1000 + 100));
|
|
912
|
+
|
|
913
|
+
try {
|
|
914
|
+
const initial = await account();
|
|
915
|
+
const password = randomBytes(24).toString("hex");
|
|
916
|
+
|
|
917
|
+
const spy = vi
|
|
918
|
+
.spyOn(handle.ctx.models!.get("Session").collection, "deleteMany")
|
|
919
|
+
.mockRejectedValueOnce(new Error("injected cleanup failure"));
|
|
920
|
+
|
|
921
|
+
try {
|
|
922
|
+
const changed = await post(
|
|
923
|
+
"/api/v1/auth/password",
|
|
924
|
+
{ password },
|
|
925
|
+
initial.access_token,
|
|
926
|
+
);
|
|
927
|
+
|
|
928
|
+
expect(changed.status).toBe(500);
|
|
929
|
+
} finally {
|
|
930
|
+
spy.mockRestore();
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
expect(
|
|
934
|
+
await handle.ctx
|
|
935
|
+
.models!.get("Session")
|
|
936
|
+
.countDocuments({ user: initial.userId }),
|
|
937
|
+
).toBe(1);
|
|
938
|
+
|
|
939
|
+
expect
|
|
940
|
+
.soft((await get("/api/v1/auth/me", initial.access_token)).status)
|
|
941
|
+
.toBe(401);
|
|
942
|
+
|
|
943
|
+
expect
|
|
944
|
+
.soft(
|
|
945
|
+
(
|
|
946
|
+
await post("/api/v1/auth/refresh", {
|
|
947
|
+
refresh_token: initial.refresh_token,
|
|
948
|
+
})
|
|
949
|
+
).status,
|
|
950
|
+
)
|
|
951
|
+
.toBe(401);
|
|
952
|
+
|
|
953
|
+
expect(
|
|
954
|
+
(
|
|
955
|
+
await post("/api/v1/auth/login", {
|
|
956
|
+
email: initial.email,
|
|
957
|
+
password: initial.password,
|
|
958
|
+
})
|
|
959
|
+
).status,
|
|
960
|
+
).toBe(401);
|
|
961
|
+
|
|
962
|
+
const login = await post("/api/v1/auth/login", {
|
|
963
|
+
email: initial.email,
|
|
964
|
+
password,
|
|
965
|
+
});
|
|
966
|
+
|
|
967
|
+
expect(login.status).toBe(200);
|
|
968
|
+
|
|
969
|
+
const current = tokens.parse(await login.json()).data;
|
|
970
|
+
const sessions = await get("/api/v1/auth/sessions", current.access_token);
|
|
971
|
+
|
|
972
|
+
expect(
|
|
973
|
+
z.object({ data: z.array(z.unknown()) }).parse(await sessions.json())
|
|
974
|
+
.data,
|
|
975
|
+
).toHaveLength(1);
|
|
976
|
+
} finally {
|
|
977
|
+
vi.useRealTimers();
|
|
978
|
+
}
|
|
979
|
+
});
|
|
980
|
+
|
|
981
|
+
test("a failed credential write preserves the old password and session", async () => {
|
|
982
|
+
const initial = await account();
|
|
983
|
+
const password = randomBytes(24).toString("hex");
|
|
984
|
+
|
|
985
|
+
const spy = vi
|
|
986
|
+
.spyOn(handle.ctx.models!.get("Credential").collection, "findOneAndUpdate")
|
|
987
|
+
.mockRejectedValueOnce(new Error("injected credential failure"));
|
|
988
|
+
|
|
989
|
+
try {
|
|
990
|
+
expect(
|
|
991
|
+
(await post("/api/v1/auth/password", { password }, initial.access_token))
|
|
992
|
+
.status,
|
|
993
|
+
).toBe(500);
|
|
994
|
+
} finally {
|
|
995
|
+
spy.mockRestore();
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
expect((await get("/api/v1/auth/me", initial.access_token)).status).toBe(200);
|
|
999
|
+
|
|
1000
|
+
expect(
|
|
1001
|
+
(
|
|
1002
|
+
await post("/api/v1/auth/refresh", {
|
|
1003
|
+
refresh_token: initial.refresh_token,
|
|
1004
|
+
})
|
|
1005
|
+
).status,
|
|
1006
|
+
).toBe(200);
|
|
1007
|
+
|
|
1008
|
+
expect(
|
|
1009
|
+
(
|
|
1010
|
+
await post("/api/v1/auth/login", {
|
|
1011
|
+
email: initial.email,
|
|
1012
|
+
password: initial.password,
|
|
1013
|
+
})
|
|
1014
|
+
).status,
|
|
1015
|
+
).toBe(200);
|
|
1016
|
+
|
|
1017
|
+
expect(
|
|
1018
|
+
(await post("/api/v1/auth/login", { email: initial.email, password }))
|
|
1019
|
+
.status,
|
|
1020
|
+
).toBe(401);
|
|
1021
|
+
});
|
|
1022
|
+
|
|
1023
|
+
test("legacy credentials upgrade on login while sessions without a version require reauthentication", async () => {
|
|
1024
|
+
const initial = await account();
|
|
1025
|
+
const Credential = handle.ctx.models!.get("Credential");
|
|
1026
|
+
const Session = handle.ctx.models!.get("Session");
|
|
1027
|
+
|
|
1028
|
+
// Use collection writes to simulate persisted data from the previous starter.
|
|
1029
|
+
const credential = await Credential.findOne({
|
|
1030
|
+
user: initial.userId,
|
|
1031
|
+
}).orFail();
|
|
1032
|
+
|
|
1033
|
+
await Credential.collection.updateOne(
|
|
1034
|
+
{ _id: credential._id },
|
|
1035
|
+
{ $unset: { version: "" } },
|
|
1036
|
+
);
|
|
1037
|
+
|
|
1038
|
+
await Session.collection.updateMany(
|
|
1039
|
+
{ user: credential.get("user") },
|
|
1040
|
+
{ $unset: { credential_version: "" } },
|
|
1041
|
+
);
|
|
1042
|
+
|
|
1043
|
+
await assertRejected(initial);
|
|
1044
|
+
|
|
1045
|
+
const login = await post("/api/v1/auth/login", {
|
|
1046
|
+
email: initial.email,
|
|
1047
|
+
password: initial.password,
|
|
1048
|
+
});
|
|
1049
|
+
|
|
1050
|
+
expect(login.status).toBe(200);
|
|
1051
|
+
|
|
1052
|
+
const current = tokens.parse(await login.json()).data;
|
|
1053
|
+
|
|
1054
|
+
expect((await get("/api/v1/auth/me", current.access_token)).status).toBe(200);
|
|
1055
|
+
|
|
1056
|
+
await assertRejected(initial);
|
|
1057
|
+
});
|
|
1058
|
+
|
|
1059
|
+
test("a legacy login cannot upgrade credentials replaced while its verification was in flight", async () => {
|
|
1060
|
+
const initial = await account();
|
|
1061
|
+
const Credential = handle.ctx.models!.get("Credential");
|
|
1062
|
+
const row = await Credential.findOne({ user: initial.userId }).orFail();
|
|
1063
|
+
|
|
1064
|
+
await Credential.collection.updateOne(
|
|
1065
|
+
{ _id: row._id },
|
|
1066
|
+
{ $unset: { version: "" } },
|
|
1067
|
+
);
|
|
1068
|
+
|
|
1069
|
+
const update = Credential.collection.updateOne.bind(Credential.collection);
|
|
1070
|
+
const entered = barrier();
|
|
1071
|
+
const release = barrier();
|
|
1072
|
+
|
|
1073
|
+
const spy = vi
|
|
1074
|
+
.spyOn(Credential.collection, "updateOne")
|
|
1075
|
+
.mockImplementationOnce(async (...args) => {
|
|
1076
|
+
entered.resolve();
|
|
1077
|
+
|
|
1078
|
+
await release.promise;
|
|
1079
|
+
|
|
1080
|
+
return update(...args);
|
|
1081
|
+
});
|
|
1082
|
+
|
|
1083
|
+
const pending = post("/api/v1/auth/login", {
|
|
1084
|
+
email: initial.email,
|
|
1085
|
+
password: initial.password,
|
|
1086
|
+
});
|
|
1087
|
+
|
|
1088
|
+
try {
|
|
1089
|
+
await Promise.race([
|
|
1090
|
+
entered.promise,
|
|
1091
|
+
pending.then(() => {
|
|
1092
|
+
throw new Error("Login never reached the credential barrier");
|
|
1093
|
+
}),
|
|
1094
|
+
]);
|
|
1095
|
+
|
|
1096
|
+
const password = randomBytes(24).toString("hex");
|
|
1097
|
+
|
|
1098
|
+
const { setPassword } = await import(
|
|
1099
|
+
"../modules/auth/helpers/passwords.js"
|
|
1100
|
+
);
|
|
1101
|
+
|
|
1102
|
+
await setPassword(handle.ctx, initial.userId, password);
|
|
1103
|
+
|
|
1104
|
+
release.resolve();
|
|
1105
|
+
|
|
1106
|
+
expect((await pending).status).toBe(401);
|
|
1107
|
+
|
|
1108
|
+
expect(
|
|
1109
|
+
(await post("/api/v1/auth/login", { email: initial.email, password }))
|
|
1110
|
+
.status,
|
|
1111
|
+
).toBe(200);
|
|
1112
|
+
} finally {
|
|
1113
|
+
release.resolve();
|
|
1114
|
+
|
|
1115
|
+
await pending;
|
|
1116
|
+
|
|
1117
|
+
spy.mockRestore();
|
|
1118
|
+
}
|
|
1119
|
+
});
|
|
1120
|
+
|
|
1121
|
+
async function sysadmin() {
|
|
1122
|
+
const user = await account();
|
|
1123
|
+
|
|
1124
|
+
await handle.ctx
|
|
1125
|
+
.models!.get("User")
|
|
1126
|
+
.updateOne({ _id: user.userId }, { role: "sysadmin" });
|
|
1127
|
+
|
|
1128
|
+
return user;
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
async function settingsFiles(
|
|
1132
|
+
run: (files: { env: string; locale: string }) => Promise<void>,
|
|
1133
|
+
) {
|
|
1134
|
+
const { mkdtemp, mkdir, writeFile, rm } = await import("node:fs/promises");
|
|
1135
|
+
const { tmpdir } = await import("node:os");
|
|
1136
|
+
const { join } = await import("node:path");
|
|
1137
|
+
const root = await mkdtemp(join(tmpdir(), "tulipes-settings-test-"));
|
|
1138
|
+
const env = join(root, ".env.test");
|
|
1139
|
+
const locale = join(root, "i18n", "en.json");
|
|
1140
|
+
|
|
1141
|
+
const manifest = handle.ctx.modules.find(
|
|
1142
|
+
(entry) => entry.name === "settings",
|
|
1143
|
+
)!;
|
|
1144
|
+
|
|
1145
|
+
const originalDir = manifest.dir;
|
|
1146
|
+
const originalEnv = handle.ctx.Environment.envFile;
|
|
1147
|
+
const originalPassword = process.env.AUTH_ADMIN_PASSWORD;
|
|
1148
|
+
|
|
1149
|
+
await mkdir(join(root, "i18n"));
|
|
1150
|
+
|
|
1151
|
+
await writeFile(env, "# unchanged\\n");
|
|
1152
|
+
|
|
1153
|
+
await writeFile(locale, "{}\\n");
|
|
1154
|
+
|
|
1155
|
+
// Route real file operations into disposable files, never the reference app.
|
|
1156
|
+
Object.defineProperty(handle.ctx.Environment, "envFile", { value: env });
|
|
1157
|
+
|
|
1158
|
+
Object.defineProperty(manifest, "dir", { value: root });
|
|
1159
|
+
|
|
1160
|
+
try {
|
|
1161
|
+
await run({ env, locale });
|
|
1162
|
+
} finally {
|
|
1163
|
+
Object.defineProperty(handle.ctx.Environment, "envFile", {
|
|
1164
|
+
value: originalEnv,
|
|
1165
|
+
});
|
|
1166
|
+
|
|
1167
|
+
Object.defineProperty(manifest, "dir", { value: originalDir });
|
|
1168
|
+
|
|
1169
|
+
if (originalPassword === undefined) delete process.env.AUTH_ADMIN_PASSWORD;
|
|
1170
|
+
else process.env.AUTH_ADMIN_PASSWORD = originalPassword;
|
|
1171
|
+
|
|
1172
|
+
await rm(root, { recursive: true, force: true });
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
async function put(path: string, body: unknown, token: string) {
|
|
1177
|
+
return fetch(base + path, {
|
|
1178
|
+
method: "PUT",
|
|
1179
|
+
headers: {
|
|
1180
|
+
"content-type": "application/json",
|
|
1181
|
+
authorization: "Bearer " + token,
|
|
1182
|
+
},
|
|
1183
|
+
body: JSON.stringify(body),
|
|
1184
|
+
});
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
test("secret reveal persists an acknowledged audit even with fatal-only logging", async () => {
|
|
1188
|
+
const user = await sysadmin();
|
|
1189
|
+
const Audit = handle.ctx.models!.get("AuditEvent");
|
|
1190
|
+
const insert = vi.spyOn(Audit.collection, "insertOne");
|
|
1191
|
+
|
|
1192
|
+
try {
|
|
1193
|
+
const response = await get(
|
|
1194
|
+
"/api/v1/settings/variables/AUTH_ADMIN_PASSWORD?reveal=true",
|
|
1195
|
+
user.access_token,
|
|
1196
|
+
);
|
|
1197
|
+
|
|
1198
|
+
expect(response.status).toBe(200);
|
|
1199
|
+
|
|
1200
|
+
const body = z
|
|
1201
|
+
.object({ data: z.object({ value: z.string() }) })
|
|
1202
|
+
.parse(await response.json());
|
|
1203
|
+
|
|
1204
|
+
expect(body.data.value === process.env.AUTH_ADMIN_PASSWORD).toBe(true);
|
|
1205
|
+
|
|
1206
|
+
const event = await Audit.findOne({ actor: user.userId }).lean();
|
|
1207
|
+
|
|
1208
|
+
expect(event).toMatchObject({
|
|
1209
|
+
action: "variable.reveal_requested",
|
|
1210
|
+
actor_role: "sysadmin",
|
|
1211
|
+
variable: "AUTH_ADMIN_PASSWORD",
|
|
1212
|
+
});
|
|
1213
|
+
|
|
1214
|
+
expect(JSON.stringify(event).includes(body.data.value)).toBe(false);
|
|
1215
|
+
|
|
1216
|
+
expect(insert.mock.calls[0]?.[1]).toMatchObject({
|
|
1217
|
+
w: "majority",
|
|
1218
|
+
j: true,
|
|
1219
|
+
wtimeout: 5000,
|
|
1220
|
+
});
|
|
1221
|
+
} finally {
|
|
1222
|
+
insert.mockRestore();
|
|
1223
|
+
}
|
|
1224
|
+
});
|
|
1225
|
+
|
|
1226
|
+
test.each(["reveal", "variable", "translations", "reboot"])(
|
|
1227
|
+
"audit failure blocks %s without exposing sink errors or changing files",
|
|
1228
|
+
async (action) => {
|
|
1229
|
+
const user = await sysadmin();
|
|
1230
|
+
const canary = randomBytes(24).toString("hex");
|
|
1231
|
+
const Audit = handle.ctx.models!.get("AuditEvent");
|
|
1232
|
+
|
|
1233
|
+
const insert = vi
|
|
1234
|
+
.spyOn(Audit.collection, "insertOne")
|
|
1235
|
+
.mockRejectedValue(new Error(canary));
|
|
1236
|
+
|
|
1237
|
+
// Prevent a real daemon connection even when running this test against the bug.
|
|
1238
|
+
const specifier = "pm2";
|
|
1239
|
+
const pm2 = (await import(specifier)).default;
|
|
1240
|
+
|
|
1241
|
+
const connect = vi.spyOn(pm2, "connect").mockImplementation(() => {
|
|
1242
|
+
throw new Error("unexpected restart");
|
|
1243
|
+
});
|
|
1244
|
+
|
|
1245
|
+
const oldHome = process.env.PM2_HOME;
|
|
1246
|
+
|
|
1247
|
+
process.env.PM2_HOME = "/unused-settings-test";
|
|
1248
|
+
|
|
1249
|
+
try {
|
|
1250
|
+
await settingsFiles(async (files) => {
|
|
1251
|
+
const { readFile } = await import("node:fs/promises");
|
|
1252
|
+
const oldPassword = process.env.AUTH_ADMIN_PASSWORD;
|
|
1253
|
+
|
|
1254
|
+
const response =
|
|
1255
|
+
action === "reveal"
|
|
1256
|
+
? await get(
|
|
1257
|
+
"/api/v1/settings/variables/AUTH_ADMIN_PASSWORD?reveal=true",
|
|
1258
|
+
user.access_token,
|
|
1259
|
+
)
|
|
1260
|
+
: action === "variable"
|
|
1261
|
+
? await put(
|
|
1262
|
+
"/api/v1/settings/variables/AUTH_ADMIN_PASSWORD",
|
|
1263
|
+
{ value: canary },
|
|
1264
|
+
user.access_token,
|
|
1265
|
+
)
|
|
1266
|
+
: action === "translations"
|
|
1267
|
+
? await put(
|
|
1268
|
+
"/api/v1/settings/i18n/settings/en",
|
|
1269
|
+
{ translations: { text: canary } },
|
|
1270
|
+
user.access_token,
|
|
1271
|
+
)
|
|
1272
|
+
: await post("/api/v1/settings/reboot", {}, user.access_token);
|
|
1273
|
+
|
|
1274
|
+
expect.soft(response.status).toBe(503);
|
|
1275
|
+
|
|
1276
|
+
const body = await response.text();
|
|
1277
|
+
|
|
1278
|
+
expect.soft(body.includes(canary)).toBe(false);
|
|
1279
|
+
expect.soft(body.includes(oldPassword!)).toBe(false);
|
|
1280
|
+
expect.soft(await readFile(files.env, "utf8")).toBe("# unchanged\\n");
|
|
1281
|
+
expect.soft(await readFile(files.locale, "utf8")).toBe("{}\\n");
|
|
1282
|
+
expect.soft(process.env.AUTH_ADMIN_PASSWORD === oldPassword).toBe(true);
|
|
1283
|
+
expect.soft(connect).not.toHaveBeenCalled();
|
|
1284
|
+
});
|
|
1285
|
+
} finally {
|
|
1286
|
+
insert.mockRestore();
|
|
1287
|
+
|
|
1288
|
+
connect.mockRestore();
|
|
1289
|
+
|
|
1290
|
+
if (oldHome === undefined) delete process.env.PM2_HOME;
|
|
1291
|
+
else process.env.PM2_HOME = oldHome;
|
|
1292
|
+
}
|
|
1293
|
+
},
|
|
1294
|
+
);
|
|
1295
|
+
|
|
1296
|
+
test("settings mutation waits for audit acknowledgment and stores no old or new value", async () => {
|
|
1297
|
+
const user = await sysadmin();
|
|
1298
|
+
const Audit = handle.ctx.models!.get("AuditEvent");
|
|
1299
|
+
const insert = Audit.collection.insertOne.bind(Audit.collection);
|
|
1300
|
+
const entered = barrier();
|
|
1301
|
+
const release = barrier();
|
|
1302
|
+
|
|
1303
|
+
const spy = vi
|
|
1304
|
+
.spyOn(Audit.collection, "insertOne")
|
|
1305
|
+
.mockImplementationOnce(async (...args) => {
|
|
1306
|
+
entered.resolve();
|
|
1307
|
+
|
|
1308
|
+
await release.promise;
|
|
1309
|
+
|
|
1310
|
+
return insert(...args);
|
|
1311
|
+
});
|
|
1312
|
+
|
|
1313
|
+
try {
|
|
1314
|
+
await settingsFiles(async (files) => {
|
|
1315
|
+
const { readFile } = await import("node:fs/promises");
|
|
1316
|
+
const value = randomBytes(24).toString("hex");
|
|
1317
|
+
const before = process.env.AUTH_ADMIN_PASSWORD!;
|
|
1318
|
+
|
|
1319
|
+
const pending = put(
|
|
1320
|
+
"/api/v1/settings/variables/AUTH_ADMIN_PASSWORD",
|
|
1321
|
+
{ value },
|
|
1322
|
+
user.access_token,
|
|
1323
|
+
);
|
|
1324
|
+
|
|
1325
|
+
try {
|
|
1326
|
+
await Promise.race([
|
|
1327
|
+
entered.promise,
|
|
1328
|
+
pending.then(() => {
|
|
1329
|
+
throw new Error("Mutation skipped the audit barrier");
|
|
1330
|
+
}),
|
|
1331
|
+
]);
|
|
1332
|
+
|
|
1333
|
+
expect(await readFile(files.env, "utf8")).toBe("# unchanged\\n");
|
|
1334
|
+
expect(process.env.AUTH_ADMIN_PASSWORD === before).toBe(true);
|
|
1335
|
+
|
|
1336
|
+
release.resolve();
|
|
1337
|
+
|
|
1338
|
+
expect((await pending).status).toBe(200);
|
|
1339
|
+
expect((await readFile(files.env, "utf8")).includes(value)).toBe(true);
|
|
1340
|
+
|
|
1341
|
+
const event = await Audit.findOne({ actor: user.userId }).lean();
|
|
1342
|
+
|
|
1343
|
+
expect(event).toMatchObject({
|
|
1344
|
+
action: "variable.change_requested",
|
|
1345
|
+
variable: "AUTH_ADMIN_PASSWORD",
|
|
1346
|
+
});
|
|
1347
|
+
|
|
1348
|
+
expect(
|
|
1349
|
+
JSON.stringify(event).includes(value) ||
|
|
1350
|
+
JSON.stringify(event).includes(before),
|
|
1351
|
+
).toBe(false);
|
|
1352
|
+
} finally {
|
|
1353
|
+
release.resolve();
|
|
1354
|
+
|
|
1355
|
+
await pending;
|
|
1356
|
+
}
|
|
1357
|
+
});
|
|
1358
|
+
} finally {
|
|
1359
|
+
spy.mockRestore();
|
|
1360
|
+
}
|
|
1361
|
+
});
|
|
1362
|
+
|
|
1363
|
+
test("ordinary administrators cannot reveal settings secrets or create an audit event", async () => {
|
|
1364
|
+
const user = await account();
|
|
1365
|
+
|
|
1366
|
+
await handle.ctx
|
|
1367
|
+
.models!.get("User")
|
|
1368
|
+
.updateOne({ _id: user.userId }, { role: "admin" });
|
|
1369
|
+
|
|
1370
|
+
expect(
|
|
1371
|
+
(
|
|
1372
|
+
await get(
|
|
1373
|
+
"/api/v1/settings/variables/AUTH_ADMIN_PASSWORD?reveal=true",
|
|
1374
|
+
user.access_token,
|
|
1375
|
+
)
|
|
1376
|
+
).status,
|
|
1377
|
+
).toBe(403);
|
|
1378
|
+
|
|
1379
|
+
expect(
|
|
1380
|
+
await handle.ctx
|
|
1381
|
+
.models!.get("AuditEvent")
|
|
1382
|
+
.countDocuments({ actor: user.userId }),
|
|
1383
|
+
).toBe(0);
|
|
1384
|
+
});
|
|
1385
|
+
|
|
1386
|
+
test("configured proxy trust supplies the same client IP to sessions and durable audits", async () => {
|
|
1387
|
+
const user = await sysadmin();
|
|
1388
|
+
const headers = { "X-Forwarded-For": "203.0.113.200, 198.51.100.19" };
|
|
1389
|
+
|
|
1390
|
+
const login = await fetch(base + "/api/v1/auth/login", {
|
|
1391
|
+
method: "POST",
|
|
1392
|
+
headers: { ...headers, "content-type": "application/json" },
|
|
1393
|
+
body: JSON.stringify({ email: user.email, password: user.password }),
|
|
1394
|
+
});
|
|
1395
|
+
|
|
1396
|
+
expect(login.status).toBe(200);
|
|
1397
|
+
|
|
1398
|
+
const pair = tokens.parse(await login.json()).data;
|
|
1399
|
+
const identity = await get("/api/v1/auth/me", pair.access_token);
|
|
1400
|
+
|
|
1401
|
+
expect(await identity.json()).toMatchObject({
|
|
1402
|
+
data: { session: { ip: "198.51.100.19" } },
|
|
1403
|
+
});
|
|
1404
|
+
|
|
1405
|
+
const revealed = await fetch(
|
|
1406
|
+
base + "/api/v1/settings/variables/AUTH_ADMIN_PASSWORD?reveal=true",
|
|
1407
|
+
{
|
|
1408
|
+
headers: { ...headers, authorization: "Bearer " + pair.access_token },
|
|
1409
|
+
},
|
|
1410
|
+
);
|
|
1411
|
+
|
|
1412
|
+
expect(revealed.status).toBe(200);
|
|
1413
|
+
|
|
1414
|
+
expect(
|
|
1415
|
+
await handle.ctx
|
|
1416
|
+
.models!.get("AuditEvent")
|
|
1417
|
+
.findOne({ actor: user.userId })
|
|
1418
|
+
.lean(),
|
|
1419
|
+
).toMatchObject({ ip: "198.51.100.19" });
|
|
1420
|
+
});
|
|
1421
|
+
|
|
1422
|
+
test.each(["auth/login", "settings/modules"])(
|
|
1423
|
+
"%s limits attempts before parsing or authentication",
|
|
1424
|
+
async (path) => {
|
|
1425
|
+
const responses = await Promise.all(
|
|
1426
|
+
Array.from({ length: 201 }, (_, index) =>
|
|
1427
|
+
fetch(base + "/api/v1/" + path, {
|
|
1428
|
+
method: path.startsWith("auth") ? "POST" : "GET",
|
|
1429
|
+
headers: {
|
|
1430
|
+
"X-Forwarded-For":
|
|
1431
|
+
"203.0.113." + ((index % 250) + 1) + ", 198.51.100.88",
|
|
1432
|
+
"content-type": "application/json",
|
|
1433
|
+
},
|
|
1434
|
+
...(path.startsWith("auth") ? { body: "{" } : {}),
|
|
1435
|
+
}),
|
|
1436
|
+
),
|
|
1437
|
+
);
|
|
1438
|
+
|
|
1439
|
+
expect(
|
|
1440
|
+
responses.filter((response) => response.status === 429),
|
|
1441
|
+
).toHaveLength(1);
|
|
1442
|
+
|
|
1443
|
+
const blocked = responses.find((response) => response.status === 429)!;
|
|
1444
|
+
|
|
1445
|
+
expect(Number(blocked.headers.get("retry-after"))).toBeGreaterThan(0);
|
|
1446
|
+
|
|
1447
|
+
expect(await blocked.json()).toMatchObject({
|
|
1448
|
+
success: false,
|
|
1449
|
+
errors: [{ code: "TOO_MANY_REQUESTS" }],
|
|
1450
|
+
});
|
|
1451
|
+
|
|
1452
|
+
expect((await fetch(base + "/api/v1/hello")).status).toBe(200);
|
|
1453
|
+
},
|
|
1454
|
+
);
|
|
1455
|
+
|
|
1456
|
+
test.each(["symlink", "hardlink", "directory-link", "oversized"])(
|
|
1457
|
+
"settings translation reads and writes reject %s files",
|
|
1458
|
+
async (kind) => {
|
|
1459
|
+
const user = await sysadmin();
|
|
1460
|
+
|
|
1461
|
+
await settingsFiles(async (files) => {
|
|
1462
|
+
const { writeFile, readFile, unlink, symlink, link, rename } =
|
|
1463
|
+
await import("node:fs/promises");
|
|
1464
|
+
|
|
1465
|
+
const { dirname, join } = await import("node:path");
|
|
1466
|
+
const outside = join(dirname(files.env), "outside.json");
|
|
1467
|
+
const canary = randomBytes(24).toString("hex");
|
|
1468
|
+
const original = JSON.stringify({ secret: canary });
|
|
1469
|
+
|
|
1470
|
+
await writeFile(outside, original);
|
|
1471
|
+
|
|
1472
|
+
if (kind === "directory-link") {
|
|
1473
|
+
await rename(
|
|
1474
|
+
dirname(files.locale),
|
|
1475
|
+
dirname(files.locale) + "-original",
|
|
1476
|
+
);
|
|
1477
|
+
|
|
1478
|
+
await symlink(
|
|
1479
|
+
dirname(files.locale) + "-original",
|
|
1480
|
+
dirname(files.locale),
|
|
1481
|
+
);
|
|
1482
|
+
} else {
|
|
1483
|
+
await unlink(files.locale);
|
|
1484
|
+
|
|
1485
|
+
if (kind === "symlink") await symlink(outside, files.locale);
|
|
1486
|
+
else if (kind === "hardlink") await link(outside, files.locale);
|
|
1487
|
+
else await writeFile(files.locale, " ".repeat(1024 * 1024 + 1));
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
for (const response of [
|
|
1491
|
+
await get("/api/v1/settings/i18n/settings/en", user.access_token),
|
|
1492
|
+
await put(
|
|
1493
|
+
"/api/v1/settings/i18n/settings/en",
|
|
1494
|
+
{ translations: { changed: true } },
|
|
1495
|
+
user.access_token,
|
|
1496
|
+
),
|
|
1497
|
+
]) {
|
|
1498
|
+
expect(response.status).toBe(kind === "oversized" ? 413 : 409);
|
|
1499
|
+
expect((await response.text()).includes(canary)).toBe(false);
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
expect(await readFile(outside, "utf8")).toBe(original);
|
|
1503
|
+
});
|
|
1504
|
+
},
|
|
1505
|
+
);
|
|
1506
|
+
|
|
1507
|
+
test("linked missing-translation files cannot leak through the module summary", async () => {
|
|
1508
|
+
const user = await sysadmin();
|
|
1509
|
+
|
|
1510
|
+
await settingsFiles(async (files) => {
|
|
1511
|
+
const { writeFile, symlink } = await import("node:fs/promises");
|
|
1512
|
+
const { dirname, join } = await import("node:path");
|
|
1513
|
+
const outside = join(dirname(files.env), "outside.json");
|
|
1514
|
+
const canary = randomBytes(24).toString("hex");
|
|
1515
|
+
|
|
1516
|
+
await writeFile(outside, JSON.stringify({ [canary]: "private" }));
|
|
1517
|
+
|
|
1518
|
+
await symlink(outside, join(dirname(files.locale), "en.missing.json"));
|
|
1519
|
+
|
|
1520
|
+
const response = await get("/api/v1/settings/modules", user.access_token);
|
|
1521
|
+
|
|
1522
|
+
expect(response.status).toBe(409);
|
|
1523
|
+
expect((await response.text()).includes(canary)).toBe(false);
|
|
1524
|
+
});
|
|
1525
|
+
});
|
|
1526
|
+
|
|
1527
|
+
test("linked env files cannot modify their target or the live process environment", async () => {
|
|
1528
|
+
const user = await sysadmin();
|
|
1529
|
+
|
|
1530
|
+
await settingsFiles(async (files) => {
|
|
1531
|
+
const { writeFile, readFile, unlink, symlink } = await import(
|
|
1532
|
+
"node:fs/promises"
|
|
1533
|
+
);
|
|
1534
|
+
|
|
1535
|
+
const outside = files.env + "-outside";
|
|
1536
|
+
|
|
1537
|
+
await writeFile(outside, "# unchanged\\n");
|
|
1538
|
+
|
|
1539
|
+
await unlink(files.env);
|
|
1540
|
+
|
|
1541
|
+
await symlink(outside, files.env);
|
|
1542
|
+
|
|
1543
|
+
const previous = process.env.AUTH_ADMIN_PASSWORD;
|
|
1544
|
+
|
|
1545
|
+
const response = await put(
|
|
1546
|
+
"/api/v1/settings/variables/AUTH_ADMIN_PASSWORD",
|
|
1547
|
+
{ value: randomBytes(24).toString("hex") },
|
|
1548
|
+
user.access_token,
|
|
1549
|
+
);
|
|
1550
|
+
|
|
1551
|
+
expect(response.status).toBe(409);
|
|
1552
|
+
expect(await readFile(outside, "utf8")).toBe("# unchanged\\n");
|
|
1553
|
+
expect(process.env.AUTH_ADMIN_PASSWORD).toBe(previous);
|
|
1554
|
+
});
|
|
1555
|
+
});
|
|
1556
|
+
|
|
1557
|
+
test("module summaries bound the combined expansion of missing translation keys", async () => {
|
|
1558
|
+
const user = await sysadmin();
|
|
1559
|
+
|
|
1560
|
+
await settingsFiles(async (files) => {
|
|
1561
|
+
const { writeFile } = await import("node:fs/promises");
|
|
1562
|
+
const { dirname, join } = await import("node:path");
|
|
1563
|
+
|
|
1564
|
+
const entries = {
|
|
1565
|
+
["x".repeat(3000)]: Object.fromEntries(
|
|
1566
|
+
Array.from({ length: 200 }, (_, i) => [String(i), ""]),
|
|
1567
|
+
),
|
|
1568
|
+
};
|
|
1569
|
+
|
|
1570
|
+
for (const locale of ["en", "fr"])
|
|
1571
|
+
await writeFile(
|
|
1572
|
+
join(dirname(files.locale), locale + ".missing.json"),
|
|
1573
|
+
JSON.stringify(entries),
|
|
1574
|
+
);
|
|
1575
|
+
|
|
1576
|
+
const response = await get("/api/v1/settings/modules", user.access_token);
|
|
1577
|
+
|
|
1578
|
+
expect(response.status).toBe(413);
|
|
1579
|
+
|
|
1580
|
+
expect(await response.json()).toMatchObject({
|
|
1581
|
+
errors: [{ code: "SETTINGS_METADATA_TOO_LARGE" }],
|
|
1582
|
+
});
|
|
1583
|
+
});
|
|
1584
|
+
});
|
|
1585
|
+
|
|
1586
|
+
test("translation replacement remains readable through the settings API", async () => {
|
|
1587
|
+
const user = await sysadmin();
|
|
1588
|
+
|
|
1589
|
+
await settingsFiles(async (files) => {
|
|
1590
|
+
const translations = {
|
|
1591
|
+
greeting: "updated",
|
|
1592
|
+
nested: { empty: "No entries" },
|
|
1593
|
+
};
|
|
1594
|
+
|
|
1595
|
+
const response = await put(
|
|
1596
|
+
"/api/v1/settings/i18n/settings/en",
|
|
1597
|
+
{ translations },
|
|
1598
|
+
user.access_token,
|
|
1599
|
+
);
|
|
1600
|
+
|
|
1601
|
+
expect(response.status).toBe(200);
|
|
1602
|
+
|
|
1603
|
+
const read = await get(
|
|
1604
|
+
"/api/v1/settings/i18n/settings/en",
|
|
1605
|
+
user.access_token,
|
|
1606
|
+
);
|
|
1607
|
+
|
|
1608
|
+
expect(read.status).toBe(200);
|
|
1609
|
+
expect(await read.json()).toMatchObject({ data: { translations } });
|
|
1610
|
+
|
|
1611
|
+
const { readFile } = await import("node:fs/promises");
|
|
1612
|
+
|
|
1613
|
+
expect(JSON.parse(await readFile(files.locale, "utf8"))).toEqual(
|
|
1614
|
+
translations,
|
|
1615
|
+
);
|
|
1616
|
+
});
|
|
1617
|
+
});
|
|
1618
|
+
|
|
1619
|
+
async function logFiles(run: (dir: string) => Promise<void>) {
|
|
1620
|
+
const { mkdtemp, rm } = await import("node:fs/promises");
|
|
1621
|
+
const { tmpdir } = await import("node:os");
|
|
1622
|
+
const { join } = await import("node:path");
|
|
1623
|
+
const dir = await mkdtemp(join(tmpdir(), "tulipes-log-http-"));
|
|
1624
|
+
const env = handle.ctx.Environment;
|
|
1625
|
+
|
|
1626
|
+
const previous = {
|
|
1627
|
+
file: env.get("LOG_FILE"),
|
|
1628
|
+
dir: env.get("LOG_DIR"),
|
|
1629
|
+
name: env.get("LOG_BASENAME"),
|
|
1630
|
+
};
|
|
1631
|
+
|
|
1632
|
+
env.store.set("LOG_FILE", true);
|
|
1633
|
+
|
|
1634
|
+
env.store.set("LOG_DIR", dir);
|
|
1635
|
+
|
|
1636
|
+
env.store.set("LOG_BASENAME", "app");
|
|
1637
|
+
|
|
1638
|
+
try {
|
|
1639
|
+
await run(dir);
|
|
1640
|
+
} finally {
|
|
1641
|
+
env.store.set("LOG_FILE", previous.file);
|
|
1642
|
+
|
|
1643
|
+
env.store.set("LOG_DIR", previous.dir);
|
|
1644
|
+
|
|
1645
|
+
env.store.set("LOG_BASENAME", previous.name);
|
|
1646
|
+
|
|
1647
|
+
await rm(dir, { recursive: true, force: true });
|
|
1648
|
+
}
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
test("log streaming enforces sysadmin access and never follows linked log targets", async () => {
|
|
1652
|
+
const ordinary = await account();
|
|
1653
|
+
const user = await sysadmin();
|
|
1654
|
+
|
|
1655
|
+
await logFiles(async (dir) => {
|
|
1656
|
+
const { writeFile, symlink } = await import("node:fs/promises");
|
|
1657
|
+
const { join } = await import("node:path");
|
|
1658
|
+
const canary = randomBytes(24).toString("hex");
|
|
1659
|
+
|
|
1660
|
+
await writeFile(join(dir, "private"), canary);
|
|
1661
|
+
|
|
1662
|
+
await symlink(join(dir, "private"), join(dir, "app-backend.log"));
|
|
1663
|
+
|
|
1664
|
+
expect(
|
|
1665
|
+
(await get("/api/v1/settings/logs/stream", ordinary.access_token)).status,
|
|
1666
|
+
).toBe(403);
|
|
1667
|
+
|
|
1668
|
+
const response = await get(
|
|
1669
|
+
"/api/v1/settings/logs/stream",
|
|
1670
|
+
user.access_token,
|
|
1671
|
+
);
|
|
1672
|
+
|
|
1673
|
+
expect(response.status).toBe(409);
|
|
1674
|
+
expect((await response.text()).includes(canary)).toBe(false);
|
|
1675
|
+
});
|
|
1676
|
+
});
|
|
1677
|
+
|
|
1678
|
+
test("log stream slots are capped per account and released on HTTP cancellation", async () => {
|
|
1679
|
+
const user = await sysadmin();
|
|
1680
|
+
|
|
1681
|
+
await logFiles(async (dir) => {
|
|
1682
|
+
const { writeFile } = await import("node:fs/promises");
|
|
1683
|
+
const { join } = await import("node:path");
|
|
1684
|
+
|
|
1685
|
+
await writeFile(
|
|
1686
|
+
join(dir, "app-backend.2026-09-10.1.log"),
|
|
1687
|
+
JSON.stringify({ level: 30, msg: "retained line" }) + "\\n",
|
|
1688
|
+
);
|
|
1689
|
+
|
|
1690
|
+
const first = await get("/api/v1/settings/logs/stream", user.access_token);
|
|
1691
|
+
const second = await get("/api/v1/settings/logs/stream", user.access_token);
|
|
1692
|
+
|
|
1693
|
+
try {
|
|
1694
|
+
expect(first.status).toBe(200);
|
|
1695
|
+
expect(first.headers.get("content-type")).toContain("text/event-stream");
|
|
1696
|
+
|
|
1697
|
+
const blocked = await get(
|
|
1698
|
+
"/api/v1/settings/logs/stream",
|
|
1699
|
+
user.access_token,
|
|
1700
|
+
);
|
|
1701
|
+
|
|
1702
|
+
expect(blocked.status).toBe(429);
|
|
1703
|
+
|
|
1704
|
+
expect(await blocked.json()).toMatchObject({
|
|
1705
|
+
errors: [{ code: "TOO_MANY_LOG_STREAMS" }],
|
|
1706
|
+
});
|
|
1707
|
+
} finally {
|
|
1708
|
+
await first.body?.cancel();
|
|
1709
|
+
|
|
1710
|
+
await second.body?.cancel();
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
let reopened: Response | undefined;
|
|
1714
|
+
|
|
1715
|
+
await vi.waitFor(async () => {
|
|
1716
|
+
const response = await get(
|
|
1717
|
+
"/api/v1/settings/logs/stream",
|
|
1718
|
+
user.access_token,
|
|
1719
|
+
);
|
|
1720
|
+
|
|
1721
|
+
if (response.status === 200) reopened = response;
|
|
1722
|
+
|
|
1723
|
+
expect(response.status).toBe(200);
|
|
1724
|
+
});
|
|
1725
|
+
|
|
1726
|
+
try {
|
|
1727
|
+
const reader = reopened!.body!.getReader();
|
|
1728
|
+
let received = "";
|
|
1729
|
+
|
|
1730
|
+
try {
|
|
1731
|
+
while (!received.includes("retained line")) {
|
|
1732
|
+
const part = await reader.read();
|
|
1733
|
+
|
|
1734
|
+
if (part.done) break;
|
|
1735
|
+
|
|
1736
|
+
received += new TextDecoder().decode(part.value);
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
expect(received).toContain("event: ready");
|
|
1740
|
+
expect(received).toContain("retained line");
|
|
1741
|
+
} finally {
|
|
1742
|
+
await reader.cancel();
|
|
1743
|
+
}
|
|
1744
|
+
} finally {
|
|
1745
|
+
if (!reopened!.body!.locked) await reopened!.body!.cancel();
|
|
1746
|
+
}
|
|
1747
|
+
});
|
|
1748
|
+
});
|
|
1749
|
+
|
|
1750
|
+
test.each([
|
|
1751
|
+
"role removal",
|
|
1752
|
+
"user deletion",
|
|
1753
|
+
"session revocation",
|
|
1754
|
+
"session expiry",
|
|
1755
|
+
"access revocation",
|
|
1756
|
+
"logout",
|
|
1757
|
+
"password replacement",
|
|
1758
|
+
"refresh reuse",
|
|
1759
|
+
])("an open log stream closes after %s", async (change) => {
|
|
1760
|
+
const user = await sysadmin();
|
|
1761
|
+
|
|
1762
|
+
const { verify, endSession, revokeAccess } = await import(
|
|
1763
|
+
"../modules/auth/helpers/tokens.js"
|
|
1764
|
+
);
|
|
1765
|
+
|
|
1766
|
+
const claims = verify(handle.ctx, user.access_token, "access")!;
|
|
1767
|
+
|
|
1768
|
+
await logFiles(async (dir) => {
|
|
1769
|
+
const response = await get(
|
|
1770
|
+
"/api/v1/settings/logs/stream?tail=0",
|
|
1771
|
+
user.access_token,
|
|
1772
|
+
);
|
|
1773
|
+
|
|
1774
|
+
expect(response.status).toBe(200);
|
|
1775
|
+
|
|
1776
|
+
const reader = response.body!.getReader();
|
|
1777
|
+
const decoder = new TextDecoder();
|
|
1778
|
+
let body = "";
|
|
1779
|
+
|
|
1780
|
+
try {
|
|
1781
|
+
// Observe actual SSE delivery before changing the authority behind it.
|
|
1782
|
+
while (!body.includes("event: ready")) {
|
|
1783
|
+
const part = await reader.read();
|
|
1784
|
+
|
|
1785
|
+
expect(part.done).toBe(false);
|
|
1786
|
+
|
|
1787
|
+
body += decoder.decode(part.value);
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1790
|
+
if (change === "role removal") {
|
|
1791
|
+
await handle.ctx
|
|
1792
|
+
.models!.get("User")
|
|
1793
|
+
.updateOne({ _id: user.userId }, { role: "admin" });
|
|
1794
|
+
} else if (change === "user deletion") {
|
|
1795
|
+
await handle.ctx.models!.get("User").deleteOne({ _id: user.userId });
|
|
1796
|
+
} else if (change === "session revocation") {
|
|
1797
|
+
await endSession(handle.ctx, claims.sid);
|
|
1798
|
+
} else if (change === "session expiry") {
|
|
1799
|
+
await handle.ctx
|
|
1800
|
+
.models!.get("Session")
|
|
1801
|
+
.updateOne({ sid: claims.sid }, { expires_at: new Date(0) });
|
|
1802
|
+
} else if (change === "access revocation") {
|
|
1803
|
+
await revokeAccess(handle.ctx, claims);
|
|
1804
|
+
} else if (change === "logout") {
|
|
1805
|
+
expect(
|
|
1806
|
+
(await post("/api/v1/auth/logout", {}, user.access_token)).status,
|
|
1807
|
+
).toBe(200);
|
|
1808
|
+
} else if (change === "password replacement") {
|
|
1809
|
+
const { setPassword } = await import(
|
|
1810
|
+
"../modules/auth/helpers/passwords.js"
|
|
1811
|
+
);
|
|
1812
|
+
|
|
1813
|
+
await setPassword(
|
|
1814
|
+
handle.ctx,
|
|
1815
|
+
user.userId,
|
|
1816
|
+
randomBytes(24).toString("hex"),
|
|
1817
|
+
);
|
|
1818
|
+
} else {
|
|
1819
|
+
expect(
|
|
1820
|
+
(
|
|
1821
|
+
await post("/api/v1/auth/refresh", {
|
|
1822
|
+
refresh_token: user.refresh_token,
|
|
1823
|
+
})
|
|
1824
|
+
).status,
|
|
1825
|
+
).toBe(200);
|
|
1826
|
+
|
|
1827
|
+
expect(
|
|
1828
|
+
(
|
|
1829
|
+
await post("/api/v1/auth/refresh", {
|
|
1830
|
+
refresh_token: user.refresh_token,
|
|
1831
|
+
})
|
|
1832
|
+
).status,
|
|
1833
|
+
).toBe(401);
|
|
1834
|
+
}
|
|
1835
|
+
|
|
1836
|
+
const started = performance.now();
|
|
1837
|
+
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
1838
|
+
|
|
1839
|
+
try {
|
|
1840
|
+
await Promise.race([
|
|
1841
|
+
(async () => {
|
|
1842
|
+
for (;;) {
|
|
1843
|
+
const part = await reader.read();
|
|
1844
|
+
|
|
1845
|
+
if (part.done) break;
|
|
1846
|
+
|
|
1847
|
+
body += decoder.decode(part.value);
|
|
1848
|
+
}
|
|
1849
|
+
})(),
|
|
1850
|
+
new Promise<never>((_resolve, reject) => {
|
|
1851
|
+
timeout = setTimeout(
|
|
1852
|
+
() => reject(new Error("Revoked stream stayed open")),
|
|
1853
|
+
3000,
|
|
1854
|
+
);
|
|
1855
|
+
}),
|
|
1856
|
+
]);
|
|
1857
|
+
} finally {
|
|
1858
|
+
clearTimeout(timeout);
|
|
1859
|
+
}
|
|
1860
|
+
|
|
1861
|
+
// Two-second authorization lease plus scheduling tolerance on this runner.
|
|
1862
|
+
expect(performance.now() - started).toBeLessThan(3000);
|
|
1863
|
+
|
|
1864
|
+
const { writeFile } = await import("node:fs/promises");
|
|
1865
|
+
const { join } = await import("node:path");
|
|
1866
|
+
|
|
1867
|
+
await writeFile(join(dir, "app-backend.log"), "after revocation\\n");
|
|
1868
|
+
|
|
1869
|
+
expect((await reader.read()).done).toBe(true);
|
|
1870
|
+
expect(body).not.toContain("after revocation");
|
|
1871
|
+
|
|
1872
|
+
// A different administrator can still use the controller after closure.
|
|
1873
|
+
const other = await sysadmin();
|
|
1874
|
+
|
|
1875
|
+
const replacement = await get(
|
|
1876
|
+
"/api/v1/settings/logs/stream?tail=0",
|
|
1877
|
+
other.access_token,
|
|
1878
|
+
);
|
|
1879
|
+
|
|
1880
|
+
expect(replacement.status).toBe(200);
|
|
1881
|
+
|
|
1882
|
+
await replacement.body?.cancel();
|
|
1883
|
+
} finally {
|
|
1884
|
+
await reader.cancel();
|
|
1885
|
+
}
|
|
1886
|
+
});
|
|
1887
|
+
});
|
|
1888
|
+
|
|
1889
|
+
test("restart acknowledgment finishes before PM2 commands and retains its audit", async () => {
|
|
1890
|
+
const user = await sysadmin();
|
|
1891
|
+
const specifier = "pm2";
|
|
1892
|
+
|
|
1893
|
+
const pm2 = (await import(specifier)).default as {
|
|
1894
|
+
connect(callback: (error?: Error) => void): void;
|
|
1895
|
+
list(
|
|
1896
|
+
callback: (error: Error | undefined, processes: unknown[]) => void,
|
|
1897
|
+
): void;
|
|
1898
|
+
restart(id: number, callback: (error?: Error) => void): void;
|
|
1899
|
+
disconnect(): void;
|
|
1900
|
+
};
|
|
1901
|
+
|
|
1902
|
+
const { realpathSync } = await import("node:fs");
|
|
1903
|
+
const root = realpathSync(fileURLToPath(new URL("../", import.meta.url)));
|
|
1904
|
+
const oldHome = process.env.PM2_HOME;
|
|
1905
|
+
|
|
1906
|
+
process.env.PM2_HOME = "/unused-restart-response-test";
|
|
1907
|
+
|
|
1908
|
+
const connect = vi
|
|
1909
|
+
.spyOn(pm2, "connect")
|
|
1910
|
+
.mockImplementation((callback) => callback());
|
|
1911
|
+
|
|
1912
|
+
const list = vi.spyOn(pm2, "list").mockImplementation((callback) =>
|
|
1913
|
+
callback(undefined, [
|
|
1914
|
+
{
|
|
1915
|
+
name: "test-backend",
|
|
1916
|
+
pm_id: 1,
|
|
1917
|
+
pid: 1001,
|
|
1918
|
+
pm2_env: { pm_cwd: root },
|
|
1919
|
+
},
|
|
1920
|
+
]),
|
|
1921
|
+
);
|
|
1922
|
+
|
|
1923
|
+
const disconnect = vi.spyOn(pm2, "disconnect").mockImplementation(() => {});
|
|
1924
|
+
const restarted = barrier();
|
|
1925
|
+
|
|
1926
|
+
const restart = vi
|
|
1927
|
+
.spyOn(pm2, "restart")
|
|
1928
|
+
.mockImplementation((_id, callback) => {
|
|
1929
|
+
restarted.resolve();
|
|
1930
|
+
|
|
1931
|
+
callback();
|
|
1932
|
+
});
|
|
1933
|
+
|
|
1934
|
+
try {
|
|
1935
|
+
const response = await post(
|
|
1936
|
+
"/api/v1/settings/reboot",
|
|
1937
|
+
{},
|
|
1938
|
+
user.access_token,
|
|
1939
|
+
);
|
|
1940
|
+
|
|
1941
|
+
expect(response.status).toBe(202);
|
|
1942
|
+
|
|
1943
|
+
expect(await response.json()).toMatchObject({
|
|
1944
|
+
success: true,
|
|
1945
|
+
data: { supervised: true, accepted: true, scheduled: ["test-backend"] },
|
|
1946
|
+
});
|
|
1947
|
+
|
|
1948
|
+
await restarted.promise;
|
|
1949
|
+
|
|
1950
|
+
expect(restart).toHaveBeenCalledWith(1, expect.any(Function));
|
|
1951
|
+
|
|
1952
|
+
expect(
|
|
1953
|
+
await handle.ctx
|
|
1954
|
+
.models!.get("AuditEvent")
|
|
1955
|
+
.findOne({ actor: user.userId })
|
|
1956
|
+
.lean(),
|
|
1957
|
+
).toMatchObject({ action: "reboot.requested" });
|
|
1958
|
+
} finally {
|
|
1959
|
+
connect.mockRestore();
|
|
1960
|
+
|
|
1961
|
+
list.mockRestore();
|
|
1962
|
+
|
|
1963
|
+
restart.mockRestore();
|
|
1964
|
+
|
|
1965
|
+
disconnect.mockRestore();
|
|
1966
|
+
|
|
1967
|
+
if (oldHome === undefined) delete process.env.PM2_HOME;
|
|
1968
|
+
else process.env.PM2_HOME = oldHome;
|
|
1969
|
+
}
|
|
1970
|
+
});
|
|
1971
|
+
|
|
1972
|
+
test("shutdown drains settings streams and a later boot owns fresh stream admission", async () => {
|
|
1973
|
+
const user = await sysadmin();
|
|
1974
|
+
|
|
1975
|
+
await logFiles(async (dir) => {
|
|
1976
|
+
const { writeFile } = await import("node:fs/promises");
|
|
1977
|
+
const { join } = await import("node:path");
|
|
1978
|
+
|
|
1979
|
+
await writeFile(
|
|
1980
|
+
join(dir, "app-backend.log"),
|
|
1981
|
+
JSON.stringify({ msg: "before drain" }) + "\\n",
|
|
1982
|
+
);
|
|
1983
|
+
|
|
1984
|
+
const active = await get("/api/v1/settings/logs/stream", user.access_token);
|
|
1985
|
+
|
|
1986
|
+
const idle = await get(
|
|
1987
|
+
"/api/v1/settings/logs/stream?tail=0",
|
|
1988
|
+
user.access_token,
|
|
1989
|
+
);
|
|
1990
|
+
|
|
1991
|
+
expect(active.status).toBe(200);
|
|
1992
|
+
expect(idle.status).toBe(200);
|
|
1993
|
+
|
|
1994
|
+
const readers = [active.body!.getReader(), idle.body!.getReader()];
|
|
1995
|
+
|
|
1996
|
+
try {
|
|
1997
|
+
for (const [index, reader] of readers.entries()) {
|
|
1998
|
+
let body = "";
|
|
1999
|
+
|
|
2000
|
+
do {
|
|
2001
|
+
const part = await reader.read();
|
|
2002
|
+
|
|
2003
|
+
expect(part.done).toBe(false);
|
|
2004
|
+
|
|
2005
|
+
body += new TextDecoder().decode(part.value);
|
|
2006
|
+
} while (!body.includes(index ? "event: ready" : "before drain"));
|
|
2007
|
+
}
|
|
2008
|
+
|
|
2009
|
+
const stopping = handle.shutdown();
|
|
2010
|
+
|
|
2011
|
+
expect(handle.shutdown()).toBe(stopping);
|
|
2012
|
+
|
|
2013
|
+
await expect(stopping).resolves.toBeUndefined();
|
|
2014
|
+
|
|
2015
|
+
for (const reader of readers)
|
|
2016
|
+
expect((await reader.read()).done).toBe(true);
|
|
2017
|
+
|
|
2018
|
+
// The module is imported once, but stream admission belongs to each boot.
|
|
2019
|
+
const next = await boot({
|
|
2020
|
+
rootDir: fileURLToPath(new URL("../", import.meta.url)),
|
|
2021
|
+
appEnv: "test",
|
|
2022
|
+
handleSignals: false,
|
|
2023
|
+
});
|
|
2024
|
+
|
|
2025
|
+
try {
|
|
2026
|
+
next.env.store.set("LOG_FILE", true);
|
|
2027
|
+
|
|
2028
|
+
next.env.store.set("LOG_DIR", dir);
|
|
2029
|
+
|
|
2030
|
+
next.env.store.set("LOG_BASENAME", "app");
|
|
2031
|
+
|
|
2032
|
+
const address = next.server!.address();
|
|
2033
|
+
|
|
2034
|
+
if (!address || typeof address === "string")
|
|
2035
|
+
throw new Error("No listener after restart");
|
|
2036
|
+
|
|
2037
|
+
const response = await fetch(
|
|
2038
|
+
"http://127.0.0.1:" +
|
|
2039
|
+
address.port +
|
|
2040
|
+
"/api/v1/settings/logs/stream?tail=0",
|
|
2041
|
+
{
|
|
2042
|
+
headers: { authorization: "Bearer " + user.access_token },
|
|
2043
|
+
},
|
|
2044
|
+
);
|
|
2045
|
+
|
|
2046
|
+
expect(response.status).toBe(200);
|
|
2047
|
+
|
|
2048
|
+
await response.body?.cancel();
|
|
2049
|
+
} finally {
|
|
2050
|
+
await next.shutdown();
|
|
2051
|
+
}
|
|
2052
|
+
} finally {
|
|
2053
|
+
await Promise.all(readers.map((reader) => reader.cancel()));
|
|
2054
|
+
}
|
|
2055
|
+
});
|
|
2056
|
+
});
|
|
2057
|
+
`
|
|
2058
|
+
: `import { randomBytes } from "node:crypto";
|
|
2059
|
+
import { fileURLToPath } from "node:url";
|
|
2060
|
+
|
|
2061
|
+
import { afterAll, beforeAll, expect, test, vi } from "vitest";
|
|
2062
|
+
import { boot, inspectRoutes, type BootHandle } from "@tulipes/core/boot";
|
|
2063
|
+
import { extract, toOpenApi, toPostman } from "@tulipes/spec";
|
|
2064
|
+
|
|
2065
|
+
let handle: BootHandle;
|
|
2066
|
+
let base: string;
|
|
2067
|
+
|
|
2068
|
+
beforeAll(async () => {
|
|
2069
|
+
vi.stubEnv("PORT", "0");
|
|
2070
|
+
|
|
2071
|
+
handle = await boot({
|
|
2072
|
+
rootDir: fileURLToPath(new URL("../", import.meta.url)),
|
|
2073
|
+
appEnv: "test",
|
|
2074
|
+
handleSignals: false,
|
|
2075
|
+
});
|
|
2076
|
+
|
|
2077
|
+
const address = handle.server?.address();
|
|
2078
|
+
|
|
2079
|
+
if (!address || typeof address === "string")
|
|
2080
|
+
throw new Error("No HTTP listener");
|
|
2081
|
+
|
|
2082
|
+
base = "http://127.0.0.1:" + address.port;
|
|
2083
|
+
}, 15_000);
|
|
2084
|
+
|
|
2085
|
+
afterAll(async () => {
|
|
2086
|
+
await handle?.shutdown();
|
|
2087
|
+
|
|
2088
|
+
vi.unstubAllEnvs();
|
|
2089
|
+
});
|
|
2090
|
+
|
|
2091
|
+
test("hello responds through the declared public endpoint", async () => {
|
|
2092
|
+
const response = await fetch(base + "/api/v1/hello");
|
|
2093
|
+
|
|
2094
|
+
expect(response.status).toBe(200);
|
|
2095
|
+
expect(await response.json()).toMatchObject({ success: true, errors: [] });
|
|
2096
|
+
});
|
|
2097
|
+
|
|
2098
|
+
test("unknown endpoints return the error envelope", async () => {
|
|
2099
|
+
const response = await fetch(base + "/not-a-route");
|
|
2100
|
+
|
|
2101
|
+
expect(response.status).toBe(404);
|
|
2102
|
+
expect(await response.json()).toMatchObject({ success: false, data: null });
|
|
2103
|
+
});
|
|
2104
|
+
|
|
2105
|
+
test("offline declarations describe the mounted runtime routes and schemas", async () => {
|
|
2106
|
+
const context = await inspectRoutes({
|
|
2107
|
+
rootDir: fileURLToPath(new URL("../", import.meta.url)),
|
|
2108
|
+
});
|
|
2109
|
+
|
|
2110
|
+
const offline = { ...extract(context), app: { name: "parity" } };
|
|
2111
|
+
const runtime = { ...extract(handle.ctx), app: { name: "parity" } };
|
|
2112
|
+
|
|
2113
|
+
expect(await toOpenApi(offline)).toEqual(await toOpenApi(runtime));
|
|
2114
|
+
expect(await toPostman(offline)).toEqual(await toPostman(runtime));
|
|
2115
|
+
});
|
|
2116
|
+
|
|
2117
|
+
test("health needs no external infrastructure", async () => {
|
|
2118
|
+
expect(handle.ctx.models).toBeUndefined();
|
|
2119
|
+
expect(handle.ctx.queues).toBeUndefined();
|
|
2120
|
+
|
|
2121
|
+
const response = await fetch(base + "/health");
|
|
2122
|
+
|
|
2123
|
+
expect(await response.json()).toMatchObject({
|
|
2124
|
+
success: true,
|
|
2125
|
+
data: { status: "ok" },
|
|
2126
|
+
});
|
|
2127
|
+
});
|
|
2128
|
+
`;
|
|
2129
|
+
}
|
|
2130
|
+
export function renderMinimalProject(name, coreVersion, cliVersion) {
|
|
2131
|
+
const core = `^${coreVersion}`;
|
|
2132
|
+
const cli = `^${cliVersion}`;
|
|
2133
|
+
const manifest = (module, priority, dependencies = {}) => json({
|
|
2134
|
+
name: `@${name}/${module}`, private: true, type: "module", version: "0.0.0",
|
|
2135
|
+
tulipes: { offline: true, ...(priority === undefined ? {} : { tier: "sys", priority }) },
|
|
2136
|
+
dependencies: { "@tulipes/core": core, ...dependencies },
|
|
2137
|
+
});
|
|
2138
|
+
return [
|
|
2139
|
+
["package.json", json({
|
|
2140
|
+
name, private: true, type: "module", packageManager: "yarn@4.18.0", engines: { node: ">=24.0.0 <25" },
|
|
2141
|
+
workspaces: ["modules/*"],
|
|
2142
|
+
scripts: { dev: "tulipes dev", start: "tsx app.ts", sync: "tulipes sync", check: "tulipes env:check",
|
|
2143
|
+
typecheck: "tsc --noEmit", test: "vitest run", spec: "tulipes spec --offline --openapi docs/openapi.json --postman docs/collection.json" },
|
|
2144
|
+
dependencies: { "@tulipes/core": core, express: "^5", zod: "^3.25" },
|
|
2145
|
+
devDependencies: { "@tulipes/cli": cli, "@tulipes/spec": "^0.3.0-rc.6", "@types/express": "^5", "@types/node": "^24", tsx: "^4", typescript: "^7", vitest: "^5.0.0", vite: "^8.2.2" },
|
|
2146
|
+
})],
|
|
2147
|
+
["tsconfig.json", json({ compilerOptions: { target: "ES2023", module: "NodeNext", moduleResolution: "NodeNext", strict: true,
|
|
2148
|
+
noUncheckedIndexedAccess: true, verbatimModuleSyntax: true, skipLibCheck: true, noEmit: true }, include: ["app.ts", "modules", "types", "config", "tests"] })],
|
|
2149
|
+
[".yarnrc.yml", "nodeLinker: node-modules\n"],
|
|
2150
|
+
[".gitignore", "node_modules/\n.envs/\ncoverage/\nlogs/\n"],
|
|
2151
|
+
["app.ts", 'import { boot } from "@tulipes/core/boot";\n\nawait boot({ rootDir: import.meta.dirname, mode: "backend" });\n'],
|
|
2152
|
+
["tests/app.test.ts", starterTests(false)],
|
|
2153
|
+
["vitest.config.ts", "import { defineConfig } from \"vitest/config\";\n\nexport default defineConfig({ test: { include: [\"tests/**/*.test.ts\"], execArgv: [\"--import\", \"tsx\"] } });\n"],
|
|
2154
|
+
["modules/core/package.json", manifest("core", 0)],
|
|
2155
|
+
["modules/core/meta.variables.json", json({ variables: [{ name: "PORT", type: "number", default: 3000, description: "HTTP listener port" }] })],
|
|
2156
|
+
["modules/core/module.acl.ts", `import type { AclBuilder } from "@tulipes/core/acl";
|
|
2157
|
+
export default function permissions(acl: AclBuilder) {
|
|
2158
|
+
acl.defineRole("guest").allow("guest", "core:health");
|
|
2159
|
+
}
|
|
2160
|
+
`],
|
|
2161
|
+
["modules/security/package.json", manifest("security", 10, { express: "^5", helmet: "^8" })],
|
|
2162
|
+
["modules/security/routes/security.routes.ts", `import express from "express";
|
|
2163
|
+
import helmet from "helmet";
|
|
2164
|
+
import { defineRoutes } from "@tulipes/core/http";
|
|
2165
|
+
export default defineRoutes(({ app }) => {
|
|
2166
|
+
app.disable("x-powered-by");
|
|
2167
|
+
app.use(helmet());
|
|
2168
|
+
app.use(express.json({ limit: "100kb" }));
|
|
2169
|
+
});
|
|
2170
|
+
`],
|
|
2171
|
+
["modules/hello/package.json", manifest("hello", undefined, { express: "^5", zod: "^3.25" })],
|
|
2172
|
+
["modules/hello/module.acl.ts", `import type { AclBuilder } from "@tulipes/core/acl";
|
|
2173
|
+
export default function permissions(acl: AclBuilder) { acl.allow("guest", "hello:read"); }
|
|
2174
|
+
`],
|
|
2175
|
+
["modules/hello/i18n/en.json", json({ greeting: "Hello" })],
|
|
2176
|
+
["modules/hello/routes/hello.routes.ts", `import { Router } from "express";
|
|
2177
|
+
import { z } from "zod/v4";
|
|
2178
|
+
import { defineRoutes } from "@tulipes/core/http";
|
|
2179
|
+
export default defineRoutes(({ app, rai, routes }) => {
|
|
2180
|
+
const router = Router();
|
|
2181
|
+
router.get("/hello", rai({ id: "hello:read", name: "Hello", returns: z.object({ message: z.string() }) }), (req, res) => {
|
|
2182
|
+
res.respond({ data: { message: req.t("greeting") } });
|
|
2183
|
+
});
|
|
2184
|
+
routes.mount(app, "/api/v1", router);
|
|
2185
|
+
});
|
|
2186
|
+
`],
|
|
2187
|
+
["modules/hello/routes/health.routes.ts", `import { Router } from "express";
|
|
2188
|
+
import { defineRoutes } from "@tulipes/core/http";
|
|
2189
|
+
export default defineRoutes(({ rai }) => {
|
|
2190
|
+
const router = Router();
|
|
2191
|
+
router.get("/health", rai({ id: "core:health", name: "Health" }), (_req, res) => {
|
|
2192
|
+
res.respond({ data: { status: "ok" } });
|
|
2193
|
+
});
|
|
2194
|
+
return router;
|
|
2195
|
+
});
|
|
2196
|
+
`],
|
|
2197
|
+
["README.md", `# ${name}\n\nMinimal Tulipes API. Requires Node 24.x and Corepack; no MongoDB, Redis or credentials.\n\n\`\`\`sh\ncorepack enable\nyarn install\nyarn sync\nyarn typecheck\nyarn check\nyarn test\nyarn spec\nyarn dev\n\`\`\`\n\nOpen http://localhost:3000/api/v1/hello or http://localhost:3000/health.\n\nRoutes declare a RAI and use res.respond. Middleware belongs to sys modules.\nCore still installs optional infrastructure libraries, but this app opens no infrastructure connections.\nThe production preset adds auth, users, settings, queues and deployment examples:\n\n\`tulipes init another-api --preset production\`\n`],
|
|
2198
|
+
];
|
|
2199
|
+
}
|
|
2200
|
+
//# sourceMappingURL=minimal.js.map
|