diff --git a/src/server/computer-routes.ts b/src/server/computer-routes.ts index a7d6824..aa94bd4 100644 --- a/src/server/computer-routes.ts +++ b/src/server/computer-routes.ts @@ -4,17 +4,27 @@ import type { ComputerService } from './computer-service.js'; import type { ComputerAction } from '../shared/computer-types.js'; export function computerRoutes(computers: ComputerService) { const app = new Hono(); - app.onError((error, c) => - c.json( - { - error: - error instanceof z.ZodError - ? 'Invalid computer request.' - : error.message, - }, - 400, - ), - ); + app.onError((error, c) => { + if (error instanceof SyntaxError) + return c.json({ error: 'Invalid JSON request.' }, 400); + if (error instanceof z.ZodError) + return c.json({ error: 'Invalid computer request.' }, 400); + const text = error.message; + if (text === 'Dot not found.') return c.json({ error: text }, 404); + if ( + text === 'Computer permission is disabled.' || + text === 'Human controls are owner-only.' + ) + return c.json({ error: text }, 403); + if ( + text === 'Start this Dot’s computer first.' || + text === 'There is no active control request.' + ) + return c.json({ error: text }, 409); + if (text === 'Unknown computer action.') + return c.json({ error: text }, 400); + return c.json({ error: text }, 503); + }); app.get('/dots/:id/computer', async (c) => c.json(await computers.status(c.req.param('id'))), ); diff --git a/tests/computer-routes.test.ts b/tests/computer-routes.test.ts new file mode 100644 index 0000000..2a1e84f --- /dev/null +++ b/tests/computer-routes.test.ts @@ -0,0 +1,121 @@ +import { afterEach, expect, it } from 'vitest'; +import { WorkspaceStore } from '../src/server/workspace.js'; +import { ComputerService } from '../src/server/computer-service.js'; +import { computerRoutes } from '../src/server/computer-routes.js'; + +const stores: WorkspaceStore[] = []; +afterEach(() => { + for (const store of stores.splice(0)) store.close(); +}); + +function fixture() { + const workspace = new WorkspaceStore(':memory:', 'owner'); + stores.push(workspace); + const id = workspace.dots()[0].id; + const config = { + baseUrl: 'https://example.com', + voiceName: 'voice', + slackUsers: [], + runtimeUrl: 'http://localhost', + }; + // No computer supervisor configured: `configured` is false, so any + // action that reaches `allowed()` fails with a plain domain error + // rather than attempting a network call. + const service = new ComputerService(workspace, config, () => false); + const app = computerRoutes(service); + return { workspace, id, app }; +} + +// `configured: true`, so `allowed()` reaches its permission check instead of +// failing earlier with "Computer service is not configured."; `transport` is +// never called because `allowed()` throws before any network request. +function configuredFixture() { + const workspace = new WorkspaceStore(':memory:', 'owner'); + stores.push(workspace); + const id = workspace.dots()[0].id; + const config = { + baseUrl: 'https://example.com', + voiceName: 'voice', + slackUsers: [], + runtimeUrl: 'http://localhost', + computerSupervisorUrl: 'https://supervisor.example.com', + computerSupervisorToken: 'supervisor-token', + computerToken: 'computer-token', + }; + const transport = () => { + throw new Error('transport should not be called in this test'); + }; + const service = new ComputerService( + workspace, + config, + () => false, + transport, + ); + const app = computerRoutes(service); + return { workspace, id, app }; +} + +it('answers malformed JSON on the actions route with 400, not a service error', async () => { + const { app, id } = fixture(); + const response = await app.request(`/dots/${id}/computer/actions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: '{', + }); + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ error: 'Invalid JSON request.' }); +}); + +it('answers an invalid action shape with 400 from the Zod schema', async () => { + const { app, id } = fixture(); + const response = await app.request(`/dots/${id}/computer/actions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ input: {} }), + }); + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ error: 'Invalid computer request.' }); +}); + +it('answers an unconfigured computer service with 503, not 400', async () => { + const { app, id } = fixture(); + const response = await app.request(`/dots/${id}/computer/start`, { + method: 'POST', + }); + expect(response.status).toBe(503); + expect(await response.json()).toEqual({ + error: 'Computer service is not configured.', + }); +}); + +it('answers an unknown Dot id with 404, not a service error', async () => { + const { app } = fixture(); + const response = await app.request('/dots/missing/computer/start', { + method: 'POST', + }); + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ error: 'Dot not found.' }); +}); + +it('answers an unknown computer action with 400, not a service error', async () => { + const { app, id } = fixture(); + const response = await app.request(`/dots/${id}/computer/actions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'not_a_real_action', input: {} }), + }); + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ error: 'Unknown computer action.' }); +}); + +it('answers a disabled computer permission with 403, not a service error', async () => { + const { app, id, workspace } = configuredFixture(); + workspace.computers.patch(id, { enabled: false }); + const response = await app.request(`/dots/${id}/computer/take`, { + method: 'POST', + }); + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ + error: 'Computer permission is disabled.', + }); +});