test(integration): tenant isolation tests for goals and organizations
This commit is contained in:
parent
c3b0005e19
commit
458217a0ec
1 changed files with 106 additions and 0 deletions
106
src/test/tenant-isolation.spec.ts
Normal file
106
src/test/tenant-isolation.spec.ts
Normal file
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue