/** * ZCLAW API Integration Tests * * Uses the mock server to test Hands, Workflows, Security, and Audit APIs * via REST endpoints. Does not require WebSocket handshake. */ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { createZclawMockServer, MockServerInstance } from '../../fixtures/zclaw-mock-server'; describe('ZCLAW API Integration', () => { let server: MockServerInstance; let baseUrl: string; const testPort = 14200; beforeAll(async () => { server = createZclawMockServer({ port: testPort }); await server.start(); baseUrl = server.getHttpUrl(); }); afterAll(async () => { await server.stop(); }); // Helper function for REST calls async function restGet(path: string): Promise { return fetch(`${baseUrl}${path}`); } async function restPost(path: string, body?: unknown): Promise { return fetch(`${baseUrl}${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: body ? JSON.stringify(body) : undefined, }); } // === Hands API Tests === describe('Hands API', () => { it('GET /api/hands returns hands list', async () => { const response = await restGet('/api/hands'); expect(response.ok).toBe(true); const result = await response.json(); expect(result).toBeDefined(); expect(result.hands).toBeDefined(); expect(Array.isArray(result.hands)).toBe(true); expect(result.hands.length).toBeGreaterThan(0); // Verify default hands are present const handNames = result.hands.map((h: { name: string }) => h.name); expect(handNames).toContain('clip'); expect(handNames).toContain('lead'); expect(handNames).toContain('collector'); expect(handNames).toContain('predictor'); expect(handNames).toContain('researcher'); expect(handNames).toContain('twitter'); expect(handNames).toContain('browser'); }); it('GET /api/hands/:name returns hand details', async () => { const response = await restGet('/api/hands/clip'); expect(response.ok).toBe(true); const result = await response.json(); expect(result.name).toBe('clip'); expect(result.description).toBeDefined(); }); it('POST /api/hands/:name/trigger triggers a hand', async () => { const response = await restPost('/api/hands/clip/trigger'); expect(response.ok).toBe(true); const result = await response.json(); expect(result.runId).toBeDefined(); expect(result.status).toBeDefined(); expect(typeof result.runId).toBe('string'); expect(result.runId).toMatch(/^run-clip-\d+$/); }); it('POST /api/hands/:name/trigger with params passes parameters', async () => { const params = { target: 'video.mp4', quality: 'high' }; const response = await restPost('/api/hands/clip/trigger', params); expect(response.ok).toBe(true); const result = await response.json(); expect(result.runId).toBeDefined(); }); it('POST /api/hands/lead/trigger returns needs_approval status', async () => { // 'lead' hand requires approval in mock server const response = await restPost('/api/hands/lead/trigger'); expect(response.ok).toBe(true); const result = await response.json(); expect(result.status).toBe('needs_approval'); }); it('POST /api/hands/:name/runs/:runId/approve approves a hand execution', async () => { // First trigger a hand that needs approval const triggerResponse = await restPost('/api/hands/lead/trigger'); const triggerResult = await triggerResponse.json(); expect(triggerResult.status).toBe('needs_approval'); // Approve the hand const approveResponse = await restPost( `/api/hands/lead/runs/${triggerResult.runId}/approve`, { approved: true } ); expect(approveResponse.ok).toBe(true); const approveResult = await approveResponse.json(); expect(approveResult.status).toBe('running'); }); it('POST /api/hands/:name/runs/:runId/approve rejects a hand execution', async () => { // First trigger a hand that needs approval const triggerResponse = await restPost('/api/hands/lead/trigger'); const triggerResult = await triggerResponse.json(); // Reject the hand const rejectResponse = await restPost( `/api/hands/lead/runs/${triggerResult.runId}/approve`, { approved: false, reason: 'Not authorized' } ); expect(rejectResponse.ok).toBe(true); const rejectResult = await rejectResponse.json(); expect(rejectResult.status).toBe('cancelled'); }); it('POST /api/hands/:name/runs/:runId/cancel cancels a running hand', async () => { // First trigger a hand const triggerResponse = await restPost('/api/hands/collector/trigger'); const triggerResult = await triggerResponse.json(); // Cancel it const cancelResponse = await restPost( `/api/hands/collector/runs/${triggerResult.runId}/cancel` ); expect(cancelResponse.ok).toBe(true); const cancelResult = await cancelResponse.json(); expect(cancelResult.status).toBe('cancelled'); }); it('GET /api/hands/:name/runs returns execution history', async () => { // Trigger a few hands first await restPost('/api/hands/clip/trigger'); await restPost('/api/hands/collector/trigger'); const response = await restGet('/api/hands/clip/runs'); expect(response.ok).toBe(true); const result = await response.json(); expect(result.runs).toBeDefined(); expect(Array.isArray(result.runs)).toBe(true); expect(result.runs.length).toBeGreaterThan(0); const clipRun = result.runs.find((r: { runId: string }) => r.runId.includes('clip')); expect(clipRun).toBeDefined(); expect(clipRun.status).toBeDefined(); expect(clipRun.startedAt).toBeDefined(); }); it('GET /api/hands/:name returns 404 for non-existent hand', async () => { const response = await restGet('/api/hands/nonexistent-hand'); expect(response.status).toBe(404); }); it('POST /api/hands/:name/trigger returns 404 for non-existent hand', async () => { const response = await restPost('/api/hands/nonexistent-hand/trigger'); expect(response.status).toBe(404); }); }); // === Workflows API Tests === describe('Workflows API', () => { it('GET /api/workflows returns workflows list', async () => { const response = await restGet('/api/workflows'); expect(response.ok).toBe(true); const result = await response.json(); expect(result.workflows).toBeDefined(); expect(Array.isArray(result.workflows)).toBe(true); expect(result.workflows.length).toBeGreaterThan(0); // Verify default workflows are present const workflowIds = result.workflows.map((w: { id: string }) => w.id); expect(workflowIds).toContain('wf-001'); expect(workflowIds).toContain('wf-002'); expect(workflowIds).toContain('wf-003'); }); it('GET /api/workflows/:id returns workflow details', async () => { const response = await restGet('/api/workflows/wf-001'); expect(response.ok).toBe(true); const result = await response.json(); expect(result.id).toBe('wf-001'); expect(result.name).toBeDefined(); expect(result.steps).toBeDefined(); expect(Array.isArray(result.steps)).toBe(true); }); it('POST /api/workflows/:id/execute starts a workflow', async () => { const input = { topic: 'Test workflow' }; const response = await restPost('/api/workflows/wf-001/execute', input); expect(response.ok).toBe(true); const result = await response.json(); expect(result.runId).toBeDefined(); expect(result.status).toBeDefined(); expect(result.status).toBe('running'); expect(typeof result.runId).toBe('string'); }); it('GET /api/workflows/:id/runs/:runId returns execution status', async () => { // First execute a workflow const executeResponse = await restPost('/api/workflows/wf-001/execute', { data: 'test' }); const executeResult = await executeResponse.json(); // Get the run status const statusResponse = await restGet( `/api/workflows/wf-001/runs/${executeResult.runId}` ); expect(statusResponse.ok).toBe(true); const runStatus = await statusResponse.json(); expect(runStatus.status).toBeDefined(); }); it('POST /api/workflows/:id/runs/:runId/cancel cancels a workflow', async () => { // First execute a workflow const executeResponse = await restPost('/api/workflows/wf-002/execute', {}); const executeResult = await executeResponse.json(); // Cancel it const cancelResponse = await restPost( `/api/workflows/wf-002/runs/${executeResult.runId}/cancel` ); expect(cancelResponse.ok).toBe(true); const cancelResult = await cancelResponse.json(); expect(cancelResult.status).toBe('cancelled'); }); it('GET /api/workflows/:id returns 404 for non-existent workflow', async () => { const response = await restGet('/api/workflows/nonexistent-wf'); expect(response.status).toBe(404); }); it('POST /api/workflows/:id/execute returns 404 for non-existent workflow', async () => { const response = await restPost('/api/workflows/nonexistent-wf/execute', {}); expect(response.status).toBe(404); }); }); // === Security API Tests === describe('Security API', () => { it('GET /api/security/status returns security layers', async () => { const response = await restGet('/api/security/status'); expect(response.ok).toBe(true); const result = await response.json(); expect(result.layers).toBeDefined(); expect(Array.isArray(result.layers)).toBe(true); expect(result.layers.length).toBe(16); // Verify layer structure const layer = result.layers[0]; expect(layer.name).toBeDefined(); expect(typeof layer.enabled).toBe('boolean'); }); it('securityLevel is calculated correctly for critical level', async () => { const response = await restGet('/api/security/status'); const result = await response.json(); // Default config has 15 enabled layers out of 16 // This should be 'critical' level (14/16 = 87.5% >= 87.5%) const enabledCount = result.layers.filter( (l: { enabled: boolean }) => l.enabled ).length; expect(enabledCount).toBeGreaterThanOrEqual(14); expect(result.securityLevel).toBe('critical'); }); it('securityLevel is calculated correctly with custom layers', async () => { // Set custom security layers with only 10 enabled server.setSecurityLayers([ { name: 'layer1', enabled: true }, { name: 'layer2', enabled: true }, { name: 'layer3', enabled: true }, { name: 'layer4', enabled: true }, { name: 'layer5', enabled: true }, { name: 'layer6', enabled: true }, { name: 'layer7', enabled: true }, { name: 'layer8', enabled: true }, { name: 'layer9', enabled: true }, { name: 'layer10', enabled: true }, { name: 'layer11', enabled: false }, { name: 'layer12', enabled: false }, { name: 'layer13', enabled: false }, { name: 'layer14', enabled: false }, { name: 'layer15', enabled: false }, { name: 'layer16', enabled: false }, ]); const response = await restGet('/api/security/status'); const result = await response.json(); expect(result.layers).toBeDefined(); const enabledCount = result.layers.filter( (l: { enabled: boolean }) => l.enabled ).length; expect(enabledCount).toBe(10); expect(result.securityLevel).toBe('high'); // 10/16 = 62.5% }); it('GET /api/capabilities returns capabilities list', async () => { const response = await restGet('/api/capabilities'); expect(response.ok).toBe(true); const result = await response.json(); expect(result.capabilities).toBeDefined(); expect(Array.isArray(result.capabilities)).toBe(true); expect(result.capabilities.length).toBeGreaterThan(0); // Verify expected capabilities expect(result.capabilities).toContain('operator.read'); expect(result.capabilities).toContain('operator.write'); expect(result.capabilities).toContain('operator.admin'); expect(result.capabilities).toContain('operator.approvals'); expect(result.capabilities).toContain('operator.pairing'); }); }); // === Audit Logs API Tests === describe('Audit Logs API', () => { it('GET /api/audit/logs returns paginated logs', async () => { const response = await restGet('/api/audit/logs'); expect(response.ok).toBe(true); const result = await response.json(); expect(result.logs).toBeDefined(); expect(Array.isArray(result.logs)).toBe(true); }); it('GET /api/audit/logs respects limit parameter', async () => { // First add some audit logs server.addAuditLog({ action: 'test.action', actor: 'test-user', result: 'success' }); server.addAuditLog({ action: 'test.action2', actor: 'test-user2', result: 'success' }); server.addAuditLog({ action: 'test.action3', actor: 'test-user3', result: 'failure' }); const response = await restGet('/api/audit/logs?limit=2'); expect(response.ok).toBe(true); const result = await response.json(); expect(result.logs.length).toBeLessThanOrEqual(2); }); it('GET /api/audit/logs respects offset parameter', async () => { // Reset server state and add multiple logs server.reset(); server.addAuditLog({ action: 'action.1', actor: 'user1', result: 'success' }); server.addAuditLog({ action: 'action.2', actor: 'user2', result: 'success' }); server.addAuditLog({ action: 'action.3', actor: 'user3', result: 'success' }); const firstPageResponse = await restGet('/api/audit/logs?limit=1&offset=0'); const secondPageResponse = await restGet('/api/audit/logs?limit=1&offset=1'); expect(firstPageResponse.ok).toBe(true); expect(secondPageResponse.ok).toBe(true); const firstPage = await firstPageResponse.json(); const secondPage = await secondPageResponse.json(); expect(firstPage.logs.length).toBe(1); expect(secondPage.logs.length).toBe(1); }); it('audit logs contain required fields', async () => { server.addAuditLog({ action: 'hand.trigger', actor: 'operator', result: 'success', details: { handName: 'clip', runId: 'run-123' }, }); const response = await restGet('/api/audit/logs'); const result = await response.json(); const log = result.logs.find( (l: { action: string }) => l.action === 'hand.trigger' ); expect(log).toBeDefined(); expect(log?.id).toBeDefined(); expect(log?.timestamp).toBeDefined(); expect(log?.action).toBe('hand.trigger'); expect(log?.actor).toBe('operator'); expect(log?.result).toBe('success'); expect(log?.details).toBeDefined(); }); }); // === Error Handling Tests === describe('Error Handling', () => { it('returns 404 for unknown routes', async () => { const response = await restGet('/api/unknown/endpoint'); expect(response.status).toBe(404); }); }); // === Agents API Tests === describe('Agents API', () => { it('GET /api/agents returns agents list', async () => { const response = await restGet('/api/agents'); expect(response.ok).toBe(true); const result = await response.json(); expect(result.clones).toBeDefined(); expect(Array.isArray(result.clones)).toBe(true); }); it('POST /api/agents creates a new agent', async () => { const response = await restPost('/api/agents', { name: 'Test Agent', role: 'assistant', model: 'gpt-4', }); expect(response.ok).toBe(true); const result = await response.json(); expect(result.clone).toBeDefined(); expect(result.clone.name).toBe('Test Agent'); }); }); // === Chat API Tests === describe('Chat API', () => { it('POST /api/chat initiates a chat session', async () => { const response = await restPost('/api/chat', { message: 'Hello, world!', agent_id: 'agent-001', }); expect(response.ok).toBe(true); const result = await response.json(); expect(result.runId).toBeDefined(); expect(result.sessionId).toBeDefined(); }); }); // === Models API Tests === describe('Models API', () => { it('GET /api/models returns available models', async () => { const response = await restGet('/api/models'); expect(response.ok).toBe(true); const result = await response.json(); expect(result.models).toBeDefined(); expect(Array.isArray(result.models)).toBe(true); expect(result.models.length).toBeGreaterThan(0); const model = result.models[0]; expect(model.id).toBeDefined(); expect(model.name).toBeDefined(); expect(model.provider).toBeDefined(); }); }); // === Config API Tests === describe('Config API', () => { it('GET /api/config returns configuration', async () => { const response = await restGet('/api/config'); expect(response.ok).toBe(true); const result = await response.json(); expect(result.server).toBeDefined(); expect(result.agent).toBeDefined(); }); it('GET /api/config/quick returns quick config', async () => { const response = await restGet('/api/config/quick'); expect(response.ok).toBe(true); const result = await response.json(); expect(result.quickConfig).toBeDefined(); }); }); // === Triggers API Tests === describe('Triggers API', () => { it('GET /api/triggers returns triggers list', async () => { const response = await restGet('/api/triggers'); expect(response.ok).toBe(true); const result = await response.json(); expect(result.triggers).toBeDefined(); expect(Array.isArray(result.triggers)).toBe(true); const trigger = result.triggers[0]; expect(trigger.id).toBeDefined(); expect(trigger.type).toBeDefined(); expect(typeof trigger.enabled).toBe('boolean'); }); }); });