#!/usr/bin/env bash set -Eeuo pipefail umask 077 TESTS_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" readonly TESTS_DIR # shellcheck source=helpers.sh source "${TESTS_DIR}/helpers.sh" # shellcheck source=../wg-manager.sh source "${PROJECT_DIR}/wg-manager.sh" case $# in 0) ;; 1) case $1 in config|parser|status|list|list-redirected|list-deterministic|transaction|rollback|locking|backups|mutation-preserves-source|secret-modes|add-client|address-allocation|add-collisions|add-rollback|no-secret-logs|add-golden-config|add-invalid-names|show-client|config-export|qr|path-safety|no-secret-default-output|remove-client|remove-cancel|remove-exact-block|remove-rollback|archive-client|archive-path-safety|remove-preserves-neighbors|diagnostics|diagnostics-systemd|diagnostics-firewall|diagnostics-forwarding|diagnostics-runtime-diff|diagnostics-read-only|diagnostics-exit-codes|audit|audit-no-secrets|backup-list|restore|restore-invalid|restore-rollback|backup-path-safety|runtime-file-modes|gum-resolution|tui-routing|tui-cancel|tui-read-only|tui-no-gum|tui-non-tty|tui-add|tui-remove|tui-client-actions|tui-backup|tui-restore|tui-backend-failure|tui-no-secret-args|symlink-safety|no-host-writes|no-host-command-bypass|m11-e2e|secret-scan) ;; *) printf 'Ошибка: неизвестная группа тестов или лишние аргументы\n' >&2; exit 64 ;; esac ;; *) printf 'Ошибка: неизвестная группа тестов или лишние аргументы\n' >&2; exit 64 ;; esac test_group=${1-} setup_test_environment write_valid_config() { local output_file=$1 override_key=${2-} override_value=${3-} local wg_interface=wg0 wg_conf="${TEST_ROOT}/wg0.conf" client_dir="${TEST_ROOT}/clients" local server_endpoint=vpn.example.invalid server_port=51820 server_vpn_ip=192.0.2.1 local client_dns='192.0.2.53,2001:db8::53' vpn_subnet_prefix=192.0.2 start_host=2 end_host=254 local use_ipv6=0 ipv6_prefix=2001:db8:ffff:: default_client_mode=full-tunnel local recent_handshake_seconds=180 backup_retention=10 case $override_key in '') ;; WG_INTERFACE) wg_interface=$override_value ;; WG_CONF) wg_conf=$override_value ;; CLIENT_DIR) client_dir=$override_value ;; SERVER_ENDPOINT) server_endpoint=$override_value ;; SERVER_PORT) server_port=$override_value ;; SERVER_VPN_IP) server_vpn_ip=$override_value ;; CLIENT_DNS) client_dns=$override_value ;; VPN_SUBNET_PREFIX) vpn_subnet_prefix=$override_value ;; START_HOST) start_host=$override_value ;; END_HOST) end_host=$override_value ;; USE_IPV6) use_ipv6=$override_value ;; IPV6_PREFIX) ipv6_prefix=$override_value ;; DEFAULT_CLIENT_MODE) default_client_mode=$override_value ;; RECENT_HANDSHAKE_SECONDS) recent_handshake_seconds=$override_value ;; BACKUP_RETENTION) backup_retention=$override_value ;; RANGE_REVERSED) start_host=254; end_host=2 ;; *) printf 'unknown test config key: %s\n' "$override_key" >&2; return 64 ;; esac cat >"$output_file" <"$temp_file" mv -- "$temp_file" "$input_file" } replace_config_line() { local input_file=$1 prefix=$2 replacement=$3 line temp_file temp_file="${input_file}.tmp" while IFS= read -r line || [[ -n $line ]]; do if [[ $line == "${prefix}"* ]]; then printf '%s\n' "$replacement"; else printf '%s\n' "$line"; fi done <"$input_file" >"$temp_file" mv -- "$temp_file" "$input_file" } run_config_tests() { local valid_config="${TEST_ROOT}/valid.conf" status output marker write_valid_config "$valid_config" export WG_MANAGER_CONFIG="$valid_config" export WG_MANAGER_BACKUP_DIR="${TEST_ROOT}/backups" export WG_MANAGER_LOG_DIR="${TEST_ROOT}/logs" export WG_MANAGER_METADATA_DIR="${TEST_ROOT}/clients" export WG_MANAGER_ARCHIVE_DIR="${TEST_ROOT}/archive" export WG_MANAGER_LOCK_FILE="${TEST_ROOT}/manager.lock" begin_test 'valid restricted configuration loads with isolated paths' load_config assert_equal wg0 "$WG_INTERFACE" 'имя интерфейса' assert_equal "$TEST_ROOT/wg0.conf" "$WG_CONF" 'путь WG_CONF' assert_equal "$TEST_ROOT/clients" "$CLIENT_DIR" 'путь клиентов' assert_equal "$TEST_ROOT/backups" "$BACKUP_DIR" 'путь резервных копий' assert_equal "$TEST_ROOT/logs" "$LOG_DIR" 'путь журнала' assert_equal "$TEST_ROOT/clients" "$METADATA_DIR" 'путь метаданных' assert_equal "$TEST_ROOT/archive" "$ARCHIVE_DIR" 'путь архива' assert_equal "$TEST_ROOT/manager.lock" "$LOCK_FILE" 'путь lock-файла' begin_test 'Bash version guard has explicit supported and rejected branches' assert_equal 0 "$(wg_manager_bash_supported 4 3 4 3; printf '%s' "$?")" 'supported Bash version' assert_failure "$(wg_manager_bash_supported 4 3 4 2; printf '%s' "$?")" 'old Bash minor version' assert_failure "$(wg_manager_bash_supported 4 3 3 9; printf '%s' "$?")" 'old Bash major version' begin_test 'test hooks require a confined non-symlink root and paths' assert_equal 1 "$(TEST_ROOT=/ WG_MANAGER_TEST_MODE=1 CONFIG_PATH=/tmp/x transaction_test_hooks_enabled >/dev/null 2>&1; printf '%s' "$?")" 'TEST_ROOT=/ отклонён' assert_equal 1 "$(TEST_ROOT=relative WG_MANAGER_TEST_MODE=1 CONFIG_PATH=relative transaction_test_hooks_enabled >/dev/null 2>&1; printf '%s' "$?")" 'относительный TEST_ROOT отклонён' ln -s -- "$TEST_ROOT" "${TEST_ROOT}/root-link" assert_equal 1 "$(TEST_ROOT="${TEST_ROOT}/root-link" WG_MANAGER_TEST_MODE=1 CONFIG_PATH="${TEST_ROOT}/root-link/config" transaction_test_hooks_enabled >/dev/null 2>&1; printf '%s' "$?")" 'symlink TEST_ROOT отклонён' assert_equal 1 "$(TEST_ROOT="$TEST_ROOT" WG_MANAGER_TEST_MODE=1 CONFIG_PATH=/tmp/outside transaction_test_hooks_enabled >/dev/null 2>&1; printf '%s' "$?")" 'CONFIG_PATH вне root отклонён' assert_equal 1 "$(TEST_ROOT="$TEST_ROOT" WG_MANAGER_TEST_MODE=1 CONFIG_PATH="$CONFIG_PATH" WG_CONF=/tmp/outside transaction_test_hooks_enabled >/dev/null 2>&1; printf '%s' "$?")" 'WG_CONF вне root отклонён' for hook_path_name in CLIENT_DIR METADATA_DIR BACKUP_DIR LOCK_FILE LOG_DIR ARCHIVE_DIR; do assert_equal 1 "$(TEST_ROOT="$TEST_ROOT" WG_MANAGER_TEST_MODE=1 CONFIG_PATH="$CONFIG_PATH" WG_CONF="$WG_CONF"; declare "$hook_path_name=/tmp/outside"; if transaction_test_hooks_enabled >/dev/null 2>&1; then printf 0; else printf 1; fi)" "${hook_path_name} вне root отклонён" done mkdir -- "${TEST_ROOT}/hook-real"; ln -s -- "${TEST_ROOT}/hook-real" "${TEST_ROOT}/hook-link" for hook_path_name in WG_CONF CLIENT_DIR METADATA_DIR BACKUP_DIR LOCK_FILE LOG_DIR ARCHIVE_DIR; do assert_equal 1 "$(TEST_ROOT="$TEST_ROOT" WG_MANAGER_TEST_MODE=1 CONFIG_PATH="$CONFIG_PATH"; declare "$hook_path_name=${TEST_ROOT}/hook-link/value"; if transaction_test_hooks_enabled >/dev/null 2>&1; then printf 0; else printf 1; fi)" "${hook_path_name} с symlink-компонентом отклонён" done rm -rf -- "${TEST_ROOT}/hook-real" "${TEST_ROOT}/hook-link" mkfifo "${TEST_ROOT}/hook-ready" "${TEST_ROOT}/hook-release" ln -s -- "${TEST_ROOT}/hook-ready" "${TEST_ROOT}/hook-ready-link" printf 'sentinel\n' >"${TEST_ROOT}/hook-pid-sentinel" ln -s -- "${TEST_ROOT}/hook-pid-sentinel" "${TEST_ROOT}/hook-pid-link" assert_equal 1 "$(ADD_PUBLISH_READY_FIFO="${TEST_ROOT}/hook-ready-link" ADD_PUBLISH_RELEASE_FIFO="${TEST_ROOT}/hook-release" WG_MANAGER_TEST_MODE=1; if transaction_test_hooks_enabled >/dev/null 2>&1; then printf 0; else printf 1; fi)" 'symlink publish FIFO отклонён' assert_equal 1 "$(ADD_PUBLISH_READY_FIFO="${TEST_ROOT}/hook-ready" ADD_PUBLISH_RELEASE_FIFO="${TEST_ROOT}/hook-release" ADD_PUBLISH_PID_FILE="${TEST_ROOT}/hook-pid-link" WG_MANAGER_TEST_MODE=1; if transaction_test_hooks_enabled >/dev/null 2>&1; then printf 0; else printf 1; fi)" 'symlink publish PID отклонён' assert_equal 1 "$(ADD_PUBLISH_READY_FIFO="${TEST_ROOT}/hook-ready" ADD_PUBLISH_RELEASE_FIFO="${TEST_ROOT}/hook-release" WG_MANAGER_TEST_MODE=0; if transaction_test_hooks_enabled >/dev/null 2>&1; then printf 0; else printf 1; fi)" 'hooks вне test mode отклонены' rm -f -- "${TEST_ROOT}/hook-ready" "${TEST_ROOT}/hook-release" "${TEST_ROOT}/hook-ready-link" "${TEST_ROOT}/hook-pid-link" "${TEST_ROOT}/hook-pid-sentinel" rm -f -- "${TEST_ROOT}/root-link" begin_test 'required keys and parser quoting branches are covered' write_valid_config "${TEST_ROOT}/missing.conf" remove_config_key "${TEST_ROOT}/missing.conf" WG_INTERFACE WG_MANAGER_CONFIG="${TEST_ROOT}/missing.conf" set +e output=$(load_config 2>&1) status=$? set -e assert_equal "$EXIT_VALIDATION" "$status" 'отсутствующий параметр' assert_contains 'Отсутствует параметр' "$output" 'диагностика отсутствующего параметра' write_valid_config "${TEST_ROOT}/quotes.conf" SERVER_ENDPOINT 'vpn.example.invalid"' WG_MANAGER_CONFIG="${TEST_ROOT}/quotes.conf" set +e output=$(load_config 2>&1) status=$? set -e assert_equal "$EXIT_VALIDATION" "$status" 'встроенные кавычки' assert_contains 'Некорректные кавычки' "$output" 'диагностика кавычек' write_valid_config "${TEST_ROOT}/ipv6-on.conf" USE_IPV6 1 WG_MANAGER_CONFIG="${TEST_ROOT}/ipv6-on.conf" load_config assert_equal 1 "$USE_IPV6" 'включённый IPv6' write_valid_config "${TEST_ROOT}/whitespace.conf" replace_config_line "${TEST_ROOT}/whitespace.conf" 'SERVER_PORT=' ' SERVER_PORT = 51820 ' printf '\n# Full-line comment\n' >>"${TEST_ROOT}/whitespace.conf" WG_MANAGER_CONFIG="${TEST_ROOT}/whitespace.conf" load_config assert_equal 51820 "$SERVER_PORT" 'пробелы и комментарии' write_valid_config "${TEST_ROOT}/unquoted.conf" replace_config_line "${TEST_ROOT}/unquoted.conf" 'SERVER_PORT=' 'SERVER_PORT=51820' WG_MANAGER_CONFIG="${TEST_ROOT}/unquoted.conf" load_config assert_equal 51820 "$SERVER_PORT" 'неквотированное значение' begin_test 'runtime path defaults derive from SCRIPT_DIR' unset WG_MANAGER_CONFIG WG_MANAGER_BACKUP_DIR WG_MANAGER_LOG_DIR WG_MANAGER_METADATA_DIR WG_MANAGER_ARCHIVE_DIR WG_MANAGER_LOCK_FILE set_runtime_paths assert_equal "${SCRIPT_DIR}/wg-manager.conf" "$CONFIG_PATH" 'конфигурация по умолчанию' assert_equal "${SCRIPT_DIR}/backups" "$BACKUP_DIR" 'резервные копии по умолчанию' assert_equal "${SCRIPT_DIR}/logs" "$LOG_DIR" 'журнал по умолчанию' assert_equal "${SCRIPT_DIR}/state/clients" "$METADATA_DIR" 'метаданные по умолчанию' assert_equal "${SCRIPT_DIR}/state/archive" "$ARCHIVE_DIR" 'архив по умолчанию' assert_equal "${SCRIPT_DIR}/state/wg-manager.lock" "$LOCK_FILE" 'lock по умолчанию' export WG_MANAGER_CONFIG="$valid_config" export WG_MANAGER_BACKUP_DIR="${TEST_ROOT}/backups" export WG_MANAGER_LOG_DIR="${TEST_ROOT}/logs" export WG_MANAGER_METADATA_DIR="${TEST_ROOT}/clients" export WG_MANAGER_ARCHIVE_DIR="${TEST_ROOT}/archive" export WG_MANAGER_LOCK_FILE="${TEST_ROOT}/manager.lock" begin_test 'add without a name reports usage' set +e output=$("${PROJECT_DIR}/wg-manager.sh" add 2>&1) status=$? set -e assert_equal "$EXIT_USAGE" "$status" 'статус add без имени' assert_contains 'Ожидается имя клиента' "$output" 'сообщение add без имени' begin_test 'invalid configurations are rejected without execution' marker="${TEST_ROOT}/marker" local invalid_file write_valid_config "${TEST_ROOT}/duplicate.conf" for invalid_file in unknown malformed; do write_valid_config "${TEST_ROOT}/${invalid_file}.conf"; done write_valid_config "${TEST_ROOT}/substitution.conf" SERVER_ENDPOINT "\$(touch ${marker})" write_valid_config "${TEST_ROOT}/backtick.conf" SERVER_ENDPOINT "\`touch ${marker}\`" write_valid_config "${TEST_ROOT}/semicolon.conf" SERVER_ENDPOINT "vpn.example.invalid;touch ${marker}" write_valid_config "${TEST_ROOT}/wildcard.conf" SERVER_ENDPOINT 'vpn*.example.invalid' write_valid_config "${TEST_ROOT}/escape.conf" SERVER_ENDPOINT 'vpn\.example.invalid' write_valid_config "${TEST_ROOT}/bad_ipv4.conf" SERVER_VPN_IP '192.0.999.1' write_valid_config "${TEST_ROOT}/bad_port.conf" SERVER_PORT 0 write_valid_config "${TEST_ROOT}/bad_mode.conf" DEFAULT_CLIENT_MODE unsafe write_valid_config "${TEST_ROOT}/bad_range.conf" RANGE_REVERSED 1 write_valid_config "${TEST_ROOT}/bad_path.conf" WG_CONF relative.conf printf 'WG_INTERFACE="wg1"\n' >>"${TEST_ROOT}/duplicate.conf" printf 'NOT_ALLOWED="x"\n' >>"${TEST_ROOT}/unknown.conf" printf 'BROKEN LINE\n' >>"${TEST_ROOT}/malformed.conf" for invalid_file in duplicate unknown malformed substitution backtick semicolon wildcard escape bad_ipv4 bad_port bad_mode bad_range bad_path; do export WG_MANAGER_CONFIG="${TEST_ROOT}/${invalid_file}.conf" set +e output=$(load_config 2>&1) status=$? set -e assert_equal "$EXIT_VALIDATION" "$status" "отклонение ${invalid_file}" case $invalid_file in duplicate) assert_contains 'Дублирующийся параметр' "$output" 'ветка duplicate' ;; unknown) assert_contains 'Неизвестный параметр' "$output" 'ветка unknown' ;; malformed) assert_contains 'Некорректное присваивание' "$output" 'ветка malformed' ;; substitution|backtick|semicolon|wildcard|escape) assert_contains 'Недопустимый синтаксис' "$output" "ветка ${invalid_file}" ;; bad_ipv4) assert_contains 'Некорректный IPv4' "$output" 'ветка IPv4' ;; bad_port) assert_contains 'Некорректный порт' "$output" 'ветка port' ;; bad_mode) assert_contains 'Некорректный режим' "$output" 'ветка mode' ;; bad_range) assert_contains 'Некорректный диапазон' "$output" 'ветка range' ;; bad_path) assert_contains 'Некорректный путь' "$output" 'ветка path' ;; esac done assert_file_absent "$marker" 'конфигурация не выполняет текст' begin_test 'semantic validation rejects one invalid value at a time' local semantic_case semantic_output begin_test 'IPv6 validator rejects malformed compression and accepts valid forms' for invalid_value in ::: 2001:db8:::1; do set +e validate_ipv6 "$invalid_value" status=$? set -e assert_failure "$status" "отклонён IPv6 ${invalid_value}" done for valid_value in :: 2001:db8::1 2001:db8:0:1:2:3:4:5; do set +e validate_ipv6 "$valid_value" status=$? set -e assert_success "$status" "принят IPv6 ${valid_value}" done declare -a semantic_cases=( 'server_inside|SERVER_VPN_IP|192.0.2.100|Диапазон клиентов содержит' 'empty_dns|CLIENT_DNS||Некорректный список DNS' 'trailing_dns|CLIENT_DNS|192.0.2.53,|Некорректный список DNS' 'dotted_endpoint|SERVER_ENDPOINT|999.999.999.999|Некорректная конечная точка' 'bracketed_hostname|SERVER_ENDPOINT|[vpn.example.invalid]|Некорректная конечная точка' 'invalid_interface|WG_INTERFACE|bad/name|Некорректное имя интерфейса' 'invalid_client_path|CLIENT_DIR|relative|Некорректный путь' 'invalid_dns|CLIENT_DNS|192.0.2.53,,2001:db8::53|Некорректный список DNS' 'invalid_prefix|VPN_SUBNET_PREFIX|192.0|Некорректный префикс VPN' 'mismatched_prefix|VPN_SUBNET_PREFIX|192.0.3|Префикс VPN не совпадает' 'nonnumeric_port|SERVER_PORT|abc|Некорректный порт' 'nonnumeric_range|START_HOST|abc|Некорректный диапазон' 'out_of_range|START_HOST|0|Некорректный диапазон' 'invalid_ipv6|IPV6_PREFIX|gg::1|Некорректный IPv6' 'invalid_ipv6_prefix_shape|IPV6_PREFIX|2001:db8:ffff:1|Некорректный IPv6-префикс' 'triple_ipv6_prefix|IPV6_PREFIX|:::|Некорректный IPv6' 'triple_ipv6_dns|CLIENT_DNS|192.0.2.53,:::|Некорректный адрес DNS' 'triple_ipv6_endpoint|SERVER_ENDPOINT|:::|Некорректная конечная точка' 'invalid_ipv6_mode|USE_IPV6|2|USE_IPV6 должен' 'invalid_retention|BACKUP_RETENTION|-1|Некорректное число резервных копий' 'invalid_handshake|RECENT_HANDSHAKE_SECONDS|604801|Некорректный срок handshake' ) for semantic_case in "${semantic_cases[@]}"; do IFS='|' read -r invalid_file invalid_key invalid_value expected_text <<<"$semantic_case" write_valid_config "${TEST_ROOT}/${invalid_file}.conf" "$invalid_key" "$invalid_value" WG_MANAGER_CONFIG="${TEST_ROOT}/${invalid_file}.conf" set +e semantic_output=$(load_config 2>&1) status=$? set -e assert_equal "$EXIT_VALIDATION" "$status" "отклонение ${invalid_file}" assert_contains "$expected_text" "$semantic_output" "диагностика ${invalid_file}" done begin_test 'valid endpoint forms and split tunnel are accepted' local valid_case valid_output declare -a valid_cases=( 'documentation_ipv4|SERVER_ENDPOINT|192.0.2.10' 'plain_ipv6|SERVER_ENDPOINT|2001:db8::10' 'bracketed_ipv6|SERVER_ENDPOINT|[2001:db8::10]' 'split_tunnel|DEFAULT_CLIENT_MODE|split-tunnel' ) for valid_case in "${valid_cases[@]}"; do IFS='|' read -r invalid_file invalid_key invalid_value <<<"$valid_case" write_valid_config "${TEST_ROOT}/${invalid_file}.conf" "$invalid_key" "$invalid_value" WG_MANAGER_CONFIG="${TEST_ROOT}/${invalid_file}.conf" set +e valid_output=$(load_config 2>&1) status=$? set -e assert_success "$status" "принимается ${invalid_file}" assert_equal '' "$valid_output" "нет ошибки ${invalid_file}" done begin_test 'runtime path overrides reject unsafe values' WG_MANAGER_CONFIG='relative.conf' set +e set_runtime_paths >/dev/null 2>&1 status=$? set -e assert_equal "$EXIT_VALIDATION" "$status" 'относительный override отклонён' WG_MANAGER_CONFIG='' set +e set_runtime_paths >/dev/null 2>&1 status=$? set -e assert_equal "$EXIT_VALIDATION" "$status" 'пустой override отклонён' WG_MANAGER_BACKUP_DIR="${TEST_ROOT}/../escape" set +e set_runtime_paths >/dev/null 2>&1 status=$? set -e assert_equal "$EXIT_VALIDATION" "$status" 'traversal override отклонён' WG_MANAGER_BACKUP_DIR="${TEST_ROOT}/[unsafe]" set +e set_runtime_paths >/dev/null 2>&1 status=$? set -e assert_equal "$EXIT_VALIDATION" "$status" 'скобки в override отклонены' write_valid_config "${TEST_ROOT}/bracket-path.conf" WG_CONF "${TEST_ROOT}/[unsafe]" export WG_MANAGER_BACKUP_DIR="${TEST_ROOT}/backups" WG_MANAGER_CONFIG="${TEST_ROOT}/bracket-path.conf" set +e output=$(load_config 2>&1) status=$? set -e assert_equal "$EXIT_VALIDATION" "$status" 'скобки в пути конфигурации отклонены' assert_contains 'Некорректный путь' "$output" 'диагностика скобок в пути' export WG_MANAGER_CONFIG="$valid_config" } run_gum_tests() { local copied_script="${TEST_ROOT}/manager-copy.sh" output status cp -- "${PROJECT_DIR}/wg-manager.sh" "$copied_script" chmod 700 "$copied_script" mkdir -p "${TEST_ROOT}/bin" "${TEST_ROOT}/empty-path" printf '#!/usr/bin/env bash\nexit 0\n' >"${TEST_ROOT}/override-gum" chmod 700 "${TEST_ROOT}/override-gum" printf '#!/usr/bin/env bash\nexit 0\n' >"${TEST_ROOT}/bin/gum" chmod 700 "${TEST_ROOT}/bin/gum" begin_test 'gum environment override has priority' output=$(WG_MANAGER_GUM="${TEST_ROOT}/override-gum" PATH="${TEST_ROOT}/empty-path" /bin/bash "${TESTS_DIR}/resolve-gum.sh" "$copied_script") assert_equal "${TEST_ROOT}/override-gum" "$output" 'WG_MANAGER_GUM' begin_test 'local gum binary precedes PATH' output=$(env -u WG_MANAGER_GUM PATH="${TEST_ROOT}/empty-path" /bin/bash "${TESTS_DIR}/resolve-gum.sh" "$copied_script") assert_equal "${TEST_ROOT}/bin/gum" "$output" 'локальный gum' begin_test 'PATH gum is used when local binary is absent' rm -- "${TEST_ROOT}/bin/gum" printf '#!/usr/bin/env bash\nexit 0\n' >"${TEST_ROOT}/empty-path/gum" chmod 700 "${TEST_ROOT}/empty-path/gum" output=$(env -u WG_MANAGER_GUM PATH="${TEST_ROOT}/empty-path" /bin/bash "${TESTS_DIR}/resolve-gum.sh" "$copied_script") assert_equal "${TEST_ROOT}/empty-path/gum" "$output" 'gum из PATH' rm -- "${TEST_ROOT}/empty-path/gum" begin_test 'gum completely absent reports dependency failure' set +e output=$(env -u WG_MANAGER_GUM PATH="${TEST_ROOT}/empty-path" /bin/bash "${TESTS_DIR}/resolve-gum.sh" "$copied_script" 2>&1) status=$? set -e assert_equal "$EXIT_DEPENDENCY" "$status" 'gum отсутствует' assert_contains 'Не найден gum' "$output" 'диагностика отсутствующего gum' mkdir -- "${TEST_ROOT}/directory-gum" begin_test 'gum directory override is rejected' set +e output=$(WG_MANAGER_GUM="${TEST_ROOT}/directory-gum" PATH="${TEST_ROOT}/empty-path" /bin/bash "${TESTS_DIR}/resolve-gum.sh" "$copied_script" 2>&1) status=$? set -e assert_equal "$EXIT_DEPENDENCY" "$status" 'каталог WG_MANAGER_GUM' mkdir -- "${TEST_ROOT}/bin/gum" begin_test 'local gum directory is rejected' set +e output=$(env -u WG_MANAGER_GUM PATH="${TEST_ROOT}/empty-path" /bin/bash "${TESTS_DIR}/resolve-gum.sh" "$copied_script" 2>&1) status=$? set -e assert_equal "$EXIT_DEPENDENCY" "$status" 'каталог local gum' begin_test 'unusable gum override fails without fallback' set +e output=$(WG_MANAGER_GUM="${TEST_ROOT}/missing-gum" PATH="${TEST_ROOT}/empty-path" /bin/bash "${TESTS_DIR}/resolve-gum.sh" "$copied_script" 2>&1) status=$? set -e assert_equal "$EXIT_DEPENDENCY" "$status" 'ошибка WG_MANAGER_GUM' assert_contains 'WG_MANAGER_GUM' "$output" 'сообщение WG_MANAGER_GUM' rmdir -- "${TEST_ROOT}/bin/gum" rmdir -- "${TEST_ROOT}/directory-gum" } prepare_m9_fixture() { local config_file="${TEST_ROOT}/m9.conf" fixture_error # Each focused/full-suite invocation shares TEST_ROOT; own every fixture # directory before seeding it so backup counts and captures are deterministic. rm -rf -- "${TEST_ROOT}/clients" "${TEST_ROOT}/metadata" "${TEST_ROOT}/backups" "${TEST_ROOT}/logs" "${TEST_ROOT}/archive" rm -f -- "${TEST_ROOT}/manager.lock" "${TEST_ROOT}/lock" "${TEST_ROOT}/gum-script" "${TEST_ROOT}/gum-style.txt" "${TEST_ROOT}/gum-pager.txt" "${TEST_ROOT}/gum-choices.txt" mkdir -p -- "${TEST_ROOT}/clients" "${TEST_ROOT}/metadata" "${TEST_ROOT}/backups" "${TEST_ROOT}/logs" cp -- "${PROJECT_DIR}/tests/fixtures/wg0.conf" "${TEST_ROOT}/wg0.conf" write_valid_config "$config_file" export WG_MANAGER_CONFIG="$config_file" WG_MANAGER_BACKUP_DIR="${TEST_ROOT}/backups" WG_MANAGER_METADATA_DIR="${TEST_ROOT}/metadata" WG_MANAGER_LOG_DIR="${TEST_ROOT}/logs" WG_MANAGER_ARCHIVE_DIR="${TEST_ROOT}/archive" WG_MANAGER_LOCK_FILE="${TEST_ROOT}/manager.lock" WG_MANAGER_GUM="${PROJECT_DIR}/tests/mocks/bin/gum" WG_MANAGER_TEST_MODE=1 WG_MANAGER_TEST_TTY=both GUM_STYLE_CAPTURE="${TEST_ROOT}/gum-style.txt" GUM_PAGER_CAPTURE="${TEST_ROOT}/gum-pager.txt" GUM_CHOICES_CAPTURE="${TEST_ROOT}/gum-choices.txt" mkdir -p -- "${TEST_ROOT}/archive" printf '1\n' >"${TEST_ROOT}/ipv4-forward" printf '1\n' >"${TEST_ROOT}/ipv6-forward" export WG_MANAGER_IPV4_FORWARD_FILE="${TEST_ROOT}/ipv4-forward" WG_MANAGER_IPV6_FORWARD_FILE="${TEST_ROOT}/ipv6-forward" printf '[Interface]\nPrivateKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\nAddress = 10.0.0.2/32\n' >"${TEST_ROOT}/clients/alice.conf" chmod 600 -- "${TEST_ROOT}/clients/alice.conf" printf 'NAME=alice\nPUBLIC_KEY=BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=\nIPV4=192.0.2.2\nCREATED_AT=1700000000\nCONFIG_PATH=%s\nCLIENT_MODE=full-tunnel\nMANAGED=1\n' "${TEST_ROOT}/clients/alice.conf" >"${TEST_ROOT}/metadata/alice.meta" chmod 600 -- "${TEST_ROOT}/metadata/alice.meta" load_config >/dev/null printf 'timestamp=2023-11-14T22:13:20Z actor_uid=%s operation=tui target=fixture client=fixture key_suffix= outcome=success error=read_only\n' "$EUID" >"${TEST_ROOT}/logs/wg-manager.log" chmod 600 -- "${TEST_ROOT}/logs/wg-manager.log" if ! fixture_error=$(create_backup "$WG_CONF" tui fixture 2>&1); then printf 'Ошибка тестовой фикстуры: не удалось создать резервную копию: %s\n' "$fixture_error" >&2 return 1 fi : >"$TEST_MOCK_LOG" } prepare_m10_add_fixture() { local config_file="${TEST_ROOT}/m10-add.conf" fixture="${TEST_ROOT}/wg0.conf" runtime_file="${TEST_ROOT}/runtime.dump" rm -rf -- "${TEST_ROOT}/backups" "${TEST_ROOT}/logs" "${TEST_ROOT}/metadata" "${TEST_ROOT}/archive" "${TEST_ROOT}/clients" "${TEST_ROOT}/client-files" rm -f -- "${TEST_ROOT}/lock" "${TEST_ROOT}/manager.lock" "${TEST_ROOT}/gum-script" "${TEST_ROOT}/gum-style.txt" "${TEST_ROOT}/gum-pager.txt" "${TEST_ROOT}/gum-choices.txt" cp -- "${PROJECT_DIR}/tests/fixtures/m3/source.conf" "$fixture" write_m2_config "$config_file" "$fixture" export WG_MANAGER_CONFIG="$config_file" WG_MANAGER_BACKUP_DIR="${TEST_ROOT}/backups" WG_MANAGER_LOG_DIR="${TEST_ROOT}/logs" WG_MANAGER_METADATA_DIR="${TEST_ROOT}/metadata" WG_MANAGER_ARCHIVE_DIR="${TEST_ROOT}/archive" WG_MANAGER_LOCK_FILE="${TEST_ROOT}/lock" WG_MANAGER_GUM="${PROJECT_DIR}/tests/mocks/bin/gum" WG_MANAGER_NOW=1700000100 WG_MANAGER_TEST_MODE=1 WG_MANAGER_TEST_TTY=both WG_MOCK_INTERFACE=wg0 GUM_STYLE_CAPTURE="${TEST_ROOT}/gum-style.txt" GUM_PAGER_CAPTURE="${TEST_ROOT}/gum-pager.txt" GUM_CHOICES_CAPTURE="${TEST_ROOT}/gum-choices.txt" printf 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\tAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\t51820\t0\nBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=\t(none)\tvpn.example.invalid:51820\t192.0.2.2/32\t0\t0\t0\t0\n' >"$runtime_file" rm -rf -- "${TEST_ROOT}/dump-sequence" mkdir -p -- "${TEST_ROOT}/dump-sequence" cp -- "$runtime_file" "${TEST_ROOT}/dump-sequence/2" cp -- "$runtime_file" "${TEST_ROOT}/dump-sequence/3" cp -- "$runtime_file" "${TEST_ROOT}/dump-sequence/4" cp -- "$runtime_file" "${TEST_ROOT}/dump-sequence/5" cp -- "$runtime_file" "${TEST_ROOT}/dump-sequence/6" printf 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\tAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\t51820\t0\nBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=\t(none)\tvpn.example.invalid:51820\t192.0.2.2/32\t0\t0\t0\t0\nFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF=\t(none)\tvpn.example.invalid:51820\t192.0.2.3/32\t0\t0\t0\t0\n' >"${TEST_ROOT}/dump-sequence/1" export WG_MOCK_DUMP_FILE="$runtime_file" WG_MOCK_DUMP_SEQUENCE_DIR="${TEST_ROOT}/dump-sequence" unset WG_SYNC_FAIL WG_SYNC_FAIL_ONCE_FILE WG_SYNC_FAIL_ON_CALL WG_MOCK_DUMP_FAIL_ON_CALL WG_MOCK_PRIVATE_KEY WG_MOCK_PUBLIC_KEY WG_MOCK_PSK WG_MOCK_EXPECTED_PRIVATE_KEY TRANSACTION_ROLLBACK_REPLACE_FAIL set_runtime_paths load_config rm -rf -- "$METADATA_DIR" "$CLIENT_DIR" mkdir -p -- "$METADATA_DIR" "$CLIENT_DIR" "${TEST_ROOT}/backups" "${TEST_ROOT}/logs" "${TEST_ROOT}/archive" : >"$TEST_MOCK_LOG" } prepare_m10_remove_fixture() { local config_file="${TEST_ROOT}/m10-remove.conf" fixture="${TEST_ROOT}/wg0.conf" runtime_dir="${TEST_ROOT}/remove-dumps" rm -rf -- "${TEST_ROOT}/backups" "${TEST_ROOT}/logs" "${TEST_ROOT}/metadata" "${TEST_ROOT}/archive" "${TEST_ROOT}/clients" "${TEST_ROOT}/client-files" "${runtime_dir}" rm -f -- "${TEST_ROOT}/lock" "${TEST_ROOT}/manager.lock" "${TEST_ROOT}/gum-script" "${TEST_ROOT}/gum-style.txt" "${TEST_ROOT}/gum-pager.txt" "${TEST_ROOT}/gum-choices.txt" cp -- "${PROJECT_DIR}/tests/fixtures/m3/source.conf" "$fixture" chmod 600 -- "$fixture" sed -i 's/wg-manager-client: fixture/wg-manager-client: alice/' "$fixture" sed -i '/\[Peer\]/{N; s/\[Peer\]\n# wg-manager-client: alice/# wg-manager-client: alice\n[Peer]/;}' "$fixture" write_m2_config "$config_file" "$fixture" replace_config_line "$config_file" 'CLIENT_DIR=' "CLIENT_DIR=\"${TEST_ROOT}/client-files\"" export WG_MANAGER_CONFIG="$config_file" WG_MANAGER_BACKUP_DIR="${TEST_ROOT}/backups" WG_MANAGER_LOG_DIR="${TEST_ROOT}/logs" WG_MANAGER_METADATA_DIR="${TEST_ROOT}/metadata" WG_MANAGER_ARCHIVE_DIR="${TEST_ROOT}/archive" WG_MANAGER_LOCK_FILE="${TEST_ROOT}/lock" WG_MANAGER_GUM="${PROJECT_DIR}/tests/mocks/bin/gum" WG_MANAGER_NOW=1700000100 WG_MANAGER_TEST_MODE=1 WG_MANAGER_TEST_TTY=both WG_MOCK_INTERFACE=wg0 GUM_STYLE_CAPTURE="${TEST_ROOT}/gum-style.txt" GUM_PAGER_CAPTURE="${TEST_ROOT}/gum-pager.txt" GUM_CHOICES_CAPTURE="${TEST_ROOT}/gum-choices.txt" set_runtime_paths load_config rm -rf -- "$METADATA_DIR" "$CLIENT_DIR" "$ARCHIVE_DIR" "$LOG_DIR" "$runtime_dir" "$BACKUP_DIR" mkdir -p -- "$METADATA_DIR" "$CLIENT_DIR" "$ARCHIVE_DIR" "$LOG_DIR" "$runtime_dir" chmod 700 -- "$METADATA_DIR" "$CLIENT_DIR" "$ARCHIVE_DIR" "$LOG_DIR" "$runtime_dir" write_alice_metadata printf '[Interface]\nPrivateKey = EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE=\nAddress = 192.0.2.2/32\n' >"$CLIENT_DIR/alice.conf" printf 'EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE=\n' >"$CLIENT_DIR/alice.key" printf 'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF=\n' >"$CLIENT_DIR/alice.pub" printf 'GGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGG=\n' >"$CLIENT_DIR/alice.psk" chmod 600 -- "$METADATA_DIR/alice.meta" "$CLIENT_DIR"/alice.* printf 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\tAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\t51820\t0\nBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=\t(none)\tvpn.example.invalid:51820\t192.0.2.2/32\t0\t0\t0\t0\n' >"$runtime_dir/1" cp -- "$runtime_dir/1" "$runtime_dir/2" cp -- "$runtime_dir/1" "$runtime_dir/3" printf 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\tAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\t51820\t0\n' >"$runtime_dir/4" cp -- "$runtime_dir/1" "$runtime_dir/5" export WG_MOCK_DUMP_SEQUENCE_DIR="$runtime_dir" WG_MOCK_DUMP_FILE="$runtime_dir/1" TEST_MOCK_LOG="${TEST_ROOT}/mocks.log" unset WG_SYNC_FAIL WG_SYNC_FAIL_ONCE_FILE WG_SYNC_FAIL_ON_CALL M6_ARCHIVE_COPY_FAIL_INDEX M6_ARCHIVE_REMOVE_FAIL_INDEX M6_ARCHIVE_RECOVERY_FAIL_INDEX : >"$TEST_MOCK_LOG" } prepare_m10_restore_fixture() { local config_file="${TEST_ROOT}/m10-restore.conf" fixture="${TEST_ROOT}/wg0.conf" runtime_file="${TEST_ROOT}/restore-runtime.dump" rm -rf -- "${TEST_ROOT}/backups" "${TEST_ROOT}/logs" "${TEST_ROOT}/metadata" "${TEST_ROOT}/archive" "${TEST_ROOT}/clients" "${TEST_ROOT}/client-files" rm -f -- "${TEST_ROOT}/lock" "${TEST_ROOT}/manager.lock" "${TEST_ROOT}/gum-script" "${TEST_ROOT}/gum-style.txt" "${TEST_ROOT}/gum-pager.txt" "${TEST_ROOT}/gum-choices.txt" cp -- "${PROJECT_DIR}/tests/fixtures/m3/source.conf" "$fixture" chmod 600 -- "$fixture" write_m2_config "$config_file" "$fixture" export WG_MANAGER_CONFIG="$config_file" WG_MANAGER_BACKUP_DIR="${TEST_ROOT}/backups" WG_MANAGER_LOG_DIR="${TEST_ROOT}/logs" WG_MANAGER_METADATA_DIR="${TEST_ROOT}/metadata" WG_MANAGER_ARCHIVE_DIR="${TEST_ROOT}/archive" WG_MANAGER_LOCK_FILE="${TEST_ROOT}/lock" WG_MANAGER_GUM="${PROJECT_DIR}/tests/mocks/bin/gum" WG_MANAGER_NOW=1700000100 WG_MANAGER_TEST_MODE=1 WG_MANAGER_TEST_TTY=both WG_MOCK_INTERFACE=wg0 GUM_STYLE_CAPTURE="${TEST_ROOT}/gum-style.txt" GUM_PAGER_CAPTURE="${TEST_ROOT}/gum-pager.txt" GUM_CHOICES_CAPTURE="${TEST_ROOT}/gum-choices.txt" set_runtime_paths load_config rm -rf -- "$BACKUP_DIR" "$LOG_DIR" "$METADATA_DIR" "$CLIENT_DIR" "$ARCHIVE_DIR" mkdir -p -- "$BACKUP_DIR" "$LOG_DIR" "$METADATA_DIR" "$CLIENT_DIR" "$ARCHIVE_DIR" chmod 700 -- "$BACKUP_DIR" "$LOG_DIR" "$METADATA_DIR" "$CLIENT_DIR" "$ARCHIVE_DIR" printf 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\tAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\t51820\t0\nBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=\t(none)\tvpn.example.invalid:51820\t192.0.2.2/32\t0\t0\t0\t0\n' >"$runtime_file" export WG_MOCK_DUMP_FILE="$runtime_file" unset WG_MOCK_DUMP_SEQUENCE_DIR WG_SYNC_FAIL WG_SYNC_FAIL_ONCE_FILE WG_SYNC_FAIL_ON_CALL TRANSACTION_ROLLBACK_REPLACE_FAIL : >"$TEST_MOCK_LOG" } run_m9_tests() { local output status script_file tty_mode pager_count snapshot_before lock_status long_name before_backup secret_stream add_output captured_prompt formatted_prompt long_endpoint case $test_group in gum-resolution) run_gum_tests prepare_m9_fixture begin_test 'gum capability probes accept the scripted mock' output=$(resolve_gum) assert_contains 'tests/mocks/bin/gum' "$output" 'gum mock resolved' validate_gum_capabilities "$output" >/dev/null assert_success "$?" 'gum capabilities' export GUM_FAIL_CAPABILITY=filter set +e; output=$(validate_gum_capabilities "$output" 2>&1); status=$?; set -e assert_equal "$EXIT_DEPENDENCY" "$status" 'missing gum capability status' assert_contains 'filter' "$output" 'missing capability diagnostic names subcommand' unset GUM_FAIL_CAPABILITY printf 'Выбор\nКлиент\ny\ny\n' >"${TEST_ROOT}/gum-wrapper-script" export GUM_SCRIPT_FILE="${TEST_ROOT}/gum-wrapper-script" : >"$TEST_MOCK_LOG" gum_header 'Заголовок' >/dev/null gum_choose 'Клиент' 'Выход' >/dev/null gum_filter 'Клиент' >/dev/null gum_input --placeholder 'Имя' >/dev/null gum_confirm 'Продолжить' >/dev/null printf 'таблица\n' | gum_table >/dev/null printf 'страница\n' | gum_pager >/dev/null gum_spin --title 'Ожидание' >/dev/null begin_test 'all gum wrappers keep their raw invocation details isolated' for capability in style choose filter input confirm table pager spin; do assert_contains "gum $capability" "$(<"$TEST_MOCK_LOG")" "обёртка gum $capability" done ;; tui-routing) prepare_m9_fixture script_file="${TEST_ROOT}/gum-script"; printf 'Состояние\nВыход\n' >"$script_file"; export GUM_SCRIPT_FILE="$script_file" begin_test 'TUI routes status through the dashboard loop' set +e; output=$(tui_screen 2>&1); status=$?; set -e assert_equal 0 "$status" 'TUI status route exits cleanly' assert_contains 'gum choose' "$(<"$TEST_MOCK_LOG")" 'menu wrapper invocation recorded' assert_contains 'gum pager' "$(<"$TEST_MOCK_LOG")" 'pager wrapper invocation recorded' assert_contains 'WireGuard Manager — wg0' "$(<"$GUM_STYLE_CAPTURE")" 'dashboard interface' assert_contains 'Состояние:' "$(<"$GUM_STYLE_CAPTURE")" 'dashboard runtime state' assert_contains 'Управляемых:' "$(<"$GUM_STYLE_CAPTURE")" 'dashboard managed count' assert_contains 'Активны недавно:' "$(<"$GUM_STYLE_CAPTURE")" 'dashboard active count' assert_contains 'Предупреждения:' "$(<"$GUM_STYLE_CAPTURE")" 'dashboard warning count' assert_equal 0 "$(grep -c '\\\\n' "$GUM_STYLE_CAPTURE" || true)" 'dashboard has real line breaks' assert_equal 0 "$(awk 'length($0) > 80 { count += 1 } END { print count + 0 }' "$GUM_STYLE_CAPTURE")" 'dashboard lines fit 80 columns' printf 'Неизвестно\nНеизвестно\nНеизвестно\n' >"$script_file" set +e; tui_screen >/dev/null 2>&1; status=$?; set -e assert_equal "$EXIT_OPERATION" "$status" 'three route failures are bounded' printf 'Диагностика\nЖурнал\nРезервные копии\nОтмена\nДобавить\n\nУдалить\n\nВыход\n' >"$script_file" : >"$TEST_MOCK_LOG" set +e; output=$(tui_screen 2>&1); status=$?; set -e assert_equal 0 "$status" 'all read-only and placeholder routes return to menu' assert_contains 'gum input' "$(<"$TEST_MOCK_LOG")" 'add route reaches input wrapper' assert_contains 'gum filter' "$(<"$TEST_MOCK_LOG")" 'remove route reaches filter wrapper' assert_equal 0 "$(grep -c '^gum pager$' "$TEST_MOCK_LOG" | awk '$1 >= 2 { print 0; next } { print 1 }')" 'diagnose and log reach pager' printf 'Выход\n' >"$script_file" set +e; main >/dev/null 2>&1; status=$?; set -e assert_equal 0 "$status" 'zero-argument main enters TUI' printf 'Выход\n' >"$script_file" set +e; main tui >/dev/null 2>&1; status=$?; set -e assert_equal 0 "$status" 'explicit tui command exits cleanly' assert_equal '' "${TUI_GUM_PATH:-}" 'explicit tui clears resolved gum path' ;; tui-cancel) prepare_m9_fixture script_file="${TEST_ROOT}/gum-script"; : >"$script_file"; export GUM_SCRIPT_FILE="$script_file" begin_test 'TUI cancellation returns normally without mutation' set +e; tui_screen >/dev/null 2>&1; status=$?; set -e assert_equal 0 "$status" 'menu cancellation' assert_equal '' "$(grep -E 'wg (syncconf|quick)|wg-quick' "$TEST_MOCK_LOG" || true)" 'cancellation has no mutation command' printf 'Клиенты\n\nВыход\n' >"$script_file" : >"$TEST_MOCK_LOG" set +e; tui_screen >/dev/null 2>&1; status=$?; set -e assert_equal 0 "$status" 'client filter cancellation returns to menu' assert_equal 2 "$(grep '^gum choose' "$TEST_MOCK_LOG" | grep -vc -- '--help' || true)" 'filter cancellation returns to main menu' assert_equal 1 "$(grep '^gum filter' "$TEST_MOCK_LOG" | grep -vc -- '--help' || true)" 'filter cancellation invokes one filter' assert_equal 1 "$(grep '^gum pager' "$TEST_MOCK_LOG" | grep -vc -- '--help' || true)" 'filter cancellation only pages the client list' assert_equal 0 "$(grep -c 'TUI завершён\|Неизвестный пункт' "$TEST_MOCK_LOG" || true)" 'filter cancellation is not a route failure' assert_equal '' "${TUI_GUM_PATH:-}" 'TUI gum path is cleared after cancellation' unset WG_MANAGER_TEST_TTY set +e; tui_is_terminal >/dev/null 2>&1; status=$?; set -e assert_failure "$status" 'unset test TTY is not accepted' ;; tui-read-only) prepare_m9_fixture script_file="${TEST_ROOT}/gum-script"; printf 'Клиенты\nalice — BBBBBBBB\nДетали\nВыход\n' >"$script_file"; export GUM_SCRIPT_FILE="$script_file" snapshot_before=$(find "${TEST_ROOT}/backups" "${TEST_ROOT}/logs" "${TEST_ROOT}/archive" "${TEST_ROOT}/clients" "${TEST_ROOT}/metadata" -mindepth 1 -maxdepth 2 -printf '%P %y %m %s\n' | LC_ALL=C sort; sha256sum "$WG_CONF" "$WG_MANAGER_CONFIG" "${TEST_ROOT}/clients/alice.conf" "${TEST_ROOT}/metadata/alice.meta"; stat -c '%a:%u:%g:%h' "$WG_CONF" "$WG_MANAGER_CONFIG" "${TEST_ROOT}/clients/alice.conf" "${TEST_ROOT}/metadata/alice.meta") begin_test 'TUI client selection uses stable identity and read-only backend' set +e; tui_screen >/dev/null 2>&1; status=$?; set -e assert_equal 0 "$status" 'client route exits cleanly' assert_contains 'gum filter' "$(<"$TEST_MOCK_LOG")" 'filter wrapper invocation recorded' pager_count=$(grep -c '^gum pager$' "$TEST_MOCK_LOG" || true) ((pager_count >= 1)) && ((TEST_ASSERTIONS += 1)) || record_failure 'client list is paged before selection' assert_equal '' "$(grep -E 'syncconf|wg-quick (up|down|save)|ip (add|del|replace)' "$TEST_MOCK_LOG" || true)" 'read-only screen has no mutation calls' CLIENT_NAMES=(same same) CLIENT_KEYS=(ABCDEFGH111111111111111111111111111111111= ABCDEFGH222222222222222222222222222222222=) CLIENT_ADDRESSES=(192.0.2.2/32 192.0.2.2/32) CLIENT_ENDPOINTS=(- -) CLIENT_HANDSHAKES=(0 0) CLIENT_RX=(0 0) CLIENT_TX=(0 0) CLIENT_STATES=(unmanaged unmanaged) CLIENT_ORIGINS=(unmanaged unmanaged) printf 'same — ABCDEFGH #2\nДетали\n' >"$script_file" set +e; tui_select_client >/dev/null 2>&1; status=$?; set -e assert_equal 0 "$status" 'duplicate display names select the offered stable index' assert_contains 'ABCDEFGH222222222222222222222222222222222=' "$(<"$GUM_PAGER_CAPTURE")" 'selected duplicate identity renders the second full key' assert_equal 0 "$(grep -c 'ABCDEFGH111111111111111111111111111111111=' "$GUM_PAGER_CAPTURE" || true)" 'selected detail does not render the first key' long_name=$(printf 'n%.0s' {1..64}) CLIENT_NAMES=("$long_name" "$long_name") CLIENT_KEYS=(ABCDEFGH111111111111111111111111111111111= ABCDEFGH222222222222222222222222222222222=) CLIENT_ADDRESSES=(192.0.2.2/32 192.0.2.2/32) printf '%s\nДетали\n' "${long_name:0:60} — ABCDEFGH #2" >"$script_file" set +e; tui_select_client >/dev/null 2>&1; status=$?; set -e assert_equal 0 "$status" 'long client names remain selectable' assert_equal 0 "$(awk 'length($0) > 80 { bad += 1 } END { print bad + 0 }' "$GUM_CHOICES_CAPTURE")" 'offered client labels fit 80 columns' assert_contains 'ABCDEFGH222222222222222222222222222222222=' "$(<"$GUM_PAGER_CAPTURE")" 'long-name collision keeps the selected identity' assert_equal "$snapshot_before" "$(find "${TEST_ROOT}/backups" "${TEST_ROOT}/logs" "${TEST_ROOT}/archive" "${TEST_ROOT}/clients" "${TEST_ROOT}/metadata" -mindepth 1 -maxdepth 2 -printf '%P %y %m %s\n' | LC_ALL=C sort; sha256sum "$WG_CONF" "$WG_MANAGER_CONFIG" "${TEST_ROOT}/clients/alice.conf" "${TEST_ROOT}/metadata/alice.meta"; stat -c '%a:%u:%g:%h' "$WG_CONF" "$WG_MANAGER_CONFIG" "${TEST_ROOT}/clients/alice.conf" "${TEST_ROOT}/metadata/alice.meta")" 'read-only files, modes, and directory entries unchanged' assert_equal '' "$(grep -E 'syncconf|wg-quick (up|down|save)|ip (add|del|replace)|systemctl (start|stop|restart)|nft (add|delete)|iptables -.*-(A|D|I)' "$TEST_MOCK_LOG" || true)" 'read-only command log has no mutation' assert_equal 0 "$(find "${WG_CONF%/*}" -maxdepth 1 -name '.wg-manager-tui.*' -print | wc -l)" 'read-only pages create no config-directory temporary files' if [[ -e $WG_MANAGER_LOCK_FILE ]]; then lock_status=1; else lock_status=0; fi assert_equal 0 "$lock_status" 'read-only screens do not create a mutation lock' assert_equal 0 "$(grep -hE 'AAAAAAAAAAAAAAAA|PresharedKey' "$GUM_PAGER_CAPTURE" "$GUM_STYLE_CAPTURE" "$TEST_MOCK_LOG" | wc -l)" 'read-only TUI output has no secrets' ;; tui-add) prepare_m10_add_fixture begin_test 'TUI add real full-tunnel success uses the shared backend' printf 'alice\nПолный туннель\ny\ny\nn\n' >"${TEST_ROOT}/gum-script" export GUM_SCRIPT_FILE="${TEST_ROOT}/gum-script" set +e; output=$(tui_add_flow 2>"${TEST_ROOT}/add.err"); status=$?; set -e assert_equal 0 "$status" 'real TUI add succeeds' assert_file_exists "${CLIENT_DIR}/alice.conf" 'real client config exists' assert_file_exists "${METADATA_DIR}/alice.meta" 'real client metadata exists' assert_file_mode 600 "${CLIENT_DIR}/alice.conf" 'real client config mode' assert_file_mode 600 "${METADATA_DIR}/alice.meta" 'real metadata mode' assert_contains 'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF=' "$(<"$WG_CONF")" 'generated peer is in server config' assert_contains '192.0.2.3/32' "$(<"$WG_CONF")" 'generated address is in server config' assert_contains 'AllowedIPs = 0.0.0.0/0,::/0' "$(<"${CLIENT_DIR}/alice.conf")" 'full-tunnel AllowedIPs are generated' assert_contains 'Endpoint = vpn.example.invalid:51820' "$(<"${CLIENT_DIR}/alice.conf")" 'configured endpoint is generated' assert_contains 'wg syncconf' "$(<"$TEST_MOCK_LOG")" 'runtime syncconf is invoked' assert_contains 'operation=add' "$(<"${LOG_DIR}/wg-manager.log")" 'add audit is recorded' assert_contains "${CLIENT_DIR}/alice.conf" "$output" 'final config path is shown' assert_equal '' "$(grep -E 'EEEE|GGGG|PrivateKey|PresharedKey' "$TEST_MOCK_LOG" "${LOG_DIR}/wg-manager.log" "${TEST_ROOT}/add.err" "$GUM_PAGER_CAPTURE" || true)" 'add outputs and logs are secret-free' begin_test 'TUI add preserves QR operation failure status' prepare_m10_add_fixture export QR_MOCK_EXPECTED_FILE="${CLIENT_DIR}/qr-fail.conf" QR_MOCK_FAIL=1 printf 'qr-fail\nПолный туннель\ny\ny\ny\n' >"${TEST_ROOT}/gum-script"; export GUM_SCRIPT_FILE="${TEST_ROOT}/gum-script" set +e; tui_add_flow >/dev/null 2>"${TEST_ROOT}/qr-fail-add.err"; status=$?; set -e unset QR_MOCK_EXPECTED_FILE QR_MOCK_FAIL assert_equal "$EXIT_OPERATION" "$status" 'TUI add returns QR operation failure' assert_contains 'QR-код недоступен' "$(<"${TEST_ROOT}/qr-fail-add.err")" 'TUI add preserves QR diagnostic' begin_test 'TUI add treats missing QR dependency as a normal fallback' prepare_m10_add_fixture export QR_MOCK_EXPECTED_FILE="${CLIENT_DIR}/qr-missing.conf" saved_path=$PATH mkdir -- "${TEST_ROOT}/qr-add-no-command" for utility in bash env wg wg-quick cat stat sort paste date tail mv cp chmod mkdir mktemp flock rm ln; do ln -s -- "$(command -v "$utility")" "${TEST_ROOT}/qr-add-no-command/$utility" done PATH="${TEST_ROOT}/qr-add-no-command" printf 'qr-missing\nПолный туннель\ny\ny\ny\n' >"${TEST_ROOT}/gum-script"; export GUM_SCRIPT_FILE="${TEST_ROOT}/gum-script" set +e; output=$(tui_add_flow 2>"${TEST_ROOT}/qr-missing-add.err"); status=$?; set -e PATH=$saved_path unset QR_MOCK_EXPECTED_FILE assert_equal 0 "$status" 'missing QR dependency is a normal TUI add fallback' assert_contains 'qr-missing.conf' "$output" 'missing QR fallback retains config path' assert_contains 'QR-код недоступен' "$(<"${TEST_ROOT}/qr-missing-add.err")" 'missing QR fallback is actionable' begin_test 'TUI add split tunnel accepts normalized networks and endpoint override' prepare_m10_add_fixture printf 'bob\nВыборочные сети\n10.40.0.0/16, 2001:db8:40::/48\nn\nalt.example.invalid\ny\nn\n' >"${TEST_ROOT}/gum-script" export GUM_SCRIPT_FILE="${TEST_ROOT}/gum-script" set +e; output=$(tui_add_flow 2>"${TEST_ROOT}/split.err"); status=$?; set -e assert_equal 0 "$status" 'split TUI add succeeds' assert_file_exists "${CLIENT_DIR}/bob.conf" 'split client config exists' assert_contains 'AllowedIPs = 10.40.0.0/16,2001:db8:40::/48' "$(<"${CLIENT_DIR}/bob.conf")" 'split networks are normalized' assert_contains 'Endpoint = alt.example.invalid:51820' "$(<"${CLIENT_DIR}/bob.conf")" 'endpoint override is applied' assert_contains 'MODE=split-tunnel' "$(<"${METADATA_DIR}/bob.meta")" 'split mode is recorded' assert_contains 'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF=' "$(<"$WG_CONF")" 'split peer reaches server config' assert_equal '' "$(grep -E 'EEEE|GGGG|PrivateKey|PresharedKey' "$TEST_MOCK_LOG" "${LOG_DIR}/wg-manager.log" "${TEST_ROOT}/split.err" "$GUM_PAGER_CAPTURE" || true)" 'split output and logs are secret-free' begin_test 'TUI add cancellation before create preserves isolated state' prepare_m10_add_fixture before_hash=$(sha256sum "$WG_CONF") printf 'alice\nПолный туннель\ny\nn\n' >"${TEST_ROOT}/gum-script" export GUM_SCRIPT_FILE="${TEST_ROOT}/gum-script" set +e; tui_add_flow >/dev/null 2>"${TEST_ROOT}/cancel.err"; status=$?; set -e assert_equal 0 "$status" 'create cancellation is normal' assert_equal "$before_hash" "$(sha256sum "$WG_CONF")" 'create cancellation preserves server bytes' assert_equal 0 "$(find "$CLIENT_DIR" "$METADATA_DIR" -mindepth 1 -maxdepth 1 -type f | wc -l)" 'create cancellation leaves no artifacts' assert_equal '' "$(grep -E 'syncconf|wg-quick' "$TEST_MOCK_LOG" || true)" 'create cancellation does not mutate runtime' begin_test 'TUI add cancellation before name is harmless' prepare_m10_add_fixture before_hash=$(sha256sum "$WG_CONF") : >"${TEST_ROOT}/gum-script"; export GUM_SCRIPT_FILE="${TEST_ROOT}/gum-script" set +e; tui_add_flow >/dev/null 2>"${TEST_ROOT}/empty-name.err"; status=$?; set -e assert_equal 0 "$status" 'initial name cancellation is normal' assert_equal "$before_hash" "$(sha256sum "$WG_CONF")" 'initial cancellation preserves server bytes' assert_equal 0 "$(find "$CLIENT_DIR" "$METADATA_DIR" -mindepth 1 -maxdepth 1 -type f | wc -l)" 'initial cancellation leaves no artifacts' begin_test 'TUI add corrects invalid name and endpoint' prepare_m10_add_fixture printf 'bad name\ncarol\nПолный туннель\nn\nbad endpoint\ncarol.example.invalid\ny\nn\n' >"${TEST_ROOT}/gum-script" export GUM_SCRIPT_FILE="${TEST_ROOT}/gum-script" set +e; output=$(tui_add_flow 2>"${TEST_ROOT}/correction.err"); status=$?; set -e assert_equal 0 "$status" 'invalid name and endpoint can be corrected' assert_file_exists "${CLIENT_DIR}/carol.conf" 'corrected client artifact exists' assert_contains 'carol.example.invalid:51820' "$(<"${CLIENT_DIR}/carol.conf")" 'corrected endpoint is used' assert_contains 'Некорректное имя клиента' "$(<"${TEST_ROOT}/correction.err")" 'invalid name gives actionable feedback' assert_contains 'Некорректный endpoint' "$(<"${TEST_ROOT}/correction.err")" 'invalid endpoint gives actionable feedback' begin_test 'TUI add rejects invalid split networks before mutation' prepare_m10_add_fixture before_hash=$(sha256sum "$WG_CONF") printf 'badnet\nВыборочные сети\nnot-a-cidr\n' >"${TEST_ROOT}/gum-script"; export GUM_SCRIPT_FILE="${TEST_ROOT}/gum-script" set +e; tui_add_flow >/dev/null 2>"${TEST_ROOT}/bad-net.err"; status=$?; set -e assert_equal "$EXIT_VALIDATION" "$status" 'invalid split network status' assert_equal "$before_hash" "$(sha256sum "$WG_CONF")" 'invalid split network preserves server bytes' assert_equal 0 "$(find "$CLIENT_DIR" "$METADATA_DIR" -mindepth 1 -maxdepth 1 -type f | wc -l)" 'invalid split network leaves no artifacts' assert_equal '' "$(grep -E 'syncconf|wg-quick' "$TEST_MOCK_LOG" || true)" 'invalid split network does not mutate runtime' begin_test 'TUI add apply failure rolls back and removes staged artifacts' prepare_m10_add_fixture before_hash=$(sha256sum "$WG_CONF") export WG_SYNC_FAIL_ONCE_FILE="${TEST_ROOT}/sync-fail" unset WG_MOCK_DUMP_SEQUENCE_DIR printf 'dana\nПолный туннель\ny\ny\nn\n' >"${TEST_ROOT}/gum-script"; export GUM_SCRIPT_FILE="${TEST_ROOT}/gum-script" set +e; tui_add_flow >/dev/null 2>"${TEST_ROOT}/apply-fail.err"; status=$?; set -e unset WG_SYNC_FAIL_ONCE_FILE assert_equal "$EXIT_OPERATION" "$status" 'apply failure returns operation status after rollback' assert_equal "$before_hash" "$(sha256sum "$WG_CONF")" 'apply failure restores server bytes' assert_equal 0 "$(find "$CLIENT_DIR" "$METADATA_DIR" -maxdepth 1 -type f -name 'dana.*' | wc -l)" 'apply failure leaves no client artifacts' assert_contains 'syncconf' "$(<"$TEST_MOCK_LOG")" 'apply failure attempted runtime update and rollback' assert_contains 'operation=add' "$(<"${LOG_DIR}/wg-manager.log")" 'apply failure is audited' begin_test 'CLI add split-networks contract is validated' prepare_m10_add_fixture set +e; add_client cli --mode split-tunnel --split-networks '10.40.0.0/16,2001:db8:40::/48' >/dev/null 2>"${TEST_ROOT}/cli-split.err"; status=$?; set -e assert_equal 0 "$status" 'CLI split add succeeds' assert_contains 'AllowedIPs = 10.40.0.0/16,2001:db8:40::/48' "$(<"${CLIENT_DIR}/cli.conf")" 'CLI split networks reach config' prepare_m10_add_fixture set +e; add_client missing --mode split-tunnel --split-networks >/dev/null 2>&1; status=$?; set -e assert_equal "$EXIT_USAGE" "$status" 'missing split-networks value is usage error' prepare_m10_add_fixture set +e; add_client repeat --mode split-tunnel --split-networks '10.40.0.0/16' --split-networks '10.41.0.0/16' >/dev/null 2>&1; status=$?; set -e assert_equal "$EXIT_USAGE" "$status" 'repeated split-networks is usage error' prepare_m10_add_fixture set +e; add_client invalid --mode split-tunnel --split-networks 'not-a-cidr' >/dev/null 2>&1; status=$?; set -e assert_equal "$EXIT_VALIDATION" "$status" 'invalid split CIDR is validation error' prepare_m10_add_fixture set +e; add_client full --mode full-tunnel --split-networks '10.40.0.0/16' >/dev/null 2>&1; status=$?; set -e assert_equal "$EXIT_VALIDATION" "$status" 'split-networks is rejected for full tunnel' ;; tui-remove) prepare_m10_remove_fixture load_client_model >/dev/null begin_test 'TUI remove real success archives exact client and peer' printf 'alice — 192.0.2.2/32 — BBBBBBBB\ny\n' >"${TEST_ROOT}/gum-script"; export GUM_SCRIPT_FILE="${TEST_ROOT}/gum-script" set +e; output=$(tui_remove_flow 2>"${TEST_ROOT}/remove.err"); status=$?; set -e output+=$'\n'"$(<"${TEST_ROOT}/remove.err")" assert_equal 0 "$status" 'real TUI remove succeeds' assert_equal 0 "$(grep -c 'PublicKey = BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=' "$WG_CONF" || true)" 'removed peer is absent' assert_file_absent "$CLIENT_DIR/alice.conf" 'client config is archived' assert_file_absent "$METADATA_DIR/alice.meta" 'client metadata is archived' archive_path=$(find "$ARCHIVE_DIR" -mindepth 1 -maxdepth 1 -type d | head -n 1) if [[ -n $archive_path ]]; then assert_file_exists "$archive_path/alice.conf" 'archive contains config' assert_file_exists "$archive_path/alice.meta" 'archive contains metadata' assert_file_mode 700 "$archive_path" 'archive mode is restrictive' assert_file_mode 600 "$archive_path/alice.conf" 'archived config mode' else assert_equal 1 0 'archive path is returned after successful removal' fi assert_contains 'Архив:' "$output" 'TUI reports actual archive path' assert_contains 'syncconf' "$(<"$TEST_MOCK_LOG")" 'remove applies runtime update' assert_contains 'operation=remove' "$(<"$LOG_DIR/wg-manager.log")" 'remove audit is recorded' assert_equal '' "$(grep -E 'EEEE|GGGG|PrivateKey|PresharedKey' "$TEST_MOCK_LOG" "$LOG_DIR/wg-manager.log" "$TEST_ROOT/remove.err" || true)" 'remove output and logs are secret-free' begin_test 'TUI remove cancellation at filter preserves state' prepare_m10_remove_fixture load_client_model >/dev/null before_hash=$(sha256sum "$WG_CONF"); before_files=$(find "$CLIENT_DIR" "$METADATA_DIR" "$ARCHIVE_DIR" -type f -printf '%P %s\n' | LC_ALL=C sort) : >"${TEST_ROOT}/gum-script"; export GUM_SCRIPT_FILE="${TEST_ROOT}/gum-script" set +e; tui_remove_flow >/dev/null 2>&1; status=$?; set -e assert_equal 0 "$status" 'filter cancellation is normal' assert_equal "$before_hash" "$(sha256sum "$WG_CONF")" 'filter cancellation preserves server' assert_equal "$before_files" "$(find "$CLIENT_DIR" "$METADATA_DIR" "$ARCHIVE_DIR" -type f -printf '%P %s\n' | LC_ALL=C sort)" 'filter cancellation preserves artifacts' assert_equal '' "$(grep -E 'syncconf|wg-quick' "$TEST_MOCK_LOG" || true)" 'filter cancellation does not mutate runtime' begin_test 'TUI remove cancellation at confirmation preserves state' prepare_m10_remove_fixture load_client_model >/dev/null before_hash=$(sha256sum "$WG_CONF") printf 'alice — 192.0.2.2/32 — BBBBBBBB\nn\n' >"${TEST_ROOT}/gum-script"; export GUM_SCRIPT_FILE="${TEST_ROOT}/gum-script" set +e; tui_remove_flow >/dev/null 2>&1; status=$?; set -e assert_equal 0 "$status" 'confirmation cancellation is normal' assert_equal "$before_hash" "$(sha256sum "$WG_CONF")" 'confirmation cancellation preserves server' assert_equal 0 "$(find "$ARCHIVE_DIR" -mindepth 1 -maxdepth 1 -type d | wc -l)" 'confirmation cancellation creates no archive' begin_test 'TUI remove excludes unmanaged clients and keeps labels bounded' prepare_m10_remove_fixture load_client_model >/dev/null printf '%s\t%s\t-\t192.0.2.9/32\t0\t0\t0\t0\n' EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE= '(none)' >>"${TEST_ROOT}/remove-dumps/1" printf '\n' >"${TEST_ROOT}/gum-script"; export GUM_SCRIPT_FILE="${TEST_ROOT}/gum-script" set +e; tui_remove_flow >/dev/null 2>&1; status=$?; set -e assert_equal 0 "$status" 'unmanaged-only selection cancellation is normal' assert_equal 0 "$(grep -c EEEEEEEE "$GUM_CHOICES_CAPTURE" || true)" 'unmanaged peer is not offered' long_name=$(printf 'n%.0s' {1..64}) CLIENT_NAMES=("$long_name") CLIENT_KEYS=(BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=) CLIENT_ADDRESSES=(192.0.2.2/32) CLIENT_ORIGINS=(managed) printf '\n' >"${TEST_ROOT}/gum-script" set +e; tui_remove_flow >/dev/null 2>&1; status=$?; set -e assert_equal 0 "$(awk 'length($0) > 80 { bad += 1 } END { print bad + 0 }' "$GUM_CHOICES_CAPTURE")" 'remove labels stay within 80 columns' begin_test 'destructive TUI previews retain identity while fitting 80 columns' captured_prompt='' gum_confirm() { captured_prompt=$1; return 1; } set +e; tui_remove_flow_for_index "$long_name" 0 >/dev/null 2>&1; status=$?; set -e unset -f gum_confirm gum_confirm() { gum_command confirm "$@"; } assert_equal 0 "$status" 'long destructive preview cancellation is normal' assert_contains "$long_name" "$captured_prompt" 'long name remains in destructive preview' assert_contains '192.0.2.2/32' "$captured_prompt" 'address remains in destructive preview' assert_equal 0 "$(awk 'length($0) > 80 { bad += 1 } END { print bad + 0 }' <<<"$captured_prompt")" 'destructive preview lines fit 80 columns' long_endpoint=$(printf 'e%.0s' {1..253}) formatted_prompt=$(tui_wrap_value 'Использовать endpoint:' "$long_endpoint") assert_equal 0 "$(awk 'length($0) > 80 { bad += 1 } END { print bad + 0 }' <<<"$formatted_prompt")" 'endpoint preview lines fit 80 columns' begin_test 'TUI remove exact-key mismatch is rejected before mutation' prepare_m10_remove_fixture load_client_model >/dev/null before_hash=$(sha256sum "$WG_CONF") set +e; remove_client_exact alice CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC= >/dev/null 2>"${TEST_ROOT}/wrong-key.err"; status=$?; set -e assert_equal "$EXIT_VALIDATION" "$status" 'wrong selected key is rejected' assert_equal "$before_hash" "$(sha256sum "$WG_CONF")" 'wrong selected key preserves server' begin_test 'TUI remove apply failure rolls back disk and artifacts' prepare_m10_remove_fixture load_client_model >/dev/null cp -- "$TEST_ROOT/remove-dumps/1" "$TEST_ROOT/remove-dumps/2" cp -- "$TEST_ROOT/remove-dumps/1" "$TEST_ROOT/remove-dumps/3" cp -- "$TEST_ROOT/remove-dumps/1" "$TEST_ROOT/remove-dumps/4" cp -- "$TEST_ROOT/remove-dumps/1" "$TEST_ROOT/remove-dumps/5" before_hash=$(sha256sum "$WG_CONF") export WG_SYNC_FAIL_ONCE_FILE="${TEST_ROOT}/remove-sync-fail" printf 'alice — 192.0.2.2/32 — BBBBBBBB\ny\n' >"${TEST_ROOT}/gum-script"; export GUM_SCRIPT_FILE="${TEST_ROOT}/gum-script" set +e; tui_remove_flow >/dev/null 2>"${TEST_ROOT}/remove-fail.err"; status=$?; set -e unset WG_SYNC_FAIL_ONCE_FILE assert_equal "$EXIT_OPERATION" "$status" 'remove apply failure returns operation' assert_equal "$before_hash" "$(sha256sum "$WG_CONF")" 'remove apply failure restores server' assert_file_exists "$CLIENT_DIR/alice.conf" 'remove apply failure preserves config' assert_file_exists "$METADATA_DIR/alice.meta" 'remove apply failure preserves metadata' assert_equal 0 "$(find "$ARCHIVE_DIR" -mindepth 1 -maxdepth 1 -type d | wc -l)" 'remove apply failure leaves no completed archive' assert_contains 'operation=rollback' "$(<"$LOG_DIR/wg-manager.log")" 'remove rollback is audited' assert_contains 'operation=remove target=alice client=alice key_suffix=BBBBBBB= outcome=failed error=transaction-failed' "$(<"$LOG_DIR/wg-manager.log")" 'remove failure is audited' begin_test 'TUI remove artifact recovery failure is critical' prepare_m10_remove_fixture load_client_model >/dev/null export M6_ARCHIVE_REMOVE_FAIL_INDEX=1 M6_ARCHIVE_RECOVERY_FAIL_INDEX=1 printf 'alice — 192.0.2.2/32 — BBBBBBBB\ny\n' >"${TEST_ROOT}/gum-script"; export GUM_SCRIPT_FILE="${TEST_ROOT}/gum-script" set +e; output=$(tui_remove_flow 2>&1); status=$?; set -e unset M6_ARCHIVE_REMOVE_FAIL_INDEX M6_ARCHIVE_RECOVERY_FAIL_INDEX assert_equal "$EXIT_ROLLBACK" "$status" 'artifact recovery returns critical status' assert_contains 'КРИТИЧНО' "$output" 'artifact recovery reports critical error' prepare_m10_remove_fixture load_client_model >/dev/null export M6_ARCHIVE_REMOVE_FAIL_INDEX=1 M6_ARCHIVE_RECOVERY_FAIL_INDEX=1 printf 'Удалить\nalice — 192.0.2.2/32 — BBBBBBBB\ny\nВыход\n' >"${TEST_ROOT}/gum-script"; export GUM_SCRIPT_FILE="${TEST_ROOT}/gum-script" set +e; tui_screen >/dev/null 2>"${TEST_ROOT}/screen-critical.err"; status=$?; set -e unset M6_ARCHIVE_REMOVE_FAIL_INDEX M6_ARCHIVE_RECOVERY_FAIL_INDEX assert_equal "$EXIT_ROLLBACK" "$status" 'TUI screen propagates critical remove failure' assert_contains 'КРИТИЧНО' "$(<"${TEST_ROOT}/screen-critical.err")" 'screen critical diagnostic' ;; tui-client-actions) prepare_m10_remove_fixture load_client_model >/dev/null begin_test 'managed client details use the selected exact identity' printf 'alice — BBBBBBBB\nДетали\n' >"${TEST_ROOT}/gum-script"; export GUM_SCRIPT_FILE="${TEST_ROOT}/gum-script" set +e; tui_select_client >/dev/null 2>&1; status=$?; set -e assert_equal 0 "$status" 'managed details succeed' assert_contains 'Публичный ключ: BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=' "$(<"$GUM_PAGER_CAPTURE")" 'details show selected key' begin_test 'config denial does not read or page client config' printf 'alice — BBBBBBBB\nПоказать конфигурацию\nn\n' >"${TEST_ROOT}/gum-script"; : >"$GUM_PAGER_CAPTURE" set +e; tui_select_client >/dev/null 2>&1; status=$?; set -e assert_equal 0 "$status" 'config denial is normal' assert_equal '' "$(grep -E 'PrivateKey|PresharedKey|EEEE|GGGG|\[Interface\]|\[Peer\]' "$GUM_PAGER_CAPTURE" || true)" 'denied config is not paged' assert_equal '' "$(grep -E 'PrivateKey|PresharedKey|EEEE|GGGG|\[Interface\]' "$TEST_MOCK_LOG" || true)" 'denied config stays out of gum log' begin_test 'confirmed config reaches pager only after confirmation' printf 'alice — BBBBBBBB\nПоказать конфигурацию\ny\n' >"${TEST_ROOT}/gum-script"; : >"$GUM_PAGER_CAPTURE" set +e; tui_select_client >/dev/null 2>&1; status=$?; set -e assert_equal 0 "$status" 'confirmed config succeeds' assert_contains 'PrivateKey = EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE=' "$(<"$GUM_PAGER_CAPTURE")" 'confirmed config reaches pager stdin' assert_equal '' "$(grep -E 'PrivateKey|PresharedKey|EEEE|GGGG' "$TEST_MOCK_LOG" || true)" 'confirmed config absent from gum argv log' begin_test 'QR denial never invokes qrencode' prepare_m10_remove_fixture load_client_model >/dev/null printf 'alice — BBBBBBBB\nПоказать QR\nn\n' >"${TEST_ROOT}/gum-script"; export GUM_SCRIPT_FILE="${TEST_ROOT}/gum-script"; : >"$TEST_MOCK_LOG" set +e; tui_select_client >/dev/null 2>&1; status=$?; set -e assert_equal 0 "$status" 'QR denial is normal' assert_equal 0 "$(grep -c '^qrencode ' "$TEST_MOCK_LOG" || true)" 'QR denial does not invoke qrencode' begin_test 'confirmed QR uses exact argv and config stdin' export QR_MOCK_EXPECTED_FILE="$CLIENT_DIR/alice.conf" printf 'alice — BBBBBBBB\nПоказать QR\ny\n' >"${TEST_ROOT}/gum-script"; : >"$TEST_MOCK_LOG" set +e; output=$(tui_select_client 2>"${TEST_ROOT}/qr-action.err"); status=$?; set -e assert_equal 0 "$status" 'confirmed QR succeeds' assert_contains 'MOCK-QR' "$output" 'QR output is returned' assert_equal 1 "$(grep -c '^qrencode -t ansiutf8$' "$TEST_MOCK_LOG" || true)" 'QR argv is exact' assert_equal '' "$(grep -E 'PrivateKey|PresharedKey|EEEE|GGGG' "$TEST_MOCK_LOG" "${TEST_ROOT}/qr-action.err" || true)" 'QR action logs stay secret-free' unset QR_MOCK_EXPECTED_FILE begin_test 'missing qrencode is a normal fallback' prepare_m10_remove_fixture load_client_model >/dev/null local saved_path=$PATH mkdir -p -- "${TEST_ROOT}/qr-no-command" for utility in bash env wg cat stat sort paste date tail mv; do ln -s -- "$(command -v "$utility")" "${TEST_ROOT}/qr-no-command/$utility"; done PATH="${TEST_ROOT}/qr-no-command" printf 'alice — BBBBBBBB\nПоказать QR\ny\n' >"${TEST_ROOT}/gum-script"; export GUM_SCRIPT_FILE="$TEST_ROOT/gum-script" set +e; output=$(tui_select_client 2>"${TEST_ROOT}/qr-missing-action.err"); status=$?; set -e PATH=$saved_path assert_equal 0 "$status" 'missing QR dependency returns normally' assert_contains "${CLIENT_DIR}/alice.conf" "$output" 'missing QR fallback gives config path' assert_contains 'qrencode не найден' "$(<"${TEST_ROOT}/qr-missing-action.err")" 'missing QR fallback is actionable' begin_test 'qrencode failure preserves backend error status' prepare_m10_remove_fixture load_client_model >/dev/null export QR_MOCK_FAIL=1 printf 'alice — BBBBBBBB\nПоказать QR\ny\n' >"${TEST_ROOT}/gum-script"; export GUM_SCRIPT_FILE="$TEST_ROOT/gum-script" set +e; tui_select_client >/dev/null 2>"${TEST_ROOT}/qr-fail-action.err"; status=$?; set -e unset QR_MOCK_FAIL assert_equal "$EXIT_OPERATION" "$status" 'QR failure is nonzero' assert_contains 'QR-код недоступен' "$(<"${TEST_ROOT}/qr-fail-action.err")" 'QR failure diagnostic is preserved' begin_test 'unmanaged client has details and back actions only' prepare_m10_remove_fixture load_client_model >/dev/null CLIENT_NAMES+=(unmanaged); CLIENT_KEYS+=(EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE=); CLIENT_ADDRESSES+=(192.0.2.9/32); CLIENT_ENDPOINTS+=(-); CLIENT_HANDSHAKES+=(0); CLIENT_RX+=(0); CLIENT_TX+=(0); CLIENT_STATES+=(unmanaged); CLIENT_ORIGINS+=(unmanaged) printf 'Детали\n' >"${TEST_ROOT}/gum-script"; export GUM_SCRIPT_FILE="$TEST_ROOT/gum-script"; : >"$GUM_CHOICES_CAPTURE" set +e; tui_client_action_flow unmanaged 1 >/dev/null 2>&1; status=$?; set -e assert_equal 0 "$status" 'unmanaged details remain readable' assert_contains 'Детали' "$(<"$GUM_CHOICES_CAPTURE")" 'unmanaged action list includes details' assert_contains 'Назад' "$(<"$GUM_CHOICES_CAPTURE")" 'unmanaged action list includes back' assert_equal '' "$(grep -E 'Показать QR|Удалить|Показать конфигурацию' "$GUM_CHOICES_CAPTURE" || true)" 'unmanaged actions exclude config/QR/remove' begin_test 'managed remove action uses exact shared backend' prepare_m10_remove_fixture load_client_model >/dev/null cp -- "$TEST_ROOT/remove-dumps/4" "$TEST_ROOT/remove-dumps/3" printf 'alice — BBBBBBBB\nУдалить\ny\n' >"${TEST_ROOT}/gum-script"; export GUM_SCRIPT_FILE="$TEST_ROOT/gum-script" set +e; output=$(tui_select_client 2>"${TEST_ROOT}/action-remove.err"); status=$?; set -e assert_equal 0 "$status" 'managed remove action succeeds' assert_equal 0 "$(grep -c 'PublicKey = BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=' "$WG_CONF" || true)" 'managed remove action removes selected peer' assert_contains 'Клиент удалён' "$output" 'managed remove action reports outcome' assert_equal '' "$(grep -E 'EEEE|GGGG|PrivateKey|PresharedKey' "$TEST_MOCK_LOG" "${TEST_ROOT}/action-remove.err" || true)" 'managed action remains secret-free' begin_test 'client action cancellation is harmless' prepare_m10_remove_fixture load_client_model >/dev/null printf '\n' >"${TEST_ROOT}/gum-script"; export GUM_SCRIPT_FILE="$TEST_ROOT/gum-script" set +e; tui_select_client >/dev/null 2>&1; status=$?; set -e assert_equal 0 "$status" 'client action cancellation returns normally' ;; tui-backup) prepare_m9_fixture begin_test 'TUI backup action creates a real validated backup and reports its path' printf 'Создать резервную копию\n' >"${TEST_ROOT}/gum-script"; export GUM_SCRIPT_FILE="${TEST_ROOT}/gum-script" set +e; output=$(tui_backup_flow 2>&1); status=$?; set -e assert_equal 0 "$status" 'real backup action success' assert_contains 'Резервная копия создана:' "$output" 'real backup path is displayed' assert_equal 2 "$(find "$BACKUP_DIR" -mindepth 1 -maxdepth 1 -type d | wc -l)" 'real backup directory exists' begin_test 'TUI backup cancellation and validation failure preserve state' before_backup=$(find "$BACKUP_DIR" -mindepth 1 -maxdepth 1 -type d | sort) printf 'Отмена\n' >"${TEST_ROOT}/gum-script" set +e; tui_backup_flow >/dev/null 2>&1; status=$?; set -e assert_equal 0 "$status" 'backup cancellation' assert_equal "$before_backup" "$(find "$BACKUP_DIR" -mindepth 1 -maxdepth 1 -type d | sort)" 'cancel leaves backups unchanged' mv -- "$BACKUP_DIR" "${TEST_ROOT}/backups-real" ln -s -- "${TEST_ROOT}/backups-real" "$BACKUP_DIR" printf 'Создать резервную копию\n' >"${TEST_ROOT}/gum-script" set +e; tui_backup_flow >/dev/null 2>&1; status=$?; set -e assert_failure "$status" 'unsafe backup root failure is preserved' rm -- "$BACKUP_DIR" mv -- "${TEST_ROOT}/backups-real" "$BACKUP_DIR" ;; tui-restore) prepare_m10_restore_fixture begin_test 'TUI restore real success applies validated backup and audits it' backup_path=$(create_backup "$WG_CONF" manual "$WG_INTERFACE"); restore_id=${backup_path##*/} printf '\n# changed before restore\n' >>"$WG_CONF" printf 'Восстановить резервную копию\n%s\n%s\n' "$restore_id" "$restore_id" >"${TEST_ROOT}/gum-script"; export GUM_SCRIPT_FILE="${TEST_ROOT}/gum-script" set +e; output=$(tui_backup_flow 2>"${TEST_ROOT}/restore.err"); status=$?; set -e assert_equal 0 "$status" 'real restore succeeds' assert_equal "$(sha256sum "$backup_path/wg.conf" | awk '{print $1}')" "$(sha256sum "$WG_CONF" | awk '{print $1}')" 'restore applies exact backup bytes' assert_contains "$restore_id" "$(<"$GUM_PAGER_CAPTURE")" 'restore preview shows selected id' assert_contains 'manual' "$(<"$GUM_PAGER_CAPTURE")" 'restore preview shows operation details' assert_equal 2 "$(find "$BACKUP_DIR" -mindepth 1 -maxdepth 1 -type d | wc -l)" 'restore keeps current-state and selected backups' assert_contains 'syncconf' "$(<"$TEST_MOCK_LOG")" 'restore applies runtime configuration' assert_contains 'operation=restore' "$(<"$LOG_DIR/wg-manager.log")" 'restore audit is recorded' assert_equal '' "$(grep -E 'PrivateKey|PresharedKey|AAAAAAAA|BBBBBBBB' "$TEST_MOCK_LOG" "$LOG_DIR/wg-manager.log" "$TEST_ROOT/restore.err" || true)" 'restore output and logs are secret-free' begin_test 'TUI restore cancellation and typed mismatch preserve state' prepare_m10_restore_fixture backup_path=$(create_backup "$WG_CONF" manual "$WG_INTERFACE"); restore_id=${backup_path##*/} before_hash=$(sha256sum "$WG_CONF"); before_count=$(find "$BACKUP_DIR" -mindepth 1 -maxdepth 1 -type d | wc -l) printf 'Восстановить резервную копию\n%s\nwrong-id\n' "$restore_id" >"${TEST_ROOT}/gum-script"; export GUM_SCRIPT_FILE="${TEST_ROOT}/gum-script" set +e; tui_backup_flow >/dev/null 2>&1; status=$?; set -e assert_equal 0 "$status" 'typed restore mismatch is harmless' assert_equal "$before_hash" "$(sha256sum "$WG_CONF")" 'typed mismatch preserves config' assert_equal "$before_count" "$(find "$BACKUP_DIR" -mindepth 1 -maxdepth 1 -type d | wc -l)" 'typed mismatch creates no backup' begin_test 'TUI restore rejects an unoffered selection without mutation' prepare_m10_restore_fixture backup_path=$(create_backup "$WG_CONF" manual "$WG_INTERFACE"); restore_id=${backup_path##*/}; before_hash=$(sha256sum "$WG_CONF") printf 'Восстановить резервную копию\nnot-a-backup\n' >"${TEST_ROOT}/gum-script"; export GUM_SCRIPT_FILE="${TEST_ROOT}/gum-script" set +e; tui_backup_flow >/dev/null 2>&1; status=$?; set -e assert_equal "$EXIT_VALIDATION" "$status" 'unoffered restore selection is rejected' assert_equal "$before_hash" "$(sha256sum "$WG_CONF")" 'unoffered restore leaves config unchanged' begin_test 'TUI restore apply failure rolls back and retains backup' prepare_m10_restore_fixture backup_path=$(create_backup "$WG_CONF" manual "$WG_INTERFACE"); restore_id=${backup_path##*/}; printf '\n# changed before failed restore\n' >>"$WG_CONF"; before_hash=$(sha256sum "$WG_CONF") export WG_SYNC_FAIL_ONCE_FILE="${TEST_ROOT}/restore-sync-fail" printf 'Восстановить резервную копию\n%s\n%s\n' "$restore_id" "$restore_id" >"${TEST_ROOT}/gum-script"; export GUM_SCRIPT_FILE="${TEST_ROOT}/gum-script" set +e; tui_backup_flow >/dev/null 2>"${TEST_ROOT}/restore-fail.err"; status=$?; set -e unset WG_SYNC_FAIL_ONCE_FILE assert_equal "$EXIT_OPERATION" "$status" 'restore apply failure returns operation' assert_equal "$before_hash" "$(sha256sum "$WG_CONF")" 'restore apply failure restores attempted state' assert_contains 'operation=rollback' "$(<"$LOG_DIR/wg-manager.log")" 'restore rollback is audited' assert_contains 'operation=restore target='"$restore_id"' client='"$restore_id"' key_suffix= outcome=failed error=transaction-failed' "$(<"$LOG_DIR/wg-manager.log")" 'restore failure is audited' assert_equal 2 "$(find "$BACKUP_DIR" -mindepth 1 -maxdepth 1 -type d | wc -l)" 'restore failure retains backup copies' begin_test 'TUI screen propagates critical restore rollback' prepare_m10_restore_fixture backup_path=$(create_backup "$WG_CONF" manual "$WG_INTERFACE"); restore_id=${backup_path##*/}; printf '\n# changed before critical restore\n' >>"$WG_CONF" export WG_SYNC_FAIL_ONCE_FILE="${TEST_ROOT}/restore-critical-sync" TRANSACTION_ROLLBACK_REPLACE_FAIL=1 printf 'Резервные копии\nВосстановить резервную копию\n%s\n%s\nВыход\n' "$restore_id" "$restore_id" >"${TEST_ROOT}/gum-script"; export GUM_SCRIPT_FILE="${TEST_ROOT}/gum-script" set +e; tui_screen >/dev/null 2>"${TEST_ROOT}/restore-critical.err"; status=$?; set -e unset WG_SYNC_FAIL_ONCE_FILE TRANSACTION_ROLLBACK_REPLACE_FAIL assert_equal "$EXIT_ROLLBACK" "$status" 'critical restore rollback exits nonzero' assert_contains 'КРИТИЧНО' "$(<"${TEST_ROOT}/restore-critical.err")" 'critical restore diagnostic is preserved' ;; tui-backend-failure) prepare_m9_fixture begin_test 'TUI ordinary backup failure remains readable and bounded' rm -rf -- "$BACKUP_DIR" printf 'not a directory\n' >"$BACKUP_DIR" before_backup=0 printf 'Резервные копии\nСоздать резервную копию\nВыход\n' >"${TEST_ROOT}/gum-script" export GUM_SCRIPT_FILE="${TEST_ROOT}/gum-script" set +e; output=$(tui_screen 2>"${TEST_ROOT}/backend-failure.err"); status=$?; set -e rm -- "$BACKUP_DIR" mkdir -- "$BACKUP_DIR" chmod 700 -- "$BACKUP_DIR" assert_equal 0 "$status" 'ordinary backend failure returns to menu' assert_contains 'Предупреждение' "$(<"${TEST_ROOT}/backend-failure.err")" 'ordinary failure has context' assert_equal "$before_backup" "$(find "$BACKUP_DIR" -mindepth 1 -maxdepth 1 -type d | wc -l)" 'failed backup creates no entry' begin_test 'TUI repeated real backend failures are bounded' prepare_m9_fixture rm -rf -- "$BACKUP_DIR" printf 'not a directory\n' >"$BACKUP_DIR" before_backup=0 printf 'Резервные копии\nСоздать резервную копию\nРезервные копии\nСоздать резервную копию\nРезервные копии\nСоздать резервную копию\nВыход\n' >"${TEST_ROOT}/gum-script" export GUM_SCRIPT_FILE="${TEST_ROOT}/gum-script" set +e; tui_screen >"${TEST_ROOT}/bounded.out" 2>"${TEST_ROOT}/bounded.err"; status=$?; set -e rm -- "$BACKUP_DIR" mkdir -- "$BACKUP_DIR" chmod 700 -- "$BACKUP_DIR" assert_equal "$EXIT_OPERATION" "$status" 'three real failures terminate with operation status' assert_contains 'TUI завершён' "$(<"${TEST_ROOT}/bounded.err")" 'bounded failure diagnostic' assert_equal "$before_backup" "$(find "$BACKUP_DIR" -mindepth 1 -maxdepth 1 -type d | wc -l)" 'repeated failures create no backups' begin_test 'TUI critical restore rollback propagates without stubs' prepare_m10_restore_fixture local critical_backup critical_id critical_backup=$(create_backup "$WG_CONF" manual "$WG_INTERFACE") critical_id=${critical_backup##*/} printf '\n# changed before critical TUI restore\n' >>"$WG_CONF" export WG_SYNC_FAIL_ONCE_FILE="${TEST_ROOT}/critical-sync" TRANSACTION_ROLLBACK_REPLACE_FAIL=1 printf 'Резервные копии\nВосстановить резервную копию\n%s\n%s\nВыход\n' "$critical_id" "$critical_id" >"${TEST_ROOT}/gum-script" export GUM_SCRIPT_FILE="${TEST_ROOT}/gum-script" set +e; tui_screen >"${TEST_ROOT}/critical.out" 2>"${TEST_ROOT}/critical.err"; status=$?; set -e unset WG_SYNC_FAIL_ONCE_FILE TRANSACTION_ROLLBACK_REPLACE_FAIL assert_equal "$EXIT_ROLLBACK" "$status" 'critical rollback exits TUI immediately' assert_contains 'КРИТИЧНО' "$(<"${TEST_ROOT}/critical.err")" 'critical rollback diagnostic is preserved' assert_equal '' "${TUI_GUM_PATH:-}" 'critical route clears gum state' ;; tui-no-secret-args) prepare_m10_add_fixture begin_test 'successful add keeps secrets out of TUI arguments and audit' printf 'secretcheck\nПолный туннель\ny\ny\nn\n' >"${TEST_ROOT}/gum-script"; export GUM_SCRIPT_FILE="${TEST_ROOT}/gum-script" set +e; add_output=$(tui_add_flow 2>"${TEST_ROOT}/secret-add.err"); status=$?; set -e assert_equal 0 "$status" 'real add for secret boundary succeeds' assert_equal 600 "$(stat -c '%a' "$CLIENT_DIR/secretcheck.conf")" 'generated config is restrictive' assert_contains 'secretcheck.conf' "$add_output" 'successful add reports validated path' secret_stream=$(cat "$TEST_MOCK_LOG" "$LOG_DIR/wg-manager.log" "${TEST_ROOT}/secret-add.err"; printf '%s\n' "$add_output") assert_equal '' "$(grep -E 'EEEE|GGGG|PrivateKey|PresharedKey|\[Interface\]|\[Peer\]' <<<"$secret_stream" || true)" 'successful add outputs and logs are secret-free' unset WG_MOCK_DUMP_SEQUENCE_DIR export WG_MOCK_DUMP_FILE="${TEST_ROOT}/dump-sequence/1" load_client_model >/dev/null local secret_label secret_label='secretcheck — FFFFFFFF' begin_test 'confirmed config is sent only to pager stdin' : >"$GUM_PAGER_CAPTURE"; : >"$TEST_MOCK_LOG" printf '%s\nПоказать конфигурацию\ny\n' "$secret_label" >"${TEST_ROOT}/gum-script"; export GUM_SCRIPT_FILE="${TEST_ROOT}/gum-script" set +e; tui_select_client >"${TEST_ROOT}/secret-config.out" 2>"${TEST_ROOT}/secret-config.err"; status=$?; set -e assert_equal 0 "$status" 'confirmed config action succeeds' assert_contains 'PrivateKey' "$(<"$GUM_PAGER_CAPTURE")" 'confirmed config reaches pager stdin' secret_stream=$(cat "$TEST_MOCK_LOG" "$LOG_DIR/wg-manager.log" "${TEST_ROOT}/secret-config.out" "${TEST_ROOT}/secret-config.err") assert_equal '' "$(grep -E 'EEEE|GGGG|PrivateKey|PresharedKey|\[Interface\]|\[Peer\]' <<<"$secret_stream" || true)" 'config secrets absent from args/logs/output' begin_test 'confirmed QR receives config on stdin without leaking it to gum' export QR_MOCK_EXPECTED_FILE="$CLIENT_DIR/secretcheck.conf" : >"$TEST_MOCK_LOG" printf '%s\nПоказать QR\ny\n' "$secret_label" >"${TEST_ROOT}/gum-script"; export GUM_SCRIPT_FILE="${TEST_ROOT}/gum-script" set +e; tui_select_client >"${TEST_ROOT}/secret-qr.out" 2>"${TEST_ROOT}/secret-qr.err"; status=$?; set -e unset QR_MOCK_EXPECTED_FILE assert_equal 0 "$status" 'confirmed QR action succeeds' assert_contains 'MOCK-QR' "$(<"${TEST_ROOT}/secret-qr.out")" 'QR output is returned' assert_contains 'qrencode -t ansiutf8' "$(<"$TEST_MOCK_LOG")" 'QR argv is exact and nonsecret' secret_stream=$(cat "$TEST_MOCK_LOG" "$LOG_DIR/wg-manager.log" "${TEST_ROOT}/secret-qr.out" "${TEST_ROOT}/secret-qr.err") assert_equal '' "$(grep -E 'EEEE|GGGG|PrivateKey|PresharedKey|\[Interface\]|\[Peer\]' <<<"$secret_stream" || true)" 'QR secrets absent from args/logs/output' ;; tui-no-gum) prepare_m9_fixture export WG_MANAGER_GUM="${TEST_ROOT}/missing-gum" begin_test 'TUI without gum reports dependency while CLI remains independent' set +e; output=$(tui_screen 2>&1); status=$?; set -e assert_equal "$EXIT_DEPENDENCY" "$status" 'missing gum status' assert_contains 'WG_MANAGER_GUM' "$output" 'missing gum diagnostic' assert_equal '' "${TUI_GUM_PATH:-}" 'gum path is cleared after dependency failure' set +e; output=$(main status 2>&1); status=$?; set -e assert_equal 0 "$status" 'CLI status remains independent of gum' ;; tui-non-tty) prepare_m9_fixture unset WG_MANAGER_TEST_TTY begin_test 'TUI refuses noninteractive input before gum output' set +e; output=$(tui_screen 2>&1); status=$?; set -e assert_equal "$EXIT_USAGE" "$status" 'non-TTY status' assert_contains 'интерактивный терминал' "$output" 'non-TTY diagnostic' assert_equal '' "$(<"$TEST_MOCK_LOG")" 'non-TTY has no gum invocation' for tty_mode in stdin stdout none; do export WG_MANAGER_TEST_TTY="$tty_mode" set +e; tui_is_terminal >/dev/null 2>&1; status=$?; set -e assert_failure "$status" "TTY режим ${tty_mode} отклонён" done export WG_MANAGER_TEST_TTY=both set +e; tui_is_terminal >/dev/null 2>&1; status=$?; set -e assert_equal 0 "$status" 'оба потока TTY принимаются в изолированном режиме' set +e; main tui extra >/dev/null 2>&1; status=$?; set -e assert_equal "$EXIT_USAGE" "$status" 'tui extra argument is usage' ;; esac unset GUM_SCRIPT_FILE WG_MANAGER_GUM WG_MANAGER_TEST_TTY WG_MANAGER_IPV4_FORWARD_FILE WG_MANAGER_IPV6_FORWARD_FILE GUM_STYLE_CAPTURE GUM_PAGER_CAPTURE GUM_CHOICES_CAPTURE GUM_FAIL_CAPABILITY } run_dependency_tests() { local output status code local -a exit_codes=("$EXIT_USAGE" "$EXIT_VALIDATION" "$EXIT_OPERATION" "$EXIT_ROLLBACK" "$EXIT_PERMISSION" "$EXIT_DEPENDENCY") declare -A seen_codes=() begin_test 'stable exit categories are distinct' assert_equal 0 "$EXIT_SUCCESS" 'EXIT_SUCCESS' for code in "${exit_codes[@]}"; do assert_failure "$code" "ненулевой код ${code}" if [[ -v "seen_codes[$code]" ]]; then record_failure "коды не различаются: ${code}" else seen_codes["$code"]=1 ((TEST_ASSERTIONS += 1)) fi done begin_test 'required and optional dependency checks are controlled' output=$(require_command wg) assert_contains '/tests/mocks/bin/wg' "$output" 'обязательная команда через mock PATH' output=$(optional_command gum) assert_contains '/tests/mocks/bin/gum' "$output" 'необязательная команда через mock PATH' set +e require_command definitely-missing >/dev/null 2>&1 status=$? set -e assert_equal "$EXIT_DEPENDENCY" "$status" 'отсутствующая обязательная команда' set +e optional_command definitely-missing >/dev/null 2>&1 status=$? set -e assert_equal "$EXIT_DEPENDENCY" "$status" 'отсутствующая необязательная команда' } run_debug_tests() { local output status begin_test 'disabled debug is a successful no-op' unset WG_MANAGER_DEBUG set +e output=$(debug hidden 2>&1) status=$? set -e assert_success "$status" 'отключённая отладка' assert_equal '' "$output" 'отключённая отладка без вывода' begin_test 'enabled debug uses stderr only' : >"${TEST_ROOT}/debug.stdout" : >"${TEST_ROOT}/debug.stderr" WG_MANAGER_DEBUG=1 debug visible >"${TEST_ROOT}/debug.stdout" 2>"${TEST_ROOT}/debug.stderr" assert_equal '' "$(<"${TEST_ROOT}/debug.stdout")" 'debug stdout пуст' assert_contains 'Отладка: visible' "$(<"${TEST_ROOT}/debug.stderr")" 'debug stderr' } write_m2_config() { local output_file=$1 wireguard_file=$2 write_valid_config "$output_file" replace_config_line "$output_file" 'WG_CONF=' "WG_CONF=\"${wireguard_file}\"" } write_alice_metadata() { mkdir -p "$METADATA_DIR" cat >"${METADATA_DIR}/alice.meta" <"${METADATA_DIR}/bob.meta" <"$TEST_MOCK_LOG" unset WG_MOCK_DUMP_FILE WG_MOCK_INTERFACE begin_test 'persistent WireGuard parser and trusted metadata identity' write_alice_metadata parse_wireguard_config "$fixture" assert_equal 1 "${#DISK_ALLOWED[@]}" 'один peer на диске' assert_equal 192.0.2.2/32 "${DISK_IPV4[BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=]}" 'адрес peer' parse_metadata assert_equal alice "${META_NAME[BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=]}" 'имя из метаданных' assert_equal 1 "${META_MANAGED[BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=]}" 'доверенная метка managed' assert_equal "${CLIENT_DIR}/alice.conf" "${META_CONFIG[BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=]}" 'путь клиентского конфига' assert_equal 1700000000 "${META_CREATED[BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=]}" 'время создания метаданных' begin_test 'two managed peers match exact keys and addresses' cp -- "${PROJECT_DIR}/tests/fixtures/m2/multiple-managed.conf" "$fixture" write_bob_metadata printf 'IPV6=2001:db8::2\n' >>"${METADATA_DIR}/alice.meta" load_client_model local managed_count=0 for record_index in "${!CLIENT_KEYS[@]}"; do [[ ${CLIENT_ORIGINS[$record_index]} == managed ]] && ((managed_count += 1)); done assert_equal 2 "$managed_count" 'две управляемые записи' rm -- "${METADATA_DIR}/bob.meta" cp -- "${PROJECT_DIR}/tests/fixtures/m2/managed.conf" "$fixture" begin_test 'pre-peer marker and spaced mixed AllowedIPs are parsed' cp -- "${PROJECT_DIR}/tests/fixtures/m2/pre-peer-marker.conf" "$fixture" parse_wireguard_config "$fixture" assert_equal alice "${DISK_MARKER[BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=]}" 'маркер перед peer' assert_equal '192.0.2.2/32,2001:db8::2/128' "${DISK_ALLOWED[BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=]}" 'нормализация AllowedIPs' cp -- "${PROJECT_DIR}/tests/fixtures/m2/managed.conf" "$fixture" begin_test 'repeatable interface fields and duplicate peer singleton checks' cp -- "${PROJECT_DIR}/tests/fixtures/m2/repeatable-interface.conf" "$fixture" parse_wireguard_config "$fixture" replace_config_line "$fixture" 'FwMark =' 'FwMark = 0xca6c' parse_wireguard_config "$fixture" for bad_fwmark in 0x100000000 0xnope 4294967296; do replace_config_line "$fixture" 'FwMark =' "FwMark = ${bad_fwmark}" set +e parse_wireguard_config "$fixture" >/dev/null 2>&1 status=$? set -e assert_equal "$EXIT_VALIDATION" "$status" "некорректный FwMark ${bad_fwmark}" done cp -- "${PROJECT_DIR}/tests/fixtures/m2/managed.conf" "$fixture" printf 'Endpoint = vpn.example.invalid:51820\n' >>"$fixture" set +e output=$(parse_wireguard_config "$fixture" 2>&1) status=$? set -e assert_equal "$EXIT_VALIDATION" "$status" 'дублирующийся Endpoint' assert_contains 'Дублирующийся Endpoint' "$output" 'диагностика duplicate Endpoint' for duplicate_field in PresharedKey AllowedIPs PersistentKeepalive; do cp -- "${PROJECT_DIR}/tests/fixtures/m2/managed.conf" "$fixture" case $duplicate_field in PresharedKey) printf 'PresharedKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\nPresharedKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\n' >>"$fixture" ;; AllowedIPs) printf 'AllowedIPs = 192.0.2.2/32\nAllowedIPs = 192.0.2.2/32\n' >>"$fixture" ;; PersistentKeepalive) printf 'PersistentKeepalive = 25\nPersistentKeepalive = 25\n' >>"$fixture" ;; esac set +e output=$(parse_wireguard_config "$fixture" 2>&1) status=$? set -e assert_equal "$EXIT_VALIDATION" "$status" "дубликат ${duplicate_field}" done cp -- "${PROJECT_DIR}/tests/fixtures/m2/managed.conf" "$fixture" printf 'Endpoint = vpn.example.invalid\n' >>"$fixture" set +e output=$(parse_wireguard_config "$fixture" 2>&1) status=$? set -e assert_equal "$EXIT_VALIDATION" "$status" 'Endpoint без порта' cp -- "${PROJECT_DIR}/tests/fixtures/m2/managed.conf" "$fixture" printf 'PresharedKey = (none)\n' >>"$fixture" set +e output=$(parse_wireguard_config "$fixture" 2>&1) status=$? set -e assert_equal "$EXIT_VALIDATION" "$status" 'persistent PSK token отвергнут' begin_test 'section ordering and marker leakage are rejected safely' printf '# wg-manager-client: leaked\n[Interface]\nPrivateKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\nListenPort = 51820\n[Peer]\nPublicKey = BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=\nAllowedIPs = 192.0.2.2/32\n' >"${TEST_ROOT}/marker-leak.conf" parse_wireguard_config "${TEST_ROOT}/marker-leak.conf" assert_equal '' "${DISK_MARKER[BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=]:-}" 'маркер не протёк через Interface' for invalid_section in peer-before-interface empty-config repeated-interface; do case $invalid_section in peer-before-interface) printf '[Peer]\nPublicKey = BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=\nAllowedIPs = 192.0.2.2/32\n' >"${TEST_ROOT}/${invalid_section}.conf" ;; empty-config) : >"${TEST_ROOT}/${invalid_section}.conf" ;; repeated-interface) printf '[Interface]\nPrivateKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\n[Interface]\nListenPort = 51820\n' >"${TEST_ROOT}/${invalid_section}.conf" ;; esac set +e parse_wireguard_config "${TEST_ROOT}/${invalid_section}.conf" >/dev/null 2>&1 status=$? set -e assert_equal "$EXIT_VALIDATION" "$status" "секция ${invalid_section}" done begin_test 'adjacent conventional markers attach to the next peer' cp -- "${PROJECT_DIR}/tests/fixtures/m2/markers-adjacent.conf" "$fixture" parse_wireguard_config "$fixture" assert_equal first "${DISK_LEGACY[BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=]}" 'первый marker' assert_equal second "${DISK_LEGACY[CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC=]}" 'соседний marker' assert_equal third "${DISK_MARKER[DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD=]}" 'соседний explicit marker' begin_test 'metadata names remain restricted and cannot create paths' rm -- "${METADATA_DIR}/alice.meta" local unsafe_name metadata_output for unsafe_name in 'bad name' '*glob?' '-name' $'bad\nname'; do printf 'NAME=%s\nPUBLIC_KEY=BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=\nIPV4=192.0.2.2\nCREATED_AT=1700000000\nCONFIG_PATH=%s/bad.conf\nCLIENT_MODE=full-tunnel\nMANAGED=1\n' "$unsafe_name" "$CLIENT_DIR" >"${METADATA_DIR}/bad.meta" set +e metadata_output=$(parse_metadata 2>&1) status=$? set -e assert_equal "$EXIT_VALIDATION" "$status" "unsafe metadata name ${unsafe_name@Q}" assert_equal '' "$(printf '%s' "$metadata_output" | grep -E 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=|PrivateKey|PresharedKey' || true)" 'metadata error without key material' assert_equal 0 "$( [[ ! -e "${CLIENT_DIR}/${unsafe_name}.conf" ]] && printf 0 || printf 1 )" 'unsafe metadata does not create path' rm -- "${METADATA_DIR}/bad.meta" done write_alice_metadata begin_test 'duplicate metadata identity is rejected' cp -- "${METADATA_DIR}/alice.meta" "${METADATA_DIR}/duplicate.meta" set +e output=$(parse_metadata 2>&1) status=$? set -e assert_equal "$EXIT_VALIDATION" "$status" 'дубликат метаданных' assert_contains 'Дублирующийся' "$output" 'диагностика дубликата метаданных' rm -- "${METADATA_DIR}/duplicate.meta" begin_test 'empty peer list remains a valid disk model' cp -- "${PROJECT_DIR}/tests/fixtures/m2/no-peers.conf" "$fixture" rm -- "${METADATA_DIR}/alice.meta" unset WG_MOCK_DUMP_FILE load_client_model assert_equal 0 "${#CLIENT_KEYS[@]}" 'нет peers' write_alice_metadata cp -- "${PROJECT_DIR}/tests/fixtures/m2/managed.conf" "$fixture" begin_test 'metadata address mismatch never establishes managed identity' replace_config_line "${METADATA_DIR}/alice.meta" 'IPV4=' 'IPV4=192.0.2.99' load_client_model assert_equal legacy "${CLIENT_ORIGINS[0]}" 'mismatch origin' write_alice_metadata begin_test 'legacy and unmanaged origins remain distinct from managed identity' cp -- "${PROJECT_DIR}/tests/fixtures/m2/multiple.conf" "$fixture" rm -- "${METADATA_DIR}/alice.meta" unset WG_MOCK_DUMP_FILE load_client_model local record_index for record_index in "${!CLIENT_KEYS[@]}"; do case ${CLIENT_KEYS[$record_index]} in BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=) assert_equal legacy "${CLIENT_ORIGINS[$record_index]}" 'peer с явным marker без метаданных'; assert_equal unmanaged "${CLIENT_STATES[$record_index]}" 'marker без доверенной метадаты' ;; CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC=) assert_equal legacy "${CLIENT_ORIGINS[$record_index]}" 'legacy peer'; assert_equal unmanaged "${CLIENT_STATES[$record_index]}" 'legacy state' ;; DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD=) assert_equal unmanaged "${CLIENT_ORIGINS[$record_index]}" 'unmanaged peer' ;; esac done begin_test 'runtime parser and exact wg dump command' cp -- "${PROJECT_DIR}/tests/fixtures/m2/managed.conf" "$fixture" write_alice_metadata cp -- "${PROJECT_DIR}/tests/fixtures/m2/runtime.dump" "$runtime_file" export WG_MOCK_DUMP_FILE="$runtime_file" WG_MOCK_INTERFACE=wg0 : >"$TEST_MOCK_LOG" load_client_model assert_equal 1 "$RUNTIME_AVAILABLE" 'runtime доступен' assert_equal 51820 "$RUNTIME_LISTEN_PORT" 'порт runtime' assert_equal active "${CLIENT_STATES[0]}" 'активный peer на границе threshold' local runtime_log runtime_log=$(<"$TEST_MOCK_LOG") runtime_log+=$'\n' assert_equal $'wg show wg0 dump\n' "$runtime_log" 'точный argv wg' assert_equal '' "$(printf '%s' "$runtime_log" | grep -E 'systemctl|ip|gum' || true)" 'runtime без лишних команд' begin_test 'runtime unavailable tokens normalize safely' parse_runtime_dump $'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\tAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\t51820\toff\nBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=\t(none)\t(none)\t(none)\t0\t0\t0\toff' assert_equal - "${RUNTIME_ENDPOINT[BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=]}" 'endpoint none' assert_equal - "${RUNTIME_ALLOWED[BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=]}" 'allowed none' parse_runtime_dump $'(none)\t(none)\t51820\toff' assert_equal 51820 "$RUNTIME_LISTEN_PORT" 'interface none tokens' set +e output=$(parse_runtime_dump $'(none)\t(none)\t51820\toff\nBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=\t(none)\t(none)\t(none)\t0\t0\t0\tdisabled' 2>&1) status=$? set -e assert_equal "$EXIT_VALIDATION" "$status" 'runtime keepalive accepts off but rejects unknown tokens' assert_contains 'Некорректные числа' "$output" 'runtime keepalive diagnostic' set +e output=$(parse_runtime_dump $'(none)\t(none)\t51820\toff\nBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=\t(none)\t(none)\t(none)\t0\t0\t0\t65536' 2>&1) status=$? set -e assert_equal "$EXIT_VALIDATION" "$status" 'runtime keepalive rejects values above WireGuard range' set +e output=$(parse_runtime_dump $'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\tAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\t51820\t0\nBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=\t(none)\tvpn.example.invalid\t192.0.2.2/32\t0\t0\t0\t0' 2>&1) status=$? set -e assert_equal "$EXIT_VALIDATION" "$status" 'runtime endpoint without port' parse_runtime_dump $'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\tAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\t51820\t0\nBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=\t(none)\t[2001:db8::1]:51820\t192.0.2.2/32\t0\t0\t0\t0' assert_equal '[2001:db8::1]:51820' "${RUNTIME_ENDPOINT[BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=]}" 'runtime IPv6 endpoint' begin_test 'unavailable runtime command degrades safely' local saved_path=$PATH local safe_runtime_path="${TEST_ROOT}/no-wg" mkdir -p "$safe_runtime_path" ln -s -- "$(type -P sort)" "${safe_runtime_path}/sort" ln -s -- "$(type -P paste)" "${safe_runtime_path}/paste" PATH=$safe_runtime_path if command -v wg >/dev/null 2>&1; then record_failure 'контролируемый PATH неожиданно содержит wg' fi unset WG_MOCK_DUMP_FILE output=$(/bin/bash "${PROJECT_DIR}/wg-manager.sh" status) PATH=$saved_path assert_contains 'недоступен' "$output" 'wg отсутствует' export WG_MOCK_DUMP_FILE="$runtime_file" WG_MOCK_INTERFACE=wg0 begin_test 'active threshold, inactive, and never states are deterministic' replace_config_line "$runtime_file" 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=' $'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=\tAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\tvpn.example.invalid:51820\t192.0.2.2/32\t1699999919\t100\t200\t25' load_client_model assert_equal inactive "${CLIENT_STATES[0]}" 'peer just beyond threshold' replace_config_line "$runtime_file" 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=' $'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=\tAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\tvpn.example.invalid:51820\t192.0.2.2/32\t0\t100\t200\t25' load_client_model assert_equal never "${CLIENT_STATES[0]}" 'peer without handshake' cp -- "${PROJECT_DIR}/tests/fixtures/m2/runtime.dump" "$runtime_file" begin_test 'runtime-only peer is retained as unmanaged' printf '%s\t%s\t-\t192.0.2.9/32\t1700000100\t1\t2\t0\n' 'EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE=' '(none)' >>"$runtime_file" load_client_model local runtime_index runtime_origin='' assert_contains EEEEEEEE "$(printf '%s\n' "${CLIENT_KEYS[@]}")" 'runtime-only key parsed' for runtime_index in "${!CLIENT_KEYS[@]}"; do [[ ${CLIENT_KEYS[$runtime_index]} == EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE= ]] && runtime_origin=${CLIENT_ORIGINS[$runtime_index]} done assert_equal unmanaged "$runtime_origin" 'runtime-only origin' begin_test 'malformed runtime dump fails without exposing fields' printf 'malformed runtime line\n' >"$runtime_file" set +e output=$("${PROJECT_DIR}/wg-manager.sh" list 2>&1) status=$? set -e assert_equal "$EXIT_VALIDATION" "$status" 'malformed runtime status' assert_contains 'wg dump' "$output" 'malformed runtime diagnostic' cp -- "${PROJECT_DIR}/tests/fixtures/m2/runtime.dump" "$runtime_file" begin_test 'status is useful without gum or systemd' local model_before model_after model_before=$(snapshot_model_inputs "$config_file" "$fixture" "$METADATA_DIR" "$runtime_file" "$BACKUP_DIR" "$LOG_DIR" "$ARCHIVE_DIR" "$LOCK_FILE") output=$("${PROJECT_DIR}/wg-manager.sh" status 2>&1) assert_contains 'Интерфейс: wg0' "$output" 'status interface' assert_contains 'Управляемые: 1' "$output" 'status managed count' assert_contains 'Активные: 1' "$output" 'status active count' output=$("${PROJECT_DIR}/wg-manager.sh" list) model_after=$(snapshot_model_inputs "$config_file" "$fixture" "$METADATA_DIR" "$runtime_file" "$BACKUP_DIR" "$LOG_DIR" "$ARCHIVE_DIR" "$LOCK_FILE") assert_equal "$model_before" "$model_after" 'status/list не изменяют состояние' begin_test 'invalid deterministic clock fails without secrets' WG_MANAGER_NOW=invalid set +e output=$("${PROJECT_DIR}/wg-manager.sh" status 2>&1) status=$? set -e assert_equal "$EXIT_VALIDATION" "$status" 'некорректные часы' assert_equal '' "$(printf '%s' "$output" | grep -E 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=|BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=' || true)" 'ошибка часов без ключей' WG_MANAGER_NOW=1700000100 begin_test 'list is deterministic and secret-free' output=$("${PROJECT_DIR}/wg-manager.sh" list) assert_contains $'NAME\tADDRESSES\tPUBLIC_KEY' "$output" 'list header' assert_contains $'alice\t192.0.2.2/32\tBBBBBBBB' "$output" 'list managed row' assert_equal '' "$(grep -E 'PrivateKey|PresharedKey|AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=' <<<"$output" || true)" 'list не показывает секретные поля' begin_test 'redirected list remains plain text' "${PROJECT_DIR}/wg-manager.sh" list >"${TEST_ROOT}/list.out" assert_contains $'alice\t192.0.2.2/32' "$(<"${TEST_ROOT}/list.out")" 'redirected list row' assert_equal '' "$(LC_ALL=C grep -nP '[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]' "${TEST_ROOT}/list.out" || true)" 'redirected list without controls' begin_test 'list ordering is independent of peer input order' rm -- "${METADATA_DIR}/alice.meta" unset WG_MOCK_DUMP_FILE cp -- "${PROJECT_DIR}/tests/fixtures/m2/multiple.conf" "$fixture" output=$("${PROJECT_DIR}/wg-manager.sh" list) cp -- "${PROJECT_DIR}/tests/fixtures/m2/multiple-reversed.conf" "$fixture" assert_equal "$output" "$("${PROJECT_DIR}/wg-manager.sh" list)" 'стабильная сортировка list' cp -- "${PROJECT_DIR}/tests/fixtures/m2/managed.conf" "$fixture" write_alice_metadata export WG_MOCK_DUMP_FILE="$runtime_file" WG_MOCK_INTERFACE=wg0 begin_test 'stopped runtime degrades to disk-only output' unset WG_MOCK_DUMP_FILE output=$("${PROJECT_DIR}/wg-manager.sh" status) assert_contains 'недоступен' "$output" 'runtime unavailable' assert_contains 'Peers: 1' "$output" 'disk peer count' begin_test 'malformed persistent config fails safely' cp -- "${PROJECT_DIR}/tests/fixtures/m2/malformed.conf" "$fixture" set +e output=$("${PROJECT_DIR}/wg-manager.sh" list 2>&1) status=$? set -e assert_equal "$EXIT_VALIDATION" "$status" 'malformed config status' assert_contains 'PublicKey' "$output" 'malformed config diagnostic' begin_test 'duplicate AllowedIPs are rejected' cp -- "${PROJECT_DIR}/tests/fixtures/m2/duplicate-allowed.conf" "$fixture" set +e output=$("${PROJECT_DIR}/wg-manager.sh" list 2>&1) status=$? set -e assert_equal "$EXIT_VALIDATION" "$status" 'duplicate AllowedIPs status' assert_contains 'AllowedIPs' "$output" 'duplicate AllowedIPs diagnostic' begin_test 'duplicate public keys and overlapping networks are rejected' for invalid_fixture in duplicate-public overlap overlap-v6; do cp -- "${PROJECT_DIR}/tests/fixtures/m2/${invalid_fixture}.conf" "$fixture" set +e output=$("${PROJECT_DIR}/wg-manager.sh" list 2>&1) status=$? set -e assert_equal "$EXIT_VALIDATION" "$status" "${invalid_fixture} status" if [[ $invalid_fixture == duplicate-public ]]; then assert_contains 'PublicKey' "$output" "${invalid_fixture} diagnostic"; else assert_contains 'AllowedIPs' "$output" "${invalid_fixture} diagnostic"; fi done } transaction_builder_copy() { [[ ${TRANSACTION_IN_CRITICAL:-0} == 1 ]] || return 91 sed -e 's/^Address = 192\.0\.2\.1\/24$/Address = 192.0.2.1\/25/' "$WG_CONF" >"$1" } transaction_builder_invalid() { [[ ${TRANSACTION_IN_CRITICAL:-0} == 1 ]] || return 91 printf '[broken\n' >"$1" } transaction_builder_fail() { [[ ${TRANSACTION_IN_CRITICAL:-0} == 1 ]] || return 91 return 77 } transaction_builder_symlink() { [[ ${TRANSACTION_IN_CRITICAL:-0} == 1 ]] || return 91 rm -- "$1" ln -s -- "$WG_CONF" "$1" } run_m4_tests() { local config_file="${TEST_ROOT}/m4.conf" fixture="${TEST_ROOT}/wg0.conf" runtime_file="${TEST_ROOT}/runtime.dump" status output before unset TRANSACTION_FINALIZER TRANSACTION_ROLLBACK_ACTIVE ADD_PUBLISH_READY_FIFO ADD_PUBLISH_RELEASE_FIFO ADD_PUBLISH_PID_FILE ADD_PUBLISH_READY_INDEX ADD_PUBLISH_FAIL ADD_PUBLISH_RACE TRANSACTION_ADD_STAGE_FAIL TRANSACTION_ADD_DIRECTORY_FAIL cp -- "${PROJECT_DIR}/tests/fixtures/m3/source.conf" "$fixture" write_m2_config "$config_file" "$fixture" export WG_MANAGER_CONFIG="$config_file" WG_MANAGER_BACKUP_DIR="${TEST_ROOT}/backups" WG_MANAGER_LOG_DIR="${TEST_ROOT}/logs" WG_MANAGER_METADATA_DIR="${TEST_ROOT}/metadata" WG_MANAGER_ARCHIVE_DIR="${TEST_ROOT}/archive" WG_MANAGER_LOCK_FILE="${TEST_ROOT}/lock" WG_MANAGER_NOW=1700000100 WG_MANAGER_TEST_MODE=1 WG_MOCK_INTERFACE=wg0 printf 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\tAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\t51820\t0\nBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=\t(none)\tvpn.example.invalid:51820\t192.0.2.2/32\t0\t0\t0\t0\n' >"$runtime_file" rm -rf -- "${TEST_ROOT}/dump-sequence" mkdir -p "${TEST_ROOT}/dump-sequence" cp -- "$runtime_file" "${TEST_ROOT}/dump-sequence/2" printf 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\tAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\t51820\t0\nBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=\t(none)\tvpn.example.invalid:51820\t192.0.2.2/32\t0\t0\t0\t0\nFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF=\t(none)\tvpn.example.invalid:51820\t192.0.2.3/32\t0\t0\t0\t0\n' >"${TEST_ROOT}/dump-sequence/1" export WG_MOCK_DUMP_FILE="$runtime_file" WG_MOCK_DUMP_SEQUENCE_DIR="${TEST_ROOT}/dump-sequence" unset WG_SYNC_FAIL WG_SYNC_FAIL_ONCE_FILE WG_SYNC_FAIL_ON_CALL WG_MOCK_DUMP_FAIL_ON_CALL WG_MOCK_PRIVATE_KEY WG_MOCK_PUBLIC_KEY WG_MOCK_PSK set_runtime_paths load_config rm -rf -- "$METADATA_DIR" "$CLIENT_DIR" mkdir -p "$METADATA_DIR" "$CLIENT_DIR" case $test_group in add-invalid-names) begin_test 'invalid names are rejected before key generation' for name in '' 'bad name' '-option' '../path' 'glob*' $'line\nfeed' 'кириллица'; do set +e; output=$(add_client "$name" 2>&1); status=$?; set -e assert_equal "$EXIT_VALIDATION" "$status" "имя отклонено: ${name:-empty}" done assert_equal '' "$(grep -E 'genkey|genpsk|pubkey' "$TEST_MOCK_LOG" || true)" 'генерация не вызвана' set +e; add_client cli --mode full-tunnel --mode split-tunnel >/dev/null 2>&1; status=$?; set -e assert_equal "$EXIT_USAGE" "$status" 'повторный --mode отклонён' set +e; add_client cli --endpoint >/dev/null 2>&1; status=$?; set -e assert_equal "$EXIT_USAGE" "$status" 'отсутствующее значение endpoint отклонено' set +e; add_client cli --unknown value >/dev/null 2>&1; status=$?; set -e assert_equal "$EXIT_USAGE" "$status" 'неизвестный флаг отклонён' set +e; add_client cli --endpoint vpn.example.invalid:51820 >/dev/null 2>&1; status=$?; set -e assert_equal "$EXIT_VALIDATION" "$status" 'endpoint с портом отклонён' return ;; ''|address-allocation|add-client|add-golden-config|no-secret-logs|secret-modes) begin_test 'fresh CLI add loads configuration before defaults' local fresh_add_script="${TEST_ROOT}/fresh-add.sh" fresh_add_status printf '%s\n' '#!/usr/bin/env bash' 'set -Eeuo pipefail' "cd -- \"$PROJECT_DIR\"" './wg-manager.sh add alice' >"$fresh_add_script" chmod 700 "$fresh_add_script" set +e; /bin/bash "$fresh_add_script" >/dev/null 2>&1; fresh_add_status=$?; set -e assert_equal 0 "$fresh_add_status" 'fresh process add succeeds' assert_file_exists "${CLIENT_DIR}/alice.conf" 'fresh client config' cp -- "${PROJECT_DIR}/tests/fixtures/m3/source.conf" "$fixture" rm -rf -- "$METADATA_DIR" "$CLIENT_DIR"; mkdir -p -- "$METADATA_DIR" "$CLIENT_DIR" rm -f -- "${TEST_ROOT}/dump-sequence/counter" begin_test 'successful add creates isolated client artifacts and peer' : >"$TEST_MOCK_LOG" export WG_MOCK_EXPECTED_PRIVATE_KEY=EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE= if [[ $test_group == no-secret-logs ]]; then local trace_file="${TEST_ROOT}/add-xtrace.log" trace_fd exec {trace_fd}>"$trace_file" BASH_XTRACEFD=$trace_fd set -x set +e; output=$(add_client alice 2>&1); status=$?; set -e set +x exec {trace_fd}>&- else set +e; output=$(add_client alice 2>&1); status=$?; set -e fi unset WG_MOCK_EXPECTED_PRIVATE_KEY assert_equal 0 "$status" 'add succeeds' assert_file_exists "${CLIENT_DIR}/alice.conf" 'client config' assert_file_exists "${CLIENT_DIR}/alice.key" 'client private key' assert_file_exists "${METADATA_DIR}/alice.meta" 'client metadata' assert_file_mode 600 "${CLIENT_DIR}/alice.conf" 'config mode' assert_file_mode 600 "${CLIENT_DIR}/alice.key" 'key mode' assert_file_mode 600 "${CLIENT_DIR}/alice.pub" 'public key mode' assert_file_mode 600 "${CLIENT_DIR}/alice.psk" 'psk mode' assert_file_mode 600 "${METADATA_DIR}/alice.meta" 'metadata mode' assert_file_mode 700 "$CLIENT_DIR" 'client directory mode' assert_file_mode 700 "$METADATA_DIR" 'metadata directory mode' for stage_failure in mkdir chmod meta conf key pub psk; do export TRANSACTION_ADD_DIRECTORY_FAIL= unset TRANSACTION_ADD_STAGE_FAIL if [[ $stage_failure == mkdir || $stage_failure == chmod ]]; then export TRANSACTION_ADD_DIRECTORY_FAIL=$stage_failure; else export TRANSACTION_ADD_STAGE_FAIL=$stage_failure; fi set +e; add_client "fail-${stage_failure//./-}" >/dev/null 2>&1; status=$?; set -e assert_equal "$EXIT_OPERATION" "$status" "предтранзакционный отказ ${stage_failure}" assert_equal 0 "$(find "$CLIENT_DIR" "$METADATA_DIR" -maxdepth 1 -name '.wg-manager.add.*' | wc -l)" "нет stage после ${stage_failure}" unset TRANSACTION_ADD_DIRECTORY_FAIL TRANSACTION_ADD_STAGE_FAIL done assert_contains '192.0.2.3/32' "$(<"${CLIENT_DIR}/alice.conf")" 'allocated address' assert_contains 'PresharedKey = GGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGG=' "$(<"$fixture")" 'server peer preshared key' assert_equal '' "$(grep -E 'EEEE|FFFF|GGGG|AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=' "$TEST_MOCK_LOG" || true)" 'mock log without key material' local -a add_log_lines=(); mapfile -t add_log_lines < <(grep -E '^(flock -n [0-9]+$|wg show wg0 (public-key|dump)$|wg genkey$|wg pubkey$|wg genpsk$|wg-quick strip |wg syncconf )' "$TEST_MOCK_LOG") assert_equal 9 "${#add_log_lines[@]}" 'полный порядок add внешних вызовов' assert_equal 1 "$(grep -c '^wg show wg0 public-key$' "$TEST_MOCK_LOG" || true)" 'ровно один server public-key' assert_equal 1 "$(grep -c '^wg genkey$' "$TEST_MOCK_LOG" || true)" 'ровно один genkey' assert_equal 1 "$(grep -c '^wg pubkey$' "$TEST_MOCK_LOG" || true)" 'ровно один pubkey' assert_equal 1 "$(grep -c '^wg genpsk$' "$TEST_MOCK_LOG" || true)" 'ровно один genpsk' assert_equal 1 "$( [[ ${add_log_lines[1]:-} == 'wg show wg0 public-key' ]] && printf 1 || printf 0 )" 'exact server public-key argv' assert_equal 'wg genkey' "${add_log_lines[2]:-}" 'exact genkey argv' assert_equal 'wg pubkey' "${add_log_lines[3]:-}" 'exact pubkey argv' assert_equal 'wg genpsk' "${add_log_lines[4]:-}" 'exact genpsk argv' if [[ $test_group == no-secret-logs ]]; then assert_equal '' "$output" 'add stdout/stderr without secrets' assert_equal '' "$(grep -E 'EEEE|FFFF|GGGG|PrivateKey|PresharedKey|\[Interface\]|\[Peer\]' "$trace_file" || true)" 'xtrace без secret/config values' fi if [[ $test_group == address-allocation ]]; then rm -f -- "${CLIENT_DIR}/alice.conf" "${CLIENT_DIR}/alice.key" "${CLIENT_DIR}/alice.pub" "${CLIENT_DIR}/alice.psk" "${METADATA_DIR}/alice.meta" cp -- "${PROJECT_DIR}/tests/fixtures/m3/source.conf" "$fixture"; rm -f -- "${TEST_ROOT}/dump-sequence/counter" cat >"${METADATA_DIR}/occupied.meta" <"${TEST_ROOT}/dump-sequence/address.dump" cp -- "${TEST_ROOT}/dump-sequence/address.dump" "${TEST_ROOT}/dump-sequence/1" set +e; add_client alice >/dev/null 2>&1; local allocation_status=$?; set -e assert_equal 0 "$allocation_status" 'metadata-only address allocation succeeds' assert_contains '192.0.2.4/32' "$(<"${CLIENT_DIR}/alice.conf")" 'metadata address is skipped' rm -f -- "${CLIENT_DIR}/alice.conf" "${CLIENT_DIR}/alice.key" "${CLIENT_DIR}/alice.pub" "${CLIENT_DIR}/alice.psk" "${METADATA_DIR}/alice.meta" "${METADATA_DIR}/occupied.meta" cp -- "${PROJECT_DIR}/tests/fixtures/m3/source.conf" "$fixture"; rm -f -- "${TEST_ROOT}/dump-sequence/counter" replace_config_line "$config_file" 'USE_IPV6=' 'USE_IPV6="1"' sed 's/192\.0\.2\.4\/32/192.0.2.3\/32,2001:db8:ffff::3\/128/' "${TEST_ROOT}/dump-sequence/1" >"${TEST_ROOT}/dump-sequence/ipv6.dump" cp -- "${TEST_ROOT}/dump-sequence/ipv6.dump" "${TEST_ROOT}/dump-sequence/1" set +e; add_client alice >/dev/null 2>&1; allocation_status=$?; set -e assert_equal 0 "$allocation_status" 'ipv6 add succeeds' assert_contains '2001:db8:ffff::3/128' "$(<"${CLIENT_DIR}/alice.conf")" 'client ipv6 address' assert_contains '2001:db8:ffff::3' "$(<"${METADATA_DIR}/alice.meta")" 'metadata ipv6 address' rm -f -- "${CLIENT_DIR}/alice.conf" "${CLIENT_DIR}/alice.key" "${CLIENT_DIR}/alice.pub" "${CLIENT_DIR}/alice.psk" "${METADATA_DIR}/alice.meta" "${METADATA_DIR}/ipv6-occupied.meta" cp -- "${PROJECT_DIR}/tests/fixtures/m3/source.conf" "$fixture" replace_config_line "$config_file" 'START_HOST=' 'START_HOST="10"' replace_config_line "$config_file" 'END_HOST=' 'END_HOST="10"' replace_config_line "$config_file" 'USE_IPV6=' 'USE_IPV6="1"' sed 's/192\.0\.2\.3\/32/192.0.2.10\/32,2001:db8:ffff::a\/128/' "${TEST_ROOT}/dump-sequence/1" >"${TEST_ROOT}/dump-sequence/boundary.dump" cp -- "${TEST_ROOT}/dump-sequence/boundary.dump" "${TEST_ROOT}/dump-sequence/1" load_config; parse_wireguard_config "$fixture"; parse_metadata assert_equal '192.0.2.10/32' "$(allocate_client_ipv4)" 'boundary host 10 allocation' assert_equal '2001:db8:ffff::a/128' "$(allocate_client_ipv6 10)" 'hex host identity a' replace_config_line "$config_file" 'START_HOST=' 'START_HOST="2"' replace_config_line "$config_file" 'END_HOST=' 'END_HOST="2"' replace_config_line "$config_file" 'USE_IPV6=' 'USE_IPV6="1"' rm -f -- "${TEST_ROOT}/dump-sequence/counter" "$TEST_MOCK_LOG"; : >"$TEST_MOCK_LOG" mixed_before=$(sha256sum "$fixture"); local exhaustion_attrs; exhaustion_attrs=$(stat -c '%a:%u:%g' "$fixture") set +e; output=$(add_client exhausted 2>&1); mixed_status=$?; set -e assert_equal "$EXIT_VALIDATION" "$mixed_status" 'IPv4 pool exhaustion rejected' assert_contains 'диапазон IPv4' "$output" 'IPv4 exhaustion diagnostic' assert_equal '' "$(printf '%s' "$output" | grep -E 'EEEE|FFFF|GGGG|PrivateKey|PresharedKey' || true)" 'IPv4 exhaustion diagnostic is secret-free' assert_equal "$mixed_before" "$(sha256sum "$fixture")" 'IPv4 exhaustion preserves source' assert_equal "$exhaustion_attrs" "$(stat -c '%a:%u:%g' "$fixture")" 'IPv4 exhaustion preserves attributes' assert_equal '' "$(grep -E '^wg syncconf |^wg genkey$' "$TEST_MOCK_LOG" || true)" 'IPv4 exhaustion before mutation' for exhausted_artifact in "${METADATA_DIR}/exhausted.meta" "${CLIENT_DIR}/exhausted.conf" "${CLIENT_DIR}/exhausted.key" "${CLIENT_DIR}/exhausted.pub" "${CLIENT_DIR}/exhausted.psk"; do assert_file_absent "$exhausted_artifact" 'IPv4 exhaustion leaves final artifact absent' done assert_equal 0 "$(find "$CLIENT_DIR" "$METADATA_DIR" -maxdepth 1 \( -name '.wg-manager.add.*' -o -name '.wg-manager.key.*' \) | wc -l)" 'IPv4 exhaustion leaves no temps' replace_config_line "$config_file" 'START_HOST=' 'START_HOST="2"' replace_config_line "$config_file" 'END_HOST=' 'END_HOST="254"' replace_config_line "$config_file" 'USE_IPV6=' 'USE_IPV6="1"' rm -f -- "${CLIENT_DIR}/alice.conf" "${CLIENT_DIR}/alice.key" "${CLIENT_DIR}/alice.pub" "${CLIENT_DIR}/alice.psk" "${METADATA_DIR}/alice.meta" cp -- "${PROJECT_DIR}/tests/fixtures/m3/source.conf" "$fixture" sed -i 's/AllowedIPs = 192.0.2.2\/32/AllowedIPs = 192.0.2.2\/32, 2001:db8:ffff::3\/128/' "$fixture" rm -f -- "${TEST_ROOT}/dump-sequence/counter" "$TEST_MOCK_LOG"; : >"$TEST_MOCK_LOG" local mixed_before mixed_status mixed_before=$(sha256sum "$fixture") set +e; add_client mixed-ipv6 >/dev/null 2>&1; mixed_status=$?; set -e assert_equal "$EXIT_VALIDATION" "$mixed_status" 'mixed disk IPv6 collision rejected' assert_equal "$mixed_before" "$(sha256sum "$fixture")" 'mixed IPv6 collision preserves source' assert_equal '' "$(grep -E '^wg syncconf ' "$TEST_MOCK_LOG" || true)" 'mixed IPv6 collision no syncconf' assert_equal '' "$(grep -E '^wg genkey$' "$TEST_MOCK_LOG" || true)" 'mixed IPv6 collision before key generation' cp -- "${PROJECT_DIR}/tests/fixtures/m3/source.conf" "$fixture" cat >"${METADATA_DIR}/ipv6-occupied.meta" <"$TEST_MOCK_LOG" mixed_before=$(sha256sum "$fixture") set +e; add_client metadata-ipv6 >/dev/null 2>&1; mixed_status=$?; set -e assert_equal "$EXIT_VALIDATION" "$mixed_status" 'metadata-only IPv6 collision rejected' assert_equal "$mixed_before" "$(sha256sum "$fixture")" 'metadata IPv6 collision preserves source' assert_equal 0 "$(find "$CLIENT_DIR" "$METADATA_DIR" -maxdepth 1 -name '.wg-manager.add.*' | wc -l)" 'no artifacts after IPv6 collision' rm -f -- "${METADATA_DIR}/ipv6-occupied.meta" fi if [[ $test_group == add-golden-config ]]; then assert_equal "$(<"${PROJECT_DIR}/tests/fixtures/m4/golden-server.conf")" "$(<"$fixture")" 'server full golden bytes' assert_equal "$(<"${PROJECT_DIR}/tests/fixtures/m4/golden-client-full.txt")" "$(<"${CLIENT_DIR}/alice.conf")" 'full tunnel golden bytes' assert_equal "$(sed "s|CLIENT_DIR|$CLIENT_DIR|" "${PROJECT_DIR}/tests/fixtures/m4/golden-client.meta.txt")" "$(<"${METADATA_DIR}/alice.meta")" 'metadata golden bytes' rm -f -- "${CLIENT_DIR}/alice.conf" "${CLIENT_DIR}/alice.key" "${CLIENT_DIR}/alice.pub" "${CLIENT_DIR}/alice.psk" "${METADATA_DIR}/alice.meta" cp -- "${PROJECT_DIR}/tests/fixtures/m3/source.conf" "$fixture"; rm -f -- "${TEST_ROOT}/dump-sequence/counter" set +e; add_client alice --mode split-tunnel >/dev/null 2>&1; local split_status=$?; set -e assert_equal 0 "$split_status" 'split tunnel add succeeds' assert_equal "$(<"${PROJECT_DIR}/tests/fixtures/m4/golden-client-split.txt")" "$(<"${CLIENT_DIR}/alice.conf")" 'split tunnel golden bytes' fi return ;; add-collisions) begin_test 'name and path collisions are rejected' mkdir -p "$CLIENT_DIR"; printf 'existing\n' >"${CLIENT_DIR}/bob.conf"; chmod 600 "${CLIENT_DIR}/bob.conf" set +e; output=$(add_client bob 2>&1); status=$?; set -e assert_equal "$EXIT_VALIDATION" "$status" 'existing client path collision' assert_contains 'Добавление клиента' "$output" 'existing path diagnostic' assert_equal '' "$(printf '%s' "$output" | grep -E 'EEEE|FFFF|GGGG|PrivateKey|PresharedKey' || true)" 'existing path diagnostic is secret-free' set +e; add_client fixture >/dev/null 2>&1; status=$?; set -e assert_equal "$EXIT_VALIDATION" "$status" 'explicit marker name collision' cat >"${METADATA_DIR}/managed.meta" </dev/null 2>&1; status=$?; set -e assert_equal "$EXIT_VALIDATION" "$status" 'metadata name collision' cp -- "${PROJECT_DIR}/tests/fixtures/m3/source.conf" "$fixture" sed -i 's/# wg-manager-client: fixture/### Client legacy/' "$fixture" set +e; add_client legacy >/dev/null 2>&1; status=$?; set -e assert_equal "$EXIT_VALIDATION" "$status" 'legacy marker name collision' for artifact_case in meta conf key pub psk; do rm -rf -- "$METADATA_DIR" "$CLIENT_DIR"; mkdir -p -- "$METADATA_DIR" "$CLIENT_DIR" local artifact_path case $artifact_case in meta) artifact_path="${METADATA_DIR}/collision.meta" ;; *) artifact_path="${CLIENT_DIR}/collision.${artifact_case}" ;; esac printf 'administrator sentinel\n' >"$artifact_path"; chmod 600 "$artifact_path" set +e; output=$(add_client collision 2>&1); status=$?; set -e assert_equal "$EXIT_VALIDATION" "$status" "regular ${artifact_case} collision" assert_equal 1 "$([[ -n $output ]] && printf 1 || printf 0)" "regular ${artifact_case} diagnostic" assert_equal 'administrator sentinel' "$(<"$artifact_path")" "regular ${artifact_case} preserved" rm -f -- "$artifact_path" printf 'outside sentinel\n' >"${TEST_ROOT}/outside-${artifact_case}" ln -s -- "${TEST_ROOT}/outside-${artifact_case}" "$artifact_path" set +e; output=$(add_client collision 2>&1); status=$?; set -e assert_equal "$EXIT_VALIDATION" "$status" "symlink ${artifact_case} collision" assert_equal 1 "$([[ -n $output ]] && printf 1 || printf 0)" "symlink ${artifact_case} diagnostic" assert_equal "${TEST_ROOT}/outside-${artifact_case}" "$(readlink "$artifact_path")" "symlink ${artifact_case} preserved" rm -f -- "$artifact_path" "${TEST_ROOT}/outside-${artifact_case}" done ln -s -- "${TEST_ROOT}/outside" "${CLIENT_DIR}/symlink.conf" set +e; add_client symlink >/dev/null 2>&1; status=$?; set -e assert_equal "$EXIT_VALIDATION" "$status" 'final artifact symlink collision' export WG_MOCK_PUBLIC_KEY=BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB= set +e; add_client keycollision >/dev/null 2>&1; status=$?; set -e unset WG_MOCK_PUBLIC_KEY assert_equal "$EXIT_VALIDATION" "$status" 'generated public key collision' cat >"${METADATA_DIR}/key-only.meta" </dev/null 2>&1; status=$?; set -e unset WG_MOCK_PUBLIC_KEY assert_equal "$EXIT_VALIDATION" "$status" 'metadata-only public key collision' rm -f -- "${METADATA_DIR}/key-only.meta" for key_case in private-invalid public-invalid psk-invalid; do unset WG_MOCK_PRIVATE_KEY WG_MOCK_PUBLIC_KEY WG_MOCK_PSK case $key_case in private-invalid) export WG_MOCK_PRIVATE_KEY='not-a-key' ;; public-invalid) export WG_MOCK_PUBLIC_KEY='not-a-key' ;; psk-invalid) export WG_MOCK_PSK=$'not-a-key\nextra' ;; esac set +e; output=$(add_client "key-${key_case}" 2>&1); status=$?; set -e assert_equal "$EXIT_OPERATION" "$status" "${key_case} rejected" assert_contains 'Добавление клиента' "$output" "${key_case} diagnostic" assert_equal '' "$(printf '%s' "$output" | grep -E 'EEEE|FFFF|GGGG|PrivateKey|PresharedKey' || true)" "${key_case} diagnostic is secret-free" unset WG_MOCK_PRIVATE_KEY WG_MOCK_PUBLIC_KEY WG_MOCK_PSK done for key_case in server-public-fail server-public-multiline genkey-fail pubkey-fail genpsk-fail private-multiline public-multiline psk-multiline; do unset WG_MOCK_FAIL_SERVER_PUBLIC_KEY WG_MOCK_SERVER_PUBLIC_KEY_OUTPUT WG_MOCK_FAIL_GENKEY WG_MOCK_FAIL_PUBKEY WG_MOCK_FAIL_GENPSK WG_MOCK_PRIVATE_KEY WG_MOCK_PUBLIC_KEY WG_MOCK_PSK case $key_case in server-public-fail) export WG_MOCK_FAIL_SERVER_PUBLIC_KEY=1 ;; server-public-multiline) export WG_MOCK_SERVER_PUBLIC_KEY_OUTPUT=$'DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD=\n' ;; genkey-fail) export WG_MOCK_FAIL_GENKEY=1 ;; pubkey-fail) export WG_MOCK_FAIL_PUBKEY=1 ;; genpsk-fail) export WG_MOCK_FAIL_GENPSK=1 ;; private-multiline) export WG_MOCK_PRIVATE_KEY=$'EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE=\n' ;; public-multiline) export WG_MOCK_PUBLIC_KEY=$'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF=\n' ;; psk-multiline) export WG_MOCK_PSK=$'GGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGG=\n' ;; esac rm -f -- "${TEST_ROOT}/dump-sequence/counter" local key_before; key_before=$(sha256sum "$fixture") set +e; output=$(add_client "key-${key_case}" 2>&1); status=$?; set -e assert_equal "$EXIT_OPERATION" "$status" "${key_case} rejected" assert_contains 'Добавление клиента' "$output" "${key_case} diagnostic" assert_equal '' "$(printf '%s' "$output" | grep -E 'DDDD|EEEE|FFFF|GGGG|PrivateKey|PresharedKey' || true)" "${key_case} diagnostic is secret-free" assert_equal "$key_before" "$(sha256sum "$fixture")" "${key_case} source preserved" assert_equal '' "$(grep -E '^wg syncconf ' "$TEST_MOCK_LOG" || true)" "${key_case} no syncconf" assert_equal 0 "$(find "$CLIENT_DIR" "$METADATA_DIR" -maxdepth 1 -name '.wg-manager.add.*' | wc -l)" "no artifacts after ${key_case}" assert_equal 0 "$(find "$CLIENT_DIR" "$METADATA_DIR" -maxdepth 1 -name '.wg-manager.key.*' | wc -l)" "no key temp after ${key_case}" unset WG_MOCK_FAIL_SERVER_PUBLIC_KEY WG_MOCK_SERVER_PUBLIC_KEY_OUTPUT WG_MOCK_FAIL_GENKEY WG_MOCK_FAIL_PUBKEY WG_MOCK_FAIL_GENPSK WG_MOCK_PRIVATE_KEY WG_MOCK_PUBLIC_KEY WG_MOCK_PSK done return ;; add-rollback) begin_test 'failed apply removes newly generated artifacts' export WG_SYNC_FAIL=1 set +e; add_client alice >/dev/null 2>&1; status=$?; set -e unset WG_SYNC_FAIL assert_equal "$EXIT_ROLLBACK" "$status" 'rollback failure is distinct' assert_file_absent "${CLIENT_DIR}/alice.conf" 'config cleaned after failure' assert_file_absent "${METADATA_DIR}/alice.meta" 'metadata cleaned after failure' begin_test 'publish failure rolls back server and runtime' cp -- "${PROJECT_DIR}/tests/fixtures/m3/source.conf" "$fixture"; rm -rf -- "$METADATA_DIR" "$CLIENT_DIR"; mkdir -p -- "$METADATA_DIR" "$CLIENT_DIR"; rm -f -- "${TEST_ROOT}/dump-sequence/counter" before=$(sha256sum "$fixture") export ADD_PUBLISH_FAIL=1 set +e; output=$(add_client alice 2>&1); status=$?; set -e unset ADD_PUBLISH_FAIL assert_equal "$EXIT_OPERATION" "$status" 'ошибка публикации откатывается' assert_contains 'Добавление клиента' "$output" 'ошибка публикации сообщает причину' assert_equal '' "$(printf '%s' "$output" | grep -E 'EEEE|FFFF|GGGG|PrivateKey|PresharedKey' || true)" 'ошибка публикации без секретов' assert_equal "$before" "$(sha256sum "$fixture")" 'server restored after publish failure' assert_file_absent "${CLIENT_DIR}/alice.conf" 'publish failure client config absent' assert_file_absent "${METADATA_DIR}/alice.meta" 'publish failure metadata absent' begin_test 'publish race preserves administrator target' rm -f -- "${TEST_ROOT}/dump-sequence/counter"; export ADD_PUBLISH_RACE=1 set +e; add_client alice >/dev/null 2>&1; status=$?; set -e unset ADD_PUBLISH_RACE assert_equal "$EXIT_OPERATION" "$status" 'publish race status' assert_equal 'administrator collision' "$(<"${METADATA_DIR}/alice.meta")" 'administrator target preserved' rm -f -- "${METADATA_DIR}/alice.meta" begin_test 'publish rollback failure is critical' rm -f -- "${TEST_ROOT}/dump-sequence/counter"; export ADD_PUBLISH_FAIL=1 TRANSACTION_ROLLBACK_REPLACE_FAIL=1 set +e; output=$(add_client alice 2>&1); status=$?; set -e unset ADD_PUBLISH_FAIL TRANSACTION_ROLLBACK_REPLACE_FAIL assert_equal "$EXIT_ROLLBACK" "$status" 'publish rollback failure status' assert_contains 'КРИТИЧНО' "$output" 'publish rollback critical' local publish_env_path; publish_env_path=$(command -v env) for publish_case in 'INT:0:operation' 'TERM:2:operation' 'INT:4:operation' 'TERM:4:operation' 'INT:0:rollback'; do IFS=: read -r publish_signal publish_index publish_outcome <<<"$publish_case" begin_test "${publish_signal} during publish index ${publish_index} is rolled back" cp -- "${PROJECT_DIR}/tests/fixtures/m3/source.conf" "$fixture" rm -rf -- "$METADATA_DIR" "$CLIENT_DIR"; mkdir -p -- "$METADATA_DIR" "$CLIENT_DIR" rm -f -- "${TEST_ROOT}/dump-sequence/counter" "$TEST_MOCK_LOG" : >"$TEST_MOCK_LOG" local ready_fifo="${TEST_ROOT}/publish-${publish_signal}-${publish_index}-${publish_outcome}.ready" local release_fifo="${TEST_ROOT}/publish-${publish_signal}-${publish_index}-${publish_outcome}.release" local pid_file="${TEST_ROOT}/publish-${publish_signal}-${publish_index}-${publish_outcome}.pid" local publish_script="${TEST_ROOT}/publish-${publish_signal}-${publish_index}-${publish_outcome}.sh" local cli_pid transaction_pid publish_status publish_before publish_attrs ready release_fd ready_fd tail_status rm -f -- "$ready_fifo" "$release_fifo" "$pid_file" mkfifo "$ready_fifo" "$release_fifo" exec {ready_fd}<>"$ready_fifo" exec {release_fd}<>"$release_fifo" publish_before=$(sha256sum "$fixture"); publish_attrs=$(stat -c '%a:%u:%g' "$fixture") printf '%s\n' '#!/usr/bin/env bash' 'set -Eeuo pipefail' 'source "$1"' 'set +e' 'add_client alice' 'status=$?' 'exit "$status"' >"$publish_script" chmod 700 "$publish_script" export ADD_PUBLISH_READY_FIFO=$ready_fifo ADD_PUBLISH_RELEASE_FIFO=$release_fifo ADD_PUBLISH_PID_FILE=$pid_file ADD_PUBLISH_READY_INDEX=$publish_index if [[ $publish_outcome == rollback ]]; then export TRANSACTION_ROLLBACK_REPLACE_FAIL=1; else unset TRANSACTION_ROLLBACK_REPLACE_FAIL; fi "$publish_env_path" --default-signal=INT --default-signal=TERM WG_MANAGER_CONFIG="$config_file" WG_MANAGER_BACKUP_DIR="$BACKUP_DIR" WG_MANAGER_METADATA_DIR="$METADATA_DIR" WG_MANAGER_LOCK_FILE="$LOCK_FILE" ADD_PUBLISH_READY_FIFO="$ready_fifo" ADD_PUBLISH_RELEASE_FIFO="$release_fifo" ADD_PUBLISH_PID_FILE="$pid_file" ADD_PUBLISH_READY_INDEX="$publish_index" TRANSACTION_ROLLBACK_REPLACE_FAIL="${TRANSACTION_ROLLBACK_REPLACE_FAIL:-0}" /bin/bash "$publish_script" "$PROJECT_DIR/wg-manager.sh" >"${publish_script}.out" 2>&1 & cli_pid=$! if IFS= read -r -t 10 -u "$ready_fd" ready && [[ $ready == ready ]] && IFS= read -r -t 10 publish_pid <"$pid_file" && [[ $publish_pid =~ ^[0-9]+$ ]]; then transaction_pid=$publish_pid kill -s "$publish_signal" -- "$transaction_pid" printf 'release\n' >&"$release_fd" set +e timeout 10 tail --pid="$cli_pid" -f /dev/null >/dev/null 2>&1; tail_status=$? wait "$cli_pid"; publish_status=$? set -e if [[ $publish_outcome == rollback ]]; then assert_equal "$EXIT_ROLLBACK" "$publish_status" "${publish_signal} publish rollback failure status" assert_contains 'КРИТИЧНО' "$(<"${publish_script}.out")" 'publish rollback failure diagnostic' else assert_equal "$EXIT_OPERATION" "$publish_status" "${publish_signal} publish status" assert_contains "сигналом ${publish_signal}" "$(<"${publish_script}.out")" "${publish_signal} diagnostic" fi assert_equal 0 "$tail_status" "${publish_signal} child completed bounded" if [[ $publish_outcome == operation ]]; then assert_equal "$publish_before" "$(sha256sum "$fixture")" "${publish_signal} source preserved" assert_equal "$publish_attrs" "$(stat -c '%a:%u:%g' "$fixture")" "${publish_signal} attributes preserved" assert_equal 2 "$(grep -c '^wg syncconf wg0 ' "$TEST_MOCK_LOG" || true)" "${publish_signal} syncconf rollback pair" assert_equal 2 "$(grep -c '^wg show wg0 dump$' "$TEST_MOCK_LOG" || true)" "${publish_signal} dump rollback pair" fi for publish_artifact in "${CLIENT_DIR}/alice.conf" "${CLIENT_DIR}/alice.key" "${CLIENT_DIR}/alice.pub" "${CLIENT_DIR}/alice.psk" "${METADATA_DIR}/alice.meta"; do assert_file_absent "$publish_artifact" "${publish_signal} owned artifact cleanup"; done assert_equal 0 "$(find "$(dirname "$fixture")" -maxdepth 1 -name '.wg-manager.add.*' | wc -l)" "${publish_signal} add stages cleanup" unset ADD_PUBLISH_READY_FIFO ADD_PUBLISH_RELEASE_FIFO ADD_PUBLISH_PID_FILE ADD_PUBLISH_READY_INDEX TRANSACTION_ROLLBACK_REPLACE_FAIL rm -f -- "${TEST_ROOT}/dump-sequence/counter" "$TEST_MOCK_LOG" if [[ $publish_outcome == operation ]]; then set +e; add_client after_signal >/dev/null 2>&1; status=$?; set -e assert_equal 0 "$status" "${publish_signal} lock reusable" rm -f -- "${CLIENT_DIR}/after_signal.conf" "${CLIENT_DIR}/after_signal.key" "${CLIENT_DIR}/after_signal.pub" "${CLIENT_DIR}/after_signal.psk" "${METADATA_DIR}/after_signal.meta" fi else kill -TERM "$cli_pid" 2>/dev/null || : printf 'release\n' >&"$release_fd" || : set +e; wait "$cli_pid"; set -e record_failure "${publish_signal} publish synchronization failed" fi exec {ready_fd}>&- exec {release_fd}>&- rm -f -- "$ready_fifo" "$release_fifo" "$pid_file" "$publish_script" "${publish_script}.out" done return ;; esac } run_m5_tests() { local config_file="${TEST_ROOT}/m5.conf" fixture="${TEST_ROOT}/wg0.conf" runtime_file="${TEST_ROOT}/runtime.dump" status output expected qr_path mode cp -- "${PROJECT_DIR}/tests/fixtures/m3/source.conf" "$fixture" write_m2_config "$config_file" "$fixture" export WG_MANAGER_CONFIG="$config_file" WG_MANAGER_BACKUP_DIR="${TEST_ROOT}/backups" WG_MANAGER_LOG_DIR="${TEST_ROOT}/logs" WG_MANAGER_METADATA_DIR="${TEST_ROOT}/metadata" WG_MANAGER_ARCHIVE_DIR="${TEST_ROOT}/archive" WG_MANAGER_LOCK_FILE="${TEST_ROOT}/lock" WG_MANAGER_TEST_MODE=1 WG_MOCK_INTERFACE=wg0 printf 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\tAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\t51820\t0\nBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=\t(none)\tvpn.example.invalid:51820\t192.0.2.2/32\t0\t0\t0\t0\nFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF=\t(none)\tvpn.example.invalid:51820\t192.0.2.3/32\t0\t0\t0\t0\n' >"$runtime_file" rm -rf -- "${TEST_ROOT}/dump-sequence"; mkdir -p "${TEST_ROOT}/dump-sequence"; cp -- "$runtime_file" "${TEST_ROOT}/dump-sequence/1"; cp -- "${PROJECT_DIR}/tests/fixtures/m3/runtime.dump" "${TEST_ROOT}/dump-sequence/2" export WG_MOCK_DUMP_FILE="$runtime_file" WG_MOCK_DUMP_SEQUENCE_DIR="${TEST_ROOT}/dump-sequence" unset WG_SYNC_FAIL WG_SYNC_FAIL_ONCE_FILE WG_SYNC_FAIL_ON_CALL WG_SYNC_FAIL_COUNTER_FILE WG_QUICK_STRIP_FAIL QR_MOCK_FAIL TRANSACTION_ADD_STAGE_FAIL TRANSACTION_ADD_DIRECTORY_FAIL ADD_PUBLISH_FAIL ADD_PUBLISH_RACE set_runtime_paths; load_config; rm -rf -- "$METADATA_DIR" "$CLIENT_DIR"; mkdir -p "$METADATA_DIR" "$CLIENT_DIR" add_client alice >/dev/null : >"$TEST_MOCK_LOG" case $test_group in ''|show-client|no-secret-default-output) begin_test 'show exposes only non-secret managed details' set +e; output=$(show_client alice 2>&1); status=$?; set -e assert_equal 0 "$status" 'show succeeds' assert_contains 'Имя: alice' "$output" 'show name' assert_contains 'Путь конфигурации:' "$output" 'show validated path' assert_equal '' "$(grep -E 'PrivateKey|PresharedKey|EEEE|GGGG' <<<"$output" || true)" 'show without secrets' local cli_stdout="${TEST_ROOT}/cli.stdout" cli_stderr="${TEST_ROOT}/cli.stderr" set +e; "${PROJECT_DIR}/wg-manager.sh" show alice >"$cli_stdout" 2>"$cli_stderr"; status=$?; set -e assert_equal 0 "$status" 'CLI show succeeds' assert_contains 'Имя: alice' "$(<"$cli_stdout")" 'CLI show name' assert_contains 'Публичный ключ:' "$(<"$cli_stdout")" 'CLI show public key' assert_contains 'Адреса:' "$(<"$cli_stdout")" 'CLI show addresses' assert_contains 'Endpoint:' "$(<"$cli_stdout")" 'CLI show endpoint' assert_contains 'Последний handshake:' "$(<"$cli_stdout")" 'CLI show handshake' assert_contains 'RX:' "$(<"$cli_stdout")" 'CLI show RX' assert_contains 'TX:' "$(<"$cli_stdout")" 'CLI show TX' assert_contains 'Режим:' "$(<"$cli_stdout")" 'CLI show mode' assert_contains 'Создан:' "$(<"$cli_stdout")" 'CLI show creation time' assert_contains 'Происхождение: managed' "$(<"$cli_stdout")" 'CLI show origin' assert_equal '' "$(<"$cli_stderr")" 'CLI show stderr clean' assert_equal '' "$(grep -E 'PrivateKey|PresharedKey|EEEE|GGGG' "$cli_stdout" || true)" 'CLI show secret-free' assert_equal '' "$(grep -F $'\033' "$cli_stdout" || true)" 'CLI show has no ANSI' for cli_args in 'show' 'config' 'qr'; do set +e; "${PROJECT_DIR}/wg-manager.sh" $cli_args >"$cli_stdout" 2>"$cli_stderr"; status=$?; set -e assert_equal "$EXIT_USAGE" "$status" "CLI ${cli_args} missing name" done set +e; "${PROJECT_DIR}/wg-manager.sh" show absent >/dev/null 2>"$cli_stderr"; status=$?; set -e assert_equal "$EXIT_VALIDATION" "$status" 'CLI missing client name validation' set +e; "${PROJECT_DIR}/wg-manager.sh" show alice extra >/dev/null 2>&1; status=$?; set -e assert_equal "$EXIT_USAGE" "$status" 'CLI show extra argument' set +e; "${PROJECT_DIR}/wg-manager.sh" qr alice extra >/dev/null 2>&1; status=$?; set -e assert_equal "$EXIT_USAGE" "$status" 'CLI qr extra argument' set +e; "${PROJECT_DIR}/wg-manager.sh" config alice --show --show >/dev/null 2>&1; status=$?; set -e assert_equal "$EXIT_USAGE" "$status" 'CLI duplicate config show' set +e; "${PROJECT_DIR}/wg-manager.sh" show fixture >"$cli_stdout" 2>"$cli_stderr"; status=$?; set -e assert_equal 0 "$status" 'CLI show legacy peer succeeds' assert_contains 'Происхождение: legacy' "$(<"$cli_stdout")" 'legacy origin shown' assert_equal '' "$(grep -F 'Путь конфигурации:' "$cli_stdout" || true)" 'legacy has no config path' set +e; "${PROJECT_DIR}/wg-manager.sh" config fixture >/dev/null 2>"$cli_stderr"; status=$?; set -e assert_equal "$EXIT_VALIDATION" "$status" 'legacy config export rejected' set +e; "${PROJECT_DIR}/wg-manager.sh" qr fixture >/dev/null 2>"$cli_stderr"; status=$?; set -e assert_equal "$EXIT_VALIDATION" "$status" 'legacy QR rejected' begin_test 'ambiguous legacy and mismatched identities never grant access' cp -- "${PROJECT_DIR}/tests/fixtures/m3/source.conf" "$fixture" cat >>"$fixture" <<'EOF' [Peer] ### Client fixture PublicKey = FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF= AllowedIPs = 192.0.2.3/32 EOF printf 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\tAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\t51820\t0\nBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=\t(none)\tvpn.example.invalid:51820\t192.0.2.2/32\t0\t0\t0\t0\nFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF=\t(none)\tvpn.example.invalid:51820\t192.0.2.3/32\t0\t0\t0\t0\n' >"$runtime_file" unset WG_MOCK_DUMP_SEQUENCE_DIR export WG_MOCK_DUMP_FILE="$runtime_file" rm -f -- "${METADATA_DIR}/alice.meta" set +e; "${PROJECT_DIR}/wg-manager.sh" show fixture >/dev/null 2>"$cli_stderr"; status=$?; set -e assert_equal "$EXIT_VALIDATION" "$status" 'duplicate legacy name is ambiguous' assert_equal '' "$(grep -E 'EEEE|GGGG|PrivateKey|PresharedKey|sentinel' "$cli_stderr" || true)" 'ambiguous identity diagnostic is secret-free' write_alice_metadata replace_config_line "${METADATA_DIR}/alice.meta" 'IPV4=' 'IPV4=192.0.2.99' set +e; "${PROJECT_DIR}/wg-manager.sh" show alice >/dev/null 2>"$cli_stderr"; status=$?; set -e assert_equal "$EXIT_VALIDATION" "$status" 'mismatched metadata address is rejected' set +e; "${PROJECT_DIR}/wg-manager.sh" config alice >/dev/null 2>"$cli_stderr"; status=$?; set -e assert_equal "$EXIT_VALIDATION" "$status" 'mismatched metadata config is rejected' write_alice_metadata replace_config_line "${METADATA_DIR}/alice.meta" 'PUBLIC_KEY=' 'PUBLIC_KEY=HHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHH=' set +e; "${PROJECT_DIR}/wg-manager.sh" qr alice >/dev/null 2>"$cli_stderr"; status=$?; set -e assert_equal "$EXIT_VALIDATION" "$status" 'mismatched metadata key QR is rejected' cp -- "${PROJECT_DIR}/tests/fixtures/m3/source.conf" "$fixture" write_alice_metadata replace_config_line "${METADATA_DIR}/alice.meta" 'PUBLIC_KEY=' 'PUBLIC_KEY=FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF=' replace_config_line "${METADATA_DIR}/alice.meta" 'IPV4=' 'IPV4=192.0.2.3' printf 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\tAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\t51820\t0\nBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=\t(none)\tvpn.example.invalid:51820\t192.0.2.2/32\t0\t0\t0\t0\nFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF=\t(none)\tvpn.example.invalid:51820\t192.0.2.3/32\t0\t0\t0\t0\n' >"$runtime_file" set +e; "${PROJECT_DIR}/wg-manager.sh" show alice >/dev/null 2>"$cli_stderr"; status=$?; set -e assert_equal "$EXIT_VALIDATION" "$status" 'runtime-only peer cannot use metadata path' set +e; "${PROJECT_DIR}/wg-manager.sh" config alice >/dev/null 2>"$cli_stderr"; status=$?; set -e assert_equal "$EXIT_VALIDATION" "$status" 'runtime-only peer config is rejected' set +e; "${PROJECT_DIR}/wg-manager.sh" qr alice >/dev/null 2>"$cli_stderr"; status=$?; set -e assert_equal "$EXIT_VALIDATION" "$status" 'runtime-only peer QR is rejected' set -e if [[ $- == *e* ]]; then runtime_errexit=1; else runtime_errexit=0; fi assert_equal 1 "$runtime_errexit" 'errexit enabled before runtime load' load_runtime_state if [[ $- == *e* ]]; then runtime_errexit=1; else runtime_errexit=0; fi assert_equal 1 "$runtime_errexit" 'errexit preserved after enabled runtime load' set +e if [[ $- == *e* ]]; then runtime_errexit=1; else runtime_errexit=0; fi assert_equal 0 "$runtime_errexit" 'errexit disabled before runtime load' load_runtime_state if [[ $- == *e* ]]; then runtime_errexit=1; else runtime_errexit=0; fi assert_equal 0 "$runtime_errexit" 'errexit preserved after disabled runtime load' set -e write_alice_metadata rm -f -- "${CLIENT_DIR}/alice.conf" set +e; output=$(show_client alice 2>&1); status=$?; set -e assert_equal 0 "$status" 'show tolerates unavailable client config' assert_contains '(недоступен)' "$output" 'show marks unavailable config' if [[ $test_group == no-secret-default-output ]]; then assert_equal '' "$(grep -E 'PrivateKey|PresharedKey|EEEE|GGGG' "$TEST_MOCK_LOG" || true)" 'default mock log without secrets'; fi return ;; config-export) begin_test 'config prints path by default and content only with --show' expected="${CLIENT_DIR}/alice.conf" output=$(export_client_config alice) assert_equal "$expected" "$output" 'default config export path' local cli_path_bin="${TEST_ROOT}/config-path" cli_out="${TEST_ROOT}/config.stdout" cli_err="${TEST_ROOT}/config.stderr" mkdir -p -- "$cli_path_bin" for utility in wg cat stat sort paste date; do ln -s -- "$(command -v "$utility")" "$cli_path_bin/$utility"; done set +e; PATH="$cli_path_bin" /bin/bash "${PROJECT_DIR}/wg-manager.sh" config alice >"$cli_out" 2>"$cli_err"; status=$?; set -e assert_equal 0 "$status" 'fresh CLI config succeeds without gum or qrencode' assert_equal "$expected" "$(<"$cli_out")" 'fresh CLI config prints one exact path' assert_equal '' "$(<"$cli_err")" 'fresh CLI config stderr is empty' assert_equal '' "$(grep -F $'\033' "$cli_out" || true)" 'fresh CLI config has no ANSI' set +e; PATH="$cli_path_bin" /bin/bash "${PROJECT_DIR}/wg-manager.sh" config alice --show >"$cli_out" 2>"$cli_err"; status=$?; set -e assert_equal 0 "$status" 'fresh CLI explicit config succeeds' assert_equal "$(<"${PROJECT_DIR}/tests/fixtures/m5/managed-client.conf")" "$(<"$cli_out")" 'fresh CLI explicit config exact bytes' assert_equal '' "$(<"$cli_err")" 'fresh CLI explicit config stderr is empty' set +e; PATH="$cli_path_bin" /bin/bash "${PROJECT_DIR}/wg-manager.sh" config alice --bad >"$cli_out" 2>"$cli_err"; status=$?; set -e assert_equal "$EXIT_USAGE" "$status" 'fresh CLI unknown config flag rejected' set +e; output=$(export_client_config alice 1); status=$?; set -e assert_equal 0 "$status" 'explicit full export' assert_equal "$(<"${PROJECT_DIR}/tests/fixtures/m5/managed-client.conf")" "$output" 'explicit config content matches fixture' set +e; export_client_config alice 2 >/dev/null 2>&1; status=$?; set -e assert_equal "$EXIT_USAGE" "$status" 'invalid export mode rejected' rm -f -- "${CLIENT_DIR}/alice.conf" set +e; output=$(export_client_config alice 1 2>&1); status=$?; set -e assert_equal "$EXIT_VALIDATION" "$status" 'missing export config rejected' assert_contains 'Файл конфигурации клиента' "$output" 'missing export diagnostic' printf 'placeholder\n' >"${CLIENT_DIR}/alice.conf"; chmod 600 "${CLIENT_DIR}/alice.conf" open_validated_client_config() { return "$EXIT_OPERATION"; } set +e; output=$(export_client_config alice 1 2>&1); status=$?; set -e assert_equal "$EXIT_OPERATION" "$status" 'export open failure status preserved' assert_contains 'безопасно открыть' "$output" 'export open failure diagnostic' return ;; qr) begin_test 'qr uses exact argv and stdin without logging config' export QR_MOCK_EXPECTED_FILE="${CLIENT_DIR}/alice.conf" : >"$TEST_MOCK_LOG" output=$(qr_client alice) assert_contains 'MOCK-QR' "$output" 'mock QR output' assert_equal 1 "$(grep -c '^qrencode -t ansiutf8$' "$TEST_MOCK_LOG" || true)" 'exact QR invocation count' assert_equal 'qrencode -t ansiutf8' "$(tail -n 1 "$TEST_MOCK_LOG")" 'exact QR argv' assert_equal '' "$(grep -E 'PrivateKey|PresharedKey|EEEE|GGGG' "$TEST_MOCK_LOG" || true)" 'QR log without secrets' export QR_MOCK_FAIL=1 set +e; qr_client alice >"${TEST_ROOT}/qr-fail.stdout" 2>"${TEST_ROOT}/qr-fail.stderr"; status=$?; set -e unset QR_MOCK_FAIL assert_equal "$EXIT_OPERATION" "$status" 'QR failure status' assert_equal '' "$(<"${TEST_ROOT}/qr-fail.stdout")" 'QR failure stdout is empty' assert_equal '' "$(grep -E 'PrivateKey|PresharedKey|EEEE|GGGG|\[Interface\]|\[Peer\]' "${TEST_ROOT}/qr-fail.stderr" || true)" 'QR failure diagnostic is secret-free' local saved_path=$PATH mkdir -p -- "${TEST_ROOT}/empty-path" ln -s -- "${PROJECT_DIR}/tests/mocks/bin/wg" "${TEST_ROOT}/empty-path/wg" ln -s -- "$(command -v cat)" "${TEST_ROOT}/empty-path/cat" ln -s -- "$(command -v stat)" "${TEST_ROOT}/empty-path/stat" ln -s -- "$(command -v sort)" "${TEST_ROOT}/empty-path/sort" ln -s -- "$(command -v paste)" "${TEST_ROOT}/empty-path/paste" ln -s -- "$(command -v date)" "${TEST_ROOT}/empty-path/date" PATH="${TEST_ROOT}/empty-path" set +e; qr_client alice >"${TEST_ROOT}/qr-missing.stdout" 2>"${TEST_ROOT}/qr-missing.stderr"; status=$?; set -e PATH=$saved_path assert_equal "$EXIT_DEPENDENCY" "$status" 'missing qrencode dependency status' assert_equal "${CLIENT_DIR}/alice.conf" "$(<"${TEST_ROOT}/qr-missing.stdout")" 'missing qrencode safe path only on stdout' assert_contains 'qrencode не найден' "$(<"${TEST_ROOT}/qr-missing.stderr")" 'missing qrencode diagnostic on stderr' assert_equal '' "$(grep -E 'PrivateKey|PresharedKey|EEEE|GGGG' "${TEST_ROOT}/qr-missing.stderr" || true)" 'missing QR diagnostic is secret-free' open_validated_client_config() { return "$EXIT_VALIDATION"; } set +e; output=$(qr_client alice 2>&1); status=$?; set -e assert_equal "$EXIT_VALIDATION" "$status" 'QR identity failure status preserved' assert_contains 'безопасно открыть' "$output" 'QR identity failure diagnostic' unset QR_MOCK_EXPECTED_FILE return ;; path-safety) begin_test 'unsafe or permissive config paths are rejected' cp -- "${METADATA_DIR}/alice.meta" "${TEST_ROOT}/alice-meta.base" cp -- "${TEST_ROOT}/alice-meta.base" "${METADATA_DIR}/alice.meta" printf 'NAME=alice\nPUBLIC_KEY=BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=\nIPV4=192.0.2.2\nCREATED_AT=1700000000\nCONFIG_PATH=%s\nconf\nCLIENT_MODE=full-tunnel\nMANAGED=1\n' "$CLIENT_DIR/alice" >"${METADATA_DIR}/alice.meta" set +e; output=$(export_client_config alice 2>&1); status=$?; set -e assert_equal "$EXIT_VALIDATION" "$status" 'metadata embedded newline/control record rejected' assert_equal '' "$(printf '%s' "$output" | grep -E 'sentinel|PrivateKey|PresharedKey|BBBB|EEEE|GGGG' || true)" 'newline metadata diagnostic is secret-free' cp -- "${TEST_ROOT}/alice-meta.base" "${METADATA_DIR}/alice.meta" local real_stat real_stat=$(command -v stat) stat() { if [[ ${2:-} == '%u:%g' && ${4:-} == "${METADATA_DIR}/alice.meta" ]]; then printf '999:999\n' return 0 fi "$real_stat" "$@" } set +e; output=$(export_client_config alice 2>&1); status=$?; set -e unset -f stat assert_equal "$EXIT_VALIDATION" "$status" 'metadata owner mismatch is rejected' assert_contains 'Владелец' "$output" 'metadata owner mismatch diagnostic' assert_equal '' "$(printf '%s' "$output" | grep -E 'PrivateKey|PresharedKey|BBBB|EEEE|GGGG' || true)" 'owner mismatch diagnostic is secret-free' real_stat=$(command -v stat) stat() { if [[ ${2:-} == '%a' && ${4:-} == "${CLIENT_DIR}/alice.conf" ]]; then return 1 fi "$real_stat" "$@" } set +e; output=$(export_client_config alice 2>&1); status=$?; set -e unset -f stat assert_equal "$EXIT_OPERATION" "$status" 'config stat failure is operation error' assert_contains 'режим конфигурации клиента' "$output" 'config stat failure diagnostic' assert_equal '' "$(printf '%s' "$output" | grep -E 'PrivateKey|PresharedKey|BBBB|EEEE|GGGG' || true)" 'config stat diagnostic is secret-free' real_stat=$(command -v stat) stat() { if [[ ${2:-} == '%a' && ${4:-} == "${METADATA_DIR}/alice.meta" ]]; then return 1 fi "$real_stat" "$@" } set +e; output=$(export_client_config alice 2>&1); status=$?; set -e unset -f stat assert_equal "$EXIT_OPERATION" "$status" 'metadata stat failure is operation error' assert_contains 'режим метаданных клиента' "$output" 'metadata stat failure diagnostic' assert_equal '' "$(printf '%s' "$output" | grep -E 'PrivateKey|PresharedKey|BBBB|EEEE|GGGG' || true)" 'metadata stat diagnostic is secret-free' cp -- "${TEST_ROOT}/alice-meta.base" "${METADATA_DIR}/alice.meta" for unsafe_path in "${CLIENT_DIR}/nested/alice.conf" "${CLIENT_DIR}/alice file.conf" "${CLIENT_DIR}/-alice.conf" "${CLIENT_DIR}/alice\$'\\n'conf"; do cp -- "${TEST_ROOT}/alice-meta.base" "${METADATA_DIR}/alice.meta" replace_config_line "${METADATA_DIR}/alice.meta" 'CONFIG_PATH=' "CONFIG_PATH=${unsafe_path}" set +e; output=$(export_client_config alice 2>&1); status=$?; set -e assert_equal "$EXIT_VALIDATION" "$status" "unsafe path rejected: ${unsafe_path@Q}" assert_equal '' "$(printf '%s' "$output" | grep -E 'sentinel|PrivateKey|PresharedKey|EEEE|GGGG' || true)" 'unsafe path diagnostic is secret-free' done cp -- "${TEST_ROOT}/alice-meta.base" "${METADATA_DIR}/alice.meta" mkdir -p -- "${CLIENT_DIR}/nested" printf 'sentinel\n' >"${CLIENT_DIR}/nested/alice.conf" replace_config_line "${METADATA_DIR}/alice.meta" 'CONFIG_PATH=' "CONFIG_PATH=${CLIENT_DIR}/nested/alice.conf" set +e; export_client_config alice >/dev/null 2>&1; status=$?; set -e assert_equal "$EXIT_VALIDATION" "$status" 'nested client path rejected' rm -f -- "${CLIENT_DIR}/nested/alice.conf"; rmdir -- "${CLIENT_DIR}/nested" cp -- "${TEST_ROOT}/alice-meta.base" "${METADATA_DIR}/alice.meta" rm -- "${CLIENT_DIR}/alice.conf"; mkdir -- "${CLIENT_DIR}/alice.conf" set +e; export_client_config alice >/dev/null 2>&1; status=$?; set -e assert_equal "$EXIT_VALIDATION" "$status" 'directory client target rejected' rmdir -- "${CLIENT_DIR}/alice.conf" printf 'placeholder\n' >"${CLIENT_DIR}/alice.conf"; chmod 600 "${CLIENT_DIR}/alice.conf" cp -- "${TEST_ROOT}/alice-meta.base" "${METADATA_DIR}/alice.meta" mv -- "$CLIENT_DIR" "${TEST_ROOT}/clients-real" ln -s -- "${TEST_ROOT}/clients-real" "$CLIENT_DIR" set +e; output=$(export_client_config alice 2>&1); status=$?; set -e assert_equal "$EXIT_VALIDATION" "$status" 'symlink client directory rejected' rm -- "$CLIENT_DIR"; mv -- "${TEST_ROOT}/clients-real" "$CLIENT_DIR" mv -- "$METADATA_DIR" "${TEST_ROOT}/metadata-real" ln -s -- "${TEST_ROOT}/metadata-real" "$METADATA_DIR" set +e; output=$(export_client_config alice 2>&1); status=$?; set -e assert_equal "$EXIT_VALIDATION" "$status" 'symlink metadata directory rejected' rm -- "$METADATA_DIR"; mv -- "${TEST_ROOT}/metadata-real" "$METADATA_DIR" chmod 755 "$CLIENT_DIR" "$METADATA_DIR" set +e; output=$(export_client_config alice 2>&1); status=$?; set -e assert_equal "$EXIT_VALIDATION" "$status" 'permissive client directories rejected' chmod 700 "$CLIENT_DIR" "$METADATA_DIR" replace_config_line "${METADATA_DIR}/alice.meta" 'CONFIG_PATH=' 'CONFIG_PATH="${TEST_ROOT}/outside.conf"' set +e; config_path=$(export_client_config alice 2>&1); status=$?; set -e assert_equal "$EXIT_VALIDATION" "$status" 'outside path rejected' sed -i "s#CONFIG_PATH=.*#CONFIG_PATH=${CLIENT_DIR}/alice.conf#" "${METADATA_DIR}/alice.meta" chmod 644 "${CLIENT_DIR}/alice.conf" set +e; export_client_config alice >/dev/null 2>&1; status=$?; set -e assert_equal "$EXIT_VALIDATION" "$status" 'permissive config rejected' chmod 600 "${CLIENT_DIR}/alice.conf" chmod 644 "${METADATA_DIR}/alice.meta" set +e; export_client_config alice >/dev/null 2>&1; status=$?; set -e assert_equal "$EXIT_VALIDATION" "$status" 'permissive metadata rejected' chmod 600 "${METADATA_DIR}/alice.meta" ln -- "${METADATA_DIR}/alice.meta" "${TEST_ROOT}/alice-meta-hardlink" set +e; export_client_config alice >/dev/null 2>&1; status=$?; set -e assert_equal "$EXIT_VALIDATION" "$status" 'hardlinked metadata rejected' rm -f -- "${TEST_ROOT}/alice-meta-hardlink" ln -- "${CLIENT_DIR}/alice.conf" "${TEST_ROOT}/alice-hardlink.conf" set +e; export_client_config alice >/dev/null 2>&1; status=$?; set -e assert_equal "$EXIT_VALIDATION" "$status" 'hardlinked config rejected' rm -f -- "${TEST_ROOT}/alice-hardlink.conf" mv -- "${CLIENT_DIR}/alice.conf" "${CLIENT_DIR}/alice-real.conf" ln -s -- "${CLIENT_DIR}/alice-real.conf" "${CLIENT_DIR}/alice.conf" set +e; export_client_config alice >/dev/null 2>&1; status=$?; set -e assert_equal "$EXIT_VALIDATION" "$status" 'symlink config rejected' rm -- "${CLIENT_DIR}/alice.conf" mv -- "${CLIENT_DIR}/alice-real.conf" "${CLIENT_DIR}/alice.conf" printf 'sentinel\n' >"${TEST_ROOT}/outside-meta" mv -- "${METADATA_DIR}/alice.meta" "${METADATA_DIR}/alice.real.meta" rm -f -- "${METADATA_DIR}/alice.real.meta" ln -s -- "${TEST_ROOT}/outside-meta" "${METADATA_DIR}/alice.meta" set +e; export_client_config alice >/dev/null 2>&1; status=$?; set -e assert_equal "$EXIT_VALIDATION" "$status" 'symlink metadata rejected' return ;; esac } run_m3_tests() { local config_file="${TEST_ROOT}/m3.conf" fixture="${TEST_ROOT}/wg0.conf" runtime_file="${TEST_ROOT}/runtime.dump" status output before after backup_count manifest_content parent_exit parent_int parent_term cp -- "${PROJECT_DIR}/tests/fixtures/m2/managed.conf" "$fixture" cp -- "${PROJECT_DIR}/tests/fixtures/m2/runtime.dump" "$runtime_file" write_m2_config "$config_file" "$fixture" export WG_MANAGER_CONFIG="$config_file" WG_MANAGER_BACKUP_DIR="${TEST_ROOT}/backups" WG_MANAGER_LOG_DIR="${TEST_ROOT}/logs" WG_MANAGER_METADATA_DIR="${TEST_ROOT}/metadata" WG_MANAGER_ARCHIVE_DIR="${TEST_ROOT}/archive" WG_MANAGER_LOCK_FILE="${TEST_ROOT}/lock" WG_MANAGER_NOW=1700000100 WG_MANAGER_TEST_MODE=1 WG_MOCK_DUMP_FILE="$runtime_file" WG_MOCK_INTERFACE=wg0 unset WG_SYNC_FAIL WG_SYNC_FAIL_ONCE_FILE WG_SYNC_FAIL_ON_CALL WG_QUICK_STRIP_FAIL TRANSACTION_METADATA_COPY_FAIL TRANSACTION_BACKUP_FAIL TRANSACTION_BACKUP_DIR_CHMOD_FAIL TRANSACTION_BACKUP_MANIFEST_WRITE_FAIL TRANSACTION_BACKUP_MANIFEST_CHMOD_FAIL TRANSACTION_ATOMIC_REPLACE_FAIL MOCK_FLOCK_BUSY MOCK_FLOCK_FAIL WG_MOCK_DUMP_SEQUENCE_DIR WG_MOCK_DUMP_FAIL_ON_CALL set_runtime_paths load_config write_alice_metadata begin_test 'fresh process transaction discovers config before lock' local fresh_transaction_script="${TEST_ROOT}/fresh-transaction.sh" fresh_status printf '%s\n' '#!/usr/bin/env bash' 'set -Eeuo pipefail' 'source "$1"' 'transaction_builder_fresh() { cp -- "$WG_CONF" "$1"; }' 'run_transaction update fresh transaction_builder_fresh "$WG_MANAGER_METADATA_DIR/alice.meta"' >"$fresh_transaction_script" chmod 700 "$fresh_transaction_script" set +e; /bin/bash "$fresh_transaction_script" "$PROJECT_DIR/wg-manager.sh" >/dev/null 2>&1; fresh_status=$?; set -e assert_equal 0 "$fresh_status" 'fresh process transaction' if [[ -n $test_group && $test_group != transaction ]]; then case $test_group in locking) begin_test 'lock contention never enters the mutation callback' : >"${TEST_ROOT}/builder-marker" transaction_builder_marker() { : >"${TEST_ROOT}/builder-marker.entered"; return 0; } export MOCK_FLOCK_BUSY=1 set +e; run_transaction test lock transaction_builder_marker >/dev/null 2>&1; status=$?; set -e unset MOCK_FLOCK_BUSY assert_equal "$EXIT_OPERATION" "$status" 'lock contention status' assert_file_absent "${TEST_ROOT}/builder-marker.entered" 'callback не вызван' begin_test 'real isolated flock serializes concurrent transactions' local flock_path="${TEST_ROOT}/flock-path" first_pid real_flock first_status mkdir -p "$flock_path" for command_name in bash env cp chmod mv mkdir mktemp stat date rm sed sort paste cat; do ln -s -- "$(type -P "$command_name")" "${flock_path}/${command_name}"; done real_flock=$(PATH=/usr/bin:/bin type -P flock) ln -s -- "$real_flock" "${flock_path}/flock" ln -s -- "${PROJECT_DIR}/tests/mocks/bin/wg" "${flock_path}/wg" ln -s -- "${PROJECT_DIR}/tests/mocks/bin/wg-quick" "${flock_path}/wg-quick" mkfifo "${TEST_ROOT}/builder-ready" "${TEST_ROOT}/builder-release" transaction_builder_hold() { printf 'ready\n' >"${TEST_ROOT}/builder-ready"; IFS= read -r _ <"${TEST_ROOT}/builder-release"; cp -- "$WG_CONF" "$1"; } local saved_path=$PATH PATH=$flock_path : >"${TEST_ROOT}/first-status" run_transaction test concurrency transaction_builder_hold >"${TEST_ROOT}/first-status" 2>&1 & first_pid=$! IFS= read -r ready <"${TEST_ROOT}/builder-ready" assert_equal ready "$ready" 'первый builder вошёл' set +e; run_transaction test concurrency transaction_builder_marker >/dev/null 2>&1; status=$?; set -e assert_equal "$EXIT_OPERATION" "$status" 'второй transaction заблокирован' assert_file_absent "${TEST_ROOT}/builder-marker.entered" 'второй builder не вошёл' printf 'release\n' >"${TEST_ROOT}/builder-release" set +e; wait "$first_pid"; first_status=$?; set -e assert_equal 0 "$first_status" 'первый transaction завершён успешно' PATH=$saved_path local env_path env_path=$(command -v env) for interrupt_signal in INT TERM; do begin_test "${interrupt_signal} interruption cleans transaction and releases lock" local interrupt_script="${TEST_ROOT}/interrupt-${interrupt_signal}.sh" interrupt_pid interrupt_status interrupt_before interrupt_attrs mkfifo "${TEST_ROOT}/interrupt-${interrupt_signal}-ready" "${TEST_ROOT}/interrupt-${interrupt_signal}-hold" interrupt_before=$(sha256sum "$fixture"); interrupt_attrs=$(stat -c '%a:%u:%g' "$fixture") printf '%s\n' '#!/usr/bin/env bash' 'set -Eeuo pipefail' 'trap - INT' 'source "$1"' 'hold_builder() { printf "%s\\n" "$BASHPID" >"$INTERRUPT_READY"; cat "$INTERRUPT_HOLD" >/dev/null; cp -- "$WG_CONF" "$1"; }' 'set +e; run_transaction update interrupt hold_builder; status=$?; exit "$status"' >"$interrupt_script" chmod 700 "$interrupt_script" "$env_path" --default-signal=INT --default-signal=TERM WG_MANAGER_CONFIG="$config_file" WG_MANAGER_BACKUP_DIR="$BACKUP_DIR" WG_MANAGER_METADATA_DIR="$METADATA_DIR" WG_MANAGER_LOCK_FILE="$LOCK_FILE" INTERRUPT_READY="${TEST_ROOT}/interrupt-${interrupt_signal}-ready" INTERRUPT_HOLD="${TEST_ROOT}/interrupt-${interrupt_signal}-hold" /bin/bash "$interrupt_script" "$PROJECT_DIR/wg-manager.sh" >"${TEST_ROOT}/interrupt-${interrupt_signal}.log" 2>&1 & interrupt_pid=$! if ! IFS= read -r -t 5 ready <"${TEST_ROOT}/interrupt-${interrupt_signal}-ready"; then kill -TERM "$interrupt_pid" 2>/dev/null || : set +e; wait "$interrupt_pid"; interrupt_status=$?; set -e record_failure "${interrupt_signal} builder did not reach synchronization point: $(<"${TEST_ROOT}/interrupt-${interrupt_signal}.log")" continue fi kill -"$interrupt_signal" "$ready" printf 'release\n' >"${TEST_ROOT}/interrupt-${interrupt_signal}-hold" kill -"$interrupt_signal" "$ready" 2>/dev/null || : set +e; wait "$interrupt_pid"; interrupt_status=$?; set -e assert_equal "$EXIT_OPERATION" "$interrupt_status" "${interrupt_signal} status" assert_contains "сигналом ${interrupt_signal}" "$(<"${TEST_ROOT}/interrupt-${interrupt_signal}.log")" "${interrupt_signal} diagnostic" assert_equal "$interrupt_before" "$(sha256sum "$fixture")" "source after ${interrupt_signal}" assert_equal "$interrupt_attrs" "$(stat -c '%a:%u:%g' "$fixture")" "attributes after ${interrupt_signal}" assert_equal 0 "$(find "$(dirname "$fixture")" -maxdepth 1 -name '.wg-manager.*' | wc -l)" "temps after ${interrupt_signal}" run_transaction update interrupt transaction_builder_copy >/dev/null done begin_test 'resource change after lock is rejected before builder' transaction_builder_marker() { : >"${TEST_ROOT}/resource-builder-entered"; return 0; } local changed_resource_config="${TEST_ROOT}/changed-resource.conf" cp -- "$config_file" "$changed_resource_config" replace_config_line "$changed_resource_config" 'WG_CONF=' 'WG_CONF="/tmp/unsafe-resource.conf"' rm -f -- "${TEST_ROOT}/resource-builder-entered" export MOCK_FLOCK_CHANGE_TO="$changed_resource_config" set +e; run_transaction update resource transaction_builder_marker >/dev/null 2>&1; status=$?; set -e unset MOCK_FLOCK_CHANGE_TO assert_equal "$EXIT_VALIDATION" "$status" 'resource change after lock rejected' assert_file_absent "${TEST_ROOT}/resource-builder-entered" 'builder not entered after resource change' return ;; backups) begin_test 'backup retention and unrelated-file safety' replace_config_line "$config_file" 'BACKUP_RETENTION=' 'BACKUP_RETENTION="1"' mkdir -p "${TEST_ROOT}/backups" printf 'unrelated\n' >"${TEST_ROOT}/backups/unrelated.txt" local collision_index for collision_index in {1..12}; do run_transaction update client transaction_builder_copy >/dev/null; done backup_count=$(find "$BACKUP_DIR" -mindepth 1 -maxdepth 1 -type d -name "*-${WG_INTERFACE}-*" | wc -l) assert_equal 1 "$backup_count" 'ограничение резервных копий' newest_backup=$(find "$BACKUP_DIR" -mindepth 1 -maxdepth 1 -type d -name "*-${WG_INTERFACE}-*" -printf '%f\n' | LC_ALL=C sort | tail -n 1) assert_equal 012 "${newest_backup##*-}" 'счетчик backup имеет стабильный порядок' assert_equal "INTERFACE=wg0" "$(sed -n '1p' "$BACKUP_DIR/$newest_backup/manifest")" 'manifest interface exact' assert_equal 600 "$(stat -c '%a' "$BACKUP_DIR/$newest_backup/manifest")" 'manifest защищен' mkdir -p "$BACKUP_DIR/20240101T000000Z-wg0-999" printf 'invalid\n' >"$BACKUP_DIR/20240101T000000Z-wg0-999/manifest" assert_file_exists "$BACKUP_DIR/20240101T000000Z-wg0-999/manifest" 'некорректный backup сохранен' assert_file_exists "${TEST_ROOT}/backups/unrelated.txt" 'чужой файл сохранён' begin_test 'backup manifest decoys remain untouched during pruning' local valid_backup decoy_base decoy_path valid_backup="$BACKUP_DIR/$newest_backup" make_decoy() { decoy_path="$BACKUP_DIR/$1"; cp -a -- "$valid_backup" "$decoy_path"; } make_decoy 20240101T000000Z-wg9-001; local wrong_interface=$decoy_path make_decoy 20240101T000000Z-wg0-002; local timestamp_mismatch=$decoy_path make_decoy 20231114T221500Z-wg0-003; local unsafe_dir=$decoy_path; chmod 755 "$unsafe_dir" make_decoy 20231114T221500Z-wg0-004; local unsafe_manifest=$decoy_path; chmod 644 "$unsafe_manifest/manifest" make_decoy 20231114T221500Z-wg0-005; local unsafe_config=$decoy_path; chmod 644 "$unsafe_config/wg.conf" ln -s -- "$valid_backup" "$BACKUP_DIR/20231114T221500Z-wg0-006"; local symlink_dir="$BACKUP_DIR/20231114T221500Z-wg0-006" make_decoy 20231114T221500Z-wg0-007; local symlink_manifest=$decoy_path; rm -- "$symlink_manifest/manifest"; ln -s -- "$valid_backup/manifest" "$symlink_manifest/manifest" make_decoy 20231114T221500Z-wg0-008; local symlink_config=$decoy_path; rm -- "$symlink_config/wg.conf"; ln -s -- "$valid_backup/wg.conf" "$symlink_config/wg.conf" make_decoy 20231114T221500Z-wg0-009; local malformed=$decoy_path; printf 'BROKEN\n' >>"$malformed/manifest" make_decoy 20231114T221500Z-wg0-010; local duplicate_field=$decoy_path; printf 'INTERFACE=wg0\n' >>"$duplicate_field/manifest" make_decoy 20231114T221500Z-wg0-011; local unknown_field=$decoy_path; printf 'UNKNOWN=x\n' >>"$unknown_field/manifest" make_decoy 20231114T221500Z-wg0-016; local missing_field=$decoy_path; sed -i '/^INTERFACE=/d' "$missing_field/manifest" make_decoy 20231114T221500Z-wg0-017; local missing_metadata=$decoy_path; printf 'METADATA=missing.meta\n' >>"$missing_metadata/manifest" make_decoy 20231114T221500Z-wg0-018; local bad_metadata=$decoy_path; printf 'metadata\n' >"$bad_metadata/alice.meta"; chmod 644 "$bad_metadata/alice.meta" make_decoy 20231114T221500Z-wg0-019; local undeclared_payload=$decoy_path; printf 'payload\n' >"$undeclared_payload/extra" mkdir -p "$BACKUP_DIR/unrelated-dir"; printf 'unrelated\n' >"$BACKUP_DIR/unrelated-file" replace_config_line "$config_file" 'BACKUP_RETENTION=' 'BACKUP_RETENTION="1"' run_transaction update client transaction_builder_copy >/dev/null for decoy_path in "$wrong_interface" "$timestamp_mismatch" "$unsafe_dir" "$unsafe_manifest" "$unsafe_config" "$symlink_dir" "$symlink_manifest" "$symlink_config" "$malformed" "$duplicate_field" "$unknown_field" "$missing_field" "$missing_metadata" "$bad_metadata" "$undeclared_payload" "$BACKUP_DIR/unrelated-dir" "$BACKUP_DIR/unrelated-file"; do [[ -e "$decoy_path" || -L "$decoy_path" ]] && assert_equal 1 1 "decoy preserved ${decoy_path##*/}" || assert_equal 1 0 "decoy preserved ${decoy_path##*/}" done begin_test 'failed mutation does not prune known-good backups' replace_config_line "$config_file" 'BACKUP_RETENTION=' 'BACKUP_RETENTION="10"' run_transaction update client transaction_builder_copy >/dev/null local known_good_one=$TRANSACTION_BACKUP_PATH run_transaction update client transaction_builder_copy >/dev/null local known_good_two=$TRANSACTION_BACKUP_PATH replace_config_line "$config_file" 'BACKUP_RETENTION=' 'BACKUP_RETENTION="1"' export WG_SYNC_FAIL_ONCE_FILE="${TEST_ROOT}/failed-no-prune" set +e; run_transaction update client transaction_builder_copy >/dev/null 2>&1; status=$?; set -e unset WG_SYNC_FAIL_ONCE_FILE assert_equal "$EXIT_OPERATION" "$status" 'failed mutation status' assert_file_exists "$known_good_one/wg.conf" 'old known-good backup one preserved' assert_file_exists "$known_good_two/wg.conf" 'old known-good backup two preserved' begin_test 'retention zero preserves sole valid backup' replace_config_line "$config_file" 'BACKUP_RETENTION=' 'BACKUP_RETENTION="0"' run_transaction update client transaction_builder_copy >/dev/null local valid_count=0 candidate_backup for candidate_backup in "$BACKUP_DIR"/*; do [[ -d "$candidate_backup" ]] || continue if validate_backup_manifest "$candidate_backup"; then ((valid_count += 1)); fi done assert_equal 1 "$valid_count" 'retention zero keeps one valid backup' ln -s -- "$BACKUP_DIR" "$TEST_ROOT/backup-symlink" assert_file_exists "$TEST_ROOT/backup-symlink" 'symlink candidate preserved' begin_test 'metadata backup sources are constrained and required' local metadata_before metadata_status metadata_before=$(sha256sum "$fixture") printf 'outside\n' >"${TEST_ROOT}/outside.meta" set +e; run_transaction update client transaction_builder_copy "${TEST_ROOT}/outside.meta" >/dev/null 2>&1; metadata_status=$?; set -e assert_equal "$EXIT_OPERATION" "$metadata_status" 'внешние метаданные отклонены' assert_equal "$metadata_before" "$(sha256sum "$fixture")" 'внешняя metadata не меняет source' ln -s -- "${METADATA_DIR}/alice.meta" "${METADATA_DIR}/linked.meta" set +e; run_transaction update client transaction_builder_copy "${METADATA_DIR}/linked.meta" >/dev/null 2>&1; metadata_status=$?; set -e assert_equal "$EXIT_OPERATION" "$metadata_status" 'symlink metadata отклонена' rm -- "${METADATA_DIR}/linked.meta" begin_test 'backup counter exhaustion fails without overwriting 999' local exhaustion_index exhaustion_dir exhaustion_status exhaustion_before for exhaustion_index in $(seq -w 0 999); do exhaustion_dir="$BACKUP_DIR/20231114T221500Z-wg0-${exhaustion_index}" if [[ ! -e "$exhaustion_dir" && ! -L "$exhaustion_dir" ]]; then mkdir -p "$exhaustion_dir"; fi done printf 'sentinel\n' >"$BACKUP_DIR/20231114T221500Z-wg0-999/sentinel" exhaustion_before=$(<"$BACKUP_DIR/20231114T221500Z-wg0-999/sentinel") set +e; run_transaction update client transaction_builder_copy >/dev/null 2>&1; exhaustion_status=$?; set -e assert_equal "$EXIT_OPERATION" "$exhaustion_status" 'counter exhaustion status' assert_equal "$exhaustion_before" "$(<"$BACKUP_DIR/20231114T221500Z-wg0-999/sentinel")" 'counter 999 sentinel preserved' return ;; mutation-preserves-source) begin_test 'pre-replacement validation preserves source bytes' before=$(sha256sum "$fixture") export WG_QUICK_STRIP_FAIL=1 set +e; run_transaction update client transaction_builder_copy >/dev/null 2>&1; status=$?; set -e unset WG_QUICK_STRIP_FAIL after=$(sha256sum "$fixture") assert_equal "$EXIT_VALIDATION" "$status" 'ошибка staged strip' assert_equal "$before" "$after" 'байты исходника сохранены' begin_test 'old live strip failure preserves source' before=$(sha256sum "$fixture"); source_attrs=$(stat -c '%a:%u:%g' "$fixture") export WG_QUICK_STRIP_FAIL=live set +e; run_transaction update client transaction_builder_copy >/dev/null 2>&1; status=$?; set -e unset WG_QUICK_STRIP_FAIL assert_equal "$EXIT_OPERATION" "$status" 'ошибка live strip' assert_equal "$before" "$(sha256sum "$fixture")" 'байты после live strip' assert_equal "$source_attrs" "$(stat -c '%a:%u:%g' "$fixture")" 'атрибуты после live strip' begin_test 'metadata copy failure preserves source' before=$(sha256sum "$fixture"); source_attrs=$(stat -c '%a:%u:%g' "$fixture") export TRANSACTION_METADATA_COPY_FAIL=1 set +e; run_transaction update client transaction_builder_copy "${METADATA_DIR}/alice.meta" >/dev/null 2>&1; status=$?; set -e unset TRANSACTION_METADATA_COPY_FAIL assert_equal "$EXIT_OPERATION" "$status" 'ошибка копирования metadata' assert_equal "$before" "$(sha256sum "$fixture")" 'байты после metadata failure' assert_equal "$source_attrs" "$(stat -c '%a:%u:%g' "$fixture")" 'атрибуты после metadata failure' begin_test 'backup creation failure preserves source' before=$(sha256sum "$fixture"); source_attrs=$(stat -c '%a:%u:%g' "$fixture") export TRANSACTION_BACKUP_FAIL=1 set +e; run_transaction update client transaction_builder_copy >/dev/null 2>&1; status=$?; set -e unset TRANSACTION_BACKUP_FAIL assert_equal "$EXIT_OPERATION" "$status" 'ошибка создания backup' assert_equal "$before" "$(sha256sum "$fixture")" 'байты после backup failure' assert_equal "$source_attrs" "$(stat -c '%a:%u:%g' "$fixture")" 'атрибуты после backup failure' begin_test 'backup directory protection failure cleans incomplete backup' before=$(sha256sum "$fixture"); source_attrs=$(stat -c '%a:%u:%g' "$fixture") backup_count_before=$(find "$BACKUP_DIR" -mindepth 1 -maxdepth 1 -type d -name '*-wg0-*' | wc -l) export TRANSACTION_BACKUP_DIR_CHMOD_FAIL=1 set +e; run_transaction update client transaction_builder_copy >/dev/null 2>&1; status=$?; set -e unset TRANSACTION_BACKUP_DIR_CHMOD_FAIL assert_equal "$EXIT_OPERATION" "$status" 'ошибка chmod каталога backup' assert_equal "$before" "$(sha256sum "$fixture")" 'байты после chmod каталога backup' assert_equal "$source_attrs" "$(stat -c '%a:%u:%g' "$fixture")" 'атрибуты после chmod каталога backup' assert_equal "$backup_count_before" "$(find "$BACKUP_DIR" -mindepth 1 -maxdepth 1 -type d -name '*-wg0-*' | wc -l)" 'нет incomplete backup после chmod' begin_test 'backup manifest write failure cleans incomplete backup' before=$(sha256sum "$fixture"); source_attrs=$(stat -c '%a:%u:%g' "$fixture") backup_count_before=$(find "$BACKUP_DIR" -mindepth 1 -maxdepth 1 -type d -name '*-wg0-*' | wc -l) export TRANSACTION_BACKUP_MANIFEST_WRITE_FAIL=1 set +e; run_transaction update client transaction_builder_copy >/dev/null 2>&1; status=$?; set -e unset TRANSACTION_BACKUP_MANIFEST_WRITE_FAIL assert_equal "$EXIT_OPERATION" "$status" 'ошибка записи manifest' assert_equal "$before" "$(sha256sum "$fixture")" 'байты после записи manifest' assert_equal "$source_attrs" "$(stat -c '%a:%u:%g' "$fixture")" 'атрибуты после записи manifest' assert_equal "$backup_count_before" "$(find "$BACKUP_DIR" -mindepth 1 -maxdepth 1 -type d -name '*-wg0-*' | wc -l)" 'нет incomplete backup после manifest' begin_test 'backup manifest mode failure cleans incomplete backup' before=$(sha256sum "$fixture"); source_attrs=$(stat -c '%a:%u:%g' "$fixture") backup_count_before=$(find "$BACKUP_DIR" -mindepth 1 -maxdepth 1 -type d -name '*-wg0-*' | wc -l) export TRANSACTION_BACKUP_MANIFEST_CHMOD_FAIL=1 set +e; run_transaction update client transaction_builder_copy >/dev/null 2>&1; status=$?; set -e unset TRANSACTION_BACKUP_MANIFEST_CHMOD_FAIL assert_equal "$EXIT_OPERATION" "$status" 'ошибка chmod manifest' assert_equal "$before" "$(sha256sum "$fixture")" 'байты после chmod manifest' assert_equal "$source_attrs" "$(stat -c '%a:%u:%g' "$fixture")" 'атрибуты после chmod manifest' assert_equal "$backup_count_before" "$(find "$BACKUP_DIR" -mindepth 1 -maxdepth 1 -type d -name '*-wg0-*' | wc -l)" 'нет incomplete backup после chmod manifest' begin_test 'builder failure preserves source attributes' before=$(sha256sum "$fixture"); source_attrs=$(stat -c '%a:%u:%g' "$fixture") set +e; run_transaction update client transaction_builder_fail >/dev/null 2>&1; status=$?; set -e assert_equal 77 "$status" 'builder failure status' assert_equal "$before" "$(sha256sum "$fixture")" 'байты после builder failure' assert_equal "$source_attrs" "$(stat -c '%a:%u:%g' "$fixture")" 'атрибуты после builder failure' chmod 644 "$fixture" set +e; run_transaction update client transaction_builder_copy >/dev/null 2>&1; status=$?; set -e chmod 600 "$fixture" assert_equal "$EXIT_VALIDATION" "$status" 'небезопасный режим source отклонён' set +e; run_transaction $'bad\noperation' client transaction_builder_copy >/dev/null 2>&1; status=$?; set -e assert_equal "$EXIT_VALIDATION" "$status" 'unsafe operation отклонена до mutation' begin_test 'each transaction temp allocation fails closed' local mktemp_bin="${TEST_ROOT}/mktemp-bin" saved_path fail_kind backup_count_before mkdir -p "$mktemp_bin" printf '%s\n' '#!/usr/bin/env bash' 'case ${1##*/} in' ' *.stage.XXXXXX) kind=stage ;;' ' *.strip.new.XXXXXX) kind=strip.new ;;' ' *.strip.old.XXXXXX) kind=strip.old ;;' ' *.original.XXXXXX) kind=original ;;' ' *.result.XXXXXX) kind=result ;;' ' *) kind=other ;;' 'esac' '[[ ${MOCK_MKTEMP_FAIL_KIND:-} == "$kind" ]] && exit 97' 'exec /usr/bin/mktemp "$@"' >"${mktemp_bin}/mktemp" chmod 700 "${mktemp_bin}/mktemp" saved_path=$PATH PATH="${mktemp_bin}:$PATH" for fail_kind in stage strip.new strip.old original; do before=$(sha256sum "$fixture") export MOCK_MKTEMP_FAIL_KIND=$fail_kind set +e; run_transaction update client transaction_builder_copy >/dev/null 2>&1; status=$?; set -e unset MOCK_MKTEMP_FAIL_KIND assert_failure "$status" "mktemp ${fail_kind} отклонён" assert_equal "$before" "$(sha256sum "$fixture")" "source сохранён при mktemp ${fail_kind}" assert_equal 0 "$(find "$(dirname "$fixture")" -maxdepth 1 -name '.wg-manager.*' -type f -o -type l | wc -l)" "нет temp после mktemp ${fail_kind}" done PATH=$saved_path begin_test 'each required transaction dependency fails before mutation' local dependency_path missing_dependency dependency_status dependency_path="${TEST_ROOT}/dependency-path" mkdir -p "$dependency_path" for dependency_command in mktemp stat mkdir chmod rm cp mv date; do ln -s -- "$(PATH=/usr/bin:/bin type -P "$dependency_command")" "$dependency_path/$dependency_command"; done for missing_dependency in flock wg wg-quick; do rm -f -- "$dependency_path/flock" "$dependency_path/wg" "$dependency_path/wg-quick" for dependency_command in flock wg wg-quick; do [[ $dependency_command == "$missing_dependency" ]] && continue ln -sf -- "${PROJECT_DIR}/tests/mocks/bin/${dependency_command}" "$dependency_path/$dependency_command" done before=$(sha256sum "$fixture") PATH=$dependency_path set +e; run_transaction update client transaction_builder_copy >/dev/null 2>&1; dependency_status=$?; set -e PATH=$saved_path assert_equal "$EXIT_DEPENDENCY" "$dependency_status" "отсутствует ${missing_dependency}" assert_equal "$before" "$(sha256sum "$fixture")" "source не меняется без ${missing_dependency}" done return ;; rollback) begin_test 'apply failure restores disk and runtime' before=$(sha256sum "$fixture") export WG_SYNC_FAIL_ONCE_FILE="${TEST_ROOT}/sync-failed-once" set +e; run_transaction update client transaction_builder_copy >/dev/null 2>&1; status=$?; set -e unset WG_SYNC_FAIL_ONCE_FILE after=$(sha256sum "$fixture") assert_equal "$EXIT_OPERATION" "$status" 'ошибка apply с успешным rollback' assert_equal "$before" "$after" 'байты после rollback' begin_test 'rollback verification failure is critical' local rollback_sequence="${TEST_ROOT}/rollback-sequence" rollback_mismatch mkdir -p "$rollback_sequence" rollback_mismatch="${TEST_ROOT}/rollback-mismatch.dump" sed 's/192\.0\.2\.2\/32/192.0.2.99\/32/' "$runtime_file" >"$rollback_mismatch" cp -- "$rollback_mismatch" "${rollback_sequence}/1" cp -- "$rollback_mismatch" "${rollback_sequence}/2" : >"$TEST_MOCK_LOG" export WG_MOCK_DUMP_SEQUENCE_DIR="$rollback_sequence" unset WG_MOCK_DUMP_FILE set +e; output=$(run_transaction update client transaction_builder_copy 2>&1); status=$?; set -e unset WG_MOCK_DUMP_SEQUENCE_DIR export WG_MOCK_DUMP_FILE="$runtime_file" assert_equal "$EXIT_ROLLBACK" "$status" 'ошибка проверки rollback' assert_contains 'КРИТИЧНО' "$output" 'critical rollback verification' assert_equal 2 "$(grep -c '^wg show wg0 dump$' "$TEST_MOCK_LOG" || true)" 'два dump при rollback' assert_equal "$before" "$(sha256sum "$fixture")" 'диск восстановлен при ошибке проверки rollback' begin_test 'forward atomic replace failure preserves disk and skips runtime' before=$(sha256sum "$fixture") export TRANSACTION_ATOMIC_REPLACE_FAIL=1 set +e; run_transaction update client transaction_builder_copy >/dev/null 2>&1; status=$?; set -e unset TRANSACTION_ATOMIC_REPLACE_FAIL assert_equal "$EXIT_OPERATION" "$status" 'ошибка atomic replace' assert_equal "$before" "$(sha256sum "$fixture")" 'source после atomic replace' begin_test 'syncconf failure restores disk and runtime' before=$(sha256sum "$fixture") export WG_SYNC_FAIL_ONCE_FILE="${TEST_ROOT}/sync-failed-once-2" set +e; run_transaction update client transaction_builder_copy >/dev/null 2>&1; status=$?; set -e unset WG_SYNC_FAIL_ONCE_FILE assert_equal "$EXIT_OPERATION" "$status" 'ошибка syncconf с rollback' assert_equal "$before" "$(sha256sum "$fixture")" 'source после syncconf' begin_test 'forward verification command failure rolls back' before=$(sha256sum "$fixture") export WG_MOCK_DUMP_FAIL_ON_CALL=1 WG_MOCK_DUMP_FAIL_COUNTER_FILE="${TEST_ROOT}/dump-fail-counter" set +e; run_transaction update client transaction_builder_copy >/dev/null 2>&1; status=$?; set -e unset WG_MOCK_DUMP_FAIL_ON_CALL WG_MOCK_DUMP_FAIL_COUNTER_FILE assert_equal "$EXIT_OPERATION" "$status" 'ошибка команды verification' assert_equal "$before" "$(sha256sum "$fixture")" 'source после verification command' begin_test 'disk rollback replacement failure is critical' export WG_SYNC_FAIL_ONCE_FILE="${TEST_ROOT}/sync-failed-disk" export TRANSACTION_ROLLBACK_REPLACE_FAIL=1 set +e; output=$(run_transaction update client transaction_builder_copy 2>&1); status=$?; set -e unset WG_SYNC_FAIL_ONCE_FILE TRANSACTION_ROLLBACK_REPLACE_FAIL assert_equal "$EXIT_ROLLBACK" "$status" 'disk rollback failure status' assert_contains 'КРИТИЧНО' "$output" 'disk rollback critical' begin_test 'runtime rollback syncconf failure is critical' cp -- "$rollback_mismatch" "${rollback_sequence}/1" cp -- "$runtime_file" "${rollback_sequence}/2" rm -f -- "${rollback_sequence}/counter" export WG_MOCK_DUMP_SEQUENCE_DIR="$rollback_sequence" export WG_SYNC_FAIL_ON_CALL=2 WG_SYNC_FAIL_COUNTER_FILE="${TEST_ROOT}/sync-fail-counter" set +e; output=$(run_transaction update client transaction_builder_copy 2>&1); status=$?; set -e unset WG_SYNC_FAIL_ON_CALL WG_SYNC_FAIL_COUNTER_FILE WG_MOCK_DUMP_SEQUENCE_DIR export WG_MOCK_DUMP_FILE="$runtime_file" assert_equal "$EXIT_ROLLBACK" "$status" 'runtime rollback failure status' assert_contains 'КРИТИЧНО' "$output" 'runtime rollback critical' begin_test 'rollback verification command failure is critical' cp -- "$rollback_mismatch" "${rollback_sequence}/1" rm -f -- "${rollback_sequence}/2" rm -f -- "${rollback_sequence}/counter" export WG_MOCK_DUMP_SEQUENCE_DIR="$rollback_sequence" set +e; output=$(run_transaction update client transaction_builder_copy 2>&1); status=$?; set -e unset WG_MOCK_DUMP_SEQUENCE_DIR export WG_MOCK_DUMP_FILE="$runtime_file" assert_equal "$EXIT_ROLLBACK" "$status" 'rollback verification command status' assert_contains 'КРИТИЧНО' "$output" 'rollback verification command critical' begin_test 'forward runtime dump variants roll back safely' local variant_name variant_file variant_status variant_before variant_attrs declare -a variant_names=(malformed missing extra duplicate allowed-mismatch listen-mismatch) for variant_name in "${variant_names[@]}"; do variant_file="${rollback_sequence}/${variant_name}.dump" case $variant_name in malformed) printf 'malformed\n' >"$variant_file" ;; missing) head -n 1 "$runtime_file" >"$variant_file" ;; extra) { cat "$runtime_file"; printf '%s\t(none)\t(none)\t192.0.2.3/32\t0\t0\t0\t0\n' CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC=; } >"$variant_file" ;; duplicate) { cat "$runtime_file"; tail -n 1 "$runtime_file"; } >"$variant_file" ;; allowed-mismatch) sed 's/192\.0\.2\.2\/32/192.0.2.99\/32/' "$runtime_file" >"$variant_file" ;; listen-mismatch) sed '1s/51820/51821/' "$runtime_file" >"$variant_file" ;; esac rm -f -- "${rollback_sequence}/1" "${rollback_sequence}/2" "${rollback_sequence}/counter" cp -- "$variant_file" "${rollback_sequence}/1" cp -- "$runtime_file" "${rollback_sequence}/2" : >"$TEST_MOCK_LOG" export WG_MOCK_DUMP_SEQUENCE_DIR="$rollback_sequence" WG_MOCK_DUMP_FILE="$runtime_file" variant_before=$(sha256sum "$fixture"); variant_attrs=$(stat -c '%a:%u:%g' "$fixture") set +e; run_transaction update client transaction_builder_copy >/dev/null 2>&1; variant_status=$?; set -e assert_equal "$EXIT_OPERATION" "$variant_status" "variant ${variant_name} status" assert_equal "$variant_before" "$(sha256sum "$fixture")" "variant ${variant_name} bytes" assert_equal "$variant_attrs" "$(stat -c '%a:%u:%g' "$fixture")" "variant ${variant_name} attrs" assert_equal 2 "$(grep -c '^wg syncconf wg0 ' "$TEST_MOCK_LOG" || true)" "variant ${variant_name} syncconf count" assert_equal 2 "$(grep -c '^wg show wg0 dump$' "$TEST_MOCK_LOG" || true)" "variant ${variant_name} dump count" assert_equal 2 "$(<"${rollback_sequence}/counter")" "variant ${variant_name} sequence count" assert_equal 0 "$(find "$(dirname "$fixture")" -maxdepth 1 -name '.wg-manager.*' | wc -l)" "variant ${variant_name} temp cleanup" done unset WG_MOCK_DUMP_SEQUENCE_DIR begin_test 'malformed rollback verification is critical' cp -- "$rollback_mismatch" "${rollback_sequence}/1" printf 'malformed\n' >"${rollback_sequence}/2" rm -f -- "${rollback_sequence}/counter" export WG_MOCK_DUMP_SEQUENCE_DIR="$rollback_sequence" WG_MOCK_DUMP_FILE="$runtime_file" variant_before=$(sha256sum "$fixture"); variant_attrs=$(stat -c '%a:%u:%g' "$fixture") set +e; output=$(run_transaction update client transaction_builder_copy 2>&1); status=$?; set -e unset WG_MOCK_DUMP_SEQUENCE_DIR assert_equal "$EXIT_ROLLBACK" "$status" 'malformed rollback status' assert_contains 'КРИТИЧНО' "$output" 'malformed rollback critical' assert_equal "$variant_before" "$(sha256sum "$fixture")" 'malformed rollback bytes' assert_equal "$variant_attrs" "$(stat -c '%a:%u:%g' "$fixture")" 'malformed rollback attrs' return ;; secret-modes) begin_test 'backup modes and secret-safe mock log' : >"$TEST_MOCK_LOG" run_transaction update client transaction_builder_copy "${METADATA_DIR}/alice.meta" >/dev/null assert_file_mode 700 "$BACKUP_DIR" 'режим backup каталога' assert_file_mode 700 "$(dirname "$TRANSACTION_BACKUP_PATH")" 'режим backup root' assert_file_mode 700 "$TRANSACTION_BACKUP_PATH" 'режим backup subdir' assert_file_mode 600 "$TRANSACTION_BACKUP_PATH/wg.conf" 'режим backup config' assert_file_mode 600 "$TRANSACTION_BACKUP_PATH/manifest" 'режим manifest' assert_file_mode 600 "$TRANSACTION_BACKUP_PATH/alice.meta" 'режим metadata' assert_file_mode 600 "$LOCK_FILE" 'режим lock' assert_file_mode 600 "$fixture" 'режим live config' assert_equal '' "$(grep -E 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=' "$TEST_MOCK_LOG" || true)" 'секреты не в mock log' begin_test 'temporary transaction files are mode 0600 while active' local mode_script="${TEST_ROOT}/active-mode.sh" mode_pid mode_status mode_ready fixture_dir mode_file mode_class local -a mode_matches=() mkfifo "${TEST_ROOT}/active-mode-ready" "${TEST_ROOT}/active-mode-release" printf '%s\n' '#!/usr/bin/env bash' 'set -Eeuo pipefail' 'source "$1"' 'builder() { cp -- "$WG_CONF" "$1"; }' 'run_transaction update active builder' >"$mode_script" chmod 700 "$mode_script" WG_MANAGER_CONFIG="$config_file" WG_MANAGER_BACKUP_DIR="$BACKUP_DIR" WG_MANAGER_METADATA_DIR="$METADATA_DIR" WG_MANAGER_LOCK_FILE="$LOCK_FILE" WG_SYNC_READY_FIFO="${TEST_ROOT}/active-mode-ready" WG_SYNC_RELEASE_FIFO="${TEST_ROOT}/active-mode-release" /bin/bash "$mode_script" "$PROJECT_DIR/wg-manager.sh" >"${TEST_ROOT}/active-mode.out" 2>"${TEST_ROOT}/active-mode.err" & mode_pid=$! IFS= read -r -t 5 mode_ready <"${TEST_ROOT}/active-mode-ready" fixture_dir=${WG_CONF%/*} shopt -s nullglob for mode_class in result strip.new strip.old original; do mode_matches=("$fixture_dir"/.wg-manager."$mode_class".*) assert_equal 1 "${#mode_matches[@]}" "ровно один active ${mode_class} temp" mode_file=${mode_matches[0]} assert_equal 600 "$(stat -c '%a' "$mode_file")" "активный temp mode ${mode_file##*/}" assert_equal 0 "$( [[ -f "$mode_file" && ! -L "$mode_file" && "$mode_file" == "$TEST_ROOT"/* ]] && printf 0 || printf 1 )" "активный temp confined ${mode_file##*/}" done assert_equal 0 "$(find "$(dirname "$fixture")" -maxdepth 1 -name '.wg-manager.stage.*' | wc -l)" 'stage отсутствует после rename' assert_file_mode 600 "$fixture" 'live mode while syncconf blocked' printf 'release\n' >"${TEST_ROOT}/active-mode-release" set +e; wait "$mode_pid"; mode_status=$?; set -e assert_equal 0 "$mode_status" 'active mode transaction succeeds' assert_equal 0 "$(find "$(dirname "$fixture")" -maxdepth 1 -name '.wg-manager.*' | wc -l)" 'временные transaction files очищены' assert_equal '' "$(grep -E 'PrivateKey|PresharedKey|\[Interface\]|\[Peer\]|AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=|BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=' "${TEST_ROOT}/active-mode.out" "${TEST_ROOT}/active-mode.err" "$TEST_MOCK_LOG" || true)" 'active output/logs secret-free' return ;; esac fi begin_test 'successful transaction uses lock, backup, strip, syncconf, and verification' parent_exit=$(trap -p EXIT); parent_int=$(trap -p INT); parent_term=$(trap -p TERM) before=$(sha256sum "$fixture") : >"$TEST_MOCK_LOG" set +e run_transaction update client transaction_builder_copy "${METADATA_DIR}/alice.meta" status=$? set -e after=$(sha256sum "$fixture") assert_success "$status" 'успешная транзакция' assert_equal 1 "$([[ "$before" != "$after" ]] && printf 1 || printf 0)" 'предложение действительно изменяет конфиг' assert_file_exists "$TRANSACTION_BACKUP_PATH/wg.conf" 'резервная копия конфига' assert_file_exists "$TRANSACTION_BACKUP_PATH/manifest" 'манифест резервной копии' assert_file_exists "$TRANSACTION_BACKUP_PATH/alice.meta" 'копия метаданных' assert_file_mode 700 "$(dirname "$TRANSACTION_BACKUP_PATH")" 'режим каталога резервных копий' assert_file_mode 600 "$TRANSACTION_BACKUP_PATH/wg.conf" 'режим резервного конфига' assert_file_mode 600 "$TRANSACTION_BACKUP_PATH/manifest" 'режим манифеста' assert_file_mode 600 "$fixture" 'режим live конфига' manifest_content=$(<"$TRANSACTION_BACKUP_PATH/manifest") assert_contains $'INTERFACE=wg0\n' "$manifest_content" 'manifest interface' assert_contains $'TIMESTAMP=' "$manifest_content" 'manifest timestamp' assert_contains $'OPERATION=update\n' "$manifest_content" 'manifest operation' assert_contains $'TARGET=client\n' "$manifest_content" 'manifest target' assert_contains $'SOURCE_MODE=600\n' "$manifest_content" 'manifest source mode' assert_contains $'CONFIG=wg.conf\nMETADATA=alice.meta' "$manifest_content" 'manifest config metadata' assert_equal "$(<"${METADATA_DIR}/alice.meta")" "$(<"$TRANSACTION_BACKUP_PATH/alice.meta")" 'metadata backup bytes' assert_file_mode 600 "$TRANSACTION_BACKUP_PATH/alice.meta" 'режим metadata backup' local transaction_log local -a transaction_lines=() transaction_log=$(<"$TEST_MOCK_LOG") assert_contains $'flock -n ' "$transaction_log" 'argv flock' assert_contains $'wg-quick strip ' "$transaction_log" 'argv strip' assert_contains $'wg syncconf wg0 ' "$transaction_log" 'argv syncconf' assert_contains $'wg show wg0 dump' "$transaction_log" 'argv verification dump' mapfile -t transaction_lines < <(grep -E '^(flock -n [0-9]+$|wg-quick strip |wg syncconf |wg show )' "$TEST_MOCK_LOG") assert_equal 5 "${#transaction_lines[@]}" 'точно пять внешних transaction вызовов' assert_equal 1 "$([[ ${transaction_lines[0]} =~ ^flock\ -n\ [0-9]+$ ]] && printf 1 || printf 0)" 'первый вызов flock' assert_equal 1 "$([[ ${transaction_lines[1]} == 'wg-quick strip '* && ${transaction_lines[2]} == 'wg-quick strip '* ]] && printf 1 || printf 0)" 'два strip подряд' assert_equal 1 "$([[ ${transaction_lines[3]} == 'wg syncconf wg0 '* && ${transaction_lines[4]} == 'wg show wg0 dump' ]] && printf 1 || printf 0)" 'syncconf затем dump' assert_equal 1 "$( [[ ${transaction_lines[1]} =~ ^wg-quick\ strip\ ${TEST_ROOT//./\.}/\.wg-manager\.stage\.[A-Za-z0-9]+$ && ${transaction_lines[2]} == "wg-quick strip ${fixture}" && ${transaction_lines[3]} =~ ^wg\ syncconf\ wg0\ ${TEST_ROOT//./\.}/\.wg-manager\.strip\.new\.[A-Za-z0-9]+$ ]] && printf 1 || printf 0 )" 'точные классы stage/live/syncconf' assert_equal '' "$(grep -E 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=' "$TEST_MOCK_LOG" || true)" 'mock log без ключей' assert_equal "$parent_exit" "$(trap -p EXIT)" 'EXIT trap восстановлен' assert_equal "$parent_int" "$(trap -p INT)" 'INT trap восстановлен' assert_equal "$parent_term" "$(trap -p TERM)" 'TERM trap восстановлен' begin_test 'run_transaction rejects fewer than three arguments' set +e; run_transaction update client >/dev/null 2>&1; status=$?; set -e assert_equal "$EXIT_USAGE" "$status" 'неполный публичный API' begin_test 'legacy generic transaction hooks do not affect normal mode' before=$(sha256sum "$fixture") old_test_mode=${WG_MANAGER_TEST_MODE:-0} export WG_MANAGER_TEST_MODE=0 TRANSACTION_METADATA_COPY_FAIL=1 TRANSACTION_BACKUP_FAIL=1 TRANSACTION_ATOMIC_REPLACE_FAIL=1 TRANSACTION_ROLLBACK_REPLACE_FAIL=1 set +e; run_transaction update client transaction_builder_copy >/dev/null 2>&1; status=$?; set -e unset TRANSACTION_METADATA_COPY_FAIL TRANSACTION_BACKUP_FAIL TRANSACTION_ATOMIC_REPLACE_FAIL TRANSACTION_ROLLBACK_REPLACE_FAIL export WG_MANAGER_TEST_MODE=$old_test_mode assert_success "$status" 'старые generic hooks не меняют production mode' assert_equal "$before" "$(sha256sum "$fixture")" 'обычная транзакция завершилась без подмены source' begin_test 'staging validation failure preserves source' before=$(sha256sum "$fixture") export WG_QUICK_STRIP_FAIL=1 set +e run_transaction update client transaction_builder_copy >/dev/null 2>&1 status=$? set -e unset WG_QUICK_STRIP_FAIL after=$(sha256sum "$fixture") assert_equal "$EXIT_VALIDATION" "$status" 'ошибка strip' assert_equal "$before" "$after" 'источник после ошибки strip' begin_test 'builder failure cleans staging and preserves source' before=$(sha256sum "$fixture") set +e run_transaction update client transaction_builder_fail >/dev/null 2>&1 status=$? set -e after=$(sha256sum "$fixture") assert_equal 77 "$status" 'статус ошибки builder' assert_equal "$before" "$after" 'источник после ошибки builder' assert_equal 0 "$(find "$(dirname "$fixture")" -maxdepth 1 -name '.wg-manager.stage.*' -type f | wc -l)" 'staging очищен' begin_test 'builder symlink replacement is rejected' before=$(sha256sum "$fixture") set +e run_transaction update client transaction_builder_symlink >/dev/null 2>&1 status=$? set -e after=$(sha256sum "$fixture") assert_equal "$EXIT_VALIDATION" "$status" 'staged symlink отклонён' assert_equal "$before" "$after" 'источник после staged symlink' begin_test 'apply failure rolls back disk and runtime' before=$(sha256sum "$fixture") export WG_SYNC_FAIL_ONCE_FILE="${TEST_ROOT}/sync-failed-once" set +e run_transaction update client transaction_builder_copy >/dev/null 2>&1 status=$? set -e unset WG_SYNC_FAIL_ONCE_FILE after=$(sha256sum "$fixture") assert_equal "$EXIT_OPERATION" "$status" 'ошибка apply с успешным rollback' assert_equal "$before" "$after" 'источник после rollback apply' begin_test 'verification failure rolls back disk and runtime' local sequence_dir="${TEST_ROOT}/dump-sequence" mismatch_dump mkdir -p "$sequence_dir" mismatch_dump="${TEST_ROOT}/mismatch.dump" sed 's/192\.0\.2\.2\/32/192.0.2.99\/32/' "$runtime_file" >"$mismatch_dump" cp -- "$mismatch_dump" "${sequence_dir}/1" cp -- "$runtime_file" "${sequence_dir}/2" before=$(sha256sum "$fixture") export WG_MOCK_DUMP_SEQUENCE_DIR="$sequence_dir" unset WG_MOCK_DUMP_FILE set +e run_transaction update client transaction_builder_copy >/dev/null 2>&1 status=$? set -e unset WG_MOCK_DUMP_SEQUENCE_DIR export WG_MOCK_DUMP_FILE="$runtime_file" after=$(sha256sum "$fixture") assert_equal "$EXIT_OPERATION" "$status" 'ошибка verification с успешным rollback' assert_equal "$before" "$after" 'источник после rollback verification' begin_test 'lock contention does not enter builder' : >"${TEST_ROOT}/builder-marker" transaction_builder_marker() { : >"${TEST_ROOT}/builder-marker.entered"; return 0; } export MOCK_FLOCK_BUSY=1 set +e run_transaction test lock transaction_builder_marker >/dev/null 2>&1 status=$? set -e unset MOCK_FLOCK_BUSY assert_equal "$EXIT_OPERATION" "$status" 'lock contention status' assert_file_absent "${TEST_ROOT}/builder-marker.entered" 'builder не вошёл при contention' begin_test 'retention keeps newest valid backup and unrelated files' replace_config_line "$config_file" 'BACKUP_RETENTION=' 'BACKUP_RETENTION="1"' printf 'unrelated\n' >"${TEST_ROOT}/backups/unrelated.txt" for collision_index in {1..12}; do run_transaction update client transaction_builder_copy >/dev/null; done backup_count=$(find "$BACKUP_DIR" -mindepth 1 -maxdepth 1 -type d -name "*-${WG_INTERFACE}-*" | wc -l) assert_equal 1 "$backup_count" 'ограничение резервных копий' assert_file_exists "${TEST_ROOT}/backups/unrelated.txt" 'чужой файл сохранён' begin_test 'rollback failure is distinct and critical' export WG_SYNC_FAIL=1 set +e output=$(run_transaction update client transaction_builder_copy 2>&1) status=$? set -e unset WG_SYNC_FAIL assert_equal "$EXIT_ROLLBACK" "$status" 'ошибка rollback' assert_contains 'КРИТИЧНО' "$output" 'critical rollback diagnostic' } run_m2_focused() { local group=$1 config_file="${TEST_ROOT}/focused-m2.conf" fixture="${TEST_ROOT}/wg0.conf" runtime_file="${TEST_ROOT}/runtime.dump" output reversed_output status cp -- "${PROJECT_DIR}/tests/fixtures/m2/managed.conf" "$fixture" write_m2_config "$config_file" "$fixture" export WG_MANAGER_CONFIG="$config_file" WG_MANAGER_BACKUP_DIR="${TEST_ROOT}/backups" WG_MANAGER_LOG_DIR="${TEST_ROOT}/logs" WG_MANAGER_METADATA_DIR="${TEST_ROOT}/clients" WG_MANAGER_ARCHIVE_DIR="${TEST_ROOT}/archive" WG_MANAGER_LOCK_FILE="${TEST_ROOT}/lock" WG_MANAGER_NOW=1700000100 set_runtime_paths WG_CONF=$fixture mkdir -p "$METADATA_DIR" load_config write_alice_metadata case $group in parser) begin_test 'focused persistent and runtime parser' parse_wireguard_config "$fixture" parse_metadata cp -- "${PROJECT_DIR}/tests/fixtures/m2/repeatable-interface.conf" "$fixture" replace_config_line "$fixture" 'FwMark =' 'FwMark = 0xca6c' parse_wireguard_config "$fixture" assert_equal 1 "${#DISK_ALLOWED[@]}" 'hex FwMark не ломает parser' cp -- "${PROJECT_DIR}/tests/fixtures/m2/markers-adjacent.conf" "$fixture" parse_wireguard_config "$fixture" assert_equal second "${DISK_LEGACY[CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC=]}" 'focused marker scoping' cp -- "${PROJECT_DIR}/tests/fixtures/m2/runtime.dump" "$runtime_file" parse_runtime_dump "$(<"$runtime_file")" assert_equal 1 "$RUNTIME_AVAILABLE" 'runtime parser доступен' assert_equal 51820 "$RUNTIME_LISTEN_PORT" 'порт интерфейса runtime' assert_equal 1700000100 "${RUNTIME_HANDSHAKE[BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=]}" 'handshake peer' assert_equal 100 "${RUNTIME_RX[BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=]}" 'rx peer' assert_equal 200 "${RUNTIME_TX[BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=]}" 'tx peer' assert_equal '192.0.2.2/32' "${RUNTIME_ALLOWED[BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=]}" 'allowed ips peer' parse_runtime_dump $'(none)\t(none)\t51820\t0\nBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=\t(none)\t[2001:db8::1]:51820\t192.0.2.2/32\t0\t0\t0\t0' assert_equal '[2001:db8::1]:51820' "${RUNTIME_ENDPOINT[BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=]}" 'focused runtime endpoint' set +e output=$(parse_runtime_dump $'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\tAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\t51820\t0\nBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=\tmalformed\t(none)\t(none)\t0\t0\t0\t0' 2>&1) status=$? set -e assert_equal "$EXIT_VALIDATION" "$status" 'malformed PSK отвергнут' assert_contains 'ключ' "$output" 'диагностика malformed PSK' ;; status) begin_test 'focused status output' cp -- "${PROJECT_DIR}/tests/fixtures/m2/runtime.dump" "$runtime_file" export WG_MOCK_DUMP_FILE="$runtime_file" WG_MOCK_INTERFACE=wg0 output=$("${PROJECT_DIR}/wg-manager.sh" status) assert_contains 'Интерфейс: wg0' "$output" 'status interface' assert_contains 'Управляемые: 1' "$output" 'status managed' ;; list) begin_test 'focused list output' cp -- "${PROJECT_DIR}/tests/fixtures/m2/runtime.dump" "$runtime_file" export WG_MOCK_DUMP_FILE="$runtime_file" WG_MOCK_INTERFACE=wg0 output=$("${PROJECT_DIR}/wg-manager.sh" list) assert_contains $'alice\t192.0.2.2/32' "$output" 'list row' assert_equal '' "$(printf '%s' "$output" | grep -E 'PrivateKey|PresharedKey' || true)" 'list secret-free' ;; list-redirected) begin_test 'focused redirected list' cp -- "${PROJECT_DIR}/tests/fixtures/m2/runtime.dump" "$runtime_file" export WG_MOCK_DUMP_FILE="$runtime_file" WG_MOCK_INTERFACE=wg0 "${PROJECT_DIR}/wg-manager.sh" list >"${TEST_ROOT}/focused-list.out" 2>"${TEST_ROOT}/focused-list.err" assert_contains $'alice\t192.0.2.2/32' "$(<"${TEST_ROOT}/focused-list.out")" 'redirected row' assert_equal '' "$(<"${TEST_ROOT}/focused-list.err")" 'redirected stderr empty' ;; list-deterministic) begin_test 'focused deterministic list ordering' unset WG_MOCK_DUMP_FILE cp -- "${PROJECT_DIR}/tests/fixtures/m2/multiple.conf" "$fixture" output=$("${PROJECT_DIR}/wg-manager.sh" list) cp -- "${PROJECT_DIR}/tests/fixtures/m2/multiple-reversed.conf" "$fixture" reversed_output=$("${PROJECT_DIR}/wg-manager.sh" list) assert_equal "$output" "$reversed_output" 'порядок list стабилен' ;; esac } if [[ -z $test_group ]]; then begin_test 'sourcing entry point does not execute main' source_output='' source_output=$(/bin/bash "${TESTS_DIR}/source-entry.sh" "${PROJECT_DIR}/wg-manager.sh") assert_equal sourced "$source_output" 'безопасная загрузка' begin_test 'help succeeds without root or dependencies' set +e help_output=$("${PROJECT_DIR}/wg-manager.sh" help 2>&1) help_status=$? set -e assert_success "$help_status" 'команда help' assert_contains 'Использование:' "$help_output" 'справка' assert_equal "$help_output" "${help_output//$'\e'/}" 'help без ANSI' begin_test 'version succeeds without config or dependencies' set +e version_output=$(env -u WG_MANAGER_CONFIG -u WG_MANAGER_GUM PATH=/usr/bin:/bin "${PROJECT_DIR}/wg-manager.sh" version 2>&1) version_status=$? set -e assert_success "$version_status" 'команда version' assert_contains 'WireGuard Manager 0.1.0' "$version_output" 'версия' assert_equal "$version_output" "${version_output//$'\e'/}" 'version без ANSI' begin_test 'extra arguments fail with usage status' set +e extra_output=$("${PROJECT_DIR}/wg-manager.sh" help extra 2>&1) extra_status=$? set -e assert_equal "$EXIT_USAGE" "$extra_status" 'лишний аргумент' assert_contains 'Ожидается одна команда' "$extra_output" 'диагностика лишнего аргумента' begin_test 'unknown command fails' set +e unknown_output=$("${PROJECT_DIR}/wg-manager.sh" unknown 2>&1) unknown_status=$? set -e assert_failure "$unknown_status" 'неизвестная команда' assert_contains 'неизвестная команда' "$unknown_output" 'сообщение об ошибке' begin_test 'help works outside project directory' set +e outside_output=$(cd /tmp && "${PROJECT_DIR}/wg-manager.sh" help 2>&1) outside_status=$? set -e assert_success "$outside_status" 'запуск из /tmp' assert_contains 'Использование:' "$outside_output" 'справка из /tmp' begin_test 'mock guard accepts only explicitly supported empty invocation' set +e MOCK_ALLOW_EMPTY_INVOCATION=wg wg expected_mock_status=$? wg unapproved_empty_status=$? wg unexpected unexpected_mock_status=$? set -e assert_success "$expected_mock_status" 'разрешённый вызов мока' assert_failure "$unapproved_empty_status" 'неразрешённый пустой вызов мока' assert_failure "$unexpected_mock_status" 'неожиданный вызов мока' expected_mock_log=$'wg\nwg\nwg unexpected\n' actual_mock_log=$(<"$TEST_MOCK_LOG") actual_mock_log+=$'\n' assert_equal "$expected_mock_log" "$actual_mock_log" 'точный журнал вызовов мока' begin_test 'test writes stay below isolated root' assert_file_exists "${TEST_ROOT}/wg0.conf" 'копия фикстуры' assert_equal "$TEST_ROOT" "${TEST_MOCK_LOG%/*}" 'журнал находится в тестовом корне' scan_test_sources_for_host_path() { local forbidden_path='/etc' forbidden_path+='/wireguard' scan_path() { local path line for path in "$1"/*; do if [[ -d "$path" ]]; then scan_path "$path" elif [[ -f "$path" ]]; then while IFS= read -r line || [[ -n "$line" ]]; do if [[ "$line" == *"$forbidden_path"* ]]; then record_failure "тестовый файл содержит запрещённый путь: ${path}" return fi done <"$path" fi done } scan_path "$TESTS_DIR" } scan_sources_for_absolute_external_commands() { local command_name candidate path line for command_name in wg wg-quick ip systemctl nft iptables journalctl sysctl; do candidate='/bin' candidate+="/${command_name}" for path in "$PROJECT_DIR/wg-manager.sh" "$TESTS_DIR"/*.sh "$TESTS_DIR"/mocks/bin/*; do [[ -f "$path" ]] || continue while IFS= read -r line || [[ -n "$line" ]]; do if [[ "$line" == "$candidate" || "$line" == *" $candidate"* || "$line" == *"\"$candidate"* || "$line" == *"'$candidate"* || "$line" == *"=$candidate"* ]]; then record_failure "абсолютный внешний вызов в ${path}: ${candidate}"; break; fi done <"$path" done candidate='/usr' candidate+="/bin/${command_name}" for path in "$PROJECT_DIR/wg-manager.sh" "$TESTS_DIR"/*.sh "$TESTS_DIR"/mocks/bin/*; do [[ -f "$path" ]] || continue while IFS= read -r line || [[ -n "$line" ]]; do if [[ "$line" == "$candidate" || "$line" == *" $candidate"* || "$line" == *"\"$candidate"* || "$line" == *"'$candidate"* || "$line" == *"=$candidate"* ]]; then record_failure "абсолютный внешний вызов в ${path}: ${candidate}"; break; fi done <"$path" done done } scan_test_sources_for_host_path scan_sources_for_absolute_external_commands fi run_m6_tests() { local config_file=$TEST_ROOT/m6.conf fixture=$TEST_ROOT/wg0.conf runtime_dir=$TEST_ROOT/dumps local output status before archive_dir cp -- "$PROJECT_DIR/tests/fixtures/m3/source.conf" "$fixture"; chmod 600 -- "$fixture" sed -i 's/wg-manager-client: fixture/wg-manager-client: alice/' "$fixture" sed -i '/\[Peer\]/{N; s/\[Peer\]\n# wg-manager-client: alice/# wg-manager-client: alice\n[Peer]/;}' "$fixture" write_m2_config "$config_file" "$fixture" replace_config_line "$config_file" 'CLIENT_DIR=' "CLIENT_DIR=\"$TEST_ROOT/client-files\"" export WG_MANAGER_CONFIG=$config_file WG_MANAGER_BACKUP_DIR=$TEST_ROOT/backups WG_MANAGER_LOG_DIR=$TEST_ROOT/logs WG_MANAGER_METADATA_DIR=$TEST_ROOT/clients WG_MANAGER_ARCHIVE_DIR=$TEST_ROOT/archive WG_MANAGER_LOCK_FILE=$TEST_ROOT/lock WG_MANAGER_NOW=1700000100 WG_MANAGER_TEST_MODE=1 export WG_MOCK_INTERFACE=wg0 TEST_MOCK_LOG=$TEST_ROOT/mocks.log WG_MOCK_DUMP_SEQUENCE_DIR=$runtime_dir set_runtime_paths load_config rm -rf -- "$TEST_ROOT/clients" "$TEST_ROOT/client-files" "$TEST_ROOT/archive" "$TEST_ROOT/logs" "$runtime_dir" mkdir -p -- "$METADATA_DIR" "$CLIENT_DIR" "$runtime_dir"; chmod 700 -- "$METADATA_DIR" "$CLIENT_DIR" "$runtime_dir" write_alice_metadata; chmod 600 -- "$METADATA_DIR/alice.meta" printf '[Interface]\nAddress = 192.0.2.2/32\nPrivateKey = EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE=\n' >"$CLIENT_DIR/alice.conf" printf 'EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE=\n' >"$CLIENT_DIR/alice.key" printf 'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF=\n' >"$CLIENT_DIR/alice.pub" printf 'GGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGG=\n' >"$CLIENT_DIR/alice.psk"; chmod 600 -- "$CLIENT_DIR"/alice.* printf 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\tAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\t51820\t0\nBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=\t(none)\tvpn.example.invalid:51820\t192.0.2.2/32\t0\t0\t0\t0\n' >"$runtime_dir/1" printf 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\tAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\t51820\t0\n' >"$runtime_dir/2" printf 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\tAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\t51820\t0\nBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=\t(none)\tvpn.example.invalid:51820\t192.0.2.2/32\t0\t0\t0\t0\n' >"$runtime_dir/3" if [[ $test_group == remove-exact-block || $test_group == remove-preserves-neighbors ]]; then cat >"$fixture" <<'EOF' [Interface] Address = 192.0.2.1/24 ListenPort = 51820 PrivateKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= # wg-manager-client: before [Peer] PublicKey = CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC= AllowedIPs = 192.0.2.3/32 Endpoint = before.example.invalid:51820 # wg-manager-client: alice [Peer] PublicKey = BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB= AllowedIPs = 192.0.2.2/32 Endpoint = vpn.example.invalid:51820 # wg-manager-client: after [Peer] PublicKey = DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD= AllowedIPs = 192.0.2.4/32 Endpoint = after.example.invalid:51820 EOF printf 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\tAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\t51820\t0\nCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC=\t(none)\tbefore.example.invalid:51820\t192.0.2.3/32\t0\t0\t0\t0\nBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=\t(none)\tvpn.example.invalid:51820\t192.0.2.2/32\t0\t0\t0\t0\nDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD=\t(none)\tafter.example.invalid:51820\t192.0.2.4/32\t0\t0\t0\t0\n' >"$runtime_dir/1" printf 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\tAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\t51820\t0\nCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC=\t(none)\tbefore.example.invalid:51820\t192.0.2.3/32\t0\t0\t0\t0\nDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD=\t(none)\tafter.example.invalid:51820\t192.0.2.4/32\t0\t0\t0\t0\n' >"$runtime_dir/2" begin_test 'remove exact block preserves byte-identical neighboring peers' local before_neighbor after_neighbor after_before after_after before_neighbor=$(awk '/# wg-manager-client: before/,/Endpoint = before.example.invalid:51820/' "$fixture") after_neighbor=$(awk '/# wg-manager-client: after/{on=1} on{print}' "$fixture") set +e; output=$(remove_client alice --yes 2>&1); status=$?; set -e assert_equal 0 "$status" 'удаление точного блока с соседями' after_before=$(awk '/# wg-manager-client: before/,/Endpoint = before.example.invalid:51820/' "$fixture") after_after=$(awk '/# wg-manager-client: after/{on=1} on{print}' "$fixture") assert_equal "$before_neighbor" "$after_before" 'предшествующий peer сохранён побайтно' assert_equal "$after_neighbor" "$after_after" 'следующий peer сохранён побайтно' assert_equal '' "$(grep -E 'BBBB|wg-manager-client: alice|192\.0\.2\.2/32' "$fixture" || true)" 'target block removed completely' assert_equal '' "$(grep -E 'PrivateKey|PresharedKey|EEEE|GGGG' <<<"$output" || true)" 'neighbor removal output is secret-free' elif [[ $test_group == remove-client ]]; then begin_test 'remove identity and argument refusals preserve the source' local remove_before=$(sha256sum -- "$fixture") set +e; output=$(remove_client alice extra 2>&1); status=$?; set -e assert_equal "$EXIT_USAGE" "$status" 'лишний аргумент remove отклонён' assert_equal "$remove_before" "$(sha256sum -- "$fixture")" 'лишний аргумент не меняет конфиг' set +e; output=$(remove_client alice --yes --expected-key BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB= 2>&1); status=$?; set -e assert_equal "$EXIT_USAGE" "$status" 'публичный remove не принимает ключ в аргументах' assert_equal "$remove_before" "$(sha256sum -- "$fixture")" 'запрещённый ключ не меняет конфиг' set +e; output=$(/bin/bash "$PROJECT_DIR/wg-manager.sh" remove 2>&1); status=$?; set -e assert_equal "$EXIT_USAGE" "$status" 'отсутствующее имя remove отклонено' set +e; output=$(remove_client absent --yes 2>&1); status=$?; set -e assert_equal "$EXIT_VALIDATION" "$status" 'отсутствующий клиент отклонён' assert_equal "$remove_before" "$(sha256sum -- "$fixture")" 'отсутствующий клиент не меняет конфиг' cp -- "$PROJECT_DIR/tests/fixtures/m3/source.conf" "$fixture" sed -i 's/wg-manager-client: fixture/### Client legacy/' "$fixture" rm -f -- "$METADATA_DIR/alice.meta" set +e; output=$(remove_client legacy --yes 2>&1); status=$?; set -e assert_equal "$EXIT_VALIDATION" "$status" 'legacy peer без metadata отклонён' cp -- "$PROJECT_DIR/tests/fixtures/m3/source.conf" "$fixture" sed -i 's/wg-manager-client: fixture/wg-manager-client: alice/' "$fixture" sed -i '/\[Peer\]/{N; s/\[Peer\]\n# wg-manager-client: alice/# wg-manager-client: alice\n[Peer]/;}' "$fixture" write_alice_metadata replace_config_line "$METADATA_DIR/alice.meta" 'PUBLIC_KEY=' 'PUBLIC_KEY=FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF=' set +e; output=$(remove_client alice --yes 2>&1); status=$?; set -e assert_equal "$EXIT_VALIDATION" "$status" 'metadata public key mismatch отклонён' assert_equal "$remove_before" "$(sha256sum -- "$fixture")" 'public key mismatch не меняет конфиг' write_alice_metadata replace_config_line "$METADATA_DIR/alice.meta" 'IPV4=' 'IPV4=192.0.2.99' set +e; output=$(remove_client alice --yes 2>&1); status=$?; set -e assert_equal "$EXIT_VALIDATION" "$status" 'несовпадение адреса managed identity отклонено' assert_equal "$remove_before" "$(sha256sum -- "$fixture")" 'несовпадение identity не меняет конфиг' cat >>"$fixture" <<'EOF' # wg-manager-client: alice [Peer] PublicKey = CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC= AllowedIPs = 192.0.2.3/32 EOF printf 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\tAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\t51820\t0\nBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=\t(none)\tvpn.example.invalid:51820\t192.0.2.2/32\t0\t0\t0\t0\nCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC=\t(none)\tvpn.example.invalid:51820\t192.0.2.3/32\t0\t0\t0\t0\n' >"$runtime_dir/1" rm -f -- "$runtime_dir/counter" set +e; output=$(remove_client alice --yes 2>&1); status=$?; set -e assert_equal "$EXIT_VALIDATION" "$status" 'duplicate display name is refused' cp -- "$PROJECT_DIR/tests/fixtures/m3/source.conf" "$fixture" sed -i 's/wg-manager-client: fixture/wg-manager-client: alice/' "$fixture" sed -i '/\[Peer\]/{N; s/\[Peer\]\n# wg-manager-client: alice/# wg-manager-client: alice\n[Peer]/;}' "$fixture" write_alice_metadata replace_config_line "$METADATA_DIR/alice.meta" 'PUBLIC_KEY=' 'PUBLIC_KEY=FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF=' replace_config_line "$METADATA_DIR/alice.meta" 'IPV4=' 'IPV4=192.0.2.3' printf 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\tAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\t51820\t0\nFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF=\t(none)\tvpn.example.invalid:51820\t192.0.2.3/32\t0\t0\t0\t0\n' >"$runtime_dir/1" rm -f -- "$runtime_dir/counter" set +e; output=$(remove_client alice --yes 2>&1); status=$?; set -e assert_equal "$EXIT_VALIDATION" "$status" 'runtime-only identity is refused' cp -- "$PROJECT_DIR/tests/fixtures/m3/source.conf" "$fixture" sed -i 's/wg-manager-client: fixture/### Client alice/' "$fixture" write_alice_metadata set +e; output=$(remove_client alice --yes 2>&1); status=$?; set -e assert_equal "$EXIT_VALIDATION" "$status" 'legacy marker is not a managed identity' cp -- "$PROJECT_DIR/tests/fixtures/m3/source.conf" "$fixture" sed -i 's/wg-manager-client: fixture/wg-manager-client: alice/' "$fixture" sed -i '/\[Peer\]/{N; s/\[Peer\]\n# wg-manager-client: alice/# wg-manager-client: alice\n[Peer]/;}' "$fixture" write_alice_metadata printf '\n# wg-manager-client: alice\n[Peer]\nPublicKey = BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=\nAllowedIPs = 192.0.2.2/32\nEndpoint = vpn.example.invalid:51820\n' >>"$fixture" local duplicate_before=$(sha256sum -- "$fixture") set +e; output=$(remove_client alice --yes 2>&1); status=$?; set -e assert_equal "$EXIT_VALIDATION" "$status" 'duplicate exact managed block is refused' assert_equal "$duplicate_before" "$(sha256sum -- "$fixture")" 'duplicate block leaves source unchanged' cp -- "$PROJECT_DIR/tests/fixtures/m3/source.conf" "$fixture" sed -i 's/wg-manager-client: fixture/wg-manager-client: alice/' "$fixture" sed -i '/\[Peer\]/{N; s/\[Peer\]\n# wg-manager-client: alice/# wg-manager-client: alice\n[Peer]/;}' "$fixture" write_alice_metadata begin_test 'locked identity revalidation rejects metadata changed after confirmation' load_client_model local race_index race_key race_path race_index=$(resolve_named_client alice); race_key=${CLIENT_KEYS[$race_index]}; race_path=${META_CONFIG[$race_key]} REMOVE_NAME=alice REMOVE_KEY=$race_key REMOVE_CONFIG_PATH=$race_path REMOVE_META_FILE=${META_FILE[$race_key]} REMOVE_ALLOWED=${DISK_ALLOWED[$race_key]} remove_builder_mutate() { replace_config_line "$REMOVE_META_FILE" 'IPV4=' 'IPV4=192.0.2.99'; parse_metadata; remove_peer_builder "$1"; } local race_before=$(sha256sum -- "$fixture") set +e; output=$(run_transaction remove alice remove_builder_mutate "$REMOVE_META_FILE" 2>&1); status=$?; set -e unset -f remove_builder_mutate assert_equal "$EXIT_VALIDATION" "$status" 'изменение metadata после lock отклонено' assert_equal "$race_before" "$(sha256sum -- "$fixture")" 'race не изменяет серверный конфиг' cp -- "$PROJECT_DIR/tests/fixtures/m3/source.conf" "$fixture" sed -i 's/wg-manager-client: fixture/wg-manager-client: alice/' "$fixture" sed -i '/\[Peer\]/{N; s/\[Peer\]\n# wg-manager-client: alice/# wg-manager-client: alice\n[Peer]/;}' "$fixture" write_alice_metadata elif [[ $test_group == remove-cancel ]]; then begin_test 'explicit cancellation helper leaves removal untouched' local cancel_before=$(sha256sum -- "$fixture") set +e; output=$(printf 'n\n' | confirm_remove alice 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=' '192.0.2.2/32' 2>&1); status=$?; set -e assert_equal 1 "$status" 'отмена подтверждения возвращает отказ' assert_contains 'Удалить клиента alice' "$output" 'подтверждение показывает имя' assert_equal "$cancel_before" "$(sha256sum -- "$fixture")" 'отмена не меняет конфиг' elif [[ -z $test_group || $test_group == remove-client || $test_group == archive-client ]]; then begin_test 'managed remove archives artifacts and removes exact peer' before=$(sha256sum -- "$fixture") local meta_before conf_before key_before pub_before psk_before meta_before=$(sha256sum -- "$METADATA_DIR/alice.meta") conf_before=$(sha256sum -- "$CLIENT_DIR/alice.conf") key_before=$(sha256sum -- "$CLIENT_DIR/alice.key") pub_before=$(sha256sum -- "$CLIENT_DIR/alice.pub") psk_before=$(sha256sum -- "$CLIENT_DIR/alice.psk") set +e; output=$(remove_client alice --yes 2>&1); status=$?; set -e assert_equal 0 "$status" 'успешное удаление managed клиента' assert_equal '' "$(grep -F 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=' "$fixture" || true)" 'точный peer удалён' archive_dir=$(find "$TEST_ROOT/archive" -mindepth 1 -maxdepth 1 -type d -print -quit) assert_equal 0 "$([[ -f $archive_dir/alice.meta ]] && printf 0 || printf 1)" 'метаданные архивированы' for archived_name in alice.meta alice.conf alice.key alice.pub alice.psk; do local original_path if [[ $archived_name == alice.meta ]]; then original_path="$METADATA_DIR/alice.meta"; else original_path="$CLIENT_DIR/$archived_name"; fi assert_equal 0 "$([[ -f $archive_dir/$archived_name && $(stat -c '%a' -- "$archive_dir/$archived_name") == 600 ]] && printf 0 || printf 1)" "архивный ${archived_name} защищён" assert_equal 1 "$([[ -e $original_path || -L $original_path ]] && printf 0 || printf 1)" "исходный ${archived_name} удалён" done local archived_hash archived_hash=$(sha256sum -- "$archive_dir/alice.meta"); assert_equal "${meta_before%% *}" "${archived_hash%% *}" 'архив метаданных побайтно совпадает' archived_hash=$(sha256sum -- "$archive_dir/alice.conf"); assert_equal "${conf_before%% *}" "${archived_hash%% *}" 'архив конфига побайтно совпадает' archived_hash=$(sha256sum -- "$archive_dir/alice.key"); assert_equal "${key_before%% *}" "${archived_hash%% *}" 'архив ключа побайтно совпадает' archived_hash=$(sha256sum -- "$archive_dir/alice.pub"); assert_equal "${pub_before%% *}" "${archived_hash%% *}" 'архив public побайтно совпадает' archived_hash=$(sha256sum -- "$archive_dir/alice.psk"); assert_equal "${psk_before%% *}" "${archived_hash%% *}" 'архив PSK побайтно совпадает' assert_equal 700 "$(stat -c '%a' -- "$archive_dir")" 'каталог архива защищён' assert_equal '' "$(grep -E 'EEEE|GGGG|PrivateKey|PresharedKey' <<<"$output" || true)" 'вывод удаления без секретов' assert_contains 'operation=remove' "$(<"$TEST_ROOT/logs/wg-manager.log")" 'аудит удаления' assert_contains 'client=alice' "$(<"$TEST_ROOT/logs/wg-manager.log")" 'аудит содержит client' assert_contains 'actor_uid=' "$(<"$TEST_ROOT/logs/wg-manager.log")" 'аудит содержит actor uid' assert_contains 'error=success' "$(<"$TEST_ROOT/logs/wg-manager.log")" 'аудит содержит outcome category' assert_contains 'timestamp=2023-11-14T22:15:00Z' "$(<"$TEST_ROOT/logs/wg-manager.log")" 'аудит содержит детерминированное UTC-время' assert_equal '' "$(grep -E 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=|EEEE|GGGG|PrivateKey|PresharedKey' "$TEST_ROOT/logs/wg-manager.log" || true)" 'аудит без секретов' cp -- "$PROJECT_DIR/tests/fixtures/m3/source.conf" "$fixture" sed -i 's/wg-manager-client: fixture/wg-manager-client: alice/' "$fixture" sed -i '/\[Peer\]/{N; s/\[Peer\]\n# wg-manager-client: alice/# wg-manager-client: alice\n[Peer]/;}' "$fixture" write_alice_metadata printf '[Interface]\nPrivateKey = EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE=\nAddress = 192.0.2.2/32\n' >"$CLIENT_DIR/alice.conf" printf 'EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE=\n' >"$CLIENT_DIR/alice.key" printf 'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF=\n' >"$CLIENT_DIR/alice.pub" printf 'GGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGG=\n' >"$CLIENT_DIR/alice.psk" chmod 600 -- "$CLIENT_DIR"/* "$METADATA_DIR/alice.meta" printf 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\tAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\t51820\t0\n' >"$runtime_dir/1" printf 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\tAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\t51820\t0\n' >"$runtime_dir/2" printf 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\tAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\t51820\t0\nBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=\t(none)\tvpn.example.invalid:51820\t192.0.2.2/32\t0\t0\t0\t0\n' >"$runtime_dir/3" rm -f -- "$runtime_dir/counter" local fail_meta fail_conf fail_key fail_pub fail_psk archive_count fail_meta=$(sha256sum -- "$METADATA_DIR/alice.meta"); fail_conf=$(sha256sum -- "$CLIENT_DIR/alice.conf") fail_key=$(sha256sum -- "$CLIENT_DIR/alice.key"); fail_pub=$(sha256sum -- "$CLIENT_DIR/alice.pub"); fail_psk=$(sha256sum -- "$CLIENT_DIR/alice.psk") archive_count=$(find "$ARCHIVE_DIR" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | wc -l) export M6_ARCHIVE_COPY_FAIL_INDEX=1 set +e; output=$(remove_client alice --yes 2>&1); status=$?; set -e unset M6_ARCHIVE_COPY_FAIL_INDEX assert_equal "$EXIT_OPERATION" "$status" 'ошибка архивации возвращает operation' assert_contains 'Ошибка:' "$output" 'ошибка архивации имеет диагностику' assert_contains 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=' "$(<"$fixture")" 'при ошибке архивации peer восстановлен' assert_equal 0 "$([[ -f $CLIENT_DIR/alice.conf && -f $METADATA_DIR/alice.meta ]] && printf 0 || printf 1)" 'исходные артефакты доступны для восстановления' for artifact in "$METADATA_DIR/alice.meta" "$CLIENT_DIR/alice.conf" "$CLIENT_DIR/alice.key" "$CLIENT_DIR/alice.pub" "$CLIENT_DIR/alice.psk"; do assert_equal 600 "$(stat -c '%a' -- "$artifact")" "copy failure сохраняет режим ${artifact##*/}" done assert_equal "${fail_meta%% *}" "$(sha256sum -- "$METADATA_DIR/alice.meta" | cut -d' ' -f1)" 'copy failure сохраняет metadata bytes' assert_equal "${fail_conf%% *}" "$(sha256sum -- "$CLIENT_DIR/alice.conf" | cut -d' ' -f1)" 'copy failure сохраняет config bytes' assert_equal "$archive_count" "$(find "$ARCHIVE_DIR" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | wc -l)" 'copy failure cleans incomplete archive' export M6_ARCHIVE_COPY_FAIL_INDEX=4 rm -f -- "$runtime_dir/counter" set +e; output=$(remove_client alice --yes 2>&1); status=$?; set -e unset M6_ARCHIVE_COPY_FAIL_INDEX assert_equal "$EXIT_OPERATION" "$status" 'ошибка финального копирования возвращает operation' assert_equal 0 "$([[ -f $CLIENT_DIR/alice.conf && -f $METADATA_DIR/alice.meta ]] && printf 0 || printf 1)" 'финальная ошибка сохраняет исходные артефакты' assert_equal "${fail_key%% *}" "$(sha256sum -- "$CLIENT_DIR/alice.key" | cut -d' ' -f1)" 'final copy failure сохраняет key bytes' assert_equal "${fail_pub%% *}" "$(sha256sum -- "$CLIENT_DIR/alice.pub" | cut -d' ' -f1)" 'final copy failure сохраняет pub bytes' assert_equal "${fail_psk%% *}" "$(sha256sum -- "$CLIENT_DIR/alice.psk" | cut -d' ' -f1)" 'final copy failure сохраняет psk bytes' rm -f -- "$runtime_dir/counter" export M6_ARCHIVE_REMOVE_FAIL_INDEX=1 set +e; output=$(remove_client alice --yes 2>&1); status=$?; set -e unset M6_ARCHIVE_REMOVE_FAIL_INDEX assert_equal "$EXIT_OPERATION" "$status" 'ошибка удаления после частичной архивации возвращает operation' assert_equal 0 "$([[ -f $CLIENT_DIR/alice.conf && -f $CLIENT_DIR/alice.key && -f $METADATA_DIR/alice.meta ]] && printf 0 || printf 1)" 'частичная архивация восстановила исходные файлы' assert_equal '' "$(grep -E 'PrivateKey|PresharedKey|EEEE|GGGG' <<<"$output" || true)" 'ошибка удаления без секретов' begin_test 'artifact recovery failure remains critical after server rollback' rm -f -- "$runtime_dir/counter" printf 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\tAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\t51820\t0\n' >"$runtime_dir/1" printf 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\tAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\t51820\t0\n' >"$runtime_dir/2" printf 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\tAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\t51820\t0\nBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=\t(none)\tvpn.example.invalid:51820\t192.0.2.2/32\t0\t0\t0\t0\n' >"$runtime_dir/3" export M6_ARCHIVE_REMOVE_FAIL_INDEX=1 M6_ARCHIVE_RECOVERY_FAIL_INDEX=1 set +e; output=$(remove_client alice --yes 2>&1); status=$?; set -e unset M6_ARCHIVE_REMOVE_FAIL_INDEX M6_ARCHIVE_RECOVERY_FAIL_INDEX assert_equal "$EXIT_ROLLBACK" "$status" 'ошибка recovery сохраняет critical status' assert_contains 'КРИТИЧНО' "$output" 'ошибка recovery явно critical' assert_contains 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=' "$(<"$fixture")" 'server rollback выполнен при recovery failure' assert_equal 0 "$( [[ -n $(find "$ARCHIVE_DIR" -mindepth 2 -type f \( -name alice.meta -o -name alice.conf \) -print -quit) ]] && printf 0 || printf 1 )" 'recovery archive remains available' assert_equal '' "$(grep -E 'PrivateKey|PresharedKey|EEEE|GGGG' <<<"$output" || true)" 'recovery failure без секретов' write_alice_metadata printf '[Interface]\nPrivateKey = EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE=\nAddress = 192.0.2.2/32\n' >"$CLIENT_DIR/alice.conf" printf 'EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE=\n' >"$CLIENT_DIR/alice.key" printf 'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF=\n' >"$CLIENT_DIR/alice.pub" printf 'GGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGG=\n' >"$CLIENT_DIR/alice.psk" chmod 600 -- "$CLIENT_DIR"/* "$METADATA_DIR/alice.meta" begin_test 'post-mutation audit failure reports partial success safely' rm -f -- "$runtime_dir/counter" printf 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\tAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\t51820\t0\n' >"$runtime_dir/1" printf 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\tAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\t51820\t0\n' >"$runtime_dir/2" export M6_AUDIT_FAIL=1 set +e; output=$(remove_client alice --yes 2>&1); status=$?; set -e unset M6_AUDIT_FAIL assert_equal "$EXIT_OPERATION" "$status" 'ошибка аудита после mutation возвращает operation' assert_contains 'частичный результат' "$output" 'ошибка аудита явно обозначает partial success' assert_equal '' "$(grep -F 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=' "$fixture" || true)" 'при audit failure peer остаётся удалённым' assert_equal 1 "$( [[ ! -e $CLIENT_DIR/alice.conf && ! -e $METADATA_DIR/alice.meta ]] && printf 1 || printf 0 )" 'при audit failure исходные артефакты архивированы' assert_equal '' "$(grep -E 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=|EEEE|GGGG|PrivateKey|PresharedKey' <<<"$output" || true)" 'audit failure без секретов' cp -- "$PROJECT_DIR/tests/fixtures/m3/source.conf" "$fixture" sed -i 's/wg-manager-client: fixture/wg-manager-client: alice/' "$fixture" sed -i '/\[Peer\]/{N; s/\[Peer\]\n# wg-manager-client: alice/# wg-manager-client: alice\n[Peer]/;}' "$fixture" write_alice_metadata printf '[Interface]\nPrivateKey = EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE=\nAddress = 192.0.2.2/32\n' >"$CLIENT_DIR/alice.conf" chmod 600 -- "$CLIENT_DIR/alice.conf" begin_test 'optional client artifacts are not fabricated' rm -f -- "$CLIENT_DIR/alice.key" "$CLIENT_DIR/alice.pub" "$CLIENT_DIR/alice.psk" "$runtime_dir/counter" printf 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\tAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\t51820\t0\n' >"$runtime_dir/1" printf 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\tAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\t51820\t0\n' >"$runtime_dir/2" set +e; output=$(remove_client alice --yes 2>&1); status=$?; set -e assert_equal 0 "$status" 'удаление без optional artifacts успешно' local optional_archive optional_archive=$(find "$ARCHIVE_DIR" -mindepth 1 -maxdepth 1 -type d -printf '%T@ %p\n' | sort -n | tail -n1 | cut -d' ' -f2-) assert_equal 1 "$( [[ -f $optional_archive/alice.meta && -f $optional_archive/alice.conf && ! -e $optional_archive/alice.key && ! -e $optional_archive/alice.pub && ! -e $optional_archive/alice.psk ]] && printf 1 || printf 0 )" 'optional artifacts не создаются в архиве' fi if [[ $test_group == remove-cancel ]]; then begin_test 'simulated interactive cancellation previews safe identity' remove_is_interactive() { return 0; } local interactive_before=$(sha256sum -- "$fixture") set +e; output=$(printf 'n\n' | remove_client alice 2>&1); status=$?; set -e unset -f remove_is_interactive remove_is_interactive() { [[ -t 0 && -t 1 ]]; } assert_equal 0 "$status" 'интерактивная отмена успешна' assert_contains 'Удалить клиента alice' "$output" 'preview содержит имя' assert_contains '192.0.2.2/32' "$output" 'preview содержит адрес' assert_contains '*BBBBBBB=' "$output" 'preview содержит только суффикс ключа' assert_equal '' "$(grep -E 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=|PrivateKey|PresharedKey|EEEE|GGGG' <<<"$output" || true)" 'preview без секретов' assert_equal "$interactive_before" "$(sha256sum -- "$fixture")" 'интерактивная отмена без изменений' begin_test 'cancelled removal survives audit failure without mutation' remove_is_interactive() { return 0; } export M6_AUDIT_FAIL=1 set +e; output=$(printf 'n\n' | remove_client alice 2>&1); status=$?; set -e unset M6_AUDIT_FAIL unset -f remove_is_interactive remove_is_interactive() { [[ -t 0 && -t 1 ]]; } assert_equal 0 "$status" 'отмена при ошибке аудита остаётся успешной' assert_contains 'аудит не записан' "$output" 'ошибка аудита отмены предупреждается' assert_equal "$interactive_before" "$(sha256sum -- "$fixture")" 'ошибка аудита отмены без изменений' begin_test 'noninteractive removal requires explicit confirmation' before=$(sha256sum -- "$fixture") set +e; remove_client alice >/dev/null 2>"$TEST_ROOT/cancel.err"; status=$?; set -e assert_equal "$EXIT_USAGE" "$status" 'без --yes требуется подтверждение' assert_equal "$before" "$(sha256sum -- "$fixture")" 'отмена не меняет конфиг' set +e; remove_client alice --yes --yes >/dev/null 2>"$TEST_ROOT/cancel.err"; status=$?; set -e assert_equal "$EXIT_USAGE" "$status" 'дублированный --yes отклонён' set +e; remove_client alice --bad >/dev/null 2>"$TEST_ROOT/cancel.err"; status=$?; set -e assert_equal "$EXIT_USAGE" "$status" 'неизвестный флаг отклонён' fi if [[ $test_group == remove-rollback ]]; then begin_test 'apply failure rolls back peer and preserves artifacts' cp -- "$runtime_dir/3" "$runtime_dir/2" export WG_SYNC_FAIL_ONCE_FILE=$TEST_ROOT/sync-fail set +e; remove_client alice --yes >/dev/null 2>"$TEST_ROOT/rollback.err"; status=$?; set -e assert_equal "$EXIT_OPERATION" "$status" 'ошибка apply возвращает operation' assert_contains 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=' "$(<"$fixture")" 'peer восстановлен' assert_equal 0 "$([[ -f $CLIENT_DIR/alice.conf && -f $METADATA_DIR/alice.meta ]] && printf 0 || printf 1)" 'артефакты сохранены' unset WG_SYNC_FAIL_ONCE_FILE begin_test 'post-apply verification failure rolls back disk runtime and artifacts' local rollback_before=$(sha256sum -- "$fixture") rm -f -- "$runtime_dir/counter" printf 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\tAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\t51819\t0\n' >"$runtime_dir/1" printf 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\tAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\t51820\t0\nBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=\t(none)\tvpn.example.invalid:51820\t192.0.2.2/32\t0\t0\t0\t0\n' >"$runtime_dir/2" set +e; output=$(remove_client alice --yes 2>&1); status=$?; set -e assert_equal "$EXIT_OPERATION" "$status" 'ошибка post-apply verification возвращает operation' assert_equal "$rollback_before" "$(sha256sum -- "$fixture")" 'verification rollback restores exact config' assert_equal 0 "$([[ -f $CLIENT_DIR/alice.conf && -f $METADATA_DIR/alice.meta ]] && printf 0 || printf 1)" 'verification rollback сохраняет артефакты' assert_equal '' "$(grep -E 'PrivateKey|PresharedKey|EEEE|GGGG' <<<"$output" || true)" 'verification failure is secret-free' fi if [[ $test_group == archive-path-safety ]]; then begin_test 'unsafe archive path is rejected' local archive_link=$TEST_ROOT/archive-link ln -s -- "$TEST_ROOT" "$archive_link"; export WG_MANAGER_ARCHIVE_DIR=$archive_link set +e; remove_client alice --yes >/dev/null 2>"$TEST_ROOT/archive.err"; status=$?; set -e assert_equal "$EXIT_VALIDATION" "$status" 'symlink archive rejected' assert_equal 0 "$([[ -f $CLIENT_DIR/alice.conf ]] && printf 0 || printf 1)" 'при unsafe archive файлы сохранены' export WG_MANAGER_ARCHIVE_DIR="$TEST_ROOT/archive" REMOVE_NAME=alice REMOVE_KEY=BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB= REMOVE_CONFIG_PATH="$CLIENT_DIR/alice.conf" REMOVE_META_FILE="$METADATA_DIR/alice.meta" begin_test 'archive validates source identity, modes, links, and collisions' printf 'sentinel\n' >"$TEST_ROOT/archive-sentinel" mv -- "$CLIENT_DIR/alice.conf" "$CLIENT_DIR/alice.real.conf" ln -s -- "$TEST_ROOT/archive-sentinel" "$CLIENT_DIR/alice.conf" set +e; archive_remove_artifacts >/dev/null 2>"$TEST_ROOT/archive.err"; status=$?; set -e assert_equal "$EXIT_VALIDATION" "$status" 'symlink source config rejected' assert_equal sentinel "$(<"$TEST_ROOT/archive-sentinel")" 'symlink target preserved' rm -- "$CLIENT_DIR/alice.conf"; mv -- "$CLIENT_DIR/alice.real.conf" "$CLIENT_DIR/alice.conf" chmod 644 "$CLIENT_DIR/alice.conf" set +e; archive_remove_artifacts >/dev/null 2>"$TEST_ROOT/archive.err"; status=$?; set -e assert_equal "$EXIT_VALIDATION" "$status" 'permissive source config rejected' chmod 600 "$CLIENT_DIR/alice.conf" local archive_real_stat archive_real_stat=$(command -v stat) stat() { if [[ ${2:-} == '%u:%g' && ${4:-} == "$CLIENT_DIR/alice.conf" ]]; then printf '999:999\n'; return 0; fi "$archive_real_stat" "$@" } set +e; output=$(archive_remove_artifacts 2>&1); status=$?; set -e unset -f stat assert_equal "$EXIT_VALIDATION" "$status" 'owner mismatch source rejected' assert_contains 'небезопасные атрибуты' "$output" 'owner mismatch archive diagnostic' ln -- "$CLIENT_DIR/alice.conf" "$TEST_ROOT/alice.conf.link" set +e; archive_remove_artifacts >/dev/null 2>"$TEST_ROOT/archive.err"; status=$?; set -e assert_equal "$EXIT_VALIDATION" "$status" 'hardlinked source config rejected' rm -- "$TEST_ROOT/alice.conf.link" mv -- "$METADATA_DIR/alice.meta" "$METADATA_DIR/alice.real.meta" ln -s -- "$TEST_ROOT/archive-sentinel" "$METADATA_DIR/alice.meta" set +e; archive_remove_artifacts >/dev/null 2>"$TEST_ROOT/archive.err"; status=$?; set -e assert_equal "$EXIT_VALIDATION" "$status" 'symlink source metadata rejected' rm -- "$METADATA_DIR/alice.meta"; mv -- "$METADATA_DIR/alice.real.meta" "$METADATA_DIR/alice.meta" chmod 644 "$METADATA_DIR/alice.meta" set +e; archive_remove_artifacts >/dev/null 2>"$TEST_ROOT/archive.err"; status=$?; set -e assert_equal "$EXIT_VALIDATION" "$status" 'permissive source metadata rejected' chmod 600 "$METADATA_DIR/alice.meta" ln -- "$METADATA_DIR/alice.meta" "$TEST_ROOT/alice.meta.link" set +e; archive_remove_artifacts >/dev/null 2>"$TEST_ROOT/archive.err"; status=$?; set -e assert_equal "$EXIT_VALIDATION" "$status" 'hardlinked source metadata rejected' rm -- "$TEST_ROOT/alice.meta.link" printf 'old\n' >"$TEST_ROOT/archive-existing" rm -f -- "$TEST_ROOT/archive-existing" local archive_stamp archive_stamp=$(date -u -d @1700000100 +%Y%m%dT%H%M%SZ) rm -rf -- "$ARCHIVE_DIR/${archive_stamp}-wg0-alice" mkdir -- "$ARCHIVE_DIR/${archive_stamp}-wg0-alice"; chmod 700 -- "$ARCHIVE_DIR/${archive_stamp}-wg0-alice" write_alice_metadata set +e; archive_remove_artifacts >/dev/null 2>"$TEST_ROOT/archive.err"; status=$?; set -e assert_equal 0 "$status" 'archive collision receives a unique suffix' assert_equal 0 "$([[ -d $ARCHIVE_DIR/${archive_stamp}-wg0-alice ]] && printf 0 || printf 1)" 'existing archive preserved' assert_equal "${ARCHIVE_DIR}/${archive_stamp}-wg0-alice-1" "$REMOVE_ARCHIVE_PATH" 'archive uses exact collision suffix' for archived_name in alice.meta alice.conf alice.key alice.pub alice.psk; do assert_equal 0 "$([[ -f ${REMOVE_ARCHIVE_PATH}/${archived_name} && $(stat -c '%a' -- "${REMOVE_ARCHIVE_PATH}/${archived_name}") == 600 ]] && printf 0 || printf 1)" "архив содержит ${archived_name} с режимом 600" done assert_equal 700 "$(stat -c '%a' -- "$ARCHIVE_DIR")" 'корень архива имеет режим 700' assert_equal 700 "$(stat -c '%a' -- "$REMOVE_ARCHIVE_PATH")" 'каталог архива имеет режим 700' begin_test 'audit rejects symlinked log objects without mutation' local audit_target=$TEST_ROOT/audit-sentinel printf 'sentinel\n' >"$audit_target" rm -rf -- "$LOG_DIR" ln -s -- "$audit_target" "$LOG_DIR" set +e; output=$(REMOVE_NAME=alice REMOVE_KEY=BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB= remove_audit failed 2>&1); status=$?; set -e assert_failure "$status" 'symlinked log directory rejected' assert_equal sentinel "$(<"$audit_target")" 'audit symlink target preserved' rm -f -- "$LOG_DIR"; mkdir -- "$LOG_DIR"; chmod 700 -- "$LOG_DIR" ln -s -- "$audit_target" "$LOG_DIR/wg-manager.log" set +e; output=$(REMOVE_NAME=alice REMOVE_KEY=BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB= remove_audit failed 2>&1); status=$?; set -e assert_failure "$status" 'symlinked audit file rejected' assert_equal sentinel "$(<"$audit_target")" 'audit file target preserved' rm -f -- "$LOG_DIR/wg-manager.log" begin_test 'audit log directory mode and owner are validated' chmod 755 -- "$LOG_DIR" set +e; output=$(REMOVE_NAME=alice REMOVE_KEY=BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB= remove_audit failed 2>&1); status=$?; set -e assert_equal "$EXIT_VALIDATION" "$status" 'permissive log directory rejected' chmod 700 -- "$LOG_DIR" local log_real_stat log_real_stat=$(command -v stat) stat() { if [[ ${2:-} == '%u:%g' && ${4:-} == "$LOG_DIR" ]]; then printf '999:999\n'; return 0; fi "$log_real_stat" "$@" } set +e; output=$(REMOVE_NAME=alice REMOVE_KEY=BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB= remove_audit failed 2>&1); status=$?; set -e unset -f stat assert_equal "$EXIT_VALIDATION" "$status" 'owner mismatch log directory rejected' begin_test 'audit validates opened FD identity and restrictive attributes' printf 'existing\n' >"$LOG_DIR/wg-manager.log"; chmod 600 -- "$LOG_DIR/wg-manager.log" local audit_real_stat audit_before audit_real_stat=$(command -v stat); audit_before=$(<"$LOG_DIR/wg-manager.log") stat() { if [[ ${1:-} == -Lc && ${4:-} == /proc/self/fd/* ]]; then printf '999:999:600:999:999:1\n'; return 0; fi "$audit_real_stat" "$@" } set +e; output=$(REMOVE_NAME=alice REMOVE_KEY=BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB= remove_audit failed 2>&1); status=$?; set -e unset -f stat assert_equal "$EXIT_VALIDATION" "$status" 'audit FD identity mismatch rejected' assert_equal "$audit_before" "$(<"$LOG_DIR/wg-manager.log")" 'FD mismatch writes no bytes' chmod 644 -- "$LOG_DIR/wg-manager.log" set +e; output=$(REMOVE_NAME=alice REMOVE_KEY=BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB= remove_audit failed 2>&1); status=$?; set -e assert_equal "$EXIT_VALIDATION" "$status" 'permissive audit file rejected' chmod 600 -- "$LOG_DIR/wg-manager.log" ln -- "$LOG_DIR/wg-manager.log" "$TEST_ROOT/audit-hardlink" set +e; output=$(REMOVE_NAME=alice REMOVE_KEY=BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB= remove_audit failed 2>&1); status=$?; set -e assert_equal "$EXIT_VALIDATION" "$status" 'hardlinked audit file rejected' rm -f -- "$TEST_ROOT/audit-hardlink" "$LOG_DIR/wg-manager.log" fi } run_m8_tests() { local config_file=$TEST_ROOT/m8.conf fixture=$TEST_ROOT/m8-wg.conf output status backup audit_content audit_text first_backup second_backup replacement restore_before valid_count manual_now newest_stamp manifest_replacement retained manifest_before_hash cp -- "$PROJECT_DIR/tests/fixtures/m3/source.conf" "$fixture"; chmod 600 -- "$fixture" write_m2_config "$config_file" "$fixture" replace_config_line "$config_file" 'CLIENT_DIR=' "CLIENT_DIR=\"$TEST_ROOT/m8-clients\"" export WG_MANAGER_TEST_MODE=1 WG_MANAGER_CONFIG=$config_file WG_MANAGER_BACKUP_DIR=$TEST_ROOT/m8-backups WG_MANAGER_LOG_DIR=$TEST_ROOT/m8-logs WG_MANAGER_METADATA_DIR=$TEST_ROOT/m8-meta WG_MANAGER_ARCHIVE_DIR=$TEST_ROOT/m8-archive WG_MANAGER_LOCK_FILE=$TEST_ROOT/m8.lock WG_MANAGER_NOW=1700000100 WG_MOCK_INTERFACE=wg0 mkdir -p -- "$TEST_ROOT/m8-backups" "$TEST_ROOT/m8-logs" "$TEST_ROOT/m8-meta" "$TEST_ROOT/m8-clients" "$TEST_ROOT/m8-archive" chmod 700 -- "$TEST_ROOT/m8-backups" "$TEST_ROOT/m8-logs" "$TEST_ROOT/m8-meta" "$TEST_ROOT/m8-clients" "$TEST_ROOT/m8-archive" set_runtime_paths; load_config transaction_builder_copy() { cp -- "$WG_CONF" "$1"; } transaction_builder_fail() { return "$EXIT_VALIDATION"; } case $test_group in audit) begin_test 'audit covers transaction backup apply and rollback outcomes' export WG_MOCK_DUMP_FILE="$PROJECT_DIR/tests/fixtures/m3/runtime.dump" : >"$TEST_MOCK_LOG" set +e; run_transaction update audit transaction_builder_copy >/dev/null 2>&1; status=$?; set -e assert_equal 0 "$status" 'successful transaction is audited' audit_text=$(<"$LOG_DIR/wg-manager.log") assert_equal 1 "$(grep -c '^.*operation=backup .*outcome=success ' <<<"$audit_text")" 'backup event is emitted once' assert_equal 1 "$(grep -c '^.*operation=apply .*outcome=success ' <<<"$audit_text")" 'apply event is emitted once' export WG_SYNC_FAIL_ONCE_FILE="$TEST_ROOT/m8-sync-fail" set +e; run_transaction update audit transaction_builder_copy >/dev/null 2>&1; status=$?; set -e unset WG_SYNC_FAIL_ONCE_FILE assert_equal "$EXIT_OPERATION" "$status" 'failed apply with successful rollback keeps operation status' assert_contains 'operation=rollback' "$(<"$LOG_DIR/wg-manager.log")" 'successful rollback is audited' export WG_SYNC_FAIL_ONCE_FILE="$TEST_ROOT/m8-sync-fail-critical" TRANSACTION_ROLLBACK_REPLACE_FAIL=1 set +e; run_transaction update audit transaction_builder_copy >/dev/null 2>&1; status=$?; set -e unset WG_SYNC_FAIL_ONCE_FILE TRANSACTION_ROLLBACK_REPLACE_FAIL assert_equal "$EXIT_ROLLBACK" "$status" 'rollback failure keeps critical status' assert_contains 'operation=rollback' "$(<"$LOG_DIR/wg-manager.log")" 'critical rollback is audited' assert_equal '' "$(grep -E 'PrivateKey|PresharedKey|EEEE|GGGG|BBBBBBBBBBBBBBBB' "$LOG_DIR/wg-manager.log" || true)" 'transaction audit contains no secrets' : >"$LOG_DIR/wg-manager.log" set +e; run_transaction update preflight transaction_builder_fail >/dev/null 2>&1; status=$?; set -e assert_equal "$EXIT_VALIDATION" "$status" 'pre-apply builder failure retains validation status' assert_equal 0 "$(grep -Ec 'operation=(apply|rollback) ' "$LOG_DIR/wg-manager.log" || true)" 'pre-apply failure emits no apply or rollback event' set +e; output=$(list_audit 1 2>&1); status=$?; set -e assert_equal 0 "$status" 'audit listing accepts limit one' assert_equal 1 "$(grep -c '^timestamp=' <<<"$output")" 'audit listing limit one returns one record' set +e; list_audit 0 >/dev/null 2>&1; status=$?; set -e assert_equal "$EXIT_USAGE" "$status" 'audit listing rejects zero limit' set +e; list_audit 1001 >/dev/null 2>&1; status=$?; set -e assert_equal "$EXIT_USAGE" "$status" 'audit listing rejects excessive limit' : >"$LOG_DIR/wg-manager.log" local audit_index audit_pid audit_wait_status local -a audit_pids=() for audit_index in {1..10}; do ( AUDIT_CLIENT=worker write_audit_event audit "worker${audit_index}" success success ) & audit_pids+=($!) done audit_wait_status=0 for audit_pid in "${audit_pids[@]}"; do wait "$audit_pid" || audit_wait_status=1; done assert_equal 0 "$audit_wait_status" 'concurrent audit writers all complete' assert_equal 10 "$(grep -Ec '^timestamp=.* operation=audit target=worker[0-9]+ ' "$LOG_DIR/wg-manager.log" || true)" 'concurrent audit records remain complete' assert_equal 8 "$(awk 'length($0)>0 {print NF}' "$LOG_DIR/wg-manager.log" | sort -u | tr -d '\n')" 'concurrent audit records have stable field counts' ;; audit-no-secrets) begin_test 'audit rejects unsafe fields and filters hostile records' set +e; write_audit_event 'bad operation' alice success success >/dev/null 2>&1; status=$?; set -e assert_equal "$EXIT_VALIDATION" "$status" 'unsafe operation is rejected' audit_content=''; [[ -e $LOG_DIR/wg-manager.log ]] && audit_content=$(grep -E 'PrivateKey|PresharedKey|\[Interface\]|\[Peer\]' "$LOG_DIR/wg-manager.log" || true) assert_equal '' "$audit_content" 'audit output has no config content' printf '%s\n' 'PrivateKey=EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE=' 'malformed hostile line' >"$LOG_DIR/wg-manager.log" set +e; output=$(list_audit 2>&1); status=$?; set -e assert_equal 0 "$status" 'malformed audit input is handled safely' assert_equal '' "$(grep -E 'PrivateKey|EEEE|malformed' <<<"$output" || true)" 'hostile audit input is never emitted' assert_equal '' "$(grep -E 'PrivateKey|PresharedKey|EEEE|GGGG|BBBBBBBBBBBBBBBB' <<<"$output" || true)" 'filtered audit output is secret-free' ;; backup-list) begin_test 'backup listing returns valid newest entries and ignores hostile entries' backup=$(create_backup "$WG_CONF" manual "$WG_INTERFACE") first_backup=${backup##*/} export WG_MANAGER_NOW=1700000200 backup=$(create_backup "$WG_CONF" manual "$WG_INTERFACE") second_backup=${backup##*/} export WG_MANAGER_NOW=1700000100 mkdir -- "$BACKUP_DIR/20231114T221500Z-wg0-777"; chmod 700 -- "$BACKUP_DIR/20231114T221500Z-wg0-777" printf 'INTERFACE=wg0\nTIMESTAMP=20231114T221500Z\nOPERATION=manual\nTARGET=bad\nSOURCE_MODE=600\nSOURCE_UID=0\nSOURCE_GID=0\nCONFIG=wg.conf\nEXEC=touch %s\n' "$TEST_ROOT/m8-hostile-sentinel" >"$BACKUP_DIR/20231114T221500Z-wg0-777/manifest"; chmod 600 -- "$BACKUP_DIR/20231114T221500Z-wg0-777/manifest" set +e; output=$(list_backups 2>&1); status=$?; set -e assert_equal 0 "$status" 'backup list succeeds' assert_contains "$first_backup" "$output" 'first valid backup appears in list' assert_contains "$second_backup" "$output" 'newer valid backup appears in list' assert_equal 0 "$(grep -c '20231114T221500Z-wg0-777' <<<"$output")" 'invalid backup name is not listed' assert_file_absent "$TEST_ROOT/m8-hostile-sentinel" 'manifest payload is never executed' assert_equal 0 "$(grep -c 'Резервные копии отсутствуют' <<<"$output")" 'valid list is not reported empty' mv -- "$BACKUP_DIR" "$TEST_ROOT/m8-backups-missing" set +e; output=$(list_backups 2>&1); status=$?; set -e assert_equal 0 "$status" 'missing backup root is an empty list' assert_contains 'Резервные копии отсутствуют' "$output" 'missing backup root is reported safely' mv -- "$TEST_ROOT/m8-backups-missing" "$BACKUP_DIR" manifest_replacement=$TEST_ROOT/m8-manifest-replacement manifest_before_hash=$(sha256sum -- "$BACKUP_DIR/$first_backup/manifest" | awk '{print $1}') cp -- "$BACKUP_DIR/$first_backup/manifest" "$manifest_replacement" replace_config_line "$manifest_replacement" 'TARGET=' 'TARGET=swapped' export LIST_BACKUPS_REPLACE_MANIFEST=$manifest_replacement set +e; output=$(list_backups 2>&1); status=$?; set -e unset LIST_BACKUPS_REPLACE_MANIFEST LIST_BACKUPS_REPLACE_DONE assert_equal 0 "$status" 'backup list rejects a swapped manifest safely' assert_equal 0 "$(grep -Ec '(^|[[:space:]])swapped([[:space:]]|$)' <<<"$output")" 'swapped manifest fields are never displayed' assert_equal "$manifest_before_hash" "$(sha256sum -- "$BACKUP_DIR/$first_backup/manifest" | awk '{print $1}')" 'swapped manifest is restored byte-for-byte' ;; restore) begin_test 'restore applies pinned backup and creates current-state backup' local old_config restore_id restored_hash current_backup_count old_config=$TEST_ROOT/m8-old.conf; cp -- "$fixture" "$old_config" backup=$(create_backup "$fixture" manual "$WG_INTERFACE"); restore_id=${backup##*/} printf '# changed-live-state\n' >>"$fixture" export WG_MOCK_DUMP_FILE="$PROJECT_DIR/tests/fixtures/m3/runtime.dump" set +e; output=$(restore_backup "$restore_id" --yes 2>&1); status=$?; set -e assert_equal 0 "$status" 'restore succeeds' assert_equal "$(sha256sum -- "$old_config" | awk '{print $1}')" "$(sha256sum -- "$fixture" | awk '{print $1}')" 'restore replaces live config with backup bytes' assert_contains "operation=restore" "$(<"$LOG_DIR/wg-manager.log")" 'restore audit is emitted' current_backup_count=$(find "$BACKUP_DIR" -mindepth 1 -maxdepth 1 -type d -name '*-wg0-*' | wc -l) assert_equal 1 "$(( current_backup_count >= 2 ? 1 : 0 ))" 'restore keeps source and current-state backups' assert_contains 'Резервная копия:' "$output" 'restore preview identifies timestamp' ;; restore-invalid) begin_test 'restore rejects unsafe identifiers and noninteractive confirmation' local invalid_restore_status restore_id for invalid_id in '../unsafe' '-bad' '2023X114T221500Z-wg0-001' 'nested/item'; do set +e; restore_backup "$invalid_id" --yes >/dev/null 2>&1; invalid_restore_status=$?; set -e assert_equal "$EXIT_VALIDATION" "$invalid_restore_status" "restore rejects $invalid_id" done backup=$(create_backup "$fixture" manual "$WG_INTERFACE"); restore_id=${backup##*/} set +e; restore_backup "$restore_id" >/dev/null 2>&1; invalid_restore_status=$?; set -e assert_equal "$EXIT_USAGE" "$invalid_restore_status" 'restore requires --yes without a terminal' cp -- "$fixture" "$TEST_ROOT/m8-invalid-live.conf" replace_config_line "$backup/manifest" 'INTERFACE=' 'INTERFACE=wg9' set +e; restore_backup "$restore_id" --yes >/dev/null 2>&1; invalid_restore_status=$?; set -e assert_equal "$EXIT_VALIDATION" "$invalid_restore_status" 'restore rejects a backup for another interface' cp -- "$backup/manifest" "$TEST_ROOT/m8-invalid-manifest" replace_config_line "$TEST_ROOT/m8-invalid-manifest" 'INTERFACE=' "INTERFACE=$WG_INTERFACE" cp -- "$TEST_ROOT/m8-invalid-manifest" "$backup/manifest" printf '[Broken]\n' >>"$backup/wg.conf" set +e; restore_backup "$restore_id" --yes >/dev/null 2>&1; invalid_restore_status=$?; set -e assert_equal "$EXIT_VALIDATION" "$invalid_restore_status" 'restore rejects a malformed backup config' assert_equal "$(sha256sum -- "$TEST_ROOT/m8-invalid-live.conf" | awk '{print $1}')" "$(sha256sum -- "$fixture" | awk '{print $1}')" 'invalid restore leaves live config unchanged' ;; restore-rollback) begin_test 'restore apply failure rolls back disk and reports audit outcome' local restore_source restore_id rollback_before restore_source=$TEST_ROOT/m8-rollback.conf; cp -- "$fixture" "$restore_source" backup=$(create_backup "$fixture" manual "$WG_INTERFACE"); restore_id=${backup##*/} printf '# restore-candidate\n' >>"$fixture"; rollback_before=$(sha256sum -- "$fixture") export WG_SYNC_FAIL_ONCE_FILE="$TEST_ROOT/m8-restore-sync-fail" WG_MOCK_DUMP_FILE="$PROJECT_DIR/tests/fixtures/m3/runtime.dump" set +e; output=$(restore_backup "$restore_id" --yes 2>&1); status=$?; set -e unset WG_SYNC_FAIL_ONCE_FILE assert_equal "$EXIT_OPERATION" "$status" 'restore apply failure returns operation status' assert_equal "$rollback_before" "$(sha256sum -- "$fixture")" 'failed restore restores live disk' assert_contains 'operation=rollback' "$(<"$LOG_DIR/wg-manager.log")" 'restore rollback is audited' backup=$(create_backup "$fixture" manual "$WG_INTERFACE"); restore_id=${backup##*/} printf '# restore-critical-candidate\n' >>"$fixture" export WG_SYNC_FAIL_ONCE_FILE="$TEST_ROOT/m8-restore-sync-fail-critical" TRANSACTION_ROLLBACK_REPLACE_FAIL=1 set +e; output=$(restore_backup "$restore_id" --yes 2>&1); status=$?; set -e unset WG_SYNC_FAIL_ONCE_FILE TRANSACTION_ROLLBACK_REPLACE_FAIL assert_equal "$EXIT_ROLLBACK" "$status" 'restore rollback failure is critical' assert_contains 'КРИТИЧНО' "$output" 'critical restore rollback is diagnosed' ;; backup-path-safety) begin_test 'backup path safety rejects symlinked root' mv -- "$BACKUP_DIR" "$TEST_ROOT/m8-backups-real"; ln -s -- "$TEST_ROOT/m8-backups-real" "$BACKUP_DIR" set +e; output=$(list_backups 2>&1); status=$?; set -e assert_failure "$status" 'symlinked backup directory is rejected' rm -f -- "$BACKUP_DIR"; mv -- "$TEST_ROOT/m8-backups-real" "$BACKUP_DIR" export MOCK_FLOCK_BUSY=1 set +e; output=$(manual_backup 2>&1); status=$?; set -e unset MOCK_FLOCK_BUSY assert_equal "$EXIT_OPERATION" "$status" 'manual backup respects lock contention' backup=$(create_backup "$WG_CONF" manual "$WG_INTERFACE"); restore_id=${backup##*/} replacement=$TEST_ROOT/m8-replacement.conf; cp -- "$WG_CONF" "$replacement"; printf '# replacement-source\n' >>"$replacement" restore_before=$(sha256sum -- "$WG_CONF" | awk '{print $1}') export RESTORE_PREOPEN_REPLACEMENT=$replacement set +e; output=$(restore_backup "$restore_id" --yes 2>&1); status=$?; set -e unset RESTORE_PREOPEN_REPLACEMENT assert_equal "$EXIT_VALIDATION" "$status" 'restore rejects source replacement before open' assert_equal "$restore_before" "$(sha256sum -- "$WG_CONF" | awk '{print $1}')" 'pre-open replacement does not alter live config' replacement=$TEST_ROOT/m8-lock-replacement; printf 'lock replacement\n' >"$replacement"; chmod 600 -- "$replacement" export MANUAL_BACKUP_PREOPEN_REPLACEMENT=$replacement set +e; output=$(manual_backup 2>&1); status=$?; set -e unset MANUAL_BACKUP_PREOPEN_REPLACEMENT assert_equal "$EXIT_VALIDATION" "$status" 'manual backup rejects lock replacement before open' replacement=$TEST_ROOT/m8-config-replacement; cp -- "$WG_CONF" "$replacement"; printf '# config replacement\n' >>"$replacement" restore_before=$(sha256sum -- "$WG_CONF" | awk '{print $1}') export MANUAL_BACKUP_PRECOPY_REPLACEMENT=$replacement set +e; output=$(manual_backup 2>&1); status=$?; set -e unset MANUAL_BACKUP_PRECOPY_REPLACEMENT assert_equal "$EXIT_VALIDATION" "$status" 'manual backup rejects config replacement before copy' assert_equal "$restore_before" "$(sha256sum -- "$WG_CONF" | awk '{print $1}')" 'config replacement restores original source' ;; runtime-file-modes) begin_test 'manual backup runtime files are restrictive' set +e; backup=$(manual_backup 2>"$TEST_ROOT/m8-manual-backup.err"); status=$?; set -e assert_equal 0 "$status" 'manual backup succeeds through locked path' assert_file_exists "$backup/manifest" 'manual backup manifest exists' assert_equal 700 "$(stat -c '%a' -- "$BACKUP_DIR")" 'backup root is 0700' assert_equal 700 "$(stat -c '%a' -- "$backup")" 'backup directory is 0700' assert_equal 600 "$(stat -c '%a' -- "$backup/manifest")" 'manifest is 0600' assert_equal 600 "$(stat -c '%a' -- "$backup/wg.conf")" 'backup config is 0600' assert_contains 'operation=manual-backup' "$(<"$LOG_DIR/wg-manager.log")" 'manual backup is audited' replace_config_line "$config_file" 'BACKUP_RETENTION=' 'BACKUP_RETENTION="2"' for manual_now in 1700000300 1700000400 1700000500; do export WG_MANAGER_NOW=$manual_now set +e; manual_backup >/dev/null 2>&1; status=$?; set -e assert_equal 0 "$status" "manual backup succeeds at timestamp $manual_now" done valid_count=0 for retained in "$BACKUP_DIR"/*; do validate_backup_manifest "$retained" && ((valid_count += 1)) done assert_equal 2 "$valid_count" 'manual backup retention keeps exactly two valid backups' newest_stamp=$(date -u -d '@1700000500' '+%Y%m%dT%H%M%SZ') assert_equal 1 "$(find "$BACKUP_DIR" -mindepth 1 -maxdepth 1 -type d -name "${newest_stamp}-wg0-*" | wc -l)" 'retention keeps newest manual backup' assert_equal 700 "$(stat -c '%a' -- "$LOG_DIR")" 'log directory remains 0700' assert_equal 600 "$(stat -c '%a' -- "$LOCK_FILE")" 'manual lock remains 0600' ;; esac } run_m7_tests() { local config_file=$TEST_ROOT/diag.conf fixture=$TEST_ROOT/diag-wg0.conf output status saved_test_root rm -rf -- "$TEST_ROOT/diag-meta" "$TEST_ROOT/diag-clients" "$TEST_ROOT/diag-backups" "$TEST_ROOT/diag-logs" "$TEST_ROOT/diag-archive" "$TEST_ROOT/diag.lock" "$TEST_ROOT/ipv4-forward" "$TEST_ROOT/runtime-diff.dump" : >"$TEST_MOCK_LOG" unset SYSTEMCTL_STATE DIAG_DISABLE_COMMANDS DIAG_IP_LINK_FAIL DIAG_IP_ADDR_FAIL DIAG_IP_ROUTE_FAIL DIAG_SS_FAIL DIAG_NFT_FAIL DIAG_IPTABLES_FAIL DIAG_JOURNAL_FAIL DIAG_IP_LINK_OUTPUT DIAG_IP_ADDR_OUTPUT DIAG_IP_ROUTE_OUTPUT DIAG_SS_OUTPUT DIAG_NFT_OUTPUT DIAG_IPTABLES_OUTPUT DIAG_JOURNAL_OUTPUT WG_MOCK_DUMP_SEQUENCE_DIR cp -- "$PROJECT_DIR/tests/fixtures/m3/source.conf" "$fixture"; chmod 600 -- "$fixture" sed -i 's/wg-manager-client: fixture/wg-manager-client: alice/' "$fixture" write_m2_config "$config_file" "$fixture" replace_config_line "$config_file" 'CLIENT_DIR=' "CLIENT_DIR=\"$TEST_ROOT/diag-clients\"" export WG_MANAGER_TEST_MODE=1 SYSTEMCTL_INIT=running WG_MANAGER_CONFIG=$config_file WG_MANAGER_BACKUP_DIR=$TEST_ROOT/diag-backups WG_MANAGER_LOG_DIR=$TEST_ROOT/diag-logs WG_MANAGER_METADATA_DIR=$TEST_ROOT/diag-meta WG_MANAGER_ARCHIVE_DIR=$TEST_ROOT/diag-archive WG_MANAGER_LOCK_FILE=$TEST_ROOT/diag.lock set_runtime_paths; load_config mkdir -p -- "$TEST_ROOT/diag-meta" "$TEST_ROOT/diag-clients"; chmod 700 -- "$TEST_ROOT/diag-meta" "$TEST_ROOT/diag-clients" printf '0\n' >"$TEST_ROOT/ipv4-forward" export WG_MANAGER_IPV4_FORWARD_FILE=$TEST_ROOT/ipv4-forward WG_MOCK_INTERFACE=wg0 WG_MOCK_DUMP_FILE=$PROJECT_DIR/tests/fixtures/m3/runtime.dump begin_test "diagnostics $test_group" set +e; output=$(diagnose 2>&1); status=$?; set -e assert_failure "$status" 'критическая диагностика возвращает ошибку' assert_contains '[FAIL]' "$output" 'diagnostics FAIL status' assert_contains '[SKIP]' "$output" 'diagnostics SKIP status' assert_contains '[WARN]' "$output" 'diagnostics WARN status' case $test_group in diagnostics) export DIAG_IP_LINK_OUTPUT='4: wg0: mtu 1420' DIAG_IP_ADDR_OUTPUT='4: wg0 inet 192.0.2.1/24 scope global wg0' DIAG_IP_ROUTE_OUTPUT='192.0.2.0/24 dev wg0 scope link' DIAG_SS_OUTPUT='udp UNCONN 0 0 0.0.0.0:51820 0.0.0.0:*' set +e; output=$(diagnose --quiet 2>&1); status=$?; set -e assert_contains 'interface-exists=OK' "$output" 'interface existence uses exact mock argv' assert_contains 'interface-address=OK' "$output" 'exact IPv4 address is accepted' assert_contains 'routes=OK' "$output" 'exact route and device are accepted' export DIAG_IP_ADDR_OUTPUT=$'4: wg0 inet 198.51.100.1/24 scope global wg0\n4: wg0 inet 192.0.2.1/24 scope global wg0' set +e; output=$(diagnose --quiet 2>&1); status=$?; set -e assert_contains 'interface-address=OK' "$output" 'IPv4 address is found on a later ip output line' export DIAG_IP_ADDR_OUTPUT='4: wg0 inet 192x0x2x1/24 scope global wg0' DIAG_IP_ROUTE_OUTPUT='192.0.20.0/24 dev wg0 scope link' set +e; output=$(diagnose --quiet 2>&1); status=$?; set -e assert_contains 'interface-address=WARN' "$output" 'lookalike IPv4 address is rejected' assert_contains 'routes=WARN' "$output" 'lookalike IPv4 route is rejected' export DIAG_IP_LINK_FAIL=1 set +e; output=$(diagnose --quiet 2>&1); status=$?; set -e assert_contains 'interface-exists=FAIL' "$output" 'missing interface is FAIL' assert_contains 'interface-runtime=OK' "$output" 'interface failure does not stop runtime check' unset DIAG_IP_LINK_FAIL export DIAG_DISABLE_COMMANDS=ip set +e; output=$(diagnose --quiet 2>&1); status=$?; set -e assert_contains 'command-ip=FAIL' "$output" 'missing required ip is FAIL' assert_contains 'interface-address=SKIP' "$output" 'missing ip makes address check SKIP' unset DIAG_DISABLE_COMMANDS export DIAG_SS_OUTPUT='udp UNCONN 0 0 [::]:151820 [::]:*' set +e; output=$(diagnose --quiet 2>&1); status=$?; set -e assert_contains 'udp-listener=WARN' "$output" 'false-positive UDP port is rejected' export DIAG_SS_OUTPUT='udp UNCONN 0 0 [::]:51820 [::]:*' : >"$TEST_MOCK_LOG" set +e; output=$(diagnose --quiet 2>&1); status=$?; set -e assert_contains 'udp-listener=OK' "$output" 'IPv6 wildcard UDP port is accepted' assert_equal 1 "$(grep -c '^ss -lun$' "$TEST_MOCK_LOG")" 'UDP probe uses one exact ss call' export DIAG_DISABLE_COMMANDS=ss set +e; output=$(diagnose --quiet 2>&1); status=$?; set -e assert_contains 'udp-listener=SKIP' "$output" 'missing ss is SKIP' unset DIAG_DISABLE_COMMANDS export DIAG_SS_FAIL=1 DIAG_JOURNAL_OUTPUT='Error: FAILED to activate peer' set +e; output=$(diagnose --quiet 2>&1); status=$?; set -e assert_contains 'udp-listener=WARN' "$output" 'UDP command failure is WARN' assert_contains 'journal=WARN' "$output" 'journal errors are case-insensitive WARN' unset DIAG_IP_LINK_OUTPUT DIAG_IP_ADDR_OUTPUT DIAG_IP_ROUTE_OUTPUT DIAG_SS_OUTPUT DIAG_SS_FAIL DIAG_JOURNAL_OUTPUT ;; diagnostics-systemd) assert_contains 'systemctl is-system-running' "$(<"$TEST_MOCK_LOG")" 'systemd capability probe argv' assert_contains 'systemctl is-active -- wg-quick@wg0' "$(<"$TEST_MOCK_LOG")" 'systemd exact read-only argv' assert_contains 'systemd [WARN]' "$output" 'systemd uncertain state is WARN' export SYSTEMCTL_STATE=active set +e; output=$(diagnose 2>&1); status=$?; set -e assert_contains 'systemd [OK]' "$output" 'active systemd is OK' export SYSTEMCTL_STATE=failed set +e; output=$(diagnose 2>&1); status=$?; set -e assert_contains 'systemd [FAIL]' "$output" 'failed systemd is FAIL' export SYSTEMCTL_INIT=degraded SYSTEMCTL_STATE=active set +e; output=$(diagnose 2>&1); status=$?; set -e assert_contains 'systemd [OK]' "$output" 'degraded systemd still checks service state' export SYSTEMCTL_INIT=offline set +e; output=$(diagnose --quiet 2>&1); status=$?; set -e assert_contains 'systemd=SKIP' "$output" 'unsupported systemd is SKIP' assert_contains 'journal=SKIP' "$output" 'journal is SKIP without systemd' export SYSTEMCTL_INIT=running unset SYSTEMCTL_STATE ;; diagnostics-firewall) assert_contains 'nft list ruleset' "$(<"$TEST_MOCK_LOG")" 'firewall exact read-only query' assert_contains 'firewall [WARN]' "$output" 'firewall uncertainty is WARN' assert_equal '' "$(grep -E ' (add|delete|insert|replace|flush| -w ) ' "$TEST_MOCK_LOG" || true)" 'firewall mock log has no mutation argv' export DIAG_NFT_OUTPUT='oifname "wg0" ip saddr 192.0.2.0/24 masquerade' set +e; output=$(diagnose --quiet 2>&1); status=$?; set -e assert_contains 'firewall=OK' "$output" 'nft NAT evidence is OK' export DIAG_NFT_FAIL=0 DIAG_NFT_OUTPUT=$'oifname "wg0" ip saddr 192.0.20.0/24 masquerade\nip saddr 192.0.2.0/24 accept' set +e; output=$(diagnose --quiet 2>&1); status=$?; set -e assert_contains 'firewall=WARN' "$output" 'separated unrelated firewall tokens are WARN' export DIAG_NFT_OUTPUT='oifname "wg0" ip saddr 192.0.2/24' DIAG_NFT_FAIL=1 DIAG_IPTABLES_OUTPUT='-A POSTROUTING -s 192.0.2.0/24 -o wg0 -j MASQUERADE' set +e; output=$(diagnose --quiet 2>&1); status=$?; set -e assert_contains 'firewall=OK' "$output" 'iptables fallback NAT evidence is OK' export DIAG_DISABLE_COMMANDS=nft,iptables set +e; output=$(diagnose --quiet 2>&1); status=$?; set -e assert_contains 'firewall=SKIP' "$output" 'both firewall tools absent are SKIP' unset DIAG_NFT_OUTPUT DIAG_NFT_FAIL DIAG_IPTABLES_OUTPUT DIAG_DISABLE_COMMANDS ;; diagnostics-forwarding) printf '1\n' >"$TEST_ROOT/ipv4-forward" set +e; output=$(diagnose --quiet 2>&1); status=$?; set -e assert_contains 'forwarding-ipv4=OK' "$output" 'IPv4 forwarding 1 is OK' assert_contains 'forwarding-ipv6=SKIP' "$output" 'disabled IPv6 forwarding is SKIP' printf 'malformed\n' >"$TEST_ROOT/ipv4-forward" set +e; output=$(diagnose --quiet 2>&1); status=$?; set -e assert_contains 'forwarding-ipv4=FAIL' "$output" 'malformed forwarding fails' cp -- "$config_file" "$TEST_ROOT/ipv6-diag.conf" replace_config_line "$TEST_ROOT/ipv6-diag.conf" 'USE_IPV6=' 'USE_IPV6="1"' printf '1\n' >"$TEST_ROOT/ipv6-forward" export WG_MANAGER_CONFIG=$TEST_ROOT/ipv6-diag.conf WG_MANAGER_IPV6_FORWARD_FILE=$TEST_ROOT/ipv6-forward DIAG_IP_ADDR_OUTPUT='4: wg0 inet 192.0.2.1/24 scope global wg0 6: wg0 inet6 2001:db8:ffff::1/64 scope global' DIAG_IP_ROUTE_OUTPUT='192.0.2.0/24 dev wg0 scope link 2001:db8:ffff::/64 dev wg0 scope link' set +e; output=$(diagnose --quiet 2>&1); status=$?; set -e assert_contains 'forwarding-ipv6=OK' "$output" 'IPv6 forwarding 1 is OK' assert_contains 'interface-address-ipv6=OK' "$output" 'exact IPv6 address is accepted' assert_contains 'routes-ipv6=OK' "$output" 'exact IPv6 route is accepted' export DIAG_IP_ADDR_OUTPUT=$'4: wg0 inet 192.0.2.1/24 scope global wg0\n6: wg0 inet6 2001:db8:ffff::1/64 scope global' set +e; output=$(diagnose --quiet 2>&1); status=$?; set -e assert_contains 'interface-address-ipv6=OK' "$output" 'IPv6 address is found on a later ip output line' printf '0\n' >"$TEST_ROOT/ipv6-forward" set +e; output=$(diagnose --quiet 2>&1); status=$?; set -e assert_contains 'forwarding-ipv6=FAIL' "$output" 'disabled IPv6 forwarding is FAIL when enabled' printf '1\n' >"$TEST_ROOT/ipv6-forward" export DIAG_IP_ADDR_OUTPUT='4: wg0 inet 192.0.2.1/24 scope global wg0 6: wg0 inet6 2001:db8:ffff::10/64 scope global' DIAG_IP_ROUTE_OUTPUT='192.0.2.0/24 dev other scope link 2001:db8:ffff::/64 dev other scope link' set +e; output=$(diagnose --quiet 2>&1); status=$?; set -e assert_contains 'interface-address-ipv6=WARN' "$output" 'wrong IPv6 address is WARN' assert_contains 'routes-ipv6=WARN' "$output" 'wrong IPv6 route device is WARN' unset WG_MANAGER_IPV6_FORWARD_FILE DIAG_IP_ADDR_OUTPUT DIAG_IP_ROUTE_OUTPUT ;; diagnostics-runtime-diff) set +e; output=$(diagnose --quiet 2>&1); status=$?; set -e assert_equal 1 "$(grep -c '^interface-exists=' <<<"$output")" 'interface existence has one record' assert_equal 1 "$(grep -c '^interface-runtime=' <<<"$output")" 'runtime dump has one record' assert_equal 1 "$(grep -c '^runtime-disk=' <<<"$output")" 'runtime-disk has one record' assert_contains 'runtime-disk=' "$output" 'runtime-disk is independently reported' for duplicate_fixture in duplicate-public.conf duplicate-allowed.conf overlap.conf overlap-v6.conf; do cp -- "$PROJECT_DIR/tests/fixtures/m2/$duplicate_fixture" "$fixture" set +e; output=$(diagnose --quiet 2>&1); status=$?; set -e assert_contains 'config-parse=FAIL' "$output" "${duplicate_fixture} parser failure is reported" assert_contains 'interface-exists=OK' "$output" "${duplicate_fixture} does not stop interface checks" assert_contains 'interface-runtime=OK' "$output" "${duplicate_fixture} does not stop runtime checks" case $duplicate_fixture in duplicate-public.conf) assert_contains 'duplicate-keys=FAIL' "$output" 'duplicate public key has a distinct result' ;; *) assert_contains 'allowedips-overlap=FAIL' "$output" "${duplicate_fixture} overlap has a distinct result" ;; esac done cp -- "$PROJECT_DIR/tests/fixtures/m3/runtime.dump" "$TEST_ROOT/runtime-diff.dump" sed -i 's/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=/CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC=/' "$TEST_ROOT/runtime-diff.dump" export WG_MOCK_DUMP_FILE="$TEST_ROOT/runtime-diff.dump" cp -- "$PROJECT_DIR/tests/fixtures/m3/source.conf" "$fixture" set +e; output=$(diagnose --quiet 2>&1); status=$?; set -e assert_contains 'runtime-disk=WARN' "$output" 'runtime and disk peer mismatch is WARN' cp -- "$PROJECT_DIR/tests/fixtures/m3/runtime.dump" "$TEST_ROOT/runtime-same-key.dump" sed -i 's/192.0.2.2\/32/192.0.2.99\/32/' "$TEST_ROOT/runtime-same-key.dump" export WG_MOCK_DUMP_FILE="$TEST_ROOT/runtime-same-key.dump" same_key_ok=$(awk -F '\t' 'NR == 2 && $1 == "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=" { print "yes" }' "$TEST_ROOT/runtime-same-key.dump") assert_equal yes "$same_key_ok" 'same-key fixture retains the original public key' set +e; output=$(diagnose --quiet 2>&1); status=$?; set -e assert_contains 'runtime-disk=WARN' "$output" 'same key with changed AllowedIPs is WARN' export WG_MOCK_DUMP_FAIL_ON_CALL=1 WG_MOCK_DUMP_FAIL_COUNTER_FILE="$TEST_ROOT/runtime-fail-counter" set +e; output=$(diagnose --quiet 2>&1); status=$?; set -e assert_contains 'interface-runtime=FAIL' "$output" 'nonzero runtime dump is FAIL' assert_contains 'runtime-disk=SKIP' "$output" 'runtime-disk skips unavailable runtime' unset WG_MOCK_DUMP_FAIL_ON_CALL WG_MOCK_DUMP_FAIL_COUNTER_FILE printf 'malformed runtime line\n' >"$TEST_ROOT/malformed-runtime.dump"; export WG_MOCK_DUMP_FILE="$TEST_ROOT/malformed-runtime.dump" set +e; output=$(diagnose --quiet 2>&1); status=$?; set -e assert_contains 'interface-runtime=FAIL' "$output" 'malformed runtime dump is FAIL' assert_contains 'runtime-disk=SKIP' "$output" 'malformed runtime skips comparison' export WG_MOCK_DUMP_FILE="$PROJECT_DIR/tests/fixtures/m3/runtime.dump" ;; diagnostics-read-only) local diag_before diag_mode config_before forward_before keys_before invalid_result diag_before=$(sha256sum -- "$fixture"); diag_mode=$(stat -c '%a' -- "$fixture"); config_before=$(sha256sum -- "$config_file"); forward_before=$(sha256sum -- "$TEST_ROOT/ipv4-forward") assert_equal '' "$(grep -E 'syncconf|wg-quick (up|down|save)|ip (add|del|replace)|systemctl (start|stop|restart)|nft (add|delete|insert|flush)|iptables (-A|-D|-I|-F)|sysctl -w' "$TEST_MOCK_LOG" || true)" 'diagnostics has no mutation commands' assert_equal "$diag_before" "$(sha256sum -- "$fixture")" 'diagnostics does not modify config' assert_equal "$diag_mode" "$(stat -c '%a' -- "$fixture")" 'diagnostics preserves config mode' set +e; local quiet_output; quiet_output=$(diagnose --quiet 2>&1); status=$?; set -e assert_equal "$(printf '%s\n' "$quiet_output" | sed '/^$/d' | wc -l)" "$(printf '%s\n' "$quiet_output" | sed '/^$/d' | cut -d= -f1 | sort -u | wc -l)" 'quiet output has unique diagnostic ids' assert_equal 0 "$(grep -Ev '^[A-Za-z0-9_-]+=(OK|WARN|FAIL|SKIP)$' <<<"$quiet_output" | wc -l)" 'quiet output statuses are constrained' DIAG_IDS=(); DIAG_FAILS=0; DIAG_QUIET=1 set +e; diag_result 'bad id' OK 'ignored' >/dev/null 2>&1; status=$?; set -e assert_failure "$status" 'invalid diagnostic id is rejected' set +e; diag_result valid-id BOGUS 'ignored' >/dev/null 2>&1; status=$?; set -e assert_failure "$status" 'invalid diagnostic status is rejected' assert_equal 0 "$(grep -c '^bad id=' <<<"$quiet_output")" 'invalid diagnostic id emits no record' diag_result valid-id OK 'first' >/dev/null set +e; diag_result valid-id OK 'duplicate' >/dev/null 2>&1; status=$?; set -e assert_failure "$status" 'duplicate diagnostic id is rejected' assert_equal 3 "$DIAG_FAILS" 'diagnostic engine counts invalid and duplicate records' export DIAG_DISABLE_COMMANDS=ss,nft,iptables,systemctl,journalctl set +e; quiet_output=$(diagnose --quiet 2>&1); status=$?; set -e assert_contains 'udp-listener=SKIP' "$quiet_output" 'missing optional ss is SKIP' assert_contains 'firewall=SKIP' "$quiet_output" 'missing optional firewall tools are SKIP' assert_contains 'systemd=SKIP' "$quiet_output" 'missing systemctl is SKIP' assert_contains 'journal=SKIP' "$quiet_output" 'missing journal is SKIP' export DIAG_DISABLE_COMMANDS=wg saved_test_root=$TEST_ROOT; TEST_ROOT='' set +e; diag_command_available wg; status=$?; set -e assert_equal 0 "$status" 'diagnostic disable override is ignored outside confined root' TEST_ROOT=$saved_test_root set +e; quiet_output=$(diagnose --quiet 2>&1); status=$?; set -e assert_contains 'command-wg=FAIL' "$quiet_output" 'missing required wg is FAIL' unset DIAG_DISABLE_COMMANDS assert_equal "$config_before" "$(sha256sum -- "$config_file")" 'manager config remains unchanged' assert_equal "$forward_before" "$(sha256sum -- "$TEST_ROOT/ipv4-forward")" 'forwarding fixture remains unchanged' printf 'KEY-PLACEHOLDER\n' >"$TEST_ROOT/diag-clients/alice.key"; chmod 600 -- "$TEST_ROOT/diag-clients/alice.key" printf 'PSK-PLACEHOLDER\n' >"$TEST_ROOT/diag-clients/alice.psk"; chmod 600 -- "$TEST_ROOT/diag-clients/alice.psk" keys_before=$(sha256sum -- "$TEST_ROOT/diag-clients/alice.key" "$TEST_ROOT/diag-clients/alice.psk") set +e; quiet_output=$(diagnose --quiet 2>&1); status=$?; set -e assert_contains 'key-permissions=OK' "$quiet_output" 'secure key artifacts are accepted' chmod 644 -- "$TEST_ROOT/diag-clients/alice.key" set +e; quiet_output=$(diagnose --quiet 2>&1); status=$?; set -e assert_contains 'key-permissions=FAIL' "$quiet_output" 'permissive key artifact is rejected' chmod 600 -- "$TEST_ROOT/diag-clients/alice.key" mv -- "$TEST_ROOT/diag-clients/alice.psk" "$TEST_ROOT/diag-clients/alice.psk.saved" ln -s -- "$TEST_ROOT/diag-clients/alice.psk.saved" "$TEST_ROOT/diag-clients/alice.psk" set +e; quiet_output=$(diagnose --quiet 2>&1); status=$?; set -e assert_contains 'key-permissions=FAIL' "$quiet_output" 'symlink key artifact is rejected' rm -f -- "$TEST_ROOT/diag-clients/alice.psk"; mv -- "$TEST_ROOT/diag-clients/alice.psk.saved" "$TEST_ROOT/diag-clients/alice.psk" ln -- "$TEST_ROOT/diag-clients/alice.key" "$TEST_ROOT/key-hardlink" set +e; quiet_output=$(diagnose --quiet 2>&1); status=$?; set -e assert_contains 'key-permissions=FAIL' "$quiet_output" 'hardlinked key artifact is rejected' rm -f -- "$TEST_ROOT/key-hardlink" chmod 644 -- "$config_file"; set +e; quiet_output=$(diagnose --quiet 2>&1); status=$?; set -e assert_contains 'config-permissions=FAIL' "$quiet_output" 'permissive manager config is reported' chmod 600 -- "$config_file" ln -- "$config_file" "$TEST_ROOT/config-hardlink" set +e; quiet_output=$(diagnose --quiet 2>&1); status=$?; set -e assert_contains 'config-permissions=FAIL' "$quiet_output" 'hardlinked manager config is rejected' rm -f -- "$TEST_ROOT/config-hardlink" mv -- "$config_file" "$TEST_ROOT/manager-real.conf"; ln -s -- "$TEST_ROOT/manager-real.conf" "$config_file" set +e; quiet_output=$(diagnose --quiet 2>&1); status=$?; set -e assert_contains 'config-readable=FAIL' "$quiet_output" 'symlinked manager config is rejected' assert_contains 'config-parse=FAIL' "$quiet_output" 'manager symlink does not get loaded' rm -f -- "$config_file"; mv -- "$TEST_ROOT/manager-real.conf" "$config_file" mv -- "$fixture" "$TEST_ROOT/wg-real.conf"; ln -s -- "$TEST_ROOT/wg-real.conf" "$fixture" set +e; quiet_output=$(diagnose --quiet 2>&1); status=$?; set -e assert_contains 'config-permissions=FAIL' "$quiet_output" 'symlinked server config is rejected' assert_contains 'config-parse=FAIL' "$quiet_output" 'server symlink target is not parsed' rm -f -- "$fixture"; mv -- "$TEST_ROOT/wg-real.conf" "$fixture" chmod 750 -- "$TEST_ROOT/diag-clients" set +e; quiet_output=$(diagnose --quiet 2>&1); status=$?; set -e assert_contains 'key-permissions=FAIL' "$quiet_output" 'group-accessible client directory is rejected' chmod 700 -- "$TEST_ROOT/diag-clients" mkdir -p -- "$TEST_ROOT/diag-outside"; printf 'outside-sentinel\n' >"$TEST_ROOT/diag-outside/sentinel" mv -- "$TEST_ROOT/diag-clients" "$TEST_ROOT/diag-clients-real"; ln -s -- "$TEST_ROOT/diag-outside" "$TEST_ROOT/diag-clients" set +e; quiet_output=$(diagnose --quiet 2>&1); status=$?; set -e assert_contains 'key-permissions=FAIL' "$quiet_output" 'symlinked client directory is rejected' assert_equal 'outside-sentinel' "$(<"$TEST_ROOT/diag-outside/sentinel")" 'symlink target remains unread and unchanged' rm -f -- "$TEST_ROOT/diag-clients"; mv -- "$TEST_ROOT/diag-clients-real" "$TEST_ROOT/diag-clients" assert_equal "$keys_before" "$(sha256sum -- "$TEST_ROOT/diag-clients/alice.key" "$TEST_ROOT/diag-clients/alice.psk")" 'key artifacts remain unchanged' invalid_result=$(grep -Ev '^(wg show [^ ]+ dump|ip link show dev [^ ]+|ip -o addr show dev [^ ]+|ip -o route show [0-9.]+/24 dev [^ ]+|ip -o route show [0-9A-Fa-f:]+/64 dev [^ ]+|systemctl is-system-running|systemctl is-active -- wg-quick@[^ ]+|ss -lun|nft list ruleset|iptables -t nat -S|journalctl -u wg-quick@[^ ]+ --since 24\\ hours\\ ago --no-pager)$' "$TEST_MOCK_LOG" || true) assert_equal '' "$invalid_result" 'diagnostic log contains only read-only allowlisted argv' assert_equal "$diag_before" "$(sha256sum -- "$fixture")" 'final server config snapshot is unchanged' assert_equal "$diag_mode" "$(stat -c '%a' -- "$fixture")" 'final server config mode is unchanged' assert_equal "$config_before" "$(sha256sum -- "$config_file")" 'final manager config snapshot is unchanged' assert_equal 600 "$(stat -c '%a' -- "$config_file")" 'final manager config mode is unchanged' assert_equal "$forward_before" "$(sha256sum -- "$TEST_ROOT/ipv4-forward")" 'final forwarding snapshot is unchanged' ;; diagnostics-exit-codes) set +e; diagnose --bad >/dev/null 2>&1; status=$?; set -e assert_equal "$EXIT_USAGE" "$status" 'unknown diagnose flag is usage' set +e; diagnose extra >/dev/null 2>&1; status=$?; set -e assert_equal "$EXIT_USAGE" "$status" 'extra diagnose argument is usage' set +e; output=$(bash "$PROJECT_DIR/wg-manager.sh" diagnose --bad 2>&1); status=$?; set -e assert_equal "$EXIT_USAGE" "$status" 'CLI unknown diagnose flag is usage' set +e; output=$(bash "$PROJECT_DIR/wg-manager.sh" diagnose extra 2>&1); status=$?; set -e assert_equal "$EXIT_USAGE" "$status" 'CLI extra diagnose argument is usage' printf '1\n' >"$TEST_ROOT/ipv4-forward" export DIAG_IP_LINK_OUTPUT='4: wg0: mtu 1420' DIAG_IP_ADDR_OUTPUT='4: wg0 inet 192.0.2.1/24 scope global wg0' DIAG_IP_ROUTE_OUTPUT='192.0.2.0/24 dev wg0 scope link' DIAG_SS_OUTPUT='udp UNCONN 0 0 0.0.0.0:51820 0.0.0.0:*' DIAG_NFT_OUTPUT='oifname "wg0" ip saddr 192.0.2.0/24 masquerade' SYSTEMCTL_STATE=active set +e; output=$(diagnose --quiet 2>&1); status=$?; set -e assert_equal 0 "$status" 'healthy diagnostics returns zero' assert_contains 'firewall=OK' "$output" 'healthy firewall has NAT evidence' for expected_id in config-readable config-permissions config-parse duplicate-keys allowedips-overlap key-permissions command-wg command-wg-quick command-ip interface-exists interface-runtime systemd forwarding-ipv4 forwarding-ipv6 interface-address routes interface-address-ipv6 routes-ipv6 udp-listener firewall runtime-disk journal; do assert_equal 1 "$(grep -c "^${expected_id}=" <<<"$output")" "healthy output has one ${expected_id} record" done export WG_MANAGER_CONFIG=$config_file WG_MANAGER_IPV4_FORWARD_FILE=$TEST_ROOT/ipv4-forward WG_MOCK_INTERFACE=wg9 cp -- "$config_file" "$TEST_ROOT/nondefault.conf" sed -i 's/WG_INTERFACE="wg0"/WG_INTERFACE="wg9"/' "$TEST_ROOT/nondefault.conf" export WG_MANAGER_CONFIG=$TEST_ROOT/nondefault.conf export DIAG_IP_LINK_OUTPUT='4: wg9: mtu 1420' DIAG_IP_ADDR_OUTPUT='4: wg9 inet 192.0.2.1/24 scope global wg9' DIAG_IP_ROUTE_OUTPUT='192.0.2.0/24 dev wg9 scope link' DIAG_SS_OUTPUT='udp UNCONN 0 0 0.0.0.0:51820 0.0.0.0:*' DIAG_NFT_OUTPUT='oifname "wg9" ip saddr 192.0.2.0/24 masquerade' SYSTEMCTL_INIT=running SYSTEMCTL_STATE=active : >"$TEST_MOCK_LOG" set +e; output=$(bash "$PROJECT_DIR/wg-manager.sh" diagnose --quiet 2>&1); status=$?; set -e assert_equal 0 "$status" 'fresh CLI healthy non-default config returns zero' assert_contains 'interface-exists=OK' "$output" 'fresh CLI loads non-default interface config' assert_contains 'interface-runtime=OK' "$output" 'fresh CLI uses non-default wg argv' assert_equal 1 "$(grep -c '^wg show wg9 dump$' "$TEST_MOCK_LOG")" 'fresh CLI emits one exact non-default wg query' assert_equal 1 "$(grep -c '^ip link show dev wg9$' "$TEST_MOCK_LOG")" 'fresh CLI emits one exact non-default ip query' assert_equal 1 "$(grep -c '^systemctl is-system-running$' "$TEST_MOCK_LOG")" 'fresh CLI emits one systemd capability query' assert_equal 1 "$(grep -c '^systemctl is-active -- wg-quick@wg9$' "$TEST_MOCK_LOG")" 'fresh CLI emits one service query' assert_equal 1 "$(grep -c '^journalctl -u wg-quick@wg9 --since 24\\ hours\\ ago --no-pager$' "$TEST_MOCK_LOG")" 'fresh CLI emits one bounded journal query' export DIAG_IP_LINK_FAIL=1 : >"$TEST_MOCK_LOG" set +e; output=$(bash "$PROJECT_DIR/wg-manager.sh" diagnose --quiet 2>&1); status=$?; set -e assert_equal "$EXIT_OPERATION" "$status" 'fresh CLI critical failure returns operation status' assert_contains 'journal=' "$output" 'fresh CLI continues after an early interface failure' unset DIAG_IP_LINK_FAIL unset DIAG_IP_LINK_OUTPUT DIAG_IP_ADDR_OUTPUT DIAG_IP_ROUTE_OUTPUT DIAG_SS_OUTPUT DIAG_NFT_OUTPUT SYSTEMCTL_STATE ;; esac if [[ $test_group == diagnostics-exit-codes ]]; then set +e; diagnose --bad >/dev/null 2>&1; status=$?; set -e assert_equal "$EXIT_USAGE" "$status" 'неизвестный флаг diagnose' fi } run_m11_tests() { local config_file="$TEST_ROOT/m11.conf" fixture="$TEST_ROOT/m11-wg0.conf" local output status before_tree after_tree outside sentinel local initial_hash baseline_backup baseline_id m11_dump failed_hash cp -- "$PROJECT_DIR/tests/fixtures/m3/source.conf" "$fixture" chmod 600 -- "$fixture" write_m2_config "$config_file" "$fixture" replace_config_line "$config_file" 'CLIENT_DIR=' "CLIENT_DIR=\"$TEST_ROOT/m11-clients\"" export WG_MANAGER_TEST_MODE=1 WG_MANAGER_CONFIG="$config_file" export WG_MANAGER_BACKUP_DIR="$TEST_ROOT/m11-backups" WG_MANAGER_LOG_DIR="$TEST_ROOT/m11-logs" export WG_MANAGER_METADATA_DIR="$TEST_ROOT/m11-meta" WG_MANAGER_ARCHIVE_DIR="$TEST_ROOT/m11-archive" export WG_MANAGER_LOCK_FILE="$TEST_ROOT/m11.lock" WG_MOCK_INTERFACE=wg0 export WG_MOCK_DUMP_FILE="$PROJECT_DIR/tests/fixtures/m3/runtime.dump" unset WG_MANAGER_GUM GUM_SCRIPT_FILE WG_SYNC_FAIL WG_SYNC_FAIL_ONCE_FILE set_runtime_paths load_config rm -rf -- "$CLIENT_DIR" "$METADATA_DIR" "$BACKUP_DIR" "$LOG_DIR" "$ARCHIVE_DIR" mkdir -p -- "$CLIENT_DIR" "$METADATA_DIR" "$BACKUP_DIR" "$LOG_DIR" "$ARCHIVE_DIR" chmod 700 -- "$CLIENT_DIR" "$METADATA_DIR" "$BACKUP_DIR" "$LOG_DIR" "$ARCHIVE_DIR" : >"$TEST_MOCK_LOG" case $test_group in symlink-safety) begin_test 'M11 rejects symlinked runtime/config targets without following them' outside="$TEST_ROOT/m11-outside"; mkdir -p -- "$outside" sentinel="$outside/sentinel"; printf 'unchanged\n' >"$sentinel" mv -- "$config_file" "$TEST_ROOT/m11-manager-real.conf" ln -s -- "$TEST_ROOT/m11-manager-real.conf" "$config_file" set +e; output=$(bash "$PROJECT_DIR/wg-manager.sh" diagnose --quiet 2>&1); status=$?; set -e assert_contains 'config-readable=FAIL' "$output" 'symlinked manager config is rejected' assert_equal unchanged "$(<"$sentinel")" 'symlink target sentinel remains unchanged' rm -f -- "$config_file"; mv -- "$TEST_ROOT/m11-manager-real.conf" "$config_file" mv -- "$BACKUP_DIR" "$TEST_ROOT/m11-backups-real" ln -s -- "$outside" "$BACKUP_DIR" set +e; output=$(manual_backup 2>&1); status=$?; set -e assert_failure "$status" 'symlinked backup directory is rejected' assert_equal unchanged "$(<"$sentinel")" 'backup symlink target remains unchanged' rm -f -- "$BACKUP_DIR"; mv -- "$TEST_ROOT/m11-backups-real" "$BACKUP_DIR" ;; no-host-writes) begin_test 'M11 isolated lifecycle does not write the sandbox sibling or repository' local operation_root sibling_root source_snapshot repo_runtime_before repo_runtime_after operation_root="$TEST_ROOT/operation-root"; sibling_root="$TEST_ROOT/sibling-root" mkdir -p -- "$operation_root" "$sibling_root/keep" printf 'sentinel-before\n' >"$sibling_root/keep/sentinel" cp -- "$PROJECT_DIR/tests/fixtures/m3/source.conf" "$operation_root/wg0.conf" write_m2_config "$operation_root/m11.conf" "$operation_root/wg0.conf" replace_config_line "$operation_root/m11.conf" 'CLIENT_DIR=' "CLIENT_DIR=\"$operation_root/clients\"" replace_config_line "$operation_root/m11.conf" 'BACKUP_RETENTION=' 'BACKUP_RETENTION="3"' export WG_MANAGER_CONFIG="$operation_root/m11.conf" WG_MANAGER_BACKUP_DIR="$operation_root/backups" WG_MANAGER_LOG_DIR="$operation_root/logs" WG_MANAGER_METADATA_DIR="$operation_root/metadata" WG_MANAGER_ARCHIVE_DIR="$operation_root/archive" WG_MANAGER_LOCK_FILE="$operation_root/lock" cp -- "$PROJECT_DIR/tests/fixtures/m3/runtime.dump" "$operation_root/runtime.dump" mkdir -p -- "$operation_root/dumps" cp -- "$operation_root/runtime.dump" "$operation_root/dumps/1" for candidate in 3 4 5 6; do cp -- "$operation_root/runtime.dump" "$operation_root/dumps/$candidate"; done { cat "$operation_root/runtime.dump"; printf 'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF=\t(none)\tvpn.example.invalid:51820\t192.0.2.3/32\t0\t0\t0\t0\n'; } >"$operation_root/dumps/2" export WG_MOCK_DUMP_FILE="$operation_root/runtime.dump" WG_MOCK_DUMP_SEQUENCE_DIR="$operation_root/dumps" mkdir -p -- "$operation_root/clients" "$operation_root/backups" "$operation_root/logs" "$operation_root/metadata" "$operation_root/archive" chmod 700 -- "$operation_root/clients" "$operation_root/backups" "$operation_root/logs" "$operation_root/metadata" "$operation_root/archive" load_config source_snapshot=$(sha256sum "$sibling_root/keep/sentinel") repo_runtime_before=$(find "$PROJECT_DIR" -maxdepth 1 -name '.wg-manager.*' -o -name 'wg-manager.log' | LC_ALL=C sort) set +e; bash "$PROJECT_DIR/wg-manager.sh" status >/dev/null 2>/dev/null; status=$?; set -e assert_equal 0 "$status" 'isolated status succeeds' set +e; add_client sandbox-client >/dev/null 2>"$operation_root/add.err"; status=$?; set -e assert_equal 0 "$status" 'isolated real mutation succeeds' assert_equal "$source_snapshot" "$(sha256sum "$sibling_root/keep/sentinel")" 'sandbox sibling is unchanged' assert_equal '' "$(find "$operation_root" -path "$operation_root/*" ! -path "$operation_root/clients/*" ! -path "$operation_root/backups/*" ! -path "$operation_root/logs/*" ! -path "$operation_root/metadata/*" ! -path "$operation_root/archive/*" -type f -name '*wg-manager*' -print)" 'temporary mutation files stay in operation root' repo_runtime_after=$(find "$PROJECT_DIR" -maxdepth 1 -name '.wg-manager.*' -o -name 'wg-manager.log' | LC_ALL=C sort) assert_equal "$repo_runtime_before" "$repo_runtime_after" 'repository has no runtime files' assert_equal '' "$(grep -E '(/etc|/proc|/sys)' "$TEST_MOCK_LOG" || true)" 'mock log contains no host state path' assert_equal '' "$(grep -E 'wg-quick (up|down|save)|ip (add|del|replace)|systemctl (start|stop|restart)|nft (add|delete|insert|flush)|iptables (-A|-D|-I|-F)|sysctl -w' "$TEST_MOCK_LOG" || true)" 'lifecycle uses no host mutation commands' local forbidden_host_path="/$(printf 'etc')/$(printf 'wireguard')" assert_equal '' "$(rg -n --glob 'wg-manager.sh' --glob 'add-user.sh' "${forbidden_host_path}|/bin/(wg|wg-quick|ip|systemctl|journalctl|nft|iptables|ss)|env -i|bash -c|sh -c|eval[[:space:]]" "$PROJECT_DIR" || true)" 'production has no unscoped host command bypass' ;; no-host-command-bypass) begin_test 'M11 sensitive commands resolve to isolated mocks and execute through them' local failures_before=$TEST_FAILURES command_name candidate path line command_dir safe_dir resolved saved_path bypass_output command_dir="$TESTS_DIR/mocks/bin"; safe_dir="$TEST_ROOT/safe-bin"; mkdir -p -- "$safe_dir" for candidate in awk bash cat chmod cp cut date find grep head mkdir mktemp mv paste rg rm sed sha256sum sort stat tail tr; do ln -s -- "$(command -v "$candidate")" "$safe_dir/$candidate" done local bypass_path="$command_dir:$safe_dir" for command_name in wg wg-quick ip systemctl journalctl ss nft iptables qrencode gum; do resolved=$(PATH="$bypass_path" command -v "$command_name" || true) assert_equal "$command_dir/$command_name" "$resolved" "${command_name} resolves to isolated mock" done : >"$TEST_MOCK_LOG" saved_path=$PATH PATH="$bypass_path"; export PATH set +e; bypass_output=$(bash "$PROJECT_DIR/wg-manager.sh" status 2>&1); status=$?; set -e assert_equal 0 "$status" 'controlled PATH status succeeds' set +e; bypass_output=$(bash "$PROJECT_DIR/wg-manager.sh" diagnose --quiet 2>&1); status=$?; set -e assert_equal 0 "$status" 'controlled PATH diagnose succeeds with isolated mocks' mkdir -p -- "$TEST_ROOT/bypass-dumps" cp -- "$PROJECT_DIR/tests/fixtures/m3/runtime.dump" "$TEST_ROOT/bypass-dumps/1" cp -- "$TEST_ROOT/bypass-dumps/1" "$TEST_ROOT/bypass-dumps/3" { cat "$TEST_ROOT/bypass-dumps/1"; printf 'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF=\t(none)\tvpn.example.invalid:51820\t192.0.2.3/32\t0\t0\t0\t0\n'; } >"$TEST_ROOT/bypass-dumps/2" export WG_MOCK_DUMP_SEQUENCE_DIR='' WG_MOCK_DUMP_FILE="$TEST_ROOT/bypass-dumps/1" WG_SYNC_FAIL_ONCE_FILE="$TEST_ROOT/bypass-sync-fail" set +e; bypass_output=$(add_client bypass-client 2>&1); status=$?; set -e unset WG_SYNC_FAIL_ONCE_FILE assert_equal "$EXIT_OPERATION" "$status" 'controlled PATH real add failure is contained' PATH=$saved_path; export PATH assert_contains 'wg show wg0 dump' "$(<"$TEST_MOCK_LOG")" 'controlled mock recorded wg runtime access' assert_contains 'wg syncconf wg0' "$(<"$TEST_MOCK_LOG")" 'controlled mock recorded wg mutation apply' for command_name in wg wg-quick ip systemctl journalctl nft iptables sysctl; do for candidate in "/bin/${command_name}" "/usr/bin/${command_name}"; do for path in "$PROJECT_DIR/wg-manager.sh" "$TESTS_DIR"/*.sh "$TESTS_DIR"/mocks/bin/*; do [[ -f "$path" ]] || continue while IFS= read -r line || [[ -n "$line" ]]; do if [[ "$line" == "$candidate" || "$line" == *" $candidate"* || "$line" == *"=\"$candidate"* ]]; then record_failure "absolute host command in ${path}: ${candidate}" break fi done <"$path" done done done assert_equal "$failures_before" "$TEST_FAILURES" 'no absolute host networking/service command' assert_equal '' "$(grep -E '^(wg|wg-quick|ip|systemctl|journalctl|nft|iptables|sysctl|ss|qrencode|gum) ' "$TEST_MOCK_LOG" | grep -E '(/etc|/proc|/sys)' || true)" 'mock argv has no host-state path' assert_equal '' "$(rg -n --glob 'wg-manager.sh' --glob 'add-user.sh' 'env -i|bash -c|sh -c|eval[[:space:]]|(^|[[:space:]])/(bin|usr/bin)/(wg|wg-quick|ip|systemctl|journalctl|nft|iptables|ss|qrencode|gum)([[:space:]]|$)' "$PROJECT_DIR" || true)" 'no direct command bypass forms' ;; m11-e2e) begin_test 'M11 fixture matrix covers fresh, mixed, stopped, and malformed states' cp -- "$PROJECT_DIR/tests/fixtures/m2/no-peers.conf" "$fixture" set +e; output=$(bash "$PROJECT_DIR/wg-manager.sh" status 2>&1); status=$?; set -e assert_equal 0 "$status" 'fresh server with no peers is readable' cp -- "$PROJECT_DIR/tests/fixtures/m2/managed.conf" "$fixture" set +e; output=$(bash "$PROJECT_DIR/wg-manager.sh" list 2>&1); status=$?; set -e assert_equal 0 "$status" 'mixed/managed fixture remains readable without mutation' export WG_MOCK_DUMP_FAIL_ON_CALL=1 set +e; output=$(bash "$PROJECT_DIR/wg-manager.sh" status 2>&1); status=$?; set -e assert_equal 0 "$status" 'stopped runtime degrades to disk-only status' unset WG_MOCK_DUMP_FAIL_ON_CALL cp -- "$PROJECT_DIR/tests/fixtures/m2/malformed.conf" "$fixture" set +e; output=$(bash "$PROJECT_DIR/wg-manager.sh" list 2>&1); status=$?; set -e assert_equal "$EXIT_VALIDATION" "$status" 'malformed persistent config fails closed' begin_test 'M11 compatibility wrapper is source-safe and delegates one backend' set +e; output=$(bash "$PROJECT_DIR/add-user.sh" 2>&1); status=$?; set -e assert_equal "$EXIT_USAGE" "$status" 'legacy wrapper rejects missing name' assert_contains 'Устаревший wrapper' "$output" 'legacy wrapper is documented as compatibility path' assert_equal '' "$(grep -E '/etc|37\.26\.136\.232|PrivateKey|PresharedKey' <<<"$output" || true)" 'wrapper output has no host path or secret' begin_test 'M11 wrapper is source-safe and forwards an exact argument vector' local wrapper_dir wrapper_capture probe_output fake_wrapper wrapper_dir="$TEST_ROOT/wrapper"; wrapper_capture="$TEST_ROOT/wrapper-argv" mkdir -- "$wrapper_dir" cp -- "$PROJECT_DIR/add-user.sh" "$wrapper_dir/add-user.sh" fake_wrapper="$wrapper_dir/wg-manager.sh" printf '#!/usr/bin/env bash\nprintf "%%s\\0" "$@" >%q\n' "$wrapper_capture" >"$fake_wrapper" chmod 700 -- "$fake_wrapper" "$wrapper_dir/add-user.sh" probe_output=$(printf 'before\n'; main() { printf 'caller-main\n'; }; usage() { printf 'caller-usage\n'; }; source "$wrapper_dir/add-user.sh"; main; usage; printf 'after\n') assert_equal $'before\ncaller-main\ncaller-usage\nafter' "$probe_output" 'sourcing wrapper preserves caller functions' "$wrapper_dir/add-user.sh" alice vpn.example.invalid local -a wrapper_args=() mapfile -d '' -t wrapper_args <"$wrapper_capture" assert_equal 4 "${#wrapper_args[@]}" 'wrapper forwards four arguments' assert_equal add "${wrapper_args[0]}" 'wrapper command argument' assert_equal alice "${wrapper_args[1]}" 'wrapper name argument' assert_equal --endpoint "${wrapper_args[2]}" 'wrapper endpoint flag' assert_equal vpn.example.invalid "${wrapper_args[3]}" 'wrapper endpoint value' set +e; "$wrapper_dir/add-user.sh" >/dev/null 2>&1; status=$?; set -e assert_equal "$EXIT_USAGE" "$status" 'wrapper rejects missing name' set +e; "$wrapper_dir/add-user.sh" one two three >/dev/null 2>&1; status=$?; set -e assert_equal "$EXIT_USAGE" "$status" 'wrapper rejects extra arguments' "$wrapper_dir/add-user.sh" $'-unsafe\nname' wrapper_args=(); mapfile -d '' -t wrapper_args <"$wrapper_capture" assert_equal $'-unsafe\nname' "${wrapper_args[1]}" 'wrapper forwards option-like newline name literally' begin_test 'M11 real add backend produces an inspectable isolated client' prepare_m10_add_fixture set +e; add_client m11-client >/dev/null 2>"$TEST_ROOT/m11-add.err"; status=$?; set -e assert_equal 0 "$status" 'real M11 add succeeds' assert_file_exists "$CLIENT_DIR/m11-client.conf" 'real M11 client config exists' assert_file_exists "$METADATA_DIR/m11-client.meta" 'real M11 metadata exists' set +e; output=$(export_client_config m11-client 0 2>&1); status=$?; set -e assert_equal 0 "$status" 'real M11 config path is validated' assert_contains 'm11-client.conf' "$output" 'real M11 config path is shown' unset WG_MOCK_DUMP_SEQUENCE_DIR export WG_MOCK_DUMP_FILE="$TEST_ROOT/dump-sequence/1" local m11_no_qr utility m11_no_qr="$TEST_ROOT/m11-no-qr"; mkdir -- "$m11_no_qr" ln -s -- "${PROJECT_DIR}/tests/mocks/bin/wg" "$m11_no_qr/wg" ln -s -- "${PROJECT_DIR}/tests/mocks/bin/wg-quick" "$m11_no_qr/wg-quick" for utility in stat sort date cat paste grep awk; do ln -s -- "$(command -v "$utility")" "$m11_no_qr/$utility"; done local m11_saved_path=$PATH PATH="$m11_no_qr"; export PATH set +e; output=$(qr_client m11-client 2>&1); status=$?; set -e PATH=$m11_saved_path; export PATH assert_equal "$EXIT_DEPENDENCY" "$status" 'M11 QR fallback reports missing optional dependency' assert_contains 'm11-client.conf' "$output" 'M11 QR fallback exposes only safe path' assert_equal '' "$(grep -E 'PrivateKey|PresharedKey|EEEE|GGGG' "$TEST_ROOT/m11-add.err" "$TEST_MOCK_LOG" "$LOG_DIR/wg-manager.log" || true)" 'M11 lifecycle diagnostics remain secret-free' begin_test 'M11 real lifecycle add, inspect, remove, and restore returns to baseline' # The add fixture is the same isolated model used by the production M4 # tests. Keep the complete lifecycle in one process so every transition # is verified against the preceding state rather than a stubbed callback. prepare_m10_add_fixture initial_hash=$(sha256sum "$WG_CONF") baseline_backup=$(create_backup "$WG_CONF" m11 "$WG_INTERFACE") baseline_id=${baseline_backup##*/} set +e; add_client m11-client >/dev/null 2>"$TEST_ROOT/m11-lifecycle-add.err"; status=$?; set -e assert_equal 0 "$status" 'lifecycle add succeeds' assert_file_exists "$CLIENT_DIR/m11-client.conf" 'lifecycle client config published' assert_file_exists "$METADATA_DIR/m11-client.meta" 'lifecycle metadata published' assert_equal 600 "$(stat -c '%a' "$CLIENT_DIR/m11-client.conf")" 'lifecycle config mode' assert_equal 600 "$(stat -c '%a' "$METADATA_DIR/m11-client.meta")" 'lifecycle metadata mode' assert_contains '192.0.2.3/32' "$(<"$WG_CONF")" 'lifecycle server peer address' assert_contains 'operation=add' "$(<"$LOG_DIR/wg-manager.log")" 'lifecycle add audit' # Both the original fixture peer and the newly managed peer are present; # assert their independent origins before removing the managed one. m11_dump="$TEST_ROOT/m11-after-add.dump" cat >"$m11_dump" <<'EOF' AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= 51820 0 BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB= (none) vpn.example.invalid:51820 192.0.2.2/32 0 0 0 0 FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF= (none) vpn.example.invalid:51820 192.0.2.3/32 0 0 0 0 EOF export WG_MOCK_DUMP_FILE="$m11_dump"; unset WG_MOCK_DUMP_SEQUENCE_DIR set +e; output=$(bash "$PROJECT_DIR/wg-manager.sh" list 2>&1); status=$?; set -e assert_equal 0 "$status" 'lifecycle mixed model is readable' assert_contains $'\tmanaged' "$output" 'lifecycle managed origin' assert_contains $'\tunmanaged' "$output" 'lifecycle unmanaged origin' assert_contains '192.0.2.3/32' "$output" 'lifecycle managed address is distinct' assert_contains '192.0.2.2/32' "$output" 'lifecycle unmanaged address is distinct' # The runtime state after removal is the baseline (the managed peer is # absent); keep this separate from the add-state dump used for listing. printf 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\tAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\t51820\t0\nBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=\t(none)\tvpn.example.invalid:51820\t192.0.2.2/32\t0\t0\t0\t0\n' >"$TEST_ROOT/m11-after-remove.dump" export WG_MOCK_DUMP_FILE="$TEST_ROOT/m11-after-remove.dump" # Remove by the authoritative public key, then verify the exact source # bytes and archive rather than merely checking a return status. set +e; output=$(remove_client_exact m11-client FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF= 2>"$TEST_ROOT/m11-lifecycle-remove.err"); status=$?; set -e assert_equal 0 "$status" 'lifecycle remove succeeds' assert_equal 0 "$(grep -c '192.0.2.3/32' "$WG_CONF" || true)" 'lifecycle remove removes managed peer address' assert_equal 0 "$(find "$CLIENT_DIR" "$METADATA_DIR" -maxdepth 1 -type f -name 'm11-client.*' | wc -l)" 'lifecycle remove deletes source artifacts' assert_equal 1 "$(find "$ARCHIVE_DIR" -mindepth 1 -maxdepth 1 -type d | wc -l)" 'lifecycle remove creates one archive' assert_contains 'operation=remove' "$(<"$LOG_DIR/wg-manager.log")" 'lifecycle remove audit' # Restore the pre-add snapshot through the real restore backend. The # runtime fixture is the baseline dump, so verification also covers the # current-state backup created by restore. export WG_MOCK_DUMP_FILE="$TEST_ROOT/dump-sequence/2" set +e; output=$(restore_backup "$baseline_id" --yes 2>"$TEST_ROOT/m11-lifecycle-restore.err"); status=$?; set -e assert_equal 0 "$status" 'lifecycle restore succeeds' assert_equal "$initial_hash" "$(sha256sum "$WG_CONF")" 'lifecycle restore is byte-identical to baseline' assert_equal 1 "$(find "$BACKUP_DIR" -mindepth 1 -maxdepth 1 -type d -name "$baseline_id" | wc -l)" 'lifecycle source backup remains valid' assert_contains 'operation=restore' "$(<"$LOG_DIR/wg-manager.log")" 'lifecycle restore audit' assert_contains 'syncconf' "$(<"$TEST_MOCK_LOG")" 'lifecycle restore applies runtime state' begin_test 'M11 real add rollback and failed rollback are distinct' prepare_m10_add_fixture failed_hash=$(sha256sum "$WG_CONF") export WG_MOCK_DUMP_FILE="$TEST_ROOT/dump-sequence/2"; unset WG_MOCK_DUMP_SEQUENCE_DIR export WG_SYNC_FAIL_ONCE_FILE="$TEST_ROOT/m11-sync-fail" set +e; add_client m11-failed >/dev/null 2>"$TEST_ROOT/m11-failed.err"; status=$?; set -e unset WG_SYNC_FAIL_ONCE_FILE assert_equal "$EXIT_OPERATION" "$status" 'lifecycle apply failure rolls back successfully' assert_equal "$failed_hash" "$(sha256sum "$WG_CONF")" 'successful rollback preserves disk' assert_equal 0 "$(find "$CLIENT_DIR" "$METADATA_DIR" -maxdepth 1 -type f -name 'm11-failed.*' | wc -l)" 'successful rollback removes client artifacts' assert_contains 'operation=rollback' "$(<"$LOG_DIR/wg-manager.log")" 'successful rollback is audited' prepare_m10_add_fixture failed_hash=$(sha256sum "$WG_CONF") export WG_MOCK_DUMP_FILE="$TEST_ROOT/dump-sequence/2"; unset WG_MOCK_DUMP_SEQUENCE_DIR export WG_SYNC_FAIL_ONCE_FILE="$TEST_ROOT/m11-critical-sync" TRANSACTION_ROLLBACK_REPLACE_FAIL=1 set +e; add_client m11-critical >/dev/null 2>"$TEST_ROOT/m11-critical.err"; status=$?; set -e unset WG_SYNC_FAIL_ONCE_FILE TRANSACTION_ROLLBACK_REPLACE_FAIL assert_equal "$EXIT_ROLLBACK" "$status" 'failed rollback is critical' assert_equal 1 "$(test -f "$WG_CONF" && printf 1 || printf 0)" 'failed rollback keeps a live source file' assert_contains 'КРИТИЧНО' "$(<"$TEST_ROOT/m11-critical.err")" 'failed rollback diagnostic is critical' begin_test 'M11 diagnostics skip unavailable optional capabilities' prepare_m10_add_fixture printf '1\n' >"$TEST_ROOT/m11-ipv4-forward" printf '1\n' >"$TEST_ROOT/m11-ipv6-forward" export WG_MANAGER_IPV4_FORWARD_FILE="$TEST_ROOT/m11-ipv4-forward" WG_MANAGER_IPV6_FORWARD_FILE="$TEST_ROOT/m11-ipv6-forward" DIAG_DISABLE_COMMANDS='systemctl,journalctl,nft,iptables,ss' set +e; output=$(diagnose --quiet 2>&1); status=$?; set -e unset DIAG_DISABLE_COMMANDS assert_equal 0 "$status" 'optional diagnostic capabilities degrade safely' assert_contains 'systemd=SKIP' "$output" 'systemd unavailable is skipped' assert_contains 'journal=SKIP' "$output" 'journal unavailable is skipped' assert_contains 'udp-listener=SKIP' "$output" 'ss unavailable is skipped' assert_contains 'firewall=SKIP' "$output" 'firewall unavailable is skipped' begin_test 'M11 malformed metadata is inert for read-only inspection' printf 'UNKNOWN=value\n' >"$METADATA_DIR/bad.meta"; chmod 600 -- "$METADATA_DIR/bad.meta" set +e; output=$(bash "$PROJECT_DIR/wg-manager.sh" list 2>&1); status=$?; set -e assert_failure "$status" 'malformed metadata fails closed' assert_equal 0 "$(grep -c 'UNKNOWN=value' "$TEST_MOCK_LOG" || true)" 'metadata text never reaches mock argv' ;; secret-scan) begin_test 'M11 repository and lifecycle secret scan' local repository_hits lifecycle_output secret_token secret_tokens forbidden_endpoint local forbidden_host_path="/$(printf 'etc')/$(printf 'wireguard')" repository_hits=$(rg -n --glob 'wg-manager.sh' --glob 'add-user.sh' --glob 'README.md' "37\\.26\\.136\\.232|${forbidden_host_path}" "$PROJECT_DIR" || true) assert_equal '' "$repository_hits" 'production/docs contain no host endpoint or live WireGuard path' local production_keys production_keys=$(rg -o --glob 'wg-manager.sh' --glob 'add-user.sh' --glob 'README.md' '[A-Za-z0-9+/]{43}=' "$PROJECT_DIR" || true) assert_equal '' "$production_keys" 'production and docs contain no key-shaped literals' secret_tokens=$(rg -o --glob '!/.git/**' --glob '!*.md' '[A-Za-z0-9+/]{43}=' "$PROJECT_DIR" | sed 's/.*://' | sort -u || true) local unexpected_token=0 while IFS= read -r secret_token; do [[ -z $secret_token ]] && continue case $secret_token in AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=|BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=|CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC=|DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD=|EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE=|FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF=|GGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGG=|HHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHH=) ;; *) unexpected_token=1 ;; esac done <<<"$secret_tokens" assert_equal 0 "$unexpected_token" 'repository key-shaped values are documented placeholders only' forbidden_endpoint=$(rg -n '37\\.26\\.136\\.232' "$PROJECT_DIR/wg-manager.sh" "$PROJECT_DIR/add-user.sh" "$PROJECT_DIR/README.md" "$PROJECT_DIR/tests/fixtures" || true) assert_equal '' "$forbidden_endpoint" 'repository contains no removed public endpoint' lifecycle_output=$(bash "$PROJECT_DIR/wg-manager.sh" help 2>&1) assert_equal '' "$(grep -E 'PrivateKey|PresharedKey|[A-Za-z0-9+/]{43}=' <<<"$lifecycle_output" || true)" 'captured CLI output has no key material' assert_equal '' "$(grep -E "37\\.26\\.136\\.232|${forbidden_host_path}" "$TEST_MOCK_LOG" || true)" 'mock log has no host paths' assert_equal '' "$(grep -E 'PrivateKey|PresharedKey|EEEE|GGGG' "$TEST_MOCK_LOG" || true)" 'mock log has no secret markers' begin_test 'M11 captured real lifecycle output is secret-free' prepare_m10_add_fixture set +e; lifecycle_output=$(add_client secret-scan 2>"$TEST_ROOT/secret-add.err"); status=$?; set -e assert_equal 0 "$status" 'secret scan real add succeeds' set +e; lifecycle_output+=$'\n'"$(show_client secret-scan 2>"$TEST_ROOT/secret-show.err")"; status=$?; set -e assert_equal 0 "$status" 'secret scan real show succeeds' export WG_MOCK_DUMP_FILE="$TEST_ROOT/dump-sequence/2"; unset WG_MOCK_DUMP_SEQUENCE_DIR WG_SYNC_FAIL_ONCE_FILE set +e; add_client secret-failed >/dev/null 2>"$TEST_ROOT/secret-fail.err"; status=$?; set -e local captured_secret_stream captured_secret_stream=$(printf '%s\n' "$lifecycle_output" "$(<"$TEST_ROOT/secret-add.err")" "$(<"$TEST_ROOT/secret-show.err")" "$(<"$TEST_ROOT/secret-fail.err")" "$(<"$LOG_DIR/wg-manager.log")" "$(<"$TEST_MOCK_LOG")") assert_equal '' "$(grep -E 'PrivateKey|PresharedKey|EEEE|GGGG|\[Interface\]|\[Peer\]' <<<"$captured_secret_stream" || true)" 'ordinary lifecycle output contains no secret/config markers' assert_equal '' "$(grep -E 'PrivateKey|PresharedKey|EEEE|GGGG' "$LOG_DIR/wg-manager.log" "$TEST_MOCK_LOG" || true)" 'lifecycle audit and mocks contain no private/PSK values' ;; esac } if [[ $test_group == symlink-safety || $test_group == no-host-writes || $test_group == no-host-command-bypass || $test_group == m11-e2e || $test_group == secret-scan ]]; then run_m11_tests elif [[ $test_group == config ]]; then run_config_tests elif [[ $test_group == show-client || $test_group == config-export || $test_group == qr || $test_group == path-safety || $test_group == no-secret-default-output ]]; then run_m5_tests elif [[ $test_group == parser || $test_group == status || $test_group == list || $test_group == list-redirected || $test_group == list-deterministic ]]; then run_m2_focused "$test_group" elif [[ $test_group == transaction || $test_group == rollback || $test_group == locking || $test_group == backups || $test_group == mutation-preserves-source || $test_group == secret-modes ]]; then run_m3_tests [[ $test_group == secret-modes ]] && run_m4_tests elif [[ $test_group == add-client || $test_group == address-allocation || $test_group == add-collisions || $test_group == add-rollback || $test_group == no-secret-logs || $test_group == add-golden-config || $test_group == add-invalid-names ]]; then run_m4_tests elif [[ $test_group == remove-client || $test_group == remove-cancel || $test_group == remove-exact-block || $test_group == remove-rollback || $test_group == archive-client || $test_group == archive-path-safety || $test_group == remove-preserves-neighbors ]]; then run_m6_tests elif [[ $test_group == gum-resolution || $test_group == tui-routing || $test_group == tui-cancel || $test_group == tui-read-only || $test_group == tui-no-gum || $test_group == tui-non-tty || $test_group == tui-add || $test_group == tui-remove || $test_group == tui-client-actions || $test_group == tui-backup || $test_group == tui-restore || $test_group == tui-backend-failure || $test_group == tui-no-secret-args ]]; then run_m9_tests elif [[ $test_group == audit || $test_group == audit-no-secrets || $test_group == backup-list || $test_group == restore || $test_group == restore-invalid || $test_group == restore-rollback || $test_group == backup-path-safety || $test_group == runtime-file-modes ]]; then run_m8_tests elif [[ $test_group == diagnostics || $test_group == diagnostics-systemd || $test_group == diagnostics-firewall || $test_group == diagnostics-forwarding || $test_group == diagnostics-runtime-diff || $test_group == diagnostics-read-only || $test_group == diagnostics-exit-codes ]]; then run_m7_tests else run_config_tests run_gum_tests run_dependency_tests run_debug_tests run_m2_tests run_m3_tests run_m4_tests test_group=show-client run_m5_tests test_group='' run_m6_tests for m7_group in diagnostics diagnostics-systemd diagnostics-firewall diagnostics-forwarding diagnostics-runtime-diff diagnostics-read-only diagnostics-exit-codes; do test_group=$m7_group run_m7_tests done unset m7_group for m8_group in audit audit-no-secrets backup-list restore restore-invalid restore-rollback backup-path-safety runtime-file-modes; do test_group=$m8_group run_m8_tests done unset m8_group for m9_group in gum-resolution tui-routing tui-cancel tui-read-only tui-no-gum tui-non-tty tui-add tui-remove tui-client-actions tui-backup tui-restore tui-backend-failure tui-no-secret-args; do test_group=$m9_group run_m9_tests done unset m9_group for m11_group in symlink-safety no-host-writes no-host-command-bypass m11-e2e secret-scan; do test_group=$m11_group run_m11_tests done unset m11_group test_group='' fi finish_tests