dbf8f814c0
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>
72 lines
3.1 KiB
JavaScript
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);
|