@bevel-software/platform-core-backend 0.13.6 → 0.14.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/core/create-core-server.d.ts.map +1 -1
- package/dist/core/create-core-server.js +2 -1
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts +2 -0
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +9 -0
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/core-config.d.ts.map +1 -1
- package/dist/core-config.js +12 -5
- package/dist/core-config.js.map +1 -1
- package/dist/modules/access-model/kb-read-filter.d.ts +12 -0
- package/dist/modules/access-model/kb-read-filter.d.ts.map +1 -1
- package/dist/modules/access-model/kb-read-filter.js +15 -0
- package/dist/modules/access-model/kb-read-filter.js.map +1 -1
- package/dist/modules/connection-probe/connection-probe.contract.d.ts +43 -0
- package/dist/modules/connection-probe/connection-probe.contract.d.ts.map +1 -0
- package/dist/modules/connection-probe/connection-probe.contract.js +2 -0
- package/dist/modules/connection-probe/connection-probe.contract.js.map +1 -0
- package/dist/modules/connection-probe/connection-probe.service.d.ts +94 -0
- package/dist/modules/connection-probe/connection-probe.service.d.ts.map +1 -0
- package/dist/modules/connection-probe/connection-probe.service.js +684 -0
- package/dist/modules/connection-probe/connection-probe.service.js.map +1 -0
- package/dist/modules/connection-probe/index.d.ts +3 -0
- package/dist/modules/connection-probe/index.d.ts.map +1 -0
- package/dist/modules/connection-probe/index.js +3 -0
- package/dist/modules/connection-probe/index.js.map +1 -0
- package/dist/modules/diff/diff.routes.d.ts.map +1 -1
- package/dist/modules/diff/diff.routes.js +3 -5
- package/dist/modules/diff/diff.routes.js.map +1 -1
- package/dist/modules/kb-fs/mutex.d.ts +37 -0
- package/dist/modules/kb-fs/mutex.d.ts.map +1 -1
- package/dist/modules/kb-fs/mutex.js +48 -5
- package/dist/modules/kb-fs/mutex.js.map +1 -1
- package/dist/modules/secrets-vault/db-secrets-vault.service.js +1 -1
- package/dist/modules/secrets-vault/db-secrets-vault.service.js.map +1 -1
- package/dist/modules/secrets-vault/secrets-vault.routes.d.ts +6 -0
- package/dist/modules/secrets-vault/secrets-vault.routes.d.ts.map +1 -1
- package/dist/modules/secrets-vault/secrets-vault.routes.js +31 -1
- package/dist/modules/secrets-vault/secrets-vault.routes.js.map +1 -1
- package/dist/modules/tool-manuals/mcp-json-discovery.d.ts.map +1 -1
- package/dist/modules/tool-manuals/mcp-json-discovery.js +10 -1
- package/dist/modules/tool-manuals/mcp-json-discovery.js.map +1 -1
- package/dist/modules/tool-manuals/mcp-server-edit.service.d.ts.map +1 -1
- package/dist/modules/tool-manuals/mcp-server-edit.service.js +10 -4
- package/dist/modules/tool-manuals/mcp-server-edit.service.js.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +83 -0
- package/dist/modules/tool-manuals/tool-manuals.contract.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.service.d.ts +9 -1
- package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.service.js +181 -13
- package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
- package/dist/modules/workflow/git/git.service.d.ts +18 -1
- package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/git.service.js +98 -6
- package/dist/modules/workflow/git/git.service.js.map +1 -1
- package/dist/modules/workflow/workflow.routes.d.ts +2 -1
- package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.routes.js +98 -9
- package/dist/modules/workflow/workflow.routes.js.map +1 -1
- package/dist/modules/workflow/workflow.service.d.ts +81 -8
- package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.service.js +220 -43
- package/dist/modules/workflow/workflow.service.js.map +1 -1
- package/dist/modules/workspace/workspace.routes.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.routes.js +2 -5
- package/dist/modules/workspace/workspace.routes.js.map +1 -1
- package/dist/modules/workspace/workspace.service.d.ts +5 -1
- package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.service.js +21 -4
- package/dist/modules/workspace/workspace.service.js.map +1 -1
- package/dist/shared/token-crypto.d.ts +17 -2
- package/dist/shared/token-crypto.d.ts.map +1 -1
- package/dist/shared/token-crypto.js +25 -8
- package/dist/shared/token-crypto.js.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/core-config.admin.test.ts +21 -0
- package/src/core/create-core-server.ts +3 -0
- package/src/core/create-core-services.ts +11 -0
- package/src/core-config.ts +14 -7
- package/src/modules/access-model/kb-read-filter.ts +22 -0
- package/src/modules/connection-probe/__tests__/connection-probe.service.test.ts +685 -0
- package/src/modules/connection-probe/connection-probe.contract.ts +44 -0
- package/src/modules/connection-probe/connection-probe.service.ts +734 -0
- package/src/modules/connection-probe/index.ts +2 -0
- package/src/modules/diff/diff.routes.ts +9 -5
- package/src/modules/kb-fs/__tests__/mutex.test.ts +107 -0
- package/src/modules/kb-fs/mutex.ts +50 -5
- package/src/modules/secrets-vault/__tests__/connect-pending.route.test.ts +12 -0
- package/src/modules/secrets-vault/__tests__/oauth-return-to.route.test.ts +10 -3
- package/src/modules/secrets-vault/__tests__/tool-owner-gate.route.test.ts +26 -0
- package/src/modules/secrets-vault/db-secrets-vault.service.ts +1 -1
- package/src/modules/secrets-vault/secrets-vault.routes.ts +35 -1
- package/src/modules/tool-manuals/__tests__/mcp-json-discovery.test.ts +11 -0
- package/src/modules/tool-manuals/__tests__/tool-manuals.health-check.test.ts +104 -0
- package/src/modules/tool-manuals/__tests__/tool-manuals.service.test.ts +56 -0
- package/src/modules/tool-manuals/mcp-json-discovery.ts +13 -1
- package/src/modules/tool-manuals/mcp-server-edit.service.ts +13 -4
- package/src/modules/tool-manuals/tool-manuals.contract.ts +89 -0
- package/src/modules/tool-manuals/tool-manuals.service.ts +196 -14
- package/src/modules/workflow/__tests__/workflow.routes.history-read-gate.test.ts +139 -0
- package/src/modules/workflow/__tests__/workflow.service.branch-in-use.test.ts +337 -0
- package/src/modules/workflow/__tests__/workflow.service.deleted-branch-sweep.test.ts +7 -1
- package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +95 -8
- package/src/modules/workflow/git/__tests__/git.service.history-guards.test.ts +86 -0
- package/src/modules/workflow/git/git.service.ts +115 -7
- package/src/modules/workflow/workflow.routes.ts +104 -4
- package/src/modules/workflow/workflow.service.ts +245 -55
- package/src/modules/workspace/workspace.routes.ts +8 -4
- package/src/modules/workspace/workspace.service.ts +21 -3
- package/src/shared/__tests__/token-crypto.test.ts +34 -0
- package/src/shared/token-crypto.ts +28 -10
|
@@ -0,0 +1,685 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { CommunicationProtocol, type CallTemplate } from '@utcp/sdk';
|
|
3
|
+
import type {
|
|
4
|
+
IToolManualService,
|
|
5
|
+
ToolHealthCheck,
|
|
6
|
+
ToolProbeTarget,
|
|
7
|
+
} from '../../tool-manuals/tool-manuals.contract.js';
|
|
8
|
+
import type { ISecretsVaultService } from '../../secrets-vault/secrets-vault.contract.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The three-way verdict is the whole point of this module, and the line that
|
|
12
|
+
* matters most is between `failed` and `unverifiable`.
|
|
13
|
+
*
|
|
14
|
+
* `failed` is the only status that ACCUSES the user's credential, so it must be
|
|
15
|
+
* reachable ONLY from a provider definitively rejecting it. Everything else —
|
|
16
|
+
* a timeout, a 500, a tool with nothing to call — has to come back "we don't
|
|
17
|
+
* know", because a badge that shouts "not working" during someone else's outage
|
|
18
|
+
* gets ignored, and an ignored badge is the bug we started with wearing a
|
|
19
|
+
* different colour.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const registerManualMock = vi.fn<(client: unknown, manual: CallTemplate) => Promise<unknown>>();
|
|
23
|
+
const createClientMock = vi.fn<() => Promise<unknown>>();
|
|
24
|
+
|
|
25
|
+
// Only `registerManual` is replaced. The rest of this package has to stay real:
|
|
26
|
+
// `shared/utcp-namespace.ts` RE-EXPORTS `utcpNamespacedKey` from it, so a bare
|
|
27
|
+
// factory silently removes the function every vault lookup goes through.
|
|
28
|
+
vi.mock('@bevel-software/platform-mcp-core', async (importOriginal) => ({
|
|
29
|
+
...(await importOriginal<typeof import('@bevel-software/platform-mcp-core')>()),
|
|
30
|
+
registerManual: (client: unknown, manual: CallTemplate) => registerManualMock(client, manual),
|
|
31
|
+
}));
|
|
32
|
+
vi.mock('@utcp/code-mode', () => ({
|
|
33
|
+
CodeModeUtcpClient: { create: () => createClientMock() },
|
|
34
|
+
}));
|
|
35
|
+
|
|
36
|
+
const { ConnectionProbeService, readCapped } = await import('../connection-probe.service.js');
|
|
37
|
+
|
|
38
|
+
const DECLARED_PROBE: ToolHealthCheck = {
|
|
39
|
+
url: 'https://api.acme.test/me',
|
|
40
|
+
headers: { Authorization: 'Bearer ${API_KEY}' },
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const MCP_TEMPLATE = {
|
|
44
|
+
name: 'acme',
|
|
45
|
+
call_template_type: 'mcp',
|
|
46
|
+
config: { mcpServers: { acme: { transport: 'http', url: 'https://mcp.acme.test' } } },
|
|
47
|
+
} as unknown as CallTemplate;
|
|
48
|
+
|
|
49
|
+
/** The shape a real remote MCP manual has: a url and a credential in a header. */
|
|
50
|
+
const MCP_TEMPLATE_WITH_KEY = {
|
|
51
|
+
name: 'acme',
|
|
52
|
+
call_template_type: 'mcp',
|
|
53
|
+
config: {
|
|
54
|
+
mcpServers: {
|
|
55
|
+
acme: {
|
|
56
|
+
transport: 'http',
|
|
57
|
+
url: 'https://mcp.acme.test',
|
|
58
|
+
headers: { Authorization: 'Bearer ${API_KEY}' },
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
} as unknown as CallTemplate;
|
|
63
|
+
|
|
64
|
+
const TEMPLATED_HOST = {
|
|
65
|
+
name: 'acme',
|
|
66
|
+
call_template_type: 'mcp',
|
|
67
|
+
config: { mcpServers: { acme: { transport: 'http', url: 'https://${HOST}/mcp' } } },
|
|
68
|
+
} as unknown as CallTemplate;
|
|
69
|
+
|
|
70
|
+
const serversOf = (template: unknown) =>
|
|
71
|
+
(template as { config: { mcpServers: Record<string, { url?: string }> } }).config.mcpServers;
|
|
72
|
+
|
|
73
|
+
function build(
|
|
74
|
+
target: Partial<ToolProbeTarget> | null,
|
|
75
|
+
resolve: (key: string) => Promise<string | null> = async () => 'sk-live-abc',
|
|
76
|
+
) {
|
|
77
|
+
const toolManualService = {
|
|
78
|
+
// Probe config travels by its own accessor rather than on the summary: it
|
|
79
|
+
// carries `headers`, which a `.tool` may write as a literal token, and the
|
|
80
|
+
// summary is serialized straight to the browser.
|
|
81
|
+
probeTargetFor: async (): Promise<ToolProbeTarget | null> =>
|
|
82
|
+
target === null
|
|
83
|
+
? null
|
|
84
|
+
: { name: 'acme', type: 'http', callTemplate: null, ...target },
|
|
85
|
+
} as unknown as IToolManualService;
|
|
86
|
+
const secretsVault = { resolve: (_u: string, key: string) => resolve(key) } as unknown as ISecretsVaultService;
|
|
87
|
+
return new ConnectionProbeService(toolManualService, secretsVault);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const httpTool = (probe: ToolHealthCheck = DECLARED_PROBE) => build({ type: 'http', healthCheck: probe });
|
|
91
|
+
const mcpTool = () => build({ type: 'mcp', callTemplate: MCP_TEMPLATE });
|
|
92
|
+
|
|
93
|
+
/** Long enough to be redactable, and shaped like the keys providers echo. */
|
|
94
|
+
const SECRET = 'sk-live-supersecret-1234';
|
|
95
|
+
|
|
96
|
+
describe('ConnectionProbeService: what the probe concludes', () => {
|
|
97
|
+
beforeEach(() => {
|
|
98
|
+
vi.stubGlobal('fetch', vi.fn());
|
|
99
|
+
registerManualMock.mockReset();
|
|
100
|
+
createClientMock.mockReset();
|
|
101
|
+
});
|
|
102
|
+
afterEach(() => {
|
|
103
|
+
vi.unstubAllGlobals();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('is ok when the provider accepts the credential', async () => {
|
|
107
|
+
vi.mocked(fetch).mockResolvedValue(new Response('{}', { status: 200 }));
|
|
108
|
+
|
|
109
|
+
const r = await httpTool().probe('u1', 'a@b.c', 'acme');
|
|
110
|
+
|
|
111
|
+
expect(r?.status).toBe('ok');
|
|
112
|
+
expect(r?.detail).toBeNull();
|
|
113
|
+
expect(r?.checkedAt).toBeInstanceOf(Date);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('carries the credential into the probe, so it tests what the tool would send', async () => {
|
|
117
|
+
vi.mocked(fetch).mockResolvedValue(new Response('{}', { status: 200 }));
|
|
118
|
+
|
|
119
|
+
await httpTool().probe('u1', 'a@b.c', 'acme');
|
|
120
|
+
|
|
121
|
+
const [url, init] = vi.mocked(fetch).mock.calls[0];
|
|
122
|
+
expect(url).toBe('https://api.acme.test/me');
|
|
123
|
+
expect((init as RequestInit).headers).toEqual({ Authorization: 'Bearer sk-live-abc' });
|
|
124
|
+
// A probe runs unattended on every save; it must not be able to mutate, and
|
|
125
|
+
// a 3xx to an internal host must not be followed.
|
|
126
|
+
expect((init as RequestInit).method).toBe('GET');
|
|
127
|
+
expect((init as RequestInit).redirect).toBe('manual');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it.each([401, 403])('fails on a definitive rejection (%i), quoting the provider', async (status) => {
|
|
131
|
+
vi.mocked(fetch).mockResolvedValue(new Response('invalid api key', { status }));
|
|
132
|
+
|
|
133
|
+
const r = await httpTool().probe('u1', 'a@b.c', 'acme');
|
|
134
|
+
|
|
135
|
+
expect(r?.status).toBe('failed');
|
|
136
|
+
expect(r?.detail).toContain('invalid api key');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('does NOT accuse the credential when the provider is merely unwell', async () => {
|
|
140
|
+
vi.mocked(fetch).mockResolvedValue(new Response('boom', { status: 500 }));
|
|
141
|
+
|
|
142
|
+
const r = await httpTool().probe('u1', 'a@b.c', 'acme');
|
|
143
|
+
|
|
144
|
+
expect(r?.status).toBe('unverifiable');
|
|
145
|
+
expect(r?.detail).toContain('500');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('does NOT accuse the credential when the provider is unreachable', async () => {
|
|
149
|
+
vi.mocked(fetch).mockRejectedValue(new Error('ETIMEDOUT'));
|
|
150
|
+
|
|
151
|
+
const r = await httpTool().probe('u1', 'a@b.c', 'acme');
|
|
152
|
+
|
|
153
|
+
expect(r?.status).toBe('unverifiable');
|
|
154
|
+
expect(r?.detail).toMatch(/Couldn't reach the provider/);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('does not fire a request at all when the credential is not set yet', async () => {
|
|
158
|
+
const svc = build({ type: 'http', healthCheck: DECLARED_PROBE }, async () => null);
|
|
159
|
+
|
|
160
|
+
const r = await svc.probe('u1', 'a@b.c', 'acme');
|
|
161
|
+
|
|
162
|
+
// Sending an empty Bearer and reading the inevitable 401 would report a
|
|
163
|
+
// MISSING key as a WRONG one — two different problems with two different fixes.
|
|
164
|
+
expect(r?.status).toBe('unverifiable');
|
|
165
|
+
expect(r?.detail).toMatch(/isn't set yet/);
|
|
166
|
+
expect(fetch).not.toHaveBeenCalled();
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('is unverifiable, not ok, for an http tool that declares no probe', async () => {
|
|
170
|
+
// Built directly rather than through `httpTool`: passing an explicit
|
|
171
|
+
// `undefined` to a defaulted parameter selects the DEFAULT probe, which is
|
|
172
|
+
// the opposite of what this test is about.
|
|
173
|
+
const r = await build({ type: 'http' }).probe('u1', 'a@b.c', 'acme');
|
|
174
|
+
|
|
175
|
+
expect(r?.status).toBe('unverifiable');
|
|
176
|
+
expect(r?.detail).toMatch(/doesn't offer a way to test/);
|
|
177
|
+
expect(fetch).not.toHaveBeenCalled();
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('is unverifiable for a local-only tool this server cannot reach', async () => {
|
|
181
|
+
const svc = build({ type: 'http', healthCheck: DECLARED_PROBE, remote: false });
|
|
182
|
+
|
|
183
|
+
const r = await svc.probe('u1', 'a@b.c', 'acme');
|
|
184
|
+
|
|
185
|
+
expect(r?.status).toBe('unverifiable');
|
|
186
|
+
expect(r?.detail).toMatch(/local agent/);
|
|
187
|
+
expect(fetch).not.toHaveBeenCalled();
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('is null for a tool the caller cannot read, so a 404 confirms nothing', async () => {
|
|
191
|
+
expect(await build(null).probe('u1', 'a@b.c', 'nope')).toBeNull();
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The parse-time SSRF guard cannot check a TEMPLATED host — it does not exist
|
|
196
|
+
* until a secret resolves — so the fetch-time re-check is the only thing
|
|
197
|
+
* standing between a `.tool` and the cloud metadata endpoint.
|
|
198
|
+
*/
|
|
199
|
+
it('refuses to call an internal host a variable resolved to', async () => {
|
|
200
|
+
const svc = build({ type: 'http', healthCheck: { url: 'https://${HOST}/me' } }, async () => '169.254.169.254');
|
|
201
|
+
|
|
202
|
+
const r = await svc.probe('u1', 'a@b.c', 'acme');
|
|
203
|
+
|
|
204
|
+
expect(r?.status).toBe('unverifiable');
|
|
205
|
+
expect(fetch).not.toHaveBeenCalled();
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* UTCP substitutes BOTH `${VAR}` and bare `$VAR`, and the scanner that decides
|
|
210
|
+
* which variables to ask the user for accepts both too. A probe that only
|
|
211
|
+
* understood the braced form sent the literal `$API_KEY` on the wire, drew a
|
|
212
|
+
* 401, and reported a perfectly good credential as "Not working".
|
|
213
|
+
*/
|
|
214
|
+
describe('variable references, in UTCP’s own grammar', () => {
|
|
215
|
+
it('substitutes a BARE $VAR, not just ${VAR}', async () => {
|
|
216
|
+
const svc = build({ type: 'http', healthCheck: { url: 'https://api.acme.test/me', headers: { Authorization: 'Bearer $API_KEY' } } });
|
|
217
|
+
vi.mocked(fetch).mockResolvedValue(new Response('{}', { status: 200 }));
|
|
218
|
+
|
|
219
|
+
await svc.probe('u1', 'a@b.c', 'acme');
|
|
220
|
+
|
|
221
|
+
const [, init] = vi.mocked(fetch).mock.calls[0];
|
|
222
|
+
expect((init as RequestInit).headers).toEqual({ Authorization: 'Bearer sk-live-abc' });
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('does not let a SHORTER name corrupt a longer one sharing its prefix', async () => {
|
|
226
|
+
// `$API` must not eat the head of `$API_KEY`. A replace-per-name loop
|
|
227
|
+
// gets this wrong; one left-to-right pass, as the SDK does it, does not.
|
|
228
|
+
const svc = build(
|
|
229
|
+
{ type: 'http', healthCheck: { url: 'https://api.acme.test/me', headers: { A: '$API_KEY', B: '$API' } } },
|
|
230
|
+
async (key) => (key.endsWith('API_KEY') ? 'long-value' : 'short'),
|
|
231
|
+
);
|
|
232
|
+
vi.mocked(fetch).mockResolvedValue(new Response('{}', { status: 200 }));
|
|
233
|
+
|
|
234
|
+
await svc.probe('u1', 'a@b.c', 'acme');
|
|
235
|
+
|
|
236
|
+
const [, init] = vi.mocked(fetch).mock.calls[0];
|
|
237
|
+
expect((init as RequestInit).headers).toEqual({ A: 'long-value', B: 'short' });
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('leaves a `$ref` string alone, exactly as UTCP does', async () => {
|
|
241
|
+
const resolve = vi.fn(async () => 'sk-live-abc');
|
|
242
|
+
const svc = build({ type: 'http', healthCheck: { url: 'https://api.acme.test/me', headers: { X: '{"$ref": "#/x"}' } } }, resolve);
|
|
243
|
+
vi.mocked(fetch).mockResolvedValue(new Response('{}', { status: 200 }));
|
|
244
|
+
|
|
245
|
+
await svc.probe('u1', 'a@b.c', 'acme');
|
|
246
|
+
|
|
247
|
+
const [, init] = vi.mocked(fetch).mock.calls[0];
|
|
248
|
+
expect((init as RequestInit).headers).toEqual({ X: '{"$ref": "#/x"}' });
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* The MCP handshake is the probe for an `mcp` tool — but `@utcp/mcp` caches
|
|
254
|
+
* sessions on a MODULE-LEVEL singleton keyed only `<serverName>:<transport>`,
|
|
255
|
+
* and hands back a cached session WITHOUT looking at the credential. A
|
|
256
|
+
* throwaway client is therefore not a throwaway session, and probing under the
|
|
257
|
+
* manual's own name would let a session opened with an EARLIER token answer
|
|
258
|
+
* for a key that was just mistyped — reporting "Connected" for a wrong key,
|
|
259
|
+
* which is the bug this module exists to remove.
|
|
260
|
+
*/
|
|
261
|
+
describe('the MCP handshake probes the credential, not the session cache', () => {
|
|
262
|
+
it('never dials under the manual’s own session key, and never twice under the same one', async () => {
|
|
263
|
+
createClientMock.mockResolvedValue({});
|
|
264
|
+
registerManualMock.mockResolvedValue({ ok: true });
|
|
265
|
+
|
|
266
|
+
await mcpTool().probe('u1', 'a@b.c', 'acme');
|
|
267
|
+
await mcpTool().probe('u2', 'a@b.c', 'acme');
|
|
268
|
+
|
|
269
|
+
const keysOf = (call: number) =>
|
|
270
|
+
Object.keys((registerManualMock.mock.calls[call][1] as unknown as { config: { mcpServers: Record<string, unknown> } }).config.mcpServers);
|
|
271
|
+
const [first] = keysOf(0);
|
|
272
|
+
const [second] = keysOf(1);
|
|
273
|
+
|
|
274
|
+
expect(first).not.toBe('acme');
|
|
275
|
+
expect(second).not.toBe('acme');
|
|
276
|
+
// Two probes, two sessions: one user's probe must never be answered by
|
|
277
|
+
// another's session, and a failing probe must not evict a live one.
|
|
278
|
+
expect(first).not.toBe(second);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it('leaves the original template untouched, so the live session key is unchanged', async () => {
|
|
282
|
+
createClientMock.mockResolvedValue({});
|
|
283
|
+
registerManualMock.mockResolvedValue({ ok: true });
|
|
284
|
+
|
|
285
|
+
await mcpTool().probe('u1', 'a@b.c', 'acme');
|
|
286
|
+
|
|
287
|
+
expect(Object.keys((MCP_TEMPLATE as unknown as { config: { mcpServers: Record<string, unknown> } }).config.mcpServers)).toEqual(['acme']);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it('closes its session even when the handshake FAILED', async () => {
|
|
291
|
+
// The failure case is the one that leaks: the client never saves a manual
|
|
292
|
+
// it couldn't register, so `client.deregisterManual` finds nothing, while
|
|
293
|
+
// the session — created before `listTools` was attempted — stays open.
|
|
294
|
+
const deregisterManual = vi.fn(async () => {});
|
|
295
|
+
const previous = CommunicationProtocol.communicationProtocols.mcp;
|
|
296
|
+
CommunicationProtocol.communicationProtocols.mcp = { deregisterManual } as never;
|
|
297
|
+
try {
|
|
298
|
+
createClientMock.mockResolvedValue({});
|
|
299
|
+
registerManualMock.mockResolvedValue({ ok: false, error: '401 Unauthorized' });
|
|
300
|
+
|
|
301
|
+
const r = await mcpTool().probe('u1', 'a@b.c', 'acme');
|
|
302
|
+
|
|
303
|
+
expect(r?.status).toBe('failed');
|
|
304
|
+
expect(deregisterManual).toHaveBeenCalledTimes(1);
|
|
305
|
+
// Deregistered under the SAME isolated key it dialled, or it would close
|
|
306
|
+
// somebody else's session instead of its own.
|
|
307
|
+
const closed = deregisterManual.mock.calls[0][1] as unknown as { config: { mcpServers: Record<string, unknown> } };
|
|
308
|
+
const dialled = registerManualMock.mock.calls[0][1] as unknown as { config: { mcpServers: Record<string, unknown> } };
|
|
309
|
+
expect(Object.keys(closed.config.mcpServers)).toEqual(Object.keys(dialled.config.mcpServers));
|
|
310
|
+
} finally {
|
|
311
|
+
CommunicationProtocol.communicationProtocols.mcp = previous;
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it('a 401 that only appears inside a URL accuses nobody', async () => {
|
|
316
|
+
// `fetch failed: https://mcp.example.com/v1/401/stream` carries the
|
|
317
|
+
// digits in a PATH — the provider said nothing about the credential.
|
|
318
|
+
const deregisterManual = vi.fn(async () => {});
|
|
319
|
+
const previous = CommunicationProtocol.communicationProtocols.mcp;
|
|
320
|
+
CommunicationProtocol.communicationProtocols.mcp = { deregisterManual } as never;
|
|
321
|
+
try {
|
|
322
|
+
createClientMock.mockResolvedValue({});
|
|
323
|
+
registerManualMock.mockResolvedValue({
|
|
324
|
+
ok: false,
|
|
325
|
+
error: 'fetch failed: https://mcp.example.com/v1/401/stream — socket hang up',
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
const r = await mcpTool().probe('u1', 'a@b.c', 'acme');
|
|
329
|
+
|
|
330
|
+
expect(r?.status).toBe('unverifiable');
|
|
331
|
+
} finally {
|
|
332
|
+
CommunicationProtocol.communicationProtocols.mcp = previous;
|
|
333
|
+
}
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it('reports a non-auth registration failure as unverifiable, not as a bad key', async () => {
|
|
337
|
+
createClientMock.mockResolvedValue({});
|
|
338
|
+
registerManualMock.mockResolvedValue({ ok: false, error: 'ECONNREFUSED' });
|
|
339
|
+
|
|
340
|
+
const r = await mcpTool().probe('u1', 'a@b.c', 'acme');
|
|
341
|
+
|
|
342
|
+
expect(r?.status).toBe('unverifiable');
|
|
343
|
+
expect(r?.detail).toMatch(/Couldn't reach the provider/);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it('is unverifiable when the manual produces no valid call template', async () => {
|
|
347
|
+
const r = await build({ type: 'mcp', callTemplate: null }).probe('u1', 'a@b.c', 'acme');
|
|
348
|
+
|
|
349
|
+
expect(r?.status).toBe('unverifiable');
|
|
350
|
+
expect(r?.detail).toMatch(/couldn't be resolved/);
|
|
351
|
+
expect(registerManualMock).not.toHaveBeenCalled();
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it('prefers a DECLARED health check over the handshake, even for an mcp tool', async () => {
|
|
355
|
+
// The author naming an endpoint is a stronger statement about how to test
|
|
356
|
+
// their tool than any inference of ours.
|
|
357
|
+
const svc = build({ type: 'mcp', callTemplate: MCP_TEMPLATE, healthCheck: DECLARED_PROBE });
|
|
358
|
+
vi.mocked(fetch).mockResolvedValue(new Response('{}', { status: 200 }));
|
|
359
|
+
|
|
360
|
+
const r = await svc.probe('u1', 'a@b.c', 'acme');
|
|
361
|
+
|
|
362
|
+
expect(r?.status).toBe('ok');
|
|
363
|
+
expect(registerManualMock).not.toHaveBeenCalled();
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* The parse-time guard cannot see a templated host, exactly as for the
|
|
368
|
+
* declared probe — and `@utcp/mcp`'s own `ensureSecureMcpUrl` is no
|
|
369
|
+
* substitute: it checks the SCHEME, so `https://169.254.169.254` sails
|
|
370
|
+
* through it. Without a re-check here, clicking Test connection on such a
|
|
371
|
+
* manual reaches the metadata endpoint.
|
|
372
|
+
*/
|
|
373
|
+
it('refuses to dial an internal host the server url resolved to', async () => {
|
|
374
|
+
const svc = build({ type: 'mcp', callTemplate: TEMPLATED_HOST }, async () => '169.254.169.254');
|
|
375
|
+
createClientMock.mockResolvedValue({});
|
|
376
|
+
registerManualMock.mockResolvedValue({ ok: true });
|
|
377
|
+
|
|
378
|
+
const r = await svc.probe('u1', 'a@b.c', 'acme');
|
|
379
|
+
|
|
380
|
+
expect(r?.status).toBe('unverifiable');
|
|
381
|
+
expect(registerManualMock).not.toHaveBeenCalled();
|
|
382
|
+
expect(createClientMock).not.toHaveBeenCalled();
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it('still dials a templated host that resolves somewhere public', async () => {
|
|
386
|
+
const svc = build({ type: 'mcp', callTemplate: TEMPLATED_HOST }, async () => 'mcp.acme.test');
|
|
387
|
+
createClientMock.mockResolvedValue({});
|
|
388
|
+
registerManualMock.mockResolvedValue({ ok: true });
|
|
389
|
+
|
|
390
|
+
expect((await svc.probe('u1', 'a@b.c', 'acme'))?.status).toBe('ok');
|
|
391
|
+
expect(registerManualMock).toHaveBeenCalledTimes(1);
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* The guard and the handshake have to see the SAME bytes. Checking
|
|
396
|
+
* `https://${HOST}/mcp` and then handing UTCP the template back leaves the
|
|
397
|
+
* vault free to answer differently the second time, so a concurrent
|
|
398
|
+
* `PUT …/vars/HOST` landing in that gap aims the handshake wherever it
|
|
399
|
+
* likes — with the guard's approval behind it.
|
|
400
|
+
*/
|
|
401
|
+
it('registers the url it CHECKED, not the template that produced it', async () => {
|
|
402
|
+
const svc = build({ type: 'mcp', callTemplate: TEMPLATED_HOST }, async () => 'mcp.acme.test');
|
|
403
|
+
createClientMock.mockResolvedValue({});
|
|
404
|
+
registerManualMock.mockResolvedValue({ ok: true });
|
|
405
|
+
|
|
406
|
+
await svc.probe('u1', 'a@b.c', 'acme');
|
|
407
|
+
|
|
408
|
+
const dialled = Object.values(serversOf(registerManualMock.mock.calls[0][1]))[0];
|
|
409
|
+
expect(dialled.url).toBe('https://mcp.acme.test/mcp');
|
|
410
|
+
// The manual's own template is untouched — only the probe's clone resolves.
|
|
411
|
+
expect(serversOf(TEMPLATED_HOST).acme.url).toBe('https://${HOST}/mcp');
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* A handshake that outlives the deadline is abandoned, not cancelled — and
|
|
416
|
+
* `@utcp/mcp` caches its session only AFTER `connect()` resolves. So the
|
|
417
|
+
* verdict's own cleanup runs while there is nothing yet to close, and the
|
|
418
|
+
* session appears moments later with nobody holding it: one live session
|
|
419
|
+
* leaked per probe against a slow server, on every save and every click.
|
|
420
|
+
*/
|
|
421
|
+
it('closes the session a slow handshake opens AFTER the probe gave up', async () => {
|
|
422
|
+
vi.useFakeTimers();
|
|
423
|
+
const deregisterManual = vi.fn(async () => {});
|
|
424
|
+
const previous = CommunicationProtocol.communicationProtocols.mcp;
|
|
425
|
+
CommunicationProtocol.communicationProtocols.mcp = { deregisterManual } as never;
|
|
426
|
+
try {
|
|
427
|
+
createClientMock.mockResolvedValue({});
|
|
428
|
+
let finishHandshake: (result: unknown) => void = () => {};
|
|
429
|
+
registerManualMock.mockReturnValue(
|
|
430
|
+
new Promise((resolve) => {
|
|
431
|
+
finishHandshake = resolve;
|
|
432
|
+
}),
|
|
433
|
+
);
|
|
434
|
+
|
|
435
|
+
const pending = mcpTool().probe('u1', 'a@b.c', 'acme');
|
|
436
|
+
await vi.advanceTimersByTimeAsync(11_000);
|
|
437
|
+
const r = await pending;
|
|
438
|
+
|
|
439
|
+
expect(r?.status).toBe('unverifiable');
|
|
440
|
+
expect(r?.detail).toMatch(/didn't answer within/);
|
|
441
|
+
// One close so far, and it found nothing: the session does not exist yet.
|
|
442
|
+
expect(deregisterManual).toHaveBeenCalledTimes(1);
|
|
443
|
+
|
|
444
|
+
finishHandshake({ ok: true });
|
|
445
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
446
|
+
|
|
447
|
+
// The late-settle owner. Without it this session stays open forever.
|
|
448
|
+
expect(deregisterManual).toHaveBeenCalledTimes(2);
|
|
449
|
+
expect(Object.keys(serversOf(deregisterManual.mock.calls[1][1]))).toEqual(
|
|
450
|
+
Object.keys(serversOf(registerManualMock.mock.calls[0][1])),
|
|
451
|
+
);
|
|
452
|
+
} finally {
|
|
453
|
+
CommunicationProtocol.communicationProtocols.mcp = previous;
|
|
454
|
+
vi.useRealTimers();
|
|
455
|
+
}
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
it('does not quote the credential back from a rejected handshake', async () => {
|
|
459
|
+
const svc = build({ type: 'mcp', callTemplate: MCP_TEMPLATE_WITH_KEY }, async () => SECRET);
|
|
460
|
+
createClientMock.mockResolvedValue({});
|
|
461
|
+
registerManualMock.mockResolvedValue({
|
|
462
|
+
ok: false,
|
|
463
|
+
error: `401 Unauthorized: {"message":"bad token ${SECRET}"}`,
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
const r = await svc.probe('u1', 'a@b.c', 'acme');
|
|
467
|
+
|
|
468
|
+
expect(r?.status).toBe('failed');
|
|
469
|
+
expect(r?.detail).not.toContain(SECRET);
|
|
470
|
+
expect(r?.detail).toContain('[redacted]');
|
|
471
|
+
});
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Anyone who can READ a tool can probe it, and providers routinely echo the
|
|
476
|
+
* key they refused. Quoting that body verbatim hands a plain reader part of a
|
|
477
|
+
* shared ADMIN credential they were never allowed to see — the 200-character
|
|
478
|
+
* cap bounds the quote, it does not redact it.
|
|
479
|
+
*/
|
|
480
|
+
describe('the quoted rejection cannot carry the credential back out', () => {
|
|
481
|
+
it('redacts the credential the provider echoed in full', async () => {
|
|
482
|
+
const svc = build({ type: 'http', healthCheck: DECLARED_PROBE }, async () => SECRET);
|
|
483
|
+
vi.mocked(fetch).mockResolvedValue(
|
|
484
|
+
new Response(`Incorrect API key provided: ${SECRET}. Check your key at acme.test.`, { status: 401 }),
|
|
485
|
+
);
|
|
486
|
+
|
|
487
|
+
const r = await svc.probe('u1', 'a@b.c', 'acme');
|
|
488
|
+
|
|
489
|
+
expect(r?.status).toBe('failed');
|
|
490
|
+
expect(r?.detail).not.toContain(SECRET);
|
|
491
|
+
expect(r?.detail).toContain('[redacted]');
|
|
492
|
+
// The actionable half of the provider's sentence still survives — that is
|
|
493
|
+
// the whole reason the body is quoted at all.
|
|
494
|
+
expect(r?.detail).toContain('Check your key at acme.test.');
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
it('redacts a TRUNCATED echo, which is the usual shape of one', async () => {
|
|
498
|
+
const svc = build({ type: 'http', healthCheck: DECLARED_PROBE }, async () => SECRET);
|
|
499
|
+
vi.mocked(fetch).mockResolvedValue(
|
|
500
|
+
new Response(`Incorrect API key provided: ${SECRET.slice(0, 14)}****`, { status: 401 }),
|
|
501
|
+
);
|
|
502
|
+
|
|
503
|
+
const r = await svc.probe('u1', 'a@b.c', 'acme');
|
|
504
|
+
|
|
505
|
+
// A whole-value search finds nothing here and prints the head of the key.
|
|
506
|
+
expect(r?.detail).not.toContain(SECRET.slice(0, 14));
|
|
507
|
+
expect(r?.detail).toContain('[redacted]');
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Redacting a short secret before a long one that starts with it consumes
|
|
512
|
+
* the long one's head and prints its tail — the half-redaction this class
|
|
513
|
+
* exists to prevent, reached from the other direction. Set iteration is
|
|
514
|
+
* insertion order, and the url is substituted before the headers, so
|
|
515
|
+
* putting the shorter value in the url makes that order deterministic.
|
|
516
|
+
*/
|
|
517
|
+
it('redacts a secret that another secret is a prefix of, whole', async () => {
|
|
518
|
+
const short = 'sk-live-abc123';
|
|
519
|
+
const long = `${short}-456789`;
|
|
520
|
+
const svc = build(
|
|
521
|
+
{
|
|
522
|
+
type: 'http',
|
|
523
|
+
healthCheck: {
|
|
524
|
+
url: 'https://api.acme.test/me?v=${SHORTKEY}',
|
|
525
|
+
headers: { Authorization: 'Bearer ${LONGKEY}' },
|
|
526
|
+
},
|
|
527
|
+
},
|
|
528
|
+
async (key) => (key.includes('SHORTKEY') ? short : long),
|
|
529
|
+
);
|
|
530
|
+
vi.mocked(fetch).mockResolvedValue(
|
|
531
|
+
new Response(`Incorrect API key provided: ${long}. Check your key.`, { status: 401 }),
|
|
532
|
+
);
|
|
533
|
+
|
|
534
|
+
const r = await svc.probe('u1', 'a@b.c', 'acme');
|
|
535
|
+
|
|
536
|
+
expect(r?.detail).not.toContain('456789');
|
|
537
|
+
expect(r?.detail).toContain('Check your key.');
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* A `.tool` may write its token inline instead of through a `${VAR}`, which
|
|
542
|
+
* is why `ToolManualSummary` withholds `headers` from the browser at all.
|
|
543
|
+
* Substitution never sees such a value, so before this the one credential a
|
|
544
|
+
* reader could not otherwise reach was the one a 401 could quote back.
|
|
545
|
+
*/
|
|
546
|
+
it('redacts a token the manual wrote out in full, not only one from the vault', async () => {
|
|
547
|
+
const literal = 'sk-live-written-into-the-file';
|
|
548
|
+
const svc = build({
|
|
549
|
+
type: 'http',
|
|
550
|
+
healthCheck: { url: 'https://api.acme.test/me', headers: { Authorization: `Bearer ${literal}` } },
|
|
551
|
+
});
|
|
552
|
+
vi.mocked(fetch).mockResolvedValue(
|
|
553
|
+
new Response(`Incorrect API key provided: ${literal}. Check your key at acme.test.`, { status: 401 }),
|
|
554
|
+
);
|
|
555
|
+
|
|
556
|
+
const r = await svc.probe('u1', 'a@b.c', 'acme');
|
|
557
|
+
|
|
558
|
+
expect(r?.status).toBe('failed');
|
|
559
|
+
expect(r?.detail).not.toContain(literal);
|
|
560
|
+
expect(r?.detail).toContain('Check your key at acme.test.');
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
it('redacts a literal token an mcp manual carries on its server headers', async () => {
|
|
564
|
+
const literal = 'sk-live-mcp-in-the-file';
|
|
565
|
+
const template = {
|
|
566
|
+
name: 'acme',
|
|
567
|
+
call_template_type: 'mcp',
|
|
568
|
+
config: {
|
|
569
|
+
mcpServers: {
|
|
570
|
+
acme: { transport: 'http', url: 'https://mcp.acme.test', headers: { Authorization: `Bearer ${literal}` } },
|
|
571
|
+
},
|
|
572
|
+
},
|
|
573
|
+
} as unknown as CallTemplate;
|
|
574
|
+
createClientMock.mockResolvedValue({});
|
|
575
|
+
registerManualMock.mockResolvedValue({ ok: false, error: `401 Unauthorized: bad token ${literal}` });
|
|
576
|
+
|
|
577
|
+
const r = await build({ type: 'mcp', callTemplate: template }).probe('u1', 'a@b.c', 'acme');
|
|
578
|
+
|
|
579
|
+
expect(r?.status).toBe('failed');
|
|
580
|
+
expect(r?.detail).not.toContain(literal);
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
it('redacts a literal token the manual put in the query string', async () => {
|
|
584
|
+
const literal = 'sk-live-in-the-query-string';
|
|
585
|
+
const svc = build({
|
|
586
|
+
type: 'http',
|
|
587
|
+
healthCheck: { url: `https://api.acme.test/me?api_key=${literal}` },
|
|
588
|
+
});
|
|
589
|
+
vi.mocked(fetch).mockResolvedValue(
|
|
590
|
+
new Response(`Incorrect API key provided: ${literal}`, { status: 401 }),
|
|
591
|
+
);
|
|
592
|
+
|
|
593
|
+
const r = await svc.probe('u1', 'a@b.c', 'acme');
|
|
594
|
+
|
|
595
|
+
expect(r?.detail).not.toContain(literal);
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* A signed url grants access exactly as a bearer token does — the secret is
|
|
600
|
+
* what derived the signature, and the signature is what travels. `sig` is
|
|
601
|
+
* matched only as a whole word, since inside one it would swallow `design`.
|
|
602
|
+
*/
|
|
603
|
+
it('redacts a signature, and leaves a word that merely contains "sig" alone', async () => {
|
|
604
|
+
const signature = 'a3f9c1e77b2d4406b8e15c9f0d3a7e42';
|
|
605
|
+
const design = 'lakeside-revision-7';
|
|
606
|
+
const svc = build({
|
|
607
|
+
type: 'http',
|
|
608
|
+
healthCheck: { url: `https://api.acme.test/me?sig=${signature}&design=${design}` },
|
|
609
|
+
});
|
|
610
|
+
vi.mocked(fetch).mockResolvedValue(
|
|
611
|
+
new Response(`Signature ${signature} is not valid for design ${design}`, { status: 401 }),
|
|
612
|
+
);
|
|
613
|
+
|
|
614
|
+
const r = await svc.probe('u1', 'a@b.c', 'acme');
|
|
615
|
+
|
|
616
|
+
expect(r?.detail).not.toContain(signature);
|
|
617
|
+
expect(r?.detail).toContain(design);
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* The other half of the bargain: the quote is only worth having because it
|
|
622
|
+
* carries the provider's own words, so a header that is a description of
|
|
623
|
+
* the request rather than a credential must survive it.
|
|
624
|
+
*/
|
|
625
|
+
it('leaves a header that is not a credential in the message', async () => {
|
|
626
|
+
const accept = 'application/vnd.acme.v3+json';
|
|
627
|
+
const svc = build({
|
|
628
|
+
type: 'http',
|
|
629
|
+
healthCheck: { url: 'https://api.acme.test/me', headers: { Accept: accept } },
|
|
630
|
+
});
|
|
631
|
+
vi.mocked(fetch).mockResolvedValue(new Response(`Unsupported Accept: ${accept}`, { status: 401 }));
|
|
632
|
+
|
|
633
|
+
const r = await svc.probe('u1', 'a@b.c', 'acme');
|
|
634
|
+
|
|
635
|
+
expect(r?.detail).toContain(accept);
|
|
636
|
+
});
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* The rejecting body is written by whatever server the credential points at,
|
|
641
|
+
* so how much of it we read is that server's choice unless we bound it.
|
|
642
|
+
*/
|
|
643
|
+
it('quotes a rejection without swallowing an unbounded body', async () => {
|
|
644
|
+
const huge = 'x'.repeat(5_000_000);
|
|
645
|
+
let cancelled = false;
|
|
646
|
+
const body = new ReadableStream<Uint8Array>({
|
|
647
|
+
pull(controller) {
|
|
648
|
+
// Endless: only the read cap can stop this.
|
|
649
|
+
controller.enqueue(new TextEncoder().encode(huge));
|
|
650
|
+
},
|
|
651
|
+
cancel() {
|
|
652
|
+
cancelled = true;
|
|
653
|
+
},
|
|
654
|
+
});
|
|
655
|
+
vi.mocked(fetch).mockResolvedValue(new Response(body, { status: 401 }));
|
|
656
|
+
|
|
657
|
+
const r = await httpTool().probe('u1', 'a@b.c', 'acme');
|
|
658
|
+
|
|
659
|
+
expect(r?.status).toBe('failed');
|
|
660
|
+
// 200 chars of quote plus the ellipsis and the fixed prefix — not 5MB.
|
|
661
|
+
expect(r?.detail!.length).toBeLessThan(400);
|
|
662
|
+
expect(cancelled).toBe(true);
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* The cap has to bound what is DECODED, not merely how many times we loop.
|
|
667
|
+
* Counting a chunk against the budget after decoding it lets a provider that
|
|
668
|
+
* answers in one huge chunk put the whole thing through the decoder — the
|
|
669
|
+
* bound bypassed by exactly the party it exists to bound. Asserted on
|
|
670
|
+
* `readCapped` directly because the caller's 200-character snippet is
|
|
671
|
+
* identical either way, which is how the first version of this passed.
|
|
672
|
+
*/
|
|
673
|
+
it('holds one oversized chunk to the byte budget, not just the loop', async () => {
|
|
674
|
+
const oneHugeChunk = new ReadableStream<Uint8Array>({
|
|
675
|
+
start(controller) {
|
|
676
|
+
controller.enqueue(new TextEncoder().encode('x'.repeat(5_000_000)));
|
|
677
|
+
controller.close();
|
|
678
|
+
},
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
const out = await readCapped(new Response(oneHugeChunk), 8 * 1024);
|
|
682
|
+
|
|
683
|
+
expect(out.length).toBe(8 * 1024);
|
|
684
|
+
});
|
|
685
|
+
});
|