LibrePortal/scripts/dev/lp-restore-wizard-test
librelad 7fef102369 Put the found backups under the field they fill in
The results were rendered above the whole form, which made them read as a
separate step rather than as an answer about the Folder input. They now sit
under that field, inside its box, alongside the verdict.

What is shown depends on how many were found, because those are different
situations:

  one       it is the answer, not a choice — filled in, with its verdict. No
            card, because the card and the verdict said the same thing twice
  several   listed as buttons, most snapshots first; the field stays empty,
            since this is genuinely ambiguous and guessing would be worse
  none      says so — "we looked and there is nothing here" is information

And a bug: the single result filled in only the PLACEHOLDER, so the field was
still empty and pressing Check replied "give a full path, starting with /"
about the very backup shown directly above it. It fills the value in now, and
shows the verdict straight from the scan rather than making the user press
Check to be told what is already on screen. Never overwrites something already
typed — the user's own answer outranks anything we guessed.

_adoptSingleResult moved into renderFoundBackups: "show what we found, and if
there is exactly one, take it" is one behaviour, and splitting it meant the
test could only reach half of it.

Not done, deliberately: listing individual snapshots before the password. The
count is a directory listing, but each snapshot's identity — host, tags,
contents, when they are from — is in the encrypted object. Unlocked, all that
could be shown is a column of hex IDs and file timestamps, which is not
something anyone can choose between. After unlocking, the Contents step already
lists the settings snapshot and one entry per app, which is how people think
about it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 07:27:16 +01:00

344 lines
18 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 = ['Backup', '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.
// The progress bar said "Source" while the heading said "Where is your
// backup?", which read as two different steps. They have to agree.
const sec = document.querySelector('.setup-step[data-step="9"] .setup-section-title');
out.titleMatchesStepName = !!sec && sec.textContent.trim() === 'Backup';
// Fields laid out like the rest of the wizard: label with a tooltip, and an
// icon beside the input — not the label-left rows the Storage step uses.
out.fieldsHaveIcons = document.querySelectorAll('#sw-rs-fields .setup-field-icon').length > 0;
out.fieldsHaveTooltips = document.querySelectorAll('#sw-rs-fields .setup-tooltip').length > 0;
out.passwordHasIcon = !!document.querySelector('#sw-rs-pass')
?.closest('.setup-input-row')?.querySelector('.setup-field-icon');
// Fields for one backend are boxed under their own heading. Loose rows
// appearing beneath the type dropdown gave no signal that they belonged to
// the choice above them.
const shownGroup = () => {
const g = Array.from(document.querySelectorAll('.setup-subgroup'))
.filter(el => el.style.display !== 'none');
return g.length === 1 ? g[0].querySelector('.setup-subgroup-title').textContent.trim() : null;
};
const selType = (v) => { const s = $('#sw-rs-type'); s.value = v; fire(s, 'change'); };
selType('sftp'); out.groupForSftp = shownGroup();
selType('b2'); out.groupForB2 = shownGroup();
selType('local'); out.groupForLocal = shownGroup();
// Every visible field must clear the one above it. The step's 16px gap only
// reaches .setup-step's DIRECT children, and these sit a level deeper inside
// a .setup-section — so each input ran straight into the next field's label.
selType('sftp');
w.showStep(1);
await new Promise(r => setTimeout(r, 200));
const onScreen = Array.from(document.querySelectorAll('.setup-step[data-step="9"] .setup-field'))
.filter(el => el.offsetParent !== null);
let minGap = Infinity;
for (let i = 1; i < onScreen.length; i++) {
const a = onScreen[i - 1].getBoundingClientRect(), b = onScreen[i].getBoundingClientRect();
minGap = Math.min(minGap, Math.round(b.top - a.bottom));
}
out.visibleFieldCount = onScreen.length;
out.smallestFieldGap = onScreen.length > 1 ? minGap : null;
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();
// Finding backups without a password. A restic repository keeps one file per
// snapshot under snapshots/, so "is this a backup, and how many" is a
// directory listing — nothing is decrypted. That is what lets the step tell
// the user something useful BEFORE asking for the password, which is the one
// thing a person rebuilding a server may not have to hand.
const t0 = Date.now();
while (Date.now() - t0 < 45000 && !(w.foundBackups || []).length) {
await new Promise(r => setTimeout(r, 700));
}
out.scanFoundSomething = (w.foundBackups || []).length > 0;
out.foundCarrySnapshotCounts = (w.foundBackups || []).every(f => typeof f.snapshots === 'number');
// Both the list and the verdict belong to the Folder field, under it, rather
// than floating above the whole form: they are answers about that input.
const localGroup = $('.setup-subgroup[data-rs-group="local"]');
out.foundInsideTheGroup = !!(localGroup && $('#sw-rs-found') && localGroup.contains($('#sw-rs-found')));
out.verdictInsideTheGroup = !!(localGroup && $('#sw-rs-verify-result')
&& localGroup.contains($('#sw-rs-verify-result')));
// --- exactly one found: it is the answer, not a choice ---
$('#sw-rs-path').value = '';
w.foundBackups = [{ path: '/only/one', snapshots: 4, newest: '2026-08-29T05:51:00+01:00' }];
w.renderFoundBackups();
out.singlePrefillsThePath = ($('#sw-rs-path') || {}).value === '/only/one';
out.singleShowsItsVerdict = /4 snapshots/.test(($('#sw-rs-verify-result') || {}).textContent || '');
// The card and the verdict would otherwise say the same thing twice.
out.singleDrawsNoDuplicateCard = $('#sw-rs-found .setup-found-backup') === null;
// Pressing Check must confirm, not reply "give a full path" about the backup
// shown directly above it — which is what an empty field did.
await w.verifyBackupPath();
out.singleCheckDoesNotScold = !/full path/i.test(($('#sw-rs-verify-result') || {}).textContent || '');
// --- several found: a real choice, so ask ---
$('#sw-rs-path').value = '';
w.foundBackups = [
{ path: '/repo/a', snapshots: 3, newest: '2026-08-01T10:00:00+01:00' },
{ path: '/repo/b', snapshots: 9, newest: '2026-08-20T10:00:00+01:00' }
];
w.renderFoundBackups();
out.multipleAreListed = document.querySelectorAll('#sw-rs-found .setup-found-backup').length;
out.multipleRenderedAsButtons =
Array.from(document.querySelectorAll('#sw-rs-found .setup-found-backup')).every(b => b.tagName === 'BUTTON');
// Never guessed when it is genuinely ambiguous.
out.multipleLeaveThePathEmpty = ($('#sw-rs-path') || {}).value === '';
$('#sw-rs-found .setup-found-backup').click();
out.pickingOneFillsThePath = ($('#sw-rs-path') || {}).value === '/repo/a';
out.pickingOneReportsWithoutPassword =
/snapshot/i.test(($('#sw-rs-verify-result') || {}).textContent || '');
// --- none found ---
$('#sw-rs-path').value = '';
w.foundBackups = [];
w.renderFoundBackups();
// "We looked and there is nothing here" is information; empty space is not.
out.noneSaysSo = /nothing found/i.test(($('#sw-rs-found') || {}).textContent || '');
out.placeholderIsReal = !/mnt\/usb/.test(($('#sw-rs-path') || {}).placeholder || '');
// Pointing at the folder that HOLDS the repositories is the common near-miss,
// and is worth one click to fix rather than a paragraph explaining it.
$('#sw-rs-path').value = '/libreportal-backups';
await w.verifyBackupPath();
out.parentFolderExplained = /holds backups rather than being one/i.test(
($('#sw-rs-verify-result') || {}).textContent || '');
out.offersTheRealPath = !!$('#sw-rs-usesuggest');
// 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;
// Both halves are stubbed, not just the POST. readBackup polls the published
// document for a full minute before giving up, and leaving that loop running
// kept the page from ever going network-idle — the whole eval then died on
// the harness's 90s cap, which reads as "the browser failed" rather than as
// a test that never finished.
window.fetch = async (url, opts) => {
const u = String(url);
if (u.includes('/api/setup/restore/read')) {
sentBody = JSON.parse(opts.body);
return { ok: true, json: async () => ({ ok: true, taskId: 't', nonce: 'n' }) };
}
if (u.includes('/data/system/restore_read.json')) {
return { ok: true, json: async () => ({
nonce: 'n', host: 'oldbox', hosts: ['oldbox'],
system: { present: true, date: '2026-08-28T13:10:02+01:00', domains: [] },
apps: [] }) };
}
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'));
// Now that the poll is stubbed too, the read completes rather than hanging.
await readPromise.catch(() => {});
window.fetch = realFetch;
// 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,Backup,Contents,Rebuild"
chk "step name matches its title" "$(g .titleMatchesStepName)" true
echo "the backup source form"
chk "every backend offered" "$(g '.kinds | join(",")')" "local,sftp,rest,s3,b2"
chk "fields carry icons" "$(g .fieldsHaveIcons)" true
chk "fields carry tooltips" "$(g .fieldsHaveTooltips)" true
chk "so does the password field" "$(g .passwordHasIcon)" true
chk "local shows only its own" "$(g .localShowsOnlyLocal)" true
chk "sftp fields are boxed together" "$(g .groupForSftp)" "SFTP server"
chk "b2 fields are boxed together" "$(g .groupForB2)" "Backblaze B2"
chk "local fields are boxed too" "$(g .groupForLocal)" "On this machine"
chk "no field overlaps the next" "$(g '.smallestFieldGap >= 8')" 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 "finding backups without a password"
chk "the scan found one" "$(g .scanFoundSomething)" true
chk "with a snapshot count" "$(g .foundCarrySnapshotCounts)" true
chk "the list sits under the field" "$(g .foundInsideTheGroup)" true
chk "so does the verdict" "$(g .verdictInsideTheGroup)" true
echo " one result is the answer, not a choice"
chk "it fills the path in" "$(g .singlePrefillsThePath)" true
chk "and shows its verdict" "$(g .singleShowsItsVerdict)" true
chk "without a duplicate card" "$(g .singleDrawsNoDuplicateCard)" true
chk "Check confirms, not scolds" "$(g .singleCheckDoesNotScold)" true
echo " several is a real choice, so ask"
chk "all are listed" "$(g .multipleAreListed)" 2
chk "as buttons" "$(g .multipleRenderedAsButtons)" true
chk "and nothing is guessed" "$(g .multipleLeaveThePathEmpty)" true
chk "picking one fills the path" "$(g .pickingOneFillsThePath)" true
chk "and reports before any password" "$(g .pickingOneReportsWithoutPassword)" true
echo " none found"
chk "says so rather than nothing" "$(g .noneSaysSo)" true
chk "placeholder is a real path" "$(g .placeholderIsReal)" true
chk "the parent-folder mistake is explained" "$(g .parentFolderExplained)" true
chk "and the real path is offered" "$(g .offersTheRealPath)" 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