#!/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'); const card = $('.setup-found-backup'); out.foundRenderedAsButton = !!card && card.tagName === 'BUTTON'; if (card) { card.click(); await new Promise(r => setTimeout(r, 200)); out.clickFillsThePath = ($('#sw-rs-path') || {}).value === (w.foundBackups[0] || {}).path; out.clickReportsWithoutPassword = /snapshot/i.test(($('#sw-rs-verify-result') || {}).textContent || ''); } // The placeholder must be a path from THIS machine, never an invented // example: /mnt/usb/... sends someone looking for a folder that is not there. 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 "rendered as a button" "$(g .foundRenderedAsButton)" true chk "clicking fills the path" "$(g .clickFillsThePath)" true chk "and reports before any password" "$(g .clickReportsWithoutPassword)" 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