import { describe, it, expect, beforeEach, vi } from 'vitest'; import { ASSISTANT_NAME } from './config.js'; import { _setRegisteredGroupForTests, _initTestDatabase, assignRoom, createTask, getAllTasks, recallMemories, getRegisteredAgentTypesForJid, getRegisteredGroup, getStoredRoomSettings, getTaskById, } from './db.js'; import { forwardAuthorizedIpcMessage, processTaskIpc, IpcDeps } from './ipc.js'; import { DEFAULT_WATCH_CI_MAX_DURATION_MS } from './task-watch-status.js'; import { RegisteredGroup } from './types.js'; // Set up registered groups used across tests const MAIN_GROUP: RegisteredGroup = { name: 'Main', folder: 'whatsapp_main', trigger: 'always', added_at: '2024-01-01T00:00:00.000Z', isMain: true, }; const OTHER_GROUP: RegisteredGroup = { name: 'Other', folder: 'other-group', trigger: '@Andy', added_at: '2024-01-01T00:00:00.000Z', }; const THIRD_GROUP: RegisteredGroup = { name: 'Third', folder: 'third-group', trigger: '@Andy', added_at: '2024-01-01T00:00:00.000Z', }; let groups: Record; let deps: IpcDeps; beforeEach(() => { _initTestDatabase(); groups = { 'main@g.us': MAIN_GROUP, 'other@g.us': OTHER_GROUP, 'third@g.us': THIRD_GROUP, }; // Populate DB as well _setRegisteredGroupForTests('main@g.us', MAIN_GROUP); _setRegisteredGroupForTests('other@g.us', OTHER_GROUP); _setRegisteredGroupForTests('third@g.us', THIRD_GROUP); deps = { sendMessage: async () => {}, nudgeScheduler: vi.fn(), registeredGroups: () => groups, assignRoom: (jid, room) => { const assigned = assignRoom(jid, room); if (!assigned) return; const { jid: _ignoredJid, ...group } = assigned; groups[jid] = group; }, syncGroups: async () => {}, getAvailableGroups: () => [], writeGroupsSnapshot: () => {}, }; }); // --- schedule_task authorization --- describe('schedule_task authorization', () => { it('main group can schedule for another group', async () => { await processTaskIpc( { type: 'schedule_task', prompt: 'do something', schedule_type: 'once', schedule_value: '2025-06-01T00:00:00', targetJid: 'other@g.us', }, 'whatsapp_main', true, deps, ); // Verify task was created in DB for the other group const allTasks = getAllTasks(); expect(allTasks.length).toBe(1); expect(allTasks[0].group_folder).toBe('other-group'); }); it('non-main group can schedule for itself', async () => { await processTaskIpc( { type: 'schedule_task', prompt: 'self task', schedule_type: 'once', schedule_value: '2025-06-01T00:00:00', targetJid: 'other@g.us', }, 'other-group', false, deps, ); const allTasks = getAllTasks(); expect(allTasks.length).toBe(1); expect(allTasks[0].group_folder).toBe('other-group'); }); it('stores the target group agent type on scheduled tasks', async () => { groups['other@g.us'] = { ...OTHER_GROUP, agentType: 'codex', }; _setRegisteredGroupForTests('other@g.us', groups['other@g.us']); await processTaskIpc( { type: 'schedule_task', prompt: 'codex owned task', schedule_type: 'once', schedule_value: '2025-06-01T00:00:00', targetJid: 'other@g.us', }, 'whatsapp_main', true, deps, ); const allTasks = getAllTasks(); expect(allTasks).toHaveLength(1); expect(allTasks[0].agent_type).toBe('codex'); }); it('non-main group cannot schedule for another group', async () => { await processTaskIpc( { type: 'schedule_task', prompt: 'unauthorized', schedule_type: 'once', schedule_value: '2025-06-01T00:00:00', targetJid: 'main@g.us', }, 'other-group', false, deps, ); const allTasks = getAllTasks(); expect(allTasks.length).toBe(0); }); it('rejects schedule_task for unregistered target JID', async () => { await processTaskIpc( { type: 'schedule_task', prompt: 'no target', schedule_type: 'once', schedule_value: '2025-06-01T00:00:00', targetJid: 'unknown@g.us', }, 'whatsapp_main', true, deps, ); const allTasks = getAllTasks(); expect(allTasks.length).toBe(0); }); it('accepts a target folder name and resolves it to the registered JID', async () => { await processTaskIpc( { type: 'schedule_task', prompt: 'folder-based target', schedule_type: 'once', schedule_value: '2025-06-01T00:00:00', targetJid: 'other-group', }, 'other-group', false, deps, ); const allTasks = getAllTasks(); expect(allTasks).toHaveLength(1); expect(allTasks[0].group_folder).toBe('other-group'); expect(allTasks[0].chat_jid).toBe('other@g.us'); }); }); describe('persist_memory authorization', () => { it('allows a room runtime to persist memory for its own room scope', async () => { await processTaskIpc( { type: 'persist_memory', scopeKind: 'room', scopeKey: 'room:other-group', content: 'compact summary', keywords: ['room:other-group'], source_kind: 'compact', source_ref: 'compact:test', }, 'other-group', false, deps, ); const recalled = recallMemories({ scopeKind: 'room', scopeKey: 'room:other-group', limit: 10, }); expect(recalled).toHaveLength(1); expect(recalled[0].content).toBe('compact summary'); }); it('blocks persist_memory when scopeKey does not match the source group', async () => { await processTaskIpc( { type: 'persist_memory', scopeKind: 'room', scopeKey: 'room:third-group', content: 'should be denied', keywords: ['room:third-group'], source_kind: 'compact', }, 'other-group', false, deps, ); const recalled = recallMemories({ scopeKind: 'room', scopeKey: 'room:third-group', limit: 10, }); expect(recalled).toHaveLength(0); }); }); // --- pause_task authorization --- describe('pause_task authorization', () => { beforeEach(() => { createTask({ id: 'task-main', group_folder: 'whatsapp_main', chat_jid: 'main@g.us', prompt: 'main task', schedule_type: 'once', schedule_value: '2025-06-01T00:00:00', context_mode: 'isolated', next_run: '2025-06-01T00:00:00.000Z', status: 'active', created_at: '2024-01-01T00:00:00.000Z', }); createTask({ id: 'task-other', group_folder: 'other-group', chat_jid: 'other@g.us', prompt: 'other task', schedule_type: 'once', schedule_value: '2025-06-01T00:00:00', context_mode: 'isolated', next_run: '2025-06-01T00:00:00.000Z', status: 'active', created_at: '2024-01-01T00:00:00.000Z', }); }); it('main group can pause any task', async () => { await processTaskIpc( { type: 'pause_task', taskId: 'task-other' }, 'whatsapp_main', true, deps, ); expect(getTaskById('task-other')!.status).toBe('paused'); }); it('non-main group can pause its own task', async () => { await processTaskIpc( { type: 'pause_task', taskId: 'task-other' }, 'other-group', false, deps, ); expect(getTaskById('task-other')!.status).toBe('paused'); }); it('non-main group cannot pause another groups task', async () => { await processTaskIpc( { type: 'pause_task', taskId: 'task-main' }, 'other-group', false, deps, ); expect(getTaskById('task-main')!.status).toBe('active'); }); }); // --- resume_task authorization --- describe('resume_task authorization', () => { beforeEach(() => { createTask({ id: 'task-paused', group_folder: 'other-group', chat_jid: 'other@g.us', prompt: 'paused task', schedule_type: 'once', schedule_value: '2025-06-01T00:00:00', context_mode: 'isolated', next_run: '2025-06-01T00:00:00.000Z', status: 'paused', created_at: '2024-01-01T00:00:00.000Z', }); }); it('main group can resume any task', async () => { await processTaskIpc( { type: 'resume_task', taskId: 'task-paused' }, 'whatsapp_main', true, deps, ); expect(getTaskById('task-paused')!.status).toBe('active'); }); it('non-main group can resume its own task', async () => { await processTaskIpc( { type: 'resume_task', taskId: 'task-paused' }, 'other-group', false, deps, ); expect(getTaskById('task-paused')!.status).toBe('active'); }); it('non-main group cannot resume another groups task', async () => { await processTaskIpc( { type: 'resume_task', taskId: 'task-paused' }, 'third-group', false, deps, ); expect(getTaskById('task-paused')!.status).toBe('paused'); }); }); // --- cancel_task authorization --- describe('cancel_task authorization', () => { it('main group can cancel any task', async () => { createTask({ id: 'task-to-cancel', group_folder: 'other-group', chat_jid: 'other@g.us', prompt: 'cancel me', schedule_type: 'once', schedule_value: '2025-06-01T00:00:00', context_mode: 'isolated', next_run: null, status: 'active', created_at: '2024-01-01T00:00:00.000Z', }); await processTaskIpc( { type: 'cancel_task', taskId: 'task-to-cancel' }, 'whatsapp_main', true, deps, ); expect(getTaskById('task-to-cancel')).toBeUndefined(); }); it('non-main group can cancel its own task', async () => { createTask({ id: 'task-own', group_folder: 'other-group', chat_jid: 'other@g.us', prompt: 'my task', schedule_type: 'once', schedule_value: '2025-06-01T00:00:00', context_mode: 'isolated', next_run: null, status: 'active', created_at: '2024-01-01T00:00:00.000Z', }); await processTaskIpc( { type: 'cancel_task', taskId: 'task-own' }, 'other-group', false, deps, ); expect(getTaskById('task-own')).toBeUndefined(); }); it('non-main group cannot cancel another groups task', async () => { createTask({ id: 'task-foreign', group_folder: 'whatsapp_main', chat_jid: 'main@g.us', prompt: 'not yours', schedule_type: 'once', schedule_value: '2025-06-01T00:00:00', context_mode: 'isolated', next_run: null, status: 'active', created_at: '2024-01-01T00:00:00.000Z', }); await processTaskIpc( { type: 'cancel_task', taskId: 'task-foreign' }, 'other-group', false, deps, ); expect(getTaskById('task-foreign')).toBeDefined(); }); }); // --- assign_room authorization --- describe('assign_room authorization', () => { it('non-main group cannot assign a room', async () => { await processTaskIpc( { type: 'assign_room', jid: 'new@g.us', name: 'New Group', folder: 'new-group', trigger: '@Andy', }, 'other-group', false, deps, ); // registeredGroups should not have changed expect(groups['new@g.us']).toBeUndefined(); }); it('main group cannot assign with unsafe folder path', async () => { await processTaskIpc( { type: 'assign_room', jid: 'new@g.us', name: 'New Group', folder: '../../outside', trigger: '@Andy', }, 'whatsapp_main', true, deps, ); expect(groups['new@g.us']).toBeUndefined(); }); }); // --- refresh_groups authorization --- describe('refresh_groups authorization', () => { it('non-main group cannot trigger refresh', async () => { // This should be silently blocked (no crash, no effect) await processTaskIpc( { type: 'refresh_groups' }, 'other-group', false, deps, ); // If we got here without error, the auth gate worked }); }); // --- IPC message authorization --- // Tests the authorization pattern from startIpcWatcher (ipc.ts). // The logic: isMain || (targetGroup && targetGroup.folder === sourceGroup) describe('IPC message authorization', () => { // Replicate the exact check from the IPC watcher function isMessageAuthorized( sourceGroup: string, isMain: boolean, targetChatJid: string, registeredGroups: Record, ): boolean { const targetGroup = registeredGroups[targetChatJid]; return isMain || (!!targetGroup && targetGroup.folder === sourceGroup); } it('main group can send to any group', () => { expect( isMessageAuthorized('whatsapp_main', true, 'other@g.us', groups), ).toBe(true); expect( isMessageAuthorized('whatsapp_main', true, 'third@g.us', groups), ).toBe(true); }); it('non-main group can send to its own chat', () => { expect( isMessageAuthorized('other-group', false, 'other@g.us', groups), ).toBe(true); }); it('non-main group cannot send to another groups chat', () => { expect(isMessageAuthorized('other-group', false, 'main@g.us', groups)).toBe( false, ); expect( isMessageAuthorized('other-group', false, 'third@g.us', groups), ).toBe(false); }); it('non-main group cannot send to unregistered JID', () => { expect( isMessageAuthorized('other-group', false, 'unknown@g.us', groups), ).toBe(false); }); it('main group can send to unregistered JID', () => { // Main is always authorized regardless of target expect( isMessageAuthorized('whatsapp_main', true, 'unknown@g.us', groups), ).toBe(true); }); it('forwards senderRole through authorized IPC messages', async () => { const sendMessage = vi.fn(async () => {}); const result = await forwardAuthorizedIpcMessage( { type: 'message', chatJid: 'other@g.us', text: 'review text', senderRole: 'reviewer', }, 'other-group', false, groups, sendMessage, ); expect(sendMessage).toHaveBeenCalledWith( 'other@g.us', 'review text', 'reviewer', ); expect(result).toEqual( expect.objectContaining({ outcome: 'sent', senderRole: 'reviewer', }), ); }); it('does not forward unauthorized IPC messages even when senderRole exists', async () => { const sendMessage = vi.fn(async () => {}); const result = await forwardAuthorizedIpcMessage( { type: 'message', chatJid: 'third@g.us', text: 'review text', senderRole: 'reviewer', }, 'other-group', false, groups, sendMessage, ); expect(result.outcome).toBe('blocked'); expect(result.senderRole).toBe('reviewer'); expect(sendMessage).not.toHaveBeenCalled(); }); }); // --- schedule_task with cron and interval types --- describe('schedule_task schedule types', () => { it('creates task with cron schedule and computes next_run', async () => { await processTaskIpc( { type: 'schedule_task', prompt: 'cron task', schedule_type: 'cron', schedule_value: '0 9 * * *', // every day at 9am targetJid: 'other@g.us', }, 'whatsapp_main', true, deps, ); const tasks = getAllTasks(); expect(tasks).toHaveLength(1); expect(tasks[0].schedule_type).toBe('cron'); expect(tasks[0].next_run).toBeTruthy(); // next_run should be a valid ISO date in the future expect(new Date(tasks[0].next_run!).getTime()).toBeGreaterThan( Date.now() - 60000, ); }); it('rejects invalid cron expression', async () => { await processTaskIpc( { type: 'schedule_task', prompt: 'bad cron', schedule_type: 'cron', schedule_value: 'not a cron', targetJid: 'other@g.us', }, 'whatsapp_main', true, deps, ); expect(getAllTasks()).toHaveLength(0); }); it('creates task with interval schedule', async () => { const before = Date.now(); await processTaskIpc( { type: 'schedule_task', prompt: 'interval task', schedule_type: 'interval', schedule_value: '3600000', // 1 hour targetJid: 'other@g.us', }, 'whatsapp_main', true, deps, ); const tasks = getAllTasks(); expect(tasks).toHaveLength(1); expect(tasks[0].schedule_type).toBe('interval'); // next_run should be ~1 hour from now const nextRun = new Date(tasks[0].next_run!).getTime(); expect(nextRun).toBeGreaterThanOrEqual(before + 3600000 - 1000); expect(nextRun).toBeLessThanOrEqual(Date.now() + 3600000 + 1000); }); it('starts watch_ci interval tasks immediately and nudges the scheduler', async () => { const before = Date.now(); await processTaskIpc( { type: 'schedule_task', prompt: ` [BACKGROUND CI WATCH] Watch target: GitHub Actions run 123456 Check instructions: Check the run. `.trim(), schedule_type: 'interval', schedule_value: '60000', targetJid: 'other@g.us', }, 'whatsapp_main', true, deps, ); const tasks = getAllTasks(); expect(tasks).toHaveLength(1); expect(tasks[0].schedule_type).toBe('interval'); expect(tasks[0].max_duration_ms).toBe(DEFAULT_WATCH_CI_MAX_DURATION_MS); const nextRun = new Date(tasks[0].next_run!).getTime(); expect(nextRun).toBeGreaterThanOrEqual(before - 1000); expect(nextRun).toBeLessThanOrEqual(Date.now() + 1000); expect(deps.nudgeScheduler).toHaveBeenCalledTimes(1); }); it('persists structured GitHub watch metadata for host-driven watchers', async () => { await processTaskIpc( { type: 'schedule_task', prompt: ` [BACKGROUND CI WATCH] Watch target: GitHub Actions run 654321 Check instructions: Managed by host-driven watcher. `.trim(), schedule_type: 'interval', schedule_value: '15000', ci_provider: 'github', ci_metadata: JSON.stringify({ repo: 'owner/repo', run_id: 654321, }), targetJid: 'other@g.us', }, 'whatsapp_main', true, deps, ); const tasks = getAllTasks(); expect(tasks).toHaveLength(1); expect(tasks[0].ci_provider).toBe('github'); expect(tasks[0].ci_metadata).toContain('owner/repo'); expect(tasks[0].max_duration_ms).toBe(DEFAULT_WATCH_CI_MAX_DURATION_MS); }); it('does not assign a max duration to regular scheduled tasks', async () => { await processTaskIpc( { type: 'schedule_task', prompt: 'regular interval task', schedule_type: 'interval', schedule_value: '60000', targetJid: 'other@g.us', }, 'whatsapp_main', true, deps, ); const tasks = getAllTasks(); expect(tasks).toHaveLength(1); expect(tasks[0].max_duration_ms).toBeNull(); }); it('rejects invalid interval (non-numeric)', async () => { await processTaskIpc( { type: 'schedule_task', prompt: 'bad interval', schedule_type: 'interval', schedule_value: 'abc', targetJid: 'other@g.us', }, 'whatsapp_main', true, deps, ); expect(getAllTasks()).toHaveLength(0); }); it('rejects invalid interval (zero)', async () => { await processTaskIpc( { type: 'schedule_task', prompt: 'zero interval', schedule_type: 'interval', schedule_value: '0', targetJid: 'other@g.us', }, 'whatsapp_main', true, deps, ); expect(getAllTasks()).toHaveLength(0); }); it('rejects invalid once timestamp', async () => { await processTaskIpc( { type: 'schedule_task', prompt: 'bad once', schedule_type: 'once', schedule_value: 'not-a-date', targetJid: 'other@g.us', }, 'whatsapp_main', true, deps, ); expect(getAllTasks()).toHaveLength(0); }); }); // --- context_mode defaulting --- describe('schedule_task context_mode', () => { it('accepts context_mode=group', async () => { await processTaskIpc( { type: 'schedule_task', prompt: 'group context', schedule_type: 'once', schedule_value: '2025-06-01T00:00:00', context_mode: 'group', targetJid: 'other@g.us', }, 'whatsapp_main', true, deps, ); const tasks = getAllTasks(); expect(tasks[0].context_mode).toBe('group'); }); it('accepts context_mode=isolated', async () => { await processTaskIpc( { type: 'schedule_task', prompt: 'isolated context', schedule_type: 'once', schedule_value: '2025-06-01T00:00:00', context_mode: 'isolated', targetJid: 'other@g.us', }, 'whatsapp_main', true, deps, ); const tasks = getAllTasks(); expect(tasks[0].context_mode).toBe('isolated'); }); it('defaults invalid context_mode to isolated', async () => { await processTaskIpc( { type: 'schedule_task', prompt: 'bad context', schedule_type: 'once', schedule_value: '2025-06-01T00:00:00', context_mode: 'bogus' as any, targetJid: 'other@g.us', }, 'whatsapp_main', true, deps, ); const tasks = getAllTasks(); expect(tasks[0].context_mode).toBe('isolated'); }); it('defaults missing context_mode to isolated', async () => { await processTaskIpc( { type: 'schedule_task', prompt: 'no context mode', schedule_type: 'once', schedule_value: '2025-06-01T00:00:00', targetJid: 'other@g.us', }, 'whatsapp_main', true, deps, ); const tasks = getAllTasks(); expect(tasks[0].context_mode).toBe('isolated'); }); }); // --- assign_room success path --- describe('assign_room success', () => { it('main group can assign a new room', async () => { await processTaskIpc( { type: 'assign_room', jid: 'new@g.us', name: 'New Group', folder: 'new-group', trigger: '@Andy', }, 'whatsapp_main', true, deps, ); const group = getRegisteredGroup('new@g.us'); expect(group).toBeDefined(); expect(group!.name).toBe('New Group'); expect(group!.folder).toBe('new-group'); expect(group!.trigger).toBe('@Andy'); }); it('assign_room auto-fills missing folder and trigger for single rooms', async () => { await processTaskIpc( { type: 'assign_room', jid: 'partial@g.us', name: 'Partial', }, 'whatsapp_main', true, deps, ); const group = getRegisteredGroup('partial@g.us'); expect(group).toBeDefined(); expect(group!.folder).toMatch(/^grp_whatsapp_/); expect(group!.trigger).toBe(`@${ASSISTANT_NAME}`); expect(getStoredRoomSettings('partial@g.us')?.roomMode).toBe('single'); }); it('assign_room preserves an existing tribunal room mode when room_mode is omitted', async () => { assignRoom('legacy-tribunal@g.us', { name: 'Legacy Tribunal', roomMode: 'tribunal', ownerAgentType: 'codex', folder: 'legacy-tribunal', }); await processTaskIpc( { type: 'assign_room', jid: 'legacy-tribunal@g.us', name: 'Legacy Tribunal Renamed', folder: 'legacy-tribunal', }, 'whatsapp_main', true, deps, ); expect(getStoredRoomSettings('legacy-tribunal@g.us')).toMatchObject({ chatJid: 'legacy-tribunal@g.us', roomMode: 'tribunal', modeSource: 'explicit', name: 'Legacy Tribunal Renamed', }); expect( getRegisteredAgentTypesForJid('legacy-tribunal@g.us').sort(), ).toEqual(['claude-code', 'codex']); }); it('main group can assign a tribunal room and materialize capability rows', async () => { await processTaskIpc( { type: 'assign_room', jid: 'tribunal@g.us', name: 'Tribunal Room', room_mode: 'tribunal', owner_agent_type: 'codex', }, 'whatsapp_main', true, deps, ); const stored = getStoredRoomSettings('tribunal@g.us'); expect(stored).toMatchObject({ chatJid: 'tribunal@g.us', roomMode: 'tribunal', modeSource: 'explicit', name: 'Tribunal Room', ownerAgentType: 'codex', }); expect(stored?.folder).toMatch(/^grp_whatsapp_/); expect(getRegisteredAgentTypesForJid('tribunal@g.us').sort()).toEqual([ 'claude-code', 'codex', ]); }); it('assign_room rejects request with missing fields', async () => { await processTaskIpc( { type: 'assign_room', jid: 'broken@g.us', }, 'whatsapp_main', true, deps, ); expect(getRegisteredGroup('broken@g.us')).toBeUndefined(); }); });