@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.
- package/package.json +5 -8
- package/src/__tests__/build-prod-bundle.integration.test.ts +1 -1
- package/src/__tests__/compose-stacks.test.ts +1 -1
- package/src/__tests__/discover-format.test.ts +1 -1
- package/src/__tests__/env-schema.integration.test.ts +1 -1
- package/src/__tests__/walkthrough.integration.test.ts +1 -1
- package/src/build.ts +1 -1
- package/src/create-kumiko-server.ts +12 -6
- package/src/index.ts +4 -12
- package/src/run-dev-app.ts +12 -8
- package/src/scaffold-app.ts +9 -4
- package/src/schema-apply.ts +4 -1
- package/src/schema-check-core.ts +1 -1
- package/src/setup-test-stack-from-features.ts +4 -1
- package/src/__tests__/boot-extra-context.test.ts +0 -140
- package/src/__tests__/build-prod-bundle.test.ts +0 -278
- package/src/__tests__/cache-headers.test.ts +0 -83
- package/src/__tests__/compose-features-mfa-wiring.integration.test.ts +0 -308
- package/src/__tests__/compose-features-wiring.integration.test.ts +0 -382
- package/src/__tests__/compose-features.test.ts +0 -128
- package/src/__tests__/config-seed-boot.integration.test.ts +0 -158
- package/src/__tests__/inject-schema.test.ts +0 -62
- package/src/__tests__/pii-boot-gate.test.ts +0 -68
- package/src/__tests__/renderer-web-css-relocation.integration.test.ts +0 -85
- package/src/__tests__/renderer-web-shell-sentinel.test.ts +0 -35
- package/src/__tests__/require-env.test.ts +0 -29
- package/src/__tests__/resolve-auth-mail.test.ts +0 -69
- package/src/__tests__/resolve-tailwind-cli.test.ts +0 -81
- package/src/__tests__/run-prod-app-env-source.test.ts +0 -157
- package/src/__tests__/run-prod-app-spec.test.ts +0 -57
- package/src/__tests__/run-prod-app.integration.test.ts +0 -915
- package/src/__tests__/session-wiring.test.ts +0 -51
- package/src/__tests__/try-hono-first.test.ts +0 -63
- package/src/boot/__tests__/job-run-logger.test.ts +0 -26
- package/src/boot/apply-boot-seeds.ts +0 -19
- package/src/boot/boot-crypto.ts +0 -82
- package/src/boot/job-run-logger.ts +0 -51
- package/src/build-prod-bundle.ts +0 -692
- package/src/compose-features.ts +0 -164
- package/src/extra-routes-deps.ts +0 -47
- package/src/inject-schema.ts +0 -30
- package/src/pii-boot-gate.ts +0 -62
- package/src/resolve-tailwind-cli.ts +0 -45
- package/src/run-prod-app-boot-context.ts +0 -241
- package/src/run-prod-app-static-files.ts +0 -273
- package/src/run-prod-app.ts +0 -1158
- package/src/session-wiring.ts +0 -29
- package/src/try-hono-first.ts +0 -46
|
@@ -1,278 +0,0 @@
|
|
|
1
|
-
// Unit-Tests für die Pure-Logic-Helpers von build-prod-bundle.
|
|
2
|
-
//
|
|
3
|
-
// Bun.build und Tailwind-CLI brauchen einen Bun-Runtime, deshalb
|
|
4
|
-
// werden die hier nicht aufgerufen — nur Discovery + HTML-Injection
|
|
5
|
-
// die unter Node funktionieren. End-to-End-Tests (mit echtem Bun.build)
|
|
6
|
-
// laufen im CI als `bun run build` auf der Showcase-App; das ist der
|
|
7
|
-
// ehrlichere Smoke-Test.
|
|
8
|
-
|
|
9
|
-
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
10
|
-
import { existsSync } from "node:fs";
|
|
11
|
-
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
12
|
-
import { tmpdir } from "node:os";
|
|
13
|
-
import { join } from "node:path";
|
|
14
|
-
import {
|
|
15
|
-
type ClientEntry,
|
|
16
|
-
computeBuildId,
|
|
17
|
-
discoverClientEntries,
|
|
18
|
-
discoverHtmlTemplate,
|
|
19
|
-
injectAssetTags,
|
|
20
|
-
} from "../build-prod-bundle";
|
|
21
|
-
|
|
22
|
-
// Synthetic single-entry helper. Realer Build erzeugt das via
|
|
23
|
-
// discoverClientEntries; hier reicht die Form für injectAssetTags-Tests.
|
|
24
|
-
function clientEntry(): ClientEntry {
|
|
25
|
-
return {
|
|
26
|
-
name: "client",
|
|
27
|
-
sourceFile: "src/client.tsx",
|
|
28
|
-
manifestKey: "client.js",
|
|
29
|
-
htmlPath: "index.html",
|
|
30
|
-
};
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
function namedEntry(name: string): ClientEntry {
|
|
34
|
-
return {
|
|
35
|
-
name,
|
|
36
|
-
sourceFile: `src/client-${name}.tsx`,
|
|
37
|
-
manifestKey: `client-${name}.js`,
|
|
38
|
-
htmlPath: name === "public" ? "index.html" : `${name}.html`,
|
|
39
|
-
};
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
describe("build-prod-bundle/discovery", () => {
|
|
43
|
-
let workDir = "";
|
|
44
|
-
|
|
45
|
-
beforeEach(async () => {
|
|
46
|
-
workDir = await mkdtemp(join(tmpdir(), "kumiko-build-test-"));
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
afterEach(async () => {
|
|
50
|
-
await rm(workDir, { recursive: true, force: true });
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
test("discoverClientEntries findet single-mode src/client.tsx", async () => {
|
|
54
|
-
await mkdir(join(workDir, "src"), { recursive: true });
|
|
55
|
-
await writeFile(join(workDir, "src/client.tsx"), "// single");
|
|
56
|
-
|
|
57
|
-
const entries = discoverClientEntries(workDir);
|
|
58
|
-
|
|
59
|
-
expect(entries).toHaveLength(1);
|
|
60
|
-
expect(entries[0]?.name).toBe("client");
|
|
61
|
-
expect(entries[0]?.manifestKey).toBe("client.js");
|
|
62
|
-
expect(entries[0]?.htmlPath).toBe("index.html");
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
test("discoverClientEntries findet multi-mode client-public + client-admin", async () => {
|
|
66
|
-
await mkdir(join(workDir, "src"), { recursive: true });
|
|
67
|
-
await writeFile(join(workDir, "src/client-public.tsx"), "// public");
|
|
68
|
-
await writeFile(join(workDir, "src/client-admin.tsx"), "// admin");
|
|
69
|
-
|
|
70
|
-
const entries = discoverClientEntries(workDir);
|
|
71
|
-
|
|
72
|
-
// Sortiert nach name.
|
|
73
|
-
expect(entries.map((e) => e.name)).toEqual(["admin", "public"]);
|
|
74
|
-
|
|
75
|
-
const admin = entries.find((e) => e.name === "admin");
|
|
76
|
-
expect(admin?.manifestKey).toBe("client-admin.js");
|
|
77
|
-
expect(admin?.htmlPath).toBe("admin.html");
|
|
78
|
-
|
|
79
|
-
const pub = entries.find((e) => e.name === "public");
|
|
80
|
-
expect(pub?.manifestKey).toBe("client-public.js");
|
|
81
|
-
// "public" mappt auf das Default-Template (index.html), nicht
|
|
82
|
-
// public.html — das ist Convention damit das default-served-Template
|
|
83
|
-
// den vom-User-erwarteten Namen behält.
|
|
84
|
-
expect(pub?.htmlPath).toBe("index.html");
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
test("discoverClientEntries multi-mode dominiert über single-mode wenn beide da", async () => {
|
|
88
|
-
await mkdir(join(workDir, "src"), { recursive: true });
|
|
89
|
-
await writeFile(join(workDir, "src/client.tsx"), "// single");
|
|
90
|
-
await writeFile(join(workDir, "src/client-admin.tsx"), "// admin");
|
|
91
|
-
|
|
92
|
-
const entries = discoverClientEntries(workDir);
|
|
93
|
-
|
|
94
|
-
// Multi-mode aktiv → "client" wird ignoriert.
|
|
95
|
-
expect(entries.map((e) => e.name)).toEqual(["admin"]);
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
test("discoverClientEntries gibt leeres Array zurück wenn nichts da", () => {
|
|
99
|
-
expect(discoverClientEntries(workDir)).toEqual([]);
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
test("discoverHtmlTemplate findet index.html im cwd", async () => {
|
|
103
|
-
await writeFile(join(workDir, "index.html"), "<html></html>");
|
|
104
|
-
|
|
105
|
-
expect(discoverHtmlTemplate(workDir)).toBe(join(workDir, "index.html"));
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
test("discoverHtmlTemplate findet public/index.html als Fallback", async () => {
|
|
109
|
-
await mkdir(join(workDir, "public"), { recursive: true });
|
|
110
|
-
await writeFile(join(workDir, "public/index.html"), "<html></html>");
|
|
111
|
-
|
|
112
|
-
expect(discoverHtmlTemplate(workDir)).toBe(join(workDir, "public/index.html"));
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
test("discoverHtmlTemplate bevorzugt cwd-index.html über public/index.html", async () => {
|
|
116
|
-
await mkdir(join(workDir, "public"), { recursive: true });
|
|
117
|
-
await writeFile(join(workDir, "index.html"), "<!-- root -->");
|
|
118
|
-
await writeFile(join(workDir, "public/index.html"), "<!-- public -->");
|
|
119
|
-
|
|
120
|
-
expect(discoverHtmlTemplate(workDir)).toBe(join(workDir, "index.html"));
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
test("discoverHtmlTemplate gibt undefined zurück wenn nichts da ist", () => {
|
|
124
|
-
expect(existsSync(workDir)).toBe(true);
|
|
125
|
-
expect(discoverHtmlTemplate(workDir)).toBeUndefined();
|
|
126
|
-
});
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
describe("build-prod-bundle/injectAssetTags", () => {
|
|
130
|
-
test("ersetzt /client.js durch hashed URL im script-tag", () => {
|
|
131
|
-
const html = `<html><body><script type="module" src="/client.js"></script></body></html>`;
|
|
132
|
-
const result = injectAssetTags(
|
|
133
|
-
html,
|
|
134
|
-
{ "client.js": "/assets/client-abc123.js" },
|
|
135
|
-
clientEntry(),
|
|
136
|
-
);
|
|
137
|
-
|
|
138
|
-
expect(result).toContain('src="/assets/client-abc123.js"');
|
|
139
|
-
expect(result).not.toContain('src="/client.js"');
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
test("ersetzt /styles.css durch hashed URL im link-tag", () => {
|
|
143
|
-
const html = `<html><head><link rel="stylesheet" href="/styles.css" /></head><body></body></html>`;
|
|
144
|
-
const result = injectAssetTags(
|
|
145
|
-
html,
|
|
146
|
-
{ "styles.css": "/assets/styles-def456.css" },
|
|
147
|
-
clientEntry(),
|
|
148
|
-
);
|
|
149
|
-
|
|
150
|
-
expect(result).toContain('href="/assets/styles-def456.css"');
|
|
151
|
-
expect(result).not.toContain('href="/styles.css"');
|
|
152
|
-
});
|
|
153
|
-
|
|
154
|
-
test("wirft mit Anweisung wenn /client.js Placeholder fehlt", () => {
|
|
155
|
-
const html = `<html><body><div id="root"></div></body></html>`;
|
|
156
|
-
|
|
157
|
-
expect(() =>
|
|
158
|
-
injectAssetTags(html, { "client.js": "/assets/client-abc.js" }, clientEntry()),
|
|
159
|
-
).toThrow(/keinen Entry-Tag für \/client\.js/);
|
|
160
|
-
expect(() =>
|
|
161
|
-
injectAssetTags(html, { "client.js": "/assets/client-abc.js" }, clientEntry()),
|
|
162
|
-
).toThrow(/<script type="module" src="\/client\.js"><\/script>/);
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
test("wirft mit Anweisung wenn /styles.css Placeholder fehlt", () => {
|
|
166
|
-
const html = `<html><head><title>App</title></head><body></body></html>`;
|
|
167
|
-
|
|
168
|
-
expect(() =>
|
|
169
|
-
injectAssetTags(html, { "styles.css": "/assets/styles-xyz.css" }, clientEntry()),
|
|
170
|
-
).toThrow(/keinen Entry-Tag für \/styles\.css/);
|
|
171
|
-
expect(() =>
|
|
172
|
-
injectAssetTags(html, { "styles.css": "/assets/styles-xyz.css" }, clientEntry()),
|
|
173
|
-
).toThrow(/<link rel="stylesheet" href="\/styles\.css" \/>/);
|
|
174
|
-
});
|
|
175
|
-
|
|
176
|
-
test("ist idempotent — zweite Injection auf bereits ersetztem HTML ändert nichts", () => {
|
|
177
|
-
const html = `<html><body><script type="module" src="/client.js"></script></body></html>`;
|
|
178
|
-
const manifest = { "client.js": "/assets/client-abc.js" };
|
|
179
|
-
const first = injectAssetTags(html, manifest, clientEntry());
|
|
180
|
-
const second = injectAssetTags(first, manifest, clientEntry());
|
|
181
|
-
|
|
182
|
-
expect(first).toBe(second);
|
|
183
|
-
expect(first).toContain('src="/assets/client-abc.js"');
|
|
184
|
-
});
|
|
185
|
-
|
|
186
|
-
test("ändert template ohne client/styles im manifest nicht", () => {
|
|
187
|
-
const html = `<html><body>Hello</body></html>`;
|
|
188
|
-
const result = injectAssetTags(html, {}, clientEntry());
|
|
189
|
-
|
|
190
|
-
expect(result).toBe(html);
|
|
191
|
-
});
|
|
192
|
-
|
|
193
|
-
test("verträgt mehrere script-tags und ersetzt nur den /client.js", () => {
|
|
194
|
-
const html = `<html><body>
|
|
195
|
-
<script>console.log("inline");</script>
|
|
196
|
-
<script type="module" src="/client.js"></script>
|
|
197
|
-
</body></html>`;
|
|
198
|
-
const result = injectAssetTags(html, { "client.js": "/assets/client-x.js" }, clientEntry());
|
|
199
|
-
|
|
200
|
-
expect(result).toContain('console.log("inline")');
|
|
201
|
-
expect(result).toContain('src="/assets/client-x.js"');
|
|
202
|
-
expect(result).not.toContain('src="/client.js"');
|
|
203
|
-
});
|
|
204
|
-
|
|
205
|
-
test("multi-mode: admin-entry ersetzt nur /client-admin.js, lässt /client-public.js liegen", () => {
|
|
206
|
-
const html = `<html><body>
|
|
207
|
-
<script type="module" src="/client-admin.js"></script>
|
|
208
|
-
<script type="module" src="/client-public.js"></script>
|
|
209
|
-
</body></html>`;
|
|
210
|
-
const manifest = {
|
|
211
|
-
"client-admin.js": "/assets/client-admin-aaa.js",
|
|
212
|
-
"client-public.js": "/assets/client-public-bbb.js",
|
|
213
|
-
};
|
|
214
|
-
const result = injectAssetTags(html, manifest, namedEntry("admin"));
|
|
215
|
-
|
|
216
|
-
expect(result).toContain('src="/assets/client-admin-aaa.js"');
|
|
217
|
-
// public-bundle bleibt unangetastet — admin.html lädt nur sein eigenes Bundle.
|
|
218
|
-
expect(result).toContain('src="/client-public.js"');
|
|
219
|
-
expect(result).not.toContain('src="/client-admin.js"');
|
|
220
|
-
});
|
|
221
|
-
|
|
222
|
-
test("multi-mode: error-message nennt den richtigen Template-Namen", () => {
|
|
223
|
-
const html = `<html><body>no admin script</body></html>`;
|
|
224
|
-
const manifest = { "client-admin.js": "/assets/client-admin-x.js" };
|
|
225
|
-
|
|
226
|
-
expect(() => injectAssetTags(html, manifest, namedEntry("admin"))).toThrow(
|
|
227
|
-
/admin\.html hat keinen Entry-Tag für \/client-admin\.js/,
|
|
228
|
-
);
|
|
229
|
-
});
|
|
230
|
-
});
|
|
231
|
-
|
|
232
|
-
describe("build-prod-bundle/computeBuildId", () => {
|
|
233
|
-
test("identisches Manifest → gleiche id", () => {
|
|
234
|
-
const manifest = {
|
|
235
|
-
"client.js": "/assets/client-abc.js",
|
|
236
|
-
"styles.css": "/assets/styles-xyz.css",
|
|
237
|
-
};
|
|
238
|
-
expect(computeBuildId(manifest)).toBe(computeBuildId({ ...manifest }));
|
|
239
|
-
});
|
|
240
|
-
|
|
241
|
-
test("Key-Reihenfolge egal — nur die URL-Werte zählen", () => {
|
|
242
|
-
const a = { "client.js": "/assets/client-abc.js", "styles.css": "/assets/styles-xyz.css" };
|
|
243
|
-
const b = { "styles.css": "/assets/styles-xyz.css", "client.js": "/assets/client-abc.js" };
|
|
244
|
-
expect(computeBuildId(a)).toBe(computeBuildId(b));
|
|
245
|
-
});
|
|
246
|
-
|
|
247
|
-
test("geändertes Asset (neuer Hash) → andere id", () => {
|
|
248
|
-
const before = { "client.js": "/assets/client-abc.js" };
|
|
249
|
-
const after = { "client.js": "/assets/client-def.js" };
|
|
250
|
-
expect(computeBuildId(before)).not.toBe(computeBuildId(after));
|
|
251
|
-
});
|
|
252
|
-
|
|
253
|
-
test("12 Hex-Zeichen", () => {
|
|
254
|
-
expect(computeBuildId({ "client.js": "/assets/client-abc.js" })).toMatch(/^[0-9a-f]{12}$/);
|
|
255
|
-
});
|
|
256
|
-
});
|
|
257
|
-
|
|
258
|
-
describe("build-prod-bundle/injectAssetTags build-info", () => {
|
|
259
|
-
test("bäckt window.__KUMIKO_BUILD__ vor </head> wenn buildInfo gesetzt", () => {
|
|
260
|
-
const html = `<html><head><title>x</title></head><body><script type="module" src="/client.js"></script></body></html>`;
|
|
261
|
-
const result = injectAssetTags(html, { "client.js": "/assets/client-abc.js" }, clientEntry(), {
|
|
262
|
-
id: "deadbeef0000",
|
|
263
|
-
builtAt: "2026-06-18T12:00:00.000Z",
|
|
264
|
-
});
|
|
265
|
-
|
|
266
|
-
expect(result).toContain(
|
|
267
|
-
'window.__KUMIKO_BUILD__={"id":"deadbeef0000","builtAt":"2026-06-18T12:00:00.000Z"}',
|
|
268
|
-
);
|
|
269
|
-
expect(result.indexOf("__KUMIKO_BUILD__")).toBeLessThan(result.indexOf("</head>"));
|
|
270
|
-
});
|
|
271
|
-
|
|
272
|
-
test("ohne buildInfo kein __KUMIKO_BUILD__-Script", () => {
|
|
273
|
-
const html = `<html><head></head><body><script type="module" src="/client.js"></script></body></html>`;
|
|
274
|
-
const result = injectAssetTags(html, { "client.js": "/assets/client-abc.js" }, clientEntry());
|
|
275
|
-
|
|
276
|
-
expect(result).not.toContain("__KUMIKO_BUILD__");
|
|
277
|
-
});
|
|
278
|
-
});
|
|
@@ -1,83 +0,0 @@
|
|
|
1
|
-
// Unit-Tests für staticCachePolicy — die Cache-Strategie hinter
|
|
2
|
-
// runProdApp's static-fallback. Pure function, leicht zu testen.
|
|
3
|
-
//
|
|
4
|
-
// Strategie (siehe run-prod-app.ts):
|
|
5
|
-
// /assets/* → immutable
|
|
6
|
-
// /, /index.html → revalidate (max-age=0, must-revalidate)
|
|
7
|
-
// /manifest.json, /sw.js,
|
|
8
|
-
// /build-info.json → no-cache
|
|
9
|
-
// alles andere → none
|
|
10
|
-
|
|
11
|
-
import { describe, expect, test } from "bun:test";
|
|
12
|
-
import { cacheControlHeader } from "@cosmicdrift/kumiko-framework/api";
|
|
13
|
-
import { staticCachePolicy } from "../run-prod-app";
|
|
14
|
-
|
|
15
|
-
function cacheControlFor(pathname: string): Record<string, string> {
|
|
16
|
-
const header = cacheControlHeader(staticCachePolicy(pathname));
|
|
17
|
-
return header === undefined ? {} : { "cache-control": header };
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
describe("staticCachePolicy", () => {
|
|
21
|
-
test("hashed asset → immutable + 1 Jahr", () => {
|
|
22
|
-
expect(cacheControlFor("/assets/client-abc123.js")).toEqual({
|
|
23
|
-
"cache-control": "public, max-age=31536000, immutable",
|
|
24
|
-
});
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
test("hashed CSS asset → immutable + 1 Jahr", () => {
|
|
28
|
-
expect(cacheControlFor("/assets/styles-def456.css")).toEqual({
|
|
29
|
-
"cache-control": "public, max-age=31536000, immutable",
|
|
30
|
-
});
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
test("nested asset path → immutable", () => {
|
|
34
|
-
expect(cacheControlFor("/assets/chunks/foo-789.js")).toEqual({
|
|
35
|
-
"cache-control": "public, max-age=31536000, immutable",
|
|
36
|
-
});
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
test("/ → revalidate", () => {
|
|
40
|
-
expect(cacheControlFor("/")).toEqual({
|
|
41
|
-
"cache-control": "public, max-age=0, must-revalidate",
|
|
42
|
-
});
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
test("/index.html → revalidate", () => {
|
|
46
|
-
expect(cacheControlFor("/index.html")).toEqual({
|
|
47
|
-
"cache-control": "public, max-age=0, must-revalidate",
|
|
48
|
-
});
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
test("/manifest.json → no-cache", () => {
|
|
52
|
-
expect(cacheControlFor("/manifest.json")).toEqual({
|
|
53
|
-
"cache-control": "no-cache",
|
|
54
|
-
});
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
test("/sw.js → no-cache", () => {
|
|
58
|
-
expect(cacheControlFor("/sw.js")).toEqual({
|
|
59
|
-
"cache-control": "no-cache",
|
|
60
|
-
});
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
test("/build-info.json → no-cache (sonst pollt UpdateChecker eine veraltete id)", () => {
|
|
64
|
-
expect(cacheControlFor("/build-info.json")).toEqual({
|
|
65
|
-
"cache-control": "no-cache",
|
|
66
|
-
});
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
test("public-folder file (favicon) → kein expliziter Header", () => {
|
|
70
|
-
expect(cacheControlFor("/favicon.ico")).toEqual({});
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
test("public-folder file (og-image) → kein expliziter Header", () => {
|
|
74
|
-
expect(cacheControlFor("/og-image.png")).toEqual({});
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
test("path mit assets als Substring (kein /assets/ prefix) → kein immutable", () => {
|
|
78
|
-
// Schutz: /myassets/foo.js soll NICHT immutable kriegen — wäre ein Bug
|
|
79
|
-
// weil die nicht gehashed sind.
|
|
80
|
-
expect(cacheControlFor("/myassets/foo.js")).toEqual({});
|
|
81
|
-
expect(cacheControlFor("/foo/assets/bar.js")).toEqual({});
|
|
82
|
-
});
|
|
83
|
-
});
|
|
@@ -1,308 +0,0 @@
|
|
|
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
|
-
});
|