- Updated all package.json name fields and dependency references - Updated all TypeScript/JavaScript imports - Updated .woodpecker/publish.yml filters and registry paths - Updated tools/install.sh scope default - Updated .npmrc registry paths (worktree + host) - Enhanced update-checker.ts with checkForAllUpdates() multi-package support - Updated CLI update command to show table of all packages - Added KNOWN_PACKAGES, formatAllPackagesTable, getInstallAllCommand - Marked checkForUpdate() with @deprecated JSDoc Closes #391
486 lines
18 KiB
TypeScript
486 lines
18 KiB
TypeScript
/**
|
|
* 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<number>(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<string, unknown>)['id'] as string | undefined) ??
|
|
((r as Record<string, Record<string, unknown>>)['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();
|
|
});
|
|
});
|