#!/bin/bash set -uo pipefail HERE="$(cd "$(dirname "$0")" && pwd)" RESTORE="$HERE/../restore.sh" FAILURES=0 assert_eq() { local expected="$1" actual="$2" msg="$3" if [[ "$expected" != "$actual" ]]; then echo "FAIL: $msg (expected '$expected', got '$actual')" FAILURES=$((FAILURES + 1)) else echo "PASS: $msg" fi } assert_contains() { local haystack="$1" needle="$2" msg="$3" if [[ "$haystack" != *"$needle"* ]]; then echo "FAIL: $msg (expected to contain '$needle', got '$haystack')" FAILURES=$((FAILURES + 1)) else echo "PASS: $msg" fi } test_help_exits_zero() { local out rc out="$(bash "$RESTORE" -h)" rc=$? assert_eq "0" "$rc" "help exits 0" assert_contains "$out" "Usage:" "help prints usage" } test_no_args_exits_one() { local rc bash "$RESTORE" >/dev/null 2>&1 rc=$? assert_eq "1" "$rc" "no args exits 1" } test_unknown_command_exits_one() { local rc bash "$RESTORE" bogus >/dev/null 2>&1 rc=$? assert_eq "1" "$rc" "unknown command exits 1" } source "$HERE/lib/setup_mocks.sh" # Every test below isolates two things beyond PATH: BASH_ENV forces borg/ # docker/mysql/mariadb to the mock regardless of what's really installed on # this machine's PATH (restore.sh's own hardened PATH would otherwise shadow # the mock with any real binary in a system dir), and LOCKFILE/ # BORG_PASSPHRASE_FILE point at throwaway files so acquire_lock()/preflight() # never touch real system paths like /var/lock or /root. test_list_archives() { local mockdir out mockdir="$(mktemp -d)" setup_mock_bin "$mockdir" out="$(PATH="$mockdir:$PATH" BASH_ENV="$(mock_bash_env "$mockdir")" bash "$RESTORE" --list-archives)" assert_contains "$out" "host-2026-01-01T00-00-00" "list-archives shows first archive" assert_contains "$out" "host-2026-06-01T00-00-00" "list-archives shows latest archive" rm -rf "$mockdir" } test_file_restore_extracts_to_dest() { local mockdir dest out final passfile lockfile mockdir="$(mktemp -d)" dest="$(mktemp -d)" setup_mock_bin "$mockdir" passfile="$mockdir/passphrase" lockfile="$mockdir/lock" echo "s3cr3t" > "$passfile" out="$(PATH="$mockdir:$PATH" BASH_ENV="$(mock_bash_env "$mockdir")" \ BORG_PASSPHRASE_FILE="$passfile" LOCKFILE="$lockfile" \ bash "$RESTORE" file photos/img.jpg --dest "$dest")" final="$dest/home/srv/files/content/photos/img.jpg" assert_contains "$out" "Restored file available at: $final" "file mode reports final path" if [[ -f "$final" ]]; then echo "PASS: extracted file exists on disk" else echo "FAIL: extracted file missing at $final" FAILURES=$((FAILURES + 1)) fi rm -rf "$mockdir" "$dest" } test_file_restore_dry_run_makes_no_borg_call() { local mockdir dest mockdir="$(mktemp -d)" dest="$(mktemp -d)" setup_mock_bin "$mockdir" PATH="$mockdir:$PATH" BASH_ENV="$(mock_bash_env "$mockdir")" LOCKFILE="$mockdir/lock" \ bash "$RESTORE" file photos/img.jpg --dest "$dest" --dry-run >/dev/null if [[ -s "$mockdir/mock.log" ]] && grep -q "^borg extract" "$mockdir/mock.log"; then echo "FAIL: dry-run invoked borg extract" FAILURES=$((FAILURES + 1)) else echo "PASS: dry-run made no borg extract call" fi rm -rf "$mockdir" "$dest" } test_db_restore_with_yes_runs_full_sequence() { local mockdir out passfile mockdir="$(mktemp -d)" setup_mock_bin "$mockdir" echo "rootpass" > "$mockdir/rootpw" passfile="$mockdir/passphrase" echo "s3cr3t" > "$passfile" out="$(PATH="$mockdir:$PATH" BASH_ENV="$(mock_bash_env "$mockdir")" \ ROOT_PASSWORD_FILE="$mockdir/rootpw" BORG_PASSPHRASE_FILE="$passfile" \ LOCKFILE="$mockdir/lock" \ bash "$RESTORE" db shopdb --yes /dev/null 2>&1 rc=$? set -e assert_eq "1" "$rc" "db mode aborts on mismatched confirmation" rm -rf "$mockdir" } test_full_restore_refuses_nonempty_target_without_force() { local mockdir target out rc mockdir="$(mktemp -d)" target="$(mktemp -d)" touch "$target/existing-file" setup_mock_bin "$mockdir" set +e out="$(PATH="$mockdir:$PATH" BASH_ENV="$(mock_bash_env "$mockdir")" LOCKFILE="$mockdir/lock" bash -c ' sed "s#^TARGET=\"/home/srv/files/content\"#TARGET=\"'"$target"'\"#; s@^ARCHIVE_TARGET_PATH=.*@ARCHIVE_TARGET_PATH=\"\${TARGET#/}\"@" "'"$RESTORE"'" > "'"$mockdir"'/restore_patched.sh" bash "'"$mockdir"'/restore_patched.sh" full ' 2>&1)" rc=$? set -e assert_eq "1" "$rc" "full mode refuses non-empty target without --force" assert_contains "$out" "is not empty - pass --force" "refusal message names the reason" rm -rf "$mockdir" "$target" } test_full_restore_dry_run_makes_no_calls() { local mockdir target out mockdir="$(mktemp -d)" target="$(mktemp -d)" setup_mock_bin "$mockdir" out="$(PATH="$mockdir:$PATH" BASH_ENV="$(mock_bash_env "$mockdir")" LOCKFILE="$mockdir/lock" bash -c ' sed "s#^TARGET=\"/home/srv/files/content\"#TARGET=\"'"$target"'\"#; s@^ARCHIVE_TARGET_PATH=.*@ARCHIVE_TARGET_PATH=\"\${TARGET#/}\"@" "'"$RESTORE"'" > "'"$mockdir"'/restore_patched.sh" bash "'"$mockdir"'/restore_patched.sh" full --dry-run ')" assert_contains "$out" "DRY-RUN" "full dry-run prints DRY-RUN plan" # cmd_full calls resolve_archive() (a borg list call) before checking # DRY_RUN, same as cmd_db/cmd_file, so mock.log legitimately gets a # "borg list" entry. What must NOT happen in dry-run is a borg extract, # or any docker/db-client interaction. if grep -qE "^(borg extract|docker|mysql|mariadb)" "$mockdir/mock.log" 2>/dev/null; then echo "FAIL: full dry-run invoked borg extract or a docker/db-client mock binary" FAILURES=$((FAILURES + 1)) else echo "PASS: full dry-run made no borg extract or docker/db-client calls" fi rm -rf "$mockdir" "$target" } test_help_exits_zero test_no_args_exits_one test_unknown_command_exits_one test_list_archives test_file_restore_extracts_to_dest test_file_restore_dry_run_makes_no_borg_call test_db_restore_with_yes_runs_full_sequence test_db_restore_dry_run_skips_confirmation_and_calls test_db_restore_aborts_on_wrong_confirmation test_full_restore_refuses_nonempty_target_without_force test_full_restore_dry_run_makes_no_calls echo "-----" if [[ "$FAILURES" -gt 0 ]]; then echo "$FAILURES failure(s)" exit 1 fi echo "All tests passed"