test: complete test suite — 166 new tests (stores + hardening + agent)
@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
This commit is contained in:
parent
c0eca4964a
commit
1995f03682
7 changed files with 911 additions and 10 deletions
165
ui-electrobun/tests/unit/hardening/channel-acl.test.ts
Normal file
165
ui-electrobun/tests/unit/hardening/channel-acl.test.ts
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
// 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');
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue