mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 09:01:22 +00:00
refactor(core): dedupe gateway runtime and config tests
This commit is contained in:
@@ -46,6 +46,46 @@ function createTailscaleWhois() {
|
||||
}
|
||||
|
||||
describe("gateway auth", () => {
|
||||
async function expectTokenMismatchWithLimiter(params: {
|
||||
reqHeaders: Record<string, string>;
|
||||
allowRealIpFallback?: boolean;
|
||||
}) {
|
||||
const limiter = createLimiterSpy();
|
||||
const res = await authorizeGatewayConnect({
|
||||
auth: { mode: "token", token: "secret", allowTailscale: false },
|
||||
connectAuth: { token: "wrong" },
|
||||
req: {
|
||||
socket: { remoteAddress: "127.0.0.1" },
|
||||
headers: params.reqHeaders,
|
||||
} as never,
|
||||
trustedProxies: ["127.0.0.1"],
|
||||
...(params.allowRealIpFallback ? { allowRealIpFallback: true } : {}),
|
||||
rateLimiter: limiter,
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.reason).toBe("token_mismatch");
|
||||
return limiter;
|
||||
}
|
||||
|
||||
async function expectTailscaleHeaderAuthResult(params: {
|
||||
authorize: typeof authorizeHttpGatewayConnect | typeof authorizeWsControlUiGatewayConnect;
|
||||
expected: { ok: false; reason: string } | { ok: true; method: string; user: string };
|
||||
}) {
|
||||
const res = await params.authorize({
|
||||
auth: { mode: "token", token: "secret", allowTailscale: true },
|
||||
connectAuth: null,
|
||||
tailscaleWhois: createTailscaleWhois(),
|
||||
req: createTailscaleForwardedReq(),
|
||||
});
|
||||
expect(res.ok).toBe(params.expected.ok);
|
||||
if (!params.expected.ok) {
|
||||
expect(res.reason).toBe(params.expected.reason);
|
||||
return;
|
||||
}
|
||||
expect(res.method).toBe(params.expected.method);
|
||||
expect(res.user).toBe(params.expected.user);
|
||||
}
|
||||
|
||||
it("resolves token/password from OPENCLAW gateway env vars", () => {
|
||||
expect(
|
||||
resolveGatewayAuth({
|
||||
@@ -238,82 +278,40 @@ describe("gateway auth", () => {
|
||||
});
|
||||
|
||||
it("keeps tailscale header auth disabled on HTTP auth wrapper", async () => {
|
||||
const res = await authorizeHttpGatewayConnect({
|
||||
auth: { mode: "token", token: "secret", allowTailscale: true },
|
||||
connectAuth: null,
|
||||
tailscaleWhois: createTailscaleWhois(),
|
||||
req: createTailscaleForwardedReq(),
|
||||
await expectTailscaleHeaderAuthResult({
|
||||
authorize: authorizeHttpGatewayConnect,
|
||||
expected: { ok: false, reason: "token_missing" },
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.reason).toBe("token_missing");
|
||||
});
|
||||
|
||||
it("enables tailscale header auth on ws control-ui auth wrapper", async () => {
|
||||
const res = await authorizeWsControlUiGatewayConnect({
|
||||
auth: { mode: "token", token: "secret", allowTailscale: true },
|
||||
connectAuth: null,
|
||||
tailscaleWhois: createTailscaleWhois(),
|
||||
req: createTailscaleForwardedReq(),
|
||||
await expectTailscaleHeaderAuthResult({
|
||||
authorize: authorizeWsControlUiGatewayConnect,
|
||||
expected: { ok: true, method: "tailscale", user: "peter" },
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.method).toBe("tailscale");
|
||||
expect(res.user).toBe("peter");
|
||||
});
|
||||
|
||||
it("uses proxy-aware request client IP by default for rate-limit checks", async () => {
|
||||
const limiter = createLimiterSpy();
|
||||
const res = await authorizeGatewayConnect({
|
||||
auth: { mode: "token", token: "secret", allowTailscale: false },
|
||||
connectAuth: { token: "wrong" },
|
||||
req: {
|
||||
socket: { remoteAddress: "127.0.0.1" },
|
||||
headers: { "x-forwarded-for": "203.0.113.10" },
|
||||
} as never,
|
||||
trustedProxies: ["127.0.0.1"],
|
||||
rateLimiter: limiter,
|
||||
const limiter = await expectTokenMismatchWithLimiter({
|
||||
reqHeaders: { "x-forwarded-for": "203.0.113.10" },
|
||||
});
|
||||
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.reason).toBe("token_mismatch");
|
||||
expect(limiter.check).toHaveBeenCalledWith("203.0.113.10", "shared-secret");
|
||||
expect(limiter.recordFailure).toHaveBeenCalledWith("203.0.113.10", "shared-secret");
|
||||
});
|
||||
|
||||
it("ignores X-Real-IP fallback by default for rate-limit checks", async () => {
|
||||
const limiter = createLimiterSpy();
|
||||
const res = await authorizeGatewayConnect({
|
||||
auth: { mode: "token", token: "secret", allowTailscale: false },
|
||||
connectAuth: { token: "wrong" },
|
||||
req: {
|
||||
socket: { remoteAddress: "127.0.0.1" },
|
||||
headers: { "x-real-ip": "203.0.113.77" },
|
||||
} as never,
|
||||
trustedProxies: ["127.0.0.1"],
|
||||
rateLimiter: limiter,
|
||||
const limiter = await expectTokenMismatchWithLimiter({
|
||||
reqHeaders: { "x-real-ip": "203.0.113.77" },
|
||||
});
|
||||
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.reason).toBe("token_mismatch");
|
||||
expect(limiter.check).toHaveBeenCalledWith("127.0.0.1", "shared-secret");
|
||||
expect(limiter.recordFailure).toHaveBeenCalledWith("127.0.0.1", "shared-secret");
|
||||
});
|
||||
|
||||
it("uses X-Real-IP when fallback is explicitly enabled", async () => {
|
||||
const limiter = createLimiterSpy();
|
||||
const res = await authorizeGatewayConnect({
|
||||
auth: { mode: "token", token: "secret", allowTailscale: false },
|
||||
connectAuth: { token: "wrong" },
|
||||
req: {
|
||||
socket: { remoteAddress: "127.0.0.1" },
|
||||
headers: { "x-real-ip": "203.0.113.77" },
|
||||
} as never,
|
||||
trustedProxies: ["127.0.0.1"],
|
||||
const limiter = await expectTokenMismatchWithLimiter({
|
||||
reqHeaders: { "x-real-ip": "203.0.113.77" },
|
||||
allowRealIpFallback: true,
|
||||
rateLimiter: limiter,
|
||||
});
|
||||
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.reason).toBe("token_mismatch");
|
||||
expect(limiter.check).toHaveBeenCalledWith("203.0.113.77", "shared-secret");
|
||||
expect(limiter.recordFailure).toHaveBeenCalledWith("203.0.113.77", "shared-secret");
|
||||
});
|
||||
|
||||
@@ -95,6 +95,22 @@ function getLatestWs(): MockWebSocket {
|
||||
return ws;
|
||||
}
|
||||
|
||||
function createClientWithIdentity(
|
||||
deviceId: string,
|
||||
onClose: (code: number, reason: string) => void,
|
||||
) {
|
||||
const identity: DeviceIdentity = {
|
||||
deviceId,
|
||||
privateKeyPem: "private-key",
|
||||
publicKeyPem: "public-key",
|
||||
};
|
||||
return new GatewayClient({
|
||||
url: "ws://127.0.0.1:18789",
|
||||
deviceIdentity: identity,
|
||||
onClose,
|
||||
});
|
||||
}
|
||||
|
||||
describe("GatewayClient security checks", () => {
|
||||
beforeEach(() => {
|
||||
wsInstances.length = 0;
|
||||
@@ -177,16 +193,7 @@ describe("GatewayClient close handling", () => {
|
||||
|
||||
it("clears stale token on device token mismatch close", () => {
|
||||
const onClose = vi.fn();
|
||||
const identity: DeviceIdentity = {
|
||||
deviceId: "dev-1",
|
||||
privateKeyPem: "private-key",
|
||||
publicKeyPem: "public-key",
|
||||
};
|
||||
const client = new GatewayClient({
|
||||
url: "ws://127.0.0.1:18789",
|
||||
deviceIdentity: identity,
|
||||
onClose,
|
||||
});
|
||||
const client = createClientWithIdentity("dev-1", onClose);
|
||||
|
||||
client.start();
|
||||
getLatestWs().emitClose(
|
||||
@@ -208,16 +215,7 @@ describe("GatewayClient close handling", () => {
|
||||
throw new Error("disk unavailable");
|
||||
});
|
||||
const onClose = vi.fn();
|
||||
const identity: DeviceIdentity = {
|
||||
deviceId: "dev-2",
|
||||
privateKeyPem: "private-key",
|
||||
publicKeyPem: "public-key",
|
||||
};
|
||||
const client = new GatewayClient({
|
||||
url: "ws://127.0.0.1:18789",
|
||||
deviceIdentity: identity,
|
||||
onClose,
|
||||
});
|
||||
const client = createClientWithIdentity("dev-2", onClose);
|
||||
|
||||
client.start();
|
||||
expect(() => {
|
||||
@@ -235,16 +233,7 @@ describe("GatewayClient close handling", () => {
|
||||
it("does not break close flow when pairing clear rejects", async () => {
|
||||
clearDevicePairingMock.mockRejectedValue(new Error("pairing store unavailable"));
|
||||
const onClose = vi.fn();
|
||||
const identity: DeviceIdentity = {
|
||||
deviceId: "dev-3",
|
||||
privateKeyPem: "private-key",
|
||||
publicKeyPem: "public-key",
|
||||
};
|
||||
const client = new GatewayClient({
|
||||
url: "ws://127.0.0.1:18789",
|
||||
deviceIdentity: identity,
|
||||
onClose,
|
||||
});
|
||||
const client = createClientWithIdentity("dev-3", onClose);
|
||||
|
||||
client.start();
|
||||
expect(() => {
|
||||
@@ -258,4 +247,17 @@ describe("GatewayClient close handling", () => {
|
||||
expect(onClose).toHaveBeenCalledWith(1008, "unauthorized: device token mismatch");
|
||||
client.stop();
|
||||
});
|
||||
|
||||
it("does not clear auth state for non-mismatch close reasons", () => {
|
||||
const onClose = vi.fn();
|
||||
const client = createClientWithIdentity("dev-4", onClose);
|
||||
|
||||
client.start();
|
||||
getLatestWs().emitClose(1008, "unauthorized: signature invalid");
|
||||
|
||||
expect(clearDeviceAuthTokenMock).not.toHaveBeenCalled();
|
||||
expect(clearDevicePairingMock).not.toHaveBeenCalled();
|
||||
expect(onClose).toHaveBeenCalledWith(1008, "unauthorized: signature invalid");
|
||||
client.stop();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -58,6 +58,22 @@ async function postChatCompletions(port: number, body: unknown, headers?: Record
|
||||
return res;
|
||||
}
|
||||
|
||||
async function expectChatCompletionsDisabled(
|
||||
start: (port: number) => Promise<{ close: (opts?: { reason?: string }) => Promise<void> }>,
|
||||
) {
|
||||
const port = await getFreePort();
|
||||
const server = await start(port);
|
||||
try {
|
||||
const res = await postChatCompletions(port, {
|
||||
model: "openclaw",
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
} finally {
|
||||
await server.close({ reason: "test done" });
|
||||
}
|
||||
}
|
||||
|
||||
function parseSseDataLines(text: string): string[] {
|
||||
return text
|
||||
.split("\n")
|
||||
@@ -68,35 +84,12 @@ function parseSseDataLines(text: string): string[] {
|
||||
|
||||
describe("OpenAI-compatible HTTP API (e2e)", () => {
|
||||
it("rejects when disabled (default + config)", { timeout: 120_000 }, async () => {
|
||||
{
|
||||
const port = await getFreePort();
|
||||
const server = await startServerWithDefaultConfig(port);
|
||||
try {
|
||||
const res = await postChatCompletions(port, {
|
||||
model: "openclaw",
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
} finally {
|
||||
await server.close({ reason: "test done" });
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
const port = await getFreePort();
|
||||
const server = await startServer(port, {
|
||||
await expectChatCompletionsDisabled(startServerWithDefaultConfig);
|
||||
await expectChatCompletionsDisabled((port) =>
|
||||
startServer(port, {
|
||||
openAiChatCompletionsEnabled: false,
|
||||
});
|
||||
try {
|
||||
const res = await postChatCompletions(port, {
|
||||
model: "openclaw",
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
} finally {
|
||||
await server.close({ reason: "test done" });
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("handles request validation and routing", async () => {
|
||||
@@ -133,6 +126,15 @@ describe("OpenAI-compatible HTTP API (e2e)", () => {
|
||||
expect(message).toContain(line);
|
||||
}
|
||||
};
|
||||
const getFirstAgentCall = () =>
|
||||
(agentCommand.mock.calls[0] as unknown[] | undefined)?.[0] as
|
||||
| {
|
||||
sessionKey?: string;
|
||||
message?: string;
|
||||
extraSystemPrompt?: string;
|
||||
}
|
||||
| undefined;
|
||||
const getFirstAgentMessage = () => getFirstAgentCall()?.message ?? "";
|
||||
|
||||
try {
|
||||
{
|
||||
@@ -252,8 +254,7 @@ describe("OpenAI-compatible HTTP API (e2e)", () => {
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const opts = (agentCommand.mock.calls[0] as unknown[] | undefined)?.[0];
|
||||
const message = (opts as { message?: string } | undefined)?.message ?? "";
|
||||
const message = getFirstAgentMessage();
|
||||
expectMessageContext(message, {
|
||||
history: ["User: Hello, who are you?", "Assistant: I am Claude."],
|
||||
current: ["User: What did I just ask you?"],
|
||||
@@ -272,8 +273,7 @@ describe("OpenAI-compatible HTTP API (e2e)", () => {
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const opts = (agentCommand.mock.calls[0] as unknown[] | undefined)?.[0];
|
||||
const message = (opts as { message?: string } | undefined)?.message ?? "";
|
||||
const message = getFirstAgentMessage();
|
||||
expect(message).not.toContain(HISTORY_CONTEXT_MARKER);
|
||||
expect(message).not.toContain(CURRENT_MESSAGE_MARKER);
|
||||
expect(message).toBe("Hello");
|
||||
@@ -291,9 +291,7 @@ describe("OpenAI-compatible HTTP API (e2e)", () => {
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const opts = (agentCommand.mock.calls[0] as unknown[] | undefined)?.[0];
|
||||
const extraSystemPrompt =
|
||||
(opts as { extraSystemPrompt?: string } | undefined)?.extraSystemPrompt ?? "";
|
||||
const extraSystemPrompt = getFirstAgentCall()?.extraSystemPrompt ?? "";
|
||||
expect(extraSystemPrompt).toBe("You are a helpful assistant.");
|
||||
await res.text();
|
||||
}
|
||||
@@ -311,8 +309,7 @@ describe("OpenAI-compatible HTTP API (e2e)", () => {
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const opts = (agentCommand.mock.calls[0] as unknown[] | undefined)?.[0];
|
||||
const message = (opts as { message?: string } | undefined)?.message ?? "";
|
||||
const message = getFirstAgentMessage();
|
||||
expectMessageContext(message, {
|
||||
history: ["User: What's the weather?", "Assistant: Checking the weather."],
|
||||
current: ["Tool: Sunny, 70F."],
|
||||
|
||||
@@ -49,6 +49,17 @@ describe("resolveGatewayRuntimeConfig", () => {
|
||||
},
|
||||
expectedBindHost: "127.0.0.1",
|
||||
},
|
||||
{
|
||||
name: "loopback binding with loopback cidr proxy",
|
||||
cfg: {
|
||||
gateway: {
|
||||
bind: "loopback" as const,
|
||||
auth: TRUSTED_PROXY_AUTH,
|
||||
trustedProxies: ["127.0.0.0/8"],
|
||||
},
|
||||
},
|
||||
expectedBindHost: "127.0.0.1",
|
||||
},
|
||||
])("allows $name", async ({ cfg, expectedBindHost }) => {
|
||||
const result = await resolveGatewayRuntimeConfig({ cfg, port: 18789 });
|
||||
expect(result.authMode).toBe("trusted-proxy");
|
||||
|
||||
@@ -39,6 +39,19 @@ describe("ensureGatewayStartupAuth", () => {
|
||||
mocks.writeConfigFile.mockReset();
|
||||
});
|
||||
|
||||
async function expectNoTokenGeneration(cfg: OpenClawConfig, mode: string) {
|
||||
const result = await ensureGatewayStartupAuth({
|
||||
cfg,
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
persist: true,
|
||||
});
|
||||
|
||||
expect(result.generatedToken).toBeUndefined();
|
||||
expect(result.persistedGeneratedToken).toBe(false);
|
||||
expect(result.auth.mode).toBe(mode);
|
||||
expect(mocks.writeConfigFile).not.toHaveBeenCalled();
|
||||
}
|
||||
|
||||
it("generates and persists a token when startup auth is missing", async () => {
|
||||
const result = await ensureGatewayStartupAuth({
|
||||
cfg: {},
|
||||
@@ -79,64 +92,43 @@ describe("ensureGatewayStartupAuth", () => {
|
||||
});
|
||||
|
||||
it("does not generate in password mode", async () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
gateway: {
|
||||
auth: {
|
||||
mode: "password",
|
||||
await expectNoTokenGeneration(
|
||||
{
|
||||
gateway: {
|
||||
auth: {
|
||||
mode: "password",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const result = await ensureGatewayStartupAuth({
|
||||
cfg,
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
persist: true,
|
||||
});
|
||||
|
||||
expect(result.generatedToken).toBeUndefined();
|
||||
expect(result.persistedGeneratedToken).toBe(false);
|
||||
expect(result.auth.mode).toBe("password");
|
||||
expect(mocks.writeConfigFile).not.toHaveBeenCalled();
|
||||
"password",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not generate in trusted-proxy mode", async () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
gateway: {
|
||||
auth: {
|
||||
mode: "trusted-proxy",
|
||||
trustedProxy: { userHeader: "x-forwarded-user" },
|
||||
await expectNoTokenGeneration(
|
||||
{
|
||||
gateway: {
|
||||
auth: {
|
||||
mode: "trusted-proxy",
|
||||
trustedProxy: { userHeader: "x-forwarded-user" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const result = await ensureGatewayStartupAuth({
|
||||
cfg,
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
persist: true,
|
||||
});
|
||||
|
||||
expect(result.generatedToken).toBeUndefined();
|
||||
expect(result.persistedGeneratedToken).toBe(false);
|
||||
expect(result.auth.mode).toBe("trusted-proxy");
|
||||
expect(mocks.writeConfigFile).not.toHaveBeenCalled();
|
||||
"trusted-proxy",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not generate in explicit none mode", async () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
gateway: {
|
||||
auth: {
|
||||
mode: "none",
|
||||
await expectNoTokenGeneration(
|
||||
{
|
||||
gateway: {
|
||||
auth: {
|
||||
mode: "none",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const result = await ensureGatewayStartupAuth({
|
||||
cfg,
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
persist: true,
|
||||
});
|
||||
|
||||
expect(result.generatedToken).toBeUndefined();
|
||||
expect(result.persistedGeneratedToken).toBe(false);
|
||||
expect(result.auth.mode).toBe("none");
|
||||
expect(mocks.writeConfigFile).not.toHaveBeenCalled();
|
||||
"none",
|
||||
);
|
||||
});
|
||||
|
||||
it("treats undefined token override as no override", async () => {
|
||||
|
||||
@@ -198,6 +198,17 @@ const allowAgentsListForMain = () => {
|
||||
};
|
||||
};
|
||||
|
||||
const postToolsInvoke = async (params: {
|
||||
port: number;
|
||||
headers?: Record<string, string>;
|
||||
body: Record<string, unknown>;
|
||||
}) =>
|
||||
await fetch(`http://127.0.0.1:${params.port}/tools/invoke`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", ...params.headers },
|
||||
body: JSON.stringify(params.body),
|
||||
});
|
||||
|
||||
const invokeAgentsList = async (params: {
|
||||
port: number;
|
||||
headers?: Record<string, string>;
|
||||
@@ -207,11 +218,7 @@ const invokeAgentsList = async (params: {
|
||||
if (params.sessionKey) {
|
||||
body.sessionKey = params.sessionKey;
|
||||
}
|
||||
return await fetch(`http://127.0.0.1:${params.port}/tools/invoke`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", ...params.headers },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return await postToolsInvoke({ port: params.port, headers: params.headers, body });
|
||||
};
|
||||
|
||||
const invokeTool = async (params: {
|
||||
@@ -232,11 +239,7 @@ const invokeTool = async (params: {
|
||||
if (params.sessionKey) {
|
||||
body.sessionKey = params.sessionKey;
|
||||
}
|
||||
return await fetch(`http://127.0.0.1:${params.port}/tools/invoke`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", ...params.headers },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return await postToolsInvoke({ port: params.port, headers: params.headers, body });
|
||||
};
|
||||
|
||||
const invokeAgentsListAuthed = async (params: { sessionKey?: string } = {}) =>
|
||||
|
||||
Reference in New Issue
Block a user