// Tests for the Next-style `opengraph-image.png` / `twitter-image.png` // file convention wired up in `applyAutoSocialImages`. import { afterEach, describe, expect, test } from "bun:test"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { applyAutoIcons, applyAutoSocialImages, renderMetadata, buildHydrationTail, errorDigest, resolveOrigin, isSafeRedirect, asRouteControl, PylonRouteControl, finalizeHeaders, escapeScriptJson, makeResponseController, makeReadTrackingProxy, makeRevocableReadTrackingProxy, jsonClone, } from "./ssr-runtime"; describe("resolveOrigin — Host-header allowlist (cache-poisoning fence)", () => { const publicUrl = "https://www.notbehind.com"; test("trusts the Host only when it's the configured public origin", () => { expect(resolveOrigin({ host: "www.notbehind.com", publicUrl })).toBe( "https://www.notbehind.com", ); }); test("an attacker Host falls back to the public origin (no poisoning)", () => { // The crux: Host: evil.com must NOT produce https://evil.com (which would // be baked into og:image + teed into the shared ISR/CDN cache). expect(resolveOrigin({ host: "evil.com", publicUrl })).toBe( "https://www.notbehind.com", ); }); test("explicit PYLON_TRUSTED_HOSTS + canonical host are honored", () => { expect( resolveOrigin({ host: "cdn.notbehind.com", publicUrl, trustedHostsCsv: "cdn.notbehind.com, x.com" }), ).toBe("https://cdn.notbehind.com"); expect(resolveOrigin({ host: "notbehind.com", publicUrl, canonicalHost: "notbehind.com" })).toBe( "https://notbehind.com", ); }); test("loopback is trusted for dev; X-Forwarded-Proto honored only there", () => { expect(resolveOrigin({ host: "localhost:4321" })).toBe("http://localhost:4321"); // Attacker downgrade attempt on an untrusted host is ignored (falls back). expect( resolveOrigin({ host: "evil.com", forwardedProto: "http", publicUrl }), ).toBe("https://www.notbehind.com"); }); test("no public origin + untrusted host → empty (relative, never poisoned)", () => { expect(resolveOrigin({ host: "evil.com" })).toBe(""); }); test("off-loopback never honors X-Forwarded-Proto (no downgrade poisoning)", () => { // Even on a TRUSTED host, a client-supplied proto must not change the // absolute origin — it feeds og:image/canonical and is cache-keyed only by // host, so honoring `http` would downgrade the cached URL for everyone. // Off-loopback is ALWAYS https. expect( resolveOrigin({ host: "www.notbehind.com", publicUrl, forwardedProto: "http" }), ).toBe("https://www.notbehind.com"); expect( resolveOrigin({ host: "www.notbehind.com", publicUrl, forwardedProto: "javascript:alert(1)", }), ).toBe("https://www.notbehind.com"); // Loopback (dev) may be http; an explicit https there is honored. expect(resolveOrigin({ host: "localhost:4321", forwardedProto: "http" })).toBe( "http://localhost:4321", ); expect(resolveOrigin({ host: "localhost:4321", forwardedProto: "https" })).toBe( "https://localhost:4321", ); }); }); describe("reserved x-pylon-* header namespace (cache-proof forgery fence)", () => { test("response.setHeader() rejects the reserved x-pylon-* namespace", () => { const state = { status: 200, headers: {} as Record, cookies: [] as string[], }; const res = makeResponseController(state); // Forging the #277 cache proof from userland must throw, not silently set it. expect(() => res.setHeader("x-pylon-cacheable", "300")).toThrow(/reserved/i); expect(() => res.setHeader("X-Pylon-Anything", "1")).toThrow(/reserved/i); // An ordinary header still works. res.setHeader("x-custom", "ok"); expect(state.headers["x-custom"]).toBe("ok"); }); test("finalizeHeaders: x-pylon-* survives ONLY from the trusted internal channel", () => { // The #277 proof must come from the 3rd `internal` arg. A page-set header // (state.headers) OR a route-handler header (the 2nd `extra` arg, which // ssr-form-runtime fills from user-returned headers) is stripped — so // userland can't forge the host-side cache verdict through ANY path. const state = { status: 200, headers: { "x-pylon-cacheable": "999", "x-keep": "yes" } as Record, cookies: [] as string[], }; const out = finalizeHeaders( state, { "x-pylon-cacheable": "888", "x-extra": "e" }, // untrusted extra → stripped { "x-pylon-cacheable": "60" }, // trusted internal → kept ); expect(out["x-pylon-cacheable"]).toBe("60"); // only the trusted value expect(out["x-keep"]).toBe("yes"); expect(out["x-extra"]).toBe("e"); // a non-reserved extra header still merges // No internal proof → NO x-pylon-* survives, from page headers OR extra. const out2 = finalizeHeaders( { status: 200, headers: { "x-pylon-cacheable": "999" }, cookies: [] as string[] }, { "x-pylon-cacheable": "777" }, ); expect(out2["x-pylon-cacheable"]).toBeUndefined(); }); test("finalizeHeaders: 3xx open-redirect guard on setHeader('location') / returned headers", () => { const st = (status: number, location: string) => ({ status, headers: { location } as Record, cookies: [] as string[], }); // An off-site absolute Location on a 3xx (set via setHeader or a route // handler's returned headers) is refused — the same rule redirect() applies. expect(() => finalizeHeaders(st(302, "https://evil.example/steal"))).toThrow( /open redirect/i, ); // Protocol-relative `//host` is the classic bypass — also refused. expect(() => finalizeHeaders(st(307, "//evil.example"))).toThrow(/open redirect/i); // A backslash variant that browsers normalize cross-origin — refused. expect(() => finalizeHeaders(st(303, "/\\evil.example"))).toThrow(/open redirect/i); // A same-site relative path is fine and passes through unchanged. expect(finalizeHeaders(st(302, "/dashboard"))["location"]).toBe("/dashboard"); // Case-insensitive header name is still caught (host lowercases, but guard // must not depend on that). expect(() => finalizeHeaders({ status: 302, headers: { Location: "https://evil.example" } as Record, cookies: [], }), ).toThrow(/open redirect/i); // NON-3xx status → `location` is just a header, not a redirect: no guard. expect( finalizeHeaders(st(200, "https://evil.example"))["location"], ).toBe("https://evil.example"); // A raw `route.ts` GET returns its OWN status via the `effectiveStatus` // arg — an off-site Location there is still refused even though // `state.status` is 200. expect(() => finalizeHeaders( { status: 200, headers: {}, cookies: [] }, { location: "https://evil.example" }, undefined, 302, ), ).toThrow(/open redirect/i); }); test("makeReadTrackingProxy trips on get / in / Object.keys / descriptor / spread", () => { const probes: Array<(o: any) => unknown> = [ (o) => o.host, (o) => "host" in o, (o) => Object.keys(o), (o) => Object.getOwnPropertyDescriptor(o, "host"), (o) => ({ ...o }), ]; for (const probe of probes) { let touched = false; const p = makeReadTrackingProxy({ host: "x" }, () => { touched = true; }); probe(p); expect(touched).toBe(true); // a bare `get` trap would miss in/keys } // No observation → never touched. let t = false; makeReadTrackingProxy({ host: "x" }, () => { t = true; }); expect(t).toBe(false); }); test("revocable proxy throws after revoke (stale module-stashed props fence)", () => { // P0 (codex 2026-06-28): a page that stashes `props` (or `props.auth`) in // module-level state and reads it on a LATER render must not silently read a // prior request's identity without tripping THIS render's read-tracking. The // render path revokes each per-request proxy when the render ends, so any // retained reference throws on access — fail-closed. const { proxy, revoke } = makeRevocableReadTrackingProxy( { user_id: "alice" }, () => {}, ); expect((proxy as any).user_id).toBe("alice"); // live during the render revoke(); // A stashed reference, read on a later render: expect(() => (proxy as any).user_id).toThrow(); expect(() => "user_id" in proxy).toThrow(); expect(() => Object.keys(proxy)).toThrow(); }); test("jsonClone snapshot is independent of later source mutation (bucket params fence)", () => { // The bucket-tail snapshot (bucketTailBase) is jsonClone'd at render START. // The defense rests on the clone being decoupled from the live params object: // a page mutating a NESTED field afterwards (props.searchParams.leak = // props.auth) can't reach the already-captured snapshot. const source: any = { id: "a", nested: { keep: 1 } }; const snap = jsonClone(source); // Simulate the page smuggling identity in after the snapshot was taken. source.leak = { user_id: "alice" }; source.nested.keep = 999; expect(snap).toEqual({ id: "a", nested: { keep: 1 } }); expect((snap as any).leak).toBeUndefined(); // And it strips non-JSON values (a proxy aliased in would serialize as its // target via JSON, but a function/symbol is dropped entirely). const stripped = jsonClone({ ok: "v", fn: () => 1, sym: Symbol("x") } as any); expect(stripped).toEqual({ ok: "v" }); }); }); // Pull the JSON out of the `__PYLON_DATA__` &y", }, }); const scripts: any[] = frag.children.filter((k: any) => k.type === "script"); expect(scripts.length).toBe(1); expect(scripts[0].props.type).toBe("application/ld+json"); expect(scripts[0].props["data-pylon-meta"]).toBe(""); const body = scripts[0].children[0] as string; // Breakout chars must be \u-escaped — the payload can't contain a literal // ``, `<`, `>`, or `&`. expect(body).not.toContain(""); expect(body).not.toMatch(/[<>&]/); expect(body).toContain("\\u003c"); // …and it's still valid structured data once a parser decodes it. const parsed = JSON.parse(body); expect(parsed["@type"]).toBe("Organization"); expect(parsed.name).toBe("Pylon &y"); }); test("JSON-LD array emits one script per item", () => { const frag = renderMetadata(fakeReact, { jsonLd: [{ "@type": "A" }, { "@type": "B" }], }); const scripts: any[] = frag.children.filter((k: any) => k.type === "script"); expect(scripts.map((s) => JSON.parse(s.children[0])["@type"])).toEqual(["A", "B"]); }); test("emits the extended SEO/social tags", () => { const frag = renderMetadata(fakeReact, { authors: ["Ada", "Grace"], themeColor: "#0b5fff", openGraph: { locale: "en_US", images: [{ url: "https://x.test/a.png", width: 1200, height: 630, alt: "A" }], article: { author: "Ada", publishedTime: "2026-01-01", tags: ["ai", "ssr"] }, }, twitter: { card: "summary", site: "@pylon", creator: "@ada", imageAlt: "card" }, alternates: { languages: { "en-US": "https://x.test/en", "fr-FR": "https://x.test/fr" }, }, }); const metas: any[] = frag.children.filter((k: any) => k.type === "meta"); const links: any[] = frag.children.filter((k: any) => k.type === "link"); const find = (sel: (m: any) => boolean) => metas.find(sel); expect( metas.filter((m) => m.props.name === "author").map((m) => m.props.content), ).toEqual(["Ada", "Grace"]); expect(find((m) => m.props.name === "theme-color")?.props.content).toBe("#0b5fff"); expect(find((m) => m.props.property === "og:locale")?.props.content).toBe("en_US"); expect( find((m) => m.props.property === "og:image" && m.props.content === "https://x.test/a.png"), ).toBeDefined(); expect(find((m) => m.props.property === "article:author")?.props.content).toBe("Ada"); expect(find((m) => m.props.property === "article:published_time")?.props.content).toBe( "2026-01-01", ); expect( metas.filter((m) => m.props.property === "article:tag").map((m) => m.props.content), ).toEqual(["ai", "ssr"]); expect(find((m) => m.props.name === "twitter:site")?.props.content).toBe("@pylon"); expect(find((m) => m.props.name === "twitter:creator")?.props.content).toBe("@ada"); expect(find((m) => m.props.name === "twitter:image:alt")?.props.content).toBe("card"); const alts = links.filter((l) => l.props.rel === "alternate"); expect(alts.map((l) => [l.props.hrefLang, l.props.href])).toEqual([ ["en-US", "https://x.test/en"], ["fr-FR", "https://x.test/fr"], ]); // Every emitted meta/link still carries the nav-swap marker. for (const el of [...metas, ...links]) { expect(el.props["data-pylon-meta"]).toBe(""); } }); }); describe("buildHydrationTail — boundary hydration (#279) + strip (#270)", () => { const manifestRoute = { file: "app__error-x.js", imports: [], css: [] }; test("error boundary serializes {message,digest}; raw error/stack/cookies NEVER cross the wire", () => { const tail = buildHydrationTail({ component: "app/error", layouts: ["app/layout"], props: { url: "/boom", auth: { user_id: "u1", is_admin: false, tenant_id: null, roles: [] }, // live, non-serializable + sensitive handles that MUST be stripped: error: new Error("DB exploded at secretHost:5432"), serverData: { get() {} }, response: { setStatus() {} }, reset: () => {}, headers: { cookie: "pylon_session=SUPERSECRET" }, cookies: { pylon_session: "SUPERSECRET" }, }, ssrData: {}, manifestRoute, publicPrefix: "/_pylon/build/", manifestErr: null, kind: "error", errorForClient: { message: "Something went wrong", digest: "deadbeef" }, }); const data = extractPylonData(tail); expect(data.kind).toBe("error"); expect(data.component).toBe("app/error"); // The client error.tsx gets ONLY the safe projection. expect(data.props.error).toEqual({ message: "Something went wrong", digest: "deadbeef", }); // Live handles stripped; headers/cookies emptied (shape preserved). expect(data.props.serverData).toBeUndefined(); expect(data.props.response).toBeUndefined(); expect(data.props.reset).toBeUndefined(); expect(data.props.headers).toEqual({}); expect(data.props.cookies).toEqual({}); // The session cookie + the raw error message/stack are nowhere in the blob. expect(tail).not.toContain("SUPERSECRET"); expect(tail).not.toContain("secretHost"); expect(tail).not.toContain("stack"); // The per-boundary entry script is appended. expect(tail).toContain('src="/_pylon/build/app__error-x.js"'); }); test("not-found boundary carries kind but no error/reset", () => { const tail = buildHydrationTail({ component: "app/not-found", layouts: [], props: { url: "/missing", auth: {}, response: {}, serverData: {} }, ssrData: {}, manifestRoute, publicPrefix: "/_pylon/build/", manifestErr: null, kind: "not-found", }); const data = extractPylonData(tail); expect(data.kind).toBe("not-found"); expect(data.props.error).toBeUndefined(); expect(data.props.reset).toBeUndefined(); }); test("a page (no kind) hydrates without a kind field", () => { const tail = buildHydrationTail({ component: "app/page", layouts: ["app/layout"], props: { url: "/", auth: {}, response: {}, serverData: {} }, ssrData: { "list:Note": [] }, manifestRoute, publicPrefix: "/_pylon/build/", manifestErr: null, }); const data = extractPylonData(tail); expect(data.kind).toBeUndefined(); expect(data.ssrData).toEqual({ "list:Note": [] }); }); test("no manifest entry → hydration-disabled warning, not an entry script", () => { const tail = buildHydrationTail({ component: "app/page", layouts: [], props: { url: "/" }, ssrData: {}, manifestRoute: null, publicPrefix: "/_pylon/build/", manifestErr: "manifest crashed", }); expect(tail).toContain("hydration disabled"); expect(tail).not.toContain('type="module" src='); }); test("errorDigest is deterministic, stack-free, 8 hex chars", () => { const e = new Error("boom"); const d1 = errorDigest(e); const d2 = errorDigest(e); expect(d1).toBe(d2); expect(d1).toMatch(/^[0-9a-f]{8}$/); // A different error yields a different digest. expect(errorDigest(new Error("other"))).not.toBe(d1); }); }); describe("asRouteControl — route-control normalization (redirect/notFound)", () => { test("passes the framework's own PylonRouteControl straight through", () => { const redirect = new PylonRouteControl("redirect"); redirect.url = "/login"; redirect.redirectStatus = 302; expect(asRouteControl(redirect)).toBe(redirect); const nf = new PylonRouteControl("notFound"); expect(asRouteControl(nf)).toBe(nf); }); test("recognizes @pylonsync/react's branded notFound() error by digest", () => { // The cross-package contract: `notFound()` from @pylonsync/react throws an // error stamped `digest === "PYLON_NOT_FOUND"`. The runtime duck-types on // that brand (no import of the React class) and turns it into a notFound // control → a real 404 + nearest not-found.tsx. If this regresses, a // server page calling notFound() would 500 instead of 404. const reactNotFound = Object.assign(new Error("PYLON_NOT_FOUND"), { digest: "PYLON_NOT_FOUND", }); const ctrl = asRouteControl(reactNotFound); expect(ctrl).not.toBeNull(); expect(ctrl?.kind).toBe("notFound"); }); test("does NOT swallow ordinary errors as a 404 (fails open is forbidden)", () => { // The critical safety property: a real render error must fall through to // the error.tsx / 500 path, never be silently masked as a not-found. expect(asRouteControl(new Error("boom"))).toBeNull(); expect(asRouteControl(new TypeError("nope"))).toBeNull(); expect(asRouteControl({ digest: "SOME_OTHER_DIGEST" })).toBeNull(); expect(asRouteControl({ digest: 42 })).toBeNull(); expect(asRouteControl(null)).toBeNull(); expect(asRouteControl(undefined)).toBeNull(); expect(asRouteControl("PYLON_NOT_FOUND")).toBeNull(); // a bare string, not an error }); }); describe("isSafeRedirect — open-redirect guard for response.redirect()", () => { const trusted = { publicUrl: "https://app.example.com", trustedHostsCsv: "checkout.stripe.com, other.example.com", }; test("allows same-site relative paths", () => { expect(isSafeRedirect("/", trusted)).toBe(true); expect(isSafeRedirect("/dashboard", trusted)).toBe(true); expect(isSafeRedirect("/a/b?x=1#h", trusted)).toBe(true); // %2F in a path stays a path segment (browsers don't change origin on it). expect(isSafeRedirect("/%2F%2Fevil.com", trusted)).toBe(true); }); test("rejects the classic open-redirect vectors", () => { expect(isSafeRedirect("//evil.com", trusted)).toBe(false); // protocol-relative expect(isSafeRedirect("/\\evil.com", trusted)).toBe(false); // backslash trick expect(isSafeRedirect("\\/evil.com", trusted)).toBe(false); expect(isSafeRedirect("https://evil.com", trusted)).toBe(false); // other origin expect(isSafeRedirect("https://evil.com/path", trusted)).toBe(false); expect(isSafeRedirect("javascript:alert(1)", trusted)).toBe(false); expect(isSafeRedirect("data:text/html,x", trusted)).toBe(false); expect(isSafeRedirect("dashboard", trusted)).toBe(false); // bare-relative → reject }); test("allows absolute URLs to a trusted host (public origin / PYLON_TRUSTED_HOSTS / loopback)", () => { expect(isSafeRedirect("https://app.example.com/next", trusted)).toBe(true); expect(isSafeRedirect("https://checkout.stripe.com/pay/abc", trusted)).toBe(true); expect(isSafeRedirect("http://localhost:3000/x", trusted)).toBe(true); expect(isSafeRedirect("http://127.0.0.1/x", trusted)).toBe(true); }); test("with no trusted config, only relative paths + loopback are allowed", () => { expect(isSafeRedirect("/ok", {})).toBe(true); expect(isSafeRedirect("http://localhost/x", {})).toBe(true); expect(isSafeRedirect("https://app.example.com/x", {})).toBe(false); }); }); describe("script-escaping + secure cookies", () => { test("escapeScriptJson neutralizes a breakout", () => { const out = escapeScriptJson(JSON.stringify("")); expect(out).not.toContain("<"); expect(out).toContain("\\u003c"); }); test("buildHydrationTail fallback warn script escapes manifestErr", () => { const tail = buildHydrationTail({ component: "app/x/page", layouts: [], props: {}, ssrData: {}, manifestRoute: null, publicPrefix: "/_pylon/build/", manifestErr: 'no entry for ""', }); const warn = tail.slice(tail.indexOf("console.warn")); // The executable fallback must escape the error — no raw injected tag. expect(warn).not.toContain("