diff --git a/packages/webui/tests/pinned-copy.test.mjs b/packages/webui/tests/pinned-copy.test.mjs index 4103f533..07589efc 100644 --- a/packages/webui/tests/pinned-copy.test.mjs +++ b/packages/webui/tests/pinned-copy.test.mjs @@ -169,6 +169,53 @@ test('a copy that loses the rename race leaves no read-only temp directory behin 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.equal(r.stderr, `webui-service: could not rename the 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}\ninstall it with: scripts/webui-service.sh install --copy ${r.head}, with the unit's flags\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. async function serve(t, cli, args, env) { diff --git a/packages/webui/tests/service.test.mjs b/packages/webui/tests/service.test.mjs index d1226b19..d5d55a1b 100644 --- a/packages/webui/tests/service.test.mjs +++ b/packages/webui/tests/service.test.mjs @@ -71,19 +71,22 @@ const BUS_UNITS = 'mosaic-bus@acme.service loaded active running Mosaic bus host // to stderr. The bus calls install makes are answered apart: `busUnits` for // the mosaic-bus@ list-units, and `bus` for show, by default a bus started // from the run's own copy ($HOME, as run() sets it). `busFails` makes both -// bus calls fail. -function standIn(dir, lines = '', { listFails = false, bus = null, busUnits = BUS_UNITS, busFails = false } = {}) { +// bus calls fail. `busOther` answers show for mosaic-bus@other.service +// apart from `bus`, so a second loaded unit can differ from the first. +function standIn(dir, lines = '', { listFails = false, bus = null, busUnits = BUS_UNITS, busFails = false, busOther = null } = {}) { const bin = join(dir, 'bin'); mkdirSync(bin); writeFileSync(join(dir, 'list-units.txt'), lines); writeFileSync(join(dir, 'bus-units.txt'), busUnits); if (bus !== null) writeFileSync(join(dir, 'bus-show.txt'), bus); + if (busOther !== null) writeFileSync(join(dir, 'bus-show-other.txt'), busOther); const fail = 'echo "Failed to connect to bus" >&2; exit 1'; const show = bus !== null ? `cat "${dir}/bus-show.txt"` : `r="$HOME/.local/share/mosaic-console/${COPY_ID}"; printf '%s' "${busShow('$r')}"`; writeFileSync(join(bin, 'systemctl'), `#!/bin/sh echo "$*" >> "${dir}/calls.txt" case "$*" in *list-units*mosaic-bus@*) ${busFails ? fail : `cat "${dir}/bus-units.txt"`} ;; + *show*mosaic-bus@other.service*) ${busFails ? fail : busOther !== null ? `cat "${dir}/bus-show-other.txt"` : show} ;; *show*mosaic-bus@*) ${busFails ? fail : show} ;; *list-units*) ${listFails ? fail : `cat "${dir}/list-units.txt"`} ;; *is-enabled*) echo enabled ;; @@ -538,10 +541,21 @@ test('install refuses unless the bus unit runs from the same copy, naming both p assert.equal((await install(s.bin)).status, 0); assert.match(readFileSync(join(s.d, 'calls.txt'), 'utf8'), /show mosaic-bus@other\.service -p LoadState -p ExecStart/); rmSync(unitDir, { recursive: true }); + // The second loaded unit is off the copy: refused, naming the second unit and its path. + const two = `${BUS_UNITS}mosaic-bus@other.service loaded active running Mosaic bus host (other)\n`; + s = sys({ busUnits: two, bus: busShow(copy), busOther: busShow(REPO) }); + refused(await install(s.bin), `mosaic-bus@other.service runs from ${REPO}/scripts/mosaic, not the copy ${copy}; the bus accepts only its own human-cli.mjs, so every Console bus call would fail human-required until the bus runs from the same copy`); + assert.match(readFileSync(join(s.d, 'calls.txt'), 'utf8'), /show mosaic-bus@acme\.service -p LoadState -p ExecStart\n.*show mosaic-bus@other\.service -p LoadState -p ExecStart/s); // No bus unit installed for the business: refused. s = sys({ bus: 'LoadState=not-found\nExecStart=\n' }); refused(await install(s.bin, ['--business', 'acme']), `no bus unit mosaic-bus@acme.service is installed (LoadState not-found); the Console on the copy ${copy} needs its bus on that copy`); + // Only loaded counts: a unit systemd shows at any other LoadState is not the bus, even with an + // ExecStart on the copy. + for (const state of ['bad-setting', 'error', 'masked']) { + s = sys({ bus: busShow(copy).replace('LoadState=loaded', `LoadState=${state}`) }); + refused(await install(s.bin, ['--business', 'acme']), `no bus unit mosaic-bus@acme.service is installed (LoadState ${state}); the Console on the copy ${copy} needs its bus on that copy`); + } // No --business and no mosaic-bus@ unit loaded: refused. s = sys({ busUnits: '' }); refused(await install(s.bin), `no mosaic-bus@ unit is loaded, and the Console on the copy ${copy} needs its bus on that copy; name the business with --business ID`); @@ -644,6 +658,14 @@ test('status names the copy and whether refactor has moved past it, and says whe r = await run(dir, ['status', '--dir', unitDir], bin); assert.match(r.stdout, new RegExp(`^copy: none; the unit runs from ${esc(REPO)}$`, 'm')); assert.ok(!/^refactor:/m.test(r.stdout)); + // Only a whole 40-hex last part is a copy: 40 hex plus a character, or 41 hex, is not. + for (const last of [`${COPY_ID}x`, `x${COPY_ID}`, `${COPY_ID}0`]) { + unit(join(dir, 'copies', last)); + r = await run(dir, ['status', '--dir', unitDir], bin); + assert.equal(r.status, 0, r.stderr); + assert.match(r.stdout, new RegExp(`^copy: none; the unit runs from ${esc(join(dir, 'copies', last))}$`, 'm'), last); + assert.ok(!/^refactor:/m.test(r.stdout), last); + } // status reads; it calls nothing that changes the unit. assert.deepEqual(readFileSync(join(dir, readdirSync(dir).find((d) => d.startsWith('si-')), 'calls.txt'), 'utf8').split('\n').filter(Boolean).filter((c) => !/^--user (is-enabled|is-active|show) mosaic-webui\.service( |$)/.test(c)), []); });