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

Viewing files at commit 989559e001cdCommit detailsView latest

tests/api.test.ts 257 lines · 8.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: true });
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
          events: [],
35
          conflicts: [],
36
          receipts: [],
37
          profiles: [],
38
          runtimes: [],
39
        };
40
      },
41
      getSettings: async (operatorId: string) => {
42
        calls.push(`settings:${operatorId}`);
43
        return {
44
          displayName: '',
45
          contributionEnabled: false,
46
          retentionDays: 30,
47
          evidenceSharing: 'ask' as const,
48
          rawConversationsStored: true as const,
49
        };
50
      },
51
      saveSettings: async (operatorId: string, value: any) => {
52
        calls.push(`save:${operatorId}`);
53
        return { ...value, rawConversationsStored: true as const };
54
      },
55
      upsert: async () => undefined,
56
      list: async () => [],
57
      publishAgent: async (_operatorId: string, card: any) => card,
58
      unpublishAgent: async () => true,
59
      getPublishedAgent: async () => undefined,
60
      searchPublishedAgents: async () => [],
61
      registerAgentRuntime: async (operatorId: string, agentId: string, endpoint: string) => {
62
        calls.push(`runtime:${operatorId}:${agentId}:${endpoint}`);
63
        return {
64
          agentId,
65
          endpoint,
66
          status: 'verified' as const,
67
          verifiedAt: new Date().toISOString(),
68
          signingSecret: 'shown-once',
69
          secretShownOnce: true,
70
          queuedMessages: 0,
71
          queueFailures: 0,
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
    };
79
    const app = buildApi(undefined, undefined, repository);
80
    await app.ready();
81
    expect((await app.inject({ method: 'GET', url: '/v0.1/dashboard' })).statusCode).toBe(401);
82
    expect(
83
      (
84
        await app.inject({
85
          method: 'GET',
86
          url: '/v0.1/dashboard',
87
          headers: { 'x-openclasp-operator': 'user-a' },
88
        })
89
      ).statusCode,
90
    ).toBe(200);
91
    expect(
92
      (
93
        await app.inject({
94
          method: 'GET',
95
          url: '/v0.1/settings',
96
          headers: { 'x-openclasp-operator': 'user-b' },
97
        })
98
      ).statusCode,
99
    ).toBe(200);
100
    expect(
101
      (
102
        await app.inject({
103
          method: 'PUT',
104
          url: '/v0.1/agents/agent-a/runtime',
105
          headers: { 'x-openclasp-operator': 'user-a' },
106
          payload: { endpoint: 'https://agent.example/openclasp' },
107
        })
108
      ).statusCode,
109
    ).toBe(200);
110
    expect(calls).toEqual([
111
      'dashboard:user-a',
112
      'settings:user-b',
113
      'runtime:user-a:agent-a:https://agent.example/openclasp',
114
    ]);
115
    await app.close();
116
  });
117
118
  it('publishes only an owned agent public card', async () => {
119
    const published: any[] = [];
120
    const repository = {
121
      dashboard: async () => ({}) as any,
122
      getSettings: async () => ({}) as any,
123
      saveSettings: async () => ({}) as any,
124
      upsert: async () => undefined,
125
      list: async () => [
126
        {
127
          kind: 'agent_profile' as const,
128
          recordId: 'agent-one',
129
          payload: {
130
            agentId: 'agent-one',
131
            projectId: 'secret-project',
132
            name: 'Research agent',
133
            description: 'Finds primary sources',
134
            framework: 'Codex',
135
            agentVersion: '1.0.0',
136
            a2aEndpoint: 'https://agent.example/a2a',
137
            capabilities: ['research'],
138
            limitations: ['no purchases'],
139
            identityMode: 'oauth_installation' as const,
140
            status: 'active' as const,
141
            createdAt: '2026-01-01T00:00:00.000Z',
142
            updatedAt: '2026-01-01T00:00:00.000Z',
143
          },
144
        },
145
      ],
146
      publishAgent: async (_operatorId: string, card: any) => {
147
        published.push(card);
148
        return card;
149
      },
150
      unpublishAgent: async () => true,
151
      getPublishedAgent: async () => published[0],
152
      searchPublishedAgents: async () => published,
153
    };
154
    const app = buildApi(undefined, undefined, repository);
155
    await app.ready();
156
    const response = await app.inject({
157
      method: 'POST',
158
      url: '/v0.1/agents/agent-one/publication',
159
      headers: { 'x-openclasp-operator': 'owner-one' },
160
      payload: { published: true },
161
    });
162
    expect(response.statusCode).toBe(200);
163
    expect(published[0]).toMatchObject({
164
      agentId: 'agent-one',
165
      capabilities: ['research'],
166
      assurance: 'oauth_authenticated',
167
    });
168
    expect(published[0]).not.toHaveProperty('projectId');
169
    expect(published[0]).not.toHaveProperty('operatorId');
170
    const publicCard = await app.inject({
171
      method: 'GET',
172
      url: '/agents/agent-one/card.json',
173
    });
174
    expect(publicCard.statusCode).toBe(200);
175
    expect(publicCard.json()).not.toHaveProperty('projectId');
176
    const a2aCard = await app.inject({
177
      method: 'GET',
178
      url: '/agents/agent-one/a2a-agent-card.json',
179
    });
180
    expect(a2aCard.statusCode).toBe(200);
181
    expect(a2aCard.json().supportedInterfaces[0]).toMatchObject({
182
      url: 'https://localhost:80/a2a/agent-one',
183
      protocolVersion: '1.0',
184
    });
185
    const automation = await app.inject({
186
      method: 'PUT',
187
      url: '/v0.1/agents/agent-one/automation',
188
      headers: { 'x-openclasp-operator': 'owner-one' },
189
      payload: {
190
        a2aEndpoint: 'https://agent.example/a2a',
191
        autoPublish: true,
192
        autoAcceptPolicy: 'safe_matching',
193
        autoAcceptTaskCategories: ['research'],
194
      },
195
    });
196
    expect(automation.statusCode).toBe(200);
197
    expect(automation.json()).toMatchObject({
198
      autoPublish: true,
199
      autoAcceptPolicy: 'safe_matching',
200
    });
201
    await app.close();
202
  });
203
204
  it('accepts scoped A2A JSON-RPC delivery at the hosted endpoint', async () => {
205
    const queued: any[] = [];
206
    const repository = {
207
      dashboard: async () => ({}) as any,
208
      getSettings: async () => ({}) as any,
209
      saveSettings: async () => ({}) as any,
210
      upsert: async () => undefined,
211
      list: async () => [],
212
      publishAgent: async (_operatorId: string, card: any) => card,
213
      unpublishAgent: async () => true,
214
      getPublishedAgent: async () => undefined,
215
      searchPublishedAgents: async () => [],
216
      verifyGatewayToken: () => ({
217
        interactionId: 'interaction-1',
218
        senderAgentId: 'agent-a',
219
        recipientAgentId: 'agent-b',
220
        expiresAt: Date.now() + 60_000,
221
      }),
222
      enqueueGatewayMessage: async (value: any) => {
223
        queued.push(value);
224
        return {
225
          accepted: true,
226
          deduplicated: false,
227
          messageId: 'message-1',
228
          runtimeDispatchQueued: false,
229
          expiresAt: new Date(Date.now() + 60_000).toISOString(),
230
        };
231
      },
232
    };
233
    const app = buildApi(undefined, undefined, repository);
234
    await app.ready();
235
    expect((await app.inject({ method: 'POST', url: '/a2a/agent-b' })).statusCode).toBe(401);
236
    const response = await app.inject({
237
      method: 'POST',
238
      url: '/a2a/agent-b',
239
      headers: { authorization: 'Bearer scoped-token' },
240
      payload: {
241
        jsonrpc: '2.0',
242
        id: 'request-1',
243
        method: 'message/send',
244
        params: { message: { role: 'user', parts: [{ kind: 'text', text: 'hello' }] } },
245
      },
246
    });
247
    expect(response.statusCode).toBe(200);
248
    expect(response.json()).toMatchObject({ jsonrpc: '2.0', id: 'request-1' });
249
    expect(queued[0]).toMatchObject({
250
      interactionId: 'interaction-1',
251
      senderAgentId: 'agent-a',
252
      recipientAgentId: 'agent-b',
253
      idempotencyKey: 'a2a:interaction-1:request-1',
254
    });
255
    await app.close();
256
  });
257
});