A registered drive that is unplugged rendered through the same path as any
other candidate — a "needs care" badge, "free of" with no numbers on either
side, an empty meter. To a first-time installer that reads as two broken disks
the scan turned up, with nothing tying the card back to a drive they registered
and later unplugged. Say "not connected", name the path, and draw no meter: a
meter with nothing in it is a claim about free space nobody measured. The same
locations are withheld from the dropdowns, since the wizard cannot stat a
directory on a drive that is absent.
Both dropdowns now end in "Custom path…", for a NAS mount or an LVM volume the
disk heuristics never rank as a candidate. Validation goes through
validateStep(3) rather than a disabled button: the apply side already refuses a
relative or system path, but its refusal is to fall back to the system disk,
and that is indistinguishable from having chosen the system disk on purpose.
A typed path is not a registered location, so setup_apply registers it via
storageAdd — which is what keeps the empty-directory admission rule and the
fitness checks in play — named after its basename, so it reads as "nas" rather
than "location-3" in the placement menus.
libreportal-storage: accept the name the listing prints. remove matched id and
path only, so `remove location-3` failed against a row displayed as
location-3. Root-owned helper changed, so footprint_version 10 -> 11.
Expose window.setupWizard: the instance was local to a promise in the
orchestrator and unreachable from the console or a test.
lp-storage-custom-test drives the step in a browser. Two holes it found in the
tests themselves, both the shape it exists to catch — a check whose failure
mode is to not run:
- It counted the cards that say "not connected" and asserted over those.
Turn the feature off and the count is zero, every() over an empty list is
true, and the block passed having checked nothing. The expectation now
comes from the feed.
- Both browser tests exited 0 whenever the page returned nothing. Under sudo,
where chromium will not start, they reported PASS having asserted nothing.
They now probe with `lp-shot --url` and curl: if the WebUI answers HTTP the
browser is the only thing that can have broken, and that is a failure.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
208 lines
9.8 KiB
Bash
Executable File
208 lines
9.8 KiB
Bash
Executable File
#!/bin/bash
|
|
# Drive the wizard's "Add a backup destination" dialog in a real browser.
|
|
#
|
|
# scripts/dev/lp-backup-dialog-test # needs a running WebUI
|
|
#
|
|
# Everything here is behind a click, which is why it needs driving rather than
|
|
# reading. The step shipped once with its add button carrying a class that does
|
|
# not exist (.setup-add-domain — the real one is .setup-domain-add), so it
|
|
# rendered as a bare browser button in the middle of a styled form, and nothing
|
|
# that inspected the DOM structurally noticed.
|
|
#
|
|
# The assertion that matters most is the last: a credential typed here must
|
|
# leave as a REFERENCE. The wizard payload is base64'd into a task's command
|
|
# string and tasks are recorded world-readable, so a secret travelling as itself
|
|
# would be readable by any local account.
|
|
#
|
|
# One page load, because a cold SPA boot is slow: the whole interaction runs in
|
|
# a single `lp-shot --eval` and reports one JSON blob for bash to assert on.
|
|
|
|
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 DUMMY = 'dummy-not-a-real-secret-0000';
|
|
const out = {};
|
|
const wait = ms => new Promise(r => setTimeout(r, ms));
|
|
const groupsShown = () => ['local','sftp','s3','b2']
|
|
.filter(g => {
|
|
const el = document.querySelector(`[data-bk-group="${g}"]`);
|
|
return el && el.style.display !== 'none';
|
|
});
|
|
|
|
const add = document.getElementById('sw-backup-add');
|
|
if (!add) return JSON.stringify({ error: 'add button missing' });
|
|
// "Is it styled" cannot be a fixed colour (themes) nor "is it transparent"
|
|
// (a native button is grey, not transparent). Compare it against a bare
|
|
// button dropped into the same parent: if nothing differs, no rule matched
|
|
// and the class in the markup is one the stylesheet never defines.
|
|
{
|
|
const bare = document.createElement('button');
|
|
bare.type = 'button';
|
|
add.parentElement.appendChild(bare);
|
|
const a = getComputedStyle(add), b = getComputedStyle(bare);
|
|
out.addButtonStyled = ['background-color','border-radius','color','padding']
|
|
.some(prop => a.getPropertyValue(prop) !== b.getPropertyValue(prop));
|
|
out.addButtonBg = a.backgroundColor;
|
|
bare.remove();
|
|
}
|
|
out.rowsBefore = document.querySelectorAll('[data-backup-edit]').length;
|
|
|
|
add.click();
|
|
await wait(800);
|
|
const type = document.getElementById('bk-type');
|
|
out.dialogOpen = !!type;
|
|
out.typeEnhanced = !!(type && type.closest('.custom-select'));
|
|
out.typeOptions = type ? [...type.options].map(o => o.value) : [];
|
|
out.labelsStyled = !!document.querySelector('.setup-field label');
|
|
out.groupsAtOpen = groupsShown();
|
|
|
|
// The popup must be REACHABLE, not merely present. custom-select portals it
|
|
// into <body> at z-index 1200 — above eo-modal's 1100 — but the wizard raises
|
|
// its modal to 10000, which put the popup behind the dialog that owns it. The
|
|
// control still reported as enhanced and simply did not respond, so only a
|
|
// hit test catches it: what is actually on top at the popup's own centre?
|
|
{
|
|
const btn = type.closest('.custom-select').querySelector('.custom-select-button');
|
|
btn.click();
|
|
await wait(400);
|
|
const popup = document.querySelector('.custom-select-popup');
|
|
out.popupOpens = !!popup;
|
|
if (popup) {
|
|
const r = popup.getBoundingClientRect();
|
|
const hit = document.elementFromPoint(r.left + r.width / 2, r.top + 12);
|
|
out.popupZ = parseInt(getComputedStyle(popup).zIndex, 10);
|
|
out.modalZ = parseInt(getComputedStyle(document.querySelector('.eo-modal')).zIndex, 10);
|
|
out.popupOnTop = !!(hit && hit.closest('.custom-select-popup'));
|
|
out.popupOptions = popup.querySelectorAll('.custom-select-option').length;
|
|
|
|
// And picking one has to take effect, the way a person would do it.
|
|
const opt = [...popup.querySelectorAll('.custom-select-option')]
|
|
.find(o => /SFTP/i.test(o.textContent));
|
|
if (opt) { opt.click(); await wait(400); }
|
|
out.pickedValue = type.value;
|
|
out.pickedLabel = btn.textContent.trim();
|
|
out.pickedGroups = groupsShown();
|
|
}
|
|
}
|
|
|
|
// Connect is offered but cannot be picked until the service exists. The
|
|
// option must be disabled AND say why — an option that is merely greyed out
|
|
// with no explanation reads as something broken.
|
|
{
|
|
const opt = [...type.options].find(o => o.value === 'connect');
|
|
out.connectPresent = !!opt;
|
|
out.connectDisabled = opt ? opt.disabled : null;
|
|
out.connectLabel = opt ? opt.text : null;
|
|
type.value = 'connect'; type.dispatchEvent(new Event('change'));
|
|
await wait(150);
|
|
const panel = document.querySelector('[data-bk-group="connect"]');
|
|
out.connectPanel = panel ? panel.innerText.replace(/\s+/g, ' ') : '';
|
|
out.connectHasToken = !!document.getElementById('bk-cn-token');
|
|
}
|
|
|
|
// Each type shows only its own fields.
|
|
out.swap = {};
|
|
for (const want of ['sftp','s3','b2','local']) {
|
|
type.value = want; type.dispatchEvent(new Event('change'));
|
|
await wait(120);
|
|
out.swap[want] = groupsShown();
|
|
}
|
|
|
|
// The SSH password appears only for password auth.
|
|
type.value = 'sftp'; type.dispatchEvent(new Event('change'));
|
|
await wait(150);
|
|
const auth = document.getElementById('bk-auth');
|
|
out.pwWithKey = document.querySelector('[data-bk-auth="password"]').style.display !== 'none';
|
|
auth.value = 'password'; auth.dispatchEvent(new Event('change'));
|
|
await wait(150);
|
|
out.pwWithPassword = document.querySelector('[data-bk-auth="password"]').style.display !== 'none';
|
|
out.pwInputType = (document.getElementById('bk-sshpass') || {}).type || null;
|
|
|
|
// Fill it in and submit.
|
|
document.getElementById('bk-name').value = 'Offsite';
|
|
document.getElementById('bk-host').value = 'backup.example.org';
|
|
document.getElementById('bk-user').value = 'lp';
|
|
document.getElementById('bk-rpath').value = '/srv/lp';
|
|
document.getElementById('bk-sshpass').value = DUMMY;
|
|
[...document.querySelectorAll('button')].find(b => b.textContent.trim() === 'Add').click();
|
|
await wait(2000);
|
|
|
|
out.rowsAfter = document.querySelectorAll('[data-backup-edit]').length;
|
|
out.listsOffsite = /Offsite/.test((document.getElementById('sw-backup-dests') || {}).innerText || '');
|
|
out.leaksInDom = document.body.innerHTML.includes(DUMMY);
|
|
return JSON.stringify(out);
|
|
JS
|
|
|
|
OUT=$("$SHOT" --eval "/?step=4" "$DRIVE" 2>/dev/null)
|
|
if [[ -z "$OUT" ]] || ! jq -e . >/dev/null 2>&1 <<< "$OUT"; then
|
|
if lp_reachable; then
|
|
echo " FAIL the WebUI is up but the step returned nothing (browser failed?)"; exit 1
|
|
fi
|
|
echo " SKIP no WebUI reachable"
|
|
exit 0
|
|
fi
|
|
if [[ "$(jq -r '.error // ""' <<< "$OUT")" != "" ]]; then
|
|
echo " FAIL $(jq -r .error <<< "$OUT")"; exit 1
|
|
fi
|
|
|
|
echo "--- the dialog opens, styled ---"
|
|
chk "dialog open" "$(jq -r .dialogOpen <<< "$OUT")" "true"
|
|
chk "type enhanced" "$(jq -r .typeEnhanced <<< "$OUT")" "true"
|
|
chk "every backend offered" "$(jq -r '.typeOptions | join(",")' <<< "$OUT")" "local,sftp,s3,b2,connect"
|
|
chk "labels styled" "$(jq -r .labelsStyled <<< "$OUT")" "true"
|
|
chk "opens on local" "$(jq -r '.groupsAtOpen | join(",")' <<< "$OUT")" "local"
|
|
chk "add button is styled" "$(jq -r .addButtonStyled <<< "$OUT")" "true"
|
|
|
|
echo "--- the dropdown is reachable, not just enhanced ---"
|
|
chk "popup opens" "$(jq -r .popupOpens <<< "$OUT")" "true"
|
|
chk "five options" "$(jq -r .popupOptions <<< "$OUT")" "5"
|
|
chk "popup above modal" "$(jq -r 'if .popupZ > .modalZ then "true" else "false" end' <<< "$OUT")" "true"
|
|
chk "popup is on top" "$(jq -r .popupOnTop <<< "$OUT")" "true"
|
|
chk "picking applies" "$(jq -r .pickedValue <<< "$OUT")" "sftp"
|
|
chk "button label" "$(jq -r .pickedLabel <<< "$OUT")" "SFTP"
|
|
chk "fields followed" "$(jq -r '.pickedGroups | join(",")' <<< "$OUT")" "sftp"
|
|
|
|
echo "--- Connect is offered, and honest about not being ready ---"
|
|
chk "offered" "$(jq -r .connectPresent <<< "$OUT")" "true"
|
|
chk "cannot be picked" "$(jq -r .connectDisabled <<< "$OUT")" "true"
|
|
chk "label says so" "$(jq -r '.connectLabel | test("not available") ' <<< "$OUT")" "true"
|
|
chk "no credential field" "$(jq -r .connectHasToken <<< "$OUT")" "false"
|
|
chk "points at the free equivalent" \
|
|
"$(jq -r '.connectPanel | test("SFTP and S3")' <<< "$OUT")" "true"
|
|
|
|
echo "--- each type shows only its own fields ---"
|
|
# connect is deliberately absent: its option is disabled while the service does
|
|
# not exist, and a disabled option cannot be selected — which is the behaviour we
|
|
# want, and is asserted directly above rather than through this loop.
|
|
for ty in local sftp s3 b2; do
|
|
chk "$ty" "$(jq -r --arg t "$ty" '.swap[$t] | join(",")' <<< "$OUT")" "$ty"
|
|
done
|
|
|
|
echo "--- the SSH password follows the auth choice ---"
|
|
chk "hidden for key auth" "$(jq -r .pwWithKey <<< "$OUT")" "false"
|
|
chk "shown for password auth" "$(jq -r .pwWithPassword <<< "$OUT")" "true"
|
|
chk "masked" "$(jq -r .pwInputType <<< "$OUT")" "password"
|
|
|
|
echo "--- submitting adds it, and the credential does not stay behind ---"
|
|
chk "a row was added" "$(jq -r '(.rowsAfter - .rowsBefore)' <<< "$OUT")" "1"
|
|
chk "listed by name" "$(jq -r .listsOffsite <<< "$OUT")" "true"
|
|
chk "not left in the DOM" "$(jq -r .leaksInDom <<< "$OUT")" "false"
|
|
|
|
echo ""
|
|
if (( fail )); then echo "FAILED"; exit 1; fi
|
|
echo "All backup-dialog checks passed."
|