@cosmicdrift/kumiko-server-runtime 0.329.0 → 0.330.0

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.
Files changed (90) hide show
  1. package/README.md +15 -0
  2. package/dist/boot/apply-boot-seeds.d.ts +8 -0
  3. package/{src/boot/apply-boot-seeds.ts → dist/boot/apply-boot-seeds.js} +2 -10
  4. package/dist/boot/boot-crypto.d.ts +9 -0
  5. package/dist/boot/boot-crypto.js +39 -0
  6. package/dist/boot/job-run-logger.d.ts +19 -0
  7. package/dist/boot/job-run-logger.js +44 -0
  8. package/dist/boot/pii-event-backfill-on-boot.d.ts +13 -0
  9. package/dist/boot/pii-event-backfill-on-boot.js +25 -0
  10. package/dist/build-prod-bundle.d.ts +103 -0
  11. package/dist/build-prod-bundle.js +751 -0
  12. package/dist/bun-serve-options.d.ts +32 -0
  13. package/dist/bun-serve-options.js +49 -0
  14. package/dist/compose-features.d.ts +31 -0
  15. package/dist/compose-features.js +124 -0
  16. package/dist/extra-routes-deps.d.ts +30 -0
  17. package/dist/extra-routes-deps.js +4 -0
  18. package/dist/index.d.ts +6 -0
  19. package/dist/index.js +7 -0
  20. package/dist/pii-boot-gate.d.ts +13 -0
  21. package/dist/pii-boot-gate.js +46 -0
  22. package/dist/renderer-web-fonts.d.ts +9 -0
  23. package/{src/renderer-web-fonts.ts → dist/renderer-web-fonts.js} +14 -18
  24. package/dist/resolve-tailwind-cli.d.ts +14 -0
  25. package/dist/resolve-tailwind-cli.js +36 -0
  26. package/dist/run-prod-app-boot-context.d.ts +43 -0
  27. package/dist/run-prod-app-boot-context.js +198 -0
  28. package/dist/run-prod-app-static-files.d.ts +32 -0
  29. package/dist/run-prod-app-static-files.js +280 -0
  30. package/dist/run-prod-app.d.ts +471 -0
  31. package/dist/run-prod-app.js +741 -0
  32. package/dist/run-worker-app.d.ts +92 -0
  33. package/dist/run-worker-app.js +220 -0
  34. package/dist/security-headers.d.ts +20 -0
  35. package/dist/security-headers.js +49 -0
  36. package/dist/session-boot-gate.d.ts +11 -0
  37. package/dist/session-boot-gate.js +22 -0
  38. package/dist/session-wiring.d.ts +11 -0
  39. package/{src/session-wiring.ts → dist/session-wiring.js} +2 -6
  40. package/dist/try-hono-first.d.ts +24 -0
  41. package/{src/try-hono-first.ts → dist/try-hono-first.js} +8 -36
  42. package/package.json +102 -36
  43. package/src/changes.json +6 -0
  44. package/src/__tests__/boot-extra-context.test.ts +0 -163
  45. package/src/__tests__/boot-probe-fixture.ts +0 -64
  46. package/src/__tests__/build-prod-bundle.inprocess.test.ts +0 -233
  47. package/src/__tests__/build-prod-bundle.test.ts +0 -724
  48. package/src/__tests__/cache-headers.test.ts +0 -83
  49. package/src/__tests__/compose-features-mfa-wiring.integration.test.ts +0 -308
  50. package/src/__tests__/compose-features-wiring.integration.test.ts +0 -609
  51. package/src/__tests__/compose-features.test.ts +0 -206
  52. package/src/__tests__/config-seed-boot.integration.test.ts +0 -158
  53. package/src/__tests__/pii-boot-gate.test.ts +0 -112
  54. package/src/__tests__/renderer-web-css-relocation.integration.test.ts +0 -85
  55. package/src/__tests__/renderer-web-shell-sentinel.test.ts +0 -35
  56. package/src/__tests__/renderer-web-theme-plugin.integration.test.ts +0 -77
  57. package/src/__tests__/require-env.test.ts +0 -34
  58. package/src/__tests__/resolve-auth-mail.test.ts +0 -144
  59. package/src/__tests__/resolve-tailwind-cli.test.ts +0 -81
  60. package/src/__tests__/run-prod-app-dry-run.test.ts +0 -135
  61. package/src/__tests__/run-prod-app-env-source.test.ts +0 -118
  62. package/src/__tests__/run-prod-app-kms-slots.test.ts +0 -108
  63. package/src/__tests__/run-prod-app-spec.test.ts +0 -167
  64. package/src/__tests__/run-prod-app-static-files.test.ts +0 -298
  65. package/src/__tests__/run-prod-app.integration.test.ts +0 -1525
  66. package/src/__tests__/run-worker-app-boot.test.ts +0 -183
  67. package/src/__tests__/run-worker-app.integration.test.ts +0 -218
  68. package/src/__tests__/security-headers.test.ts +0 -88
  69. package/src/__tests__/session-boot-gate.test.ts +0 -70
  70. package/src/__tests__/session-wiring.test.ts +0 -16
  71. package/src/__tests__/try-hono-first.test.ts +0 -108
  72. package/src/__tests__/websocket-route.integration.test.ts +0 -695
  73. package/src/boot/__tests__/job-run-logger.integration.test.ts +0 -262
  74. package/src/boot/__tests__/job-run-logger.test.ts +0 -26
  75. package/src/boot/boot-crypto.ts +0 -67
  76. package/src/boot/job-run-logger.ts +0 -63
  77. package/src/boot/pii-event-backfill-on-boot.ts +0 -43
  78. package/src/build-prod-bundle.ts +0 -996
  79. package/src/bun-serve-options.ts +0 -110
  80. package/src/compose-features.ts +0 -197
  81. package/src/extra-routes-deps.ts +0 -38
  82. package/src/index.ts +0 -27
  83. package/src/pii-boot-gate.ts +0 -80
  84. package/src/resolve-tailwind-cli.ts +0 -45
  85. package/src/run-prod-app-boot-context.ts +0 -319
  86. package/src/run-prod-app-static-files.ts +0 -359
  87. package/src/run-prod-app.ts +0 -1420
  88. package/src/run-worker-app.ts +0 -366
  89. package/src/security-headers.ts +0 -67
  90. package/src/session-boot-gate.ts +0 -34
@@ -1,609 +0,0 @@
1
- // Wrapper-spezifischer Integration-Test für composeFeatures + auth-routes-
2
- // Verdrahtung. Ergänzt password-reset.integration.ts (das das Feature
3
- // direkt instantiiert) — hier wird der EXAKTE Bootstrap-Pfad gefahren den
4
- // runProdApp / runDevApp produzieren:
5
- //
6
- // composeFeatures([], { includeBundled: true, authOptions: {...} })
7
- // → setupTestStack(features=..., authConfig=...)
8
- // → POST /api/auth/request-password-reset
9
- //
10
- // Bug-Pattern den dieser Test pinst: composeFeatures.authOptions wird
11
- // NICHT an createAuthEmailPasswordFeature durchgereicht → routes mounten
12
- // (auth-routes-config tut das blind), aber die request-password-reset/
13
- // reset-password Handler fehlen im Feature → dispatcher kennt den
14
- // QualifiedName nicht → 5xx in Production. Whitebox-Variante in
15
- // compose-features.test.ts checkt nur Object.keys(writeHandlers); dieser
16
- // Test fährt den vollen HTTP-Roundtrip und kann den dispatch-error nicht
17
- // übersehen.
18
- //
19
- // Kein Mocking: setupTestStack bootet echte DB + Redis. reset/verify mailen
20
- // via delivery (ctx.notify → channel-email); der In-Memory-Transport fängt
21
- // die echte Mail ab — gewollter Capture ohne Mock-API (CLAUDE.md "Kein Mock
22
- // in *.integration.ts").
23
-
24
- import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
25
- import { randomBytes } from "node:crypto";
26
- import {
27
- AuthErrors,
28
- AuthHandlers,
29
- hashPassword,
30
- signToken,
31
- TokenPurpose,
32
- verifyToken,
33
- } from "@cosmicdrift/kumiko-bundled-features/auth-email-password";
34
- import {
35
- createChannelEmailFeature,
36
- createInMemoryTransport,
37
- } from "@cosmicdrift/kumiko-bundled-features/channel-email";
38
- import {
39
- configValuesTable,
40
- createConfigResolver,
41
- } from "@cosmicdrift/kumiko-bundled-features/config";
42
- import {
43
- createDeliveryFeature,
44
- createDeliveryTestContext,
45
- notificationPreferencesTable,
46
- } from "@cosmicdrift/kumiko-bundled-features/delivery";
47
- import { createRendererFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/renderer-foundation";
48
- import {
49
- createRendererSimpleFeature,
50
- simpleRenderer,
51
- } from "@cosmicdrift/kumiko-bundled-features/renderer-simple";
52
- import { createTemplateResolverFeature } from "@cosmicdrift/kumiko-bundled-features/template-resolver";
53
- import { tenantEntity, tenantMembershipsTable } from "@cosmicdrift/kumiko-bundled-features/tenant";
54
- import { seedTenantMembership } from "@cosmicdrift/kumiko-bundled-features/tenant/testing";
55
- import { UserHandlers, userEntity, userTable } from "@cosmicdrift/kumiko-bundled-features/user";
56
- import { LOCALE_HEADER_NAME } from "@cosmicdrift/kumiko-framework/api";
57
- import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
58
- import { registerMailTranslations } from "@cosmicdrift/kumiko-framework/i18n";
59
- import {
60
- setupTestStack,
61
- type TestStack,
62
- TestUsers,
63
- unsafeCreateEntityTable,
64
- unsafePushTables,
65
- } from "@cosmicdrift/kumiko-framework/stack";
66
- import { deleteRows } from "@cosmicdrift/kumiko-framework/testing";
67
- import { buildComposeAuthOptions, composeFeatures } from "../compose-features";
68
- import type { RunProdAppAuthOptions } from "../run-prod-app";
69
- import { resolveAuthMail } from "../run-prod-app-boot-context";
70
-
71
- // resolveMailLocale only recognizes locales with a registered mail bundle;
72
- // an empty bundle is enough to make "de" negotiable here.
73
- registerMailTranslations("de", {});
74
-
75
- const RESET_HMAC = randomBytes(32).toString("base64");
76
- const VERIFY_HMAC = randomBytes(32).toString("base64");
77
- const APP_RESET_URL = "https://app.example.com/reset-password";
78
- const APP_VERIFY_URL = "https://app.example.com/verify-email";
79
- const TEST_TENANT_ID: TenantId = "00000000-0000-4000-8000-000000000001" as TenantId;
80
- const systemAdmin = TestUsers.systemAdmin;
81
-
82
- // Pulls the magic-link out of the rendered mail HTML — the renderer-simple
83
- // button carries it as the only href. Replaces the old callback-captured
84
- // resetUrl now that reset/verify mail via delivery.
85
- function tokenUrlFromHtml(html: string): URL {
86
- const match = html.match(/href="([^"]*\?token=[^"]*)"/);
87
- if (!match?.[1]) throw new Error("no magic-link href in mail html");
88
- return new URL(match[1]);
89
- }
90
-
91
- async function bootStack(
92
- authOptionsKind: "with-both" | "with-reset-only" | "without-auth-options",
93
- ): Promise<{ stack: TestStack; emailTransport: ReturnType<typeof createInMemoryTransport> }> {
94
- const emailTransport = createInMemoryTransport();
95
-
96
- // Genau das was runProdApp/runDevApp machen würde — composeFeatures als
97
- // single-source der Feature-Liste. delivery + channel-email kommen als
98
- // App-Features dazu, weil reset/verify ihre Mail via ctx.notify schicken.
99
- const features = composeFeatures(
100
- [
101
- createTemplateResolverFeature(),
102
- createRendererFoundationFeature(),
103
- createDeliveryFeature(),
104
- createRendererSimpleFeature(),
105
- createChannelEmailFeature({
106
- transport: emailTransport,
107
- renderer: simpleRenderer,
108
- resolveEmail: async () => "unused@test.local",
109
- }),
110
- ],
111
- {
112
- includeBundled: true,
113
- ...(authOptionsKind !== "without-auth-options" && {
114
- authOptions: {
115
- passwordReset: { hmacSecret: RESET_HMAC, tokenTtlMinutes: 15, appUrl: APP_RESET_URL },
116
- ...(authOptionsKind === "with-both" && {
117
- emailVerification: {
118
- hmacSecret: VERIFY_HMAC,
119
- tokenTtlMinutes: 60,
120
- mode: "off",
121
- appUrl: APP_VERIFY_URL,
122
- },
123
- }),
124
- },
125
- }),
126
- },
127
- );
128
-
129
- const stack = await setupTestStack({
130
- features,
131
- extraContext: (deps) => ({
132
- ...createDeliveryTestContext(deps),
133
- configResolver: createConfigResolver(),
134
- }),
135
- authConfig: {
136
- membershipQuery: "tenant:query:memberships",
137
- loginHandler: AuthHandlers.login,
138
- // Routes IMMER mounten — egal welche authOptionsKind. Genau das
139
- // ist die Bug-Bedingung: Routes da, Handler eventuell nicht.
140
- passwordReset: {
141
- requestHandler: AuthHandlers.requestPasswordReset,
142
- confirmHandler: AuthHandlers.resetPassword,
143
- },
144
- ...(authOptionsKind === "with-both" && {
145
- emailVerification: {
146
- requestHandler: AuthHandlers.requestEmailVerification,
147
- confirmHandler: AuthHandlers.verifyEmail,
148
- },
149
- }),
150
- },
151
- });
152
-
153
- await unsafeCreateEntityTable(stack.db, userEntity);
154
- await unsafeCreateEntityTable(stack.db, tenantEntity);
155
- await unsafePushTables(stack.db, {
156
- configValuesTable,
157
- tenantMembershipsTable,
158
- notificationPreferencesTable,
159
- });
160
-
161
- return { stack, emailTransport };
162
- }
163
-
164
- // Boots via resolveAuthMail's `auth.mail` convenience with its own
165
- // hmacSecret, distinct from the resolveAuthMail call-site secret.
166
- async function bootMailConventionStack(
167
- mailHmacSecret: string,
168
- callSiteSecret = "unused-call-site-secret",
169
- ): Promise<{ stack: TestStack; emailTransport: ReturnType<typeof createInMemoryTransport> }> {
170
- const emailTransport = createInMemoryTransport();
171
-
172
- const mailAuth = resolveAuthMail<RunProdAppAuthOptions>(
173
- {
174
- admin: {
175
- email: "admin@example.com",
176
- password: "pw-long-enough",
177
- displayName: "Admin",
178
- memberships: [],
179
- },
180
- mail: {
181
- baseUrl: "https://app.example.com",
182
- hmacSecret: mailHmacSecret,
183
- paths: {
184
- resetPassword: (locale) => (locale === "de" ? "/de/reset-password" : "/reset-password"),
185
- },
186
- },
187
- },
188
- callSiteSecret,
189
- { SMTP_HOST: "localhost" },
190
- );
191
-
192
- const features = composeFeatures(
193
- [
194
- createTemplateResolverFeature(),
195
- createRendererFoundationFeature(),
196
- createDeliveryFeature(),
197
- createRendererSimpleFeature(),
198
- createChannelEmailFeature({
199
- transport: emailTransport,
200
- renderer: simpleRenderer,
201
- resolveEmail: async () => "unused@test.local",
202
- }),
203
- ],
204
- {
205
- includeBundled: true,
206
- authOptions: buildComposeAuthOptions(mailAuth),
207
- },
208
- );
209
-
210
- const stack = await setupTestStack({
211
- features,
212
- extraContext: (deps) => ({
213
- ...createDeliveryTestContext(deps),
214
- configResolver: createConfigResolver(),
215
- }),
216
- authConfig: {
217
- membershipQuery: "tenant:query:memberships",
218
- loginHandler: AuthHandlers.login,
219
- passwordReset: {
220
- requestHandler: AuthHandlers.requestPasswordReset,
221
- confirmHandler: AuthHandlers.resetPassword,
222
- },
223
- },
224
- });
225
-
226
- await unsafeCreateEntityTable(stack.db, userEntity);
227
- await unsafeCreateEntityTable(stack.db, tenantEntity);
228
- await unsafePushTables(stack.db, {
229
- configValuesTable,
230
- tenantMembershipsTable,
231
- notificationPreferencesTable,
232
- });
233
-
234
- return { stack, emailTransport };
235
- }
236
-
237
- async function seedUser(
238
- stack: TestStack,
239
- opts: { email: string; password: string },
240
- ): Promise<{ id: string }> {
241
- const hash = await hashPassword(opts.password);
242
- const created = await stack.http.writeOk<{ id: string }>(
243
- UserHandlers.create,
244
- {
245
- email: opts.email,
246
- passwordHash: hash,
247
- displayName: opts.email.split("@")[0] ?? "user",
248
- },
249
- systemAdmin,
250
- );
251
- await seedTenantMembership(stack.db, {
252
- userId: created.id,
253
- tenantId: TEST_TENANT_ID,
254
- roles: ["User"],
255
- });
256
- return { id: created.id };
257
- }
258
-
259
- describe("composeFeatures wiring — passwordReset", () => {
260
- let suite: Awaited<ReturnType<typeof bootStack>>;
261
-
262
- beforeAll(async () => {
263
- suite = await bootStack("with-both");
264
- });
265
-
266
- afterAll(async () => {
267
- await suite.stack.cleanup();
268
- });
269
-
270
- beforeEach(async () => {
271
- await deleteRows(suite.stack.db, userTable, {});
272
- await deleteRows(suite.stack.db, tenantMembershipsTable, {});
273
- suite.emailTransport.sent.length = 0;
274
- });
275
-
276
- test("full reset roundtrip: request → email → reset → login with new password", async () => {
277
- // Beweis: composeFeatures(authOptions.passwordReset) hat den Handler
278
- // im Feature registriert UND auth-routes hat die /api/auth/...-Routes
279
- // gemountet — der Wrapper-Pfad ist konsistent. password-reset.
280
- // integration.ts beweist das gleiche für direkten Feature-Aufruf;
281
- // dieser Test pinst dass der Wrapper das Pattern repliziert.
282
- await seedUser(suite.stack, { email: "alice@example.com", password: "old-password-1234" });
283
-
284
- const requestRes = await suite.stack.http.raw("POST", "/api/auth/request-password-reset", {
285
- email: "alice@example.com",
286
- });
287
- expect(requestRes.status).toBe(200);
288
- expect(suite.emailTransport.sent).toHaveLength(1);
289
- const sent = suite.emailTransport.sent[0];
290
- if (!sent) throw new Error("no email sent");
291
- expect(sent.to).toBe("alice@example.com");
292
-
293
- // Token aus der Mail-URL extrahieren — wie der echte User es täte (Mail
294
- // klicken, Browser parsed query-string). Das pinst den vollen
295
- // URL-zu-Handler-Roundtrip über delivery.
296
- const resetUrl = tokenUrlFromHtml(sent.html);
297
- expect(`${resetUrl.origin}${resetUrl.pathname}`).toBe(APP_RESET_URL);
298
- const token = resetUrl.searchParams.get("token");
299
- expect(token).toBeTruthy();
300
- if (!token) return;
301
-
302
- const resetRes = await suite.stack.http.raw("POST", "/api/auth/reset-password", {
303
- token,
304
- newPassword: "brand-new-pw-9876",
305
- });
306
- expect(resetRes.status).toBe(200);
307
-
308
- // Confirmation: das neue Passwort funktioniert für /api/auth/login.
309
- // Das ist der echte End-to-End-Beweis (DB-Read würde nur
310
- // password_hash != old verifizieren — hier prüfen wir die User-
311
- // visible Konsequenz).
312
- const loginRes = await suite.stack.http.raw("POST", "/api/auth/login", {
313
- email: "alice@example.com",
314
- password: "brand-new-pw-9876",
315
- });
316
- expect(loginRes.status).toBe(200);
317
- });
318
-
319
- test("invalid token via wrapper-routes → 422 invalid_reset_token", async () => {
320
- await seedUser(suite.stack, { email: "carol@example.com", password: "keep-me-1234" });
321
-
322
- const res = await suite.stack.http.raw("POST", "/api/auth/reset-password", {
323
- token: "tampered.totally.fake",
324
- newPassword: "should-not-stick-1234",
325
- });
326
- expect(res.status).toBe(422);
327
- const body = (await res.json()) as { error?: { details?: { reason?: string } } };
328
- expect(body.error?.details?.reason).toBe(AuthErrors.invalidResetToken);
329
- });
330
- });
331
-
332
- describe("composeFeatures wiring — emailVerification", () => {
333
- let suite: Awaited<ReturnType<typeof bootStack>>;
334
-
335
- beforeAll(async () => {
336
- suite = await bootStack("with-both");
337
- });
338
-
339
- afterAll(async () => {
340
- await suite.stack.cleanup();
341
- });
342
-
343
- beforeEach(async () => {
344
- await deleteRows(suite.stack.db, userTable, {});
345
- await deleteRows(suite.stack.db, tenantMembershipsTable, {});
346
- suite.emailTransport.sent.length = 0;
347
- });
348
-
349
- test("emailVerification authOption durchgereicht → request handler dispatched", async () => {
350
- // Symmetric zum reset-Test — pinst dass authOptions.emailVerification
351
- // genauso durchgereicht wird wie passwordReset. Bug-Pattern wäre dass
352
- // der Wrapper EINS funktioniert, ANDERES vergisst.
353
- await seedUser(suite.stack, { email: "bob@example.com", password: "any-pw-1234" });
354
-
355
- const res = await suite.stack.http.raw("POST", "/api/auth/request-email-verification", {
356
- email: "bob@example.com",
357
- });
358
- expect(res.status).toBe(200);
359
- expect(suite.emailTransport.sent).toHaveLength(1);
360
- const sent = suite.emailTransport.sent[0];
361
- if (!sent) throw new Error("no verification email sent");
362
- expect(sent.to).toBe("bob@example.com");
363
- const verifyUrl = tokenUrlFromHtml(sent.html);
364
- expect(`${verifyUrl.origin}${verifyUrl.pathname}`).toBe(APP_VERIFY_URL);
365
- expect(verifyUrl.searchParams.get("token")).toBeTruthy();
366
- });
367
- });
368
-
369
- describe("composeFeatures wiring — asymmetric activation", () => {
370
- // Pinst dass passwordReset und emailVerification UNABHÄNGIG durchgereicht
371
- // werden. Bug-Pattern: ein Refactor des Helpers könnte einen Block
372
- // versehentlich an den anderen koppeln (z.B. emailVerification nur
373
- // durchreichen wenn passwordReset auch gesetzt ist) — dann würde eine
374
- // App die NUR Reset-Flow will plötzlich keine Reset-Mails mehr kriegen,
375
- // oder eine die NUR Verify-Flow will keine Verify-Mails. Asymmetric-
376
- // activation ist also ein eigenständiger Wrapper-Vertrag.
377
-
378
- let suite: Awaited<ReturnType<typeof bootStack>>;
379
-
380
- beforeAll(async () => {
381
- suite = await bootStack("with-reset-only");
382
- });
383
-
384
- afterAll(async () => {
385
- await suite.stack.cleanup();
386
- });
387
-
388
- beforeEach(async () => {
389
- await deleteRows(suite.stack.db, userTable, {});
390
- await deleteRows(suite.stack.db, tenantMembershipsTable, {});
391
- suite.emailTransport.sent.length = 0;
392
- });
393
-
394
- test("nur passwordReset gesetzt → reset-flow live, verify-flow fail-closed", async () => {
395
- await seedUser(suite.stack, { email: "alice@example.com", password: "any-pw-1234" });
396
-
397
- // Reset-flow funktioniert: Mail wird produziert.
398
- const resetRes = await suite.stack.http.raw("POST", "/api/auth/request-password-reset", {
399
- email: "alice@example.com",
400
- });
401
- expect(resetRes.status).toBe(200);
402
- expect(suite.emailTransport.sent).toHaveLength(1);
403
-
404
- // Verify-Routes sind in dieser bootStack-Variante NICHT gemounted
405
- // (authConfig.emailVerification fehlt). Der Endpoint existiert also
406
- // gar nicht — Hono returnt 404. Unterscheidet sich vom 200-silent-
407
- // success-Pfad: hier ist die ROUTE selbst nicht da. Kein zweites Mail.
408
- const verifyRes = await suite.stack.http.raw("POST", "/api/auth/request-email-verification", {
409
- email: "alice@example.com",
410
- });
411
- expect(verifyRes.status).toBe(404);
412
- expect(suite.emailTransport.sent).toHaveLength(1);
413
- });
414
- });
415
-
416
- describe("composeFeatures wiring — accountLockout (kumiko-framework#1627)", () => {
417
- // Pinst dass authOptions.accountLockout genauso durchgereicht wird wie
418
- // passwordReset/emailVerification — der Bug-Fall war, dass runDevApp die
419
- // Option gar nicht erst annahm (kein Feld auf RunDevAppAuthOptions) und
420
- // buildComposeAuthOptions sie ignoriert hätte, selbst wenn doch. Anders als
421
- // reset/verify mountet accountLockout keine eigenen Routes — der Beweis
422
- // ist Login-Verhalten, kein Mail-Capture.
423
- let stack: TestStack;
424
- const MAX_ATTEMPTS = 2;
425
-
426
- beforeAll(async () => {
427
- const features = composeFeatures([], {
428
- includeBundled: true,
429
- authOptions: {
430
- accountLockout: { maxFailedAttempts: MAX_ATTEMPTS, lockoutDurationMinutes: 1 },
431
- },
432
- });
433
- stack = await setupTestStack({
434
- features,
435
- extraContext: { configResolver: createConfigResolver() },
436
- authConfig: {
437
- membershipQuery: "tenant:query:memberships",
438
- loginHandler: AuthHandlers.login,
439
- loginErrorStatusMap: {
440
- [AuthErrors.invalidCredentials]: 401,
441
- [AuthErrors.noMembership]: 403,
442
- [AuthErrors.accountLocked]: 423,
443
- },
444
- },
445
- });
446
- await unsafeCreateEntityTable(stack.db, userEntity);
447
- await unsafeCreateEntityTable(stack.db, tenantEntity);
448
- await unsafePushTables(stack.db, { configValuesTable, tenantMembershipsTable });
449
- });
450
-
451
- afterAll(async () => {
452
- await stack.cleanup();
453
- });
454
-
455
- afterEach(async () => {
456
- await deleteRows(stack.db, userTable, {});
457
- await deleteRows(stack.db, tenantMembershipsTable, {});
458
- await stack.redis.flushNamespace();
459
- });
460
-
461
- test("MAX_ATTEMPTS wrong passwords via the wrapper path lock the account (423)", async () => {
462
- await seedUser(stack, { email: "lockout@example.com", password: "right-pw-1234" });
463
-
464
- for (let i = 0; i < MAX_ATTEMPTS; i++) {
465
- const res = await stack.http.raw("POST", "/api/auth/login", {
466
- email: "lockout@example.com",
467
- password: `wrong-${i}`,
468
- });
469
- expect(res.status).toBe(401);
470
- }
471
-
472
- // Correct password no longer helps once locked — proves the option
473
- // actually reached createLoginHandler's accountLockout param, not just
474
- // the type.
475
- const lockedRes = await stack.http.raw("POST", "/api/auth/login", {
476
- email: "lockout@example.com",
477
- password: "right-pw-1234",
478
- });
479
- expect(lockedRes.status).toBe(423);
480
- const body = (await lockedRes.json()) as { error?: { details?: { reason?: string } } };
481
- expect(body.error?.details?.reason).toBe(AuthErrors.accountLocked);
482
- });
483
- });
484
-
485
- describe("composeFeatures wiring — fail-closed ohne authOptions", () => {
486
- // Der Bug den der Review-Agent gefangen hat. Whitebox-Variante in
487
- // compose-features.test.ts checkt nur Object.keys(writeHandlers); hier
488
- // pinst der Test das User-visible Verhalten: WENN user existiert + POST
489
- // request-password-reset gefeuert wird, MUSS der Wrapper-Pfad eine Mail
490
- // produzieren. Tut er das nicht, ist der composeFeatures-authOptions-
491
- // Bug zurück.
492
- //
493
- // Subtilität: auth-routes mountet die request-Route by-design als
494
- // enumeration-safe (always-200, silently swallow handler-Failures —
495
- // siehe registerTokenRequestRoute in auth-routes.ts). Ein fehlender
496
- // Handler endet daher nicht in 4xx/5xx, sondern silent in "200 + 0
497
- // mails". Genau das pinnt dieser Test gegen die Regression: ohne
498
- // resetEmails-Capture als Counter-Evidence wäre der Bug unsichtbar.
499
-
500
- let suite: Awaited<ReturnType<typeof bootStack>>;
501
-
502
- beforeAll(async () => {
503
- suite = await bootStack("without-auth-options");
504
- });
505
-
506
- afterAll(async () => {
507
- await suite.stack.cleanup();
508
- });
509
-
510
- afterEach(async () => {
511
- await deleteRows(suite.stack.db, userTable, {});
512
- await deleteRows(suite.stack.db, tenantMembershipsTable, {});
513
- suite.emailTransport.sent.length = 0;
514
- });
515
-
516
- test("authOptions fehlt → POST returnt enumeration-safe 200, ABER NULL Mails (der echte Bug-Beweis)", async () => {
517
- // User EXISTIERT — das pinst dass die fehlende Mail auf dem
518
- // composeFeatures-bug beruht, nicht auf "user not found"
519
- // (welcher legitim 0 mails produziert: enumeration-safety).
520
- await seedUser(suite.stack, { email: "noop@example.com", password: "any-pw-1234" });
521
-
522
- const res = await suite.stack.http.raw("POST", "/api/auth/request-password-reset", {
523
- email: "noop@example.com",
524
- });
525
-
526
- // 200 ist by-design (registerTokenRequestRoute swallowt handler-
527
- // Failures). Bug-Beweis ist die fehlende Mail — wenn der "with-both"-
528
- // Test 1 Mail bekommt und dieser Test 0 bekommt für denselben
529
- // existierenden User, ist die Differenz EXAKT der composeFeatures-
530
- // Bug. Diese Asymmetrie zwischen den beiden describe-Blöcken IST
531
- // der Test.
532
- expect(res.status).toBe(200);
533
- expect(suite.emailTransport.sent).toHaveLength(0);
534
- });
535
- });
536
-
537
- describe("composeFeatures wiring — auth.mail convenience locale + secret", () => {
538
- // mailHmacSecret and callSiteSecret are deliberately different values: a
539
- // live reset roundtrip must validate against mailHmacSecret, not callSiteSecret.
540
- const mailHmacSecret = randomBytes(32).toString("base64");
541
- const callSiteSecret = randomBytes(32).toString("base64");
542
- let suite: Awaited<ReturnType<typeof bootMailConventionStack>>;
543
-
544
- beforeAll(async () => {
545
- suite = await bootMailConventionStack(mailHmacSecret, callSiteSecret);
546
- });
547
-
548
- afterAll(async () => {
549
- await suite.stack.cleanup();
550
- });
551
-
552
- beforeEach(async () => {
553
- await deleteRows(suite.stack.db, userTable, {});
554
- await deleteRows(suite.stack.db, tenantMembershipsTable, {});
555
- suite.emailTransport.sent.length = 0;
556
- });
557
-
558
- test("X-Locale: de → reset link carries the localized path and its token is accepted", async () => {
559
- await seedUser(suite.stack, { email: "greta@example.com", password: "old-password-1234" });
560
-
561
- const res = await suite.stack.http.raw(
562
- "POST",
563
- "/api/auth/request-password-reset",
564
- { email: "greta@example.com" },
565
- { [LOCALE_HEADER_NAME]: "de" },
566
- );
567
- expect(res.status).toBe(200);
568
- const sent = suite.emailTransport.sent[0];
569
- if (!sent) throw new Error("no email sent");
570
- const resetUrl = tokenUrlFromHtml(sent.html);
571
- expect(resetUrl.pathname).toBe("/de/reset-password");
572
- const token = resetUrl.searchParams.get("token");
573
- if (!token) throw new Error("no token in mail link");
574
- expect(verifyToken(token, TokenPurpose.passwordReset, mailHmacSecret).ok).toBe(true);
575
- expect(verifyToken(token, TokenPurpose.passwordReset, callSiteSecret).ok).toBe(false);
576
-
577
- const confirmRes = await suite.stack.http.raw("POST", "/api/auth/reset-password", {
578
- token,
579
- newPassword: "brand-new-pw-9876",
580
- });
581
- expect(confirmRes.status).toBe(200);
582
- });
583
-
584
- test("no X-Locale → reset link falls back to the default path", async () => {
585
- await seedUser(suite.stack, { email: "henry@example.com", password: "old-password-1234" });
586
-
587
- const res = await suite.stack.http.raw("POST", "/api/auth/request-password-reset", {
588
- email: "henry@example.com",
589
- });
590
- expect(res.status).toBe(200);
591
- const sent = suite.emailTransport.sent[0];
592
- if (!sent) throw new Error("no email sent");
593
- expect(tokenUrlFromHtml(sent.html).pathname).toBe("/reset-password");
594
- });
595
-
596
- test("a token signed with the resolveAuthMail call-site secret is rejected", async () => {
597
- const seeded = await seedUser(suite.stack, {
598
- email: "ivy@example.com",
599
- password: "old-password-1234",
600
- });
601
- const { token } = signToken(seeded.id, TokenPurpose.passwordReset, 15, callSiteSecret);
602
-
603
- const res = await suite.stack.http.raw("POST", "/api/auth/reset-password", {
604
- token,
605
- newPassword: "should-not-stick-1234",
606
- });
607
- expect(res.status).toBe(422);
608
- });
609
- });