LibrePortal/scripts/dev/lp-backup-setup-test
librelad 34512f7b28 setup: rebuild the wizard's Backups step around the locations that exist
The step asked one question — pick a destination, or "not now" — while the
system underneath already had a full location model: eight backend types, per
location engine, path mode, credentials and retention, and a generated
locations.json carrying all of it. None of that was reachable during setup, so a
second destination, or even seeing where the first one points, meant finding the
Backup page afterwards.

Now it mirrors the Storage step — the choice above, the list below:

  Backups        Automatic — daily, on a schedule  |  Manual
  Destinations   Local disk [default] /libreportal-backups/1   [Edit]
                 + Add destination

Automatic/Manual needed a setting, because there was no off switch:
crontabSetupBackupScheduler installed the entry unconditionally. CFG_BACKUP_MODE
is explicit rather than overloading "empty schedule", so it reads properly in
the config editor too, and Manual REMOVES an entry that is already installed
rather than merely declining to add one — otherwise answering Manual changes
nothing. The schedule itself is left alone, so switching back restores the time
the user picked.

Destinations are seeded from locations.json, so the default one is shown and
editable instead of being discovered later, and only entries the user actually
added or changed are submitted. A destination on the same disk as the app data
says so on the card rather than in a paragraph under the step.

Remote destinations are what the secret channel was for. The wizard payload is
base64'd into a task's command string and tasks are recorded world-readable, so
a password is POSTed to /api/setup/secret, which writes it where only the
manager can read it and returns an opaque reference; the reference travels in
the payload and setup_apply redeems it once, at the write. A reference that
cannot be redeemed leaves the password alone and says so, rather than blanking
it.

Verified in the browser on a clean install: the step renders both modes, lists
the existing destination at its resolved path, and the add dialog swaps between
local and remote fields. scripts/dev/lp-backup-setup-test covers the apply side,
including that what reaches the config is the secret and never the reference.

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

103 lines
5.2 KiB
Bash
Executable File

#!/bin/bash
# The wizard's Backups step, on the apply side.
#
# scripts/dev/lp-backup-setup-test
#
# Two things the step submits, and the reason each matters:
#
# backup_mode Automatic or Manual. Manual has to REMOVE a schedule
# that is already installed, not merely decline to add
# one, or answering "Manual" changes nothing.
# backup_locations Destinations. A password arrives as a REFERENCE, never a
# value: the whole wizard payload is base64'd into a task's
# command string, and tasks are recorded in
# frontend/data/tasks/*.json — 0644, in a world-readable
# directory. A backup repository password sent that way is
# readable by any local account.
#
# So the case that matters most is the last one: what reaches the config file is
# the secret, and what was in the payload never was.
REPO="$(cd "$(dirname "$0")/../.." && pwd)"
BASE="$(mktemp -d "${TMPDIR:-/tmp}/lp-bksetup-XXXXXX")"
trap 'rm -rf "$BASE"' EXIT
command -v jq >/dev/null 2>&1 || { echo " SKIP jq not installed"; exit 0; }
fail=0
chk(){ if [[ "$2" == "$3" ]]; then echo " ok $1"; else echo " FAIL $1: got '$2' want '$3'"; fail=1; fi; }
# Extract the block under test from setup_apply.sh so edits there are what break
# this, rather than a copy drifting out of date.
# From the mode comment to the line that hands off to the legacy single-
# destination path, which is where the new block ends.
BLOCK=$(awk '/^ # Automatic or Manual\./{f=1} f{print} /^ backup_dest=""$/{exit}' \
"$REPO/scripts/setup/setup_apply.sh")
BLOCK="$BLOCK
fi"
[[ -n "$BLOCK" ]] || { echo " FAIL could not extract the backup block from setup_apply.sh"; exit 1; }
run() { # run <payload-json>
local payload="$1"
cat > "$BASE/run.sh" <<EOS
payload='$payload'
configs_dir="$BASE/configs/"
mkdir -p "\$configs_dir/backup"
: > "\$configs_dir/backup/backup_general"
isSuccessful(){ echo "OK: \$*"; }; isNotice(){ echo "NOTE: \$*"; }; isError(){ echo "ERR: \$*"; }
crontabSetupBackupScheduler(){ echo "SCHED_CALLED"; }
locationAdd(){ echo "LOCADD:\$1:\$2" >> "$BASE/calls"; echo 7; }
# The real one returns an existing file; the block skips a destination whose
# config is missing, which is correct behaviour and not what is under test here.
backupLocationConfig(){ local f="$BASE/loc.\$1.config"; : > "\$f"; echo "\$f"; }
engineInitLocation(){ echo "INIT:\$1" >> "$BASE/calls"; return 0; }
webuiSecretResolve(){ echo "RESOLVE:\$1" >> "$BASE/calls"; printf 'the-actual-password'; }
updateConfigOption(){ echo "SET:\$1=\$2" >> "$BASE/calls"; }
backup_dest=""
# Parsed earlier in setup_apply, outside the extracted region.
backup_mode=\$(echo "\$payload" | jq -r '.backup_mode // ""')
$(printf '%s' "$BLOCK")
EOS
: > "$BASE/calls"
bash "$BASE/run.sh" 2>&1
}
echo "--- Manual is applied and the scheduler is re-run ---"
out=$(run '{"backup_mode":"manual"}')
chk "mode written" "$(grep -c 'SET:CFG_BACKUP_MODE=manual' "$BASE/calls")" "1"
chk "scheduler re-run" "$(grep -c 'SCHED_CALLED' <<< "$out")" "1"
echo "--- a new remote destination ---"
out=$(run '{"backup_mode":"automatic","backup_locations":[{"name":"Offsite","type":"sftp","ssh_host":"h.example.org","ssh_user":"lp","ssh_path":"/srv/lp","password_ref":"secret:deadbeefcafe"}]}')
chk "location created" "$(grep -c 'LOCADD:Offsite:sftp' "$BASE/calls")" "1"
chk "host set" "$(grep -c 'SET:CFG_BACKUP_LOC_7_SSH_HOST=h.example.org' "$BASE/calls")" "1"
chk "user set" "$(grep -c 'SET:CFG_BACKUP_LOC_7_SSH_USER=lp' "$BASE/calls")" "1"
chk "enabled" "$(grep -c 'SET:CFG_BACKUP_LOC_7_ENABLED=true' "$BASE/calls")" "1"
chk "repo initialised" "$(grep -c 'INIT:7' "$BASE/calls")" "1"
echo "--- the password: a reference in, the secret out ---"
chk "reference redeemed" "$(grep -c 'RESOLVE:secret:deadbeefcafe' "$BASE/calls")" "1"
chk "secret written" "$(grep -c 'SET:CFG_BACKUP_LOC_7_PASSWORD=the-actual-password' "$BASE/calls")" "1"
chk "reference never written as the value" \
"$(grep -c 'SET:CFG_BACKUP_LOC_7_PASSWORD=secret:' "$BASE/calls")" "0"
echo "--- editing the destination that already exists ---"
out=$(run '{"backup_locations":[{"idx":1,"name":"Local disk","type":"local","path":"/mnt/usb/lp"}]}')
chk "no new location" "$(grep -c 'LOCADD' "$BASE/calls")" "0"
chk "path set on 1" "$(grep -c 'SET:CFG_BACKUP_LOC_1_PATH=/mnt/usb/lp' "$BASE/calls")" "1"
chk "switched to custom" "$(grep -c 'SET:CFG_BACKUP_LOC_1_PATH_MODE=custom' "$BASE/calls")" "1"
echo "--- an unusable reference must not blank the password ---"
out=$(run '{"backup_locations":[{"idx":1,"type":"local","password_ref":"secret:gone"}]}' )
# webuiSecretResolve is stubbed to succeed, so re-stub it as failing for this case
cat > "$BASE/run2.sh" <<EOS
$(sed 's|^webuiSecretResolve().*|webuiSecretResolve(){ return 1; }|' "$BASE/run.sh")
EOS
: > "$BASE/calls"
out=$(bash "$BASE/run2.sh" 2>&1)
chk "password untouched" "$(grep -c 'SET:CFG_BACKUP_LOC_1_PASSWORD' "$BASE/calls")" "0"
chk "and it says so" "$(grep -c 'Could not read the password' <<< "$out")" "1"
echo ""
if (( fail )); then echo "FAILED"; exit 1; fi
echo "All backup-setup checks passed."