Files
EJClaw/src/ipc-auth.test.ts
2026-04-28 17:24:38 +09:00

1172 lines
29 KiB
TypeScript

import { describe, it, expect, beforeEach, vi } from 'vitest';
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<string, RegisteredGroup>;
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(),
roomBindings: () => 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',
},
'other-group',
false,
deps,
);
// roomBindings 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',
},
'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,
roomBindings: Record<string, RegisteredGroup>,
): boolean {
const targetGroup = roomBindings[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',
runId: 'run-reviewer-ipc',
},
'other-group',
false,
groups,
sendMessage,
);
expect(sendMessage).toHaveBeenCalledWith(
'other@g.us',
'review text',
'reviewer',
'run-reviewer-ipc',
undefined,
);
expect(result).toEqual(
expect.objectContaining({
outcome: 'sent',
senderRole: 'reviewer',
}),
);
});
it('forwards structured attachments through authorized IPC messages', async () => {
const sendMessage = vi.fn(async () => {});
const attachments = [
{
path: '/tmp/ejclaw-room-mobile-list-390.png',
name: 'room.png',
mime: 'image/png',
},
];
const result = await forwardAuthorizedIpcMessage(
{
type: 'message',
chatJid: 'other@g.us',
text: '스크린샷입니다.',
attachments,
},
'other-group',
false,
groups,
sendMessage,
);
expect(sendMessage).toHaveBeenCalledWith(
'other@g.us',
'스크린샷입니다.',
undefined,
undefined,
attachments,
);
expect(result.outcome).toBe('sent');
});
it('normalizes raw EJClaw envelopes in authorized IPC messages', async () => {
const sendMessage = vi.fn(async () => {});
const result = await forwardAuthorizedIpcMessage(
{
type: 'message',
chatJid: 'other@g.us',
text: JSON.stringify({
ejclaw: {
visibility: 'public',
text: '첨부를 렌더링했습니다.',
verdict: 'done',
attachments: [
{
path: '/tmp/bar-chart-label-fit-playwright.png',
name: 'bar-chart-label-fit-playwright.png',
mime: 'image/png',
},
],
},
}),
},
'other-group',
false,
groups,
sendMessage,
);
expect(sendMessage).toHaveBeenCalledWith(
'other@g.us',
'첨부를 렌더링했습니다.',
undefined,
undefined,
[
{
path: '/tmp/bar-chart-label-fit-playwright.png',
name: 'bar-chart-label-fit-playwright.png',
mime: 'image/png',
},
],
);
expect(result.outcome).toBe('sent');
});
it('injects authorized inbound messages for the source group without routing them as outbound sends', async () => {
const sendMessage = vi.fn(async () => {});
const injectInboundMessage = vi.fn(async () => {});
const result = await (forwardAuthorizedIpcMessage as any)(
{
type: 'inject_inbound_message',
chatJid: 'other@g.us',
text: 'act like a human input',
senderRole: 'owner',
sender: '1480575043007418420',
senderName: 'algo',
treatAsHuman: true,
sourceKind: 'trusted_external_bot',
},
'other-group',
false,
groups,
sendMessage,
injectInboundMessage,
);
expect(sendMessage).not.toHaveBeenCalled();
expect(injectInboundMessage).toHaveBeenCalledWith(
expect.objectContaining({
chatJid: 'other@g.us',
text: 'act like a human input',
sender: '1480575043007418420',
senderName: 'algo',
treatAsHuman: true,
sourceKind: 'trusted_external_bot',
}),
);
expect(result).toEqual(
expect.objectContaining({
outcome: 'sent',
chatJid: 'other@g.us',
}),
);
});
it('does not inject unauthorized inbound messages for another group', async () => {
const sendMessage = vi.fn(async () => {});
const injectInboundMessage = vi.fn(async () => {});
const result = await (forwardAuthorizedIpcMessage as any)(
{
type: 'inject_inbound_message',
chatJid: 'third@g.us',
text: 'should be blocked',
sender: '1480575043007418420',
senderName: 'algo',
treatAsHuman: true,
sourceKind: 'trusted_external_bot',
},
'other-group',
false,
groups,
sendMessage,
injectInboundMessage,
);
expect(result.outcome).toBe('blocked');
expect(sendMessage).not.toHaveBeenCalled();
expect(injectInboundMessage).not.toHaveBeenCalled();
});
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',
runId: 'run-reviewer-ipc',
},
'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',
},
'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');
});
it('assign_room auto-fills missing folder for single rooms without exposing trigger metadata', 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(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('main group can assign per-room reviewer and arbiter agent types', async () => {
await processTaskIpc(
{
type: 'assign_room',
jid: 'tribunal-role-overrides@g.us',
name: 'Tribunal Role Overrides',
room_mode: 'tribunal',
owner_agent_type: 'claude-code',
reviewer_agent_type: 'codex',
arbiter_agent_type: 'claude-code',
},
'whatsapp_main',
true,
deps,
);
expect(getStoredRoomSettings('tribunal-role-overrides@g.us')).toMatchObject(
{
chatJid: 'tribunal-role-overrides@g.us',
roomMode: 'tribunal',
modeSource: 'explicit',
name: 'Tribunal Role Overrides',
ownerAgentType: 'claude-code',
},
);
expect(
getRegisteredAgentTypesForJid('tribunal-role-overrides@g.us').sort(),
).toEqual(['claude-code', 'codex']);
expect(
getRegisteredGroup('tribunal-role-overrides@g.us', 'codex'),
).toMatchObject({
agentType: '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();
});
});