diff --git a/extensions/mattermost/src/mattermost/interactions.test.ts b/extensions/mattermost/src/mattermost/interactions.test.ts index 4d273e12da7..a3b7e10a04a 100644 --- a/extensions/mattermost/src/mattermost/interactions.test.ts +++ b/extensions/mattermost/src/mattermost/interactions.test.ts @@ -81,6 +81,28 @@ describe("generateInteractionToken / verifyInteractionToken", () => { const t2 = generateInteractionToken(context); expect(t1).toBe(t2); }); + + it("produces the same token regardless of key order", () => { + const contextA = { action_id: "do_now", tweet_id: "123", action: "do" }; + const contextB = { action: "do", action_id: "do_now", tweet_id: "123" }; + const contextC = { tweet_id: "123", action: "do", action_id: "do_now" }; + const tokenA = generateInteractionToken(contextA); + const tokenB = generateInteractionToken(contextB); + const tokenC = generateInteractionToken(contextC); + expect(tokenA).toBe(tokenB); + expect(tokenB).toBe(tokenC); + }); + + it("verifies a token when Mattermost reorders context keys", () => { + // Simulate: token generated with keys in one order, verified with keys in another + // (Mattermost reorders context keys when storing/returning interactive message payloads) + const originalContext = { action_id: "bm_do", tweet_id: "999", action: "do" }; + const token = generateInteractionToken(originalContext); + + // Mattermost returns keys in alphabetical order (or any arbitrary order) + const reorderedContext = { action: "do", action_id: "bm_do", tweet_id: "999" }; + expect(verifyInteractionToken(reorderedContext, token)).toBe(true); + }); }); // ── Callback URL registry ──────────────────────────────────────────── @@ -244,6 +266,25 @@ describe("buildButtonAttachments", () => { const { _token, ...contextWithoutToken } = ctx; expect(verifyInteractionToken(contextWithoutToken, token)).toBe(true); }); + + it("generates tokens that verify even when Mattermost reorders context keys", () => { + const result = buildButtonAttachments({ + callbackUrl: "http://localhost/cb", + buttons: [{ id: "do_action", name: "Do", context: { tweet_id: "42", category: "ai" } }], + }); + + const ctx = result[0].actions![0].integration.context; + const token = ctx._token as string; + + // Simulate Mattermost returning context with keys in a different order + const reordered: Record = {}; + const keys = Object.keys(ctx).filter((k) => k !== "_token"); + // Reverse the key order to simulate reordering + for (const key of keys.reverse()) { + reordered[key] = ctx[key]; + } + expect(verifyInteractionToken(reordered, token)).toBe(true); + }); }); // ── isLocalhostRequest ─────────────────────────────────────────────── diff --git a/extensions/mattermost/src/mattermost/interactions.ts b/extensions/mattermost/src/mattermost/interactions.ts index a6e2999b361..ab910b47d61 100644 --- a/extensions/mattermost/src/mattermost/interactions.ts +++ b/extensions/mattermost/src/mattermost/interactions.ts @@ -82,7 +82,8 @@ export function getInteractionSecret(): string { export function generateInteractionToken(context: Record): string { const secret = getInteractionSecret(); - const payload = JSON.stringify(context); + // Sort keys for stable serialization — Mattermost may reorder context keys + const payload = JSON.stringify(context, Object.keys(context).sort()); return createHmac("sha256", secret).update(payload).digest("hex"); }