/** * Tier isolation test — the most load-bearing privacy guarantee in the * whole memory system. If any of these fail, users can see each other's * private rows, which would be a serious correctness regression. * * Three guarantees covered: * 1. Agent-tier rows (`memory/agent/*`) are shared across every user * of the same agent. * 2. User-tier rows (`memory/user/*`) are private to the caller — * another user of the same agent can NEVER read them. * 3. Writes to user-tier paths require a scoped caller; isolated * contexts (no userId) throw. * * Plus the original cross-agent isolation (two different agents can * never see each other's rows). */ import { MockMemoryBackend } from './mockBackend'; describe('MemoryBackend tier + user isolation', () => { const salesAlice = { agentId: 'sales', userId: 'alice' }; const salesBob = { agentId: 'sales', userId: 'bob' }; const salesNoCaller = { agentId: 'sales' }; // isolated / autonomous const supportAlice = { agentId: 'support', userId: 'alice' }; let backend: MockMemoryBackend; beforeEach(async () => { backend = new MockMemoryBackend(); }); describe('agent tier (shared)', () => { it('one agent/playbook.md row is visible to every user of the same agent', async () => { await backend.append(salesAlice, { path: 'memory/agent/playbook.md', content: 'Sales discovery: lead with ROI numbers, not features.', }); // Bob, a different user of the SAME agent, sees the row. const fromBob = await backend.get(salesBob, { path: 'memory/agent/playbook.md', }); expect(fromBob?.text).toContain('ROI numbers'); // Isolated caller (no userId) also sees it — agent tier is inherent. const fromIsolated = await backend.get(salesNoCaller, { path: 'memory/agent/playbook.md', }); expect(fromIsolated?.text).toContain('ROI numbers'); // Search returns exactly one row (shared slot, NULLS NOT DISTINCT). const rows = backend.allRows(); const playbookRows = rows.filter( (r) => r.path === 'memory/agent/playbook.md' ); expect(playbookRows).toHaveLength(1); expect(playbookRows[0].userId).toBeNull(); }); it('different users appending to the same agent-tier path merge into one row', async () => { await backend.append(salesAlice, { path: 'memory/agent/pitfalls.md', content: 'AlicePitfall', }); await backend.append(salesBob, { path: 'memory/agent/pitfalls.md', content: 'BobPitfall', }); const rows = backend .allRows() .filter( (r) => r.agentId === 'sales' && r.path === 'memory/agent/pitfalls.md' ); expect(rows).toHaveLength(1); expect(rows[0].userId).toBeNull(); expect(rows[0].content).toContain('AlicePitfall'); expect(rows[0].content).toContain('BobPitfall'); // Latest writer recorded as provenance even though scoping stays NULL. expect(rows[0].lastUserId).toBe('bob'); }); }); describe('user tier (private per caller)', () => { beforeEach(async () => { await backend.append(salesAlice, { path: 'memory/user/preferences.md', content: 'Alice prefers bullet points and formal tone.', }); await backend.append(salesBob, { path: 'memory/user/preferences.md', content: 'Bob prefers terse one-liners.', }); }); it('each user sees only their own preferences row via get()', async () => { const aliceRead = await backend.get(salesAlice, { path: 'memory/user/preferences.md', }); expect(aliceRead?.text).toContain('Alice prefers'); expect(aliceRead?.text).not.toContain('Bob prefers'); const bobRead = await backend.get(salesBob, { path: 'memory/user/preferences.md', }); expect(bobRead?.text).toContain('Bob prefers'); expect(bobRead?.text).not.toContain('Alice prefers'); }); it('each user sees only their own user-tier rows via search()', async () => { const aliceHits = await backend.search(salesAlice, 'prefers bullet'); expect(aliceHits.length).toBeGreaterThan(0); expect(aliceHits.every((h) => h.content.includes('Alice prefers'))).toBe( true ); expect(aliceHits.some((h) => h.content.includes('Bob prefers'))).toBe( false ); const bobHits = await backend.search(salesBob, 'prefers one-liners'); expect(bobHits.length).toBeGreaterThan(0); expect(bobHits.every((h) => h.content.includes('Bob prefers'))).toBe( true ); }); it('isolated caller (no userId) cannot see any user-tier row', async () => { const hits = await backend.search(salesNoCaller, 'prefers'); // Only agent-tier rows would surface. None exist here, so empty. expect( hits.filter((h) => h.path.startsWith('memory/user/')) ).toHaveLength(0); }); it('two rows exist in storage — one per user, same path', async () => { const prefRows = backend .allRows() .filter((r) => r.path === 'memory/user/preferences.md'); expect(prefRows).toHaveLength(2); const byUser = new Set(prefRows.map((r) => r.userId)); expect(byUser).toEqual(new Set(['alice', 'bob'])); }); it('writing to a user-tier path without userId throws', async () => { await expect( backend.append(salesNoCaller, { path: 'memory/user/profile.md', content: 'nope', }) ).rejects.toThrow(/requires a caller userId/); }); }); describe('path whitelist', () => { it('rejects legacy date-keyed paths', async () => { await expect( backend.append(salesAlice, { path: 'memory/2026-04-14.md', content: 'anything', }) ).rejects.toThrow(/not on the canonical whitelist/); }); it('rejects paths without the memory/ prefix', async () => { await expect( backend.append(salesAlice, { path: 'agent/playbook.md', content: 'anything', }) ).rejects.toThrow(/must start with "memory\//); }); it('rejects invented sibling paths inside memory/agent/', async () => { await expect( backend.append(salesAlice, { path: 'memory/agent/notes.md', content: 'anything', }) ).rejects.toThrow(/not on the canonical whitelist/); }); }); describe('cross-agent isolation', () => { it('two different agents cannot see each other rows — even agent-tier', async () => { await backend.append(salesAlice, { path: 'memory/agent/playbook.md', content: 'Sales playbook entry', }); await backend.append(supportAlice, { path: 'memory/agent/playbook.md', content: 'Support playbook entry', }); const salesRead = await backend.get(salesAlice, { path: 'memory/agent/playbook.md', }); expect(salesRead?.text).toContain('Sales playbook'); expect(salesRead?.text).not.toContain('Support playbook'); const supportRead = await backend.get(supportAlice, { path: 'memory/agent/playbook.md', }); expect(supportRead?.text).toContain('Support playbook'); expect(supportRead?.text).not.toContain('Sales playbook'); }); }); });