chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,392 @@
|
||||
/**
|
||||
* T-07 — embed proxy route handler tests.
|
||||
*
|
||||
* Tests GET/POST/PUT/PATCH/DELETE handlers in
|
||||
* /dashboard/providers/services/[name]/embed/[[...path]]/route.ts.
|
||||
*
|
||||
* Uses registerSupervisor to inject fake supervisors (ESM live bindings
|
||||
* can't be reassigned, so direct module patching is not possible).
|
||||
*/
|
||||
|
||||
import { describe, it, afterEach } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { registerSupervisor, unregisterSupervisor } from "../../../src/lib/services/registry.ts";
|
||||
import type { ServiceSupervisor } from "../../../src/lib/services/ServiceSupervisor.ts";
|
||||
import {
|
||||
GET,
|
||||
POST,
|
||||
PUT,
|
||||
PATCH,
|
||||
DELETE,
|
||||
HEAD,
|
||||
OPTIONS,
|
||||
} from "../../../src/app/(dashboard)/dashboard/providers/services/[name]/embed/[[...path]]/route.ts";
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
unregisterSupervisor("9router");
|
||||
});
|
||||
|
||||
// ─── helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeFakeParams(
|
||||
name: string,
|
||||
path: string[]
|
||||
): { params: Promise<{ name: string; path: string[] }> } {
|
||||
return { params: Promise.resolve({ name, path }) };
|
||||
}
|
||||
|
||||
function registerFake(state: string, port: number): void {
|
||||
const fake = {
|
||||
getStatus: () => ({
|
||||
tool: "9router",
|
||||
state,
|
||||
port,
|
||||
pid: null,
|
||||
health: "unknown" as const,
|
||||
startedAt: null,
|
||||
lastError: null,
|
||||
}),
|
||||
};
|
||||
registerSupervisor(fake as unknown as ServiceSupervisor);
|
||||
}
|
||||
|
||||
// ─── tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("embed proxy route", () => {
|
||||
it("returns 404 for unknown service", async () => {
|
||||
// No supervisor registered — getSupervisor returns null.
|
||||
const req = new Request("http://localhost/dashboard/providers/services/unknown/embed/");
|
||||
const resp = await GET(req, makeFakeParams("unknown", []));
|
||||
assert.equal(resp.status, 404);
|
||||
});
|
||||
|
||||
it("returns 503 when service exists but is not running", async () => {
|
||||
registerFake("stopped", 20130);
|
||||
const req = new Request("http://localhost/dashboard/providers/services/9router/embed/");
|
||||
const resp = await GET(req, makeFakeParams("9router", []));
|
||||
assert.equal(resp.status, 503);
|
||||
});
|
||||
|
||||
it("proxies GET to the upstream service", async () => {
|
||||
registerFake("running", 20130);
|
||||
let capturedUrl = "";
|
||||
globalThis.fetch = async (input: string | URL | Request) => {
|
||||
capturedUrl = String(input);
|
||||
return new Response("<html>9router UI</html>", {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/html" },
|
||||
});
|
||||
};
|
||||
|
||||
const req = new Request(
|
||||
"http://localhost/dashboard/providers/services/9router/embed/ui/index.html"
|
||||
);
|
||||
const resp = await GET(req, makeFakeParams("9router", ["ui", "index.html"]));
|
||||
|
||||
assert.equal(resp.status, 200);
|
||||
assert.ok(capturedUrl.startsWith("http://127.0.0.1:20130/ui/index.html"));
|
||||
assert.ok((await resp.text()).includes("9router UI"));
|
||||
});
|
||||
|
||||
it("forwards query string to upstream", async () => {
|
||||
registerFake("running", 20130);
|
||||
let capturedUrl = "";
|
||||
globalThis.fetch = async (input: string | URL | Request) => {
|
||||
capturedUrl = String(input);
|
||||
return new Response("{}", { status: 200 });
|
||||
};
|
||||
|
||||
const req = new Request(
|
||||
"http://localhost/dashboard/providers/services/9router/embed/api/models?page=2"
|
||||
);
|
||||
await GET(req, makeFakeParams("9router", ["api", "models"]));
|
||||
assert.ok(capturedUrl.includes("?page=2"));
|
||||
});
|
||||
|
||||
it("proxies POST and forwards body", async () => {
|
||||
registerFake("running", 20130);
|
||||
let capturedMethod = "";
|
||||
globalThis.fetch = async (_input: string | URL | Request, init?: RequestInit) => {
|
||||
capturedMethod = init?.method ?? "UNKNOWN";
|
||||
return new Response('{"ok":true}', { status: 200 });
|
||||
};
|
||||
|
||||
const req = new Request("http://localhost/dashboard/providers/services/9router/embed/api/v1", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ test: 1 }),
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
const resp = await POST(req, makeFakeParams("9router", ["api", "v1"]));
|
||||
assert.equal(resp.status, 200);
|
||||
assert.equal(capturedMethod, "POST");
|
||||
});
|
||||
|
||||
it("strips hop-by-hop headers from the upstream response", async () => {
|
||||
registerFake("running", 20130);
|
||||
globalThis.fetch = async () =>
|
||||
new Response("body", {
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "text/plain",
|
||||
"transfer-encoding": "chunked",
|
||||
connection: "keep-alive",
|
||||
"x-custom": "kept",
|
||||
},
|
||||
});
|
||||
|
||||
const req = new Request("http://localhost/dashboard/providers/services/9router/embed/");
|
||||
const resp = await GET(req, makeFakeParams("9router", []));
|
||||
assert.equal(resp.headers.get("x-custom"), "kept");
|
||||
assert.equal(resp.headers.get("transfer-encoding"), null);
|
||||
assert.equal(resp.headers.get("connection"), null);
|
||||
});
|
||||
|
||||
it("returns 502 on upstream network error", async () => {
|
||||
registerFake("running", 20130);
|
||||
globalThis.fetch = async () => {
|
||||
throw new Error("ECONNREFUSED");
|
||||
};
|
||||
|
||||
const req = new Request("http://localhost/dashboard/providers/services/9router/embed/");
|
||||
const resp = await GET(req, makeFakeParams("9router", []));
|
||||
assert.equal(resp.status, 502);
|
||||
});
|
||||
|
||||
it("PUT, PATCH, DELETE are handled", async () => {
|
||||
registerFake("running", 20130);
|
||||
globalThis.fetch = async (_input: string | URL | Request, init?: RequestInit) =>
|
||||
new Response(null, { status: 204 });
|
||||
|
||||
const params = makeFakeParams("9router", ["resource", "1"]);
|
||||
const reqUrl = "http://localhost/dashboard/providers/services/9router/embed/resource/1";
|
||||
|
||||
assert.equal((await PUT(new Request(reqUrl, { method: "PUT" }), params)).status, 204);
|
||||
assert.equal((await PATCH(new Request(reqUrl, { method: "PATCH" }), params)).status, 204);
|
||||
assert.equal((await DELETE(new Request(reqUrl, { method: "DELETE" }), params)).status, 204);
|
||||
});
|
||||
|
||||
// ─── G-05: cookie/auth strip + response header strip + HTML rewrite ──────────
|
||||
|
||||
it("G-05: strips cookie header before forwarding to upstream", async () => {
|
||||
registerFake("running", 20130);
|
||||
let capturedHeaders: Record<string, string> = {};
|
||||
globalThis.fetch = async (_input: string | URL | Request, init?: RequestInit) => {
|
||||
for (const [k, v] of new Headers(init?.headers as HeadersInit).entries()) {
|
||||
capturedHeaders[k.toLowerCase()] = v;
|
||||
}
|
||||
return new Response("ok", { status: 200 });
|
||||
};
|
||||
|
||||
const req = new Request("http://localhost/dashboard/providers/services/9router/embed/", {
|
||||
headers: { cookie: "session=abc123; jwt=secret" },
|
||||
});
|
||||
await GET(req, makeFakeParams("9router", []));
|
||||
assert.equal(capturedHeaders["cookie"], undefined, "cookie must not be forwarded upstream");
|
||||
});
|
||||
|
||||
it("G-05: sets Authorization: Bearer on upstream request", async () => {
|
||||
registerFake("running", 20130);
|
||||
let capturedAuth: string | undefined;
|
||||
globalThis.fetch = async (_input: string | URL | Request, init?: RequestInit) => {
|
||||
capturedAuth = new Headers(init?.headers as HeadersInit).get("authorization") ?? undefined;
|
||||
return new Response("ok", { status: 200 });
|
||||
};
|
||||
|
||||
const req = new Request("http://localhost/dashboard/providers/services/9router/embed/");
|
||||
await GET(req, makeFakeParams("9router", []));
|
||||
assert.ok(capturedAuth, "authorization header must be present");
|
||||
assert.ok(capturedAuth!.startsWith("Bearer "), "authorization must be a Bearer token");
|
||||
});
|
||||
|
||||
it("G-05: strips client Authorization before forwarding, injects service key instead", async () => {
|
||||
registerFake("running", 20130);
|
||||
let capturedAuth: string | undefined;
|
||||
globalThis.fetch = async (_input: string | URL | Request, init?: RequestInit) => {
|
||||
capturedAuth = new Headers(init?.headers as HeadersInit).get("authorization") ?? undefined;
|
||||
return new Response("ok", { status: 200 });
|
||||
};
|
||||
|
||||
const req = new Request("http://localhost/dashboard/providers/services/9router/embed/", {
|
||||
headers: { authorization: "Bearer client-token-should-not-leak" },
|
||||
});
|
||||
await GET(req, makeFakeParams("9router", []));
|
||||
assert.ok(capturedAuth, "authorization header must be set");
|
||||
assert.notEqual(
|
||||
capturedAuth,
|
||||
"Bearer client-token-should-not-leak",
|
||||
"client authorization must not be forwarded as-is"
|
||||
);
|
||||
});
|
||||
|
||||
it("G-05: strips set-cookie from upstream response", async () => {
|
||||
registerFake("running", 20130);
|
||||
globalThis.fetch = async () =>
|
||||
new Response("ok", {
|
||||
status: 200,
|
||||
headers: { "set-cookie": "session=upstream; Path=/", "content-type": "text/plain" },
|
||||
});
|
||||
|
||||
const resp = await GET(
|
||||
new Request("http://localhost/dashboard/providers/services/9router/embed/"),
|
||||
makeFakeParams("9router", [])
|
||||
);
|
||||
assert.equal(resp.headers.get("set-cookie"), null, "set-cookie must be stripped from response");
|
||||
});
|
||||
|
||||
it("G-05: strips x-frame-options from upstream response", async () => {
|
||||
registerFake("running", 20130);
|
||||
globalThis.fetch = async () =>
|
||||
new Response("ok", {
|
||||
status: 200,
|
||||
headers: { "x-frame-options": "DENY", "content-type": "text/plain" },
|
||||
});
|
||||
|
||||
const resp = await GET(
|
||||
new Request("http://localhost/dashboard/providers/services/9router/embed/"),
|
||||
makeFakeParams("9router", [])
|
||||
);
|
||||
assert.equal(resp.headers.get("x-frame-options"), null, "x-frame-options must be stripped");
|
||||
});
|
||||
|
||||
it("G-05: strips content-security-policy from upstream response", async () => {
|
||||
registerFake("running", 20130);
|
||||
globalThis.fetch = async () =>
|
||||
new Response("ok", {
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-security-policy": "default-src 'none'",
|
||||
"content-type": "text/plain",
|
||||
},
|
||||
});
|
||||
|
||||
const resp = await GET(
|
||||
new Request("http://localhost/dashboard/providers/services/9router/embed/"),
|
||||
makeFakeParams("9router", [])
|
||||
);
|
||||
assert.equal(
|
||||
resp.headers.get("content-security-policy"),
|
||||
null,
|
||||
"content-security-policy must be stripped"
|
||||
);
|
||||
});
|
||||
|
||||
it("G-05: strips cross-origin-* headers from upstream response", async () => {
|
||||
registerFake("running", 20130);
|
||||
globalThis.fetch = async () =>
|
||||
new Response("ok", {
|
||||
status: 200,
|
||||
headers: {
|
||||
"cross-origin-embedder-policy": "require-corp",
|
||||
"cross-origin-opener-policy": "same-origin",
|
||||
"cross-origin-resource-policy": "same-site",
|
||||
"content-type": "text/plain",
|
||||
},
|
||||
});
|
||||
|
||||
const resp = await GET(
|
||||
new Request("http://localhost/dashboard/providers/services/9router/embed/"),
|
||||
makeFakeParams("9router", [])
|
||||
);
|
||||
assert.equal(
|
||||
resp.headers.get("cross-origin-embedder-policy"),
|
||||
null,
|
||||
"cross-origin-embedder-policy must be stripped"
|
||||
);
|
||||
assert.equal(
|
||||
resp.headers.get("cross-origin-opener-policy"),
|
||||
null,
|
||||
"cross-origin-opener-policy must be stripped"
|
||||
);
|
||||
assert.equal(
|
||||
resp.headers.get("cross-origin-resource-policy"),
|
||||
null,
|
||||
"cross-origin-resource-policy must be stripped"
|
||||
);
|
||||
});
|
||||
|
||||
it("G-05: HTML response is rewritten — contains injected <base href>", async () => {
|
||||
registerFake("running", 20130);
|
||||
globalThis.fetch = async () =>
|
||||
new Response('<html><head></head><body><a href="/dashboard">link</a></body></html>', {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/html; charset=utf-8" },
|
||||
});
|
||||
|
||||
const resp = await GET(
|
||||
new Request("http://localhost/dashboard/providers/services/9router/embed/"),
|
||||
makeFakeParams("9router", [])
|
||||
);
|
||||
const body = await resp.text();
|
||||
assert.ok(
|
||||
body.includes('<base href="/dashboard/providers/services/9router/embed/">'),
|
||||
`Expected <base href> in rewritten HTML. Got: ${body.substring(0, 200)}`
|
||||
);
|
||||
});
|
||||
|
||||
it("G-05: HTML response rewrites path-absolute links to go through proxy", async () => {
|
||||
registerFake("running", 20130);
|
||||
globalThis.fetch = async () =>
|
||||
new Response('<html><head></head><body><a href="/ui/page">x</a></body></html>', {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/html" },
|
||||
});
|
||||
|
||||
const resp = await GET(
|
||||
new Request("http://localhost/dashboard/providers/services/9router/embed/"),
|
||||
makeFakeParams("9router", [])
|
||||
);
|
||||
const body = await resp.text();
|
||||
assert.ok(
|
||||
body.includes('href="/dashboard/providers/services/9router/embed/ui/page"'),
|
||||
`Expected rewritten href. Got: ${body.substring(0, 300)}`
|
||||
);
|
||||
});
|
||||
|
||||
it("G-05: JSON response is NOT rewritten (streaming pass-through)", async () => {
|
||||
registerFake("running", 20130);
|
||||
const jsonPayload = '{"models":["gpt-4","claude-3"]}';
|
||||
globalThis.fetch = async () =>
|
||||
new Response(jsonPayload, {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
|
||||
const resp = await GET(
|
||||
new Request("http://localhost/dashboard/providers/services/9router/embed/api/models"),
|
||||
makeFakeParams("9router", ["api", "models"])
|
||||
);
|
||||
const body = await resp.text();
|
||||
assert.equal(body, jsonPayload, "JSON response must pass through unchanged");
|
||||
});
|
||||
|
||||
it("G-05: HEAD method is handled", async () => {
|
||||
registerFake("running", 20130);
|
||||
globalThis.fetch = async () =>
|
||||
new Response(null, { status: 200, headers: { "content-type": "text/html" } });
|
||||
|
||||
const req = new Request("http://localhost/dashboard/providers/services/9router/embed/", {
|
||||
method: "HEAD",
|
||||
});
|
||||
const resp = await HEAD(req, makeFakeParams("9router", []));
|
||||
assert.equal(resp.status, 200);
|
||||
});
|
||||
|
||||
it("G-05: OPTIONS method is handled", async () => {
|
||||
registerFake("running", 20130);
|
||||
globalThis.fetch = async () =>
|
||||
new Response(null, {
|
||||
status: 204,
|
||||
headers: { allow: "GET, HEAD, POST, OPTIONS" },
|
||||
});
|
||||
|
||||
const req = new Request("http://localhost/dashboard/providers/services/9router/embed/", {
|
||||
method: "OPTIONS",
|
||||
});
|
||||
const resp = await OPTIONS(req, makeFakeParams("9router", []));
|
||||
assert.equal(resp.status, 204);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user