f0ea1ca553
CI / build (push) Failing after 2m8s
Adds adapter-owned externalEventId extraction to the ChannelAdapter contract (WebhookAdapter→eventId, EmailAdapter→Message-ID) and uses it in InboundService, so email replays dedup on Message-ID (previously only payload.eventId worked). Adds an email-dedup spec + smoke-email.mjs proving inbound → normalized, a reply joins the same email thread, bad signature rejected, and outbound EMAIL SENT. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
217 lines
11 KiB
TypeScript
217 lines
11 KiB
TypeScript
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
|
import { ConflictException } from '@nestjs/common';
|
|
import { PrismaClient } from '@prisma/client';
|
|
import { DlqService } from '../outbox/dlq.service';
|
|
import { resetDb } from '../test-utils/reset-db';
|
|
import { ProjectionCursorService } from '../projection/projection-cursor.service';
|
|
import { makeFakePorts } from '@insignia/iios-testkit';
|
|
import { IIOS_EVENTS, type CloudEvent } from '@insignia/iios-contracts';
|
|
import { signedFixture, emailFixture, emailInboundFixture, emailReplyFixture } from '@insignia/iios-adapter-sdk';
|
|
import { AdapterRegistry } from './adapter.registry';
|
|
import { InboundService } from './inbound.service';
|
|
import { OutboundService } from './outbound.service';
|
|
import { IdempotencyService } from '../idempotency/idempotency.service';
|
|
import { CapabilityBroker } from '../capability/capability.broker';
|
|
import { CapabilityProviderRegistry } from '../capability/capability.registry';
|
|
import { RawEventProjector } from './raw-event.projector';
|
|
import { IngestService } from '../interactions/ingest.service';
|
|
import { ActorResolver } from '../identity/actor.resolver';
|
|
import { OutboxBus } from '../outbox/outbox.bus';
|
|
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 SECRET = 'dev-adapter-secret'; // AdapterRegistry default
|
|
|
|
const registry = () => new AdapterRegistry();
|
|
const inbound = () => new InboundService(asService, registry());
|
|
const projector = () =>
|
|
new RawEventProjector(asService, new OutboxBus(), new DlqService(asService), registry(), new IngestService(asService, makeFakePorts(), new ActorResolver(asService)), new ProjectionCursorService(asService));
|
|
|
|
async function rawReceivedEvents(): Promise<CloudEvent[]> {
|
|
const rows = await prisma.iiosOutboxEvent.findMany({ where: { eventType: IIOS_EVENTS.rawReceived }, orderBy: { createdAt: 'asc' } });
|
|
return rows.map((r) => r.cloudEvent as unknown as CloudEvent);
|
|
}
|
|
|
|
beforeAll(async () => { await prisma.$connect(); });
|
|
afterAll(async () => { await prisma.$disconnect(); });
|
|
beforeEach(async () => { await resetDb(prisma); });
|
|
|
|
describe('Adapter inbound (P5)', () => {
|
|
it('signed webhook → raw stored, then projector normalizes → interaction with the text + traceId', async () => {
|
|
const { body, headers } = signedFixture(emailFixture, SECRET);
|
|
const ack = await inbound().receive('WEBHOOK', body, headers);
|
|
expect(ack.status).toBe('RECEIVED');
|
|
|
|
const raw = await prisma.iiosInboundRawEvent.findUniqueOrThrow({ where: { id: ack.rawEventId } });
|
|
expect(raw.signatureStatus).toBe('VERIFIED');
|
|
|
|
await projector().onRawReceived((await rawReceivedEvents())[0]!);
|
|
|
|
const after = await prisma.iiosInboundRawEvent.findUniqueOrThrow({ where: { id: ack.rawEventId } });
|
|
expect(after.status).toBe('NORMALIZED');
|
|
expect(after.interactionId).toBeTruthy();
|
|
|
|
const interaction = await prisma.iiosInteraction.findUniqueOrThrow({
|
|
where: { id: after.interactionId! },
|
|
include: { parts: true },
|
|
});
|
|
expect(interaction.parts[0]?.bodyText).toBe(emailFixture.text);
|
|
expect(interaction.traceId).toBe(raw.traceId); // trace flows raw → interaction
|
|
});
|
|
|
|
it('bad signature → REJECTED, nothing ingested (fail-closed)', async () => {
|
|
const { body } = signedFixture(emailFixture, SECRET);
|
|
await expect(inbound().receive('WEBHOOK', body, { 'x-iios-signature': 'sha256=bad' })).rejects.toThrow();
|
|
expect(await prisma.iiosInboundRawEvent.count({ where: { status: 'REJECTED' } })).toBe(1);
|
|
expect(await prisma.iiosInteraction.count()).toBe(0);
|
|
});
|
|
|
|
it('replays: same provider eventId twice → one raw event, one interaction', async () => {
|
|
const { body, headers } = signedFixture(emailFixture, SECRET);
|
|
const svc = inbound();
|
|
await svc.receive('WEBHOOK', body, headers);
|
|
const second = await svc.receive('WEBHOOK', body, headers);
|
|
expect(second.deduped).toBe(true);
|
|
|
|
await projector().onRawReceived((await rawReceivedEvents())[0]!);
|
|
expect(await prisma.iiosInboundRawEvent.count()).toBe(1);
|
|
expect(await prisma.iiosInteraction.count()).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('Email adapter — inbound + threading', () => {
|
|
const receiveEmail = async (payload: object) => {
|
|
const { body, headers } = signedFixture(payload, SECRET);
|
|
return inbound().receive('EMAIL', body, headers);
|
|
};
|
|
const normalizeAll = async () => {
|
|
for (const ev of await rawReceivedEvents()) await projector().onRawReceived(ev);
|
|
};
|
|
|
|
it('a signed inbound email → a normalized interaction with the body on the email thread', async () => {
|
|
const ack = await receiveEmail(emailInboundFixture);
|
|
expect(ack.status).toBe('RECEIVED');
|
|
await normalizeAll();
|
|
|
|
const raw = await prisma.iiosInboundRawEvent.findUniqueOrThrow({ where: { id: ack.rawEventId } });
|
|
expect(raw.status).toBe('NORMALIZED');
|
|
const interaction = await prisma.iiosInteraction.findUniqueOrThrow({ where: { id: raw.interactionId! }, include: { parts: true, thread: true } });
|
|
expect(interaction.parts[0]?.bodyText).toBe(emailInboundFixture.text);
|
|
expect(interaction.thread?.externalThreadRef).toBe('email:<m1@example.com>');
|
|
});
|
|
|
|
it('a reply email (In-Reply-To) lands on the SAME email thread as the original', async () => {
|
|
await receiveEmail(emailInboundFixture);
|
|
await receiveEmail(emailReplyFixture);
|
|
await normalizeAll();
|
|
|
|
const interactions = await prisma.iiosInteraction.findMany({ orderBy: { occurredAt: 'asc' } });
|
|
expect(interactions).toHaveLength(2);
|
|
expect(interactions[0]!.threadId).toBe(interactions[1]!.threadId); // reply joined the thread
|
|
expect(await prisma.iiosThread.count()).toBe(1); // one email conversation
|
|
});
|
|
|
|
it('redelivered email (same Message-ID) → one raw event, one interaction (dedup)', async () => {
|
|
const svc = inbound();
|
|
const { body, headers } = signedFixture(emailInboundFixture, SECRET);
|
|
await svc.receive('EMAIL', body, headers);
|
|
const second = await svc.receive('EMAIL', body, headers);
|
|
expect(second.deduped).toBe(true);
|
|
await normalizeAll();
|
|
expect(await prisma.iiosInboundRawEvent.count()).toBe(1);
|
|
expect(await prisma.iiosInteraction.count()).toBe(1);
|
|
});
|
|
|
|
it('bad signature on EMAIL → REJECTED, nothing ingested (fail-closed)', async () => {
|
|
const { body } = signedFixture(emailInboundFixture, SECRET);
|
|
await expect(inbound().receive('EMAIL', body, { 'x-iios-signature': 'sha256=bad' })).rejects.toThrow();
|
|
expect(await prisma.iiosInboundRawEvent.count({ where: { status: 'REJECTED' } })).toBe(1);
|
|
expect(await prisma.iiosInteraction.count()).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('Adapter outbound (P5)', () => {
|
|
const outbound = (limit?: number): OutboundService => {
|
|
const s = new OutboundService(asService, new CapabilityBroker(makeFakePorts(), new CapabilityProviderRegistry()), new IdempotencyService(asService));
|
|
if (limit) s.limit = limit;
|
|
return s;
|
|
};
|
|
|
|
it('send → command SENT + one delivery attempt (sandbox, no network)', async () => {
|
|
const cmd = await outbound().send('WEBHOOK', 'user@x', { text: 'hi' }, 'ob-1');
|
|
expect(cmd.status).toBe('SENT');
|
|
expect(cmd.providerRef).toContain('sim-');
|
|
expect(await prisma.iiosDeliveryAttempt.count({ where: { commandId: cmd.id } })).toBe(1);
|
|
});
|
|
|
|
it('rate limit: exceeding the window → RATE_LIMITED', async () => {
|
|
const svc = outbound(2);
|
|
await svc.send('WEBHOOK', 't', {}, 'a');
|
|
await svc.send('WEBHOOK', 't', {}, 'b');
|
|
const third = await svc.send('WEBHOOK', 't', {}, 'c');
|
|
expect(third.status).toBe('RATE_LIMITED');
|
|
});
|
|
|
|
it('idempotent: same key twice → one command', async () => {
|
|
const svc = outbound();
|
|
const a = await svc.send('WEBHOOK', 't2', {}, 'k');
|
|
const b = await svc.send('WEBHOOK', 't2', {}, 'k');
|
|
expect(b.id).toBe(a.id);
|
|
expect(await prisma.iiosOutboundCommand.count()).toBe(1);
|
|
});
|
|
|
|
it('broker obligation DENY_OUTBOUND → command BLOCKED, no send (P9)', async () => {
|
|
const ports = makeFakePorts();
|
|
ports.opa.setDecision({ allow: true, obligations: [{ kind: 'DENY_OUTBOUND', reason: 'recipient opted out' }] });
|
|
const svc = new OutboundService(asService, new CapabilityBroker(ports, new CapabilityProviderRegistry()), new IdempotencyService(asService));
|
|
const cmd = await svc.send('WEBHOOK', 'user@x', { text: 'hi' }, 'blk-1');
|
|
expect(cmd.status).toBe('BLOCKED');
|
|
expect(cmd.providerRef).toBe('blocked');
|
|
});
|
|
|
|
it('CMP allow → command records the consent receipt (P9 slice 2)', async () => {
|
|
const ports = makeFakePorts();
|
|
ports.cmp.setStatus('ALLOW');
|
|
const svc = new OutboundService(asService, new CapabilityBroker(ports, new CapabilityProviderRegistry()), new IdempotencyService(asService));
|
|
const cmd = await svc.send('WEBHOOK', 'user@x', { text: 'hi' }, 'consent-1', 'scope1', 'support');
|
|
expect(cmd.status).toBe('SENT');
|
|
expect(cmd.consentReceiptRef).toBe('fake-receipt');
|
|
});
|
|
|
|
it('CMP DENY for the purpose → command BLOCKED, no send (P9 slice 2)', async () => {
|
|
const ports = makeFakePorts();
|
|
ports.cmp.denyPurpose('marketing');
|
|
const svc = new OutboundService(asService, new CapabilityBroker(ports, new CapabilityProviderRegistry()), new IdempotencyService(asService));
|
|
const cmd = await svc.send('WEBHOOK', 'user@x', { text: 'promo' }, 'consent-2', 'scope1', 'marketing');
|
|
expect(cmd.status).toBe('BLOCKED');
|
|
expect(await prisma.iiosDeliveryAttempt.count({ where: { commandId: cmd.id, status: 'SENT' } })).toBe(0);
|
|
});
|
|
|
|
it('per-tenant quota: a noisy tenant is capped while another still sends (P9 slice 3)', async () => {
|
|
const svc = outbound();
|
|
svc.tenantLimit = 1;
|
|
const a1 = await svc.send('WEBHOOK', 't1', {}, 'ta-1', 'scope-A');
|
|
const a2 = await svc.send('WEBHOOK', 't2', {}, 'ta-2', 'scope-A'); // A over its cap
|
|
const b1 = await svc.send('WEBHOOK', 't3', {}, 'tb-1', 'scope-B'); // B unaffected
|
|
expect(a1.status).toBe('SENT');
|
|
expect(a2.status).toBe('RATE_LIMITED');
|
|
expect(b1.status).toBe('SENT');
|
|
});
|
|
|
|
it('idempotency ledger: same key + a different target → conflict, no second command (P9 slice 10)', async () => {
|
|
const svc = outbound();
|
|
const first = await svc.send('WEBHOOK', 'user@a', { text: 'hi' }, 'dup-key');
|
|
expect(first.status).toBe('SENT');
|
|
await expect(svc.send('WEBHOOK', 'user@b', { text: 'hi' }, 'dup-key')).rejects.toBeInstanceOf(ConflictException);
|
|
expect(await prisma.iiosOutboundCommand.count({ where: { idempotencyKey: 'dup-key' } })).toBe(1);
|
|
});
|
|
|
|
it('opt-in: a keyless send creates a command but writes no idempotency-ledger row (P9 slice 10)', async () => {
|
|
const cmd = await outbound().send('WEBHOOK', 'user@x', { text: 'hi' });
|
|
expect(cmd.status).toBe('SENT');
|
|
expect(await prisma.iiosIdempotencyCommand.count()).toBe(0);
|
|
});
|
|
});
|