Two things about the restore source step were wrong.
The progress bar said "Source" while the heading said "Where is your backup?",
which reads as two different steps. Every other step's section title is its step
name — Storage, Backups, Import — so this one is "Backup" in both places, with
the friendly question moved to the hint where the rest of the wizard puts it.
Contents and Rebuild got the same treatment.
And the fields used the Storage step's label-left rows. That layout suits a
column of dropdowns; a form of typed values in the middle of a wizard that
looks nothing like the rest of it just reads as unfinished. They now use the
same shape as Identity: a label with a tooltip, then an icon beside the input.
Every field has both, including the password.
On automating the relocate: it cannot be a WebUI action, and the reason is the
thing the privilege model rests on. Root helpers have their paths baked at
install so the manager cannot redirect a privileged operation by editing
something it owns; relocating re-bakes those paths, so a helper that did it
from a caller-supplied path would hand the manager the whole trust boundary.
Narrowing to "registry targets only" does not help either — the manager can add
to that registry by design. libreportal-relocate says this at the top and is
deliberately outside the manager's sudoers.
What was fixable is the part that actually annoyed — being handed a command
with no idea whether it worked:
- Copy button, with a fallback that selects the text and names the keys,
because clipboard access needs a secure context and a LAN install on
http:// is not one.
- The pending move persists to localStorage, not session state: it happens in
a terminal minutes or days later, after the tab is gone.
- A watcher on every page shows the outstanding command, polls for the move
landing, and reloads to the homepage — whatever route you were on belonged
to the old install.
"Landed" means the host REPORTS its system dir as the target, which is why
system_dir is now in storage.json. Deliberately not "the server restarted": an
ordinary container restart is indistinguishable, and announcing a relocation
that never happened is worse than saying nothing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
264 lines
13 KiB
Bash
Executable File
264 lines
13 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;
|
|
}
|
|
// --- the relocate hand-off ---
|
|
// Moving LibrePortal's own tree cannot be a WebUI action: it re-bakes the
|
|
// paths inside every root helper, and those are baked at install so the
|
|
// manager cannot redirect a privileged operation. So the wizard prints a
|
|
// command — and the watcher makes that bearable by noticing the move
|
|
// landing and following it.
|
|
//
|
|
// "Landed" must mean the host REPORTS the new system dir, never "the server
|
|
// restarted": an ordinary container restart looks identical, and announcing
|
|
// a relocation that never happened is worse than saying nothing.
|
|
const W = window.RelocateWatcher;
|
|
out.watcherExists = !!W;
|
|
if (W) {
|
|
out.notLandedForAnUnrelatedPath =
|
|
await new W({ target: '/mnt/__nope/libreportal-system', cmd: 'x' }).landed();
|
|
const feed = await (await fetch('/data/system/storage.json', { cache: 'no-store' })).json();
|
|
out.feedReportsSystemDir = !!feed.system_dir;
|
|
out.landsWhenFeedMatches =
|
|
await new W({ target: feed.system_dir, cmd: 'x' }).landed();
|
|
out.trailingSlashTolerated =
|
|
await new W({ target: feed.system_dir + '/', cmd: 'x' }).landed();
|
|
|
|
localStorage.removeItem('lp.pendingRelocate');
|
|
W.start();
|
|
out.silentWhenNothingPending = !document.querySelector('.lp-relocate-banner');
|
|
|
|
localStorage.setItem('lp.pendingRelocate', JSON.stringify({
|
|
target: '/mnt/__nope/libreportal-system',
|
|
cmd: 'sudo libreportal-relocate --system-dir=/mnt/__nope/libreportal-system' }));
|
|
W.start();
|
|
await new Promise(r => setTimeout(r, 300));
|
|
const b = document.querySelector('.lp-relocate-banner');
|
|
out.bannerCarriesTheCommand = !!b && b.textContent.includes('libreportal-relocate --system-dir=/mnt/__nope');
|
|
out.bannerOffersCopy = !!b && !!b.querySelector('[data-act="copy"]');
|
|
if (b) b.remove();
|
|
|
|
// Already done before this page loaded: clear, do not nag.
|
|
localStorage.setItem('lp.pendingRelocate', JSON.stringify({ target: feed.system_dir, cmd: 'x' }));
|
|
W.start();
|
|
await new Promise(r => setTimeout(r, 300));
|
|
out.clearsWhenAlreadyLanded =
|
|
!document.querySelector('.lp-relocate-banner') && !localStorage.getItem('lp.pendingRelocate');
|
|
localStorage.removeItem('lp.pendingRelocate');
|
|
}
|
|
|
|
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
|
|
|
|
echo "the relocate hand-off"
|
|
chk "the watcher is loaded" "$(g .watcherExists)" true
|
|
chk "the feed reports the system dir" "$(g .feedReportsSystemDir)" true
|
|
chk "an unrelated path has not landed" "$(g .notLandedForAnUnrelatedPath)" false
|
|
chk "landed when the feed matches" "$(g .landsWhenFeedMatches)" true
|
|
chk "a trailing slash is tolerated" "$(g .trailingSlashTolerated)" true
|
|
chk "silent when nothing is pending" "$(g .silentWhenNothingPending)" true
|
|
chk "the banner carries the command" "$(g .bannerCarriesTheCommand)" true
|
|
chk "and offers to copy it" "$(g .bannerOffersCopy)" true
|
|
chk "clears when already landed" "$(g .clearsWhenAlreadyLanded)" true
|
|
|
|
[[ $fail -eq 0 ]] && echo "storage step test: OK"
|
|
exit $fail
|