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.
181 lines
11 KiB
JavaScript
181 lines
11 KiB
JavaScript
// Lifecycle Task 2 (#797 / Gitea #1839): the one narrow organization-writer method that adds a
|
|
// bootstrapped project's mapping. Every ordinary writer keeps refusing project changes.
|
|
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { organizationWriter } from '../src/organization.mjs';
|
|
import { managedState, markerBytes, markerFile } from '../src/project-bootstrap.mjs';
|
|
import { businessDoc, rolesCopy, tmp, writeJson } from './helpers.mjs';
|
|
import { mappingEntry } from '../../library/tests/helpers.mjs';
|
|
|
|
const DIGEST = 'a'.repeat(64);
|
|
|
|
function fixture(t, { barrier } = {}) {
|
|
const root = tmp(); t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
const doc = businessDoc(root);
|
|
doc.projects = { books: mappingEntry(), ...doc.projects };
|
|
doc.roles.chief.credentials = { gitea: { ...doc.roles.coder.credentials.gitea } };
|
|
const dir = join(root, 'config'), file = writeJson(join(dir, 'businesses/acme.json'), doc);
|
|
const dataRoot = join(root, 'data');
|
|
const writer = organizationWriter({ dir, rolesDir: rolesCopy(root), ...(barrier ? { barrier: step => barrier(step) } : {}) });
|
|
const entry = { root: join(dataRoot, 'products/acme/timer/code'), repo: join(dataRoot, 'projects/acme/timer') };
|
|
const receipt = { request: 'req-1', plan_digest: DIGEST, project: 'timer', data_root: dataRoot };
|
|
const add = (over = {}) => writer.addProject('acme', { revision: writer.read('acme').revision, project: 'timer', entry, receipt, ...over }, 'jason');
|
|
return { root, dataRoot, file, writer, entry, receipt, add, log: writer.logFile('acme') };
|
|
}
|
|
const bytes = f => ({ file: readFileSync(f.file), log: (() => { try { return readFileSync(f.log); } catch { return null; } })() });
|
|
const untouched = (f, before) => {
|
|
assert.deepEqual(readFileSync(f.file), before.file);
|
|
assert.deepEqual((() => { try { return readFileSync(f.log); } catch { return null; } })(), before.log);
|
|
};
|
|
|
|
test('add_project_publishes_exactly_one_mapping_with_a_matching_receipt', t => {
|
|
const f = fixture(t), old = f.writer.read('acme');
|
|
const out = f.add();
|
|
const now = f.writer.read('acme');
|
|
assert.equal(out.revision, now.revision);
|
|
assert.deepEqual(Object.keys(now.document.projects).sort(), ['books', 'stack', 'timer']);
|
|
assert.deepEqual(now.document.projects.timer, f.entry);
|
|
assert.deepEqual(now.document.projects.stack, old.document.projects.stack);
|
|
assert.deepEqual(now.document.projects.books, old.document.projects.books);
|
|
const rest = structuredClone(now.document), was = structuredClone(old.document);
|
|
delete rest.projects; delete was.projects;
|
|
assert.deepEqual(rest, was, 'nothing outside projects changed');
|
|
const last = JSON.parse(readFileSync(f.log, 'utf8').trim().split('\n').at(-1));
|
|
assert.equal(last.state, 'committed'); assert.equal(last.actor, 'jason'); assert.equal(last.before, old.revision); assert.equal(last.after, now.revision);
|
|
assert.deepEqual(last.changes.map(c => c.path), ['projects.timer']);
|
|
assert.deepEqual(last.detail, { verb: 'project.add', request: 'req-1', plan_digest: DIGEST, project: 'timer' });
|
|
assert.deepEqual(f.writer.findAddition('acme', { request: 'req-1', plan_digest: DIGEST, project: 'timer' }),
|
|
{ after: now.revision, before: old.revision, transaction: last.transaction });
|
|
assert.equal(f.writer.findAddition('acme', { request: 'req-1', plan_digest: 'b'.repeat(64), project: 'timer' }), null);
|
|
assert.equal(f.writer.findAddition('acme', { request: 'req-2', plan_digest: DIGEST, project: 'timer' }), null);
|
|
});
|
|
|
|
test('add_project_refuses_everything_but_the_exact_addition_and_changes_no_byte', t => {
|
|
const f = fixture(t), before = bytes(f);
|
|
const refuse = (what, over, code = 'organization-project-add-refused') => {
|
|
assert.throws(() => f.add(over), { code }, what); untouched(f, before);
|
|
};
|
|
refuse('replacement', { project: 'stack', receipt: { ...f.receipt, project: 'stack' }, entry: { ...f.entry } });
|
|
refuse('library name', { project: 'books', receipt: { ...f.receipt, project: 'books' } });
|
|
refuse('library entry shape', { entry: { ...mappingEntry() } });
|
|
refuse('extra entry key', { entry: { ...f.entry, kind: 'library' } });
|
|
refuse('unrelated entry key', { entry: { ...f.entry, tracker: { project: 3 } } });
|
|
refuse('entry root off the product layout', { entry: { ...f.entry, root: join(f.root, 'elsewhere') } });
|
|
refuse('entry repo off the records layout', { entry: { ...f.entry, repo: join(f.root, 'elsewhere') } });
|
|
refuse('entry in another business area', { entry: { root: join(f.dataRoot, 'products/other/timer/code'), repo: join(f.dataRoot, 'projects/other/timer') } });
|
|
refuse('receipt names another project', { receipt: { ...f.receipt, project: 'other' } });
|
|
refuse('receipt data root differs', { receipt: { ...f.receipt, data_root: join(f.root, 'other-data') } });
|
|
refuse('receipt digest is not a digest', { receipt: { ...f.receipt, plan_digest: 'nope' } });
|
|
refuse('receipt carries extra keys', { receipt: { ...f.receipt, extra: 1 } });
|
|
refuse('receipt lacks the request', { receipt: { project: 'timer', plan_digest: DIGEST, data_root: f.dataRoot } });
|
|
refuse('relative project key', { project: '../timer', receipt: { ...f.receipt, project: '../timer' } });
|
|
assert.throws(() => f.add({ revision: 'stale' }), { code: 'revision-conflict' }); untouched(f, before);
|
|
assert.throws(() => f.writer.addProject('acme', { revision: f.writer.read('acme').revision, project: 'timer', entry: f.entry, receipt: f.receipt }, ''), /authenticated actor/);
|
|
untouched(f, before);
|
|
});
|
|
|
|
test('add_project_checks_what_lands_on_disk_not_only_the_input', t => {
|
|
const f = fixture(t), before = bytes(f);
|
|
const entry = { ...f.entry };
|
|
Object.defineProperty(entry, 'toJSON', { value: () => ({ ...f.entry, root: join(f.root, 'serialized-elsewhere') }) });
|
|
assert.throws(() => f.add({ entry }), { code: 'organization-project-add-refused' });
|
|
untouched(f, before);
|
|
});
|
|
|
|
test('add_project_twice_is_refused_and_the_first_receipt_stays_findable', t => {
|
|
const f = fixture(t); f.add();
|
|
const after = bytes(f);
|
|
assert.throws(() => f.add(), { code: 'organization-project-add-refused' });
|
|
untouched(f, after);
|
|
assert.ok(f.writer.findAddition('acme', { request: 'req-1', plan_digest: DIGEST, project: 'timer' }));
|
|
});
|
|
|
|
test('ordinary_writers_still_refuse_any_project_change_even_the_exact_addition', t => {
|
|
const f = fixture(t), before = bytes(f);
|
|
const change = fn => {
|
|
const next = structuredClone(f.writer.read('acme').document); fn(next);
|
|
assert.throws(() => f.writer.write('acme', next, { revision: f.writer.read('acme').revision, actor: 'jason' }), { code: 'organization-projects-protected' });
|
|
untouched(f, before);
|
|
};
|
|
change(next => { next.projects.timer = f.entry; });
|
|
change(next => { delete next.projects.stack; });
|
|
change(next => { next.projects.stack.root = join(f.root, 'moved'); });
|
|
// The record verb writes no document, so it cannot add one either.
|
|
assert.equal(typeof f.writer.record, 'function');
|
|
assert.equal(Object.hasOwn(f.writer, 'commit'), false, 'the shared commit is private');
|
|
});
|
|
|
|
test('hold_runs_the_check_under_the_writer_lock_after_recovery', t => {
|
|
const f = fixture(t), seen = [];
|
|
const out = f.writer.hold('acme', ({ revision, document }) => {
|
|
seen.push(revision, document.id);
|
|
assert.throws(() => f.add(), { code: 'organization-busy' }, 'a writer inside the hold finds the lock');
|
|
return 'checked';
|
|
});
|
|
assert.equal(out, 'checked'); assert.deepEqual(seen, [f.writer.read('acme').revision, 'acme']);
|
|
assert.ok(f.add(), 'the lock is released afterwards');
|
|
assert.throws(() => f.writer.hold('acme', async () => {}), { code: 'organization-hold-sync' }, 'a hold callback must be synchronous');
|
|
});
|
|
|
|
for (const rejected of [false, true]) {
|
|
test(`hold_async_keeps_writers_busy_until_${rejected ? 'rejection' : 'fulfillment'}`, async t => {
|
|
const f = fixture(t), before = f.writer.read('acme').revision, failure = new Error('effect failed');
|
|
const held = f.writer.holdAsync('acme', async ({ revision }) => {
|
|
assert.equal(revision, before);
|
|
await Promise.resolve();
|
|
assert.throws(() => f.add(), { code: 'organization-busy' });
|
|
if (rejected) throw failure;
|
|
return 'done';
|
|
});
|
|
if (rejected) await assert.rejects(held, error => error === failure);
|
|
else assert.equal(await held, 'done');
|
|
assert.equal(f.writer.read('acme').revision, before);
|
|
assert.ok(f.add(), 'the lock releases on either outcome');
|
|
});
|
|
}
|
|
|
|
test('a_crash_before_publication_leaves_the_file_and_retry_recovers_the_prepared_receipt', t => {
|
|
let armed = true;
|
|
const f = fixture(t, { barrier: step => { if (armed && step === 'prepared') { armed = false; throw new Error('killed'); } } });
|
|
const before = readFileSync(f.file);
|
|
assert.throws(() => f.add(), /killed/);
|
|
assert.deepEqual(readFileSync(f.file), before);
|
|
assert.equal(f.writer.findAddition('acme', { request: 'req-1', plan_digest: DIGEST, project: 'timer' }), null);
|
|
f.add();
|
|
assert.ok(f.writer.findAddition('acme', { request: 'req-1', plan_digest: DIGEST, project: 'timer' }));
|
|
const states = readFileSync(f.log, 'utf8').trim().split('\n').map(l => JSON.parse(l).state);
|
|
assert.deepEqual(states, ['prepared', 'aborted', 'prepared', 'committed']);
|
|
});
|
|
|
|
test('a_crash_after_the_rename_still_finds_the_addition_from_the_prepared_receipt', t => {
|
|
let armed = true;
|
|
const f = fixture(t, { barrier: step => { if (armed && step === 'renamed') { armed = false; throw new Error('killed'); } } });
|
|
assert.throws(() => f.add(), /killed/);
|
|
assert.deepEqual(f.writer.read('acme').document.projects.timer, f.entry);
|
|
const found = f.writer.findAddition('acme', { request: 'req-1', plan_digest: DIGEST, project: 'timer' });
|
|
assert.equal(found.after, f.writer.read('acme').revision);
|
|
assert.throws(() => f.add(), { code: 'organization-project-add-refused' });
|
|
});
|
|
|
|
test('managed_state_follows_the_layout_and_never_depends_on_the_marker', t => {
|
|
const f = fixture(t), at = { dataRoot: f.dataRoot, business: 'acme', project: 'timer', entry: f.entry };
|
|
assert.equal(managedState(at), 'reserved', 'no marker, at the managed layout');
|
|
const file = markerFile(at); mkdirSync(join(f.dataRoot, 'products/acme/timer'), { recursive: true });
|
|
writeFileSync(file, markerBytes({ state: 'reserved', request: 'req-1', plan_digest: DIGEST, project: 'timer' }));
|
|
assert.equal(managedState(at), 'reserved');
|
|
writeFileSync(file, markerBytes({ state: 'complete', request: 'req-1', plan_digest: DIGEST, project: 'timer' }));
|
|
assert.equal(managedState(at), 'complete');
|
|
writeFileSync(file, markerBytes({ state: 'complete', request: 'req-1', plan_digest: DIGEST, project: 'other' }));
|
|
assert.equal(managedState(at), 'reserved', 'a marker naming another project does not finish this one');
|
|
writeFileSync(file, '{ not json');
|
|
assert.equal(managedState(at), 'reserved', 'an unreadable marker is not an ordinary project');
|
|
rmSync(file);
|
|
assert.equal(managedState(at), 'reserved', 'deleting the marker does not make it ordinary');
|
|
assert.equal(managedState({ ...at, entry: { ...f.entry, root: join(f.root, 'elsewhere') } }), 'unmanaged');
|
|
assert.equal(managedState({ ...at, entry: { ...f.entry, repo: join(f.root, 'elsewhere') } }), 'unmanaged');
|
|
assert.equal(managedState({ ...at, entry: mappingEntry() }), 'unmanaged');
|
|
assert.equal(managedState({ ...at, entry: null }), 'unmanaged');
|
|
});
|