From 11f31ef7963289ff55a53e04974af49ed543b366 Mon Sep 17 00:00:00 2001 From: Robin Bobbitt Date: Thu, 14 Aug 2025 14:02:34 -0400 Subject: [PATCH] AAP-43883: clear cached LICENSE setting on change (#16065) (#7064) * clear LICENSE from cache on change * Adds tests for license cache clearing Generated by Cursor (claude-4-sonnet) * test fixes Generated with Cursor (claude-4-sonnet) --------- Signed-off-by: Robin Y Bobbitt Co-authored-by: Jake Jackson --- awx/api/views/root.py | 6 +- .../api/test_license_cache_clearing.py | 191 ++++++++++++++++++ 2 files changed, 196 insertions(+), 1 deletion(-) create mode 100644 awx/main/tests/functional/api/test_license_cache_clearing.py diff --git a/awx/api/views/root.py b/awx/api/views/root.py index 2ba8a01bbc..539016935e 100644 --- a/awx/api/views/root.py +++ b/awx/api/views/root.py @@ -9,6 +9,7 @@ from collections import OrderedDict from django.conf import settings from django.core.cache import cache +from django.db import connection from django.utils.encoding import smart_str from django.utils.decorators import method_decorator from django.views.decorators.csrf import ensure_csrf_cookie @@ -27,6 +28,7 @@ from awx.api.generics import APIView from awx.conf.registry import settings_registry from awx.main.analytics import all_collectors from awx.main.ha import is_ha_environment +from awx.main.tasks.system import clear_setting_cache from awx.main.utils import get_awx_version, get_custom_venv_choices from awx.main.utils.licensing import validate_entitlement_manifest from awx.api.versioning import URLPathVersioning, is_optional_api_urlpattern_prefix_request, reverse, drf_reverse @@ -268,6 +270,7 @@ class ApiV2AttachView(APIView): if sub['subscription_id'] == subscription_id: sub['valid_key'] = True settings.LICENSE = sub + connection.on_commit(lambda: clear_setting_cache.delay(['LICENSE'])) return Response(sub) return Response({"error": _("Error processing subscription metadata.")}, status=status.HTTP_400_BAD_REQUEST) @@ -287,7 +290,6 @@ class ApiV2ConfigView(APIView): '''Return various sitewide configuration settings''' license_data = get_licenser().validate() - if not license_data.get('valid_key', False): license_data = {} @@ -364,6 +366,7 @@ class ApiV2ConfigView(APIView): try: license_data_validated = get_licenser().license_from_manifest(license_data) + connection.on_commit(lambda: clear_setting_cache.delay(['LICENSE'])) except Exception: logger.warning(smart_str(u"Invalid subscription submitted."), extra=dict(actor=request.user.username)) return Response({"error": _("Invalid License")}, status=status.HTTP_400_BAD_REQUEST) @@ -382,6 +385,7 @@ class ApiV2ConfigView(APIView): def delete(self, request): try: settings.LICENSE = {} + connection.on_commit(lambda: clear_setting_cache.delay(['LICENSE'])) return Response(status=status.HTTP_204_NO_CONTENT) except Exception: # FIX: Log diff --git a/awx/main/tests/functional/api/test_license_cache_clearing.py b/awx/main/tests/functional/api/test_license_cache_clearing.py new file mode 100644 index 0000000000..08b8e35138 --- /dev/null +++ b/awx/main/tests/functional/api/test_license_cache_clearing.py @@ -0,0 +1,191 @@ +import pytest +from unittest.mock import patch, MagicMock + +from awx.api.versioning import reverse + + +# Generated by Cursor (claude-4-sonnet) +@pytest.mark.django_db +class TestLicenseCacheClearing: + """Test cache clearing for LICENSE setting changes""" + + def test_license_from_manifest_clears_cache(self, admin_user, post): + """Test that posting a manifest to /api/v2/config/ clears the LICENSE cache""" + + # Mock the licenser and clear_setting_cache + with patch('awx.api.views.root.get_licenser') as mock_get_licenser, patch('awx.api.views.root.validate_entitlement_manifest') as mock_validate, patch( + 'awx.api.views.root.clear_setting_cache' + ) as mock_clear_cache, patch('django.db.connection.on_commit') as mock_on_commit: + + # Set up mock license data + mock_license_data = {'valid_key': True, 'license_type': 'enterprise', 'instance_count': 100, 'subscription_name': 'Test Enterprise License'} + + # Mock the validation and license processing + mock_validate.return_value = [{'some': 'manifest_data'}] + mock_licenser = MagicMock() + mock_licenser.license_from_manifest.return_value = mock_license_data + mock_get_licenser.return_value = mock_licenser + + # Prepare the request data (base64 encoded manifest) + manifest_data = {'manifest': 'ZmFrZS1tYW5pZmVzdC1kYXRh'} # base64 for "fake-manifest-data" + + # Make the POST request + url = reverse('api:api_v2_config_view') + response = post(url, manifest_data, admin_user, expect=200) + + # Verify the response + assert response.data == mock_license_data + + # Verify license_from_manifest was called + mock_licenser.license_from_manifest.assert_called_once() + + # Verify on_commit was called (may be multiple times due to other settings) + assert mock_on_commit.call_count >= 1 + + # Execute all on_commit callbacks to trigger cache clearing + for call_args in mock_on_commit.call_args_list: + callback = call_args[0][0] + callback() + + # Verify that clear_setting_cache.delay was called with ['LICENSE'] + mock_clear_cache.delay.assert_any_call(['LICENSE']) + + def test_config_delete_clears_cache(self, admin_user, delete): + """Test that DELETE /api/v2/config/ clears the LICENSE cache""" + + with patch('awx.api.views.root.clear_setting_cache') as mock_clear_cache, patch('django.db.connection.on_commit') as mock_on_commit: + + # Make the DELETE request + url = reverse('api:api_v2_config_view') + delete(url, admin_user, expect=204) + + # Verify on_commit was called at least once + assert mock_on_commit.call_count >= 1 + + # Execute all on_commit callbacks to trigger cache clearing + for call_args in mock_on_commit.call_args_list: + callback = call_args[0][0] + callback() + + mock_clear_cache.delay.assert_called_once_with(['LICENSE']) + + def test_attach_view_clears_cache(self, admin_user, post): + """Test that posting to /api/v2/config/attach/ clears the LICENSE cache""" + + with patch('awx.api.views.root.get_licenser') as mock_get_licenser, patch('awx.api.views.root.clear_setting_cache') as mock_clear_cache, patch( + 'django.db.connection.on_commit' + ) as mock_on_commit, patch('awx.api.views.root.settings') as mock_settings: + + # Set up subscription credentials in settings + mock_settings.SUBSCRIPTIONS_CLIENT_ID = 'test-client-id' + mock_settings.SUBSCRIPTIONS_CLIENT_SECRET = 'test-client-secret' + + # Set up mock licenser with validated subscriptions + mock_licenser = MagicMock() + subscription_data = {'subscription_id': 'test-subscription-123', 'valid_key': False, 'license_type': 'enterprise', 'instance_count': 50} + mock_licenser.validate_rh.return_value = [subscription_data] + mock_get_licenser.return_value = mock_licenser + + # Prepare request data + request_data = {'subscription_id': 'test-subscription-123'} + + # Make the POST request + url = reverse('api:api_v2_attach_view') + response = post(url, request_data, admin_user, expect=200) + + # Verify the response includes valid_key=True + assert response.data['valid_key'] is True + assert response.data['subscription_id'] == 'test-subscription-123' + + # Verify settings.LICENSE was set + expected_license = subscription_data.copy() + expected_license['valid_key'] = True + assert mock_settings.LICENSE == expected_license + + # Verify cache clearing was scheduled + mock_on_commit.assert_called_once() + call_args = mock_on_commit.call_args[0][0] # Get the lambda function + + # Execute the lambda to verify it calls clear_setting_cache + call_args() + mock_clear_cache.delay.assert_called_once_with(['LICENSE']) + + def test_attach_view_subscription_not_found_no_cache_clear(self, admin_user, post): + """Test that attach view doesn't clear cache when subscription is not found""" + + with patch('awx.api.views.root.get_licenser') as mock_get_licenser, patch('awx.api.views.root.clear_setting_cache') as mock_clear_cache, patch( + 'django.db.connection.on_commit' + ) as mock_on_commit: + + # Set up mock licenser with different subscription + mock_licenser = MagicMock() + subscription_data = {'subscription_id': 'different-subscription-456', 'valid_key': False, 'license_type': 'enterprise'} # Different ID + mock_licenser.validate_rh.return_value = [subscription_data] + mock_get_licenser.return_value = mock_licenser + + # Request data with non-matching subscription ID + request_data = { + 'subscription_id': 'test-subscription-123', # This won't match + } + + # Make the POST request + url = reverse('api:api_v2_attach_view') + response = post(url, request_data, admin_user, expect=400) + + # Verify error response + assert 'error' in response.data + + # Verify cache clearing was NOT called (no matching subscription) + mock_on_commit.assert_not_called() + mock_clear_cache.delay.assert_not_called() + + def test_manifest_validation_error_no_cache_clear(self, admin_user, post): + """Test that config view doesn't clear cache when manifest validation fails""" + + with patch('awx.api.views.root.validate_entitlement_manifest') as mock_validate, patch( + 'awx.api.views.root.clear_setting_cache' + ) as mock_clear_cache, patch('django.db.connection.on_commit') as mock_on_commit: + + # Mock validation to raise ValueError + mock_validate.side_effect = ValueError("Invalid manifest") + + # Prepare request data + manifest_data = {'manifest': 'aW52YWxpZC1tYW5pZmVzdA=='} # base64 for "invalid-manifest" + + # Make the POST request + url = reverse('api:api_v2_config_view') + response = post(url, manifest_data, admin_user, expect=400) + + # Verify error response + assert response.data['error'] == 'Invalid manifest' + + # Verify cache clearing was NOT called (validation failed) + mock_on_commit.assert_not_called() + mock_clear_cache.delay.assert_not_called() + + def test_license_processing_error_no_cache_clear(self, admin_user, post): + """Test that config view doesn't clear cache when license processing fails""" + + with patch('awx.api.views.root.get_licenser') as mock_get_licenser, patch('awx.api.views.root.validate_entitlement_manifest') as mock_validate, patch( + 'awx.api.views.root.clear_setting_cache' + ) as mock_clear_cache, patch('django.db.connection.on_commit') as mock_on_commit: + + # Mock validation to succeed but license processing to fail + mock_validate.return_value = [{'some': 'manifest_data'}] + mock_licenser = MagicMock() + mock_licenser.license_from_manifest.side_effect = Exception("License processing failed") + mock_get_licenser.return_value = mock_licenser + + # Prepare request data + manifest_data = {'manifest': 'ZmFrZS1tYW5pZmVzdA=='} # base64 for "fake-manifest" + + # Make the POST request + url = reverse('api:api_v2_config_view') + response = post(url, manifest_data, admin_user, expect=400) + + # Verify error response + assert response.data['error'] == 'Invalid License' + + # Verify cache clearing was NOT called (license processing failed) + mock_on_commit.assert_not_called() + mock_clear_cache.delay.assert_not_called()