diff --git a/src/test/tenant-isolation.spec.ts b/src/test/tenant-isolation.spec.ts new file mode 100644 index 0000000..555a200 --- /dev/null +++ b/src/test/tenant-isolation.spec.ts @@ -0,0 +1,106 @@ +/** + * Tenant Isolation Integration Tests + * + * Verifies that data written under tenant-A is never readable from tenant-B session. + * Runs against real Drizzle/Postgres via DATABASE_URL env var. + * Skip if DATABASE_URL is not set (CI without DB). + */ +import { eq, and, isNull } from 'drizzle-orm'; +import { db } from '../db/client'; +import { goals, organizations, tasks } from '../db/schema'; + +const HAS_DB = !!process.env.DATABASE_URL; + +const TENANT_A = '00000000-0000-0000-0000-000000000a01'; +const TENANT_B = '00000000-0000-0000-0000-000000000b02'; +const WS_A = '00000000-0000-0000-0000-000000000a03'; +const WS_B = '00000000-0000-0000-0000-000000000b04'; +const USER_A = '00000000-0000-0000-0000-000000000a05'; +const USER_B = '00000000-0000-0000-0000-000000000b05'; + +(HAS_DB ? describe : describe.skip)('Tenant Isolation', () => { + let goalIdA: string; + let orgIdA: string; + + afterAll(async () => { + // Cleanup test data + if (goalIdA) await db.delete(goals).where(eq(goals.id, goalIdA)); + if (orgIdA) await db.delete(organizations).where(eq(organizations.id, orgIdA)); + }); + + describe('Goals', () => { + it('tenant-A can create a goal', async () => { + // Arrange + Act + const [row] = await db + .insert(goals) + .values({ + tenantId: TENANT_A, + ownerUserId: USER_A, + horizon: 'Q4 2026', + metric: 'MRR', + target: '100k', + milestones: [] as unknown[], + }) + .returning(); + goalIdA = row.id; + + // Assert + expect(row.tenantId).toBe(TENANT_A); + expect(row.id).toBeTruthy(); + }); + + it('tenant-B cannot see tenant-A goals', async () => { + // Act + const results = await db.query.goals.findMany({ + where: and(eq(goals.tenantId, TENANT_B), isNull(goals.deletedAt)), + }); + + // Assert — no tenant-A goal leaks into tenant-B query + const leaked = results.filter((g) => g.tenantId === TENANT_A); + expect(leaked).toHaveLength(0); + }); + + it('tenant-A can see their own goal by id', async () => { + // Act + const row = await db.query.goals.findFirst({ + where: and(eq(goals.id, goalIdA), eq(goals.tenantId, TENANT_A), isNull(goals.deletedAt)), + }); + + // Assert + expect(row).toBeDefined(); + expect(row!.tenantId).toBe(TENANT_A); + }); + + it('tenant-B cannot fetch tenant-A goal by id', async () => { + // Act — tenant-B tries to access tenant-A goal with TENANT_B filter + const row = await db.query.goals.findFirst({ + where: and(eq(goals.id, goalIdA), eq(goals.tenantId, TENANT_B)), + }); + + // Assert — should return undefined (not found) + expect(row).toBeUndefined(); + }); + }); + + describe('Organizations', () => { + it('tenant-A creates organization invisible to tenant-B', async () => { + // Arrange + Act + const [org] = await db + .insert(organizations) + .values({ + tenantId: TENANT_A, + name: 'Tenant A Corp', + country: 'RO', + }) + .returning(); + orgIdA = org.id; + + // tenant-B query + const tenantBOrgs = await db.query.organizations.findMany({ + where: and(eq(organizations.tenantId, TENANT_B), isNull(organizations.deletedAt)), + }); + + expect(tenantBOrgs.find((o) => o.id === orgIdA)).toBeUndefined(); + }); + }); +});