Files
wireguard-scripts/tests/run.sh
T

4133 lines
294 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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) ;; *) 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" <<EOF
# Safe test configuration
WG_INTERFACE="${wg_interface}"
WG_CONF="${wg_conf}"
CLIENT_DIR="${client_dir}"
SERVER_ENDPOINT="${server_endpoint}"
SERVER_PORT="${server_port}"
SERVER_VPN_IP="${server_vpn_ip}"
CLIENT_DNS="${client_dns}"
VPN_SUBNET_PREFIX="${vpn_subnet_prefix}"
START_HOST="${start_host}"
END_HOST="${end_host}"
USE_IPV6="${use_ipv6}"
IPV6_PREFIX="${ipv6_prefix}"
DEFAULT_CLIENT_MODE="${default_client_mode}"
RECENT_HANDSHAKE_SECONDS="${recent_handshake_seconds}"
BACKUP_RETENTION="${backup_retention}"
EOF
}
remove_config_key() {
local input_file=$1 key=$2 line temp_file
temp_file="${input_file}.tmp"
while IFS= read -r line || [[ -n $line ]]; do
[[ $line == "${key}="* ]] && continue
printf '%s\n' "$line"
done <"$input_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 '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
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" <<EOF
NAME=alice
PUBLIC_KEY=BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=
IPV4=192.0.2.2
CREATED_AT=1700000000
CONFIG_PATH=${CLIENT_DIR}/alice.conf
CLIENT_MODE=full-tunnel
MANAGED=1
EOF
}
write_bob_metadata() {
cat >"${METADATA_DIR}/bob.meta" <<EOF
NAME=bob
PUBLIC_KEY=CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC=
IPV4=192.0.2.3
CREATED_AT=1700000001
CONFIG_PATH=${CLIENT_DIR}/bob.conf
CLIENT_MODE=split-tunnel
MANAGED=1
EOF
}
snapshot_model_inputs() {
local root path mode
for root in "$@"; do
[[ -e "$root" ]] || continue
find "$root" -print0
done | LC_ALL=C sort -z | while IFS= read -r -d '' path; do
mode=$(stat -c '%a' -- "$path")
if [[ -d "$path" ]]; then
printf 'D:%s:%s\n' "$path" "$mode"
else
printf 'F:%s:%s:' "$path" "$mode"
sha256sum -- "$path"
fi
done
}
run_m2_tests() {
local config_file="${TEST_ROOT}/m2.conf" fixture="${TEST_ROOT}/wg0.conf" output status
local runtime_file="${TEST_ROOT}/runtime.dump"
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"
: >"$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\t0\nBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=\t(none)\t(none)\t(none)\t0\t0\t0\t0'
assert_equal - "${RUNTIME_ENDPOINT[BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=]}" 'endpoint none'
assert_equal - "${RUNTIME_ALLOWED[BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=]}" 'allowed none'
parse_runtime_dump $'(none)\t(none)\t51820\t0'
assert_equal 51820 "$RUNTIME_LISTEN_PORT" 'interface none tokens'
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" <<EOF
NAME=occupied
PUBLIC_KEY=CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC=
IPV4=192.0.2.3
CREATED_AT=1700000000
CONFIG_PATH=${CLIENT_DIR}/occupied.conf
CLIENT_MODE=full-tunnel
MANAGED=1
EOF
chmod 600 "${METADATA_DIR}/occupied.meta"
sed 's/192\.0\.2\.3\/32/192.0.2.4\/32/' "${TEST_ROOT}/dump-sequence/1" >"${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" <<EOF
NAME=ipv6-occupied
PUBLIC_KEY=CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC=
IPV4=192.0.2.9
IPV6=2001:db8:ffff::3
CREATED_AT=1700000000
CONFIG_PATH=${CLIENT_DIR}/ipv6-occupied.conf
CLIENT_MODE=full-tunnel
MANAGED=1
EOF
chmod 600 "${METADATA_DIR}/ipv6-occupied.meta"
rm -f -- "${TEST_ROOT}/dump-sequence/counter" "$TEST_MOCK_LOG"; : >"$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" <<EOF
NAME=managed
PUBLIC_KEY=CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC=
IPV4=192.0.2.3
CREATED_AT=1700000000
CONFIG_PATH=${CLIENT_DIR}/managed.conf
CLIENT_MODE=full-tunnel
MANAGED=1
EOF
chmod 600 "${METADATA_DIR}/managed.meta"
set +e; add_client managed >/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" <<EOF
NAME=key-only
PUBLIC_KEY=HHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHH=
IPV4=192.0.2.9
CREATED_AT=1700000000
CONFIG_PATH=${CLIENT_DIR}/key-only.conf
CLIENT_MODE=full-tunnel
MANAGED=1
EOF
chmod 600 "${METADATA_DIR}/key-only.meta"
export WG_MOCK_PUBLIC_KEY=HHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHH=
set +e; add_client metadata-keycollision >/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: <UP> 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: <POINTOPOINT> 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: <UP> 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
}
if [[ $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
test_group=''
fi
finish_tests