From d002c8f5218026fe11560d3c0a180e1c53cfa75f Mon Sep 17 00:00:00 2001 From: maaz519 Date: Thu, 2 Jul 2026 21:53:03 +0530 Subject: [PATCH] test(p9): cross-tenant isolation suite (KG-10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- packages/iios-service/src/isolation.spec.ts | 96 +++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 packages/iios-service/src/isolation.spec.ts diff --git a/packages/iios-service/src/isolation.spec.ts b/packages/iios-service/src/isolation.spec.ts new file mode 100644 index 0000000..a31f8a9 --- /dev/null +++ b/packages/iios-service/src/isolation.spec.ts @@ -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 { + 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); + }); +});