Skip to content

Commit c18e39d

Browse files
garethxclaude
andcommitted
test: cover the gaps a sibling plugin's live-test inventory exposed
Their framing is the useful part: a suite that fakes the transport agrees with itself by construction, and every defect they found survived a green suite. Working through their inventory against ours. Three gaps were real and are now closed. The published artifact was never loaded. `npm pack` honours the `files` field, so a file needed at runtime that nobody listed is invisible to every other test here — they all read the source tree, where it is present. `npm run test:package` packs, extracts OUTSIDE the repo (inside, the working tree shadows the install and the test passes without loading what shipped), boots a real Gateway from the extracted package, and requires 8 tools and a verified delivery. It passes: 104K, no test directory, nothing missing. A burst past the concurrency cap was untested. Admission defers rather than queues and a deferred event is held nowhere, so the surviving burst is maxConcurrent x the retry count. Now pinned: six concurrent deliveries against a cap of two admit exactly two, defer four with the configured Retry-After, and record a ledger row for none of the deferred ones — recording one would make Hookdeck's redelivery look like a duplicate and the event would vanish. A deliberate operator retry was untested. Admission is attempt-count based and does not read the trigger, which is right — the trigger arrives in an unsigned header, so honouring it would let anyone able to replay a body bypass deduplication. What matters is that Hookdeck's own increment admits a MANUAL retry, including of an event that already succeeded. All three cases now have tests. Added from their finding about provider verification: a source's type does not enable it, and a source with a secret is byte-identical to one without over the API — so `hookdeck_doctor` now reads `verified` on recent inbound requests, which is the only evidence there is. Already covered here: malformed and non-ASCII signatures (their 500 case — ours refuses cleanly, checked last round), duplicate suppression, crash-recovery orphan reconciliation, and driving the tools with a real model, which is how this plugin's tool surface was found broken twice. 659 tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 08bfd04 commit c18e39d

9 files changed

Lines changed: 324 additions & 2 deletions

File tree

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,8 @@ The README covers the common path. Everything else lives in [`docs/`](docs/):
146146

147147
```bash
148148
openclaw plugins install --link ./hookdeck-openclaw
149-
npm test # no Gateway or Hookdeck account required
149+
npm test # no Gateway or Hookdeck account required
150+
npm run test:package # loads the packed tarball in a real Gateway
150151
```
151152

152153
## Learn more

docs/agent-tools.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,3 +93,12 @@ A status tool that returns a page and lets the reader infer a total is worse tha
9393
- `hookdeck_status.openIssues` and `hookdeck_issues`' `total` come from Hookdeck's count endpoint, not from the length of a page.
9494
- `hookdeck_recent_deliveries` returns `openIssuesTotal` beside the page it shows, and an `openIssuesTruncated` note whenever the two differ. The local records get the same treatment via `localTruncated`.
9595
- `hookdeck_status.deadLetters` is the local log's true size, but the log evicts oldest-first at its cap — so once it is full, `deadLettersIsAtLeast: true` says the number is a floor. A floor reported as a floor beats a ceiling reported as a total.
96+
97+
## What `hookdeck_doctor` checks
98+
99+
Beyond the obvious config validation:
100+
101+
- **Provider verification is actually in force.** Setting a source's *type* to STRIPE or GITHUB does not enable signature verification — the provider's signing secret has to be set on the source as well. A source with one is byte-identical to a source without it over the API, because the secret is never returned. So the only evidence is whether the requests that arrived were verified, and that is what this check reads.
102+
- **The retry rule still covers every status the plugin emits.** A rule narrower than the emitted codes turns admission control into silent data loss.
103+
- **The CLI and the API key point at the same project.** See [Transport](transport.md#the-two-projects-problem).
104+
- **The burst each route can absorb**, from `maxConcurrent` and the connection's retry count.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,8 @@
4747
"test:watch": "vitest --exclude \"test/live/**\"",
4848
"typecheck": "tsc --noEmit",
4949
"test:live": "vitest run test/live",
50-
"test:agent": "bash scripts/agent-smoke.sh"
50+
"test:agent": "bash scripts/agent-smoke.sh",
51+
"test:package": "bash scripts/test-package.sh"
5152
},
5253
"homepage": "https://github.com/hookdeck/hookdeck-openclaw#readme",
5354
"bugs": {

scripts/test-package.sh

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
#!/usr/bin/env bash
2+
# Loads the PACKAGED plugin in a real Gateway, not the working tree.
3+
#
4+
# `npm pack` honours the `files` field, so a file the plugin needs at runtime
5+
# but nobody listed is invisible to every other test in this repo — they all
6+
# read the source directory, where it is present.
7+
#
8+
# Everything runs from OUTSIDE the repo. Run it from inside and the working
9+
# tree shadows the extracted package, and the test passes without ever loading
10+
# what was shipped.
11+
set -uo pipefail
12+
cd "$(dirname "$0")/.."
13+
REPO="$(pwd)"
14+
15+
ROOT="${TMPDIR:-/tmp}/hookdeck-openclaw-package"
16+
rm -rf "$ROOT"; mkdir -p "$ROOT/state"
17+
18+
echo "==> packing"
19+
TARBALL="$(npm pack --silent --pack-destination "$ROOT")" || exit 1
20+
echo " $TARBALL"
21+
22+
echo "==> extracting outside the repo"
23+
tar -xzf "$ROOT/$TARBALL" -C "$ROOT"
24+
PKG="$ROOT/package"
25+
26+
# The runtime dependencies are not in the tarball; link the ones the repo
27+
# already resolved rather than hitting the network.
28+
mkdir -p "$PKG/node_modules"
29+
for dep in typebox zod openclaw; do
30+
[ -d "$REPO/node_modules/$dep" ] && ln -sfn "$REPO/node_modules/$dep" "$PKG/node_modules/$dep"
31+
done
32+
33+
echo "==> what shipped"
34+
( cd "$PKG" && find . -name node_modules -prune -o -type f -print | sed 's|^\./||' | sort )
35+
36+
for required in openclaw.plugin.json index.ts LICENSE README.md; do
37+
if [ ! -f "$PKG/$required" ]; then
38+
echo "MISSING from the package: $required"
39+
exit 1
40+
fi
41+
done
42+
43+
if [ -d "$PKG/test" ]; then
44+
echo "The test suite shipped to users. Check the files field."
45+
exit 1
46+
fi
47+
48+
cat > "$ROOT/openclaw.json" <<JSON
49+
{
50+
"gateway": { "mode": "local", "bind": "loopback", "port": 18851 },
51+
"plugins": {
52+
"load": { "paths": ["$PKG"] },
53+
"entries": { "hookdeck": { "enabled": true, "config": {
54+
"signingSecret": "whsec_package_test",
55+
"ingress": { "basePath": "/hookdeck" },
56+
"routes": { "stripe": { "source": "stripe",
57+
"dispatch": { "mode": "wake", "sessionKey": "main" } } }
58+
} } }
59+
}
60+
}
61+
JSON
62+
63+
echo "==> booting a Gateway from the extracted package"
64+
cd "$ROOT" # outside the repo, so nothing shadows the install
65+
OPENCLAW_CONFIG_PATH="$ROOT/openclaw.json" OPENCLAW_STATE_DIR="$ROOT/state" \
66+
"$REPO/node_modules/.bin/openclaw" gateway --allow-unconfigured > "$ROOT/gw.log" 2>&1 &
67+
GW=$!
68+
trap 'kill $GW 2>/dev/null' EXIT
69+
sleep 14
70+
71+
fail() { echo "FAIL: $1"; echo "--- log ---"; tail -20 "$ROOT/gw.log"; exit 1; }
72+
73+
grep -q "ingress ready" "$ROOT/gw.log" || fail "the packaged plugin did not start"
74+
TOOLS=$(grep -o "declared [0-9]* tool(s)" "$ROOT/gw.log" | head -1 | grep -o "[0-9]*")
75+
[ "${TOOLS:-0}" = "8" ] || fail "expected 8 tools from the package, saw '${TOOLS:-none}'"
76+
77+
BODY='{"type":"invoice.paid"}'
78+
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac 'whsec_package_test' -binary | base64)
79+
CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST http://127.0.0.1:18851/hookdeck/stripe \
80+
-H 'content-type: application/json' -H "x-hookdeck-signature: $SIG" \
81+
-H 'x-hookdeck-eventid: evt_pkg' -H 'x-hookdeck-attempt-count: 1' --data "$BODY")
82+
[ "$CODE" = "200" ] || fail "a signed delivery to the packaged plugin answered $CODE"
83+
84+
echo
85+
echo "PASS: the packaged plugin loads, declares 8 tools, and verifies a signed delivery."
86+
echo " Size: $(du -h "$ROOT/$TARBALL" | cut -f1)"

src/hookdeck/client.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,27 @@ export interface HookdeckClient {
216216
* endpoint takes `{cause, webhook_id, transformation_id}` with no date
217217
* filter, and there is no project-wide listing of ignored events.
218218
*/
219+
/**
220+
* Recent inbound requests, for the one question the API cannot answer
221+
* directly: whether a source is really verifying its provider's signatures.
222+
*
223+
* A source with a provider secret set is byte-identical to one without it
224+
* over the API — the secret is never returned — so `verified` on the requests
225+
* that actually arrived is the only signal there is.
226+
*/
227+
listRequests(params?: {
228+
limit?: number;
229+
sourceId?: string;
230+
}): Promise<
231+
ApiResult<
232+
{
233+
id: string;
234+
verified?: boolean | null;
235+
rejection_cause?: string | null;
236+
}[]
237+
>
238+
>;
239+
219240
bulkReplayRequests(params: {
220241
query: Record<string, unknown>;
221242
target: { webhook_ids?: string[]; source_id?: string };
@@ -460,6 +481,20 @@ export function createHookdeckClient(
460481
return result.ok ? { ok: true, data: result.data.count ?? 0 } : result;
461482
},
462483

484+
async listRequests(params = {}) {
485+
const query = new URLSearchParams({ limit: String(params.limit ?? 10) });
486+
if (params.sourceId !== undefined)
487+
query.set("source_id", params.sourceId);
488+
const result = await request<{
489+
models?: {
490+
id: string;
491+
verified?: boolean | null;
492+
rejection_cause?: string | null;
493+
}[];
494+
}>("GET", `/requests?${query}`);
495+
return result.ok ? { ok: true, data: result.data.models ?? [] } : result;
496+
},
497+
463498
async bulkReplayRequests(params) {
464499
// `target` goes INSIDE `query`, and is required there. Sending it at the
465500
// top level is answered `422 query.target is required` — the whole call

src/tools/doctor.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,30 @@ export async function doctorHandler(deps: ToolDeps) {
6565
});
6666
}
6767

68+
// Whether provider verification is actually in force. Setting a source's
69+
// TYPE to STRIPE or GITHUB does not enable it — the provider's secret has to
70+
// be set as well — and a source with one is indistinguishable from a source
71+
// without it over the API, because the secret is never returned. So the only
72+
// evidence is whether the requests that arrived were verified.
73+
if (deps.client !== undefined) {
74+
const requests = await deps.client.listRequests({ limit: 20 });
75+
if (requests.ok && requests.data.length > 0) {
76+
const unverified = requests.data.filter(
77+
(r) => r.verified === false,
78+
).length;
79+
checks.push({
80+
name: "provider verification",
81+
ok: unverified === 0,
82+
detail:
83+
unverified === 0
84+
? `the last ${requests.data.length} inbound request(s) were verified by Hookdeck`
85+
: `${unverified} of the last ${requests.data.length} inbound request(s) arrived UNVERIFIED. ` +
86+
`A source's type does not enable verification on its own — the provider's signing secret ` +
87+
`has to be set on the source too, or anyone who learns the URL can post to it.`,
88+
});
89+
}
90+
}
91+
6892
// In `cli` transport, "which project" has two independent answers:
6993
// provisioning acts on the API key's project, while `hookdeck listen` looks
7094
// for that connection in whichever project the CLI's session points at. When

test/handler.test.ts

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1187,3 +1187,116 @@ describe("form-encoded providers", () => {
11871187
expect(dispatch).toHaveBeenCalledOnce();
11881188
});
11891189
});
1190+
1191+
describe("a deliberate operator retry is not swallowed as a duplicate", () => {
1192+
// Admission is attempt-count based and does not special-case the trigger:
1193+
// the trigger arrives in an unsigned header, so honouring it would let
1194+
// anyone who can replay a body bypass deduplication. Hookdeck increments the
1195+
// attempt count on a manual retry, which is what admits it.
1196+
it("admits a MANUAL retry that advances the attempt count", async () => {
1197+
const { deps, dispatch } = harness();
1198+
await handleDelivery(deps, request({ attemptCount: "1" }));
1199+
1200+
const manual = await handleDelivery(
1201+
deps,
1202+
request({
1203+
attemptCount: "2",
1204+
extraHeaders: { "x-hookdeck-attempt-trigger": "MANUAL" },
1205+
}),
1206+
);
1207+
1208+
expect(manual.plan.status).toBe(200);
1209+
expect(manual.plan.code).toBe("dispatched");
1210+
expect(dispatch).toHaveBeenCalledTimes(2);
1211+
});
1212+
1213+
it("admits a manual retry of an event that already succeeded", async () => {
1214+
// Hookdeck allows retrying a successful event, and an operator doing so
1215+
// means it. This is also what makes Hookdeck usable as the work queue for
1216+
// interrupted runs.
1217+
const { deps, dispatch, ledger } = harness();
1218+
await handleDelivery(deps, request({ attemptCount: "1" }));
1219+
expect(ledger.get("evt_1")?.status).toBe("succeeded");
1220+
1221+
await handleDelivery(
1222+
deps,
1223+
request({
1224+
attemptCount: "2",
1225+
extraHeaders: { "x-hookdeck-attempt-trigger": "MANUAL" },
1226+
}),
1227+
);
1228+
expect(dispatch).toHaveBeenCalledTimes(2);
1229+
});
1230+
1231+
it("still suppresses a replayed attempt that does not advance", async () => {
1232+
// The trigger header is unsigned, so it cannot be the thing that decides.
1233+
const { deps, dispatch } = harness();
1234+
await handleDelivery(deps, request({ attemptCount: "2" }));
1235+
1236+
const replayed = await handleDelivery(
1237+
deps,
1238+
request({
1239+
attemptCount: "2",
1240+
extraHeaders: { "x-hookdeck-attempt-trigger": "MANUAL" },
1241+
}),
1242+
);
1243+
expect(replayed.plan.code).toBe("duplicate");
1244+
expect(dispatch).toHaveBeenCalledOnce();
1245+
});
1246+
});
1247+
1248+
describe("a burst past the concurrency cap", () => {
1249+
// Admission defers rather than queues, and a deferred event is held nowhere:
1250+
// it only returns when Hookdeck retries it. So the burst that survives is
1251+
// maxConcurrent x the connection's retry count, and anything beyond that is
1252+
// lost. This pins the local half of that arithmetic.
1253+
it("admits exactly maxConcurrent and defers the rest, recording nothing for them", async () => {
1254+
let release!: () => void;
1255+
const blocked = new Promise<void>((r) => {
1256+
release = r;
1257+
});
1258+
1259+
const { deps, ledger } = harness({
1260+
config: buildConfig({ maxConcurrent: 2 }),
1261+
dispatch: async () => {
1262+
await blocked;
1263+
return { settle: "succeeded" as const, plan: okPlan("dispatched") };
1264+
},
1265+
});
1266+
1267+
const inFlight = Array.from({ length: 6 }, (_, i) =>
1268+
handleDelivery(deps, request({ eventId: `evt_${i}` })),
1269+
);
1270+
// Let the first two claim their slots before the rest are judged.
1271+
await new Promise((r) => setTimeout(r, 10));
1272+
1273+
release();
1274+
const results = await Promise.all(inFlight);
1275+
1276+
const deferred = results.filter((r) => r.plan.status === 503);
1277+
expect(deferred).toHaveLength(4);
1278+
expect(results.filter((r) => r.plan.status === 200)).toHaveLength(2);
1279+
1280+
// Nothing is recorded for a deferred event: a ledger row would make
1281+
// Hookdeck's redelivery look like a duplicate, and the event would vanish.
1282+
for (let i = 0; i < 6; i += 1) {
1283+
const row = ledger.get(`evt_${i}`);
1284+
if (row !== undefined) expect(row.status).toBe("succeeded");
1285+
}
1286+
expect(ledger.stats().entries).toBe(2);
1287+
});
1288+
1289+
it("tells a deferred sender when to come back", async () => {
1290+
const { deps } = harness({
1291+
config: buildConfig({ maxConcurrent: 1, busyRetryAfterSeconds: 7 }),
1292+
dispatch: () => new Promise(() => ({})) as never,
1293+
});
1294+
1295+
void handleDelivery(deps, request({ eventId: "evt_a" }));
1296+
await new Promise((r) => setTimeout(r, 10));
1297+
const second = await handleDelivery(deps, request({ eventId: "evt_b" }));
1298+
1299+
expect(second.plan.status).toBe(503);
1300+
expect(second.plan.retry).toEqual({ kind: "after", seconds: 7 });
1301+
});
1302+
});

test/manager.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,10 @@ function fakeClient(overrides: Partial<HookdeckClient> = {}): HookdeckClient {
6060
ok: true as const,
6161
data: [{ id: "web_1", team_id: "tm_a" }],
6262
})),
63+
listRequests: vi.fn(async () => ({
64+
ok: true as const,
65+
data: [{ id: "req_1", verified: true }],
66+
})),
6367
getIssue: vi.fn(async (id: string) => ({
6468
ok: true as const,
6569
data: { id },

test/tools.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,10 @@ function fakeClient(overrides: Partial<HookdeckClient> = {}): HookdeckClient {
117117
ok: true as const,
118118
data: [{ id: "web_1", team_id: "tm_a" }],
119119
})),
120+
listRequests: vi.fn(async () => ({
121+
ok: true as const,
122+
data: [{ id: "req_1", verified: true }],
123+
})),
120124
...overrides,
121125
};
122126
}
@@ -1664,3 +1668,48 @@ describe("bulk replay is always scoped to a configured route", () => {
16641668
expect(d.client!.bulkReplayRequests).not.toHaveBeenCalled();
16651669
});
16661670
});
1671+
1672+
describe("doctor checks that provider verification is actually in force", () => {
1673+
// A source's TYPE does not enable verification — the provider's secret must
1674+
// be set too — and a source with one is byte-identical to one without over
1675+
// the API. Whether arriving requests were verified is the only evidence.
1676+
it("fails when requests are arriving unverified", async () => {
1677+
const d = await deps({
1678+
client: fakeClient({
1679+
listRequests: vi.fn(async () => ({
1680+
ok: true as const,
1681+
data: [
1682+
{ id: "req_1", verified: false },
1683+
{ id: "req_2", verified: true },
1684+
{ id: "req_3", verified: false },
1685+
],
1686+
})),
1687+
}),
1688+
});
1689+
1690+
const check = (await doctorHandler(d)).checks.find(
1691+
(c) => c.name === "provider verification",
1692+
);
1693+
expect(check?.ok).toBe(false);
1694+
expect(check?.detail).toMatch(/2 of the last 3/);
1695+
expect(check?.detail).toMatch(/anyone who learns the URL can post to it/);
1696+
});
1697+
1698+
it("passes when they are all verified", async () => {
1699+
const check = (await doctorHandler(await deps())).checks.find(
1700+
(c) => c.name === "provider verification",
1701+
);
1702+
expect(check?.ok).toBe(true);
1703+
});
1704+
1705+
it("says nothing rather than guessing when no request has arrived yet", async () => {
1706+
const d = await deps({
1707+
client: fakeClient({
1708+
listRequests: vi.fn(async () => ({ ok: true as const, data: [] })),
1709+
}),
1710+
});
1711+
expect(
1712+
(await doctorHandler(d)).checks.find((c) => c.name === "provider verification"),
1713+
).toBeUndefined();
1714+
});
1715+
});

0 commit comments

Comments
 (0)