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

Viewing files at commit 296bb55574a2Commit detailsView latest

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