import { afterAll, beforeAll, describe, it } from "@std/testing/bdd"; import { expect } from "@std/expect"; import * as path from "@std/path"; import { cors } from "@hono/hono/cors"; import env from "@/env.ts"; import createApp, { createRouter } from "@/lib/create-app.ts"; import router from "@/routes/shell/shell.index.ts"; import { createShellStaticRouter, StaticResponse, } from "@/routes/shell/shell-static.ts"; if (env.ENV !== "test") { throw new Error("ENV must be 'test'"); } const app = createApp().route("/", router); const INDEX_HTML = "shellindex"; const APP_JS = "globalThis.__shell = true;\n"; const APP_CSS = "body { color: rebeccapurple; }\n"; const SENTINEL = "TOP_SECRET_OUTSIDE_ROOT"; let tempDir: string; let sentinelPath: string; // A static router mounted directly, used for the serving-behavior assertions. let staticApp: ReturnType; // The static router behind the same CORS middleware the shell wires up, mounted // on a fully composed app, used to assert middleware applies to a 200 document. let composedApp: ReturnType; // Global hooks must be registered before any global describe() below, so the // fixture setup for the static-router suites lives here at the top of the file. beforeAll(async () => { tempDir = await Deno.makeTempDir(); await Deno.writeTextFile(path.join(tempDir, "index.html"), INDEX_HTML); await Deno.writeTextFile(path.join(tempDir, "app.js"), APP_JS); await Deno.writeTextFile(path.join(tempDir, "app.css"), APP_CSS); // Sentinel lives outside the static root, in the temp dir's parent, so a // traversal request that escaped the root would expose it. sentinelPath = path.join(path.dirname(tempDir), "shell-sentinel.txt"); await Deno.writeTextFile(sentinelPath, SENTINEL); staticApp = createApp().route("/", createShellStaticRouter(tempDir)); const corsRouter = createRouter(); corsRouter.use( "/*", cors({ origin: "*", allowMethods: ["GET", "OPTIONS"] }), ); corsRouter.route("/", createShellStaticRouter(tempDir)); composedApp = createApp().route("/", corsRouter); }); afterAll(async () => { await Deno.remove(tempDir, { recursive: true }); await Deno.remove(sentinelPath); }); // The shell document must stay NON-cross-origin-isolated so that untrusted // patterns are never handed SharedArrayBuffer / Atomics or a high-resolution // clock. A page is cross-origin isolated only when it is served with BOTH // `Cross-Origin-Opener-Policy: same-origin` AND a require-corp/credentialless // `Cross-Origin-Embedder-Policy`. These tests fail loudly if a future change // flips the served document to that isolating combination. // // See docs/specs/sandboxing/cross-origin-isolation.md. describe("Shell cross-origin isolation posture", () => { it("does not serve the isolating COOP+COEP header combination", async () => { const response = await app.request("/"); // Drain the body so the response does not leak into the test runner. await response.text(); const coop = response.headers.get("Cross-Origin-Opener-Policy"); const coep = response.headers.get("Cross-Origin-Embedder-Policy"); const isolatingCoop = coop === "same-origin"; const isolatingCoep = coep === "require-corp" || coep === "credentialless"; // Isolation requires BOTH headers; assert we never emit both together. expect(isolatingCoop && isolatingCoep).toBe(false); }); it("pins COOP to a non-isolating value", async () => { const response = await app.request("/"); await response.text(); const coop = response.headers.get("Cross-Origin-Opener-Policy"); expect(coop).not.toBe("same-origin"); expect(coop).toBe("same-origin-allow-popups"); }); it("pins COEP to a non-isolating value", async () => { const response = await app.request("/"); await response.text(); const coep = response.headers.get("Cross-Origin-Embedder-Policy"); expect(coep).not.toBe("require-corp"); expect(coep).not.toBe("credentialless"); expect(coep).toBe("unsafe-none"); }); it("applies the non-isolating headers to nested paths too", async () => { // The posture must hold for every served path, not just the document root, // because any same-origin response can establish or reuse the page's agent // cluster. const response = await app.request("/assets/app.js"); await response.text(); expect(response.headers.get("Cross-Origin-Opener-Policy")).toBe( "same-origin-allow-popups", ); expect(response.headers.get("Cross-Origin-Embedder-Policy")).toBe( "unsafe-none", ); }); }); // The shell routes serve read-only content to any origin. These pin that // permissive CORS keeps working alongside the isolation headers, so a future // change to one does not silently disturb the other. describe("Shell route CORS", () => { it("allows a cross-origin GET with a wildcard origin", async () => { const response = await app.request("/", { headers: { Origin: "https://example.com" }, }); await response.text(); expect(response.headers.get("Access-Control-Allow-Origin")).toBe("*"); }); it("answers an OPTIONS preflight", async () => { const response = await app.request("/", { method: "OPTIONS", headers: { Origin: "https://example.com", "Access-Control-Request-Method": "GET", }, }); await response.text(); expect(response.status).toBe(204); expect(response.headers.get("Access-Control-Allow-Origin")).toBe("*"); }); }); // With no compiled frontend and no SHELL_URL proxy target — the unit-test // environment — the shell router answers with a 404 that tells an operator how // to bring the shell up. This guards that operator hint and its port. describe("Shell dev fallback without a compiled build or proxy", () => { it("returns 404 with a hint naming SHELL_URL and the shell port", async () => { const response = await app.request("/anything"); const body = await response.text(); expect(response.status).toBe(404); expect(/Shell app not available/.test(body)).toBe(true); expect(/SHELL_URL=http:\/\/localhost:\d+/.test(body)).toBe(true); }); }); describe("createShellStaticRouter", () => { it("serves index.html at the root with status 200, text/html, and an ETag", async () => { const response = await staticApp.request("/"); expect(response.status).toBe(200); expect(response.headers.get("Content-Type")).toBe("text/html"); expect(response.headers.get("ETag")).toBeTruthy(); expect(await response.text()).toBe(INDEX_HTML); }); it("serves an asset with a JS MIME type and its own ETag", async () => { const response = await staticApp.request("/app.js"); expect(response.status).toBe(200); expect(response.headers.get("Content-Type")).toBe("text/javascript"); expect(response.headers.get("ETag")).toBeTruthy(); expect(await response.text()).toBe(APP_JS); // The asset's ETag differs from index.html's (different content). const indexResponse = await staticApp.request("/"); expect(response.headers.get("ETag")).not.toBe( indexResponse.headers.get("ETag"), ); }); it("serves a CSS asset with the text/css MIME type", async () => { const response = await staticApp.request("/app.css"); expect(response.status).toBe(200); expect(response.headers.get("Content-Type")).toBe("text/css"); expect(await response.text()).toBe(APP_CSS); }); it("returns 304 with empty body and the same ETag for If-None-Match", async () => { const first = await staticApp.request("/app.js"); const etag = first.headers.get("ETag"); expect(etag).toBeTruthy(); const second = await staticApp.request("/app.js", { headers: { "If-None-Match": etag! }, }); expect(second.status).toBe(304); expect(await second.text()).toBe(""); expect(second.headers.get("ETag")).toBe(etag); }); it("falls back to index.html for a path with no matching file", async () => { const response = await staticApp.request("/notes/42"); expect(response.status).toBe(200); expect(response.headers.get("Content-Type")).toBe("text/html"); expect(await response.text()).toBe(INDEX_HTML); }); it("does not serve files outside the static root via traversal", async () => { // The request resolves outside the static root; the traversal guard (and // URL normalization) keep it from reaching the sentinel, so the client-side // routing fallback serves index.html instead. const response = await staticApp.request("/../shell-sentinel.txt"); expect(response.status).toBe(200); const body = await response.text(); expect(body).toBe(INDEX_HTML); expect(body).not.toContain(SENTINEL); }); it("returns a stable ETag across repeated requests for the same file", async () => { const first = await staticApp.request("/app.js"); const second = await staticApp.request("/app.js"); expect(first.headers.get("ETag")).toBe(second.headers.get("ETag")); }); }); describe("createShellStaticRouter behind composed app middleware", () => { it("applies the cross-origin middleware to a served 200 document", async () => { // Exercises middleware ordering on a real 200 document rather than only on // the dev 404 fallback: the served index.html must still carry the // cross-origin header the shell wires up ahead of the static router. const response = await composedApp.request("/"); expect(response.status).toBe(200); expect(await response.text()).toBe(INDEX_HTML); expect(response.headers.get("Access-Control-Allow-Origin")).toBe("*"); }); }); describe("StaticResponse", () => { const encoder = new TextEncoder(); // In-memory file set so StaticResponse can be exercised without touching disk. const files: Record = { "/root/index.html": encoder.encode(INDEX_HTML), "/root/app.js": encoder.encode(APP_JS), }; const deps = { readFile: (filePath: string) => { const content = files[filePath]; if (!content) return Promise.reject(new Deno.errors.NotFound(filePath)); return Promise.resolve(content); }, generateETag: (content: Uint8Array) => Promise.resolve(`"len-${content.byteLength}"`), }; it("derives MIME type and ETag from the file via injected deps", async () => { const res = await StaticResponse.fromFile("/root/app.js", deps); expect(res.mimeType).toBe("text/javascript"); expect(res.etag).toBe(`"len-${files["/root/app.js"].byteLength}"`); const response = res.response(); expect(response.status).toBe(200); expect(response.headers.get("Content-Type")).toBe("text/javascript"); expect(response.headers.get("ETag")).toBe(res.etag); expect(await response.text()).toBe(APP_JS); }); it("returns 304 with no body when the ETag matches If-None-Match", async () => { const res = await StaticResponse.fromFile("/root/index.html", deps); const response = res.response(res.etag); expect(response.status).toBe(304); expect(response.headers.get("ETag")).toBe(res.etag); expect(await response.text()).toBe(""); }); it("returns 200 when the If-None-Match ETag does not match", async () => { const res = await StaticResponse.fromFile("/root/index.html", deps); const response = res.response('"some-other-etag"'); expect(response.status).toBe(200); expect(await response.text()).toBe(INDEX_HTML); }); });