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

Viewing files at commit 5ab277316767Commit detailsView latest

tests/api.test.ts 346 lines · 11.7 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
        };
42
      },
43
      getSettings: async (operatorId: string) => {
44
        calls.push(`settings:${operatorId}`);
45
        return {
46
          displayName: '',
47
          contributionEnabled: false,
48
          retentionDays: 30,
49
          evidenceSharing: 'ask' as const,
50
          rawConversationsStored: false as const,
51
        };
52
      },
53
      saveSettings: async (operatorId: string, value: any) => {
54
        calls.push(`save:${operatorId}`);
55
        return { ...value, rawConversationsStored: false as const };
56
      },
57
      upsert: async () => undefined,
58
      list: async () => [],
59
      publishAgent: async (_operatorId: string, card: any) => card,
60
      unpublishAgent: async () => true,
61
      getPublishedAgent: async () => undefined,
62
      searchPublishedAgents: async () => [],
63
      registerAgentRuntime: async (operatorId: string, agentId: string, endpoint: string) => {
64
        calls.push(`runtime:${operatorId}:${agentId}:${endpoint}`);
65
        return {
66
          agentId,
67
          endpoint,
68
          a2aEndpoint: endpoint,
69
          status: 'verified' as const,
70
          verifiedAt: new Date().toISOString(),
71
          verificationKey: 'public-key',
72
        };
73
      },
74
      disableAgentRuntime: async (operatorId: string, agentId: string) => {
75
        calls.push(`disable-runtime:${operatorId}:${agentId}`);
76
        return { agentId, status: 'disabled' as const };
77
      },
78
      deleteAgent: async (operatorId: string, agentId: string) => {
79
        calls.push(`delete-agent:${operatorId}:${agentId}`);
80
        return { agentId, deleted: true as const, historyRetained: true as const };
81
      },
82
      receiveTemporaryMessage: async (
83
        token: string,
84
        agentId: string,
85
        requestKey: string,
86
        content: string,
87
      ) => {
88
        calls.push(`temporary:${token}:${agentId}:${requestKey}:${content}`);
89
        return {
90
          message: {
91
            messageId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
92
            threadId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb',
93
            interactionId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb',
94
            senderAgentId: 'agent-peer',
95
            recipientAgentId: agentId,
96
            contentType: 'text/plain' as const,
97
            content,
98
            contentHash: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
99
            delivery: 'delivered' as const,
100
            createdAt: new Date().toISOString(),
101
          },
102
          deduplicated: false,
103
        };
104
      },
105
    };
106
    const app = buildApi(undefined, undefined, repository);
107
    await app.ready();
108
    expect((await app.inject({ method: 'GET', url: '/v0.1/dashboard' })).statusCode).toBe(401);
109
    expect(
110
      (
111
        await app.inject({
112
          method: 'GET',
113
          url: '/v0.1/dashboard',
114
          headers: { 'x-openclasp-operator': 'user-a' },
115
        })
116
      ).statusCode,
117
    ).toBe(200);
118
    expect(
119
      (
120
        await app.inject({
121
          method: 'GET',
122
          url: '/v0.1/settings',
123
          headers: { 'x-openclasp-operator': 'user-b' },
124
        })
125
      ).statusCode,
126
    ).toBe(200);
127
    expect(
128
      (
129
        await app.inject({
130
          method: 'PUT',
131
          url: '/v0.1/agents/agent-a/runtime',
132
          headers: { 'x-openclasp-operator': 'user-a' },
133
          payload: { endpoint: 'https://agent.example/openclasp' },
134
        })
135
      ).statusCode,
136
    ).toBe(200);
137
    expect(calls).toEqual([
138
      'dashboard:user-a',
139
      'settings:user-b',
140
      'runtime:user-a:agent-a:https://agent.example/openclasp',
141
    ]);
142
    expect(
143
      (
144
        await app.inject({
145
          method: 'DELETE',
146
          url: '/v0.1/agents/agent-a',
147
          headers: { 'x-openclasp-operator': 'user-a' },
148
        })
149
      ).json(),
150
    ).toMatchObject({ agentId: 'agent-a', deleted: true, historyRetained: true });
151
    expect(calls.at(-1)).toBe('delete-agent:user-a:agent-a');
152
    expect(
153
      (
154
        await app.inject({
155
          method: 'POST',
156
          url: '/a2a/temporary/agent-a',
157
          payload: {
158
            jsonrpc: '2.0',
159
            id: 'request-1',
160
            method: 'message/send',
161
            params: { message: { parts: [{ kind: 'text', text: 'Hello engineer' }] } },
162
          },
163
        })
164
      ).statusCode,
165
    ).toBe(401);
166
    const temporaryDelivery = await app.inject({
167
      method: 'POST',
168
      url: '/a2a/temporary/agent-a',
169
      headers: { authorization: 'Bearer scoped-token' },
170
      payload: {
171
        jsonrpc: '2.0',
172
        id: 'request-1',
173
        method: 'message/send',
174
        params: { message: { parts: [{ kind: 'text', text: 'Hello engineer' }] } },
175
      },
176
    });
177
    expect(temporaryDelivery.statusCode).toBe(200);
178
    expect(temporaryDelivery.json()).toMatchObject({
179
      result: {
180
        task: { id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', state: 'submitted' },
181
        privacyMode: 'openclasp_hosted_temporary',
182
      },
183
    });
184
    expect(calls.at(-1)).toBe('temporary:scoped-token:agent-a:request-1:Hello engineer');
185
    await app.close();
186
  });
187
188
  it('publishes only an owned agent public card', async () => {
189
    const published: any[] = [];
190
    const repository = {
191
      dashboard: async () => ({}) as any,
192
      getSettings: async () => ({}) as any,
193
      saveSettings: async () => ({}) as any,
194
      upsert: async () => undefined,
195
      list: async () => [
196
        {
197
          kind: 'agent_profile' as const,
198
          recordId: 'agent-one',
199
          payload: {
200
            agentId: 'agent-one',
201
            projectId: 'secret-project',
202
            name: 'Research agent',
203
            description: 'Finds primary sources',
204
            framework: 'Codex',
205
            agentVersion: '1.0.0',
206
            a2aEndpoint: 'https://agent.example/a2a',
207
            capabilities: ['research'],
208
            limitations: ['no purchases'],
209
            identityMode: 'oauth_installation' as const,
210
            status: 'active' as const,
211
            createdAt: '2026-01-01T00:00:00.000Z',
212
            updatedAt: '2026-01-01T00:00:00.000Z',
213
          },
214
        },
215
      ],
216
      publishAgent: async (_operatorId: string, card: any) => {
217
        published.push(card);
218
        return card;
219
      },
220
      unpublishAgent: async () => true,
221
      getPublishedAgent: async () => published[0],
222
      searchPublishedAgents: async () => published,
223
    };
224
    const app = buildApi(undefined, undefined, repository);
225
    await app.ready();
226
    const response = await app.inject({
227
      method: 'POST',
228
      url: '/v0.1/agents/agent-one/publication',
229
      headers: { 'x-openclasp-operator': 'owner-one' },
230
      payload: { published: true },
231
    });
232
    expect(response.statusCode).toBe(200);
233
    expect(published[0]).toMatchObject({
234
      agentId: 'agent-one',
235
      capabilities: ['research'],
236
      assurance: 'oauth_authenticated',
237
    });
238
    expect(published[0]).not.toHaveProperty('projectId');
239
    expect(published[0]).not.toHaveProperty('operatorId');
240
    const publicCard = await app.inject({
241
      method: 'GET',
242
      url: '/agents/agent-one/card.json',
243
    });
244
    expect(publicCard.statusCode).toBe(200);
245
    expect(publicCard.json()).not.toHaveProperty('projectId');
246
    const a2aCard = await app.inject({
247
      method: 'GET',
248
      url: '/agents/agent-one/a2a-agent-card.json',
249
    });
250
    expect(a2aCard.statusCode).toBe(200);
251
    expect(a2aCard.json().supportedInterfaces[0]).toMatchObject({
252
      url: 'https://agent.example/a2a',
253
      protocolVersion: '1.0',
254
    });
255
    const automation = await app.inject({
256
      method: 'PUT',
257
      url: '/v0.1/agents/agent-one/automation',
258
      headers: { 'x-openclasp-operator': 'owner-one' },
259
      payload: {
260
        a2aEndpoint: 'https://agent.example/a2a',
261
        autoPublish: true,
262
        autoAcceptPolicy: 'safe_matching',
263
        autoAcceptTaskCategories: ['research'],
264
      },
265
    });
266
    expect(automation.statusCode).toBe(200);
267
    expect(automation.json()).toMatchObject({
268
      autoPublish: true,
269
      autoAcceptPolicy: 'safe_matching',
270
    });
271
    await app.close();
272
  });
273
274
  it('accepts only structured live-session events at the reporting endpoint', async () => {
275
    const events: any[] = [];
276
    const repository = {
277
      dashboard: async () => ({}) as any,
278
      getSettings: async () => ({}) as any,
279
      saveSettings: async () => ({}) as any,
280
      upsert: async () => undefined,
281
      list: async () => [],
282
      publishAgent: async (_operatorId: string, card: any) => card,
283
      unpublishAgent: async () => true,
284
      getPublishedAgent: async () => undefined,
285
      searchPublishedAgents: async () => [],
286
      recordLiveSessionEvent: async (token: string, value: any) => {
287
        events.push({ token, value });
288
        return {
289
          recorded: true,
290
          deduplicated: false,
291
          eventId: value.eventId,
292
          attestation: {
293
            algorithm: 'Ed25519' as const,
294
            keyId: 'openclasp:test',
295
            value: 'signature',
296
            digest: 'digest',
297
          },
298
        };
299
      },
300
    };
301
    const app = buildApi(undefined, undefined, repository);
302
    await app.ready();
303
    const interactionId = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb';
304
    expect(
305
      (await app.inject({ method: 'POST', url: `/sessions/${interactionId}/events` })).statusCode,
306
    ).toBe(401);
307
    const response = await app.inject({
308
      method: 'POST',
309
      url: `/sessions/${interactionId}/events`,
310
      headers: { authorization: 'Bearer live-session-token' },
311
      payload: {
312
        eventId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
313
        interactionId,
314
        agentId: 'agent-a',
315
        sequence: 1,
316
        type: 'message_sent',
317
        occurredAt: new Date().toISOString(),
318
        messageHash: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
319
        evidenceReferences: [],
320
        details: {},
321
      },
322
    });
323
    expect(response.statusCode).toBe(200);
324
    expect(events[0]).toMatchObject({
325
      token: 'live-session-token',
326
      value: { interactionId, agentId: 'agent-a', type: 'message_sent' },
327
    });
328
    const rawMessageAttempt = await app.inject({
329
      method: 'POST',
330
      url: `/sessions/${interactionId}/events`,
331
      headers: { authorization: 'Bearer live-session-token' },
332
      payload: {
333
        eventId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc',
334
        interactionId,
335
        agentId: 'agent-a',
336
        sequence: 2,
337
        type: 'message_sent',
338
        occurredAt: new Date().toISOString(),
339
        details: { message: 'raw conversation text' },
340
      },
341
    });
342
    expect(rawMessageAttempt.statusCode).toBe(400);
343
    expect(events).toHaveLength(1);
344
    await app.close();
345
  });
346
});