feat(testkit): deterministic fake platform ports + fixtures + replay oracle
Fakes for all 7 IiosPlatformPorts (deny/timeout/ambiguous controls), portal + unknown-email fixtures, replayTwice idempotency oracle. Adds ingest request contract. Vitest alias resolves workspace packages to source. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
export * from './scope';
|
||||
export * from './enums';
|
||||
export * from './ingest';
|
||||
export * from './ports';
|
||||
export * from './events';
|
||||
export * from './commands';
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { ScopeVector } from './scope';
|
||||
import type { HandleKind, InteractionKind, MessagePartKind } from './enums';
|
||||
|
||||
/**
|
||||
* The ingest API contract (Data Model §11 OpenAPI `IngestInteractionRequest`).
|
||||
* A trusted host app or channel adapter posts this to `/v1/interactions/ingest`.
|
||||
*/
|
||||
export interface IngestInteractionRequest {
|
||||
scope: ScopeVector;
|
||||
channel: {
|
||||
type: string; // PORTAL, EMAIL, WHATSAPP, ...
|
||||
externalChannelId?: string;
|
||||
capabilityContract?: Record<string, unknown>;
|
||||
};
|
||||
source: {
|
||||
handleKind: HandleKind;
|
||||
externalId: string;
|
||||
displayName?: string;
|
||||
};
|
||||
kind?: InteractionKind;
|
||||
thread?: {
|
||||
externalThreadId?: string;
|
||||
subject?: string;
|
||||
};
|
||||
parts: Array<{
|
||||
kind: MessagePartKind;
|
||||
bodyText?: string;
|
||||
contentRef?: string;
|
||||
mimeType?: string;
|
||||
}>;
|
||||
occurredAt: string;
|
||||
providerEventId?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface IngestInteractionResponse {
|
||||
interactionId: string;
|
||||
sourceHandleId: string;
|
||||
threadId: string;
|
||||
state: string;
|
||||
next: string[];
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "@insignia/iios-testkit",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"files": ["dist"],
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@insignia/iios-contracts": "workspace:*"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { IiosPlatformPorts } from '@insignia/iios-contracts';
|
||||
|
||||
type CapabilityPort = IiosPlatformPorts['capability'];
|
||||
|
||||
/** Deterministic Capability Broker fake. Reports a successful provider send. */
|
||||
export class FakeCapability implements CapabilityPort {
|
||||
async invoke(_input: unknown): Promise<{ providerRef: string; outcome: string }> {
|
||||
return { providerRef: 'fake-provider', outcome: 'SENT' };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { IiosPlatformPorts } from '@insignia/iios-contracts';
|
||||
|
||||
type CmpPort = IiosPlatformPorts['cmp'];
|
||||
|
||||
/** Deterministic CMP (consent) fake. Defaults to NOT_REQUIRED. */
|
||||
export class FakeCmp implements CmpPort {
|
||||
private status: 'ALLOW' | 'DENY' | 'NOT_REQUIRED' = 'NOT_REQUIRED';
|
||||
|
||||
setStatus(s: 'ALLOW' | 'DENY' | 'NOT_REQUIRED'): this {
|
||||
this.status = s;
|
||||
return this;
|
||||
}
|
||||
|
||||
async checkPurpose(_input: unknown): Promise<{ receiptRef?: string; status: 'ALLOW' | 'DENY' | 'NOT_REQUIRED' }> {
|
||||
return {
|
||||
receiptRef: this.status === 'ALLOW' ? 'fake-receipt' : undefined,
|
||||
status: this.status,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { IiosPlatformPorts, ContextDecisionBundle } from '@insignia/iios-contracts';
|
||||
|
||||
type CrrePort = IiosPlatformPorts['crre'];
|
||||
|
||||
/** Deterministic CRRE fake. Returns an empty (no-constraint) context bundle. */
|
||||
export class FakeCrre implements CrrePort {
|
||||
async resolve(_input: unknown): Promise<ContextDecisionBundle> {
|
||||
return {
|
||||
bundleRef: 'fake-bundle',
|
||||
scopeHash: 'fake-hash',
|
||||
resourceSet: [],
|
||||
constraints: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { IiosPlatformPorts } from '@insignia/iios-contracts';
|
||||
import { FakeSession } from './session';
|
||||
import { FakeOpa } from './opa';
|
||||
import { FakeCmp } from './cmp';
|
||||
import { FakeMdm } from './mdm';
|
||||
import { FakeCrre } from './crre';
|
||||
import { FakeSas } from './sas';
|
||||
import { FakeCapability } from './capability';
|
||||
|
||||
export { FakeSession, FakeOpa, FakeCmp, FakeMdm, FakeCrre, FakeSas, FakeCapability };
|
||||
|
||||
/** The full set of fakes, typed so tests can reach each fake's control methods. */
|
||||
export interface FakePorts extends IiosPlatformPorts {
|
||||
session: FakeSession;
|
||||
opa: FakeOpa;
|
||||
cmp: FakeCmp;
|
||||
mdm: FakeMdm;
|
||||
crre: FakeCrre;
|
||||
sas: FakeSas;
|
||||
capability: FakeCapability;
|
||||
}
|
||||
|
||||
/** Assemble a fresh, fully-faked IiosPlatformPorts (all default to "happy"). */
|
||||
export function makeFakePorts(): FakePorts {
|
||||
return {
|
||||
session: new FakeSession(),
|
||||
opa: new FakeOpa(),
|
||||
cmp: new FakeCmp(),
|
||||
mdm: new FakeMdm(),
|
||||
crre: new FakeCrre(),
|
||||
sas: new FakeSas(),
|
||||
capability: new FakeCapability(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { IiosPlatformPorts, SourceHandleResolution } from '@insignia/iios-contracts';
|
||||
|
||||
type MdmPort = IiosPlatformPorts['mdm'];
|
||||
|
||||
/**
|
||||
* Deterministic MDM fake. Defaults to UNRESOLVED (an unknown handle stays a
|
||||
* SourceHandle — no silent merge). Can be set to ambiguous or resolved.
|
||||
*/
|
||||
export class FakeMdm implements MdmPort {
|
||||
private resolution: SourceHandleResolution = {
|
||||
canonicalEntityId: undefined,
|
||||
confidence: 0,
|
||||
status: 'UNRESOLVED',
|
||||
};
|
||||
|
||||
setResolution(r: Partial<SourceHandleResolution>): this {
|
||||
this.resolution = { ...this.resolution, ...r };
|
||||
return this;
|
||||
}
|
||||
|
||||
ambiguous(confidence = 0.42): this {
|
||||
this.resolution = { canonicalEntityId: undefined, confidence, status: 'AMBIGUOUS' };
|
||||
return this;
|
||||
}
|
||||
|
||||
resolved(canonicalEntityId: string, confidence = 0.99): this {
|
||||
this.resolution = { canonicalEntityId, confidence, status: 'RESOLVED' };
|
||||
return this;
|
||||
}
|
||||
|
||||
async resolveSourceHandle(_input: unknown): Promise<SourceHandleResolution> {
|
||||
return this.resolution;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { IiosPlatformPorts, PolicyDecision } from '@insignia/iios-contracts';
|
||||
|
||||
type OpaPort = IiosPlatformPorts['opa'];
|
||||
|
||||
/** Deterministic OPA fake. Defaults to allow; can be set to deny or timeout. */
|
||||
export class FakeOpa implements OpaPort {
|
||||
private decision: PolicyDecision = {
|
||||
decisionRef: 'fake-allow',
|
||||
allow: true,
|
||||
obligations: [],
|
||||
ttlSeconds: 60,
|
||||
};
|
||||
private mode: 'normal' | 'timeout' = 'normal';
|
||||
|
||||
setDecision(d: Partial<PolicyDecision>): this {
|
||||
this.decision = { ...this.decision, ...d };
|
||||
return this;
|
||||
}
|
||||
|
||||
deny(reason = 'denied by fake opa'): this {
|
||||
this.decision = {
|
||||
decisionRef: 'fake-deny',
|
||||
allow: false,
|
||||
obligations: [{ kind: 'AUDIT', reason }],
|
||||
ttlSeconds: 0,
|
||||
};
|
||||
return this;
|
||||
}
|
||||
|
||||
timeout(): this {
|
||||
this.mode = 'timeout';
|
||||
return this;
|
||||
}
|
||||
|
||||
async decide(_input: unknown): Promise<PolicyDecision> {
|
||||
if (this.mode === 'timeout') throw new Error('opa timeout');
|
||||
return this.decision;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { IiosPlatformPorts } from '@insignia/iios-contracts';
|
||||
|
||||
type SasPort = IiosPlatformPorts['sas'];
|
||||
|
||||
/** Deterministic SAS (secure-store) fake. Pass-through tokenization in dev. */
|
||||
export class FakeSas implements SasPort {
|
||||
async tokenizeParts(parts: unknown[]): Promise<unknown[]> {
|
||||
return parts;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { IiosPlatformPorts, PlatformPrincipal } from '@insignia/iios-contracts';
|
||||
|
||||
type SessionPort = IiosPlatformPorts['session'];
|
||||
|
||||
/** Deterministic Session fake. Returns a fixed principal; can be set to reject. */
|
||||
export class FakeSession implements SessionPort {
|
||||
private principal: PlatformPrincipal = {
|
||||
principalRef: 'fake-principal',
|
||||
orgId: 'org_demo',
|
||||
appId: 'portal-demo',
|
||||
assurance: 'authenticated',
|
||||
};
|
||||
private fail = false;
|
||||
|
||||
setPrincipal(p: Partial<PlatformPrincipal>): this {
|
||||
this.principal = { ...this.principal, ...p };
|
||||
return this;
|
||||
}
|
||||
|
||||
reject(): this {
|
||||
this.fail = true;
|
||||
return this;
|
||||
}
|
||||
|
||||
async verify(_token: string): Promise<PlatformPrincipal> {
|
||||
if (this.fail) throw new Error('invalid token');
|
||||
return this.principal;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { IngestInteractionRequest } from '@insignia/iios-contracts';
|
||||
|
||||
/** An email from an unrecognised address — the handle must stay UNRESOLVED. */
|
||||
export const emailFromUnknown: IngestInteractionRequest = {
|
||||
scope: { orgId: 'org_demo', appId: 'portal-demo' },
|
||||
channel: { type: 'EMAIL', externalChannelId: 'support-inbox' },
|
||||
source: { handleKind: 'EMAIL', externalId: 'someone@example.com', displayName: 'Someone' },
|
||||
kind: 'EMAIL',
|
||||
thread: { externalThreadId: 'thr_email_1', subject: 'Question about my plot' },
|
||||
parts: [{ kind: 'TEXT', bodyText: 'Hello, I have a question.' }],
|
||||
occurredAt: '2026-06-30T15:12:00.000Z',
|
||||
providerEventId: 'evt_email_1',
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { IngestInteractionRequest } from '@insignia/iios-contracts';
|
||||
|
||||
/** A known portal user sends a plain text message into a thread. */
|
||||
export const portalMessageBasic: IngestInteractionRequest = {
|
||||
scope: { orgId: 'org_demo', appId: 'portal-demo' },
|
||||
channel: { type: 'PORTAL', externalChannelId: 'portal-web' },
|
||||
source: { handleKind: 'PORTAL_USER', externalId: 'user_rahul', displayName: 'Rahul' },
|
||||
kind: 'MESSAGE',
|
||||
thread: { externalThreadId: 'thr_demo_1', subject: 'Payment help' },
|
||||
parts: [{ kind: 'TEXT', bodyText: 'my payment failed' }],
|
||||
occurredAt: '2026-06-30T15:10:00.000Z',
|
||||
providerEventId: 'evt_portal_1',
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './fakes';
|
||||
export * from './replay';
|
||||
export { portalMessageBasic } from './fixtures/portal-message-basic';
|
||||
export { emailFromUnknown } from './fixtures/email-from-unknown';
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Replay oracle (Bottom-Up hard gate: "replay the same fixture twice and prove
|
||||
* no duplicate business state"). Ingests a fixture, counts, ingests it AGAIN,
|
||||
* counts again. The caller asserts: same interaction id, unchanged count.
|
||||
*
|
||||
* The `ingest` function the caller supplies must derive its idempotency key
|
||||
* deterministically from the fixture (e.g. its providerEventId) so the second
|
||||
* run is a true duplicate.
|
||||
*/
|
||||
export interface ReplayResult {
|
||||
firstId: string;
|
||||
secondId: string;
|
||||
countAfterFirst: number;
|
||||
countAfterSecond: number;
|
||||
}
|
||||
|
||||
export async function replayTwice<F>(
|
||||
fixture: F,
|
||||
ingest: (f: F) => Promise<{ interactionId: string }>,
|
||||
countInteractions: () => Promise<number>,
|
||||
): Promise<ReplayResult> {
|
||||
const first = await ingest(fixture);
|
||||
const countAfterFirst = await countInteractions();
|
||||
const second = await ingest(fixture);
|
||||
const countAfterSecond = await countInteractions();
|
||||
return {
|
||||
firstId: first.interactionId,
|
||||
secondId: second.interactionId,
|
||||
countAfterFirst,
|
||||
countAfterSecond,
|
||||
};
|
||||
}
|
||||
|
||||
/** Convenience predicate: true when a replay produced no new business state. */
|
||||
export function isIdempotent(r: ReplayResult): boolean {
|
||||
return r.firstId === r.secondId && r.countAfterFirst === r.countAfterSecond;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
makeFakePorts,
|
||||
replayTwice,
|
||||
isIdempotent,
|
||||
portalMessageBasic,
|
||||
} from './index';
|
||||
import { PolicyDeniedError, type IngestInteractionRequest } from '@insignia/iios-contracts';
|
||||
|
||||
describe('fake platform ports', () => {
|
||||
it('OPA defaults to allow, can be set to deny, and can time out', async () => {
|
||||
const ports = makeFakePorts();
|
||||
expect((await ports.opa.decide({})).allow).toBe(true);
|
||||
|
||||
ports.opa.deny('nope');
|
||||
const denied = await ports.opa.decide({});
|
||||
expect(denied.allow).toBe(false);
|
||||
expect(denied.obligations[0]?.reason).toBe('nope');
|
||||
|
||||
ports.opa.timeout();
|
||||
await expect(ports.opa.decide({})).rejects.toThrow('opa timeout');
|
||||
});
|
||||
|
||||
it('MDM keeps an unknown handle UNRESOLVED, and can be made ambiguous', async () => {
|
||||
const ports = makeFakePorts();
|
||||
expect((await ports.mdm.resolveSourceHandle({})).status).toBe('UNRESOLVED');
|
||||
|
||||
ports.mdm.ambiguous(0.42);
|
||||
const res = await ports.mdm.resolveSourceHandle({});
|
||||
expect(res.status).toBe('AMBIGUOUS');
|
||||
expect(res.canonicalEntityId).toBeUndefined();
|
||||
expect(res.confidence).toBe(0.42);
|
||||
});
|
||||
|
||||
it('fail-closed pattern: a denied OPA decision yields PolicyDeniedError', async () => {
|
||||
const ports = makeFakePorts();
|
||||
ports.opa.deny();
|
||||
const decision = await ports.opa.decide({});
|
||||
// This is the guard the kernel will apply (Task 1.3).
|
||||
const guard = () => {
|
||||
if (!decision.allow) throw new PolicyDeniedError(decision.obligations[0]?.reason);
|
||||
};
|
||||
expect(guard).toThrow(PolicyDeniedError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('replay oracle', () => {
|
||||
it('replaying a fixture twice creates no duplicate business state', async () => {
|
||||
// In-memory "kernel": dedupes on the fixture's providerEventId.
|
||||
const store = new Map<string, string>();
|
||||
let seq = 0;
|
||||
const ingest = async (f: IngestInteractionRequest) => {
|
||||
const key = f.providerEventId ?? 'no-key';
|
||||
if (!store.has(key)) store.set(key, `int_${++seq}`);
|
||||
return { interactionId: store.get(key)! };
|
||||
};
|
||||
const countInteractions = async () => store.size;
|
||||
|
||||
const result = await replayTwice(portalMessageBasic, ingest, countInteractions);
|
||||
expect(isIdempotent(result)).toBe(true);
|
||||
expect(result.countAfterFirst).toBe(1);
|
||||
expect(result.countAfterSecond).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.spec.ts"]
|
||||
}
|
||||
Generated
+6
@@ -20,6 +20,12 @@ importers:
|
||||
|
||||
packages/iios-contracts: {}
|
||||
|
||||
packages/iios-testkit:
|
||||
dependencies:
|
||||
'@insignia/iios-contracts':
|
||||
specifier: workspace:*
|
||||
version: link:../iios-contracts
|
||||
|
||||
packages:
|
||||
|
||||
'@esbuild/aix-ppc64@0.28.1':
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const r = (p: string) => fileURLToPath(new URL(p, import.meta.url));
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
// Resolve workspace packages to their TS source so tests run without a
|
||||
// build step. Production builds still go through tsc/dist.
|
||||
'@insignia/iios-contracts': r('./packages/iios-contracts/src/index.ts'),
|
||||
'@insignia/iios-testkit': r('./packages/iios-testkit/src/index.ts'),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
include: ['packages/**/src/**/*.{test,spec}.ts'],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user