// PR #1916 redaction (org-redact5): a refusal names the file by its label, never // by host path. Every fixture here sits under a directory named for the sentinel, // so a refusal that quoted the path would carry it. import { test } from 'node:test'; import assert from 'node:assert/strict'; import { chmodSync, copyFileSync, mkdirSync, readFileSync, renameSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { REPO_ROLES, tmp, writeJson } from './helpers.mjs'; import { parseJsonFileText, readJsonFile } from '../src/util.mjs'; import { loadAccess } from '../src/access.mjs'; import { validateProjectDocument } from '../src/project.mjs'; import { loadSeatSets } from '../src/library-seats.mjs'; import { loadRole, loadRoleFile } from '../src/role.mjs'; import { loadBusiness } from '../src/organization-config.mjs'; import { createOrg, deleteOrg, notificationShow, templateRender } from '../src/org-service.mjs'; import { checkCommands } from '../src/hosts.mjs'; import { BUSINESS_CONFIG, runConfigCommand } from '../src/config-command.mjs'; const SENTINEL = 'sk-test-NOT-A-SECRET-1234'; function under(t) { const top = tmp(); t.after(() => rmSync(top, { recursive: true, force: true })); const root = join(top, SENTINEL); mkdirSync(root, { mode: 0o700 }); return root; } // The refusal is exactly `message`, with its exit code, and carries neither the // sentinel nor any of `paths`. function refusal(message, { exitCode, name = 'BusinessError', paths = [] } = {}) { return (error) => { assert.equal(error.name, name); if (exitCode !== undefined) assert.equal(error.exitCode, exitCode); assert.equal(error.message, message); assert.ok(!error.message.includes(SENTINEL), error.message); for (const path of paths) assert.ok(!error.message.includes(path), error.message); return true; }; } test('readJsonFile: a missing file is refused by label, exit 4', (t) => { const file = join(under(t), 'config.json'); assert.throws(() => readJsonFile(file, 'config file'), refusal('config file not found', { exitCode: 4, paths: [file] })); }); test('readJsonFile: a symbolic link is refused by label at open, exit 4', (t) => { const root = under(t), file = join(root, 'config.json'); writeJson(join(root, 'real.json'), {}); symlinkSync(join(root, 'real.json'), file); assert.throws(() => readJsonFile(file, 'config file'), refusal('config file must be a regular, non-symbolic-link file', { exitCode: 4, paths: [file] })); }); test('readJsonFile: a directory is refused by label after the stat, exit 4', (t) => { const file = join(under(t), 'config.json'); mkdirSync(file); assert.throws(() => readJsonFile(file, 'config file'), refusal('config file must be a regular, non-symbolic-link file', { exitCode: 4, paths: [file] })); }); test('readJsonFile: invalid JSON is refused by label, exit 2', (t) => { const file = writeJson(join(under(t), 'config.json'), '{not json'); assert.throws(() => readJsonFile(file, 'config file'), refusal('config file is not valid JSON', { exitCode: 2, paths: [file] })); }); test('parseJsonFileText: the SyntaxError names the label only', (t) => { const file = join(under(t), 'config.json'); assert.throws(() => parseJsonFileText('{not json', file, 'config file'), refusal('config file is not valid JSON', { name: 'SyntaxError', paths: [file] })); }); test('loadAccess: an access file of another uid is refused without its path', (t) => { const file = writeJson(join(under(t), 'access.json'), {}); const uid = process.getuid() + 1; assert.throws(() => loadAccess(file, {}, { uid }), refusal(`access file must belong to uid ${uid}`, { exitCode: 2, paths: [file] })); }); test('loadAccess: a group-writable access file is refused without its path', (t) => { const file = writeJson(join(under(t), 'access.json'), {}, 0o620); assert.throws(() => loadAccess(file, {}), refusal('access file must not be writable by group or other (mode 620)', { exitCode: 2, paths: [file] })); }); for (const [what, document, message] of [ ['projectVersion', { projectVersion: 2, id: 'stack' }, 'project file "projectVersion" must be 1'], ['vars', { projectVersion: 1, id: 'stack', vars: [] }, 'project file vars must be a JSON object'], ['roles', { projectVersion: 1, id: 'stack', roles: [] }, 'project file roles must be a JSON object'], ['role instance', { projectVersion: 1, id: 'stack', roles: { 'Bad!': {} } }, 'project file role instance must match /^[a-z0-9][a-z0-9._-]{0,63}$/'], ['roles entry', { projectVersion: 1, id: 'stack', roles: { coder: [] } }, 'project file roles.coder must be a JSON object'], ['roles entry key', { projectVersion: 1, id: 'stack', roles: { coder: { extra: 1 } } }, 'unsupported project file roles.coder key: "extra"'], ['roles entry vars', { projectVersion: 1, id: 'stack', roles: { coder: { vars: [] } } }, 'project file roles.coder.vars must be a JSON object'], ]) test(`validateProjectDocument: ${what} is refused by label, never by the project root`, (t) => { const file = join(under(t), 'project', '.mosaic', 'project.json'); assert.throws(() => validateProjectDocument(document, file), refusal(message, { exitCode: 2, paths: [file] })); }); test('loadSeatSets: a config directory that is a file is refused by its error code', (t) => { const dir = join(under(t), 'config'); writeFileSync(dir, ''); assert.throws(() => loadSeatSets({ dir, rolesDir: REPO_ROLES }), refusal('cannot list businesses in the config directory: ENOTDIR', { exitCode: 2, paths: [dir] })); }); test('loadSeatSets: a businesses entry that does not list is refused by its error code', (t) => { const dir = join(under(t), 'config'); mkdirSync(dir); writeFileSync(join(dir, 'businesses'), ''); assert.throws(() => loadSeatSets({ dir, rolesDir: REPO_ROLES }), refusal('cannot list businesses in the config directory: ENOTDIR', { exitCode: 2, paths: [dir] })); }); test('loadRole: a missing role file is named by its checked name', (t) => { const dir = under(t); assert.throws(() => loadRole(dir, 'coder'), refusal('role file coder.json not found', { exitCode: 4, paths: [dir] })); }); test('loadRoleFile: a file name outside the id pattern is not quoted', (t) => { const file = join(under(t), 'Not An Id.json'); assert.throws(() => loadRoleFile(file), refusal('role file not found', { exitCode: 4, paths: [file] })); }); test('loadRoleFile: invalid JSON is named by its checked file name', (t) => { const dir = under(t); writeJson(join(dir, 'coder.json'), '{not json'); assert.throws(() => loadRole(dir, 'coder'), refusal('role file coder.json is not valid JSON', { exitCode: 2, paths: [dir] })); }); test('loadBusiness: a missing business file is named by its checked id', (t) => { const dir = join(under(t), 'config'); assert.throws(() => loadBusiness('acme', { dir, rolesDir: REPO_ROLES }), refusal('business file acme.json not found', { exitCode: 4, paths: [dir] })); }); test('loadBusiness: a symbolic link business file is named by its checked id', (t) => { const root = under(t), dir = join(root, 'config'); writeJson(join(root, 'real.json'), {}); mkdirSync(join(dir, 'businesses'), { recursive: true }); symlinkSync(join(root, 'real.json'), join(dir, 'businesses', 'acme.json')); assert.throws(() => loadBusiness('acme', { dir, rolesDir: REPO_ROLES }), refusal('business file acme.json must be a regular, non-symbolic-link file', { exitCode: 4, paths: [dir] })); }); test('deleteOrg: a failed deletion names the generated receipt folder under the data root, never the host path', async (t) => { const root = under(t); const ctx = { configDir: join(root, 'config'), dataRoot: join(root, 'data'), instance: 'test', principal: { provider: 'local-dev', iss: 'local-dev', sub: 'local-dev-owner' } }; for (const dir of [ctx.configDir, ctx.dataRoot]) mkdirSync(dir, { mode: 0o700 }); await createOrg(ctx, 'alpha', await templateRender('alpha')); const folder = 'alpha-2026-11-10T00-00-00-000Z-0123abcd'; const registryFile = join(ctx.configDir, 'org-runtime.json'); const registry = JSON.parse(readFileSync(registryFile)); registry.orgs.alpha.state = 'deleting'; registry.orgs.alpha.deletion = { actor: ctx.principal, time: new Date().toISOString(), revision: 1, folder }; writeJson(registryFile, registry); const destination = join(ctx.dataRoot, 'deleted', folder); mkdirSync(destination, { recursive: true, mode: 0o700 }); writeJson(join(destination, 'receipt.json'), '{not json'); chmodSync(join(destination, 'receipt.json'), 0o600); await assert.rejects(deleteOrg(ctx, 'alpha', { confirm: 'alpha', ifRevision: 1, busStop: async () => assert.fail('must refuse first') }), refusal(`deletion receipt file is not valid JSON; deletion remains in progress. Inspect deleted/${folder}/receipt.json under the data root and rerun mosaic org delete alpha --confirm alpha`, { name: 'SyntaxError', paths: [ctx.dataRoot, destination] })); }); // Review round (org-redact5 Sol P2 #1): a raw filesystem error from the organization // service names the operation, the file by label and the error code, never the host path. async function deletingOrg(t) { const root = under(t); const ctx = { configDir: join(root, 'config'), dataRoot: join(root, 'data'), instance: 'test', principal: { provider: 'local-dev', iss: 'local-dev', sub: 'local-dev-owner' } }; for (const dir of [ctx.configDir, ctx.dataRoot]) mkdirSync(dir, { mode: 0o700 }); await createOrg(ctx, 'alpha', await templateRender('alpha')); return { root, ctx }; } test('deleteOrg: a receipt that is a symbolic link is refused by label and error code, never by host path', async (t) => { const { root, ctx } = await deletingOrg(t); const folder = 'alpha-2026-11-10T00-00-00-000Z-0123abcd'; const registryFile = join(ctx.configDir, 'org-runtime.json'); const registry = JSON.parse(readFileSync(registryFile)); registry.orgs.alpha.state = 'deleting'; registry.orgs.alpha.deletion = { actor: ctx.principal, time: new Date().toISOString(), revision: 1, folder }; writeJson(registryFile, registry); const destination = join(ctx.dataRoot, 'deleted', folder); mkdirSync(destination, { recursive: true, mode: 0o700 }); writeJson(join(root, 'elsewhere.json'), {}); symlinkSync(join(root, 'elsewhere.json'), join(destination, 'receipt.json')); await assert.rejects(deleteOrg(ctx, 'alpha', { confirm: 'alpha', ifRevision: 1, busStop: async () => assert.fail('must refuse first') }), (error) => { assert.equal(error.code, 'ELOOP'); assert.equal(error.syscall, 'open'); assert.equal(error.deleteStep, 'prepare'); return refusal(`cannot open deletion receipt file (ELOOP); deletion remains in progress. Inspect deleted/${folder}/receipt.json under the data root and rerun mosaic org delete alpha --confirm alpha`, { name: 'Error', paths: [ctx.dataRoot, destination, root] })(error); }); }); test('notificationShow: a missing notifier directory is refused by label and error code, never by host path', async (t) => { const { root, ctx } = await deletingOrg(t); const notify = join(ctx.dataRoot, 'org-runtime', 'alpha', 'notify'); renameSync(notify, `${notify}.gone`); await assert.rejects(notificationShow(ctx, 'alpha'), (error) => { assert.equal(error.code, 'ENOENT'); return refusal('cannot lstat notifier directory (ENOENT)', { name: 'Error', paths: [ctx.dataRoot, notify, root] })(error); }); }); // Sol P3 #4: a refused command given by absolute path is named by the basename refusedCommand matched. test('checkCommands: a refused interpreter by absolute path is named by its basename, never by host path', (t) => { const bin = join(under(t), 'bin'); assert.throws(() => checkCommands([[join(bin, 'bash'), '-c']], 'grant.commands'), refusal('grant.commands[0] starts with bash, a shell, runner or interpreter, which would grant any command', { exitCode: 2, paths: [bin] })); }); // Sol P2 #3: the system config refusal names where the config came from, never its path. test('config validate: a missing system config is named by where it came from, never by host path', (t) => { const root = under(t), config = join(root, 'config.json'); const err = { text: '' }, out = { text: '' }; const streams = { stdout: { write: (s) => { out.text += s; return true; } }, stderr: { write: (s) => { err.text += s; return true; } } }; assert.equal(runConfigCommand(['validate', 'acme'], { ...streams, env: { PATH: process.env.PATH, HOME: root, MOSAIC_CONFIG: config } }), 3); assert.equal(out.text, ''); assert.match(err.text, new RegExp(`^${BUSINESS_CONFIG.prefix}: system config problem \\(mosaic-config exit [1-9][0-9]*\\): .*the system config MOSAIC_CONFIG names\n$`)); assert.ok(!err.text.includes(SENTINEL) && !err.text.includes(config), err.text); });