Files
iios/packages/iios-service/scripts/smoke-membership.mjs
maaz519 ba745bb71a feat(iios): policy-enforced membership + threaded replies + dev IdP (generic)
Enriches the platform PLANE, not the kernel:
- DevOpaPort: the dev OPA stub now evaluates a small policy table (behind the
  same opa.decide) — DM capped at 2, add-participant requires member/group-admin,
  governed self-join for membership threads. Real OPA swaps in unchanged.
- Dev IdP login (POST /v1/dev/login) issues the same JWT claims a real IdP would.
- PolicyDeniedFilter maps fail-closed denials to HTTP 403.

Generic kernel additions (no chat vocabulary — 'dm'/'group' live only as OPA
policy + an opaque thread attribute):
- MessageService.addParticipant (governed membership by userId), governed
  openThread self-join (scoped to threads with a membership attribute),
  parentInteractionId on send (reply link), and a generic listThreads.
- REST: GET /v1/threads, POST /v1/threads, POST /v1/threads/:id/participants;
  socket add_participant + membership/parentInteractionId. ensureParticipant
  gains a role.

Tests: dev-opa.port.spec + message.spec (DM cap / group admin / governed join /
listThreads / reply). smoke-membership.mjs; realtime smokes updated for governed
join. 175 unit tests + all smokes green; kernel free of dm/group literals.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 16:27:47 +05:30

62 lines
3.2 KiB
JavaScript

// v1.1 smoke: dev IdP login, policy-enforced DM cap / group membership, listThreads,
// and threaded replies — all over REST. Requires the service with IIOS_DEV_TOKENS=1.
import 'dotenv/config';
const SERVICE = process.env.SMOKE_URL ?? 'http://localhost:3200';
const assert = (c, m) => { if (!c) { console.error('✗', m); process.exit(1); } console.log('✓', m); };
async function login(username, password) {
const r = await fetch(`${SERVICE}/v1/dev/login`, {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ username, password }),
});
return { status: r.status, body: r.ok ? await r.json() : null };
}
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();
}
// 1) dev IdP: correct password issues a token; wrong password is rejected.
const ok = await login('alice', 'alice');
assert(ok.body?.token, 'dev IdP: alice logs in with password');
const bad = await login('alice', 'nope');
assert(bad.status === 401, 'dev IdP: wrong password → 401');
const alice = ok.body.token;
// 2) DM is capped at two (policy).
const dm = await json(alice, '/v1/threads', 'POST', { membership: 'dm' });
assert(dm.threadId, `alice created a DM (${dm.threadId})`);
const add2 = await json(alice, `/v1/threads/${dm.threadId}/participants`, 'POST', { userId: 'bob' });
assert(add2.participantCount === 2, 'added bob → 2 participants');
const add3 = await call(alice, `/v1/threads/${dm.threadId}/participants`, 'POST', { userId: 'carol' });
assert(add3.status === 403, `adding a 3rd to a DM → 403 policy denied (${add3.status})`);
// 3) group: creator (ADMIN) can add several.
const grp = await json(alice, '/v1/threads', 'POST', { membership: 'group', creatorRole: 'ADMIN' });
await json(alice, `/v1/threads/${grp.threadId}/participants`, 'POST', { userId: 'bob' });
const g3 = await json(alice, `/v1/threads/${grp.threadId}/participants`, 'POST', { userId: 'carol' });
assert(g3.participantCount === 3, 'group admin added bob + carol → 3 participants');
// 4) threaded reply round-trips.
const first = await json(alice, `/v1/threads/${grp.threadId}/messages`, 'POST', { content: 'question?' });
const reply = await json(alice, `/v1/threads/${grp.threadId}/messages`, 'POST', { content: 'answer', parentInteractionId: first.id });
assert(reply.parentInteractionId === first.id, 'a reply carries parentInteractionId');
// 5) listThreads shows the caller's threads with membership + last message.
const mine = await json(alice, '/v1/threads');
const g = mine.find((t) => t.threadId === grp.threadId);
assert(g && g.membership === 'group' && g.participantCount === 3, 'GET /v1/threads lists the group with membership + count');
assert(g.lastMessage === 'answer', 'thread summary carries the last message');
console.log('\nv1.1 membership + replies smoke: PASS');
process.exit(0);