Skip to content

Commit 2e96f45

Browse files
committed
[AAP-51419] Fix remote object handling in RBAC assignments and resource sync
This commit addresses multiple issues with remote object support in RBAC: 1. **Fix prefetch_related issue with remote objects**: Update FederatedForeignKey to properly handle remote objects that were incorrectly cached as None by Django's prefetch_related mechanism. Remote objects are now recreated when needed instead of returning None. 2. **Fix UUID serialization in resource sync**: Resolve TypeError in ResourceAPIClient.sync_unassignment when syncing assignments with UUID primary keys by converting pk values to strings for JSON serialization. 3. **Fix typo in RemoteObject error message**: Correct "Generlized" to "Generalized" in RemoteObject.get_ct_from_type method. 4. **Add comprehensive test coverage**: New test suites covering: - Remote object serialization behavior in assignment serializers - Remote object handling in assignment view operations - ResourceAPIClient remote object sync functionality - UUID serialization fixes for various object types - Integration tests validating end-to-end remote object workflows These changes improve the reliability of remote object assignments and ensure proper synchronization of permissions across distributed systems. Co-Authored-By: Claude <[email protected]> Signed-off-by: Fabricio Aguiar <[email protected]> rh-pre-commit.version: 2.3.2 rh-pre-commit.check-secrets: ENABLED
1 parent a22e139 commit 2e96f45

14 files changed

+1420
-26
lines changed

ansible_base/rbac/models/fields.py

Lines changed: 59 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -68,27 +68,67 @@ def get_content_type(self, obj=None, id=None, using=None, model=None):
6868
def __get__(self, instance, cls=None):
6969
if instance is None:
7070
return self
71+
72+
ct_id, pk_val = self._get_field_values(instance)
73+
rel_obj = self.get_cached_value(instance, default=None)
74+
75+
if self._should_use_cached_object(instance, rel_obj, ct_id):
76+
return rel_obj
77+
78+
if self._is_cached_object_valid(rel_obj, ct_id, pk_val):
79+
return rel_obj
80+
81+
rel_obj = self._fetch_related_object(ct_id, pk_val)
82+
self.set_cached_value(instance, rel_obj)
83+
return rel_obj
84+
85+
def _get_field_values(self, instance):
86+
"""Extract content type ID and primary key values from instance."""
7187
f = instance._meta.get_field(self.ct_field)
7288
ct_id = getattr(instance, f.attname, None)
7389
pk_val = getattr(instance, self.fk_field)
74-
rel_obj = self.get_cached_value(instance, default=None)
75-
if rel_obj is None and self.is_cached(instance):
76-
return rel_obj
77-
if rel_obj is not None:
78-
ct_match = ct_id == self.get_content_type(obj=rel_obj).id
79-
pk_match = ct_match and rel_obj.pk == pk_val
80-
if pk_match:
81-
return rel_obj
82-
else:
83-
rel_obj = None
90+
return ct_id, pk_val
91+
92+
def _should_use_cached_object(self, instance, rel_obj, ct_id):
93+
"""Check if we should return the cached object (even if None)."""
94+
if rel_obj is not None or not self.is_cached(instance):
95+
return False
96+
97+
# Handle prefetch_related cache issue for remote objects
8498
if ct_id is not None:
8599
ct = self.get_content_type(id=ct_id)
86-
if ct.service in (get_local_resource_prefix(), "shared"):
87-
try:
88-
rel_obj = ct.get_object_for_this_type(pk=pk_val)
89-
except (ObjectDoesNotExist, LookupError):
90-
rel_obj = None
91-
else:
92-
rel_obj = ct.get_object_for_this_type(pk=pk_val)
93-
self.set_cached_value(instance, rel_obj)
94-
return rel_obj
100+
local_prefix = get_local_resource_prefix()
101+
if ct.service not in (local_prefix, "shared"):
102+
# Remote object incorrectly cached as None - don't use cache
103+
return False
104+
105+
# Local/shared object genuinely None or no content type
106+
return True
107+
108+
def _is_cached_object_valid(self, rel_obj, ct_id, pk_val):
109+
"""Check if the cached object matches the current field values."""
110+
if rel_obj is None:
111+
return False
112+
113+
ct_match = ct_id == self.get_content_type(obj=rel_obj).id
114+
pk_match = ct_match and rel_obj.pk == pk_val
115+
return pk_match
116+
117+
def _fetch_related_object(self, ct_id, pk_val):
118+
"""Fetch the related object from database or create remote object."""
119+
if ct_id is None:
120+
return None
121+
122+
ct = self.get_content_type(id=ct_id)
123+
local_prefix = get_local_resource_prefix()
124+
if ct.service == local_prefix or ct.service == "shared":
125+
return self._fetch_local_object(ct, pk_val)
126+
else:
127+
return ct.get_object_for_this_type(pk=pk_val)
128+
129+
def _fetch_local_object(self, ct, pk_val):
130+
"""Fetch local/shared object with exception handling."""
131+
try:
132+
return ct.get_object_for_this_type(pk=pk_val)
133+
except (ObjectDoesNotExist, LookupError):
134+
return None

ansible_base/rbac/remote.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ def __hash__(self):
9090
@classmethod
9191
def get_ct_from_type(cls):
9292
if not hasattr(cls, '_meta'):
93-
raise ValueError('Generlized RemoteObject can not obtain content_type from its class')
93+
raise ValueError('Generalized RemoteObject can not obtain content_type from its class')
9494
ct_model = apps.get_model('dab_rbac', 'DABContentType')
9595
return ct_model.objects.get_by_natural_key(cls._meta.service, cls._meta.app_label, cls._meta.model_name)
9696

ansible_base/resource_registry/rest_client.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,8 @@ def sync_unassignment(self, role_definition, actor, content_object):
207207
if ct.service == 'shared':
208208
data['object_ansible_id'] = str(content_object.resource.ansible_id)
209209
else:
210-
data['object_id'] = content_object.pk
210+
# Convert pk to string to handle UUID objects for JSON serialization
211+
data["object_id"] = str(content_object.pk)
211212

212213
return self._sync_assignment(data, giving=False)
213214

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Import fixtures from parent directories by importing the specific fixtures we need
2+
from test_app.tests.rbac.conftest import global_inv_rd, inv_rd, rando
3+
from test_app.tests.rbac.remote.conftest import foo_permission, foo_rd, foo_type
4+
5+
__all__ = ['rando', 'inv_rd', 'global_inv_rd', 'foo_type', 'foo_rd', 'foo_permission']
Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
1+
"""
2+
Tests for FederatedForeignKey functionality and helper methods.
3+
4+
This module tests the refactored FederatedForeignKey methods to ensure
5+
80%+ coverage on the new helper methods introduced for complexity reduction.
6+
"""
7+
8+
from unittest.mock import Mock, patch
9+
10+
import pytest
11+
from django.core.exceptions import ObjectDoesNotExist
12+
13+
from ansible_base.rbac.models import RoleUserAssignment
14+
from ansible_base.rbac.models.fields import FederatedForeignKey
15+
from ansible_base.rbac.remote import RemoteObject
16+
17+
pytestmark = pytest.mark.django_db
18+
19+
20+
class TestFederatedForeignKeyHelperMethods:
21+
"""Test the helper methods added during the cognitive complexity refactor."""
22+
23+
def setup_method(self):
24+
"""Set up test fixtures."""
25+
self.field = FederatedForeignKey(ct_field="content_type", fk_field="object_id")
26+
self.mock_instance = Mock()
27+
self.mock_instance._meta.get_field.return_value.attname = "content_type_id"
28+
29+
def test_get_field_values_extracts_correct_values(self):
30+
"""Test _get_field_values correctly extracts content type ID and object ID."""
31+
# Setup mock instance
32+
self.mock_instance.content_type_id = 123
33+
self.mock_instance.object_id = 456
34+
35+
# Call the method
36+
ct_id, pk_val = self.field._get_field_values(self.mock_instance)
37+
38+
# Verify results
39+
assert ct_id == 123
40+
assert pk_val == 456
41+
self.mock_instance._meta.get_field.assert_called_once_with("content_type")
42+
43+
def test_get_field_values_handles_none_values(self):
44+
"""Test _get_field_values handles None values correctly."""
45+
# Setup mock instance with None values
46+
self.mock_instance.content_type_id = None
47+
self.mock_instance.object_id = None
48+
49+
# Call the method
50+
ct_id, pk_val = self.field._get_field_values(self.mock_instance)
51+
52+
# Verify results
53+
assert ct_id is None
54+
assert pk_val is None
55+
56+
def test_should_use_cached_object_returns_false_when_object_exists(self):
57+
"""Test _should_use_cached_object returns False when rel_obj is not None."""
58+
mock_rel_obj = Mock()
59+
result = self.field._should_use_cached_object(self.mock_instance, mock_rel_obj, 123)
60+
assert result is False
61+
62+
def test_should_use_cached_object_returns_false_when_not_cached(self):
63+
"""Test _should_use_cached_object returns False when object is not cached."""
64+
with patch.object(self.field, 'is_cached', return_value=False):
65+
result = self.field._should_use_cached_object(self.mock_instance, None, 123)
66+
assert result is False
67+
68+
def test_should_use_cached_object_handles_remote_object_case(self, foo_type):
69+
"""Test _should_use_cached_object detects remote objects and returns False."""
70+
# Mock the field methods
71+
with patch.object(self.field, 'is_cached', return_value=True):
72+
with patch.object(self.field, 'get_content_type', return_value=foo_type):
73+
result = self.field._should_use_cached_object(self.mock_instance, None, 123)
74+
75+
# Should return False for remote objects (don't use cache)
76+
assert result is False
77+
78+
def test_should_use_cached_object_handles_local_object_case(self):
79+
"""Test _should_use_cached_object returns True for local/shared objects."""
80+
from ansible_base.rbac.remote import get_local_resource_prefix
81+
82+
# Mock a local content type
83+
mock_ct = Mock()
84+
mock_ct.service = get_local_resource_prefix() # Use actual local prefix
85+
86+
with patch.object(self.field, 'is_cached', return_value=True):
87+
with patch.object(self.field, 'get_content_type', return_value=mock_ct):
88+
result = self.field._should_use_cached_object(self.mock_instance, None, 123)
89+
90+
# Should return True for local objects (use cache)
91+
assert result is True
92+
93+
def test_should_use_cached_object_handles_shared_object_case(self):
94+
"""Test _should_use_cached_object returns True for shared objects."""
95+
# Mock a shared content type
96+
mock_ct = Mock()
97+
mock_ct.service = "shared"
98+
99+
with patch.object(self.field, 'is_cached', return_value=True):
100+
with patch.object(self.field, 'get_content_type', return_value=mock_ct):
101+
result = self.field._should_use_cached_object(self.mock_instance, None, 123)
102+
103+
# Should return True for shared objects (use cache)
104+
assert result is True
105+
106+
def test_should_use_cached_object_handles_none_ct_id(self):
107+
"""Test _should_use_cached_object returns True when ct_id is None."""
108+
with patch.object(self.field, 'is_cached', return_value=True):
109+
result = self.field._should_use_cached_object(self.mock_instance, None, None)
110+
111+
# Should return True when no content type (use cache)
112+
assert result is True
113+
114+
def test_is_cached_object_valid_returns_false_for_none_object(self):
115+
"""Test _is_cached_object_valid returns False when rel_obj is None."""
116+
result = self.field._is_cached_object_valid(None, 123, 456)
117+
assert result is False
118+
119+
def test_is_cached_object_valid_returns_true_for_matching_object(self):
120+
"""Test _is_cached_object_valid returns True when object matches."""
121+
mock_rel_obj = Mock()
122+
mock_rel_obj.pk = 456
123+
mock_ct = Mock()
124+
mock_ct.id = 123
125+
126+
with patch.object(self.field, 'get_content_type', return_value=mock_ct):
127+
result = self.field._is_cached_object_valid(mock_rel_obj, 123, 456)
128+
129+
assert result is True
130+
131+
def test_is_cached_object_valid_returns_false_for_mismatched_ct(self):
132+
"""Test _is_cached_object_valid returns False when content type doesn't match."""
133+
mock_rel_obj = Mock()
134+
mock_rel_obj.pk = 456
135+
mock_ct = Mock()
136+
mock_ct.id = 999 # Different content type ID
137+
138+
with patch.object(self.field, 'get_content_type', return_value=mock_ct):
139+
result = self.field._is_cached_object_valid(mock_rel_obj, 123, 456)
140+
141+
assert result is False
142+
143+
def test_is_cached_object_valid_returns_false_for_mismatched_pk(self):
144+
"""Test _is_cached_object_valid returns False when primary key doesn't match."""
145+
mock_rel_obj = Mock()
146+
mock_rel_obj.pk = 999 # Different primary key
147+
mock_ct = Mock()
148+
mock_ct.id = 123
149+
150+
with patch.object(self.field, 'get_content_type', return_value=mock_ct):
151+
result = self.field._is_cached_object_valid(mock_rel_obj, 123, 456)
152+
153+
assert result is False
154+
155+
def test_fetch_related_object_returns_none_for_none_ct_id(self):
156+
"""Test _fetch_related_object returns None when ct_id is None."""
157+
result = self.field._fetch_related_object(None, 456)
158+
assert result is None
159+
160+
def test_fetch_related_object_handles_local_objects(self):
161+
"""Test _fetch_related_object calls _fetch_local_object for local objects."""
162+
from ansible_base.rbac.remote import get_local_resource_prefix
163+
164+
mock_ct = Mock()
165+
mock_ct.service = get_local_resource_prefix() # Use actual local prefix
166+
mock_result = Mock()
167+
168+
with patch.object(self.field, 'get_content_type', return_value=mock_ct):
169+
with patch.object(self.field, '_fetch_local_object', return_value=mock_result) as mock_fetch:
170+
result = self.field._fetch_related_object(123, 456)
171+
172+
mock_fetch.assert_called_once_with(mock_ct, 456)
173+
assert result == mock_result
174+
175+
def test_fetch_related_object_handles_shared_objects(self):
176+
"""Test _fetch_related_object calls _fetch_local_object for shared objects."""
177+
mock_ct = Mock()
178+
mock_ct.service = "shared"
179+
mock_result = Mock()
180+
181+
with patch.object(self.field, 'get_content_type', return_value=mock_ct):
182+
with patch.object(self.field, '_fetch_local_object', return_value=mock_result) as mock_fetch:
183+
result = self.field._fetch_related_object(123, 456)
184+
185+
mock_fetch.assert_called_once_with(mock_ct, 456)
186+
assert result == mock_result
187+
188+
def test_fetch_related_object_handles_remote_objects(self, foo_type):
189+
"""Test _fetch_related_object creates RemoteObject for remote objects."""
190+
mock_result = Mock()
191+
192+
with patch.object(self.field, 'get_content_type', return_value=foo_type):
193+
with patch.object(foo_type, 'get_object_for_this_type', return_value=mock_result) as mock_get:
194+
result = self.field._fetch_related_object(123, 456)
195+
196+
mock_get.assert_called_once_with(pk=456)
197+
assert result == mock_result
198+
199+
def test_fetch_local_object_returns_object_on_success(self):
200+
"""Test _fetch_local_object returns object when found."""
201+
mock_ct = Mock()
202+
mock_result = Mock()
203+
mock_ct.get_object_for_this_type.return_value = mock_result
204+
205+
result = self.field._fetch_local_object(mock_ct, 456)
206+
207+
mock_ct.get_object_for_this_type.assert_called_once_with(pk=456)
208+
assert result == mock_result
209+
210+
def test_fetch_local_object_returns_none_on_does_not_exist(self):
211+
"""Test _fetch_local_object returns None when object doesn't exist."""
212+
mock_ct = Mock()
213+
mock_ct.get_object_for_this_type.side_effect = ObjectDoesNotExist()
214+
215+
result = self.field._fetch_local_object(mock_ct, 456)
216+
217+
assert result is None
218+
219+
def test_fetch_local_object_returns_none_on_lookup_error(self):
220+
"""Test _fetch_local_object returns None on LookupError."""
221+
mock_ct = Mock()
222+
mock_ct.get_object_for_this_type.side_effect = LookupError()
223+
224+
result = self.field._fetch_local_object(mock_ct, 456)
225+
226+
assert result is None
227+
228+
229+
class TestFederatedForeignKeyIntegration:
230+
"""Integration tests for the refactored __get__ method."""
231+
232+
def test_get_method_integration_with_prefetch_fix(self, foo_type, foo_rd, rando):
233+
"""Test that the refactored __get__ method properly handles prefetch_related bug."""
234+
# Create an assignment to a remote object
235+
remote_obj = RemoteObject(content_type=foo_type, object_id=42)
236+
assignment = foo_rd.give_permission(rando, remote_obj)
237+
assignment.refresh_from_db()
238+
239+
# Simulate prefetch_related scenario
240+
qs = RoleUserAssignment.objects.filter(id=assignment.id).prefetch_related('content_object')
241+
assignment_with_prefetch = qs.first()
242+
243+
# The refactored __get__ method should handle this correctly
244+
content_object = assignment_with_prefetch.content_object
245+
246+
# Should recreate the RemoteObject, not return None
247+
assert content_object is not None
248+
assert isinstance(content_object, RemoteObject)
249+
assert content_object.object_id == '42'
250+
251+
def test_get_method_integration_with_valid_cache(self, foo_type, foo_rd, rando):
252+
"""Test that the refactored __get__ method uses valid cached objects."""
253+
# Create an assignment
254+
remote_obj = RemoteObject(content_type=foo_type, object_id=42)
255+
assignment = foo_rd.give_permission(rando, remote_obj)
256+
257+
# Access content_object to populate cache
258+
first_access = assignment.content_object
259+
260+
# Second access should use cache
261+
second_access = assignment.content_object
262+
263+
# Should be the same object (cached)
264+
assert first_access is second_access
265+
assert isinstance(second_access, RemoteObject)
266+
267+
def test_get_method_integration_with_none_ct_id(self):
268+
"""Test that the refactored __get__ method handles None content_type_id."""
269+
# Create a mock assignment with no content type
270+
mock_assignment = Mock()
271+
mock_assignment._meta.get_field.return_value.attname = "content_type_id"
272+
mock_assignment.content_type_id = None
273+
mock_assignment.object_id = 42
274+
275+
field = FederatedForeignKey()
276+
277+
with patch.object(field, 'get_cached_value', return_value=None):
278+
with patch.object(field, 'is_cached', return_value=False):
279+
with patch.object(field, 'set_cached_value') as mock_set_cache:
280+
result = field.__get__(mock_assignment)
281+
282+
# Should return None for assignments with no content type
283+
assert result is None
284+
mock_set_cache.assert_called_once_with(mock_assignment, None)

0 commit comments

Comments
 (0)