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.
61 lines
4.1 KiB
JavaScript
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);
|
|
});
|