/** * Integration test: Cross-user data isolation (M2-007) * * Verifies that every repository query path is scoped to the requesting user — * no user can read, write, or enumerate another user's records. * * Test strategy: * - Two real users (User A, User B) are inserted directly into the database. * - Realistic data (conversations + messages, agent configs, preferences, * insights) is created for each user. * - A shared system agent is inserted so both users can see it via * findAccessible(). * - All assertions are made against the live database (no mocks). * - All inserted rows are cleaned up in the afterAll hook. * * Requires: DATABASE_URL pointing at a running PostgreSQL instance with * pgvector enabled and the Mosaic schema already applied. */ import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; import { createDb } from '@mosaicstack/db'; import { createConversationsRepo } from '@mosaicstack/brain'; import { createAgentsRepo } from '@mosaicstack/brain'; import { createPreferencesRepo, createInsightsRepo } from '@mosaicstack/memory'; import { users, conversations, messages, agents, preferences, insights } from '@mosaicstack/db'; import { eq } from '@mosaicstack/db'; import type { DbHandle } from '@mosaicstack/db'; // ─── Fixed IDs so the afterAll cleanup is deterministic ────────────────────── const USER_A_ID = 'test-iso-user-a'; const USER_B_ID = 'test-iso-user-b'; const CONV_A_ID = 'aaaaaaaa-0000-0000-0000-000000000001'; const CONV_B_ID = 'bbbbbbbb-0000-0000-0000-000000000001'; const MSG_A_ID = 'aaaaaaaa-0000-0000-0000-000000000002'; const MSG_B_ID = 'bbbbbbbb-0000-0000-0000-000000000002'; const AGENT_A_ID = 'aaaaaaaa-0000-0000-0000-000000000003'; const AGENT_B_ID = 'bbbbbbbb-0000-0000-0000-000000000003'; const AGENT_SYS_ID = 'ffffffff-0000-0000-0000-000000000001'; const PREF_A_ID = 'aaaaaaaa-0000-0000-0000-000000000004'; const PREF_B_ID = 'bbbbbbbb-0000-0000-0000-000000000004'; const INSIGHT_A_ID = 'aaaaaaaa-0000-0000-0000-000000000005'; const INSIGHT_B_ID = 'bbbbbbbb-0000-0000-0000-000000000005'; // ─── Test fixture ───────────────────────────────────────────────────────────── let handle: DbHandle; let dbAvailable = false; beforeAll(async () => { try { handle = createDb(); const db = handle.db; // Insert two users await db .insert(users) .values([ { id: USER_A_ID, name: 'Isolation Test User A', email: 'test-iso-user-a@example.invalid', emailVerified: false, }, { id: USER_B_ID, name: 'Isolation Test User B', email: 'test-iso-user-b@example.invalid', emailVerified: false, }, ]) .onConflictDoNothing(); // Conversations — one per user await db .insert(conversations) .values([ { id: CONV_A_ID, userId: USER_A_ID, title: 'User A conversation' }, { id: CONV_B_ID, userId: USER_B_ID, title: 'User B conversation' }, ]) .onConflictDoNothing(); // Messages — one per conversation await db .insert(messages) .values([ { id: MSG_A_ID, conversationId: CONV_A_ID, role: 'user', content: 'Hello from User A', }, { id: MSG_B_ID, conversationId: CONV_B_ID, role: 'user', content: 'Hello from User B', }, ]) .onConflictDoNothing(); // Agent configs — private agents (one per user) + one system agent await db .insert(agents) .values([ { id: AGENT_A_ID, name: 'Agent A (private)', provider: 'test', model: 'test-model', ownerId: USER_A_ID, isSystem: false, }, { id: AGENT_B_ID, name: 'Agent B (private)', provider: 'test', model: 'test-model', ownerId: USER_B_ID, isSystem: false, }, { id: AGENT_SYS_ID, name: 'Shared System Agent', provider: 'test', model: 'test-model', ownerId: null, isSystem: true, }, ]) .onConflictDoNothing(); // Preferences — one per user (same key, different values) await db .insert(preferences) .values([ { id: PREF_A_ID, userId: USER_A_ID, key: 'theme', value: 'dark', category: 'appearance', }, { id: PREF_B_ID, userId: USER_B_ID, key: 'theme', value: 'light', category: 'appearance', }, ]) .onConflictDoNothing(); // Insights — no embedding to keep the fixture simple; embedding-based search // is tested separately with a zero-vector that falls outside maxDistance await db .insert(insights) .values([ { id: INSIGHT_A_ID, userId: USER_A_ID, content: 'User A insight', source: 'user', category: 'general', relevanceScore: 1.0, }, { id: INSIGHT_B_ID, userId: USER_B_ID, content: 'User B insight', source: 'user', category: 'general', relevanceScore: 1.0, }, ]) .onConflictDoNothing(); dbAvailable = true; } catch { // Database is not reachable (e.g., CI environment without Postgres on port 5433). // All tests in this suite will be skipped. } }); // Skip all tests in this file when the database is not reachable (e.g., CI without Postgres). beforeEach((ctx) => { if (!dbAvailable) { ctx.skip(); } }); afterAll(async () => { if (!handle) return; const db = handle.db; // Delete in dependency order (FK constraints) await db.delete(messages).where(eq(messages.id, MSG_A_ID)); await db.delete(messages).where(eq(messages.id, MSG_B_ID)); await db.delete(conversations).where(eq(conversations.id, CONV_A_ID)); await db.delete(conversations).where(eq(conversations.id, CONV_B_ID)); await db.delete(agents).where(eq(agents.id, AGENT_A_ID)); await db.delete(agents).where(eq(agents.id, AGENT_B_ID)); await db.delete(agents).where(eq(agents.id, AGENT_SYS_ID)); await db.delete(preferences).where(eq(preferences.id, PREF_A_ID)); await db.delete(preferences).where(eq(preferences.id, PREF_B_ID)); await db.delete(insights).where(eq(insights.id, INSIGHT_A_ID)); await db.delete(insights).where(eq(insights.id, INSIGHT_B_ID)); await db.delete(users).where(eq(users.id, USER_A_ID)); await db.delete(users).where(eq(users.id, USER_B_ID)); await handle.close(); }); // ─── Conversations isolation ────────────────────────────────────────────────── describe('ConversationsRepo — cross-user isolation', () => { it('User A can find their own conversation by id', async () => { const repo = createConversationsRepo(handle.db); const conv = await repo.findById(CONV_A_ID, USER_A_ID); expect(conv).toBeDefined(); expect(conv!.id).toBe(CONV_A_ID); }); it('User B cannot find User A conversation by id (returns undefined)', async () => { const repo = createConversationsRepo(handle.db); const conv = await repo.findById(CONV_A_ID, USER_B_ID); expect(conv).toBeUndefined(); }); it('User A cannot find User B conversation by id (returns undefined)', async () => { const repo = createConversationsRepo(handle.db); const conv = await repo.findById(CONV_B_ID, USER_A_ID); expect(conv).toBeUndefined(); }); it('findAll returns only own conversations for User A', async () => { const repo = createConversationsRepo(handle.db); const convs = await repo.findAll(USER_A_ID); const ids = convs.map((c) => c.id); expect(ids).toContain(CONV_A_ID); expect(ids).not.toContain(CONV_B_ID); }); it('findAll returns only own conversations for User B', async () => { const repo = createConversationsRepo(handle.db); const convs = await repo.findAll(USER_B_ID); const ids = convs.map((c) => c.id); expect(ids).toContain(CONV_B_ID); expect(ids).not.toContain(CONV_A_ID); }); }); // ─── Messages isolation ─────────────────────────────────────────────────────── describe('ConversationsRepo.findMessages — cross-user isolation', () => { it('User A can read messages from their own conversation', async () => { const repo = createConversationsRepo(handle.db); const msgs = await repo.findMessages(CONV_A_ID, USER_A_ID); const ids = msgs.map((m) => m.id); expect(ids).toContain(MSG_A_ID); }); it('User B cannot read messages from User A conversation (returns empty array)', async () => { const repo = createConversationsRepo(handle.db); const msgs = await repo.findMessages(CONV_A_ID, USER_B_ID); expect(msgs).toHaveLength(0); }); it('User A cannot read messages from User B conversation (returns empty array)', async () => { const repo = createConversationsRepo(handle.db); const msgs = await repo.findMessages(CONV_B_ID, USER_A_ID); expect(msgs).toHaveLength(0); }); it('addMessage is rejected when user does not own the conversation', async () => { const repo = createConversationsRepo(handle.db); const result = await repo.addMessage( { conversationId: CONV_A_ID, role: 'user', content: 'Attempted injection by User B', }, USER_B_ID, ); expect(result).toBeUndefined(); }); }); // ─── Agent configs isolation ────────────────────────────────────────────────── describe('AgentsRepo.findAccessible — cross-user isolation', () => { it('User A sees their own private agent', async () => { const repo = createAgentsRepo(handle.db); const accessible = await repo.findAccessible(USER_A_ID); const ids = accessible.map((a) => a.id); expect(ids).toContain(AGENT_A_ID); }); it('User A does NOT see User B private agent', async () => { const repo = createAgentsRepo(handle.db); const accessible = await repo.findAccessible(USER_A_ID); const ids = accessible.map((a) => a.id); expect(ids).not.toContain(AGENT_B_ID); }); it('User B does NOT see User A private agent', async () => { const repo = createAgentsRepo(handle.db); const accessible = await repo.findAccessible(USER_B_ID); const ids = accessible.map((a) => a.id); expect(ids).not.toContain(AGENT_A_ID); }); it('Both users can see the shared system agent', async () => { const repo = createAgentsRepo(handle.db); const accessibleA = await repo.findAccessible(USER_A_ID); const accessibleB = await repo.findAccessible(USER_B_ID); expect(accessibleA.map((a) => a.id)).toContain(AGENT_SYS_ID); expect(accessibleB.map((a) => a.id)).toContain(AGENT_SYS_ID); }); it('findSystem returns the system agent for any caller', async () => { const repo = createAgentsRepo(handle.db); const system = await repo.findSystem(); const ids = system.map((a) => a.id); expect(ids).toContain(AGENT_SYS_ID); }); it('update with ownerId prevents User B from modifying User A agent', async () => { const repo = createAgentsRepo(handle.db); const result = await repo.update(AGENT_A_ID, { model: 'hacked' }, USER_B_ID); expect(result).toBeUndefined(); // Verify the agent was not actually mutated const unchanged = await repo.findById(AGENT_A_ID); expect(unchanged?.model).toBe('test-model'); }); it('remove prevents User B from deleting User A agent', async () => { const repo = createAgentsRepo(handle.db); const deleted = await repo.remove(AGENT_A_ID, USER_B_ID); expect(deleted).toBe(false); // Verify the agent still exists const still = await repo.findById(AGENT_A_ID); expect(still).toBeDefined(); }); }); // ─── Preferences isolation ──────────────────────────────────────────────────── describe('PreferencesRepo — cross-user isolation', () => { it('User A can retrieve their own preferences', async () => { const repo = createPreferencesRepo(handle.db); const prefs = await repo.findByUser(USER_A_ID); const ids = prefs.map((p) => p.id); expect(ids).toContain(PREF_A_ID); }); it('User A preferences do not contain User B preferences', async () => { const repo = createPreferencesRepo(handle.db); const prefs = await repo.findByUser(USER_A_ID); const ids = prefs.map((p) => p.id); expect(ids).not.toContain(PREF_B_ID); }); it('User B preferences do not contain User A preferences', async () => { const repo = createPreferencesRepo(handle.db); const prefs = await repo.findByUser(USER_B_ID); const ids = prefs.map((p) => p.id); expect(ids).not.toContain(PREF_A_ID); }); it('findByUserAndKey is scoped to the requesting user', async () => { const repo = createPreferencesRepo(handle.db); // Both users have key "theme" — each should only see their own value const prefA = await repo.findByUserAndKey(USER_A_ID, 'theme'); const prefB = await repo.findByUserAndKey(USER_B_ID, 'theme'); expect(prefA).toBeDefined(); // Drizzle returns JSONB values as parsed JS values; '"dark"' (JSON string) → 'dark' expect(prefA!.value).toBe('dark'); expect(prefB).toBeDefined(); expect(prefB!.value).toBe('light'); }); it('remove is scoped to the requesting user (cannot delete another user pref)', async () => { const repo = createPreferencesRepo(handle.db); // User B tries to delete User A's "theme" preference — should silently fail const deleted = await repo.remove(USER_B_ID, 'theme'); // This only deletes USER_B's own "theme" row; re-insert it for afterAll cleanup expect(deleted).toBe(true); // deletes User B's OWN theme pref // User A's theme pref must be untouched const prefA = await repo.findByUserAndKey(USER_A_ID, 'theme'); expect(prefA).toBeDefined(); // Re-insert User B's preference so afterAll cleanup still finds it await repo.upsert({ id: PREF_B_ID, userId: USER_B_ID, key: 'theme', value: 'light', category: 'appearance', }); }); }); // ─── Insights isolation ─────────────────────────────────────────────────────── describe('InsightsRepo — cross-user isolation', () => { it('User A can retrieve their own insights', async () => { const repo = createInsightsRepo(handle.db); const list = await repo.findByUser(USER_A_ID); const ids = list.map((i) => i.id); expect(ids).toContain(INSIGHT_A_ID); }); it('User A insights do not contain User B insights', async () => { const repo = createInsightsRepo(handle.db); const list = await repo.findByUser(USER_A_ID); const ids = list.map((i) => i.id); expect(ids).not.toContain(INSIGHT_B_ID); }); it('User B insights do not contain User A insights', async () => { const repo = createInsightsRepo(handle.db); const list = await repo.findByUser(USER_B_ID); const ids = list.map((i) => i.id); expect(ids).not.toContain(INSIGHT_A_ID); }); it('findById is scoped to the requesting user', async () => { const repo = createInsightsRepo(handle.db); const own = await repo.findById(INSIGHT_A_ID, USER_A_ID); const cross = await repo.findById(INSIGHT_A_ID, USER_B_ID); expect(own).toBeDefined(); expect(cross).toBeUndefined(); }); it('searchByEmbedding returns only own insights', async () => { const repo = createInsightsRepo(handle.db); // Our test insights have no embedding — the query filters WHERE embedding IS NOT NULL // so the result set is empty, which already proves no cross-user leakage. // Using a 1536-dimension zero vector as the query embedding. const zeroVector = Array(1536).fill(0); const resultsA = await repo.searchByEmbedding(USER_A_ID, zeroVector, 50, 2.0); const resultsB = await repo.searchByEmbedding(USER_B_ID, zeroVector, 50, 2.0); // The raw SQL query returns row objects directly (not wrapped in { insight }). // Cast via unknown to extract id safely regardless of the return shape. const toId = (r: unknown): string => ((r as Record)['id'] as string | undefined) ?? ((r as Record>)['insight']?.['id'] as string | undefined) ?? ''; const idsInA = resultsA.map(toId); const idsInB = resultsB.map(toId); // User B's insight must never appear in User A's search results expect(idsInA).not.toContain(INSIGHT_B_ID); // User A's insight must never appear in User B's search results expect(idsInB).not.toContain(INSIGHT_A_ID); }); it('update is scoped to the requesting user', async () => { const repo = createInsightsRepo(handle.db); const result = await repo.update(INSIGHT_A_ID, USER_B_ID, { content: 'hacked' }); expect(result).toBeUndefined(); // Verify the insight was not mutated const unchanged = await repo.findById(INSIGHT_A_ID, USER_A_ID); expect(unchanged?.content).toBe('User A insight'); }); it('remove is scoped to the requesting user', async () => { const repo = createInsightsRepo(handle.db); const deleted = await repo.remove(INSIGHT_A_ID, USER_B_ID); expect(deleted).toBe(false); // Verify the insight still exists const still = await repo.findById(INSIGHT_A_ID, USER_A_ID); expect(still).toBeDefined(); }); });