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