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 { IdempotencyService } from './idempotency/idempotency.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()), new IdempotencyService(asService)); 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); expect(scopeA.cellId).toBeTruthy(); // new scopes carry a cell assignment (P9 cell hook) 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); }); });