refactor: share mattermost interaction test helpers

This commit is contained in:
Peter Steinberger 2026-03-13 20:10:09 +00:00
parent e731974da1
commit 2dd180472f
1 changed files with 142 additions and 170 deletions

View File

@ -496,6 +496,104 @@ describe("createMattermostInteractionHandler", () => {
return res as unknown as ServerResponse & { headers: Record<string, string>; body: string };
}
function createActionContext(actionId = "approve", channelId = "chan-1") {
const context = { action_id: actionId, __openclaw_channel_id: channelId };
return { context, token: generateInteractionToken(context, "acct") };
}
function createInteractionBody(params: {
context: Record<string, unknown>;
token: string;
channelId?: string;
postId?: string;
userId?: string;
userName?: string;
}) {
return {
user_id: params.userId ?? "user-1",
...(params.userName ? { user_name: params.userName } : {}),
channel_id: params.channelId ?? "chan-1",
post_id: params.postId ?? "post-1",
context: { ...params.context, _token: params.token },
};
}
async function runHandler(
handler: ReturnType<typeof createMattermostInteractionHandler>,
params: {
body: unknown;
remoteAddress?: string;
headers?: Record<string, string>;
},
) {
const req = createReq({
remoteAddress: params.remoteAddress,
headers: params.headers,
body: params.body,
});
const res = createRes();
await handler(req, res);
return res;
}
function expectForbiddenResponse(
res: ServerResponse & { body: string },
expectedMessage: string,
) {
expect(res.statusCode).toBe(403);
expect(res.body).toContain(expectedMessage);
}
function expectSuccessfulApprovalUpdate(
res: ServerResponse & { body: string },
requestLog?: Array<{ path: string; method?: string }>,
) {
expect(res.statusCode).toBe(200);
expect(res.body).toBe("{}");
if (requestLog) {
expect(requestLog).toEqual([
{ path: "/posts/post-1", method: undefined },
{ path: "/posts/post-1", method: "PUT" },
]);
}
}
function createActionPost(params?: {
actionId?: string;
actionName?: string;
channelId?: string;
rootId?: string;
}): MattermostPost {
return {
id: "post-1",
channel_id: params?.channelId ?? "chan-1",
...(params?.rootId ? { root_id: params.rootId } : {}),
message: "Choose",
props: {
attachments: [
{
actions: [
{
id: params?.actionId ?? "approve",
name: params?.actionName ?? "Approve",
},
],
},
],
},
};
}
function createUnusedInteractionHandler() {
return createMattermostInteractionHandler({
client: {
request: async () => ({ message: "unused" }),
} as unknown as MattermostClient,
botUserId: "bot",
accountId: "acct",
});
}
async function runApproveInteraction(params?: {
actionName?: string;
allowedSourceIps?: string[];
@ -503,8 +601,7 @@ describe("createMattermostInteractionHandler", () => {
remoteAddress?: string;
headers?: Record<string, string>;
}) {
const context = { action_id: "approve", __openclaw_channel_id: "chan-1" };
const token = generateInteractionToken(context, "acct");
const { context, token } = createActionContext();
const requestLog: Array<{ path: string; method?: string }> = [];
const handler = createMattermostInteractionHandler({
client: {
@ -513,15 +610,7 @@ describe("createMattermostInteractionHandler", () => {
if (init?.method === "PUT") {
return { id: "post-1" };
}
return {
channel_id: "chan-1",
message: "Choose",
props: {
attachments: [
{ actions: [{ id: "approve", name: params?.actionName ?? "Approve" }] },
],
},
};
return createActionPost({ actionName: params?.actionName });
},
} as unknown as MattermostClient,
botUserId: "bot",
@ -530,50 +619,27 @@ describe("createMattermostInteractionHandler", () => {
trustedProxies: params?.trustedProxies,
});
const req = createReq({
const res = await runHandler(handler, {
remoteAddress: params?.remoteAddress,
headers: params?.headers,
body: {
user_id: "user-1",
user_name: "alice",
channel_id: "chan-1",
post_id: "post-1",
context: { ...context, _token: token },
},
body: createInteractionBody({ context, token, userName: "alice" }),
});
const res = createRes();
await handler(req, res);
return { res, requestLog };
}
async function runInvalidActionRequest(actionId: string) {
const context = { action_id: "approve", __openclaw_channel_id: "chan-1" };
const token = generateInteractionToken(context, "acct");
const { context, token } = createActionContext();
const handler = createMattermostInteractionHandler({
client: {
request: async () => ({
channel_id: "chan-1",
message: "Choose",
props: {
attachments: [{ actions: [{ id: actionId, name: actionId }] }],
},
}),
request: async () => createActionPost({ actionId, actionName: actionId }),
} as unknown as MattermostClient,
botUserId: "bot",
accountId: "acct",
});
const req = createReq({
body: {
user_id: "user-1",
channel_id: "chan-1",
post_id: "post-1",
context: { ...context, _token: token },
},
return await runHandler(handler, {
body: createInteractionBody({ context, token }),
});
const res = createRes();
await handler(req, res);
return res;
}
it("accepts callback requests from an allowlisted source IP", async () => {
@ -582,12 +648,7 @@ describe("createMattermostInteractionHandler", () => {
remoteAddress: "198.51.100.8",
});
expect(res.statusCode).toBe(200);
expect(res.body).toBe("{}");
expect(requestLog).toEqual([
{ path: "/posts/post-1", method: undefined },
{ path: "/posts/post-1", method: "PUT" },
]);
expectSuccessfulApprovalUpdate(res, requestLog);
});
it("accepts forwarded Mattermost source IPs from a trusted proxy", async () => {
@ -603,8 +664,7 @@ describe("createMattermostInteractionHandler", () => {
});
it("rejects callback requests from non-allowlisted source IPs", async () => {
const context = { action_id: "approve", __openclaw_channel_id: "chan-1" };
const token = generateInteractionToken(context, "acct");
const { context, token } = createActionContext();
const handler = createMattermostInteractionHandler({
client: {
request: async () => {
@ -616,33 +676,17 @@ describe("createMattermostInteractionHandler", () => {
allowedSourceIps: ["127.0.0.1"],
});
const req = createReq({
const res = await runHandler(handler, {
remoteAddress: "198.51.100.8",
body: {
user_id: "user-1",
channel_id: "chan-1",
post_id: "post-1",
context: { ...context, _token: token },
},
body: createInteractionBody({ context, token }),
});
const res = createRes();
await handler(req, res);
expect(res.statusCode).toBe(403);
expect(res.body).toContain("Forbidden origin");
expectForbiddenResponse(res, "Forbidden origin");
});
it("rejects requests with an invalid interaction token", async () => {
const handler = createMattermostInteractionHandler({
client: {
request: async () => ({ message: "unused" }),
} as unknown as MattermostClient,
botUserId: "bot",
accountId: "acct",
});
const handler = createUnusedInteractionHandler();
const req = createReq({
const res = await runHandler(handler, {
body: {
user_id: "user-1",
channel_id: "chan-1",
@ -650,72 +694,33 @@ describe("createMattermostInteractionHandler", () => {
context: { action_id: "approve", _token: "deadbeef" },
},
});
const res = createRes();
await handler(req, res);
expect(res.statusCode).toBe(403);
expect(res.body).toContain("Invalid token");
expectForbiddenResponse(res, "Invalid token");
});
it("rejects requests when the signed channel does not match the callback payload", async () => {
const context = { action_id: "approve", __openclaw_channel_id: "chan-1" };
const token = generateInteractionToken(context, "acct");
const handler = createMattermostInteractionHandler({
client: {
request: async () => ({ message: "unused" }),
} as unknown as MattermostClient,
botUserId: "bot",
accountId: "acct",
const { context, token } = createActionContext();
const handler = createUnusedInteractionHandler();
const res = await runHandler(handler, {
body: createInteractionBody({ context, token, channelId: "chan-2" }),
});
const req = createReq({
body: {
user_id: "user-1",
channel_id: "chan-2",
post_id: "post-1",
context: { ...context, _token: token },
},
});
const res = createRes();
await handler(req, res);
expect(res.statusCode).toBe(403);
expect(res.body).toContain("Channel mismatch");
expectForbiddenResponse(res, "Channel mismatch");
});
it("rejects requests when the fetched post does not belong to the callback channel", async () => {
const context = { action_id: "approve", __openclaw_channel_id: "chan-1" };
const token = generateInteractionToken(context, "acct");
const { context, token } = createActionContext();
const handler = createMattermostInteractionHandler({
client: {
request: async () => ({
channel_id: "chan-9",
message: "Choose",
props: {
attachments: [{ actions: [{ id: "approve", name: "Approve" }] }],
},
}),
request: async () => createActionPost({ channelId: "chan-9" }),
} as unknown as MattermostClient,
botUserId: "bot",
accountId: "acct",
});
const req = createReq({
body: {
user_id: "user-1",
channel_id: "chan-1",
post_id: "post-1",
context: { ...context, _token: token },
},
const res = await runHandler(handler, {
body: createInteractionBody({ context, token }),
});
const res = createRes();
await handler(req, res);
expect(res.statusCode).toBe(403);
expect(res.body).toContain("Post/channel mismatch");
expectForbiddenResponse(res, "Post/channel mismatch");
});
it("rejects requests when the action is not present on the fetched post", async () => {
@ -730,12 +735,7 @@ describe("createMattermostInteractionHandler", () => {
actionName: "approve",
});
expect(res.statusCode).toBe(200);
expect(res.body).toBe("{}");
expect(requestLog).toEqual([
{ path: "/posts/post-1", method: undefined },
{ path: "/posts/post-1", method: "PUT" },
]);
expectSuccessfulApprovalUpdate(res, requestLog);
});
it("forwards fetched post threading metadata to session and button callbacks", async () => {
@ -745,19 +745,10 @@ describe("createMattermostInteractionHandler", () => {
enqueueSystemEvent,
},
} as unknown as Parameters<typeof setMattermostRuntime>[0]);
const context = { action_id: "approve", __openclaw_channel_id: "chan-1" };
const token = generateInteractionToken(context, "acct");
const { context, token } = createActionContext();
const resolveSessionKey = vi.fn().mockResolvedValue("session:thread:root-9");
const dispatchButtonClick = vi.fn();
const fetchedPost: MattermostPost = {
id: "post-1",
channel_id: "chan-1",
root_id: "root-9",
message: "Choose",
props: {
attachments: [{ actions: [{ id: "approve", name: "Approve" }] }],
},
};
const fetchedPost = createActionPost({ rootId: "root-9" });
const handler = createMattermostInteractionHandler({
client: {
request: async (_path: string, init?: { method?: string }) =>
@ -769,19 +760,9 @@ describe("createMattermostInteractionHandler", () => {
dispatchButtonClick,
});
const req = createReq({
body: {
user_id: "user-1",
user_name: "alice",
channel_id: "chan-1",
post_id: "post-1",
context: { ...context, _token: token },
},
const res = await runHandler(handler, {
body: createInteractionBody({ context, token, userName: "alice" }),
});
const res = createRes();
await handler(req, res);
expect(res.statusCode).toBe(200);
expect(resolveSessionKey).toHaveBeenCalledWith({
channelId: "chan-1",
@ -803,8 +784,7 @@ describe("createMattermostInteractionHandler", () => {
});
it("lets a custom interaction handler short-circuit generic completion updates", async () => {
const context = { action_id: "mdlprov", __openclaw_channel_id: "chan-1" };
const token = generateInteractionToken(context, "acct");
const { context, token } = createActionContext("mdlprov");
const requestLog: Array<{ path: string; method?: string }> = [];
const handleInteraction = vi.fn().mockResolvedValue({
ephemeral_text: "Only the original requester can use this picker.",
@ -814,14 +794,10 @@ describe("createMattermostInteractionHandler", () => {
client: {
request: async (path: string, init?: { method?: string }) => {
requestLog.push({ path, method: init?.method });
return {
id: "post-1",
channel_id: "chan-1",
message: "Choose",
props: {
attachments: [{ actions: [{ id: "mdlprov", name: "Browse providers" }] }],
},
};
return createActionPost({
actionId: "mdlprov",
actionName: "Browse providers",
});
},
} as unknown as MattermostClient,
botUserId: "bot",
@ -830,18 +806,14 @@ describe("createMattermostInteractionHandler", () => {
dispatchButtonClick,
});
const req = createReq({
body: {
user_id: "user-2",
user_name: "alice",
channel_id: "chan-1",
post_id: "post-1",
context: { ...context, _token: token },
},
const res = await runHandler(handler, {
body: createInteractionBody({
context,
token,
userId: "user-2",
userName: "alice",
}),
});
const res = createRes();
await handler(req, res);
expect(res.statusCode).toBe(200);
expect(res.body).toBe(