Files

216 lines
13 KiB
JavaScript

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