feat(reliability): idempotency store, custom webhook replay protection, correlation ids

Add LICENSE (MIT), an IdempotencyStore abstraction with a KV implementation and provider-scoped delivery keys, optional replay protection for custom webhooks (X-WebHooker-Timestamp + X-WebHooker-Nonce), and per-request correlation ids in webhook responses and logs.
This commit is contained in:
RhenCloud 2026-08-15 14:39:41 +08:00
parent 92ba2203a5
commit 737dd7af98
No known key found for this signature in database
GPG key ID: A574A617378C4E0B
15 changed files with 378 additions and 26 deletions

View file

@ -101,6 +101,8 @@ server/ # Nitro server
│ └── richheader.ts # GET /api/richheader: Open Graph page for Telegram avatar link-preview card
└── lib/ # shared infra
├── i18n.ts # loadTranslations (KV i18n:{lang} overrides), t() with param interpolation
├── idempotency.ts # IdempotencyStore interface + kvIdempotencyStore (delivery dedup via claim/has) + deliveryKey
├── correlation.ts # newCorrelationId() — per-request/delivery correlation id for logs + responses
├── send-log.ts # SendRecord, recordSend/getSendLog/getSendLogById (D1 send_logs)
├── audit.ts # recordAudit/getAuditLog/pruneAuditLogs (D1 audit_logs, best-effort writes)
├── log.ts # JSON console logger (info/warn/error/fatal)
@ -113,7 +115,7 @@ tests/ # bun test unit tests (webhook, formatter, discord, tel
- Verify GitHub webhook signatures (Web Crypto HMAC-SHA256, `X-Hub-Signature-256`)
- Verify Gitea webhook signatures (Web Crypto HMAC-SHA256, plain hex `X-Gitea-Signature`)
- Verify custom webhook signatures (Web Crypto HMAC-SHA256, GitHub-style `sha256=` via `X-WebHooker-Signature`)
- Verify custom webhook signatures (Web Crypto HMAC-SHA256, GitHub-style `sha256=` via `X-WebHooker-Signature`; optional replay protection via `X-WebHooker-Timestamp` + `X-WebHooker-Nonce` — signature over `{timestamp}.{nonce}.{body}`, ±5 min window, nonce dedup in KV)
- Normalize Gitea webhook payloads to a GitHub-shaped `WebhookEvent` (push `compare_url``compare`, `pull_request_comment``pull_request_review_comment`, ...)
- Verify Discord interactions (Web Crypto Ed25519, X-Signature-Ed25519 over timestamp + body)
- Verify Telegram webhook calls (X-Telegram-Bot-Api-Secret-Token when configured)
@ -129,7 +131,8 @@ tests/ # bun test unit tests (webhook, formatter, discord, tel
- Route messages to Discord channels/threads and Telegram chats/topics via REST
- Edit already-sent messages in place for `workflow_run` / `check_run` progress (stable `updateKey`, KV `msg:*` tracking)
- Record every dispatch attempt to D1 `send_logs` (route id, event, target, ok/error, duration, error code)
- Serve a per-group webhook ingress (`POST /webhook/{groupId}`, per-group secret in KV `tenant:{groupId}`) for Gitea/classic-GitHub/custom senders; only that group's routes fire; dedup keys are tenant-scoped
- Serve a per-group webhook ingress (`POST /webhook/{groupId}`, per-group secret in KV `tenant:{groupId}`) for Gitea/classic-GitHub/custom senders; only that group's routes fire; dedup keys are provider- and tenant-scoped (`delivery:{provider}:{groupId}:{id}` via `kvIdempotencyStore`)
- Issue a per-request correlation id (`requestId`) in webhook responses and dispatch logs
- Send a per-event summary (event, repo, delivery id, per route×target ✅/❌ outcome) to the group's `logTarget` when configured
- Serve `/gh` slash commands + message context-menu commands + PR merge/close buttons + comment modals
- Serve Telegram `/gh` commands (login/logout/comment/merge/close) via reply-message parsing

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 WebHooker contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -6,7 +6,7 @@ GitHub / Gitea webhook → Discord / Telegram dispatcher. Receives webhook event
- **28 event formatters** — push, pull_request, issues, issue_comment, workflow_run, workflow_job, status, deployment, deployment_status, check_run, check_suite, ping, release, create, delete, star, fork, pull_request_review, pull_request_review_comment, commit_comment, member, label, milestone, discussion, discussion_comment, repository, code_scanning_alert, dependabot_alert (+ generic fallback, + `custom` webhooks)
- **Multi-provider webhooks** — GitHub (`X-Hub-Signature-256`) and Gitea (`X-Gitea-Signature`) share one `/webhook` endpoint; the provider is auto-detected from headers
- **Per-group webhook ingress** — every group can get its own `POST /webhook/{groupId}` URL + secret (Gitea, classic GitHub webhooks, and arbitrary custom JSON posts signed with `X-WebHooker-Signature`)
- **Per-group webhook ingress** — every group can get its own `POST /webhook/{groupId}` URL + secret (Gitea, classic GitHub webhooks, and arbitrary custom JSON posts signed with `X-WebHooker-Signature`, with optional timestamp+nonce replay protection)
- **GitHub App tenant isolation** — bind a group to a GitHub App installation id so only that org/user's events enter it
- HMAC-SHA256 signature verification (Web Crypto API)
- Filter by event type, repo, actor, action, branch, keyword (supports `*`/`?` globs and `/regex/`)

View file

@ -6,7 +6,7 @@ GitHub / Gitea webhook → Discord / Telegram 分发服务。通过 Cloudflare W
- **28 种事件格式化** — push、pull_request、issues、issue_comment、workflow_run、workflow_job、status、deployment、deployment_status、check_run、check_suite、ping、release、create、delete、star、fork、pull_request_review、pull_request_review_comment、commit_comment、member、label、milestone、discussion、discussion_comment、repository、code_scanning_alert、dependabot_alert+ 通用回退,+ `custom` 自定义 webhook
- **多平台 webhook** — GitHub`X-Hub-Signature-256`)与 Gitea`X-Gitea-Signature`)共用 `/webhook` 端点,自动识别来源平台
- **分组级 webhook 入口** — 每个分组可拥有独立的 `POST /webhook/{groupId}` URL + secretGitea、classic GitHub webhook以及用 `X-WebHooker-Signature` 签名的任意自定义 JSON
- **分组级 webhook 入口** — 每个分组可拥有独立的 `POST /webhook/{groupId}` URL + secretGitea、classic GitHub webhook以及用 `X-WebHooker-Signature` 签名的任意自定义 JSON,可选的 timestamp+nonce 重放防护
- **GitHub App 租户隔离** — 将分组绑定到 GitHub App 安装 ID只有该组织/用户的事件才能进入该分组
- HMAC-SHA256 签名验证Web Crypto API
- 按事件类型、仓库、操作人、操作、分支、关键词过滤(支持 `*`/`?` 通配符与 `/正则/`

View file

@ -94,7 +94,7 @@ Verifies the payload against the **group's** secret (KV `tenant:{groupId}`, gene
### Custom Webhooks
Any JSON payload signed with `X-WebHooker-Signature: sha256=<hex>` (HMAC-SHA256 of the raw body, group or global secret) becomes a `custom` event. Route it with a route whose filter is `event: custom`. Payload schema: see [Configuration → Custom webhooks](../guide/ingress.md#custom-webhooks).
Any JSON payload signed with `X-WebHooker-Signature: sha256=<hex>` (HMAC-SHA256 of the raw body, group or global secret) becomes a `custom` event. Route it with a route whose filter is `event: custom`. Optional `X-WebHooker-Timestamp` + `X-WebHooker-Nonce` headers enable replay protection (signature over `{timestamp}.{nonce}.{body}`, ±5 min window, nonce dedup). Payload schema: see [Configuration → Custom webhooks](../guide/ingress.md#custom-webhooks).
### GitHub App Installation Events

View file

@ -23,13 +23,32 @@ Every group can opt into its own webhook ingress with an independent secret (gen
- Supported for any provider: GitHub (`X-Hub-Signature-256`), Gitea (`X-Gitea-Signature`), custom (`X-WebHooker-Signature`)
- The secret is a 64-char hex string; regenerate from the console invalidates the old one immediately
- Delivery-id dedup keys are tenant-scoped (`delivery:{groupId}:{id}`)
- Delivery-id dedup keys are provider- and tenant-scoped (`delivery:{provider}:{groupId}:{id}`)
- When the group has no secret (or no longer exists) the endpoint returns `404`
## Custom Webhooks
Post arbitrary JSON to `POST /webhook/{groupId}` (or the global endpoint) with the body signed as `X-WebHooker-Signature: sha256=<hmac-sha256 hex of the raw body>` using the group's secret. The payload becomes a `custom` event that flows through the normal route pipeline — create a route with `event: custom` (there is a console template) and it dispatches to that route's targets, records `send_logs`, and appears in the group's webhook log channel.
### Replay Protection
Custom webhooks support optional replay protection via two extra headers alongside the signature:
- `X-WebHooker-Timestamp` — Unix seconds the request was sent
- `X-WebHooker-Nonce` — a unique, unpredictable value per request (e.g. a UUID)
When **both** headers are present, the signature is computed over `{timestamp}.{nonce}.{rawBody}` instead of the raw body, and the request is accepted only if:
1. The timestamp is within ±5 minutes of the server clock (rejects replays and clock-drift abusers)
2. The nonce has never been seen before (stored in KV for 10 minutes; a replayed nonce is rejected)
```bash
input="${timestamp}.${nonce}.${body}"
signature="sha256=$(printf '%s' "$input" | openssl dgst -sha256 -hmac "$secret" -hex | sed 's/.*= //')"
```
When the headers are omitted, WebHooker falls back to the legacy body-only signature, so existing senders keep working unchanged.
Payload schema:
```json

View file

@ -94,7 +94,7 @@ POST /webhook
### 自定义 Webhook
任意 JSON 载荷用 `X-WebHooker-Signature: sha256=<hex>`(对原始 body 的 HMAC-SHA256使用分组或全局 secret签名后即可成为 `custom` 事件。用 `event: custom` 过滤器的路由接收。载荷格式见[配置 → 自定义 Webhook](../guide/ingress.md#自定义-webhook)。
任意 JSON 载荷用 `X-WebHooker-Signature: sha256=<hex>`(对原始 body 的 HMAC-SHA256使用分组或全局 secret签名后即可成为 `custom` 事件。用 `event: custom` 过滤器的路由接收。可选的 `X-WebHooker-Timestamp` + `X-WebHooker-Nonce` 请求头启用重放防护(对 `{timestamp}.{nonce}.{body}` 签名、±5 分钟窗口、nonce 去重)。载荷格式见[配置 → 自定义 Webhook](../guide/ingress.md#自定义-webhook)。
### GitHub App 安装事件

View file

@ -23,13 +23,32 @@ Gitea 载荷会被归一化为与 GitHub 事件相同的内部结构,因此路
- 支持任意提供方GitHub`X-Hub-Signature-256`、Gitea`X-Gitea-Signature`)、自定义(`X-WebHooker-Signature`
- 密钥为 64 位 hex 字符串;在控制台重新生成会立即失效旧密钥
- 投递 id 去重键按租户隔离(`delivery:{groupId}:{id}`
- 投递 id 去重键按提供方与租户隔离(`delivery:{provider}:{groupId}:{id}`
- 分组没有密钥(或已不存在)时端点返回 `404`
## 自定义 Webhook
`POST /webhook/{groupId}`或全局端点POST 任意 JSON并使用分组密钥将原始 body 的 HMAC-SHA256 以 `X-WebHooker-Signature: sha256=<hex>` 签名。载荷会变成 `custom` 事件,走正常的路由管线——创建一条 `event: custom` 的路由(控制台有模板),即可分发到该路由的目标、记录 `send_logs`,并出现在分组的 webhook 日志频道中。
### 重放防护
自定义 webhook 支持可选的防重放机制,在签名之外再附带两个请求头:
- `X-WebHooker-Timestamp` — 请求发送时的 Unix 秒数
- `X-WebHooker-Nonce` — 每次请求唯一且不可预测的值(如 UUID
当**同时**提供这两个请求头时,签名改为对 `{timestamp}.{nonce}.{原始body}` 计算(而非仅原始 body且仅当以下条件满足时才被接受
1. 时间戳与服务器时钟相差不超过 ±5 分钟(拒绝重放与时钟漂移滥用)
2. nonce 从未被使用过(存入 KV 保留 10 分钟;重放的 nonce 会被拒绝)
```bash
input="${timestamp}.${nonce}.${body}"
signature="sha256=$(printf '%s' "$input" | openssl dgst -sha256 -hmac "$secret" -hex | sed 's/.*= //')"
```
省略这些请求头时WebHooker 回退到旧版的仅对 body 签名,现有发送方无需改动即可继续工作。
载荷模式:
```json

View file

@ -0,0 +1,7 @@
/** A short, unique correlation id for tracing one webhook request. */
export function newCorrelationId(): string {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
}
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
}

View file

@ -0,0 +1,39 @@
/**
* Delivery idempotency: a small, reusable abstraction over "has this key been
* seen / can I claim it once" so webhook dedup, nonce replay protection and
* future delivery retry all share one semantics. `claim` is best-effort atomic
* on KV (get-then-put): under a concurrent double-send both callers may see
* "unclaimed", but that matches the existing dedup behavior and is acceptable
* because dispatch itself is idempotent per (provider, group, delivery).
*/
export interface IdempotencyStore {
has(key: string): Promise<boolean>;
claim(key: string, ttlSeconds: number): Promise<boolean>;
}
export function kvIdempotencyStore(kv: KVNamespace): IdempotencyStore {
return {
async has(key): Promise<boolean> {
const value = await kv.get(key);
return value !== null && value !== undefined;
},
async claim(key, ttlSeconds): Promise<boolean> {
const value = await kv.get(key);
if (value !== null && value !== undefined) return false;
await kv.put(key, "1", { expirationTtl: ttlSeconds });
return true;
},
};
}
/**
* Canonical dedup key for a webhook delivery: provider + tenant + delivery id.
* The legacy global endpoint has no tenant, so `groupId` is "global".
*/
export function deliveryKey(
provider: string,
groupId: string | undefined,
deliveryId: string,
): string {
return `delivery:${provider}:${groupId ?? "global"}:${deliveryId}`;
}

View file

@ -1,12 +1,55 @@
import type { Env, WebhookEvent } from "../../types";
import type { Provider } from "../types";
import { hmacSha256Hex, timingSafeEqual } from "../hmac";
import { verifySignature } from "../github/verify";
const REPLAY_WINDOW_SECONDS = 300;
const NONCE_TTL_SECONDS = 600;
function parseTimestamp(value: string | undefined): number | null {
if (!value) return null;
const n = Number(value);
return Number.isFinite(n) ? n : null;
}
/**
* Replay protection for `custom` webhooks: the sender adds `X-WebHooker-Timestamp`
* (Unix seconds) and `X-WebHooker-Nonce` and signs
* `timestamp + "." + nonce + "." + body` instead of the bare body. We reject
* timestamps outside a ±5min window and reject any nonce already seen (nonces
* live in KV for 10min). Senders without those headers keep the legacy
* body-only signature so existing integrations continue to work.
*/
async function verifyReplayProtected(
body: string,
headers: Record<string, string>,
secret: string,
kv: KVNamespace,
): Promise<boolean> {
const timestamp = parseTimestamp(headers["x-webhooker-timestamp"]);
const nonce = headers["x-webhooker-nonce"];
const signature = headers["x-webhooker-signature"];
if (timestamp == null || !nonce || !secret) return false;
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - timestamp) > REPLAY_WINDOW_SECONDS) return false;
const expected = `sha256=${await hmacSha256Hex(secret, `${timestamp}.${nonce}.${body}`)}`;
if (!timingSafeEqual(signature ?? "", expected)) return false;
const nonceKey = `nonce:${nonce}`;
const seen = await kv.get(nonceKey);
if (seen !== null && seen !== undefined) return false;
await kv.put(nonceKey, "1", { expirationTtl: NONCE_TTL_SECONDS });
return true;
}
/**
* Custom webhook provider: accepts arbitrary JSON posts (monitoring, CI,
* scripts, ...) that are not signed by a forge. The sender signs the raw body
* scripts, ...) that are not signed by a forge. The sender signs the payload
* with the tenant's secret using the GitHub-style `sha256=<hex>` HMAC header
* `X-WebHooker-Signature`. Payloads become `custom` events that flow through
* `X-WebHooker-Signature`, optionally adding replay-protection headers (see
* `verifyReplayProtected`). Payloads become `custom` events that flow through
* the normal route matching pipeline (a route with `event: custom`).
*/
export const customProvider: Provider = {
@ -24,7 +67,14 @@ export const customProvider: Provider = {
// The tenant webhook handler overrides GITHUB_WEBHOOK_SECRET with the
// group's secret; on the legacy global endpoint this falls back to the
// operator's global secret.
return verifySignature(body, headers["x-webhooker-signature"], env.GITHUB_WEBHOOK_SECRET);
const secret = env.GITHUB_WEBHOOK_SECRET;
if (
headers["x-webhooker-timestamp"] !== undefined &&
headers["x-webhooker-nonce"] !== undefined
) {
return verifyReplayProtected(body, headers, secret, env.KV);
}
return verifySignature(body, headers["x-webhooker-signature"], secret);
},
parse(body, _headers): WebhookEvent | null {

View file

@ -9,6 +9,8 @@ import { getTenantSecret } from "./web/tenants";
import { recordAudit } from "./lib/audit";
import { cfEnv, cfWaitUntil, headersFrom } from "./cf";
import { log } from "./lib/log";
import { deliveryKey, kvIdempotencyStore } from "./lib/idempotency";
import { newCorrelationId } from "./lib/correlation";
const MAX_BODY_SIZE = 1024 * 1024;
@ -32,6 +34,7 @@ export async function processWebhook(
waitUntil: (promise: Promise<unknown>) => void,
tenantId?: string,
): Promise<WebhookResult> {
const requestId = newCorrelationId();
let effectiveEnv = env;
const groups = await loadGroups(env.KV);
if (tenantId) {
@ -56,7 +59,10 @@ export async function processWebhook(
} catch (err) {
// A malformed secret or an unavailable crypto implementation must fail as
// a clean 401, never an uncaught 500.
log.warn({ provider: provider.id, err: String(err) }, "Webhook signature verification failed");
log.warn(
{ provider: provider.id, requestId, err: String(err) },
"Webhook signature verification failed",
);
return { status: 401, body: { error: "Invalid signature" } };
}
if (!verified) {
@ -67,9 +73,12 @@ export async function processWebhook(
? effectiveEnv.GITEA_WEBHOOK_SECRET
: effectiveEnv.GITHUB_WEBHOOK_SECRET;
if (!secret) {
log.warn({ provider: provider.id }, "Webhook rejected: provider secret is not configured");
log.warn(
{ provider: provider.id, requestId },
"Webhook rejected: provider secret is not configured",
);
} else {
log.warn({ provider: provider.id }, "Webhook rejected: invalid signature");
log.warn({ provider: provider.id, requestId }, "Webhook rejected: invalid signature");
}
return { status: 401, body: { error: "Invalid signature" } };
}
@ -111,16 +120,14 @@ export async function processWebhook(
}
if (event.deliveryId) {
// Tenant-scoped dedup keys: different accounts can reuse the same
// delivery id, so the global key would wrongly dedupe across tenants.
const key = tenantId
? `delivery:${tenantId}:${event.deliveryId}`
: `delivery:${event.deliveryId}`;
const seen = await env.KV.get(key);
if (seen) {
return { status: 200, body: { ok: true, duplicate: true } };
// Dedup via the idempotency store: a provider/tenant-scoped key means
// different accounts may reuse a delivery id without colliding, while
// retries of the same delivery never dispatch twice.
const store = kvIdempotencyStore(env.KV);
const key = deliveryKey(provider.id, tenantId, event.deliveryId);
if (!(await store.claim(key, 300))) {
return { status: 200, body: { ok: true, duplicate: true, requestId } };
}
await env.KV.put(key, "1", { expirationTtl: 300 });
}
const config = await loadConfig(env);
@ -129,11 +136,11 @@ export async function processWebhook(
}
const dispatch = dispatchEvent(config, event, env, groups).catch((err) =>
log.error(err, "Dispatch failed"),
log.error({ requestId, err }, "Dispatch failed"),
);
waitUntil(dispatch);
return { status: 200, body: { ok: true } };
return { status: 200, body: { ok: true, requestId } };
}
/** h3 wrapper for `POST /webhook` / `POST /webhook/:groupId`. */

126
tests/custom-replay.test.ts Normal file
View file

@ -0,0 +1,126 @@
import { describe, it, expect } from "bun:test";
import { createHmac } from "crypto";
import { customProvider } from "../server/lib/providers/custom";
import type { Env } from "../server/lib/types";
function createMockKV(): KVNamespace {
const store = new Map<string, string>();
return {
get: async (key: string) => (store.has(key) ? store.get(key)! : null),
put: async (key: string, value: string) => {
store.set(key, value);
},
delete: async (key: string) => {
store.delete(key);
},
list: async () => ({ keys: [], list_complete: true, cacheStatus: null }),
} as unknown as KVNamespace;
}
function createEnv(secret: string): Env {
return {
GITHUB_WEBHOOK_SECRET: secret,
KV: createMockKV(),
DB: {} as D1Database,
};
}
function replaySignature(secret: string, timestamp: number, nonce: string, body: string): string {
return `sha256=${createHmac("sha256", secret).update(`${timestamp}.${nonce}.${body}`).digest("hex")}`;
}
const now = Math.floor(Date.now() / 1000);
describe("custom provider replay protection", () => {
it("verifies a valid timestamp + nonce + signature", async () => {
const secret = "s3cret";
const env = createEnv(secret);
const body = JSON.stringify({ hello: "world" });
const nonce = "nonce-1";
const ok = await customProvider.verify(
body,
{
"x-webhooker-signature": replaySignature(secret, now, nonce, body),
"x-webhooker-timestamp": String(now),
"x-webhooker-nonce": nonce,
},
env,
);
expect(ok).toBe(true);
});
it("rejects a replayed nonce even with a valid signature", async () => {
const secret = "s3cret";
const env = createEnv(secret);
const body = JSON.stringify({ hello: "world" });
const nonce = "nonce-reused";
const headers = {
"x-webhooker-signature": replaySignature(secret, now, nonce, body),
"x-webhooker-timestamp": String(now),
"x-webhooker-nonce": nonce,
};
expect(await customProvider.verify(body, headers, env)).toBe(true);
expect(await customProvider.verify(body, headers, env)).toBe(false);
});
it("rejects a timestamp outside the window", async () => {
const secret = "s3cret";
const env = createEnv(secret);
const body = JSON.stringify({ hello: "world" });
const stale = now - 3600;
const nonce = "nonce-stale";
const ok = await customProvider.verify(
body,
{
"x-webhooker-signature": replaySignature(secret, stale, nonce, body),
"x-webhooker-timestamp": String(stale),
"x-webhooker-nonce": nonce,
},
env,
);
expect(ok).toBe(false);
});
it("rejects a signature computed over the wrong input", async () => {
const secret = "s3cret";
const env = createEnv(secret);
const body = JSON.stringify({ hello: "world" });
const nonce = "nonce-bad-sig";
const wrong = `sha256=${createHmac("sha256", secret).update(body).digest("hex")}`;
const ok = await customProvider.verify(
body,
{
"x-webhooker-signature": wrong,
"x-webhooker-timestamp": String(now),
"x-webhooker-nonce": nonce,
},
env,
);
expect(ok).toBe(false);
});
it("still verifies the legacy body-only signature", async () => {
const secret = "s3cret";
const env = createEnv(secret);
const body = JSON.stringify({ hello: "world" });
const sig = `sha256=${createHmac("sha256", secret).update(body).digest("hex")}`;
const ok = await customProvider.verify(body, { "x-webhooker-signature": sig }, env);
expect(ok).toBe(true);
});
it("treats a missing nonce as a legacy (body-only) signature", async () => {
const secret = "s3cret";
const env = createEnv(secret);
const body = JSON.stringify({ hello: "world" });
// Only a timestamp, no nonce → falls back to body-only verification.
const sig = `sha256=${createHmac("sha256", secret).update(body).digest("hex")}`;
const ok = await customProvider.verify(
body,
{ "x-webhooker-signature": sig, "x-webhooker-timestamp": String(now) },
env,
);
// Timestamp is present but nonce missing, so replay path is skipped and the
// body-only signature is used → verified.
expect(ok).toBe(true);
});
});

61
tests/idempotency.test.ts Normal file
View file

@ -0,0 +1,61 @@
import { describe, it, expect } from "bun:test";
import { deliveryKey, kvIdempotencyStore } from "../server/lib/lib/idempotency";
function createMockKV(): KVNamespace {
const store = new Map<string, string>();
const ttl = new Map<string, number>();
return {
get: async (key: string) => (store.has(key) ? store.get(key)! : null),
put: async (key: string, value: string, opts?: { expirationTtl?: number }) => {
store.set(key, value);
if (opts?.expirationTtl) ttl.set(key, opts.expirationTtl);
},
delete: async (key: string) => {
store.delete(key);
},
list: async () => ({ keys: [], list_complete: true, cacheStatus: null }),
} as unknown as KVNamespace;
}
describe("deliveryKey", () => {
it("includes provider, tenant and delivery id", () => {
expect(deliveryKey("github", "team-a", "deliv-1")).toBe("delivery:github:team-a:deliv-1");
});
it("falls back to 'global' without a tenant", () => {
expect(deliveryKey("github", undefined, "deliv-1")).toBe("delivery:github:global:deliv-1");
});
});
describe("kvIdempotencyStore", () => {
it("claims a key once and reports it thereafter", async () => {
const kv = createMockKV();
const store = kvIdempotencyStore(kv);
expect(await store.has("k")).toBe(false);
expect(await store.claim("k", 300)).toBe(true);
expect(await store.has("k")).toBe(true);
expect(await store.claim("k", 300)).toBe(false);
});
it("stores the claimed key with the requested TTL", async () => {
const kv = createMockKV();
let ttl = 0;
(kv.put as unknown) = async (
key: string,
value: string,
opts?: { expirationTtl?: number },
): Promise<void> => {
ttl = opts?.expirationTtl ?? 0;
};
const store = kvIdempotencyStore(kv);
await store.claim("k", 600);
expect(ttl).toBe(600);
});
it("treats distinct keys independently", async () => {
const store = kvIdempotencyStore(createMockKV());
expect(await store.claim("a", 300)).toBe(true);
expect(await store.claim("b", 300)).toBe(true);
expect(await store.claim("a", 300)).toBe(false);
});
});

View file

@ -198,7 +198,7 @@ describe("processWebhook", () => {
const second = await processWebhook(env, body, headers, makeWait().waitUntil, "team-a");
expect(first.status).toBe(200);
expect(second.status).toBe(200);
expect(second.body).toEqual({ ok: true, duplicate: true });
expect(second.body).toEqual({ ok: true, duplicate: true, requestId: expect.any(String) });
expect(fetched).toHaveLength(1);
});