Files
iios/packages/iios-service/src/isolation.spec.ts
T
maaz519 a933e2e3af feat(iios): idempotency ledger on outbound egress (409 on key reuse) (P9)
OutboundService.send is now opt-in idempotent: an explicit idempotencyKey routes
the send through IdempotencyService.run (Slice 6), so reusing a key with a
different target/payload returns a 409 conflict and a same-key/same-request retry
replays the cached command. The inner findUnique early-return stays as a backstop
so a FAILED-then-retried send never collides on the command's unique key. Keyless
sends keep the old behavior (no ledger row).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 18:44:22 +05:30

99 lines
5.8 KiB
TypeScript

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<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);
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);
});
});