Skip to content

Commit 7fac9a1

Browse files
committed
[feature] Invalidate VPN cache when organization config variables change
When organization configuration variables are updated, VPN cache wasn't being invalidated automatically. This caused VPNs to use stale config data leading to connectivity issues. Added signal handler to detect context changes in OrganizationConfigSettings and trigger cache invalidation for all VPNs in that organization. Also added comprehensive tests to ensure the functionality works correctly. Fixes #1098
1 parent aa2561f commit 7fac9a1

File tree

4 files changed

+226
-0
lines changed

4 files changed

+226
-0
lines changed

openwisp_controller/config/apps.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,7 @@ def enable_cache_invalidation(self):
270270
device_cache_invalidation_handler,
271271
devicegroup_change_handler,
272272
devicegroup_delete_handler,
273+
organization_config_settings_change_handler,
273274
vpn_server_change_handler,
274275
)
275276

@@ -336,6 +337,11 @@ def enable_cache_invalidation(self):
336337
sender=self.vpn_model,
337338
dispatch_uid="vpn.invalidate_checksum_cache",
338339
)
340+
post_save.connect(
341+
organization_config_settings_change_handler,
342+
sender=self.organization_config_settings_model,
343+
dispatch_uid="organization_config_settings.invalidate_vpn_cache",
344+
)
339345

340346
def register_dashboard_charts(self):
341347
register_dashboard_chart(

openwisp_controller/config/handlers.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,3 +152,22 @@ def organization_disabled_handler(instance, **kwargs):
152152
# No change in is_active
153153
return
154154
tasks.invalidate_controller_views_cache.delay(str(instance.id))
155+
156+
157+
def organization_config_settings_change_handler(instance, **kwargs):
158+
"""
159+
Invalidates VPN cache when OrganizationConfigSettings context changes.
160+
"""
161+
if instance._state.adding:
162+
return
163+
164+
try:
165+
db_instance = instance.__class__.objects.only("context").get(id=instance.id)
166+
if db_instance.context != instance.context:
167+
transaction.on_commit(
168+
lambda: tasks.invalidate_organization_vpn_cache.delay(
169+
str(instance.organization_id)
170+
)
171+
)
172+
except instance.__class__.DoesNotExist:
173+
pass

openwisp_controller/config/tasks.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,27 @@ def invalidate_vpn_server_devices_cache_change(vpn_pk):
100100
VpnClient.invalidate_clients_cache(vpn)
101101

102102

103+
@shared_task(soft_time_limit=7200)
104+
def invalidate_organization_vpn_cache(organization_id):
105+
"""
106+
Invalidates VPN cache for all VPNs in an organization when
107+
organization configuration variables change.
108+
"""
109+
from .controller.views import GetVpnView
110+
111+
Vpn = load_model("config", "Vpn")
112+
113+
try:
114+
vpn_queryset = Vpn.objects.filter(organization_id=organization_id).only("id")
115+
for vpn in vpn_queryset.iterator():
116+
GetVpnView.invalidate_get_vpn_cache(vpn)
117+
vpn.invalidate_checksum_cache()
118+
except Exception as e:
119+
logger.error(
120+
f"Error invalidating VPN cache for organization {organization_id}: {e}"
121+
)
122+
123+
103124
@shared_task(soft_time_limit=7200)
104125
def invalidate_devicegroup_cache_delete(instance_id, model_name, **kwargs):
105126
from .api.views import DeviceGroupCommonName
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
"""
2+
Tests for VPN cache invalidation when Organization Configuration variables change
3+
Addresses GitHub issue #1098
4+
"""
5+
6+
from unittest import mock
7+
8+
from django.test import TestCase, TransactionTestCase
9+
from django.test.utils import override_settings
10+
from swapper import load_model
11+
12+
from openwisp_controller.config.tests.utils import CreateConfigTemplateMixin
13+
14+
Device = load_model('config', 'Device')
15+
Vpn = load_model('config', 'Vpn')
16+
OrganizationConfigSettings = load_model('config', 'OrganizationConfigSettings')
17+
18+
19+
class TestOrganizationVpnCacheInvalidation(CreateConfigTemplateMixin, TransactionTestCase):
20+
"""
21+
Test VPN cache invalidation when Organization Configuration variables change
22+
"""
23+
24+
def setUp(self):
25+
super().setUp()
26+
27+
def _create_org_config_settings(self, organization=None, context=None):
28+
"""Helper method to create organization config settings"""
29+
if organization is None:
30+
organization = self._get_org()
31+
if context is None:
32+
context = {'test_var': 'initial_value'}
33+
34+
return OrganizationConfigSettings.objects.create(
35+
organization=organization,
36+
context=context
37+
)
38+
39+
def test_vpn_cache_invalidated_on_context_change(self):
40+
"""
41+
Test that VPN cache is invalidated when organization context changes
42+
"""
43+
org = self._get_org()
44+
vpn = self._create_vpn(organization=org)
45+
46+
org_config = self._create_org_config_settings(
47+
organization=org,
48+
context={'api_key': 'test123'}
49+
)
50+
51+
with mock.patch(
52+
'openwisp_controller.config.tasks.invalidate_organization_vpn_cache.delay'
53+
) as mock_task:
54+
org_config.context = {'api_key': 'test456'}
55+
org_config.save()
56+
57+
mock_task.assert_called_once_with(str(org.id))
58+
59+
def test_vpn_cache_not_invalidated_on_non_context_change(self):
60+
"""
61+
Test that VPN cache is NOT invalidated when non-context fields change
62+
"""
63+
org = self._get_org()
64+
vpn = self._create_vpn(organization=org)
65+
66+
org_config = self._create_org_config_settings(organization=org)
67+
68+
with mock.patch(
69+
'openwisp_controller.config.tasks.invalidate_organization_vpn_cache.delay'
70+
) as mock_task:
71+
org_config.registration_enabled = False
72+
org_config.save()
73+
74+
mock_task.assert_not_called()
75+
76+
def test_vpn_cache_not_invalidated_on_create(self):
77+
"""
78+
Test that VPN cache is NOT invalidated when creating new org config settings
79+
"""
80+
org = self._get_org()
81+
vpn = self._create_vpn(organization=org)
82+
83+
with mock.patch(
84+
'openwisp_controller.config.tasks.invalidate_organization_vpn_cache.delay'
85+
) as mock_task:
86+
self._create_org_config_settings(organization=org)
87+
88+
mock_task.assert_not_called()
89+
90+
@mock.patch('openwisp_controller.config.controller.views.GetVpnView.invalidate_get_vpn_cache')
91+
def test_invalidate_organization_vpn_cache_task(self, mock_invalidate_view_cache):
92+
"""
93+
Test the invalidate_organization_vpn_cache task directly
94+
"""
95+
from openwisp_controller.config.tasks import invalidate_organization_vpn_cache
96+
97+
org = self._get_org()
98+
vpn1 = self._create_vpn(organization=org, name='vpn1')
99+
vpn2 = self._create_vpn(organization=org, name='vpn2')
100+
101+
other_org = self._create_org(name='other-org', slug='other-org')
102+
vpn3 = self._create_vpn(organization=other_org, name='vpn3')
103+
104+
with mock.patch.object(vpn1, 'invalidate_checksum_cache') as mock_vpn1_cache, \
105+
mock.patch.object(vpn2, 'invalidate_checksum_cache') as mock_vpn2_cache, \
106+
mock.patch.object(vpn3, 'invalidate_checksum_cache') as mock_vpn3_cache:
107+
108+
invalidate_organization_vpn_cache(str(org.id))
109+
110+
self.assertEqual(mock_invalidate_view_cache.call_count, 2)
111+
mock_vpn1_cache.assert_called_once()
112+
mock_vpn2_cache.assert_called_once()
113+
mock_vpn3_cache.assert_not_called()
114+
115+
@override_settings(CELERY_TASK_ALWAYS_EAGER=True)
116+
def test_end_to_end_cache_invalidation(self):
117+
"""
118+
Test end-to-end cache invalidation with actual task execution
119+
"""
120+
org = self._get_org()
121+
vpn = self._create_vpn(organization=org)
122+
123+
org_config = self._create_org_config_settings(organization=org)
124+
125+
with mock.patch.object(vpn, 'invalidate_checksum_cache') as mock_vpn_cache, \
126+
mock.patch(
127+
'openwisp_controller.config.controller.views.GetVpnView.invalidate_get_vpn_cache'
128+
) as mock_view_cache:
129+
130+
org_config.context = {'changed': 'value'}
131+
org_config.save()
132+
133+
mock_vpn_cache.assert_called_once()
134+
mock_view_cache.assert_called_once_with(vpn)
135+
136+
def test_task_error_handling(self):
137+
"""
138+
Test that the task handles errors gracefully
139+
"""
140+
from openwisp_controller.config.tasks import invalidate_organization_vpn_cache
141+
142+
try:
143+
invalidate_organization_vpn_cache('non-existent-id')
144+
except Exception as e:
145+
self.fail(f"Task should handle errors gracefully, but raised: {e}")
146+
147+
148+
class TestOrganizationVpnCacheInvalidationSimple(CreateConfigTemplateMixin, TestCase):
149+
"""
150+
Simple test cases for basic functionality verification
151+
"""
152+
153+
def test_handler_function_exists(self):
154+
"""Test that the handler function is properly defined"""
155+
from openwisp_controller.config.handlers import organization_config_settings_change_handler
156+
157+
self.assertTrue(callable(organization_config_settings_change_handler))
158+
159+
def test_task_function_exists(self):
160+
"""Test that the task function is properly defined"""
161+
from openwisp_controller.config.tasks import invalidate_organization_vpn_cache
162+
163+
self.assertTrue(callable(invalidate_organization_vpn_cache))
164+
165+
def test_signal_connection(self):
166+
"""Test that the signal is properly connected"""
167+
from django.db.models.signals import post_save
168+
from openwisp_controller.config.handlers import organization_config_settings_change_handler
169+
170+
receivers = post_save._live_receivers(sender=OrganizationConfigSettings)
171+
172+
handler_connected = any(
173+
receiver.__name__ == 'organization_config_settings_change_handler'
174+
for receiver in receivers
175+
)
176+
177+
self.assertTrue(
178+
handler_connected,
179+
"organization_config_settings_change_handler should be connected to post_save signal"
180+
)

0 commit comments

Comments
 (0)