diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 3c11174fd..d22dca8d5 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -26,10 +26,10 @@ jobs: - name: Check out code uses: actions/checkout@v4 - - name: Set up Python 3.10.14 + - name: Set up Python 3.11.9 uses: actions/setup-python@v5 with: - python-version: 3.10.14 + python-version: 3.11.9 - name: Install ansible-base (v${{ matrix.ansible }}) run: pip install https://github.com/ansible/ansible/archive/v${{ matrix.ansible }}.tar.gz --disable-pip-version-check @@ -51,12 +51,12 @@ jobs: strategy: matrix: ansible: [2.14.15, 2.15.10, 2.16.5, 2.17.8] - python: ['3.10'] + python: ['3.11'] steps: - - name: Set up Python 3.10.14 + - name: Set up Python 3.11.9 uses: actions/setup-python@v5 with: - python-version: 3.10.14 + python-version: 3.11.9 - name: Upgrade pip run: | @@ -77,3 +77,51 @@ jobs: - name: Run sanity tests run: ansible-test sanity --docker --python ${{matrix.python}} -v --color --truncate 0 working-directory: /home/runner/.ansible/collections/ansible_collections/cisco/nac_dc_vxlan + + unit: + name: Unit (Ⓐ${{ matrix.ansible }}) + needs: + - build + runs-on: ubuntu-latest + strategy: + matrix: + ansible: [2.14.15, 2.15.10, 2.16.5, 2.17.8] + python: ['3.11'] + steps: + - name: Set up Python 3.11.9 + uses: actions/setup-python@v5 + with: + python-version: 3.11.9 + + - name: Upgrade pip + run: | + pip install --upgrade pip + + - name: Install ansible-base (v${{ matrix.ansible }}) + run: pip install https://github.com/ansible/ansible/archive/v${{ matrix.ansible }}.tar.gz --disable-pip-version-check + + - name: Download migrated collection artifacts + uses: actions/download-artifact@v4 + with: + name: collection-${{ matrix.ansible }} + path: .cache/collection-tarballs + + - name: Install iac-validate (v0.2.7) + run: pip install iac-validate==0.2.7 + + - name: Install coverage (v7.9.2) + run: pip install coverage==7.9.2 + + - name: Install pytest (v8.4.1) + run: pip install pytest==8.4.1 + + - name: Install the collection tarball + run: ansible-galaxy collection install .cache/collection-tarballs/*.tar.gz + + - name: Run unit tests + run: coverage run --source=. -m pytest tests/unit/. -vvvv + working-directory: /home/runner/.ansible/collections/ansible_collections/cisco/nac_dc_vxlan + + - name: Generate coverage report + run: coverage report --include="plugins/*" + working-directory: /home/runner/.ansible/collections/ansible_collections/cisco/nac_dc_vxlan diff --git a/plugins/action/dtc/manage_child_fabric_networks.py b/plugins/action/dtc/manage_child_fabric_networks.py index 199881949..e8dacc86d 100644 --- a/plugins/action/dtc/manage_child_fabric_networks.py +++ b/plugins/action/dtc/manage_child_fabric_networks.py @@ -28,7 +28,7 @@ from ansible.plugins.action import ActionBase from ansible.template import Templar from ansible.errors import AnsibleFileNotFound -from ansible_collections.cisco.nac_dc_vxlan.plugins.filter import version_compare +from ...filter.version_compare import version_compare import re @@ -202,7 +202,7 @@ def run(self, tmp=None, task_vars=None): # Attempt to find and read the template file role_path = task_vars.get('role_path') version = '3.2' - if version_compare.version_compare(nd_major_minor_patch, '3.1.1', '<='): + if version_compare(nd_major_minor_patch, '3.1.1', '<='): version = '3.1' template_path = f"{role_path}{MSD_CHILD_FABRIC_NETWORK_TEMPLATE_PATH}{version}{MSD_CHILD_FABRIC_NETWORK_TEMPLATE}" diff --git a/plugins/action/dtc/manage_child_fabric_vrfs.py b/plugins/action/dtc/manage_child_fabric_vrfs.py index 696f6dec8..2dbbfdc3f 100644 --- a/plugins/action/dtc/manage_child_fabric_vrfs.py +++ b/plugins/action/dtc/manage_child_fabric_vrfs.py @@ -28,7 +28,7 @@ from ansible.plugins.action import ActionBase from ansible.template import Templar from ansible.errors import AnsibleFileNotFound -from ansible_collections.cisco.nac_dc_vxlan.plugins.filter import version_compare +from ...filter.version_compare import version_compare import re @@ -214,7 +214,7 @@ def run(self, tmp=None, task_vars=None): # Attempt to find and read the template file role_path = task_vars.get('role_path') version = '3.2' - if version_compare.version_compare(nd_major_minor_patch, '3.1.1', '<='): + if version_compare(nd_major_minor_patch, '3.1.1', '<='): version = '3.1' template_path = f"{role_path}{MSD_CHILD_FABRIC_VRF_TEMPLATE_PATH}{version}{MSD_CHILD_FABRIC_VRF_TEMPLATE}" diff --git a/plugins/filter/version_compare.py b/plugins/filter/version_compare.py index 8d739ed91..17f12bd86 100644 --- a/plugins/filter/version_compare.py +++ b/plugins/filter/version_compare.py @@ -87,8 +87,10 @@ def version_compare(version1, version2, op): version1 (str): The first version string to compare. version2 (str): The second version string to compare. op (str): The comparison operator as a string. Supported: '==', '!=', '>', '>=', '<', '<='. + Returns: bool: The result of the comparison. + Raises: AnsibleError: If the 'packaging' library is not installed. AnsibleFilterTypeError: If the version arguments are not strings. diff --git a/tests/unit/plugins/action/dtc/__init__.py b/tests/unit/plugins/action/dtc/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/plugins/action/dtc/base_test.py b/tests/unit/plugins/action/dtc/base_test.py new file mode 100644 index 000000000..d33b0c068 --- /dev/null +++ b/tests/unit/plugins/action/dtc/base_test.py @@ -0,0 +1,100 @@ +# Copyright (c) 2025 Cisco Systems, Inc. and its affiliates +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +""" +Base test class for DTC action plugins. +""" + +import unittest +from unittest.mock import MagicMock +import os +import tempfile +import shutil + +from ansible.playbook.task import Task +from ansible.template import Templar +from ansible.vars.manager import VariableManager +from ansible.inventory.manager import InventoryManager +from ansible.parsing.dataloader import DataLoader + + +class ActionModuleTestCase(unittest.TestCase): + """Base test case for action module tests.""" + + def setUp(self): + """Set up test fixtures.""" + self.loader = DataLoader() + self.inventory = InventoryManager(loader=self.loader, sources=[]) + self.variable_manager = VariableManager(loader=self.loader, inventory=self.inventory) + + # Create mock task + self.task = Task() + self.task.args = {} + self.task.action = 'test_action' + + # Create mock connection + self.connection = MagicMock() + + # Create mock play context + self.play_context = MagicMock() + + # Create mock loader + self.loader_mock = MagicMock() + + # Create mock templar + self.templar = Templar(loader=self.loader, variables={}) + + # Create temporary directory for test files + self.temp_dir = tempfile.mkdtemp() + + def tearDown(self): + """Clean up test fixtures.""" + # Clean up temporary directory + if os.path.exists(self.temp_dir): + shutil.rmtree(self.temp_dir) + + def create_temp_file(self, content, filename=None): + """Create a temporary file with given content.""" + if filename is None: + fd, filepath = tempfile.mkstemp(dir=self.temp_dir, text=True) + with os.fdopen(fd, 'w') as f: + f.write(content) + else: + filepath = os.path.join(self.temp_dir, filename) + with open(filepath, 'w') as f: + f.write(content) + return filepath + + def create_action_module(self, action_class, task_args=None): + """Create an action module instance for testing.""" + if task_args: + self.task.args = task_args + + action_module = action_class( + task=self.task, + connection=self.connection, + play_context=self.play_context, + loader=self.loader_mock, + templar=self.templar, + shared_loader_obj=None + ) + + return action_module diff --git a/tests/unit/plugins/action/dtc/test_add_device_check.py b/tests/unit/plugins/action/dtc/test_add_device_check.py new file mode 100644 index 000000000..a0d8863cd --- /dev/null +++ b/tests/unit/plugins/action/dtc/test_add_device_check.py @@ -0,0 +1,369 @@ +# Copyright (c) 2025 Cisco Systems, Inc. and its affiliates +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +""" +Unit tests for add_device_check action plugin. +""" + +import unittest +from unittest.mock import patch + +# Try to import from the plugins directory +try: + from plugins.action.dtc.add_device_check import ActionModule +except ImportError: + # Fallback for when running tests from different locations + import sys + import os + sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..', '..', '..')) + from plugins.action.dtc.add_device_check import ActionModule + +from .base_test import ActionModuleTestCase + + +class TestAddDeviceCheckActionModule(ActionModuleTestCase): + """Test cases for add_device_check action plugin.""" + + def setUp(self): + """Set up test fixtures.""" + super().setUp() + self.action_module = self.create_action_module(ActionModule) + + def test_run_valid_fabric_data(self): + """Test run with valid fabric data.""" + fabric_data = { + 'global': { + 'auth_proto': 'md5' + }, + 'topology': { + 'switches': [ + { + 'name': 'switch1', + 'management': {'ip': '192.168.1.1'}, + 'role': 'spine' + }, + { + 'name': 'switch2', + 'management': {'ip': '192.168.1.2'}, + 'role': 'leaf' + } + ] + } + } + + task_args = { + 'fabric_data': fabric_data + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Mock the run method from parent class + with patch.object(ActionModule.__bases__[0], 'run') as mock_parent_run: + mock_parent_run.return_value = {'changed': False} + + result = action_module.run() + + self.assertFalse(result['failed']) + self.assertNotIn('msg', result) + + def test_run_missing_auth_proto(self): + """Test run when auth_proto is missing.""" + fabric_data = { + 'global': {}, + 'topology': { + 'switches': [ + { + 'name': 'switch1', + 'management': {'ip': '192.168.1.1'}, + 'role': 'spine' + } + ] + } + } + + task_args = { + 'fabric_data': fabric_data + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Mock the run method from parent class + with patch.object(ActionModule.__bases__[0], 'run') as mock_parent_run: + mock_parent_run.return_value = {'changed': False} + + result = action_module.run() + + self.assertTrue(result['failed']) + self.assertIn("Data model path 'vxlan.global.auth_proto' must be defined!", result['msg']) + + def test_run_missing_global_section(self): + """Test run when global section is missing.""" + fabric_data = { + 'topology': { + 'switches': [ + { + 'name': 'switch1', + 'management': {'ip': '192.168.1.1'}, + 'role': 'spine' + } + ] + } + } + + task_args = { + 'fabric_data': fabric_data + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Mock the run method from parent class + with patch.object(ActionModule.__bases__[0], 'run') as mock_parent_run: + mock_parent_run.return_value = {'changed': False} + + # The plugin doesn't handle None from fabric_data.get('global') + # It will throw AttributeError when trying to call get() on None + with self.assertRaises(AttributeError): + action_module.run() + + def test_run_missing_management_in_switch(self): + """Test run when management is missing in switch.""" + fabric_data = { + 'global': { + 'auth_proto': 'md5' + }, + 'topology': { + 'switches': [ + { + 'name': 'switch1', + 'role': 'spine' + # Missing management + } + ] + } + } + + task_args = { + 'fabric_data': fabric_data + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Mock the run method from parent class + with patch.object(ActionModule.__bases__[0], 'run') as mock_parent_run: + mock_parent_run.return_value = {'changed': False} + + result = action_module.run() + + self.assertTrue(result['failed']) + self.assertIn("Data model path 'vxlan.topology.switches.switch1.management' must be defined!", result['msg']) + + def test_run_missing_role_in_switch(self): + """Test run when role is missing in switch.""" + fabric_data = { + 'global': { + 'auth_proto': 'md5' + }, + 'topology': { + 'switches': [ + { + 'name': 'switch1', + 'management': {'ip': '192.168.1.1'} + # Missing role + } + ] + } + } + + task_args = { + 'fabric_data': fabric_data + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Mock the run method from parent class + with patch.object(ActionModule.__bases__[0], 'run') as mock_parent_run: + mock_parent_run.return_value = {'changed': False} + + result = action_module.run() + + self.assertTrue(result['failed']) + self.assertIn("Data model path 'vxlan.topology.switches.switch1.role' must be defined!", result['msg']) + + def test_run_no_switches(self): + """Test run when no switches are defined.""" + fabric_data = { + 'global': { + 'auth_proto': 'md5' + }, + 'topology': { + 'switches': None + } + } + + task_args = { + 'fabric_data': fabric_data + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Mock the run method from parent class + with patch.object(ActionModule.__bases__[0], 'run') as mock_parent_run: + mock_parent_run.return_value = {'changed': False} + + result = action_module.run() + + self.assertFalse(result['failed']) + self.assertNotIn('msg', result) + + def test_run_empty_switches_list(self): + """Test run when switches list is empty.""" + fabric_data = { + 'global': { + 'auth_proto': 'md5' + }, + 'topology': { + 'switches': [] + } + } + + task_args = { + 'fabric_data': fabric_data + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Mock the run method from parent class + with patch.object(ActionModule.__bases__[0], 'run') as mock_parent_run: + mock_parent_run.return_value = {'changed': False} + + result = action_module.run() + + self.assertFalse(result['failed']) + self.assertNotIn('msg', result) + + def test_run_missing_topology_section(self): + """Test run when topology section is missing.""" + fabric_data = { + 'global': { + 'auth_proto': 'md5' + } + } + + task_args = { + 'fabric_data': fabric_data + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Mock the run method from parent class + with patch.object(ActionModule.__bases__[0], 'run') as mock_parent_run: + mock_parent_run.return_value = {'changed': False} + + # The plugin doesn't handle None from fabric_data.get('topology') + # It will throw AttributeError when trying to call get() on None + with self.assertRaises(AttributeError): + action_module.run() + + def test_run_multiple_switches_with_errors(self): + """Test run with multiple switches where some have errors.""" + fabric_data = { + 'global': { + 'auth_proto': 'md5' + }, + 'topology': { + 'switches': [ + { + 'name': 'switch1', + 'management': {'ip': '192.168.1.1'}, + 'role': 'spine' + }, + { + 'name': 'switch2', + 'management': {'ip': '192.168.1.2'} + # Missing role + }, + { + 'name': 'switch3', + 'role': 'leaf' + # Missing management + } + ] + } + } + + task_args = { + 'fabric_data': fabric_data + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Mock the run method from parent class + with patch.object(ActionModule.__bases__[0], 'run') as mock_parent_run: + mock_parent_run.return_value = {'changed': False} + + result = action_module.run() + + self.assertTrue(result['failed']) + # Should fail on the first error encountered (switch2 missing role) + self.assertIn("Data model path 'vxlan.topology.switches.switch2.role' must be defined!", result['msg']) + + def test_run_switches_with_different_auth_proto_values(self): + """Test run with different auth_proto values.""" + auth_proto_values = ['md5', 'sha1', 'cleartext', None] + + for auth_proto in auth_proto_values: + with self.subTest(auth_proto=auth_proto): + fabric_data = { + 'global': { + 'auth_proto': auth_proto + }, + 'topology': { + 'switches': [ + { + 'name': 'switch1', + 'management': {'ip': '192.168.1.1'}, + 'role': 'spine' + } + ] + } + } + + task_args = { + 'fabric_data': fabric_data + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Mock the run method from parent class + with patch.object(ActionModule.__bases__[0], 'run') as mock_parent_run: + mock_parent_run.return_value = {'changed': False} + + result = action_module.run() + + if auth_proto is None: + self.assertTrue(result['failed']) + self.assertIn("Data model path 'vxlan.global.auth_proto' must be defined!", result['msg']) + else: + self.assertFalse(result['failed']) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/unit/plugins/action/dtc/test_diff_model_changes.py b/tests/unit/plugins/action/dtc/test_diff_model_changes.py new file mode 100644 index 000000000..941a38bd8 --- /dev/null +++ b/tests/unit/plugins/action/dtc/test_diff_model_changes.py @@ -0,0 +1,293 @@ +# Copyright (c) 2025 Cisco Systems, Inc. and its affiliates +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +""" +Unit tests for diff_model_changes action plugin. +""" + +import unittest +from unittest.mock import patch +import os + +# Try to import from the plugins directory +try: + from plugins.action.dtc.diff_model_changes import ActionModule +except ImportError: + # Fallback for when running tests from different locations + import sys + import os + sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..', '..', '..')) + from plugins.action.dtc.diff_model_changes import ActionModule + +from .base_test import ActionModuleTestCase + + +class TestDiffModelChangesActionModule(ActionModuleTestCase): + """Test cases for diff_model_changes action plugin.""" + + def setUp(self): + """Set up test fixtures.""" + super().setUp() + self.action_module = self.create_action_module(ActionModule) + + def test_run_previous_file_does_not_exist(self): + """Test run when previous file does not exist.""" + # Create current file + current_file = self.create_temp_file("current content") + previous_file = os.path.join(self.temp_dir, "non_existent_file.txt") + + task_args = { + 'file_name_previous': previous_file, + 'file_name_current': current_file + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Mock the run method from parent class + with patch.object(ActionModule.__bases__[0], 'run') as mock_parent_run: + mock_parent_run.return_value = {'changed': False} + + result = action_module.run() + + self.assertTrue(result['file_data_changed']) + self.assertFalse(result.get('failed', False)) + + def test_run_identical_files(self): + """Test run when files are identical.""" + content = "identical content" + current_file = self.create_temp_file(content) + previous_file = self.create_temp_file(content) + + task_args = { + 'file_name_previous': previous_file, + 'file_name_current': current_file + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Mock the run method from parent class + with patch.object(ActionModule.__bases__[0], 'run') as mock_parent_run: + mock_parent_run.return_value = {'changed': False} + + result = action_module.run() + + self.assertFalse(result['file_data_changed']) + self.assertFalse(result.get('failed', False)) + # Previous file should be deleted when files are identical + self.assertFalse(os.path.exists(previous_file)) + + def test_run_different_files_no_normalization(self): + """Test run when files are different and no normalization is needed.""" + previous_content = "previous content" + current_content = "current content" + + current_file = self.create_temp_file(current_content) + previous_file = self.create_temp_file(previous_content) + + task_args = { + 'file_name_previous': previous_file, + 'file_name_current': current_file + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Mock the run method from parent class + with patch.object(ActionModule.__bases__[0], 'run') as mock_parent_run: + mock_parent_run.return_value = {'changed': False} + + result = action_module.run() + + self.assertTrue(result['file_data_changed']) + self.assertFalse(result.get('failed', False)) + # Previous file should still exist when files are different + self.assertTrue(os.path.exists(previous_file)) + + def test_run_files_identical_after_normalization(self): + """Test run when files are identical after __omit_place_holder__ normalization.""" + previous_content = "key1: __omit_place_holder__abc123\nkey2: value2" + current_content = "key1: __omit_place_holder__xyz789\nkey2: value2" + + current_file = self.create_temp_file(current_content) + previous_file = self.create_temp_file(previous_content) + + task_args = { + 'file_name_previous': previous_file, + 'file_name_current': current_file + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Mock the run method from parent class + with patch.object(ActionModule.__bases__[0], 'run') as mock_parent_run: + mock_parent_run.return_value = {'changed': False} + + result = action_module.run() + + self.assertFalse(result['file_data_changed']) + self.assertFalse(result.get('failed', False)) + # Previous file should be deleted when files are identical after normalization + self.assertFalse(os.path.exists(previous_file)) + + def test_run_files_different_after_normalization(self): + """Test run when files are still different after normalization.""" + previous_content = "key1: __omit_place_holder__abc123\nkey2: value2" + current_content = "key1: __omit_place_holder__xyz789\nkey2: different_value" + + current_file = self.create_temp_file(current_content) + previous_file = self.create_temp_file(previous_content) + + task_args = { + 'file_name_previous': previous_file, + 'file_name_current': current_file + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Mock the run method from parent class + with patch.object(ActionModule.__bases__[0], 'run') as mock_parent_run: + mock_parent_run.return_value = {'changed': False} + + result = action_module.run() + + self.assertTrue(result['file_data_changed']) + self.assertFalse(result.get('failed', False)) + # Previous file should still exist when files are different + self.assertTrue(os.path.exists(previous_file)) + + def test_run_complex_omit_placeholder_patterns(self): + """Test run with complex __omit_place_holder__ patterns.""" + previous_content = """ +key1: __omit_place_holder__abc123 +key2: value2 +key3: __omit_place_holder__def456_suffix +nested: + key4: __omit_place_holder__ghi789 +""" + current_content = """ +key1: __omit_place_holder__xyz999 +key2: value2 +key3: __omit_place_holder__uvw000_suffix +nested: + key4: __omit_place_holder__rst111 +""" + + current_file = self.create_temp_file(current_content) + previous_file = self.create_temp_file(previous_content) + + task_args = { + 'file_name_previous': previous_file, + 'file_name_current': current_file + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Mock the run method from parent class + with patch.object(ActionModule.__bases__[0], 'run') as mock_parent_run: + mock_parent_run.return_value = {'changed': False} + + result = action_module.run() + + self.assertFalse(result['file_data_changed']) + self.assertFalse(result.get('failed', False)) + # Previous file should be deleted when files are identical after normalization + self.assertFalse(os.path.exists(previous_file)) + + @patch('builtins.open', side_effect=OSError("Permission denied")) + def test_run_file_read_error(self, mock_open): + """Test run when file read fails.""" + current_file = self.create_temp_file("current content") + previous_file = self.create_temp_file("previous content") + + task_args = { + 'file_name_previous': previous_file, + 'file_name_current': current_file + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Mock the run method from parent class + with patch.object(ActionModule.__bases__[0], 'run') as mock_parent_run: + mock_parent_run.return_value = {'changed': False} + + with self.assertRaises(OSError): + action_module.run() + + def test_run_multiline_content(self): + """Test run with multiline content containing __omit_place_holder__.""" + previous_content = """line1 +line2 with __omit_place_holder__abc123 +line3 +line4 with __omit_place_holder__def456 +line5""" + + current_content = """line1 +line2 with __omit_place_holder__xyz789 +line3 +line4 with __omit_place_holder__uvw000 +line5""" + + current_file = self.create_temp_file(current_content) + previous_file = self.create_temp_file(previous_content) + + task_args = { + 'file_name_previous': previous_file, + 'file_name_current': current_file + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Mock the run method from parent class + with patch.object(ActionModule.__bases__[0], 'run') as mock_parent_run: + mock_parent_run.return_value = {'changed': False} + + result = action_module.run() + + self.assertFalse(result['file_data_changed']) + self.assertFalse(result.get('failed', False)) + # Previous file should be deleted when files are identical after normalization + self.assertFalse(os.path.exists(previous_file)) + + def test_run_empty_files(self): + """Test run with empty files.""" + current_file = self.create_temp_file("") + previous_file = self.create_temp_file("") + + task_args = { + 'file_name_previous': previous_file, + 'file_name_current': current_file + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Mock the run method from parent class + with patch.object(ActionModule.__bases__[0], 'run') as mock_parent_run: + mock_parent_run.return_value = {'changed': False} + + result = action_module.run() + + self.assertFalse(result['file_data_changed']) + self.assertFalse(result.get('failed', False)) + # Previous file should be deleted when files are identical + self.assertFalse(os.path.exists(previous_file)) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/unit/plugins/action/dtc/test_existing_links_check.py b/tests/unit/plugins/action/dtc/test_existing_links_check.py new file mode 100644 index 000000000..defbf74e1 --- /dev/null +++ b/tests/unit/plugins/action/dtc/test_existing_links_check.py @@ -0,0 +1,555 @@ +# Copyright (c) 2025 Cisco Systems, Inc. and its affiliates +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +""" +Unit tests for existing_links_check action plugin. +""" + +import unittest +from unittest.mock import patch + +# Try to import from the plugins directory +try: + from plugins.action.dtc.existing_links_check import ActionModule +except ImportError: + # Fallback for when running tests from different locations + import sys + import os + sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..', '..', '..')) + from plugins.action.dtc.existing_links_check import ActionModule +from .base_test import ActionModuleTestCase + + +class TestExistingLinksCheckActionModule(ActionModuleTestCase): + """Test cases for existing_links_check action plugin.""" + + def setUp(self): + """Set up test fixtures.""" + super().setUp() + self.action_module = self.create_action_module(ActionModule) + + def test_run_no_existing_links(self): + """Test run when no existing links are present.""" + existing_links = [] + fabric_links = [ + { + 'src_device': 'switch1', + 'src_interface': 'eth1/1', + 'dst_device': 'switch2', + 'dst_interface': 'eth1/1', + 'template': 'int_intra_fabric_link', + 'profile': {} + } + ] + + task_args = { + 'existing_links': existing_links, + 'fabric_links': fabric_links + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Mock the run method from parent class + with patch.object(ActionModule.__bases__[0], 'run') as mock_parent_run: + mock_parent_run.return_value = {'changed': False} + + result = action_module.run() + + self.assertEqual(result['required_links'], fabric_links) + + def test_run_exact_link_match_no_template(self): + """Test run when exact link match exists but no template name.""" + existing_links = [ + { + 'sw1-info': { + 'sw-sys-name': 'switch1', + 'if-name': 'eth1/1' + }, + 'sw2-info': { + 'sw-sys-name': 'switch2', + 'if-name': 'eth1/1' + } + } + ] + + fabric_links = [ + { + 'src_device': 'switch1', + 'src_interface': 'eth1/1', + 'dst_device': 'switch2', + 'dst_interface': 'eth1/1', + 'template': 'int_intra_fabric_link', + 'profile': {} + } + ] + + task_args = { + 'existing_links': existing_links, + 'fabric_links': fabric_links + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Mock the run method from parent class + with patch.object(ActionModule.__bases__[0], 'run') as mock_parent_run: + mock_parent_run.return_value = {'changed': False} + + result = action_module.run() + + # Link should be marked as not required since it exists without template + self.assertEqual(result['required_links'], []) + + def test_run_reverse_link_match_no_template(self): + """Test run when reverse link match exists but no template name.""" + existing_links = [ + { + 'sw1-info': { + 'sw-sys-name': 'switch2', + 'if-name': 'eth1/1' + }, + 'sw2-info': { + 'sw-sys-name': 'switch1', + 'if-name': 'eth1/1' + } + } + ] + + fabric_links = [ + { + 'src_device': 'switch1', + 'src_interface': 'eth1/1', + 'dst_device': 'switch2', + 'dst_interface': 'eth1/1', + 'template': 'int_intra_fabric_link', + 'profile': {} + } + ] + + task_args = { + 'existing_links': existing_links, + 'fabric_links': fabric_links + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Mock the run method from parent class + with patch.object(ActionModule.__bases__[0], 'run') as mock_parent_run: + mock_parent_run.return_value = {'changed': False} + + result = action_module.run() + + # Link should be marked as not required since it exists without template + self.assertEqual(result['required_links'], []) + + def test_run_pre_provision_template_match(self): + """Test run when link matches with pre-provision template.""" + existing_links = [ + { + 'sw1-info': { + 'sw-sys-name': 'switch1', + 'if-name': 'eth1/1' + }, + 'sw2-info': { + 'sw-sys-name': 'switch2', + 'if-name': 'eth1/1' + }, + 'templateName': 'int_pre_provision_intra_fabric_link' + } + ] + + fabric_links = [ + { + 'src_device': 'switch1', + 'src_interface': 'eth1/1', + 'dst_device': 'switch2', + 'dst_interface': 'eth1/1', + 'template': 'int_intra_fabric_link', + 'profile': {} + } + ] + + task_args = { + 'existing_links': existing_links, + 'fabric_links': fabric_links + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Mock the run method from parent class + with patch.object(ActionModule.__bases__[0], 'run') as mock_parent_run: + mock_parent_run.return_value = {'changed': False} + + result = action_module.run() + + # Link should be required since it has pre-provision template + self.assertEqual(result['required_links'], fabric_links) + + def test_run_num_link_template_match(self): + """Test run when link matches with num_link template.""" + existing_links = [ + { + 'sw1-info': { + 'sw-sys-name': 'switch1', + 'if-name': 'eth1/1' + }, + 'sw2-info': { + 'sw-sys-name': 'switch2', + 'if-name': 'eth1/1' + }, + 'templateName': 'int_intra_fabric_num_link', + 'nvPairs': { + 'PEER1_IP': '192.168.1.1', + 'PEER2_IP': '192.168.1.2', + 'ENABLE_MACSEC': 'true' + } + } + ] + + fabric_links = [ + { + 'src_device': 'switch1', + 'src_interface': 'eth1/1', + 'dst_device': 'switch2', + 'dst_interface': 'eth1/1', + 'template': 'int_intra_fabric_link', + 'profile': {} + } + ] + + task_args = { + 'existing_links': existing_links, + 'fabric_links': fabric_links + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Mock the run method from parent class + with patch.object(ActionModule.__bases__[0], 'run') as mock_parent_run: + mock_parent_run.return_value = {'changed': False} + + result = action_module.run() + + # Link should be required with updated template and profile + self.assertEqual(len(result['required_links']), 1) + link = result['required_links'][0] + self.assertEqual(link['template'], 'int_intra_fabric_num_link') + self.assertEqual(link['profile']['peer1_ipv4_addr'], '192.168.1.1') + self.assertEqual(link['profile']['peer2_ipv4_addr'], '192.168.1.2') + self.assertEqual(link['profile']['enable_macsec'], 'true') + + def test_run_num_link_template_no_macsec(self): + """Test run when link matches with num_link template but no MACSEC.""" + existing_links = [ + { + 'sw1-info': { + 'sw-sys-name': 'switch1', + 'if-name': 'eth1/1' + }, + 'sw2-info': { + 'sw-sys-name': 'switch2', + 'if-name': 'eth1/1' + }, + 'templateName': 'int_intra_fabric_num_link', + 'nvPairs': { + 'PEER1_IP': '192.168.1.1', + 'PEER2_IP': '192.168.1.2' + } + } + ] + + fabric_links = [ + { + 'src_device': 'switch1', + 'src_interface': 'eth1/1', + 'dst_device': 'switch2', + 'dst_interface': 'eth1/1', + 'template': 'int_intra_fabric_link', + 'profile': {} + } + ] + + task_args = { + 'existing_links': existing_links, + 'fabric_links': fabric_links + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Mock the run method from parent class + with patch.object(ActionModule.__bases__[0], 'run') as mock_parent_run: + mock_parent_run.return_value = {'changed': False} + + result = action_module.run() + + # Link should be required with updated template and profile + self.assertEqual(len(result['required_links']), 1) + link = result['required_links'][0] + self.assertEqual(link['template'], 'int_intra_fabric_num_link') + self.assertEqual(link['profile']['peer1_ipv4_addr'], '192.168.1.1') + self.assertEqual(link['profile']['peer2_ipv4_addr'], '192.168.1.2') + self.assertEqual(link['profile']['enable_macsec'], 'false') + + def test_run_other_template_match(self): + """Test run when link matches with other template.""" + existing_links = [ + { + 'sw1-info': { + 'sw-sys-name': 'switch1', + 'if-name': 'eth1/1' + }, + 'sw2-info': { + 'sw-sys-name': 'switch2', + 'if-name': 'eth1/1' + }, + 'templateName': 'some_other_template' + } + ] + + fabric_links = [ + { + 'src_device': 'switch1', + 'src_interface': 'eth1/1', + 'dst_device': 'switch2', + 'dst_interface': 'eth1/1', + 'template': 'int_intra_fabric_link', + 'profile': {} + } + ] + + task_args = { + 'existing_links': existing_links, + 'fabric_links': fabric_links + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Mock the run method from parent class + with patch.object(ActionModule.__bases__[0], 'run') as mock_parent_run: + mock_parent_run.return_value = {'changed': False} + + result = action_module.run() + + # Link should be marked as not required since it exists with other template + self.assertEqual(result['required_links'], []) + + def test_run_case_insensitive_matching(self): + """Test run with case insensitive matching.""" + existing_links = [ + { + 'sw1-info': { + 'sw-sys-name': 'SWITCH1', + 'if-name': 'ETH1/1' + }, + 'sw2-info': { + 'sw-sys-name': 'SWITCH2', + 'if-name': 'ETH1/1' + } + } + ] + + fabric_links = [ + { + 'src_device': 'switch1', + 'src_interface': 'eth1/1', + 'dst_device': 'switch2', + 'dst_interface': 'eth1/1', + 'template': 'int_intra_fabric_link', + 'profile': {} + } + ] + + task_args = { + 'existing_links': existing_links, + 'fabric_links': fabric_links + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Mock the run method from parent class + with patch.object(ActionModule.__bases__[0], 'run') as mock_parent_run: + mock_parent_run.return_value = {'changed': False} + + result = action_module.run() + + # Link should be marked as not required due to case insensitive matching + self.assertEqual(result['required_links'], []) + + def test_run_multiple_links_mixed_scenarios(self): + """Test run with multiple links in different scenarios.""" + existing_links = [ + { + 'sw1-info': { + 'sw-sys-name': 'switch1', + 'if-name': 'eth1/1' + }, + 'sw2-info': { + 'sw-sys-name': 'switch2', + 'if-name': 'eth1/1' + } + }, + { + 'sw1-info': { + 'sw-sys-name': 'switch3', + 'if-name': 'eth1/1' + }, + 'sw2-info': { + 'sw-sys-name': 'switch4', + 'if-name': 'eth1/1' + }, + 'templateName': 'int_intra_fabric_num_link', + 'nvPairs': { + 'PEER1_IP': '192.168.1.3', + 'PEER2_IP': '192.168.1.4' + } + } + ] + + fabric_links = [ + { + 'src_device': 'switch1', + 'src_interface': 'eth1/1', + 'dst_device': 'switch2', + 'dst_interface': 'eth1/1', + 'template': 'int_intra_fabric_link', + 'profile': {} + }, + { + 'src_device': 'switch3', + 'src_interface': 'eth1/1', + 'dst_device': 'switch4', + 'dst_interface': 'eth1/1', + 'template': 'int_intra_fabric_link', + 'profile': {} + }, + { + 'src_device': 'switch5', + 'src_interface': 'eth1/1', + 'dst_device': 'switch6', + 'dst_interface': 'eth1/1', + 'template': 'int_intra_fabric_link', + 'profile': {} + } + ] + + task_args = { + 'existing_links': existing_links, + 'fabric_links': fabric_links + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Mock the run method from parent class + with patch.object(ActionModule.__bases__[0], 'run') as mock_parent_run: + mock_parent_run.return_value = {'changed': False} + + result = action_module.run() + + # Should have 2 required links: one updated for num_link template, one new + self.assertEqual(len(result['required_links']), 2) + + # Check that the num_link template was applied + num_link_found = False + for link in result['required_links']: + if link['src_device'] == 'switch3' and link['template'] == 'int_intra_fabric_num_link': + num_link_found = True + self.assertEqual(link['profile']['peer1_ipv4_addr'], '192.168.1.3') + self.assertEqual(link['profile']['peer2_ipv4_addr'], '192.168.1.4') + self.assertEqual(link['profile']['enable_macsec'], 'false') + + self.assertTrue(num_link_found) + + def test_run_missing_sw_info_keys(self): + """Test run when existing links are missing required keys.""" + existing_links = [ + { + 'sw1-info': { + 'if-name': 'eth1/1' + # Missing sw-sys-name + }, + 'sw2-info': { + 'sw-sys-name': 'switch2', + 'if-name': 'eth1/1' + } + } + ] + + fabric_links = [ + { + 'src_device': 'switch1', + 'src_interface': 'eth1/1', + 'dst_device': 'switch2', + 'dst_interface': 'eth1/1', + 'template': 'int_intra_fabric_link', + 'profile': {} + } + ] + + task_args = { + 'existing_links': existing_links, + 'fabric_links': fabric_links + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Mock the run method from parent class + with patch.object(ActionModule.__bases__[0], 'run') as mock_parent_run: + mock_parent_run.return_value = {'changed': False} + + # The plugin has a bug - it checks for key existence in the if condition + # but then tries to access it in the or condition. This raises a KeyError. + with self.assertRaises(KeyError): + action_module.run() + + def test_run_empty_fabric_links(self): + """Test run with empty fabric links.""" + existing_links = [ + { + 'sw1-info': { + 'sw-sys-name': 'switch1', + 'if-name': 'eth1/1' + }, + 'sw2-info': { + 'sw-sys-name': 'switch2', + 'if-name': 'eth1/1' + } + } + ] + + fabric_links = [] + + task_args = { + 'existing_links': existing_links, + 'fabric_links': fabric_links + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Mock the run method from parent class + with patch.object(ActionModule.__bases__[0], 'run') as mock_parent_run: + mock_parent_run.return_value = {'changed': False} + + result = action_module.run() + + self.assertEqual(result['required_links'], []) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/unit/plugins/action/dtc/test_fabric_check_sync.py b/tests/unit/plugins/action/dtc/test_fabric_check_sync.py new file mode 100644 index 000000000..84263d3c9 --- /dev/null +++ b/tests/unit/plugins/action/dtc/test_fabric_check_sync.py @@ -0,0 +1,380 @@ +# Copyright (c) 2025 Cisco Systems, Inc. and its affiliates +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +""" +Unit tests for fabric_check_sync action plugin. +""" + +import unittest +from unittest.mock import patch + +# Try to import from the plugins directory +try: + from plugins.action.dtc.fabric_check_sync import ActionModule +except ImportError: + # Fallback for when running tests from different locations + import sys + import os + sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..', '..', '..')) + from plugins.action.dtc.fabric_check_sync import ActionModule +from .base_test import ActionModuleTestCase + + +class TestFabricCheckSyncActionModule(ActionModuleTestCase): + """Test cases for fabric_check_sync action plugin.""" + + def setUp(self): + """Set up test fixtures.""" + super().setUp() + + def test_run_all_switches_in_sync(self): + """Test run when all switches are in sync.""" + fabric_name = "test_fabric" + + mock_response = { + 'response': { + 'DATA': [ + { + 'logicalName': 'switch1', + 'ccStatus': 'In-Sync' + }, + { + 'logicalName': 'switch2', + 'ccStatus': 'In-Sync' + }, + { + 'logicalName': 'switch3', + 'ccStatus': 'In-Sync' + } + ] + } + } + + task_args = { + 'fabric': fabric_name + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Mock _execute_module method and ActionBase.run + with patch.object(action_module, '_execute_module') as mock_execute, \ + patch('ansible.plugins.action.ActionBase.run') as mock_parent_run: + + mock_parent_run.return_value = {'changed': False, 'failed': False} + mock_execute.return_value = mock_response + + result = action_module.run() + + self.assertFalse(result['changed']) + self.assertFalse(result['failed']) + + # Verify the correct API call was made + mock_execute.assert_called_once_with( + module_name="cisco.dcnm.dcnm_rest", + module_args={ + "method": "GET", + "path": f"/appcenter/cisco/ndfc/api/v1/lan-fabric/rest/control/fabrics/{fabric_name}/inventory/switchesByFabric", + }, + task_vars=None, + tmp=None + ) + + def test_run_switch_out_of_sync(self): + """Test run when at least one switch is out of sync.""" + fabric_name = "test_fabric" + + mock_response = { + 'response': { + 'DATA': [ + { + 'logicalName': 'switch1', + 'ccStatus': 'In-Sync' + }, + { + 'logicalName': 'switch2', + 'ccStatus': 'Out-of-Sync' + }, + { + 'logicalName': 'switch3', + 'ccStatus': 'In-Sync' + } + ] + } + } + + task_args = { + 'fabric': fabric_name + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Mock _execute_module method and ActionBase.run + with patch.object(action_module, '_execute_module') as mock_execute, \ + patch('ansible.plugins.action.ActionBase.run') as mock_parent_run: + + mock_parent_run.return_value = {'changed': False, 'failed': False} + mock_execute.return_value = mock_response + + result = action_module.run() + + self.assertTrue(result['changed']) + self.assertFalse(result['failed']) + + # Verify the correct API call was made + mock_execute.assert_called_once_with( + module_name="cisco.dcnm.dcnm_rest", + module_args={ + "method": "GET", + "path": f"/appcenter/cisco/ndfc/api/v1/lan-fabric/rest/control/fabrics/{fabric_name}/inventory/switchesByFabric", + }, + task_vars=None, + tmp=None + ) + + def test_run_multiple_switches_out_of_sync(self): + """Test run when multiple switches are out of sync.""" + fabric_name = "test_fabric" + + mock_response = { + 'response': { + 'DATA': [ + { + 'logicalName': 'switch1', + 'ccStatus': 'Out-of-Sync' + }, + { + 'logicalName': 'switch2', + 'ccStatus': 'Out-of-Sync' + }, + { + 'logicalName': 'switch3', + 'ccStatus': 'In-Sync' + } + ] + } + } + + task_args = { + 'fabric': fabric_name + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Mock _execute_module method and ActionBase.run + with patch.object(action_module, '_execute_module') as mock_execute, \ + patch('ansible.plugins.action.ActionBase.run') as mock_parent_run: + + mock_parent_run.return_value = {'changed': False, 'failed': False} + mock_execute.return_value = mock_response + + result = action_module.run() + + # Should detect out-of-sync and break early, so changed=True + self.assertTrue(result['changed']) + self.assertFalse(result['failed']) + + def test_run_empty_data(self): + """Test run when DATA is empty.""" + fabric_name = "test_fabric" + + mock_response = { + 'response': { + 'DATA': [] + } + } + + task_args = { + 'fabric': fabric_name + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Mock _execute_module method and ActionBase.run + with patch.object(action_module, '_execute_module') as mock_execute, \ + patch('ansible.plugins.action.ActionBase.run') as mock_parent_run: + + mock_parent_run.return_value = {'changed': False, 'failed': False} + mock_execute.return_value = mock_response + + result = action_module.run() + + self.assertFalse(result['changed']) + self.assertFalse(result['failed']) + + def test_run_no_data_key(self): + """Test run when DATA key is missing.""" + fabric_name = "test_fabric" + + mock_response = { + 'response': { + 'OTHER_KEY': 'value' + } + } + + task_args = { + 'fabric': fabric_name + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Mock _execute_module method and ActionBase.run + with patch.object(action_module, '_execute_module') as mock_execute, \ + patch('ansible.plugins.action.ActionBase.run') as mock_parent_run: + + mock_parent_run.return_value = {'changed': False, 'failed': False} + mock_execute.return_value = mock_response + + result = action_module.run() + + self.assertFalse(result['changed']) + self.assertFalse(result['failed']) + + def test_run_null_data(self): + """Test run when DATA is null.""" + fabric_name = "test_fabric" + + mock_response = { + 'response': { + 'DATA': None + } + } + + task_args = { + 'fabric': fabric_name + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Mock _execute_module method and ActionBase.run + with patch.object(action_module, '_execute_module') as mock_execute, \ + patch('ansible.plugins.action.ActionBase.run') as mock_parent_run: + + mock_parent_run.return_value = {'changed': False, 'failed': False} + mock_execute.return_value = mock_response + + result = action_module.run() + + self.assertFalse(result['changed']) + self.assertFalse(result['failed']) + + def test_run_missing_cc_status(self): + """Test run when ccStatus is missing from switch data.""" + fabric_name = "test_fabric" + + mock_response = { + 'response': { + 'DATA': [ + { + 'logicalName': 'switch1' + # Missing ccStatus + } + ] + } + } + + task_args = { + 'fabric': fabric_name + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Mock _execute_module method and ActionBase.run + with patch.object(action_module, '_execute_module') as mock_execute, \ + patch('ansible.plugins.action.ActionBase.run') as mock_parent_run: + + mock_parent_run.return_value = {'changed': False, 'failed': False} + mock_execute.return_value = mock_response + + # Should raise KeyError when trying to access missing ccStatus + with self.assertRaises(KeyError): + action_module.run() + + def test_run_different_cc_status_values(self): + """Test run with different ccStatus values.""" + fabric_name = "test_fabric" + + # Test various status values + status_values = [ + ('In-Sync', False), # (status, expected_changed) + ('Out-of-Sync', True), + ('Pending', False), + ('Unknown', False), + ('Error', False) + ] + + for status, expected_changed in status_values: + with self.subTest(status=status): + mock_response = { + 'response': { + 'DATA': [ + { + 'logicalName': 'switch1', + 'ccStatus': status + } + ] + } + } + + task_args = { + 'fabric': fabric_name + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Mock _execute_module method and ActionBase.run + with patch.object(action_module, '_execute_module') as mock_execute, \ + patch('ansible.plugins.action.ActionBase.run') as mock_parent_run: + + mock_parent_run.return_value = {'changed': False, 'failed': False} + mock_execute.return_value = mock_response + + result = action_module.run() + + # Only 'Out-of-Sync' should cause changed=True + self.assertEqual(result['changed'], expected_changed) + self.assertFalse(result['failed']) + + def test_run_no_response_key(self): + """Test run when response key is missing.""" + fabric_name = "test_fabric" + + mock_response = {} + + task_args = { + 'fabric': fabric_name + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Mock _execute_module method and ActionBase.run + with patch.object(action_module, '_execute_module') as mock_execute, \ + patch('ansible.plugins.action.ActionBase.run') as mock_parent_run: + + mock_parent_run.return_value = {'changed': False, 'failed': False} + mock_execute.return_value = mock_response + + # Should raise KeyError when trying to access response + with self.assertRaises(KeyError): + action_module.run() + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/unit/plugins/action/dtc/test_fabrics_config_save.py b/tests/unit/plugins/action/dtc/test_fabrics_config_save.py new file mode 100644 index 000000000..a47065bf2 --- /dev/null +++ b/tests/unit/plugins/action/dtc/test_fabrics_config_save.py @@ -0,0 +1,316 @@ +# Copyright (c) 2025 Cisco Systems, Inc. and its affiliates +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +""" +Unit tests for fabrics_config_save action plugin. +""" + +from unittest.mock import patch + +# Try to import from the plugins directory +try: + from plugins.action.dtc.fabrics_config_save import ActionModule +except ImportError: + # Fallback for when running tests from different locations + import sys + import os + sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..', '..', '..')) + from plugins.action.dtc.fabrics_config_save import ActionModule +from .base_test import ActionModuleTestCase + + +class TestFabricsConfigSaveActionModule(ActionModuleTestCase): + """Test cases for fabrics_config_save action plugin.""" + + def test_run_single_fabric_success(self): + """Test run with single fabric successful config save.""" + fabrics = ["fabric1"] + + mock_response = { + 'response': { + 'RETURN_CODE': 200, + 'METHOD': 'POST', + 'MESSAGE': 'OK', + 'DATA': { + 'status': 'Config save is completed' + } + } + } + + task_args = { + 'fabrics': fabrics + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Mock only _execute_module + with patch.object(ActionModule, '_execute_module') as mock_execute: + mock_execute.return_value = mock_response + + result = action_module.run() + + self.assertTrue(result['changed']) + self.assertFalse(result['failed']) + + def test_run_single_fabric_failure(self): + """Test run with single fabric failed config save.""" + fabrics = ["fabric1"] + + mock_response = { + 'msg': { + 'RETURN_CODE': 400, + 'METHOD': 'POST', + 'MESSAGE': 'Bad Request', + 'DATA': { + 'path': 'rest/control/fabrics/fabric1/config-save', + 'Error': 'Bad Request Error', + 'message': 'Config save failed due to error', + 'timestamp': '2025-02-24 13:49:41.024', + 'status': '400' + } + } + } + + task_args = { + 'fabrics': fabrics + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Mock only _execute_module + with patch.object(ActionModule, '_execute_module') as mock_execute: + mock_execute.return_value = mock_response + + result = action_module.run() + + self.assertFalse(result['changed']) + self.assertTrue(result['failed']) + self.assertIn('fabric1', result['msg']) + + def test_run_multiple_fabrics_success(self): + """Test run with multiple fabrics successful config save.""" + fabrics = ["fabric1", "fabric2", "fabric3"] + + mock_response = { + 'response': { + 'RETURN_CODE': 200, + 'METHOD': 'POST', + 'MESSAGE': 'OK', + 'DATA': { + 'status': 'Config save is completed' + } + } + } + + task_args = { + 'fabrics': fabrics + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Mock only _execute_module + with patch.object(ActionModule, '_execute_module') as mock_execute: + mock_execute.return_value = mock_response + + result = action_module.run() + + self.assertTrue(result['changed']) + self.assertFalse(result['failed']) + + def test_run_empty_fabrics_list(self): + """Test run with empty fabrics list.""" + fabrics = [] + + task_args = { + 'fabrics': fabrics + } + + action_module = self.create_action_module(ActionModule, task_args) + + result = action_module.run() + + self.assertFalse(result['changed']) + self.assertFalse(result['failed']) + + def test_run_mixed_success_failure(self): + """Test run with mixed success and failure scenarios.""" + fabrics = ["fabric1", "fabric2"] + + mock_success_response = { + 'response': { + 'RETURN_CODE': 200, + 'METHOD': 'POST', + 'MESSAGE': 'OK', + 'DATA': { + 'status': 'Config save is completed' + } + } + } + + mock_failure_response = { + 'msg': { + 'RETURN_CODE': 400, + 'METHOD': 'POST', + 'MESSAGE': 'Bad Request', + 'DATA': { + 'path': 'rest/control/fabrics/fabric2/config-save', + 'Error': 'Bad Request Error', + 'message': 'Config save failed', + 'timestamp': '2025-02-24 13:49:41.024', + 'status': '400' + } + } + } + + task_args = { + 'fabrics': fabrics + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Mock _execute_module to return different responses + with patch.object(ActionModule, '_execute_module') as mock_execute: + mock_execute.side_effect = [mock_success_response, mock_failure_response] + + result = action_module.run() + + self.assertTrue(result['changed']) # First fabric succeeded + self.assertTrue(result['failed']) # Second fabric failed + + def test_run_no_response_key(self): + """Test run when response key is missing.""" + fabrics = ["fabric1"] + + mock_response = { + 'other_key': 'value' + } + + task_args = { + 'fabrics': fabrics + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Mock only _execute_module + with patch.object(ActionModule, '_execute_module') as mock_execute: + mock_execute.return_value = mock_response + + result = action_module.run() + + self.assertFalse(result['changed']) + self.assertFalse(result['failed']) + + def test_run_non_200_response_code(self): + """Test run with non-200 response code in success response.""" + fabrics = ["fabric1"] + + mock_response = { + 'response': { + 'RETURN_CODE': 201, + 'METHOD': 'POST', + 'MESSAGE': 'Created', + 'DATA': { + 'status': 'Config save is completed' + } + } + } + + task_args = { + 'fabrics': fabrics + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Mock only _execute_module + with patch.object(ActionModule, '_execute_module') as mock_execute: + mock_execute.return_value = mock_response + + result = action_module.run() + + self.assertFalse(result['changed']) # Only 200 sets changed=True + self.assertFalse(result['failed']) + + def test_run_msg_with_200_return_code(self): + """Test run when msg key exists but return code is 200.""" + fabrics = ["fabric1"] + + mock_response = { + 'msg': { + 'RETURN_CODE': 200, + 'METHOD': 'POST', + 'MESSAGE': 'OK', + 'DATA': { + 'status': 'Config save is completed' + } + } + } + + task_args = { + 'fabrics': fabrics + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Mock only _execute_module + with patch.object(ActionModule, '_execute_module') as mock_execute: + mock_execute.return_value = mock_response + + result = action_module.run() + + self.assertFalse(result['changed']) + self.assertFalse(result['failed']) + + @patch('plugins.action.dtc.fabrics_config_save.display') + def test_run_display_messages(self, mock_display): + """Test run displays correct messages.""" + fabrics = ["fabric1", "fabric2"] + + mock_response = { + 'response': { + 'RETURN_CODE': 200, + 'METHOD': 'POST', + 'MESSAGE': 'OK', + 'DATA': { + 'status': 'Config save is completed' + } + } + } + + task_args = { + 'fabrics': fabrics + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Mock only _execute_module + with patch.object(ActionModule, '_execute_module') as mock_execute: + mock_execute.return_value = mock_response + + result = action_module.run() + + # Verify display messages were called for each fabric + expected_calls = [ + 'Executing config-save on Fabric: fabric1', + 'Executing config-save on Fabric: fabric2' + ] + + actual_calls = [call[0][0] for call in mock_display.display.call_args_list] + self.assertEqual(actual_calls, expected_calls) diff --git a/tests/unit/plugins/action/dtc/test_fabrics_deploy.py b/tests/unit/plugins/action/dtc/test_fabrics_deploy.py new file mode 100644 index 000000000..edb01f669 --- /dev/null +++ b/tests/unit/plugins/action/dtc/test_fabrics_deploy.py @@ -0,0 +1,418 @@ +# Copyright (c) 2025 Cisco Systems, Inc. and its affiliates +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +""" +Unit tests for fabrics_deploy action plugin. +""" + +import unittest +from unittest.mock import patch + +# Try to import from the plugins directory +try: + from plugins.action.dtc.fabrics_deploy import ActionModule +except ImportError: + # Fallback for when running tests from different locations + import sys + import os + sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..', '..', '..')) + from plugins.action.dtc.fabrics_deploy import ActionModule +from .base_test import ActionModuleTestCase + + +class TestFabricsDeployActionModule(ActionModuleTestCase): + """Test cases for fabrics_deploy action plugin.""" + + def setUp(self): + """Set up test fixtures.""" + super().setUp() + + def test_run_single_fabric_success(self): + """Test run with single fabric deployment success.""" + fabrics = ["fabric1"] + + mock_response = { + 'response': { + 'RETURN_CODE': 200, + 'METHOD': 'POST', + 'MESSAGE': 'OK', + 'DATA': { + 'status': 'Configuration deployment completed.' + } + } + } + + task_args = { + 'fabrics': fabrics + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Mock _execute_module method and ActionBase.run and display + with patch.object(action_module, '_execute_module') as mock_execute, \ + patch('ansible.plugins.action.ActionBase.run') as mock_parent_run, \ + patch('plugins.action.dtc.fabrics_deploy.display') as mock_display: + + mock_parent_run.return_value = {'changed': False, 'failed': False} + mock_execute.return_value = mock_response + + result = action_module.run() + + self.assertTrue(result['changed']) + self.assertFalse(result['failed']) + + # Verify display message was called + mock_display.display.assert_called_once_with("Executing config-deploy on Fabric: fabric1") + + # Verify the correct API call was made + mock_execute.assert_called_once_with( + module_name="cisco.dcnm.dcnm_rest", + module_args={ + "method": "POST", + "path": "/appcenter/cisco/ndfc/api/v1/lan-fabric/rest/control/fabrics/fabric1/config-deploy?forceShowRun=false", + }, + task_vars=None, + tmp=None + ) + + def test_run_single_fabric_failure(self): + """Test run with single fabric deployment failure.""" + fabrics = ["fabric1"] + + mock_response = { + 'msg': { + 'RETURN_CODE': 400, + 'METHOD': 'POST', + 'MESSAGE': 'Bad Request', + 'DATA': { + 'message': 'Deployment failed due to configuration errors' + } + } + } + + task_args = { + 'fabrics': fabrics + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Mock _execute_module method and ActionBase.run + with patch.object(action_module, '_execute_module') as mock_execute, \ + patch('ansible.plugins.action.ActionBase.run') as mock_parent_run, \ + patch('plugins.action.dtc.fabrics_deploy.display'): + + mock_parent_run.return_value = {'changed': False, 'failed': False} + mock_execute.return_value = mock_response + + result = action_module.run() + + self.assertFalse(result['changed']) + self.assertTrue(result['failed']) + self.assertIn('For fabric fabric1', result['msg']) + + def test_run_multiple_fabrics_success(self): + """Test run with multiple fabric deployments success.""" + fabrics = ["fabric1", "fabric2"] + + mock_response = { + 'response': { + 'RETURN_CODE': 200, + 'METHOD': 'POST', + 'MESSAGE': 'OK', + 'DATA': { + 'status': 'Configuration deployment completed.' + } + } + } + + task_args = { + 'fabrics': fabrics + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Mock _execute_module method and ActionBase.run + with patch.object(action_module, '_execute_module') as mock_execute, \ + patch('ansible.plugins.action.ActionBase.run') as mock_parent_run, \ + patch('plugins.action.dtc.fabrics_deploy.display') as mock_display: + + mock_parent_run.return_value = {'changed': False, 'failed': False} + mock_execute.return_value = mock_response + + result = action_module.run() + + self.assertTrue(result['changed']) + self.assertFalse(result['failed']) + + # Verify display messages were called for both fabrics + expected_calls = [ + unittest.mock.call("Executing config-deploy on Fabric: fabric1"), + unittest.mock.call("Executing config-deploy on Fabric: fabric2") + ] + mock_display.display.assert_has_calls(expected_calls) + + # Verify the correct API calls were made + self.assertEqual(mock_execute.call_count, 2) + + def test_run_multiple_fabrics_continue_on_failure(self): + """Test run with multiple fabrics, continuing on failure.""" + fabrics = ["fabric1", "fabric2"] + + mock_response = { + 'msg': { + 'RETURN_CODE': 400, + 'METHOD': 'POST', + 'MESSAGE': 'Bad Request', + 'DATA': { + 'message': 'Deployment failed' + } + } + } + + task_args = { + 'fabrics': fabrics + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Mock _execute_module method and ActionBase.run + with patch.object(action_module, '_execute_module') as mock_execute, \ + patch('ansible.plugins.action.ActionBase.run') as mock_parent_run, \ + patch('plugins.action.dtc.fabrics_deploy.display'): + + mock_parent_run.return_value = {'changed': False, 'failed': False} + mock_execute.return_value = mock_response + + result = action_module.run() + + self.assertFalse(result['changed']) + self.assertTrue(result['failed']) + + # Should be called for both fabrics since it continues on failure + self.assertEqual(mock_execute.call_count, 2) + + def test_run_mixed_success_failure(self): + """Test run with mixed success and failure responses.""" + fabrics = ["fabric1", "fabric2"] + + success_response = { + 'response': { + 'RETURN_CODE': 200, + 'METHOD': 'POST', + 'MESSAGE': 'OK', + 'DATA': { + 'status': 'Configuration deployment completed.' + } + } + } + + failure_response = { + 'msg': { + 'RETURN_CODE': 400, + 'METHOD': 'POST', + 'MESSAGE': 'Bad Request', + 'DATA': { + 'message': 'Deployment failed' + } + } + } + + task_args = { + 'fabrics': fabrics + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Mock _execute_module method and ActionBase.run + with patch.object(action_module, '_execute_module') as mock_execute, \ + patch('ansible.plugins.action.ActionBase.run') as mock_parent_run, \ + patch('plugins.action.dtc.fabrics_deploy.display'): + + mock_parent_run.return_value = {'changed': False, 'failed': False} + mock_execute.side_effect = [success_response, failure_response] + + result = action_module.run() + + self.assertTrue(result['changed']) # First succeeded + self.assertTrue(result['failed']) # Second failed + + # Should be called twice + self.assertEqual(mock_execute.call_count, 2) + + def test_run_no_response_key(self): + """Test run when response key is missing.""" + fabrics = ["fabric1"] + + mock_response = {} # No response or msg key + + task_args = { + 'fabrics': fabrics + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Mock _execute_module method and ActionBase.run + with patch.object(action_module, '_execute_module') as mock_execute, \ + patch('ansible.plugins.action.ActionBase.run') as mock_parent_run, \ + patch('plugins.action.dtc.fabrics_deploy.display'): + + mock_parent_run.return_value = {'changed': False, 'failed': False} + mock_execute.return_value = mock_response + + result = action_module.run() + + self.assertFalse(result['changed']) + self.assertFalse(result['failed']) + + def test_run_non_200_response_code(self): + """Test run with non-200 response code in success response.""" + fabrics = ["fabric1"] + + mock_response = { + 'response': { + 'RETURN_CODE': 404, + 'METHOD': 'POST', + 'MESSAGE': 'Not Found', + 'DATA': { + 'status': 'Fabric not found.' + } + } + } + + task_args = { + 'fabrics': fabrics + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Mock _execute_module method and ActionBase.run + with patch.object(action_module, '_execute_module') as mock_execute, \ + patch('ansible.plugins.action.ActionBase.run') as mock_parent_run, \ + patch('plugins.action.dtc.fabrics_deploy.display'): + + mock_parent_run.return_value = {'changed': False, 'failed': False} + mock_execute.return_value = mock_response + + result = action_module.run() + + self.assertFalse(result['changed']) + self.assertFalse(result['failed']) + + def test_run_msg_with_200_return_code(self): + """Test run when msg key exists but with 200 return code.""" + fabrics = ["fabric1"] + + mock_response = { + 'msg': { + 'RETURN_CODE': 200, + 'METHOD': 'POST', + 'MESSAGE': 'OK', + 'DATA': { + 'message': 'Success message' + } + } + } + + task_args = { + 'fabrics': fabrics + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Mock _execute_module method and ActionBase.run + with patch.object(action_module, '_execute_module') as mock_execute, \ + patch('ansible.plugins.action.ActionBase.run') as mock_parent_run, \ + patch('plugins.action.dtc.fabrics_deploy.display'): + + mock_parent_run.return_value = {'changed': False, 'failed': False} + mock_execute.return_value = mock_response + + result = action_module.run() + + self.assertFalse(result['changed']) + self.assertFalse(result['failed']) + + def test_run_empty_fabrics_list(self): + """Test run with empty fabrics list.""" + fabrics = [] + + task_args = { + 'fabrics': fabrics + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Mock _execute_module method and ActionBase.run + with patch.object(action_module, '_execute_module') as mock_execute, \ + patch('ansible.plugins.action.ActionBase.run') as mock_parent_run, \ + patch('plugins.action.dtc.fabrics_deploy.display'): + + mock_parent_run.return_value = {'changed': False, 'failed': False} + + result = action_module.run() + + self.assertFalse(result['changed']) + self.assertFalse(result['failed']) + + # No execute_module calls should be made + mock_execute.assert_not_called() + + def test_run_display_messages(self): + """Test that display messages are shown for each fabric.""" + fabrics = ["fabric1", "fabric2"] + + mock_response = { + 'response': { + 'RETURN_CODE': 200, + 'METHOD': 'POST', + 'MESSAGE': 'OK', + 'DATA': { + 'status': 'Configuration deployment completed.' + } + } + } + + task_args = { + 'fabrics': fabrics + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Mock _execute_module method and ActionBase.run + with patch.object(action_module, '_execute_module') as mock_execute, \ + patch('ansible.plugins.action.ActionBase.run') as mock_parent_run, \ + patch('plugins.action.dtc.fabrics_deploy.display') as mock_display: + + mock_parent_run.return_value = {'changed': False, 'failed': False} + mock_execute.return_value = mock_response + + action_module.run() + + # Verify all display messages + expected_calls = [ + unittest.mock.call("Executing config-deploy on Fabric: fabric1"), + unittest.mock.call("Executing config-deploy on Fabric: fabric2") + ] + mock_display.display.assert_has_calls(expected_calls) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/unit/plugins/action/dtc/test_get_poap_data.py b/tests/unit/plugins/action/dtc/test_get_poap_data.py new file mode 100644 index 000000000..7564f6f9c --- /dev/null +++ b/tests/unit/plugins/action/dtc/test_get_poap_data.py @@ -0,0 +1,508 @@ +# Copyright (c) 2025 Cisco Systems, Inc. and its affiliates +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +""" +Unit tests for get_poap_data action plugin. +""" + +import unittest +from unittest.mock import MagicMock, patch + +# Try to import from the plugins directory +try: + from plugins.action.dtc.get_poap_data import ActionModule, POAPDevice +except ImportError: + # Fallback for when running tests from different locations + import sys + import os + sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..', '..', '..')) + from plugins.action.dtc.get_poap_data import ActionModule, POAPDevice +from .base_test import ActionModuleTestCase + + +class TestPOAPDevice(unittest.TestCase): + """Test cases for POAPDevice class.""" + + def setUp(self): + """Set up test fixtures.""" + self.mock_execute_module = MagicMock() + self.mock_task_vars = {} + self.mock_tmp = '/tmp' + + self.params = { + 'model_data': { + 'vxlan': { + 'fabric': { + 'name': 'test_fabric' + }, + 'topology': { + 'switches': [ + { + 'name': 'switch1', + 'management': { + 'management_ipv4_address': '192.168.1.1' + }, + 'role': 'leaf', + 'poap': {'bootstrap': True} + }, + { + 'name': 'switch2', + 'management': { + 'management_ipv4_address': '192.168.1.2' + }, + 'role': 'spine', + 'poap': {'bootstrap': False} + } + ] + } + } + }, + 'action_plugin': self.mock_execute_module, + 'task_vars': self.mock_task_vars, + 'tmp': self.mock_tmp + } + + def test_init_with_valid_params(self): + """Test POAPDevice initialization with valid parameters.""" + device = POAPDevice(self.params) + + self.assertEqual(device.fabric_name, 'test_fabric') + self.assertEqual(len(device.switches), 2) + self.assertEqual(device.switches[0]['name'], 'switch1') + self.assertEqual(device.switches[1]['name'], 'switch2') + self.assertEqual(device.execute_module, self.mock_execute_module) + self.assertEqual(device.task_vars, self.mock_task_vars) + self.assertEqual(device.tmp, self.mock_tmp) + + def test_init_missing_fabric(self): + """Test POAPDevice initialization with missing fabric.""" + params = self.params.copy() + del params['model_data']['vxlan']['fabric'] + + with self.assertRaises(KeyError): + POAPDevice(params) + + def test_init_missing_switches(self): + """Test POAPDevice initialization with missing switches.""" + params = self.params.copy() + del params['model_data']['vxlan']['topology']['switches'] + + with self.assertRaises(KeyError): + POAPDevice(params) + + def test_check_poap_supported_switches_with_poap_enabled(self): + """Test check_poap_supported_switches with POAP enabled switches.""" + device = POAPDevice(self.params) + # Mock _get_discovered to return False (switch not discovered) + device._get_discovered = MagicMock(return_value=False) + device.check_poap_supported_switches() + + self.assertTrue(device.poap_supported_switches) + + def test_check_poap_supported_switches_no_poap_enabled(self): + """Test check_poap_supported_switches with no POAP enabled switches.""" + params = self.params.copy() + for switch in params['model_data']['vxlan']['topology']['switches']: + if 'poap' in switch: + switch['poap']['bootstrap'] = False + + device = POAPDevice(params) + device.check_poap_supported_switches() + + self.assertFalse(device.poap_supported_switches) + + def test_check_poap_supported_switches_no_poap_key(self): + """Test check_poap_supported_switches with no poap key.""" + params = self.params.copy() + for switch in params['model_data']['vxlan']['topology']['switches']: + if 'poap' in switch: + del switch['poap'] + + device = POAPDevice(params) + device.check_poap_supported_switches() + + self.assertFalse(device.poap_supported_switches) + + def test_check_preprovision_supported_switches_with_preprovision(self): + """Test check_preprovision_supported_switches with preprovision enabled.""" + params = self.params.copy() + params['model_data']['vxlan']['topology']['switches'][0]['poap']['preprovision'] = True + + device = POAPDevice(params) + device.check_preprovision_supported_switches() + + self.assertTrue(device.preprovision_supported_switches) + + def test_check_preprovision_supported_switches_no_preprovision(self): + """Test check_preprovision_supported_switches with no preprovision.""" + device = POAPDevice(self.params) + device.check_preprovision_supported_switches() + + self.assertFalse(device.preprovision_supported_switches) + + def test_refresh_discovered_successful(self): + """Test refresh_discovered with successful response.""" + mock_response = { + 'response': [ + { + 'ipAddress': '192.168.1.1', + 'switchRole': 'leaf', + 'logicalName': 'switch1' + } + ] + } + + self.mock_execute_module.return_value = mock_response + + device = POAPDevice(self.params) + device.refresh_discovered() + + self.assertEqual(device.discovered_switch_data, mock_response['response']) + self.mock_execute_module.assert_called_once_with( + module_name="cisco.dcnm.dcnm_inventory", + module_args={ + "fabric": "test_fabric", + "state": "query", + }, + task_vars=self.mock_task_vars, + tmp=self.mock_tmp + ) + + def test_refresh_discovered_no_response(self): + """Test refresh_discovered with no response.""" + self.mock_execute_module.return_value = {} + + device = POAPDevice(self.params) + device.refresh_discovered() + + self.assertEqual(device.discovered_switch_data, []) + + def test_refresh_discovered_empty_response(self): + """Test refresh_discovered with empty response.""" + self.mock_execute_module.return_value = {'response': []} + + device = POAPDevice(self.params) + device.refresh_discovered() + + self.assertEqual(device.discovered_switch_data, []) + + def test_get_discovered_found(self): + """Test _get_discovered when switch is found.""" + device = POAPDevice(self.params) + device.discovered_switch_data = [ + { + 'ipAddress': '192.168.1.1', + 'switchRole': 'leaf', + 'logicalName': 'switch1' + } + ] + + result = device._get_discovered('192.168.1.1', 'leaf', 'switch1') + self.assertTrue(result) + + def test_get_discovered_not_found(self): + """Test _get_discovered when switch is not found.""" + device = POAPDevice(self.params) + device.discovered_switch_data = [] + + result = device._get_discovered('192.168.1.1', 'leaf', 'switch1') + self.assertFalse(result) + + def test_get_discovered_partial_match(self): + """Test _get_discovered with partial match (different role).""" + device = POAPDevice(self.params) + device.discovered_switch_data = [ + { + 'ipAddress': '192.168.1.1', + 'switchRole': 'spine', + 'logicalName': 'switch1' + } + ] + + result = device._get_discovered('192.168.1.1', 'leaf', 'switch1') + self.assertFalse(result) + + def test_check_poap_supported_switches_already_discovered(self): + """Test check_poap_supported_switches when switch is already discovered (continue branch).""" + device = POAPDevice(self.params) + # Mock _get_discovered to return True (switch already discovered) + device._get_discovered = MagicMock(return_value=True) + device.check_poap_supported_switches() + + # Should remain False because discovered switches are skipped + self.assertFalse(device.poap_supported_switches) + + def test_refresh_failed_response(self): + """Test refresh method with failed response to cover elif branch.""" + device = POAPDevice(self.params) + + # Mock execute_module to return failed response + device.execute_module = MagicMock(return_value={ + 'failed': True, + 'msg': {'DATA': 'Some error message'} + }) + + device.refresh() + + self.assertFalse(device.refresh_succeeded) + self.assertEqual(device.refresh_message, 'Some error message') + + def test_split_string_data_json_decode_error(self): + """Test _split_string_data with invalid JSON to cover exception handling.""" + device = POAPDevice(self.params) + + # Test with invalid JSON data + result = device._split_string_data('invalid json data') + + self.assertEqual(result['gateway'], 'NOT_SET') + self.assertEqual(result['modulesModel'], 'NOT_SET') + + def test_split_string_data_valid_json(self): + """Test _split_string_data with valid JSON data.""" + device = POAPDevice(self.params) + + # Test with valid JSON data + valid_json = '{"gateway": "192.168.1.1/24", "modulesModel": ["N9K-X9364v", "N9K-vSUP"]}' + result = device._split_string_data(valid_json) + + self.assertEqual(result['gateway'], '192.168.1.1/24') + self.assertEqual(result['modulesModel'], ['N9K-X9364v', 'N9K-vSUP']) + + +class TestGetPoapDataActionModule(ActionModuleTestCase): + """Test cases for ActionModule.""" + + def test_run_no_poap_supported_switches(self): + """Test run when no switches support POAP.""" + model_data = { + 'vxlan': { + 'fabric': { + 'name': 'test_fabric' + }, + 'topology': { + 'switches': [ + { + 'name': 'switch1', + 'management': {'management_ipv4_address': '192.168.1.1'}, + 'role': 'leaf' + # No poap configuration + } + ] + } + } + } + + task_args = { + 'model_data': model_data + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Mock _execute_module + with patch.object(action_module, '_execute_module') as mock_execute: + mock_execute.return_value = {'response': []} + + result = action_module.run() + + # Should not fail and should return with poap_data + self.assertFalse(result.get('failed', False)) + self.assertIn('poap_data', result) + + def test_run_poap_supported_but_no_poap_data(self): + """Test run when POAP is supported but no data is available.""" + model_data = { + 'vxlan': { + 'fabric': { + 'name': 'test_fabric' + }, + 'topology': { + 'switches': [ + { + 'name': 'switch1', + 'management': {'management_ipv4_address': '192.168.1.1'}, + 'role': 'leaf', + 'poap': {'bootstrap': True} + } + ] + } + } + } + + task_args = { + 'model_data': model_data + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Mock _execute_module calls + with patch.object(action_module, '_execute_module') as mock_execute: + def mock_side_effect(module_name, **kwargs): + if module_name == "cisco.dcnm.dcnm_inventory": + return {'response': []} # refresh_discovered + elif module_name == "cisco.dcnm.dcnm_rest": + return {'response': {'RETURN_CODE': 200, 'DATA': []}} # refresh (empty POAP data) + + mock_execute.side_effect = mock_side_effect + + result = action_module.run() + + self.assertTrue(result['failed']) + self.assertIn('POAP is enabled', result['message']) + + def test_run_poap_supported_refresh_successful(self): + """Test run when POAP refresh is successful.""" + model_data = { + 'vxlan': { + 'fabric': { + 'name': 'test_fabric' + }, + 'topology': { + 'switches': [ + { + 'name': 'switch1', + 'management': {'management_ipv4_address': '192.168.1.1'}, + 'role': 'leaf', + 'poap': {'bootstrap': True} + } + ] + } + } + } + + task_args = { + 'model_data': model_data + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Create valid POAP data that matches what _parse_poap_data expects + poap_data = [ + { + 'serialNumber': 'ABC123', + 'model': 'N9K-C9300v', + 'version': '9.3(8)', + 'data': '{"gateway": "192.168.1.1/24", "modulesModel": ["N9K-X9364v", "N9K-vSUP"]}' + } + ] + + # Mock _execute_module calls + with patch.object(action_module, '_execute_module') as mock_execute: + def mock_side_effect(module_name, **kwargs): + if module_name == "cisco.dcnm.dcnm_inventory": + return {'response': []} # refresh_discovered + elif module_name == "cisco.dcnm.dcnm_rest": + return {'response': {'RETURN_CODE': 200, 'DATA': poap_data}} # refresh + + mock_execute.side_effect = mock_side_effect + + result = action_module.run() + + self.assertFalse(result.get('failed', False)) + self.assertIn('poap_data', result) + # Verify the parsed structure + self.assertIn('ABC123', result['poap_data']) + self.assertEqual(result['poap_data']['ABC123']['model'], 'N9K-C9300v') + + @patch('plugins.action.dtc.get_poap_data.POAPDevice') + def test_run_poap_supported_refresh_failed_dhcp_message(self, mock_poap_device): + """Test run when POAP refresh fails with DHCP message.""" + model_data = { + 'vxlan': {'fabric': {'name': 'test_fabric'}} + } + + task_args = { + 'model_data': model_data + } + + # Mock POAPDevice instance + mock_workflow = MagicMock() + mock_workflow.poap_supported_switches = True + mock_workflow.refresh_succeeded = False + mock_workflow.refresh_message = "Please enable the DHCP in Fabric Settings to start the bootstrap" + mock_workflow.poap_data = {} + mock_poap_device.return_value = mock_workflow + + action_module = self.create_action_module(ActionModule, task_args) + + result = action_module.run() + + # The logic flaw in the plugin causes this to still fail with "Unrecognized Failure" + # because the else clause applies to the second if statement, not both + self.assertTrue(result.get('failed', False)) + self.assertIn('Unrecognized Failure', result['message']) + + @patch('plugins.action.dtc.get_poap_data.POAPDevice') + def test_run_poap_supported_refresh_failed_invalid_fabric(self, mock_poap_device): + """Test run when POAP refresh fails with invalid fabric message.""" + model_data = { + 'vxlan': {'fabric': {'name': 'test_fabric'}} + } + + task_args = { + 'model_data': model_data + } + + # Mock POAPDevice instance + mock_workflow = MagicMock() + mock_workflow.poap_supported_switches = True + mock_workflow.refresh_succeeded = False + mock_workflow.refresh_message = "Invalid Fabric" + mock_workflow.poap_data = {} # Empty dict will still cause failure due to "not results['poap_data']" check + mock_poap_device.return_value = mock_workflow + + action_module = self.create_action_module(ActionModule, task_args) + + result = action_module.run() + + # Should fail because empty poap_data fails the "not results['poap_data']" check + # even though the Invalid Fabric error message is ignored + self.assertTrue(result.get('failed', False)) + self.assertIn('POAP is enabled on at least one switch', result['message']) + + @patch('plugins.action.dtc.get_poap_data.POAPDevice') + def test_run_poap_supported_refresh_failed_unrecognized_error(self, mock_poap_device): + """Test run when POAP refresh fails with unrecognized error.""" + model_data = { + 'vxlan': {'fabric': {'name': 'test_fabric'}} + } + + task_args = { + 'model_data': model_data + } + + # Mock POAPDevice instance + mock_workflow = MagicMock() + mock_workflow.poap_supported_switches = True + mock_workflow.refresh_succeeded = False + mock_workflow.refresh_message = "Some unrecognized error" + mock_workflow.poap_data = {} + mock_poap_device.return_value = mock_workflow + + action_module = self.create_action_module(ActionModule, task_args) + + result = action_module.run() + + self.assertTrue(result['failed']) + self.assertIn('Unrecognized Failure', result['message']) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/unit/plugins/action/dtc/test_links_filter_and_remove.py b/tests/unit/plugins/action/dtc/test_links_filter_and_remove.py new file mode 100644 index 000000000..65e1bfbf5 --- /dev/null +++ b/tests/unit/plugins/action/dtc/test_links_filter_and_remove.py @@ -0,0 +1,487 @@ +# Copyright (c) 2025 Cisco Systems, Inc. and its affiliates +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +""" +Unit tests for links_filter_and_remove action plugin. +""" + +# Try to import from the plugins directory +try: + from plugins.action.dtc.links_filter_and_remove import ActionModule +except ImportError: + # Fallback for when running tests from different locations + import sys + import os + sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..', '..', '..')) + from plugins.action.dtc.links_filter_and_remove import ActionModule +from .base_test import ActionModuleTestCase + + +class TestLinksFilterAndRemoveActionModule(ActionModuleTestCase): + """Test cases for links_filter_and_remove action plugin.""" + + def test_run_exact_link_match_to_remove(self): + """Test run with exact link match that should be removed.""" + existing_links = [ + { + 'templateName': 'int_intra_fabric_num_link', + 'fabricName': 'test-fabric', + 'sw1-info': { + 'sw-sys-name': 'switch1', + 'if-name': 'Ethernet1/1' + }, + 'sw2-info': { + 'sw-sys-name': 'switch2', + 'if-name': 'Ethernet1/2' + } + } + ] + + fabric_links = [ + { + 'src_device': 'switch3', + 'src_interface': 'Ethernet1/3', + 'dst_device': 'switch4', + 'dst_interface': 'Ethernet1/4' + } + ] + + task_args = { + 'existing_links': existing_links, + 'fabric_links': fabric_links + } + + action_module = self.create_action_module(ActionModule, task_args) + + result = action_module.run() + + self.assertEqual(len(result['links_to_be_removed']), 1) + self.assertEqual(result['links_to_be_removed'][0]['dst_fabric'], 'test-fabric') + + def test_run_exact_link_match_keep_link(self): + """Test run with exact link match that should be kept.""" + existing_links = [ + { + 'templateName': 'int_intra_fabric_num_link', + 'fabricName': 'test-fabric', + 'sw1-info': { + 'sw-sys-name': 'switch1', + 'if-name': 'Ethernet1/1' + }, + 'sw2-info': { + 'sw-sys-name': 'switch2', + 'if-name': 'Ethernet1/2' + } + } + ] + + fabric_links = [ + { + 'src_device': 'switch1', + 'src_interface': 'Ethernet1/1', + 'dst_device': 'switch2', + 'dst_interface': 'Ethernet1/2' + } + ] + + task_args = { + 'existing_links': existing_links, + 'fabric_links': fabric_links + } + + action_module = self.create_action_module(ActionModule, task_args) + + result = action_module.run() + + self.assertEqual(len(result['links_to_be_removed']), 0) + + def test_run_reverse_link_match_keep_link(self): + """Test run with reverse link match that should be kept.""" + existing_links = [ + { + 'templateName': 'int_intra_fabric_num_link', + 'fabricName': 'test-fabric', + 'sw1-info': { + 'sw-sys-name': 'switch1', + 'if-name': 'Ethernet1/1' + }, + 'sw2-info': { + 'sw-sys-name': 'switch2', + 'if-name': 'Ethernet1/2' + } + } + ] + + fabric_links = [ + { + 'src_device': 'switch2', + 'src_interface': 'Ethernet1/2', + 'dst_device': 'switch1', + 'dst_interface': 'Ethernet1/1' + } + ] + + task_args = { + 'existing_links': existing_links, + 'fabric_links': fabric_links + } + + action_module = self.create_action_module(ActionModule, task_args) + + result = action_module.run() + + self.assertEqual(len(result['links_to_be_removed']), 0) + + def test_run_case_insensitive_matching(self): + """Test run verifies case insensitive matching.""" + existing_links = [ + { + 'templateName': 'int_intra_fabric_num_link', + 'fabricName': 'test-fabric', + 'sw1-info': { + 'sw-sys-name': 'SWITCH1', + 'if-name': 'ETHERNET1/1' + }, + 'sw2-info': { + 'sw-sys-name': 'SWITCH2', + 'if-name': 'ETHERNET1/2' + } + } + ] + + fabric_links = [ + { + 'src_device': 'switch1', + 'src_interface': 'ethernet1/1', + 'dst_device': 'switch2', + 'dst_interface': 'ethernet1/2' + } + ] + + task_args = { + 'existing_links': existing_links, + 'fabric_links': fabric_links + } + + action_module = self.create_action_module(ActionModule, task_args) + + result = action_module.run() + + self.assertEqual(len(result['links_to_be_removed']), 0) + + def test_run_pre_provision_template_match(self): + """Test run with pre-provision template that should be considered.""" + existing_links = [ + { + 'templateName': 'int_pre_provision_intra_fabric_link', + 'fabricName': 'test-fabric', + 'sw1-info': { + 'sw-sys-name': 'switch1', + 'if-name': 'Ethernet1/1' + }, + 'sw2-info': { + 'sw-sys-name': 'switch2', + 'if-name': 'Ethernet1/2' + } + } + ] + + fabric_links = [ + { + 'src_device': 'switch3', + 'src_interface': 'Ethernet1/3', + 'dst_device': 'switch4', + 'dst_interface': 'Ethernet1/4' + } + ] + + task_args = { + 'existing_links': existing_links, + 'fabric_links': fabric_links + } + + action_module = self.create_action_module(ActionModule, task_args) + + result = action_module.run() + + self.assertEqual(len(result['links_to_be_removed']), 1) + + def test_run_other_template_ignored(self): + """Test run with other template types that should be ignored.""" + existing_links = [ + { + 'templateName': 'other_template', + 'fabricName': 'test-fabric', + 'sw1-info': { + 'sw-sys-name': 'switch1', + 'if-name': 'Ethernet1/1' + }, + 'sw2-info': { + 'sw-sys-name': 'switch2', + 'if-name': 'Ethernet1/2' + } + } + ] + + fabric_links = [ + { + 'src_device': 'switch3', + 'src_interface': 'Ethernet1/3', + 'dst_device': 'switch4', + 'dst_interface': 'Ethernet1/4' + } + ] + + task_args = { + 'existing_links': existing_links, + 'fabric_links': fabric_links + } + + action_module = self.create_action_module(ActionModule, task_args) + + result = action_module.run() + + self.assertEqual(len(result['links_to_be_removed']), 0) + + def test_run_missing_template_name(self): + """Test run with missing template name should be ignored.""" + existing_links = [ + { + 'fabricName': 'test-fabric', + 'sw1-info': { + 'sw-sys-name': 'switch1', + 'if-name': 'Ethernet1/1' + }, + 'sw2-info': { + 'sw-sys-name': 'switch2', + 'if-name': 'Ethernet1/2' + } + } + ] + + fabric_links = [ + { + 'src_device': 'switch3', + 'src_interface': 'Ethernet1/3', + 'dst_device': 'switch4', + 'dst_interface': 'Ethernet1/4' + } + ] + + task_args = { + 'existing_links': existing_links, + 'fabric_links': fabric_links + } + + action_module = self.create_action_module(ActionModule, task_args) + + result = action_module.run() + + self.assertEqual(len(result['links_to_be_removed']), 0) + + def test_run_missing_fabric_name_skip_removal(self): + """Test run with missing fabric name should skip removal.""" + existing_links = [ + { + 'templateName': 'int_intra_fabric_num_link', + 'sw1-info': { + 'sw-sys-name': 'switch1', + 'if-name': 'Ethernet1/1' + }, + 'sw2-info': { + 'sw-sys-name': 'switch2', + 'if-name': 'Ethernet1/2' + } + } + ] + + fabric_links = [ + { + 'src_device': 'switch3', + 'src_interface': 'Ethernet1/3', + 'dst_device': 'switch4', + 'dst_interface': 'Ethernet1/4' + } + ] + + task_args = { + 'existing_links': existing_links, + 'fabric_links': fabric_links + } + + action_module = self.create_action_module(ActionModule, task_args) + + result = action_module.run() + + self.assertEqual(len(result['links_to_be_removed']), 0) + + def test_run_missing_sw_info_keys(self): + """Test run with missing sw-info keys should raise KeyError.""" + existing_links = [ + { + 'templateName': 'int_intra_fabric_num_link', + 'fabricName': 'test-fabric', + 'sw1-info': { + 'sw-sys-name': 'switch1' + # Missing 'if-name' + }, + 'sw2-info': { + 'sw-sys-name': 'switch2', + 'if-name': 'Ethernet1/2' + } + } + ] + + fabric_links = [ + { + 'src_device': 'switch1', + 'src_interface': 'Ethernet1/1', + 'dst_device': 'switch2', + 'dst_interface': 'Ethernet1/2' + } + ] + + task_args = { + 'existing_links': existing_links, + 'fabric_links': fabric_links + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Should raise KeyError due to missing 'if-name' key + with self.assertRaises(KeyError): + action_module.run() + + def test_run_empty_existing_links(self): + """Test run with empty existing links list.""" + existing_links = [] + + fabric_links = [ + { + 'src_device': 'switch1', + 'src_interface': 'Ethernet1/1', + 'dst_device': 'switch2', + 'dst_interface': 'Ethernet1/2' + } + ] + + task_args = { + 'existing_links': existing_links, + 'fabric_links': fabric_links + } + + action_module = self.create_action_module(ActionModule, task_args) + + result = action_module.run() + + self.assertEqual(len(result['links_to_be_removed']), 0) + + def test_run_empty_fabric_links(self): + """Test run with empty fabric links list.""" + existing_links = [ + { + 'templateName': 'int_intra_fabric_num_link', + 'fabricName': 'test-fabric', + 'sw1-info': { + 'sw-sys-name': 'switch1', + 'if-name': 'Ethernet1/1' + }, + 'sw2-info': { + 'sw-sys-name': 'switch2', + 'if-name': 'Ethernet1/2' + } + } + ] + + fabric_links = [] + + task_args = { + 'existing_links': existing_links, + 'fabric_links': fabric_links + } + + action_module = self.create_action_module(ActionModule, task_args) + + result = action_module.run() + + self.assertEqual(len(result['links_to_be_removed']), 1) + + def test_run_multiple_links_mixed_scenarios(self): + """Test run with multiple links and mixed scenarios.""" + existing_links = [ + { + 'templateName': 'int_intra_fabric_num_link', + 'fabricName': 'test-fabric', + 'sw1-info': { + 'sw-sys-name': 'switch1', + 'if-name': 'Ethernet1/1' + }, + 'sw2-info': { + 'sw-sys-name': 'switch2', + 'if-name': 'Ethernet1/2' + } + }, + { + 'templateName': 'int_intra_fabric_num_link', + 'fabricName': 'test-fabric', + 'sw1-info': { + 'sw-sys-name': 'switch3', + 'if-name': 'Ethernet1/3' + }, + 'sw2-info': { + 'sw-sys-name': 'switch4', + 'if-name': 'Ethernet1/4' + } + }, + { + 'templateName': 'int_intra_fabric_num_link', + 'fabricName': 'test-fabric', + 'sw1-info': { + 'sw-sys-name': 'switch5', + 'if-name': 'Ethernet1/5' + }, + 'sw2-info': { + 'sw-sys-name': 'switch6', + 'if-name': 'Ethernet1/6' + } + } + ] + + fabric_links = [ + { + 'src_device': 'switch1', + 'src_interface': 'Ethernet1/1', + 'dst_device': 'switch2', + 'dst_interface': 'Ethernet1/2' + } + ] + + task_args = { + 'existing_links': existing_links, + 'fabric_links': fabric_links + } + + action_module = self.create_action_module(ActionModule, task_args) + + result = action_module.run() + + # Should remove 2 links (switch3-4 and switch5-6), keep 1 (switch1-2) + self.assertEqual(len(result['links_to_be_removed']), 2) diff --git a/tests/unit/plugins/action/dtc/test_manage_child_fabric_networks.py b/tests/unit/plugins/action/dtc/test_manage_child_fabric_networks.py new file mode 100644 index 000000000..1ab3ab653 --- /dev/null +++ b/tests/unit/plugins/action/dtc/test_manage_child_fabric_networks.py @@ -0,0 +1,478 @@ +# Copyright (c) 2025 Cisco Systems, Inc. and its affiliates +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +""" +Unit tests for manage_child_fabric_networks action plugin. +""" + +import unittest +from unittest.mock import patch, mock_open + +# Try to import from the plugins directory +try: + from plugins.action.dtc.manage_child_fabric_networks import ActionModule +except ImportError: + # Fallback for when running tests from different locations + import sys + import os + sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..', '..', '..')) + from plugins.action.dtc.manage_child_fabric_networks import ActionModule +from .base_test import ActionModuleTestCase + + +class TestManageChildFabricNetworksActionModule(ActionModuleTestCase): + """Test cases for manage_child_fabric_networks action plugin.""" + + def setUp(self): + """Set up test fixtures.""" + super().setUp() + self.maxDiff = None + self.mock_nd_version = '3.2.2m' + self.mock_msite_data = { + 'overlay_attach_groups': { + 'networks': [ + { + 'name': 'test_network', + 'network_attach_group': 'test_group', + 'child_fabrics': [ + { + 'name': 'child_fabric1', + 'netflow_enable': True, + 'trm_enable': True, + 'dhcp_loopback_id': '100', + 'vlan_netflow_monitor': 'test_monitor', + 'multicast_group_address': '239.1.1.1' + } + ] + } + ], + 'network_attach_groups': [ + { + 'name': 'test_group', + 'switches': [ + {'mgmt_ip_address': '10.1.1.1'}, + {'mgmt_ip_address': '10.1.1.2'} + ] + } + ] + }, + 'child_fabrics_data': { + 'child_fabric1': { + 'type': 'Switch_Fabric', + 'attributes': { + 'ENABLE_NETFLOW': 'true', + 'ENABLE_TRM': 'true' + }, + 'switches': [ + {'mgmt_ip_address': '10.1.1.1'}, + {'mgmt_ip_address': '10.1.1.3'} + ] + }, + 'child_fabric2': { + 'type': 'External', + 'attributes': {}, + 'switches': [] + } + } + } + + def test_run_no_networks(self): + """Test run with no networks to process.""" + msite_data = { + 'overlay_attach_groups': { + 'networks': [], + 'network_attach_groups': [] + }, + 'child_fabrics_data': {} + } + + task_args = { + 'nd_version': self.mock_nd_version, + 'msite_data': msite_data + } + action_module = self.create_action_module(ActionModule, task_args) + + result = action_module.run() + + self.assertFalse(result['changed']) + self.assertFalse(result['failed']) + self.assertEqual(result['child_fabrics_changed'], []) + + def test_run_no_child_fabrics(self): + """Test run with networks but no child fabrics.""" + msite_data = { + 'overlay_attach_groups': { + 'networks': [{'name': 'test_net', 'network_attach_group': 'test_group'}], + 'network_attach_groups': [{'name': 'test_group', 'switches': []}] + }, + 'child_fabrics_data': {} + } + + task_args = { + 'nd_version': self.mock_nd_version, + 'msite_data': msite_data + } + action_module = self.create_action_module(ActionModule, task_args) + + result = action_module.run() + + self.assertFalse(result['changed']) + self.assertEqual(result['child_fabrics_changed'], []) + + def test_run_non_switch_fabric_type(self): + """Test run with non-Switch_Fabric type child fabrics.""" + task_args = { + 'nd_version': self.mock_nd_version, + 'msite_data': self.mock_msite_data + } + action_module = self.create_action_module(ActionModule, task_args) + + # Change child fabric type to non-Switch_Fabric + self.mock_msite_data['child_fabrics_data']['child_fabric1']['type'] = 'External' + + result = action_module.run() + + self.assertFalse(result['changed']) + self.assertEqual(result['child_fabrics_changed'], []) + + def test_run_no_switch_intersection(self): + """Test run with no switch intersection between network attach group and child fabric.""" + msite_data = { + 'overlay_attach_groups': { + 'networks': [ + { + 'name': 'test_network', + 'network_attach_group': 'test_group' + } + ], + 'network_attach_groups': [ + { + 'name': 'test_group', + 'switches': [ + {'mgmt_ip_address': '10.1.1.1'}, + {'mgmt_ip_address': '10.1.1.2'} + ] + } + ] + }, + 'child_fabrics_data': { + 'child_fabric1': { + 'type': 'Switch_Fabric', + 'attributes': {}, + 'switches': [ + {'mgmt_ip_address': '10.1.1.5'}, # No intersection + {'mgmt_ip_address': '10.1.1.6'} + ] + } + } + } + + task_args = { + 'nd_version': self.mock_nd_version, + 'msite_data': msite_data + } + action_module = self.create_action_module(ActionModule, task_args) + + result = action_module.run() + + self.assertFalse(result['changed']) + self.assertEqual(result['child_fabrics_changed'], []) + + def test_run_netflow_fabric_disabled_error(self): + """Test run with netflow enabled in network but disabled in fabric attributes.""" + msite_data = self.mock_msite_data.copy() + # Set fabric netflow to false but network netflow to true + msite_data['child_fabrics_data']['child_fabric1']['attributes']['ENABLE_NETFLOW'] = 'false' + + task_args = { + 'nd_version': self.mock_nd_version, + 'msite_data': msite_data + } + action_module = self.create_action_module(ActionModule, task_args) + + result = action_module.run() + + self.assertTrue(result['failed']) + self.assertIn('NetFlow is not enabled in the fabric settings', result['msg']) + + def test_run_trm_fabric_disabled_error(self): + """Test run with TRM enabled in network but disabled in fabric attributes.""" + msite_data = self.mock_msite_data.copy() + # Set fabric TRM to false but network TRM to true + msite_data['child_fabrics_data']['child_fabric1']['attributes']['ENABLE_TRM'] = 'false' + + task_args = { + 'nd_version': self.mock_nd_version, + 'msite_data': msite_data + } + action_module = self.create_action_module(ActionModule, task_args) + + result = action_module.run() + + self.assertTrue(result['failed']) + self.assertIn('TRM is not enabled in the fabric settings', result['msg']) + + def test_run_network_update_required(self): + """Test run when network configuration needs to be updated.""" + task_args = { + 'nd_version': self.mock_nd_version, + 'msite_data': self.mock_msite_data + } + action_module = self.create_action_module(ActionModule, task_args) + + # Mock template file content and path finding + template_content = '{"networkName": "{{ network_name }}", "fabric": "{{ fabric_name }}"}' + + with patch.object(action_module, '_execute_module') as mock_execute, \ + patch.object(action_module, '_find_needle') as mock_find_needle, \ + patch('builtins.open', mock_open(read_data=template_content)): + + # Mock NDFC get network response + mock_execute.side_effect = [ + # First call: get network + { + 'response': { + 'DATA': { + 'fabric': 'child_fabric1', + 'networkName': 'test_network', + 'networkTemplateConfig': ( + '{"ENABLE_NETFLOW": "false", ' + '"VLAN_NETFLOW_MONITOR": "", ' + '"trmEnabled": "false", ' + '"mcastGroup": "239.1.1.2", ' + '"loopbackId": ""}' + ) + } + } + }, + # Second call: update network + { + 'response': { + 'RETURN_CODE': 200, + 'MESSAGE': 'OK' + } + } + ] + + mock_find_needle.return_value = '/path/to/template.j2' + + result = action_module.run(task_vars={'role_path': '/test/role'}) + + self.assertTrue(result['changed']) + self.assertIn('child_fabric1', result['child_fabrics_changed']) + self.assertEqual(mock_execute.call_count, 2) + + def test_run_network_no_update_required(self): + """Test run when network configuration matches and no update is needed.""" + task_args = { + 'nd_version': self.mock_nd_version, + 'msite_data': self.mock_msite_data + } + action_module = self.create_action_module(ActionModule, task_args) + + with patch.object(action_module, '_execute_module') as mock_execute: + # Mock NDFC get network response with matching config + mock_execute.return_value = { + 'response': { + 'DATA': { + 'fabric': 'child_fabric1', + 'networkName': 'test_network', + 'networkTemplateConfig': ( + '{"ENABLE_NETFLOW": "true", ' + '"VLAN_NETFLOW_MONITOR": "test_monitor", ' + '"trmEnabled": "true", ' + '"mcastGroup": "239.1.1.1", ' + '"loopbackId": "100"}' + ) + } + } + } + + result = action_module.run() + + self.assertFalse(result['changed']) + self.assertEqual(result['child_fabrics_changed'], []) + + def test_run_template_file_not_found(self): + """Test run when template file cannot be found.""" + task_args = { + 'nd_version': self.mock_nd_version, + 'msite_data': self.mock_msite_data + } + action_module = self.create_action_module(ActionModule, task_args) + + with patch.object(action_module, '_execute_module') as mock_execute, \ + patch.object(action_module, '_find_needle') as mock_find_needle: + + # Mock NDFC get network response that requires update + mock_execute.return_value = { + 'response': { + 'DATA': { + 'fabric': 'child_fabric1', + 'networkName': 'test_network', + 'networkTemplateConfig': ( + '{"ENABLE_NETFLOW": "false", ' + '"VLAN_NETFLOW_MONITOR": "", ' + '"trmEnabled": "false", ' + '"mcastGroup": "239.1.1.2", ' + '"loopbackId": ""}' + ) + } + } + } + + # Mock template file not found + from ansible.errors import AnsibleFileNotFound + mock_find_needle.side_effect = AnsibleFileNotFound("Template not found") + + result = action_module.run(task_vars={'role_path': '/test/role'}) + + self.assertTrue(result['failed']) + self.assertIn('Template file not found', result['msg']) + + def test_run_network_update_failed(self): + """Test run when network update fails.""" + task_args = { + 'nd_version': self.mock_nd_version, + 'msite_data': self.mock_msite_data + } + action_module = self.create_action_module(ActionModule, task_args) + + template_content = '{"networkName": "{{ network_name }}"}' + + with patch.object(action_module, '_execute_module') as mock_execute, \ + patch.object(action_module, '_find_needle') as mock_find_needle, \ + patch('builtins.open', mock_open(read_data=template_content)): + + # Mock responses + mock_execute.side_effect = [ + # First call: get network (needs update) + { + 'response': { + 'DATA': { + 'fabric': 'child_fabric1', + 'networkName': 'test_network', + 'networkTemplateConfig': ( + '{"ENABLE_NETFLOW": "false", ' + '"VLAN_NETFLOW_MONITOR": "", ' + '"trmEnabled": "false", ' + '"mcastGroup": "239.1.1.2", ' + '"loopbackId": ""}' + ) + } + } + }, + # Second call: update network (fails) + { + 'msg': { + 'RETURN_CODE': 500, + 'DATA': { + 'message': 'Internal Server Error' + } + } + } + ] + + mock_find_needle.return_value = '/path/to/template.j2' + + result = action_module.run(task_vars={'role_path': '/test/role'}) + + self.assertTrue(result['failed']) + self.assertIn('Internal Server Error', result['msg']) + + def test_run_network_without_child_fabrics_config(self): + """Test run with network that has no child_fabrics configuration.""" + msite_data = { + 'overlay_attach_groups': { + 'networks': [ + { + 'name': 'test_network', + 'network_attach_group': 'test_group' + # No child_fabrics key + } + ], + 'network_attach_groups': [ + { + 'name': 'test_group', + 'switches': [{'mgmt_ip_address': '10.1.1.1'}] + } + ] + }, + 'child_fabrics_data': { + 'child_fabric1': { + 'type': 'Switch_Fabric', + 'attributes': {'ENABLE_NETFLOW': 'true', 'ENABLE_TRM': 'true'}, + 'switches': [{'mgmt_ip_address': '10.1.1.1'}] + } + } + } + + task_args = { + 'nd_version': self.mock_nd_version, + 'msite_data': msite_data + } + action_module = self.create_action_module(ActionModule, task_args) + + template_content = '{"networkName": "{{ network_name }}", "fabric": "{{ fabric_name }}"}' + + with patch.object(action_module, '_execute_module') as mock_execute, \ + patch.object(action_module, '_find_needle') as mock_find_needle, \ + patch('builtins.open', mock_open(read_data=template_content)): + + # Mock NDFC responses - need two calls: GET then PUT + mock_execute.side_effect = [ + # First call: get network (shows config needs update) + { + 'response': { + 'DATA': { + 'fabric': 'child_fabric1', + 'networkName': 'test_network', + 'networkTemplateConfig': ( + '{"ENABLE_NETFLOW": "true", ' + '"VLAN_NETFLOW_MONITOR": "", ' + '"trmEnabled": "false", ' + '"mcastGroup": "", ' + '"loopbackId": ""}' + ) + } + } + }, + # Second call: update network (success) + { + 'response': { + 'RETURN_CODE': 200, + 'MESSAGE': 'OK' + } + } + ] + + mock_find_needle.return_value = '/path/to/template.j2' + + result = action_module.run(task_vars={'role_path': '/test/role'}) + + # Should work with default values for missing child fabric config + self.assertFalse(result.get('failed', False)) + self.assertTrue(result['changed']) + self.assertIn('child_fabric1', result['child_fabrics_changed']) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/unit/plugins/action/dtc/test_manage_child_fabric_vrfs.py b/tests/unit/plugins/action/dtc/test_manage_child_fabric_vrfs.py new file mode 100644 index 000000000..6116c492f --- /dev/null +++ b/tests/unit/plugins/action/dtc/test_manage_child_fabric_vrfs.py @@ -0,0 +1,557 @@ +# Copyright (c) 2025 Cisco Systems, Inc. and its affiliates +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +""" +Unit tests for manage_child_fabric_vrfs action plugin. +""" + +import unittest +from unittest.mock import patch, mock_open + +# Try to import from the plugins directory +try: + from plugins.action.dtc.manage_child_fabric_vrfs import ActionModule +except ImportError: + # Fallback for when running tests from different locations + import sys + import os + sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..', '..', '..')) + from plugins.action.dtc.manage_child_fabric_vrfs import ActionModule +from .base_test import ActionModuleTestCase + + +class TestManageChildFabricVrfsActionModule(ActionModuleTestCase): + """Test cases for manage_child_fabric_vrfs action plugin.""" + + def setUp(self): + """Set up test fixtures.""" + super().setUp() + self.maxDiff = None + self.mock_nd_version = '3.2.2m' + + # Standard mock VRF template config JSON string that matches the test VRF config exactly + self.standard_vrf_config = ( + '{' + '"ENABLE_NETFLOW": "true", ' + '"loopbackId": "100", ' + '"vrfTemplate": "Custom_VRF_Template", ' + '"advertiseHostRouteFlag": "false", ' + '"advertiseDefaultRouteFlag": "false", ' + '"configureStaticDefaultRouteFlag": "false", ' + '"bgpPassword": "", ' + '"bgpPasswordKeyType": "", ' + '"NETFLOW_MONITOR": "", ' + '"trmEnabled": "false", ' + '"loopbackNumber": "", ' + '"rpAddress": "", ' + '"isRPAbsent": "false", ' + '"isRPExternal": "false", ' + '"L3VniMcastGroup": "", ' + '"multicastGroup": "", ' + '"routeTargetImportMvpn": "", ' + '"routeTargetExportMvpn": ""' + '}' + ) + + self.mock_msite_data = { + 'overlay_attach_groups': { + 'vrfs': [ + { + 'name': 'test_vrf', + 'vrf_attach_group': 'test_group', + 'child_fabrics': [ + { + 'name': 'child_fabric1', + 'netflow_enable': True, + 'loopback_id': 100, + 'vrf_template': 'Custom_VRF_Template', + 'adv_host_routes': False, + 'adv_default_routes': False, + 'config_static_default_route': False, + 'bgp_password': '', + 'bgp_password_key_type': '', + 'netflow_monitor': '', + 'trm_enable': False, + 'rp_loopback_id': '', + 'rp_address': '', + 'no_rp': False, + 'rp_external': False, + 'underlay_mcast_ip': '', + 'overlay_multicast_group': '', + 'import_mvpn_rt': '', + 'export_mvpn_rt': '' + } + ] + } + ], + 'vrf_attach_groups': [ + { + 'name': 'test_group', + 'switches': [ + {'mgmt_ip_address': '10.1.1.1'}, + {'mgmt_ip_address': '10.1.1.2'} + ] + } + ] + }, + 'child_fabrics_data': { + 'child_fabric1': { + 'type': 'Switch_Fabric', + 'attributes': { + 'ENABLE_NETFLOW': 'true' + }, + 'switches': [ + {'mgmt_ip_address': '10.1.1.1'}, + {'mgmt_ip_address': '10.1.1.3'} + ] + } + } + } + + def test_run_no_vrfs(self): + """Test run with no VRFs to process.""" + msite_data = { + 'overlay_attach_groups': { + 'vrfs': [], + 'vrf_attach_groups': [] + }, + 'child_fabrics_data': {} + } + + task_args = { + 'nd_version': self.mock_nd_version, + 'msite_data': msite_data + } + action_module = self.create_action_module(ActionModule, task_args) + + result = action_module.run() + + self.assertFalse(result['changed']) + self.assertFalse(result['failed']) + self.assertEqual(result['child_fabrics_changed'], []) + + def test_run_no_child_fabrics(self): + """Test run with VRFs but no child fabrics.""" + msite_data = { + 'overlay_attach_groups': { + 'vrfs': [{'name': 'test_vrf', 'vrf_attach_group': 'test_group'}], + 'vrf_attach_groups': [{'name': 'test_group', 'switches': []}] + }, + 'child_fabrics_data': {} + } + + task_args = { + 'nd_version': self.mock_nd_version, + 'msite_data': msite_data + } + action_module = self.create_action_module(ActionModule, task_args) + + result = action_module.run() + + self.assertFalse(result['changed']) + self.assertEqual(result['child_fabrics_changed'], []) + + def test_run_non_switch_fabric_type(self): + """Test run with non-Switch_Fabric type child fabrics.""" + task_args = { + 'nd_version': self.mock_nd_version, + 'msite_data': self.mock_msite_data + } + action_module = self.create_action_module(ActionModule, task_args) + + # Change child fabric type to non-Switch_Fabric + self.mock_msite_data['child_fabrics_data']['child_fabric1']['type'] = 'External' + + result = action_module.run() + + self.assertFalse(result['changed']) + self.assertEqual(result['child_fabrics_changed'], []) + + def test_run_netflow_fabric_disabled_error(self): + """Test run with netflow enabled in VRF but disabled in fabric attributes.""" + msite_data = self.mock_msite_data.copy() + # Set fabric netflow to false but VRF netflow to true + msite_data['child_fabrics_data']['child_fabric1']['attributes']['ENABLE_NETFLOW'] = 'false' + + task_args = { + 'nd_version': self.mock_nd_version, + 'msite_data': msite_data + } + action_module = self.create_action_module(ActionModule, task_args) + + result = action_module.run() + + self.assertTrue(result['failed']) + self.assertIn('NetFlow is not enabled in the fabric settings', result['msg']) + + def test_run_vrf_no_update_required(self): + """Test run when VRF configuration matches and no update is needed.""" + task_args = { + 'nd_version': self.mock_nd_version, + 'msite_data': self.mock_msite_data + } + action_module = self.create_action_module(ActionModule, task_args) + + with patch.object(action_module, '_execute_module') as mock_execute: + # Mock NDFC get VRF response with matching config + mock_execute.return_value = { + 'response': { + 'DATA': { + 'fabric': 'child_fabric1', + 'vrfName': 'test_vrf', + 'vrfTemplateConfig': self.standard_vrf_config + } + } + } + + result = action_module.run() + + self.assertFalse(result['changed']) + self.assertEqual(result['child_fabrics_changed'], []) + + def test_run_trm_fabric_disabled_error(self): + """Test run with TRM enabled in VRF but disabled in fabric attributes.""" + msite_data = self.mock_msite_data.copy() + # Set fabric TRM to false but VRF TRM to true + msite_data['child_fabrics_data']['child_fabric1']['attributes']['ENABLE_TRM'] = 'false' + msite_data['overlay_attach_groups']['vrfs'][0]['child_fabrics'][0]['trm_enable'] = True + + task_args = { + 'nd_version': self.mock_nd_version, + 'msite_data': msite_data + } + action_module = self.create_action_module(ActionModule, task_args) + + result = action_module.run() + + self.assertTrue(result['failed']) + self.assertIn('TRM is not enabled in the fabric settings', result['msg']) + + def test_run_vrf_update_required(self): + """Test run when VRF configuration needs to be updated.""" + task_args = { + 'nd_version': self.mock_nd_version, + 'msite_data': self.mock_msite_data + } + action_module = self.create_action_module(ActionModule, task_args) + + # Mock template file content and path finding + template_content = '{"vrfName": "{{ dm.name }}", "fabric": "{{ fabric_name }}"}' + + with patch.object(action_module, '_execute_module') as mock_execute, \ + patch.object(action_module, '_find_needle') as mock_find_needle, \ + patch('builtins.open', mock_open(read_data=template_content)): + + # Mock NDFC VRF responses + mock_execute.side_effect = [ + # First call: get VRF (needs update) + { + 'response': { + 'DATA': { + 'fabric': 'child_fabric1', + 'vrfName': 'test_vrf', + 'vrfTemplateConfig': ( + '{"ENABLE_NETFLOW": "false", ' + '"loopbackId": "200", ' + '"vrfTemplate": "Different_Template", ' + '"advertiseHostRouteFlag": "true", ' + '"advertiseDefaultRouteFlag": "true", ' + '"configureStaticDefaultRouteFlag": "true", ' + '"bgpPassword": "old", ' + '"bgpPasswordKeyType": "old", ' + '"NETFLOW_MONITOR": "old", ' + '"trmEnabled": "true", ' + '"loopbackNumber": "old", ' + '"rpAddress": "old", ' + '"isRPAbsent": "true", ' + '"isRPExternal": "true", ' + '"L3VniMcastGroup": "old", ' + '"multicastGroup": "old", ' + '"routeTargetImportMvpn": "old", ' + '"routeTargetExportMvpn": "old"}' + ) + } + } + }, + # Second call: update VRF (success) + { + 'response': { + 'RETURN_CODE': 200, + 'MESSAGE': 'OK' + } + } + ] + + mock_find_needle.return_value = '/path/to/template.j2' + + result = action_module.run(task_vars={'role_path': '/test/role'}) + + self.assertTrue(result['changed']) + self.assertIn('child_fabric1', result['child_fabrics_changed']) + self.assertEqual(mock_execute.call_count, 2) + + def test_run_template_file_not_found(self): + """Test run when template file cannot be found.""" + task_args = { + 'nd_version': self.mock_nd_version, + 'msite_data': self.mock_msite_data + } + action_module = self.create_action_module(ActionModule, task_args) + + with patch.object(action_module, '_execute_module') as mock_execute, \ + patch.object(action_module, '_find_needle') as mock_find_needle: + + # Mock NDFC get VRF response that requires update + mock_execute.return_value = { + 'response': { + 'DATA': { + 'fabric': 'child_fabric1', + 'vrfName': 'test_vrf', + 'vrfTemplateConfig': ( + '{"ENABLE_NETFLOW": "false", ' + '"loopbackId": "200", ' + '"vrfTemplate": "Different_Template", ' + '"advertiseHostRouteFlag": "true", ' + '"advertiseDefaultRouteFlag": "true", ' + '"configureStaticDefaultRouteFlag": "true", ' + '"bgpPassword": "old", ' + '"bgpPasswordKeyType": "old", ' + '"NETFLOW_MONITOR": "old", ' + '"trmEnabled": "true", ' + '"loopbackNumber": "old", ' + '"rpAddress": "old", ' + '"isRPAbsent": "true", ' + '"isRPExternal": "true", ' + '"L3VniMcastGroup": "old", ' + '"multicastGroup": "old", ' + '"routeTargetImportMvpn": "old", ' + '"routeTargetExportMvpn": "old"}' + ) + } + } + } + + # Mock template file not found + from ansible.errors import AnsibleFileNotFound + mock_find_needle.side_effect = AnsibleFileNotFound("Template not found") + + result = action_module.run(task_vars={'role_path': '/test/role'}) + + self.assertTrue(result['failed']) + self.assertIn('Template file not found', result['msg']) + + def test_run_vrf_update_failed(self): + """Test run when VRF update fails.""" + task_args = { + 'nd_version': self.mock_nd_version, + 'msite_data': self.mock_msite_data + } + action_module = self.create_action_module(ActionModule, task_args) + + template_content = '{"vrfName": "{{ dm.name }}"}' + + with patch.object(action_module, '_execute_module') as mock_execute, \ + patch.object(action_module, '_find_needle') as mock_find_needle, \ + patch('builtins.open', mock_open(read_data=template_content)): + + # Mock responses + mock_execute.side_effect = [ + # First call: get VRF (needs update) + { + 'response': { + 'DATA': { + 'fabric': 'child_fabric1', + 'vrfName': 'test_vrf', + 'vrfTemplateConfig': ( + '{"ENABLE_NETFLOW": "false", ' + '"loopbackId": "200", ' + '"vrfTemplate": "Different_Template", ' + '"advertiseHostRouteFlag": "true", ' + '"advertiseDefaultRouteFlag": "true", ' + '"configureStaticDefaultRouteFlag": "true", ' + '"bgpPassword": "old", ' + '"bgpPasswordKeyType": "old", ' + '"NETFLOW_MONITOR": "old", ' + '"trmEnabled": "true", ' + '"loopbackNumber": "old", ' + '"rpAddress": "old", ' + '"isRPAbsent": "true", ' + '"isRPExternal": "true", ' + '"L3VniMcastGroup": "old", ' + '"multicastGroup": "old", ' + '"routeTargetImportMvpn": "old", ' + '"routeTargetExportMvpn": "old"}' + ) + } + } + }, + # Second call: update VRF (fails) + { + 'msg': { + 'RETURN_CODE': 500, + 'DATA': { + 'message': 'Internal Server Error' + } + } + } + ] + + mock_find_needle.return_value = '/path/to/template.j2' + + result = action_module.run(task_vars={'role_path': '/test/role'}) + + self.assertTrue(result['failed']) + self.assertIn('Internal Server Error', result['msg']) + + def test_run_vrf_without_child_fabrics_config(self): + """Test run with VRF that has no child_fabrics configuration.""" + msite_data = { + 'overlay_attach_groups': { + 'vrfs': [ + { + 'name': 'test_vrf', + 'vrf_attach_group': 'test_group' + # No child_fabrics key + } + ], + 'vrf_attach_groups': [ + { + 'name': 'test_group', + 'switches': [{'mgmt_ip_address': '10.1.1.1'}] + } + ] + }, + 'child_fabrics_data': { + 'child_fabric1': { + 'type': 'Switch_Fabric', + 'attributes': {'ENABLE_NETFLOW': 'true', 'ENABLE_TRM': 'true'}, + 'switches': [{'mgmt_ip_address': '10.1.1.1'}] + } + } + } + + task_args = { + 'nd_version': self.mock_nd_version, + 'msite_data': msite_data + } + action_module = self.create_action_module(ActionModule, task_args) + + template_content = '{"vrfName": "test_vrf", "fabric": "{{ fabric_name }}"}' + + with patch.object(action_module, '_execute_module') as mock_execute, \ + patch.object(action_module, '_find_needle') as mock_find_needle, \ + patch('builtins.open', mock_open(read_data=template_content)): + + # Mock NDFC responses - VRF needs update due to default values + mock_execute.side_effect = [ + # First call: get VRF (shows config needs update with default values) + { + 'response': { + 'DATA': { + 'fabric': 'child_fabric1', + 'vrfName': 'test_vrf', + 'vrfTemplateConfig': ( + '{"ENABLE_NETFLOW": "true", ' + '"loopbackId": "", ' + '"vrfTemplate": "", ' + '"advertiseHostRouteFlag": "true", ' + '"advertiseDefaultRouteFlag": "true", ' + '"configureStaticDefaultRouteFlag": "true", ' + '"bgpPassword": "old", ' + '"bgpPasswordKeyType": "old", ' + '"NETFLOW_MONITOR": "old", ' + '"trmEnabled": "true", ' + '"loopbackNumber": "old", ' + '"rpAddress": "old", ' + '"isRPAbsent": "true", ' + '"isRPExternal": "true", ' + '"L3VniMcastGroup": "old", ' + '"multicastGroup": "old", ' + '"routeTargetImportMvpn": "old", ' + '"routeTargetExportMvpn": "old"}' + ) + } + } + }, + # Second call: update VRF (success) + { + 'response': { + 'RETURN_CODE': 200, + 'MESSAGE': 'OK' + } + } + ] + + mock_find_needle.return_value = '/path/to/template.j2' + + result = action_module.run(task_vars={'role_path': '/test/role'}) + + # Should work with default values for missing child fabric config + self.assertFalse(result.get('failed', False)) + self.assertTrue(result['changed']) + self.assertIn('child_fabric1', result['child_fabrics_changed']) + + def test_run_no_switch_intersection(self): + """Test run with no switch intersection between VRF attach group and child fabric.""" + msite_data = { + 'overlay_attach_groups': { + 'vrfs': [ + { + 'name': 'test_vrf', + 'vrf_attach_group': 'test_group' + } + ], + 'vrf_attach_groups': [ + { + 'name': 'test_group', + 'switches': [ + {'mgmt_ip_address': '10.1.1.1'}, + {'mgmt_ip_address': '10.1.1.2'} + ] + } + ] + }, + 'child_fabrics_data': { + 'child_fabric1': { + 'type': 'Switch_Fabric', + 'attributes': {}, + 'switches': [ + {'mgmt_ip_address': '10.1.1.5'}, # No intersection + {'mgmt_ip_address': '10.1.1.6'} + ] + } + } + } + + task_args = { + 'nd_version': self.mock_nd_version, + 'msite_data': msite_data + } + action_module = self.create_action_module(ActionModule, task_args) + + result = action_module.run() + + self.assertFalse(result['changed']) + self.assertEqual(result['child_fabrics_changed'], []) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/unit/plugins/action/dtc/test_manage_child_fabrics.py b/tests/unit/plugins/action/dtc/test_manage_child_fabrics.py new file mode 100644 index 000000000..9ae94a8a9 --- /dev/null +++ b/tests/unit/plugins/action/dtc/test_manage_child_fabrics.py @@ -0,0 +1,279 @@ +# Copyright (c) 2025 Cisco Systems, Inc. and its affiliates +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +""" +Unit tests for manage_child_fabrics action plugin. +""" + +from unittest.mock import patch + +# Try to import from the plugins directory +try: + from plugins.action.dtc.manage_child_fabrics import ActionModule +except ImportError: + # Fallback for when running tests from different locations + import sys + import os + sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..', '..', '..')) + from plugins.action.dtc.manage_child_fabrics import ActionModule +from .base_test import ActionModuleTestCase + + +class TestManageChildFabricsActionModule(ActionModuleTestCase): + """Test cases for manage_child_fabrics action plugin.""" + + def test_run_single_child_fabric_present(self): + """Test run with single child fabric in present state.""" + parent_fabric = "parent-fabric" + child_fabrics = ["single-child-fabric"] + state = "present" + + # Mock successful response + mock_response = { + 'changed': False, + 'failed': False + } + + task_args = { + 'parent_fabric': parent_fabric, + 'child_fabrics': child_fabrics, + 'state': state + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Mock only _execute_module + with patch.object(ActionModule, '_execute_module') as mock_execute: + mock_execute.return_value = mock_response + + result = action_module.run() + + self.assertTrue(result['changed']) + self.assertFalse(result['failed']) + self.assertTrue(result['child_fabrics_moved']) + + def test_run_present_state_success(self): + """Test run with present state successful execution.""" + parent_fabric = "parent-fabric" + child_fabrics = ["child-fabric1", "child-fabric2"] + state = "present" + + mock_response = { + 'changed': False, + 'failed': False + } + + task_args = { + 'parent_fabric': parent_fabric, + 'child_fabrics': child_fabrics, + 'state': state + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Mock only _execute_module + with patch.object(ActionModule, '_execute_module') as mock_execute: + mock_execute.return_value = mock_response + + result = action_module.run() + + self.assertTrue(result['changed']) + self.assertFalse(result['failed']) + self.assertTrue(result['child_fabrics_moved']) + + def test_run_present_state_failure(self): + """Test run with present state failed execution.""" + parent_fabric = "parent-fabric" + child_fabrics = ["child-fabric1"] + state = "present" + + mock_response = { + 'failed': True, + 'msg': { + 'MESSAGE': 'Bad Request', + 'DATA': 'Child fabric already exists' + } + } + + task_args = { + 'parent_fabric': parent_fabric, + 'child_fabrics': child_fabrics, + 'state': state + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Mock only _execute_module + with patch.object(ActionModule, '_execute_module') as mock_execute: + mock_execute.return_value = mock_response + + result = action_module.run() + + self.assertTrue(result['failed']) + self.assertIn('Bad Request', result['msg']) + + def test_run_absent_state_success(self): + """Test run with absent state successful execution.""" + parent_fabric = "parent-fabric" + child_fabrics = ["child-fabric1"] + state = "absent" + + mock_response = { + 'changed': False, + 'failed': False + } + + task_args = { + 'parent_fabric': parent_fabric, + 'child_fabrics': child_fabrics, + 'state': state + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Mock only _execute_module + with patch.object(ActionModule, '_execute_module') as mock_execute: + mock_execute.return_value = mock_response + + result = action_module.run() + + self.assertTrue(result['changed']) + self.assertFalse(result['failed']) + + def test_run_absent_state_failure(self): + """Test run with absent state failed execution.""" + parent_fabric = "parent-fabric" + child_fabrics = ["child-fabric1"] + state = "absent" + + mock_response = { + 'failed': True, + 'msg': { + 'MESSAGE': 'Not Found', + 'DATA': 'Child fabric does not exist' + } + } + + task_args = { + 'parent_fabric': parent_fabric, + 'child_fabrics': child_fabrics, + 'state': state + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Mock only _execute_module + with patch.object(ActionModule, '_execute_module') as mock_execute: + mock_execute.return_value = mock_response + + result = action_module.run() + + self.assertTrue(result['failed']) + self.assertIn('Not Found', result['msg']) + + def test_run_mixed_success_failure_present(self): + """Test run with mixed success and failure in present state.""" + parent_fabric = "parent-fabric" + child_fabrics = ["child-fabric1", "child-fabric2"] + state = "present" + + mock_responses = [ + {'changed': False, 'failed': False}, # First fabric succeeds + { # Second fabric fails + 'failed': True, + 'msg': { + 'MESSAGE': 'Bad Request', + 'DATA': 'Child fabric already exists' + } + } + ] + + task_args = { + 'parent_fabric': parent_fabric, + 'child_fabrics': child_fabrics, + 'state': state + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Mock _execute_module to return different responses + with patch.object(ActionModule, '_execute_module') as mock_execute: + mock_execute.side_effect = mock_responses + + result = action_module.run() + + # Should fail because second fabric failed + self.assertTrue(result['failed']) + self.assertIn('Bad Request', result['msg']) + + def test_run_json_data_format(self): + """Test run verifies correct JSON data format for API calls.""" + parent_fabric = "parent-fabric" + child_fabrics = ["child-fabric1"] + state = "present" + + mock_response = { + 'changed': False, + 'failed': False + } + + task_args = { + 'parent_fabric': parent_fabric, + 'child_fabrics': child_fabrics, + 'state': state + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Mock only _execute_module + with patch.object(ActionModule, '_execute_module') as mock_execute: + mock_execute.return_value = mock_response + + result = action_module.run() + + # Verify that _execute_module was called with correct arguments + mock_execute.assert_called_once() + call_args = mock_execute.call_args[1]['module_args'] + + self.assertEqual(call_args['method'], 'POST') + self.assertEqual(call_args['path'], '/appcenter/cisco/ndfc/api/v1/lan-fabric/rest/control/fabrics/msdAdd') + expected_json = '{"destFabric":"parent-fabric","sourceFabric":"child-fabric1"}' + self.assertEqual(call_args['json_data'], expected_json) + + def test_run_empty_child_fabrics_list(self): + """Test run with empty child fabrics list.""" + parent_fabric = "parent-fabric" + child_fabrics = [] + state = "present" + + task_args = { + 'parent_fabric': parent_fabric, + 'child_fabrics': child_fabrics, + 'state': state + } + + action_module = self.create_action_module(ActionModule, task_args) + + result = action_module.run() + + self.assertFalse(result['changed']) + self.assertFalse(result['failed']) + self.assertFalse(result['child_fabrics_moved']) diff --git a/tests/unit/plugins/action/dtc/test_map_msd_inventory.py b/tests/unit/plugins/action/dtc/test_map_msd_inventory.py new file mode 100644 index 000000000..0be7d4fa7 --- /dev/null +++ b/tests/unit/plugins/action/dtc/test_map_msd_inventory.py @@ -0,0 +1,341 @@ +# Copyright (c) 2025 Cisco Systems, Inc. and its affiliates +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +""" +Unit tests for map_msd_inventory action plugin. +""" + +from unittest.mock import patch + +# Try to import from the plugins directory +try: + from plugins.action.dtc.map_msd_inventory import ActionModule +except ImportError: + # Fallback for when running tests from different locations + import sys + import os + sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..', '..', '..')) + from plugins.action.dtc.map_msd_inventory import ActionModule +from .base_test import ActionModuleTestCase + + +class TestMapMsdInventoryActionModule(ActionModuleTestCase): + """Test cases for map_msd_inventory action plugin.""" + + def test_run_successful_inventory_query(self): + """Test run with successful inventory query.""" + parent_fabric_name = "msd-fabric" + model_data_overlay = { + 'vrf_attach_switches_list': ['switch1', '10.1.1.1'], + 'network_attach_switches_list': ['switch2', '10.1.1.2'] + } + + mock_inventory_response = { + 'response': [ + { + 'hostName': 'switch1', + 'ipAddress': '10.1.1.1', + 'fabricName': 'child-fabric1' + }, + { + 'hostName': 'switch2', + 'ipAddress': '10.1.1.2', + 'fabricName': 'child-fabric2' + } + ] + } + + task_args = { + 'parent_fabric_name': parent_fabric_name, + 'model_data_overlay': model_data_overlay + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Mock only _execute_module + with patch.object(ActionModule, '_execute_module') as mock_execute: + mock_execute.return_value = mock_inventory_response + + result = action_module.run() + + self.assertFalse(result['failed']) + self.assertIn('msd_switches', result) + self.assertIn('switch1', result['msd_switches']) + self.assertIn('10.1.1.1', result['msd_switches']) + + def test_run_switch_mapping_behavior(self): + """Test run verifies switch mapping behavior.""" + parent_fabric_name = "msd-fabric" + model_data_overlay = { + 'vrf_attach_switches_list': [], + 'network_attach_switches_list': [] + } + + mock_inventory_response = { + 'response': [ + { + 'hostName': 'test-switch', + 'ipAddress': '10.1.1.100', + 'fabricName': 'test-fabric' + } + ] + } + + task_args = { + 'parent_fabric_name': parent_fabric_name, + 'model_data_overlay': model_data_overlay + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Mock only _execute_module + with patch.object(ActionModule, '_execute_module') as mock_execute: + mock_execute.return_value = mock_inventory_response + + result = action_module.run() + + # Verify mapping behavior + msd_switches = result['msd_switches'] + self.assertEqual(msd_switches['test-switch'], '10.1.1.100') + self.assertEqual(msd_switches['10.1.1.100'], '10.1.1.100') + self.assertEqual(msd_switches['test-fabric'], 'test-fabric') + + def test_run_vrf_attach_switch_not_found(self): + """Test run when VRF attach switch is not found in inventory.""" + parent_fabric_name = "msd-fabric" + model_data_overlay = { + 'vrf_attach_switches_list': ['missing-switch'], + 'network_attach_switches_list': [] + } + + mock_inventory_response = { + 'response': [ + { + 'hostName': 'existing-switch', + 'ipAddress': '10.1.1.1', + 'fabricName': 'child-fabric1' + } + ] + } + + task_args = { + 'parent_fabric_name': parent_fabric_name, + 'model_data_overlay': model_data_overlay + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Mock only _execute_module + with patch.object(ActionModule, '_execute_module') as mock_execute: + mock_execute.return_value = mock_inventory_response + + result = action_module.run() + + self.assertTrue(result['failed']) + self.assertIn('msg', result) + self.assertIn('missing-switch', result['msg'][0]) + + def test_run_network_attach_switch_not_found(self): + """Test run when network attach switch is not found in inventory.""" + parent_fabric_name = "msd-fabric" + model_data_overlay = { + 'vrf_attach_switches_list': [], + 'network_attach_switches_list': ['missing-network-switch'] + } + + mock_inventory_response = { + 'response': [ + { + 'hostName': 'existing-switch', + 'ipAddress': '10.1.1.1', + 'fabricName': 'child-fabric1' + } + ] + } + + task_args = { + 'parent_fabric_name': parent_fabric_name, + 'model_data_overlay': model_data_overlay + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Mock only _execute_module + with patch.object(ActionModule, '_execute_module') as mock_execute: + mock_execute.return_value = mock_inventory_response + + result = action_module.run() + + self.assertTrue(result['failed']) + self.assertIn('msg', result) + self.assertIn('missing-network-switch', result['msg'][0]) + + def test_run_multiple_missing_switches(self): + """Test run with multiple missing switches.""" + parent_fabric_name = "msd-fabric" + model_data_overlay = { + 'vrf_attach_switches_list': ['missing-vrf-switch'], + 'network_attach_switches_list': ['missing-network-switch'] + } + + # Need at least one switch in inventory for the plugin to check + mock_inventory_response = { + 'response': [ + { + 'hostName': 'existing-switch', + 'ipAddress': '10.1.1.1', + 'fabricName': 'child-fabric1' + } + ] + } + + task_args = { + 'parent_fabric_name': parent_fabric_name, + 'model_data_overlay': model_data_overlay + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Mock only _execute_module + with patch.object(ActionModule, '_execute_module') as mock_execute: + mock_execute.return_value = mock_inventory_response + + result = action_module.run() + + self.assertTrue(result['failed']) + self.assertEqual(len(result['msg']), 2) + + def test_run_empty_inventory_response(self): + """Test run with empty inventory response.""" + parent_fabric_name = "msd-fabric" + model_data_overlay = { + 'vrf_attach_switches_list': [], + 'network_attach_switches_list': [] + } + + mock_inventory_response = { + 'response': [] + } + + task_args = { + 'parent_fabric_name': parent_fabric_name, + 'model_data_overlay': model_data_overlay + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Mock only _execute_module + with patch.object(ActionModule, '_execute_module') as mock_execute: + mock_execute.return_value = mock_inventory_response + + result = action_module.run() + + self.assertFalse(result['failed']) + self.assertEqual(result['msd_switches'], {}) + + def test_run_switch_not_part_of_fabric_string_response(self): + """Test run when response is string indicating switch not part of fabric.""" + parent_fabric_name = "msd-fabric" + model_data_overlay = { + 'vrf_attach_switches_list': [], + 'network_attach_switches_list': [] + } + + mock_inventory_response = { + 'response': 'The queried switch is not part of the fabric configured' + } + + task_args = { + 'parent_fabric_name': parent_fabric_name, + 'model_data_overlay': model_data_overlay + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Mock only _execute_module + with patch.object(ActionModule, '_execute_module') as mock_execute: + mock_execute.return_value = mock_inventory_response + + result = action_module.run() + + self.assertFalse(result['failed']) + self.assertEqual(result['msd_switches'], {}) + + def test_run_missing_response_key(self): + """Test run when response key is missing.""" + parent_fabric_name = "msd-fabric" + model_data_overlay = { + 'vrf_attach_switches_list': [], + 'network_attach_switches_list': [] + } + + mock_inventory_response = {} + + task_args = { + 'parent_fabric_name': parent_fabric_name, + 'model_data_overlay': model_data_overlay + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Mock only _execute_module + with patch.object(ActionModule, '_execute_module') as mock_execute: + mock_execute.return_value = mock_inventory_response + + result = action_module.run() + + self.assertFalse(result['failed']) + self.assertEqual(result['msd_switches'], {}) + + def test_run_empty_attach_lists(self): + """Test run with empty attach lists.""" + parent_fabric_name = "msd-fabric" + model_data_overlay = { + 'vrf_attach_switches_list': [], + 'network_attach_switches_list': [] + } + + mock_inventory_response = { + 'response': [ + { + 'hostName': 'switch1', + 'ipAddress': '10.1.1.1', + 'fabricName': 'child-fabric1' + } + ] + } + + task_args = { + 'parent_fabric_name': parent_fabric_name, + 'model_data_overlay': model_data_overlay + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Mock only _execute_module + with patch.object(ActionModule, '_execute_module') as mock_execute: + mock_execute.return_value = mock_inventory_response + + result = action_module.run() + + self.assertFalse(result['failed']) + self.assertIn('msd_switches', result) + self.assertEqual(result['msd_switches']['switch1'], '10.1.1.1') diff --git a/tests/unit/plugins/action/dtc/test_prepare_msite_child_fabrics_data.py b/tests/unit/plugins/action/dtc/test_prepare_msite_child_fabrics_data.py new file mode 100644 index 000000000..86e6a6cdc --- /dev/null +++ b/tests/unit/plugins/action/dtc/test_prepare_msite_child_fabrics_data.py @@ -0,0 +1,339 @@ +# Copyright (c) 2025 Cisco Systems, Inc. and its affiliates +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +""" +Unit tests for prepare_msite_child_fabrics_data action plugin. +""" + +from unittest.mock import patch + +# Try to import from the plugins directory +try: + from plugins.action.dtc.prepare_msite_child_fabrics_data import ActionModule +except ImportError: + # Fallback for when running tests from different locations + import sys + import os + sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..', '..', '..')) + from plugins.action.dtc.prepare_msite_child_fabrics_data import ActionModule +from .base_test import ActionModuleTestCase + + +class TestPrepareMsiteChildFabricsDataActionModule(ActionModuleTestCase): + """Test cases for prepare_msite_child_fabrics_data action plugin.""" + + def test_run_add_new_child_fabrics(self): + """Test run when new child fabrics need to be added.""" + parent_fabric = "msd-parent" + child_fabrics = [ + {'name': 'child-fabric1'}, + {'name': 'child-fabric2'}, + {'name': 'child-fabric3'} + ] + + # Currently no child fabrics are associated + mock_msd_response = { + 'response': { + 'DATA': [] + } + } + + task_args = { + 'parent_fabric': parent_fabric, + 'child_fabrics': child_fabrics + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Only mock _execute_module, not the parent run() + with patch.object(ActionModule, '_execute_module') as mock_execute: + mock_execute.return_value = mock_msd_response + + result = action_module.run() + + self.assertFalse(result['failed']) + self.assertEqual(result['current_associated_child_fabrics'], []) + self.assertEqual(result['to_be_removed'], []) + self.assertEqual(set(result['to_be_added']), {'child-fabric1', 'child-fabric2', 'child-fabric3'}) + + def test_run_remove_existing_child_fabrics(self): + """Test run when existing child fabrics need to be removed.""" + parent_fabric = "msd-parent" + child_fabrics = [ + {'name': 'child-fabric1'} # Only keeping one fabric + ] + + # Currently has three child fabrics associated + mock_msd_response = { + 'response': { + 'DATA': [ + {'fabricParent': 'msd-parent', 'fabricName': 'child-fabric1'}, + {'fabricParent': 'msd-parent', 'fabricName': 'child-fabric2'}, + {'fabricParent': 'msd-parent', 'fabricName': 'child-fabric3'}, + {'fabricParent': 'other-parent', 'fabricName': 'other-child'} # Different parent + ] + } + } + + task_args = { + 'parent_fabric': parent_fabric, + 'child_fabrics': child_fabrics + } + + action_module = self.create_action_module(ActionModule, task_args) + + with patch.object(ActionModule, '_execute_module') as mock_execute: + mock_execute.return_value = mock_msd_response + + result = action_module.run() + + self.assertFalse(result['failed']) + self.assertEqual(set(result['current_associated_child_fabrics']), {'child-fabric1', 'child-fabric2', 'child-fabric3'}) + self.assertEqual(set(result['to_be_removed']), {'child-fabric2', 'child-fabric3'}) + self.assertEqual(result['to_be_added'], []) + + def test_run_mixed_add_remove_scenarios(self): + """Test run with mixed add and remove scenarios.""" + parent_fabric = "msd-parent" + child_fabrics = [ + {'name': 'child-fabric1'}, # Keep existing + {'name': 'child-fabric3'}, # Add new + {'name': 'child-fabric4'} # Add new + ] + + # Currently has child-fabric1 and child-fabric2 + mock_msd_response = { + 'response': { + 'DATA': [ + {'fabricParent': 'msd-parent', 'fabricName': 'child-fabric1'}, + {'fabricParent': 'msd-parent', 'fabricName': 'child-fabric2'}, + {'fabricParent': 'other-parent', 'fabricName': 'other-child'} + ] + } + } + + task_args = { + 'parent_fabric': parent_fabric, + 'child_fabrics': child_fabrics + } + + action_module = self.create_action_module(ActionModule, task_args) + + with patch.object(ActionModule, '_execute_module') as mock_execute: + mock_execute.return_value = mock_msd_response + + result = action_module.run() + + self.assertFalse(result['failed']) + self.assertEqual(set(result['current_associated_child_fabrics']), {'child-fabric1', 'child-fabric2'}) + self.assertEqual(result['to_be_removed'], ['child-fabric2']) # Remove child-fabric2 + self.assertEqual(set(result['to_be_added']), {'child-fabric3', 'child-fabric4'}) # Add new ones + + def test_run_no_changes_needed(self): + """Test run when no changes are needed.""" + parent_fabric = "msd-parent" + child_fabrics = [ + {'name': 'child-fabric1'}, + {'name': 'child-fabric2'} + ] + + # Currently has exactly the same child fabrics + mock_msd_response = { + 'response': { + 'DATA': [ + {'fabricParent': 'msd-parent', 'fabricName': 'child-fabric1'}, + {'fabricParent': 'msd-parent', 'fabricName': 'child-fabric2'}, + {'fabricParent': 'other-parent', 'fabricName': 'other-child'} + ] + } + } + + task_args = { + 'parent_fabric': parent_fabric, + 'child_fabrics': child_fabrics + } + + action_module = self.create_action_module(ActionModule, task_args) + + with patch.object(ActionModule, '_execute_module') as mock_execute: + mock_execute.return_value = mock_msd_response + + result = action_module.run() + + self.assertFalse(result['failed']) + self.assertEqual(set(result['current_associated_child_fabrics']), {'child-fabric1', 'child-fabric2'}) + self.assertEqual(result['to_be_removed'], []) + self.assertEqual(result['to_be_added'], []) + + def test_run_empty_child_fabrics_list(self): + """Test run with empty child fabrics list (remove all).""" + parent_fabric = "msd-parent" + child_fabrics = [] + + mock_msd_response = { + 'response': { + 'DATA': [ + {'fabricParent': 'msd-parent', 'fabricName': 'child-fabric1'}, + {'fabricParent': 'msd-parent', 'fabricName': 'child-fabric2'} + ] + } + } + + task_args = { + 'parent_fabric': parent_fabric, + 'child_fabrics': child_fabrics + } + + action_module = self.create_action_module(ActionModule, task_args) + + with patch.object(ActionModule, '_execute_module') as mock_execute: + mock_execute.return_value = mock_msd_response + + result = action_module.run() + + self.assertFalse(result['failed']) + self.assertEqual(set(result['current_associated_child_fabrics']), {'child-fabric1', 'child-fabric2'}) + self.assertEqual(set(result['to_be_removed']), {'child-fabric1', 'child-fabric2'}) + self.assertEqual(result['to_be_added'], []) + + def test_run_empty_msd_response(self): + """Test run with empty MSD fabric associations response.""" + parent_fabric = "msd-parent" + child_fabrics = [ + {'name': 'child-fabric1'} + ] + + mock_msd_response = { + 'response': { + 'DATA': [] + } + } + + task_args = { + 'parent_fabric': parent_fabric, + 'child_fabrics': child_fabrics + } + + action_module = self.create_action_module(ActionModule, task_args) + + with patch.object(ActionModule, '_execute_module') as mock_execute: + mock_execute.return_value = mock_msd_response + + result = action_module.run() + + self.assertFalse(result['failed']) + self.assertEqual(result['current_associated_child_fabrics'], []) + self.assertEqual(result['to_be_removed'], []) + self.assertEqual(result['to_be_added'], ['child-fabric1']) + + def test_run_different_parent_fabrics_filtered(self): + """Test run that child fabrics from different parents are filtered out.""" + parent_fabric = "msd-parent" + child_fabrics = [ + {'name': 'child-fabric1'} + ] + + mock_msd_response = { + 'response': { + 'DATA': [ + {'fabricParent': 'msd-parent', 'fabricName': 'child-fabric1'}, + {'fabricParent': 'different-parent', 'fabricName': 'other-child1'}, + {'fabricParent': 'different-parent', 'fabricName': 'other-child2'}, + {'fabricParent': 'msd-parent', 'fabricName': 'child-fabric2'} + ] + } + } + + task_args = { + 'parent_fabric': parent_fabric, + 'child_fabrics': child_fabrics + } + + action_module = self.create_action_module(ActionModule, task_args) + + with patch.object(ActionModule, '_execute_module') as mock_execute: + mock_execute.return_value = mock_msd_response + + result = action_module.run() + + self.assertFalse(result['failed']) + # Should only include child fabrics from 'msd-parent', not 'different-parent' + self.assertEqual(set(result['current_associated_child_fabrics']), {'child-fabric1', 'child-fabric2'}) + self.assertEqual(result['to_be_removed'], ['child-fabric2']) + self.assertEqual(result['to_be_added'], []) + + def test_run_fabric_data_structure(self): + """Test run verifies correct handling of fabric data structure.""" + parent_fabric = "msd-parent" + child_fabrics = [ + {'name': 'fabric-with-name'}, + {'name': 'another-fabric'} + ] + + mock_msd_response = { + 'response': { + 'DATA': [ + {'fabricParent': 'msd-parent', 'fabricName': 'existing-fabric'} + ] + } + } + + task_args = { + 'parent_fabric': parent_fabric, + 'child_fabrics': child_fabrics + } + + action_module = self.create_action_module(ActionModule, task_args) + + with patch.object(ActionModule, '_execute_module') as mock_execute: + mock_execute.return_value = mock_msd_response + + result = action_module.run() + + # Verify that fabric names are extracted correctly from the 'name' key + self.assertEqual(set(result['to_be_added']), {'fabric-with-name', 'another-fabric'}) + self.assertEqual(result['to_be_removed'], ['existing-fabric']) + + def test_run_missing_response_keys(self): + """Test run when response keys are missing - should handle gracefully or raise appropriate error.""" + parent_fabric = "msd-parent" + child_fabrics = [ + {'name': 'child-fabric1'} + ] + + # Missing 'response' or 'DATA' keys should be handled gracefully + mock_msd_response = { + 'other_key': 'value' + } + + task_args = { + 'parent_fabric': parent_fabric, + 'child_fabrics': child_fabrics + } + + action_module = self.create_action_module(ActionModule, task_args) + + with patch.object(ActionModule, '_execute_module') as mock_execute: + mock_execute.return_value = mock_msd_response + + # This should raise an AttributeError due to missing keys + with self.assertRaises(AttributeError): + result = action_module.run() diff --git a/tests/unit/plugins/action/dtc/test_prepare_msite_data.py b/tests/unit/plugins/action/dtc/test_prepare_msite_data.py new file mode 100644 index 000000000..da2d38001 --- /dev/null +++ b/tests/unit/plugins/action/dtc/test_prepare_msite_data.py @@ -0,0 +1,493 @@ +# Copyright (c) 2025 Cisco Systems, Inc. and its affiliates +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +""" +Unit tests for prepare_msite_data action plugin. +""" + +from unittest.mock import patch + +# Try to import from the plugins directory +try: + from plugins.action.dtc.prepare_msite_data import ActionModule +except ImportError: + # Fallback for when running tests from different locations + import sys + import os + sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..', '..', '..')) + from plugins.action.dtc.prepare_msite_data import ActionModule +from .base_test import ActionModuleTestCase + + +class TestPrepareMsiteDataActionModule(ActionModuleTestCase): + """Test cases for prepare_msite_data action plugin.""" + + def test_run_basic_functionality(self): + """Test run with basic functionality.""" + model_data = { + 'vxlan': { + 'multisite': { + 'overlay': { + 'vrf_attach_groups': [ + { + 'name': 'vrf-group1', + 'switches': [ + {'hostname': 'switch1'}, + {'hostname': 'switch2'} + ] + } + ], + 'network_attach_groups': [ + { + 'name': 'net-group1', + 'switches': [ + {'hostname': 'switch1'}, + {'hostname': 'switch3'} + ] + } + ], + 'vrfs': [ + {'name': 'vrf1', 'vrf_attach_group': 'vrf-group1'}, + {'name': 'vrf2', 'vrf_attach_group': 'nonexistent-group'} + ], + 'networks': [ + {'name': 'net1', 'network_attach_group': 'net-group1'}, + {'name': 'net2', 'network_attach_group': 'nonexistent-group'} + ] + } + } + } + } + parent_fabric = "msd-parent" + + mock_msd_response = { + 'response': { + 'DATA': [ + {'fabricParent': 'msd-parent', 'fabricName': 'child-fabric1', 'fabricType': 'VXLAN_EVPN'}, + {'fabricParent': 'msd-parent', 'fabricName': 'child-fabric2', 'fabricType': 'VXLAN_EVPN'} + ] + } + } + + mock_fabric_attributes = {'attr1': 'value1'} + mock_fabric_switches = [ + {'hostname': 'switch1', 'mgmt_ip_address': '10.1.1.1'}, + {'hostname': 'switch2', 'mgmt_ip_address': '10.1.1.2'} + ] + + task_args = { + 'model_data': model_data, + 'parent_fabric': parent_fabric + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Only mock external dependencies, not the parent run() + with patch.object(ActionModule, '_execute_module') as mock_execute, \ + patch('plugins.action.dtc.prepare_msite_data.ndfc_get_fabric_attributes') as mock_get_attributes, \ + patch('plugins.action.dtc.prepare_msite_data.ndfc_get_fabric_switches') as mock_get_switches: + + mock_execute.return_value = mock_msd_response + mock_get_attributes.return_value = mock_fabric_attributes + mock_get_switches.return_value = mock_fabric_switches + + result = action_module.run() + + self.assertFalse(result['failed']) + self.assertIn('child_fabrics_data', result) + self.assertIn('overlay_attach_groups', result) + + # Verify child fabrics data structure + self.assertIn('child-fabric1', result['child_fabrics_data']) + self.assertIn('child-fabric2', result['child_fabrics_data']) + self.assertEqual(result['child_fabrics_data']['child-fabric1']['type'], 'VXLAN_EVPN') + self.assertEqual(result['child_fabrics_data']['child-fabric1']['attributes'], mock_fabric_attributes) + self.assertEqual(result['child_fabrics_data']['child-fabric1']['switches'], mock_fabric_switches) + + def test_run_switch_hostname_ip_mapping(self): + """Test run with hostname to IP mapping functionality.""" + model_data = { + 'vxlan': { + 'multisite': { + 'overlay': { + 'vrf_attach_groups': [ + { + 'name': 'vrf-group1', + 'switches': [ + {'hostname': 'leaf1'}, + {'hostname': 'leaf2'} + ] + } + ], + 'network_attach_groups': [], + 'vrfs': [{'name': 'vrf1', 'vrf_attach_group': 'vrf-group1'}], + 'networks': [] + } + } + } + } + parent_fabric = "msd-parent" + + mock_msd_response = { + 'response': { + 'DATA': [ + {'fabricParent': 'msd-parent', 'fabricName': 'child-fabric1', 'fabricType': 'VXLAN_EVPN'} + ] + } + } + + mock_fabric_attributes = {'attr1': 'value1'} + mock_fabric_switches = [ + {'hostname': 'leaf1', 'mgmt_ip_address': '10.1.1.1'}, + {'hostname': 'leaf2.domain.com', 'mgmt_ip_address': '10.1.1.2'} + ] + + task_args = { + 'model_data': model_data, + 'parent_fabric': parent_fabric + } + + action_module = self.create_action_module(ActionModule, task_args) + + with patch.object(ActionModule, '_execute_module') as mock_execute, \ + patch('plugins.action.dtc.prepare_msite_data.ndfc_get_fabric_attributes') as mock_get_attributes, \ + patch('plugins.action.dtc.prepare_msite_data.ndfc_get_fabric_switches') as mock_get_switches: + + mock_execute.return_value = mock_msd_response + mock_get_attributes.return_value = mock_fabric_attributes + mock_get_switches.return_value = mock_fabric_switches + + result = action_module.run() + + # Check that hostnames are mapped to IP addresses + vrf_groups = result['overlay_attach_groups']['vrf_attach_groups_dict'] + self.assertIn('vrf-group1', vrf_groups) + + # Find the switch that should have gotten the IP mapping + leaf1_found = False + for switch in vrf_groups['vrf-group1']: + if switch['hostname'] == 'leaf1': + self.assertEqual(switch['mgmt_ip_address'], '10.1.1.1') + leaf1_found = True + self.assertTrue(leaf1_found) + + def test_run_empty_model_data(self): + """Test run with empty model data.""" + model_data = { + 'vxlan': { + 'multisite': { + 'overlay': { + 'vrf_attach_groups': [], + 'network_attach_groups': [], + 'vrfs': [], + 'networks': [] + } + } + } + } + parent_fabric = "msd-parent" + + mock_msd_response = { + 'response': { + 'DATA': [] + } + } + + task_args = { + 'model_data': model_data, + 'parent_fabric': parent_fabric + } + + action_module = self.create_action_module(ActionModule, task_args) + + with patch.object(ActionModule, '_execute_module') as mock_execute, \ + patch('plugins.action.dtc.prepare_msite_data.ndfc_get_fabric_attributes') as mock_get_attributes, \ + patch('plugins.action.dtc.prepare_msite_data.ndfc_get_fabric_switches') as mock_get_switches: + + mock_execute.return_value = mock_msd_response + mock_get_attributes.return_value = {} + mock_get_switches.return_value = [] + + result = action_module.run() + + self.assertFalse(result['failed']) + self.assertEqual(result['child_fabrics_data'], {}) + self.assertIn('vrf_attach_groups_dict', result['overlay_attach_groups']) + self.assertIn('network_attach_groups_dict', result['overlay_attach_groups']) + + def test_run_vrf_attach_group_removal(self): + """Test run with VRF attach group removal for nonexistent groups.""" + model_data = { + 'vxlan': { + 'multisite': { + 'overlay': { + 'vrf_attach_groups': [ + { + 'name': 'vrf-group1', + 'switches': [{'hostname': 'switch1'}] + } + ], + 'network_attach_groups': [], + 'vrfs': [ + {'name': 'vrf1', 'vrf_attach_group': 'vrf-group1'}, + {'name': 'vrf2', 'vrf_attach_group': 'nonexistent-group'} + ], + 'networks': [] + } + } + } + } + parent_fabric = "msd-parent" + + mock_msd_response = { + 'response': { + 'DATA': [ + {'fabricParent': 'msd-parent', 'fabricName': 'child-fabric1', 'fabricType': 'VXLAN_EVPN'} + ] + } + } + + task_args = { + 'model_data': model_data, + 'parent_fabric': parent_fabric + } + + action_module = self.create_action_module(ActionModule, task_args) + + with patch.object(ActionModule, '_execute_module') as mock_execute, \ + patch('plugins.action.dtc.prepare_msite_data.ndfc_get_fabric_attributes') as mock_get_attributes, \ + patch('plugins.action.dtc.prepare_msite_data.ndfc_get_fabric_switches') as mock_get_switches: + + mock_execute.return_value = mock_msd_response + mock_get_attributes.return_value = {} + mock_get_switches.return_value = [] + + result = action_module.run() + + # Check that nonexistent vrf_attach_group is removed + vrfs = result['overlay_attach_groups']['vrfs'] + vrf1 = next((vrf for vrf in vrfs if vrf['name'] == 'vrf1'), None) + vrf2 = next((vrf for vrf in vrfs if vrf['name'] == 'vrf2'), None) + + self.assertIsNotNone(vrf1) + self.assertIn('vrf_attach_group', vrf1) + self.assertEqual(vrf1['vrf_attach_group'], 'vrf-group1') + + self.assertIsNotNone(vrf2) + self.assertNotIn('vrf_attach_group', vrf2) # Should be removed + + def test_run_network_attach_group_removal(self): + """Test run with network attach group removal for nonexistent groups.""" + model_data = { + 'vxlan': { + 'multisite': { + 'overlay': { + 'vrf_attach_groups': [], + 'network_attach_groups': [ + { + 'name': 'net-group1', + 'switches': [{'hostname': 'switch1'}] + } + ], + 'vrfs': [], + 'networks': [ + {'name': 'net1', 'network_attach_group': 'net-group1'}, + {'name': 'net2', 'network_attach_group': 'nonexistent-group'} + ] + } + } + } + } + parent_fabric = "msd-parent" + + mock_msd_response = { + 'response': { + 'DATA': [ + {'fabricParent': 'msd-parent', 'fabricName': 'child-fabric1', 'fabricType': 'VXLAN_EVPN'} + ] + } + } + + task_args = { + 'model_data': model_data, + 'parent_fabric': parent_fabric + } + + action_module = self.create_action_module(ActionModule, task_args) + + with patch.object(ActionModule, '_execute_module') as mock_execute, \ + patch('plugins.action.dtc.prepare_msite_data.ndfc_get_fabric_attributes') as mock_get_attributes, \ + patch('plugins.action.dtc.prepare_msite_data.ndfc_get_fabric_switches') as mock_get_switches: + + mock_execute.return_value = mock_msd_response + mock_get_attributes.return_value = {} + mock_get_switches.return_value = [] + + result = action_module.run() + + # Check that nonexistent network_attach_group is removed + networks = result['overlay_attach_groups']['networks'] + net1 = next((net for net in networks if net['name'] == 'net1'), None) + net2 = next((net for net in networks if net['name'] == 'net2'), None) + + self.assertIsNotNone(net1) + self.assertIn('network_attach_group', net1) + self.assertEqual(net1['network_attach_group'], 'net-group1') + + self.assertIsNotNone(net2) + self.assertNotIn('network_attach_group', net2) # Should be removed + + def test_run_switches_list_population(self): + """Test run verifies switches list population.""" + model_data = { + 'vxlan': { + 'multisite': { + 'overlay': { + 'vrf_attach_groups': [ + { + 'name': 'vrf-group1', + 'switches': [ + {'hostname': 'switch1'}, + {'hostname': 'switch2'} + ] + } + ], + 'network_attach_groups': [ + { + 'name': 'net-group1', + 'switches': [ + {'hostname': 'switch3'}, + {'hostname': 'switch4'} + ] + } + ], + 'vrfs': [], + 'networks': [] + } + } + } + } + parent_fabric = "msd-parent" + + mock_msd_response = { + 'response': { + 'DATA': [ + {'fabricParent': 'msd-parent', 'fabricName': 'child-fabric1', 'fabricType': 'VXLAN_EVPN'} + ] + } + } + + task_args = { + 'model_data': model_data, + 'parent_fabric': parent_fabric + } + + action_module = self.create_action_module(ActionModule, task_args) + + with patch.object(ActionModule, '_execute_module') as mock_execute, \ + patch('plugins.action.dtc.prepare_msite_data.ndfc_get_fabric_attributes') as mock_get_attributes, \ + patch('plugins.action.dtc.prepare_msite_data.ndfc_get_fabric_switches') as mock_get_switches: + + mock_execute.return_value = mock_msd_response + mock_get_attributes.return_value = {} + mock_get_switches.return_value = [] + + result = action_module.run() + + # Check that switches lists are populated correctly + overlay = result['overlay_attach_groups'] + self.assertIn('vrf_attach_switches_list', overlay) + self.assertIn('network_attach_switches_list', overlay) + + # Verify the switches are in the lists + self.assertIn('switch1', overlay['vrf_attach_switches_list']) + self.assertIn('switch2', overlay['vrf_attach_switches_list']) + self.assertIn('switch3', overlay['network_attach_switches_list']) + self.assertIn('switch4', overlay['network_attach_switches_list']) + + def test_run_regex_hostname_matching(self): + """Test run with regex hostname matching functionality.""" + model_data = { + 'vxlan': { + 'multisite': { + 'overlay': { + 'vrf_attach_groups': [ + { + 'name': 'vrf-group1', + 'switches': [ + {'hostname': 'leaf1'}, + {'hostname': 'leaf2'} + ] + } + ], + 'network_attach_groups': [], + 'vrfs': [], + 'networks': [] + } + } + } + } + parent_fabric = "msd-parent" + + mock_msd_response = { + 'response': { + 'DATA': [ + {'fabricParent': 'msd-parent', 'fabricName': 'child-fabric1', 'fabricType': 'VXLAN_EVPN'} + ] + } + } + + mock_fabric_attributes = {} + # Test regex matching - NDFC returns FQDN but data model has just hostname + mock_fabric_switches = [ + {'hostname': 'leaf1.example.com', 'mgmt_ip_address': '10.1.1.1'}, + {'hostname': 'leaf2.example.com', 'mgmt_ip_address': '10.1.1.2'} + ] + + task_args = { + 'model_data': model_data, + 'parent_fabric': parent_fabric + } + + action_module = self.create_action_module(ActionModule, task_args) + + with patch.object(ActionModule, '_execute_module') as mock_execute, \ + patch('plugins.action.dtc.prepare_msite_data.ndfc_get_fabric_attributes') as mock_get_attributes, \ + patch('plugins.action.dtc.prepare_msite_data.ndfc_get_fabric_switches') as mock_get_switches: + + mock_execute.return_value = mock_msd_response + mock_get_attributes.return_value = mock_fabric_attributes + mock_get_switches.return_value = mock_fabric_switches + + result = action_module.run() + + # Check that regex matching worked for hostname mapping + vrf_groups = result['overlay_attach_groups']['vrf_attach_groups_dict'] + self.assertIn('vrf-group1', vrf_groups) + + # Both switches should have gotten IP mappings via regex matching + switches = vrf_groups['vrf-group1'] + for switch in switches: + self.assertIn('mgmt_ip_address', switch) + if switch['hostname'] == 'leaf1': + self.assertEqual(switch['mgmt_ip_address'], '10.1.1.1') + elif switch['hostname'] == 'leaf2': + self.assertEqual(switch['mgmt_ip_address'], '10.1.1.2') diff --git a/tests/unit/plugins/action/dtc/test_unmanaged_child_fabric_networks.py b/tests/unit/plugins/action/dtc/test_unmanaged_child_fabric_networks.py new file mode 100644 index 000000000..128d832cb --- /dev/null +++ b/tests/unit/plugins/action/dtc/test_unmanaged_child_fabric_networks.py @@ -0,0 +1,331 @@ +# Copyright (c) 2025 Cisco Systems, Inc. and its affiliates +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +""" +Unit tests for unmanaged_child_fabric_networks action plugin. +""" + +import unittest +from unittest.mock import patch + +# Try to import from the plugins directory +try: + from plugins.action.dtc.unmanaged_child_fabric_networks import ActionModule +except ImportError: + # Fallback for when running tests from different locations + import sys + import os + sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..', '..', '..')) + from plugins.action.dtc.unmanaged_child_fabric_networks import ActionModule +from .base_test import ActionModuleTestCase + + +class TestUnmanagedChildFabricNetworksActionModule(ActionModuleTestCase): + """Test cases for unmanaged_child_fabric_networks action plugin.""" + + def setUp(self): + """Set up test fixtures.""" + super().setUp() + self.maxDiff = None + + self.mock_task_args = { + 'fabric': 'test_fabric', + 'msite_data': { + 'overlay_attach_groups': { + 'networks': [ + {'name': 'managed_network_1'}, + {'name': 'managed_network_2'} + ] + } + } + } + + def test_run_no_networks_in_ndfc(self): + """Test run when NDFC has no networks.""" + action_module = self.create_action_module(ActionModule, self.mock_task_args) + + with patch.object(action_module, '_execute_module') as mock_execute: + # Mock NDFC query returning no networks + mock_execute.return_value = { + 'response': [] + } + + result = action_module.run() + + self.assertFalse(result['changed']) + self.assertFalse(result['failed']) + self.assertEqual(mock_execute.call_count, 1) + + def test_run_ndfc_query_failed(self): + """Test run when NDFC query fails.""" + action_module = self.create_action_module(ActionModule, self.mock_task_args) + + with patch.object(action_module, '_execute_module') as mock_execute: + # Mock NDFC query failure + mock_execute.return_value = { + 'failed': True, + 'msg': 'Fabric test_fabric missing on DCNM or does not have any switches' + } + + result = action_module.run() + + self.assertTrue(result['failed']) + self.assertIn('Fabric test_fabric missing', result['msg']) + self.assertEqual(mock_execute.call_count, 1) + + def test_run_no_unmanaged_networks(self): + """Test run when all NDFC networks are managed.""" + action_module = self.create_action_module(ActionModule, self.mock_task_args) + + with patch.object(action_module, '_execute_module') as mock_execute: + # Mock NDFC query returning only managed networks + mock_execute.return_value = { + 'response': [ + { + 'parent': { + 'networkName': 'managed_network_1' + } + }, + { + 'parent': { + 'networkName': 'managed_network_2' + } + } + ] + } + + result = action_module.run() + + self.assertFalse(result['changed']) + self.assertFalse(result['failed']) + self.assertEqual(mock_execute.call_count, 1) + + def test_run_with_unmanaged_networks_delete_success(self): + """Test run when unmanaged networks are found and successfully deleted.""" + action_module = self.create_action_module(ActionModule, self.mock_task_args) + + with patch.object(action_module, '_execute_module') as mock_execute: + # Mock NDFC query returning managed + unmanaged networks + mock_execute.side_effect = [ + # First call: query networks + { + 'response': [ + { + 'parent': { + 'networkName': 'managed_network_1' + } + }, + { + 'parent': { + 'networkName': 'unmanaged_network_1' + } + }, + { + 'parent': { + 'networkName': 'unmanaged_network_2' + } + } + ] + }, + # Second call: delete unmanaged networks (success) + { + 'changed': True + } + ] + + result = action_module.run() + + self.assertTrue(result['changed']) + self.assertFalse(result['failed']) + self.assertEqual(mock_execute.call_count, 2) + + # Verify the delete call was made with correct config + delete_call_args = mock_execute.call_args_list[1] + delete_config = delete_call_args[1]['module_args']['config'] + self.assertEqual(len(delete_config), 2) + self.assertEqual(delete_config[0]['net_name'], 'unmanaged_network_1') + self.assertEqual(delete_config[1]['net_name'], 'unmanaged_network_2') + self.assertTrue(delete_config[0]['deploy']) + self.assertTrue(delete_config[1]['deploy']) + + def test_run_with_unmanaged_networks_delete_failed(self): + """Test run when unmanaged networks deletion fails.""" + action_module = self.create_action_module(ActionModule, self.mock_task_args) + + with patch.object(action_module, '_execute_module') as mock_execute: + # Mock NDFC query returning unmanaged networks + mock_execute.side_effect = [ + # First call: query networks + { + 'response': [ + { + 'parent': { + 'networkName': 'managed_network_1' + } + }, + { + 'parent': { + 'networkName': 'unmanaged_network_1' + } + } + ] + }, + # Second call: delete unmanaged networks (fails) + { + 'failed': True, + 'msg': 'Failed to delete network unmanaged_network_1' + } + ] + + result = action_module.run() + + self.assertTrue(result['failed']) + self.assertIn('Failed to delete network', result['msg']) + self.assertEqual(mock_execute.call_count, 2) + + def test_run_ndfc_networks_no_response_key(self): + """Test run when NDFC query succeeds but has no response key.""" + action_module = self.create_action_module(ActionModule, self.mock_task_args) + + with patch.object(action_module, '_execute_module') as mock_execute: + # Mock NDFC query without response key + mock_execute.return_value = { + 'changed': False + } + + result = action_module.run() + + self.assertFalse(result['changed']) + self.assertFalse(result['failed']) + self.assertEqual(mock_execute.call_count, 1) + + def test_run_empty_managed_networks_list(self): + """Test run when data model has no managed networks.""" + task_args = { + 'fabric': 'test_fabric', + 'msite_data': { + 'overlay_attach_groups': { + 'networks': [] + } + } + } + + action_module = self.create_action_module(ActionModule, task_args) + + with patch.object(action_module, '_execute_module') as mock_execute: + # Mock NDFC query returning some networks + mock_execute.side_effect = [ + # First call: query networks + { + 'response': [ + { + 'parent': { + 'networkName': 'unmanaged_network_1' + } + }, + { + 'parent': { + 'networkName': 'unmanaged_network_2' + } + } + ] + }, + # Second call: delete all networks (success) + { + 'changed': True + } + ] + + result = action_module.run() + + self.assertTrue(result['changed']) + self.assertFalse(result['failed']) + self.assertEqual(mock_execute.call_count, 2) + + def test_run_mixed_network_scenarios(self): + """Test run with various network name patterns.""" + task_args = { + 'fabric': 'test_fabric', + 'msite_data': { + 'overlay_attach_groups': { + 'networks': [ + {'name': 'prod_network'}, + {'name': 'test_network_123'}, + {'name': 'special-chars_network'} + ] + } + } + } + + action_module = self.create_action_module(ActionModule, task_args) + + with patch.object(action_module, '_execute_module') as mock_execute: + # Mock NDFC query with mixed managed/unmanaged networks + mock_execute.side_effect = [ + # First call: query networks + { + 'response': [ + { + 'parent': { + 'networkName': 'prod_network' # managed + } + }, + { + 'parent': { + 'networkName': 'old_unmanaged_net' # unmanaged + } + }, + { + 'parent': { + 'networkName': 'test_network_123' # managed + } + }, + { + 'parent': { + 'networkName': 'legacy_network' # unmanaged + } + } + ] + }, + # Second call: delete unmanaged networks (success) + { + 'changed': True + } + ] + + result = action_module.run() + + self.assertTrue(result['changed']) + self.assertFalse(result['failed']) + self.assertEqual(mock_execute.call_count, 2) + + # Verify only unmanaged networks were marked for deletion + delete_call_args = mock_execute.call_args_list[1] + delete_config = delete_call_args[1]['module_args']['config'] + unmanaged_names = [config['net_name'] for config in delete_config] + self.assertIn('old_unmanaged_net', unmanaged_names) + self.assertIn('legacy_network', unmanaged_names) + self.assertNotIn('prod_network', unmanaged_names) + self.assertNotIn('test_network_123', unmanaged_names) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/unit/plugins/action/dtc/test_unmanaged_child_fabric_vrfs.py b/tests/unit/plugins/action/dtc/test_unmanaged_child_fabric_vrfs.py new file mode 100644 index 000000000..7e78d2070 --- /dev/null +++ b/tests/unit/plugins/action/dtc/test_unmanaged_child_fabric_vrfs.py @@ -0,0 +1,395 @@ +# Copyright (c) 2025 Cisco Systems, Inc. and its affiliates +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +""" +Unit tests for unmanaged_child_fabric_vrfs action plugin. +""" + +import unittest +from unittest.mock import patch + +# Try to import from the plugins directory +try: + from plugins.action.dtc.unmanaged_child_fabric_vrfs import ActionModule +except ImportError: + # Fallback for when running tests from different locations + import sys + import os + sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..', '..', '..')) + from plugins.action.dtc.unmanaged_child_fabric_vrfs import ActionModule +from .base_test import ActionModuleTestCase + + +class TestUnmanagedChildFabricVrfsActionModule(ActionModuleTestCase): + """Test cases for unmanaged_child_fabric_vrfs action plugin.""" + + def setUp(self): + """Set up test fixtures.""" + super().setUp() + self.maxDiff = None + + self.mock_task_args = { + 'fabric': 'test_fabric', + 'msite_data': { + 'overlay_attach_groups': { + 'vrfs': [ + {'name': 'managed_vrf_1'}, + {'name': 'managed_vrf_2'} + ] + } + } + } + + def test_run_no_vrfs_in_ndfc(self): + """Test run when NDFC has no VRFs.""" + action_module = self.create_action_module(ActionModule, self.mock_task_args) + + with patch.object(action_module, '_execute_module') as mock_execute: + # Mock NDFC query returning no VRFs + mock_execute.return_value = { + 'response': [] + } + + result = action_module.run() + + self.assertFalse(result['changed']) + self.assertFalse(result['failed']) + self.assertEqual(mock_execute.call_count, 1) + + def test_run_ndfc_query_failed(self): + """Test run when NDFC query fails.""" + action_module = self.create_action_module(ActionModule, self.mock_task_args) + + with patch.object(action_module, '_execute_module') as mock_execute: + # Mock NDFC query failure + mock_execute.return_value = { + 'failed': True, + 'msg': 'Fabric test_fabric missing on DCNM or does not have any switches' + } + + result = action_module.run() + + self.assertTrue(result['failed']) + self.assertIn('Fabric test_fabric missing', result['msg']) + self.assertEqual(mock_execute.call_count, 1) + + def test_run_no_unmanaged_vrfs(self): + """Test run when all NDFC VRFs are managed.""" + action_module = self.create_action_module(ActionModule, self.mock_task_args) + + with patch.object(action_module, '_execute_module') as mock_execute: + # Mock NDFC query returning only managed VRFs + mock_execute.return_value = { + 'response': [ + { + 'parent': { + 'vrfName': 'managed_vrf_1' + } + }, + { + 'parent': { + 'vrfName': 'managed_vrf_2' + } + } + ] + } + + result = action_module.run() + + self.assertFalse(result['changed']) + self.assertFalse(result['failed']) + self.assertEqual(mock_execute.call_count, 1) + + def test_run_with_unmanaged_vrfs_delete_success(self): + """Test run when unmanaged VRFs are found and successfully deleted.""" + action_module = self.create_action_module(ActionModule, self.mock_task_args) + + with patch.object(action_module, '_execute_module') as mock_execute: + # Mock NDFC query returning managed + unmanaged VRFs + mock_execute.side_effect = [ + # First call: query VRFs + { + 'response': [ + { + 'parent': { + 'vrfName': 'managed_vrf_1' + } + }, + { + 'parent': { + 'vrfName': 'unmanaged_vrf_1' + } + }, + { + 'parent': { + 'vrfName': 'unmanaged_vrf_2' + } + } + ] + }, + # Second call: delete unmanaged VRFs (success) + { + 'changed': True + } + ] + + result = action_module.run() + + self.assertTrue(result['changed']) + self.assertFalse(result['failed']) + self.assertEqual(mock_execute.call_count, 2) + + # Verify the delete call was made with correct config + delete_call_args = mock_execute.call_args_list[1] + delete_config = delete_call_args[1]['module_args']['config'] + self.assertEqual(len(delete_config), 2) + self.assertEqual(delete_config[0]['vrf_name'], 'unmanaged_vrf_1') + self.assertEqual(delete_config[1]['vrf_name'], 'unmanaged_vrf_2') + self.assertTrue(delete_config[0]['deploy']) + self.assertTrue(delete_config[1]['deploy']) + + def test_run_with_unmanaged_vrfs_delete_failed(self): + """Test run when unmanaged VRFs deletion fails.""" + action_module = self.create_action_module(ActionModule, self.mock_task_args) + + with patch.object(action_module, '_execute_module') as mock_execute: + # Mock NDFC query returning unmanaged VRFs + mock_execute.side_effect = [ + # First call: query VRFs + { + 'response': [ + { + 'parent': { + 'vrfName': 'managed_vrf_1' + } + }, + { + 'parent': { + 'vrfName': 'unmanaged_vrf_1' + } + } + ] + }, + # Second call: delete unmanaged VRFs (fails) + { + 'failed': True, + 'msg': 'Failed to delete VRF unmanaged_vrf_1' + } + ] + + result = action_module.run() + + self.assertTrue(result['failed']) + self.assertIn('Failed to delete VRF', result['msg']) + self.assertEqual(mock_execute.call_count, 2) + + def test_run_ndfc_vrfs_no_response_key(self): + """Test run when NDFC query succeeds but has no response key.""" + action_module = self.create_action_module(ActionModule, self.mock_task_args) + + with patch.object(action_module, '_execute_module') as mock_execute: + # Mock NDFC query without response key + mock_execute.return_value = { + 'changed': False + } + + result = action_module.run() + + self.assertFalse(result['changed']) + self.assertFalse(result['failed']) + self.assertEqual(mock_execute.call_count, 1) + + def test_run_empty_managed_vrfs_list(self): + """Test run when data model has no managed VRFs.""" + task_args = { + 'fabric': 'test_fabric', + 'msite_data': { + 'overlay_attach_groups': { + 'vrfs': [] + } + } + } + + action_module = self.create_action_module(ActionModule, task_args) + + with patch.object(action_module, '_execute_module') as mock_execute: + # Mock NDFC query returning some VRFs + mock_execute.side_effect = [ + # First call: query VRFs + { + 'response': [ + { + 'parent': { + 'vrfName': 'unmanaged_vrf_1' + } + }, + { + 'parent': { + 'vrfName': 'unmanaged_vrf_2' + } + } + ] + }, + # Second call: delete all VRFs (success) + { + 'changed': True + } + ] + + result = action_module.run() + + self.assertTrue(result['changed']) + self.assertFalse(result['failed']) + self.assertEqual(mock_execute.call_count, 2) + + def test_run_mixed_vrf_scenarios(self): + """Test run with various VRF name patterns.""" + task_args = { + 'fabric': 'test_fabric', + 'msite_data': { + 'overlay_attach_groups': { + 'vrfs': [ + {'name': 'prod_vrf'}, + {'name': 'test_vrf_123'}, + {'name': 'special-chars_vrf'} + ] + } + } + } + + action_module = self.create_action_module(ActionModule, task_args) + + with patch.object(action_module, '_execute_module') as mock_execute: + # Mock NDFC query with mixed managed/unmanaged VRFs + mock_execute.side_effect = [ + # First call: query VRFs + { + 'response': [ + { + 'parent': { + 'vrfName': 'prod_vrf' # managed + } + }, + { + 'parent': { + 'vrfName': 'old_unmanaged_vrf' # unmanaged + } + }, + { + 'parent': { + 'vrfName': 'test_vrf_123' # managed + } + }, + { + 'parent': { + 'vrfName': 'legacy_vrf' # unmanaged + } + } + ] + }, + # Second call: delete unmanaged VRFs (success) + { + 'changed': True + } + ] + + result = action_module.run() + + self.assertTrue(result['changed']) + self.assertFalse(result['failed']) + self.assertEqual(mock_execute.call_count, 2) + + # Verify only unmanaged VRFs were marked for deletion + delete_call_args = mock_execute.call_args_list[1] + delete_config = delete_call_args[1]['module_args']['config'] + unmanaged_names = [config['vrf_name'] for config in delete_config] + self.assertIn('old_unmanaged_vrf', unmanaged_names) + self.assertIn('legacy_vrf', unmanaged_names) + self.assertNotIn('prod_vrf', unmanaged_names) + self.assertNotIn('test_vrf_123', unmanaged_names) + + def test_run_vrf_query_with_complex_response(self): + """Test run with complex NDFC VRF response structure.""" + action_module = self.create_action_module(ActionModule, self.mock_task_args) + + with patch.object(action_module, '_execute_module') as mock_execute: + # Mock NDFC query with complex response structure + mock_execute.side_effect = [ + # First call: query VRFs with full response structure + { + 'response': [ + { + 'parent': { + 'fabric': 'test_fabric', + 'vrfName': 'managed_vrf_1', + 'enforce': 'None', + 'defaultSGTag': 'None', + 'vrfTemplate': 'Default_VRF_Universal', + 'vrfExtensionTemplate': 'Default_VRF_Extension_Universal', + 'vrfTemplateConfig': '{"vrfName":"managed_vrf_1"}', + 'tenantName': 'None', + 'id': 123, + 'vrfId': 150001, + 'serviceVrfTemplate': 'None', + 'source': 'None', + 'vrfStatus': 'DEPLOYED', + 'hierarchicalKey': 'test_fabric' + }, + 'attach': [ + { + 'vrfName': 'managed_vrf_1', + 'templateName': 'Default_VRF_Universal', + 'switchDetailsList': [] + } + ] + }, + { + 'parent': { + 'fabric': 'test_fabric', + 'vrfName': 'legacy_unmanaged_vrf', + 'vrfStatus': 'DEPLOYED' + }, + 'attach': [] + } + ] + }, + # Second call: delete unmanaged VRFs (success) + { + 'changed': True + } + ] + + result = action_module.run() + + self.assertTrue(result['changed']) + self.assertFalse(result['failed']) + self.assertEqual(mock_execute.call_count, 2) + + # Verify the correct VRF was marked for deletion + delete_call_args = mock_execute.call_args_list[1] + delete_config = delete_call_args[1]['module_args']['config'] + self.assertEqual(len(delete_config), 1) + self.assertEqual(delete_config[0]['vrf_name'], 'legacy_unmanaged_vrf') + self.assertTrue(delete_config[0]['deploy']) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/unit/plugins/action/dtc/test_unmanaged_edge_connections.py b/tests/unit/plugins/action/dtc/test_unmanaged_edge_connections.py new file mode 100644 index 000000000..e8d73210a --- /dev/null +++ b/tests/unit/plugins/action/dtc/test_unmanaged_edge_connections.py @@ -0,0 +1,344 @@ +# Copyright (c) 2025 Cisco Systems, Inc. and its affiliates +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +""" +Unit tests for unmanaged_edge_connections action plugin. +""" + +from unittest.mock import patch + +# Try to import from the plugins directory +try: + from plugins.action.dtc.unmanaged_edge_connections import ActionModule +except ImportError: + # Fallback for when running tests from different locations + import sys + import os + sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..', '..', '..')) + from plugins.action.dtc.unmanaged_edge_connections import ActionModule +from .base_test import ActionModuleTestCase + + +class TestUnmanagedEdgeConnectionsActionModule(ActionModuleTestCase): + """Test cases for unmanaged_edge_connections action plugin.""" + + def test_run_with_unmanaged_policy(self): + """Test run when NDFC has policies not in the data model (unmanaged).""" + switch_data = [ + { + 'serialNumber': 'ABC123', + 'ipAddress': '10.1.1.1' + } + ] + + edge_connections = [ + { + "switch": [ + { + 'ip': '10.1.1.1', + 'policies': [ + {'description': 'nace_test_policy_1'}, + {'description': 'nace_test_policy_2'} + ] + } + ] + } + ] + + def mock_helper_side_effect(self, task_vars, tmp, serial, prefix): + if prefix == 'edge_': + return [] # No edge_ policies + else: # prefix == 'nace_' + return [{'policyId': 'POL123', 'description': 'nace_unmanaged_policy'}] + + task_args = { + 'switch_data': switch_data, + 'edge_connections': edge_connections + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Only mock the helper function, not the parent run() + with patch('plugins.action.dtc.unmanaged_edge_connections.ndfc_get_switch_policy_using_desc') as mock_helper: + mock_helper.side_effect = mock_helper_side_effect + + result = action_module.run() + + # Should detect unmanaged policy and set changed=True + self.assertTrue(result['changed']) + self.assertIn('unmanaged_edge_connections', result) + # Should have one switch with unmanaged policies + self.assertEqual(len(result['unmanaged_edge_connections'][0]['switch']), 1) + self.assertEqual(result['unmanaged_edge_connections'][0]['switch'][0]['ip'], '10.1.1.1') + self.assertEqual(len(result['unmanaged_edge_connections'][0]['switch'][0]['policies']), 1) + self.assertEqual(result['unmanaged_edge_connections'][0]['switch'][0]['policies'][0]['name'], 'POL123') + + def test_run_no_unmanaged_connections(self): + """Test run when all edge connections are managed.""" + switch_data = [ + { + 'serialNumber': 'ABC123', + 'ipAddress': '10.1.1.1' + } + ] + + edge_connections = [ + { + "switch": [ + { + 'ip': '10.1.1.1', + 'policies': [ + {'description': 'nace_test_policy_1'}, + {'description': 'nace_test_policy_2'} + ] + } + ] + } + ] + + def mock_helper_side_effect(self, task_vars, tmp, serial, prefix): + if prefix == 'edge_': + return [] # No edge_ policies + else: # prefix == 'nace_' + # NDFC returns only managed policies (those in the data model) + return [ + {'policyId': 'POL123', 'description': 'nace_test_policy_1'}, + {'policyId': 'POL124', 'description': 'nace_test_policy_2'} + ] + + task_args = { + 'switch_data': switch_data, + 'edge_connections': edge_connections + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Only mock the helper function + with patch('plugins.action.dtc.unmanaged_edge_connections.ndfc_get_switch_policy_using_desc') as mock_helper: + mock_helper.side_effect = mock_helper_side_effect + + result = action_module.run() + + self.assertFalse(result['changed']) + self.assertIn('unmanaged_edge_connections', result) + # Should have empty switch list when no unmanaged policies + self.assertEqual(len(result['unmanaged_edge_connections'][0]['switch']), 0) + + def test_run_empty_edge_connections(self): + """Test run with empty edge connections.""" + switch_data = [] + edge_connections = [{"switch": []}] + + task_args = { + 'switch_data': switch_data, + 'edge_connections': edge_connections + } + + action_module = self.create_action_module(ActionModule, task_args) + + result = action_module.run() + + self.assertFalse(result['changed']) + self.assertIn('unmanaged_edge_connections', result) + + def test_run_switch_not_in_edge_connections(self): + """Test run when switch exists in NDFC but not in edge connections data.""" + switch_data = [ + { + 'serialNumber': 'ABC123', + 'ipAddress': '10.1.1.2' # Different IP not in edge_connections + } + ] + + edge_connections = [ + { + "switch": [ + { + 'ip': '10.1.1.1', + 'policies': [ + {'description': 'nace_test_policy_1'} + ] + } + ] + } + ] + + task_args = { + 'switch_data': switch_data, + 'edge_connections': edge_connections + } + + action_module = self.create_action_module(ActionModule, task_args) + + result = action_module.run() + + self.assertFalse(result['changed']) + self.assertEqual(len(result['unmanaged_edge_connections'][0]['switch']), 0) + + def test_run_multiple_switches_with_mixed_policies(self): + """Test run with multiple switches, some with unmanaged policies.""" + switch_data = [ + { + 'serialNumber': 'ABC123', + 'ipAddress': '10.1.1.1' + }, + { + 'serialNumber': 'DEF456', + 'ipAddress': '10.1.1.2' + } + ] + + edge_connections = [ + { + "switch": [ + { + 'ip': '10.1.1.1', + 'policies': [ + {'description': 'nace_policy_1'} + ] + }, + { + 'ip': '10.1.1.2', + 'policies': [ + {'description': 'nace_policy_2'} + ] + } + ] + } + ] + + def mock_helper_side_effect(self, task_vars, tmp, serial, prefix): + if serial == 'ABC123': + if prefix == 'edge_': + return [] # No edge_ policies for first switch + else: # prefix == 'nace_' + return [{'policyId': 'POL123', 'description': 'nace_unmanaged'}] + else: # serial == 'DEF456' + # Second switch has only managed policies for both prefixes + if prefix == 'edge_': + return [] + else: # prefix == 'nace_' + return [{'policyId': 'POL124', 'description': 'nace_policy_2'}] + + task_args = { + 'switch_data': switch_data, + 'edge_connections': edge_connections + } + + action_module = self.create_action_module(ActionModule, task_args) + + with patch('plugins.action.dtc.unmanaged_edge_connections.ndfc_get_switch_policy_using_desc') as mock_helper: + mock_helper.side_effect = mock_helper_side_effect + + result = action_module.run() + + self.assertTrue(result['changed']) + # Should have only one switch with unmanaged policies + self.assertEqual(len(result['unmanaged_edge_connections'][0]['switch']), 1) + self.assertEqual(result['unmanaged_edge_connections'][0]['switch'][0]['ip'], '10.1.1.1') + + def test_run_edge_prefix_backwards_compatibility(self): + """Test run with legacy 'edge_' prefix.""" + switch_data = [ + { + 'serialNumber': 'ABC123', + 'ipAddress': '10.1.1.1' + } + ] + + edge_connections = [ + { + "switch": [ + { + 'ip': '10.1.1.1', + 'policies': [ + {'description': 'edge_test_policy_1'} + ] + } + ] + } + ] + + def mock_helper_side_effect(self, task_vars, tmp, serial, prefix): + if prefix == 'edge_': + return [{'policyId': 'POL123', 'description': 'edge_unmanaged'}] + else: # prefix == 'nace_' + return [] + + task_args = { + 'switch_data': switch_data, + 'edge_connections': edge_connections + } + + action_module = self.create_action_module(ActionModule, task_args) + + with patch('plugins.action.dtc.unmanaged_edge_connections.ndfc_get_switch_policy_using_desc') as mock_helper: + mock_helper.side_effect = mock_helper_side_effect + + result = action_module.run() + + self.assertTrue(result['changed']) + self.assertEqual(result['unmanaged_edge_connections'][0]['switch'][0]['policies'][0]['description'], 'edge_unmanaged') + + def test_run_combined_edge_and_nace_prefixes(self): + """Test run with both edge_ and nace_ prefixes returning policies.""" + switch_data = [ + { + 'serialNumber': 'ABC123', + 'ipAddress': '10.1.1.1' + } + ] + + edge_connections = [ + { + "switch": [ + { + 'ip': '10.1.1.1', + 'policies': [ + {'description': 'nace_managed_policy'} + ] + } + ] + } + ] + + def mock_helper_side_effect(self, task_vars, tmp, serial, prefix): + if prefix == 'edge_': + return [{'policyId': 'POL123', 'description': 'edge_unmanaged'}] + else: # prefix == 'nace_' + return [{'policyId': 'POL124', 'description': 'nace_another_unmanaged'}] + + task_args = { + 'switch_data': switch_data, + 'edge_connections': edge_connections + } + + action_module = self.create_action_module(ActionModule, task_args) + + with patch('plugins.action.dtc.unmanaged_edge_connections.ndfc_get_switch_policy_using_desc') as mock_helper: + mock_helper.side_effect = mock_helper_side_effect + + result = action_module.run() + + self.assertTrue(result['changed']) + # Should have both unmanaged policies detected + # Note: The plugin logic adds each unmanaged policy as a separate switch entry + # This is based on the plugin's current implementation diff --git a/tests/unit/plugins/action/dtc/test_unmanaged_policy.py b/tests/unit/plugins/action/dtc/test_unmanaged_policy.py new file mode 100644 index 000000000..2ec7c54b5 --- /dev/null +++ b/tests/unit/plugins/action/dtc/test_unmanaged_policy.py @@ -0,0 +1,474 @@ +# Copyright (c) 2025 Cisco Systems, Inc. and its affiliates +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +""" +Unit tests for unmanaged_policy action plugin. +""" + +from unittest.mock import patch + +# Try to import from the plugins directory +try: + from plugins.action.dtc.unmanaged_policy import ActionModule +except ImportError: + # Fallback for when running tests from different locations + import sys + import os + sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..', '..', '..')) + from plugins.action.dtc.unmanaged_policy import ActionModule +from .base_test import ActionModuleTestCase + + +class TestUnmanagedPolicyActionModule(ActionModuleTestCase): + """Test cases for unmanaged_policy action plugin.""" + + def test_run_no_unmanaged_policies(self): + """Test run when there are no unmanaged policies.""" + switch_serial_numbers = ["ABC123"] + model_data = { + "vxlan": { + "topology": { + "switches": [ + { + "serial_number": "ABC123", + "management": { + "management_ipv4_address": "10.1.1.1" + } + } + ] + }, + "policy": { + "policies": [], + "groups": [ + { + "name": "group1", + "policies": [ + {"name": "Test Policy"} + ] + } + ], + "switches": [ + { + "mgmt_ip_address": "10.1.1.1", + "groups": ["group1"] + } + ] + } + } + } + + # NDFC returns policies that match the data model + mock_ndfc_policies = [ + { + "policyId": "policy_123", + "description": "nac_Test_Policy" + } + ] + + task_args = { + 'switch_serial_numbers': switch_serial_numbers, + 'model_data': model_data + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Only mock the helper function, not the parent run() + with patch('plugins.action.dtc.unmanaged_policy.ndfc_get_switch_policy_using_desc') as mock_helper: + mock_helper.return_value = mock_ndfc_policies + + result = action_module.run() + + self.assertFalse(result['changed']) + self.assertIn('unmanaged_policies', result) + self.assertEqual(result['unmanaged_policies'], [{'switch': []}]) + + def test_run_with_unmanaged_policies(self): + """Test run when there are unmanaged policies.""" + switch_serial_numbers = ["ABC123"] + model_data = { + "vxlan": { + "topology": { + "switches": [ + { + "serial_number": "ABC123", + "management": { + "management_ipv4_address": "10.1.1.1" + } + } + ] + }, + "policy": { + "policies": [], + "groups": [ + { + "name": "group1", + "policies": [ + {"name": "Managed Policy"} + ] + } + ], + "switches": [ + { + "mgmt_ip_address": "10.1.1.1", + "groups": ["group1"] + } + ] + } + } + } + + # NDFC returns policies including unmanaged ones + mock_ndfc_policies = [ + { + "policyId": "policy_123", + "description": "nac_Managed_Policy" # This is managed + }, + { + "policyId": "policy_456", + "description": "nac_Unmanaged_Policy" # This is unmanaged + } + ] + + task_args = { + 'switch_serial_numbers': switch_serial_numbers, + 'model_data': model_data + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Only mock the helper function, not the parent run() + with patch('plugins.action.dtc.unmanaged_policy.ndfc_get_switch_policy_using_desc') as mock_helper: + mock_helper.return_value = mock_ndfc_policies + + result = action_module.run() + + self.assertTrue(result['changed']) + self.assertIn('unmanaged_policies', result) + # Should have one switch with unmanaged policies + self.assertEqual(len(result['unmanaged_policies'][0]['switch']), 1) + self.assertEqual(result['unmanaged_policies'][0]['switch'][0]['ip'], '10.1.1.1') + # Should have one unmanaged policy + self.assertEqual(len(result['unmanaged_policies'][0]['switch'][0]['policies']), 1) + self.assertEqual(result['unmanaged_policies'][0]['switch'][0]['policies'][0]['name'], 'policy_456') + self.assertEqual(result['unmanaged_policies'][0]['switch'][0]['policies'][0]['description'], 'nac_Unmanaged_Policy') + + def test_run_multiple_switches_mixed_policies(self): + """Test run with multiple switches having mixed policies.""" + switch_serial_numbers = ["ABC123", "DEF456"] + model_data = { + "vxlan": { + "topology": { + "switches": [ + { + "serial_number": "ABC123", + "management": { + "management_ipv4_address": "10.1.1.1" + } + }, + { + "serial_number": "DEF456", + "management": { + "management_ipv4_address": "10.1.1.2" + } + } + ] + }, + "policy": { + "policies": [], + "groups": [ + { + "name": "group1", + "policies": [ + {"name": "Policy 1"} + ] + } + ], + "switches": [ + { + "mgmt_ip_address": "10.1.1.1", + "groups": ["group1"] + }, + { + "mgmt_ip_address": "10.1.1.2", + "groups": ["group1"] + } + ] + } + } + } + + def mock_helper_side_effect(self, task_vars, tmp, serial, prefix): + if serial == "ABC123": + # First switch has an unmanaged policy + return [ + {"policyId": "policy_123", "description": "nac_Policy_1"}, + {"policyId": "policy_999", "description": "nac_Unmanaged_Policy"} + ] + else: # DEF456 + # Second switch has only managed policies + return [ + {"policyId": "policy_456", "description": "nac_Policy_1"} + ] + + task_args = { + 'switch_serial_numbers': switch_serial_numbers, + 'model_data': model_data + } + + action_module = self.create_action_module(ActionModule, task_args) + + with patch('plugins.action.dtc.unmanaged_policy.ndfc_get_switch_policy_using_desc') as mock_helper: + mock_helper.side_effect = mock_helper_side_effect + + result = action_module.run() + + self.assertTrue(result['changed']) + # Should have only one switch with unmanaged policies + self.assertEqual(len(result['unmanaged_policies'][0]['switch']), 1) + self.assertEqual(result['unmanaged_policies'][0]['switch'][0]['ip'], '10.1.1.1') + + def test_run_ipv6_management_address(self): + """Test run with IPv6 management address.""" + switch_serial_numbers = ["ABC123"] + model_data = { + "vxlan": { + "topology": { + "switches": [ + { + "serial_number": "ABC123", + "management": { + "management_ipv6_address": "2001:db8::1" + } + } + ] + }, + "policy": { + "policies": [], + "groups": [ + { + "name": "group1", + "policies": [ + {"name": "Policy 1"} + ] + } + ], + "switches": [ + { + "mgmt_ip_address": "2001:db8::1", + "groups": ["group1"] + } + ] + } + } + } + + mock_ndfc_policies = [ + { + "policyId": "policy_999", + "description": "nac_Unmanaged_Policy" + } + ] + + task_args = { + 'switch_serial_numbers': switch_serial_numbers, + 'model_data': model_data + } + + action_module = self.create_action_module(ActionModule, task_args) + + with patch('plugins.action.dtc.unmanaged_policy.ndfc_get_switch_policy_using_desc') as mock_helper: + mock_helper.return_value = mock_ndfc_policies + + result = action_module.run() + + self.assertTrue(result['changed']) + self.assertEqual(result['unmanaged_policies'][0]['switch'][0]['ip'], '2001:db8::1') + + def test_run_switch_not_in_model(self): + """Test run when switch is not found in data model.""" + switch_serial_numbers = ["XYZ999"] # Not in model + model_data = { + "vxlan": { + "topology": { + "switches": [ + { + "serial_number": "ABC123", + "management": { + "management_ipv4_address": "10.1.1.1" + } + } + ] + }, + "policy": { + "policies": [], + "groups": [], + "switches": [] + } + } + } + + mock_ndfc_policies = [] + + task_args = { + 'switch_serial_numbers': switch_serial_numbers, + 'model_data': model_data + } + + action_module = self.create_action_module(ActionModule, task_args) + + with patch('plugins.action.dtc.unmanaged_policy.ndfc_get_switch_policy_using_desc') as mock_helper: + mock_helper.return_value = mock_ndfc_policies + + result = action_module.run() + + self.assertFalse(result['changed']) + self.assertEqual(result['unmanaged_policies'], [{'switch': []}]) + + def test_run_missing_management_addresses(self): + """Test run when management addresses are missing.""" + switch_serial_numbers = ["ABC123"] + model_data = { + "vxlan": { + "topology": { + "switches": [ + { + "serial_number": "ABC123", + "management": {} # No IP addresses + } + ] + }, + "policy": { + "policies": [], + "groups": [], + "switches": [] + } + } + } + + mock_ndfc_policies = [] + + task_args = { + 'switch_serial_numbers': switch_serial_numbers, + 'model_data': model_data + } + + action_module = self.create_action_module(ActionModule, task_args) + + with patch('plugins.action.dtc.unmanaged_policy.ndfc_get_switch_policy_using_desc') as mock_helper: + mock_helper.return_value = mock_ndfc_policies + + result = action_module.run() + + self.assertFalse(result['changed']) + self.assertIn('unmanaged_policies', result) + + def test_run_empty_switch_serial_numbers(self): + """Test run with empty switch serial numbers list.""" + switch_serial_numbers = [] + model_data = { + "vxlan": { + "topology": { + "switches": [] + }, + "policy": { + "policies": [], + "groups": [], + "switches": [] + } + } + } + + task_args = { + 'switch_serial_numbers': switch_serial_numbers, + 'model_data': model_data + } + + action_module = self.create_action_module(ActionModule, task_args) + + result = action_module.run() + + self.assertFalse(result['changed']) + self.assertIn('unmanaged_policies', result) + self.assertEqual(result['unmanaged_policies'], [{'switch': []}]) + + def test_run_policy_name_formatting(self): + """Test run verifies correct policy name formatting (spaces to underscores).""" + switch_serial_numbers = ["ABC123"] + model_data = { + "vxlan": { + "topology": { + "switches": [ + { + "serial_number": "ABC123", + "management": { + "management_ipv4_address": "10.1.1.1" + } + } + ] + }, + "policy": { + "policies": [], + "groups": [ + { + "name": "group1", + "policies": [ + {"name": "Policy With Spaces"} # Spaces should become underscores + ] + } + ], + "switches": [ + { + "mgmt_ip_address": "10.1.1.1", + "groups": ["group1"] + } + ] + } + } + } + + # NDFC returns policy that doesn't match due to formatting + mock_ndfc_policies = [ + { + "policyId": "policy_123", + "description": "nac_Policy_With_Spaces" # Matches formatted name + }, + { + "policyId": "policy_456", + "description": "nac_Unmanaged Policy" # Different formatting - unmanaged + } + ] + + task_args = { + 'switch_serial_numbers': switch_serial_numbers, + 'model_data': model_data + } + + action_module = self.create_action_module(ActionModule, task_args) + + with patch('plugins.action.dtc.unmanaged_policy.ndfc_get_switch_policy_using_desc') as mock_helper: + mock_helper.return_value = mock_ndfc_policies + + result = action_module.run() + + self.assertTrue(result['changed']) + self.assertIn('unmanaged_policies', result) + # Should detect the unmanaged policy due to formatting difference + self.assertEqual(len(result['unmanaged_policies'][0]['switch']), 1) diff --git a/tests/unit/plugins/action/dtc/test_update_switch_hostname_policy.py b/tests/unit/plugins/action/dtc/test_update_switch_hostname_policy.py new file mode 100644 index 000000000..f08a17ab4 --- /dev/null +++ b/tests/unit/plugins/action/dtc/test_update_switch_hostname_policy.py @@ -0,0 +1,441 @@ +# Copyright (c) 2025 Cisco Systems, Inc. and its affiliates +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +""" +Unit tests for update_switch_hostname_policy action plugin. +""" + +from unittest.mock import patch + +# Try to import from the plugins directory +try: + from plugins.action.dtc.update_switch_hostname_policy import ActionModule +except ImportError: + # Fallback for when running tests from different locations + import sys + import os + sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..', '..', '..')) + from plugins.action.dtc.update_switch_hostname_policy import ActionModule +from .base_test import ActionModuleTestCase + + +class TestUpdateSwitchHostnamePolicyActionModule(ActionModuleTestCase): + """Test cases for update_switch_hostname_policy action plugin.""" + + def test_run_hostname_needs_update(self): + """Test run when hostname needs to be updated.""" + model_data = { + 'vxlan': { + 'fabric': { + 'type': 'VXLAN_EVPN' + }, + 'topology': { + 'switches': [ + { + 'serial_number': 'ABC123', + 'name': 'new-switch-name' + } + ] + } + } + } + + switch_serial_numbers = ['ABC123'] + template_name = 'switch_freeform' + + mock_policy = { + 'nvPairs': { + 'SWITCH_NAME': 'old-switch-name' + }, + 'templateName': 'switch_freeform', + 'serialNumber': 'ABC123' + } + + task_args = { + 'model_data': model_data, + 'switch_serial_numbers': switch_serial_numbers, + 'template_name': template_name + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Only mock the helper function, not the parent run() + with patch('plugins.action.dtc.update_switch_hostname_policy.ndfc_get_switch_policy_using_template') as mock_helper: + mock_helper.return_value = mock_policy + + result = action_module.run() + + self.assertTrue(result['changed']) + self.assertIn('policy_update', result) + self.assertIn('ABC123', result['policy_update']) + self.assertEqual(result['policy_update']['ABC123']['nvPairs']['SWITCH_NAME'], 'new-switch-name') + + def test_run_hostname_no_update_needed(self): + """Test run when hostname is already correct.""" + model_data = { + 'vxlan': { + 'fabric': { + 'type': 'VXLAN_EVPN' + }, + 'topology': { + 'switches': [ + { + 'serial_number': 'ABC123', + 'name': 'correct-switch-name' + } + ] + } + } + } + + switch_serial_numbers = ['ABC123'] + template_name = 'switch_freeform' + + mock_policy = { + 'nvPairs': { + 'SWITCH_NAME': 'correct-switch-name' # Already matches + }, + 'templateName': 'switch_freeform', + 'serialNumber': 'ABC123' + } + + task_args = { + 'model_data': model_data, + 'switch_serial_numbers': switch_serial_numbers, + 'template_name': template_name + } + + action_module = self.create_action_module(ActionModule, task_args) + + with patch('plugins.action.dtc.update_switch_hostname_policy.ndfc_get_switch_policy_using_template') as mock_helper: + mock_helper.return_value = mock_policy + + result = action_module.run() + + self.assertFalse(result['changed']) + self.assertIn('policy_update', result) + self.assertEqual(result['policy_update'], {}) + + def test_run_multiple_switches_mixed_updates(self): + """Test run with multiple switches, some needing updates.""" + model_data = { + 'vxlan': { + 'fabric': { + 'type': 'VXLAN_EVPN' + }, + 'topology': { + 'switches': [ + { + 'serial_number': 'ABC123', + 'name': 'new-name-1' + }, + { + 'serial_number': 'DEF456', + 'name': 'correct-name-2' + } + ] + } + } + } + + switch_serial_numbers = ['ABC123', 'DEF456'] + template_name = 'switch_freeform' + + def mock_helper_side_effect(self, task_vars, tmp, switch_serial_number, template_name): + if switch_serial_number == 'ABC123': + return { + 'nvPairs': {'SWITCH_NAME': 'old-name-1'}, + 'templateName': 'switch_freeform', + 'serialNumber': 'ABC123' + } + else: # DEF456 + return { + 'nvPairs': {'SWITCH_NAME': 'correct-name-2'}, + 'templateName': 'switch_freeform', + 'serialNumber': 'DEF456' + } + + task_args = { + 'model_data': model_data, + 'switch_serial_numbers': switch_serial_numbers, + 'template_name': template_name + } + + action_module = self.create_action_module(ActionModule, task_args) + + with patch('plugins.action.dtc.update_switch_hostname_policy.ndfc_get_switch_policy_using_template') as mock_helper: + mock_helper.side_effect = mock_helper_side_effect + + result = action_module.run() + + self.assertTrue(result['changed']) + # Should have only one switch needing update + self.assertEqual(len(result['policy_update']), 1) + self.assertIn('ABC123', result['policy_update']) + self.assertNotIn('DEF456', result['policy_update']) + + def test_run_external_fabric_type(self): + """Test run with External fabric type.""" + model_data = { + 'vxlan': { + 'fabric': { + 'type': 'External' + }, + 'topology': { + 'switches': [ + { + 'serial_number': 'ABC123', + 'name': 'external-switch' + } + ] + } + } + } + + switch_serial_numbers = ['ABC123'] + template_name = 'switch_freeform' + + mock_policy = { + 'nvPairs': { + 'SWITCH_NAME': 'old-external-switch' + }, + 'templateName': 'switch_freeform', + 'serialNumber': 'ABC123' + } + + task_args = { + 'model_data': model_data, + 'switch_serial_numbers': switch_serial_numbers, + 'template_name': template_name + } + + action_module = self.create_action_module(ActionModule, task_args) + + with patch('plugins.action.dtc.update_switch_hostname_policy.ndfc_get_switch_policy_using_template') as mock_helper: + mock_helper.return_value = mock_policy + + result = action_module.run() + + self.assertTrue(result['changed']) + self.assertIn('ABC123', result['policy_update']) + + def test_run_isn_fabric_type(self): + """Test run with ISN fabric type.""" + model_data = { + 'vxlan': { + 'fabric': { + 'type': 'ISN' + }, + 'topology': { + 'switches': [ + { + 'serial_number': 'ABC123', + 'name': 'isn-switch' + } + ] + } + } + } + + switch_serial_numbers = ['ABC123'] + template_name = 'switch_freeform' + + mock_policy = { + 'nvPairs': { + 'SWITCH_NAME': 'old-isn-switch' + }, + 'templateName': 'switch_freeform', + 'serialNumber': 'ABC123' + } + + task_args = { + 'model_data': model_data, + 'switch_serial_numbers': switch_serial_numbers, + 'template_name': template_name + } + + action_module = self.create_action_module(ActionModule, task_args) + + with patch('plugins.action.dtc.update_switch_hostname_policy.ndfc_get_switch_policy_using_template') as mock_helper: + mock_helper.return_value = mock_policy + + result = action_module.run() + + self.assertTrue(result['changed']) + self.assertIn('ABC123', result['policy_update']) + + def test_run_policy_update_triggers_changed(self): + """Test run verifies that policy updates trigger changed flag.""" + model_data = { + 'vxlan': { + 'fabric': { + 'type': 'VXLAN_EVPN' + }, + 'topology': { + 'switches': [ + { + 'serial_number': 'ABC123', + 'name': 'updated-name' + } + ] + } + } + } + + switch_serial_numbers = ['ABC123'] + template_name = 'switch_freeform' + + mock_policy = { + 'nvPairs': { + 'SWITCH_NAME': 'original-name' + }, + 'templateName': 'switch_freeform', + 'serialNumber': 'ABC123' + } + + task_args = { + 'model_data': model_data, + 'switch_serial_numbers': switch_serial_numbers, + 'template_name': template_name + } + + action_module = self.create_action_module(ActionModule, task_args) + + with patch('plugins.action.dtc.update_switch_hostname_policy.ndfc_get_switch_policy_using_template') as mock_helper: + mock_helper.return_value = mock_policy + + result = action_module.run() + + self.assertTrue(result['changed']) + self.assertTrue(len(result['policy_update']) > 0) + + def test_run_empty_switch_serial_numbers(self): + """Test run with empty switch serial numbers list.""" + model_data = { + 'vxlan': { + 'fabric': { + 'type': 'VXLAN_EVPN' + }, + 'topology': { + 'switches': [] + } + } + } + + switch_serial_numbers = [] + template_name = 'switch_freeform' + + task_args = { + 'model_data': model_data, + 'switch_serial_numbers': switch_serial_numbers, + 'template_name': template_name + } + + action_module = self.create_action_module(ActionModule, task_args) + + result = action_module.run() + + self.assertFalse(result['changed']) + self.assertIn('policy_update', result) + self.assertEqual(result['policy_update'], {}) + + def test_run_switch_not_found_in_model(self): + """Test run when switch is not found in data model.""" + model_data = { + 'vxlan': { + 'fabric': { + 'type': 'VXLAN_EVPN' + }, + 'topology': { + 'switches': [ + { + 'serial_number': 'ABC123', + 'name': 'existing-switch' + } + ] + } + } + } + + switch_serial_numbers = ['XYZ999'] # Not in model + template_name = 'switch_freeform' + + mock_policy = { + 'nvPairs': { + 'SWITCH_NAME': 'some-name' + }, + 'templateName': 'switch_freeform', + 'serialNumber': 'XYZ999' + } + + task_args = { + 'model_data': model_data, + 'switch_serial_numbers': switch_serial_numbers, + 'template_name': template_name + } + + action_module = self.create_action_module(ActionModule, task_args) + + with patch('plugins.action.dtc.update_switch_hostname_policy.ndfc_get_switch_policy_using_template') as mock_helper: + mock_helper.return_value = mock_policy + + # Should raise StopIteration when switch not found + with self.assertRaises(StopIteration): + result = action_module.run() + + def test_run_unsupported_fabric_type(self): + """Test run with unsupported fabric type.""" + model_data = { + 'vxlan': { + 'fabric': { + 'type': 'UNSUPPORTED_TYPE' + }, + 'topology': { + 'switches': [] + } + } + } + + switch_serial_numbers = ['ABC123'] + template_name = 'switch_freeform' + + mock_policy = { + 'nvPairs': { + 'SWITCH_NAME': 'some-name' + }, + 'templateName': 'switch_freeform', + 'serialNumber': 'ABC123' + } + + task_args = { + 'model_data': model_data, + 'switch_serial_numbers': switch_serial_numbers, + 'template_name': template_name + } + + action_module = self.create_action_module(ActionModule, task_args) + + with patch('plugins.action.dtc.update_switch_hostname_policy.ndfc_get_switch_policy_using_template') as mock_helper: + mock_helper.return_value = mock_policy + + # Should raise StopIteration when fabric type doesn't match supported types + with self.assertRaises(StopIteration): + result = action_module.run() diff --git a/tests/unit/plugins/action/dtc/test_verify_tags.py b/tests/unit/plugins/action/dtc/test_verify_tags.py new file mode 100644 index 000000000..e230097dc --- /dev/null +++ b/tests/unit/plugins/action/dtc/test_verify_tags.py @@ -0,0 +1,313 @@ +# Copyright (c) 2025 Cisco Systems, Inc. and its affiliates +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +""" +Unit tests for verify_tags action plugin. +""" + +import unittest +from unittest.mock import patch + +# Try to import from the plugins directory +try: + from plugins.action.dtc.verify_tags import ActionModule +except ImportError: + # Fallback for when running tests from different locations + import sys + import os + sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..', '..', '..')) + from plugins.action.dtc.verify_tags import ActionModule +from .base_test import ActionModuleTestCase + + +class TestVerifyTagsActionModule(ActionModuleTestCase): + """Test cases for verify_tags action plugin.""" + + def setUp(self): + """Set up test fixtures.""" + super().setUp() + self.action_module = self.create_action_module(ActionModule) + + def test_run_valid_tags(self): + """Test run with valid tags.""" + all_tags = ['fabric', 'deploy', 'config', 'validate', 'backup'] + play_tags = ['fabric', 'deploy'] + + task_args = { + 'all_tags': all_tags, + 'play_tags': play_tags + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Mock the run method from parent class + with patch.object(ActionModule.__bases__[0], 'run') as mock_parent_run: + mock_parent_run.return_value = {'changed': False} + + result = action_module.run() + + self.assertFalse(result['failed']) + self.assertNotIn('msg', result) + self.assertNotIn('supported_tags', result) + + def test_run_all_tag_in_play_tags(self): + """Test run when 'all' tag is in play_tags.""" + all_tags = ['fabric', 'deploy', 'config', 'validate', 'backup'] + play_tags = ['all'] + + task_args = { + 'all_tags': all_tags, + 'play_tags': play_tags + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Mock the run method from parent class + with patch.object(ActionModule.__bases__[0], 'run') as mock_parent_run: + mock_parent_run.return_value = {'changed': False} + + result = action_module.run() + + self.assertFalse(result['failed']) + self.assertNotIn('msg', result) + self.assertNotIn('supported_tags', result) + + def test_run_all_tag_with_other_tags(self): + """Test run when 'all' tag is mixed with other tags.""" + all_tags = ['fabric', 'deploy', 'config', 'validate', 'backup'] + play_tags = ['all', 'fabric', 'deploy'] + + task_args = { + 'all_tags': all_tags, + 'play_tags': play_tags + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Mock the run method from parent class + with patch.object(ActionModule.__bases__[0], 'run') as mock_parent_run: + mock_parent_run.return_value = {'changed': False} + + result = action_module.run() + + self.assertFalse(result['failed']) + self.assertNotIn('msg', result) + self.assertNotIn('supported_tags', result) + + def test_run_invalid_tag(self): + """Test run with invalid tag.""" + all_tags = ['fabric', 'deploy', 'config', 'validate', 'backup'] + play_tags = ['fabric', 'invalid_tag'] + + task_args = { + 'all_tags': all_tags, + 'play_tags': play_tags + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Mock the run method from parent class + with patch.object(ActionModule.__bases__[0], 'run') as mock_parent_run: + mock_parent_run.return_value = {'changed': False} + + result = action_module.run() + + self.assertTrue(result['failed']) + self.assertIn("Tag 'invalid_tag' not found in list of supported tags", result['msg']) + self.assertEqual(result['supported_tags'], all_tags) + + def test_run_multiple_invalid_tags(self): + """Test run with multiple invalid tags.""" + all_tags = ['fabric', 'deploy', 'config', 'validate', 'backup'] + play_tags = ['fabric', 'invalid_tag1', 'invalid_tag2'] + + task_args = { + 'all_tags': all_tags, + 'play_tags': play_tags + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Mock the run method from parent class + with patch.object(ActionModule.__bases__[0], 'run') as mock_parent_run: + mock_parent_run.return_value = {'changed': False} + + result = action_module.run() + + self.assertTrue(result['failed']) + # Should fail on the last invalid tag encountered (plugin doesn't return early) + self.assertIn("Tag 'invalid_tag2' not found in list of supported tags", result['msg']) + self.assertEqual(result['supported_tags'], all_tags) + + def test_run_empty_play_tags(self): + """Test run with empty play_tags.""" + all_tags = ['fabric', 'deploy', 'config', 'validate', 'backup'] + play_tags = [] + + task_args = { + 'all_tags': all_tags, + 'play_tags': play_tags + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Mock the run method from parent class + with patch.object(ActionModule.__bases__[0], 'run') as mock_parent_run: + mock_parent_run.return_value = {'changed': False} + + result = action_module.run() + + self.assertFalse(result['failed']) + self.assertNotIn('msg', result) + self.assertNotIn('supported_tags', result) + + def test_run_empty_all_tags(self): + """Test run with empty all_tags.""" + all_tags = [] + play_tags = ['fabric'] + + task_args = { + 'all_tags': all_tags, + 'play_tags': play_tags + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Mock the run method from parent class + with patch.object(ActionModule.__bases__[0], 'run') as mock_parent_run: + mock_parent_run.return_value = {'changed': False} + + result = action_module.run() + + self.assertTrue(result['failed']) + self.assertIn("Tag 'fabric' not found in list of supported tags", result['msg']) + self.assertEqual(result['supported_tags'], all_tags) + + def test_run_case_sensitive_tags(self): + """Test run with case-sensitive tags.""" + all_tags = ['fabric', 'deploy', 'config', 'validate', 'backup'] + play_tags = ['Fabric', 'DEPLOY'] + + task_args = { + 'all_tags': all_tags, + 'play_tags': play_tags + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Mock the run method from parent class + with patch.object(ActionModule.__bases__[0], 'run') as mock_parent_run: + mock_parent_run.return_value = {'changed': False} + + result = action_module.run() + + self.assertTrue(result['failed']) + # Should fail on the last case-mismatched tag (plugin doesn't return early) + self.assertIn("Tag 'DEPLOY' not found in list of supported tags", result['msg']) + self.assertEqual(result['supported_tags'], all_tags) + + def test_run_single_tag_scenarios(self): + """Test run with single tag scenarios.""" + all_tags = ['fabric', 'deploy', 'config', 'validate', 'backup'] + + # Test single valid tag + play_tags = ['fabric'] + task_args = { + 'all_tags': all_tags, + 'play_tags': play_tags + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Mock the run method from parent class + with patch.object(ActionModule.__bases__[0], 'run') as mock_parent_run: + mock_parent_run.return_value = {'changed': False} + + result = action_module.run() + + self.assertFalse(result['failed']) + self.assertNotIn('msg', result) + self.assertNotIn('supported_tags', result) + + def test_run_duplicate_tags_in_play_tags(self): + """Test run with duplicate tags in play_tags.""" + all_tags = ['fabric', 'deploy', 'config', 'validate', 'backup'] + play_tags = ['fabric', 'deploy', 'fabric'] + + task_args = { + 'all_tags': all_tags, + 'play_tags': play_tags + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Mock the run method from parent class + with patch.object(ActionModule.__bases__[0], 'run') as mock_parent_run: + mock_parent_run.return_value = {'changed': False} + + result = action_module.run() + + self.assertFalse(result['failed']) + self.assertNotIn('msg', result) + self.assertNotIn('supported_tags', result) + + def test_run_duplicate_tags_in_all_tags(self): + """Test run with duplicate tags in all_tags.""" + all_tags = ['fabric', 'deploy', 'fabric', 'config', 'validate', 'backup'] + play_tags = ['fabric', 'deploy'] + + task_args = { + 'all_tags': all_tags, + 'play_tags': play_tags + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Mock the run method from parent class + with patch.object(ActionModule.__bases__[0], 'run') as mock_parent_run: + mock_parent_run.return_value = {'changed': False} + + result = action_module.run() + + self.assertFalse(result['failed']) + self.assertNotIn('msg', result) + self.assertNotIn('supported_tags', result) + + def test_run_with_none_values(self): + """Test run with None values.""" + # Test with None all_tags + task_args = { + 'all_tags': None, + 'play_tags': ['fabric'] + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Mock the run method from parent class + with patch.object(ActionModule.__bases__[0], 'run') as mock_parent_run: + mock_parent_run.return_value = {'changed': False} + + with self.assertRaises(TypeError): + action_module.run() + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/unit/plugins/action/dtc/test_vpc_pair_check.py b/tests/unit/plugins/action/dtc/test_vpc_pair_check.py new file mode 100644 index 000000000..a781ccf23 --- /dev/null +++ b/tests/unit/plugins/action/dtc/test_vpc_pair_check.py @@ -0,0 +1,454 @@ +# Copyright (c) 2025 Cisco Systems, Inc. and its affiliates +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +""" +Unit tests for vpc_pair_check action plugin. +""" + +import unittest +from unittest.mock import patch + +# Try to import from the plugins directory +try: + from plugins.action.dtc.vpc_pair_check import ActionModule +except ImportError: + # Fallback for when running tests from different locations + import sys + import os + sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..', '..', '..')) + from plugins.action.dtc.vpc_pair_check import ActionModule +from .base_test import ActionModuleTestCase + + +class TestVpcPairCheckActionModule(ActionModuleTestCase): + """Test cases for vpc_pair_check action plugin.""" + + def setUp(self): + """Set up test fixtures.""" + super().setUp() + self.action_module = self.create_action_module(ActionModule) + + def test_run_valid_vpc_data_all_configured(self): + """Test run with valid VPC data where all switches are configured.""" + vpc_data = { + 'results': [ + { + 'response': [ + { + 'hostName': 'switch1', + 'isVpcConfigured': True + }, + { + 'hostName': 'switch2', + 'isVpcConfigured': True + } + ] + }, + { + 'response': [ + { + 'hostName': 'switch3', + 'isVpcConfigured': True + }, + { + 'hostName': 'switch4', + 'isVpcConfigured': True + } + ] + } + ] + } + + task_args = { + 'vpc_data': vpc_data + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Mock the run method from parent class + with patch.object(ActionModule.__bases__[0], 'run') as mock_parent_run: + mock_parent_run.return_value = {'changed': False} + + result = action_module.run() + + self.assertFalse(result['failed']) + self.assertNotIn('msg', result) + + def test_run_valid_vpc_data_some_not_configured(self): + """Test run with valid VPC data where some switches are not configured.""" + vpc_data = { + 'results': [ + { + 'response': [ + { + 'hostName': 'switch1', + 'isVpcConfigured': False + }, + { + 'hostName': 'switch2', + 'isVpcConfigured': True + } + ] + }, + { + 'response': [ + { + 'hostName': 'switch3', + 'isVpcConfigured': False + }, + { + 'hostName': 'switch4', + 'isVpcConfigured': False + } + ] + } + ] + } + + task_args = { + 'vpc_data': vpc_data + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Mock the run method from parent class + with patch.object(ActionModule.__bases__[0], 'run') as mock_parent_run: + mock_parent_run.return_value = {'changed': False} + + result = action_module.run() + + self.assertFalse(result['failed']) + self.assertNotIn('msg', result) + + def test_run_single_vpc_pair(self): + """Test run with single VPC pair.""" + vpc_data = { + 'results': [ + { + 'response': [ + { + 'hostName': 'switch1', + 'isVpcConfigured': True + }, + { + 'hostName': 'switch2', + 'isVpcConfigured': False + } + ] + } + ] + } + + task_args = { + 'vpc_data': vpc_data + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Mock the run method from parent class + with patch.object(ActionModule.__bases__[0], 'run') as mock_parent_run: + mock_parent_run.return_value = {'changed': False} + + result = action_module.run() + + self.assertFalse(result['failed']) + self.assertNotIn('msg', result) + + def test_run_empty_vpc_data(self): + """Test run with empty VPC data.""" + vpc_data = { + 'results': [] + } + + task_args = { + 'vpc_data': vpc_data + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Mock the run method from parent class + with patch.object(ActionModule.__bases__[0], 'run') as mock_parent_run: + mock_parent_run.return_value = {'changed': False} + + result = action_module.run() + + self.assertFalse(result['failed']) + self.assertNotIn('msg', result) + + def test_run_empty_response_in_pair(self): + """Test run with empty response in VPC pair.""" + vpc_data = { + 'results': [ + { + 'response': [] + } + ] + } + + task_args = { + 'vpc_data': vpc_data + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Mock the run method from parent class + with patch.object(ActionModule.__bases__[0], 'run') as mock_parent_run: + mock_parent_run.return_value = {'changed': False} + + result = action_module.run() + + self.assertFalse(result['failed']) + self.assertNotIn('msg', result) + + def test_run_single_switch_in_pair(self): + """Test run with single switch in VPC pair.""" + vpc_data = { + 'results': [ + { + 'response': [ + { + 'hostName': 'switch1', + 'isVpcConfigured': False + } + ] + } + ] + } + + task_args = { + 'vpc_data': vpc_data + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Mock the run method from parent class + with patch.object(ActionModule.__bases__[0], 'run') as mock_parent_run: + mock_parent_run.return_value = {'changed': False} + + result = action_module.run() + + self.assertFalse(result['failed']) + self.assertNotIn('msg', result) + + def test_run_multiple_vpc_pairs_mixed_states(self): + """Test run with multiple VPC pairs in mixed states.""" + vpc_data = { + 'results': [ + { + 'response': [ + { + 'hostName': 'leaf1', + 'isVpcConfigured': True + }, + { + 'hostName': 'leaf2', + 'isVpcConfigured': True + } + ] + }, + { + 'response': [ + { + 'hostName': 'leaf3', + 'isVpcConfigured': False + }, + { + 'hostName': 'leaf4', + 'isVpcConfigured': False + } + ] + }, + { + 'response': [ + { + 'hostName': 'leaf5', + 'isVpcConfigured': True + }, + { + 'hostName': 'leaf6', + 'isVpcConfigured': False + } + ] + } + ] + } + + task_args = { + 'vpc_data': vpc_data + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Mock the run method from parent class + with patch.object(ActionModule.__bases__[0], 'run') as mock_parent_run: + mock_parent_run.return_value = {'changed': False} + + result = action_module.run() + + self.assertFalse(result['failed']) + self.assertNotIn('msg', result) + + def test_run_missing_hostname_key(self): + """Test run with missing hostName key.""" + vpc_data = { + 'results': [ + { + 'response': [ + { + 'isVpcConfigured': False + # Missing hostName key + } + ] + } + ] + } + + task_args = { + 'vpc_data': vpc_data + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Mock the run method from parent class + with patch.object(ActionModule.__bases__[0], 'run') as mock_parent_run: + mock_parent_run.return_value = {'changed': False} + + with self.assertRaises(KeyError): + action_module.run() + + def test_run_missing_is_vpc_configured_key(self): + """Test run with missing isVpcConfigured key.""" + vpc_data = { + 'results': [ + { + 'response': [ + { + 'hostName': 'switch1' + } + ] + } + ] + } + + task_args = { + 'vpc_data': vpc_data + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Mock the run method from parent class + with patch.object(ActionModule.__bases__[0], 'run') as mock_parent_run: + mock_parent_run.return_value = {'changed': False} + + with self.assertRaises(KeyError): + action_module.run() + + def test_run_missing_response_key(self): + """Test run with missing response key.""" + vpc_data = { + 'results': [ + { + 'other_key': 'value' + } + ] + } + + task_args = { + 'vpc_data': vpc_data + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Mock the run method from parent class + with patch.object(ActionModule.__bases__[0], 'run') as mock_parent_run: + mock_parent_run.return_value = {'changed': False} + + with self.assertRaises(KeyError): + action_module.run() + + def test_run_missing_results_key(self): + """Test run with missing results key.""" + vpc_data = { + 'other_key': 'value' + } + + task_args = { + 'vpc_data': vpc_data + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Mock the run method from parent class + with patch.object(ActionModule.__bases__[0], 'run') as mock_parent_run: + mock_parent_run.return_value = {'changed': False} + + with self.assertRaises(KeyError): + action_module.run() + + def test_run_vpc_pairs_creation_logic(self): + """Test the VPC pairs creation logic with specific data structure.""" + vpc_data = { + 'results': [ + { + 'response': [ + { + 'hostName': 'netascode-rtp-leaf1', + 'isVpcConfigured': False + }, + { + 'hostName': 'netascode-rtp-leaf2', + 'isVpcConfigured': False + } + ] + }, + { + 'response': [ + { + 'hostName': 'netascode-rtp-leaf3', + 'isVpcConfigured': False + }, + { + 'hostName': 'netascode-rtp-leaf4', + 'isVpcConfigured': False + } + ] + } + ] + } + + task_args = { + 'vpc_data': vpc_data + } + + action_module = self.create_action_module(ActionModule, task_args) + + # Mock the run method from parent class + with patch.object(ActionModule.__bases__[0], 'run') as mock_parent_run: + mock_parent_run.return_value = {'changed': False} + + result = action_module.run() + + self.assertFalse(result['failed']) + self.assertNotIn('msg', result) + + +if __name__ == '__main__': + unittest.main()