test(p9): cross-tenant isolation suite (KG-10)
Task T3.3: isolation.spec.ts — two tenants (org_A/org_B) on the same app. Proves tenant B is denied on approve/getArtifact/accept/getMeeting/summarize of A's resources, B's lists (decisions/artifacts/meetings) show none of A's, the denied calls neither mutate A's data nor create a scope for B, and each tenant sees only its own lists. The mandated one-DB tenant-isolation test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { resetDb } from './test-utils/reset-db';
|
||||
import { makeFakePorts, makeFakeInference } from '@insignia/iios-testkit';
|
||||
import { MessageService, type MessagePrincipal } from './messaging/message.service';
|
||||
import { ActorResolver } from './identity/actor.resolver';
|
||||
import { RuleEngine } from './routing/rule-engine';
|
||||
import { RouteSimulator } from './routing/route-simulator';
|
||||
import { RouteService } from './routing/route.service';
|
||||
import { AiJobService } from './ai/ai.service';
|
||||
import { AiBudgetGuard } from './ai/ai-budget.guard';
|
||||
import { CalendarService } from './calendar/calendar.service';
|
||||
import { OutboundService } from './adapters/outbound.service';
|
||||
import { CapabilityBroker } from './capability/capability.broker';
|
||||
import { CapabilityProviderRegistry } from './capability/capability.registry';
|
||||
import type { PrismaService } from './prisma/prisma.service';
|
||||
|
||||
const url = process.env.DATABASE_URL ?? 'postgresql://iios:iios@localhost:5434/iios?schema=public';
|
||||
const prisma = new PrismaClient({ datasources: { db: { url } } });
|
||||
const asService = prisma as unknown as PrismaService;
|
||||
const actors = new ActorResolver(asService);
|
||||
|
||||
// Two tenants on the same app, different orgs → different scopes.
|
||||
const A: MessagePrincipal = { userId: 'a1', orgId: 'org_A', appId: 'portal-demo', displayName: 'A' };
|
||||
const B: MessagePrincipal = { userId: 'b1', orgId: 'org_B', appId: 'portal-demo', displayName: 'B' };
|
||||
const START = '2026-07-02T10:00:00.000Z';
|
||||
|
||||
const ai = () => new AiJobService(asService, makeFakePorts(), makeFakeInference(), new AiBudgetGuard(asService), actors);
|
||||
const outbound = () => new OutboundService(asService, new CapabilityBroker(makeFakePorts(), new CapabilityProviderRegistry()));
|
||||
const routeSvc = () => new RouteService(asService, new RouteSimulator(asService, new RuleEngine()), outbound(), actors);
|
||||
const cal = () => new CalendarService(asService, makeFakePorts(), actors, ai(), outbound());
|
||||
|
||||
async function interactionFor(p: MessagePrincipal): Promise<string> {
|
||||
const m = new MessageService(asService, makeFakePorts(), actors);
|
||||
const { threadId } = await m.openThread(null, p);
|
||||
const msg = await m.send(threadId, p, { content: 'party at 9 PM' }, `k-${randomUUID()}`);
|
||||
return msg.id;
|
||||
}
|
||||
|
||||
beforeAll(async () => { await prisma.$connect(); });
|
||||
afterAll(async () => { await prisma.$disconnect(); });
|
||||
beforeEach(async () => { await resetDb(prisma); });
|
||||
|
||||
describe('Tenant isolation (P9 / KG-10 — cross-tenant blast radius)', () => {
|
||||
it('tenant B cannot read or mutate tenant A resources, and sees none of them', async () => {
|
||||
// ── Seed tenant A: a route decision, an AI artifact, a meeting ──
|
||||
const scopeA = await actors.resolveScope(A);
|
||||
const interactionId = await interactionFor(A);
|
||||
await prisma.iiosRouteBinding.create({
|
||||
data: { scopeId: scopeA.id, originChannelType: 'WHATSAPP', originRef: 'adult-group', destinationChannelType: 'PORTAL', destinationRef: 'adults', requiresReview: true, enabled: true },
|
||||
});
|
||||
const { decisions } = await routeSvc().simulate(interactionId, { channelType: 'WHATSAPP', ref: 'adult-group' });
|
||||
const decisionId = decisions[0]!.id;
|
||||
|
||||
const { artifact } = await ai().runJob({ interactionId, jobType: 'CLASSIFY', principal: A });
|
||||
const artifactId = artifact!.id;
|
||||
|
||||
const meeting = await cal().scheduleDirect(A, { meetingType: 'INTERNAL', title: 'A only', startAt: START });
|
||||
|
||||
// ── Same-tenant control: A can read its own ──
|
||||
expect((await ai().getArtifact(artifactId, A)).id).toBe(artifactId);
|
||||
expect((await cal().getMeeting(meeting.id, A)).id).toBe(meeting.id);
|
||||
|
||||
// ── Cross-tenant DENIED (B owns nothing in A's scope) ──
|
||||
await expect(routeSvc().approve(decisionId, B)).rejects.toThrow(/tenant scope/);
|
||||
await expect(ai().getArtifact(artifactId, B)).rejects.toThrow(/tenant scope/);
|
||||
await expect(ai().accept(artifactId, B)).rejects.toThrow(/tenant scope/);
|
||||
await expect(cal().getMeeting(meeting.id, B)).rejects.toThrow(/tenant scope/);
|
||||
await expect(cal().summarize(meeting.id, B)).rejects.toThrow(/tenant scope/);
|
||||
|
||||
// ── The denied calls neither mutated A's data nor created any scope for B ──
|
||||
expect((await prisma.iiosRouteDecision.findUniqueOrThrow({ where: { id: decisionId } })).decisionState).toBe('REVIEW');
|
||||
expect((await prisma.iiosAiArtifact.findUniqueOrThrow({ where: { id: artifactId } })).status).toBe('PROPOSED');
|
||||
expect(await prisma.iiosScope.findFirst({ where: { orgId: 'org_B' } })).toBeNull();
|
||||
|
||||
// ── B's own lists show NONE of A's data (B is a legit but empty tenant) ──
|
||||
expect(await routeSvc().listDecisions(B)).toHaveLength(0);
|
||||
expect(await ai().listArtifacts(B)).toHaveLength(0);
|
||||
expect(await cal().listMeetings(B)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('each tenant only sees its own lists', async () => {
|
||||
const iA = await interactionFor(A);
|
||||
const iB = await interactionFor(B);
|
||||
await ai().runJob({ interactionId: iA, jobType: 'CLASSIFY', principal: A });
|
||||
await ai().runJob({ interactionId: iB, jobType: 'CLASSIFY', principal: B });
|
||||
await cal().scheduleDirect(A, { meetingType: 'INTERNAL', title: 'A', startAt: START });
|
||||
await cal().scheduleDirect(B, { meetingType: 'INTERNAL', title: 'B', startAt: START });
|
||||
|
||||
expect(await ai().listArtifacts(A)).toHaveLength(1);
|
||||
expect(await ai().listArtifacts(B)).toHaveLength(1);
|
||||
expect((await cal().listMeetings(A)).every((m) => m.title === 'A')).toBe(true);
|
||||
expect((await cal().listMeetings(B)).every((m) => m.title === 'B')).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user