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:secret@git.example/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); });