b4104b9769
- media upload policy now allows text/html, text/markdown, text/x-markdown, text/csv (in addition to images/av, pdf, txt, zip, office docs) - blob endpoint adds X-Content-Type-Options: nosniff, and forces Content-Disposition: attachment for script-capable types (html, xhtml, svg, xml) so an uploaded file can't render/execute inline from the IIOS origin (stored-XSS). Images/video/audio/pdf still serve inline for preview. - dev-opa test covering the allowed types + unknown/oversize denials Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
75 lines
4.2 KiB
TypeScript
75 lines
4.2 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import { DevOpaPort } from './dev-opa.port';
|
|
|
|
const opa = new DevOpaPort();
|
|
|
|
describe('DevOpaPort (dev policy plane — membership rules)', () => {
|
|
it('allows every existing action by default (thread create/read, message send)', async () => {
|
|
for (const action of ['iios.thread.create', 'iios.thread.read', 'iios.message.send', undefined]) {
|
|
expect((await opa.decide({ action })).allow).toBe(true);
|
|
}
|
|
});
|
|
|
|
it('DM is capped at two: adding a 2nd is allowed, a 3rd is denied', async () => {
|
|
const add = (participantCount: number) =>
|
|
opa.decide({ action: 'iios.thread.participant.add', membership: 'dm', callerRole: 'MEMBER', participantCount });
|
|
expect((await add(1)).allow).toBe(true); // 1 → adding the 2nd
|
|
const third = await add(2); // 2 → adding a 3rd
|
|
expect(third.allow).toBe(false);
|
|
expect(third.obligations[0]?.reason).toMatch(/two people/);
|
|
});
|
|
|
|
it('adding a participant requires the caller be a member', async () => {
|
|
const d = await opa.decide({ action: 'iios.thread.participant.add', membership: 'group', callerRole: 'NONE', participantCount: 3 });
|
|
expect(d.allow).toBe(false);
|
|
expect(d.obligations[0]?.reason).toMatch(/member/);
|
|
});
|
|
|
|
it('group add/remove requires ADMIN', async () => {
|
|
const asMember = await opa.decide({ action: 'iios.thread.participant.add', membership: 'group', callerRole: 'MEMBER', participantCount: 3 });
|
|
expect(asMember.allow).toBe(false);
|
|
expect(asMember.obligations[0]?.reason).toMatch(/admin/);
|
|
const asAdmin = await opa.decide({ action: 'iios.thread.participant.add', membership: 'group', callerRole: 'ADMIN', participantCount: 3 });
|
|
expect(asAdmin.allow).toBe(true);
|
|
});
|
|
|
|
it('group rename (thread.update) requires ADMIN; ungoverned threads allow it', async () => {
|
|
const asMember = await opa.decide({ action: 'iios.thread.update', membership: 'group', callerRole: 'MEMBER' });
|
|
expect(asMember.allow).toBe(false);
|
|
expect(asMember.obligations[0]?.reason).toMatch(/admin/);
|
|
expect((await opa.decide({ action: 'iios.thread.update', membership: 'group', callerRole: 'ADMIN' })).allow).toBe(true);
|
|
expect((await opa.decide({ action: 'iios.thread.update' })).allow).toBe(true); // no membership attr → allow
|
|
});
|
|
|
|
it('group remove requires ADMIN; a member on an ungoverned thread may remove', async () => {
|
|
const asMember = await opa.decide({ action: 'iios.thread.participant.remove', membership: 'group', callerRole: 'MEMBER' });
|
|
expect(asMember.allow).toBe(false);
|
|
expect(asMember.obligations[0]?.reason).toMatch(/admin/);
|
|
expect((await opa.decide({ action: 'iios.thread.participant.remove', membership: 'group', callerRole: 'ADMIN' })).allow).toBe(true);
|
|
expect((await opa.decide({ action: 'iios.thread.participant.remove', callerRole: 'MEMBER' })).allow).toBe(true);
|
|
});
|
|
|
|
it('media upload allows images + docs (incl. html/markdown/csv), denies unknown types and oversize', async () => {
|
|
const up = (mime: string, sizeBytes = 1024) => opa.decide({ action: 'iios.media.upload', mime, sizeBytes });
|
|
for (const mime of ['image/png', 'video/mp4', 'audio/mpeg', 'application/pdf', 'text/plain', 'text/markdown', 'text/html', 'text/csv']) {
|
|
expect((await up(mime)).allow).toBe(true);
|
|
}
|
|
const bad = await up('application/x-msdownload');
|
|
expect(bad.allow).toBe(false);
|
|
expect(bad.obligations[0]?.reason).toMatch(/not allowed/);
|
|
const big = await up('image/png', 30 * 1024 * 1024);
|
|
expect(big.allow).toBe(false);
|
|
expect(big.obligations[0]?.reason).toMatch(/too large/);
|
|
});
|
|
|
|
it('self-join is governed only on membership threads', async () => {
|
|
// generic / support thread (no membership attr) → open join, unchanged
|
|
expect((await opa.decide({ action: 'iios.thread.join', alreadyMember: false })).allow).toBe(true);
|
|
// a membership (chat) thread → existing member allowed, stranger denied
|
|
expect((await opa.decide({ action: 'iios.thread.join', membership: 'group', alreadyMember: true })).allow).toBe(true);
|
|
const stranger = await opa.decide({ action: 'iios.thread.join', membership: 'group', alreadyMember: false });
|
|
expect(stranger.allow).toBe(false);
|
|
expect(stranger.obligations[0]?.reason).toMatch(/not a member/);
|
|
});
|
|
});
|