feat(iios): idempotency smoke + P9 slice-6 verification
smoke-idempotency.mjs: dup POST /v1/support/tickets with the same Idempotency-Key replays the same ticket, same key + different body → 409, and a second tenant reusing the key gets its own ticket (isolation). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
// P9 slice-6 idempotency-ledger smoke: a retried command with the same Idempotency-Key
|
||||
// replays the same ticket (no duplicate side-effect); the same key with a different body
|
||||
// is a 409; two tenants may reuse the same key independently (KG-02/KG-06).
|
||||
// Requires the service running with IIOS_DEV_TOKENS=1.
|
||||
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); };
|
||||
|
||||
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;
|
||||
}
|
||||
function post(token, path, body, idempotencyKey) {
|
||||
return fetch(`${SERVICE}${path}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
authorization: `Bearer ${token}`,
|
||||
...(idempotencyKey ? { 'idempotency-key': idempotencyKey } : {}),
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
async function json(r, ctx) {
|
||||
if (!r.ok) throw new Error(`${ctx} ${r.status}: ${await r.text()}`);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
const A = await devToken('idem-a', 'org_idem_A');
|
||||
const B = await devToken('idem-b', 'org_idem_B');
|
||||
const KEY = 'k-smoke-1';
|
||||
|
||||
// A creates a ticket under KEY.
|
||||
const t1 = await json(await post(A, '/v1/support/tickets', { subject: 'X' }, KEY), 'A create');
|
||||
assert(t1.id, `A created ticket ${t1.id}`);
|
||||
|
||||
// Same key + same body → the SAME ticket (replay, no re-execution).
|
||||
const t1again = await json(await post(A, '/v1/support/tickets', { subject: 'X' }, KEY), 'A replay');
|
||||
assert(t1again.id === t1.id, `same key + same body replays the same ticket id (${t1again.id})`);
|
||||
|
||||
// Same key + a DIFFERENT body → 409 conflict.
|
||||
const conflict = await post(A, '/v1/support/tickets', { subject: 'Y' }, KEY);
|
||||
assert(conflict.status === 409, `same key + different body → 409 (got ${conflict.status})`);
|
||||
|
||||
// Tenant B reuses the SAME key → its own ticket (cross-tenant isolation).
|
||||
const tB = await json(await post(B, '/v1/support/tickets', { subject: 'X' }, KEY), 'B create');
|
||||
assert(tB.id && tB.id !== t1.id, `tenant B's same-key create is independent (${tB.id} ≠ ${t1.id})`);
|
||||
|
||||
// A's ticket list contains t1 exactly once (no duplicate side-effect from the replay/conflict).
|
||||
const list = await json(
|
||||
await fetch(`${SERVICE}/v1/support/tickets`, { headers: { authorization: `Bearer ${A}` } }),
|
||||
'A list',
|
||||
);
|
||||
assert(list.filter((t) => t.id === t1.id).length === 1, 'A sees the ticket exactly once (no duplicate)');
|
||||
|
||||
console.log('\nP9 idempotency smoke: PASS');
|
||||
process.exit(0);
|
||||
Reference in New Issue
Block a user