ba745bb71a
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>
62 lines
3.2 KiB
JavaScript
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);
|