PR #1916 rule: a refusal names the field or file, never a config value or a host path. readJsonFile's three refusals and parseJsonFileText now name the file by its `what` label only. Callers whose label alone is ambiguous add a name that already passed a strict pattern (business id, role name, org id, turn id, project key) or a fixed path under the data root. Exit codes, refusal codes and check order are unchanged. organization: util, access, project, config-command, library-seats, org-service deleteOrg receipt, agent-levels regex; T1 files role.mjs and organization-config.mjs change only the readJsonFile label. cli: access lock, accounts owner, host state/lock, notifier journal, notify-config, run-evidence, dev-fixture, git-socket, org-bus, org-console. discord: turn record label names the turn id. Sentinel tests (sk-test-NOT-A-SECRET-1234 as a path component) cover each changed refusal and fail on BASE; old path pins updated.
137 lines
8.1 KiB
JavaScript
137 lines
8.1 KiB
JavaScript
import { test } from 'node:test';
|
|
import fsPromises from 'node:fs/promises';
|
|
import { syncBuiltinESMExports } from 'node:module';
|
|
import assert from 'node:assert/strict';
|
|
import { createHash } from 'node:crypto';
|
|
import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { tmp, writeJson, REPO_ROLES } from './helpers.mjs';
|
|
import { loadBusiness } from '../src/organization-config.mjs';
|
|
import { loadBusiness as legacyLoadBusiness } from '../../business/src/business.mjs';
|
|
import { loadProject } from '../src/project.mjs';
|
|
import { loadRootAgents } from '../src/agent-levels.mjs';
|
|
import { readJsonFile } from '../src/util.mjs';
|
|
import { organizationWriter } from '../src/organization.mjs';
|
|
import { readRegistry, withRegistryWriter } from '../src/org-registry.mjs';
|
|
import { createOrg, deleteOrg, notificationShow, showOrg, templateRender } from '../src/org-service.mjs';
|
|
|
|
const secret = 'sk-test-NOT-A-SECRET-1234';
|
|
const malformed = secret;
|
|
// PR #1916 redaction (org-redact5): the sentinel is also a path component, so a
|
|
// refusal that quoted the host path would carry it.
|
|
function fixture(t) {
|
|
const top = tmp();
|
|
t.after(() => rmSync(top, { recursive: true, force: true }));
|
|
const root = join(top, secret);
|
|
mkdirSync(root, { mode: 0o700 });
|
|
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 });
|
|
return ctx;
|
|
}
|
|
// The refusal names the file by its label and never by path.
|
|
function safe(file, { name = 'SyntaxError', code, exitCode, prefix, label, suffix = '' } = {}) {
|
|
return error => {
|
|
assert.equal(error.name, name);
|
|
assert.equal(error.code, code);
|
|
assert.equal(error.exitCode, exitCode);
|
|
assert.ok(!error.message.includes(file), error.message);
|
|
assert.equal(error.message, `${prefix ?? ''}${label} is not valid JSON${suffix}`);
|
|
assert.doesNotMatch(error.message, /sk-test|private|broken|Unexpected|position|line \d/);
|
|
assert.ok(!error.stack.includes(secret));
|
|
assert.equal(error.cause, undefined);
|
|
return true;
|
|
};
|
|
}
|
|
|
|
for (const [label, load, relative, labelText] of [
|
|
['shared reader', file => readJsonFile(file, 'config file'), 'config.json', 'config file'],
|
|
['organization loader', (_file, ctx) => loadBusiness('alpha', { dir: ctx.configDir, rolesDir: REPO_ROLES }), 'businesses/alpha.json', 'business file alpha.json'],
|
|
['legacy business loader', (_file, ctx) => legacyLoadBusiness('alpha', { dir: ctx.configDir, rolesDir: REPO_ROLES }), 'businesses/alpha.json', 'business file alpha.json'],
|
|
['project loader', (_file, ctx) => loadProject(ctx.configDir), '.mosaic/project.json', 'project file'],
|
|
]) test(`${label} names malformed JSON's file without quoting bytes`, t => {
|
|
const ctx = fixture(t), file = writeJson(join(ctx.configDir, relative), malformed);
|
|
assert.throws(() => load(file, ctx), safe(file, { name: 'BusinessError', exitCode: 2, label: labelText }));
|
|
});
|
|
|
|
test('root agents keep their existing file-only refusal', t => {
|
|
const ctx = fixture(t); writeJson(join(ctx.configDir, 'agents.json'), malformed);
|
|
assert.throws(() => loadRootAgents(ctx.configDir, { rolesDir: REPO_ROLES }), safe(join(ctx.configDir, 'agents.json'), { name: 'BusinessError', exitCode: 2, label: 'root agent file', suffix: ' (agents.json)' }));
|
|
});
|
|
|
|
for (const operation of ['read', 'write', 'recover', 'findAddition']) test(`organization writer ${operation} does not quote malformed file bytes`, async t => {
|
|
const ctx = fixture(t);
|
|
await createOrg(ctx, 'alpha', await templateRender('alpha'));
|
|
const writer = organizationWriter({ dir: ctx.configDir, rolesDir: REPO_ROLES });
|
|
const current = writer.read('alpha');
|
|
const receipt = ['recover', 'findAddition'].includes(operation);
|
|
const file = receipt ? writer.logFile('alpha') : join(ctx.configDir, 'businesses/alpha.json');
|
|
if (receipt) mkdirSync(join(ctx.configDir, 'businesses/.history/alpha'), { recursive: true, mode: 0o700 });
|
|
writeJson(file, malformed + '\n');
|
|
if (operation === 'write') {
|
|
// Exercise the parse after both the revision and registry digest fences.
|
|
current.revision = createHash('sha256').update(readFileSync(file)).digest('hex');
|
|
const registryFile = join(ctx.configDir, 'org-runtime.json');
|
|
const registry = JSON.parse(readFileSync(registryFile)); registry.orgs.alpha.digest = current.revision;
|
|
writeJson(registryFile, registry);
|
|
}
|
|
const invoke = () => operation === 'write'
|
|
? writer.write('alpha', current.document, { revision: current.revision, actor: ctx.principal.sub })
|
|
: writer[operation]('alpha', {});
|
|
assert.throws(invoke, safe(file, { label: receipt ? 'organization receipt file' : 'organization file' }));
|
|
});
|
|
|
|
for (const operation of ['async', 'sync']) test(`${operation} registry read names the file without parser bytes`, async t => {
|
|
const ctx = fixture(t), file = writeJson(join(ctx.configDir, 'org-runtime.json'), malformed);
|
|
if (operation === 'async') await assert.rejects(readRegistry(ctx), safe(file, { name: 'OrgError', code: 'org-mismatch', prefix: 'invalid registry: ', label: 'metadata file' }));
|
|
else assert.throws(() => withRegistryWriter(ctx.configDir, () => assert.fail('must refuse')), safe(file, { label: 'registry file' }));
|
|
});
|
|
|
|
for (const kind of ['owner', 'organization', 'notifier', 'deletion receipt']) test(`service ${kind} refuses malformed JSON without file bytes`, async t => {
|
|
const ctx = fixture(t);
|
|
const created = await createOrg(ctx, 'alpha', await templateRender('alpha'));
|
|
let file;
|
|
if (kind === 'owner') file = join(created.root, 'owner.json');
|
|
if (kind === 'organization') file = join(ctx.configDir, 'businesses/alpha.json');
|
|
if (kind === 'notifier') file = join(created.root, 'notify/alpha/notify.json');
|
|
if (kind === 'deletion receipt') {
|
|
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: 'alpha-redaction' };
|
|
writeJson(registryFile, registry);
|
|
const destination = join(ctx.dataRoot, 'deleted/alpha-redaction');
|
|
mkdirSync(destination, { recursive: true, mode: 0o700 });
|
|
file = join(destination, 'receipt.json');
|
|
}
|
|
writeJson(file, malformed);
|
|
if (kind === 'organization') {
|
|
const registryFile = join(ctx.configDir, 'org-runtime.json');
|
|
const registry = JSON.parse(readFileSync(registryFile));
|
|
registry.orgs.alpha.digest = createHash('sha256').update(malformed).digest('hex');
|
|
writeJson(registryFile, registry);
|
|
}
|
|
const label = { owner: 'organization owner file', organization: 'organization file', notifier: 'notifier configuration file', 'deletion receipt': 'deletion receipt file' }[kind];
|
|
if (kind === 'notifier') await assert.rejects(notificationShow(ctx, 'alpha'), safe(file, { label }));
|
|
else if (kind === 'deletion receipt') await assert.rejects(deleteOrg(ctx, 'alpha', { confirm: 'alpha', ifRevision: 1, busStop: async () => assert.fail('must refuse first') }),
|
|
safe(file, { label, suffix: '; deletion remains in progress. Inspect the deletion receipt under the data root and rerun mosaic org delete alpha --confirm alpha' }));
|
|
else await assert.rejects(showOrg(ctx, 'alpha'), safe(file, { label }));
|
|
});
|
|
|
|
// The packaged template is immutable at runtime; substitute only its read result
|
|
// to exercise a corrupt template without editing repository data.
|
|
test('template reader refuses malformed JSON without quoting template bytes', async t => {
|
|
const original = fsPromises.readFile;
|
|
t.mock.method(fsPromises, 'readFile', function(file, ...args) {
|
|
if (file instanceof URL && file.pathname.endsWith('/org-template.json')) return Promise.resolve(malformed);
|
|
return original.call(this, file, ...args);
|
|
});
|
|
syncBuiltinESMExports();
|
|
try {
|
|
await assert.rejects(templateRender('alpha'), safe('/org-template.json', { label: 'organization template file' }));
|
|
} finally {
|
|
t.mock.restoreAll();
|
|
syncBuiltinESMExports();
|
|
}
|
|
});
|