diff --git a/README.md b/README.md index 1c0654f..087153b 100644 --- a/README.md +++ b/README.md @@ -143,6 +143,7 @@ tests/run_all.sh --skip-integration 测试覆盖: - `tests/test_cli.sh`:入口脚本与安装脚本帮助输出 +- `tests/test_interactive.sh`:交互主菜单的 add/list/delete/save 回归 - `tests/test_common.sh`:输入校验 - `tests/test_storage.sh`:规则存储 - `tests/test_env_check.sh`:环境检查与修复 diff --git a/iptables-forward.sh b/iptables-forward.sh index 2c93c45..42f86a2 100644 --- a/iptables-forward.sh +++ b/iptables-forward.sh @@ -96,7 +96,8 @@ main_menu_loop() { local choice while true; do render_main_menu - choice=$(prompt_input '请选择 [0-5]' '') + prompt_input_capture '请选择 [0-5]' '' + choice=${PROMPT_INPUT_RESULT} case ${choice} in 1) cmd_list ;; 2) cmd_add ;; diff --git a/lib/common.sh b/lib/common.sh index 83832c2..4eadc4a 100644 --- a/lib/common.sh +++ b/lib/common.sh @@ -150,17 +150,16 @@ print_section() { } read_test_input() { - local reply='' + READ_TEST_INPUT_RESULT='' if [[ -n ${IPF_TEST_INPUTS-} ]]; then if [[ ${IPF_TEST_INPUTS} == *$'\n'* ]]; then - reply=${IPF_TEST_INPUTS%%$'\n'*} + READ_TEST_INPUT_RESULT=${IPF_TEST_INPUTS%%$'\n'*} IPF_TEST_INPUTS=${IPF_TEST_INPUTS#*$'\n'} else - reply=${IPF_TEST_INPUTS} + READ_TEST_INPUT_RESULT=${IPF_TEST_INPUTS} IPF_TEST_INPUTS='' fi fi - printf '%s' "${reply}" } prompt_input() { @@ -169,10 +168,12 @@ prompt_input() { local reply='' if [[ -n ${IPF_TEST_INPUTS-} ]]; then - reply=$(read_test_input) + read_test_input + reply=${READ_TEST_INPUT_RESULT} if [[ -z ${reply} && -n ${default_value} ]]; then reply=${default_value} fi + PROMPT_INPUT_RESULT=${reply} printf '%s\n' "${reply}" >&2 printf '%s\n' "${reply}" return 0 @@ -184,9 +185,14 @@ prompt_input() { else read -r -p "${prompt}: " reply fi + PROMPT_INPUT_RESULT=${reply} printf '%s\n' "${reply}" } +prompt_input_capture() { + prompt_input "$@" >/dev/null +} + prompt_confirm() { local prompt=$1 local default_choice=${2:-n} @@ -198,7 +204,8 @@ prompt_confirm() { suffix='[y/N]' fi - answer=$(prompt_input "${prompt} ${suffix}" "") + prompt_input_capture "${prompt} ${suffix}" "" + answer=${PROMPT_INPUT_RESULT} if [[ -z ${answer} ]]; then answer=${default_choice} fi @@ -218,15 +225,21 @@ prompt_select() { done while true; do - answer=$(prompt_input "${prompt}" "") + prompt_input_capture "${prompt}" "" + answer=${PROMPT_INPUT_RESULT} if [[ ${answer} =~ ^[0-9]+$ ]] && (( answer >= 1 && answer <= count )); then - printf '%s\n' "$((answer - 1))" + PROMPT_SELECT_RESULT=$((answer - 1)) + printf '%s\n' "${PROMPT_SELECT_RESULT}" return 0 fi log_warn "请输入 1-${count} 之间的编号。" done } +prompt_select_capture() { + prompt_select "$@" >/dev/null +} + pause_return() { if [[ -n ${IPF_TEST_INPUTS-} ]]; then return 0 @@ -272,7 +285,7 @@ _validate_ipv6_part_count() { continue fi [[ ${field} =~ ^[0-9A-Fa-f]{1,4}$ ]] || return 1 - ((count++)) + ((count += 1)) done printf '%s\n' "${count}" diff --git a/lib/rules_mgr.sh b/lib/rules_mgr.sh index a8b210a..3220a4d 100644 --- a/lib/rules_mgr.sh +++ b/lib/rules_mgr.sh @@ -216,7 +216,8 @@ cmd_add() { local proto_choice ipver_choice proto ipver lport tip tport desc print_section '添加新的转发规则' - proto_choice=$(prompt_select '请选择协议' 'TCP' 'UDP' 'TCP + UDP') + prompt_select_capture '请选择协议' 'TCP' 'UDP' 'TCP + UDP' + proto_choice=${PROMPT_SELECT_RESULT} case ${proto_choice} in 0) proto=tcp ;; 1) proto=udp ;; @@ -225,12 +226,14 @@ cmd_add() { esac while true; do - lport=$(prompt_input '请输入本地监听端口' '') + prompt_input_capture '请输入本地监听端口' '' + lport=${PROMPT_INPUT_RESULT} validate_port "${lport}" && break log_warn '端口无效,请重新输入。' done - ipver_choice=$(prompt_select '请选择 IP 版本' '仅 IPv4' '仅 IPv6' '同时 IPv4 + IPv6') + prompt_select_capture '请选择 IP 版本' '仅 IPv4' '仅 IPv6' '同时 IPv4 + IPv6' + ipver_choice=${PROMPT_SELECT_RESULT} case ${ipver_choice} in 0) ipver=4 ;; 1) ipver=6 ;; @@ -239,18 +242,21 @@ cmd_add() { esac while true; do - tip=$(prompt_input "$(rule_target_hint "${ipver}")" '') + prompt_input_capture "$(rule_target_hint "${ipver}")" '' + tip=${PROMPT_INPUT_RESULT} rule_validate_target "${tip}" "${ipver}" && break log_warn '目标地址无效,请重新输入。' done while true; do - tport=$(prompt_input '请输入目标端口' '') + prompt_input_capture '请输入目标端口' '' + tport=${PROMPT_INPUT_RESULT} validate_port "${tport}" && break log_warn '目标端口无效,请重新输入。' done - desc=$(prompt_input '请输入描述(可留空)' '') + prompt_input_capture '请输入描述(可留空)' '' + desc=${PROMPT_INPUT_RESULT} desc=$(rule_sanitize_desc "${desc}") printf '协议: %s\n' "$(rule_proto_label "${proto}")" @@ -319,7 +325,8 @@ cmd_delete() { cmd_list 0 while true; do - answer=$(prompt_input '请输入要删除的规则编号' '') + prompt_input_capture '请输入要删除的规则编号' '' + answer=${PROMPT_INPUT_RESULT} if [[ ${answer} =~ ^[0-9]+$ ]] && (( answer >= 1 && answer <= ${#RULES_CACHE[@]} )); then index=$((answer - 1)) break diff --git a/lib/storage.sh b/lib/storage.sh index 4b0fe0d..8b19534 100644 --- a/lib/storage.sh +++ b/lib/storage.sh @@ -127,7 +127,7 @@ storage_count() { local count=0 line while IFS= read -r line || [[ -n ${line} ]]; do [[ -n ${line} ]] || continue - ((count++)) + ((count += 1)) done < <(storage_list) printf '%s\n' "${count}" } diff --git a/tests/run_all.sh b/tests/run_all.sh index 2b57fbd..21495f6 100644 --- a/tests/run_all.sh +++ b/tests/run_all.sh @@ -24,6 +24,7 @@ run_test() { run_test "${ROOT_DIR}/tests/test_common.sh" run_test "${ROOT_DIR}/tests/test_cli.sh" +run_test "${ROOT_DIR}/tests/test_interactive.sh" run_test "${ROOT_DIR}/tests/test_storage.sh" run_test "${ROOT_DIR}/tests/test_env_check.sh" run_test "${ROOT_DIR}/tests/test_rules_unit.sh" diff --git a/tests/test_common.sh b/tests/test_common.sh index d225100..82221f5 100644 --- a/tests/test_common.sh +++ b/tests/test_common.sh @@ -15,6 +15,17 @@ status_of() { printf '%s\n' "${rc}" } +prompt_tmp=$(mktemp) +trap 'rm -f "${prompt_tmp}"' EXIT + +export IPF_TEST_INPUTS=$'first\nsecond\n' +prompt_input 'first prompt' '' >"${prompt_tmp}" +assert_eq 'first' "$(cat "${prompt_tmp}")" 'prompt_input should consume the first queued test input' +prompt_input 'second prompt' '' >"${prompt_tmp}" +assert_eq 'second' "$(cat "${prompt_tmp}")" 'prompt_input should consume subsequent queued test inputs' +assert_eq '' "${IPF_TEST_INPUTS}" 'prompt_input should drain queued test inputs in the current shell' +unset IPF_TEST_INPUTS + assert_status 0 "$(status_of validate_ipv4 '0.0.0.0')" 'validate_ipv4 should accept 0.0.0.0' assert_status 0 "$(status_of validate_ipv4 '192.168.1.1')" 'validate_ipv4 should accept private address' assert_status 0 "$(status_of validate_ipv4 '255.255.255.255')" 'validate_ipv4 should accept broadcast' diff --git a/tests/test_interactive.sh b/tests/test_interactive.sh new file mode 100644 index 0000000..8f74929 --- /dev/null +++ b/tests/test_interactive.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) +# shellcheck source=tests/lib/assert.sh +source "${ROOT_DIR}/tests/lib/assert.sh" + +maybe_enter_namespace() { + if (( EUID == 0 )); then + return 0 + fi + + if [[ ${IPF_IN_NAMESPACE:-0} == 1 ]]; then + return 0 + fi + + if command -v unshare >/dev/null 2>&1 && unshare -Urn true >/dev/null 2>&1; then + exec unshare -Urn env IPF_IN_NAMESPACE=1 bash "$0" + fi + + printf 'SKIP: 交互测试需要 root 或可用的 unshare。\n' + exit 0 +} + +maybe_enter_namespace + +TMP_DIR=$(mktemp -d) +trap 'rm -rf "${TMP_DIR}"' EXIT + +export IPF_STORAGE_DIR="${TMP_DIR}/storage" +export IPF_STORAGE_DB="${IPF_STORAGE_DIR}/rules.db" +export IPF_LOCK_FILE="${IPF_STORAGE_DIR}/.lock" +export IPTABLES_MOCK_DIR="${TMP_DIR}/iptables-mock" +export IPTABLES_MOCK_LOG="${TMP_DIR}/iptables.log" +export IPTABLES_BIN="${ROOT_DIR}/tests/mocks/iptables" +export IP6TABLES_BIN="${ROOT_DIR}/tests/mocks/ip6tables" +export IPTABLES_SAVE_BIN="${ROOT_DIR}/tests/mocks/iptables-save" +export IP6TABLES_SAVE_BIN="${ROOT_DIR}/tests/mocks/ip6tables-save" +export NETFILTER_PERSISTENT_BIN="${ROOT_DIR}/tests/mocks/persist-mock.sh" +export PERSIST_MOCK_LOG="${TMP_DIR}/persist.log" +export IPF_SKIP_ENV_CHECK=1 +export IPF_FORCE_PLAIN_UI=1 +export IPF_TEST_INPUTS=$'4\n2\n1\n8080\n1\n127.0.0.1\n80\ninteractive web\ny\n5\n1\n3\n1\ny\n0\n' + +output=$("${ROOT_DIR}/iptables-forward.sh" 2>&1) + +assert_contains "${output}" '== 环境状态 ==' 'interactive flow should open environment status view' +assert_contains "${output}" '规则总数: 0' 'environment status should show empty rule count before adding' +assert_contains "${output}" '== 添加新的转发规则 ==' 'interactive flow should enter add screen' +assert_contains "${output}" '描述: interactive web' 'interactive add summary should echo description' +assert_contains "${output}" '规则已保存到磁盘。' 'interactive flow should exercise menu save action' +assert_contains "${output}" 'interactive web' 'interactive list/delete flow should display the created description' +assert_contains "${output}" '规则已添加,UUID=' 'interactive flow should add one managed rule' +assert_contains "${output}" '已退出。' 'interactive flow should return cleanly from main menu' + +assert_eq '0' "$(wc -l < "${IPF_STORAGE_DB}")" 'interactive add/delete flow should leave storage empty' +assert_eq '0' "$(wc -l < "${IPTABLES_MOCK_DIR}/state.v4")" 'interactive add/delete flow should leave runtime mock state empty' +assert_eq '3' "$(grep -Ec ' -A ' "${IPTABLES_MOCK_LOG}")" 'interactive add flow should emit three IPv4 add commands' +assert_eq '3' "$(grep -Ec ' -D ' "${IPTABLES_MOCK_LOG}")" 'interactive delete should emit three delete commands' +assert_eq '3' "$(grep -Ec 'persist-mock\.sh save' "${PERSIST_MOCK_LOG}")" 'interactive add/menu-save/delete flow should persist three times' + +pass 'test_interactive.sh'