Skip to content

Commit 6d997e1

Browse files
pandafynemesifier
andauthored
[fix] Don't delete VpnClients when enforcing required templates
Bug: The SortedManyToManyField clears all templates before adding new ones. This operation emits an `m2m_changed` signal with the `post_clear` action, which triggers the `Config.enforce_required_templates` receiver to add required templates back. Adding required templates emits another `m2m_changed` signal with the `post_add` action, which executes `Config.manage_vpn_clients` receiver. At this point, only required templates exist in the DB, hence we cannot decide which VpnClient objects to delete. Fix: We don't delete any VpnClient objects when the `m2m_changed` signal is sent for the `post_add` action for adding required templates. The receiver will be called again with the `post_add` action when all the templates are added back, including the required ones. And then, it will delete any VpnClient objects that are not associated with the current templates. --------- Co-authored-by: Federico Capoano <[email protected]>
1 parent f9f0250 commit 6d997e1

File tree

2 files changed

+114
-4
lines changed

2 files changed

+114
-4
lines changed

openwisp_controller/config/base/config.py

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -314,10 +314,27 @@ def manage_vpn_clients(cls, action, instance, pk_set, **kwargs):
314314
else:
315315
templates = pk_set
316316

317-
# Delete VPN clients that are not associated with current templates
318-
instance.vpnclient_set.exclude(
319-
template_id__in=instance.templates.values_list("id", flat=True)
320-
).delete()
317+
# Check if all templates in pk_set are required templates. If they are,
318+
# skip deletion of VpnClient objects at this point.
319+
if len(pk_set) != templates.filter(required=True).count():
320+
# Explanation:
321+
# SortedManyToManyField clears all existing templates before adding
322+
# new ones. This triggers an m2m_changed signal with the "post_clear"
323+
# action, which is handled by the "enforce_required_templates" signal
324+
# receiver. That receiver re-adds the required templates.
325+
#
326+
# Re-adding required templates triggers another m2m_changed signal
327+
# with the "post_add" action. At this stage, only required templates
328+
# exist in the DB, so we cannot yet determine which VpnClient objects
329+
# should be deleted based on the new selection.
330+
#
331+
# Therefore, we defer deletion of VpnClient objects until the "post_add"
332+
# signal is triggered again—after all templates, including the required
333+
# ones, have been fully added. At that point, we can identify and
334+
# delete VpnClient objects not linked to the final template set.
335+
instance.vpnclient_set.exclude(
336+
template_id__in=instance.templates.values_list("id", flat=True)
337+
).delete()
321338

322339
if action == "post_add":
323340
for template in templates.filter(type="vpn"):

openwisp_controller/config/tests/test_admin.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2012,6 +2012,99 @@ def test_device_changelist_activate_deactivate_admin_action_security(
20122012
)
20132013
self.assertEqual(mocked_deactivate.call_count, 1)
20142014

2015+
def test_required_template_doesnt_recreate_vpn_client(self):
2016+
"""The required templates logic should not delete VpnClients"""
2017+
2018+
def _update_template(templates):
2019+
params.update(
2020+
{
2021+
"config-0-templates": ",".join(
2022+
[str(template.pk) for template in templates]
2023+
)
2024+
}
2025+
)
2026+
response = self.client.post(path, data=params, follow=True)
2027+
self.assertEqual(response.status_code, 200)
2028+
return response
2029+
2030+
# Create required and default templates
2031+
required_template = self._create_template(
2032+
name="required-template", required=True
2033+
)
2034+
# Create VPNs and VPN templates
2035+
vpn1 = self._create_vpn(
2036+
name="test-vpn1",
2037+
)
2038+
vpn2 = self._create_vpn(
2039+
name="test-vpn2",
2040+
)
2041+
vpn1_template = self._create_template(
2042+
name="vpn1-template",
2043+
type="vpn",
2044+
vpn=vpn1,
2045+
)
2046+
vpn2_template = self._create_template(
2047+
name="vpn2-template",
2048+
type="vpn",
2049+
vpn=vpn2,
2050+
)
2051+
# Add a new device
2052+
path = reverse(f"admin:{self.app_label}_device_add")
2053+
params = self._get_device_params(org=self._get_org())
2054+
response = self.client.post(path, data=params, follow=True)
2055+
# Verify pre-codintions
2056+
self.assertEqual(response.status_code, 200)
2057+
config = Device.objects.first().config
2058+
self.assertEqual(config.vpnclient_set.count(), 0)
2059+
self.assertEqual(config.templates.count(), 1)
2060+
# Change to device change form
2061+
path = reverse(f"admin:{self.app_label}_device_change", args=[config.device_id])
2062+
params.update(
2063+
{
2064+
"config-0-id": str(config.pk),
2065+
"config-0-device": str(config.device_id),
2066+
"config-INITIAL_FORMS": 1,
2067+
"_continue": True,
2068+
}
2069+
)
2070+
# Add first VPN template via admin
2071+
_update_template(templates=[required_template, vpn1_template])
2072+
self.assertEqual(config.templates.count(), 2)
2073+
self.assertEqual(config.vpnclient_set.count(), 1)
2074+
vpn1_client = config.vpnclient_set.first()
2075+
# Add second VPN template via admin
2076+
_update_template(
2077+
templates=[
2078+
required_template,
2079+
vpn1_template,
2080+
vpn2_template,
2081+
]
2082+
)
2083+
self.assertEqual(config.templates.count(), 3)
2084+
self.assertEqual(config.vpnclient_set.count(), 2)
2085+
# Verify that the first VPN client is preserved (same PK)
2086+
self.assertEqual(
2087+
vpn1_client.pk, config.vpnclient_set.filter(vpn=vpn1).first().pk
2088+
)
2089+
vpn2_client = config.vpnclient_set.filter(vpn=vpn2).first()
2090+
# Remove first VPN template via admin
2091+
_update_template(templates=[required_template, vpn2_template])
2092+
self.assertEqual(config.templates.count(), 2)
2093+
self.assertEqual(config.vpnclient_set.count(), 1)
2094+
# Verify only vpn2 client remains
2095+
self.assertEqual(config.vpnclient_set.filter(vpn=vpn1).exists(), False)
2096+
self.assertEqual(config.vpnclient_set.filter(vpn=vpn2).exists(), True)
2097+
# Verify that the vpn2 client is the same as before (same PK)
2098+
self.assertEqual(
2099+
vpn2_client.pk, config.vpnclient_set.filter(vpn=vpn2).first().pk
2100+
)
2101+
# Remove the second VPN template and ensure the required template remains.
2102+
# This verifies that VpnClient object should get deleted it there's only
2103+
# one required template applied.
2104+
_update_template(templates=[required_template])
2105+
self.assertEqual(config.templates.count(), 1)
2106+
self.assertEqual(config.vpnclient_set.count(), 0)
2107+
20152108
def test_vpn_template_switch(self):
20162109
"""
20172110
Test switching between two VPN templates that use the same VPN server

0 commit comments

Comments
 (0)