Files
stack/packages/cli/tests/org-repo-set.test.mjs
jason.woltje 2ad4b30ebd fix(org): repo set refuses under a copy binding that still grants the project (#1873 F7)
A copy binding grants the repositories its projects' remotes name when a
credential is issued, and its decision records only project names. repo set
(CLI, route, Settings tab) is now refused with project-repo-bound while a
pending or bound copy binding names the project, before any check, origin
change or write, and again under the lock. The message names the copies and
the next step: revoke the binding, set the repository, raise a new binding
decision. An unbound binding, or none, behaves as before. Bindings are read
from the loader-validated document; binding, launcher and broker code are
unchanged. New refusal-table entry for project-repo-bound.
2026-11-12 13:39:26 -06:00

92 lines
7.7 KiB
JavaScript

// #1873 P1-6 and P1-7: `org project repo init --remote` sets and checks origin; `org project repo
// set` changes the repository both ways. The remotes point at 127.0.0.1 port 1, which refuses at
// once, so the check runs for real and answers "unreachable" without leaving the host.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { join, resolve } from 'node:path';
import { tmpdir } from 'node:os';
import { execFileSync, spawnSync } from 'node:child_process';
const repo = resolve(import.meta.dirname, '../../..');
const run = (env, ...args) => spawnSync(join(repo, 'scripts/mosaic'), args, { cwd: repo, env, encoding: 'utf8' });
const git = (cwd, ...args) => execFileSync('git', ['-C', cwd, ...args], { env: { PATH: process.env.PATH, HOME: '/nonexistent', GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null' }, encoding: 'utf8' }).trim();
test('org project repo init --remote and repo set: origin follows, the check is reported, refusals are plain', () => {
const home = mkdtempSync(join(tmpdir(), 'org-repo-')); const env = { ...process.env, HOME: home, MOSAIC_CONFIG: join(home, '.config/mosaic-orgtest/config.json') };
const boot = spawnSync(join(repo, 'scripts/bootstrap.sh'), ['--instance', 'orgtest'], { cwd: repo, env, encoding: 'utf8' }); assert.equal(boot.status, 0, boot.stderr);
const ok = (...args) => { const r = run(env, ...args); assert.equal(r.status, 0, r.stderr); return JSON.parse(r.stdout); };
const file = join(home, 'alpha.json');
run(env, 'org', 'template', 'render', 'alpha', '--fixture', 'local', '--output', file); ok('org', 'create', 'alpha', '--from', file);
const ssh = 'ssh://[email protected]:1/team/work.git';
const init = ok('org', 'project', 'repo', 'init', 'alpha', 'work', '--remote', ssh);
assert.equal(init.origin, ssh); assert.equal(init.check.result, 'unreachable'); assert.match(init.check.message, /port 1|refused/i);
assert.equal(git(init.root, 'remote', '-v'), `origin\t${ssh} (fetch)\norigin\t${ssh} (push)`);
// Usage errors come first and change nothing.
for (const args of [['--local-only', '--accept-local-loss-risk'], ['--remote', ssh, '--local-only', '--if-revision', '9'], ['--if-revision', '9'], ['--remote', ssh, '--if-revision', '0']]) {
const r = run(env, 'org', 'project', 'repo', 'set', 'alpha', 'work', ...args); assert.equal(r.status, 4, `${args}: ${r.stderr}`);
}
assert.equal(run(env, 'org', 'queue', 'set', 'alpha', '--local-only', '--accept-local-loss-risk', '--if-revision', '1').status, 4);
let rev = ok('org', 'show', 'alpha', '--json').revision;
for (const bad of ['ext::sh -c id', 'file:///etc', '/srv/repo', 'https://user:[email protected]:1/o/r', 'http://127.0.0.1:1/o/r']) {
const r = run(env, 'org', 'project', 'repo', 'set', 'alpha', 'work', '--remote', bad, '--if-revision', String(rev));
assert.equal(r.status, 2, bad); assert.match(r.stderr, /org-invalid/, bad);
}
const stale = run(env, 'org', 'project', 'repo', 'set', 'alpha', 'work', '--local-only', '--accept-local-loss-risk', '--if-revision', String(rev + 5));
assert.equal(stale.status, 2); assert.match(stale.stderr, /revision-conflict/);
assert.equal(git(init.root, 'remote', 'get-url', 'origin'), ssh);
const local = ok('org', 'project', 'repo', 'set', 'alpha', 'work', '--local-only', '--accept-local-loss-risk', '--if-revision', String(rev));
assert.equal(local.op, 'org.project.repo.set'); assert.equal(local.revision, rev + 1); assert.equal(local.origin, null); assert.equal(git(init.root, 'remote', '-v'), '');
rev = local.revision;
const https = 'https://127.0.0.1:1/team/work.git';
const back = ok('org', 'project', 'repo', 'set', 'alpha', 'work', '--remote', https, '--if-revision', String(rev), '--json');
assert.equal(back.revision, rev + 1); assert.equal(back.check.result, 'unreachable'); assert.match(back.check.message, /Failed to connect|Could not connect/);
assert.equal(git(init.root, 'remote', 'get-url', 'origin'), https);
assert.deepEqual(ok('org', 'project', 'repo', 'show', 'alpha', 'work', '--json').current, { mode: 'remote', remote: https, origin: https, revision: back.revision, check: back.check, changedAt: back.at });
assert.equal(back.check.url, https);
// The acceptance URL (#1873 P1-6): an .invalid host never resolves, so the check is unreachable
// and origin is still set to it.
const invalid = 'https://git.example.invalid/acme/work';
const named = ok('org', 'project', 'repo', 'set', 'alpha', 'work', '--remote', invalid, '--if-revision', String(back.revision), '--json');
assert.equal(named.origin, invalid); assert.equal(named.check.result, 'unreachable'); assert.equal(named.check.url, invalid);
assert.match(named.check.message, /resolve|timed out/i);
assert.equal(git(init.root, 'remote', '-v'), `origin\t${invalid} (fetch)\norigin\t${invalid} (push)`);
});
// #1873 F7: while a copy binding that names the project is approved or active, repo set is refused
// with exit 2 and the next step; once the binding is unbound (revoked) the same set goes through.
// The bindings are written into the document as the CB1 writer would leave them.
test('org project repo set is refused under a copy binding that names the project, and allowed after revoke', () => {
const home = mkdtempSync(join(tmpdir(), 'org-repo-')); const env = { ...process.env, HOME: home, MOSAIC_CONFIG: join(home, '.config/mosaic-orgtest/config.json') };
const boot = spawnSync(join(repo, 'scripts/bootstrap.sh'), ['--instance', 'orgtest'], { cwd: repo, env, encoding: 'utf8' }); assert.equal(boot.status, 0, boot.stderr);
const ok = (...args) => { const r = run(env, ...args); assert.equal(r.status, 0, r.stderr); return JSON.parse(r.stdout); };
const file = join(home, 'alpha.json');
run(env, 'org', 'template', 'render', 'alpha', '--fixture', 'local', '--output', file); ok('org', 'create', 'alpha', '--from', file);
const init = ok('org', 'project', 'repo', 'init', 'alpha', 'work', '--local-only', '--accept-local-loss-risk');
const configDir = join(home, '.config/mosaic-orgtest');
const seed = (state) => {
const docFile = join(configDir, 'businesses/alpha.json'); const doc = JSON.parse(readFileSync(docFile, 'utf8'));
doc.copyBindings = { 'rev.work': { copyId: '1'.repeat(32), orgIncarnation: 'a'.repeat(32), source: { level: 'project', name: 'rev', project: 'work' },
definition: { ref: 'reviewer', rev: null, sha256: 'b'.repeat(64) }, generation: 1, state,
services: state === 'unbound' ? {} : { gitea: { slot: 'self', scopes: ['read:repository'], resources: { projects: ['work'] } } },
decision: '11111111-1111-4111-8111-111111111111' } };
const raw = JSON.stringify(doc, null, 2) + '\n'; writeFileSync(docFile, raw);
const regFile = join(configDir, 'org-runtime.json'); const reg = JSON.parse(readFileSync(regFile, 'utf8'));
reg.orgs.alpha.digest = createHash('sha256').update(raw).digest('hex'); writeFileSync(regFile, JSON.stringify(reg, null, 2) + '\n');
};
const invalid = 'https://git.example.invalid/acme/other';
seed('pending');
const rev = ok('org', 'show', 'alpha', '--json').revision;
const refused = run(env, 'org', 'project', 'repo', 'set', 'alpha', 'work', '--remote', invalid, '--if-revision', String(rev));
assert.equal(refused.status, 2, refused.stderr);
assert.match(refused.stderr, /project-repo-bound: project work is in a copy binding that still grants it: rev\.work \(approved, not yet verified\)\. Revoke that copy binding first/);
assert.equal(git(init.root, 'remote', '-v'), '');
seed('unbound');
const set = ok('org', 'project', 'repo', 'set', 'alpha', 'work', '--remote', invalid, '--if-revision', String(rev), '--json');
assert.equal(set.revision, rev + 1); assert.equal(git(init.root, 'remote', 'get-url', 'origin'), invalid);
});