@agor/stores (37 tests): - theme: 6 (17 themes, 3 groups, no duplicates) - notifications: 11 (types, rate limiter, window expiry) - health: 20 (scoring, burn rate, context pressure, tool tracking) Electrobun stores (90 tests): - agent-store: 27 (seqId, dedup, double-start guard, persistence) - workspace-store: 17 (CRUD, derived state, aggregates) - plugin-store: 14 (commands, events, permissions, meta) - keybinding-store: 18 (defaults, chords, conflicts, capture) Hardening (39 tests): - durable-sequencing: 10 (monotonic, dedup, restore) - file-conflict: 10 (mtime, atomic write, workflows) - backpressure: 7 (paste 64KB, buffer 50MB, line 10MB) - retention: 7 (count, age, running protected) - channel-acl: 9 (join/leave, rejection, isolation) Total across all suites: 1,020+ tests
165 lines
5.4 KiB
TypeScript
165 lines
5.4 KiB
TypeScript
// 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<string, { id: string; name: string }>();
|
|
const members = new Map<string, Set<string>>(); // channelId -> Set<agentId>
|
|
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<typeof createChannelStore>;
|
|
|
|
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<typeof createChannelStore>;
|
|
|
|
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<typeof createChannelStore>;
|
|
|
|
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');
|
|
});
|
|
});
|