106 lines
4.5 KiB
TypeScript
106 lines
4.5 KiB
TypeScript
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { mkdtempSync, readFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { ForbiddenException } from '@nestjs/common';
|
|
import { ApprovalsService } from '../src/approvals/approvals.service.js';
|
|
|
|
const projectAdmin = { actor: 'alice', role: 'project-admin', project: 'default', tenant: 'default' };
|
|
const approver = { actor: 'bob', role: 'approver', project: 'default', tenant: 'default' };
|
|
const orgAdmin = { actor: 'root', role: 'org-admin', project: 'default', tenant: 'default' };
|
|
|
|
function withTempGovernance(fn: (paths: { inbox: string; store: string }) => void) {
|
|
const prevInbox = process.env.CASAN_APPROVAL_INBOX_FILE;
|
|
const prevStrict = process.env.CASAN_APPROVAL_STRICT;
|
|
const prevStore = process.env.CASAN_CP_STORE_FILE;
|
|
const prevKeyDir = process.env.CASAN_CP_KEY_DIR;
|
|
const prevPub = process.env.CASAN_CP_PUB;
|
|
const work = mkdtempSync(join(tmpdir(), 'cp-approval-'));
|
|
process.env.CASAN_APPROVAL_INBOX_FILE = join(work, 'approval-inbox.json');
|
|
process.env.CASAN_CP_STORE_FILE = join(work, 'settings.json');
|
|
process.env.CASAN_CP_KEY_DIR = join(work, 'keys');
|
|
process.env.CASAN_CP_PUB = join(work, 'cp.pub');
|
|
try {
|
|
fn({ inbox: process.env.CASAN_APPROVAL_INBOX_FILE, store: process.env.CASAN_CP_STORE_FILE });
|
|
} finally {
|
|
if (prevInbox === undefined) delete process.env.CASAN_APPROVAL_INBOX_FILE;
|
|
else process.env.CASAN_APPROVAL_INBOX_FILE = prevInbox;
|
|
if (prevStrict === undefined) delete process.env.CASAN_APPROVAL_STRICT;
|
|
else process.env.CASAN_APPROVAL_STRICT = prevStrict;
|
|
if (prevStore === undefined) delete process.env.CASAN_CP_STORE_FILE;
|
|
else process.env.CASAN_CP_STORE_FILE = prevStore;
|
|
if (prevKeyDir === undefined) delete process.env.CASAN_CP_KEY_DIR;
|
|
else process.env.CASAN_CP_KEY_DIR = prevKeyDir;
|
|
if (prevPub === undefined) delete process.env.CASAN_CP_PUB;
|
|
else process.env.CASAN_CP_PUB = prevPub;
|
|
}
|
|
}
|
|
|
|
test('approval inbox submit -> approve applies governed setting and writes oversight', () => {
|
|
withTempGovernance(({ inbox, store }) => {
|
|
const svc = new ApprovalsService();
|
|
const submitted = svc.submit({
|
|
action: 'settings.write',
|
|
target: 'security.strict',
|
|
risk: 'high',
|
|
sensitive: true,
|
|
reason: 'tighten strict mode',
|
|
payload: { key: 'security.strict', value: true },
|
|
}, projectAdmin) as any;
|
|
assert.equal(submitted.proposal.status, 'pending');
|
|
assert.equal(submitted.proposal.delegation.requires_approval, true);
|
|
|
|
const decided = svc.decide({ id: submitted.proposal.id, decision: 'approve', reason: 'approved by reviewer' }, approver) as any;
|
|
assert.equal(decided.proposal.status, 'approved');
|
|
assert.equal(decided.applied.value, true);
|
|
|
|
const inboxRaw = JSON.parse(readFileSync(inbox, 'utf8'));
|
|
assert.equal(inboxRaw.oversight.length, 2);
|
|
assert.equal(inboxRaw.oversight[1].event, 'proposal_approved');
|
|
|
|
const storeRaw = JSON.parse(readFileSync(store, 'utf8'));
|
|
assert.equal(storeRaw.settings['security.strict'].value, true);
|
|
assert.equal(storeRaw.audit.length, 1);
|
|
});
|
|
});
|
|
|
|
test('approval inbox denies forged JWT in strict mode without deciding proposal', () => {
|
|
withTempGovernance(({ inbox }) => {
|
|
const svc = new ApprovalsService();
|
|
const submitted = svc.submit({
|
|
action: 'settings.write',
|
|
target: 'security.strict',
|
|
risk: 'high',
|
|
sensitive: true,
|
|
reason: 'strict approval required',
|
|
payload: { key: 'security.strict', value: true },
|
|
}, projectAdmin) as any;
|
|
process.env.CASAN_APPROVAL_STRICT = '1';
|
|
assert.throws(
|
|
() => svc.decide({ id: submitted.proposal.id, decision: 'approve', reason: 'forged jwt', approvalJwt: 'fake.jwt.token' }, approver),
|
|
ForbiddenException,
|
|
);
|
|
const inboxRaw = JSON.parse(readFileSync(inbox, 'utf8'));
|
|
assert.equal(inboxRaw.proposals[0].status, 'pending');
|
|
assert.equal(inboxRaw.oversight.length, 1);
|
|
});
|
|
});
|
|
|
|
test('approval inbox enforces separation of duties', () => {
|
|
withTempGovernance(() => {
|
|
const svc = new ApprovalsService();
|
|
const submitted = svc.submit({
|
|
action: 'settings.write',
|
|
target: 'compression.enabled',
|
|
risk: 'standard',
|
|
reason: 'change by root',
|
|
payload: { key: 'compression.enabled', value: true },
|
|
}, orgAdmin) as any;
|
|
assert.throws(
|
|
() => svc.decide({ id: submitted.proposal.id, decision: 'approve', reason: 'self approve' }, orgAdmin),
|
|
ForbiddenException,
|
|
);
|
|
});
|
|
});
|