import { randomUUID } from 'node:crypto'; import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; import { PrismaClient, IiosRouteMode } from '@prisma/client'; import { resetDb } from '../test-utils/reset-db'; import { makeFakePorts } from '@insignia/iios-testkit'; import { MessageService, type MessagePrincipal } from '../messaging/message.service'; import { ActorResolver } from '../identity/actor.resolver'; import { RuleEngine } from './rule-engine'; import { RouteSimulator } from './route-simulator'; import { RouteService } from './route.service'; import { RouteProjector } from './route.projector'; import { IngestService } from '../interactions/ingest.service'; import { OutboundService } from '../adapters/outbound.service'; import { CapabilityBroker } from '../capability/capability.broker'; import { CapabilityProviderRegistry } from '../capability/capability.registry'; import { OutboxBus } from '../outbox/outbox.bus'; import { IIOS_EVENTS, type CloudEvent } from '@insignia/iios-contracts'; 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); const sim = () => new RouteSimulator(asService, new RuleEngine()); const sender: MessagePrincipal = { userId: 'wa-user', orgId: 'org_demo', appId: 'portal-demo', displayName: 'WA User' }; const ORIGIN = { channelType: 'WHATSAPP', ref: 'adult-group' }; async function makeInteraction(text: string): Promise<{ interactionId: string; scopeId: string }> { const m = new MessageService(asService, makeFakePorts(), actors); const { threadId } = await m.openThread(null, sender); const msg = await m.send(threadId, sender, { content: text }, `k-${randomUUID()}`); const interaction = await prisma.iiosInteraction.findUniqueOrThrow({ where: { id: msg.id } }); return { interactionId: msg.id, scopeId: interaction.scopeId }; } async function makeBinding( scopeId: string, destinationRef: string, restrictionProfile?: string, opts?: { requiresReview?: boolean; mode?: IiosRouteMode }, ) { return prisma.iiosRouteBinding.create({ data: { scopeId, originChannelType: ORIGIN.channelType, originRef: ORIGIN.ref, destinationChannelType: 'PORTAL', destinationRef, restrictionProfile, requiresReview: opts?.requiresReview ?? true, mode: opts?.mode ?? 'MANUAL', enabled: true, }, }); } async function stateFor(interactionId: string, destinationRef: string): Promise { const b = await prisma.iiosRouteBinding.findFirstOrThrow({ where: { destinationRef } }); const d = await prisma.iiosRouteDecision.findUniqueOrThrow({ where: { interactionId_routeBindingId: { interactionId, routeBindingId: b.id } }, }); return d.decisionState; } beforeAll(async () => { await prisma.$connect(); }); afterAll(async () => { await prisma.$disconnect(); }); beforeEach(async () => { await resetDb(prisma); }); describe('Route simulator (P6, preview-first)', () => { it('"party at 9 PM" → children DENY, adult+seniors REVIEW, and NO send', async () => { const { interactionId, scopeId } = await makeInteraction('party at 9 PM'); await makeBinding(scopeId, 'adult', 'ADULT'); await makeBinding(scopeId, 'seniors', 'SENIORS'); await makeBinding(scopeId, 'children', 'CHILD'); await sim().simulate(interactionId, ORIGIN); expect(await stateFor(interactionId, 'children')).toBe('DENY'); expect(await stateFor(interactionId, 'adult')).toBe('REVIEW'); expect(await stateFor(interactionId, 'seniors')).toBe('REVIEW'); expect(await prisma.iiosOutboundCommand.count()).toBe(0); // simulation never sends }); it('safe message + requiresReview:false → ALLOW', async () => { const { interactionId, scopeId } = await makeInteraction('hello team, meeting notes attached'); await makeBinding(scopeId, 'general', undefined, { requiresReview: false }); await sim().simulate(interactionId, ORIGIN); expect(await stateFor(interactionId, 'general')).toBe('ALLOW'); }); it('replay: simulating twice yields identical decisions (deterministic)', async () => { const { interactionId, scopeId } = await makeInteraction('party at 9 PM'); await makeBinding(scopeId, 'children', 'CHILD'); const first = await sim().simulate(interactionId, ORIGIN); const second = await sim().simulate(interactionId, ORIGIN); expect(await prisma.iiosRouteDecision.count()).toBe(1); // upsert, not duplicated expect(first.decisions[0]?.decisionState).toBe(second.decisions[0]?.decisionState); expect(first.decisions[0]?.reasonCodes).toEqual(second.decisions[0]?.reasonCodes); }); }); describe('RouteService approve/deny → execute (P6)', () => { const admin: MessagePrincipal = { userId: 'admin', orgId: 'org_demo', appId: 'portal-demo', displayName: 'Admin' }; const svc = () => new RouteService(asService, new RouteSimulator(asService, new RuleEngine()), new OutboundService(asService, new CapabilityBroker(makeFakePorts(), new CapabilityProviderRegistry())), actors); it('approving a REVIEW decision executes the forward to the sandbox', async () => { const { interactionId, scopeId } = await makeInteraction('party at 9 PM'); await makeBinding(scopeId, 'seniors', 'SENIORS'); const service = svc(); await service.simulate(interactionId, ORIGIN); const b = await prisma.iiosRouteBinding.findFirstOrThrow({ where: { destinationRef: 'seniors' } }); const decision = await prisma.iiosRouteDecision.findUniqueOrThrow({ where: { interactionId_routeBindingId: { interactionId, routeBindingId: b.id } }, }); expect(decision.decisionState).toBe('REVIEW'); const approved = await service.approve(decision.id, admin); expect(approved.decisionState).toBe('ALLOW'); expect(approved.executed).toBe(true); expect(await prisma.iiosOutboundCommand.count()).toBe(1); // dispatched to sandbox }); it('tenant fence: another tenant cannot approve this tenant’s decision (KG-02)', async () => { const { interactionId, scopeId } = await makeInteraction('party at 9 PM'); await makeBinding(scopeId, 'seniors', 'SENIORS'); const service = svc(); await service.simulate(interactionId, ORIGIN); const b = await prisma.iiosRouteBinding.findFirstOrThrow({ where: { destinationRef: 'seniors' } }); const decision = await prisma.iiosRouteDecision.findUniqueOrThrow({ where: { interactionId_routeBindingId: { interactionId, routeBindingId: b.id } }, }); const tenantB: MessagePrincipal = { userId: 'intruder', orgId: 'org_other', appId: 'portal-demo', displayName: 'B' }; await expect(service.approve(decision.id, tenantB)).rejects.toThrow(/tenant scope/); const after = await prisma.iiosRouteDecision.findUniqueOrThrow({ where: { id: decision.id } }); expect(after.decisionState).toBe('REVIEW'); // unchanged expect(await prisma.iiosScope.findFirst({ where: { orgId: 'org_other' } })).toBeNull(); // no scope created for B // B's decision list is empty (scoped to caller) expect(await service.listDecisions(tenantB)).toHaveLength(0); }); it('denying a decision never executes', async () => { const { interactionId, scopeId } = await makeInteraction('party at 9 PM'); await makeBinding(scopeId, 'children', 'CHILD'); const service = svc(); await service.simulate(interactionId, ORIGIN); const b = await prisma.iiosRouteBinding.findFirstOrThrow({ where: { destinationRef: 'children' } }); const decision = await prisma.iiosRouteDecision.findUniqueOrThrow({ where: { interactionId_routeBindingId: { interactionId, routeBindingId: b.id } }, }); // already DENY; approving is rejected, and nothing is sent await expect(service.approve(decision.id, admin)).rejects.toThrow(); expect(await prisma.iiosOutboundCommand.count()).toBe(0); }); }); describe('RouteProjector — AUTOMATIC forwarding (P6)', () => { const routeSvc = () => new RouteService(asService, new RouteSimulator(asService, new RuleEngine()), new OutboundService(asService, new CapabilityBroker(makeFakePorts(), new CapabilityProviderRegistry())), actors); const projector = () => new RouteProjector(asService, new OutboxBus(), routeSvc()); // Ingest an inbound (channel-bearing) interaction — routable, unlike native DMs. async function ingestInbound(text: string): Promise<{ interactionId: string; scopeId: string; event: CloudEvent }> { const ingest = new IngestService(asService, makeFakePorts(), actors); const res = await ingest.ingest( { scope: { orgId: 'org_demo', appId: 'portal-demo' }, channel: { type: 'WEBHOOK', externalChannelId: 'webhook' }, source: { handleKind: 'EXTERNAL_VISITOR', externalId: 'ext-1' }, kind: 'MESSAGE', thread: { externalThreadId: `wh-${randomUUID().slice(0, 6)}` }, parts: [{ kind: 'TEXT', bodyText: text }], occurredAt: new Date().toISOString(), providerEventId: `pe-${randomUUID()}`, }, `ing-${randomUUID()}`, ); const interaction = await prisma.iiosInteraction.findUniqueOrThrow({ where: { id: res.interactionId } }); const ev = (await prisma.iiosOutboxEvent.findFirstOrThrow({ where: { eventType: IIOS_EVENTS.interactionNormalized, aggregateId: res.interactionId }, })).cloudEvent as unknown as CloudEvent; return { interactionId: res.interactionId, scopeId: interaction.scopeId, event: ev }; } async function automaticBinding(scopeId: string, restriction?: string) { return prisma.iiosRouteBinding.create({ data: { scopeId, originChannelType: 'WEBHOOK', originRef: 'webhook', destinationChannelType: 'PORTAL', destinationRef: 'community', restrictionProfile: restriction, mode: 'AUTOMATIC', requiresReview: false, enabled: true, }, }); } it('AUTOMATIC + safe → auto-forwards to sandbox', async () => { const { scopeId, event } = await ingestInbound('good morning everyone'); await automaticBinding(scopeId); await projector().onNormalized(event); expect(await prisma.iiosOutboundCommand.count()).toBe(1); }); it('AUTOMATIC but flagged+protected → NOT forwarded (deny)', async () => { const { scopeId, event } = await ingestInbound('party at 9 PM'); await automaticBinding(scopeId, 'CHILD'); await projector().onNormalized(event); expect(await prisma.iiosOutboundCommand.count()).toBe(0); }); it('idempotent: replaying the normalized event forwards once', async () => { const { scopeId, event } = await ingestInbound('hello team'); await automaticBinding(scopeId); const p = projector(); await p.onNormalized(event); await p.onNormalized(event); expect(await prisma.iiosOutboundCommand.count()).toBe(1); }); });