@antasphere/chassis-server 1.1.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/app.d.ts.map +1 -1
- package/dist/app.js +11 -5
- package/dist/app.js.map +1 -1
- 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/index.d.ts +1 -0
- package/dist/middleware/index.d.ts.map +1 -1
- package/dist/middleware/index.js +1 -0
- package/dist/middleware/index.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/middleware/static-assets.d.ts +63 -0
- package/dist/middleware/static-assets.d.ts.map +1 -0
- package/dist/middleware/static-assets.js +125 -0
- package/dist/middleware/static-assets.js.map +1 -0
- 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/app.ts +17 -4
- 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/index.ts +11 -0
- package/src/middleware/scopes.ts +14 -0
- package/src/middleware/static-assets.ts +144 -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/static-assets.test.ts +227 -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,124 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { createSecretBox, SecretBoxError } from '../../src/secret-box.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The readable-secret box: AES-256-GCM under an HKDF key per (auth secret,
|
|
6
|
+
* info). Every refusal is a SecretBoxError, a malformed string included, and
|
|
7
|
+
* nothing that was changed ever opens to garbage.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const AUTH_SECRET = 'unit-test-secret-0123456789abcdef0123456789abcdef';
|
|
11
|
+
const INFO = 'webhooks:signing-secret';
|
|
12
|
+
const PLAIN = 'whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw';
|
|
13
|
+
|
|
14
|
+
const box = createSecretBox(AUTH_SECRET, INFO);
|
|
15
|
+
|
|
16
|
+
/** Flip one bit of the decoded bytes of part `index` (1 = iv, 2 = tag, 3 = ciphertext). */
|
|
17
|
+
function flipByte(sealed: string, index: 1 | 2 | 3): string {
|
|
18
|
+
const parts = sealed.split('.');
|
|
19
|
+
const bytes = Buffer.from(parts[index]!, 'base64url');
|
|
20
|
+
bytes[0] = bytes[0]! ^ 0x01;
|
|
21
|
+
parts[index] = bytes.toString('base64url');
|
|
22
|
+
return parts.join('.');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function expectRefused(open: () => string): void {
|
|
26
|
+
let thrown: unknown;
|
|
27
|
+
try {
|
|
28
|
+
open();
|
|
29
|
+
} catch (error) {
|
|
30
|
+
thrown = error;
|
|
31
|
+
}
|
|
32
|
+
expect(thrown).toBeInstanceOf(SecretBoxError);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe('createSecretBox', () => {
|
|
36
|
+
it('round-trips a value', () => {
|
|
37
|
+
expect(box.open(box.seal(PLAIN))).toBe(PLAIN);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('round-trips the empty string', () => {
|
|
41
|
+
expect(box.open(box.seal(''))).toBe('');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('spells v1.<iv>.<tag>.<ciphertext> in base64url without the plaintext', () => {
|
|
45
|
+
const sealed = box.seal(PLAIN);
|
|
46
|
+
const parts = sealed.split('.');
|
|
47
|
+
expect(parts).toHaveLength(4);
|
|
48
|
+
expect(parts[0]).toBe('v1');
|
|
49
|
+
expect(Buffer.from(parts[1]!, 'base64url')).toHaveLength(12);
|
|
50
|
+
expect(Buffer.from(parts[2]!, 'base64url')).toHaveLength(16);
|
|
51
|
+
for (const part of parts.slice(1)) expect(part).toMatch(/^[A-Za-z0-9_-]+$/);
|
|
52
|
+
expect(sealed).not.toContain('MfKQ9r8');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('seals one value twice to two different strings, both of which open', () => {
|
|
56
|
+
const first = box.seal(PLAIN);
|
|
57
|
+
const second = box.seal(PLAIN);
|
|
58
|
+
expect(first).not.toBe(second);
|
|
59
|
+
expect(first.split('.')[1]).not.toBe(second.split('.')[1]);
|
|
60
|
+
expect(box.open(first)).toBe(PLAIN);
|
|
61
|
+
expect(box.open(second)).toBe(PLAIN);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it.each([
|
|
65
|
+
['the iv', 1],
|
|
66
|
+
['the tag', 2],
|
|
67
|
+
['the ciphertext', 3]
|
|
68
|
+
] as const)('refuses a flipped byte in %s', (_name, index) => {
|
|
69
|
+
const sealed = box.seal(PLAIN);
|
|
70
|
+
expectRefused(() => box.open(flipByte(sealed, index)));
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('refuses a value sealed under another info', () => {
|
|
74
|
+
const other = createSecretBox(AUTH_SECRET, 'webhooks:other-purpose');
|
|
75
|
+
expectRefused(() => other.open(box.seal(PLAIN)));
|
|
76
|
+
expectRefused(() => box.open(other.seal(PLAIN)));
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('refuses a value sealed under another auth secret', () => {
|
|
80
|
+
const other = createSecretBox('another-auth-secret-0123456789abcdef0123456789', INFO);
|
|
81
|
+
expectRefused(() => other.open(box.seal(PLAIN)));
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('opens a value sealed by another box of the same auth secret and info', () => {
|
|
85
|
+
const twin = createSecretBox(AUTH_SECRET, INFO);
|
|
86
|
+
expect(twin.open(box.seal(PLAIN))).toBe(PLAIN);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('keeps unicode plaintext intact', () => {
|
|
90
|
+
const unicode = 'clé secrète: 🔐 — 秘密 — Ωμέγα\u0000end';
|
|
91
|
+
expect(box.open(box.seal(unicode))).toBe(unicode);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe('a malformed string is a SecretBoxError and nothing else', () => {
|
|
95
|
+
const sealed = box.seal(PLAIN);
|
|
96
|
+
const [, iv, tag, ciphertext] = sealed.split('.') as [string, string, string, string];
|
|
97
|
+
const cases: Array<[string, unknown]> = [
|
|
98
|
+
['the empty string', ''],
|
|
99
|
+
['no separators', 'garbage'],
|
|
100
|
+
['three parts', `v1.${iv}.${tag}`],
|
|
101
|
+
['five parts', `${sealed}.extra`],
|
|
102
|
+
['an unknown version', `v2.${iv}.${tag}.${ciphertext}`],
|
|
103
|
+
['an empty version', `.${iv}.${tag}.${ciphertext}`],
|
|
104
|
+
['a short iv', `v1.${iv.slice(0, 8)}.${tag}.${ciphertext}`],
|
|
105
|
+
['a long iv', `v1.${iv}AAAA.${tag}.${ciphertext}`],
|
|
106
|
+
['an empty iv', `v1..${tag}.${ciphertext}`],
|
|
107
|
+
['a short tag', `v1.${iv}.${tag.slice(0, 10)}.${ciphertext}`],
|
|
108
|
+
['an empty tag', `v1.${iv}..${ciphertext}`],
|
|
109
|
+
['an empty ciphertext', `v1.${iv}.${tag}.`],
|
|
110
|
+
['a character outside base64url', `v1.${iv}.${tag}.${ciphertext}*`],
|
|
111
|
+
['standard base64 characters', `v1.${iv}.${tag}.+/${ciphertext}`],
|
|
112
|
+
['a non-string', null],
|
|
113
|
+
['a number', 42]
|
|
114
|
+
];
|
|
115
|
+
it.each(cases)('refuses %s', (_name, input) => {
|
|
116
|
+
expectRefused(() => box.open(input as string));
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('refuses an empty auth secret or an empty info', () => {
|
|
121
|
+
expect(() => createSecretBox('', INFO)).toThrow(SecretBoxError);
|
|
122
|
+
expect(() => createSecretBox(AUTH_SECRET, '')).toThrow(SecretBoxError);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
2
|
+
import { Hono } from 'hono';
|
|
3
|
+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { brotliCompressSync, gzipSync } from 'node:zlib';
|
|
7
|
+
import {
|
|
8
|
+
IMMUTABLE_CACHE_CONTROL,
|
|
9
|
+
REVALIDATE_CACHE_CONTROL,
|
|
10
|
+
acceptedEncodings,
|
|
11
|
+
loadSpaFallback,
|
|
12
|
+
staticAssets,
|
|
13
|
+
staticCacheControl
|
|
14
|
+
} from '@antasphere/chassis-server/middleware';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* PRDCT-3584. The dashboard build went out with no cache policy and no
|
|
18
|
+
* compression, so every visit re-downloaded it. Three properties:
|
|
19
|
+
*
|
|
20
|
+
* 1. a content-hashed asset (`/_app/immutable/*`) is cached for a year and
|
|
21
|
+
* marked immutable; nothing else ever is;
|
|
22
|
+
* 2. a precompressed sibling is served when the request accepts its
|
|
23
|
+
* encoding, with `Content-Encoding` and `Vary`, and the plain file when
|
|
24
|
+
* it does not;
|
|
25
|
+
* 3. a path that is not a file falls through to the SPA fallback, which is
|
|
26
|
+
* revalidated on every use.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
const CHUNK = 'export const answer = 42; '.repeat(40);
|
|
30
|
+
let publicDir: string;
|
|
31
|
+
let app: Hono;
|
|
32
|
+
|
|
33
|
+
beforeAll(() => {
|
|
34
|
+
publicDir = mkdtempSync(join(tmpdir(), 'chassis-static-'));
|
|
35
|
+
mkdirSync(join(publicDir, '_app', 'immutable', 'chunks'), { recursive: true });
|
|
36
|
+
const chunk = join(publicDir, '_app', 'immutable', 'chunks', 'abc123.js');
|
|
37
|
+
writeFileSync(chunk, CHUNK);
|
|
38
|
+
writeFileSync(`${chunk}.br`, brotliCompressSync(Buffer.from(CHUNK)));
|
|
39
|
+
writeFileSync(`${chunk}.gz`, gzipSync(Buffer.from(CHUNK)));
|
|
40
|
+
// a hashed asset with no compressed sibling
|
|
41
|
+
writeFileSync(join(publicDir, '_app', 'immutable', 'chunks', 'plain.js'), CHUNK);
|
|
42
|
+
writeFileSync(join(publicDir, 'index.html'), '<!doctype html><title>t</title>');
|
|
43
|
+
writeFileSync(join(publicDir, 'favicon.png'), Buffer.from([0x89, 0x50, 0x4e, 0x47]));
|
|
44
|
+
|
|
45
|
+
app = new Hono();
|
|
46
|
+
app.use('*', staticAssets({ publicDir }));
|
|
47
|
+
app.get('*', (c) => {
|
|
48
|
+
c.header('Cache-Control', REVALIDATE_CACHE_CONTROL);
|
|
49
|
+
return c.html('<!doctype html><title>fallback</title>');
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
afterAll(() => {
|
|
54
|
+
rmSync(publicDir, { recursive: true, force: true });
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe('staticCacheControl', () => {
|
|
58
|
+
it('marks only the content-hashed prefix immutable', () => {
|
|
59
|
+
expect(staticCacheControl('/_app/immutable/chunks/abc123.js')).toBe(IMMUTABLE_CACHE_CONTROL);
|
|
60
|
+
expect(staticCacheControl('/_app/immutable/entry/start.js')).toBe(IMMUTABLE_CACHE_CONTROL);
|
|
61
|
+
expect(staticCacheControl('/_app/version.json')).toBe(REVALIDATE_CACHE_CONTROL);
|
|
62
|
+
expect(staticCacheControl('/index.html')).toBe(REVALIDATE_CACHE_CONTROL);
|
|
63
|
+
expect(staticCacheControl('/favicon.png')).toBe(REVALIDATE_CACHE_CONTROL);
|
|
64
|
+
expect(staticCacheControl('/')).toBe(REVALIDATE_CACHE_CONTROL);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe('staticAssets', () => {
|
|
69
|
+
it('serves a hashed asset immutable for a year', async () => {
|
|
70
|
+
const res = await app.request('/_app/immutable/chunks/plain.js');
|
|
71
|
+
expect(res.status).toBe(200);
|
|
72
|
+
expect(res.headers.get('cache-control')).toBe(IMMUTABLE_CACHE_CONTROL);
|
|
73
|
+
expect(res.headers.get('content-encoding')).toBeNull();
|
|
74
|
+
expect(res.headers.get('vary')).toBe('Accept-Encoding');
|
|
75
|
+
expect(await res.text()).toBe(CHUNK);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('serves the brotli sibling when the request accepts br', async () => {
|
|
79
|
+
const res = await app.request('/_app/immutable/chunks/abc123.js', {
|
|
80
|
+
headers: { 'accept-encoding': 'gzip, deflate, br, zstd' }
|
|
81
|
+
});
|
|
82
|
+
expect(res.status).toBe(200);
|
|
83
|
+
expect(res.headers.get('content-encoding')).toBe('br');
|
|
84
|
+
expect(res.headers.get('vary')).toContain('Accept-Encoding');
|
|
85
|
+
expect(res.headers.get('cache-control')).toBe(IMMUTABLE_CACHE_CONTROL);
|
|
86
|
+
expect(res.headers.get('content-type')).toContain('javascript');
|
|
87
|
+
const body = Buffer.from(await res.arrayBuffer());
|
|
88
|
+
expect(body.equals(brotliCompressSync(Buffer.from(CHUNK)))).toBe(true);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('serves the gzip sibling when only gzip is accepted', async () => {
|
|
92
|
+
const res = await app.request('/_app/immutable/chunks/abc123.js', {
|
|
93
|
+
headers: { 'accept-encoding': 'gzip' }
|
|
94
|
+
});
|
|
95
|
+
expect(res.headers.get('content-encoding')).toBe('gzip');
|
|
96
|
+
const body = Buffer.from(await res.arrayBuffer());
|
|
97
|
+
expect(body.equals(gzipSync(Buffer.from(CHUNK)))).toBe(true);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('serves the plain file when no encoding is accepted', async () => {
|
|
101
|
+
const res = await app.request('/_app/immutable/chunks/abc123.js');
|
|
102
|
+
expect(res.headers.get('content-encoding')).toBeNull();
|
|
103
|
+
expect(await res.text()).toBe(CHUNK);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('revalidates every static file outside the hashed prefix', async () => {
|
|
107
|
+
const icon = await app.request('/favicon.png');
|
|
108
|
+
expect(icon.status).toBe(200);
|
|
109
|
+
expect(icon.headers.get('cache-control')).toBe(REVALIDATE_CACHE_CONTROL);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('leaves the application HTML itself to the fallback', async () => {
|
|
113
|
+
for (const path of ['/', '/index.html']) {
|
|
114
|
+
const res = await app.request(path);
|
|
115
|
+
expect(res.status, path).toBe(200);
|
|
116
|
+
expect(res.headers.get('cache-control'), path).toBe(REVALIDATE_CACHE_CONTROL);
|
|
117
|
+
expect(await res.text(), path).toContain('fallback');
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('falls through to the SPA fallback on an application route, revalidated', async () => {
|
|
122
|
+
const res = await app.request('/login');
|
|
123
|
+
expect(res.status).toBe(200);
|
|
124
|
+
expect(res.headers.get('cache-control')).toBe(REVALIDATE_CACHE_CONTROL);
|
|
125
|
+
expect(await res.text()).toContain('fallback');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('never marks a non-hashed response immutable', async () => {
|
|
129
|
+
for (const path of ['/', '/index.html', '/favicon.png', '/login', '/_app/version.json']) {
|
|
130
|
+
const res = await app.request(path);
|
|
131
|
+
expect(res.headers.get('cache-control'), path).not.toContain('immutable');
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe('the SPA fallback', () => {
|
|
137
|
+
const html = '<!doctype html><title>app</title><script>1</script>';
|
|
138
|
+
let dir: string;
|
|
139
|
+
let fallbackApp: Hono;
|
|
140
|
+
let etag: string;
|
|
141
|
+
|
|
142
|
+
beforeAll(async () => {
|
|
143
|
+
dir = mkdtempSync(join(tmpdir(), 'chassis-fallback-'));
|
|
144
|
+
writeFileSync(join(dir, 'index.html'), html);
|
|
145
|
+
writeFileSync(join(dir, 'index.html.br'), brotliCompressSync(Buffer.from(html)));
|
|
146
|
+
// no .gz sibling on purpose: a gzip-only client gets the plain HTML
|
|
147
|
+
const fallback = await loadSpaFallback(dir, html);
|
|
148
|
+
etag = fallback.etag;
|
|
149
|
+
fallbackApp = new Hono();
|
|
150
|
+
fallbackApp.use('*', staticAssets({ publicDir: dir }));
|
|
151
|
+
fallbackApp.get('*', (c) => fallback.respond(c));
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
afterAll(() => {
|
|
155
|
+
rmSync(dir, { recursive: true, force: true });
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('answers a deep link with the plain HTML, a strong ETag and no-cache', async () => {
|
|
159
|
+
const res = await fallbackApp.request('/settings/members');
|
|
160
|
+
expect(res.status).toBe(200);
|
|
161
|
+
expect(res.headers.get('content-type')).toContain('text/html');
|
|
162
|
+
expect(res.headers.get('cache-control')).toBe(REVALIDATE_CACHE_CONTROL);
|
|
163
|
+
expect(res.headers.get('etag')).toBe(etag);
|
|
164
|
+
expect(res.headers.get('content-encoding')).toBeNull();
|
|
165
|
+
expect(res.headers.get('vary')).toBe('Accept-Encoding');
|
|
166
|
+
expect(await res.text()).toBe(html);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('answers 304 to a matching If-None-Match, with no body and the HTML type', async () => {
|
|
170
|
+
const res = await fallbackApp.request('/settings/members', { headers: { 'if-none-match': etag } });
|
|
171
|
+
expect(res.status).toBe(304);
|
|
172
|
+
expect(await res.text()).toBe('');
|
|
173
|
+
expect(res.headers.get('etag')).toBe(etag);
|
|
174
|
+
expect(res.headers.get('cache-control')).toBe(REVALIDATE_CACHE_CONTROL);
|
|
175
|
+
expect(res.headers.get('content-type')).toContain('text/html');
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('answers the root and /index.html with the validator too', async () => {
|
|
179
|
+
for (const path of ['/', '/index.html']) {
|
|
180
|
+
const res = await fallbackApp.request(path);
|
|
181
|
+
expect(res.status, path).toBe(200);
|
|
182
|
+
expect(res.headers.get('etag'), path).toBe(etag);
|
|
183
|
+
const again = await fallbackApp.request(path, { headers: { 'if-none-match': etag } });
|
|
184
|
+
expect(again.status, path).toBe(304);
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('serves the brotli sibling to a client that accepts br, under its own validator', async () => {
|
|
189
|
+
const res = await fallbackApp.request('/login', {
|
|
190
|
+
headers: { 'accept-encoding': 'gzip, deflate, br;q=1.0' }
|
|
191
|
+
});
|
|
192
|
+
expect(res.status).toBe(200);
|
|
193
|
+
expect(res.headers.get('content-encoding')).toBe('br');
|
|
194
|
+
const variant = res.headers.get('etag')!;
|
|
195
|
+
expect(variant).not.toBe(etag);
|
|
196
|
+
expect(variant.endsWith('-br"')).toBe(true);
|
|
197
|
+
const body = Buffer.from(await res.arrayBuffer());
|
|
198
|
+
expect(body.equals(brotliCompressSync(Buffer.from(html)))).toBe(true);
|
|
199
|
+
const again = await fallbackApp.request('/login', {
|
|
200
|
+
headers: { 'accept-encoding': 'br', 'if-none-match': variant }
|
|
201
|
+
});
|
|
202
|
+
expect(again.status).toBe(304);
|
|
203
|
+
// the plain validator never confirms the compressed body
|
|
204
|
+
const cross = await fallbackApp.request('/login', {
|
|
205
|
+
headers: { 'accept-encoding': 'br', 'if-none-match': etag }
|
|
206
|
+
});
|
|
207
|
+
expect(cross.status).toBe(200);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('falls back to the plain HTML when the accepted encoding has no sibling', async () => {
|
|
211
|
+
const res = await fallbackApp.request('/login', { headers: { 'accept-encoding': 'gzip' } });
|
|
212
|
+
expect(res.status).toBe(200);
|
|
213
|
+
expect(res.headers.get('content-encoding')).toBeNull();
|
|
214
|
+
expect(await res.text()).toBe(html);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('reads Accept-Encoding tokens with their weights stripped', () => {
|
|
218
|
+
expect([...acceptedEncodings('gzip, deflate, br;q=1.0, zstd')]).toEqual([
|
|
219
|
+
'gzip',
|
|
220
|
+
'deflate',
|
|
221
|
+
'br',
|
|
222
|
+
'zstd'
|
|
223
|
+
]);
|
|
224
|
+
expect([...acceptedEncodings(undefined)]).toEqual([]);
|
|
225
|
+
expect(acceptedEncodings(' BR ').has('br')).toBe(true);
|
|
226
|
+
});
|
|
227
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import pg from 'pg';
|
|
3
|
+
import { drizzle } from 'drizzle-orm/node-postgres';
|
|
4
|
+
import { connectionOf } from '../../src/webhooks/tx-send.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The transactional send reaches the connection behind a drizzle handle
|
|
8
|
+
* through the driver binding's session (`session.client`): this pins the
|
|
9
|
+
* shape against the installed drizzle, so a bump that moves it fails here
|
|
10
|
+
* and not in a delivery.
|
|
11
|
+
*/
|
|
12
|
+
describe('tx-send: the connection behind a drizzle handle', () => {
|
|
13
|
+
it('a plain handle exposes its pool, with query', () => {
|
|
14
|
+
const pool = new pg.Pool({ connectionString: 'postgres://nobody@127.0.0.1:1/none' });
|
|
15
|
+
const db = drizzle(pool);
|
|
16
|
+
expect(connectionOf(db)).toBe(pool);
|
|
17
|
+
expect(typeof connectionOf(db).query).toBe('function');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('something that is not a drizzle handle is refused, naming the shape', () => {
|
|
21
|
+
expect(() => connectionOf({} as never)).toThrow(/session\.client/);
|
|
22
|
+
});
|
|
23
|
+
});
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import {
|
|
4
|
+
buildWebhookCatalog,
|
|
5
|
+
catalogToWire,
|
|
6
|
+
CHASSIS_WEBHOOK_EVENTS,
|
|
7
|
+
type WebhookCatalogDeclaration,
|
|
8
|
+
type WebhookEventDeclaration
|
|
9
|
+
} from '../../src/webhooks/catalog.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The boot check of the webhooks catalog (PRDCT-3482, verifier round 1, G7):
|
|
13
|
+
* the chassis's events merged with the tool's, every fault named at boot, and
|
|
14
|
+
* the wire form `GET /webhooks/events` answers.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const thingCreated: WebhookEventDeclaration = {
|
|
18
|
+
type: 'thing.created',
|
|
19
|
+
description: 'A thing was created.',
|
|
20
|
+
subjects: ['thing', 'project'],
|
|
21
|
+
data: z.object({ thingId: z.string(), name: z.string() })
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const tool = (over: Partial<WebhookCatalogDeclaration> = {}): WebhookCatalogDeclaration => ({
|
|
25
|
+
subjects: { thing: { field: 'thingId', description: 'A thing, by id' } },
|
|
26
|
+
events: [thingCreated],
|
|
27
|
+
...over
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe('buildWebhookCatalog', () => {
|
|
31
|
+
it('merges the chassis events with the tool events', () => {
|
|
32
|
+
const catalog = buildWebhookCatalog(tool());
|
|
33
|
+
for (const e of CHASSIS_WEBHOOK_EVENTS) expect(catalog.events.has(e.type), e.type).toBe(true);
|
|
34
|
+
expect(catalog.events.get('thing.created')).toBe(thingCreated);
|
|
35
|
+
expect(Object.keys(catalog.subjects).sort()).toEqual(['project', 'thing']);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('a tool with no webhooks slot gets the chassis catalog alone', () => {
|
|
39
|
+
const catalog = buildWebhookCatalog(undefined);
|
|
40
|
+
expect([...catalog.events.keys()].sort()).toEqual(CHASSIS_WEBHOOK_EVENTS.map((e) => e.type).sort());
|
|
41
|
+
expect(catalog.subjects.project).toMatchObject({ field: 'projectId' });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('refuses a type declared twice', () => {
|
|
45
|
+
expect(() => buildWebhookCatalog(tool({ events: [thingCreated, { ...thingCreated }] }))).toThrow(
|
|
46
|
+
/"thing\.created" is declared twice/
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('refuses a tool re-declaring a chassis type', () => {
|
|
51
|
+
expect(() =>
|
|
52
|
+
buildWebhookCatalog(tool({ events: [{ ...thingCreated, type: 'project.created' }] }))
|
|
53
|
+
).toThrow(/"project\.created" is declared twice/);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('refuses an event carrying a subject nobody declared', () => {
|
|
57
|
+
expect(() =>
|
|
58
|
+
buildWebhookCatalog(tool({ events: [{ ...thingCreated, subjects: ['thing', 'widget'] }] }))
|
|
59
|
+
).toThrow(/carries the subject "widget", which webhooks\.subjects does not declare/);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it.each(['Thing.created', 'thing', 'thing.created.now', 'thing-x.created', '1thing.created', 'thing.'])(
|
|
63
|
+
'refuses the malformed type %s',
|
|
64
|
+
(type) => {
|
|
65
|
+
expect(() => buildWebhookCatalog(tool({ events: [{ ...thingCreated, type }] }))).toThrow(
|
|
66
|
+
/must be family\.verb/
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
it.each(['*', 'thing.*'])('refuses the pattern %s as a type', (type) => {
|
|
72
|
+
expect(() => buildWebhookCatalog(tool({ events: [{ ...thingCreated, type }] }))).toThrow(
|
|
73
|
+
new RegExp(`"${type.replace(/[.*]/g, '\\$&')}"`)
|
|
74
|
+
);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('refuses an event without a description', () => {
|
|
78
|
+
expect(() => buildWebhookCatalog(tool({ events: [{ ...thingCreated, description: '' }] }))).toThrow(
|
|
79
|
+
/"thing\.created" needs a description/
|
|
80
|
+
);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('refuses a malformed subject key and a subject without a field', () => {
|
|
84
|
+
expect(() =>
|
|
85
|
+
buildWebhookCatalog(tool({ subjects: { 'Bad-Key': { field: 'x', description: 'x' } } }))
|
|
86
|
+
).toThrow(/webhooks\.subjects key "Bad-Key"/);
|
|
87
|
+
expect(() => buildWebhookCatalog(tool({ subjects: { thing: { field: '', description: 'x' } } }))).toThrow(
|
|
88
|
+
/webhooks\.subjects\.thing\.field is required/
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('accepts a tool renaming the project subject’s field (a list under projectIds)', () => {
|
|
93
|
+
const catalog = buildWebhookCatalog(
|
|
94
|
+
tool({
|
|
95
|
+
subjects: {
|
|
96
|
+
thing: { field: 'thingId', description: 'A thing, by id' },
|
|
97
|
+
project: { field: 'projectIds', description: 'The projects of a thing', list: true }
|
|
98
|
+
}
|
|
99
|
+
})
|
|
100
|
+
);
|
|
101
|
+
expect(catalog.subjects.project).toEqual({
|
|
102
|
+
field: 'projectIds',
|
|
103
|
+
description: 'The projects of a thing',
|
|
104
|
+
list: true
|
|
105
|
+
});
|
|
106
|
+
// The chassis's own project events now carry the tool's declaration.
|
|
107
|
+
expect(catalog.events.get('project.created')?.subjects).toEqual(['project']);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe('catalogToWire', () => {
|
|
112
|
+
it('carries list: true for a list subject and false otherwise, and the JSON Schema of every event', () => {
|
|
113
|
+
const wire = catalogToWire(
|
|
114
|
+
buildWebhookCatalog(
|
|
115
|
+
tool({
|
|
116
|
+
subjects: {
|
|
117
|
+
thing: { field: 'thingId', description: 'A thing, by id' },
|
|
118
|
+
project: { field: 'projectIds', description: 'The projects of a thing', list: true }
|
|
119
|
+
}
|
|
120
|
+
})
|
|
121
|
+
)
|
|
122
|
+
);
|
|
123
|
+
expect(wire.subjects).toEqual(
|
|
124
|
+
expect.arrayContaining([
|
|
125
|
+
{ key: 'thing', field: 'thingId', description: 'A thing, by id', list: false },
|
|
126
|
+
{ key: 'project', field: 'projectIds', description: 'The projects of a thing', list: true }
|
|
127
|
+
])
|
|
128
|
+
);
|
|
129
|
+
expect(wire.subjects).toHaveLength(2);
|
|
130
|
+
const created = wire.events.find((e) => e.type === 'thing.created');
|
|
131
|
+
expect(created).toMatchObject({
|
|
132
|
+
description: 'A thing was created.',
|
|
133
|
+
subjects: ['thing', 'project'],
|
|
134
|
+
schema: {
|
|
135
|
+
type: 'object',
|
|
136
|
+
properties: { thingId: { type: 'string' }, name: { type: 'string' } },
|
|
137
|
+
required: ['thingId', 'name']
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
expect(wire.events.map((e) => e.type)).toEqual(
|
|
141
|
+
expect.arrayContaining(['webhook.ping', 'project.created', 'thing.created'])
|
|
142
|
+
);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('the chassis’s own project subject reads list: false', () => {
|
|
146
|
+
const wire = catalogToWire(buildWebhookCatalog(undefined));
|
|
147
|
+
expect(wire.subjects).toEqual([
|
|
148
|
+
{ key: 'project', field: 'projectId', description: expect.any(String), list: false }
|
|
149
|
+
]);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { buildEnvSchema } from '../../src/env.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The webhooks' environment (PRDCT-3482, verifier round 1, G8): the switch
|
|
6
|
+
* that opens private networks is refused on the cloud edition, by name, and
|
|
7
|
+
* the delivery log's retention defaults to thirty days.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const schema = buildEnvSchema({ version: '0.0.0-test' });
|
|
11
|
+
const BASE = { DATABASE_URL: 'postgres://u:p@localhost:5432/db' };
|
|
12
|
+
const CLOUD = {
|
|
13
|
+
...BASE,
|
|
14
|
+
EDITION: 'cloud',
|
|
15
|
+
HUB_ISSUER_URL: 'https://account.example.com',
|
|
16
|
+
HUB_CLIENT_ID: 'tool-client',
|
|
17
|
+
HUB_CLIENT_SECRET: 'a-hub-client-secret-of-length'
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const issuesOf = (source: Record<string, string>) => {
|
|
21
|
+
const result = schema.safeParse(source);
|
|
22
|
+
return result.success
|
|
23
|
+
? []
|
|
24
|
+
: result.error.issues.map((i) => ({ key: i.path.join('.'), message: i.message }));
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
describe('WEBHOOKS_ALLOW_PRIVATE_NETWORKS', () => {
|
|
28
|
+
it('is refused on EDITION=cloud, naming the key', () => {
|
|
29
|
+
const issues = issuesOf({ ...CLOUD, WEBHOOKS_ALLOW_PRIVATE_NETWORKS: 'true' });
|
|
30
|
+
expect(issues).toEqual([
|
|
31
|
+
{ key: 'WEBHOOKS_ALLOW_PRIVATE_NETWORKS', message: expect.stringMatching(/never on EDITION=cloud/) }
|
|
32
|
+
]);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('a cloud env without it parses (the refusal is the switch, not the edition)', () => {
|
|
36
|
+
expect(issuesOf(CLOUD)).toEqual([]);
|
|
37
|
+
expect(
|
|
38
|
+
schema.parse({ ...CLOUD, WEBHOOKS_ALLOW_PRIVATE_NETWORKS: 'false' }).WEBHOOKS_ALLOW_PRIVATE_NETWORKS
|
|
39
|
+
).toBe(false);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('parses true on oss, and defaults to false', () => {
|
|
43
|
+
expect(
|
|
44
|
+
schema.parse({ ...BASE, WEBHOOKS_ALLOW_PRIVATE_NETWORKS: 'true' }).WEBHOOKS_ALLOW_PRIVATE_NETWORKS
|
|
45
|
+
).toBe(true);
|
|
46
|
+
expect(schema.parse(BASE).WEBHOOKS_ALLOW_PRIVATE_NETWORKS).toBe(false);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe('WEBHOOK_DELIVERY_RETENTION_DAYS', () => {
|
|
51
|
+
it('defaults to 30, takes 0 (keep forever), refuses a negative', () => {
|
|
52
|
+
expect(schema.parse(BASE).WEBHOOK_DELIVERY_RETENTION_DAYS).toBe(30);
|
|
53
|
+
expect(
|
|
54
|
+
schema.parse({ ...BASE, WEBHOOK_DELIVERY_RETENTION_DAYS: '0' }).WEBHOOK_DELIVERY_RETENTION_DAYS
|
|
55
|
+
).toBe(0);
|
|
56
|
+
expect(issuesOf({ ...BASE, WEBHOOK_DELIVERY_RETENTION_DAYS: '-1' }).map((i) => i.key)).toEqual([
|
|
57
|
+
'WEBHOOK_DELIVERY_RETENTION_DAYS'
|
|
58
|
+
]);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import type { Logger } from '../../src/logger.js';
|
|
3
|
+
import { webhookJobs } from '../../src/webhooks/jobs.js';
|
|
4
|
+
import {
|
|
5
|
+
MAX_IN_FLIGHT_PER_ENDPOINT,
|
|
6
|
+
WEBHOOK_DELIVERIES_HELD_QUEUE,
|
|
7
|
+
WEBHOOK_DELIVERIES_QUEUE,
|
|
8
|
+
WEBHOOK_EVENTS_QUEUE,
|
|
9
|
+
WEBHOOK_PURGE_QUEUE,
|
|
10
|
+
WebhookDeliveryDeferred,
|
|
11
|
+
WebhookDeliveryFailed,
|
|
12
|
+
type WebhookService
|
|
13
|
+
} from '../../src/webhooks/service.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The webhooks' queue declarations (verifier round 2, MJ-R2-1): the two
|
|
17
|
+
* queues that must drain a burst poll every half second, the delivery queue
|
|
18
|
+
* runs eight pollers, and an attempt the per-endpoint bound defers goes back
|
|
19
|
+
* in the queue with its retry budget untouched (the job completes), while a
|
|
20
|
+
* failed attempt still throws so pg-boss retries it.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const logger = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() } as unknown as Logger;
|
|
24
|
+
|
|
25
|
+
function fakeService(attempt: (id: string) => Promise<unknown>) {
|
|
26
|
+
const requeue = vi.fn(async (_id: string, _inSeconds: number) => undefined);
|
|
27
|
+
const service = {
|
|
28
|
+
dials: { retryLimit: 2, retryDelaySeconds: 1 },
|
|
29
|
+
attempt: vi.fn(async (id: string) => attempt(id)),
|
|
30
|
+
requeue,
|
|
31
|
+
fanOut: vi.fn(async () => 0),
|
|
32
|
+
hold: vi.fn(async () => undefined),
|
|
33
|
+
purge: vi.fn(async () => 0)
|
|
34
|
+
} as unknown as WebhookService;
|
|
35
|
+
return { service, requeue };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const jobOf = (service: WebhookService, queue: string) => {
|
|
39
|
+
const job = webhookJobs(service, logger, 30).find((j) => j.queue === queue);
|
|
40
|
+
expect(job, `no declaration for ${queue}`).toBeDefined();
|
|
41
|
+
return job!;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const META = { id: 'job-1', queue: WEBHOOK_DELIVERIES_QUEUE, key: null };
|
|
45
|
+
|
|
46
|
+
describe('webhookJobs', () => {
|
|
47
|
+
it('the fan-out and the delivery queues poll every half second; the held queue and the purge keep the default', () => {
|
|
48
|
+
const { service } = fakeService(async () => null);
|
|
49
|
+
const byQueue = new Map(webhookJobs(service, logger, 30).map((j) => [j.queue, j]));
|
|
50
|
+
expect(byQueue.get(WEBHOOK_EVENTS_QUEUE)).toMatchObject({ concurrency: 2, pollingIntervalSeconds: 0.5 });
|
|
51
|
+
expect(byQueue.get(WEBHOOK_DELIVERIES_QUEUE)).toMatchObject({
|
|
52
|
+
concurrency: 8,
|
|
53
|
+
pollingIntervalSeconds: 0.5
|
|
54
|
+
});
|
|
55
|
+
expect(byQueue.get(WEBHOOK_DELIVERIES_HELD_QUEUE)?.pollingIntervalSeconds).toBeUndefined();
|
|
56
|
+
expect(byQueue.get(WEBHOOK_PURGE_QUEUE)?.pollingIntervalSeconds).toBeUndefined();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('the bound leaves room for other endpoints: two per endpoint of the eight pollers', () => {
|
|
60
|
+
expect(MAX_IN_FLIGHT_PER_ENDPOINT).toBe(2);
|
|
61
|
+
const { service } = fakeService(async () => null);
|
|
62
|
+
expect(jobOf(service, WEBHOOK_DELIVERIES_QUEUE).concurrency).toBeGreaterThan(MAX_IN_FLIGHT_PER_ENDPOINT);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('a deferred attempt is put back in the queue two seconds later and the job completes', async () => {
|
|
66
|
+
const { service, requeue } = fakeService(async (id) => {
|
|
67
|
+
throw new WebhookDeliveryDeferred(id);
|
|
68
|
+
});
|
|
69
|
+
const job = jobOf(service, WEBHOOK_DELIVERIES_QUEUE);
|
|
70
|
+
vi.mocked(logger.warn).mockClear();
|
|
71
|
+
await expect(job.handler({ deliveryId: 'd-1' }, META)).resolves.toBeUndefined();
|
|
72
|
+
expect(requeue).toHaveBeenCalledTimes(1);
|
|
73
|
+
expect(requeue).toHaveBeenCalledWith('d-1', 2);
|
|
74
|
+
expect(logger.warn).not.toHaveBeenCalled();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('a failed attempt throws so pg-boss retries it, and is never requeued by hand', async () => {
|
|
78
|
+
const { service, requeue } = fakeService(async (id) => {
|
|
79
|
+
throw new WebhookDeliveryFailed(id, 'HTTP 500');
|
|
80
|
+
});
|
|
81
|
+
const job = jobOf(service, WEBHOOK_DELIVERIES_QUEUE);
|
|
82
|
+
await expect(job.handler({ deliveryId: 'd-2' }, META)).rejects.toBeInstanceOf(WebhookDeliveryFailed);
|
|
83
|
+
expect(requeue).not.toHaveBeenCalled();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('a delivered attempt neither throws nor requeues', async () => {
|
|
87
|
+
const { service, requeue } = fakeService(async () => ({ status: 'delivered' }));
|
|
88
|
+
const job = jobOf(service, WEBHOOK_DELIVERIES_QUEUE);
|
|
89
|
+
await expect(job.handler({ deliveryId: 'd-3' }, META)).resolves.toBeUndefined();
|
|
90
|
+
expect(requeue).not.toHaveBeenCalled();
|
|
91
|
+
});
|
|
92
|
+
});
|