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

Viewing files at commit faf8a393d637Commit detailsView latest

tests/security.test.ts 105 lines · 4.1 KB · typescript
1
import { describe, expect, it } from 'vitest';
2
import {
3
  ScopeError,
4
  assertScopes,
5
  requiredAgentApiScopes,
6
  requiredMcpRequestScopes,
7
  requiredMcpToolScope,
8
} from '../apps/api/src/access-control.js';
9
import { productionConfigurationErrors } from '../apps/api/src/production-config.js';
10
import { guardRequest } from '../apps/api/src/request-security.js';
11
import { buildApi } from '../apps/api/src/app.js';
12
import { FixedWindowRateLimiter } from '../apps/api/src/security.js';
13
import { UnavailableFactCheckProvider } from '../packages/core/src/index.js';
14
15
describe('production security boundaries', () => {
16
  it('maps agent and MCP actions to least-privilege scopes', async () => {
17
    expect(requiredMcpToolScope('openclasp_get_profile')).toBe('profile:read');
18
    expect(requiredMcpToolScope('openclasp_connect_to_agent')).toBe('interaction:write');
19
    expect(requiredMcpToolScope('openclasp_submit_interaction_feedback')).toBe('feedback:write');
20
    expect(requiredMcpToolScope('openclasp_update_profile')).toBe('agent:manage');
21
    expect(
22
      await requiredMcpRequestScopes(
23
        new Request('https://openclasp.example/mcp', {
24
          method: 'POST',
25
          body: JSON.stringify({
26
            jsonrpc: '2.0',
27
            method: 'tools/call',
28
            params: { name: 'openclasp_connect_to_agent', arguments: {} },
29
          }),
30
        }),
31
      ),
32
    ).toEqual(['mcp:access', 'interaction:write']);
33
    expect(
34
      requiredAgentApiScopes(
35
        'POST',
36
        '/v0.1/federated-interactions/11111111-1111-4111-8111-111111111111/feedback',
37
      ),
38
    ).toEqual(['feedback:write']);
39
    expect(requiredAgentApiScopes('GET', '/v0.1/dashboard')).toBeUndefined();
40
    expect(() => assertScopes(['mcp:access'], ['interaction:write'])).toThrow(ScopeError);
41
  });
42
43
  it('rate limits and rejects oversized function requests', async () => {
44
    const limiter = new FixedWindowRateLimiter();
45
    expect(limiter.consume('operator:one', 1, 60_000, 1).allowed).toBe(true);
46
    expect(limiter.consume('operator:one', 1, 60_000, 2).allowed).toBe(false);
47
    const request = new Request('https://openclasp.example/oauth/token', {
48
      method: 'POST',
49
      headers: { 'x-real-ip': '192.0.2.44' },
50
      body: 'x'.repeat(33),
51
    });
52
    expect(
53
      await guardRequest(request, 'security-test-payload', { limit: 10, maximumBytes: 32 }),
54
    ).toMatchObject({ status: 413 });
55
  });
56
57
  it('fails closed on incomplete production configuration', () => {
58
    const errors = productionConfigurationErrors('api', {
59
      NODE_ENV: 'production',
60
      OPENCLASP_PUBLIC_URL: 'http://openclasp.example',
61
      OPENCLASP_RELAY_ENCRYPTION_KEY: 'short',
62
    });
63
    expect(errors).toContain('DATABASE_URL is required');
64
    expect(errors).toContain('AUTH0_DOMAIN is required');
65
    expect(errors).toContain('OPENCLASP_PUBLIC_URL must use HTTPS');
66
    expect(errors).toContain('OPENCLASP_RELAY_ENCRYPTION_KEY must contain at least 32 characters');
67
  });
68
69
  it('requires the unforgeable internal auth boundary for hosted account data', async () => {
70
    const repository = {
71
      dashboard: async (operatorId: string) => ({ operatorId }),
72
    } as any;
73
    const app = buildApi(undefined, undefined, repository, {
74
      internalAuthSecret: 'internal-secret',
75
    });
76
    await app.ready();
77
    const spoofed = await app.inject({
78
      method: 'GET',
79
      url: '/v0.1/dashboard',
80
      headers: { 'x-openclasp-operator': 'victim' },
81
    });
82
    expect(spoofed.statusCode).toBe(401);
83
    const trusted = await app.inject({
84
      method: 'GET',
85
      url: '/v0.1/dashboard',
86
      headers: {
87
        'x-openclasp-operator': 'owner',
88
        'x-openclasp-internal-auth': 'internal-secret',
89
      },
90
    });
91
    expect(trusted.statusCode).toBe(200);
92
    expect(trusted.json()).toEqual({ operatorId: 'owner' });
93
    await app.close();
94
  });
95
96
  it('returns unknown instead of fixture-backed claims when no provider is configured', async () => {
97
    await expect(
98
      new UnavailableFactCheckProvider().check('The moon is cheese'),
99
    ).resolves.toMatchObject({
100
      status: 'unverified',
101
      confidence: 0,
102
      evidenceReferences: [],
103
    });
104
  });
105
});