#!/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 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."