@cosmicdrift/kumiko-server-runtime 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/LICENSE +57 -0
- package/package.json +87 -0
- package/src/__tests__/boot-extra-context.test.ts +140 -0
- package/src/__tests__/build-prod-bundle.test.ts +278 -0
- package/src/__tests__/cache-headers.test.ts +83 -0
- package/src/__tests__/compose-features-mfa-wiring.integration.test.ts +308 -0
- package/src/__tests__/compose-features-wiring.integration.test.ts +382 -0
- package/src/__tests__/compose-features.test.ts +128 -0
- package/src/__tests__/config-seed-boot.integration.test.ts +158 -0
- package/src/__tests__/inject-schema.test.ts +62 -0
- package/src/__tests__/pii-boot-gate.test.ts +68 -0
- package/src/__tests__/renderer-web-css-relocation.integration.test.ts +85 -0
- package/src/__tests__/renderer-web-shell-sentinel.test.ts +35 -0
- package/src/__tests__/require-env.test.ts +29 -0
- package/src/__tests__/resolve-auth-mail.test.ts +69 -0
- package/src/__tests__/resolve-tailwind-cli.test.ts +81 -0
- package/src/__tests__/run-prod-app-env-source.test.ts +157 -0
- package/src/__tests__/run-prod-app-spec.test.ts +57 -0
- package/src/__tests__/run-prod-app.integration.test.ts +915 -0
- package/src/__tests__/session-wiring.test.ts +51 -0
- package/src/__tests__/try-hono-first.test.ts +63 -0
- package/src/boot/__tests__/job-run-logger.test.ts +26 -0
- package/src/boot/apply-boot-seeds.ts +19 -0
- package/src/boot/boot-crypto.ts +82 -0
- package/src/boot/job-run-logger.ts +51 -0
- package/src/build-prod-bundle.ts +692 -0
- package/src/bun-serve-options.ts +22 -0
- package/src/compose-features.ts +164 -0
- package/src/extra-routes-deps.ts +47 -0
- package/src/index.ts +16 -0
- package/src/inject-schema.ts +30 -0
- package/src/pii-boot-gate.ts +62 -0
- package/src/resolve-tailwind-cli.ts +45 -0
- package/src/run-prod-app-boot-context.ts +241 -0
- package/src/run-prod-app-static-files.ts +273 -0
- package/src/run-prod-app.ts +1137 -0
- package/src/session-wiring.ts +29 -0
- package/src/try-hono-first.ts +46 -0
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
// Full-stack wiring test for kumiko-framework#266 Step 6: proves the exact
|
|
2
|
+
// bootstrap path runProdApp/runDevApp produce — composeFeatures() auto-
|
|
3
|
+
// detecting a mounted auth-mfa app-feature and wiring its status-checker
|
|
4
|
+
// into the login handler — actually completes a two-step login over real
|
|
5
|
+
// HTTP. compose-features-wiring.integration.test.ts pins the same pattern
|
|
6
|
+
// for passwordReset/emailVerification; this is the auth-mfa analogue.
|
|
7
|
+
//
|
|
8
|
+
// No mocking: setupTestStack boots a real DB + Redis.
|
|
9
|
+
|
|
10
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
11
|
+
import {
|
|
12
|
+
AuthHandlers,
|
|
13
|
+
hashPassword,
|
|
14
|
+
} from "@cosmicdrift/kumiko-bundled-features/auth-email-password";
|
|
15
|
+
import {
|
|
16
|
+
AuthMfaHandlers,
|
|
17
|
+
base32Decode,
|
|
18
|
+
bindMfaRevokeAllOtherSessionsFromFeature,
|
|
19
|
+
createAuthMfaFeature,
|
|
20
|
+
mfaRequiredConfigHandle,
|
|
21
|
+
userMfaEntity,
|
|
22
|
+
} from "@cosmicdrift/kumiko-bundled-features/auth-mfa";
|
|
23
|
+
import { currentTotpCode } from "@cosmicdrift/kumiko-bundled-features/auth-mfa/testing";
|
|
24
|
+
import {
|
|
25
|
+
configValuesTable,
|
|
26
|
+
createConfigResolver,
|
|
27
|
+
} from "@cosmicdrift/kumiko-bundled-features/config";
|
|
28
|
+
import {
|
|
29
|
+
createSessionCallbacks,
|
|
30
|
+
userSessionEntity,
|
|
31
|
+
} from "@cosmicdrift/kumiko-bundled-features/sessions";
|
|
32
|
+
import { tenantEntity, tenantMembershipsTable } from "@cosmicdrift/kumiko-bundled-features/tenant";
|
|
33
|
+
import { seedTenantMembership } from "@cosmicdrift/kumiko-bundled-features/tenant/testing";
|
|
34
|
+
import { UserHandlers, userEntity, userTable } from "@cosmicdrift/kumiko-bundled-features/user";
|
|
35
|
+
import { configureEntityFieldEncryption } from "@cosmicdrift/kumiko-framework/db";
|
|
36
|
+
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
37
|
+
import {
|
|
38
|
+
setupTestStack,
|
|
39
|
+
type TestStack,
|
|
40
|
+
TestUsers,
|
|
41
|
+
unsafeCreateEntityTable,
|
|
42
|
+
unsafePushTables,
|
|
43
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
44
|
+
import { createTestEnvelopeCipher, deleteRows } from "@cosmicdrift/kumiko-framework/testing";
|
|
45
|
+
import { composeFeatures } from "../compose-features";
|
|
46
|
+
|
|
47
|
+
const CHALLENGE_TOKEN_SECRET = "test-mfa-challenge-secret-at-least-32-bytes!!";
|
|
48
|
+
const SETUP_TOKEN_SECRET = "test-mfa-setup-secret-at-least-32-bytes-long!!";
|
|
49
|
+
const TEST_TENANT_ID: TenantId = "00000000-0000-4000-8000-000000000001" as TenantId;
|
|
50
|
+
const systemAdmin = TestUsers.systemAdmin;
|
|
51
|
+
|
|
52
|
+
async function bootStack(): Promise<TestStack> {
|
|
53
|
+
configureEntityFieldEncryption(createTestEnvelopeCipher());
|
|
54
|
+
const mfaFeature = createAuthMfaFeature({
|
|
55
|
+
setupTokenSecret: SETUP_TOKEN_SECRET,
|
|
56
|
+
challengeTokenSecret: CHALLENGE_TOKEN_SECRET,
|
|
57
|
+
issuer: "Kumiko Test",
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// Exactly what runProdApp/runDevApp do: composeFeatures sees auth-mfa in
|
|
61
|
+
// the app-feature list and threads mfaStatusChecker into the bundled
|
|
62
|
+
// auth-email-password feature's login handler on its own — no authOptions
|
|
63
|
+
// override needed here, which is the thing this test proves.
|
|
64
|
+
const features = composeFeatures([mfaFeature], { includeBundled: true });
|
|
65
|
+
|
|
66
|
+
const stack = await setupTestStack({
|
|
67
|
+
features,
|
|
68
|
+
extraContext: () => ({ configResolver: createConfigResolver() }),
|
|
69
|
+
authConfig: {
|
|
70
|
+
membershipQuery: "tenant:query:memberships",
|
|
71
|
+
loginHandler: AuthHandlers.login,
|
|
72
|
+
mfaVerifyHandler: AuthMfaHandlers.verify,
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const sessionCallbacks = createSessionCallbacks({ db: stack.db });
|
|
77
|
+
bindMfaRevokeAllOtherSessionsFromFeature(mfaFeature)?.(sessionCallbacks.sessionRevokeAllOthers);
|
|
78
|
+
|
|
79
|
+
await unsafeCreateEntityTable(stack.db, userEntity);
|
|
80
|
+
await unsafeCreateEntityTable(stack.db, tenantEntity);
|
|
81
|
+
await unsafeCreateEntityTable(stack.db, userMfaEntity);
|
|
82
|
+
await unsafeCreateEntityTable(stack.db, userSessionEntity);
|
|
83
|
+
await unsafePushTables(stack.db, { configValuesTable, tenantMembershipsTable });
|
|
84
|
+
|
|
85
|
+
return stack;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function seedUser(
|
|
89
|
+
stack: TestStack,
|
|
90
|
+
opts: { email: string; password: string; roles?: readonly string[] },
|
|
91
|
+
): Promise<{ id: string }> {
|
|
92
|
+
const hash = await hashPassword(opts.password);
|
|
93
|
+
const created = await stack.http.writeOk<{ id: string }>(
|
|
94
|
+
UserHandlers.create,
|
|
95
|
+
{ email: opts.email, passwordHash: hash, displayName: opts.email.split("@")[0] ?? "user" },
|
|
96
|
+
systemAdmin,
|
|
97
|
+
);
|
|
98
|
+
await seedTenantMembership(stack.db, {
|
|
99
|
+
userId: created.id,
|
|
100
|
+
tenantId: TEST_TENANT_ID,
|
|
101
|
+
roles: opts.roles ?? ["User"],
|
|
102
|
+
});
|
|
103
|
+
return { id: created.id };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
describe("composeFeatures wiring — auth-mfa (kumiko-framework#266 Step 6)", () => {
|
|
107
|
+
let stack: TestStack;
|
|
108
|
+
|
|
109
|
+
beforeAll(async () => {
|
|
110
|
+
stack = await bootStack();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
afterAll(async () => {
|
|
114
|
+
await stack.cleanup();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
beforeEach(async () => {
|
|
118
|
+
await deleteRows(stack.db, userTable, {});
|
|
119
|
+
await deleteRows(stack.db, tenantMembershipsTable, {});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("full login-with-MFA flow: password login → mfa-challenge → /auth/mfa/verify → session", async () => {
|
|
123
|
+
const user = await seedUser(stack, {
|
|
124
|
+
email: "alice@example.com",
|
|
125
|
+
password: "correct-password-1234",
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
const start = await stack.http.writeOk<{ setupToken: string; otpauthUri: string }>(
|
|
129
|
+
AuthMfaHandlers.enableStart,
|
|
130
|
+
{ accountLabel: "alice@example.com" },
|
|
131
|
+
{ id: user.id, tenantId: TEST_TENANT_ID, roles: ["User"] },
|
|
132
|
+
);
|
|
133
|
+
const secretParam = new URLSearchParams(start.otpauthUri.split("?")[1]).get("secret") ?? "";
|
|
134
|
+
const secret = base32Decode(secretParam);
|
|
135
|
+
await stack.http.writeOk(
|
|
136
|
+
AuthMfaHandlers.enableConfirm,
|
|
137
|
+
{ setupToken: start.setupToken, code: currentTotpCode(secret) },
|
|
138
|
+
{ id: user.id, tenantId: TEST_TENANT_ID, roles: ["User"] },
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
// Step 1: a correct password no longer mints a session directly — it
|
|
142
|
+
// hands back a challenge, proving composeFeatures actually wired
|
|
143
|
+
// mfaStatusChecker into the login handler (without the wiring this
|
|
144
|
+
// would 200 straight to a session and the test would fail below).
|
|
145
|
+
const loginRes = await stack.http.raw("POST", "/api/auth/login", {
|
|
146
|
+
email: "alice@example.com",
|
|
147
|
+
password: "correct-password-1234",
|
|
148
|
+
});
|
|
149
|
+
expect(loginRes.status).toBe(200);
|
|
150
|
+
const loginBody = (await loginRes.json()) as {
|
|
151
|
+
isSuccess: boolean;
|
|
152
|
+
mfaRequired?: boolean;
|
|
153
|
+
challengeToken?: string;
|
|
154
|
+
};
|
|
155
|
+
expect(loginBody.mfaRequired).toBe(true);
|
|
156
|
+
expect(loginBody.challengeToken).toBeTruthy();
|
|
157
|
+
const challengeToken = loginBody.challengeToken;
|
|
158
|
+
if (!challengeToken) throw new Error("no challengeToken in login response");
|
|
159
|
+
|
|
160
|
+
// Step 2: completing the challenge over the REAL /api/auth/mfa/verify
|
|
161
|
+
// route (not a direct handler dispatch) mints the session.
|
|
162
|
+
const verifyRes = await stack.http.raw("POST", "/api/auth/mfa/verify", {
|
|
163
|
+
challengeToken,
|
|
164
|
+
code: currentTotpCode(secret),
|
|
165
|
+
});
|
|
166
|
+
expect(verifyRes.status).toBe(200);
|
|
167
|
+
const verifyBody = (await verifyRes.json()) as {
|
|
168
|
+
isSuccess: boolean;
|
|
169
|
+
token: string;
|
|
170
|
+
user: { id: string; tenantId: string };
|
|
171
|
+
};
|
|
172
|
+
expect(verifyBody.isSuccess).toBe(true);
|
|
173
|
+
expect(verifyBody.token).toBeTruthy();
|
|
174
|
+
expect(verifyBody.user.id).toBe(user.id);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("a user without MFA enabled logs in with a straight session (no challenge)", async () => {
|
|
178
|
+
await seedUser(stack, { email: "bob@example.com", password: "any-password-1234" });
|
|
179
|
+
|
|
180
|
+
const res = await stack.http.raw("POST", "/api/auth/login", {
|
|
181
|
+
email: "bob@example.com",
|
|
182
|
+
password: "any-password-1234",
|
|
183
|
+
});
|
|
184
|
+
expect(res.status).toBe(200);
|
|
185
|
+
const body = (await res.json()) as {
|
|
186
|
+
isSuccess: boolean;
|
|
187
|
+
mfaRequired?: boolean;
|
|
188
|
+
token?: string;
|
|
189
|
+
};
|
|
190
|
+
expect(body.mfaRequired).toBeUndefined();
|
|
191
|
+
expect(body.token).toBeTruthy();
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
async function setPolicy(stack: TestStack, policy: "optional" | "admins" | "all"): Promise<void> {
|
|
196
|
+
await stack.http.writeOk(
|
|
197
|
+
"config:write:set",
|
|
198
|
+
{ key: mfaRequiredConfigHandle.name, value: policy },
|
|
199
|
+
{ id: systemAdmin.id, tenantId: TEST_TENANT_ID, roles: ["SystemAdmin"] },
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Step 7: enforcement policy only matters for UNENROLLED users — an
|
|
204
|
+
// enrolled user always gets a challenge regardless of policy (they opted
|
|
205
|
+
// in themselves). ponytail (see auth-mfa's config.ts): "admins"/"all"
|
|
206
|
+
// hard-block an unenrolled matching user with mfaSetupRequired — there is
|
|
207
|
+
// no enrollment-during-login UI yet (PR3). By design for this backend step.
|
|
208
|
+
describe("composeFeatures wiring — auth-mfa enforcement policy (kumiko-framework#266 Step 7)", () => {
|
|
209
|
+
let stack: TestStack;
|
|
210
|
+
|
|
211
|
+
beforeAll(async () => {
|
|
212
|
+
stack = await bootStack();
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
afterAll(async () => {
|
|
216
|
+
await stack.cleanup();
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
beforeEach(async () => {
|
|
220
|
+
await deleteRows(stack.db, userTable, {});
|
|
221
|
+
await deleteRows(stack.db, tenantMembershipsTable, {});
|
|
222
|
+
await setPolicy(stack, "optional");
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("optional (default): unenrolled user still gets a straight session", async () => {
|
|
226
|
+
await seedUser(stack, { email: "carol@example.com", password: "any-password-1234" });
|
|
227
|
+
const res = await stack.http.raw("POST", "/api/auth/login", {
|
|
228
|
+
email: "carol@example.com",
|
|
229
|
+
password: "any-password-1234",
|
|
230
|
+
});
|
|
231
|
+
expect(res.status).toBe(200);
|
|
232
|
+
const body = (await res.json()) as { mfaSetupRequired?: boolean; token?: string };
|
|
233
|
+
expect(body.mfaSetupRequired).toBeUndefined();
|
|
234
|
+
expect(body.token).toBeTruthy();
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test("all: unenrolled user is blocked with mfaSetupRequired, no session/challenge", async () => {
|
|
238
|
+
await setPolicy(stack, "all");
|
|
239
|
+
await seedUser(stack, { email: "dave@example.com", password: "any-password-1234" });
|
|
240
|
+
const res = await stack.http.raw("POST", "/api/auth/login", {
|
|
241
|
+
email: "dave@example.com",
|
|
242
|
+
password: "any-password-1234",
|
|
243
|
+
});
|
|
244
|
+
expect(res.status).toBe(200);
|
|
245
|
+
const body = (await res.json()) as {
|
|
246
|
+
mfaSetupRequired?: boolean;
|
|
247
|
+
mfaRequired?: boolean;
|
|
248
|
+
token?: string;
|
|
249
|
+
};
|
|
250
|
+
expect(body.mfaSetupRequired).toBe(true);
|
|
251
|
+
expect(body.mfaRequired).toBeUndefined();
|
|
252
|
+
expect(body.token).toBeUndefined();
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test("all: an already-enrolled user still gets a challenge, not a block", async () => {
|
|
256
|
+
await setPolicy(stack, "all");
|
|
257
|
+
const user = await seedUser(stack, {
|
|
258
|
+
email: "erin@example.com",
|
|
259
|
+
password: "any-password-1234",
|
|
260
|
+
});
|
|
261
|
+
const start = await stack.http.writeOk<{ setupToken: string; otpauthUri: string }>(
|
|
262
|
+
AuthMfaHandlers.enableStart,
|
|
263
|
+
{ accountLabel: "erin@example.com" },
|
|
264
|
+
{ id: user.id, tenantId: TEST_TENANT_ID, roles: ["User"] },
|
|
265
|
+
);
|
|
266
|
+
const secretParam = new URLSearchParams(start.otpauthUri.split("?")[1]).get("secret") ?? "";
|
|
267
|
+
const secret = base32Decode(secretParam);
|
|
268
|
+
await stack.http.writeOk(
|
|
269
|
+
AuthMfaHandlers.enableConfirm,
|
|
270
|
+
{ setupToken: start.setupToken, code: currentTotpCode(secret) },
|
|
271
|
+
{ id: user.id, tenantId: TEST_TENANT_ID, roles: ["User"] },
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
const res = await stack.http.raw("POST", "/api/auth/login", {
|
|
275
|
+
email: "erin@example.com",
|
|
276
|
+
password: "any-password-1234",
|
|
277
|
+
});
|
|
278
|
+
expect(res.status).toBe(200);
|
|
279
|
+
const body = (await res.json()) as { mfaSetupRequired?: boolean; mfaRequired?: boolean };
|
|
280
|
+
expect(body.mfaRequired).toBe(true);
|
|
281
|
+
expect(body.mfaSetupRequired).toBeUndefined();
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
test("admins: unenrolled admin is blocked, unenrolled non-admin logs in normally", async () => {
|
|
285
|
+
await setPolicy(stack, "admins");
|
|
286
|
+
await seedUser(stack, {
|
|
287
|
+
email: "frank-admin@example.com",
|
|
288
|
+
password: "any-password-1234",
|
|
289
|
+
roles: ["Admin"],
|
|
290
|
+
});
|
|
291
|
+
await seedUser(stack, { email: "gina-user@example.com", password: "any-password-1234" });
|
|
292
|
+
|
|
293
|
+
const adminRes = await stack.http.raw("POST", "/api/auth/login", {
|
|
294
|
+
email: "frank-admin@example.com",
|
|
295
|
+
password: "any-password-1234",
|
|
296
|
+
});
|
|
297
|
+
const adminBody = (await adminRes.json()) as { mfaSetupRequired?: boolean };
|
|
298
|
+
expect(adminBody.mfaSetupRequired).toBe(true);
|
|
299
|
+
|
|
300
|
+
const userRes = await stack.http.raw("POST", "/api/auth/login", {
|
|
301
|
+
email: "gina-user@example.com",
|
|
302
|
+
password: "any-password-1234",
|
|
303
|
+
});
|
|
304
|
+
const userBody = (await userRes.json()) as { mfaSetupRequired?: boolean; token?: string };
|
|
305
|
+
expect(userBody.mfaSetupRequired).toBeUndefined();
|
|
306
|
+
expect(userBody.token).toBeTruthy();
|
|
307
|
+
});
|
|
308
|
+
});
|
|
@@ -0,0 +1,382 @@
|
|
|
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
|
+
});
|