@vellumai/credential-executor 0.10.7 → 0.10.8-dev.202607102228.5945895

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.
Files changed (65) hide show
  1. package/Dockerfile +1 -1
  2. package/node_modules/@vellumai/service-contracts/package.json +1 -2
  3. package/node_modules/@vellumai/service-contracts/src/__tests__/attachment-naming.test.ts +104 -0
  4. package/node_modules/@vellumai/service-contracts/src/__tests__/contracts.test.ts +0 -2
  5. package/node_modules/@vellumai/service-contracts/src/attachment-naming.ts +118 -0
  6. package/node_modules/@vellumai/service-contracts/src/credential-rpc.ts +3 -5
  7. package/node_modules/@vellumai/service-contracts/src/index.ts +2 -4
  8. package/node_modules/@vellumai/service-contracts/src/rpc.ts +4 -447
  9. package/package.json +2 -3
  10. package/src/__tests__/bulk-set-credentials.test.ts +1 -1
  11. package/src/__tests__/local-standalone.test.ts +5 -36
  12. package/src/__tests__/managed-integration.test.ts +112 -91
  13. package/src/__tests__/managed-reconnect.test.ts +2 -2
  14. package/src/__tests__/transport.test.ts +23 -27
  15. package/src/cli.ts +1 -1
  16. package/src/index.ts +8 -88
  17. package/src/main.ts +228 -340
  18. package/src/paths.ts +4 -20
  19. package/src/server.ts +52 -469
  20. package/node_modules/@vellumai/service-contracts/src/__tests__/grants.test.ts +0 -686
  21. package/node_modules/@vellumai/service-contracts/src/grants.ts +0 -184
  22. package/node_modules/@vellumai/service-contracts/src/rendering.ts +0 -135
  23. package/src/__tests__/command-executor.test.ts +0 -1879
  24. package/src/__tests__/command-validator.test.ts +0 -1405
  25. package/src/__tests__/command-workspace.test.ts +0 -1050
  26. package/src/__tests__/grant-store.test.ts +0 -689
  27. package/src/__tests__/http-executor.test.ts +0 -1336
  28. package/src/__tests__/http-policy.test.ts +0 -1069
  29. package/src/__tests__/local-materializers.test.ts +0 -860
  30. package/src/__tests__/local-token-refresh.test.ts +0 -361
  31. package/src/__tests__/manage-secure-command-tool.test.ts +0 -134
  32. package/src/__tests__/managed-lazy-getters.test.ts +0 -359
  33. package/src/__tests__/managed-materializers.test.ts +0 -1028
  34. package/src/__tests__/managed-rejection.test.ts +0 -43
  35. package/src/__tests__/toolstore.test.ts +0 -773
  36. package/src/audit/store.ts +0 -188
  37. package/src/commands/auth-adapters.ts +0 -169
  38. package/src/commands/egress-hooks.ts +0 -203
  39. package/src/commands/executor.ts +0 -1155
  40. package/src/commands/output-scan.ts +0 -157
  41. package/src/commands/profiles.ts +0 -286
  42. package/src/commands/validator.ts +0 -702
  43. package/src/commands/workspace.ts +0 -550
  44. package/src/grants/index.ts +0 -17
  45. package/src/grants/persistent-store.ts +0 -309
  46. package/src/grants/rpc-handlers.ts +0 -293
  47. package/src/grants/temporary-store.ts +0 -289
  48. package/src/http/audit.ts +0 -84
  49. package/src/http/executor.ts +0 -684
  50. package/src/http/path-template.ts +0 -245
  51. package/src/http/policy.ts +0 -238
  52. package/src/http/response-filter.ts +0 -233
  53. package/src/managed-errors.ts +0 -9
  54. package/src/managed-lazy-getters.ts +0 -106
  55. package/src/managed-main.ts +0 -822
  56. package/src/materializers/local-oauth-lookup.ts +0 -98
  57. package/src/materializers/local-token-refresh.ts +0 -287
  58. package/src/materializers/local.ts +0 -316
  59. package/src/materializers/managed-platform.ts +0 -295
  60. package/src/subjects/local.ts +0 -177
  61. package/src/subjects/managed.ts +0 -311
  62. package/src/subjects/policy.ts +0 -79
  63. package/src/toolstore/integrity.ts +0 -94
  64. package/src/toolstore/manifest.ts +0 -154
  65. package/src/toolstore/publish.ts +0 -571
@@ -1,860 +0,0 @@
1
- /**
2
- * Tests for CES local subject resolution and credential materialisation.
3
- *
4
- * Covers:
5
- * 1. UUID and service/field refs for static credentials
6
- * 2. Disconnected OAuth handles (missing connection, missing access token)
7
- * 3. Missing secure keys (metadata present but secret missing)
8
- * 4. Refresh-on-expiry for OAuth tokens
9
- * 5. Deterministic failure behaviour before any network call or command launch
10
- * 6. Circuit breaker tripping after repeated refresh failures
11
- * 7. Provider key mismatch on OAuth handles
12
- * 8. Non-local handle types rejected by the local resolver
13
- */
14
-
15
- import { describe, expect, test } from "bun:test";
16
-
17
- import {
18
- HandleType,
19
- localStaticHandle,
20
- localOAuthHandle,
21
- platformOAuthHandle,
22
- } from "@vellumai/service-contracts/credential-rpc";
23
- import {
24
- type OAuthConnectionRecord,
25
- type SecureKeyBackend,
26
- type SecureKeyDeleteResult,
27
- type StaticCredentialRecord,
28
- StaticCredentialMetadataStore,
29
- oauthConnectionAccessTokenPath,
30
- oauthConnectionRefreshTokenPath,
31
- REFRESH_FAILURE_THRESHOLD,
32
- } from "@vellumai/credential-storage";
33
-
34
- import {
35
- resolveLocalSubject,
36
- type OAuthConnectionLookup,
37
- type LocalSubjectResolverDeps,
38
- } from "../subjects/local.js";
39
- import {
40
- LocalMaterialiser,
41
- type TokenRefreshFn,
42
- } from "../materializers/local.js";
43
-
44
- // ---------------------------------------------------------------------------
45
- // Test helpers
46
- // ---------------------------------------------------------------------------
47
-
48
- /**
49
- * In-memory secure-key backend for testing. Stores key-value pairs in a Map.
50
- */
51
- function createMemoryBackend(
52
- initial: Record<string, string> = {},
53
- ): SecureKeyBackend {
54
- const store = new Map<string, string>(Object.entries(initial));
55
- return {
56
- async get(key: string): Promise<string | undefined> {
57
- return store.get(key);
58
- },
59
- async set(key: string, value: string): Promise<boolean> {
60
- store.set(key, value);
61
- return true;
62
- },
63
- async delete(key: string): Promise<SecureKeyDeleteResult> {
64
- if (store.has(key)) {
65
- store.delete(key);
66
- return "deleted";
67
- }
68
- return "not-found";
69
- },
70
- async list(): Promise<string[]> {
71
- return Array.from(store.keys());
72
- },
73
- };
74
- }
75
-
76
- /**
77
- * Build a minimal static credential record for testing.
78
- */
79
- function buildStaticRecord(
80
- overrides: Partial<StaticCredentialRecord> = {},
81
- ): StaticCredentialRecord {
82
- return {
83
- credentialId: overrides.credentialId ?? "cred-uuid-1",
84
- service: overrides.service ?? "github",
85
- field: overrides.field ?? "api_key",
86
- allowedTools: overrides.allowedTools ?? [],
87
- allowedDomains: overrides.allowedDomains ?? [],
88
- createdAt: overrides.createdAt ?? Date.now(),
89
- updatedAt: overrides.updatedAt ?? Date.now(),
90
- };
91
- }
92
-
93
- /**
94
- * Build a minimal OAuth connection record for testing.
95
- */
96
- function buildOAuthConnection(
97
- overrides: Partial<OAuthConnectionRecord> = {},
98
- ): OAuthConnectionRecord {
99
- return {
100
- id: overrides.id ?? "conn-uuid-1",
101
- providerKey: overrides.providerKey ?? "google",
102
- accountInfo: overrides.accountInfo ?? "user@example.com",
103
- grantedScopes: overrides.grantedScopes ?? ["openid", "email"],
104
- accessTokenPath: overrides.accessTokenPath ??
105
- oauthConnectionAccessTokenPath("conn-uuid-1"),
106
- hasRefreshToken: overrides.hasRefreshToken ?? true,
107
- expiresAt: overrides.expiresAt ?? null,
108
- createdAt: overrides.createdAt ?? Date.now(),
109
- updatedAt: overrides.updatedAt ?? Date.now(),
110
- };
111
- }
112
-
113
- /**
114
- * Create an in-memory OAuth connection lookup backed by a Map.
115
- */
116
- function createOAuthLookup(
117
- connections: OAuthConnectionRecord[] = [],
118
- ): OAuthConnectionLookup {
119
- const byId = new Map(connections.map((c) => [c.id, c]));
120
- return {
121
- getById(connectionId: string) {
122
- return byId.get(connectionId);
123
- },
124
- };
125
- }
126
-
127
- /**
128
- * Create a StaticCredentialMetadataStore backed by an in-memory JSON file.
129
- * Uses a temporary path that is unique per test.
130
- */
131
- function createMemoryMetadataStore(
132
- records: StaticCredentialRecord[] = [],
133
- ): StaticCredentialMetadataStore {
134
- const tmpPath = `/tmp/ces-test-metadata-${Date.now()}-${Math.random().toString(36).slice(2)}.json`;
135
- const store = new StaticCredentialMetadataStore(tmpPath);
136
- // Seed records by upserting them
137
- for (const r of records) {
138
- store.upsert(r.service, r.field, {
139
- allowedTools: r.allowedTools,
140
- allowedDomains: r.allowedDomains,
141
- usageDescription: r.usageDescription,
142
- alias: r.alias,
143
- injectionTemplates: r.injectionTemplates,
144
- });
145
- }
146
- return store;
147
- }
148
-
149
- /**
150
- * Create resolver deps from lists of records and connections.
151
- */
152
- function createResolverDeps(opts: {
153
- staticRecords?: StaticCredentialRecord[];
154
- oauthConnections?: OAuthConnectionRecord[];
155
- }): LocalSubjectResolverDeps {
156
- return {
157
- metadataStore: createMemoryMetadataStore(opts.staticRecords ?? []),
158
- oauthConnections: createOAuthLookup(opts.oauthConnections ?? []),
159
- };
160
- }
161
-
162
- // ---------------------------------------------------------------------------
163
- // 1. Local static subject resolution
164
- // ---------------------------------------------------------------------------
165
-
166
- describe("local static subject resolution", () => {
167
- test("resolves a valid service/field handle to a static subject", () => {
168
- const record = buildStaticRecord({
169
- service: "github",
170
- field: "api_key",
171
- });
172
- const deps = createResolverDeps({ staticRecords: [record] });
173
- const handle = localStaticHandle("github", "api_key");
174
-
175
- const result = resolveLocalSubject(handle, deps);
176
-
177
- expect(result.ok).toBe(true);
178
- if (!result.ok) return;
179
- expect(result.subject.type).toBe(HandleType.LocalStatic);
180
- if (result.subject.type !== HandleType.LocalStatic) return;
181
- expect(result.subject.metadata.service).toBe("github");
182
- expect(result.subject.metadata.field).toBe("api_key");
183
- expect(result.subject.storageKey).toBe("credential/github/api_key");
184
- });
185
-
186
- test("fails when no metadata exists for the service/field", () => {
187
- const deps = createResolverDeps({ staticRecords: [] });
188
- const handle = localStaticHandle("nonexistent", "key");
189
-
190
- const result = resolveLocalSubject(handle, deps);
191
-
192
- expect(result.ok).toBe(false);
193
- if (result.ok) return;
194
- expect(result.error).toMatch(/No local static credential found/);
195
- expect(result.error).toMatch(/nonexistent/);
196
- });
197
-
198
- test("resolves different service/field combinations independently", () => {
199
- const records = [
200
- buildStaticRecord({ service: "fal", field: "api_key" }),
201
- buildStaticRecord({ service: "github", field: "token" }),
202
- ];
203
- const deps = createResolverDeps({ staticRecords: records });
204
-
205
- const falResult = resolveLocalSubject(
206
- localStaticHandle("fal", "api_key"),
207
- deps,
208
- );
209
- const ghResult = resolveLocalSubject(
210
- localStaticHandle("github", "token"),
211
- deps,
212
- );
213
-
214
- expect(falResult.ok).toBe(true);
215
- expect(ghResult.ok).toBe(true);
216
- if (!falResult.ok || !ghResult.ok) return;
217
- expect(falResult.subject.type).toBe(HandleType.LocalStatic);
218
- expect(ghResult.subject.type).toBe(HandleType.LocalStatic);
219
- });
220
- });
221
-
222
- // ---------------------------------------------------------------------------
223
- // 2. Local OAuth subject resolution
224
- // ---------------------------------------------------------------------------
225
-
226
- describe("local OAuth subject resolution", () => {
227
- test("resolves a valid OAuth handle to an OAuth subject", () => {
228
- const conn = buildOAuthConnection({
229
- id: "conn-abc",
230
- providerKey: "google",
231
- });
232
- const deps = createResolverDeps({ oauthConnections: [conn] });
233
- const handle = localOAuthHandle("google", "conn-abc");
234
-
235
- const result = resolveLocalSubject(handle, deps);
236
-
237
- expect(result.ok).toBe(true);
238
- if (!result.ok) return;
239
- expect(result.subject.type).toBe(HandleType.LocalOAuth);
240
- if (result.subject.type !== HandleType.LocalOAuth) return;
241
- expect(result.subject.connection.id).toBe("conn-abc");
242
- expect(result.subject.connection.providerKey).toBe("google");
243
- });
244
-
245
- test("fails when the connection does not exist", () => {
246
- const deps = createResolverDeps({ oauthConnections: [] });
247
- const handle = localOAuthHandle("google", "missing-conn");
248
-
249
- const result = resolveLocalSubject(handle, deps);
250
-
251
- expect(result.ok).toBe(false);
252
- if (result.ok) return;
253
- expect(result.error).toMatch(/No local OAuth connection found/);
254
- expect(result.error).toMatch(/missing-conn/);
255
- });
256
-
257
- test("fails when provider key in handle does not match connection", () => {
258
- const conn = buildOAuthConnection({
259
- id: "conn-xyz",
260
- providerKey: "slack",
261
- });
262
- const deps = createResolverDeps({ oauthConnections: [conn] });
263
- // Handle says google but connection is slack
264
- const handle = localOAuthHandle("google", "conn-xyz");
265
-
266
- const result = resolveLocalSubject(handle, deps);
267
-
268
- expect(result.ok).toBe(false);
269
- if (result.ok) return;
270
- expect(result.error).toMatch(/providerKey/);
271
- expect(result.error).toMatch(/slack/);
272
- expect(result.error).toMatch(/google/);
273
- });
274
- });
275
-
276
- // ---------------------------------------------------------------------------
277
- // 3. Non-local handle types rejected
278
- // ---------------------------------------------------------------------------
279
-
280
- describe("non-local handle rejection", () => {
281
- test("rejects platform_oauth handles in the local resolver", () => {
282
- const deps = createResolverDeps({});
283
- const handle = platformOAuthHandle("platform-conn-123");
284
-
285
- const result = resolveLocalSubject(handle, deps);
286
-
287
- expect(result.ok).toBe(false);
288
- if (result.ok) return;
289
- expect(result.error).toMatch(/not a local handle/);
290
- });
291
-
292
- test("rejects malformed handles", () => {
293
- const deps = createResolverDeps({});
294
-
295
- const result = resolveLocalSubject("garbage-no-colon", deps);
296
-
297
- expect(result.ok).toBe(false);
298
- if (result.ok) return;
299
- expect(result.error).toMatch(/Invalid handle format/);
300
- });
301
-
302
- test("rejects unknown handle type prefixes", () => {
303
- const deps = createResolverDeps({});
304
-
305
- const result = resolveLocalSubject("unknown_type:foo/bar", deps);
306
-
307
- expect(result.ok).toBe(false);
308
- if (result.ok) return;
309
- expect(result.error).toMatch(/Unknown handle type/);
310
- });
311
- });
312
-
313
- // ---------------------------------------------------------------------------
314
- // 4. Static credential materialisation
315
- // ---------------------------------------------------------------------------
316
-
317
- describe("static credential materialisation", () => {
318
- test("materialises a stored secret value", async () => {
319
- const record = buildStaticRecord({
320
- service: "fal",
321
- field: "api_key",
322
- });
323
- const deps = createResolverDeps({ staticRecords: [record] });
324
- const handle = localStaticHandle("fal", "api_key");
325
-
326
- const resolved = resolveLocalSubject(handle, deps);
327
- expect(resolved.ok).toBe(true);
328
- if (!resolved.ok) return;
329
-
330
- const backend = createMemoryBackend({
331
- "credential/fal/api_key": "secret-fal-key-123",
332
- });
333
- const materialiser = new LocalMaterialiser({
334
- secureKeyBackend: backend,
335
- });
336
-
337
- const result = await materialiser.materialise(resolved.subject);
338
-
339
- expect(result.ok).toBe(true);
340
- if (!result.ok) return;
341
- expect(result.credential.value).toBe("secret-fal-key-123");
342
- expect(result.credential.handleType).toBe(HandleType.LocalStatic);
343
- });
344
-
345
- test("fails when the secure key is missing (metadata without secret)", async () => {
346
- const record = buildStaticRecord({
347
- service: "github",
348
- field: "token",
349
- });
350
- const deps = createResolverDeps({ staticRecords: [record] });
351
- const handle = localStaticHandle("github", "token");
352
-
353
- const resolved = resolveLocalSubject(handle, deps);
354
- expect(resolved.ok).toBe(true);
355
- if (!resolved.ok) return;
356
-
357
- // Empty backend — no secret stored
358
- const backend = createMemoryBackend({});
359
- const materialiser = new LocalMaterialiser({
360
- secureKeyBackend: backend,
361
- });
362
-
363
- const result = await materialiser.materialise(resolved.subject);
364
-
365
- expect(result.ok).toBe(false);
366
- if (result.ok) return;
367
- expect(result.error).toMatch(/Secure key/);
368
- expect(result.error).toMatch(/not found/);
369
- expect(result.error).toMatch(/credential\/github\/token/);
370
- });
371
- });
372
-
373
- // ---------------------------------------------------------------------------
374
- // 5. OAuth token materialisation
375
- // ---------------------------------------------------------------------------
376
-
377
- describe("OAuth token materialisation", () => {
378
- test("materialises a valid non-expired access token", async () => {
379
- const conn = buildOAuthConnection({
380
- id: "conn-1",
381
- providerKey: "google",
382
- // Token expires in the future (1 hour from now)
383
- expiresAt: Date.now() + 60 * 60 * 1000,
384
- hasRefreshToken: true,
385
- });
386
- const deps = createResolverDeps({ oauthConnections: [conn] });
387
- const handle = localOAuthHandle("google", "conn-1");
388
-
389
- const resolved = resolveLocalSubject(handle, deps);
390
- expect(resolved.ok).toBe(true);
391
- if (!resolved.ok) return;
392
-
393
- const backend = createMemoryBackend({
394
- [oauthConnectionAccessTokenPath("conn-1")]: "ya29.valid-token",
395
- });
396
- const materialiser = new LocalMaterialiser({
397
- secureKeyBackend: backend,
398
- });
399
-
400
- const result = await materialiser.materialise(resolved.subject);
401
-
402
- expect(result.ok).toBe(true);
403
- if (!result.ok) return;
404
- expect(result.credential.value).toBe("ya29.valid-token");
405
- expect(result.credential.handleType).toBe(HandleType.LocalOAuth);
406
- });
407
-
408
- test("fails when no access token is stored (disconnected connection)", async () => {
409
- const conn = buildOAuthConnection({
410
- id: "conn-disconnected",
411
- providerKey: "slack",
412
- hasRefreshToken: false,
413
- });
414
- const deps = createResolverDeps({ oauthConnections: [conn] });
415
- const handle = localOAuthHandle("slack", "conn-disconnected");
416
-
417
- const resolved = resolveLocalSubject(handle, deps);
418
- expect(resolved.ok).toBe(true);
419
- if (!resolved.ok) return;
420
-
421
- // Empty backend — no access token
422
- const backend = createMemoryBackend({});
423
- const materialiser = new LocalMaterialiser({
424
- secureKeyBackend: backend,
425
- });
426
-
427
- const result = await materialiser.materialise(resolved.subject);
428
-
429
- expect(result.ok).toBe(false);
430
- if (result.ok) return;
431
- expect(result.error).toMatch(/No access token found/);
432
- expect(result.error).toMatch(/disconnected/);
433
- });
434
-
435
- test("fails when token is expired and hasRefreshToken is false", async () => {
436
- const conn = buildOAuthConnection({
437
- id: "conn-expired-no-refresh",
438
- providerKey: "google",
439
- // Token expired 10 minutes ago
440
- expiresAt: Date.now() - 10 * 60 * 1000,
441
- hasRefreshToken: false,
442
- });
443
- const deps = createResolverDeps({ oauthConnections: [conn] });
444
- const handle = localOAuthHandle(
445
- "google",
446
- "conn-expired-no-refresh",
447
- );
448
-
449
- const resolved = resolveLocalSubject(handle, deps);
450
- expect(resolved.ok).toBe(true);
451
- if (!resolved.ok) return;
452
-
453
- const backend = createMemoryBackend({
454
- [oauthConnectionAccessTokenPath("conn-expired-no-refresh")]:
455
- "old-expired-token",
456
- });
457
- const materialiser = new LocalMaterialiser({
458
- secureKeyBackend: backend,
459
- });
460
-
461
- const result = await materialiser.materialise(resolved.subject);
462
-
463
- expect(result.ok).toBe(false);
464
- if (result.ok) return;
465
- expect(result.error).toMatch(/expired/);
466
- expect(result.error).toMatch(/no refresh.*token.*available/i);
467
- });
468
-
469
- test("materialises a token with null expiresAt (no expiry info)", async () => {
470
- const conn = buildOAuthConnection({
471
- id: "conn-noexpiry",
472
- providerKey: "github",
473
- expiresAt: null,
474
- hasRefreshToken: false,
475
- });
476
- const deps = createResolverDeps({ oauthConnections: [conn] });
477
- const handle = localOAuthHandle("github", "conn-noexpiry");
478
-
479
- const resolved = resolveLocalSubject(handle, deps);
480
- expect(resolved.ok).toBe(true);
481
- if (!resolved.ok) return;
482
-
483
- const backend = createMemoryBackend({
484
- [oauthConnectionAccessTokenPath("conn-noexpiry")]: "gho_token123",
485
- });
486
- const materialiser = new LocalMaterialiser({
487
- secureKeyBackend: backend,
488
- });
489
-
490
- const result = await materialiser.materialise(resolved.subject);
491
-
492
- // null expiresAt means isTokenExpired returns false, so the token is used as-is
493
- expect(result.ok).toBe(true);
494
- if (!result.ok) return;
495
- expect(result.credential.value).toBe("gho_token123");
496
- });
497
- });
498
-
499
- // ---------------------------------------------------------------------------
500
- // 6. Refresh-on-expiry
501
- // ---------------------------------------------------------------------------
502
-
503
- describe("OAuth refresh-on-expiry", () => {
504
- test("refreshes an expired token and returns the new access token", async () => {
505
- const conn = buildOAuthConnection({
506
- id: "conn-expired",
507
- providerKey: "google",
508
- // Token expired 10 minutes ago
509
- expiresAt: Date.now() - 10 * 60 * 1000,
510
- hasRefreshToken: true,
511
- });
512
- const deps = createResolverDeps({ oauthConnections: [conn] });
513
- const handle = localOAuthHandle("google", "conn-expired");
514
-
515
- const resolved = resolveLocalSubject(handle, deps);
516
- expect(resolved.ok).toBe(true);
517
- if (!resolved.ok) return;
518
-
519
- const backend = createMemoryBackend({
520
- [oauthConnectionAccessTokenPath("conn-expired")]: "old-expired-token",
521
- [oauthConnectionRefreshTokenPath("conn-expired")]: "refresh-token-abc",
522
- });
523
-
524
- const refreshFn: TokenRefreshFn = async (_connId, _refreshToken) => {
525
- return {
526
- success: true,
527
- accessToken: "ya29.new-refreshed-token",
528
- expiresAt: Date.now() + 3600 * 1000,
529
- };
530
- };
531
-
532
- const materialiser = new LocalMaterialiser({
533
- secureKeyBackend: backend,
534
- tokenRefreshFn: refreshFn,
535
- });
536
-
537
- const result = await materialiser.materialise(resolved.subject);
538
-
539
- expect(result.ok).toBe(true);
540
- if (!result.ok) return;
541
- expect(result.credential.value).toBe("ya29.new-refreshed-token");
542
- expect(result.credential.handleType).toBe(HandleType.LocalOAuth);
543
- });
544
-
545
- test("fails when token is expired but no refresh function is configured", async () => {
546
- const conn = buildOAuthConnection({
547
- id: "conn-no-refresh-fn",
548
- providerKey: "google",
549
- expiresAt: Date.now() - 10 * 60 * 1000,
550
- hasRefreshToken: true,
551
- });
552
- const deps = createResolverDeps({ oauthConnections: [conn] });
553
- const handle = localOAuthHandle("google", "conn-no-refresh-fn");
554
-
555
- const resolved = resolveLocalSubject(handle, deps);
556
- expect(resolved.ok).toBe(true);
557
- if (!resolved.ok) return;
558
-
559
- const backend = createMemoryBackend({
560
- [oauthConnectionAccessTokenPath("conn-no-refresh-fn")]: "old-token",
561
- [oauthConnectionRefreshTokenPath("conn-no-refresh-fn")]: "refresh-tok",
562
- });
563
-
564
- // No tokenRefreshFn provided
565
- const materialiser = new LocalMaterialiser({
566
- secureKeyBackend: backend,
567
- });
568
-
569
- const result = await materialiser.materialise(resolved.subject);
570
-
571
- expect(result.ok).toBe(false);
572
- if (result.ok) return;
573
- expect(result.error).toMatch(/expired/);
574
- expect(result.error).toMatch(/no refresh.*function/i);
575
- });
576
-
577
- test("fails when token is expired and no refresh token is stored", async () => {
578
- const conn = buildOAuthConnection({
579
- id: "conn-no-stored-refresh",
580
- providerKey: "google",
581
- expiresAt: Date.now() - 10 * 60 * 1000,
582
- hasRefreshToken: true,
583
- });
584
- const deps = createResolverDeps({ oauthConnections: [conn] });
585
- const handle = localOAuthHandle(
586
- "google",
587
- "conn-no-stored-refresh",
588
- );
589
-
590
- const resolved = resolveLocalSubject(handle, deps);
591
- expect(resolved.ok).toBe(true);
592
- if (!resolved.ok) return;
593
-
594
- // Access token exists but refresh token does not
595
- const backend = createMemoryBackend({
596
- [oauthConnectionAccessTokenPath("conn-no-stored-refresh")]: "old-token",
597
- // No refresh token entry
598
- });
599
-
600
- const refreshFn: TokenRefreshFn = async () => {
601
- throw new Error("Should not be called");
602
- };
603
-
604
- const materialiser = new LocalMaterialiser({
605
- secureKeyBackend: backend,
606
- tokenRefreshFn: refreshFn,
607
- });
608
-
609
- const result = await materialiser.materialise(resolved.subject);
610
-
611
- expect(result.ok).toBe(false);
612
- if (result.ok) return;
613
- expect(result.error).toMatch(/expired/);
614
- expect(result.error).toMatch(/no refresh.*token.*available/i);
615
- });
616
-
617
- test("fails when refresh function returns a failure result", async () => {
618
- const conn = buildOAuthConnection({
619
- id: "conn-refresh-fail",
620
- providerKey: "google",
621
- expiresAt: Date.now() - 10 * 60 * 1000,
622
- hasRefreshToken: true,
623
- });
624
- const deps = createResolverDeps({ oauthConnections: [conn] });
625
- const handle = localOAuthHandle(
626
- "google",
627
- "conn-refresh-fail",
628
- );
629
-
630
- const resolved = resolveLocalSubject(handle, deps);
631
- expect(resolved.ok).toBe(true);
632
- if (!resolved.ok) return;
633
-
634
- const backend = createMemoryBackend({
635
- [oauthConnectionAccessTokenPath("conn-refresh-fail")]: "old-token",
636
- [oauthConnectionRefreshTokenPath("conn-refresh-fail")]: "refresh-tok",
637
- });
638
-
639
- const refreshFn: TokenRefreshFn = async () => {
640
- return { success: false, error: "Token has been revoked by user" };
641
- };
642
-
643
- const materialiser = new LocalMaterialiser({
644
- secureKeyBackend: backend,
645
- tokenRefreshFn: refreshFn,
646
- });
647
-
648
- const result = await materialiser.materialise(resolved.subject);
649
-
650
- expect(result.ok).toBe(false);
651
- if (result.ok) return;
652
- expect(result.error).toMatch(/Failed to refresh/);
653
- expect(result.error).toMatch(/revoked/);
654
- });
655
- });
656
-
657
- // ---------------------------------------------------------------------------
658
- // 7. Circuit breaker
659
- // ---------------------------------------------------------------------------
660
-
661
- describe("refresh circuit breaker", () => {
662
- test("trips after repeated refresh failures and returns error", async () => {
663
- const conn = buildOAuthConnection({
664
- id: "conn-breaker",
665
- providerKey: "google",
666
- expiresAt: Date.now() - 10 * 60 * 1000,
667
- hasRefreshToken: true,
668
- });
669
- const deps = createResolverDeps({ oauthConnections: [conn] });
670
- const handle = localOAuthHandle("google", "conn-breaker");
671
-
672
- const resolved = resolveLocalSubject(handle, deps);
673
- expect(resolved.ok).toBe(true);
674
- if (!resolved.ok) return;
675
-
676
- const backend = createMemoryBackend({
677
- [oauthConnectionAccessTokenPath("conn-breaker")]: "old-token",
678
- [oauthConnectionRefreshTokenPath("conn-breaker")]: "refresh-tok",
679
- });
680
-
681
- let callCount = 0;
682
- const refreshFn: TokenRefreshFn = async () => {
683
- callCount++;
684
- return { success: false, error: "Provider rejected refresh" };
685
- };
686
-
687
- const materialiser = new LocalMaterialiser({
688
- secureKeyBackend: backend,
689
- tokenRefreshFn: refreshFn,
690
- });
691
-
692
- // Exhaust the circuit breaker threshold
693
- for (let i = 0; i < REFRESH_FAILURE_THRESHOLD; i++) {
694
- const result = await materialiser.materialise(resolved.subject);
695
- expect(result.ok).toBe(false);
696
- }
697
-
698
- // The next attempt should be blocked by the circuit breaker
699
- const blockedResult = await materialiser.materialise(resolved.subject);
700
- expect(blockedResult.ok).toBe(false);
701
- if (blockedResult.ok) return;
702
- expect(blockedResult.error).toMatch(/circuit breaker/i);
703
-
704
- // The refresh function should NOT have been called for the blocked attempt
705
- expect(callCount).toBe(REFRESH_FAILURE_THRESHOLD);
706
- });
707
- });
708
-
709
- // ---------------------------------------------------------------------------
710
- // 8. Deterministic failure before outbound work
711
- // ---------------------------------------------------------------------------
712
-
713
- describe("deterministic fail-closed behaviour", () => {
714
- test("all resolution failures happen synchronously before materialisation", () => {
715
- const deps = createResolverDeps({});
716
-
717
- // Invalid handle format
718
- const r1 = resolveLocalSubject("not-a-handle", deps);
719
- expect(r1.ok).toBe(false);
720
-
721
- // Missing static credential
722
- const r2 = resolveLocalSubject(localStaticHandle("missing", "key"), deps);
723
- expect(r2.ok).toBe(false);
724
-
725
- // Missing OAuth connection
726
- const r3 = resolveLocalSubject(
727
- localOAuthHandle("x", "missing-conn"),
728
- deps,
729
- );
730
- expect(r3.ok).toBe(false);
731
-
732
- // Platform handle in local resolver
733
- const r4 = resolveLocalSubject(platformOAuthHandle("plat-conn"), deps);
734
- expect(r4.ok).toBe(false);
735
-
736
- // All failures are deterministic, synchronous, and happen before
737
- // any async materialisation (network call, command launch) is attempted.
738
- });
739
-
740
- test("materialisation failures for missing keys are deterministic", async () => {
741
- const record = buildStaticRecord({
742
- service: "aws",
743
- field: "secret_key",
744
- });
745
- const deps = createResolverDeps({ staticRecords: [record] });
746
- const handle = localStaticHandle("aws", "secret_key");
747
-
748
- const resolved = resolveLocalSubject(handle, deps);
749
- expect(resolved.ok).toBe(true);
750
- if (!resolved.ok) return;
751
-
752
- const backend = createMemoryBackend({}); // Empty — key not stored
753
- const materialiser = new LocalMaterialiser({
754
- secureKeyBackend: backend,
755
- });
756
-
757
- const result = await materialiser.materialise(resolved.subject);
758
-
759
- expect(result.ok).toBe(false);
760
- if (result.ok) return;
761
- // Error happens before any network call could be made
762
- expect(result.error).toMatch(/not found/);
763
- });
764
-
765
- test("OAuth disconnection detected before any refresh attempt", async () => {
766
- const conn = buildOAuthConnection({
767
- id: "conn-disco",
768
- providerKey: "slack",
769
- });
770
- const deps = createResolverDeps({ oauthConnections: [conn] });
771
- const handle = localOAuthHandle("slack", "conn-disco");
772
-
773
- const resolved = resolveLocalSubject(handle, deps);
774
- expect(resolved.ok).toBe(true);
775
- if (!resolved.ok) return;
776
-
777
- // No access token stored
778
- const backend = createMemoryBackend({});
779
- let refreshCalled = false;
780
- const refreshFn: TokenRefreshFn = async () => {
781
- refreshCalled = true;
782
- return { success: true, accessToken: "tok", expiresAt: null };
783
- };
784
-
785
- const materialiser = new LocalMaterialiser({
786
- secureKeyBackend: backend,
787
- tokenRefreshFn: refreshFn,
788
- });
789
-
790
- const result = await materialiser.materialise(resolved.subject);
791
-
792
- expect(result.ok).toBe(false);
793
- // Refresh function should NOT have been called
794
- expect(refreshCalled).toBe(false);
795
- });
796
- });
797
-
798
- // ---------------------------------------------------------------------------
799
- // 9. End-to-end resolution + materialisation
800
- // ---------------------------------------------------------------------------
801
-
802
- describe("end-to-end local materialisation", () => {
803
- test("full pipeline: resolve static handle -> materialise secret", async () => {
804
- const record = buildStaticRecord({
805
- service: "openai",
806
- field: "api_key",
807
- });
808
- const deps = createResolverDeps({ staticRecords: [record] });
809
- const handle = localStaticHandle("openai", "api_key");
810
-
811
- // Step 1: Resolve
812
- const resolved = resolveLocalSubject(handle, deps);
813
- expect(resolved.ok).toBe(true);
814
- if (!resolved.ok) return;
815
-
816
- // Step 2: Materialise
817
- const backend = createMemoryBackend({
818
- "credential/openai/api_key": "sk-live-abc123",
819
- });
820
- const materialiser = new LocalMaterialiser({
821
- secureKeyBackend: backend,
822
- });
823
-
824
- const result = await materialiser.materialise(resolved.subject);
825
- expect(result.ok).toBe(true);
826
- if (!result.ok) return;
827
- expect(result.credential.value).toBe("sk-live-abc123");
828
- expect(result.credential.handleType).toBe(HandleType.LocalStatic);
829
- });
830
-
831
- test("full pipeline: resolve OAuth handle -> materialise token", async () => {
832
- const conn = buildOAuthConnection({
833
- id: "conn-e2e",
834
- providerKey: "linear",
835
- expiresAt: Date.now() + 3600 * 1000,
836
- hasRefreshToken: true,
837
- });
838
- const deps = createResolverDeps({ oauthConnections: [conn] });
839
- const handle = localOAuthHandle("linear", "conn-e2e");
840
-
841
- // Step 1: Resolve
842
- const resolved = resolveLocalSubject(handle, deps);
843
- expect(resolved.ok).toBe(true);
844
- if (!resolved.ok) return;
845
-
846
- // Step 2: Materialise
847
- const backend = createMemoryBackend({
848
- [oauthConnectionAccessTokenPath("conn-e2e")]: "lin_api_token_xyz",
849
- });
850
- const materialiser = new LocalMaterialiser({
851
- secureKeyBackend: backend,
852
- });
853
-
854
- const result = await materialiser.materialise(resolved.subject);
855
- expect(result.ok).toBe(true);
856
- if (!result.ok) return;
857
- expect(result.credential.value).toBe("lin_api_token_xyz");
858
- expect(result.credential.handleType).toBe(HandleType.LocalOAuth);
859
- });
860
- });