LibrePortal/scripts/dev/lp-storage-step-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

208 lines
10 KiB
Bash
Executable File

#!/bin/bash
# Drive the wizard's Storage step in a real browser.
#
# scripts/dev/lp-storage-step-test # needs a running WebUI
#
# Three things this guards.
#
# A location whose drive is absent used to render as a warn card — "needs care",
# "free of" with no numbers, an empty meter. A new user has no way to read that
# as "a drive you registered is unplugged", so it looked like the scan had found
# two broken disks. It must say it is not connected, name the path, draw no
# meter, and not be offered as somewhere to put data — the wizard cannot stat a
# directory on a drive that is not there.
#
# The drive dropdown must offer drives and NOTHING else. It shipped once with a
# "Custom path…" entry in the same list, which made typing a directory look like
# one of the normal answers when most people just want to pick a disk.
#
# Exact paths are advanced-only, and an edited one must not reach the payload
# half-typed. The apply side refuses a relative or system path by falling back
# to the system disk — indistinguishable from having chosen the system disk on
# purpose, which is the failure shape this project keeps having to fix. So
# validateStep blocks it at the step instead.
#
# One page load: the whole interaction runs in a single `lp-shot --eval`.
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; }
# A skip must not be able to stand in for a pass. If the WebUI answers HTTP
# then the browser is the only thing that can have failed, and that is a
# failure — skipping there is how this test reported success under sudo, where
# chromium refuses to start at all.
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 }));
// Looked up by name, never hardcoded. Inserting the Start step moved Storage
// from 3 to 4, and a test pinned to the old number silently began validating
// the Domains step instead — reporting that nothing blocked, which is
// indistinguishable from validation being broken.
const STORAGE = w.stepNames.indexOf('Storage');
if (STORAGE < 0) return JSON.stringify({ error: 'no Storage step' });
const $ = s => document.querySelector(s);
// Re-queried after every renderStorage(), which rebuilds the selects — a
// reference held across a re-render points at a detached node, and setting
// .value on one of those succeeds silently while changing nothing.
const sel = () => ({ sys: $('#sw-storage-system'), app: $('#sw-storage-apps') });
let { sys: sysSel, app: appSel } = sel();
const paths = $('#sw-storage-paths');
if (!sysSel || !appSel || !paths) return JSON.stringify({ error: 'storage step not rendered' });
// --- the dropdown offers drives, and only drives ---
const labels = Array.from(appSel.options).map(o => o.textContent.trim());
out.optionCount = labels.length;
out.offersCustomPath = labels.some(l => /custom path/i.test(l));
out.offersPrimary = labels.some(l => /system disk/i.test(l));
// Every non-primary option is a path, i.e. a drive, not an instruction.
out.allOptionsArePaths = Array.from(appSel.options)
.filter(o => o.value !== 'primary').every(o => o.value.startsWith('/'));
// --- unmounted locations ---
// Injected rather than waited for: whether a drive happens to be mounted is
// ambient state, and an assertion that only runs on some machines is not a
// guard. Counting the cards that say "not connected" would be worse still —
// turn the feature off and the count is zero, every() over an empty list is
// true, and the whole block passes having checked nothing.
const FAKE = '/mnt/__lp_test_offline';
w.storageCandidates = w.storageCandidates.concat([{
id: '99', name: 'unplugged-disk', path: FAKE, state: 'unmounted',
verdict: 'warn', size: '', free: '', apps: '', registered: true }]);
w.renderStorage();
({ sys: sysSel, app: appSel } = sel());
const card = Array.from(document.querySelectorAll('.setup-app-card, .setup-storage-card'))
.find(c => c.textContent.includes(FAKE));
out.offlineHasCard = !!card;
const txt = card ? card.textContent.replace(/\s+/g, ' ') : '';
out.offlineSaysNotConnected = /not connected/i.test(txt);
out.offlineSaysNotMounted = /not mounted right now/i.test(txt);
out.offlineSaysNeedsCare = /needs care/i.test(txt);
out.offlineHasNoMeter = !!card && !card.querySelector('.setup-storage-meter');
out.offlineOffered = Array.from($('#sw-storage-apps').options).some(o => o.value === FAKE);
// --- exact paths: hidden for a beginner ---
w.installLevel = 'beginner'; w.renderStoragePaths(false);
out.pathsHiddenForBeginner = paths.style.display === 'none';
out.beginnerStoresDrive = w.storageDefault;
w.installLevel = 'advanced'; w.renderStoragePaths(false);
out.pathsShownForAdvanced = paths.style.display !== 'none';
// On the primary drive there is nothing to relocate, so no LibrePortal row.
out.systemRowOnPrimary = !!$('#sw-path-system');
out.appsRowAlways = !!$('#sw-path-apps');
// --- picking a drive fills the path in from that drive ---
const disk = Array.from(appSel.options).map(o => o.value)
.find(v => v !== 'primary');
if (!disk) return JSON.stringify(Object.assign(out, { noSecondDrive: true }));
appSel.value = disk; fire(appSel, 'change');
out.pathFollowsDrive = $('#sw-path-apps').value;
out.pathFollowsDriveWant = disk;
sysSel.value = disk; fire(sysSel, 'change');
out.systemRowAppears = !!$('#sw-path-system');
out.systemPathDefault = $('#sw-path-system') ? $('#sw-path-system').value : '';
out.systemPathWant = disk + '/libreportal-system';
// The system directory is not an app-data location and must never be
// registered as one — collectStorage takes the drive, not the subdirectory.
out.registersDriveNotSystemDir = (w.collectStorage() || []).includes(out.systemPathDefault);
// --- editing a path ---
const inp = $('#sw-path-apps'), err = $('#sw-path-apps-err');
out.untouchedDefaultBlocks = !!w.validateStep(STORAGE);
const probe = (v) => { inp.value = v; fire(inp, 'input');
return { blocks: !!w.validateStep(STORAGE),
marked: inp.classList.contains('is-invalid'),
said: (err && err.textContent) || '' }; };
out.relative = probe('mnt/nas');
out.systemDir = probe('/etc/libreportal');
out.root = probe('/');
out.spaces = probe('/mnt/my disk');
out.good = probe(disk + '/myapps');
out.goodStored = w.storageDefault;
out.goodRegistered = (w.collectStorage() || []).includes(disk + '/myapps');
// Changing drive must not leave a path belonging to the old one behind.
const other = Array.from(appSel.options).map(o => o.value)
.find(v => v !== 'primary' && v !== disk);
if (other) {
appSel.value = other; fire(appSel, 'change');
out.staleAfterDriveChange = $('#sw-path-apps').value;
out.staleWant = other;
}
return JSON.stringify(out);
JS
J=$("$SHOT" --eval "/?step=3" "$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 drive dropdown"
chk "offers the system disk" "$(g .offersPrimary)" true
chk "offers no 'Custom path' entry" "$(g .offersCustomPath)" false
chk "every other entry is a drive" "$(g .allOptionsArePaths)" true
echo "an unmounted location"
chk "still gets a card" "$(g .offlineHasCard)" true
chk "badged 'not connected'" "$(g .offlineSaysNotConnected)" true
chk "says it is not mounted" "$(g .offlineSaysNotMounted)" true
chk "does not say 'needs care'" "$(g .offlineSaysNeedsCare)" false
chk "draws no free-space meter" "$(g .offlineHasNoMeter)" true
chk "is not offered as a target" "$(g .offlineOffered)" false
echo "exact paths"
chk "hidden for a beginner" "$(g .pathsHiddenForBeginner)" true
chk "beginner keeps the drive" "$(g .beginnerStoresDrive)" primary
chk "shown for advanced" "$(g .pathsShownForAdvanced)" true
chk "no LibrePortal row on primary" "$(g .systemRowOnPrimary)" false
chk "New apps row always" "$(g .appsRowAlways)" true
if [[ "$(g '.noSecondDrive // false')" == "true" ]]; then
echo " SKIP only one drive registered — nothing to switch to"
else
chk "path follows the chosen drive" "$(g .pathFollowsDrive)" "$(g .pathFollowsDriveWant)"
chk "LibrePortal row appears" "$(g .systemRowAppears)" true
chk "and defaults under that drive" "$(g .systemPathDefault)" "$(g .systemPathWant)"
chk "system dir is not registered" "$(g .registersDriveNotSystemDir)" false
echo "editing a path"
chk "an untouched default passes" "$(g .untouchedDefaultBlocks)" false
chk "relative path blocks" "$(g .relative.blocks)" true
chk "relative path is marked" "$(g .relative.marked)" true
chk "relative path says why" "$(g '.relative.said != ""')" true
chk "system dir blocks" "$(g .systemDir.blocks)" true
chk "/ blocks" "$(g .root.blocks)" true
chk "path with spaces blocks" "$(g .spaces.blocks)" true
chk "an absolute path passes" "$(g .good.blocks)" false
chk "and is not marked" "$(g .good.marked)" false
chk "and its error is cleared" "$(g '.good.said == ""')" true
chk "and it is what gets stored" "$(g .goodStored)" "$(g .pathFollowsDriveWant)/myapps"
chk "and what gets registered" "$(g .goodRegistered)" true
if [[ "$(g '.staleWant // empty')" != "" ]]; then
chk "changing drive drops the old path" "$(g .staleAfterDriveChange)" "$(g .staleWant)"
fi
fi
[[ $fail -eq 0 ]] && echo "storage step test: OK"
exit $fail