LibrePortal/scripts/dev/lp-restore-wizard-test
librelad a361e38562 Wizard: New install or Restore from backup
The wizard's first question is now "is this a new server, or a replacement for
one?", which §2 of the roadmap described and nothing implemented. Start asks,
and the answer selects one of two disjoint step sets:

  new      Start > Experience > Identity > Domains > Storage > Backups
                 > Import > Recommended > (Metrics)
  restore  Start > Source > Contents > Rebuild

Disjoint deliberately. A restore is never asked for an install name, domains or
an app list — the backup answers all three, and asking invites someone to type
an answer that is about to be written over. The test asserts non-overlap in
both directions, not just that the restore steps appear.

Source collects the repository the way the Backup page does, minus everything
that only means something for a place you write TO: no retention, no schedule,
no enable toggle. The password leaves through the one-shot secret:<ref> channel
and is cleared from the DOM, and the test asserts the value never appears in
the payload — that payload reaches a task command line, and tasks are recorded
world-readable.

Contents is the reconciliation, rendered: apps with sizes, and each domain with
a verdict, checked through the same /api/setup/dns-check the Domains step uses
rather than adding a second way to ask. Plus the offer to leave the strays out
until DNS is repointed.

Rebuild runs `restore rebuild`: settings first (they carry every other
repository's credentials), then domains, then apps with no explicit list so
bulk discovers and re-preflights them itself.

Inserting Start shifted every step index by one. validateStep was a chain of
idx === 1 … idx === 6, carrying a comment that already explained which earlier
insertions had moved them — it is keyed on the step name now.
lp-storage-step-test had the same pin and did not survive: it called
validateStep(3) for Storage, which had become Domains, and reported that
nothing blocked. That reads exactly like validation being broken. Tests look
their step up by name now too.

Also: locationRemove's fix means a failed connect can finally clean up after
itself, so a wrong password no longer leaves a dead destination behind on every
retry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 05:04:43 +01:00

159 lines
7.6 KiB
Bash
Executable File

#!/bin/bash
# The wizard's New install / Restore branch, driven in a real browser.
#
# scripts/dev/lp-restore-wizard-test # needs a running WebUI
#
# The branch point is the whole design: Start asks new-or-restore, and the
# answer selects one of two DISJOINT step sets. A restore must never be asked
# for an install name, domains or an app list — the backup answers all three,
# and asking invites the user to contradict what is about to be written over
# their answer. So the test asserts the sets do not overlap, in both
# directions, rather than only that the restore steps appear.
#
# It also asserts the two things that would be invisible until someone had
# already lost by them: that the repository password leaves through the
# one-shot secret channel and does not linger in the DOM, and that submit()
# routes to the restore path — the normal payload is built from steps a restore
# never showed, so submitting it posts an empty install name and is rejected by
# the route, which is a confusing way to find out the branch was never wired.
REPO="$(cd "$(dirname "$0")/../.." && pwd)"
SHOT="$REPO/scripts/dev/lp-shot"
fail=0
chk(){ if [[ "$2" == "$3" ]]; then echo " ok $1"; else echo " FAIL $1: got '$2' want '$3'"; fail=1; fi; }
command -v jq >/dev/null 2>&1 || { echo " SKIP jq not installed"; exit 0; }
lp_reachable() {
local u; u=$("$SHOT" --url 2>/dev/null) || return 1
[[ -n "$u" ]] || return 1
curl -fsS -o /dev/null --max-time 5 "$u" 2>/dev/null
}
read -r -d '' DRIVE <<'JS'
const out = {};
const w = window.setupWizard;
if (!w) return JSON.stringify({ error: 'wizard handle missing' });
const fire = (el, ev) => el.dispatchEvent(new Event(ev, { bubbles: true }));
const $ = s => document.querySelector(s);
const visible = () => w.stepNames.filter((n, i) => w._stepVisible(i));
const newRadio = $('input[name="sw-mode"][value="new"]');
const resRadio = $('input[name="sw-mode"][value="restore"]');
if (!newRadio || !resRadio) return JSON.stringify({ error: 'Start step has no mode cards' });
out.newSteps = visible();
out.newIsDefault = w.installMode === 'new';
resRadio.checked = true; fire(resRadio, 'change');
out.restoreSteps = visible();
out.mode = w.installMode;
// Disjoint in both directions, apart from Start itself.
const NEW_ONLY = ['Experience', 'Identity', 'Domains', 'Storage', 'Backups', 'Import', 'Recommended', 'Metrics'];
const RESTORE_ONLY = ['Source', 'Contents', 'Rebuild'];
out.restoreLeaksNewStep = out.restoreSteps.some(s => NEW_ONLY.includes(s));
out.newLeaksRestoreStep = out.newSteps.some(s => RESTORE_ONLY.includes(s));
out.startAlwaysShown = out.newSteps[0] === 'Start' && out.restoreSteps[0] === 'Start';
// The source form offers every backend, and shows only the chosen one.
out.kinds = Array.from(document.querySelectorAll('#sw-rs-type option')).map(o => o.value);
const groupsFor = (t) => {
const sel = $('#sw-rs-type'); sel.value = t; fire(sel, 'change');
return Array.from(document.querySelectorAll('[data-rs-group]'))
.filter(g => g.style.display !== 'none')
.map(g => g.dataset.rsGroup)
.filter((v, i, a) => a.indexOf(v) === i);
};
out.localShowsOnlyLocal = JSON.stringify(groupsFor('local')) === JSON.stringify(['local']);
out.sftpShowsOnlySftp = JSON.stringify(groupsFor('sftp')) === JSON.stringify(['sftp']);
// Validation, before anything is sent.
$('#sw-rs-type').value = 'local'; fire($('#sw-rs-type'), 'change');
$('#sw-rs-path').value = ''; $('#sw-rs-pass').value = '';
out.emptyPathRefused = !!w._restoreSourceProblem();
$('#sw-rs-path').value = 'relative/path';
out.relativePathRefused = !!w._restoreSourceProblem();
$('#sw-rs-path').value = '/somewhere/backups';
out.missingPasswordRefused = !!w._restoreSourceProblem();
$('#sw-rs-pass').value = 'x';
out.completeAccepted = !w._restoreSourceProblem();
// A password must leave as a reference and not linger in the DOM. Stubbed:
// the real channel is covered by lp-secret-channel-test, and what matters
// here is that readBackup routes through it at all rather than putting the
// value in the payload.
let stashedValue = null, sentBody = null;
w.stashSecret = async (v) => { stashedValue = v; return 'secret:' + '0'.repeat(32); };
const realFetch = window.fetch;
window.fetch = async (url, opts) => {
if (String(url).includes('/api/setup/restore/read')) {
sentBody = JSON.parse(opts.body);
return { ok: true, json: async () => ({ ok: true, taskId: 't', nonce: 'n' }) };
}
return realFetch(url, opts);
};
$('#sw-rs-pass').value = 'hunter2-not-a-real-password';
const readPromise = w.readBackup();
// Do not wait out the poll: what is under test is what left the browser.
await new Promise(r => setTimeout(r, 500));
out.passwordWasStashed = stashedValue === 'hunter2-not-a-real-password';
out.passwordClearedFromDom = $('#sw-rs-pass').value === '';
out.payloadCarriesRef = !!(sentBody && sentBody.location && sentBody.location.password_ref);
out.payloadCarriesNoPassword = !!(sentBody && sentBody.location
&& !JSON.stringify(sentBody.location).includes('hunter2'));
window.fetch = realFetch;
// Deliberately NOT awaited: the stubbed response has no matching document to
// find, so readBackup polls for a full minute before giving up. What is
// under test already happened — what left the browser — and waiting for the
// timeout only makes the test take a minute longer than it needs to.
readPromise.catch(() => {});
// submit() must route to the restore path, not the install payload.
let routedTo = null;
w.submitRestore = async () => { routedTo = 'restore'; };
w._submitting = false;
await w.submit();
out.submitRoutedToRestore = routedTo === 'restore';
return JSON.stringify(out);
JS
J=$("$SHOT" --eval "/" "$DRIVE" 2>/dev/null)
if [[ -z "$J" ]] || ! jq -e . >/dev/null 2>&1 <<< "$J"; then
if lp_reachable; then
echo " FAIL the WebUI is up but the page returned nothing (browser failed?)"; exit 1
fi
echo " SKIP no WebUI reachable"; exit 0
fi
g(){ jq -r "$1" <<< "$J" 2>/dev/null; }
if [[ "$(g '.error // empty')" != "" ]]; then echo " FAIL $(g .error)"; exit 1; fi
echo "the branch"
chk "new install is the default" "$(g .newIsDefault)" true
chk "picking restore switches mode" "$(g .mode)" restore
chk "Start shows in both" "$(g .startAlwaysShown)" true
chk "restore shows no install steps" "$(g .restoreLeaksNewStep)" false
chk "new shows no restore steps" "$(g .newLeaksRestoreStep)" false
chk "restore step set" "$(g '.restoreSteps | join(",")')" "Start,Source,Contents,Rebuild"
echo "the backup source form"
chk "every backend offered" "$(g '.kinds | join(",")')" "local,sftp,rest,s3,b2"
chk "local shows only its own" "$(g .localShowsOnlyLocal)" true
chk "sftp shows only its own" "$(g .sftpShowsOnlySftp)" true
chk "empty path refused" "$(g .emptyPathRefused)" true
chk "relative path refused" "$(g .relativePathRefused)" true
chk "missing password refused" "$(g .missingPasswordRefused)" true
chk "a complete source accepted" "$(g .completeAccepted)" true
echo "the password"
chk "goes through the secret channel" "$(g .passwordWasStashed)" true
chk "leaves the payload as a ref" "$(g .payloadCarriesRef)" true
chk "and never as a value" "$(g .payloadCarriesNoPassword)" true
chk "and is cleared from the DOM" "$(g .passwordClearedFromDom)" true
echo "submit"
chk "routes to the restore path" "$(g .submitRoutedToRestore)" true
[[ $fail -eq 0 ]] && echo "restore wizard test: OK"
exit $fail