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