test: tighten gateway helper coverage
This commit is contained in:
@@ -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" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user