@antasphere/chassis-cli 1.1.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,468 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import type { CliIo } from '@antasphere/chassis-cli';
3
+ import { cli, run } from '@chassis-cli-test/host';
4
+
5
+ // The literals that spell the tool come from the host (its identity).
6
+ const { bin, displayName, webhooksScope } = cli.identity;
7
+
8
+ /**
9
+ * The webhook commands (PRDCT-3482): the method, path and body each one
10
+ * sends (recording fake fetch, as every file of this suite), the secret
11
+ * printed once on its own line, `--json` byte-exactness, the usage errors
12
+ * that never reach the wire, the exit code of a ping that was not delivered,
13
+ * and the refusals read as sentences. The server side of each answer
14
+ * (delivery to a loopback receiver included) is the chassis integration
15
+ * suite's `webhooks.test.ts`; here the answers are canned.
16
+ */
17
+
18
+ interface Canned {
19
+ status?: number;
20
+ body?: unknown;
21
+ }
22
+
23
+ interface Call {
24
+ method: string;
25
+ path: string;
26
+ body: unknown;
27
+ }
28
+
29
+ function harness(responses: Canned[] = [{ status: 200, body: {} }]) {
30
+ const calls: Call[] = [];
31
+ const out: string[] = [];
32
+ const err: string[] = [];
33
+ const queue = [...responses];
34
+ const fetch = (async (input: string | URL | Request, init?: RequestInit) => {
35
+ const url = new URL(input.toString());
36
+ calls.push({
37
+ method: (init?.method ?? 'GET').toUpperCase(),
38
+ path: url.pathname + url.search,
39
+ body: typeof init?.body === 'string' ? JSON.parse(init.body) : undefined
40
+ });
41
+ const r = queue.shift() ?? { status: 200, body: {} };
42
+ return new Response(JSON.stringify(r.body ?? {}), {
43
+ status: r.status ?? 200,
44
+ headers: { 'content-type': 'application/json' }
45
+ });
46
+ }) as typeof globalThis.fetch;
47
+ const io: CliIo = {
48
+ env: {},
49
+ out: { write: (s) => out.push(s) },
50
+ err: { write: (s) => err.push(s) },
51
+ fetch
52
+ };
53
+ return { io, calls, out: () => out.join(''), err: () => err.join('') };
54
+ }
55
+
56
+ const WIRED = ['--url', 'http://x', '--api-key', 'k'];
57
+
58
+ const HOOK_ID = '11111111-1111-4111-8111-111111111111';
59
+ const DELIVERY_ID = '22222222-2222-4222-8222-222222222222';
60
+ const SECRET = 'whsec_c2VjcmV0LW9mLXRoZS1zdWl0ZQ';
61
+
62
+ const ENDPOINT = {
63
+ id: HOOK_ID,
64
+ url: 'https://receiver.example/hooks',
65
+ description: 'The audit mirror',
66
+ events: ['thing.created', 'thing.deleted'],
67
+ filters: {},
68
+ enabled: true,
69
+ createdBy: 'u1',
70
+ createdAt: '2026-10-01T10:00:00.000Z',
71
+ updatedAt: '2026-10-01T10:00:00.000Z',
72
+ disabledAt: null,
73
+ disabledReason: null,
74
+ consecutiveFailures: 0,
75
+ lastDeliveryAt: '2026-10-02T09:30:00.000Z',
76
+ lastDeliveryStatus: 'delivered',
77
+ previousSecretExpiresAt: null
78
+ };
79
+
80
+ const DELIVERY = {
81
+ id: DELIVERY_ID,
82
+ endpointId: HOOK_ID,
83
+ eventId: 'evt_1',
84
+ eventType: 'webhook.ping',
85
+ status: 'delivered',
86
+ attempt: 1,
87
+ lastStatusCode: 200,
88
+ lastError: null,
89
+ nextAttemptAt: null,
90
+ deliveredAt: '2026-10-02T09:30:01.000Z',
91
+ createdAt: '2026-10-02T09:30:00.000Z',
92
+ updatedAt: '2026-10-02T09:30:01.000Z',
93
+ payload: { type: 'webhook.ping', data: {} }
94
+ };
95
+
96
+ const CATALOG = {
97
+ subjects: [{ key: 'thing', field: 'thingId', description: 'A thing of the workspace, by id' }],
98
+ events: [
99
+ { type: 'webhook.ping', description: 'A test delivery.', subjects: [], schema: { type: 'object' } },
100
+ {
101
+ type: 'thing.created',
102
+ description: 'A thing was created.',
103
+ subjects: ['thing'],
104
+ schema: { type: 'object' }
105
+ }
106
+ ]
107
+ };
108
+
109
+ const refusal = (
110
+ status: number,
111
+ code: string,
112
+ message = 'terse wire message',
113
+ details?: unknown
114
+ ): Canned => ({
115
+ status,
116
+ body: { error: { code, message, ...(details ? { details } : {}) } }
117
+ });
118
+
119
+ describe(`${bin} webhooks`, () => {
120
+ it('list says so when there is nothing', async () => {
121
+ const h = harness([{ body: { webhooks: [], nextCursor: null } }]);
122
+ expect(await run(['webhooks', 'list', ...WIRED], h.io)).toBe(0);
123
+ expect(h.calls.map((c) => `${c.method} ${c.path}`)).toEqual(['GET /api/v1/webhooks']);
124
+ expect(h.out()).toBe('No webhooks.\n');
125
+ });
126
+
127
+ it('list reads every page and prints the columns', async () => {
128
+ const second = { ...ENDPOINT, id: 'w2', enabled: false, lastDeliveryAt: null, lastDeliveryStatus: null };
129
+ const h = harness([
130
+ { body: { webhooks: [ENDPOINT], nextCursor: 'c1' } },
131
+ { body: { webhooks: [second], nextCursor: null } }
132
+ ]);
133
+ expect(await run(['webhooks', 'list', ...WIRED], h.io)).toBe(0);
134
+ expect(h.calls.map((c) => c.path)).toEqual(['/api/v1/webhooks', '/api/v1/webhooks?cursor=c1']);
135
+ const lines = h.out().split('\n');
136
+ expect(lines[0]).toMatch(/^ID\s+URL\s+EVENTS\s+ENABLED\s+LAST DELIVERY\s+FAILURES$/);
137
+ expect(h.out()).toMatch(
138
+ /11111111-\S+\s+https:\/\/receiver\.example\/hooks\s+thing\.created,thing\.deleted\s+yes\s+delivered 2026-10-02 09:30\s+0/
139
+ );
140
+ expect(h.out()).toMatch(/w2\s+\S+\s+\S+\s+no\s+-\s+0/);
141
+ });
142
+
143
+ it('create sends the URL, the events, the description, the filters (OR on a subject) and the switch', async () => {
144
+ const h = harness([{ status: 201, body: { ...ENDPOINT, secret: SECRET } }]);
145
+ const code = await run(
146
+ [
147
+ 'webhooks',
148
+ 'create',
149
+ 'https://receiver.example/hooks',
150
+ '--event',
151
+ 'thing.created',
152
+ '--event',
153
+ 'thing.*',
154
+ '--description',
155
+ 'The audit mirror',
156
+ '--filter',
157
+ 'thing=a1',
158
+ '--filter',
159
+ 'thing=a2',
160
+ '--filter',
161
+ 'project=p1',
162
+ '--disabled',
163
+ ...WIRED
164
+ ],
165
+ h.io
166
+ );
167
+ expect(code).toBe(0);
168
+ expect(h.calls).toEqual([
169
+ {
170
+ method: 'POST',
171
+ path: '/api/v1/webhooks',
172
+ body: {
173
+ url: 'https://receiver.example/hooks',
174
+ events: ['thing.created', 'thing.*'],
175
+ description: 'The audit mirror',
176
+ filters: { thing: ['a1', 'a2'], project: ['p1'] },
177
+ enabled: false
178
+ }
179
+ }
180
+ ]);
181
+ });
182
+
183
+ it('create prints the id, then the secret alone on its line, said to be shown once', async () => {
184
+ const h = harness([{ status: 201, body: { ...ENDPOINT, secret: SECRET } }]);
185
+ const argv = ['webhooks', 'create', 'https://receiver.example/hooks', '--event', 'thing.created'];
186
+ expect(await run([...argv, ...WIRED], h.io)).toBe(0);
187
+ expect(h.calls[0]!.body).toEqual({ url: 'https://receiver.example/hooks', events: ['thing.created'] });
188
+ const lines = h.out().split('\n');
189
+ expect(lines[0]).toContain(HOOK_ID);
190
+ expect(h.out()).toContain('shown this once');
191
+ expect(lines).toContain(SECRET);
192
+ expect(h.out().indexOf(HOOK_ID)).toBeLessThan(h.out().indexOf(SECRET));
193
+ });
194
+
195
+ it('create --json prints the payload byte-exactly, the secret included', async () => {
196
+ const created = { ...ENDPOINT, description: 'Mirror\u001b[2KHIDDEN', secret: SECRET };
197
+ const h = harness([{ status: 201, body: created }]);
198
+ const argv = ['webhooks', 'create', 'https://receiver.example/hooks', '--event', 'thing.created'];
199
+ expect(await run([...argv, '--json', ...WIRED], h.io)).toBe(0);
200
+ expect(h.out()).toBe(`${JSON.stringify(created, null, 2)}\n`);
201
+ });
202
+
203
+ it('create without --event is a usage error that never reaches the wire', async () => {
204
+ const h = harness();
205
+ expect(await run(['webhooks', 'create', 'https://receiver.example/hooks', ...WIRED], h.io)).toBe(1);
206
+ expect(h.calls).toEqual([]);
207
+ expect(h.err()).toContain('Name at least one event');
208
+ });
209
+
210
+ it('create refuses a --filter that is not <subject>=<id> before any request', async () => {
211
+ const h = harness();
212
+ const argv = ['webhooks', 'create', 'https://r.example', '--event', 'thing.created', '--filter', 'thing'];
213
+ expect(await run([...argv, ...WIRED], h.io)).toBe(1);
214
+ expect(h.calls).toEqual([]);
215
+ expect(h.err()).toContain('--filter takes <subject>=<id>');
216
+ });
217
+
218
+ it('events prints the catalog, webhook.ping among the types, and the subjects', async () => {
219
+ const h = harness([{ body: CATALOG }]);
220
+ expect(await run(['webhooks', 'events', ...WIRED], h.io)).toBe(0);
221
+ expect(h.calls.map((c) => c.path)).toEqual(['/api/v1/webhooks/events']);
222
+ expect(h.out()).toMatch(/^TYPE\s+SUBJECTS\s+DESCRIPTION/);
223
+ expect(h.out()).toMatch(/webhook\.ping\s+-\s+A test delivery\./);
224
+ expect(h.out()).toMatch(/thing\.created\s+thing\s+A thing was created\./);
225
+ expect(h.out()).toMatch(/thing\s+subjects\.thingId\s+A thing of the workspace, by id/);
226
+ });
227
+
228
+ it('events --json is the catalog verbatim', async () => {
229
+ const h = harness([{ body: CATALOG }]);
230
+ expect(await run(['webhooks', 'events', '--json', ...WIRED], h.io)).toBe(0);
231
+ expect(h.out()).toBe(`${JSON.stringify(CATALOG, null, 2)}\n`);
232
+ });
233
+
234
+ it('get reads the endpoint and shows it, its text sanitized', async () => {
235
+ const h = harness([{ body: { ...ENDPOINT, description: 'Mirror\u001b[2KHIDDEN' } }]);
236
+ expect(await run(['webhooks', 'get', HOOK_ID, ...WIRED], h.io)).toBe(0);
237
+ expect(h.calls.map((c) => `${c.method} ${c.path}`)).toEqual([`GET /api/v1/webhooks/${HOOK_ID}`]);
238
+ expect(h.out()).toContain('https://receiver.example/hooks');
239
+ expect(h.out()).toContain('thing.created, thing.deleted');
240
+ expect(h.out()).not.toContain('\u001b');
241
+ expect(h.out()).toContain('MirrorHIDDEN');
242
+ });
243
+
244
+ it('update sends only what was passed', async () => {
245
+ const h = harness([{ body: { ...ENDPOINT, events: ['thing.deleted'], enabled: false } }]);
246
+ const code = await run(
247
+ ['webhooks', 'update', HOOK_ID, '--event', 'thing.deleted', '--disable', ...WIRED],
248
+ h.io
249
+ );
250
+ expect(code).toBe(0);
251
+ expect(h.calls).toEqual([
252
+ {
253
+ method: 'PATCH',
254
+ path: `/api/v1/webhooks/${HOOK_ID}`,
255
+ body: { events: ['thing.deleted'], enabled: false }
256
+ }
257
+ ]);
258
+ expect(h.out()).toContain(`Updated webhook ${HOOK_ID}.`);
259
+ });
260
+
261
+ it('update --endpoint-url and --clear-filters send the url and an empty filter set', async () => {
262
+ const h = harness([{ body: ENDPOINT }]);
263
+ const argv = ['webhooks', 'update', HOOK_ID, '--endpoint-url', 'https://r2.example', '--clear-filters'];
264
+ expect(await run([...argv, '--enable', ...WIRED], h.io)).toBe(0);
265
+ expect(h.calls[0]!.body).toEqual({ url: 'https://r2.example', filters: {}, enabled: true });
266
+ });
267
+
268
+ it('update with nothing to change, or --enable with --disable, never reaches the wire', async () => {
269
+ const h = harness();
270
+ expect(await run(['webhooks', 'update', HOOK_ID, ...WIRED], h.io)).toBe(1);
271
+ expect(h.err()).toContain('Nothing to change');
272
+
273
+ const h2 = harness();
274
+ expect(await run(['webhooks', 'update', HOOK_ID, '--enable', '--disable', ...WIRED], h2.io)).toBe(1);
275
+ expect(h2.err()).toContain('--enable or --disable, not both');
276
+
277
+ const h3 = harness();
278
+ const both = ['webhooks', 'update', HOOK_ID, '--filter', 'thing=a1', '--clear-filters'];
279
+ expect(await run([...both, ...WIRED], h3.io)).toBe(1);
280
+ expect([...h.calls, ...h2.calls, ...h3.calls]).toEqual([]);
281
+ });
282
+
283
+ it('deliveries reads one page with the status and the limit, and says when it is empty', async () => {
284
+ const h = harness([{ body: { deliveries: [], nextCursor: null } }]);
285
+ const argv = ['webhooks', 'deliveries', HOOK_ID, '--status', 'failed', '--limit', '10'];
286
+ expect(await run([...argv, ...WIRED], h.io)).toBe(0);
287
+ expect(h.calls.map((c) => c.path)).toEqual([
288
+ `/api/v1/webhooks/${HOOK_ID}/deliveries?limit=10&status=failed`
289
+ ]);
290
+ expect(h.out()).toBe('No deliveries.\n');
291
+ });
292
+
293
+ it('deliveries prints the columns and the next cursor; --json is the page verbatim', async () => {
294
+ const page = { deliveries: [DELIVERY], nextCursor: 'd1' };
295
+ const h = harness([{ body: page }]);
296
+ expect(await run(['webhooks', 'deliveries', HOOK_ID, ...WIRED], h.io)).toBe(0);
297
+ expect(h.out()).toMatch(/^ID\s+EVENT\s+STATUS\s+ATTEMPT\s+CODE\s+CREATED/);
298
+ expect(h.out()).toMatch(/22222222-\S+\s+webhook\.ping\s+delivered\s+1\s+200\s+2026-10-02 09:30/);
299
+ expect(h.out()).toContain('More: --cursor d1');
300
+
301
+ const h2 = harness([{ body: page }]);
302
+ expect(await run(['webhooks', 'deliveries', HOOK_ID, '--cursor', 'd1', '--json', ...WIRED], h2.io)).toBe(
303
+ 0
304
+ );
305
+ expect(h2.calls[0]!.path).toBe(`/api/v1/webhooks/${HOOK_ID}/deliveries?cursor=d1`);
306
+ expect(h2.out()).toBe(`${JSON.stringify(page, null, 2)}\n`);
307
+ });
308
+
309
+ it('deliveries refuses an unknown --status before any request', async () => {
310
+ const h = harness();
311
+ expect(await run(['webhooks', 'deliveries', HOOK_ID, '--status', 'lost', ...WIRED], h.io)).toBe(1);
312
+ expect(h.calls).toEqual([]);
313
+ expect(h.err()).toContain('--status takes one of pending, delivered, failed, held');
314
+ });
315
+
316
+ it('test prints the delivered ping and exits 0', async () => {
317
+ const h = harness([{ body: { delivery: DELIVERY } }]);
318
+ expect(await run(['webhooks', 'test', HOOK_ID, ...WIRED], h.io)).toBe(0);
319
+ expect(h.calls.map((c) => `${c.method} ${c.path}`)).toEqual([`POST /api/v1/webhooks/${HOOK_ID}/test`]);
320
+ expect(h.out()).toContain('Ping delivered, HTTP 200');
321
+ });
322
+
323
+ it('test prints a failed ping, its code and its error, and exits 1', async () => {
324
+ const failed = {
325
+ ...DELIVERY,
326
+ status: 'failed',
327
+ lastStatusCode: 500,
328
+ lastError: 'HTTP 500',
329
+ deliveredAt: null
330
+ };
331
+ const h = harness([{ body: { delivery: failed } }]);
332
+ expect(await run(['webhooks', 'test', HOOK_ID, ...WIRED], h.io)).toBe(1);
333
+ expect(h.out()).toContain('Ping failed, HTTP 500: HTTP 500');
334
+ expect(h.err()).toContain('The ping was not delivered (failed).');
335
+
336
+ const h2 = harness([{ body: { delivery: failed } }]);
337
+ expect(await run(['webhooks', 'test', HOOK_ID, '--json', ...WIRED], h2.io)).toBe(1);
338
+ expect(h2.out()).toBe(`${JSON.stringify({ delivery: failed }, null, 2)}\n`);
339
+ });
340
+
341
+ it('rotate-secret prints the new secret once and the date the previous one stops signing', async () => {
342
+ const rotated = { ...ENDPOINT, secret: SECRET, previousSecretExpiresAt: '2026-10-03T10:00:00.000Z' };
343
+ const h = harness([{ body: rotated }]);
344
+ expect(await run(['webhooks', 'rotate-secret', HOOK_ID, ...WIRED], h.io)).toBe(0);
345
+ expect(h.calls.map((c) => `${c.method} ${c.path}`)).toEqual([
346
+ `POST /api/v1/webhooks/${HOOK_ID}/rotate-secret`
347
+ ]);
348
+ expect(h.out().split('\n')).toContain(SECRET);
349
+ expect(h.out()).toContain('shown this once');
350
+ expect(h.out()).toContain('The previous secret keeps signing until 2026-10-03T10:00:00.000Z.');
351
+
352
+ const h2 = harness([{ body: rotated }]);
353
+ expect(await run(['webhooks', 'rotate-secret', HOOK_ID, '--json', ...WIRED], h2.io)).toBe(0);
354
+ expect(h2.out()).toBe(`${JSON.stringify(rotated, null, 2)}\n`);
355
+ });
356
+
357
+ it('delete deletes; a get after it reads the not-found sentence and exits 1', async () => {
358
+ const h = harness([{ body: ENDPOINT }]);
359
+ expect(await run(['webhooks', 'delete', HOOK_ID, ...WIRED], h.io)).toBe(0);
360
+ expect(h.calls).toEqual([{ method: 'DELETE', path: `/api/v1/webhooks/${HOOK_ID}`, body: undefined }]);
361
+ expect(h.out()).toContain(`Deleted webhook ${HOOK_ID}`);
362
+
363
+ const h2 = harness([refusal(404, 'not_found', 'Webhook not found')]);
364
+ expect(await run(['webhooks', 'get', HOOK_ID, ...WIRED], h2.io)).toBe(1);
365
+ expect(h2.err()).toContain('No such webhook in this workspace.');
366
+ });
367
+
368
+ it('delivery and redeliver hit the delivery paths; an unknown delivery reads as such', async () => {
369
+ const h = harness([{ body: DELIVERY }]);
370
+ expect(await run(['webhooks', 'delivery', HOOK_ID, DELIVERY_ID, ...WIRED], h.io)).toBe(0);
371
+ expect(h.calls[0]!.path).toBe(`/api/v1/webhooks/${HOOK_ID}/deliveries/${DELIVERY_ID}`);
372
+ expect(h.out()).toContain('status: delivered');
373
+
374
+ const h2 = harness([{ status: 201, body: { ...DELIVERY, id: 'd-new', status: 'pending' } }]);
375
+ expect(await run(['webhooks', 'redeliver', HOOK_ID, DELIVERY_ID, ...WIRED], h2.io)).toBe(0);
376
+ expect(h2.calls[0]).toEqual({
377
+ method: 'POST',
378
+ path: `/api/v1/webhooks/${HOOK_ID}/deliveries/${DELIVERY_ID}/redeliver`,
379
+ body: undefined
380
+ });
381
+ expect(h2.out()).toContain('Queued delivery d-new of webhook.ping (event evt_1).');
382
+
383
+ const h3 = harness([refusal(404, 'not_found', 'Delivery not found')]);
384
+ expect(await run(['webhooks', 'redeliver', HOOK_ID, DELIVERY_ID, ...WIRED], h3.io)).toBe(1);
385
+ expect(h3.err()).toContain('No such delivery.');
386
+ });
387
+
388
+ // ── the refusals ─────────────────────────────────────────────────────────
389
+
390
+ it('a key without the webhooks scope reads the scope sentence', async () => {
391
+ const expected = webhooksScope
392
+ ? `This key cannot manage webhooks: create one with the ${webhooksScope} scope on the API keys page.`
393
+ : `${displayName} opens webhooks to no API key`;
394
+ const h = harness([refusal(403, 'insufficient_scope')]);
395
+ expect(await run(['webhooks', 'list', ...WIRED], h.io)).toBe(1);
396
+ expect(h.err()).toContain(expected);
397
+ expect(h.err()).not.toContain('terse wire message');
398
+
399
+ const h2 = harness([refusal(403, 'endpoint_not_allowed')]);
400
+ expect(await run(['webhooks', 'test', HOOK_ID, ...WIRED], h2.io)).toBe(1);
401
+ expect(h2.err()).toContain(expected);
402
+ });
403
+
404
+ it('the catalog keeps the server sentence for a key without the read scope', async () => {
405
+ const h = harness([refusal(403, 'insufficient_scope', 'This credential was not granted "x:read"')]);
406
+ expect(await run(['webhooks', 'events', ...WIRED], h.io)).toBe(1);
407
+ expect(h.err()).toContain('This credential was not granted "x:read"');
408
+ });
409
+
410
+ it('403 forbidden and guest_forbidden read as sentences', async () => {
411
+ const h = harness([refusal(403, 'forbidden')]);
412
+ expect(await run(['webhooks', 'list', ...WIRED], h.io)).toBe(1);
413
+ expect(h.err()).toContain('Only an owner or an admin manages webhooks.');
414
+
415
+ const h2 = harness([refusal(403, 'guest_forbidden')]);
416
+ expect(await run(['webhooks', 'list', ...WIRED], h2.io)).toBe(1);
417
+ expect(h2.err()).toContain('guests do not manage its webhooks');
418
+ });
419
+
420
+ it('url_refused and demo_session keep the server message verbatim', async () => {
421
+ const h = harness([refusal(400, 'url_refused', 'This address is on a private network')]);
422
+ const argv = ['webhooks', 'create', 'http://10.0.0.1/hook', '--event', 'thing.created'];
423
+ expect(await run([...argv, ...WIRED], h.io)).toBe(1);
424
+ expect(h.err()).toContain('Error: This address is on a private network');
425
+
426
+ const h2 = harness([refusal(403, 'demo_session', 'A demonstration session cannot do this')]);
427
+ expect(await run(['webhooks', 'rotate-secret', HOOK_ID, ...WIRED], h2.io)).toBe(1);
428
+ expect(h2.err()).toContain('A demonstration session cannot do this');
429
+ });
430
+
431
+ it('unknown_event points at `webhooks events` and the family form; unknown_subject at the subject keys (N-R2-1)', async () => {
432
+ const h = harness([refusal(400, 'unknown_event', 'Unknown event type or family: nothing.*')]);
433
+ const argv = ['webhooks', 'create', 'https://r.example', '--event', 'nothing.*'];
434
+ expect(await run([...argv, ...WIRED], h.io)).toBe(1);
435
+ expect(h.calls).toHaveLength(1);
436
+ expect(h.err()).toContain('Unknown event type or family: nothing.*');
437
+ expect(h.err()).toContain('The exact types are in `webhooks events`');
438
+ expect(h.err()).toContain('a family is its first word plus `.*`');
439
+
440
+ const h2 = harness([refusal(400, 'unknown_subject', 'Unknown filter subject: bogus')]);
441
+ const argv2 = [
442
+ 'webhooks',
443
+ 'create',
444
+ 'https://r.example',
445
+ '--event',
446
+ 'thing.created',
447
+ '--filter',
448
+ 'bogus=x'
449
+ ];
450
+ expect(await run([...argv2, ...WIRED], h2.io)).toBe(1);
451
+ expect(h2.calls).toHaveLength(1);
452
+ expect(h2.err()).toContain('Unknown filter subject: bogus');
453
+ expect(h2.err()).toContain('The subject keys are in `webhooks events`');
454
+ });
455
+
456
+ it('plan_required carries the upgrade link', async () => {
457
+ const h = harness([
458
+ refusal(403, 'plan_required', 'Webhooks need a paid plan', {
459
+ upgradeUrl: 'https://account.example/orgs/o1'
460
+ })
461
+ ]);
462
+ const argv = ['webhooks', 'create', 'https://r.example', '--event', 'thing.created'];
463
+ expect(await run([...argv, ...WIRED], h.io)).toBe(1);
464
+ expect(h.err()).toContain(
465
+ 'Webhooks need a paid plan — upgrade the plan at https://account.example/orgs/o1'
466
+ );
467
+ });
468
+ });