Files
wireguard-scripts/tests/run.sh
T

1622 lines
95 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) ;; *) 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 '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 'known command validates config then reports not implemented'
set +e
output=$("${PROJECT_DIR}/wg-manager.sh" add 2>&1)
status=$?
set -e
assert_equal "$EXIT_OPERATION" "$status" 'статус команды-заглушки'
assert_contains 'пока не реализована' "$output" 'сообщение заглушки'
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'
'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'
}
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_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 <"$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
if [[ $test_group == config ]]; then
run_config_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
else
run_config_tests
run_gum_tests
run_dependency_tests
run_debug_tests
run_m2_tests
run_m3_tests
fi
finish_tests