- The copy launch gate reads the organization registry entry on every check for a verified copy. A state other than active, or a new incarnation, refuses credential-revoked; an unreadable registry refuses credential-unavailable. - Repository reads need read or write repository scope, like writes need write scope. - The admitted organization revision travels from the launcher through bindLaunch. prepare checks it before and after the live proof, and admit checks it after the launch authority; a change refuses organization-changed. - Tests for the completion authority recheck, the broker session authority compare and an incarnation change; the copyRefusal comment now names where restart-required is answered.
239 lines
15 KiB
JavaScript
239 lines
15 KiB
JavaScript
// #1873 CB3 live acceptance: a bound copy's launch gate against a scratch gitea/gitea:1.27.1, a
|
|
// throwaway container on 127.0.0.1 over TLS with a scratch CA, removed after. The boot proves the
|
|
// slot live, a launch carries its authority to the bound session, one checked call reads the bound
|
|
// repository, and a reissue denies the next call before any request. The test never pulls an image;
|
|
// without Docker or the image it skips. Every token here is a scratch token this container made;
|
|
// none is printed, and nothing here reaches git.mosaicstack.dev or any real service.
|
|
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { createHash, randomBytes } from 'node:crypto';
|
|
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
import { request } from 'node:https';
|
|
import { createServer } from 'node:net';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { spawnSync } from 'node:child_process';
|
|
import { Broker, BusError } from '../src/broker.mjs';
|
|
import { Store } from '../src/store.mjs';
|
|
import { Credentials } from '../src/credentials.mjs';
|
|
import { copyGrant } from '../src/copy-grant.mjs';
|
|
import { copyLaunchGate } from '../src/copy-launch.mjs';
|
|
import { send } from '../src/gitea-grant.mjs';
|
|
import { hireDecisions } from '../../organization/src/hire-decisions.mjs';
|
|
import { organizationWriter } from '../../organization/src/organization.mjs';
|
|
import { checkRootAgents } from '../../organization/src/agent-levels.mjs';
|
|
import { loadWithCopies, materializeCopies, promoteCopies } from '../../organization/src/agent-copies.mjs';
|
|
import { activateOrg, reserveOrg } from '../../organization/src/org-registry.mjs';
|
|
import { copyBotLogin } from '../../organization/src/copy-bindings.mjs';
|
|
import { businessDoc, rolesCopy, writeJson } from '../../organization/tests/helpers.mjs';
|
|
import { scratchCa } from '../../seat/tests/fake-gitea.mjs';
|
|
|
|
const IMAGE = 'gitea/gitea:1.27.1';
|
|
const SUBJECT = { provider: 'test', iss: 'https://issuer.test', sub: 'owner' };
|
|
const READ = { gitea: { scopes: ['read:repository', 'read:user'], projects: ['stack'] } };
|
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
const ahead = (days) => new Date(Date.now() + days * 86400000).toISOString().slice(0, 10);
|
|
const sha = (s) => createHash('sha256').update(s).digest('hex');
|
|
const docker = (...args) => spawnSync('docker', args, { encoding: 'utf8', timeout: 120000 });
|
|
const skip = docker('image', 'inspect', IMAGE).status === 0 ? false : `no Docker or no local ${IMAGE}; this test never pulls`;
|
|
|
|
const freePort = () =>
|
|
new Promise((resolve, reject) => {
|
|
const s = createServer();
|
|
s.once('error', reject);
|
|
s.listen(0, '127.0.0.1', () => {
|
|
const { port } = s.address();
|
|
s.close(() => resolve(port));
|
|
});
|
|
});
|
|
|
|
// One HTTPS call to the scratch container as its administrator, trusting only the scratch CA.
|
|
function call(url, ca, method, path, { auth = null, body = null } = {}) {
|
|
return new Promise((resolve) => {
|
|
const data = body ? JSON.stringify(body) : null;
|
|
const headers = { accept: 'application/json', ...(data ? { 'content-type': 'application/json' } : {}), ...(auth ? { authorization: auth } : {}) };
|
|
const req = request(url + path, { method, ca, headers, timeout: 10000 }, (res) => {
|
|
let text = '';
|
|
res.on('data', (c) => (text += c));
|
|
res.on('end', () => {
|
|
let json = null;
|
|
try {
|
|
json = text ? JSON.parse(text) : null;
|
|
} catch {}
|
|
resolve({ status: res.statusCode, json });
|
|
});
|
|
});
|
|
req.on('timeout', () => req.destroy());
|
|
req.on('error', () => resolve({ status: 0, json: null }));
|
|
req.end(data ?? undefined);
|
|
});
|
|
}
|
|
|
|
test('CB3-D: on scratch 1.27.1 a bound copy boots proved, launches with its authority, reads only its repository, refuses a redirect, and a reissue denies the next call unsent', { skip, timeout: 300000 }, async (t) => {
|
|
// The child's complete TAP output is captured before any of it reaches this runner, so the parent
|
|
// can search it for the minted values without putting one in an assertion.
|
|
if (!process.env.CB3_COPY_CAPTURE) {
|
|
const capture = mkdtempSync(join(tmpdir(), 'cb3cap-'));
|
|
t.after(() => rmSync(capture, { recursive: true, force: true }));
|
|
const file = join(capture, 'minted');
|
|
const childEnv = { ...process.env, CB3_COPY_CAPTURE: file };
|
|
delete childEnv.NODE_TEST_CONTEXT;
|
|
const child = spawnSync(process.execPath, ['--test', '--test-name-pattern=^CB3-D:', fileURLToPath(import.meta.url)], {
|
|
encoding: 'utf8', timeout: 280000, env: childEnv,
|
|
});
|
|
const values = existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : [];
|
|
const output = (child.stdout ?? '') + (child.stderr ?? '');
|
|
assert.ok(values.every((value) => !output.includes(value) && !output.includes(sha(value))), 'complete TAP stdout/stderr contain no minted token');
|
|
t.diagnostic(output);
|
|
t.diagnostic(`minted ${values.length}, hits 0 (complete TAP stdout/stderr)`);
|
|
assert.equal(child.status, 0, 'Docker child passed');
|
|
assert.equal(values.length, 3, 'all three scratch tokens were minted: the acceptance actually ran');
|
|
return;
|
|
}
|
|
|
|
const dir = mkdtempSync(join(tmpdir(), 'cb3d-'));
|
|
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
|
const tlsDir = join(dir, 'tls'), tls = scratchCa(tlsDir);
|
|
// The container's git user reads the scratch key; it is a throwaway key for this run only.
|
|
chmodSync(tlsDir, 0o755);
|
|
chmodSync(join(tlsDir, 'server.key'), 0o644);
|
|
const ca = readFileSync(tls.ca, 'utf8'), port = await freePort();
|
|
const url = `https://127.0.0.1:${port}`, name = 'cb3-' + randomBytes(6).toString('hex');
|
|
t.after(() => docker('rm', '-f', name));
|
|
const started = docker('run', '-d', '--rm', '--name', name, '-p', `127.0.0.1:${port}:3000`, '-v', `${tlsDir}:/certs:ro`,
|
|
'-e', 'GITEA__security__INSTALL_LOCK=true', '-e', 'GITEA__database__DB_TYPE=sqlite3',
|
|
'-e', 'GITEA__service__DISABLE_REGISTRATION=true', '-e', 'GITEA__server__PROTOCOL=https',
|
|
'-e', 'GITEA__server__CERT_FILE=/certs/server.pem', '-e', 'GITEA__server__KEY_FILE=/certs/server.key',
|
|
'-e', `GITEA__server__ROOT_URL=${url}/`, IMAGE);
|
|
assert.equal(started.status, 0, 'scratch container starts');
|
|
const end = Date.now() + 120000;
|
|
while ((await call(url, ca, 'GET', '/api/v1/version')).status !== 200) {
|
|
assert.ok(Date.now() < end, 'scratch Gitea answers in 120 seconds');
|
|
await sleep(500);
|
|
}
|
|
const gitea = (...args) => docker('exec', '-u', 'git', name, 'gitea', ...args);
|
|
const minted = [];
|
|
const createUser = (user, admin = false) => {
|
|
const r = gitea('admin', 'user', 'create', '--username', user, '--email', `${user}@example.test`,
|
|
'--password', randomBytes(16).toString('base64url'), '--must-change-password=false', ...(admin ? ['--admin'] : []));
|
|
assert.equal(r.status, 0, `create scratch ${user}`);
|
|
};
|
|
const mint = (user, scopes) => {
|
|
const r = gitea('admin', 'user', 'generate-access-token', '--username', user, '--token-name', 'cb3-' + randomBytes(4).toString('hex'),
|
|
'--scopes', scopes.join(','), '--raw');
|
|
assert.equal(r.status, 0, `mint scratch ${user}`);
|
|
const value = r.stdout.trim();
|
|
assert.ok(/^[0-9a-f]{40}$/.test(value), 'minted token shape');
|
|
minted.push(value);
|
|
writeFileSync(process.env.CB3_COPY_CAPTURE, JSON.stringify(minted), { mode: 0o600 });
|
|
return value;
|
|
};
|
|
createUser('scratch-admin', true);
|
|
createUser('owner');
|
|
const admin = 'token ' + mint('scratch-admin', ['write:admin', 'write:repository', 'write:user']);
|
|
for (const repo of ['stack', 'other']) {
|
|
const made = await call(url, ca, 'POST', '/api/v1/admin/users/owner/repos', { auth: admin, body: { name: repo, auto_init: true, private: true } });
|
|
assert.equal(made.status, 201, `create owner/${repo}`);
|
|
}
|
|
|
|
// The organization: one root agent "rev" (research, Gitea alone) bound read-only to project stack.
|
|
const root = mkdtempSync(join(tmpdir(), 'cb3o-'));
|
|
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
const rolesDir = rolesCopy(root);
|
|
const doc = { ...businessDoc(root, 'demo'), plugins: { harness: ['pi'] }, vars: { 'bus.tester': true } };
|
|
doc.projects.stack.remote = `${url}/owner/stack`;
|
|
writeJson(join(root, 'businesses/demo.json'), doc);
|
|
const dataRoot = join(root, 'data');
|
|
mkdirSync(dataRoot, { mode: 0o700 });
|
|
const ctx = { configDir: root, dataRoot, instance: 'test' };
|
|
await reserveOrg(ctx, 'demo');
|
|
await activateOrg(ctx, 'demo');
|
|
const snapshot = materializeCopies({ orgId: 'demo', doc, root: { state: 'ok', ...checkRootAgents({ agentsVersion: 1, agents: { rev: { definition: 'research' } } }, { rolesDir }) }, rolesDir });
|
|
const writer = organizationWriter({ dir: root, rolesDir }), store = new Store(dataRoot);
|
|
t.after(() => store.close());
|
|
const service = hireDecisions({ writer, rolesDir, dataRoot, configDir: root, assertClean: () => {}, copies: (id) => (snapshot.org === id ? snapshot : null) });
|
|
const storeRoot = join(root, 'secrets', 'agents');
|
|
mkdirSync(storeRoot, { recursive: true, mode: 0o700 });
|
|
chmodSync(join(root, 'secrets'), 0o700);
|
|
const credentials = new Credentials({ references: {}, storeRoot, repoRoots: [], dataRoot: '/nonexistent-data' });
|
|
// Every request the gate sends, by op only.
|
|
const sent = [];
|
|
const transport = (req) => { sent.push(`${req.method} ${req.path.split('?')[0]}`); return send(req); };
|
|
const gate = copyLaunchGate({ credentials, writer, configDir: root, rolesDir, snapshot, transport, ca, timeoutMs: 10000 });
|
|
let promoted = snapshot;
|
|
const business = () => {
|
|
const d = loadWithCopies('demo', { dir: root, rolesDir, snapshot: promoted });
|
|
return { ...d, roles: Object.fromEntries(Object.entries(d.roles).map(([n, e]) => [n, { ...d.definitions?.[e.definition], ...e }])) };
|
|
};
|
|
const issue = copyGrant({ credentials, writer, copies: snapshot, organizations: () => ['demo'], ca, timeoutMs: 10000 });
|
|
const b = new Broker({
|
|
store, businesses: { demo: business() }, copyLaunch: gate,
|
|
hires: { ...service, apply: async (...args) => { const r = await service.apply(...args); b.reloadBusiness('demo', business()); return r; } },
|
|
credentialVerbs: () => { throw new BusError('credential-unknown'); }, copyGrant: (...a) => issue(...a),
|
|
});
|
|
const consoleCap = b.bindConsole({ business: 'demo', human: 'jason', subject: SUBJECT });
|
|
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
|
const request_ = (cap, verb, args) => Promise.resolve().then(() => b.request(cap, { verb, args }));
|
|
await request_(consoleCap, 'hire.start.console', { op: 'change', level: 'root', name: 'rev', reason: 'Bind the copy', binding: { services: READ } });
|
|
const rec = () => JSON.parse(readFileSync(join(root, 'businesses/demo.json'), 'utf8')).copyBindings.rev;
|
|
const login = copyBotLogin(rec().orgIncarnation, rec().copyId, 'gitea');
|
|
createUser(login);
|
|
const collaborate = (repo) => call(url, ca, 'PUT', `/api/v1/repos/owner/${repo}/collaborators/${login}`, { auth: admin, body: { permission: 'read' } });
|
|
assert.equal((await collaborate('stack')).status, 204, 'bot is a read collaborator on owner/stack');
|
|
const issueToken = (token) => request_(human, 'credentials.issue', { agent: 'rev', service: 'gitea', date: ahead(30), token });
|
|
assert.equal((await issueToken(mint(login, ['read:repository', 'read:user']))).state, 'bound');
|
|
|
|
// Bound after this gate was made: restart-required until a boot proves it.
|
|
await assert.rejects(gate.prepare('demo', 'rev'), { code: 'restart-required' });
|
|
assert.deepEqual(sent, []);
|
|
await gate.boot('demo');
|
|
assert.deepEqual(gate.bound(), { rev: ['gitea'] });
|
|
assert.ok(sent.includes('GET /api/v1/token'), 'the boot proved the slot live at the service');
|
|
promoted = promoteCopies(snapshot, gate.bound());
|
|
b.reloadBusiness('demo', business());
|
|
|
|
// A launch: prepare proves the slot again, bindLaunch carries the authority to the session.
|
|
const project = (await request_(human, 'project.create', { name: 'stack work', repo: 'stack' })).id;
|
|
const thread = (await request_(human, 'thread.create', { project, name: 'one', instance: 'rev' })).id;
|
|
const proofs = sent.length;
|
|
const revision = writer.read('demo').revision;
|
|
const { authority } = await gate.prepare('demo', 'rev', revision);
|
|
assert.ok(sent.slice(proofs).includes('GET /api/v1/token'));
|
|
const cap = b.bindLaunch({ business: 'demo', role: 'rev', run: 'rev-run', harness: 'pi', thread, copyAuthority: authority, revision });
|
|
const use = (req) => Promise.resolve().then(() => gate.call(b.copySession(cap), req));
|
|
const own = await use({ method: 'GET', path: '/api/v1/repos/owner/stack' });
|
|
assert.equal(own.status, 200);
|
|
assert.equal(own.json.full_name, 'owner/stack');
|
|
// A repository outside the binding, and a write on a read binding: refused before any request.
|
|
const before = sent.length;
|
|
await assert.rejects(use({ method: 'GET', path: '/api/v1/repos/owner/other' }), { code: 'copy-request-refused' });
|
|
await assert.rejects(use({ method: 'POST', path: '/api/v1/repos/owner/stack/issues', body: { title: 'x' } }), { code: 'copy-request-refused' });
|
|
assert.equal(sent.length, before);
|
|
|
|
// A reissue (a new generation) while the session is live: the next call refuses, none is sent.
|
|
await issueToken(mint(login, ['read:repository', 'read:user']));
|
|
assert.equal(rec().services.gitea.grantGeneration, 2);
|
|
const afterIssue = sent.length;
|
|
await assert.rejects(use({ method: 'GET', path: '/api/v1/repos/owner/stack' }), { code: 'restart-required' });
|
|
assert.equal(sent.length, afterIssue, 'no request after the reissue');
|
|
|
|
// Gitea answers a renamed repository's old path with a redirect. A fresh boot and launch on the
|
|
// second token; the bound path now redirects, and the gate refuses it without following.
|
|
const gate2 = copyLaunchGate({ credentials, writer, configDir: root, rolesDir, snapshot, transport, ca, timeoutMs: 10000 });
|
|
await gate2.boot('demo');
|
|
assert.deepEqual(gate2.bound(), { rev: ['gitea'] });
|
|
const { authority: authority2 } = await gate2.prepare('demo', 'rev');
|
|
// The broker's session for the old launch is gone with its authority; this one is gate2's own.
|
|
assert.throws(() => b.copySession(cap), { code: 'restart-required' });
|
|
const session = { business: 'demo', role: 'rev', copyAuthority: authority2 };
|
|
assert.equal((await gate2.call(session, { method: 'GET', path: '/api/v1/repos/owner/stack' })).status, 200);
|
|
assert.equal((await call(url, ca, 'PATCH', '/api/v1/repos/owner/stack', { auth: admin, body: { name: 'moved' } })).status, 200, 'rename owner/stack');
|
|
const viaOld = await call(url, ca, 'GET', '/api/v1/repos/owner/stack', { auth: admin });
|
|
assert.equal(viaOld.status, 301, 'Gitea 1.27.1 redirects a renamed repository\'s old path');
|
|
const n = sent.length;
|
|
await assert.rejects(gate2.call(session, { method: 'GET', path: '/api/v1/repos/owner/stack' }), { code: 'copy-redirect-refused' });
|
|
assert.equal(sent.length, n + 1, 'one request, never followed');
|
|
t.diagnostic(`minted ${minted.length}`);
|
|
});
|