Skip to content

[fix] Prevent command creation for devices without a DeviceConnection #1016#1021

Merged
nemesifier merged 7 commits into
openwisp:masterfrom
DragnEmperor:issues/1016-command-connection-check
May 23, 2025
Merged

[fix] Prevent command creation for devices without a DeviceConnection #1016#1021
nemesifier merged 7 commits into
openwisp:masterfrom
DragnEmperor:issues/1016-command-connection-check

Conversation

@DragnEmperor
Copy link
Copy Markdown
Member

@DragnEmperor DragnEmperor commented May 5, 2025

Checklist

  • I have read the OpenWISP Contributing Guidelines.
  • I have manually tested the changes proposed in this pull request.
  • I have written new test cases for new code and/or updated existing tests for changes to existing code.

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'.

@DragnEmperor DragnEmperor force-pushed the issues/1016-command-connection-check branch from a73d01f to 17d17e6 Compare May 6, 2025 01:32
@coveralls
Copy link
Copy Markdown

coveralls commented May 6, 2025

Coverage Status

coverage: 98.827% (+0.003%) from 98.824%
when pulling 2860931 on DragnEmperor:issues/1016-command-connection-check
into 957c4a3 on openwisp:master.

@DragnEmperor DragnEmperor force-pushed the issues/1016-command-connection-check branch from 17d17e6 to 6cab435 Compare May 6, 2025 02:25
Comment on lines +69 to +73
if not DeviceConnection.objects.filter(
device_id=self.kwargs['device_pk'],
credentials__isnull=False,
).exists():
raise NotFound(detail='Device has no credentials assigned.')
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you raise the validation error from the serializer itself?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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],
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you try with response.json() here?

@DragnEmperor DragnEmperor force-pushed the issues/1016-command-connection-check branch from 6cab435 to d6ec29a Compare May 7, 2025 12:40
Copy link
Copy Markdown
Member

@devkapilbansal devkapilbansal left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good till now, please address below requests from my side

Comment thread openwisp_controller/connection/api/serializers.py
Copy link
Copy Markdown
Member

@nemesifier nemesifier left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

@pandafy
Copy link
Copy Markdown
Member

pandafy commented May 9, 2025

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.

Copy link
Copy Markdown
Member

@pandafy pandafy left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good, just need some minor improvements.

Comment on lines +42 to +44
self.fields['connection'].queryset = DeviceConnection.objects.filter(
device=device
)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
self.fields['connection'].queryset = DeviceConnection.objects.filter(
device=device
)
self.fields['connection'].queryset = self.fields['connection'].queryset.filter(
device_id=device.id
)

Comment on lines +472 to +475
if not DeviceConnection.objects.filter(
device=self.device,
credentials__isnull=False,
).exists():
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will this be more readable?

Suggested change
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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd also prefer this.

Comment on lines +42 to +44
self.fields['connection'].queryset = DeviceConnection.objects.filter(
device=device
)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is altering the labels of the type field in the browsable API.

image

The option label should be Change password instead of change_password.

Comment on lines +179 to +193
# 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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We generally test the presence/absence of desired elements in browsable API like this

def test_template_list_for_shared_objects(self):
org1 = self._get_org()
self._create_vpn(name='shared-vpn', organization=None)
test_user = self._create_operator(organizations=[org1])
self.client.force_login(test_user)
path = reverse('config_api:template_list')
r = self.client.get(path, {'format': 'api'})
self.assertEqual(r.status_code, 200)
self.assertContains(r, 'shared-vpn</option>')

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! Was looking for something like this

@pandafy pandafy moved this from To do (general) to In progress in OpenWISP Contributor's Board May 12, 2025
Comment on lines +37 to +44
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
)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about something like:

Suggested change
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')
)```

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes would reduce query in case we are using it somewhere where context not provided by view

Comment on lines +472 to +475
if not DeviceConnection.objects.filter(
device=self.device,
credentials__isnull=False,
).exists():
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd also prefer this.

Comment thread openwisp_controller/connection/base/models.py
@DragnEmperor DragnEmperor force-pushed the issues/1016-command-connection-check branch from ce2b540 to 66d3cf9 Compare May 13, 2025 09:19
@pandafy pandafy self-requested a review May 13, 2025 17:01
Copy link
Copy Markdown
Member

@pandafy pandafy left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have performed manual testing and it works as expected.

Comment on lines +567 to +572
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'}
)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It should be okay, I guess.

'"custom" command is not available for this organization',
response.data['input'][0],
'"custom" is not a valid choice.',
response.json()['type'][0],
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are you changing response.data to response.json()?

Comment on lines +43 to +49
# 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
]
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we move this to a class method in Command model?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. Makes sense

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should i combine this with get_org_choices as they seem to to do the same thing?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think, that is an acceptable solution. I am afraid, that will be backward incompatible though.

We would also need to update this code

if self.type not in self.get_org_choices(
organization_id=self.device.organization_id
):

cc @nemesifier

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, go ahead!

@DragnEmperor DragnEmperor force-pushed the issues/1016-command-connection-check branch 2 times, most recently from 3997b12 to 2106d23 Compare May 15, 2025 19:03
pandafy
pandafy previously approved these changes May 20, 2025
Copy link
Copy Markdown
Member

@pandafy pandafy left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great work @DragnEmperor! 👏🏼

LGTM!

@pandafy pandafy moved this from In progress to Needs review in OpenWISP Contributor's Board May 20, 2025
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]
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick: we can iterate over commands list and search it in dict.

Suggested change
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>
@DragnEmperor DragnEmperor force-pushed the issues/1016-command-connection-check branch from b0e5398 to 7d42f3d Compare May 23, 2025 14:54
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>
@nemesifier
Copy link
Copy Markdown
Member

Thanks! @DragnEmperor @pandafy @codesankalp 👏

@nemesifier nemesifier merged commit 4836913 into openwisp:master May 23, 2025
15 checks passed
@github-project-automation github-project-automation Bot moved this from Needs review to Done in OpenWISP Contributor's Board May 23, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Archived in project

Development

Successfully merging this pull request may close these issues.

[bug] Command API allow creating command for devices without DeviceConnection

6 participants