Files
maaz519 dbf8f814c0 feat(iios): tenant-scoped /v1/dlq REST + chaos endpoint + smoke (P9 slice 5)
GET /v1/dlq lists the caller tenant's dead-letters; POST /v1/dlq/:id/replay
fences by assertOwns (KG-02) + audits each replay. Dev-only POST
/v1/dev/chaos/poison + ChaosConsumer (fails first delivery, succeeds on
replay) drive smoke-dlq.mjs: poison → QUARANTINED → cross-tenant denied →
owner replay → RESOLVED. Adds dev.chaos.v1 contract event.

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

72 lines
3.1 KiB
JavaScript

// P9 slice-5 DLQ smoke: a poison chaos event is dead-lettered (not lost), shows up
// tenant-scoped in /v1/dlq, another tenant can neither see nor replay it, and the
// owner's replay resolves it (KG-13 / KG-06). Requires the service running with
// IIOS_DEV_TOKENS=1 (the relay ticks by default, publishing the poison to the consumer).
import 'dotenv/config';
const SERVICE = process.env.SMOKE_URL ?? 'http://localhost:3200';
const APP_ID = 'portal-demo';
const assert = (c, m) => { if (!c) { console.error('✗', m); process.exit(1); } console.log('✓', m); };
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function devToken(userId, orgId) {
const r = await fetch(`${SERVICE}/v1/dev/token`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ appId: APP_ID, userId, name: userId, orgId }),
});
if (!r.ok) throw new Error(`devToken ${r.status} (service with IIOS_DEV_TOKENS=1?)`);
return (await r.json()).token;
}
async function call(token, path, method = 'GET', body) {
return fetch(`${SERVICE}${path}`, {
method,
headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
body: body ? JSON.stringify(body) : undefined,
});
}
async function json(token, path, method = 'GET', body) {
const r = await call(token, path, method, body);
if (!r.ok) throw new Error(`${method} ${path} ${r.status}: ${await r.text()}`);
return r.json();
}
const A = await devToken('a1', 'org_A');
const B = await devToken('b1', 'org_B');
// A injects a poison chaos event.
const { eventId } = await json(A, '/v1/dev/chaos/poison', 'POST');
assert(eventId.startsWith('evt_chaos_'), `A injected a poison chaos event (${eventId})`);
// Poll until the relay publishes it and the chaos consumer dead-letters it.
let item;
for (let i = 0; i < 40; i++) {
const list = await json(A, '/v1/dlq');
item = list.find((d) => d.sourceId === eventId);
if (item) break;
await sleep(250);
}
assert(item, 'poison surfaced in A\'s DLQ (relay published → consumer failed → dead-lettered)');
assert(item.status === 'QUARANTINED', `DLQ item is QUARANTINED (got ${item.status})`);
assert(item.consumerName === 'chaos-consumer', `dead-lettered by the chaos-consumer lane (got ${item.consumerName})`);
// Tenant B cannot see A's DLQ item.
const bList = await json(B, '/v1/dlq');
assert(!bList.some((d) => d.sourceId === eventId), "tenant B's DLQ excludes A's item (isolation)");
// Tenant B cannot replay A's DLQ item.
const bReplay = await call(B, `/v1/dlq/${item.id}/replay`, 'POST');
assert(bReplay.status === 403, `tenant B replaying A's item → 403 (got ${bReplay.status}) — KG-02`);
// A replays → resolves.
const replayed = await json(A, `/v1/dlq/${item.id}/replay`, 'POST');
assert(replayed.status === 'RESOLVED', `A's replay resolves the poison (got ${replayed.status})`);
// It now shows RESOLVED in A's DLQ.
const afterList = await json(A, '/v1/dlq');
const after = afterList.find((d) => d.sourceId === eventId);
assert(after && after.status === 'RESOLVED', 'DLQ item is RESOLVED after replay');
console.log('\nP9 DLQ smoke: PASS');
process.exit(0);