Files
stack/packages/organization/tests/json-redaction.test.mjs

131 lines
7.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;
function fixture(t) {
const root = tmp();
t.after(() => rmSync(root, { recursive: true, force: true }));
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;
}
function safe(file, { name = 'SyntaxError', code, exitCode, prefix } = {}) {
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.match(error.message, /not valid JSON/);
assert.doesNotMatch(error.message, /sk-test|private|broken|Unexpected|position|line \d/);
assert.ok(!error.stack.includes(secret));
assert.equal(error.cause, undefined);
if (prefix) assert.ok(error.message.startsWith(prefix));
return true;
};
}
for (const [label, load, relative] of [
['shared reader', file => readJsonFile(file, 'config file'), 'config.json'],
['organization loader', (_file, ctx) => loadBusiness('alpha', { dir: ctx.configDir, rolesDir: REPO_ROLES }), 'businesses/alpha.json'],
['legacy business loader', (_file, ctx) => legacyLoadBusiness('alpha', { dir: ctx.configDir, rolesDir: REPO_ROLES }), 'businesses/alpha.json'],
['project loader', (_file, ctx) => loadProject(ctx.configDir), '.mosaic/project.json'],
]) 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 }));
});
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('agents.json', { name: 'BusinessError', exitCode: 2 }));
});
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));
});
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: ' }));
else assert.throws(() => withRegistryWriter(ctx.configDir, () => assert.fail('must refuse')), safe(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);
}
if (kind === 'notifier') await assert.rejects(notificationShow(ctx, 'alpha'), safe(file));
else if (kind === 'deletion receipt') await assert.rejects(deleteOrg(ctx, 'alpha', { confirm: 'alpha', ifRevision: 1, busStop: async () => assert.fail('must refuse first') }), safe(file));
else await assert.rejects(showOrg(ctx, 'alpha'), safe(file));
});
// 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'));
} finally {
t.mock.restoreAll();
syncBuiltinESMExports();
}
});