diff options
| author | Craig Jennings <c@cjennings.net> | 2026-06-25 01:07:31 -0400 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-06-25 01:07:31 -0400 |
| commit | 03a9ff353721ba8fb5f37cad2546d25788011451 (patch) | |
| tree | 9c98fb4fb20d9c11b894e30924f37d447c45d9c2 /tests/cases | |
| parent | 2e895830798d1685c35f24e595896b5caa4fdbcc (diff) | |
| download | rsyncshot-03a9ff353721ba8fb5f37cad2546d25788011451.tar.gz rsyncshot-03a9ff353721ba8fb5f37cad2546d25788011451.zip | |
fix: harden rsyncshot destination, rotation, and mount handling
Refuse to run when REMOTE_PATH or MOUNTDIR is empty or non-absolute. A blank config value otherwise resolves the destination to the filesystem root, where rotation runs rm -rf and --delete.
Run the rotation cp/mv/rm as bare command names in remote mode, resolved by the remote PATH, instead of hardcoded /usr/bin paths that broke on remotes whose binaries live elsewhere. Resolve the real binary via command -v in local mode.
Pass rsync -R so each source is stored under its full path. Two includes sharing a basename (/usr/local/bin and /usr/bin) previously both mapped to latest/bin, and the second sync's --delete wiped the first. This changes the stored layout: /usr/local/bin now lives at latest/usr/local/bin instead of latest/bin. /home and /etc are unchanged.
Add rsync --numeric-ids so /etc and /home ownership survives a restore when the destination has a different passwd/group database.
Match mount points exactly via is_mounted() instead of a substring grep of /proc/mounts, so /media/backup no longer matches /media/backup2, paths compare literally, and mount points with spaces work.
Reject a retention count below 1, which previously still created one snapshot. Drop the grep "|| echo 0" that emitted a stray second line, assemble ssh options as arrays, and quote the RSYNC_RSH identity path.
Extract derive_paths() and is_mounted() as sourceable functions and add unit, rsync-flag, and gated remote-mode test suites. The suite now runs 36 tests, and shellcheck is clean across the script and tests.
Diffstat (limited to 'tests/cases')
| -rwxr-xr-x | tests/cases/test_backup.sh | 73 | ||||
| -rwxr-xr-x | tests/cases/test_cron.sh | 8 | ||||
| -rw-r--r-- | tests/cases/test_functions.sh | 146 | ||||
| -rw-r--r-- | tests/cases/test_remote.sh | 60 | ||||
| -rw-r--r-- | tests/cases/test_rsync_flags.sh | 53 | ||||
| -rwxr-xr-x | tests/cases/test_validation.sh | 56 |
6 files changed, 371 insertions, 25 deletions
diff --git a/tests/cases/test_backup.sh b/tests/cases/test_backup.sh index 1ab4fbd..af01221 100755 --- a/tests/cases/test_backup.sh +++ b/tests/cases/test_backup.sh @@ -15,9 +15,7 @@ test_creates_backup_structure() { create_test_includes create_test_excludes - local output - output=$(sudo INSTALLHOME="$TEST_CONFIG_DIR" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" manual 1 2>&1) - local exit_code=$? + sudo INSTALLHOME="$TEST_CONFIG_DIR" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" manual 1 >/dev/null 2>&1 # Check directory structure assert_dir_exists "$TEST_BACKUP_DIR/$HOSTNAME" "should create hostname dir" || { @@ -46,15 +44,14 @@ test_copies_files() { create_test_includes create_test_excludes - local output - output=$(sudo INSTALLHOME="$TEST_CONFIG_DIR" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" manual 1 2>&1) + sudo INSTALLHOME="$TEST_CONFIG_DIR" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" manual 1 >/dev/null 2>&1 # Check files were copied - assert_file_exists "$TEST_BACKUP_DIR/$HOSTNAME/latest/home/testuser/file1.txt" "should copy file1.txt" || { + assert_file_exists "$TEST_BACKUP_DIR/$HOSTNAME/latest$TEST_SOURCE_DIR/home/testuser/file1.txt" "should copy file1.txt" || { teardown_test_env return 1 } - assert_file_exists "$TEST_BACKUP_DIR/$HOSTNAME/latest/etc/test.conf" "should copy test.conf" || { + assert_file_exists "$TEST_BACKUP_DIR/$HOSTNAME/latest$TEST_SOURCE_DIR/etc/test.conf" "should copy test.conf" || { teardown_test_env return 1 } @@ -72,8 +69,7 @@ test_snapshot_readonly() { create_test_includes create_test_excludes - local output - output=$(sudo INSTALLHOME="$TEST_CONFIG_DIR" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" manual 1 2>&1) + sudo INSTALLHOME="$TEST_CONFIG_DIR" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" manual 1 >/dev/null 2>&1 # Check snapshot directory has no write permission # Note: We use stat to check actual permissions because -w always returns true for root @@ -100,8 +96,8 @@ test_rotation() { create_test_excludes # Run backup twice - sudo INSTALLHOME="$TEST_CONFIG_DIR" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" manual 3 2>&1 >/dev/null - sudo INSTALLHOME="$TEST_CONFIG_DIR" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" manual 3 2>&1 >/dev/null + sudo INSTALLHOME="$TEST_CONFIG_DIR" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" manual 3 >/dev/null 2>&1 + sudo INSTALLHOME="$TEST_CONFIG_DIR" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" manual 3 >/dev/null 2>&1 # Should have MANUAL.0 and MANUAL.1 assert_dir_exists "$TEST_BACKUP_DIR/$HOSTNAME/MANUAL.0" "should have MANUAL.0" || { @@ -127,8 +123,8 @@ test_retention_limit() { create_test_excludes # Run backup 4 times with retention of 3 - for i in 1 2 3 4; do - sudo INSTALLHOME="$TEST_CONFIG_DIR" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" manual 3 2>&1 >/dev/null + for _ in 1 2 3 4; do + sudo INSTALLHOME="$TEST_CONFIG_DIR" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" manual 3 >/dev/null 2>&1 done # Should have MANUAL.0, MANUAL.1, MANUAL.2 but NOT MANUAL.3 @@ -162,9 +158,7 @@ test_backup_command() { create_test_includes create_test_excludes - local output - output=$(sudo INSTALLHOME="$TEST_CONFIG_DIR" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" backup 2>&1) - local exit_code=$? + sudo INSTALLHOME="$TEST_CONFIG_DIR" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" backup >/dev/null 2>&1 # Should create MANUAL.0 (backup is alias for manual 1) assert_dir_exists "$TEST_BACKUP_DIR/$HOSTNAME/MANUAL.0" "backup should create MANUAL.0" || { @@ -194,20 +188,56 @@ EOF echo "temp" > "$TEST_SOURCE_DIR/home/testuser/temp.tmp" echo "log" > "$TEST_SOURCE_DIR/home/testuser/app.log" - local output - output=$(sudo INSTALLHOME="$TEST_CONFIG_DIR" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" manual 1 2>&1) + sudo INSTALLHOME="$TEST_CONFIG_DIR" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" manual 1 >/dev/null 2>&1 # Excluded files should not exist in backup - assert_file_not_exists "$TEST_BACKUP_DIR/$HOSTNAME/latest/home/testuser/temp.tmp" "should exclude .tmp files" || { + assert_file_not_exists "$TEST_BACKUP_DIR/$HOSTNAME/latest$TEST_SOURCE_DIR/home/testuser/temp.tmp" "should exclude .tmp files" || { teardown_test_env return 1 } - assert_file_not_exists "$TEST_BACKUP_DIR/$HOSTNAME/latest/home/testuser/app.log" "should exclude .log files" || { + assert_file_not_exists "$TEST_BACKUP_DIR/$HOSTNAME/latest$TEST_SOURCE_DIR/home/testuser/app.log" "should exclude .log files" || { teardown_test_env return 1 } # Regular files should still exist - assert_file_exists "$TEST_BACKUP_DIR/$HOSTNAME/latest/home/testuser/file1.txt" "should include regular files" || { + assert_file_exists "$TEST_BACKUP_DIR/$HOSTNAME/latest$TEST_SOURCE_DIR/home/testuser/file1.txt" "should include regular files" || { + teardown_test_env + return 1 + } + + teardown_test_env +} + +# ------------------------------------------------------------------------------ +# Test: Same-basename sources don't collide (relative paths) +# ------------------------------------------------------------------------------ +# Two includes with the same basename ("bin") previously both mapped to +# latest/bin, and the second sync's --delete wiped the first. With -R each is +# stored under its full path, so both survive. +test_no_basename_collision() { + setup_test_env + + mkdir -p "$TEST_SOURCE_DIR/opt/bin" "$TEST_SOURCE_DIR/usr/bin" + echo "from-opt" > "$TEST_SOURCE_DIR/opt/bin/optfile" + echo "from-usr" > "$TEST_SOURCE_DIR/usr/bin/usrfile" + + cat > "$TEST_CONFIG_DIR/config" <<EOF +REMOTE_HOST="" +MOUNTDIR="$TEST_BACKUP_DIR" +EOF + cat > "$TEST_CONFIG_DIR/include.txt" <<EOF +$TEST_SOURCE_DIR/opt/bin +$TEST_SOURCE_DIR/usr/bin +EOF + create_test_excludes >/dev/null + + sudo INSTALLHOME="$TEST_CONFIG_DIR" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" manual 1 >/dev/null 2>&1 + + assert_file_exists "$TEST_BACKUP_DIR/$HOSTNAME/latest$TEST_SOURCE_DIR/opt/bin/optfile" "opt/bin should survive" || { + teardown_test_env + return 1 + } + assert_file_exists "$TEST_BACKUP_DIR/$HOSTNAME/latest$TEST_SOURCE_DIR/usr/bin/usrfile" "usr/bin should survive (no collision)" || { teardown_test_env return 1 } @@ -230,6 +260,7 @@ run_backup_tests() { run_test "respects retention limit" test_retention_limit run_test "backup command works as alias" test_backup_command run_test "excludes files matching patterns" test_excludes_patterns + run_test "same-basename sources don't collide" test_no_basename_collision } # Run if executed directly diff --git a/tests/cases/test_cron.sh b/tests/cases/test_cron.sh index 2fdb6f5..5c9bbfa 100755 --- a/tests/cases/test_cron.sh +++ b/tests/cases/test_cron.sh @@ -39,7 +39,7 @@ EOF create_test_excludes # Run setup (will fail on some checks but should still add cron) - sudo INSTALLHOME="$TEST_CONFIG_DIR" SCRIPTLOC="/tmp/rsyncshot-test" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" setup 2>&1 >/dev/null || true + sudo INSTALLHOME="$TEST_CONFIG_DIR" SCRIPTLOC="/tmp/rsyncshot-test" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" setup >/dev/null 2>&1 || true # Check crontab contains rsyncshot entries local crontab_content @@ -72,8 +72,8 @@ EOF create_test_excludes # Run setup twice - sudo INSTALLHOME="$TEST_CONFIG_DIR" SCRIPTLOC="/tmp/rsyncshot-test" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" setup 2>&1 >/dev/null || true - sudo INSTALLHOME="$TEST_CONFIG_DIR" SCRIPTLOC="/tmp/rsyncshot-test" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" setup 2>&1 >/dev/null || true + sudo INSTALLHOME="$TEST_CONFIG_DIR" SCRIPTLOC="/tmp/rsyncshot-test" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" setup >/dev/null 2>&1 || true + sudo INSTALLHOME="$TEST_CONFIG_DIR" SCRIPTLOC="/tmp/rsyncshot-test" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" setup >/dev/null 2>&1 || true # Count rsyncshot entries local crontab_content hourly_count @@ -105,7 +105,7 @@ EOF create_test_excludes # Run setup - sudo INSTALLHOME="$TEST_CONFIG_DIR" SCRIPTLOC="/tmp/rsyncshot-test" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" setup 2>&1 >/dev/null || true + sudo INSTALLHOME="$TEST_CONFIG_DIR" SCRIPTLOC="/tmp/rsyncshot-test" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" setup >/dev/null 2>&1 || true # Check custom job still exists local crontab_content diff --git a/tests/cases/test_functions.sh b/tests/cases/test_functions.sh new file mode 100644 index 0000000..cd607ab --- /dev/null +++ b/tests/cases/test_functions.sh @@ -0,0 +1,146 @@ +#!/usr/bin/env bash +# ============================================================================== +# Unit Tests for Internal Functions (is_mounted, derive_paths) +# ============================================================================== +# These source the script with RSYNCSHOT_SOURCE_ONLY=1 so the functions are +# defined without running the main flow, then exercise them directly. Each test +# runs the source + assertions in a subshell so the derived globals and config +# variables don't leak into other suites. + +source "$(dirname "${BASH_SOURCE[0]}")/../lib/test_helpers.sh" + +# ------------------------------------------------------------------------------ +# is_mounted: exact mount point matches +# ------------------------------------------------------------------------------ +test_is_mounted_exact_match() { + setup_test_env + local mounts="$TEST_DIR/mounts" + cat > "$mounts" <<EOF +/dev/sda1 /media/backup ext4 rw,relatime 0 0 +/dev/sdb1 /home ext4 rw,relatime 0 0 +EOF + ( + INSTALLHOME="$TEST_CONFIG_DIR" RSYNCSHOT_SOURCE_ONLY=1 source "$SCRIPT_PATH" + is_mounted "/media/backup" "$mounts" + ) + local rc=$? + teardown_test_env + assert_exit_code 0 "$rc" "exact mount point should match" || return 1 +} + +# ------------------------------------------------------------------------------ +# is_mounted: a prefix is NOT a match (the substring-grep bug) +# ------------------------------------------------------------------------------ +test_is_mounted_no_substring_match() { + setup_test_env + local mounts="$TEST_DIR/mounts" + cat > "$mounts" <<EOF +/dev/sda1 /media/backup2 ext4 rw,relatime 0 0 +EOF + ( + INSTALLHOME="$TEST_CONFIG_DIR" RSYNCSHOT_SOURCE_ONLY=1 source "$SCRIPT_PATH" + is_mounted "/media/backup" "$mounts" + ) + local rc=$? + teardown_test_env + assert_exit_code 1 "$rc" "/media/backup must NOT match /media/backup2" || return 1 +} + +# ------------------------------------------------------------------------------ +# is_mounted: mount points with spaces (encoded as \040 in /proc/mounts) +# ------------------------------------------------------------------------------ +test_is_mounted_handles_spaces() { + setup_test_env + local mounts="$TEST_DIR/mounts" + printf '/dev/sda1 /media/my\\040backup ext4 rw 0 0\n' > "$mounts" + ( + INSTALLHOME="$TEST_CONFIG_DIR" RSYNCSHOT_SOURCE_ONLY=1 source "$SCRIPT_PATH" + is_mounted "/media/my backup" "$mounts" + ) + local rc=$? + teardown_test_env + assert_exit_code 0 "$rc" "mount point with spaces should match" || return 1 +} + +# ------------------------------------------------------------------------------ +# is_mounted: target is treated literally, not as a regex +# ------------------------------------------------------------------------------ +test_is_mounted_target_is_literal() { + setup_test_env + local mounts="$TEST_DIR/mounts" + cat > "$mounts" <<EOF +/dev/sda1 /media/backup ext4 rw 0 0 +EOF + ( + INSTALLHOME="$TEST_CONFIG_DIR" RSYNCSHOT_SOURCE_ONLY=1 source "$SCRIPT_PATH" + is_mounted "/media/b.ckup" "$mounts" + ) + local rc=$? + teardown_test_env + assert_exit_code 1 "$rc" "regex metacharacters in target must be literal" || return 1 +} + +# ------------------------------------------------------------------------------ +# derive_paths: remote mode uses bare cp/mv/rm (resolved by remote PATH) +# ------------------------------------------------------------------------------ +test_derive_paths_remote() { + setup_test_env + cat > "$TEST_CONFIG_DIR/config" <<EOF +REMOTE_HOST="truenas" +REMOTE_PATH="/mnt/vault/Backups" +EOF + ( + # The script sources the config and runs derive_paths during load. + INSTALLHOME="$TEST_CONFIG_DIR" RSYNCSHOT_SOURCE_ONLY=1 source "$SCRIPT_PATH" + assert_equals "remote" "$MODE" "should be remote mode" && + assert_equals "truenas:/mnt/vault/Backups/$HOSTNAME" "$DESTINATION" "remote destination" && + assert_equals "cp" "$CP" "remote cp is bare (remote PATH resolves it)" && + assert_equals "mv" "$MV" "remote mv is bare" && + assert_equals "rm" "$RM" "remote rm is bare" + ) + local rc=$? + teardown_test_env + return $rc +} + +# ------------------------------------------------------------------------------ +# derive_paths: local mode resolves cp/mv/rm to real absolute binaries +# ------------------------------------------------------------------------------ +test_derive_paths_local() { + setup_test_env + cat > "$TEST_CONFIG_DIR/config" <<EOF +REMOTE_HOST="" +MOUNTDIR="/media/backup" +EOF + ( + INSTALLHOME="$TEST_CONFIG_DIR" RSYNCSHOT_SOURCE_ONLY=1 source "$SCRIPT_PATH" + assert_equals "local" "$MODE" "should be local mode" && + assert_equals "/media/backup/$HOSTNAME" "$DESTINATION" "local destination" && + assert_contains "$CP" "/" "local cp resolves to an absolute path, not a bare name" + ) + local rc=$? + teardown_test_env + return $rc +} + +# ------------------------------------------------------------------------------ +# Run tests +# ------------------------------------------------------------------------------ +run_functions_tests() { + echo "" + echo "Running function unit tests..." + echo "------------------------------------------------------------" + + run_test "is_mounted exact match" test_is_mounted_exact_match + run_test "is_mounted rejects prefix (no substring match)" test_is_mounted_no_substring_match + run_test "is_mounted handles spaces" test_is_mounted_handles_spaces + run_test "is_mounted treats target literally" test_is_mounted_target_is_literal + run_test "derive_paths remote mode" test_derive_paths_remote + run_test "derive_paths local mode" test_derive_paths_local +} + +# Run if executed directly +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + run_functions_tests + print_summary +fi diff --git a/tests/cases/test_remote.sh b/tests/cases/test_remote.sh new file mode 100644 index 0000000..c2ee75e --- /dev/null +++ b/tests/cases/test_remote.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# ============================================================================== +# Remote (SSH) Mode Tests +# ============================================================================== +# First coverage of the remote path — backup + rotation run over SSH. The whole +# transfer and rotation happen via ssh to localhost, which exercises run_cmd's +# remote branch and the bare cp/mv/rm rotation commands. Gated: if root can't +# ssh to localhost with key auth, the suite skips rather than fails, so machines +# without loopback SSH still pass. + +source "$(dirname "${BASH_SOURCE[0]}")/../lib/test_helpers.sh" + +remote_available() { + ssh -o BatchMode=yes -o ConnectTimeout=5 localhost true 2>/dev/null +} + +# ------------------------------------------------------------------------------ +# Backup + rotation over SSH-to-localhost +# ------------------------------------------------------------------------------ +test_remote_backup_and_rotation() { + setup_test_env + cat > "$TEST_CONFIG_DIR/config" <<EOF +REMOTE_HOST="localhost" +REMOTE_PATH="$TEST_BACKUP_DIR" +EOF + create_test_includes >/dev/null + create_test_excludes >/dev/null + + # Two runs to exercise rotation over SSH (MANUAL.0 rotates to MANUAL.1) + INSTALLHOME="$TEST_CONFIG_DIR" "$SCRIPT_PATH" manual 2 >/dev/null 2>&1 + INSTALLHOME="$TEST_CONFIG_DIR" "$SCRIPT_PATH" manual 2 >/dev/null 2>&1 + + assert_dir_exists "$TEST_BACKUP_DIR/$HOSTNAME/MANUAL.0" "remote backup should create MANUAL.0" || { teardown_test_env; return 1; } + assert_dir_exists "$TEST_BACKUP_DIR/$HOSTNAME/MANUAL.1" "remote rotation should produce MANUAL.1" || { teardown_test_env; return 1; } + assert_file_exists "$TEST_BACKUP_DIR/$HOSTNAME/latest$TEST_SOURCE_DIR/home/testuser/file1.txt" "remote backup should copy files" || { teardown_test_env; return 1; } + + teardown_test_env +} + +# ------------------------------------------------------------------------------ +# Run tests +# ------------------------------------------------------------------------------ +run_remote_tests() { + echo "" + echo "Running remote (SSH) tests..." + echo "------------------------------------------------------------" + + if ! remote_available; then + echo " SKIP: root cannot ssh to localhost with key auth — skipping remote tests" + return 0 + fi + + run_test "remote backup + rotation over SSH" test_remote_backup_and_rotation +} + +# Run if executed directly +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + run_remote_tests + print_summary +fi diff --git a/tests/cases/test_rsync_flags.sh b/tests/cases/test_rsync_flags.sh new file mode 100644 index 0000000..768b432 --- /dev/null +++ b/tests/cases/test_rsync_flags.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# ============================================================================== +# rsync Invocation Flag Tests +# ============================================================================== +# Uses the command-capture shim to intercept rsync and assert on the flags the +# script passes, without performing a real transfer. Runs a dryrun so only rsync +# is invoked (no rotation, no cp/mv/rm). + +source "$(dirname "${BASH_SOURCE[0]}")/../lib/test_helpers.sh" + +# ------------------------------------------------------------------------------ +# rsync is called with --numeric-ids (ownership fidelity) and -R (relative paths) +# ------------------------------------------------------------------------------ +test_rsync_flags_numeric_ids_and_relative() { + setup_test_env + create_test_config >/dev/null + create_test_includes >/dev/null + create_test_excludes >/dev/null + + local shim + shim=$(make_command_shim rsync) + + PATH="$shim:$PATH" INSTALLHOME="$TEST_CONFIG_DIR" RSYNCSHOT_SKIP_MOUNT_CHECK=1 \ + "$SCRIPT_PATH" dryrun manual 1 >/dev/null 2>&1 + + local args + args=$(cat "$shim/rsync.args" 2>/dev/null) + + assert_file_exists "$shim/rsync.args" "rsync shim should have been invoked" || { rm -rf "$shim"; teardown_test_env; return 1; } + assert_contains "$args" "--numeric-ids" "rsync should be called with --numeric-ids" || { rm -rf "$shim"; teardown_test_env; return 1; } + assert_contains "$args" "-avhR" "rsync should be called with -R (relative paths, in -avhR)" || { rm -rf "$shim"; teardown_test_env; return 1; } + assert_contains "$args" "--dry-run" "dryrun should pass --dry-run" || { rm -rf "$shim"; teardown_test_env; return 1; } + + rm -rf "$shim" + teardown_test_env +} + +# ------------------------------------------------------------------------------ +# Run tests +# ------------------------------------------------------------------------------ +run_rsync_flags_tests() { + echo "" + echo "Running rsync flag tests..." + echo "------------------------------------------------------------" + + run_test "rsync uses --numeric-ids and -R" test_rsync_flags_numeric_ids_and_relative +} + +# Run if executed directly +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + run_rsync_flags_tests + print_summary +fi diff --git a/tests/cases/test_validation.sh b/tests/cases/test_validation.sh index 03da676..9a89787 100755 --- a/tests/cases/test_validation.sh +++ b/tests/cases/test_validation.sh @@ -80,6 +80,59 @@ test_accepts_valid_snapshot_types() { } # ------------------------------------------------------------------------------ +# Test: Rejects a retention count of zero +# ------------------------------------------------------------------------------ +test_rejects_zero_count() { + local output + output=$(sudo "$SCRIPT_PATH" "manual" "0" 2>&1) + local exit_code=$? + + assert_exit_code 1 "$exit_code" "should reject count 0" || return 1 + assert_contains "$output" "at least 1" "should explain minimum count" || return 1 +} + +# ------------------------------------------------------------------------------ +# Test: Refuses an empty MOUNTDIR (would resolve to the filesystem root) +# ------------------------------------------------------------------------------ +test_rejects_empty_mountdir() { + setup_test_env + cat > "$TEST_CONFIG_DIR/config" <<EOF +REMOTE_HOST="" +MOUNTDIR="" +EOF + create_test_includes >/dev/null + create_test_excludes >/dev/null + + local output + output=$(sudo INSTALLHOME="$TEST_CONFIG_DIR" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" manual 1 2>&1) + local exit_code=$? + + teardown_test_env + + assert_exit_code 1 "$exit_code" "empty MOUNTDIR should be rejected" || return 1 + assert_contains "$output" "MOUNTDIR is empty" "should explain the empty-MOUNTDIR refusal" || return 1 +} + +# ------------------------------------------------------------------------------ +# Test: status produces no stderr noise (the grep "0\n0" integer-test bug) +# ------------------------------------------------------------------------------ +test_status_no_stderr_noise() { + setup_test_env + create_test_config >/dev/null + create_test_includes >/dev/null + create_test_excludes >/dev/null + + local stderr_file="$TEST_DIR/status.stderr" + sudo INSTALLHOME="$TEST_CONFIG_DIR" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" status >/dev/null 2>"$stderr_file" + local err + err=$(cat "$stderr_file") + + teardown_test_env + + assert_no_stderr "$err" "status should emit no stderr (no 'integer expression' noise)" || return 1 +} + +# ------------------------------------------------------------------------------ # Run tests # ------------------------------------------------------------------------------ run_validation_tests() { @@ -95,6 +148,9 @@ run_validation_tests() { run_test "rejects alphabetic retention count" test_rejects_alpha_retention_count run_test "rejects mixed retention count" test_rejects_mixed_retention_count run_test "accepts valid snapshot types" test_accepts_valid_snapshot_types + run_test "rejects retention count of zero" test_rejects_zero_count + run_test "refuses empty MOUNTDIR" test_rejects_empty_mountdir + run_test "status emits no stderr noise" test_status_no_stderr_noise teardown_test_env } |
