@antasphere/chassis-server 1.0.2 → 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/cli-package.d.ts +58 -0
- package/dist/api/cli-package.d.ts.map +1 -0
- package/dist/api/cli-package.js +83 -0
- package/dist/api/cli-package.js.map +1 -0
- 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 +33 -4
- package/dist/api/create-api.js.map +1 -1
- package/dist/api/index.d.ts +1 -0
- package/dist/api/index.d.ts.map +1 -1
- package/dist/api/index.js +1 -0
- package/dist/api/index.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/cli-package/index.d.ts +44 -0
- package/dist/cli-package/index.d.ts.map +1 -0
- package/dist/cli-package/index.js +60 -0
- package/dist/cli-package/index.js.map +1 -0
- 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 +11 -0
- package/dist/env.d.ts.map +1 -1
- package/dist/env.js +24 -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 +11 -3
- package/src/api/apikeys.ts +12 -1
- package/src/api/cli-package.ts +151 -0
- package/src/api/create-api.ts +42 -4
- package/src/api/index.ts +1 -0
- 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/cli-package/index.ts +88 -0
- package/src/email/index.ts +1 -0
- package/src/env.ts +25 -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/cli-package.test.ts +403 -0
- package/test/integration/mcp-webhooks.test.ts +360 -0
- package/test/integration/webhooks.test.ts +1684 -0
- package/test/unit/cli-package.test.ts +93 -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,360 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
2
|
+
import type { StartedPostgreSqlContainer } from '@testcontainers/postgresql';
|
|
3
|
+
import { workspaceMembers } from '@antasphere/chassis-db';
|
|
4
|
+
import {
|
|
5
|
+
createDatabase,
|
|
6
|
+
createTestApp,
|
|
7
|
+
extractCookie,
|
|
8
|
+
readJson,
|
|
9
|
+
startPostgres,
|
|
10
|
+
type TestApp,
|
|
11
|
+
SETUP_TOKEN,
|
|
12
|
+
host
|
|
13
|
+
} from './helpers.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The eleven WEBHOOK tools the chassis registers after the team reads
|
|
17
|
+
* (PRDCT-3482), driven as a real MCP client would: raw JSON-RPC POSTs against
|
|
18
|
+
* the stateless /mcp, authenticated with the host's own API keys.
|
|
19
|
+
*
|
|
20
|
+
* An owner runs the whole life of an endpoint through the tools alone (the
|
|
21
|
+
* catalog, create with its once-shown secret, list, read, change the events,
|
|
22
|
+
* rotate the secret, a test ping, its delivery read back and redelivered,
|
|
23
|
+
* delete), and the refusals read as words: a key without the webhooks scope
|
|
24
|
+
* is told which scope is missing yet still reads the catalog, and a plain
|
|
25
|
+
* member holding the scope is told only an owner or an admin manages
|
|
26
|
+
* webhooks. Nothing is delivered for real: the test ping targets a name that
|
|
27
|
+
* resolves to no address (the server-side suite covers delivery).
|
|
28
|
+
*
|
|
29
|
+
* Nothing here spells a tool's prefix, scope names or event types beyond the
|
|
30
|
+
* family the host's catalog carries: they come from `host`. A host whose tool
|
|
31
|
+
* declares no webhooks scope serves none of these tools, and the file skips.
|
|
32
|
+
*/
|
|
33
|
+
const PASSWORD = 'a-long-mcp-webhooks-password-1';
|
|
34
|
+
const OWNER = { email: 'owner@mcp-webhooks.test', name: 'Wanda Owner', password: PASSWORD };
|
|
35
|
+
|
|
36
|
+
const PREFIX = host.identity.mcp.toolPrefix;
|
|
37
|
+
const WEBHOOKS_SCOPE = host.identity.scopes.webhooks;
|
|
38
|
+
|
|
39
|
+
/** In `tools/list` order, the order the chassis registers them. */
|
|
40
|
+
const WEBHOOK_TOOLS = [
|
|
41
|
+
'webhook_events',
|
|
42
|
+
'list_webhooks',
|
|
43
|
+
'create_webhook',
|
|
44
|
+
'get_webhook',
|
|
45
|
+
'update_webhook',
|
|
46
|
+
'delete_webhook',
|
|
47
|
+
'rotate_webhook_secret',
|
|
48
|
+
'test_webhook',
|
|
49
|
+
'list_webhook_deliveries',
|
|
50
|
+
'get_webhook_delivery',
|
|
51
|
+
'redeliver_webhook'
|
|
52
|
+
].map((name) => `${PREFIX}${name}`);
|
|
53
|
+
|
|
54
|
+
const TOOL = {
|
|
55
|
+
events: `${PREFIX}webhook_events`,
|
|
56
|
+
list: `${PREFIX}list_webhooks`,
|
|
57
|
+
create: `${PREFIX}create_webhook`,
|
|
58
|
+
get: `${PREFIX}get_webhook`,
|
|
59
|
+
update: `${PREFIX}update_webhook`,
|
|
60
|
+
remove: `${PREFIX}delete_webhook`,
|
|
61
|
+
rotate: `${PREFIX}rotate_webhook_secret`,
|
|
62
|
+
test: `${PREFIX}test_webhook`,
|
|
63
|
+
deliveries: `${PREFIX}list_webhook_deliveries`,
|
|
64
|
+
delivery: `${PREFIX}get_webhook_delivery`,
|
|
65
|
+
redeliver: `${PREFIX}redeliver_webhook`
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** The address of the test ping: `.invalid` never resolves (RFC 2606), so nothing leaves the machine. */
|
|
69
|
+
const UNREACHABLE_URL = 'https://example.invalid/hooks';
|
|
70
|
+
|
|
71
|
+
let container: StartedPostgreSqlContainer;
|
|
72
|
+
let app: TestApp;
|
|
73
|
+
let workspaceId = '';
|
|
74
|
+
/** The owner's key holds read, write and webhooks; the other two prove the refusals. */
|
|
75
|
+
let ownerKey = '';
|
|
76
|
+
let noWebhooksKey = '';
|
|
77
|
+
let memberKey = '';
|
|
78
|
+
|
|
79
|
+
let ipCounter = 0;
|
|
80
|
+
const nextIp = () => `10.89.${Math.floor(ipCounter / 250)}.${(ipCounter++ % 250) + 1}`;
|
|
81
|
+
|
|
82
|
+
const json = (body: unknown, headers: Record<string, string> = {}) => ({
|
|
83
|
+
method: 'POST',
|
|
84
|
+
headers: { 'content-type': 'application/json', 'x-forwarded-for': nextIp(), ...headers },
|
|
85
|
+
body: JSON.stringify(body)
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
let rpcId = 0;
|
|
89
|
+
|
|
90
|
+
/** JSON-RPC result over the stateless /mcp (throws on protocol-level errors). */
|
|
91
|
+
async function rpc(key: string, method: string, params: unknown = {}) {
|
|
92
|
+
const res = await app.app.request('/mcp', {
|
|
93
|
+
method: 'POST',
|
|
94
|
+
headers: {
|
|
95
|
+
'content-type': 'application/json',
|
|
96
|
+
accept: 'application/json, text/event-stream',
|
|
97
|
+
'x-forwarded-for': nextIp(),
|
|
98
|
+
authorization: `Bearer ${key}`
|
|
99
|
+
},
|
|
100
|
+
body: JSON.stringify({ jsonrpc: '2.0', id: ++rpcId, method, params })
|
|
101
|
+
});
|
|
102
|
+
expect(res.status).toBe(200);
|
|
103
|
+
const body = await readJson(res);
|
|
104
|
+
expect(body.error, `JSON-RPC error for ${method}: ${JSON.stringify(body.error)}`).toBeUndefined();
|
|
105
|
+
return body.result;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Parse a tool's JSON text block; an error text is not JSON → null. */
|
|
109
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
110
|
+
function parseMaybe(text: string): any {
|
|
111
|
+
try {
|
|
112
|
+
return JSON.parse(text);
|
|
113
|
+
} catch {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function callTool(key: string, name: string, args: Record<string, unknown> = {}) {
|
|
119
|
+
const result = await rpc(key, 'tools/call', { name, arguments: args });
|
|
120
|
+
const text: string = result.content?.[0]?.text ?? '';
|
|
121
|
+
return { isError: result.isError === true, text, data: parseMaybe(text) };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The tool call must have succeeded — the text is shown when it did not. */
|
|
125
|
+
async function ok(key: string, name: string, args: Record<string, unknown> = {}) {
|
|
126
|
+
const res = await callTool(key, name, args);
|
|
127
|
+
expect(res.isError, `${name}: ${res.text}`).toBe(false);
|
|
128
|
+
return res.data;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function mintKey(cookie: string, name: string, scopes: string[]): Promise<string> {
|
|
132
|
+
const res = await app.app.request('/api/v1/api-keys', json({ name, scopes }, { cookie }));
|
|
133
|
+
expect(res.status, `minting ${name}`).toBe(201);
|
|
134
|
+
return (await readJson(res)).key as string;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function signIn(email: string): Promise<string> {
|
|
138
|
+
const res = await app.app.request('/api/v1/auth/sign-in/email', json({ email, password: PASSWORD }));
|
|
139
|
+
expect(res.status).toBe(200);
|
|
140
|
+
return extractCookie(res);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Under a real tool's host the catalog has no `thing.*` event: the file runs on the chassis's own host only.
|
|
144
|
+
describe.skipIf(!WEBHOOKS_SCOPE || !host.webhooks)('the webhook tools', () => {
|
|
145
|
+
const scope = WEBHOOKS_SCOPE!;
|
|
146
|
+
|
|
147
|
+
beforeAll(async () => {
|
|
148
|
+
container = await startPostgres();
|
|
149
|
+
app = await createTestApp(await createDatabase(container, 'mcp_webhooks'));
|
|
150
|
+
|
|
151
|
+
const setup = await app.app.request(
|
|
152
|
+
'/api/v1/setup',
|
|
153
|
+
json({ setupToken: SETUP_TOKEN, instanceName: 'MCP Webhooks', owner: OWNER })
|
|
154
|
+
);
|
|
155
|
+
expect(setup.status).toBe(201);
|
|
156
|
+
const ownerCookie = await signIn(OWNER.email);
|
|
157
|
+
ownerKey = await mintKey(ownerCookie, 'owner-webhooks', [host.scopes.read, host.scopes.write, scope]);
|
|
158
|
+
noWebhooksKey = await mintKey(ownerCookie, 'owner-rw', [host.scopes.read, host.scopes.write]);
|
|
159
|
+
workspaceId = (await readJson(await app.app.request('/api/v1/me', { headers: { cookie: ownerCookie } })))
|
|
160
|
+
.workspace.id;
|
|
161
|
+
|
|
162
|
+
const email = 'member@mcp-webhooks.test';
|
|
163
|
+
const created = await app.auth.api.signUpEmail({
|
|
164
|
+
body: { email, password: PASSWORD, name: 'Milo Member' }
|
|
165
|
+
});
|
|
166
|
+
await app.db.db
|
|
167
|
+
.insert(workspaceMembers)
|
|
168
|
+
.values({ workspaceId, userId: created.user.id, role: 'member', origin: 'local' });
|
|
169
|
+
memberKey = await mintKey(await signIn(email), 'member-webhooks', [host.scopes.read, scope]);
|
|
170
|
+
}, 240_000);
|
|
171
|
+
|
|
172
|
+
afterAll(async () => {
|
|
173
|
+
await app?.stop();
|
|
174
|
+
await container?.stop();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
describe('they are served under the host prefix', () => {
|
|
178
|
+
it('tools/list carries the eleven right after the team reads, reads flagged read-only', async () => {
|
|
179
|
+
interface ToolInfo {
|
|
180
|
+
name: string;
|
|
181
|
+
description: string;
|
|
182
|
+
inputSchema?: { properties?: Record<string, unknown> };
|
|
183
|
+
annotations?: { readOnlyHint?: boolean; destructiveHint?: boolean };
|
|
184
|
+
}
|
|
185
|
+
const tools = (await rpc(ownerKey, 'tools/list')).tools as ToolInfo[];
|
|
186
|
+
const names = tools.map((t) => t.name);
|
|
187
|
+
const after = names.indexOf(`${PREFIX}list_team_members`);
|
|
188
|
+
expect(after, 'the team reads are listed').toBeGreaterThan(-1);
|
|
189
|
+
expect(names.slice(after + 1, after + 1 + WEBHOOK_TOOLS.length)).toEqual(WEBHOOK_TOOLS);
|
|
190
|
+
|
|
191
|
+
const byName = new Map(tools.map((t) => [t.name, t]));
|
|
192
|
+
for (const name of WEBHOOK_TOOLS) {
|
|
193
|
+
expect(byName.get(name)!.inputSchema?.properties?.workspace, `${name} lacks workspace`).toBeDefined();
|
|
194
|
+
}
|
|
195
|
+
for (const name of [TOOL.events, TOOL.list, TOOL.get, TOOL.deliveries, TOOL.delivery]) {
|
|
196
|
+
expect(byName.get(name)!.annotations?.readOnlyHint, `${name} readOnlyHint`).toBe(true);
|
|
197
|
+
}
|
|
198
|
+
expect(byName.get(TOOL.remove)!.annotations?.destructiveHint).toBe(true);
|
|
199
|
+
expect(byName.get(TOOL.create)!.description).toContain('whsec_');
|
|
200
|
+
expect(byName.get(TOOL.create)!.description).toContain('Standard Webhooks');
|
|
201
|
+
expect(byName.get(TOOL.rotate)!.description).toContain('previousSecretExpiresAt');
|
|
202
|
+
expect(byName.get(TOOL.events)!.description).toContain('thing.*');
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
describe('an owner runs the life of an endpoint through the tools', () => {
|
|
207
|
+
let webhookId = '';
|
|
208
|
+
let firstSecret = '';
|
|
209
|
+
let deliveryId = '';
|
|
210
|
+
let eventId = '';
|
|
211
|
+
|
|
212
|
+
it('reads the catalog', async () => {
|
|
213
|
+
const catalog = await ok(ownerKey, TOOL.events);
|
|
214
|
+
const types = catalog.events.map((e: { type: string }) => e.type);
|
|
215
|
+
expect(types).toContain('thing.created');
|
|
216
|
+
const created = catalog.events.find((e: { type: string }) => e.type === 'thing.created');
|
|
217
|
+
expect(created.schema).toBeTypeOf('object');
|
|
218
|
+
expect(catalog.subjects.map((s: { key: string }) => s.key)).toContain('thing');
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('creates one, and the answer carries its secret once', async () => {
|
|
222
|
+
const created = await ok(ownerKey, TOOL.create, {
|
|
223
|
+
url: UNREACHABLE_URL,
|
|
224
|
+
events: ['thing.created'],
|
|
225
|
+
description: 'The MCP suite',
|
|
226
|
+
filters: { thing: ['t-1'] }
|
|
227
|
+
});
|
|
228
|
+
expect(created.secret).toMatch(/^whsec_/);
|
|
229
|
+
expect(created.url).toBe(UNREACHABLE_URL);
|
|
230
|
+
expect(created.events).toEqual(['thing.created']);
|
|
231
|
+
expect(created.filters).toEqual({ thing: ['t-1'] });
|
|
232
|
+
expect(created.enabled).toBe(true);
|
|
233
|
+
webhookId = created.id;
|
|
234
|
+
firstSecret = created.secret;
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('lists it and reads it back, never with the secret', async () => {
|
|
238
|
+
const list = await ok(ownerKey, TOOL.list);
|
|
239
|
+
expect(list.webhooks.map((w: { id: string }) => w.id)).toContain(webhookId);
|
|
240
|
+
expect(list).toHaveProperty('nextCursor');
|
|
241
|
+
const one = await ok(ownerKey, TOOL.get, { webhookId });
|
|
242
|
+
expect(one.id).toBe(webhookId);
|
|
243
|
+
expect(one).not.toHaveProperty('secret');
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('changes the events, and refuses a call that changes nothing', async () => {
|
|
247
|
+
const updated = await ok(ownerKey, TOOL.update, { webhookId, events: ['thing.*'] });
|
|
248
|
+
expect(updated.events).toEqual(['thing.*']);
|
|
249
|
+
expect(updated.filters).toEqual({ thing: ['t-1'] });
|
|
250
|
+
|
|
251
|
+
const nothing = await callTool(ownerKey, TOOL.update, { webhookId });
|
|
252
|
+
expect(nothing.isError).toBe(true);
|
|
253
|
+
expect(nothing.text).toContain('Nothing to change');
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it('refuses an address the instance does not deliver to, in words', async () => {
|
|
257
|
+
const refused = await callTool(ownerKey, TOOL.update, {
|
|
258
|
+
webhookId,
|
|
259
|
+
url: 'https://example.com:8443/hooks'
|
|
260
|
+
});
|
|
261
|
+
expect(refused.isError).toBe(true);
|
|
262
|
+
expect(refused.text).toContain('url_refused');
|
|
263
|
+
expect(refused.text).toContain(' — Next: ');
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('rotates the secret: a new one, the previous signing until a date', async () => {
|
|
267
|
+
const rotated = await ok(ownerKey, TOOL.rotate, { webhookId });
|
|
268
|
+
expect(rotated.secret).toMatch(/^whsec_/);
|
|
269
|
+
expect(rotated.secret).not.toBe(firstSecret);
|
|
270
|
+
expect(Date.parse(rotated.previousSecretExpiresAt)).toBeGreaterThan(Date.now());
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it('sends a test ping, which fails on an address that resolves to nothing', async () => {
|
|
274
|
+
const result = await ok(ownerKey, TOOL.test, { webhookId });
|
|
275
|
+
expect(result.delivery.eventType).toBe('webhook.ping');
|
|
276
|
+
expect(result.delivery.status).toBe('failed');
|
|
277
|
+
expect(result.delivery.lastError).toBeTruthy();
|
|
278
|
+
deliveryId = result.delivery.id;
|
|
279
|
+
eventId = result.delivery.eventId;
|
|
280
|
+
}, 30_000);
|
|
281
|
+
|
|
282
|
+
it('lists the delivery, by status too, and reads it', async () => {
|
|
283
|
+
const failed = await ok(ownerKey, TOOL.deliveries, { webhookId, status: 'failed' });
|
|
284
|
+
expect(failed.deliveries.map((d: { id: string }) => d.id)).toContain(deliveryId);
|
|
285
|
+
const delivered = await ok(ownerKey, TOOL.deliveries, { webhookId, status: 'delivered' });
|
|
286
|
+
expect(delivered.deliveries).toEqual([]);
|
|
287
|
+
const one = await ok(ownerKey, TOOL.delivery, { webhookId, deliveryId });
|
|
288
|
+
expect(one.id).toBe(deliveryId);
|
|
289
|
+
expect(one.payload).toBeTypeOf('object');
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it('redelivers it as a new delivery of the same event', async () => {
|
|
293
|
+
const again = await ok(ownerKey, TOOL.redeliver, { webhookId, deliveryId });
|
|
294
|
+
expect(again.id).not.toBe(deliveryId);
|
|
295
|
+
expect(again.eventId).toBe(eventId);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it('deletes it, and it is gone', async () => {
|
|
299
|
+
const removed = await ok(ownerKey, TOOL.remove, { webhookId });
|
|
300
|
+
expect(removed.id).toBe(webhookId);
|
|
301
|
+
const list = await ok(ownerKey, TOOL.list);
|
|
302
|
+
expect(list.webhooks.map((w: { id: string }) => w.id)).not.toContain(webhookId);
|
|
303
|
+
const gone = await callTool(ownerKey, TOOL.get, { webhookId });
|
|
304
|
+
expect(gone.isError).toBe(true);
|
|
305
|
+
expect(gone.text).toContain('not_found');
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
describe('the refusals read as words', () => {
|
|
310
|
+
it('a key without the webhooks scope is told so, and still reads the catalog', async () => {
|
|
311
|
+
const denied = await callTool(noWebhooksKey, TOOL.list);
|
|
312
|
+
expect(denied.isError).toBe(true);
|
|
313
|
+
expect(denied.text).toContain(`Missing scope "${scope}"`);
|
|
314
|
+
|
|
315
|
+
const catalog = await ok(noWebhooksKey, TOOL.events);
|
|
316
|
+
expect(catalog.events.map((e: { type: string }) => e.type)).toContain('thing.created');
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it('a plain member holding the scope is told only an owner or an admin manages webhooks', async () => {
|
|
320
|
+
const refused = await callTool(memberKey, TOOL.list);
|
|
321
|
+
expect(refused.isError).toBe(true);
|
|
322
|
+
expect(refused.text).toContain('code: forbidden');
|
|
323
|
+
const hint = refused.text.split(' — Next: ')[1];
|
|
324
|
+
expect(hint, `no "Next:" sentence in: ${refused.text}`).toMatch(/owner or an admin/);
|
|
325
|
+
|
|
326
|
+
const create = await callTool(memberKey, TOOL.create, { url: UNREACHABLE_URL, events: ['*'] });
|
|
327
|
+
expect(create.isError).toBe(true);
|
|
328
|
+
expect(create.text).toContain('code: forbidden');
|
|
329
|
+
|
|
330
|
+
// The catalog stays every member's.
|
|
331
|
+
await ok(memberKey, TOOL.events);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it('an unknown event type or filter key is told where the catalog is (N-R2-1)', async () => {
|
|
335
|
+
const unknownEvent = await callTool(ownerKey, TOOL.create, {
|
|
336
|
+
url: UNREACHABLE_URL,
|
|
337
|
+
events: ['nothing.*']
|
|
338
|
+
});
|
|
339
|
+
expect(unknownEvent.isError).toBe(true);
|
|
340
|
+
expect(unknownEvent.text).toContain('unknown_event');
|
|
341
|
+
const eventHint = unknownEvent.text.split(' — Next: ')[1];
|
|
342
|
+
expect(eventHint, `no "Next:" sentence in: ${unknownEvent.text}`).toContain(TOOL.events);
|
|
343
|
+
expect(eventHint).toContain('`.*`');
|
|
344
|
+
|
|
345
|
+
const unknownSubject = await callTool(ownerKey, TOOL.create, {
|
|
346
|
+
url: UNREACHABLE_URL,
|
|
347
|
+
events: ['thing.created'],
|
|
348
|
+
filters: { bogus: ['x'] }
|
|
349
|
+
});
|
|
350
|
+
expect(unknownSubject.isError).toBe(true);
|
|
351
|
+
expect(unknownSubject.text).toContain('unknown_subject');
|
|
352
|
+
const subjectHint = unknownSubject.text.split(' — Next: ')[1];
|
|
353
|
+
expect(subjectHint, `no "Next:" sentence in: ${unknownSubject.text}`).toContain(TOOL.events);
|
|
354
|
+
|
|
355
|
+
// Neither refusal left an endpoint behind.
|
|
356
|
+
const list = await ok(ownerKey, TOOL.list);
|
|
357
|
+
expect(list.webhooks.map((w: { url: string }) => w.url)).not.toContain(UNREACHABLE_URL);
|
|
358
|
+
});
|
|
359
|
+
});
|
|
360
|
+
});
|