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); });