import { describe, it, expect, beforeEach } from 'vitest'; import { _initTestDatabase, createTask, getAllTasks, getRegisteredGroup, getTaskById, setRegisteredGroup, } from './db.js'; import { processTaskIpc, IpcDeps } from './ipc.js'; import { RegisteredGroup } from './types.js'; // Set up registered groups used across tests const MAIN_GROUP: RegisteredGroup = { name: 'Main', folder: 'main', trigger: 'always', added_at: '2024-01-01T00:00:00.000Z', }; 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 setRegisteredGroup('main@g.us', MAIN_GROUP); setRegisteredGroup('other@g.us', OTHER_GROUP); setRegisteredGroup('third@g.us', THIRD_GROUP); deps = { sendMessage: async () => {}, sendReaction: async () => {}, registeredGroups: () => groups, registerGroup: (jid, group) => { groups[jid] = group; setRegisteredGroup(jid, group); }, unregisterGroup: (jid) => { const existed = jid in groups; delete groups[jid]; return existed; }, syncGroupMetadata: 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.000Z', targetJid: 'other@g.us', }, '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.000Z', 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('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.000Z', 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.000Z', targetJid: 'unknown@g.us', }, 'main', true, deps, ); const allTasks = getAllTasks(); expect(allTasks.length).toBe(0); }); }); // --- pause_task authorization --- describe('pause_task authorization', () => { beforeEach(() => { createTask({ id: 'task-main', group_folder: 'main', chat_jid: 'main@g.us', prompt: 'main task', schedule_type: 'once', schedule_value: '2025-06-01T00:00:00.000Z', 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.000Z', 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' }, '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.000Z', 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' }, '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.000Z', 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' }, '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.000Z', 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: 'main', chat_jid: 'main@g.us', prompt: 'not yours', schedule_type: 'once', schedule_value: '2025-06-01T00:00:00.000Z', 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(); }); }); // --- register_group authorization --- describe('register_group authorization', () => { it('non-main group cannot register a group', async () => { await processTaskIpc( { type: 'register_group', 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 register with unsafe folder path', async () => { await processTaskIpc( { type: 'register_group', jid: 'new@g.us', name: 'New Group', folder: '../../outside', trigger: '@Andy', }, '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('main', true, 'other@g.us', groups)).toBe(true); expect(isMessageAuthorized('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('main', true, 'unknown@g.us', groups)).toBe( true, ); }); }); // --- IPC reaction authorization --- // Same authorization pattern as message sending (ipc.ts lines 104-127). describe('IPC reaction authorization', () => { // Replicate the exact check from the IPC watcher for reactions function isReactionAuthorized( sourceGroup: string, isMain: boolean, targetChatJid: string, registeredGroups: Record, ): boolean { const targetGroup = registeredGroups[targetChatJid]; return isMain || (!!targetGroup && targetGroup.folder === sourceGroup); } it('main group can react in any chat', () => { expect(isReactionAuthorized('main', true, 'other@g.us', groups)).toBe(true); expect(isReactionAuthorized('main', true, 'third@g.us', groups)).toBe(true); }); it('non-main group can react in its own chat', () => { expect( isReactionAuthorized('other-group', false, 'other@g.us', groups), ).toBe(true); }); it('non-main group cannot react in another groups chat', () => { expect( isReactionAuthorized('other-group', false, 'main@g.us', groups), ).toBe(false); expect( isReactionAuthorized('other-group', false, 'third@g.us', groups), ).toBe(false); }); it('non-main group cannot react in unregistered JID', () => { expect( isReactionAuthorized('other-group', false, 'unknown@g.us', groups), ).toBe(false); }); }); // --- sendReaction mock is exercised --- // The sendReaction dep is wired in but was never called in tests. // These tests verify startIpcWatcher would call it by testing the pattern inline. describe('IPC reaction sendReaction integration', () => { it('sendReaction mock is callable', async () => { const calls: Array<{ jid: string; emoji: string; messageId?: string }> = []; deps.sendReaction = async (jid, emoji, messageId) => { calls.push({ jid, emoji, messageId }); }; // Simulate what processIpcFiles does for a reaction const data = { type: 'reaction' as const, chatJid: 'other@g.us', emoji: '👍', messageId: 'msg-123', }; const sourceGroup = 'main'; const isMain = true; const registeredGroups = deps.registeredGroups(); const targetGroup = registeredGroups[data.chatJid]; if (isMain || (targetGroup && targetGroup.folder === sourceGroup)) { await deps.sendReaction(data.chatJid, data.emoji, data.messageId); } expect(calls).toHaveLength(1); expect(calls[0]).toEqual({ jid: 'other@g.us', emoji: '👍', messageId: 'msg-123', }); }); it('sendReaction is blocked for unauthorized group', async () => { const calls: Array<{ jid: string; emoji: string; messageId?: string }> = []; deps.sendReaction = async (jid, emoji, messageId) => { calls.push({ jid, emoji, messageId }); }; const data = { type: 'reaction' as const, chatJid: 'main@g.us', emoji: '❤️', }; const sourceGroup = 'other-group'; const isMain = false; const registeredGroups = deps.registeredGroups(); const targetGroup = registeredGroups[data.chatJid]; if (isMain || (targetGroup && targetGroup.folder === sourceGroup)) { await deps.sendReaction(data.chatJid, data.emoji); } expect(calls).toHaveLength(0); }); it('sendReaction works without messageId (react to latest)', async () => { const calls: Array<{ jid: string; emoji: string; messageId?: string }> = []; deps.sendReaction = async (jid, emoji, messageId) => { calls.push({ jid, emoji, messageId }); }; const data = { type: 'reaction' as const, chatJid: 'other@g.us', emoji: '🔥', }; const sourceGroup = 'other-group'; const isMain = false; const registeredGroups = deps.registeredGroups(); const targetGroup = registeredGroups[data.chatJid]; if (isMain || (targetGroup && targetGroup.folder === sourceGroup)) { await deps.sendReaction(data.chatJid, data.emoji, undefined); } expect(calls).toHaveLength(1); expect(calls[0]).toEqual({ jid: 'other@g.us', emoji: '🔥', messageId: undefined, }); }); }); // --- 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', }, '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', }, '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', }, '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('rejects invalid interval (non-numeric)', async () => { await processTaskIpc( { type: 'schedule_task', prompt: 'bad interval', schedule_type: 'interval', schedule_value: 'abc', targetJid: 'other@g.us', }, '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', }, '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', }, '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.000Z', context_mode: 'group', targetJid: 'other@g.us', }, '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.000Z', context_mode: 'isolated', targetJid: 'other@g.us', }, '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.000Z', context_mode: 'bogus' as any, targetJid: 'other@g.us', }, '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.000Z', targetJid: 'other@g.us', }, 'main', true, deps, ); const tasks = getAllTasks(); expect(tasks[0].context_mode).toBe('isolated'); }); }); // --- register_group success path --- describe('register_group success', () => { it('main group can register a new group', async () => { await processTaskIpc( { type: 'register_group', jid: 'new@g.us', name: 'New Group', folder: 'new-group', trigger: '@Andy', }, 'main', true, deps, ); // Verify group was registered in DB 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('register_group rejects request with missing fields', async () => { await processTaskIpc( { type: 'register_group', jid: 'partial@g.us', name: 'Partial', // missing folder and trigger }, 'main', true, deps, ); expect(getRegisteredGroup('partial@g.us')).toBeUndefined(); }); });