393 lines
14 KiB
TypeScript
393 lines
14 KiB
TypeScript
/**
|
|
* 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);
|
|
});
|
|
});
|