import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { createHmac } from "node:crypto"; import { verifySlackSignature, participantForSlackUser } from "@/lib/slack-verify"; /** * The signature is one of the two gates on a route that writes splits. Getting * it wrong is not a cosmetic bug — it is an unauthenticated write path — so the * fail-closed cases are tested first and explicitly. */ const SECRET = "test-signing-secret"; function sign(body: string, ts: string, secret = SECRET) { return "v0=" + createHmac("sha256", secret).update(`v0:${ts}:${body}`).digest("hex"); } const now = () => String(Math.floor(Date.now() / 1000)); beforeEach(() => { process.env.SLACK_SIGNING_SECRET = SECRET; }); afterEach(() => { vi.useRealTimers(); }); describe("verifySlackSignature", () => { it("accepts a correctly signed request", () => { const ts = now(); const body = "payload=%7B%22type%22%3A%22block_actions%22%7D"; expect(verifySlackSignature(body, ts, sign(body, ts))).toBe(true); }); it("rejects when the signing secret is unset", () => { const ts = now(); const body = "x=1"; const sig = sign(body, ts); delete process.env.SLACK_SIGNING_SECRET; // Fails CLOSED. An unset secret waving requests through would turn a // misconfigured deploy into an open write endpoint. expect(verifySlackSignature(body, ts, sig)).toBe(false); }); it("rejects a tampered body", () => { const ts = now(); const sig = sign("payload=original", ts); expect(verifySlackSignature("payload=tampered", ts, sig)).toBe(false); }); it("rejects a signature made with a different secret", () => { const ts = now(); const body = "x=1"; expect(verifySlackSignature(body, ts, sign(body, ts, "wrong-secret"))).toBe(false); }); it("rejects a replay outside the 5 minute window", () => { const old = String(Math.floor(Date.now() / 1000) - 400); const body = "x=1"; expect(verifySlackSignature(body, old, sign(body, old))).toBe(false); }); it("accepts inside the window", () => { const recent = String(Math.floor(Date.now() / 1000) - 60); const body = "x=1"; expect(verifySlackSignature(body, recent, sign(body, recent))).toBe(true); }); it("rejects missing headers", () => { expect(verifySlackSignature("x=1", null, "v0=abc")).toBe(false); expect(verifySlackSignature("x=1", now(), null)).toBe(false); }); it("rejects a signature of the wrong length without throwing", () => { // timingSafeEqual throws on length mismatch; a truncated signature must be // a plain false, not a 500. expect(() => verifySlackSignature("x=1", now(), "v0=short")).not.toThrow(); expect(verifySlackSignature("x=1", now(), "v0=short")).toBe(false); }); }); describe("participantForSlackUser", () => { beforeEach(() => { process.env.SLACK_USER_MAP = "U111:1, U444:4"; }); it("maps known users", () => { expect(participantForSlackUser("U111")).toBe(1); expect(participantForSlackUser("U444")).toBe(4); }); it("returns null for an unknown user rather than defaulting to the owner", () => { // A wrong attribution records the other person's opinion under your name, // which is worse than refusing. expect(participantForSlackUser("U999")).toBeNull(); }); it("returns null when the map is unset", () => { delete process.env.SLACK_USER_MAP; expect(participantForSlackUser("U111")).toBeNull(); }); });