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>
This commit is contained in:
@@ -1,15 +1,20 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { PolicyDeniedError, type IiosPlatformPorts } from '@insignia/iios-contracts';
|
||||
import { resetDb } from '../test-utils/reset-db';
|
||||
import { makeFakePorts } from '@insignia/iios-testkit';
|
||||
import { MessageService, type MessagePrincipal } from './message.service';
|
||||
import { ActorResolver } from '../identity/actor.resolver';
|
||||
import { DevOpaPort } from '../platform/dev-opa.port';
|
||||
import type { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
const url = process.env.DATABASE_URL ?? 'postgresql://iios:iios@localhost:5434/iios?schema=public';
|
||||
const prisma = new PrismaClient({ datasources: { db: { url } } });
|
||||
const asService = prisma as unknown as PrismaService;
|
||||
const svc = () => new MessageService(asService, makeFakePorts(), new ActorResolver(asService));
|
||||
// A service whose OPA actually enforces the membership rules (real dev policy plane).
|
||||
const gov = () =>
|
||||
new MessageService(asService, { ...makeFakePorts(), opa: new DevOpaPort() } as IiosPlatformPorts, new ActorResolver(asService));
|
||||
|
||||
const alice: MessagePrincipal = { userId: 'alice', orgId: 'org_demo', appId: 'portal-demo', displayName: 'Alice' };
|
||||
const bob: MessagePrincipal = { userId: 'bob', orgId: 'org_demo', appId: 'portal-demo', displayName: 'Bob' };
|
||||
@@ -101,3 +106,52 @@ describe('MessageService (P2 native messaging)', () => {
|
||||
expect(ce.insignia.correlationId).toBe(msg.traceId);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Governed membership + replies (v1.1, policy-enforced)', () => {
|
||||
it('a direct message is capped at two people (OPA policy)', async () => {
|
||||
const s = gov();
|
||||
const { threadId } = await s.openThread(null, alice, { membership: 'dm' });
|
||||
expect((await s.addParticipant(threadId, alice, 'bob')).participantCount).toBe(2);
|
||||
await expect(s.addParticipant(threadId, alice, 'carol')).rejects.toBeInstanceOf(PolicyDeniedError);
|
||||
expect(await prisma.iiosThreadParticipant.count({ where: { threadId } })).toBe(2); // unchanged
|
||||
});
|
||||
|
||||
it('group: the creator is ADMIN and can add; a plain member cannot', async () => {
|
||||
const s = gov();
|
||||
const { threadId } = await s.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN' });
|
||||
await s.addParticipant(threadId, alice, 'bob'); // admin adds a member
|
||||
expect(await prisma.iiosThreadParticipant.count({ where: { threadId } })).toBe(2);
|
||||
await expect(s.addParticipant(threadId, bob, 'carol')).rejects.toBeInstanceOf(PolicyDeniedError); // bob is MEMBER
|
||||
});
|
||||
|
||||
it('self-join is governed: a non-member cannot open a thread by id; after being added, they can', async () => {
|
||||
const s = gov();
|
||||
const { threadId } = await s.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN' });
|
||||
await expect(s.openThread(threadId, bob)).rejects.toBeInstanceOf(PolicyDeniedError);
|
||||
await s.addParticipant(threadId, alice, 'bob');
|
||||
expect((await s.openThread(threadId, bob)).threadId).toBe(threadId);
|
||||
});
|
||||
|
||||
it('listThreads returns the caller’s threads with membership, count, last message + unread', async () => {
|
||||
const s = gov();
|
||||
const { threadId } = await s.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN' });
|
||||
await s.addParticipant(threadId, alice, 'bob');
|
||||
await s.send(threadId, alice, { content: 'hello team' }, 'k1');
|
||||
const mine = await s.listThreads(alice);
|
||||
expect(mine).toHaveLength(1);
|
||||
expect(mine[0]).toMatchObject({ membership: 'group', participantCount: 2, lastMessage: 'hello team' });
|
||||
expect((await s.listThreads(bob))[0]?.unread).toBe(1);
|
||||
});
|
||||
|
||||
it('a reply stores + returns parentInteractionId; a cross-thread parent is ignored', async () => {
|
||||
const s = gov();
|
||||
const { threadId } = await s.openThread(null, alice, { membership: 'dm' });
|
||||
const first = await s.send(threadId, alice, { content: 'question?' }, 'k1');
|
||||
const reply = await s.send(threadId, alice, { content: 'answer' }, 'k2', undefined, first.id);
|
||||
expect(reply.parentInteractionId).toBe(first.id);
|
||||
|
||||
const { threadId: other } = await s.openThread(null, alice, { membership: 'dm' });
|
||||
const cross = await s.send(other, alice, { content: 'x' }, 'k3', undefined, first.id);
|
||||
expect(cross.parentInteractionId).toBeUndefined(); // parent not in this thread
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user