import { afterEach, describe, expect, test } from "bun:test"; import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { NO_ROUTE_MATCH_HEADER_NAME } from "@cosmicdrift/kumiko-framework/api"; import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db"; import { createBooleanField, createEntity, createTextField, defineFeature, } from "@cosmicdrift/kumiko-framework/engine"; import { TestUsers } from "@cosmicdrift/kumiko-framework/stack"; import { z } from "zod"; import { createKumikoServer, type KumikoServerHandle } from "../create-kumiko-server"; // Integration-Test: bootet createKumikoServer mit echtem Postgres, // echtem Redis, echter Kumiko-Pipeline. Treibt den fetch-Handler // direkt an (nicht über Bun.serve + Socket), weil vitest unter Node // läuft. Unter Bun würde derselbe Handler identisch antworten — // das Routing ist runtime-neutral. const probeEntity = createEntity({ fields: { title: createTextField({ personal: false, reason: "test_fixture", required: true }), done: createBooleanField(), }, table: "kumiko_server_probe", }); const probeFeature = defineFeature("dev-server-probe", (r) => { r.entity("probe", probeEntity); // SystemAdmin-gated write — Ziel des extraRoutes.dispatchSystemWrite- // Tests (252/2): Echo von user.tenantId + roles beweist Dispatch durch // den echten Dispatcher (Zod + Access-Check) mit Ziel-Tenant-SystemUser. r.writeHandler({ name: "probe-write", schema: z.object({ note: z.string() }), access: { roles: ["SystemAdmin"] }, handler: async (event) => ({ isSuccess: true as const, data: { tenantSeen: event.user.tenantId, roles: event.user.roles }, }), }); }); let handle: KumikoServerHandle | undefined; afterEach(async () => { if (handle) { await handle.stop(); handle = undefined; } }); async function boot(): Promise { handle = await createKumikoServer({ features: [probeFeature], port: 0, installSignalHandlers: false, }); return handle; } describe("createKumikoServer", () => { test("bootet den Kumiko-Stack + legt die Feature-Tables an", async () => { const h = await boot(); const rows = await asRawClient(h.stack.db).unsafe( `SELECT to_regclass('public.kumiko_server_probe') IS NOT NULL AS "exists"`, ); expect((rows as Array>)[0]?.["exists"]).toBe(true); }); test("GET / → HTML + kumiko_auth/kumiko_csrf Set-Cookie", async () => { const h = await boot(); const res = await h.fetch(new Request("http://localhost/")); expect(res.status).toBe(200); expect(res.headers.get("content-type")).toMatch(/text\/html/); const setCookie = res.headers.get("set-cookie") ?? ""; expect(setCookie).toMatch(/kumiko_auth=/); expect(setCookie).toMatch(/kumiko_csrf=/); const body = await res.text(); expect(body).toMatch(/
/); // Reload-Snippet wurde in injiziert. expect(body).toMatch(/EventSource\("\/_reload"\)/); // As a classic script the bundle's top-level declarations become window // properties: a dependency exporting `function history()` // (prosemirror-history, via tiptap) then replaces window.history and every // pushState throws. Prod has always emitted the module form. expect(body).toMatch(/PUBLIC-HTML`, ); writeFileSync( adminHtml, `
ADMIN-HTML`, ); return createKumikoServer({ features: [probeFeature], port: 0, installSignalHandlers: false, clientEntries: [ { name: "public", sourceFile: publicEntry, htmlPath: publicHtml }, { name: "admin", sourceFile: adminEntry, htmlPath: adminHtml }, ], // Stub: der Bundle-Inhalt enthält den Entry-Namen damit der Test // beweisen kann dass /client-public.js ≠ /client-admin.js. Echtes // Bun.build würde unterschiedlich-gehashte Bundles produzieren — // wir simulieren das mit deterministischen Markern. _buildBundle: async (sourceFile) => { if (sourceFile === publicEntry) { return { js: "// PUBLIC-BUNDLE", map: "" }; } if (sourceFile === adminEntry) { return { js: "// ADMIN-BUNDLE", map: "" }; } throw new Error(`unexpected entry: ${sourceFile}`); }, hostDispatch: (req) => { const host = (req.headers.get("host") ?? "").split(":")[0]?.toLowerCase() ?? ""; if (host === "apex.test") return { kind: "not-found" }; if (host === "old.test") return { kind: "redirect", to: "https://new.test/" }; if (host.startsWith("admin.")) { return { kind: "html", entryName: "admin", injectSchema: true }; } return { kind: "html", entryName: "public", injectSchema: false }; }, }); } test("HTML-Dispatch: admin-Host bekommt admin.html, sonst index.html", async () => { handle = await bootMultiEntry(); const publicRes = await handle.fetch( new Request("http://status.localhost/", { headers: { host: "status.localhost" } }), ); const publicBody = await publicRes.text(); expect(publicBody).toMatch(/PUBLIC-HTML/); expect(publicBody).not.toMatch(/ADMIN-HTML/); const adminRes = await handle.fetch( new Request("http://admin.localhost/", { headers: { host: "admin.localhost" } }), ); const adminBody = await adminRes.text(); expect(adminBody).toMatch(/ADMIN-HTML/); expect(adminBody).not.toMatch(/PUBLIC-HTML/); }); test("Bundle-Routing: /client-public.js ≠ /client-admin.js", async () => { handle = await bootMultiEntry(); const publicJs = await handle.fetch(new Request("http://status.localhost/client-public.js")); expect(publicJs.status).toBe(200); expect(publicJs.headers.get("content-type")).toMatch(/application\/javascript/); expect(await publicJs.text()).toBe("// PUBLIC-BUNDLE"); const adminJs = await handle.fetch(new Request("http://admin.localhost/client-admin.js")); expect(adminJs.status).toBe(200); expect(await adminJs.text()).toBe("// ADMIN-BUNDLE"); // Cross-routing existiert NICHT — /client.js (Single-Entry-Pfad) // ist im Multi-Mode kein registrierter Asset-Path. const noFallback = await handle.fetch(new Request("http://localhost/client.js")); expect(noFallback.status).toBe(404); }); test("Schema-Inject: admin → injected, public → NICHT injected", async () => { handle = await bootMultiEntry(); const publicHtml = await ( await handle.fetch( new Request("http://status.localhost/", { headers: { host: "status.localhost" } }), ) ).text(); expect(publicHtml).not.toMatch(/__KUMIKO_SCHEMA__/); const adminHtml = await ( await handle.fetch( new Request("http://admin.localhost/", { headers: { host: "admin.localhost" } }), ) ).text(); expect(adminHtml).toMatch(/__KUMIKO_SCHEMA__/); // #2062: setupTestStack always wires an in-memory SearchAdapter // (test-stack.ts), so the dev-server call site's // `searchAdapterMissing: !stack.context.searchAdapter` must read false — // proving the flag actually flows from stack.context through to the // injected schema instead of silently staying at its own default. const schemaMatch = adminHtml.match(/window\.__KUMIKO_SCHEMA__=(\{.*?\});<\/script>/s); expect(schemaMatch).not.toBeNull(); const injectedSchema = JSON.parse(schemaMatch?.[1] ?? "{}"); // Guards against a regex miss silently degrading to `?? "{}"` — an empty // features array would make `.some(...)` below vacuously false too. expect(injectedSchema.features.length).toBeGreaterThan(0); const anyFeatureMissingAdapter = injectedSchema.features.some( (f: { searchAdapterMissing?: boolean }) => f.searchAdapterMissing === true, ); expect(anyFeatureMissingAdapter).toBe(false); }); test("hostDispatch redirect: liefert 302 mit Location-Header", async () => { handle = await bootMultiEntry(); const res = await handle.fetch( new Request("http://old.test/", { headers: { host: "old.test" } }), ); expect(res.status).toBe(302); expect(res.headers.get("location")).toBe("https://new.test/"); }); test("hostDispatch not-found: liefert 404", async () => { handle = await bootMultiEntry(); const res = await handle.fetch( new Request("http://apex.test/", { headers: { host: "apex.test" } }), ); expect(res.status).toBe(404); }); }); // 252/2: der Dev-Pfad bekommt dieselbe extraRoutes-deps-Closure wie // runProdApp (seit der Extraktion nach extra-routes-deps.ts geteilt) — // hier der analoge Beweis gegen createKumikoServer. describe("createKumikoServer extraRoutes-deps", () => { test("dispatchSystemWrite schreibt als SystemAdmin des Ziel-Tenants, registry verfügbar", async () => { const tenantId = "00000000-0000-4000-8000-000000000042"; let registryHasProbe = false; handle = await createKumikoServer({ features: [probeFeature], port: 0, installSignalHandlers: false, extraRoutes: (app, deps) => { registryHasProbe = deps.registry.features.has("dev-server-probe"); app.post("/webhook-probe", async (c) => { const result = await deps.dispatchSystemWrite({ handlerQn: "dev-server-probe:write:probe-write", payload: { note: "from-webhook" }, tenantId: tenantId as import("@cosmicdrift/kumiko-framework/engine").TenantId, }); return c.json(result); }); }, }); expect(registryHasProbe).toBe(true); const res = await handle.fetch(new Request("http://test/webhook-probe", { method: "POST" })); expect(res.status).toBe(200); const body = (await res.json()) as { isSuccess: boolean; data?: { tenantSeen: string; roles: string[] }; }; expect(body.isSuccess).toBe(true); expect(body.data?.tenantSeen).toBe(tenantId); expect(body.data?.roles).toContain("SystemAdmin"); }); }); // framework#1668: extraRoutes must be registered BEFORE onAfterSetup // dispatches through stack.http — Hono builds its matcher on first dispatch, // and any route registered after that throws. Pins the ordering. describe("createKumikoServer — extraRoutes vs. onAfterSetup ordering", () => { test("an extraRoutes route stays reachable after onAfterSetup dispatches via stack.http", async () => { handle = await createKumikoServer({ features: [probeFeature], port: 0, installSignalHandlers: false, extraRoutes: (app) => { app.get("/probe", (c) => c.json({ ok: true })); }, onAfterSetup: async (stack) => { await stack.http.writeOk( "dev-server-probe:write:probe-write", { note: "onAfterSetup" }, TestUsers.systemAdmin, ); }, }); const res = await handle.fetch(new Request("http://test/probe")); expect(res.status).toBe(200); }); }); describe("createKumikoServer — stylesheet tailwind failure (graceful)", () => { test("missing stylesheet entry boots without CSS — GET /styles.css → 404", async () => { const tmpDir = mkdtempSync(join(tmpdir(), "kumiko-css-fail-")); const entry = join(tmpDir, "client.tsx"); writeFileSync(entry, "export const x = 1;\n"); try { handle = await createKumikoServer({ features: [probeFeature], port: 0, installSignalHandlers: false, clientEntry: entry, stylesheet: join(tmpDir, "does-not-exist.css"), _buildBundle: async () => ({ js: "// stub", map: "" }), }); const cssRes = await handle.fetch(new Request("http://localhost/styles.css")); expect(cssRes.status).toBe(404); expect(await cssRes.text()).toBe("no stylesheet"); } finally { rmSync(tmpDir, { recursive: true, force: true }); } }); }); describe("createKumikoServer — real Bun.build (buildClient)", () => { test("clientEntry without _buildBundle produces a JS bundle via Bun.build", async () => { const tmpDir = mkdtempSync(join(tmpdir(), "kumiko-real-build-")); const entry = join(tmpDir, "client.tsx"); // Minimal entry Bun.build can emit — no JSX, no imports. writeFileSync(entry, "export const ping = 1;\n"); // Empty dirs matching a glob — exercises expandWatchPatterns without // writing files (avoids process.exit(75) from bare .tsx events). mkdirSync(join(tmpDir, "pkg-a")); mkdirSync(join(tmpDir, "pkg-b")); try { handle = await createKumikoServer({ features: [probeFeature], port: 0, installSignalHandlers: false, clientEntry: entry, stylesheet: false, watchDirs: [join(tmpDir, "pkg-*")], }); const res = await handle.fetch(new Request("http://localhost/client.js")); expect(res.status).toBe(200); expect(res.headers.get("content-type")).toMatch(/application\/javascript/); const body = await res.text(); expect(body.length).toBeGreaterThan(0); expect(body).toMatch(/ping/); // Stop the watcher before teardown rmSync deletes clientEntry out from // under it — a bare "client.tsx" delete event classifies as "restart" // (classifyChange only special-cases endsWith("/client.tsx")) and // process.exit(75) kills the whole bun test runner mid-suite. await handle.stop(); handle = undefined; } finally { rmSync(tmpDir, { recursive: true, force: true }); } }); }); describe("createKumikoServer — hot-reload broadcast", () => { test("file change under web/ rebuilds and broadcasts SSE reload", async () => { const tmpDir = mkdtempSync(join(tmpdir(), "kumiko-watch-")); const entry = join(tmpDir, "client.tsx"); const webDir = join(tmpDir, "web"); mkdirSync(webDir); writeFileSync(entry, "export const ping = 1;\n"); let builds = 0; try { handle = await createKumikoServer({ features: [probeFeature], port: 0, installSignalHandlers: false, clientEntry: entry, stylesheet: false, // Only the entry dir is watched (no extra watchDirs) — a nested // web/page.tsx event arrives as "web/page.tsx" → hot-reload. // Watching web/ separately would fire bare "page.tsx" → restart // → process.exit(75) and kill the test runner. _buildBundle: async () => { builds += 1; return { js: `// build-${builds}`, map: "" }; }, }); const sseRes = await handle.fetch(new Request("http://localhost/_reload")); expect(sseRes.status).toBe(200); const reader = sseRes.body?.getReader(); expect(reader).toBeDefined(); if (!reader) return; await reader.read(); // drain connected comment const initialBuilds = builds; writeFileSync(join(webDir, "page.tsx"), "export const x = 1;\n"); const readAll = (async () => { for (;;) { const { value, done } = await reader.read(); if (done) return false; if (new TextDecoder().decode(value).includes("event: reload")) return true; } })(); const sawReload = await Promise.race([readAll, Bun.sleep(3000).then(() => false)]); await reader.cancel(); expect(builds).toBeGreaterThan(initialBuilds); expect(sawReload).toBe(true); const js = await handle.fetch(new Request("http://localhost/client.js")); expect(await js.text()).toMatch(/build-/); // Abort watchers before teardown rmSync can fire a restart event. await handle.stop(); handle = undefined; } finally { rmSync(tmpDir, { recursive: true, force: true }); } }); }); // kumiko-framework#3026: runDevApp gains resolvePageHead parity with // runProdApp — both now call the same @cosmicdrift/kumiko-headless/apex // resolveAndInjectPageHead, so a resolver produces the same tags in the // same order under dev as under prod (see run-prod-app-static-files.test.ts's // "resolver meta → og-tags appear" case for the prod-side equivalent). The // real 300ms-timeout path is covered once, in headless's own unit test for // resolveAndInjectPageHead (same shared function) — no need to re-run a slow // timeout wait here too. describe("createKumikoServer resolvePageHead", () => { test("resolver meta → head tags injected, same tags + order as runProdApp", async () => { handle = await createKumikoServer({ features: [probeFeature], port: 0, installSignalHandlers: false, resolvePageHead: async () => ({ title: "Vehicle X", description: "A car", ogImage: "https://x/i.png", }), }); const res = await handle.fetch(new Request("http://localhost/")); expect(res.status).toBe(200); const body = await res.text(); expect(body).not.toContain("Kumiko"); expect((body.match(/kumiko-page-head/g) ?? []).length).toBe(1); expect(body).toContain("Vehicle X"); expect(body).toContain(''); expect(body).toContain(''); expect(body).toContain(''); expect(body).toContain(''); const titleIdx = body.indexOf("Vehicle X"); const descIdx = body.indexOf(' { handle = await createKumikoServer({ features: [probeFeature], port: 0, installSignalHandlers: false, resolvePageHead: async () => { throw new Error("boom"); }, }); const res = await handle.fetch(new Request("http://localhost/")); expect(res.status).toBe(200); const body = await res.text(); expect(body).not.toContain("kumiko-page-head"); expect(body).toMatch(/
/); }); test("resolver returns null → 200 with the unchanged default shell", async () => { handle = await createKumikoServer({ features: [probeFeature], port: 0, installSignalHandlers: false, resolvePageHead: async () => null, }); const res = await handle.fetch(new Request("http://localhost/")); expect(res.status).toBe(200); expect(await res.text()).not.toContain("kumiko-page-head"); }); test("no resolvePageHead configured → no page-head marker in the response", async () => { const h = await boot(); const res = await h.fetch(new Request("http://localhost/")); expect(await res.text()).not.toContain("kumiko-page-head"); }); }); // kumiko-framework#2435: tryHonoFirst used to decide "no route matched" // purely from the status code — a matched httpRoute answering 404 on // purpose (e.g. default-deny reads) was indistinguishable from an // unregistered path and got silently rewritten to the SPA shell with // status 200. Drives the REAL fetch-handler end to end (real Postgres, // real Hono app via createKumikoServer) so the fix's actual wiring // (buildServer's app.notFound() marker + tryHonoFirst reading it) is // under test, not just the pure-function pin in try-hono-first.test.ts. const probeHttpRouteFeature = defineFeature("dev-server-probe-http-route", (r) => { r.httpRoute({ method: "GET", path: "/probe/:id", anonymous: true, handler: async (c) => { const id = c.req.param("id"); if (id === "missing") return c.text("not found", 404); return c.text(`probe:${id}`, 200); }, }); }); describe("createKumikoServer — tryHonoFirst 404-vs-router-miss (#2435)", () => { async function bootWithProbeRoute(): Promise { handle = await createKumikoServer({ features: [probeHttpRouteFeature], port: 0, installSignalHandlers: false, }); return handle; } test("gematchte httpRoute mit bewusster 404 bleibt 404 (keine SPA-Maskierung)", async () => { const h = await bootWithProbeRoute(); const res = await h.fetch(new Request("http://localhost/probe/missing")); expect(res.status).toBe(404); expect(res.headers.get("content-type") ?? "").not.toMatch(/text\/html/); expect(await res.text()).toBe("not found"); }); test("gematchte httpRoute mit Treffer antwortet normal (200)", async () => { const h = await bootWithProbeRoute(); const res = await h.fetch(new Request("http://localhost/probe/exists")); expect(res.status).toBe(200); expect(await res.text()).toBe("probe:exists"); }); test("unbekannte SPA-Route liefert weiterhin die SPA-Shell (200 HTML)", async () => { const h = await bootWithProbeRoute(); const res = await h.fetch(new Request("http://localhost/some/client-side/route")); expect(res.status).toBe(200); expect(res.headers.get("content-type")).toMatch(/text\/html/); expect(await res.text()).toMatch(/
/); }); test("kein Router-miss-Marker leakt an den Client", async () => { const h = await bootWithProbeRoute(); const missRes = await h.fetch(new Request("http://localhost/some/client-side/route")); const deniedRes = await h.fetch(new Request("http://localhost/probe/missing")); expect(missRes.headers.has(NO_ROUTE_MATCH_HEADER_NAME)).toBe(false); expect(deniedRes.headers.has(NO_ROUTE_MATCH_HEADER_NAME)).toBe(false); }); }); // A dotted GET path (e.g. /marketing/hero.png) used to fall through the SPA // catch-all's "no dot" filter straight to the API stack and 404 — works in // prod (buildStaticFallback's disk lookup under staticDir) but not dev, // which had no public/-serving at all. publicDir is process.cwd()-relative // (same App-Root convention as resolveStylesheet's src/styles.css lookup), // so these tests chdir into a fixture directory for the boot. describe("createKumikoServer — public/ static files", () => { test("GET on an existing file under public/ → 200, correct content-type + content", async () => { const tmpDir = realpathSync(mkdtempSync(join(tmpdir(), "kumiko-public-"))); const publicDir = join(tmpDir, "public"); mkdirSync(join(publicDir, "marketing"), { recursive: true }); writeFileSync(join(publicDir, "marketing", "hero.png"), "PNGDATA"); const cwdBefore = process.cwd(); process.chdir(tmpDir); try { handle = await createKumikoServer({ features: [probeFeature], port: 0, installSignalHandlers: false, }); const res = await handle.fetch(new Request("http://localhost/marketing/hero.png")); expect(res.status).toBe(200); expect(res.headers.get("content-type")).toBe("image/png"); expect(await res.text()).toBe("PNGDATA"); } finally { process.chdir(cwdBefore); rmSync(tmpDir, { recursive: true, force: true }); } }); test("traversal attempts never serve a file from outside public/", async () => { const tmpDir = realpathSync(mkdtempSync(join(tmpdir(), "kumiko-public-traversal-"))); const publicDir = join(tmpDir, "public"); mkdirSync(publicDir, { recursive: true }); // Secret sits as a SIBLING of public/ — a traversal that escapes // containment would read this instead of 404ing. writeFileSync(join(tmpDir, "secret.txt"), "TOP-SECRET"); const cwdBefore = process.cwd(); process.chdir(tmpDir); try { handle = await createKumikoServer({ features: [probeFeature], port: 0, installSignalHandlers: false, }); // WHATWG URL parsing already collapses a literal ".." segment (it's // delimited by a real "/"), so this pins the composed behavior — // request never reaches secret.txt — rather than the guard itself. const literal = await handle.fetch(new Request("http://localhost/../secret.txt")); expect(literal.status).not.toBe(200); // %2e%2e%2f is the actual vector the decode+resolve+containment guard // exists for: URL path-parsing only normalizes dot-segments split by a // literal "/", so an encoded slash survives untouched into pathname — // without the explicit decode in resolvePublicFilePath this would // resolve straight to tmpDir/secret.txt. const encoded = await handle.fetch(new Request("http://localhost/%2e%2e%2fsecret.txt")); expect(encoded.status).not.toBe(200); // Neither attempt leaked the secret's content through any other path // (e.g. as an error body). expect(await literal.clone().text()).not.toContain("TOP-SECRET"); expect(await encoded.clone().text()).not.toContain("TOP-SECRET"); } finally { process.chdir(cwdBefore); rmSync(tmpDir, { recursive: true, force: true }); } }); test("a dot-less path still hits the SPA catch-all, not the public/-file lookup", async () => { const tmpDir = realpathSync(mkdtempSync(join(tmpdir(), "kumiko-public-spa-"))); const publicDir = join(tmpDir, "public"); mkdirSync(publicDir, { recursive: true }); const cwdBefore = process.cwd(); process.chdir(tmpDir); try { handle = await createKumikoServer({ features: [probeFeature], port: 0, installSignalHandlers: false, }); const res = await handle.fetch(new Request("http://localhost/some/client-route")); expect(res.status).toBe(200); expect(res.headers.get("content-type")).toMatch(/text\/html/); expect(await res.text()).toMatch(/
/); } finally { process.chdir(cwdBefore); rmSync(tmpDir, { recursive: true, force: true }); } }); });