// Tests for channel ACL — membership-gated messaging. // Uses bun:test. Tests the logic from btmsg-db.ts channel operations. import { describe, it, expect, beforeEach } from 'bun:test'; // ── In-memory channel store (replicated logic from btmsg-db.ts) ───────────── interface ChannelMessage { id: string; channelId: string; fromAgent: string; content: string; } function createChannelStore() { const channels = new Map(); const members = new Map>(); // channelId -> Set const messages: ChannelMessage[] = []; let msgCounter = 0; return { createChannel(id: string, name: string): void { channels.set(id, { id, name }); members.set(id, new Set()); }, joinChannel(channelId: string, agentId: string): void { const ch = channels.get(channelId); if (!ch) throw new Error(`Channel '${channelId}' not found`); members.get(channelId)!.add(agentId); }, leaveChannel(channelId: string, agentId: string): void { members.get(channelId)?.delete(agentId); }, sendChannelMessage(channelId: string, fromAgent: string, content: string): string { const memberSet = members.get(channelId); if (!memberSet || !memberSet.has(fromAgent)) { throw new Error(`Agent '${fromAgent}' is not a member of channel '${channelId}'`); } const id = `msg-${++msgCounter}`; messages.push({ id, channelId, fromAgent, content }); return id; }, getChannelMembers(channelId: string): string[] { return Array.from(members.get(channelId) ?? []); }, getMessages(channelId: string): ChannelMessage[] { return messages.filter(m => m.channelId === channelId); }, }; } // ── Tests ─────────────────────────────────────────────────────────────────── describe('channel membership', () => { let store: ReturnType; beforeEach(() => { store = createChannelStore(); store.createChannel('general', 'General'); }); it('joinChannel adds member', () => { store.joinChannel('general', 'agent-1'); expect(store.getChannelMembers('general')).toContain('agent-1'); }); it('joinChannel to nonexistent channel throws', () => { expect(() => store.joinChannel('nonexistent', 'agent-1')).toThrow('not found'); }); it('leaveChannel removes member', () => { store.joinChannel('general', 'agent-1'); store.leaveChannel('general', 'agent-1'); expect(store.getChannelMembers('general')).not.toContain('agent-1'); }); it('leaveChannel is idempotent', () => { store.leaveChannel('general', 'agent-1'); expect(store.getChannelMembers('general')).toHaveLength(0); }); it('getChannelMembers returns all members', () => { store.joinChannel('general', 'agent-1'); store.joinChannel('general', 'agent-2'); store.joinChannel('general', 'agent-3'); expect(store.getChannelMembers('general')).toHaveLength(3); }); it('duplicate join is idempotent (Set semantics)', () => { store.joinChannel('general', 'agent-1'); store.joinChannel('general', 'agent-1'); expect(store.getChannelMembers('general')).toHaveLength(1); }); }); describe('channel message ACL', () => { let store: ReturnType; beforeEach(() => { store = createChannelStore(); store.createChannel('ops', 'Operations'); store.joinChannel('ops', 'manager'); }); it('member can send message', () => { const id = store.sendChannelMessage('ops', 'manager', 'hello team'); expect(id).toBeTruthy(); const msgs = store.getMessages('ops'); expect(msgs).toHaveLength(1); expect(msgs[0].content).toBe('hello team'); expect(msgs[0].fromAgent).toBe('manager'); }); it('non-member is rejected', () => { expect(() => { store.sendChannelMessage('ops', 'outsider', 'sneaky message'); }).toThrow("not a member"); }); it('former member is rejected after leaving', () => { store.leaveChannel('ops', 'manager'); expect(() => { store.sendChannelMessage('ops', 'manager', 'should fail'); }).toThrow("not a member"); }); it('rejoined member can send again', () => { store.leaveChannel('ops', 'manager'); store.joinChannel('ops', 'manager'); const id = store.sendChannelMessage('ops', 'manager', 'back again'); expect(id).toBeTruthy(); }); }); describe('channel isolation', () => { let store: ReturnType; beforeEach(() => { store = createChannelStore(); store.createChannel('ch-a', 'Channel A'); store.createChannel('ch-b', 'Channel B'); store.joinChannel('ch-a', 'agent-1'); }); it('member of channel A cannot send to channel B', () => { expect(() => { store.sendChannelMessage('ch-b', 'agent-1', 'wrong channel'); }).toThrow("not a member"); }); it('messages are channel-scoped', () => { store.joinChannel('ch-b', 'agent-2'); store.sendChannelMessage('ch-a', 'agent-1', 'msg in A'); store.sendChannelMessage('ch-b', 'agent-2', 'msg in B'); expect(store.getMessages('ch-a')).toHaveLength(1); expect(store.getMessages('ch-b')).toHaveLength(1); expect(store.getMessages('ch-a')[0].content).toBe('msg in A'); expect(store.getMessages('ch-b')[0].content).toBe('msg in B'); }); });