test_forms.py 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572
  1. from django.contrib.contenttypes.models import ContentType
  2. from django.core.exceptions import ValidationError
  3. from django.template import Context, Template
  4. from django.test import TestCase
  5. from dcim.constants import InterfaceTypeChoices
  6. from dcim.models import Device, DeviceRole, DeviceType, Interface, Location, Manufacturer, Region, Site, SiteGroup
  7. from ipam.choices import PrefixStatusChoices
  8. from ipam.constants import SERVICE_PORT_MAX, VLANGROUP_SCOPE_TYPES
  9. from ipam.filtersets import ServiceFilterSet, ServiceTemplateFilterSet, VLANFilterSet
  10. from ipam.forms import PrefixForm, VLANGroupBulkEditForm, VLANGroupForm, VLANIDBulkCreateForm
  11. from ipam.forms.bulk_import import IPAddressImportForm, ServiceTemplateImportForm
  12. from ipam.forms.fields import PortMappingField
  13. from ipam.forms.filtersets import ServiceFilterForm, ServiceTemplateFilterForm, VLANFilterForm
  14. from ipam.forms.widgets import PortMappingWidget
  15. from ipam.models import Prefix, VLANGroup
  16. from utilities.forms.widgets import FilterModifierWidget
  17. class PrefixFormTestCase(TestCase):
  18. default_dynamic_params = '[{"fieldName":"scope_object_id","queryParam":"available_at_site"}]'
  19. @classmethod
  20. def setUpTestData(cls):
  21. cls.site = Site.objects.create(name='Site 1', slug='site-1')
  22. def test_vlan_field_sets_dynamic_params_by_default(self):
  23. """data-dynamic-params present when no scope_type selected"""
  24. form = PrefixForm(data={})
  25. assert form.fields['vlan'].widget.attrs['data-dynamic-params'] == self.default_dynamic_params
  26. def test_vlan_field_sets_dynamic_params_for_scope_site(self):
  27. """data-dynamic-params present when scope type is Site and when scope is specifc site"""
  28. form = PrefixForm(data={
  29. 'scope_content_type': ContentType.objects.get_for_model(Site).id,
  30. 'scope_object_id': self.site.pk,
  31. })
  32. assert form.fields['vlan'].widget.attrs['data-dynamic-params'] == self.default_dynamic_params
  33. def test_vlan_field_sets_dynamic_params_for_scope_site_group(self):
  34. """data-dynamic-params present with available_at_site_group when scope type is Site Group"""
  35. site_group = SiteGroup.objects.create(name='Site Group 1', slug='site-group-1')
  36. form = PrefixForm(data={
  37. 'scope_content_type': ContentType.objects.get_for_model(SiteGroup).id,
  38. 'scope_object_id': site_group.pk,
  39. })
  40. expected = '[{"fieldName":"scope_object_id","queryParam":"available_at_site_group"}]'
  41. assert form.fields['vlan'].widget.attrs['data-dynamic-params'] == expected
  42. def test_vlan_field_does_not_set_dynamic_params_for_other_scopes(self):
  43. """data-dynamic-params not present when scope type is not Site or Site Group"""
  44. cases = [
  45. Region(name='Region 1', slug='region-1'),
  46. Location(site=self.site, name='Location 1', slug='location-1'),
  47. ]
  48. for case in cases:
  49. case.save()
  50. form = PrefixForm(data={
  51. 'scope_content_type': ContentType.objects.get_for_model(case._meta.model).id,
  52. 'scope_object_id': case.pk,
  53. })
  54. assert 'data-dynamic-params' not in form.fields['vlan'].widget.attrs
  55. def test_scope_type_change_without_scope(self):
  56. """Changing the scope type without selecting a scope is reported on the scope field."""
  57. prefix = Prefix.objects.create(
  58. prefix='10.0.0.0/24',
  59. scope_type=ContentType.objects.get_for_model(Site),
  60. scope_id=self.site.pk,
  61. )
  62. form = PrefixForm(
  63. data={
  64. 'prefix': '10.0.0.0/24',
  65. 'status': PrefixStatusChoices.STATUS_ACTIVE,
  66. 'scope_content_type': ContentType.objects.get_for_model(Location).pk,
  67. 'scope_object_id': '',
  68. },
  69. instance=prefix,
  70. )
  71. self.assertFalse(form.is_valid())
  72. self.assertIn('scope', form.errors)
  73. class IPAddressImportFormTestCase(TestCase):
  74. """Tests for IPAddressImportForm bulk import behavior."""
  75. @classmethod
  76. def setUpTestData(cls):
  77. site = Site.objects.create(name='Site 1', slug='site-1')
  78. manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
  79. device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Model 1', slug='model-1')
  80. device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
  81. cls.device = Device.objects.create(
  82. name='Device 1',
  83. site=site,
  84. device_type=device_type,
  85. role=device_role,
  86. )
  87. cls.interface = Interface.objects.create(
  88. device=cls.device,
  89. name='eth0',
  90. type=InterfaceTypeChoices.TYPE_1GE_FIXED,
  91. )
  92. def test_import_with_empty_is_primary_column_no_device(self):
  93. """
  94. Regression test for #22561: importing an IP where the is_primary/is_oob columns are
  95. present but empty (and no device/VM specified) should succeed, not raise AttributeError.
  96. """
  97. form = IPAddressImportForm(data={
  98. 'address': '172.16.0.1/20',
  99. 'status': 'active',
  100. 'device': '',
  101. 'virtual_machine': '',
  102. 'interface': '',
  103. 'is_primary': '',
  104. 'is_oob': '',
  105. 'description': 'gateway for group A - Site 01',
  106. })
  107. self.assertTrue(form.is_valid(), form.errors)
  108. ip = form.save()
  109. self.assertEqual(str(ip.address), '172.16.0.1/20')
  110. def test_import_with_false_is_primary_no_device(self):
  111. """
  112. Regression test for #22561: importing an IP with an explicit is_primary=false (and no
  113. device/VM specified) should succeed as a no-op, not raise AttributeError. An explicit
  114. falsy boolean is not caught by clean_is_primary()'s "column absent" check.
  115. """
  116. form = IPAddressImportForm(data={
  117. 'address': '172.16.0.1/20',
  118. 'status': 'active',
  119. 'is_primary': 'false',
  120. 'is_oob': 'false',
  121. 'description': 'no parent specified',
  122. })
  123. self.assertTrue(form.is_valid(), form.errors)
  124. ip = form.save()
  125. self.assertEqual(str(ip.address), '172.16.0.1/20')
  126. def test_primary_not_cleared_by_subsequent_non_primary_row_with_device(self):
  127. """
  128. Guard against re-breaking #21440 while fixing #22561: importing a second IP with
  129. is_primary=false (device specified) must not clear the primary IP set by a previous
  130. row. The save-side parent guard must leave the conservative "only clear if currently
  131. primary" behavior intact.
  132. """
  133. form1 = IPAddressImportForm(data={
  134. 'address': '10.10.10.1/24',
  135. 'status': 'active',
  136. 'device': 'Device 1',
  137. 'interface': 'eth0',
  138. 'is_primary': True,
  139. })
  140. self.assertTrue(form1.is_valid(), form1.errors)
  141. ip1 = form1.save()
  142. self.device.refresh_from_db()
  143. self.assertEqual(self.device.primary_ip4, ip1)
  144. form2 = IPAddressImportForm(data={
  145. 'address': '10.10.10.2/24',
  146. 'status': 'active',
  147. 'device': 'Device 1',
  148. 'interface': 'eth0',
  149. 'is_primary': False,
  150. })
  151. self.assertTrue(form2.is_valid(), form2.errors)
  152. form2.save()
  153. self.device.refresh_from_db()
  154. self.assertEqual(
  155. self.device.primary_ip4, ip1, "primary IP was incorrectly cleared by a row with is_primary=False"
  156. )
  157. def test_oob_import_not_cleared_by_subsequent_non_oob_row(self):
  158. """
  159. Regression test for #21440: importing a second IP with is_oob=False should
  160. not clear the OOB IP set by a previous row with is_oob=True.
  161. """
  162. form1 = IPAddressImportForm(data={
  163. 'address': '10.10.10.1/24',
  164. 'status': 'active',
  165. 'device': 'Device 1',
  166. 'interface': 'eth0',
  167. 'is_oob': True,
  168. })
  169. self.assertTrue(form1.is_valid(), form1.errors)
  170. ip1 = form1.save()
  171. self.device.refresh_from_db()
  172. self.assertEqual(self.device.oob_ip, ip1)
  173. form2 = IPAddressImportForm(data={
  174. 'address': '2001:db8::1/64',
  175. 'status': 'active',
  176. 'device': 'Device 1',
  177. 'interface': 'eth0',
  178. 'is_oob': False,
  179. })
  180. self.assertTrue(form2.is_valid(), form2.errors)
  181. form2.save()
  182. self.device.refresh_from_db()
  183. self.assertEqual(self.device.oob_ip, ip1, "OOB IP was incorrectly cleared by a row with is_oob=False")
  184. class VLANFormTestCase(TestCase):
  185. def test_bulk_create_valid_patterns(self):
  186. """Single values, ranges, and combinations expand to sorted, deduplicated VLAN IDs."""
  187. cases = (
  188. ('100', [100]),
  189. ('5,10,20', [5, 10, 20]),
  190. ('10-20', list(range(10, 21))),
  191. ('1,10-20,300-305', [1, *range(10, 21), *range(300, 306)]),
  192. (' 5 , 7 - 9 ', [5, 7, 8, 9]),
  193. ('5,5,4-6', [4, 5, 6]),
  194. )
  195. for pattern, expected in cases:
  196. with self.subTest(pattern=pattern):
  197. form = VLANIDBulkCreateForm({'pattern': pattern})
  198. self.assertTrue(form.is_valid(), form.errors)
  199. self.assertEqual(form.cleaned_data['pattern'], expected)
  200. def test_bulk_create_invalid_patterns(self):
  201. """Malformed, descending, or out-of-range patterns are rejected with an error on the pattern field."""
  202. cases = ('', 'abc', '10,abc', '20-10', '10-', '5,', '-5', '0', '4095')
  203. for pattern in cases:
  204. with self.subTest(pattern=pattern):
  205. form = VLANIDBulkCreateForm({'pattern': pattern})
  206. self.assertFalse(form.is_valid())
  207. self.assertIn('pattern', form.errors)
  208. def test_vlan_filter_form_exposes_related_to_site(self):
  209. """The Location fieldset offers related to site under the same name as the filter."""
  210. form = VLANFilterForm()
  211. fieldset_items = [item for fieldset in VLANFilterForm.fieldsets for item in fieldset.items]
  212. self.assertIn('related_to_site', fieldset_items)
  213. self.assertIn('related_to_site', form.fields)
  214. self.assertFalse(form.fields['related_to_site'].required)
  215. # The form field's name must match the filter's, or the rendered query does nothing
  216. self.assertIn('related_to_site', VLANFilterSet.get_filters())
  217. template = Template('{% load form_helpers %}{% render_form form %}')
  218. html = template.render(Context({'form': VLANFilterForm()}))
  219. self.assertIn('id_related_to_site', html)
  220. def test_vlan_filter_form_offers_related_to_site_operators(self):
  221. """The declared negation filter is what puts an is/is not operator on the field."""
  222. widget = VLANFilterForm().fields['related_to_site'].widget
  223. self.assertIsInstance(widget, FilterModifierWidget)
  224. self.assertEqual([lookup for lookup, _label in widget.lookups], ['exact', 'n'])
  225. self.assertIn('related_to_site__n', VLANFilterSet.get_filters())
  226. class PortMappingFieldTestCase(TestCase):
  227. def test_ports_and_ranges_expand(self):
  228. """A protocol row's comma/range port string expands into individual protocol/port mappings."""
  229. field = PortMappingField()
  230. value = field.clean('[{"protocol": "tcp", "ports": "80,443,8000-8002"}]')
  231. self.assertEqual(value, ['tcp/80', 'tcp/443', 'tcp/8000', 'tcp/8001', 'tcp/8002'])
  232. def test_out_of_range_rejected_without_expanding(self):
  233. """
  234. An out-of-bounds range is rejected before it is expanded, so a pathological range cannot
  235. exhaust memory (regression guard for the unbounded parse_numeric_range expansion).
  236. """
  237. field = PortMappingField()
  238. with self.assertRaises(ValidationError):
  239. field.clean('[{"protocol": "tcp", "ports": "1-9999999999"}]')
  240. with self.assertRaises(ValidationError):
  241. field.clean(f'[{{"protocol": "tcp", "ports": "1-{SERVICE_PORT_MAX + 1}"}}]')
  242. def test_malformed_payload_rejected(self):
  243. """
  244. The hidden input is ordinary POST data, so a hand-crafted payload need not be the list of
  245. {protocol, ports} objects the widget's JS produces. Anything else must raise a ValidationError
  246. (a 400) rather than an unhandled AttributeError/TypeError (a 500).
  247. """
  248. field = PortMappingField()
  249. for value in (
  250. '5', # a JSON scalar
  251. '"tcp/80"', # a JSON string
  252. '{"protocol": "tcp", "ports": "80"}', # an object rather than a list of them
  253. '[5]', # a list of non-objects
  254. '[[1, 2]]', # a list of lists
  255. '[null]', # a null row
  256. '[{"protocol": "tcp", "ports": {"a": 1}}]', # ports of the wrong type
  257. '[{"protocol": ["tcp"], "ports": "80"}]', # protocol of the wrong type
  258. ):
  259. with self.subTest(value=value), self.assertRaises(ValidationError):
  260. field.clean(value)
  261. def test_widget_tolerates_malformed_value(self):
  262. """
  263. Re-rendering an invalid bound form hands the widget back the raw POST value, which may be valid
  264. JSON of the wrong shape. It must fall back to a blank row rather than raise while rendering.
  265. """
  266. widget = PortMappingWidget()
  267. for value in ('5', '"tcp/80"', '{"a": 1}', '[5]', '[[1, 2]]', 'not json at all'):
  268. with self.subTest(value=value):
  269. context = widget.get_context('port_mappings', value, {})
  270. self.assertEqual(context['widget']['rows'], [{'protocol': '', 'ports': ''}])
  271. def test_ports_as_list_requires_protocol(self):
  272. """
  273. A programmatically-set list of ports still gets the blank-protocol check, rather than emitting a
  274. '/80' token that surfaces as a blank "Invalid protocol:" message.
  275. """
  276. field = PortMappingField()
  277. self.assertEqual(field.clean('[{"protocol": "tcp", "ports": [80, 443]}]'), ['tcp/80', 'tcp/443'])
  278. with self.assertRaises(ValidationError) as ctx:
  279. field.clean('[{"protocol": "", "ports": [80]}]')
  280. self.assertTrue(any('protocol' in msg.lower() for msg in ctx.exception.messages))
  281. self.assertFalse(any(msg.strip().endswith('Invalid protocol:') for msg in ctx.exception.messages))
  282. def test_protocol_without_ports_reports_clear_error(self):
  283. """A protocol chosen with no ports reports the 'protocol/port' error, not 'Range \"\" is invalid'."""
  284. field = PortMappingField()
  285. with self.assertRaises(ValidationError) as ctx:
  286. field.clean('[{"protocol": "tcp", "ports": ""}]')
  287. self.assertTrue(any('tcp/' in msg for msg in ctx.exception.messages))
  288. def test_ports_without_protocol_reports_clear_error(self):
  289. """Ports entered with no protocol (e.g. the blank initial row) report a clear protocol error."""
  290. field = PortMappingField()
  291. with self.assertRaises(ValidationError) as ctx:
  292. field.clean('[{"protocol": "", "ports": "80"}]')
  293. self.assertTrue(any('protocol' in msg.lower() for msg in ctx.exception.messages))
  294. # Specifically not the confusing blank "Invalid protocol:" message.
  295. self.assertFalse(any(msg.strip().endswith('Invalid protocol:') for msg in ctx.exception.messages))
  296. def test_row_errors_identify_the_row(self):
  297. """
  298. A per-row error names the offending row, since the widget renders one row per protocol and an
  299. unqualified message gives no clue which of several rows to fix.
  300. """
  301. field = PortMappingField()
  302. # A row with ports but no protocol
  303. rows = '[{"protocol": "tcp", "ports": "80"}, {"protocol": "", "ports": "53"}]'
  304. with self.assertRaises(ValidationError) as ctx:
  305. field.clean(rows)
  306. self.assertTrue(
  307. any(msg.startswith('Row 2:') for msg in ctx.exception.messages), ctx.exception.messages
  308. )
  309. # A row whose port range is invalid
  310. rows = '[{"protocol": "tcp", "ports": "80"}, {"protocol": "udp", "ports": "9000-53"}]'
  311. with self.assertRaises(ValidationError) as ctx:
  312. field.clean(rows)
  313. self.assertTrue(
  314. any(msg.startswith('Row 2:') for msg in ctx.exception.messages), ctx.exception.messages
  315. )
  316. def test_whole_field_errors_are_not_row_attributed(self):
  317. """
  318. Errors raised by validate_port_mappings() are left unqualified: each already quotes the offending
  319. mapping, and a duplicate spans two rows so attributing it to one would be misleading.
  320. """
  321. field = PortMappingField()
  322. with self.assertRaises(ValidationError) as ctx:
  323. field.clean('[{"protocol": "tcp", "ports": "80"}, {"protocol": "udp", "ports": ""}]')
  324. self.assertFalse(any(msg.startswith('Row ') for msg in ctx.exception.messages))
  325. self.assertTrue(any('udp/' in msg for msg in ctx.exception.messages), ctx.exception.messages)
  326. def test_reversed_range_rejected(self):
  327. """A reversed range must raise rather than silently expanding to an empty (dropped) list."""
  328. field = PortMappingField()
  329. with self.assertRaises(ValidationError):
  330. field.clean('[{"protocol": "tcp", "ports": "9000-53"}]')
  331. def test_invalid_subrange_alongside_valid_rejected(self):
  332. """
  333. An invalid range combined with a valid one must raise rather than silently dropping the
  334. invalid sub-range (the valid range would otherwise mask the empty expansion).
  335. """
  336. field = PortMappingField()
  337. with self.assertRaises(ValidationError):
  338. field.clean('[{"protocol": "tcp", "ports": "80,9000-53"}]')
  339. with self.assertRaises(ValidationError):
  340. field.clean('[{"protocol": "tcp", "ports": "80,70000-80"}]')
  341. def test_normalizes_leading_zero_ports(self):
  342. """Leading-zero ports are normalized so they remain matchable by the port filter."""
  343. field = PortMappingField()
  344. self.assertEqual(field.clean('[{"protocol": "tcp", "ports": "080"}]'), ['tcp/80'])
  345. def test_prepare_value_grouped_json_passthrough(self):
  346. """An already-grouped JSON string (bound-form re-render) is passed to the widget unchanged."""
  347. field = PortMappingField()
  348. self.assertEqual(
  349. field.prepare_value('[{"protocol": "tcp", "ports": "80"}]'),
  350. '[{"protocol": "tcp", "ports": "80"}]',
  351. )
  352. def test_prepare_value_flat_list_grouped(self):
  353. """A flat protocol/port list (e.g. a multi-mapping clone) is grouped into widget rows."""
  354. field = PortMappingField()
  355. self.assertEqual(
  356. field.prepare_value(['tcp/80', 'tcp/443']),
  357. '[{"protocol": "tcp", "ports": "80,443"}]',
  358. )
  359. def test_prepare_value_bare_string_grouped(self):
  360. """
  361. Cloning a single-mapping object collapses port_mappings to a bare 'protocol/port' string
  362. (normalize_querydict single-value collapse); it must group into a row, not blank the widget.
  363. Regression guard for the single-protocol clone losing its port mapping.
  364. """
  365. field = PortMappingField()
  366. self.assertEqual(
  367. field.prepare_value('tcp/80'),
  368. '[{"protocol": "tcp", "ports": "80"}]',
  369. )
  370. class ServiceTemplateImportFormTestCase(TestCase):
  371. def test_valid_port_mappings_parsed_and_normalized(self):
  372. form = ServiceTemplateImportForm(data={'name': 'X', 'port_mappings': 'tcp/080,tcp/443,udp/53'})
  373. self.assertTrue(form.is_valid(), form.errors)
  374. self.assertEqual(form.cleaned_data['port_mappings'], ['tcp/80', 'tcp/443', 'udp/53'])
  375. def test_protocol_lowercased(self):
  376. """Protocols may be given in any case; the input is lowercased before validation."""
  377. form = ServiceTemplateImportForm(data={'name': 'X', 'port_mappings': 'TCP/80,UDP/53'})
  378. self.assertTrue(form.is_valid(), form.errors)
  379. self.assertEqual(form.cleaned_data['port_mappings'], ['tcp/80', 'udp/53'])
  380. def test_invalid_protocol_rejected(self):
  381. form = ServiceTemplateImportForm(data={'name': 'X', 'port_mappings': 'tcp/80,bogus/53'})
  382. self.assertFalse(form.is_valid())
  383. self.assertIn('port_mappings', form.errors)
  384. def test_duplicate_mapping_rejected(self):
  385. form = ServiceTemplateImportForm(data={'name': 'X', 'port_mappings': 'tcp/80,tcp/080'})
  386. self.assertFalse(form.is_valid())
  387. self.assertIn('port_mappings', form.errors)
  388. def test_port_range_expanded(self):
  389. form = ServiceTemplateImportForm(data={'name': 'X', 'port_mappings': 'tcp/8000-8002,udp/53'})
  390. self.assertTrue(form.is_valid(), form.errors)
  391. self.assertEqual(
  392. form.cleaned_data['port_mappings'],
  393. ['tcp/8000', 'tcp/8001', 'tcp/8002', 'udp/53'],
  394. )
  395. def test_reversed_port_range_rejected(self):
  396. form = ServiceTemplateImportForm(data={'name': 'X', 'port_mappings': 'tcp/8010-8000'})
  397. self.assertFalse(form.is_valid())
  398. self.assertIn('port_mappings', form.errors)
  399. def test_empty_port_rejected(self):
  400. form = ServiceTemplateImportForm(data={'name': 'X', 'port_mappings': 'tcp/'})
  401. self.assertFalse(form.is_valid())
  402. self.assertIn('port_mappings', form.errors)
  403. def test_blank_protocol_rejected(self):
  404. form = ServiceTemplateImportForm(data={'name': 'X', 'port_mappings': '/80'})
  405. self.assertFalse(form.is_valid())
  406. self.assertIn('port_mappings', form.errors)
  407. class ServiceFilterFormTestCase(TestCase):
  408. """
  409. `port_mappings` matches a complete protocol/port pair, which the correlated `protocol`/`port` pair
  410. cannot express on its own, so it must be reachable from the UI and not only from the API.
  411. """
  412. forms_and_filtersets = (
  413. (ServiceTemplateFilterForm, ServiceTemplateFilterSet),
  414. (ServiceFilterForm, ServiceFilterSet),
  415. )
  416. def test_port_mappings_field_present(self):
  417. # ServiceFilterForm inherits the field from ServiceTemplateFilterForm but redeclares fieldsets,
  418. # so both must be checked.
  419. for form_class, filterset_class in self.forms_and_filtersets:
  420. with self.subTest(form=form_class.__name__):
  421. fieldset_items = [item for fieldset in form_class.fieldsets for item in fieldset.items]
  422. self.assertIn('port_mappings', fieldset_items)
  423. self.assertIn('port_mappings', form_class().fields)
  424. # The form field's name must match the filter's, or the rendered query does nothing
  425. self.assertIn('port_mappings', filterset_class.get_filters())
  426. # Render the form to confirm the fieldset entry resolves to a real field
  427. template = Template('{% load form_helpers %}{% render_form form %}')
  428. html = template.render(Context({'form': form_class()}))
  429. self.assertIn('id_port_mappings', html)
  430. def test_port_mappings_value_cleans(self):
  431. for form_class, _ in self.forms_and_filtersets:
  432. with self.subTest(form=form_class.__name__):
  433. form = form_class(data={'port_mappings': 'tcp/80'})
  434. self.assertTrue(form.is_valid(), form.errors)
  435. self.assertEqual(form.cleaned_data['port_mappings'], 'tcp/80')
  436. class VLANGroupFormTestCase(TestCase):
  437. @classmethod
  438. def setUpTestData(cls):
  439. cls.site = Site.objects.create(name='Site 1', slug='site-1')
  440. cls.site_type = ContentType.objects.get_for_model(Site)
  441. cls.location_type = ContentType.objects.get_for_model(Location)
  442. cls.vlan_group = VLANGroup.objects.create(
  443. name='VLAN Group 1',
  444. slug='vlan-group-1',
  445. scope=cls.site,
  446. )
  447. def test_scope_can_be_cleared(self):
  448. """Clearing scope type and scope on an existing group nulls the assignment."""
  449. form = VLANGroupForm(
  450. data=self.get_form_data(scope_content_type='', scope_object_id=''),
  451. instance=VLANGroup.objects.get(pk=self.vlan_group.pk),
  452. )
  453. self.assertTrue(form.is_valid(), form.errors)
  454. vlan_group = form.save()
  455. vlan_group.refresh_from_db()
  456. self.assertIsNone(vlan_group.scope_type_id)
  457. self.assertIsNone(vlan_group.scope_id)
  458. def test_scope_required_with_scope_type(self):
  459. """A scope type without a scope is reported on the scope field."""
  460. forms = {
  461. 'existing group': VLANGroupForm(
  462. data=self.get_form_data(scope_object_id=''),
  463. instance=VLANGroup.objects.get(pk=self.vlan_group.pk),
  464. ),
  465. 'new group': VLANGroupForm(
  466. data=self.get_form_data(name='VLAN Group 2', slug='vlan-group-2', scope_object_id=''),
  467. ),
  468. 'retyped group': VLANGroupForm(
  469. data=self.get_form_data(scope_content_type=self.location_type.pk, scope_object_id=''),
  470. instance=VLANGroup.objects.get(pk=self.vlan_group.pk),
  471. ),
  472. }
  473. for case, form in forms.items():
  474. with self.subTest(case=case):
  475. self.assertFalse(form.is_valid())
  476. self.assertIn('scope', form.errors)
  477. def test_scope_initial_retained_for_new_group(self):
  478. """A prepopulated scope survives instantiation of an unsaved group."""
  479. form = VLANGroupForm(initial={'scope': self.site})
  480. self.assertEqual(form.initial['scope'], self.site)
  481. def test_scope_type_choices(self):
  482. """Both VLAN group forms offer every VLAN group scope type."""
  483. for form_class in (VLANGroupForm, VLANGroupBulkEditForm):
  484. with self.subTest(form=form_class.__name__):
  485. form = form_class()
  486. models = set(
  487. form.fields['scope'].content_type_queryset.values_list('model', flat=True)
  488. )
  489. self.assertEqual(models, set(VLANGROUP_SCOPE_TYPES))
  490. def get_form_data(self, **overrides):
  491. return {
  492. 'name': self.vlan_group.name,
  493. 'slug': self.vlan_group.slug,
  494. 'vid_ranges': '1-4094',
  495. 'scope_content_type': self.site_type.pk,
  496. 'scope_object_id': self.site.pk,
  497. **overrides,
  498. }