@datacules/agent-identity-fastify 0.5.0 → 0.7.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/fastify.test.ts +342 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@datacules/agent-identity-fastify",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "private": false,
5
5
  "description": "Fastify plugin 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
  "fastify": ">=4.0.0",
16
16
  "fastify-plugin": ">=4.0.0"
17
17
  },
@@ -0,0 +1,342 @@
1
+ /**
2
+ * fastify.test.ts
3
+ *
4
+ * Vitest test suite for agentIdentityPlugin from
5
+ * @datacules/agent-identity-fastify.
6
+ *
7
+ * Fastify uses `import type` for FastifyPluginAsync, FastifyRequest,
8
+ * FastifyReply — type imports are erased at runtime.
9
+ *
10
+ * The preHandler hook under test is extracted by calling the plugin with
11
+ * a minimal mock Fastify instance that captures decorateRequest and
12
+ * addHook calls. This avoids requiring the fastify or fastify-plugin
13
+ * runtime packages.
14
+ *
15
+ * 12 test cases:
16
+ * passThrough behavior (4): absent context passThrough true/false, 400 naming
17
+ * credential resolution (5): attach, resolvedFor, 403 on no match, 403 expired
18
+ * custom contextKey (2): reads correct field, 400 names custom key
19
+ * plugin registration (1): fastify-plugin wrapping verified
20
+ */
21
+ import { describe, it, expect, vi } from 'vitest';
22
+ import { agentIdentityPlugin } from './index';
23
+ import type { Credential, RoutingRule } from '@datacules/agent-identity';
24
+
25
+ // ─── Fixtures ─────────────────────────────────────────────────────────────────
26
+
27
+ const FIXED_CREDENTIAL: Credential = {
28
+ id: 'cred-openai-fixed',
29
+ kind: 'fixed',
30
+ name: 'OpenAI Prod Key',
31
+ scope: 'read write',
32
+ status: 'active',
33
+ provider: 'openai',
34
+ ref: 'openai-prod-key',
35
+ };
36
+
37
+ const USER_DELEGATED_CREDENTIAL: Credential = {
38
+ id: 'cred-anthropic-user',
39
+ kind: 'user-delegated',
40
+ name: 'Anthropic User Token',
41
+ scope: 'read',
42
+ status: 'active',
43
+ provider: 'anthropic',
44
+ ref: 'anthropic-user-token',
45
+ };
46
+
47
+ const EXPIRED_CREDENTIAL: Credential = {
48
+ id: 'cred-expired',
49
+ kind: 'fixed',
50
+ name: 'Expired Key',
51
+ scope: 'read write',
52
+ status: 'active',
53
+ provider: 'openai',
54
+ ref: 'expired-key',
55
+ expiresAt: new Date(Date.now() - 1_000).toISOString(),
56
+ };
57
+
58
+ const RULES: RoutingRule[] = [
59
+ {
60
+ id: 'rule-openai-shared',
61
+ credentialRef: 'openai-prod-key',
62
+ priority: 10,
63
+ matchProvider: 'openai',
64
+ matchResourceKind: 'shared',
65
+ },
66
+ {
67
+ id: 'rule-anthropic-personal',
68
+ credentialRef: 'anthropic-user-token',
69
+ priority: 20,
70
+ matchProvider: 'anthropic',
71
+ matchResourceKind: 'personal',
72
+ },
73
+ ];
74
+
75
+ const EXPIRED_RULES: RoutingRule[] = [
76
+ {
77
+ id: 'rule-expired',
78
+ credentialRef: 'expired-key',
79
+ priority: 5,
80
+ matchProvider: 'openai',
81
+ matchResourceKind: 'shared',
82
+ },
83
+ ];
84
+
85
+ const BASE_CONTEXT = {
86
+ userId: 'user-123',
87
+ resourceId: 'res-abc',
88
+ resourceKind: 'shared' as const,
89
+ provider: 'openai' as const,
90
+ model: 'gpt-4',
91
+ action: 'complete',
92
+ traceId: 'trace-001',
93
+ requestedAt: new Date().toISOString(),
94
+ };
95
+
96
+ // ─── Mock Fastify instance helpers ────────────────────────────────────────────
97
+
98
+ /**
99
+ * Builds a minimal mock Fastify instance and calls the plugin's inner
100
+ * function (the one wrapped by fastify-plugin) to capture the addHook
101
+ * preHandler call. Returns the captured preHandler function so tests
102
+ * can invoke it directly without running a real Fastify server.
103
+ *
104
+ * fastify-plugin sets [Symbol.for('skip-override')] = true and exposes
105
+ * the original plugin via .default or the function itself — we access
106
+ * the unwrapped function by checking for [Symbol.for('fastify.display-name')]
107
+ * or falling back to calling the exported plugin directly.
108
+ */
109
+ async function extractPreHandler(
110
+ options: Parameters<typeof agentIdentityPlugin>[1]
111
+ ): Promise<(request: Record<string, unknown>, reply: ReturnType<typeof makeReply>) => Promise<void>> {
112
+ let capturedHook: ((req: unknown, reply: unknown) => Promise<void>) | null = null;
113
+
114
+ const mockFastify = {
115
+ decorateRequest: vi.fn(),
116
+ addHook: vi.fn((_hookName: string, fn: (req: unknown, reply: unknown) => Promise<void>) => {
117
+ capturedHook = fn;
118
+ }),
119
+ };
120
+
121
+ // agentIdentityPlugin is the fp()-wrapped plugin. fp() sets skip-override
122
+ // and the wrapped function is stored on .default or exposed through the
123
+ // Symbol-keyed property. We can call it directly — fp() returns a function
124
+ // that accepts (fastify, options) just like the inner plugin, but also
125
+ // skips Fastify's encapsulation. When called as a plain function it still
126
+ // invokes the inner plugin body.
127
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
128
+ await (agentIdentityPlugin as any)(mockFastify, options);
129
+
130
+ if (!capturedHook) {
131
+ throw new Error('addHook(preHandler) was not called by agentIdentityPlugin');
132
+ }
133
+
134
+ return capturedHook as (req: Record<string, unknown>, reply: ReturnType<typeof makeReply>) => Promise<void>;
135
+ }
136
+
137
+ function makeRequest(body?: Record<string, unknown>) {
138
+ return { body, resolvedCredential: null } as Record<string, unknown>;
139
+ }
140
+
141
+ function makeReply() {
142
+ const send = vi.fn();
143
+ const status = vi.fn(() => ({ send }));
144
+ return { status, send };
145
+ }
146
+
147
+ // ─── Tests ────────────────────────────────────────────────────────────────────
148
+
149
+ describe('agentIdentityPlugin', () => {
150
+
151
+ // ─── Plugin registration ───────────────────────────────────────────────────
152
+
153
+ describe('plugin registration', () => {
154
+ it('is a fastify-plugin (has skip-override symbol set by fp())', () => {
155
+ // fastify-plugin sets this symbol to true so Fastify does not create
156
+ // a child scope — verifies fp() wrapping is in place
157
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
158
+ expect((agentIdentityPlugin as any)[Symbol.for('skip-override')]).toBe(true);
159
+ });
160
+ });
161
+
162
+ // ─── passThrough behavior ──────────────────────────────────────────────────
163
+
164
+ describe('passThrough behavior', () => {
165
+ it('does not call reply when agentContext is absent and passThrough=true (default)', async () => {
166
+ const hook = await extractPreHandler({
167
+ credentials: [FIXED_CREDENTIAL],
168
+ rules: RULES,
169
+ });
170
+ const req = makeRequest({ otherField: 'value' });
171
+ const reply = makeReply();
172
+
173
+ await hook(req, reply);
174
+
175
+ expect(reply.status).not.toHaveBeenCalled();
176
+ });
177
+
178
+ it('does not call reply when req.body is undefined and passThrough=true', async () => {
179
+ const hook = await extractPreHandler({
180
+ credentials: [FIXED_CREDENTIAL],
181
+ rules: RULES,
182
+ });
183
+ const req = makeRequest(undefined);
184
+ const reply = makeReply();
185
+
186
+ await hook(req, reply);
187
+
188
+ expect(reply.status).not.toHaveBeenCalled();
189
+ });
190
+
191
+ it('sends 400 when agentContext is absent and passThrough=false', async () => {
192
+ const hook = await extractPreHandler({
193
+ credentials: [FIXED_CREDENTIAL],
194
+ rules: RULES,
195
+ passThrough: false,
196
+ });
197
+ const req = makeRequest({});
198
+ const reply = makeReply();
199
+
200
+ await hook(req, reply);
201
+
202
+ expect(reply.status).toHaveBeenCalledWith(400);
203
+ });
204
+
205
+ it('400 error message names the missing contextKey', async () => {
206
+ const hook = await extractPreHandler({
207
+ credentials: [FIXED_CREDENTIAL],
208
+ rules: RULES,
209
+ passThrough: false,
210
+ });
211
+ const req = makeRequest({});
212
+ const reply = makeReply();
213
+
214
+ await hook(req, reply);
215
+
216
+ expect(reply.send).toHaveBeenCalledWith(
217
+ expect.objectContaining({ error: expect.stringContaining('agentContext') })
218
+ );
219
+ });
220
+ });
221
+
222
+ // ─── Credential resolution ─────────────────────────────────────────────────
223
+
224
+ describe('credential resolution', () => {
225
+ it('attaches resolvedCredential to request on successful resolution', async () => {
226
+ const hook = await extractPreHandler({
227
+ credentials: [FIXED_CREDENTIAL],
228
+ rules: RULES,
229
+ });
230
+ const req = makeRequest({ agentContext: BASE_CONTEXT });
231
+ const reply = makeReply();
232
+
233
+ await hook(req, reply);
234
+
235
+ expect((req as Record<string, unknown>).resolvedCredential).toBeDefined();
236
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
237
+ expect(((req as any).resolvedCredential as any)?.credentialId).toBe('cred-openai-fixed');
238
+ });
239
+
240
+ it('sets resolvedFor to "service" for fixed credentials', async () => {
241
+ const hook = await extractPreHandler({
242
+ credentials: [FIXED_CREDENTIAL],
243
+ rules: RULES,
244
+ });
245
+ const req = makeRequest({ agentContext: BASE_CONTEXT });
246
+ const reply = makeReply();
247
+
248
+ await hook(req, reply);
249
+
250
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
251
+ const resolved = (req as any).resolvedCredential as any;
252
+ expect(resolved?.kind).toBe('fixed');
253
+ expect(resolved?.resolvedFor).toBe('service');
254
+ });
255
+
256
+ it('sets resolvedFor to ctx.userId for user-delegated credentials', async () => {
257
+ const hook = await extractPreHandler({
258
+ credentials: [FIXED_CREDENTIAL, USER_DELEGATED_CREDENTIAL],
259
+ rules: RULES,
260
+ });
261
+ const anthropicCtx = {
262
+ ...BASE_CONTEXT,
263
+ provider: 'anthropic' as const,
264
+ resourceKind: 'personal' as const,
265
+ };
266
+ const req = makeRequest({ agentContext: anthropicCtx });
267
+ const reply = makeReply();
268
+
269
+ await hook(req, reply);
270
+
271
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
272
+ const resolved = (req as any).resolvedCredential as any;
273
+ expect(resolved?.kind).toBe('user-delegated');
274
+ expect(resolved?.resolvedFor).toBe('user-123');
275
+ });
276
+
277
+ it('sends 403 when no routing rule matches the context', async () => {
278
+ const hook = await extractPreHandler({
279
+ credentials: [FIXED_CREDENTIAL],
280
+ rules: RULES,
281
+ });
282
+ // gemini matches no configured rule
283
+ const ctx = { ...BASE_CONTEXT, provider: 'gemini' as const };
284
+ const req = makeRequest({ agentContext: ctx });
285
+ const reply = makeReply();
286
+
287
+ await hook(req, reply);
288
+
289
+ expect(reply.status).toHaveBeenCalledWith(403);
290
+ });
291
+
292
+ it('sends 403 when the matched credential is expired', async () => {
293
+ const hook = await extractPreHandler({
294
+ credentials: [EXPIRED_CREDENTIAL],
295
+ rules: EXPIRED_RULES,
296
+ });
297
+ const req = makeRequest({ agentContext: BASE_CONTEXT });
298
+ const reply = makeReply();
299
+
300
+ await hook(req, reply);
301
+
302
+ expect(reply.status).toHaveBeenCalledWith(403);
303
+ });
304
+ });
305
+
306
+ // ─── Custom contextKey ─────────────────────────────────────────────────────
307
+
308
+ describe('custom contextKey', () => {
309
+ it('reads the agent context from the custom contextKey field', async () => {
310
+ const hook = await extractPreHandler({
311
+ credentials: [FIXED_CREDENTIAL],
312
+ rules: RULES,
313
+ contextKey: 'identity',
314
+ });
315
+ const req = makeRequest({ identity: BASE_CONTEXT });
316
+ const reply = makeReply();
317
+
318
+ await hook(req, reply);
319
+
320
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
321
+ expect((req as any).resolvedCredential).toBeDefined();
322
+ });
323
+
324
+ it('400 error message names the custom contextKey when passThrough=false', async () => {
325
+ const hook = await extractPreHandler({
326
+ credentials: [FIXED_CREDENTIAL],
327
+ rules: RULES,
328
+ contextKey: 'identity',
329
+ passThrough: false,
330
+ });
331
+ const req = makeRequest({});
332
+ const reply = makeReply();
333
+
334
+ await hook(req, reply);
335
+
336
+ expect(reply.status).toHaveBeenCalledWith(400);
337
+ expect(reply.send).toHaveBeenCalledWith(
338
+ expect.objectContaining({ error: expect.stringContaining('identity') })
339
+ );
340
+ });
341
+ });
342
+ });