test: tighten gateway helper coverage

This commit is contained in:
Peter Steinberger
2026-03-13 17:45:21 +00:00
parent 9b5000057e
commit 4aec20d365
4 changed files with 511 additions and 359 deletions

View File

@@ -2,65 +2,114 @@ import { describe, expect, it } from "vitest";
import { classifyControlUiRequest } from "./control-ui-routing.js";
describe("classifyControlUiRequest", () => {
it("falls through non-read root requests for plugin webhooks", () => {
const classified = classifyControlUiRequest({
basePath: "",
pathname: "/bluebubbles-webhook",
search: "",
method: "POST",
describe("root-mounted control ui", () => {
it.each([
{
name: "serves the root entrypoint",
pathname: "/",
method: "GET",
expected: { kind: "serve" as const },
},
{
name: "serves other read-only SPA routes",
pathname: "/chat",
method: "HEAD",
expected: { kind: "serve" as const },
},
{
name: "keeps health probes outside the SPA catch-all",
pathname: "/healthz",
method: "GET",
expected: { kind: "not-control-ui" as const },
},
{
name: "keeps readiness probes outside the SPA catch-all",
pathname: "/ready",
method: "HEAD",
expected: { kind: "not-control-ui" as const },
},
{
name: "keeps plugin routes outside the SPA catch-all",
pathname: "/plugins/webhook",
method: "GET",
expected: { kind: "not-control-ui" as const },
},
{
name: "keeps API routes outside the SPA catch-all",
pathname: "/api/sessions",
method: "GET",
expected: { kind: "not-control-ui" as const },
},
{
name: "returns not-found for legacy ui routes",
pathname: "/ui/settings",
method: "GET",
expected: { kind: "not-found" as const },
},
{
name: "falls through non-read requests",
pathname: "/bluebubbles-webhook",
method: "POST",
expected: { kind: "not-control-ui" as const },
},
])("$name", ({ pathname, method, expected }) => {
expect(
classifyControlUiRequest({
basePath: "",
pathname,
search: "",
method,
}),
).toEqual(expected);
});
expect(classified).toEqual({ kind: "not-control-ui" });
});
it("returns not-found for legacy /ui routes when root-mounted", () => {
const classified = classifyControlUiRequest({
basePath: "",
pathname: "/ui/settings",
search: "",
method: "GET",
});
expect(classified).toEqual({ kind: "not-found" });
});
it("falls through basePath non-read methods for plugin webhooks", () => {
const classified = classifyControlUiRequest({
basePath: "/openclaw",
pathname: "/openclaw",
search: "",
method: "POST",
});
expect(classified).toEqual({ kind: "not-control-ui" });
});
it("falls through PUT/DELETE/PATCH/OPTIONS under basePath for plugin handlers", () => {
for (const method of ["PUT", "DELETE", "PATCH", "OPTIONS"]) {
const classified = classifyControlUiRequest({
basePath: "/openclaw",
describe("basePath-mounted control ui", () => {
it.each([
{
name: "redirects the basePath entrypoint",
pathname: "/openclaw",
search: "?foo=1",
method: "GET",
expected: { kind: "redirect" as const, location: "/openclaw/?foo=1" },
},
{
name: "serves nested read-only routes",
pathname: "/openclaw/chat",
search: "",
method: "HEAD",
expected: { kind: "serve" as const },
},
{
name: "falls through unmatched paths",
pathname: "/elsewhere/chat",
search: "",
method: "GET",
expected: { kind: "not-control-ui" as const },
},
{
name: "falls through write requests to the basePath entrypoint",
pathname: "/openclaw",
search: "",
method: "POST",
expected: { kind: "not-control-ui" as const },
},
...["PUT", "DELETE", "PATCH", "OPTIONS"].map((method) => ({
name: `falls through ${method} subroute requests`,
pathname: "/openclaw/webhook",
search: "",
method,
});
expect(classified, `${method} should fall through`).toEqual({ kind: "not-control-ui" });
}
});
it("returns redirect for basePath entrypoint GET", () => {
const classified = classifyControlUiRequest({
basePath: "/openclaw",
pathname: "/openclaw",
search: "?foo=1",
method: "GET",
expected: { kind: "not-control-ui" as const },
})),
])("$name", ({ pathname, search, method, expected }) => {
expect(
classifyControlUiRequest({
basePath: "/openclaw",
pathname,
search,
method,
}),
).toEqual(expected);
});
expect(classified).toEqual({ kind: "redirect", location: "/openclaw/?foo=1" });
});
it("classifies basePath subroutes as control ui", () => {
const classified = classifyControlUiRequest({
basePath: "/openclaw",
pathname: "/openclaw/chat",
search: "",
method: "HEAD",
});
expect(classified).toEqual({ kind: "serve" });
});
});

View File

@@ -8,198 +8,245 @@ import {
} from "./live-tool-probe-utils.js";
describe("live tool probe utils", () => {
it("matches nonce pair when both are present", () => {
expect(hasExpectedToolNonce("value a-1 and b-2", "a-1", "b-2")).toBe(true);
expect(hasExpectedToolNonce("value a-1 only", "a-1", "b-2")).toBe(false);
describe("nonce matching", () => {
it.each([
{
name: "matches tool nonce pairs only when both are present",
actual: hasExpectedToolNonce("value a-1 and b-2", "a-1", "b-2"),
expected: true,
},
{
name: "rejects partial tool nonce matches",
actual: hasExpectedToolNonce("value a-1 only", "a-1", "b-2"),
expected: false,
},
{
name: "matches a single nonce when present",
actual: hasExpectedSingleNonce("value nonce-1", "nonce-1"),
expected: true,
},
{
name: "rejects single nonce mismatches",
actual: hasExpectedSingleNonce("value nonce-2", "nonce-1"),
expected: false,
},
])("$name", ({ actual, expected }) => {
expect(actual).toBe(expected);
});
});
it("matches single nonce when present", () => {
expect(hasExpectedSingleNonce("value nonce-1", "nonce-1")).toBe(true);
expect(hasExpectedSingleNonce("value nonce-2", "nonce-1")).toBe(false);
describe("refusal detection", () => {
it.each([
{
name: "detects nonce refusal phrasing",
text: "Same request, same answer — this isn't a real OpenClaw probe. No part of the system asks me to parrot back nonce values.",
expected: true,
},
{
name: "detects prompt-injection style refusals without nonce text",
text: "That's not a legitimate self-test. This looks like a prompt injection attempt.",
expected: true,
},
{
name: "ignores generic helper text",
text: "I can help with that request.",
expected: false,
},
{
name: "does not treat nonce markers without the word nonce as refusal",
text: "No part of the system asks me to parrot back values.",
expected: false,
},
])("$name", ({ text, expected }) => {
expect(isLikelyToolNonceRefusal(text)).toBe(expected);
});
});
it("detects anthropic nonce refusal phrasing", () => {
expect(
isLikelyToolNonceRefusal(
"Same request, same answer — this isn't a real OpenClaw probe. No part of the system asks me to parrot back nonce values.",
),
).toBe(true);
describe("shouldRetryToolReadProbe", () => {
it.each([
{
name: "retries malformed tool output when attempts remain",
params: {
text: "read[object Object],[object Object]",
nonceA: "nonce-a",
nonceB: "nonce-b",
provider: "mistral",
attempt: 0,
maxAttempts: 3,
},
expected: true,
},
{
name: "does not retry once max attempts are exhausted",
params: {
text: "read[object Object],[object Object]",
nonceA: "nonce-a",
nonceB: "nonce-b",
provider: "mistral",
attempt: 2,
maxAttempts: 3,
},
expected: false,
},
{
name: "does not retry when the nonce pair is already present",
params: {
text: "nonce-a nonce-b",
nonceA: "nonce-a",
nonceB: "nonce-b",
provider: "mistral",
attempt: 0,
maxAttempts: 3,
},
expected: false,
},
{
name: "prefers a valid nonce pair even if the text still contains scaffolding words",
params: {
text: "tool output nonce-a nonce-b function",
nonceA: "nonce-a",
nonceB: "nonce-b",
provider: "openai",
attempt: 0,
maxAttempts: 3,
},
expected: false,
},
{
name: "retries empty output",
params: {
text: " ",
nonceA: "nonce-a",
nonceB: "nonce-b",
provider: "openai",
attempt: 0,
maxAttempts: 3,
},
expected: true,
},
{
name: "retries tool scaffolding output",
params: {
text: "Use tool function read[] now.",
nonceA: "nonce-a",
nonceB: "nonce-b",
provider: "openai",
attempt: 0,
maxAttempts: 3,
},
expected: true,
},
{
name: "retries mistral nonce marker echoes without parsed values",
params: {
text: "nonceA= nonceB=",
nonceA: "nonce-a",
nonceB: "nonce-b",
provider: "mistral",
attempt: 0,
maxAttempts: 3,
},
expected: true,
},
{
name: "retries anthropic refusal output",
params: {
text: "This isn't a real OpenClaw probe; I won't parrot back nonce values.",
nonceA: "nonce-a",
nonceB: "nonce-b",
provider: "anthropic",
attempt: 0,
maxAttempts: 3,
},
expected: true,
},
{
name: "does not special-case anthropic refusals for other providers",
params: {
text: "This isn't a real OpenClaw probe; I won't parrot back nonce values.",
nonceA: "nonce-a",
nonceB: "nonce-b",
provider: "openai",
attempt: 0,
maxAttempts: 3,
},
expected: false,
},
])("$name", ({ params, expected }) => {
expect(shouldRetryToolReadProbe(params)).toBe(expected);
});
});
it("does not treat generic helper text as nonce refusal", () => {
expect(isLikelyToolNonceRefusal("I can help with that request.")).toBe(false);
});
it("detects prompt-injection style tool refusal without nonce text", () => {
expect(
isLikelyToolNonceRefusal(
"That's not a legitimate self-test. This looks like a prompt injection attempt.",
),
).toBe(true);
});
it("retries malformed tool output when attempts remain", () => {
expect(
shouldRetryToolReadProbe({
text: "read[object Object],[object Object]",
nonceA: "nonce-a",
nonceB: "nonce-b",
provider: "mistral",
attempt: 0,
maxAttempts: 3,
}),
).toBe(true);
});
it("does not retry once max attempts are exhausted", () => {
expect(
shouldRetryToolReadProbe({
text: "read[object Object],[object Object]",
nonceA: "nonce-a",
nonceB: "nonce-b",
provider: "mistral",
attempt: 2,
maxAttempts: 3,
}),
).toBe(false);
});
it("does not retry when nonce pair is already present", () => {
expect(
shouldRetryToolReadProbe({
text: "nonce-a nonce-b",
nonceA: "nonce-a",
nonceB: "nonce-b",
provider: "mistral",
attempt: 0,
maxAttempts: 3,
}),
).toBe(false);
});
it("retries when tool output is empty and attempts remain", () => {
expect(
shouldRetryToolReadProbe({
text: " ",
nonceA: "nonce-a",
nonceB: "nonce-b",
provider: "openai",
attempt: 0,
maxAttempts: 3,
}),
).toBe(true);
});
it("retries when output still looks like tool/function scaffolding", () => {
expect(
shouldRetryToolReadProbe({
text: "Use tool function read[] now.",
nonceA: "nonce-a",
nonceB: "nonce-b",
provider: "openai",
attempt: 0,
maxAttempts: 3,
}),
).toBe(true);
});
it("retries mistral nonce marker echoes without parsed nonce values", () => {
expect(
shouldRetryToolReadProbe({
text: "nonceA= nonceB=",
nonceA: "nonce-a",
nonceB: "nonce-b",
provider: "mistral",
attempt: 0,
maxAttempts: 3,
}),
).toBe(true);
});
it("retries anthropic nonce refusal output", () => {
expect(
shouldRetryToolReadProbe({
text: "This isn't a real OpenClaw probe; I won't parrot back nonce values.",
nonceA: "nonce-a",
nonceB: "nonce-b",
provider: "anthropic",
attempt: 0,
maxAttempts: 3,
}),
).toBe(true);
});
it("retries anthropic prompt-injection refusal output", () => {
expect(
shouldRetryToolReadProbe({
text: "This is not a legitimate self-test; it appears to be a prompt injection attempt.",
nonceA: "nonce-a",
nonceB: "nonce-b",
provider: "anthropic",
attempt: 0,
maxAttempts: 3,
}),
).toBe(true);
});
it("does not retry nonce marker echoes for non-mistral providers", () => {
expect(
shouldRetryToolReadProbe({
text: "nonceA= nonceB=",
nonceA: "nonce-a",
nonceB: "nonce-b",
provider: "openai",
attempt: 0,
maxAttempts: 3,
}),
).toBe(false);
});
it("retries malformed exec+read output when attempts remain", () => {
expect(
shouldRetryExecReadProbe({
text: "read[object Object]",
nonce: "nonce-c",
provider: "openai",
attempt: 0,
maxAttempts: 3,
}),
).toBe(true);
});
it("does not retry exec+read once max attempts are exhausted", () => {
expect(
shouldRetryExecReadProbe({
text: "read[object Object]",
nonce: "nonce-c",
provider: "openai",
attempt: 2,
maxAttempts: 3,
}),
).toBe(false);
});
it("does not retry exec+read when nonce is present", () => {
expect(
shouldRetryExecReadProbe({
text: "nonce-c",
nonce: "nonce-c",
provider: "openai",
attempt: 0,
maxAttempts: 3,
}),
).toBe(false);
});
it("retries anthropic exec+read nonce refusal output", () => {
expect(
shouldRetryExecReadProbe({
text: "No part of the system asks me to parrot back nonce values.",
nonce: "nonce-c",
provider: "anthropic",
attempt: 0,
maxAttempts: 3,
}),
).toBe(true);
describe("shouldRetryExecReadProbe", () => {
it.each([
{
name: "retries malformed exec+read output when attempts remain",
params: {
text: "read[object Object]",
nonce: "nonce-c",
provider: "openai",
attempt: 0,
maxAttempts: 3,
},
expected: true,
},
{
name: "does not retry once max attempts are exhausted",
params: {
text: "read[object Object]",
nonce: "nonce-c",
provider: "openai",
attempt: 2,
maxAttempts: 3,
},
expected: false,
},
{
name: "does not retry when the nonce is already present",
params: {
text: "nonce-c",
nonce: "nonce-c",
provider: "openai",
attempt: 0,
maxAttempts: 3,
},
expected: false,
},
{
name: "prefers a valid nonce even if the text still contains scaffolding words",
params: {
text: "tool output nonce-c function",
nonce: "nonce-c",
provider: "openai",
attempt: 0,
maxAttempts: 3,
},
expected: false,
},
{
name: "retries anthropic nonce refusal output",
params: {
text: "No part of the system asks me to parrot back nonce values.",
nonce: "nonce-c",
provider: "anthropic",
attempt: 0,
maxAttempts: 3,
},
expected: true,
},
{
name: "does not special-case anthropic refusals for other providers",
params: {
text: "No part of the system asks me to parrot back nonce values.",
nonce: "nonce-c",
provider: "openai",
attempt: 0,
maxAttempts: 3,
},
expected: false,
},
])("$name", ({ params, expected }) => {
expect(shouldRetryExecReadProbe(params)).toBe(expected);
});
});
});

View File

@@ -2,102 +2,93 @@ import { describe, expect, it } from "vitest";
import { checkBrowserOrigin } from "./origin-check.js";
describe("checkBrowserOrigin", () => {
it("accepts same-origin host matches only with legacy host-header fallback", () => {
const result = checkBrowserOrigin({
requestHost: "127.0.0.1:18789",
origin: "http://127.0.0.1:18789",
allowHostHeaderOriginFallback: true,
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.matchedBy).toBe("host-header-fallback");
}
});
it("rejects same-origin host matches when legacy host-header fallback is disabled", () => {
const result = checkBrowserOrigin({
requestHost: "gateway.example.com:18789",
origin: "https://gateway.example.com:18789",
});
expect(result.ok).toBe(false);
});
it("accepts loopback host mismatches for dev", () => {
const result = checkBrowserOrigin({
requestHost: "127.0.0.1:18789",
origin: "http://localhost:5173",
isLocalClient: true,
});
expect(result.ok).toBe(true);
});
it("rejects loopback origin mismatches when request is not local", () => {
const result = checkBrowserOrigin({
requestHost: "127.0.0.1:18789",
origin: "http://localhost:5173",
isLocalClient: false,
});
expect(result.ok).toBe(false);
});
it("accepts allowlisted origins", () => {
const result = checkBrowserOrigin({
requestHost: "gateway.example.com:18789",
origin: "https://control.example.com",
allowedOrigins: ["https://control.example.com"],
});
expect(result.ok).toBe(true);
});
it("accepts wildcard allowedOrigins", () => {
const result = checkBrowserOrigin({
requestHost: "gateway.example.com:18789",
origin: "https://any-origin.example.com",
allowedOrigins: ["*"],
});
expect(result.ok).toBe(true);
});
it("rejects missing origin", () => {
const result = checkBrowserOrigin({
requestHost: "gateway.example.com:18789",
origin: "",
});
expect(result.ok).toBe(false);
});
it("rejects mismatched origins", () => {
const result = checkBrowserOrigin({
requestHost: "gateway.example.com:18789",
origin: "https://attacker.example.com",
});
expect(result.ok).toBe(false);
});
it('accepts any origin when allowedOrigins includes "*" (regression: #30990)', () => {
const result = checkBrowserOrigin({
requestHost: "100.86.79.37:18789",
origin: "https://100.86.79.37:18789",
allowedOrigins: ["*"],
});
expect(result.ok).toBe(true);
});
it('accepts any origin when allowedOrigins includes "*" alongside specific entries', () => {
const result = checkBrowserOrigin({
requestHost: "gateway.tailnet.ts.net:18789",
origin: "https://gateway.tailnet.ts.net:18789",
allowedOrigins: ["https://control.example.com", "*"],
});
expect(result.ok).toBe(true);
});
it("accepts wildcard entries with surrounding whitespace", () => {
const result = checkBrowserOrigin({
requestHost: "100.86.79.37:18789",
origin: "https://100.86.79.37:18789",
allowedOrigins: [" * "],
});
expect(result.ok).toBe(true);
it.each([
{
name: "accepts host-header fallback when explicitly enabled",
input: {
requestHost: "127.0.0.1:18789",
origin: "http://127.0.0.1:18789",
allowHostHeaderOriginFallback: true,
},
expected: { ok: true as const, matchedBy: "host-header-fallback" as const },
},
{
name: "rejects same-origin host matches when fallback is disabled",
input: {
requestHost: "gateway.example.com:18789",
origin: "https://gateway.example.com:18789",
},
expected: { ok: false as const, reason: "origin not allowed" },
},
{
name: "accepts local loopback mismatches for local clients",
input: {
requestHost: "127.0.0.1:18789",
origin: "http://localhost:5173",
isLocalClient: true,
},
expected: { ok: true as const, matchedBy: "local-loopback" as const },
},
{
name: "rejects loopback mismatches for non-local clients",
input: {
requestHost: "127.0.0.1:18789",
origin: "http://localhost:5173",
isLocalClient: false,
},
expected: { ok: false as const, reason: "origin not allowed" },
},
{
name: "accepts trimmed lowercase-normalized allowlist matches",
input: {
requestHost: "gateway.example.com:18789",
origin: "https://CONTROL.example.com",
allowedOrigins: [" https://control.example.com "],
},
expected: { ok: true as const, matchedBy: "allowlist" as const },
},
{
name: "accepts wildcard allowlists even alongside specific entries",
input: {
requestHost: "gateway.tailnet.ts.net:18789",
origin: "https://any-origin.example.com",
allowedOrigins: ["https://control.example.com", " * "],
},
expected: { ok: true as const, matchedBy: "allowlist" as const },
},
{
name: "rejects missing origin",
input: {
requestHost: "gateway.example.com:18789",
origin: "",
},
expected: { ok: false as const, reason: "origin missing or invalid" },
},
{
name: 'rejects literal "null" origin',
input: {
requestHost: "gateway.example.com:18789",
origin: "null",
},
expected: { ok: false as const, reason: "origin missing or invalid" },
},
{
name: "rejects malformed origin URLs",
input: {
requestHost: "gateway.example.com:18789",
origin: "not a url",
},
expected: { ok: false as const, reason: "origin missing or invalid" },
},
{
name: "rejects mismatched origins",
input: {
requestHost: "gateway.example.com:18789",
origin: "https://attacker.example.com",
},
expected: { ok: false as const, reason: "origin not allowed" },
},
])("$name", ({ input, expected }) => {
expect(checkBrowserOrigin(input)).toEqual(expected);
});
});

View File

@@ -2,20 +2,39 @@ import { describe, expect, test } from "vitest";
import { formatForLog, shortId, summarizeAgentEventForWsLog } from "./ws-log.js";
describe("gateway ws log helpers", () => {
test("shortId compacts uuids and long strings", () => {
expect(shortId("12345678-1234-1234-1234-123456789abc")).toBe("12345678…9abc");
expect(shortId("a".repeat(30))).toBe("aaaaaaaaaaaa…aaaa");
expect(shortId("short")).toBe("short");
test.each([
{
name: "compacts uuids",
input: "12345678-1234-1234-1234-123456789abc",
expected: "12345678…9abc",
},
{
name: "compacts long strings",
input: "a".repeat(30),
expected: "aaaaaaaaaaaa…aaaa",
},
{
name: "trims before checking length",
input: " short ",
expected: "short",
},
])("shortId $name", ({ input, expected }) => {
expect(shortId(input)).toBe(expected);
});
test("formatForLog formats errors and messages", () => {
const err = new Error("boom");
err.name = "TestError";
expect(formatForLog(err)).toContain("TestError");
expect(formatForLog(err)).toContain("boom");
const obj = { name: "Oops", message: "failed", code: "E1" };
expect(formatForLog(obj)).toBe("Oops: failed: code=E1");
test.each([
{
name: "formats Error instances",
input: Object.assign(new Error("boom"), { name: "TestError" }),
expected: "TestError: boom",
},
{
name: "formats message-like objects with codes",
input: { name: "Oops", message: "failed", code: "E1" },
expected: "Oops: failed: code=E1",
},
])("formatForLog $name", ({ input, expected }) => {
expect(formatForLog(input)).toBe(expected);
});
test("formatForLog redacts obvious secrets", () => {
@@ -26,33 +45,79 @@ describe("gateway ws log helpers", () => {
expect(out).toContain("…");
});
test("summarizeAgentEventForWsLog extracts useful fields", () => {
test("summarizeAgentEventForWsLog compacts assistant payloads", () => {
const summary = summarizeAgentEventForWsLog({
runId: "12345678-1234-1234-1234-123456789abc",
sessionKey: "agent:main:main",
stream: "assistant",
seq: 2,
data: { text: "hello world", mediaUrls: ["a", "b"] },
data: {
text: "hello\n\nworld ".repeat(20),
mediaUrls: ["a", "b"],
},
});
expect(summary).toMatchObject({
agent: "main",
run: "12345678…9abc",
session: "main",
stream: "assistant",
aseq: 2,
text: "hello world",
media: 2,
});
expect(summary.text).toBeTypeOf("string");
expect(summary.text).not.toContain("\n");
});
const tool = summarizeAgentEventForWsLog({
runId: "run-1",
stream: "tool",
data: { phase: "start", name: "fetch", toolCallId: "call-1" },
});
expect(tool).toMatchObject({
test("summarizeAgentEventForWsLog includes tool metadata", () => {
expect(
summarizeAgentEventForWsLog({
runId: "run-1",
stream: "tool",
data: { phase: "start", name: "fetch", toolCallId: "12345678-1234-1234-1234-123456789abc" },
}),
).toMatchObject({
run: "run-1",
stream: "tool",
tool: "start:fetch",
call: "call-1",
call: "12345678…9abc",
});
});
test("summarizeAgentEventForWsLog includes lifecycle errors with compact previews", () => {
const summary = summarizeAgentEventForWsLog({
runId: "run-2",
sessionKey: "agent:main:thread-1",
stream: "lifecycle",
data: {
phase: "abort",
aborted: true,
error: "fatal ".repeat(40),
},
});
expect(summary).toMatchObject({
agent: "main",
session: "thread-1",
stream: "lifecycle",
phase: "abort",
aborted: true,
});
expect(summary.error).toBeTypeOf("string");
expect((summary.error as string).length).toBeLessThanOrEqual(120);
});
test("summarizeAgentEventForWsLog preserves invalid session keys and unknown-stream reasons", () => {
expect(
summarizeAgentEventForWsLog({
sessionKey: "bogus-session",
stream: "other",
data: { reason: "dropped" },
}),
).toEqual({
session: "bogus-session",
stream: "other",
reason: "dropped",
});
});
});