An installed unit must equal the render but for the copy id, so a re-pin that adds --sso refused render-refused. case2-unit gains --check (every render check, nothing written) and --check-new (a first install's checks). stack-upgrade runs both checks before anything stops, and --next-args reads root's <instance>.args.next: equal before -- but for --copy, different after it. After the stage it moves the installed unit files into a new <instance>.replaced.XXXXXX directory and renders a first install. A failure moves them back unchanged and restarts the old copy, and confirm swaps the args file. The run sheet gives the commands, the rollback past the migration included. A stamped rehearsal now starts an isolated provider on 127.0.0.1 port 0 with a certificate and keys made for the run. It serves discovery and a JWKS for the live provider ids and answers 403 to authorize and token. Only the scratch Console trusts its certificate. The gate requires each provider ready (sso-ready); provider-discovery requires discovery and JWKS fetches and no code or token request. The provider stops with the run, and by itself once the run's shell is gone. The system-path test helper asserts the log's from copy again. The pinned-copy sign-in requests are bounded, and cleanup waits for each Console to exit.
413 lines
26 KiB
JavaScript
413 lines
26 KiB
JavaScript
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { execFileSync, spawn } from 'node:child_process';
|
|
import { once } from 'node:events';
|
|
import { chmodSync, cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { dirname, join } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { fixture } from '../../cli/tests/helpers.mjs';
|
|
import { writeDeployment } from '../../cli/src/deployment.mjs';
|
|
import { fakeOidc } from './helpers/fake-oidc.mjs';
|
|
import { request as httpRequest } from 'node:http';
|
|
import { consoleReads } from '../src/reads.mjs';
|
|
|
|
// Row 77 (#1571, lead decision 112): `scripts/webui-service.sh copy` and
|
|
// serve from the copy it writes. The copy comes from a fixture repository
|
|
// holding this tree's packages, scripts and roles, so a path the Console
|
|
// loads that the script's list leaves out fails the serve test here.
|
|
// Nothing here reaches systemd: copy makes no systemctl call.
|
|
const REPO = fileURLToPath(new URL('../../..', import.meta.url)).replace(/\/$/, '');
|
|
const NODE_DIR = dirname(process.execPath);
|
|
const GIT = ['-c', 'user.name=fixture', '-c', '[email protected]', '-c', 'commit.gpgsign=false', '-c', 'core.hooksPath=/dev/null'];
|
|
|
|
const git = (cwd, ...args) => execFileSync('git', [...GIT, '-C', cwd, ...args], { encoding: 'utf8' }).trim();
|
|
|
|
// A copy's directories are read-only, so removal gives write back first,
|
|
// as the docs' removal command does.
|
|
function tmp(t, prefix) {
|
|
const root = mkdtempSync(join(tmpdir(), prefix));
|
|
t.after(() => { execFileSync('chmod', ['-R', 'u+w', root]); rmSync(root, { recursive: true, force: true }); });
|
|
return root;
|
|
}
|
|
|
|
// A canonical checkout (a .git directory) on branch refactor, with this
|
|
// tree's files, the uncommitted ones included, and a RELEASE of its own.
|
|
// A deploy tree links node_modules; .gitignore's node_modules/ matches only a
|
|
// directory, so the link is listed as an untracked file. The copy never takes
|
|
// node_modules from git (npm ci makes it), so the fixture leaves the link out.
|
|
function fixtureRepo(t) {
|
|
const dir = tmp(t, 'webui-copy-');
|
|
const repo = join(dir, 'repo');
|
|
const files = execFileSync('git', ['-C', REPO, 'ls-files', '-z', '-co', '--exclude-standard', '--', 'packages', 'scripts', 'roles', 'adapters', 'tools/unslop-hook', 'RELEASE', ':!packages/*/tests', ':!scripts/foundation', ':(exclude,glob)**/node_modules'], { encoding: 'utf8' }).split('\0').filter(Boolean);
|
|
for (const f of files) if (existsSync(join(REPO, f))) cpSync(join(REPO, f), join(repo, f));
|
|
writeFileSync(join(repo, 'RELEASE'), '0.0.0-fixture\n');
|
|
writeFileSync(join(repo,'package.json'),JSON.stringify({name:'copy-fixture',version:'1.0.0'}));
|
|
writeFileSync(join(repo,'package-lock.json'),JSON.stringify({name:'copy-fixture',version:'1.0.0',lockfileVersion:3,packages:{'':{name:'copy-fixture',version:'1.0.0'}}}));
|
|
git(dir, 'init', '-q', '-b', 'refactor', repo);
|
|
// The commit leaves thousands of loose objects; a detached gc --auto
|
|
// would still be deleting them when the cleanup's chmod walks .git.
|
|
git(repo, 'config', 'gc.auto', '0');
|
|
git(repo, 'add', '-A');
|
|
git(repo, 'commit', '-q', '-m', 'fixture');
|
|
git(repo,'update-ref','refs/remotes/origin/refactor',git(repo,'rev-parse','HEAD'));
|
|
return { dir, repo, script: join(repo, 'scripts', 'webui-service.sh'), head: git(repo, 'rev-parse', 'HEAD') };
|
|
}
|
|
|
|
async function run(dir, script, args) {
|
|
const env = { PATH: `${NODE_DIR}:/usr/bin:/bin`, HOME: dir, XDG_CONFIG_HOME: join(dir, 'config') };
|
|
const child = spawn(script, args, { env });
|
|
let stdout = '', stderr = '';
|
|
child.stdout.on('data', (d) => { stdout += d; });
|
|
child.stderr.on('data', (d) => { stderr += d; });
|
|
const [status] = await once(child, 'close');
|
|
return { status, stdout, stderr };
|
|
}
|
|
|
|
function filesUnder(root) {
|
|
const out = [];
|
|
for (const entry of readdirSync(root, { withFileTypes: true, recursive: true })) if (entry.isFile()) out.push(join(entry.parentPath, entry.name));
|
|
return out;
|
|
}
|
|
|
|
test('copy writes the Console paths at the full id, read-only, and leaves an existing copy alone', async (t) => {
|
|
const { dir, repo, script, head } = fixtureRepo(t);
|
|
const copies = join(dir, 'copies');
|
|
const target = join(copies, head);
|
|
const r = await run(dir, script, ['copy', head.slice(0, 12), '--copies', copies]);
|
|
assert.equal(r.status, 0, r.stderr);
|
|
assert.match(r.stdout, new RegExp(`^copied: ${target}$`, 'm'));
|
|
assert.deepEqual(readdirSync(copies), [head], 'no temp directory left behind');
|
|
assert.deepEqual(readdirSync(target).sort(), ['.stack-build.json', 'RELEASE', 'adapters', 'package-lock.json', 'package.json', 'packages', 'roles', 'scripts', 'tools']);
|
|
assert.deepEqual(readdirSync(join(target, 'scripts')), ['case2-persona-import.mjs', 'mosaic', 'mosaic-config.mjs']);
|
|
assert.ok(existsSync(join(target, 'packages', 'webui', 'README.md')), "the unit's Documentation=");
|
|
assert.equal(statSync(join(target, 'packages', 'webui', 'README.md')).mode & 0o222, 0, 'README.md is read-only');
|
|
assert.ok(existsSync(join(target, 'packages', 'tasks', 'fields.json')), 'the task field table');
|
|
assert.ok(existsSync(join(target, 'packages', 'queue', 'src')), 'wrapper queue branch imports queue code; mutations still refuse off checkout');
|
|
assert.equal(readFileSync(join(target, 'RELEASE'), 'utf8'), '0.0.0-fixture\n');
|
|
const files = filesUnder(target);
|
|
assert.ok(files.length > 50, String(files.length));
|
|
for (const f of files) assert.equal(statSync(f).mode & 0o222, 0, `${f} is writable`);
|
|
// Directories too, the top one included, so a writer that replaces by
|
|
// rename can neither change a file nor add one.
|
|
const dirs = [target, ...readdirSync(target, { withFileTypes: true, recursive: true }).filter((e) => e.isDirectory()).map((e) => join(e.parentPath, e.name))];
|
|
assert.ok(dirs.length > 10, String(dirs.length));
|
|
for (const d of dirs) assert.equal(statSync(d).mode & 0o777, 0o555, `${d} mode`);
|
|
const config = join(target, 'scripts', 'mosaic-config.mjs');
|
|
const before = readFileSync(config, 'utf8');
|
|
const sed = await run(dir, '/usr/bin/sed', ['-i', 's/^/x/', config]);
|
|
assert.notEqual(sed.status, 0, 'sed -i replaced a file in the copy');
|
|
assert.equal(readFileSync(config, 'utf8'), before);
|
|
assert.throws(() => writeFileSync(join(target, 'scripts', 'added.mjs'), ''), { code: 'EACCES' });
|
|
// Already there: left as it is, by id or by branch name.
|
|
for (const commit of [head, 'refactor']) {
|
|
const again = await run(dir, script, ['copy', commit, '--copies', copies]);
|
|
assert.equal(again.status, 0, again.stderr);
|
|
assert.equal(again.stdout, `exists: ${target}\n`);
|
|
}
|
|
// The default directory is under XDG_DATA_HOME, else ~/.local/share.
|
|
const byDefault = await run(dir, script, ['copy', 'refactor']);
|
|
assert.equal(byDefault.status, 0, byDefault.stderr);
|
|
assert.ok(existsSync(join(dir, '.local', 'share', 'mosaic-stack', 'copies', head, 'packages', 'webui', 'src', 'cli.mjs')));
|
|
assert.equal(git(repo, 'status', '--porcelain'), '', 'the checkout is untouched');
|
|
});
|
|
|
|
test('copy refuses a commit off refactor, an unknown one, a worktree and a bad copies directory', async (t) => {
|
|
const { dir, repo, script } = fixtureRepo(t);
|
|
const copies = join(dir, 'copies');
|
|
git(repo, 'checkout', '-q', '-b', 'side');
|
|
writeFileSync(join(repo, 'side.txt'), 'side\n');
|
|
git(repo, 'add', 'side.txt');
|
|
git(repo, 'commit', '-q', '-m', 'side');
|
|
const side = git(repo, 'rev-parse', 'HEAD');
|
|
git(repo, 'checkout', '-q', 'refactor');
|
|
for (const [commit, why] of [[side, new RegExp(`^stack-copy: refusing: ${side} is not reachable from refs/heads/refactor$`, 'm')], ['side', /is not reachable from refs\/heads\/refactor/], ['nope', /^stack-copy: not a commit: nope$/m], ['a b', /commit must be a commit id or branch name/], ['HEAD~1', /commit must be a commit id or branch name/], ['.refactor', /^stack-copy: commit must be a commit id or branch name: \.refactor$/m]]) {
|
|
const r = await run(dir, script, ['copy', commit, '--copies', copies]);
|
|
assert.equal(r.status, 1, commit);
|
|
assert.match(r.stderr, why, commit);
|
|
assert.equal(r.stdout, '', commit);
|
|
}
|
|
for (const args of [['copy'], ['copy', '-x'], ['copy', 'refactor', '--copy', side], ['copy', 'refactor', '--port', '7332'], ['copy', 'refactor', '--dir', dir]]) {
|
|
assert.equal((await run(dir, script, args)).status, 4, args.join(' '));
|
|
}
|
|
for (const bad of ['copies', `${dir}/a b`, `${dir}/a%b`]) {
|
|
const r = await run(dir, script, ['copy', 'refactor', '--copies', bad]);
|
|
assert.equal(r.status, 1, bad);
|
|
assert.match(r.stderr, /copies directory (must be an absolute path|contains a character the unit cannot carry)/, bad);
|
|
}
|
|
assert.ok(!existsSync(copies), 'no refusal writes anything');
|
|
// N2: a linked worktree has a .git file; --allow-worktree is for tests.
|
|
const tree = join(dir, 'tree');
|
|
git(repo, 'worktree', 'add', '-q', '--detach', tree, 'refactor');
|
|
const fromTree = await run(dir, join(tree, 'scripts', 'webui-service.sh'), ['copy', 'refactor', '--copies', copies]);
|
|
assert.equal(fromTree.status, 1);
|
|
assert.match(fromTree.stderr, /^stack-copy: refusing: \S+ is not the canonical checkout \(its \.git is not a directory\)/m);
|
|
assert.ok(!existsSync(copies));
|
|
const allowed = await run(dir, join(tree, 'scripts', 'webui-service.sh'), ['copy', 'refactor', '--copies', copies, '--allow-worktree']);
|
|
assert.equal(allowed.status, 0, allowed.stderr);
|
|
assert.match(allowed.stdout, /^copied: /m);
|
|
assert.match((await run(dir, script, ['--help'])).stdout, /--allow-worktree is for the tests only/);
|
|
});
|
|
|
|
test('copy writes nothing when an archived path is missing at the commit', async (t) => {
|
|
const { dir, repo, script } = fixtureRepo(t);
|
|
const copies = join(dir, 'copies');
|
|
// Row 190: the entry point runs scripts/mosaic-config.mjs to select the
|
|
// instance before it copies, so the missing path is one only the copy reads.
|
|
git(repo, 'rm', '-q', 'packages/webui/README.md');
|
|
git(repo, 'commit', '-q', '-m', 'drop the Console README');
|
|
git(repo,'update-ref','refs/remotes/origin/refactor',git(repo,'rev-parse','HEAD'));
|
|
const r = await run(dir, script, ['copy', 'refactor', '--copies', copies]);
|
|
assert.equal(r.status, 1);
|
|
assert.match(r.stderr, /^stack-copy: git archive failed; no copy written$/m);
|
|
assert.deepEqual(readdirSync(copies), [], 'the temp directory is removed');
|
|
});
|
|
|
|
test('a copy that loses the rename race leaves no read-only temp directory behind', async (t) => {
|
|
const { dir, script, head } = fixtureRepo(t);
|
|
const copies = join(dir, 'copies');
|
|
// A stand-in mv that makes the target appear first, as a concurrent copy
|
|
// would, then fails: copy says exists: and its trap removes the temp copy.
|
|
const bin = join(dir, 'bin');
|
|
mkdirSync(bin);
|
|
writeFileSync(join(bin, 'mv'), `#!/bin/sh\nfor a; do t="$a"; done\nmkdir "$t"\nexit 1\n`, { mode: 0o755 });
|
|
const env = { PATH: `${bin}:${NODE_DIR}:/usr/bin:/bin`, HOME: dir, XDG_CONFIG_HOME: join(dir, 'config') };
|
|
const child = spawn(script, ['copy', 'refactor', '--copies', copies], { env });
|
|
let stdout = '';
|
|
child.stdout.on('data', (d) => { stdout += d; });
|
|
const [status] = await once(child, 'close');
|
|
assert.equal(status, 0);
|
|
assert.equal(stdout, `exists: ${join(copies, head)}\n`);
|
|
assert.deepEqual(readdirSync(copies), [head], 'the temp directory is removed');
|
|
});
|
|
|
|
// Row 98 (#1592). The rename's two other outcomes: it fails and no target
|
|
// appears (a copy that was never written must not print exists:), it
|
|
// meets a target that holds files (mv fails and nothing lands in the
|
|
// target), and it meets an empty target directory. Only the last tells mv -T
|
|
// from plain mv: the temp copy's directories are 0555, so plain mv cannot move
|
|
// it into a target at all (Permission denied) and fails exactly as -T does on a
|
|
// full one, but -T may replace an empty directory by rename.
|
|
async function copyWithMv(t, mvBody) {
|
|
const { dir, script, head } = fixtureRepo(t);
|
|
const copies = join(dir, 'copies');
|
|
const bin = join(dir, 'bin');
|
|
mkdirSync(bin);
|
|
writeFileSync(join(bin, 'mv'), `#!/bin/sh\n${mvBody}\n`, { mode: 0o755 });
|
|
const env = { PATH: `${bin}:${NODE_DIR}:/usr/bin:/bin`, HOME: dir, XDG_CONFIG_HOME: join(dir, 'config') };
|
|
const child = spawn(script, ['copy', 'refactor', '--copies', copies], { env });
|
|
let stdout = '', stderr = '';
|
|
child.stdout.on('data', (d) => { stdout += d; });
|
|
child.stderr.on('data', (d) => { stderr += d; });
|
|
const [status] = await once(child, 'close');
|
|
return { copies, target: join(copies, head), head, status, stdout, stderr };
|
|
}
|
|
|
|
test('a copy whose rename fails with no target refuses, writes no copy and leaves no temp directory', async (t) => {
|
|
const r = await copyWithMv(t, 'exit 1');
|
|
assert.equal(r.status, 1);
|
|
assert.ok(r.stderr.includes(`stack-copy: could not rename copy into ${r.target}\n`));
|
|
assert.equal(r.stdout, '');
|
|
assert.deepEqual(readdirSync(r.copies), [], 'no target and no temp directory');
|
|
});
|
|
|
|
test('a copy that meets a target holding files says exists: and moves nothing into it', async (t) => {
|
|
// The stand-in makes the target appear with a file in it, then runs the real mv with the script's flags.
|
|
const r = await copyWithMv(t, 'for a; do t="$a"; done\nmkdir "$t"\n: > "$t/other"\nexec /usr/bin/mv "$@"');
|
|
assert.equal(r.status, 0, r.stderr);
|
|
assert.equal(r.stdout, `exists: ${r.target}\n`);
|
|
assert.deepEqual(readdirSync(r.copies), [r.head], 'the temp directory is removed');
|
|
assert.deepEqual(readdirSync(r.target), ['other'], 'nothing was moved into the target');
|
|
});
|
|
|
|
test('a copy that meets an empty target directory replaces it by rename, not by moving into it', async (t) => {
|
|
const r = await copyWithMv(t, 'for a; do t="$a"; done\nmkdir "$t"\nexec /usr/bin/mv "$@"');
|
|
assert.equal(r.status, 0, r.stderr);
|
|
assert.equal(r.stdout, `copied: ${r.target}\n`);
|
|
assert.deepEqual(readdirSync(r.copies), [r.head], 'the temp directory is gone');
|
|
assert.ok(existsSync(join(r.target, 'packages', 'webui', 'README.md')), 'the copy is the target itself');
|
|
});
|
|
|
|
// serve from a copy: /healthz, the config check, roles and RELEASE from the
|
|
// copy, and the queue read from --checkout or, without it, from the copy.
|
|
// A copy is stamped, so it starts only with --sso and the deployed
|
|
// prerequisites (M3): console.sso and its slot, .deployed and accounts.json.
|
|
async function serve(t, cli, args, env) {
|
|
const child = spawn(process.execPath, [cli, 'serve', '--port', '0', '--board', 'http://127.0.0.1:9', ...args], { env, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
const exited = once(child, 'exit');
|
|
// Cleanup waits for the Console to exit, so no child outlives the test.
|
|
t.after(async () => { if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); await exited; });
|
|
let out = '', err = '';
|
|
child.stderr.on('data', (c) => { err += c; });
|
|
const timer = setTimeout(() => child.kill('SIGKILL'), 60_000);
|
|
const port = await new Promise((resolve, reject) => {
|
|
child.stdout.on('data', (c) => { out += c; const m = out.match(/^Mosaic Console: http:\/\/127\.0\.0\.1:(\d+)\/$/m); if (m && out.includes('Ctrl-C')) resolve(Number(m[1])); });
|
|
child.on('exit', (code, signal) => reject(new Error(`exited ${code ?? signal} before listening: ${out}${err}`)));
|
|
}).finally(() => clearTimeout(timer));
|
|
return { out, port };
|
|
}
|
|
|
|
// A start that refuses: exit 2, the refusal on stderr, and no listener line.
|
|
// A start that listens instead is killed at its listener line (or after 60s),
|
|
// so a removed check fails here rather than holding the file open.
|
|
async function refusedStart(cli, args, env) {
|
|
const child = spawn(process.execPath, [cli, 'serve', '--port', '0', '--board', 'http://127.0.0.1:9', ...args], { env, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
let out = '', err = '';
|
|
const timer = setTimeout(() => child.kill('SIGKILL'), 60_000);
|
|
child.stdout.on('data', (c) => { out += c; if (/Mosaic Console:/.test(out)) child.kill('SIGKILL'); });
|
|
child.stderr.on('data', (c) => { err += c; });
|
|
const [code] = await once(child, 'close');
|
|
clearTimeout(timer);
|
|
assert.doesNotMatch(out, /Mosaic Console:/);
|
|
return { code, err };
|
|
}
|
|
|
|
test('a stamped copy refuses the former unauthenticated Console start', async t => {
|
|
const { dir, script, head } = fixtureRepo(t);
|
|
const copies = join(dir, 'copies');
|
|
assert.equal((await run(dir, script, ['copy', 'refactor', '--copies', copies])).status, 0);
|
|
const cli = join(copies, head, 'packages/webui/src/cli.mjs');
|
|
const { env } = fixture(join(dir, 'home'));
|
|
const { code, err } = await refusedStart(cli, ['--business', 'acme'], env);
|
|
assert.equal(code, 2);
|
|
assert.match(err, /stamped build serve requires --sso/);
|
|
});
|
|
|
|
const PUBLIC = 'console.test';
|
|
// Each request is bounded: a stall, an aborted or incomplete response, or a
|
|
// body that is not JSON rejects with the path instead of hanging the test.
|
|
function request(port, path, headers = {}) {
|
|
return new Promise((resolve, reject) => {
|
|
const req = httpRequest({ host: '127.0.0.1', port, path, headers: { host: PUBLIC, ...headers } }, (res) => {
|
|
let text = '';
|
|
res.setEncoding('utf8');
|
|
res.on('data', (c) => { text += c; });
|
|
res.on('aborted', () => reject(new Error(`${path}: response aborted`)));
|
|
res.on('error', reject);
|
|
res.on('end', () => {
|
|
if (!res.complete) return reject(new Error(`${path}: incomplete response`));
|
|
let body = null;
|
|
try { body = text ? JSON.parse(text) : null; } catch (e) { return reject(new Error(`${path}: ${res.statusCode} body is not JSON: ${text.slice(0, 200)}`)); }
|
|
resolve({ status: res.statusCode, headers: res.headers, body });
|
|
});
|
|
});
|
|
req.setTimeout(10_000, () => req.destroy(new Error(`${path}: no response within 10s`)));
|
|
req.on('error', reject); req.end();
|
|
});
|
|
}
|
|
|
|
// The owner signs in through the fake provider; reads carry the session on the public Host.
|
|
async function signIn(port, op, iss) {
|
|
const login = await request(port, '/auth/login');
|
|
assert.equal(login.status, 303);
|
|
const cookie = [login.headers['set-cookie']].flat()[0].split(';')[0];
|
|
const grant = op.grant(iss, login.headers.location, { sub: 'owner' });
|
|
const state = new URL(login.headers.location).searchParams.get('state');
|
|
const callback = await request(port, `/auth/callback?${new URLSearchParams({ code: grant.code, state })}`, { cookie });
|
|
assert.equal(callback.status, 303);
|
|
const session = [callback.headers['set-cookie']].flat().find((v) => v.startsWith('__Host-mosaic_session=') && !v.includes('Max-Age=0')).split(';')[0];
|
|
return async (path) => { const r = await request(port, path, { cookie: session }); return { status: r.status, body: r.body }; };
|
|
}
|
|
|
|
test('serve runs from a copy: /healthz 200, the config check, roles and RELEASE from the copy, the queue from --checkout', { timeout: 240_000 }, async (t) => {
|
|
const { dir, script, head } = fixtureRepo(t);
|
|
const copies = join(dir, 'copies');
|
|
assert.equal((await run(dir, script, ['copy', 'refactor', '--copies', copies])).status, 0);
|
|
const cli = join(copies, head, 'packages', 'webui', 'src', 'cli.mjs');
|
|
// A deployed copy gets node_modules from npm ci; the fixture links this tree's (the SSO client needs it).
|
|
const mode = statSync(join(copies, head)).mode & 0o777; chmodSync(join(copies, head), mode | 0o200); symlinkSync(join(REPO, 'node_modules'), join(copies, head, 'node_modules')); chmodSync(join(copies, head), mode);
|
|
// A checkout whose queue read says where it ran.
|
|
const checkout = join(dir, 'checkout');
|
|
mkdirSync(join(checkout, 'packages', 'webui', 'src'), { recursive: true });
|
|
writeFileSync(join(checkout, 'packages', 'webui', 'src', 'queue-read.mjs'), `process.stdout.write(JSON.stringify({ rows: [], notes: [process.cwd() === ${JSON.stringify(checkout)} ? 'read in the checkout' : 'read elsewhere'] }));\n`);
|
|
const f = fixture(join(dir, 'home'));
|
|
const env = { ...f.env };
|
|
delete env.MOSAIC_ROLES_DIR;
|
|
const args = ['--business', 'acme', '--sso', '--checkout', checkout];
|
|
// Each prerequisite missing in turn refuses the --sso start before it listens.
|
|
const op = await fakeOidc(); t.after(() => op.close());
|
|
const iss = op.issuer('a');
|
|
const ca = join(dir, 'ca.pem'); writeFileSync(ca, op.cert);
|
|
env.NODE_EXTRA_CA_CERTS = ca;
|
|
const cfgDir = dirname(f.config);
|
|
let r = await refusedStart(cli, args, env);
|
|
assert.equal(r.code, 2, r.err); assert.match(r.err, /\.deployed is missing or invalid/);
|
|
writeDeployment({ dataRoot: f.dataRoot }, cfgDir);
|
|
r = await refusedStart(cli, args, env);
|
|
assert.equal(r.code, 2, r.err); assert.match(r.err, /accounts file not found/);
|
|
writeFileSync(join(cfgDir, 'accounts.json'), JSON.stringify({ accountsVersion: 1, revision: 1, users: [{ id: 'u-000000000001', subjects: [{ provider: 'a', sub: 'owner' }], name: 'Owner', instanceRole: 'operator', state: 'active', authGen: 1, created: '2026-11-10T00:00:00Z' }], memberships: [], invites: [] }), { mode: 0o600 });
|
|
r = await refusedStart(cli, args, env);
|
|
// Without console.sso no provider is configured, so the accounts file's subject has none.
|
|
assert.equal(r.code, 2, r.err); assert.match(r.err, /must be a configured provider/);
|
|
const config = JSON.parse(readFileSync(f.config, 'utf8'));
|
|
config.console = { sso: { publicOrigin: `https://${PUBLIC}`, providers: [{ id: 'a', issuer: iss.issuer, clientId: iss.clientId, clientSecret: { slot: 'a' }, algs: ['RS256'], subjects: [{ sub: 'owner', name: 'Owner' }] }] } };
|
|
writeFileSync(f.config, JSON.stringify(config));
|
|
r = await refusedStart(cli, args, env);
|
|
assert.equal(r.code, 2, r.err); assert.match(r.err, /client secret slot is missing/);
|
|
mkdirSync(join(cfgDir, 'secrets', 'mosaic-stack'), { recursive: true, mode: 0o700 });
|
|
const slot = join(cfgDir, 'secrets', 'mosaic-stack', 'a'); writeFileSync(slot, iss.clientSecret, { mode: 0o600 }); chmodSync(slot, 0o600);
|
|
const { out, port } = await serve(t, cli, args, env);
|
|
assert.match(out, /^Bus: acme$/m, 'the config check ran from the copy');
|
|
assert.match(out, /^Queue: read from the --checkout directory$/m);
|
|
const health = await fetch(`http://127.0.0.1:${port}/healthz`, { signal: AbortSignal.timeout(10_000) });
|
|
assert.equal(health.status, 200);
|
|
// Under --sso the body adds each provider's state beside ok (serve.mjs /healthz).
|
|
const hb = await health.json();
|
|
assert.equal(hb.ok, true); assert.deepEqual(Object.keys(hb), ['ok', 'sso']); assert.deepEqual(Object.keys(hb.sso), ['a']);
|
|
const get = await signIn(port, op, iss);
|
|
const settings = await get('/api/settings');
|
|
assert.equal(settings.status, 200, JSON.stringify(settings.body));
|
|
assert.equal(settings.body.release, '0.0.0-fixture', 'RELEASE from the copy');
|
|
const business = await get('/api/business');
|
|
assert.equal(business.status, 200, JSON.stringify(business.body));
|
|
assert.equal(business.body.business.id, 'acme', 'roles from the copy');
|
|
const queue = await get('/api/queue');
|
|
assert.equal(queue.status, 200, JSON.stringify(queue.body));
|
|
assert.deepEqual(queue.body.notes, ['read in the checkout']);
|
|
// Row 61's field table loads from the copy's packages/tasks.
|
|
const fields = await get('/api/task-fields');
|
|
assert.equal(fields.status, 200, JSON.stringify(fields.body));
|
|
assert.deepEqual(fields.body, JSON.parse(readFileSync(join(REPO, 'packages', 'tasks', 'fields.json'), 'utf8')));
|
|
// Without --checkout the copy's own queue read runs, and the copy holds
|
|
// no packages/queue, so it fails closed.
|
|
const plain = await serve(t, cli, ['--business', 'acme', '--sso'], env);
|
|
assert.doesNotMatch(plain.out, /^Queue:/m);
|
|
assert.equal((await fetch(`http://127.0.0.1:${plain.port}/healthz`, { signal: AbortSignal.timeout(10_000) })).status, 200);
|
|
const none = await (await signIn(plain.port, op, iss))('/api/queue');
|
|
assert.equal(none.status, 503);
|
|
assert.equal(none.body.error, 'queue-refused');
|
|
});
|
|
|
|
test('serve refuses a --checkout that is not an absolute directory path', async (t) => {
|
|
const dir = tmp(t, 'webui-checkout-');
|
|
writeFileSync(join(dir, 'file'), '');
|
|
// 'sub' is a directory under the child's cwd, so only its being relative refuses it.
|
|
mkdirSync(join(dir, 'sub'));
|
|
for (const value of ['sub', join(dir, 'missing'), join(dir, 'file')]) {
|
|
const child = spawn(process.execPath, [join(REPO, 'packages', 'webui', 'src', 'cli.mjs'), 'serve', '--port', '0', '--checkout', value], { cwd: dir, timeout: 10000, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
let out = '', err = '';
|
|
child.stdout.on('data', (c) => { out += c; });
|
|
child.stderr.on('data', (c) => { err += c; });
|
|
const [code] = await once(child, 'close');
|
|
assert.equal(code, 2, value);
|
|
assert.equal(err, 'refused: --checkout must be the absolute path of a directory\n', value);
|
|
assert.equal(out, '');
|
|
}
|
|
});
|
|
|
|
test('consoleReads runs the queue read under queueRoot and reads RELEASE under root', async (t) => {
|
|
const dir = tmp(t, 'webui-roots-');
|
|
const [root, queueRoot] = [join(dir, 'copy'), join(dir, 'checkout')];
|
|
for (const [base, said] of [[root, 'copy'], [queueRoot, 'checkout']]) {
|
|
mkdirSync(join(base, 'packages', 'webui', 'src'), { recursive: true });
|
|
writeFileSync(join(base, 'packages', 'webui', 'src', 'queue-read.mjs'), `process.stdout.write(JSON.stringify({ rows: [], notes: ['${said}', process.cwd() === ${JSON.stringify(base)} ? 'cwd there' : 'cwd elsewhere'] }));\n`);
|
|
writeFileSync(join(base, 'RELEASE'), `${said}-release\n`);
|
|
}
|
|
const apart = consoleReads({ root, queueRoot, rolesDir: join(dir, 'roles') });
|
|
assert.deepEqual((await apart.queue()).notes, ['checkout', 'cwd there'], 'the checkout ran it, with its cwd there');
|
|
assert.equal((await apart.settings()).release, 'copy-release');
|
|
const together = consoleReads({ root, rolesDir: join(dir, 'roles') });
|
|
assert.deepEqual((await together.queue()).notes, ['copy', 'cwd there'], 'queueRoot defaults to root');
|
|
assert.equal((await together.settings()).release, 'copy-release');
|
|
});
|