feat(p9): tenant isolation smoke + slice-3 verification green
Task T3.5: smoke-tenant.mjs — two dev tokens (org_A/org_B); tenant B reading A's meeting → 403 (KG-02), B's list excludes A's, A reads its own → 200. Slice-3 verification: 114 tests green, pnpm -r build all packages, boundary clean, tenant smoke + capability smoke + all prior smokes (realtime/inbox/support/adapter/route/ ai/calendar) pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
Reference in New Issue
Block a user