[fix] Prevent command creation for devices without a DeviceConnection #1016#1021
Conversation
a73d01f to
17d17e6
Compare
17d17e6 to
6cab435
Compare
| if not DeviceConnection.objects.filter( | ||
| device_id=self.kwargs['device_pk'], | ||
| credentials__isnull=False, | ||
| ).exists(): | ||
| raise NotFound(detail='Device has no credentials assigned.') |
There was a problem hiding this comment.
Can you raise the validation error from the serializer itself?
There was a problem hiding this comment.
Sure! does the following format works?
{ "device": [ "Device has no credentials assigned." ] }
| '"custom" command is not available for this organization', | ||
| response.data['input'][0], | ||
| '"custom" is not a valid choice.', | ||
| json.loads(response.content)['type'][0], |
There was a problem hiding this comment.
can you try with response.json() here?
6cab435 to
d6ec29a
Compare
devkapilbansal
left a comment
There was a problem hiding this comment.
Looks good till now, please address below requests from my side
nemesifier
left a comment
There was a problem hiding this comment.
Why don't we do this at model level?
Does it make sense to create a command object for a device which doesn't have any connection info? What do you think @pandafy?
Yes, we should add validation at the model level. |
pandafy
left a comment
There was a problem hiding this comment.
Looks good, just need some minor improvements.
| self.fields['connection'].queryset = DeviceConnection.objects.filter( | ||
| device=device | ||
| ) |
There was a problem hiding this comment.
| self.fields['connection'].queryset = DeviceConnection.objects.filter( | |
| device=device | |
| ) | |
| self.fields['connection'].queryset = self.fields['connection'].queryset.filter( | |
| device_id=device.id | |
| ) |
| if not DeviceConnection.objects.filter( | ||
| device=self.device, | ||
| credentials__isnull=False, | ||
| ).exists(): |
There was a problem hiding this comment.
Will this be more readable?
| if not DeviceConnection.objects.filter( | |
| device=self.device, | |
| credentials__isnull=False, | |
| ).exists(): | |
| if self.device and self.device.deviceconnection_set.exists(): |
Let's check if this generates any additional queries.
| self.fields['connection'].queryset = DeviceConnection.objects.filter( | ||
| device=device | ||
| ) |
| # for ensuring that only related connections are shown | ||
| def test_available_connections(self): | ||
| url = self._get_path('device_command_list', self.device_id) | ||
| response = self.client.get(url) | ||
| serializer = CommandSerializer( | ||
| instance=self.device_conn.device, | ||
| context={'device_id': self.device_id, 'request': response.wsgi_request}, | ||
| ) | ||
| device = self._create_device( | ||
| name='default.test.device2', mac_address='12:23:34:45:56:67' | ||
| ) | ||
| self._create_config(device=device) | ||
| credentials_2 = self._create_credentials(name='Test Credentials 2') | ||
| device_conn2 = self._create_device_connection( | ||
| device=device, credentials=credentials_2 | ||
| ) | ||
| connections = serializer.fields['connection'] | ||
| queryset = list(connections.get_queryset().values_list('id', flat=True)) | ||
| queryset = [str(i) for i in queryset] | ||
| self.assertIn(str(self.device_conn.id), queryset) | ||
| self.assertNotIn(device_conn2.id, queryset) | ||
|
|
There was a problem hiding this comment.
We generally test the presence/absence of desired elements in browsable API like this
openwisp-controller/openwisp_controller/config/tests/test_api.py
Lines 767 to 775 in 523f4da
There was a problem hiding this comment.
Thanks! Was looking for something like this
| if ( | ||
| device := Device.objects.filter(pk=self.context.get('device_id')) | ||
| .only('organization_id', 'id') | ||
| .first() | ||
| ): | ||
| self.fields['connection'].queryset = DeviceConnection.objects.filter( | ||
| device=device | ||
| ) |
There was a problem hiding this comment.
What about something like:
| if ( | |
| device := Device.objects.filter(pk=self.context.get('device_id')) | |
| .only('organization_id', 'id') | |
| .first() | |
| ): | |
| self.fields['connection'].queryset = DeviceConnection.objects.filter( | |
| device=device | |
| ) | |
| device_id = self.context.get('device_id') | |
| if device_id: | |
| self.fields['connection'].queryset = DeviceConnection.objects.filter( | |
| device_id=self.context.get('device_id') | |
| )``` |
There was a problem hiding this comment.
Yes would reduce query in case we are using it somewhere where context not provided by view
| if not DeviceConnection.objects.filter( | ||
| device=self.device, | ||
| credentials__isnull=False, | ||
| ).exists(): |
ce2b540 to
66d3cf9
Compare
pandafy
left a comment
There was a problem hiding this comment.
I have performed manual testing and it works as expected.
| device = self._create_device( | ||
| name='default.test.device2', mac_address='11:22:33:44:55:66' | ||
| ) | ||
| new_command = Command( | ||
| device=device, type='custom', input={'command': 'echo test'} | ||
| ) |
There was a problem hiding this comment.
Instead of creating new objects, can you we please do this?
device.deviceconnection_set.delete()
command.type = 'custom'
command.input = {'command': 'echo test'}
with self.assertRaises(ValidationError) as context_manager:
command.full_clean()This is more of a personal preference. And. this keeps the entire test consistent.
There was a problem hiding this comment.
With this we need to make sure that this subTest is executed last, if any new subTests are added here in future, as then those subtests will raise error. Just a heads up
| '"custom" command is not available for this organization', | ||
| response.data['input'][0], | ||
| '"custom" is not a valid choice.', | ||
| response.json()['type'][0], |
There was a problem hiding this comment.
Why are you changing response.data to response.json()?
| # filter command types based on the device's organization | ||
| allowed_commands = Command.get_org_choices(device.organization_id) | ||
| # this translates the commands to their labels | ||
| commands_map = dict(COMMAND_CHOICES) | ||
| self.fields['type'].choices = [ | ||
| (i, commands_map[i]) for i in commands_map if i in allowed_commands | ||
| ] |
There was a problem hiding this comment.
Can we move this to a class method in Command model?
There was a problem hiding this comment.
Should i combine this with get_org_choices as they seem to to do the same thing?
There was a problem hiding this comment.
I think, that is an acceptable solution. I am afraid, that will be backward incompatible though.
We would also need to update this code
openwisp-controller/openwisp_controller/connection/base/models.py
Lines 473 to 475 in 523f4da
cc @nemesifier
There was a problem hiding this comment.
Yes, i think this is the only place where it is used, so it should be easier now to do this change. Should I proceed?
3997b12 to
2106d23
Compare
pandafy
left a comment
There was a problem hiding this comment.
Great work @DragnEmperor! 👏🏼
LGTM!
| str(organization_id), ORGANIZATION_ENABLED_COMMANDS.get('__all__') | ||
| ) | ||
| commands_map = dict(COMMAND_CHOICES) | ||
| return [(i, commands_map[i]) for i in commands_map if i in allowed_commands] |
There was a problem hiding this comment.
nitpick: we can iterate over commands list and search it in dict.
| return [(i, commands_map[i]) for i in commands_map if i in allowed_commands] | |
| return [(cmd, commands_map[cmd]) for cmd in allowed_commands if cmd in commands_map] |
…penwisp#1016 Added a check before creation to ensure device has credentials via 'DeviceConnection'. To ensure only relevant connection and command types are displayed modified their queryset in serializer's 'init'. Closes openwisp#1016 Signed-off-by: DragnEmperor <dragnemperor@gmail.com>
As we are now restricting the choices at serializer level, the checks in api test cases required a small change to handle this. Added a new test case for creating command for a device without device connection. Signed-off-by: DragnEmperor <dragnemperor@gmail.com>
Instead of raising the credentials not assigned error from views, its better to raise it from serializer as part of validations. Also updated the tests to handle this change. Signed-off-by: DragnEmperor <dragnemperor@gmail.com>
Added a test to ensure only the connections of device are available on browser api form. Migrated the validation error from serializer to model level. Signed-off-by: DragnEmperor <dragnemperor@gmail.com>
Modified checks in model for better readability. Choices labels are now being shown in browseable API form using `COMMAND_CHOICES`. `test_available_commands` is modified to check using browseable api form instead of serializer. Also, added additional test case for Connection model. Signed-off-by: DragnEmperor <dragnemperor@gmail.com>
Modified `get_org_choices` method to fetch choices as list of tuples to support labels wherever possible. Refactored test cases Signed-off-by: DragnEmperor <dragnemperor@gmail.com>
b0e5398 to
7d42f3d
Compare
Instead of looping through the `commands_map`, its better to loop the allowed commands to reduce overhead. Corrected tuple format of `ORGANIZATION_ENABLED_COMMANDS` for `test_execute_user_registered_command_without_input`. Signed-off-by: DragnEmperor <dragnemperor@gmail.com>
|
Thanks! @DragnEmperor @pandafy @codesankalp 👏 |

Checklist
Reference to Existing Issue
Closes #1016 .
Description of Changes
Added a check before creation of command via REST API to ensure device has credentials via 'DeviceConnection'. API throws 400 error if not. To ensure only relevant connection and command types are displayed modified their queryset in serializer's 'init'.