@cosmicdrift/kumiko-dev-server 0.152.0 → 0.154.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 (48) hide show
  1. package/package.json +5 -8
  2. package/src/__tests__/build-prod-bundle.integration.test.ts +1 -1
  3. package/src/__tests__/compose-stacks.test.ts +1 -1
  4. package/src/__tests__/discover-format.test.ts +1 -1
  5. package/src/__tests__/env-schema.integration.test.ts +1 -1
  6. package/src/__tests__/walkthrough.integration.test.ts +1 -1
  7. package/src/build.ts +1 -1
  8. package/src/create-kumiko-server.ts +12 -6
  9. package/src/index.ts +4 -12
  10. package/src/run-dev-app.ts +12 -8
  11. package/src/scaffold-app.ts +9 -4
  12. package/src/schema-apply.ts +4 -1
  13. package/src/schema-check-core.ts +1 -1
  14. package/src/setup-test-stack-from-features.ts +4 -1
  15. package/src/__tests__/boot-extra-context.test.ts +0 -140
  16. package/src/__tests__/build-prod-bundle.test.ts +0 -278
  17. package/src/__tests__/cache-headers.test.ts +0 -83
  18. package/src/__tests__/compose-features-mfa-wiring.integration.test.ts +0 -308
  19. package/src/__tests__/compose-features-wiring.integration.test.ts +0 -382
  20. package/src/__tests__/compose-features.test.ts +0 -128
  21. package/src/__tests__/config-seed-boot.integration.test.ts +0 -158
  22. package/src/__tests__/inject-schema.test.ts +0 -62
  23. package/src/__tests__/pii-boot-gate.test.ts +0 -68
  24. package/src/__tests__/renderer-web-css-relocation.integration.test.ts +0 -85
  25. package/src/__tests__/renderer-web-shell-sentinel.test.ts +0 -35
  26. package/src/__tests__/require-env.test.ts +0 -29
  27. package/src/__tests__/resolve-auth-mail.test.ts +0 -69
  28. package/src/__tests__/resolve-tailwind-cli.test.ts +0 -81
  29. package/src/__tests__/run-prod-app-env-source.test.ts +0 -157
  30. package/src/__tests__/run-prod-app-spec.test.ts +0 -57
  31. package/src/__tests__/run-prod-app.integration.test.ts +0 -915
  32. package/src/__tests__/session-wiring.test.ts +0 -51
  33. package/src/__tests__/try-hono-first.test.ts +0 -63
  34. package/src/boot/__tests__/job-run-logger.test.ts +0 -26
  35. package/src/boot/apply-boot-seeds.ts +0 -19
  36. package/src/boot/boot-crypto.ts +0 -82
  37. package/src/boot/job-run-logger.ts +0 -51
  38. package/src/build-prod-bundle.ts +0 -692
  39. package/src/compose-features.ts +0 -164
  40. package/src/extra-routes-deps.ts +0 -47
  41. package/src/inject-schema.ts +0 -30
  42. package/src/pii-boot-gate.ts +0 -62
  43. package/src/resolve-tailwind-cli.ts +0 -45
  44. package/src/run-prod-app-boot-context.ts +0 -241
  45. package/src/run-prod-app-static-files.ts +0 -273
  46. package/src/run-prod-app.ts +0 -1158
  47. package/src/session-wiring.ts +0 -29
  48. package/src/try-hono-first.ts +0 -46
@@ -1,382 +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
- } from "@cosmicdrift/kumiko-bundled-features/auth-email-password";
31
- import {
32
- createChannelEmailFeature,
33
- createInMemoryTransport,
34
- } from "@cosmicdrift/kumiko-bundled-features/channel-email";
35
- import {
36
- configValuesTable,
37
- createConfigResolver,
38
- } from "@cosmicdrift/kumiko-bundled-features/config";
39
- import {
40
- createDeliveryFeature,
41
- createDeliveryTestContext,
42
- notificationPreferencesTable,
43
- } from "@cosmicdrift/kumiko-bundled-features/delivery";
44
- import { createRendererFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/renderer-foundation";
45
- import {
46
- createRendererSimpleFeature,
47
- simpleRenderer,
48
- } from "@cosmicdrift/kumiko-bundled-features/renderer-simple";
49
- import { createTemplateResolverFeature } from "@cosmicdrift/kumiko-bundled-features/template-resolver";
50
- import { tenantEntity, tenantMembershipsTable } from "@cosmicdrift/kumiko-bundled-features/tenant";
51
- import { seedTenantMembership } from "@cosmicdrift/kumiko-bundled-features/tenant/testing";
52
- import { UserHandlers, userEntity, userTable } from "@cosmicdrift/kumiko-bundled-features/user";
53
- import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
54
- import {
55
- setupTestStack,
56
- type TestStack,
57
- TestUsers,
58
- unsafeCreateEntityTable,
59
- unsafePushTables,
60
- } from "@cosmicdrift/kumiko-framework/stack";
61
- import { deleteRows } from "@cosmicdrift/kumiko-framework/testing";
62
- import { composeFeatures } from "../compose-features";
63
-
64
- const RESET_HMAC = randomBytes(32).toString("base64");
65
- const VERIFY_HMAC = randomBytes(32).toString("base64");
66
- const APP_RESET_URL = "https://app.example.com/reset-password";
67
- const APP_VERIFY_URL = "https://app.example.com/verify-email";
68
- const TEST_TENANT_ID: TenantId = "00000000-0000-4000-8000-000000000001" as TenantId;
69
- const systemAdmin = TestUsers.systemAdmin;
70
-
71
- // Pulls the magic-link out of the rendered mail HTML — the renderer-simple
72
- // button carries it as the only href. Replaces the old callback-captured
73
- // resetUrl now that reset/verify mail via delivery.
74
- function tokenUrlFromHtml(html: string): URL {
75
- const match = html.match(/href="([^"]*\?token=[^"]*)"/);
76
- if (!match?.[1]) throw new Error("no magic-link href in mail html");
77
- return new URL(match[1]);
78
- }
79
-
80
- async function bootStack(
81
- authOptionsKind: "with-both" | "with-reset-only" | "without-auth-options",
82
- ): Promise<{ stack: TestStack; emailTransport: ReturnType<typeof createInMemoryTransport> }> {
83
- const emailTransport = createInMemoryTransport();
84
-
85
- // Genau das was runProdApp/runDevApp machen würde — composeFeatures als
86
- // single-source der Feature-Liste. delivery + channel-email kommen als
87
- // App-Features dazu, weil reset/verify ihre Mail via ctx.notify schicken.
88
- const features = composeFeatures(
89
- [
90
- createTemplateResolverFeature(),
91
- createRendererFoundationFeature(),
92
- createDeliveryFeature(),
93
- createRendererSimpleFeature(),
94
- createChannelEmailFeature({
95
- transport: emailTransport,
96
- renderer: simpleRenderer,
97
- resolveEmail: async () => "unused@test.local",
98
- }),
99
- ],
100
- {
101
- includeBundled: true,
102
- ...(authOptionsKind !== "without-auth-options" && {
103
- authOptions: {
104
- passwordReset: { hmacSecret: RESET_HMAC, tokenTtlMinutes: 15, appUrl: APP_RESET_URL },
105
- ...(authOptionsKind === "with-both" && {
106
- emailVerification: {
107
- hmacSecret: VERIFY_HMAC,
108
- tokenTtlMinutes: 60,
109
- mode: "off",
110
- appUrl: APP_VERIFY_URL,
111
- },
112
- }),
113
- },
114
- }),
115
- },
116
- );
117
-
118
- const stack = await setupTestStack({
119
- features,
120
- extraContext: (deps) => ({
121
- ...createDeliveryTestContext(deps),
122
- configResolver: createConfigResolver(),
123
- }),
124
- authConfig: {
125
- membershipQuery: "tenant:query:memberships",
126
- loginHandler: AuthHandlers.login,
127
- // Routes IMMER mounten — egal welche authOptionsKind. Genau das
128
- // ist die Bug-Bedingung: Routes da, Handler eventuell nicht.
129
- passwordReset: {
130
- requestHandler: AuthHandlers.requestPasswordReset,
131
- confirmHandler: AuthHandlers.resetPassword,
132
- },
133
- ...(authOptionsKind === "with-both" && {
134
- emailVerification: {
135
- requestHandler: AuthHandlers.requestEmailVerification,
136
- confirmHandler: AuthHandlers.verifyEmail,
137
- },
138
- }),
139
- },
140
- });
141
-
142
- await unsafeCreateEntityTable(stack.db, userEntity);
143
- await unsafeCreateEntityTable(stack.db, tenantEntity);
144
- await unsafePushTables(stack.db, {
145
- configValuesTable,
146
- tenantMembershipsTable,
147
- notificationPreferencesTable,
148
- });
149
-
150
- return { stack, emailTransport };
151
- }
152
-
153
- async function seedUser(
154
- stack: TestStack,
155
- opts: { email: string; password: string },
156
- ): Promise<{ id: string }> {
157
- const hash = await hashPassword(opts.password);
158
- const created = await stack.http.writeOk<{ id: string }>(
159
- UserHandlers.create,
160
- {
161
- email: opts.email,
162
- passwordHash: hash,
163
- displayName: opts.email.split("@")[0] ?? "user",
164
- },
165
- systemAdmin,
166
- );
167
- await seedTenantMembership(stack.db, {
168
- userId: created.id,
169
- tenantId: TEST_TENANT_ID,
170
- roles: ["User"],
171
- });
172
- return { id: created.id };
173
- }
174
-
175
- describe("composeFeatures wiring — passwordReset", () => {
176
- let suite: Awaited<ReturnType<typeof bootStack>>;
177
-
178
- beforeAll(async () => {
179
- suite = await bootStack("with-both");
180
- });
181
-
182
- afterAll(async () => {
183
- await suite.stack.cleanup();
184
- });
185
-
186
- beforeEach(async () => {
187
- await deleteRows(suite.stack.db, userTable, {});
188
- await deleteRows(suite.stack.db, tenantMembershipsTable, {});
189
- suite.emailTransport.sent.length = 0;
190
- });
191
-
192
- test("full reset roundtrip: request → email → reset → login with new password", async () => {
193
- // Beweis: composeFeatures(authOptions.passwordReset) hat den Handler
194
- // im Feature registriert UND auth-routes hat die /api/auth/...-Routes
195
- // gemountet — der Wrapper-Pfad ist konsistent. password-reset.
196
- // integration.ts beweist das gleiche für direkten Feature-Aufruf;
197
- // dieser Test pinst dass der Wrapper das Pattern repliziert.
198
- await seedUser(suite.stack, { email: "alice@example.com", password: "old-password-1234" });
199
-
200
- const requestRes = await suite.stack.http.raw("POST", "/api/auth/request-password-reset", {
201
- email: "alice@example.com",
202
- });
203
- expect(requestRes.status).toBe(200);
204
- expect(suite.emailTransport.sent).toHaveLength(1);
205
- const sent = suite.emailTransport.sent[0];
206
- if (!sent) throw new Error("no email sent");
207
- expect(sent.to).toBe("alice@example.com");
208
-
209
- // Token aus der Mail-URL extrahieren — wie der echte User es täte (Mail
210
- // klicken, Browser parsed query-string). Das pinst den vollen
211
- // URL-zu-Handler-Roundtrip über delivery.
212
- const resetUrl = tokenUrlFromHtml(sent.html);
213
- expect(`${resetUrl.origin}${resetUrl.pathname}`).toBe(APP_RESET_URL);
214
- const token = resetUrl.searchParams.get("token");
215
- expect(token).toBeTruthy();
216
- if (!token) return;
217
-
218
- const resetRes = await suite.stack.http.raw("POST", "/api/auth/reset-password", {
219
- token,
220
- newPassword: "brand-new-pw-9876",
221
- });
222
- expect(resetRes.status).toBe(200);
223
-
224
- // Confirmation: das neue Passwort funktioniert für /api/auth/login.
225
- // Das ist der echte End-to-End-Beweis (DB-Read würde nur
226
- // password_hash != old verifizieren — hier prüfen wir die User-
227
- // visible Konsequenz).
228
- const loginRes = await suite.stack.http.raw("POST", "/api/auth/login", {
229
- email: "alice@example.com",
230
- password: "brand-new-pw-9876",
231
- });
232
- expect(loginRes.status).toBe(200);
233
- });
234
-
235
- test("invalid token via wrapper-routes → 422 invalid_reset_token", async () => {
236
- await seedUser(suite.stack, { email: "carol@example.com", password: "keep-me-1234" });
237
-
238
- const res = await suite.stack.http.raw("POST", "/api/auth/reset-password", {
239
- token: "tampered.totally.fake",
240
- newPassword: "should-not-stick-1234",
241
- });
242
- expect(res.status).toBe(422);
243
- const body = (await res.json()) as { error?: { details?: { reason?: string } } };
244
- expect(body.error?.details?.reason).toBe(AuthErrors.invalidResetToken);
245
- });
246
- });
247
-
248
- describe("composeFeatures wiring — emailVerification", () => {
249
- let suite: Awaited<ReturnType<typeof bootStack>>;
250
-
251
- beforeAll(async () => {
252
- suite = await bootStack("with-both");
253
- });
254
-
255
- afterAll(async () => {
256
- await suite.stack.cleanup();
257
- });
258
-
259
- beforeEach(async () => {
260
- await deleteRows(suite.stack.db, userTable, {});
261
- await deleteRows(suite.stack.db, tenantMembershipsTable, {});
262
- suite.emailTransport.sent.length = 0;
263
- });
264
-
265
- test("emailVerification authOption durchgereicht → request handler dispatched", async () => {
266
- // Symmetric zum reset-Test — pinst dass authOptions.emailVerification
267
- // genauso durchgereicht wird wie passwordReset. Bug-Pattern wäre dass
268
- // der Wrapper EINS funktioniert, ANDERES vergisst.
269
- await seedUser(suite.stack, { email: "bob@example.com", password: "any-pw-1234" });
270
-
271
- const res = await suite.stack.http.raw("POST", "/api/auth/request-email-verification", {
272
- email: "bob@example.com",
273
- });
274
- expect(res.status).toBe(200);
275
- expect(suite.emailTransport.sent).toHaveLength(1);
276
- const sent = suite.emailTransport.sent[0];
277
- if (!sent) throw new Error("no verification email sent");
278
- expect(sent.to).toBe("bob@example.com");
279
- const verifyUrl = tokenUrlFromHtml(sent.html);
280
- expect(`${verifyUrl.origin}${verifyUrl.pathname}`).toBe(APP_VERIFY_URL);
281
- expect(verifyUrl.searchParams.get("token")).toBeTruthy();
282
- });
283
- });
284
-
285
- describe("composeFeatures wiring — asymmetric activation", () => {
286
- // Pinst dass passwordReset und emailVerification UNABHÄNGIG durchgereicht
287
- // werden. Bug-Pattern: ein Refactor des Helpers könnte einen Block
288
- // versehentlich an den anderen koppeln (z.B. emailVerification nur
289
- // durchreichen wenn passwordReset auch gesetzt ist) — dann würde eine
290
- // App die NUR Reset-Flow will plötzlich keine Reset-Mails mehr kriegen,
291
- // oder eine die NUR Verify-Flow will keine Verify-Mails. Asymmetric-
292
- // activation ist also ein eigenständiger Wrapper-Vertrag.
293
-
294
- let suite: Awaited<ReturnType<typeof bootStack>>;
295
-
296
- beforeAll(async () => {
297
- suite = await bootStack("with-reset-only");
298
- });
299
-
300
- afterAll(async () => {
301
- await suite.stack.cleanup();
302
- });
303
-
304
- beforeEach(async () => {
305
- await deleteRows(suite.stack.db, userTable, {});
306
- await deleteRows(suite.stack.db, tenantMembershipsTable, {});
307
- suite.emailTransport.sent.length = 0;
308
- });
309
-
310
- test("nur passwordReset gesetzt → reset-flow live, verify-flow fail-closed", async () => {
311
- await seedUser(suite.stack, { email: "alice@example.com", password: "any-pw-1234" });
312
-
313
- // Reset-flow funktioniert: Mail wird produziert.
314
- const resetRes = await suite.stack.http.raw("POST", "/api/auth/request-password-reset", {
315
- email: "alice@example.com",
316
- });
317
- expect(resetRes.status).toBe(200);
318
- expect(suite.emailTransport.sent).toHaveLength(1);
319
-
320
- // Verify-Routes sind in dieser bootStack-Variante NICHT gemounted
321
- // (authConfig.emailVerification fehlt). Der Endpoint existiert also
322
- // gar nicht — Hono returnt 404. Unterscheidet sich vom 200-silent-
323
- // success-Pfad: hier ist die ROUTE selbst nicht da. Kein zweites Mail.
324
- const verifyRes = await suite.stack.http.raw("POST", "/api/auth/request-email-verification", {
325
- email: "alice@example.com",
326
- });
327
- expect(verifyRes.status).toBe(404);
328
- expect(suite.emailTransport.sent).toHaveLength(1);
329
- });
330
- });
331
-
332
- describe("composeFeatures wiring — fail-closed ohne authOptions", () => {
333
- // Der Bug den der Review-Agent gefangen hat. Whitebox-Variante in
334
- // compose-features.test.ts checkt nur Object.keys(writeHandlers); hier
335
- // pinst der Test das User-visible Verhalten: WENN user existiert + POST
336
- // request-password-reset gefeuert wird, MUSS der Wrapper-Pfad eine Mail
337
- // produzieren. Tut er das nicht, ist der composeFeatures-authOptions-
338
- // Bug zurück.
339
- //
340
- // Subtilität: auth-routes mountet die request-Route by-design als
341
- // enumeration-safe (always-200, silently swallow handler-Failures —
342
- // siehe registerTokenRequestRoute in auth-routes.ts). Ein fehlender
343
- // Handler endet daher nicht in 4xx/5xx, sondern silent in "200 + 0
344
- // mails". Genau das pinnt dieser Test gegen die Regression: ohne
345
- // resetEmails-Capture als Counter-Evidence wäre der Bug unsichtbar.
346
-
347
- let suite: Awaited<ReturnType<typeof bootStack>>;
348
-
349
- beforeAll(async () => {
350
- suite = await bootStack("without-auth-options");
351
- });
352
-
353
- afterAll(async () => {
354
- await suite.stack.cleanup();
355
- });
356
-
357
- afterEach(async () => {
358
- await deleteRows(suite.stack.db, userTable, {});
359
- await deleteRows(suite.stack.db, tenantMembershipsTable, {});
360
- suite.emailTransport.sent.length = 0;
361
- });
362
-
363
- test("authOptions fehlt → POST returnt enumeration-safe 200, ABER NULL Mails (der echte Bug-Beweis)", async () => {
364
- // User EXISTIERT — das pinst dass die fehlende Mail auf dem
365
- // composeFeatures-bug beruht, nicht auf "user not found"
366
- // (welcher legitim 0 mails produziert: enumeration-safety).
367
- await seedUser(suite.stack, { email: "noop@example.com", password: "any-pw-1234" });
368
-
369
- const res = await suite.stack.http.raw("POST", "/api/auth/request-password-reset", {
370
- email: "noop@example.com",
371
- });
372
-
373
- // 200 ist by-design (registerTokenRequestRoute swallowt handler-
374
- // Failures). Bug-Beweis ist die fehlende Mail — wenn der "with-both"-
375
- // Test 1 Mail bekommt und dieser Test 0 bekommt für denselben
376
- // existierenden User, ist die Differenz EXAKT der composeFeatures-
377
- // Bug. Diese Asymmetrie zwischen den beiden describe-Blöcken IST
378
- // der Test.
379
- expect(res.status).toBe(200);
380
- expect(suite.emailTransport.sent).toHaveLength(0);
381
- });
382
- });
@@ -1,128 +0,0 @@
1
- // Unit-Tests für composeFeatures.authOptions — pinst dass die
2
- // passwordReset / emailVerification options an
3
- // createAuthEmailPasswordFeature durchgereicht werden, sodass die
4
- // request- und confirm-Handler im resultierenden Feature registriert
5
- // sind. Bug-Pattern: ohne diesen Wiring würde runProdApp.options.auth.
6
- // passwordReset = {sendResetEmail, appResetUrl} die routes mounten,
7
- // aber die Handler fehlen → POST /api/auth/request-password-reset
8
- // dispatched ins Leere → 500.
9
-
10
- import { describe, expect, spyOn, test } from "bun:test";
11
- import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
12
- import { composeFeatures } from "../compose-features";
13
-
14
- const noopFeature = defineFeature("noop-app", () => {});
15
-
16
- // Mirrors what the create-kumiko-app picker hands back when the user
17
- // ticks an auto-mounted feature: a stub with the same name as a bundled
18
- // one. The dedupe path drops it and warns.
19
- const pickerAuthDupe = defineFeature("auth-email-password", () => {});
20
-
21
- const HMAC_SECRET = "test-secret-with-at-least-32-bytes-aaa";
22
-
23
- describe("composeFeatures", () => {
24
- test("includeBundled=false → nur App-Features", () => {
25
- const features = composeFeatures([noopFeature], { includeBundled: false });
26
- expect(features.map((f) => f.name)).toEqual(["noop-app"]);
27
- });
28
-
29
- test("includeBundled=true → 4 bundled Features davor", () => {
30
- const features = composeFeatures([noopFeature], { includeBundled: true });
31
- expect(features.map((f) => f.name)).toEqual([
32
- "config",
33
- "user",
34
- "tenant",
35
- "auth-email-password",
36
- "noop-app",
37
- ]);
38
- });
39
-
40
- test("authOptions.passwordReset → request-password-reset + reset-password handlers registriert", () => {
41
- const features = composeFeatures([noopFeature], {
42
- includeBundled: true,
43
- authOptions: { passwordReset: { hmacSecret: HMAC_SECRET, appUrl: "https://app/reset" } },
44
- });
45
- const auth = features.find((f) => f.name === "auth-email-password");
46
- expect(auth).toBeDefined();
47
- if (!auth) return;
48
- const handlerNames = Array.from(Object.keys(auth.writeHandlers));
49
- expect(handlerNames).toContain("request-password-reset");
50
- expect(handlerNames).toContain("reset-password");
51
- });
52
-
53
- test("authOptions.emailVerification → request-email-verification + verify-email handlers registriert", () => {
54
- const features = composeFeatures([noopFeature], {
55
- includeBundled: true,
56
- authOptions: { emailVerification: { hmacSecret: HMAC_SECRET, appUrl: "https://app/verify" } },
57
- });
58
- const auth = features.find((f) => f.name === "auth-email-password");
59
- expect(auth).toBeDefined();
60
- if (!auth) return;
61
- const handlerNames = Array.from(Object.keys(auth.writeHandlers));
62
- expect(handlerNames).toContain("request-email-verification");
63
- expect(handlerNames).toContain("verify-email");
64
- });
65
-
66
- test("authOptions.signup → signup-request + signup-confirm handlers registriert", () => {
67
- const features = composeFeatures([noopFeature], {
68
- includeBundled: true,
69
- authOptions: { signup: { appUrl: "https://app/signup/complete" } },
70
- });
71
- const auth = features.find((f) => f.name === "auth-email-password");
72
- expect(auth).toBeDefined();
73
- if (!auth) return;
74
- const handlerNames = Array.from(Object.keys(auth.writeHandlers));
75
- expect(handlerNames).toContain("signup-request");
76
- expect(handlerNames).toContain("signup-confirm");
77
- });
78
-
79
- test("OHNE authOptions → KEINE reset/verify-handlers (anti-default-deploy-bug)", () => {
80
- // Genau der Bug der vom Review-Agent gefangen wurde: composeFeatures
81
- // ohne authOptions registriert die handler nicht. Wenn jemand das
82
- // versehentlich vergisst und nur die routes (auth-routes-config)
83
- // wired, schlagen die requests auf prod fehl. Der Test pinst dass
84
- // dieser default-deny-Pfad bewusst leer ist.
85
- const features = composeFeatures([noopFeature], { includeBundled: true });
86
- const auth = features.find((f) => f.name === "auth-email-password");
87
- expect(auth).toBeDefined();
88
- if (!auth) return;
89
- const handlerNames = Array.from(Object.keys(auth.writeHandlers));
90
- expect(handlerNames).not.toContain("request-password-reset");
91
- expect(handlerNames).not.toContain("reset-password");
92
- expect(handlerNames).not.toContain("request-email-verification");
93
- expect(handlerNames).not.toContain("verify-email");
94
- // Die regulären auth-handlers (login/logout/change-password) MÜSSEN
95
- // aber immer da sein — das ist der core-flow.
96
- expect(handlerNames).toContain("login");
97
- expect(handlerNames).toContain("logout");
98
- });
99
-
100
- test("app feature duplicating a bundled name is dropped (no createRegistry crash)", () => {
101
- // create-kumiko-app's picker hands back createAuthEmailPasswordFeature()
102
- // because the user ticked it in the recommended set; runDevApp then adds
103
- // its OWN bundled copy via includeBundled:true, and createRegistry throws
104
- // "Duplicate feature: auth-email-password". The dedupe path keeps the
105
- // bundled instance (it carries authOptions wiring) and drops the app stub.
106
- const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
107
- const features = composeFeatures([pickerAuthDupe, noopFeature], {
108
- includeBundled: true,
109
- });
110
- // The warn is part of the fix contract (changeset: "warn so the user can
111
- // remove the line") — without this assertion the warn is removable with no RED.
112
- expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("auth-email-password"));
113
- warnSpy.mockRestore();
114
- expect(features.map((f) => f.name)).toEqual([
115
- "config",
116
- "user",
117
- "tenant",
118
- "auth-email-password",
119
- "noop-app",
120
- ]);
121
- // The kept instance is the bundled one — confirmed by the presence of
122
- // login/logout handlers (the picker stub has none).
123
- const auth = features.find((f) => f.name === "auth-email-password");
124
- expect(auth).toBeDefined();
125
- if (!auth) return;
126
- expect(Object.keys(auth.writeHandlers)).toContain("login");
127
- });
128
- });
@@ -1,158 +0,0 @@
1
- // Pins the boot-wiring contract for config-seeds. runDevApp's onAfterSetup
2
- // and runProdApp's seed-block both call `applyBootSeeds(...)` — this test
3
- // calls the SAME helper, so if someone removes the call site from
4
- // runDevApp / runProdApp the helper still has at least one caller (this
5
- // test). Code review then sees an orphaned helper, not a silently broken
6
- // boot. For a stricter end-to-end pin you'd start an actual runDevApp;
7
- // that's heavy and not done here.
8
- //
9
- // Tests:
10
- // 1. seed rows land in the projection after applyBootSeeds runs,
11
- // 2. a re-boot is a no-op (idempotent),
12
- // 3. an admin set on top of a seed wins the resolver cascade; coexistence
13
- // vs. override semantics depend on the admin user's tenantId.
14
-
15
- import { afterAll, beforeAll, describe, expect, test } from "bun:test";
16
- import {
17
- configValuesTable,
18
- createConfigAccessorFactory,
19
- createConfigFeature,
20
- createConfigResolver,
21
- } from "@cosmicdrift/kumiko-bundled-features/config";
22
- import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
23
- import {
24
- access,
25
- createSystemConfig,
26
- createSystemSeed,
27
- createTenantConfig,
28
- createTenantSeed,
29
- defineFeature,
30
- } from "@cosmicdrift/kumiko-framework/engine";
31
- import {
32
- setupTestStack,
33
- type TestStack,
34
- TestUsers,
35
- unsafePushTables,
36
- } from "@cosmicdrift/kumiko-framework/stack";
37
- import { applyBootSeeds } from "../boot/apply-boot-seeds";
38
-
39
- const bootSeedsFeature = defineFeature("boot-seeds-test", (r) => {
40
- r.requires("config");
41
- r.config({
42
- keys: {
43
- siteName: createTenantConfig("text", {
44
- default: "DEFAULT_SITE",
45
- read: access.all,
46
- write: access.all,
47
- }),
48
- maintenance: createSystemConfig("boolean", {
49
- default: false,
50
- read: access.all,
51
- write: access.systemAdmin,
52
- }),
53
- },
54
- seeds: {
55
- siteName: createTenantSeed({ value: "from-seed" }),
56
- maintenance: createSystemSeed({ value: true }),
57
- },
58
- });
59
- });
60
-
61
- const SITE_KEY = "boot-seeds-test:config:site-name";
62
- const MAINT_KEY = "boot-seeds-test:config:maintenance";
63
-
64
- let stack: TestStack;
65
- const resolver = createConfigResolver();
66
-
67
- beforeAll(async () => {
68
- stack = await setupTestStack({
69
- features: [createConfigFeature(), bootSeedsFeature],
70
- extraContext: ({ registry }) => ({
71
- configResolver: resolver,
72
- _configAccessorFactory: createConfigAccessorFactory(registry, resolver),
73
- }),
74
- });
75
- await unsafePushTables(stack.db, { configValuesTable });
76
- });
77
-
78
- afterAll(async () => {
79
- await stack.cleanup();
80
- });
81
-
82
- describe("config-seed boot wiring", () => {
83
- test("first boot: applyBootSeeds writes one row per seed", async () => {
84
- await applyBootSeeds({ registry: stack.registry, db: stack.db });
85
-
86
- const rows = await selectMany(stack.db, configValuesTable);
87
- expect(rows.length).toBe(2);
88
-
89
- const siteKeyDef = stack.registry.getConfigKey(SITE_KEY);
90
- expect(siteKeyDef).toBeDefined();
91
- const sitePeek = await resolver.get(
92
- SITE_KEY,
93
- siteKeyDef!,
94
- TestUsers.systemAdmin.tenantId,
95
- TestUsers.systemAdmin.id,
96
- stack.db,
97
- );
98
- expect(sitePeek).toBe("from-seed");
99
-
100
- const maintKeyDef = stack.registry.getConfigKey(MAINT_KEY);
101
- expect(maintKeyDef).toBeDefined();
102
- const maintPeek = await resolver.get(
103
- MAINT_KEY,
104
- maintKeyDef!,
105
- TestUsers.systemAdmin.tenantId,
106
- TestUsers.systemAdmin.id,
107
- stack.db,
108
- );
109
- expect(maintPeek).toBe(true);
110
- });
111
-
112
- test("re-boot: idempotent — every seed already on disk → no extra rows", async () => {
113
- await applyBootSeeds({ registry: stack.registry, db: stack.db });
114
-
115
- const rows = await selectMany(stack.db, configValuesTable);
116
- expect(rows.length).toBe(2);
117
- });
118
-
119
- test("admin set on top of seed wins resolver — Re-Boot preserves admin", async () => {
120
- // siteName is a TENANT-scope key. The seed writes a row under
121
- // SYSTEM_TENANT_ID (= "for all tenants"). An admin on a real tenant
122
- // writes a row under THAT tenantId — higher specificity. Both rows
123
- // coexist; the resolver returns the more specific one.
124
- //
125
- // If the admin happens to write as SYSTEM_TENANT_ID (e.g. test user
126
- // is the system-admin on the system-tenant), the admin write hits
127
- // the seed-row directly and updates the same aggregate stream. Both
128
- // paths end up with the admin value winning — the row-count
129
- // assertion makes the path explicit.
130
- await stack.http.writeOk(
131
- "config:write:set",
132
- { key: SITE_KEY, value: "admin-override", scope: "tenant" },
133
- TestUsers.systemAdmin,
134
- );
135
-
136
- await applyBootSeeds({ registry: stack.registry, db: stack.db });
137
-
138
- const siteKeyDef = stack.registry.getConfigKey(SITE_KEY);
139
- expect(siteKeyDef).toBeDefined();
140
- const peek = await resolver.get(
141
- SITE_KEY,
142
- siteKeyDef!,
143
- TestUsers.systemAdmin.tenantId,
144
- TestUsers.systemAdmin.id,
145
- stack.db,
146
- );
147
- expect(peek).toBe("admin-override");
148
-
149
- // Row-count tells us which path was hit:
150
- // - 2 rows = override path (admin tenantId === SYSTEM_TENANT_ID,
151
- // updated the seed-stream in place).
152
- // - 3 rows = coexistence path (admin tenantId !== SYSTEM_TENANT_ID,
153
- // new specific-tenant row sits next to the seed system-row).
154
- // Either is correct as long as the resolver picks the admin value.
155
- const rows = await selectMany(stack.db, configValuesTable);
156
- expect([2, 3]).toContain(rows.length);
157
- });
158
- });