diff --git a/apps/gateway/src/__tests__/cross-user-isolation.test.ts b/apps/gateway/src/__tests__/cross-user-isolation.test.ts new file mode 100644 index 0000000..040e0de --- /dev/null +++ b/apps/gateway/src/__tests__/cross-user-isolation.test.ts @@ -0,0 +1,470 @@ +/** + * 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, describe, expect, it } from 'vitest'; +import { createDb } from '@mosaic/db'; +import { createConversationsRepo } from '@mosaic/brain'; +import { createAgentsRepo } from '@mosaic/brain'; +import { createPreferencesRepo, createInsightsRepo } from '@mosaic/memory'; +import { users, conversations, messages, agents, preferences, insights } from '@mosaic/db'; +import { eq } from '@mosaic/db'; +import type { DbHandle } from '@mosaic/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; + +beforeAll(async () => { + 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(); +}); + +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(); + }); +});