063049b724
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>
226 lines
11 KiB
TypeScript
226 lines
11 KiB
TypeScript
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 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);
|
||
});
|
||
});
|