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.
243 lines
18 KiB
JavaScript
243 lines
18 KiB
JavaScript
// #1873 P1-7 (ruling 11): an organization project's Repository on its Settings tab is editable. The
|
|
// Console, its per-organization routes, the org-admin route and the real organization service are
|
|
// real; the organization's bus transports are a small fake, and the remote check goes through the
|
|
// test seam, so nothing leaves the host. The page sets a remote, is refused a bad URL and a stale
|
|
// revision, switches to local only and back, and `git remote -v` in the checkout follows each step.
|
|
// MOSAIC_UX_SHOTS=<dir> also writes light and dark shots at 1440x900 and 390x844.
|
|
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { mkdtempSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { execFileSync } from 'node:child_process';
|
|
import { createHash } from 'node:crypto';
|
|
import { startServer } from '../src/serve.mjs';
|
|
import { createDevOwner } from '../src/dev-owner.mjs';
|
|
import { createOrgAdmin } from '../src/org-admin.mjs';
|
|
import * as service from '../../organization/src/org-service.mjs';
|
|
import * as registry from '../../organization/src/org-registry.mjs';
|
|
import { repoRemoteTestSeam } from '../../organization/src/repo-remote.mjs';
|
|
import { browser } from './browser.mjs';
|
|
import { close } from './fixture.mjs';
|
|
|
|
const P = '10000000-0000-4000-8000-0000000000c1';
|
|
const NEEDS = { result: 'needs-credential', message: 'the repository needs a credential; this check uses none' };
|
|
const waitFor = b => expression => b.evaluate(`(async()=>{for(let i=0;i<300;i++){if(${expression})return true;await new Promise(r=>setTimeout(r,30))}throw Error(${JSON.stringify(expression)}+' | '+(document.querySelector('main')?.innerText||'').slice(0,1500))})()`);
|
|
const text = (b, selector) => b.evaluate(`(document.querySelector(${JSON.stringify(selector)})?.innerText ?? '').replace(/\\s+/g, ' ').trim()`);
|
|
const click = (b, selector) => b.evaluate(`(document.querySelector(${JSON.stringify(selector)}).click(), true)`);
|
|
const fill = (b, selector, value) => b.evaluate(`(e => { e.value = ${JSON.stringify(value)}; e.dispatchEvent(new Event('input', { bubbles: true })); return true; })(document.querySelector(${JSON.stringify(selector)}))`);
|
|
// A same-origin call from the page, so the write fence sees a real browser request.
|
|
const api = (b, path, body) => b.evaluate(`fetch(${JSON.stringify(path)}, ${body === undefined ? '{}' : `{ method: 'POST', headers: { 'content-type': 'application/json' }, body: ${JSON.stringify(JSON.stringify(body))} }`}).then(async r => ({ status: r.status, body: await r.json() }))`);
|
|
|
|
// One running organization whose chat project "Work" is the organization project `work`.
|
|
function organizations() {
|
|
const business = { business: { id: 'acme', human: 'local-dev-owner', arbiters: [], projects: [P], vars: {}, instances: [], appearance: { theme: null } } };
|
|
return {
|
|
list: async () => [{ id: 'acme', label: 'Acme', theme: null, busHealthy: true }],
|
|
resolve: async id => id !== 'acme' ? null : ({
|
|
busHealthy: true, send: async () => ({}),
|
|
reads: { business: async () => business, settings: async () => ({}), queue: async () => ({ rows: [] }), busStatus: async () => ({}) },
|
|
bus: { instances: async () => ({ business: 'acme', revision: 'a'.repeat(64), launch: true, noHire: [], retired: [], definitions: {}, instances: {} }),
|
|
messages: async () => ({ messages: [], unread: 0 }), agents: async () => [], 'thread.claims': async () => [], inbox: async () => [], tasks: async () => [] },
|
|
chat: { projects: async () => [{ id: P, name: 'Work', repo: 'work' }], threads: async () => [] },
|
|
}),
|
|
};
|
|
}
|
|
|
|
async function console_(t, { send = true, width = 1440, height = 900, root = null } = {}) {
|
|
const fresh = root === null; root ??= mkdtempSync(join(tmpdir(), 'prb')); const configDir = join(root, 'config'), dataRoot = join(root, 'data');
|
|
if (fresh) { mkdirSync(configDir, { mode: 0o700 }); mkdirSync(dataRoot, { mode: 0o700 }); }
|
|
const orgAdmin = createOrgAdmin({ service, registry, bus: null, context: async () => ({ configDir, dataRoot, instance: 'test' }) });
|
|
const server = await startServer({ port: 0, sso: createDevOwner(), ...(send ? { send: async () => ({}) } : {}), organizations: organizations(), orgAdmin, log: () => {} });
|
|
t.after(() => close(server));
|
|
const b = await browser({ machine: false, args: ['--disable-extensions'] }); t.after(() => b.close());
|
|
const errors = [];
|
|
b.on('Runtime.exceptionThrown', p => errors.push(p.exceptionDetails?.exception?.description ?? p.exceptionDetails?.text ?? 'exception'));
|
|
await b.viewport(width, height);
|
|
const base = `http://127.0.0.1:${server.address().port}/`;
|
|
const checkout = join(dataRoot, 'org-runtime/acme/projects/work');
|
|
const git = (...args) => execFileSync('git', ['-C', checkout, ...args], { env: { PATH: process.env.PATH, HOME: '/nonexistent', GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null' }, encoding: 'utf8' }).trim();
|
|
const remotes = () => git('remote', '-v');
|
|
return { b, base, wait: waitFor(b), errors, root, remotes, git, receipts: join(dataRoot, 'org-runtime/acme/receipts.jsonl') };
|
|
}
|
|
|
|
// Create the organization and set the project up local-only, as Quick Setup does, through the routes.
|
|
async function seed(p) {
|
|
await p.b.navigate(`${p.base}#/orgs/acme/organization`);
|
|
const tpl = await api(p.b, '/api/org-template?id=acme');
|
|
assert.equal(tpl.status, 200, JSON.stringify(tpl.body));
|
|
const made = await api(p.b, '/api/orgs', { id: 'acme', doc: tpl.body.doc });
|
|
assert.equal(made.status, 201, JSON.stringify(made.body));
|
|
const set = await api(p.b, '/api/orgs/acme/setup/project/work', { mode: 'local-only', acceptLocalLossRisk: true });
|
|
assert.equal(set.status, 201, JSON.stringify(set.body));
|
|
}
|
|
const settings = async ({ b, base, wait }) => {
|
|
await b.navigate(`${base}#/orgs/acme/projects/${P}/settings`);
|
|
await wait('document.querySelector("[data-project-repo]") && !/Reading the repository/.test(document.querySelector("[data-project-repo]").textContent)');
|
|
};
|
|
// A fresh page load, so the section reads the repository again rather than reusing its last view.
|
|
const reread = async p => { await p.b.evaluate('location.reload(), true'); await new Promise(r => setTimeout(r, 200)); await settings(p); };
|
|
const facts = b => b.evaluate(`['mode', 'url', 'origin', 'check'].map(k => document.querySelector('[data-repo-' + k + ']')?.textContent ?? null)`);
|
|
const save = async ({ b, wait }, pattern) => {
|
|
await b.evaluate(`document.querySelector('[data-project-repo-status]').textContent = '', true`);
|
|
await click(b, '[data-project-repo-form] button[type=submit]');
|
|
await wait(`${pattern}.test(document.querySelector('[data-project-repo-status]')?.textContent)`);
|
|
return text(b, '[data-project-repo-status]');
|
|
};
|
|
|
|
test('the owner edits the repository on Settings: remote, refusals, local only and back, origin follows', { timeout: 90000 }, async t => {
|
|
const checked = []; repoRemoteTestSeam.useCheck(async url => { checked.push(url); return url.includes('down.invalid') ? { result: 'unreachable', message: 'Could not resolve host: down.invalid' } : NEEDS; });
|
|
t.after(() => repoRemoteTestSeam.useCheck(null));
|
|
const p = await console_(t); const { b } = p;
|
|
await seed(p);
|
|
assert.equal(p.remotes(), '');
|
|
await settings(p);
|
|
assert.deepEqual(await facts(b), ['Local only', 'None (local only)', 'None', 'None (local only has no remote)']);
|
|
assert.match(await text(b, '[data-repo-verb]'), /^mosaic org project repo set acme work --local-only --accept-local-loss-risk --if-revision \d+$/);
|
|
|
|
// Remote, https: origin is set, the check is reported in plain words, nothing is pushed.
|
|
await click(b, '[data-project-repo-form] input[name=repo-mode][value=remote]');
|
|
assert.equal(await b.evaluate('document.activeElement?.value'), 'remote', 'focus stays on the radio it changed');
|
|
const https = 'https://git.example.invalid/acme/work';
|
|
await fill(b, '#project-repo-url', https);
|
|
const saved = await save(p, '/Saved/');
|
|
assert.match(saved, new RegExp(`^Saved at \\S+\\. Origin is ${https.replace(/[.]/g, '\\.')}\\. Check: Needs a credential: the repository needs a credential; this check uses none\\. Nothing was pushed\\.$`));
|
|
assert.equal(await b.evaluate(`document.activeElement?.hasAttribute('data-project-repo-status')`), true);
|
|
assert.deepEqual(await facts(b), ['Remote', https, https, 'Needs a credential: the repository needs a credential; this check uses none.']);
|
|
assert.equal(p.remotes(), `origin\t${https} (fetch)\norigin\t${https} (push)`);
|
|
assert.deepEqual(checked, [https]);
|
|
|
|
// URLs the rules refuse never reach git, and origin stays as it was.
|
|
for (const [bad, why] of [['ext::sh -c id', /transport helper/], ['https://user:[email protected]/acme/work', /credential/], ['-uhost:x/y', /must not start with "-"/]]) {
|
|
await fill(b, '#project-repo-url', bad);
|
|
const line = await save(p, '/./');
|
|
assert.match(line, why, bad); assert.doesNotMatch(line, /Saved/, bad);
|
|
assert.equal(await b.evaluate(`document.querySelector('#project-repo-url').value`), bad, 'the entry is kept');
|
|
}
|
|
assert.deepEqual(checked, [https]);
|
|
assert.equal(p.remotes(), `origin\t${https} (fetch)\norigin\t${https} (push)`);
|
|
|
|
// An unreachable remote is still saved; the git error is summarized.
|
|
const down = 'ssh://[email protected]/acme/work.git';
|
|
await fill(b, '#project-repo-url', down);
|
|
assert.match(await save(p, '/Saved/'), /Check: Unreachable: Could not resolve host: down\.invalid\. Nothing was pushed\.$/);
|
|
assert.equal(p.remotes(), `origin\t${down} (fetch)\norigin\t${down} (push)`);
|
|
|
|
// Another write changed the organization meanwhile: the first try is refused, the second lands.
|
|
const shown = await api(b, '/api/orgs/acme/admin');
|
|
assert.equal((await api(b, '/api/orgs/acme/admin', { doc: shown.body.doc, ifRevision: shown.body.revision })).status, 200);
|
|
await click(b, '[data-project-repo-form] input[name=repo-mode][value=local-only]');
|
|
await click(b, '[data-project-repo-form] input[name=repo-risk]');
|
|
assert.match(await save(p, '/changed after this page read it/'), /your entry is kept/);
|
|
assert.equal(p.remotes(), `origin\t${down} (fetch)\norigin\t${down} (push)`);
|
|
|
|
// Local only needs the risk box; with it, origin is removed.
|
|
assert.equal(await b.evaluate(`document.querySelector('[data-project-repo-form] input[name=repo-risk]').checked`), true, 'the draft survived the refusal');
|
|
await click(b, '[data-project-repo-form] input[name=repo-risk]');
|
|
assert.equal(await save(p, '/Tick the box/'), 'Tick the box to accept that a local-only repository has no copy anywhere else.');
|
|
await click(b, '[data-project-repo-form] input[name=repo-risk]');
|
|
assert.match(await save(p, '/Saved/'), /^Saved at \S+\. Origin removed; local only\. Nothing was pushed\.$/);
|
|
assert.deepEqual(await facts(b), ['Local only', 'None (local only)', 'None', 'None (local only has no remote)']);
|
|
assert.equal(p.remotes(), '');
|
|
|
|
// And back to remote, scp-like ssh.
|
|
const scp = '[email protected]:acme/work.git';
|
|
await click(b, '[data-project-repo-form] input[name=repo-mode][value=remote]');
|
|
await fill(b, '#project-repo-url', scp);
|
|
assert.match(await save(p, '/Saved/'), new RegExp(`Origin is ${scp.replace(/[.]/g, '\\.')}\\.`));
|
|
assert.equal(p.remotes(), `origin\t${scp} (fetch)\norigin\t${scp} (push)`);
|
|
const receipts = (await api(b, '/api/orgs/acme/repo/project/work')).body;
|
|
assert.equal(receipts.current.mode, 'remote'); assert.equal(receipts.current.origin, scp);
|
|
assert.equal(await b.evaluate(`document.querySelector('[data-repo-drift]')`), null, 'origin matches: no drift line');
|
|
|
|
// #1873 review F2: origin moved away from the recorded URL (a save that stopped after setting
|
|
// origin). The page says so; saving the recorded URL again repairs it.
|
|
p.git('remote', 'set-url', 'origin', 'https://elsewhere.invalid/x/y');
|
|
await reread(p);
|
|
assert.deepEqual(await facts(b), ['Remote', scp, 'https://elsewhere.invalid/x/y', 'Needs a credential: the repository needs a credential; this check uses none; the ssh host key was not verified.']);
|
|
assert.equal(await text(b, '[data-repo-drift]'), 'Git origin differs from the recorded repository; save it again to repair.');
|
|
await save(p, '/Saved/');
|
|
assert.equal(p.remotes(), `origin\t${scp} (fetch)\norigin\t${scp} (push)`);
|
|
assert.equal(await b.evaluate(`document.querySelector('[data-repo-drift]')`), null);
|
|
// A recorded check of another URL is not shown as this URL's check.
|
|
const lines = readFileSync(p.receipts, 'utf8').trim().split('\n').map(l => JSON.parse(l));
|
|
lines.at(-1).check.url = 'https://git.example.invalid/acme/other'; writeFileSync(p.receipts, lines.map(l => JSON.stringify(l)).join('\n') + '\n');
|
|
await reread(p);
|
|
assert.equal((await facts(b))[3], 'Not recorded for this URL');
|
|
assert.doesNotMatch(await b.evaluate('document.body.innerText'), new RegExp(p.root.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')), 'no server path reaches the page');
|
|
assert.deepEqual(p.errors, []);
|
|
});
|
|
|
|
// #1873 F7: an active copy binding names the project, so Settings is refused with the copy and the
|
|
// next step, the entry is kept and git is untouched; after the binding is revoked the same save lands.
|
|
test('Settings refuses the repository change under an active copy binding and saves after revoke', { timeout: 60000 }, async t => {
|
|
repoRemoteTestSeam.useCheck(async () => NEEDS); t.after(() => repoRemoteTestSeam.useCheck(null));
|
|
const p = await console_(t); const { b } = p;
|
|
await seed(p);
|
|
const configDir = join(p.root, 'config');
|
|
const bind = state => {
|
|
const docFile = join(configDir, 'businesses/acme.json'); const doc = JSON.parse(readFileSync(docFile, 'utf8'));
|
|
doc.copyBindings = { rev: { copyId: '1'.repeat(32), orgIncarnation: 'a'.repeat(32), source: { level: 'root', name: 'rev', project: null },
|
|
definition: { ref: 'reviewer', rev: null, sha256: 'b'.repeat(64) }, generation: 1, state,
|
|
services: state === 'unbound' ? {} : { gitea: { slot: 'self', scopes: ['read:repository'], resources: { projects: ['work'] }, rotateBy: '2099-01-01', grantId: 'gitea:5:7', grantGeneration: 1 } },
|
|
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.acme.digest = createHash('sha256').update(raw).digest('hex'); writeFileSync(regFile, JSON.stringify(reg, null, 2) + '\n');
|
|
};
|
|
bind('bound');
|
|
await settings(p);
|
|
const url = 'https://git.example.invalid/acme/work';
|
|
await click(b, '[data-project-repo-form] input[name=repo-mode][value=remote]');
|
|
await fill(b, '#project-repo-url', url);
|
|
assert.equal(await save(p, '/copy binding/'), 'project work is in a copy binding that still grants it: rev (active). Revoke that copy binding first (the chief raises an unbind decision and the owner approves it), then set the repository, then ask the chief to raise a new binding decision');
|
|
assert.equal(await b.evaluate(`document.querySelector('#project-repo-url').value`), url, 'the entry is kept');
|
|
assert.equal(p.remotes(), '');
|
|
bind('unbound');
|
|
assert.match(await save(p, '/Saved/'), new RegExp(`Origin is ${url.replace(/[.]/g, '\\.')}\\.`));
|
|
assert.equal(p.remotes(), `origin\t${url} (fetch)\norigin\t${url} (push)`);
|
|
assert.deepEqual(p.errors, []);
|
|
});
|
|
|
|
test('a read-only Console shows the setting, refuses the change in plain words and git is untouched', { timeout: 60000 }, async t => {
|
|
repoRemoteTestSeam.useCheck(async () => NEEDS); t.after(() => repoRemoteTestSeam.useCheck(null));
|
|
// Seed through a send-mode Console, then open the same organization in one without send mode.
|
|
const p = await console_(t); await seed(p);
|
|
const q = await console_(t, { send: false, root: p.root }); const { b } = q;
|
|
await settings(q);
|
|
assert.deepEqual(await facts(b), ['Local only', 'None (local only)', 'None', 'None (local only has no remote)']);
|
|
await click(b, '[data-project-repo-form] input[name=repo-mode][value=remote]');
|
|
await fill(b, '#project-repo-url', 'https://git.example.invalid/acme/work');
|
|
assert.equal(await save(q, '/only reads/'), 'This Console only reads. Changing the repository needs signed-in send mode.');
|
|
assert.equal(p.remotes(), '');
|
|
assert.deepEqual(q.errors, []);
|
|
});
|
|
|
|
test('the Repository section fits 1440x900 and a phone in light and dark', { timeout: 90000 }, async t => {
|
|
repoRemoteTestSeam.useCheck(async () => NEEDS); t.after(() => repoRemoteTestSeam.useCheck(null));
|
|
const shots = process.env.MOSAIC_UX_SHOTS;
|
|
if (shots) mkdirSync(shots, { recursive: true });
|
|
for (const [width, height] of [[1440, 900], [390, 844]]) {
|
|
const p = await console_(t, { width, height }); const { b } = p;
|
|
await seed(p);
|
|
await settings(p);
|
|
await click(b, '[data-project-repo-form] input[name=repo-mode][value=remote]');
|
|
await fill(b, '#project-repo-url', 'https://git.example.invalid/acme/work');
|
|
await save(p, '/Saved/');
|
|
for (const mode of ['light', 'dark']) {
|
|
await b.call('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-color-scheme', value: mode }] });
|
|
if (await b.evaluate(`document.getElementById('theme-switch').getAttribute('aria-checked')`) !== String(mode === 'dark')) {
|
|
await b.evaluate(`document.getElementById('theme-switch').click(), true`);
|
|
await p.wait(`document.getElementById('theme-switch').getAttribute('aria-checked') === '${mode === 'dark'}'`);
|
|
}
|
|
await b.evaluate(`document.querySelector('[data-project-repo]').scrollIntoView({ block: 'start' }), true`);
|
|
if (width === 390) assert.equal(await b.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth'), true, `page ${width} ${mode}`);
|
|
assert.equal(await b.evaluate(`(s => s.scrollWidth <= s.clientWidth && s.getBoundingClientRect().right <= document.documentElement.clientWidth)(document.querySelector('[data-project-repo]'))`), true, `section ${width} ${mode}`);
|
|
assert.equal(await b.evaluate(`[...document.querySelectorAll('[data-project-repo] .project-repo-option')].every(o => o.getBoundingClientRect().height >= 44)`), true, `targets ${width} ${mode}`);
|
|
assert.equal(await b.evaluate(`document.querySelector('#project-repo-url').getBoundingClientRect().height >= 44`), true, `url field ${width} ${mode}`);
|
|
if (shots) await b.screenshot(join(shots, `p1-7-project-repo-${width}-${mode}.png`));
|
|
}
|
|
assert.deepEqual(p.errors, []);
|
|
}
|
|
});
|