250 lines
8.1 KiB
JavaScript
250 lines
8.1 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import { createServer } from "node:http";
|
|
import test from "node:test";
|
|
|
|
import { upstreamRequestHeaders } from "./cloud-web-proxy.mjs";
|
|
import { cloudWebProxyRoutePolicy } from "./cloud-web-routes.mjs";
|
|
import { proxyHttpRequest } from "./http.mjs";
|
|
|
|
test("cloud web route policy proxies public Code Agent chat without gating other writes", () => {
|
|
assert.deepEqual(cloudWebProxyRoutePolicy("POST", "/v1/agent/chat"), {
|
|
proxy: true,
|
|
authRequired: false,
|
|
publicRoute: true,
|
|
routeKey: "POST /v1/agent/chat"
|
|
});
|
|
assert.deepEqual(cloudWebProxyRoutePolicy("POST", "/v1/agent/chat/cancel"), {
|
|
proxy: true,
|
|
authRequired: false,
|
|
publicRoute: true,
|
|
routeKey: "POST /v1/agent/chat/cancel"
|
|
});
|
|
assert.deepEqual(cloudWebProxyRoutePolicy("POST", "/v1/agent/chat/steer"), {
|
|
proxy: true,
|
|
authRequired: false,
|
|
publicRoute: true,
|
|
routeKey: "POST /v1/agent/chat/steer"
|
|
});
|
|
assert.deepEqual(cloudWebProxyRoutePolicy("POST", "/v1/m3/io"), {
|
|
proxy: true,
|
|
authRequired: true,
|
|
publicRoute: false,
|
|
routeKey: "POST /v1/m3/io"
|
|
});
|
|
assert.deepEqual(cloudWebProxyRoutePolicy("GET", "/v1/agent/traces/trc_live"), {
|
|
proxy: true,
|
|
authRequired: false,
|
|
publicRoute: true,
|
|
routeKey: "GET /v1/agent/traces/trc_live"
|
|
});
|
|
assert.deepEqual(cloudWebProxyRoutePolicy("GET", "/v1/agent/chat/trace/trc_live"), {
|
|
proxy: true,
|
|
authRequired: true,
|
|
publicRoute: false,
|
|
routeKey: "GET /v1/agent/chat/trace/trc_live"
|
|
});
|
|
assert.deepEqual(cloudWebProxyRoutePolicy("GET", "/v1/agent/chat/result/trc_live"), {
|
|
proxy: true,
|
|
authRequired: false,
|
|
publicRoute: true,
|
|
routeKey: "GET /v1/agent/chat/result/trc_live"
|
|
});
|
|
assert.deepEqual(cloudWebProxyRoutePolicy("GET", "/v1/agent/turns/trc_live"), {
|
|
proxy: true,
|
|
authRequired: false,
|
|
publicRoute: true,
|
|
routeKey: "GET /v1/agent/turns/trc_live"
|
|
});
|
|
assert.deepEqual(cloudWebProxyRoutePolicy("GET", "/v1"), {
|
|
proxy: true,
|
|
authRequired: true,
|
|
publicRoute: false,
|
|
routeKey: "GET /v1"
|
|
});
|
|
assert.deepEqual(cloudWebProxyRoutePolicy("GET", "/v1/m3/status"), {
|
|
proxy: true,
|
|
authRequired: true,
|
|
publicRoute: false,
|
|
routeKey: "GET /v1/m3/status"
|
|
});
|
|
assert.deepEqual(cloudWebProxyRoutePolicy("POST", "/v1/project-management/mdtodo/sources"), {
|
|
proxy: true,
|
|
authRequired: true,
|
|
publicRoute: false,
|
|
routeKey: "POST /v1/project-management/mdtodo/sources"
|
|
});
|
|
assert.deepEqual(cloudWebProxyRoutePolicy("POST", "/v1/project-management/mdtodo/sources/d601-f103-v2-mdtodo/reindex"), {
|
|
proxy: true,
|
|
authRequired: true,
|
|
publicRoute: false,
|
|
routeKey: "POST /v1/project-management/mdtodo/sources/d601-f103-v2-mdtodo/reindex"
|
|
});
|
|
assert.deepEqual(cloudWebProxyRoutePolicy("PATCH", "/v1/project-management/mdtodo/tasks/mdtodo%3Asource%3Afile%3AR1.1"), {
|
|
proxy: true,
|
|
authRequired: true,
|
|
publicRoute: false,
|
|
routeKey: "PATCH /v1/project-management/mdtodo/tasks/mdtodo%3Asource%3Afile%3AR1.1"
|
|
});
|
|
assert.deepEqual(cloudWebProxyRoutePolicy("DELETE", "/v1/project-management/mdtodo/tasks/mdtodo%3Asource%3Afile%3AR1.1"), {
|
|
proxy: true,
|
|
authRequired: true,
|
|
publicRoute: false,
|
|
routeKey: "DELETE /v1/project-management/mdtodo/tasks/mdtodo%3Asource%3Afile%3AR1.1"
|
|
});
|
|
assert.deepEqual(cloudWebProxyRoutePolicy("GET", "/app.js"), {
|
|
proxy: false,
|
|
authRequired: false,
|
|
publicRoute: false,
|
|
routeKey: "GET /app.js"
|
|
});
|
|
});
|
|
|
|
test("cloud web route policy delegates auth authority to cloud-api", () => {
|
|
assert.deepEqual(cloudWebProxyRoutePolicy("GET", "/auth/session"), {
|
|
proxy: true,
|
|
authRequired: false,
|
|
publicRoute: true,
|
|
routeKey: "GET /auth/session"
|
|
});
|
|
assert.deepEqual(cloudWebProxyRoutePolicy("POST", "/auth/login"), {
|
|
proxy: true,
|
|
authRequired: false,
|
|
publicRoute: true,
|
|
routeKey: "POST /auth/login"
|
|
});
|
|
assert.deepEqual(cloudWebProxyRoutePolicy("POST", "/auth/logout"), {
|
|
proxy: true,
|
|
authRequired: false,
|
|
publicRoute: true,
|
|
routeKey: "POST /auth/logout"
|
|
});
|
|
});
|
|
|
|
test("cloud web proxy forwards user credentials without forwarding session-token credentials", () => {
|
|
const headers = upstreamRequestHeaders({
|
|
headers: {
|
|
accept: "application/json",
|
|
authorization: "Bearer hwl_live_must-forward",
|
|
cookie: "hwlab_session=session-a",
|
|
"x-hwlab-session-token": "session-a-secret",
|
|
"x-trace-id": "trc_hwpod_session_proxy"
|
|
}
|
|
});
|
|
|
|
assert.equal(headers["x-hwlab-session-token"], undefined);
|
|
assert.equal(headers.cookie, "hwlab_session=session-a");
|
|
assert.equal(headers["x-trace-id"], "trc_hwpod_session_proxy");
|
|
assert.equal(headers.authorization, "Bearer hwl_live_must-forward");
|
|
});
|
|
|
|
test("dev entrypoint proxy allows slow first response beyond legacy 4500ms", async () => {
|
|
const upstream = createServer((request, response) => {
|
|
request.resume();
|
|
setTimeout(() => {
|
|
response.writeHead(200, { "content-type": "application/json" });
|
|
response.end(JSON.stringify({
|
|
status: "completed",
|
|
traceId: request.headers["x-trace-id"] ?? null
|
|
}));
|
|
}, 4700);
|
|
});
|
|
await listen(upstream);
|
|
|
|
const proxy = createServer((request, response) => {
|
|
proxyHttpRequest({
|
|
request,
|
|
response,
|
|
upstream: serverUrl(upstream),
|
|
timeoutMs: 6000
|
|
});
|
|
});
|
|
await listen(proxy);
|
|
|
|
try {
|
|
const response = await fetch(`${serverUrl(proxy)}/v1/agent/chat`, {
|
|
method: "POST",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
"x-trace-id": "trc_dev-entrypoint-proxy-slow-success"
|
|
},
|
|
body: JSON.stringify({ message: "列出你能使用的所有skill" })
|
|
});
|
|
assert.equal(response.status, 200);
|
|
const payload = await response.json();
|
|
assert.equal(payload.status, "completed");
|
|
assert.equal(payload.traceId, "trc_dev-entrypoint-proxy-slow-success");
|
|
} finally {
|
|
await close(proxy);
|
|
await close(upstream);
|
|
}
|
|
});
|
|
|
|
test("dev entrypoint proxy returns 502 when hard timeout expires", async () => {
|
|
const upstream = createServer((request, response) => {
|
|
request.resume();
|
|
setTimeout(() => {
|
|
response.writeHead(200, { "content-type": "application/json" });
|
|
response.end(JSON.stringify({ status: "completed" }));
|
|
}, 250);
|
|
});
|
|
await listen(upstream);
|
|
|
|
const proxy = createServer((request, response) => {
|
|
proxyHttpRequest({
|
|
request,
|
|
response,
|
|
upstream: serverUrl(upstream),
|
|
timeoutMs: 50
|
|
});
|
|
});
|
|
await listen(proxy);
|
|
|
|
try {
|
|
const response = await fetch(`${serverUrl(proxy)}/v1/agent/chat`, {
|
|
method: "POST",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
"x-trace-id": "trc_dev-entrypoint-proxy-timeout"
|
|
},
|
|
body: JSON.stringify({ message: "slow" })
|
|
});
|
|
assert.equal(response.status, 502);
|
|
const payload = await response.json();
|
|
assert.equal(payload.status, "failed");
|
|
assert.equal(payload.error.code, "upstream_unavailable");
|
|
assert.equal(payload.error.layer, "proxy");
|
|
assert.equal(payload.error.category, "timeout");
|
|
assert.equal(payload.error.retryable, true);
|
|
assert.equal(payload.error.timeoutMs, 50);
|
|
assert.equal(payload.error.traceId, "trc_dev-entrypoint-proxy-timeout");
|
|
assert.equal(payload.error.userMessage, "暂时无法连接上游。");
|
|
assert.equal(payload.error.message, "暂时无法连接上游。");
|
|
assert.equal(payload.error.blocker.code, "upstream_unavailable");
|
|
assert.equal(payload.error.blocker.retryable, true);
|
|
assert.equal(payload.traceId, "trc_dev-entrypoint-proxy-timeout");
|
|
assert.equal(payload.message, "暂时无法连接上游。");
|
|
assert.doesNotMatch(JSON.stringify(payload), /Code Agent|输入已保留|稍后重试/u);
|
|
} finally {
|
|
await close(proxy);
|
|
await close(upstream);
|
|
}
|
|
});
|
|
|
|
function listen(server) {
|
|
return new Promise((resolve, reject) => {
|
|
server.once("error", reject);
|
|
server.listen(0, "127.0.0.1", resolve);
|
|
});
|
|
}
|
|
|
|
function close(server) {
|
|
return new Promise((resolve, reject) => {
|
|
server.close((error) => (error ? reject(error) : resolve()));
|
|
});
|
|
}
|
|
|
|
function serverUrl(server) {
|
|
const address = server.address();
|
|
return `http://127.0.0.1:${address.port}`;
|
|
}
|