feat: add control panel
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
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 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 (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 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { actorFromHeaders } from '../src/common/auth-context.js';
|
||||
|
||||
test('auth context keeps local explicit roles for dev', () => {
|
||||
const actor = actorFromHeaders({
|
||||
'x-casan-actor': 'alice',
|
||||
'x-casan-role': 'project-admin',
|
||||
'x-casan-project': 'okr',
|
||||
'x-casan-tenant': 'tenant-a',
|
||||
});
|
||||
assert.deepEqual(actor, { actor: 'alice', role: 'project-admin', project: 'okr', tenant: 'tenant-a' });
|
||||
});
|
||||
|
||||
test('auth context maps IdP group claim to RBAC role', () => {
|
||||
const actor = actorFromHeaders({
|
||||
'x-auth-request-user': 'bob@example.com',
|
||||
'x-auth-request-groups': 'engineering,casan-approver',
|
||||
});
|
||||
assert.equal(actor.actor, 'bob@example.com');
|
||||
assert.equal(actor.role, 'approver');
|
||||
});
|
||||
|
||||
test('auth context fails closed to viewer for unknown role claim', () => {
|
||||
const actor = actorFromHeaders({
|
||||
'x-auth-request-user': 'eve@example.com',
|
||||
'x-auth-request-groups': 'unknown-admin',
|
||||
});
|
||||
assert.equal(actor.role, 'viewer');
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { ForbiddenException } from '@nestjs/common';
|
||||
import { KillSwitchService } from '../src/kill-switch/kill-switch.service.js';
|
||||
|
||||
const viewer = { actor: 'viewer-1', role: 'viewer', project: 'default', tenant: 'default' };
|
||||
const operator = { actor: 'operator-1', role: 'operator', project: 'default', tenant: 'default' };
|
||||
const admin = { actor: 'admin-1', role: 'org-admin', project: 'default', tenant: 'default' };
|
||||
|
||||
function withTempKillSwitch(fn: () => void) {
|
||||
const prev = process.env.CASAN_KILLSWITCH_DIR;
|
||||
process.env.CASAN_KILLSWITCH_DIR = mkdtempSync(join(tmpdir(), 'cp-ks-'));
|
||||
try {
|
||||
fn();
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env.CASAN_KILLSWITCH_DIR;
|
||||
else process.env.CASAN_KILLSWITCH_DIR = prev;
|
||||
}
|
||||
}
|
||||
|
||||
test('kill-switch status is readable by viewer', () => {
|
||||
withTempKillSwitch(() => {
|
||||
const svc = new KillSwitchService();
|
||||
const status = svc.status(viewer);
|
||||
assert.equal(status.count, 0);
|
||||
assert.deepEqual(status.engaged, []);
|
||||
});
|
||||
});
|
||||
|
||||
test('viewer cannot engage kill-switch', () => {
|
||||
withTempKillSwitch(() => {
|
||||
const svc = new KillSwitchService();
|
||||
assert.throws(
|
||||
() => svc.engage({ scope: 'project', id: 'p1', reason: 'viewer should fail' }, viewer),
|
||||
ForbiddenException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('operator can engage and org-admin can clear kill-switch', () => {
|
||||
withTempKillSwitch(() => {
|
||||
const svc = new KillSwitchService();
|
||||
const engaged = svc.engage({ scope: 'project', id: 'p1', reason: 'incident drill' }, operator) as any;
|
||||
assert.equal(engaged.status.count, 1);
|
||||
assert.equal(engaged.status.engaged[0].scope, 'project');
|
||||
assert.equal(engaged.status.engaged[0].actor, 'operator-1');
|
||||
|
||||
assert.throws(
|
||||
() => svc.clear({ scope: 'project', id: 'p1', reason: 'viewer clear should fail' }, viewer),
|
||||
ForbiddenException,
|
||||
);
|
||||
|
||||
const cleared = svc.clear({ scope: 'project', id: 'p1', reason: 'resolved' }, admin) as any;
|
||||
assert.equal(cleared.status.count, 0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync, mkdtempSync, readFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { ForbiddenException } from '@nestjs/common';
|
||||
import { SettingsService } from '../src/settings/settings.service.js';
|
||||
|
||||
const viewer = { actor: 'viewer-1', role: 'viewer', project: 'default', tenant: 'default' };
|
||||
const admin = { actor: 'admin-1', role: 'org-admin', project: 'default', tenant: 'default' };
|
||||
|
||||
function withTempStore(fn: (storeFile: string) => void) {
|
||||
const prev = process.env.CASAN_CP_STORE_FILE;
|
||||
const storeFile = join(mkdtempSync(join(tmpdir(), 'cp-settings-')), 'settings.json');
|
||||
process.env.CASAN_CP_STORE_FILE = storeFile;
|
||||
try {
|
||||
fn(storeFile);
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env.CASAN_CP_STORE_FILE;
|
||||
else process.env.CASAN_CP_STORE_FILE = prev;
|
||||
}
|
||||
}
|
||||
|
||||
test('settings list is readable by viewer and exposes policy/capabilities', () => {
|
||||
withTempStore(() => {
|
||||
const svc = new SettingsService();
|
||||
const res = svc.list(viewer) as any;
|
||||
assert.ok(res.policy['compression.enabled']);
|
||||
assert.equal(res.capabilities.can_write_standard, false);
|
||||
assert.equal(res.capabilities.can_write_sensitive, false);
|
||||
});
|
||||
});
|
||||
|
||||
test('viewer cannot write settings', () => {
|
||||
withTempStore(() => {
|
||||
const svc = new SettingsService();
|
||||
assert.throws(
|
||||
() => svc.set({ key: 'compression.enabled', value: true, reason: 'test viewer deny' }, viewer),
|
||||
ForbiddenException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('org-admin writes and rolls back through governed store with audit', () => {
|
||||
withTempStore((storeFile) => {
|
||||
const svc = new SettingsService();
|
||||
const first = svc.set({ key: 'compression.enabled', value: true, reason: 'enable compression' }, admin) as any;
|
||||
const second = svc.set({ key: 'compression.enabled', value: false, reason: 'disable compression' }, admin) as any;
|
||||
const rolled = svc.rollback({ key: 'compression.enabled', reason: 'rollback compression' }, admin) as any;
|
||||
|
||||
assert.equal(first.setting.version, 1);
|
||||
assert.equal(second.setting.version, 2);
|
||||
assert.equal(rolled.setting.version, 3);
|
||||
assert.equal(rolled.setting.value, true);
|
||||
assert.equal(rolled.audit_verify.ok, true);
|
||||
assert.ok(existsSync(storeFile));
|
||||
|
||||
const raw = JSON.parse(readFileSync(storeFile, 'utf8'));
|
||||
assert.equal(raw.audit.length, 3);
|
||||
assert.equal(raw.audit[0].action, 'set');
|
||||
assert.equal(raw.audit[2].action, 'rollback');
|
||||
});
|
||||
});
|
||||
|
||||
test('sensitive setting requires approval even for org-admin in dev gate', () => {
|
||||
withTempStore(() => {
|
||||
const svc = new SettingsService();
|
||||
assert.throws(
|
||||
() => svc.set({ key: 'security.strict', value: false, reason: 'loosen strict mode' }, admin),
|
||||
ForbiddenException,
|
||||
);
|
||||
const res = svc.set({
|
||||
key: 'security.strict',
|
||||
value: true,
|
||||
reason: 'tighten strict mode with approval token',
|
||||
approval: 'approved-in-dev',
|
||||
}, admin) as any;
|
||||
assert.equal(res.setting.value, true);
|
||||
});
|
||||
});
|
||||
@@ -47,6 +47,30 @@ test('security()/governance()/cost() return objects with expected keys', () => {
|
||||
assert.ok('provider_tokens' in (svc.cost() as any));
|
||||
});
|
||||
|
||||
test('commandCenter() returns evidence-backed widget contract', () => {
|
||||
const svc = new TelemetryService();
|
||||
const command = svc.commandCenter() as any;
|
||||
const ids = Object.keys(command.widgets);
|
||||
assert.deepEqual(ids.sort(), [
|
||||
'certification',
|
||||
'finops',
|
||||
'hitl',
|
||||
'kill_switch',
|
||||
'maturity',
|
||||
'security',
|
||||
'self_improve',
|
||||
'traceability',
|
||||
]);
|
||||
for (const w of Object.values(command.widgets) as any[]) {
|
||||
assert.ok(w.id && w.title && w.status);
|
||||
assert.ok(w.envelope && typeof w.envelope.artifact_path === 'string');
|
||||
assert.ok(['verified', 'present_unverified', 'missing'].includes(w.envelope.status));
|
||||
assert.equal(typeof w.envelope.verified, 'boolean');
|
||||
}
|
||||
assert.ok(Array.isArray(command.briefing));
|
||||
assert.ok(Array.isArray(command.ticker));
|
||||
});
|
||||
|
||||
test('freshness helpers behave (age is number|null, isStale is boolean)', () => {
|
||||
const age = metricsAgeSeconds();
|
||||
assert.ok(age === null || typeof age === 'number');
|
||||
|
||||
Reference in New Issue
Block a user