@cosmicdrift/kumiko-server-runtime 1.0.0 → 2.0.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 +3 -3
- package/src/__tests__/boot-probe-fixture.ts +63 -0
- package/src/__tests__/build-prod-bundle.inprocess.test.ts +164 -0
- package/src/__tests__/compose-features.test.ts +33 -0
- package/src/__tests__/run-prod-app-dry-run.test.ts +135 -0
- package/src/__tests__/run-prod-app-env-source.test.ts +5 -44
- package/src/__tests__/run-prod-app-static-files.test.ts +171 -0
- package/src/__tests__/run-prod-app.integration.test.ts +40 -6
- package/src/__tests__/session-boot-gate.test.ts +8 -40
- package/src/__tests__/session-wiring.test.ts +8 -43
- package/src/compose-features.ts +23 -11
- package/src/run-prod-app-boot-context.ts +16 -25
- package/src/run-prod-app-static-files.ts +8 -1
- package/src/run-prod-app.ts +91 -33
- package/src/session-boot-gate.ts +9 -18
- package/src/session-wiring.ts +7 -19
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-server-runtime",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "Production server-boot runtime for Kumiko apps: connections, schema-drift-gate, seeds, lifecycle, graceful shutdown. Symmetric to kumiko-dev-server's runDevApp, without dev/scaffold/codegen tooling.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -72,8 +72,8 @@
|
|
|
72
72
|
}
|
|
73
73
|
},
|
|
74
74
|
"dependencies": {
|
|
75
|
-
"@cosmicdrift/kumiko-bundled-features": "
|
|
76
|
-
"@cosmicdrift/kumiko-framework": "
|
|
75
|
+
"@cosmicdrift/kumiko-bundled-features": "2.0.0",
|
|
76
|
+
"@cosmicdrift/kumiko-framework": "2.0.0",
|
|
77
77
|
"temporal-polyfill": "^0.3.2"
|
|
78
78
|
},
|
|
79
79
|
"publishConfig": {
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Shared fixture for run-prod-app-{dry-run,env-source}.test.ts — both boot
|
|
2
|
+
// runProdApp against a minimal probe feature with process.env cleared so the
|
|
3
|
+
// test fully controls config via envSource. Table/feature name stay
|
|
4
|
+
// parametrized (never collapsed to one shared fixture): a table clash
|
|
5
|
+
// between the two test files' entities would surface as flaky cross-file
|
|
6
|
+
// DB state, not a compile error.
|
|
7
|
+
import { afterEach, beforeEach } from "bun:test";
|
|
8
|
+
import {
|
|
9
|
+
createBooleanField,
|
|
10
|
+
createEntity,
|
|
11
|
+
createTextField,
|
|
12
|
+
defineFeature,
|
|
13
|
+
type FeatureDefinition,
|
|
14
|
+
type FeatureRegistrar,
|
|
15
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
16
|
+
import { z } from "zod";
|
|
17
|
+
|
|
18
|
+
export function makeProbeFeature(opts: {
|
|
19
|
+
readonly name: string;
|
|
20
|
+
readonly table: string;
|
|
21
|
+
readonly extraSetup?: (r: FeatureRegistrar<string>) => void;
|
|
22
|
+
}): FeatureDefinition {
|
|
23
|
+
const probeEntity = createEntity({
|
|
24
|
+
fields: {
|
|
25
|
+
name: createTextField({ required: true }),
|
|
26
|
+
active: createBooleanField({ default: true }),
|
|
27
|
+
},
|
|
28
|
+
table: opts.table,
|
|
29
|
+
});
|
|
30
|
+
return defineFeature(opts.name, (r) => {
|
|
31
|
+
r.entity("widget", probeEntity);
|
|
32
|
+
opts.extraSetup?.(r);
|
|
33
|
+
r.queryHandler({
|
|
34
|
+
name: "ping",
|
|
35
|
+
schema: z.object({}),
|
|
36
|
+
access: { roles: ["anonymous"] },
|
|
37
|
+
handler: async () => ({ pong: true }),
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// DATABASE_URL/REDIS_URL/JWT_SECRET are required (their read throws
|
|
43
|
+
// pre-#1441-fix boot bugs); PORT is non-throwing, cleared only so ambient
|
|
44
|
+
// PORT can't mask an "envSource wins" assertion.
|
|
45
|
+
export const CLEARED_BOOT_VARS = ["DATABASE_URL", "REDIS_URL", "JWT_SECRET", "PORT"] as const;
|
|
46
|
+
|
|
47
|
+
// Registers beforeEach/afterEach for the current describe block — call this
|
|
48
|
+
// at the top of a `describe(...)` body, same as calling beforeEach directly.
|
|
49
|
+
export function withClearedBootEnv(): void {
|
|
50
|
+
const saved: Record<string, string | undefined> = {};
|
|
51
|
+
beforeEach(() => {
|
|
52
|
+
for (const k of CLEARED_BOOT_VARS) {
|
|
53
|
+
saved[k] = process.env[k];
|
|
54
|
+
delete process.env[k];
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
afterEach(() => {
|
|
58
|
+
for (const k of CLEARED_BOOT_VARS) {
|
|
59
|
+
if (saved[k] === undefined) delete process.env[k];
|
|
60
|
+
else process.env[k] = saved[k];
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// In-process buildProdBundle — covers Bun.build + HTML render + build-info
|
|
2
|
+
// in the same process (CLI subprocess tests do not contribute to lcov).
|
|
3
|
+
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
5
|
+
import { existsSync } from "node:fs";
|
|
6
|
+
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import { join, resolve } from "node:path";
|
|
9
|
+
import { buildProdBundle } from "../build-prod-bundle";
|
|
10
|
+
|
|
11
|
+
const REPO_ROOT = resolve(import.meta.dir, "../../../..");
|
|
12
|
+
|
|
13
|
+
describe("buildProdBundle in-process (Bun.build)", () => {
|
|
14
|
+
let tmp = "";
|
|
15
|
+
|
|
16
|
+
beforeEach(async () => {
|
|
17
|
+
tmp = await mkdtemp(join(tmpdir(), "kumiko-build-inproc-"));
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
afterEach(async () => {
|
|
21
|
+
await rm(tmp, { recursive: true, force: true });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
async function writeMinimalClientApp(opts?: { html?: string; client?: string }) {
|
|
25
|
+
await mkdir(join(tmp, "src"), { recursive: true });
|
|
26
|
+
await writeFile(
|
|
27
|
+
join(tmp, "src/client.ts"),
|
|
28
|
+
opts?.client ??
|
|
29
|
+
`const root = document.getElementById("root"); if (root) root.textContent = "hi";`,
|
|
30
|
+
);
|
|
31
|
+
await mkdir(join(tmp, "public"), { recursive: true });
|
|
32
|
+
await writeFile(
|
|
33
|
+
join(tmp, "public/index.html"),
|
|
34
|
+
opts?.html ??
|
|
35
|
+
`<!doctype html><html><head></head><body><div id="root"></div><script type="module" src="/client.js"></script></body></html>`,
|
|
36
|
+
);
|
|
37
|
+
await writeFile(join(tmp, "package.json"), `{"name":"inproc-fixture","private":true}`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
test("client.ts → hashed bundle, manifest, build-info, injected script", async () => {
|
|
41
|
+
await writeMinimalClientApp();
|
|
42
|
+
|
|
43
|
+
const result = await buildProdBundle({ cwd: tmp, stylesheet: false });
|
|
44
|
+
|
|
45
|
+
expect(result.manifest["client.js"]).toMatch(/^\/assets\/client-[a-z0-9]+\.js$/);
|
|
46
|
+
expect(result.buildInfo?.id).toMatch(/^[0-9a-f]{12}$/);
|
|
47
|
+
expect(existsSync(join(tmp, "dist/build-info.json"))).toBe(true);
|
|
48
|
+
|
|
49
|
+
const html = await readFile(join(tmp, "dist/index.html"), "utf8");
|
|
50
|
+
expect(html).toContain(`src="${result.manifest["client.js"]}"`);
|
|
51
|
+
expect(html).toContain("__KUMIKO_BUILD__");
|
|
52
|
+
|
|
53
|
+
const assetPath = join(tmp, "dist", result.manifest["client.js"] ?? "");
|
|
54
|
+
expect(existsSync(assetPath)).toBe(true);
|
|
55
|
+
expect(await readFile(assetPath, "utf8")).toContain('"hi"');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("missing index.html → error with template snippet", async () => {
|
|
59
|
+
await mkdir(join(tmp, "src"), { recursive: true });
|
|
60
|
+
await writeFile(join(tmp, "src/client.ts"), `console.log("hi");`);
|
|
61
|
+
await writeFile(join(tmp, "package.json"), `{"name":"no-html","private":true}`);
|
|
62
|
+
|
|
63
|
+
await expect(buildProdBundle({ cwd: tmp, stylesheet: false })).rejects.toThrow(
|
|
64
|
+
/kein index\.html gefunden/,
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("index.html without /client.js placeholder → error with snippet", async () => {
|
|
69
|
+
await writeMinimalClientApp({
|
|
70
|
+
html: `<!doctype html><html><body>no script</body></html>`,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
await expect(buildProdBundle({ cwd: tmp, stylesheet: false })).rejects.toThrow(
|
|
74
|
+
/keinen Entry-Tag für \/client\.js/,
|
|
75
|
+
);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("syntax-broken client → Bun.build rejects", async () => {
|
|
79
|
+
await writeMinimalClientApp({ client: `const x = {{{` });
|
|
80
|
+
|
|
81
|
+
// Bun may throw "Bundle failed" before returning `{ success: false }`.
|
|
82
|
+
await expect(buildProdBundle({ cwd: tmp, stylesheet: false })).rejects.toThrow(
|
|
83
|
+
/Bundle failed|Bun\.build failed/,
|
|
84
|
+
);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("stylesheet override src/styles.css → hashed styles.css in manifest", async () => {
|
|
88
|
+
// Temp under REPO_ROOT so @tailwindcss/cli + tailwindcss peer resolve
|
|
89
|
+
// (same constraint as renderer-web-css-relocation.integration.test.ts).
|
|
90
|
+
const dir = await mkdtemp(join(REPO_ROOT, ".inproc-styles-"));
|
|
91
|
+
const cwd = join(dir, "app");
|
|
92
|
+
try {
|
|
93
|
+
await mkdir(join(cwd, "src"), { recursive: true });
|
|
94
|
+
await writeFile(
|
|
95
|
+
join(cwd, "src/client.ts"),
|
|
96
|
+
`const root = document.getElementById("root"); if (root) root.textContent = "hi";`,
|
|
97
|
+
);
|
|
98
|
+
await writeFile(join(cwd, "src/styles.css"), "body { margin: 0; }\n");
|
|
99
|
+
await mkdir(join(cwd, "public"), { recursive: true });
|
|
100
|
+
await writeFile(
|
|
101
|
+
join(cwd, "public/index.html"),
|
|
102
|
+
`<!doctype html><html><head><link rel="stylesheet" href="/styles.css" /></head><body><div id="root"></div><script type="module" src="/client.js"></script></body></html>`,
|
|
103
|
+
);
|
|
104
|
+
await writeFile(join(cwd, "package.json"), `{"name":"inproc-styles","private":true}`);
|
|
105
|
+
|
|
106
|
+
const result = await buildProdBundle({ cwd, stylesheet: "src/styles.css" });
|
|
107
|
+
|
|
108
|
+
expect(result.manifest["styles.css"]).toMatch(/^\/assets\/styles-[a-z0-9]+\.css$/);
|
|
109
|
+
const cssPath = join(cwd, "dist", result.manifest["styles.css"] ?? "");
|
|
110
|
+
expect(existsSync(cssPath)).toBe(true);
|
|
111
|
+
expect(await readFile(cssPath, "utf8")).toMatch(/margin:\s*0/);
|
|
112
|
+
|
|
113
|
+
const html = await readFile(join(cwd, "dist/index.html"), "utf8");
|
|
114
|
+
expect(html).toContain(`href="${result.manifest["styles.css"]}"`);
|
|
115
|
+
expect(html).not.toContain('href="/styles.css"');
|
|
116
|
+
} finally {
|
|
117
|
+
await rm(dir, { recursive: true, force: true });
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("missing stylesheet override → tailwind rejects", async () => {
|
|
122
|
+
const dir = await mkdtemp(join(REPO_ROOT, ".inproc-styles-miss-"));
|
|
123
|
+
const cwd = join(dir, "app");
|
|
124
|
+
try {
|
|
125
|
+
await mkdir(join(cwd, "src"), { recursive: true });
|
|
126
|
+
await writeFile(join(cwd, "src/client.ts"), `console.log("hi");`);
|
|
127
|
+
await mkdir(join(cwd, "public"), { recursive: true });
|
|
128
|
+
await writeFile(
|
|
129
|
+
join(cwd, "public/index.html"),
|
|
130
|
+
`<!doctype html><html><body><div id="root"></div><script type="module" src="/client.js"></script></body></html>`,
|
|
131
|
+
);
|
|
132
|
+
await writeFile(join(cwd, "package.json"), `{"name":"inproc-styles-miss","private":true}`);
|
|
133
|
+
|
|
134
|
+
await expect(buildProdBundle({ cwd, stylesheet: "src/missing-theme.css" })).rejects.toThrow(
|
|
135
|
+
/tailwind|Bundle failed|tailwindcss/i,
|
|
136
|
+
);
|
|
137
|
+
} finally {
|
|
138
|
+
await rm(dir, { recursive: true, force: true });
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("multi-entry client-admin + client-public → two hashed bundles", async () => {
|
|
143
|
+
await mkdir(join(tmp, "src"), { recursive: true });
|
|
144
|
+
await writeFile(join(tmp, "src/client-admin.ts"), `console.log("admin");`);
|
|
145
|
+
await writeFile(join(tmp, "src/client-public.ts"), `console.log("public");`);
|
|
146
|
+
await mkdir(join(tmp, "public"), { recursive: true });
|
|
147
|
+
await writeFile(
|
|
148
|
+
join(tmp, "public/index.html"),
|
|
149
|
+
`<!doctype html><html><body><script type="module" src="/client-public.js"></script></body></html>`,
|
|
150
|
+
);
|
|
151
|
+
await writeFile(
|
|
152
|
+
join(tmp, "admin.html"),
|
|
153
|
+
`<!doctype html><html><body><script type="module" src="/client-admin.js"></script></body></html>`,
|
|
154
|
+
);
|
|
155
|
+
await writeFile(join(tmp, "package.json"), `{"name":"multi-inproc","private":true}`);
|
|
156
|
+
|
|
157
|
+
const result = await buildProdBundle({ cwd: tmp, stylesheet: false });
|
|
158
|
+
|
|
159
|
+
expect(result.manifest["client-admin.js"]).toMatch(/^\/assets\/client-admin-/);
|
|
160
|
+
expect(result.manifest["client-public.js"]).toMatch(/^\/assets\/client-public-/);
|
|
161
|
+
expect(existsSync(join(tmp, "dist/admin.html"))).toBe(true);
|
|
162
|
+
expect(existsSync(join(tmp, "dist/index.html"))).toBe(true);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
@@ -76,6 +76,39 @@ describe("composeFeatures", () => {
|
|
|
76
76
|
expect(handlerNames).toContain("signup-confirm");
|
|
77
77
|
});
|
|
78
78
|
|
|
79
|
+
// Regression: signup-request no-ops silently (always-200 anti-enumeration
|
|
80
|
+
// contract) unless "auth-self-registration" is mounted. composeFeatures
|
|
81
|
+
// must bundle it alongside authOptions.signup, not leave apps using the
|
|
82
|
+
// includeBundled convenience path to mount it by hand.
|
|
83
|
+
test("authOptions.signup → auth-self-registration mounted, default ON", () => {
|
|
84
|
+
const features = composeFeatures([noopFeature], {
|
|
85
|
+
includeBundled: true,
|
|
86
|
+
authOptions: { signup: { appUrl: "https://app/signup/complete" } },
|
|
87
|
+
});
|
|
88
|
+
const toggle = features.find((f) => f.name === "auth-self-registration");
|
|
89
|
+
expect(toggle).toBeDefined();
|
|
90
|
+
// The security-relevant part isn't that the feature is mounted, it's
|
|
91
|
+
// that self-signup defaults ON — a flip to `default: false` upstream
|
|
92
|
+
// would silently no-op signup (always-200 anti-enumeration masks it)
|
|
93
|
+
// while this assertion alone (toBeDefined) stayed green.
|
|
94
|
+
expect(toggle?.toggleableDefault).toBe(true);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("no authOptions.signup → auth-self-registration NOT mounted", () => {
|
|
98
|
+
const features = composeFeatures([noopFeature], { includeBundled: true });
|
|
99
|
+
expect(features.map((f) => f.name)).not.toContain("auth-self-registration");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("authOptions.signup + app also mounts its own auth-self-registration stub → deduped to exactly one", () => {
|
|
103
|
+
const pickerSelfRegDupe = defineFeature("auth-self-registration", () => {});
|
|
104
|
+
const features = composeFeatures([noopFeature, pickerSelfRegDupe], {
|
|
105
|
+
includeBundled: true,
|
|
106
|
+
authOptions: { signup: { appUrl: "https://app/signup/complete" } },
|
|
107
|
+
});
|
|
108
|
+
const names = features.map((f) => f.name).filter((n) => n === "auth-self-registration");
|
|
109
|
+
expect(names).toEqual(["auth-self-registration"]);
|
|
110
|
+
});
|
|
111
|
+
|
|
79
112
|
test("OHNE authOptions → KEINE reset/verify-handlers (anti-default-deploy-bug)", () => {
|
|
80
113
|
// Genau der Bug der vom Review-Agent gefangen wurde: composeFeatures
|
|
81
114
|
// ohne authOptions registriert die handler nicht. Wenn jemand das
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// Dry-run + bootErrorReporter paths — no DB/Redis. envSource avoids process.exit(0).
|
|
2
|
+
|
|
3
|
+
import { describe, expect, test } from "bun:test";
|
|
4
|
+
import { composeEnvSchema, KumikoBootError } from "@cosmicdrift/kumiko-framework/env";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { runProdApp } from "../run-prod-app";
|
|
7
|
+
import { makeProbeFeature, withClearedBootEnv } from "./boot-probe-fixture";
|
|
8
|
+
|
|
9
|
+
const probeFeature = makeProbeFeature({
|
|
10
|
+
name: "dry-run-probe",
|
|
11
|
+
table: "dry_run_probe",
|
|
12
|
+
extraSetup: (r) => {
|
|
13
|
+
r.envSchema(z.object({ DRY_RUN_PROBE: z.string().optional().describe("probe var") }));
|
|
14
|
+
},
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe("runProdApp dry-run / bootErrorReporter", () => {
|
|
18
|
+
withClearedBootEnv();
|
|
19
|
+
|
|
20
|
+
test("KUMIKO_DRY_RUN_ENV=human + envSource → render + dry-run handle (no exit)", async () => {
|
|
21
|
+
const logs: string[] = [];
|
|
22
|
+
const originalLog = console.log;
|
|
23
|
+
console.log = (...args: unknown[]) => {
|
|
24
|
+
logs.push(args.map(String).join(" "));
|
|
25
|
+
};
|
|
26
|
+
const envSchema = composeEnvSchema({ features: [probeFeature] });
|
|
27
|
+
let handle: Awaited<ReturnType<typeof runProdApp>>;
|
|
28
|
+
try {
|
|
29
|
+
handle = await runProdApp({
|
|
30
|
+
features: [probeFeature],
|
|
31
|
+
envSchema,
|
|
32
|
+
autoListen: false,
|
|
33
|
+
migrations: false,
|
|
34
|
+
envSource: { KUMIKO_DRY_RUN_ENV: "human" },
|
|
35
|
+
});
|
|
36
|
+
} finally {
|
|
37
|
+
console.log = originalLog;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
expect(logs.some((l) => l.includes("DRY_RUN_PROBE"))).toBe(true);
|
|
41
|
+
const res = await handle!.fetch(new Request("http://test/"));
|
|
42
|
+
expect(res.status).toBe(503);
|
|
43
|
+
expect(await res.text()).toBe("dry-run");
|
|
44
|
+
await handle!.stop();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("KUMIKO_DRY_RUN_ENV=json → structured dry-run output", async () => {
|
|
48
|
+
const logs: string[] = [];
|
|
49
|
+
const originalLog = console.log;
|
|
50
|
+
console.log = (...args: unknown[]) => {
|
|
51
|
+
logs.push(args.map(String).join(" "));
|
|
52
|
+
};
|
|
53
|
+
const envSchema = composeEnvSchema({ features: [probeFeature] });
|
|
54
|
+
try {
|
|
55
|
+
const handle = await runProdApp({
|
|
56
|
+
features: [probeFeature],
|
|
57
|
+
envSchema,
|
|
58
|
+
autoListen: false,
|
|
59
|
+
migrations: false,
|
|
60
|
+
envSource: { KUMIKO_DRY_RUN_ENV: "json" },
|
|
61
|
+
});
|
|
62
|
+
await handle.stop();
|
|
63
|
+
} finally {
|
|
64
|
+
console.log = originalLog;
|
|
65
|
+
}
|
|
66
|
+
const jsonLine = logs.find((l) => l.trimStart().startsWith("{"));
|
|
67
|
+
if (!jsonLine) throw new Error(`No JSON line in logs: ${JSON.stringify(logs)}`);
|
|
68
|
+
const parsed = JSON.parse(jsonLine) as {
|
|
69
|
+
optional: Array<{ name: string; feature: string }>;
|
|
70
|
+
};
|
|
71
|
+
expect(parsed.optional).toContainEqual(
|
|
72
|
+
expect.objectContaining({ name: "DRY_RUN_PROBE", feature: "dry-run-probe" }),
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("unrecognized KUMIKO_DRY_RUN_ENV warns then hits envSchema parse", async () => {
|
|
77
|
+
const warnings: string[] = [];
|
|
78
|
+
const originalWarn = console.warn;
|
|
79
|
+
console.warn = (...args: unknown[]) => {
|
|
80
|
+
warnings.push(args.map(String).join(" "));
|
|
81
|
+
};
|
|
82
|
+
const envSchema = composeEnvSchema({
|
|
83
|
+
features: [],
|
|
84
|
+
extend: z.object({ MUST_HAVE: z.string().describe("required for test") }),
|
|
85
|
+
});
|
|
86
|
+
try {
|
|
87
|
+
await expect(
|
|
88
|
+
runProdApp({
|
|
89
|
+
features: [probeFeature],
|
|
90
|
+
envSchema,
|
|
91
|
+
autoListen: false,
|
|
92
|
+
migrations: false,
|
|
93
|
+
envSource: { KUMIKO_DRY_RUN_ENV: "not-a-real-mode" },
|
|
94
|
+
bootErrorReporter: (err) => {
|
|
95
|
+
throw err;
|
|
96
|
+
},
|
|
97
|
+
}),
|
|
98
|
+
).rejects.toBeInstanceOf(KumikoBootError);
|
|
99
|
+
} finally {
|
|
100
|
+
console.warn = originalWarn;
|
|
101
|
+
}
|
|
102
|
+
expect(
|
|
103
|
+
warnings.some(
|
|
104
|
+
(w) => w.includes('KUMIKO_DRY_RUN_ENV="not-a-real-mode"') && w.includes("unrecognized"),
|
|
105
|
+
),
|
|
106
|
+
).toBe(true);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("bootErrorReporter receives KumikoBootError instead of process.exit", async () => {
|
|
110
|
+
const envSchema = composeEnvSchema({
|
|
111
|
+
features: [],
|
|
112
|
+
extend: z.object({ MUST_HAVE: z.string().describe("required for test") }),
|
|
113
|
+
});
|
|
114
|
+
let reported: KumikoBootError | undefined;
|
|
115
|
+
await expect(
|
|
116
|
+
runProdApp({
|
|
117
|
+
features: [probeFeature],
|
|
118
|
+
envSchema,
|
|
119
|
+
autoListen: false,
|
|
120
|
+
migrations: false,
|
|
121
|
+
envSource: {
|
|
122
|
+
// no MUST_HAVE → parseEnv throws KumikoBootError
|
|
123
|
+
DATABASE_URL: "postgres://x",
|
|
124
|
+
REDIS_URL: "redis://x",
|
|
125
|
+
},
|
|
126
|
+
bootErrorReporter: (err) => {
|
|
127
|
+
reported = err;
|
|
128
|
+
throw err;
|
|
129
|
+
},
|
|
130
|
+
}),
|
|
131
|
+
).rejects.toBeInstanceOf(KumikoBootError);
|
|
132
|
+
expect(reported).toBeInstanceOf(KumikoBootError);
|
|
133
|
+
expect(reported!.errors.some((e) => e.name === "MUST_HAVE")).toBe(true);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
@@ -6,40 +6,15 @@
|
|
|
6
6
|
// required-var test would throw "required env var DATABASE_URL is missing" and
|
|
7
7
|
// the PORT test would bind the default instead of the injected port.
|
|
8
8
|
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
createBooleanField,
|
|
12
|
-
createEntity,
|
|
13
|
-
createTextField,
|
|
14
|
-
defineFeature,
|
|
15
|
-
} from "@cosmicdrift/kumiko-framework/engine";
|
|
16
|
-
import { z } from "zod";
|
|
9
|
+
import { describe, expect, test } from "bun:test";
|
|
17
10
|
import { runProdApp } from "../run-prod-app";
|
|
11
|
+
import { makeProbeFeature, withClearedBootEnv } from "./boot-probe-fixture";
|
|
18
12
|
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
name: createTextField({ required: true }),
|
|
22
|
-
active: createBooleanField({ default: true }),
|
|
23
|
-
},
|
|
13
|
+
const probeFeature = makeProbeFeature({
|
|
14
|
+
name: "env-source-probe",
|
|
24
15
|
table: "env_source_probe",
|
|
25
16
|
});
|
|
26
17
|
|
|
27
|
-
const probeFeature = defineFeature("env-source-probe", (r) => {
|
|
28
|
-
r.entity("widget", probeEntity);
|
|
29
|
-
r.queryHandler({
|
|
30
|
-
name: "ping",
|
|
31
|
-
schema: z.object({}),
|
|
32
|
-
access: { roles: ["anonymous"] },
|
|
33
|
-
handler: async () => ({ pong: true }),
|
|
34
|
-
});
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
// Cleared from process.env so the test fully controls config via envSource.
|
|
38
|
-
// DATABASE_URL/REDIS_URL/JWT_SECRET are required (their read throws pre-fix);
|
|
39
|
-
// PORT is non-throwing, cleared only so ambient PORT can't mask the second
|
|
40
|
-
// test's "PORT comes from envSource" assertion.
|
|
41
|
-
const CLEARED_VARS = ["DATABASE_URL", "REDIS_URL", "JWT_SECRET", "PORT"] as const;
|
|
42
|
-
|
|
43
18
|
const DUMMY_ENV = {
|
|
44
19
|
KUMIKO_DRY_RUN_ENV: "boot",
|
|
45
20
|
DATABASE_URL: "postgres://smoke:smoke@127.0.0.1:1/smoke",
|
|
@@ -48,21 +23,7 @@ const DUMMY_ENV = {
|
|
|
48
23
|
} as const;
|
|
49
24
|
|
|
50
25
|
describe("runProdApp boot-mode env-source", () => {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
beforeEach(() => {
|
|
54
|
-
for (const k of CLEARED_VARS) {
|
|
55
|
-
saved[k] = process.env[k];
|
|
56
|
-
delete process.env[k];
|
|
57
|
-
}
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
afterEach(() => {
|
|
61
|
-
for (const k of CLEARED_VARS) {
|
|
62
|
-
if (saved[k] === undefined) delete process.env[k];
|
|
63
|
-
else process.env[k] = saved[k];
|
|
64
|
-
}
|
|
65
|
-
});
|
|
26
|
+
withClearedBootEnv();
|
|
66
27
|
|
|
67
28
|
test("boots from injected envSource even when process.env lacks the required vars", async () => {
|
|
68
29
|
const logs: string[] = [];
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// Unit coverage for mimeTypeFor + hostDispatch edge in buildStaticFallback.
|
|
2
|
+
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
4
|
+
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import {
|
|
8
|
+
buildStaticFallback,
|
|
9
|
+
mimeTypeFor,
|
|
10
|
+
readStaticFile,
|
|
11
|
+
serveDiskFile,
|
|
12
|
+
} from "../run-prod-app-static-files";
|
|
13
|
+
|
|
14
|
+
describe("mimeTypeFor", () => {
|
|
15
|
+
const cases: ReadonlyArray<readonly [string, string]> = [
|
|
16
|
+
["x.html", "text/html; charset=utf-8"],
|
|
17
|
+
["x.js", "text/javascript; charset=utf-8"],
|
|
18
|
+
["x.mjs", "text/javascript; charset=utf-8"],
|
|
19
|
+
["x.css", "text/css; charset=utf-8"],
|
|
20
|
+
["x.json", "application/json; charset=utf-8"],
|
|
21
|
+
["x.svg", "image/svg+xml"],
|
|
22
|
+
["x.png", "image/png"],
|
|
23
|
+
["x.jpg", "image/jpeg"],
|
|
24
|
+
["x.jpeg", "image/jpeg"],
|
|
25
|
+
["x.ico", "image/x-icon"],
|
|
26
|
+
["x.txt", "text/plain; charset=utf-8"],
|
|
27
|
+
["x.xml", "application/xml; charset=utf-8"],
|
|
28
|
+
["x.webmanifest", "application/manifest+json"],
|
|
29
|
+
["x.bin", "application/octet-stream"],
|
|
30
|
+
["noext", "application/octet-stream"],
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
for (const [path, mime] of cases) {
|
|
34
|
+
test(`${path} → ${mime}`, () => {
|
|
35
|
+
expect(mimeTypeFor(path)).toBe(mime);
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe("readStaticFile / serveDiskFile", () => {
|
|
41
|
+
let tmp = "";
|
|
42
|
+
|
|
43
|
+
beforeEach(async () => {
|
|
44
|
+
tmp = await mkdtemp(join(tmpdir(), "kumiko-static-"));
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
afterEach(async () => {
|
|
48
|
+
await rm(tmp, { recursive: true, force: true });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("readStaticFile returns bytes+mime; ENOENT → undefined", async () => {
|
|
52
|
+
const path = join(tmp, "a.css");
|
|
53
|
+
await writeFile(path, "body{}");
|
|
54
|
+
const file = await readStaticFile(path);
|
|
55
|
+
expect(file?.mime).toBe("text/css; charset=utf-8");
|
|
56
|
+
expect(new TextDecoder().decode(file!.bytes)).toBe("body{}");
|
|
57
|
+
expect(await readStaticFile(join(tmp, "missing.css"))).toBeUndefined();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("readStaticFile on a directory → undefined (EISDIR), not a throw", async () => {
|
|
61
|
+
const dirPath = join(tmp, "sub");
|
|
62
|
+
await mkdir(dirPath);
|
|
63
|
+
expect(await readStaticFile(dirPath)).toBeUndefined();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("readStaticFile through a file used as a directory segment → undefined (ENOTDIR), not a throw (#1504)", async () => {
|
|
67
|
+
const path = join(tmp, "index.html");
|
|
68
|
+
await writeFile(path, "<html/>");
|
|
69
|
+
// GET /index.html/x — "index.html" is a file, so treating it as a
|
|
70
|
+
// directory segment must fall through to the SPA fallback, not 500.
|
|
71
|
+
expect(await readStaticFile(join(path, "x"))).toBeUndefined();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("serveDiskFile sets content-type from mime", async () => {
|
|
75
|
+
const path = join(tmp, "a.svg");
|
|
76
|
+
await writeFile(path, "<svg/>");
|
|
77
|
+
const file = await readStaticFile(path);
|
|
78
|
+
const res = serveDiskFile(new Request("http://t/a.svg"), "/a.svg", file!);
|
|
79
|
+
expect(res.headers.get("content-type")).toBe("image/svg+xml");
|
|
80
|
+
expect(await res.text()).toBe("<svg/>");
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe("buildStaticFallback hostDispatch", () => {
|
|
85
|
+
let tmp = "";
|
|
86
|
+
|
|
87
|
+
beforeEach(async () => {
|
|
88
|
+
tmp = await mkdtemp(join(tmpdir(), "kumiko-fallback-"));
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
afterEach(async () => {
|
|
92
|
+
await rm(tmp, { recursive: true, force: true });
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("hostDispatch html pointing at missing file → 500", async () => {
|
|
96
|
+
const handler = buildStaticFallback(
|
|
97
|
+
() => new Response("api-404", { status: 404 }),
|
|
98
|
+
tmp,
|
|
99
|
+
"{}",
|
|
100
|
+
() => ({ kind: "html", file: "gone.html" }),
|
|
101
|
+
);
|
|
102
|
+
const res = await handler(new Request("http://t/"));
|
|
103
|
+
expect(res.status).toBe(500);
|
|
104
|
+
expect(await res.text()).toContain("hostDispatch: file not found: gone.html");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("hostDispatch not-found → 404; redirect → 302", async () => {
|
|
108
|
+
const notFound = buildStaticFallback(
|
|
109
|
+
() => new Response("x", { status: 404 }),
|
|
110
|
+
tmp,
|
|
111
|
+
"{}",
|
|
112
|
+
() => ({ kind: "not-found" }),
|
|
113
|
+
);
|
|
114
|
+
expect((await notFound(new Request("http://t/"))).status).toBe(404);
|
|
115
|
+
|
|
116
|
+
const redirect = buildStaticFallback(
|
|
117
|
+
() => new Response("x", { status: 404 }),
|
|
118
|
+
tmp,
|
|
119
|
+
"{}",
|
|
120
|
+
() => ({ kind: "redirect", to: "https://example.com/", status: 301 }),
|
|
121
|
+
);
|
|
122
|
+
const res = await redirect(new Request("http://t/"));
|
|
123
|
+
expect(res.status).toBe(301);
|
|
124
|
+
expect(res.headers.get("location")).toBe("https://example.com/");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("/api/* always hits apiHandler", async () => {
|
|
128
|
+
const handler = buildStaticFallback(() => new Response("from-api", { status: 200 }), tmp, "{}");
|
|
129
|
+
const res = await handler(new Request("http://t/api/query", { method: "POST" }));
|
|
130
|
+
expect(res.status).toBe(200);
|
|
131
|
+
expect(await res.text()).toBe("from-api");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("serves disk asset under staticDir", async () => {
|
|
135
|
+
await writeFile(join(tmp, "logo.png"), "PNGDATA");
|
|
136
|
+
const handler = buildStaticFallback(() => new Response("404", { status: 404 }), tmp, "{}");
|
|
137
|
+
const res = await handler(new Request("http://t/logo.png"));
|
|
138
|
+
expect(res.status).toBe(200);
|
|
139
|
+
expect(res.headers.get("content-type")).toBe("image/png");
|
|
140
|
+
expect(await res.text()).toBe("PNGDATA");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("a request for a directory copied verbatim from public/ falls back to index.html instead of 500ing", async () => {
|
|
144
|
+
await mkdir(join(tmp, "sub"));
|
|
145
|
+
await writeFile(join(tmp, "index.html"), "<!doctype html><html><body>spa-shell</body></html>");
|
|
146
|
+
const handler = buildStaticFallback(() => new Response("404", { status: 404 }), tmp, "{}");
|
|
147
|
+
const res = await handler(new Request("http://t/sub"));
|
|
148
|
+
expect(res.status).toBe(200);
|
|
149
|
+
expect(await res.text()).toContain("spa-shell");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("hostDispatch html with CSP + Vary: Host", async () => {
|
|
153
|
+
await writeFile(join(tmp, "tenant.html"), "<!doctype html><html><body>ok</body></html>");
|
|
154
|
+
const handler = buildStaticFallback(
|
|
155
|
+
() => new Response("404", { status: 404 }),
|
|
156
|
+
tmp,
|
|
157
|
+
'{"screens":[]}',
|
|
158
|
+
() => ({
|
|
159
|
+
kind: "html",
|
|
160
|
+
file: "tenant.html",
|
|
161
|
+
injectSchema: false,
|
|
162
|
+
csp: "default-src 'self'",
|
|
163
|
+
}),
|
|
164
|
+
);
|
|
165
|
+
const res = await handler(new Request("http://t/", { headers: { host: "a.example" } }));
|
|
166
|
+
expect(res.status).toBe(200);
|
|
167
|
+
expect(res.headers.get("vary")).toBe("Host");
|
|
168
|
+
expect(res.headers.get("content-security-policy")).toBe("default-src 'self'");
|
|
169
|
+
expect(await res.text()).toContain("ok");
|
|
170
|
+
});
|
|
171
|
+
});
|