2058 lines
129 KiB
Bash
Executable File
2058 lines
129 KiB
Bash
Executable File
#!/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) ;; *) 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; 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; 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'
|
||
}
|
||
|
||
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
|
||
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 <"$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_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
|
||
[[ $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
|
||
else
|
||
run_config_tests
|
||
run_gum_tests
|
||
run_dependency_tests
|
||
run_debug_tests
|
||
run_m2_tests
|
||
run_m3_tests
|
||
run_m4_tests
|
||
fi
|
||
|
||
finish_tests
|