@cosmicdrift/kumiko-server-runtime 0.329.0 → 0.330.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/README.md +15 -0
- package/dist/boot/apply-boot-seeds.d.ts +8 -0
- package/{src/boot/apply-boot-seeds.ts → dist/boot/apply-boot-seeds.js} +2 -10
- package/dist/boot/boot-crypto.d.ts +9 -0
- package/dist/boot/boot-crypto.js +39 -0
- package/dist/boot/job-run-logger.d.ts +19 -0
- package/dist/boot/job-run-logger.js +44 -0
- package/dist/boot/pii-event-backfill-on-boot.d.ts +13 -0
- package/dist/boot/pii-event-backfill-on-boot.js +25 -0
- package/dist/build-prod-bundle.d.ts +103 -0
- package/dist/build-prod-bundle.js +751 -0
- package/dist/bun-serve-options.d.ts +32 -0
- package/dist/bun-serve-options.js +49 -0
- package/dist/compose-features.d.ts +31 -0
- package/dist/compose-features.js +124 -0
- package/dist/extra-routes-deps.d.ts +30 -0
- package/dist/extra-routes-deps.js +4 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +7 -0
- package/dist/pii-boot-gate.d.ts +13 -0
- package/dist/pii-boot-gate.js +46 -0
- package/dist/renderer-web-fonts.d.ts +9 -0
- package/{src/renderer-web-fonts.ts → dist/renderer-web-fonts.js} +14 -18
- package/dist/resolve-tailwind-cli.d.ts +14 -0
- package/dist/resolve-tailwind-cli.js +36 -0
- package/dist/run-prod-app-boot-context.d.ts +43 -0
- package/dist/run-prod-app-boot-context.js +198 -0
- package/dist/run-prod-app-static-files.d.ts +32 -0
- package/dist/run-prod-app-static-files.js +280 -0
- package/dist/run-prod-app.d.ts +471 -0
- package/dist/run-prod-app.js +741 -0
- package/dist/run-worker-app.d.ts +92 -0
- package/dist/run-worker-app.js +220 -0
- package/dist/security-headers.d.ts +20 -0
- package/dist/security-headers.js +49 -0
- package/dist/session-boot-gate.d.ts +11 -0
- package/dist/session-boot-gate.js +22 -0
- package/dist/session-wiring.d.ts +11 -0
- package/{src/session-wiring.ts → dist/session-wiring.js} +2 -6
- package/dist/try-hono-first.d.ts +24 -0
- package/{src/try-hono-first.ts → dist/try-hono-first.js} +8 -36
- package/package.json +102 -36
- package/src/changes.json +6 -0
- package/src/__tests__/boot-extra-context.test.ts +0 -163
- package/src/__tests__/boot-probe-fixture.ts +0 -64
- package/src/__tests__/build-prod-bundle.inprocess.test.ts +0 -233
- package/src/__tests__/build-prod-bundle.test.ts +0 -724
- 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 -609
- package/src/__tests__/compose-features.test.ts +0 -206
- package/src/__tests__/config-seed-boot.integration.test.ts +0 -158
- package/src/__tests__/pii-boot-gate.test.ts +0 -112
- 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__/renderer-web-theme-plugin.integration.test.ts +0 -77
- package/src/__tests__/require-env.test.ts +0 -34
- package/src/__tests__/resolve-auth-mail.test.ts +0 -144
- package/src/__tests__/resolve-tailwind-cli.test.ts +0 -81
- package/src/__tests__/run-prod-app-dry-run.test.ts +0 -135
- package/src/__tests__/run-prod-app-env-source.test.ts +0 -118
- package/src/__tests__/run-prod-app-kms-slots.test.ts +0 -108
- package/src/__tests__/run-prod-app-spec.test.ts +0 -167
- package/src/__tests__/run-prod-app-static-files.test.ts +0 -298
- package/src/__tests__/run-prod-app.integration.test.ts +0 -1525
- package/src/__tests__/run-worker-app-boot.test.ts +0 -183
- package/src/__tests__/run-worker-app.integration.test.ts +0 -218
- package/src/__tests__/security-headers.test.ts +0 -88
- package/src/__tests__/session-boot-gate.test.ts +0 -70
- package/src/__tests__/session-wiring.test.ts +0 -16
- package/src/__tests__/try-hono-first.test.ts +0 -108
- package/src/__tests__/websocket-route.integration.test.ts +0 -695
- package/src/boot/__tests__/job-run-logger.integration.test.ts +0 -262
- package/src/boot/__tests__/job-run-logger.test.ts +0 -26
- package/src/boot/boot-crypto.ts +0 -67
- package/src/boot/job-run-logger.ts +0 -63
- package/src/boot/pii-event-backfill-on-boot.ts +0 -43
- package/src/build-prod-bundle.ts +0 -996
- package/src/bun-serve-options.ts +0 -110
- package/src/compose-features.ts +0 -197
- package/src/extra-routes-deps.ts +0 -38
- package/src/index.ts +0 -27
- package/src/pii-boot-gate.ts +0 -80
- package/src/resolve-tailwind-cli.ts +0 -45
- package/src/run-prod-app-boot-context.ts +0 -319
- package/src/run-prod-app-static-files.ts +0 -359
- package/src/run-prod-app.ts +0 -1420
- package/src/run-worker-app.ts +0 -366
- package/src/security-headers.ts +0 -67
- package/src/session-boot-gate.ts +0 -34
|
@@ -1,183 +0,0 @@
|
|
|
1
|
-
// Boot-mode + KMS-gate tests for runWorkerApp — no real Postgres/Redis
|
|
2
|
-
// needed (like run-prod-app-env-source.test.ts): KUMIKO_DRY_RUN_ENV=boot
|
|
3
|
-
// exits BEFORE any connection, and the KMS health gate runs BEFORE
|
|
4
|
-
// createDbConnection/new Redis(...) — so neither path needs real infra.
|
|
5
|
-
|
|
6
|
-
import { describe, expect, spyOn, test } from "bun:test";
|
|
7
|
-
import type { KmsAdapter } from "@cosmicdrift/kumiko-framework/crypto";
|
|
8
|
-
import { createEntity, createTextField, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
9
|
-
import { runWorkerApp } from "../run-worker-app";
|
|
10
|
-
import { makeProbeFeature, withClearedBootEnv } from "./boot-probe-fixture";
|
|
11
|
-
|
|
12
|
-
const probeFeature = makeProbeFeature({
|
|
13
|
-
name: "worker-boot-probe",
|
|
14
|
-
table: "worker_boot_probe",
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
const DUMMY_ENV = {
|
|
18
|
-
DATABASE_URL: "postgres://smoke:smoke@127.0.0.1:1/smoke",
|
|
19
|
-
REDIS_URL: "redis://127.0.0.1:1",
|
|
20
|
-
JWT_SECRET: "smokesmokesmokesmokesmokesmokesmokesmoke",
|
|
21
|
-
} as const;
|
|
22
|
-
|
|
23
|
-
describe("runWorkerApp boot-mode", () => {
|
|
24
|
-
withClearedBootEnv();
|
|
25
|
-
|
|
26
|
-
test("KUMIKO_DRY_RUN_ENV=boot returns an inert handle without opening DB/Redis", async () => {
|
|
27
|
-
const logs: string[] = [];
|
|
28
|
-
const originalLog = console.log;
|
|
29
|
-
console.log = (...args: unknown[]) => {
|
|
30
|
-
logs.push(args.map(String).join(" "));
|
|
31
|
-
};
|
|
32
|
-
let handle: Awaited<ReturnType<typeof runWorkerApp>>;
|
|
33
|
-
try {
|
|
34
|
-
handle = await runWorkerApp({
|
|
35
|
-
features: [probeFeature],
|
|
36
|
-
migrations: false,
|
|
37
|
-
envSource: { ...DUMMY_ENV, KUMIKO_DRY_RUN_ENV: "boot" },
|
|
38
|
-
});
|
|
39
|
-
} finally {
|
|
40
|
-
console.log = originalLog;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
expect(handle).toBeDefined();
|
|
44
|
-
expect(typeof handle.stop).toBe("function");
|
|
45
|
-
expect(logs.some((line) => line.includes("boot validation OK"))).toBe(true);
|
|
46
|
-
await handle.stop();
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
test("ensureTemporalPolyfill runs even in boot-mode — Temporal is defined right after boot validation", async () => {
|
|
50
|
-
// Regression pin for fw#1725: the bug that cost real time was a
|
|
51
|
-
// missing polyfill call. Boot-mode boots without running any job —
|
|
52
|
-
// this test only proves "the polyfill call happens"; the ordering-
|
|
53
|
-
// before-composeFeatures guarantee is covered by the integration
|
|
54
|
-
// test (run-worker-app.integration.test.ts) via a real job.
|
|
55
|
-
const originalLog = console.log;
|
|
56
|
-
console.log = () => {};
|
|
57
|
-
try {
|
|
58
|
-
const handle = await runWorkerApp({
|
|
59
|
-
features: [probeFeature],
|
|
60
|
-
migrations: false,
|
|
61
|
-
envSource: { ...DUMMY_ENV, KUMIKO_DRY_RUN_ENV: "boot" },
|
|
62
|
-
});
|
|
63
|
-
await handle.stop();
|
|
64
|
-
} finally {
|
|
65
|
-
console.log = originalLog;
|
|
66
|
-
}
|
|
67
|
-
expect(typeof (globalThis as { Temporal?: unknown }).Temporal).toBe("object");
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
test("unhealthy KMS aborts boot before any DB/Redis connection is opened", async () => {
|
|
71
|
-
let healthChecked = false;
|
|
72
|
-
const unhealthyKms: KmsAdapter = {
|
|
73
|
-
capabilities: { mode: "local-key" },
|
|
74
|
-
createKey: async () => {},
|
|
75
|
-
getKey: async () => {
|
|
76
|
-
throw new Error("unreachable");
|
|
77
|
-
},
|
|
78
|
-
eraseKey: async () => {},
|
|
79
|
-
health: async () => {
|
|
80
|
-
healthChecked = true;
|
|
81
|
-
return { ok: false, latencyMs: 3 };
|
|
82
|
-
},
|
|
83
|
-
};
|
|
84
|
-
const originalLog = console.log;
|
|
85
|
-
console.log = () => {};
|
|
86
|
-
try {
|
|
87
|
-
await expect(
|
|
88
|
-
runWorkerApp({
|
|
89
|
-
features: [probeFeature],
|
|
90
|
-
migrations: false,
|
|
91
|
-
kms: unhealthyKms,
|
|
92
|
-
envSource: { ...DUMMY_ENV },
|
|
93
|
-
}),
|
|
94
|
-
).rejects.toThrow(/KMS health check failed/);
|
|
95
|
-
} finally {
|
|
96
|
-
console.log = originalLog;
|
|
97
|
-
}
|
|
98
|
-
expect(healthChecked).toBe(true);
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
test("validateBootOptions reaches validateBoot — an opt-in warning fires only when passed", async () => {
|
|
102
|
-
// Reach-the-boot proof, not an acceptance proof: the probe feature's
|
|
103
|
-
// single "anonymous" handler makes warnOnUniqueAccessRoles emit a
|
|
104
|
-
// warning naming that role, and validateBoot emits it only when the
|
|
105
|
-
// option arrives (fw#3080).
|
|
106
|
-
const originalLog = console.log;
|
|
107
|
-
console.log = () => {};
|
|
108
|
-
const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
|
|
109
|
-
const roleWarnings = (): string[] =>
|
|
110
|
-
warnSpy.mock.calls
|
|
111
|
-
.map((call) => String(call[0]))
|
|
112
|
-
.filter((line) => line.includes('Access role "anonymous"'));
|
|
113
|
-
try {
|
|
114
|
-
const withOption = await runWorkerApp({
|
|
115
|
-
features: [probeFeature],
|
|
116
|
-
migrations: false,
|
|
117
|
-
validateBootOptions: { warnOnUniqueAccessRoles: true },
|
|
118
|
-
envSource: { ...DUMMY_ENV, KUMIKO_DRY_RUN_ENV: "boot" },
|
|
119
|
-
});
|
|
120
|
-
await withOption.stop();
|
|
121
|
-
expect(roleWarnings().length).toBeGreaterThan(0);
|
|
122
|
-
|
|
123
|
-
warnSpy.mockClear();
|
|
124
|
-
const withoutOption = await runWorkerApp({
|
|
125
|
-
features: [probeFeature],
|
|
126
|
-
migrations: false,
|
|
127
|
-
envSource: { ...DUMMY_ENV, KUMIKO_DRY_RUN_ENV: "boot" },
|
|
128
|
-
});
|
|
129
|
-
await withoutOption.stop();
|
|
130
|
-
expect(roleWarnings()).toEqual([]);
|
|
131
|
-
} finally {
|
|
132
|
-
warnSpy.mockRestore();
|
|
133
|
-
console.log = originalLog;
|
|
134
|
-
}
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
test("kmsSlots: a master key that exists only as ciphertext reaches the boot probe", async () => {
|
|
138
|
-
const encryptedFeature = defineFeature("worker-kms-probe", (r) => {
|
|
139
|
-
r.entity(
|
|
140
|
-
"note",
|
|
141
|
-
createEntity({
|
|
142
|
-
table: "worker_kms_probe_note",
|
|
143
|
-
fields: {
|
|
144
|
-
body: createTextField({ personal: false, reason: "test_fixture", encrypted: true }),
|
|
145
|
-
},
|
|
146
|
-
}),
|
|
147
|
-
);
|
|
148
|
-
});
|
|
149
|
-
const originalFetch = globalThis.fetch;
|
|
150
|
-
const originalLog = console.log;
|
|
151
|
-
const originalInfo = console.info;
|
|
152
|
-
globalThis.fetch = (async () =>
|
|
153
|
-
new Response(JSON.stringify({ plaintext: Buffer.alloc(32, 7).toString("base64") }), {
|
|
154
|
-
status: 200,
|
|
155
|
-
})) as unknown as typeof globalThis.fetch;
|
|
156
|
-
console.log = () => {};
|
|
157
|
-
console.info = () => {};
|
|
158
|
-
const env = {
|
|
159
|
-
...DUMMY_ENV,
|
|
160
|
-
KUMIKO_DRY_RUN_ENV: "boot",
|
|
161
|
-
KUMIKO_SECRETS_MASTER_KEY_V1_CIPHERTEXT: "Y2lwaGVy",
|
|
162
|
-
PLATFORM_KEK_KMS_KEY_ID: "key-1",
|
|
163
|
-
PLATFORM_KEK_KMS_TOKEN: "token",
|
|
164
|
-
};
|
|
165
|
-
try {
|
|
166
|
-
await expect(
|
|
167
|
-
runWorkerApp({ features: [encryptedFeature], migrations: false, envSource: env }),
|
|
168
|
-
).rejects.toThrow(/no usable master key/);
|
|
169
|
-
|
|
170
|
-
const handle = await runWorkerApp({
|
|
171
|
-
features: [encryptedFeature],
|
|
172
|
-
migrations: false,
|
|
173
|
-
envSource: env,
|
|
174
|
-
kmsSlots: ["KUMIKO_SECRETS_MASTER_KEY_V1"],
|
|
175
|
-
});
|
|
176
|
-
await handle.stop();
|
|
177
|
-
} finally {
|
|
178
|
-
globalThis.fetch = originalFetch;
|
|
179
|
-
console.log = originalLog;
|
|
180
|
-
console.info = originalInfo;
|
|
181
|
-
}
|
|
182
|
-
});
|
|
183
|
-
});
|
|
@@ -1,218 +0,0 @@
|
|
|
1
|
-
// runWorkerApp integration: boots the dedicated worker process against
|
|
2
|
-
// real Postgres + Redis. Proves:
|
|
3
|
-
// - ensureTemporalPolyfill ran BEFORE the job executed (fw#1725: the
|
|
4
|
-
// bug that cost real time — without the polyfill, every job in the
|
|
5
|
-
// worker fails with "Temporal is not defined")
|
|
6
|
-
// - event-triggered jobs run end-to-end (afterCommit → BullMQ → handler)
|
|
7
|
-
// - the schema-drift gate aborts the boot on pending migrations
|
|
8
|
-
// - wireComponents gets db/redis/registry/dispatchSystemWrite/lifecycle
|
|
9
|
-
// and can register its own shutdown hooks
|
|
10
|
-
|
|
11
|
-
import { afterEach, beforeAll, describe, expect, test } from "bun:test";
|
|
12
|
-
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
13
|
-
import { tmpdir } from "node:os";
|
|
14
|
-
import { join } from "node:path";
|
|
15
|
-
import { createDbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
16
|
-
import { createEntity, createTextField, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
17
|
-
import {
|
|
18
|
-
createArchivedStreamsTable,
|
|
19
|
-
createEventsTable,
|
|
20
|
-
} from "@cosmicdrift/kumiko-framework/event-store";
|
|
21
|
-
import {
|
|
22
|
-
createEventConsumerStateTable,
|
|
23
|
-
createProjectionStateTable,
|
|
24
|
-
} from "@cosmicdrift/kumiko-framework/pipeline";
|
|
25
|
-
import { unsafeEnsureEntityTable } from "@cosmicdrift/kumiko-framework/stack";
|
|
26
|
-
import { waitFor } from "@cosmicdrift/kumiko-framework/testing";
|
|
27
|
-
import postgres from "postgres";
|
|
28
|
-
import * as z from "zod";
|
|
29
|
-
import { makeDispatchSystemWrite } from "../extra-routes-deps";
|
|
30
|
-
import { runWorkerApp, type WorkerAppHandle } from "../run-worker-app";
|
|
31
|
-
|
|
32
|
-
const jobRuns: Array<{ note: string; temporalWasDefined: boolean }> = [];
|
|
33
|
-
|
|
34
|
-
const workerProbeEntity = createEntity({
|
|
35
|
-
fields: { note: createTextField({ personal: false, reason: "test_fixture", required: true }) },
|
|
36
|
-
table: "worker_probes",
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
const workerProbeFeature = defineFeature("worker-probe", (r) => {
|
|
40
|
-
r.entity("probe", workerProbeEntity);
|
|
41
|
-
r.writeHandler({
|
|
42
|
-
name: "ping",
|
|
43
|
-
schema: z.object({ note: z.string() }),
|
|
44
|
-
access: { roles: ["SystemAdmin"] },
|
|
45
|
-
handler: async (event) => ({
|
|
46
|
-
isSuccess: true as const,
|
|
47
|
-
data: { note: (event.payload as { note: string }).note },
|
|
48
|
-
}),
|
|
49
|
-
});
|
|
50
|
-
// The job's only purpose: prove Temporal is already defined by the time
|
|
51
|
-
// the handler runs. Before fw#1725 there was no framework-side boot
|
|
52
|
-
// path for this — apps had to rebuild the polyfill call by hand
|
|
53
|
-
// (solon#42) and forgot it.
|
|
54
|
-
r.job(
|
|
55
|
-
"record-ping",
|
|
56
|
-
{ trigger: { on: "worker-probe:write:ping" }, runIn: "worker" },
|
|
57
|
-
async (payload) => {
|
|
58
|
-
const temporalWasDefined =
|
|
59
|
-
typeof (globalThis as { Temporal?: unknown }).Temporal === "object";
|
|
60
|
-
// Touches the global directly — throws "Temporal is not defined" if the
|
|
61
|
-
// polyfill never ran, which is the exact failure mode fw#1725 reports.
|
|
62
|
-
Temporal.Now.instant();
|
|
63
|
-
jobRuns.push({
|
|
64
|
-
note: (payload as { note: string }).note,
|
|
65
|
-
temporalWasDefined,
|
|
66
|
-
});
|
|
67
|
-
},
|
|
68
|
-
);
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
const TENANT_ID = "00000000-0000-4000-8000-000000000002";
|
|
72
|
-
const TEST_DB = `kumiko_runworker_${Date.now().toString(36)}`;
|
|
73
|
-
const ADMIN_URL = process.env["TEST_DATABASE_URL"] ?? "";
|
|
74
|
-
|
|
75
|
-
const tempDirs: string[] = [];
|
|
76
|
-
let handles: WorkerAppHandle[] = [];
|
|
77
|
-
|
|
78
|
-
beforeAll(async () => {
|
|
79
|
-
if (!ADMIN_URL) throw new Error("TEST_DATABASE_URL must be set");
|
|
80
|
-
const adminClient = postgres(ADMIN_URL.replace(/\/[^/]+$/, "/postgres"));
|
|
81
|
-
try {
|
|
82
|
-
await adminClient.unsafe(`CREATE DATABASE "${TEST_DB}"`);
|
|
83
|
-
} finally {
|
|
84
|
-
await adminClient.end();
|
|
85
|
-
}
|
|
86
|
-
const url = ADMIN_URL.replace(/\/[^/]+$/, `/${TEST_DB}`);
|
|
87
|
-
const { db, close } = createDbConnection(url);
|
|
88
|
-
try {
|
|
89
|
-
await createEventsTable(db);
|
|
90
|
-
await createArchivedStreamsTable(db);
|
|
91
|
-
await createProjectionStateTable(db);
|
|
92
|
-
await createEventConsumerStateTable(db);
|
|
93
|
-
await unsafeEnsureEntityTable(db, workerProbeEntity, "probe");
|
|
94
|
-
} finally {
|
|
95
|
-
await close();
|
|
96
|
-
}
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
afterEach(async () => {
|
|
100
|
-
for (const handle of handles) {
|
|
101
|
-
await handle.stop();
|
|
102
|
-
}
|
|
103
|
-
handles = [];
|
|
104
|
-
for (const dir of tempDirs) {
|
|
105
|
-
await rm(dir, { recursive: true, force: true });
|
|
106
|
-
}
|
|
107
|
-
tempDirs.length = 0;
|
|
108
|
-
jobRuns.length = 0;
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
async function boot(extra?: Partial<Parameters<typeof runWorkerApp>[0]>): Promise<WorkerAppHandle> {
|
|
112
|
-
const originalDbUrl = process.env["DATABASE_URL"];
|
|
113
|
-
process.env["DATABASE_URL"] = ADMIN_URL.replace(/\/[^/]+$/, `/${TEST_DB}`);
|
|
114
|
-
process.env["REDIS_URL"] = process.env["REDIS_URL"] ?? "redis://localhost:16379";
|
|
115
|
-
process.env["JWT_SECRET"] = "test-runworker-secret-32-chars-min!!";
|
|
116
|
-
try {
|
|
117
|
-
const handle = await runWorkerApp({
|
|
118
|
-
features: [workerProbeFeature],
|
|
119
|
-
migrations: false,
|
|
120
|
-
jobs: { queueNamePrefix: `test-worker-${Date.now().toString(36)}` },
|
|
121
|
-
...(extra ?? {}),
|
|
122
|
-
});
|
|
123
|
-
handles.push(handle);
|
|
124
|
-
return handle;
|
|
125
|
-
} finally {
|
|
126
|
-
if (originalDbUrl !== undefined) process.env["DATABASE_URL"] = originalDbUrl;
|
|
127
|
-
else delete process.env["DATABASE_URL"];
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
async function pollFor<T>(probe: () => T | undefined): Promise<T> {
|
|
132
|
-
let result: T | undefined;
|
|
133
|
-
await waitFor(
|
|
134
|
-
() => {
|
|
135
|
-
result = probe();
|
|
136
|
-
return result !== undefined;
|
|
137
|
-
},
|
|
138
|
-
{ delays: Array(80).fill(100) },
|
|
139
|
-
);
|
|
140
|
-
if (result === undefined) throw new Error("pollFor: timeout");
|
|
141
|
-
return result;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
describe("runWorkerApp", () => {
|
|
145
|
-
test("boots against real Postgres/Redis — mode is worker, dispatcher available", async () => {
|
|
146
|
-
const handle = await boot();
|
|
147
|
-
expect(handle.entrypoint.mode).toBe("worker");
|
|
148
|
-
expect(handle.entrypoint.dispatcher).toBeDefined();
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
test("event-triggered job runs end-to-end with Temporal already defined (fw#1725 regression)", async () => {
|
|
152
|
-
const handle = await boot();
|
|
153
|
-
const dispatchSystemWrite = makeDispatchSystemWrite(handle.entrypoint.dispatcher);
|
|
154
|
-
|
|
155
|
-
const result = await dispatchSystemWrite({
|
|
156
|
-
handlerQn: "worker-probe:write:ping",
|
|
157
|
-
payload: { note: "hello-from-worker" },
|
|
158
|
-
tenantId: TENANT_ID as import("@cosmicdrift/kumiko-framework/engine").TenantId,
|
|
159
|
-
});
|
|
160
|
-
expect(result.isSuccess).toBe(true);
|
|
161
|
-
|
|
162
|
-
const run = await pollFor(() => jobRuns.find((r) => r.note === "hello-from-worker"));
|
|
163
|
-
expect(run.temporalWasDefined).toBe(true);
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
test("wireComponents receives db/redis/registry/dispatchSystemWrite/lifecycle and can register a shutdown hook", async () => {
|
|
167
|
-
let seenDeps: {
|
|
168
|
-
db: boolean;
|
|
169
|
-
redis: boolean;
|
|
170
|
-
registry: boolean;
|
|
171
|
-
dispatchSystemWrite: boolean;
|
|
172
|
-
} | null = null;
|
|
173
|
-
let shutdownHookRan = false;
|
|
174
|
-
|
|
175
|
-
const handle = await boot({
|
|
176
|
-
wireComponents: async (deps) => {
|
|
177
|
-
seenDeps = {
|
|
178
|
-
db: deps.db !== undefined,
|
|
179
|
-
redis: deps.redis !== undefined,
|
|
180
|
-
registry: deps.registry.features.has("worker-probe"),
|
|
181
|
-
dispatchSystemWrite: typeof deps.dispatchSystemWrite === "function",
|
|
182
|
-
};
|
|
183
|
-
deps.lifecycle.registerShutdownHook("test-component", async () => {
|
|
184
|
-
shutdownHookRan = true;
|
|
185
|
-
});
|
|
186
|
-
},
|
|
187
|
-
});
|
|
188
|
-
|
|
189
|
-
expect(seenDeps!).toEqual({
|
|
190
|
-
db: true,
|
|
191
|
-
redis: true,
|
|
192
|
-
registry: true,
|
|
193
|
-
dispatchSystemWrite: true,
|
|
194
|
-
});
|
|
195
|
-
|
|
196
|
-
await handle.stop();
|
|
197
|
-
handles = handles.filter((h) => h !== handle);
|
|
198
|
-
expect(shutdownHookRan).toBe(true);
|
|
199
|
-
});
|
|
200
|
-
|
|
201
|
-
test("Schema-Drift-Gate: pending migration aborts the boot before anything else initializes", async () => {
|
|
202
|
-
const driftDir = await mkdtemp(join(tmpdir(), "kumiko-worker-drift-boot-"));
|
|
203
|
-
tempDirs.push(driftDir);
|
|
204
|
-
await writeFile(
|
|
205
|
-
join(driftDir, "0001_pending.sql"),
|
|
206
|
-
`CREATE TABLE "worker_never_created_table" ("id" uuid PRIMARY KEY);`,
|
|
207
|
-
);
|
|
208
|
-
await writeFile(
|
|
209
|
-
join(driftDir, ".snapshot.json"),
|
|
210
|
-
JSON.stringify({
|
|
211
|
-
version: 1,
|
|
212
|
-
tables: [{ tableName: "worker_never_created_table", columns: [] }],
|
|
213
|
-
}),
|
|
214
|
-
);
|
|
215
|
-
|
|
216
|
-
await expect(boot({ migrations: { dir: driftDir } })).rejects.toThrow(/Schema drift detected/);
|
|
217
|
-
});
|
|
218
|
-
});
|
|
@@ -1,88 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "bun:test";
|
|
2
|
-
import { resolveSecurityHeaders, withSecurityHeaders } from "../security-headers";
|
|
3
|
-
|
|
4
|
-
const okHandler = (_req: Request) => new Response("ok");
|
|
5
|
-
const req = new Request("http://localhost/");
|
|
6
|
-
|
|
7
|
-
describe("resolveSecurityHeaders", () => {
|
|
8
|
-
it("returns the four defaults without csp when unconfigured", () => {
|
|
9
|
-
const headers = new Map(resolveSecurityHeaders(undefined));
|
|
10
|
-
expect(headers.get("strict-transport-security")).toBe("max-age=31536000; includeSubDomains");
|
|
11
|
-
expect(headers.get("x-frame-options")).toBe("DENY");
|
|
12
|
-
expect(headers.get("x-content-type-options")).toBe("nosniff");
|
|
13
|
-
expect(headers.get("referrer-policy")).toBe("strict-origin-when-cross-origin");
|
|
14
|
-
expect(headers.has("content-security-policy")).toBe(false);
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
it("returns nothing when disabled", () => {
|
|
18
|
-
expect(resolveSecurityHeaders(false)).toEqual([]);
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
it("applies per-header overrides and opt-outs", () => {
|
|
22
|
-
const headers = new Map(
|
|
23
|
-
resolveSecurityHeaders({
|
|
24
|
-
hsts: "max-age=60",
|
|
25
|
-
frameOptions: false,
|
|
26
|
-
referrerPolicy: "no-referrer",
|
|
27
|
-
csp: "default-src 'self'",
|
|
28
|
-
}),
|
|
29
|
-
);
|
|
30
|
-
expect(headers.get("strict-transport-security")).toBe("max-age=60");
|
|
31
|
-
expect(headers.has("x-frame-options")).toBe(false);
|
|
32
|
-
expect(headers.get("x-content-type-options")).toBe("nosniff");
|
|
33
|
-
expect(headers.get("referrer-policy")).toBe("no-referrer");
|
|
34
|
-
expect(headers.get("content-security-policy")).toBe("default-src 'self'");
|
|
35
|
-
});
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
describe("withSecurityHeaders", () => {
|
|
39
|
-
it("sets defaults on every response", async () => {
|
|
40
|
-
const res = await withSecurityHeaders(okHandler, undefined)(req);
|
|
41
|
-
expect(res.headers.get("strict-transport-security")).toBe(
|
|
42
|
-
"max-age=31536000; includeSubDomains",
|
|
43
|
-
);
|
|
44
|
-
expect(res.headers.get("x-frame-options")).toBe("DENY");
|
|
45
|
-
expect(res.headers.get("x-content-type-options")).toBe("nosniff");
|
|
46
|
-
expect(res.headers.get("referrer-policy")).toBe("strict-origin-when-cross-origin");
|
|
47
|
-
expect(await res.text()).toBe("ok");
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
it("returns the handler unwrapped when disabled", () => {
|
|
51
|
-
expect(withSecurityHeaders(okHandler, false)).toBe(okHandler);
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
it("never overrides a header the response already set", async () => {
|
|
55
|
-
const handler = (_req: Request) =>
|
|
56
|
-
new Response("ok", {
|
|
57
|
-
headers: {
|
|
58
|
-
"content-security-policy": "default-src 'none'",
|
|
59
|
-
"x-frame-options": "SAMEORIGIN",
|
|
60
|
-
},
|
|
61
|
-
});
|
|
62
|
-
const res = await withSecurityHeaders(handler, { csp: "default-src 'self'" })(req);
|
|
63
|
-
expect(res.headers.get("content-security-policy")).toBe("default-src 'none'");
|
|
64
|
-
expect(res.headers.get("x-frame-options")).toBe("SAMEORIGIN");
|
|
65
|
-
expect(res.headers.get("x-content-type-options")).toBe("nosniff");
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
it("re-wraps responses with immutable headers", async () => {
|
|
69
|
-
const immutable = new Response("ok", { status: 201, statusText: "Created" });
|
|
70
|
-
Object.defineProperty(immutable, "headers", {
|
|
71
|
-
value: new Proxy(immutable.headers, {
|
|
72
|
-
get(target, prop) {
|
|
73
|
-
if (prop === "set") {
|
|
74
|
-
return () => {
|
|
75
|
-
throw new TypeError("immutable");
|
|
76
|
-
};
|
|
77
|
-
}
|
|
78
|
-
const value = Reflect.get(target, prop);
|
|
79
|
-
return typeof value === "function" ? value.bind(target) : value;
|
|
80
|
-
},
|
|
81
|
-
}),
|
|
82
|
-
});
|
|
83
|
-
const res = await withSecurityHeaders(() => immutable, undefined)(req);
|
|
84
|
-
expect(res.status).toBe(201);
|
|
85
|
-
expect(res.headers.get("x-content-type-options")).toBe("nosniff");
|
|
86
|
-
expect(await res.text()).toBe("ok");
|
|
87
|
-
});
|
|
88
|
-
});
|
|
@@ -1,70 +0,0 @@
|
|
|
1
|
-
import { describe, expect, spyOn, test } from "bun:test";
|
|
2
|
-
import { assertSessionBootInvariants } from "../session-boot-gate";
|
|
3
|
-
|
|
4
|
-
describe("assertSessionBootInvariants (#1372)", () => {
|
|
5
|
-
test("no auth → no throw", () => {
|
|
6
|
-
expect(() =>
|
|
7
|
-
assertSessionBootInvariants({
|
|
8
|
-
hasAuth: false,
|
|
9
|
-
sessionStoreProviderMounted: false,
|
|
10
|
-
mode: "prod",
|
|
11
|
-
}),
|
|
12
|
-
).not.toThrow();
|
|
13
|
-
});
|
|
14
|
-
|
|
15
|
-
test("auth + sessionStore → no throw", () => {
|
|
16
|
-
expect(() =>
|
|
17
|
-
assertSessionBootInvariants({
|
|
18
|
-
hasAuth: true,
|
|
19
|
-
sessionStoreProviderMounted: true,
|
|
20
|
-
mode: "prod",
|
|
21
|
-
}),
|
|
22
|
-
).not.toThrow();
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
test("prod: auth without sessionStore → BOOT ABORTED throw", () => {
|
|
26
|
-
expect(() =>
|
|
27
|
-
assertSessionBootInvariants({
|
|
28
|
-
hasAuth: true,
|
|
29
|
-
sessionStoreProviderMounted: false,
|
|
30
|
-
mode: "prod",
|
|
31
|
-
}),
|
|
32
|
-
).toThrow(/BOOT ABORTED/);
|
|
33
|
-
});
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
// dev/prod split (#2027) — dev-server had no equivalent gate at all before
|
|
37
|
-
// this: a forgotten sessions mount silently left session-list empty with no
|
|
38
|
-
// signal. Mirrors assertPiiBootInvariants' dev/prod split (warn vs abort).
|
|
39
|
-
describe("assertSessionBootInvariants — dev mode (#2027)", () => {
|
|
40
|
-
test("dev: auth without sessionStore → warns, does NOT throw", () => {
|
|
41
|
-
const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
|
|
42
|
-
try {
|
|
43
|
-
expect(() =>
|
|
44
|
-
assertSessionBootInvariants({
|
|
45
|
-
hasAuth: true,
|
|
46
|
-
sessionStoreProviderMounted: false,
|
|
47
|
-
mode: "dev",
|
|
48
|
-
}),
|
|
49
|
-
).not.toThrow();
|
|
50
|
-
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("sessionStore"));
|
|
51
|
-
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("[runDevApp]"));
|
|
52
|
-
} finally {
|
|
53
|
-
warnSpy.mockRestore();
|
|
54
|
-
}
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
test("dev: auth + sessionStore → no throw, no warn", () => {
|
|
58
|
-
const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
|
|
59
|
-
try {
|
|
60
|
-
assertSessionBootInvariants({
|
|
61
|
-
hasAuth: true,
|
|
62
|
-
sessionStoreProviderMounted: true,
|
|
63
|
-
mode: "dev",
|
|
64
|
-
});
|
|
65
|
-
expect(warnSpy).not.toHaveBeenCalled();
|
|
66
|
-
} finally {
|
|
67
|
-
warnSpy.mockRestore();
|
|
68
|
-
}
|
|
69
|
-
});
|
|
70
|
-
});
|
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "bun:test";
|
|
2
|
-
import { shouldWireProdSessions } from "../session-wiring";
|
|
3
|
-
|
|
4
|
-
describe("shouldWireProdSessions — secure-by-default (#1372)", () => {
|
|
5
|
-
it("wires when auth + sessionStore provider mounted", () => {
|
|
6
|
-
expect(shouldWireProdSessions(true, true)).toBe(true);
|
|
7
|
-
});
|
|
8
|
-
|
|
9
|
-
it("does not wire without auth", () => {
|
|
10
|
-
expect(shouldWireProdSessions(false, true)).toBe(false);
|
|
11
|
-
});
|
|
12
|
-
|
|
13
|
-
it("does not wire without sessionStore provider", () => {
|
|
14
|
-
expect(shouldWireProdSessions(true, false)).toBe(false);
|
|
15
|
-
});
|
|
16
|
-
});
|
|
@@ -1,108 +0,0 @@
|
|
|
1
|
-
// Pure-function pin for tryHonoFirst. Trivial but load-bearing: drift
|
|
2
|
-
// between dev (createKumikoServer) and prod (runProdApp) has already
|
|
3
|
-
// caused a bug before (legal-pages worked in prod but not in dev). Both
|
|
4
|
-
// now use this helper — if the semantics change (e.g. "matched" treating
|
|
5
|
-
// other 4xx differently than 404), both paths MUST update in sync.
|
|
6
|
-
//
|
|
7
|
-
// kumiko-framework#2435: "matched" used to depend ONLY on the status code
|
|
8
|
-
// (404 = no match) — that masked any matched route's deliberate 404 as the
|
|
9
|
-
// SPA shell with status 200. The contract now: a router-miss is ONLY a 404
|
|
10
|
-
// that ADDITIONALLY carries NO_ROUTE_MATCH_HEADER_NAME (set by buildServer's
|
|
11
|
-
// app.notFound(), see framework/api/server.ts). A plain 404 without the
|
|
12
|
-
// marker counts as matched — the route answered deliberately.
|
|
13
|
-
|
|
14
|
-
import { describe, expect, test } from "bun:test";
|
|
15
|
-
import { NO_ROUTE_MATCH_HEADER_NAME } from "@cosmicdrift/kumiko-framework/api";
|
|
16
|
-
import { type HonoLikeApp, stripNoRouteMatchHeader, tryHonoFirst } from "../try-hono-first";
|
|
17
|
-
|
|
18
|
-
function makeApp(response: Response): HonoLikeApp {
|
|
19
|
-
return { fetch: () => response };
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
function makeAsyncApp(response: Response): HonoLikeApp {
|
|
23
|
-
return { fetch: async () => response };
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
function routerMissResponse(): Response {
|
|
27
|
-
return new Response("Not Found", {
|
|
28
|
-
status: 404,
|
|
29
|
-
headers: { [NO_ROUTE_MATCH_HEADER_NAME]: "1" },
|
|
30
|
-
});
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
describe("tryHonoFirst", () => {
|
|
34
|
-
test("matched=true on 200 (a Hono route handled it)", async () => {
|
|
35
|
-
const app = makeApp(new Response("ok", { status: 200 }));
|
|
36
|
-
const res = await tryHonoFirst(app, new Request("http://test/foo"));
|
|
37
|
-
expect(res.matched).toBe(true);
|
|
38
|
-
expect(res.response.status).toBe(200);
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
test("matched=false on a router-miss (404 + NO_ROUTE_MATCH_HEADER_NAME — caller falls back to the SPA)", async () => {
|
|
42
|
-
const app = makeApp(routerMissResponse());
|
|
43
|
-
const res = await tryHonoFirst(app, new Request("http://test/unknown"));
|
|
44
|
-
expect(res.matched).toBe(false);
|
|
45
|
-
// response is still returned — caller can use the 404 as a last-resort
|
|
46
|
-
// safety net if the SPA fallback doesn't deliver anything either.
|
|
47
|
-
expect(res.response.status).toBe(404);
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
test("matched=true for a matched route that deliberately answers 404 (no marker — bug #2435)", async () => {
|
|
51
|
-
// The actual bug: file-derivatives' public-variant route matches and
|
|
52
|
-
// answers `c.text("not found", 404)` for default-deny — NOT a
|
|
53
|
-
// router-miss. Without the marker header this used to be
|
|
54
|
-
// misinterpreted as "no match" and the caller would have served the
|
|
55
|
-
// SPA shell with status 200 instead of passing the real 404 through.
|
|
56
|
-
const app = makeApp(new Response("not found", { status: 404 }));
|
|
57
|
-
const res = await tryHonoFirst(app, new Request("http://test/files/x/thumb"));
|
|
58
|
-
expect(res.matched).toBe(true);
|
|
59
|
-
expect(res.response.status).toBe(404);
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
test("matched=true on 401/403/500 (Hono answered — no SPA fallback)", async () => {
|
|
63
|
-
// Bug pin: matched may ONLY be false on a router-miss. When Hono
|
|
64
|
-
// returns 401 (auth required), the route was clearly found and
|
|
65
|
-
// deliberately rejected — an SPA fallback would override that and
|
|
66
|
-
// redirect the user into the SPA instead of showing the 401 message.
|
|
67
|
-
for (const status of [401, 403, 422, 500] as const) {
|
|
68
|
-
const app = makeApp(new Response(null, { status }));
|
|
69
|
-
const res = await tryHonoFirst(app, new Request("http://test/x"));
|
|
70
|
-
expect(res.matched, `status ${status} should be matched`).toBe(true);
|
|
71
|
-
}
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
test("accepts both sync and async fetch (Hono variation)", async () => {
|
|
75
|
-
// Hono.app.fetch returns Response | Promise<Response> depending on the
|
|
76
|
-
// handler mix. createApiEntrypoint's apiHandler does the same. The
|
|
77
|
-
// helper must accept both.
|
|
78
|
-
const sync = await tryHonoFirst(
|
|
79
|
-
makeApp(new Response("s", { status: 200 })),
|
|
80
|
-
new Request("http://t/"),
|
|
81
|
-
);
|
|
82
|
-
const asyncRes = await tryHonoFirst(
|
|
83
|
-
makeAsyncApp(new Response("a", { status: 200 })),
|
|
84
|
-
new Request("http://t/"),
|
|
85
|
-
);
|
|
86
|
-
expect(sync.matched).toBe(true);
|
|
87
|
-
expect(asyncRes.matched).toBe(true);
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
test("strips NO_ROUTE_MATCH_HEADER_NAME from the returned response (never leaks to the client)", async () => {
|
|
91
|
-
const app = makeApp(routerMissResponse());
|
|
92
|
-
const res = await tryHonoFirst(app, new Request("http://test/unknown"));
|
|
93
|
-
expect(res.response.headers.has(NO_ROUTE_MATCH_HEADER_NAME)).toBe(false);
|
|
94
|
-
});
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
describe("stripNoRouteMatchHeader", () => {
|
|
98
|
-
test("removes the header when present", () => {
|
|
99
|
-
const res = stripNoRouteMatchHeader(routerMissResponse());
|
|
100
|
-
expect(res.headers.has(NO_ROUTE_MATCH_HEADER_NAME)).toBe(false);
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
test("is a no-op when the header is absent", () => {
|
|
104
|
-
const res = stripNoRouteMatchHeader(new Response("ok", { status: 200 }));
|
|
105
|
-
expect(res.status).toBe(200);
|
|
106
|
-
expect(res.headers.has(NO_ROUTE_MATCH_HEADER_NAME)).toBe(false);
|
|
107
|
-
});
|
|
108
|
-
});
|