Files
jason.woltje a8d9eecf65 organization: move packages/business to packages/organization (rename r2 stage B1)
git mv packages/business -> packages/organization (@mosaic/organization). The
old package stays as one-line re-export shims of each moved module, with
src/business.mjs re-exporting src/organization-config.mjs and src/cli.mjs
forwarding to the moved executable. BusinessError is OrganizationConfigError
(one constructor, name kept BusinessError); OrganizationError stays distinct.
The bus adapter gains organization-context.mjs (busOrganization), with
business.mjs (busBusiness) as the same function.

Import consumers, manifests, the lockfile, scripts/mosaic, stack-copy.sh
COPY_PATHS (both roots), seat sandbox RUNNER_FILES and the copy closure
walker (organization-imports.mjs, both roots required) move to the new path.
v1 serialization, v1 writers and the stage A fixtures are unchanged; the stage
A contract tests stay where they were and pass as before.
2026-11-09 11:47:48 -06:00

61 lines
4.1 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { organizationWriter } from '../src/organization.mjs';
import { getProjectRemote, remoteProblem } from '../src/project-remotes.mjs';
import { businessDoc, rolesCopy, tmp, writeJson } from './helpers.mjs';
import { mappingEntry } from '../../library/tests/helpers.mjs';
function fixture(t) {
const root = tmp(); t.after(() => rmSync(root, { recursive: true, force: true }));
const doc = businessDoc(root);
doc.projects.books = mappingEntry();
doc.roles.chief.credentials = { gitea: { ...doc.roles.coder.credentials.gitea } };
const dir = join(root, 'config'), file = writeJson(join(dir, 'businesses/acme.json'), doc);
return { file, writer: organizationWriter({ dir, rolesDir: rolesCopy(root) }) };
}
const url = 'https://git.example/owner/repo.git';
test('first save changes only the remote and survives a fresh reader', t => {
const { file, writer } = fixture(t), before = writer.read('acme');
assert.deepEqual(getProjectRemote(writer, 'acme', 'stack'), { project: 'stack', remote: null, revision: before.revision, firstSaveAllowed: true });
const result = writer.setProjectRemote('acme', { revision: before.revision, project: 'stack', remote: url }, 'jason');
const after = writer.read('acme');
const expected = structuredClone(before.document); expected.projects.stack.remote = url;
assert.deepEqual(after.document, expected);
assert.equal(result.revision, after.revision);
assert.deepEqual(getProjectRemote(writer, 'acme', 'stack'), { project: 'stack', remote: url, revision: after.revision, firstSaveAllowed: false });
const log = readFileSync(writer.logFile('acme'), 'utf8').trim().split('\n').map(JSON.parse);
assert.deepEqual(log.at(-1).changes.map(x => x.path), ['projects.stack.remote']);
assert.equal(JSON.parse(readFileSync(file, 'utf8')).projects.stack.remote, url);
});
test('refusals do not change the business and never echo a supplied credential', t => {
const { file, writer } = fixture(t), before = writer.read('acme'), bytes = readFileSync(file);
const refuse = (args, actor, code) => {
assert.throws(() => writer.setProjectRemote('acme', { revision: before.revision, project: 'stack', remote: url, ...args }, actor), e => e.code === code && !e.message.includes('secret'));
assert.deepEqual(readFileSync(file), bytes);
};
for (const value of ['http://git.example/a/b', 'https://user:[email protected]/a/b', 'https://git.example/a/b?secret=x', 'https://git.example/a/b#secret', 'https://git.example/a/../b', 'https://GIT.example/a/b', '', null])
refuse({ remote: value }, 'jason', 'project-remote-invalid');
refuse({ project: 'missing' }, 'jason', 'project-remote-unknown');
refuse({ project: 'books' }, 'jason', 'project-remote-library');
refuse({ revision: 'stale' }, 'jason', 'revision-conflict');
refuse({}, 'tester', 'human-required');
refuse({}, 'coder', 'human-required');
const next = structuredClone(before.document); next.projects.stack.remote = url;
assert.throws(() => writer.write('acme', next, { revision: before.revision, actor: 'jason' }), { code: 'organization-projects-protected' });
next.vars['tracker.pollSeconds']++;
assert.equal(remoteProblem(before.document, next, 'stack', url), 'project-remote-protected');
writer.setProjectRemote('acme', { revision: before.revision, project: 'stack', remote: url }, 'jason');
const saved = readFileSync(file), revision = writer.read('acme').revision;
assert.throws(() => writer.setProjectRemote('acme', { revision, project: 'stack', remote: 'https://git.example/other/repo' }, 'jason'), { code: 'project-remote-configured' });
assert.deepEqual(readFileSync(file), saved);
});
test('the writer lock refuses competing remote writes', t => {
const { writer } = fixture(t), revision = writer.read('acme').revision;
writer.hold('acme', () => assert.throws(() => writer.setProjectRemote('acme', { revision, project: 'stack', remote: url }, 'jason'), { code: 'organization-busy' }));
assert.equal(writer.read('acme').revision, revision);
});