@datacules/agent-identity-express 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/express.test.ts +340 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@datacules/agent-identity-express",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Express middleware for @datacules/agent-identity",
|
|
6
6
|
"main": "./dist/cjs/index.js",
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
"type-check": "tsc --noEmit"
|
|
12
12
|
},
|
|
13
13
|
"peerDependencies": {
|
|
14
|
-
"@datacules/agent-identity": "^0.
|
|
14
|
+
"@datacules/agent-identity": "^0.6.0",
|
|
15
15
|
"express": ">=4.0.0"
|
|
16
16
|
},
|
|
17
17
|
"devDependencies": {
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* express.test.ts
|
|
3
|
+
*
|
|
4
|
+
* Vitest test suite for agentIdentityMiddleware from
|
|
5
|
+
* @datacules/agent-identity-express.
|
|
6
|
+
*
|
|
7
|
+
* Express uses `import type` for Request/Response/NextFunction — those imports
|
|
8
|
+
* are erased at runtime, so no express runtime dependency is needed here.
|
|
9
|
+
* req, res, and next are created as plain typed mock objects.
|
|
10
|
+
*
|
|
11
|
+
* 13 test cases:
|
|
12
|
+
* passThrough behavior (4): absent context + passThrough=true/false variants
|
|
13
|
+
* credential resolution (7): attach, next, resolvedFor, 403, expired, logger
|
|
14
|
+
* custom contextKey (2): reads correct field, 400 names custom key
|
|
15
|
+
*/
|
|
16
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
17
|
+
import { agentIdentityMiddleware } from './index';
|
|
18
|
+
import type { Credential, RoutingRule } from '@datacules/agent-identity';
|
|
19
|
+
|
|
20
|
+
// ─── Fixtures ────────────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
const FIXED_CREDENTIAL: Credential = {
|
|
23
|
+
id: 'cred-openai-fixed',
|
|
24
|
+
kind: 'fixed',
|
|
25
|
+
name: 'OpenAI Prod Key',
|
|
26
|
+
scope: 'read write',
|
|
27
|
+
status: 'active',
|
|
28
|
+
provider: 'openai',
|
|
29
|
+
ref: 'openai-prod-key',
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const USER_DELEGATED_CREDENTIAL: Credential = {
|
|
33
|
+
id: 'cred-anthropic-user',
|
|
34
|
+
kind: 'user-delegated',
|
|
35
|
+
name: 'Anthropic User Token',
|
|
36
|
+
scope: 'read',
|
|
37
|
+
status: 'active',
|
|
38
|
+
provider: 'anthropic',
|
|
39
|
+
ref: 'anthropic-user-token',
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const EXPIRED_CREDENTIAL: Credential = {
|
|
43
|
+
id: 'cred-expired',
|
|
44
|
+
kind: 'fixed',
|
|
45
|
+
name: 'Expired Key',
|
|
46
|
+
scope: 'read write',
|
|
47
|
+
status: 'active',
|
|
48
|
+
provider: 'openai',
|
|
49
|
+
ref: 'expired-key',
|
|
50
|
+
expiresAt: new Date(Date.now() - 1_000).toISOString(), // 1 second in the past
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const RULES: RoutingRule[] = [
|
|
54
|
+
{
|
|
55
|
+
id: 'rule-openai-shared',
|
|
56
|
+
credentialRef: 'openai-prod-key',
|
|
57
|
+
priority: 10,
|
|
58
|
+
matchProvider: 'openai',
|
|
59
|
+
matchResourceKind: 'shared',
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
id: 'rule-anthropic-personal',
|
|
63
|
+
credentialRef: 'anthropic-user-token',
|
|
64
|
+
priority: 20,
|
|
65
|
+
matchProvider: 'anthropic',
|
|
66
|
+
matchResourceKind: 'personal',
|
|
67
|
+
},
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
const EXPIRED_RULES: RoutingRule[] = [
|
|
71
|
+
{
|
|
72
|
+
id: 'rule-expired',
|
|
73
|
+
credentialRef: 'expired-key',
|
|
74
|
+
priority: 5,
|
|
75
|
+
matchProvider: 'openai',
|
|
76
|
+
matchResourceKind: 'shared',
|
|
77
|
+
},
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
const BASE_CONTEXT = {
|
|
81
|
+
userId: 'user-123',
|
|
82
|
+
resourceId: 'res-abc',
|
|
83
|
+
resourceKind: 'shared' as const,
|
|
84
|
+
provider: 'openai' as const,
|
|
85
|
+
model: 'gpt-4',
|
|
86
|
+
action: 'complete',
|
|
87
|
+
traceId: 'trace-001',
|
|
88
|
+
requestedAt: new Date().toISOString(),
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
// ─── Mock helpers ─────────────────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
// req.body can be undefined (before express.json() middleware runs)
|
|
94
|
+
function makeReq(body?: Record<string, unknown>) {
|
|
95
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
96
|
+
return { body } as any;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// res.status(N).json(obj) — status() returns a plain object whose json property
|
|
100
|
+
// is the same vi.fn() exposed as res.json, so assertions on res.json capture
|
|
101
|
+
// all json() calls made through either the chained or direct path.
|
|
102
|
+
function makeRes() {
|
|
103
|
+
const json = vi.fn();
|
|
104
|
+
const status = vi.fn(() => ({ json }));
|
|
105
|
+
return { status, json };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function makeNext() {
|
|
109
|
+
return vi.fn();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ─── Tests ────────────────────────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
describe('agentIdentityMiddleware', () => {
|
|
115
|
+
|
|
116
|
+
// ─── passThrough behavior ──────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
describe('passThrough behavior', () => {
|
|
119
|
+
it('calls next() when agentContext is absent and passThrough=true (default)', () => {
|
|
120
|
+
const mw = agentIdentityMiddleware({
|
|
121
|
+
credentials: [FIXED_CREDENTIAL],
|
|
122
|
+
rules: RULES,
|
|
123
|
+
});
|
|
124
|
+
const req = makeReq({ otherField: 'value' });
|
|
125
|
+
const res = makeRes();
|
|
126
|
+
const next = makeNext();
|
|
127
|
+
|
|
128
|
+
mw(req, res as any, next); // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
129
|
+
|
|
130
|
+
expect(next).toHaveBeenCalledOnce();
|
|
131
|
+
expect(res.status).not.toHaveBeenCalled();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('calls next() when req.body is undefined and passThrough=true', () => {
|
|
135
|
+
const mw = agentIdentityMiddleware({
|
|
136
|
+
credentials: [FIXED_CREDENTIAL],
|
|
137
|
+
rules: RULES,
|
|
138
|
+
});
|
|
139
|
+
const req = makeReq(undefined);
|
|
140
|
+
const res = makeRes();
|
|
141
|
+
const next = makeNext();
|
|
142
|
+
|
|
143
|
+
mw(req, res as any, next); // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
144
|
+
|
|
145
|
+
expect(next).toHaveBeenCalledOnce();
|
|
146
|
+
expect(res.status).not.toHaveBeenCalled();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('sends 400 when agentContext is absent and passThrough=false', () => {
|
|
150
|
+
const mw = agentIdentityMiddleware({
|
|
151
|
+
credentials: [FIXED_CREDENTIAL],
|
|
152
|
+
rules: RULES,
|
|
153
|
+
passThrough: false,
|
|
154
|
+
});
|
|
155
|
+
const req = makeReq({});
|
|
156
|
+
const res = makeRes();
|
|
157
|
+
const next = makeNext();
|
|
158
|
+
|
|
159
|
+
mw(req, res as any, next); // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
160
|
+
|
|
161
|
+
expect(res.status).toHaveBeenCalledWith(400);
|
|
162
|
+
expect(next).not.toHaveBeenCalled();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('400 error message names the missing contextKey', () => {
|
|
166
|
+
const mw = agentIdentityMiddleware({
|
|
167
|
+
credentials: [FIXED_CREDENTIAL],
|
|
168
|
+
rules: RULES,
|
|
169
|
+
passThrough: false,
|
|
170
|
+
});
|
|
171
|
+
const req = makeReq({});
|
|
172
|
+
const res = makeRes();
|
|
173
|
+
const next = makeNext();
|
|
174
|
+
|
|
175
|
+
mw(req, res as any, next); // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
176
|
+
|
|
177
|
+
// res.status() returns { json: same-vi-fn }, so res.json captures the call
|
|
178
|
+
expect(res.json).toHaveBeenCalledWith(
|
|
179
|
+
expect.objectContaining({ error: expect.stringContaining('agentContext') })
|
|
180
|
+
);
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// ─── Credential resolution ─────────────────────────────────────────────────
|
|
185
|
+
|
|
186
|
+
describe('credential resolution', () => {
|
|
187
|
+
it('attaches resolvedCredential to req on successful resolution', () => {
|
|
188
|
+
const mw = agentIdentityMiddleware({
|
|
189
|
+
credentials: [FIXED_CREDENTIAL],
|
|
190
|
+
rules: RULES,
|
|
191
|
+
});
|
|
192
|
+
const req = makeReq({ agentContext: BASE_CONTEXT });
|
|
193
|
+
const res = makeRes();
|
|
194
|
+
const next = makeNext();
|
|
195
|
+
|
|
196
|
+
mw(req, res as any, next); // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
197
|
+
|
|
198
|
+
expect(req.resolvedCredential).toBeDefined();
|
|
199
|
+
expect(req.resolvedCredential?.credentialId).toBe('cred-openai-fixed');
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('calls next() and sends no response on successful resolution', () => {
|
|
203
|
+
const mw = agentIdentityMiddleware({
|
|
204
|
+
credentials: [FIXED_CREDENTIAL],
|
|
205
|
+
rules: RULES,
|
|
206
|
+
});
|
|
207
|
+
const req = makeReq({ agentContext: BASE_CONTEXT });
|
|
208
|
+
const res = makeRes();
|
|
209
|
+
const next = makeNext();
|
|
210
|
+
|
|
211
|
+
mw(req, res as any, next); // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
212
|
+
|
|
213
|
+
expect(next).toHaveBeenCalledOnce();
|
|
214
|
+
expect(res.status).not.toHaveBeenCalled();
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('sets resolvedFor to "service" for fixed credentials', () => {
|
|
218
|
+
const mw = agentIdentityMiddleware({
|
|
219
|
+
credentials: [FIXED_CREDENTIAL],
|
|
220
|
+
rules: RULES,
|
|
221
|
+
});
|
|
222
|
+
const req = makeReq({ agentContext: BASE_CONTEXT });
|
|
223
|
+
const res = makeRes();
|
|
224
|
+
const next = makeNext();
|
|
225
|
+
|
|
226
|
+
mw(req, res as any, next); // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
227
|
+
|
|
228
|
+
expect(req.resolvedCredential?.kind).toBe('fixed');
|
|
229
|
+
expect(req.resolvedCredential?.resolvedFor).toBe('service');
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('sets resolvedFor to ctx.userId for user-delegated credentials', () => {
|
|
233
|
+
const mw = agentIdentityMiddleware({
|
|
234
|
+
credentials: [FIXED_CREDENTIAL, USER_DELEGATED_CREDENTIAL],
|
|
235
|
+
rules: RULES,
|
|
236
|
+
});
|
|
237
|
+
const anthropicCtx = {
|
|
238
|
+
...BASE_CONTEXT,
|
|
239
|
+
provider: 'anthropic' as const,
|
|
240
|
+
resourceKind: 'personal' as const,
|
|
241
|
+
};
|
|
242
|
+
const req = makeReq({ agentContext: anthropicCtx });
|
|
243
|
+
const res = makeRes();
|
|
244
|
+
const next = makeNext();
|
|
245
|
+
|
|
246
|
+
mw(req, res as any, next); // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
247
|
+
|
|
248
|
+
expect(req.resolvedCredential?.kind).toBe('user-delegated');
|
|
249
|
+
expect(req.resolvedCredential?.resolvedFor).toBe('user-123');
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('sends 403 when no routing rule matches the context', () => {
|
|
253
|
+
const mw = agentIdentityMiddleware({
|
|
254
|
+
credentials: [FIXED_CREDENTIAL],
|
|
255
|
+
rules: RULES,
|
|
256
|
+
});
|
|
257
|
+
// gemini matches no configured rule
|
|
258
|
+
const ctx = { ...BASE_CONTEXT, provider: 'gemini' as const };
|
|
259
|
+
const req = makeReq({ agentContext: ctx });
|
|
260
|
+
const res = makeRes();
|
|
261
|
+
const next = makeNext();
|
|
262
|
+
|
|
263
|
+
mw(req, res as any, next); // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
264
|
+
|
|
265
|
+
expect(res.status).toHaveBeenCalledWith(403);
|
|
266
|
+
expect(next).not.toHaveBeenCalled();
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it('sends 403 when the matched credential is expired', () => {
|
|
270
|
+
const mw = agentIdentityMiddleware({
|
|
271
|
+
credentials: [EXPIRED_CREDENTIAL],
|
|
272
|
+
rules: EXPIRED_RULES,
|
|
273
|
+
});
|
|
274
|
+
const req = makeReq({ agentContext: BASE_CONTEXT });
|
|
275
|
+
const res = makeRes();
|
|
276
|
+
const next = makeNext();
|
|
277
|
+
|
|
278
|
+
mw(req, res as any, next); // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
279
|
+
|
|
280
|
+
expect(res.status).toHaveBeenCalledWith(403);
|
|
281
|
+
expect(next).not.toHaveBeenCalled();
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it('invokes the audit logger when a credential resolves successfully', () => {
|
|
285
|
+
const logger = { log: vi.fn() };
|
|
286
|
+
const mw = agentIdentityMiddleware({
|
|
287
|
+
credentials: [FIXED_CREDENTIAL],
|
|
288
|
+
rules: RULES,
|
|
289
|
+
logger,
|
|
290
|
+
});
|
|
291
|
+
const req = makeReq({ agentContext: BASE_CONTEXT });
|
|
292
|
+
const res = makeRes();
|
|
293
|
+
const next = makeNext();
|
|
294
|
+
|
|
295
|
+
mw(req, res as any, next); // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
296
|
+
|
|
297
|
+
// logger.log() is called synchronously inside Promise.resolve(logger.log(entry))
|
|
298
|
+
expect(logger.log).toHaveBeenCalledOnce();
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
// ─── Custom contextKey ─────────────────────────────────────────────────────
|
|
303
|
+
|
|
304
|
+
describe('custom contextKey', () => {
|
|
305
|
+
it('reads the agent context from the custom contextKey field', () => {
|
|
306
|
+
const mw = agentIdentityMiddleware({
|
|
307
|
+
credentials: [FIXED_CREDENTIAL],
|
|
308
|
+
rules: RULES,
|
|
309
|
+
contextKey: 'identity',
|
|
310
|
+
});
|
|
311
|
+
const req = makeReq({ identity: BASE_CONTEXT });
|
|
312
|
+
const res = makeRes();
|
|
313
|
+
const next = makeNext();
|
|
314
|
+
|
|
315
|
+
mw(req, res as any, next); // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
316
|
+
|
|
317
|
+
expect(req.resolvedCredential).toBeDefined();
|
|
318
|
+
expect(next).toHaveBeenCalledOnce();
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it('400 error message names the custom contextKey when passThrough=false', () => {
|
|
322
|
+
const mw = agentIdentityMiddleware({
|
|
323
|
+
credentials: [FIXED_CREDENTIAL],
|
|
324
|
+
rules: RULES,
|
|
325
|
+
contextKey: 'identity',
|
|
326
|
+
passThrough: false,
|
|
327
|
+
});
|
|
328
|
+
const req = makeReq({});
|
|
329
|
+
const res = makeRes();
|
|
330
|
+
const next = makeNext();
|
|
331
|
+
|
|
332
|
+
mw(req, res as any, next); // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
333
|
+
|
|
334
|
+
expect(res.status).toHaveBeenCalledWith(400);
|
|
335
|
+
expect(res.json).toHaveBeenCalledWith(
|
|
336
|
+
expect.objectContaining({ error: expect.stringContaining('identity') })
|
|
337
|
+
);
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
});
|