@cosmicdrift/kumiko-dev-server 0.1.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.
- package/bin/kumiko-build.ts +85 -0
- package/bin/kumiko-dev.ts +90 -0
- package/package.json +45 -0
- package/src/__tests__/build-prod-bundle.integration.ts +265 -0
- package/src/__tests__/build-prod-bundle.test.ts +262 -0
- package/src/__tests__/cache-headers.test.ts +70 -0
- package/src/__tests__/classify-change.test.ts +87 -0
- package/src/__tests__/compose-features-wiring.integration.ts +352 -0
- package/src/__tests__/compose-features.test.ts +81 -0
- package/src/__tests__/crash-tracker.test.ts +89 -0
- package/src/__tests__/create-kumiko-server.integration.ts +286 -0
- package/src/__tests__/few-shot-corpus.test.ts +311 -0
- package/src/__tests__/inject-schema.test.ts +62 -0
- package/src/__tests__/resolve-stylesheet.test.ts +90 -0
- package/src/__tests__/resolve-tailwind-cli.test.ts +49 -0
- package/src/__tests__/run-prod-app-spec.test.ts +57 -0
- package/src/__tests__/run-prod-app.integration.ts +535 -0
- package/src/__tests__/scaffold-feature.test.ts +143 -0
- package/src/__tests__/try-hono-first.test.ts +63 -0
- package/src/build-prod-bundle.ts +587 -0
- package/src/build-server-bundle.ts +308 -0
- package/src/build.ts +28 -0
- package/src/codegen/__tests__/run-codegen.test.ts +494 -0
- package/src/codegen/__tests__/strict-mode-diagnostics.test.ts +467 -0
- package/src/codegen/__tests__/watch.test.ts +186 -0
- package/src/codegen/index.ts +17 -0
- package/src/codegen/render.ts +225 -0
- package/src/codegen/run-codegen.ts +157 -0
- package/src/codegen/scan-events.ts +574 -0
- package/src/codegen/watch.ts +127 -0
- package/src/compose-features.ts +128 -0
- package/src/crash-tracker.ts +56 -0
- package/src/create-kumiko-server.ts +1010 -0
- package/src/drizzle-config.ts +44 -0
- package/src/drizzle-tables-auth-mode.ts +32 -0
- package/src/drizzle-tables-minimal.ts +22 -0
- package/src/few-shot-corpus.ts +369 -0
- package/src/index.ts +57 -0
- package/src/inject-schema.ts +24 -0
- package/src/resolve-tailwind-cli.ts +28 -0
- package/src/run-dev-app.ts +290 -0
- package/src/run-prod-app.ts +892 -0
- package/src/scaffold-feature.ts +226 -0
- package/src/try-hono-first.ts +46 -0
|
@@ -0,0 +1,352 @@
|
|
|
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. Die
|
|
20
|
+
// sendResetEmail-callback (analog runProdApp's createAuthMailerConfig)
|
|
21
|
+
// schreibt in ein lokales Array — gewollter Capture-Spy ohne vitest-
|
|
22
|
+
// Mock-API (CLAUDE.md "Kein Mock in *.integration.ts").
|
|
23
|
+
|
|
24
|
+
import { randomBytes } from "node:crypto";
|
|
25
|
+
import {
|
|
26
|
+
AuthErrors,
|
|
27
|
+
AuthHandlers,
|
|
28
|
+
hashPassword,
|
|
29
|
+
} from "@cosmicdrift/kumiko-bundled-features/auth-email-password";
|
|
30
|
+
import {
|
|
31
|
+
configValuesTable,
|
|
32
|
+
createConfigResolver,
|
|
33
|
+
} from "@cosmicdrift/kumiko-bundled-features/config";
|
|
34
|
+
import { tenantEntity, tenantMembershipsTable } from "@cosmicdrift/kumiko-bundled-features/tenant";
|
|
35
|
+
import { seedTenantMembership } from "@cosmicdrift/kumiko-bundled-features/tenant/testing";
|
|
36
|
+
import { UserHandlers, userEntity, userTable } from "@cosmicdrift/kumiko-bundled-features/user";
|
|
37
|
+
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
38
|
+
import {
|
|
39
|
+
createEntityTable,
|
|
40
|
+
pushTables,
|
|
41
|
+
setupTestStack,
|
|
42
|
+
type TestStack,
|
|
43
|
+
TestUsers,
|
|
44
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
45
|
+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "vitest";
|
|
46
|
+
import { composeFeatures } from "../compose-features";
|
|
47
|
+
|
|
48
|
+
const RESET_HMAC = randomBytes(32).toString("base64");
|
|
49
|
+
const VERIFY_HMAC = randomBytes(32).toString("base64");
|
|
50
|
+
const APP_RESET_URL = "https://app.example.com/reset-password";
|
|
51
|
+
const APP_VERIFY_URL = "https://app.example.com/verify-email";
|
|
52
|
+
const TEST_TENANT_ID: TenantId = "00000000-0000-4000-8000-000000000001" as TenantId;
|
|
53
|
+
const systemAdmin = TestUsers.systemAdmin;
|
|
54
|
+
|
|
55
|
+
type CapturedReset = { email: string; resetUrl: string; expiresAt: string };
|
|
56
|
+
type CapturedVerify = { email: string; verificationUrl: string; expiresAt: string };
|
|
57
|
+
|
|
58
|
+
async function bootStack(
|
|
59
|
+
authOptionsKind: "with-both" | "with-reset-only" | "without-auth-options",
|
|
60
|
+
): Promise<{
|
|
61
|
+
stack: TestStack;
|
|
62
|
+
resetEmails: CapturedReset[];
|
|
63
|
+
verifyEmails: CapturedVerify[];
|
|
64
|
+
}> {
|
|
65
|
+
const resetEmails: CapturedReset[] = [];
|
|
66
|
+
const verifyEmails: CapturedVerify[] = [];
|
|
67
|
+
|
|
68
|
+
// Genau das was runProdApp/runDevApp machen würde — composeFeatures als
|
|
69
|
+
// single-source der Feature-Liste, je nach authOptionsKind variiert die
|
|
70
|
+
// Konfiguration. Dieser Test cared explizit um die DURCHREICHE — also
|
|
71
|
+
// dass das Wrapper-API-Object an createAuthEmailPasswordFeature ankommt.
|
|
72
|
+
const features = composeFeatures([], {
|
|
73
|
+
includeBundled: true,
|
|
74
|
+
...(authOptionsKind !== "without-auth-options" && {
|
|
75
|
+
authOptions: {
|
|
76
|
+
passwordReset: { hmacSecret: RESET_HMAC, tokenTtlMinutes: 15 },
|
|
77
|
+
...(authOptionsKind === "with-both" && {
|
|
78
|
+
emailVerification: { hmacSecret: VERIFY_HMAC, tokenTtlMinutes: 60, mode: "off" },
|
|
79
|
+
}),
|
|
80
|
+
},
|
|
81
|
+
}),
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const stack = await setupTestStack({
|
|
85
|
+
features,
|
|
86
|
+
extraContext: { configResolver: createConfigResolver() },
|
|
87
|
+
authConfig: {
|
|
88
|
+
membershipQuery: "tenant:query:memberships",
|
|
89
|
+
loginHandler: AuthHandlers.login,
|
|
90
|
+
// Routes IMMER mounten — egal welche authOptionsKind. Genau das
|
|
91
|
+
// ist die Bug-Bedingung: Routes da, Handler eventuell nicht.
|
|
92
|
+
passwordReset: {
|
|
93
|
+
requestHandler: AuthHandlers.requestPasswordReset,
|
|
94
|
+
confirmHandler: AuthHandlers.resetPassword,
|
|
95
|
+
appResetUrl: APP_RESET_URL,
|
|
96
|
+
sendResetEmail: async (args) => {
|
|
97
|
+
resetEmails.push(args);
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
...(authOptionsKind === "with-both" && {
|
|
101
|
+
emailVerification: {
|
|
102
|
+
requestHandler: AuthHandlers.requestEmailVerification,
|
|
103
|
+
confirmHandler: AuthHandlers.verifyEmail,
|
|
104
|
+
appVerifyUrl: APP_VERIFY_URL,
|
|
105
|
+
sendVerificationEmail: async (args) => {
|
|
106
|
+
verifyEmails.push(args);
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
}),
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
await createEntityTable(stack.db, userEntity);
|
|
114
|
+
await createEntityTable(stack.db, tenantEntity);
|
|
115
|
+
await pushTables(stack.db, { configValuesTable, tenantMembershipsTable });
|
|
116
|
+
|
|
117
|
+
return { stack, resetEmails, verifyEmails };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function seedUser(
|
|
121
|
+
stack: TestStack,
|
|
122
|
+
opts: { email: string; password: string },
|
|
123
|
+
): Promise<{ id: string }> {
|
|
124
|
+
const hash = await hashPassword(opts.password);
|
|
125
|
+
const created = await stack.http.writeOk<{ id: string }>(
|
|
126
|
+
UserHandlers.create,
|
|
127
|
+
{
|
|
128
|
+
email: opts.email,
|
|
129
|
+
passwordHash: hash,
|
|
130
|
+
displayName: opts.email.split("@")[0] ?? "user",
|
|
131
|
+
},
|
|
132
|
+
systemAdmin,
|
|
133
|
+
);
|
|
134
|
+
await seedTenantMembership(stack.db, {
|
|
135
|
+
userId: created.id,
|
|
136
|
+
tenantId: TEST_TENANT_ID,
|
|
137
|
+
roles: ["User"],
|
|
138
|
+
});
|
|
139
|
+
return { id: created.id };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
describe("composeFeatures wiring — passwordReset", () => {
|
|
143
|
+
let suite: Awaited<ReturnType<typeof bootStack>>;
|
|
144
|
+
|
|
145
|
+
beforeAll(async () => {
|
|
146
|
+
suite = await bootStack("with-both");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
afterAll(async () => {
|
|
150
|
+
await suite.stack.cleanup();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
beforeEach(async () => {
|
|
154
|
+
await suite.stack.db.delete(userTable);
|
|
155
|
+
await suite.stack.db.delete(tenantMembershipsTable);
|
|
156
|
+
suite.resetEmails.length = 0;
|
|
157
|
+
suite.verifyEmails.length = 0;
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("full reset roundtrip: request → email → reset → login with new password", async () => {
|
|
161
|
+
// Beweis: composeFeatures(authOptions.passwordReset) hat den Handler
|
|
162
|
+
// im Feature registriert UND auth-routes hat die /api/auth/...-Routes
|
|
163
|
+
// gemountet — der Wrapper-Pfad ist konsistent. password-reset.
|
|
164
|
+
// integration.ts beweist das gleiche für direkten Feature-Aufruf;
|
|
165
|
+
// dieser Test pinst dass der Wrapper das Pattern repliziert.
|
|
166
|
+
await seedUser(suite.stack, { email: "alice@example.com", password: "old-password-1234" });
|
|
167
|
+
|
|
168
|
+
const requestRes = await suite.stack.http.raw("POST", "/api/auth/request-password-reset", {
|
|
169
|
+
email: "alice@example.com",
|
|
170
|
+
});
|
|
171
|
+
expect(requestRes.status).toBe(200);
|
|
172
|
+
expect(suite.resetEmails).toHaveLength(1);
|
|
173
|
+
const captured = suite.resetEmails[0];
|
|
174
|
+
if (!captured) throw new Error("no email captured");
|
|
175
|
+
expect(captured.email).toBe("alice@example.com");
|
|
176
|
+
|
|
177
|
+
// Token aus URL extrahieren — wie der echte User es täte (Mail klicken,
|
|
178
|
+
// Browser parsed query-string). Das macht dieser Test "echter" als ein
|
|
179
|
+
// signResetToken-Bypass: er pinst den vollen URL-zu-Handler-Roundtrip.
|
|
180
|
+
const resetUrl = new URL(captured.resetUrl);
|
|
181
|
+
expect(`${resetUrl.origin}${resetUrl.pathname}`).toBe(APP_RESET_URL);
|
|
182
|
+
const token = resetUrl.searchParams.get("token");
|
|
183
|
+
expect(token).toBeTruthy();
|
|
184
|
+
if (!token) return;
|
|
185
|
+
|
|
186
|
+
const resetRes = await suite.stack.http.raw("POST", "/api/auth/reset-password", {
|
|
187
|
+
token,
|
|
188
|
+
newPassword: "brand-new-pw-9876",
|
|
189
|
+
});
|
|
190
|
+
expect(resetRes.status).toBe(200);
|
|
191
|
+
|
|
192
|
+
// Confirmation: das neue Passwort funktioniert für /api/auth/login.
|
|
193
|
+
// Das ist der echte End-to-End-Beweis (DB-Read würde nur
|
|
194
|
+
// password_hash != old verifizieren — hier prüfen wir die User-
|
|
195
|
+
// visible Konsequenz).
|
|
196
|
+
const loginRes = await suite.stack.http.raw("POST", "/api/auth/login", {
|
|
197
|
+
email: "alice@example.com",
|
|
198
|
+
password: "brand-new-pw-9876",
|
|
199
|
+
});
|
|
200
|
+
expect(loginRes.status).toBe(200);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("invalid token via wrapper-routes → 422 invalid_reset_token", async () => {
|
|
204
|
+
await seedUser(suite.stack, { email: "carol@example.com", password: "keep-me-1234" });
|
|
205
|
+
|
|
206
|
+
const res = await suite.stack.http.raw("POST", "/api/auth/reset-password", {
|
|
207
|
+
token: "tampered.totally.fake",
|
|
208
|
+
newPassword: "should-not-stick-1234",
|
|
209
|
+
});
|
|
210
|
+
expect(res.status).toBe(422);
|
|
211
|
+
const body = (await res.json()) as { error?: { details?: { reason?: string } } };
|
|
212
|
+
expect(body.error?.details?.reason).toBe(AuthErrors.invalidResetToken);
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
describe("composeFeatures wiring — emailVerification", () => {
|
|
217
|
+
let suite: Awaited<ReturnType<typeof bootStack>>;
|
|
218
|
+
|
|
219
|
+
beforeAll(async () => {
|
|
220
|
+
suite = await bootStack("with-both");
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
afterAll(async () => {
|
|
224
|
+
await suite.stack.cleanup();
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
beforeEach(async () => {
|
|
228
|
+
await suite.stack.db.delete(userTable);
|
|
229
|
+
await suite.stack.db.delete(tenantMembershipsTable);
|
|
230
|
+
suite.resetEmails.length = 0;
|
|
231
|
+
suite.verifyEmails.length = 0;
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test("emailVerification authOption durchgereicht → request handler dispatched", async () => {
|
|
235
|
+
// Symmetric zum reset-Test — pinst dass authOptions.emailVerification
|
|
236
|
+
// genauso durchgereicht wird wie passwordReset. Bug-Pattern wäre dass
|
|
237
|
+
// der Wrapper EINS funktioniert, ANDERES vergisst.
|
|
238
|
+
await seedUser(suite.stack, { email: "bob@example.com", password: "any-pw-1234" });
|
|
239
|
+
|
|
240
|
+
const res = await suite.stack.http.raw("POST", "/api/auth/request-email-verification", {
|
|
241
|
+
email: "bob@example.com",
|
|
242
|
+
});
|
|
243
|
+
expect(res.status).toBe(200);
|
|
244
|
+
expect(suite.verifyEmails).toHaveLength(1);
|
|
245
|
+
const captured = suite.verifyEmails[0];
|
|
246
|
+
if (!captured) throw new Error("no verification email captured");
|
|
247
|
+
expect(captured.email).toBe("bob@example.com");
|
|
248
|
+
const verifyUrl = new URL(captured.verificationUrl);
|
|
249
|
+
expect(`${verifyUrl.origin}${verifyUrl.pathname}`).toBe(APP_VERIFY_URL);
|
|
250
|
+
expect(verifyUrl.searchParams.get("token")).toBeTruthy();
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
describe("composeFeatures wiring — asymmetric activation", () => {
|
|
255
|
+
// Pinst dass passwordReset und emailVerification UNABHÄNGIG durchgereicht
|
|
256
|
+
// werden. Bug-Pattern: ein Refactor des Helpers könnte einen Block
|
|
257
|
+
// versehentlich an den anderen koppeln (z.B. emailVerification nur
|
|
258
|
+
// durchreichen wenn passwordReset auch gesetzt ist) — dann würde eine
|
|
259
|
+
// App die NUR Reset-Flow will plötzlich keine Reset-Mails mehr kriegen,
|
|
260
|
+
// oder eine die NUR Verify-Flow will keine Verify-Mails. Asymmetric-
|
|
261
|
+
// activation ist also ein eigenständiger Wrapper-Vertrag.
|
|
262
|
+
|
|
263
|
+
let suite: Awaited<ReturnType<typeof bootStack>>;
|
|
264
|
+
|
|
265
|
+
beforeAll(async () => {
|
|
266
|
+
suite = await bootStack("with-reset-only");
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
afterAll(async () => {
|
|
270
|
+
await suite.stack.cleanup();
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
beforeEach(async () => {
|
|
274
|
+
await suite.stack.db.delete(userTable);
|
|
275
|
+
await suite.stack.db.delete(tenantMembershipsTable);
|
|
276
|
+
suite.resetEmails.length = 0;
|
|
277
|
+
suite.verifyEmails.length = 0;
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test("nur passwordReset gesetzt → reset-flow live, verify-flow fail-closed", async () => {
|
|
281
|
+
await seedUser(suite.stack, { email: "alice@example.com", password: "any-pw-1234" });
|
|
282
|
+
|
|
283
|
+
// Reset-flow funktioniert: Mail wird produziert.
|
|
284
|
+
const resetRes = await suite.stack.http.raw("POST", "/api/auth/request-password-reset", {
|
|
285
|
+
email: "alice@example.com",
|
|
286
|
+
});
|
|
287
|
+
expect(resetRes.status).toBe(200);
|
|
288
|
+
expect(suite.resetEmails).toHaveLength(1);
|
|
289
|
+
|
|
290
|
+
// Verify-Routes sind in dieser bootStack-Variante NICHT gemounted
|
|
291
|
+
// (authConfig.emailVerification fehlt). Der Endpoint existiert also
|
|
292
|
+
// gar nicht — Hono returnt 404. Unterscheidet sich vom 200-silent-
|
|
293
|
+
// success-Pfad: hier ist die ROUTE selbst nicht da.
|
|
294
|
+
const verifyRes = await suite.stack.http.raw("POST", "/api/auth/request-email-verification", {
|
|
295
|
+
email: "alice@example.com",
|
|
296
|
+
});
|
|
297
|
+
expect(verifyRes.status).toBe(404);
|
|
298
|
+
expect(suite.verifyEmails).toHaveLength(0);
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
describe("composeFeatures wiring — fail-closed ohne authOptions", () => {
|
|
303
|
+
// Der Bug den der Review-Agent gefangen hat. Whitebox-Variante in
|
|
304
|
+
// compose-features.test.ts checkt nur Object.keys(writeHandlers); hier
|
|
305
|
+
// pinst der Test das User-visible Verhalten: WENN user existiert + POST
|
|
306
|
+
// request-password-reset gefeuert wird, MUSS der Wrapper-Pfad eine Mail
|
|
307
|
+
// produzieren. Tut er das nicht, ist der composeFeatures-authOptions-
|
|
308
|
+
// Bug zurück.
|
|
309
|
+
//
|
|
310
|
+
// Subtilität: auth-routes mountet die request-Route by-design als
|
|
311
|
+
// enumeration-safe (always-200, silently swallow handler-Failures —
|
|
312
|
+
// siehe registerTokenRequestRoute in auth-routes.ts). Ein fehlender
|
|
313
|
+
// Handler endet daher nicht in 4xx/5xx, sondern silent in "200 + 0
|
|
314
|
+
// mails". Genau das pinnt dieser Test gegen die Regression: ohne
|
|
315
|
+
// resetEmails-Capture als Counter-Evidence wäre der Bug unsichtbar.
|
|
316
|
+
|
|
317
|
+
let suite: Awaited<ReturnType<typeof bootStack>>;
|
|
318
|
+
|
|
319
|
+
beforeAll(async () => {
|
|
320
|
+
suite = await bootStack("without-auth-options");
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
afterAll(async () => {
|
|
324
|
+
await suite.stack.cleanup();
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
afterEach(async () => {
|
|
328
|
+
await suite.stack.db.delete(userTable);
|
|
329
|
+
await suite.stack.db.delete(tenantMembershipsTable);
|
|
330
|
+
suite.resetEmails.length = 0;
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
test("authOptions fehlt → POST returnt enumeration-safe 200, ABER NULL Mails (der echte Bug-Beweis)", async () => {
|
|
334
|
+
// User EXISTIERT — das pinst dass die fehlende Mail auf dem
|
|
335
|
+
// composeFeatures-bug beruht, nicht auf "user not found"
|
|
336
|
+
// (welcher legitim 0 mails produziert: enumeration-safety).
|
|
337
|
+
await seedUser(suite.stack, { email: "noop@example.com", password: "any-pw-1234" });
|
|
338
|
+
|
|
339
|
+
const res = await suite.stack.http.raw("POST", "/api/auth/request-password-reset", {
|
|
340
|
+
email: "noop@example.com",
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
// 200 ist by-design (registerTokenRequestRoute swallowt handler-
|
|
344
|
+
// Failures). Bug-Beweis ist die fehlende Mail — wenn der "with-both"-
|
|
345
|
+
// Test 1 Mail bekommt und dieser Test 0 bekommt für denselben
|
|
346
|
+
// existierenden User, ist die Differenz EXAKT der composeFeatures-
|
|
347
|
+
// Bug. Diese Asymmetrie zwischen den beiden describe-Blöcken IST
|
|
348
|
+
// der Test.
|
|
349
|
+
expect(res.status).toBe(200);
|
|
350
|
+
expect(suite.resetEmails).toHaveLength(0);
|
|
351
|
+
});
|
|
352
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
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 { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
11
|
+
import { describe, expect, test } from "vitest";
|
|
12
|
+
import { composeFeatures } from "../compose-features";
|
|
13
|
+
|
|
14
|
+
const noopFeature = defineFeature("noop-app", () => {});
|
|
15
|
+
|
|
16
|
+
const HMAC_SECRET = "test-secret-with-at-least-32-bytes-aaa";
|
|
17
|
+
|
|
18
|
+
describe("composeFeatures", () => {
|
|
19
|
+
test("includeBundled=false → nur App-Features", () => {
|
|
20
|
+
const features = composeFeatures([noopFeature], { includeBundled: false });
|
|
21
|
+
expect(features.map((f) => f.name)).toEqual(["noop-app"]);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("includeBundled=true → 4 bundled Features davor", () => {
|
|
25
|
+
const features = composeFeatures([noopFeature], { includeBundled: true });
|
|
26
|
+
expect(features.map((f) => f.name)).toEqual([
|
|
27
|
+
"config",
|
|
28
|
+
"user",
|
|
29
|
+
"tenant",
|
|
30
|
+
"auth-email-password",
|
|
31
|
+
"noop-app",
|
|
32
|
+
]);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("authOptions.passwordReset → request-password-reset + reset-password handlers registriert", () => {
|
|
36
|
+
const features = composeFeatures([noopFeature], {
|
|
37
|
+
includeBundled: true,
|
|
38
|
+
authOptions: { passwordReset: { hmacSecret: HMAC_SECRET } },
|
|
39
|
+
});
|
|
40
|
+
const auth = features.find((f) => f.name === "auth-email-password");
|
|
41
|
+
expect(auth).toBeDefined();
|
|
42
|
+
if (!auth) return;
|
|
43
|
+
const handlerNames = Array.from(Object.keys(auth.writeHandlers));
|
|
44
|
+
expect(handlerNames).toContain("request-password-reset");
|
|
45
|
+
expect(handlerNames).toContain("reset-password");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("authOptions.emailVerification → request-email-verification + verify-email handlers registriert", () => {
|
|
49
|
+
const features = composeFeatures([noopFeature], {
|
|
50
|
+
includeBundled: true,
|
|
51
|
+
authOptions: { emailVerification: { hmacSecret: HMAC_SECRET } },
|
|
52
|
+
});
|
|
53
|
+
const auth = features.find((f) => f.name === "auth-email-password");
|
|
54
|
+
expect(auth).toBeDefined();
|
|
55
|
+
if (!auth) return;
|
|
56
|
+
const handlerNames = Array.from(Object.keys(auth.writeHandlers));
|
|
57
|
+
expect(handlerNames).toContain("request-email-verification");
|
|
58
|
+
expect(handlerNames).toContain("verify-email");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("OHNE authOptions → KEINE reset/verify-handlers (anti-default-deploy-bug)", () => {
|
|
62
|
+
// Genau der Bug der vom Review-Agent gefangen wurde: composeFeatures
|
|
63
|
+
// ohne authOptions registriert die handler nicht. Wenn jemand das
|
|
64
|
+
// versehentlich vergisst und nur die routes (auth-routes-config)
|
|
65
|
+
// wired, schlagen die requests auf prod fehl. Der Test pinst dass
|
|
66
|
+
// dieser default-deny-Pfad bewusst leer ist.
|
|
67
|
+
const features = composeFeatures([noopFeature], { includeBundled: true });
|
|
68
|
+
const auth = features.find((f) => f.name === "auth-email-password");
|
|
69
|
+
expect(auth).toBeDefined();
|
|
70
|
+
if (!auth) return;
|
|
71
|
+
const handlerNames = Array.from(Object.keys(auth.writeHandlers));
|
|
72
|
+
expect(handlerNames).not.toContain("request-password-reset");
|
|
73
|
+
expect(handlerNames).not.toContain("reset-password");
|
|
74
|
+
expect(handlerNames).not.toContain("request-email-verification");
|
|
75
|
+
expect(handlerNames).not.toContain("verify-email");
|
|
76
|
+
// Die regulären auth-handlers (login/logout/change-password) MÜSSEN
|
|
77
|
+
// aber immer da sein — das ist der core-flow.
|
|
78
|
+
expect(handlerNames).toContain("login");
|
|
79
|
+
expect(handlerNames).toContain("logout");
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Unit-Tests für createCrashTracker — die rollende-Fenster-Logik aus
|
|
2
|
+
// kumiko-dev. Hier steckt der Crash-Loop-Schutz, der entscheidet ob der
|
|
3
|
+
// Wrapper aufgibt oder noch eine Runde respawnt; off-by-one am Fenster-
|
|
4
|
+
// Rand würde im Bin-Skript schlecht auffallen.
|
|
5
|
+
|
|
6
|
+
import { describe, expect, test } from "vitest";
|
|
7
|
+
import { createCrashTracker } from "../crash-tracker";
|
|
8
|
+
|
|
9
|
+
describe("createCrashTracker", () => {
|
|
10
|
+
test("erste maxCrashes Crashes sind erlaubt, der nächste nicht", () => {
|
|
11
|
+
const t = createCrashTracker({ maxCrashes: 3, windowMs: 10_000 });
|
|
12
|
+
expect(t.noteCrash(1000)).toBe(true);
|
|
13
|
+
expect(t.noteCrash(1100)).toBe(true);
|
|
14
|
+
expect(t.noteCrash(1200)).toBe(true);
|
|
15
|
+
// 4. Crash innerhalb des Fensters → über Limit
|
|
16
|
+
expect(t.noteCrash(1300)).toBe(false);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("alte Crashes außerhalb des Fensters werden geprunt", () => {
|
|
20
|
+
const t = createCrashTracker({ maxCrashes: 2, windowMs: 1000 });
|
|
21
|
+
t.noteCrash(0);
|
|
22
|
+
t.noteCrash(500);
|
|
23
|
+
// bei t=1500 ist t=0 raus (1500 - 1000 = 500, alles < 500 fliegt),
|
|
24
|
+
// im Fenster bleibt nur t=500. Mit dem neuen Crash bei 1500 sind
|
|
25
|
+
// wir bei 2 → noch im Limit.
|
|
26
|
+
expect(t.noteCrash(1500)).toBe(true);
|
|
27
|
+
expect(t.crashCountInWindow(1500)).toBe(2);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("crashCountInWindow zählt nur, was im Fenster liegt", () => {
|
|
31
|
+
const t = createCrashTracker({ maxCrashes: 5, windowMs: 1000 });
|
|
32
|
+
t.noteCrash(0);
|
|
33
|
+
t.noteCrash(0);
|
|
34
|
+
t.noteCrash(2000); // pruned beide alten weg
|
|
35
|
+
expect(t.crashCountInWindow(2000)).toBe(1);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("crashCountInWindow prunt lazy auch ohne vorheriges noteCrash", () => {
|
|
39
|
+
// Wichtig: crashCountInWindow muss eigenständig korrekt sein, nicht
|
|
40
|
+
// nur als Folge eines vorangegangenen noteCrash. Sonst lügt der Name.
|
|
41
|
+
const t = createCrashTracker({ maxCrashes: 5, windowMs: 1000 });
|
|
42
|
+
t.noteCrash(0);
|
|
43
|
+
t.noteCrash(100);
|
|
44
|
+
// Direkter Aufruf bei now=2000 — alle alten Crashes sind raus.
|
|
45
|
+
expect(t.crashCountInWindow(2000)).toBe(0);
|
|
46
|
+
// Idempotent: zweiter Aufruf liefert dasselbe.
|
|
47
|
+
expect(t.crashCountInWindow(2000)).toBe(0);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("Boundary: Crash genau am Fenster-Endpoint bleibt im Fenster", () => {
|
|
51
|
+
// Endpoint inklusive: cutoff = now - windowMs. Crash bei t=cutoff
|
|
52
|
+
// soll noch zählen. Hier: now=1000, windowMs=1000 → cutoff=0.
|
|
53
|
+
// Ein vorheriger Crash bei t=0 darf nicht als "alt" geprunt werden.
|
|
54
|
+
const t = createCrashTracker({ maxCrashes: 2, windowMs: 1000 });
|
|
55
|
+
t.noteCrash(0);
|
|
56
|
+
expect(t.noteCrash(1000)).toBe(true);
|
|
57
|
+
expect(t.crashCountInWindow(1000)).toBe(2);
|
|
58
|
+
// Dritter Crash bei 1000 → 3 im Fenster, über Limit
|
|
59
|
+
expect(t.noteCrash(1000)).toBe(false);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("Mehrere Crashes am gleichen Timestamp zählen einzeln", () => {
|
|
63
|
+
const t = createCrashTracker({ maxCrashes: 2, windowMs: 1000 });
|
|
64
|
+
expect(t.noteCrash(500)).toBe(true);
|
|
65
|
+
expect(t.noteCrash(500)).toBe(true);
|
|
66
|
+
expect(t.noteCrash(500)).toBe(false);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("Nach Wartezeit > windowMs ist das Limit zurückgesetzt", () => {
|
|
70
|
+
const t = createCrashTracker({ maxCrashes: 2, windowMs: 1000 });
|
|
71
|
+
t.noteCrash(0);
|
|
72
|
+
t.noteCrash(100);
|
|
73
|
+
expect(t.noteCrash(200)).toBe(false); // über Limit
|
|
74
|
+
// Wartezeit, alte Crashes raus
|
|
75
|
+
expect(t.noteCrash(2000)).toBe(true);
|
|
76
|
+
expect(t.crashCountInWindow(2000)).toBe(1);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("maxCrashes=1 erlaubt genau einen Crash pro Fenster", () => {
|
|
80
|
+
const t = createCrashTracker({ maxCrashes: 1, windowMs: 1000 });
|
|
81
|
+
expect(t.noteCrash(0)).toBe(true);
|
|
82
|
+
expect(t.noteCrash(500)).toBe(false);
|
|
83
|
+
// Bei t=1600 sind beide vorigen Crashes (0 und 500) außerhalb
|
|
84
|
+
// (cutoff = 600, 0 < 600 und 500 < 600), Tracker ist leer
|
|
85
|
+
// bevor der neue gepusht wird.
|
|
86
|
+
expect(t.noteCrash(1600)).toBe(true);
|
|
87
|
+
expect(t.crashCountInWindow(1600)).toBe(1);
|
|
88
|
+
});
|
|
89
|
+
});
|