Files
time/taskmaster/test/webhook.test.js
T

137 lines
6.1 KiB
JavaScript
Raw Normal View History

// Simulated, signed webhooks. Nothing is bought: the signature, the deal lookup and the amount check are what the
// test exercises, with a fake Whop payload.
import { test } from "node:test";
import assert from "node:assert/strict";
import { verify, sign, handle, SIG_HEADER, TS_HEADER, TOLERANCE_S } from "../src/webhook.js";
import { openDeal, quote, agree } from "../src/dealdesk.js";
import { memorySink, record } from "../src/deallog.js";
const SECRET = "whsec_test";
const NOW = Date.parse("2026-10-20T12:00:00Z");
const agreed = (over = {}) => {
const d = openDeal({ id: "d1", tier: "sprint", member: "ann", thread: "t1", now: NOW });
quote(d, { tier: "sprint", price: 100 });
quote(d, { tier: "sprint", price: 92, reason: "scope", note: "smaller job" });
agree(d, { now: NOW });
return Object.assign(d, over);
};
const body = (over = {}) => JSON.stringify({
action: "payment.succeeded",
data: { id: "pay_1", membership_id: "mem_1", final_amount: 92, metadata: { deal: "d1", member: "ann", thread: "t1" } },
...over,
});
async function signed(raw, { timestamp = Math.floor(NOW / 1000), secret = SECRET } = {}) {
return { [SIG_HEADER]: await sign(raw, secret, { timestamp }), [TS_HEADER]: String(timestamp) };
}
function deps(deal) {
const sink = memorySink();
const store = new Map(deal ? [[deal.id, deal]] : []);
return {
sink,
saved: store,
deals: { get: async (id) => store.get(id) ?? null, save: async (d) => void store.set(d.id, d) },
log: (d) => record(d, { sink, now: NOW }),
};
}
test("a signed body verifies; a tampered one does not", async () => {
const raw = body();
const headers = await signed(raw);
assert.deepEqual(await verify(raw, headers, SECRET, { now: NOW }), { ok: true });
assert.equal((await verify(raw.replace("92", "5"), headers, SECRET, { now: NOW })).ok, false);
assert.equal((await verify(raw, headers, "another-secret", { now: NOW })).ok, false);
assert.equal((await verify(raw, {}, SECRET, { now: NOW })).why, "no signature");
assert.equal((await verify(raw, { [SIG_HEADER]: "deadbeef" }, SECRET, { now: NOW })).why, "signature does not match");
});
test("a replayed webhook is refused once it is stale", async () => {
const raw = body();
const old = await signed(raw, { timestamp: Math.floor(NOW / 1000) - TOLERANCE_S - 60 });
assert.match((await verify(raw, old, SECRET, { now: NOW })).why, /old/);
assert.equal((await verify(raw, { ...await signed(raw), [TS_HEADER]: "soon" }, SECRET, { now: NOW })).why, "bad timestamp");
});
test("a body signed without a timestamp verifies as the raw body alone", async () => {
const raw = body();
const headers = { [SIG_HEADER]: await sign(raw, SECRET) };
assert.equal((await verify(raw, headers, SECRET, { now: NOW })).ok, true);
});
test("Headers objects work as well as plain ones, since that is what a request carries", async () => {
const raw = body();
assert.equal((await verify(raw, new Headers(await signed(raw)), SECRET, { now: NOW })).ok, true);
});
test("a verified payment moves the deal to paid, logs it and unlocks coach mode", async () => {
const deal = agreed();
const d = deps(deal);
const raw = body();
const out = await handle(raw, await signed(raw), { secret: SECRET, now: NOW, deps: d });
assert.equal(out.status, 200);
assert.deepEqual(out.body, { ok: true, deal: "d1", mode: "coach" });
assert.equal(deal.outcome, "paid");
assert.equal(deal.receipt, "pay_1");
assert.equal(deal.membership, "mem_1");
assert.equal(d.sink.rows.length, 1);
assert.equal(d.sink.rows[0].final, 92);
assert.equal(d.sink.rows[0].outcome, "paid");
});
test("a forged payment moves nothing", async () => {
const deal = agreed();
const d = deps(deal);
const raw = body();
const out = await handle(raw, { [SIG_HEADER]: "0".repeat(64), [TS_HEADER]: String(Math.floor(NOW / 1000)) },
{ secret: SECRET, now: NOW, deps: d });
assert.equal(out.status, 401);
assert.equal(deal.outcome, "agreed");
assert.equal(d.sink.rows.length, 0);
});
test("a payment for less than was agreed is a mismatch to look at, not a cheaper deal", async () => {
const deal = agreed();
const d = deps(deal);
const raw = body({ data: { id: "pay_2", membership_id: "mem_1", final_amount: 5, metadata: { deal: "d1" } } });
const out = await handle(raw, await signed(raw), { secret: SECRET, now: NOW, deps: d });
assert.deepEqual(out.body.mismatch, { deal: "d1", agreed: 92, paid: 5 });
assert.equal(deal.outcome, "agreed");
});
test("an event that is not a payment, or names no deal, is acknowledged and ignored", async () => {
const deal = agreed();
const d = deps(deal);
for (const [raw, key] of [[body({ action: "membership.went_invalid" }), "ignored"],
[body({ data: { id: "pay_3", metadata: {} } }), "ignored"],
[JSON.stringify({ action: "payment.succeeded", data: { metadata: { deal: "nope" } } }), "ignored"]]) {
const out = await handle(raw, await signed(raw), { secret: SECRET, now: NOW, deps: d });
assert.equal(out.status, 200);
assert.ok(key in out.body, JSON.stringify(out.body));
}
assert.equal(deal.outcome, "agreed");
assert.equal((await handle("not json", await signed("not json"), { secret: SECRET, now: NOW, deps: d })).status, 400);
});
test("the same payment twice pays once", async () => {
const deal = agreed();
const d = deps(deal);
const raw = body();
const headers = await signed(raw);
await handle(raw, headers, { secret: SECRET, now: NOW, deps: d });
const again = await handle(raw, headers, { secret: SECRET, now: NOW, deps: d });
assert.deepEqual(again.body, { ok: true, already: "d1" });
assert.equal(d.sink.rows.length, 1);
});
test("a paid seat pack hands the membership on, so its seat list can be set up", async () => {
const deal = agreed({ tier: "seats5", seats: 5, price: 500, anchor: 500, floor: 400 });
const d = deps(deal);
const packed = [];
const raw = body({ data: { id: "pay_4", membership_id: "mem_pack", final_amount: 500, metadata: { deal: "d1" } } });
await handle(raw, await signed(raw), { secret: SECRET, now: NOW,
deps: { ...d, seats: async (m, tier) => packed.push([m, tier]) } });
assert.deepEqual(packed, [["mem_pack", "seats5"]]);
});