ci / lint-test (push) Successful in 40s
Being sent to a web app to answer "was this shared?" is enough friction that the question stops getting answered — which is the exact failure the nudge exists to prevent. So the buttons now act in place: pressing "Shared 50/50" writes the transaction_splits row and edits the message, and the app is never opened. Slack does NOT reach this route directly. It posts to an n8n webhook that forwards the raw body and signature headers here (user's suggestion). That is the better shape: n8n already terminates public webhooks, so the app keeps its blanket OAuth chain and gains no internet-facing unauthenticated route, and no Traefik change is needed. n8n cannot verify the signature itself — its Code sandbox has no `require`, so no `crypto`. Two independent gates, both failing closed: the shared x-ingest-token (came from n8n) and Slack's v0 signature over the raw body (came from Slack, not replayed within 5 minutes). An unset signing secret rejects everything rather than waving it through, because the alternative turns a misconfigured deploy into an open write endpoint. An unmapped Slack user is refused rather than defaulted to the owner. In a two-person household a wrong attribution is not a rounding error, it is the other person's opinion recorded under your name. Block Kit is built in the app (ingest returns slack_blocks) rather than in n8n expressions: a template string is untestable, and this shape has to stay in step with what the interactive endpoint re-renders after each press. Null when there is no transaction yet — a card-settled order is parked until its statement arrives, so there is nothing to split or rate. Also: /transactions now honours ?q=, so the link lands on the row instead of the top of an unfiltered ledger.
100 lines
3.4 KiB
TypeScript
100 lines
3.4 KiB
TypeScript
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();
|
|
});
|
|
});
|