diff --git a/packages/iios-service/scripts/smoke-tenant.mjs b/packages/iios-service/scripts/smoke-tenant.mjs new file mode 100644 index 0000000..9fbddcf --- /dev/null +++ b/packages/iios-service/scripts/smoke-tenant.mjs @@ -0,0 +1,54 @@ +// P9 slice-3 tenant isolation smoke: two tenants (different orgId, same app) prove +// cross-tenant denial over HTTP. Tenant A schedules a meeting; tenant B cannot read +// it (403) and does not see it in its own list; A reads its own (200). +// 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); }; +const START = '2026-07-02T10:00:00.000Z'; + +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 schedules a meeting in tenant A's scope. +const meeting = await json(A, '/v1/calendar/meetings', 'POST', { meetingType: 'INTERNAL', title: 'A only', startAt: START }); +assert(meeting.id && meeting.status === 'SCHEDULED', 'tenant A scheduled a meeting'); + +// B cannot read A's meeting by id. +const bGet = await call(B, `/v1/calendar/meetings/${meeting.id}`); +assert(bGet.status === 403, `tenant B reading A's meeting → 403 (got ${bGet.status}) — KG-02`); + +// B's own list does not include A's meeting. +const bList = await json(B, '/v1/calendar/meetings'); +assert(!bList.some((m) => m.id === meeting.id), "tenant B's meeting list excludes A's meeting"); + +// A reads its own meeting fine (same-tenant control). +const aGet = await json(A, `/v1/calendar/meetings/${meeting.id}`); +assert(aGet.id === meeting.id, 'same-tenant read succeeds (200)'); + +console.log('\nP9 tenant isolation smoke: PASS'); +process.exit(0);