diff --git a/netbox/extras/tests/test_api.py b/netbox/extras/tests/test_api.py index d78259971..3414d67ba 100644 --- a/netbox/extras/tests/test_api.py +++ b/netbox/extras/tests/test_api.py @@ -1907,6 +1907,7 @@ class ScriptRunExecutionTestCase(APITestCase): site = ObjectVar(model=Site) sites = MultiObjectVar(model=Site, required=False) label = StringVar(default='hello') + flag = BooleanVar(default=True) def run(self, data, commit=True): return 'ok' @@ -2046,6 +2047,55 @@ class ScriptRunExecutionTestCase(APITestCase): self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) mock_enqueue.assert_not_called() + @patch('extras.jobs.ScriptJob.enqueue') + def test_run_ignores_undeclared_keys(self, mock_enqueue): + # Binding 'data' to the script's form means keys which don't correspond to a declared + # variable are dropped rather than forwarded to run(). This is the contract documented + # under "Running Custom Scripts > Via the API"; pin it so it can't regress silently. + payload = { + 'data': {'site': self.sites[0].pk, 'bogus': 'ignored', 'id': 99}, + 'commit': True, + } + + response = self.client.post(self.url, payload, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_200_OK) + data = mock_enqueue.call_args.kwargs['data'] + self.assertEqual(data['site'], self.sites[0]) + self.assertNotIn('bogus', data) + self.assertNotIn('id', data) + + @patch('extras.jobs.ScriptJob.enqueue') + def test_run_rejects_omitted_required_var(self, mock_enqueue): + # 'site' is required and declares no default, so unlike 'label' it cannot be + # back-filled: omitting it must 400 rather than enqueue a job that fails at runtime. + payload = {'data': {'label': 'hi'}, 'commit': True} + + response = self.client.post(self.url, payload, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + self.assertIn('site', response.data['data']) + mock_enqueue.assert_not_called() + + @patch('extras.jobs.ScriptJob.enqueue') + def test_run_resolves_booleanvar_default_and_explicit_values(self, mock_enqueue): + # BooleanVar renders as a checkbox, and CheckboxInput reads a missing key as False. + # An omitted BooleanVar must therefore pick up its declared default, while an + # explicitly supplied False must not be overwritten by that default. + for case, supplied, expected in ( + ('omitted', {}, True), + ('explicit False', {'flag': False}, False), + ('explicit True', {'flag': True}, True), + ): + with self.subTest(case=case): + mock_enqueue.reset_mock() + payload = {'data': {'site': self.sites[0].pk, **supplied}, 'commit': True} + + response = self.client.post(self.url, payload, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertIs(mock_enqueue.call_args.kwargs['data']['flag'], expected) + class CreatedUpdatedFilterTestCase(APITestCase): diff --git a/netbox/extras/tests/test_views.py b/netbox/extras/tests/test_views.py index 219069dee..e6e353c19 100644 --- a/netbox/extras/tests/test_views.py +++ b/netbox/extras/tests/test_views.py @@ -14,7 +14,7 @@ from core.models import Job, ObjectType from dcim.models import DeviceType, Manufacturer, Site from extras.choices import * from extras.models import * -from extras.scripts import BooleanVar, IntegerVar +from extras.scripts import BooleanVar, IntegerVar, StringVar from extras.scripts import Script as PythonClass from users.models import Group, ObjectPermission, User from utilities.testing import TestCase, ViewTestCases @@ -1279,6 +1279,57 @@ class ScriptModuleCreateViewTestCase(TestCase): self.assertEqual(response.context['return_url'], reverse('extras:script_list')) +class ScriptDefaultBackfillTestCase(TestCase): + """ + The UI and the REST API now share prepare_script_form(), so the UI's back-filling of + declared defaults (previously inline in ScriptView.post()) must keep working after + that logic moved into the helper. + """ + user_permissions = ['extras.view_script', 'extras.run_script'] + + class TestScriptClass(PythonClass): + class Meta: + name = 'Backfill test' + commit_default = False + + label = StringVar(default='hello') + flag = BooleanVar(default=True) + + def run(self, data, commit): + return 'Complete' + + @classmethod + def setUpTestData(cls): + with patch.object(ScriptModule, 'sync_classes'): + module = ScriptModule.objects.create( + file_root=ManagedFileRootPathChoices.SCRIPTS, + file_path='backfill_script.py', + ) + cls.script = Script.objects.create(module=module, name='Backfill test', is_executable=True) + + def setUp(self): + super().setUp() + python_class_patch = patch.object(Script, 'python_class', new=self.TestScriptClass) + python_class_patch.start() + self.addCleanup(python_class_patch.stop) + + @tag('regression') + def test_ui_backfills_declared_defaults(self): + url = reverse('extras:script', kwargs={'pk': self.script.pk}) + + with ( + patch('extras.views.any_workers_for_queue', return_value=True), + patch('extras.jobs.ScriptJob.enqueue') as mock_enqueue, + ): + mock_enqueue.return_value.pk = 1 + response = self.client.post(url, {'_commit': 'true'}) + + self.assertEqual(response.status_code, 302) + data = mock_enqueue.call_args.kwargs['data'] + self.assertEqual(data['label'], 'hello') + self.assertIs(data['flag'], True) + + class ScriptValidationErrorTestCase(TestCase): user_permissions = ['extras.view_script', 'extras.run_script']