#!/bin/sh # # === AUTO-GENERATED METADATA (make metadata) === # Generated: 2026-11-01 06:41:48 # Terminal functions (single underscore): output shell commands # Combinator functions (double underscore): output single vpn-switch command # Batch-combinator functions (triple underscore): output multiple vpn-switch commands TERMINAL_FUNCTIONS="_comment1 _note1 _error1 _error2 _error3 _fail2 _log1 _log2 _log3 _wireguard_collect0 _openvpn_collect0 _export_fresh1 _dump_header1 _restore_prune2 _filter_write3 _bundle_pack2 _seal_write2 _incoming_remove1 _extract_unpack2 _attest_sign2 _lift1 _database_init0 _environment_init1 _environment_cache1 _lift3 _setenv2 _getenv1 _unsetenv1 _cat1 _batch2 _printenv0 _env_sync0 _version_sync0 _version0 _link2 _remove1 _status0 _help0 _help1 _help2 _complete0 _complete1 _complete2 _complete3 _complete4 _complete5 _complete6 _helpenv0 _helpenv2 _database_enumerate0 _system_inspect0 _logs_inspect0 _phases_inspect0 _summary_inspect0 _prologue_inspect0 _key_import_copy3 _key_collect_files2 _manifest_write2 _openpgp_record2 _openpgp_record3 _openpgp_prerequisites0 _openvpn_patch2 _openvpn_configure1 _openvpn_connect1 _openvpn_disconnect1 _openvpn_patch1 _openvpn_enumerate0 _openvpn_import1 _openvpn_list0 _openvpn_list1 _openvpn_add1 _openvpn_add3 _openvpn_link2 _openvpn_remove1 _openvpn_remove2 _openvpn_validate0 _openvpn_info1 _phases_copy0 _provenance_serial1 _provenance_file5 _provenance_import_copy2 _provenance_collect0 _provenance_list1 _session_list0 _session_show1 _session_remove1 _session_connect1 _session_save0 _session_save1 _session_clean0 _session_refresh0 _session_validate0 _session_enumerate0 _session_describe0 _session_create1 _interface_destroy1 _session_populate3 _logs_validate0 _logs_clean0 _phases_validate0 _database_validate0 _binaries_validate0 _network_validate0 _permissions_validate0 _environment_validate0 _install_validate0 _version_validate0 _sudo_validate0 _wireguard_patch2 _wireguard_configure1 _wireguard_connect1 _wireguard_disconnect1 _wireguard_enumerate0 _wireguard_import1 _wireguard_list0 _wireguard_list1 _wireguard_add1 _wireguard_add3 _wireguard_link2 _wireguard_remove1 _wireguard_remove2 _wireguard_validate0 _wireguard_patch1 _wireguard_info1" COMBINATOR_FUNCTIONS="__export3 __export2 __export_redacted2 __export_full2 __export_minimized2 __export_pair3 __dump_readable1 __archive_key_pinned0 __restore1 __restore2 __restore_v12 __restore_v13 __restore_v14 __restore_v15 __restore3 __restore_serial_admissible3 __restore_base_exists1 __restore_replay2 __filter2 __filter_default2 __filter3 __filter_redacted2 __filter_full2 __filter_minimized2 __collection_readable1 __archiver_set0 __bundle_archive_absolute1 __bundle_manifest_beside1 __bundle_collection_inside1 __bundle_readable1 __seal_absent1 __attest_absent1 __seal_present1 __seal_matches2 __incoming_clear1 __extractor_set0 __extract_destination_absolute1 __extract_destination_empty1 __import2 __import_bootstrap2 __import3 __import4 __import5 __import6 __attest_file_exists1 __openpgp_record_complete1 __attest_signer_pinned2 __bootstrap1 __environment_cache0 __phases_lift1 __init0 __dump0 __batch1 __import1 __helpenv1 __setintp2 __getintp1 __unsetintp1 __helpintp1 __helpintp2 __key_name_valid1 __base_element_exists1 __key_record_exists2 __manifest_named1 __manifest_entries_hashable1 __manifest_readable1 __manifest_base_exists1 __manifest_tree_matches2 __openpgp_keyid_valid1 __openpgp_import2 __openpgp_collect1 __openvpn_start0 __openvpn_start1 __openvpn_stop1 __openvpn_add2 __openvpn_help0 __provenance_serial0 __provenance_add2 __provenance_add3 __provenance_add4 __provenance_add5 __provenance_add6 __provenance_bundle_readable1 __provenance_receipt5 __provenance_receipt_same3 __provenance_id_valid1 __provenance_import_place2 __provenance_list0 __session_disconnect1 __session_stop1 __session_show0 __session_start0 __session_start1 __session_help0 __session_exists1 __session_conflicts2 __interface_check1 __stop1 __start1 __start0 __wireguard_start0 __wireguard_start1 __wireguard_stop1 __wireguard_add2 __wireguard_help0" BATCH_COMBINATOR_FUNCTIONS="___collect0 ___export_pair4 ___restore_v16 ___bundle2 ___seal2 ___seal_verify2 ___extract2 ___import_bootstrap5 ___import_bootstrap6 ___import7 ___attest2 ___attest_verify2 ___bootstrap2 ___environment_refresh0 ___init1 ___dumpenv1 ___dump1 ___batch0 ___sync0 ___list0 ___environment_inspect0 ___inspect0 ___key_import3 ___manifest2 ___manifest_attest2 ___manifest_verify3 ___manifest_match2 ___openpgp_add2 ___openpgp_add3 ___openpgp_dump0 ___openpgp_collect0 ___openvpn_create1 ___openvpn_stop0 ___openvpn_inspect0 ___openvpn_clean0 ___openvpn_dump0 ___phases_sync1 ___phases_sync0 ___provenance_add7 ___provenance_import2 ___provenance_dump0 ___session_stop0 ___session_switch1 ___session_switch2 ___session_dump0 ___session_inspect0 ___session_prepare3 ___stop0 ___validate0 ___wireguard_create1 ___wireguard_inspect0 ___wireguard_stop0 ___wireguard_clean0 ___wireguard_clean1 ___wireguard_dump0" ANCHOR_FUNCTIONS="_comment1 _note1 _error1 _error2 _error3 _fail2 _log1 _log2 _log3 _wireguard_collect0 _openvpn_collect0 _export_fresh1 _dump_header1 _restore_prune2 _filter_write3 _bundle_pack2 _seal_write2 _incoming_remove1 _extract_unpack2 _attest_sign2 _lift1 _database_init0 _environment_init1 _environment_cache1 _lift3 _setenv2 _getenv1 _unsetenv1 _cat1 _batch2 _printenv0 _env_sync0 _version_sync0 _version0 _link2 _remove1 _status0 _help0 _help1 _help2 _complete0 _complete1 _complete2 _complete3 _complete4 _complete5 _complete6 _helpenv0 _helpenv2 _database_enumerate0 _system_inspect0 _logs_inspect0 _phases_inspect0 _summary_inspect0 _prologue_inspect0 _key_import_copy3 _key_collect_files2 _manifest_write2 _openpgp_record2 _openpgp_record3 _openpgp_prerequisites0 _openvpn_patch2 _openvpn_configure1 _openvpn_connect1 _openvpn_disconnect1 _openvpn_patch1 _openvpn_enumerate0 _openvpn_import1 _openvpn_list0 _openvpn_list1 _openvpn_add1 _openvpn_add3 _openvpn_link2 _openvpn_remove1 _openvpn_remove2 _openvpn_validate0 _openvpn_info1 _phases_copy0 _provenance_serial1 _provenance_file5 _provenance_import_copy2 _provenance_collect0 _provenance_list1 _session_list0 _session_show1 _session_remove1 _session_connect1 _session_save0 _session_save1 _session_clean0 _session_refresh0 _session_validate0 _session_enumerate0 _session_describe0 _session_create1 _interface_destroy1 _session_populate3 _logs_validate0 _logs_clean0 _phases_validate0 _database_validate0 _binaries_validate0 _network_validate0 _permissions_validate0 _environment_validate0 _install_validate0 _version_validate0 _sudo_validate0 _wireguard_patch2 _wireguard_configure1 _wireguard_connect1 _wireguard_disconnect1 _wireguard_enumerate0 _wireguard_import1 _wireguard_list0 _wireguard_list1 _wireguard_add1 _wireguard_add3 _wireguard_link2 _wireguard_remove1 _wireguard_remove2 _wireguard_validate0 _wireguard_patch1 _wireguard_info1 __export3 __export2 __export_redacted2 __export_full2 __export_minimized2 __export_pair3 __dump_readable1 __archive_key_pinned0 __restore1 __restore2 __restore_v12 __restore_v13 __restore_v14 __restore_v15 __restore3 __restore_serial_admissible3 __restore_base_exists1 __restore_replay2 __filter2 __filter_default2 __filter3 __filter_redacted2 __filter_full2 __filter_minimized2 __collection_readable1 __archiver_set0 __bundle_archive_absolute1 __bundle_manifest_beside1 __bundle_collection_inside1 __bundle_readable1 __seal_absent1 __attest_absent1 __seal_present1 __seal_matches2 __incoming_clear1 __extractor_set0 __extract_destination_absolute1 __extract_destination_empty1 __import2 __import_bootstrap2 __import3 __import4 __import5 __import6 __attest_file_exists1 __openpgp_record_complete1 __attest_signer_pinned2 __bootstrap1 __environment_cache0 __phases_lift1 __init0 __dump0 __batch1 __import1 __helpenv1 __setintp2 __getintp1 __unsetintp1 __helpintp1 __helpintp2 __key_name_valid1 __base_element_exists1 __key_record_exists2 __manifest_named1 __manifest_entries_hashable1 __manifest_readable1 __manifest_base_exists1 __manifest_tree_matches2 __openpgp_keyid_valid1 __openpgp_import2 __openpgp_collect1 __openvpn_start0 __openvpn_start1 __openvpn_stop1 __openvpn_add2 __openvpn_help0 __provenance_serial0 __provenance_add2 __provenance_add3 __provenance_add4 __provenance_add5 __provenance_add6 __provenance_bundle_readable1 __provenance_receipt5 __provenance_receipt_same3 __provenance_id_valid1 __provenance_import_place2 __provenance_list0 __session_disconnect1 __session_stop1 __session_show0 __session_start0 __session_start1 __session_help0 __session_exists1 __session_conflicts2 __interface_check1 __stop1 __start1 __start0 __wireguard_start0 __wireguard_start1 __wireguard_stop1 __wireguard_add2 __wireguard_help0 ___collect0 ___export_pair4 ___restore_v16 ___bundle2 ___seal2 ___seal_verify2 ___extract2 ___import_bootstrap5 ___import_bootstrap6 ___import7 ___attest2 ___attest_verify2 ___bootstrap2 ___environment_refresh0 ___init1 ___dumpenv1 ___dump1 ___batch0 ___sync0 ___list0 ___environment_inspect0 ___inspect0 ___key_import3 ___manifest2 ___manifest_attest2 ___manifest_verify3 ___manifest_match2 ___openpgp_add2 ___openpgp_add3 ___openpgp_dump0 ___openpgp_collect0 ___openvpn_create1 ___openvpn_stop0 ___openvpn_inspect0 ___openvpn_clean0 ___openvpn_dump0 ___phases_sync1 ___phases_sync0 ___provenance_add7 ___provenance_import2 ___provenance_dump0 ___session_stop0 ___session_switch1 ___session_switch2 ___session_dump0 ___session_inspect0 ___session_prepare3 ___stop0 ___validate0 ___wireguard_create1 ___wireguard_inspect0 ___wireguard_stop0 ___wireguard_clean0 ___wireguard_clean1 ___wireguard_dump0" # Function-to-module mapping (format: "func:module.sh func:module.sh ...") # Used by process_arguments() for deterministic module loading FUNCTION_MODULES="___collect0:archive.sh _wireguard_collect0:archive.sh _openvpn_collect0:archive.sh __export3:archive.sh __export2:archive.sh __export_redacted2:archive.sh __export_full2:archive.sh __export_minimized2:archive.sh __export_pair3:archive.sh _export_fresh1:archive.sh ___export_pair4:archive.sh _dump_header1:archive.sh __dump_readable1:archive.sh __archive_key_pinned0:archive.sh __restore1:archive.sh __restore2:archive.sh __restore_v12:archive.sh __restore_v13:archive.sh __restore_v14:archive.sh __restore_v15:archive.sh ___restore_v16:archive.sh _restore_prune2:archive.sh __restore3:archive.sh __restore_serial_admissible3:archive.sh __restore_base_exists1:archive.sh __restore_replay2:archive.sh __filter2:archive.sh __filter_default2:archive.sh __filter3:archive.sh __filter_redacted2:archive.sh __filter_full2:archive.sh __filter_minimized2:archive.sh _filter_write3:archive.sh ___bundle2:archive.sh __collection_readable1:archive.sh __archiver_set0:archive.sh __bundle_archive_absolute1:archive.sh __bundle_manifest_beside1:archive.sh __bundle_collection_inside1:archive.sh _bundle_pack2:archive.sh ___seal2:archive.sh __bundle_readable1:archive.sh __seal_absent1:archive.sh __attest_absent1:archive.sh _seal_write2:archive.sh ___seal_verify2:archive.sh __seal_present1:archive.sh __seal_matches2:archive.sh __incoming_clear1:archive.sh _incoming_remove1:archive.sh ___extract2:archive.sh __extractor_set0:archive.sh __extract_destination_absolute1:archive.sh __extract_destination_empty1:archive.sh _extract_unpack2:archive.sh __import2:archive.sh __import_bootstrap2:archive.sh ___import_bootstrap5:archive.sh ___import_bootstrap6:archive.sh __import3:archive.sh __import4:archive.sh __import5:archive.sh __import6:archive.sh ___import7:archive.sh ___attest2:attest.sh __attest_file_exists1:attest.sh __openpgp_record_complete1:attest.sh _attest_sign2:attest.sh ___attest_verify2:attest.sh __attest_signer_pinned2:attest.sh __bootstrap1:database.sh ___bootstrap2:database.sh _lift1:database.sh _database_init0:database.sh _environment_init1:database.sh ___environment_refresh0:database.sh __environment_cache0:database.sh _environment_cache1:database.sh _lift3:database.sh __phases_lift1:database.sh ___init1:database.sh __init0:database.sh _setenv2:database.sh _getenv1:database.sh _unsetenv1:database.sh ___dumpenv1:database.sh __dump0:database.sh ___dump1:database.sh _cat1:database.sh ___batch0:database.sh __batch1:database.sh _batch2:database.sh _printenv0:database.sh ___sync0:database.sh _env_sync0:database.sh _version_sync0:database.sh _version0:database.sh __import1:database.sh ___list0:database.sh _link2:database.sh _remove1:database.sh _status0:help.sh _help0:help.sh _help1:help.sh _help2:help.sh _complete0:help.sh _complete1:help.sh _complete2:help.sh _complete3:help.sh _complete4:help.sh _complete5:help.sh _complete6:help.sh _helpenv0:helpenv.sh __helpenv1:helpenv.sh _helpenv2:helpenv.sh __setintp2:helpenv.sh __getintp1:helpenv.sh __unsetintp1:helpenv.sh __helpintp1:helpenv.sh __helpintp2:helpenv.sh _database_enumerate0:inspect.sh ___environment_inspect0:inspect.sh _system_inspect0:inspect.sh _logs_inspect0:inspect.sh _phases_inspect0:inspect.sh _summary_inspect0:inspect.sh _prologue_inspect0:inspect.sh ___inspect0:inspect.sh __key_name_valid1:keys.sh ___key_import3:keys.sh __base_element_exists1:keys.sh _key_import_copy3:keys.sh _key_collect_files2:keys.sh __key_record_exists2:keys.sh ___manifest2:manifest.sh __manifest_named1:manifest.sh __manifest_entries_hashable1:manifest.sh _manifest_write2:manifest.sh ___manifest_attest2:manifest.sh ___manifest_verify3:manifest.sh ___manifest_match2:manifest.sh __manifest_readable1:manifest.sh __manifest_base_exists1:manifest.sh __manifest_tree_matches2:manifest.sh ___openpgp_add2:openpgp.sh ___openpgp_add3:openpgp.sh __openpgp_keyid_valid1:openpgp.sh _openpgp_record2:openpgp.sh _openpgp_record3:openpgp.sh _openpgp_prerequisites0:openpgp.sh ___openpgp_dump0:openpgp.sh __openpgp_import2:openpgp.sh ___openpgp_collect0:openpgp.sh __openpgp_collect1:openpgp.sh __openvpn_start0:openvpn.sh __openvpn_start1:openvpn.sh ___openvpn_create1:openvpn.sh _openvpn_patch2:openvpn.sh _openvpn_configure1:openvpn.sh _openvpn_connect1:openvpn.sh ___openvpn_stop0:openvpn.sh __openvpn_stop1:openvpn.sh _openvpn_disconnect1:openvpn.sh _openvpn_patch1:openvpn.sh ___openvpn_inspect0:openvpn.sh _openvpn_enumerate0:openvpn.sh _openvpn_import1:openvpn.sh _openvpn_list0:openvpn.sh _openvpn_list1:openvpn.sh _openvpn_add1:openvpn.sh __openvpn_add2:openvpn.sh _openvpn_add3:openvpn.sh _openvpn_link2:openvpn.sh _openvpn_remove1:openvpn.sh _openvpn_remove2:openvpn.sh _openvpn_validate0:openvpn.sh ___openvpn_clean0:openvpn.sh _openvpn_info1:openvpn.sh ___openvpn_dump0:openvpn.sh __openvpn_help0:openvpn.sh _phases_copy0:phase.sh ___phases_sync1:phase.sh ___phases_sync0:phase.sh __provenance_serial0:provenance.sh _provenance_serial1:provenance.sh __provenance_add2:provenance.sh __provenance_add3:provenance.sh __provenance_add4:provenance.sh __provenance_add5:provenance.sh __provenance_add6:provenance.sh ___provenance_add7:provenance.sh __provenance_bundle_readable1:provenance.sh __provenance_receipt5:provenance.sh __provenance_receipt_same3:provenance.sh _provenance_file5:provenance.sh ___provenance_import2:provenance.sh __provenance_id_valid1:provenance.sh __provenance_import_place2:provenance.sh _provenance_import_copy2:provenance.sh ___provenance_dump0:provenance.sh _provenance_collect0:provenance.sh __provenance_list0:provenance.sh _provenance_list1:provenance.sh __session_disconnect1:session.sh ___session_stop0:session.sh __session_stop1:session.sh _session_list0:session.sh __session_show0:session.sh _session_show1:session.sh _session_remove1:session.sh __session_start0:session.sh __session_start1:session.sh ___session_switch1:session.sh ___session_switch2:session.sh _session_connect1:session.sh _session_save0:session.sh _session_save1:session.sh _session_clean0:session.sh _session_refresh0:session.sh _session_validate0:session.sh ___session_dump0:session.sh __session_help0:session.sh ___session_inspect0:session.sh _session_enumerate0:session.sh _session_describe0:session.sh _session_create1:session.sh __session_exists1:session.sh __session_conflicts2:session.sh _interface_destroy1:session.sh __interface_check1:session.sh ___session_prepare3:session.sh _session_populate3:session.sh ___stop0:session.sh __stop1:session.sh __start1:session.sh __start0:session.sh _logs_validate0:validate.sh _logs_clean0:validate.sh _phases_validate0:validate.sh _database_validate0:validate.sh _binaries_validate0:validate.sh _network_validate0:validate.sh _permissions_validate0:validate.sh _environment_validate0:validate.sh _install_validate0:validate.sh _version_validate0:validate.sh _sudo_validate0:validate.sh ___validate0:validate.sh __wireguard_start0:wireguard.sh __wireguard_start1:wireguard.sh ___wireguard_create1:wireguard.sh _wireguard_patch2:wireguard.sh _wireguard_configure1:wireguard.sh _wireguard_connect1:wireguard.sh _wireguard_disconnect1:wireguard.sh ___wireguard_inspect0:wireguard.sh _wireguard_enumerate0:wireguard.sh ___wireguard_stop0:wireguard.sh __wireguard_stop1:wireguard.sh _wireguard_import1:wireguard.sh _wireguard_list0:wireguard.sh _wireguard_list1:wireguard.sh _wireguard_add1:wireguard.sh __wireguard_add2:wireguard.sh _wireguard_add3:wireguard.sh _wireguard_link2:wireguard.sh _wireguard_remove1:wireguard.sh _wireguard_remove2:wireguard.sh _wireguard_validate0:wireguard.sh ___wireguard_clean0:wireguard.sh ___wireguard_clean1:wireguard.sh _wireguard_patch1:wireguard.sh _wireguard_info1:wireguard.sh ___wireguard_dump0:wireguard.sh __wireguard_help0:wireguard.sh" # === END AUTO-GENERATED === # vpn-switch Architecture Test Suite # # PURPOSE: Enforce combinator pattern architectural rules # # ARCHITECTURAL RULES TESTED: # # 1. COMBINATOR FUNCTIONS (__func): # - Output exactly 1 line # - Line must contain "$VPN_SWITCH_CONTEXT_SCRIPT" (literal, not expanded) # - Line format: "$VPN_SWITCH_CONTEXT_SCRIPT" command args... # # 2. BATCH COMBINATOR FUNCTIONS (___func): # - Output N lines (N >= 0) # - Each non-empty, non-comment line must be a vpn-switch command # - Use literal "$VPN_SWITCH_CONTEXT_SCRIPT" (not expanded paths) # - Comments (#) and empty lines are allowed # # 3. TERMINAL FUNCTIONS (_func): # - Must NOT output vpn-switch commands # - Output shell commands only # - Can call: shell keywords, builtins, system commands, locally-defined functions # - Cannot call: vpn-switch internal functions (starting with _) unless locally defined # - Cannot call: error(), log(), run_env(), dispatch(), lookup_interpreter() # - Must NOT use "$VPN_SWITCH_CONTEXT_SCRIPT" (reserved for combinators) # # For new contributors/Claudes: Run these tests to verify your # combinator functions follow the architectural pattern. # # Usage: ./vpn-switch-architecture-test.sh [profile] [keep] [test ...] # profile: minimal (default) or all # keep: true/keep to preserve databases on success (default: delete) # test ...: optional test-function names to run only those (same mechanism as # vpn-switch-unit-test.sh). Note profile+keep must be given first, # e.g. ./vpn-switch-architecture-test.sh minimal false test_readonly_database # # POSIX shell compliant # # ============================================================================ # TEST DATABASE SPECIFICATION # ============================================================================ # # PURPOSE: # Architecture tests need to exercise ALL code paths, including error handling # and edge cases. Real databases are messy - sessions go stale, links break, # processes die, users interrupt operations. A "realistic but messy" database # state ensures unlikely branches get tested. # # PHILOSOPHY: # "Test with the chaos users create, not the perfection we assume" # # WHY THIS APPROACH: # 1. REALISTIC - Mirrors actual database states in production use # 2. COMPREHENSIVE - Exercises both success and error paths # 3. GENERIC - No function-specific knowledge required # 4. DISCOVERABLE - Reveals architectural violations in error handling # 5. MAINTAINABLE - Single specification for all tests # 6. DETERMINISTIC - Reproducible failures (with optional randomization) # # MAINTENANCE: # Update this specification when: # - New features add database structures (e.g., new protocol directories) # - New edge cases are discovered (add them as fixtures) # - Tests reveal blind spots in coverage (add problematic states) # # ============================================================================ # # DATABASE STRUCTURE (created by create_messy_realistic_database()) # # $TEST_DIR/ # .session/ # Active/stale sessions directory # 88888/ # VALID: Complete session (old PID) # protocol = "wireguard" # interface = "wg_test0" # Use test interface to avoid conflict # original = "$TEST_DIR/wireguard/working.conf" # started = "2024-01-15 10:30:00" # connect.sh = executable script (0600) # disconnect.sh = executable script (0600) # # 99999/ # STALE: Session with dead process # protocol = "openvpn" # interface = "tun_test0" # Use test interface to avoid conflict # original = "$TEST_DIR/openvpn/stale.ovpn" # started = "2024-01-10 08:00:00" # (missing connect.sh - incomplete session) # # 77777/ # PARTIAL: Missing metadata files # protocol = "wireguard" # (missing interface, original, started) # # session/ # Named sessions directory # default → ../.session/88888 # VALID symlink # work → ../.session/99999 # STALE symlink (dead process) # broken → ../.session/11111 # ORPHANED (target doesn't exist) # home → ../.session/88888 # DUPLICATE (same as default) # # wireguard/ # WireGuard configs # working.conf # VALID config file (0400) # broken.conf → nonexistent.conf # BROKEN symlink # duplicate.conf → working.conf # DUPLICATE (points to existing) # # privacy/ # Category directory # server1 → ../working.conf # Valid category link # server2 → ../broken.conf # Broken category link # orphan → ../gone.conf # Orphaned link # # openvpn/ # OpenVPN configs # stale.ovpn # VALID config file (0400) # invalid.ovpn → missing.ovpn # BROKEN symlink # # streaming/ # Category directory (empty) # # .conf → wireguard # Protocol extension link (valid) # .ovpn → openvpn # Protocol extension link (valid) # .broken → nowhere # BROKEN extension link # # ADVERSARIAL NAMES (test command parser robustness): # wireguard/ # help.conf # Conflicts with command name # start.conf # Conflicts with command name # --version.conf # Looks like CLI flag # -rf.conf # Dangerous if mishandled # # openvpn/ # list.ovpn # Conflicts with command name # ../escape.ovpn → stale.ovpn # Path traversal attempt (symlink) # # session/ # stop → ../.session/88888 # Command name as session name # dump → ../.session/99999 # Another command conflict # # WHY ADVERSARIAL NAMES MATTER: # - Tests dispatch resolution doesn't confuse "wireguard start help" with help command # - Ensures path normalization prevents traversal (../ handled correctly) # - Verifies special characters don't break shell command generation # - Proves the system is robust against confusing/malicious user input # - Architecture tests are PERFECT place for this (end-to-end command flow) # # RANDOMIZATION POINTS (50% probability each): # - Whether default symlink exists in session/ # - Whether additional named sessions exist # - Which session has a complete connect.sh script # # INVARIANTS (always true): # - At least one valid session exists (88888) # - At least one stale/broken state exists (99999, broken links) # - Both protocols have configs (wireguard and openvpn) # - Mix of valid and broken states for comprehensive coverage # # BENEFITS FOR TESTING: # - session_start* can find valid default OR encounter stale links # - session_save* can find existing sessions to save # - session_remove* encounter both valid and invalid targets # - session_show* test with missing/present connect.sh # - session_list* display mixed valid/stale/orphaned states # - validate* find broken links and stale sessions # - clean* have actual broken links to clean # - Protocol functions encounter both valid and broken configs # - import* test with existing vs. conflicting names # - All functions exercise error handling for broken states # # COVERAGE IMPROVEMENT: # Before: Functions hit early error exits (dead code not tested) # After: Functions execute full logic with both valid and broken inputs # Result: Architectural violations in ANY code path get detected # # ============================================================================ set -e set -u #----------------------------------------------------------------------------- # Test Configuration #----------------------------------------------------------------------------- # Test base directory TEST_BASE_DIR="${TMPDIR:-/tmp}/vpn-switch-arch-test.$$" # Test script location TEST_SCRIPT="./vpn-switch.sh" # Profile for bootstrap # Options (before the positional args): --maxprocs N runs the top-level # test functions in parallel (xargs -P N, each in its own suite process and # sandbox); default 1 = the classic sequential run. (Ported from elebake.) MAXPROCS=1 while [ $# -gt 0 ]; do case "$1" in --maxprocs) MAXPROCS="${2:?--maxprocs needs a value}"; shift 2 ;; --maxprocs=*) MAXPROCS="${1#--maxprocs=}"; shift ;; *) break ;; esac done TEST_PROFILE="${1:-minimal}" # Keep databases on success (default: false = delete on success) KEEP_DATABASES="${2:-false}" # Optional test-name filters (args 3+), same mechanism as vpn-switch-unit-test.sh. # Profile ($1) and keep ($2) are already captured above; drop them, then treat # the rest as test-name filters. Safe shift under 'set -u'. [ $# -ge 1 ] && shift # drop profile [ $# -ge 1 ] && shift # drop keep # Dynamically generate list of all test functions ALL_TESTS=$(grep -o '^test_[a-z_]*()' "$0" | sed 's/()$//' | tr '\n' ' ') # Collect test filter arguments (optional test function names) TEST_FILTER="$*" # If no filter specified, run all tests if [ -z "$TEST_FILTER" ]; then TEST_FILTER="$ALL_TESTS" fi #----------------------------------------------------------------------------- # Test Framework Infrastructure (copied from vpn-switch-unit-test.sh) #----------------------------------------------------------------------------- # should_run_test - Run a named test only if it is in the active filter # (identical to vpn-switch-unit-test.sh) should_run_test() { local test_name="$1" for filter_test in $TEST_FILTER; do if [ "$filter_test" = "$test_name" ]; then "$test_name" return 0 fi done return 0 } # Test counters TESTS_RUN=0 TESTS_PASSED=0 TESTS_FAILED=0 # Color support (detect if terminal supports colors) if [ -t 1 ] && command -v tput >/dev/null 2>&1 && tput colors >/dev/null 2>&1; then COLOR_GREEN=$(tput setaf 2) COLOR_RED=$(tput setaf 1) COLOR_YELLOW=$(tput setaf 3) COLOR_BLUE=$(tput setaf 4) COLOR_RESET=$(tput sgr0) else COLOR_GREEN="" COLOR_RED="" COLOR_YELLOW="" COLOR_BLUE="" COLOR_RESET="" fi # Global test directory TEST_DIR="" # pass - Mark assertion as passed pass() { TESTS_PASSED=$((TESTS_PASSED + 1)) echo "${COLOR_GREEN}✓${COLOR_RESET} $*" } # fail - Mark assertion as failed fail() { TESTS_FAILED=$((TESTS_FAILED + 1)) echo "${COLOR_RED}✗${COLOR_RESET} $*" } # test_header - Print test header with database path test_header() { TESTS_RUN=$((TESTS_RUN + 1)) echo "" if [ -n "${TEST_DIR:-}" ]; then echo "${COLOR_BLUE}TEST ${TESTS_RUN}:${COLOR_RESET} $* [db: $TEST_DIR]" else echo "${COLOR_BLUE}TEST ${TESTS_RUN}:${COLOR_RESET} $*" fi } # test_summary - Print test summary test_summary() { local test_functions=$TESTS_RUN local assertions_passed=$TESTS_PASSED local assertions_failed=$TESTS_FAILED local total_assertions=$((assertions_passed + assertions_failed)) echo "" echo "========================================" echo "Architecture Test Summary" echo "========================================" echo "Test Functions: $test_functions" echo "Total Assertions: $total_assertions" echo "${COLOR_GREEN}Passed Assertions: $assertions_passed${COLOR_RESET}" if [ "$assertions_failed" -gt 0 ]; then echo "${COLOR_RED}Failed Assertions: $assertions_failed${COLOR_RESET}" else echo "Failed Assertions: $assertions_failed" fi echo "" if [ "$assertions_failed" -gt 0 ]; then echo "${COLOR_RED}SOME TESTS FAILED${COLOR_RESET}" echo "" echo "Test artifacts preserved in: $TEST_BASE_DIR" echo "To clean up: rm -rf $TEST_BASE_DIR" return 1 else echo "${COLOR_GREEN}ALL TESTS PASSED${COLOR_RESET}" echo "" if [ "$KEEP_DATABASES" = "true" ] || [ "$KEEP_DATABASES" = "keep" ]; then echo "Test databases preserved in: $TEST_BASE_DIR" else echo "Cleaning up test databases..." rm -rf "$TEST_BASE_DIR" echo "Done." fi return 0 fi } #----------------------------------------------------------------------------- # Test Setup (simplified from vpn-switch-unit-test.sh) #----------------------------------------------------------------------------- # test_setup - Create exclusive test database # # Creates a fresh database for each test using vpn-switch bootstrap. # Sets TEST_DIR for use by test functions. # test_setup() { # Create unique test database directory TEST_DIR="$TEST_BASE_DIR/test-$TESTS_RUN" mkdir -p "$TEST_DIR" # Bootstrap database using vpn-switch API local bootstrap_log="$TEST_DIR/bootstrap.log" if ! VPN_SWITCH_BASE="$TEST_DIR" "$TEST_SCRIPT" bootstrap "$TEST_DIR" "$TEST_PROFILE" > "$bootstrap_log" 2>&1; then echo "${COLOR_RED}ERROR:${COLOR_RESET} Database bootstrap failed" cat "$bootstrap_log" | sed 's/^/ /' exit 1 fi # Set terminal interpreter to sh for auto-execution (simulates experienced user) VPN_SWITCH_BASE="$TEST_DIR" "$TEST_SCRIPT" setenv VPN_SWITCH_TERMINAL_INTERPRETER sh > /dev/null 2>&1 # Set minimal PATH VPN_SWITCH_BASE="$TEST_DIR" "$TEST_SCRIPT" setenv VPN_SWITCH_PATH "/bin:/usr/bin:/usr/local/bin" > /dev/null 2>&1 } # create_messy_realistic_database - Populate database with realistic messy state # # Creates a database state that mirrors real-world usage with: # - Valid and stale sessions # - Broken symlinks # - Orphaned references # - Adversarial names (command conflicts, path traversal attempts) # - Incomplete/partial data structures # # This ensures tests exercise BOTH success and error paths, revealing # architectural violations in error handling code that would otherwise # be untested "dead code". # # See TEST DATABASE SPECIFICATION comment at top of file for full details. # create_messy_realistic_database() { # ===== SESSIONS ===== # Valid session (88888) - complete with all metadata (use test interface to avoid conflict) mkdir -p "$TEST_DIR/.session/88888" echo "wireguard" > "$TEST_DIR/.session/88888/protocol" echo "wg_test0" > "$TEST_DIR/.session/88888/interface" echo "$TEST_DIR/wireguard/working.conf" > "$TEST_DIR/.session/88888/original" echo "2024-01-15 10:30:00" > "$TEST_DIR/.session/88888/started" # Create minimal connect.sh script cat > "$TEST_DIR/.session/88888/connect.sh" <<'EOF' #!/bin/sh # Mock connect script for testing echo "Mock VPN connection for session 88888" EOF chmod 0600 "$TEST_DIR/.session/88888/connect.sh" # Create minimal disconnect.sh script cat > "$TEST_DIR/.session/88888/disconnect.sh" <<'EOF' #!/bin/sh # Mock disconnect script for testing echo "Mock VPN disconnection for session 88888" EOF chmod 0600 "$TEST_DIR/.session/88888/disconnect.sh" # Stale session (99999) - missing connect.sh (incomplete) mkdir -p "$TEST_DIR/.session/99999" echo "openvpn" > "$TEST_DIR/.session/99999/protocol" echo "tun_test0" > "$TEST_DIR/.session/99999/interface" echo "$TEST_DIR/openvpn/stale.ovpn" > "$TEST_DIR/.session/99999/original" echo "2024-01-10 08:00:00" > "$TEST_DIR/.session/99999/started" # Note: No connect.sh - simulates interrupted/incomplete session # Partial session (77777) - missing most metadata mkdir -p "$TEST_DIR/.session/77777" echo "wireguard" > "$TEST_DIR/.session/77777/protocol" # Missing: interface, original, started # ===== NAMED SESSIONS ===== mkdir -p "$TEST_DIR/session" # Deterministic: Always create default symlink (most common case) ln -s "../.session/88888" "$TEST_DIR/session/default" # Deterministic: Always create additional named sessions for maximum coverage # (Randomization commented out - was causing POSIX compatibility issues) # Original intent: 50% chance, but deterministic is better for reproducibility ln -s "../.session/99999" "$TEST_DIR/session/work" # Stale ln -s "../.session/88888" "$TEST_DIR/session/home" # Duplicate # Broken/orphaned links (always present for error path testing) ln -s "../.session/11111" "$TEST_DIR/session/broken" # Target doesn't exist # Adversarial: Command names as session names ln -s "../.session/88888" "$TEST_DIR/session/stop" ln -s "../.session/99999" "$TEST_DIR/session/dump" # ===== WIREGUARD CONFIGS ===== # Valid working config cat > "$TEST_DIR/wireguard/working.conf" <<'EOF' [Interface] PrivateKey = fake-test-key-for-architecture-tests Address = 10.0.0.1/32 [Peer] PublicKey = fake-peer-key-for-testing Endpoint = 192.0.2.1:51820 AllowedIPs = 0.0.0.0/0 EOF chmod 0400 "$TEST_DIR/wireguard/working.conf" # Broken symlink ln -s "nonexistent.conf" "$TEST_DIR/wireguard/broken.conf" # Duplicate symlink ln -s "working.conf" "$TEST_DIR/wireguard/duplicate.conf" # Adversarial: Command names and dangerous patterns cat > "$TEST_DIR/wireguard/help.conf" <<'EOF' [Interface] PrivateKey = fake-key-help-command-conflict Address = 10.0.0.2/32 EOF chmod 0400 "$TEST_DIR/wireguard/help.conf" cat > "$TEST_DIR/wireguard/start.conf" <<'EOF' [Interface] PrivateKey = fake-key-start-command-conflict Address = 10.0.0.3/32 EOF chmod 0400 "$TEST_DIR/wireguard/start.conf" # Dangerous-looking names (ensure these don't cause issues) cat > "$TEST_DIR/wireguard/--version.conf" <<'EOF' [Interface] PrivateKey = fake-key-flag-lookalike Address = 10.0.0.4/32 EOF chmod 0400 "$TEST_DIR/wireguard/--version.conf" cat > "$TEST_DIR/wireguard/-rf.conf" <<'EOF' [Interface] PrivateKey = fake-key-dangerous-flag Address = 10.0.0.5/32 EOF chmod 0400 "$TEST_DIR/wireguard/-rf.conf" # Category with mixed valid/broken links mkdir -p "$TEST_DIR/wireguard/privacy" ln -s "../working.conf" "$TEST_DIR/wireguard/privacy/server1" # Valid ln -s "../broken.conf" "$TEST_DIR/wireguard/privacy/server2" # Broken ln -s "../gone.conf" "$TEST_DIR/wireguard/privacy/orphan" # Orphaned # ===== OPENVPN CONFIGS ===== # Valid stale config cat > "$TEST_DIR/openvpn/stale.ovpn" <<'EOF' remote 192.0.2.10 1194 udp dev tun # Mock OpenVPN config for testing EOF chmod 0400 "$TEST_DIR/openvpn/stale.ovpn" # Broken symlink ln -s "missing.ovpn" "$TEST_DIR/openvpn/invalid.ovpn" # Adversarial: Command name cat > "$TEST_DIR/openvpn/list.ovpn" <<'EOF' remote 192.0.2.11 1194 udp dev tun EOF chmod 0400 "$TEST_DIR/openvpn/list.ovpn" # Path traversal attempt (symlink with ../) ln -s "../openvpn/stale.ovpn" "$TEST_DIR/openvpn/../escape.ovpn" 2>/dev/null || true # Empty category mkdir -p "$TEST_DIR/openvpn/streaming" # ===== PROTOCOL EXTENSION LINKS ===== # Valid extension links (these exist from bootstrap, recreate for clarity) ln -sf "wireguard" "$TEST_DIR/.conf" ln -sf "openvpn" "$TEST_DIR/.ovpn" # Broken extension link ln -s "nowhere" "$TEST_DIR/.broken" #============================================================================= # CORRUPTION PATTERNS - Realistic Filesystem Anomalies #============================================================================= # ===== 1. BROKEN SYMLINKS ===== # WireGuard broken links ln -s /nonexistent "$TEST_DIR/wireguard/broken-absolute.conf" ln -s ../missing/file.conf "$TEST_DIR/wireguard/broken-relative.conf" # OpenVPN broken links ln -s /nonexistent "$TEST_DIR/openvpn/broken-absolute.ovpn" ln -s ../missing/file.ovpn "$TEST_DIR/openvpn/broken-relative.ovpn" # ===== 2. DIRECTORIES WITH CONFIG EXTENSIONS ===== # Confuses file type checks mkdir "$TEST_DIR/wireguard/looks-like-config.conf" mkdir "$TEST_DIR/openvpn/fake-vpn-file.ovpn" # ===== 3. SYMLINK LOOPS ===== # Infinite loop: A → B → A ln -s "loop-b.conf" "$TEST_DIR/wireguard/loop-a.conf" ln -s "loop-a.conf" "$TEST_DIR/wireguard/loop-b.conf" # Self-referencing ln -s "self.conf" "$TEST_DIR/wireguard/self.conf" # ===== 4. CROSS-PROTOCOL CONFUSION ===== # Create temp configs for cross-protocol tests cat > "$TEST_DIR/wireguard/cross-test-wg.conf" <<'EOF' [Interface] PrivateKey = cross-test-key Address = 10.99.0.1/32 EOF cat > "$TEST_DIR/openvpn/cross-test-ovpn.ovpn" <<'EOF' remote 192.0.2.99 1194 udp dev tun EOF # WireGuard config in openvpn/ with .ovpn extension cp "$TEST_DIR/wireguard/cross-test-wg.conf" "$TEST_DIR/openvpn/wireguard-masquerade.ovpn" # OpenVPN config in wireguard/ with .conf extension cp "$TEST_DIR/openvpn/cross-test-ovpn.ovpn" "$TEST_DIR/wireguard/openvpn-masquerade.conf" # Cross-protocol symlinks ln -s "../wireguard/working.conf" "$TEST_DIR/openvpn/link-to-wireguard.ovpn" ln -s "../openvpn/stale.ovpn" "$TEST_DIR/wireguard/link-to-openvpn.conf" # ===== 5. EMPTY/MALFORMED FILES ===== # Empty configs touch "$TEST_DIR/wireguard/empty.conf" touch "$TEST_DIR/openvpn/empty.ovpn" # Partial/incomplete configs (will fail validation) echo "[Interface]" > "$TEST_DIR/wireguard/incomplete.conf" echo "remote" > "$TEST_DIR/openvpn/partial.ovpn" # Configs with only comments cat > "$TEST_DIR/wireguard/only-comments.conf" <<'EOF' # This file has no actual config # Just comments EOF cat > "$TEST_DIR/openvpn/only-comments.ovpn" <<'EOF' # This file has no actual config # Just comments EOF # ===== 6. MISSING EXTENSIONS ===== # Valid structure, wrong/missing extension cat > "$TEST_DIR/wireguard/no-extension" <<'EOF' [Interface] PrivateKey = no-ext-key Address = 10.98.0.1/32 EOF cat > "$TEST_DIR/openvpn/no-extension" <<'EOF' remote 192.0.2.98 1194 udp dev tun EOF # Wrong extension entirely cp "$TEST_DIR/wireguard/working.conf" "$TEST_DIR/wireguard/wrong.txt" cp "$TEST_DIR/openvpn/stale.ovpn" "$TEST_DIR/openvpn/wrong.cfg" # ===== 7. SPECIAL CHARACTERS & EDGE CASES ===== # Spaces in names cat > "$TEST_DIR/wireguard/space in name.conf" <<'EOF' [Interface] PrivateKey = space-test-key Address = 10.97.0.1/32 EOF cat > "$TEST_DIR/openvpn/space in name.ovpn" <<'EOF' remote 192.0.2.97 1194 udp dev tun EOF # Leading/trailing spaces (hard to see visually!) touch "$TEST_DIR/wireguard/ leading.conf" touch "$TEST_DIR/wireguard/trailing .conf" # Shell metacharacters (should be safely quoted) touch "$TEST_DIR/wireguard/has\$dollar.conf" touch "$TEST_DIR/wireguard/has;semicolon.conf" touch "$TEST_DIR/wireguard/has&ersand.conf" # Command substitution attempts (testing quoting safety) touch "$TEST_DIR/wireguard/\$(echo test).conf" 2>/dev/null || true touch "$TEST_DIR/wireguard/\`echo test\`.conf" 2>/dev/null || true # Newline in filename (extremely evil!) # Note: Some systems don't support this, so use || true touch "$TEST_DIR/wireguard/has newline.conf" 2>/dev/null || true # Unicode/international characters touch "$TEST_DIR/wireguard/café.conf" 2>/dev/null || true touch "$TEST_DIR/wireguard/日本語.conf" 2>/dev/null || true touch "$TEST_DIR/wireguard/файл.conf" 2>/dev/null || true # ===== 8. CORRUPT CATEGORIES ===== # Category is a file, not directory touch "$TEST_DIR/wireguard/file-not-directory" # Category is a broken symlink ln -s /nonexistent "$TEST_DIR/wireguard/broken-category-link" # Category with special characters mkdir "$TEST_DIR/wireguard/streaming servers" 2>/dev/null || true mkdir "$TEST_DIR/openvpn/work-vpn" 2>/dev/null || true # Empty category (valid but edge case) mkdir "$TEST_DIR/wireguard/empty-category" mkdir "$TEST_DIR/openvpn/empty-group" # ===== 9. SESSION CORRUPTION ===== # Session with non-numeric PID mkdir -p "$TEST_DIR/.session/not-a-number" echo "wireguard" > "$TEST_DIR/.session/not-a-number/protocol" echo "wg_test0" > "$TEST_DIR/.session/not-a-number/interface" # Session with alphabetic PID mkdir -p "$TEST_DIR/.session/abcdef" echo "openvpn" > "$TEST_DIR/.session/abcdef/protocol" echo "tun99" > "$TEST_DIR/.session/abcdef/interface" # Session with missing critical metadata mkdir -p "$TEST_DIR/.session/11111" echo "wireguard" > "$TEST_DIR/.session/11111/protocol" # Missing: interface, original, started - incomplete! # Session referencing non-existent config mkdir -p "$TEST_DIR/.session/22222" echo "wireguard" > "$TEST_DIR/.session/22222/protocol" echo "wg_test0" > "$TEST_DIR/.session/22222/interface" echo "$TEST_DIR/wireguard/DOES_NOT_EXIST.conf" > "$TEST_DIR/.session/22222/original" echo "2024-01-01 00:00:00" > "$TEST_DIR/.session/22222/started" # Session referencing cross-protocol config (WireGuard session → OpenVPN config) mkdir -p "$TEST_DIR/.session/33333" echo "wireguard" > "$TEST_DIR/.session/33333/protocol" echo "wg_test0" > "$TEST_DIR/.session/33333/interface" echo "$TEST_DIR/openvpn/stale.ovpn" > "$TEST_DIR/.session/33333/original" echo "2024-01-01 00:00:00" > "$TEST_DIR/.session/33333/started" # Session with broken connect.sh symlink mkdir -p "$TEST_DIR/.session/44444" echo "wireguard" > "$TEST_DIR/.session/44444/protocol" echo "wg_test0" > "$TEST_DIR/.session/44444/interface" ln -s /nonexistent "$TEST_DIR/.session/44444/connect.sh" # ===== 10. CORRUPT NAMED SESSIONS ===== # Named session pointing to non-existent PID ln -s "../.session/99999999" "$TEST_DIR/session/broken-reference" # Named session pointing to another named session (indirect) ln -s "work" "$TEST_DIR/session/indirect" # Named session symlink loop ln -s "session-loop-b" "$TEST_DIR/session/session-loop-a" ln -s "session-loop-a" "$TEST_DIR/session/session-loop-b" # Named session is a file, not symlink touch "$TEST_DIR/session/file-not-link" # Named session with special characters ln -s "../.session/88888" "$TEST_DIR/session/my vpn" ln -s "../.session/88888" "$TEST_DIR/session/work@home" # ===== 11. PERMISSION ISSUES (commented out - may require root) ===== # Unreadable config (permission denied) # touch "$TEST_DIR/wireguard/unreadable.conf" # chmod 0000 "$TEST_DIR/wireguard/unreadable.conf" # Write-only config (can't read) # touch "$TEST_DIR/wireguard/writeonly.conf" # chmod 0200 "$TEST_DIR/wireguard/writeonly.conf" # ===== 12. DUPLICATE NAMES (different types) ===== # Config file and category with same name (minus extension) cat > "$TEST_DIR/wireguard/conflict.conf" <<'EOF' [Interface] PrivateKey = dup-key Address = 10.96.0.1/32 EOF mkdir "$TEST_DIR/wireguard/conflict" # ===== 13. EXTREMELY LONG NAMES ===== # Very long filename (test buffer limits) long_name="very_long_name_that_goes_on_and_on" long_name="${long_name}_${long_name}_${long_name}_${long_name}" touch "$TEST_DIR/wireguard/${long_name}.conf" 2>/dev/null || true # ===== 14. HIDDEN FILES ===== # Dotfiles (should be ignored by most operations) touch "$TEST_DIR/wireguard/.hidden.conf" touch "$TEST_DIR/openvpn/.hidden.ovpn" # ===== END CORRUPTION PATTERNS ===== } # run_vpn_switch - Execute vpn-switch.sh command # # Arguments: command and arguments to pass to vpn-switch.sh # Returns: captured stdout and stderr # run_vpn_switch() { # Pass through BATCH_KEEP_GOING if set (runtime parameter for testing) if [ -n "${VPN_SWITCH_BATCH_KEEP_GOING:-}" ]; then VPN_SWITCH_BASE="$TEST_DIR" \ VPN_SWITCH_DISPLAY_ANSI=0 \ VPN_SWITCH_BATCH_KEEP_GOING="$VPN_SWITCH_BATCH_KEEP_GOING" \ "$TEST_SCRIPT" "$@" 2>&1 else VPN_SWITCH_BASE="$TEST_DIR" \ VPN_SWITCH_DISPLAY_ANSI=0 \ "$TEST_SCRIPT" "$@" 2>&1 fi } # set_function_interpreter - Set interpreter for a specific function # # Arguments: # $1 - Function name (e.g., "wireguard_connect", "stop") # $2 - Interpreter value (e.g., "cat", "sh") # set_function_interpreter() { local func_name="$1" local interpreter="$2" run_vpn_switch setenv "VPN_SWITCH_INTERPRETER_${func_name}" "$interpreter" > /dev/null 2>&1 } # create_failing_interpreter - Create type-appropriate failing interpreter # # Arguments: # $1 - Function name (with underscores, e.g., "___list0", "__stop1", "_log1") # Output: Interpreter string that consumes input properly and exits with 1 # # Different function types need different failing interpreters: # - Terminal (_): "sh -c 'cat && false'" (pass through output, then exit with 1) # - Combinator (__): "head -n1 | xargs sh -c 'cat && false' --" (consume 1 line, pass through, exit with 1) # - Batch (___): "sh -c 'cat && false'" (pass through all output, then exit with 1) # # NOTE: Must use "sh -c" wrapper because && operator requires shell evaluation # Combinator pattern uses xargs to prevent the consumed line from becoming arguments to sh # This pattern works because: # 1. sh -c executes the command string as shell syntax # 2. cat passes through stdin to stdout # 3. && false ensures exit code is 1 after cat succeeds # 4. Survives double-eval (process_arguments + run_env) # create_failing_interpreter() { local func_name="$1" case "$func_name" in ___*) # Batch combinator: pass through all input, then exit with failure echo "sh -c 'cat && false'" ;; __*) # Combinator: consume one line via head, use xargs to buffer it, then pass through and fail echo "head -n1 | xargs sh -c 'cat && false' --" ;; _*) # Terminal: pass through output, then exit with failure echo "sh -c 'cat && false'" ;; *) # Unknown type - fallback echo "sh -c 'cat && false'" ;; esac } #----------------------------------------------------------------------------- # Architecture Validators #----------------------------------------------------------------------------- # validate_terminal_function_output - Validate terminal function output # # Terminal function rules: # - Must output shell commands only (no direct vpn-switch internal calls) # - Can call: shell keywords, builtins, system commands, locally-defined functions # - Cannot call: vpn-switch functions (starting with _) unless locally defined # - Cannot call: error(), log(), run_env(), dispatch(), lookup_interpreter() # # Arguments: $1 - output to validate, $2 - function name (for error messages) # Returns: 0 if valid, 1 if invalid # validate_terminal_function_output() { local output="$1" local func_name="$2" # Empty output is valid [ -z "$output" ] && return 0 # Shell keywords and common commands whitelist local shell_keywords="if then else elif fi while do done for case esac until select function" local common_commands="echo cat test mkdir chmod chown cp mv rm ln grep sed awk cut sort uniq wc head tail tr date sleep true false cd pwd ls find xargs touch dirname basename readlink stat tee printf yes no which command type sh" # Extract locally-defined functions (pattern: function_name() or function name()) local local_functions=$(echo "$output" | \ sed 's/^[[:space:]]*//' | \ grep -E '^(function[[:space:]]+)?[a-zA-Z_][a-zA-Z0-9_]*[[:space:]]*\(\)' | \ sed -E 's/^function[[:space:]]+//' | \ sed -E 's/[[:space:]]*\(\).*//') # Check each line for forbidden patterns # Save output to temp file to avoid subshell issues local tmpfile="${TMPDIR:-/tmp}/terminal-validate.$$" echo "$output" > "$tmpfile" local line_num=0 local violations=0 while IFS= read -r line; do line_num=$((line_num + 1)) # Skip empty lines [ -z "$line" ] && continue # Skip comment lines echo "$line" | grep -q '^[[:space:]]*#' && continue # Skip heredoc markers echo "$line" | grep -qE '^[[:space:]]*(EOF|HEREDOC|END)' && continue # Check for direct display() output patterns (FORBIDDEN in terminal functions) # Terminal functions must generate shell commands, not call display functions directly # Valid: generate_error "message" → outputs commands # Invalid: error "message" → calls display() directly local line_stripped=$(echo "$line" | sed 's/^[[:space:]]*//') if echo "$line_stripped" | grep -qE '^(Error|Warning|Success|Info|Log): '; then violations=$((violations + 1)) echo " ${COLOR_YELLOW}Line $line_num:${COLOR_RESET} Direct display() call detected (use generate_error/generate_warning instead)" echo " ${COLOR_YELLOW}Context:${COLOR_RESET} $line" continue fi # A loop in the emission runs at execution time over a set the anchor # already knows at generation time (template lines, records, packages, # files): the anchor unrolls it -- one emitted line per element -- and # only the change of system state stays for execution time. The one loop # that may stay is a wait on the world: while/until over a probe with a # sleep in the same line (a boot, an ssh answer, a console line cannot be # known before they happen). A for loop never is one. if echo "$line_stripped" | grep -qE '(^|[;&|(][[:space:]]*)(for|while|until)[[:space:]]' \ && ! echo "$line_stripped" | grep -qE '(^|[;&|(][[:space:]]*)(while|until)[[:space:]].*;[[:space:]]*do[[:space:]].*sleep [0-9]'; then violations=$((violations + 1)) echo " ${COLOR_YELLOW}Line $line_num:${COLOR_RESET} Loop in the emission -- unroll it at generation time (one line per element); only a wait on the world (while/until with a sleep) may stay" echo " ${COLOR_YELLOW}Context:${COLOR_RESET} $line" continue fi # Extract first token (strip leading whitespace, get first word) local first_token=$(echo "$line" | sed 's/^[[:space:]]*//' | awk '{print $1}') # Skip if no token [ -z "$first_token" ] && continue # Strip trailing punctuation/operators for better matching local token_clean=$(echo "$first_token" | sed -E 's/[();{}&|]+$//') # Check if it's a shell keyword local is_keyword=0 for kw in $shell_keywords; do if [ "$token_clean" = "$kw" ]; then is_keyword=1 break fi done [ $is_keyword -eq 1 ] && continue # Check if it's a common command local is_command=0 for cmd in $common_commands; do if [ "$token_clean" = "$cmd" ]; then is_command=1 break fi done [ $is_command -eq 1 ] && continue # Check if it's locally defined local is_local=0 for local_func in $local_functions; do if [ "$token_clean" = "$local_func" ]; then is_local=1 break fi done [ $is_local -eq 1 ] && continue # Check for forbidden vpn-switch internal functions case "$token_clean" in error|log|run_env|dispatch|lookup_interpreter) violations=$((violations + 1)) echo " ${COLOR_YELLOW}Line $line_num:${COLOR_RESET} FORBIDDEN vpn-switch function: $token_clean" echo " ${COLOR_YELLOW}Context:${COLOR_RESET} $line" continue ;; esac # Check for underscore functions (vpn-switch internal) not locally defined if echo "$token_clean" | grep -q '^_'; then violations=$((violations + 1)) echo " ${COLOR_YELLOW}Line $line_num:${COLOR_RESET} Underscore function not locally defined: $token_clean" echo " ${COLOR_YELLOW}Context:${COLOR_RESET} $line" continue fi # Check for execution pattern of $VPN_SWITCH_CONTEXT_SCRIPT (reserved for combinators) # Forbidden: "$VPN_SWITCH_CONTEXT_SCRIPT" as first token (execution/composition) # Allowed: "$VPN_SWITCH_CONTEXT_SCRIPT" in test conditions, echo, assignments, etc. # # Exception: _bootstrap2 is allowed to execute $VPN_SWITCH_CONTEXT_SCRIPT # Reason: Bootstrap bypasses process_arguments() dispatch (vpn-switch.sh:2501) # and runs before database exists, so interpreter overrides are not available. # While it could be refactored to batch combinator, there's no benefit since # interpreter customization is impossible at bootstrap time. if [ "$func_name" != "_bootstrap2" ]; then local line_stripped=$(echo "$line" | sed 's/^[[:space:]]*//') if echo "$line_stripped" | grep -q '^"\$VPN_SWITCH_CONTEXT_SCRIPT"'; then violations=$((violations + 1)) echo " ${COLOR_YELLOW}Line $line_num:${COLOR_RESET} Terminal function executing \$VPN_SWITCH_CONTEXT_SCRIPT (reserved for combinators)" echo " ${COLOR_YELLOW}Context:${COLOR_RESET} $line" continue fi fi done < "$tmpfile" # Cleanup temp file rm -f "$tmpfile" # Return validation result [ "$violations" -eq 0 ] } # validate_combinator_syntax - Validate combinator function output # # Combinator rules: # - Exactly 1 line # - Contains literal "$VPN_SWITCH_CONTEXT_SCRIPT" (not expanded) # - Does not start with / (would indicate expanded variable) # # Arguments: $1 - output to validate # Returns: 0 if valid, 1 if invalid # validate_combinator_syntax() { local output="$1" # Count lines (ignore empty output = 0 lines) local line_count if [ -z "$output" ]; then line_count=0 else line_count=$(echo "$output" | wc -l | tr -d ' ') fi # Must be exactly 1 line if [ "$line_count" -ne 1 ]; then echo " ${COLOR_YELLOW}Expected:${COLOR_RESET} 1 line" echo " ${COLOR_YELLOW}Got:${COLOR_RESET} $line_count lines" return 1 fi # Must contain literal $VPN_SWITCH_CONTEXT_SCRIPT (not expanded) if ! echo "$output" | grep -q '"\$VPN_SWITCH_CONTEXT_SCRIPT"'; then echo " ${COLOR_YELLOW}Expected:${COLOR_RESET} Literal '\"\$VPN_SWITCH_CONTEXT_SCRIPT\"'" echo " ${COLOR_YELLOW}Got:${COLOR_RESET} $output" return 1 fi # Must not be expanded (would start with absolute path) if echo "$output" | grep -q '^/'; then echo " ${COLOR_YELLOW}Error:${COLOR_RESET} Variable expanded (contains absolute path)" echo " ${COLOR_YELLOW}Got:${COLOR_RESET} $output" return 1 fi return 0 } # validate_batch_combinator_syntax - Validate batch combinator function output # # Batch combinator rules: # - Outputs N lines (N >= 0) # - Each non-empty, non-comment line must be a vpn-switch command # - Use literal "$VPN_SWITCH_CONTEXT_SCRIPT" (not expanded) # - Comments (#) and empty lines are allowed # # Arguments: $1 - output to validate # Returns: 0 if valid, 1 if invalid # validate_batch_combinator_syntax() { local output="$1" # Empty output is valid (N=0) [ -z "$output" ] && return 0 # Check each line local line_num=0 echo "$output" | while IFS= read -r line; do line_num=$((line_num + 1)) # Skip empty lines [ -z "$line" ] && continue # Skip comments echo "$line" | grep -q '^#' && continue # Must be vpn-switch command (contains literal variable) if ! echo "$line" | grep -q '"\$VPN_SWITCH_CONTEXT_SCRIPT"'; then echo " ${COLOR_YELLOW}Error at line $line_num:${COLOR_RESET} Not a vpn-switch command" echo " ${COLOR_YELLOW}Expected:${COLOR_RESET} '\"\$VPN_SWITCH_CONTEXT_SCRIPT\" ...'" echo " ${COLOR_YELLOW}Got:${COLOR_RESET} $line" return 1 fi # Must not be expanded if echo "$line" | grep -q '^/'; then echo " ${COLOR_YELLOW}Error at line $line_num:${COLOR_RESET} Variable expanded" echo " ${COLOR_YELLOW}Got:${COLOR_RESET} $line" return 1 fi done return $? } #----------------------------------------------------------------------------- # Test Argument Mapping #----------------------------------------------------------------------------- # get_test_argument_variations - Return test argument variations for a function # # Returns: Pipe-separated list of argument sets to test # Format: "arg1|arg2 arg3|arg4" # Each variation is tested separately # # Special values: # EMPTY - Function takes no arguments (arity-0) # SKIP - Function requires special setup, skip for now # # Strategy: # 1. Check for functions needing special argument patterns # 2. Otherwise, extract arity from function name and use generic args # get_test_argument_variations() { local func="$1" # Extract arity from function name (trailing digit) local arity arity=$(echo "$func" | sed 's/.*\([0-9]\)$/\1/') # Check if extraction failed (no trailing digit) if ! echo "$arity" | grep -q '^[0-9]$'; then echo "SKIP" return fi # Special cases - functions needing specific argument patterns case "$func" in # cat1 - SKIP: This function captures stdin and writes directly to file. # It must execute immediately (can't output commands) to preserve stdin. # Testing with arbitrary args creates files in cwd. cat1) echo "SKIP" return ;; # import1 - needs file paths with different extensions import1) echo "\$TEST_BASE_DIR/test.conf|\$TEST_BASE_DIR/test.ovpn|\$TEST_BASE_DIR/nonexistent.conf|\$TEST_BASE_DIR/noext" return ;; # stop1 - needs interface names (use test names to avoid conflict with real interfaces) stop1|openvpn_stop1|wireguard_stop1) echo "wg_test0|tun_test0|nonexistent99" return ;; # start1 - needs config names start1|openvpn_start1|wireguard_start1) echo "test-wg|nonexistent|test-wg.conf" return ;; # create1 - needs config names wireguard_create1|openvpn_create1) echo "test-wg.conf|nonexistent.conf|test-ovpn.ovpn" return ;; # bootstrap1 - needs directory path bootstrap1) echo "\$TEST_BASE_DIR/bootstrap-test" return ;; # bootstrap2 - needs directory path and profile (creates filesystem artifacts) bootstrap2) echo "\$TEST_BASE_DIR/bootstrap-test1 minimal|\$TEST_BASE_DIR/bootstrap-test2 all" return ;; # batch1 - needs function name batch1) echo "wireguard_list|nonexistent_function" return ;; # restore1 - needs dump file path restore1) echo "\$TEST_BASE_DIR/dump.sh|\$TEST_BASE_DIR/nonexistent.sh" return ;; # the export/import pair (ported from elebake): the cascades carry a # serial, a key record, a fingerprint, a floor and the current serial as # arguments -- numbers and names, never the generic placeholders restore2) echo "\$TEST_BASE_DIR/dump.sh \$TEST_DIR|\$TEST_BASE_DIR/nonexistent.sh \$TEST_DIR"; return ;; restore_v12) echo "\$TEST_BASE_DIR/dump.sh \$TEST_DIR|\$TEST_BASE_DIR/nonexistent.sh \$TEST_DIR"; return ;; restore_v13|provenance_add3) echo "\$TEST_BASE_DIR/dump.sh - 3"; return ;; restore_v14|provenance_add4|import4) echo "\$TEST_BASE_DIR/dump.sh - 3 unit|\$TEST_BASE_DIR/dump.sh - 3 nokey"; return ;; restore_v15|provenance_add5|import5) echo "\$TEST_BASE_DIR/dump.sh - 3 unit 0123456789ABCDEF0123456789ABCDEF01234567"; return ;; restore_v16|provenance_add6|import6) echo "\$TEST_BASE_DIR/dump.sh - 3 unit 0123456789ABCDEF0123456789ABCDEF01234567 0"; return ;; provenance_add7|import7) echo "\$TEST_BASE_DIR/dump.sh - 3 unit 0123456789ABCDEF0123456789ABCDEF01234567 0 0"; return ;; provenance_receipt5|provenance_file5) echo "\$TEST_BASE_DIR/dump.sh - 3 0123456789ABCDEF0123456789ABCDEF01234567 0"; return ;; provenance_receipt_same3) echo "\$TEST_BASE_DIR/dump.sh - 3"; return ;; restore_serial_admissible3) echo "3 0 0123456789ABCDEF0123456789ABCDEF01234567|1 3 0123456789ABCDEF0123456789ABCDEF01234567"; return ;; provenance_serial1|provenance_list1|dump_header1|dump1) echo "0|7"; return ;; export3) echo "redacted \$TEST_BASE_DIR/d.sh \$TEST_BASE_DIR/b.tar.gz|full \$TEST_BASE_DIR/d.sh \$TEST_BASE_DIR/b.tar.gz|bogus \$TEST_BASE_DIR/d.sh \$TEST_BASE_DIR/b.tar.gz"; return ;; export_pair3) echo "full \$TEST_BASE_DIR/d.sh \$TEST_BASE_DIR/b.tar.gz"; return ;; export_pair4) echo "full \$TEST_BASE_DIR/d.sh \$TEST_BASE_DIR/b.tar.gz 0"; return ;; filter3) echo "bogus \$TEST_BASE_DIR/collection \$TEST_BASE_DIR/out"; return ;; filter_redacted2|filter_minimized2|filter_default2) echo "\$TEST_BASE_DIR/collection \$TEST_BASE_DIR/out"; return ;; export2|export_redacted2|export_full2|export_minimized2|import2|seal2|seal_verify2|seal_write2|seal_matches2) echo "\$TEST_BASE_DIR/dump.sh \$TEST_BASE_DIR/b.tar.gz|\$TEST_BASE_DIR/nonexistent.sh \$TEST_BASE_DIR/b.tar.gz"; return ;; provenance_add2) echo "\$TEST_BASE_DIR/dump.sh -|\$TEST_BASE_DIR/nonexistent.sh -"; return ;; provenance_import2|provenance_import_place2|provenance_import_copy2) echo "000001-abcdef012345 \$TEST_BASE_DIR/dump.sh|bad/id \$TEST_BASE_DIR/dump.sh"; return ;; openpgp_add2|openpgp_record2) echo "unit 0123456789ABCDEF|bad/name 0123456789ABCDEF"; return ;; openpgp_add3|openpgp_record3) echo "unit 0123456789ABCDEF \$TEST_BASE_DIR|unit nothex \$TEST_BASE_DIR"; return ;; openpgp_keyid_valid1) echo "0123456789ABCDEF|nothex"; return ;; openpgp_import2) echo "unit \$TEST_BASE_DIR/dump.sh|unit \$TEST_BASE_DIR/nonexistent"; return ;; key_import_copy3|key_import3) echo "openpgp unit \$TEST_BASE_DIR/dump.sh|openpgp unit \$TEST_BASE_DIR/nonexistent"; return ;; key_collect_files2|key_record_exists2) echo "openpgp unit|openpgp nokey"; return ;; attest2|attest_verify2|attest_sign2|attest_signer_pinned2|manifest_attest2) echo "\$TEST_BASE_DIR/dump.sh unit|\$TEST_BASE_DIR/nonexistent unit"; return ;; manifest2|manifest_write2) echo "\$TEST_BASE_DIR/collection \$TEST_BASE_DIR/MANIFEST"; return ;; manifest_verify3) echo "\$TEST_BASE_DIR/MANIFEST \$TEST_BASE_DIR unit"; return ;; manifest_match2|manifest_tree_matches2) echo "\$TEST_BASE_DIR/MANIFEST \$TEST_BASE_DIR"; return ;; filter2|filter_full2|bundle2|bundle_pack2|extract2|extract_unpack2) echo "\$TEST_BASE_DIR/collection \$TEST_BASE_DIR/out"; return ;; filter_write3) echo "full \$TEST_BASE_DIR/collection \$TEST_BASE_DIR/out"; return ;; incoming_clear1|incoming_remove1) echo "\$TEST_BASE_DIR/incoming/x|\$TEST_DIR"; return ;; # session_disconnect1 - needs PID session_disconnect1) echo "12345|99999" return ;; # wireguard_add2 - needs category and target wireguard_add2) echo "privacy test.conf|nonexistent invalid.conf" return ;; # phases_sync1 - needs protocol name phases_sync1) echo "wireguard|openvpn|nonexistent" return ;; # init1 - needs directory path init1) echo "\$TEST_BASE_DIR/init-test" return ;; # dumpenv1 - needs valid profile names dumpenv1) echo "minimal|all" return ;; # lift3 - needs phase_name, input_file, output_file # Security: requires absolute paths, rejects relative paths with error command # Test BOTH: success path (absolute) AND error path (relative) lift3) echo "test_phase \$TEST_BASE_DIR/template/phase/base/firewall/pf.sh \$TEST_BASE_DIR/.include/phase/test.sh|test_phase relative/input.sh relative/output.sh" return ;; esac # Generic argument generation based on arity # Use function name prefix to identify which test creates any stray files case "$arity" in 0) echo "EMPTY" ;; 1) echo "${func}_a1|${func}_a2|${func}_a3" ;; 2) echo "${func}_a1 ${func}_a2|${func}_a3 ${func}_a4" ;; 3) echo "${func}_a1 ${func}_a2 ${func}_a3|${func}_a4 ${func}_a5 ${func}_a6" ;; *) echo "SKIP" ;; esac } # get_test_argument_by_database - Extract arguments from messy database state # # Returns: Pipe-separated list of arguments extracted from actual database # Format: "arg1|arg2|arg3" (same as get_test_argument_variations) # Returns "SKIP" if function doesn't need database-aware testing # # Purpose: Provide realistic arguments that match actual data in messy database # This catches violations that generic arguments miss (e.g., session_start1) # # Strategy: ADVERSARIAL TESTING - Generate arguments for BOTH success AND error paths # - Success paths: test command generation with valid inputs # - Error paths: test error handling (should use generate_error, not error()) # - Edge cases: test boundary conditions # get_test_argument_by_database() { local func="$1" case "$func" in # Session functions - use actual session names and PIDs session_start0) # No arguments, but function needs default symlink (already in messy DB) echo "EMPTY" ;; session_start1) # Extract actual session names and PIDs from messy database local sessions="" # Named sessions from session/ directory if [ -d "$TEST_DIR/session" ]; then for link in "$TEST_DIR/session"/*; do [ -L "$link" ] || continue local name=$(basename -- "$link") # Escape shell metacharacters for eval (prevent expansion/substitution) name=$(echo "$name" | sed 's/\$/\\$/g; s/`/\\`/g; s/(/\\(/g; s/)/\\)/g; s/;/\\;/g; s/&/\\&/g') sessions="$sessions|$name" done fi # PID sessions from .session/ directory if [ -d "$TEST_DIR/.session" ]; then for dir in "$TEST_DIR/.session"/*; do [ -d "$dir" ] || continue local pid=$(basename -- "$dir") # Escape shell metacharacters for eval (prevent expansion/substitution) pid=$(echo "$pid" | sed 's/\$/\\$/g; s/`/\\`/g; s/(/\\(/g; s/)/\\)/g; s/;/\\;/g; s/&/\\&/g') sessions="$sessions|$pid" done fi # Remove leading pipe echo "$sessions" | sed 's/^|//' ;; session_remove1|session_show1|session_connect1) # ADVERSARIAL: Test both success and error paths local pids="" # SUCCESS PATH: Extract actual PIDs from .session/ directory if [ -d "$TEST_DIR/.session" ]; then for dir in "$TEST_DIR/.session"/*; do [ -d "$dir" ] || continue local pid=$(basename -- "$dir") # Escape shell metacharacters for eval (prevent expansion/substitution) pid=$(echo "$pid" | sed 's/\$/\\$/g; s/`/\\`/g; s/(/\\(/g; s/)/\\)/g; s/;/\\;/g; s/&/\\&/g') pids="$pids|$pid" done fi # ERROR PATH: Non-existent PIDs (should trigger error handling) pids="$pids|99999999|12345|00000" echo "$pids" | sed 's/^|//' ;; session_save0) # No arguments echo "EMPTY" ;; session_save1) # Session names to save as (extract existing + add new) echo "test-session|my-vpn|backup" ;; wireguard_add1|openvpn_add1) # ADVERSARIAL: Test both success and error paths for category creation local protocol="${func%%_*}" local results="" if [ -d "$TEST_DIR/$protocol" ]; then # Find existing category local existing_cat="" for dir in "$TEST_DIR/$protocol"/*; do [ -d "$dir" ] || continue existing_cat=$(basename -- "$dir") # Escape shell metacharacters for eval (prevent expansion/substitution) existing_cat=$(echo "$existing_cat" | sed 's/\$/\\$/g; s/`/\\`/g; s/(/\\(/g; s/)/\\)/g; s/;/\\;/g; s/&/\\&/g') break done # SUCCESS PATH: new category name results="$results|new_category|test_cat" # ERROR PATH: existing category name (should fail) [ -n "$existing_cat" ] && results="$results|$existing_cat" # ERROR PATH: category that conflicts with existing file for item in "$TEST_DIR/$protocol"/*.conf "$TEST_DIR/$protocol"/*.ovpn; do [ -f "$item" ] || [ -L "$item" ] || continue local conf=$(basename -- "$item" .conf) conf=$(basename -- "$conf" .ovpn) # Escape shell metacharacters for eval (prevent expansion/substitution) conf=$(echo "$conf" | sed 's/\$/\\$/g; s/`/\\`/g; s/(/\\(/g; s/)/\\)/g; s/;/\\;/g; s/&/\\&/g') results="$results|$conf" break done fi echo "$results" | sed 's/^|//' ;; wireguard_start1|wireguard_info1|wireguard_patch1) # ADVERSARIAL: Test both success and error paths local configs="" # SUCCESS PATH: Extract actual WireGuard config names if [ -d "$TEST_DIR/wireguard" ]; then for item in "$TEST_DIR/wireguard"/*.conf "$TEST_DIR/wireguard"/*; do [ -f "$item" ] || [ -L "$item" ] || [ -d "$item" ] || continue local name=$(basename -- "$item") # Skip if it's just the glob pattern (no matches) [ "$name" = "*.conf" ] && continue # Escape shell metacharacters for eval (prevent expansion/substitution) name=$(echo "$name" | sed 's/\$/\\$/g; s/`/\\`/g; s/(/\\(/g; s/)/\\)/g; s/;/\\;/g; s/&/\\&/g') configs="$configs|$name" done fi # ERROR PATH: Non-existent configs (should trigger error handling) configs="$configs|nonexistent.conf|missing-config.conf" echo "$configs" | sed 's/^|//' ;; openvpn_start1|openvpn_info1|openvpn_patch1) # ADVERSARIAL: Test both success and error paths local configs="" # SUCCESS PATH: Extract actual OpenVPN config names if [ -d "$TEST_DIR/openvpn" ]; then for item in "$TEST_DIR/openvpn"/*.ovpn "$TEST_DIR/openvpn"/*; do [ -f "$item" ] || [ -L "$item" ] || [ -d "$item" ] || continue local name=$(basename -- "$item") [ "$name" = "*.ovpn" ] && continue # Escape shell metacharacters for eval (prevent expansion/substitution) name=$(echo "$name" | sed 's/\$/\\$/g; s/`/\\`/g; s/(/\\(/g; s/)/\\)/g; s/;/\\;/g; s/&/\\&/g') configs="$configs|$name" done fi # ERROR PATH: Non-existent configs (should trigger error handling) configs="$configs|nonexistent.ovpn|missing-config.ovpn" echo "$configs" | sed 's/^|//' ;; wireguard_add2|openvpn_add2) # ADVERSARIAL: Test both success and error paths local protocol="${func%%_*}" local results="" if [ -d "$TEST_DIR/$protocol" ]; then # Find first category (directory) and first config (file) local first_cat="" local first_conf="" for dir in "$TEST_DIR/$protocol"/*; do [ -d "$dir" ] || continue first_cat=$(basename -- "$dir") # Escape shell metacharacters for eval (prevent expansion/substitution) first_cat=$(echo "$first_cat" | sed 's/\$/\\$/g; s/`/\\`/g; s/(/\\(/g; s/)/\\)/g; s/;/\\;/g; s/&/\\&/g') break done for item in "$TEST_DIR/$protocol"/*.conf "$TEST_DIR/$protocol"/*.ovpn; do [ -f "$item" ] || [ -L "$item" ] || continue local conf=$(basename -- "$item") [ "$conf" = "*.conf" ] && continue [ "$conf" = "*.ovpn" ] && continue # Escape shell metacharacters for eval (prevent expansion/substitution) conf=$(echo "$conf" | sed 's/\$/\\$/g; s/`/\\`/g; s/(/\\(/g; s/)/\\)/g; s/;/\\;/g; s/&/\\&/g') first_conf="$conf" break done # SUCCESS PATH: existing category + existing config [ -n "$first_cat" ] && [ -n "$first_conf" ] && results="$results|$first_cat $first_conf" # ERROR PATH 1: non-existent category + existing config [ -n "$first_conf" ] && results="$results|nonexistent_category $first_conf" # ERROR PATH 2: existing category + non-existent config [ -n "$first_cat" ] && results="$results|$first_cat nonexistent.conf" # ERROR PATH 3: both non-existent results="$results|fake_cat fake.conf" fi echo "$results" | sed 's/^|//' ;; *) # No database-aware arguments for this function echo "SKIP" ;; esac } #----------------------------------------------------------------------------- # Command Walking Functions (extracted from vpn-switch-walkthrough.sh) #----------------------------------------------------------------------------- # is_terminal_function - Check if function is a terminal function is_terminal_function() { local func="$1" echo "$TERMINAL_FUNCTIONS" | grep -qw "$func" } # is_combinator - Check if function is a combinator (double underscore) is_combinator() { local func="$1" echo "$COMBINATOR_FUNCTIONS" | grep -qw "$func" } # is_batch_combinator - Check if function is a batch combinator (triple underscore) is_batch_combinator() { local func="$1" echo "$BATCH_COMBINATOR_FUNCTIONS" | grep -qw "$func" } # extract_arity - Get function arity from name extract_arity() { local func="$1" # Function names end with a digit (the arity) echo "$func" | sed 's/.*\([0-9]\)$/\1/' } # extract_command_from_output - Extract command arguments from output # Args: $1 - line containing "$VPN_SWITCH_CONTEXT_SCRIPT" command # Output: command arguments (without the script prefix) extract_command_from_output() { local line="$1" # Remove "$VPN_SWITCH_CONTEXT_SCRIPT" prefix (keep quotes in arguments) echo "$line" | sed 's/^"\$VPN_SWITCH_CONTEXT_SCRIPT" //' } # resolve_function - Resolve command arguments to function name # Args: command arguments (e.g., "stop" or "wireguard stop") # Output: function name (e.g., "___stop0" or "__wireguard_stop0") # Returns: 0 if found, 1 if not found resolve_function() { local args="$*" local arg_count=$# # Build function name base (concatenate with underscores) local base=$(echo "$args" | tr ' ' '_') # Try different arity values (arity 0 first, then increasing) local arity=0 while [ $arity -le $((arg_count + 2)) ]; do local func_base="${base}${arity}" # Try different underscore prefixes (batch first, then combinator, then terminal) # This matches the resolution priority in vpn-switch.sh dispatch for func_name in "___${func_base}" "__${func_base}" "_${func_base}"; do # Check if this function exists in ANCHOR_FUNCTIONS if echo " $ANCHOR_FUNCTIONS " | grep -q " $func_name "; then echo "$func_name" return 0 fi done arity=$((arity + 1)) done return 1 } # walk_command_tree - Recursively walk command resolution tree # Args: $1 - depth (integer) # $2 - command string # $3 - callback function to call at each node # Output: Calls callback with: callback depth func_name cmd output exit_status # # Callback receives: # $1 - depth # $2 - function type: "terminal", "combinator", "batch", "unresolved" # $3 - function name (with underscores) # $4 - command string # $5 - output from function # $6 - exit status # walk_command_tree() { local depth="$1" local cmd="$2" local callback="$3" # Resolve command to function name local func_name=$(resolve_function $cmd) if [ -z "$func_name" ]; then # Could not resolve - treat as terminal and stop recursion $callback "$depth" "unresolved" "" "$cmd" "" "0" return 0 fi # Determine function type local func_type="" case "$func_name" in ___*) func_type="batch" ;; __*) func_type="combinator" ;; _*) func_type="terminal" ;; esac # Strip underscores for interpreter variable naming local mangled=$(echo "$func_name" | sed 's/^_*//') # Set ONLY this specific function's interpreter to 'cat' set_function_interpreter "$mangled" "cat" # Execute and capture output local output=$(run_vpn_switch $cmd 2>&1) local exit_status=$? # Immediately unset this interpreter (cleanup - critical for correct recursion!) run_vpn_switch unsetenv "VPN_SWITCH_INTERPRETER_${mangled}" >/dev/null 2>&1 || true # Call callback for this node $callback "$depth" "$func_type" "$func_name" "$cmd" "$output" "$exit_status" # Recurse based on function type if [ "$func_type" = "terminal" ]; then # Terminal functions don't produce vpn-switch commands - stop recursion return 0 elif [ "$func_type" = "combinator" ]; then # Combinator outputs a single vpn-switch command - recurse once local next_cmd=$(extract_command_from_output "$output") if [ -n "$next_cmd" ]; then walk_command_tree $((depth + 1)) "$next_cmd" "$callback" fi elif [ "$func_type" = "batch" ]; then # Batch combinator outputs multiple vpn-switch commands - recurse on each echo "$output" | while IFS= read -r line; do # Skip comments and empty lines case "$line" in \#*|"") continue ;; '"$VPN_SWITCH_CONTEXT_SCRIPT"'*) local next_cmd=$(extract_command_from_output "$line") if [ -n "$next_cmd" ]; then walk_command_tree $((depth + 1)) "$next_cmd" "$callback" fi ;; esac done fi } # collect_tree_nodes - Collect all nodes in command tree # Args: $1 - command string # Output: Sets global variables: # TREE_NODES - space-separated list of "depth|type|func|cmd|output|status" # # This is a helper that uses walk_command_tree to collect the full tree structure collect_tree_nodes() { local cmd="$1" # Reset global collection TREE_NODES="" TREE_NODE_COUNT=0 # Define callback that collects nodes _collect_callback() { local depth="$1" local type="$2" local func="$3" local cmd="$4" local output="$5" local status="$6" # One LINE per node: a terminal's emission may span lines and carry # pipes (bundle pack feeds the archiver template a heredoc), and the # readers below split TREE_NODES by line and by | -- so the free # fields are flattened before they are stored cmd=$(printf '%s' "$cmd" | tr '\n|' ' /') output=$(printf '%s' "$output" | tr '\n|' ' /') local node_data="${depth}|${type}|${func}|${cmd}|${output}|${status}" if [ -z "$TREE_NODES" ]; then TREE_NODES="$node_data" else TREE_NODES="$TREE_NODES $node_data" fi TREE_NODE_COUNT=$((TREE_NODE_COUNT + 1)) } # Walk tree and collect nodes walk_command_tree 0 "$cmd" "_collect_callback" } #----------------------------------------------------------------------------- # Combinator Tests #----------------------------------------------------------------------------- # test_combinator_function - Test a single combinator function # # Strategy: Prepare database once, test all argument variations # # Arguments: $1 - function name (without __ prefix) # test_combinator_function() { local func="$1" # Get test argument variations local variations variations=$(get_test_argument_variations "$func") # Handle SKIP if [ "$variations" = "SKIP" ]; then echo " ${COLOR_YELLOW}Skipped: __${func} (requires special setup)${COLOR_RESET}" return fi test_header "Combinator: __${func} - validate syntax with variations" test_setup # Populate database with messy realistic state create_messy_realistic_database # Override to cat for command inspection set_function_interpreter "$func" "cat" # Prepare database ONCE (reused across all variations) # Create source config files (temporary, outside database) local test_wg_config="$TEST_BASE_DIR/test-wg.conf" local test_ovpn_config="$TEST_BASE_DIR/test-ovpn.ovpn" # WireGuard test config cat > "$test_wg_config" <<'EOF' [Interface] PrivateKey = fake-key-for-testing Address = 10.0.0.1/32 [Peer] PublicKey = fake-peer-key Endpoint = 192.0.2.1:51820 AllowedIPs = 0.0.0.0/0 EOF # OpenVPN test config cat > "$test_ovpn_config" <<'EOF' remote 192.0.2.10 1194 udp dev tun EOF # Import configs using API set_function_interpreter "wireguard_import1" "sh" set_function_interpreter "openvpn_import1" "sh" run_vpn_switch wireguard import "$test_wg_config" > /dev/null 2>&1 || true run_vpn_switch openvpn import "$test_ovpn_config" > /dev/null 2>&1 || true # Reset to cat for testing set_function_interpreter "$func" "cat" # Test each variation (GENERIC strategy) # Use temp file to avoid subshell (pipe creates subshell, breaks all_valid) local variations_file="${TMPDIR:-/tmp}/comb-variations-$$.txt" echo "$variations" | tr '|' '\n' > "$variations_file" local all_valid=true local variation_num=0 local max_variations=15 # Limit variations to prevent test explosion while IFS= read -r variation; do variation_num=$((variation_num + 1)) # Limit total variations tested (prevent combinatorial explosion) if [ "$variation_num" -gt "$max_variations" ]; then break fi # Expand variables in variation (e.g., $TEST_BASE_DIR) local expanded_args expanded_args=$(eval echo "$variation") # Handle EMPTY special case if [ "$variation" = "EMPTY" ]; then expanded_args="" fi # Test this variation local output if [ -z "$expanded_args" ]; then output=$(run_vpn_switch $func 2>&1 || true) else output=$(run_vpn_switch $func $expanded_args 2>&1 || true) fi # Validate syntax if ! validate_combinator_syntax "$output"; then all_valid=false fail "__${func} (variation $variation_num: $variation): Invalid syntax" echo " ${COLOR_YELLOW}Output:${COLOR_RESET} $output" fi done < "$variations_file" # Cleanup rm -f "$variations_file" # Test DATABASE-AWARE variations (if available) local db_variations=$(get_test_argument_by_database "$func") if [ "$db_variations" != "SKIP" ] && [ -n "$db_variations" ]; then local db_variations_file="${TMPDIR:-/tmp}/comb-db-variations-$$.txt" echo "$db_variations" | tr '|' '\n' > "$db_variations_file" while IFS= read -r variation; do variation_num=$((variation_num + 1)) # Expand variables in variation local expanded_args expanded_args=$(eval echo "$variation") # Handle EMPTY special case if [ "$variation" = "EMPTY" ]; then expanded_args="" fi # Test this variation local output if [ -z "$expanded_args" ]; then output=$(run_vpn_switch $func 2>&1 || true) else output=$(run_vpn_switch $func $expanded_args 2>&1 || true) fi # Validate syntax if ! validate_combinator_syntax "$output"; then all_valid=false fail "__${func} (database-aware variation $variation_num: $variation): Invalid syntax" echo " ${COLOR_YELLOW}Output:${COLOR_RESET} $output" fi done < "$db_variations_file" rm -f "$db_variations_file" fi # Report overall pass (individual failures already reported via fail()) if $all_valid; then pass "__${func}: Valid combinator syntax (all variations)" fi } #----------------------------------------------------------------------------- # Terminal Function Tests #----------------------------------------------------------------------------- # test_terminal_function - Test a single terminal function # # Strategy: Execute function and validate output follows terminal function rules # # Arguments: $1 - function name (without _ prefix) # test_terminal_function() { local func="$1" # Get test argument variations local variations variations=$(get_test_argument_variations "$func") # Handle SKIP if [ "$variations" = "SKIP" ]; then echo " ${COLOR_YELLOW}Skipped: _${func} (requires special setup)${COLOR_RESET}" return fi test_header "Terminal: _${func} - validate output rules" test_setup # Populate database with messy realistic state # This ensures functions exercise full code paths (not just error exits) create_messy_realistic_database # Override to cat for output inspection set_function_interpreter "$func" "cat" # Prepare database (same as combinator tests) local test_wg_config="$TEST_BASE_DIR/test-wg.conf" local test_ovpn_config="$TEST_BASE_DIR/test-ovpn.ovpn" # WireGuard test config cat > "$test_wg_config" <<'EOF' [Interface] PrivateKey = fake-key-for-testing Address = 10.0.0.1/32 [Peer] PublicKey = fake-peer-key Endpoint = 192.0.2.1:51820 AllowedIPs = 0.0.0.0/0 EOF # OpenVPN test config cat > "$test_ovpn_config" <<'EOF' remote 192.0.2.10 1194 udp dev tun EOF # Import configs using API set_function_interpreter "wireguard_import1" "sh" set_function_interpreter "openvpn_import1" "sh" run_vpn_switch wireguard import "$test_wg_config" > /dev/null 2>&1 || true run_vpn_switch openvpn import "$test_ovpn_config" > /dev/null 2>&1 || true # Reset to cat for testing set_function_interpreter "$func" "cat" # Test each variation (GENERIC strategy) # Use temp file to avoid subshell (pipe creates subshell, breaks all_valid) local variations_file="${TMPDIR:-/tmp}/variations-$$.txt" echo "$variations" | tr '|' '\n' > "$variations_file" local all_valid=true local variation_num=0 while IFS= read -r variation; do variation_num=$((variation_num + 1)) # Expand variables in variation local expanded_args expanded_args=$(eval echo "$variation") # Handle EMPTY special case if [ "$variation" = "EMPTY" ]; then expanded_args="" fi # Test this variation local output if [ -z "$expanded_args" ]; then output=$(run_vpn_switch $func 2>&1 || true) else output=$(run_vpn_switch $func $expanded_args 2>&1 || true) fi # Validate terminal function rules if ! validate_terminal_function_output "$output" "_${func}"; then all_valid=false fail "_${func} (variation $variation_num: $variation): Invalid terminal function output" fi done < "$variations_file" # Cleanup rm -f "$variations_file" # Test DATABASE-AWARE variations (if available) local db_variations=$(get_test_argument_by_database "$func") if [ "$db_variations" != "SKIP" ] && [ -n "$db_variations" ]; then local db_variations_file="${TMPDIR:-/tmp}/db-variations-$$.txt" echo "$db_variations" | tr '|' '\n' > "$db_variations_file" while IFS= read -r variation; do variation_num=$((variation_num + 1)) # Expand variables in variation local expanded_args expanded_args=$(eval echo "$variation") # Handle EMPTY special case if [ "$variation" = "EMPTY" ]; then expanded_args="" fi # Test this variation local output if [ -z "$expanded_args" ]; then output=$(run_vpn_switch $func 2>&1 || true) else output=$(run_vpn_switch $func $expanded_args 2>&1 || true) fi # Validate terminal function rules if ! validate_terminal_function_output "$output" "_${func}"; then all_valid=false fail "_${func} (database-aware variation $variation_num: $variation): Invalid terminal function output" fi done < "$db_variations_file" rm -f "$db_variations_file" fi # Report overall pass if $all_valid; then pass "_${func}: Valid terminal function output (all variations)" fi } #----------------------------------------------------------------------------- # Batch Combinator Tests #----------------------------------------------------------------------------- # test_batch_combinator_function - Test a single batch combinator function # # Strategy: Prepare database once, iterate 10 times to discover all branches # # Arguments: $1 - function name (without ___ prefix) # test_batch_combinator_function() { local func="$1" # Get test argument variations local variations variations=$(get_test_argument_variations "$func") # Handle SKIP if [ "$variations" = "SKIP" ]; then echo " ${COLOR_YELLOW}Skipped: ___${func} (requires special setup)${COLOR_RESET}" return fi test_header "Batch Combinator: ___${func} - validate syntax with variations" test_setup # Populate database with messy realistic state create_messy_realistic_database # Override to cat for command inspection set_function_interpreter "$func" "cat" # === PREPARE DATABASE USING API === # Create source config files local test_wg_config="$TEST_BASE_DIR/test-wg.conf" local test_ovpn_config="$TEST_BASE_DIR/test-ovpn.ovpn" # WireGuard test config cat > "$test_wg_config" <<'EOF' [Interface] PrivateKey = fake-key-for-testing Address = 10.0.0.1/32 [Peer] PublicKey = fake-peer-key Endpoint = 192.0.2.1:51820 AllowedIPs = 0.0.0.0/0 EOF # OpenVPN test config cat > "$test_ovpn_config" <<'EOF' remote 192.0.2.10 1194 udp dev tun EOF # Import using API set_function_interpreter "wireguard_import1" "sh" set_function_interpreter "openvpn_import1" "sh" run_vpn_switch wireguard import "$test_wg_config" > /dev/null 2>&1 || true run_vpn_switch openvpn import "$test_ovpn_config" > /dev/null 2>&1 || true # Reset to cat for testing set_function_interpreter "$func" "cat" # === TEST EACH VARIATION === # Use temp file to communicate failure from subshell (pipes create subshells) local failure_marker="$TEST_BASE_DIR/.batch_failure_$$" rm -f "$failure_marker" local variation_num=0 echo "$variations" | tr '|' '\n' | while IFS= read -r variation; do variation_num=$((variation_num + 1)) # Expand variables in variation (e.g., $TEST_BASE_DIR) local expanded_args expanded_args=$(eval echo "$variation") # Handle EMPTY special case if [ "$variation" = "EMPTY" ]; then expanded_args="" fi # Test this variation local output if [ -z "$expanded_args" ]; then output=$(run_vpn_switch $func 2>&1 || true) else output=$(run_vpn_switch $func $expanded_args 2>&1 || true) fi # Validate syntax if ! validate_batch_combinator_syntax "$output"; then echo "1" >> "$failure_marker" # Append line for counting in parent shell echo "${COLOR_RED}✗${COLOR_RESET} ___${func} (variation $variation_num: $variation): Invalid syntax" echo " ${COLOR_YELLOW}Output:${COLOR_RESET} $output" fi done # Report result - must call fail() in parent shell since subshell counters are lost if [ -f "$failure_marker" ]; then # Count failures from marker file (one line per failure) local failure_count failure_count=$(wc -l < "$failure_marker") local i=0 while [ "$i" -lt "$failure_count" ]; do TESTS_FAILED=$((TESTS_FAILED + 1)) i=$((i + 1)) done rm -f "$failure_marker" else pass "___${func}: Valid batch combinator syntax (all variations)" fi } #----------------------------------------------------------------------------- # Error Propagation Tests (KEEP_GOING behavior) #----------------------------------------------------------------------------- # test_batch_error_propagation - Test error handling in batch processing # # Tests that errors propagate correctly through recursive batch processing # and that KEEP_GOING mode controls whether execution continues or stops. # # Strategy: # 1. Find a batch combinator that outputs multiple commands # 2. Walk the command tree to find all descendant nodes # 3. Inject a failure at a specific depth/node # 4. Test with KEEP_GOING=0 (fail-fast): execution should stop # 5. Test with KEEP_GOING=1 (continue): execution should continue # test_batch_error_propagation() { test_header "Batch Error Propagation - test _batch2 with failing commands" test_setup # Create a simple test batch file with multiple commands local batch_file="$TEST_BASE_DIR/test_batch.txt" cat > "$batch_file" <&1 || true) # Cleanup: Remove test interpreter override (template provides default) run_vpn_switch unsetenv VPN_SWITCH_INTERPRETER_log1 >/dev/null 2>&1 || true # Verify: Should see "stopped due to error" if echo "$output" | grep -q "stopped due to error"; then pass "KEEP_GOING=0: Batch execution stopped on first error" else fail "KEEP_GOING=0: Expected batch to stop on error" echo " ${COLOR_YELLOW}Output:${COLOR_RESET}" echo "$output" | head -20 fi # Test 2: KEEP_GOING=1 (continue mode) - should process all commands despite errors test_header " Sub-test: KEEP_GOING=1 (continue mode)" # Create a batch file where middle command will fail cat > "$batch_file" <&1 || true) unset VPN_SWITCH_BATCH_KEEP_GOING unset VPN_SWITCH_TRACE_FILE # Verify: trace shows the Summary line (proves batch ran to completion) if grep -q '_batch2: Summary:' "$trace_file"; then pass "KEEP_GOING=1: Batch execution completed despite errors" else fail "KEEP_GOING=1: Expected batch to complete despite errors" echo " ${COLOR_YELLOW}Trace:${COLOR_RESET}" grep '_batch2:' "$trace_file" 2>/dev/null | head -10 fi # Verify: Should have processed all 3 commands (not stopped at command 2) local execute_count=$(grep -c '_batch2: Executing:' "$trace_file" || true) if [ "$execute_count" -eq 3 ]; then pass "KEEP_GOING=1: All commands were executed (count=$execute_count)" else fail "KEEP_GOING=1: Expected 3 commands executed, got $execute_count" grep '_batch2: Executing:' "$trace_file" 2>/dev/null fi rm -f "$trace_file" # Cleanup rm -f "$batch_file" } # test_recursive_batch_error_propagation - Test error propagation through recursive batch processing # # Tests the scenario: line1 → _batch2 → line2 → _batch2 → line3 (ERROR) → should not reach line4 # # This tests that when batch processing recurses (a batch file contains a batch command), # errors propagate correctly up the call stack. # test_recursive_batch_error_propagation() { test_header "Recursive Batch Error Propagation - test chained _batch2 calls" test_setup # Create batch files that reference each other local batch1="$TEST_BASE_DIR/batch1.txt" local batch2="$TEST_BASE_DIR/batch2.txt" local batch3="$TEST_BASE_DIR/batch3.txt" # batch1 calls batch2 cat > "$batch1" < "$batch2" < "$batch3" <&1 || true) # Restore error1 interpreter run_vpn_switch unsetenv VPN_SWITCH_INTERPRETER_error1 >/dev/null 2>&1 || true # Verify: Should NOT see "Batch 3 - command 3" (execution stopped) if ! echo "$output" | grep -q "Batch 3 - command 3"; then pass "Recursive fail-fast: Command 3 in batch 3 was not executed" else fail "Recursive fail-fast: Command 3 should not have been executed" echo " ${COLOR_YELLOW}Output:${COLOR_RESET}" echo "$output" | head -20 fi # Verify: Should NOT see "Batch 2 - after" (error propagated up) if ! echo "$output" | grep -q "Batch 2 - after"; then pass "Recursive fail-fast: Batch 2 stopped after error in batch 3" else fail "Recursive fail-fast: Batch 2 should have stopped" fi # Verify: Should NOT see "Batch 1 - after" (error propagated to top) if ! echo "$output" | grep -q "Batch 1 - after"; then pass "Recursive fail-fast: Batch 1 stopped after error in nested batch" else fail "Recursive fail-fast: Batch 1 should have stopped" fi # Test with KEEP_GOING=1 (continue mode) output=$(VPN_SWITCH_BATCH_KEEP_GOING=1 run_vpn_switch batch "$batch1" 2>&1 || true) # Verify: Should see all "after" messages (execution continued despite error) if echo "$output" | grep -q "Batch 2 - after"; then pass "Recursive continue: Batch 2 continued after error in batch 3" else fail "Recursive continue: Batch 2 should have continued" fi if echo "$output" | grep -q "Batch 1 - after"; then pass "Recursive continue: Batch 1 continued after error in nested batch" else fail "Recursive continue: Batch 1 should have continued" fi # Cleanup rm -f "$batch1" "$batch2" "$batch3" } # test_batch_keep_going_with_realistic_dump - Test KEEP_GOING with realistic dump file # # Tests _batch2 with a realistic dump-style batch file (like what dump/restore use). # Uses precise position-based validation to verify KEEP_GOING behavior. # # Strategy: # 1. Generate realistic batch file from inspect command output # 2. Randomly inject failures at specific positions # 3. Test KEEP_GOING=0: Verify commands after first failure NOT executed # 4. Test KEEP_GOING=1: Verify ALL commands executed # test_batch_keep_going_with_realistic_dump() { test_header "Batch KEEP_GOING with Realistic Dump File" test_setup # Populate database for realistic commands create_messy_realistic_database # Create a realistic batch file using inspect output local batch_file="$TEST_BASE_DIR/realistic_batch.txt" cat > "$batch_file" <&1 || true) # Count how many "# Executing:" lines appear (indicates command was attempted) local ff_executed=$(echo "$ff_output" | grep -c "# Executing:" || true) # Should execute up to and including the failure position, then stop if [ "$ff_executed" -le "$failure_position" ]; then pass "KEEP_GOING=0: Stopped at or before position $failure_position (executed $ff_executed commands)" else fail "KEEP_GOING=0: Executed $ff_executed commands, expected ≤ $failure_position" echo " ${COLOR_YELLOW}Output (first 15 lines):${COLOR_RESET}" echo "$ff_output" | head -15 fi # Verify "stopped due to error" message appears if echo "$ff_output" | grep -qE "stopped due to error|fail-fast mode"; then pass "KEEP_GOING=0: Error stop message present" else echo " ${COLOR_YELLOW}Note:${COLOR_RESET} No explicit error stop message" fi # Test 2: KEEP_GOING=1 (continue mode) test_header " Sub-test: KEEP_GOING=1 at position $failure_position/$total_commands" set_function_interpreter "$mangled_name" "$failing_interp" local cont_trace=$(mktemp) export VPN_SWITCH_BATCH_KEEP_GOING=1 export VPN_SWITCH_TRACE_FILE="$cont_trace" local cont_output=$(run_vpn_switch batch "$batch_file" 2>&1 || true) unset VPN_SWITCH_BATCH_KEEP_GOING unset VPN_SWITCH_TRACE_FILE # Count executed commands from trace (fd79dc8 moved batch diagnostics off stdout) local cont_executed=$(grep -c '_batch2: Executing:' "$cont_trace" || true) # Should execute ALL commands despite the failure if [ "$cont_executed" -eq "$total_commands" ]; then pass "KEEP_GOING=1: All commands executed ($cont_executed/$total_commands)" else fail "KEEP_GOING=1: Only $cont_executed/$total_commands commands executed" echo " ${COLOR_YELLOW}Trace (first 15 lines):${COLOR_RESET}" grep '_batch2:' "$cont_trace" 2>/dev/null | head -15 fi # Verify completion message appears in trace if grep -q '_batch2: Summary:' "$cont_trace"; then pass "KEEP_GOING=1: Completion message present" else echo " ${COLOR_YELLOW}Note:${COLOR_RESET} No explicit completion message" fi rm -f "$cont_trace" # Verify at least one failure was recorded if echo "$cont_output" | grep -qE "# ✗ Failed|failed"; then pass "KEEP_GOING=1: Failure markers present in output" else echo " ${COLOR_YELLOW}Note:${COLOR_RESET} No failure markers found" fi # Cleanup: Remove test interpreter override (template provides default) run_vpn_switch unsetenv "VPN_SWITCH_INTERPRETER_${mangled_name}" >/dev/null 2>&1 || true # Cleanup batch file rm -f "$batch_file" } # test_batch_combinator_transitive_hull - Implementation-independent error injection # # Tests batch combinators by walking their ACTUAL command trees (transitive hull) # and injecting failures at different nodes. This is implementation-independent: # if a batch combinator's implementation changes, the test automatically adapts. # # Strategy: # 1. Walk the batch combinator's actual command tree to discover all nodes # 2. Inject failure at a node (round-robin: pick middle node) # 3. Test KEEP_GOING=0: verify execution stops # 4. Test KEEP_GOING=1: verify execution continues # # Arguments: $1 - function name (without ___ prefix, e.g., "stop0", "list0") # test_batch_combinator_transitive_hull() { local func="$1" test_header "Batch Transitive Hull: ___${func} - walkthrough-based error injection" test_setup # Populate database for realistic command trees create_messy_realistic_database # Extract command from function name (remove arity suffix) # E.g., "stop0" -> "stop", "list0" -> "list" local test_cmd=$(echo "$func" | sed 's/[0-9]*$//') test_header " Sub-test: Walk command tree for '___${func}' (command: $test_cmd)" # Walk the command tree and collect all nodes collect_tree_nodes "$test_cmd" if [ "$TREE_NODE_COUNT" -eq 0 ]; then echo " ${COLOR_YELLOW}Skipped:${COLOR_RESET} Command '$test_cmd' produced no tree nodes" return 0 fi pass "Command tree collected: $TREE_NODE_COUNT nodes in transitive hull" # Extract all non-terminal nodes (these are injectable) local injectable_nodes="" local injectable_count=0 # Parse TREE_NODES to find injectable nodes echo "$TREE_NODES" | while IFS='|' read -r depth type func cmd output status; do # Skip empty lines [ -z "$type" ] && continue # Skip terminal nodes (they don't call other vpn-switch commands) [ "$type" = "terminal" ] && continue [ "$type" = "unresolved" ] && continue # This is a combinator or batch node - can inject failure here echo "$func" done > /tmp/injectable_nodes.$$ injectable_count=$(wc -l < /tmp/injectable_nodes.$$ | tr -d ' ') if [ "$injectable_count" -eq 0 ]; then echo " ${COLOR_YELLOW}Skipped:${COLOR_RESET} No injectable nodes found (all terminal)" rm -f /tmp/injectable_nodes.$$ return 0 fi pass "Found $injectable_count injectable nodes (combinators/batches) in tree" # Select a node to inject failure (round-robin: pick middle node) local target_index=$(( (injectable_count + 1) / 2 )) local target_func=$(sed -n "${target_index}p" /tmp/injectable_nodes.$$) rm -f /tmp/injectable_nodes.$$ if [ -z "$target_func" ]; then echo " ${COLOR_YELLOW}Skipped:${COLOR_RESET} Could not select target node for injection" return 0 fi test_header " Sub-test: Inject failure at node '$target_func' (node $target_index/$injectable_count)" # Strip underscores for interpreter variable name local mangled=$(echo "$target_func" | sed 's/^_*//') # Create type-appropriate failing interpreter local failing_interpreter=$(create_failing_interpreter "$target_func") # Test 1: KEEP_GOING=0 (fail-fast mode) set_function_interpreter "$mangled" "$failing_interpreter" local ff_output=$(VPN_SWITCH_BATCH_KEEP_GOING=0 run_vpn_switch $test_cmd 2>&1 || true) local ff_exitcode=$? # Test 2: KEEP_GOING=1 (continue mode) set_function_interpreter "$mangled" "$failing_interpreter" local cont_output=$(VPN_SWITCH_BATCH_KEEP_GOING=1 run_vpn_switch $test_cmd 2>&1 || true) local cont_exitcode=$? # Cleanup: Remove the test interpreter override (template provides default) run_vpn_switch unsetenv "VPN_SWITCH_INTERPRETER_${mangled}" >/dev/null 2>&1 || true # Analyze results: fail-fast should stop earlier local ff_lines=$(echo "$ff_output" | wc -l) local cont_lines=$(echo "$cont_output" | wc -l) # Heuristic 1: Continue mode should produce at least as much output if [ "$cont_lines" -ge "$ff_lines" ]; then pass "KEEP_GOING affects output: fail-fast=$ff_lines lines, continue=$cont_lines lines" else echo " ${COLOR_YELLOW}Note:${COLOR_RESET} Continue mode produced less output ($cont_lines vs $ff_lines lines)" fi # Heuristic 2: Check for error indicators in fail-fast mode if echo "$ff_output" | grep -qE "stopped due to error|Failed|✗"; then pass "KEEP_GOING=0: Execution stopped with error indicator" else echo " ${COLOR_YELLOW}Note:${COLOR_RESET} No clear error stop indicator in fail-fast mode" fi # Heuristic 3: Check for completion in continue mode if echo "$cont_output" | grep -qE "complete|Summary"; then pass "KEEP_GOING=1: Execution completed despite injected failure" else echo " ${COLOR_YELLOW}Note:${COLOR_RESET} No clear completion indicator in continue mode" fi } # pick_random_lines - Pick N random lines from input (for KEEP_GOING tests) # # Arguments: # $1 - multi-line string (filtered output) # $2 - number of lines to pick (optional, default: random between 1 and total) # Output: Selected lines (one per line) # pick_random_lines() { local input="${1:-}" local count="${2:-}" # Count total lines local total_lines=$(echo "$input" | wc -l | tr -d ' ') if [ "$total_lines" -eq 0 ]; then return 1 fi # If count not specified, pick random number between 1 and total_lines if [ -z "$count" ]; then count=$(awk -v max="$total_lines" 'BEGIN { srand(); print int(rand() * max) + 1 }') # Fallback to 1 if awk fails count="${count:-1}" fi # Ensure count is at least 1 and at most total_lines if [ "${count:-0}" -lt 1 ]; then count=1 fi if [ "${count:-0}" -gt "$total_lines" ]; then count="$total_lines" fi # Pick random lines using shuf (or fallback to awk if shuf not available) if command -v shuf >/dev/null 2>&1; then echo "$input" | shuf | head -n "${count:-1}" else # Fallback: use awk to shuffle lines echo "$input" | awk -v count="${count:-1}" ' BEGIN { srand() } { lines[NR] = $0 } END { # Fisher-Yates shuffle for (i = 1; i <= NR; i++) { j = int(rand() * (NR - i + 1)) + i temp = lines[i] lines[i] = lines[j] lines[j] = temp } # Output first count lines for (i = 1; i <= count && i <= NR; i++) { print lines[i] } } ' fi } # test_batch_combinator_keep_going_precise - Precise KEEP_GOING validation # # Tests batch combinators with precise architectural validation: # - For KEEP_GOING=1: All commands must execute (even after failures) # - For KEEP_GOING=0: Commands after first failure must NOT execute # # Strategy: # 1. Capture batch output with cat interpreter # 2. Randomly select command lines to inject failures # 3. Execute batch and validate architectural properties # # Arguments: $1 - function name (without ___ prefix, e.g., "inspect0", "stop0") # test_batch_combinator_keep_going_precise() { local func="$1" # Check if this function should be excluded case "$func" in init1) # Skip: complex initialization logic that modifies environment echo " ${COLOR_YELLOW}Skipped:${COLOR_RESET} ___${func} (complex initialization logic)" return 0 ;; phases_sync1|phases_sync0) # Skip: phase synchronization requires specific template setup echo " ${COLOR_YELLOW}Skipped:${COLOR_RESET} ___${func} (requires phase template setup)" return 0 ;; esac test_header "Batch KEEP_GOING Precise: ___${func}" test_setup # Populate database for realistic command trees create_messy_realistic_database # Extract command from function name (remove arity suffix) local test_cmd=$(echo "$func" | sed 's/[0-9]*$//') # Step 1: Capture batch combinator output with cat interpreter # Note: Use arity-specific interpreter name (e.g., stop0 not stop) local output=$(run_vpn_switch setenv "VPN_SWITCH_INTERPRETER_${func}" "cat" && \ run_vpn_switch $test_cmd 2>&1 && \ run_vpn_switch unsetenv "VPN_SWITCH_INTERPRETER_${func}") # Validate we got output local line_count=$(echo "$output" | wc -l | tr -d ' ') if [ "$line_count" -eq 0 ]; then echo " ${COLOR_YELLOW}Skipped:${COLOR_RESET} No output from ___${func}" return 0 fi # Step 2: Filter output - remove comments and empty lines local filtered_output=$(echo "$output" | grep -v '^[[:space:]]*#' | grep -v '^[[:space:]]*$') local filtered_count=$(echo "$filtered_output" | wc -l | tr -d ' ') if [ "$filtered_count" -eq 0 ]; then echo " ${COLOR_YELLOW}Skipped:${COLOR_RESET} No command lines after filtering" return 0 fi pass "Captured $filtered_count command lines from ___${func}" # Step 3: Pick random lines (arbitrary number: 1 to all) local random_lines=$(pick_random_lines "$filtered_output") local selected_count=$(echo "$random_lines" | wc -l | tr -d ' ') if [ "$selected_count" -eq 0 ]; then echo " ${COLOR_YELLOW}Skipped:${COLOR_RESET} Failed to pick random lines" return 0 fi pass "Selected $selected_count random line(s) for failure injection" # Step 4: Build lists of selected and non-selected lines echo "$random_lines" > "$TEST_BASE_DIR/selected_lines.txt" local non_selected_lines=$(echo "$filtered_output" | grep -Fvxf "$TEST_BASE_DIR/selected_lines.txt") local non_selected_count=$(echo "$non_selected_lines" | wc -l | tr -d ' ') # Step 5: Inject failing interpreters for selected lines # Save to temp file first to avoid subshell issues with pipe rm -f "$TEST_BASE_DIR/injected_functions.txt" while IFS= read -r line; do local cmd=$(extract_command_from_output "$line") [ -z "$cmd" ] && continue local func_name=$(resolve_function "$cmd") [ -z "$func_name" ] && continue local failing_interp=$(create_failing_interpreter "$func_name") local mangled_name=$(echo "$func_name" | sed 's/^_*//') set_function_interpreter "$mangled_name" "$failing_interp" echo "$mangled_name" >> "$TEST_BASE_DIR/injected_functions.txt" done <> "$TEST_BASE_DIR/cat_functions.txt" done <&1 || true) # Step 8: Analyze execution - count which functions were executed local executed_failures=0 local executed_cats=0 # Count failures that executed if [ -f "$TEST_BASE_DIR/injected_functions.txt" ]; then rm -f "$TEST_BASE_DIR/fail_executed.txt" while IFS= read -r mangled; do # Check if this function's failure marker appears in output if echo "$exec_output" | grep -qE "# ✗.*exit.*1|Failed|error"; then echo "FAIL_EXEC" >> "$TEST_BASE_DIR/fail_executed.txt" fi done <> "$TEST_BASE_DIR/cat_executed.txt" fi done < "$TEST_BASE_DIR/cat_functions.txt" if [ -f "$TEST_BASE_DIR/cat_executed.txt" ]; then executed_cats=$(wc -l < "$TEST_BASE_DIR/cat_executed.txt" | tr -d ' ') fi fi local total_executed=$((executed_failures + executed_cats)) local total_expected=$((selected_count + non_selected_count)) # Step 9: Validate KEEP_GOING=1 behavior (default for most batch combinators) # All commands should be attempted despite failures if [ "$total_executed" -eq "$total_expected" ]; then pass "KEEP_GOING=1: All commands attempted ($total_executed/$total_expected)" else echo " ${COLOR_YELLOW}Note:${COLOR_RESET} Not all commands attempted ($total_executed/$total_expected)" echo " ${COLOR_YELLOW}Note:${COLOR_RESET} This may indicate KEEP_GOING=0 or execution stopped early" fi # Step 10: Cleanup - restore all interpreters if [ -f "$TEST_BASE_DIR/injected_functions.txt" ]; then while IFS= read -r mangled_name; do run_vpn_switch unsetenv "VPN_SWITCH_INTERPRETER_${mangled_name}" >/dev/null 2>&1 || true done < "$TEST_BASE_DIR/injected_functions.txt" fi if [ -f "$TEST_BASE_DIR/cat_functions.txt" ]; then while IFS= read -r mangled_name; do run_vpn_switch unsetenv "VPN_SWITCH_INTERPRETER_${mangled_name}" >/dev/null 2>&1 || true done < "$TEST_BASE_DIR/cat_functions.txt" fi } #----------------------------------------------------------------------------- # Static Analysis: Architecture Violation Scanner #----------------------------------------------------------------------------- test_architecture_violation_scanner() { test_header "Architecture Violation Scanner" # Check if scanner exists local scanner_script="scripts/architecture-violation-scanner.sh" if [ ! -f "$scanner_script" ]; then echo " ${COLOR_YELLOW}Skipped:${COLOR_RESET} Scanner not found at $scanner_script" return 0 fi # Run scanner (it exits 1 if violations found, 0 if clean) local scanner_output=$(sh "$scanner_script" 2>&1) local scanner_exit=$? # For now, we expect violations (87+ direct error() calls documented) # Scanner should complete successfully (exit 0 or 1, not crash) if [ "$scanner_exit" -ne 0 ] && [ "$scanner_exit" -ne 1 ]; then fail "Scanner crashed with exit code $scanner_exit" echo "$scanner_output" | head -20 return 1 fi pass "Scanner executed successfully (exit code: $scanner_exit)" # Extract violation counts from summary local total_violations=$(echo "$scanner_output" | grep "^Total violations found:" | sed 's/.*: //' | sed 's/\x1b\[[0-9;]*m//g') local critical=$(echo "$scanner_output" | grep "^ CRITICAL:" | sed 's/.*: //' | sed 's/ .*//' | sed 's/\x1b\[[0-9;]*m//g') local high=$(echo "$scanner_output" | grep "^ HIGH:" | sed 's/.*: //' | sed 's/ .*//' | sed 's/\x1b\[[0-9;]*m//g') local misnamed=$(echo "$scanner_output" | grep "Misnamed functions" | sed 's/.*: //' | sed 's/\x1b\[[0-9;]*m//g') local state_mod=$(echo "$scanner_output" | grep "State modification" | sed 's/.*: //' | sed 's/\x1b\[[0-9;]*m//g') # Report findings if [ -n "$total_violations" ] && [ "$total_violations" != "0" ]; then echo " ${COLOR_YELLOW}Found:${COLOR_RESET} $total_violations total violations" [ -n "$critical" ] && [ "$critical" != "0" ] && \ echo " ${COLOR_RED}CRITICAL:${COLOR_RESET} $critical (misnamed functions: ${misnamed:-0}, state mod: ${state_mod:-0})" [ -n "$high" ] && [ "$high" != "0" ] && \ echo " ${COLOR_YELLOW}HIGH:${COLOR_RESET} $high" pass "Scanner detection working (violations found as expected)" else pass "No violations found (codebase is clean!)" fi # Test that scanner detects MODIFY_* variables (once we start using them) if echo "$scanner_output" | grep -q "MODIFY_"; then pass "Scanner detects MODIFY_* variable usage" else echo " ${COLOR_BLUE}Note:${COLOR_RESET} No MODIFY_* variables in use yet (refactoring pending)" fi } #----------------------------------------------------------------------------- # Sync Feature Architecture Tests #----------------------------------------------------------------------------- # test_sync_metadata_registration - Verify new sync functions are registered # correctly across all metadata lists (TERMINAL/BATCH_COMBINATOR/ANCHOR). # Catches regressions where someone adds/renames a function but forgets to # run 'gmake metadata'. test_sync_metadata_registration() { test_header "Sync: metadata registration for new functions" # _version_sync0, _env_sync0, _version0 are terminals (single underscore) for func in _env_sync0 _version_sync0 _version0; do if echo "$TERMINAL_FUNCTIONS" | grep -qw "$func"; then pass "$func registered in TERMINAL_FUNCTIONS" else fail "$func missing from TERMINAL_FUNCTIONS (run 'gmake metadata')" fi if echo "$ANCHOR_FUNCTIONS" | grep -qw "$func"; then pass "$func registered in ANCHOR_FUNCTIONS" else fail "$func missing from ANCHOR_FUNCTIONS" fi done # ___sync0 is a batch combinator (triple underscore) if echo "$BATCH_COMBINATOR_FUNCTIONS" | grep -qw '___sync0'; then pass "___sync0 registered in BATCH_COMBINATOR_FUNCTIONS" else fail "___sync0 missing from BATCH_COMBINATOR_FUNCTIONS (run 'gmake metadata')" fi if echo "$ANCHOR_FUNCTIONS" | grep -qw '___sync0'; then pass "___sync0 registered in ANCHOR_FUNCTIONS" else fail "___sync0 missing from ANCHOR_FUNCTIONS" fi } # test_sync_batch_combinator_output_shape - Verify ___sync0 emits exactly # four valid vpn-switch subcommands with the literal context variable # (not an expanded path). This is the combinator-discipline rule. test_sync_batch_combinator_output_shape() { test_header "Sync: ___sync0 emits four literal vpn-switch subcommands" test_setup # Override sync interpreter to cat for inspection VPN_SWITCH_BASE="$TEST_DIR" "$TEST_SCRIPT" setenv VPN_SWITCH_INTERPRETER_sync cat > /dev/null 2>&1 local output output=$(VPN_SWITCH_BASE="$TEST_DIR" "$TEST_SCRIPT" sync 2>&1) # Filter to non-comment, non-empty lines local command_lines command_lines=$(printf '%s\n' "$output" | grep -v '^[[:space:]]*#' | grep -v '^[[:space:]]*$') local count count=$(printf '%s\n' "$command_lines" | wc -l | tr -d ' ') if [ "$count" = "4" ]; then pass "___sync0 emits exactly four command lines" else fail "___sync0 emitted $count command lines (expected 4): $command_lines" fi # Each line must start with literal "$VPN_SWITCH_CONTEXT_SCRIPT" local non_literal non_literal=$(printf '%s\n' "$command_lines" | grep -v '^"\$VPN_SWITCH_CONTEXT_SCRIPT"' || true) if [ -z "$non_literal" ]; then pass "___sync0 uses literal \$VPN_SWITCH_CONTEXT_SCRIPT (no path expansion)" else fail "___sync0 expanded paths instead of literal variable: $non_literal" fi # Must contain exactly the four expected subcommands for subcmd in 'database init' 'phases sync' 'env sync' 'version sync'; do if printf '%s\n' "$command_lines" | grep -q " $subcmd\$"; then pass "___sync0 emits '$subcmd'" else fail "___sync0 missing '$subcmd'" fi done } # test_bootstrap_emits_version_sync - Verify ___bootstrap2 was updated to # auto-stamp .version. Static check against include/database.sh, since # changing this is a deliberate architectural decision worth guarding. test_profile_template_bijection() { test_header "Profiles and templates: bijective (no dead entries, no unshipped pins)" local viol="" v t for prof in template/environment/VPN_SWITCH_PROFILE_MINIMAL template/environment/VPN_SWITCH_PROFILE_ALL; do for v in $(head -1 "$prof"); do [ -f "template/environment/$v" ] || viol="$viol $(basename "$prof"):$v(no-template)" done done for t in template/environment/VPN_SWITCH_*; do t=$(basename "$t") case "$t" in VPN_SWITCH_PROFILE_*|VPN_SWITCH_LIBDIR) continue ;; esac grep -qw "$t" template/environment/VPN_SWITCH_PROFILE_ALL || viol="$viol ALL-misses:$t" done viol=$(printf '%s\n' $viol | sort -u | tr '\n' ' ') if [ -z "${viol# }" ]; then pass "every profile entry has a template, every template is in the all profile" else fail "profiles and templates diverge:$viol" fi } test_pin_resolved_from_store() { test_header "Engine: the pin of a call is read from the environment store, no runner keeps its environment in step by hand" local body body=$(awk '/^lookup_interpreter\(\)/,/^}/' include/engine.sh) if printf '%s\n' "$body" | grep -q 'build_env_args'; then pass "lookup_interpreter resolves the pin through build_env_args (the cache file, else the layered scan)" else fail "lookup_interpreter does not read the environment store" fi if printf '%s\n' "$body" | grep -q 'unset VPN_SWITCH_CACHE_ENV_ARGS'; then pass "lookup_interpreter sets the inherited cache string aside" else fail "lookup_interpreter trusts the inherited cache string" fi if ! grep -q 'env_reload\|batch_env_refresh' include/*.sh vpn-switch-binary.sh; then pass "no runner re-exports the environment after a line (no env_reload, no batch_env_refresh)" else fail "a runner keeps its process environment in step by hand: $(grep -l 'env_reload\|batch_env_refresh' include/*.sh vpn-switch-binary.sh | tr '\n' ' ')" fi } test_bootstrap_emits_version_sync() { test_header "Sync: ___bootstrap2 emits 'version sync' after init" if [ ! -f include/database.sh ]; then fail "include/database.sh not found" return fi # ___bootstrap2 body must contain a 'version sync' dispatch. # The variable and quotes are escaped because they're inside an echo string # that runs at execution time, so we grep for the escaped form. if awk '/^___bootstrap2\(\)/,/^}/' include/database.sh | grep -q '\\"\\\$VPN_SWITCH_CONTEXT_SCRIPT\\" version sync'; then pass "___bootstrap2 dispatches 'version sync' subcommand" else fail "___bootstrap2 missing 'version sync' dispatch (bootstrap won't auto-stamp .version)" fi # Bootstrap dispatch must export interpreters directly (not just via # CACHE_ENV_ARGS) so 'version sync' resolves its interpreter correctly. # 'version sync' is NOT in COMMANDS_WITHOUT_DATABASE so CACHE_ENV_ARGS # is not eval'd before lookup_interpreter — regression guard. local version_sync_line version_sync_line=$(awk '/^___bootstrap2\(\)/,/^}/' include/database.sh | grep 'version sync') if echo "$version_sync_line" | grep -q 'VPN_SWITCH_TERMINAL_INTERPRETER=/bin/sh'; then pass "Bootstrap 'version sync' exports TERMINAL_INTERPRETER directly" else fail "Bootstrap 'version sync' missing direct TERMINAL_INTERPRETER export" fi } #----------------------------------------------------------------------------- # Read-Only Database Tests #----------------------------------------------------------------------------- # READONLY_SKIP_FUNCTIONS - Functions that legitimately write at generation time # # These functions are excluded from read-only database testing because they # MUST write to the database during generation (not just execution time): # # - cat1: Captures stdin and writes to file immediately (can't defer) # - batch2: Executes batch files which may write # - bootstrap2: Creates database structure # - init1: Initializes database directories # - database_init0: Initializes database # - environment_init1: Initializes environment files # - environment_cache1: Caches environment to disk # - phases_copy0: Copies phase templates to database # READONLY_SKIP_FUNCTIONS="cat1 batch2 bootstrap2 init1 database_init0 environment_init1 environment_cache1 phases_copy0" # is_readonly_skip_function - Check if function should skip read-only test # # Arguments: $1 - function name (without prefix) # Returns: 0 if should skip, 1 otherwise # is_readonly_skip_function() { local func="$1" local skip_func for skip_func in $READONLY_SKIP_FUNCTIONS; do if [ "$func" = "$skip_func" ]; then return 0 fi done return 1 } # make_database_readonly - Make database read-only except .tmp and .log # # Arguments: $1 - database directory (TEST_DIR) # # Makes all files and directories read-only using chmod -w recursively. # Preserves write permissions for .tmp and .log directories (needed for # execution artifacts that don't violate the architecture). # make_database_readonly() { local basedir="$1" # First, make everything read-only chmod -R a-w "$basedir" # Restore write permissions for .tmp and .log (execution artifacts) if [ -d "$basedir/.tmp" ]; then chmod -R u+w "$basedir/.tmp" fi if [ -d "$basedir/.log" ]; then chmod -R u+w "$basedir/.log" fi # Create .tmp and .log if they don't exist (with write permissions) mkdir -p "$basedir/.tmp" 2>/dev/null || true mkdir -p "$basedir/.log" 2>/dev/null || true chmod -R u+w "$basedir/.tmp" 2>/dev/null || true chmod -R u+w "$basedir/.log" 2>/dev/null || true } # restore_database_writable - Restore writable permissions to database # # Arguments: $1 - database directory (TEST_DIR) # restore_database_writable() { local basedir="$1" chmod -R u+w "$basedir" 2>/dev/null || true } # test_readonly_function - Test a function on read-only database # # Arguments: # $1 - function name (without prefix, e.g., "wireguard_list") # $2 - function type ("terminal", "combinator", "batch") # # Strategy: # 1. Setup database with messy state # 2. Run function on WRITABLE database, capture output # 3. Make database read-only # 4. Run function on READ-ONLY database, capture output + stderr # 5. Verify: no permission errors in stderr # 6. Verify: output matches writable run (optional, reveals state modification) # test_readonly_function() { local func="$1" local func_type="$2" # Get test argument variations local variations variations=$(get_test_argument_variations "$func") # Handle SKIP from variations if [ "$variations" = "SKIP" ]; then return 0 # Silently skip (already handled in main tests) fi # Check if function should skip read-only test if is_readonly_skip_function "$func"; then echo " ${COLOR_YELLOW}Skipped:${COLOR_RESET} ${func} (legitimately writes at generation time)" return 0 fi # Get prefix for function type local prefix case "$func_type" in terminal) prefix="_" ;; combinator) prefix="__" ;; batch) prefix="___" ;; esac # Prepare test configs in shared location local test_wg_config="$TEST_BASE_DIR/test-wg.conf" local test_ovpn_config="$TEST_BASE_DIR/test-ovpn.ovpn" if [ ! -f "$test_wg_config" ]; then cat > "$test_wg_config" <<'EOF' [Interface] PrivateKey = fake-key-for-testing Address = 10.0.0.1/32 [Peer] PublicKey = fake-peer-key Endpoint = 192.0.2.1:51820 AllowedIPs = 0.0.0.0/0 EOF fi if [ ! -f "$test_ovpn_config" ]; then cat > "$test_ovpn_config" <<'EOF' remote 192.0.2.10 1194 udp dev tun EOF fi # Override to cat for output inspection set_function_interpreter "$func" "cat" # Import configs (need writable database for this) set_function_interpreter "wireguard_import1" "sh" set_function_interpreter "openvpn_import1" "sh" run_vpn_switch wireguard import "$test_wg_config" > /dev/null 2>&1 || true run_vpn_switch openvpn import "$test_ovpn_config" > /dev/null 2>&1 || true # Reset to cat for testing set_function_interpreter "$func" "cat" # Regenerate cache to include new interpreter overrides # Without this, CACHE_ENV_ARGS from bootstrap would be used, missing our overrides run_vpn_switch environment cache on > /dev/null 2>&1 || true # Use first variation only (sufficient for read-only test) local first_variation first_variation=$(echo "$variations" | tr '|' '\n' | head -1) # Expand variables local expanded_args expanded_args=$(eval echo "$first_variation") # Handle EMPTY special case if [ "$first_variation" = "EMPTY" ]; then expanded_args="" fi # Phase 1: Run on WRITABLE database local writable_output writable_stderr local writable_tmp="${TMPDIR:-/tmp}/readonly-writable-$$.txt" local writable_err="${TMPDIR:-/tmp}/readonly-writable-err-$$.txt" if [ -z "$expanded_args" ]; then run_vpn_switch $func > "$writable_tmp" 2> "$writable_err" || true else run_vpn_switch $func $expanded_args > "$writable_tmp" 2> "$writable_err" || true fi writable_output=$(cat "$writable_tmp") # Phase 2: Make database read-only make_database_readonly "$TEST_DIR" # Phase 3: Run on READ-ONLY database local readonly_output readonly_stderr local readonly_tmp="${TMPDIR:-/tmp}/readonly-test-$$.txt" local readonly_err="${TMPDIR:-/tmp}/readonly-test-err-$$.txt" if [ -z "$expanded_args" ]; then run_vpn_switch $func > "$readonly_tmp" 2> "$readonly_err" || true else run_vpn_switch $func $expanded_args > "$readonly_tmp" 2> "$readonly_err" || true fi readonly_output=$(cat "$readonly_tmp") readonly_stderr=$(cat "$readonly_err") # Phase 4: Restore writable (for cleanup) restore_database_writable "$TEST_DIR" # Phase 5: Check for permission errors local has_violation=false if echo "$readonly_stderr" | grep -qiE "permission denied|read-only|cannot create|cannot open.*for writing"; then has_violation=true fail "${prefix}${func}: Permission error on read-only database" echo " ${COLOR_RED}Stderr:${COLOR_RESET} $(echo "$readonly_stderr" | head -3)" fi # Phase 6: Compare outputs - if equal, test passed (writable output already validated) # If different, check compliance and warn if [ "$writable_output" = "$readonly_output" ]; then : # Outputs match - good, nothing more to check else # Outputs differ - validate compliance based on function type # Note: Functions output literal "$VPN_SWITCH_CONTEXT_SCRIPT" (not expanded) # Filter out CACHE_ENV_ARGS which contains embedded interpreter definitions with the pattern local filtered_output filtered_output=$(echo "$readonly_output" | grep -v 'CACHE_ENV_ARGS') local is_compliant=true case "$func_type" in terminal) # Terminal functions must NOT output vpn-switch commands if echo "$filtered_output" | grep -q '"\$VPN_SWITCH_CONTEXT_SCRIPT"'; then is_compliant=false has_violation=true fail "${prefix}${func}: Terminal function must not output vpn-switch commands" fi ;; combinator) # Combinator must output exactly one vpn-switch command (if any output) if [ -n "$filtered_output" ] && ! echo "$filtered_output" | grep -q '"\$VPN_SWITCH_CONTEXT_SCRIPT"'; then is_compliant=false has_violation=true fail "${prefix}${func}: Combinator must output vpn-switch command" fi ;; batch) # Batch combinators: each non-comment line should be a vpn-switch command if [ -n "$filtered_output" ]; then local non_comment_lines=$(echo "$filtered_output" | grep -v '^#' | grep -v '^$') if [ -n "$non_comment_lines" ] && ! echo "$non_comment_lines" | grep -q '"\$VPN_SWITCH_CONTEXT_SCRIPT"'; then is_compliant=false has_violation=true fail "${prefix}${func}: Batch combinator must output vpn-switch commands" fi fi ;; esac # Warn about output difference if still compliant if $is_compliant; then echo " ${COLOR_YELLOW}Warning:${COLOR_RESET} ${func}: output differs between writable/readonly (but compliant)" fi fi # Cleanup temp files rm -f "$writable_tmp" "$writable_err" "$readonly_tmp" "$readonly_err" # Cleanup interpreter overrides to avoid polluting subsequent tests run_vpn_switch unsetenv "VPN_SWITCH_INTERPRETER_${func}" > /dev/null 2>&1 || true run_vpn_switch unsetenv "VPN_SWITCH_INTERPRETER_wireguard_import1" > /dev/null 2>&1 || true run_vpn_switch unsetenv "VPN_SWITCH_INTERPRETER_openvpn_import1" > /dev/null 2>&1 || true run_vpn_switch environment cache on > /dev/null 2>&1 || true if ! $has_violation; then pass "${prefix}${func}: No permission errors on read-only database" fi } # test_readonly_database - Run read-only tests for all function types # # Purpose: Verify that functions don't modify database state at generation time # # This test catches violations where functions: # - Write files during output generation (should only happen at execution time) # - Don't use EXAMINE_/MODIFY_ platform variables correctly # - Have side effects that break the declarative model # test_readonly_database() { test_header "Read-Only Database: All functions" test_setup # Populate with messy state create_messy_realistic_database local tested=0 local skipped=0 echo " Testing terminal functions..." for func_with_prefix in $TERMINAL_FUNCTIONS; do local func="${func_with_prefix#_}" if is_readonly_skip_function "$func"; then skipped=$((skipped + 1)) else test_readonly_function "$func" "terminal" tested=$((tested + 1)) fi done echo "" echo " Testing combinator functions..." for func_with_prefix in $COMBINATOR_FUNCTIONS; do local func="${func_with_prefix#__}" if is_readonly_skip_function "$func"; then skipped=$((skipped + 1)) else test_readonly_function "$func" "combinator" tested=$((tested + 1)) fi done echo "" echo " Testing batch combinator functions..." for func_with_prefix in $BATCH_COMBINATOR_FUNCTIONS; do local func="${func_with_prefix#___}" if is_readonly_skip_function "$func"; then skipped=$((skipped + 1)) else test_readonly_function "$func" "batch" tested=$((tested + 1)) fi done echo "" echo " ${COLOR_BLUE}Summary:${COLOR_RESET} Tested $tested functions, skipped $skipped" } #----------------------------------------------------------------------------- # Main Test Runner #----------------------------------------------------------------------------- # --- Phase wrappers --------------------------------------------------------- # The parameterised phases (loops over the function lists) are wrapped in named # test_* functions so they are individually selectable via the filter, exactly # like the flat tests. Each carries its own section header so it only prints when # the phase actually runs. test_all_combinator_syntax() { echo "" echo "${COLOR_BLUE}Running Combinator Tests${COLOR_RESET}" echo "========================================" echo "" echo "Found $(echo "$COMBINATOR_FUNCTIONS" | wc -w | tr -d ' ') combinator functions" echo "" for func_with_prefix in $COMBINATOR_FUNCTIONS; do func="${func_with_prefix#__}" test_combinator_function "$func" done } test_all_batch_combinator_syntax() { echo "" echo "${COLOR_BLUE}Running Batch Combinator Tests${COLOR_RESET}" echo "========================================" echo "" echo "Found $(echo "$BATCH_COMBINATOR_FUNCTIONS" | wc -w | tr -d ' ') batch combinator functions" echo "" for func_with_prefix in $BATCH_COMBINATOR_FUNCTIONS; do func="${func_with_prefix#___}" test_batch_combinator_function "$func" done } test_all_terminal_output() { echo "" echo "${COLOR_BLUE}Running Terminal Function Tests${COLOR_RESET}" echo "========================================" echo "" echo "Found $(echo "$TERMINAL_FUNCTIONS" | wc -w | tr -d ' ') terminal functions" echo "" for func_with_prefix in $TERMINAL_FUNCTIONS; do func="${func_with_prefix#_}" test_terminal_function "$func" done } test_all_batch_error_propagation_hull() { echo "" echo "${COLOR_BLUE}Running Batch Combinator Error Propagation Tests${COLOR_RESET}" echo "========================================" echo "" echo "Testing ALL batch combinators with error injection and KEEP_GOING validation" echo "Found $(echo "$BATCH_COMBINATOR_FUNCTIONS" | wc -w | tr -d ' ') batch combinator functions" echo "" for func_with_prefix in $BATCH_COMBINATOR_FUNCTIONS; do func="${func_with_prefix#___}" test_batch_combinator_transitive_hull "$func" done } test_all_batch_keep_going_precise() { echo "" echo "${COLOR_BLUE}Running Batch Combinator KEEP_GOING Precise Tests${COLOR_RESET}" echo "========================================" echo "" echo "Testing ALL batch combinators with precise architectural KEEP_GOING validation" echo "Found $(echo "$BATCH_COMBINATOR_FUNCTIONS" | wc -w | tr -d ' ') batch combinator functions" echo "" for func_with_prefix in $BATCH_COMBINATOR_FUNCTIONS; do func="${func_with_prefix#___}" test_batch_combinator_keep_going_precise "$func" done } # --- Help doc-block corpus tests (Idea 1: generated help) ------------------- # Enforce docs/HELP_TEMPLATE_SPEC.md. A = presence/classification (the worklist), # B = block conformance, C = cross-reference graph integrity. # Source files that may carry doc blocks. _help_source_files() { echo vpn-switch.sh local m for m in include/*.sh; do echo "$m"; done } # Emit one TSV record per block / unclassified anchor function across all sources: # B bound function block # F - free-floating block (defgroup/topic) # N - - - anchor function with NO bound block # kind = command|internal|defgroup|topic|unknown ; tagblob = tag lines joined by ';' _help_records() { local f for f in $(_help_source_files); do [ -f "$f" ] || continue awk -v OFS=' ' ' function emit(type, fn) { kind="unknown" if (tagblob ~ /@command/) kind="command" else if (tagblob ~ /@internal/) kind="internal" else if (tagblob ~ /@defgroup/) kind="defgroup" else if (tagblob ~ /@topic/) kind="topic" else if (tagblob ~ /@defcompletion/) kind="defcompletion" print type, fn, bname, kind, tagblob } /^#@help/ { if (pend) { emit("F","-"); pend=0 } inblk=1; bname=(NF>=2 ? $2 : "-"); tagblob=""; next } inblk && /^#@end$/ { inblk=0; pend=1; next } inblk { tagblob = tagblob ";" $0; next } { isdef = ($0 ~ /^_+[a-z][a-z0-9_]*[0-9]\(\) *\{/) fn="" if (isdef) { fn=$0; sub(/\(\).*/,"",fn) } if (pend) { if (isdef) { emit("B", fn); pend=0; next } else { emit("F","-"); pend=0 } } if (isdef) print "N", fn, "-", "-", "-" } END { if (pend) emit("F","-") } ' "$f" done } # A — Presence: every anchor function carries a name-matching block classified as # @command or @internal. Failure output is the worklist of what still needs doing. test_help_blocks_present() { test_header "Help blocks: every anchor function classified (@command|@internal)" local recs total=0 ok=0 missing="" recs=$(_help_records) local fp for fp in $ANCHOR_FUNCTIONS; do total=$((total + 1)) local rec rec=$(printf '%s\n' "$recs" | awk -F' ' -v f="$fp" '$1=="B" && $2==f {print; exit}') if [ -z "$rec" ]; then missing="$missing $fp"; continue; fi local sentinel kind sentinel=$(printf '%s' "$rec" | cut -f3) kind=$(printf '%s' "$rec" | cut -f4) if [ "$sentinel" != "$fp" ]; then missing="$missing ${fp}!namemismatch"; continue; fi case "$kind" in command|internal) ok=$((ok + 1)) ;; *) missing="$missing ${fp}!unclassified" ;; esac done if [ -z "$missing" ]; then pass "All $total anchor functions carry a conforming help block ($ok classified)" else fail "$((total - ok))/$total anchor functions still need a help block" printf '%s' "$missing" | tr ' ' '\n' | grep -v '^$' | sed 's/^/ - /' fi } # B — Conformance: each present block has a recognised kind, only known tags, and # the required fields for its kind. Single assertion (problems accumulated). test_help_blocks_conform() { test_header "Help blocks: well-formed, known tags, required fields" local recs problems="" allowed=" command summary group param option returns example see since defgroup order parent topic internal env completion defcompletion " recs=$(_help_records) local tmp; tmp=$(mktemp 2>/dev/null || echo "/tmp/help_conf.$$") printf '%s\n' "$recs" | awk -F' ' '$1=="B" || $1=="F"' > "$tmp" || true local type fn sentinel kind tagblob t label while IFS=' ' read -r type fn sentinel kind tagblob; do [ -n "${type:-}" ] || continue label="${fn}${sentinel}" if [ "$kind" = "unknown" ]; then problems="$problems '$label': no recognised anchor tag (@command/@internal/@defgroup/@topic)" continue fi for t in $(printf '%s' "$tagblob" | tr ';' '\n' | sed -n 's/^# *@\([a-z][a-z]*\).*/\1/p'); do case "$allowed" in *" $t "*) ;; *) problems="$problems '$label': unknown tag @$t" ;; esac done if [ "$kind" = "command" ]; then printf '%s' "$tagblob" | tr ';' '\n' | grep -q '@summary' || problems="$problems '$label': command block missing @summary" printf '%s' "$tagblob" | tr ';' '\n' | grep -q '@group' || problems="$problems '$label': command block missing @group" fi done < "$tmp" rm -f "$tmp" if [ -z "$problems" ]; then pass "All present help blocks are well-formed" else fail "Non-conforming help blocks:$problems" fi } # C — Graph: every @group resolves to a @defgroup, every @see resolves to a known # command/group, no duplicate command paths. Single assertion. test_help_graph_resolves() { test_header "Help graph: groups/see-refs resolve, no duplicate commands" local recs problems="" recs=$(_help_records) local tags; tags=$(printf '%s\n' "$recs" | cut -f5 | tr ';' '\n') local groups cmds g s groups=$(printf '%s\n' "$tags" | sed -n 's/^# *@defgroup *\([A-Za-z0-9_-]*\).*/\1/p' | sort -u) cmds=$(printf '%s\n' "$tags" | sed -n 's/^# *@command *\(.*\)/\1/p' | sed 's/[<[].*//; s/ *$//' | sort) for g in $(printf '%s\n' "$tags" | sed -n 's/^# *@group *\([A-Za-z0-9_-]*\).*/\1/p' | sort -u); do printf '%s\n' "$groups" | grep -qx "$g" || problems="$problems @group '$g' has no matching @defgroup" done for s in $(printf '%s\n' "$tags" | sed -n 's/^# *@see *\(.*\)/\1/p' | sed 's/ *$//' | sort -u | tr ' ' '\037'); do s=$(printf '%s' "$s" | tr '\037' ' ') printf '%s\n' "$cmds" | grep -qx "$s" && continue printf '%s\n' "$groups" | grep -qx "$s" && continue problems="$problems @see '$s' resolves to no command or group" done local dup; dup=$(printf '%s\n' "$cmds" | grep -v '^$' | uniq -d) [ -n "$dup" ] && problems="$problems duplicate @command path(s): $(printf '%s' "$dup" | tr '\n' ',')" # Completion sources: every in a @command usage resolves to a # source - through @completion in its block or the @defcompletion table - # and every source is one that complete_values() (include/help.sh) knows. local vocab=" files dirs wireguard-config openvpn-config any-config wireguard-category openvpn-group session pid env-var interpreter-fn phase profile layer command-path none " local defc pair nm src usage over defc=$(printf '%s\n' "$tags" | sed -n 's/^# *@defcompletion *\([A-Za-z_]*\) *\([a-z-]*\).*/\1 \2/p') for pair in $(printf '%s\n' "$defc" | tr ' ' '='); do nm=${pair%%=*}; src=${pair#*=} case "$vocab" in *" $src "*) ;; *) problems="$problems @defcompletion '$nm' uses unknown source '$src'" ;; esac done local tmpc; tmpc=$(mktemp 2>/dev/null || echo "/tmp/help_compl.$$") printf '%s\n' "$recs" | awk -F' ' '$1=="B" && $4=="command"' > "$tmpc" || true local type fn sentinel kind tagblob while IFS=' ' read -r type fn sentinel kind tagblob; do [ -n "${type:-}" ] || continue usage=$(printf '%s' "$tagblob" | tr ';' '\n' | sed -n 's/^# *@command *//p' | head -1) over=$(printf '%s' "$tagblob" | tr ';' '\n' | sed -n 's/^# *@completion *\([A-Za-z_]*\) *\([a-z-]*\).*/\1 \2/p') for pair in $(printf '%s\n' "$over" | tr ' ' '='); do nm=${pair%%=*}; src=${pair#*=} case "$vocab" in *" $src "*) ;; *) problems="$problems @completion '$nm' in '$fn' uses unknown source '$src'" ;; esac done for nm in $(printf '%s\n' "$usage" | grep -o '<[^>]*>' | tr -d '<>'); do printf '%s\n' "$over" | cut -d' ' -f1 | grep -qx "$nm" && continue printf '%s\n' "$defc" | cut -d' ' -f1 | grep -qx "$nm" || problems="$problems placeholder <$nm> in '$usage' has no @completion/@defcompletion source" done done < "$tmpc" rm -f "$tmpc" if [ -z "$problems" ]; then pass "Help graph resolves (groups, see-refs, uniqueness, completion sources)" else fail "Help graph has unresolved references:$problems" fi } # test_help_env_documented - every @command function documents the environment # variables its body uses (scripts/audit-env-docs.sh; @env tag or literal # mention in the block). Internal plumbing vars are whitelisted in the script. test_help_env_documented() { test_header "Help blocks: used environment variables documented (@env)" local missing missing=$(sh scripts/audit-env-docs.sh VPN_SWITCH vpn-switch.sh include/*.sh 2>/dev/null | awk '$1=="MISSING" && $2=="command"') if [ -z "$missing" ]; then pass "every @command function documents its environment variables" else fail "undocumented environment variables in @command help blocks:" printf '%s\n' "$missing" | sed 's/^/ - /' fi } # parallel_main - run every test function of the active filter as its own # suite process (sequential inside), MAXPROCS at a time via xargs -P; then # replay the outputs in list order and print an aggregated summary. parallel_main() { local outdir rc=0 t outdir=$(mktemp -d "${TMPDIR:-/tmp}/vsw-par.XXXXXX") || exit 1 echo "vpn-switch Architecture Test Suite (parallel, -P $MAXPROCS)" echo "" local tests if [ "$TEST_FILTER" = "$ALL_TESTS" ]; then # default run: the REAL test list are the should_run_test lines in main # (ALL_TESTS is the grep-derived filter BASE and also catches the # framework functions test_header/test_summary/test_setup) tests=$(grep -E '^[[:space:]]*should_run_test ' "$0" | awk '{print $2}') else tests="$TEST_FILTER" fi printf '%s\n' $tests | xargs -n1 -P "$MAXPROCS" -I{} \ sh -c 'sh "$0" "$1" "$2" {} > "$3/{}.out" 2>&1; echo $? > "$3/{}.rc"' \ "$0" "$TEST_PROFILE" "$KEEP_DATABASES" "$outdir" for t in $tests; do cat "$outdir/$t.out" 2>/dev/null [ "$(cat "$outdir/$t.rc" 2>/dev/null)" = "0" ] || rc=1 done echo "" echo "========================================" echo "Aggregated Summary (parallel run)" echo "========================================" awk ' /^Test Functions:/ { tf += $3 } /^Total Assertions:/ { ta += $3 } /^Passed Assertions:/ { pa += $3 } /^Failed Assertions:/ { fa += $3 } END { printf "Test Functions: %d\n", tf printf "Total Assertions: %d\n", ta printf "Passed Assertions: %d\n", pa printf "Failed Assertions: %d\n", fa }' "$outdir"/*.out if [ "$rc" -eq 0 ]; then echo ""; echo "ALL TESTS PASSED"; rm -rf "$outdir" else echo ""; echo "SOME TESTS FAILED"; echo "Per-test outputs preserved in: $outdir" fi return $rc } main() { if [ "$MAXPROCS" -gt 1 ]; then parallel_main return $? fi echo "${COLOR_BLUE}========================================${COLOR_RESET}" echo "${COLOR_BLUE}vpn-switch Architecture Test Suite${COLOR_RESET}" echo "${COLOR_BLUE}========================================${COLOR_RESET}" echo "" echo "Testing architectural rules for combinator functions" echo "Profile: $TEST_PROFILE" if [ "$TEST_FILTER" != "$ALL_TESTS" ]; then echo "${COLOR_YELLOW}Filter active - running only:${COLOR_RESET} $TEST_FILTER" fi echo "" # Check prerequisites if [ ! -f "$TEST_SCRIPT" ]; then echo "${COLOR_RED}ERROR:${COLOR_RESET} $TEST_SCRIPT not found" echo "Run this script from the repository root" exit 1 fi # Combinator / terminal / batch architecture (parameterised phases) should_run_test test_all_combinator_syntax should_run_test test_all_batch_combinator_syntax should_run_test test_all_terminal_output # Error propagation should_run_test test_batch_error_propagation should_run_test test_recursive_batch_error_propagation should_run_test test_batch_keep_going_with_realistic_dump should_run_test test_all_batch_error_propagation_hull should_run_test test_all_batch_keep_going_precise # Static analysis & read-only compliance should_run_test test_architecture_violation_scanner should_run_test test_readonly_database # Sync features should_run_test test_sync_metadata_registration should_run_test test_sync_batch_combinator_output_shape should_run_test test_bootstrap_emits_version_sync should_run_test test_pin_resolved_from_store should_run_test test_profile_template_bijection # Help doc-block corpus (docs/HELP_TEMPLATE_SPEC.md) - red until the corpus is filled should_run_test test_help_blocks_present should_run_test test_help_blocks_conform should_run_test test_help_graph_resolves should_run_test test_help_env_documented # Print summary test_summary } # Run main and exit with its status main exit $?