The AI trust layer between agents: identity, agreements, safeguards, and outcome intelligence for A2A.

Viewing files at commit 3b628facecbcCommit detailsView latest

tests/api.test.ts 417 lines · 14.4 KB · typescript
1
import { describe, expect, it } from 'vitest';
2
import { buildApi } from '../apps/api/src/app.js';
3
4
describe('HTTP API', () => {
5
  it('serves health, readiness, and OpenAPI', async () => {
6
    const app = buildApi();
7
    await app.ready();
8
    expect((await app.inject({ method: 'GET', url: '/health' })).statusCode).toBe(200);
9
    expect((await app.inject({ method: 'GET', url: '/ready' })).json()).toEqual({
10
      status: 'ready',
11
    });
12
    expect(
13
      (await app.inject({ method: 'GET', url: '/extensions/trust/v0.1' })).json(),
14
    ).toMatchObject({ version: '0.1', transportsMessages: false });
15
    const specification = (await app.inject({ method: 'GET', url: '/openapi.json' })).json();
16
    expect(specification.info.title).toBe('OpenClasp API');
17
    expect(specification.paths).toHaveProperty('/v0.1/risk/assess');
18
    await app.close();
19
  });
20
21
  it('isolates hosted dashboard and settings by authenticated operator', async () => {
22
    const calls: string[] = [];
23
    const repository = {
24
      dashboard: async (operatorId: string) => {
25
        calls.push(`dashboard:${operatorId}`);
26
        return {
27
          agents: [],
28
          projects: [],
29
          installations: [],
30
          setupRequests: [],
31
          publications: [],
32
          interactions: [],
33
          federatedInteractions: [],
34
          liveSessions: [],
35
          hostedThreads: [],
36
          events: [],
37
          conflicts: [],
38
          receipts: [],
39
          profiles: [],
40
          runtimes: [],
41
          accessTokens: [],
42
        };
43
      },
44
      getSettings: async (operatorId: string) => {
45
        calls.push(`settings:${operatorId}`);
46
        return {
47
          displayName: '',
48
          contributionEnabled: false,
49
          retentionDays: 30,
50
          evidenceSharing: 'ask' as const,
51
          rawConversationsStored: false as const,
52
        };
53
      },
54
      saveSettings: async (operatorId: string, value: any) => {
55
        calls.push(`save:${operatorId}`);
56
        return { ...value, rawConversationsStored: false as const };
57
      },
58
      upsert: async () => undefined,
59
      list: async () => [],
60
      publishAgent: async (_operatorId: string, card: any) => card,
61
      unpublishAgent: async () => true,
62
      getPublishedAgent: async () => undefined,
63
      searchPublishedAgents: async () => [],
64
      registerAgentRuntime: async (operatorId: string, agentId: string, endpoint: string) => {
65
        calls.push(`runtime:${operatorId}:${agentId}:${endpoint}`);
66
        return {
67
          agentId,
68
          endpoint,
69
          a2aEndpoint: endpoint,
70
          status: 'verified' as const,
71
          verifiedAt: new Date().toISOString(),
72
          verificationKey: 'public-key',
73
        };
74
      },
75
      disableAgentRuntime: async (operatorId: string, agentId: string) => {
76
        calls.push(`disable-runtime:${operatorId}:${agentId}`);
77
        return { agentId, status: 'disabled' as const };
78
      },
79
      deleteAgent: async (operatorId: string, agentId: string) => {
80
        calls.push(`delete-agent:${operatorId}:${agentId}`);
81
        return { agentId, deleted: true as const, historyRetained: true as const };
82
      },
83
      listAgentAccessTokens: async (operatorId: string, agentId?: string) => {
84
        calls.push(`list-tokens:${operatorId}:${agentId ?? '*'}`);
85
        return [];
86
      },
87
      issueAgentAccessToken: async (
88
        operatorId: string,
89
        agentId: string,
90
        value: { name: string; expiresInDays: number },
91
      ) => {
92
        calls.push(`issue-token:${operatorId}:${agentId}:${value.name}:${value.expiresInDays}`);
93
        return {
94
          tokenId: 'abcdefghijklmnop',
95
          token: 'oc_at_abcdefghijklmnop.abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG',
96
          agentId,
97
          name: value.name,
98
          scopes: ['mcp:access'],
99
          createdAt: '2026-01-01T00:00:00.000Z',
100
          expiresAt: '2027-01-01T00:00:00.000Z',
101
        };
102
      },
103
      revokeAgentAccessToken: async (operatorId: string, agentId: string, tokenId: string) => {
104
        calls.push(`revoke-token:${operatorId}:${agentId}:${tokenId}`);
105
        return { tokenId, agentId, revokedAt: '2026-01-02T00:00:00.000Z' };
106
      },
107
      receiveTemporaryMessage: async (
108
        token: string,
109
        agentId: string,
110
        requestKey: string,
111
        content: string,
112
      ) => {
113
        calls.push(`temporary:${token}:${agentId}:${requestKey}:${content}`);
114
        return {
115
          message: {
116
            messageId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
117
            threadId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb',
118
            interactionId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb',
119
            senderAgentId: 'agent-peer',
120
            recipientAgentId: agentId,
121
            contentType: 'text/plain' as const,
122
            content,
123
            contentHash: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
124
            delivery: 'delivered' as const,
125
            createdAt: new Date().toISOString(),
126
          },
127
          deduplicated: false,
128
        };
129
      },
130
    };
131
    const app = buildApi(undefined, undefined, repository);
132
    await app.ready();
133
    expect((await app.inject({ method: 'GET', url: '/v0.1/dashboard' })).statusCode).toBe(401);
134
    expect(
135
      (
136
        await app.inject({
137
          method: 'GET',
138
          url: '/v0.1/dashboard',
139
          headers: { 'x-openclasp-operator': 'user-a' },
140
        })
141
      ).statusCode,
142
    ).toBe(200);
143
    expect(
144
      (
145
        await app.inject({
146
          method: 'GET',
147
          url: '/v0.1/settings',
148
          headers: { 'x-openclasp-operator': 'user-b' },
149
        })
150
      ).statusCode,
151
    ).toBe(200);
152
    expect(
153
      (
154
        await app.inject({
155
          method: 'PUT',
156
          url: '/v0.1/agents/agent-a/runtime',
157
          headers: { 'x-openclasp-operator': 'user-a' },
158
          payload: { endpoint: 'https://agent.example/openclasp' },
159
        })
160
      ).statusCode,
161
    ).toBe(200);
162
    expect(calls).toEqual([
163
      'dashboard:user-a',
164
      'settings:user-b',
165
      'runtime:user-a:agent-a:https://agent.example/openclasp',
166
    ]);
167
    expect(
168
      (
169
        await app.inject({
170
          method: 'DELETE',
171
          url: '/v0.1/agents/agent-a',
172
          headers: { 'x-openclasp-operator': 'user-a' },
173
        })
174
      ).json(),
175
    ).toMatchObject({ agentId: 'agent-a', deleted: true, historyRetained: true });
176
    expect(calls.at(-1)).toBe('delete-agent:user-a:agent-a');
177
    const issuedToken = await app.inject({
178
      method: 'POST',
179
      url: '/v0.1/agents/agent-a/access-tokens',
180
      headers: { 'x-openclasp-operator': 'user-a' },
181
      payload: { name: 'Botpress', expiresInDays: 365 },
182
    });
183
    expect(issuedToken.statusCode).toBe(200);
184
    expect(issuedToken.json()).toMatchObject({
185
      agentId: 'agent-a',
186
      name: 'Botpress',
187
      scopes: ['mcp:access'],
188
    });
189
    expect(calls.at(-1)).toBe('issue-token:user-a:agent-a:Botpress:365');
190
    expect(
191
      (
192
        await app.inject({
193
          method: 'DELETE',
194
          url: '/v0.1/agents/agent-a/access-tokens/abcdefghijklmnop',
195
          headers: { 'x-openclasp-operator': 'user-a' },
196
        })
197
      ).statusCode,
198
    ).toBe(200);
199
    expect(calls.at(-1)).toBe('revoke-token:user-a:agent-a:abcdefghijklmnop');
200
    const providerConnection = await app.inject({
201
      method: 'POST',
202
      url: '/v0.1/provider-connections',
203
      headers: { 'x-openclasp-operator': 'user-a' },
204
      payload: {
205
        provider: 'botpress',
206
        agentName: 'Recruiting agent',
207
        projectName: 'Recruiting',
208
        capabilities: ['candidate matching'],
209
        limitations: ['no final hiring decisions'],
210
        expiresInDays: 365,
211
      },
212
    });
213
    expect(providerConnection.statusCode).toBe(200);
214
    expect(providerConnection.json()).toMatchObject({
215
      agent: {
216
        name: 'Recruiting agent',
217
        framework: 'Botpress',
218
        identityMode: 'owner_managed',
219
      },
220
      accessToken: { name: 'Botpress', scopes: ['mcp:access'] },
221
    });
222
    expect(calls.at(-1)).toMatch(/^issue-token:user-a:agent_.*:Botpress:365$/);
223
    expect(
224
      (
225
        await app.inject({
226
          method: 'POST',
227
          url: '/a2a/temporary/agent-a',
228
          payload: {
229
            jsonrpc: '2.0',
230
            id: 'request-1',
231
            method: 'message/send',
232
            params: { message: { parts: [{ kind: 'text', text: 'Hello engineer' }] } },
233
          },
234
        })
235
      ).statusCode,
236
    ).toBe(401);
237
    const temporaryDelivery = await app.inject({
238
      method: 'POST',
239
      url: '/a2a/temporary/agent-a',
240
      headers: { authorization: 'Bearer scoped-token' },
241
      payload: {
242
        jsonrpc: '2.0',
243
        id: 'request-1',
244
        method: 'message/send',
245
        params: { message: { parts: [{ kind: 'text', text: 'Hello engineer' }] } },
246
      },
247
    });
248
    expect(temporaryDelivery.statusCode).toBe(200);
249
    expect(temporaryDelivery.json()).toMatchObject({
250
      result: {
251
        task: { id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', state: 'submitted' },
252
        privacyMode: 'openclasp_hosted_temporary',
253
      },
254
    });
255
    expect(calls.at(-1)).toBe('temporary:scoped-token:agent-a:request-1:Hello engineer');
256
    await app.close();
257
  });
258
259
  it('publishes only an owned agent public card', async () => {
260
    const published: any[] = [];
261
    const repository = {
262
      dashboard: async () => ({}) as any,
263
      getSettings: async () => ({}) as any,
264
      saveSettings: async () => ({}) as any,
265
      upsert: async () => undefined,
266
      list: async () => [
267
        {
268
          kind: 'agent_profile' as const,
269
          recordId: 'agent-one',
270
          payload: {
271
            agentId: 'agent-one',
272
            projectId: 'secret-project',
273
            name: 'Research agent',
274
            description: 'Finds primary sources',
275
            framework: 'Codex',
276
            agentVersion: '1.0.0',
277
            a2aEndpoint: 'https://agent.example/a2a',
278
            capabilities: ['research'],
279
            limitations: ['no purchases'],
280
            identityMode: 'oauth_installation' as const,
281
            status: 'active' as const,
282
            createdAt: '2026-01-01T00:00:00.000Z',
283
            updatedAt: '2026-01-01T00:00:00.000Z',
284
          },
285
        },
286
      ],
287
      publishAgent: async (_operatorId: string, card: any) => {
288
        published.push(card);
289
        return card;
290
      },
291
      unpublishAgent: async () => true,
292
      getPublishedAgent: async () => published[0],
293
      searchPublishedAgents: async () => published,
294
    };
295
    const app = buildApi(undefined, undefined, repository);
296
    await app.ready();
297
    const response = await app.inject({
298
      method: 'POST',
299
      url: '/v0.1/agents/agent-one/publication',
300
      headers: { 'x-openclasp-operator': 'owner-one' },
301
      payload: { published: true },
302
    });
303
    expect(response.statusCode).toBe(200);
304
    expect(published[0]).toMatchObject({
305
      agentId: 'agent-one',
306
      capabilities: ['research'],
307
      assurance: 'oauth_authenticated',
308
    });
309
    expect(published[0]).not.toHaveProperty('projectId');
310
    expect(published[0]).not.toHaveProperty('operatorId');
311
    const publicCard = await app.inject({
312
      method: 'GET',
313
      url: '/agents/agent-one/card.json',
314
    });
315
    expect(publicCard.statusCode).toBe(200);
316
    expect(publicCard.json()).not.toHaveProperty('projectId');
317
    const a2aCard = await app.inject({
318
      method: 'GET',
319
      url: '/agents/agent-one/a2a-agent-card.json',
320
    });
321
    expect(a2aCard.statusCode).toBe(200);
322
    expect(a2aCard.json().supportedInterfaces[0]).toMatchObject({
323
      url: 'https://agent.example/a2a',
324
      protocolVersion: '1.0',
325
    });
326
    const automation = await app.inject({
327
      method: 'PUT',
328
      url: '/v0.1/agents/agent-one/automation',
329
      headers: { 'x-openclasp-operator': 'owner-one' },
330
      payload: {
331
        a2aEndpoint: 'https://agent.example/a2a',
332
        autoPublish: true,
333
        autoAcceptPolicy: 'safe_matching',
334
        autoAcceptTaskCategories: ['research'],
335
      },
336
    });
337
    expect(automation.statusCode).toBe(200);
338
    expect(automation.json()).toMatchObject({
339
      autoPublish: true,
340
      autoAcceptPolicy: 'safe_matching',
341
    });
342
    await app.close();
343
  });
344
345
  it('accepts only structured live-session events at the reporting endpoint', async () => {
346
    const events: any[] = [];
347
    const repository = {
348
      dashboard: async () => ({}) as any,
349
      getSettings: async () => ({}) as any,
350
      saveSettings: async () => ({}) as any,
351
      upsert: async () => undefined,
352
      list: async () => [],
353
      publishAgent: async (_operatorId: string, card: any) => card,
354
      unpublishAgent: async () => true,
355
      getPublishedAgent: async () => undefined,
356
      searchPublishedAgents: async () => [],
357
      recordLiveSessionEvent: async (token: string, value: any) => {
358
        events.push({ token, value });
359
        return {
360
          recorded: true,
361
          deduplicated: false,
362
          eventId: value.eventId,
363
          attestation: {
364
            algorithm: 'Ed25519' as const,
365
            keyId: 'openclasp:test',
366
            value: 'signature',
367
            digest: 'digest',
368
          },
369
        };
370
      },
371
    };
372
    const app = buildApi(undefined, undefined, repository);
373
    await app.ready();
374
    const interactionId = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb';
375
    expect(
376
      (await app.inject({ method: 'POST', url: `/sessions/${interactionId}/events` })).statusCode,
377
    ).toBe(401);
378
    const response = await app.inject({
379
      method: 'POST',
380
      url: `/sessions/${interactionId}/events`,
381
      headers: { authorization: 'Bearer live-session-token' },
382
      payload: {
383
        eventId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
384
        interactionId,
385
        agentId: 'agent-a',
386
        sequence: 1,
387
        type: 'message_sent',
388
        occurredAt: new Date().toISOString(),
389
        messageHash: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
390
        evidenceReferences: [],
391
        details: {},
392
      },
393
    });
394
    expect(response.statusCode).toBe(200);
395
    expect(events[0]).toMatchObject({
396
      token: 'live-session-token',
397
      value: { interactionId, agentId: 'agent-a', type: 'message_sent' },
398
    });
399
    const rawMessageAttempt = await app.inject({
400
      method: 'POST',
401
      url: `/sessions/${interactionId}/events`,
402
      headers: { authorization: 'Bearer live-session-token' },
403
      payload: {
404
        eventId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc',
405
        interactionId,
406
        agentId: 'agent-a',
407
        sequence: 2,
408
        type: 'message_sent',
409
        occurredAt: new Date().toISOString(),
410
        details: { message: 'raw conversation text' },
411
      },
412
    });
413
    expect(rawMessageAttempt.statusCode).toBe(400);
414
    expect(events).toHaveLength(1);
415
    await app.close();
416
  });
417
});