From f272f2a14c60ef853bb860c0612ad931d5a21d74 Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Thu, 29 Jan 2026 12:18:12 -0600 Subject: Add SSH remote backup support, new commands, and test suite - Add remote mode for SSH-based backups to servers like TrueNAS - Add SSH_IDENTITY_FILE config for non-root SSH keys - Add new commands: backup, status, list, dryrun - Add dependency checks for rsync, ssh, flock - Add timestamped logging - Fix: duplicate cron jobs on repeated setup - Fix: use mktemp for temp files - Fix: use portable sed instead of grep -oP - Fix: strengthen input validation with regex anchors - Fix: handle paths with spaces (newline-separated includes) - Change license from MIT to GPL v3 - Add automated test suite (25 tests) - Update README with new features and testing docs --- tests/cases/test_backup.sh | 239 +++++++++++++++++++++++++++++++++++++++ tests/cases/test_cron.sh | 138 +++++++++++++++++++++++ tests/cases/test_dryrun.sh | 120 ++++++++++++++++++++ tests/cases/test_includes.sh | 161 +++++++++++++++++++++++++++ tests/cases/test_validation.sh | 106 ++++++++++++++++++ tests/lib/test_helpers.sh | 247 +++++++++++++++++++++++++++++++++++++++++ tests/test_rsyncshot.sh | 120 ++++++++++++++++++++ 7 files changed, 1131 insertions(+) create mode 100755 tests/cases/test_backup.sh create mode 100755 tests/cases/test_cron.sh create mode 100755 tests/cases/test_dryrun.sh create mode 100755 tests/cases/test_includes.sh create mode 100755 tests/cases/test_validation.sh create mode 100755 tests/lib/test_helpers.sh create mode 100755 tests/test_rsyncshot.sh (limited to 'tests') diff --git a/tests/cases/test_backup.sh b/tests/cases/test_backup.sh new file mode 100755 index 0000000..1ab4fbd --- /dev/null +++ b/tests/cases/test_backup.sh @@ -0,0 +1,239 @@ +#!/usr/bin/env bash +# ============================================================================== +# Backup and Rotation Tests +# ============================================================================== + +source "$(dirname "${BASH_SOURCE[0]}")/../lib/test_helpers.sh" + +# ------------------------------------------------------------------------------ +# Test: Creates backup directory structure +# ------------------------------------------------------------------------------ +test_creates_backup_structure() { + setup_test_env + + create_test_config + 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=$? + + # Check directory structure + assert_dir_exists "$TEST_BACKUP_DIR/$HOSTNAME" "should create hostname dir" || { + teardown_test_env + return 1 + } + assert_dir_exists "$TEST_BACKUP_DIR/$HOSTNAME/latest" "should create latest dir" || { + teardown_test_env + return 1 + } + assert_dir_exists "$TEST_BACKUP_DIR/$HOSTNAME/MANUAL.0" "should create MANUAL.0 snapshot" || { + teardown_test_env + return 1 + } + + teardown_test_env +} + +# ------------------------------------------------------------------------------ +# Test: Copies files to backup +# ------------------------------------------------------------------------------ +test_copies_files() { + setup_test_env + + create_test_config + 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) + + # Check files were copied + assert_file_exists "$TEST_BACKUP_DIR/$HOSTNAME/latest/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" || { + teardown_test_env + return 1 + } + + teardown_test_env +} + +# ------------------------------------------------------------------------------ +# Test: Snapshot is read-only +# ------------------------------------------------------------------------------ +test_snapshot_readonly() { + setup_test_env + + create_test_config + 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) + + # Check snapshot directory has no write permission + # Note: We use stat to check actual permissions because -w always returns true for root + local perms + perms=$(stat -c '%A' "$TEST_BACKUP_DIR/$HOSTNAME/MANUAL.0" 2>/dev/null) + if [[ "$perms" == *w* ]]; then + echo "FAIL: Snapshot should be read-only (perms: $perms)" + teardown_test_env + return 1 + fi + + teardown_test_env + return 0 +} + +# ------------------------------------------------------------------------------ +# Test: Rotation works correctly +# ------------------------------------------------------------------------------ +test_rotation() { + setup_test_env + + create_test_config + create_test_includes + 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 + + # Should have MANUAL.0 and MANUAL.1 + assert_dir_exists "$TEST_BACKUP_DIR/$HOSTNAME/MANUAL.0" "should have MANUAL.0" || { + teardown_test_env + return 1 + } + assert_dir_exists "$TEST_BACKUP_DIR/$HOSTNAME/MANUAL.1" "should have MANUAL.1 after rotation" || { + teardown_test_env + return 1 + } + + teardown_test_env +} + +# ------------------------------------------------------------------------------ +# Test: Deletes oldest snapshot beyond retention +# ------------------------------------------------------------------------------ +test_retention_limit() { + setup_test_env + + create_test_config + create_test_includes + 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 + done + + # Should have MANUAL.0, MANUAL.1, MANUAL.2 but NOT MANUAL.3 + assert_dir_exists "$TEST_BACKUP_DIR/$HOSTNAME/MANUAL.0" "should have MANUAL.0" || { + teardown_test_env + return 1 + } + assert_dir_exists "$TEST_BACKUP_DIR/$HOSTNAME/MANUAL.1" "should have MANUAL.1" || { + teardown_test_env + return 1 + } + assert_dir_exists "$TEST_BACKUP_DIR/$HOSTNAME/MANUAL.2" "should have MANUAL.2" || { + teardown_test_env + return 1 + } + assert_dir_not_exists "$TEST_BACKUP_DIR/$HOSTNAME/MANUAL.3" "should NOT have MANUAL.3 (beyond retention)" || { + teardown_test_env + return 1 + } + + teardown_test_env +} + +# ------------------------------------------------------------------------------ +# Test: Backup command works as alias +# ------------------------------------------------------------------------------ +test_backup_command() { + setup_test_env + + create_test_config + 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=$? + + # Should create MANUAL.0 (backup is alias for manual 1) + assert_dir_exists "$TEST_BACKUP_DIR/$HOSTNAME/MANUAL.0" "backup should create MANUAL.0" || { + teardown_test_env + return 1 + } + + teardown_test_env +} + +# ------------------------------------------------------------------------------ +# Test: Excludes files matching patterns +# ------------------------------------------------------------------------------ +test_excludes_patterns() { + setup_test_env + + create_test_config + create_test_includes + + # Create exclude file + cat > "$TEST_CONFIG_DIR/exclude.txt" << 'EOF' +*.tmp +*.log +EOF + + # Create files that should be excluded + 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) + + # Excluded files should not exist in backup + assert_file_not_exists "$TEST_BACKUP_DIR/$HOSTNAME/latest/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" || { + 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" || { + teardown_test_env + return 1 + } + + teardown_test_env +} + +# ------------------------------------------------------------------------------ +# Run tests +# ------------------------------------------------------------------------------ +run_backup_tests() { + echo "" + echo "Running backup tests..." + echo "------------------------------------------------------------" + + run_test "creates backup directory structure" test_creates_backup_structure + run_test "copies files to backup" test_copies_files + run_test "snapshot is read-only" test_snapshot_readonly + run_test "rotation works correctly" test_rotation + 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 if executed directly +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + run_backup_tests + print_summary +fi diff --git a/tests/cases/test_cron.sh b/tests/cases/test_cron.sh new file mode 100755 index 0000000..2fdb6f5 --- /dev/null +++ b/tests/cases/test_cron.sh @@ -0,0 +1,138 @@ +#!/usr/bin/env bash +# ============================================================================== +# Cron Job Management Tests +# ============================================================================== + +source "$(dirname "${BASH_SOURCE[0]}")/../lib/test_helpers.sh" + +# Save original crontab to restore later +ORIGINAL_CRONTAB="" + +save_crontab() { + ORIGINAL_CRONTAB=$(crontab -l 2>/dev/null || true) +} + +restore_crontab() { + if [ -n "$ORIGINAL_CRONTAB" ]; then + echo "$ORIGINAL_CRONTAB" | crontab - + else + crontab -r 2>/dev/null || true + fi +} + +# ------------------------------------------------------------------------------ +# Test: Setup adds cron jobs +# ------------------------------------------------------------------------------ +test_setup_adds_cron_jobs() { + setup_test_env + save_crontab + + # Clear existing crontab + crontab -r 2>/dev/null || true + + # Create minimal config + cat > "$TEST_CONFIG_DIR/config" << EOF +REMOTE_HOST="" +MOUNTDIR="$TEST_BACKUP_DIR" +EOF + create_test_includes + 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 + + # Check crontab contains rsyncshot entries + local crontab_content + crontab_content=$(crontab -l 2>/dev/null) + + restore_crontab + teardown_test_env + + assert_contains "$crontab_content" "rsyncshot" "crontab should contain rsyncshot" || return 1 + assert_contains "$crontab_content" "hourly" "crontab should contain hourly job" || return 1 + assert_contains "$crontab_content" "daily" "crontab should contain daily job" || return 1 + assert_contains "$crontab_content" "weekly" "crontab should contain weekly job" || return 1 +} + +# ------------------------------------------------------------------------------ +# Test: Repeated setup doesn't duplicate entries +# ------------------------------------------------------------------------------ +test_no_duplicate_cron_entries() { + setup_test_env + save_crontab + + # Clear existing crontab + crontab -r 2>/dev/null || true + + cat > "$TEST_CONFIG_DIR/config" << EOF +REMOTE_HOST="" +MOUNTDIR="$TEST_BACKUP_DIR" +EOF + create_test_includes + 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 + + # Count rsyncshot entries + local crontab_content hourly_count + crontab_content=$(crontab -l 2>/dev/null) + hourly_count=$(echo "$crontab_content" | grep -c "hourly" || echo 0) + + restore_crontab + teardown_test_env + + # Should only have 1 hourly entry, not 2 + assert_equals "1" "$hourly_count" "should have exactly 1 hourly entry after repeated setup" || return 1 +} + +# ------------------------------------------------------------------------------ +# Test: Setup preserves existing cron jobs +# ------------------------------------------------------------------------------ +test_preserves_existing_cron() { + setup_test_env + save_crontab + + # Add a custom cron job + (crontab -l 2>/dev/null || true; echo "0 5 * * * /custom/job.sh") | crontab - + + cat > "$TEST_CONFIG_DIR/config" << EOF +REMOTE_HOST="" +MOUNTDIR="$TEST_BACKUP_DIR" +EOF + create_test_includes + 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 + + # Check custom job still exists + local crontab_content + crontab_content=$(crontab -l 2>/dev/null) + + restore_crontab + teardown_test_env + + assert_contains "$crontab_content" "/custom/job.sh" "should preserve existing cron jobs" || return 1 + assert_contains "$crontab_content" "rsyncshot" "should also have rsyncshot jobs" || return 1 +} + +# ------------------------------------------------------------------------------ +# Run tests +# ------------------------------------------------------------------------------ +run_cron_tests() { + echo "" + echo "Running cron tests..." + echo "------------------------------------------------------------" + + run_test "setup adds cron jobs" test_setup_adds_cron_jobs + run_test "repeated setup doesn't duplicate entries" test_no_duplicate_cron_entries + run_test "setup preserves existing cron jobs" test_preserves_existing_cron +} + +# Run if executed directly +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + run_cron_tests + print_summary +fi diff --git a/tests/cases/test_dryrun.sh b/tests/cases/test_dryrun.sh new file mode 100755 index 0000000..bff45e1 --- /dev/null +++ b/tests/cases/test_dryrun.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash +# ============================================================================== +# Dry-Run Mode Tests +# ============================================================================== + +source "$(dirname "${BASH_SOURCE[0]}")/../lib/test_helpers.sh" + +# ------------------------------------------------------------------------------ +# Test: Dry-run doesn't create backup directory +# ------------------------------------------------------------------------------ +test_dryrun_no_directory_creation() { + setup_test_env + + create_test_config + create_test_includes + create_test_excludes + + # Remove backup dir to verify it's not created + rmdir "$TEST_BACKUP_DIR" + + local output + output=$(sudo INSTALLHOME="$TEST_CONFIG_DIR" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" dryrun manual 1 2>&1) + + # Backup directory should NOT be created in dry-run mode + assert_dir_not_exists "$TEST_BACKUP_DIR/$HOSTNAME" "backup dir should not be created in dryrun" || { + teardown_test_env + return 1 + } + + teardown_test_env +} + +# ------------------------------------------------------------------------------ +# Test: Dry-run shows what would be transferred +# ------------------------------------------------------------------------------ +test_dryrun_shows_transfer_info() { + setup_test_env + + create_test_config + create_test_includes + create_test_excludes + + # Create the backup directory structure for dryrun to work + mkdir -p "$TEST_BACKUP_DIR/$HOSTNAME/latest" + + local output + output=$(sudo INSTALLHOME="$TEST_CONFIG_DIR" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" dryrun manual 1 2>&1) + + teardown_test_env + + # Should show syncing messages + assert_contains "$output" "Syncing" "should show syncing info" || return 1 + # Should show dry run message + assert_contains "$output" "Dry run complete" "should show dryrun complete message" || return 1 +} + +# ------------------------------------------------------------------------------ +# Test: Dry-run shows command to run actual backup +# ------------------------------------------------------------------------------ +test_dryrun_shows_actual_command() { + setup_test_env + + create_test_config + create_test_includes + create_test_excludes + + mkdir -p "$TEST_BACKUP_DIR/$HOSTNAME/latest" + + local output + output=$(sudo INSTALLHOME="$TEST_CONFIG_DIR" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" dryrun manual 1 2>&1) + + teardown_test_env + + # Should show how to run actual backup + assert_contains "$output" "sudo rsyncshot manual 1" "should show actual command" || return 1 +} + +# ------------------------------------------------------------------------------ +# Test: Dry-run doesn't create snapshots +# ------------------------------------------------------------------------------ +test_dryrun_no_snapshot_creation() { + setup_test_env + + create_test_config + create_test_includes + create_test_excludes + + mkdir -p "$TEST_BACKUP_DIR/$HOSTNAME/latest" + + local output + output=$(sudo INSTALLHOME="$TEST_CONFIG_DIR" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" dryrun manual 1 2>&1) + + # Should not create manual.0 snapshot + assert_dir_not_exists "$TEST_BACKUP_DIR/$HOSTNAME/manual.0" "snapshot should not be created in dryrun" || { + teardown_test_env + return 1 + } + + teardown_test_env +} + +# ------------------------------------------------------------------------------ +# Run tests +# ------------------------------------------------------------------------------ +run_dryrun_tests() { + echo "" + echo "Running dry-run tests..." + echo "------------------------------------------------------------" + + run_test "dry-run doesn't create backup directory" test_dryrun_no_directory_creation + run_test "dry-run shows transfer info" test_dryrun_shows_transfer_info + run_test "dry-run shows actual command" test_dryrun_shows_actual_command + run_test "dry-run doesn't create snapshots" test_dryrun_no_snapshot_creation +} + +# Run if executed directly +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + run_dryrun_tests + print_summary +fi diff --git a/tests/cases/test_includes.sh b/tests/cases/test_includes.sh new file mode 100755 index 0000000..8f556e3 --- /dev/null +++ b/tests/cases/test_includes.sh @@ -0,0 +1,161 @@ +#!/usr/bin/env bash +# ============================================================================== +# Include File Parsing Tests +# ============================================================================== + +source "$(dirname "${BASH_SOURCE[0]}")/../lib/test_helpers.sh" + +# ------------------------------------------------------------------------------ +# Test: Reads newline-separated paths +# ------------------------------------------------------------------------------ +test_reads_newline_paths() { + setup_test_env + + # Create config and include files + create_test_config + create_test_excludes + + # Create include file with newline-separated paths + cat > "$TEST_CONFIG_DIR/include.txt" << EOF +$TEST_SOURCE_DIR/home +$TEST_SOURCE_DIR/etc +EOF + + # Run dryrun to test parsing + local output + output=$(sudo INSTALLHOME="$TEST_CONFIG_DIR" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" dryrun manual 1 2>&1) + local exit_code=$? + + teardown_test_env + + # Should process both directories + assert_contains "$output" "Syncing $TEST_SOURCE_DIR/home" "should sync home" || return 1 + assert_contains "$output" "Syncing $TEST_SOURCE_DIR/etc" "should sync etc" || return 1 +} + +# ------------------------------------------------------------------------------ +# Test: Skips comment lines +# ------------------------------------------------------------------------------ +test_skips_comments() { + setup_test_env + + create_test_config + create_test_excludes + + # Create include file with comments + cat > "$TEST_CONFIG_DIR/include.txt" << EOF +# This is a comment +$TEST_SOURCE_DIR/home +# Another comment +$TEST_SOURCE_DIR/etc +EOF + + local output + output=$(sudo INSTALLHOME="$TEST_CONFIG_DIR" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" dryrun manual 1 2>&1) + + teardown_test_env + + # Should not try to sync comment lines + assert_not_contains "$output" "Syncing # This" "should skip comments" || return 1 + assert_contains "$output" "Syncing $TEST_SOURCE_DIR/home" "should sync home" || return 1 +} + +# ------------------------------------------------------------------------------ +# Test: Skips empty lines +# ------------------------------------------------------------------------------ +test_skips_empty_lines() { + setup_test_env + + create_test_config + create_test_excludes + + # Create include file with empty lines + cat > "$TEST_CONFIG_DIR/include.txt" << EOF +$TEST_SOURCE_DIR/home + +$TEST_SOURCE_DIR/etc + +EOF + + local output + output=$(sudo INSTALLHOME="$TEST_CONFIG_DIR" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" dryrun manual 1 2>&1) + + teardown_test_env + + # Should process both directories without errors + assert_contains "$output" "Syncing $TEST_SOURCE_DIR/home" "should sync home" || return 1 + assert_contains "$output" "Syncing $TEST_SOURCE_DIR/etc" "should sync etc" || return 1 +} + +# ------------------------------------------------------------------------------ +# Test: Handles paths with spaces +# ------------------------------------------------------------------------------ +test_handles_paths_with_spaces() { + setup_test_env + + create_test_config + create_test_excludes + + # Create a directory with spaces + mkdir -p "$TEST_SOURCE_DIR/path with spaces" + echo "test" > "$TEST_SOURCE_DIR/path with spaces/file.txt" + + # Create include file with path containing spaces + cat > "$TEST_CONFIG_DIR/include.txt" << EOF +$TEST_SOURCE_DIR/path with spaces +EOF + + local output + output=$(sudo INSTALLHOME="$TEST_CONFIG_DIR" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" dryrun manual 1 2>&1) + + teardown_test_env + + # Should handle the path with spaces + assert_contains "$output" "path with spaces" "should handle spaces in path" || return 1 + assert_not_contains "$output" "not found" "should not report path not found" || return 1 +} + +# ------------------------------------------------------------------------------ +# Test: Reports missing directory +# ------------------------------------------------------------------------------ +test_reports_missing_directory() { + setup_test_env + + create_test_config + create_test_excludes + + # Create include file with non-existent path + cat > "$TEST_CONFIG_DIR/include.txt" << EOF +/nonexistent/path/that/does/not/exist +EOF + + local output + output=$(sudo INSTALLHOME="$TEST_CONFIG_DIR" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" dryrun manual 1 2>&1) + local exit_code=$? + + teardown_test_env + + assert_exit_code 1 "$exit_code" "should fail for missing directory" || return 1 + assert_contains "$output" "not found" "should report directory not found" || return 1 +} + +# ------------------------------------------------------------------------------ +# Run tests +# ------------------------------------------------------------------------------ +run_includes_tests() { + echo "" + echo "Running include file tests..." + echo "------------------------------------------------------------" + + run_test "reads newline-separated paths" test_reads_newline_paths + run_test "skips comment lines" test_skips_comments + run_test "skips empty lines" test_skips_empty_lines + run_test "handles paths with spaces" test_handles_paths_with_spaces + run_test "reports missing directory" test_reports_missing_directory +} + +# Run if executed directly +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + run_includes_tests + print_summary +fi diff --git a/tests/cases/test_validation.sh b/tests/cases/test_validation.sh new file mode 100755 index 0000000..03da676 --- /dev/null +++ b/tests/cases/test_validation.sh @@ -0,0 +1,106 @@ +#!/usr/bin/env bash +# ============================================================================== +# Input Validation Tests +# ============================================================================== + +source "$(dirname "${BASH_SOURCE[0]}")/../lib/test_helpers.sh" + +# ------------------------------------------------------------------------------ +# Test: Help works without root +# ------------------------------------------------------------------------------ +test_help_without_root() { + local output + output=$("$SCRIPT_PATH" help 2>&1) + local exit_code=$? + + assert_exit_code 0 "$exit_code" "help should exit with 0" || return 1 + assert_contains "$output" "rsyncshot" "help should mention rsyncshot" || return 1 + assert_contains "$output" "Usage" "help should show usage" || return 1 +} + +# ------------------------------------------------------------------------------ +# Test: Rejects non-alphabetic snapshot type +# ------------------------------------------------------------------------------ +test_rejects_numeric_snapshot_type() { + local output + output=$(sudo "$SCRIPT_PATH" "123" "5" 2>&1) + local exit_code=$? + + assert_exit_code 1 "$exit_code" "should reject numeric snapshot type" || return 1 + assert_contains "$output" "must be alphabetic" "should show alphabetic error" || return 1 +} + +# ------------------------------------------------------------------------------ +# Test: Rejects mixed alphanumeric snapshot type +# ------------------------------------------------------------------------------ +test_rejects_mixed_snapshot_type() { + local output + output=$(sudo "$SCRIPT_PATH" "hourly123" "5" 2>&1) + local exit_code=$? + + assert_exit_code 1 "$exit_code" "should reject mixed snapshot type" || return 1 + assert_contains "$output" "must be alphabetic" "should show alphabetic error" || return 1 +} + +# ------------------------------------------------------------------------------ +# Test: Rejects non-numeric retention count +# ------------------------------------------------------------------------------ +test_rejects_alpha_retention_count() { + local output + output=$(sudo "$SCRIPT_PATH" "manual" "abc" 2>&1) + local exit_code=$? + + assert_exit_code 1 "$exit_code" "should reject alphabetic count" || return 1 + assert_contains "$output" "must be a number" "should show number error" || return 1 +} + +# ------------------------------------------------------------------------------ +# Test: Rejects mixed alphanumeric retention count +# ------------------------------------------------------------------------------ +test_rejects_mixed_retention_count() { + local output + output=$(sudo "$SCRIPT_PATH" "manual" "5abc" 2>&1) + local exit_code=$? + + assert_exit_code 1 "$exit_code" "should reject mixed count" || return 1 + assert_contains "$output" "must be a number" "should show number error" || return 1 +} + +# ------------------------------------------------------------------------------ +# Test: Accepts valid alphabetic snapshot types +# ------------------------------------------------------------------------------ +test_accepts_valid_snapshot_types() { + # We use dryrun to avoid actual backup, and expect it to fail on missing config + # but it should get past the validation stage + local output + output=$(sudo "$SCRIPT_PATH" dryrun "hourly" "24" 2>&1) + + # Should not contain the validation error (might fail for other reasons like missing config) + assert_not_contains "$output" "must be alphabetic" "should accept valid type" || return 1 +} + +# ------------------------------------------------------------------------------ +# Run tests +# ------------------------------------------------------------------------------ +run_validation_tests() { + echo "" + echo "Running validation tests..." + echo "------------------------------------------------------------" + + setup_test_env + + run_test "help works without root" test_help_without_root + run_test "rejects numeric snapshot type" test_rejects_numeric_snapshot_type + run_test "rejects mixed alphanumeric snapshot type" test_rejects_mixed_snapshot_type + 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 + + teardown_test_env +} + +# Run if executed directly +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + run_validation_tests + print_summary +fi diff --git a/tests/lib/test_helpers.sh b/tests/lib/test_helpers.sh new file mode 100755 index 0000000..726d788 --- /dev/null +++ b/tests/lib/test_helpers.sh @@ -0,0 +1,247 @@ +#!/usr/bin/env bash +# ============================================================================== +# Test Helper Functions for rsyncshot +# ============================================================================== + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +NC='\033[0m' # No Color + +# Counters +TESTS_RUN=0 +TESTS_PASSED=0 +TESTS_FAILED=0 + +# Test environment paths +TEST_DIR="" +TEST_CONFIG_DIR="" +TEST_BACKUP_DIR="" +SCRIPT_PATH="" + +# ------------------------------------------------------------------------------ +# Setup/Teardown +# ------------------------------------------------------------------------------ + +setup_test_env() { + # Create temporary directories for testing + TEST_DIR=$(mktemp -d) + TEST_CONFIG_DIR="$TEST_DIR/etc/rsyncshot" + TEST_BACKUP_DIR="$TEST_DIR/backup" + TEST_SOURCE_DIR="$TEST_DIR/source" + + mkdir -p "$TEST_CONFIG_DIR" + mkdir -p "$TEST_BACKUP_DIR" + mkdir -p "$TEST_SOURCE_DIR/home/testuser" + mkdir -p "$TEST_SOURCE_DIR/etc" + + # Create some test files + echo "test file 1" > "$TEST_SOURCE_DIR/home/testuser/file1.txt" + echo "test file 2" > "$TEST_SOURCE_DIR/home/testuser/file2.txt" + echo "config data" > "$TEST_SOURCE_DIR/etc/test.conf" + + # Find the script (relative to tests directory) + SCRIPT_PATH="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/rsyncshot" + + if [ ! -f "$SCRIPT_PATH" ]; then + echo "ERROR: Cannot find rsyncshot script at $SCRIPT_PATH" + exit 1 + fi +} + +teardown_test_env() { + # Clean up temporary directories + if [ -n "$TEST_DIR" ] && [ -d "$TEST_DIR" ]; then + rm -rf "$TEST_DIR" + fi +} + +# Run rsyncshot with test environment variables +# Usage: run_rsyncshot [args...] +run_rsyncshot() { + INSTALLHOME="$TEST_CONFIG_DIR" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" "$@" +} + +# Create a test config file +create_test_config() { + local config_file="$TEST_CONFIG_DIR/config" + cat > "$config_file" << EOF +REMOTE_HOST="" +MOUNTDIR="$TEST_BACKUP_DIR" +EOF + echo "$config_file" +} + +# Create a test include file +create_test_includes() { + local include_file="$TEST_CONFIG_DIR/include.txt" + cat > "$include_file" << EOF +$TEST_SOURCE_DIR/home +$TEST_SOURCE_DIR/etc +EOF + echo "$include_file" +} + +# Create a test exclude file +create_test_excludes() { + local exclude_file="$TEST_CONFIG_DIR/exclude.txt" + cat > "$exclude_file" << EOF +*.tmp +*.log +.cache +EOF + echo "$exclude_file" +} + +# ------------------------------------------------------------------------------ +# Assertions +# ------------------------------------------------------------------------------ + +assert_equals() { + local expected="$1" + local actual="$2" + local message="${3:-Values should be equal}" + + if [ "$expected" = "$actual" ]; then + return 0 + else + echo -e "${RED}FAIL${NC}: $message" + echo " Expected: $expected" + echo " Actual: $actual" + return 1 + fi +} + +assert_exit_code() { + local expected="$1" + local actual="$2" + local message="${3:-Exit code should be $expected}" + + if [ "$expected" -eq "$actual" ]; then + return 0 + else + echo -e "${RED}FAIL${NC}: $message" + echo " Expected exit code: $expected" + echo " Actual exit code: $actual" + return 1 + fi +} + +assert_contains() { + local haystack="$1" + local needle="$2" + local message="${3:-Output should contain '$needle'}" + + if echo "$haystack" | grep -q "$needle"; then + return 0 + else + echo -e "${RED}FAIL${NC}: $message" + echo " Looking for: $needle" + echo " In output: $haystack" + return 1 + fi +} + +assert_not_contains() { + local haystack="$1" + local needle="$2" + local message="${3:-Output should not contain '$needle'}" + + if ! echo "$haystack" | grep -q "$needle"; then + return 0 + else + echo -e "${RED}FAIL${NC}: $message" + echo " Should not contain: $needle" + echo " But found in: $haystack" + return 1 + fi +} + +assert_file_exists() { + local file="$1" + local message="${2:-File should exist: $file}" + + if [ -f "$file" ]; then + return 0 + else + echo -e "${RED}FAIL${NC}: $message" + return 1 + fi +} + +assert_dir_exists() { + local dir="$1" + local message="${2:-Directory should exist: $dir}" + + if [ -d "$dir" ]; then + return 0 + else + echo -e "${RED}FAIL${NC}: $message" + return 1 + fi +} + +assert_file_not_exists() { + local file="$1" + local message="${2:-File should not exist: $file}" + + if [ ! -f "$file" ]; then + return 0 + else + echo -e "${RED}FAIL${NC}: $message" + return 1 + fi +} + +assert_dir_not_exists() { + local dir="$1" + local message="${2:-Directory should not exist: $dir}" + + if [ ! -d "$dir" ]; then + return 0 + else + echo -e "${RED}FAIL${NC}: $message" + return 1 + fi +} + +# ------------------------------------------------------------------------------ +# Test Runner +# ------------------------------------------------------------------------------ + +run_test() { + local test_name="$1" + local test_func="$2" + + TESTS_RUN=$((TESTS_RUN + 1)) + + # Run the test function and capture result + if $test_func; then + echo -e "${GREEN}PASS${NC}: $test_name" + TESTS_PASSED=$((TESTS_PASSED + 1)) + return 0 + else + TESTS_FAILED=$((TESTS_FAILED + 1)) + return 1 + fi +} + +print_summary() { + echo "" + echo "============================================================" + echo "Test Summary" + echo "============================================================" + echo -e "Total: $TESTS_RUN" + echo -e "Passed: ${GREEN}$TESTS_PASSED${NC}" + echo -e "Failed: ${RED}$TESTS_FAILED${NC}" + echo "" + + if [ "$TESTS_FAILED" -eq 0 ]; then + echo -e "${GREEN}All tests passed!${NC}" + return 0 + else + echo -e "${RED}Some tests failed.${NC}" + return 1 + fi +} diff --git a/tests/test_rsyncshot.sh b/tests/test_rsyncshot.sh new file mode 100755 index 0000000..15f2a99 --- /dev/null +++ b/tests/test_rsyncshot.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash +# ============================================================================== +# rsyncshot Test Suite +# ============================================================================== +# +# Runs all automated tests for rsyncshot. +# +# Usage: +# sudo ./tests/test_rsyncshot.sh # Run all tests +# sudo ./tests/test_rsyncshot.sh -v # Verbose output +# sudo ./tests/test_rsyncshot.sh --quick # Skip slow tests +# +# ============================================================================== + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +VERBOSE=false +QUICK=false + +# Parse arguments +while [[ $# -gt 0 ]]; do + case $1 in + -v|--verbose) + VERBOSE=true + shift + ;; + -q|--quick) + QUICK=true + shift + ;; + -h|--help) + echo "Usage: $0 [-v|--verbose] [-q|--quick]" + echo "" + echo "Options:" + echo " -v, --verbose Show detailed test output" + echo " -q, --quick Skip slow tests (backup/rotation)" + exit 0 + ;; + *) + echo "Unknown option: $1" + exit 1 + ;; + esac +done + +# Check for root +if [ "$EUID" -ne 0 ]; then + echo "Tests must be run as root (sudo ./tests/test_rsyncshot.sh)" + exit 1 +fi + +# Source test helpers +source "$SCRIPT_DIR/lib/test_helpers.sh" + +echo "============================================================" +echo "rsyncshot Test Suite" +echo "============================================================" +echo "" +echo "Script: $(cd "$SCRIPT_DIR/.." && pwd)/rsyncshot" +echo "Date: $(date)" +echo "" + +# Track overall results +TOTAL_RUN=0 +TOTAL_PASSED=0 +TOTAL_FAILED=0 + +# Run a test file and accumulate results +run_test_file() { + local test_file="$1" + local test_name="$2" + + # Reset counters before sourcing + TESTS_RUN=0 + TESTS_PASSED=0 + TESTS_FAILED=0 + + # Source and run the test file + source "$test_file" + + # Call the run function + "run_${test_name}_tests" + + # Accumulate totals + TOTAL_RUN=$((TOTAL_RUN + TESTS_RUN)) + TOTAL_PASSED=$((TOTAL_PASSED + TESTS_PASSED)) + TOTAL_FAILED=$((TOTAL_FAILED + TESTS_FAILED)) +} + +# Run test suites +run_test_file "$SCRIPT_DIR/cases/test_validation.sh" "validation" +run_test_file "$SCRIPT_DIR/cases/test_includes.sh" "includes" +run_test_file "$SCRIPT_DIR/cases/test_dryrun.sh" "dryrun" + +if [ "$QUICK" = false ]; then + run_test_file "$SCRIPT_DIR/cases/test_backup.sh" "backup" + run_test_file "$SCRIPT_DIR/cases/test_cron.sh" "cron" +else + echo "" + echo "Skipping slow tests (backup, cron) - use without --quick to run all" +fi + +# Print final summary +echo "" +echo "============================================================" +echo "Final Summary" +echo "============================================================" +echo -e "Total: $TOTAL_RUN" +echo -e "Passed: ${GREEN}$TOTAL_PASSED${NC}" +echo -e "Failed: ${RED}$TOTAL_FAILED${NC}" +echo "" + +if [ "$TOTAL_FAILED" -eq 0 ]; then + echo -e "${GREEN}All tests passed!${NC}" + exit 0 +else + echo -e "${RED}Some tests failed.${NC}" + exit 1 +fi -- cgit v1.2.3