/** * @sh-mcp/tasks — unit tests * * All tests use: * - A mock AuthContext (no real JWT; the auth layer is DEFERRED — see TODO * in src/client.ts). requireScope() from @sh-mcp/shared is exercised as * the live implementation so that scope-guard behaviour is tested. * - A mock TasksClient (no DynamoDB, no AWS credentials, no network). * * Coverage targets: * - Happy path for each of the four tools. * - Empty result from listTasks. * - Client error propagation (the tool must not swallow errors). * - Throttle / transient-error retry surface (the tool propagates the error * upward; retry policy lives at the transport layer, not in tool handlers). * - Scope guard: a caller without ops:tasks is rejected before the client * is ever called. * - ABAC isolation: the client always receives ctx.sub, not an override from * the input payload. */ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { buildTaskTools } from '../src/tools.js'; import type { TasksClient, Task } from '../src/client.js'; import type { AuthContext } from '@sh-mcp/shared'; // --------------------------------------------------------------------------- // Test fixtures // --------------------------------------------------------------------------- const MOCK_CTX: AuthContext = { sub: 'lauren@seahavenind.com', scopes: ['ops:read', 'ops:tasks'], aud: 'sh-mcp-ops', }; const CTX_NO_TASKS: AuthContext = { sub: 'staff@seahavenind.com', scopes: ['ops:read'], // no ops:tasks aud: 'sh-mcp-ops', }; const TASK_1: Task = { taskId: 'task-001', sub: 'lauren@seahavenind.com', title: 'Review vendor invoices', description: 'Check against PO log before EOD', completed: false, createdAt: '2026-06-11T09:00:00.000Z', }; const TASK_1_COMPLETED: Task = { ...TASK_1, completed: true, completedAt: '2026-06-11T10:00:00.000Z', }; // --------------------------------------------------------------------------- // Mock client factory // --------------------------------------------------------------------------- function makeMockClient(overrides: Partial = {}): TasksClient { return { createTask: vi.fn().mockResolvedValue(TASK_1), listTasks: vi.fn().mockResolvedValue([TASK_1]), completeTask: vi.fn().mockResolvedValue(TASK_1_COMPLETED), deleteTask: vi.fn().mockResolvedValue(undefined), ...overrides, }; } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function getHandler(tools: ReturnType, name: string) { const tool = tools.find((t) => t.name === name); if (!tool) throw new Error(`Tool '${name}' not found`); // eslint-disable-next-line @typescript-eslint/no-explicit-any return (input: any, ctx: AuthContext) => tool.handler(input, ctx); } // --------------------------------------------------------------------------- // create_task // --------------------------------------------------------------------------- describe('create_task', () => { let client: TasksClient; let tools: ReturnType; beforeEach(() => { client = makeMockClient(); tools = buildTaskTools(client); }); it('happy path: creates a task and returns the expected shape', async () => { const call = getHandler(tools, 'create_task'); const result = await call({ title: 'Review vendor invoices', description: 'Check against PO log' }, MOCK_CTX); expect(result).toMatchObject({ task: { taskId: 'task-001', title: 'Review vendor invoices', completed: false, createdAt: '2026-06-11T09:00:00.000Z', }, }); }); it('passes ctx.sub as the owner, not any caller-supplied override', async () => { const call = getHandler(tools, 'create_task'); await call({ title: 'Test ABAC' }, MOCK_CTX); expect(client.createTask).toHaveBeenCalledWith( expect.objectContaining({ sub: MOCK_CTX.sub }), ); }); it('propagates client errors without swallowing them', async () => { client = makeMockClient({ createTask: vi.fn().mockRejectedValue(new Error('DynamoDB write failed')), }); tools = buildTaskTools(client); const call = getHandler(tools, 'create_task'); await expect(call({ title: 'Failing task' }, MOCK_CTX)).rejects.toThrow('DynamoDB write failed'); }); it('scope guard: rejects callers without ops:tasks before touching the client', async () => { const call = getHandler(tools, 'create_task'); await expect(call({ title: 'Sneaky task' }, CTX_NO_TASKS)).rejects.toThrow(); expect(client.createTask).not.toHaveBeenCalled(); }); it('tool metadata: name, tier, requiredScope are correct', () => { const tool = tools.find((t) => t.name === 'create_task')!; expect(tool.tier).toBe('ops'); expect(tool.requiredScope).toBe('ops:tasks'); }); }); // --------------------------------------------------------------------------- // list_tasks // --------------------------------------------------------------------------- describe('list_tasks', () => { let client: TasksClient; let tools: ReturnType; beforeEach(() => { client = makeMockClient(); tools = buildTaskTools(client); }); it('happy path: returns tasks with expected fields', async () => { const call = getHandler(tools, 'list_tasks'); const result = await call({}, MOCK_CTX); expect(result.tasks).toHaveLength(1); expect(result.tasks[0]).toMatchObject({ taskId: 'task-001', title: 'Review vendor invoices', completed: false, }); }); it('empty result: returns an empty array without error', async () => { client = makeMockClient({ listTasks: vi.fn().mockResolvedValue([]) }); tools = buildTaskTools(client); const call = getHandler(tools, 'list_tasks'); const result = await call({}, MOCK_CTX); expect(result.tasks).toEqual([]); }); it('passes includeCompleted: false by default', async () => { const call = getHandler(tools, 'list_tasks'); await call({}, MOCK_CTX); expect(client.listTasks).toHaveBeenCalledWith( expect.objectContaining({ includeCompleted: false }), ); }); it('passes includeCompleted: true when requested', async () => { const call = getHandler(tools, 'list_tasks'); await call({ includeCompleted: true }, MOCK_CTX); expect(client.listTasks).toHaveBeenCalledWith( expect.objectContaining({ includeCompleted: true }), ); }); it('always passes ctx.sub to the client for ABAC', async () => { const call = getHandler(tools, 'list_tasks'); await call({}, MOCK_CTX); expect(client.listTasks).toHaveBeenCalledWith( expect.objectContaining({ sub: MOCK_CTX.sub }), ); }); it('propagates client errors', async () => { client = makeMockClient({ listTasks: vi.fn().mockRejectedValue(new Error('DynamoDB query failed')), }); tools = buildTaskTools(client); const call = getHandler(tools, 'list_tasks'); await expect(call({}, MOCK_CTX)).rejects.toThrow('DynamoDB query failed'); }); it('simulates a throttle error (ProvisionedThroughputExceededException)', async () => { const throttleError = Object.assign( new Error('ProvisionedThroughputExceededException: rate exceeded'), { name: 'ProvisionedThroughputExceededException', $retryable: { throttling: true } }, ); client = makeMockClient({ listTasks: vi.fn().mockRejectedValue(throttleError) }); tools = buildTaskTools(client); const call = getHandler(tools, 'list_tasks'); // The tool handler propagates the error; retry logic belongs at the transport layer. const err = await call({}, MOCK_CTX).catch((e) => e); expect(err.name).toBe('ProvisionedThroughputExceededException'); }); it('scope guard: rejects callers without ops:tasks', async () => { const call = getHandler(tools, 'list_tasks'); await expect(call({}, CTX_NO_TASKS)).rejects.toThrow(); expect(client.listTasks).not.toHaveBeenCalled(); }); it('tool metadata: name, tier, requiredScope are correct', () => { const tool = tools.find((t) => t.name === 'list_tasks')!; expect(tool.tier).toBe('ops'); expect(tool.requiredScope).toBe('ops:tasks'); }); }); // --------------------------------------------------------------------------- // complete_task // --------------------------------------------------------------------------- describe('complete_task', () => { let client: TasksClient; let tools: ReturnType; beforeEach(() => { client = makeMockClient(); tools = buildTaskTools(client); }); it('happy path: returns the completed task shape', async () => { const call = getHandler(tools, 'complete_task'); const result = await call({ taskId: 'task-001' }, MOCK_CTX); expect(result.task).toMatchObject({ taskId: 'task-001', completed: true, completedAt: '2026-06-11T10:00:00.000Z', }); }); it('passes ctx.sub for ownership enforcement (ABAC)', async () => { const call = getHandler(tools, 'complete_task'); await call({ taskId: 'task-001' }, MOCK_CTX); expect(client.completeTask).toHaveBeenCalledWith( expect.objectContaining({ sub: MOCK_CTX.sub, taskId: 'task-001' }), ); }); it('propagates a ConditionalCheckFailedException (task not owned by caller)', async () => { const ownershipError = Object.assign( new Error('ConditionalCheckFailedException: condition not met'), { name: 'ConditionalCheckFailedException' }, ); client = makeMockClient({ completeTask: vi.fn().mockRejectedValue(ownershipError) }); tools = buildTaskTools(client); const call = getHandler(tools, 'complete_task'); const err = await call({ taskId: 'task-999' }, MOCK_CTX).catch((e) => e); expect(err.name).toBe('ConditionalCheckFailedException'); }); it('propagates a throttle error', async () => { const throttleError = Object.assign( new Error('ProvisionedThroughputExceededException'), { name: 'ProvisionedThroughputExceededException', $retryable: { throttling: true } }, ); client = makeMockClient({ completeTask: vi.fn().mockRejectedValue(throttleError) }); tools = buildTaskTools(client); const call = getHandler(tools, 'complete_task'); const err = await call({ taskId: 'task-001' }, MOCK_CTX).catch((e) => e); expect(err.name).toBe('ProvisionedThroughputExceededException'); }); it('scope guard: rejects callers without ops:tasks', async () => { const call = getHandler(tools, 'complete_task'); await expect(call({ taskId: 'task-001' }, CTX_NO_TASKS)).rejects.toThrow(); expect(client.completeTask).not.toHaveBeenCalled(); }); it('tool metadata: tier and requiredScope', () => { const tool = tools.find((t) => t.name === 'complete_task')!; expect(tool.tier).toBe('ops'); expect(tool.requiredScope).toBe('ops:tasks'); }); }); // --------------------------------------------------------------------------- // delete_task // --------------------------------------------------------------------------- describe('delete_task', () => { let client: TasksClient; let tools: ReturnType; beforeEach(() => { client = makeMockClient(); tools = buildTaskTools(client); }); it('happy path: returns deleted: true and the taskId', async () => { const call = getHandler(tools, 'delete_task'); const result = await call({ taskId: 'task-001' }, MOCK_CTX); expect(result).toEqual({ deleted: true, taskId: 'task-001' }); }); it('passes ctx.sub for ownership enforcement (ABAC)', async () => { const call = getHandler(tools, 'delete_task'); await call({ taskId: 'task-001' }, MOCK_CTX); expect(client.deleteTask).toHaveBeenCalledWith( expect.objectContaining({ sub: MOCK_CTX.sub, taskId: 'task-001' }), ); }); it('propagates client errors', async () => { client = makeMockClient({ deleteTask: vi.fn().mockRejectedValue(new Error('DynamoDB delete failed')), }); tools = buildTaskTools(client); const call = getHandler(tools, 'delete_task'); await expect(call({ taskId: 'task-001' }, MOCK_CTX)).rejects.toThrow('DynamoDB delete failed'); }); it('propagates a throttle error', async () => { const throttleError = Object.assign( new Error('ProvisionedThroughputExceededException'), { name: 'ProvisionedThroughputExceededException', $retryable: { throttling: true } }, ); client = makeMockClient({ deleteTask: vi.fn().mockRejectedValue(throttleError) }); tools = buildTaskTools(client); const call = getHandler(tools, 'delete_task'); const err = await call({ taskId: 'task-001' }, MOCK_CTX).catch((e) => e); expect(err.name).toBe('ProvisionedThroughputExceededException'); }); it('scope guard: rejects callers without ops:tasks', async () => { const call = getHandler(tools, 'delete_task'); await expect(call({ taskId: 'task-001' }, CTX_NO_TASKS)).rejects.toThrow(); expect(client.deleteTask).not.toHaveBeenCalled(); }); it('tool metadata: tier and requiredScope', () => { const tool = tools.find((t) => t.name === 'delete_task')!; expect(tool.tier).toBe('ops'); expect(tool.requiredScope).toBe('ops:tasks'); }); });