LibrePortal/scripts/dev/lp-restore-wizard-test
librelad 429ec3fa2d Contents step: show the two snapshot kinds as two things
A repository holds one system=config snapshot and one snapshot per app. They
are separate because they are used separately: the settings tree is small,
changes rarely and is meaningless per-app, while app data is large, changes at
its own rate, and has to be restorable, movable and ageable on its own — which
is what the per-app tag buys.

The step listed "Apps" and "Domains" as peer sections, which hid that entirely.
It read as though a backup held three kinds of thing, and gave no clue that the
domains come OUT of the system snapshot rather than being a third kind.

Now: a Settings section (one snapshot, dated, saying plainly that it carries
every backup repository with its credentials and is restored first because it
is what makes the others reachable) with the domains nested under it and each
one's DNS verdict; then an App data section, one snapshot each, dated and
sized. A repository with app data and no settings snapshot says so — the
consequence, that repositories and logins do not come back, is not something to
find out afterwards.

restoreInspect emits that shape now, assembled with jq against the discover
JSON rather than by hand-rolled string concatenation, and carries each
snapshot's date: a backup's age is what people actually judge it by.

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

196 lines
9.7 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(() => {});
// The Contents step must present the two snapshot KINDS as two things. A
// repository holds one system=config snapshot and one per app, restored by
// different machinery; listing "Apps" and "Domains" as peers hid that, and
// hid that the domains come out of the system snapshot rather than being a
// third kind of thing in the backup.
w.restoreInfo = {
host: 'oldbox', hosts: ['oldbox'],
system: { present: true, date: '2026-08-28T13:10:02+01:00', domains: [] },
apps: [{ name: 'linkding', size: '1M', date: '2026-08-28T13:10:02+01:00' }]
};
await w.renderRestoreContents();
const contents = $('#sw-rs-contents').textContent.replace(/\s+/g, ' ');
out.showsSettingsSection = /Settings/.test(contents);
out.showsAppSection = /App data/.test(contents);
out.explainsSettingsFirst = /makes the others reachable/i.test(contents);
out.showsSnapshotDate = /28 Aug 2026/.test(contents);
// Domains belong under Settings, so with none there is no stray heading.
out.noDomainsHeadingWhenEmpty = !/Domains it will bring across/.test(contents);
// A repository with app data but no settings snapshot must say so: the
// user's repositories and logins will NOT come back, and finding that out
// afterwards is the worst possible time.
w.restoreInfo = { host: 'oldbox', hosts: ['oldbox'],
system: { present: false, date: '', domains: [] },
apps: [{ name: 'linkding', size: '1M', date: '' }] };
await w.renderRestoreContents();
const noSys = $('#sw-rs-contents').textContent.replace(/\s+/g, ' ');
out.warnsWhenNoSystemSnapshot = /no settings snapshot/i.test(noSys);
// 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 "the contents step separates the two snapshot kinds"
chk "a Settings section" "$(g .showsSettingsSection)" true
chk "an App data section" "$(g .showsAppSection)" true
chk "says why settings come first" "$(g .explainsSettingsFirst)" true
chk "shows when each was taken" "$(g .showsSnapshotDate)" true
chk "no domain heading when there are none" "$(g .noDomainsHeadingWhenEmpty)" true
chk "warns when there is no settings snapshot" "$(g .warnsWhenNoSystemSnapshot)" true
echo "submit"
chk "routes to the restore path" "$(g .submitRoutedToRestore)" true
[[ $fail -eq 0 ]] && echo "restore wizard test: OK"
exit $fail