Skip to content

Commit bb0db13

Browse files
fix(plugin-webhooks): the redeliver veto refuses a delivery whose subscription is inactive (#22836)
Fixes #22804 Clause-②: no ## What changes `createWebhookRedeliverGuard` (`packages/plugins/plugin-webhooks/src/redeliver-guard.ts`), the redeliver veto, now refuses a delivery whose `sys_webhook` subscription is inactive. This follows triage's reading on the card: an explicit replay is "the dispatcher" for `active`. The refusal works like the gone case. The door answers `409 DELIVERY_NOT_ELIGIBLE` and leaves the row untouched. The reason names the webhook, says it is inactive, and names the remedy: > webhook 'NAME' is inactive, and an inactive webhook is skipped by the dispatcher, so this delivery is refused rather than sent to an endpoint that was switched off. Fix: re-activate the webhook, then redeliver; new events are delivered again from the moment it is active. On the wire, the outbox puts its own prefix before the reason: `Delivery row 'ID' cannot be redelivered: REASON`. - **Order.** The new check runs after "gone" and before the secret checks. So an inactive webhook whose key is also unrecoverable is refused as inactive, the operator's first remedy. The gone and unrecoverable-secret refusals keep their text and their order. Ruling `5271033283` on #8069 says no operator action may send an unsigned delivery. This change only adds a refusal and weakens nothing. - **The `active` field description stays as it is.** "Inactive webhooks are skipped by the dispatcher" is now true of both paths. - The guard's docblock lists the refusal cases as 1 gone, 2 inactive, 3 unrecoverable secret, 4 lookup failed (refused by the caller). The `SYSTEM_CTX` docblock now says what the read is for: existence, the `active` switch, name and secret posture. ## Measured (the dispatch's mechanism assumptions) 1. **The column.** Its name is `active`: `Field.boolean`, `required: true`, `defaultValue: true` (`sys-webhook.object.ts:175`–`181`). The guard reads with `engine.findOne(subscriptionsObject, { where: { id } }, { context: { isSystem: true } })`, with no projection, so the column is in the row. Booleans come back as JS booleans on every driver: the SQL driver converts SQLite/MySQL `0`/`1` to booleans in `formatOutput` and leaves null as null, and Postgres is native. **Null or absent `active`: refused. I decided this from the dispatcher's predicate, not from the declared default.** The dispatch suggested the declared default, so this is a deviation, stated here. The automatic dispatcher loads `{ where: { active: true } }` (`auto-enqueuer.ts:393`) and so skips a null or absent row on every driver. If the guard allowed that row, the field's sentence would be false again for that row. The declared default and `required` are why the engine never writes such a row (it fills `true` on insert), so the two readings differ only on rows written outside the engine. There, refusing is the fail-closed side and matches what Setup shows. The test is `subscription.active !== true`. 2. **The door.** Measured on both faces: the `webhooks` slot's `handleRedeliver` and the self-hosted mount. The guard's string reaches the door as `409` `DELIVERY_NOT_ELIGIBLE` with the string as the message, through `assertRedeliverAllowed` (`service-messaging/src/http-outbox.ts`). The member and the mount answer byte-equal (status, headers, body), as the parity pin asserts. 3. **The spec docblock.** The `IWebhookService.handleRedeliver` text, "`DELIVERY_NOT_ELIGIBLE` (the row is not finished, or the producer's veto refused it)", covers the third case: verified, no change. One sentence in the same file's header now under-describes the veto: "the messaging service's veto over replaying a webhook row whose subscription or signing secret is gone". It still holds for the two cases it names, but it lists two of the three. `packages/spec` is untouched, as the claim directs; the report names the sentence for the seat to route. 4. **The order and existing pins.** No existing pin depends on the order. One existing fixture changed. The `webhook-system-context.pin.test.ts` row had no `active` key, and the new rule refuses that. The fixture gains `active: true`, as every engine-written row carries it. The pin still asserts what it is for, the read's `isSystem` context. ## Pins - Guard (`webhook-drop-durable-record.test.ts`, new describe): - inactive is refused, naming the webhook, `inactive` and the remedy; - null and absent `active` are refused; - inactive is checked before the secret: the resolver is never called; - control: active, signed and unsigned, is allowed; - control: gone keeps its own refusal. - Door (`webhook-redeliver-member.test.ts`): - inactive answers `409` `DELIVERY_NOT_ELIGIBLE` on both faces, with the reason, and the row stays `dead`; - CONTROL: active and signed is replayed by both faces (`200`, `pending`); - CONTROL: gone is unchanged; - CONTROL: the same delivery is replayed once re-activated; - the inactive case is also a row in the existing every-refusal table and in the member/mount byte-parity table. - Refusal pins assert the envelope's `code` and the HTTP `status`. On the message they assert the named subject (`webhook 'paused_hook'`), the state word `inactive`, and the remedy fragment `re-activate the webhook, then redeliver`. The card names those as the reason's contract, so the whole sentence is not pinned. ## Ablation (every negative pin) The ablation was done on the committed fix (`4c3ef9cef`), through `scripts/ablation-replace.mjs`, wrapped with a `trap` restore. The anchor `if (subscription.active !== true) {` became `if (false) {`. - On disk: anchor 1 to 0, replacement 0 to 1, blob `4a8096099b21` to `684c106c56e3`. - Result: 2 of 3 files and **7 of 35 tests red**. These are the five pins that assert a refusal (the three guard refusal pins, the door's inactive pin and the re-activated control's first leg) plus the two tables that now carry the inactive row. - The controls stayed green: active and signed replayed, gone unchanged, and the guard's active and gone controls. - Restore was proven: blob equal to the HEAD blob, `git diff HEAD` empty, and the tree clean. - The tests import the subject by relative source path (`./redeliver-guard.js`, `./webhook-outbox-plugin.js`), not through a package `exports`. So no `dist/` is in the loop, and the dist preflight does not apply. ## Verification All runs below are at `4c3ef9cef`. `os-verify-lock` timings are shared-box figures. - `pnpm --filter '@objectstack/plugin-webhooks^...' build` (the dependency closure): VERDICT command-exit 0. - `pnpm --filter @objectstack/plugin-webhooks build`: `check-dts-emitted` 4/4. - `pnpm --filter @objectstack/plugin-webhooks typecheck`: `tsc --noEmit` and the scripts program clean, and `check:test-typecheck: OK`, 0 errors. - `pnpm --filter @objectstack/plugin-webhooks test`: **17 files, 191 tests passed**. - Gates: I re-derived the gate list on this change: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` gives 65 commands, the dispatch's 54 plus 11 that the changeset brings. **All 65 exit 0**, and `--ran` reconciles them: "65 derived, 65 run, 0 NOT-MEASURED, 0 UNRUN", a derived zero, because every line records its exit code. - Three first runs answered exit 3, PREREQUISITE NOT MET, and none of them counted as a measurement. `check-plugin-teardown-shape.mjs --self-test` needed its pinned fixture commit in this shallow clone, fetched at depth 1. `check:i18n` and `check:dual-build-cjs-loads` needed built output, so I built the whole workspace (`turbo run build --filter='!@objectstack/docs'`, 73/73 tasks). After those steps, all three re-ran at exit 0. - Every gate that reads `dist/` was re-run on the fully built tree: `check:dts-closure` (73 packages, 172/172), `check:published-files`, `check:lean-entry-closure` and `check:sourcemap-no-sources-content`. - ESLint, narrowed to the 4 changed `.ts` files: 0 errors and 0 warnings over 4 files, a count read from `--format json`. The config ignores none of the four (`ESLint.isPathIgnored` is false for each), and it enables no type-aware linting (no `parserOptions.project`), so this diff cannot move any untouched file's verdict. The repo-wide `pnpm lint` is CI's. - The branch was not merged with `origin/main`. The 3 commits since base (`efcbac73c`, trigger-api, mcp and the objectui pin) touch none of these files, and `git merge-tree` against `efcbac73c` is clean. CI's merge ref and the queue test the joint tree. The public surface is unchanged in bytes: no export, type, code or status moves. So the import sites owe no tests of their own. ## Acceptance notes - `IWebhookService` header sentence (`packages/spec/src/contracts/webhook-service.ts`): see measured item 3. Left for the seat, as the claim directs. - `WebhookOutboxPlugin.installRedeliverGuard`'s docblock still describes the veto by its signing half ("refuses a webhook row whose signing configuration is no longer available"). It is still true, just narrower than the veto now is. That file is outside the claim's file surface, so it is left alone. - `content/docs/automation/webhooks.mdx` lists `409 DELIVERY_NOT_ELIGIBLE` without naming the veto's cases, and its `active` row now reads true of both paths. No sentence there is false. Disclosure: the card is labelled `security`. This body describes behaviour only, with no reproduction recipe. --- _Generated by [Claude Code](https://claude.ai/code/session_013LbZ9MhPp1iriZEtgJqioA)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1eff322 commit bb0db13

5 files changed

Lines changed: 207 additions & 14 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/plugin-webhooks': patch
3+
---
4+
5+
The webhook redeliver veto now refuses a delivery whose `sys_webhook` subscription is inactive. The `active` field declares that an inactive webhook is skipped by the dispatcher, and an operator's explicit replay is the dispatcher too, so the declaration now holds on both paths.
6+
7+
Clause-②: no
8+
9+
- FROM: the redeliver door (`POST /api/v1/webhooks/redeliver`, and the `webhooks` slot's `handleRedeliver`) replayed a finished delivery whose subscription had `active` switched off. The veto refused only a subscription that was gone, or whose signing secret could not be recovered.
10+
- TO: the door answers `409 DELIVERY_NOT_ELIGIBLE` for that delivery and leaves the row as it is. The message names the webhook, says it is inactive, and names the remedy. The test is the dispatcher's own predicate, `active === true`, so a row whose `active` is null or absent is refused exactly as the dispatcher skips it. Every row the engine writes carries a boolean there: the field is required, with default `true`.
11+
- Fix, for an operator who means to replay: re-activate the webhook, then redeliver.
12+
13+
The gone-subscription and unrecoverable-secret refusals are unchanged, in text and in order. The inactive check runs after the gone check and before the secret check, so an inactive webhook whose key is also unrecoverable is refused as inactive, the first remedy to try. No code, status, field or export changes.

‎packages/plugins/plugin-webhooks/src/redeliver-guard.ts‎

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -21,21 +21,33 @@
2121
* should still receive this payload, or under which key — and an operator
2222
* deleting a webhook has expressed that it should stop. The maintainer
2323
* named this case specifically.
24-
* 2. **A secret is stored but does not come back.** The subscription is signed
24+
* 2. **The subscription is inactive.** [#22804] `active` is the operator's
25+
* off switch for the endpoint, and the field declares that an inactive
26+
* webhook is skipped by the dispatcher. An explicit replay is the
27+
* dispatcher too: sending a past delivery to a switched-off endpoint would
28+
* undo the switch without saying so. The test is the dispatcher's own
29+
* predicate — the subscription cache loads `{ active: true }`, so the
30+
* guard allows only `active === true`, and a row whose `active` is null or
31+
* absent (unreachable through the engine, which fills the declared default
32+
* `true` and requires the field) is refused, exactly as the dispatcher
33+
* skips it. Re-activating the webhook, then replaying, is the open path.
34+
* Checked after "gone" and before the secret, so an inactive webhook whose
35+
* key is also unrecoverable names the operator's first remedy.
36+
* 3. **A secret is stored but does not come back.** The subscription is signed
2537
* and the key cannot be recovered — a rotated KMS key, an unregistered
2638
* CryptoProvider, a deleted `sys_secret` row. Deliveries for it are being
2739
* dropped right now; replaying an old one is the same fail-open by another
2840
* route.
29-
* 3. **The lookup itself failed.** Handled by the caller
41+
* 4. **The lookup itself failed.** Handled by the caller
3042
* (`assertRedeliverAllowed` turns a throwing guard into a refusal), because
3143
* "we could not check" must never read as "allowed".
3244
*
33-
* It ALLOWS a subscription that is legitimately unsigned (`secret` is optional
34-
* on the authoring envelope) and any row from another producer (`source !==
35-
* 'webhook'`) — this guard speaks only for webhook rows.
45+
* It ALLOWS an active subscription that is legitimately unsigned (`secret` is
46+
* optional on the authoring envelope) and any row from another producer
47+
* (`source !== 'webhook'`) — this guard speaks only for webhook rows.
3648
*
3749
* ## The narrow fail-open this closes deliberately
38-
* Case 2 is checked as *"a value is stored but nothing came back"*, not as
50+
* Case 3 is checked as *"a value is stored but nothing came back"*, not as
3951
* *"the resolver threw"*. Presence is decidable from the masked read even
4052
* though the value is not, so the guard asks the question it can actually
4153
* answer.
@@ -64,8 +76,8 @@ import {
6476
* explicit system opt-in. The guard runs inside the messaging service's
6577
* redeliver path, after the delivery row has been read under the requesting
6678
* caller's organization, and reads the subscription that row belongs to only
67-
* for its existence, name and secret posture — the inputs of the refusal
68-
* reason it returns. What it reads and returns is unchanged by the opt-in; it
79+
* for its existence, `active` switch, name and secret posture — the inputs of
80+
* the refusal reason it returns. What it reads and returns is unchanged by the opt-in; it
6981
* may simply no longer rely on a missing principal to pass the security
7082
* middleware's principal-less hand-off, which ADR-0096 D5 closes.
7183
*/
@@ -107,6 +119,20 @@ export function createWebhookRedeliverGuard(
107119
);
108120
}
109121

122+
// [#22804] Case 2: the off switch. `=== true` is the dispatcher's own
123+
// predicate (`AutoEnqueuer` loads `{ active: true }`), so a replay is
124+
// refused for exactly the rows automatic dispatch skips — `false`, and
125+
// a null or absent value too. Before the secret checks on purpose:
126+
// re-activating is the remedy an operator reaches for first.
127+
if (subscription.active !== true) {
128+
return (
129+
`webhook '${String(subscription.name ?? row.refId)}' is inactive, and an inactive webhook `
130+
+ 'is skipped by the dispatcher, so this delivery is refused rather than sent to an '
131+
+ 'endpoint that was switched off. Fix: re-activate the webhook, then redeliver; '
132+
+ 'new events are delivered again from the moment it is active.'
133+
);
134+
}
135+
110136
// Presence is decidable on the masked read — a set secret comes back as
111137
// the engine's mask, an unset one as null — even though the value is not.
112138
const storesSecret =
@@ -120,8 +146,8 @@ export function createWebhookRedeliverGuard(
120146
// the rule moved down one level instead of being written twice. This
121147
// guard's contract is unchanged in both directions, which is the point:
122148
// a stored-but-unresolvable key still returns the refusal REASON below
123-
// (case 2), and any OTHER failure still propagates, because "we could
124-
// not check" must never read as "allowed" (case 3, handled by
149+
// (case 3), and any OTHER failure still propagates, because "we could
150+
// not check" must never read as "allowed" (case 4, handled by
125151
// `assertRedeliverAllowed`).
126152
let plaintext: string | undefined;
127153
try {

‎packages/plugins/plugin-webhooks/src/webhook-drop-durable-record.test.ts‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -386,3 +386,61 @@ describe('webhook redeliver guard — signing configuration availability (#8069)
386386
await expect(guard({ source: 'flow', refId: 'node_1' })).resolves.toBeUndefined();
387387
});
388388
});
389+
390+
/**
391+
* [#22804] The off switch. `active: false` is how an operator stops an endpoint
392+
* receiving events, and the field declares that an inactive webhook is skipped
393+
* by the dispatcher; an explicit replay is the dispatcher too. The refusal
394+
* reason is the contract an operator reads: it names the webhook, says it is
395+
* inactive, and names the remedy (re-activate, then replay).
396+
*/
397+
describe('webhook redeliver guard — an inactive subscription is refused (#22804)', () => {
398+
const row = { source: 'webhook', refId: 'wh-1' };
399+
400+
it('refuses an inactive subscription, naming the webhook, its state and the remedy', async () => {
401+
const guard = createWebhookRedeliverGuard(makeEngine([subscriptionRow({ active: false })]));
402+
const reason = await guard(row);
403+
expect(reason).toContain("webhook 'orders_hook'");
404+
expect(reason).toMatch(/\binactive\b/);
405+
expect(reason).toMatch(/re-activate the webhook, then redeliver/);
406+
});
407+
408+
it('refuses a null or absent `active` as the dispatcher skips it: only `active === true` replays', async () => {
409+
// The subscription cache loads `{ active: true }`, so a row whose
410+
// `active` is null or missing is never dispatched; the replay agrees.
411+
const noActive: Record<string, unknown> = subscriptionRow();
412+
delete noActive.active;
413+
for (const subscription of [subscriptionRow({ active: null }), noActive]) {
414+
const guard = createWebhookRedeliverGuard(makeEngine([subscription]));
415+
await expect(guard(row), JSON.stringify(subscription)).resolves.toMatch(/\binactive\b/);
416+
}
417+
});
418+
419+
it('checks inactive before the secret: an unrecoverable key on an inactive webhook is never resolved', async () => {
420+
let resolverCalls = 0;
421+
const guard = createWebhookRedeliverGuard(
422+
makeEngine([subscriptionRow({ active: false, signing_secret: SECRET_REF })], async () => {
423+
resolverCalls += 1;
424+
throw new Error('no CryptoProvider');
425+
}),
426+
);
427+
await expect(guard(row)).resolves.toMatch(/\binactive\b/);
428+
expect(resolverCalls).toBe(0);
429+
});
430+
431+
it('control: the same subscriptions, active, are allowed — signed and unsigned', async () => {
432+
const signed = createWebhookRedeliverGuard(
433+
makeEngine([subscriptionRow({ active: true, signing_secret: SECRET_REF })], async () => 'whsec_live'),
434+
);
435+
await expect(signed(row)).resolves.toBeUndefined();
436+
const unsigned = createWebhookRedeliverGuard(makeEngine([subscriptionRow({ active: true })]));
437+
await expect(unsigned(row)).resolves.toBeUndefined();
438+
});
439+
440+
it('control: a gone subscription keeps its own refusal, not the inactive one', async () => {
441+
const guard = createWebhookRedeliverGuard(makeEngine([]));
442+
const reason = await guard(row);
443+
expect(reason).toContain('no longer exists');
444+
expect(reason).not.toMatch(/\binactive\b/);
445+
});
446+
});

‎packages/plugins/plugin-webhooks/src/webhook-redeliver-member.test.ts‎

Lines changed: 95 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,11 @@
2222
* from `http.server` before the `http-server` alias, and never where none
2323
* does; the slot is registered everywhere the veto is, and nowhere else;
2424
* 4. the veto no longer needs realtime or auto-enqueue: a redelivery of a
25-
* delivery whose subscription is gone is refused by both faces.
25+
* delivery whose subscription is gone is refused by both faces;
26+
* 5. [#22804] a delivery whose subscription is inactive is refused by both
27+
* faces, `409 DELIVERY_NOT_ELIGIBLE` with a reason naming the state and the
28+
* remedy, beside its controls: active and signed replays, gone keeps its
29+
* own refusal, and the same delivery replays once re-activated.
2630
*/
2731

2832
import { randomUUID } from 'node:crypto';
@@ -39,6 +43,10 @@ const PATH = '/api/v1/webhooks/redeliver';
3943
const ORIGIN = 'http://webhooks.test';
4044
const SUBSCRIPTION = 'wh_live';
4145
const GONE_SUBSCRIPTION = 'wh_gone';
46+
/** [#22804] A subscription an operator switched off: `active: false`. */
47+
const INACTIVE_SUBSCRIPTION = 'wh_off';
48+
/** [#22804] An active subscription that stores a signing secret which resolves. */
49+
const SIGNED_SUBSCRIPTION = 'wh_signed';
4250
const ORG_A = 'org_a';
4351
const ORG_B = 'org_b';
4452
const silentLogger = { info() {}, warn() {}, error() {}, debug() {} } as any;
@@ -54,6 +62,10 @@ function fakeEngine(subscriptions: Map<string, Record<string, unknown>>) {
5462
},
5563
registerHook() { /* provenance stamp + headers gate + cleartext gate */ },
5664
unregisterHooksByPackage() { return 0; },
65+
/** [#22804] The privileged dereference: only the signed subscription's key resolves. */
66+
async resolveSecretField(_object: string, id: string) {
67+
return id === SIGNED_SUBSCRIPTION ? 'whsec_live' : null;
68+
},
5769
};
5870
}
5971

@@ -88,6 +100,10 @@ class FixturePlugin implements Plugin {
88100
version = '1.0.0';
89101
readonly subscriptions = new Map<string, Record<string, unknown>>([
90102
[SUBSCRIPTION, { id: SUBSCRIPTION, name: 'orders_hook', active: true }],
103+
[INACTIVE_SUBSCRIPTION, { id: INACTIVE_SUBSCRIPTION, name: 'paused_hook', active: false }],
104+
[SIGNED_SUBSCRIPTION, {
105+
id: SIGNED_SUBSCRIPTION, name: 'signed_hook', active: true, signing_secret: 'secret:sec_22804',
106+
}],
91107
]);
92108
readonly outbox = new MemoryHttpOutbox();
93109
readonly messaging = new MessagingService({ logger: silentLogger });
@@ -243,6 +259,7 @@ describe('[#22756] handleRedeliver — the redeliver door from a Request, on a k
243259
const { member, attempted, pending, parked, statusOf } = await boot();
244260
const crossOrg = await attempted(ORG_B);
245261
const vetoed = await attempted(ORG_A, GONE_SUBSCRIPTION);
262+
const inactive = await attempted(ORG_A, INACTIVE_SUBSCRIPTION);
246263
const neverSent = await parked(ORG_A);
247264
const notFinished = await pending(ORG_A);
248265

@@ -257,6 +274,7 @@ describe('[#22756] handleRedeliver — the redeliver door from a Request, on a k
257274
['a delivery not yet finished', redeliver({ deliveryId: notFinished }), 409, 'DELIVERY_NOT_ELIGIBLE'],
258275
['a parked delivery, never sent', redeliver({ deliveryId: neverSent }), 409, 'DELIVERY_NEVER_SENT'],
259276
['a delivery whose subscription is gone (the veto)', redeliver({ deliveryId: vetoed }), 409, 'DELIVERY_NOT_ELIGIBLE'],
277+
['a delivery whose subscription is inactive (the veto)', redeliver({ deliveryId: inactive }), 409, 'DELIVERY_NOT_ELIGIBLE'],
260278
];
261279
for (const [label, request, status, code] of cases) {
262280
const answer = await snapshot(await member(request));
@@ -269,6 +287,7 @@ describe('[#22756] handleRedeliver — the redeliver door from a Request, on a k
269287
// Every refusal wrote nothing.
270288
expect(await statusOf(crossOrg)).toBe('dead');
271289
expect(await statusOf(vetoed)).toBe('dead');
290+
expect(await statusOf(inactive)).toBe('dead');
272291
expect(await statusOf(neverSent)).toBe('dead');
273292
expect(await statusOf(notFinished)).toBe('pending');
274293
});
@@ -308,6 +327,7 @@ describe('[#22756] parity — the member answers what the self-hosted mount answ
308327
const app = fixture.canonicalApp;
309328
const crossOrg = await attempted(ORG_B);
310329
const vetoed = await attempted(ORG_A, GONE_SUBSCRIPTION);
330+
const inactive = await attempted(ORG_A, INACTIVE_SUBSCRIPTION);
311331
const neverSent = await parked(ORG_A);
312332
const notFinished = await pending(ORG_A);
313333

@@ -323,6 +343,7 @@ describe('[#22756] parity — the member answers what the self-hosted mount answ
323343
['409 not finished', () => redeliver({ deliveryId: notFinished })],
324344
['409 never sent', () => redeliver({ deliveryId: neverSent })],
325345
['409 veto', () => redeliver({ deliveryId: vetoed })],
346+
['409 veto, inactive', () => redeliver({ deliveryId: inactive })],
326347
];
327348
for (const [label, make] of requests) {
328349
const viaMember = await snapshot(await member(make()));
@@ -401,3 +422,76 @@ describe('[#22756] the veto is installed without realtime or auto-enqueue, and t
401422
expect(plugin.lines('error', 'exposes no registerRedeliverGuard')).toHaveLength(1);
402423
});
403424
});
425+
426+
/**
427+
* [#22804] The veto refuses a delivery whose subscription is inactive.
428+
*
429+
* `active: false` is the operator's off switch for an endpoint, and the field
430+
* declares that an inactive webhook is skipped by the dispatcher; an explicit
431+
* replay is the dispatcher too. The refusal is the gone case's: `409
432+
* DELIVERY_NOT_ELIGIBLE`, the row untouched, and a reason an operator can act
433+
* on — it names the webhook, says it is inactive, and names the remedy.
434+
* Each control below is what a refuse-everything veto would fail.
435+
*/
436+
describe('[#22804] the veto refuses a delivery whose subscription is inactive, at the door', () => {
437+
const faces = (fixture: FixturePlugin, member: (r: Request) => Promise<Response>) => [
438+
['member', member],
439+
['mount', (r: Request) => fixture.canonicalApp.fetch(r)],
440+
] as const;
441+
442+
it('inactive: 409 DELIVERY_NOT_ELIGIBLE naming the webhook, its state and the remedy, on both faces', async () => {
443+
const { fixture, member, attempted, statusOf } = await boot({ server: 'both' });
444+
const inactive = await attempted(ORG_A, INACTIVE_SUBSCRIPTION);
445+
446+
for (const [face, send] of faces(fixture, member)) {
447+
const answer = await snapshot(await send(redeliver({ deliveryId: inactive })));
448+
expect(answer.status, face).toBe(409);
449+
const body = json(answer);
450+
expect(body, face).toMatchObject({ success: false, error: { code: 'DELIVERY_NOT_ELIGIBLE' } });
451+
expect(body.error.message, face).toContain("webhook 'paused_hook'");
452+
expect(body.error.message, face).toMatch(/\binactive\b/);
453+
expect(body.error.message, face).toMatch(/re-activate the webhook, then redeliver/);
454+
}
455+
expect(await statusOf(inactive)).toBe('dead');
456+
});
457+
458+
it('control: an active, signed subscription is replayed by both faces', async () => {
459+
const { fixture, member, attempted, statusOf } = await boot({ server: 'both' });
460+
for (const [face, send] of faces(fixture, member)) {
461+
const id = await attempted(ORG_A, SIGNED_SUBSCRIPTION);
462+
const answer = await snapshot(await send(redeliver({ deliveryId: id })));
463+
expect(answer.status, face).toBe(200);
464+
expect(json(answer), face).toEqual({ success: true, data: { id, status: 'pending' } });
465+
expect(await statusOf(id), face).toBe('pending');
466+
}
467+
});
468+
469+
it('control: a gone subscription keeps its own refusal, unchanged', async () => {
470+
const { fixture, member, attempted, statusOf } = await boot({ server: 'both' });
471+
const gone = await attempted(ORG_A, GONE_SUBSCRIPTION);
472+
for (const [face, send] of faces(fixture, member)) {
473+
const answer = await snapshot(await send(redeliver({ deliveryId: gone })));
474+
expect(answer.status, face).toBe(409);
475+
const body = json(answer);
476+
expect(body.error.code, face).toBe('DELIVERY_NOT_ELIGIBLE');
477+
expect(body.error.message, face).toContain(GONE_SUBSCRIPTION);
478+
expect(body.error.message, face).toContain('no longer exists');
479+
expect(body.error.message, face).not.toMatch(/\binactive\b/);
480+
}
481+
expect(await statusOf(gone)).toBe('dead');
482+
});
483+
484+
it('control: the same delivery is replayed once its webhook is re-activated', async () => {
485+
const { fixture, member, attempted, statusOf } = await boot({ server: 'both' });
486+
const id = await attempted(ORG_A, INACTIVE_SUBSCRIPTION);
487+
expect((await member(redeliver({ deliveryId: id }))).status).toBe(409);
488+
expect(await statusOf(id)).toBe('dead');
489+
490+
fixture.subscriptions.get(INACTIVE_SUBSCRIPTION)!.active = true;
491+
492+
const answer = await snapshot(await member(redeliver({ deliveryId: id })));
493+
expect(answer.status).toBe(200);
494+
expect(json(answer)).toEqual({ success: true, data: { id, status: 'pending' } });
495+
expect(await statusOf(id)).toBe('pending');
496+
});
497+
});

0 commit comments

Comments
 (0)