From 45dc95873fd906c582fbbd5e6ca3838caf867399 Mon Sep 17 00:00:00 2001 From: omnom62 Date: Mon, 6 Jul 2026 14:54:12 +1000 Subject: T8989: wave4 vyos_command, dict_op refactor * T8989: vyos_command module * T8989: vyos_command module UAT and SIT * T8989: vyos_command changelog * T8989: vyos_command linter * T8989: vyos_config module * T8989: vyos_config module changelog * T8989: Wave 4 vyos_config module with integration and unit tests * T8323: vyos_system module * T8332: vyos_system SIT and UAT * T8323: vyos_vlan module * T8323: vyos_vlan module * T8323: vyos_vlan module SIT and UAT * T8323: vyos_system module * T8989: Wave 4 vyos_vlan reworked with dict_op engine * T8989: Fix dict_op single-value string list handling, add vyos_system integration tests * T8989: logging_global refactor * T8989: migrate ntp_global, logging_global, firewall_global to dict_op engine * T8989: vyos_nat module for REST API collection * T8989: vyos_nat module for REST API collection, linter fixes * T8989: vyos_ha module for REST API collection * T8989: vyos_ha module for REST API collection * T8989: vyos_ha module sanity and linter fixes * T8989: vyos_ha module sanity and linter fixes * T8989: vyos_ha module linter fixes * T8989: vyos.rest AI comment fixes * T8323: vyos_nat AI comment fixes * T8989 ai fixes * T8989: vyos_bgp_address_family dict_op * T8989: vyos_bgp_address_family vyos_bgp_global dict_op * T8989: dict_op refactor for firewall_*, nat, user * T8989: dict_op refactor for firewall_*, nat, user * T8989: dict_op refactor for ntp_global, ha * T8989: snmp_server dict_op refactor * T8989: snmp_server dict_op refactor * T8989: route_map dict_op refactor --- .../targets/vyos_banner/tasks/httpapi.yaml | 2 +- .../targets/vyos_banner/tests/httpapi/deleted.yaml | 4 +- .../vyos_banner/tests/httpapi/gathered.yaml | 4 +- .../targets/vyos_banner/tests/httpapi/merged.yaml | 4 +- .../vyos_bgp_address_family/tasks/httpapi.yaml | 2 +- .../tests/httpapi/deleted.yaml | 4 +- .../tests/httpapi/gathered.yaml | 4 +- .../tests/httpapi/overridden.yaml | 4 +- .../tests/httpapi/replaced.yaml | 4 +- .../targets/vyos_bgp_global/tasks/httpapi.yaml | 2 +- .../vyos_bgp_global/tests/httpapi/deleted.yaml | 4 +- .../vyos_bgp_global/tests/httpapi/gathered.yaml | 4 +- .../vyos_bgp_global/tests/httpapi/overridden.yaml | 4 +- .../vyos_bgp_global/tests/httpapi/replaced.yaml | 4 +- tests/integration/targets/vyos_command/aliases | 1 + .../targets/vyos_command/defaults/main.yaml | 3 + .../targets/vyos_command/tasks/httpapi.yaml | 21 + .../targets/vyos_command/tasks/main.yaml | 5 + .../targets/vyos_command/tests/httpapi/show.yaml | 45 ++ .../vyos_command/tests/httpapi/wait_for.yaml | 36 ++ .../targets/vyos_command/vars/main.yaml | 2 + tests/integration/targets/vyos_config/aliases | 1 + .../targets/vyos_config/defaults/main.yaml | 3 + .../targets/vyos_config/tasks/httpapi.yaml | 21 + .../targets/vyos_config/tasks/main.yaml | 5 + .../vyos_config/tests/httpapi/_remove_config.yaml | 6 + .../targets/vyos_config/tests/httpapi/delete.yaml | 37 ++ .../targets/vyos_config/tests/httpapi/lines.yaml | 44 ++ .../integration/targets/vyos_config/vars/main.yaml | 2 + .../targets/vyos_configure/tasks/httpapi.yaml | 2 +- .../targets/vyos_facts/tasks/httpapi.yaml | 2 +- .../vyos_facts/tests/httpapi/_populate_config.yaml | 16 + .../vyos_facts/tests/httpapi/_remove_config.yaml | 8 + .../targets/vyos_facts/tests/httpapi/all.yaml | 5 + .../targets/vyos_facts/tests/httpapi/default.yaml | 4 +- .../targets/vyos_facts/tests/httpapi/subsets.yaml | 25 +- .../vyos_firewall_global/tasks/httpapi.yaml | 2 +- .../tests/httpapi/deleted.yaml | 4 +- .../tests/httpapi/gathered.yaml | 4 +- .../tests/httpapi/overridden.yaml | 4 +- .../tests/httpapi/replaced.yaml | 4 +- .../vyos_firewall_interfaces/tasks/httpapi.yaml | 2 +- .../tests/httpapi/deleted.yaml | 4 +- .../tests/httpapi/gathered.yaml | 4 +- .../tests/httpapi/overridden.yaml | 4 +- .../tests/httpapi/replaced.yaml | 4 +- .../targets/vyos_firewall_rules/tasks/httpapi.yaml | 2 +- .../vyos_firewall_rules/tests/httpapi/deleted.yaml | 4 +- .../tests/httpapi/gathered.yaml | 4 +- .../tests/httpapi/overridden.yaml | 4 +- .../tests/httpapi/replaced.yaml | 4 +- tests/integration/targets/vyos_ha/aliases | 1 + .../integration/targets/vyos_ha/defaults/main.yaml | 3 + .../integration/targets/vyos_ha/tasks/httpapi.yaml | 18 + tests/integration/targets/vyos_ha/tasks/main.yaml | 3 + .../vyos_ha/tests/httpapi/_populate_config.yaml | 40 ++ .../vyos_ha/tests/httpapi/_remove_config.yaml | 4 + .../targets/vyos_ha/tests/httpapi/deleted.yaml | 36 ++ .../targets/vyos_ha/tests/httpapi/gathered.yaml | 29 + .../targets/vyos_ha/tests/httpapi/merged.yaml | 54 ++ .../targets/vyos_ha/tests/httpapi/overridden.yaml | 50 ++ .../targets/vyos_ha/tests/httpapi/replaced.yaml | 48 ++ tests/integration/targets/vyos_ha/vars/main.yaml | 2 + .../targets/vyos_hostname/tasks/httpapi.yaml | 2 +- .../vyos_hostname/tests/httpapi/gathered.yaml | 4 +- .../vyos_hostname/tests/httpapi/replaced.yaml | 4 +- .../targets/vyos_lldp_global/tasks/httpapi.yaml | 2 +- .../vyos_lldp_global/tests/httpapi/deleted.yaml | 4 +- .../vyos_lldp_global/tests/httpapi/gathered.yaml | 4 +- .../vyos_lldp_global/tests/httpapi/replaced.yaml | 4 +- .../targets/vyos_logging_global/tasks/httpapi.yaml | 2 +- .../vyos_logging_global/tests/httpapi/deleted.yaml | 4 +- .../tests/httpapi/gathered.yaml | 4 +- .../tests/httpapi/replaced.yaml | 4 +- tests/integration/targets/vyos_nat/aliases | 1 + .../targets/vyos_nat/defaults/main.yaml | 3 + .../targets/vyos_nat/tasks/httpapi.yaml | 18 + tests/integration/targets/vyos_nat/tasks/main.yaml | 5 + .../vyos_nat/tests/httpapi/_populate_config.yaml | 39 ++ .../vyos_nat/tests/httpapi/_remove_config.yaml | 5 + .../targets/vyos_nat/tests/httpapi/deleted.yaml | 36 ++ .../targets/vyos_nat/tests/httpapi/gathered.yaml | 30 + .../targets/vyos_nat/tests/httpapi/merged.yaml | 49 ++ .../targets/vyos_nat/tests/httpapi/overridden.yaml | 49 ++ .../targets/vyos_nat/tests/httpapi/replaced.yaml | 49 ++ tests/integration/targets/vyos_nat/vars/main.yaml | 2 + .../targets/vyos_ntp_global/tasks/httpapi.yaml | 2 +- .../vyos_ntp_global/tests/httpapi/deleted.yaml | 6 +- .../vyos_ntp_global/tests/httpapi/gathered.yaml | 8 +- .../vyos_ntp_global/tests/httpapi/overridden.yaml | 4 +- .../vyos_ntp_global/tests/httpapi/replaced.yaml | 8 +- .../targets/vyos_ntp_global/tests/httpapi/rtt.yaml | 54 +- .../vyos_ospf_interfaces/tasks/httpapi.yaml | 2 +- .../vyos_prefix_lists/tests/httpapi/deleted.yaml | 4 +- .../vyos_prefix_lists/tests/httpapi/gathered.yaml | 4 +- .../vyos_prefix_lists/tests/httpapi/replaced.yaml | 4 +- .../targets/vyos_route_maps/tasks/httpapi.yaml | 2 +- .../vyos_route_maps/tests/httpapi/deleted.yaml | 4 +- .../vyos_route_maps/tests/httpapi/gathered.yaml | 4 +- .../vyos_route_maps/tests/httpapi/replaced.yaml | 4 +- .../targets/vyos_route_maps/tests/httpapi/rtt.yaml | 128 +++- .../targets/vyos_snmp_server/tasks/httpapi.yaml | 2 +- .../vyos_snmp_server/tests/httpapi/deleted.yaml | 4 +- .../vyos_snmp_server/tests/httpapi/gathered.yaml | 4 +- .../vyos_snmp_server/tests/httpapi/replaced.yaml | 4 +- .../vyos_snmp_server/tests/httpapi/rtt.yaml | 77 ++- tests/integration/targets/vyos_system/aliases | 1 + .../targets/vyos_system/defaults/main.yaml | 3 + .../targets/vyos_system/tasks/httpapi.yaml | 21 + .../targets/vyos_system/tasks/main.yaml | 5 + .../vyos_system/tests/httpapi/_remove_config.yaml | 12 + .../targets/vyos_system/tests/httpapi/absent.yaml | 48 ++ .../vyos_system/tests/httpapi/gathered.yaml | 14 + .../targets/vyos_system/tests/httpapi/present.yaml | 63 ++ .../integration/targets/vyos_system/vars/main.yaml | 2 + .../targets/vyos_user/tasks/httpapi.yaml | 2 +- tests/integration/targets/vyos_vlan/aliases | 1 + .../targets/vyos_vlan/defaults/main.yaml | 3 + .../targets/vyos_vlan/tasks/httpapi.yaml | 21 + .../integration/targets/vyos_vlan/tasks/main.yaml | 5 + .../vyos_vlan/tests/httpapi/_remove_config.yaml | 9 + .../targets/vyos_vlan/tests/httpapi/absent.yaml | 45 ++ .../targets/vyos_vlan/tests/httpapi/gathered.yaml | 28 + .../targets/vyos_vlan/tests/httpapi/present.yaml | 44 ++ .../targets/vyos_vlan/tests/httpapi/rtt.yaml | 43 ++ tests/integration/targets/vyos_vlan/vars/main.yaml | 2 + tests/unit/fixtures/bgp_af_running.json | 17 +- tests/unit/fixtures/bgp_global_running.json | 14 +- .../unit/fixtures/firewall_interfaces_running.json | 14 +- tests/unit/fixtures/ha_running.json | 44 ++ tests/unit/fixtures/nat_running.json | 80 +++ tests/unit/fixtures/route_maps_running.json | 11 +- tests/unit/fixtures/snmp_server_running.json | 11 + tests/unit/fixtures/system_running.json | 11 + tests/unit/fixtures/vlan_running.json | 24 + tests/unit/modules/base.py | 15 +- tests/unit/modules/test_vyos_bgp_address_family.py | 358 ++++++------ tests/unit/modules/test_vyos_bgp_global.py | 341 +++++++---- tests/unit/modules/test_vyos_command.py | 111 ++++ tests/unit/modules/test_vyos_config.py | 107 ++++ tests/unit/modules/test_vyos_facts.py | 8 +- tests/unit/modules/test_vyos_firewall_global.py | 380 ++++++++---- .../unit/modules/test_vyos_firewall_interfaces.py | 306 +++++++--- tests/unit/modules/test_vyos_firewall_rules.py | 345 +++++++---- tests/unit/modules/test_vyos_ha.py | 345 +++++++++++ tests/unit/modules/test_vyos_logging_global.py | 416 +++++++------ tests/unit/modules/test_vyos_nat.py | 558 ++++++++++++++++++ tests/unit/modules/test_vyos_ntp_global.py | 265 +++++---- tests/unit/modules/test_vyos_route_maps.py | 506 +++++++++++----- tests/unit/modules/test_vyos_snmp_server.py | 644 +++++++++++++++++++++ tests/unit/modules/test_vyos_system.py | 93 +++ tests/unit/modules/test_vyos_user.py | 278 +++++---- tests/unit/modules/test_vyos_vlan.py | 116 ++++ 153 files changed, 5766 insertions(+), 1363 deletions(-) create mode 100644 tests/integration/targets/vyos_command/aliases create mode 100644 tests/integration/targets/vyos_command/defaults/main.yaml create mode 100644 tests/integration/targets/vyos_command/tasks/httpapi.yaml create mode 100644 tests/integration/targets/vyos_command/tasks/main.yaml create mode 100644 tests/integration/targets/vyos_command/tests/httpapi/show.yaml create mode 100644 tests/integration/targets/vyos_command/tests/httpapi/wait_for.yaml create mode 100644 tests/integration/targets/vyos_command/vars/main.yaml create mode 100644 tests/integration/targets/vyos_config/aliases create mode 100644 tests/integration/targets/vyos_config/defaults/main.yaml create mode 100644 tests/integration/targets/vyos_config/tasks/httpapi.yaml create mode 100644 tests/integration/targets/vyos_config/tasks/main.yaml create mode 100644 tests/integration/targets/vyos_config/tests/httpapi/_remove_config.yaml create mode 100644 tests/integration/targets/vyos_config/tests/httpapi/delete.yaml create mode 100644 tests/integration/targets/vyos_config/tests/httpapi/lines.yaml create mode 100644 tests/integration/targets/vyos_config/vars/main.yaml create mode 100644 tests/integration/targets/vyos_facts/tests/httpapi/_populate_config.yaml create mode 100644 tests/integration/targets/vyos_facts/tests/httpapi/_remove_config.yaml create mode 100644 tests/integration/targets/vyos_ha/aliases create mode 100644 tests/integration/targets/vyos_ha/defaults/main.yaml create mode 100644 tests/integration/targets/vyos_ha/tasks/httpapi.yaml create mode 100644 tests/integration/targets/vyos_ha/tasks/main.yaml create mode 100644 tests/integration/targets/vyos_ha/tests/httpapi/_populate_config.yaml create mode 100644 tests/integration/targets/vyos_ha/tests/httpapi/_remove_config.yaml create mode 100644 tests/integration/targets/vyos_ha/tests/httpapi/deleted.yaml create mode 100644 tests/integration/targets/vyos_ha/tests/httpapi/gathered.yaml create mode 100644 tests/integration/targets/vyos_ha/tests/httpapi/merged.yaml create mode 100644 tests/integration/targets/vyos_ha/tests/httpapi/overridden.yaml create mode 100644 tests/integration/targets/vyos_ha/tests/httpapi/replaced.yaml create mode 100644 tests/integration/targets/vyos_ha/vars/main.yaml create mode 100644 tests/integration/targets/vyos_nat/aliases create mode 100644 tests/integration/targets/vyos_nat/defaults/main.yaml create mode 100644 tests/integration/targets/vyos_nat/tasks/httpapi.yaml create mode 100644 tests/integration/targets/vyos_nat/tasks/main.yaml create mode 100644 tests/integration/targets/vyos_nat/tests/httpapi/_populate_config.yaml create mode 100644 tests/integration/targets/vyos_nat/tests/httpapi/_remove_config.yaml create mode 100644 tests/integration/targets/vyos_nat/tests/httpapi/deleted.yaml create mode 100644 tests/integration/targets/vyos_nat/tests/httpapi/gathered.yaml create mode 100644 tests/integration/targets/vyos_nat/tests/httpapi/merged.yaml create mode 100644 tests/integration/targets/vyos_nat/tests/httpapi/overridden.yaml create mode 100644 tests/integration/targets/vyos_nat/tests/httpapi/replaced.yaml create mode 100644 tests/integration/targets/vyos_nat/vars/main.yaml create mode 100644 tests/integration/targets/vyos_system/aliases create mode 100644 tests/integration/targets/vyos_system/defaults/main.yaml create mode 100644 tests/integration/targets/vyos_system/tasks/httpapi.yaml create mode 100644 tests/integration/targets/vyos_system/tasks/main.yaml create mode 100644 tests/integration/targets/vyos_system/tests/httpapi/_remove_config.yaml create mode 100644 tests/integration/targets/vyos_system/tests/httpapi/absent.yaml create mode 100644 tests/integration/targets/vyos_system/tests/httpapi/gathered.yaml create mode 100644 tests/integration/targets/vyos_system/tests/httpapi/present.yaml create mode 100644 tests/integration/targets/vyos_system/vars/main.yaml create mode 100644 tests/integration/targets/vyos_vlan/aliases create mode 100644 tests/integration/targets/vyos_vlan/defaults/main.yaml create mode 100644 tests/integration/targets/vyos_vlan/tasks/httpapi.yaml create mode 100644 tests/integration/targets/vyos_vlan/tasks/main.yaml create mode 100644 tests/integration/targets/vyos_vlan/tests/httpapi/_remove_config.yaml create mode 100644 tests/integration/targets/vyos_vlan/tests/httpapi/absent.yaml create mode 100644 tests/integration/targets/vyos_vlan/tests/httpapi/gathered.yaml create mode 100644 tests/integration/targets/vyos_vlan/tests/httpapi/present.yaml create mode 100644 tests/integration/targets/vyos_vlan/tests/httpapi/rtt.yaml create mode 100644 tests/integration/targets/vyos_vlan/vars/main.yaml create mode 100644 tests/unit/fixtures/ha_running.json create mode 100644 tests/unit/fixtures/nat_running.json create mode 100644 tests/unit/fixtures/system_running.json create mode 100644 tests/unit/fixtures/vlan_running.json create mode 100644 tests/unit/modules/test_vyos_command.py create mode 100644 tests/unit/modules/test_vyos_config.py create mode 100644 tests/unit/modules/test_vyos_ha.py create mode 100644 tests/unit/modules/test_vyos_nat.py create mode 100644 tests/unit/modules/test_vyos_snmp_server.py create mode 100644 tests/unit/modules/test_vyos_system.py create mode 100644 tests/unit/modules/test_vyos_vlan.py (limited to 'tests') diff --git a/tests/integration/targets/vyos_banner/tasks/httpapi.yaml b/tests/integration/targets/vyos_banner/tasks/httpapi.yaml index 4147e6d..0ed3e42 100644 --- a/tests/integration/targets/vyos_banner/tasks/httpapi.yaml +++ b/tests/integration/targets/vyos_banner/tasks/httpapi.yaml @@ -9,7 +9,7 @@ - name: Set test_items ansible.builtin.set_fact: - test_items: "{{ test_cases.files | map(attribute='path') | list }}" + test_items: "{{ test_cases.files | map(attribute='path') | list | sort }}" - name: Run test case (connection=httpapi) ansible.builtin.include_tasks: "{{ test_case_to_run }}" diff --git a/tests/integration/targets/vyos_banner/tests/httpapi/deleted.yaml b/tests/integration/targets/vyos_banner/tests/httpapi/deleted.yaml index 82e0f6e..61f3f2d 100644 --- a/tests/integration/targets/vyos_banner/tests/httpapi/deleted.yaml +++ b/tests/integration/targets/vyos_banner/tests/httpapi/deleted.yaml @@ -3,9 +3,9 @@ msg: START vyos_banner deleted integration tests on connection={{ ansible_connection }} - include_tasks: _remove_config.yaml -- include_tasks: _populate_config.yaml - - block: + - include_tasks: _populate_config.yaml + - name: Delete pre-login banner register: result vyos.rest.vyos_banner: &id001 diff --git a/tests/integration/targets/vyos_banner/tests/httpapi/gathered.yaml b/tests/integration/targets/vyos_banner/tests/httpapi/gathered.yaml index a51ff48..a332563 100644 --- a/tests/integration/targets/vyos_banner/tests/httpapi/gathered.yaml +++ b/tests/integration/targets/vyos_banner/tests/httpapi/gathered.yaml @@ -3,9 +3,9 @@ msg: START vyos_banner gathered integration tests on connection={{ ansible_connection }} - include_tasks: _remove_config.yaml -- include_tasks: _populate_config.yaml - - block: + - include_tasks: _populate_config.yaml + - name: Gather banner configuration register: result vyos.rest.vyos_banner: diff --git a/tests/integration/targets/vyos_banner/tests/httpapi/merged.yaml b/tests/integration/targets/vyos_banner/tests/httpapi/merged.yaml index 96a984e..85fafdb 100644 --- a/tests/integration/targets/vyos_banner/tests/httpapi/merged.yaml +++ b/tests/integration/targets/vyos_banner/tests/httpapi/merged.yaml @@ -3,9 +3,9 @@ msg: START vyos_banner gathered integration tests on connection={{ ansible_connection }} - include_tasks: _remove_config.yaml -- include_tasks: _populate_config.yaml - - block: + - include_tasks: _populate_config.yaml + - name: Gather all banners (no config specified) register: result vyos.rest.vyos_banner: diff --git a/tests/integration/targets/vyos_bgp_address_family/tasks/httpapi.yaml b/tests/integration/targets/vyos_bgp_address_family/tasks/httpapi.yaml index 4147e6d..0ed3e42 100644 --- a/tests/integration/targets/vyos_bgp_address_family/tasks/httpapi.yaml +++ b/tests/integration/targets/vyos_bgp_address_family/tasks/httpapi.yaml @@ -9,7 +9,7 @@ - name: Set test_items ansible.builtin.set_fact: - test_items: "{{ test_cases.files | map(attribute='path') | list }}" + test_items: "{{ test_cases.files | map(attribute='path') | list | sort }}" - name: Run test case (connection=httpapi) ansible.builtin.include_tasks: "{{ test_case_to_run }}" diff --git a/tests/integration/targets/vyos_bgp_address_family/tests/httpapi/deleted.yaml b/tests/integration/targets/vyos_bgp_address_family/tests/httpapi/deleted.yaml index ce1ab70..225f057 100644 --- a/tests/integration/targets/vyos_bgp_address_family/tests/httpapi/deleted.yaml +++ b/tests/integration/targets/vyos_bgp_address_family/tests/httpapi/deleted.yaml @@ -3,9 +3,9 @@ msg: START vyos_bgp_address_family deleted integration tests on connection={{ ansible_connection }} - include_tasks: _remove_config.yaml -- include_tasks: _populate_config.yaml - - block: + - include_tasks: _populate_config.yaml + - name: Delete BGP address-family configuration register: result vyos.rest.vyos_bgp_address_family: diff --git a/tests/integration/targets/vyos_bgp_address_family/tests/httpapi/gathered.yaml b/tests/integration/targets/vyos_bgp_address_family/tests/httpapi/gathered.yaml index e686a32..50db299 100644 --- a/tests/integration/targets/vyos_bgp_address_family/tests/httpapi/gathered.yaml +++ b/tests/integration/targets/vyos_bgp_address_family/tests/httpapi/gathered.yaml @@ -3,9 +3,9 @@ msg: START vyos_bgp_address_family gathered integration tests on connection={{ ansible_connection }} - include_tasks: _remove_config.yaml -- include_tasks: _populate_config.yaml - - block: + - include_tasks: _populate_config.yaml + - name: Gather BGP address-family configuration register: result vyos.rest.vyos_bgp_address_family: diff --git a/tests/integration/targets/vyos_bgp_address_family/tests/httpapi/overridden.yaml b/tests/integration/targets/vyos_bgp_address_family/tests/httpapi/overridden.yaml index 9a497d2..cae014e 100644 --- a/tests/integration/targets/vyos_bgp_address_family/tests/httpapi/overridden.yaml +++ b/tests/integration/targets/vyos_bgp_address_family/tests/httpapi/overridden.yaml @@ -3,9 +3,9 @@ msg: START vyos_bgp_address_family overridden integration tests on connection={{ ansible_connection }} - include_tasks: _remove_config.yaml -- include_tasks: _populate_config.yaml - - block: + - include_tasks: _populate_config.yaml + - name: Override BGP address-family configuration register: result vyos.rest.vyos_bgp_address_family: &id001 diff --git a/tests/integration/targets/vyos_bgp_address_family/tests/httpapi/replaced.yaml b/tests/integration/targets/vyos_bgp_address_family/tests/httpapi/replaced.yaml index 133ae39..f4ae60f 100644 --- a/tests/integration/targets/vyos_bgp_address_family/tests/httpapi/replaced.yaml +++ b/tests/integration/targets/vyos_bgp_address_family/tests/httpapi/replaced.yaml @@ -3,9 +3,9 @@ msg: START vyos_bgp_address_family replaced integration tests on connection={{ ansible_connection }} - include_tasks: _remove_config.yaml -- include_tasks: _populate_config.yaml - - block: + - include_tasks: _populate_config.yaml + - name: Replace BGP address-family configuration register: result vyos.rest.vyos_bgp_address_family: &id001 diff --git a/tests/integration/targets/vyos_bgp_global/tasks/httpapi.yaml b/tests/integration/targets/vyos_bgp_global/tasks/httpapi.yaml index 4147e6d..0ed3e42 100644 --- a/tests/integration/targets/vyos_bgp_global/tasks/httpapi.yaml +++ b/tests/integration/targets/vyos_bgp_global/tasks/httpapi.yaml @@ -9,7 +9,7 @@ - name: Set test_items ansible.builtin.set_fact: - test_items: "{{ test_cases.files | map(attribute='path') | list }}" + test_items: "{{ test_cases.files | map(attribute='path') | list | sort }}" - name: Run test case (connection=httpapi) ansible.builtin.include_tasks: "{{ test_case_to_run }}" diff --git a/tests/integration/targets/vyos_bgp_global/tests/httpapi/deleted.yaml b/tests/integration/targets/vyos_bgp_global/tests/httpapi/deleted.yaml index ed2c285..9fe1f62 100644 --- a/tests/integration/targets/vyos_bgp_global/tests/httpapi/deleted.yaml +++ b/tests/integration/targets/vyos_bgp_global/tests/httpapi/deleted.yaml @@ -3,9 +3,9 @@ msg: START vyos_bgp_global deleted integration tests on connection={{ ansible_connection }} - include_tasks: _remove_config.yaml -- include_tasks: _populate_config.yaml - - block: + - include_tasks: _populate_config.yaml + - name: Delete BGP configuration register: result vyos.rest.vyos_bgp_global: diff --git a/tests/integration/targets/vyos_bgp_global/tests/httpapi/gathered.yaml b/tests/integration/targets/vyos_bgp_global/tests/httpapi/gathered.yaml index b38dba0..c8d327c 100644 --- a/tests/integration/targets/vyos_bgp_global/tests/httpapi/gathered.yaml +++ b/tests/integration/targets/vyos_bgp_global/tests/httpapi/gathered.yaml @@ -3,9 +3,9 @@ msg: START vyos_bgp_global gathered integration tests on connection={{ ansible_connection }} - include_tasks: _remove_config.yaml -- include_tasks: _populate_config.yaml - - block: + - include_tasks: _populate_config.yaml + - name: Gather BGP global configuration register: result vyos.rest.vyos_bgp_global: diff --git a/tests/integration/targets/vyos_bgp_global/tests/httpapi/overridden.yaml b/tests/integration/targets/vyos_bgp_global/tests/httpapi/overridden.yaml index 603aff3..aad9a9c 100644 --- a/tests/integration/targets/vyos_bgp_global/tests/httpapi/overridden.yaml +++ b/tests/integration/targets/vyos_bgp_global/tests/httpapi/overridden.yaml @@ -3,9 +3,9 @@ msg: START vyos_bgp_global overridden integration tests on connection={{ ansible_connection }} - include_tasks: _remove_config.yaml -- include_tasks: _populate_config.yaml - - block: + - include_tasks: _populate_config.yaml + - name: Override BGP global configuration register: result vyos.rest.vyos_bgp_global: &id001 diff --git a/tests/integration/targets/vyos_bgp_global/tests/httpapi/replaced.yaml b/tests/integration/targets/vyos_bgp_global/tests/httpapi/replaced.yaml index 1a7d615..d88af15 100644 --- a/tests/integration/targets/vyos_bgp_global/tests/httpapi/replaced.yaml +++ b/tests/integration/targets/vyos_bgp_global/tests/httpapi/replaced.yaml @@ -3,9 +3,9 @@ msg: START vyos_bgp_global replaced integration tests on connection={{ ansible_connection }} - include_tasks: _remove_config.yaml -- include_tasks: _populate_config.yaml - - block: + - include_tasks: _populate_config.yaml + - name: Replace BGP global configuration register: result vyos.rest.vyos_bgp_global: &id001 diff --git a/tests/integration/targets/vyos_command/aliases b/tests/integration/targets/vyos_command/aliases new file mode 100644 index 0000000..cc0afef --- /dev/null +++ b/tests/integration/targets/vyos_command/aliases @@ -0,0 +1 @@ +network/vyos diff --git a/tests/integration/targets/vyos_command/defaults/main.yaml b/tests/integration/targets/vyos_command/defaults/main.yaml new file mode 100644 index 0000000..164afea --- /dev/null +++ b/tests/integration/targets/vyos_command/defaults/main.yaml @@ -0,0 +1,3 @@ +--- +testcase: "[^_].*" +test_items: [] diff --git a/tests/integration/targets/vyos_command/tasks/httpapi.yaml b/tests/integration/targets/vyos_command/tasks/httpapi.yaml new file mode 100644 index 0000000..0ed3e42 --- /dev/null +++ b/tests/integration/targets/vyos_command/tasks/httpapi.yaml @@ -0,0 +1,21 @@ +--- +- name: Collect all httpapi test cases + ansible.builtin.find: + paths: "{{ role_path }}/tests/httpapi" + patterns: "{{ testcase }}.yaml" + use_regex: true + register: test_cases + delegate_to: localhost + +- name: Set test_items + ansible.builtin.set_fact: + test_items: "{{ test_cases.files | map(attribute='path') | list | sort }}" + +- name: Run test case (connection=httpapi) + ansible.builtin.include_tasks: "{{ test_case_to_run }}" + vars: + ansible_connection: ansible.netcommon.httpapi + ansible_network_os: vyos.rest.vyos + with_items: "{{ test_items }}" + loop_control: + loop_var: test_case_to_run diff --git a/tests/integration/targets/vyos_command/tasks/main.yaml b/tests/integration/targets/vyos_command/tasks/main.yaml new file mode 100644 index 0000000..b1f6193 --- /dev/null +++ b/tests/integration/targets/vyos_command/tasks/main.yaml @@ -0,0 +1,5 @@ +--- +- name: Run httpapi tests + ansible.builtin.include_tasks: httpapi.yaml + tags: + - httpapi diff --git a/tests/integration/targets/vyos_command/tests/httpapi/show.yaml b/tests/integration/targets/vyos_command/tests/httpapi/show.yaml new file mode 100644 index 0000000..fed35d7 --- /dev/null +++ b/tests/integration/targets/vyos_command/tests/httpapi/show.yaml @@ -0,0 +1,45 @@ +--- +- debug: + msg: START vyos_command show integration tests on connection={{ ansible_connection }} + +- block: + - name: Run show version + register: result + vyos.rest.vyos_command: + commands: + - version + + - assert: + that: + - result.stdout | length == 1 + - result.stdout[0] | length > 0 + - "'VyOS' in result.stdout[0]" + - result.stdout_lines | length == 1 + + - name: Run multiple show commands + register: result + vyos.rest.vyos_command: + commands: + - interfaces + - ip route + - system uptime + + - assert: + that: + - result.stdout | length == 3 + - result.stdout_lines | length == 3 + - result.stdout[0] | length > 0 + - result.stdout[1] | length > 0 + - result.stdout[2] | length > 0 + + - name: Run show commands as lists + register: result + vyos.rest.vyos_command: + commands: + - - system + - uptime + + - assert: + that: + - result.stdout | length == 1 + - "'Uptime' in result.stdout[0]" diff --git a/tests/integration/targets/vyos_command/tests/httpapi/wait_for.yaml b/tests/integration/targets/vyos_command/tests/httpapi/wait_for.yaml new file mode 100644 index 0000000..298c68c --- /dev/null +++ b/tests/integration/targets/vyos_command/tests/httpapi/wait_for.yaml @@ -0,0 +1,36 @@ +--- +- debug: + msg: START vyos_command wait_for integration tests on connection={{ ansible_connection }} + +- block: + - name: Wait for version output to contain VyOS + register: result + vyos.rest.vyos_command: + commands: + - version + wait_for: + - result[0] contains VyOS + retries: 3 + interval: 1 + + - assert: + that: + - result.stdout | length == 1 + - "'VyOS' in result.stdout[0]" + + - name: Wait for any condition to match + register: result + vyos.rest.vyos_command: + commands: + - version + - interfaces + wait_for: + - result[0] contains VyOS + - result[1] contains eth0 + match: all + retries: 3 + interval: 1 + + - assert: + that: + - result.stdout | length == 2 diff --git a/tests/integration/targets/vyos_command/vars/main.yaml b/tests/integration/targets/vyos_command/vars/main.yaml new file mode 100644 index 0000000..4303881 --- /dev/null +++ b/tests/integration/targets/vyos_command/vars/main.yaml @@ -0,0 +1,2 @@ +--- +# only common vars here diff --git a/tests/integration/targets/vyos_config/aliases b/tests/integration/targets/vyos_config/aliases new file mode 100644 index 0000000..cc0afef --- /dev/null +++ b/tests/integration/targets/vyos_config/aliases @@ -0,0 +1 @@ +network/vyos diff --git a/tests/integration/targets/vyos_config/defaults/main.yaml b/tests/integration/targets/vyos_config/defaults/main.yaml new file mode 100644 index 0000000..164afea --- /dev/null +++ b/tests/integration/targets/vyos_config/defaults/main.yaml @@ -0,0 +1,3 @@ +--- +testcase: "[^_].*" +test_items: [] diff --git a/tests/integration/targets/vyos_config/tasks/httpapi.yaml b/tests/integration/targets/vyos_config/tasks/httpapi.yaml new file mode 100644 index 0000000..0ed3e42 --- /dev/null +++ b/tests/integration/targets/vyos_config/tasks/httpapi.yaml @@ -0,0 +1,21 @@ +--- +- name: Collect all httpapi test cases + ansible.builtin.find: + paths: "{{ role_path }}/tests/httpapi" + patterns: "{{ testcase }}.yaml" + use_regex: true + register: test_cases + delegate_to: localhost + +- name: Set test_items + ansible.builtin.set_fact: + test_items: "{{ test_cases.files | map(attribute='path') | list | sort }}" + +- name: Run test case (connection=httpapi) + ansible.builtin.include_tasks: "{{ test_case_to_run }}" + vars: + ansible_connection: ansible.netcommon.httpapi + ansible_network_os: vyos.rest.vyos + with_items: "{{ test_items }}" + loop_control: + loop_var: test_case_to_run diff --git a/tests/integration/targets/vyos_config/tasks/main.yaml b/tests/integration/targets/vyos_config/tasks/main.yaml new file mode 100644 index 0000000..b1f6193 --- /dev/null +++ b/tests/integration/targets/vyos_config/tasks/main.yaml @@ -0,0 +1,5 @@ +--- +- name: Run httpapi tests + ansible.builtin.include_tasks: httpapi.yaml + tags: + - httpapi diff --git a/tests/integration/targets/vyos_config/tests/httpapi/_remove_config.yaml b/tests/integration/targets/vyos_config/tests/httpapi/_remove_config.yaml new file mode 100644 index 0000000..2c602fa --- /dev/null +++ b/tests/integration/targets/vyos_config/tests/httpapi/_remove_config.yaml @@ -0,0 +1,6 @@ +--- +- name: Remove test configuration + vyos.rest.vyos_config: + lines: + - delete interfaces ethernet eth1 description + match: none diff --git a/tests/integration/targets/vyos_config/tests/httpapi/delete.yaml b/tests/integration/targets/vyos_config/tests/httpapi/delete.yaml new file mode 100644 index 0000000..0db8e84 --- /dev/null +++ b/tests/integration/targets/vyos_config/tests/httpapi/delete.yaml @@ -0,0 +1,37 @@ +--- +- debug: + msg: START vyos_config delete integration tests on connection={{ ansible_connection }} + +- include_tasks: _remove_config.yaml + +- block: + - name: Setup - add description + vyos.rest.vyos_config: + lines: + - set interfaces ethernet eth1 description "To be deleted" + match: none + + - name: Delete configuration + register: result + vyos.rest.vyos_config: + lines: + - delete interfaces ethernet eth1 description + save: true + + - assert: + that: + - result.changed == true + + - name: Delete again (IDEMPOTENT) + register: result + vyos.rest.vyos_config: + lines: + - delete interfaces ethernet eth1 description + + - assert: + that: + - result.changed == false + - result.commands == [] + + always: + - include_tasks: _remove_config.yaml diff --git a/tests/integration/targets/vyos_config/tests/httpapi/lines.yaml b/tests/integration/targets/vyos_config/tests/httpapi/lines.yaml new file mode 100644 index 0000000..11b69ce --- /dev/null +++ b/tests/integration/targets/vyos_config/tests/httpapi/lines.yaml @@ -0,0 +1,44 @@ +--- +- debug: + msg: START vyos_config lines integration tests on connection={{ ansible_connection }} + +- include_tasks: _remove_config.yaml + +- block: + - name: Apply configuration lines + register: result + vyos.rest.vyos_config: + lines: + - set interfaces ethernet eth1 description "Test description" + save: true + + - assert: + that: + - result.changed == true + - result.commands | length == 1 + + - name: Apply same config (match=line IDEMPOTENT) + register: result + vyos.rest.vyos_config: + lines: + - set interfaces ethernet eth1 description "Test description" + match: line + + - assert: + that: + - result.changed == false + - result.commands == [] + + - name: Apply with match=none (always apply) + register: result + vyos.rest.vyos_config: + lines: + - set interfaces ethernet eth1 description "Test description" + match: none + + - assert: + that: + - result.changed == true + + always: + - include_tasks: _remove_config.yaml diff --git a/tests/integration/targets/vyos_config/vars/main.yaml b/tests/integration/targets/vyos_config/vars/main.yaml new file mode 100644 index 0000000..4303881 --- /dev/null +++ b/tests/integration/targets/vyos_config/vars/main.yaml @@ -0,0 +1,2 @@ +--- +# only common vars here diff --git a/tests/integration/targets/vyos_configure/tasks/httpapi.yaml b/tests/integration/targets/vyos_configure/tasks/httpapi.yaml index 4147e6d..0ed3e42 100644 --- a/tests/integration/targets/vyos_configure/tasks/httpapi.yaml +++ b/tests/integration/targets/vyos_configure/tasks/httpapi.yaml @@ -9,7 +9,7 @@ - name: Set test_items ansible.builtin.set_fact: - test_items: "{{ test_cases.files | map(attribute='path') | list }}" + test_items: "{{ test_cases.files | map(attribute='path') | list | sort }}" - name: Run test case (connection=httpapi) ansible.builtin.include_tasks: "{{ test_case_to_run }}" diff --git a/tests/integration/targets/vyos_facts/tasks/httpapi.yaml b/tests/integration/targets/vyos_facts/tasks/httpapi.yaml index 4147e6d..0ed3e42 100644 --- a/tests/integration/targets/vyos_facts/tasks/httpapi.yaml +++ b/tests/integration/targets/vyos_facts/tasks/httpapi.yaml @@ -9,7 +9,7 @@ - name: Set test_items ansible.builtin.set_fact: - test_items: "{{ test_cases.files | map(attribute='path') | list }}" + test_items: "{{ test_cases.files | map(attribute='path') | list | sort }}" - name: Run test case (connection=httpapi) ansible.builtin.include_tasks: "{{ test_case_to_run }}" diff --git a/tests/integration/targets/vyos_facts/tests/httpapi/_populate_config.yaml b/tests/integration/targets/vyos_facts/tests/httpapi/_populate_config.yaml new file mode 100644 index 0000000..690c0c8 --- /dev/null +++ b/tests/integration/targets/vyos_facts/tests/httpapi/_populate_config.yaml @@ -0,0 +1,16 @@ +--- +- name: Configure logging for facts testing + vyos.rest.vyos_logging_global: + config: + global_params: + facilities: + - facility: all + severity: notice + state: merged + +- name: Configure NTP for facts testing + vyos.rest.vyos_ntp_global: + config: + servers: + - server: time1.vyos.net + state: merged diff --git a/tests/integration/targets/vyos_facts/tests/httpapi/_remove_config.yaml b/tests/integration/targets/vyos_facts/tests/httpapi/_remove_config.yaml new file mode 100644 index 0000000..fbc2e73 --- /dev/null +++ b/tests/integration/targets/vyos_facts/tests/httpapi/_remove_config.yaml @@ -0,0 +1,8 @@ +--- +- name: Remove logging config + vyos.rest.vyos_logging_global: + state: deleted + +- name: Remove NTP config + vyos.rest.vyos_ntp_global: + state: deleted diff --git a/tests/integration/targets/vyos_facts/tests/httpapi/all.yaml b/tests/integration/targets/vyos_facts/tests/httpapi/all.yaml index 419634f..4d6ad53 100644 --- a/tests/integration/targets/vyos_facts/tests/httpapi/all.yaml +++ b/tests/integration/targets/vyos_facts/tests/httpapi/all.yaml @@ -3,6 +3,8 @@ msg: START vyos_facts all integration tests on connection={{ ansible_connection }} - block: + - include_tasks: _populate_config.yaml + - name: Gather all facts vyos.rest.vyos_facts: gather_subset: all @@ -14,3 +16,6 @@ - ansible_facts.vyos_users is defined - ansible_facts.vyos_config is defined - ansible_facts.vyos_logging is defined + + always: + - include_tasks: _remove_config.yaml diff --git a/tests/integration/targets/vyos_facts/tests/httpapi/default.yaml b/tests/integration/targets/vyos_facts/tests/httpapi/default.yaml index ff1caee..b5937be 100644 --- a/tests/integration/targets/vyos_facts/tests/httpapi/default.yaml +++ b/tests/integration/targets/vyos_facts/tests/httpapi/default.yaml @@ -5,9 +5,9 @@ - block: - name: Gather default facts vyos.rest.vyos_facts: + gather_subset: default - assert: that: - ansible_facts.vyos_hostname is defined - - ansible_facts.vyos_interfaces is defined - - ansible_facts.vyos_hostname | length > 0 + - ansible_facts.vyos_config is defined diff --git a/tests/integration/targets/vyos_facts/tests/httpapi/subsets.yaml b/tests/integration/targets/vyos_facts/tests/httpapi/subsets.yaml index eb4ca4f..5151b4a 100644 --- a/tests/integration/targets/vyos_facts/tests/httpapi/subsets.yaml +++ b/tests/integration/targets/vyos_facts/tests/httpapi/subsets.yaml @@ -3,31 +3,34 @@ msg: START vyos_facts subsets integration tests on connection={{ ansible_connection }} - block: - - name: Gather hostname only + - include_tasks: _populate_config.yaml + + - name: Gather logging subset vyos.rest.vyos_facts: gather_subset: - - hostname + - logging - assert: that: - - ansible_facts.vyos_hostname is defined + - ansible_facts.vyos_logging is defined - - name: Gather users + - name: Gather ntp subset vyos.rest.vyos_facts: gather_subset: - - users + - ntp - assert: that: - - ansible_facts.vyos_users is defined - - ansible_facts.vyos_users | selectattr('name', 'eq', 'vyos') | list | length == 1 + - ansible_facts.vyos_ntp is defined - - name: Gather config + - name: Gather interfaces subset vyos.rest.vyos_facts: gather_subset: - - config + - interfaces - assert: that: - - ansible_facts.vyos_config is defined - - ansible_facts.vyos_config.system is defined + - ansible_facts.vyos_interfaces is defined + + always: + - include_tasks: _remove_config.yaml diff --git a/tests/integration/targets/vyos_firewall_global/tasks/httpapi.yaml b/tests/integration/targets/vyos_firewall_global/tasks/httpapi.yaml index 4147e6d..0ed3e42 100644 --- a/tests/integration/targets/vyos_firewall_global/tasks/httpapi.yaml +++ b/tests/integration/targets/vyos_firewall_global/tasks/httpapi.yaml @@ -9,7 +9,7 @@ - name: Set test_items ansible.builtin.set_fact: - test_items: "{{ test_cases.files | map(attribute='path') | list }}" + test_items: "{{ test_cases.files | map(attribute='path') | list | sort }}" - name: Run test case (connection=httpapi) ansible.builtin.include_tasks: "{{ test_case_to_run }}" diff --git a/tests/integration/targets/vyos_firewall_global/tests/httpapi/deleted.yaml b/tests/integration/targets/vyos_firewall_global/tests/httpapi/deleted.yaml index ade5fe4..8f1b327 100644 --- a/tests/integration/targets/vyos_firewall_global/tests/httpapi/deleted.yaml +++ b/tests/integration/targets/vyos_firewall_global/tests/httpapi/deleted.yaml @@ -3,9 +3,9 @@ msg: START vyos_firewall_global deleted integration tests on connection={{ ansible_connection }} - include_tasks: _remove_config.yaml -- include_tasks: _populate_config.yaml - - block: + - include_tasks: _populate_config.yaml + - name: Delete firewall global configuration register: result vyos.rest.vyos_firewall_global: diff --git a/tests/integration/targets/vyos_firewall_global/tests/httpapi/gathered.yaml b/tests/integration/targets/vyos_firewall_global/tests/httpapi/gathered.yaml index 6f470bd..50df093 100644 --- a/tests/integration/targets/vyos_firewall_global/tests/httpapi/gathered.yaml +++ b/tests/integration/targets/vyos_firewall_global/tests/httpapi/gathered.yaml @@ -3,9 +3,9 @@ msg: START vyos_firewall_global gathered integration tests on connection={{ ansible_connection }} - include_tasks: _remove_config.yaml -- include_tasks: _populate_config.yaml - - block: + - include_tasks: _populate_config.yaml + - name: Gather firewall global configuration register: result vyos.rest.vyos_firewall_global: diff --git a/tests/integration/targets/vyos_firewall_global/tests/httpapi/overridden.yaml b/tests/integration/targets/vyos_firewall_global/tests/httpapi/overridden.yaml index 04d7c95..97e76b5 100644 --- a/tests/integration/targets/vyos_firewall_global/tests/httpapi/overridden.yaml +++ b/tests/integration/targets/vyos_firewall_global/tests/httpapi/overridden.yaml @@ -3,9 +3,9 @@ msg: START vyos_firewall_global overridden integration tests on connection={{ ansible_connection }} - include_tasks: _remove_config.yaml -- include_tasks: _populate_config.yaml - - block: + - include_tasks: _populate_config.yaml + - name: Override firewall global configuration register: result vyos.rest.vyos_firewall_global: &id001 diff --git a/tests/integration/targets/vyos_firewall_global/tests/httpapi/replaced.yaml b/tests/integration/targets/vyos_firewall_global/tests/httpapi/replaced.yaml index b5af06f..661d983 100644 --- a/tests/integration/targets/vyos_firewall_global/tests/httpapi/replaced.yaml +++ b/tests/integration/targets/vyos_firewall_global/tests/httpapi/replaced.yaml @@ -3,9 +3,9 @@ msg: START vyos_firewall_global replaced integration tests on connection={{ ansible_connection }} - include_tasks: _remove_config.yaml -- include_tasks: _populate_config.yaml - - block: + - include_tasks: _populate_config.yaml + - name: Replace firewall global configuration register: result vyos.rest.vyos_firewall_global: &id001 diff --git a/tests/integration/targets/vyos_firewall_interfaces/tasks/httpapi.yaml b/tests/integration/targets/vyos_firewall_interfaces/tasks/httpapi.yaml index 4147e6d..0ed3e42 100644 --- a/tests/integration/targets/vyos_firewall_interfaces/tasks/httpapi.yaml +++ b/tests/integration/targets/vyos_firewall_interfaces/tasks/httpapi.yaml @@ -9,7 +9,7 @@ - name: Set test_items ansible.builtin.set_fact: - test_items: "{{ test_cases.files | map(attribute='path') | list }}" + test_items: "{{ test_cases.files | map(attribute='path') | list | sort }}" - name: Run test case (connection=httpapi) ansible.builtin.include_tasks: "{{ test_case_to_run }}" diff --git a/tests/integration/targets/vyos_firewall_interfaces/tests/httpapi/deleted.yaml b/tests/integration/targets/vyos_firewall_interfaces/tests/httpapi/deleted.yaml index 00025f7..9e76d63 100644 --- a/tests/integration/targets/vyos_firewall_interfaces/tests/httpapi/deleted.yaml +++ b/tests/integration/targets/vyos_firewall_interfaces/tests/httpapi/deleted.yaml @@ -3,9 +3,9 @@ msg: START vyos_firewall_interfaces deleted integration tests on connection={{ ansible_connection }} - include_tasks: _remove_config.yaml -- include_tasks: _populate_config.yaml - - block: + - include_tasks: _populate_config.yaml + - name: Delete all firewall interfaces configuration register: result vyos.rest.vyos_firewall_interfaces: diff --git a/tests/integration/targets/vyos_firewall_interfaces/tests/httpapi/gathered.yaml b/tests/integration/targets/vyos_firewall_interfaces/tests/httpapi/gathered.yaml index 27ad537..192f2f0 100644 --- a/tests/integration/targets/vyos_firewall_interfaces/tests/httpapi/gathered.yaml +++ b/tests/integration/targets/vyos_firewall_interfaces/tests/httpapi/gathered.yaml @@ -3,9 +3,9 @@ msg: START vyos_firewall_interfaces gathered integration tests on connection={{ ansible_connection }} - include_tasks: _remove_config.yaml -- include_tasks: _populate_config.yaml - - block: + - include_tasks: _populate_config.yaml + - name: Gather firewall interfaces configuration register: result vyos.rest.vyos_firewall_interfaces: diff --git a/tests/integration/targets/vyos_firewall_interfaces/tests/httpapi/overridden.yaml b/tests/integration/targets/vyos_firewall_interfaces/tests/httpapi/overridden.yaml index 8a20a48..ff69cc1 100644 --- a/tests/integration/targets/vyos_firewall_interfaces/tests/httpapi/overridden.yaml +++ b/tests/integration/targets/vyos_firewall_interfaces/tests/httpapi/overridden.yaml @@ -3,9 +3,9 @@ msg: START vyos_firewall_interfaces overridden integration tests on connection={{ ansible_connection }} - include_tasks: _remove_config.yaml -- include_tasks: _populate_config.yaml - - block: + - include_tasks: _populate_config.yaml + - name: Override firewall interfaces configuration register: result vyos.rest.vyos_firewall_interfaces: &id001 diff --git a/tests/integration/targets/vyos_firewall_interfaces/tests/httpapi/replaced.yaml b/tests/integration/targets/vyos_firewall_interfaces/tests/httpapi/replaced.yaml index f782fc6..7109dd4 100644 --- a/tests/integration/targets/vyos_firewall_interfaces/tests/httpapi/replaced.yaml +++ b/tests/integration/targets/vyos_firewall_interfaces/tests/httpapi/replaced.yaml @@ -3,9 +3,9 @@ msg: START vyos_firewall_interfaces replaced integration tests on connection={{ ansible_connection }} - include_tasks: _remove_config.yaml -- include_tasks: _populate_config.yaml - - block: + - include_tasks: _populate_config.yaml + - name: Replace firewall interfaces configuration register: result vyos.rest.vyos_firewall_interfaces: &id001 diff --git a/tests/integration/targets/vyos_firewall_rules/tasks/httpapi.yaml b/tests/integration/targets/vyos_firewall_rules/tasks/httpapi.yaml index 4147e6d..0ed3e42 100644 --- a/tests/integration/targets/vyos_firewall_rules/tasks/httpapi.yaml +++ b/tests/integration/targets/vyos_firewall_rules/tasks/httpapi.yaml @@ -9,7 +9,7 @@ - name: Set test_items ansible.builtin.set_fact: - test_items: "{{ test_cases.files | map(attribute='path') | list }}" + test_items: "{{ test_cases.files | map(attribute='path') | list | sort }}" - name: Run test case (connection=httpapi) ansible.builtin.include_tasks: "{{ test_case_to_run }}" diff --git a/tests/integration/targets/vyos_firewall_rules/tests/httpapi/deleted.yaml b/tests/integration/targets/vyos_firewall_rules/tests/httpapi/deleted.yaml index db4cbf0..78a4d62 100644 --- a/tests/integration/targets/vyos_firewall_rules/tests/httpapi/deleted.yaml +++ b/tests/integration/targets/vyos_firewall_rules/tests/httpapi/deleted.yaml @@ -3,9 +3,9 @@ msg: START vyos_firewall_rules deleted integration tests on connection={{ ansible_connection }} - include_tasks: _remove_config.yaml -- include_tasks: _populate_config.yaml - - block: + - include_tasks: _populate_config.yaml + - name: Delete all firewall rules configuration register: result vyos.rest.vyos_firewall_rules: diff --git a/tests/integration/targets/vyos_firewall_rules/tests/httpapi/gathered.yaml b/tests/integration/targets/vyos_firewall_rules/tests/httpapi/gathered.yaml index 6729976..89f54ca 100644 --- a/tests/integration/targets/vyos_firewall_rules/tests/httpapi/gathered.yaml +++ b/tests/integration/targets/vyos_firewall_rules/tests/httpapi/gathered.yaml @@ -3,9 +3,9 @@ msg: START vyos_firewall_rules gathered integration tests on connection={{ ansible_connection }} - include_tasks: _remove_config.yaml -- include_tasks: _populate_config.yaml - - block: + - include_tasks: _populate_config.yaml + - name: Gather firewall rules configuration register: result vyos.rest.vyos_firewall_rules: diff --git a/tests/integration/targets/vyos_firewall_rules/tests/httpapi/overridden.yaml b/tests/integration/targets/vyos_firewall_rules/tests/httpapi/overridden.yaml index d5db4b9..31dcc8d 100644 --- a/tests/integration/targets/vyos_firewall_rules/tests/httpapi/overridden.yaml +++ b/tests/integration/targets/vyos_firewall_rules/tests/httpapi/overridden.yaml @@ -3,9 +3,9 @@ msg: START vyos_firewall_rules overridden integration tests on connection={{ ansible_connection }} - include_tasks: _remove_config.yaml -- include_tasks: _populate_config.yaml - - block: + - include_tasks: _populate_config.yaml + - name: Override firewall rules configuration register: result vyos.rest.vyos_firewall_rules: &id001 diff --git a/tests/integration/targets/vyos_firewall_rules/tests/httpapi/replaced.yaml b/tests/integration/targets/vyos_firewall_rules/tests/httpapi/replaced.yaml index 92033c4..1200fb7 100644 --- a/tests/integration/targets/vyos_firewall_rules/tests/httpapi/replaced.yaml +++ b/tests/integration/targets/vyos_firewall_rules/tests/httpapi/replaced.yaml @@ -3,9 +3,9 @@ msg: START vyos_firewall_rules replaced integration tests on connection={{ ansible_connection }} - include_tasks: _remove_config.yaml -- include_tasks: _populate_config.yaml - - block: + - include_tasks: _populate_config.yaml + - name: Replace firewall rules configuration register: result vyos.rest.vyos_firewall_rules: &id001 diff --git a/tests/integration/targets/vyos_ha/aliases b/tests/integration/targets/vyos_ha/aliases new file mode 100644 index 0000000..cc0afef --- /dev/null +++ b/tests/integration/targets/vyos_ha/aliases @@ -0,0 +1 @@ +network/vyos diff --git a/tests/integration/targets/vyos_ha/defaults/main.yaml b/tests/integration/targets/vyos_ha/defaults/main.yaml new file mode 100644 index 0000000..164afea --- /dev/null +++ b/tests/integration/targets/vyos_ha/defaults/main.yaml @@ -0,0 +1,3 @@ +--- +testcase: "[^_].*" +test_items: [] diff --git a/tests/integration/targets/vyos_ha/tasks/httpapi.yaml b/tests/integration/targets/vyos_ha/tasks/httpapi.yaml new file mode 100644 index 0000000..014aa9d --- /dev/null +++ b/tests/integration/targets/vyos_ha/tasks/httpapi.yaml @@ -0,0 +1,18 @@ +--- +- name: Collect all httpapi test cases + ansible.builtin.find: + paths: "{{ role_path }}/tests/httpapi" + patterns: "*.yaml" + excludes: "_*.yaml" + register: test_cases + delegate_to: localhost + +- name: Set test_items + ansible.builtin.set_fact: + test_items: "{{ test_cases.files | map(attribute='path') | list | sort }}" + +- name: Run test case (connection=httpapi) + ansible.builtin.include_tasks: "{{ test_case_to_run }}" + loop: "{{ test_items }}" + loop_control: + loop_var: test_case_to_run diff --git a/tests/integration/targets/vyos_ha/tasks/main.yaml b/tests/integration/targets/vyos_ha/tasks/main.yaml new file mode 100644 index 0000000..d14215c --- /dev/null +++ b/tests/integration/targets/vyos_ha/tasks/main.yaml @@ -0,0 +1,3 @@ +--- +- name: Run httpapi tests + ansible.builtin.include_tasks: httpapi.yaml diff --git a/tests/integration/targets/vyos_ha/tests/httpapi/_populate_config.yaml b/tests/integration/targets/vyos_ha/tests/httpapi/_populate_config.yaml new file mode 100644 index 0000000..0a73b89 --- /dev/null +++ b/tests/integration/targets/vyos_ha/tests/httpapi/_populate_config.yaml @@ -0,0 +1,40 @@ +--- +- name: Populate HA config for testing + vyos.rest.vyos_ha: + config: + vrrp: + global_parameters: + startup_delay: 30 + garp: + master_repeat: 6 + groups: + - name: g1 + interface: eth0 + vrid: 20 + priority: 100 + address: + - 192.168.1.100/24 + peer_address: 192.168.1.3 + - name: g2 + interface: eth1 + vrid: 21 + priority: 50 + address: + - 192.168.2.100/24 + sync_groups: + - name: sg1 + member: [g1] + health_check: + failure_count: 5 + ping: 192.168.1.1 + snmp: enabled + virtual_servers: + - name: s1 + address: 10.10.10.5 + algorithm: round-robin + port: 80 + protocol: tcp + real_server: + - address: 10.10.50.2 + port: 8080 + state: merged diff --git a/tests/integration/targets/vyos_ha/tests/httpapi/_remove_config.yaml b/tests/integration/targets/vyos_ha/tests/httpapi/_remove_config.yaml new file mode 100644 index 0000000..77a90d0 --- /dev/null +++ b/tests/integration/targets/vyos_ha/tests/httpapi/_remove_config.yaml @@ -0,0 +1,4 @@ +--- +- name: Remove all HA configuration + vyos.rest.vyos_ha: + state: deleted diff --git a/tests/integration/targets/vyos_ha/tests/httpapi/deleted.yaml b/tests/integration/targets/vyos_ha/tests/httpapi/deleted.yaml new file mode 100644 index 0000000..571e02d --- /dev/null +++ b/tests/integration/targets/vyos_ha/tests/httpapi/deleted.yaml @@ -0,0 +1,36 @@ +--- +- debug: + msg: START vyos_ha deleted integration tests on connection={{ ansible_connection }} + +- include_tasks: _remove_config.yaml +- block: + - include_tasks: _populate_config.yaml + + - name: Delete all HA configuration + vyos.rest.vyos_ha: &id001 + state: deleted + register: result + + - assert: + that: + - result.changed == true + + - name: Gather after delete + vyos.rest.vyos_ha: + state: gathered + register: gathered + + - assert: + that: + - gathered.gathered == {} + + - name: Delete again (IDEMPOTENT) + vyos.rest.vyos_ha: *id001 + register: result + + - assert: + that: + - result.changed == false + + always: + - include_tasks: _remove_config.yaml diff --git a/tests/integration/targets/vyos_ha/tests/httpapi/gathered.yaml b/tests/integration/targets/vyos_ha/tests/httpapi/gathered.yaml new file mode 100644 index 0000000..cf261fb --- /dev/null +++ b/tests/integration/targets/vyos_ha/tests/httpapi/gathered.yaml @@ -0,0 +1,29 @@ +--- +- debug: + msg: START vyos_ha gathered integration tests on connection={{ ansible_connection }} + +- include_tasks: _remove_config.yaml +- block: + - include_tasks: _populate_config.yaml + + - name: Gather HA configuration + vyos.rest.vyos_ha: + state: gathered + register: result + + - assert: + that: + - result.changed == false + - "'vrrp' in result.gathered" + - "'virtual_servers' in result.gathered" + - result.gathered.vrrp.groups | length == 2 + - result.gathered.vrrp.groups | selectattr('name','equalto','g1') | list | length == 1 + - result.gathered.vrrp.groups | selectattr('name','equalto','g2') | list | length == 1 + - result.gathered.vrrp.snmp == "enabled" + - result.gathered.vrrp.global_parameters.startup_delay == 30 + - result.gathered.vrrp.sync_groups[0].name == "sg1" + - result.gathered.vrrp.sync_groups[0].member == ["g1"] + - result.gathered.virtual_servers[0].name == "s1" + + always: + - include_tasks: _remove_config.yaml diff --git a/tests/integration/targets/vyos_ha/tests/httpapi/merged.yaml b/tests/integration/targets/vyos_ha/tests/httpapi/merged.yaml new file mode 100644 index 0000000..5614720 --- /dev/null +++ b/tests/integration/targets/vyos_ha/tests/httpapi/merged.yaml @@ -0,0 +1,54 @@ +--- +- debug: + msg: START vyos_ha merged integration tests on connection={{ ansible_connection }} + +- include_tasks: _remove_config.yaml + +- block: + - name: Merge HA configuration + vyos.rest.vyos_ha: &id001 + config: + vrrp: + global_parameters: + startup_delay: 30 + groups: + - name: g1 + interface: eth0 + vrid: 20 + priority: 100 + address: + - 192.168.1.100/24 + sync_groups: + - name: sg1 + member: [g1] + health_check: + failure_count: 3 + ping: 192.168.1.1 + snmp: enabled + virtual_servers: + - name: s1 + address: 10.10.10.5 + port: 80 + protocol: tcp + real_server: + - address: 10.10.50.2 + port: 8080 + state: merged + register: result + + - assert: + that: + - result.changed == true + - result.commands | length > 0 + + - name: Merge again (IDEMPOTENT) + vyos.rest.vyos_ha: *id001 + register: result + + - assert: + that: + - result.changed == false + - result.commands == [] + + always: + - include_tasks: _remove_config.yaml diff --git a/tests/integration/targets/vyos_ha/tests/httpapi/overridden.yaml b/tests/integration/targets/vyos_ha/tests/httpapi/overridden.yaml new file mode 100644 index 0000000..c916e2d --- /dev/null +++ b/tests/integration/targets/vyos_ha/tests/httpapi/overridden.yaml @@ -0,0 +1,50 @@ +--- +- debug: + msg: START vyos_ha overridden integration tests on connection={{ ansible_connection }} + +- include_tasks: _remove_config.yaml +- block: + - include_tasks: _populate_config.yaml + + - name: Override - only g1 remains, virtual_servers removed + vyos.rest.vyos_ha: &id001 + config: + vrrp: + groups: + - name: g1 + interface: eth0 + vrid: 20 + priority: 100 + address: + - 192.168.1.100/24 + state: overridden + register: result + + - assert: + that: + - result.changed == true + + - name: Gather after override + vyos.rest.vyos_ha: + state: gathered + register: gathered + + - assert: + that: + - gathered.gathered.vrrp.groups | length == 1 + - gathered.gathered.vrrp.groups[0].name == "g1" + - "'virtual_servers' not in gathered.gathered" + - "'sync_groups' not in gathered.gathered.vrrp" + - "'snmp' not in gathered.gathered.vrrp" + + - name: Override again (IDEMPOTENT) + vyos.rest.vyos_ha: *id001 + register: result + + - assert: + that: + - result.changed == false + - result.commands == [] + + always: + - include_tasks: _remove_config.yaml diff --git a/tests/integration/targets/vyos_ha/tests/httpapi/replaced.yaml b/tests/integration/targets/vyos_ha/tests/httpapi/replaced.yaml new file mode 100644 index 0000000..6ad458b --- /dev/null +++ b/tests/integration/targets/vyos_ha/tests/httpapi/replaced.yaml @@ -0,0 +1,48 @@ +--- +- debug: + msg: START vyos_ha replaced integration tests on connection={{ ansible_connection }} + +- include_tasks: _remove_config.yaml +- block: + - include_tasks: _populate_config.yaml + + - name: Replace vrrp groups - removes g2, keeps virtual_servers + vyos.rest.vyos_ha: &id001 + config: + vrrp: + groups: + - name: g1 + interface: eth0 + vrid: 20 + priority: 100 + address: + - 192.168.1.100/24 + state: replaced + register: result + + - assert: + that: + - result.changed == true + + - name: Gather after replace + vyos.rest.vyos_ha: + state: gathered + register: gathered + + - assert: + that: + - gathered.gathered.vrrp.groups | length == 1 + - gathered.gathered.vrrp.groups[0].name == "g1" + - "'virtual_servers' in gathered.gathered" + + - name: Replace again (IDEMPOTENT) + vyos.rest.vyos_ha: *id001 + register: result + + - assert: + that: + - result.changed == false + - result.commands == [] + + always: + - include_tasks: _remove_config.yaml diff --git a/tests/integration/targets/vyos_ha/vars/main.yaml b/tests/integration/targets/vyos_ha/vars/main.yaml new file mode 100644 index 0000000..4303881 --- /dev/null +++ b/tests/integration/targets/vyos_ha/vars/main.yaml @@ -0,0 +1,2 @@ +--- +# only common vars here diff --git a/tests/integration/targets/vyos_hostname/tasks/httpapi.yaml b/tests/integration/targets/vyos_hostname/tasks/httpapi.yaml index 4147e6d..0ed3e42 100644 --- a/tests/integration/targets/vyos_hostname/tasks/httpapi.yaml +++ b/tests/integration/targets/vyos_hostname/tasks/httpapi.yaml @@ -9,7 +9,7 @@ - name: Set test_items ansible.builtin.set_fact: - test_items: "{{ test_cases.files | map(attribute='path') | list }}" + test_items: "{{ test_cases.files | map(attribute='path') | list | sort }}" - name: Run test case (connection=httpapi) ansible.builtin.include_tasks: "{{ test_case_to_run }}" diff --git a/tests/integration/targets/vyos_hostname/tests/httpapi/gathered.yaml b/tests/integration/targets/vyos_hostname/tests/httpapi/gathered.yaml index dfc8896..e8e0973 100644 --- a/tests/integration/targets/vyos_hostname/tests/httpapi/gathered.yaml +++ b/tests/integration/targets/vyos_hostname/tests/httpapi/gathered.yaml @@ -2,9 +2,9 @@ - debug: msg: START vyos_hostname gathered integration tests on connection={{ ansible_connection }} -- include_tasks: _populate_config.yaml - - block: + - include_tasks: _populate_config.yaml + - name: Gather hostname configuration register: result vyos.rest.vyos_hostname: diff --git a/tests/integration/targets/vyos_hostname/tests/httpapi/replaced.yaml b/tests/integration/targets/vyos_hostname/tests/httpapi/replaced.yaml index b4e318d..0d418e2 100644 --- a/tests/integration/targets/vyos_hostname/tests/httpapi/replaced.yaml +++ b/tests/integration/targets/vyos_hostname/tests/httpapi/replaced.yaml @@ -2,9 +2,9 @@ - debug: msg: START vyos_hostname replaced integration tests on connection={{ ansible_connection }} -- include_tasks: _populate_config.yaml - - block: + - include_tasks: _populate_config.yaml + - name: Replace hostname configuration register: result vyos.rest.vyos_hostname: &id001 diff --git a/tests/integration/targets/vyos_lldp_global/tasks/httpapi.yaml b/tests/integration/targets/vyos_lldp_global/tasks/httpapi.yaml index 4147e6d..0ed3e42 100644 --- a/tests/integration/targets/vyos_lldp_global/tasks/httpapi.yaml +++ b/tests/integration/targets/vyos_lldp_global/tasks/httpapi.yaml @@ -9,7 +9,7 @@ - name: Set test_items ansible.builtin.set_fact: - test_items: "{{ test_cases.files | map(attribute='path') | list }}" + test_items: "{{ test_cases.files | map(attribute='path') | list | sort }}" - name: Run test case (connection=httpapi) ansible.builtin.include_tasks: "{{ test_case_to_run }}" diff --git a/tests/integration/targets/vyos_lldp_global/tests/httpapi/deleted.yaml b/tests/integration/targets/vyos_lldp_global/tests/httpapi/deleted.yaml index d084d4a..bb0f7fc 100644 --- a/tests/integration/targets/vyos_lldp_global/tests/httpapi/deleted.yaml +++ b/tests/integration/targets/vyos_lldp_global/tests/httpapi/deleted.yaml @@ -3,9 +3,9 @@ msg: START vyos_lldp_global deleted integration tests on connection={{ ansible_connection }} - include_tasks: _remove_config.yaml -- include_tasks: _populate_config.yaml - - block: + - include_tasks: _populate_config.yaml + - name: Delete all lldp_global configuration register: result vyos.rest.vyos_lldp_global: &id001 diff --git a/tests/integration/targets/vyos_lldp_global/tests/httpapi/gathered.yaml b/tests/integration/targets/vyos_lldp_global/tests/httpapi/gathered.yaml index 20ac2b2..58c55cf 100644 --- a/tests/integration/targets/vyos_lldp_global/tests/httpapi/gathered.yaml +++ b/tests/integration/targets/vyos_lldp_global/tests/httpapi/gathered.yaml @@ -3,9 +3,9 @@ msg: START vyos_lldp_global gathered integration tests on connection={{ ansible_connection }} - include_tasks: _remove_config.yaml -- include_tasks: _populate_config.yaml - - block: + - include_tasks: _populate_config.yaml + - name: Gather lldp_global configuration register: result vyos.rest.vyos_lldp_global: diff --git a/tests/integration/targets/vyos_lldp_global/tests/httpapi/replaced.yaml b/tests/integration/targets/vyos_lldp_global/tests/httpapi/replaced.yaml index d5523eb..edc908a 100644 --- a/tests/integration/targets/vyos_lldp_global/tests/httpapi/replaced.yaml +++ b/tests/integration/targets/vyos_lldp_global/tests/httpapi/replaced.yaml @@ -3,9 +3,9 @@ msg: START vyos_lldp_global replaced integration tests on connection={{ ansible_connection }} - include_tasks: _remove_config.yaml -- include_tasks: _populate_config.yaml - - block: + - include_tasks: _populate_config.yaml + - name: Replace lldp_global configuration register: result vyos.rest.vyos_lldp_global: &id001 diff --git a/tests/integration/targets/vyos_logging_global/tasks/httpapi.yaml b/tests/integration/targets/vyos_logging_global/tasks/httpapi.yaml index 4147e6d..0ed3e42 100644 --- a/tests/integration/targets/vyos_logging_global/tasks/httpapi.yaml +++ b/tests/integration/targets/vyos_logging_global/tasks/httpapi.yaml @@ -9,7 +9,7 @@ - name: Set test_items ansible.builtin.set_fact: - test_items: "{{ test_cases.files | map(attribute='path') | list }}" + test_items: "{{ test_cases.files | map(attribute='path') | list | sort }}" - name: Run test case (connection=httpapi) ansible.builtin.include_tasks: "{{ test_case_to_run }}" diff --git a/tests/integration/targets/vyos_logging_global/tests/httpapi/deleted.yaml b/tests/integration/targets/vyos_logging_global/tests/httpapi/deleted.yaml index 8c62627..6eb4ed9 100644 --- a/tests/integration/targets/vyos_logging_global/tests/httpapi/deleted.yaml +++ b/tests/integration/targets/vyos_logging_global/tests/httpapi/deleted.yaml @@ -3,9 +3,9 @@ msg: START vyos_logging_global deleted integration tests on connection={{ ansible_connection }} - include_tasks: _remove_config.yaml -- include_tasks: _populate_config.yaml - - block: + - include_tasks: _populate_config.yaml + - name: Delete all logging_global configuration register: result vyos.rest.vyos_logging_global: &id001 diff --git a/tests/integration/targets/vyos_logging_global/tests/httpapi/gathered.yaml b/tests/integration/targets/vyos_logging_global/tests/httpapi/gathered.yaml index 667c088..660d1fc 100644 --- a/tests/integration/targets/vyos_logging_global/tests/httpapi/gathered.yaml +++ b/tests/integration/targets/vyos_logging_global/tests/httpapi/gathered.yaml @@ -3,9 +3,9 @@ msg: START vyos_logging_global gathered integration tests on connection={{ ansible_connection }} - include_tasks: _remove_config.yaml -- include_tasks: _populate_config.yaml - - block: + - include_tasks: _populate_config.yaml + - name: Gather logging_global configuration register: result vyos.rest.vyos_logging_global: diff --git a/tests/integration/targets/vyos_logging_global/tests/httpapi/replaced.yaml b/tests/integration/targets/vyos_logging_global/tests/httpapi/replaced.yaml index a1c24c0..3ecd341 100644 --- a/tests/integration/targets/vyos_logging_global/tests/httpapi/replaced.yaml +++ b/tests/integration/targets/vyos_logging_global/tests/httpapi/replaced.yaml @@ -3,9 +3,9 @@ msg: START vyos_logging_global replaced integration tests on connection={{ ansible_connection }} - include_tasks: _remove_config.yaml -- include_tasks: _populate_config.yaml - - block: + - include_tasks: _populate_config.yaml + - name: Replace logging_global configuration register: result vyos.rest.vyos_logging_global: &id001 diff --git a/tests/integration/targets/vyos_nat/aliases b/tests/integration/targets/vyos_nat/aliases new file mode 100644 index 0000000..cc0afef --- /dev/null +++ b/tests/integration/targets/vyos_nat/aliases @@ -0,0 +1 @@ +network/vyos diff --git a/tests/integration/targets/vyos_nat/defaults/main.yaml b/tests/integration/targets/vyos_nat/defaults/main.yaml new file mode 100644 index 0000000..164afea --- /dev/null +++ b/tests/integration/targets/vyos_nat/defaults/main.yaml @@ -0,0 +1,3 @@ +--- +testcase: "[^_].*" +test_items: [] diff --git a/tests/integration/targets/vyos_nat/tasks/httpapi.yaml b/tests/integration/targets/vyos_nat/tasks/httpapi.yaml new file mode 100644 index 0000000..014aa9d --- /dev/null +++ b/tests/integration/targets/vyos_nat/tasks/httpapi.yaml @@ -0,0 +1,18 @@ +--- +- name: Collect all httpapi test cases + ansible.builtin.find: + paths: "{{ role_path }}/tests/httpapi" + patterns: "*.yaml" + excludes: "_*.yaml" + register: test_cases + delegate_to: localhost + +- name: Set test_items + ansible.builtin.set_fact: + test_items: "{{ test_cases.files | map(attribute='path') | list | sort }}" + +- name: Run test case (connection=httpapi) + ansible.builtin.include_tasks: "{{ test_case_to_run }}" + loop: "{{ test_items }}" + loop_control: + loop_var: test_case_to_run diff --git a/tests/integration/targets/vyos_nat/tasks/main.yaml b/tests/integration/targets/vyos_nat/tasks/main.yaml new file mode 100644 index 0000000..b1f6193 --- /dev/null +++ b/tests/integration/targets/vyos_nat/tasks/main.yaml @@ -0,0 +1,5 @@ +--- +- name: Run httpapi tests + ansible.builtin.include_tasks: httpapi.yaml + tags: + - httpapi diff --git a/tests/integration/targets/vyos_nat/tests/httpapi/_populate_config.yaml b/tests/integration/targets/vyos_nat/tests/httpapi/_populate_config.yaml new file mode 100644 index 0000000..fd543d5 --- /dev/null +++ b/tests/integration/targets/vyos_nat/tests/httpapi/_populate_config.yaml @@ -0,0 +1,39 @@ +--- +- name: Populate NAT config for testing + vyos.rest.vyos_nat: + config: + nat: + source: + rule: + - id: 100 + description: "Source rule 100" + outbound_interface: + name: eth0 + translation: + address: masquerade + - id: 101 + outbound_interface: + name: eth1 + translation: + address: masquerade + destination: + rule: + - id: 200 + protocol: tcp + inbound_interface: + name: eth0 + destination: + address: 198.51.100.10 + port: "80" + translation: + address: 192.168.1.10 + port: "8080" + static: + rule: + - id: 300 + inbound_interface: eth0 + destination: + address: 198.51.100.20 + translation: + address: 192.168.1.20 + state: merged diff --git a/tests/integration/targets/vyos_nat/tests/httpapi/_remove_config.yaml b/tests/integration/targets/vyos_nat/tests/httpapi/_remove_config.yaml new file mode 100644 index 0000000..8ee616b --- /dev/null +++ b/tests/integration/targets/vyos_nat/tests/httpapi/_remove_config.yaml @@ -0,0 +1,5 @@ +--- +- name: Remove all NAT configuration + vyos.rest.vyos_nat: + state: deleted + ignore_errors: true diff --git a/tests/integration/targets/vyos_nat/tests/httpapi/deleted.yaml b/tests/integration/targets/vyos_nat/tests/httpapi/deleted.yaml new file mode 100644 index 0000000..2d0cff5 --- /dev/null +++ b/tests/integration/targets/vyos_nat/tests/httpapi/deleted.yaml @@ -0,0 +1,36 @@ +--- +- debug: + msg: START vyos_nat deleted integration tests on connection={{ ansible_connection }} + +- include_tasks: _remove_config.yaml +- block: + - include_tasks: _populate_config.yaml + + - name: Delete all NAT configuration + vyos.rest.vyos_nat: &id001 + state: deleted + register: result + + - assert: + that: + - result.changed == true + + - name: Gather after delete + vyos.rest.vyos_nat: + state: gathered + register: gathered + + - assert: + that: + - gathered.gathered == {} + + - name: Delete again (IDEMPOTENT) + vyos.rest.vyos_nat: *id001 + register: result + + - assert: + that: + - result.changed == false + + always: + - include_tasks: _remove_config.yaml diff --git a/tests/integration/targets/vyos_nat/tests/httpapi/gathered.yaml b/tests/integration/targets/vyos_nat/tests/httpapi/gathered.yaml new file mode 100644 index 0000000..1aca580 --- /dev/null +++ b/tests/integration/targets/vyos_nat/tests/httpapi/gathered.yaml @@ -0,0 +1,30 @@ +--- +- debug: + msg: START vyos_nat gathered integration tests on connection={{ ansible_connection }} + +- include_tasks: _remove_config.yaml +- block: + - include_tasks: _populate_config.yaml + + - name: Gather NAT configuration + vyos.rest.vyos_nat: + state: gathered + register: result + + - assert: + that: + - result.changed == false + - "'nat' in result.gathered" + - "'source' in result.gathered.nat" + - "'destination' in result.gathered.nat" + - "'static' in result.gathered.nat" + - result.gathered.nat.source.rule | length == 2 + - result.gathered.nat.source.rule[0].id == 100 + - result.gathered.nat.source.rule[1].id == 101 + - result.gathered.nat.destination.rule[0].id == 200 + - result.gathered.nat.destination.rule[0].protocol == "tcp" + - result.gathered.nat.static.rule[0].id == 300 + - result.gathered.nat.static.rule[0].inbound_interface == "eth0" + + always: + - include_tasks: _remove_config.yaml diff --git a/tests/integration/targets/vyos_nat/tests/httpapi/merged.yaml b/tests/integration/targets/vyos_nat/tests/httpapi/merged.yaml new file mode 100644 index 0000000..c64fdb8 --- /dev/null +++ b/tests/integration/targets/vyos_nat/tests/httpapi/merged.yaml @@ -0,0 +1,49 @@ +--- +- debug: + msg: START vyos_nat merged integration tests on connection={{ ansible_connection }} + +- include_tasks: _remove_config.yaml + +- block: + - name: Merge NAT configuration + vyos.rest.vyos_nat: &id001 + config: + nat: + source: + rule: + - id: 100 + outbound_interface: + name: eth0 + translation: + address: masquerade + destination: + rule: + - id: 200 + protocol: tcp + inbound_interface: + name: eth0 + destination: + address: 198.51.100.10 + port: "80" + translation: + address: 192.168.1.10 + port: "8080" + state: merged + register: result + + - assert: + that: + - result.changed == true + - result.commands | length > 0 + + - name: Merge again (IDEMPOTENT) + vyos.rest.vyos_nat: *id001 + register: result + + - assert: + that: + - result.changed == false + - result.commands == [] + + always: + - include_tasks: _remove_config.yaml diff --git a/tests/integration/targets/vyos_nat/tests/httpapi/overridden.yaml b/tests/integration/targets/vyos_nat/tests/httpapi/overridden.yaml new file mode 100644 index 0000000..d753cc4 --- /dev/null +++ b/tests/integration/targets/vyos_nat/tests/httpapi/overridden.yaml @@ -0,0 +1,49 @@ +--- +- debug: + msg: START vyos_nat overridden integration tests on connection={{ ansible_connection }} + +- include_tasks: _remove_config.yaml +- block: + - include_tasks: _populate_config.yaml + + - name: Override - replaces all NAT with only one rule + vyos.rest.vyos_nat: &id001 + config: + nat: + source: + rule: + - id: 999 + outbound_interface: + name: eth0 + translation: + address: masquerade + state: overridden + register: result + + - assert: + that: + - result.changed == true + + - name: Gather after override + vyos.rest.vyos_nat: + state: gathered + register: gathered + + - assert: + that: + - gathered.gathered.nat.source.rule | length == 1 + - gathered.gathered.nat.source.rule[0].id == 999 + - "'destination' not in gathered.gathered.nat" + - "'static' not in gathered.gathered.nat" + + - name: Override again (IDEMPOTENT) + vyos.rest.vyos_nat: *id001 + register: result + + - assert: + that: + - result.changed == false + - result.commands == [] + + always: + - include_tasks: _remove_config.yaml diff --git a/tests/integration/targets/vyos_nat/tests/httpapi/replaced.yaml b/tests/integration/targets/vyos_nat/tests/httpapi/replaced.yaml new file mode 100644 index 0000000..67a16f7 --- /dev/null +++ b/tests/integration/targets/vyos_nat/tests/httpapi/replaced.yaml @@ -0,0 +1,49 @@ +--- +- debug: + msg: START vyos_nat replaced integration tests on connection={{ ansible_connection }} + +- include_tasks: _remove_config.yaml +- block: + - include_tasks: _populate_config.yaml + + - name: Replace source NAT - removes rule 101, keeps destination and static + vyos.rest.vyos_nat: &id001 + config: + nat: + source: + rule: + - id: 100 + outbound_interface: + name: eth0 + translation: + address: masquerade + state: replaced + register: result + + - assert: + that: + - result.changed == true + + - name: Gather after replace + vyos.rest.vyos_nat: + state: gathered + register: gathered + + - assert: + that: + - gathered.gathered.nat.source.rule | length == 1 + - gathered.gathered.nat.source.rule[0].id == 100 + - "'destination' in gathered.gathered.nat" + - "'static' in gathered.gathered.nat" + + - name: Replace again (IDEMPOTENT) + vyos.rest.vyos_nat: *id001 + register: result + + - assert: + that: + - result.changed == false + - result.commands == [] + + always: + - include_tasks: _remove_config.yaml diff --git a/tests/integration/targets/vyos_nat/vars/main.yaml b/tests/integration/targets/vyos_nat/vars/main.yaml new file mode 100644 index 0000000..4303881 --- /dev/null +++ b/tests/integration/targets/vyos_nat/vars/main.yaml @@ -0,0 +1,2 @@ +--- +# only common vars here diff --git a/tests/integration/targets/vyos_ntp_global/tasks/httpapi.yaml b/tests/integration/targets/vyos_ntp_global/tasks/httpapi.yaml index 4147e6d..0ed3e42 100644 --- a/tests/integration/targets/vyos_ntp_global/tasks/httpapi.yaml +++ b/tests/integration/targets/vyos_ntp_global/tasks/httpapi.yaml @@ -9,7 +9,7 @@ - name: Set test_items ansible.builtin.set_fact: - test_items: "{{ test_cases.files | map(attribute='path') | list }}" + test_items: "{{ test_cases.files | map(attribute='path') | list | sort }}" - name: Run test case (connection=httpapi) ansible.builtin.include_tasks: "{{ test_case_to_run }}" diff --git a/tests/integration/targets/vyos_ntp_global/tests/httpapi/deleted.yaml b/tests/integration/targets/vyos_ntp_global/tests/httpapi/deleted.yaml index 957e119..d831f18 100644 --- a/tests/integration/targets/vyos_ntp_global/tests/httpapi/deleted.yaml +++ b/tests/integration/targets/vyos_ntp_global/tests/httpapi/deleted.yaml @@ -3,9 +3,9 @@ msg: START vyos_ntp_global deleted integration tests on connection={{ ansible_connection }} - include_tasks: _remove_config.yaml -- include_tasks: _populate_config.yaml - - block: + - include_tasks: _populate_config.yaml + - name: Delete all NTP configuration register: result vyos.rest.vyos_ntp_global: &id001 @@ -20,7 +20,7 @@ that: - result.after.allow_clients == [] - result.after.listen_addresses == [] - - result.after.servers == {} + - result.after.servers == [] - name: Delete the existing configuration (IDEMPOTENT) register: result diff --git a/tests/integration/targets/vyos_ntp_global/tests/httpapi/gathered.yaml b/tests/integration/targets/vyos_ntp_global/tests/httpapi/gathered.yaml index 73ee1ff..61a7d3d 100644 --- a/tests/integration/targets/vyos_ntp_global/tests/httpapi/gathered.yaml +++ b/tests/integration/targets/vyos_ntp_global/tests/httpapi/gathered.yaml @@ -3,9 +3,9 @@ msg: START vyos_ntp_global gathered integration tests on connection={{ ansible_connection }} - include_tasks: _remove_config.yaml -- include_tasks: _populate_config.yaml - - block: + - include_tasks: _populate_config.yaml + - name: Gather NTP configuration register: result vyos.rest.vyos_ntp_global: @@ -16,8 +16,8 @@ - result.changed == false - "'10.4.9.0/24' in result.gathered.allow_clients" - "'10.1.9.16' in result.gathered.listen_addresses" - - "'10.3.6.5' in result.gathered.servers" - - "'server4.example.com' in result.gathered.servers" + - "'10.3.6.5' in result.gathered.servers | map(attribute='server')" + - "'server4.example.com' in result.gathered.servers | map(attribute='server')" always: - include_tasks: _remove_config.yaml diff --git a/tests/integration/targets/vyos_ntp_global/tests/httpapi/overridden.yaml b/tests/integration/targets/vyos_ntp_global/tests/httpapi/overridden.yaml index deee2c0..a405b81 100644 --- a/tests/integration/targets/vyos_ntp_global/tests/httpapi/overridden.yaml +++ b/tests/integration/targets/vyos_ntp_global/tests/httpapi/overridden.yaml @@ -3,9 +3,9 @@ msg: START vyos_ntp_global overridden integration tests on connection={{ ansible_connection }} - include_tasks: _remove_config.yaml -- include_tasks: _populate_config.yaml - - block: + - include_tasks: _populate_config.yaml + - name: Override the existing configuration with the provided configuration register: result vyos.rest.vyos_ntp_global: &id001 diff --git a/tests/integration/targets/vyos_ntp_global/tests/httpapi/replaced.yaml b/tests/integration/targets/vyos_ntp_global/tests/httpapi/replaced.yaml index 77136fc..cd21412 100644 --- a/tests/integration/targets/vyos_ntp_global/tests/httpapi/replaced.yaml +++ b/tests/integration/targets/vyos_ntp_global/tests/httpapi/replaced.yaml @@ -3,9 +3,9 @@ msg: START vyos_ntp_global replaced integration tests on connection={{ ansible_connection }} - include_tasks: _remove_config.yaml -- include_tasks: _populate_config.yaml - - block: + - include_tasks: _populate_config.yaml + - name: Replace the existing configuration with the provided configuration register: result vyos.rest.vyos_ntp_global: &id001 @@ -31,8 +31,8 @@ that: - "'10.4.9.0/24' not in gathered.gathered.allow_clients" - "'10.99.99.0/24' in gathered.gathered.allow_clients" - - "'server-new.example.com' in gathered.gathered.servers" - - "'10.3.6.5' not in gathered.gathered.servers" + - "'server-new.example.com' in gathered.gathered.servers | map(attribute='server')" + - "'10.3.6.5' not in gathered.gathered.servers | map(attribute='server')" - name: Replace the provided configuration (IDEMPOTENT) register: result diff --git a/tests/integration/targets/vyos_ntp_global/tests/httpapi/rtt.yaml b/tests/integration/targets/vyos_ntp_global/tests/httpapi/rtt.yaml index d05e203..41daa42 100644 --- a/tests/integration/targets/vyos_ntp_global/tests/httpapi/rtt.yaml +++ b/tests/integration/targets/vyos_ntp_global/tests/httpapi/rtt.yaml @@ -8,58 +8,56 @@ - name: RTT - Apply base configuration vyos.rest.vyos_ntp_global: config: + allow_clients: + - 10.5.5.0/24 + - 10.6.6.0/24 + listen_addresses: + - 10.1.2.3 servers: - - server: 10.3.6.5 + - server: 203.0.113.10 options: - - noselect - prefer - - server: server4.example.com - options: - - pool - allow_clients: - - 10.4.9.0/24 - listen_addresses: - - 10.1.9.16 state: merged - - name: RTT - Gather configuration + - name: RTT - Gather register: gathered vyos.rest.vyos_ntp_global: state: gathered - - name: RTT - Assert gathered matches applied + - name: RTT - Assert base configuration present assert: that: - - "'10.3.6.5' in gathered.gathered.servers" - - "'10.4.9.0/24' in gathered.gathered.allow_clients" - - "'10.1.9.16' in gathered.gathered.listen_addresses" + - "'10.5.5.0/24' in gathered.gathered.allow_clients" + - "'10.6.6.0/24' in gathered.gathered.allow_clients" + - "'10.1.2.3' in gathered.gathered.listen_addresses" + - "'203.0.113.10' in gathered.gathered.servers | map(attribute='server')" + - (gathered.gathered.servers | selectattr('server', 'eq', '203.0.113.10') | first).options == ['prefer'] - - name: RTT - Modify configuration + - name: RTT - Modify configuration (remove a stale allow_client, add a server option) vyos.rest.vyos_ntp_global: config: - servers: - - server: 10.3.6.5 - options: - - noselect - - server: server5.example.com allow_clients: - - 10.4.8.0/24 + - 10.5.5.0/24 listen_addresses: - - 10.1.9.16 + - 10.1.2.3 + servers: + - server: 203.0.113.10 + options: + - prefer + - nts state: replaced - - name: RTT - Gather modified configuration + - name: RTT - Gather after modify register: gathered2 vyos.rest.vyos_ntp_global: state: gathered - - name: RTT - Assert modification applied correctly + - name: RTT - Assert stale entry removed and update applied assert: that: - - "'server5.example.com' in gathered2.gathered.servers" - - "'server4.example.com' not in gathered2.gathered.servers" - - "'10.4.8.0/24' in gathered2.gathered.allow_clients" - - "'10.4.9.0/24' not in gathered2.gathered.allow_clients" + - "'10.5.5.0/24' in gathered2.gathered.allow_clients" + - "'10.6.6.0/24' not in gathered2.gathered.allow_clients" + - (gathered2.gathered.servers | selectattr('server', 'eq', '203.0.113.10') | first).options | sort == ['nts', 'prefer'] always: - include_tasks: _remove_config.yaml diff --git a/tests/integration/targets/vyos_ospf_interfaces/tasks/httpapi.yaml b/tests/integration/targets/vyos_ospf_interfaces/tasks/httpapi.yaml index 4147e6d..0ed3e42 100644 --- a/tests/integration/targets/vyos_ospf_interfaces/tasks/httpapi.yaml +++ b/tests/integration/targets/vyos_ospf_interfaces/tasks/httpapi.yaml @@ -9,7 +9,7 @@ - name: Set test_items ansible.builtin.set_fact: - test_items: "{{ test_cases.files | map(attribute='path') | list }}" + test_items: "{{ test_cases.files | map(attribute='path') | list | sort }}" - name: Run test case (connection=httpapi) ansible.builtin.include_tasks: "{{ test_case_to_run }}" diff --git a/tests/integration/targets/vyos_prefix_lists/tests/httpapi/deleted.yaml b/tests/integration/targets/vyos_prefix_lists/tests/httpapi/deleted.yaml index 6cfb063..145cfaf 100644 --- a/tests/integration/targets/vyos_prefix_lists/tests/httpapi/deleted.yaml +++ b/tests/integration/targets/vyos_prefix_lists/tests/httpapi/deleted.yaml @@ -3,9 +3,9 @@ msg: START vyos_prefix_lists deleted integration tests on connection={{ ansible_connection }} - include_tasks: _remove_config.yaml -- include_tasks: _populate_config.yaml - - block: + - include_tasks: _populate_config.yaml + - name: Delete all prefix_lists configuration register: result vyos.rest.vyos_prefix_lists: &id001 diff --git a/tests/integration/targets/vyos_prefix_lists/tests/httpapi/gathered.yaml b/tests/integration/targets/vyos_prefix_lists/tests/httpapi/gathered.yaml index 083cb48..ce752a8 100644 --- a/tests/integration/targets/vyos_prefix_lists/tests/httpapi/gathered.yaml +++ b/tests/integration/targets/vyos_prefix_lists/tests/httpapi/gathered.yaml @@ -3,9 +3,9 @@ msg: START vyos_prefix_lists gathered integration tests on connection={{ ansible_connection }} - include_tasks: _remove_config.yaml -- include_tasks: _populate_config.yaml - - block: + - include_tasks: _populate_config.yaml + - name: Gather prefix_lists configuration register: result vyos.rest.vyos_prefix_lists: diff --git a/tests/integration/targets/vyos_prefix_lists/tests/httpapi/replaced.yaml b/tests/integration/targets/vyos_prefix_lists/tests/httpapi/replaced.yaml index bd8c5c1..8c9754a 100644 --- a/tests/integration/targets/vyos_prefix_lists/tests/httpapi/replaced.yaml +++ b/tests/integration/targets/vyos_prefix_lists/tests/httpapi/replaced.yaml @@ -3,9 +3,9 @@ msg: START vyos_prefix_lists replaced integration tests on connection={{ ansible_connection }} - include_tasks: _remove_config.yaml -- include_tasks: _populate_config.yaml - - block: + - include_tasks: _populate_config.yaml + - name: Replace prefix_lists configuration register: result vyos.rest.vyos_prefix_lists: &id001 diff --git a/tests/integration/targets/vyos_route_maps/tasks/httpapi.yaml b/tests/integration/targets/vyos_route_maps/tasks/httpapi.yaml index 4147e6d..0ed3e42 100644 --- a/tests/integration/targets/vyos_route_maps/tasks/httpapi.yaml +++ b/tests/integration/targets/vyos_route_maps/tasks/httpapi.yaml @@ -9,7 +9,7 @@ - name: Set test_items ansible.builtin.set_fact: - test_items: "{{ test_cases.files | map(attribute='path') | list }}" + test_items: "{{ test_cases.files | map(attribute='path') | list | sort }}" - name: Run test case (connection=httpapi) ansible.builtin.include_tasks: "{{ test_case_to_run }}" diff --git a/tests/integration/targets/vyos_route_maps/tests/httpapi/deleted.yaml b/tests/integration/targets/vyos_route_maps/tests/httpapi/deleted.yaml index ec80b23..f826d45 100644 --- a/tests/integration/targets/vyos_route_maps/tests/httpapi/deleted.yaml +++ b/tests/integration/targets/vyos_route_maps/tests/httpapi/deleted.yaml @@ -3,9 +3,9 @@ msg: START vyos_route_maps deleted integration tests on connection={{ ansible_connection }} - include_tasks: _remove_config.yaml -- include_tasks: _populate_config.yaml - - block: + - include_tasks: _populate_config.yaml + - name: Delete all route_maps configuration register: result vyos.rest.vyos_route_maps: &id001 diff --git a/tests/integration/targets/vyos_route_maps/tests/httpapi/gathered.yaml b/tests/integration/targets/vyos_route_maps/tests/httpapi/gathered.yaml index 494a23e..569d9b3 100644 --- a/tests/integration/targets/vyos_route_maps/tests/httpapi/gathered.yaml +++ b/tests/integration/targets/vyos_route_maps/tests/httpapi/gathered.yaml @@ -3,9 +3,9 @@ msg: START vyos_route_maps gathered integration tests on connection={{ ansible_connection }} - include_tasks: _remove_config.yaml -- include_tasks: _populate_config.yaml - - block: + - include_tasks: _populate_config.yaml + - name: Gather route_maps configuration register: result vyos.rest.vyos_route_maps: diff --git a/tests/integration/targets/vyos_route_maps/tests/httpapi/replaced.yaml b/tests/integration/targets/vyos_route_maps/tests/httpapi/replaced.yaml index dece9a8..6e06ab7 100644 --- a/tests/integration/targets/vyos_route_maps/tests/httpapi/replaced.yaml +++ b/tests/integration/targets/vyos_route_maps/tests/httpapi/replaced.yaml @@ -3,9 +3,9 @@ msg: START vyos_route_maps replaced integration tests on connection={{ ansible_connection }} - include_tasks: _remove_config.yaml -- include_tasks: _populate_config.yaml - - block: + - include_tasks: _populate_config.yaml + - name: Replace route_maps configuration register: result vyos.rest.vyos_route_maps: &id001 diff --git a/tests/integration/targets/vyos_route_maps/tests/httpapi/rtt.yaml b/tests/integration/targets/vyos_route_maps/tests/httpapi/rtt.yaml index 641a46f..1646775 100644 --- a/tests/integration/targets/vyos_route_maps/tests/httpapi/rtt.yaml +++ b/tests/integration/targets/vyos_route_maps/tests/httpapi/rtt.yaml @@ -14,40 +14,150 @@ action: permit match: peer: 192.0.2.32 + metric: 100 + protocol: bgp set: - metric: "5" + metric: 5 + as_path_exclude: "111" + aggregator: + as: 100 + community: + add: + - "no-export" + - route_map: my_route_map + entries: + - sequence: 10 + action: permit + continue_sequence: 20 + - sequence: 20 + action: permit + set: + large_community: + none: true + - route_map: test3 + entries: + - sequence: 1 + action: permit + match: + metric: 1 + peer: 192.0.2.32 + set: + local_preference: 4 + metric: 5 + metric_type: type-1 + origin: egp + originator_id: 192.0.2.34 + tag: 5 + weight: 4 state: merged - - name: RTT - Gather configuration + - name: RTT - Gather register: gathered vyos.rest.vyos_route_maps: state: gathered - - name: RTT - Assert gathered matches applied + - name: RTT - Assert base configuration present assert: that: - - gathered.gathered | selectattr('route_map', 'eq', 'RM-TEST-EXPORT-POLICY') | list | length == 1 + - "'RM-TEST-EXPORT-POLICY' in gathered.gathered | map(attribute='route_map')" + - "'my_route_map' in gathered.gathered | map(attribute='route_map')" + - "'test3' in gathered.gathered | map(attribute='route_map')" + - (gathered.gathered | selectattr('route_map', 'eq', 'RM-TEST-EXPORT-POLICY') | first).entries[0].match.metric == 100 + - (gathered.gathered | selectattr('route_map', 'eq', 'RM-TEST-EXPORT-POLICY') | first).entries[0].match.protocol == 'bgp' + - (gathered.gathered | selectattr('route_map', 'eq', 'RM-TEST-EXPORT-POLICY') | first).entries[0].set.aggregator.as_ == 100 + - (gathered.gathered | selectattr('route_map', 'eq', 'RM-TEST-EXPORT-POLICY') | first).entries[0].set.community.add == ['no-export'] + - (gathered.gathered | selectattr('route_map', 'eq', 'my_route_map') | first).entries[0].continue_sequence == 20 + - (gathered.gathered | selectattr('route_map', 'eq', 'my_route_map') | first).entries[1].set.large_community.none == true + - (gathered.gathered | selectattr('route_map', 'eq', 'test3') | first).entries[0].set.local_preference == 4 + - (gathered.gathered | selectattr('route_map', 'eq', 'test3') | first).entries[0].set.metric_type == 'type-1' + - (gathered.gathered | selectattr('route_map', 'eq', 'test3') | first).entries[0].set.origin == 'egp' + - (gathered.gathered | selectattr('route_map', 'eq', 'test3') | first).entries[0].set.originator_id == '192.0.2.34' + - (gathered.gathered | selectattr('route_map', 'eq', 'test3') | first).entries[0].set.tag == 5 + - (gathered.gathered | selectattr('route_map', 'eq', 'test3') | first).entries[0].set.weight == 4 - - name: RTT - Modify configuration + - name: RTT - Modify configuration (replaced -- remove RM-TEST-EXPORT-POLICY's community, add a new route map) vyos.rest.vyos_route_maps: config: - route_map: RM-TEST-EXPORT-POLICY entries: - sequence: 10 action: permit + match: + peer: 192.0.2.32 set: - metric: "20" + metric: 5 + as_path_exclude: "111" + aggregator: + as: 100 + - route_map: my_route_map + entries: + - sequence: 10 + action: permit + continue_sequence: 20 + - sequence: 20 + action: permit + set: + large_community: + none: true state: replaced - - name: RTT - Gather modified configuration + - name: RTT - Gather after modify register: gathered2 vyos.rest.vyos_route_maps: state: gathered - - name: RTT - Assert modification applied correctly + - name: RTT - Assert stale community removed and match cleared, my_route_map untouched + assert: + that: + - "'community' not in (gathered2.gathered | selectattr('route_map', 'eq', 'RM-TEST-EXPORT-POLICY') | first).entries[0].set" + - "'metric' not in (gathered2.gathered | selectattr('route_map', 'eq', 'RM-TEST-EXPORT-POLICY') | first).entries[0].match" + - (gathered2.gathered | selectattr('route_map', 'eq', 'RM-TEST-EXPORT-POLICY') | first).entries[0].set.aggregator.as_ == 100 + - (gathered2.gathered | selectattr('route_map', 'eq', 'my_route_map') | first).entries[0].continue_sequence == 20 + + - name: RTT - Re-establish the original baseline + vyos.rest.vyos_route_maps: &baseline_config + config: "{{ gathered.gathered }}" + state: overridden + + - name: RTT - Gather the re-established baseline + register: rebaselined + vyos.rest.vyos_route_maps: + state: gathered + + - name: RTT - Apply an unrelated temporary route map + vyos.rest.vyos_route_maps: + config: + - route_map: temp_policy + entries: + - sequence: 5 + action: deny + state: merged + + - name: RTT - Revert to the original baseline via gather + overridden + register: reverted + vyos.rest.vyos_route_maps: *baseline_config + + - name: RTT - Gather after revert + register: gathered3 + vyos.rest.vyos_route_maps: + state: gathered + + - name: RTT - Assert the revert exactly restores the baseline and is then idempotent + assert: + that: + - reverted.changed == true + - "'temp_policy' not in gathered3.gathered | map(attribute='route_map')" + - gathered3.gathered | symmetric_difference(rebaselined.gathered) == [] + + - name: RTT - Confirm reverted baseline is idempotent + register: reverted_again + vyos.rest.vyos_route_maps: *baseline_config + + - name: RTT - Assert idempotent assert: that: - - gathered2.gathered | selectattr('route_map', 'eq', 'RM-TEST-EXPORT-POLICY') | list | length == 1 + - reverted_again.changed == false + - reverted_again.commands == [] always: - include_tasks: _remove_config.yaml diff --git a/tests/integration/targets/vyos_snmp_server/tasks/httpapi.yaml b/tests/integration/targets/vyos_snmp_server/tasks/httpapi.yaml index 1ba3569..80d9e23 100644 --- a/tests/integration/targets/vyos_snmp_server/tasks/httpapi.yaml +++ b/tests/integration/targets/vyos_snmp_server/tasks/httpapi.yaml @@ -9,7 +9,7 @@ - name: Set test_items ansible.builtin.set_fact: - test_items: "{{ test_cases.files | map(attribute='path') | list }}" + test_items: "{{ test_cases.files | map(attribute='path') | list | sort }}" - name: Run test case (connection=httpapi) ansible.builtin.include_tasks: "{{ test_case_to_run }}" diff --git a/tests/integration/targets/vyos_snmp_server/tests/httpapi/deleted.yaml b/tests/integration/targets/vyos_snmp_server/tests/httpapi/deleted.yaml index a626a26..9c1c288 100644 --- a/tests/integration/targets/vyos_snmp_server/tests/httpapi/deleted.yaml +++ b/tests/integration/targets/vyos_snmp_server/tests/httpapi/deleted.yaml @@ -2,9 +2,9 @@ - debug: msg: START vyos_snmp_server deleted integration tests on connection={{ ansible_connection }} -- include_tasks: _populate_config.yaml - - block: + - include_tasks: _populate_config.yaml + - name: Delete all snmp_server configuration register: result vyos.rest.vyos_snmp_server: &id001 diff --git a/tests/integration/targets/vyos_snmp_server/tests/httpapi/gathered.yaml b/tests/integration/targets/vyos_snmp_server/tests/httpapi/gathered.yaml index 770845c..5956f51 100644 --- a/tests/integration/targets/vyos_snmp_server/tests/httpapi/gathered.yaml +++ b/tests/integration/targets/vyos_snmp_server/tests/httpapi/gathered.yaml @@ -2,9 +2,9 @@ - debug: msg: START vyos_snmp_server gathered integration tests on connection={{ ansible_connection }} -- include_tasks: _populate_config.yaml - - block: + - include_tasks: _populate_config.yaml + - name: Gather snmp_server configuration register: result vyos.rest.vyos_snmp_server: diff --git a/tests/integration/targets/vyos_snmp_server/tests/httpapi/replaced.yaml b/tests/integration/targets/vyos_snmp_server/tests/httpapi/replaced.yaml index d989272..e659731 100644 --- a/tests/integration/targets/vyos_snmp_server/tests/httpapi/replaced.yaml +++ b/tests/integration/targets/vyos_snmp_server/tests/httpapi/replaced.yaml @@ -2,9 +2,9 @@ - debug: msg: START vyos_snmp_server replaced integration tests on connection={{ ansible_connection }} -- include_tasks: _populate_config.yaml - - block: + - include_tasks: _populate_config.yaml + - name: Replace snmp_server configuration register: result vyos.rest.vyos_snmp_server: &id001 diff --git a/tests/integration/targets/vyos_snmp_server/tests/httpapi/rtt.yaml b/tests/integration/targets/vyos_snmp_server/tests/httpapi/rtt.yaml index 1d354b7..00bc93e 100644 --- a/tests/integration/targets/vyos_snmp_server/tests/httpapi/rtt.yaml +++ b/tests/integration/targets/vyos_snmp_server/tests/httpapi/rtt.yaml @@ -8,46 +8,101 @@ - name: RTT - Apply base configuration vyos.rest.vyos_snmp_server: config: + contact: admin@example.com communities: - name: switches authorization_type: rw - contact: admin@example.com + - name: bridges + clients: + - 1.1.1.1 + - 12.1.1.10 listen_addresses: - address: 20.1.1.1 + snmp_v3: + engine_id: "000000000000000000000002" + groups: + - group: admins + mode: rw + seclevel: priv + view: all + users: + - user: admin_user + group: admins + authentication: + type: sha + plaintext_key: authpass123 + privacy: + type: aes + plaintext_key: privpass123 + views: + - view: all + oid: "1.3.6.1.2.1.1" + mask: "ff.ff" state: merged - - name: RTT - Gather configuration + - name: RTT - Gather register: gathered vyos.rest.vyos_snmp_server: state: gathered - - name: RTT - Assert gathered matches applied + - name: RTT - Assert base configuration present assert: that: - gathered.gathered.contact == "admin@example.com" - - gathered.gathered.communities | selectattr('name', 'eq', 'switches') | list | length == 1 + - "'switches' in gathered.gathered.communities | map(attribute='name')" + - "'bridges' in gathered.gathered.communities | map(attribute='name')" + - gathered.gathered.snmp_v3.engine_id == "000000000000000000000002" + - "'admins' in gathered.gathered.snmp_v3.groups | map(attribute='group')" + - (gathered.gathered.snmp_v3.users | selectattr('user', 'eq', 'admin_user') | first).authentication.type == 'sha' + - (gathered.gathered.snmp_v3.views | selectattr('view', 'eq', 'all') | first).oid == '1.3.6.1.2.1.1' + - (gathered.gathered.snmp_v3.views | selectattr('view', 'eq', 'all') | first).mask == 'ff.ff' - - name: RTT - Modify configuration + - name: RTT - Modify configuration (remove a stale community, add a trap target) vyos.rest.vyos_snmp_server: config: + contact: admin@example.com communities: - name: switches - authorization_type: ro - contact: ops@example.com + authorization_type: rw listen_addresses: - address: 20.1.1.1 + trap_target: + address: 203.0.113.5 + community: switches + port: 162 + snmp_v3: + engine_id: "000000000000000000000002" + groups: + - group: admins + mode: rw + seclevel: priv + view: all + users: + - user: admin_user + group: admins + authentication: + type: sha + privacy: + type: aes + views: + - view: all + oid: "1.3.6.1.2.1.1" + mask: "ff.ff" state: replaced - - name: RTT - Gather modified configuration + - name: RTT - Gather after modify register: gathered2 vyos.rest.vyos_snmp_server: state: gathered - - name: RTT - Assert modification applied correctly + - name: RTT - Assert stale community removed and trap target added assert: that: - - gathered2.gathered.contact == "ops@example.com" - - gathered2.gathered.communities | selectattr('name', 'eq', 'switches') | map(attribute='authorization_type') | first == 'ro' + - "'bridges' not in gathered2.gathered.communities | map(attribute='name')" + - "'switches' in gathered2.gathered.communities | map(attribute='name')" + - gathered2.gathered.trap_target.address == "203.0.113.5" + - gathered2.gathered.trap_target.community == "switches" + - gathered2.gathered.trap_target.port == 162 always: - include_tasks: _remove_config.yaml diff --git a/tests/integration/targets/vyos_system/aliases b/tests/integration/targets/vyos_system/aliases new file mode 100644 index 0000000..cc0afef --- /dev/null +++ b/tests/integration/targets/vyos_system/aliases @@ -0,0 +1 @@ +network/vyos diff --git a/tests/integration/targets/vyos_system/defaults/main.yaml b/tests/integration/targets/vyos_system/defaults/main.yaml new file mode 100644 index 0000000..164afea --- /dev/null +++ b/tests/integration/targets/vyos_system/defaults/main.yaml @@ -0,0 +1,3 @@ +--- +testcase: "[^_].*" +test_items: [] diff --git a/tests/integration/targets/vyos_system/tasks/httpapi.yaml b/tests/integration/targets/vyos_system/tasks/httpapi.yaml new file mode 100644 index 0000000..0ed3e42 --- /dev/null +++ b/tests/integration/targets/vyos_system/tasks/httpapi.yaml @@ -0,0 +1,21 @@ +--- +- name: Collect all httpapi test cases + ansible.builtin.find: + paths: "{{ role_path }}/tests/httpapi" + patterns: "{{ testcase }}.yaml" + use_regex: true + register: test_cases + delegate_to: localhost + +- name: Set test_items + ansible.builtin.set_fact: + test_items: "{{ test_cases.files | map(attribute='path') | list | sort }}" + +- name: Run test case (connection=httpapi) + ansible.builtin.include_tasks: "{{ test_case_to_run }}" + vars: + ansible_connection: ansible.netcommon.httpapi + ansible_network_os: vyos.rest.vyos + with_items: "{{ test_items }}" + loop_control: + loop_var: test_case_to_run diff --git a/tests/integration/targets/vyos_system/tasks/main.yaml b/tests/integration/targets/vyos_system/tasks/main.yaml new file mode 100644 index 0000000..b1f6193 --- /dev/null +++ b/tests/integration/targets/vyos_system/tasks/main.yaml @@ -0,0 +1,5 @@ +--- +- name: Run httpapi tests + ansible.builtin.include_tasks: httpapi.yaml + tags: + - httpapi diff --git a/tests/integration/targets/vyos_system/tests/httpapi/_remove_config.yaml b/tests/integration/targets/vyos_system/tests/httpapi/_remove_config.yaml new file mode 100644 index 0000000..f8a4750 --- /dev/null +++ b/tests/integration/targets/vyos_system/tests/httpapi/_remove_config.yaml @@ -0,0 +1,12 @@ +--- +- name: Remove test system configuration + vyos.rest.vyos_system: + domain_name: lab.example.com + name_server: + - 8.8.8.8 + - 8.8.4.4 + - 1.1.1.1 + domain_search: + - sub1.example.com + state: absent + ignore_errors: true diff --git a/tests/integration/targets/vyos_system/tests/httpapi/absent.yaml b/tests/integration/targets/vyos_system/tests/httpapi/absent.yaml new file mode 100644 index 0000000..f82e4d3 --- /dev/null +++ b/tests/integration/targets/vyos_system/tests/httpapi/absent.yaml @@ -0,0 +1,48 @@ +--- +- debug: + msg: START vyos_system absent integration tests on connection={{ ansible_connection }} + +- include_tasks: _remove_config.yaml + +- block: + - name: Setup system configuration + vyos.rest.vyos_system: + domain_name: lab.example.com + name_server: + - 8.8.8.8 + state: present + + - name: Remove domain name + register: result + vyos.rest.vyos_system: + domain_name: lab.example.com + state: absent + + - assert: + that: + - result.changed == true + + - name: Remove domain name (IDEMPOTENT) + register: result + vyos.rest.vyos_system: + domain_name: lab.example.com + state: absent + + - assert: + that: + - result.changed == false + - result.commands == [] + + - name: Remove name server + register: result + vyos.rest.vyos_system: + name_server: + - 8.8.8.8 + state: absent + + - assert: + that: + - result.changed == true + + always: + - include_tasks: _remove_config.yaml diff --git a/tests/integration/targets/vyos_system/tests/httpapi/gathered.yaml b/tests/integration/targets/vyos_system/tests/httpapi/gathered.yaml new file mode 100644 index 0000000..cf5f46d --- /dev/null +++ b/tests/integration/targets/vyos_system/tests/httpapi/gathered.yaml @@ -0,0 +1,14 @@ +--- +- debug: + msg: START vyos_system gathered integration tests on connection={{ ansible_connection }} + +- block: + - name: Gather system configuration + register: result + vyos.rest.vyos_system: + state: present + + - assert: + that: + - result.before['host-name'] is defined + - result.before['host-name'] | length > 0 diff --git a/tests/integration/targets/vyos_system/tests/httpapi/present.yaml b/tests/integration/targets/vyos_system/tests/httpapi/present.yaml new file mode 100644 index 0000000..f830e60 --- /dev/null +++ b/tests/integration/targets/vyos_system/tests/httpapi/present.yaml @@ -0,0 +1,63 @@ +--- +- debug: + msg: START vyos_system present integration tests on connection={{ ansible_connection }} + +- include_tasks: _remove_config.yaml + +- block: + - name: Configure domain and name servers + register: result + vyos.rest.vyos_system: + domain_name: lab.example.com + name_server: + - 8.8.8.8 + - 8.8.4.4 + state: present + + - assert: + that: + - result.changed == true + + - name: Configure domain and name servers (IDEMPOTENT) + register: result + vyos.rest.vyos_system: + domain_name: lab.example.com + name_server: + - 8.8.8.8 + - 8.8.4.4 + state: present + + - assert: + that: + - result.changed == false + - result.commands == [] + + - name: Update domain name + register: result + vyos.rest.vyos_system: + domain_name: new.example.com + state: present + + - assert: + that: + - result.changed == true + + - name: Update domain name (IDEMPOTENT) + register: result + vyos.rest.vyos_system: + domain_name: new.example.com + state: present + + - assert: + that: + - result.changed == false + - result.commands == [] + + always: + - include_tasks: _remove_config.yaml + + - name: Remove new domain name + vyos.rest.vyos_system: + domain_name: new.example.com + state: absent + ignore_errors: true diff --git a/tests/integration/targets/vyos_system/vars/main.yaml b/tests/integration/targets/vyos_system/vars/main.yaml new file mode 100644 index 0000000..4303881 --- /dev/null +++ b/tests/integration/targets/vyos_system/vars/main.yaml @@ -0,0 +1,2 @@ +--- +# only common vars here diff --git a/tests/integration/targets/vyos_user/tasks/httpapi.yaml b/tests/integration/targets/vyos_user/tasks/httpapi.yaml index 4147e6d..0ed3e42 100644 --- a/tests/integration/targets/vyos_user/tasks/httpapi.yaml +++ b/tests/integration/targets/vyos_user/tasks/httpapi.yaml @@ -9,7 +9,7 @@ - name: Set test_items ansible.builtin.set_fact: - test_items: "{{ test_cases.files | map(attribute='path') | list }}" + test_items: "{{ test_cases.files | map(attribute='path') | list | sort }}" - name: Run test case (connection=httpapi) ansible.builtin.include_tasks: "{{ test_case_to_run }}" diff --git a/tests/integration/targets/vyos_vlan/aliases b/tests/integration/targets/vyos_vlan/aliases new file mode 100644 index 0000000..cc0afef --- /dev/null +++ b/tests/integration/targets/vyos_vlan/aliases @@ -0,0 +1 @@ +network/vyos diff --git a/tests/integration/targets/vyos_vlan/defaults/main.yaml b/tests/integration/targets/vyos_vlan/defaults/main.yaml new file mode 100644 index 0000000..164afea --- /dev/null +++ b/tests/integration/targets/vyos_vlan/defaults/main.yaml @@ -0,0 +1,3 @@ +--- +testcase: "[^_].*" +test_items: [] diff --git a/tests/integration/targets/vyos_vlan/tasks/httpapi.yaml b/tests/integration/targets/vyos_vlan/tasks/httpapi.yaml new file mode 100644 index 0000000..217692f --- /dev/null +++ b/tests/integration/targets/vyos_vlan/tasks/httpapi.yaml @@ -0,0 +1,21 @@ +--- +- name: Collect all httpapi test cases + ansible.builtin.find: + paths: "{{ role_path }}/tests/httpapi" + patterns: "{{ testcase }}.yaml" + use_regex: true + register: test_cases + delegate_to: localhost + +- name: Set test_items + ansible.builtin.set_fact: + test_items: "{{ test_cases.files | map(attribute='path') | list | sort}}" + +- name: Run test case (connection=httpapi) + ansible.builtin.include_tasks: "{{ test_case_to_run }}" + vars: + ansible_connection: ansible.netcommon.httpapi + ansible_network_os: vyos.rest.vyos + with_items: "{{ test_items }}" + loop_control: + loop_var: test_case_to_run diff --git a/tests/integration/targets/vyos_vlan/tasks/main.yaml b/tests/integration/targets/vyos_vlan/tasks/main.yaml new file mode 100644 index 0000000..b1f6193 --- /dev/null +++ b/tests/integration/targets/vyos_vlan/tasks/main.yaml @@ -0,0 +1,5 @@ +--- +- name: Run httpapi tests + ansible.builtin.include_tasks: httpapi.yaml + tags: + - httpapi diff --git a/tests/integration/targets/vyos_vlan/tests/httpapi/_remove_config.yaml b/tests/integration/targets/vyos_vlan/tests/httpapi/_remove_config.yaml new file mode 100644 index 0000000..a0959b8 --- /dev/null +++ b/tests/integration/targets/vyos_vlan/tests/httpapi/_remove_config.yaml @@ -0,0 +1,9 @@ +--- +- name: Remove test VLANs + vyos.rest.vyos_vlan: + config: + - vlan_id: 10 + interfaces: [eth1] + - vlan_id: 20 + interfaces: [eth1] + state: absent diff --git a/tests/integration/targets/vyos_vlan/tests/httpapi/absent.yaml b/tests/integration/targets/vyos_vlan/tests/httpapi/absent.yaml new file mode 100644 index 0000000..3547ed1 --- /dev/null +++ b/tests/integration/targets/vyos_vlan/tests/httpapi/absent.yaml @@ -0,0 +1,45 @@ +--- +- debug: + msg: START vyos_vlan absent integration tests on connection={{ ansible_connection }} + +- include_tasks: _remove_config.yaml + +- block: + - name: Setup VLANs + vyos.rest.vyos_vlan: + config: + - vlan_id: 10 + description: VLAN10 + interfaces: [eth1] + - vlan_id: 20 + description: VLAN20 + interfaces: [eth1] + state: present + + - name: Remove VLAN + register: result + vyos.rest.vyos_vlan: + config: + - vlan_id: 10 + interfaces: [eth1] + state: absent + + - assert: + that: + - result.changed == true + + - name: Remove VLAN (IDEMPOTENT) + register: result + vyos.rest.vyos_vlan: + config: + - vlan_id: 10 + interfaces: [eth1] + state: absent + + - assert: + that: + - result.changed == false + - result.commands == [] + + always: + - include_tasks: _remove_config.yaml diff --git a/tests/integration/targets/vyos_vlan/tests/httpapi/gathered.yaml b/tests/integration/targets/vyos_vlan/tests/httpapi/gathered.yaml new file mode 100644 index 0000000..483f7ad --- /dev/null +++ b/tests/integration/targets/vyos_vlan/tests/httpapi/gathered.yaml @@ -0,0 +1,28 @@ +--- +- debug: + msg: START vyos_vlan gathered integration tests on connection={{ ansible_connection }} + +- include_tasks: _remove_config.yaml + +- block: + - name: Setup VLANs + vyos.rest.vyos_vlan: + config: + - vlan_id: 10 + description: VLAN10 + address: 192.168.10.1/24 + interfaces: [eth1] + state: present + + - name: Gather VLANs + register: result + vyos.rest.vyos_vlan: + state: gathered + + - assert: + that: + - result.gathered | selectattr('vlan_id', 'eq', 10) | list | length == 1 + - (result.gathered | selectattr('vlan_id', 'eq', 10) | first).description == 'VLAN10' + + always: + - include_tasks: _remove_config.yaml diff --git a/tests/integration/targets/vyos_vlan/tests/httpapi/present.yaml b/tests/integration/targets/vyos_vlan/tests/httpapi/present.yaml new file mode 100644 index 0000000..cfbec27 --- /dev/null +++ b/tests/integration/targets/vyos_vlan/tests/httpapi/present.yaml @@ -0,0 +1,44 @@ +--- +- debug: + msg: START vyos_vlan present integration tests on connection={{ ansible_connection }} + +- include_tasks: _remove_config.yaml + +- block: + - name: Configure VLANs + register: result + vyos.rest.vyos_vlan: + config: + - vlan_id: 10 + description: VLAN10 + address: 192.168.10.1/24 + interfaces: [eth1] + - vlan_id: 20 + description: VLAN20 + interfaces: [eth1] + state: present + + - assert: + that: + - result.changed == true + + - name: Configure VLANs (IDEMPOTENT) + register: result + vyos.rest.vyos_vlan: + config: + - vlan_id: 10 + description: VLAN10 + address: 192.168.10.1/24 + interfaces: [eth1] + - vlan_id: 20 + description: VLAN20 + interfaces: [eth1] + state: present + + - assert: + that: + - result.changed == false + - result.commands == [] + + always: + - include_tasks: _remove_config.yaml diff --git a/tests/integration/targets/vyos_vlan/tests/httpapi/rtt.yaml b/tests/integration/targets/vyos_vlan/tests/httpapi/rtt.yaml new file mode 100644 index 0000000..e30032f --- /dev/null +++ b/tests/integration/targets/vyos_vlan/tests/httpapi/rtt.yaml @@ -0,0 +1,43 @@ +--- +- debug: + msg: START vyos_vlan round trip integration tests on connection={{ ansible_connection }} + +- include_tasks: _remove_config.yaml + +- block: + - name: RTT - Configure VLAN + vyos.rest.vyos_vlan: + config: + - vlan_id: 10 + description: VLAN10 + interfaces: [eth1] + state: present + + - name: RTT - Gather + register: gathered + vyos.rest.vyos_vlan: + state: gathered + + - assert: + that: + - gathered.gathered | selectattr('vlan_id', 'eq', 10) | list | length == 1 + + - name: RTT - Update description + vyos.rest.vyos_vlan: + config: + - vlan_id: 10 + description: VLAN10-updated + interfaces: [eth1] + state: present + + - name: RTT - Gather after update + register: gathered2 + vyos.rest.vyos_vlan: + state: gathered + + - assert: + that: + - (gathered2.gathered | selectattr('vlan_id', 'eq', 10) | first).description == 'VLAN10-updated' + + always: + - include_tasks: _remove_config.yaml diff --git a/tests/integration/targets/vyos_vlan/vars/main.yaml b/tests/integration/targets/vyos_vlan/vars/main.yaml new file mode 100644 index 0000000..4303881 --- /dev/null +++ b/tests/integration/targets/vyos_vlan/vars/main.yaml @@ -0,0 +1,2 @@ +--- +# only common vars here diff --git a/tests/unit/fixtures/bgp_af_running.json b/tests/unit/fixtures/bgp_af_running.json index f9c36d9..8e14e52 100644 --- a/tests/unit/fixtures/bgp_af_running.json +++ b/tests/unit/fixtures/bgp_af_running.json @@ -4,7 +4,7 @@ "ipv4-unicast": { "network": { "192.0.2.0/24": {}, - "192.0.3.0/24": {} + "192.0.3.0/24": { "route-map": "RM-OUT", "backdoor": {} } }, "redistribute": { "connected": { "metric": "10" }, @@ -18,7 +18,20 @@ "address-family": { "ipv4-unicast": { "nexthop-self": {}, - "soft-reconfiguration": { "inbound": {} } + "soft-reconfiguration": { "inbound": {} }, + "attribute-unchanged": { + "as-path": {}, + "med": {} + }, + "capability": { + "orf": { + "prefix-list": { "receive": {} } + } + }, + "distribute-list": { + "import": "10", + "export": "20" + } }, "ipv6-unicast": { "soft-reconfiguration": { "inbound": {} } diff --git a/tests/unit/fixtures/bgp_global_running.json b/tests/unit/fixtures/bgp_global_running.json index dc76159..65c3f58 100644 --- a/tests/unit/fixtures/bgp_global_running.json +++ b/tests/unit/fixtures/bgp_global_running.json @@ -2,7 +2,14 @@ "system-as": "65000", "parameters": { "router-id": "192.0.1.1", - "log-neighbor-changes": {} + "log-neighbor-changes": {}, + "bestpath": { + "as-path": "multipath-relax" + }, + "confederation": { + "identifier": "100", + "peers": ["65001", "65002"] + } }, "neighbor": { "192.0.2.1": { @@ -11,6 +18,11 @@ "timers": { "holdtime": "30", "keepalive": "10" + }, + "address-family": { + "ipv4-unicast": { + "nexthop-self": {} + } } }, "192.0.2.2": { diff --git a/tests/unit/fixtures/firewall_interfaces_running.json b/tests/unit/fixtures/firewall_interfaces_running.json index b0ce2d5..af120ce 100644 --- a/tests/unit/fixtures/firewall_interfaces_running.json +++ b/tests/unit/fixtures/firewall_interfaces_running.json @@ -5,7 +5,12 @@ "default-action": "accept", "rule": { "10": { "action": "accept", "state": "established" }, - "20": { "action": "drop", "state": "invalid" } + "20": { + "action": "drop", + "state": "invalid", + "source": { "address": "10.0.0.0/8" }, + "destination": { "port": "22" } + } } } }, @@ -18,6 +23,13 @@ "filter": { "default-action": "accept" } + }, + "name": { + "my-ruleset": { + "rule": { + "1": { "action": "accept" } + } + } } }, "ipv6": { diff --git a/tests/unit/fixtures/ha_running.json b/tests/unit/fixtures/ha_running.json new file mode 100644 index 0000000..ee7a257 --- /dev/null +++ b/tests/unit/fixtures/ha_running.json @@ -0,0 +1,44 @@ +{ + "disable": {}, + "virtual-server": { + "s1": { + "address": "10.10.10.5", + "algorithm": "round-robin", + "port": "80", + "protocol": "tcp", + "real-server": { + "10.10.50.2": { "port": "8443" } + } + } + }, + "vrrp": { + "global-parameters": { + "startup-delay": "30", + "garp": { "master-repeat": "6" } + }, + "snmp": {}, + "group": { + "g1": { + "interface": "eth0", + "vrid": "20", + "priority": "100", + "peer-address": "192.168.1.3", + "address": { "192.168.1.100/24": {} }, + "no-preempt": {}, + "track": { "interface": ["eth1", "eth2"] } + }, + "g2": { + "interface": "eth1", + "vrid": "21", + "priority": "50", + "address": "192.168.2.100/24" + } + }, + "sync-group": { + "sg1": { + "member": "g1", + "health-check": { "failure-count": "5", "ping": "192.168.1.1" } + } + } + } +} diff --git a/tests/unit/fixtures/nat_running.json b/tests/unit/fixtures/nat_running.json new file mode 100644 index 0000000..abaede0 --- /dev/null +++ b/tests/unit/fixtures/nat_running.json @@ -0,0 +1,80 @@ +{ + "nat": { + "source": { + "rule": { + "100": { + "description": "Source rule 100", + "outbound-interface": { "name": "eth0" }, + "translation": { "address": "masquerade" }, + "load-balance": { + "hash": "random", + "backend": { + "192.168.1.10": { "weight": "50" }, + "192.168.1.11": {} + } + } + }, + "101": { + "outbound-interface": { "name": "eth1" }, + "translation": { "address": "masquerade" } + } + } + }, + "destination": { + "rule": { + "200": { + "protocol": "tcp", + "inbound-interface": { "name": "eth0" }, + "destination": { "address": "198.51.100.10", "port": "80" }, + "translation": { "address": "192.168.1.10", "port": "8080" } + } + } + }, + "static": { + "rule": { + "300": { + "inbound-interface": "eth0", + "destination": { "address": "198.51.100.20" }, + "translation": { "address": "192.168.1.20" } + } + } + }, + "cgnat": { + "log-allocation": {}, + "pool": { + "external": { + "EXT1": { + "external-port-range": "1024-65535", + "range": { + "203.0.113.1-203.0.113.10": { "seq": "1" } + } + } + }, + "internal": { + "INT1": { + "range": ["10.0.0.0/24", "10.0.1.0/24"] + } + } + }, + "rule": { + "1": { + "destination": { "group": { "address-group": "CGNAT-DST" } } + } + } + } + }, + "nat64": { + "source": { + "rule": { + "10": { + "source": { "prefix": "2001:db8::/96" }, + "translation": { + "pool": { + "1": { "address": "192.168.100.10", "port": "1-65535" } + } + } + } + } + } + } +} diff --git a/tests/unit/fixtures/route_maps_running.json b/tests/unit/fixtures/route_maps_running.json index acf733b..0ae4939 100644 --- a/tests/unit/fixtures/route_maps_running.json +++ b/tests/unit/fixtures/route_maps_running.json @@ -4,11 +4,18 @@ "rule": { "10": { "action": "permit", - "match": { "peer": "192.0.2.32" }, + "match": { + "peer": "192.0.2.32", + "ip": { + "address": { "prefix-list": "PL-MATCH" }, + "nexthop": { "address": "10.0.0.1" } + } + }, "set": { "aggregator": { "as": "100" }, "as-path": { "exclude": "111" }, - "metric": "5" + "metric": "5", + "community": { "add": ["no-export", "no-advertise"] } } } } diff --git a/tests/unit/fixtures/snmp_server_running.json b/tests/unit/fixtures/snmp_server_running.json index 7acc59f..47d1067 100644 --- a/tests/unit/fixtures/snmp_server_running.json +++ b/tests/unit/fixtures/snmp_server_running.json @@ -10,6 +10,9 @@ "20.1.1.1": {}, "100.1.2.1": { "port": "33" } }, + "trap-target": { + "203.0.113.5": { "community": "public", "port": "162" } + }, "v3": { "engineid": "000000000000000000000002", "group": { @@ -30,6 +33,14 @@ }, "view": { "default": { "oid": { "1": {} } } + }, + "trap-target": { + "198.51.100.5": { + "port": "162", + "protocol": "udp", + "type": "inform", + "auth": { "type": "sha", "encrypted-password": "def456" } + } } } } diff --git a/tests/unit/fixtures/system_running.json b/tests/unit/fixtures/system_running.json new file mode 100644 index 0000000..1193645 --- /dev/null +++ b/tests/unit/fixtures/system_running.json @@ -0,0 +1,11 @@ +{ + "config-management": { "commit-revisions": "100" }, + "console": { "device": { "ttyS0": { "speed": "115200" } } }, + "host-name": "vyos150", + "domain-name": "lab.example.com", + "login": { + "user": { "vyos": { "authentication": { "encrypted-password": "$6$abc" } } } + }, + "name-server": ["8.8.8.8", "8.8.4.4"], + "syslog": { "local": {} } +} diff --git a/tests/unit/fixtures/vlan_running.json b/tests/unit/fixtures/vlan_running.json new file mode 100644 index 0000000..80edcb1 --- /dev/null +++ b/tests/unit/fixtures/vlan_running.json @@ -0,0 +1,24 @@ +{ + "ethernet": { + "eth1": { + "hw-id": "52:54:00:xx", + "vif": { + "10": { + "description": "VLAN10", + "address": "192.168.10.1/24" + }, + "20": { + "description": "VLAN20" + } + } + }, + "eth2": { + "hw-id": "52:54:00:yy", + "vif": { + "10": { + "description": "VLAN10" + } + } + } + } +} diff --git a/tests/unit/modules/base.py b/tests/unit/modules/base.py index 4d49fbb..a3e2eeb 100644 --- a/tests/unit/modules/base.py +++ b/tests/unit/modules/base.py @@ -13,12 +13,23 @@ import unittest from unittest.mock import MagicMock # noqa: F401 +_fixture_cache = {} + + def load_fixture(filename): - """Load a JSON fixture file from tests/unit/fixtures/.""" + """Load a fixture file from tests/unit/fixtures/. Results are cached.""" fixtures_dir = os.path.join(os.path.dirname(__file__), "..", "fixtures") path = os.path.join(fixtures_dir, filename) + if path in _fixture_cache: + return _fixture_cache[path] with open(path) as f: - return json.load(f) + data = f.read() + try: + data = json.loads(data) + except json.JSONDecodeError: + pass + _fixture_cache[path] = data + return data class VyOSModuleTestCase(unittest.TestCase): diff --git a/tests/unit/modules/test_vyos_bgp_address_family.py b/tests/unit/modules/test_vyos_bgp_address_family.py index fc080af..a63dd56 100644 --- a/tests/unit/modules/test_vyos_bgp_address_family.py +++ b/tests/unit/modules/test_vyos_bgp_address_family.py @@ -4,25 +4,24 @@ from __future__ import absolute_import, division, print_function __metaclass__ = type -import json -import os import unittest from unittest.mock import MagicMock from ansible_collections.vyos.rest.plugins.modules.vyos_bgp_address_family import ( + _device_to_argspec, + _global_af_from_device, + _global_af_to_device, + _neighbor_af_from_device, + _neighbor_af_to_device, build_commands, get_running_config, ) - -_BASE = ["protocols", "bgp"] +from .base import load_fixture -def load_fixture(filename): - fixtures_dir = os.path.join(os.path.dirname(__file__), "..", "fixtures") - with open(os.path.join(fixtures_dir, filename)) as f: - return json.load(f) +_BASE = ["protocols", "bgp"] class VyOSModuleTestCase(unittest.TestCase): @@ -32,204 +31,233 @@ class VyOSModuleTestCase(unittest.TestCase): self.mock_vyos.get_config = MagicMock(return_value=self.fixture) -class TestVyOSBgpAFGetRunning(VyOSModuleTestCase): - - def test_parses_as_number(self): - result = get_running_config(self.mock_vyos) - self.assertEqual(result["as_number"], 65000) - - def test_parses_global_af_networks(self): - result = get_running_config(self.mock_vyos) - ipv4 = next(af for af in result["address_family"] if af["afi"] == "ipv4") - prefixes = [n["prefix"] for n in ipv4["networks"]] - self.assertIn("192.0.2.0/24", prefixes) - self.assertIn("192.0.3.0/24", prefixes) - - def test_parses_global_af_redistribute(self): - result = get_running_config(self.mock_vyos) - ipv4 = next(af for af in result["address_family"] if af["afi"] == "ipv4") - protos = [r["protocol"] for r in ipv4["redistribute"]] - self.assertIn("connected", protos) - connected = next(r for r in ipv4["redistribute"] if r["protocol"] == "connected") - self.assertEqual(connected["metric"], 10) - - def test_parses_neighbor_af(self): - result = get_running_config(self.mock_vyos) - nb = next(n for n in result["neighbors"] if n["neighbor_address"] == "192.0.2.1") - afis = [af["afi"] for af in nb["address_family"]] - self.assertIn("ipv4", afis) - self.assertIn("ipv6", afis) - ipv4 = next(af for af in nb["address_family"] if af["afi"] == "ipv4") - self.assertTrue(ipv4["nexthop_self"]) - self.assertTrue(ipv4["soft_reconfiguration"]) +class TestGetRunningConfig(VyOSModuleTestCase): + def test_returns_raw_device_dict(self): + self.assertEqual(get_running_config(self.mock_vyos), self.fixture) def test_empty_config(self): - self.mock_vyos.get_config = MagicMock(return_value={}) - result = get_running_config(self.mock_vyos) - self.assertEqual(result, {}) + self.mock_vyos.get_config = MagicMock(return_value=None) + self.assertEqual(get_running_config(self.mock_vyos), {}) -class TestVyOSBgpAFBuildCommands(unittest.TestCase): +class TestNeighborAfToDevice(unittest.TestCase): + """The three genuine device-shape exceptions, individually, plus proof + that everything else is untouched by _autoclean alone.""" - def _have(self): - return { - "as_number": 65000, - "address_family": [ + def test_soft_reconfiguration_nests_under_inbound(self): + result = _neighbor_af_to_device([{"afi": "ipv4", "soft_reconfiguration": True}]) + self.assertEqual(result, {"ipv4-unicast": {"soft_reconfiguration": {"inbound": {}}}}) + + def test_allowas_in_wraps_under_number(self): + result = _neighbor_af_to_device([{"afi": "ipv4", "allowas_in": 3}]) + self.assertEqual(result, {"ipv4-unicast": {"allowas_in": {"number": 3}}}) + + def test_capability_orf_value_becomes_dict_key(self): + result = _neighbor_af_to_device([{"afi": "ipv4", "capability": {"orf": "send"}}]) + self.assertEqual( + result, + {"ipv4-unicast": {"capability": {"orf": {"prefix-list": {"send": {}}}}}}, + ) + + def test_generic_options_pass_through_autoclean_only(self): + result = _neighbor_af_to_device( + [ { "afi": "ipv4", - "networks": [{"prefix": "192.0.2.0/24"}], - "redistribute": [{"protocol": "connected", "metric": 10}], + "nexthop_self": True, + "weight": 50, + "route_map": {"import": "RM-IN"}, + "distribute_list": {"import": 10, "export": 20}, + "attribute_unchanged": {"as_path": True, "next_hop": False}, }, ], - "neighbors": [ - { - "neighbor_address": "192.0.2.1", - "address_family": [ - {"afi": "ipv4", "soft_reconfiguration": True, "nexthop_self": True}, - ], + ) + self.assertEqual( + result, + { + "ipv4-unicast": { + "nexthop_self": {}, + "weight": 50, + "route_map": {"import": "RM-IN"}, + "distribute_list": {"import": 10, "export": 20}, + "attribute_unchanged": {"as_path": {}}, }, - ], - } + }, + ) - def test_deleted_removes_global_af(self): - cmds = build_commands({"as_number": 65000}, self._have(), "deleted") - self.assertIn(("delete", _BASE + ["address-family"]), cmds) + def test_no_options_is_bare_presence(self): + self.assertEqual(_neighbor_af_to_device([{"afi": "ipv4"}]), {"ipv4-unicast": {}}) - def test_deleted_removes_neighbor_af(self): - cmds = build_commands({"as_number": 65000}, self._have(), "deleted") - self.assertIn( - ("delete", _BASE + ["neighbor", "192.0.2.1", "address-family"]), - cmds, + +class TestNeighborAfFromDevice(unittest.TestCase): + def test_soft_reconfiguration_from_nested_inbound(self): + result = _neighbor_af_from_device( + {"ipv4-unicast": {"soft-reconfiguration": {"inbound": {}}}}, ) + self.assertEqual(result, [{"afi": "ipv4", "soft_reconfiguration": True}]) - def test_merged_network(self): - config = { - "as_number": 65000, - "address_family": [ - {"afi": "ipv4", "networks": [{"prefix": "192.0.5.0/24"}]}, - ], - } - cmds = build_commands(config, {}, "merged") - self.assertIn( - ("set", _BASE + ["address-family", "ipv4-unicast", "network", "192.0.5.0/24"]), - cmds, + def test_allowas_in_from_number_wrapper(self): + result = _neighbor_af_from_device({"ipv4-unicast": {"allowas-in": {"number": "3"}}}) + self.assertEqual(result, [{"afi": "ipv4", "allowas_in": 3}]) + + def test_allowas_in_bare_presence_defaults_to_one(self): + result = _neighbor_af_from_device({"ipv4-unicast": {"allowas-in": {}}}) + self.assertEqual(result, [{"afi": "ipv4", "allowas_in": 1}]) + + def test_capability_orf_receive_and_send(self): + r1 = _neighbor_af_from_device( + {"ipv4-unicast": {"capability": {"orf": {"prefix-list": {"receive": {}}}}}}, ) + self.assertEqual(r1[0]["capability"], {"orf": "receive"}) + r2 = _neighbor_af_from_device( + {"ipv4-unicast": {"capability": {"orf": {"prefix-list": {"send": {}}}}}}, + ) + self.assertEqual(r2[0]["capability"], {"orf": "send"}) - def test_merged_redistribute(self): - config = { - "as_number": 65000, - "address_family": [ - {"afi": "ipv4", "redistribute": [{"protocol": "connected", "metric": 10}]}, - ], - } - cmds = build_commands(config, {}, "merged") - self.assertIn( - ("set", _BASE + ["address-family", "ipv4-unicast", "redistribute", "connected"]), - cmds, + def test_ints_cast_via_argspec_not_hardcoded_list(self): + result = _neighbor_af_from_device( + { + "ipv4-unicast": { + "maximum-prefix": "100", + "weight": "50", + "distribute-list": {"import": "10", "export": "20"}, + }, + }, ) + entry = result[0] + self.assertEqual(entry["maximum_prefix"], 100) + self.assertEqual(entry["weight"], 50) + self.assertEqual(entry["distribute_list"], {"import": 10, "export": 20}) + + +class TestGlobalAfToDeviceFromDevice(unittest.TestCase): + def test_networks_keyed_by_prefix(self): + result = _global_af_to_device( + [{"afi": "ipv4", "networks": [{"prefix": "192.0.2.0/24", "backdoor": True}]}], + ) + self.assertEqual( + result, + {"ipv4-unicast": {"network": {"192.0.2.0/24": {"backdoor": {}}}}}, + ) + + def test_redistribute_keyed_by_protocol(self): + result = _global_af_to_device( + [{"afi": "ipv4", "redistribute": [{"protocol": "connected", "metric": 10}]}], + ) + self.assertEqual( + result, + {"ipv4-unicast": {"redistribute": {"connected": {"metric": 10}}}}, + ) + + def test_from_device_metric_cast_via_argspec(self): + result = _global_af_from_device( + {"ipv4-unicast": {"redistribute": {"connected": {"metric": "10"}}}}, + ) + self.assertEqual(result[0]["redistribute"], [{"protocol": "connected", "metric": 10}]) + + +class TestDeviceToArgspecFixture(VyOSModuleTestCase): + def test_as_number(self): + self.assertEqual(_device_to_argspec(self.fixture)["as_number"], 65000) + + def test_global_networks_and_redistribute(self): + af = _device_to_argspec(self.fixture)["address_family"][0] + prefixes = {n["prefix"]: n for n in af["networks"]} + self.assertEqual( + prefixes["192.0.3.0/24"], + {"prefix": "192.0.3.0/24", "route_map": "RM-OUT", "backdoor": True}, + ) + protocols = {r["protocol"]: r for r in af["redistribute"]} + self.assertEqual(protocols["connected"]["metric"], 10) + + def test_neighbor_wired_options(self): + nb = _device_to_argspec(self.fixture)["neighbors"][0] + ipv4 = next(af for af in nb["address_family"] if af["afi"] == "ipv4") + self.assertTrue(ipv4["nexthop_self"]) + self.assertTrue(ipv4["soft_reconfiguration"]) + self.assertEqual(ipv4["attribute_unchanged"], {"as_path": True, "med": True}) + self.assertEqual(ipv4["capability"], {"orf": "receive"}) + self.assertEqual(ipv4["distribute_list"], {"import": 10, "export": 20}) + + def test_empty_config(self): + self.assertEqual(_device_to_argspec({}), {}) + self.assertEqual(_device_to_argspec(None), {}) + + +class TestBuildCommands(VyOSModuleTestCase): + """End-to-end, exactly as main() calls it.""" + + def test_merged_idempotent_against_own_fixture(self): + have = _device_to_argspec(self.fixture) + self.assertEqual(build_commands(have, self.fixture, "merged"), []) + + def test_replaced_idempotent_against_own_fixture(self): + have = _device_to_argspec(self.fixture) + self.assertEqual(build_commands(have, self.fixture, "replaced"), []) + + def test_merged_new_option(self): + have = _device_to_argspec(self.fixture) + have["neighbors"][0]["address_family"][0]["weight"] = 200 + cmds = build_commands(have, self.fixture, "merged") self.assertIn( ( "set", _BASE - + [ - "address-family", - "ipv4-unicast", - "redistribute", - "connected", - "metric", - "10", - ], + + ["neighbor", "192.0.2.1", "address-family", "ipv4-unicast", "weight", "200"], ), cmds, ) - def test_merged_neighbor_soft_reconfig(self): - config = { - "as_number": 65000, - "neighbors": [ - { - "neighbor_address": "192.0.2.1", - "address_family": [ - {"afi": "ipv4", "soft_reconfiguration": True}, - ], - }, - ], - } - cmds = build_commands(config, {}, "merged") + def test_replaced_never_touches_neighbor_siblings(self): + """Regression test: dict_op is scoped strictly to each neighbor's + address-family subtree, never the whole neighbor. entry, so + fields owned by other modules (remote-as, timers, ...) are safe.""" + cmds = build_commands({"as_number": 65000}, self.fixture, "replaced") + self.assertTrue(all("remote-as" not in c[1] for c in cmds)) self.assertIn( - ( - "set", - _BASE - + [ - "neighbor", - "192.0.2.1", - "address-family", - "ipv4-unicast", - "soft-reconfiguration", - "inbound", - ], - ), + ("delete", _BASE + ["neighbor", "192.0.2.1", "address-family", "ipv4-unicast"]), + cmds, + ) + self.assertIn( + ("delete", _BASE + ["neighbor", "192.0.2.1", "address-family", "ipv6-unicast"]), cmds, ) - def test_merged_idempotent(self): - have = self._have() + def test_deleted_scoped_to_address_family_only(self): + cmds = build_commands({}, self.fixture, "deleted") + self.assertIn(("delete", _BASE + ["address-family"]), cmds) + self.assertIn( + ("delete", _BASE + ["neighbor", "192.0.2.1", "address-family"]), + cmds, + ) + self.assertTrue(all(c[1] != _BASE + ["neighbor", "192.0.2.1"] for c in cmds)) + + def test_normalize_have_prevents_char_iteration_bug(self): + """A single-child tag node collapsed to a bare string by the + device must not be iterated character-by-character.""" + raw_have = {"address-family": {"ipv4-unicast": {"network": "192.0.2.0/24"}}} config = { "as_number": 65000, - "address_family": [ - { - "afi": "ipv4", - "networks": [{"prefix": "192.0.2.0/24"}], - "redistribute": [{"protocol": "connected", "metric": 10}], - }, - ], - "neighbors": [ - { - "neighbor_address": "192.0.2.1", - "address_family": [ - {"afi": "ipv4", "soft_reconfiguration": True, "nexthop_self": True}, - ], - }, - ], + "address_family": [{"afi": "ipv4", "networks": [{"prefix": "192.0.2.0/24"}]}], } - cmds = build_commands(config, have, "merged") - self.assertEqual(cmds, []) + self.assertEqual(build_commands(config, raw_have, "merged"), []) - def test_replaced_idempotent(self): - have = self._have() + def test_fresh_merged_add(self): config = { "as_number": 65000, - "address_family": [ - { - "afi": "ipv4", - "networks": [{"prefix": "192.0.2.0/24"}], - "redistribute": [{"protocol": "connected", "metric": 10}], - }, - ], "neighbors": [ { - "neighbor_address": "192.0.2.1", - "address_family": [ - {"afi": "ipv4", "soft_reconfiguration": True, "nexthop_self": True}, - ], + "neighbor_address": "10.0.0.1", + "address_family": [{"afi": "ipv4", "weight": 200}], }, ], } - cmds = build_commands(config, have, "replaced") - self.assertEqual(cmds, []) - - def test_replaced_rebuilds_on_change(self): - have = self._have() - config = { - "as_number": 65000, - "address_family": [ - {"afi": "ipv4", "networks": [{"prefix": "192.0.9.0/24"}]}, - ], - } - cmds = build_commands(config, have, "replaced") - self.assertIn(("delete", _BASE + ["address-family"]), cmds) + cmds = build_commands(config, {}, "merged") self.assertIn( - ("set", _BASE + ["address-family", "ipv4-unicast", "network", "192.0.9.0/24"]), + ( + "set", + _BASE + ["neighbor", "10.0.0.1", "address-family", "ipv4-unicast", "weight", "200"], + ), cmds, ) diff --git a/tests/unit/modules/test_vyos_bgp_global.py b/tests/unit/modules/test_vyos_bgp_global.py index f91516b..118cea8 100644 --- a/tests/unit/modules/test_vyos_bgp_global.py +++ b/tests/unit/modules/test_vyos_bgp_global.py @@ -4,25 +4,25 @@ from __future__ import absolute_import, division, print_function __metaclass__ = type -import json -import os import unittest from unittest.mock import MagicMock from ansible_collections.vyos.rest.plugins.modules.vyos_bgp_global import ( + _device_to_argspec, + _neighbors_from_device, + _neighbors_to_device, + _peer_groups_from_device, + _peer_groups_to_device, + _want_to_device, build_commands, get_running_config, ) - -_BASE = ["protocols", "bgp"] +from .base import load_fixture -def load_fixture(filename): - fixtures_dir = os.path.join(os.path.dirname(__file__), "..", "fixtures") - with open(os.path.join(fixtures_dir, filename)) as f: - return json.load(f) +_BASE = ["protocols", "bgp"] class VyOSModuleTestCase(unittest.TestCase): @@ -32,156 +32,253 @@ class VyOSModuleTestCase(unittest.TestCase): self.mock_vyos.get_config = MagicMock(return_value=self.fixture) -class TestVyOSBgpGlobalGetRunning(VyOSModuleTestCase): - - def test_parses_as_number(self): - result = get_running_config(self.mock_vyos) - self.assertEqual(result["as_number"], 65000) - - def test_parses_parameters(self): - result = get_running_config(self.mock_vyos) - self.assertEqual(result["parameters"]["router_id"], "192.0.1.1") - self.assertTrue(result["parameters"]["log_neighbor_changes"]) - - def test_parses_neighbors(self): - result = get_running_config(self.mock_vyos) - nb_addrs = [n["neighbor_address"] for n in result["neighbors"]] - self.assertIn("192.0.2.1", nb_addrs) - self.assertIn("192.0.2.2", nb_addrs) - nb1 = next(n for n in result["neighbors"] if n["neighbor_address"] == "192.0.2.1") - self.assertEqual(nb1["remote_as"], 65001) - self.assertEqual(nb1["description"], "peer1") - self.assertEqual(nb1["timers"]["holdtime"], 30) - self.assertEqual(nb1["timers"]["keepalive"], 10) - nb2 = next(n for n in result["neighbors"] if n["neighbor_address"] == "192.0.2.2") - self.assertEqual(nb2["ebgp_multihop"], 2) - self.assertEqual(nb2["update_source"], "eth0") - - def test_parses_peer_groups(self): - result = get_running_config(self.mock_vyos) - self.assertEqual(len(result["peer_groups"]), 1) - self.assertEqual(result["peer_groups"][0]["peer_group"], "PG1") - self.assertEqual(result["peer_groups"][0]["remote_as"], 65003) +class TestGetRunningConfig(VyOSModuleTestCase): + def test_returns_raw_device_dict(self): + self.assertEqual(get_running_config(self.mock_vyos), self.fixture) def test_empty_config(self): - self.mock_vyos.get_config = MagicMock(return_value={}) - result = get_running_config(self.mock_vyos) - self.assertEqual(result, {}) + self.mock_vyos.get_config = MagicMock(return_value=None) + self.assertEqual(get_running_config(self.mock_vyos), {}) -class TestVyOSBgpGlobalBuildCommands(unittest.TestCase): +class TestNeighborsToDeviceFromDevice(unittest.TestCase): + def test_bare_neighbor_is_presence(self): + self.assertEqual( + _neighbors_to_device([{"neighbor_address": "192.0.2.1"}]), + {"192.0.2.1": {}}, + ) - def _have(self): - return { - "as_number": 65000, - "parameters": {"router_id": "192.0.1.1"}, - "neighbors": [ + def test_full_neighbor(self): + result = _neighbors_to_device( + [ { "neighbor_address": "192.0.2.1", "remote_as": 65001, "description": "peer1", + "shutdown": True, + "timers": {"holdtime": 30, "keepalive": 10}, }, ], - "peer_groups": [{"peer_group": "PG1", "remote_as": 65003}], - } + ) + self.assertEqual( + result, + { + "192.0.2.1": { + "remote_as": 65001, + "description": "peer1", + "shutdown": {}, + "timers": {"holdtime": 30, "keepalive": 10}, + }, + }, + ) - def test_deleted_with_have(self): - cmds = build_commands({}, self._have(), "deleted") - self.assertEqual(cmds, [("delete", _BASE)]) + def test_from_device_ints_cast_via_argspec(self): + result = _neighbors_from_device( + { + "192.0.2.1": { + "remote-as": "65001", + "ebgp-multihop": "2", + "timers": {"holdtime": "30", "keepalive": "10"}, + }, + }, + ) + entry = result[0] + self.assertEqual(entry["remote_as"], 65001) + self.assertEqual(entry["ebgp_multihop"], 2) + self.assertEqual(entry["timers"], {"holdtime": 30, "keepalive": 10}) + + def test_from_device_foreign_address_family_never_surfaces(self): + """Regression test: a neighbor's address-family subtree (owned by + vyos_bgp_address_family) must never appear in this module's have/ + gathered output.""" + result = _neighbors_from_device( + { + "192.0.2.1": { + "remote-as": "65001", + "address-family": {"ipv4-unicast": {"nexthop-self": {}}}, + }, + }, + ) + entry = result[0] + self.assertEqual(entry["remote_as"], 65001) + self.assertNotIn("address_family", entry) - def test_deleted_without_have(self): - cmds = build_commands({}, {}, "deleted") - self.assertEqual(cmds, []) - def test_merged_as_number(self): - config = {"as_number": 65000} - cmds = build_commands(config, {}, "merged") - self.assertIn(("set", _BASE + ["system-as", "65000"]), cmds) +class TestPeerGroupsToDeviceFromDevice(unittest.TestCase): + def test_bare_peer_group_is_presence(self): + self.assertEqual(_peer_groups_to_device([{"peer_group": "PG1"}]), {"PG1": {}}) - def test_merged_router_id(self): - config = {"as_number": 65000, "parameters": {"router_id": "192.0.1.1"}} - cmds = build_commands(config, {}, "merged") - self.assertIn(("set", _BASE + ["parameters", "router-id", "192.0.1.1"]), cmds) + def test_full_peer_group(self): + result = _peer_groups_to_device( + [{"peer_group": "PG1", "remote_as": 65002, "timers": {"holdtime": 30}}], + ) + self.assertEqual( + result, + {"PG1": {"remote_as": 65002, "timers": {"holdtime": 30}}}, + ) - def test_merged_neighbor(self): - config = { - "as_number": 65000, - "neighbors": [ - {"neighbor_address": "192.0.2.1", "remote_as": 65001}, - ], - } - cmds = build_commands(config, {}, "merged") - self.assertIn(("set", _BASE + ["neighbor", "192.0.2.1", "remote-as", "65001"]), cmds) + def test_from_device_cast(self): + result = _peer_groups_from_device({"PG1": {"remote-as": "65002"}}) + self.assertEqual(result, [{"peer_group": "PG1", "remote_as": 65002}]) - def test_merged_neighbor_timers(self): + +class TestWantToDevice(unittest.TestCase): + def test_empty(self): + self.assertEqual(_want_to_device({}), {}) + self.assertEqual(_want_to_device(None), {}) + + def test_confederation_peers_list_passes_through(self): + result = _want_to_device( + { + "as_number": 65000, + "parameters": {"confederation": {"identifier": 100, "peers": [65001, 65002]}}, + }, + ) + self.assertEqual( + result, + { + "system_as": 65000, + "parameters": {"confederation": {"identifier": 100, "peers": [65001, 65002]}}, + }, + ) + + def test_full_config(self): config = { "as_number": 65000, - "neighbors": [ - { - "neighbor_address": "192.0.2.1", - "remote_as": 65001, - "timers": {"holdtime": 30, "keepalive": 10}, - }, - ], + "parameters": {"router_id": "192.0.1.1", "graceful_restart": True}, + "neighbors": [{"neighbor_address": "192.0.2.1", "remote_as": 65001}], + "peer_groups": [{"peer_group": "PG1", "remote_as": 65002}], } - cmds = build_commands(config, {}, "merged") + result = _want_to_device(config) + self.assertEqual( + result, + { + "system_as": 65000, + "parameters": {"router_id": "192.0.1.1", "graceful_restart": {}}, + "neighbor": {"192.0.2.1": {"remote_as": 65001}}, + "peer_group": {"PG1": {"remote_as": 65002}}, + }, + ) + + +class TestDeviceToArgspecFixture(VyOSModuleTestCase): + def test_as_number_and_parameters(self): + have = _device_to_argspec(self.fixture) + self.assertEqual(have["as_number"], 65000) + self.assertEqual(have["parameters"]["router_id"], "192.0.1.1") + self.assertEqual( + have["parameters"]["confederation"], + {"identifier": 100, "peers": [65001, 65002]}, + ) + + def test_neighbor_and_peer_group(self): + have = _device_to_argspec(self.fixture) + nb = next(n for n in have["neighbors"] if n["neighbor_address"] == "192.0.2.1") + self.assertEqual(nb["remote_as"], 65001) + self.assertEqual(nb["timers"], {"holdtime": 30, "keepalive": 10}) + self.assertNotIn("address_family", nb) + pg = have["peer_groups"][0] + self.assertEqual(pg["peer_group"], "PG1") + self.assertEqual(pg["remote_as"], 65003) + + def test_empty_config(self): + self.assertEqual(_device_to_argspec({}), {}) + self.assertEqual(_device_to_argspec(None), {}) + + +class TestBuildCommands(VyOSModuleTestCase): + def test_merged_idempotent_against_own_fixture(self): + have = _device_to_argspec(self.fixture) + self.assertEqual(build_commands(have, self.fixture, "merged"), []) + + def test_replaced_idempotent_against_own_fixture(self): + have = _device_to_argspec(self.fixture) + self.assertEqual(build_commands(have, self.fixture, "replaced"), []) + + def test_replaced_purges_extra_confederation_peer(self): + """Regression test: dict_op's purge mode originally had no + handling for list-valued leaves at all (only dicts), so removing + a peer from confederation.peers under 'replaced' silently did + nothing. Fixed centrally in dict_op itself.""" + have = _device_to_argspec(self.fixture) + have["parameters"]["confederation"]["peers"] = [65001] + cmds = build_commands(have, self.fixture, "replaced") self.assertIn( - ("set", _BASE + ["neighbor", "192.0.2.1", "timers", "holdtime", "30"]), + ("delete", _BASE + ["parameters", "confederation", "peers", "65002"]), cmds, ) + + def test_merged_new_neighbor_field(self): + have = _device_to_argspec(self.fixture) + have["neighbors"][0]["local_as"] = 65099 + cmds = build_commands(have, self.fixture, "merged") self.assertIn( - ("set", _BASE + ["neighbor", "192.0.2.1", "timers", "keepalive", "10"]), + ("set", _BASE + ["neighbor", "192.0.2.1", "local-as", "65099"]), cmds, ) - def test_merged_idempotent(self): - have = self._have() - config = { - "as_number": 65000, - "parameters": {"router_id": "192.0.1.1"}, - "neighbors": [ - { - "neighbor_address": "192.0.2.1", - "remote_as": 65001, - "description": "peer1", - }, - ], - "peer_groups": [{"peer_group": "PG1", "remote_as": 65003}], - } - cmds = build_commands(config, have, "merged") + def test_replaced_never_touches_address_family(self): + """Regression test: this module shares protocols.bgp with + vyos_bgp_address_family; replaced/deleted must never purge or + delete that sibling module's address-family subtree.""" + cmds = build_commands({"as_number": 65000}, self.fixture, "replaced") + self.assertTrue(all("address-family" not in c[1] for c in cmds)) + + def test_deleted_removes_atomically_not_scoped(self): + """Regression test for the real device-model bug: VyOS rejects any + commit that removes system-as while other protocols.bgp content + (including a neighbor's address-family, owned by + vyos_bgp_address_family) still exists. A scoped/incremental + deletion here would leave an invalid intermediate state and hard- + fail at commit time -- deleted must delete the whole tree in one + atomic command whenever system-as is present.""" + cmds = build_commands({}, self.fixture, "deleted") + self.assertEqual(cmds, [("delete", _BASE)]) + + def test_deleted_with_no_system_as_is_a_noop(self): + cmds = build_commands({}, {}, "deleted") self.assertEqual(cmds, []) - def test_replaced_idempotent(self): - have = self._have() - config = { - "as_number": 65000, - "parameters": {"router_id": "192.0.1.1"}, - "neighbors": [ - { - "neighbor_address": "192.0.2.1", - "remote_as": 65001, - "description": "peer1", - }, - ], - "peer_groups": [{"peer_group": "PG1", "remote_as": 65003}], - } - cmds = build_commands(config, have, "replaced") + def test_replaced_without_as_number_also_nukes_atomically(self): + """The same VyOS constraint applies to 'replaced' whenever the new + desired state omits as_number -- not just 'deleted'.""" + cmds = build_commands({"neighbors": []}, self.fixture, "replaced") + self.assertEqual(cmds, [("delete", _BASE)]) + + def test_merged_with_empty_config_is_a_safe_noop(self): + """Regression test: merged must NEVER trigger the nuke short- + circuit just because as_number was omitted -- an omitted config + for merged means "nothing to change", not "delete everything".""" + cmds = build_commands({}, self.fixture, "merged") + self.assertEqual(cmds, []) + + def test_replaced_keeping_as_number_still_scopes_normally(self): + """When as_number is retained, replaced must still use the normal + scoped purge/set flow, not the atomic nuke.""" + have = _device_to_argspec(self.fixture) + cmds = build_commands(have, self.fixture, "replaced") self.assertEqual(cmds, []) + self.assertNotEqual(cmds, [("delete", _BASE)]) - def test_replaced_rebuilds_on_change(self): - have = self._have() - config = {"as_number": 65000, "parameters": {"router_id": "192.0.1.2"}} - cmds = build_commands(config, have, "replaced") - self.assertEqual(cmds[0], ("delete", _BASE)) - self.assertIn(("set", _BASE + ["parameters", "router-id", "192.0.1.2"]), cmds) + def test_collapsed_single_neighbor_no_char_iteration_bug(self): + """A neighbor tag node collapsed to a bare address string by the + device (single neighbor, otherwise unconfigured) must not be + iterated character-by-character.""" + raw_have = {"system-as": "65000", "neighbor": "192.0.2.1"} + config = {"as_number": 65000, "neighbors": [{"neighbor_address": "192.0.2.1"}]} + self.assertEqual(build_commands(config, raw_have, "merged"), []) - def test_merged_peer_group(self): + def test_fresh_merged_add(self): config = { "as_number": 65000, - "peer_groups": [{"peer_group": "PG1", "remote_as": 65003}], + "neighbors": [{"neighbor_address": "10.0.0.1", "remote_as": 65010}], } cmds = build_commands(config, {}, "merged") - self.assertIn(("set", _BASE + ["peer-group", "PG1", "remote-as", "65003"]), cmds) + self.assertIn( + ("set", _BASE + ["neighbor", "10.0.0.1", "remote-as", "65010"]), + cmds, + ) + self.assertIn(("set", _BASE + ["system-as", "65000"]), cmds) if __name__ == "__main__": diff --git a/tests/unit/modules/test_vyos_command.py b/tests/unit/modules/test_vyos_command.py new file mode 100644 index 0000000..5b474f9 --- /dev/null +++ b/tests/unit/modules/test_vyos_command.py @@ -0,0 +1,111 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, division, print_function + + +__metaclass__ = type + +import unittest + +from unittest.mock import MagicMock + +from ansible_collections.vyos.rest.plugins.modules.vyos_command import ( + evaluate_conditions, + parse_command, + run_commands, +) + + +class TestVyOSCommandParseCommand(unittest.TestCase): + + def test_string_single_word(self): + self.assertEqual(parse_command("version"), ["version"]) + + def test_string_multi_word(self): + self.assertEqual(parse_command("ip route"), ["ip", "route"]) + + def test_list_passthrough(self): + self.assertEqual(parse_command(["ip", "route"]), ["ip", "route"]) + + +class TestVyOSCommandEvaluateConditions(unittest.TestCase): + + def _stdout(self): + return ["VyOS 1.5.0 output", "eth0 192.168.1.1"] + + def test_contains_match(self): + failed, conds = evaluate_conditions( + self._stdout(), + ["result[0] contains VyOS"], + "all", + ) + self.assertFalse(failed) + self.assertEqual(conds, []) + + def test_contains_no_match(self): + failed, conds = evaluate_conditions( + self._stdout(), + ["result[0] contains NonExistent"], + "all", + ) + self.assertTrue(failed) + self.assertIn("result[0] contains NonExistent", conds) + + def test_match_all_both_pass(self): + failed, conditions = evaluate_conditions( + self._stdout(), + ["result[0] contains VyOS", "result[1] contains eth0"], + "all", + ) + self.assertFalse(failed) + + def test_match_all_one_fails(self): + failed, conditions = evaluate_conditions( + self._stdout(), + ["result[0] contains VyOS", "result[1] contains NonExistent"], + "all", + ) + self.assertTrue(failed) + + def test_match_any_one_passes(self): + failed, conditions = evaluate_conditions( + self._stdout(), + ["result[0] contains VyOS", "result[1] contains NonExistent"], + "any", + ) + self.assertFalse(failed) + + def test_empty_conditions(self): + failed, conds = evaluate_conditions(self._stdout(), [], "all") + self.assertFalse(failed) + self.assertEqual(conds, []) + + +class TestVyOSCommandRunCommands(unittest.TestCase): + + def setUp(self): + self.mock_vyos = MagicMock() + + def test_run_list_command(self): + self.mock_vyos.show = MagicMock(return_value="VyOS 1.5.0") + result = run_commands(self.mock_vyos, [["version"]]) + self.mock_vyos.show.assert_called_once_with(["version"]) + self.assertEqual(result, ["VyOS 1.5.0"]) + + def test_run_string_command(self): + self.mock_vyos.show = MagicMock(return_value="uptime") + run_commands(self.mock_vyos, ["system uptime"]) + self.mock_vyos.show.assert_called_once_with(["system", "uptime"]) + + def test_run_multiple_commands(self): + self.mock_vyos.show = MagicMock(side_effect=["out1", "out2"]) + result = run_commands(self.mock_vyos, [["version"], ["interfaces"]]) + self.assertEqual(result, ["out1", "out2"]) + + def test_run_none_returns_empty_string(self): + self.mock_vyos.show = MagicMock(return_value=None) + result = run_commands(self.mock_vyos, [["version"]]) + self.assertEqual(result, [""]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/modules/test_vyos_config.py b/tests/unit/modules/test_vyos_config.py new file mode 100644 index 0000000..749aa82 --- /dev/null +++ b/tests/unit/modules/test_vyos_config.py @@ -0,0 +1,107 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, division, print_function + + +__metaclass__ = type + +import unittest + +from unittest.mock import MagicMock + +from ansible_collections.vyos.rest.plugins.modules.vyos_config import ( + filter_commands, + parse_commands, + parse_line, +) + + +class TestVyOSConfigParseLine(unittest.TestCase): + + def test_set_single_value(self): + op, path = parse_line("set system host-name router1") + self.assertEqual(op, "set") + self.assertEqual(path, ["system", "host-name", "router1"]) + + def test_delete(self): + op, path = parse_line("delete protocols bgp") + self.assertEqual(op, "delete") + self.assertEqual(path, ["protocols", "bgp"]) + + def test_quoted_value(self): + op, path = parse_line('set interfaces ethernet eth0 description "My WAN"') + self.assertEqual(op, "set") + self.assertEqual(path, ["interfaces", "ethernet", "eth0", "description", "My WAN"]) + + def test_blank_line(self): + self.assertIsNone(parse_line("")) + + def test_comment_line(self): + self.assertIsNone(parse_line("# this is a comment")) + + def test_whitespace_only(self): + self.assertIsNone(parse_line(" ")) + + def test_invalid_op(self): + self.assertIsNone(parse_line("get system host-name")) + + +class TestVyOSConfigParseCommands(unittest.TestCase): + + def test_mixed_lines(self): + lines = [ + "# comment", + "", + "set system host-name router1", + "delete protocols bgp", + ] + result = parse_commands(lines) + self.assertEqual(len(result), 2) + self.assertEqual(result[0], ("set", ["system", "host-name", "router1"])) + self.assertEqual(result[1], ("delete", ["protocols", "bgp"])) + + +class TestVyOSConfigFilterCommands(unittest.TestCase): + + def setUp(self): + self.mock_vyos = MagicMock() + + def test_set_already_exists(self): + # API returns {"host-name": "router1"} for path ["system", "host-name"] + self.mock_vyos.get_config = MagicMock( + return_value={"host-name": "router1"}, + ) + cmds = [("set", ["system", "host-name", "router1"])] + result = filter_commands(cmds, self.mock_vyos) + self.assertEqual(result, []) + + def test_set_different_value(self): + self.mock_vyos.get_config = MagicMock( + return_value={"host-name": "old-name"}, + ) + cmds = [("set", ["system", "host-name", "new-name"])] + result = filter_commands(cmds, self.mock_vyos) + self.assertEqual(len(result), 1) + + def test_set_not_present(self): + self.mock_vyos.get_config = MagicMock(return_value={}) + cmds = [("set", ["system", "host-name", "router1"])] + result = filter_commands(cmds, self.mock_vyos) + self.assertEqual(len(result), 1) + + def test_delete_exists(self): + self.mock_vyos.get_config = MagicMock( + return_value={"description": "some desc"}, + ) + cmds = [("delete", ["interfaces", "ethernet", "eth0", "description"])] + result = filter_commands(cmds, self.mock_vyos) + self.assertEqual(len(result), 1) + + def test_delete_not_exists(self): + self.mock_vyos.get_config = MagicMock(return_value={}) + cmds = [("delete", ["interfaces", "ethernet", "eth0", "description"])] + result = filter_commands(cmds, self.mock_vyos) + self.assertEqual(result, []) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/modules/test_vyos_facts.py b/tests/unit/modules/test_vyos_facts.py index 3fcf82e..7eec8d4 100644 --- a/tests/unit/modules/test_vyos_facts.py +++ b/tests/unit/modules/test_vyos_facts.py @@ -4,8 +4,6 @@ from __future__ import absolute_import, division, print_function __metaclass__ = type -import json -import os import unittest from unittest.mock import MagicMock @@ -18,11 +16,7 @@ from ansible_collections.vyos.rest.plugins.modules.vyos_facts import ( gather_users, ) - -def load_fixture(filename): - fixtures_dir = os.path.join(os.path.dirname(__file__), "..", "fixtures") - with open(os.path.join(fixtures_dir, filename)) as f: - return json.load(f) +from .base import load_fixture class TestVyOSFactsGather(unittest.TestCase): diff --git a/tests/unit/modules/test_vyos_firewall_global.py b/tests/unit/modules/test_vyos_firewall_global.py index a507c0c..e3aff0b 100644 --- a/tests/unit/modules/test_vyos_firewall_global.py +++ b/tests/unit/modules/test_vyos_firewall_global.py @@ -4,38 +4,165 @@ from __future__ import absolute_import, division, print_function __metaclass__ = type -import json -import os import unittest -from unittest.mock import MagicMock - +from ansible_collections.vyos.rest.plugins.module_utils.vyos import dict_op from ansible_collections.vyos.rest.plugins.modules.vyos_firewall_global import ( + _device_to_argspec, + _groups_from_device, + _groups_to_device, + _want_to_device, build_commands, get_running_config, ) +from .base import load_fixture + _BASE = ["firewall", "group"] -def load_fixture(filename): - fixtures_dir = os.path.join(os.path.dirname(__file__), "..", "fixtures") - with open(os.path.join(fixtures_dir, filename)) as f: - return json.load(f) +class TestGroupHelpers(unittest.TestCase): + """Test group list <-> device dict conversion helpers.""" + def test_groups_to_device_with_members(self): + groups = [{"name": "SERVERS", "address": ["192.168.1.10", "192.168.1.11"]}] + result = _groups_to_device(groups, "address") + self.assertIn("SERVERS", result) + self.assertIn("192.168.1.10", result["SERVERS"]["address"]) + self.assertIn("192.168.1.11", result["SERVERS"]["address"]) -class VyOSModuleTestCase(unittest.TestCase): - def setUp(self): - self.mock_vyos = MagicMock() - self.fixture = load_fixture("firewall_global_running.json") - self.mock_vyos.get_config = MagicMock(return_value=self.fixture) + def test_groups_to_device_with_description(self): + groups = [{"name": "LAN", "description": "Local network", "network": ["192.168.0.0/16"]}] + result = _groups_to_device(groups, "network") + self.assertEqual(result["LAN"]["description"], "Local network") + + def test_groups_to_device_empty(self): + self.assertEqual(_groups_to_device([], "address"), {}) + self.assertEqual(_groups_to_device(None, "address"), {}) + + def test_groups_from_device_with_members(self): + raw = {"SERVERS": {"address": {"192.168.1.10": {}, "192.168.1.11": {}}}} + result = _groups_from_device(raw, "address") + self.assertEqual(len(result), 1) + self.assertEqual(result[0]["name"], "SERVERS") + self.assertIn("192.168.1.10", result[0]["address"]) + + def test_groups_from_device_single_member_string(self): + # VyOS returns single member as string + raw = {"WEB": {"port": "80"}} + result = _groups_from_device(raw, "port") + self.assertIn("80", result[0]["port"]) + + def test_groups_from_device_with_description(self): + raw = {"LAN": {"description": "Local", "network": {"192.168.0.0/16": {}}}} + result = _groups_from_device(raw, "network") + self.assertEqual(result[0]["description"], "Local") + + def test_groups_from_device_sorted(self): + raw = {"Z-GROUP": {}, "A-GROUP": {}} + result = _groups_from_device(raw, "address") + self.assertEqual(result[0]["name"], "A-GROUP") + self.assertEqual(result[1]["name"], "Z-GROUP") + + def test_groups_from_device_empty(self): + self.assertEqual(_groups_from_device({}, "address"), []) + self.assertEqual(_groups_from_device(None, "address"), []) + + +class TestWantToDevice(unittest.TestCase): + """Test argspec -> device shape conversion.""" + + def test_empty(self): + self.assertEqual(_want_to_device({}), {}) + self.assertEqual(_want_to_device(None), {}) + + def test_address_group(self): + config = { + "group": { + "address_group": [ + {"name": "SERVERS", "address": ["192.168.1.10"]}, + ], + }, + } + result = _want_to_device(config) + self.assertIn("address-group", result) + self.assertIn("SERVERS", result["address-group"]) + self.assertIn("192.168.1.10", result["address-group"]["SERVERS"]["address"]) + + def test_network_group(self): + config = { + "group": { + "network_group": [{"name": "LAN", "network": ["192.168.0.0/16"]}], + }, + } + result = _want_to_device(config) + self.assertIn("network-group", result) + self.assertIn("LAN", result["network-group"]) + + def test_port_group(self): + config = { + "group": { + "port_group": [{"name": "WEB", "port": ["80", "443"]}], + }, + } + result = _want_to_device(config) + self.assertIn("port-group", result) + self.assertIn("80", result["port-group"]["WEB"]["port"]) + + def test_interface_group(self): + config = { + "group": { + "interface_group": [{"name": "LAN-IFACES", "interface": ["eth1"]}], + }, + } + result = _want_to_device(config) + self.assertIn("interface-group", result) + + def test_ipv6_network_group(self): + config = { + "group": { + "ipv6_network_group": [{"name": "IPV6-LAN", "network": ["2001:db8::/32"]}], + }, + } + result = _want_to_device(config) + self.assertIn("ipv6-network-group", result) -class TestVyOSFirewallGlobalGetRunning(VyOSModuleTestCase): +class TestDeviceToArgspec(unittest.TestCase): + """Test device response -> argspec shape conversion.""" + + def test_empty(self): + self.assertEqual(_device_to_argspec({}), {}) + self.assertEqual(_device_to_argspec(None), {}) + + def test_address_group(self): + raw = { + "address-group": { + "SERVERS": { + "description": "Web servers", + "address": {"192.168.1.10": {}, "192.168.1.11": {}}, + }, + }, + } + result = _device_to_argspec(raw) + groups = result["group"]["address_group"] + servers = next(g for g in groups if g["name"] == "SERVERS") + self.assertEqual(servers["description"], "Web servers") + self.assertIn("192.168.1.10", servers["address"]) + + def test_no_group_returns_empty(self): + self.assertEqual(_device_to_argspec({}), {}) + + +class TestDeviceToArgspecFixture(unittest.TestCase): + """Test _device_to_argspec against fixture.""" + + def setUp(self): + self.fixture = load_fixture("firewall_global_running.json") def test_parses_address_groups(self): - result = get_running_config(self.mock_vyos) + result = _device_to_argspec(self.fixture) groups = result["group"]["address_group"] names = [g["name"] for g in groups] self.assertIn("SERVERS", names) @@ -43,148 +170,185 @@ class TestVyOSFirewallGlobalGetRunning(VyOSModuleTestCase): servers = next(g for g in groups if g["name"] == "SERVERS") self.assertEqual(servers["description"], "Web servers") self.assertIn("192.168.1.10", servers["address"]) - self.assertIn("192.168.1.11", servers["address"]) def test_parses_network_groups(self): - result = get_running_config(self.mock_vyos) + result = _device_to_argspec(self.fixture) groups = result["group"]["network_group"] dmz = next(g for g in groups if g["name"] == "DMZ") self.assertIn("10.0.0.0/8", dmz["network"]) - self.assertIn("172.16.0.0/12", dmz["network"]) def test_parses_port_groups(self): - result = get_running_config(self.mock_vyos) + result = _device_to_argspec(self.fixture) groups = result["group"]["port_group"] web = next(g for g in groups if g["name"] == "WEB-PORTS") self.assertIn("80", web["port"]) - self.assertIn("443", web["port"]) def test_parses_interface_groups(self): - result = get_running_config(self.mock_vyos) + result = _device_to_argspec(self.fixture) groups = result["group"]["interface_group"] lan = next(g for g in groups if g["name"] == "LAN-IFACES") self.assertIn("eth1", lan["interface"]) def test_parses_ipv6_network_groups(self): - result = get_running_config(self.mock_vyos) + result = _device_to_argspec(self.fixture) groups = result["group"]["ipv6_network_group"] ipv6 = next(g for g in groups if g["name"] == "IPV6-LAN") self.assertIn("2001:db8::/32", ipv6["network"]) def test_empty_config(self): - self.mock_vyos.get_config = MagicMock(return_value={}) - result = get_running_config(self.mock_vyos) + result = _device_to_argspec({}) self.assertEqual(result, {}) -class TestVyOSFirewallGlobalBuildCommands(unittest.TestCase): +class TestDictOpFirewall(unittest.TestCase): + """Test dict_op behaviour with firewall group shapes.""" - def _have(self): - return { - "group": { - "address_group": [ - {"name": "SERVERS", "address": ["192.168.1.10", "192.168.1.11"]}, - ], - "network_group": [ - {"name": "LAN", "network": ["192.168.0.0/16"]}, - ], + def test_merged_adds_address_group(self): + want = _want_to_device( + { + "group": { + "address_group": [{"name": "SERVERS", "address": ["192.168.1.10"]}], + }, }, - } - - def test_deleted_with_have(self): - cmds = build_commands({}, self._have(), "deleted") - self.assertEqual(cmds, [("delete", _BASE)]) - - def test_deleted_without_have(self): - cmds = build_commands({}, {}, "deleted") - self.assertEqual(cmds, []) - - def test_merged_address_group(self): - config = { - "group": { - "address_group": [ - {"name": "SERVERS", "address": ["192.168.1.10"]}, - ], - }, - } - cmds = build_commands(config, {}, "merged") - self.assertIn( - ("set", _BASE + ["address-group", "SERVERS", "address", "192.168.1.10"]), - cmds, - ) - - def test_merged_network_group(self): - config = { - "group": { - "network_group": [ - {"name": "LAN", "network": ["192.168.0.0/16"]}, - ], - }, - } - cmds = build_commands(config, {}, "merged") - self.assertIn( - ("set", _BASE + ["network-group", "LAN", "network", "192.168.0.0/16"]), - cmds, - ) - - def test_merged_port_group(self): - config = { - "group": { - "port_group": [ - {"name": "WEB", "port": ["80", "443"]}, - ], - }, - } - cmds = build_commands(config, {}, "merged") - self.assertIn( - ("set", _BASE + ["port-group", "WEB", "port", "80"]), - cmds, ) + cmds = dict_op(want, {}, _BASE, op="set") + paths = [c[1] for c in cmds] + self.assertIn(_BASE + ["address-group", "SERVERS", "address", "192.168.1.10"], paths) def test_merged_idempotent(self): - have = self._have() config = { "group": { - "address_group": [ - {"name": "SERVERS", "address": ["192.168.1.10", "192.168.1.11"]}, - ], - "network_group": [ - {"name": "LAN", "network": ["192.168.0.0/16"]}, - ], + "address_group": [{"name": "SERVERS", "address": ["192.168.1.10"]}], }, } - cmds = build_commands(config, have, "merged") + want = _want_to_device(config) + have = {"address-group": {"SERVERS": {"address": {"192.168.1.10": {}}}}} + cmds = dict_op(want, have, _BASE, op="set") self.assertEqual(cmds, []) - def test_replaced_removes_extra_group(self): - have = self._have() - config = { - "group": { - "network_group": [ - {"name": "DMZ", "network": ["10.0.0.0/8"]}, - ], + def test_merged_adds_network_group(self): + want = _want_to_device( + { + "group": { + "network_group": [{"name": "LAN", "network": ["192.168.0.0/16"]}], + }, }, + ) + cmds = dict_op(want, {}, _BASE, op="set") + paths = [c[1] for c in cmds] + self.assertIn(_BASE + ["network-group", "LAN", "network", "192.168.0.0/16"], paths) + + def test_purge_removes_extra_group(self): + want = _want_to_device( + { + "group": { + "network_group": [{"name": "DMZ", "network": ["10.0.0.0/8"]}], + }, + }, + ) + have = { + "address-group": {"SERVERS": {"address": {"192.168.1.10": {}}}}, + "network-group": {"LAN": {"network": {"192.168.0.0/16": {}}}}, } - cmds = build_commands(config, have, "replaced") + cmds = dict_op(want, have, _BASE, op="purge") paths = [c[1] for c in cmds] - self.assertIn(_BASE + ["address-group", "SERVERS"], paths) + # address-group entirely absent from want -> whole type deleted + self.assertIn(_BASE + ["address-group"], paths) self.assertIn(_BASE + ["network-group", "LAN"], paths) - def test_replaced_idempotent(self): - have = self._have() + def test_merged_idempotent_with_description(self): + """Regression test: the original implementation's have-side + normalization corrupted plain scalar fields (description) into + bogus presence-dicts via the same blanket conversion used for + member lists, breaking idempotency for any group with a + description set. Fixed by using dict_op's native list handling + for members and never touching scalar fields at all.""" config = { "group": { "address_group": [ - {"name": "SERVERS", "address": ["192.168.1.10", "192.168.1.11"]}, - ], - "network_group": [ - {"name": "LAN", "network": ["192.168.0.0/16"]}, + {"name": "SERVERS", "description": "Web servers", "address": ["10.0.0.1"]}, ], }, } - cmds = build_commands(config, have, "replaced") + want = _want_to_device(config) + have = { + "address-group": { + "SERVERS": {"description": "Web servers", "address": ["10.0.0.1"]}, + }, + } + cmds = dict_op(want, have, _BASE, op="set") self.assertEqual(cmds, []) + def test_replaced_removes_stale_member(self): + """Regression test: dict_op's purge mode originally had no + handling for list-valued leaves (only dicts), so a member + present on the device but absent from the desired list was + silently never removed under 'replaced'. Fixed centrally in + dict_op itself.""" + want = _want_to_device( + {"group": {"address_group": [{"name": "SERVERS", "address": ["10.0.0.1"]}]}}, + ) + have = {"address-group": {"SERVERS": {"address": ["10.0.0.1", "10.0.0.2"]}}} + cmds = dict_op(want, have, _BASE, op="purge") + self.assertIn( + ("delete", _BASE + ["address-group", "SERVERS", "address", "10.0.0.2"]), + cmds, + ) + + def test_deleted_removes_base(self): + # deleted state is handled in main() not dict_op + # just verify want_to_device produces correct shape + want = _want_to_device({}) + self.assertEqual(want, {}) + + +class TestBuildCommands(unittest.TestCase): + """build_commands/get_running_config were extracted from main() during + the dict_op refactor so they're independently testable.""" + + def test_get_running_config_fetches_at_base(self): + from unittest.mock import MagicMock + + mock_vyos = MagicMock() + mock_vyos.get_config = MagicMock(return_value={"address-group": {}}) + get_running_config(mock_vyos) + mock_vyos.get_config.assert_called_once_with(_BASE) + + def test_merged_idempotent_against_fixture(self): + fixture = load_fixture("firewall_global_running.json") + have = _device_to_argspec(fixture) + self.assertEqual(build_commands(have, fixture, "merged"), []) + + def test_replaced_idempotent_against_fixture(self): + fixture = load_fixture("firewall_global_running.json") + have = _device_to_argspec(fixture) + self.assertEqual(build_commands(have, fixture, "replaced"), []) + + def test_deleted_no_have_is_noop(self): + self.assertEqual(build_commands({}, {}, "deleted"), []) + + def test_deleted_with_have(self): + self.assertEqual( + build_commands({}, {"address-group": {"SERVERS": {}}}, "deleted"), + [("delete", _BASE)], + ) + + def test_single_value_member_collapse_no_char_iteration_bug(self): + """A group with exactly one member, collapsed by the device to a + bare string instead of a list, must not be iterated + character-by-character.""" + raw_have = {"port-group": {"WEB": {"port": "80"}}} + config = {"group": {"port_group": [{"name": "WEB", "port": ["80"]}]}} + self.assertEqual(build_commands(config, raw_have, "merged"), []) + + def test_group_tag_node_collapse_no_char_iteration_bug(self): + """A single group with zero other fields, collapsed by the + device to a bare group-name string, must not be iterated + character-by-character.""" + raw_have = {"address-group": "SERVERS"} + config = {"group": {"address_group": [{"name": "SERVERS"}]}} + self.assertEqual(build_commands(config, raw_have, "merged"), []) + if __name__ == "__main__": unittest.main() diff --git a/tests/unit/modules/test_vyos_firewall_interfaces.py b/tests/unit/modules/test_vyos_firewall_interfaces.py index 66b3883..26d2c4e 100644 --- a/tests/unit/modules/test_vyos_firewall_interfaces.py +++ b/tests/unit/modules/test_vyos_firewall_interfaces.py @@ -4,107 +4,188 @@ from __future__ import absolute_import, division, print_function __metaclass__ = type -import json -import os import unittest from unittest.mock import MagicMock from ansible_collections.vyos.rest.plugins.modules.vyos_firewall_interfaces import ( + _device_to_argspec, + _hook_filter_from_device, + _hook_filter_to_device, + _rules_from_device, + _rules_to_device, + _want_to_device, build_commands, get_running_config, ) - -_BASE = ["firewall"] +from .base import load_fixture -def load_fixture(filename): - fixtures_dir = os.path.join(os.path.dirname(__file__), "..", "fixtures") - with open(os.path.join(fixtures_dir, filename)) as f: - return json.load(f) +_BASE = ["firewall"] class VyOSModuleTestCase(unittest.TestCase): def setUp(self): self.mock_vyos = MagicMock() self.fixture = load_fixture("firewall_interfaces_running.json") + self.mock_vyos.get_config = MagicMock(return_value=self.fixture) - def _set_afi(self, afi): - data = self.fixture.get(afi, {}) - self.mock_vyos.get_config = MagicMock(return_value=data) +class TestGetRunningConfig(VyOSModuleTestCase): + def test_single_combined_fetch(self): + """Confirm get_running_config fetches once at _BASE, not per-AFI.""" + get_running_config(self.mock_vyos) + self.mock_vyos.get_config.assert_called_once_with(_BASE) -class TestVyOSFirewallInterfacesGetRunning(VyOSModuleTestCase): + def test_returns_raw_device_dict(self): + self.assertEqual(get_running_config(self.mock_vyos), self.fixture) - def test_parses_ipv4_hooks(self): - self._set_afi("ipv4") - result = get_running_config(self.mock_vyos) - ipv4 = next((e for e in result if e["afi"] == "ipv4"), None) - self.assertIsNotNone(ipv4) - hook_names = [h["hook"] for h in ipv4["hooks"]] - self.assertIn("input", hook_names) - self.assertIn("forward", hook_names) - self.assertIn("output", hook_names) + def test_empty_config(self): + self.mock_vyos.get_config = MagicMock(return_value=None) + self.assertEqual(get_running_config(self.mock_vyos), {}) - def test_parses_input_rules(self): - self._set_afi("ipv4") - result = get_running_config(self.mock_vyos) - ipv4 = next(e for e in result if e["afi"] == "ipv4") - input_hook = next(h for h in ipv4["hooks"] if h["hook"] == "input") - self.assertEqual(input_hook["default_action"], "accept") - self.assertEqual(len(input_hook["rules"]), 2) - r10 = next(r for r in input_hook["rules"] if r["number"] == 10) - self.assertEqual(r10["action"], "accept") - self.assertEqual(r10["state"], "established") - - def test_parses_ipv6_hooks(self): - self._set_afi("ipv6") - result = get_running_config(self.mock_vyos) - ipv6 = next((e for e in result if e["afi"] == "ipv6"), None) - self.assertIsNotNone(ipv6) - self.assertEqual(ipv6["hooks"][0]["hook"], "input") - def test_empty_config(self): - self.mock_vyos.get_config = MagicMock(return_value={}) - result = get_running_config(self.mock_vyos) - self.assertEqual(result, []) +class TestRulesToDeviceFromDevice(unittest.TestCase): + def test_bare_rule_is_presence(self): + self.assertEqual(_rules_to_device([{"number": 10}]), {"10": {}}) + + def test_full_rule(self): + result = _rules_to_device( + [ + { + "number": 20, + "action": "drop", + "state": "invalid", + "source": {"address": "10.0.0.0/8"}, + "disable": True, + }, + ], + ) + self.assertEqual( + result, + { + "20": { + "action": "drop", + "state": "invalid", + "source": {"address": "10.0.0.0/8"}, + "disable": {}, + }, + }, + ) + + def test_from_device_number_cast_to_int(self): + result = _rules_from_device({"10": {"action": "accept"}}) + self.assertEqual(result, [{"number": 10, "action": "accept"}]) + + def test_from_device_sorted_numerically_not_lexically(self): + result = _rules_from_device({"20": {}, "9": {}, "100": {}}) + self.assertEqual([r["number"] for r in result], [9, 20, 100]) + + def test_source_destination_round_trip(self): + raw = {"20": {"source": {"address": "10.0.0.0/8"}, "destination": {"port": "22"}}} + result = _rules_from_device(raw) + self.assertEqual(result[0]["source"], {"address": "10.0.0.0/8"}) + self.assertEqual(result[0]["destination"], {"port": "22"}) + + +class TestHookFilterToDeviceFromDevice(unittest.TestCase): + def test_bare_hook_is_presence(self): + self.assertEqual(_hook_filter_to_device({"hook": "input"}), {}) + + def test_default_action_and_description(self): + result = _hook_filter_to_device( + {"hook": "input", "default_action": "accept", "description": "desc"}, + ) + self.assertEqual(result, {"default_action": "accept", "description": "desc"}) + + def test_with_rules(self): + result = _hook_filter_to_device( + {"hook": "input", "rules": [{"number": 10, "action": "accept"}]}, + ) + self.assertEqual(result, {"rule": {"10": {"action": "accept"}}}) + def test_from_device_basic(self): + entry = _hook_filter_from_device("input", {"default-action": "accept"}) + self.assertEqual(entry, {"hook": "input", "default_action": "accept"}) + + def test_from_device_with_rules(self): + entry = _hook_filter_from_device( + "input", + {"default-action": "accept", "rule": {"10": {"action": "accept"}}}, + ) + self.assertEqual(entry["default_action"], "accept") + self.assertEqual(entry["rules"], [{"number": 10, "action": "accept"}]) -class TestVyOSFirewallInterfacesBuildCommands(unittest.TestCase): - def _have(self): - return [ +class TestWantToDevice(unittest.TestCase): + def test_empty(self): + self.assertEqual(_want_to_device([]), {}) + self.assertEqual(_want_to_device(None), {}) + + def test_afi_with_no_hooks_omitted(self): + self.assertEqual(_want_to_device([{"afi": "ipv4", "hooks": []}]), {}) + + def test_full_config(self): + config = [ { "afi": "ipv4", "hooks": [ - { - "hook": "input", - "default_action": "accept", - "rules": [ - {"number": 10, "action": "accept", "state": "established"}, - {"number": 20, "action": "drop", "state": "invalid"}, - ], - }, - {"hook": "forward", "default_action": "accept"}, + {"hook": "input", "default_action": "accept"}, ], }, ] + result = _want_to_device(config) + self.assertEqual( + result, + {"ipv4": {"input": {"filter": {"default_action": "accept"}}}}, + ) - def test_deleted_all(self): - cmds = build_commands([], self._have(), "deleted") - paths = [c[1] for c in cmds] - self.assertIn(_BASE + ["ipv4", "input", "filter"], paths) - self.assertIn(_BASE + ["ipv4", "forward", "filter"], paths) - def test_deleted_specific(self): - config = [{"afi": "ipv4", "hooks": [{"hook": "input"}]}] - cmds = build_commands(config, self._have(), "deleted") - self.assertIn(("delete", _BASE + ["ipv4", "input", "filter"]), cmds) - paths = [c[1] for c in cmds] - self.assertNotIn(_BASE + ["ipv4", "forward", "filter"], paths) +class TestDeviceToArgspecFixture(VyOSModuleTestCase): + def test_ipv4_input_with_rules(self): + have = _device_to_argspec(self.fixture) + ipv4 = next(e for e in have if e["afi"] == "ipv4") + input_hook = next(h for h in ipv4["hooks"] if h["hook"] == "input") + self.assertEqual(input_hook["default_action"], "accept") + rule20 = next(r for r in input_hook["rules"] if r["number"] == 20) + self.assertEqual(rule20["source"], {"address": "10.0.0.0/8"}) + self.assertEqual(rule20["destination"], {"port": "22"}) + + def test_sibling_module_data_never_surfaces(self): + """Regression test: firewall.ipv4.name (owned by + vyos_firewall_rules) must never appear in this module's output.""" + have = _device_to_argspec(self.fixture) + ipv4 = next(e for e in have if e["afi"] == "ipv4") + hook_names = {h["hook"] for h in ipv4["hooks"]} + self.assertEqual(hook_names, {"input", "forward", "output"}) + + def test_ipv6_present(self): + have = _device_to_argspec(self.fixture) + afis = {e["afi"] for e in have} + self.assertIn("ipv6", afis) + + def test_empty_config(self): + self.assertEqual(_device_to_argspec({}), []) + self.assertEqual(_device_to_argspec(None), []) + + +class TestBuildCommands(VyOSModuleTestCase): + def test_merged_idempotent_against_own_fixture(self): + have = _device_to_argspec(self.fixture) + self.assertEqual(build_commands(have, self.fixture, "merged"), []) + + def test_replaced_idempotent_against_own_fixture(self): + have = _device_to_argspec(self.fixture) + self.assertEqual(build_commands(have, self.fixture, "replaced"), []) + + def test_overridden_idempotent_against_own_fixture(self): + have = _device_to_argspec(self.fixture) + self.assertEqual(build_commands(have, self.fixture, "overridden"), []) - def test_merged_hook(self): + def test_replaced_scoped_to_named_hooks_only(self): + """replaced only touches hooks explicitly named in config -- an + omitted hook (output here) must be left alone.""" config = [ { "afi": "ipv4", @@ -112,23 +193,24 @@ class TestVyOSFirewallInterfacesBuildCommands(unittest.TestCase): { "hook": "input", "default_action": "accept", - "rules": [{"number": 10, "action": "accept", "state": "established"}], + "rules": [ + {"number": 10, "action": "accept", "state": "established"}, + { + "number": 20, + "action": "drop", + "state": "invalid", + "source": {"address": "10.0.0.0/8"}, + "destination": {"port": "22"}, + }, + ], }, ], }, ] - cmds = build_commands(config, [], "merged") - self.assertIn( - ("set", _BASE + ["ipv4", "input", "filter", "default-action", "accept"]), - cmds, - ) - self.assertIn( - ("set", _BASE + ["ipv4", "input", "filter", "rule", "10", "action", "accept"]), - cmds, - ) + self.assertEqual(build_commands(config, self.fixture, "replaced"), []) - def test_merged_idempotent(self): - have = self._have() + def test_overridden_deletes_omitted_hook(self): + """overridden is full-model: an omitted hook must be deleted.""" config = [ { "afi": "ipv4", @@ -138,30 +220,62 @@ class TestVyOSFirewallInterfacesBuildCommands(unittest.TestCase): "default_action": "accept", "rules": [ {"number": 10, "action": "accept", "state": "established"}, - {"number": 20, "action": "drop", "state": "invalid"}, + { + "number": 20, + "action": "drop", + "state": "invalid", + "source": {"address": "10.0.0.0/8"}, + "destination": {"port": "22"}, + }, ], }, - {"hook": "forward", "default_action": "accept"}, ], }, ] - cmds = build_commands(config, have, "merged") - self.assertEqual(cmds, []) + cmds = build_commands(config, self.fixture, "overridden") + self.assertIn(("delete", _BASE + ["ipv4", "output", "filter"]), cmds) + self.assertIn(("delete", _BASE + ["ipv4", "forward", "filter"]), cmds) + self.assertIn(("delete", _BASE + ["ipv6", "input", "filter"]), cmds) - def test_overridden_removes_extra_hook(self): - have = self._have() - config = [ - { - "afi": "ipv4", - "hooks": [ - {"hook": "output", "default_action": "accept"}, - ], - }, - ] - cmds = build_commands(config, have, "overridden") - paths = [c[1] for c in cmds] - self.assertIn(_BASE + ["ipv4", "input", "filter"], paths) - self.assertIn(_BASE + ["ipv4", "forward", "filter"], paths) + def test_overridden_never_touches_sibling_ruleset(self): + cmds = build_commands([], self.fixture, "overridden") + self.assertTrue(all("name" not in c[1] for c in cmds)) + + def test_deleted_never_touches_sibling_ruleset(self): + cmds = build_commands([], self.fixture, "deleted") + self.assertTrue(all("name" not in c[1] for c in cmds)) + self.assertIn(("delete", _BASE + ["ipv4", "input", "filter"]), cmds) + + def test_deleted_scoped_to_named_config(self): + cmds = build_commands( + [{"afi": "ipv4", "hooks": [{"hook": "input"}]}], + self.fixture, + "deleted", + ) + self.assertEqual(cmds, [("delete", _BASE + ["ipv4", "input", "filter"])]) + + def test_collapsed_rule_no_char_iteration_bug(self): + """A single rule with no other config collapsed to a bare string + by the device must not be iterated character-by-character.""" + raw_have = {"ipv4": {"input": {"filter": {"rule": "10"}}}} + config = [{"afi": "ipv4", "hooks": [{"hook": "input", "rules": [{"number": 10}]}]}] + self.assertEqual(build_commands(config, raw_have, "merged"), []) + + def test_merged_new_rule(self): + cmds = build_commands( + [ + { + "afi": "ipv4", + "hooks": [{"hook": "input", "rules": [{"number": 30, "action": "accept"}]}], + }, + ], + self.fixture, + "merged", + ) + self.assertIn( + ("set", _BASE + ["ipv4", "input", "filter", "rule", "30", "action", "accept"]), + cmds, + ) if __name__ == "__main__": diff --git a/tests/unit/modules/test_vyos_firewall_rules.py b/tests/unit/modules/test_vyos_firewall_rules.py index 4eaad9e..7642da3 100644 --- a/tests/unit/modules/test_vyos_firewall_rules.py +++ b/tests/unit/modules/test_vyos_firewall_rules.py @@ -4,25 +4,27 @@ from __future__ import absolute_import, division, print_function __metaclass__ = type -import json -import os import unittest from unittest.mock import MagicMock from ansible_collections.vyos.rest.plugins.modules.vyos_firewall_rules import ( + _device_to_argspec, + _endpoint_from_device, + _endpoint_to_device, + _rule_set_from_device, + _rule_set_to_device, + _rules_from_device, + _rules_to_device, + _want_to_device, build_commands, get_running_config, ) - -_BASE = ["firewall"] +from .base import load_fixture -def load_fixture(filename): - fixtures_dir = os.path.join(os.path.dirname(__file__), "..", "fixtures") - with open(os.path.join(fixtures_dir, filename)) as f: - return json.load(f) +_BASE = ["firewall"] class VyOSModuleTestCase(unittest.TestCase): @@ -30,171 +32,258 @@ class VyOSModuleTestCase(unittest.TestCase): self.mock_vyos = MagicMock() self.fixture = load_fixture("firewall_rules_running.json") - def _set_afi(self, afi): - data = self.fixture.get(afi, {}) - self.mock_vyos.get_config = MagicMock(return_value=data) + def _get_config(path): + # path == _BASE + [afi, "name"]; fixture is wrapped one level + # deeper ({"ipv4": {"name": {...}}}), matching a real device + # response that still needs the defensive unwrap. + afi = path[1] + return self.fixture.get(afi) + self.mock_vyos.get_config = MagicMock(side_effect=_get_config) -class TestVyOSFirewallRulesGetRunning(VyOSModuleTestCase): - def test_parses_ipv4_rule_sets(self): - self._set_afi("ipv4") - result = get_running_config(self.mock_vyos) - ipv4 = next((e for e in result if e["afi"] == "ipv4"), None) - self.assertIsNotNone(ipv4) - rs = next(rs for rs in ipv4["rule_sets"] if rs["name"] == "RULE-SET1") - self.assertEqual(rs["default_action"], "drop") - self.assertEqual(len(rs["rules"]), 2) - r10 = next(r for r in rs["rules"] if r["number"] == 10) - self.assertEqual(r10["action"], "accept") - self.assertEqual(r10["protocol"], "tcp") - self.assertEqual(r10["source"]["address"], "192.168.1.0/24") - self.assertEqual(r10["destination"]["port"], "80") - - def test_parses_rule_state(self): - self._set_afi("ipv4") +class TestGetRunningConfig(VyOSModuleTestCase): + def test_targeted_per_afi_fetch(self): + """Confirm the targeted firewall..name fetch is preserved + (not widened to a broader firewall. or firewall fetch).""" + get_running_config(self.mock_vyos) + calls = [c.args[0] for c in self.mock_vyos.get_config.call_args_list] + self.assertEqual(calls, [_BASE + ["ipv4", "name"], _BASE + ["ipv6", "name"]]) + + def test_unwraps_name_wrapper(self): result = get_running_config(self.mock_vyos) - ipv4 = next(e for e in result if e["afi"] == "ipv4") - rs = ipv4["rule_sets"][0] - r20 = next(r for r in rs["rules"] if r["number"] == 20) - self.assertEqual(r20["state"], "invalid") + self.assertIn("RULE-SET1", result["ipv4"]) def test_empty_config(self): - self.mock_vyos.get_config = MagicMock(return_value={}) - result = get_running_config(self.mock_vyos) - self.assertEqual(result, []) + self.mock_vyos.get_config = MagicMock(return_value=None) + self.assertEqual(get_running_config(self.mock_vyos), {}) + + +class TestEndpointToDeviceFromDevice(unittest.TestCase): + """The one genuine device-shape exception in this module: group.""" + + def test_group_wraps_under_address_group(self): + result = _endpoint_to_device({"address": "10.0.0.0/8", "group": "GROUP1"}) + self.assertEqual(result, {"address": "10.0.0.0/8", "group": {"address-group": "GROUP1"}}) + + def test_no_group_no_exception_applied(self): + result = _endpoint_to_device({"address": "10.0.0.0/8", "port": "80"}) + self.assertEqual(result, {"address": "10.0.0.0/8", "port": "80"}) + def test_from_device_extracts_group_regardless_of_kind(self): + """Read side stays generic: it can surface any group kind already + configured (address-group, network-group, ...), even though + write side (above) can only ever create address-group.""" + result = _endpoint_from_device({"group": {"network-group": "NETGRP1"}}) + self.assertEqual(result, {"group": "NETGRP1"}) -class TestVyOSFirewallRulesBuildCommands(unittest.TestCase): + def test_from_device_bare_string_group(self): + result = _endpoint_from_device({"group": "GROUP1"}) + self.assertEqual(result, {"group": "GROUP1"}) - def _have(self): - return [ + +class TestRulesToDeviceFromDevice(unittest.TestCase): + def test_bare_rule_is_presence(self): + self.assertEqual(_rules_to_device([{"number": 10}]), {"10": {}}) + + def test_full_rule_with_source_destination(self): + result = _rules_to_device( + [ + { + "number": 10, + "action": "accept", + "protocol": "tcp", + "source": {"address": "192.168.1.0/24"}, + "destination": {"port": "80"}, + }, + ], + ) + self.assertEqual( + result, { - "afi": "ipv4", - "rule_sets": [ - { - "name": "RULE-SET1", - "default_action": "drop", - "rules": [ - {"number": 10, "action": "accept", "protocol": "tcp"}, - {"number": 20, "action": "drop", "state": "invalid"}, - ], - }, - ], + "10": { + "action": "accept", + "protocol": "tcp", + "source": {"address": "192.168.1.0/24"}, + "destination": {"port": "80"}, + }, }, - ] + ) - def test_deleted_all(self): - cmds = build_commands([], self._have(), "deleted") - self.assertIn(("delete", _BASE), cmds) + def test_icmp_generic_no_exception_needed(self): + result = _rules_to_device([{"number": 10, "icmp": {"type": 8, "code": 0}}]) + self.assertEqual(result, {"10": {"icmp": {"type": 8, "code": 0}}}) - def test_deleted_specific(self): - config = [{"afi": "ipv4", "rule_sets": [{"name": "RULE-SET1"}]}] - cmds = build_commands(config, self._have(), "deleted") - self.assertIn(("delete", _BASE + ["ipv4", "name", "RULE-SET1"]), cmds) + def test_from_device_number_cast_and_sorted_numerically(self): + result = _rules_from_device({"20": {}, "9": {}}) + self.assertEqual([r["number"] for r in result], [9, 20]) - def test_merged_rule_set(self): - config = [ + def test_from_device_icmp_cast_to_int(self): + result = _rules_from_device({"10": {"icmp": {"type": "8", "code": "0"}}}) + self.assertEqual(result[0]["icmp"], {"type": "8", "code": "0"}) + # Note: icmp int-casting happens via cast_by_spec in + # _device_to_argspec, not in the raw _rules_from_device step -- + # verified separately in TestDeviceToArgspecFixture. + + +class TestRuleSetToDeviceFromDevice(unittest.TestCase): + def test_bare_rule_set_is_presence(self): + self.assertEqual(_rule_set_to_device({"name": "RS1"}), {}) + + def test_with_rules(self): + result = _rule_set_to_device( { - "afi": "ipv4", - "rule_sets": [ - { - "name": "NEW-SET", - "default_action": "accept", - "rules": [{"number": 10, "action": "accept"}], - }, - ], + "name": "RS1", + "default_action": "drop", + "rules": [{"number": 10, "action": "accept"}], }, - ] - cmds = build_commands(config, [], "merged") - self.assertIn( - ("set", _BASE + ["ipv4", "name", "NEW-SET", "default-action", "accept"]), - cmds, ) - self.assertIn( - ("set", _BASE + ["ipv4", "name", "NEW-SET", "rule", "10", "action", "accept"]), - cmds, + self.assertEqual( + result, + {"default_action": "drop", "rule": {"10": {"action": "accept"}}}, + ) + + def test_from_device(self): + entry = _rule_set_from_device( + "RS1", + {"default-action": "drop", "rule": {"10": {"action": "accept"}}}, ) + self.assertEqual(entry["name"], "RS1") + self.assertEqual(entry["default_action"], "drop") + self.assertEqual(entry["rules"], [{"number": 10, "action": "accept"}]) + + +class TestWantToDevice(unittest.TestCase): + def test_empty(self): + self.assertEqual(_want_to_device([]), {}) + self.assertEqual(_want_to_device(None), {}) - def test_merged_rule_with_protocol_and_source(self): + def test_afi_with_no_rule_sets_omitted(self): + self.assertEqual(_want_to_device([{"afi": "ipv4", "rule_sets": []}]), {}) + + def test_full_config(self): config = [ { "afi": "ipv4", - "rule_sets": [ - { - "name": "RULE-SET1", - "rules": [ - { - "number": 10, - "action": "accept", - "protocol": "tcp", - "source": {"address": "10.0.0.0/8"}, - }, - ], - }, - ], + "rule_sets": [{"name": "RS1", "default_action": "drop"}], }, ] - cmds = build_commands(config, [], "merged") - self.assertIn( - ("set", _BASE + ["ipv4", "name", "RULE-SET1", "rule", "10", "protocol", "tcp"]), - cmds, - ) - self.assertIn( - ( - "set", - _BASE - + [ - "ipv4", - "name", - "RULE-SET1", - "rule", - "10", - "source", - "address", - "10.0.0.0/8", - ], - ), - cmds, + self.assertEqual( + _want_to_device(config), + {"ipv4": {"RS1": {"default_action": "drop"}}}, ) - def test_merged_idempotent(self): - have = self._have() - config = [ + +class TestDeviceToArgspecFixture(VyOSModuleTestCase): + def test_ipv4_rule_set_with_rules(self): + raw = get_running_config(self.mock_vyos) + have = _device_to_argspec(raw) + ipv4 = next(e for e in have if e["afi"] == "ipv4") + rs1 = next(r for r in ipv4["rule_sets"] if r["name"] == "RULE-SET1") + self.assertEqual(rs1["default_action"], "drop") + rule10 = next(r for r in rs1["rules"] if r["number"] == 10) + self.assertEqual(rule10["source"], {"address": "192.168.1.0/24"}) + self.assertEqual(rule10["destination"], {"port": "80"}) + + def test_ipv6_present(self): + raw = get_running_config(self.mock_vyos) + have = _device_to_argspec(raw) + afis = {e["afi"] for e in have} + self.assertIn("ipv6", afis) + + def test_empty_config(self): + self.assertEqual(_device_to_argspec({}), []) + self.assertEqual(_device_to_argspec(None), []) + + +class TestBuildCommands(VyOSModuleTestCase): + def _have_and_raw(self): + raw = get_running_config(self.mock_vyos) + have = _device_to_argspec(raw) + return have, raw + + def test_merged_idempotent_against_own_fixture(self): + have, raw = self._have_and_raw() + self.assertEqual(build_commands(have, raw, "merged"), []) + + def test_replaced_idempotent_against_own_fixture(self): + have, raw = self._have_and_raw() + self.assertEqual(build_commands(have, raw, "replaced"), []) + + def test_overridden_idempotent_against_own_fixture(self): + have, raw = self._have_and_raw() + self.assertEqual(build_commands(have, raw, "overridden"), []) + + def test_replaced_scoped_to_named_rule_sets_only(self): + raw = { + "ipv4": { + "RS1": {"default-action": "drop", "rule": {"10": {"action": "accept"}}}, + "RS2": {"default-action": "accept"}, + }, + } + cfg = [ { "afi": "ipv4", "rule_sets": [ { - "name": "RULE-SET1", + "name": "RS1", "default_action": "drop", - "rules": [ - {"number": 10, "action": "accept", "protocol": "tcp"}, - {"number": 20, "action": "drop", "state": "invalid"}, - ], + "rules": [{"number": 10, "action": "accept"}], }, ], }, ] - cmds = build_commands(config, have, "merged") - self.assertEqual(cmds, []) + self.assertEqual(build_commands(cfg, raw, "replaced"), []) - def test_overridden_removes_extra_rule_set(self): - have = self._have() - config = [ + def test_overridden_deletes_omitted_rule_set(self): + raw = {"ipv4": {"RS1": {"default-action": "drop"}, "RS2": {"default-action": "accept"}}} + cfg = [{"afi": "ipv4", "rule_sets": [{"name": "RS1", "default_action": "drop"}]}] + cmds = build_commands(cfg, raw, "overridden") + self.assertIn(("delete", _BASE + ["ipv4", "name", "RS2"]), cmds) + + def test_overridden_never_touches_sibling_hook_filters(self): + """Regression test: firewall.ipv4.{input,output,forward} (owned + by vyos_firewall_interfaces) and firewall.group (owned by + vyos_firewall_global) must never be touched by this module.""" + raw = {"ipv4": {"RS1": {"default-action": "drop"}}} + cmds = build_commands([], raw, "overridden") + self.assertTrue(all("input" not in c[1] and "group" not in c[1] for c in cmds)) + + def test_deleted_no_config_deletes_all_present(self): + raw = {"ipv4": {"RS1": {}}, "ipv6": {"RS6": {}}} + cmds = build_commands([], raw, "deleted") + self.assertIn(("delete", _BASE + ["ipv4", "name", "RS1"]), cmds) + self.assertIn(("delete", _BASE + ["ipv6", "name", "RS6"]), cmds) + + def test_deleted_scoped_to_named_config(self): + raw = {"ipv4": {"RS1": {}, "RS2": {}}} + cmds = build_commands([{"afi": "ipv4", "rule_sets": [{"name": "RS1"}]}], raw, "deleted") + self.assertEqual(cmds, [("delete", _BASE + ["ipv4", "name", "RS1"])]) + + def test_collapsed_rule_no_char_iteration_bug(self): + raw = {"ipv4": {"RS1": {"rule": "10"}}} + cfg = [{"afi": "ipv4", "rule_sets": [{"name": "RS1", "rules": [{"number": 10}]}]}] + self.assertEqual(build_commands(cfg, raw, "merged"), []) + + def test_merged_new_rule_with_group(self): + cfg = [ { "afi": "ipv4", "rule_sets": [ { - "name": "NEW-SET", - "default_action": "accept", - "rules": [{"number": 10, "action": "accept"}], + "name": "RS1", + "rules": [{"number": 30, "action": "accept", "source": {"group": "G1"}}], }, ], }, ] - cmds = build_commands(config, have, "overridden") + cmds = build_commands(cfg, {}, "merged") self.assertIn( - ("delete", _BASE + ["ipv4", "name", "RULE-SET1"]), + ( + "set", + _BASE + + ["ipv4", "name", "RS1", "rule", "30", "source", "group", "address-group", "G1"], + ), cmds, ) diff --git a/tests/unit/modules/test_vyos_ha.py b/tests/unit/modules/test_vyos_ha.py new file mode 100644 index 0000000..10a9144 --- /dev/null +++ b/tests/unit/modules/test_vyos_ha.py @@ -0,0 +1,345 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, division, print_function + + +__metaclass__ = type + +import unittest + +from unittest.mock import MagicMock + +from ansible_collections.vyos.rest.plugins.modules.vyos_ha import ( + _device_to_argspec, + _group_from_device, + _group_to_device, + _real_server_from_device, + _real_server_to_device, + _virtual_server_from_device, + _virtual_server_to_device, + _want_to_device, + build_commands, + get_running_config, +) + +from .base import load_fixture + + +_BASE = ["high-availability"] + + +class VyOSModuleTestCase(unittest.TestCase): + def setUp(self): + self.mock_vyos = MagicMock() + self.fixture = load_fixture("ha_running.json") + self.mock_vyos.get_config = MagicMock(return_value=self.fixture) + + +class TestGetRunningConfig(VyOSModuleTestCase): + def test_returns_raw_device_dict(self): + self.assertEqual(get_running_config(self.mock_vyos), self.fixture) + + def test_empty_config(self): + self.mock_vyos.get_config = MagicMock(return_value=None) + self.assertEqual(get_running_config(self.mock_vyos), {}) + + +class TestRealServer(unittest.TestCase): + def test_to_device_generic_fields(self): + result = _real_server_to_device({"address": "10.0.0.2", "port": 8080}) + self.assertEqual(result, {"port": 8080}) + + def test_to_device_health_check_script_nested(self): + """health_check_script is a genuine structural exception -- the + argspec has it flat, the device nests it under health-check.script.""" + result = _real_server_to_device( + {"address": "10.0.0.2", "health_check_script": "/check.sh"}, + ) + self.assertEqual(result, {"health-check": {"script": "/check.sh"}}) + + def test_from_device_basic(self): + entry = _real_server_from_device("10.0.0.2", {"port": "8080"}) + self.assertEqual(entry["address"], "10.0.0.2") + self.assertEqual(entry["port"], 8080) + + def test_from_device_health_check_script_extracted(self): + entry = _real_server_from_device( + "10.0.0.2", + {"health-check": {"script": "/check.sh"}}, + ) + self.assertEqual(entry["health_check_script"], "/check.sh") + + +class TestVirtualServer(unittest.TestCase): + def test_to_device_keyed_fields(self): + result = _virtual_server_to_device({"name": "s1", "address": "10.0.0.1", "port": 80}) + self.assertEqual(result, {"address": "10.0.0.1", "port": 80}) + + def test_to_device_real_server_keyed_by_address(self): + vs = { + "name": "s1", + "port": 80, + "real_server": [{"address": "10.0.0.2", "port": 8080}], + } + result = _virtual_server_to_device(vs) + self.assertEqual(result["real-server"]["10.0.0.2"], {"port": 8080}) + + def test_from_device_list_with_real_servers(self): + entry = _virtual_server_from_device( + "s1", + {"port": "80", "real-server": {"10.0.0.2": {"port": "8080"}}}, + ) + self.assertEqual(entry["name"], "s1") + self.assertEqual(entry["port"], 80) + self.assertEqual(entry["real_server"][0]["address"], "10.0.0.2") + self.assertEqual(entry["real_server"][0]["port"], 8080) + + +class TestGroup(unittest.TestCase): + """address/excluded_address are genuine tagNodes (confirmed); track + is NOT special-cased for interface since that's a plain list.""" + + def test_to_device_basic_fields_generic(self): + result = _group_to_device({"name": "g1", "vrid": 20, "interface": "eth0"}) + self.assertEqual(result, {"vrid": 20, "interface": "eth0"}) + + def test_to_device_address_tag_node(self): + result = _group_to_device( + {"name": "g1", "address": ["192.168.1.1/24", "192.168.1.2/24"]}, + ) + self.assertEqual( + result["address"], + {"192.168.1.1/24": {}, "192.168.1.2/24": {}}, + ) + + def test_to_device_excluded_address_tag_node(self): + result = _group_to_device({"name": "g1", "excluded_address": ["10.0.0.1"]}) + self.assertEqual(result["excluded-address"], {"10.0.0.1": {}}) + + def test_to_device_track_interface_stays_plain_list(self): + """Regression test: track.interface is a confirmed + leafNode, not a tag node -- must NOT be reshaped into a + dict-of-presence like address/excluded_address are.""" + result = _group_to_device({"name": "g1", "track": {"interface": ["eth1", "eth2"]}}) + self.assertEqual(result["track"]["interface"], ["eth1", "eth2"]) + + def test_to_device_bool_fields(self): + result = _group_to_device( + {"name": "g1", "disable": True, "no_preempt": True, "rfc3768_compatibility": False}, + ) + self.assertEqual(result["disable"], {}) + self.assertEqual(result["no_preempt"], {}) + self.assertNotIn("rfc3768_compatibility", result) + + def test_from_device_vrid_and_priority_cast_to_int(self): + entry = _group_from_device("g1", {"vrid": "20", "priority": "100"}) + self.assertEqual(entry["vrid"], 20) + self.assertEqual(entry["priority"], 100) + + def test_from_device_address_tag_node_to_sorted_list(self): + entry = _group_from_device( + "g1", + {"address": {"192.168.1.2/24": {}, "192.168.1.1/24": {}}}, + ) + self.assertEqual(entry["address"], ["192.168.1.1/24", "192.168.1.2/24"]) + + def test_from_device_single_address_string_collapse(self): + entry = _group_from_device("g1", {"address": "192.168.1.1/24"}) + self.assertEqual(entry["address"], ["192.168.1.1/24"]) + + def test_from_device_track_interface_stays_plain_list(self): + entry = _group_from_device("g1", {"track": {"interface": ["eth1", "eth2"]}}) + self.assertEqual(entry["track"]["interface"], ["eth1", "eth2"]) + + def test_from_device_bool_presence_nodes(self): + entry = _group_from_device("g1", {"disable": {}, "no-preempt": {}}) + self.assertTrue(entry["disable"]) + self.assertTrue(entry["no_preempt"]) + + +class TestWantToDevice(unittest.TestCase): + def test_empty(self): + self.assertEqual(_want_to_device({}), {}) + self.assertEqual(_want_to_device(None), {}) + + def test_disable(self): + result = _want_to_device({"disable": True}) + self.assertEqual(result["disable"], {}) + + def test_virtual_server_keyed_by_name(self): + config = {"virtual_servers": [{"name": "s1", "address": "10.0.0.1", "port": 80}]} + result = _want_to_device(config) + self.assertIn("s1", result["virtual-server"]) + + def test_vrrp_global_parameters_generic(self): + config = { + "vrrp": {"global_parameters": {"startup_delay": 30, "garp": {"master_repeat": 6}}}, + } + result = _want_to_device(config) + gp = result["vrrp"]["global_parameters"] + self.assertEqual(gp["startup_delay"], 30) + self.assertEqual(gp["garp"]["master_repeat"], 6) + + def test_snmp_enabled_becomes_presence_node(self): + result = _want_to_device({"vrrp": {"snmp": "enabled"}}) + self.assertEqual(result["vrrp"]["snmp"], {}) + + def test_snmp_disabled_not_in_want(self): + result = _want_to_device({"vrrp": {"snmp": "disabled"}}) + self.assertNotIn("snmp", result.get("vrrp", {})) + + def test_group_keyed_by_name(self): + config = {"vrrp": {"groups": [{"name": "g1", "vrid": 20, "interface": "eth0"}]}} + result = _want_to_device(config) + self.assertEqual(result["vrrp"]["group"]["g1"]["vrid"], 20) + + def test_sync_group_member_stays_plain_list(self): + """Regression test: member is a confirmed leafNode, not + a tag node -- must stay a plain list.""" + config = {"vrrp": {"sync_groups": [{"name": "sg1", "member": ["g1", "g2"]}]}} + result = _want_to_device(config) + self.assertEqual(result["vrrp"]["sync-group"]["sg1"]["member"], ["g1", "g2"]) + + +class TestDeviceToArgspecFixture(VyOSModuleTestCase): + def test_disable_parsed(self): + result = _device_to_argspec(self.fixture) + self.assertTrue(result["disable"]) + + def test_virtual_server_parsed(self): + result = _device_to_argspec(self.fixture) + vs = result["virtual_servers"][0] + self.assertEqual(vs["name"], "s1") + self.assertEqual(vs["port"], 80) + self.assertEqual(vs["real_server"][0]["address"], "10.10.50.2") + self.assertEqual(vs["real_server"][0]["port"], 8443) + + def test_global_parameters_parsed(self): + result = _device_to_argspec(self.fixture) + gp = result["vrrp"]["global_parameters"] + self.assertEqual(gp["startup_delay"], 30) + self.assertEqual(gp["garp"]["master_repeat"], 6) + + def test_snmp_parsed(self): + result = _device_to_argspec(self.fixture) + self.assertEqual(result["vrrp"]["snmp"], "enabled") + + def test_groups_parsed_with_track_interface_as_list(self): + result = _device_to_argspec(self.fixture) + groups = {g["name"]: g for g in result["vrrp"]["groups"]} + self.assertEqual(groups["g1"]["interface"], "eth0") + self.assertEqual(groups["g1"]["vrid"], 20) + self.assertIn("192.168.1.100/24", groups["g1"]["address"]) + self.assertTrue(groups["g1"]["no_preempt"]) + self.assertEqual(groups["g1"]["track"]["interface"], ["eth1", "eth2"]) + # g2: single address string collapsed by device -> list + self.assertEqual(groups["g2"]["address"], ["192.168.2.100/24"]) + + def test_sync_group_parsed_member_as_list(self): + result = _device_to_argspec(self.fixture) + sg = result["vrrp"]["sync_groups"][0] + self.assertEqual(sg["name"], "sg1") + self.assertEqual(sg["member"], ["g1"]) + self.assertEqual(sg["health_check"]["failure_count"], 5) + self.assertEqual(sg["health_check"]["ping"], "192.168.1.1") + + def test_empty_config(self): + self.assertEqual(_device_to_argspec({}), {}) + self.assertEqual(_device_to_argspec(None), {}) + + +class TestBuildCommands(VyOSModuleTestCase): + def test_merged_idempotent_against_own_fixture(self): + have = _device_to_argspec(self.fixture) + self.assertEqual(build_commands(have, self.fixture, "merged"), []) + + def test_replaced_idempotent_against_own_fixture(self): + have = _device_to_argspec(self.fixture) + self.assertEqual(build_commands(have, self.fixture, "replaced"), []) + + def test_overridden_idempotent_against_own_fixture(self): + """overridden is a single dict_op purge+set call (simplified from + the original manual section-scan loop -- confirmed identical + behavior before removing the loop).""" + have = _device_to_argspec(self.fixture) + self.assertEqual(build_commands(have, self.fixture, "overridden"), []) + + def test_merged_adds_vrrp_group(self): + config = { + "vrrp": { + "groups": [{"name": "g3", "vrid": 30, "interface": "eth2", "priority": 100}], + }, + } + cmds = build_commands(config, {}, "merged") + self.assertIn(("set", _BASE + ["vrrp", "group", "g3", "vrid", "30"]), cmds) + self.assertIn(("set", _BASE + ["vrrp", "group", "g3", "interface", "eth2"]), cmds) + + def test_overridden_deletes_omitted_top_level_section(self): + raw_have = {"virtual-server": {"s1": {"port": "80"}}, "vrrp": {"group": {"g1": {}}}} + config = {"vrrp": {"groups": [{"name": "g1"}]}} + cmds = build_commands(config, raw_have, "overridden") + self.assertIn(("delete", _BASE + ["virtual-server"]), cmds) + + def test_replaced_removes_stale_track_interface_member(self): + """Regression test for the dict_op purge list-value fix (this + session): track.interface being a plain list means removing a + member under 'replaced' relies on dict_op's list-purge handling.""" + raw_have = {"vrrp": {"group": {"g1": {"track": {"interface": ["eth1", "eth2"]}}}}} + config = {"vrrp": {"groups": [{"name": "g1", "track": {"interface": ["eth1"]}}]}} + cmds = build_commands(config, raw_have, "replaced") + self.assertIn( + ("delete", _BASE + ["vrrp", "group", "g1", "track", "interface", "eth2"]), + cmds, + ) + + def test_replaced_removes_stale_sync_group_member(self): + raw_have = {"vrrp": {"sync-group": {"sg1": {"member": ["g1", "g2"]}}}} + config = {"vrrp": {"sync_groups": [{"name": "sg1", "member": ["g1"]}]}} + cmds = build_commands(config, raw_have, "replaced") + self.assertIn( + ("delete", _BASE + ["vrrp", "sync-group", "sg1", "member", "g2"]), + cmds, + ) + + def test_snmp_disabled_deletes_presence_node(self): + raw_have = {"vrrp": {"snmp": {}}} + config = {"vrrp": {"snmp": "disabled"}} + cmds = build_commands(config, raw_have, "merged") + self.assertIn(("delete", _BASE + ["vrrp", "snmp"]), cmds) + + def test_deleted_no_have_is_noop(self): + self.assertEqual(build_commands({}, {}, "deleted"), []) + + def test_deleted_with_have(self): + self.assertEqual( + build_commands({}, {"vrrp": {"group": {"g1": {}}}}, "deleted"), + [("delete", _BASE)], + ) + + def test_collapsed_track_interface_no_char_iteration_bug(self): + """A group with exactly one tracked interface, collapsed by the + device to a bare string, must not be iterated character-by- + character (dict_op's own list handling corrects this natively).""" + raw_have = {"vrrp": {"group": {"g1": {"track": {"interface": "eth1"}}}}} + config = {"vrrp": {"groups": [{"name": "g1", "track": {"interface": ["eth1"]}}]}} + self.assertEqual(build_commands(config, raw_have, "merged"), []) + + def test_collapsed_sync_group_member_no_char_iteration_bug(self): + raw_have = {"vrrp": {"sync-group": {"sg1": {"member": "g1"}}}} + config = {"vrrp": {"sync_groups": [{"name": "sg1", "member": ["g1"]}]}} + self.assertEqual(build_commands(config, raw_have, "merged"), []) + + def test_virtual_server_address_never_treated_as_tag_node(self): + """Regression test: virtual-server..address is a flat + scalar (the load-balancer bind address), unlike vrrp.group. + .address which IS a genuine tag node (VRRP virtual IPs). + Same key name, different device shape depending on section -- + a blanket key-name-based coercion previously corrupted this + into a spurious diff every single run.""" + raw_have = {"virtual-server": {"s1": {"address": "10.10.10.5", "port": "80"}}} + config = {"virtual_servers": [{"name": "s1", "address": "10.10.10.5", "port": 80}]} + self.assertEqual(build_commands(config, raw_have, "merged"), []) + self.assertEqual(build_commands(config, raw_have, "replaced"), []) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/modules/test_vyos_logging_global.py b/tests/unit/modules/test_vyos_logging_global.py index 7d91182..ccada8f 100644 --- a/tests/unit/modules/test_vyos_logging_global.py +++ b/tests/unit/modules/test_vyos_logging_global.py @@ -4,235 +4,271 @@ from __future__ import absolute_import, division, print_function __metaclass__ = type -import os -import sys import unittest - -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "..")) - +from ansible_collections.vyos.rest.plugins.module_utils.vyos import dict_op from ansible_collections.vyos.rest.plugins.modules.vyos_logging_global import ( - build_commands, - normalize_config, - normalize_running, + _device_to_argspec, + _fac_device_to_list, + _fac_list_to_device, + _want_to_device, ) -class TestVyOSLoggingGlobalNormalize(unittest.TestCase): +_BASE = ["system", "syslog"] + + +class TestFacHelpers(unittest.TestCase): + """Test facility list <-> device dict conversion helpers.""" + + def test_fac_list_to_device_with_severity(self): + facs = [{"facility": "local7", "severity": "err"}] + result = _fac_list_to_device(facs) + self.assertEqual(result, {"local7": {"level": "err"}}) + + def test_fac_list_to_device_no_severity(self): + facs = [{"facility": "all"}] + result = _fac_list_to_device(facs) + self.assertEqual(result, {"all": {}}) + + def test_fac_list_to_device_with_protocol(self): + facs = [{"facility": "all", "protocol": "udp"}] + result = _fac_list_to_device(facs) + self.assertEqual(result["all"]["protocol"], "udp") + self.assertNotIn("level", result["all"]) + + def test_fac_list_to_device_empty(self): + self.assertEqual(_fac_list_to_device([]), {}) + self.assertEqual(_fac_list_to_device(None), {}) + + def test_fac_device_to_list_with_level(self): + raw = {"local7": {"level": "err"}, "all": {}} + result = _fac_device_to_list(raw) + names = [f["facility"] for f in result] + self.assertIn("local7", names) + self.assertIn("all", names) + local7 = next(f for f in result if f["facility"] == "local7") + self.assertEqual(local7["severity"], "err") + + def test_fac_device_to_list_empty(self): + self.assertEqual(_fac_device_to_list({}), []) + self.assertEqual(_fac_device_to_list(None), []) + + def test_fac_device_to_list_sorted(self): + raw = {"z-fac": {}, "a-fac": {}} + result = _fac_device_to_list(raw) + self.assertEqual(result[0]["facility"], "a-fac") + self.assertEqual(result[1]["facility"], "z-fac") - def test_normalize_config_console_severity_is_string(self): - cfg = { - "console": { - "facilities": [{"facility": "local7", "severity": "err"}], - }, - } - result = normalize_config(cfg) - self.assertIn("local7", result["console"]["facilities"]) - # severity is stored as plain string, not dict - self.assertEqual(result["console"]["facilities"]["local7"], "err") - - def test_normalize_config_console_no_severity(self): - cfg = { - "console": { - "facilities": [{"facility": "all"}], - }, - } - result = normalize_config(cfg) - self.assertIsNone(result["console"]["facilities"]["all"]) - def test_normalize_config_hosts(self): - cfg = { +class TestWantToDevice(unittest.TestCase): + """Test argspec -> device shape conversion.""" + + def test_empty(self): + self.assertEqual(_want_to_device({}), {}) + self.assertEqual(_want_to_device(None), {}) + + def test_console_facilities(self): + config = {"console": {"facilities": [{"facility": "local7", "severity": "err"}]}} + result = _want_to_device(config) + self.assertIn("console", result) + self.assertEqual(result["console"]["facility"]["local7"], {"level": "err"}) + + def test_global_params_facilities(self): + config = {"global_params": {"facilities": [{"facility": "cron", "severity": "debug"}]}} + result = _want_to_device(config) + self.assertIn("local", result) + self.assertEqual(result["local"]["facility"]["cron"], {"level": "debug"}) + + def test_global_params_marker_interval(self): + config = {"global_params": {"marker_interval": 111}} + result = _want_to_device(config) + self.assertEqual(result["marker"], {"interval": 111}) + + def test_global_params_preserve_fqdn(self): + config = {"global_params": {"preserve_fqdn": True}} + result = _want_to_device(config) + self.assertEqual(result["preserve-fqdn"], {}) + + def test_hosts_mapped_to_remote(self): + config = { "hosts": [ { "hostname": "172.16.0.1", "port": 514, - "facilities": [ - {"facility": "local7", "severity": "all"}, - {"facility": "all", "protocol": "udp"}, - ], + "facilities": [{"facility": "local7", "severity": "all"}], }, ], } - result = normalize_config(cfg) - self.assertIn("172.16.0.1", result["hosts"]) - host = result["hosts"]["172.16.0.1"] - self.assertEqual(host["port"], 514) - self.assertIn("local7", host["facilities"]) - # host facilities are dicts with severity/protocol - self.assertEqual(host["facilities"]["local7"]["severity"], "all") - self.assertEqual(host["facilities"]["all"]["protocol"], "udp") - - def test_normalize_config_global_preserve_fqdn(self): - cfg = {"global_params": {"preserve_fqdn": True}} - result = normalize_config(cfg) - self.assertTrue(result["global"]["preserve_fqdn"]) - - def test_normalize_config_global_archive(self): - cfg = {"global_params": {"archive": {"file_num": 2, "size": 111}}} - result = normalize_config(cfg) - self.assertEqual(result["global"]["archive"]["file_num"], 2) - self.assertEqual(result["global"]["archive"]["size"], 111) - - def test_normalize_config_empty(self): - result = normalize_config({}) - self.assertEqual(result["console"]["facilities"], {}) - self.assertEqual(result["hosts"], {}) - self.assertEqual(result["files"], {}) - self.assertEqual(result["users"], {}) - - def test_normalize_running_console_severity_is_string(self): - raw = { - "console": { - "facility": { - "local7": {"level": "err"}, - "all": {}, + result = _want_to_device(config) + self.assertIn("remote", result) + self.assertIn("172.16.0.1", result["remote"]) + self.assertEqual(result["remote"]["172.16.0.1"]["port"], 514) + self.assertIn("local7", result["remote"]["172.16.0.1"]["facility"]) + + def test_users_mapped_to_user(self): + config = { + "users": [ + { + "username": "vyos", + "facilities": [{"facility": "local7", "severity": "debug"}], }, - }, + ], } - result = normalize_running(raw) - self.assertIn("local7", result["console"]["facilities"]) - # severity is plain string from "level" key - self.assertEqual(result["console"]["facilities"]["local7"], "err") - self.assertIsNone(result["console"]["facilities"]["all"]) - - def test_normalize_running_host_port_not_cast(self): - """Port is NOT cast to int — stored as-is from API response.""" + result = _want_to_device(config) + self.assertIn("user", result) + self.assertIn("vyos", result["user"]) + + +class TestDeviceToArgspec(unittest.TestCase): + """Test device response -> argspec shape conversion.""" + + def test_empty(self): + self.assertEqual(_device_to_argspec({}), {}) + self.assertEqual(_device_to_argspec(None), {}) + + def test_console(self): + raw = {"console": {"facility": {"local7": {"level": "err"}}}} + result = _device_to_argspec(raw) + self.assertIn("console", result) + facs = result["console"]["facilities"] + self.assertEqual(facs[0]["facility"], "local7") + self.assertEqual(facs[0]["severity"], "err") + + def test_local_to_global_params(self): + raw = {"local": {"facility": {"cron": {"level": "debug"}}}} + result = _device_to_argspec(raw) + self.assertIn("global_params", result) + facs = result["global_params"]["facilities"] + self.assertEqual(facs[0]["facility"], "cron") + + def test_marker_interval(self): + raw = {"marker": {"interval": "111"}} + result = _device_to_argspec(raw) + self.assertEqual(result["global_params"]["marker_interval"], "111") + + def test_preserve_fqdn(self): + raw = {"preserve-fqdn": {}} + result = _device_to_argspec(raw) + self.assertTrue(result["global_params"]["preserve_fqdn"]) + + def test_remote_to_hosts(self): raw = { "remote": { "172.16.0.1": { - "port": "514", - "facility": {}, + "port": 514, + "facility": {"local7": {"level": "all"}}, }, }, } - result = normalize_running(raw) - # port stays as string — module does not cast - self.assertEqual(result["hosts"]["172.16.0.1"]["port"], "514") + result = _device_to_argspec(raw) + self.assertIn("hosts", result) + host = result["hosts"][0] + self.assertEqual(host["hostname"], "172.16.0.1") + self.assertEqual(host["port"], 514) + self.assertEqual(host["facilities"][0]["facility"], "local7") - def test_normalize_running_global_archive_key(self): - """Archive stored under 'archive' key — no file_num remapping.""" - raw = { - "local": { - "archive": {"file": "2", "size": "111"}, - "marker": {"interval": "111"}, - "preserve-fqdn": {}, - }, - } - result = normalize_running(raw) - # archive stored as-is from API - self.assertEqual(result["global"]["archive"]["file"], "2") - self.assertEqual(result["global"]["archive"]["size"], "111") - # marker_interval stored as string — no cast - self.assertEqual(result["global"]["marker_interval"], "111") - self.assertTrue(result["global"]["preserve_fqdn"]) - - def test_normalize_running_empty(self): - result = normalize_running({}) - self.assertEqual(result["console"]["facilities"], {}) - self.assertEqual(result["hosts"], {}) - - def test_normalize_running_host_facilities(self): - raw = { - "remote": { - "172.16.0.1": { - "facility": { - "local7": {"level": "all"}, - "all": {"protocol": "udp"}, - }, - "port": "223", - }, - }, - } - result = normalize_running(raw) - h = result["hosts"]["172.16.0.1"] - self.assertEqual(h["facilities"]["local7"]["severity"], "all") - self.assertEqual(h["facilities"]["all"]["protocol"], "udp") + def test_user_to_users(self): + raw = {"user": {"vyos": {"facility": {"local7": {"level": "debug"}}}}} + result = _device_to_argspec(raw) + self.assertIn("users", result) + self.assertEqual(result["users"][0]["username"], "vyos") + def test_hosts_sorted(self): + raw = {"remote": {"z.host": {}, "a.host": {}}} + result = _device_to_argspec(raw) + self.assertEqual(result["hosts"][0]["hostname"], "a.host") -class TestVyOSLoggingGlobalBuildCommands(unittest.TestCase): - def _empty_have(self): - return { - "console": {"facilities": {}}, - "global": {"facilities": {}}, - "hosts": {}, - "files": {}, - "users": {}, - } +class TestDictOpLogging(unittest.TestCase): + """Test dict_op behaviour with logging device shapes.""" - def test_merged_adds_console_facility_with_severity(self): - want = self._empty_have() - want["console"]["facilities"]["local7"] = "err" - cmds = build_commands(want, self._empty_have(), "merged") - self.assertIn( - ("set", ["system", "syslog", "console", "facility", "local7", "level", "err"]), - cmds, + def test_merged_adds_console_facility(self): + want = _want_to_device( + { + "console": {"facilities": [{"facility": "local7", "severity": "err"}]}, + }, ) + cmds = dict_op(want, {}, _BASE, op="set") + paths = [c[1] for c in cmds] + self.assertIn(_BASE + ["console", "facility", "local7", "level", "err"], paths) - def test_merged_adds_console_facility_no_severity(self): - want = self._empty_have() - want["console"]["facilities"]["all"] = None - cmds = build_commands(want, self._empty_have(), "merged") - self.assertIn( - ("set", ["system", "syslog", "console", "facility", "all"]), - cmds, + def test_merged_idempotent_console(self): + want = _want_to_device( + { + "console": {"facilities": [{"facility": "local7", "severity": "err"}]}, + }, ) + have = {"console": {"facility": {"local7": {"level": "err"}}}} + cmds = dict_op(want, have, _BASE, op="set") + self.assertEqual(cmds, []) - def test_merged_idempotent_console(self): - facs = {"local7": "err"} - want = self._empty_have() - have = self._empty_have() - want["console"]["facilities"] = facs - have["console"]["facilities"] = dict(facs) - cmds = build_commands(want, have, "merged") + def test_merged_adds_preserve_fqdn(self): + want = _want_to_device({"global_params": {"preserve_fqdn": True}}) + cmds = dict_op(want, {}, _BASE, op="set") + paths = [c[1] for c in cmds] + self.assertIn(_BASE + ["preserve-fqdn"], paths) + + def test_preserve_fqdn_idempotent(self): + want = _want_to_device({"global_params": {"preserve_fqdn": True}}) + have = {"preserve-fqdn": {}} + cmds = dict_op(want, have, _BASE, op="set") self.assertEqual(cmds, []) - def test_merged_adds_host(self): - want = self._empty_have() - want["hosts"]["172.16.0.1"] = { - "port": 514, - "facilities": {"local7": {"severity": "all", "protocol": None}}, + def test_purge_removes_extra_remote_host(self): + want = _want_to_device( + { + "hosts": [{"hostname": "10.0.0.1", "facilities": []}], + }, + ) + have = { + "remote": { + "10.0.0.1": {}, + "10.0.0.2": {}, + }, } - cmds = build_commands(want, self._empty_have(), "merged") + cmds = dict_op(want, have, _BASE, op="purge") paths = [c[1] for c in cmds] - # diff_map only adds the host key, not per-facility details - self.assertIn(["system", "syslog", "remote", "172.16.0.1"], paths) - - def test_replaced_removes_extra_host(self): - want = self._empty_have() - have = self._empty_have() - have["hosts"]["172.16.0.1"] = {"port": None, "facilities": {}} - cmds = build_commands(want, have, "replaced") - self.assertIn(("delete", ["system", "syslog", "remote", "172.16.0.1"]), cmds) - - def test_deleted_removes_per_field(self): - """deleted state removes per-facility entries, not single subtree.""" - have = self._empty_have() - have["console"]["facilities"]["all"] = None - cmds = build_commands(self._empty_have(), have, "deleted") - self.assertIn( - ("delete", ["system", "syslog", "console", "facility", "all"]), - cmds, - ) + self.assertIn(_BASE + ["remote", "10.0.0.2"], paths) + self.assertNotIn(_BASE + ["remote", "10.0.0.1"], paths) - def test_overridden_deletes_all_then_merges(self): - want = self._empty_have() - want["console"]["facilities"]["local7"] = "err" - have = self._empty_have() - have["console"]["facilities"]["all"] = None - cmds = build_commands(want, have, "overridden") - # first command is full syslog delete - self.assertEqual(cmds[0], ("delete", ["system", "syslog"])) - # then adds wanted facility - self.assertIn( - ("set", ["system", "syslog", "console", "facility", "local7", "level", "err"]), - cmds, - ) + def test_merged_adds_marker_interval(self): + want = _want_to_device({"global_params": {"marker_interval": 111}}) + cmds = dict_op(want, {}, _BASE, op="set") + paths = [c[1] for c in cmds] + self.assertIn(_BASE + ["marker", "interval", "111"], paths) def test_no_commands_when_already_correct(self): - state = self._empty_have() - state["console"]["facilities"]["local7"] = "err" - cmds = build_commands(state, state, "merged") + config = { + "console": {"facilities": [{"facility": "local7", "severity": "err"}]}, + "global_params": {"marker_interval": 111}, + } + want = _want_to_device(config) + have = { + "console": {"facility": {"local7": {"level": "err"}}}, + "marker": {"interval": 111}, + } + cmds = dict_op(want, have, _BASE, op="set") + self.assertEqual(cmds, []) + + def test_overridden_idempotent(self): + config = { + "console": {"facilities": [{"facility": "local7", "severity": "err"}]}, + "global_params": {"marker_interval": 111}, + } + want = _want_to_device(config) + have = { + "console": {"facility": {"local7": {"level": "err"}}}, + "marker": {"interval": 111}, + } + # First pass — purge+set + purge_cmds = [] + for section, section_want in want.items(): + section_have = have.get(section, {}) + purge_cmds += dict_op(section_want, section_have, _BASE + [section], op="purge") + set_cmds = dict_op(want, have, _BASE, op="set") + cmds = purge_cmds + set_cmds + # Second pass — should be empty (idempotent) self.assertEqual(cmds, []) diff --git a/tests/unit/modules/test_vyos_nat.py b/tests/unit/modules/test_vyos_nat.py new file mode 100644 index 0000000..9393e03 --- /dev/null +++ b/tests/unit/modules/test_vyos_nat.py @@ -0,0 +1,558 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, division, print_function + + +__metaclass__ = type + +import unittest + +from ansible_collections.vyos.rest.plugins.module_utils.vyos import dict_op +from ansible_collections.vyos.rest.plugins.modules.vyos_nat import ( + _cgnat_from_device, + _cgnat_to_device, + _device_to_argspec, + _normalize_nat_have, + _rules_from_device, + _rules_to_device, + _want_to_device, +) + +from .base import load_fixture + + +def _load_nat_fixture(): + return load_fixture("nat_running.json") + + +class TestRulesToDevice(unittest.TestCase): + """Keys stay snake_case here -- dict_op does kebab translation itself + at comparison time, so _rules_to_device must not do it manually.""" + + def test_simple_source_rule(self): + rules = [ + { + "id": 100, + "outbound_interface": {"name": "eth0"}, + "translation": {"address": "masquerade"}, + }, + ] + result = _rules_to_device(rules) + self.assertIn("100", result) + self.assertEqual(result["100"]["outbound_interface"]["name"], "eth0") + self.assertEqual(result["100"]["translation"]["address"], "masquerade") + + def test_bool_fields_become_presence_nodes(self): + rules = [ + { + "id": 100, + "log": True, + "exclude": True, + "translation": {"address": "masquerade"}, + }, + ] + result = _rules_to_device(rules) + self.assertEqual(result["100"]["log"], {}) + self.assertEqual(result["100"]["exclude"], {}) + + def test_false_bool_not_emitted(self): + rules = [{"id": 100, "log": False, "translation": {"address": "masquerade"}}] + result = _rules_to_device(rules) + self.assertNotIn("log", result["100"]) + + def test_destination_rule_with_port(self): + rules = [ + { + "id": 200, + "protocol": "tcp", + "inbound_interface": {"name": "eth0"}, + "destination": {"address": "198.51.100.10", "port": "80"}, + "translation": {"address": "192.168.1.10", "port": "8080"}, + }, + ] + result = _rules_to_device(rules) + self.assertEqual(result["200"]["protocol"], "tcp") + self.assertEqual(result["200"]["destination"]["address"], "198.51.100.10") + self.assertEqual(result["200"]["destination"]["port"], "80") + + def test_static_rule_inbound_interface_string(self): + """inbound_interface is a genuine union type: a plain string for + static NAT (confirmed vyos-1x: bare leafNode), a dict for source/ + destination NAT (confirmed: node with name/group children). No + special-casing needed either way -- autoclean passes a string + through unchanged and recurses into a dict identically.""" + rules = [ + { + "id": 300, + "inbound_interface": "eth0", + "destination": {"address": "198.51.100.20"}, + "translation": {"address": "192.168.1.20"}, + }, + ] + result = _rules_to_device(rules) + self.assertEqual(result["300"]["inbound_interface"], "eth0") + + def test_multiple_rules_keyed_by_id(self): + rules = [ + {"id": 100, "translation": {"address": "masquerade"}}, + {"id": 200, "translation": {"address": "masquerade"}}, + ] + result = _rules_to_device(rules) + self.assertIn("100", result) + self.assertIn("200", result) + + def test_none_values_not_emitted(self): + rules = [{"id": 100, "description": None, "translation": {"address": "masquerade"}}] + result = _rules_to_device(rules) + self.assertNotIn("description", result["100"]) + + def test_load_balance_hash_stays_a_plain_list(self): + """hash is a multi-value leafNode (confirmed ), not a tag + node -- it must pass through as a plain list untouched, letting + dict_op's own native list handling manage it.""" + rules = [ + { + "id": 100, + "load_balance": {"hash": ["source-address", "random"]}, + "translation": {"address": "masquerade"}, + }, + ] + result = _rules_to_device(rules) + self.assertEqual(result["100"]["load_balance"]["hash"], ["source-address", "random"]) + + def test_load_balance_backend_reshaped_to_tag_node(self): + """backend IS a genuine tag node (confirmed: nested "weight" + leaf), unlike hash -- this one needs the structural reshape.""" + rules = [ + { + "id": 100, + "load_balance": {"backend": [{"ip": "192.168.1.10", "weight": 50}]}, + "translation": {"address": "masquerade"}, + }, + ] + result = _rules_to_device(rules) + self.assertEqual(result["100"]["load_balance"]["backend"], {"192.168.1.10": {"weight": 50}}) + + def test_load_balance_backend_without_weight_is_bare_presence(self): + """Regression check for the autoclean-based simplification: a + backend entry with no weight must still produce a bare presence + node, matching the previous manual if/else exactly.""" + rules = [ + { + "id": 100, + "load_balance": {"backend": [{"ip": "192.168.1.10"}]}, + "translation": {"address": "masquerade"}, + }, + ] + result = _rules_to_device(rules) + self.assertEqual(result["100"]["load_balance"]["backend"], {"192.168.1.10": {}}) + + def test_nat64_translation_pool_reshaped_to_tag_node(self): + rules = [ + {"id": 10, "translation": {"pool": [{"id": 1, "address": "192.168.100.10"}]}}, + ] + result = _rules_to_device(rules) + self.assertEqual(result["10"]["translation"]["pool"], {"1": {"address": "192.168.100.10"}}) + + +class TestRulesFromDevice(unittest.TestCase): + def test_simple_rule(self): + raw = { + "100": { + "outbound-interface": {"name": "eth0"}, + "translation": {"address": "masquerade"}, + }, + } + result = _rules_from_device(raw) + self.assertEqual(len(result), 1) + self.assertEqual(result[0]["id"], 100) + self.assertEqual(result[0]["outbound_interface"]["name"], "eth0") + + def test_rules_sorted_by_id(self): + raw = { + "200": {"translation": {"address": "masquerade"}}, + "100": {"translation": {"address": "masquerade"}}, + } + result = _rules_from_device(raw) + self.assertEqual(result[0]["id"], 100) + self.assertEqual(result[1]["id"], 200) + + def test_presence_node_becomes_bool(self): + raw = {"100": {"log": {}, "translation": {"address": "masquerade"}}} + result = _rules_from_device(raw) + self.assertTrue(result[0]["log"]) + + def test_static_inbound_interface_string(self): + raw = { + "300": { + "inbound-interface": "eth0", + "destination": {"address": "198.51.100.20"}, + "translation": {"address": "192.168.1.20"}, + }, + } + result = _rules_from_device(raw) + self.assertEqual(result[0]["inbound_interface"], "eth0") + + def test_load_balance_hash_single_value_collapse(self): + """The device can collapse a single-value multi-leaf to a bare + string; this must come back as a 1-element list, not a string, + to match the field's real (list) type.""" + raw = {"100": {"load_balance": {}, "load-balance": {"hash": "random"}}} + # (duplicate key above is just illustrating intent; real call:) + raw = {"100": {"load-balance": {"hash": "random"}}} + result = _rules_from_device(raw) + self.assertEqual(result[0]["load_balance"]["hash"], ["random"]) + + def test_load_balance_backend_from_tag_node(self): + raw = {"100": {"load-balance": {"backend": {"192.168.1.10": {"weight": "50"}}}}} + result = _rules_from_device(raw) + self.assertEqual( + result[0]["load_balance"]["backend"], + [{"ip": "192.168.1.10", "weight": 50}], + ) + + def test_nat64_pool_from_tag_node(self): + raw = {"10": {"translation": {"pool": {"1": {"address": "192.168.100.10"}}}}} + result = _rules_from_device(raw) + self.assertEqual(result[0]["translation"]["pool"], [{"id": 1, "address": "192.168.100.10"}]) + + def test_empty_returns_empty(self): + self.assertEqual(_rules_from_device({}), []) + self.assertEqual(_rules_from_device(None), []) + + +class TestCgnat(unittest.TestCase): + """The core bug-fix area: external pool range is a genuine tag node + (nested "seq" leaf), internal pool range is a plain multi-value leaf + -- confirmed against vyos-1x schema, and previously conflated.""" + + def test_external_pool_range_is_tag_node_with_seq(self): + cgnat = { + "pool": { + "external": [ + {"name": "EXT1", "range": [{"value": "203.0.113.1-203.0.113.10", "seq": 1}]}, + ], + }, + } + result = _cgnat_to_device(cgnat) + self.assertEqual( + result["pool"]["external"]["EXT1"]["range"], + {"203.0.113.1-203.0.113.10": {"seq": 1}}, + ) + + def test_external_pool_range_without_seq(self): + cgnat = {"pool": {"external": [{"name": "EXT1", "range": [{"value": "203.0.113.1-.10"}]}]}} + result = _cgnat_to_device(cgnat) + self.assertEqual(result["pool"]["external"]["EXT1"]["range"], {"203.0.113.1-.10": {}}) + + def test_internal_pool_range_stays_a_plain_list(self): + cgnat = {"pool": {"internal": [{"name": "INT1", "range": ["10.0.0.0/24", "10.0.1.0/24"]}]}} + result = _cgnat_to_device(cgnat) + self.assertEqual( + result["pool"]["internal"]["INT1"]["range"], + ["10.0.0.0/24", "10.0.1.0/24"], + ) + + def test_internal_pool_multi_value_range_from_device_not_dropped(self): + """Regression test for the confirmed data-loss bug: the previous + implementation only checked isinstance(str)/isinstance(dict) for + internal pool range and silently dropped it whenever the device + returned the real shape for >1 value -- a plain list.""" + raw = {"pool": {"internal": {"INT1": {"range": ["10.0.0.0/24", "10.0.1.0/24"]}}}} + result = _cgnat_from_device(raw) + pool = result["pool"]["internal"][0] + self.assertEqual(pool["range"], ["10.0.0.0/24", "10.0.1.0/24"]) + + def test_internal_pool_single_value_range_collapse(self): + raw = {"pool": {"internal": {"INT1": {"range": "10.0.2.0/24"}}}} + result = _cgnat_from_device(raw) + self.assertEqual(result["pool"]["internal"][0]["range"], ["10.0.2.0/24"]) + + def test_external_pool_range_from_device_with_seq(self): + raw = {"pool": {"external": {"EXT1": {"range": {"203.0.113.1-.10": {"seq": "1"}}}}}} + result = _cgnat_from_device(raw) + rng = result["pool"]["external"][0]["range"] + self.assertEqual(rng, [{"value": "203.0.113.1-.10", "seq": 1}]) + + def test_log_allocation_generic_presence(self): + result = _cgnat_to_device({"log_allocation": True}) + self.assertEqual(result["log_allocation"], {}) + + def test_cgnat_rule_generic(self): + cgnat = {"rule": [{"id": 1, "destination": {"group": {"address_group": "CGNAT-DST"}}}]} + result = _cgnat_to_device(cgnat) + self.assertEqual( + result["rule"]["1"]["destination"]["group"]["address_group"], + "CGNAT-DST", + ) + + def test_empty(self): + self.assertEqual(_cgnat_to_device({}), {}) + self.assertEqual(_cgnat_from_device({}), {}) + + +class TestWantToDevice(unittest.TestCase): + def test_empty(self): + self.assertEqual(_want_to_device({}), {}) + self.assertEqual(_want_to_device(None), {}) + + def test_source_nat(self): + config = { + "nat": { + "source": { + "rule": [ + { + "id": 100, + "outbound_interface": {"name": "eth0"}, + "translation": {"address": "masquerade"}, + }, + ], + }, + }, + } + result = _want_to_device(config) + self.assertIn("100", result["nat"]["source"]["rule"]) + + def test_nat64_pools(self): + config = { + "nat64": { + "source": { + "rule": [ + { + "id": 10, + "translation": {"pool": [{"id": 1, "address": "192.168.100.10"}]}, + }, + ], + }, + }, + } + result = _want_to_device(config) + rule = result["nat64"]["source"]["rule"]["10"] + self.assertIn("1", rule["translation"]["pool"]) + + def test_nat66(self): + config = { + "nat66": { + "source": { + "rule": [{"id": 10, "outbound_interface": {"name": "eth0"}}], + }, + }, + } + result = _want_to_device(config) + self.assertIn("10", result["nat66"]["source"]["rule"]) + + def test_nat66_destination_and_source_via_dispatch_table(self): + """nat66 has no cgnat, and only destination/source (no static) -- + exercised via _NAT_TYPE_SECTIONS, not hand-written per-type + blocks.""" + config = { + "nat66": { + "destination": {"rule": [{"id": 10, "protocol": "tcp"}]}, + "source": {"rule": [{"id": 20}]}, + }, + } + result = _want_to_device(config) + self.assertIn("10", result["nat66"]["destination"]["rule"]) + self.assertIn("20", result["nat66"]["source"]["rule"]) + self.assertNotIn("cgnat", result["nat66"]) + + def test_cgnat_in_want(self): + config = {"nat": {"cgnat": {"log_allocation": True}}} + result = _want_to_device(config) + self.assertEqual(result["nat"]["cgnat"]["log_allocation"], {}) + + +class TestDeviceToArgspec(unittest.TestCase): + def test_empty(self): + self.assertEqual(_device_to_argspec({}), {}) + self.assertEqual(_device_to_argspec(None), {}) + + def test_source_rule(self): + raw = {"nat": {"source": {"rule": {"100": {"translation": {"address": "masquerade"}}}}}} + result = _device_to_argspec(raw) + self.assertEqual(result["nat"]["source"]["rule"][0]["id"], 100) + + def test_nat64_pools_parsed(self): + raw = { + "nat64": { + "source": { + "rule": {"10": {"translation": {"pool": {"1": {"address": "192.168.100.10"}}}}}, + }, + }, + } + result = _device_to_argspec(raw) + pools = result["nat64"]["source"]["rule"][0]["translation"]["pool"] + self.assertEqual(pools[0]["id"], 1) + + def test_static_inbound_interface_string(self): + raw = {"nat": {"static": {"rule": {"300": {"inbound-interface": "eth0"}}}}} + result = _device_to_argspec(raw) + self.assertEqual(result["nat"]["static"]["rule"][0]["inbound_interface"], "eth0") + + +class TestDeviceToArgspecFixture(unittest.TestCase): + def setUp(self): + self.fixture = _load_nat_fixture() + + def test_source_rules_parsed(self): + result = _device_to_argspec(self.fixture) + ids = [r["id"] for r in result["nat"]["source"]["rule"]] + self.assertIn(100, ids) + self.assertIn(101, ids) + + def test_destination_rule_parsed(self): + result = _device_to_argspec(self.fixture) + rule = result["nat"]["destination"]["rule"][0] + self.assertEqual(rule["id"], 200) + self.assertEqual(rule["destination"]["port"], "80") + + def test_static_rule_parsed(self): + result = _device_to_argspec(self.fixture) + rule = result["nat"]["static"]["rule"][0] + self.assertEqual(rule["inbound_interface"], "eth0") + + def test_nat64_pools_parsed(self): + result = _device_to_argspec(self.fixture) + pools = result["nat64"]["source"]["rule"][0]["translation"]["pool"] + self.assertEqual(pools[0]["port"], "1-65535") + + def test_description_parsed(self): + result = _device_to_argspec(self.fixture) + rule100 = next(r for r in result["nat"]["source"]["rule"] if r["id"] == 100) + self.assertEqual(rule100["description"], "Source rule 100") + + def test_hash_single_value_collapse_from_fixture(self): + result = _device_to_argspec(self.fixture) + rule100 = next(r for r in result["nat"]["source"]["rule"] if r["id"] == 100) + self.assertEqual(rule100["load_balance"]["hash"], ["random"]) + + def test_backend_from_fixture(self): + result = _device_to_argspec(self.fixture) + rule100 = next(r for r in result["nat"]["source"]["rule"] if r["id"] == 100) + backends = {b["ip"]: b.get("weight") for b in rule100["load_balance"]["backend"]} + self.assertEqual(backends["192.168.1.10"], 50) + self.assertEqual(backends["192.168.1.11"], None) + + def test_cgnat_internal_pool_range_not_dropped(self): + """The actual regression this whole refactor was triggered by.""" + result = _device_to_argspec(self.fixture) + pool = result["nat"]["cgnat"]["pool"]["internal"][0] + self.assertEqual(pool["range"], ["10.0.0.0/24", "10.0.1.0/24"]) + + def test_cgnat_external_pool_range_with_seq(self): + result = _device_to_argspec(self.fixture) + pool = result["nat"]["cgnat"]["pool"]["external"][0] + self.assertEqual(pool["range"], [{"value": "203.0.113.1-203.0.113.10", "seq": 1}]) + + def test_cgnat_rule_parsed(self): + result = _device_to_argspec(self.fixture) + rule = result["nat"]["cgnat"]["rule"][0] + self.assertEqual(rule["destination"]["group"]["address_group"], "CGNAT-DST") + + +class TestDictOpNat(unittest.TestCase): + """End-to-end command generation, exactly as main() calls it.""" + + def test_merged_adds_source_rule(self): + want = _want_to_device( + { + "nat": { + "source": { + "rule": [ + { + "id": 100, + "outbound_interface": {"name": "eth0"}, + "translation": {"address": "masquerade"}, + }, + ], + }, + }, + }, + ) + cmds = dict_op(want.get("nat", {}), {}, ["nat"], op="set") + paths = [c[1] for c in cmds] + self.assertIn(["nat", "source", "rule", "100", "outbound-interface", "name", "eth0"], paths) + self.assertIn( + ["nat", "source", "rule", "100", "translation", "address", "masquerade"], + paths, + ) + + def test_merged_idempotent_against_fixture(self): + fixture = _load_nat_fixture() + have = _device_to_argspec(fixture) + want = _want_to_device({"nat": have.get("nat", {})}).get("nat", {}) + norm_have = _normalize_nat_have(fixture, "nat") + cmds = dict_op(want, norm_have, ["nat"], op="set") + self.assertEqual(cmds, []) + + def test_nat64_idempotent_against_fixture(self): + fixture = _load_nat_fixture() + have = _device_to_argspec(fixture) + want = _want_to_device({"nat64": have.get("nat64", {})}).get("nat64", {}) + norm_have = _normalize_nat_have(fixture, "nat64") + cmds = dict_op(want, norm_have, ["nat64"], op="set") + self.assertEqual(cmds, []) + + def test_cgnat_idempotent_against_fixture_including_ranges(self): + """The real proof the bug is fixed: idempotency now holds even + though it involves both the tag-node (external) and plain-list + (internal) range shapes at once.""" + fixture = _load_nat_fixture() + have = _device_to_argspec(fixture) + want = _want_to_device({"nat": have.get("nat", {})}).get("nat", {}) + norm_have = _normalize_nat_have(fixture, "nat") + cmds = dict_op(want, norm_have, ["nat"], op="set") + self.assertEqual(cmds, []) + + def test_replaced_purges_stale_internal_range_member(self): + """The internal-pool range being a plain list means removing a + member under 'replaced' relies on dict_op's list-purge handling + (fixed earlier this session) -- confirmed it applies here too.""" + raw_have = { + "cgnat": {"pool": {"internal": {"INT1": {"range": ["10.0.0.0/24", "10.0.1.0/24"]}}}}, + } + want = _want_to_device( + { + "nat": { + "cgnat": {"pool": {"internal": [{"name": "INT1", "range": ["10.0.0.0/24"]}]}}, + }, + }, + )["nat"] + norm_have = _normalize_nat_have({"nat": raw_have}, "nat") + cmds = dict_op(want, norm_have, ["nat"], op="purge") + self.assertIn( + ("delete", ["nat", "cgnat", "pool", "internal", "INT1", "range", "10.0.1.0/24"]), + cmds, + ) + + def test_overridden_deletes_entire_omitted_section(self): + """overridden is full-model: a section entirely omitted from + want (not just a rule within it) must be deleted, via the same + single dict_op purge call main() uses -- no manual section-scan + loop needed.""" + raw_have = { + "destination": {"rule": {"200": {"protocol": "tcp"}}}, + "source": {"rule": {"100": {}}}, + } + nat_want = _want_to_device( + {"nat": {"source": {"rule": [{"id": 100}]}}}, + )["nat"] + norm_have = _normalize_nat_have({"nat": raw_have}, "nat") + cmds = dict_op(nat_want, norm_have, ["nat"], op="purge") + self.assertIn(("delete", ["nat", "destination"]), cmds) + self.assertTrue(all(c[1] != ["nat", "source"] for c in cmds)) + + def test_overridden_full_wipe_deletes_each_section_individually(self): + """Empty want under overridden purges every present section -- + granular per-section deletes, not one blanket delete of the + whole nat_type (that distinction only matters for vyos_bgp_global, + where system-as's device-model constraint forces atomicity; NAT + has no equivalent cross-field constraint).""" + raw_have = {"destination": {"rule": {"200": {}}}, "source": {"rule": {"100": {}}}} + norm_have = _normalize_nat_have({"nat": raw_have}, "nat") + cmds = dict_op({}, norm_have, ["nat"], op="purge") + self.assertIn(("delete", ["nat", "destination"]), cmds) + self.assertIn(("delete", ["nat", "source"]), cmds) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/modules/test_vyos_ntp_global.py b/tests/unit/modules/test_vyos_ntp_global.py index c536141..4bbeed8 100644 --- a/tests/unit/modules/test_vyos_ntp_global.py +++ b/tests/unit/modules/test_vyos_ntp_global.py @@ -4,179 +4,178 @@ from __future__ import absolute_import, division, print_function __metaclass__ = type -import json -import os import unittest from unittest.mock import MagicMock from ansible_collections.vyos.rest.plugins.modules.vyos_ntp_global import ( + _device_to_argspec, + _servers_from_device, + _servers_to_device, + _want_to_device, build_commands, get_running_config, - normalize_config, - normalize_servers, ) +from .base import load_fixture -def load_fixture(filename): - fixtures_dir = os.path.join(os.path.dirname(__file__), "..", "fixtures") - path = os.path.join(fixtures_dir, filename) - with open(path) as f: - return json.load(f) + +_BASE = ["service", "ntp"] class VyOSModuleTestCase(unittest.TestCase): def setUp(self): self.mock_vyos = MagicMock() - self.mock_vyos.get_config = MagicMock(return_value={}) - - def set_running_config(self, data): - self.mock_vyos.get_config.return_value = data - - -class TestVyOSNtpGlobalNormalize(unittest.TestCase): - """Test normalize_config and normalize_servers — no device needed.""" - - def test_normalize_config_empty(self): - result = normalize_config({}) - self.assertEqual(result["allow_clients"], []) - self.assertEqual(result["listen_addresses"], []) - self.assertEqual(result["servers"], {}) - - def test_normalize_config_servers_sorted(self): - config = { - "servers": [ - {"server": "b.example.com", "options": ["prefer", "noselect"]}, - {"server": "a.example.com"}, - ], - } - result = normalize_config(config) - self.assertIn("a.example.com", result["servers"]) - self.assertIn("b.example.com", result["servers"]) - self.assertEqual(result["servers"]["b.example.com"], ["noselect", "prefer"]) - - def test_normalize_servers_dict_with_options(self): - raw = { - "time1.vyos.net": {}, - "203.0.113.0": {"prefer": {}}, - } - result = normalize_servers(raw) - self.assertEqual(result["time1.vyos.net"], []) - self.assertEqual(result["203.0.113.0"], ["prefer"]) - - def test_normalize_servers_list(self): - raw = ["time1.vyos.net", "time2.vyos.net"] - result = normalize_servers(raw) - self.assertEqual(result["time1.vyos.net"], []) - - def test_normalize_servers_string(self): - result = normalize_servers("time1.vyos.net") - self.assertEqual(result["time1.vyos.net"], []) - - -class TestVyOSNtpGlobalGetRunning(VyOSModuleTestCase): - """Test get_running_config parsing against fixture API responses.""" - - def setUp(self): - super().setUp() self.fixture = load_fixture("ntp_global_running.json") + self.mock_vyos.get_config = MagicMock(return_value=self.fixture) - def test_parses_allow_clients(self): - self.set_running_config(self.fixture) - result = get_running_config(self.mock_vyos) - self.assertIn("10.6.6.0/24", result["allow_clients"]) - def test_parses_listen_addresses(self): - self.set_running_config(self.fixture) - result = get_running_config(self.mock_vyos) - self.assertIn("10.1.3.1", result["listen_addresses"]) +class TestGetRunningConfig(VyOSModuleTestCase): + def test_returns_raw_device_dict(self): + self.assertEqual(get_running_config(self.mock_vyos), self.fixture) - def test_parses_servers(self): - self.set_running_config(self.fixture) - result = get_running_config(self.mock_vyos) - self.assertIn("time1.vyos.net", result["servers"]) - self.assertIn("203.0.113.0", result["servers"]) - self.assertIn("prefer", result["servers"]["203.0.113.0"]) + def test_empty_config(self): + self.mock_vyos.get_config = MagicMock(return_value=None) + self.assertEqual(get_running_config(self.mock_vyos), {}) - def test_empty_config_returns_empty(self): - self.set_running_config({}) - result = get_running_config(self.mock_vyos) - self.assertEqual(result["allow_clients"], []) - self.assertEqual(result["servers"], {}) +class TestServersToDeviceFromDevice(unittest.TestCase): + """options is the one genuine structural exception: the argspec + wraps them in a named field, but the device puts each option as a + direct presence-leaf sibling under the server tag node itself.""" -class TestVyOSNtpGlobalBuildCommands(unittest.TestCase): - """Test build_commands diff logic — no device needed.""" + def test_to_device_bare_server_is_presence(self): + self.assertEqual(_servers_to_device([{"server": "time1.vyos.net"}]), {"time1.vyos.net": {}}) - def _have(self, **kwargs): - base = {"allow_clients": [], "listen_addresses": [], "servers": {}} - base.update(kwargs) - return base + def test_to_device_options_become_sibling_presence_leaves(self): + result = _servers_to_device([{"server": "203.0.113.0", "options": ["prefer", "nts"]}]) + self.assertEqual(result, {"203.0.113.0": {"prefer": {}, "nts": {}}}) - def _want(self, **kwargs): - return self._have(**kwargs) + def test_from_device_bare_server(self): + result = _servers_from_device({"time1.vyos.net": {}}) + self.assertEqual(result, [{"server": "time1.vyos.net"}]) - def test_merged_adds_new_server(self): - want = self._want(servers={"new.server.com": []}) - have = self._have(servers={}) - cmds = build_commands(want, have, "merged") - self.assertIn(("set", ["service", "ntp", "server", "new.server.com"]), cmds) + def test_from_device_options_extracted_as_sorted_list(self): + result = _servers_from_device({"203.0.113.0": {"prefer": {}, "nts": {}}}) + self.assertEqual(result, [{"server": "203.0.113.0", "options": ["nts", "prefer"]}]) + + +class TestWantToDevice(unittest.TestCase): + def test_empty(self): + self.assertEqual(_want_to_device({}), {}) + + def test_allow_clients_nested_under_address(self): + """allow_clients is a flat argspec list, but the device nests + the multi-value leaf one level deeper under a literal "address" + child -- confirmed against vyos-1x (allow-client.xml.i).""" + result = _want_to_device({"allow_clients": ["10.6.6.0/24"]}) + self.assertEqual(result, {"allow-client": {"address": ["10.6.6.0/24"]}}) - def test_merged_idempotent_existing_server(self): - want = self._want(servers={"time1.vyos.net": []}) - have = self._have(servers={"time1.vyos.net": []}) - cmds = build_commands(want, have, "merged") - self.assertEqual(cmds, []) + def test_listen_addresses_direct_no_nesting(self): + result = _want_to_device({"listen_addresses": ["10.1.3.1"]}) + self.assertEqual(result, {"listen-address": ["10.1.3.1"]}) + + def test_servers_keyed_by_address(self): + result = _want_to_device({"servers": [{"server": "203.0.113.0", "options": ["prefer"]}]}) + self.assertEqual(result, {"server": {"203.0.113.0": {"prefer": {}}}}) + + +class TestDeviceToArgspecFixture(VyOSModuleTestCase): + def test_allow_clients_parsed(self): + result = _device_to_argspec(self.fixture) + self.assertIn("10.6.6.0/24", result["allow_clients"]) + + def test_listen_addresses_parsed(self): + result = _device_to_argspec(self.fixture) + self.assertIn("10.1.3.1", result["listen_addresses"]) + + def test_servers_parsed_with_options(self): + result = _device_to_argspec(self.fixture) + servers = {s["server"]: s.get("options", []) for s in result["servers"]} + self.assertIn("time1.vyos.net", servers) + self.assertIn("prefer", servers["203.0.113.0"]) + + def test_empty_config(self): + result = _device_to_argspec({}) + self.assertEqual(result, {"allow_clients": [], "listen_addresses": [], "servers": []}) + + def test_1_5_plus_shape_no_address_wrapper(self): + """Confirmed against vyos-1x, but kept defensive: some REST + responses omit the "address" subnode under allow-client.""" + raw = {"allow-client": {"10.6.6.0/24": {}}} + result = _device_to_argspec(raw) + self.assertEqual(result["allow_clients"], ["10.6.6.0/24"]) + + +class TestBuildCommands(VyOSModuleTestCase): + def test_merged_idempotent_against_own_fixture(self): + have = _device_to_argspec(self.fixture) + self.assertEqual(build_commands(have, self.fixture, "merged"), []) + + def test_replaced_idempotent_against_own_fixture(self): + have = _device_to_argspec(self.fixture) + self.assertEqual(build_commands(have, self.fixture, "replaced"), []) + + def test_1_5_plus_shape_idempotent(self): + """Regression test for the real bug caught this session: want + always emits the "address"-wrapped shape, but dict_op compares + directly against the raw device tree -- without normalizing + have's shape first, a device reporting the unwrapped 1.5+ + variant would never be idempotent.""" + raw_have = {"allow-client": {"10.6.6.0/24": {}}} + have = _device_to_argspec(raw_have) + self.assertEqual(build_commands(have, raw_have, "merged"), []) + + def test_merged_adds_new_server(self): + cmds = build_commands({"servers": [{"server": "new.server.com"}]}, {}, "merged") + self.assertIn(("set", _BASE + ["server", "new.server.com"]), cmds) def test_merged_adds_server_option(self): - want = self._want(servers={"time1.vyos.net": ["prefer"]}) - have = self._have(servers={"time1.vyos.net": []}) - cmds = build_commands(want, have, "merged") - self.assertIn(("set", ["service", "ntp", "server", "time1.vyos.net", "prefer"]), cmds) + raw_have = {"server": {"time1.vyos.net": {}}} + config = {"servers": [{"server": "time1.vyos.net", "options": ["prefer"]}]} + cmds = build_commands(config, raw_have, "merged") + self.assertIn(("set", _BASE + ["server", "time1.vyos.net", "prefer"]), cmds) def test_replaced_removes_extra_server(self): - want = self._want(servers={"time1.vyos.net": []}) - have = self._have(servers={"time1.vyos.net": [], "time2.vyos.net": []}) - cmds = build_commands(want, have, "replaced") - self.assertIn(("delete", ["service", "ntp", "server", "time2.vyos.net"]), cmds) + raw_have = {"server": {"time1.vyos.net": {}, "time2.vyos.net": {}}} + config = {"servers": [{"server": "time1.vyos.net"}]} + cmds = build_commands(config, raw_have, "replaced") + self.assertIn(("delete", _BASE + ["server", "time2.vyos.net"]), cmds) def test_replaced_removes_extra_allow_client(self): - want = self._want(allow_clients=["10.1.0.0/24"]) - have = self._have(allow_clients=["10.1.0.0/24", "10.2.0.0/24"]) - cmds = build_commands(want, have, "replaced") - self.assertIn( - ("delete", ["service", "ntp", "allow-client", "address", "10.2.0.0/24"]), - cmds, - ) + """This exercises the real dict_op purge gap fixed this session: + have's allow-client returned as dict-of-presence (not a plain + list) while want is a plain list -- purge must still correctly + remove the stale entry.""" + raw_have = {"allow-client": {"address": {"10.1.0.0/24": {}, "10.2.0.0/24": {}}}} + config = {"allow_clients": ["10.1.0.0/24"]} + cmds = build_commands(config, raw_have, "replaced") + self.assertIn(("delete", _BASE + ["allow-client", "address", "10.2.0.0/24"]), cmds) def test_deleted_removes_all(self): - have = self._have( - servers={"time1.vyos.net": []}, - allow_clients=["10.0.0.0/24"], - listen_addresses=["192.168.1.1"], - ) - cmds = build_commands({}, have, "deleted") - self.assertEqual(len(cmds), 1) - self.assertEqual(cmds[0], ("delete", ["service", "ntp"])) + raw_have = {"server": {"time1.vyos.net": {}}} + cmds = build_commands({}, raw_have, "deleted") + self.assertEqual(cmds, [("delete", _BASE)]) def test_deleted_idempotent_when_empty(self): - have = self._have(servers={}, allow_clients=[], listen_addresses=[]) - cmds = build_commands({}, have, "deleted") - self.assertEqual(cmds, []) + self.assertEqual(build_commands({}, {}, "deleted"), []) def test_overridden_deletes_then_merges(self): - want = self._want(servers={"new.server.com": []}) - have = self._have(servers={"old.server.com": []}) - cmds = build_commands(want, have, "overridden") - ops_paths = [(c[0], c[1]) for c in cmds] - self.assertIn(("delete", ["service", "ntp", "server", "old.server.com"]), ops_paths) - self.assertIn(("set", ["service", "ntp", "server", "new.server.com"]), ops_paths) - self.assertNotIn(("delete", ["service", "ntp", "server"]), ops_paths) + raw_have = {"server": {"old.server.com": {}}} + config = {"servers": [{"server": "new.server.com"}]} + cmds = build_commands(config, raw_have, "overridden") + self.assertIn(("delete", _BASE + ["server", "old.server.com"]), cmds) + self.assertIn(("set", _BASE + ["server", "new.server.com"]), cmds) def test_no_commands_when_already_correct(self): - state = {"allow_clients": ["10.0.0.0/24"], "listen_addresses": [], "servers": {}} - cmds = build_commands(state, state, "merged") - self.assertEqual(cmds, []) + raw_have = {"allow-client": {"address": {"10.0.0.0/24": {}}}} + config = {"allow_clients": ["10.0.0.0/24"]} + self.assertEqual(build_commands(config, raw_have, "merged"), []) + + def test_collapsed_single_server_no_char_iteration_bug(self): + raw_have = {"server": "203.0.113.0"} + config = {"servers": [{"server": "203.0.113.0"}]} + self.assertEqual(build_commands(config, raw_have, "merged"), []) if __name__ == "__main__": diff --git a/tests/unit/modules/test_vyos_route_maps.py b/tests/unit/modules/test_vyos_route_maps.py index 97d814b..4eec600 100644 --- a/tests/unit/modules/test_vyos_route_maps.py +++ b/tests/unit/modules/test_vyos_route_maps.py @@ -4,189 +4,431 @@ from __future__ import absolute_import, division, print_function __metaclass__ = type -import json -import os import unittest from unittest.mock import MagicMock from ansible_collections.vyos.rest.plugins.modules.vyos_route_maps import ( - _want_to_api_match, - _want_to_api_set, + ARGUMENT_SPEC, + _derive_key_field, + _device_to_argspec, + _keyed_list_from_device, + _keyed_list_to_device, + _match_from_device, + _match_to_device, + _rule_entry_from_device, + _rule_entry_to_device, + _seed_route_map_placeholders, + _set_from_device, + _set_to_device, + _want_to_device, build_commands, get_running_config, ) +from .base import load_fixture -def load_fixture(filename): - fixtures_dir = os.path.join(os.path.dirname(__file__), "..", "fixtures") - path = os.path.join(fixtures_dir, filename) - with open(path) as f: - return json.load(f) + +_BASE = ["policy", "route-map"] class VyOSModuleTestCase(unittest.TestCase): def setUp(self): self.mock_vyos = MagicMock() - self.mock_vyos.get_config = MagicMock(return_value={}) - - def set_running_config(self, data): - self.mock_vyos.get_config.return_value = data - + self.fixture = load_fixture("route_maps_running.json") + self.mock_vyos.get_config = MagicMock(return_value=self.fixture) -class TestVyOSRouteMapsGetRunning(VyOSModuleTestCase): - def setUp(self): - super().setUp() - self.fixture = load_fixture("route_maps_running.json") +class TestGetRunningConfig(VyOSModuleTestCase): + def test_unwraps_route_map_wrapper_key(self): + """Confirmed against the pre-existing fixture (built from real + device data): the REST API wraps the response in an extra + "route-map" key even when querying at the policy/route-map + path itself -- the same defensive-unwrap pattern every other + module this session needed for its own top-level get_config.""" + result = get_running_config(self.mock_vyos) + self.assertIn("RM-TEST-EXPORT-POLICY", result) + self.assertNotIn("route-map", result) - def test_unwraps_route_map_nesting(self): - """API returns {"route-map": {"NAME": {...}}} — must unwrap.""" - self.set_running_config(self.fixture) + def test_no_wrapper_key_passes_through(self): + self.mock_vyos.get_config = MagicMock(return_value={"RM1": {"rule": {}}}) result = get_running_config(self.mock_vyos) - names = [e["route_map"] for e in result] + self.assertIn("RM1", result) + + def test_empty_config(self): + self.mock_vyos.get_config = MagicMock(return_value=None) + self.assertEqual(get_running_config(self.mock_vyos), {}) + + +class TestDeriveKeyField(unittest.TestCase): + def test_derives_route_map_key(self): + opts = ARGUMENT_SPEC["config"]["options"] + self.assertEqual(_derive_key_field(opts), "route_map") + + def test_derives_sequence_key(self): + entry_opts = ARGUMENT_SPEC["config"]["options"]["entries"]["options"] + self.assertEqual(_derive_key_field(entry_opts), "sequence") + + def test_raises_if_none_required(self): + with self.assertRaises(ValueError): + _derive_key_field({"a": {"type": "str"}}) + + def test_raises_if_more_than_one_required(self): + with self.assertRaises(ValueError): + _derive_key_field({"a": {"required": True}, "b": {"required": True}}) + + +class TestKeyedListHelper(unittest.TestCase): + def test_to_device_default_transform_is_autoclean(self): + result = _keyed_list_to_device([{"route_map": "RM1", "description": "x"}], "route_map") + self.assertEqual(result, {"RM1": {"description": "x"}}) + + def test_from_device_default_transform_is_from_device(self): + result = _keyed_list_from_device({"RM1": {"description": "x"}}, "route_map") + self.assertEqual(result, [{"route_map": "RM1", "description": "x"}]) + + def test_empty(self): + self.assertEqual(_keyed_list_to_device([], "route_map"), {}) + self.assertEqual(_keyed_list_from_device({}, "route_map"), []) + + +class TestMatchToDeviceFromDevice(unittest.TestCase): + """Most match options are fully generic; only prefix_list/ + prefix_list6 and ip/ipv6 nexthop matching are genuine structural + exceptions (confirmed against vyos-1x: the device nests these + deeper than the argspec).""" + + def test_simple_fields_generic(self): + result = _match_to_device({"peer": "192.0.2.1", "protocol": "bgp", "metric": 100}) + self.assertEqual(result, {"peer": "192.0.2.1", "protocol": "bgp", "metric": 100}) + + def test_prefix_list_nested_two_levels(self): + result = _match_to_device({"prefix_list": "PL1"}) + self.assertEqual(result, {"ip": {"address": {"prefix-list": "PL1"}}}) + + def test_prefix_list6_nested_two_levels(self): + result = _match_to_device({"prefix_list6": "PL6"}) + self.assertEqual(result, {"ipv6": {"address": {"prefix-list": "PL6"}}}) + + def test_ip_nexthop_extra_nesting_level(self): + result = _match_to_device( + {"ip": {"nexthop_address": "10.0.0.1", "nexthop_prefix_list": "PL2"}}, + ) + self.assertEqual( + result, + {"ip": {"nexthop": {"address": "10.0.0.1", "prefix-list": "PL2"}}}, + ) + + def test_ipv6_nexthop_extra_nesting_level(self): + result = _match_to_device({"ipv6": {"nexthop_address": "2001:db8::1"}}) + self.assertEqual(result, {"ipv6": {"nexthop": {"address": "2001:db8::1"}}}) + + def test_from_device_prefix_list(self): + entry = _match_from_device({"ip": {"address": {"prefix-list": "PL1"}}}) + self.assertEqual(entry["prefix_list"], "PL1") + + def test_from_device_nexthop(self): + entry = _match_from_device({"ip": {"nexthop": {"address": "10.0.0.1"}}}) + self.assertEqual(entry["ip"], {"nexthop_address": "10.0.0.1"}) + + def test_from_device_generic_fields(self): + entry = _match_from_device({"peer": "192.0.2.1", "protocol": "bgp"}) + self.assertEqual(entry, {"peer": "192.0.2.1", "protocol": "bgp"}) + + def test_empty(self): + self.assertEqual(_match_to_device({}), {}) + self.assertEqual(_match_to_device(None), {}) + self.assertEqual(_match_from_device({}), {}) + self.assertEqual(_match_from_device(None), {}) + + +class TestSetToDeviceFromDevice(unittest.TestCase): + """as_path_* collapse onto one nested device node. community/ + large_community/ipv6_next_hop are fully generic once modeled as + real nested dicts. "as_" is a genuine Python-keyword-collision + rename, nested inside aggregator specifically.""" + + def test_atomic_aggregate_fully_generic(self): + result = _set_to_device({"atomic_aggregate": True}) + self.assertEqual(result, {"atomic_aggregate": {}}) + + def test_as_path_options_collapse_onto_one_node(self): + result = _set_to_device( + {"as_path_exclude": "111", "as_path_prepend": "65001", "as_path_prepend_last_as": 2}, + ) + self.assertEqual( + result["as-path"], + {"exclude": "111", "prepend": "65001", "prepend-last-as": 2}, + ) + + def test_aggregator_as_rename(self): + """Regression test for the real bug caught this session: "as_" + is nested inside "aggregator", not a top-level set field -- a + flat rename map applied only at the top level misses it + entirely.""" + result = _set_to_device({"aggregator": {"as_": 100, "ip": "10.0.0.5"}}) + self.assertEqual(result, {"aggregator": {"as": 100, "ip": "10.0.0.5"}}) + + def test_aggregator_as_only(self): + result = _set_to_device({"aggregator": {"as_": 100}}) + self.assertEqual(result, {"aggregator": {"as": 100}}) + + def test_community_add_stays_a_plain_list(self): + result = _set_to_device({"community": {"add": ["no-export", "no-advertise"]}}) + self.assertEqual(result["community"], {"add": ["no-export", "no-advertise"]}) + + def test_large_community_none_presence(self): + result = _set_to_device({"large_community": {"none": True}}) + self.assertEqual(result["large_community"], {"none": {}}) + + def test_ipv6_next_hop_generic(self): + result = _set_to_device({"ipv6_next_hop": {"global": "2001:db8::1"}}) + self.assertEqual(result["ipv6_next_hop"], {"global": "2001:db8::1"}) + + def test_ipv6_next_hop_valueless_options(self): + result = _set_to_device({"ipv6_next_hop": {"peer_address": True, "prefer_global": True}}) + self.assertEqual( + result["ipv6_next_hop"], + {"peer_address": {}, "prefer_global": {}}, + ) + + def test_from_device_community_add(self): + entry = _set_from_device({"community": {"add": ["no-export"]}}) + self.assertEqual(entry["community"], {"add": ["no-export"]}) + + def test_from_device_large_community_none(self): + entry = _set_from_device({"large-community": {"none": {}}}) + self.assertEqual(entry["large_community"], {"none": True}) + + def test_from_device_as_path(self): + entry = _set_from_device({"as-path": {"exclude": "111", "prepend-last-as": "2"}}) + self.assertEqual(entry["as_path_exclude"], "111") + self.assertEqual(entry["as_path_prepend_last_as"], 2) + + def test_from_device_aggregator_as_rename_with_int_cast(self): + entry = _set_from_device({"aggregator": {"as": "100", "ip": "10.0.0.5"}}) + self.assertEqual(entry["aggregator"]["as_"], 100) + self.assertEqual(entry["aggregator"]["ip"], "10.0.0.5") + + def test_empty(self): + self.assertEqual(_set_to_device({}), {}) + self.assertEqual(_set_to_device(None), {}) + self.assertEqual(_set_from_device({}), {}) + self.assertEqual(_set_from_device(None), {}) + + +class TestRuleEntryToDeviceFromDevice(unittest.TestCase): + def test_continue_sequence_renamed(self): + """ "continue" is a Python keyword and can't be used as a + dict() kwarg -- "continue_sequence" is the unavoidable argspec + name, handled directly at the rule level (not a set field).""" + result = _rule_entry_to_device({"continue_sequence": 20}) + self.assertEqual(result["continue"], 20) + + def test_generic_fields(self): + result = _rule_entry_to_device({"action": "permit", "call": "RM2"}) + self.assertEqual(result, {"action": "permit", "call": "RM2"}) + + def test_from_device_continue(self): + entry = _rule_entry_from_device({"continue": "20"}) + self.assertEqual(entry["continue_sequence"], 20) + + def test_from_device_bare_collapse(self): + entry = _rule_entry_from_device(None) + self.assertEqual(entry, {}) + + +class TestWantToDevice(unittest.TestCase): + def test_empty(self): + self.assertEqual(_want_to_device([]), {}) + self.assertEqual(_want_to_device(None), {}) + + def test_route_map_without_entries_omitted(self): + self.assertEqual(_want_to_device([{"route_map": "RM1"}]), {}) + + def test_keyed_by_route_map_name(self): + config = [{"route_map": "RM1", "entries": [{"sequence": 10, "action": "permit"}]}] + result = _want_to_device(config) + self.assertIn("10", result["RM1"]["rule"]) + + def test_underscore_route_map_name_stays_verbatim(self): + """Confirmed against vyos-1x: route-map names may legitimately + contain underscores. _want_to_device itself must not alter the + key -- the dict_op-level protection is tested separately in + TestBuildCommands.""" + config = [{"route_map": "my_route_map", "entries": [{"sequence": 10}]}] + result = _want_to_device(config) + self.assertIn("my_route_map", result) + + +class TestSeedRouteMapPlaceholders(unittest.TestCase): + """Regression tests for the confirmed bug: dict_op's fallback + guesses a kebab-cased device key whenever a want key is missing + from have -- correct for schema field names, wrong for a route-map + name (an opaque value that may contain an underscore). Reproduced + directly before this fix: "my_route_map" became "my-route-map" in + the generated command on first creation.""" + + def test_seeds_new_route_map_verbatim(self): + want = {"my_route_map": {"rule": {"10": {}}}} + have = {} + _seed_route_map_placeholders(want, have) + self.assertIn("my_route_map", have) + + def test_seeds_new_rule_with_none_not_empty_dict(self): + """Seeding with {} instead of None would make dict_op think a + presence-only rule already matches and skip emitting its set + command -- the same mistake caught once already this session.""" + want = {"RM1": {"rule": {"10": {}}}} + have = {"RM1": {"rule": {}}} + _seed_route_map_placeholders(want, have) + self.assertIsNone(have["RM1"]["rule"]["10"]) + + def test_does_not_overwrite_existing_entries(self): + want = {"RM1": {"rule": {"10": {}}}} + have = {"RM1": {"rule": {"10": {"action": "permit"}}}} + _seed_route_map_placeholders(want, have) + self.assertEqual(have["RM1"]["rule"]["10"], {"action": "permit"}) + + +class TestDeviceToArgspecFixture(VyOSModuleTestCase): + def test_all_route_maps_parsed(self): + raw = get_running_config(self.mock_vyos) + result = _device_to_argspec(raw) + names = [rm["route_map"] for rm in result] self.assertIn("RM-TEST-EXPORT-POLICY", names) self.assertIn("rm1", names) - # "route-map" itself must NOT appear as a route map name - self.assertNotIn("route-map", names) - - def test_parses_rule_action(self): - self.set_running_config(self.fixture) - result = get_running_config(self.mock_vyos) - rm = next(e for e in result if e["route_map"] == "RM-TEST-EXPORT-POLICY") - rule = rm["entries"][0] - self.assertEqual(rule["action"], "permit") - self.assertEqual(rule["sequence"], 10) - def test_parses_match_peer(self): - self.set_running_config(self.fixture) - result = get_running_config(self.mock_vyos) - rm = next(e for e in result if e["route_map"] == "RM-TEST-EXPORT-POLICY") + def test_prefix_list_and_nexthop_match_parsed(self): + raw = get_running_config(self.mock_vyos) + result = _device_to_argspec(raw) + rm = next(rm for rm in result if rm["route_map"] == "RM-TEST-EXPORT-POLICY") rule = rm["entries"][0] - self.assertEqual(rule["match"]["peer"], "192.0.2.32") + self.assertEqual(rule["match"]["prefix_list"], "PL-MATCH") + self.assertEqual(rule["match"]["ip"]["nexthop_address"], "10.0.0.1") - def test_parses_set_fields(self): - self.set_running_config(self.fixture) - result = get_running_config(self.mock_vyos) - rm = next(e for e in result if e["route_map"] == "RM-TEST-EXPORT-POLICY") + def test_community_add_parsed_as_list(self): + raw = get_running_config(self.mock_vyos) + result = _device_to_argspec(raw) + rm = next(rm for rm in result if rm["route_map"] == "RM-TEST-EXPORT-POLICY") rule = rm["entries"][0] - self.assertEqual(rule["set"]["metric"], "5") - self.assertEqual(rule["set"]["aggregator"]["as"], "100") - self.assertEqual(rule["set"]["as-path"]["exclude"], "111") - - def test_empty_returns_empty_list(self): - self.set_running_config({}) - result = get_running_config(self.mock_vyos) - self.assertEqual(result, []) - - -class TestVyOSRouteMapsWantToApi(unittest.TestCase): - - def test_as_path_exclude_nested(self): - """as_path_exclude maps to nested as-path.exclude.""" - result = _want_to_api_set({"as_path_exclude": "111"}) - self.assertEqual(result["as-path"]["exclude"], "111") - - def test_metric_flat(self): - result = _want_to_api_set({"metric": "5"}) - self.assertEqual(result["metric"], "5") + self.assertEqual(rule["set"]["community"]["add"], ["no-export", "no-advertise"]) - def test_aggregator_as(self): - result = _want_to_api_set({"aggregator": {"as": 100}}) - self.assertEqual(result["aggregator"]["as"], "100") + def test_large_community_none_parsed(self): + raw = get_running_config(self.mock_vyos) + result = _device_to_argspec(raw) + rm1 = next(rm for rm in result if rm["route_map"] == "rm1") + self.assertTrue(rm1["entries"][0]["set"]["large_community"]["none"]) - def test_aggregator_as_underscore(self): - """aggregator.as_ is an alias for aggregator.as.""" - result = _want_to_api_set({"aggregator": {"as_": 100}}) - self.assertEqual(result["aggregator"]["as"], "100") + def test_aggregator_as_parsed(self): + raw = get_running_config(self.mock_vyos) + result = _device_to_argspec(raw) + rm = next(rm for rm in result if rm["route_map"] == "RM-TEST-EXPORT-POLICY") + self.assertEqual(rm["entries"][0]["set"]["aggregator"]["as_"], 100) - def test_large_community_presence_node(self): - result = _want_to_api_set({"large_community": "none"}) - self.assertEqual(result["large-community"], {"none": {}}) + def test_empty_config(self): + self.assertEqual(_device_to_argspec({}), []) + self.assertEqual(_device_to_argspec(None), []) - def test_match_peer(self): - result = _want_to_api_match({"peer": "192.0.2.32"}) - self.assertEqual(result["peer"], "192.0.2.32") - -class TestVyOSRouteMapsBuildCommands(unittest.TestCase): - - def _have_empty(self): - return [] - - def _have_with_rm(self): - return [ +class TestBuildCommands(VyOSModuleTestCase): + def setUp(self): + super().setUp() + self.raw = get_running_config(self.mock_vyos) + + def test_merged_idempotent_against_own_fixture(self): + have = _device_to_argspec(self.raw) + self.assertEqual(build_commands(have, self.raw, "merged"), []) + + def test_replaced_idempotent_against_own_fixture(self): + have = _device_to_argspec(self.raw) + self.assertEqual(build_commands(have, self.raw, "replaced"), []) + + def test_overridden_idempotent_against_own_fixture(self): + have = _device_to_argspec(self.raw) + self.assertEqual(build_commands(have, self.raw, "overridden"), []) + + def test_underscore_route_map_name_not_kebab_cased_on_creation(self): + """The primary confirmed bug this session, reproduced directly + end to end before the fix: "my_route_map" became + "my-route-map" in the generated command on first creation.""" + config = [{"route_map": "my_route_map", "entries": [{"sequence": 10, "action": "permit"}]}] + cmds = build_commands(config, {}, "merged") + self.assertFalse(any("my-route-map" in str(c) for c in cmds)) + self.assertTrue(any("my_route_map" in str(c) for c in cmds)) + + def test_replaced_scoped_to_named_route_map_only(self): + have = _device_to_argspec(self.raw) + config = [ { - "route_map": "RM1", - "entries": [ - { - "sequence": 10, - "action": "permit", - "match": {"peer": "192.0.2.32"}, - "set": {"metric": "5", "as-path": {"exclude": "111"}}, - }, - ], + "route_map": "RM-TEST-EXPORT-POLICY", + "entries": have[0]["entries"], }, ] + cmds = build_commands(config, self.raw, "replaced") + self.assertEqual(cmds, []) + self.assertFalse(any("rm1" in str(c) for c in cmds)) - def test_merged_adds_new_rm(self): + def test_replaced_removes_omitted_field_full_replace_semantic(self): + """Confirms this is the intended "replaced" semantic (matching + every other module this session), not a bug: omitting a field + from a route map named in "replaced" removes it.""" config = [ { - "route_map": "RM-NEW", + "route_map": "RM-TEST-EXPORT-POLICY", "entries": [{"sequence": 10, "action": "permit"}], }, ] - cmds = build_commands(config, self._have_empty(), "merged") - paths = [c[1] for c in cmds] - self.assertIn(["policy", "route-map", "RM-NEW", "rule", "10", "action", "permit"], paths) + cmds = build_commands(config, self.raw, "replaced") + self.assertIn(("delete", _BASE + ["RM-TEST-EXPORT-POLICY", "rule", "10", "set"]), cmds) + self.assertIn(("delete", _BASE + ["RM-TEST-EXPORT-POLICY", "rule", "10", "match"]), cmds) - def test_merged_idempotent(self): + def test_overridden_deletes_omitted_route_map(self): + have = _device_to_argspec(self.raw) config = [ { - "route_map": "RM1", - "entries": [ - { - "sequence": 10, - "action": "permit", - "match": {"peer": "192.0.2.32"}, - "set": {"metric": "5", "as_path_exclude": "111"}, - }, - ], + "route_map": "RM-TEST-EXPORT-POLICY", + "entries": have[0]["entries"], }, ] - cmds = build_commands(config, self._have_with_rm(), "merged") - self.assertEqual(cmds, []) + cmds = build_commands(config, self.raw, "overridden") + self.assertIn(("delete", _BASE + ["rm1"]), cmds) + + def test_deleted_scoped_to_named_route_map(self): + cmds = build_commands([{"route_map": "rm1"}], self.raw, "deleted") + self.assertEqual(cmds, [("delete", _BASE + ["rm1"])]) - def test_deleted_no_config_deletes_all(self): - cmds = build_commands([], self._have_with_rm(), "deleted") - self.assertIn(("delete", ["policy", "route-map"]), cmds) + def test_deleted_no_config_removes_all(self): + cmds = build_commands([], self.raw, "deleted") + self.assertEqual(cmds, [("delete", _BASE)]) - def test_deleted_with_config_deletes_named(self): - config = [{"route_map": "RM1"}] - cmds = build_commands(config, self._have_with_rm(), "deleted") - self.assertIn(("delete", ["policy", "route-map", "RM1"]), cmds) + def test_deleted_named_nonexistent_is_noop(self): + cmds = build_commands([{"route_map": "NONEXISTENT"}], self.raw, "deleted") + self.assertEqual(cmds, []) + + def test_collapsed_single_rule_no_char_iteration_bug(self): + raw_have = {"RM1": {"rule": "10"}} + config = [{"route_map": "RM1", "entries": [{"sequence": 10}]}] + self.assertEqual(build_commands(config, raw_have, "merged"), []) - def test_replaced_deletes_then_resets(self): + def test_merged_new_rule_with_community(self): config = [ { - "route_map": "RM1", - "entries": [{"sequence": 10, "action": "deny"}], + "route_map": "RM-NEW", + "entries": [ + { + "sequence": 10, + "action": "permit", + "set": {"community": {"add": ["no-export"]}}, + }, + ], }, ] - cmds = build_commands(config, self._have_with_rm(), "replaced") - ops = [c[0] for c in cmds] - # delete must come before set - self.assertIn("delete", ops) - self.assertIn("set", ops) - delete_idx = ops.index("delete") - set_idx = ops.index("set") - self.assertLess(delete_idx, set_idx) - - def test_overridden_removes_extra_rm(self): - config = [{"route_map": "RM-NEW", "entries": []}] - have = self._have_with_rm() # has RM1 - cmds = build_commands(config, have, "overridden") - self.assertIn(("delete", ["policy", "route-map", "RM1"]), cmds) + cmds = build_commands(config, {}, "merged") + self.assertIn( + ("set", _BASE + ["RM-NEW", "rule", "10", "set", "community", "add", "no-export"]), + cmds, + ) if __name__ == "__main__": diff --git a/tests/unit/modules/test_vyos_snmp_server.py b/tests/unit/modules/test_vyos_snmp_server.py new file mode 100644 index 0000000..4ae438e --- /dev/null +++ b/tests/unit/modules/test_vyos_snmp_server.py @@ -0,0 +1,644 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, division, print_function + + +__metaclass__ = type + +import unittest + +from unittest.mock import MagicMock + +from ansible_collections.vyos.rest.plugins.modules.vyos_snmp_server import ( + _DEVICE_RENAMES, + ARGUMENT_SPEC, + _derive_key_field, + _device_to_argspec, + _device_to_spec, + _keyed_list_from_device, + _keyed_list_to_device, + _single_from_device, + _single_to_device, + _spec_to_device, + _view_entry_from_device, + _view_entry_to_device, + _want_to_device, + build_commands, + get_running_config, +) + +from .base import load_fixture + + +_BASE = ["service", "snmp"] + + +class VyOSModuleTestCase(unittest.TestCase): + def setUp(self): + self.mock_vyos = MagicMock() + self.fixture = load_fixture("snmp_server_running.json") + self.mock_vyos.get_config = MagicMock(return_value=self.fixture) + + +class TestGetRunningConfig(VyOSModuleTestCase): + def test_returns_raw_device_dict(self): + self.assertEqual(get_running_config(self.mock_vyos), self.fixture) + + def test_empty_config_path_error_returns_empty(self): + self.mock_vyos.get_config = MagicMock( + side_effect=Exception("Configuration under specified path is empty"), + ) + self.assertEqual(get_running_config(self.mock_vyos), {}) + + def test_other_error_reraises(self): + self.mock_vyos.get_config = MagicMock(side_effect=Exception("some other error")) + with self.assertRaises(Exception): + get_running_config(self.mock_vyos) + + +class TestDeviceRenames(unittest.TestCase): + """The one thing a purely structural walk can never infer: field + names that mean something different on the device, and aren't a + mechanical kebab<->snake conversion. Declared once here as a flat + value map, not embedded in ARGUMENT_SPEC and not scattered across + per-section functions.""" + + def test_confirmed_renames_present(self): + for arg_key, device_key in [ + ("communities", "community"), + ("listen_addresses", "listen-address"), + ("snmp_v3", "v3"), + ("authorization_type", "authorization"), + ("clients", "client"), + ("networks", "network"), + ("authentication", "auth"), + ("encrypted_key", "encrypted-password"), + ("plaintext_key", "plaintext-password"), + ("engine_id", "engineid"), + ("groups", "group"), + ("users", "user"), + ("views", "view"), + ("trap_targets", "trap-target"), + ]: + self.assertEqual(_DEVICE_RENAMES.get(arg_key), device_key) + + +class TestSpecToDevice(unittest.TestCase): + """The generic recursive walker that replaced a hand-written to- + device/from-device function pair for every section in this module. + Driven by ARGUMENT_SPEC's own structure (dict -> recurse, list with + options -> a named list keyed by _derive_key_field, list with no + options -> a plain multi-value leaf) plus _DEVICE_RENAMES for the + handful of non-mechanical name differences.""" + + def test_plain_scalar_passes_through_unrenamed(self): + spec = {"contact": {"type": "str"}} + self.assertEqual(_spec_to_device({"contact": "x"}, spec), {"contact": "x"}) + + def test_rename_applied_via_device_renames(self): + spec = {"authorization_type": {"type": "str"}} + result = _spec_to_device({"authorization_type": "rw"}, spec) + self.assertEqual(result, {"authorization": "rw"}) + + def test_nested_dict_recurses(self): + spec = { + "authentication": { + "type": "dict", + "options": {"type": {"type": "str"}, "encrypted_key": {"type": "str"}}, + }, + } + result = _spec_to_device( + {"authentication": {"type": "sha", "encrypted_key": "abc123"}}, + spec, + ) + self.assertEqual(result, {"auth": {"type": "sha", "encrypted-password": "abc123"}}) + + def test_named_list_keyed_by_required_field(self): + spec = { + "communities": { + "type": "list", + "options": {"name": {"type": "str", "required": True}, "port": {"type": "int"}}, + }, + } + result = _spec_to_device( + {"communities": [{"name": "switches", "port": 5}]}, + spec, + ) + self.assertEqual(result, {"community": {"switches": {"port": 5}}}) + + def test_plain_scalar_list_passes_through(self): + spec = {"clients": {"type": "list", "elements": "str"}} + result = _spec_to_device({"clients": ["1.1.1.1"]}, spec) + self.assertEqual(result, {"client": ["1.1.1.1"]}) + + def test_bool_true_is_presence(self): + spec = {"disable": {"type": "bool"}} + self.assertEqual(_spec_to_device({"disable": True}, spec), {"disable": {}}) + + def test_bool_false_omitted(self): + spec = {"disable": {"type": "bool"}} + self.assertEqual(_spec_to_device({"disable": False}, spec), {}) + + def test_non_dict_value_passes_through(self): + self.assertEqual(_spec_to_device("not-a-dict", {}), "not-a-dict") + + +class TestDeviceToSpec(unittest.TestCase): + """The reverse of _spec_to_device -- same structural rules, same + single source of truth for renames.""" + + def test_mechanical_field_matched_via_hyphen_normalization(self): + spec = {"local_stratum": {"type": "str"}} + result = _device_to_spec({"local-stratum": "5"}, spec) + self.assertEqual(result, {"local_stratum": "5"}) + + def test_renamed_field_matched_via_device_renames(self): + spec = {"authorization_type": {"type": "str"}} + result = _device_to_spec({"authorization": "rw"}, spec) + self.assertEqual(result, {"authorization_type": "rw"}) + + def test_nested_dict_recurses(self): + spec = { + "authentication": { + "type": "dict", + "options": {"encrypted_key": {"type": "str"}}, + }, + } + result = _device_to_spec({"auth": {"encrypted-password": "abc123"}}, spec) + self.assertEqual(result, {"authentication": {"encrypted_key": "abc123"}}) + + def test_named_list_keyed_by_required_field(self): + spec = { + "communities": { + "type": "list", + "options": {"name": {"type": "str", "required": True}, "port": {"type": "int"}}, + }, + } + result = _device_to_spec({"community": {"switches": {"port": "5"}}}, spec) + self.assertEqual(result, {"communities": [{"name": "switches", "port": "5"}]}) + + def test_plain_scalar_list_sorted_and_collapse_safe(self): + spec = {"clients": {"type": "list", "elements": "str"}} + result = _device_to_spec({"client": "1.1.1.1"}, spec) + self.assertEqual(result, {"clients": ["1.1.1.1"]}) + + def test_presence_dict_becomes_bool(self): + spec = {"disable": {"type": "bool"}} + self.assertEqual(_device_to_spec({"disable": {}}, spec), {"disable": True}) + + def test_empty_or_non_dict_raw(self): + self.assertEqual(_device_to_spec({}, {}), {}) + self.assertEqual(_device_to_spec(None, {}), {}) + self.assertEqual(_device_to_spec("not-a-dict", {}), {}) + + +class TestKeyedListHelper(unittest.TestCase): + """The generic mechanic every named-list section shares: a list of + dicts identified by one field becomes a device dict keyed by that + field's value. This used to be reimplemented six separate times.""" + + def test_to_device_default_transform_is_autoclean(self): + result = _keyed_list_to_device([{"group": "admins", "mode": "rw"}], "group") + self.assertEqual(result, {"admins": {"mode": "rw"}}) + + def test_to_device_skips_entries_missing_key_field(self): + result = _keyed_list_to_device([{"mode": "rw"}], "group") + self.assertEqual(result, {}) + + def test_to_device_custom_entry_transform_receives_rest_only(self): + seen = {} + + def transform(rest): + seen.update(rest) + return rest + + _keyed_list_to_device([{"name": "switches", "authorization_type": "rw"}], "name", transform) + self.assertNotIn("name", seen) + self.assertEqual(seen, {"authorization_type": "rw"}) + + def test_from_device_default_transform_is_from_device(self): + result = _keyed_list_from_device({"admins": {"mode": "rw"}}, "group") + self.assertEqual(result, [{"group": "admins", "mode": "rw"}]) + + def test_from_device_bare_string_collapse(self): + result = _keyed_list_from_device("admins", "group") + self.assertEqual(result, [{"group": "admins"}]) + + def test_empty(self): + self.assertEqual(_keyed_list_to_device([], "group"), {}) + self.assertEqual(_keyed_list_to_device(None, "group"), {}) + self.assertEqual(_keyed_list_from_device({}, "group"), []) + self.assertEqual(_keyed_list_from_device(None, "group"), []) + + +class TestCommunity(unittest.TestCase): + """authorization_type->authorization and clients/networks-> + client/network are genuine renames (in _DEVICE_RENAMES, not + embedded in ARGUMENT_SPEC); both member fields are confirmed plain + multi-value leaves, passed straight through. Tested via the + generic walker directly against communities' own entry options, + since there's no bespoke per-entry function anymore.""" + + def setUp(self): + self.entry_options = ARGUMENT_SPEC["config"]["options"]["communities"]["options"] + + def test_to_device_authorization_rename(self): + result = _spec_to_device({"authorization_type": "rw"}, self.entry_options) + self.assertEqual(result, {"authorization": "rw"}) + + def test_to_device_clients_networks_rename(self): + result = _spec_to_device( + {"clients": ["1.1.1.1"], "networks": ["10.0.0.0/8"]}, + self.entry_options, + ) + self.assertEqual(result, {"client": ["1.1.1.1"], "network": ["10.0.0.0/8"]}) + + def test_from_device(self): + entry = _device_to_spec( + {"client": ["1.1.1.1", "12.1.1.10"], "authorization": "ro"}, + self.entry_options, + ) + self.assertEqual(entry["clients"], ["1.1.1.1", "12.1.1.10"]) + self.assertEqual(entry["authorization_type"], "ro") + + def test_from_device_single_client_collapse(self): + entry = _device_to_spec({"client": "1.1.1.1"}, self.entry_options) + self.assertEqual(entry["clients"], ["1.1.1.1"]) + + def test_full_pipeline_via_keyed_list_helper(self): + """Confirms the entry-transform and the generic keying mechanic + compose correctly end to end, matching how _spec_to_device + itself calls them for any named-list section.""" + result = _keyed_list_to_device( + [{"name": "switches", "authorization_type": "rw"}], + "name", + lambda rest: _spec_to_device(rest, self.entry_options), + ) + self.assertEqual(result, {"switches": {"authorization": "rw"}}) + + +class TestDeriveKeyField(unittest.TestCase): + """key_field is derived from each section's argspec, not + hand-declared -- every named-list section marks exactly one + suboption required=True (you can't create a community without a + name, and so on), so that's the field identifying each entry.""" + + def test_derives_the_single_required_field(self): + self.assertEqual( + _derive_key_field({"name": {"required": True}, "clients": {"type": "list"}}), + "name", + ) + + def test_raises_if_none_required(self): + with self.assertRaises(ValueError): + _derive_key_field({"clients": {"type": "list"}}) + + def test_raises_if_more_than_one_required(self): + with self.assertRaises(ValueError): + _derive_key_field({"a": {"required": True}, "b": {"required": True}}) + + +class TestTrapTarget(unittest.TestCase): + """Confirmed a genuine tagNode keyed by address on the device, but + the argspec models only a single object -- a documented limitation + (the device supports multiple), preserved as-is. Reuses the same + generic keyed-list mechanic as "a list capped to one entry" rather + than a bespoke pair of functions.""" + + def test_to_device_keyed_by_address(self): + result = _single_to_device({"address": "203.0.113.5", "community": "public"}, "address") + self.assertEqual(result, {"203.0.113.5": {"community": "public"}}) + + def test_to_device_no_address_is_noop(self): + self.assertEqual(_single_to_device({}, "address"), {}) + self.assertEqual(_single_to_device(None, "address"), {}) + + def test_from_device(self): + entry = _single_from_device( + {"203.0.113.5": {"community": "public", "port": "162"}}, + "address", + ) + self.assertEqual(entry["address"], "203.0.113.5") + self.assertEqual(entry["community"], "public") + + def test_from_device_bare_string_collapse(self): + entry = _single_from_device("203.0.113.5", "address") + self.assertEqual(entry, {"address": "203.0.113.5"}) + + def test_from_device_empty_is_none(self): + self.assertIsNone(_single_from_device(None, "address")) + self.assertIsNone(_single_from_device({}, "address")) + + +class TestV3View(unittest.TestCase): + """The confirmed structural bug: "oid" is a genuine tag node (keyed + by the OID value) with its own exclude/mask children -- the + previous implementation read exclude/mask from the wrong nesting + level (directly under the view) and only handled a single oid key + via list(oid_data.keys())[0], silently dropping any others. Like + community, the entry-transform receives only the dict's "rest" + (the key field "view" is stripped by the generic helper first).""" + + def test_to_device_oid_is_nested_tag_node(self): + result = _view_entry_to_device({"oid": "1.3.6.1", "mask": "ff"}) + self.assertEqual(result, {"oid": {"1.3.6.1": {"mask": "ff"}}}) + + def test_to_device_exclude_nested_under_oid_not_view(self): + result = _view_entry_to_device({"oid": "1.3.6.1", "exclude": "1.3.6.1.9"}) + self.assertEqual(result, {"oid": {"1.3.6.1": {"exclude": ["1.3.6.1.9"]}}}) + + def test_to_device_no_oid_is_empty(self): + self.assertEqual(_view_entry_to_device({}), {}) + + def test_from_device_reads_exclude_mask_from_oid_level(self): + """Regression test for the confirmed bug: exclude/mask must be + read from data["oid"][], not data directly.""" + entry = _view_entry_from_device( + {"oid": {"1.3.6.1": {"exclude": ["1.3.6.1.9"], "mask": "ff.ff"}}}, + ) + self.assertEqual(entry["oid"], "1.3.6.1") + self.assertEqual(entry["exclude"], "1.3.6.1.9") + self.assertEqual(entry["mask"], "ff.ff") + + def test_from_device_bare_oid_string_collapse(self): + entry = _view_entry_from_device({"oid": "1.3.6.1"}) + self.assertEqual(entry["oid"], "1.3.6.1") + self.assertNotIn("exclude", entry) + + def test_from_device_no_oid(self): + entry = _view_entry_from_device({}) + self.assertEqual(entry, {}) + + +class TestWantToDevice(unittest.TestCase): + def test_empty(self): + self.assertEqual(_want_to_device({}), {}) + self.assertEqual(_want_to_device(None), {}) + + def test_engine_id_rename(self): + """Confirmed bug: "engineid" (device, one word) vs "engine_id" + (argspec) is not a mechanical kebab<->snake conversion since + there's no hyphen to split -- a genuine rename exception.""" + result = _want_to_device({"snmp_v3": {"engine_id": "0002"}}) + self.assertEqual(result["v3"]["engineid"], "0002") + self.assertNotIn("engine_id", result["v3"]) + + def test_communities_keyed_by_name(self): + config = {"communities": [{"name": "switches", "authorization_type": "rw"}]} + result = _want_to_device(config) + self.assertEqual(result["community"]["switches"], {"authorization": "rw"}) + + def test_generic_scalar_fields(self): + result = _want_to_device({"contact": "admin@example.com", "location": "DC1"}) + self.assertEqual(result, {"contact": "admin@example.com", "location": "DC1"}) + + +class TestDeviceToArgspecFixture(VyOSModuleTestCase): + def test_communities_parsed(self): + have = _device_to_argspec(self.fixture) + names = {c["name"] for c in have["communities"]} + self.assertEqual(names, {"switches", "bridges"}) + + def test_engine_id_parsed(self): + have = _device_to_argspec(self.fixture) + self.assertEqual(have["snmp_v3"]["engine_id"], "000000000000000000000002") + + def test_v3_user_authentication_parsed(self): + have = _device_to_argspec(self.fixture) + user = have["snmp_v3"]["users"][0] + self.assertEqual(user["authentication"]["type"], "sha") + self.assertEqual(user["authentication"]["encrypted_key"], "abc123") + + def test_v3_view_oid_parsed(self): + have = _device_to_argspec(self.fixture) + view = have["snmp_v3"]["views"][0] + self.assertEqual(view["oid"], "1") + + def test_trap_target_parsed(self): + have = _device_to_argspec(self.fixture) + self.assertEqual(have["trap_target"]["address"], "203.0.113.5") + self.assertEqual(have["trap_target"]["community"], "public") + self.assertEqual(have["trap_target"]["port"], 162) + + def test_v3_trap_targets_parsed(self): + have = _device_to_argspec(self.fixture) + target = have["snmp_v3"]["trap_targets"][0] + self.assertEqual(target["address"], "198.51.100.5") + self.assertEqual(target["protocol"], "udp") + self.assertEqual(target["authentication"]["type"], "sha") + + def test_empty_config(self): + self.assertEqual(_device_to_argspec({}), {}) + self.assertEqual(_device_to_argspec(None), {}) + + +class TestBuildCommands(VyOSModuleTestCase): + def test_merged_idempotent_against_own_fixture(self): + have = _device_to_argspec(self.fixture) + self.assertEqual(build_commands(have, self.fixture, "merged"), []) + + def test_replaced_idempotent_against_own_fixture(self): + have = _device_to_argspec(self.fixture) + self.assertEqual(build_commands(have, self.fixture, "replaced"), []) + + def test_overridden_idempotent_against_own_fixture(self): + have = _device_to_argspec(self.fixture) + self.assertEqual(build_commands(have, self.fixture, "overridden"), []) + + def test_underscore_username_not_kebab_cased_on_creation(self): + """Confirmed real bug: dict_op's fallback for a key missing from + have assumed every key is a translatable schema field name -- + but a brand-new tag-node entry (username, community name, any + user-supplied identifier) is opaque data, and "admin_user" was + silently becoming "admin-user" in the generated command on + first creation, before verbatim_keys was wired in.""" + cmds = build_commands( + {"snmp_v3": {"users": [{"user": "admin_user", "group": "admins"}]}}, + {}, + "merged", + ) + self.assertTrue(any("admin_user" in c[1] for c in cmds)) + self.assertFalse(any("admin-user" in c[1] for c in cmds)) + + def test_underscore_names_verbatim_across_every_tag_node_section(self): + """Same regression, covering every section with an opaque + tag-node key in this module, not just v3 users.""" + config = { + "communities": [{"name": "my_community"}], + "snmp_v3": { + "groups": [{"group": "my_group"}], + "views": [{"view": "my_view", "oid": "1.3.6.1"}], + "trap_targets": [{"address": "198.51.100.5"}], + }, + } + cmds = build_commands(config, {}, "merged") + joined = [str(c[1]) for c in cmds] + self.assertTrue(any("my_community" in p for p in joined)) + self.assertTrue(any("my_group" in p for p in joined)) + self.assertTrue(any("my_view" in p for p in joined)) + self.assertFalse(any("my-community" in p for p in joined)) + self.assertFalse(any("my-group" in p for p in joined)) + self.assertFalse(any("my-view" in p for p in joined)) + + def test_underscore_username_removed_verbatim_on_replaced(self): + """The purge path (replaced/overridden) must also match and + delete the opaque key verbatim, not a kebab-cased guess.""" + raw_have = {"v3": {"user": {"admin_user": {"group": "admins"}}}} + cmds = build_commands({"snmp_v3": {"users": []}}, raw_have, "replaced") + self.assertIn(("delete", ["service", "snmp", "v3", "user", "admin_user"]), cmds) + + def test_replaced_does_not_purge_credential_without_new_password(self): + """Confirmed real device-rejected commit: VyOS requires an + auth/privacy node to carry an encrypted-password or plaintext- + password whenever it exists at all. A "replaced" config update + that changes an unrelated field (or nothing) without + re-supplying a password -- which the user can never read back + to re-supply -- must not purge the existing credential out from + under it, or the commit is rejected entirely.""" + raw_have = { + "v3": { + "user": { + "admin_user": { + "auth": {"type": "sha", "encrypted-password": "hash1"}, + "privacy": {"type": "aes", "encrypted-password": "hash2"}, + "group": "admins", + }, + }, + }, + } + config = { + "snmp_v3": { + "users": [ + { + "user": "admin_user", + "group": "admins", + "authentication": {"type": "sha"}, + "privacy": {"type": "aes"}, + }, + ], + }, + } + cmds = build_commands(config, raw_have, "replaced") + self.assertFalse(any("encrypted-password" in str(c) for c in cmds)) + + def test_replaced_still_sets_a_genuinely_new_password(self): + """The credential-protection fix must not mask an intentional + password change -- only fill in what's missing.""" + raw_have = { + "v3": { + "user": { + "admin_user": { + "auth": {"type": "sha", "encrypted-password": "hash1"}, + "group": "admins", + }, + }, + }, + } + config = { + "snmp_v3": { + "users": [ + { + "user": "admin_user", + "group": "admins", + "authentication": {"type": "sha", "plaintext_key": "newpass"}, + }, + ], + }, + } + cmds = build_commands(config, raw_have, "replaced") + expected = ( + "set", + [ + "service", + "snmp", + "v3", + "user", + "admin_user", + "auth", + "plaintext-password", + "newpass", + ], + ) + self.assertIn(expected, cmds) + + def test_plaintext_password_write_path(self): + """The primary confirmed bug fix, exercised end to end: the + device path must use plaintext-password, not plaintext-key.""" + config = { + "snmp_v3": { + "users": [ + { + "user": "newuser", + "authentication": {"type": "sha", "plaintext_key": "abc1234567"}, + }, + ], + }, + } + cmds = build_commands(config, {}, "merged") + self.assertIn( + ("set", _BASE + ["v3", "user", "newuser", "auth", "plaintext-password", "abc1234567"]), + cmds, + ) + self.assertTrue(all("plaintext-key" not in c[1] for c in cmds)) + + def test_replaced_scoped_to_named_sections_only(self): + """Regression test for the three-way key-name-collision bug this + session's investigation found (community/view/group each mean a + tag node at one level and an unrelated scalar leaf at another) -- + replaced must not touch an unrelated section, and must not + crash comparing a scalar have value as if it were a dict.""" + raw_have = { + "community": {"switches": {"authorization": "rw"}, "bridges": {"client": ["1.1.1.1"]}}, + "contact": "old@example.com", + } + config = {"communities": [{"name": "switches", "authorization_type": "rw"}]} + cmds = build_commands(config, raw_have, "replaced") + self.assertIn(("delete", _BASE + ["community", "bridges"]), cmds) + self.assertTrue(all(c[1][: len(_BASE) + 1] != _BASE + ["contact"] for c in cmds)) + + def test_overridden_removes_omitted_scalar_field(self): + raw_have = {"contact": "old@example.com", "community": {"switches": {}}} + config = {"communities": [{"name": "switches"}]} + cmds = build_commands(config, raw_have, "overridden") + self.assertIn(("delete", _BASE + ["contact"]), cmds) + + def test_deleted_no_have_is_noop(self): + self.assertEqual(build_commands({}, {}, "deleted"), []) + + def test_deleted_with_have(self): + self.assertEqual(build_commands({}, {"contact": "x"}, "deleted"), [("delete", _BASE)]) + + def test_collapsed_v3_group_no_char_iteration_bug(self): + """A single v3 group with no other config, collapsed by the + device to a bare group-name string, must not be iterated + character-by-character.""" + raw_have = {"v3": {"group": "admins"}} + config = {"snmp_v3": {"groups": [{"group": "admins"}]}} + self.assertEqual(build_commands(config, raw_have, "merged"), []) + + def test_collapsed_trap_target_no_char_iteration_bug(self): + raw_have = {"trap-target": "203.0.113.5"} + config = {"trap_target": {"address": "203.0.113.5"}} + self.assertEqual(build_commands(config, raw_have, "merged"), []) + + def test_v3_group_view_scalar_not_confused_with_v3_view_tag_node(self): + """Regression test: v3.group..view (a scalar leaf naming + which view the group uses) must never be coerced into a + presence-dict just because "view" is also a genuine tag node + one level up, under v3 itself.""" + raw_have = {"v3": {"group": {"admins": {"view": "all"}}}} + config = {"snmp_v3": {"groups": [{"group": "admins", "view": "all"}]}} + self.assertEqual(build_commands(config, raw_have, "merged"), []) + + def test_v3_user_group_scalar_not_confused_with_v3_group_tag_node(self): + raw_have = {"v3": {"user": {"admin_user": {"group": "admins"}}}} + config = {"snmp_v3": {"users": [{"user": "admin_user", "group": "admins"}]}} + self.assertEqual(build_commands(config, raw_have, "merged"), []) + + def test_trap_target_community_scalar_not_confused_with_community_tag_node(self): + raw_have = {"trap-target": {"203.0.113.5": {"community": "public"}}} + config = {"trap_target": {"address": "203.0.113.5", "community": "public"}} + self.assertEqual(build_commands(config, raw_have, "merged"), []) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/modules/test_vyos_system.py b/tests/unit/modules/test_vyos_system.py new file mode 100644 index 0000000..9345b57 --- /dev/null +++ b/tests/unit/modules/test_vyos_system.py @@ -0,0 +1,93 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, division, print_function + + +__metaclass__ = type + +import unittest + +from ansible_collections.vyos.rest.plugins.module_utils.vyos import ( + dict_op, + owned_config, +) +from ansible_collections.vyos.rest.plugins.modules.vyos_system import ( + _BASE, + ARGUMENT_SPEC, +) + +from .base import load_fixture + + +class TestOwnedConfig(unittest.TestCase): + + def setUp(self): + self.fixture = load_fixture("system_running.json") + + def test_filters_to_owned_keys(self): + result = owned_config(self.fixture, ARGUMENT_SPEC) + self.assertIn("host-name", result) + self.assertIn("domain-name", result) + self.assertIn("name-server", result) + + def test_excludes_non_owned_keys(self): + result = owned_config(self.fixture, ARGUMENT_SPEC) + self.assertNotIn("config-management", result) + self.assertNotIn("console", result) + self.assertNotIn("login", result) + self.assertNotIn("syslog", result) + + +class TestDictOp(unittest.TestCase): + + def _have(self): + return { + "host-name": "vyos150", + "domain-name": "lab.example.com", + "name-server": ["8.8.8.8", "8.8.4.4"], + } + + def test_set_idempotent(self): + want = { + "host_name": "vyos150", + "domain_name": "lab.example.com", + "name_server": ["8.8.8.8", "8.8.4.4"], + } + cmds = dict_op(want, self._have(), _BASE, op="set") + self.assertEqual(cmds, []) + + def test_set_new_value(self): + want = {"domain_name": "new.example.com"} + cmds = dict_op(want, self._have(), _BASE, op="set") + self.assertIn(("set", ["system", "domain-name", "new.example.com"]), cmds) + + def test_set_new_nameserver(self): + want = {"name_server": ["8.8.8.8", "8.8.4.4", "1.1.1.1"]} + cmds = dict_op(want, self._have(), _BASE, op="set") + self.assertIn(("set", ["system", "name-server", "1.1.1.1"]), cmds) + self.assertNotIn(("set", ["system", "name-server", "8.8.8.8"]), cmds) + + def test_delete_scalar(self): + want = {"domain_name": "lab.example.com"} + cmds = dict_op(want, self._have(), _BASE, op="delete") + self.assertIn(("delete", ["system", "domain-name"]), cmds) + + def test_delete_list_item(self): + want = {"name_server": ["8.8.8.8"]} + cmds = dict_op(want, self._have(), _BASE, op="delete") + self.assertIn(("delete", ["system", "name-server", "8.8.8.8"]), cmds) + self.assertNotIn(("delete", ["system", "name-server", "8.8.4.4"]), cmds) + + def test_delete_nonexistent(self): + want = {"domain_name": "other.com"} + have = {"host-name": "vyos150"} + cmds = dict_op(want, have, _BASE, op="delete") + self.assertEqual(cmds, []) + + def test_set_missing_key(self): + want = {"host_name": "vyos150"} + cmds = dict_op(want, {}, _BASE, op="set") + self.assertIn(("set", ["system", "host-name", "vyos150"]), cmds) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/modules/test_vyos_user.py b/tests/unit/modules/test_vyos_user.py index 511ecef..32459ca 100644 --- a/tests/unit/modules/test_vyos_user.py +++ b/tests/unit/modules/test_vyos_user.py @@ -4,169 +4,239 @@ from __future__ import absolute_import, division, print_function __metaclass__ = type -import json -import os import unittest from unittest.mock import MagicMock from ansible_collections.vyos.rest.plugins.modules.vyos_user import ( + _device_to_argspec, + _public_keys_from_device, + _public_keys_to_device, + _user_from_device, + _user_to_device, build_commands, get_running_config, ) - -_BASE = ["system", "login", "user"] +from .base import load_fixture -def load_fixture(filename): - fixtures_dir = os.path.join(os.path.dirname(__file__), "..", "fixtures") - with open(os.path.join(fixtures_dir, filename)) as f: - return json.load(f) +_BASE = ["system", "login", "user"] class VyOSModuleTestCase(unittest.TestCase): def setUp(self): self.mock_vyos = MagicMock() - self.fixture = load_fixture("user_running.json") - self.mock_vyos.get_config = MagicMock(return_value=self.fixture) - + fixture = load_fixture("user_running.json") + self.fixture = fixture.get("user", fixture) + self.mock_vyos.get_config = MagicMock(return_value={"user": self.fixture}) -class TestVyOSUserGetRunning(VyOSModuleTestCase): - def test_parses_users(self): +class TestGetRunningConfig(VyOSModuleTestCase): + def test_unwraps_user_key(self): result = get_running_config(self.mock_vyos) - names = [u["name"] for u in result] - self.assertIn("vyos", names) - self.assertIn("alice", names) + self.assertIn("alice", result) + self.assertIn("vyos", result) - def test_parses_full_name(self): - result = get_running_config(self.mock_vyos) - alice = next(u for u in result if u["name"] == "alice") - self.assertEqual(alice["full_name"], "Alice Smith") + def test_empty_config(self): + self.mock_vyos.get_config = MagicMock(return_value=None) + self.assertEqual(get_running_config(self.mock_vyos), {}) - def test_parses_encrypted_password(self): - result = get_running_config(self.mock_vyos) - alice = next(u for u in result if u["name"] == "alice") - self.assertEqual(alice["encrypted_password"], "$6$def456") - def test_parses_public_keys(self): - result = get_running_config(self.mock_vyos) - alice = next(u for u in result if u["name"] == "alice") - self.assertEqual(len(alice["public_keys"]), 1) - key = alice["public_keys"][0] - self.assertEqual(key["name"], "alice-laptop") - self.assertEqual(key["type"], "ssh-rsa") - self.assertEqual(key["key"], "AAAAB3NzaC1yc2EAAAA") +class TestPublicKeysToDeviceFromDevice(unittest.TestCase): + def test_to_device(self): + result = _public_keys_to_device([{"name": "laptop", "key": "AAAA", "type": "ssh-rsa"}]) + self.assertEqual(result, {"laptop": {"key": "AAAA", "type": "ssh-rsa"}}) + + def test_from_device(self): + result = _public_keys_from_device({"laptop": {"key": "AAAA", "type": "ssh-rsa"}}) + self.assertEqual(result, [{"name": "laptop", "key": "AAAA", "type": "ssh-rsa"}]) + + def test_empty(self): + self.assertEqual(_public_keys_to_device([]), {}) + self.assertEqual(_public_keys_from_device({}), []) - def test_empty_config(self): - self.mock_vyos.get_config = MagicMock(return_value={}) - result = get_running_config(self.mock_vyos) - self.assertEqual(result, []) +class TestUserToDeviceFromDevice(unittest.TestCase): + """Password is the critical case here: it must NEVER appear in + _user_to_device's output (it's handled separately, outside dict_op, + since it can't be compared against have's encrypted-password).""" -class TestVyOSUserBuildCommands(unittest.TestCase): + def test_password_never_enters_dict_op_path(self): + result = _user_to_device({"name": "alice", "password": "secret", "full_name": "Alice"}) + self.assertNotIn("password", result) + self.assertNotIn("plaintext-password", str(result)) + self.assertEqual(result, {"full_name": "Alice"}) - def _have(self): - return [ - {"name": "vyos", "encrypted_password": "$6$abc123"}, + def test_update_password_never_enters_dict_op_path(self): + result = _user_to_device({"name": "alice", "update_password": "on_create"}) + self.assertEqual(result, {}) + + def test_public_keys_wrapped_under_authentication(self): + result = _user_to_device( { "name": "alice", - "full_name": "Alice Smith", - "encrypted_password": "$6$def456", + "public_keys": [{"name": "laptop", "key": "AAAA", "type": "ssh-rsa"}], }, - ] + ) + self.assertEqual( + result, + {"authentication": {"public_keys": {"laptop": {"key": "AAAA", "type": "ssh-rsa"}}}}, + ) - def test_present_new_user_with_password(self): - users = [ - { - "name": "bob", - "full_name": "Bob Jones", - "password": "secret", - "update_password": "always", - }, - ] - cmds = build_commands(users, self._have(), "present") - self.assertIn(("set", _BASE + ["bob", "full-name", "Bob Jones"]), cmds) - self.assertIn( - ("set", _BASE + ["bob", "authentication", "plaintext-password", "secret"]), - cmds, + def test_from_device_encrypted_password_surfaces_as_fact_only(self): + entry = _user_from_device("alice", {"authentication": {"encrypted-password": "hash1"}}) + self.assertEqual(entry["encrypted_password"], "hash1") + self.assertNotIn("password", entry) + + def test_from_device_plaintext_password_placeholder_ignored(self): + """VyOS's write-only placeholder (an empty plaintext-password + marker) must never surface in the argspec-facing output.""" + entry = _user_from_device( + "vyos", + {"authentication": {"encrypted-password": "hash1", "plaintext-password": ""}}, ) + self.assertNotIn("plaintext_password", entry) + self.assertNotIn("password", entry) + + def test_from_device_with_public_keys(self): + entry = _user_from_device( + "alice", + {"authentication": {"public-keys": {"laptop": {"key": "AAAA", "type": "ssh-rsa"}}}}, + ) + self.assertEqual( + entry["public_keys"], + [{"name": "laptop", "key": "AAAA", "type": "ssh-rsa"}], + ) + + +class TestDeviceToArgspecFixture(VyOSModuleTestCase): + def test_alice_full_name_and_keys(self): + have = _device_to_argspec(self.fixture) + alice = next(u for u in have if u["name"] == "alice") + self.assertEqual(alice["full_name"], "Alice Smith") + self.assertEqual(alice["encrypted_password"], "$6$def456") + self.assertEqual(alice["public_keys"][0]["name"], "alice-laptop") - def test_present_update_password_always(self): - users = [{"name": "alice", "password": "newpass", "update_password": "always"}] - cmds = build_commands(users, self._have(), "present") + def test_vyos_user_present_no_plaintext_leak(self): + have = _device_to_argspec(self.fixture) + vyos_user = next(u for u in have if u["name"] == "vyos") + self.assertNotIn("password", vyos_user) + self.assertEqual(vyos_user["encrypted_password"], "$6$abc123") + + def test_empty_config(self): + self.assertEqual(_device_to_argspec({}), []) + self.assertEqual(_device_to_argspec(None), []) + + +class TestBuildCommands(VyOSModuleTestCase): + """Password policy is the module's core correctness risk -- covered + heavily here since it can never be validated via idempotency + (there's no way to compare plaintext to a hash).""" + + def test_present_idempotent_without_password(self): + have = _device_to_argspec(self.fixture) + # drop encrypted_password/keys not settable via argspec anyway; + # use only what a user would actually pass back in + users = [{"name": u["name"], "full_name": u.get("full_name")} for u in have] + cmds = build_commands(users, self.fixture, "present") + self.assertEqual(cmds, []) + + def test_update_password_always_resets_existing_user(self): + cmds = build_commands( + [{"name": "alice", "password": "newpass", "update_password": "always"}], + self.fixture, + "present", + ) self.assertIn( ("set", _BASE + ["alice", "authentication", "plaintext-password", "newpass"]), cmds, ) - def test_present_update_password_on_create_existing(self): - users = [{"name": "alice", "password": "newpass", "update_password": "on_create"}] - cmds = build_commands(users, self._have(), "present") - paths = [c[1] for c in cmds] - self.assertNotIn( - _BASE + ["alice", "authentication", "plaintext-password", "newpass"], - paths, + def test_update_password_on_create_skips_existing_user(self): + cmds = build_commands( + [{"name": "alice", "password": "newpass", "update_password": "on_create"}], + self.fixture, + "present", ) + self.assertTrue(all("plaintext-password" not in c[1] for c in cmds)) - def test_present_update_password_on_create_new(self): - users = [{"name": "bob", "password": "secret", "update_password": "on_create"}] - cmds = build_commands(users, self._have(), "present") + def test_update_password_on_create_sets_for_new_user(self): + cmds = build_commands( + [{"name": "bob", "password": "newpass", "update_password": "on_create"}], + self.fixture, + "present", + ) self.assertIn( - ("set", _BASE + ["bob", "authentication", "plaintext-password", "secret"]), + ("set", _BASE + ["bob", "authentication", "plaintext-password", "newpass"]), cmds, ) - def test_present_idempotent_full_name(self): - users = [{"name": "alice", "full_name": "Alice Smith"}] - cmds = build_commands(users, self._have(), "present") - self.assertEqual(cmds, []) - - def test_present_update_full_name(self): - users = [{"name": "alice", "full_name": "Alice Updated"}] - cmds = build_commands(users, self._have(), "present") + def test_default_update_password_is_always(self): + """default of 'always' must re-set even without explicit + update_password, matching the argspec default.""" + cmds = build_commands([{"name": "alice", "password": "newpass"}], self.fixture, "present") self.assertIn( - ("set", _BASE + ["alice", "full-name", "Alice Updated"]), + ("set", _BASE + ["alice", "authentication", "plaintext-password", "newpass"]), cmds, ) - def test_absent_existing_user(self): - users = [{"name": "alice"}] - cmds = build_commands(users, self._have(), "absent") - self.assertIn(("delete", _BASE + ["alice"]), cmds) + def test_no_password_never_sets_plaintext(self): + cmds = build_commands( + [{"name": "alice", "full_name": "Alice Smith"}], + self.fixture, + "present", + ) + self.assertTrue(all("plaintext-password" not in c[1] for c in cmds)) - def test_absent_nonexistent_user(self): - users = [{"name": "bob"}] - cmds = build_commands(users, self._have(), "absent") + def test_vyos_user_never_deleted(self): + cmds = build_commands([{"name": "vyos"}], self.fixture, "absent") self.assertEqual(cmds, []) - def test_present_public_key(self): - users = [ - { - "name": "alice", - "public_keys": [ - {"name": "new-key", "key": "AAAAB3...", "type": "ssh-ed25519"}, - ], - }, - ] - cmds = build_commands(users, self._have(), "present") - self.assertIn( - ( - "set", - _BASE + ["alice", "authentication", "public-keys", "new-key", "key", "AAAAB3..."], - ), - cmds, + def test_absent_deletes_named_existing_user(self): + cmds = build_commands([{"name": "alice"}], self.fixture, "absent") + self.assertEqual(cmds, [("delete", _BASE + ["alice"])]) + + def test_absent_skips_nonexistent_user(self): + cmds = build_commands([{"name": "nobody"}], self.fixture, "absent") + self.assertEqual(cmds, []) + + def test_present_adds_new_public_key_without_removing_others(self): + """present is additive-only: adding a key for an existing user + must not touch other existing fields.""" + cmds = build_commands( + [ + { + "name": "alice", + "public_keys": [ + {"name": "alice-desktop", "key": "BBBB", "type": "ssh-ed25519"}, + ], + }, + ], + self.fixture, + "present", ) self.assertIn( ( "set", _BASE - + ["alice", "authentication", "public-keys", "new-key", "type", "ssh-ed25519"], + + [ + "alice", + "authentication", + "public-keys", + "alice-desktop", + "key", + "BBBB", + ], ), cmds, ) + def test_collapsed_single_public_key_no_char_iteration_bug(self): + raw_have = {"alice": {"authentication": {"public-keys": "alice-laptop"}}} + users = [{"name": "alice", "public_keys": [{"name": "alice-laptop"}]}] + self.assertEqual(build_commands(users, raw_have, "present"), []) + if __name__ == "__main__": unittest.main() diff --git a/tests/unit/modules/test_vyos_vlan.py b/tests/unit/modules/test_vyos_vlan.py new file mode 100644 index 0000000..58c1be1 --- /dev/null +++ b/tests/unit/modules/test_vyos_vlan.py @@ -0,0 +1,116 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, division, print_function + + +__metaclass__ = type + +import unittest + +from unittest.mock import MagicMock + +from ansible_collections.vyos.rest.plugins.modules.vyos_vlan import ( + build_commands, + get_running_config, +) + +from .base import load_fixture + + +_BASE = ["interfaces", "ethernet"] + + +class TestVyOSVlanGetRunning(unittest.TestCase): + + def setUp(self): + self.mock_vyos = MagicMock() + self.fixture = load_fixture("vlan_running.json") + self.mock_vyos.get_config = MagicMock(return_value=self.fixture) + + def test_parses_vlans(self): + result = get_running_config(self.mock_vyos) + vlan_ids = [v["vlan_id"] for v in result] + self.assertIn(10, vlan_ids) + self.assertIn(20, vlan_ids) + + def test_parses_description(self): + result = get_running_config(self.mock_vyos) + v10 = next(v for v in result if v["vlan_id"] == 10) + self.assertEqual(v10["description"], "VLAN10") + + def test_parses_address(self): + result = get_running_config(self.mock_vyos) + v10 = next(v for v in result if v["vlan_id"] == 10) + self.assertEqual(v10["address"], "192.168.10.1/24") + + def test_parses_multiple_interfaces(self): + result = get_running_config(self.mock_vyos) + v10 = next(v for v in result if v["vlan_id"] == 10) + self.assertIn("eth1", v10["interfaces"]) + self.assertIn("eth2", v10["interfaces"]) + + def test_empty_config(self): + self.mock_vyos.get_config = MagicMock(return_value={}) + result = get_running_config(self.mock_vyos) + self.assertEqual(result, []) + + +class TestVyOSVlanBuildCommands(unittest.TestCase): + + def _have(self): + return [ + { + "vlan_id": 10, + "interfaces": ["eth1"], + "description": "VLAN10", + "address": "192.168.10.1/24", + }, + {"vlan_id": 20, "interfaces": ["eth1"], "description": "VLAN20"}, + ] + + def test_present_new_vlan(self): + config = [{"vlan_id": 30, "description": "VLAN30", "interfaces": ["eth1"]}] + cmds = build_commands(config, [], "present") + self.assertIn( + ("set", _BASE + ["eth1", "vif", "30", "description", "VLAN30"]), + cmds, + ) + + def test_present_idempotent(self): + config = [ + { + "vlan_id": 10, + "description": "VLAN10", + "address": "192.168.10.1/24", + "interfaces": ["eth1"], + }, + {"vlan_id": 20, "description": "VLAN20", "interfaces": ["eth1"]}, + ] + cmds = build_commands(config, self._have(), "present") + self.assertEqual(cmds, []) + + def test_present_update_description(self): + config = [{"vlan_id": 10, "description": "VLAN10-new", "interfaces": ["eth1"]}] + cmds = build_commands(config, self._have(), "present") + self.assertIn( + ("set", _BASE + ["eth1", "vif", "10", "description", "VLAN10-new"]), + cmds, + ) + + def test_absent_existing(self): + config = [{"vlan_id": 10, "interfaces": ["eth1"]}] + cmds = build_commands(config, self._have(), "absent") + self.assertIn(("delete", _BASE + ["eth1", "vif", "10"]), cmds) + + def test_absent_nonexistent(self): + config = [{"vlan_id": 99, "interfaces": ["eth1"]}] + cmds = build_commands(config, self._have(), "absent") + self.assertEqual(cmds, []) + + def test_present_bare_vif(self): + config = [{"vlan_id": 30, "interfaces": ["eth1"]}] + cmds = build_commands(config, [], "present") + self.assertIn(("set", _BASE + ["eth1", "vif", "30"]), cmds) + + +if __name__ == "__main__": + unittest.main() -- cgit v1.2.3