Files
iios/packages/iios-service/src/routing/route.spec.ts
T
maaz519 063049b724 feat(p9): scope-ownership assert + fence routing approve/deny/listDecisions
Task T3.1: ActorResolver gains findScope (no-create) + assertOwns(principal,
resourceScopeId) → ForbiddenException on mismatch (KG-02). RouteService.approve/
deny now assertOwns on the decision's binding scope before mutating; listDecisions
is scoped to the caller. Fixes the critical cross-tenant mutation (tenant A could
approve tenant B's route). Also adds IiosScope.cellId + IiosOutboundCommand.scopeId
columns (migration tenant-isolation) + cell assignment on scope create. Test:
another tenant approving → Forbidden, decision unchanged, no scope created, empty list.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 21:46:40 +05:30

226 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string> {
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 tenants 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);
});
});