Files
openclaw/src/gateway/server-http.test-harness.ts
Vincent Koc ab5fcfcc01 feat(gateway): add channel-backed readiness probes (#38285)
* Changelog: add channel-backed readiness probe entry

* Gateway: add channel-backed readiness probes

* Docs: describe readiness probe behavior

* Gateway: add readiness probe regression tests

* Changelog: dedupe gateway probe entries

* Docs: fix readiness startup grace description

* Changelog: remove stale readiness entry

* Gateway: cover readiness hardening

* Gateway: harden readiness probes
2026-03-06 15:15:23 -05:00

275 lines
8.2 KiB
TypeScript

import type { IncomingMessage, ServerResponse } from "node:http";
import { expect, vi } from "vitest";
import type { createSubsystemLogger } from "../logging/subsystem.js";
import type { ResolvedGatewayAuth } from "./auth.js";
import { createGatewayRequest, createHooksConfig } from "./hooks-test-helpers.js";
import { canonicalizePathVariant, isProtectedPluginRoutePath } from "./security-path.js";
import { createGatewayHttpServer, createHooksRequestHandler } from "./server-http.js";
import { withTempConfig } from "./test-temp-config.js";
export type GatewayHttpServer = ReturnType<typeof createGatewayHttpServer>;
export type GatewayServerOptions = Partial<Parameters<typeof createGatewayHttpServer>[0]>;
export const AUTH_NONE: ResolvedGatewayAuth = {
mode: "none",
token: undefined,
password: undefined,
allowTailscale: false,
};
export const AUTH_TOKEN: ResolvedGatewayAuth = {
mode: "token",
token: "test-token",
password: undefined,
allowTailscale: false,
};
export function createRequest(params: {
path: string;
authorization?: string;
method?: string;
remoteAddress?: string;
host?: string;
}): IncomingMessage {
return createGatewayRequest({
path: params.path,
authorization: params.authorization,
method: params.method,
remoteAddress: params.remoteAddress,
host: params.host,
});
}
export function createResponse(): {
res: ServerResponse;
setHeader: ReturnType<typeof vi.fn>;
end: ReturnType<typeof vi.fn>;
getBody: () => string;
} {
const setHeader = vi.fn();
let body = "";
const end = vi.fn((chunk?: unknown) => {
if (typeof chunk === "string") {
body = chunk;
return;
}
if (chunk == null) {
body = "";
return;
}
body = JSON.stringify(chunk);
});
const res = {
headersSent: false,
statusCode: 200,
setHeader,
end,
} as unknown as ServerResponse;
return {
res,
setHeader,
end,
getBody: () => body,
};
}
export async function dispatchRequest(
server: GatewayHttpServer,
req: IncomingMessage,
res: ServerResponse,
): Promise<void> {
server.emit("request", req, res);
await new Promise((resolve) => setImmediate(resolve));
}
export async function withGatewayTempConfig(
prefix: string,
run: () => Promise<void>,
): Promise<void> {
await withTempConfig({
cfg: { gateway: { trustedProxies: [] } },
prefix,
run,
});
}
export function createTestGatewayServer(options: {
resolvedAuth: ResolvedGatewayAuth;
overrides?: GatewayServerOptions;
}): GatewayHttpServer {
return createGatewayHttpServer({
canvasHost: null,
clients: new Set(),
controlUiEnabled: false,
controlUiBasePath: "/__control__",
openAiChatCompletionsEnabled: false,
openResponsesEnabled: false,
handleHooksRequest: async () => false,
...options.overrides,
resolvedAuth: options.resolvedAuth,
});
}
export async function withGatewayServer(params: {
prefix: string;
resolvedAuth: ResolvedGatewayAuth;
overrides?: GatewayServerOptions;
run: (server: GatewayHttpServer) => Promise<void>;
}): Promise<void> {
await withGatewayTempConfig(params.prefix, async () => {
const server = createTestGatewayServer({
resolvedAuth: params.resolvedAuth,
overrides: params.overrides,
});
await params.run(server);
});
}
export async function sendRequest(
server: GatewayHttpServer,
params: {
path: string;
authorization?: string;
method?: string;
remoteAddress?: string;
host?: string;
},
): Promise<ReturnType<typeof createResponse>> {
const response = createResponse();
await dispatchRequest(server, createRequest(params), response.res);
return response;
}
export function expectUnauthorizedResponse(
response: ReturnType<typeof createResponse>,
label?: string,
): void {
expect(response.res.statusCode, label).toBe(401);
expect(response.getBody(), label).toContain("Unauthorized");
}
export function createCanonicalizedChannelPluginHandler() {
return vi.fn(async (req: IncomingMessage, res: ServerResponse) => {
const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
const canonicalPath = canonicalizePathVariant(pathname);
if (canonicalPath !== "/api/channels/nostr/default/profile") {
return false;
}
res.statusCode = 200;
res.setHeader("Content-Type", "application/json; charset=utf-8");
res.end(JSON.stringify({ ok: true, route: "channel-canonicalized" }));
return true;
});
}
export function createHooksHandler(bindHost: string) {
return createHooksRequestHandler({
getHooksConfig: () => createHooksConfig(),
bindHost,
port: 18789,
logHooks: {
warn: vi.fn(),
debug: vi.fn(),
info: vi.fn(),
error: vi.fn(),
} as unknown as ReturnType<typeof createSubsystemLogger>,
dispatchWakeHook: () => {},
dispatchAgentHook: () => "run-1",
});
}
export type RouteVariant = {
label: string;
path: string;
};
export const CANONICAL_UNAUTH_VARIANTS: RouteVariant[] = [
{ label: "case-variant", path: "/API/channels/nostr/default/profile" },
{ label: "encoded-slash", path: "/api/channels%2Fnostr%2Fdefault%2Fprofile" },
{
label: "encoded-slash-4x",
path: "/api%2525252fchannels%2525252fnostr%2525252fdefault%2525252fprofile",
},
{ label: "encoded-segment", path: "/api/%63hannels/nostr/default/profile" },
{ label: "dot-traversal-encoded-slash", path: "/api/foo/..%2fchannels/nostr/default/profile" },
{
label: "dot-traversal-encoded-dotdot-slash",
path: "/api/foo/%2e%2e%2fchannels/nostr/default/profile",
},
{
label: "dot-traversal-double-encoded",
path: "/api/foo/%252e%252e%252fchannels/nostr/default/profile",
},
{ label: "duplicate-slashes", path: "/api/channels//nostr/default/profile" },
{ label: "trailing-slash", path: "/api/channels/nostr/default/profile/" },
{ label: "malformed-short-percent", path: "/api/channels%2" },
{ label: "malformed-double-slash-short-percent", path: "/api//channels%2" },
];
export const CANONICAL_AUTH_VARIANTS: RouteVariant[] = [
{ label: "auth-case-variant", path: "/API/channels/nostr/default/profile" },
{
label: "auth-encoded-slash-4x",
path: "/api%2525252fchannels%2525252fnostr%2525252fdefault%2525252fprofile",
},
{ label: "auth-encoded-segment", path: "/api/%63hannels/nostr/default/profile" },
{ label: "auth-duplicate-trailing-slash", path: "/api/channels//nostr/default/profile/" },
{
label: "auth-dot-traversal-encoded-slash",
path: "/api/foo/..%2fchannels/nostr/default/profile",
},
{
label: "auth-dot-traversal-double-encoded",
path: "/api/foo/%252e%252e%252fchannels/nostr/default/profile",
},
];
export function buildChannelPathFuzzCorpus(): RouteVariant[] {
const variants = [
"/api/channels/nostr/default/profile",
"/API/channels/nostr/default/profile",
"/api/foo/..%2fchannels/nostr/default/profile",
"/api/foo/%2e%2e%2fchannels/nostr/default/profile",
"/api/foo/%252e%252e%252fchannels/nostr/default/profile",
"/api/channels//nostr/default/profile/",
"/api/channels%2Fnostr%2Fdefault%2Fprofile",
"/api/channels%252Fnostr%252Fdefault%252Fprofile",
"/api%2525252fchannels%2525252fnostr%2525252fdefault%2525252fprofile",
"/api//channels/nostr/default/profile",
"/api/channels%2",
"/api/channels%zz",
"/api//channels%2",
"/api//channels%zz",
];
return variants.map((path) => ({ label: `fuzz:${path}`, path }));
}
export async function expectUnauthorizedVariants(params: {
server: GatewayHttpServer;
variants: RouteVariant[];
}) {
for (const variant of params.variants) {
const response = await sendRequest(params.server, { path: variant.path });
expectUnauthorizedResponse(response, variant.label);
}
}
export async function expectAuthorizedVariants(params: {
server: GatewayHttpServer;
variants: RouteVariant[];
authorization: string;
}) {
for (const variant of params.variants) {
const response = await sendRequest(params.server, {
path: variant.path,
authorization: params.authorization,
});
expect(response.res.statusCode, variant.label).toBe(200);
expect(response.getBody(), variant.label).toContain('"route":"channel-canonicalized"');
}
}
export function defaultProtectedPluginRoutePath(pathname: string): boolean {
return isProtectedPluginRoutePath(pathname);
}