486 lines
16 KiB
TypeScript
486 lines
16 KiB
TypeScript
import { describe, expect, test } from "vitest";
|
|
import { resolveMainSessionKeyFromConfig } from "../config/sessions.js";
|
|
import { drainSystemEvents, peekSystemEvents } from "../infra/system-events.js";
|
|
import {
|
|
cronIsolatedRun,
|
|
getFreePort,
|
|
installGatewayTestHooks,
|
|
startGatewayServer,
|
|
testState,
|
|
waitForSystemEvent,
|
|
} from "./test-helpers.js";
|
|
|
|
installGatewayTestHooks({ scope: "suite" });
|
|
|
|
const resolveMainKey = () => resolveMainSessionKeyFromConfig();
|
|
|
|
describe("gateway server hooks", () => {
|
|
test("handles auth, wake, and agent flows", async () => {
|
|
testState.hooksConfig = { enabled: true, token: "hook-secret" };
|
|
testState.agentsConfig = {
|
|
list: [{ id: "main", default: true }, { id: "hooks" }],
|
|
};
|
|
const port = await getFreePort();
|
|
const server = await startGatewayServer(port);
|
|
try {
|
|
const resNoAuth = await fetch(`http://127.0.0.1:${port}/hooks/wake`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ text: "Ping" }),
|
|
});
|
|
expect(resNoAuth.status).toBe(401);
|
|
|
|
const resWake = await fetch(`http://127.0.0.1:${port}/hooks/wake`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: "Bearer hook-secret",
|
|
},
|
|
body: JSON.stringify({ text: "Ping", mode: "next-heartbeat" }),
|
|
});
|
|
expect(resWake.status).toBe(200);
|
|
const wakeEvents = await waitForSystemEvent();
|
|
expect(wakeEvents.some((e) => e.includes("Ping"))).toBe(true);
|
|
drainSystemEvents(resolveMainKey());
|
|
|
|
cronIsolatedRun.mockReset();
|
|
cronIsolatedRun.mockResolvedValueOnce({
|
|
status: "ok",
|
|
summary: "done",
|
|
});
|
|
const resAgent = await fetch(`http://127.0.0.1:${port}/hooks/agent`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: "Bearer hook-secret",
|
|
},
|
|
body: JSON.stringify({ message: "Do it", name: "Email" }),
|
|
});
|
|
expect(resAgent.status).toBe(202);
|
|
const agentEvents = await waitForSystemEvent();
|
|
expect(agentEvents.some((e) => e.includes("Hook Email: done"))).toBe(true);
|
|
drainSystemEvents(resolveMainKey());
|
|
|
|
cronIsolatedRun.mockReset();
|
|
cronIsolatedRun.mockResolvedValueOnce({
|
|
status: "ok",
|
|
summary: "done",
|
|
});
|
|
const resAgentModel = await fetch(`http://127.0.0.1:${port}/hooks/agent`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: "Bearer hook-secret",
|
|
},
|
|
body: JSON.stringify({
|
|
message: "Do it",
|
|
name: "Email",
|
|
model: "openai/gpt-4.1-mini",
|
|
}),
|
|
});
|
|
expect(resAgentModel.status).toBe(202);
|
|
await waitForSystemEvent();
|
|
const call = cronIsolatedRun.mock.calls[0]?.[0] as {
|
|
job?: { payload?: { model?: string } };
|
|
};
|
|
expect(call?.job?.payload?.model).toBe("openai/gpt-4.1-mini");
|
|
drainSystemEvents(resolveMainKey());
|
|
|
|
cronIsolatedRun.mockReset();
|
|
cronIsolatedRun.mockResolvedValueOnce({
|
|
status: "ok",
|
|
summary: "done",
|
|
});
|
|
const resAgentWithId = await fetch(`http://127.0.0.1:${port}/hooks/agent`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: "Bearer hook-secret",
|
|
},
|
|
body: JSON.stringify({ message: "Do it", name: "Email", agentId: "hooks" }),
|
|
});
|
|
expect(resAgentWithId.status).toBe(202);
|
|
await waitForSystemEvent();
|
|
const routedCall = cronIsolatedRun.mock.calls[0]?.[0] as {
|
|
job?: { agentId?: string };
|
|
};
|
|
expect(routedCall?.job?.agentId).toBe("hooks");
|
|
drainSystemEvents(resolveMainKey());
|
|
|
|
cronIsolatedRun.mockReset();
|
|
cronIsolatedRun.mockResolvedValueOnce({
|
|
status: "ok",
|
|
summary: "done",
|
|
});
|
|
const resAgentUnknown = await fetch(`http://127.0.0.1:${port}/hooks/agent`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: "Bearer hook-secret",
|
|
},
|
|
body: JSON.stringify({ message: "Do it", name: "Email", agentId: "missing-agent" }),
|
|
});
|
|
expect(resAgentUnknown.status).toBe(202);
|
|
await waitForSystemEvent();
|
|
const fallbackCall = cronIsolatedRun.mock.calls[0]?.[0] as {
|
|
job?: { agentId?: string };
|
|
};
|
|
expect(fallbackCall?.job?.agentId).toBe("main");
|
|
drainSystemEvents(resolveMainKey());
|
|
|
|
const resQuery = await fetch(`http://127.0.0.1:${port}/hooks/wake?token=hook-secret`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ text: "Query auth" }),
|
|
});
|
|
expect(resQuery.status).toBe(400);
|
|
|
|
const resBadChannel = await fetch(`http://127.0.0.1:${port}/hooks/agent`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: "Bearer hook-secret",
|
|
},
|
|
body: JSON.stringify({ message: "Nope", channel: "sms" }),
|
|
});
|
|
expect(resBadChannel.status).toBe(400);
|
|
expect(peekSystemEvents(resolveMainKey()).length).toBe(0);
|
|
|
|
const resHeader = await fetch(`http://127.0.0.1:${port}/hooks/wake`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
"x-openclaw-token": "hook-secret",
|
|
},
|
|
body: JSON.stringify({ text: "Header auth" }),
|
|
});
|
|
expect(resHeader.status).toBe(200);
|
|
const headerEvents = await waitForSystemEvent();
|
|
expect(headerEvents.some((e) => e.includes("Header auth"))).toBe(true);
|
|
drainSystemEvents(resolveMainKey());
|
|
|
|
const resGet = await fetch(`http://127.0.0.1:${port}/hooks/wake`, {
|
|
method: "GET",
|
|
headers: { Authorization: "Bearer hook-secret" },
|
|
});
|
|
expect(resGet.status).toBe(405);
|
|
|
|
const resBlankText = await fetch(`http://127.0.0.1:${port}/hooks/wake`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: "Bearer hook-secret",
|
|
},
|
|
body: JSON.stringify({ text: " " }),
|
|
});
|
|
expect(resBlankText.status).toBe(400);
|
|
|
|
const resBlankMessage = await fetch(`http://127.0.0.1:${port}/hooks/agent`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: "Bearer hook-secret",
|
|
},
|
|
body: JSON.stringify({ message: " " }),
|
|
});
|
|
expect(resBlankMessage.status).toBe(400);
|
|
|
|
const resBadJson = await fetch(`http://127.0.0.1:${port}/hooks/wake`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: "Bearer hook-secret",
|
|
},
|
|
body: "{",
|
|
});
|
|
expect(resBadJson.status).toBe(400);
|
|
} finally {
|
|
await server.close();
|
|
}
|
|
});
|
|
|
|
test("rejects request sessionKey unless hooks.allowRequestSessionKey is enabled", async () => {
|
|
testState.hooksConfig = { enabled: true, token: "hook-secret" };
|
|
const port = await getFreePort();
|
|
const server = await startGatewayServer(port);
|
|
try {
|
|
const denied = await fetch(`http://127.0.0.1:${port}/hooks/agent`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: "Bearer hook-secret",
|
|
},
|
|
body: JSON.stringify({
|
|
message: "Do it",
|
|
sessionKey: "agent:main:dm:u99999",
|
|
}),
|
|
});
|
|
expect(denied.status).toBe(400);
|
|
const deniedBody = (await denied.json()) as { error?: string };
|
|
expect(deniedBody.error).toContain("hooks.allowRequestSessionKey");
|
|
} finally {
|
|
await server.close();
|
|
}
|
|
});
|
|
|
|
test("respects hooks session policy for request + mapping session keys", async () => {
|
|
testState.hooksConfig = {
|
|
enabled: true,
|
|
token: "hook-secret",
|
|
allowRequestSessionKey: true,
|
|
allowedSessionKeyPrefixes: ["hook:"],
|
|
defaultSessionKey: "hook:ingress",
|
|
mappings: [
|
|
{
|
|
match: { path: "mapped-ok" },
|
|
action: "agent",
|
|
messageTemplate: "Mapped: {{payload.subject}}",
|
|
sessionKey: "hook:mapped:{{payload.id}}",
|
|
},
|
|
{
|
|
match: { path: "mapped-bad" },
|
|
action: "agent",
|
|
messageTemplate: "Mapped: {{payload.subject}}",
|
|
sessionKey: "agent:main:main",
|
|
},
|
|
],
|
|
};
|
|
const port = await getFreePort();
|
|
const server = await startGatewayServer(port);
|
|
try {
|
|
cronIsolatedRun.mockReset();
|
|
cronIsolatedRun.mockResolvedValue({ status: "ok", summary: "done" });
|
|
|
|
const defaultRoute = await fetch(`http://127.0.0.1:${port}/hooks/agent`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: "Bearer hook-secret",
|
|
},
|
|
body: JSON.stringify({ message: "No key" }),
|
|
});
|
|
expect(defaultRoute.status).toBe(202);
|
|
await waitForSystemEvent();
|
|
const defaultCall = cronIsolatedRun.mock.calls[0]?.[0] as { sessionKey?: string } | undefined;
|
|
expect(defaultCall?.sessionKey).toBe("hook:ingress");
|
|
drainSystemEvents(resolveMainKey());
|
|
|
|
cronIsolatedRun.mockReset();
|
|
cronIsolatedRun.mockResolvedValue({ status: "ok", summary: "done" });
|
|
const mappedOk = await fetch(`http://127.0.0.1:${port}/hooks/mapped-ok`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: "Bearer hook-secret",
|
|
},
|
|
body: JSON.stringify({ subject: "hello", id: "42" }),
|
|
});
|
|
expect(mappedOk.status).toBe(202);
|
|
await waitForSystemEvent();
|
|
const mappedCall = cronIsolatedRun.mock.calls[0]?.[0] as { sessionKey?: string } | undefined;
|
|
expect(mappedCall?.sessionKey).toBe("hook:mapped:42");
|
|
drainSystemEvents(resolveMainKey());
|
|
|
|
const requestBadPrefix = await fetch(`http://127.0.0.1:${port}/hooks/agent`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: "Bearer hook-secret",
|
|
},
|
|
body: JSON.stringify({
|
|
message: "Bad key",
|
|
sessionKey: "agent:main:main",
|
|
}),
|
|
});
|
|
expect(requestBadPrefix.status).toBe(400);
|
|
|
|
const mappedBadPrefix = await fetch(`http://127.0.0.1:${port}/hooks/mapped-bad`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: "Bearer hook-secret",
|
|
},
|
|
body: JSON.stringify({ subject: "hello" }),
|
|
});
|
|
expect(mappedBadPrefix.status).toBe(400);
|
|
} finally {
|
|
await server.close();
|
|
}
|
|
});
|
|
|
|
test("enforces hooks.allowedAgentIds for explicit agent routing", async () => {
|
|
testState.hooksConfig = {
|
|
enabled: true,
|
|
token: "hook-secret",
|
|
allowedAgentIds: ["hooks"],
|
|
mappings: [
|
|
{
|
|
match: { path: "mapped" },
|
|
action: "agent",
|
|
agentId: "main",
|
|
messageTemplate: "Mapped: {{payload.subject}}",
|
|
},
|
|
],
|
|
};
|
|
testState.agentsConfig = {
|
|
list: [{ id: "main", default: true }, { id: "hooks" }],
|
|
};
|
|
const port = await getFreePort();
|
|
const server = await startGatewayServer(port);
|
|
try {
|
|
cronIsolatedRun.mockReset();
|
|
cronIsolatedRun.mockResolvedValueOnce({
|
|
status: "ok",
|
|
summary: "done",
|
|
});
|
|
const resNoAgent = await fetch(`http://127.0.0.1:${port}/hooks/agent`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: "Bearer hook-secret",
|
|
},
|
|
body: JSON.stringify({ message: "No explicit agent" }),
|
|
});
|
|
expect(resNoAgent.status).toBe(202);
|
|
await waitForSystemEvent();
|
|
const noAgentCall = cronIsolatedRun.mock.calls[0]?.[0] as {
|
|
job?: { agentId?: string };
|
|
};
|
|
expect(noAgentCall?.job?.agentId).toBeUndefined();
|
|
drainSystemEvents(resolveMainKey());
|
|
|
|
cronIsolatedRun.mockReset();
|
|
cronIsolatedRun.mockResolvedValueOnce({
|
|
status: "ok",
|
|
summary: "done",
|
|
});
|
|
const resAllowed = await fetch(`http://127.0.0.1:${port}/hooks/agent`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: "Bearer hook-secret",
|
|
},
|
|
body: JSON.stringify({ message: "Allowed", agentId: "hooks" }),
|
|
});
|
|
expect(resAllowed.status).toBe(202);
|
|
await waitForSystemEvent();
|
|
const allowedCall = cronIsolatedRun.mock.calls[0]?.[0] as {
|
|
job?: { agentId?: string };
|
|
};
|
|
expect(allowedCall?.job?.agentId).toBe("hooks");
|
|
drainSystemEvents(resolveMainKey());
|
|
|
|
const resDenied = await fetch(`http://127.0.0.1:${port}/hooks/agent`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: "Bearer hook-secret",
|
|
},
|
|
body: JSON.stringify({ message: "Denied", agentId: "main" }),
|
|
});
|
|
expect(resDenied.status).toBe(400);
|
|
const deniedBody = (await resDenied.json()) as { error?: string };
|
|
expect(deniedBody.error).toContain("hooks.allowedAgentIds");
|
|
|
|
const resMappedDenied = await fetch(`http://127.0.0.1:${port}/hooks/mapped`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: "Bearer hook-secret",
|
|
},
|
|
body: JSON.stringify({ subject: "hello" }),
|
|
});
|
|
expect(resMappedDenied.status).toBe(400);
|
|
const mappedDeniedBody = (await resMappedDenied.json()) as { error?: string };
|
|
expect(mappedDeniedBody.error).toContain("hooks.allowedAgentIds");
|
|
expect(peekSystemEvents(resolveMainKey()).length).toBe(0);
|
|
} finally {
|
|
await server.close();
|
|
}
|
|
});
|
|
|
|
test("denies explicit agentId when hooks.allowedAgentIds is empty", async () => {
|
|
testState.hooksConfig = {
|
|
enabled: true,
|
|
token: "hook-secret",
|
|
allowedAgentIds: [],
|
|
};
|
|
testState.agentsConfig = {
|
|
list: [{ id: "main", default: true }, { id: "hooks" }],
|
|
};
|
|
const port = await getFreePort();
|
|
const server = await startGatewayServer(port);
|
|
try {
|
|
const resDenied = await fetch(`http://127.0.0.1:${port}/hooks/agent`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: "Bearer hook-secret",
|
|
},
|
|
body: JSON.stringify({ message: "Denied", agentId: "hooks" }),
|
|
});
|
|
expect(resDenied.status).toBe(400);
|
|
const deniedBody = (await resDenied.json()) as { error?: string };
|
|
expect(deniedBody.error).toContain("hooks.allowedAgentIds");
|
|
expect(peekSystemEvents(resolveMainKey()).length).toBe(0);
|
|
} finally {
|
|
await server.close();
|
|
}
|
|
});
|
|
|
|
test("throttles repeated hook auth failures and resets after success", async () => {
|
|
testState.hooksConfig = { enabled: true, token: "hook-secret" };
|
|
const port = await getFreePort();
|
|
const server = await startGatewayServer(port);
|
|
try {
|
|
const firstFail = await fetch(`http://127.0.0.1:${port}/hooks/wake`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: "Bearer wrong",
|
|
},
|
|
body: JSON.stringify({ text: "blocked" }),
|
|
});
|
|
expect(firstFail.status).toBe(401);
|
|
|
|
let throttled: Response | null = null;
|
|
for (let i = 0; i < 20; i++) {
|
|
throttled = await fetch(`http://127.0.0.1:${port}/hooks/wake`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: "Bearer wrong",
|
|
},
|
|
body: JSON.stringify({ text: "blocked" }),
|
|
});
|
|
}
|
|
expect(throttled?.status).toBe(429);
|
|
expect(throttled?.headers.get("retry-after")).toBeTruthy();
|
|
|
|
const allowed = await fetch(`http://127.0.0.1:${port}/hooks/wake`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: "Bearer hook-secret",
|
|
},
|
|
body: JSON.stringify({ text: "auth reset" }),
|
|
});
|
|
expect(allowed.status).toBe(200);
|
|
await waitForSystemEvent();
|
|
drainSystemEvents(resolveMainKey());
|
|
|
|
const failAfterSuccess = await fetch(`http://127.0.0.1:${port}/hooks/wake`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: "Bearer wrong",
|
|
},
|
|
body: JSON.stringify({ text: "blocked" }),
|
|
});
|
|
expect(failAfterSuccess.status).toBe(401);
|
|
} finally {
|
|
await server.close();
|
|
}
|
|
});
|
|
});
|