#!/bin/sh # # === AUTO-GENERATED METADATA (make metadata) === # Generated: 2026-11-01 06:41:49 # Terminal functions (single underscore): output shell commands # Combinator functions (double underscore): output single vpn-switch command # Batch-combinator functions (triple underscore): output multiple vpn-switch commands TERMINAL_FUNCTIONS="_comment1 _note1 _error1 _error2 _error3 _fail2 _log1 _log2 _log3 _wireguard_collect0 _openvpn_collect0 _export_fresh1 _dump_header1 _restore_prune2 _filter_write3 _bundle_pack2 _seal_write2 _incoming_remove1 _extract_unpack2 _attest_sign2 _lift1 _database_init0 _environment_init1 _environment_cache1 _lift3 _setenv2 _getenv1 _unsetenv1 _cat1 _batch2 _printenv0 _env_sync0 _version_sync0 _version0 _link2 _remove1 _status0 _help0 _help1 _help2 _complete0 _complete1 _complete2 _complete3 _complete4 _complete5 _complete6 _helpenv0 _helpenv2 _database_enumerate0 _system_inspect0 _logs_inspect0 _phases_inspect0 _summary_inspect0 _prologue_inspect0 _key_import_copy3 _key_collect_files2 _manifest_write2 _openpgp_record2 _openpgp_record3 _openpgp_prerequisites0 _openvpn_patch2 _openvpn_configure1 _openvpn_connect1 _openvpn_disconnect1 _openvpn_patch1 _openvpn_enumerate0 _openvpn_import1 _openvpn_list0 _openvpn_list1 _openvpn_add1 _openvpn_add3 _openvpn_link2 _openvpn_remove1 _openvpn_remove2 _openvpn_validate0 _openvpn_info1 _phases_copy0 _provenance_serial1 _provenance_file5 _provenance_import_copy2 _provenance_collect0 _provenance_list1 _session_list0 _session_show1 _session_remove1 _session_connect1 _session_save0 _session_save1 _session_clean0 _session_refresh0 _session_validate0 _session_enumerate0 _session_describe0 _session_create1 _interface_destroy1 _session_populate3 _logs_validate0 _logs_clean0 _phases_validate0 _database_validate0 _binaries_validate0 _network_validate0 _permissions_validate0 _environment_validate0 _install_validate0 _version_validate0 _sudo_validate0 _wireguard_patch2 _wireguard_configure1 _wireguard_connect1 _wireguard_disconnect1 _wireguard_enumerate0 _wireguard_import1 _wireguard_list0 _wireguard_list1 _wireguard_add1 _wireguard_add3 _wireguard_link2 _wireguard_remove1 _wireguard_remove2 _wireguard_validate0 _wireguard_patch1 _wireguard_info1" COMBINATOR_FUNCTIONS="__export3 __export2 __export_redacted2 __export_full2 __export_minimized2 __export_pair3 __dump_readable1 __archive_key_pinned0 __restore1 __restore2 __restore_v12 __restore_v13 __restore_v14 __restore_v15 __restore3 __restore_serial_admissible3 __restore_base_exists1 __restore_replay2 __filter2 __filter_default2 __filter3 __filter_redacted2 __filter_full2 __filter_minimized2 __collection_readable1 __archiver_set0 __bundle_archive_absolute1 __bundle_manifest_beside1 __bundle_collection_inside1 __bundle_readable1 __seal_absent1 __attest_absent1 __seal_present1 __seal_matches2 __incoming_clear1 __extractor_set0 __extract_destination_absolute1 __extract_destination_empty1 __import2 __import_bootstrap2 __import3 __import4 __import5 __import6 __attest_file_exists1 __openpgp_record_complete1 __attest_signer_pinned2 __bootstrap1 __environment_cache0 __phases_lift1 __init0 __dump0 __batch1 __import1 __helpenv1 __setintp2 __getintp1 __unsetintp1 __helpintp1 __helpintp2 __key_name_valid1 __base_element_exists1 __key_record_exists2 __manifest_named1 __manifest_entries_hashable1 __manifest_readable1 __manifest_base_exists1 __manifest_tree_matches2 __openpgp_keyid_valid1 __openpgp_import2 __openpgp_collect1 __openvpn_start0 __openvpn_start1 __openvpn_stop1 __openvpn_add2 __openvpn_help0 __provenance_serial0 __provenance_add2 __provenance_add3 __provenance_add4 __provenance_add5 __provenance_add6 __provenance_bundle_readable1 __provenance_receipt5 __provenance_receipt_same3 __provenance_id_valid1 __provenance_import_place2 __provenance_list0 __session_disconnect1 __session_stop1 __session_show0 __session_start0 __session_start1 __session_help0 __session_exists1 __session_conflicts2 __interface_check1 __stop1 __start1 __start0 __wireguard_start0 __wireguard_start1 __wireguard_stop1 __wireguard_add2 __wireguard_help0" BATCH_COMBINATOR_FUNCTIONS="___collect0 ___export_pair4 ___restore_v16 ___bundle2 ___seal2 ___seal_verify2 ___extract2 ___import_bootstrap5 ___import_bootstrap6 ___import7 ___attest2 ___attest_verify2 ___bootstrap2 ___environment_refresh0 ___init1 ___dumpenv1 ___dump1 ___batch0 ___sync0 ___list0 ___environment_inspect0 ___inspect0 ___key_import3 ___manifest2 ___manifest_attest2 ___manifest_verify3 ___manifest_match2 ___openpgp_add2 ___openpgp_add3 ___openpgp_dump0 ___openpgp_collect0 ___openvpn_create1 ___openvpn_stop0 ___openvpn_inspect0 ___openvpn_clean0 ___openvpn_dump0 ___phases_sync1 ___phases_sync0 ___provenance_add7 ___provenance_import2 ___provenance_dump0 ___session_stop0 ___session_switch1 ___session_switch2 ___session_dump0 ___session_inspect0 ___session_prepare3 ___stop0 ___validate0 ___wireguard_create1 ___wireguard_inspect0 ___wireguard_stop0 ___wireguard_clean0 ___wireguard_clean1 ___wireguard_dump0" ANCHOR_FUNCTIONS="_comment1 _note1 _error1 _error2 _error3 _fail2 _log1 _log2 _log3 _wireguard_collect0 _openvpn_collect0 _export_fresh1 _dump_header1 _restore_prune2 _filter_write3 _bundle_pack2 _seal_write2 _incoming_remove1 _extract_unpack2 _attest_sign2 _lift1 _database_init0 _environment_init1 _environment_cache1 _lift3 _setenv2 _getenv1 _unsetenv1 _cat1 _batch2 _printenv0 _env_sync0 _version_sync0 _version0 _link2 _remove1 _status0 _help0 _help1 _help2 _complete0 _complete1 _complete2 _complete3 _complete4 _complete5 _complete6 _helpenv0 _helpenv2 _database_enumerate0 _system_inspect0 _logs_inspect0 _phases_inspect0 _summary_inspect0 _prologue_inspect0 _key_import_copy3 _key_collect_files2 _manifest_write2 _openpgp_record2 _openpgp_record3 _openpgp_prerequisites0 _openvpn_patch2 _openvpn_configure1 _openvpn_connect1 _openvpn_disconnect1 _openvpn_patch1 _openvpn_enumerate0 _openvpn_import1 _openvpn_list0 _openvpn_list1 _openvpn_add1 _openvpn_add3 _openvpn_link2 _openvpn_remove1 _openvpn_remove2 _openvpn_validate0 _openvpn_info1 _phases_copy0 _provenance_serial1 _provenance_file5 _provenance_import_copy2 _provenance_collect0 _provenance_list1 _session_list0 _session_show1 _session_remove1 _session_connect1 _session_save0 _session_save1 _session_clean0 _session_refresh0 _session_validate0 _session_enumerate0 _session_describe0 _session_create1 _interface_destroy1 _session_populate3 _logs_validate0 _logs_clean0 _phases_validate0 _database_validate0 _binaries_validate0 _network_validate0 _permissions_validate0 _environment_validate0 _install_validate0 _version_validate0 _sudo_validate0 _wireguard_patch2 _wireguard_configure1 _wireguard_connect1 _wireguard_disconnect1 _wireguard_enumerate0 _wireguard_import1 _wireguard_list0 _wireguard_list1 _wireguard_add1 _wireguard_add3 _wireguard_link2 _wireguard_remove1 _wireguard_remove2 _wireguard_validate0 _wireguard_patch1 _wireguard_info1 __export3 __export2 __export_redacted2 __export_full2 __export_minimized2 __export_pair3 __dump_readable1 __archive_key_pinned0 __restore1 __restore2 __restore_v12 __restore_v13 __restore_v14 __restore_v15 __restore3 __restore_serial_admissible3 __restore_base_exists1 __restore_replay2 __filter2 __filter_default2 __filter3 __filter_redacted2 __filter_full2 __filter_minimized2 __collection_readable1 __archiver_set0 __bundle_archive_absolute1 __bundle_manifest_beside1 __bundle_collection_inside1 __bundle_readable1 __seal_absent1 __attest_absent1 __seal_present1 __seal_matches2 __incoming_clear1 __extractor_set0 __extract_destination_absolute1 __extract_destination_empty1 __import2 __import_bootstrap2 __import3 __import4 __import5 __import6 __attest_file_exists1 __openpgp_record_complete1 __attest_signer_pinned2 __bootstrap1 __environment_cache0 __phases_lift1 __init0 __dump0 __batch1 __import1 __helpenv1 __setintp2 __getintp1 __unsetintp1 __helpintp1 __helpintp2 __key_name_valid1 __base_element_exists1 __key_record_exists2 __manifest_named1 __manifest_entries_hashable1 __manifest_readable1 __manifest_base_exists1 __manifest_tree_matches2 __openpgp_keyid_valid1 __openpgp_import2 __openpgp_collect1 __openvpn_start0 __openvpn_start1 __openvpn_stop1 __openvpn_add2 __openvpn_help0 __provenance_serial0 __provenance_add2 __provenance_add3 __provenance_add4 __provenance_add5 __provenance_add6 __provenance_bundle_readable1 __provenance_receipt5 __provenance_receipt_same3 __provenance_id_valid1 __provenance_import_place2 __provenance_list0 __session_disconnect1 __session_stop1 __session_show0 __session_start0 __session_start1 __session_help0 __session_exists1 __session_conflicts2 __interface_check1 __stop1 __start1 __start0 __wireguard_start0 __wireguard_start1 __wireguard_stop1 __wireguard_add2 __wireguard_help0 ___collect0 ___export_pair4 ___restore_v16 ___bundle2 ___seal2 ___seal_verify2 ___extract2 ___import_bootstrap5 ___import_bootstrap6 ___import7 ___attest2 ___attest_verify2 ___bootstrap2 ___environment_refresh0 ___init1 ___dumpenv1 ___dump1 ___batch0 ___sync0 ___list0 ___environment_inspect0 ___inspect0 ___key_import3 ___manifest2 ___manifest_attest2 ___manifest_verify3 ___manifest_match2 ___openpgp_add2 ___openpgp_add3 ___openpgp_dump0 ___openpgp_collect0 ___openvpn_create1 ___openvpn_stop0 ___openvpn_inspect0 ___openvpn_clean0 ___openvpn_dump0 ___phases_sync1 ___phases_sync0 ___provenance_add7 ___provenance_import2 ___provenance_dump0 ___session_stop0 ___session_switch1 ___session_switch2 ___session_dump0 ___session_inspect0 ___session_prepare3 ___stop0 ___validate0 ___wireguard_create1 ___wireguard_inspect0 ___wireguard_stop0 ___wireguard_clean0 ___wireguard_clean1 ___wireguard_dump0" # Function-to-module mapping (format: "func:module.sh func:module.sh ...") # Used by process_arguments() for deterministic module loading FUNCTION_MODULES="___collect0:archive.sh _wireguard_collect0:archive.sh _openvpn_collect0:archive.sh __export3:archive.sh __export2:archive.sh __export_redacted2:archive.sh __export_full2:archive.sh __export_minimized2:archive.sh __export_pair3:archive.sh _export_fresh1:archive.sh ___export_pair4:archive.sh _dump_header1:archive.sh __dump_readable1:archive.sh __archive_key_pinned0:archive.sh __restore1:archive.sh __restore2:archive.sh __restore_v12:archive.sh __restore_v13:archive.sh __restore_v14:archive.sh __restore_v15:archive.sh ___restore_v16:archive.sh _restore_prune2:archive.sh __restore3:archive.sh __restore_serial_admissible3:archive.sh __restore_base_exists1:archive.sh __restore_replay2:archive.sh __filter2:archive.sh __filter_default2:archive.sh __filter3:archive.sh __filter_redacted2:archive.sh __filter_full2:archive.sh __filter_minimized2:archive.sh _filter_write3:archive.sh ___bundle2:archive.sh __collection_readable1:archive.sh __archiver_set0:archive.sh __bundle_archive_absolute1:archive.sh __bundle_manifest_beside1:archive.sh __bundle_collection_inside1:archive.sh _bundle_pack2:archive.sh ___seal2:archive.sh __bundle_readable1:archive.sh __seal_absent1:archive.sh __attest_absent1:archive.sh _seal_write2:archive.sh ___seal_verify2:archive.sh __seal_present1:archive.sh __seal_matches2:archive.sh __incoming_clear1:archive.sh _incoming_remove1:archive.sh ___extract2:archive.sh __extractor_set0:archive.sh __extract_destination_absolute1:archive.sh __extract_destination_empty1:archive.sh _extract_unpack2:archive.sh __import2:archive.sh __import_bootstrap2:archive.sh ___import_bootstrap5:archive.sh ___import_bootstrap6:archive.sh __import3:archive.sh __import4:archive.sh __import5:archive.sh __import6:archive.sh ___import7:archive.sh ___attest2:attest.sh __attest_file_exists1:attest.sh __openpgp_record_complete1:attest.sh _attest_sign2:attest.sh ___attest_verify2:attest.sh __attest_signer_pinned2:attest.sh __bootstrap1:database.sh ___bootstrap2:database.sh _lift1:database.sh _database_init0:database.sh _environment_init1:database.sh ___environment_refresh0:database.sh __environment_cache0:database.sh _environment_cache1:database.sh _lift3:database.sh __phases_lift1:database.sh ___init1:database.sh __init0:database.sh _setenv2:database.sh _getenv1:database.sh _unsetenv1:database.sh ___dumpenv1:database.sh __dump0:database.sh ___dump1:database.sh _cat1:database.sh ___batch0:database.sh __batch1:database.sh _batch2:database.sh _printenv0:database.sh ___sync0:database.sh _env_sync0:database.sh _version_sync0:database.sh _version0:database.sh __import1:database.sh ___list0:database.sh _link2:database.sh _remove1:database.sh _status0:help.sh _help0:help.sh _help1:help.sh _help2:help.sh _complete0:help.sh _complete1:help.sh _complete2:help.sh _complete3:help.sh _complete4:help.sh _complete5:help.sh _complete6:help.sh _helpenv0:helpenv.sh __helpenv1:helpenv.sh _helpenv2:helpenv.sh __setintp2:helpenv.sh __getintp1:helpenv.sh __unsetintp1:helpenv.sh __helpintp1:helpenv.sh __helpintp2:helpenv.sh _database_enumerate0:inspect.sh ___environment_inspect0:inspect.sh _system_inspect0:inspect.sh _logs_inspect0:inspect.sh _phases_inspect0:inspect.sh _summary_inspect0:inspect.sh _prologue_inspect0:inspect.sh ___inspect0:inspect.sh __key_name_valid1:keys.sh ___key_import3:keys.sh __base_element_exists1:keys.sh _key_import_copy3:keys.sh _key_collect_files2:keys.sh __key_record_exists2:keys.sh ___manifest2:manifest.sh __manifest_named1:manifest.sh __manifest_entries_hashable1:manifest.sh _manifest_write2:manifest.sh ___manifest_attest2:manifest.sh ___manifest_verify3:manifest.sh ___manifest_match2:manifest.sh __manifest_readable1:manifest.sh __manifest_base_exists1:manifest.sh __manifest_tree_matches2:manifest.sh ___openpgp_add2:openpgp.sh ___openpgp_add3:openpgp.sh __openpgp_keyid_valid1:openpgp.sh _openpgp_record2:openpgp.sh _openpgp_record3:openpgp.sh _openpgp_prerequisites0:openpgp.sh ___openpgp_dump0:openpgp.sh __openpgp_import2:openpgp.sh ___openpgp_collect0:openpgp.sh __openpgp_collect1:openpgp.sh __openvpn_start0:openvpn.sh __openvpn_start1:openvpn.sh ___openvpn_create1:openvpn.sh _openvpn_patch2:openvpn.sh _openvpn_configure1:openvpn.sh _openvpn_connect1:openvpn.sh ___openvpn_stop0:openvpn.sh __openvpn_stop1:openvpn.sh _openvpn_disconnect1:openvpn.sh _openvpn_patch1:openvpn.sh ___openvpn_inspect0:openvpn.sh _openvpn_enumerate0:openvpn.sh _openvpn_import1:openvpn.sh _openvpn_list0:openvpn.sh _openvpn_list1:openvpn.sh _openvpn_add1:openvpn.sh __openvpn_add2:openvpn.sh _openvpn_add3:openvpn.sh _openvpn_link2:openvpn.sh _openvpn_remove1:openvpn.sh _openvpn_remove2:openvpn.sh _openvpn_validate0:openvpn.sh ___openvpn_clean0:openvpn.sh _openvpn_info1:openvpn.sh ___openvpn_dump0:openvpn.sh __openvpn_help0:openvpn.sh _phases_copy0:phase.sh ___phases_sync1:phase.sh ___phases_sync0:phase.sh __provenance_serial0:provenance.sh _provenance_serial1:provenance.sh __provenance_add2:provenance.sh __provenance_add3:provenance.sh __provenance_add4:provenance.sh __provenance_add5:provenance.sh __provenance_add6:provenance.sh ___provenance_add7:provenance.sh __provenance_bundle_readable1:provenance.sh __provenance_receipt5:provenance.sh __provenance_receipt_same3:provenance.sh _provenance_file5:provenance.sh ___provenance_import2:provenance.sh __provenance_id_valid1:provenance.sh __provenance_import_place2:provenance.sh _provenance_import_copy2:provenance.sh ___provenance_dump0:provenance.sh _provenance_collect0:provenance.sh __provenance_list0:provenance.sh _provenance_list1:provenance.sh __session_disconnect1:session.sh ___session_stop0:session.sh __session_stop1:session.sh _session_list0:session.sh __session_show0:session.sh _session_show1:session.sh _session_remove1:session.sh __session_start0:session.sh __session_start1:session.sh ___session_switch1:session.sh ___session_switch2:session.sh _session_connect1:session.sh _session_save0:session.sh _session_save1:session.sh _session_clean0:session.sh _session_refresh0:session.sh _session_validate0:session.sh ___session_dump0:session.sh __session_help0:session.sh ___session_inspect0:session.sh _session_enumerate0:session.sh _session_describe0:session.sh _session_create1:session.sh __session_exists1:session.sh __session_conflicts2:session.sh _interface_destroy1:session.sh __interface_check1:session.sh ___session_prepare3:session.sh _session_populate3:session.sh ___stop0:session.sh __stop1:session.sh __start1:session.sh __start0:session.sh _logs_validate0:validate.sh _logs_clean0:validate.sh _phases_validate0:validate.sh _database_validate0:validate.sh _binaries_validate0:validate.sh _network_validate0:validate.sh _permissions_validate0:validate.sh _environment_validate0:validate.sh _install_validate0:validate.sh _version_validate0:validate.sh _sudo_validate0:validate.sh ___validate0:validate.sh __wireguard_start0:wireguard.sh __wireguard_start1:wireguard.sh ___wireguard_create1:wireguard.sh _wireguard_patch2:wireguard.sh _wireguard_configure1:wireguard.sh _wireguard_connect1:wireguard.sh _wireguard_disconnect1:wireguard.sh ___wireguard_inspect0:wireguard.sh _wireguard_enumerate0:wireguard.sh ___wireguard_stop0:wireguard.sh __wireguard_stop1:wireguard.sh _wireguard_import1:wireguard.sh _wireguard_list0:wireguard.sh _wireguard_list1:wireguard.sh _wireguard_add1:wireguard.sh __wireguard_add2:wireguard.sh _wireguard_add3:wireguard.sh _wireguard_link2:wireguard.sh _wireguard_remove1:wireguard.sh _wireguard_remove2:wireguard.sh _wireguard_validate0:wireguard.sh ___wireguard_clean0:wireguard.sh ___wireguard_clean1:wireguard.sh _wireguard_patch1:wireguard.sh _wireguard_info1:wireguard.sh ___wireguard_dump0:wireguard.sh __wireguard_help0:wireguard.sh" # === END AUTO-GENERATED === # vpn-switch Unit Test Suite - Formalized Edition # # Principles: # - One systematic way, no exceptions # - Every test uses 'vpn-switch bootstrap' for database creation # - test_setup() does minimal, universal setup only # - Each test is self-contained and tailors its own environment # - No cleanup - leave artifacts for debugging # - Per-test tracing and output capture # # POSIX shell compliant # set -e set -u #----------------------------------------------------------------------------- # Test Configuration (Prologue) #----------------------------------------------------------------------------- # Test base directory - evaluated once at script start # Uses TMPDIR if set, otherwise /tmp (making TMPDIR dependency explicit) TEST_BASE_DIR="${TMPDIR:-/tmp}/vpn-switch-test.$$" # Test script location TEST_SCRIPT="./vpn-switch.sh" # Profile for bootstrap (can be overridden via command-line argument) # Default: minimal (only essential variables) # Alternative: all (all .env-templates installed - quality gate for tests) # # Test filtering (optional): Specify test function names to run only those tests # # Usage: # ./vpn-switch-unit-test.sh # minimal profile, all tests # ./vpn-switch-unit-test.sh all # all profile, all tests # ./vpn-switch-unit-test.sh minimal test_batch_validate_includes_system # ./vpn-switch-unit-test.sh all test_session_list test_session_show # Options (before the positional args): --maxprocs N runs the top-level # test functions in parallel (xargs -P N, each in its own suite process and # sandbox); default 1 = the classic sequential run. (Ported from elebake.) MAXPROCS=1 while [ $# -gt 0 ]; do case "$1" in --maxprocs) MAXPROCS="${2:?--maxprocs needs a value}"; shift 2 ;; --maxprocs=*) MAXPROCS="${1#--maxprocs=}"; shift ;; *) break ;; esac done TEST_PROFILE="${1:-minimal}" [ $# -ge 1 ] && shift # Dynamically generate list of all test functions ALL_TESTS=$(grep -o '^test_[a-z_]*()' "$0" | sed 's/()$//' | tr '\n' ' ') # Collect test filter arguments (optional test function names) TEST_FILTER="$*" # If no filter specified, run all tests if [ -z "$TEST_FILTER" ]; then TEST_FILTER="$ALL_TESTS" fi # Source platform-specific commands (EXAMINE_*, MODIFY_*) # Uses the default symlink which points to the correct platform PLATFORM_FILE="./template/platform/default" if [ -f "$PLATFORM_FILE" ]; then . "$PLATFORM_FILE" else echo "ERROR: Platform file not found: $PLATFORM_FILE" >&2 echo "Run this script from the repository root" >&2 exit 1 fi #----------------------------------------------------------------------------- # Test Framework Infrastructure #----------------------------------------------------------------------------- # should_run_test - Run test if it matches the filter # # Args: $1 - test function name (e.g., "test_batch_validate_includes_system") # # Returns: Always 0 (to prevent set -e from exiting when test is skipped) # # Usage: # should_run_test test_session_list # should_run_test() { local test_name="$1" # Check if test_name is in the filter list for filter_test in $TEST_FILTER; do if [ "$filter_test" = "$test_name" ]; then "$test_name" return 0 fi done # Test not in filter list - skip it return 0 } # Test counters TESTS_RUN=0 TESTS_PASSED=0 TESTS_FAILED=0 # Color support - always use escape codes # Using literal escape codes for portability (tput output varies across systems) # All color output uses printf '%b' to interpret escape sequences # Note: Always write escape codes so log files can be viewed with color # using tools like 'tail -f', 'less -R', or terminal emulators COLOR_GREEN='\033[1;32m' COLOR_RED='\033[1;31m' COLOR_YELLOW='\033[1;33m' COLOR_BLUE='\033[1;34m' COLOR_RESET='\033[0m' # Global test directory (set by test_setup, used by all tests) TEST_DIR="" # pass - Mark assertion as passed pass() { TESTS_PASSED=$((TESTS_PASSED + 1)) printf '%b✓%b %s\n' "$COLOR_GREEN" "$COLOR_RESET" "$*" } # fail - Mark assertion as failed fail() { TESTS_FAILED=$((TESTS_FAILED + 1)) printf '%b✗%b %s\n' "$COLOR_RED" "$COLOR_RESET" "$*" } # get_file_perms - Get file permissions in octal (cross-platform) # Uses EXAMINE_FILE_PERMS from platform script (e.g., "stat -f %Lp" on FreeBSD) get_file_perms() { local file="$1" $EXAMINE_FILE_PERMS "$file" 2>/dev/null } # get_file_perms_full - Get file permissions in octal (cross-platform) # Uses EXAMINE_FILE_PERMS from platform script # Returns just permission bits (e.g., "600") - tests accept both "100600" and "600" formats get_file_perms_full() { local file="$1" $EXAMINE_FILE_PERMS "$file" 2>/dev/null } # test_header - Print test header # # Args: $1 - Test description # $2 - Optional: Function name (for easy test selection) # test_header() { local description="$1" local function_name="${2:-}" TESTS_RUN=$((TESTS_RUN + 1)) echo "" if [ -n "$function_name" ]; then printf '%bTEST %s (%s):%b %s\n' "$COLOR_BLUE" "$TESTS_RUN" "$function_name" "$COLOR_RESET" "$description" else printf '%bTEST %s:%b %s\n' "$COLOR_BLUE" "$TESTS_RUN" "$COLOR_RESET" "$description" fi } # test_summary - Print test summary test_summary() { local gh for gh in /tmp/vsut-gh-$$-*; do [ -d "$gh" ] && GNUPGHOME="$gh" gpgconf --kill all 2>/dev/null && rm -rf "$gh"; done local test_functions=$TESTS_RUN local assertions_passed=$TESTS_PASSED local assertions_failed=$TESTS_FAILED local total_assertions=$((assertions_passed + assertions_failed)) echo "" echo "========================================" echo "Test Summary" echo "========================================" echo "Test Functions: $test_functions" echo "Total Assertions: $total_assertions" printf '%bPassed Assertions: %s%b\n' "$COLOR_GREEN" "$assertions_passed" "$COLOR_RESET" if [ "$assertions_failed" -gt 0 ]; then printf '%bFailed Assertions: %s%b\n' "$COLOR_RED" "$assertions_failed" "$COLOR_RESET" else echo "Failed Assertions: $assertions_failed" fi echo "" if [ "$assertions_failed" -gt 0 ]; then printf '%bSOME TESTS FAILED%b\n' "$COLOR_RED" "$COLOR_RESET" echo "" echo "Test artifacts preserved in: $TEST_BASE_DIR" echo "To clean up: rm -rf $TEST_BASE_DIR" return 1 else printf '%bALL TESTS PASSED%b\n' "$COLOR_GREEN" "$COLOR_RESET" return 0 fi } #----------------------------------------------------------------------------- # Universal Test Setup (Minimal, Systematic, No Exceptions) #----------------------------------------------------------------------------- # test_setup - Create EXCLUSIVE test database for THIS test # # Principles: # - Each test gets its own exclusive database directory # - Uses 'vpn-switch bootstrap' to create database (systematic, no exceptions) # - bootstrap creates directory and initializes database in one command # - NO test-specific configuration (tests do that themselves) # # Returns: Sets TEST_DIR to the exclusive database directory for this test # test_setup() { # Create exclusive test directory for THIS test local test_num=$TESTS_RUN TEST_DIR="$TEST_BASE_DIR/test_${test_num}" # Show database path for debugging printf ' %bDatabase:%b %s\n' "$COLOR_BLUE" "$COLOR_RESET" "$TEST_DIR" # Run 'vpn-switch bootstrap' to create database # Bootstrap creates the directory and initializes the database structure # This is the ONLY way we create test databases (systematic, no exceptions) # Profile comes from TEST_PROFILE (set via command-line argument or default) mkdir -p "$TEST_BASE_DIR" local bootstrap_log="$TEST_BASE_DIR/test_${test_num}.bootstrap.log" # Set minimal PATH for bootstrap operations (needed for run_env before database exists) export VPN_SWITCH_PATH="/bin:/usr/bin:/usr/local/bin" if ! "$TEST_SCRIPT" bootstrap "$TEST_DIR" "$TEST_PROFILE" > "$bootstrap_log" 2>&1; then printf '%bERROR:%b vpn-switch bootstrap failed for test %s (profile: %s)\n' "$COLOR_RED" "$COLOR_RESET" "$test_num" "$TEST_PROFILE" printf '%bBootstrap output:%b\n' "$COLOR_YELLOW" "$COLOR_RESET" cat "$bootstrap_log" | sed 's/^/ /' echo "" echo "Full log saved to: $bootstrap_log" exit 1 fi # Simulate experienced user who overrides safety default # Both minimal and all profiles ship TERMINAL_INTERPRETER=cat (safe by default) # Tests simulate user who has opted into auto-execution for convenience # Use setenv to configure (black-box approach - tests the actual user interface) VPN_SWITCH_BASE="$TEST_DIR" "$TEST_SCRIPT" setenv VPN_SWITCH_TERMINAL_INTERPRETER sh > /dev/null 2>&1 VPN_SWITCH_BASE="$TEST_DIR" "$TEST_SCRIPT" setenv VPN_SWITCH_INTERPRETER_interface_destroy cat > /dev/null 2>&1 # Set minimal PATH for vpn-switch operations VPN_SWITCH_BASE="$TEST_DIR" "$TEST_SCRIPT" setenv VPN_SWITCH_PATH "/bin:/usr/bin:/usr/local/bin" > /dev/null 2>&1 # Copy test configs into database (if available) if [ -d "test-configs/wireguard" ]; then cp test-configs/wireguard/*.conf "$TEST_DIR/wireguard/" 2>/dev/null || true chmod 0400 "$TEST_DIR/wireguard"/*.conf 2>/dev/null || true fi } # setup_session_test_mocks - Mock external commands for session operation tests # # Layer 2/3 tests (start, connect, stop, session operations) need to mock # external commands to avoid actually executing network operations. # # Args: $1 - Optional test ID for unique interface names (e.g., "13", "14") # If provided, interfaces will be named wg_test, tun_test # If omitted, uses default wg9, tun9 (backward compatible) # # Uses .env/local/ hierarchy (proper override location) to mock: # - VPN_SWITCH_BINARY_wireguard: wg-quick → echo wg-quick (mock network interface ops) # - VPN_SWITCH_PFCTL: pfctl → echo pfctl (mock firewall ops) # - VPN_SWITCH_INSTALL: install → echo install (mock file installation ops) # - VPN_SWITCH_WG_INTERFACE: wg0 → wg_test (unique test interface name) # # Must be called AFTER test_setup() for tests that need command mocking. # setup_session_test_mocks() { local test_id="${1:-9}" # Default to 9 for backward compatibility # Determine interface names based on test_id local wg_interface="wg_test${test_id}" local ovpn_interface="tun_test${test_id}" # Mock wg-quick to avoid actually creating network interfaces run_vpn_switch setenv VPN_SWITCH_BINARY_wireguard "echo wg-quick" > /dev/null 2>&1 # Mock openvpn to avoid actually creating VPN tunnels run_vpn_switch setenv VPN_SWITCH_BINARY_openvpn "echo openvpn" > /dev/null 2>&1 # Mock pfctl to avoid actually modifying firewall rules run_vpn_switch setenv VPN_SWITCH_PFCTL "echo pfctl" > /dev/null 2>&1 # Mock install to avoid actually writing to system directories run_vpn_switch setenv VPN_SWITCH_INSTALL "echo install" > /dev/null 2>&1 # Use unique test interface names to avoid conflicts between tests run_vpn_switch setenv VPN_SWITCH_INTERFACE_wireguard "$wg_interface" > /dev/null 2>&1 run_vpn_switch setenv VPN_SWITCH_INTERFACE_openvpn "$ovpn_interface" > /dev/null 2>&1 # Set resolvconf script paths (base path, scripts append -up/-down) run_vpn_switch setenv VPN_SWITCH_RESOLVCONF_wireguard "/usr/local/sbin/wg-resolvconf" > /dev/null 2>&1 run_vpn_switch setenv VPN_SWITCH_RESOLVCONF_openvpn "/usr/local/sbin/ovpn-resolvconf" > /dev/null 2>&1 } #----------------------------------------------------------------------------- # Helper Functions #----------------------------------------------------------------------------- # set_function_interpreter - Set interpreter for a specific function # # Reduces code duplication by centralizing the pattern of setting function-specific # interpreters in .env/local/ directory. # # Args: $1 - Function name (e.g., "wireguard_connect", "session_list") # $2 - Interpreter value (e.g., "cat", "sh", "echo") # # Creates: $TEST_DIR/.env/local/VPN_SWITCH_INTERPRETER_ # # Usage: # set_function_interpreter "wireguard_connect" "cat" # set_function_interpreter "session_list" "sh" # set_function_interpreter() { local func_name="$1" local interpreter="$2" # Use setenv to configure interpreter (black-box approach) run_vpn_switch setenv "VPN_SWITCH_INTERPRETER_${func_name}" "$interpreter" > /dev/null 2>&1 } # override_for_command_inspection - Override interpreter for command inspection # # Tests that need to inspect command output (grep for specific commands) must # explicitly override the interpreter to 'cat' regardless of profile. # # Args: $1 - Function name (without underscore prefix) # # This helper ensures tests are profile-independent by setting function-specific # interpreter (highest priority in resolution chain), which overrides intrinsic # classification (TERMINAL_INTERPRETER). # # Usage: # test_setup # override_for_command_inspection "link" # output=$(VPN_SWITCH_BASE="$TEST_DIR" "$TEST_SCRIPT" link testproto wireguard) # grep "ln -s" <<< "$output" # Works with ANY profile # override_for_command_inspection() { local func="$1" if [ -z "$func" ]; then printf '%bERROR:%b override_for_command_inspection requires function name\n' "$COLOR_RED" "$COLOR_RESET" exit 1 fi # Set function-specific interpreter to cat (highest priority) # This overrides both TERMINAL_INTERPRETER and intrinsic classification set_function_interpreter "$func" "cat" } # run_vpn_switch - Execute vpn-switch.sh through the user interface # # Arguments: # $1-N: command arguments to pass to vpn-switch.sh # # Environment: # Uses only variables a normal user would set: # - VPN_SWITCH_BASE: database location (always set in tests) # - VPN_SWITCH_DISPLAY_ANSI: disable colors for test parsing # # Log/trace files are automatically created in database's .log/ directory # based on LOG_RETENTION_DAYS and TRACE_RETENTION_DAYS settings (default: 1 day) # # Sets up per-test output capture: # - Output saved to: $TEST_BASE_DIR/traces/output_${TESTS_RUN}.log # # Returns: captured stdout and stderr # run_vpn_switch() { # Create traces directory for test output capture local test_num=$TESTS_RUN local traces_dir="$TEST_BASE_DIR/traces" mkdir -p "$traces_dir" # Set up per-test output file for assertions local output_file="$traces_dir/output_${test_num}.log" # Execute vpn-switch exactly as a user would # Only user-facing environment variables: # - VPN_SWITCH_BASE: where the database lives # - VPN_SWITCH_DISPLAY_ANSI: disable colors for grep parsing # Note: Log/trace files are automatically managed by retention settings VPN_SWITCH_BASE="$TEST_DIR" \ VPN_SWITCH_DISPLAY_ANSI=0 \ "$TEST_SCRIPT" "$@" 2>&1 | tee "$output_file" } # unit_attest_key -- a throwaway OpenPGP key in a home short enough # for gpg-agent's socket, registered in the test database and pinned as the # archive attest key. Sets UNIT_GNUPGHOME and UNIT_FPR; returns 1 (and # passes with a note) when gpg is absent. (Ported from elebake.) unit_attest_key() { command -v gpg > /dev/null 2>&1 || { pass "gpg absent -- signing checks skipped"; return 1; } UNIT_GNUPGHOME="/tmp/vsut-gh-$$-$TESTS_RUN" rm -rf "$UNIT_GNUPGHOME"; mkdir -p "$UNIT_GNUPGHOME" && chmod 700 "$UNIT_GNUPGHOME" GNUPGHOME="$UNIT_GNUPGHOME" gpg --batch --passphrase '' --pinentry-mode loopback \ --quick-generate-key "Unit $TESTS_RUN " rsa2048 sign never > /dev/null 2>&1 UNIT_FPR=$(GNUPGHOME="$UNIT_GNUPGHOME" gpg --list-secret-keys --with-colons 2>/dev/null | awk -F: '/^fpr:/{print $10; exit}') [ -n "$UNIT_FPR" ] || { fail "could not create a test key (gpg-agent socket path too long?)"; return 1; } run_vpn_switch openpgp add "$1" "$UNIT_FPR" "$UNIT_GNUPGHOME" > /dev/null 2>&1 run_vpn_switch setenv VPN_SWITCH_ARCHIVE_ATTEST_KEY "$1" > /dev/null 2>&1 return 0 } test_restore_pin_in_prologue() { test_header "restore: a pin in the prologue governs the lines after environment refresh in the same replay" "test_restore_pin_in_prologue" test_setup unit_attest_key unit || return 0 local dump="$TEST_BASE_DIR/unit-restore-pin-$TESTS_RUN.sh" c="$TEST_BASE_DIR/restore-pin-$TESTS_RUN.conf" rc=0 out printf '[Interface]\nPrivateKey = x\nAddress = 10.0.0.2/32\n[Peer]\nPublicKey = y\nEndpoint = 1.2.3.4:51820\nAllowedIPs = 0.0.0.0/0\n' > "$c" printf '# vpn-switch database dump\n# Version: 1\n# Serial: 1\n' > "$dump" printf '%s\n' '"$VPN_SWITCH_CONTEXT_SCRIPT" setenv VPN_SWITCH_INTERPRETER_wireguard_import1 cat' \ '"$VPN_SWITCH_CONTEXT_SCRIPT" environment refresh' \ "\"\$VPN_SWITCH_CONTEXT_SCRIPT\" wireguard import $c" >> "$dump" run_vpn_switch attest "$dump" unit > /dev/null 2>&1 out=$(run_vpn_switch restore "$dump" 2>&1) || rc=$? if [ "$rc" -eq 0 ] && [ "$(head -1 "$TEST_DIR/.env/local/VPN_SWITCH_INTERPRETER_wireguard_import1" 2>/dev/null)" = cat ] \ && [ ! -f "$TEST_DIR/wireguard/${c##*/}" ] && printf '%s\n' "$out" | grep -q "^# Import WireGuard config: ${c##*/}"; then pass "the pin acted, the import after the refresh was displayed, the replay succeeded" else fail "restore with a prologue pin: rc=$rc imported=$(ls "$TEST_DIR/wireguard" 2>/dev/null | tr '\n' ' ') $(printf '%s\n' "$out" | grep -i 'error' | head -2 | tr '\n' ' ')" fi } test_export_import_pair() { test_header "export/import: the signed pair moves the database -- dump + bundle, sealed, pinned signer, receipt, serial floor" "test_export_import_pair" test_setup unit_attest_key unit || return 0 local src="$TEST_DIR" dst="$TEST_BASE_DIR/pair-dst-$TESTS_RUN" out="$TEST_BASE_DIR/pair-out-$TESTS_RUN" o mkdir -p "$out" printf '[Interface]\nPrivateKey = x\nAddress = 10.0.0.2/32\n[Peer]\nPublicKey = y\nEndpoint = 1.2.3.4:51820\nAllowedIPs = 0.0.0.0/0\n' > "$out/ch-zrh-01.conf" run_vpn_switch wireguard import "$out/ch-zrh-01.conf" > /dev/null 2>&1 run_vpn_switch wireguard add privacy > /dev/null 2>&1 run_vpn_switch wireguard add privacy ch-zrh-01.conf > /dev/null 2>&1 if run_vpn_switch dump 2>/dev/null | grep -q '^# Version: 1$' \ && run_vpn_switch dump 2>/dev/null | grep -q 'wireguard import "\$VPN_SWITCH_ARCHIVE_BASE/wireguard/ch-zrh-01.conf"'; then pass "the dump carries the format version and names its base elements against the archive base" else fail "dump header/paths wrong: $(run_vpn_switch dump 2>/dev/null | grep -n 'Version\|wireguard import')" fi o=$(run_vpn_switch export full "$out/vs.sh" "$out/vs.tar.gz" 2>&1) || true if [ -f "$out/vs.sh" ] && [ -f "$out/vs.sh.asc" ] && [ -f "$out/vs.tar.gz" ] \ && grep -q '^# Bundle: sha256=' "$out/vs.sh" && [ "$(cat "$src/export/serial")" = 1 ]; then pass "export writes dump, signature and bundle, seals the pair and advances the serial to 1" else fail "export incomplete: $o" fi if tar -tzf "$out/vs.tar.gz" | grep -q 'wireguard/ch-zrh-01.conf' && tar -tzf "$out/vs.tar.gz" | grep -q 'export/MANIFEST.asc'; then pass "the bundle carries the configuration and the attested MANIFEST" else fail "bundle content wrong: $(tar -tzf "$out/vs.tar.gz")" fi run_vpn_switch export redacted "$out/red.sh" "$out/red.tar.gz" > /dev/null 2>&1 if [ -f "$out/red.tar.gz" ] && ! tar -tzf "$out/red.tar.gz" | grep -q 'wireguard/ch-zrh-01.conf' \ && grep -q "dropped (secret).*ch-zrh-01.conf" "$TEST_DIR/export/collection" \ && grep -q "wireguard add privacy" "$out/red.sh"; then pass "export redacted: the description travels complete, the configuration (the secret) stays home, auditable in the collection" else fail "redacted export wrong: $(tar -tzf "$out/red.tar.gz" 2>&1; grep dropped "$TEST_DIR/export/collection")" fi run_vpn_switch export minimized "$out/min.sh" "$out/min.tar.gz" > /dev/null 2>&1 if tar -tzf "$out/min.tar.gz" | grep -q 'wireguard/ch-zrh-01.conf' && tar -tzf "$out/min.tar.gz" | grep -q 'openpgp/unit/keyid'; then pass "export minimized: the configurations and the key travel" else fail "minimized export wrong: $(tar -tzf "$out/min.tar.gz" 2>&1)" fi if run_vpn_switch export bogus "$out/x.sh" "$out/x.tar.gz" 2>&1 | grep -q "unknown strategy bogus"; then pass "an unknown strategy is refused by name" else fail "unknown strategy accepted" fi # the receiving database: fresh, the sender's key pinned VPN_SWITCH_PATH="/bin:/usr/bin:/usr/local/bin" "$TEST_SCRIPT" bootstrap "$dst" "$TEST_PROFILE" > /dev/null 2>&1 || true VPN_SWITCH_BASE="$dst" "$TEST_SCRIPT" setenv VPN_SWITCH_TERMINAL_INTERPRETER sh > /dev/null 2>&1 VPN_SWITCH_BASE="$dst" "$TEST_SCRIPT" setenv VPN_SWITCH_INTERPRETER_interface_destroy cat > /dev/null 2>&1 o=$(VPN_SWITCH_BASE="$dst" VPN_SWITCH_DISPLAY_ANSI=0 "$TEST_SCRIPT" import "$out/vs.sh" "$out/vs.tar.gz" 2>&1) || true if printf '%s\n' "$o" | grep -q "ATTEST_KEY not set" && [ ! -f "$dst/wireguard/ch-zrh-01.conf" ]; then pass "import without a pinned signer is refused before anything lands" else fail "import without a pin: $o" fi VPN_SWITCH_BASE="$dst" "$TEST_SCRIPT" openpgp add unit "$UNIT_FPR" "$UNIT_GNUPGHOME" > /dev/null 2>&1 VPN_SWITCH_BASE="$dst" "$TEST_SCRIPT" setenv VPN_SWITCH_ARCHIVE_ATTEST_KEY unit > /dev/null 2>&1 o=$(VPN_SWITCH_BASE="$dst" VPN_SWITCH_DISPLAY_ANSI=0 "$TEST_SCRIPT" import "$out/vs.sh" "$out/vs.tar.gz" 2>&1) || true if [ -f "$dst/wireguard/ch-zrh-01.conf" ] && [ -L "$dst/wireguard/privacy/ch-zrh-01.conf" ] \ && [ "$(cat "$dst"/provenance/*/serial 2>/dev/null)" = 1 ] && [ "$(cat "$dst"/provenance/*/signer 2>/dev/null)" = "$UNIT_FPR" ] \ && [ "$(cat "$dst/export/serial")" = 1 ]; then pass "import rebuilt the configuration and the category in a fresh database, filed the receipt and continued the lineage" else fail "import result wrong: $o $(ls -R "$dst/wireguard" "$dst/provenance" 2>&1 | tr '\n' ' ')" fi o=$(VPN_SWITCH_BASE="$dst" VPN_SWITCH_DISPLAY_ANSI=0 "$TEST_SCRIPT" import "$out/vs.sh" "$out/vs.tar.gz" 2>&1) || true if printf '%s\n' "$o" | grep -q "already filed" && printf '%s\n' "$o" | grep -q "Config already exists"; then pass "the same pair again: the receipt stands, the replay reports what is there and goes on" else fail "re-import wrong: $o" fi # a database that is not there: import bootstraps it from the dump (its profile, the signer from the keyring the record names) local fresh="$TEST_BASE_DIR/pair-fresh-$TESTS_RUN/db" if grep -q "^# Profile: $TEST_PROFILE\$" "$out/vs.sh"; then pass "the dump header names the profile of its database" else fail "no profile line: $(head -n 6 "$out/vs.sh" | tr '\n' ' ')" fi o=$(VPN_SWITCH_BASE="$fresh" VPN_SWITCH_DISPLAY_ANSI=0 "$TEST_SCRIPT" import "$out/vs.sh" "$out/vs.tar.gz" 2>&1) || true if [ -f "$fresh/wireguard/ch-zrh-01.conf" ] && [ "$(cat "$fresh/openpgp/unit/keyid" 2>/dev/null)" = "$UNIT_FPR" ] \ && [ "$(cat "$fresh/.env/local/VPN_SWITCH_ARCHIVE_ATTEST_KEY" 2>/dev/null)" = unit ] \ && [ "$(cat "$fresh"/provenance/*/signer 2>/dev/null)" = "$UNIT_FPR" ]; then pass "import bootstrap: the database made with the dump's profile, the signer pinned under the dump's record name, the dump replayed, the receipt filed" else fail "import bootstrap: $(printf '%s\n' "$o" | grep -i error | head -3 | tr '\n' ' ') $(ls "$fresh" 2>&1 | tr '\n' ' ')" fi sed '/^# Profile:/d' "$out/vs.sh" > "$out/noprofile.sh"; cp "$out/vs.sh.asc" "$out/noprofile.sh.asc" o=$(VPN_SWITCH_BASE="$fresh-2" VPN_SWITCH_DISPLAY_ANSI=0 "$TEST_SCRIPT" import "$out/noprofile.sh" "$out/vs.tar.gz" 2>&1) || true if printf '%s\n' "$o" | grep -q "names no profile" && [ ! -d "$fresh-2/.env" ]; then pass "a dump without a profile line cannot bootstrap: refused, nothing made" else fail "no-profile dump: $o" fi run_vpn_switch export full "$out/vs2.sh" "$out/vs2.tar.gz" > /dev/null 2>&1 VPN_SWITCH_BASE="$dst" "$TEST_SCRIPT" import "$out/vs2.sh" "$out/vs2.tar.gz" > /dev/null 2>&1 || true o=$(VPN_SWITCH_BASE="$dst" VPN_SWITCH_DISPLAY_ANSI=0 "$TEST_SCRIPT" import "$out/vs.sh" "$out/vs.tar.gz" 2>&1) || true if printf '%s\n' "$o" | grep -q "DOWNGRADE" && [ "$(cat "$dst/export/serial")" = "$(sed -n 's/^# Serial: //p' "$out/vs2.sh")" ]; then pass "after the later serial the validly signed serial-1 pair is refused as a downgrade" else fail "downgrade accepted: $o" fi cp "$out/vs2.sh" "$out/edited.sh"; cp "$out/vs2.sh.asc" "$out/edited.sh.asc"; printf '# x\n' >> "$out/edited.sh" o=$(VPN_SWITCH_BASE="$dst" VPN_SWITCH_DISPLAY_ANSI=0 "$TEST_SCRIPT" import "$out/edited.sh" "$out/vs2.tar.gz" 2>&1) || true if printf '%s\n' "$o" | grep -q "BAD SIGNATURE"; then pass "an edited dump is refused by its signature" else fail "tampered dump accepted: $o" fi printf 'x' >> "$out/vs2.tar.gz" o=$(VPN_SWITCH_BASE="$dst" VPN_SWITCH_DISPLAY_ANSI=0 "$TEST_SCRIPT" import "$out/vs2.sh" "$out/vs2.tar.gz" 2>&1) || true if printf '%s\n' "$o" | grep -q "MISMATCHED PAIR"; then pass "a bundle the dump does not name is refused by the seal" else fail "foreign bundle accepted: $o" fi run_vpn_switch dump > "$out/plain.sh" 2>/dev/null if run_vpn_switch restore "$out/plain.sh" 2>&1 | grep -q "unsigned" \ && run_vpn_switch restore "$out/vs2.sh" 2>&1 | grep -q "already exists"; then pass "restore takes the same admissibility path as import: an unsigned dump is refused, the attested one replays" else fail "restore path wrong: $(run_vpn_switch restore "$out/plain.sh" 2>&1 | head -2)" fi o=$(run_vpn_switch export full "$out/vs2.sh" "$out/vs2.tar.gz" 2>&1) || true if [ -f "$out/vs2.sh.asc" ] && ! printf '%s\n' "$o" | grep -q "already attested" && grep -q '^# Bundle: sha256=' "$out/vs2.sh"; then pass "a second export into the same paths succeeds -- the earlier signature is void, not an obstacle" else fail "second export: $(printf '%s\n' "$o" | grep -i 'error' | head -2 | tr '\n' ' ')" fi rm -rf "$UNIT_GNUPGHOME" } # validate_batch_syntax - Validate batch combinator output syntax (Issue #001) # # Args: $1 - output (batch combinator output to validate) # $2 - function_name (for error messages) # # Returns: 0 if valid, 1 if invalid (with error message) # # Validates that batch combinator output follows strict syntax rules: # - Non-comment lines must start with "$VPN_SWITCH_CONTEXT_SCRIPT" # - Variable must be literal (escaped $), not expanded # - No multiple commands per line (no ;, &&, ||) # - Comments and empty lines are allowed # validate_batch_syntax() { local output="$1" local function_name="$2" local line_num=0 echo "$output" | while IFS= read -r line; do line_num=$((line_num + 1)) # Skip empty lines [ -z "$line" ] && continue # Allow comments case "$line" in '#'*) continue ;; esac # Must start with literal "$VPN_SWITCH_CONTEXT_SCRIPT" if ! echo "$line" | grep -q '^"\$VPN_SWITCH_CONTEXT_SCRIPT"'; then echo "ERROR: $function_name line $line_num: Must start with \"\\\$VPN_SWITCH_CONTEXT_SCRIPT\"" echo " Got: $line" return 1 fi # Must NOT have expanded path (absolute paths indicate expansion) if echo "$line" | grep -qE '"/[^$].*vpn-switch'; then echo "ERROR: $function_name line $line_num: Variable expanded (not literal)" echo " Got: $line" return 1 fi # Must NOT have multiple commands per line if echo "$line" | grep -qE ';|&&|\|\|'; then echo "ERROR: $function_name line $line_num: Multiple commands per line" echo " Got: $line" return 1 fi done } #----------------------------------------------------------------------------- # Test Cases #----------------------------------------------------------------------------- # TEST 1: Basic printenv command (simplest possible test) test_complete_command() { test_header "complete - completion candidates from the help corpus" "test_complete_command" # Create exclusive database for this test test_setup # Top level: the first words of every command path, nothing else local output=$(run_vpn_switch complete "") if printf '%s\n' "$output" | grep -qx "wireguard" && printf '%s\n' "$output" | grep -qx "session" \ && printf '%s\n' "$output" | grep -qx "help" && printf '%s\n' "$output" | grep -qx "complete"; then pass "Top-level candidates list the command words" else fail "Top-level candidates incomplete: $(printf '%s' "$output" | tr '\n' ' ')" fi if printf '%s\n' "$output" | grep -q '[<>]'; then fail "Top-level candidates leak placeholders: $(printf '%s' "$output" | tr '\n' ' ')" else pass "No placeholder syntax leaks into candidates" fi # Prefix filtering on literal words output=$(run_vpn_switch complete wireguard st) if [ "$(printf '%s\n' "$output" | tr '\n' ' ')" = "start stop " ]; then pass "'wireguard st' completes to start and stop only" else fail "'wireguard st' gave: $(printf '%s' "$output" | tr '\n' ' ')" fi # Alternatives written as literals in the usage line output=$(run_vpn_switch complete environment cache "") if printf '%s\n' "$output" | grep -qx "on" && printf '%s\n' "$output" | grep -qx "off" && printf '%s\n' "$output" | grep -qx "status"; then pass "'environment cache' offers on|off|status" else fail "'environment cache' gave: $(printf '%s' "$output" | tr '\n' ' ')" fi # help completes command paths, group ids and topics output=$(run_vpn_switch complete help "") if printf '%s\n' "$output" | grep -qx "session" && printf '%s\n' "$output" | grep -qx "sessions" && printf '%s\n' "$output" | grep -qx "environment"; then pass "'help' completes command words, group ids and topics" else fail "'help' gave: $(printf '%s' "$output" | tr '\n' ' ')" fi output=$(run_vpn_switch complete help session sh) if [ "$(printf '%s\n' "$output" | tr '\n' ' ')" = "show " ]; then pass "'help session sh' completes the command path" else fail "'help session sh' gave: $(printf '%s' "$output" | tr '\n' ' ')" fi # Placeholders resolve to database contents: import a config, then complete it set_function_interpreter "wireguard_import1" "sh" local test_config="$TEST_BASE_DIR/wg-COMPLETE-1.conf" cat > "$test_config" <<'EOF' [Interface] PrivateKey = test_key_complete Address = 10.2.0.9/32 [Peer] PublicKey = test_public_key_complete AllowedIPs = 0.0.0.0/0 Endpoint = 9.10.11.12:51820 EOF chmod 0600 "$test_config" run_vpn_switch wireguard import "$test_config" >/dev/null 2>&1 output=$(run_vpn_switch complete wireguard patch wg-COMP) if [ "$(printf '%s\n' "$output" | tr '\n' ' ')" = "wg-COMPLETE-1.conf " ]; then pass "'wireguard patch' completes an imported config" else fail "'wireguard patch wg-COMP' gave: $(printf '%s' "$output" | tr '\n' ' ')" fi output=$(run_vpn_switch complete openvpn patch "") if printf '%s\n' "$output" | grep -q "wg-COMPLETE"; then fail "'openvpn patch' offers a WireGuard config (override not applied)" else pass "'openvpn patch' does not offer WireGuard configs" fi # File arguments hand over to the shell output=$(run_vpn_switch complete wireguard import "") if [ "$(printf '%s\n' "$output" | tr '\n' ' ')" = "@files " ]; then pass "'wireguard import' asks the shell for file names" else fail "'wireguard import' gave: $(printf '%s' "$output" | tr '\n' ' ')" fi # Fixed value sets output=$(run_vpn_switch complete helpenv PATH "") if [ "$(printf '%s\n' "$output" | tr '\n' ' ')" = "default local template " ]; then pass "'helpenv ' completes the layer" else fail "'helpenv PATH' gave: $(printf '%s' "$output" | tr '\n' ' ')" fi rm -f "$test_config" } test_printenv_command() { test_header "printenv - basic environment display" "test_printenv_command" # Create exclusive database for this test test_setup # Run printenv command local output=$(run_vpn_switch printenv) # Verify printenv output shows environment variables header if printf '%s\n' "$output" | grep -q "Environment variables in isolated context"; then pass "Printenv command executed and shows header" else fail "Printenv command did not show expected output" fi # Verify it shows sorted environment if printf '%s\n' "$output" | grep -q "VPN_SWITCH_BASE"; then pass "Printenv shows VPN_SWITCH_BASE variable" else fail "Printenv did not show VPN_SWITCH_BASE variable" fi } # TEST 2: Status command test_status_command() { test_header "status - VPN status display" "test_status_command" # Create exclusive database for this test test_setup # Run status command local output=$(run_vpn_switch status) # Verify status output shows header if printf '%s\n' "$output" | grep -q "VPN Status"; then pass "Status command shows header" else fail "Status command did not show header" fi # Verify status shows disconnected state (no VPN running) if printf '%s\n' "$output" | grep -q "Disconnected"; then pass "Status shows disconnected state" else fail "Status did not show disconnected state" fi # Verify status checks network interfaces if printf '%s\n' "$output" | grep -q "Network Interfaces"; then pass "Status checks network interfaces" else fail "Status did not check network interfaces" fi } # TEST 3: WireGuard import - valid config test_wireguard_import_valid() { test_header "wireguard import - import valid config" "test_wireguard_import_valid" # Create exclusive database for this test test_setup # Enable execution for wireguard import (narrow override) set_function_interpreter "wireguard_import1" "sh" # Create a test config file in temp location local test_config="$TEST_BASE_DIR/test-import-wg-NL-99.conf" cat > "$test_config" <<'EOF' [Interface] PrivateKey = test_private_key_placeholder Address = 10.2.0.2/32 DNS = 10.2.0.1 [Peer] PublicKey = test_public_key_placeholder AllowedIPs = 0.0.0.0/0 Endpoint = 1.2.3.4:51820 EOF chmod 0600 "$test_config" # Run import command (actually execute it!) run_vpn_switch wireguard import "$test_config" >/dev/null 2>&1 # Verify the file was actually copied to the database local imported_file="$TEST_DIR/wireguard/test-import-wg-NL-99.conf" if [ -f "$imported_file" ]; then pass "Config file imported to database" else fail "Config file was not imported" ls -la "$TEST_DIR/wireguard/" || true fi # Verify permissions are 0400 (read-only) if [ -f "$imported_file" ]; then local perms=$(get_file_perms "$imported_file") if [ "$perms" = "400" ]; then pass "Permissions set to 0400 (read-only)" else fail "Permissions incorrect: got $perms, expected 400" fi fi # Cleanup rm -f "$test_config" } # TEST 4: WireGuard add - create category test_wireguard_add_category() { test_header "wireguard add - create category with permissions" "test_wireguard_add_category" # Create exclusive database for this test test_setup # Enable execution for wireguard add (narrow override) set_function_interpreter "wireguard_add" "sh" # Run add command (actually execute it!) run_vpn_switch wireguard add test-streaming >/dev/null 2>&1 # Verify category directory was created local category_dir="$TEST_DIR/wireguard/test-streaming" if [ -d "$category_dir" ]; then pass "Category directory created" else fail "Category directory was not created" ls -la "$TEST_DIR/wireguard/" || true fi # Verify permissions are 0755 (rwxr-xr-x) if [ -d "$category_dir" ]; then local perms=$(get_file_perms "$category_dir") if [ "$perms" = "755" ]; then pass "Permissions set to 0755" else fail "Permissions incorrect: got $perms, expected 755" fi fi } # TEST 5: WireGuard link - create symlink test_wireguard_link_create() { test_header "wireguard link - create symlink in protocol directory" "test_wireguard_link_create" # Create exclusive database for this test test_setup # Enable execution for wireguard import and link (narrow overrides) set_function_interpreter "wireguard_import1" "sh" set_function_interpreter "wireguard_link2" "sh" # First, import a config file to link to local test_config="$TEST_BASE_DIR/wg-TEST-RS-1.conf" cat > "$test_config" <<'EOF' [Interface] PrivateKey = test_key_for_link Address = 10.2.0.3/32 DNS = 10.2.0.1 [Peer] PublicKey = test_public_key_for_link AllowedIPs = 0.0.0.0/0 Endpoint = 5.6.7.8:51820 EOF chmod 0600 "$test_config" # Import the config file first run_vpn_switch wireguard import "$test_config" >/dev/null 2>&1 # Now create the link run_vpn_switch wireguard link test-link wg-TEST-RS-1.conf >/dev/null 2>&1 # Verify symlink was created local link_path="$TEST_DIR/wireguard/test-link" if [ -L "$link_path" ]; then pass "Symlink created" else fail "Symlink was not created" ls -la "$TEST_DIR/wireguard/" || true fi # Verify symlink points to the correct target if [ -L "$link_path" ]; then local link_target=$(readlink "$link_path") if [ "$link_target" = "./wg-TEST-RS-1.conf" ]; then pass "Symlink points to correct relative path (./wg-TEST-RS-1.conf)" else fail "Symlink target incorrect: got '$link_target', expected './wg-TEST-RS-1.conf'" fi fi # Cleanup rm -f "$test_config" } # TEST 6: WireGuard list - display configs and categories test_wireguard_list() { test_header "wireguard list - display configurations" "test_wireguard_list" # Create exclusive database for this test test_setup # Enable execution for wireguard import and add (narrow overrides) set_function_interpreter "wireguard_import1" "sh" set_function_interpreter "wireguard_add" "sh" # Add some test data to make the list more interesting # Import a config local test_config="$TEST_BASE_DIR/wg-TEST-NL-1.conf" cat > "$test_config" <<'EOF' [Interface] PrivateKey = test_key_nl1 Address = 10.2.0.4/32 DNS = 10.2.0.1 [Peer] PublicKey = test_public_key_nl1 AllowedIPs = 0.0.0.0/0 Endpoint = 9.10.11.12:51820 EOF chmod 0600 "$test_config" run_vpn_switch wireguard import "$test_config" >/dev/null 2>&1 # Create a category run_vpn_switch wireguard add privacy >/dev/null 2>&1 # Run list command local output=$(run_vpn_switch wireguard list) # Verify header displayed if printf '%s\n' "$output" | grep -q "WireGuard Configurations"; then pass "Configuration section header displayed" else fail "Configuration header missing" printf '%s\n' "Output: $output" fi # Verify config files section exists if printf '%s\n' "$output" | grep -q "Configuration files:"; then pass "Config files section displayed" else fail "Config files section missing" printf '%s\n' "Output: $output" fi # Verify our imported config is listed if printf '%s\n' "$output" | grep -q "wg-TEST-NL-1.conf"; then pass "Imported config file listed" else fail "Imported config not shown" printf '%s\n' "Output: $output" fi # Cleanup rm -f "$test_config" } # TEST 7: WireGuard validate - check configuration integrity test_wireguard_validate() { test_header "wireguard validate - validate clean configuration" "test_wireguard_validate" # Create exclusive database for this test test_setup # Run validate on clean database local output=$(run_vpn_switch wireguard validate) # Verify validation header if printf '%s\n' "$output" | grep -q "WireGuard Configuration Validation"; then pass "Validation header displayed" else fail "Validation header missing" printf '%s\n' "Output: $output" fi # Verify no errors reported if printf '%s\n' "$output" | grep -q "No issues found"; then pass "No issues found in clean configuration" else fail "Unexpected issues reported" printf '%s\n' "Output: $output" fi } # TEST 8: WireGuard info - display category information test_wireguard_info() { test_header "wireguard info - display category information" "test_wireguard_info" # Create exclusive database for this test test_setup # Enable execution for wireguard add and import (narrow overrides) set_function_interpreter "wireguard_add" "sh" set_function_interpreter "wireguard_import1" "sh" # Create privacy category run_vpn_switch wireguard add privacy >/dev/null 2>&1 # Import a config and link it to the category local test_config="$TEST_BASE_DIR/wg-TEST-CH-1.conf" cat > "$test_config" <<'EOF' [Interface] PrivateKey = test_key_ch1 Address = 10.2.0.5/32 DNS = 10.2.0.1 [Peer] PublicKey = test_public_key_ch1 AllowedIPs = 0.0.0.0/0 Endpoint = 13.14.15.16:51820 EOF chmod 0600 "$test_config" run_vpn_switch wireguard import "$test_config" >/dev/null 2>&1 # Link config to privacy category (category, target, alias) run_vpn_switch wireguard add privacy wg-TEST-CH-1.conf ch1 >/dev/null 2>&1 # Run info command on privacy category local output=$(run_vpn_switch wireguard info privacy) # Verify category header displayed if printf '%s\n' "$output" | grep -q "Category: privacy"; then pass "Category header displayed" else fail "Category header missing" printf '%s\n' "Output: $output" fi # Verify path displayed if printf '%s\n' "$output" | grep -q "Path:.*privacy"; then pass "Category path displayed" else fail "Category path missing" printf '%s\n' "Output: $output" fi # Verify contents section exists if printf '%s\n' "$output" | grep -q "Contents:"; then pass "Contents section displayed" else fail "Contents section missing" printf '%s\n' "Output: $output" fi # Verify symlinks shown with arrow notation if printf '%s\n' "$output" | grep -q -- "->"; then pass "Category contents listed with symlink arrows" else fail "Contents not shown properly" printf '%s\n' "Output: $output" fi # Cleanup rm -f "$test_config" } # TEST 9: WireGuard start - create session and connect test_wireguard_start_basic() { test_header "wireguard start - create session and connect to VPN" "test_wireguard_start_basic" # Create exclusive database for this test test_setup # Set up mocks for network operations with unique test ID local test_id=13 setup_session_test_mocks "$test_id" local test_interface="wg_test${test_id}" # Enable execution for wireguard add and import (narrow overrides) set_function_interpreter "wireguard_add" "sh" set_function_interpreter "wireguard_import1" "sh" # Create privacy category run_vpn_switch wireguard add privacy >/dev/null 2>&1 # Import a config and link it to the category local test_config="$TEST_BASE_DIR/wg-TEST-DE-1.conf" cat > "$test_config" <<'EOF' [Interface] PrivateKey = test_key_de1 Address = 10.2.0.6/32 DNS = 10.2.0.1 [Peer] PublicKey = test_public_key_de1 AllowedIPs = 0.0.0.0/0 Endpoint = 17.18.19.20:51820 EOF chmod 0600 "$test_config" run_vpn_switch wireguard import "$test_config" >/dev/null 2>&1 run_vpn_switch wireguard add privacy wg-TEST-DE-1.conf de1 >/dev/null 2>&1 # Run start command local output=$(run_vpn_switch wireguard start privacy 2>&1 || true) # Verify session was created if printf '%s\n' "$output" | grep -qi "session"; then pass "Session creation logged" else fail "Session creation not logged" printf '%s\n' "Output: $output" fi # The guard emits the destroy of the interface unconditionally; the act judges when it runs (bound to cat in every test database: displayed, never run) if printf '%s\n' "$output" | grep -qF "if $EXAMINE_NETWORK_INTERFACES $test_interface >/dev/null 2>&1; then $(printf "$MODIFY_INTERFACE_DESTROY_FMT" "$test_interface") || exit 1" \ && ! printf '%s\n' "$output" | grep -q "Auto-destroying"; then pass "The interface destroy is emitted unconditionally and judged by the act" else fail "Interface destroy act: $(printf '%s\n' "$output" | grep -i "interface" | head -3)" fi # Verify wg-quick command is in generated connect.sh local session_dir=$(ls -1d "$TEST_DIR/.session"/* 2>/dev/null | head -1) if [ -f "$session_dir/connect.sh" ] && grep -q "wg-quick" "$session_dir/connect.sh"; then pass "wg-quick command in connect.sh" else fail "wg-quick command not found in connect.sh" printf '%s\n' "Output: $output" fi # Verify session directory was created local session_count=$(ls -1d "$TEST_DIR/.session"/* 2>/dev/null | wc -l) if [ "$session_count" -ge 1 ]; then pass "Session directory created in .session/" else fail "Session directory not found" ls -la "$TEST_DIR/.session/" || true fi # Verify session metadata files exist local session_dir=$(ls -1d "$TEST_DIR/.session"/* 2>/dev/null | head -1) if [ -f "$session_dir/protocol" ] && [ -f "$session_dir/pid" ]; then pass "Session metadata files created" else fail "Session metadata files missing" ls -la "$session_dir" || true fi # Verify connect.sh was generated if [ -f "$session_dir/connect.sh" ]; then pass "connect.sh script generated" else fail "connect.sh script not found" ls -la "$session_dir" || true fi # Verify connect.sh is executable if [ -x "$session_dir/connect.sh" ]; then pass "connect.sh is executable" else fail "connect.sh is not executable" ls -la "$session_dir/connect.sh" || true fi # Verify connect.sh contains relative path to original if grep -q "Original config: ../../wireguard" "$session_dir/connect.sh"; then pass "connect.sh documents original config with relative path" else fail "connect.sh missing relative path to original" grep "Original config:" "$session_dir/connect.sh" || true fi # Verify patched config exists (uses test interface name) if [ -f "$session_dir/${test_interface}.conf" ]; then pass "Patched config (${test_interface}.conf) created" else fail "Patched config not found" ls -la "$session_dir" || true fi # Phase-based approach: PostUp/PostDown hooks should be stripped (DNS handled by DNS phase) if grep -q "PostUp.*resolvconf" "$session_dir/${test_interface}.conf"; then fail "Patched config should NOT contain PostUp hook (phase-based DNS)" grep -i "post" "$session_dir/${test_interface}.conf" || true else pass "Patched config correctly strips PostUp hook (phase-based DNS)" fi # Verify PostDown hook also stripped if grep -q "PostDown.*resolvconf" "$session_dir/${test_interface}.conf"; then fail "Patched config should NOT contain PostDown hook (phase-based DNS)" grep -i "post" "$session_dir/${test_interface}.conf" || true else pass "Patched config correctly strips PostDown hook (phase-based DNS)" fi # Keepalive injected (source config has none; default is 25) if grep -q "^PersistentKeepalive = 25$" "$session_dir/${test_interface}.conf"; then pass "Patched config injects PersistentKeepalive (default 25)" else fail "Patched config should inject 'PersistentKeepalive = 25'" cat "$session_dir/${test_interface}.conf" || true fi # Cleanup: Stop the VPN session to avoid interfering with subsequent tests set_function_interpreter "wireguard_stop" "sh" run_vpn_switch wireguard stop >/dev/null 2>&1 # Cleanup rm -f "$test_config" } # TEST 10: WireGuard connect - resume existing session test_wireguard_connect_resume() { test_header "wireguard connect - resume existing session by ID" "test_wireguard_connect_resume" # Create exclusive database for this test test_setup # Set up mocks for network operations with unique test ID local test_id=14 setup_session_test_mocks "$test_id" local test_interface="wg_test${test_id}" # Enable execution for wireguard add and import (narrow overrides) set_function_interpreter "wireguard_add" "sh" set_function_interpreter "wireguard_import1" "sh" # Create privacy category and config (same setup as start test) run_vpn_switch wireguard add privacy >/dev/null 2>&1 local test_config="$TEST_BASE_DIR/wg-TEST-SE-1.conf" cat > "$test_config" <<'EOF' [Interface] PrivateKey = test_key_se1 Address = 10.2.0.7/32 DNS = 10.2.0.1 [Peer] PublicKey = test_public_key_se1 AllowedIPs = 0.0.0.0/0 Endpoint = 21.22.23.24:51820 EOF chmod 0600 "$test_config" run_vpn_switch wireguard import "$test_config" >/dev/null 2>&1 run_vpn_switch wireguard add privacy wg-TEST-SE-1.conf se1 >/dev/null 2>&1 # Run start to create session and extract session ID from output (black-box testing) local start_output=$(run_vpn_switch wireguard start privacy 2>&1 || true) # Extract session ID from the output (what the user interface reports) local session_id=$(echo "$start_output" | grep -oE '[Ss]ession:? [0-9]+|PID:? [0-9]+|session [0-9]+' | grep -oE '[0-9]+' | head -1) if [ -z "$session_id" ]; then fail "Could not extract session ID from wireguard start output" echo "Start output: $start_output" rm -f "$test_config" return fi # Now test standalone connect with session ID local output=$(run_vpn_switch wireguard connect "$session_id" 2>&1 || true) # Verify connect.sh contains expected commands local session_dir="$TEST_DIR/.session/$session_id" if [ -f "$session_dir/connect.sh" ] && grep -q "wg-quick" "$session_dir/connect.sh"; then pass "wireguard connect generated wg-quick command in connect.sh" else fail "wg-quick command not found in connect.sh" printf '%s\n' "Output: $output" fi # Verify firewall commands in connect.sh if grep -q "pfctl" "$session_dir/connect.sh"; then pass "wireguard connect generated pfctl command in connect.sh" else fail "pfctl command not found in connect.sh" printf '%s\n' "Output: $output" fi # Verify connect.sh references the session's config if grep -qiE "wg9|wireguard" "$session_dir/connect.sh"; then pass "wireguard connect used session configuration" else fail "Session configuration not referenced in connect.sh" cat "$session_dir/connect.sh" || echo "connect.sh not found" fi # Verify connect.sh still exists after connect (shouldn't be deleted) local session_dir="$TEST_DIR/.session/$session_id" if [ -f "$session_dir/connect.sh" ]; then pass "connect.sh preserved after connect" else fail "connect.sh was deleted" ls -la "$session_dir" || true fi # Cleanup rm -f "$test_config" } # TEST 11: WireGuard stop - stop active VPN session test_wireguard_stop_basic() { test_header "stop - disconnect active VPN session" "test_wireguard_stop_basic" # Create exclusive database for this test test_setup # Set up mocks for network operations setup_session_test_mocks # Enable execution for wireguard add, import, and session stop (narrow overrides) set_function_interpreter "wireguard_add" "sh" set_function_interpreter "wireguard_import1" "sh" set_function_interpreter "wireguard_stop" "sh" # _batch2 outputs informational messages, not shell commands - override to 'cat' # Use arity-specific override (batch2) to avoid affecting combinators (__batch0, __batch1) set_function_interpreter "batch2" "cat" # Create and start a VPN session (reusing tested functionality) run_vpn_switch wireguard add privacy >/dev/null 2>&1 local test_config="$TEST_BASE_DIR/wg-TEST-NO-1.conf" cat > "$test_config" <<'EOF' [Interface] PrivateKey = test_key_no1 Address = 10.2.0.8/32 DNS = 10.2.0.1 [Peer] PublicKey = test_public_key_no1 AllowedIPs = 0.0.0.0/0 Endpoint = 25.26.27.28:51820 EOF chmod 0600 "$test_config" run_vpn_switch wireguard import "$test_config" >/dev/null 2>&1 run_vpn_switch wireguard add privacy wg-TEST-NO-1.conf no1 >/dev/null 2>&1 # Start VPN to create session run_vpn_switch wireguard start privacy >/dev/null 2>&1 # Verify session exists before stop local session_count_before=$(ls -1d "$TEST_DIR/.session"/* 2>/dev/null | wc -l) if [ "$session_count_before" -ge 1 ]; then pass "Session exists before stop" else fail "No session found before stop" fi # Verify disconnect.sh exists and contains wg-quick down local session_dir=$(ls -1d "$TEST_DIR/.session"/* 2>/dev/null | head -1) if [ -f "$session_dir/disconnect.sh" ] && grep -q "wg-quick.*down" "$session_dir/disconnect.sh"; then pass "disconnect.sh contains wg-quick down command" else fail "wg-quick down command missing from disconnect.sh" ls -la "$session_dir" || true fi # Run stop command local output=$(run_vpn_switch stop 2>&1 || true) # Verify disconnect operation logged (or no active sessions if session already stopped) if printf '%s\n' "$output" | grep -qiE "disconnect|stopping|No.*sessions to stop"; then pass "Stop command executed" else fail "Stop command output unexpected" printf '%s\n' "Output: $output" fi # Verify session was preserved (has 'latest' link after start) local session_count_after=$(ls -1d "$TEST_DIR/.session"/* 2>/dev/null | wc -l) if [ "$session_count_after" -eq 1 ]; then pass "Session preserved after stop (has 'latest' link)" else fail "Session not preserved (expected 1, got $session_count_after)" ls -la "$TEST_DIR/.session/" || true ls -la "$TEST_DIR/session/" || true fi # Cleanup rm -f "$test_config" } # TEST 12: Session list - display active sessions test_session_list() { test_header "session list - display active and stale sessions" "test_session_list" # Create exclusive database for this test test_setup # Set up mocks for network operations setup_session_test_mocks # Enable execution for wireguard add and import (narrow overrides) set_function_interpreter "wireguard_add" "sh" set_function_interpreter "wireguard_import1" "sh" # Create active session using public API (black-box testing) run_vpn_switch wireguard add privacy >/dev/null 2>&1 local test_config="$TEST_BASE_DIR/wg-TEST-LIST-1.conf" cat > "$test_config" <<'EOF' [Interface] PrivateKey = test_key_list1 Address = 10.2.0.10/32 DNS = 10.2.0.1 [Peer] PublicKey = test_public_key_list1 AllowedIPs = 0.0.0.0/0 Endpoint = 10.10.10.10:51820 EOF chmod 0600 "$test_config" run_vpn_switch wireguard import "$test_config" >/dev/null 2>&1 run_vpn_switch wireguard add privacy wg-TEST-LIST-1.conf list1 >/dev/null 2>&1 # Start VPN and extract session ID from output (black-box - use what program tells us) local start_output=$(run_vpn_switch wireguard start privacy 2>&1 || true) # Extract session PID from the output (what the user interface reports) local active_pid=$(echo "$start_output" | grep -oE '[Ss]ession:? [0-9]+|PID:? [0-9]+|session [0-9]+' | grep -oE '[0-9]+' | head -1) if [ -z "$active_pid" ]; then fail "Could not extract session ID from wireguard start output" echo "Start output: $start_output" return fi # Manually create ONLY the stale session (with dead PID) for testing stale detection # This is the ONLY manual setup needed - we're testing broken/stale session handling local stale_pid=99997 # Non-existent PID (dead process) local stale_session="$TEST_DIR/.session/$stale_pid" mkdir -p "$stale_session" echo "$stale_pid" > "$stale_session/pid" echo "wireguard" > "$stale_session/protocol" echo "$TEST_DIR/wireguard/test-config-2.conf" > "$stale_session/original" echo "wg_test1" > "$stale_session/interface" date "+%Y-%m-%d %H:%M:%S" > "$stale_session/started" echo "99996" > "$stale_session/vpn.pid" # Dead VPN process PID (stale session) # Run session list local output=$(run_vpn_switch session list 2>&1 || true) # Verify header displayed if printf '%s\n' "$output" | grep -qE "Active VPN Sessions|PID.*PROTOCOL"; then pass "Session list header displayed" else fail "Session list header missing" printf '%s\n' "Output: $output" fi # Verify active session listed if printf '%s\n' "$output" | grep -q "$active_pid"; then pass "Active session (PID $active_pid) listed" else fail "Active session not listed" printf '%s\n' "Output: $output" fi # Verify stale session listed if printf '%s\n' "$output" | grep -q "$stale_pid"; then pass "Stale session (PID $stale_pid) listed" else fail "Stale session not listed" printf '%s\n' "Output: $output" fi # Verify stale session marked as stopped (VPN not running) if printf '%s\n' "$output" | grep "$stale_pid" | grep -q "stopped"; then pass "Stale session (dead process) marked as stopped" else fail "Stale session not marked as stopped" printf '%s\n' "Output: $output" fi # Cleanup rm -f "$test_config" } # TEST: session refresh - removes stale ownership for a down interface, idempotent # # Scope note: the peer-fingerprint *matching* core (session_peer_matches_interface # and refresh's re-point-to-matching-session path) cannot be exercised through this # black-box CLI harness, because the live `wg show` output cannot be stubbed: # EXAMINE_* variables are re-sourced from the platform file at startup (overriding # setenv), and PATH injection does not reach the examination subshell. That core # logic is covered deterministically by vpn-switch-ownership-test.sh. Here we cover # the harness-reachable plumbing: a stale ownership link for a down/unknown # interface is removed, and refresh is idempotent. test_session_refresh() { test_header "session refresh - removes stale ownership, idempotent" "test_session_refresh" test_setup set_function_interpreter "session_refresh" "sh" # Simulate ownership drift: latest- points at a dead session whose # interface is down. Minimal manual broken-state setup (as in test_session_list); # drift cannot be produced through the public API. local iface="wg_drift9" local dead=99931 mkdir -p "$TEST_DIR/.session/$dead" "$TEST_DIR/session" echo "wireguard" > "$TEST_DIR/.session/$dead/protocol" echo "$iface" > "$TEST_DIR/.session/$dead/interface" printf 'PublicKey = drift_key=\n' > "$TEST_DIR/.session/$dead/$iface.conf" ln -sfn "../.session/$dead" "$TEST_DIR/session/latest-$iface" local out=$(run_vpn_switch session refresh 2>&1 || true) if [ ! -L "$TEST_DIR/session/latest-$iface" ]; then pass "Stale ownership removed for down interface ($iface)" else fail "Stale ownership link not removed" printf '%s\n' "$out" fi if printf '%s\n' "$out" | grep -qE "Reconciled ownership|removing stale ownership"; then pass "Refresh reported the reconciliation" else fail "Refresh did not report reconciliation" printf '%s\n' "$out" fi # Idempotent: a second run reports no changes. out=$(run_vpn_switch session refresh 2>&1 || true) if printf '%s\n' "$out" | grep -qE "no changes|already consistent"; then pass "session refresh is idempotent (no-op on second run)" else fail "session refresh not idempotent" printf '%s\n' "$out" fi } # TEST: session refresh - graceful no-op on a clean database test_session_refresh_clean() { test_header "session refresh - clean database is a no-op" "test_session_refresh_clean" test_setup set_function_interpreter "session_refresh" "sh" local out=$(run_vpn_switch session refresh 2>&1 || true) if printf '%s\n' "$out" | grep -qE "No sessions found|No interfaces to reconcile|no changes|already consistent"; then pass "Refresh on clean DB is a graceful no-op" else fail "Refresh on clean DB did not report a clean no-op" printf '%s\n' "$out" fi } # TEST 13: Session show - display connect script test_session_show() { test_header "session show - display connect script" "test_session_show" # Create exclusive database for this test test_setup # Set up mocks for network operations setup_session_test_mocks # Enable execution for wireguard add and import (narrow overrides) set_function_interpreter "wireguard_add" "sh" set_function_interpreter "wireguard_import1" "sh" # Create session using public API (black-box testing) run_vpn_switch wireguard add privacy >/dev/null 2>&1 local test_config="$TEST_BASE_DIR/wg-TEST-SHOW-1.conf" cat > "$test_config" <<'EOF' [Interface] PrivateKey = test_key_show1 Address = 10.2.0.11/32 DNS = 10.2.0.1 [Peer] PublicKey = test_public_key_show1 AllowedIPs = 0.0.0.0/0 Endpoint = 11.11.11.11:51820 EOF chmod 0600 "$test_config" run_vpn_switch wireguard import "$test_config" >/dev/null 2>&1 run_vpn_switch wireguard add privacy wg-TEST-SHOW-1.conf show1 >/dev/null 2>&1 # Start VPN and extract session ID from output (black-box - use what program tells us) local start_output=$(run_vpn_switch wireguard start privacy 2>&1 || true) # Extract session PID from the output (what the user interface reports) # Look for patterns like "Session: 12345" or "session 12345" or "PID: 12345" local session_id=$(echo "$start_output" | grep -oE '[Ss]ession:? [0-9]+|PID:? [0-9]+|session [0-9]+' | grep -oE '[0-9]+' | head -1) if [ -z "$session_id" ]; then fail "Could not extract session ID from wireguard start output" echo "Start output: $start_output" return fi pass "Extracted session ID ($session_id) from start command output" # Run session show with the PID we got from the program local output=$(run_vpn_switch session show "$session_id" 2>&1 || true) # Verify session metadata displayed if printf '%s\n' "$output" | grep -q "Session:.*$session_id"; then pass "Session ID displayed in header" else fail "Session ID not displayed" printf '%s\n' "Output: $output" fi # Verify protocol shown if printf '%s\n' "$output" | grep -q "Protocol:.*wireguard"; then pass "Session protocol displayed" else fail "Session protocol not displayed" printf '%s\n' "Output: $output" fi # Verify connect.sh contents displayed (should contain wg-quick commands) if printf '%s\n' "$output" | grep -qiE "wg-quick|#!/bin/sh"; then pass "Connect script contents displayed" else fail "Connect script contents not displayed" printf '%s\n' "Output: $output" fi # Cleanup rm -f "$test_config" } # TEST 13b: Session show — name resolution via API-driven setup # # Regression test: `vpn-switch session show ` is documented in # TUTORIAL_SESSIONS.md (Step 7) but currently fails with "Session not found" # because _session_show1 treats its argument as a literal PID and never # consults the `session/` symlink directory. Compare to __session_start1 / # __session_stop1, which both branch on numeric-vs-non-numeric and resolve # the named symlink before dispatching. # # This test builds the precondition (a saved session named 'work') purely # through the public API — `wireguard start` to bring a session into # existence, then `session save work` to create the named link — and then # calls `session show work` to assert that name resolution works. Until # the bug is fixed, the assertion fails. test_session_show_by_name() { test_header "session show - resolve named session symlink" "test_session_show_by_name" test_setup setup_session_test_mocks # Enable execution for the API calls we'll drive set_function_interpreter "wireguard_add" "sh" set_function_interpreter "wireguard_import1" "sh" set_function_interpreter "session_save" "sh" # Build state via the public API (no hand-crafted .session//) run_vpn_switch wireguard add privacy >/dev/null 2>&1 local test_config="$TEST_BASE_DIR/wg-TEST-SHOWNAME-1.conf" cat > "$test_config" <<'EOF' [Interface] PrivateKey = test_key_showname1 Address = 10.2.0.21/32 DNS = 10.2.0.1 [Peer] PublicKey = test_public_key_showname1 AllowedIPs = 0.0.0.0/0 Endpoint = 21.21.21.21:51820 EOF chmod 0600 "$test_config" run_vpn_switch wireguard import "$test_config" >/dev/null 2>&1 run_vpn_switch wireguard add privacy wg-TEST-SHOWNAME-1.conf showname1 >/dev/null 2>&1 # Start a session and capture its PID from the output (black-box) local start_output=$(run_vpn_switch wireguard start privacy 2>&1 || true) local session_id=$(echo "$start_output" | grep -oE '[Ss]ession:? [0-9]+|PID:? [0-9]+|session [0-9]+' | grep -oE '[0-9]+' | head -1) if [ -z "$session_id" ]; then fail "Could not extract session ID from wireguard start output" echo "Start output: $start_output" rm -f "$test_config" return fi pass "Started session $session_id via API" # Save the session as 'work' via the public API (no manual ln -sfn) run_vpn_switch session save work >/dev/null 2>&1 local save_output=$(run_vpn_switch session save work 2>&1 || true) if printf '%s\n' "$save_output" | grep -qiE "saved.*work|work"; then pass "Saved session $session_id as 'work' via API" else fail "Could not save session as 'work'" printf '%s\n' "Save output: $save_output" rm -f "$test_config" return fi # Control case: `session show ` works (no name resolution needed) local by_pid=$(run_vpn_switch session show "$session_id" 2>&1 || true) if printf '%s\n' "$by_pid" | grep -q "Session:.*$session_id"; then pass "session show works (control case)" else fail "session show broken — control case failed, cannot assess name-resolution bug" printf '%s\n' "Output: $by_pid" rm -f "$test_config" return fi # The bug under test: `session show ` should resolve the symlink local by_name=$(run_vpn_switch session show work 2>&1 || true) if printf '%s\n' "$by_name" | grep -q "Session:.*$session_id"; then pass "session show resolves named symlink" else fail "BUG: session show does not resolve named symlink (mirrors session start/stop)" printf '%s\n' "Output: $by_name" fi rm -f "$test_config" } # TEST 14: Session remove - remove stale session and refuse active test_session_remove() { test_header "session remove - remove stale, refuse active" "test_session_remove" # Create exclusive database for this test test_setup # Set up mocks for network operations setup_session_test_mocks # Enable execution for session remove, wireguard add and import (narrow overrides) set_function_interpreter "session_remove" "sh" set_function_interpreter "wireguard_add" "sh" set_function_interpreter "wireguard_import1" "sh" # Manually create ONLY the stale session (dead process) for testing stale removal # This is what we're testing: can we remove dead sessions? local dead_pid=99996 local session_dir="$TEST_DIR/.session/$dead_pid" mkdir -p "$session_dir" echo "$dead_pid" > "$session_dir/pid" echo "wireguard" > "$session_dir/protocol" echo "$TEST_DIR/wireguard/test-config.conf" > "$session_dir/original" echo "wg_test2" > "$session_dir/interface" date "+%Y-%m-%d %H:%M:%S" > "$session_dir/started" echo "99995" > "$session_dir/vpn.pid" # Dead VPN process PID (stale session) # Verify stale session exists if [ -d "$session_dir" ]; then pass "Stale session created for removal test" else fail "Stale session not created" fi # Run session remove on stale session local output=$(run_vpn_switch session remove "$dead_pid" 2>&1 || true) # Verify removal commands generated or session removed if printf '%s\n' "$output" | grep -qE "Removing session.*$dead_pid|rm.*$dead_pid|removed"; then pass "Removal operation logged for stale session" else fail "Removal not logged" printf '%s\n' "Output: $output" fi # Now test refusal to remove active session # We need to manually create an "active" session (one with a running process PID) # This documents what makes a session "active": having a PID that corresponds to a running process # In a mocked environment (where wg-quick is echo'd), using wireguard start would create a # session without a real VPN process, so it would be detected as stale local active_pid=$$ # Use current shell PID (definitely running) local active_session="$TEST_DIR/.session/$active_pid" mkdir -p "$active_session" echo "$active_pid" > "$active_session/pid" echo "wireguard" > "$active_session/protocol" echo "$TEST_DIR/wireguard/test-config.conf" > "$active_session/original" echo "wg_test4" > "$active_session/interface" date "+%Y-%m-%d %H:%M:%S" > "$active_session/started" echo "$$" > "$active_session/vpn.pid" # VPN process PID (current shell - definitely running) if [ -d "$active_session" ]; then pass "Active session (PID $$) created for refusal test" else fail "Active session not created" return fi # Try to remove active session (should refuse) local error_output=$(run_vpn_switch session remove "$active_pid" 2>&1 || true) if echo "$error_output" | grep -qiE "error.*active|refusing.*active|still running"; then pass "Refuses to remove active session (PID $active_pid)" else fail "Should refuse to remove active session" echo "Output: $error_output" fi } # TEST 15: Session save - save current session as default test_session_save_default() { test_header "session save - save current session as default" "test_session_save_default" # Create exclusive database for this test test_setup # Set up mocks for network operations setup_session_test_mocks # Enable execution for session_save (narrow override) set_function_interpreter "session_save" "sh" # Manually create active session (with running process PID) # We must manually create this because in a mocked environment, `wireguard start` # creates a session without a real VPN process, so it appears "stale" # For session save to work, we need an "active" session (PID of running process) local test_pid=$$ # Current shell PID (definitely running) local session_dir="$TEST_DIR/.session/$test_pid" mkdir -p "$session_dir" echo "$test_pid" > "$session_dir/pid" echo "wireguard" > "$session_dir/protocol" echo "$TEST_DIR/wireguard/test-config.conf" > "$session_dir/original" echo "wg9" > "$session_dir/interface" date "+%Y-%m-%d %H:%M:%S" > "$session_dir/started" echo "#!/bin/sh" > "$session_dir/connect.sh" chmod 0755 "$session_dir/connect.sh" # Create session/latest symlink (required by _session_save0 validation) mkdir -p "$TEST_DIR/session" ln -sfn "../.session/$test_pid" "$TEST_DIR/session/latest" if [ -d "$session_dir" ] && [ -L "$TEST_DIR/session/latest" ]; then pass "Active session (PID $$) with latest symlink created for save test" else fail "Active session or latest symlink not created" return fi # Run session save (should create default symlink) local output=$(run_vpn_switch session save 2>&1 || true) # Verify output mentions saving or symlink creation if printf '%s\n' "$output" | grep -qiE "saving|session.*saved|ln.*default"; then pass "Session save operation logged" else fail "Session save not logged" printf '%s\n' "Output: $output" fi # Verify default symlink was created if [ -L "$TEST_DIR/session/default" ]; then pass "Default session symlink created" else fail "Default session symlink not created" ls -la "$TEST_DIR/session/" 2>/dev/null || echo "session/ directory not found" fi # Verify symlink points to the correct session if [ -L "$TEST_DIR/session/default" ]; then local link_target=$(readlink "$TEST_DIR/session/default") if echo "$link_target" | grep -q "$test_pid"; then pass "Default symlink points to session $test_pid" else fail "Default symlink target incorrect: $link_target" fi fi } # TEST 16: Session save - save with custom name test_session_save_named() { test_header "session save work - save current session with name" "test_session_save_named" # Create exclusive database for this test test_setup # Set up mocks for network operations setup_session_test_mocks # Enable execution for session_save (narrow override) set_function_interpreter "session_save" "sh" # Manually create active session (with running process PID) local test_pid=$$ # Current shell PID (definitely running) local session_dir="$TEST_DIR/.session/$test_pid" mkdir -p "$session_dir" echo "$test_pid" > "$session_dir/pid" echo "wireguard" > "$session_dir/protocol" echo "$TEST_DIR/wireguard/test-config.conf" > "$session_dir/original" echo "wg9" > "$session_dir/interface" date "+%Y-%m-%d %H:%M:%S" > "$session_dir/started" echo "#!/bin/sh" > "$session_dir/connect.sh" chmod 0755 "$session_dir/connect.sh" # Create session/latest symlink (required by _session_save1 validation) mkdir -p "$TEST_DIR/session" ln -sfn "../.session/$test_pid" "$TEST_DIR/session/latest" if [ -d "$session_dir" ] && [ -L "$TEST_DIR/session/latest" ]; then pass "Active session (PID $$) with latest symlink created for named save test" else fail "Active session or latest symlink not created" return fi # Run session save with name "work" local output=$(run_vpn_switch session save work 2>&1 || true) # Verify output mentions saving or symlink creation if printf '%s\n' "$output" | grep -qiE "saving.*work|session.*saved.*work|ln.*work"; then pass "Named session save operation logged" else fail "Named session save not logged" printf '%s\n' "Output: $output" fi # Verify named symlink was created if [ -L "$TEST_DIR/session/work" ]; then pass "Named session symlink 'work' created" else fail "Named session symlink not created" ls -la "$TEST_DIR/session/" 2>/dev/null || echo "session/ directory not found" fi # Verify symlink points to the correct session if [ -L "$TEST_DIR/session/work" ]; then local link_target=$(readlink "$TEST_DIR/session/work") if echo "$link_target" | grep -q "$test_pid"; then pass "Named symlink points to session $test_pid" else fail "Named symlink target incorrect: $link_target" fi fi } # TEST 17: Session start - resume default session test_session_start_default() { test_header "session start - resume default session" "test_session_start_default" # Create exclusive database for this test test_setup # Set up mocks for network operations setup_session_test_mocks # Manually create a mock session for resumption testing # We're testing the resume functionality, so we need a pre-existing session local test_pid=12345 local session_dir="$TEST_DIR/.session/$test_pid" mkdir -p "$session_dir" echo "$test_pid" > "$session_dir/pid" echo "wireguard" > "$session_dir/protocol" echo "$TEST_DIR/wireguard/test-config.conf" > "$session_dir/original" echo "wg9" > "$session_dir/interface" date "+%Y-%m-%d %H:%M:%S" > "$session_dir/started" # Create connect.sh script with recognizable content cat > "$session_dir/connect.sh" <<'EOF' #!/bin/sh # Test connect script for session 12345 echo "Resuming default session" EOF chmod 0755 "$session_dir/connect.sh" # Create session/ directory and default symlink mkdir -p "$TEST_DIR/session" ln -sf "../.session/$test_pid" "$TEST_DIR/session/default" if [ -L "$TEST_DIR/session/default" ]; then pass "Default session symlink created for resume test" else fail "Default session symlink not created" return fi # Run session start (should resolve default symlink and resume) local output=$(run_vpn_switch session start 2>&1 || true) # Verify it outputs the session connect command or references the PID if printf '%s\n' "$output" | grep -qE "session.*start.*$test_pid|Resuming|connect"; then pass "Session start resolves default symlink (PID $test_pid)" else fail "Session start did not resolve default" printf '%s\n' "Output: $output" fi } # TEST 18: Session start - resume named session test_session_start_named() { test_header "session start work - resume named session" "test_session_start_named" # Create exclusive database for this test test_setup # Set up mocks for network operations setup_session_test_mocks # Manually create a mock session for resumption testing local test_pid=67890 local session_dir="$TEST_DIR/.session/$test_pid" mkdir -p "$session_dir" echo "$test_pid" > "$session_dir/pid" echo "wireguard" > "$session_dir/protocol" echo "$TEST_DIR/wireguard/test-config.conf" > "$session_dir/original" echo "wg9" > "$session_dir/interface" date "+%Y-%m-%d %H:%M:%S" > "$session_dir/started" # Create connect.sh script with recognizable content cat > "$session_dir/connect.sh" <<'EOF' #!/bin/sh # Test connect script for work session echo "Resuming work session" EOF chmod 0755 "$session_dir/connect.sh" # Create session/ directory and named symlink "work" mkdir -p "$TEST_DIR/session" ln -sf "../.session/$test_pid" "$TEST_DIR/session/work" if [ -L "$TEST_DIR/session/work" ]; then pass "Named session symlink 'work' created for resume test" else fail "Named session symlink not created" return fi # Run session start with name "work" local output=$(run_vpn_switch session start work 2>&1 || true) # Verify it outputs the session connect command or references the PID if printf '%s\n' "$output" | grep -qE "session.*start.*$test_pid|work.*session|Resuming work|connect"; then pass "Session start resolves named session 'work' (PID $test_pid)" else fail "Session start did not resolve named session" printf '%s\n' "Output: $output" fi } # TEST 19: Session start - resume by PID test_session_start_bypid() { test_header "session start - resume session by PID" "test_session_start_bypid" # Create exclusive database for this test test_setup # Set up mocks for network operations setup_session_test_mocks # Manually create a mock session for resumption testing local test_pid=11111 local session_dir="$TEST_DIR/.session/$test_pid" mkdir -p "$session_dir" echo "$test_pid" > "$session_dir/pid" echo "wireguard" > "$session_dir/protocol" echo "$TEST_DIR/wireguard/test-config.conf" > "$session_dir/original" echo "wg9" > "$session_dir/interface" date "+%Y-%m-%d %H:%M:%S" > "$session_dir/started" # Create connect.sh script with recognizable content cat > "$session_dir/connect.sh" <<'EOF' #!/bin/sh # Test connect script for PID session echo "Resuming PID session" EOF chmod 0755 "$session_dir/connect.sh" if [ -d "$session_dir" ]; then pass "Mock session (PID $test_pid) created for PID resume test" else fail "Mock session not created" return fi # Run session start with explicit PID local output=$(run_vpn_switch session start "$test_pid" 2>&1 || true) # Verify it outputs the session connect command or references the PID if printf '%s\n' "$output" | grep -qE "session.*start.*$test_pid|Resuming.*PID|connect"; then pass "Session start resumes by explicit PID ($test_pid)" else fail "Session start did not resume by PID" printf '%s\n' "Output: $output" fi } # TEST 20: Session stop - name routes to disconnect via PID resolution # # Tests __session_stop1's routing only: # name → resolve_session_id → PID → session disconnect # # Aliveness intentionally does NOT enter this path; disconnect is # idempotent and handles dead sessions gracefully. See session.sh # (___session_stop0 / __session_stop1) for the architectural decision. test_session_stop_named() { test_header "session stop work - resolves name to disconnect" "test_session_stop_named" test_setup setup_session_test_mocks # Inspect __session_stop1's combinator output without executing it override_for_command_inspection "session_stop1" # Minimal session fixture: just a session_dir and a named symlink. # No vpn.pid, no interface, no ownership — stop1 doesn't read them. local test_pid=77777 local session_dir="$TEST_DIR/.session/$test_pid" mkdir -p "$session_dir" mkdir -p "$TEST_DIR/session" ln -sf "../.session/$test_pid" "$TEST_DIR/session/work" local output=$(run_vpn_switch session stop work 2>&1 || true) if printf '%s\n' "$output" | grep -qE "session disconnect \"?${test_pid}\"?"; then pass "session stop 'work' emits 'session disconnect $test_pid'" else fail "session stop did not route name to disconnect" printf '%s\n' "Output: $output" fi } # TEST 21: Session stop - explicit PID routes directly to disconnect test_session_stop_bypid() { test_header "session stop - routes PID to disconnect" "test_session_stop_bypid" test_setup setup_session_test_mocks override_for_command_inspection "session_stop1" # Minimal session fixture, no metadata needed for stop1's routing. local test_pid=88888 local session_dir="$TEST_DIR/.session/$test_pid" mkdir -p "$session_dir" local output=$(run_vpn_switch session stop "$test_pid" 2>&1 || true) if printf '%s\n' "$output" | grep -qE "session disconnect \"?${test_pid}\"?"; then pass "session stop $test_pid emits 'session disconnect $test_pid'" else fail "session stop did not route PID to disconnect" printf '%s\n' "Output: $output" fi } # TEST 22: Session stop - inactive session also routes to disconnect (idempotency contract) # # Documents the architectural choice that 'stop' does not branch on aliveness: # inactive sessions get the same 'session disconnect' routing, and the # disconnect chain is idempotent (kill on dead PID is no-op, wg-quick down on # missing interface is no-op, firewall revert is no-op). test_session_stop_inactive() { test_header "session stop - inactive session routes to disconnect (idempotent)" "test_session_stop_inactive" test_setup setup_session_test_mocks override_for_command_inspection "session_stop1" # Inactive fixture: session dir exists, no vpn.pid. stop1 doesn't care. local test_pid=99999 local session_dir="$TEST_DIR/.session/$test_pid" mkdir -p "$session_dir" local output=$(run_vpn_switch session stop "$test_pid" 2>&1 || true) if printf '%s\n' "$output" | grep -qE "session disconnect \"?${test_pid}\"?"; then pass "Inactive session still routes to disconnect (no 'not active' branch)" else fail "Inactive session was not routed to disconnect" printf '%s\n' "Output: $output" fi } # TEST 22a: ___session_stop0 batch - emits one 'session stop ' per session # # Verifies the new batch combinator iterates all sessions instead of # picking one (avoids leaving an active session running when "latest" # happens to be dead). test_session_stop_batch_all() { test_header "session stop (no arg) - batch emits stop per session" "test_session_stop_batch_all" test_setup setup_session_test_mocks # Inspect ___session_stop0's batch output without executing it override_for_command_inspection "session_stop0" # Two minimal sessions mkdir -p "$TEST_DIR/.session/11111" "$TEST_DIR/.session/22222" local output=$(run_vpn_switch session stop 2>&1 || true) if printf '%s\n' "$output" | grep -qE 'session stop "?11111"?'; then pass "batch emits 'session stop 11111'" else fail "batch missing 'session stop 11111'" printf '%s\n' "Output: $output" fi if printf '%s\n' "$output" | grep -qE 'session stop "?22222"?'; then pass "batch emits 'session stop 22222'" else fail "batch missing 'session stop 22222'" fi } # TEST 23: WireGuard remove - refuse to remove config in use by session test_wireguard_remove_config_in_use() { test_header "wireguard remove - refuse removal when config in use" "test_wireguard_remove_config_in_use" # Create exclusive database for this test test_setup # Enable execution for wireguard import and add (narrow overrides) set_function_interpreter "wireguard_import1" "sh" set_function_interpreter "wireguard_add" "sh" # Import a test config that we'll mark as "in use" local test_config="$TEST_BASE_DIR/wg-TEST-RM-1.conf" cat > "$test_config" <<'EOF' [Interface] PrivateKey = test_key_rm1 Address = 10.2.0.20/32 DNS = 10.2.0.1 [Peer] PublicKey = test_public_key_rm1 AllowedIPs = 0.0.0.0/0 Endpoint = 20.20.20.20:51820 EOF chmod 0600 "$test_config" run_vpn_switch wireguard import "$test_config" >/dev/null 2>&1 # Manually create active session using this config # This documents the safety check: cannot remove configs in use by active sessions local real_pid=$$ # Current shell PID (definitely running) local session_dir="$TEST_DIR/.session/$real_pid" mkdir -p "$session_dir" echo "$real_pid" > "$session_dir/pid" echo "wireguard" > "$session_dir/protocol" echo "$TEST_DIR/wireguard/wg-TEST-RM-1.conf" > "$session_dir/original" echo "wg_test_safe" > "$session_dir/interface" date "+%Y-%m-%d %H:%M:%S" > "$session_dir/started" if [ -d "$session_dir" ]; then pass "Active session (PID $$) created using config" else fail "Active session not created" return fi # Try to remove the config that's in use local output=$(run_vpn_switch wireguard remove wg-TEST-RM-1.conf 2>&1 || true) # Verify error message about config in use if printf '%s\n' "$output" | grep -qiE "error.*in use|config in use|session.*using"; then pass "Error message when config in use by session" else fail "Missing error for config in use" printf '%s\n' "Output: $output" fi # Verify error mentions session removal or PID if printf '%s\n' "$output" | grep -qE "session remove|PID.*$real_pid"; then pass "Error suggests removing session first" else fail "Error doesn't suggest session removal" printf '%s\n' "Output: $output" fi # Cleanup rm -f "$test_config" } # TEST 21: WireGuard remove - refuse to remove config with symlink references test_wireguard_remove_config_with_refs() { test_header "wireguard remove - refuse removal when config has symlink refs" "test_wireguard_remove_config_with_refs" # Create exclusive database for this test test_setup # Enable execution for wireguard import, add, link (narrow overrides) set_function_interpreter "wireguard_import1" "sh" set_function_interpreter "wireguard_add" "sh" set_function_interpreter "wireguard_link2" "sh" # Import a test config local test_config="$TEST_BASE_DIR/wg-TEST-RM-2.conf" cat > "$test_config" <<'EOF' [Interface] PrivateKey = test_key_rm2 Address = 10.2.0.21/32 DNS = 10.2.0.1 [Peer] PublicKey = test_public_key_rm2 AllowedIPs = 0.0.0.0/0 Endpoint = 21.21.21.21:51820 EOF chmod 0600 "$test_config" run_vpn_switch wireguard import "$test_config" >/dev/null 2>&1 # Create privacy category and link the config to it run_vpn_switch wireguard add privacy >/dev/null 2>&1 run_vpn_switch wireguard add privacy wg-TEST-RM-2.conf rm2 >/dev/null 2>&1 # Verify symlink was created if [ -L "$TEST_DIR/wireguard/privacy/rm2" ]; then pass "Category symlink created (privacy/rm2)" else fail "Category symlink not created" return fi # Try to remove the config that has symlink references local output=$(run_vpn_switch wireguard remove wg-TEST-RM-2.conf 2>&1 || true) # Verify error message about references if printf '%s\n' "$output" | grep -qiE "error.*reference|has.*reference|symlink.*pointing"; then pass "Error message when config has symlink references" else fail "Missing error for config with references" printf '%s\n' "Output: $output" fi # Verify error lists the references or category if printf '%s\n' "$output" | grep -qE "privacy/rm2|privacy"; then pass "Error lists symlink references" else fail "Error doesn't list references" printf '%s\n' "Output: $output" fi # Cleanup rm -f "$test_config" } # TEST 22: WireGuard remove - remove symlink without safety checks test_wireguard_remove_symlink() { test_header "wireguard remove - remove symlink (no safety checks)" "test_wireguard_remove_symlink" # Create exclusive database for this test test_setup # Enable execution for wireguard import, remove (narrow overrides) set_function_interpreter "wireguard_import1" "sh" set_function_interpreter "wireguard_remove" "sh" # Import a config first local test_config="$TEST_BASE_DIR/wg-TEST-RM-3.conf" cat > "$test_config" <<'EOF' [Interface] PrivateKey = test_key_rm3 Address = 10.2.0.22/32 DNS = 10.2.0.1 [Peer] PublicKey = test_public_key_rm3 AllowedIPs = 0.0.0.0/0 Endpoint = 22.22.22.22:51820 EOF chmod 0600 "$test_config" run_vpn_switch wireguard import "$test_config" >/dev/null 2>&1 # Create a test symlink in protocol directory ln -sf "./wg-TEST-RM-3.conf" "$TEST_DIR/wireguard/test-remove-link" # Verify link was created if [ -L "$TEST_DIR/wireguard/test-remove-link" ]; then pass "Test symlink created" else fail "Failed to create test symlink" rm -f "$test_config" return fi # Run remove command on symlink (should succeed without safety checks) local output=$(run_vpn_switch wireguard remove test-remove-link 2>&1 || true) # Verify removal operation logged if printf '%s\n' "$output" | grep -qE "rm.*test-remove-link|Remov.*test-remove-link"; then pass "Symlink removal operation logged" else fail "Removal not logged" printf '%s\n' "Output: $output" fi # Verify symlink was actually removed (if execution enabled) if [ ! -L "$TEST_DIR/wireguard/test-remove-link" ]; then pass "Symlink was removed" else # Not a failure if just showing commands pass "Remove command generated for symlink" fi # Cleanup rm -f "$test_config" } # TEST 23: WireGuard remove - remove link from category test_wireguard_remove_category_link() { test_header "wireguard remove - remove link from category" "test_wireguard_remove_category_link" # Create exclusive database for this test test_setup # Enable execution for wireguard import, add, remove (narrow overrides) set_function_interpreter "wireguard_import1" "sh" set_function_interpreter "wireguard_add" "sh" set_function_interpreter "wireguard_remove" "sh" # Import a config local test_config="$TEST_BASE_DIR/wg-TEST-RM-4.conf" cat > "$test_config" <<'EOF' [Interface] PrivateKey = test_key_rm4 Address = 10.2.0.23/32 DNS = 10.2.0.1 [Peer] PublicKey = test_public_key_rm4 AllowedIPs = 0.0.0.0/0 Endpoint = 23.23.23.23:51820 EOF chmod 0600 "$test_config" run_vpn_switch wireguard import "$test_config" >/dev/null 2>&1 # Create privacy category and link the config run_vpn_switch wireguard add privacy >/dev/null 2>&1 run_vpn_switch wireguard add privacy wg-TEST-RM-4.conf rm4 >/dev/null 2>&1 # Verify category link exists if [ -L "$TEST_DIR/wireguard/privacy/rm4" ]; then pass "Category link (privacy/rm4) created" else fail "Category link not created" return fi # Run remove command on category link local output=$(run_vpn_switch wireguard remove privacy rm4 2>&1 || true) # Verify removal operation logged if printf '%s\n' "$output" | grep -qE "rm.*privacy/rm4|Remov.*privacy.*rm4"; then pass "Category link removal operation logged" else fail "Removal not logged" printf '%s\n' "Output: $output" fi # Verify category link was removed (if execution enabled) if [ ! -L "$TEST_DIR/wireguard/privacy/rm4" ]; then pass "Category link was removed" else # Not a failure if just showing commands pass "Remove command generated for category link" fi # Cleanup rm -f "$test_config" } # TEST 24: WireGuard clean - no broken links test_wireguard_clean_no_broken() { test_header "wireguard clean - no broken links found" "test_wireguard_clean_no_broken" # Create exclusive database for this test test_setup # Override interpreter to inspect clean command output (profile-independent) override_for_command_inspection "wireguard_clean" # Enable execution for wireguard add (to ensure category exists) set_function_interpreter "wireguard_add" "sh" # Create privacy category to ensure there's something to scan run_vpn_switch wireguard add privacy >/dev/null 2>&1 # Run clean on clean database (no broken links) local output=$(run_vpn_switch wireguard clean 2>&1 || true) # Verify header displayed if printf '%s\n' "$output" | grep -qE "Clean.*broken.*symlink|wireguard/"; then pass "Clean header displayed" else fail "Clean header missing" printf '%s\n' "Output: $output" fi # Verify "no broken symlinks" message if printf '%s\n' "$output" | grep -qiE "no broken symlinks|no.*broken.*found"; then pass "No broken symlinks message displayed" else fail "No broken message missing" printf '%s\n' "Output: $output" fi # Verify usage hint displayed (how to execute commands) if printf '%s\n' "$output" | grep -qE "wireguard clean.*sh|pipe.*sh"; then pass "Usage hint displayed" else fail "Usage hint missing" printf '%s\n' "Output: $output" fi } # TEST 25: WireGuard clean - broken protocol link test_wireguard_clean_broken_protocol_link() { test_header "wireguard clean - detect and generate remove for broken protocol link" "test_wireguard_clean_broken_protocol_link" # Create exclusive database for this test test_setup # Override interpreter to inspect clean command output (profile-independent) override_for_command_inspection "wireguard_clean" # Create a broken symlink in protocol directory # This simulates a config file that was deleted but symlinks remain ln -sf "./nonexistent.conf" "$TEST_DIR/wireguard/broken-proto-link" # Verify broken link was created if [ -L "$TEST_DIR/wireguard/broken-proto-link" ] && [ ! -e "$TEST_DIR/wireguard/broken-proto-link" ]; then pass "Broken protocol symlink created for testing" else fail "Failed to create broken symlink" return fi # Run clean (should detect broken link and output remove command) local output=$(run_vpn_switch wireguard clean 2>&1 || true) # Verify remove command generated for broken link if printf '%s\n' "$output" | grep -q "wireguard remove.*broken-proto-link"; then pass "Remove command generated for broken protocol link" else fail "Remove command not generated" printf '%s\n' "Output: $output" fi # Verify command is properly quoted (for safety with special characters) if printf '%s\n' "$output" | grep -qE 'wireguard remove.*"broken-proto-link"|broken-proto-link'; then pass "Command includes link name" else fail "Command formatting incorrect" printf '%s\n' "Output: $output" fi # Cleanup rm -f "$TEST_DIR/wireguard/broken-proto-link" } # TEST 26: WireGuard clean - broken category link test_wireguard_clean_broken_category_link() { test_header "wireguard clean - detect and generate remove for broken category link" "test_wireguard_clean_broken_category_link" # Create exclusive database for this test test_setup override_for_command_inspection "wireguard_clean" # Enable execution for wireguard add (narrow override) set_function_interpreter "wireguard_add" "sh" # Create privacy category run_vpn_switch wireguard add privacy >/dev/null 2>&1 # Create a broken symlink in category ln -sf "../nonexistent.conf" "$TEST_DIR/wireguard/privacy/broken-cat-link" # Verify broken link was created if [ -L "$TEST_DIR/wireguard/privacy/broken-cat-link" ] && [ ! -e "$TEST_DIR/wireguard/privacy/broken-cat-link" ]; then pass "Broken category symlink created for testing" else fail "Failed to create broken category symlink" return fi # Run clean (should detect broken link in category) local output=$(run_vpn_switch wireguard clean 2>&1 || true) # Verify remove command generated with category and link name if printf '%s\n' "$output" | grep -q "wireguard remove.*privacy.*broken-cat-link"; then pass "Remove command generated for broken category link" else fail "Remove command not generated or incorrect" printf '%s\n' "Output: $output" fi # Verify both category and link name are in command if printf '%s\n' "$output" | grep "wireguard remove" | grep -q "privacy"; then pass "Command includes category name" else fail "Category not in command" printf '%s\n' "Output: $output" fi # Cleanup rm -f "$TEST_DIR/wireguard/privacy/broken-cat-link" } # TEST 27: WireGuard clean - category with no broken links test_wireguard_clean_category_no_broken() { test_header "wireguard clean privacy - category with no broken links" "test_wireguard_clean_category_no_broken" # Create exclusive database for this test test_setup override_for_command_inspection "wireguard_clean" # Enable execution for wireguard add (narrow override) set_function_interpreter "wireguard_add" "sh" # Create privacy category run_vpn_switch wireguard add privacy >/dev/null 2>&1 # Run clean on specific category (no broken links) local output=$(run_vpn_switch wireguard clean privacy 2>&1 || true) # Verify category-specific header if printf '%s\n' "$output" | grep -qE "wireguard/privacy/|Clean.*privacy"; then pass "Category-specific header displayed" else fail "Category-specific header missing" printf '%s\n' "Output: $output" fi # Verify "no broken symlinks" message for category if printf '%s\n' "$output" | grep -qiE "no broken.*privacy|no.*broken.*found"; then pass "No broken symlinks message for category" else fail "Category message missing" printf '%s\n' "Output: $output" fi } # TEST 28: WireGuard clean - category with broken links test_wireguard_clean_category_with_broken() { test_header "wireguard clean privacy - category with broken links" "test_wireguard_clean_category_with_broken" # Create exclusive database for this test test_setup override_for_command_inspection "wireguard_clean" # Enable execution for wireguard add (narrow override) set_function_interpreter "wireguard_add" "sh" # Create privacy category run_vpn_switch wireguard add privacy >/dev/null 2>&1 # Create a broken symlink in privacy category ln -sf "../nonexistent.conf" "$TEST_DIR/wireguard/privacy/broken-test-link" # Verify broken link was created if [ -L "$TEST_DIR/wireguard/privacy/broken-test-link" ] && [ ! -e "$TEST_DIR/wireguard/privacy/broken-test-link" ]; then pass "Broken symlink created in privacy category" else fail "Failed to create broken symlink" return fi # Run clean on specific category local output=$(run_vpn_switch wireguard clean privacy 2>&1 || true) # Verify remove command generated for specific category if printf '%s\n' "$output" | grep -q "wireguard remove.*privacy.*broken-test-link"; then pass "Remove command generated for category link" else fail "Remove command not generated" printf '%s\n' "Output: $output" fi # Verify only this category is scanned (not entire wireguard directory) if printf '%s\n' "$output" | grep -qE "wireguard/privacy/|Clean.*privacy"; then pass "Category-specific clean (not global)" else fail "Should be category-specific clean" printf '%s\n' "Output: $output" fi # Cleanup rm -f "$TEST_DIR/wireguard/privacy/broken-test-link" } # TEST 29: WireGuard validate - detect broken symlinks in protocol directory test_wireguard_validate_broken_symlink() { test_header "wireguard validate - detect broken symlinks" "test_wireguard_validate_broken_symlink" # Create exclusive database for this test test_setup # Create a broken symlink in protocol directory # This simulates a config that was deleted but symlinks remain ln -sf "./nonexistent.conf" "$TEST_DIR/wireguard/broken-link" # Verify broken link was created if [ -L "$TEST_DIR/wireguard/broken-link" ] && [ ! -e "$TEST_DIR/wireguard/broken-link" ]; then pass "Broken symlink created for validation test" else fail "Failed to create broken symlink" return fi # Run validate (should detect broken symlink) local output=$(run_vpn_switch wireguard validate 2>&1 || true) # Verify broken symlink detected with ERROR marker if printf '%s\n' "$output" | grep -qE "\[ERROR\].*Broken symlink.*broken-link|Broken.*wireguard/broken-link"; then pass "Broken symlink detected in protocol directory" else fail "Broken symlink not detected" printf '%s\n' "Output: $output" fi # Verify error status reported if printf '%s\n' "$output" | grep -qiE "error.*found|issues found"; then pass "Error status reported" else fail "Error status not reported" printf '%s\n' "Output: $output" fi # Cleanup rm -f "$TEST_DIR/wireguard/broken-link" } # TEST 30: WireGuard validate - detect broken symlinks in categories test_wireguard_validate_broken_category_symlink() { test_header "wireguard validate - detect broken symlinks in categories" "test_wireguard_validate_broken_category_symlink" # Create exclusive database for this test test_setup # Enable execution for wireguard add (narrow override) set_function_interpreter "wireguard_add" "sh" # Create privacy category run_vpn_switch wireguard add privacy >/dev/null 2>&1 # Create a broken symlink in category ln -sf "../nonexistent.conf" "$TEST_DIR/wireguard/privacy/broken-cat-link" # Verify broken link was created if [ -L "$TEST_DIR/wireguard/privacy/broken-cat-link" ] && [ ! -e "$TEST_DIR/wireguard/privacy/broken-cat-link" ]; then pass "Broken category symlink created for validation test" else fail "Failed to create broken category symlink" return fi # Run validate (should detect broken symlink in category) local output=$(run_vpn_switch wireguard validate 2>&1 || true) # Verify broken symlink detected in category if printf '%s\n' "$output" | grep -qE "\[ERROR\].*Broken symlink.*privacy/broken-cat-link|Broken.*wireguard/privacy/broken-cat-link"; then pass "Broken symlink detected in category" else fail "Broken symlink not detected in category" printf '%s\n' "Output: $output" fi # Cleanup rm -f "$TEST_DIR/wireguard/privacy/broken-cat-link" } # TEST 31: WireGuard validate - detect naming conflicts test_wireguard_validate_naming_conflict() { test_header "wireguard validate - detect naming conflicts" "test_wireguard_validate_naming_conflict" # Create exclusive database for this test test_setup # Create a config file and directory with conflicting names # This simulates: test-conflict.conf (file) and test-conflict/ (directory) # which creates ambiguity in resolution touch "$TEST_DIR/wireguard/test-conflict.conf" mkdir -p "$TEST_DIR/wireguard/test-conflict" # Verify both exist if [ -f "$TEST_DIR/wireguard/test-conflict.conf" ] && [ -d "$TEST_DIR/wireguard/test-conflict" ]; then pass "Naming conflict created (file + directory)" else fail "Failed to create naming conflict" return fi # Run validate (should detect naming conflict) local output=$(run_vpn_switch wireguard validate 2>&1 || true) # Verify naming conflict detected with WARNING marker if printf '%s\n' "$output" | grep -qiE "\[WARNING\].*naming conflict.*test-conflict|conflict.*test-conflict"; then pass "Naming conflict detected" else fail "Naming conflict not detected" printf '%s\n' "Output: $output" fi # Cleanup rm -f "$TEST_DIR/wireguard/test-conflict.conf" rmdir "$TEST_DIR/wireguard/test-conflict" 2>/dev/null || true } # TEST 32: WireGuard validate - detect invalid config files test_wireguard_validate_invalid_config() { test_header "wireguard validate - detect invalid config files" "test_wireguard_validate_invalid_config" # Create exclusive database for this test test_setup # Create an empty config file (invalid - no content) touch "$TEST_DIR/wireguard/empty-test.conf" # Verify empty file exists if [ -f "$TEST_DIR/wireguard/empty-test.conf" ]; then pass "Empty config file created for validation test" else fail "Failed to create empty config" return fi # Run validate (should detect empty config) local output=$(run_vpn_switch wireguard validate 2>&1 || true) # Verify empty file detected with WARNING marker if printf '%s\n' "$output" | grep -qiE "\[WARNING\].*empty.*empty-test.conf|empty.*file"; then pass "Empty config file detected" else fail "Empty config file not detected" printf '%s\n' "Output: $output" fi # Create config without [Interface] section (invalid structure) cat > "$TEST_DIR/wireguard/no-interface.conf" <<'EOF' # Just a comment, no actual config # Missing [Interface] section EOF # Run validate again (should detect missing Interface section) local output2=$(run_vpn_switch wireguard validate 2>&1 || true) # Verify missing Interface section detected if echo "$output2" | grep -qiE "\[WARNING\].*interface.*no-interface.conf|missing.*interface"; then pass "Missing [Interface] section detected" else fail "Missing [Interface] section not detected" echo "Output: $output2" fi # Cleanup rm -f "$TEST_DIR/wireguard/empty-test.conf" rm -f "$TEST_DIR/wireguard/no-interface.conf" } # TEST 33: Stop - no sessions exist (empty output) test_stop_no_sessions() { test_header "stop - no sessions exist (empty output)" "test_stop_no_sessions" # Create exclusive database for this test test_setup # Run stop when no sessions exist local output=$(run_vpn_switch stop 2>&1 || true) # When no sessions exist, combinator produces empty output or "already removed" message # This is expected behavior - silence means "nothing to do" if [ -z "$output" ] || printf '%s\n' "$output" | grep -qiE "already removed|no.*session"; then pass "No output or log message when no sessions exist" else # Not a failure - may just show informational messages pass "Stop command handled no sessions gracefully" fi } # TEST 34: Stop - stop specific interface by name test_stop_specific_interface() { test_header "stop wg_test_safe - stop specific interface by name" "test_stop_specific_interface" # Create exclusive database for this test test_setup # Set up mocks for network operations setup_session_test_mocks # Manually create active session with wg_test_safe interface local real_pid=$$ # Current shell PID (definitely running) local session_dir="$TEST_DIR/.session/$real_pid" mkdir -p "$session_dir" echo "$real_pid" > "$session_dir/pid" echo "wireguard" > "$session_dir/protocol" echo "$TEST_DIR/wireguard/test-config.conf" > "$session_dir/original" echo "wg_test_safe" > "$session_dir/interface" date "+%Y-%m-%d %H:%M:%S" > "$session_dir/started" if [ -d "$session_dir" ]; then pass "Active session (PID $$, interface wg_test_safe) created" else fail "Active session not created" return fi # Call stop with specific interface local output=$(run_vpn_switch stop wg_test_safe 2>&1 || true) # Verify interface-specific disconnect if printf '%s\n' "$output" | grep -qE "Disconnect.*wg_test_safe|wg_test_safe"; then pass "Stopped specific interface wg_test_safe" else fail "Did not stop specific interface" printf '%s\n' "Output: $output" fi # Verify wg-quick down command for specific interface if printf '%s\n' "$output" | grep -q "wg-quick down.*wg_test_safe"; then pass "Interface wg_test_safe disconnect command generated" else fail "Interface disconnect command missing" printf '%s\n' "Output: $output" fi } # TEST 35: WireGuard disconnect - direct disconnect command test_wireguard_disconnect_interface() { test_header "wireguard disconnect wg_test_safe - direct disconnect command" "test_wireguard_disconnect_interface" # Create exclusive database for this test test_setup # Override interpreter to inspect disconnect command output override_for_command_inspection "wireguard_disconnect1" # Set up mocks for network operations setup_session_test_mocks # Manually create active session local real_pid=$$ # Current shell PID (definitely running) local session_dir="$TEST_DIR/.session/$real_pid" mkdir -p "$session_dir" echo "$real_pid" > "$session_dir/pid" echo "wireguard" > "$session_dir/protocol" echo "$TEST_DIR/wireguard/test-config.conf" > "$session_dir/original" echo "wg_test_safe" > "$session_dir/interface" date "+%Y-%m-%d %H:%M:%S" > "$session_dir/started" if [ -d "$session_dir" ]; then pass "Active session (PID $$) created for disconnect test" else fail "Active session not created" return fi # Call disconnect directly (protocol-specific command) local output=$(run_vpn_switch wireguard disconnect wg_test_safe 2>&1 || true) # Verify disconnect command executed if printf '%s\n' "$output" | grep -qiE "disconnect.*wg_test_safe|Stopping.*wg_test_safe"; then pass "Disconnect command executed for wg_test_safe" else fail "Disconnect command not executed" printf '%s\n' "Output: $output" fi # Verify session PID displayed if printf '%s\n' "$output" | grep -qE "Session.*PID.*$real_pid|PID.*$real_pid"; then pass "Session PID displayed correctly" else # Not a failure - may not always show PID pass "Disconnect command completed" fi } # TEST 36: Stop - error when interface not found test_stop_nonexistent_interface() { test_header "stop wg_test_other - error when interface not found" "test_stop_nonexistent_interface" # Create exclusive database for this test test_setup # Set up mocks for network operations setup_session_test_mocks # Create session for wg_test_safe only local real_pid=$$ local session_dir="$TEST_DIR/.session/$real_pid" mkdir -p "$session_dir" echo "$real_pid" > "$session_dir/pid" echo "wireguard" > "$session_dir/protocol" echo "$TEST_DIR/wireguard/test-config.conf" > "$session_dir/original" echo "wg_test_safe" > "$session_dir/interface" date "+%Y-%m-%d %H:%M:%S" > "$session_dir/started" if [ -d "$session_dir" ]; then pass "Active session with wg_test_safe interface created" else fail "Active session not created" return fi # Try to stop wg_test_other (doesn't exist) local output=$(run_vpn_switch stop wg_test_other 2>&1 || true) # Verify error message for nonexistent interface if printf '%s\n' "$output" | grep -qiE "error.*no.*session.*found|no.*session.*wg_test_other|no active.*session"; then pass "Error message for nonexistent interface" else fail "Missing error for nonexistent interface" printf '%s\n' "Output: $output" fi } # TEST 37: Disconnect - error without session test_disconnect_without_session() { test_header "disconnect without session - error handling" "test_disconnect_without_session" # Create exclusive database for this test test_setup # Ensure no sessions exist rm -rf "$TEST_DIR/.session"/* 2>/dev/null || true # Try to disconnect wg_test_disconnect when no session exists # Use test-specific interface name to avoid touching production interfaces (wg0, wg1, etc.) local output=$(run_vpn_switch wireguard disconnect wg_test_disconnect 2>&1 || true) # Verify error message when no session exists if printf '%s\n' "$output" | grep -qiE "error.*no.*session|no.*session found|session not found"; then pass "Error message when no session exists" else fail "Missing error for disconnect without session" printf '%s\n' "Output: $output" fi } # TEST 38: Stop - idempotent behavior (stop twice) test_stop_twice_idempotent() { test_header "stop twice - idempotent (safe to call multiple times)" "test_stop_twice_idempotent" # Create exclusive database for this test test_setup # Ensure no sessions exist rm -rf "$TEST_DIR/.session"/* 2>/dev/null || true # First stop (no sessions) local output1=$(run_vpn_switch stop 2>&1 || true) # Should handle gracefully (empty output or log message) if [ -z "$output1" ] || echo "$output1" | grep -qiE "already removed|no.*session"; then pass "First stop: handles no sessions gracefully" else # Not a failure - may show informational messages pass "First stop: executed without error" fi # Second stop (still no sessions) - should be idempotent local output2=$(run_vpn_switch stop 2>&1 || true) # Should still handle gracefully (idempotent) if [ -z "$output2" ] || echo "$output2" | grep -qiE "already removed|no.*session"; then pass "Second stop: still handles no sessions (idempotent)" else # Not a failure - idempotent behavior confirmed by no error pass "Second stop: executed without error (idempotent)" fi } # TEST 39: WireGuard import - error when file doesn't exist test_wireguard_import_nonexistent() { test_header "wireguard import - error when file doesn't exist" "test_wireguard_import_nonexistent" # Create exclusive database for this test test_setup # Try to import nonexistent file local output=$(run_vpn_switch wireguard import /nonexistent/wg-XX-99.conf 2>&1 || true) # Verify error message for nonexistent file if printf '%s\n' "$output" | grep -qiE "error.*not found|does not exist|no such file"; then pass "Error message for non-existent file" else fail "Missing error for non-existent file" printf '%s\n' "Output: $output" fi } # TEST 40: WireGuard import - error when config already exists test_wireguard_import_duplicate() { test_header "wireguard import - error when config already exists" "test_wireguard_import_duplicate" # Create exclusive database for this test test_setup # Enable execution for wireguard import (narrow override) set_function_interpreter "wireguard_import1" "sh" # First, import a config local test_config="$TEST_BASE_DIR/wg-TEST-DUP-1.conf" cat > "$test_config" <<'EOF' [Interface] PrivateKey = test_key_dup1 Address = 10.2.0.30/32 DNS = 10.2.0.1 [Peer] PublicKey = test_public_key_dup1 AllowedIPs = 0.0.0.0/0 Endpoint = 30.30.30.30:51820 EOF chmod 0600 "$test_config" run_vpn_switch wireguard import "$test_config" >/dev/null 2>&1 # Verify it was imported if [ -f "$TEST_DIR/wireguard/wg-TEST-DUP-1.conf" ]; then pass "First import succeeded" else fail "First import failed" return fi # Try to import again (duplicate) local output=$(run_vpn_switch wireguard import "$test_config" 2>&1 || true) # Verify error message for duplicate if printf '%s\n' "$output" | grep -qiE "error.*already exists|config.*exists"; then pass "Error message when config already exists" else fail "Missing error for duplicate config" printf '%s\n' "Output: $output" fi # Cleanup rm -f "$test_config" } # TEST 41: WireGuard add - error when category already exists test_wireguard_add_category_conflict() { test_header "wireguard add - error when category already exists" "test_wireguard_add_category_conflict" # Create exclusive database for this test test_setup # Enable execution for wireguard add (narrow override) set_function_interpreter "wireguard_add" "sh" # First, create privacy category run_vpn_switch wireguard add privacy >/dev/null 2>&1 # Verify it was created if [ -d "$TEST_DIR/wireguard/privacy" ]; then pass "First add succeeded (privacy category created)" else fail "First add failed" return fi # Try to create it again (conflict) local output=$(run_vpn_switch wireguard add privacy 2>&1 || true) # Verify error message for existing category if printf '%s\n' "$output" | grep -qiE "error.*already exists|category.*exists|directory.*exists"; then pass "Error message when category already exists" else fail "Missing error for existing category" printf '%s\n' "Output: $output" fi } # TEST 42: WireGuard add - arity-2 delegates to add3 test_wireguard_add_link_arity2() { test_header "wireguard add - arity-2 delegates to add3 with target as alias" "test_wireguard_add_category_conflict" # Create exclusive database for this test test_setup # Enable execution for wireguard import and add (narrow overrides) set_function_interpreter "wireguard_import1" "sh" set_function_interpreter "wireguard_add" "sh" # Import a config first local test_config="$TEST_BASE_DIR/wg-TEST-AR2-1.conf" cat > "$test_config" <<'EOF' [Interface] PrivateKey = test_key_ar2 Address = 10.2.0.31/32 DNS = 10.2.0.1 [Peer] PublicKey = test_public_key_ar2 AllowedIPs = 0.0.0.0/0 Endpoint = 31.31.31.31:51820 EOF chmod 0600 "$test_config" run_vpn_switch wireguard import "$test_config" >/dev/null 2>&1 # Create category run_vpn_switch wireguard add performance >/dev/null 2>&1 # Run add with arity-2 (category + config) local output=$(run_vpn_switch wireguard add performance wg-TEST-AR2-1.conf 2>&1 || true) # Should delegate to add3 with config name as alias # Look for success message showing target used as alias if printf '%s\n' "$output" | grep -qE "Added to.*performance.*wg-TEST-AR2-1.conf.*->.*wg-TEST-AR2-1.conf|wg-TEST-AR2-1.conf.*wg-TEST-AR2-1.conf"; then pass "Arity-2 delegates to add3 with target as alias" else fail "Delegation to add3 not working" printf '%s\n' "Output: $output" fi # Cleanup rm -f "$test_config" } # TEST 43: WireGuard add - arity-3 with custom alias test_wireguard_add_link_arity3() { test_header "wireguard add - arity-3 with custom alias" "test_wireguard_add_category_conflict" # Create exclusive database for this test test_setup # Enable execution for wireguard import and add (narrow overrides) set_function_interpreter "wireguard_import1" "sh" set_function_interpreter "wireguard_add" "sh" # Import a config first local test_config="$TEST_BASE_DIR/wg-TEST-AR3-1.conf" cat > "$test_config" <<'EOF' [Interface] PrivateKey = test_key_ar3 Address = 10.2.0.32/32 DNS = 10.2.0.1 [Peer] PublicKey = test_public_key_ar3 AllowedIPs = 0.0.0.0/0 Endpoint = 32.32.32.32:51820 EOF chmod 0600 "$test_config" run_vpn_switch wireguard import "$test_config" >/dev/null 2>&1 # Create category run_vpn_switch wireguard add streaming >/dev/null 2>&1 # Run add with arity-3 (category + config + alias) local output=$(run_vpn_switch wireguard add streaming wg-TEST-AR3-1.conf swiss 2>&1 || true) # Verify success message with alias and target if printf '%s\n' "$output" | grep -qE "Added to.*streaming.*swiss.*->.*wg-TEST-AR3-1.conf|swiss.*wg-TEST-AR3-1.conf"; then pass "Link created with custom alias (swiss)" else fail "Link creation failed" printf '%s\n' "Output: $output" fi # Verify symlink was actually created (if execution enabled) if [ -L "$TEST_DIR/wireguard/streaming/swiss" ]; then pass "Symlink file created at streaming/swiss" else # Not a failure if just showing commands pass "Success message with alias displayed" fi # Cleanup rm -f "$test_config" } # TEST 44: WireGuard add - error when link already exists test_wireguard_add_link_conflict() { test_header "wireguard add - error when link already exists" "test_wireguard_add_link_conflict" # Create exclusive database for this test test_setup # Enable execution for wireguard import and add (narrow overrides) set_function_interpreter "wireguard_import1" "sh" set_function_interpreter "wireguard_add" "sh" # Import a config local test_config="$TEST_BASE_DIR/wg-TEST-CONF-1.conf" cat > "$test_config" <<'EOF' [Interface] PrivateKey = test_key_conf1 Address = 10.2.0.33/32 DNS = 10.2.0.1 [Peer] PublicKey = test_public_key_conf1 AllowedIPs = 0.0.0.0/0 Endpoint = 33.33.33.33:51820 EOF chmod 0600 "$test_config" run_vpn_switch wireguard import "$test_config" >/dev/null 2>&1 # Create category and add link run_vpn_switch wireguard add privacy >/dev/null 2>&1 run_vpn_switch wireguard add privacy wg-TEST-CONF-1.conf link1 >/dev/null 2>&1 # Verify link was created if [ -L "$TEST_DIR/wireguard/privacy/link1" ]; then pass "First link created successfully" else fail "First link not created" return fi # Try to add the same link again (conflict) local output=$(run_vpn_switch wireguard add privacy wg-TEST-CONF-1.conf link1 2>&1 || true) # Verify error message if printf '%s\n' "$output" | grep -qiE "error.*already exists|alias.*exists|link.*exists"; then pass "Error message when link already exists" else fail "Missing error for existing link" printf '%s\n' "Output: $output" fi # Cleanup rm -f "$test_config" } # TEST 45: WireGuard link - error when alias already exists test_wireguard_link_conflict() { test_header "wireguard link - error when alias already exists" "test_wireguard_link_conflict" # Create exclusive database for this test test_setup # Enable execution for wireguard import (narrow override) set_function_interpreter "wireguard_import1" "sh" # Import a config local test_config="$TEST_BASE_DIR/wg-TEST-LINK-1.conf" cat > "$test_config" <<'EOF' [Interface] PrivateKey = test_key_link1 Address = 10.2.0.34/32 DNS = 10.2.0.1 [Peer] PublicKey = test_public_key_link1 AllowedIPs = 0.0.0.0/0 Endpoint = 34.34.34.34:51820 EOF chmod 0600 "$test_config" run_vpn_switch wireguard import "$test_config" >/dev/null 2>&1 # Verify config exists if [ -f "$TEST_DIR/wireguard/wg-TEST-LINK-1.conf" ]; then pass "Config imported successfully" else fail "Config import failed" rm -f "$test_config" return fi # Try to create link with same name as existing config file (conflict) local output=$(run_vpn_switch wireguard link wg-TEST-LINK-1.conf wg-TEST-LINK-1.conf 2>&1 || true) # Verify error message for existing alias if printf '%s\n' "$output" | grep -qiE "error.*already exists|alias.*exists|conflict"; then pass "Error message when alias already exists" else fail "Missing error for existing alias" printf '%s\n' "Output: $output" fi # Cleanup rm -f "$test_config" } # TEST 46: Error handling - invalid category test_error_invalid_category() { test_header "error handling - invalid category shows error" "test_error_invalid_category" # Create exclusive database for this test test_setup # Try to start VPN with nonexistent category local output=$(run_vpn_switch wireguard start nonexistent 2>&1 || true) # Verify error message displayed if printf '%s\n' "$output" | grep -qiE "error.*not found|category not found|no.*found"; then pass "Error message for invalid category" else fail "Missing error message for invalid category" printf '%s\n' "Output: $output" fi } # TEST 16: Error handling - broken symlink test_error_broken_symlink() { test_header "error handling - broken symlink handled gracefully" "test_error_broken_symlink" # Create exclusive database for this test test_setup # Enable execution for wireguard add (narrow override) set_function_interpreter "wireguard_add" "sh" # Create privacy category run_vpn_switch wireguard add privacy >/dev/null 2>&1 # Create a broken symlink in privacy category ln -s ../nonexistent.conf "$TEST_DIR/wireguard/privacy/broken" 2>/dev/null || true # Verify broken symlink exists if [ -L "$TEST_DIR/wireguard/privacy/broken" ] && [ ! -e "$TEST_DIR/wireguard/privacy/broken" ]; then pass "Broken symlink created for testing" else fail "Failed to create broken symlink" ls -la "$TEST_DIR/wireguard/privacy/" || true fi # Try to start VPN with category containing broken symlink local output=$(run_vpn_switch wireguard start privacy 2>&1 || true) # Should either skip broken link or error gracefully if printf '%s\n' "$output" | grep -qiE "error|selected config|no.*found|not found"; then pass "Handled broken symlink (error or skipped)" else fail "Did not handle broken symlink gracefully" printf '%s\n' "Output: $output" fi } # TEST 17: Error handling - invalid command test_error_invalid_command() { test_header "error handling - invalid command shows usage" "test_error_invalid_command" # Create exclusive database for this test test_setup # Try to run invalid command local output=$(run_vpn_switch invalid_command_xyz123 2>&1 || true) # Verify usage or help displayed if printf '%s\n' "$output" | grep -qiE "usage|help|unknown"; then pass "Usage/help shown for invalid command" else fail "No usage/help for invalid command" printf '%s\n' "Output: $output" fi } # TEST 18: Edge case - duplicate instance prevention test_edge_duplicate_instance_prevention() { test_header "edge case - prevent duplicate VPN instances" "test_edge_duplicate_instance_prevention" # Create exclusive database for this test test_setup # Set up mocks for network operations with unique test ID local test_id=53 setup_session_test_mocks "$test_id" local test_interface="wg_test${test_id}" # Enable execution for wireguard add and import (narrow overrides) set_function_interpreter "wireguard_add" "sh" set_function_interpreter "wireguard_import1" "sh" # Create privacy category and config run_vpn_switch wireguard add privacy >/dev/null 2>&1 local test_config="$TEST_BASE_DIR/wg-TEST-DUP-1.conf" cat > "$test_config" <<'EOF' [Interface] PrivateKey = test_key_dup1 Address = 10.2.0.99/32 DNS = 10.2.0.1 [Peer] PublicKey = test_public_key_dup1 AllowedIPs = 0.0.0.0/0 Endpoint = 99.99.99.99:51820 EOF chmod 0600 "$test_config" run_vpn_switch wireguard import "$test_config" >/dev/null 2>&1 run_vpn_switch wireguard add privacy wg-TEST-DUP-1.conf dup1 >/dev/null 2>&1 # Create first "active" session (one with a running process PID) # This documents what makes a session "active": having a PID that corresponds to a running process # In a mocked environment (where wg-quick is echo'd), using wireguard start would create a # session without a real VPN process, so it would be detected as stale and auto-removed local first_pid=$$ # Use current shell PID (definitely running) local first_session="$TEST_DIR/.session/$first_pid" mkdir -p "$first_session" echo "$first_pid" > "$first_session/pid" echo "wireguard" > "$first_session/protocol" echo "$TEST_DIR/wireguard/privacy/dup1" > "$first_session/original" echo "$test_interface" > "$first_session/interface" date "+%Y-%m-%d %H:%M:%S" > "$first_session/started" echo "$first_pid" > "$first_session/vpn.pid" # VPN process PID (must be running for duplicate detection) if [ -d "$first_session" ]; then pass "First VPN session created (PID $first_pid)" else fail "First VPN session not created" return fi # Try to start VPN again - should detect existing session and refuse local output=$(run_vpn_switch wireguard start privacy 2>&1 || true) # Should detect existing instance if printf '%s\n' "$output" | grep -qiE "already running|already exists|already active"; then pass "Detected existing VPN instance" else fail "Did not detect existing VPN instance" printf '%s\n' "Output: $output" fi # Verify no duplicate session created (should still be only one) local session_count=$(ls -1d "$TEST_DIR/.session"/* 2>/dev/null | wc -l) if [ "$session_count" -eq 1 ]; then pass "No duplicate session created" else fail "Duplicate session created (found $session_count sessions)" ls -la "$TEST_DIR/.session/" || true fi # Cleanup rm -f "$test_config" } # TEST 19: Edge case - stale sessions don't block new sessions test_edge_stale_session_cleanup() { test_header "edge case - stale sessions don't block new sessions" "test_edge_stale_session_cleanup" # Create exclusive database for this test test_setup # Set up mocks for network operations setup_session_test_mocks # Enable execution for wireguard add and import (narrow overrides) set_function_interpreter "wireguard_add" "sh" set_function_interpreter "wireguard_import1" "sh" # Create privacy category and config run_vpn_switch wireguard add privacy >/dev/null 2>&1 local test_config="$TEST_BASE_DIR/wg-TEST-STALE-1.conf" cat > "$test_config" <<'EOF' [Interface] PrivateKey = test_key_stale1 Address = 10.2.0.88/32 DNS = 10.2.0.1 [Peer] PublicKey = test_public_key_stale1 AllowedIPs = 0.0.0.0/0 Endpoint = 88.88.88.88:51820 EOF chmod 0600 "$test_config" run_vpn_switch wireguard import "$test_config" >/dev/null 2>&1 run_vpn_switch wireguard add privacy wg-TEST-STALE-1.conf stale1 >/dev/null 2>&1 # Create a stale session with dead PID (99998 definitely doesn't exist) local dead_pid=99998 local session_dir="$TEST_DIR/.session/$dead_pid" mkdir -p "$session_dir" echo "$dead_pid" > "$session_dir/pid" echo "wireguard" > "$session_dir/protocol" echo "$TEST_DIR/wireguard/privacy/stale1" > "$session_dir/original" echo "wg_test3" > "$session_dir/interface" date "+%Y-%m-%d %H:%M:%S" > "$session_dir/started" echo "99994" > "$session_dir/vpn.pid" # Dead VPN process PID (stale session) # Verify stale session exists if [ -d "$session_dir" ]; then pass "Stale session created for testing" else fail "Failed to create stale session" fi # Try to start VPN - should proceed (stale sessions don't block new sessions) # Note: In unit test mode (dry-run), session won't actually be created, # but the command should produce output without errors about stale sessions local output=$(run_vpn_switch wireguard start privacy 2>&1 || true) # Should proceed with connection (stale sessions don't prevent new ones) if printf '%s\n' "$output" | grep -qiE "session|wg-quick"; then pass "Proceeded with new session (stale session didn't block)" else fail "Did not proceed (should allow new session despite stale one)" printf '%s\n' "Output: $output" fi # Verify stale session was NOT automatically cleaned up # (Stale cleanup is now manual via 'session clean' command) if [ -d "$session_dir" ]; then pass "Stale session preserved (manual cleanup via 'session clean' required)" else fail "Stale session was unexpectedly removed" fi # Cleanup rm -f "$test_config" } # TEST 51: wireguard patch - displays patched configuration test_wireguard_patch_valid() { test_header "wireguard patch - displays patched configuration" "test_wireguard_patch_valid" test_setup override_for_command_inspection "wireguard_patch" # Enable execution for wireguard import (narrow override) set_function_interpreter "wireguard_import1" "sh" # Create a test config local test_config="/tmp/wg-TEST-PATCH-1.conf" # Cleanup first (in case previous run failed before cleanup) rm -f "$test_config" cat > "$test_config" <<'CONF' [Interface] PrivateKey = test123 Address = 10.2.0.2/32 DNS = 10.2.0.1 [Peer] PublicKey = server123 AllowedIPs = 0.0.0.0/0 Endpoint = 1.2.3.4:51820 CONF chmod 0400 "$test_config" # Import config first run_vpn_switch wireguard import "$test_config" >/dev/null 2>&1 # Run patch command local output=$(run_vpn_switch wireguard patch wg-TEST-PATCH-1.conf 2>&1) # Phase-based approach: DNS handled by DNS phase, not PostUp/PostDown hooks # Verify PostUp/PostDown hooks are NOT present (they should be stripped) if printf '%s\n' "$output" | grep -q "PostUp.*resolvconf"; then fail "Patch output should NOT contain PostUp hook (phase-based DNS)" else pass "Patch output correctly strips PostUp hook (phase-based DNS)" fi if printf '%s\n' "$output" | grep -q "PostDown.*resolvconf"; then fail "Patch output should NOT contain PostDown hook (phase-based DNS)" else pass "Patch output correctly strips PostDown hook (phase-based DNS)" fi # Verify output still contains original config elements if printf '%s\n' "$output" | grep -q "\[Interface\]"; then pass "Patch output preserves [Interface] section" else fail "Patch output lost [Interface] section" fi if printf '%s\n' "$output" | grep -q "\[Peer\]"; then pass "Patch output preserves [Peer] section" else fail "Patch output lost [Peer] section" fi # Keepalive injected (source config has none; default is 25) if printf '%s\n' "$output" | grep -q "^PersistentKeepalive = 25$"; then pass "Patch output injects PersistentKeepalive (default 25)" else fail "Patch output should inject 'PersistentKeepalive = 25'" printf '%s\n' "Output: $output" fi # Cleanup rm -f "$test_config" } # TEST 52: wireguard patch - handles nonexistent config test_wireguard_patch_nonexistent() { test_header "wireguard patch - handles nonexistent config" "test_wireguard_patch_nonexistent" test_setup # Try to patch non-existent config local output=$(run_vpn_switch wireguard patch nonexistent-config.conf 2>&1 || true) if printf '%s\n' "$output" | grep -qiE "not found|does not exist"; then pass "Patch rejects nonexistent config with error" else fail "Patch should reject nonexistent config" fi } # TEST 53: wireguard patch - handles absolute path test_wireguard_patch_absolute() { test_header "wireguard patch - handles absolute path" "test_wireguard_patch_absolute" test_setup override_for_command_inspection "wireguard_patch" # Enable execution for wireguard import (narrow override) set_function_interpreter "wireguard_import1" "sh" # Create a test config with absolute path local test_config="/tmp/wg-TEST-PATCH-ABS.conf" # Cleanup first (in case previous run failed before cleanup) rm -f "$test_config" cat > "$test_config" <<'CONF' [Interface] PrivateKey = test456 Address = 10.2.0.3/32 DNS = 10.2.0.1 CONF chmod 0400 "$test_config" # Import config first run_vpn_switch wireguard import "$test_config" >/dev/null 2>&1 # Run patch with absolute path local output=$(run_vpn_switch wireguard patch "$TEST_DIR/wireguard/wg-TEST-PATCH-ABS.conf" 2>&1) # Phase-based approach: DNS handled by DNS phase, not PostUp hooks if printf '%s\n' "$output" | grep -q "PostUp.*resolvconf"; then fail "Patch should NOT contain PostUp hook (phase-based DNS)" else pass "Patch works with absolute path (strips DNS hooks)" fi # Cleanup rm -f "$test_config" } # TEST 53a: wireguard patch - PersistentKeepalive injection rules test_wireguard_patch_keepalive() { test_header "wireguard patch - PersistentKeepalive injection rules" "test_wireguard_patch_keepalive" test_setup override_for_command_inspection "wireguard_patch" # Enable execution for wireguard import (narrow override) set_function_interpreter "wireguard_import1" "sh" # Config that sets PersistentKeepalive itself - the explicit value must win local test_config="/tmp/wg-TEST-KEEPALIVE-1.conf" # Cleanup first (in case previous run failed before cleanup) rm -f "$test_config" cat > "$test_config" <<'CONF' [Interface] PrivateKey = test123 Address = 10.2.0.2/32 [Peer] PublicKey = server123 AllowedIPs = 0.0.0.0/0 Endpoint = 1.2.3.4:51820 PersistentKeepalive = 7 CONF chmod 0400 "$test_config" run_vpn_switch wireguard import "$test_config" >/dev/null 2>&1 local output=$(run_vpn_switch wireguard patch wg-TEST-KEEPALIVE-1.conf 2>&1) if printf '%s\n' "$output" | grep -q "^PersistentKeepalive = 7$"; then pass "Explicit PersistentKeepalive from config preserved" else fail "Explicit PersistentKeepalive from config lost" printf '%s\n' "Output: $output" fi if printf '%s\n' "$output" | grep -q "^PersistentKeepalive = 25$"; then fail "Default keepalive must not be injected next to an explicit one" printf '%s\n' "Output: $output" else pass "No duplicate keepalive injected" fi # VPN_SWITCH_KEEPALIVE_wireguard=0 disables injection entirely local test_config2="/tmp/wg-TEST-KEEPALIVE-2.conf" rm -f "$test_config2" cat > "$test_config2" <<'CONF' [Interface] PrivateKey = test123 Address = 10.2.0.2/32 [Peer] PublicKey = server123 AllowedIPs = 0.0.0.0/0 Endpoint = 1.2.3.4:51820 CONF chmod 0400 "$test_config2" run_vpn_switch wireguard import "$test_config2" >/dev/null 2>&1 run_vpn_switch setenv VPN_SWITCH_KEEPALIVE_wireguard 0 >/dev/null 2>&1 output=$(run_vpn_switch wireguard patch wg-TEST-KEEPALIVE-2.conf 2>&1) run_vpn_switch unsetenv VPN_SWITCH_KEEPALIVE_wireguard >/dev/null 2>&1 if printf '%s\n' "$output" | grep -qi "PersistentKeepalive"; then fail "Keepalive injected although VPN_SWITCH_KEEPALIVE_wireguard=0" printf '%s\n' "Output: $output" else pass "VPN_SWITCH_KEEPALIVE_wireguard=0 disables injection" fi # Cleanup rm -f "$test_config" "$test_config2" } # TEST 54: wireguard info - displays category information test_wireguard_info_category() { test_header "wireguard info - displays category information" "test_wireguard_info_category" test_setup # Enable execution for wireguard import and add (narrow override) set_function_interpreter "wireguard_add" "sh" set_function_interpreter "wireguard_import1" "sh" # Create a test config and category local test_config="/tmp/wg-TEST-INFO-1.conf" # Cleanup first (in case previous run failed before cleanup) rm -f "$test_config" cat > "$test_config" <<'CONF' [Interface] PrivateKey = testinfo123 Address = 10.2.0.5/32 DNS = 10.2.0.1 CONF chmod 0400 "$test_config" # Import config and create category with links run_vpn_switch wireguard import "$test_config" >/dev/null 2>&1 run_vpn_switch wireguard add testinfo >/dev/null 2>&1 run_vpn_switch wireguard add testinfo wg-TEST-INFO-1.conf >/dev/null 2>&1 run_vpn_switch wireguard add testinfo wg-TEST-INFO-1.conf info1 >/dev/null 2>&1 # Run info command on category local output=$(run_vpn_switch wireguard info testinfo 2>&1 || true) # Verify category header displayed if printf '%s\n' "$output" | grep -q "# Category: testinfo"; then pass "Category header displayed" else fail "Category header missing" fi # Verify counts section if printf '%s\n' "$output" | grep -q "# Configs:"; then pass "Config count displayed" else fail "Config count missing" fi if printf '%s\n' "$output" | grep -q "# Links:"; then pass "Link count displayed" else fail "Link count missing" fi # Verify path displayed if printf '%s\n' "$output" | grep -q "# Path:.*testinfo"; then pass "Category path displayed" else fail "Category path missing" fi # Verify contents section if printf '%s\n' "$output" | grep -q "# Contents:"; then pass "Contents section header displayed" else fail "Contents section missing" fi # Verify symlinks shown (should show ->) if printf '%s\n' "$output" | grep -q -- "->"; then pass "Category contents listed with symlink arrows" else fail "Contents not shown properly" fi # Cleanup rm -f "$test_config" } # TEST 55: session save - save current session as default test_session_save_default() { test_header "session save - save current session as default" "test_session_save_default" test_setup # Set up mocks for network operations (wg-quick, pfctl, wg9 interface) setup_session_test_mocks # Enable execution for wireguard import and session save (narrow override) # Note: wireguard start is a combinator function - uses default interpreter set_function_interpreter "wireguard_import1" "sh" set_function_interpreter "session_save" "sh" # Create and import a test config local test_config="/tmp/wg-TEST-SAVE-DEFAULT.conf" # Cleanup first (in case previous run failed before cleanup) rm -f "$test_config" cat > "$test_config" <<'CONF' [Interface] PrivateKey = testsavedefault123 Address = 10.2.0.10/32 DNS = 10.2.0.1 CONF chmod 0400 "$test_config" run_vpn_switch wireguard import "$test_config" >/dev/null 2>&1 # Start a session (creates active session) local start_output=$(run_vpn_switch wireguard start wg-TEST-SAVE-DEFAULT.conf 2>&1) local session_id=$(echo "$start_output" | grep -oE '[Ss]ession:? [0-9]+|PID:? [0-9]+|session [0-9]+' | grep -oE '[0-9]+' | head -1) # Save session as default run_vpn_switch session save >/dev/null 2>&1 # Verify default symlink was created if [ -L "$TEST_DIR/session/default" ]; then pass "Default session symlink created" else fail "Default session symlink not created" fi # Verify symlink points to correct session local link_target=$(readlink "$TEST_DIR/session/default") if echo "$link_target" | grep -q "$session_id"; then pass "Default symlink points to correct session" else fail "Default symlink points to wrong session" fi # Cleanup rm -f "$test_config" } # TEST 56: session save - save with custom name test_session_save_named() { test_header "session save work - save with custom name" "test_session_save_named" test_setup # Set up mocks for network operations (wg-quick, pfctl, wg9 interface) setup_session_test_mocks # Enable execution for wireguard import and session save (narrow override) # Note: wireguard start is a combinator function - uses default interpreter set_function_interpreter "wireguard_import1" "sh" set_function_interpreter "session_save" "sh" # Create and import a test config local test_config="/tmp/wg-TEST-SAVE-NAMED.conf" # Cleanup first (in case previous run failed before cleanup) rm -f "$test_config" cat > "$test_config" <<'CONF' [Interface] PrivateKey = testsavenamed456 Address = 10.2.0.11/32 DNS = 10.2.0.1 CONF chmod 0400 "$test_config" run_vpn_switch wireguard import "$test_config" >/dev/null 2>&1 # Start a session local start_output=$(run_vpn_switch wireguard start wg-TEST-SAVE-NAMED.conf 2>&1) local session_id=$(echo "$start_output" | grep -oE '[Ss]ession:? [0-9]+|PID:? [0-9]+|session [0-9]+' | grep -oE '[0-9]+' | head -1) # Save session with custom name "work" run_vpn_switch session save work >/dev/null 2>&1 # Verify named symlink was created if [ -L "$TEST_DIR/session/work" ]; then pass "Named session symlink created" else fail "Named session symlink not created" fi # Verify symlink points to correct session local link_target=$(readlink "$TEST_DIR/session/work") if echo "$link_target" | grep -q "$session_id"; then pass "Named symlink points to correct session" else fail "Named symlink points to wrong session" fi # Cleanup rm -f "$test_config" } # TEST 57: session start - resume default session test_session_start_default() { test_header "session start - resume default session" "test_session_start_default" test_setup # Set up mocks for network operations (wg-quick, pfctl, wg9 interface) setup_session_test_mocks # Enable execution for terminal commands (narrow override) # Note: wireguard start and session start are combinator functions - use default interpreter set_function_interpreter "wireguard_import1" "sh" set_function_interpreter "session_save" "sh" set_function_interpreter "session_stop" "sh" # Create and import a test config local test_config="/tmp/wg-TEST-START-DEFAULT.conf" # Cleanup first (in case previous run failed before cleanup) rm -f "$test_config" cat > "$test_config" <<'CONF' [Interface] PrivateKey = teststartdefault789 Address = 10.2.0.12/32 DNS = 10.2.0.1 CONF chmod 0400 "$test_config" run_vpn_switch wireguard import "$test_config" >/dev/null 2>&1 # Start a session and save as default run_vpn_switch wireguard start wg-TEST-START-DEFAULT.conf >/dev/null 2>&1 local session_id=$(ls -t "$TEST_DIR/.session" | head -1) run_vpn_switch session save >/dev/null 2>&1 # Verify connect.sh exists before stopping if [ -f "$TEST_DIR/.session/$session_id/connect.sh" ]; then pass "Connect script created before stopping" else fail "Connect script not found" fi # Stop the session run_vpn_switch stop >/dev/null 2>&1 # Verify session was stopped but session data remains (because it's saved/referenced) if [ -d "$TEST_DIR/.session/$session_id" ]; then pass "Session directory kept for resumption (referenced)" else fail "Session directory was removed (should be kept for resumption)" fi if [ -L "$TEST_DIR/session/default" ]; then pass "Default symlink persists after stop" else fail "Default symlink was removed" fi # Resume default session local resume_output=$(run_vpn_switch session start 2>&1) # Verify resumption message or success if echo "$resume_output" | grep -qiE "resuming|starting|session"; then pass "Session start executed successfully" else fail "Session start failed: $resume_output" fi # Cleanup rm -f "$test_config" } # TEST 58: session start - resume named session test_session_start_named() { test_header "session start work - resume named session" "test_session_start_named" test_setup # Set up mocks for network operations (wg-quick, pfctl, wg9 interface) setup_session_test_mocks # Enable execution for terminal commands (narrow override) # Note: wireguard start and session start are combinator functions - use default interpreter set_function_interpreter "wireguard_import1" "sh" set_function_interpreter "session_save" "sh" set_function_interpreter "session_stop" "sh" # Create and import a test config local test_config="/tmp/wg-TEST-START-NAMED.conf" rm -f "$test_config" # Remove if exists from previous run cat > "$test_config" <<'CONF' [Interface] PrivateKey = teststartnamed012 Address = 10.2.0.13/32 DNS = 10.2.0.1 CONF chmod 0400 "$test_config" run_vpn_switch wireguard import "$test_config" >/dev/null 2>&1 # Start a session and save with name "work" run_vpn_switch wireguard start wg-TEST-START-NAMED.conf >/dev/null 2>&1 run_vpn_switch session save work >/dev/null 2>&1 # Stop the session run_vpn_switch stop >/dev/null 2>&1 # Verify named symlink still exists if [ -L "$TEST_DIR/session/work" ]; then pass "Named symlink persists after stop" else fail "Named symlink was removed" fi # Resume named session local resume_output=$(run_vpn_switch session start work 2>&1) # Verify resumption if echo "$resume_output" | grep -qiE "resuming|starting|session|work"; then pass "Named session start executed successfully" else fail "Named session start failed: $resume_output" fi # Cleanup rm -f "$test_config" } # TEST 59: session start - resume by PID test_session_start_by_pid() { test_header "session start - resume by PID" "test_session_start_by_pid" test_setup # Set up mocks for network operations (wg-quick, pfctl, wg9 interface) setup_session_test_mocks # Enable execution for terminal commands (narrow override) # Note: wireguard start and session start are combinator functions - use default interpreter set_function_interpreter "wireguard_import1" "sh" set_function_interpreter "session_save" "sh" set_function_interpreter "session_stop" "sh" # Create and import a test config local test_config="/tmp/wg-TEST-START-PID.conf" # Cleanup first (in case previous run failed before cleanup) rm -f "$test_config" cat > "$test_config" <<'CONF' [Interface] PrivateKey = teststartpid345 Address = 10.2.0.14/32 DNS = 10.2.0.1 CONF chmod 0400 "$test_config" run_vpn_switch wireguard import "$test_config" >/dev/null 2>&1 # Start a session and capture PID local start_output=$(run_vpn_switch wireguard start wg-TEST-START-PID.conf 2>&1) local session_id=$(echo "$start_output" | grep -oE '[Ss]ession:? [0-9]+|PID:? [0-9]+|session [0-9]+' | grep -oE '[0-9]+' | head -1) # Save session (to preserve connect.sh) run_vpn_switch session save pid-test >/dev/null 2>&1 # Stop the session run_vpn_switch stop >/dev/null 2>&1 # Resume by explicit PID local resume_output=$(run_vpn_switch session start "$session_id" 2>&1) # Verify resumption by PID if echo "$resume_output" | grep -qiE "resuming|starting|session|$session_id"; then pass "Session start by PID executed successfully" else fail "Session start by PID failed: $resume_output" fi # Cleanup rm -f "$test_config" } # TEST 60: Protocol-agnostic import - detects WireGuard config test_protocol_agnostic_import_wireguard() { test_header "import (protocol-agnostic) - detects WireGuard .conf file" "test_protocol_agnostic_import_wireguard" test_setup # Override wireguard_import to cat (profile-independent) override_for_command_inspection "wireguard_import1" # Create test WireGuard config local test_config="/tmp/wg-PROTO-IMPORT.conf" # Cleanup first (in case previous run failed before cleanup) rm -f "$test_config" cat > "$test_config" <<'CONF' [Interface] PrivateKey = protoimporttest123 Address = 10.2.0.20/32 DNS = 10.2.0.1 CONF chmod 0400 "$test_config" # Test protocol-agnostic import (combinator delegates to wireguard import) local output=$(run_vpn_switch import "$test_config" 2>&1) # Verify wrapper detected .conf and delegated to wireguard import if printf '%s\n' "$output" | grep -q "# Import WireGuard config"; then pass "Protocol-agnostic import detected .conf and delegated to wireguard import" else fail "Protocol-agnostic import failed to delegate: $output" fi # Verify output contains cp command with correct paths if printf '%s\n' "$output" | grep -q "cp.*$test_config.*wireguard/wg-PROTO-IMPORT.conf"; then pass "Protocol-agnostic import generates correct cp command" else fail "Protocol-agnostic import missing correct cp command" fi # Cleanup rm -f "$test_config" } # TEST 61: Protocol-agnostic list - delegates to protocol-specific lists test_protocol_agnostic_list() { test_header "list (protocol-agnostic) - delegates to all protocols" "test_protocol_agnostic_list" test_setup # Leave VPN_SWITCH_INTERPRETER_wireguard_list as default (cat) - we test output # Test protocol-agnostic list (combinator delegates to wireguard list) local output=$(run_vpn_switch list 2>&1) # Verify wrapper delegated to wireguard list (check for WireGuard header) if printf '%s\n' "$output" | grep -q "WireGuard Configurations"; then pass "Protocol-agnostic list delegated to wireguard list" else fail "Protocol-agnostic list failed to delegate: $output" fi # Verify output shows configs section (check for "Configuration files:") if printf '%s\n' "$output" | grep -q "Configuration files:"; then pass "Protocol-agnostic list shows wireguard config sections" else fail "Protocol-agnostic list missing config sections" fi # Verify no duplicate entries (the section labels repeat per protocol; an entry names a path) local total_lines=$(printf '%s\n' "$output" | grep -v '^[ \t]*$' | grep -v '#' | grep '/' | wc -l | tr -d ' ') local unique_lines=$(printf '%s\n' "$output" | grep -v '^[ \t]*$' | grep -v '#' | grep '/' | sort -u | wc -l | tr -d ' ') if [ "$total_lines" = "$unique_lines" ]; then pass "Protocol-agnostic list has no duplicate lines ($total_lines unique)" else fail "Protocol-agnostic list has duplicates" "$total_lines unique lines" "$unique_lines after deduplication" fi } # TEST 62: Protocol-agnostic link - creates protocol-level alias test_protocol_agnostic_link() { test_header "link (protocol-agnostic) - creates protocol-level alias" "test_protocol_agnostic_link" test_setup # Override interpreter to inspect link command output (profile-independent) override_for_command_inspection "link" # _link2 is a leaf function - test its output without execution # Protocol-level link creates: $BASE/alias -> $BASE/protocol # Test link command generation local output=$(run_vpn_switch link testproto wireguard 2>&1 || true) # Verify output contains ln command if printf '%s\n' "$output" | grep -q "ln -s"; then pass "Protocol-agnostic link generates ln command" else fail "Protocol-agnostic link missing ln command: $output" fi # Verify relative path (./wireguard) if printf '%s\n' "$output" | grep -q './wireguard'; then pass "Protocol-agnostic link uses correct relative path (./wireguard)" else fail "Protocol-agnostic link has incorrect path" fi # Verify target path includes testproto if printf '%s\n' "$output" | grep -q "testproto"; then pass "Protocol-agnostic link includes correct alias name" else fail "Protocol-agnostic link missing alias name" fi } # TEST 63: Protocol-agnostic remove - removes protocol-level link test_protocol_agnostic_remove() { test_header "remove (protocol-agnostic) - removes protocol-level link" "test_protocol_agnostic_remove" test_setup # Override interpreter to inspect remove command output (profile-independent) override_for_command_inspection "remove" # _remove1 is a leaf function - test its output without execution # First create a dummy symlink for testing ln -s "./wireguard" "$TEST_DIR/testremove" # Test remove command generation local output=$(run_vpn_switch remove testremove 2>&1) # Verify output contains rm command if printf '%s\n' "$output" | grep -q "rm.*testremove"; then pass "Protocol-agnostic remove generates rm command" else fail "Protocol-agnostic remove missing rm command: $output" fi # Verify comment about removing link if printf '%s\n' "$output" | grep -qiE "remove.*link|remove.*protocol"; then pass "Protocol-agnostic remove includes descriptive comment" else fail "Protocol-agnostic remove missing descriptive comment" fi # Test error on non-existent link (should get error, not commands) local err_output=$(run_vpn_switch remove nonexistent 2>&1 || true) if echo "$err_output" | grep -qiE "not found|error"; then pass "Protocol-agnostic remove errors on non-existent link" else fail "Protocol-agnostic remove should error on non-existent link" fi } # TEST 64: Protocol-agnostic validate - delegates to protocol-specific validate test_protocol_agnostic_validate() { test_header "validate (protocol-agnostic) - delegates to all protocols" "test_protocol_agnostic_validate" test_setup # The validate pins strip the leading '# ' (cut -b3-) -- we test the output # Test protocol-agnostic validate (combinator delegates to wireguard validate) local output=$(run_vpn_switch validate 2>&1) # Verify wrapper delegated to wireguard validate (check for validation header) if printf '%s\n' "$output" | grep -q "WireGuard Configuration Validation"; then pass "Protocol-agnostic validate delegated to wireguard validate" else fail "Protocol-agnostic validate failed to delegate: $output" fi # Verify output shows validation sections if printf '%s\n' "$output" | grep -qE "(^|# )(Checking|Validation)"; then pass "Protocol-agnostic validate shows validation output" else fail "Protocol-agnostic validate missing validation output" fi } #----------------------------------------------------------------------------- # System Validation Tests (Phase 4.1 - Feature 3) #----------------------------------------------------------------------------- # TEST 65: Network validate - checks network prerequisites test_network_validate() { test_header "network validate - check network prerequisites" "test_network_validate" test_setup # Run network validate local output=$(run_vpn_switch network validate) # Verify validation header if printf '%s\n' "$output" | grep -q "Network Prerequisites Validation"; then pass "Network validation header displayed" else fail "Network validation header missing" printf '%s\n' "Output: $output" fi # Verify summary on clean configuration. # Host-dependent findings (missing external interface, stale VPN interface) # are warnings, not errors — accept the warnings summary as passing. if printf '%s\n' "$output" | grep -q -e "Network prerequisites OK" -e "warning(s)"; then pass "Network validate passed on clean configuration" else fail "Network validate did not pass" printf '%s\n' "Output: $output" fi } # TEST 66: Permissions validate - checks file/directory permissions test_permissions_validate() { test_header "permissions validate - check filesystem permissions" "test_permissions_validate" test_setup # Run permissions validate local output=$(run_vpn_switch permissions validate) # Verify validation header if printf '%s\n' "$output" | grep -q "Permissions Validation"; then pass "Permissions validation header displayed" else fail "Permissions validation header missing" printf '%s\n' "Output: $output" fi # Verify summary on clean configuration. # Permission drift in the test DB is a warning, not an error — # accept the warnings summary as passing. if printf '%s\n' "$output" | grep -q -e "Permissions OK" -e "permission warning(s)"; then pass "Permissions validate passed on clean configuration" else fail "Permissions validate did not pass" printf '%s\n' "Output: $output" fi } # TEST 67: Environment validate - checks environment configuration test_environment_validate() { test_header "environment validate - check environment variables" "test_environment_validate" test_setup # Run environment validate local output=$(run_vpn_switch environment validate) # Verify validation header if printf '%s\n' "$output" | grep -q "Environment Configuration Validation"; then pass "Environment validation header displayed" else fail "Environment validation header missing" printf '%s\n' "Output: $output" fi # Verify summary on clean configuration. # Unset optional variables are warnings, not errors — accept the # warnings summary as passing. if printf '%s\n' "$output" | grep -q -e "Environment configuration OK" -e "configuration warning(s)"; then pass "Environment validate passed on clean configuration" else fail "Environment validate did not pass" printf '%s\n' "Output: $output" fi } # TEST 68: Batch validate - includes all system validators test_batch_validate_includes_system() { test_header "validate (batch) - includes system validators" "test_batch_validate_includes_system" test_setup # Run batch validate (batch combinator) local output=$(run_vpn_switch validate 2>&1) # Verify it includes database validate if printf '%s\n' "$output" | grep -q "Database Structure Validation"; then pass "Batch validate includes database validator" else fail "Batch validate missing database validator" fi # Verify it includes binaries validate if printf '%s\n' "$output" | grep -q "Binary Availability Validation"; then pass "Batch validate includes binaries validator" else fail "Batch validate missing binaries validator" fi # Verify it includes phases validate if printf '%s\n' "$output" | grep -q "Phase System Validation"; then pass "Batch validate includes phases validator" else fail "Batch validate missing phases validator" fi # Verify it includes network validate if printf '%s\n' "$output" | grep -q "Network Prerequisites Validation"; then pass "Batch validate includes network validator" else fail "Batch validate missing network validator" fi # Verify it includes permissions validate if printf '%s\n' "$output" | grep -q "Permissions Validation"; then pass "Batch validate includes permissions validator" else fail "Batch validate missing permissions validator" fi # Verify it includes environment validate if printf '%s\n' "$output" | grep -q "Environment Configuration Validation"; then pass "Batch validate includes environment validator" else fail "Batch validate missing environment validator" fi } #----------------------------------------------------------------------------- # Interpreter Resolution Tests (Lower Half of lookup_interpreter) #----------------------------------------------------------------------------- # TEST 65: Single underscore → TERMINAL_INTERPRETER (intrinsic classification) test_interpreter_single_underscore() { test_header "interpreter resolution - single underscore uses TERMINAL_INTERPRETER" "test_interpreter_single_underscore" test_setup # Set TERMINAL_INTERPRETER to distinctive value (NOT cat, NOT sh) # This tests the lower half of lookup_interpreter() (intrinsic classification) run_vpn_switch setenv "VPN_SWITCH_TERMINAL_INTERPRETER" "echo TERMINAL_FIRED" # IMPORTANT: Remove function-specific overrides that would block intrinsic classification # The profile may include VPN_SWITCH_INTERPRETER_wireguard_list (arity-less) # and VPN_SWITCH_INTERPRETER_wireguard_list0 (arity-specific), both of which # have higher priority than TERMINAL_INTERPRETER rm -f "$TEST_DIR/.env/default/VPN_SWITCH_INTERPRETER_wireguard_list" \ "$TEST_DIR/.env/default/VPN_SWITCH_INTERPRETER_wireguard_list0" rm -f "$TEST_DIR/.env/local/VPN_SWITCH_INTERPRETER_wireguard_list" \ "$TEST_DIR/.env/local/VPN_SWITCH_INTERPRETER_wireguard_list0" # Pick a single underscore (terminal) function # _wireguard_list0 is a good choice - it's a terminal function local output=$(run_vpn_switch wireguard list 2>&1) # Verify TERMINAL_INTERPRETER was used (output shows it was executed) if printf '%s\n' "$output" | grep -q "TERMINAL_FIRED"; then pass "Single underscore function used TERMINAL_INTERPRETER" else fail "Single underscore function did not use TERMINAL_INTERPRETER" printf '%s\n' "Output: $output" fi } # TEST 66: Double underscore → COMBINATOR_INTERPRETER (intrinsic classification) test_interpreter_double_underscore() { test_header "interpreter resolution - double underscore uses COMBINATOR_INTERPRETER" "test_interpreter_double_underscore" test_setup # Set COMBINATOR_INTERPRETER to distinctive value # This tests intrinsic classification for double underscore functions run_vpn_switch setenv "VPN_SWITCH_COMBINATOR_INTERPRETER" "echo COMBINATOR_FIRED" # IMPORTANT: Remove function-specific overrides that would block intrinsic classification # The profile may include function-specific interpreter settings rm -f "$TEST_DIR/.env/default/VPN_SWITCH_INTERPRETER_wireguard_start" rm -f "$TEST_DIR/.env/local/VPN_SWITCH_INTERPRETER_wireguard_start" # Pick a double underscore (combinator) function # __wireguard_start1 is a good choice - it's a combinator that delegates to _wireguard_start1 local output=$(run_vpn_switch wireguard start privacy 2>&1) # Verify COMBINATOR_INTERPRETER was used if printf '%s\n' "$output" | grep -q "COMBINATOR_FIRED"; then pass "Double underscore function used COMBINATOR_INTERPRETER" else fail "Double underscore function did not use COMBINATOR_INTERPRETER" printf '%s\n' "Output: $output" fi } # TEST 67: Triple underscore → BATCH_COMBINATOR_INTERPRETER (intrinsic classification) test_interpreter_triple_underscore() { test_header "interpreter resolution - triple underscore uses BATCH_COMBINATOR_INTERPRETER" "test_interpreter_triple_underscore" test_setup # Set BATCH_COMBINATOR_INTERPRETER to distinctive value # This tests intrinsic classification for triple underscore functions run_vpn_switch setenv "VPN_SWITCH_BATCH_COMBINATOR_INTERPRETER" "echo BATCH_COMBINATOR_FIRED" # IMPORTANT: Do NOT set function-specific override # We want to test that triple underscore functions use BATCH_COMBINATOR_INTERPRETER # Pick a triple underscore (batch combinator) function # ___dump0 is a good choice - it outputs multiple delegation commands local output=$(run_vpn_switch dump 2>&1) # Verify BATCH_COMBINATOR_INTERPRETER was used if printf '%s\n' "$output" | grep -q "BATCH_COMBINATOR_FIRED"; then pass "Triple underscore function used BATCH_COMBINATOR_INTERPRETER" else fail "Triple underscore function did not use BATCH_COMBINATOR_INTERPRETER" printf '%s\n' "Output: $output" fi } # TEST 68: Resolution priority - function-specific override beats intrinsic classification test_interpreter_resolution_priority() { test_header "interpreter resolution - function-specific override beats intrinsic classification" "test_interpreter_resolution_priority" test_setup # Set BOTH function-specific override AND intrinsic classification # Function-specific should win (upper half beats lower half) run_vpn_switch setenv "VPN_SWITCH_TERMINAL_INTERPRETER" "echo TERMINAL_DEFAULT" # Now set function-specific override (should take priority) set_function_interpreter "wireguard_list" "echo FUNCTION_SPECIFIC_OVERRIDE" # Call wireguard list (single underscore = terminal function) local output=$(run_vpn_switch wireguard list 2>&1) # Verify function-specific override was used (NOT intrinsic classification) if printf '%s\n' "$output" | grep -q "FUNCTION_SPECIFIC_OVERRIDE"; then pass "Function-specific override takes priority over intrinsic classification" else fail "Function-specific override did not take priority" printf '%s\n' "Output: $output" fi # Verify intrinsic classification was NOT used if ! printf '%s\n' "$output" | grep -q "TERMINAL_DEFAULT"; then pass "Intrinsic classification was correctly bypassed" else fail "Intrinsic classification was incorrectly used" printf '%s\n' "Output: $output" fi } #----------------------------------------------------------------------------- # Protocol-Agnostic Start Tests (Phase 3.2.1) #----------------------------------------------------------------------------- test_start_wireguard_only() { test_header "start - auto-detect WireGuard config" "test_start_wireguard_only" test_setup # Create WireGuard config only (must be valid for start to proceed) mkdir -p "$TEST_DIR/wireguard" cat > "$TEST_DIR/wireguard/wg-test.conf" <<'EOF' [Interface] PrivateKey = test-key Address = 10.2.0.2/32 EOF chmod 0400 "$TEST_DIR/wireguard/wg-test.conf" # Test: should delegate to wireguard start # We observe the resolution result via the session metadata that # session create + session populate write to disk before the phase # scripts run (and possibly fail without root). Stdout no longer # carries dispatch evidence — _batch2's "# Executing: ..." lines went # to trace_log in commit fd79dc8 (was needed so `dump` stderr stays clean). run_vpn_switch start wg-test.conf > /dev/null 2>&1 || true local sess=$(ls -d "$TEST_DIR"/.session/*/ 2>/dev/null | head -1) local sess_protocol="" [ -n "$sess" ] && sess_protocol=$(cat "$sess/protocol" 2>/dev/null) if [ "$sess_protocol" = "wireguard" ]; then pass "Delegates to wireguard commands for WireGuard-only config (session.protocol=wireguard)" else fail "Did not delegate to wireguard start" printf '%s\n' " session: ${sess:-none}, session.protocol: ${sess_protocol:-none}" ls -la "$TEST_DIR/.session/" 2>/dev/null fi } test_start_openvpn_only() { test_header "start - auto-detect OpenVPN config" "test_start_openvpn_only" test_setup # Create OpenVPN config only (must be valid for start to proceed) mkdir -p "$TEST_DIR/openvpn" cat > "$TEST_DIR/openvpn/ovpn-test.ovpn" <<'EOF' client dev tun proto udp remote 1.2.3.4 1194 EOF chmod 0400 "$TEST_DIR/openvpn/ovpn-test.ovpn" # Test: should delegate to openvpn start # Twin of test_start_wireguard_only — assert on session.protocol # artifact rather than _batch2 stdout, which moved to trace_log in fd79dc8. run_vpn_switch start ovpn-test.ovpn > /dev/null 2>&1 || true local sess=$(ls -d "$TEST_DIR"/.session/*/ 2>/dev/null | head -1) local sess_protocol="" [ -n "$sess" ] && sess_protocol=$(cat "$sess/protocol" 2>/dev/null) if [ "$sess_protocol" = "openvpn" ]; then pass "Delegates to openvpn commands for OpenVPN-only config (session.protocol=openvpn)" else fail "Did not delegate to openvpn start" printf '%s\n' " session: ${sess:-none}, session.protocol: ${sess_protocol:-none}" ls -la "$TEST_DIR/.session/" 2>/dev/null fi } test_start_not_found() { test_header "start - error on nonexistent config" "test_start_not_found" test_setup # Test: should error with helpful message local output=$(run_vpn_switch start nonexistent 2>&1) if printf '%s\n' "$output" | grep -q "config not found: nonexistent"; then pass "Error message for nonexistent config" else fail "Did not show proper error message" printf '%s\n' "Output: $output" fi # Verify it searched both directories if printf '%s\n' "$output" | grep -q "wireguard/nonexistent" && printf '%s\n' "$output" | grep -q "openvpn/nonexistent"; then pass "Error shows both search locations" else fail "Error does not show search locations" printf '%s\n' "Output: $output" fi } test_start_no_default() { test_header "start - error when no configs exist (Phase 3.4.1)" "test_start_no_default" test_setup # Remove all configs to test empty database case rm -f "$TEST_DIR/wireguard"/*.conf 2>/dev/null || true rm -f "$TEST_DIR/openvpn"/*.ovpn 2>/dev/null || true # Test: should error when no VPN configs are available local output=$(run_vpn_switch start 2>&1) if printf '%s\n' "$output" | grep -q "no VPN protocols found"; then pass "Error message when no configs available" else fail "Did not show proper error message" printf '%s\n' "Output: $output" fi # Verify helpful hint is provided if printf '%s\n' "$output" | grep -q "vpn-switch import"; then pass "Error provides hint to import configs" else fail "Error does not provide helpful hint" printf '%s\n' "Output: $output" fi } test_start_with_default_wireguard() { test_header "start - use default link (WireGuard) via API" "test_start_with_default_wireguard" test_setup # Create WireGuard config using proper API local test_config="$TEST_BASE_DIR/wg-test-default.conf" rm -f "$test_config" cat > "$test_config" <<'EOF' [Interface] PrivateKey = test-key-default-wg Address = 10.2.0.3/32 EOF chmod 0400 "$test_config" # Import config through API run_vpn_switch wireguard import "$test_config" >/dev/null 2>&1 # Override interpreter to prevent actual connection override_for_command_inspection "wireguard_connect" # Start session (creates .session/) local start_output start_output=$(run_vpn_switch wireguard start wg-test-default 2>&1) # Extract PID from output local session_pid session_pid=$(echo "$start_output" | grep -o 'session [0-9]*' | awk '{print $2}') if [ -z "$session_pid" ]; then fail "Could not create session for default link test" return fi # Save as default using API VPN_SWITCH_BASE="$TEST_DIR" \ VPN_SWITCH_SESSION_PID="$session_pid" \ run_vpn_switch session save >/dev/null 2>&1 # Verify default link was created if [ -L "$TEST_DIR/session/default" ]; then pass "Default session link created via API" else fail "Default session link not created" return fi # Now test: start with no args should delegate to session start # Override interpreter to inspect delegation (not execute) override_for_command_inspection "start" local output output=$(run_vpn_switch start 2>&1) if printf '%s\n' "$output" | grep -q 'session start'; then pass "Start with no args resolves to default session" else fail "Default link did not resolve correctly" printf '%s\n' "Output: $output" fi } test_start_with_default_openvpn() { test_header "start - use default link (OpenVPN) via API" "test_start_with_default_openvpn" test_setup # Create OpenVPN config using proper API local test_config="$TEST_BASE_DIR/ovpn-test-default.ovpn" rm -f "$test_config" cat > "$test_config" <<'EOF' client dev tun proto udp remote 5.6.7.8 1194 EOF chmod 0400 "$test_config" # Import config through API run_vpn_switch openvpn import "$test_config" >/dev/null 2>&1 # Override interpreter to prevent actual connection override_for_command_inspection "openvpn_connect" # Start session (creates .session/) local start_output start_output=$(run_vpn_switch openvpn start ovpn-test-default 2>&1) # Extract PID from output local session_pid session_pid=$(echo "$start_output" | grep -o 'session [0-9]*' | awk '{print $2}') if [ -z "$session_pid" ]; then fail "Could not create session for default link test" return fi # Save as default using API VPN_SWITCH_BASE="$TEST_DIR" \ VPN_SWITCH_SESSION_PID="$session_pid" \ run_vpn_switch session save >/dev/null 2>&1 # Verify default link was created if [ -L "$TEST_DIR/session/default" ]; then pass "Default session link created via API" else fail "Default session link not created" return fi # Now test: start with no args should delegate to session start # Override interpreter to inspect delegation (not execute) override_for_command_inspection "start" local output output=$(run_vpn_switch start 2>&1) if printf '%s\n' "$output" | grep -q 'session start'; then pass "Start with no args resolves to default session" else fail "Default link did not resolve correctly" printf '%s\n' "Output: $output" fi } test_start_default_invalid_target() { test_header "start - error on invalid default link target" "test_start_default_invalid_target" test_setup # Create WireGuard config using proper API local test_config="$TEST_BASE_DIR/wg-test-invalid.conf" rm -f "$test_config" cat > "$test_config" <<'EOF' [Interface] PrivateKey = test-key-invalid Address = 10.2.0.4/32 EOF chmod 0400 "$test_config" # Import config through API run_vpn_switch wireguard import "$test_config" >/dev/null 2>&1 # Override interpreter to prevent actual connection override_for_command_inspection "wireguard_connect" # Start session and save as default local start_output start_output=$(run_vpn_switch wireguard start wg-test-invalid 2>&1) local session_pid session_pid=$(echo "$start_output" | grep -o 'session [0-9]*' | awk '{print $2}') if [ -n "$session_pid" ]; then VPN_SWITCH_BASE="$TEST_DIR" \ VPN_SWITCH_SESSION_PID="$session_pid" \ run_vpn_switch session save >/dev/null 2>&1 fi # Now manually corrupt the default link to test error handling # (This simulates manual filesystem corruption/manipulation) rm -f "$TEST_DIR/session/default" ln -s "../invalid/config.conf" "$TEST_DIR/session/default" # Test: should error with helpful message local output output=$(run_vpn_switch start 2>&1) if printf '%s\n' "$output" | grep -qE "Error|not found|invalid"; then pass "Error detected for invalid default session target" else fail "Did not detect invalid default session target" printf '%s\n' "Output: $output" fi } test_init_extension_links() { test_header "init - creates protocol extension links (Phase 3.4.1)" "test_init_extension_links" test_setup # Run init run_vpn_switch init minimal >/dev/null 2>&1 # Verify extension links exist if [ -L "$TEST_DIR/conf" ]; then pass "conf link created" else fail "conf link not created" fi if [ -L "$TEST_DIR/ovpn" ]; then pass "ovpn link created" else fail "ovpn link not created" fi # Verify targets local conf_target=$(readlink "$TEST_DIR/conf") if [ "$conf_target" = "wireguard" ]; then pass "conf → wireguard" else fail "conf link has wrong target: $conf_target" fi local ovpn_target=$(readlink "$TEST_DIR/ovpn") if [ "$ovpn_target" = "openvpn" ]; then pass "ovpn → openvpn" else fail "ovpn link has wrong target: $ovpn_target" fi } # Phase 4.4: Init function tests test_database_init() { test_header "database init - creates directory structure (Phase 4.4)" "test_database_init" test_setup # database_init0 creates all required directories run_vpn_switch init minimal >/dev/null 2>&1 # Verify core directories [ -d "$TEST_DIR/wireguard" ] && pass "wireguard/ created" || fail "wireguard/ missing" [ -d "$TEST_DIR/openvpn" ] && pass "openvpn/ created" || fail "openvpn/ missing" [ -d "$TEST_DIR/.session" ] && pass ".session/ created" || fail ".session/ missing" [ -d "$TEST_DIR/session" ] && pass "session/ created" || fail "session/ missing" [ -d "$TEST_DIR/.env/default" ] && pass ".env/default/ created" || fail ".env/default/ missing" [ -d "$TEST_DIR/.env/local" ] && pass ".env/local/ created" || fail ".env/local/ missing" [ -d "$TEST_DIR/.include/phase" ] && pass ".include/phase/ created" || fail ".include/phase/ missing" chmod 0755 "$TEST_DIR/openpgp" local init_out=$(run_vpn_switch database init 2>&1) if [ "$($EXAMINE_FILE_PERMS "$TEST_DIR/openpgp" 2>/dev/null | sed 's/^0*//')" = "700" ] && printf '%s\n' "$init_out" | grep -q "Fixed: Directory openpgp"; then pass "a directory of the layout with the wrong mode is set right by init" else fail "init left openpgp at $($EXAMINE_FILE_PERMS "$TEST_DIR/openpgp" 2>/dev/null): $(printf '%s\n' "$init_out" | grep -i "openpgp" | head -2 | tr '\n' ' ')" fi } test_environment_init() { test_header "environment init - copies profile templates (Phase 4.4)" "test_environment_init" test_setup run_vpn_switch init minimal >/dev/null 2>&1 # Check profile templates were copied (uppercase profile name) if [ -f "$TEST_DIR/.env/default/VPN_SWITCH_PROFILE_MINIMAL" ]; then pass "minimal profile copied" else fail "minimal profile not copied" fi # Verify permissions (use helper function for cross-platform) local perms=$(get_file_perms_full "$TEST_DIR/.env/default/VPN_SWITCH_PROFILE_MINIMAL") if [ "$perms" = "100600" ] || [ "$perms" = "600" ]; then pass "profile has correct permissions (0600)" else fail "profile has wrong permissions: $perms" fi } test_environment_init_platform_template() { test_header "environment init - a platform variant of a template wins over the generic one" "test_environment_init_platform_template" test_setup local tpl="$(dirname "$TEST_SCRIPT")/template" os installed os=$(readlink "$tpl/platform/default"); os=${os##*/}; os=${os%.sh} run_vpn_switch environment init minimal >/dev/null 2>&1 installed=$(head -n1 "$TEST_DIR/.env/default/VPN_SWITCH_ARCHIVER" 2>/dev/null) if [ -f "$tpl/platform/$os/environment/VPN_SWITCH_ARCHIVER" ]; then if [ "$installed" = "$(head -n1 "$tpl/platform/$os/environment/VPN_SWITCH_ARCHIVER")" ]; then pass "VPN_SWITCH_ARCHIVER installed from template/platform/$os/environment" else fail "VPN_SWITCH_ARCHIVER not the $os variant: $installed" fi else if [ "$installed" = "$(head -n1 "$tpl/environment/VPN_SWITCH_ARCHIVER")" ]; then pass "VPN_SWITCH_ARCHIVER installed from template/environment ($os has no variant)" else fail "VPN_SWITCH_ARCHIVER not the generic template: $installed" fi fi if [ "$(head -n1 "$TEST_DIR/.env/default/VPN_SWITCH_EXTRACTOR" 2>/dev/null)" = "$(head -n1 "$tpl/environment/VPN_SWITCH_EXTRACTOR")" ]; then pass "VPN_SWITCH_EXTRACTOR installed from template/environment (no platform variant)" else fail "VPN_SWITCH_EXTRACTOR wrong: $(head -n1 "$TEST_DIR/.env/default/VPN_SWITCH_EXTRACTOR" 2>&1)" fi } test_phases_copy() { test_header "phases copy - copies phase templates (Phase 4.4)" "test_phases_copy" test_setup run_vpn_switch init minimal >/dev/null 2>&1 # Verify phase templates were copied [ -d "$TEST_DIR/.include/phase/wireguard" ] && pass "wireguard phases copied" || fail "wireguard phases missing" [ -d "$TEST_DIR/.include/phase/openvpn" ] && pass "openvpn phases copied" || fail "openvpn phases missing" # Verify phase directories have content local wg_count=$(find "$TEST_DIR/.include/phase/wireguard" -type d | wc -l) [ "$wg_count" -gt 1 ] && pass "wireguard phase subdirs exist" || fail "wireguard phases empty" } test_phases_lift() { test_header "phases lift - lifts phase:backend templates (Phase 4.4)" "test_phases_lift" test_setup run_vpn_switch init minimal >/dev/null 2>&1 # phases lift generates commands to lift a specific backend local output=$(run_vpn_switch phases lift firewall:pf 2>&1) # Should generate lift commands (check for completion message) printf '%s\n' "$output" | grep -q "Lifted: firewall" && pass "generates lift command" || fail "no lift command" # Check that the lifted file was created and is executable local lifted_file="$TEST_DIR/.include/phase/base/firewall/pf.sh" [ -x "$lifted_file" ] && pass "output file is executable" || fail "output file not executable or missing" } test_phases_default_profile() { test_header "phases - default profile symlink present + validate flags its absence" "test_phases_default_profile" test_setup run_vpn_switch init minimal >/dev/null 2>&1 # The default phase-profile symlink is load-bearing: phase specs without an # explicit @profile (and any :variant) resolve via .include/phase/default -> base. [ -L "$TEST_DIR/.include/phase/default" ] && pass ".include/phase/default symlink present" || fail ".include/phase/default symlink missing" [ -d "$TEST_DIR/.include/phase/default" ] && pass "default profile resolves to a directory" || fail "default profile does not resolve" # DB health: validate must FLAG a missing default symlink (not pass silently) rm -f "$TEST_DIR/.include/phase/default" local output=$(run_vpn_switch phases validate 2>&1 || true) if printf '%s\n' "$output" | grep -qi "default" && printf '%s\n' "$output" | grep -qiE "ERROR|missing|✗"; then pass "validate flags missing default symlink" else fail "validate did not flag missing default symlink" fi } test_phases_sync_single() { test_header "phases sync - discovers and lifts backends (Phase 4.4)" "test_phases_sync_single" test_setup run_vpn_switch init minimal >/dev/null 2>&1 # Delegation evidence is in the trace file now (fd79dc8 moved batch # dispatch messages off stdout). Capture trace_file specifically for # this run and assert on it. export is necessary because run_vpn_switch # is a shell function whose inner subshell needs the variable in env. local trace_file=$(mktemp) export VPN_SWITCH_TRACE_FILE="$trace_file" run_vpn_switch phases sync firewall >/dev/null 2>&1 || true unset VPN_SWITCH_TRACE_FILE if grep -q 'phases lift' "$trace_file"; then pass "delegates to phases lift" else fail "no delegation" echo " trace file: $trace_file" head -20 "$trace_file" 2>/dev/null fi rm -f "$trace_file" } test_phases_sync_all() { test_header "phases sync - syncs all phases (Phase 4.4)" "test_phases_sync_all" test_setup run_vpn_switch init minimal >/dev/null 2>&1 # Same pattern as test_phases_sync_single — trace-file based assertion. local trace_file=$(mktemp) export VPN_SWITCH_TRACE_FILE="$trace_file" run_vpn_switch phases sync >/dev/null 2>&1 || true unset VPN_SWITCH_TRACE_FILE if grep -q 'phases sync' "$trace_file"; then pass "delegates to single-phase sync" else fail "no delegation" echo " trace file: $trace_file" head -20 "$trace_file" 2>/dev/null fi rm -f "$trace_file" local vpn_out=$(run_vpn_switch phases sync vpn 2>&1) if printf '%s\n' "$vpn_out" | grep -q "^# phase vpn: no base backends" && ! printf '%s\n' "$vpn_out" | grep -qi "warning"; then pass "the vpn phase is a note, not a warning (the protocol templates provide it)" else fail "vpn phase: $(printf '%s\n' "$vpn_out" | head -2 | tr '\n' ' ')" fi } test_init_batch_combinator() { test_header "init - orchestrates full initialization (Phase 4.4)" "test_init_batch_combinator" test_setup # init should call database init, environment init, and phases sync local output=$(run_vpn_switch init minimal 2>&1) # Check for success indicators (database may be validated if already exists) if printf '%s\n' "$output" | grep -qE "Created directory|Validated:"; then pass "database init executed" else fail "no database init" fi printf '%s\n' "$output" | grep -q "Installed.*profile" && pass "environment init executed" || fail "no env init" printf '%s\n' "$output" | grep -q "Copied phase templates" && pass "phases copy executed" || fail "no phases copy" } test_init_combinator() { test_header "init combinator - delegates to batch combinator (Phase 4.4)" "test_init_combinator" test_setup # __init0 should delegate to ___init1 local output=$(run_vpn_switch init minimal 2>&1) # Should result in functional database [ -d "$TEST_DIR/wireguard" ] && pass "database created" || fail "database not created" [ -f "$TEST_DIR/.env/default/VPN_SWITCH_PROFILE_MINIMAL" ] && pass "profile installed" || fail "profile missing" } test_start_extension_link_fast_path() { test_header "start - uses extension link for protocol resolution (Phase 3.4.1)" "test_start_extension_link_fast_path" test_setup # Create WireGuard config cat > "$TEST_DIR/wireguard/test.conf" <<'EOF' [Interface] PrivateKey = test Address = 10.2.0.2/32 EOF chmod 0400 "$TEST_DIR/wireguard/test.conf" # Verify extension link exists (from init) if [ ! -L "$TEST_DIR/conf" ]; then fail "Extension link conf not found" return 1 fi # Test: start with .conf extension should use fast path # As in test_start_wireguard_only: assert on the durable artifact, not # _batch2's stdout chatter which moved to trace_log in fd79dc8. run_vpn_switch start test.conf > /dev/null 2>&1 || true local sess=$(ls -d "$TEST_DIR"/.session/*/ 2>/dev/null | head -1) local sess_protocol="" [ -n "$sess" ] && sess_protocol=$(cat "$sess/protocol" 2>/dev/null) if [ "$sess_protocol" = "wireguard" ]; then pass "Extension link used for fast protocol resolution (session.protocol=wireguard)" else fail "Extension link not used for resolution" printf '%s\n' " session: ${sess:-none}, session.protocol: ${sess_protocol:-none}" ls -la "$TEST_DIR/.session/" 2>/dev/null fi } test_start_multiple_matches() { test_header "start - error on multiple protocol matches (Phase 3.4.1)" "test_start_multiple_matches" test_setup # Create same basename in both protocols cat > "$TEST_DIR/wireguard/server.conf" <<'EOF' [Interface] PrivateKey = test1 Address = 10.2.0.2/32 EOF chmod 0400 "$TEST_DIR/wireguard/server.conf" cat > "$TEST_DIR/openvpn/server.ovpn" <<'EOF' client remote 1.2.3.4 1194 EOF chmod 0400 "$TEST_DIR/openvpn/server.ovpn" # Test: start with ambiguous name local output=$(run_vpn_switch start server 2>&1) # Should show multiple matches error if printf '%s\n' "$output" | grep -q "Multiple configs match"; then pass "Multiple matches error shown" else fail "Multiple matches error not shown" printf '%s\n' "Output: $output" fi # Should list both matches if printf '%s\n' "$output" | grep -q "wireguard/server.conf" && printf '%s\n' "$output" | grep -q "openvpn/server.ovpn"; then pass "Both matches listed" else fail "Not all matches shown" printf '%s\n' "Output: $output" fi } #----------------------------------------------------------------------------- # OpenVPN Config Management Tests (Phase 3.1.4) #----------------------------------------------------------------------------- test_openvpn_import_valid() { test_header "openvpn import - import valid .ovpn config" "test_openvpn_import_valid" test_setup # Enable execution for openvpn import set_function_interpreter "openvpn_import1" "sh" # Create test .ovpn file local test_config="$TEST_BASE_DIR/test-us-server.ovpn" cat > "$test_config" <<'EOF' client dev tun proto udp remote 1.2.3.4 1194 resolv-retry infinite nobind persist-key persist-tun cipher AES-256-CBC auth SHA256 verb 3 EOF chmod 0600 "$test_config" # Run import run_vpn_switch openvpn import "$test_config" >/dev/null 2>&1 # Verify imported local imported="$TEST_DIR/openvpn/test-us-server.ovpn" if [ -f "$imported" ]; then pass "Config imported to openvpn/" else fail "Config not imported" fi # Verify permissions if [ -f "$imported" ]; then local perms=$(get_file_perms "$imported") if [ "$perms" = "400" ]; then pass "Permissions set to 0400" else fail "Permissions incorrect: got $perms, expected 400" fi fi rm -f "$test_config" } test_openvpn_list() { test_header "openvpn list - list configs and groups" "test_openvpn_list" test_setup # Create test files manually mkdir -p "$TEST_DIR/openvpn/work" touch "$TEST_DIR/openvpn/us-server.ovpn" touch "$TEST_DIR/openvpn/eu-server.ovpn" ln -s "./us-server.ovpn" "$TEST_DIR/openvpn/work/office" # Run list command local output=$(run_vpn_switch openvpn list 2>/dev/null) # Check for configs if printf '%s\n' "$output" | grep -qx "openvpn/us-server.ovpn"; then pass "Lists config: us-server.ovpn" else fail "Missing config in list" fi # Check for group if printf '%s\n' "$output" | grep -qx "openvpn/work/"; then pass "Lists group: work/" else fail "Missing group in list" fi # Check arity-1 (list specific group) local group_output=$(run_vpn_switch openvpn list work 2>/dev/null) if echo "$group_output" | grep -qx "openvpn/work/office -> ./us-server.ovpn"; then pass "Lists group link with arity-1: work/office" else fail "Missing group link in arity-1 list" fi } test_openvpn_add_group() { test_header "openvpn add - create group" "test_openvpn_add_group" test_setup # Enable execution set_function_interpreter "openvpn_add" "sh" # Run command run_vpn_switch openvpn add work >/dev/null 2>&1 # Verify group created if [ -d "$TEST_DIR/openvpn/work" ]; then pass "Group directory created" else fail "Group directory not created" fi # Verify permissions local perms=$(get_file_perms "$TEST_DIR/openvpn/work") if [ "$perms" = "700" ]; then pass "Group permissions correct (0700)" else fail "Group permissions incorrect: $perms" fi } test_openvpn_add_to_group_arity2() { test_header "openvpn add - add config to group (arity-2)" "test_openvpn_add_group" test_setup # Setup mkdir -p "$TEST_DIR/openvpn/work" touch "$TEST_DIR/openvpn/us-server.ovpn" # Enable execution set_function_interpreter "openvpn_add" "sh" # Run command (arity-2: link name = config name minus extension) run_vpn_switch openvpn add work us-server.ovpn >/dev/null 2>&1 # Verify link created if [ -L "$TEST_DIR/openvpn/work/us-server" ]; then pass "Link created: work/us-server" else fail "Link not created" fi # Verify link target local target=$(readlink "$TEST_DIR/openvpn/work/us-server") if [ "$target" = "../us-server.ovpn" ]; then pass "Link points to correct target" else fail "Link target incorrect: $target" fi } test_openvpn_add_to_group_arity3() { test_header "openvpn add - add with custom alias (arity-3)" "test_openvpn_add_group" test_setup mkdir -p "$TEST_DIR/openvpn/work" touch "$TEST_DIR/openvpn/us-server.ovpn" set_function_interpreter "openvpn_add" "sh" # Run with custom alias run_vpn_switch openvpn add work us-server.ovpn office >/dev/null 2>&1 # Verify custom link created if [ -L "$TEST_DIR/openvpn/work/office" ]; then pass "Custom alias created: work/office" else fail "Custom alias not created" fi # Verify target local target=$(readlink "$TEST_DIR/openvpn/work/office") if [ "$target" = "../us-server.ovpn" ]; then pass "Alias points to correct target" else fail "Alias target incorrect: $target" fi } test_openvpn_link() { test_header "openvpn link - create protocol-level link" "test_openvpn_link" test_setup touch "$TEST_DIR/openvpn/us-server.ovpn" set_function_interpreter "openvpn_link2" "sh" # Create protocol-level link run_vpn_switch openvpn link quick us-server.ovpn >/dev/null 2>&1 # Verify link if [ -L "$TEST_DIR/openvpn/quick" ]; then pass "Protocol-level link created" else fail "Link not created" fi # Verify target local target=$(readlink "$TEST_DIR/openvpn/quick") if [ "$target" = "./us-server.ovpn" ]; then pass "Link target correct" else fail "Link target incorrect: $target" fi } test_openvpn_remove() { test_header "openvpn remove - remove config safely" "test_openvpn_remove" test_setup touch "$TEST_DIR/openvpn/us-server.ovpn" touch "$TEST_DIR/openvpn/eu-server.ovpn" set_function_interpreter "openvpn_remove" "sh" # Remove config run_vpn_switch openvpn remove eu-server.ovpn >/dev/null 2>&1 # Verify removed if [ ! -f "$TEST_DIR/openvpn/eu-server.ovpn" ]; then pass "Config removed" else fail "Config not removed" fi # Verify other config still exists if [ -f "$TEST_DIR/openvpn/us-server.ovpn" ]; then pass "Other config preserved" else fail "Other config incorrectly removed" fi } test_openvpn_validate() { test_header "openvpn validate - detect broken links" "test_openvpn_validate" test_setup # Create broken link mkdir -p "$TEST_DIR/openvpn/work" ln -s "../nonexistent.ovpn" "$TEST_DIR/openvpn/work/broken" # Run validate local output=$(run_vpn_switch openvpn validate 2>/dev/null) # Check for broken link detection if printf '%s\n' "$output" | grep -q "Broken symlink.*work/broken"; then pass "Broken symlink detected" else fail "Broken symlink not detected" fi # Remove broken link and create valid setup run_vpn_switch openvpn remove work/broken >/dev/null 2>&1 cat > "$TEST_DIR/openvpn/server.ovpn" <<'EOF' client remote test.example.com 1194 EOF chmod 0400 "$TEST_DIR/openvpn/server.ovpn" run_vpn_switch openvpn add work link server.ovpn >/dev/null 2>&1 output=$(run_vpn_switch openvpn validate 2>/dev/null) if printf '%s\n' "$output" | grep -q "No issues found"; then pass "Valid database passes validation" else fail "Valid database reported issues" fi } test_openvpn_info() { test_header "openvpn info - show group information" "test_openvpn_info" test_setup mkdir -p "$TEST_DIR/openvpn/work" touch "$TEST_DIR/openvpn/us-server.ovpn" touch "$TEST_DIR/openvpn/eu-server.ovpn" ln -s "../us-server.ovpn" "$TEST_DIR/openvpn/work/office" ln -s "../eu-server.ovpn" "$TEST_DIR/openvpn/work/remote" # Run info local output=$(run_vpn_switch openvpn info work 2>/dev/null) # Check for group name if printf '%s\n' "$output" | grep -q "Group: work"; then pass "Shows group name" else fail "Missing group name" fi # Check for link count if printf '%s\n' "$output" | grep -q "Total links: 2"; then pass "Shows correct link count" else fail "Incorrect link count" fi # Check for links if printf '%s\n' "$output" | grep -q "office -> ../us-server.ovpn"; then pass "Shows link: office" else fail "Missing link in output" fi } test_openvpn_dump() { test_header "openvpn dump - export database" "test_openvpn_dump" test_setup # Create test database mkdir -p "$TEST_DIR/openvpn/work" touch "$TEST_DIR/openvpn/us-server.ovpn" ln -s "./us-server.ovpn" "$TEST_DIR/openvpn/quick" ln -s "../us-server.ovpn" "$TEST_DIR/openvpn/work/office" # Run dump local output=$(run_vpn_switch openvpn dump 2>/dev/null) # Check for import command if printf '%s\n' "$output" | grep -q "openvpn import.*us-server.ovpn"; then pass "Dump includes import command" else fail "Missing import command" fi # Check for group creation if printf '%s\n' "$output" | grep -q "openvpn add work"; then pass "Dump includes group creation" else fail "Missing group creation" fi # Check for protocol link if printf '%s\n' "$output" | grep -q "openvpn link quick"; then pass "Dump includes protocol link" else fail "Missing protocol link" fi # Check for group link if printf '%s\n' "$output" | grep -q "openvpn add work.*us-server.ovpn"; then pass "Dump includes group link" else fail "Missing group link" fi } #----------------------------------------------------------------------------- # OpenVPN Lifecycle Tests (Phase 3.3.1) #----------------------------------------------------------------------------- # TEST: openvpn patch - displays patched configuration test_openvpn_patch_valid() { test_header "openvpn patch - displays patched configuration" "test_openvpn_patch_valid" test_setup override_for_command_inspection "openvpn_patch" # Enable execution for openvpn import (narrow override) set_function_interpreter "openvpn_import1" "sh" # Create a test OpenVPN config local test_config="/tmp/us-TEST-PATCH-1.ovpn" cat > "$test_config" <<'CONF' client dev tun proto udp remote vpn.example.com 1194 resolv-retry infinite nobind persist-key persist-tun cipher AES-256-CBC auth SHA256 verb 3 CONF chmod 0400 "$test_config" # Import config first run_vpn_switch openvpn import "$test_config" >/dev/null 2>&1 # Run patch command local output=$(run_vpn_switch openvpn patch us-TEST-PATCH-1.ovpn 2>&1) # Phase-based approach: DNS handled by DNS phase, not up/down scripts # Verify up/down scripts are NOT present (they should be stripped) if printf '%s\n' "$output" | grep -q "^up "; then fail "Patch output should NOT contain up script (phase-based DNS)" else pass "Patch output correctly strips up script (phase-based DNS)" fi if printf '%s\n' "$output" | grep -q "^down "; then fail "Patch output should NOT contain down script (phase-based DNS)" else pass "Patch output correctly strips down script (phase-based DNS)" fi # Verify output contains daemon mode if printf '%s\n' "$output" | grep -q "daemon"; then pass "Patch output contains daemon mode" else fail "Patch output missing daemon mode" fi # Verify output contains device interface if printf '%s\n' "$output" | grep -q "dev tun"; then pass "Patch output contains device interface" else fail "Patch output missing device interface" fi # Verify output contains writepid if printf '%s\n' "$output" | grep -q "writepid"; then pass "Patch output contains writepid directive" else fail "Patch output missing writepid" fi # Verify output still contains original config elements if printf '%s\n' "$output" | grep -q "client"; then pass "Patch output preserves client directive" else fail "Patch output lost client directive" fi if printf '%s\n' "$output" | grep -q "remote vpn.example.com"; then pass "Patch output preserves remote server" else fail "Patch output lost remote server" fi # Cleanup rm -f "$test_config" } # TEST: openvpn patch - handles nonexistent config test_openvpn_patch_nonexistent() { test_header "openvpn patch - handles nonexistent config" "test_openvpn_patch_nonexistent" test_setup override_for_command_inspection "openvpn_patch" # Run patch with nonexistent config local output=$(run_vpn_switch openvpn patch nonexistent.ovpn 2>&1 || true) # Should get an error if printf '%s\n' "$output" | grep -qiE "not found|error"; then pass "Error message for nonexistent config" else fail "Missing error for nonexistent config" fi } # TEST: openvpn patch - handles absolute path test_openvpn_patch_absolute() { test_header "openvpn patch - handles absolute path" "test_openvpn_patch_absolute" test_setup override_for_command_inspection "openvpn_patch" # Create config with absolute path local test_config="/tmp/us-absolute.ovpn" cat > "$test_config" <<'CONF' client dev tun remote 1.2.3.4 1194 CONF chmod 0400 "$test_config" # Patch with absolute path (no import needed) local output=$(run_vpn_switch openvpn patch "$test_config" 2>&1) # Should work and contain patched elements if printf '%s\n' "$output" | grep -q "daemon"; then pass "Absolute path patch works" else fail "Absolute path patch failed" fi # Cleanup rm -f "$test_config" } # TEST: openvpn patch - patches bare auth-user-pass with credentials test_openvpn_patch_credentials_bare() { test_header "openvpn patch - patches bare auth-user-pass with credentials" "test_openvpn_patch_credentials_bare" test_setup override_for_command_inspection "openvpn_patch" # Enable execution for openvpn import set_function_interpreter "openvpn_import1" "sh" # Create test config with bare auth-user-pass local test_config="/tmp/us-creds-bare.ovpn" rm -f "$test_config" cat > "$test_config" <<'CONF' client dev tun remote vpn.example.com 1194 auth-user-pass CONF chmod 0400 "$test_config" # Import config run_vpn_switch openvpn import "$test_config" >/dev/null 2>&1 # Test WITH credentials environment variable (use setenv - proper way) run_vpn_switch setenv VPN_SWITCH_CREDENTIALS_openvpn "/path/to/credentials.txt" >/dev/null 2>&1 local output=$(run_vpn_switch openvpn patch us-creds-bare.ovpn 2>&1) if printf '%s\n' "$output" | grep -q "auth-user-pass /path/to/credentials.txt"; then pass "Bare auth-user-pass patched with credentials path" else fail "Bare auth-user-pass not patched with credentials" fi # Test WITHOUT credentials environment variable (unset it first) run_vpn_switch unsetenv VPN_SWITCH_CREDENTIALS_openvpn >/dev/null 2>&1 output=$(run_vpn_switch openvpn patch us-creds-bare.ovpn 2>&1) if printf '%s\n' "$output" | grep -q "^auth-user-pass$"; then pass "Bare auth-user-pass preserved when no credentials set" else fail "Bare auth-user-pass should remain unchanged without credentials var" fi # Cleanup rm -f "$test_config" } # TEST: openvpn patch - preserves existing auth-user-pass path test_openvpn_patch_credentials_existing() { test_header "openvpn patch - preserves existing auth-user-pass path" "test_openvpn_patch_credentials_existing" test_setup override_for_command_inspection "openvpn_patch" # Enable execution for openvpn import set_function_interpreter "openvpn_import1" "sh" # Create test config with existing auth-user-pass path local test_config="/tmp/us-creds-existing.ovpn" cat > "$test_config" <<'CONF' client dev tun remote vpn.example.com 1194 auth-user-pass /existing/path/credentials.txt CONF chmod 0400 "$test_config" # Import config run_vpn_switch openvpn import "$test_config" >/dev/null 2>&1 # Test with different credentials path - should NOT override local output=$(VPN_SWITCH_CREDENTIALS_openvpn="/different/credentials.txt" \ run_vpn_switch openvpn patch us-creds-existing.ovpn 2>&1) if printf '%s\n' "$output" | grep -q "auth-user-pass /existing/path/credentials.txt"; then pass "Existing auth-user-pass path preserved (not overridden)" else fail "Existing auth-user-pass path should not be modified" fi # Cleanup rm -f "$test_config" } # TEST: openvpn patch - handles config without auth-user-pass test_openvpn_patch_credentials_missing() { test_header "openvpn patch - handles config without auth-user-pass" "test_openvpn_patch_credentials_missing" test_setup override_for_command_inspection "openvpn_patch" # Enable execution for openvpn import set_function_interpreter "openvpn_import1" "sh" # Create test config without auth-user-pass local test_config="/tmp/us-no-auth.ovpn" cat > "$test_config" <<'CONF' client dev tun remote vpn.example.com 1194 CONF chmod 0400 "$test_config" # Import config run_vpn_switch openvpn import "$test_config" >/dev/null 2>&1 # Test with credentials set - should not add auth-user-pass local output=$(VPN_SWITCH_CREDENTIALS_openvpn="/path/to/credentials.txt" \ run_vpn_switch openvpn patch us-no-auth.ovpn 2>&1) if printf '%s\n' "$output" | grep -vq "auth-user-pass"; then pass "Config without auth-user-pass remains unchanged" else fail "Should not add auth-user-pass to configs that don't have it" fi # Cleanup rm -f "$test_config" } # TEST: openvpn start - create session and connect test_openvpn_start_basic() { test_header "openvpn start - create session and connect to VPN" "test_openvpn_start_basic" # Create exclusive database for this test test_setup # Set up mocks for network operations with unique test ID local test_id=80 setup_session_test_mocks "$test_id" local test_interface="tun_test${test_id}" # Enable execution for openvpn add and import (narrow overrides) set_function_interpreter "openvpn_add" "sh" set_function_interpreter "openvpn_import1" "sh" # Create work category run_vpn_switch openvpn add work >/dev/null 2>&1 # Import an OpenVPN config local test_config="$TEST_BASE_DIR/us-server.ovpn" cat > "$test_config" <<'EOF' client dev tun proto udp remote vpn.example.com 1194 resolv-retry infinite nobind persist-key persist-tun cipher AES-256-CBC auth SHA256 verb 3 EOF chmod 0600 "$test_config" run_vpn_switch openvpn import "$test_config" >/dev/null 2>&1 run_vpn_switch openvpn add work us-server.ovpn server >/dev/null 2>&1 # Run start command local output=$(run_vpn_switch openvpn start work 2>&1 || true) # Verify session was created if printf '%s\n' "$output" | grep -qi "session"; then pass "Session creation logged" else fail "Session creation not logged" printf '%s\n' "Output: $output" fi # Verify openvpn command is in generated connect.sh local session_dir=$(ls -1d "$TEST_DIR/.session"/* 2>/dev/null | head -1) if [ -f "$session_dir/connect.sh" ] && grep -q "openvpn" "$session_dir/connect.sh"; then pass "openvpn command in connect.sh" else fail "openvpn command not found in connect.sh" printf '%s\n' "Output: $output" fi # Verify session directory was created local session_count=$(ls -1d "$TEST_DIR/.session"/* 2>/dev/null | wc -l) if [ "$session_count" -ge 1 ]; then pass "Session directory created in .session/" else fail "Session directory not found" ls -la "$TEST_DIR/.session/" || true fi # Verify session metadata files exist local session_dir=$(ls -1d "$TEST_DIR/.session"/* 2>/dev/null | head -1) if [ -f "$session_dir/protocol" ] && [ -f "$session_dir/pid" ]; then pass "Session metadata files created" else fail "Session metadata files missing" ls -la "$session_dir" || true fi # Verify connect.sh was generated if [ -f "$session_dir/connect.sh" ]; then pass "connect.sh script generated" else fail "connect.sh script not found" ls -la "$session_dir" || true fi # Verify connect.sh is executable if [ -x "$session_dir/connect.sh" ]; then pass "connect.sh is executable" else fail "connect.sh is not executable" ls -la "$session_dir/connect.sh" || true fi # Verify patched config exists (uses test interface name) if [ -f "$session_dir/${test_interface}.ovpn" ]; then pass "Patched config (${test_interface}.ovpn) created" else fail "Patched config not found" ls -la "$session_dir" || true fi # Verify patched config contains daemon mode if grep -q "daemon" "$session_dir/${test_interface}.ovpn"; then pass "Patched config contains daemon mode" else fail "Patched config missing daemon mode" grep -i "daemon" "$session_dir/${test_interface}.ovpn" || true fi # Phase-based approach: up/down scripts should be stripped (DNS handled by DNS phase) if grep -q "^up " "$session_dir/${test_interface}.ovpn"; then fail "Patched config should NOT contain up script (phase-based DNS)" grep -i "up" "$session_dir/${test_interface}.ovpn" || true else pass "Patched config correctly strips up script (phase-based DNS)" fi if grep -q "^down " "$session_dir/${test_interface}.ovpn"; then fail "Patched config should NOT contain down script (phase-based DNS)" grep -i "down" "$session_dir/${test_interface}.ovpn" || true else pass "Patched config correctly strips down script (phase-based DNS)" fi # Cleanup rm -f "$test_config" } # TEST: openvpn stop - stop all OpenVPN sessions test_openvpn_stop_basic() { test_header "openvpn stop - cleanup commands for session" "test_openvpn_stop_basic" # Create exclusive database for this test test_setup # Set up mocks for network operations with unique test ID local test_id=85 local expected_interface="tun_test${test_id}" setup_session_test_mocks "$test_id" # Create session through user interface (mocked network operations) run_vpn_switch openvpn add work >/dev/null 2>&1 local test_config="$TEST_BASE_DIR/ovpn-test-stop.ovpn" cat > "$test_config" <<'EOF' client dev tun proto udp remote vpn.example.com 1194 resolv-retry infinite nobind persist-key persist-tun cipher AES-256-CBC auth SHA256 verb 3 EOF chmod 0600 "$test_config" run_vpn_switch openvpn import "$test_config" >/dev/null 2>&1 run_vpn_switch openvpn add work ovpn-test-stop.ovpn server >/dev/null 2>&1 run_vpn_switch openvpn start work >/dev/null 2>&1 # Verify session was created local session_count=$(ls -1d "$TEST_DIR/.session"/* 2>/dev/null | wc -l) if [ "$session_count" -ge 1 ]; then pass "Session created via user interface" else fail "No session found after start" return fi # Set interpreter to cat for command inspection (instead of execution) override_for_command_inspection "openvpn_disconnect1" # Call disconnect directly with the test interface # This bypasses is_session_alive() and lets us inspect generated commands local output=$(run_vpn_switch openvpn disconnect "$expected_interface" 2>&1 || true) # Verify the generated commands include interface reference if printf '%s\n' "$output" | grep -q "$expected_interface"; then pass "Disconnect commands reference correct interface ($expected_interface)" else fail "Disconnect should reference interface $expected_interface" printf '%s\n' "Output: $output" return fi # Verify the generated commands include process termination if printf '%s\n' "$output" | grep -q "kill"; then pass "Disconnect generates process kill commands" else fail "Disconnect should generate kill commands" printf '%s\n' "Output: $output" fi } # TEST: openvpn disconnect - tear down interface test_openvpn_disconnect_interface() { test_header "openvpn disconnect - tear down OpenVPN interface" "test_openvpn_disconnect_interface" test_setup override_for_command_inspection "openvpn_disconnect1" # Create a fake session directory local session_dir="$TEST_DIR/.session/99999" mkdir -p "$session_dir" echo "tun0" > "$session_dir/interface" echo "openvpn" > "$session_dir/protocol" echo "12345" > "$session_dir/openvpn.pid" echo "$TEST_DIR/openvpn/test.ovpn" > "$session_dir/original" chmod 0600 "$session_dir"/* # Run disconnect with interface name (not PID) local output=$(run_vpn_switch openvpn disconnect tun0 2>&1 || true) # Verify disconnect generates kill commands if printf '%s\n' "$output" | grep -qi "kill"; then pass "Disconnect generates process termination commands" else fail "Disconnect missing termination logic" printf '%s\n' "Output: $output" fi } # TEST: openvpn clean - generate cleanup commands test_openvpn_clean() { test_header "openvpn clean - generate cleanup commands for broken links" "test_openvpn_clean" test_setup override_for_command_inspection "openvpn_clean" # Enable execution for openvpn add (to create symlinks) set_function_interpreter "openvpn_add" "sh" # Create a category with a broken symlink run_vpn_switch openvpn add broken >/dev/null 2>&1 ln -s "$TEST_DIR/openvpn/nonexistent.ovpn" "$TEST_DIR/openvpn/broken/link1" # Run clean command local output=$(run_vpn_switch openvpn clean 2>&1) # Should generate remove commands for broken links if printf '%s\n' "$output" | grep -q "openvpn remove"; then pass "Clean generates remove commands" else fail "Clean missing remove commands" printf '%s\n' "Output: $output" fi # Verify it found the broken link if printf '%s\n' "$output" | grep -qE "link1|nonexistent"; then pass "Clean detected broken symlink" else fail "Clean didn't detect broken link" printf '%s\n' "Output: $output" fi } #----------------------------------------------------------------------------- # Environment Management Tests #----------------------------------------------------------------------------- test_setenv_basic() { test_header "setenv - basic variable setting" "test_setenv_basic" test_setup # Run setenv (will execute) local output=$(run_vpn_switch setenv TEST_VAR "test_value" 2>&1) # Check that .env/local directory was created if [ -d "$TEST_DIR/.env/local" ]; then pass ".env/local directory created" else fail ".env/local directory not created" fi # Check that file was created with correct content if [ -f "$TEST_DIR/.env/local/TEST_VAR" ]; then pass "Variable file created" local content=$(head -n 1 "$TEST_DIR/.env/local/TEST_VAR") if [ "$content" = "test_value" ]; then pass "Variable has correct value" else fail "Variable has wrong value: $content" fi else fail "Variable file not created" fi # Check permissions (use helper function for cross-platform) local perms=$(get_file_perms_full "$TEST_DIR/.env/local/TEST_VAR") if [ "$perms" = "100600" ] || [ "$perms" = "600" ] || [ "$perms" = "100640" ] || [ "$perms" = "640" ]; then pass "File has correct permissions (0600 or 0640 for cross-user support)" else fail "File has wrong permissions: $perms" fi # Check for confirmation message if printf '%s\n' "$output" | grep -q "Set TEST_VAR"; then pass "Shows confirmation message" else fail "Missing confirmation message" fi } test_setenv_invalid_name() { test_header "setenv - invalid variable name" "test_setenv_invalid_name" test_setup # Run setenv with invalid name (contains slash) - expect exit code 1 local output=$(run_vpn_switch setenv "INVALID/VAR" "value" 2>&1 || true) # Check for error message if printf '%s\n' "$output" | grep -q "Error.*Invalid variable name"; then pass "Rejects invalid variable name" else fail "Should reject invalid variable name" fi # Verify file was not created if [ ! -f "$TEST_DIR/.env/local/INVALID/VAR" ]; then pass "No file created for invalid name" else fail "File should not be created for invalid name" fi } test_getenv_local_override() { test_header "getenv - shows local override" "test_getenv_local_override" test_setup # Create local override file mkdir -p "$TEST_DIR/.env/local" echo "local_value" > "$TEST_DIR/.env/local/TEST_VAR" # Run getenv (will execute) local output=$(run_vpn_switch getenv TEST_VAR 2>&1) # Check for local override message in stderr if printf '%s\n' "$output" | grep -q "Source.*\.env/local.*override"; then pass "Shows local override source" else fail "Missing source indicator" fi # Check that it outputs the value if printf '%s\n' "$output" | grep -q "local_value"; then pass "Outputs correct value from local" else fail "Missing value output" fi } test_getenv_fallback_chain() { test_header "getenv - fallback chain (local > default > template)" "test_getenv_fallback_chain" test_setup # Test 1: Variable not found anywhere local output=$(run_vpn_switch getenv NONEXISTENT 2>&1 || true) if printf '%s\n' "$output" | grep -q "not found"; then pass "Shows error if variable not found" else fail "Missing error message" fi # Test 2: Variable in default only mkdir -p "$TEST_DIR/.env/default" echo "default_value" > "$TEST_DIR/.env/default/TEST_VAR_DEFAULT" output=$(run_vpn_switch getenv TEST_VAR_DEFAULT 2>&1) if printf '%s\n' "$output" | grep -q "Source.*\.env/default.*default"; then pass "Falls back to .env/default" else fail "Missing .env/default fallback" fi # Test 3: Variable in template only # VPN_SWITCH_TEMPLATE_DIR is set by bootstrap (points to template/) local template_dir="${VPN_SWITCH_TEMPLATE_DIR:-$(dirname "$0")/template}" echo "template_value" > "$template_dir/environment/TEST_VAR_TEMPLATE" output=$(run_vpn_switch getenv TEST_VAR_TEMPLATE 2>&1) if printf '%s\n' "$output" | grep -q "Source.*template/environment.*template"; then pass "Falls back to template/environment" else fail "Missing template/environment fallback" fi rm -f "$template_dir/environment/TEST_VAR_TEMPLATE" } test_unsetenv_existing() { test_header "unsetenv - remove existing variable" "test_unsetenv_existing" test_setup # Create local variable file mkdir -p "$TEST_DIR/.env/local" echo "value" > "$TEST_DIR/.env/local/TEST_VAR" # Run unsetenv (will execute) local output=$(run_vpn_switch unsetenv TEST_VAR 2>&1) # Check that file was removed if [ ! -f "$TEST_DIR/.env/local/TEST_VAR" ]; then pass "Variable file was removed" else fail "Variable file still exists" fi # Check for confirmation message if printf '%s\n' "$output" | grep -q "Unset TEST_VAR"; then pass "Shows confirmation message" else fail "Missing confirmation message" fi } test_unsetenv_not_set() { test_header "unsetenv - variable not set in local" "test_unsetenv_not_set" test_setup # Run unsetenv on non-existent variable local output=$(run_vpn_switch unsetenv NONEXISTENT_VAR 2>&1) # Check for informative message if printf '%s\n' "$output" | grep -q "not set"; then pass "Shows informative message when not set" else fail "Missing informative message" fi } test_dumpenv_prologue() { test_header "dumpenv prologue - outputs safe minimal set" "test_dumpenv_prologue" test_setup # Set up test environment variables # Prologue variables (base interpreters + settings) run_vpn_switch setenv VPN_SWITCH_TERMINAL_INTERPRETER "cat" > /dev/null 2>&1 run_vpn_switch setenv VPN_SWITCH_DISPLAY_ANSI "1" > /dev/null 2>&1 # Epilogue-only variables (function-specific interpreters) run_vpn_switch setenv VPN_SWITCH_INTERPRETER_wireguard_list "cat" > /dev/null 2>&1 run_vpn_switch setenv VPN_SWITCH_INTERPRETER_wireguard_import1 "cat" > /dev/null 2>&1 # Run dumpenv prologue local output=$(run_vpn_switch dumpenv prologue 2>&1) # Should include TERMINAL_INTERPRETER (base interpreter - in prologue) if printf '%s\n' "$output" | grep -q "setenv VPN_SWITCH_TERMINAL_INTERPRETER"; then pass "Prologue includes VPN_SWITCH_TERMINAL_INTERPRETER" else fail "Prologue missing VPN_SWITCH_TERMINAL_INTERPRETER" fi # Should include DISPLAY_ANSI (setting - in prologue) if printf '%s\n' "$output" | grep -q "setenv VPN_SWITCH_DISPLAY_ANSI"; then pass "Prologue includes VPN_SWITCH_DISPLAY_ANSI" else fail "Prologue missing VPN_SWITCH_DISPLAY_ANSI" fi # Should NOT include function-specific interpreters (epilogue-only) if printf '%s\n' "$output" | grep -q "setenv VPN_SWITCH_INTERPRETER_wireguard_list"; then fail "Prologue incorrectly includes function-specific interpreter" else pass "Prologue excludes VPN_SWITCH_INTERPRETER_wireguard_list (epilogue-only)" fi if printf '%s\n' "$output" | grep -q "setenv VPN_SWITCH_INTERPRETER_wireguard_import1"; then fail "Prologue incorrectly includes function-specific interpreter" else pass "Prologue excludes VPN_SWITCH_INTERPRETER_wireguard_import1 (epilogue-only)" fi } test_dumpenv_epilogue() { test_header "dumpenv epilogue - outputs complete set" "test_dumpenv_epilogue" test_setup # Set up test environment variables (same as prologue test) run_vpn_switch setenv VPN_SWITCH_TERMINAL_INTERPRETER "cat" > /dev/null 2>&1 run_vpn_switch setenv VPN_SWITCH_DISPLAY_ANSI "1" > /dev/null 2>&1 run_vpn_switch setenv VPN_SWITCH_INTERPRETER_wireguard_list "cat" > /dev/null 2>&1 run_vpn_switch setenv VPN_SWITCH_INTERPRETER_wireguard_import1 "cat" > /dev/null 2>&1 # Run dumpenv epilogue local output=$(run_vpn_switch dumpenv epilogue 2>&1) # Should include ALL variables (complete set) if printf '%s\n' "$output" | grep -q "setenv VPN_SWITCH_TERMINAL_INTERPRETER"; then pass "Epilogue includes VPN_SWITCH_TERMINAL_INTERPRETER" else fail "Epilogue missing VPN_SWITCH_TERMINAL_INTERPRETER" fi if printf '%s\n' "$output" | grep -q "setenv VPN_SWITCH_DISPLAY_ANSI"; then pass "Epilogue includes VPN_SWITCH_DISPLAY_ANSI" else fail "Epilogue missing VPN_SWITCH_DISPLAY_ANSI" fi # Epilogue SHOULD include function-specific interpreters (complete set) if printf '%s\n' "$output" | grep -q "setenv VPN_SWITCH_INTERPRETER_wireguard_list"; then pass "Epilogue includes VPN_SWITCH_INTERPRETER_wireguard_list" else fail "Epilogue missing VPN_SWITCH_INTERPRETER_wireguard_list" fi if printf '%s\n' "$output" | grep -q "setenv VPN_SWITCH_INTERPRETER_wireguard_import1"; then pass "Epilogue includes VPN_SWITCH_INTERPRETER_wireguard_import1" else fail "Epilogue missing VPN_SWITCH_INTERPRETER_wireguard_import1" fi } test_dumpenv_profile_filtering() { test_header "dumpenv - profile filtering prevents restore breakage" "test_dumpenv_profile_filtering" test_setup # Set a function-specific interpreter that would break restore if in prologue run_vpn_switch setenv VPN_SWITCH_INTERPRETER_wireguard_import1 "cat" > /dev/null 2>&1 # Run both prologue and epilogue local prologue_output=$(run_vpn_switch dumpenv prologue 2>&1) local epilogue_output=$(run_vpn_switch dumpenv epilogue 2>&1) # CRITICAL: Prologue must NOT have function-specific interpreters # (otherwise imports would only display, not execute during restore) if echo "$prologue_output" | grep -q "INTERPRETER_wireguard_import"; then fail "CRITICAL: Prologue has function-specific interpreter (would break restore)" else pass "Prologue correctly excludes function-specific interpreter" fi # Epilogue SHOULD have function-specific interpreters (restored after restore completes) if echo "$epilogue_output" | grep -q "INTERPRETER_wireguard_import"; then pass "Epilogue correctly includes function-specific interpreter" else fail "Epilogue missing function-specific interpreter" fi } #----------------------------------------------------------------------------- # Environment Cache Tests (Phase 4.3) #----------------------------------------------------------------------------- # TEST 109: environment cache on - creates cache file test_environment_cache_on() { test_header "environment cache on - creates cache file with correct permissions" "test_environment_cache_on" test_setup # Enable cache local output=$(run_vpn_switch environment cache on 2>&1) # Check that cache file was created local cache_file="$TEST_DIR/.env/local/VPN_SWITCH_CACHE_ENV_ARGS" if [ -f "$cache_file" ]; then pass "Cache file created" else fail "Cache file not created" fi # Check permissions (should be 0600) - use helper function for cross-platform if [ -f "$cache_file" ]; then local perms=$(get_file_perms_full "$cache_file") if [ "$perms" = "100600" ] || [ "$perms" = "600" ]; then pass "Cache file has correct permissions (0600)" else fail "Cache file has wrong permissions: $perms" fi fi # Check that output shows success message if printf '%s\n' "$output" | grep -q "Environment cache ENABLED"; then pass "Shows cache enabled message" else fail "Missing cache enabled message" fi # Verify cache content is not empty if [ -s "$cache_file" ]; then pass "Cache file has content" else fail "Cache file is empty" fi } # TEST 110: environment cache off - removes cache file test_environment_cache_off() { test_header "environment cache off - removes cache file" "test_environment_cache_off" test_setup # Enable cache first run_vpn_switch environment cache on > /dev/null 2>&1 # Verify cache exists local cache_file="$TEST_DIR/.env/local/VPN_SWITCH_CACHE_ENV_ARGS" if [ ! -f "$cache_file" ]; then fail "Setup failed: cache not created" return 1 fi # Disable cache local output=$(run_vpn_switch environment cache off 2>&1) # Check that cache file was removed if [ ! -f "$cache_file" ]; then pass "Cache file removed" else fail "Cache file still exists" fi # Check for confirmation message if printf '%s\n' "$output" | grep -q "Environment cache DISABLED"; then pass "Shows cache disabled message" else fail "Missing cache disabled message" fi } # TEST 111: environment cache status - shows correct status test_environment_cache_status() { test_header "environment cache status - shows correct status" "test_environment_cache_status" test_setup # Check status when disabled local output_disabled=$(run_vpn_switch environment cache status 2>&1) if echo "$output_disabled" | grep -q "Environment cache: DISABLED"; then pass "Shows DISABLED status when cache off" else fail "Incorrect status when cache off" fi # Enable cache run_vpn_switch environment cache on > /dev/null 2>&1 # Check status when enabled local output_enabled=$(run_vpn_switch environment cache status 2>&1) if echo "$output_enabled" | grep -q "Environment cache: ENABLED"; then pass "Shows ENABLED status when cache on" else fail "Incorrect status when cache on" fi # Check that variable count is shown if echo "$output_enabled" | grep -q "vars)"; then pass "Shows cached variable count" else fail "Missing cached variable count" fi } # TEST 112: environment cache (no args) - defaults to status test_environment_cache_no_args() { test_header "environment cache (no args) - defaults to status" "test_environment_cache_no_args" test_setup # Run without arguments local output=$(run_vpn_switch environment cache 2>&1) # Should show status (not error) if printf '%s\n' "$output" | grep -q "Environment cache:"; then pass "Shows status with no arguments" else fail "Should show status with no arguments" fi } # TEST 113: setenv rebuilds cache automatically test_cache_invalidation_setenv() { test_header "setenv - rebuilds cache automatically" "test_cache_invalidation_setenv" test_setup # Enable cache run_vpn_switch environment cache on > /dev/null 2>&1 # Verify cache exists local cache_file="$TEST_DIR/.env/local/VPN_SWITCH_CACHE_ENV_ARGS" if [ ! -f "$cache_file" ]; then fail "Setup failed: cache not created" return 1 fi # Run setenv (should rebuild cache automatically) local output=$(run_vpn_switch setenv TEST_VAR "test_value" 2>&1) # Check that cache was rebuilt (still exists with updated content) if [ -f "$cache_file" ]; then pass "Cache rebuilt automatically after setenv" else fail "Cache should be rebuilt (not deleted) after setenv" fi # Check that rebuilt cache contains the new variable if [ -f "$cache_file" ] && grep -q "TEST_VAR" "$cache_file"; then pass "Rebuilt cache contains new variable TEST_VAR" else fail "Rebuilt cache should contain new variable TEST_VAR" fi } # TEST 114: unsetenv rebuilds cache automatically test_cache_invalidation_unsetenv() { test_header "unsetenv - rebuilds cache automatically" "test_cache_invalidation_unsetenv" test_setup # Set a variable first run_vpn_switch setenv TEST_VAR "test_value" > /dev/null 2>&1 # Enable cache run_vpn_switch environment cache on > /dev/null 2>&1 # Verify cache exists and contains TEST_VAR local cache_file="$TEST_DIR/.env/local/VPN_SWITCH_CACHE_ENV_ARGS" if [ ! -f "$cache_file" ]; then fail "Setup failed: cache not created" return 1 fi # Verify cache contains TEST_VAR before unsetenv if ! grep -q "TEST_VAR" "$cache_file"; then fail "Setup failed: cache should contain TEST_VAR" return 1 fi # Run unsetenv (should rebuild cache automatically) local output=$(run_vpn_switch unsetenv TEST_VAR 2>&1) # Check that cache was rebuilt (still exists with updated content) if [ -f "$cache_file" ]; then pass "Cache rebuilt automatically after unsetenv" else fail "Cache should be rebuilt (not deleted) after unsetenv" fi # Check that rebuilt cache no longer contains TEST_VAR if [ -f "$cache_file" ] && ! grep -q "TEST_VAR" "$cache_file"; then pass "Rebuilt cache no longer contains TEST_VAR" else fail "Rebuilt cache should not contain TEST_VAR after unsetenv" fi } # TEST 115: environment init invalidates cache test_cache_invalidation_environment_init() { test_header "environment init - invalidates cache automatically" "test_cache_invalidation_environment_init" test_setup # Enable cache run_vpn_switch environment cache on > /dev/null 2>&1 # Verify cache exists local cache_file="$TEST_DIR/.env/local/VPN_SWITCH_CACHE_ENV_ARGS" if [ ! -f "$cache_file" ]; then fail "Setup failed: cache not created" return 1 fi # Run environment init (should invalidate cache) local output=$(run_vpn_switch environment init minimal 2>&1) # Check that cache was removed if [ ! -f "$cache_file" ]; then pass "Cache file removed after environment init" else fail "Cache file should be removed after environment init" fi # Check for warning message if printf '%s\n' "$output" | grep -q "Cache invalidated"; then pass "Shows cache invalidation warning" else fail "Missing cache invalidation warning" fi } # TEST 116: dumpenv places environment refresh after the epilogue test_dumpenv_cache_preservation() { test_header "dumpenv epilogue - ends with environment refresh (the pins hold for the lines after it)" "test_dumpenv_cache_preservation" test_setup run_vpn_switch environment cache on > /dev/null 2>&1 local output=$(run_vpn_switch dumpenv epilogue 2>&1) if printf '%s\n' "$output" | grep -q 'VPN_SWITCH_CACHE_ENV_ARGS'; then fail "Epilogue copied the cache" else pass "Epilogue never copies the cache" fi if [ "$(printf '%s\n' "$output" | grep -v '^$' | tail -n 1)" = '"$VPN_SWITCH_CONTEXT_SCRIPT" environment refresh' ]; then pass "Epilogue ends with environment refresh" else fail "Epilogue does not end with environment refresh: $(printf '%s\n' "$output" | tail -n 2 | tr '\n' ' ')" fi } # TEST 117: dumpenv places environment refresh without a cache too test_dumpenv_cache_not_included_when_disabled() { test_header "dumpenv epilogue - environment refresh is placed with the cache off as well" "test_dumpenv_cache_not_included_when_disabled" test_setup local output=$(run_vpn_switch dumpenv epilogue 2>&1) if [ "$(printf '%s\n' "$output" | grep -v '^$' | tail -n 1)" = '"$VPN_SWITCH_CONTEXT_SCRIPT" environment refresh' ]; then pass "Epilogue ends with environment refresh" else fail "Epilogue does not end with environment refresh: $(printf '%s\n' "$output" | tail -n 2 | tr '\n' ' ')" fi } # TEST 118: cache does not include itself (recursion prevention) test_cache_no_recursion() { test_header "environment cache on - excludes cache file from scan" "test_cache_no_recursion" test_setup # Enable cache run_vpn_switch environment cache on > /dev/null 2>&1 local cache_file="$TEST_DIR/.env/local/VPN_SWITCH_CACHE_ENV_ARGS" if [ ! -f "$cache_file" ]; then fail "Setup failed: cache not created" return 1 fi # Read cache content local cache_content=$(cat "$cache_file") # Cache should not reference itself (would cause recursion) if echo "$cache_content" | grep -q "VPN_SWITCH_CACHE_ENV_ARGS="; then fail "Cache includes itself (recursion risk)" else pass "Cache correctly excludes itself" fi } #----------------------------------------------------------------------------- # Inspect Command Tests (Phase 4.1 - Feature 3) #----------------------------------------------------------------------------- # TEST 109: inspect - batch command includes all sections test_inspect_batch_complete() { test_header "inspect (batch) - includes all inspection sections, keeps going past a failing one" "test_inspect_batch_complete" test_setup # an invalid config makes the validation section fail; a diagnostic reports that and goes on : > "$TEST_DIR/openvpn/broken.ovpn" local output=$(run_vpn_switch inspect 2>&1) if printf '%s\n' "$output" | grep -q "^Summary$" && printf '%s\n' "$output" | grep -q "Complete diagnostic captured"; then pass "Batch inspect reaches the summary with a failing section before it" else fail "Batch inspect stopped before the summary" fi # Verify all sections are included if printf '%s\n' "$output" | grep -q "Environment Configuration"; then pass "Batch inspect includes environment section" else fail "Batch inspect missing environment section" fi if printf '%s\n' "$output" | grep -q "System Configuration"; then pass "Batch inspect includes system section" else fail "Batch inspect missing system section" fi if printf '%s\n' "$output" | grep -q "WireGuard.*Config"; then pass "Batch inspect includes WireGuard section" else fail "Batch inspect missing WireGuard section" fi if printf '%s\n' "$output" | grep -q "OpenVPN.*Config"; then pass "Batch inspect includes OpenVPN section" else fail "Batch inspect missing OpenVPN section" fi if printf '%s\n' "$output" | grep -qE "Session.*Status|Active.*Session"; then pass "Batch inspect includes session section" else fail "Batch inspect missing session section" fi if printf '%s\n' "$output" | grep -qE "Phase.*Configuration|Phase.*Connect|Phase.*Disconnect"; then pass "Batch inspect includes phases section" else fail "Batch inspect missing phases section" fi } # TEST 110: environment inspect - shows key configuration test_environment_inspect() { test_header "environment inspect - displays environment variables" "test_environment_inspect" test_setup # Run environment inspect local output=$(run_vpn_switch environment inspect) # Verify header if printf '%s\n' "$output" | grep -q "Environment Configuration"; then pass "Environment inspect shows header" else fail "Environment inspect missing header" fi # Verify it shows VPN_SWITCH_BASE if printf '%s\n' "$output" | grep -q "VPN_SWITCH_BASE"; then pass "Environment inspect shows VPN_SWITCH_BASE" else fail "Environment inspect missing VPN_SWITCH_BASE" fi # Verify it shows database structure if printf '%s\n' "$output" | grep -q "Database Structure"; then pass "Environment inspect shows database structure" else fail "Environment inspect missing database structure" fi } # TEST 111: system inspect - shows system information test_system_inspect() { test_header "system inspect - displays system configuration" "test_system_inspect" test_setup # Run system inspect local output=$(run_vpn_switch system inspect) # Verify header if printf '%s\n' "$output" | grep -q "System Configuration"; then pass "System inspect shows header" else fail "System inspect missing header" fi # Verify it shows system info if printf '%s\n' "$output" | grep -q "System Info"; then pass "System inspect shows system info section" else fail "System inspect missing system info" fi # Verify it shows network interfaces if printf '%s\n' "$output" | grep -q "Network Interfaces"; then pass "System inspect shows network interfaces section" else fail "System inspect missing network interfaces" fi } # Issue #001 Tests: Variable Expansion Timing and Function Classification # ========================================================================= # TEST 112: Batch combinator syntax validation (Issue #001) test_batch_syntax_wireguard_clean() { test_header "wireguard clean - validates batch syntax" "test_batch_syntax_wireguard_clean" test_setup # Override interpreter to cat (prevent execution, just show output) override_for_command_inspection "wireguard_clean" # Create broken symlink for clean to detect mkdir -p "$TEST_DIR/wireguard" ln -s /nonexistent "$TEST_DIR/wireguard/broken.conf" # Get clean output (commands only, not executed) local output=$(run_vpn_switch wireguard clean) # Validate syntax using helper if validate_batch_syntax "$output" "wireguard clean"; then pass "wireguard clean outputs valid batch syntax" else fail "wireguard clean has invalid batch syntax" fi # Verify literal variable (not expanded) if printf '%s\n' "$output" | grep -q '"\$VPN_SWITCH_CONTEXT_SCRIPT"'; then pass "wireguard clean uses literal \$VPN_SWITCH_CONTEXT_SCRIPT" else fail "wireguard clean variable expansion error" fi } # TEST 113: Combinators use literal context script (Issue #001) test_combinators_literal_variable() { test_header "combinators - use literal \$VPN_SWITCH_CONTEXT_SCRIPT" "test_combinators_literal_variable" test_setup # Override interpreter to cat for inspection override_for_command_inspection "wireguard_start" # Create test config echo "[Interface]" > "$TEST_DIR/wireguard/test.conf" # Test wireguard start combinator local output=$(run_vpn_switch wireguard start test.conf 2>&1 || true) # Must contain literal variable if printf '%s\n' "$output" | grep -q '"\$VPN_SWITCH_CONTEXT_SCRIPT"'; then pass "wireguard start uses literal variable" else fail "wireguard start variable not literal" fi # Must NOT contain expanded path if ! printf '%s\n' "$output" | grep -qE '"/.*vpn-switch\.sh"'; then pass "wireguard start variable not expanded" else fail "wireguard start variable was expanded" fi } # TEST 114: Function naming matches behavior (Issue #001) test_function_classification_correct() { test_header "function classification - naming matches behavior" "test_function_classification_correct" # Check wireguard add2 is combinator (double underscore) if grep -q '^__wireguard_add2()' include/wireguard.sh; then pass "wireguard add2 correctly classified as combinator (__)" else fail "wireguard add2 has wrong classification" fi # Check clean functions are batch combinators (triple underscore) if grep -q '^___wireguard_clean0()' include/wireguard.sh; then pass "wireguard clean0 correctly classified as batch (___)" else fail "wireguard clean0 has wrong classification" fi if grep -q '^___openvpn_clean0()' include/openvpn.sh; then pass "openvpn clean0 correctly classified as batch (___)" else fail "openvpn clean0 has wrong classification" fi } # TEST 115: No terminal functions output vpn-switch commands (Issue #001) test_no_terminal_output_vpnswitch_commands() { test_header "terminal functions - must not output vpn-switch commands" "test_no_terminal_output_vpnswitch_commands" local violations="" # Check main script for func in $(grep '^_[^_][a-z0-9_]*[0-9]*()' vpn-switch.sh | cut -d'(' -f1); do if awk "/^$func\(\)/,/^}/" vpn-switch.sh | grep -q 'echo.*"\$VPN_SWITCH_CONTEXT_SCRIPT"'; then violations="$violations vpn-switch.sh:$func" fi done # Check modules for file in include/*.sh; do for func in $(grep '^_[^_][a-z0-9_]*[0-9]*()' "$file" 2>/dev/null | cut -d'(' -f1); do if awk "/^$func\(\)/,/^}/" "$file" | grep -q 'echo.*"\$VPN_SWITCH_CONTEXT_SCRIPT"'; then violations="$violations $(basename $file):$func" fi done done if [ -z "$violations" ]; then pass "No terminal functions output vpn-switch commands" else fail "Terminal functions violate rule: $violations" fi } # TEST 116: Metadata consistency after renames (Issue #001) test_metadata_function_lists() { test_header "metadata - function lists accurate after renames" "test_metadata_function_lists" # Check combinators list includes renamed wireguard_add2 if grep 'COMBINATOR_FUNCTIONS=' vpn-switch.sh | grep -q '__wireguard_add2'; then pass "Metadata includes __wireguard_add2 in combinators" else fail "Metadata missing __wireguard_add2" fi # Check batch combinators list includes renamed clean functions if grep 'BATCH_COMBINATOR_FUNCTIONS=' vpn-switch.sh | grep -q '___wireguard_clean0'; then pass "Metadata includes ___wireguard_clean0 in batch combinators" else fail "Metadata missing ___wireguard_clean0" fi if grep 'BATCH_COMBINATOR_FUNCTIONS=' vpn-switch.sh | grep -q '___openvpn_clean0'; then pass "Metadata includes ___openvpn_clean0 in batch combinators" else fail "Metadata missing ___openvpn_clean0" fi } # TEST 117: Combinator syntax validation (Issue #001) test_combinator_syntax_validation() { test_header "combinators - validate single-command output syntax" "test_combinator_syntax_validation" test_setup # Override interpreter to cat for inspection override_for_command_inspection "wireguard_start" override_for_command_inspection "openvpn_start" override_for_command_inspection "start" # Create test configs echo "[Interface]" > "$TEST_DIR/wireguard/test.conf" echo "client" > "$TEST_DIR/openvpn/test.ovpn" # Test wireguard start combinator local wg_output=$(run_vpn_switch wireguard start test.conf 2>&1 || true) # Combinator must output exactly ONE vpn-switch command local wg_line_count=$(echo "$wg_output" | grep -c '"\$VPN_SWITCH_CONTEXT_SCRIPT"' || echo "0") if [ "$wg_line_count" -eq 1 ]; then pass "wireguard start outputs exactly one command" else fail "wireguard start should output 1 command, got $wg_line_count" fi # Must use literal variable if echo "$wg_output" | grep -q '"\$VPN_SWITCH_CONTEXT_SCRIPT"'; then pass "wireguard start uses literal variable" else fail "wireguard start missing literal variable" fi # Must NOT be expanded if ! echo "$wg_output" | grep -qE '"/.*vpn-switch\.sh"'; then pass "wireguard start variable not expanded" else fail "wireguard start variable was expanded" fi # Test openvpn start combinator local ovpn_output=$(run_vpn_switch openvpn start test.ovpn 2>&1 || true) local ovpn_line_count=$(echo "$ovpn_output" | grep -c '"\$VPN_SWITCH_CONTEXT_SCRIPT"' || echo "0") if [ "$ovpn_line_count" -eq 1 ]; then pass "openvpn start outputs exactly one command" else fail "openvpn start should output 1 command, got $ovpn_line_count" fi # Test protocol-agnostic start combinator local start_output=$(run_vpn_switch start test.conf 2>&1 || true) local start_line_count=$(echo "$start_output" | grep -c '"\$VPN_SWITCH_CONTEXT_SCRIPT"' || echo "0") if [ "$start_line_count" -eq 1 ]; then pass "start (protocol-agnostic) outputs exactly one command" else fail "start should output 1 command, got $start_line_count" fi } # TEST 118: Error command with 1 line (Issue #002) test_error_command_single_line() { test_header "error command - single line message" "test_error_command_single_line" test_setup # Override to cat (display commands, not execute them) set_function_interpreter "error" "cat" # Run error with 1 line local output=$(run_vpn_switch error "Something went wrong" 2>&1 || true) # Verify it outputs printf command with error prefix if printf '%s\n' "$output" | grep -q 'printf.*Error:.*Something went wrong'; then pass "error command outputs printf with 'Error:' prefix" else fail "error command did not output expected printf command" printf '%s\n' "Output: $output" fi # Verify it outputs exit 1 if printf '%s\n' "$output" | grep -q 'exit 1'; then pass "error command outputs exit 1" else fail "error command did not output exit 1" fi # Verify output goes to stderr (>&2) if printf '%s\n' "$output" | grep -q '>&2'; then pass "error command redirects to stderr" else fail "error command did not redirect to stderr" fi } # TEST 119: Error command with 2 lines (Issue #002) test_error_command_two_lines() { test_header "error command - two line message" "test_error_command_two_lines" test_setup # Override to cat (display commands, not execute them) set_function_interpreter "error" "cat" # Run error with 2 lines local output=$(run_vpn_switch error "First line" "Second line" 2>&1 || true) # Verify first line has Error: prefix if printf '%s\n' "$output" | grep -q 'printf.*Error:.*First line'; then pass "error command first line has 'Error:' prefix" else fail "error command first line incorrect" printf '%s\n' "Output: $output" fi # Verify second line is indented (no Error: prefix) if printf '%s\n' "$output" | grep -q 'printf.* %s.*Second line' && ! echo "$output" | tail -2 | grep -q 'Error:.*Second line'; then pass "error command second line is indented without 'Error:' prefix" else fail "error command second line not properly formatted" printf '%s\n' "Output: $output" fi # Verify exit 1 if printf '%s\n' "$output" | grep -q 'exit 1'; then pass "error command outputs exit 1" else fail "error command did not output exit 1" fi } # TEST 120: Error command with 3 lines (Issue #002) test_error_command_three_lines() { test_header "error command - three line message" "test_error_command_three_lines" test_setup # Override to cat (display commands, not execute them) set_function_interpreter "error" "cat" # Run error with 3 lines local output=$(run_vpn_switch error "Line one" "Line two" "Line three" 2>&1 || true) # Verify first line has Error: prefix if printf '%s\n' "$output" | grep -q 'printf.*Error:.*Line one'; then pass "error command first line has 'Error:' prefix" else fail "error command first line incorrect" printf '%s\n' "Output: $output" fi # Verify second and third lines are indented (count printf with comment-prefixed indent pattern) local indented_count=$(printf '%s\n' "$output" | grep 'printf "# %s' | wc -l | tr -d ' ') if [ "$indented_count" -ge 2 ]; then pass "error command has properly indented continuation lines" else fail "error command continuation lines not properly formatted (expected 2+ indented, got $indented_count)" fi # Verify all three lines are present if printf '%s\n' "$output" | grep -q 'Line one' && printf '%s\n' "$output" | grep -q 'Line two' && printf '%s\n' "$output" | grep -q 'Line three'; then pass "error command includes all three message lines" else fail "error command missing message lines" printf '%s\n' "Output: $output" fi # Verify exit 1 if printf '%s\n' "$output" | grep -q 'exit 1'; then pass "error command outputs exit 1" else fail "error command did not output exit 1" fi } # TEST 121: Error command ANSI color support (Issue #002) test_error_command_ansi_colors() { test_header "error command - ANSI color support" "test_error_command_ansi_colors" test_setup # Enable ANSI colors via database (survives environment isolation) run_vpn_switch setenv VPN_SWITCH_DISPLAY_ANSI 1 > /dev/null 2>&1 # Override to cat (display commands, not execute them) set_function_interpreter "error" "cat" # Run error with 1 line local output=$(run_vpn_switch error "Test message" 2>&1 || true) # Verify it contains ANSI escape codes when ANSI enabled if printf '%s\n' "$output" | grep -q '\\033\['; then pass "error command includes ANSI escape codes when enabled" else fail "error command did not include ANSI codes" printf '%s\n' "Output: $output" fi # Verify reset code is present if printf '%s\n' "$output" | grep -q '\\033\[0m'; then pass "error command includes ANSI reset code" else fail "error command did not include ANSI reset code" fi } # TEST 122: Regression test for wireguard add2 (Issue #001) test_wireguard_add2_delegation() { test_header "wireguard add2 - delegates correctly to add3" "test_wireguard_add2_delegation" test_setup # Override for command inspection (arity-specific to avoid breaking heterogeneous family) override_for_command_inspection "wireguard_add2" # Create test config echo "[Interface]" > "$TEST_DIR/wireguard/test.conf" mkdir -p "$TEST_DIR/wireguard/privacy" # Run add2 (category + target, target used as alias) local output=$(run_vpn_switch wireguard add privacy test.conf 2>&1 || true) # Should delegate to add3 with target as both target and alias if printf '%s\n' "$output" | grep -q '"\$VPN_SWITCH_CONTEXT_SCRIPT" wireguard add "privacy" "test.conf" "test.conf"'; then pass "wireguard add2 delegates correctly with literal variable" else fail "wireguard add2 delegation incorrect" fi } #----------------------------------------------------------------------------- # Batch Infrastructure Tests (Phase 1 - Exit Code Encoding) #----------------------------------------------------------------------------- # TEST: get_batch_id - sequential allocation test_batch_id_sequential() { test_header "get_batch_id - sequential allocation" "test_batch_id_sequential" test_setup # Source vpn-switch.sh to access infrastructure functions . "$TEST_SCRIPT" # Get first batch ID local batch_id_1=$(VPN_SWITCH_BASE="$TEST_DIR" LOG_FILE=/dev/null get_batch_id) if [ "$batch_id_1" = "0" ]; then pass "First batch ID is 0" else fail "First batch ID was $batch_id_1, expected 0" fi # Get second batch ID local batch_id_2=$(VPN_SWITCH_BASE="$TEST_DIR" LOG_FILE=/dev/null get_batch_id) if [ "$batch_id_2" = "1" ]; then pass "Second batch ID is 1" else fail "Second batch ID was $batch_id_2, expected 1" fi # Get third batch ID local batch_id_3=$(VPN_SWITCH_BASE="$TEST_DIR" LOG_FILE=/dev/null get_batch_id) if [ "$batch_id_3" = "2" ]; then pass "Third batch ID is 2" else fail "Third batch ID was $batch_id_3, expected 2" fi # Verify counter file exists and has correct value local counter_value=$(cat "$TEST_DIR/.tmp/batch-counter") if [ "$counter_value" = "3" ]; then pass "Counter file has correct value (3)" else fail "Counter file has value $counter_value, expected 3" fi } # TEST: store_batch_exits - basic storage test_store_batch_exits_basic() { test_header "store_batch_exits - stores exit codes to file" "test_store_batch_exits_basic" test_setup # Source vpn-switch.sh . "$TEST_SCRIPT" # Store batch exits VPN_SWITCH_BASE="$TEST_DIR" LOG_FILE=/dev/null store_batch_exits 5 0 1 2 3 # Verify file created if [ -f "$TEST_DIR/.tmp/batch-exits/5" ]; then pass "Batch file created" else fail "Batch file not created" fi # Verify content local content=$(cat "$TEST_DIR/.tmp/batch-exits/5") if [ "$content" = "0 1 2 3" ]; then pass "Batch file has correct content" else fail "Batch file content is '$content', expected '0 1 2 3'" fi } # TEST: store_batch_exits - with batch references test_store_batch_exits_references() { test_header "store_batch_exits - stores exit codes with batch references" "test_store_batch_exits_references" test_setup # Source vpn-switch.sh . "$TEST_SCRIPT" # Store batch exits with references (format: "exit:batch_ref") VPN_SWITCH_BASE="$TEST_DIR" LOG_FILE=/dev/null store_batch_exits 10 "0 5 129:1 0" # Verify content includes batch references local content=$(cat "$TEST_DIR/.tmp/batch-exits/10") if [ "$content" = "0 5 129:1 0" ]; then pass "Batch file stores references correctly" else fail "Batch file content is '$content', expected '0 5 129:1 0'" fi } # TEST: free_batch_id - cleanup test_free_batch_id() { test_header "free_batch_id - deletes batch file" "test_free_batch_id" test_setup # Source vpn-switch.sh . "$TEST_SCRIPT" # Create a batch file mkdir -p "$TEST_DIR/.tmp/batch-exits" echo "0 1 0" > "$TEST_DIR/.tmp/batch-exits/7" # Verify it exists if [ -f "$TEST_DIR/.tmp/batch-exits/7" ]; then pass "Batch file exists before cleanup" else fail "Batch file should exist before cleanup" fi # Free the batch ID VPN_SWITCH_BASE="$TEST_DIR" LOG_FILE=/dev/null free_batch_id 7 # Verify file deleted if [ ! -f "$TEST_DIR/.tmp/batch-exits/7" ]; then pass "Batch file deleted after cleanup" else fail "Batch file still exists after cleanup" fi } # TEST: Batch infrastructure - full lifecycle test_batch_infrastructure_lifecycle() { test_header "Batch infrastructure - full allocation/store/free lifecycle" "test_batch_infrastructure_lifecycle" test_setup # Source vpn-switch.sh . "$TEST_SCRIPT" # Allocate batch ID local batch_id=$(VPN_SWITCH_BASE="$TEST_DIR" LOG_FILE=/dev/null get_batch_id) pass "Allocated batch ID: $batch_id" # Store results VPN_SWITCH_BASE="$TEST_DIR" LOG_FILE=/dev/null store_batch_exits "$batch_id" "0 2 0 5" if [ -f "$TEST_DIR/.tmp/batch-exits/$batch_id" ]; then pass "Batch results stored" else fail "Batch results not stored" fi # Read and verify local content=$(cat "$TEST_DIR/.tmp/batch-exits/$batch_id") if [ "$content" = "0 2 0 5" ]; then pass "Batch results readable" else fail "Batch results incorrect: '$content'" fi # Free batch ID VPN_SWITCH_BASE="$TEST_DIR" LOG_FILE=/dev/null free_batch_id "$batch_id" if [ ! -f "$TEST_DIR/.tmp/batch-exits/$batch_id" ]; then pass "Batch cleaned up successfully" else fail "Batch file still exists after cleanup" fi } # Sync command tests # # Validate the three new sync functions and the version status command. # Tests use the public CLI surface only — no internal-state inspection. test_sync_batch_outputs_three_subcommands() { test_header "sync - batch combinator emits database init + phases/env/version sync" "test_sync_batch_outputs_three_subcommands" test_setup override_for_command_inspection "sync" local output=$(run_vpn_switch sync) if printf '%s\n' "$output" | grep -qE '"\$VPN_SWITCH_CONTEXT_SCRIPT" database init$'; then pass "sync emits 'database init' first (an older database gains the layout)" else fail "sync missing 'database init' subcommand" fi if printf '%s\n' "$output" | grep -qE '"\$VPN_SWITCH_CONTEXT_SCRIPT" phases sync$'; then pass "sync emits 'phases sync' subcommand" else fail "sync missing 'phases sync' subcommand" fi if printf '%s\n' "$output" | grep -qE '"\$VPN_SWITCH_CONTEXT_SCRIPT" env sync$'; then pass "sync emits 'env sync' subcommand" else fail "sync missing 'env sync' subcommand" fi if printf '%s\n' "$output" | grep -qE '"\$VPN_SWITCH_CONTEXT_SCRIPT" version sync$'; then pass "sync emits 'version sync' subcommand" else fail "sync missing 'version sync' subcommand" fi } test_env_sync_per_file_chmod() { test_header "env sync - 0600 per file without -R (regression guard)" "test_env_sync_per_file_chmod" test_setup override_for_command_inspection "env_sync" local output=$(run_vpn_switch env sync) # Regression guard: -R 0600 would strip the directory's traverse bit. # Strip comment lines first so the explanatory comment in _env_sync0 # doesn't trigger a false positive. local non_comment=$(printf '%s\n' "$output" | grep -v '^#') if ! printf '%s\n' "$non_comment" | grep -qE 'chmod -R 0600|0600 -R'; then pass "env sync does not use recursive chmod 0600" else fail "env sync uses recursive chmod 0600 (would lock out .env/default)" fi if printf "%s\n" "$output" | grep -qF "0600 \"$TEST_DIR/.env/default/\"*"; then pass "env sync sets 0600 on the default files in one glob line" else fail "env sync missing the 0600 glob line for .env/default/*" fi } test_environment_scan_ignores_stray_files() { test_header "environment scan: a stray .new or backup file under .env/ is no variable" "test_environment_scan_ignores_stray_files" test_setup printf 'x\n' > "$TEST_DIR/.env/local/VPN_SWITCH_CACHE_ENV_ARGS.new" printf 'y\n' > "$TEST_DIR/.env/local/VPN_SWITCH_DISPLAY_ANSI~" run_vpn_switch environment cache off > /dev/null 2>&1 local out if out=$(run_vpn_switch environment cache on 2>&1) && ! printf '%s\n' "$out" | grep -q "eval:" \ && ! grep -q "VPN_SWITCH_CACHE_ENV_ARGS.new\|VPN_SWITCH_DISPLAY_ANSI~" "$TEST_DIR/.env/local/VPN_SWITCH_CACHE_ENV_ARGS"; then pass "the cache rebuilt without the stray names" else fail "stray files reached the scan: $(printf '%s\n' "$out" | head -2 | tr '\n' ' ')" fi } test_batch_pin_governs_next_line() { test_header "batch: a pin set by one line governs the next line -- process runner and binary runner" test_setup local bin="$(dirname "$TEST_SCRIPT")/vpn-switch-binary.sh" ca="$TEST_BASE_DIR/pin-a-$TESTS_RUN.conf" cb="$TEST_BASE_DIR/pin-b-$TESTS_RUN.conf" out printf '[Interface]\nPrivateKey = x\nAddress = 10.0.0.2/32\n[Peer]\nPublicKey = y\nEndpoint = 1.2.3.4:51820\nAllowedIPs = 0.0.0.0/0\n' > "$ca" cp "$ca" "$cb" cat > "$TEST_BASE_DIR/unit-pin-$TESTS_RUN.sh" <&1) if [ -f "$TEST_DIR/wireguard/${ca##*/}" ] && [ ! -f "$TEST_DIR/wireguard/${cb##*/}" ] \ && printf '%s\n' "$out" | grep -q "^# Import WireGuard config: ${cb##*/}"; then pass "process runner: the import before the pin ran, the one after it was displayed" else fail "process runner: $(ls "$TEST_DIR/wireguard" 2>&1 | tr '\n' ' ')" fi rm -f "$TEST_DIR/wireguard/${ca##*/}" VPN_SWITCH_BASE="$TEST_DIR" "$TEST_SCRIPT" unsetenv VPN_SWITCH_INTERPRETER_wireguard_import1 > /dev/null 2>&1 out=$(VPN_SWITCH_DISPLAY_ANSI=0 sh "$bin" "$TEST_DIR" batch "$TEST_BASE_DIR/unit-pin-$TESTS_RUN.sh" 2>&1) if [ -f "$TEST_DIR/wireguard/${ca##*/}" ] && [ ! -f "$TEST_DIR/wireguard/${cb##*/}" ] \ && printf '%s\n' "$out" | grep -q "^# Import WireGuard config: ${cb##*/}"; then pass "binary runner: the import before the pin ran, the one after it was displayed" else fail "binary runner: $(ls "$TEST_DIR/wireguard" 2>&1 | tr '\n' ' ')" fi } test_binary_runs_in_one_process() { test_header "vpn-switch-binary.sh: the engine's exit rules in one process -- a command, fail-fast, keep-going, the three names" test_setup local bin="$(dirname "$TEST_SCRIPT")/vpn-switch-binary.sh" walk="$(dirname "$TEST_SCRIPT")/vpn-switch-walkthrough.sh" out rc n cat > "$TEST_BASE_DIR/unit-bin-$TESTS_RUN.sh" <<'EOF' "$VPN_SWITCH_CONTEXT_SCRIPT" version "$VPN_SWITCH_CONTEXT_SCRIPT" error boom "$VPN_SWITCH_CONTEXT_SCRIPT" version EOF rc=0; out=$(sh "$bin" "$TEST_DIR" version 2>&1) || rc=$? if [ "$rc" -eq 0 ] && printf '%s\n' "$out" | grep -q '^db:'; then pass "a command runs and exits 0" else fail "version via the binary: rc=$rc $(printf '%s\n' "$out" | head -3 | tr '\n' '|')" fi rc=0; sh "$bin" "$TEST_DIR" error boom > /dev/null 2>&1 || rc=$? if [ "$rc" -eq 1 ]; then pass "a failing command exits 1"; else fail "error via the binary: rc=$rc"; fi rc=0; out=$(sh "$bin" "$TEST_DIR" batch "$TEST_BASE_DIR/unit-bin-$TESTS_RUN.sh" 2>&1) || rc=$? n=$(printf '%s\n' "$out" | grep -c '^db:' || true) if [ "$rc" -ne 0 ] && [ "$n" -eq 1 ]; then pass "fail-fast: the line after the failure did not run, exit $rc" else fail "fail-fast: rc=$rc versions=$n $(printf '%s\n' "$out" | tail -3 | tr '\n' '|')" fi rc=0; out=$(VPN_SWITCH_BATCH_KEEP_GOING=1 sh "$bin" "$TEST_DIR" batch "$TEST_BASE_DIR/unit-bin-$TESTS_RUN.sh" 2>&1) || rc=$? n=$(printf '%s\n' "$out" | grep -c '^db:' || true) if [ "$rc" -ne 0 ] && [ "$n" -eq 2 ]; then pass "keep-going: every line ran, exit $rc still reports the failure" else fail "keep-going: rc=$rc versions=$n" fi rc=0; out=$(sh "$walk" "$TEST_DIR" error boom 2>&1) || rc=$? if printf '%s\n' "$out" | grep -q '^# -- _error1 error boom' && printf '%s\n' "$out" | grep -q '^exit 1'; then pass "the walkthrough shows the act under its anchor instead of running it" else fail "walkthrough: rc=$rc $(printf '%s\n' "$out" | head -4 | tr '\n' '|')" fi } test_version_sync_outputs_copy_and_perms() { test_header "version sync - copies VERSION and chmods 0600" "test_version_sync_outputs_copy_and_perms" test_setup override_for_command_inspection "version_sync" local output=$(run_vpn_switch version sync) if printf '%s\n' "$output" | grep -qE 'template/VERSION.*\.version'; then pass "version sync copies template/VERSION → .version" else fail "version sync missing copy of template/VERSION to .version" fi if printf '%s\n' "$output" | grep -qE '0600.*\.version|\.version.*0600'; then pass "version sync chmods .version to 0600" else fail "version sync missing 0600 chmod on .version" fi } test_version_command_stamped() { test_header "version - reports db and source SHAs after bootstrap" "test_version_command_stamped" test_setup # Bootstrap auto-stamps .version, so freshly bootstrapped DB should report a real SHA local output=$(run_vpn_switch version) if printf '%s\n' "$output" | grep -qE '^db: [a-f0-9]{7,}$'; then pass "version reports db SHA in 'db: ' format" else fail "version missing 'db: ' line (output: $output)" fi if printf '%s\n' "$output" | grep -qE '^source: [a-f0-9]{7,}$'; then pass "version reports source SHA in 'source: ' format" else fail "version missing 'source: ' line" fi local db_sha=$(printf '%s\n' "$output" | grep '^db:' | awk '{print $2}') local src_sha=$(printf '%s\n' "$output" | grep '^source:' | awk '{print $2}') if [ -n "$db_sha" ] && [ "$db_sha" = "$src_sha" ]; then pass "version: db SHA matches source SHA after fresh bootstrap" else fail "version: db SHA != source SHA (db=$db_sha source=$src_sha)" fi } test_version_command_unstamped() { test_header "version - reports 'unstamped' when .version is missing" "test_version_command_unstamped" test_setup # Remove the .version that bootstrap auto-stamped rm -f "$TEST_DIR/.version" local output=$(run_vpn_switch version) if printf '%s\n' "$output" | grep -qE '^db: unstamped$'; then pass "version reports 'db: unstamped' when .version absent" else fail "version did not report 'db: unstamped' (output: $output)" fi } #----------------------------------------------------------------------------- # Main Test Runner #----------------------------------------------------------------------------- # parallel_main - run every test function of the active filter as its own # suite process (sequential inside), MAXPROCS at a time via xargs -P; then # replay the outputs in list order and print an aggregated summary. parallel_main() { local outdir rc=0 t outdir=$(mktemp -d "${TMPDIR:-/tmp}/vsw-par.XXXXXX") || exit 1 echo "vpn-switch Unit Test Suite (parallel, -P $MAXPROCS)" echo "" local tests if [ "$TEST_FILTER" = "$ALL_TESTS" ]; then # default run: the REAL test list are the should_run_test lines in main # (ALL_TESTS is the grep-derived filter BASE and also catches the # framework functions test_header/test_summary/test_setup) tests=$(grep -E '^[[:space:]]*should_run_test ' "$0" | awk '{print $2}') else tests="$TEST_FILTER" fi printf '%s\n' $tests | xargs -n1 -P "$MAXPROCS" -I{} \ sh -c 'sh "$0" "$1" {} > "$2/{}.out" 2>&1; echo $? > "$2/{}.rc"' \ "$0" "$TEST_PROFILE" "$outdir" for t in $tests; do cat "$outdir/$t.out" 2>/dev/null [ "$(cat "$outdir/$t.rc" 2>/dev/null)" = "0" ] || rc=1 done echo "" echo "========================================" echo "Aggregated Summary (parallel run)" echo "========================================" awk ' /^Test Functions:/ { tf += $3 } /^Total Assertions:/ { ta += $3 } /Passed Assertions:/ { pa += $3 } /Failed Assertions:/ { fa += $3 } /Stories:$/ { sect = "s" } /Assertions:$/ { sect = "a" } /^ Passed:/ { if (sect == "s") sp += $2; else ap += $2 } /^ Failed:/ { if (sect == "s") sf += $2; else fa += $2 } END { if (tf) printf "Test Functions: %d\n", tf if (sp || sf) printf "Stories: %d passed, %d failed\n", sp, sf if (ta) printf "Total Assertions: %d\n", ta printf "Passed Assertions: %d\n", pa + ap printf "Failed Assertions: %d\n", fa }' "$outdir"/*.out if [ "$rc" -eq 0 ]; then echo "" echo "ALL TESTS PASSED" rm -rf "$outdir" else echo "" echo "SOME TESTS FAILED" echo "Per-test outputs preserved in: $outdir" fi return $rc } main() { if [ "$MAXPROCS" -gt 1 ]; then parallel_main return $? fi printf '%b========================================%b\n' "$COLOR_BLUE" "$COLOR_RESET" printf '%bvpn-switch Unit Test Suite (Formalized)%b\n' "$COLOR_BLUE" "$COLOR_RESET" printf '%b========================================%b\n' "$COLOR_BLUE" "$COLOR_RESET" echo "" # Check prerequisites if [ ! -f "$TEST_SCRIPT" ]; then printf '%bERROR:%b %s not found\n' "$COLOR_RED" "$COLOR_RESET" "$TEST_SCRIPT" echo "Run this script from the repository root" exit 1 fi echo "" printf '%bRunning Tests%b\n' "$COLOR_BLUE" "$COLOR_RESET" echo "========================================" # Print filter info if active if [ -n "$TEST_FILTER" ]; then printf '%bFilter active - running only:%b%s\n' "$COLOR_YELLOW" "$COLOR_RESET" "$TEST_FILTER" echo "" fi # Run tests (each test calls test_setup for exclusive database) # No cleanup - artifacts preserved for debugging # Batch infrastructure tests (Phase 1 - Exit Code Encoding) should_run_test test_export_import_pair should_run_test test_restore_pin_in_prologue should_run_test test_batch_id_sequential should_run_test test_store_batch_exits_basic should_run_test test_store_batch_exits_references should_run_test test_free_batch_id should_run_test test_batch_infrastructure_lifecycle # Terminal commands (simple display functions) should_run_test test_printenv_command should_run_test test_complete_command should_run_test test_status_command should_run_test test_error_command_single_line should_run_test test_error_command_two_lines should_run_test test_error_command_three_lines should_run_test test_error_command_ansi_colors should_run_test test_wireguard_list should_run_test test_wireguard_validate should_run_test test_wireguard_info # Combinator commands (file operations) should_run_test test_wireguard_import_valid should_run_test test_wireguard_add_category should_run_test test_wireguard_link_create # Layer 2: VPN connection operations should_run_test test_wireguard_start_basic should_run_test test_wireguard_connect_resume should_run_test test_wireguard_stop_basic # Layer 3: Session management should_run_test test_session_list should_run_test test_session_refresh should_run_test test_session_refresh_clean should_run_test test_session_show should_run_test test_session_show_by_name should_run_test test_session_remove # Layer 3: Session save/start (resumption) should_run_test test_session_save_default should_run_test test_session_save_named should_run_test test_session_start_default should_run_test test_session_start_named should_run_test test_session_start_bypid # Layer 3a: Session stop (active session management) should_run_test test_session_stop_named should_run_test test_session_stop_bypid should_run_test test_session_stop_inactive should_run_test test_session_stop_batch_all # Config management: remove operations should_run_test test_wireguard_remove_config_in_use should_run_test test_wireguard_remove_config_with_refs should_run_test test_wireguard_remove_symlink should_run_test test_wireguard_remove_category_link # Config management: clean operations should_run_test test_wireguard_clean_no_broken should_run_test test_wireguard_clean_broken_protocol_link should_run_test test_wireguard_clean_broken_category_link should_run_test test_wireguard_clean_category_no_broken should_run_test test_wireguard_clean_category_with_broken # Config management: validate operations should_run_test test_wireguard_validate_broken_symlink should_run_test test_wireguard_validate_broken_category_symlink should_run_test test_wireguard_validate_naming_conflict should_run_test test_wireguard_validate_invalid_config # Stop/disconnect operations should_run_test test_stop_no_sessions should_run_test test_stop_specific_interface should_run_test test_wireguard_disconnect_interface should_run_test test_stop_nonexistent_interface should_run_test test_disconnect_without_session should_run_test test_stop_twice_idempotent # Config management: import/add/link edge cases should_run_test test_wireguard_import_nonexistent should_run_test test_wireguard_import_duplicate should_run_test test_wireguard_add_category_conflict should_run_test test_wireguard_add_link_arity2 should_run_test test_wireguard_add_link_arity3 should_run_test test_wireguard_add_link_conflict should_run_test test_wireguard_link_conflict # Error handling should_run_test test_error_invalid_category should_run_test test_error_broken_symlink should_run_test test_error_invalid_command # Edge cases should_run_test test_edge_duplicate_instance_prevention should_run_test test_edge_stale_session_cleanup # Config management: patch operations should_run_test test_wireguard_patch_valid should_run_test test_wireguard_patch_nonexistent should_run_test test_wireguard_patch_absolute should_run_test test_wireguard_patch_keepalive # Config management: info operations should_run_test test_wireguard_info_category # Session management: save/start operations should_run_test test_session_save_default should_run_test test_session_save_named should_run_test test_session_start_default should_run_test test_session_start_named should_run_test test_session_start_by_pid # OpenVPN config management should_run_test test_openvpn_import_valid should_run_test test_openvpn_list should_run_test test_openvpn_add_group should_run_test test_openvpn_add_to_group_arity2 should_run_test test_openvpn_add_to_group_arity3 should_run_test test_openvpn_link should_run_test test_openvpn_remove should_run_test test_openvpn_validate should_run_test test_openvpn_info should_run_test test_openvpn_dump # OpenVPN lifecycle tests (Phase 3.3.1) should_run_test test_openvpn_patch_valid should_run_test test_openvpn_patch_nonexistent should_run_test test_openvpn_patch_absolute should_run_test test_openvpn_patch_credentials_bare should_run_test test_openvpn_patch_credentials_existing should_run_test test_openvpn_patch_credentials_missing should_run_test test_openvpn_start_basic should_run_test test_openvpn_stop_basic should_run_test test_openvpn_disconnect_interface should_run_test test_openvpn_clean # Protocol-agnostic wrappers (delegation tests) should_run_test test_protocol_agnostic_import_wireguard should_run_test test_protocol_agnostic_list should_run_test test_protocol_agnostic_link should_run_test test_protocol_agnostic_remove should_run_test test_protocol_agnostic_validate # System validation tests (Phase 4.1 - Feature 3) should_run_test test_network_validate should_run_test test_permissions_validate should_run_test test_environment_validate should_run_test test_batch_validate_includes_system # Protocol-agnostic start tests (Phase 3.2.1) should_run_test test_start_wireguard_only should_run_test test_start_openvpn_only should_run_test test_start_not_found should_run_test test_start_no_default should_run_test test_start_with_default_wireguard should_run_test test_start_with_default_openvpn should_run_test test_start_default_invalid_target # Phase 3.4.1: Protocol discovery tests should_run_test test_init_extension_links should_run_test test_start_extension_link_fast_path should_run_test test_start_multiple_matches # Phase 4.4: Init function tests should_run_test test_database_init should_run_test test_environment_init should_run_test test_environment_init_platform_template should_run_test test_phases_copy should_run_test test_phases_lift should_run_test test_phases_default_profile should_run_test test_phases_sync_single should_run_test test_phases_sync_all should_run_test test_init_batch_combinator should_run_test test_init_combinator # Environment management commands should_run_test test_setenv_basic should_run_test test_setenv_invalid_name should_run_test test_getenv_local_override should_run_test test_getenv_fallback_chain should_run_test test_unsetenv_existing should_run_test test_unsetenv_not_set should_run_test test_dumpenv_prologue should_run_test test_dumpenv_epilogue should_run_test test_dumpenv_profile_filtering # Environment cache tests (Phase 4.3) should_run_test test_environment_cache_on should_run_test test_environment_cache_off should_run_test test_environment_cache_status should_run_test test_environment_cache_no_args should_run_test test_cache_invalidation_setenv should_run_test test_cache_invalidation_unsetenv should_run_test test_cache_invalidation_environment_init should_run_test test_dumpenv_cache_preservation should_run_test test_dumpenv_cache_not_included_when_disabled should_run_test test_cache_no_recursion # Interpreter resolution tests (lower half of lookup_interpreter) should_run_test test_interpreter_single_underscore should_run_test test_interpreter_double_underscore should_run_test test_interpreter_triple_underscore should_run_test test_interpreter_resolution_priority # Inspect command tests (Phase 4.1 - Feature 3) should_run_test test_inspect_batch_complete should_run_test test_environment_inspect should_run_test test_system_inspect # Issue #001: Variable Expansion Timing and Function Classification should_run_test test_batch_syntax_wireguard_clean should_run_test test_combinators_literal_variable should_run_test test_combinator_syntax_validation should_run_test test_function_classification_correct should_run_test test_no_terminal_output_vpnswitch_commands should_run_test test_metadata_function_lists should_run_test test_wireguard_add2_delegation # Sync command tests should_run_test test_sync_batch_outputs_three_subcommands should_run_test test_env_sync_per_file_chmod should_run_test test_version_sync_outputs_copy_and_perms should_run_test test_version_command_stamped should_run_test test_version_command_unstamped should_run_test test_environment_scan_ignores_stray_files should_run_test test_batch_pin_governs_next_line should_run_test test_binary_runs_in_one_process # Print summary test_summary } # Run tests main "$@"