78 lines
4.9 KiB
JavaScript
78 lines
4.9 KiB
JavaScript
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { validateRoleDocument } from '../src/role.mjs';
|
|
import { checkAccess } from '../src/access.mjs';
|
|
import { BusinessError } from '../src/errors.mjs';
|
|
|
|
const bad = 'sk-test-NOT-A-SECRET-1234';
|
|
const badId = `${bad.toLowerCase()} "`;
|
|
function refusal(fn, names = [], omitted = bad, validation = null) {
|
|
assert.throws(fn, error => {
|
|
assert.ok(error instanceof BusinessError);
|
|
assert.equal(error.exitCode, 2);
|
|
for (const name of names) assert.ok(error.message.includes(name), error.message);
|
|
assert.doesNotMatch(error.message + error.stack, /sk-test|NOT-A-SECRET/i);
|
|
assert.ok(!error.message.includes(omitted), error.message);
|
|
if (validation !== null) assert.match(error.message, validation);
|
|
return true;
|
|
});
|
|
}
|
|
function role(t, edit = () => {}) {
|
|
const root = mkdtempSync(join(tmpdir(), 'names-'));
|
|
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
writeFileSync(join(root, 'r.md'), '# R\n\n## Authority\n');
|
|
writeFileSync(join(root, 'companion.md'), '# R\n\n## Authority\n');
|
|
const doc = { roleVersion: 2, name: 'r', title: 'R', contract: 'r.md', tools: ['read'], network: 'none',
|
|
authority: { withinRole: [], crossRole: [] }, credentials: [] };
|
|
edit(doc, root);
|
|
return () => validateRoleDocument(doc, join(root, `${doc.name}.json`));
|
|
}
|
|
|
|
for (const key of ['withinRole', 'crossRole']) test(`gated ${key} names the known action; unknown action stays unnamed`, t => {
|
|
refusal(role(t, d => { d.authority[key] = ['deploy']; }), ['authority.' + key, 'deploy']);
|
|
refusal(role(t, d => { d.authority[key] = [bad]; }));
|
|
});
|
|
test('overlapping authority lists name every known action; invalid entries stop first', t => {
|
|
refusal(role(t, d => { d.authority = { withinRole: ['message.send', 'record.write'], crossRole: ['message.send', 'record.write'] }; }), ['message.send', 'record.write']);
|
|
refusal(role(t, d => { d.authority = { withinRole: [bad], crossRole: [bad] }; }));
|
|
});
|
|
test('removed hireable key refuses without echoing its value', t => {
|
|
refusal(role(t, d => { d.hireable = [badId]; }), ['hireable'], badId, /unsupported role key/);
|
|
});
|
|
test('gitea category conflict names the checked category; invalid scope stops first', t => {
|
|
refusal(role(t, d => { d.credentials = [{ service: 'gitea', scopes: ['read:issue', 'write:issue'] }]; }), ['issue']);
|
|
refusal(role(t, d => { d.credentials = [{ service: 'gitea', scopes: [bad] }]; }));
|
|
});
|
|
test('duplicate credential names the checked service; invalid service stops first', t => {
|
|
refusal(role(t, d => { d.credentials = [{ service: 'gitea', scopes: ['read:issue'] }, { service: 'gitea', scopes: ['read:issue'] }]; }), ['gitea']);
|
|
refusal(role(t, d => { d.credentials = [{ service: bad }, { service: bad }]; }));
|
|
});
|
|
for (const field of ['tools', 'credentials']) test(`creative ${field} names the checked wider grant; invalid grant stops first`, t => {
|
|
const edit = d => { d.name = 'companion'; d.contract = 'companion.md'; d.messaging = { sendTo: ['human'], receiveFrom: ['human'] }; };
|
|
refusal(role(t, d => { edit(d); if (field === 'tools') d.tools.push('bash'); else d.credentials = [{ service: 'vikunja', scopes: { tasks: ['read_one'] } }]; }), [field === 'tools' ? 'bash' : 'vikunja']);
|
|
refusal(role(t, d => { edit(d); if (field === 'tools') d.tools = [bad]; else d.credentials = [{ service: bad }]; }));
|
|
});
|
|
|
|
const context = () => ({ providers: ['test'], peers: [], businesses: { acme: { human: 'jason', instances: ['coder'] } }, ssoSubjects: [] });
|
|
const access = () => ({ accessVersion: 1, revision: 1,
|
|
users: [{ id: 'bean', home: 'local', subjects: [{ provider: 'test', sub: 'one' }] }],
|
|
grants: [{ id: 'grant', user: 'bean', business: 'acme', projects: ['*'], instances: ['coder'], level: 'viewer' }] });
|
|
for (const kind of ['reserved', 'human', 'user', 'grant', 'project', 'instance']) test(`access ${kind} conflict names checked id; malformed replacement stops first`, () => {
|
|
function run(value) {
|
|
const d = access(), ctx = context();
|
|
if (kind === 'reserved' || kind === 'human') d.users[0].id = value;
|
|
if (kind === 'user') d.users.push({ ...structuredClone(d.users[0]), id: value, subjects: [{ provider: 'test', sub: 'two' }] });
|
|
if (kind === 'grant') { d.grants[0].id = value; d.grants.push(structuredClone(d.grants[0])); }
|
|
if (kind === 'project') d.grants[0].projects = [value, value];
|
|
if (kind === 'instance') d.grants[0].instances = [value, value];
|
|
return () => checkAccess(d, ctx);
|
|
}
|
|
const value = { reserved: 'human', human: 'jason', user: 'bean', grant: 'g-known-1', project: '11111111-2222-4333-8444-555555555555', instance: 'coder' }[kind];
|
|
refusal(run(value), [value]);
|
|
const validation = kind === 'project' ? /lowercase UUID/ : kind === 'instance' ? /declared instance/ : /\.id must match/;
|
|
refusal(run(badId), [], badId, validation);
|
|
});
|