LibrePortal/scripts/dev/lp-backup-dialog-test
librelad 00114a6ce2 setup: fix the Backups dialog, and make dialogs testable at all
Reported after looking at the step: the add button unstyled, the dialog missing
the fields a backup location actually has, and its dropdowns not working. Three
real faults, and one reason all three shipped.

  * "+ Add destination" carried class .setup-add-domain, which I invented. The
    real one is .setup-domain-add, so no rule matched and it rendered as a bare
    browser button in the middle of a styled form.
  * The dialog asked for name / type / host / user / path / password. A backup
    location has SSH port and auth method (key or password — key is the default
    and needs nothing typed), S3 access and secret keys, B2 account id and key,
    and a path mode. It now asks for what each backend needs, with the wording
    taken from the location config so the wizard and the Backup page describe
    the same thing the same way.
  * .setup-field styled input[type=text] and [type=email] but not [type=password]
    or [type=number], so a credential field and the SSH port rendered unstyled
    even inside a correct container.

Only the credentials go through the secret channel — SSH password, S3 secret
key, B2 account key. The rest is ordinary configuration and travels as itself.

The reason all three shipped is that I checked the step by querying the DOM and
never looked at it. Structural checks cannot see an unstyled control, and a
dialog is behind a click so a screenshot cannot reach it either. So:

  lp-shot --eval <route> <js>   run JS in the page and print the result
  LP_SHOT_EVAL=<js>             run JS before a capture — open a dialog, then shoot

and scripts/dev/lp-backup-dialog-test drives the whole thing in a real browser:
opens it, swaps every backend and asserts only that backend's fields show,
toggles SSH auth and asserts the password field follows, submits, and asserts
the credential is not left in the DOM.

Its styling check needed two attempts, which is the point of mutation-testing
it: "is the background transparent" passes for an unstyled button, because a
native button is grey rather than transparent. It now compares the control
against a bare <button> in the same parent, so "no rule matched" is what fails.
Verified: reintroducing the wrong class fails the test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 10:48:39 +01:00

131 lines
5.9 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; }
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();
// 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
echo " SKIP no WebUI reachable, or the step did not load"
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 "all four backends" "$(jq -r '.typeOptions | join(",")' <<< "$OUT")" "local,sftp,s3,b2"
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 "--- each type shows only its own fields ---"
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."