diff --git a/openwisp_controller/config/static/config/js/relevant_templates.js b/openwisp_controller/config/static/config/js/relevant_templates.js index 9d7a1a766..72a1668ac 100644 --- a/openwisp_controller/config/static/config/js/relevant_templates.js +++ b/openwisp_controller/config/static/config/js/relevant_templates.js @@ -138,7 +138,7 @@ django.jQuery(function ($) { resetTemplateOptions(); var enabledTemplates = [], sortedm2mUl = $("ul.sortedm2m-items:first"), - sortedm2mPrefixUl = $("ul.sortedm2m-items:last"); + sortedm2mPrefixUl = $("#config-empty ul.sortedm2m-items"); // Adds "li" elements for templates Object.keys(data).forEach(function (templateId, index) { diff --git a/openwisp_controller/config/tests/test_selenium.py b/openwisp_controller/config/tests/test_selenium.py index ae9061d39..ace698c6f 100644 --- a/openwisp_controller/config/tests/test_selenium.py +++ b/openwisp_controller/config/tests/test_selenium.py @@ -1,5 +1,6 @@ import time +from django.contrib.auth.models import Group, Permission from django.contrib.staticfiles.testing import StaticLiveServerTestCase from django.test import tag from django.urls.base import reverse @@ -40,9 +41,17 @@ def _verify_templates_visibility(self, hidden=None, visible=None): hidden = hidden or [] visible = visible or [] for template in hidden: - self.wait_for_invisibility(By.XPATH, f'//*[@value="{template.id}"]') + self.wait_for_invisibility( + By.XPATH, + f'//ul[contains(@class,"sortedm2m-items")]' + f'//input[@value="{template.id}"]', + ) for template in visible: - self.wait_for_visibility(By.XPATH, f'//*[@value="{template.id}"]') + self.wait_for_visibility( + By.XPATH, + f'//ul[contains(@class,"sortedm2m-items")]' + f'//input[@value="{template.id}"]', + ) @tag("selenium_tests") @@ -373,6 +382,93 @@ def test_add_remove_templates(self): self.assertEqual(config.templates.count(), 0) self.assertEqual(config.status, "modified") + def test_relevant_templates_duplicates(self): + """ + Test that a user with specific permissions can see shared templates + properly. Verifies that: + 1. User with custom group permissions can access the admin + 2. Multiple shared templates are displayed correctly + 3. Each template appears only once in the sortedm2m list + """ + # Define permission codenames for the custom group + permission_codenames = [ + "view_group", + "change_config", + "view_config", + "add_device", + "change_device", + "delete_device", + "view_device", + "view_devicegroup", + "view_template", + ] + # Create a custom group with the specified permissions + permissions = Permission.objects.filter(codename__in=permission_codenames) + custom_group, _ = Group.objects.get_or_create(name="Custom Operator") + custom_group.permissions.set(permissions) + # Create a user and assign the custom group + user = self._create_user( + username="limited_user", + password="testpass123", + email="limited@test.com", + is_staff=True, + ) + user.groups.add(custom_group) + org = self._get_org() + self._create_org_user(user=user, organization=org, is_admin=True) + # Create multiple shared templates (organization=None) + template1 = self._create_template( + name="Shared Template 1", organization=None, default=True + ) + template2 = self._create_template(name="Shared Template 2", organization=None) + device = self._create_config(organization=org).device + # Login as the limited user + self.login(username="limited_user", password="testpass123") + # Navigate using Selenium + self.open( + reverse("admin:config_device_change", args=[device.id]) + "#config-group" + ) + self.hide_loading_overlay() + with self.subTest( + "Regression precondition: empty Config inline is not rendered" + ): + self.assertFalse(self.web_driver.find_elements(By.ID, "config-empty")) + + with self.subTest("All shared templates should be visible"): + self._verify_templates_visibility(visible=[template1, template2]) + + with self.subTest("Verify sortedm2m list has exactly 2 template items"): + # Check that ul.sortedm2m-items.sortedm2m.ui-sortable has exactly 2 children + # with .sortedm2m-item class + sortedm2m_items = self.find_elements( + by=By.CSS_SELECTOR, + value="ul.sortedm2m-items.sortedm2m.ui-sortable > li.sortedm2m-item", + ) + self.assertEqual( + len(sortedm2m_items), + 2, + ( + "Expected exactly 2 template items in sortedm2m list," + f" found {len(sortedm2m_items)}" + ), + ) + + with self.subTest( + "Verify checkbox inputs are rendered with expected attributes" + ): + for idx, template_id in enumerate([template1.id, template2.id]): + checkbox = self.find_element( + by=By.ID, value=f"id_config-templates_{idx}" + ) + self.assertEqual(checkbox.get_attribute("value"), str(template_id)) + self.assertEqual(checkbox.get_attribute("data-required"), "false") + + with self.subTest("Save operation completes successfully"): + # Scroll to the top of the page to ensure the save button is visible + self.web_driver.execute_script("window.scrollTo(0, 0);") + self.find_element(by=By.NAME, value="_save").click() + self.wait_for_presence(By.CSS_SELECTOR, ".messagelist .success", timeout=5) + @tag("selenium_tests") class TestDeviceGroupAdmin( diff --git a/openwisp_controller/geo/tests/test_admin.py b/openwisp_controller/geo/tests/test_admin.py index 8d914dc8d..e333c4b1d 100644 --- a/openwisp_controller/geo/tests/test_admin.py +++ b/openwisp_controller/geo/tests/test_admin.py @@ -29,6 +29,12 @@ def setUp(self): """override TestAdminMixin.setUp""" pass + def _get_location_add_params(self, **kwargs): + params = super()._get_location_add_params(**kwargs) + if "organization" not in kwargs: + params["organization"] = self._get_org().id + return params + def _create_multitenancy_test_env(self, vpn=False): org1 = self._create_organization(name="test1org") org2 = self._create_organization(name="test2org") diff --git a/openwisp_controller/geo/tests/test_selenium.py b/openwisp_controller/geo/tests/test_selenium.py index 03d1dcac0..1a24354c9 100644 --- a/openwisp_controller/geo/tests/test_selenium.py +++ b/openwisp_controller/geo/tests/test_selenium.py @@ -91,7 +91,11 @@ def setUp(self): def test_unsaved_changes_readonly(self): self.login() ol = self._create_object_location() - path = reverse("admin:config_device_change", args=[ol.device.id]) + path = reverse( + f"admin:{self.object_model._meta.app_label}_" + f"{self.object_model._meta.model_name}_change", + args=[ol.device.id], + ) with self.subTest("Alert should not be displayed without any change"): self.open(path)