@vellumai/credential-executor 0.4.55
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/Dockerfile +55 -0
- package/bun.lock +37 -0
- package/package.json +32 -0
- package/src/__tests__/command-executor.test.ts +1333 -0
- package/src/__tests__/command-validator.test.ts +708 -0
- package/src/__tests__/command-workspace.test.ts +997 -0
- package/src/__tests__/grant-store.test.ts +467 -0
- package/src/__tests__/http-executor.test.ts +1251 -0
- package/src/__tests__/http-policy.test.ts +970 -0
- package/src/__tests__/local-materializers.test.ts +826 -0
- package/src/__tests__/managed-materializers.test.ts +961 -0
- package/src/__tests__/toolstore.test.ts +539 -0
- package/src/__tests__/transport.test.ts +388 -0
- package/src/audit/store.ts +188 -0
- package/src/commands/auth-adapters.ts +169 -0
- package/src/commands/executor.ts +840 -0
- package/src/commands/output-scan.ts +157 -0
- package/src/commands/profiles.ts +282 -0
- package/src/commands/validator.ts +438 -0
- package/src/commands/workspace.ts +512 -0
- package/src/grants/index.ts +17 -0
- package/src/grants/persistent-store.ts +247 -0
- package/src/grants/rpc-handlers.ts +269 -0
- package/src/grants/temporary-store.ts +219 -0
- package/src/http/audit.ts +84 -0
- package/src/http/executor.ts +540 -0
- package/src/http/path-template.ts +179 -0
- package/src/http/policy.ts +256 -0
- package/src/http/response-filter.ts +233 -0
- package/src/index.ts +106 -0
- package/src/main.ts +263 -0
- package/src/managed-main.ts +420 -0
- package/src/materializers/local.ts +300 -0
- package/src/materializers/managed-platform.ts +270 -0
- package/src/paths.ts +137 -0
- package/src/server.ts +636 -0
- package/src/subjects/local.ts +177 -0
- package/src/subjects/managed.ts +290 -0
- package/src/toolstore/integrity.ts +94 -0
- package/src/toolstore/manifest.ts +154 -0
- package/src/toolstore/publish.ts +342 -0
- package/tsconfig.json +20 -0
|
@@ -0,0 +1,1251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration tests for the CES HTTP executor.
|
|
3
|
+
*
|
|
4
|
+
* Covers:
|
|
5
|
+
* 1. Local static secret flow — resolve, grant-check, materialise, execute, filter
|
|
6
|
+
* 2. Local OAuth flow — resolve, grant-check, materialise (with token), execute, filter
|
|
7
|
+
* 3. platform_oauth:<connection_id> flow — resolve, grant-check, materialise via
|
|
8
|
+
* platform, execute, filter
|
|
9
|
+
* 4. Approval-required short-circuit — off-grant requests return a proposal
|
|
10
|
+
* without making any network call
|
|
11
|
+
* 5. Forbidden header rejection — caller-supplied auth headers are blocked
|
|
12
|
+
* 6. Redirect denial — redirect hops that violate grant policy are blocked
|
|
13
|
+
* 7. Filtered response behaviour — secret scrubbing, header stripping, body clamping
|
|
14
|
+
* 8. Audit summaries are token-free
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { describe, expect, test, beforeEach, afterEach } from "bun:test";
|
|
18
|
+
import { randomUUID } from "node:crypto";
|
|
19
|
+
import { mkdirSync, rmSync } from "node:fs";
|
|
20
|
+
import { join } from "node:path";
|
|
21
|
+
import { tmpdir } from "node:os";
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
localStaticHandle,
|
|
25
|
+
localOAuthHandle,
|
|
26
|
+
platformOAuthHandle,
|
|
27
|
+
} from "@vellumai/ces-contracts";
|
|
28
|
+
import {
|
|
29
|
+
type OAuthConnectionRecord,
|
|
30
|
+
type SecureKeyBackend,
|
|
31
|
+
type SecureKeyDeleteResult,
|
|
32
|
+
type StaticCredentialRecord,
|
|
33
|
+
StaticCredentialMetadataStore,
|
|
34
|
+
oauthConnectionAccessTokenPath,
|
|
35
|
+
credentialKey,
|
|
36
|
+
} from "@vellumai/credential-storage";
|
|
37
|
+
|
|
38
|
+
import {
|
|
39
|
+
executeAuthenticatedHttpRequest,
|
|
40
|
+
type HttpExecutorDeps,
|
|
41
|
+
} from "../http/executor.js";
|
|
42
|
+
import { PersistentGrantStore } from "../grants/persistent-store.js";
|
|
43
|
+
import { TemporaryGrantStore } from "../grants/temporary-store.js";
|
|
44
|
+
import { LocalMaterialiser } from "../materializers/local.js";
|
|
45
|
+
import type { OAuthConnectionLookup, LocalSubjectResolverDeps } from "../subjects/local.js";
|
|
46
|
+
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
// Test helpers
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
function makeTmpDir(): string {
|
|
52
|
+
const dir = join(tmpdir(), `ces-http-executor-test-${randomUUID()}`);
|
|
53
|
+
mkdirSync(dir, { recursive: true });
|
|
54
|
+
return dir;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* In-memory secure-key backend for testing.
|
|
59
|
+
*/
|
|
60
|
+
function createMemoryBackend(
|
|
61
|
+
initial: Record<string, string> = {},
|
|
62
|
+
): SecureKeyBackend {
|
|
63
|
+
const store = new Map<string, string>(Object.entries(initial));
|
|
64
|
+
return {
|
|
65
|
+
async get(key: string): Promise<string | undefined> {
|
|
66
|
+
return store.get(key);
|
|
67
|
+
},
|
|
68
|
+
async set(key: string, value: string): Promise<boolean> {
|
|
69
|
+
store.set(key, value);
|
|
70
|
+
return true;
|
|
71
|
+
},
|
|
72
|
+
async delete(key: string): Promise<SecureKeyDeleteResult> {
|
|
73
|
+
if (store.has(key)) {
|
|
74
|
+
store.delete(key);
|
|
75
|
+
return "deleted";
|
|
76
|
+
}
|
|
77
|
+
return "not-found";
|
|
78
|
+
},
|
|
79
|
+
async list(): Promise<string[]> {
|
|
80
|
+
return Array.from(store.keys());
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function buildStaticRecord(
|
|
86
|
+
overrides: Partial<StaticCredentialRecord> = {},
|
|
87
|
+
): StaticCredentialRecord {
|
|
88
|
+
return {
|
|
89
|
+
credentialId: overrides.credentialId ?? "cred-uuid-1",
|
|
90
|
+
service: overrides.service ?? "github",
|
|
91
|
+
field: overrides.field ?? "api_key",
|
|
92
|
+
allowedTools: overrides.allowedTools ?? [],
|
|
93
|
+
allowedDomains: overrides.allowedDomains ?? [],
|
|
94
|
+
createdAt: overrides.createdAt ?? Date.now(),
|
|
95
|
+
updatedAt: overrides.updatedAt ?? Date.now(),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function buildOAuthConnection(
|
|
100
|
+
overrides: Partial<OAuthConnectionRecord> = {},
|
|
101
|
+
): OAuthConnectionRecord {
|
|
102
|
+
return {
|
|
103
|
+
id: overrides.id ?? "conn-uuid-1",
|
|
104
|
+
providerKey: overrides.providerKey ?? "integration:google",
|
|
105
|
+
accountInfo: overrides.accountInfo ?? "user@example.com",
|
|
106
|
+
grantedScopes: overrides.grantedScopes ?? ["openid", "email"],
|
|
107
|
+
accessTokenPath: overrides.accessTokenPath ??
|
|
108
|
+
oauthConnectionAccessTokenPath(overrides.id ?? "conn-uuid-1"),
|
|
109
|
+
hasRefreshToken: overrides.hasRefreshToken ?? true,
|
|
110
|
+
expiresAt: overrides.expiresAt ?? null,
|
|
111
|
+
createdAt: overrides.createdAt ?? Date.now(),
|
|
112
|
+
updatedAt: overrides.updatedAt ?? Date.now(),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function createOAuthLookup(
|
|
117
|
+
connections: OAuthConnectionRecord[] = [],
|
|
118
|
+
): OAuthConnectionLookup {
|
|
119
|
+
const byId = new Map(connections.map((c) => [c.id, c]));
|
|
120
|
+
return {
|
|
121
|
+
getById(id: string) {
|
|
122
|
+
return byId.get(id);
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Build a mock fetch that returns a predetermined response.
|
|
129
|
+
*/
|
|
130
|
+
function mockFetch(
|
|
131
|
+
statusCode: number,
|
|
132
|
+
body: string,
|
|
133
|
+
headers?: Record<string, string>,
|
|
134
|
+
): typeof globalThis.fetch {
|
|
135
|
+
return asFetch(async (_url: string | URL | Request, _init?: RequestInit) => {
|
|
136
|
+
const responseHeaders = new Headers(headers ?? {});
|
|
137
|
+
if (!responseHeaders.has("content-type")) {
|
|
138
|
+
responseHeaders.set("content-type", "application/json");
|
|
139
|
+
}
|
|
140
|
+
return new Response(body, {
|
|
141
|
+
status: statusCode,
|
|
142
|
+
headers: responseHeaders,
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Build a mock fetch that records requests for inspection.
|
|
149
|
+
*/
|
|
150
|
+
function mockFetchRecorder(
|
|
151
|
+
statusCode: number,
|
|
152
|
+
body: string,
|
|
153
|
+
headers?: Record<string, string>,
|
|
154
|
+
): {
|
|
155
|
+
fetch: typeof globalThis.fetch;
|
|
156
|
+
requests: Array<{ url: string; init?: RequestInit }>;
|
|
157
|
+
} {
|
|
158
|
+
const requests: Array<{ url: string; init?: RequestInit }> = [];
|
|
159
|
+
const fetchFn = asFetch(async (
|
|
160
|
+
url: string | URL | Request,
|
|
161
|
+
init?: RequestInit,
|
|
162
|
+
) => {
|
|
163
|
+
requests.push({ url: url.toString(), init });
|
|
164
|
+
const responseHeaders = new Headers(headers ?? {});
|
|
165
|
+
if (!responseHeaders.has("content-type")) {
|
|
166
|
+
responseHeaders.set("content-type", "application/json");
|
|
167
|
+
}
|
|
168
|
+
return new Response(body, {
|
|
169
|
+
status: statusCode,
|
|
170
|
+
headers: responseHeaders,
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
return { fetch: fetchFn, requests };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Build a mock fetch that returns a redirect on first call, then a real
|
|
178
|
+
* response on subsequent calls.
|
|
179
|
+
*/
|
|
180
|
+
function mockFetchRedirect(
|
|
181
|
+
redirectUrl: string,
|
|
182
|
+
redirectStatus: number,
|
|
183
|
+
finalStatusCode: number,
|
|
184
|
+
finalBody: string,
|
|
185
|
+
): typeof globalThis.fetch {
|
|
186
|
+
let callCount = 0;
|
|
187
|
+
return asFetch(async (_url: string | URL | Request, _init?: RequestInit) => {
|
|
188
|
+
callCount++;
|
|
189
|
+
if (callCount === 1) {
|
|
190
|
+
return new Response(null, {
|
|
191
|
+
status: redirectStatus,
|
|
192
|
+
headers: { Location: redirectUrl },
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
return new Response(finalBody, {
|
|
196
|
+
status: finalStatusCode,
|
|
197
|
+
headers: { "Content-Type": "application/json" },
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Attach a no-op `preconnect` so a plain async function satisfies
|
|
204
|
+
* Bun's `typeof globalThis.fetch` (which includes `preconnect`).
|
|
205
|
+
*/
|
|
206
|
+
function asFetch(
|
|
207
|
+
fn: (url: string | URL | Request, init?: RequestInit) => Promise<Response>,
|
|
208
|
+
): typeof globalThis.fetch {
|
|
209
|
+
return Object.assign(fn, {
|
|
210
|
+
preconnect: (_url: string | URL) => {},
|
|
211
|
+
}) as unknown as typeof globalThis.fetch;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Silent logger that suppresses output during tests. */
|
|
215
|
+
const silentLogger: Pick<Console, "log" | "warn" | "error"> = {
|
|
216
|
+
log: () => {},
|
|
217
|
+
warn: () => {},
|
|
218
|
+
error: () => {},
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
// ---------------------------------------------------------------------------
|
|
222
|
+
// Test fixtures
|
|
223
|
+
// ---------------------------------------------------------------------------
|
|
224
|
+
|
|
225
|
+
interface TestFixture {
|
|
226
|
+
tmpDir: string;
|
|
227
|
+
persistentStore: PersistentGrantStore;
|
|
228
|
+
temporaryStore: TemporaryGrantStore;
|
|
229
|
+
backend: SecureKeyBackend;
|
|
230
|
+
metadataStore: StaticCredentialMetadataStore;
|
|
231
|
+
localMaterialiser: LocalMaterialiser;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function createFixture(
|
|
235
|
+
secretEntries: Record<string, string> = {},
|
|
236
|
+
staticRecords: StaticCredentialRecord[] = [],
|
|
237
|
+
oauthConnections: OAuthConnectionRecord[] = [],
|
|
238
|
+
): TestFixture {
|
|
239
|
+
const tmpDir = makeTmpDir();
|
|
240
|
+
const persistentStore = new PersistentGrantStore(tmpDir);
|
|
241
|
+
persistentStore.init();
|
|
242
|
+
const temporaryStore = new TemporaryGrantStore();
|
|
243
|
+
|
|
244
|
+
const backend = createMemoryBackend(secretEntries);
|
|
245
|
+
const metadataStore = new StaticCredentialMetadataStore(
|
|
246
|
+
join(tmpDir, "credentials.json"),
|
|
247
|
+
);
|
|
248
|
+
for (const record of staticRecords) {
|
|
249
|
+
metadataStore.upsert(record.service, record.field, {
|
|
250
|
+
allowedTools: record.allowedTools,
|
|
251
|
+
allowedDomains: record.allowedDomains,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const oauthLookup = createOAuthLookup(oauthConnections);
|
|
256
|
+
|
|
257
|
+
const localMaterialiser = new LocalMaterialiser({
|
|
258
|
+
secureKeyBackend: backend,
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
return {
|
|
262
|
+
tmpDir,
|
|
263
|
+
persistentStore,
|
|
264
|
+
temporaryStore,
|
|
265
|
+
backend,
|
|
266
|
+
metadataStore,
|
|
267
|
+
localMaterialiser,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function buildDeps(
|
|
272
|
+
fixture: TestFixture,
|
|
273
|
+
oauthConnections: OAuthConnectionRecord[] = [],
|
|
274
|
+
overrides: Partial<HttpExecutorDeps> = {},
|
|
275
|
+
): HttpExecutorDeps {
|
|
276
|
+
return {
|
|
277
|
+
persistentGrantStore: fixture.persistentStore,
|
|
278
|
+
temporaryGrantStore: fixture.temporaryStore,
|
|
279
|
+
localMaterialiser: fixture.localMaterialiser,
|
|
280
|
+
localSubjectDeps: {
|
|
281
|
+
metadataStore: fixture.metadataStore,
|
|
282
|
+
oauthConnections: createOAuthLookup(oauthConnections),
|
|
283
|
+
},
|
|
284
|
+
sessionId: "test-session",
|
|
285
|
+
logger: silentLogger,
|
|
286
|
+
...overrides,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// ---------------------------------------------------------------------------
|
|
291
|
+
// Tests: Local static secrets
|
|
292
|
+
// ---------------------------------------------------------------------------
|
|
293
|
+
|
|
294
|
+
describe("HTTP executor: local static secrets", () => {
|
|
295
|
+
let fixture: TestFixture;
|
|
296
|
+
|
|
297
|
+
beforeEach(() => {
|
|
298
|
+
const handle = localStaticHandle("github", "api_key");
|
|
299
|
+
const storageKey = credentialKey("github", "api_key");
|
|
300
|
+
fixture = createFixture(
|
|
301
|
+
{ [storageKey]: "ghp_testtoken_12345678" },
|
|
302
|
+
[buildStaticRecord({ service: "github", field: "api_key" })],
|
|
303
|
+
);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
afterEach(() => {
|
|
307
|
+
rmSync(fixture.tmpDir, { recursive: true, force: true });
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test("successful request with matching grant", async () => {
|
|
311
|
+
const handle = localStaticHandle("github", "api_key");
|
|
312
|
+
|
|
313
|
+
// Add a matching grant
|
|
314
|
+
fixture.persistentStore.add({
|
|
315
|
+
id: "grant-github-repos",
|
|
316
|
+
tool: "http",
|
|
317
|
+
pattern: "GET https://api.github.com/repos/owner/repo",
|
|
318
|
+
scope: handle,
|
|
319
|
+
createdAt: Date.now(),
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
const { fetch: fetchFn, requests } = mockFetchRecorder(
|
|
323
|
+
200,
|
|
324
|
+
'{"name": "repo", "full_name": "owner/repo"}',
|
|
325
|
+
);
|
|
326
|
+
|
|
327
|
+
const deps = buildDeps(fixture, [], { fetch: fetchFn });
|
|
328
|
+
const result = await executeAuthenticatedHttpRequest(
|
|
329
|
+
{
|
|
330
|
+
credentialHandle: handle,
|
|
331
|
+
method: "GET",
|
|
332
|
+
url: "https://api.github.com/repos/owner/repo",
|
|
333
|
+
purpose: "Get repo info",
|
|
334
|
+
},
|
|
335
|
+
deps,
|
|
336
|
+
);
|
|
337
|
+
|
|
338
|
+
expect(result.success).toBe(true);
|
|
339
|
+
expect(result.statusCode).toBe(200);
|
|
340
|
+
expect(result.responseBody).toContain("owner/repo");
|
|
341
|
+
expect(result.auditId).toBeDefined();
|
|
342
|
+
|
|
343
|
+
// Verify the outbound request had auth injected
|
|
344
|
+
expect(requests).toHaveLength(1);
|
|
345
|
+
const outboundHeaders = requests[0].init?.headers as Record<string, string>;
|
|
346
|
+
expect(outboundHeaders?.["Authorization"]).toContain("Bearer");
|
|
347
|
+
expect(outboundHeaders?.["Authorization"]).toContain("ghp_testtoken_12345678");
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
test("response body is scrubbed of secret values", async () => {
|
|
351
|
+
const handle = localStaticHandle("github", "api_key");
|
|
352
|
+
|
|
353
|
+
fixture.persistentStore.add({
|
|
354
|
+
id: "grant-github-user",
|
|
355
|
+
tool: "http",
|
|
356
|
+
pattern: "GET https://api.github.com/user",
|
|
357
|
+
scope: handle,
|
|
358
|
+
createdAt: Date.now(),
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
// Simulate API echoing back the token in response
|
|
362
|
+
const fetchFn = mockFetch(
|
|
363
|
+
200,
|
|
364
|
+
'{"token_echo": "ghp_testtoken_12345678", "name": "octocat"}',
|
|
365
|
+
);
|
|
366
|
+
|
|
367
|
+
const deps = buildDeps(fixture, [], { fetch: fetchFn });
|
|
368
|
+
const result = await executeAuthenticatedHttpRequest(
|
|
369
|
+
{
|
|
370
|
+
credentialHandle: handle,
|
|
371
|
+
method: "GET",
|
|
372
|
+
url: "https://api.github.com/user",
|
|
373
|
+
purpose: "Get user info",
|
|
374
|
+
},
|
|
375
|
+
deps,
|
|
376
|
+
);
|
|
377
|
+
|
|
378
|
+
expect(result.success).toBe(true);
|
|
379
|
+
expect(result.responseBody).not.toContain("ghp_testtoken_12345678");
|
|
380
|
+
expect(result.responseBody).toContain("[CES:REDACTED]");
|
|
381
|
+
expect(result.responseBody).toContain("octocat");
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
test("response headers are filtered (set-cookie stripped)", async () => {
|
|
385
|
+
const handle = localStaticHandle("github", "api_key");
|
|
386
|
+
|
|
387
|
+
fixture.persistentStore.add({
|
|
388
|
+
id: "grant-github-data",
|
|
389
|
+
tool: "http",
|
|
390
|
+
pattern: "GET https://api.github.com/data",
|
|
391
|
+
scope: handle,
|
|
392
|
+
createdAt: Date.now(),
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
const fetchFn = mockFetch(200, '{"ok": true}', {
|
|
396
|
+
"content-type": "application/json",
|
|
397
|
+
"set-cookie": "session=secret; HttpOnly",
|
|
398
|
+
"x-request-id": "req-123",
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
const deps = buildDeps(fixture, [], { fetch: fetchFn });
|
|
402
|
+
const result = await executeAuthenticatedHttpRequest(
|
|
403
|
+
{
|
|
404
|
+
credentialHandle: handle,
|
|
405
|
+
method: "GET",
|
|
406
|
+
url: "https://api.github.com/data",
|
|
407
|
+
purpose: "Get data",
|
|
408
|
+
},
|
|
409
|
+
deps,
|
|
410
|
+
);
|
|
411
|
+
|
|
412
|
+
expect(result.success).toBe(true);
|
|
413
|
+
expect(result.responseHeaders).toBeDefined();
|
|
414
|
+
expect(result.responseHeaders!["content-type"]).toBe("application/json");
|
|
415
|
+
expect(result.responseHeaders!["x-request-id"]).toBe("req-123");
|
|
416
|
+
// set-cookie must be stripped
|
|
417
|
+
expect(result.responseHeaders!["set-cookie"]).toBeUndefined();
|
|
418
|
+
});
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
// ---------------------------------------------------------------------------
|
|
422
|
+
// Tests: Local OAuth
|
|
423
|
+
// ---------------------------------------------------------------------------
|
|
424
|
+
|
|
425
|
+
describe("HTTP executor: local OAuth", () => {
|
|
426
|
+
let fixture: TestFixture;
|
|
427
|
+
let connection: OAuthConnectionRecord;
|
|
428
|
+
|
|
429
|
+
beforeEach(() => {
|
|
430
|
+
connection = buildOAuthConnection({
|
|
431
|
+
id: "conn-google-1",
|
|
432
|
+
providerKey: "integration:google",
|
|
433
|
+
expiresAt: Date.now() + 3600000, // valid for 1 hour
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
const accessTokenPath = oauthConnectionAccessTokenPath("conn-google-1");
|
|
437
|
+
fixture = createFixture(
|
|
438
|
+
{ [accessTokenPath]: "ya29.test-google-token-abc123" },
|
|
439
|
+
[],
|
|
440
|
+
[connection],
|
|
441
|
+
);
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
afterEach(() => {
|
|
445
|
+
rmSync(fixture.tmpDir, { recursive: true, force: true });
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
test("successful OAuth request with matching grant", async () => {
|
|
449
|
+
const handle = localOAuthHandle("integration:google", "conn-google-1");
|
|
450
|
+
|
|
451
|
+
fixture.persistentStore.add({
|
|
452
|
+
id: "grant-google-calendar",
|
|
453
|
+
tool: "http",
|
|
454
|
+
pattern: "GET https://www.googleapis.com/calendar/v3/calendars/primary/events",
|
|
455
|
+
scope: handle,
|
|
456
|
+
createdAt: Date.now(),
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
const { fetch: fetchFn, requests } = mockFetchRecorder(
|
|
460
|
+
200,
|
|
461
|
+
'{"items": [{"summary": "Meeting"}]}',
|
|
462
|
+
);
|
|
463
|
+
|
|
464
|
+
const deps = buildDeps(fixture, [connection], { fetch: fetchFn });
|
|
465
|
+
const result = await executeAuthenticatedHttpRequest(
|
|
466
|
+
{
|
|
467
|
+
credentialHandle: handle,
|
|
468
|
+
method: "GET",
|
|
469
|
+
url: "https://www.googleapis.com/calendar/v3/calendars/primary/events",
|
|
470
|
+
purpose: "List calendar events",
|
|
471
|
+
},
|
|
472
|
+
deps,
|
|
473
|
+
);
|
|
474
|
+
|
|
475
|
+
expect(result.success).toBe(true);
|
|
476
|
+
expect(result.statusCode).toBe(200);
|
|
477
|
+
expect(result.responseBody).toContain("Meeting");
|
|
478
|
+
expect(result.auditId).toBeDefined();
|
|
479
|
+
|
|
480
|
+
// Verify Bearer token was injected
|
|
481
|
+
expect(requests).toHaveLength(1);
|
|
482
|
+
const outboundHeaders = requests[0].init?.headers as Record<string, string>;
|
|
483
|
+
expect(outboundHeaders?.["Authorization"]).toBe(
|
|
484
|
+
"Bearer ya29.test-google-token-abc123",
|
|
485
|
+
);
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
test("OAuth token scrubbed from response body", async () => {
|
|
489
|
+
const handle = localOAuthHandle("integration:google", "conn-google-1");
|
|
490
|
+
|
|
491
|
+
fixture.persistentStore.add({
|
|
492
|
+
id: "grant-google-debug",
|
|
493
|
+
tool: "http",
|
|
494
|
+
pattern: "GET https://www.googleapis.com/oauth2/v1/tokeninfo",
|
|
495
|
+
scope: handle,
|
|
496
|
+
createdAt: Date.now(),
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
// Simulate tokeninfo endpoint echoing the token
|
|
500
|
+
const fetchFn = mockFetch(
|
|
501
|
+
200,
|
|
502
|
+
'{"access_token": "ya29.test-google-token-abc123", "expires_in": 3600}',
|
|
503
|
+
);
|
|
504
|
+
|
|
505
|
+
const deps = buildDeps(fixture, [connection], { fetch: fetchFn });
|
|
506
|
+
const result = await executeAuthenticatedHttpRequest(
|
|
507
|
+
{
|
|
508
|
+
credentialHandle: handle,
|
|
509
|
+
method: "GET",
|
|
510
|
+
url: "https://www.googleapis.com/oauth2/v1/tokeninfo",
|
|
511
|
+
purpose: "Check token info",
|
|
512
|
+
},
|
|
513
|
+
deps,
|
|
514
|
+
);
|
|
515
|
+
|
|
516
|
+
expect(result.success).toBe(true);
|
|
517
|
+
expect(result.responseBody).not.toContain("ya29.test-google-token-abc123");
|
|
518
|
+
expect(result.responseBody).toContain("[CES:REDACTED]");
|
|
519
|
+
});
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
// ---------------------------------------------------------------------------
|
|
523
|
+
// Tests: Managed (platform_oauth) handles
|
|
524
|
+
// ---------------------------------------------------------------------------
|
|
525
|
+
|
|
526
|
+
describe("HTTP executor: platform_oauth handles", () => {
|
|
527
|
+
let fixture: TestFixture;
|
|
528
|
+
let tmpDir: string;
|
|
529
|
+
|
|
530
|
+
beforeEach(() => {
|
|
531
|
+
fixture = createFixture();
|
|
532
|
+
tmpDir = fixture.tmpDir;
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
afterEach(() => {
|
|
536
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
test("successful platform OAuth request", async () => {
|
|
540
|
+
const handle = platformOAuthHandle("conn-platform-1");
|
|
541
|
+
|
|
542
|
+
fixture.persistentStore.add({
|
|
543
|
+
id: "grant-platform-api",
|
|
544
|
+
tool: "http",
|
|
545
|
+
pattern: "GET https://api.slack.com/api/conversations.list",
|
|
546
|
+
scope: handle,
|
|
547
|
+
createdAt: Date.now(),
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
// Mock the platform catalog response
|
|
551
|
+
const platformCatalogFetch = asFetch(async (
|
|
552
|
+
url: string | URL | Request,
|
|
553
|
+
_init?: RequestInit,
|
|
554
|
+
) => {
|
|
555
|
+
const urlStr = url.toString();
|
|
556
|
+
|
|
557
|
+
// Platform catalog
|
|
558
|
+
if (urlStr.includes("/v1/ces/catalog")) {
|
|
559
|
+
return new Response(
|
|
560
|
+
JSON.stringify({
|
|
561
|
+
connections: [
|
|
562
|
+
{
|
|
563
|
+
id: "conn-platform-1",
|
|
564
|
+
provider: "slack",
|
|
565
|
+
account_info: "workspace@slack.com",
|
|
566
|
+
granted_scopes: ["channels:read"],
|
|
567
|
+
status: "active",
|
|
568
|
+
},
|
|
569
|
+
],
|
|
570
|
+
}),
|
|
571
|
+
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
572
|
+
);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// Platform token materialization
|
|
576
|
+
if (urlStr.includes("/v1/ces/connections/conn-platform-1/materialize")) {
|
|
577
|
+
return new Response(
|
|
578
|
+
JSON.stringify({
|
|
579
|
+
access_token: "xoxp-platform-token-12345678",
|
|
580
|
+
token_type: "Bearer",
|
|
581
|
+
expires_in: 3600,
|
|
582
|
+
}),
|
|
583
|
+
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
584
|
+
);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// The actual outbound Slack API call
|
|
588
|
+
if (urlStr.includes("api.slack.com")) {
|
|
589
|
+
return new Response(
|
|
590
|
+
JSON.stringify({
|
|
591
|
+
ok: true,
|
|
592
|
+
channels: [{ name: "general" }],
|
|
593
|
+
}),
|
|
594
|
+
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
595
|
+
);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
return new Response("Not found", { status: 404 });
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
const deps = buildDeps(fixture, [], {
|
|
602
|
+
fetch: platformCatalogFetch,
|
|
603
|
+
managedSubjectOptions: {
|
|
604
|
+
platformBaseUrl: "https://api.vellum.ai",
|
|
605
|
+
assistantApiKey: "test-api-key",
|
|
606
|
+
fetch: platformCatalogFetch,
|
|
607
|
+
},
|
|
608
|
+
managedMaterializerOptions: {
|
|
609
|
+
platformBaseUrl: "https://api.vellum.ai",
|
|
610
|
+
assistantApiKey: "test-api-key",
|
|
611
|
+
fetch: platformCatalogFetch,
|
|
612
|
+
},
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
const result = await executeAuthenticatedHttpRequest(
|
|
616
|
+
{
|
|
617
|
+
credentialHandle: handle,
|
|
618
|
+
method: "GET",
|
|
619
|
+
url: "https://api.slack.com/api/conversations.list",
|
|
620
|
+
purpose: "List Slack channels",
|
|
621
|
+
},
|
|
622
|
+
deps,
|
|
623
|
+
);
|
|
624
|
+
|
|
625
|
+
expect(result.success).toBe(true);
|
|
626
|
+
expect(result.statusCode).toBe(200);
|
|
627
|
+
expect(result.responseBody).toContain("general");
|
|
628
|
+
expect(result.auditId).toBeDefined();
|
|
629
|
+
|
|
630
|
+
// Token should be scrubbed from body
|
|
631
|
+
expect(result.responseBody).not.toContain("xoxp-platform-token-12345678");
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
test("fails when managed mode is not configured", async () => {
|
|
635
|
+
const handle = platformOAuthHandle("conn-platform-1");
|
|
636
|
+
|
|
637
|
+
fixture.persistentStore.add({
|
|
638
|
+
id: "grant-platform-unconfigured",
|
|
639
|
+
tool: "http",
|
|
640
|
+
pattern: "GET https://api.slack.com/data",
|
|
641
|
+
scope: handle,
|
|
642
|
+
createdAt: Date.now(),
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
// No managedSubjectOptions or managedMaterializerOptions
|
|
646
|
+
const deps = buildDeps(fixture, []);
|
|
647
|
+
|
|
648
|
+
const result = await executeAuthenticatedHttpRequest(
|
|
649
|
+
{
|
|
650
|
+
credentialHandle: handle,
|
|
651
|
+
method: "GET",
|
|
652
|
+
url: "https://api.slack.com/data",
|
|
653
|
+
purpose: "Get data",
|
|
654
|
+
},
|
|
655
|
+
deps,
|
|
656
|
+
);
|
|
657
|
+
|
|
658
|
+
expect(result.success).toBe(false);
|
|
659
|
+
expect(result.error).toBeDefined();
|
|
660
|
+
expect(result.error!.code).toBe("MATERIALISATION_FAILED");
|
|
661
|
+
expect(result.error!.message).toContain("not configured");
|
|
662
|
+
});
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
// ---------------------------------------------------------------------------
|
|
666
|
+
// Tests: Approval required short-circuit
|
|
667
|
+
// ---------------------------------------------------------------------------
|
|
668
|
+
|
|
669
|
+
describe("HTTP executor: approval-required short-circuit", () => {
|
|
670
|
+
let fixture: TestFixture;
|
|
671
|
+
|
|
672
|
+
beforeEach(() => {
|
|
673
|
+
const storageKey = credentialKey("stripe", "api_key");
|
|
674
|
+
fixture = createFixture(
|
|
675
|
+
{ [storageKey]: "sk_test_abcdefghijklmnop" },
|
|
676
|
+
[buildStaticRecord({ service: "stripe", field: "api_key" })],
|
|
677
|
+
);
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
afterEach(() => {
|
|
681
|
+
rmSync(fixture.tmpDir, { recursive: true, force: true });
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
test("off-grant request returns approval_required without network call", async () => {
|
|
685
|
+
const handle = localStaticHandle("stripe", "api_key");
|
|
686
|
+
// No grant added — should be blocked
|
|
687
|
+
|
|
688
|
+
const { fetch: fetchFn, requests } = mockFetchRecorder(200, '{"ok": true}');
|
|
689
|
+
const deps = buildDeps(fixture, [], { fetch: fetchFn });
|
|
690
|
+
|
|
691
|
+
const result = await executeAuthenticatedHttpRequest(
|
|
692
|
+
{
|
|
693
|
+
credentialHandle: handle,
|
|
694
|
+
method: "POST",
|
|
695
|
+
url: "https://api.stripe.com/v1/charges",
|
|
696
|
+
purpose: "Create a charge",
|
|
697
|
+
},
|
|
698
|
+
deps,
|
|
699
|
+
);
|
|
700
|
+
|
|
701
|
+
// Request must be blocked
|
|
702
|
+
expect(result.success).toBe(false);
|
|
703
|
+
expect(result.error).toBeDefined();
|
|
704
|
+
expect(result.error!.code).toBe("APPROVAL_REQUIRED");
|
|
705
|
+
expect(result.error!.details).toBeDefined();
|
|
706
|
+
expect((result.error!.details as Record<string, unknown>).proposal).toBeDefined();
|
|
707
|
+
expect((result.error!.details as Record<string, unknown>).proposalHash).toBeDefined();
|
|
708
|
+
|
|
709
|
+
// No network call should have been made
|
|
710
|
+
expect(requests).toHaveLength(0);
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
test("proposal contains specific URL pattern (no wildcards)", async () => {
|
|
714
|
+
const handle = localStaticHandle("stripe", "api_key");
|
|
715
|
+
|
|
716
|
+
const deps = buildDeps(fixture, []);
|
|
717
|
+
|
|
718
|
+
const result = await executeAuthenticatedHttpRequest(
|
|
719
|
+
{
|
|
720
|
+
credentialHandle: handle,
|
|
721
|
+
method: "GET",
|
|
722
|
+
url: "https://api.stripe.com/v1/charges/123",
|
|
723
|
+
purpose: "Get charge",
|
|
724
|
+
},
|
|
725
|
+
deps,
|
|
726
|
+
);
|
|
727
|
+
|
|
728
|
+
expect(result.success).toBe(false);
|
|
729
|
+
expect(result.error!.code).toBe("APPROVAL_REQUIRED");
|
|
730
|
+
|
|
731
|
+
const proposal = (result.error!.details as Record<string, unknown>)
|
|
732
|
+
.proposal as Record<string, unknown>;
|
|
733
|
+
const allowedPatterns = proposal.allowedUrlPatterns as string[];
|
|
734
|
+
expect(allowedPatterns).toBeDefined();
|
|
735
|
+
expect(allowedPatterns.length).toBeGreaterThan(0);
|
|
736
|
+
|
|
737
|
+
for (const pattern of allowedPatterns) {
|
|
738
|
+
expect(pattern).not.toContain("/*");
|
|
739
|
+
expect(pattern).not.toContain("*.");
|
|
740
|
+
// Should have {:num} for the charge ID
|
|
741
|
+
expect(pattern).toContain("{:num}");
|
|
742
|
+
}
|
|
743
|
+
});
|
|
744
|
+
});
|
|
745
|
+
|
|
746
|
+
// ---------------------------------------------------------------------------
|
|
747
|
+
// Tests: Forbidden header rejection
|
|
748
|
+
// ---------------------------------------------------------------------------
|
|
749
|
+
|
|
750
|
+
describe("HTTP executor: forbidden header rejection", () => {
|
|
751
|
+
let fixture: TestFixture;
|
|
752
|
+
|
|
753
|
+
beforeEach(() => {
|
|
754
|
+
const storageKey = credentialKey("github", "api_key");
|
|
755
|
+
fixture = createFixture(
|
|
756
|
+
{ [storageKey]: "ghp_testtoken_12345678" },
|
|
757
|
+
[buildStaticRecord({ service: "github", field: "api_key" })],
|
|
758
|
+
);
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
afterEach(() => {
|
|
762
|
+
rmSync(fixture.tmpDir, { recursive: true, force: true });
|
|
763
|
+
});
|
|
764
|
+
|
|
765
|
+
test("rejects request with Authorization header", async () => {
|
|
766
|
+
const handle = localStaticHandle("github", "api_key");
|
|
767
|
+
|
|
768
|
+
// Even with a matching grant, should be rejected
|
|
769
|
+
fixture.persistentStore.add({
|
|
770
|
+
id: "grant-with-auth",
|
|
771
|
+
tool: "http",
|
|
772
|
+
pattern: "GET https://api.github.com/user",
|
|
773
|
+
scope: handle,
|
|
774
|
+
createdAt: Date.now(),
|
|
775
|
+
});
|
|
776
|
+
|
|
777
|
+
const { fetch: fetchFn, requests } = mockFetchRecorder(200, '{"ok": true}');
|
|
778
|
+
const deps = buildDeps(fixture, [], { fetch: fetchFn });
|
|
779
|
+
|
|
780
|
+
const result = await executeAuthenticatedHttpRequest(
|
|
781
|
+
{
|
|
782
|
+
credentialHandle: handle,
|
|
783
|
+
method: "GET",
|
|
784
|
+
url: "https://api.github.com/user",
|
|
785
|
+
headers: { Authorization: "Bearer smuggled-token" },
|
|
786
|
+
purpose: "Get user info",
|
|
787
|
+
},
|
|
788
|
+
deps,
|
|
789
|
+
);
|
|
790
|
+
|
|
791
|
+
expect(result.success).toBe(false);
|
|
792
|
+
expect(result.error!.code).toBe("FORBIDDEN_HEADERS");
|
|
793
|
+
expect(result.error!.message).toContain("Authorization");
|
|
794
|
+
expect(requests).toHaveLength(0);
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
test("rejects request with Cookie header", async () => {
|
|
798
|
+
const handle = localStaticHandle("github", "api_key");
|
|
799
|
+
|
|
800
|
+
fixture.persistentStore.add({
|
|
801
|
+
id: "grant-with-cookie",
|
|
802
|
+
tool: "http",
|
|
803
|
+
pattern: "GET https://api.github.com/user",
|
|
804
|
+
scope: handle,
|
|
805
|
+
createdAt: Date.now(),
|
|
806
|
+
});
|
|
807
|
+
|
|
808
|
+
const { fetch: fetchFn, requests } = mockFetchRecorder(200, '{"ok": true}');
|
|
809
|
+
const deps = buildDeps(fixture, [], { fetch: fetchFn });
|
|
810
|
+
|
|
811
|
+
const result = await executeAuthenticatedHttpRequest(
|
|
812
|
+
{
|
|
813
|
+
credentialHandle: handle,
|
|
814
|
+
method: "GET",
|
|
815
|
+
url: "https://api.github.com/user",
|
|
816
|
+
headers: { Cookie: "session=hijacked" },
|
|
817
|
+
purpose: "Get user info",
|
|
818
|
+
},
|
|
819
|
+
deps,
|
|
820
|
+
);
|
|
821
|
+
|
|
822
|
+
expect(result.success).toBe(false);
|
|
823
|
+
expect(result.error!.code).toBe("FORBIDDEN_HEADERS");
|
|
824
|
+
expect(requests).toHaveLength(0);
|
|
825
|
+
});
|
|
826
|
+
|
|
827
|
+
test("rejects request with X-Api-Key header", async () => {
|
|
828
|
+
const handle = localStaticHandle("github", "api_key");
|
|
829
|
+
|
|
830
|
+
fixture.persistentStore.add({
|
|
831
|
+
id: "grant-with-xapikey",
|
|
832
|
+
tool: "http",
|
|
833
|
+
pattern: "GET https://api.github.com/user",
|
|
834
|
+
scope: handle,
|
|
835
|
+
createdAt: Date.now(),
|
|
836
|
+
});
|
|
837
|
+
|
|
838
|
+
const deps = buildDeps(fixture, []);
|
|
839
|
+
|
|
840
|
+
const result = await executeAuthenticatedHttpRequest(
|
|
841
|
+
{
|
|
842
|
+
credentialHandle: handle,
|
|
843
|
+
method: "GET",
|
|
844
|
+
url: "https://api.github.com/user",
|
|
845
|
+
headers: { "X-Api-Key": "smuggled-key" },
|
|
846
|
+
purpose: "Get user info",
|
|
847
|
+
},
|
|
848
|
+
deps,
|
|
849
|
+
);
|
|
850
|
+
|
|
851
|
+
expect(result.success).toBe(false);
|
|
852
|
+
expect(result.error!.code).toBe("FORBIDDEN_HEADERS");
|
|
853
|
+
});
|
|
854
|
+
});
|
|
855
|
+
|
|
856
|
+
// ---------------------------------------------------------------------------
|
|
857
|
+
// Tests: Redirect denial
|
|
858
|
+
// ---------------------------------------------------------------------------
|
|
859
|
+
|
|
860
|
+
describe("HTTP executor: redirect denial", () => {
|
|
861
|
+
let fixture: TestFixture;
|
|
862
|
+
|
|
863
|
+
beforeEach(() => {
|
|
864
|
+
const storageKey = credentialKey("github", "api_key");
|
|
865
|
+
fixture = createFixture(
|
|
866
|
+
{ [storageKey]: "ghp_testtoken_12345678" },
|
|
867
|
+
[buildStaticRecord({ service: "github", field: "api_key" })],
|
|
868
|
+
);
|
|
869
|
+
});
|
|
870
|
+
|
|
871
|
+
afterEach(() => {
|
|
872
|
+
rmSync(fixture.tmpDir, { recursive: true, force: true });
|
|
873
|
+
});
|
|
874
|
+
|
|
875
|
+
test("blocks redirect to domain not covered by grant", async () => {
|
|
876
|
+
const handle = localStaticHandle("github", "api_key");
|
|
877
|
+
|
|
878
|
+
// Grant only covers api.github.com
|
|
879
|
+
fixture.persistentStore.add({
|
|
880
|
+
id: "grant-github-only",
|
|
881
|
+
tool: "http",
|
|
882
|
+
pattern: "GET https://api.github.com/repos/owner/repo",
|
|
883
|
+
scope: handle,
|
|
884
|
+
createdAt: Date.now(),
|
|
885
|
+
});
|
|
886
|
+
|
|
887
|
+
// Redirect to an evil domain
|
|
888
|
+
const fetchFn = mockFetchRedirect(
|
|
889
|
+
"https://evil.example.com/steal-token",
|
|
890
|
+
302,
|
|
891
|
+
200,
|
|
892
|
+
'{"stolen": true}',
|
|
893
|
+
);
|
|
894
|
+
|
|
895
|
+
const deps = buildDeps(fixture, [], { fetch: fetchFn });
|
|
896
|
+
|
|
897
|
+
const result = await executeAuthenticatedHttpRequest(
|
|
898
|
+
{
|
|
899
|
+
credentialHandle: handle,
|
|
900
|
+
method: "GET",
|
|
901
|
+
url: "https://api.github.com/repos/owner/repo",
|
|
902
|
+
purpose: "Get repo info",
|
|
903
|
+
},
|
|
904
|
+
deps,
|
|
905
|
+
);
|
|
906
|
+
|
|
907
|
+
expect(result.success).toBe(false);
|
|
908
|
+
expect(result.error!.code).toBe("HTTP_REQUEST_FAILED");
|
|
909
|
+
expect(result.error!.message).toContain("redirect");
|
|
910
|
+
expect(result.error!.message).toContain("denied");
|
|
911
|
+
});
|
|
912
|
+
|
|
913
|
+
test("allows redirect to path covered by same grant", async () => {
|
|
914
|
+
const handle = localStaticHandle("github", "api_key");
|
|
915
|
+
|
|
916
|
+
// Grant covers a template that matches both source and redirect target
|
|
917
|
+
fixture.persistentStore.add({
|
|
918
|
+
id: "grant-github-repos-template",
|
|
919
|
+
tool: "http",
|
|
920
|
+
pattern: "GET https://api.github.com/repos/owner/repo",
|
|
921
|
+
scope: handle,
|
|
922
|
+
createdAt: Date.now(),
|
|
923
|
+
});
|
|
924
|
+
|
|
925
|
+
// Redirect to the same domain/path that matches the grant
|
|
926
|
+
let callCount = 0;
|
|
927
|
+
const fetchFn = asFetch(async (
|
|
928
|
+
_url: string | URL | Request,
|
|
929
|
+
_init?: RequestInit,
|
|
930
|
+
) => {
|
|
931
|
+
callCount++;
|
|
932
|
+
if (callCount === 1) {
|
|
933
|
+
return new Response(null, {
|
|
934
|
+
status: 301,
|
|
935
|
+
headers: { Location: "https://api.github.com/repos/owner/repo" },
|
|
936
|
+
});
|
|
937
|
+
}
|
|
938
|
+
return new Response('{"name": "repo"}', {
|
|
939
|
+
status: 200,
|
|
940
|
+
headers: { "Content-Type": "application/json" },
|
|
941
|
+
});
|
|
942
|
+
});
|
|
943
|
+
|
|
944
|
+
const deps = buildDeps(fixture, [], { fetch: fetchFn });
|
|
945
|
+
|
|
946
|
+
const result = await executeAuthenticatedHttpRequest(
|
|
947
|
+
{
|
|
948
|
+
credentialHandle: handle,
|
|
949
|
+
method: "GET",
|
|
950
|
+
url: "https://api.github.com/repos/owner/repo",
|
|
951
|
+
purpose: "Get repo info",
|
|
952
|
+
},
|
|
953
|
+
deps,
|
|
954
|
+
);
|
|
955
|
+
|
|
956
|
+
expect(result.success).toBe(true);
|
|
957
|
+
expect(result.statusCode).toBe(200);
|
|
958
|
+
});
|
|
959
|
+
});
|
|
960
|
+
|
|
961
|
+
// ---------------------------------------------------------------------------
|
|
962
|
+
// Tests: Filtered response behaviour
|
|
963
|
+
// ---------------------------------------------------------------------------
|
|
964
|
+
|
|
965
|
+
describe("HTTP executor: filtered response behaviour", () => {
|
|
966
|
+
let fixture: TestFixture;
|
|
967
|
+
|
|
968
|
+
beforeEach(() => {
|
|
969
|
+
const storageKey = credentialKey("example", "key");
|
|
970
|
+
fixture = createFixture(
|
|
971
|
+
{ [storageKey]: "secret-key-value-12345678" },
|
|
972
|
+
[buildStaticRecord({ service: "example", field: "key" })],
|
|
973
|
+
);
|
|
974
|
+
});
|
|
975
|
+
|
|
976
|
+
afterEach(() => {
|
|
977
|
+
rmSync(fixture.tmpDir, { recursive: true, force: true });
|
|
978
|
+
});
|
|
979
|
+
|
|
980
|
+
test("strips www-authenticate from response headers", async () => {
|
|
981
|
+
const handle = localStaticHandle("example", "key");
|
|
982
|
+
|
|
983
|
+
fixture.persistentStore.add({
|
|
984
|
+
id: "grant-example",
|
|
985
|
+
tool: "http",
|
|
986
|
+
pattern: "GET https://api.example.com/protected",
|
|
987
|
+
scope: handle,
|
|
988
|
+
createdAt: Date.now(),
|
|
989
|
+
});
|
|
990
|
+
|
|
991
|
+
const fetchFn = mockFetch(401, '{"error": "unauthorized"}', {
|
|
992
|
+
"www-authenticate": "Bearer realm=api",
|
|
993
|
+
"content-type": "application/json",
|
|
994
|
+
});
|
|
995
|
+
|
|
996
|
+
const deps = buildDeps(fixture, [], { fetch: fetchFn });
|
|
997
|
+
|
|
998
|
+
const result = await executeAuthenticatedHttpRequest(
|
|
999
|
+
{
|
|
1000
|
+
credentialHandle: handle,
|
|
1001
|
+
method: "GET",
|
|
1002
|
+
url: "https://api.example.com/protected",
|
|
1003
|
+
purpose: "Access protected resource",
|
|
1004
|
+
},
|
|
1005
|
+
deps,
|
|
1006
|
+
);
|
|
1007
|
+
|
|
1008
|
+
expect(result.success).toBe(true);
|
|
1009
|
+
expect(result.statusCode).toBe(401);
|
|
1010
|
+
expect(result.responseHeaders!["www-authenticate"]).toBeUndefined();
|
|
1011
|
+
expect(result.responseHeaders!["content-type"]).toBe("application/json");
|
|
1012
|
+
});
|
|
1013
|
+
|
|
1014
|
+
test("passes through safe response headers", async () => {
|
|
1015
|
+
const handle = localStaticHandle("example", "key");
|
|
1016
|
+
|
|
1017
|
+
fixture.persistentStore.add({
|
|
1018
|
+
id: "grant-example-safe",
|
|
1019
|
+
tool: "http",
|
|
1020
|
+
pattern: "GET https://api.example.com/data",
|
|
1021
|
+
scope: handle,
|
|
1022
|
+
createdAt: Date.now(),
|
|
1023
|
+
});
|
|
1024
|
+
|
|
1025
|
+
const fetchFn = mockFetch(200, '{"ok": true}', {
|
|
1026
|
+
"content-type": "application/json",
|
|
1027
|
+
"x-ratelimit-remaining": "42",
|
|
1028
|
+
"x-request-id": "abc-def",
|
|
1029
|
+
"etag": '"v1"',
|
|
1030
|
+
});
|
|
1031
|
+
|
|
1032
|
+
const deps = buildDeps(fixture, [], { fetch: fetchFn });
|
|
1033
|
+
|
|
1034
|
+
const result = await executeAuthenticatedHttpRequest(
|
|
1035
|
+
{
|
|
1036
|
+
credentialHandle: handle,
|
|
1037
|
+
method: "GET",
|
|
1038
|
+
url: "https://api.example.com/data",
|
|
1039
|
+
purpose: "Get data",
|
|
1040
|
+
},
|
|
1041
|
+
deps,
|
|
1042
|
+
);
|
|
1043
|
+
|
|
1044
|
+
expect(result.success).toBe(true);
|
|
1045
|
+
expect(result.responseHeaders!["content-type"]).toBe("application/json");
|
|
1046
|
+
expect(result.responseHeaders!["x-ratelimit-remaining"]).toBe("42");
|
|
1047
|
+
expect(result.responseHeaders!["x-request-id"]).toBe("abc-def");
|
|
1048
|
+
expect(result.responseHeaders!["etag"]).toBe('"v1"');
|
|
1049
|
+
});
|
|
1050
|
+
});
|
|
1051
|
+
|
|
1052
|
+
// ---------------------------------------------------------------------------
|
|
1053
|
+
// Tests: Audit summaries are token-free
|
|
1054
|
+
// ---------------------------------------------------------------------------
|
|
1055
|
+
|
|
1056
|
+
describe("HTTP executor: audit summary integrity", () => {
|
|
1057
|
+
let fixture: TestFixture;
|
|
1058
|
+
|
|
1059
|
+
beforeEach(() => {
|
|
1060
|
+
const storageKey = credentialKey("github", "api_key");
|
|
1061
|
+
fixture = createFixture(
|
|
1062
|
+
{ [storageKey]: "ghp_secrettoken_12345678" },
|
|
1063
|
+
[buildStaticRecord({ service: "github", field: "api_key" })],
|
|
1064
|
+
);
|
|
1065
|
+
});
|
|
1066
|
+
|
|
1067
|
+
afterEach(() => {
|
|
1068
|
+
rmSync(fixture.tmpDir, { recursive: true, force: true });
|
|
1069
|
+
});
|
|
1070
|
+
|
|
1071
|
+
test("audit ID is returned on successful request", async () => {
|
|
1072
|
+
const handle = localStaticHandle("github", "api_key");
|
|
1073
|
+
|
|
1074
|
+
fixture.persistentStore.add({
|
|
1075
|
+
id: "grant-audit-test",
|
|
1076
|
+
tool: "http",
|
|
1077
|
+
pattern: "GET https://api.github.com/user",
|
|
1078
|
+
scope: handle,
|
|
1079
|
+
createdAt: Date.now(),
|
|
1080
|
+
});
|
|
1081
|
+
|
|
1082
|
+
const fetchFn = mockFetch(200, '{"login": "octocat"}');
|
|
1083
|
+
const deps = buildDeps(fixture, [], { fetch: fetchFn });
|
|
1084
|
+
|
|
1085
|
+
const result = await executeAuthenticatedHttpRequest(
|
|
1086
|
+
{
|
|
1087
|
+
credentialHandle: handle,
|
|
1088
|
+
method: "GET",
|
|
1089
|
+
url: "https://api.github.com/user",
|
|
1090
|
+
purpose: "Get user info",
|
|
1091
|
+
},
|
|
1092
|
+
deps,
|
|
1093
|
+
);
|
|
1094
|
+
|
|
1095
|
+
expect(result.success).toBe(true);
|
|
1096
|
+
expect(result.auditId).toBeDefined();
|
|
1097
|
+
expect(typeof result.auditId).toBe("string");
|
|
1098
|
+
expect(result.auditId!.length).toBeGreaterThan(0);
|
|
1099
|
+
});
|
|
1100
|
+
|
|
1101
|
+
test("audit ID is returned on materialisation failure", async () => {
|
|
1102
|
+
const handle = localStaticHandle("github", "api_key");
|
|
1103
|
+
|
|
1104
|
+
// Grant exists but we'll break materialisation by using a handle that
|
|
1105
|
+
// won't resolve (wrong service)
|
|
1106
|
+
const badHandle = localStaticHandle("nonexistent", "key");
|
|
1107
|
+
fixture.persistentStore.add({
|
|
1108
|
+
id: "grant-bad-cred",
|
|
1109
|
+
tool: "http",
|
|
1110
|
+
pattern: "GET https://api.github.com/user",
|
|
1111
|
+
scope: badHandle,
|
|
1112
|
+
createdAt: Date.now(),
|
|
1113
|
+
});
|
|
1114
|
+
|
|
1115
|
+
const deps = buildDeps(fixture, []);
|
|
1116
|
+
|
|
1117
|
+
const result = await executeAuthenticatedHttpRequest(
|
|
1118
|
+
{
|
|
1119
|
+
credentialHandle: badHandle,
|
|
1120
|
+
method: "GET",
|
|
1121
|
+
url: "https://api.github.com/user",
|
|
1122
|
+
purpose: "Get user info",
|
|
1123
|
+
},
|
|
1124
|
+
deps,
|
|
1125
|
+
);
|
|
1126
|
+
|
|
1127
|
+
expect(result.success).toBe(false);
|
|
1128
|
+
expect(result.error!.code).toBe("MATERIALISATION_FAILED");
|
|
1129
|
+
expect(result.auditId).toBeDefined();
|
|
1130
|
+
});
|
|
1131
|
+
});
|
|
1132
|
+
|
|
1133
|
+
// ---------------------------------------------------------------------------
|
|
1134
|
+
// Tests: Invalid handle
|
|
1135
|
+
// ---------------------------------------------------------------------------
|
|
1136
|
+
|
|
1137
|
+
describe("HTTP executor: invalid handle", () => {
|
|
1138
|
+
let fixture: TestFixture;
|
|
1139
|
+
|
|
1140
|
+
beforeEach(() => {
|
|
1141
|
+
fixture = createFixture();
|
|
1142
|
+
});
|
|
1143
|
+
|
|
1144
|
+
afterEach(() => {
|
|
1145
|
+
rmSync(fixture.tmpDir, { recursive: true, force: true });
|
|
1146
|
+
});
|
|
1147
|
+
|
|
1148
|
+
test("rejects completely invalid handle format", async () => {
|
|
1149
|
+
const deps = buildDeps(fixture, []);
|
|
1150
|
+
|
|
1151
|
+
const result = await executeAuthenticatedHttpRequest(
|
|
1152
|
+
{
|
|
1153
|
+
credentialHandle: "not-a-valid-handle",
|
|
1154
|
+
method: "GET",
|
|
1155
|
+
url: "https://api.example.com/data",
|
|
1156
|
+
purpose: "Get data",
|
|
1157
|
+
},
|
|
1158
|
+
deps,
|
|
1159
|
+
);
|
|
1160
|
+
|
|
1161
|
+
expect(result.success).toBe(false);
|
|
1162
|
+
expect(result.error!.code).toBe("INVALID_HANDLE");
|
|
1163
|
+
});
|
|
1164
|
+
});
|
|
1165
|
+
|
|
1166
|
+
// ---------------------------------------------------------------------------
|
|
1167
|
+
// Tests: Network error handling
|
|
1168
|
+
// ---------------------------------------------------------------------------
|
|
1169
|
+
|
|
1170
|
+
describe("HTTP executor: network error handling", () => {
|
|
1171
|
+
let fixture: TestFixture;
|
|
1172
|
+
|
|
1173
|
+
beforeEach(() => {
|
|
1174
|
+
const storageKey = credentialKey("example", "key");
|
|
1175
|
+
fixture = createFixture(
|
|
1176
|
+
{ [storageKey]: "secret-key-value-12345678" },
|
|
1177
|
+
[buildStaticRecord({ service: "example", field: "key" })],
|
|
1178
|
+
);
|
|
1179
|
+
});
|
|
1180
|
+
|
|
1181
|
+
afterEach(() => {
|
|
1182
|
+
rmSync(fixture.tmpDir, { recursive: true, force: true });
|
|
1183
|
+
});
|
|
1184
|
+
|
|
1185
|
+
test("returns error when fetch fails", async () => {
|
|
1186
|
+
const handle = localStaticHandle("example", "key");
|
|
1187
|
+
|
|
1188
|
+
fixture.persistentStore.add({
|
|
1189
|
+
id: "grant-network-fail",
|
|
1190
|
+
tool: "http",
|
|
1191
|
+
pattern: "GET https://api.example.com/data",
|
|
1192
|
+
scope: handle,
|
|
1193
|
+
createdAt: Date.now(),
|
|
1194
|
+
});
|
|
1195
|
+
|
|
1196
|
+
const fetchFn = asFetch(async () => {
|
|
1197
|
+
throw new Error("Connection refused");
|
|
1198
|
+
});
|
|
1199
|
+
|
|
1200
|
+
const deps = buildDeps(fixture, [], { fetch: fetchFn });
|
|
1201
|
+
|
|
1202
|
+
const result = await executeAuthenticatedHttpRequest(
|
|
1203
|
+
{
|
|
1204
|
+
credentialHandle: handle,
|
|
1205
|
+
method: "GET",
|
|
1206
|
+
url: "https://api.example.com/data",
|
|
1207
|
+
purpose: "Get data",
|
|
1208
|
+
},
|
|
1209
|
+
deps,
|
|
1210
|
+
);
|
|
1211
|
+
|
|
1212
|
+
expect(result.success).toBe(false);
|
|
1213
|
+
expect(result.error!.code).toBe("HTTP_REQUEST_FAILED");
|
|
1214
|
+
expect(result.error!.message).toContain("Connection refused");
|
|
1215
|
+
expect(result.auditId).toBeDefined();
|
|
1216
|
+
});
|
|
1217
|
+
|
|
1218
|
+
test("error message is scrubbed of secret values", async () => {
|
|
1219
|
+
const handle = localStaticHandle("example", "key");
|
|
1220
|
+
|
|
1221
|
+
fixture.persistentStore.add({
|
|
1222
|
+
id: "grant-error-scrub",
|
|
1223
|
+
tool: "http",
|
|
1224
|
+
pattern: "GET https://api.example.com/data",
|
|
1225
|
+
scope: handle,
|
|
1226
|
+
createdAt: Date.now(),
|
|
1227
|
+
});
|
|
1228
|
+
|
|
1229
|
+
const fetchFn = asFetch(async () => {
|
|
1230
|
+
throw new Error(
|
|
1231
|
+
"Failed to connect with token secret-key-value-12345678 to api.example.com",
|
|
1232
|
+
);
|
|
1233
|
+
});
|
|
1234
|
+
|
|
1235
|
+
const deps = buildDeps(fixture, [], { fetch: fetchFn });
|
|
1236
|
+
|
|
1237
|
+
const result = await executeAuthenticatedHttpRequest(
|
|
1238
|
+
{
|
|
1239
|
+
credentialHandle: handle,
|
|
1240
|
+
method: "GET",
|
|
1241
|
+
url: "https://api.example.com/data",
|
|
1242
|
+
purpose: "Get data",
|
|
1243
|
+
},
|
|
1244
|
+
deps,
|
|
1245
|
+
);
|
|
1246
|
+
|
|
1247
|
+
expect(result.success).toBe(false);
|
|
1248
|
+
expect(result.error!.message).not.toContain("secret-key-value-12345678");
|
|
1249
|
+
expect(result.error!.message).toContain("[CES:REDACTED]");
|
|
1250
|
+
});
|
|
1251
|
+
});
|