@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,826 @@
|
|
|
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/ces-contracts";
|
|
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 ?? "integration: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: "integration:google",
|
|
231
|
+
});
|
|
232
|
+
const deps = createResolverDeps({ oauthConnections: [conn] });
|
|
233
|
+
const handle = localOAuthHandle("integration: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("integration:google");
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test("fails when the connection does not exist", () => {
|
|
246
|
+
const deps = createResolverDeps({ oauthConnections: [] });
|
|
247
|
+
const handle = localOAuthHandle("integration: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: "integration:slack",
|
|
261
|
+
});
|
|
262
|
+
const deps = createResolverDeps({ oauthConnections: [conn] });
|
|
263
|
+
// Handle says google but connection is slack
|
|
264
|
+
const handle = localOAuthHandle("integration: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(/integration:slack/);
|
|
272
|
+
expect(result.error).toMatch(/integration: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: "integration: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("integration: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: "integration:slack",
|
|
412
|
+
hasRefreshToken: false,
|
|
413
|
+
});
|
|
414
|
+
const deps = createResolverDeps({ oauthConnections: [conn] });
|
|
415
|
+
const handle = localOAuthHandle("integration: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("materialises a token with null expiresAt (no expiry info)", async () => {
|
|
436
|
+
const conn = buildOAuthConnection({
|
|
437
|
+
id: "conn-noexpiry",
|
|
438
|
+
providerKey: "integration:github",
|
|
439
|
+
expiresAt: null,
|
|
440
|
+
hasRefreshToken: false,
|
|
441
|
+
});
|
|
442
|
+
const deps = createResolverDeps({ oauthConnections: [conn] });
|
|
443
|
+
const handle = localOAuthHandle("integration:github", "conn-noexpiry");
|
|
444
|
+
|
|
445
|
+
const resolved = resolveLocalSubject(handle, deps);
|
|
446
|
+
expect(resolved.ok).toBe(true);
|
|
447
|
+
if (!resolved.ok) return;
|
|
448
|
+
|
|
449
|
+
const backend = createMemoryBackend({
|
|
450
|
+
[oauthConnectionAccessTokenPath("conn-noexpiry")]: "gho_token123",
|
|
451
|
+
});
|
|
452
|
+
const materialiser = new LocalMaterialiser({
|
|
453
|
+
secureKeyBackend: backend,
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
const result = await materialiser.materialise(resolved.subject);
|
|
457
|
+
|
|
458
|
+
// null expiresAt means isTokenExpired returns false, so the token is used as-is
|
|
459
|
+
expect(result.ok).toBe(true);
|
|
460
|
+
if (!result.ok) return;
|
|
461
|
+
expect(result.credential.value).toBe("gho_token123");
|
|
462
|
+
});
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
// ---------------------------------------------------------------------------
|
|
466
|
+
// 6. Refresh-on-expiry
|
|
467
|
+
// ---------------------------------------------------------------------------
|
|
468
|
+
|
|
469
|
+
describe("OAuth refresh-on-expiry", () => {
|
|
470
|
+
test("refreshes an expired token and returns the new access token", async () => {
|
|
471
|
+
const conn = buildOAuthConnection({
|
|
472
|
+
id: "conn-expired",
|
|
473
|
+
providerKey: "integration:google",
|
|
474
|
+
// Token expired 10 minutes ago
|
|
475
|
+
expiresAt: Date.now() - 10 * 60 * 1000,
|
|
476
|
+
hasRefreshToken: true,
|
|
477
|
+
});
|
|
478
|
+
const deps = createResolverDeps({ oauthConnections: [conn] });
|
|
479
|
+
const handle = localOAuthHandle("integration:google", "conn-expired");
|
|
480
|
+
|
|
481
|
+
const resolved = resolveLocalSubject(handle, deps);
|
|
482
|
+
expect(resolved.ok).toBe(true);
|
|
483
|
+
if (!resolved.ok) return;
|
|
484
|
+
|
|
485
|
+
const backend = createMemoryBackend({
|
|
486
|
+
[oauthConnectionAccessTokenPath("conn-expired")]: "old-expired-token",
|
|
487
|
+
[oauthConnectionRefreshTokenPath("conn-expired")]: "refresh-token-abc",
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
const refreshFn: TokenRefreshFn = async (_connId, _refreshToken) => {
|
|
491
|
+
return {
|
|
492
|
+
success: true,
|
|
493
|
+
accessToken: "ya29.new-refreshed-token",
|
|
494
|
+
expiresAt: Date.now() + 3600 * 1000,
|
|
495
|
+
};
|
|
496
|
+
};
|
|
497
|
+
|
|
498
|
+
const materialiser = new LocalMaterialiser({
|
|
499
|
+
secureKeyBackend: backend,
|
|
500
|
+
tokenRefreshFn: refreshFn,
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
const result = await materialiser.materialise(resolved.subject);
|
|
504
|
+
|
|
505
|
+
expect(result.ok).toBe(true);
|
|
506
|
+
if (!result.ok) return;
|
|
507
|
+
expect(result.credential.value).toBe("ya29.new-refreshed-token");
|
|
508
|
+
expect(result.credential.handleType).toBe(HandleType.LocalOAuth);
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
test("fails when token is expired but no refresh function is configured", async () => {
|
|
512
|
+
const conn = buildOAuthConnection({
|
|
513
|
+
id: "conn-no-refresh-fn",
|
|
514
|
+
providerKey: "integration:google",
|
|
515
|
+
expiresAt: Date.now() - 10 * 60 * 1000,
|
|
516
|
+
hasRefreshToken: true,
|
|
517
|
+
});
|
|
518
|
+
const deps = createResolverDeps({ oauthConnections: [conn] });
|
|
519
|
+
const handle = localOAuthHandle("integration:google", "conn-no-refresh-fn");
|
|
520
|
+
|
|
521
|
+
const resolved = resolveLocalSubject(handle, deps);
|
|
522
|
+
expect(resolved.ok).toBe(true);
|
|
523
|
+
if (!resolved.ok) return;
|
|
524
|
+
|
|
525
|
+
const backend = createMemoryBackend({
|
|
526
|
+
[oauthConnectionAccessTokenPath("conn-no-refresh-fn")]: "old-token",
|
|
527
|
+
[oauthConnectionRefreshTokenPath("conn-no-refresh-fn")]: "refresh-tok",
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
// No tokenRefreshFn provided
|
|
531
|
+
const materialiser = new LocalMaterialiser({
|
|
532
|
+
secureKeyBackend: backend,
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
const result = await materialiser.materialise(resolved.subject);
|
|
536
|
+
|
|
537
|
+
expect(result.ok).toBe(false);
|
|
538
|
+
if (result.ok) return;
|
|
539
|
+
expect(result.error).toMatch(/expired/);
|
|
540
|
+
expect(result.error).toMatch(/no refresh.*function/i);
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
test("fails when token is expired and no refresh token is stored", async () => {
|
|
544
|
+
const conn = buildOAuthConnection({
|
|
545
|
+
id: "conn-no-stored-refresh",
|
|
546
|
+
providerKey: "integration:google",
|
|
547
|
+
expiresAt: Date.now() - 10 * 60 * 1000,
|
|
548
|
+
hasRefreshToken: true,
|
|
549
|
+
});
|
|
550
|
+
const deps = createResolverDeps({ oauthConnections: [conn] });
|
|
551
|
+
const handle = localOAuthHandle(
|
|
552
|
+
"integration:google",
|
|
553
|
+
"conn-no-stored-refresh",
|
|
554
|
+
);
|
|
555
|
+
|
|
556
|
+
const resolved = resolveLocalSubject(handle, deps);
|
|
557
|
+
expect(resolved.ok).toBe(true);
|
|
558
|
+
if (!resolved.ok) return;
|
|
559
|
+
|
|
560
|
+
// Access token exists but refresh token does not
|
|
561
|
+
const backend = createMemoryBackend({
|
|
562
|
+
[oauthConnectionAccessTokenPath("conn-no-stored-refresh")]: "old-token",
|
|
563
|
+
// No refresh token entry
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
const refreshFn: TokenRefreshFn = async () => {
|
|
567
|
+
throw new Error("Should not be called");
|
|
568
|
+
};
|
|
569
|
+
|
|
570
|
+
const materialiser = new LocalMaterialiser({
|
|
571
|
+
secureKeyBackend: backend,
|
|
572
|
+
tokenRefreshFn: refreshFn,
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
const result = await materialiser.materialise(resolved.subject);
|
|
576
|
+
|
|
577
|
+
expect(result.ok).toBe(false);
|
|
578
|
+
if (result.ok) return;
|
|
579
|
+
expect(result.error).toMatch(/expired/);
|
|
580
|
+
expect(result.error).toMatch(/no refresh.*token.*available/i);
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
test("fails when refresh function returns a failure result", async () => {
|
|
584
|
+
const conn = buildOAuthConnection({
|
|
585
|
+
id: "conn-refresh-fail",
|
|
586
|
+
providerKey: "integration:google",
|
|
587
|
+
expiresAt: Date.now() - 10 * 60 * 1000,
|
|
588
|
+
hasRefreshToken: true,
|
|
589
|
+
});
|
|
590
|
+
const deps = createResolverDeps({ oauthConnections: [conn] });
|
|
591
|
+
const handle = localOAuthHandle(
|
|
592
|
+
"integration:google",
|
|
593
|
+
"conn-refresh-fail",
|
|
594
|
+
);
|
|
595
|
+
|
|
596
|
+
const resolved = resolveLocalSubject(handle, deps);
|
|
597
|
+
expect(resolved.ok).toBe(true);
|
|
598
|
+
if (!resolved.ok) return;
|
|
599
|
+
|
|
600
|
+
const backend = createMemoryBackend({
|
|
601
|
+
[oauthConnectionAccessTokenPath("conn-refresh-fail")]: "old-token",
|
|
602
|
+
[oauthConnectionRefreshTokenPath("conn-refresh-fail")]: "refresh-tok",
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
const refreshFn: TokenRefreshFn = async () => {
|
|
606
|
+
return { success: false, error: "Token has been revoked by user" };
|
|
607
|
+
};
|
|
608
|
+
|
|
609
|
+
const materialiser = new LocalMaterialiser({
|
|
610
|
+
secureKeyBackend: backend,
|
|
611
|
+
tokenRefreshFn: refreshFn,
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
const result = await materialiser.materialise(resolved.subject);
|
|
615
|
+
|
|
616
|
+
expect(result.ok).toBe(false);
|
|
617
|
+
if (result.ok) return;
|
|
618
|
+
expect(result.error).toMatch(/Failed to refresh/);
|
|
619
|
+
expect(result.error).toMatch(/revoked/);
|
|
620
|
+
});
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
// ---------------------------------------------------------------------------
|
|
624
|
+
// 7. Circuit breaker
|
|
625
|
+
// ---------------------------------------------------------------------------
|
|
626
|
+
|
|
627
|
+
describe("refresh circuit breaker", () => {
|
|
628
|
+
test("trips after repeated refresh failures and returns error", async () => {
|
|
629
|
+
const conn = buildOAuthConnection({
|
|
630
|
+
id: "conn-breaker",
|
|
631
|
+
providerKey: "integration:google",
|
|
632
|
+
expiresAt: Date.now() - 10 * 60 * 1000,
|
|
633
|
+
hasRefreshToken: true,
|
|
634
|
+
});
|
|
635
|
+
const deps = createResolverDeps({ oauthConnections: [conn] });
|
|
636
|
+
const handle = localOAuthHandle("integration:google", "conn-breaker");
|
|
637
|
+
|
|
638
|
+
const resolved = resolveLocalSubject(handle, deps);
|
|
639
|
+
expect(resolved.ok).toBe(true);
|
|
640
|
+
if (!resolved.ok) return;
|
|
641
|
+
|
|
642
|
+
const backend = createMemoryBackend({
|
|
643
|
+
[oauthConnectionAccessTokenPath("conn-breaker")]: "old-token",
|
|
644
|
+
[oauthConnectionRefreshTokenPath("conn-breaker")]: "refresh-tok",
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
let callCount = 0;
|
|
648
|
+
const refreshFn: TokenRefreshFn = async () => {
|
|
649
|
+
callCount++;
|
|
650
|
+
return { success: false, error: "Provider rejected refresh" };
|
|
651
|
+
};
|
|
652
|
+
|
|
653
|
+
const materialiser = new LocalMaterialiser({
|
|
654
|
+
secureKeyBackend: backend,
|
|
655
|
+
tokenRefreshFn: refreshFn,
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
// Exhaust the circuit breaker threshold
|
|
659
|
+
for (let i = 0; i < REFRESH_FAILURE_THRESHOLD; i++) {
|
|
660
|
+
const result = await materialiser.materialise(resolved.subject);
|
|
661
|
+
expect(result.ok).toBe(false);
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// The next attempt should be blocked by the circuit breaker
|
|
665
|
+
const blockedResult = await materialiser.materialise(resolved.subject);
|
|
666
|
+
expect(blockedResult.ok).toBe(false);
|
|
667
|
+
if (blockedResult.ok) return;
|
|
668
|
+
expect(blockedResult.error).toMatch(/circuit breaker/i);
|
|
669
|
+
|
|
670
|
+
// The refresh function should NOT have been called for the blocked attempt
|
|
671
|
+
expect(callCount).toBe(REFRESH_FAILURE_THRESHOLD);
|
|
672
|
+
});
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
// ---------------------------------------------------------------------------
|
|
676
|
+
// 8. Deterministic failure before outbound work
|
|
677
|
+
// ---------------------------------------------------------------------------
|
|
678
|
+
|
|
679
|
+
describe("deterministic fail-closed behaviour", () => {
|
|
680
|
+
test("all resolution failures happen synchronously before materialisation", () => {
|
|
681
|
+
const deps = createResolverDeps({});
|
|
682
|
+
|
|
683
|
+
// Invalid handle format
|
|
684
|
+
const r1 = resolveLocalSubject("not-a-handle", deps);
|
|
685
|
+
expect(r1.ok).toBe(false);
|
|
686
|
+
|
|
687
|
+
// Missing static credential
|
|
688
|
+
const r2 = resolveLocalSubject(localStaticHandle("missing", "key"), deps);
|
|
689
|
+
expect(r2.ok).toBe(false);
|
|
690
|
+
|
|
691
|
+
// Missing OAuth connection
|
|
692
|
+
const r3 = resolveLocalSubject(
|
|
693
|
+
localOAuthHandle("integration:x", "missing-conn"),
|
|
694
|
+
deps,
|
|
695
|
+
);
|
|
696
|
+
expect(r3.ok).toBe(false);
|
|
697
|
+
|
|
698
|
+
// Platform handle in local resolver
|
|
699
|
+
const r4 = resolveLocalSubject(platformOAuthHandle("plat-conn"), deps);
|
|
700
|
+
expect(r4.ok).toBe(false);
|
|
701
|
+
|
|
702
|
+
// All failures are deterministic, synchronous, and happen before
|
|
703
|
+
// any async materialisation (network call, command launch) is attempted.
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
test("materialisation failures for missing keys are deterministic", async () => {
|
|
707
|
+
const record = buildStaticRecord({
|
|
708
|
+
service: "aws",
|
|
709
|
+
field: "secret_key",
|
|
710
|
+
});
|
|
711
|
+
const deps = createResolverDeps({ staticRecords: [record] });
|
|
712
|
+
const handle = localStaticHandle("aws", "secret_key");
|
|
713
|
+
|
|
714
|
+
const resolved = resolveLocalSubject(handle, deps);
|
|
715
|
+
expect(resolved.ok).toBe(true);
|
|
716
|
+
if (!resolved.ok) return;
|
|
717
|
+
|
|
718
|
+
const backend = createMemoryBackend({}); // Empty — key not stored
|
|
719
|
+
const materialiser = new LocalMaterialiser({
|
|
720
|
+
secureKeyBackend: backend,
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
const result = await materialiser.materialise(resolved.subject);
|
|
724
|
+
|
|
725
|
+
expect(result.ok).toBe(false);
|
|
726
|
+
if (result.ok) return;
|
|
727
|
+
// Error happens before any network call could be made
|
|
728
|
+
expect(result.error).toMatch(/not found/);
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
test("OAuth disconnection detected before any refresh attempt", async () => {
|
|
732
|
+
const conn = buildOAuthConnection({
|
|
733
|
+
id: "conn-disco",
|
|
734
|
+
providerKey: "integration:slack",
|
|
735
|
+
});
|
|
736
|
+
const deps = createResolverDeps({ oauthConnections: [conn] });
|
|
737
|
+
const handle = localOAuthHandle("integration:slack", "conn-disco");
|
|
738
|
+
|
|
739
|
+
const resolved = resolveLocalSubject(handle, deps);
|
|
740
|
+
expect(resolved.ok).toBe(true);
|
|
741
|
+
if (!resolved.ok) return;
|
|
742
|
+
|
|
743
|
+
// No access token stored
|
|
744
|
+
const backend = createMemoryBackend({});
|
|
745
|
+
let refreshCalled = false;
|
|
746
|
+
const refreshFn: TokenRefreshFn = async () => {
|
|
747
|
+
refreshCalled = true;
|
|
748
|
+
return { success: true, accessToken: "tok", expiresAt: null };
|
|
749
|
+
};
|
|
750
|
+
|
|
751
|
+
const materialiser = new LocalMaterialiser({
|
|
752
|
+
secureKeyBackend: backend,
|
|
753
|
+
tokenRefreshFn: refreshFn,
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
const result = await materialiser.materialise(resolved.subject);
|
|
757
|
+
|
|
758
|
+
expect(result.ok).toBe(false);
|
|
759
|
+
// Refresh function should NOT have been called
|
|
760
|
+
expect(refreshCalled).toBe(false);
|
|
761
|
+
});
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
// ---------------------------------------------------------------------------
|
|
765
|
+
// 9. End-to-end resolution + materialisation
|
|
766
|
+
// ---------------------------------------------------------------------------
|
|
767
|
+
|
|
768
|
+
describe("end-to-end local materialisation", () => {
|
|
769
|
+
test("full pipeline: resolve static handle -> materialise secret", async () => {
|
|
770
|
+
const record = buildStaticRecord({
|
|
771
|
+
service: "openai",
|
|
772
|
+
field: "api_key",
|
|
773
|
+
});
|
|
774
|
+
const deps = createResolverDeps({ staticRecords: [record] });
|
|
775
|
+
const handle = localStaticHandle("openai", "api_key");
|
|
776
|
+
|
|
777
|
+
// Step 1: Resolve
|
|
778
|
+
const resolved = resolveLocalSubject(handle, deps);
|
|
779
|
+
expect(resolved.ok).toBe(true);
|
|
780
|
+
if (!resolved.ok) return;
|
|
781
|
+
|
|
782
|
+
// Step 2: Materialise
|
|
783
|
+
const backend = createMemoryBackend({
|
|
784
|
+
"credential/openai/api_key": "sk-live-abc123",
|
|
785
|
+
});
|
|
786
|
+
const materialiser = new LocalMaterialiser({
|
|
787
|
+
secureKeyBackend: backend,
|
|
788
|
+
});
|
|
789
|
+
|
|
790
|
+
const result = await materialiser.materialise(resolved.subject);
|
|
791
|
+
expect(result.ok).toBe(true);
|
|
792
|
+
if (!result.ok) return;
|
|
793
|
+
expect(result.credential.value).toBe("sk-live-abc123");
|
|
794
|
+
expect(result.credential.handleType).toBe(HandleType.LocalStatic);
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
test("full pipeline: resolve OAuth handle -> materialise token", async () => {
|
|
798
|
+
const conn = buildOAuthConnection({
|
|
799
|
+
id: "conn-e2e",
|
|
800
|
+
providerKey: "integration:linear",
|
|
801
|
+
expiresAt: Date.now() + 3600 * 1000,
|
|
802
|
+
hasRefreshToken: true,
|
|
803
|
+
});
|
|
804
|
+
const deps = createResolverDeps({ oauthConnections: [conn] });
|
|
805
|
+
const handle = localOAuthHandle("integration:linear", "conn-e2e");
|
|
806
|
+
|
|
807
|
+
// Step 1: Resolve
|
|
808
|
+
const resolved = resolveLocalSubject(handle, deps);
|
|
809
|
+
expect(resolved.ok).toBe(true);
|
|
810
|
+
if (!resolved.ok) return;
|
|
811
|
+
|
|
812
|
+
// Step 2: Materialise
|
|
813
|
+
const backend = createMemoryBackend({
|
|
814
|
+
[oauthConnectionAccessTokenPath("conn-e2e")]: "lin_api_token_xyz",
|
|
815
|
+
});
|
|
816
|
+
const materialiser = new LocalMaterialiser({
|
|
817
|
+
secureKeyBackend: backend,
|
|
818
|
+
});
|
|
819
|
+
|
|
820
|
+
const result = await materialiser.materialise(resolved.subject);
|
|
821
|
+
expect(result.ok).toBe(true);
|
|
822
|
+
if (!result.ok) return;
|
|
823
|
+
expect(result.credential.value).toBe("lin_api_token_xyz");
|
|
824
|
+
expect(result.credential.handleType).toBe(HandleType.LocalOAuth);
|
|
825
|
+
});
|
|
826
|
+
});
|