@antasphere/chassis-server 1.1.0 → 1.2.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.
- package/dist/api/apikeys.d.ts +4 -1
- package/dist/api/apikeys.d.ts.map +1 -1
- package/dist/api/apikeys.js +11 -1
- package/dist/api/apikeys.js.map +1 -1
- package/dist/api/create-api.d.ts +3 -0
- package/dist/api/create-api.d.ts.map +1 -1
- package/dist/api/create-api.js +8 -3
- package/dist/api/create-api.js.map +1 -1
- package/dist/api/invitations.d.ts.map +1 -1
- package/dist/api/invitations.js +6 -0
- package/dist/api/invitations.js.map +1 -1
- package/dist/api/members.d.ts +5 -2
- package/dist/api/members.d.ts.map +1 -1
- package/dist/api/members.js +1 -0
- package/dist/api/members.js.map +1 -1
- package/dist/api/projects.d.ts +3 -0
- package/dist/api/projects.d.ts.map +1 -1
- package/dist/api/projects.js +13 -1
- package/dist/api/projects.js.map +1 -1
- package/dist/api/webhooks.d.ts +24 -0
- package/dist/api/webhooks.d.ts.map +1 -0
- package/dist/api/webhooks.js +170 -0
- package/dist/api/webhooks.js.map +1 -0
- package/dist/boot.d.ts.map +1 -1
- package/dist/boot.js +47 -4
- package/dist/boot.js.map +1 -1
- package/dist/email/index.d.ts +1 -0
- package/dist/email/index.d.ts.map +1 -1
- package/dist/email/index.js +1 -0
- package/dist/email/index.js.map +1 -1
- package/dist/env.d.ts +9 -0
- package/dist/env.d.ts.map +1 -1
- package/dist/env.js +11 -0
- package/dist/env.js.map +1 -1
- package/dist/identity/demo-pass-rules.d.ts.map +1 -1
- package/dist/identity/demo-pass-rules.js +6 -1
- package/dist/identity/demo-pass-rules.js.map +1 -1
- package/dist/identity/hub-reconcile.d.ts +2 -2
- package/dist/identity/hub-reconcile.d.ts.map +1 -1
- package/dist/identity/hub-reconcile.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/jobs/index.d.ts +1 -1
- package/dist/jobs/index.d.ts.map +1 -1
- package/dist/jobs/index.js.map +1 -1
- package/dist/jobs/pgboss.d.ts +34 -0
- package/dist/jobs/pgboss.d.ts.map +1 -1
- package/dist/jobs/pgboss.js +32 -9
- package/dist/jobs/pgboss.js.map +1 -1
- package/dist/mcp/index.d.ts +1 -0
- package/dist/mcp/index.d.ts.map +1 -1
- package/dist/mcp/index.js +1 -0
- package/dist/mcp/index.js.map +1 -1
- package/dist/mcp/server.d.ts +1 -1
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/mcp/server.js +11 -2
- package/dist/mcp/server.js.map +1 -1
- package/dist/mcp/tool-kit.d.ts +2 -0
- package/dist/mcp/tool-kit.d.ts.map +1 -1
- package/dist/mcp/tool-kit.js +5 -1
- package/dist/mcp/tool-kit.js.map +1 -1
- package/dist/mcp/webhooks.d.ts +16 -0
- package/dist/mcp/webhooks.d.ts.map +1 -0
- package/dist/mcp/webhooks.js +226 -0
- package/dist/mcp/webhooks.js.map +1 -0
- package/dist/members/removal.d.ts +10 -2
- package/dist/members/removal.d.ts.map +1 -1
- package/dist/members/removal.js +3 -0
- package/dist/members/removal.js.map +1 -1
- package/dist/middleware/idempotency.d.ts.map +1 -1
- package/dist/middleware/idempotency.js +3 -1
- package/dist/middleware/idempotency.js.map +1 -1
- package/dist/middleware/scopes.d.ts +9 -1
- package/dist/middleware/scopes.d.ts.map +1 -1
- package/dist/middleware/scopes.js +8 -1
- package/dist/middleware/scopes.js.map +1 -1
- package/dist/secret-box.d.ts +10 -0
- package/dist/secret-box.d.ts.map +1 -0
- package/dist/secret-box.js +87 -0
- package/dist/secret-box.js.map +1 -0
- package/dist/testing/helpers.d.ts +8 -0
- package/dist/testing/helpers.d.ts.map +1 -1
- package/dist/testing/helpers.js.map +1 -1
- package/dist/tool-definition.d.ts +24 -0
- package/dist/tool-definition.d.ts.map +1 -1
- package/dist/tool-definition.js.map +1 -1
- package/dist/webhooks/catalog.d.ts +80 -0
- package/dist/webhooks/catalog.d.ts.map +1 -0
- package/dist/webhooks/catalog.js +134 -0
- package/dist/webhooks/catalog.js.map +1 -0
- package/dist/webhooks/index.d.ts +9 -0
- package/dist/webhooks/index.d.ts.map +1 -0
- package/dist/webhooks/index.js +9 -0
- package/dist/webhooks/index.js.map +1 -0
- package/dist/webhooks/jobs.d.ts +12 -0
- package/dist/webhooks/jobs.d.ts.map +1 -0
- package/dist/webhooks/jobs.js +77 -0
- package/dist/webhooks/jobs.js.map +1 -0
- package/dist/webhooks/mail.d.ts +24 -0
- package/dist/webhooks/mail.d.ts.map +1 -0
- package/dist/webhooks/mail.js +21 -0
- package/dist/webhooks/mail.js.map +1 -0
- package/dist/webhooks/match.d.ts +22 -0
- package/dist/webhooks/match.d.ts.map +1 -0
- package/dist/webhooks/match.js +40 -0
- package/dist/webhooks/match.js.map +1 -0
- package/dist/webhooks/safe-fetch.d.ts +94 -0
- package/dist/webhooks/safe-fetch.d.ts.map +1 -0
- package/dist/webhooks/safe-fetch.js +263 -0
- package/dist/webhooks/safe-fetch.js.map +1 -0
- package/dist/webhooks/service.d.ts +245 -0
- package/dist/webhooks/service.d.ts.map +1 -0
- package/dist/webhooks/service.js +861 -0
- package/dist/webhooks/service.js.map +1 -0
- package/dist/webhooks/signing.d.ts +40 -0
- package/dist/webhooks/signing.d.ts.map +1 -0
- package/dist/webhooks/signing.js +108 -0
- package/dist/webhooks/signing.js.map +1 -0
- package/dist/webhooks/tx-send.d.ts +31 -0
- package/dist/webhooks/tx-send.d.ts.map +1 -0
- package/dist/webhooks/tx-send.js +22 -0
- package/dist/webhooks/tx-send.js.map +1 -0
- package/package.json +7 -3
- package/src/api/apikeys.ts +12 -1
- package/src/api/create-api.ts +17 -3
- package/src/api/invitations.ts +6 -0
- package/src/api/members.ts +6 -2
- package/src/api/projects.ts +16 -1
- package/src/api/webhooks.ts +205 -0
- package/src/boot.ts +48 -4
- package/src/email/index.ts +1 -0
- package/src/env.ts +11 -0
- package/src/identity/demo-pass-rules.ts +6 -1
- package/src/identity/hub-reconcile.ts +2 -2
- package/src/index.ts +13 -1
- package/src/jobs/index.ts +1 -0
- package/src/jobs/pgboss.ts +71 -9
- package/src/mcp/index.ts +1 -0
- package/src/mcp/server.ts +10 -2
- package/src/mcp/tool-kit.ts +7 -1
- package/src/mcp/webhooks.ts +370 -0
- package/src/members/removal.ts +21 -1
- package/src/middleware/idempotency.ts +3 -1
- package/src/middleware/scopes.ts +14 -0
- package/src/secret-box.ts +93 -0
- package/src/testing/helpers.ts +8 -0
- package/src/tool-definition.ts +29 -0
- package/src/webhooks/catalog.ts +205 -0
- package/src/webhooks/index.ts +58 -0
- package/src/webhooks/jobs.ts +92 -0
- package/src/webhooks/mail.ts +48 -0
- package/src/webhooks/match.ts +52 -0
- package/src/webhooks/safe-fetch.ts +335 -0
- package/src/webhooks/service.ts +1089 -0
- package/src/webhooks/signing.ts +132 -0
- package/src/webhooks/tx-send.ts +53 -0
- package/test/host/drizzle/0002_webhooks.sql +49 -0
- package/test/host/drizzle/meta/0002_snapshot.json +3952 -0
- package/test/host/drizzle/meta/_journal.json +8 -1
- package/test/host/identity.ts +6 -1
- package/test/host/minimal-host.ts +2 -1
- package/test/host/minimal-tool.ts +32 -4
- package/test/integration/mcp-webhooks.test.ts +360 -0
- package/test/integration/webhooks.test.ts +1684 -0
- package/test/unit/secret-box.test.ts +124 -0
- package/test/unit/tx-send.test.ts +23 -0
- package/test/unit/webhook-catalog.test.ts +151 -0
- package/test/unit/webhook-env.test.ts +60 -0
- package/test/unit/webhook-jobs.test.ts +92 -0
- package/test/unit/webhook-match.test.ts +81 -0
- package/test/unit/webhook-safe-fetch.test.ts +330 -0
- package/test/unit/webhook-signing.test.ts +169 -0
|
@@ -0,0 +1,1684 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import http from 'node:http';
|
|
3
|
+
import type { AddressInfo } from 'node:net';
|
|
4
|
+
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import type { StartedPostgreSqlContainer } from '@testcontainers/postgresql';
|
|
6
|
+
import { and, eq } from 'drizzle-orm';
|
|
7
|
+
import { webhookDeliveries, webhookEndpoints, workspaceMembers } from '@antasphere/chassis-db';
|
|
8
|
+
import { DEMO_SESSION_REFUSED_API_ROUTES } from '@antasphere/chassis-server/identity';
|
|
9
|
+
import { verifyWebhookSignature } from '@antasphere/chassis-server/webhooks';
|
|
10
|
+
import {
|
|
11
|
+
createDatabase,
|
|
12
|
+
createTestApp,
|
|
13
|
+
extractCookie,
|
|
14
|
+
host,
|
|
15
|
+
readJson,
|
|
16
|
+
RecordingEmailDriver,
|
|
17
|
+
startPostgres,
|
|
18
|
+
type TestApp
|
|
19
|
+
} from './helpers.js';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Outbound webhooks end to end (PRDCT-3482): the catalog, the endpoint CRUD
|
|
23
|
+
* and its admin rule, the emit inside the write's transaction (nothing for a
|
|
24
|
+
* rolled-back write, nothing at all when nobody listens), the fan-out with
|
|
25
|
+
* its filters, the signed delivery to a REAL receiver over the real node
|
|
26
|
+
* transport (loopback, so the instance opens private networks), retry then
|
|
27
|
+
* hold then re-drive, the auto-disable and its mail, the rotation overlap,
|
|
28
|
+
* the test ping, the redelivery, the URL policy of a closed instance (the
|
|
29
|
+
* address check inside the socket's own lookup), the scope rule for keys, the
|
|
30
|
+
* demo-session refusal, the plan limit's count and the purge.
|
|
31
|
+
*
|
|
32
|
+
* Isolation without truncation: every endpoint but `hook-a` narrows itself
|
|
33
|
+
* to a subject id no other test emits, and `hook-a` (`thing.*`, no filter,
|
|
34
|
+
* always 200) is the WITNESS of a fan-out: the fan-out writes every matching
|
|
35
|
+
* row in one transaction, so once hook-a's row for an event exists, the
|
|
36
|
+
* absence of another endpoint's row for it is a proof, not a wait.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
const PASSWORD = 'a-long-webhooks-password-1';
|
|
40
|
+
const ORIGIN = 'http://localhost:3000';
|
|
41
|
+
const OWNER = { email: 'owner@example.com', name: 'Olive Owner', password: PASSWORD };
|
|
42
|
+
const WEBHOOKS_SCOPE = host.identity.scopes.webhooks!;
|
|
43
|
+
const METRICS_TOKEN = 'webhooks-metrics-token';
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The dials, shrunk. `disableAfterFailures` is 5 rather than 3: the
|
|
47
|
+
* retry-then-hold test makes three failed attempts on one endpoint before its
|
|
48
|
+
* hold re-drives, and a streak of 3 with `disableAfterMs: 0` would switch that
|
|
49
|
+
* endpoint off at its third attempt (the re-drive then reads
|
|
50
|
+
* `endpoint_disabled`, never `delivered`). Five keeps both tests on one app.
|
|
51
|
+
*/
|
|
52
|
+
const DIALS = {
|
|
53
|
+
retryLimit: 2,
|
|
54
|
+
retryDelaySeconds: 1,
|
|
55
|
+
heldDelaySeconds: 2,
|
|
56
|
+
listenerTtlMs: 0,
|
|
57
|
+
disableAfterFailures: 5,
|
|
58
|
+
disableAfterMs: 0,
|
|
59
|
+
previousSecretOverlapMs: 60_000,
|
|
60
|
+
timeoutMs: 2000
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
type Actor = { email: string; cookie: string; userId: string };
|
|
64
|
+
type Who = { cookie?: string; key?: string };
|
|
65
|
+
type Received = { method: string; url: string; headers: http.IncomingHttpHeaders; body: string };
|
|
66
|
+
type Created = { id: string; secret: string; [k: string]: unknown };
|
|
67
|
+
|
|
68
|
+
let container: StartedPostgreSqlContainer;
|
|
69
|
+
let app: TestApp;
|
|
70
|
+
let closed: TestApp | undefined;
|
|
71
|
+
let workspaceId = '';
|
|
72
|
+
let otherWorkspaceId = '';
|
|
73
|
+
const actors: Record<string, Actor> = {};
|
|
74
|
+
const mail = new RecordingEmailDriver();
|
|
75
|
+
|
|
76
|
+
// ── The receiver ─────────────────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
let server: http.Server;
|
|
79
|
+
let base = '';
|
|
80
|
+
const received: Received[] = [];
|
|
81
|
+
/** What the receiver answers per path; 200 for any path not listed. */
|
|
82
|
+
const statusFor = new Map<string, number>();
|
|
83
|
+
/** Paths the receiver never answers (the response held open until the sender's timeout). */
|
|
84
|
+
const silentPaths = new Set<string>();
|
|
85
|
+
/** The responses held open now, and the most ever held open at once, per silent path. */
|
|
86
|
+
const openNow = new Map<string, number>();
|
|
87
|
+
const openMax = new Map<string, number>();
|
|
88
|
+
const heldOpen: http.ServerResponse[] = [];
|
|
89
|
+
/**
|
|
90
|
+
* Paths whose answer is a 200 held back this many milliseconds (the sender's
|
|
91
|
+
* timeout cuts it first when the hold is longer), counted in `openNow` /
|
|
92
|
+
* `openMax` like the silent ones; `answerAtOnce` releases them.
|
|
93
|
+
*/
|
|
94
|
+
const slowPaths = new Map<string, number>();
|
|
95
|
+
const slowHeld = new Map<string, http.ServerResponse[]>();
|
|
96
|
+
|
|
97
|
+
/** The slow path answers at once from now on, and the responses it holds are answered now. */
|
|
98
|
+
function answerAtOnce(path: string): void {
|
|
99
|
+
slowPaths.delete(path);
|
|
100
|
+
for (const res of slowHeld.get(path) ?? []) {
|
|
101
|
+
if (!res.writableEnded && !res.destroyed) res.end('ok');
|
|
102
|
+
}
|
|
103
|
+
slowHeld.delete(path);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const requestsTo = (path: string) => received.filter((r) => r.url === path);
|
|
107
|
+
const requestsWithId = (id: string) => received.filter((r) => r.headers['webhook-id'] === id);
|
|
108
|
+
/** One event's requests at one endpoint's path (hook-a, the witness, takes every thing event too). */
|
|
109
|
+
const hitsAt = (path: string, id: string) => requestsWithId(id).filter((r) => r.url === path);
|
|
110
|
+
|
|
111
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
let ipCounter = 0;
|
|
114
|
+
const nextIp = () => `10.81.${Math.floor(ipCounter / 250)}.${(ipCounter++ % 250) + 1}`;
|
|
115
|
+
|
|
116
|
+
const sendTo = (
|
|
117
|
+
target: TestApp,
|
|
118
|
+
method: string,
|
|
119
|
+
path: string,
|
|
120
|
+
who: Who,
|
|
121
|
+
body?: unknown,
|
|
122
|
+
headers: Record<string, string> = {}
|
|
123
|
+
) =>
|
|
124
|
+
target.app.request(`/api/v1${path}`, {
|
|
125
|
+
method,
|
|
126
|
+
headers: {
|
|
127
|
+
'x-forwarded-for': nextIp(),
|
|
128
|
+
origin: ORIGIN,
|
|
129
|
+
...(who.cookie ? { cookie: who.cookie } : {}),
|
|
130
|
+
...(who.key ? { authorization: `Bearer ${who.key}` } : {}),
|
|
131
|
+
...(body !== undefined ? { 'content-type': 'application/json' } : {}),
|
|
132
|
+
...headers
|
|
133
|
+
},
|
|
134
|
+
...(body !== undefined ? { body: JSON.stringify(body) } : {})
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
const send = (method: string, path: string, who: Who, body?: unknown, headers: Record<string, string> = {}) =>
|
|
138
|
+
sendTo(app, method, path, who, body, headers);
|
|
139
|
+
|
|
140
|
+
async function expectError(res: Response, status: number, code: string): Promise<void> {
|
|
141
|
+
const body = await readJson(res);
|
|
142
|
+
expect({ status: res.status, code: body?.error?.code }).toEqual({ status, code });
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function signIn(target: TestApp, email: string): Promise<string> {
|
|
146
|
+
const res = await sendTo(target, 'POST', '/auth/sign-in/email', {}, { email, password: PASSWORD });
|
|
147
|
+
expect(res.status).toBe(200);
|
|
148
|
+
return extractCookie(res);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function addActor(
|
|
152
|
+
name: string,
|
|
153
|
+
email: string,
|
|
154
|
+
opts: { role?: 'owner' | 'admin' | 'member'; origin?: 'local' | 'guest' } = {}
|
|
155
|
+
): Promise<Actor> {
|
|
156
|
+
const created = await app.auth.api.signUpEmail({ body: { email, password: PASSWORD, name } });
|
|
157
|
+
await app.db.db.insert(workspaceMembers).values({
|
|
158
|
+
workspaceId,
|
|
159
|
+
userId: created.user.id,
|
|
160
|
+
role: opts.role ?? 'member',
|
|
161
|
+
origin: opts.origin ?? 'local'
|
|
162
|
+
});
|
|
163
|
+
const actor = { email, cookie: await signIn(app, email), userId: created.user.id };
|
|
164
|
+
actors[name] = actor;
|
|
165
|
+
return actor;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function mintKey(cookie: string, scopes: string[]): Promise<string> {
|
|
169
|
+
const res = await send('POST', '/api-keys', { cookie }, { name: `key-${scopes.join('-')}`, scopes });
|
|
170
|
+
expect(res.status).toBe(201);
|
|
171
|
+
return (await readJson(res)).key;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function createEndpoint(
|
|
175
|
+
who: Who,
|
|
176
|
+
body: Record<string, unknown>,
|
|
177
|
+
headers: Record<string, string> = {},
|
|
178
|
+
target: TestApp = app
|
|
179
|
+
): Promise<Created> {
|
|
180
|
+
const res = await sendTo(target, 'POST', '/webhooks', who, body, headers);
|
|
181
|
+
const json = await readJson(res);
|
|
182
|
+
expect({ status: res.status, body: json }).toMatchObject({ status: 201 });
|
|
183
|
+
return json as Created;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const DEADLINE_MS = 15_000;
|
|
187
|
+
|
|
188
|
+
/** Poll until the probe answers something truthy; never a bare sleep. */
|
|
189
|
+
async function waitFor<T>(
|
|
190
|
+
label: string,
|
|
191
|
+
probe: () => Promise<T | undefined | null | false> | T | undefined | null | false,
|
|
192
|
+
timeoutMs = DEADLINE_MS
|
|
193
|
+
): Promise<T> {
|
|
194
|
+
const deadline = Date.now() + timeoutMs;
|
|
195
|
+
for (;;) {
|
|
196
|
+
const value = await probe();
|
|
197
|
+
if (value) return value as T;
|
|
198
|
+
if (Date.now() > deadline) throw new Error(`timed out after ${timeoutMs} ms waiting for ${label}`);
|
|
199
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** The one deliberate negative wait: long enough for a fan-out and a delivery poll. */
|
|
204
|
+
const settle = (ms = 3000) => new Promise((r) => setTimeout(r, ms));
|
|
205
|
+
|
|
206
|
+
const deliveriesOf = (endpointId: string, target: TestApp = app) =>
|
|
207
|
+
target.db.db.select().from(webhookDeliveries).where(eq(webhookDeliveries.endpointId, endpointId));
|
|
208
|
+
|
|
209
|
+
async function deliveryFor(endpointId: string, eventId: string, target: TestApp = app) {
|
|
210
|
+
const [row] = await target.db.db
|
|
211
|
+
.select()
|
|
212
|
+
.from(webhookDeliveries)
|
|
213
|
+
.where(and(eq(webhookDeliveries.endpointId, endpointId), eq(webhookDeliveries.eventId, eventId)));
|
|
214
|
+
return row;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function endpointRow(id: string) {
|
|
218
|
+
const [row] = await app.db.db.select().from(webhookEndpoints).where(eq(webhookEndpoints.id, id));
|
|
219
|
+
return row;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async function eventJobs(eventId?: string): Promise<number> {
|
|
223
|
+
const { rows } = await app.db.pool.query<{ n: number }>(
|
|
224
|
+
`SELECT count(*)::int AS n FROM pgboss.job WHERE name = 'webhook-events'${eventId ? ` AND data->>'id' = $1` : ''}`,
|
|
225
|
+
eventId ? [eventId] : []
|
|
226
|
+
);
|
|
227
|
+
return rows[0]!.n;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function auditRows(action: string): Promise<Array<Record<string, unknown>>> {
|
|
231
|
+
const { rows } = await app.db.pool.query(`SELECT * FROM audit_log WHERE action = $1 ORDER BY id DESC`, [
|
|
232
|
+
action
|
|
233
|
+
]);
|
|
234
|
+
return rows;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const emitCreated = (
|
|
238
|
+
thing: string,
|
|
239
|
+
project: string | string[] = 'p0',
|
|
240
|
+
target: TestApp = app,
|
|
241
|
+
ws = workspaceId
|
|
242
|
+
) =>
|
|
243
|
+
target.webhooks.emit(target.db.db, {
|
|
244
|
+
workspaceId: ws,
|
|
245
|
+
type: 'thing.created',
|
|
246
|
+
subjects: { thing, project },
|
|
247
|
+
data: { thingId: thing, name: `Thing ${thing}` }
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
const emitDeleted = (thing: string, ws = workspaceId) =>
|
|
251
|
+
app.webhooks.emit(app.db.db, {
|
|
252
|
+
workspaceId: ws,
|
|
253
|
+
type: 'thing.deleted',
|
|
254
|
+
subjects: { thing },
|
|
255
|
+
data: { thingId: thing }
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
/** Emit, then wait for hook-a (the witness) to hold its row for the event. */
|
|
259
|
+
async function witnessed(emitted: Promise<string | null>): Promise<string> {
|
|
260
|
+
const eventId = await emitted;
|
|
261
|
+
expect(eventId).toEqual(expect.any(String));
|
|
262
|
+
await waitFor(`hook-a's row for ${eventId}`, () => deliveryFor(hookA.id, eventId!));
|
|
263
|
+
return eventId!;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
let hookA: Created;
|
|
267
|
+
|
|
268
|
+
// ── Boot ─────────────────────────────────────────────────────────────────────
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* The whole file runs under the chassis's own host only (`host.webhooks`): it
|
|
272
|
+
* emits the minimal tool's `thing.*` events. A real tool's host skips it and
|
|
273
|
+
* proves its own events in its own suite.
|
|
274
|
+
*/
|
|
275
|
+
describe.skipIf(!host.webhooks)('outbound webhooks (PRDCT-3482)', () => {
|
|
276
|
+
beforeAll(async () => {
|
|
277
|
+
server = http.createServer((req, res) => {
|
|
278
|
+
let body = '';
|
|
279
|
+
req.setEncoding('utf8');
|
|
280
|
+
req.on('data', (chunk: string) => (body += chunk));
|
|
281
|
+
req.on('end', () => {
|
|
282
|
+
const url = req.url ?? '';
|
|
283
|
+
received.push({ method: req.method ?? '', url, headers: req.headers, body });
|
|
284
|
+
if (silentPaths.has(url)) {
|
|
285
|
+
heldOpen.push(res);
|
|
286
|
+
const now = (openNow.get(url) ?? 0) + 1;
|
|
287
|
+
openNow.set(url, now);
|
|
288
|
+
openMax.set(url, Math.max(openMax.get(url) ?? 0, now));
|
|
289
|
+
res.on('close', () => openNow.set(url, (openNow.get(url) ?? 1) - 1));
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
const holdMs = slowPaths.get(url);
|
|
293
|
+
if (holdMs !== undefined) {
|
|
294
|
+
const now = (openNow.get(url) ?? 0) + 1;
|
|
295
|
+
openNow.set(url, now);
|
|
296
|
+
openMax.set(url, Math.max(openMax.get(url) ?? 0, now));
|
|
297
|
+
res.on('close', () => openNow.set(url, (openNow.get(url) ?? 1) - 1));
|
|
298
|
+
slowHeld.set(url, [...(slowHeld.get(url) ?? []), res]);
|
|
299
|
+
const timer = setTimeout(() => {
|
|
300
|
+
if (!res.writableEnded && !res.destroyed) res.end('ok');
|
|
301
|
+
}, holdMs);
|
|
302
|
+
res.on('close', () => clearTimeout(timer));
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
res.statusCode = statusFor.get(url) ?? 200;
|
|
306
|
+
res.end('ok');
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
310
|
+
base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
|
311
|
+
|
|
312
|
+
container = await startPostgres();
|
|
313
|
+
app = await createTestApp(
|
|
314
|
+
await createDatabase(container, 'webhooks'),
|
|
315
|
+
{ WEBHOOKS_ALLOW_PRIVATE_NETWORKS: 'true', DEMO_SIGN_IN: 'true', METRICS_TOKEN },
|
|
316
|
+
{ webhookDials: DIALS, email: mail }
|
|
317
|
+
);
|
|
318
|
+
const setup = await send(
|
|
319
|
+
'POST',
|
|
320
|
+
'/setup',
|
|
321
|
+
{},
|
|
322
|
+
{ setupToken: 'integration-test-setup-token', instanceName: 'Webhooks', owner: OWNER }
|
|
323
|
+
);
|
|
324
|
+
expect(setup.status).toBe(201);
|
|
325
|
+
workspaceId = (await readJson(setup)).workspaceId;
|
|
326
|
+
const ownerCookie = await signIn(app, OWNER.email);
|
|
327
|
+
const me = await readJson(await send('GET', '/me', { cookie: ownerCookie }));
|
|
328
|
+
actors.owner = { email: OWNER.email, cookie: ownerCookie, userId: me.user.id };
|
|
329
|
+
otherWorkspaceId = (await app.registry.workspaces.create('Elsewhere', me.user.id)).workspaceId;
|
|
330
|
+
|
|
331
|
+
await addActor('admin', 'admin@example.com', { role: 'admin' });
|
|
332
|
+
await addActor('member', 'member@example.com');
|
|
333
|
+
await addActor('guest', 'guest@example.org', { origin: 'guest' });
|
|
334
|
+
}, 180_000);
|
|
335
|
+
|
|
336
|
+
afterAll(async () => {
|
|
337
|
+
await closed?.stop();
|
|
338
|
+
await app?.stop();
|
|
339
|
+
await container?.stop();
|
|
340
|
+
for (const res of heldOpen) res.destroy();
|
|
341
|
+
for (const held of slowHeld.values()) for (const res of held) res.destroy();
|
|
342
|
+
server?.closeAllConnections();
|
|
343
|
+
await new Promise<void>((resolve) => (server ? server.close(() => resolve()) : resolve()));
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
// ── The catalog ──────────────────────────────────────────────────────────────
|
|
347
|
+
|
|
348
|
+
describe('the catalog', () => {
|
|
349
|
+
it('1. GET /webhooks/events answers the subjects and the events to any member; a guest 403, anonymous 401', async () => {
|
|
350
|
+
const res = await send('GET', '/webhooks/events', actors.member!);
|
|
351
|
+
expect(res.status).toBe(200);
|
|
352
|
+
const catalog = await readJson(res);
|
|
353
|
+
expect(catalog.subjects).toEqual(
|
|
354
|
+
expect.arrayContaining([
|
|
355
|
+
expect.objectContaining({ key: 'thing', field: 'thingId', list: false }),
|
|
356
|
+
// The minimal tool re-declares the chassis's project subject as a list (MN2).
|
|
357
|
+
expect.objectContaining({ key: 'project', field: 'projectIds', list: true })
|
|
358
|
+
])
|
|
359
|
+
);
|
|
360
|
+
const byType = new Map<
|
|
361
|
+
string,
|
|
362
|
+
{ subjects: string[]; schema: { properties?: Record<string, unknown> } }
|
|
363
|
+
>(catalog.events.map((e: { type: string }) => [e.type, e]));
|
|
364
|
+
const created = byType.get('thing.created');
|
|
365
|
+
expect(created?.subjects).toEqual(['thing', 'project']);
|
|
366
|
+
expect(created?.schema.properties?.thingId).toBeDefined();
|
|
367
|
+
for (const type of ['thing.deleted', 'webhook.ping', 'project.created']) {
|
|
368
|
+
expect(byType.has(type), type).toBe(true);
|
|
369
|
+
}
|
|
370
|
+
await expectError(await send('GET', '/webhooks/events', actors.guest!), 403, 'guest_forbidden');
|
|
371
|
+
await expectError(await send('GET', '/webhooks/events', {}), 401, 'unauthenticated');
|
|
372
|
+
});
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
// ── Endpoints ────────────────────────────────────────────────────────────────
|
|
376
|
+
|
|
377
|
+
describe('creating an endpoint', () => {
|
|
378
|
+
it('2. an admin creates one: the secret once, sealed at rest, absent from reads and from the audit row; members 403, another workspace 404', async () => {
|
|
379
|
+
hookA = await createEndpoint(actors.admin!, {
|
|
380
|
+
url: `${base}/hook-a`,
|
|
381
|
+
events: ['thing.*'],
|
|
382
|
+
description: 'A'
|
|
383
|
+
});
|
|
384
|
+
expect(hookA).toMatchObject({ enabled: true, filters: {}, events: ['thing.*'], description: 'A' });
|
|
385
|
+
expect(hookA.secret).toMatch(/^whsec_/);
|
|
386
|
+
|
|
387
|
+
const row = await endpointRow(hookA.id);
|
|
388
|
+
expect(row!.secret.startsWith('v1.')).toBe(true);
|
|
389
|
+
expect(row!.secret).not.toContain(hookA.secret);
|
|
390
|
+
expect(row!.createdBy).toBe(actors.admin!.userId);
|
|
391
|
+
|
|
392
|
+
const got = await send('GET', `/webhooks/${hookA.id}`, actors.admin!);
|
|
393
|
+
expect(got.status).toBe(200);
|
|
394
|
+
const gotBody = await readJson(got);
|
|
395
|
+
expect(gotBody.id).toBe(hookA.id);
|
|
396
|
+
expect('secret' in gotBody).toBe(false);
|
|
397
|
+
|
|
398
|
+
const list = await readJson(await send('GET', '/webhooks', actors.owner!));
|
|
399
|
+
expect(list.webhooks.map((w: { id: string }) => w.id)).toContain(hookA.id);
|
|
400
|
+
expect(JSON.stringify(list)).not.toContain(hookA.secret);
|
|
401
|
+
|
|
402
|
+
const [audit] = await waitFor('the webhook.create audit row', async () => {
|
|
403
|
+
const rows = await auditRows('webhook.create');
|
|
404
|
+
return rows.length > 0 ? rows : undefined;
|
|
405
|
+
});
|
|
406
|
+
expect(audit).toMatchObject({ resource_type: 'webhook', resource_id: hookA.id });
|
|
407
|
+
expect(JSON.stringify(audit)).not.toContain(hookA.secret);
|
|
408
|
+
expect(JSON.stringify(audit)).not.toContain(row!.secret);
|
|
409
|
+
|
|
410
|
+
await expectError(
|
|
411
|
+
await send('POST', '/webhooks', actors.member!, { url: `${base}/nope`, events: ['*'] }),
|
|
412
|
+
403,
|
|
413
|
+
'forbidden'
|
|
414
|
+
);
|
|
415
|
+
await expectError(await send('GET', '/webhooks', actors.member!), 403, 'forbidden');
|
|
416
|
+
await expectError(
|
|
417
|
+
await send('GET', `/webhooks/${hookA.id}`, actors.owner!, undefined, {
|
|
418
|
+
'x-workspace-id': otherWorkspaceId
|
|
419
|
+
}),
|
|
420
|
+
404,
|
|
421
|
+
'not_found'
|
|
422
|
+
);
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
it('18. the subscription is checked against the catalog, on create and on update: an unknown type or family 400 unknown_event, an unknown filter key 400 unknown_subject (MN1)', async () => {
|
|
426
|
+
const url = `${base}/hook-mn1`;
|
|
427
|
+
const never = { thing: ['t-mn1-never'] };
|
|
428
|
+
const post = (body: Record<string, unknown>) =>
|
|
429
|
+
send('POST', '/webhooks', actors.admin!, { url, ...body });
|
|
430
|
+
await expectError(await post({ events: ['thing.nonexistent'] }), 400, 'unknown_event');
|
|
431
|
+
await expectError(await post({ events: ['nothing.*'] }), 400, 'unknown_event');
|
|
432
|
+
await expectError(
|
|
433
|
+
await post({ events: ['thing.created'], filters: { bogus: ['x'] } }),
|
|
434
|
+
400,
|
|
435
|
+
'unknown_subject'
|
|
436
|
+
);
|
|
437
|
+
const stored = async () =>
|
|
438
|
+
(await app.db.db.select().from(webhookEndpoints).where(eq(webhookEndpoints.url, url))).length;
|
|
439
|
+
expect(await stored()).toBe(0);
|
|
440
|
+
|
|
441
|
+
const accepted: Created[] = [];
|
|
442
|
+
for (const events of [['*'], ['thing.*'], ['webhook.ping']]) {
|
|
443
|
+
accepted.push(await createEndpoint(actors.admin!, { url, events, filters: never }));
|
|
444
|
+
}
|
|
445
|
+
expect(await stored()).toBe(3);
|
|
446
|
+
|
|
447
|
+
const id = accepted[0]!.id;
|
|
448
|
+
const patch = (body: Record<string, unknown>) => send('PATCH', `/webhooks/${id}`, actors.admin!, body);
|
|
449
|
+
await expectError(await patch({ events: ['thing.nonexistent'] }), 400, 'unknown_event');
|
|
450
|
+
await expectError(await patch({ events: ['nothing.*'] }), 400, 'unknown_event');
|
|
451
|
+
await expectError(await patch({ filters: { bogus: ['x'] } }), 400, 'unknown_subject');
|
|
452
|
+
expect(await endpointRow(id)).toMatchObject({ events: ['*'], filters: never });
|
|
453
|
+
for (const events of [['thing.*'], ['webhook.ping'], ['*']]) {
|
|
454
|
+
const ok = await patch({ events });
|
|
455
|
+
expect(ok.status).toBe(200);
|
|
456
|
+
expect(await readJson(ok)).toMatchObject({ events });
|
|
457
|
+
}
|
|
458
|
+
for (const e of accepted)
|
|
459
|
+
expect((await send('DELETE', `/webhooks/${e.id}`, actors.admin!)).status).toBe(200);
|
|
460
|
+
});
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
// ── Emit and deliver ─────────────────────────────────────────────────────────
|
|
464
|
+
|
|
465
|
+
let firstEventId = '';
|
|
466
|
+
let firstDeliveryId = '';
|
|
467
|
+
|
|
468
|
+
describe('emit, fan-out, delivery', () => {
|
|
469
|
+
it('3. an emit is fanned out and delivered signed (Standard Webhooks), the row delivered, the API lists it', async () => {
|
|
470
|
+
const eventId = await app.webhooks.emit(app.db.db, {
|
|
471
|
+
workspaceId,
|
|
472
|
+
type: 'thing.created',
|
|
473
|
+
subjects: { thing: 't1', project: ['p1', 'p2'] },
|
|
474
|
+
data: { thingId: 't1', name: 'One' }
|
|
475
|
+
});
|
|
476
|
+
expect(eventId).toEqual(expect.any(String));
|
|
477
|
+
firstEventId = eventId!;
|
|
478
|
+
|
|
479
|
+
const [req] = await waitFor('the receiver to see t1', () => {
|
|
480
|
+
const hits = requestsWithId(firstEventId);
|
|
481
|
+
return hits.length > 0 ? hits : undefined;
|
|
482
|
+
});
|
|
483
|
+
expect(req!.method).toBe('POST');
|
|
484
|
+
expect(req!.url).toBe('/hook-a');
|
|
485
|
+
expect(req!.headers['webhook-id']).toBe(firstEventId);
|
|
486
|
+
expect(req!.headers['webhook-timestamp']).toMatch(/^\d+$/);
|
|
487
|
+
expect(req!.headers['webhook-signature']).toMatch(/^v1,/);
|
|
488
|
+
expect(req!.headers['content-type']).toBe('application/json');
|
|
489
|
+
expect(req!.headers['user-agent']).toMatch(new RegExp(`^${host.identity.slug}-webhooks/`));
|
|
490
|
+
|
|
491
|
+
const body = JSON.parse(req!.body);
|
|
492
|
+
expect(body).toEqual({
|
|
493
|
+
id: firstEventId,
|
|
494
|
+
type: 'thing.created',
|
|
495
|
+
occurredAt: expect.any(String),
|
|
496
|
+
workspaceId,
|
|
497
|
+
subjects: { thingId: 't1', projectIds: ['p1', 'p2'] },
|
|
498
|
+
data: { thingId: 't1', name: 'One' },
|
|
499
|
+
attempt: 1
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
const headers = {
|
|
503
|
+
id: req!.headers['webhook-id'] as string,
|
|
504
|
+
timestamp: req!.headers['webhook-timestamp'] as string,
|
|
505
|
+
signature: req!.headers['webhook-signature'] as string
|
|
506
|
+
};
|
|
507
|
+
expect(verifyWebhookSignature(hookA.secret, headers, req!.body)).toBe(true);
|
|
508
|
+
expect(verifyWebhookSignature(hookA.secret, headers, req!.body.replace('One', 'Two'))).toBe(false);
|
|
509
|
+
|
|
510
|
+
const row = await waitFor('the row delivered', async () => {
|
|
511
|
+
const r = await deliveryFor(hookA.id, firstEventId);
|
|
512
|
+
return r?.status === 'delivered' ? r : undefined;
|
|
513
|
+
});
|
|
514
|
+
expect(row).toMatchObject({ attempt: 1, lastStatusCode: 200, lastError: null });
|
|
515
|
+
firstDeliveryId = row.id;
|
|
516
|
+
|
|
517
|
+
const list = await readJson(await send('GET', `/webhooks/${hookA.id}/deliveries`, actors.admin!));
|
|
518
|
+
const listed = list.deliveries.find((d: { id: string }) => d.id === firstDeliveryId);
|
|
519
|
+
expect(listed).toMatchObject({
|
|
520
|
+
eventId: firstEventId,
|
|
521
|
+
eventType: 'thing.created',
|
|
522
|
+
status: 'delivered',
|
|
523
|
+
payload: { id: firstEventId, subjects: { thingId: 't1', projectIds: ['p1', 'p2'] } }
|
|
524
|
+
});
|
|
525
|
+
const one = await send('GET', `/webhooks/${hookA.id}/deliveries/${firstDeliveryId}`, actors.admin!);
|
|
526
|
+
expect(one.status).toBe(200);
|
|
527
|
+
expect(await readJson(one)).toMatchObject({ id: firstDeliveryId, status: 'delivered', attempt: 1 });
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
it('4. nobody listening costs no job: emit answers null, no row, no fan-out job', async () => {
|
|
531
|
+
// The other workspace holds ONE endpoint, on `thing.created` only.
|
|
532
|
+
const there = { 'x-workspace-id': otherWorkspaceId };
|
|
533
|
+
const only = await createEndpoint(
|
|
534
|
+
actors.owner!,
|
|
535
|
+
{ url: `${base}/hook-other`, events: ['thing.created'] },
|
|
536
|
+
there
|
|
537
|
+
);
|
|
538
|
+
expect(await app.webhooks.wants(otherWorkspaceId, 'thing.deleted')).toBe(false);
|
|
539
|
+
expect(await app.webhooks.wants(otherWorkspaceId, 'thing.created')).toBe(true);
|
|
540
|
+
const jobsBefore = await eventJobs();
|
|
541
|
+
expect(await emitDeleted('t-nobody', otherWorkspaceId)).toBeNull();
|
|
542
|
+
expect(await eventJobs()).toBe(jobsBefore);
|
|
543
|
+
const rows = await app.db.db
|
|
544
|
+
.select()
|
|
545
|
+
.from(webhookDeliveries)
|
|
546
|
+
.where(
|
|
547
|
+
and(
|
|
548
|
+
eq(webhookDeliveries.workspaceId, otherWorkspaceId),
|
|
549
|
+
eq(webhookDeliveries.eventType, 'thing.deleted')
|
|
550
|
+
)
|
|
551
|
+
);
|
|
552
|
+
expect(rows).toEqual([]);
|
|
553
|
+
expect(await deliveriesOf(only.id)).toEqual([]);
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
it('5. a rolled-back transaction emits nothing; the same emit committed is delivered', async () => {
|
|
557
|
+
const rollBack = new Error('roll back');
|
|
558
|
+
let rolledId: string | null = null;
|
|
559
|
+
await expect(
|
|
560
|
+
app.db.db.transaction(async (tx) => {
|
|
561
|
+
rolledId = await app.webhooks.emit(tx, {
|
|
562
|
+
workspaceId,
|
|
563
|
+
type: 'thing.created',
|
|
564
|
+
subjects: { thing: 't-rolled', project: 'p0' },
|
|
565
|
+
data: { thingId: 't-rolled', name: 'Rolled' }
|
|
566
|
+
});
|
|
567
|
+
throw rollBack;
|
|
568
|
+
})
|
|
569
|
+
).rejects.toBe(rollBack);
|
|
570
|
+
expect(rolledId).toEqual(expect.any(String));
|
|
571
|
+
expect(await eventJobs(rolledId!)).toBe(0);
|
|
572
|
+
await settle();
|
|
573
|
+
expect(requestsWithId(rolledId!)).toEqual([]);
|
|
574
|
+
const rows = await app.db.db
|
|
575
|
+
.select()
|
|
576
|
+
.from(webhookDeliveries)
|
|
577
|
+
.where(eq(webhookDeliveries.eventId, rolledId!));
|
|
578
|
+
expect(rows).toEqual([]);
|
|
579
|
+
|
|
580
|
+
let committedId: string | null = null;
|
|
581
|
+
await app.db.db.transaction(async (tx) => {
|
|
582
|
+
committedId = await app.webhooks.emit(tx, {
|
|
583
|
+
workspaceId,
|
|
584
|
+
type: 'thing.created',
|
|
585
|
+
subjects: { thing: 't-committed', project: 'p0' },
|
|
586
|
+
data: { thingId: 't-committed', name: 'Committed' }
|
|
587
|
+
});
|
|
588
|
+
});
|
|
589
|
+
expect(committedId).toEqual(expect.any(String));
|
|
590
|
+
await waitFor('the committed emit delivered', () => requestsWithId(committedId!).length > 0);
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
it('6. filters: AND across keys, OR within one; an event without a filtered subject does not match', async () => {
|
|
594
|
+
const hookB = await createEndpoint(actors.admin!, {
|
|
595
|
+
url: `${base}/hook-b`,
|
|
596
|
+
events: ['thing.created'],
|
|
597
|
+
filters: { thing: ['t-yes'] }
|
|
598
|
+
});
|
|
599
|
+
const no = await witnessed(emitCreated('t-no'));
|
|
600
|
+
expect(await deliveryFor(hookB.id, no)).toBeUndefined();
|
|
601
|
+
const yes = await witnessed(emitCreated('t-yes'));
|
|
602
|
+
await waitFor('hook-b to receive t-yes', () => hitsAt('/hook-b', yes).length > 0);
|
|
603
|
+
|
|
604
|
+
const hookP = await createEndpoint(actors.admin!, {
|
|
605
|
+
url: `${base}/hook-p`,
|
|
606
|
+
events: ['thing.*'],
|
|
607
|
+
filters: { project: ['p9'] }
|
|
608
|
+
});
|
|
609
|
+
const p1 = await witnessed(emitCreated('t-p1', ['p1']));
|
|
610
|
+
expect(await deliveryFor(hookP.id, p1)).toBeUndefined();
|
|
611
|
+
const p19 = await witnessed(emitCreated('t-p19', ['p1', 'p9']));
|
|
612
|
+
await waitFor('hook-p to receive p1+p9', () => hitsAt('/hook-p', p19).length > 0);
|
|
613
|
+
// `thing.deleted` carries no project: a filter on project excludes it.
|
|
614
|
+
const deleted = await witnessed(emitDeleted('t-p19'));
|
|
615
|
+
expect(await deliveryFor(hookP.id, deleted)).toBeUndefined();
|
|
616
|
+
expect(requestsTo('/hook-p')).toHaveLength(1);
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
it('19. filters on two keys are ANDed: the thing AND the project must both match (G1)', async () => {
|
|
620
|
+
const hookAnd = await createEndpoint(actors.admin!, {
|
|
621
|
+
url: `${base}/hook-and`,
|
|
622
|
+
events: ['thing.created'],
|
|
623
|
+
filters: { thing: ['t-and-a'], project: ['p-and-a'] }
|
|
624
|
+
});
|
|
625
|
+
const both = await witnessed(emitCreated('t-and-a', ['p-and-a']));
|
|
626
|
+
await waitFor('hook-and to receive t-and-a in p-and-a', () => hitsAt('/hook-and', both).length > 0);
|
|
627
|
+
const otherProject = await witnessed(emitCreated('t-and-a', ['p-and-b']));
|
|
628
|
+
expect(await deliveryFor(hookAnd.id, otherProject)).toBeUndefined();
|
|
629
|
+
const otherThing = await witnessed(emitCreated('t-and-b', ['p-and-a']));
|
|
630
|
+
expect(await deliveryFor(hookAnd.id, otherThing)).toBeUndefined();
|
|
631
|
+
expect(requestsTo('/hook-and')).toHaveLength(1);
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
it('20. an emit whose data breaks the declared schema answers null, writes no job and no row, and logs the error (G4)', async () => {
|
|
635
|
+
const errors = vi.spyOn(app.logger, 'error');
|
|
636
|
+
try {
|
|
637
|
+
const jobsBefore = await eventJobs();
|
|
638
|
+
const rowsBefore = (await deliveriesOf(hookA.id)).length;
|
|
639
|
+
const id = await app.webhooks.emit(app.db.db, {
|
|
640
|
+
workspaceId,
|
|
641
|
+
type: 'thing.created',
|
|
642
|
+
subjects: { thing: 't-bad-data', project: 'p0' },
|
|
643
|
+
data: { thingId: 1 }
|
|
644
|
+
});
|
|
645
|
+
expect(id).toBeNull();
|
|
646
|
+
expect(await eventJobs()).toBe(jobsBefore);
|
|
647
|
+
expect(errors).toHaveBeenCalledWith(
|
|
648
|
+
expect.objectContaining({ type: 'thing.created' }),
|
|
649
|
+
expect.stringMatching(/outside its declared schema/)
|
|
650
|
+
);
|
|
651
|
+
await settle(1500);
|
|
652
|
+
expect(await deliveriesOf(hookA.id)).toHaveLength(rowsBefore);
|
|
653
|
+
expect(received.filter((r) => r.body.includes('t-bad-data'))).toEqual([]);
|
|
654
|
+
} finally {
|
|
655
|
+
errors.mockRestore();
|
|
656
|
+
}
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
it('21. a list subject is ALWAYS a list in the envelope, one id included; a list given to a one-id subject is refused (MN2)', async () => {
|
|
660
|
+
const single = await witnessed(emitCreated('t-list', 'p-one'));
|
|
661
|
+
const [one] = await waitFor('hook-a to receive the one-project emit', () => {
|
|
662
|
+
const hits = hitsAt('/hook-a', single);
|
|
663
|
+
return hits.length > 0 ? hits : undefined;
|
|
664
|
+
});
|
|
665
|
+
expect(JSON.parse(one!.body).subjects).toEqual({ thingId: 't-list', projectIds: ['p-one'] });
|
|
666
|
+
const several = await witnessed(emitCreated('t-list', ['p1', 'p2']));
|
|
667
|
+
const [two] = await waitFor('hook-a to receive the two-project emit', () => {
|
|
668
|
+
const hits = hitsAt('/hook-a', several);
|
|
669
|
+
return hits.length > 0 ? hits : undefined;
|
|
670
|
+
});
|
|
671
|
+
expect(JSON.parse(two!.body).subjects).toEqual({ thingId: 't-list', projectIds: ['p1', 'p2'] });
|
|
672
|
+
|
|
673
|
+
const errors = vi.spyOn(app.logger, 'error');
|
|
674
|
+
try {
|
|
675
|
+
const jobsBefore = await eventJobs();
|
|
676
|
+
expect(
|
|
677
|
+
await app.webhooks.emit(app.db.db, {
|
|
678
|
+
workspaceId,
|
|
679
|
+
type: 'thing.deleted',
|
|
680
|
+
subjects: { thing: ['t-x', 't-y'] },
|
|
681
|
+
data: { thingId: 't-x' }
|
|
682
|
+
})
|
|
683
|
+
).toBeNull();
|
|
684
|
+
expect(await eventJobs()).toBe(jobsBefore);
|
|
685
|
+
expect(errors).toHaveBeenCalledWith(
|
|
686
|
+
expect.objectContaining({ type: 'thing.deleted', subject: 'thing' }),
|
|
687
|
+
expect.stringMatching(/a list for a subject declared as one id/)
|
|
688
|
+
);
|
|
689
|
+
} finally {
|
|
690
|
+
errors.mockRestore();
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
const catalog = await readJson(await send('GET', '/webhooks/events', actors.member!));
|
|
694
|
+
const subjects = new Map<string, { list: boolean }>(
|
|
695
|
+
catalog.subjects.map((s: { key: string; list: boolean }) => [s.key, s])
|
|
696
|
+
);
|
|
697
|
+
expect(subjects.get('project')).toMatchObject({ field: 'projectIds', list: true });
|
|
698
|
+
expect(subjects.get('thing')).toMatchObject({ field: 'thingId', list: false });
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
it('22. the listener cache honours enabled: the only endpoint of a workspace switched off, nobody listens; back on, it listens (G6)', async () => {
|
|
702
|
+
const solo = (await app.registry.workspaces.create('Solo', actors.owner!.userId)).workspaceId;
|
|
703
|
+
const there = { 'x-workspace-id': solo };
|
|
704
|
+
const only = await createEndpoint(
|
|
705
|
+
actors.owner!,
|
|
706
|
+
{ url: `${base}/hook-solo`, events: ['thing.created'] },
|
|
707
|
+
there
|
|
708
|
+
);
|
|
709
|
+
expect(await app.webhooks.wants(solo, 'thing.created')).toBe(true);
|
|
710
|
+
|
|
711
|
+
const off = await send('PATCH', `/webhooks/${only.id}`, actors.owner!, { enabled: false }, there);
|
|
712
|
+
expect(off.status).toBe(200);
|
|
713
|
+
expect(await app.webhooks.wants(solo, 'thing.created')).toBe(false);
|
|
714
|
+
const jobsBefore = await eventJobs();
|
|
715
|
+
expect(await emitCreated('t-solo', 'p0', app, solo)).toBeNull();
|
|
716
|
+
expect(await eventJobs()).toBe(jobsBefore);
|
|
717
|
+
|
|
718
|
+
const on = await send('PATCH', `/webhooks/${only.id}`, actors.owner!, { enabled: true }, there);
|
|
719
|
+
expect(on.status).toBe(200);
|
|
720
|
+
expect(await app.webhooks.wants(solo, 'thing.created')).toBe(true);
|
|
721
|
+
});
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
// ── The chassis's own events ─────────────────────────────────────────────────
|
|
725
|
+
|
|
726
|
+
describe('the chassis’s own events', () => {
|
|
727
|
+
const chassisBodies = (path: string) =>
|
|
728
|
+
requestsTo(path).map(
|
|
729
|
+
(r) => JSON.parse(r.body) as { type: string; data: Record<string, unknown>; [k: string]: unknown }
|
|
730
|
+
);
|
|
731
|
+
const bodyAt = (path: string, type: string, key: string, value: string) => () =>
|
|
732
|
+
chassisBodies(path).find((b) => b.type === type && b.data[key] === value);
|
|
733
|
+
|
|
734
|
+
it("23. project.created (in the create's transaction) and project.archived reach the receiver, the project a list of one (G5)", async () => {
|
|
735
|
+
const hookCh = await createEndpoint(actors.owner!, {
|
|
736
|
+
url: `${base}/hook-chassis`,
|
|
737
|
+
events: ['project.*']
|
|
738
|
+
});
|
|
739
|
+
try {
|
|
740
|
+
const created = await send('POST', '/projects', actors.owner!, { name: 'Hooked project' });
|
|
741
|
+
expect(created.status).toBe(201);
|
|
742
|
+
const projectId = (await readJson(created)).id as string;
|
|
743
|
+
const createdEvent = await waitFor(
|
|
744
|
+
'project.created',
|
|
745
|
+
bodyAt('/hook-chassis', 'project.created', 'projectId', projectId)
|
|
746
|
+
);
|
|
747
|
+
expect(createdEvent).toMatchObject({
|
|
748
|
+
workspaceId,
|
|
749
|
+
data: { projectId, name: 'Hooked project', createdBy: actors.owner!.userId }
|
|
750
|
+
});
|
|
751
|
+
// The minimal tool declares `project` as a list: the chassis's one id is written as a list of one.
|
|
752
|
+
expect(createdEvent.subjects).toEqual({ projectIds: [projectId] });
|
|
753
|
+
|
|
754
|
+
const archived = await send('POST', `/projects/${projectId}/archive`, actors.owner!);
|
|
755
|
+
expect(archived.status).toBe(200);
|
|
756
|
+
const archivedEvent = await waitFor(
|
|
757
|
+
'project.archived',
|
|
758
|
+
bodyAt('/hook-chassis', 'project.archived', 'projectId', projectId)
|
|
759
|
+
);
|
|
760
|
+
expect(archivedEvent).toMatchObject({ workspaceId, data: { projectId, name: 'Hooked project' } });
|
|
761
|
+
expect(archivedEvent.subjects).toEqual({ projectIds: [projectId] });
|
|
762
|
+
} finally {
|
|
763
|
+
// Off again: the later tests' projects are not this endpoint's business.
|
|
764
|
+
await send('PATCH', `/webhooks/${hookCh.id}`, actors.owner!, { enabled: false });
|
|
765
|
+
}
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
it('29. apikey.created, bridged from the in-process bus, reaches the receiver (G5)', async () => {
|
|
769
|
+
const hookKey = await createEndpoint(actors.owner!, {
|
|
770
|
+
url: `${base}/hook-chassis-key`,
|
|
771
|
+
events: ['apikey.created']
|
|
772
|
+
});
|
|
773
|
+
try {
|
|
774
|
+
const minted = await send('POST', '/api-keys', actors.owner!, {
|
|
775
|
+
name: 'hooked-key',
|
|
776
|
+
scopes: [host.scopes.read]
|
|
777
|
+
});
|
|
778
|
+
expect(minted.status).toBe(201);
|
|
779
|
+
const apiKeyId = (await readJson(minted)).apiKey.id as string;
|
|
780
|
+
const keyEvent = await waitFor(
|
|
781
|
+
'apikey.created',
|
|
782
|
+
bodyAt('/hook-chassis-key', 'apikey.created', 'apiKeyId', apiKeyId)
|
|
783
|
+
);
|
|
784
|
+
expect(keyEvent).toMatchObject({ workspaceId, subjects: {}, data: { apiKeyId } });
|
|
785
|
+
} finally {
|
|
786
|
+
await send('PATCH', `/webhooks/${hookKey.id}`, actors.owner!, { enabled: false });
|
|
787
|
+
}
|
|
788
|
+
});
|
|
789
|
+
|
|
790
|
+
/** The owner invites a new person, who accepts with a new account (the public accept route). */
|
|
791
|
+
async function joinByInvitation(email: string, role: 'member' | 'admin') {
|
|
792
|
+
const invited = await send('POST', '/invitations', actors.owner!, { email, role });
|
|
793
|
+
expect(invited.status).toBe(201);
|
|
794
|
+
const token = ((await readJson(invited)).acceptUrl as string).split('/invite/')[1]!;
|
|
795
|
+
const accepted = await send(
|
|
796
|
+
'POST',
|
|
797
|
+
'/invitations/accept',
|
|
798
|
+
{},
|
|
799
|
+
{ token, name: 'Joining Person', password: PASSWORD }
|
|
800
|
+
);
|
|
801
|
+
expect(accepted.status).toBe(200);
|
|
802
|
+
const body = (await readJson(accepted)) as { userId: string; role: string; workspaceId: string };
|
|
803
|
+
expect(body).toMatchObject({ workspaceId, role });
|
|
804
|
+
return body;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
it('30. member.joined, on an accepted invitation, reaches the receiver with the new person and the role (G-R2-1)', async () => {
|
|
808
|
+
const hookJoin = await createEndpoint(actors.owner!, {
|
|
809
|
+
url: `${base}/hook-chassis-joined`,
|
|
810
|
+
events: ['member.joined']
|
|
811
|
+
});
|
|
812
|
+
try {
|
|
813
|
+
const joined = await joinByInvitation('joiner@example.com', 'admin');
|
|
814
|
+
expect(joined.userId).toEqual(expect.any(String));
|
|
815
|
+
expect(joined.userId).not.toBe(actors.owner!.userId);
|
|
816
|
+
const event = await waitFor(
|
|
817
|
+
'member.joined',
|
|
818
|
+
bodyAt('/hook-chassis-joined', 'member.joined', 'userId', joined.userId)
|
|
819
|
+
);
|
|
820
|
+
expect(event).toMatchObject({
|
|
821
|
+
workspaceId,
|
|
822
|
+
subjects: {},
|
|
823
|
+
data: { userId: joined.userId, role: 'admin' }
|
|
824
|
+
});
|
|
825
|
+
expect(chassisBodies('/hook-chassis-joined').filter((b) => b.type === 'member.joined')).toHaveLength(
|
|
826
|
+
1
|
|
827
|
+
);
|
|
828
|
+
} finally {
|
|
829
|
+
await send('PATCH', `/webhooks/${hookJoin.id}`, actors.owner!, { enabled: false });
|
|
830
|
+
}
|
|
831
|
+
});
|
|
832
|
+
|
|
833
|
+
it('31. member.removed, on POST /members/{id}/remove, reaches the receiver with the person removed (G-R2-1)', async () => {
|
|
834
|
+
const joined = await joinByInvitation('removed-one@example.com', 'member');
|
|
835
|
+
const hookRemoved = await createEndpoint(actors.owner!, {
|
|
836
|
+
url: `${base}/hook-chassis-removed`,
|
|
837
|
+
events: ['member.removed']
|
|
838
|
+
});
|
|
839
|
+
try {
|
|
840
|
+
const [membership] = await app.db.db
|
|
841
|
+
.select({ id: workspaceMembers.id })
|
|
842
|
+
.from(workspaceMembers)
|
|
843
|
+
.where(
|
|
844
|
+
and(eq(workspaceMembers.workspaceId, workspaceId), eq(workspaceMembers.userId, joined.userId))
|
|
845
|
+
);
|
|
846
|
+
const removed = await send('POST', `/members/${membership!.id}/remove`, actors.owner!);
|
|
847
|
+
expect(removed.status).toBe(200);
|
|
848
|
+
const event = await waitFor(
|
|
849
|
+
'member.removed',
|
|
850
|
+
bodyAt('/hook-chassis-removed', 'member.removed', 'userId', joined.userId)
|
|
851
|
+
);
|
|
852
|
+
expect(event).toMatchObject({ workspaceId, subjects: {}, data: { userId: joined.userId } });
|
|
853
|
+
expect(
|
|
854
|
+
chassisBodies('/hook-chassis-removed').filter((b) => b.type === 'member.removed')
|
|
855
|
+
).toHaveLength(1);
|
|
856
|
+
} finally {
|
|
857
|
+
await send('PATCH', `/webhooks/${hookRemoved.id}`, actors.owner!, { enabled: false });
|
|
858
|
+
}
|
|
859
|
+
});
|
|
860
|
+
|
|
861
|
+
it('32. apikey.revoked, on DELETE /api-keys/{id}, reaches the receiver with the key (G-R2-1)', async () => {
|
|
862
|
+
const minted = await send('POST', '/api-keys', actors.owner!, {
|
|
863
|
+
name: 'revoked-key',
|
|
864
|
+
scopes: [host.scopes.read]
|
|
865
|
+
});
|
|
866
|
+
expect(minted.status).toBe(201);
|
|
867
|
+
const apiKeyId = (await readJson(minted)).apiKey.id as string;
|
|
868
|
+
const hookRevoked = await createEndpoint(actors.owner!, {
|
|
869
|
+
url: `${base}/hook-chassis-revoked`,
|
|
870
|
+
events: ['apikey.revoked']
|
|
871
|
+
});
|
|
872
|
+
try {
|
|
873
|
+
const revoked = await send('DELETE', `/api-keys/${apiKeyId}`, actors.owner!);
|
|
874
|
+
expect(revoked.status).toBe(200);
|
|
875
|
+
const event = await waitFor(
|
|
876
|
+
'apikey.revoked',
|
|
877
|
+
bodyAt('/hook-chassis-revoked', 'apikey.revoked', 'apiKeyId', apiKeyId)
|
|
878
|
+
);
|
|
879
|
+
expect(event).toMatchObject({ workspaceId, subjects: {}, data: { apiKeyId } });
|
|
880
|
+
// Only the revoke: the mint before the endpoint existed is nobody's here.
|
|
881
|
+
expect(chassisBodies('/hook-chassis-revoked').map((b) => b.type)).toEqual(['apikey.revoked']);
|
|
882
|
+
} finally {
|
|
883
|
+
await send('PATCH', `/webhooks/${hookRevoked.id}`, actors.owner!, { enabled: false });
|
|
884
|
+
}
|
|
885
|
+
});
|
|
886
|
+
});
|
|
887
|
+
|
|
888
|
+
// ── A closed instance ────────────────────────────────────────────────────────
|
|
889
|
+
|
|
890
|
+
describe('the URL policy of an instance that keeps private networks closed', () => {
|
|
891
|
+
it('7. private addresses, ports, userinfo and other schemes are refused at creation; a name resolving to loopback is refused in the socket', async () => {
|
|
892
|
+
closed = await createTestApp(
|
|
893
|
+
await createDatabase(container, 'webhooks_closed'),
|
|
894
|
+
{},
|
|
895
|
+
{
|
|
896
|
+
webhookDials: DIALS,
|
|
897
|
+
// Every name resolves to loopback: the check inside the socket's own lookup must refuse it.
|
|
898
|
+
webhookFetch: { lookup: async () => [{ address: '127.0.0.1', family: 4 }] }
|
|
899
|
+
}
|
|
900
|
+
);
|
|
901
|
+
const setup = await sendTo(
|
|
902
|
+
closed,
|
|
903
|
+
'POST',
|
|
904
|
+
'/setup',
|
|
905
|
+
{},
|
|
906
|
+
{ setupToken: 'integration-test-setup-token', instanceName: 'Closed', owner: OWNER }
|
|
907
|
+
);
|
|
908
|
+
expect(setup.status).toBe(201);
|
|
909
|
+
const closedWs = (await readJson(setup)).workspaceId;
|
|
910
|
+
const owner = { cookie: await signIn(closed, OWNER.email) };
|
|
911
|
+
|
|
912
|
+
const post = (url: string) => sendTo(closed!, 'POST', '/webhooks', owner, { url, events: ['*'] });
|
|
913
|
+
await expectError(await post(`${base}/x`), 400, 'url_refused');
|
|
914
|
+
await expectError(await post('http://10.0.0.1/'), 400, 'url_refused');
|
|
915
|
+
await expectError(await post('http://169.254.169.254/'), 400, 'url_refused');
|
|
916
|
+
// The schema refuses these first (a validation error), the service would answer url_refused: 400 either way.
|
|
917
|
+
expect((await post('http://user:pw@example.com/')).status).toBe(400);
|
|
918
|
+
expect((await post('ftp://example.com/hook')).status).toBe(400);
|
|
919
|
+
|
|
920
|
+
const endpoint = await createEndpoint(
|
|
921
|
+
owner,
|
|
922
|
+
{ url: 'http://example.test/hook', events: ['thing.created'] },
|
|
923
|
+
{},
|
|
924
|
+
closed
|
|
925
|
+
);
|
|
926
|
+
const eventId = await emitCreated('t-closed', 'p0', closed, closedWs);
|
|
927
|
+
expect(eventId).toEqual(expect.any(String));
|
|
928
|
+
const row = await waitFor('the refused delivery', async () => {
|
|
929
|
+
const r = await deliveryFor(endpoint.id, eventId!, closed);
|
|
930
|
+
return r?.lastError ? r : undefined;
|
|
931
|
+
});
|
|
932
|
+
expect(row.lastError).toMatch(/^url_refused:private_address/);
|
|
933
|
+
expect(row.lastStatusCode).toBeNull();
|
|
934
|
+
expect(row.status).not.toBe('delivered');
|
|
935
|
+
expect(requestsWithId(eventId!)).toEqual([]);
|
|
936
|
+
});
|
|
937
|
+
});
|
|
938
|
+
|
|
939
|
+
// ── Failure paths ────────────────────────────────────────────────────────────
|
|
940
|
+
|
|
941
|
+
describe('failures', () => {
|
|
942
|
+
it(
|
|
943
|
+
'8. a failing receiver is retried, then held, then re-driven once it answers',
|
|
944
|
+
{ timeout: 45_000 },
|
|
945
|
+
async () => {
|
|
946
|
+
statusFor.set('/hook-c', 500);
|
|
947
|
+
const hookC = await createEndpoint(actors.admin!, {
|
|
948
|
+
url: `${base}/hook-c`,
|
|
949
|
+
events: ['thing.created'],
|
|
950
|
+
filters: { thing: ['t-c'] }
|
|
951
|
+
});
|
|
952
|
+
const eventId = (await emitCreated('t-c'))!;
|
|
953
|
+
expect(eventId).toEqual(expect.any(String));
|
|
954
|
+
const held = await waitFor(
|
|
955
|
+
'the delivery held',
|
|
956
|
+
async () => {
|
|
957
|
+
const r = await deliveryFor(hookC.id, eventId);
|
|
958
|
+
return r?.status === 'held' ? r : undefined;
|
|
959
|
+
},
|
|
960
|
+
30_000
|
|
961
|
+
);
|
|
962
|
+
// retryLimit 2: three attempts, then the dead letter moved it to held.
|
|
963
|
+
expect(held.attempt).toBe(3);
|
|
964
|
+
expect(held.lastStatusCode).toBe(500);
|
|
965
|
+
expect(held.lastError).toBe('HTTP 500');
|
|
966
|
+
expect(held.nextAttemptAt).not.toBeNull();
|
|
967
|
+
statusFor.set('/hook-c', 200);
|
|
968
|
+
|
|
969
|
+
const scrape = await app.app.request('/metrics', {
|
|
970
|
+
headers: { authorization: `Bearer ${METRICS_TOKEN}` }
|
|
971
|
+
});
|
|
972
|
+
expect(scrape.status).toBe(200);
|
|
973
|
+
const metrics = await scrape.text();
|
|
974
|
+
const heldTotal = /^webhook_deliveries_held_total (\d+)/m.exec(metrics);
|
|
975
|
+
expect(Number(heldTotal?.[1] ?? 0)).toBeGreaterThanOrEqual(1);
|
|
976
|
+
|
|
977
|
+
const delivered = await waitFor(
|
|
978
|
+
'the held delivery re-driven',
|
|
979
|
+
async () => {
|
|
980
|
+
const r = await deliveryFor(hookC.id, eventId);
|
|
981
|
+
return r?.status === 'delivered' ? r : undefined;
|
|
982
|
+
},
|
|
983
|
+
20_000
|
|
984
|
+
);
|
|
985
|
+
expect(delivered).toMatchObject({ attempt: 4, lastStatusCode: 200, lastError: null });
|
|
986
|
+
expect(hitsAt('/hook-c', eventId)).toHaveLength(4);
|
|
987
|
+
expect(JSON.parse(hitsAt('/hook-c', eventId).at(-1)!.body).attempt).toBe(4);
|
|
988
|
+
const endpoint = await endpointRow(hookC.id);
|
|
989
|
+
expect(endpoint).toMatchObject({
|
|
990
|
+
enabled: true,
|
|
991
|
+
consecutiveFailures: 0,
|
|
992
|
+
lastDeliveryStatus: 'delivered'
|
|
993
|
+
});
|
|
994
|
+
}
|
|
995
|
+
);
|
|
996
|
+
|
|
997
|
+
it(
|
|
998
|
+
'9. a failure streak switches the endpoint off, mails its creator once, and a re-enable clears it',
|
|
999
|
+
{ timeout: 60_000 },
|
|
1000
|
+
async () => {
|
|
1001
|
+
statusFor.set('/hook-d', 503);
|
|
1002
|
+
const url = `${base}/hook-d`;
|
|
1003
|
+
const hookD = await createEndpoint(actors.owner!, {
|
|
1004
|
+
url,
|
|
1005
|
+
events: ['thing.created'],
|
|
1006
|
+
filters: { thing: ['t-d'] },
|
|
1007
|
+
description: 'Dee'
|
|
1008
|
+
});
|
|
1009
|
+
// Two events, three attempts each: the fifth failed attempt disables.
|
|
1010
|
+
expect(await emitCreated('t-d')).toEqual(expect.any(String));
|
|
1011
|
+
expect(await emitCreated('t-d')).toEqual(expect.any(String));
|
|
1012
|
+
const off = await waitFor(
|
|
1013
|
+
'the endpoint switched off',
|
|
1014
|
+
async () => {
|
|
1015
|
+
const r = await endpointRow(hookD.id);
|
|
1016
|
+
return r && !r.enabled ? r : undefined;
|
|
1017
|
+
},
|
|
1018
|
+
40_000
|
|
1019
|
+
);
|
|
1020
|
+
expect(off).toMatchObject({ enabled: false, disabledReason: 'delivery_failures' });
|
|
1021
|
+
expect(off.disabledAt).not.toBeNull();
|
|
1022
|
+
expect(off.consecutiveFailures).toBeGreaterThanOrEqual(DIALS.disableAfterFailures);
|
|
1023
|
+
|
|
1024
|
+
// At least the five failures that switched it off reached the receiver; with the delivery
|
|
1025
|
+
// queue's eight pollers a sixth attempt may already have been in flight when the fifth
|
|
1026
|
+
// switched it off, so the count is not exact (it is pinned below: nothing NEW after).
|
|
1027
|
+
expect(requestsTo('/hook-d').length).toBeGreaterThanOrEqual(DIALS.disableAfterFailures);
|
|
1028
|
+
const mails = mail.sent.filter((m) => m.subject.includes('switched off'));
|
|
1029
|
+
expect(mails).toHaveLength(1);
|
|
1030
|
+
expect(mails[0]!.to).toBe(OWNER.email);
|
|
1031
|
+
expect(mails[0]!.text).toContain(url);
|
|
1032
|
+
|
|
1033
|
+
// A switched-off endpoint takes no new event (hook-a, the witness, still does).
|
|
1034
|
+
const after = await witnessed(emitCreated('t-d'));
|
|
1035
|
+
expect(await deliveryFor(hookD.id, after)).toBeUndefined();
|
|
1036
|
+
|
|
1037
|
+
// Every delivery left reads failed/endpoint_disabled once its next round (a retry, or the
|
|
1038
|
+
// hold's re-drive) finds the endpoint off.
|
|
1039
|
+
const rows = await waitFor(
|
|
1040
|
+
'the deliveries refused as endpoint_disabled',
|
|
1041
|
+
async () => {
|
|
1042
|
+
const all = await deliveriesOf(hookD.id);
|
|
1043
|
+
return all.length === 2 &&
|
|
1044
|
+
all.every((d) => d.status === 'failed' && d.lastError === 'endpoint_disabled')
|
|
1045
|
+
? all
|
|
1046
|
+
: undefined;
|
|
1047
|
+
},
|
|
1048
|
+
20_000
|
|
1049
|
+
);
|
|
1050
|
+
expect(rows).toHaveLength(2);
|
|
1051
|
+
// No new request once the endpoint is off and its deliveries are refused.
|
|
1052
|
+
const seenOnceOff = requestsTo('/hook-d').length;
|
|
1053
|
+
expect(seenOnceOff).toBeLessThanOrEqual(DIALS.disableAfterFailures + 1);
|
|
1054
|
+
await settle();
|
|
1055
|
+
expect(requestsTo('/hook-d')).toHaveLength(seenOnceOff);
|
|
1056
|
+
expect(mail.sent.filter((m) => m.subject.includes('switched off'))).toHaveLength(1);
|
|
1057
|
+
|
|
1058
|
+
statusFor.set('/hook-d', 200);
|
|
1059
|
+
const patched = await send('PATCH', `/webhooks/${hookD.id}`, actors.owner!, { enabled: true });
|
|
1060
|
+
expect(patched.status).toBe(200);
|
|
1061
|
+
expect(await readJson(patched)).toMatchObject({
|
|
1062
|
+
enabled: true,
|
|
1063
|
+
disabledAt: null,
|
|
1064
|
+
disabledReason: null,
|
|
1065
|
+
consecutiveFailures: 0
|
|
1066
|
+
});
|
|
1067
|
+
expect(await endpointRow(hookD.id)).toMatchObject({ firstFailureAt: null, consecutiveFailures: 0 });
|
|
1068
|
+
}
|
|
1069
|
+
);
|
|
1070
|
+
|
|
1071
|
+
it('24. a secret that cannot be opened fails the delivery as secret_unreadable at its first attempt, with no request and no retry (MN3)', async () => {
|
|
1072
|
+
const hookS = await createEndpoint(actors.admin!, {
|
|
1073
|
+
url: `${base}/hook-s`,
|
|
1074
|
+
events: ['thing.created'],
|
|
1075
|
+
filters: { thing: ['t-s'] }
|
|
1076
|
+
});
|
|
1077
|
+
await app.db.db
|
|
1078
|
+
.update(webhookEndpoints)
|
|
1079
|
+
.set({ secret: 'v1.garbage' })
|
|
1080
|
+
.where(eq(webhookEndpoints.id, hookS.id));
|
|
1081
|
+
const eventId = (await emitCreated('t-s'))!;
|
|
1082
|
+
expect(eventId).toEqual(expect.any(String));
|
|
1083
|
+
const row = await waitFor('the delivery failed', async () => {
|
|
1084
|
+
const r = await deliveryFor(hookS.id, eventId);
|
|
1085
|
+
return r?.status === 'failed' ? r : undefined;
|
|
1086
|
+
});
|
|
1087
|
+
expect(row).toMatchObject({ lastError: 'secret_unreadable', attempt: 1, lastStatusCode: null });
|
|
1088
|
+
expect(requestsTo('/hook-s')).toEqual([]);
|
|
1089
|
+
await settle();
|
|
1090
|
+
const rows = await deliveriesOf(hookS.id);
|
|
1091
|
+
expect(rows).toHaveLength(1);
|
|
1092
|
+
expect(rows[0]).toMatchObject({ status: 'failed', attempt: 1, lastError: 'secret_unreadable' });
|
|
1093
|
+
expect(requestsTo('/hook-s')).toEqual([]);
|
|
1094
|
+
// No retry can heal it: the endpoint is switched off, with its reason (N-R2-2).
|
|
1095
|
+
const endpoint = await endpointRow(hookS.id);
|
|
1096
|
+
expect(endpoint).toMatchObject({ enabled: false, disabledReason: 'secret_unreadable' });
|
|
1097
|
+
expect(endpoint!.disabledAt).not.toBeNull();
|
|
1098
|
+
});
|
|
1099
|
+
|
|
1100
|
+
it(
|
|
1101
|
+
'25. the auto-disable mail is not sent to a creator who has left the workspace (MN4)',
|
|
1102
|
+
{ timeout: 60_000 },
|
|
1103
|
+
async () => {
|
|
1104
|
+
const leaver = await addActor('leaver', 'leaver@example.com', { role: 'admin' });
|
|
1105
|
+
statusFor.set('/hook-leave', 503);
|
|
1106
|
+
const hookL = await createEndpoint(leaver, {
|
|
1107
|
+
url: `${base}/hook-leave`,
|
|
1108
|
+
events: ['thing.created'],
|
|
1109
|
+
filters: { thing: ['t-leave'] }
|
|
1110
|
+
});
|
|
1111
|
+
expect((await endpointRow(hookL.id))!.createdBy).toBe(leaver.userId);
|
|
1112
|
+
await app.db.db
|
|
1113
|
+
.update(workspaceMembers)
|
|
1114
|
+
.set({ isActive: false })
|
|
1115
|
+
.where(
|
|
1116
|
+
and(eq(workspaceMembers.workspaceId, workspaceId), eq(workspaceMembers.userId, leaver.userId))
|
|
1117
|
+
);
|
|
1118
|
+
const mailsBefore = mail.sent.length;
|
|
1119
|
+
|
|
1120
|
+
// Two events, three attempts each: the fifth failed attempt disables.
|
|
1121
|
+
expect(await emitCreated('t-leave')).toEqual(expect.any(String));
|
|
1122
|
+
expect(await emitCreated('t-leave')).toEqual(expect.any(String));
|
|
1123
|
+
const off = await waitFor(
|
|
1124
|
+
'the endpoint switched off',
|
|
1125
|
+
async () => {
|
|
1126
|
+
const r = await endpointRow(hookL.id);
|
|
1127
|
+
return r && !r.enabled ? r : undefined;
|
|
1128
|
+
},
|
|
1129
|
+
40_000
|
|
1130
|
+
);
|
|
1131
|
+
expect(off).toMatchObject({ enabled: false, disabledReason: 'delivery_failures' });
|
|
1132
|
+
// The mail is sent by the attempt that switched it off, right after: give it the time.
|
|
1133
|
+
await settle(1500);
|
|
1134
|
+
expect(mail.sent.filter((m) => m.to === leaver.email)).toEqual([]);
|
|
1135
|
+
expect(mail.sent.slice(mailsBefore).filter((m) => m.subject.includes('switched off'))).toEqual([]);
|
|
1136
|
+
}
|
|
1137
|
+
);
|
|
1138
|
+
|
|
1139
|
+
it('26. the failure streak is counted in the statement: concurrent failures add up, and the switch-off mails once (MN5)', async () => {
|
|
1140
|
+
statusFor.set('/hook-race', 500);
|
|
1141
|
+
const url = `${base}/hook-race`;
|
|
1142
|
+
const hookR = await createEndpoint(actors.owner!, {
|
|
1143
|
+
url,
|
|
1144
|
+
events: ['thing.created'],
|
|
1145
|
+
filters: { thing: ['t-race-never'] }
|
|
1146
|
+
});
|
|
1147
|
+
const switchedOff = () =>
|
|
1148
|
+
mail.sent.filter((m) => m.subject.includes('switched off') && (m.text ?? '').includes(url)).length;
|
|
1149
|
+
// Two pending rows attempted at once (no job behind them: the two attempts are the race).
|
|
1150
|
+
const race = async () => {
|
|
1151
|
+
const rows = await app.db.db
|
|
1152
|
+
.insert(webhookDeliveries)
|
|
1153
|
+
.values(
|
|
1154
|
+
[0, 1].map(() => ({
|
|
1155
|
+
endpointId: hookR.id,
|
|
1156
|
+
workspaceId,
|
|
1157
|
+
eventId: randomUUID(),
|
|
1158
|
+
eventType: 'thing.created',
|
|
1159
|
+
payload: {
|
|
1160
|
+
type: 'thing.created',
|
|
1161
|
+
workspaceId,
|
|
1162
|
+
subjects: { thingId: 't-race-never', projectIds: ['p0'] },
|
|
1163
|
+
data: { thingId: 't-race-never', name: 'Race' }
|
|
1164
|
+
},
|
|
1165
|
+
status: 'pending' as const
|
|
1166
|
+
}))
|
|
1167
|
+
)
|
|
1168
|
+
.returning({ id: webhookDeliveries.id });
|
|
1169
|
+
const outcomes = await Promise.all(rows.map((r) => app.webhooks.attempt(r.id, { retryable: false })));
|
|
1170
|
+
expect(outcomes.map((o) => o?.status)).toEqual(['failed', 'failed']);
|
|
1171
|
+
};
|
|
1172
|
+
|
|
1173
|
+
await race();
|
|
1174
|
+
expect(await endpointRow(hookR.id)).toMatchObject({ consecutiveFailures: 2, enabled: true });
|
|
1175
|
+
await race();
|
|
1176
|
+
expect(await endpointRow(hookR.id)).toMatchObject({ consecutiveFailures: 4, enabled: true });
|
|
1177
|
+
expect(switchedOff()).toBe(0);
|
|
1178
|
+
// 4 → 5 and 4 → 6: both reach the threshold (5), only one switch-off wins.
|
|
1179
|
+
await race();
|
|
1180
|
+
expect(await endpointRow(hookR.id)).toMatchObject({
|
|
1181
|
+
consecutiveFailures: 6,
|
|
1182
|
+
enabled: false,
|
|
1183
|
+
disabledReason: 'delivery_failures'
|
|
1184
|
+
});
|
|
1185
|
+
expect(switchedOff()).toBe(1);
|
|
1186
|
+
expect(requestsTo('/hook-race')).toHaveLength(6);
|
|
1187
|
+
});
|
|
1188
|
+
|
|
1189
|
+
it(
|
|
1190
|
+
'27. a hold whose re-send fails leaves the row pending, and the held job’s own retry re-drives it (G3)',
|
|
1191
|
+
{ timeout: 60_000 },
|
|
1192
|
+
async () => {
|
|
1193
|
+
statusFor.set('/hook-h', 500);
|
|
1194
|
+
const hookH = await createEndpoint(actors.admin!, {
|
|
1195
|
+
url: `${base}/hook-h`,
|
|
1196
|
+
events: ['thing.created'],
|
|
1197
|
+
filters: { thing: ['t-h'] }
|
|
1198
|
+
});
|
|
1199
|
+
const eventId = (await emitCreated('t-h'))!;
|
|
1200
|
+
const delivery = await waitFor('the delivery row', () => deliveryFor(hookH.id, eventId));
|
|
1201
|
+
|
|
1202
|
+
// The fan-out is done: the next send of THIS delivery on the delivery queue is the hold's
|
|
1203
|
+
// re-send. Refuse it once (another endpoint's hold passes through untouched).
|
|
1204
|
+
const boss = app.jobs.boss;
|
|
1205
|
+
const original = boss.send;
|
|
1206
|
+
let refused = false;
|
|
1207
|
+
const spy = vi.spyOn(boss, 'send').mockImplementation(((...args: unknown[]) => {
|
|
1208
|
+
const data = args[1] as { deliveryId?: string } | undefined;
|
|
1209
|
+
if (!refused && args[0] === 'webhook-deliveries' && data?.deliveryId === delivery.id) {
|
|
1210
|
+
refused = true;
|
|
1211
|
+
return Promise.reject(new Error('the test refuses this send'));
|
|
1212
|
+
}
|
|
1213
|
+
return (original as (...a: unknown[]) => Promise<string | null>)(...args);
|
|
1214
|
+
}) as typeof boss.send);
|
|
1215
|
+
let retrying: { id: string };
|
|
1216
|
+
try {
|
|
1217
|
+
retrying = await waitFor(
|
|
1218
|
+
'the held job failing its re-send',
|
|
1219
|
+
async () => {
|
|
1220
|
+
if (!refused) return undefined;
|
|
1221
|
+
const { rows } = await app.db.pool.query<{ id: string; state: string }>(
|
|
1222
|
+
`SELECT id, state FROM pgboss.job WHERE name = 'webhook-deliveries-held' AND data->>'deliveryId' = $1`,
|
|
1223
|
+
[delivery.id]
|
|
1224
|
+
);
|
|
1225
|
+
return rows.find((r) => r.state === 'retry');
|
|
1226
|
+
},
|
|
1227
|
+
30_000
|
|
1228
|
+
);
|
|
1229
|
+
} finally {
|
|
1230
|
+
spy.mockRestore();
|
|
1231
|
+
}
|
|
1232
|
+
// The hold rolled back with its send: the row is pending, never held with no job behind it.
|
|
1233
|
+
const row = await deliveryFor(hookH.id, eventId);
|
|
1234
|
+
expect(row).toMatchObject({ status: 'pending', attempt: DIALS.retryLimit + 1 });
|
|
1235
|
+
const { rows: live } = await app.db.pool.query<{ n: number }>(
|
|
1236
|
+
`SELECT count(*)::int AS n FROM pgboss.job WHERE name = 'webhook-deliveries' AND data->>'deliveryId' = $1 AND state IN ('created', 'retry', 'active')`,
|
|
1237
|
+
[delivery.id]
|
|
1238
|
+
);
|
|
1239
|
+
expect(live[0]!.n).toBe(0);
|
|
1240
|
+
|
|
1241
|
+
// The held queue retries a minute later; brought forward so the suite does not sit it out.
|
|
1242
|
+
statusFor.set('/hook-h', 200);
|
|
1243
|
+
await app.db.pool.query(
|
|
1244
|
+
`UPDATE pgboss.job SET start_after = now() WHERE id = $1 AND state = 'retry'`,
|
|
1245
|
+
[retrying.id]
|
|
1246
|
+
);
|
|
1247
|
+
const delivered = await waitFor(
|
|
1248
|
+
'the held delivery re-driven',
|
|
1249
|
+
async () => {
|
|
1250
|
+
const r = await deliveryFor(hookH.id, eventId);
|
|
1251
|
+
return r?.status === 'delivered' ? r : undefined;
|
|
1252
|
+
},
|
|
1253
|
+
30_000
|
|
1254
|
+
);
|
|
1255
|
+
expect(delivered).toMatchObject({ attempt: DIALS.retryLimit + 2, lastStatusCode: 200 });
|
|
1256
|
+
expect(hitsAt('/hook-h', eventId)).toHaveLength(DIALS.retryLimit + 2);
|
|
1257
|
+
}
|
|
1258
|
+
);
|
|
1259
|
+
|
|
1260
|
+
it(
|
|
1261
|
+
'28. a silent receiver holds one delivery slot, not the instance: a fast endpoint is served while three deliveries hang (MJ1)',
|
|
1262
|
+
{ timeout: 45_000 },
|
|
1263
|
+
async () => {
|
|
1264
|
+
silentPaths.add('/hook-silent');
|
|
1265
|
+
const hookSlow = await createEndpoint(actors.admin!, {
|
|
1266
|
+
url: `${base}/hook-silent`,
|
|
1267
|
+
events: ['thing.created'],
|
|
1268
|
+
filters: { thing: ['t-slow'] }
|
|
1269
|
+
});
|
|
1270
|
+
const hookFast = await createEndpoint(actors.admin!, {
|
|
1271
|
+
url: `${base}/hook-fast`,
|
|
1272
|
+
events: ['thing.created'],
|
|
1273
|
+
filters: { thing: ['t-fast'] }
|
|
1274
|
+
});
|
|
1275
|
+
try {
|
|
1276
|
+
for (let i = 0; i < 3; i++) expect(await emitCreated('t-slow')).toEqual(expect.any(String));
|
|
1277
|
+
const fastId = (await emitCreated('t-fast'))!;
|
|
1278
|
+
await waitFor('the fast delivery row', () => deliveryFor(hookFast.id, fastId));
|
|
1279
|
+
const rowAt = Date.now();
|
|
1280
|
+
await waitFor('the fast delivery', () => hitsAt('/hook-fast', fastId).length > 0, 12_000);
|
|
1281
|
+
const arrivedAt = Date.now();
|
|
1282
|
+
// Measured from the fast row (the fan-out done), not from the emit: the fan-out queue's own
|
|
1283
|
+
// polling (two seconds, two pollers) is not what is under test. pg-boss polls every two
|
|
1284
|
+
// seconds, so a free delivery poller takes the row within one interval; one serial poller
|
|
1285
|
+
// would reach it only behind the silent deliveries and their retries (2 s timeouts each).
|
|
1286
|
+
expect(arrivedAt - rowAt).toBeLessThan(2500);
|
|
1287
|
+
// Several silent deliveries were held open AT ONCE: the queue runs them side by side.
|
|
1288
|
+
await waitFor(
|
|
1289
|
+
'two silent deliveries open at once',
|
|
1290
|
+
() => (openMax.get('/hook-silent') ?? 0) >= 2,
|
|
1291
|
+
10_000
|
|
1292
|
+
);
|
|
1293
|
+
} finally {
|
|
1294
|
+
await send('PATCH', `/webhooks/${hookSlow.id}`, actors.admin!, { enabled: false });
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
);
|
|
1298
|
+
|
|
1299
|
+
it(
|
|
1300
|
+
'33. one slow receiver holds at most two of the eight delivery pollers: a fast endpoint is served at once, and the deferred deliveries keep their retry budget (MJ-R2-1)',
|
|
1301
|
+
{ timeout: 60_000 },
|
|
1302
|
+
async () => {
|
|
1303
|
+
const slowPath = '/hook-bound-slow';
|
|
1304
|
+
// Held three seconds, longer than the sender's two-second timeout: every slow send is cut.
|
|
1305
|
+
slowPaths.set(slowPath, 3000);
|
|
1306
|
+
const hookSlow = await createEndpoint(actors.admin!, {
|
|
1307
|
+
url: `${base}${slowPath}`,
|
|
1308
|
+
events: ['thing.created'],
|
|
1309
|
+
filters: { thing: ['t-bound-slow'] }
|
|
1310
|
+
});
|
|
1311
|
+
const hookFast = await createEndpoint(actors.admin!, {
|
|
1312
|
+
url: `${base}/hook-bound-fast`,
|
|
1313
|
+
events: ['thing.created'],
|
|
1314
|
+
filters: { thing: ['t-bound-fast'] }
|
|
1315
|
+
});
|
|
1316
|
+
try {
|
|
1317
|
+
const slowIds = await Promise.all(Array.from({ length: 10 }, () => emitCreated('t-bound-slow')));
|
|
1318
|
+
expect(slowIds.every((id) => typeof id === 'string')).toBe(true);
|
|
1319
|
+
const fastEmitAt = Date.now();
|
|
1320
|
+
const fastId = (await emitCreated('t-bound-fast'))!;
|
|
1321
|
+
expect(fastId).toEqual(expect.any(String));
|
|
1322
|
+
|
|
1323
|
+
await waitFor('the fast delivery row', () => deliveryFor(hookFast.id, fastId));
|
|
1324
|
+
const rowAt = Date.now();
|
|
1325
|
+
await waitFor('the fast delivery', () => hitsAt('/hook-bound-fast', fastId).length > 0, 12_000);
|
|
1326
|
+
const fastMs = Date.now() - rowAt;
|
|
1327
|
+
console.info(
|
|
1328
|
+
`[33] fast delivery ${fastMs} ms after its row seen, ${Date.now() - fastEmitAt} ms after its emit; slow open now ${openNow.get(slowPath) ?? 0}`
|
|
1329
|
+
);
|
|
1330
|
+
// Eight pollers minus the slow endpoint's two: a free one takes the row at its next half-second poll.
|
|
1331
|
+
expect(fastMs).toBeLessThan(3000);
|
|
1332
|
+
|
|
1333
|
+
// The slow endpoint did fill its share, and never more.
|
|
1334
|
+
await waitFor(
|
|
1335
|
+
'the slow endpoint holding two requests',
|
|
1336
|
+
() => (openMax.get(slowPath) ?? 0) >= 2,
|
|
1337
|
+
10_000
|
|
1338
|
+
);
|
|
1339
|
+
answerAtOnce(slowPath);
|
|
1340
|
+
|
|
1341
|
+
const rows = await waitFor(
|
|
1342
|
+
'the ten slow deliveries delivered',
|
|
1343
|
+
async () => {
|
|
1344
|
+
const all = await deliveriesOf(hookSlow.id);
|
|
1345
|
+
return all.length === 10 && all.every((d) => d.status === 'delivered') ? all : undefined;
|
|
1346
|
+
},
|
|
1347
|
+
40_000
|
|
1348
|
+
);
|
|
1349
|
+
console.info(
|
|
1350
|
+
`[33] slow max open ${openMax.get(slowPath)}; attempts ${rows
|
|
1351
|
+
.map((r) => r.attempt)
|
|
1352
|
+
.sort()
|
|
1353
|
+
.join(',')}; slow requests ${requestsTo(slowPath).length}`
|
|
1354
|
+
);
|
|
1355
|
+
expect(openMax.get(slowPath)).toBeLessThanOrEqual(2);
|
|
1356
|
+
// A deferral is not an attempt: a row reads 1 (never cut) or 2 (cut once, then answered).
|
|
1357
|
+
for (const row of rows) expect([1, 2]).toContain(row.attempt);
|
|
1358
|
+
expect(new Set(rows.map((r) => r.eventId))).toEqual(new Set(slowIds));
|
|
1359
|
+
for (const id of slowIds) expect(hitsAt(slowPath, id!).length).toBeGreaterThanOrEqual(1);
|
|
1360
|
+
} finally {
|
|
1361
|
+
answerAtOnce(slowPath);
|
|
1362
|
+
await send('PATCH', `/webhooks/${hookSlow.id}`, actors.admin!, { enabled: false });
|
|
1363
|
+
await send('PATCH', `/webhooks/${hookFast.id}`, actors.admin!, { enabled: false });
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
);
|
|
1367
|
+
|
|
1368
|
+
it(
|
|
1369
|
+
'34. a burst of twelve events for one endpoint lands within six seconds of the first emit: the two webhook queues poll every half second (MJ-R2-1)',
|
|
1370
|
+
{ timeout: 45_000 },
|
|
1371
|
+
async () => {
|
|
1372
|
+
const hookBurst = await createEndpoint(actors.admin!, {
|
|
1373
|
+
url: `${base}/hook-burst`,
|
|
1374
|
+
events: ['thing.created'],
|
|
1375
|
+
filters: { thing: ['t-burst'] }
|
|
1376
|
+
});
|
|
1377
|
+
try {
|
|
1378
|
+
const firstEmitAt = Date.now();
|
|
1379
|
+
const ids: string[] = [];
|
|
1380
|
+
for (let i = 0; i < 12; i++) {
|
|
1381
|
+
const id = await emitCreated('t-burst');
|
|
1382
|
+
expect(id).toEqual(expect.any(String));
|
|
1383
|
+
ids.push(id!);
|
|
1384
|
+
}
|
|
1385
|
+
const lastEmitAt = Date.now();
|
|
1386
|
+
const rows = await waitFor(
|
|
1387
|
+
'the twelve deliveries delivered',
|
|
1388
|
+
async () => {
|
|
1389
|
+
const all = await deliveriesOf(hookBurst.id);
|
|
1390
|
+
return all.length === 12 && all.every((d) => d.status === 'delivered') ? all : undefined;
|
|
1391
|
+
},
|
|
1392
|
+
30_000
|
|
1393
|
+
);
|
|
1394
|
+
expect(new Set(rows.map((r) => r.eventId))).toEqual(new Set(ids));
|
|
1395
|
+
const lastDeliveredAt = Math.max(...rows.map((r) => r.deliveredAt!.getTime()));
|
|
1396
|
+
console.info(
|
|
1397
|
+
`[34] emits took ${lastEmitAt - firstEmitAt} ms; last delivery ${lastDeliveredAt - firstEmitAt} ms after the first emit`
|
|
1398
|
+
);
|
|
1399
|
+
// pg-boss's default two-second poll with two fan-out pollers would take about twelve.
|
|
1400
|
+
expect(lastDeliveredAt - firstEmitAt).toBeLessThan(6000);
|
|
1401
|
+
} finally {
|
|
1402
|
+
await send('PATCH', `/webhooks/${hookBurst.id}`, actors.admin!, { enabled: false });
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
);
|
|
1406
|
+
|
|
1407
|
+
it('35. the endpoint a secret_unreadable delivery switched off listens no more: the next emit of its workspace answers null (N-R2-2)', async () => {
|
|
1408
|
+
const lone = (await app.registry.workspaces.create('Unreadable', actors.owner!.userId)).workspaceId;
|
|
1409
|
+
const there = { 'x-workspace-id': lone };
|
|
1410
|
+
const hookU = await createEndpoint(
|
|
1411
|
+
actors.owner!,
|
|
1412
|
+
{ url: `${base}/hook-unreadable`, events: ['thing.created'] },
|
|
1413
|
+
there
|
|
1414
|
+
);
|
|
1415
|
+
await app.db.db
|
|
1416
|
+
.update(webhookEndpoints)
|
|
1417
|
+
.set({ secret: 'v1.garbage' })
|
|
1418
|
+
.where(eq(webhookEndpoints.id, hookU.id));
|
|
1419
|
+
const eventId = (await emitCreated('t-unreadable', 'p0', app, lone))!;
|
|
1420
|
+
expect(eventId).toEqual(expect.any(String));
|
|
1421
|
+
const row = await waitFor('the delivery failed', async () => {
|
|
1422
|
+
const r = await deliveryFor(hookU.id, eventId);
|
|
1423
|
+
return r?.status === 'failed' ? r : undefined;
|
|
1424
|
+
});
|
|
1425
|
+
expect(row).toMatchObject({ lastError: 'secret_unreadable', attempt: 1 });
|
|
1426
|
+
expect(await endpointRow(hookU.id)).toMatchObject({
|
|
1427
|
+
enabled: false,
|
|
1428
|
+
disabledReason: 'secret_unreadable'
|
|
1429
|
+
});
|
|
1430
|
+
|
|
1431
|
+
expect(await app.webhooks.wants(lone, 'thing.created')).toBe(false);
|
|
1432
|
+
const jobsBefore = await eventJobs();
|
|
1433
|
+
expect(await emitCreated('t-unreadable', 'p0', app, lone)).toBeNull();
|
|
1434
|
+
expect(await eventJobs()).toBe(jobsBefore);
|
|
1435
|
+
expect(await deliveriesOf(hookU.id)).toHaveLength(1);
|
|
1436
|
+
expect(requestsTo('/hook-unreadable')).toEqual([]);
|
|
1437
|
+
});
|
|
1438
|
+
});
|
|
1439
|
+
|
|
1440
|
+
// ── Secret, ping, redeliver, update, delete ──────────────────────────────────
|
|
1441
|
+
|
|
1442
|
+
describe('the endpoint’s acts', () => {
|
|
1443
|
+
it('10. a rotation answers a new secret, and the next delivery verifies under the new AND the old one', async () => {
|
|
1444
|
+
const hookF = await createEndpoint(actors.admin!, {
|
|
1445
|
+
url: `${base}/hook-f`,
|
|
1446
|
+
events: ['thing.created'],
|
|
1447
|
+
filters: { thing: ['t-f'] }
|
|
1448
|
+
});
|
|
1449
|
+
const rotated = await send('POST', `/webhooks/${hookF.id}/rotate-secret`, actors.admin!);
|
|
1450
|
+
expect(rotated.status).toBe(200);
|
|
1451
|
+
const body = await readJson(rotated);
|
|
1452
|
+
expect(body.secret).toMatch(/^whsec_/);
|
|
1453
|
+
expect(body.secret).not.toBe(hookF.secret);
|
|
1454
|
+
expect(body.previousSecretExpiresAt).toEqual(expect.any(String));
|
|
1455
|
+
expect(Date.parse(body.previousSecretExpiresAt)).toBeGreaterThan(Date.now());
|
|
1456
|
+
|
|
1457
|
+
const eventId = (await emitCreated('t-f'))!;
|
|
1458
|
+
const [req] = await waitFor('hook-f to receive', () => {
|
|
1459
|
+
const hits = hitsAt('/hook-f', eventId);
|
|
1460
|
+
return hits.length > 0 ? hits : undefined;
|
|
1461
|
+
});
|
|
1462
|
+
const signature = req!.headers['webhook-signature'] as string;
|
|
1463
|
+
expect(signature.split(' ')).toHaveLength(2);
|
|
1464
|
+
expect(signature.split(' ').every((s) => s.startsWith('v1,'))).toBe(true);
|
|
1465
|
+
const headers = {
|
|
1466
|
+
id: req!.headers['webhook-id'] as string,
|
|
1467
|
+
timestamp: req!.headers['webhook-timestamp'] as string,
|
|
1468
|
+
signature
|
|
1469
|
+
};
|
|
1470
|
+
expect(verifyWebhookSignature(body.secret, headers, req!.body)).toBe(true);
|
|
1471
|
+
expect(verifyWebhookSignature(hookF.secret, headers, req!.body)).toBe(true);
|
|
1472
|
+
|
|
1473
|
+
const [audit] = await waitFor('the rotation audit row', async () => {
|
|
1474
|
+
const rows = (await auditRows('webhook.rotate_secret')).filter((r) => r.resource_id === hookF.id);
|
|
1475
|
+
return rows.length > 0 ? rows : undefined;
|
|
1476
|
+
});
|
|
1477
|
+
expect(JSON.stringify(audit)).not.toContain(body.secret);
|
|
1478
|
+
expect(JSON.stringify(audit)).not.toContain(hookF.secret);
|
|
1479
|
+
});
|
|
1480
|
+
|
|
1481
|
+
it('11. the test ping is attempted inline, once: delivered, then failed with no retry', async () => {
|
|
1482
|
+
const hookE = await createEndpoint(actors.admin!, {
|
|
1483
|
+
url: `${base}/hook-e`,
|
|
1484
|
+
events: ['thing.deleted'],
|
|
1485
|
+
filters: { thing: ['t-e-never'] }
|
|
1486
|
+
});
|
|
1487
|
+
const ok = await send('POST', `/webhooks/${hookE.id}/test`, actors.admin!);
|
|
1488
|
+
expect(ok.status).toBe(200);
|
|
1489
|
+
const { delivery } = await readJson(ok);
|
|
1490
|
+
expect(delivery).toMatchObject({
|
|
1491
|
+
status: 'delivered',
|
|
1492
|
+
eventType: 'webhook.ping',
|
|
1493
|
+
attempt: 1,
|
|
1494
|
+
lastStatusCode: 200
|
|
1495
|
+
});
|
|
1496
|
+
const [ping] = requestsWithId(delivery.eventId);
|
|
1497
|
+
expect(ping).toBeDefined();
|
|
1498
|
+
const pingBody = JSON.parse(ping!.body);
|
|
1499
|
+
expect(pingBody).toMatchObject({
|
|
1500
|
+
type: 'webhook.ping',
|
|
1501
|
+
data: { endpointId: hookE.id, sentBy: actors.admin!.userId }
|
|
1502
|
+
});
|
|
1503
|
+
|
|
1504
|
+
statusFor.set('/hook-e', 500);
|
|
1505
|
+
const bad = await send('POST', `/webhooks/${hookE.id}/test`, actors.admin!);
|
|
1506
|
+
expect(bad.status).toBe(200);
|
|
1507
|
+
const failed = (await readJson(bad)).delivery;
|
|
1508
|
+
expect(failed).toMatchObject({ status: 'failed', lastStatusCode: 500, eventType: 'webhook.ping' });
|
|
1509
|
+
await settle();
|
|
1510
|
+
expect(requestsWithId(failed.eventId)).toHaveLength(1);
|
|
1511
|
+
expect(await deliveryFor(hookE.id, failed.eventId)).toMatchObject({ status: 'failed', attempt: 1 });
|
|
1512
|
+
statusFor.delete('/hook-e');
|
|
1513
|
+
});
|
|
1514
|
+
|
|
1515
|
+
it('12. a redelivery is a new row with the same event id and payload; the receiver sees the same webhook-id again', async () => {
|
|
1516
|
+
const res = await send(
|
|
1517
|
+
'POST',
|
|
1518
|
+
`/webhooks/${hookA.id}/deliveries/${firstDeliveryId}/redeliver`,
|
|
1519
|
+
actors.admin!
|
|
1520
|
+
);
|
|
1521
|
+
expect(res.status).toBe(201);
|
|
1522
|
+
const again = await readJson(res);
|
|
1523
|
+
expect(again.id).not.toBe(firstDeliveryId);
|
|
1524
|
+
expect(again.eventId).toBe(firstEventId);
|
|
1525
|
+
const [original] = await app.db.db
|
|
1526
|
+
.select()
|
|
1527
|
+
.from(webhookDeliveries)
|
|
1528
|
+
.where(eq(webhookDeliveries.id, firstDeliveryId));
|
|
1529
|
+
expect(again.payload).toEqual(original!.payload);
|
|
1530
|
+
await waitFor(
|
|
1531
|
+
'the second request with the same webhook-id',
|
|
1532
|
+
() => requestsWithId(firstEventId).length === 2
|
|
1533
|
+
);
|
|
1534
|
+
const bodies = requestsWithId(firstEventId).map((r) => JSON.parse(r.body));
|
|
1535
|
+
expect(bodies[1]).toEqual(bodies[0]);
|
|
1536
|
+
});
|
|
1537
|
+
|
|
1538
|
+
it('13. an update changes what the endpoint takes; an empty patch is 400; a delete cascades its deliveries', async () => {
|
|
1539
|
+
const hookG = await createEndpoint(actors.admin!, {
|
|
1540
|
+
url: `${base}/hook-g`,
|
|
1541
|
+
events: ['thing.created'],
|
|
1542
|
+
filters: { thing: ['t2'] }
|
|
1543
|
+
});
|
|
1544
|
+
const patched = await send('PATCH', `/webhooks/${hookG.id}`, actors.admin!, {
|
|
1545
|
+
events: ['thing.deleted'],
|
|
1546
|
+
filters: { thing: ['t2'] }
|
|
1547
|
+
});
|
|
1548
|
+
expect(patched.status).toBe(200);
|
|
1549
|
+
expect(await readJson(patched)).toMatchObject({
|
|
1550
|
+
events: ['thing.deleted'],
|
|
1551
|
+
filters: { thing: ['t2'] }
|
|
1552
|
+
});
|
|
1553
|
+
|
|
1554
|
+
const created = await witnessed(emitCreated('t2'));
|
|
1555
|
+
expect(await deliveryFor(hookG.id, created)).toBeUndefined();
|
|
1556
|
+
const deleted = await witnessed(emitDeleted('t2'));
|
|
1557
|
+
await waitFor('hook-g to receive thing.deleted', () => hitsAt('/hook-g', deleted).length > 0);
|
|
1558
|
+
|
|
1559
|
+
await expectError(
|
|
1560
|
+
await send('PATCH', `/webhooks/${hookG.id}`, actors.admin!, {}),
|
|
1561
|
+
400,
|
|
1562
|
+
'validation_error'
|
|
1563
|
+
);
|
|
1564
|
+
|
|
1565
|
+
const gone = await send('DELETE', `/webhooks/${hookG.id}`, actors.admin!);
|
|
1566
|
+
expect(gone.status).toBe(200);
|
|
1567
|
+
expect(await readJson(gone)).toMatchObject({ id: hookG.id, url: `${base}/hook-g` });
|
|
1568
|
+
await expectError(await send('GET', `/webhooks/${hookG.id}`, actors.admin!), 404, 'not_found');
|
|
1569
|
+
expect(await deliveriesOf(hookG.id)).toEqual([]);
|
|
1570
|
+
});
|
|
1571
|
+
});
|
|
1572
|
+
|
|
1573
|
+
// ── Who may ──────────────────────────────────────────────────────────────────
|
|
1574
|
+
|
|
1575
|
+
describe('machine principals and demo sessions', () => {
|
|
1576
|
+
it('14. read and write keys reach the catalog only; the webhooks scope opens the family to an admin, not to a member', async () => {
|
|
1577
|
+
const both = await mintKey(actors.admin!.cookie, [host.scopes.read, host.scopes.write]);
|
|
1578
|
+
await expectError(await send('GET', '/webhooks', { key: both }), 403, 'insufficient_scope');
|
|
1579
|
+
await expectError(
|
|
1580
|
+
await send('POST', '/webhooks', { key: both }, { url: `${base}/hook-k`, events: ['*'] }),
|
|
1581
|
+
403,
|
|
1582
|
+
'insufficient_scope'
|
|
1583
|
+
);
|
|
1584
|
+
expect((await send('GET', '/webhooks/events', { key: both })).status).toBe(200);
|
|
1585
|
+
|
|
1586
|
+
const adminKey = await mintKey(actors.admin!.cookie, [WEBHOOKS_SCOPE]);
|
|
1587
|
+
expect((await send('GET', '/webhooks', { key: adminKey })).status).toBe(200);
|
|
1588
|
+
const byKey = await createEndpoint(
|
|
1589
|
+
{ key: adminKey },
|
|
1590
|
+
{ url: `${base}/hook-k`, events: ['thing.deleted'], filters: { thing: ['t-k-never'] } }
|
|
1591
|
+
);
|
|
1592
|
+
expect(byKey.secret).toMatch(/^whsec_/);
|
|
1593
|
+
|
|
1594
|
+
const memberKey = await mintKey(actors.member!.cookie, [WEBHOOKS_SCOPE]);
|
|
1595
|
+
await expectError(await send('GET', '/webhooks', { key: memberKey }), 403, 'forbidden');
|
|
1596
|
+
await expectError(
|
|
1597
|
+
await send('POST', '/webhooks', { key: memberKey }, { url: `${base}/hook-k2`, events: ['*'] }),
|
|
1598
|
+
403,
|
|
1599
|
+
'forbidden'
|
|
1600
|
+
);
|
|
1601
|
+
});
|
|
1602
|
+
|
|
1603
|
+
it('15. a session a demo link opened reads but is refused every write (403 demo_session)', async () => {
|
|
1604
|
+
const victim = await createEndpoint(actors.admin!, {
|
|
1605
|
+
url: `${base}/hook-z`,
|
|
1606
|
+
events: ['thing.deleted'],
|
|
1607
|
+
filters: { thing: ['t-z-never'] }
|
|
1608
|
+
});
|
|
1609
|
+
const minted = await send('POST', '/demo/passes', actors.owner!, { email: actors.admin!.email });
|
|
1610
|
+
expect(minted.status).toBe(201);
|
|
1611
|
+
const { secret } = await readJson(minted);
|
|
1612
|
+
const redeemed = await send('POST', '/auth/demo/redeem', {}, { pass: secret });
|
|
1613
|
+
expect(redeemed.status).toBe(200);
|
|
1614
|
+
const demo = { cookie: extractCookie(redeemed) };
|
|
1615
|
+
|
|
1616
|
+
expect((await send('GET', '/webhooks', demo)).status).toBe(200);
|
|
1617
|
+
expect((await send('GET', `/webhooks/${victim.id}`, demo)).status).toBe(200);
|
|
1618
|
+
const code = 'demo_session';
|
|
1619
|
+
await expectError(
|
|
1620
|
+
await send('POST', '/webhooks', demo, { url: `${base}/hook-demo`, events: ['*'] }),
|
|
1621
|
+
403,
|
|
1622
|
+
code
|
|
1623
|
+
);
|
|
1624
|
+
await expectError(await send('PATCH', `/webhooks/${victim.id}`, demo, { description: 'x' }), 403, code);
|
|
1625
|
+
await expectError(await send('DELETE', `/webhooks/${victim.id}`, demo), 403, code);
|
|
1626
|
+
await expectError(await send('POST', `/webhooks/${victim.id}/rotate-secret`, demo), 403, code);
|
|
1627
|
+
await expectError(await send('POST', `/webhooks/${victim.id}/test`, demo), 403, code);
|
|
1628
|
+
await expectError(
|
|
1629
|
+
await send('POST', `/webhooks/${hookA.id}/deliveries/${firstDeliveryId}/redeliver`, demo),
|
|
1630
|
+
403,
|
|
1631
|
+
code
|
|
1632
|
+
);
|
|
1633
|
+
expect(await endpointRow(victim.id)).toMatchObject({ description: '', enabled: true });
|
|
1634
|
+
expect(requestsTo('/hook-z')).toEqual([]);
|
|
1635
|
+
|
|
1636
|
+
// The list the refusal is mounted from names the four webhook entries.
|
|
1637
|
+
const webhookEntries = DEMO_SESSION_REFUSED_API_ROUTES.filter((r) =>
|
|
1638
|
+
r.path.startsWith('/webhooks')
|
|
1639
|
+
).map((r) => `${r.method} ${r.path}`);
|
|
1640
|
+
expect(webhookEntries.sort()).toEqual(
|
|
1641
|
+
['DELETE /webhooks/*', 'PATCH /webhooks/*', 'POST /webhooks', 'POST /webhooks/*'].sort()
|
|
1642
|
+
);
|
|
1643
|
+
});
|
|
1644
|
+
});
|
|
1645
|
+
|
|
1646
|
+
// ── Housekeeping ─────────────────────────────────────────────────────────────
|
|
1647
|
+
|
|
1648
|
+
describe('housekeeping', () => {
|
|
1649
|
+
it('16. count answers the endpoints of one workspace, for the plan limit', async () => {
|
|
1650
|
+
const rows = await app.db.db
|
|
1651
|
+
.select({ id: webhookEndpoints.id })
|
|
1652
|
+
.from(webhookEndpoints)
|
|
1653
|
+
.where(eq(webhookEndpoints.workspaceId, workspaceId));
|
|
1654
|
+
expect(rows.length).toBeGreaterThan(5);
|
|
1655
|
+
expect(await app.webhooks.count(workspaceId)).toBe(rows.length);
|
|
1656
|
+
expect(await app.webhooks.count(otherWorkspaceId)).toBe(1);
|
|
1657
|
+
const empty = (await app.registry.workspaces.create('Empty', actors.owner!.userId)).workspaceId;
|
|
1658
|
+
expect(await app.webhooks.count(empty)).toBe(0);
|
|
1659
|
+
});
|
|
1660
|
+
|
|
1661
|
+
it('17. the purge deletes the deliveries past the retention and keeps the fresh ones', async () => {
|
|
1662
|
+
const old = new Date(Date.now() - 40 * 24 * 3600 * 1000);
|
|
1663
|
+
const [stale] = await app.db.db
|
|
1664
|
+
.insert(webhookDeliveries)
|
|
1665
|
+
.values({
|
|
1666
|
+
endpointId: hookA.id,
|
|
1667
|
+
workspaceId,
|
|
1668
|
+
eventId: '00000000-0000-4000-8000-000000000040',
|
|
1669
|
+
eventType: 'thing.created',
|
|
1670
|
+
payload: { stale: true },
|
|
1671
|
+
status: 'delivered',
|
|
1672
|
+
createdAt: old,
|
|
1673
|
+
updatedAt: old
|
|
1674
|
+
})
|
|
1675
|
+
.returning({ id: webhookDeliveries.id });
|
|
1676
|
+
const before = (await deliveriesOf(hookA.id)).length;
|
|
1677
|
+
expect(await app.webhooks.purge(30)).toBe(1);
|
|
1678
|
+
const after = await deliveriesOf(hookA.id);
|
|
1679
|
+
expect(after.map((d) => d.id)).not.toContain(stale!.id);
|
|
1680
|
+
expect(after).toHaveLength(before - 1);
|
|
1681
|
+
expect(after.map((d) => d.id)).toContain(firstDeliveryId);
|
|
1682
|
+
});
|
|
1683
|
+
});
|
|
1684
|
+
});
|