Deno 2.9 Deno.test.each: a case table for a Sume webhook verifier

Deno 2.9 adds Deno.test.each. Table-test a sume-v1 verifier: valid, rotated, empty secret, stale timestamp and tampered body, with WebCrypto only.

5 min readSume
All posts

Short answer

Put every way a Sume webhook can fail into one array and let Deno.test.each register an independent test per row. Deno 2.9, per its release post, added Deno.test.each for parameterized tests from input tables, with object cases and $key name templates. The verifier under test uses WebCrypto and refuses an empty secret.

The signature rules come from Sume's webhooks page: HMAC SHA-256 over <timestamp>.<raw_body>, a sume-v1= prefix, a five-minute tolerance, and a comma-separated header during a secret rotation.

What the release post documents

The release post dates Deno 2.9 to 25 June 2026. Two of its testing additions matter for a verifier suite.

Deno 2.9 testing items (read 2026-10-03)
ItemWhat the post says
Deno.test.eacharray cases spread as arguments; object cases with interpolated names
Name templatesprintf-style tokens such as %i and %s, and $key.nested for object access
t.assertSnapshot()built into the test context; snapshots live in __snapshots__/<file>.snap
Updating snapshotsdeno test --update-snapshots

The verifier and its case table

Save as verify_test.ts and run deno test. The sign helper builds the header the way Sume does; the cases then break one property at a time. Every case sends a header with a stale entry first, because the docs say the newest secret comes first and any matching entry is enough, so the valid row also proves the rotation path.

import { assertEquals } from "jsr:@std/assert";

async function sign(secret: string, ts: string, body: string) {
  const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret),
    { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
  const mac = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(`${ts}.${body}`));
  return "sume-v1=" + [...new Uint8Array(mac)].map((b) => b.toString(16).padStart(2, "0")).join("");
}
function same(a: string, b: string) {
  let d = a.length ^ b.length;
  for (let i = 0; i < Math.min(a.length, b.length); i++) d |= a.charCodeAt(i) ^ b.charCodeAt(i);
  return d === 0;
}
async function verify(secret: string, ts: string, header: string, body: string, now: number) {
  if (!secret || !Number.isFinite(Number(ts)) || Math.abs(now - Number(ts)) > 300) return false;
  const want = await sign(secret, ts, body);
  return header.split(",").map((e) => same(e.trim(), want)).includes(true);
}

const NOW = 1_780_000_000, BODY = '{"event":"job.completed"}';
Deno.test.each([
  { label: "valid", secret: "s3cret", skew: 0, body: BODY, ok: true },
  { label: "empty secret", secret: "", skew: 0, body: BODY, ok: false },
  { label: "stale timestamp", secret: "s3cret", skew: 301, body: BODY, ok: false },
  { label: "tampered body", secret: "s3cret", skew: 0, body: BODY + " ", ok: false },
])("$label -> $ok", async ({ secret, skew, body, ok }) => {
  const ts = String(NOW - skew);
  const header = "sume-v1=stale," + await sign("s3cret", ts, BODY);
  assertEquals(await verify(secret, ts, header, body, NOW), ok);
});

Adding the rows you will actually hit

Extend the table with a header that has no sume-v1= entry, a timestamp that is not a number, and a body that differs only by key order. The last one is the common production bug: a framework parsed and re-serialized the JSON, and the signature no longer matches the bytes Sume signed.

The official TypeScript SDK ships verifyWebhook for the same job; the table above is for teams that keep the verifier in their own tree.

What Sume does and does not do

Sume signs every delivery with the workspace's signing secret, adds x-sume-webhook-secret-fingerprint so you can compare secrets without sending them, and lets you Redeliver a job's real terminal event with a fresh timestamp. Send test posts a dummy webhook.test body to a URL you type.

Sume does not sign progress events, because it sends none, and it will not accept a localhost or non-HTTPS webhook URL.

Sources

Related posts

More in Developers

All Developers posts

Written by Sume