| 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(requiredMcpToolScope('openclasp_list_assurance_probes')).toBe('profile:read');
|
| 22 | expect(requiredMcpToolScope('openclasp_generate_assurance_probe')).toBe('interaction:write');
|
| 23 | expect(requiredMcpToolScope('openclasp_get_assurance_brief')).toBe('profile:read');
|
| 24 | expect(requiredMcpToolScope('openclasp_decide_assurance_safeguard')).toBe('interaction:write');
|
| 25 | expect(
|
| 26 | await requiredMcpRequestScopes(
|
| 27 | new Request('https://openclasp.example/mcp', {
|
| 28 | method: 'POST',
|
| 29 | body: JSON.stringify({
|
| 30 | jsonrpc: '2.0',
|
| 31 | method: 'tools/call',
|
| 32 | params: { name: 'openclasp_connect_to_agent', arguments: {} },
|
| 33 | }),
|
| 34 | }),
|
| 35 | ),
|
| 36 | ).toEqual(['mcp:access', 'interaction:write']);
|
| 37 | expect(
|
| 38 | requiredAgentApiScopes(
|
| 39 | 'POST',
|
| 40 | '/v0.1/federated-interactions/11111111-1111-4111-8111-111111111111/feedback',
|
| 41 | ),
|
| 42 | ).toEqual(['feedback:write']);
|
| 43 | expect(requiredAgentApiScopes('GET', '/v0.1/dashboard')).toBeUndefined();
|
| 44 | expect(requiredAgentApiScopes('POST', '/v0.1/agents/agent-a/shield-tokens')).toBeUndefined();
|
| 45 | expect(
|
| 46 | requiredAgentApiScopes('GET', '/v0.1/federated-interactions/interaction-id/assurance-probes'),
|
| 47 | ).toEqual(['profile:read']);
|
| 48 | expect(
|
| 49 | requiredAgentApiScopes(
|
| 50 | 'POST',
|
| 51 | '/v0.1/federated-interactions/interaction-id/assurance-responses',
|
| 52 | ),
|
| 53 | ).toEqual(['interaction:write']);
|
| 54 | expect(
|
| 55 | requiredAgentApiScopes('GET', '/v0.1/federated-interactions/interaction-id/assurance-brief'),
|
| 56 | ).toEqual(['profile:read']);
|
| 57 | expect(
|
| 58 | requiredAgentApiScopes(
|
| 59 | 'POST',
|
| 60 | '/v0.1/federated-interactions/interaction-id/assurance-safeguards/id/decision',
|
| 61 | ),
|
| 62 | ).toEqual(['interaction:write']);
|
| 63 | expect(() => assertScopes(['mcp:access'], ['interaction:write'])).toThrow(ScopeError);
|
| 64 | });
|
| 65 |
|
| 66 | it('rate limits and rejects oversized function requests', async () => {
|
| 67 | const limiter = new FixedWindowRateLimiter();
|
| 68 | expect(limiter.consume('operator:one', 1, 60_000, 1).allowed).toBe(true);
|
| 69 | expect(limiter.consume('operator:one', 1, 60_000, 2).allowed).toBe(false);
|
| 70 | const request = new Request('https://openclasp.example/oauth/token', {
|
| 71 | method: 'POST',
|
| 72 | headers: { 'x-real-ip': '192.0.2.44' },
|
| 73 | body: 'x'.repeat(33),
|
| 74 | });
|
| 75 | expect(
|
| 76 | await guardRequest(request, 'security-test-payload', { limit: 10, maximumBytes: 32 }),
|
| 77 | ).toMatchObject({ status: 413 });
|
| 78 | });
|
| 79 |
|
| 80 | it('fails closed on incomplete production configuration', () => {
|
| 81 | const errors = productionConfigurationErrors('api', {
|
| 82 | NODE_ENV: 'production',
|
| 83 | OPENCLASP_PUBLIC_URL: 'http://openclasp.example',
|
| 84 | OPENCLASP_RELAY_ENCRYPTION_KEY: 'short',
|
| 85 | });
|
| 86 | expect(errors).toContain('DATABASE_URL is required');
|
| 87 | expect(errors).toContain('AUTH0_DOMAIN is required');
|
| 88 | expect(errors).toContain('OPENCLASP_PUBLIC_URL must use HTTPS');
|
| 89 | expect(errors).toContain('OPENCLASP_RELAY_ENCRYPTION_KEY must contain at least 32 characters');
|
| 90 | });
|
| 91 |
|
| 92 | it('requires the unforgeable internal auth boundary for hosted account data', async () => {
|
| 93 | const repository = {
|
| 94 | dashboard: async (operatorId: string) => ({ operatorId }),
|
| 95 | } as any;
|
| 96 | const app = buildApi(undefined, undefined, repository, {
|
| 97 | internalAuthSecret: 'internal-secret',
|
| 98 | });
|
| 99 | await app.ready();
|
| 100 | const spoofed = await app.inject({
|
| 101 | method: 'GET',
|
| 102 | url: '/v0.1/dashboard',
|
| 103 | headers: { 'x-openclasp-operator': 'victim' },
|
| 104 | });
|
| 105 | expect(spoofed.statusCode).toBe(401);
|
| 106 | const trusted = await app.inject({
|
| 107 | method: 'GET',
|
| 108 | url: '/v0.1/dashboard',
|
| 109 | headers: {
|
| 110 | 'x-openclasp-operator': 'owner',
|
| 111 | 'x-openclasp-internal-auth': 'internal-secret',
|
| 112 | },
|
| 113 | });
|
| 114 | expect(trusted.statusCode).toBe(200);
|
| 115 | expect(trusted.json()).toEqual({ operatorId: 'owner' });
|
| 116 | await app.close();
|
| 117 | });
|
| 118 |
|
| 119 | it('returns unknown instead of fixture-backed claims when no provider is configured', async () => {
|
| 120 | await expect(
|
| 121 | new UnavailableFactCheckProvider().check('The moon is cheese'),
|
| 122 | ).resolves.toMatchObject({
|
| 123 | status: 'unverified',
|
| 124 | confidence: 0,
|
| 125 | evidenceReferences: [],
|
| 126 | });
|
| 127 | });
|
| 128 | });
|