@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.
Files changed (2) hide show
  1. package/package.json +2 -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.5.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.1.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
+ });