2
0

test_api.py 86 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050
  1. import json
  2. import logging
  3. from django.test import tag
  4. from django.urls import reverse
  5. from netaddr import IPNetwork
  6. from rest_framework import status
  7. from dcim.models import Device, DeviceRole, DeviceType, Interface, Manufacturer, Site
  8. from ipam.choices import *
  9. from ipam.models import *
  10. from tenancy.models import Tenant
  11. from utilities.data import string_to_ranges
  12. from utilities.testing import APITestCase, APIViewTestCases, create_test_device, disable_logging
  13. class AppTestCase(APITestCase):
  14. def test_root(self):
  15. url = reverse('ipam-api:api-root')
  16. response = self.client.get('{}?format=api'.format(url), **self.header)
  17. self.assertEqual(response.status_code, 200)
  18. class ASNRangeTestCase(APIViewTestCases.APIViewTestCase):
  19. model = ASNRange
  20. brief_fields = ['description', 'display', 'id', 'name', 'url']
  21. bulk_update_data = {
  22. 'description': 'New description',
  23. }
  24. @classmethod
  25. def setUpTestData(cls):
  26. rirs = (
  27. RIR(name='RIR 1', slug='rir-1', is_private=True),
  28. RIR(name='RIR 2', slug='rir-2', is_private=True),
  29. )
  30. RIR.objects.bulk_create(rirs)
  31. tenants = (
  32. Tenant(name='Tenant 1', slug='tenant-1'),
  33. Tenant(name='Tenant 2', slug='tenant-2'),
  34. )
  35. Tenant.objects.bulk_create(tenants)
  36. asn_ranges = (
  37. ASNRange(name='ASN Range 1', slug='asn-range-1', rir=rirs[0], tenant=tenants[0], start=100, end=199),
  38. ASNRange(name='ASN Range 2', slug='asn-range-2', rir=rirs[0], tenant=tenants[0], start=200, end=299),
  39. ASNRange(name='ASN Range 3', slug='asn-range-3', rir=rirs[0], tenant=tenants[0], start=300, end=399),
  40. )
  41. ASNRange.objects.bulk_create(asn_ranges)
  42. cls.create_data = [
  43. {
  44. 'name': 'ASN Range 4',
  45. 'slug': 'asn-range-4',
  46. 'rir': rirs[1].pk,
  47. 'start': 400,
  48. 'end': 499,
  49. 'tenant': tenants[1].pk,
  50. },
  51. {
  52. 'name': 'ASN Range 5',
  53. 'slug': 'asn-range-5',
  54. 'rir': rirs[1].pk,
  55. 'start': 500,
  56. 'end': 599,
  57. 'tenant': tenants[1].pk,
  58. },
  59. {
  60. 'name': 'ASN Range 6',
  61. 'slug': 'asn-range-6',
  62. 'rir': rirs[1].pk,
  63. 'start': 600,
  64. 'end': 699,
  65. 'tenant': tenants[1].pk,
  66. },
  67. ]
  68. def test_list_available_asns(self):
  69. """
  70. Test retrieval of all available ASNs within a parent range.
  71. """
  72. rir = RIR.objects.first()
  73. asnrange = ASNRange.objects.create(name='Range 1', slug='range-1', rir=rir, start=101, end=110)
  74. url = reverse('ipam-api:asnrange-available-asns', kwargs={'pk': asnrange.pk})
  75. self.add_permissions('ipam.view_asnrange', 'ipam.view_asn')
  76. response = self.client.get(url, **self.header)
  77. self.assertHttpStatus(response, status.HTTP_200_OK)
  78. self.assertEqual(len(response.data), 10)
  79. def test_create_single_available_asn(self):
  80. """
  81. Test creation of the first available ASN within a range.
  82. """
  83. rir = RIR.objects.first()
  84. asnrange = ASNRange.objects.create(name='Range 1', slug='range-1', rir=rir, start=101, end=110)
  85. url = reverse('ipam-api:asnrange-available-asns', kwargs={'pk': asnrange.pk})
  86. self.add_permissions('ipam.view_asnrange', 'ipam.add_asn')
  87. data = {
  88. 'description': 'New ASN'
  89. }
  90. response = self.client.post(url, data, format='json', **self.header)
  91. self.assertHttpStatus(response, status.HTTP_201_CREATED)
  92. self.assertEqual(response.data['rir']['id'], asnrange.rir.pk)
  93. self.assertEqual(response.data['description'], data['description'])
  94. def test_create_multiple_available_asns(self):
  95. """
  96. Test the creation of several available ASNs within a parent range.
  97. """
  98. rir = RIR.objects.first()
  99. asnrange = ASNRange.objects.create(name='Range 1', slug='range-1', rir=rir, start=101, end=110)
  100. url = reverse('ipam-api:asnrange-available-asns', kwargs={'pk': asnrange.pk})
  101. self.add_permissions('ipam.view_asnrange', 'ipam.add_asn')
  102. # Try to create eleven ASNs (only ten are available)
  103. data = [
  104. {'description': f'New ASN {i}'}
  105. for i in range(1, 12)
  106. ]
  107. assert len(data) == 11
  108. response = self.client.post(url, data, format='json', **self.header)
  109. self.assertHttpStatus(response, status.HTTP_409_CONFLICT)
  110. self.assertIn('detail', response.data)
  111. # Create all ten available ASNs in a single request
  112. data.pop()
  113. assert len(data) == 10
  114. response = self.client.post(url, data, format='json', **self.header)
  115. self.assertHttpStatus(response, status.HTTP_201_CREATED)
  116. self.assertEqual(len(response.data), 10)
  117. class ASNTestCase(APIViewTestCases.APIViewTestCase):
  118. model = ASN
  119. brief_fields = ['asn', 'description', 'display', 'id', 'url']
  120. bulk_update_data = {
  121. 'description': 'New description',
  122. }
  123. @classmethod
  124. def setUpTestData(cls):
  125. rirs = (
  126. RIR(name='RIR 1', slug='rir-1', is_private=True),
  127. RIR(name='RIR 2', slug='rir-2', is_private=True),
  128. )
  129. RIR.objects.bulk_create(rirs)
  130. roles = (
  131. Role(name='Role 1', slug='role-1'),
  132. Role(name='Role 2', slug='role-2'),
  133. )
  134. Role.objects.bulk_create(roles)
  135. sites = (
  136. Site(name='Site 1', slug='site-1'),
  137. Site(name='Site 2', slug='site-2')
  138. )
  139. Site.objects.bulk_create(sites)
  140. tenants = (
  141. Tenant(name='Tenant 1', slug='tenant-1'),
  142. Tenant(name='Tenant 2', slug='tenant-2'),
  143. )
  144. Tenant.objects.bulk_create(tenants)
  145. asns = (
  146. ASN(asn=65000, rir=rirs[0], role=roles[0], tenant=tenants[0]),
  147. ASN(asn=65001, rir=rirs[0], role=roles[0], tenant=tenants[1]),
  148. ASN(asn=4200000000, rir=rirs[1], role=roles[1], tenant=tenants[0]),
  149. ASN(asn=4200000001, rir=rirs[1], role=roles[1], tenant=tenants[1]),
  150. )
  151. ASN.objects.bulk_create(asns)
  152. asns[0].sites.set([sites[0]])
  153. asns[1].sites.set([sites[1]])
  154. asns[2].sites.set([sites[0]])
  155. asns[3].sites.set([sites[1]])
  156. cls.create_data = [
  157. {
  158. 'asn': 64512,
  159. 'rir': rirs[0].pk,
  160. 'role': roles[0].pk,
  161. },
  162. {
  163. 'asn': 65002,
  164. 'rir': rirs[0].pk,
  165. 'role': roles[1].pk,
  166. },
  167. {
  168. 'asn': 4200000002,
  169. 'rir': rirs[1].pk,
  170. },
  171. ]
  172. class VRFTestCase(APIViewTestCases.APIViewTestCase):
  173. model = VRF
  174. brief_fields = ['description', 'display', 'id', 'name', 'prefix_count', 'rd', 'url']
  175. create_data = [
  176. {
  177. 'name': 'VRF 4',
  178. 'rd': '65000:4',
  179. },
  180. {
  181. 'name': 'VRF 5',
  182. 'rd': '65000:5',
  183. },
  184. {
  185. 'name': 'VRF 6',
  186. 'rd': '65000:6',
  187. },
  188. ]
  189. bulk_update_data = {
  190. 'description': 'New description',
  191. }
  192. @classmethod
  193. def setUpTestData(cls):
  194. tenant = Tenant.objects.create(name='Tenant 1', slug='tenant-1')
  195. route_targets = (
  196. RouteTarget(name='65000:1001', tenant=tenant),
  197. RouteTarget(name='65000:1002', tenant=tenant),
  198. RouteTarget(name='65000:1003', tenant=tenant),
  199. )
  200. RouteTarget.objects.bulk_create(route_targets)
  201. vrfs = (
  202. VRF(name='VRF 1', rd='65000:1'),
  203. VRF(name='VRF 2', rd='65000:2'),
  204. VRF(name='VRF 3'), # No RD
  205. )
  206. VRF.objects.bulk_create(vrfs)
  207. # Assigned so the query count baseline covers the non-nested route target expansion.
  208. for vrf in vrfs:
  209. vrf.import_targets.set(route_targets)
  210. vrf.export_targets.set(route_targets)
  211. class RouteTargetTestCase(APIViewTestCases.APIViewTestCase):
  212. model = RouteTarget
  213. brief_fields = ['description', 'display', 'id', 'name', 'url']
  214. create_data = [
  215. {
  216. 'name': '65000:1004',
  217. },
  218. {
  219. 'name': '65000:1005',
  220. },
  221. {
  222. 'name': '65000:1006',
  223. },
  224. ]
  225. bulk_update_data = {
  226. 'description': 'New description',
  227. }
  228. @classmethod
  229. def setUpTestData(cls):
  230. route_targets = (
  231. RouteTarget(name='65000:1001'),
  232. RouteTarget(name='65000:1002'),
  233. RouteTarget(name='65000:1003'),
  234. )
  235. RouteTarget.objects.bulk_create(route_targets)
  236. class RIRTestCase(APIViewTestCases.APIViewTestCase):
  237. model = RIR
  238. brief_fields = ['aggregate_count', 'description', 'display', 'id', 'name', 'slug', 'url']
  239. create_data = [
  240. {
  241. 'name': 'RIR 4',
  242. 'slug': 'rir-4',
  243. },
  244. {
  245. 'name': 'RIR 5',
  246. 'slug': 'rir-5',
  247. },
  248. {
  249. 'name': 'RIR 6',
  250. 'slug': 'rir-6',
  251. },
  252. ]
  253. bulk_update_data = {
  254. 'description': 'New description',
  255. }
  256. @classmethod
  257. def setUpTestData(cls):
  258. rirs = (
  259. RIR(name='RIR 1', slug='rir-1'),
  260. RIR(name='RIR 2', slug='rir-2'),
  261. RIR(name='RIR 3', slug='rir-3'),
  262. )
  263. RIR.objects.bulk_create(rirs)
  264. class AggregateTestCase(APIViewTestCases.APIViewTestCase):
  265. model = Aggregate
  266. brief_fields = ['description', 'display', 'family', 'id', 'prefix', 'url']
  267. bulk_update_data = {
  268. 'description': 'New description',
  269. }
  270. @classmethod
  271. def setUpTestData(cls):
  272. rirs = (
  273. RIR(name='RIR 1', slug='rir-1'),
  274. RIR(name='RIR 2', slug='rir-2'),
  275. )
  276. RIR.objects.bulk_create(rirs)
  277. aggregates = (
  278. Aggregate(prefix=IPNetwork('10.0.0.0/8'), rir=rirs[0]),
  279. Aggregate(prefix=IPNetwork('172.16.0.0/12'), rir=rirs[0]),
  280. Aggregate(prefix=IPNetwork('192.168.0.0/16'), rir=rirs[0]),
  281. )
  282. Aggregate.objects.bulk_create(aggregates)
  283. cls.create_data = [
  284. {
  285. 'prefix': '100.0.0.0/8',
  286. 'rir': rirs[1].pk,
  287. },
  288. {
  289. 'prefix': '101.0.0.0/8',
  290. 'rir': rirs[1].pk,
  291. },
  292. {
  293. 'prefix': '102.0.0.0/8',
  294. 'rir': rirs[1].pk,
  295. },
  296. ]
  297. @tag('regression')
  298. def test_graphql_aggregate_prefix_exact(self):
  299. """
  300. Test case to verify aggregate prefix equality via field lookup in GraphQL API.
  301. """
  302. self.add_permissions('ipam.view_aggregate', 'ipam.view_rir')
  303. rir = RIR.objects.create(name='RFC6598', slug='rfc6598', is_private=True)
  304. aggregate1 = Aggregate.objects.create(prefix='100.64.0.0/10', rir=rir)
  305. Aggregate.objects.create(prefix='203.0.113.0/24', rir=rir)
  306. url = reverse('graphql')
  307. query = """{
  308. aggregate_list(filters: { prefix: { exact: "100.64.0.0/10" } }) { prefix }
  309. }"""
  310. response = self.client.post(url, data={'query': query}, format='json', **self.header)
  311. self.assertHttpStatus(response, status.HTTP_200_OK)
  312. data = response.json()
  313. self.assertNotIn('errors', data)
  314. prefixes = {row['prefix'] for row in data['data']['aggregate_list']}
  315. self.assertIn(str(aggregate1.prefix), prefixes)
  316. @tag('regression')
  317. def test_graphql_aggregate_contains_skips_invalid(self):
  318. """
  319. Test the GraphQL API Aggregate `contains` filter skips invalid input.
  320. """
  321. self.add_permissions('ipam.view_aggregate', 'ipam.view_rir')
  322. rir = RIR.objects.create(name='RIR 3', slug='rir-3', is_private=False)
  323. aggregate1 = Aggregate.objects.create(prefix='100.64.0.0/10', rir=rir)
  324. Aggregate.objects.create(prefix='203.0.113.0/24', rir=rir)
  325. url = reverse('graphql')
  326. query = """{
  327. aggregate_list(filters: { contains: ["100.64.16.0/24", "not-a-cidr", ""] }) { prefix }
  328. }"""
  329. response = self.client.post(url, data={'query': query}, format='json', **self.header)
  330. self.assertHttpStatus(response, status.HTTP_200_OK)
  331. data = response.json()
  332. self.assertNotIn('errors', data)
  333. prefixes = {row['prefix'] for row in data['data']['aggregate_list']}
  334. self.assertIn(str(aggregate1.prefix), prefixes)
  335. # No exception occurred; invalid entries were ignored
  336. class RoleTestCase(APIViewTestCases.APIViewTestCase):
  337. model = Role
  338. brief_fields = ['asn_count', 'description', 'display', 'id', 'name', 'prefix_count', 'slug', 'url', 'vlan_count']
  339. create_data = [
  340. {
  341. 'name': 'Role 4',
  342. 'slug': 'role-4',
  343. },
  344. {
  345. 'name': 'Role 5',
  346. 'slug': 'role-5',
  347. },
  348. {
  349. 'name': 'Role 6',
  350. 'slug': 'role-6',
  351. },
  352. ]
  353. bulk_update_data = {
  354. 'description': 'New description',
  355. }
  356. @classmethod
  357. def setUpTestData(cls):
  358. roles = (
  359. Role(name='Role 1', slug='role-1'),
  360. Role(name='Role 2', slug='role-2'),
  361. Role(name='Role 3', slug='role-3'),
  362. )
  363. Role.objects.bulk_create(roles)
  364. rirs = (
  365. RIR(name='RIR 1', slug='rir-1', is_private=True),
  366. )
  367. RIR.objects.bulk_create(rirs)
  368. asns = (
  369. ASN(asn=65000, rir=rirs[0], role=roles[0]),
  370. ASN(asn=65001, rir=rirs[0], role=roles[0]),
  371. )
  372. ASN.objects.bulk_create(asns)
  373. class PrefixTestCase(APIViewTestCases.APIViewTestCase):
  374. model = Prefix
  375. brief_fields = ['_depth', 'description', 'display', 'family', 'id', 'prefix', 'url']
  376. create_data = [
  377. {
  378. 'prefix': '192.168.4.0/24',
  379. },
  380. {
  381. 'prefix': '192.168.5.0/24',
  382. },
  383. {
  384. 'prefix': '192.168.6.0/24',
  385. },
  386. ]
  387. bulk_update_data = {
  388. 'description': 'New description',
  389. }
  390. @classmethod
  391. def setUpTestData(cls):
  392. prefixes = (
  393. Prefix(prefix=IPNetwork('192.168.1.0/24')),
  394. Prefix(prefix=IPNetwork('192.168.2.0/24')),
  395. Prefix(prefix=IPNetwork('192.168.3.0/24')),
  396. )
  397. Prefix.objects.bulk_create(prefixes)
  398. @tag('regression')
  399. def test_create_with_invalid_prefix(self):
  400. """
  401. POST of a malformed prefix value returns a 400 validation error.
  402. """
  403. self.add_permissions('ipam.add_prefix')
  404. url = reverse('ipam-api:prefix-list')
  405. response = self.client.post(url, {'prefix': 'invalid'}, format='json', **self.header)
  406. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  407. self.assertEqual(response.data['prefix'][0], 'Invalid IP prefix format: invalid')
  408. @tag('regression')
  409. def test_clean_validates_scope(self):
  410. prefix = Prefix.objects.first()
  411. site = Site.objects.create(name='Test Site', slug='test-site')
  412. data = {'scope_type': 'dcim.site', 'scope_id': site.id}
  413. url = reverse('ipam-api:prefix-detail', kwargs={'pk': prefix.pk})
  414. self.add_permissions('ipam.change_prefix')
  415. response = self.client.patch(url, data, format='json', **self.header)
  416. self.assertHttpStatus(response, status.HTTP_200_OK)
  417. def test_list_available_prefixes(self):
  418. """
  419. Test retrieval of all available prefixes within a parent prefix.
  420. """
  421. vrf = VRF.objects.create(name='VRF 1')
  422. prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/24'), vrf=vrf)
  423. Prefix.objects.create(prefix=IPNetwork('192.0.2.64/26'), vrf=vrf)
  424. Prefix.objects.create(prefix=IPNetwork('192.0.2.192/27'), vrf=vrf)
  425. url = reverse('ipam-api:prefix-available-prefixes', kwargs={'pk': prefix.pk})
  426. self.add_permissions('ipam.view_prefix')
  427. # Retrieve all available IPs
  428. response = self.client.get(url, **self.header)
  429. available_prefixes = ['192.0.2.0/26', '192.0.2.128/26', '192.0.2.224/27']
  430. for i, p in enumerate(response.data):
  431. self.assertEqual(p['prefix'], available_prefixes[i])
  432. def test_create_single_available_prefix(self):
  433. """
  434. Test retrieval of the first available prefix within a parent prefix.
  435. """
  436. vrf = VRF.objects.create(name='VRF 1')
  437. prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/28'), vrf=vrf, is_pool=True)
  438. url = reverse('ipam-api:prefix-available-prefixes', kwargs={'pk': prefix.pk})
  439. self.add_permissions('ipam.view_prefix', 'ipam.add_prefix')
  440. # Create four available prefixes with individual requests
  441. prefixes_to_be_created = [
  442. '192.0.2.0/30',
  443. '192.0.2.4/30',
  444. '192.0.2.8/30',
  445. '192.0.2.12/30',
  446. ]
  447. for i in range(4):
  448. data = {
  449. 'prefix_length': 30,
  450. 'description': 'Test Prefix {}'.format(i + 1)
  451. }
  452. response = self.client.post(url, data, format='json', **self.header)
  453. self.assertHttpStatus(response, status.HTTP_201_CREATED)
  454. self.assertEqual(response.data['prefix'], prefixes_to_be_created[i])
  455. self.assertEqual(response.data['vrf']['id'], vrf.pk)
  456. self.assertEqual(response.data['description'], data['description'])
  457. # Try to create one more prefix
  458. response = self.client.post(url, {'prefix_length': 30}, format='json', **self.header)
  459. self.assertHttpStatus(response, status.HTTP_409_CONFLICT)
  460. self.assertIn('detail', response.data)
  461. # Try to create invalid prefix type
  462. response = self.client.post(url, {'prefix_length': '30'}, format='json', **self.header)
  463. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  464. self.assertIn('prefix_length', response.data[0])
  465. def test_create_multiple_available_prefixes(self):
  466. """
  467. Test the creation of available prefixes within a parent prefix.
  468. """
  469. vrf = VRF.objects.create(name='VRF 1')
  470. prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/28'), vrf=vrf, is_pool=True)
  471. url = reverse('ipam-api:prefix-available-prefixes', kwargs={'pk': prefix.pk})
  472. self.add_permissions('ipam.view_prefix', 'ipam.add_prefix')
  473. # Try to create five /30s (only four are available)
  474. data = [
  475. {'prefix_length': 30, 'description': 'Prefix 1'},
  476. {'prefix_length': 30, 'description': 'Prefix 2'},
  477. {'prefix_length': 30, 'description': 'Prefix 3'},
  478. {'prefix_length': 30, 'description': 'Prefix 4'},
  479. {'prefix_length': 30, 'description': 'Prefix 5'},
  480. ]
  481. response = self.client.post(url, data, format='json', **self.header)
  482. self.assertHttpStatus(response, status.HTTP_409_CONFLICT)
  483. self.assertIn('detail', response.data)
  484. # Verify that no prefixes were created (the entire /28 is still available)
  485. response = self.client.get(url, **self.header)
  486. self.assertHttpStatus(response, status.HTTP_200_OK)
  487. self.assertEqual(response.data[0]['prefix'], '192.0.2.0/28')
  488. # Create four /30s in a single request
  489. response = self.client.post(url, data[:4], format='json', **self.header)
  490. self.assertHttpStatus(response, status.HTTP_201_CREATED)
  491. self.assertEqual(len(response.data), 4)
  492. def test_list_available_ips(self):
  493. """
  494. Test retrieval of all available IP addresses within a parent prefix.
  495. """
  496. vrf = VRF.objects.create(name='VRF 1')
  497. prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/29'), vrf=vrf, is_pool=True)
  498. url = reverse('ipam-api:prefix-available-ips', kwargs={'pk': prefix.pk})
  499. self.add_permissions('ipam.view_prefix', 'ipam.view_ipaddress')
  500. # Retrieve all available IPs
  501. response = self.client.get(url, **self.header)
  502. self.assertHttpStatus(response, status.HTTP_200_OK)
  503. self.assertEqual(len(response.data), 8) # 8 because prefix.is_pool = True
  504. # Change the prefix to not be a pool and try again
  505. prefix.is_pool = False
  506. prefix.save()
  507. response = self.client.get(url, **self.header)
  508. self.assertEqual(len(response.data), 6) # 8 - 2 because prefix.is_pool = False
  509. def test_create_single_available_ip(self):
  510. """
  511. Test retrieval of the first available IP address within a parent prefix.
  512. """
  513. vrf = VRF.objects.create(name='VRF 1')
  514. prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/30'), vrf=vrf, is_pool=True)
  515. url = reverse('ipam-api:prefix-available-ips', kwargs={'pk': prefix.pk})
  516. self.add_permissions('ipam.view_prefix', 'ipam.add_ipaddress')
  517. # Create all four available IPs with individual requests
  518. for i in range(1, 5):
  519. data = {
  520. 'description': 'Test IP {}'.format(i)
  521. }
  522. response = self.client.post(url, data, format='json', **self.header)
  523. self.assertHttpStatus(response, status.HTTP_201_CREATED)
  524. self.assertEqual(response.data['vrf']['id'], vrf.pk)
  525. self.assertEqual(response.data['description'], data['description'])
  526. # Try to create one more IP
  527. response = self.client.post(url, {}, format='json', **self.header)
  528. self.assertHttpStatus(response, status.HTTP_409_CONFLICT)
  529. self.assertIn('detail', response.data)
  530. def test_create_multiple_available_ips(self):
  531. """
  532. Test the creation of available IP addresses within a parent prefix.
  533. """
  534. vrf = VRF.objects.create(name='VRF 1')
  535. prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/29'), vrf=vrf, is_pool=True)
  536. url = reverse('ipam-api:prefix-available-ips', kwargs={'pk': prefix.pk})
  537. self.add_permissions('ipam.view_prefix', 'ipam.add_ipaddress')
  538. # Try to create nine IPs (only eight are available)
  539. data = [{'description': f'Test IP {i}'} for i in range(1, 10)] # 9 IPs
  540. response = self.client.post(url, data, format='json', **self.header)
  541. self.assertHttpStatus(response, status.HTTP_409_CONFLICT)
  542. self.assertIn('detail', response.data)
  543. # Create all eight available IPs in a single request
  544. data = [{'description': 'Test IP {}'.format(i)} for i in range(1, 9)] # 8 IPs
  545. response = self.client.post(url, data, format='json', **self.header)
  546. self.assertHttpStatus(response, status.HTTP_201_CREATED)
  547. self.assertEqual(len(response.data), 8)
  548. def test_create_available_ip_with_mask(self):
  549. """
  550. Test the creation of an available IP address with a specific prefix length.
  551. """
  552. prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/24'))
  553. url = reverse('ipam-api:prefix-available-ips', kwargs={'pk': prefix.pk})
  554. self.add_permissions('ipam.view_prefix', 'ipam.add_ipaddress')
  555. # Create an available IP with a specific prefix length
  556. data = {
  557. 'prefix_length': 32,
  558. 'description': 'Test IP 1',
  559. }
  560. response = self.client.post(url, data, format='json', **self.header)
  561. self.assertHttpStatus(response, status.HTTP_201_CREATED)
  562. self.assertEqual(response.data['address'], '192.0.2.1/32')
  563. self.assertEqual(response.data['description'], data['description'])
  564. # Attempt to create an available IP with a prefix length less than its parent prefix
  565. data = {
  566. 'prefix_length': 23, # Prefix is a /24
  567. }
  568. response = self.client.post(url, data, format='json', **self.header)
  569. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  570. @tag('regression')
  571. def test_create_available_ips_errors_by_position(self):
  572. """
  573. Test that the errors for a request creating multiple IP addresses are correlated to the
  574. positions of the entries which failed validation.
  575. """
  576. prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/24'))
  577. url = reverse('ipam-api:prefix-available-ips', kwargs={'pk': prefix.pk})
  578. self.add_permissions('ipam.view_prefix', 'ipam.add_ipaddress')
  579. # An invalid request attribute, rejected before any address has been allocated
  580. data = [
  581. {'description': 'Test IP 1'},
  582. {'prefix_length': 23}, # Parent prefix is a /24
  583. ]
  584. response = self.client.post(url, data, format='json', **self.header)
  585. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  586. self.assertEqual(len(response.data), 2)
  587. self.assertEqual(response.data[0], {})
  588. self.assertIn('prefix_length', response.data[1])
  589. # An invalid object attribute, rejected after the addresses have been allocated
  590. data = [
  591. {'description': 'Test IP 1'},
  592. {'status': 'not-a-valid-status'},
  593. ]
  594. response = self.client.post(url, data, format='json', **self.header)
  595. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  596. self.assertEqual(len(response.data), 2)
  597. self.assertEqual(response.data[0], {})
  598. self.assertIn('status', response.data[1])
  599. # A single object is wrapped in a list, so its errors are reported in the same form
  600. response = self.client.post(url, {'prefix_length': 23}, format='json', **self.header)
  601. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  602. self.assertEqual(len(response.data), 1)
  603. self.assertIn('prefix_length', response.data[0])
  604. @tag('regression')
  605. def test_graphql_tenant_prefixes_contains_nested_skips_invalid(self):
  606. """
  607. Test the GraphQL API Tenant nested Prefix `contains` filter skips invalid input.
  608. """
  609. self.add_permissions('ipam.view_prefix', 'ipam.view_vrf', 'tenancy.view_tenant')
  610. tenant = Tenant.objects.create(name='Tenant 1', slug='tenant-1')
  611. vrf = VRF.objects.create(name='Test VRF 1', rd='64512:1')
  612. Prefix.objects.create(prefix='10.20.0.0/16', vrf=vrf, tenant=tenant)
  613. Prefix.objects.create(prefix='198.51.100.0/24', vrf=vrf) # non-tenant
  614. url = reverse('graphql')
  615. query = """{
  616. tenant_list(filters: { prefixes: { contains: ["10.20.1.0/24", "not-a-cidr"] } }) { id }
  617. }"""
  618. response = self.client.post(url, data={'query': query}, format='json', **self.header)
  619. self.assertHttpStatus(response, status.HTTP_200_OK)
  620. data = response.json()
  621. self.assertNotIn('errors', data)
  622. self.assertTrue(data['data']['tenant_list']) # tenant returned
  623. class IPRangeTestCase(APIViewTestCases.APIViewTestCase):
  624. model = IPRange
  625. brief_fields = ['description', 'display', 'end_address', 'family', 'id', 'start_address', 'url']
  626. create_data = [
  627. {
  628. 'start_address': '192.168.4.10/24',
  629. 'end_address': '192.168.4.50/24',
  630. },
  631. {
  632. 'start_address': '192.168.5.10/24',
  633. 'end_address': '192.168.5.50/24',
  634. },
  635. {
  636. 'start_address': '192.168.6.10/24',
  637. 'end_address': '192.168.6.50/24',
  638. },
  639. {
  640. # Single-address range (start == end)
  641. 'start_address': '192.168.7.10/24',
  642. 'end_address': '192.168.7.10/24',
  643. },
  644. ]
  645. bulk_update_data = {
  646. 'description': 'New description',
  647. }
  648. @classmethod
  649. def setUpTestData(cls):
  650. ip_ranges = (
  651. IPRange(start_address=IPNetwork('192.168.1.10/24'), end_address=IPNetwork('192.168.1.50/24'), size=51),
  652. IPRange(start_address=IPNetwork('192.168.2.10/24'), end_address=IPNetwork('192.168.2.50/24'), size=51),
  653. IPRange(start_address=IPNetwork('192.168.3.10/24'), end_address=IPNetwork('192.168.3.50/24'), size=51),
  654. )
  655. IPRange.objects.bulk_create(ip_ranges)
  656. def test_list_available_ips(self):
  657. """
  658. Test retrieval of all available IP addresses within a parent IP range.
  659. """
  660. iprange = IPRange.objects.create(
  661. start_address=IPNetwork('192.0.2.10/24'),
  662. end_address=IPNetwork('192.0.2.19/24')
  663. )
  664. url = reverse('ipam-api:iprange-available-ips', kwargs={'pk': iprange.pk})
  665. self.add_permissions('ipam.view_iprange', 'ipam.view_ipaddress')
  666. # Retrieve all available IPs
  667. response = self.client.get(url, **self.header)
  668. self.assertHttpStatus(response, status.HTTP_200_OK)
  669. self.assertEqual(len(response.data), 10)
  670. def test_create_single_available_ip(self):
  671. """
  672. Test retrieval of the first available IP address within a parent IP range.
  673. """
  674. vrf = VRF.objects.create(name='Test VRF 1', rd='1234')
  675. iprange = IPRange.objects.create(
  676. start_address=IPNetwork('192.0.2.1/24'),
  677. end_address=IPNetwork('192.0.2.3/24'),
  678. vrf=vrf
  679. )
  680. url = reverse('ipam-api:iprange-available-ips', kwargs={'pk': iprange.pk})
  681. self.add_permissions('ipam.view_iprange', 'ipam.add_ipaddress')
  682. # Create all three available IPs with individual requests
  683. for i in range(1, 4):
  684. data = {
  685. 'description': f'Test IP #{i}'
  686. }
  687. response = self.client.post(url, data, format='json', **self.header)
  688. self.assertHttpStatus(response, status.HTTP_201_CREATED)
  689. self.assertEqual(response.data['vrf']['id'], vrf.pk)
  690. self.assertEqual(response.data['description'], data['description'])
  691. # Try to create one more IP
  692. response = self.client.post(url, {}, format='json', **self.header)
  693. self.assertHttpStatus(response, status.HTTP_409_CONFLICT)
  694. self.assertIn('detail', response.data)
  695. def test_create_multiple_available_ips(self):
  696. """
  697. Test the creation of available IP addresses within a parent IP range.
  698. """
  699. iprange = IPRange.objects.create(
  700. start_address=IPNetwork('192.0.2.1/24'),
  701. end_address=IPNetwork('192.0.2.8/24')
  702. )
  703. url = reverse('ipam-api:iprange-available-ips', kwargs={'pk': iprange.pk})
  704. self.add_permissions('ipam.view_iprange', 'ipam.add_ipaddress')
  705. # Try to create nine IPs (only eight are available)
  706. data = [{'description': f'Test IP #{i}'} for i in range(1, 10)] # 9 IPs
  707. response = self.client.post(url, data, format='json', **self.header)
  708. self.assertHttpStatus(response, status.HTTP_409_CONFLICT)
  709. self.assertIn('detail', response.data)
  710. # Create all eight available IPs in a single request
  711. data = [{'description': f'Test IP #{i}'} for i in range(1, 9)] # 8 IPs
  712. response = self.client.post(url, data, format='json', **self.header)
  713. self.assertHttpStatus(response, status.HTTP_201_CREATED)
  714. self.assertEqual(len(response.data), 8)
  715. @tag('regression')
  716. def test_graphql_tenant_ip_ranges_parent_nested_skips_invalid(self):
  717. """
  718. Test the GraphQL API Tenant nested IP Range `parent` filter skips invalid input.
  719. """
  720. self.add_permissions('tenancy.view_tenant', 'ipam.view_iprange', 'ipam.view_vrf')
  721. tenant = Tenant.objects.create(name='Tenant 1', slug='tenant-1')
  722. vrf = VRF.objects.create(name='Test VRF 1', rd='64512:1')
  723. IPRange.objects.create(
  724. start_address=IPNetwork('10.30.0.1/24'), end_address=IPNetwork('10.30.0.255/24'), vrf=vrf, tenant=tenant
  725. )
  726. IPRange.objects.create(
  727. start_address=IPNetwork('10.31.0.1/24'), end_address=IPNetwork('10.31.0.255/24'), vrf=vrf, tenant=tenant
  728. )
  729. url = reverse('graphql')
  730. query = """{
  731. tenant_list(filters: {
  732. name: { exact: "Tenant 1" }
  733. ip_ranges: { parent: ["10.30.0.0/24", "bogus"] }
  734. }) { id }
  735. }"""
  736. response = self.client.post(url, data={'query': query}, format='json', **self.header)
  737. self.assertHttpStatus(response, status.HTTP_200_OK)
  738. data = response.json()
  739. self.assertNotIn('errors', data)
  740. self.assertTrue(data['data']['tenant_list']) # tenant returned
  741. # No exception occurred; invalid entries were ignored
  742. @tag('regression')
  743. def test_graphql_tenant_ip_ranges_contains_nested_skips_invalid(self):
  744. """
  745. Test the GraphQL API Tenant nested IP Range `contains` filter skips invalid input.
  746. """
  747. self.add_permissions('tenancy.view_tenant', 'ipam.view_iprange', 'ipam.view_vrf')
  748. tenant = Tenant.objects.create(name='Tenant 2', slug='tenant-2')
  749. vrf = VRF.objects.create(name='Test VRF 1', rd='64512:2')
  750. IPRange.objects.create(
  751. start_address=IPNetwork('10.40.0.1/24'), end_address=IPNetwork('10.40.0.255/24'), vrf=vrf, tenant=tenant
  752. )
  753. url = reverse('graphql')
  754. query = """{
  755. tenant_list(filters: {
  756. name: { exact: "Tenant 2" }
  757. ip_ranges: { contains: ["10.40.0.128/25", "###"] }
  758. }) { id }
  759. }"""
  760. response = self.client.post(url, data={'query': query}, format='json', **self.header)
  761. self.assertHttpStatus(response, status.HTTP_200_OK)
  762. data = response.json()
  763. self.assertNotIn('errors', data)
  764. self.assertTrue(data['data']['tenant_list']) # tenant returned
  765. # No exception occurred; invalid entries were ignored
  766. class IPAddressTestCase(APIViewTestCases.APIViewTestCase):
  767. model = IPAddress
  768. brief_fields = ['address', 'description', 'display', 'family', 'id', 'url']
  769. create_data = [
  770. {
  771. 'address': '192.168.0.4/24',
  772. },
  773. {
  774. 'address': '192.168.0.5/24',
  775. },
  776. {
  777. 'address': '192.168.0.6/24',
  778. },
  779. ]
  780. bulk_update_data = {
  781. 'description': 'New description',
  782. }
  783. graphql_filter = {
  784. 'address': {'lookup': 'i_exact', 'value': '192.168.0.1/24'},
  785. }
  786. @classmethod
  787. def setUpTestData(cls):
  788. ip_addresses = (
  789. IPAddress(address=IPNetwork('192.168.0.1/24')),
  790. IPAddress(address=IPNetwork('192.168.0.2/24')),
  791. IPAddress(address=IPNetwork('192.168.0.3/24')),
  792. )
  793. IPAddress.objects.bulk_create(ip_addresses)
  794. @tag('regression')
  795. def test_create_with_invalid_address(self):
  796. """
  797. POST of a malformed address value returns a 400 validation error.
  798. """
  799. self.add_permissions('ipam.add_ipaddress')
  800. url = reverse('ipam-api:ipaddress-list')
  801. response = self.client.post(url, {'address': 'invalid'}, format='json', **self.header)
  802. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  803. self.assertEqual(response.data['address'][0], 'Invalid IP address format: invalid')
  804. def test_assign_object(self):
  805. """
  806. Test the creation of available IP addresses within a parent IP range.
  807. """
  808. site = Site.objects.create(name='Site 1')
  809. manufacturer = Manufacturer.objects.create(name='Manufacturer 1')
  810. device_type = DeviceType.objects.create(model='Device Type 1', manufacturer=manufacturer)
  811. role = DeviceRole.objects.create(name='Switch')
  812. device1 = Device.objects.create(
  813. name='Device 1',
  814. site=site,
  815. device_type=device_type,
  816. role=role,
  817. status='active'
  818. )
  819. interface1 = Interface.objects.create(name='Interface 1', device=device1, type='1000baset')
  820. interface2 = Interface.objects.create(name='Interface 2', device=device1, type='1000baset')
  821. device2 = Device.objects.create(
  822. name='Device 2',
  823. site=site,
  824. device_type=device_type,
  825. role=role,
  826. status='active'
  827. )
  828. interface3 = Interface.objects.create(name='Interface 3', device=device2, type='1000baset')
  829. ip_addresses = (
  830. IPAddress(address=IPNetwork('192.168.0.4/24'), assigned_object=interface1),
  831. IPAddress(address=IPNetwork('192.168.1.4/24')),
  832. )
  833. IPAddress.objects.bulk_create(ip_addresses)
  834. ip1 = ip_addresses[0]
  835. ip1.assigned_object = interface1
  836. device1.primary_ip4 = ip_addresses[0]
  837. device1.save()
  838. url = reverse('ipam-api:ipaddress-detail', kwargs={'pk': ip1.pk})
  839. self.add_permissions('ipam.change_ipaddress')
  840. # assign to same parent
  841. data = {
  842. 'assigned_object_id': interface2.pk
  843. }
  844. response = self.client.patch(url, data, format='json', **self.header)
  845. self.assertHttpStatus(response, status.HTTP_200_OK)
  846. # assign to same different parent - should error
  847. data = {
  848. 'assigned_object_id': interface3.pk
  849. }
  850. response = self.client.patch(url, data, format='json', **self.header)
  851. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  852. @tag('regression')
  853. def test_graphql_device_primary_ip4_assigned_nested(self):
  854. """
  855. Test the GraphQL API Device nested IP Address `primary_ip4` filter.
  856. """
  857. self.add_permissions('dcim.view_device', 'dcim.view_interface', 'ipam.view_ipaddress')
  858. site = Site.objects.create(name='Site 1')
  859. manufacturer = Manufacturer.objects.create(name='Manufacturer 1')
  860. device_type = DeviceType.objects.create(model='Device Type 1', manufacturer=manufacturer)
  861. role = DeviceRole.objects.create(name='Switch')
  862. device1 = Device.objects.create(name='Device 1', site=site, device_type=device_type, role=role, status='active')
  863. interface1 = Interface.objects.create(name='Interface 1', device=device1, type='1000baset')
  864. ip1 = IPAddress.objects.create(address='10.0.0.1/24')
  865. ip1.assigned_object = interface1
  866. ip1.save()
  867. device1.primary_ip4 = ip1
  868. device1.save()
  869. device2 = Device.objects.create(name='Device 2', site=site, device_type=device_type, role=role, status='active')
  870. url = reverse('graphql')
  871. query = """{
  872. device_list(filters: { primary_ip4: { assigned: true } }) { id name }
  873. }"""
  874. response = self.client.post(url, data={'query': query}, format='json', **self.header)
  875. self.assertHttpStatus(response, status.HTTP_200_OK)
  876. data = response.json()
  877. self.assertNotIn('errors', data)
  878. ids = {row['id'] for row in data['data']['device_list']}
  879. self.assertIn(str(device1.pk), ids)
  880. self.assertNotIn(str(device2.pk), ids)
  881. @tag('regression')
  882. def test_graphql_device_primary_ip4_parent_nested_skips_invalid(self):
  883. """
  884. Test the GraphQL API Device nested IP Address `parent` filter skips invalid input.
  885. """
  886. self.add_permissions('dcim.view_device', 'dcim.view_interface', 'ipam.view_ipaddress')
  887. site = Site.objects.create(name='Site 1')
  888. manufacturer = Manufacturer.objects.create(name='Manufacturer 1')
  889. device_type = DeviceType.objects.create(model='Device Type 1', manufacturer=manufacturer)
  890. role = DeviceRole.objects.create(name='Switch')
  891. device1 = Device.objects.create(name='Device 1', site=site, device_type=device_type, role=role, status='active')
  892. interface1 = Interface.objects.create(name='Interface 1', device=device1, type='1000baset')
  893. ip1 = IPAddress.objects.create(address='192.0.2.10/24')
  894. ip1.assigned_object = interface1
  895. ip1.save()
  896. device1.primary_ip4 = ip1
  897. device1.save()
  898. url = reverse('graphql')
  899. query = """{
  900. device_list(filters: { primary_ip4: { parent: ["192.0.2.0/24", "bad-cidr"] } }) { id }
  901. }"""
  902. response = self.client.post(url, data={'query': query}, format='json', **self.header)
  903. self.assertHttpStatus(response, status.HTTP_200_OK)
  904. data = response.json()
  905. self.assertNotIn('errors', data)
  906. ids = {row['id'] for row in data['data']['device_list']}
  907. self.assertIn(str(device1.pk), ids)
  908. class FHRPGroupTestCase(APIViewTestCases.APIViewTestCase):
  909. model = FHRPGroup
  910. brief_fields = ['description', 'display', 'group_id', 'id', 'protocol', 'url']
  911. bulk_update_data = {
  912. 'protocol': FHRPGroupProtocolChoices.PROTOCOL_GLBP,
  913. 'group_id': 200,
  914. 'auth_type': FHRPGroupAuthTypeChoices.AUTHENTICATION_MD5,
  915. 'auth_key': 'foobarbaz999',
  916. 'name': 'foobar-999',
  917. 'description': 'New description',
  918. }
  919. @classmethod
  920. def setUpTestData(cls):
  921. fhrp_groups = (
  922. FHRPGroup(
  923. protocol=FHRPGroupProtocolChoices.PROTOCOL_VRRP2,
  924. group_id=10,
  925. auth_type=FHRPGroupAuthTypeChoices.AUTHENTICATION_PLAINTEXT,
  926. auth_key='foobar123',
  927. ),
  928. FHRPGroup(
  929. protocol=FHRPGroupProtocolChoices.PROTOCOL_VRRP3,
  930. group_id=20,
  931. auth_type=FHRPGroupAuthTypeChoices.AUTHENTICATION_MD5,
  932. auth_key='foobar123',
  933. ),
  934. FHRPGroup(protocol=FHRPGroupProtocolChoices.PROTOCOL_HSRP, group_id=30),
  935. )
  936. FHRPGroup.objects.bulk_create(fhrp_groups)
  937. cls.create_data = [
  938. {
  939. 'protocol': FHRPGroupProtocolChoices.PROTOCOL_VRRP2,
  940. 'group_id': 110,
  941. 'auth_type': FHRPGroupAuthTypeChoices.AUTHENTICATION_PLAINTEXT,
  942. 'auth_key': 'foobar123',
  943. },
  944. {
  945. 'protocol': FHRPGroupProtocolChoices.PROTOCOL_VRRP3,
  946. 'group_id': 120,
  947. 'auth_type': FHRPGroupAuthTypeChoices.AUTHENTICATION_MD5,
  948. 'auth_key': 'barfoo456',
  949. },
  950. {
  951. 'protocol': FHRPGroupProtocolChoices.PROTOCOL_GLBP,
  952. 'group_id': 130,
  953. },
  954. ]
  955. class FHRPGroupAssignmentTestCase(APIViewTestCases.APIViewTestCase):
  956. model = FHRPGroupAssignment
  957. brief_fields = ['display', 'group', 'id', 'interface_id', 'interface_type', 'priority', 'url']
  958. bulk_update_data = {
  959. 'priority': 100,
  960. }
  961. user_permissions = ('ipam.view_fhrpgroup', )
  962. @classmethod
  963. def setUpTestData(cls):
  964. device1 = create_test_device('device1')
  965. device2 = create_test_device('device2')
  966. device3 = create_test_device('device3')
  967. interfaces = (
  968. Interface(device=device1, name='eth0', type='other'),
  969. Interface(device=device1, name='eth1', type='other'),
  970. Interface(device=device1, name='eth2', type='other'),
  971. Interface(device=device2, name='eth0', type='other'),
  972. Interface(device=device2, name='eth1', type='other'),
  973. Interface(device=device2, name='eth2', type='other'),
  974. Interface(device=device3, name='eth0', type='other'),
  975. Interface(device=device3, name='eth1', type='other'),
  976. Interface(device=device3, name='eth2', type='other'),
  977. )
  978. Interface.objects.bulk_create(interfaces)
  979. ip_addresses = (
  980. IPAddress(address=IPNetwork('192.168.0.2/24'), assigned_object=interfaces[0]),
  981. IPAddress(address=IPNetwork('192.168.1.2/24'), assigned_object=interfaces[1]),
  982. IPAddress(address=IPNetwork('192.168.2.2/24'), assigned_object=interfaces[2]),
  983. IPAddress(address=IPNetwork('192.168.0.3/24'), assigned_object=interfaces[3]),
  984. IPAddress(address=IPNetwork('192.168.1.3/24'), assigned_object=interfaces[4]),
  985. IPAddress(address=IPNetwork('192.168.2.3/24'), assigned_object=interfaces[5]),
  986. IPAddress(address=IPNetwork('192.168.0.4/24'), assigned_object=interfaces[6]),
  987. IPAddress(address=IPNetwork('192.168.1.4/24'), assigned_object=interfaces[7]),
  988. IPAddress(address=IPNetwork('192.168.2.4/24'), assigned_object=interfaces[8]),
  989. )
  990. IPAddress.objects.bulk_create(ip_addresses)
  991. fhrp_groups = (
  992. FHRPGroup(protocol=FHRPGroupProtocolChoices.PROTOCOL_VRRP2, group_id=10),
  993. FHRPGroup(protocol=FHRPGroupProtocolChoices.PROTOCOL_VRRP2, group_id=20),
  994. FHRPGroup(protocol=FHRPGroupProtocolChoices.PROTOCOL_VRRP2, group_id=30),
  995. )
  996. FHRPGroup.objects.bulk_create(fhrp_groups)
  997. fhrp_group_assignments = (
  998. FHRPGroupAssignment(group=fhrp_groups[0], interface=interfaces[0], priority=10),
  999. FHRPGroupAssignment(group=fhrp_groups[1], interface=interfaces[1], priority=10),
  1000. FHRPGroupAssignment(group=fhrp_groups[2], interface=interfaces[2], priority=10),
  1001. FHRPGroupAssignment(group=fhrp_groups[0], interface=interfaces[3], priority=20),
  1002. FHRPGroupAssignment(group=fhrp_groups[1], interface=interfaces[4], priority=20),
  1003. FHRPGroupAssignment(group=fhrp_groups[2], interface=interfaces[5], priority=20),
  1004. )
  1005. FHRPGroupAssignment.objects.bulk_create(fhrp_group_assignments)
  1006. cls.create_data = [
  1007. {
  1008. 'group': fhrp_groups[0].pk,
  1009. 'interface_type': 'dcim.interface',
  1010. 'interface_id': interfaces[6].pk,
  1011. 'priority': 30,
  1012. },
  1013. {
  1014. 'group': fhrp_groups[1].pk,
  1015. 'interface_type': 'dcim.interface',
  1016. 'interface_id': interfaces[7].pk,
  1017. 'priority': 30,
  1018. },
  1019. {
  1020. 'group': fhrp_groups[2].pk,
  1021. 'interface_type': 'dcim.interface',
  1022. 'interface_id': interfaces[8].pk,
  1023. 'priority': 30,
  1024. },
  1025. ]
  1026. class VLANGroupTestCase(APIViewTestCases.APIViewTestCase):
  1027. model = VLANGroup
  1028. brief_fields = ['description', 'display', 'id', 'name', 'slug', 'url', 'vlan_count']
  1029. create_data = [
  1030. {
  1031. 'name': 'VLAN Group 4',
  1032. 'slug': 'vlan-group-4',
  1033. 'vid_ranges': [[1, 4094]]
  1034. },
  1035. {
  1036. 'name': 'VLAN Group 5',
  1037. 'slug': 'vlan-group-5',
  1038. 'vid_ranges': [[1, 4094]]
  1039. },
  1040. {
  1041. 'name': 'VLAN Group 6',
  1042. 'slug': 'vlan-group-6',
  1043. 'vid_ranges': [[1, 4094]]
  1044. },
  1045. ]
  1046. bulk_update_data = {
  1047. 'description': 'New description',
  1048. }
  1049. @classmethod
  1050. def setUpTestData(cls):
  1051. vlan_groups = (
  1052. VLANGroup(name='VLAN Group 1', slug='vlan-group-1'),
  1053. VLANGroup(name='VLAN Group 2', slug='vlan-group-2'),
  1054. VLANGroup(name='VLAN Group 3', slug='vlan-group-3'),
  1055. )
  1056. VLANGroup.objects.bulk_create(vlan_groups)
  1057. def test_list_available_vlans(self):
  1058. """
  1059. Test retrieval of all available VLANs within a group.
  1060. """
  1061. MIN_VID = 100
  1062. MAX_VID = 199
  1063. self.add_permissions('ipam.view_vlangroup', 'ipam.view_vlan')
  1064. vlangroup = VLANGroup.objects.create(
  1065. name='VLAN Group X',
  1066. slug='vlan-group-x',
  1067. vid_ranges=string_to_ranges(f"{MIN_VID}-{MAX_VID}")
  1068. )
  1069. # Create a set of VLANs within the group
  1070. vlans = (
  1071. VLAN(vid=10, name='VLAN 10', group=vlangroup),
  1072. VLAN(vid=20, name='VLAN 20', group=vlangroup),
  1073. VLAN(vid=30, name='VLAN 30', group=vlangroup),
  1074. )
  1075. VLAN.objects.bulk_create(vlans)
  1076. # Retrieve all available VLANs
  1077. url = reverse('ipam-api:vlangroup-available-vlans', kwargs={'pk': vlangroup.pk})
  1078. response = self.client.get(f'{url}?limit=0', **self.header)
  1079. self.assertEqual(len(response.data), MAX_VID - MIN_VID + 1)
  1080. available_vlans = {vlan['vid'] for vlan in response.data}
  1081. for vlan in vlans:
  1082. self.assertNotIn(vlan.vid, available_vlans)
  1083. # Retrieve a maximum number of available VLANs
  1084. url = reverse('ipam-api:vlangroup-available-vlans', kwargs={'pk': vlangroup.pk})
  1085. response = self.client.get(f'{url}?limit=10', **self.header)
  1086. self.assertEqual(len(response.data), 10)
  1087. def test_create_single_available_vlan(self):
  1088. """
  1089. Test the creation of a single available VLAN.
  1090. """
  1091. self.add_permissions('ipam.view_vlangroup', 'ipam.view_vlan', 'ipam.add_vlan')
  1092. vlangroup = VLANGroup.objects.first()
  1093. VLAN.objects.create(vid=1, name='VLAN 1', group=vlangroup)
  1094. data = {
  1095. "name": "First VLAN",
  1096. }
  1097. url = reverse('ipam-api:vlangroup-available-vlans', kwargs={'pk': vlangroup.pk})
  1098. response = self.client.post(url, data, format='json', **self.header)
  1099. self.assertHttpStatus(response, status.HTTP_201_CREATED)
  1100. self.assertEqual(response.data['name'], data['name'])
  1101. self.assertEqual(response.data['group']['id'], vlangroup.pk)
  1102. self.assertEqual(response.data['vid'], 2)
  1103. def test_create_multiple_available_vlans(self):
  1104. """
  1105. Test the creation of multiple available VLANs.
  1106. """
  1107. self.add_permissions('ipam.view_vlangroup', 'ipam.view_vlan', 'ipam.add_vlan')
  1108. vlangroup = VLANGroup.objects.first()
  1109. vlans = (
  1110. VLAN(vid=1, name='VLAN 1', group=vlangroup),
  1111. VLAN(vid=3, name='VLAN 3', group=vlangroup),
  1112. VLAN(vid=5, name='VLAN 5', group=vlangroup),
  1113. )
  1114. VLAN.objects.bulk_create(vlans)
  1115. data = (
  1116. {"name": "First VLAN"},
  1117. {"name": "Second VLAN"},
  1118. {"name": "Third VLAN"},
  1119. )
  1120. url = reverse('ipam-api:vlangroup-available-vlans', kwargs={'pk': vlangroup.pk})
  1121. response = self.client.post(url, data, format='json', **self.header)
  1122. self.assertHttpStatus(response, status.HTTP_201_CREATED)
  1123. self.assertEqual(len(response.data), 3)
  1124. self.assertEqual(response.data[0]['name'], data[0]['name'])
  1125. self.assertEqual(response.data[0]['group']['id'], vlangroup.pk)
  1126. self.assertEqual(response.data[0]['vid'], 2)
  1127. self.assertEqual(response.data[1]['name'], data[1]['name'])
  1128. self.assertEqual(response.data[1]['group']['id'], vlangroup.pk)
  1129. self.assertEqual(response.data[1]['vid'], 4)
  1130. self.assertEqual(response.data[2]['name'], data[2]['name'])
  1131. self.assertEqual(response.data[2]['group']['id'], vlangroup.pk)
  1132. self.assertEqual(response.data[2]['vid'], 6)
  1133. class VLANTestCase(APIViewTestCases.APIViewTestCase):
  1134. model = VLAN
  1135. brief_fields = ['description', 'display', 'id', 'name', 'url', 'vid']
  1136. bulk_update_data = {
  1137. 'description': 'New description',
  1138. }
  1139. @classmethod
  1140. def setUpTestData(cls):
  1141. vlan_groups = (
  1142. VLANGroup(name='VLAN Group 1', slug='vlan-group-1'),
  1143. VLANGroup(name='VLAN Group 2', slug='vlan-group-2'),
  1144. )
  1145. VLANGroup.objects.bulk_create(vlan_groups)
  1146. vlans = (
  1147. VLAN(name='VLAN 1', vid=1, group=vlan_groups[0]),
  1148. VLAN(name='VLAN 2', vid=2, group=vlan_groups[0]),
  1149. VLAN(name='VLAN 3', vid=3, group=vlan_groups[0]),
  1150. VLAN(name='SVLAN 1', vid=1001, qinq_role=VLANQinQRoleChoices.ROLE_SERVICE),
  1151. )
  1152. VLAN.objects.bulk_create(vlans)
  1153. cls.create_data = [
  1154. {
  1155. 'vid': 4,
  1156. 'name': 'VLAN 4',
  1157. 'group': vlan_groups[1].pk,
  1158. },
  1159. {
  1160. 'vid': 5,
  1161. 'name': 'VLAN 5',
  1162. 'group': vlan_groups[1].pk,
  1163. },
  1164. {
  1165. 'vid': 6,
  1166. 'name': 'VLAN 6',
  1167. 'group': vlan_groups[1].pk,
  1168. },
  1169. {
  1170. 'vid': 2001,
  1171. 'name': 'CVLAN 1',
  1172. 'qinq_role': VLANQinQRoleChoices.ROLE_CUSTOMER,
  1173. 'qinq_svlan': vlans[3].pk,
  1174. },
  1175. ]
  1176. def test_delete_vlan_with_prefix(self):
  1177. """
  1178. Attempt and fail to delete a VLAN with a Prefix assigned to it.
  1179. """
  1180. vlan = VLAN.objects.first()
  1181. Prefix.objects.create(prefix=IPNetwork('192.0.2.0/24'), vlan=vlan)
  1182. self.add_permissions('ipam.delete_vlan')
  1183. url = reverse('ipam-api:vlan-detail', kwargs={'pk': vlan.pk})
  1184. with disable_logging(level=logging.WARNING):
  1185. response = self.client.delete(url, **self.header)
  1186. self.assertHttpStatus(response, status.HTTP_409_CONFLICT)
  1187. content = json.loads(response.content.decode('utf-8'))
  1188. self.assertIn('detail', content)
  1189. self.assertTrue(content['detail'].startswith('Unable to delete object.'))
  1190. class VLANTranslationPolicyTestCase(APIViewTestCases.APIViewTestCase):
  1191. model = VLANTranslationPolicy
  1192. brief_fields = ['description', 'display', 'id', 'name', 'url',]
  1193. bulk_update_data = {
  1194. 'description': 'New description',
  1195. }
  1196. @classmethod
  1197. def setUpTestData(cls):
  1198. vlan_translation_policies = (
  1199. VLANTranslationPolicy(
  1200. name='Policy 1',
  1201. description='foobar1',
  1202. ),
  1203. VLANTranslationPolicy(
  1204. name='Policy 2',
  1205. description='foobar2',
  1206. ),
  1207. VLANTranslationPolicy(
  1208. name='Policy 3',
  1209. description='foobar3',
  1210. ),
  1211. )
  1212. VLANTranslationPolicy.objects.bulk_create(vlan_translation_policies)
  1213. cls.create_data = [
  1214. {
  1215. 'name': 'Policy 4',
  1216. 'description': 'foobar4',
  1217. },
  1218. {
  1219. 'name': 'Policy 5',
  1220. 'description': 'foobar5',
  1221. },
  1222. {
  1223. 'name': 'Policy 6',
  1224. 'description': 'foobar6',
  1225. },
  1226. ]
  1227. class VLANTranslationRuleTestCase(APIViewTestCases.APIViewTestCase):
  1228. model = VLANTranslationRule
  1229. brief_fields = ['description', 'display', 'id', 'local_vid', 'policy', 'remote_vid', 'url']
  1230. @classmethod
  1231. def setUpTestData(cls):
  1232. vlan_translation_policies = (
  1233. VLANTranslationPolicy(
  1234. name='Policy 1',
  1235. description='foobar1',
  1236. ),
  1237. VLANTranslationPolicy(
  1238. name='Policy 2',
  1239. description='foobar2',
  1240. ),
  1241. VLANTranslationPolicy(
  1242. name='Policy 3',
  1243. description='foobar2',
  1244. ),
  1245. )
  1246. VLANTranslationPolicy.objects.bulk_create(vlan_translation_policies)
  1247. vlan_translation_rules = (
  1248. VLANTranslationRule(
  1249. policy=vlan_translation_policies[0],
  1250. local_vid=100,
  1251. remote_vid=200,
  1252. description='foo',
  1253. ),
  1254. VLANTranslationRule(
  1255. policy=vlan_translation_policies[0],
  1256. local_vid=101,
  1257. remote_vid=201,
  1258. description='bar',
  1259. ),
  1260. VLANTranslationRule(
  1261. policy=vlan_translation_policies[1],
  1262. local_vid=102,
  1263. remote_vid=202,
  1264. description='baz',
  1265. ),
  1266. )
  1267. VLANTranslationRule.objects.bulk_create(vlan_translation_rules)
  1268. cls.create_data = [
  1269. {
  1270. 'policy': vlan_translation_policies[0].pk,
  1271. 'local_vid': 300,
  1272. 'remote_vid': 400,
  1273. },
  1274. {
  1275. 'policy': vlan_translation_policies[0].pk,
  1276. 'local_vid': 301,
  1277. 'remote_vid': 401,
  1278. },
  1279. {
  1280. 'policy': vlan_translation_policies[1].pk,
  1281. 'local_vid': 302,
  1282. 'remote_vid': 402,
  1283. },
  1284. ]
  1285. cls.bulk_update_data = {
  1286. 'policy': vlan_translation_policies[2].pk,
  1287. 'description': 'New description',
  1288. }
  1289. class ServiceTemplateTestCase(APIViewTestCases.APIViewTestCase):
  1290. model = ServiceTemplate
  1291. brief_fields = ['description', 'display', 'id', 'name', 'port_mappings', 'url']
  1292. bulk_update_data = {
  1293. 'description': 'New description',
  1294. }
  1295. graphql_base_name = 'service_template'
  1296. @classmethod
  1297. def setUpTestData(cls):
  1298. ServiceTemplate.objects.bulk_create([
  1299. ServiceTemplate(name='Service Template 1', port_mappings=['tcp/1', 'tcp/2']),
  1300. ServiceTemplate(name='Service Template 2', port_mappings=['tcp/3', 'tcp/4']),
  1301. ServiceTemplate(name='Service Template 3', port_mappings=['tcp/5', 'tcp/6']),
  1302. ])
  1303. cls.create_data = [
  1304. {
  1305. 'name': 'Service Template 4',
  1306. 'port_mappings': ['tcp/7', 'tcp/8'],
  1307. },
  1308. {
  1309. 'name': 'Service Template 5',
  1310. 'port_mappings': ['tcp/53', 'udp/53'],
  1311. },
  1312. {
  1313. 'name': 'Service Template 6',
  1314. 'port_mappings': ['tcp/11', 'tcp/12'],
  1315. },
  1316. ]
  1317. def test_graphql_port_mappings(self):
  1318. """port_mappings is exposed over GraphQL as a flat list of protocol/port strings."""
  1319. self.add_permissions('ipam.view_servicetemplate')
  1320. template = ServiceTemplate.objects.create(name='GQL Mappings', port_mappings=['tcp/80', 'udp/53'])
  1321. url = reverse('graphql')
  1322. query = f'{{ service_template(id: {template.pk}) {{ port_mappings }} }}'
  1323. response = self.client.post(url, data={'query': query}, format='json', **self.header)
  1324. self.assertHttpStatus(response, status.HTTP_200_OK)
  1325. data = json.loads(response.content)
  1326. self.assertNotIn('errors', data)
  1327. self.assertEqual(data['data']['service_template']['port_mappings'], ['tcp/80', 'udp/53'])
  1328. def test_graphql_protocol_and_port_filter(self):
  1329. """Combined protocol+port filtering works for ServiceTemplate over GraphQL."""
  1330. self.add_permissions('ipam.view_servicetemplate')
  1331. url = reverse('graphql')
  1332. query = '{ service_template_list(filters: {protocol: [TCP], port: [1]}) { name } }'
  1333. response = self.client.post(url, data={'query': query}, format='json', **self.header)
  1334. self.assertHttpStatus(response, status.HTTP_200_OK)
  1335. data = json.loads(response.content)
  1336. self.assertNotIn('errors', data)
  1337. # Only Service Template 1 exposes tcp/1.
  1338. self.assertEqual([t['name'] for t in data['data']['service_template_list']], ['Service Template 1'])
  1339. def test_graphql_port_only_filter(self):
  1340. """A port-only GraphQL filter (no protocol) works for ServiceTemplate."""
  1341. self.add_permissions('ipam.view_servicetemplate')
  1342. url = reverse('graphql')
  1343. query = '{ service_template_list(filters: {port: [3]}) { name } }'
  1344. response = self.client.post(url, data={'query': query}, format='json', **self.header)
  1345. self.assertHttpStatus(response, status.HTTP_200_OK)
  1346. data = json.loads(response.content)
  1347. self.assertNotIn('errors', data)
  1348. # Only Service Template 2 exposes port 3 (tcp/3).
  1349. self.assertEqual([t['name'] for t in data['data']['service_template_list']], ['Service Template 2'])
  1350. def test_graphql_port_mappings_filter(self):
  1351. """The whole-mapping GraphQL filter matches an exact protocol/port pair for ServiceTemplate."""
  1352. self.add_permissions('ipam.view_servicetemplate')
  1353. url = reverse('graphql')
  1354. query = '{ service_template_list(filters: {port_mappings: ["tcp/3"]}) { name } }'
  1355. response = self.client.post(url, data={'query': query}, format='json', **self.header)
  1356. self.assertHttpStatus(response, status.HTTP_200_OK)
  1357. data = json.loads(response.content)
  1358. self.assertNotIn('errors', data)
  1359. self.assertEqual([t['name'] for t in data['data']['service_template_list']], ['Service Template 2'])
  1360. # udp/3 does not exist, though tcp/3 does
  1361. query = '{ service_template_list(filters: {port_mappings: ["udp/3"]}) { name } }'
  1362. response = self.client.post(url, data={'query': query}, format='json', **self.header)
  1363. self.assertHttpStatus(response, status.HTTP_200_OK)
  1364. data = json.loads(response.content)
  1365. self.assertNotIn('errors', data)
  1366. self.assertEqual(data['data']['service_template_list'], [])
  1367. def test_graphql_port_range_lookups(self):
  1368. """The port range lookups are available on ServiceTemplate too, and stay correlated."""
  1369. self.add_permissions('ipam.view_servicetemplate')
  1370. url = reverse('graphql')
  1371. # Templates 1-3 expose tcp/1-2, tcp/3-4 and tcp/5-6 respectively
  1372. query = '{ service_template_list(filters: {port__gte: [3], port__lte: [4]}) { name } }'
  1373. response = self.client.post(url, data={'query': query}, format='json', **self.header)
  1374. self.assertHttpStatus(response, status.HTTP_200_OK)
  1375. data = json.loads(response.content)
  1376. self.assertNotIn('errors', data)
  1377. self.assertEqual([t['name'] for t in data['data']['service_template_list']], ['Service Template 2'])
  1378. # A protocol which no template exposes narrows the same range to nothing
  1379. query = '{ service_template_list(filters: {protocol: [UDP], port__gte: [3], port__lte: [4]}) { name } }'
  1380. response = self.client.post(url, data={'query': query}, format='json', **self.header)
  1381. self.assertHttpStatus(response, status.HTTP_200_OK)
  1382. data = json.loads(response.content)
  1383. self.assertNotIn('errors', data)
  1384. self.assertEqual(data['data']['service_template_list'], [])
  1385. def test_create_duplicate_mapping_rejected(self):
  1386. """A duplicate protocol/port entry is rejected with a clean 400 (not a 500)."""
  1387. self.add_permissions('ipam.add_servicetemplate')
  1388. data = {'name': 'Duplicate', 'port_mappings': ['tcp/80', 'tcp/80']}
  1389. response = self.client.post(self._get_list_url(), data, format='json', **self.header)
  1390. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  1391. def test_create_port_out_of_range_rejected(self):
  1392. """Ports outside SERVICE_PORT_MIN..SERVICE_PORT_MAX are rejected with a 400."""
  1393. self.add_permissions('ipam.add_servicetemplate')
  1394. data = {'name': 'OutOfRange', 'port_mappings': ['tcp/70000']}
  1395. response = self.client.post(self._get_list_url(), data, format='json', **self.header)
  1396. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  1397. def test_create_without_port_mappings_rejected(self):
  1398. """A service (template) must define at least one port mapping (400, not a portless object)."""
  1399. self.add_permissions('ipam.add_servicetemplate')
  1400. data = {'name': 'Portless', 'port_mappings': []}
  1401. response = self.client.post(self._get_list_url(), data, format='json', **self.header)
  1402. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  1403. def test_create_normalizes_port_mappings(self):
  1404. """Input is normalized (e.g. leading zeros stripped) into the model's canonical form."""
  1405. self.add_permissions('ipam.add_servicetemplate')
  1406. data = {'name': 'Normalized', 'port_mappings': ['tcp/443', 'tcp/080']}
  1407. response = self.client.post(self._get_list_url(), data, format='json', **self.header)
  1408. self.assertHttpStatus(response, status.HTTP_201_CREATED)
  1409. template = ServiceTemplate.objects.get(name='Normalized')
  1410. self.assertEqual(template.port_mappings, ['tcp/443', 'tcp/80'])
  1411. def test_port_mappings_read(self):
  1412. """port_mappings reads back as the stored flat list of protocol/port strings."""
  1413. self.add_permissions('ipam.view_servicetemplate')
  1414. template = ServiceTemplate.objects.create(name='Mappings', port_mappings=['tcp/443', 'tcp/80', 'udp/53'])
  1415. response = self.client.get(self._get_detail_url(template), **self.header)
  1416. self.assertHttpStatus(response, status.HTTP_200_OK)
  1417. self.assertEqual(response.data['port_mappings'], ['tcp/443', 'tcp/80', 'udp/53'])
  1418. def test_legacy_read_single_protocol(self):
  1419. """A single-protocol service reports the deprecated protocol/ports fields for compatibility."""
  1420. self.add_permissions('ipam.view_servicetemplate')
  1421. template = ServiceTemplate.objects.create(name='Legacy Single', port_mappings=['tcp/80', 'tcp/443'])
  1422. response = self.client.get(self._get_detail_url(template), **self.header)
  1423. self.assertHttpStatus(response, status.HTTP_200_OK)
  1424. # The legacy protocol field keeps the standard choice-field {value, label} read shape.
  1425. self.assertEqual(response.data['protocol'], {'value': 'tcp', 'label': 'TCP'})
  1426. self.assertEqual(response.data['ports'], [80, 443])
  1427. self.assertEqual(response.data['port_mappings'], ['tcp/80', 'tcp/443'])
  1428. def test_legacy_read_multiple_protocols_null(self):
  1429. """A multi-protocol service can't be expressed in the old format, so protocol/ports are null."""
  1430. self.add_permissions('ipam.view_servicetemplate')
  1431. template = ServiceTemplate.objects.create(name='Legacy Multi', port_mappings=['tcp/53', 'udp/53'])
  1432. response = self.client.get(self._get_detail_url(template), **self.header)
  1433. self.assertHttpStatus(response, status.HTTP_200_OK)
  1434. self.assertIsNone(response.data['protocol'])
  1435. self.assertIsNone(response.data['ports'])
  1436. self.assertEqual(response.data['port_mappings'], ['tcp/53', 'udp/53'])
  1437. def test_legacy_read_empty_distinct_from_multiple(self):
  1438. """An empty service is distinguishable from a multi-protocol one: ports=[] vs ports=null."""
  1439. self.add_permissions('ipam.view_servicetemplate')
  1440. # A mapping-less template is normally prevented by validation, but can exist via migrated data;
  1441. # objects.create() bypasses full_clean() so we can exercise the read path here.
  1442. template = ServiceTemplate.objects.create(name='Legacy Empty', port_mappings=[])
  1443. response = self.client.get(self._get_detail_url(template), **self.header)
  1444. self.assertHttpStatus(response, status.HTTP_200_OK)
  1445. self.assertIsNone(response.data['protocol'])
  1446. self.assertEqual(response.data['ports'], [])
  1447. self.assertEqual(response.data['port_mappings'], [])
  1448. def test_create_via_legacy_format(self):
  1449. """The deprecated protocol/ports format is accepted on write and translated to port_mappings."""
  1450. self.add_permissions('ipam.add_servicetemplate')
  1451. data = {'name': 'Legacy Create', 'protocol': 'tcp', 'ports': [80, 443]}
  1452. response = self.client.post(self._get_list_url(), data, format='json', **self.header)
  1453. self.assertHttpStatus(response, status.HTTP_201_CREATED)
  1454. template = ServiceTemplate.objects.get(name='Legacy Create')
  1455. self.assertEqual(template.port_mappings, ['tcp/80', 'tcp/443'])
  1456. def test_legacy_empty_ports_reports_at_least_one(self):
  1457. """
  1458. A legacy write with an explicitly-empty ports list (allowed by the old API) is rejected with the
  1459. at-least-one-mapping message keyed to ports, not the misleading "both are required" error.
  1460. """
  1461. self.add_permissions('ipam.add_servicetemplate')
  1462. data = {'name': 'Legacy Empty', 'protocol': 'tcp', 'ports': []}
  1463. response = self.client.post(self._get_list_url(), data, format='json', **self.header)
  1464. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  1465. self.assertIn('ports', response.data)
  1466. def test_create_port_mappings_case_insensitive(self):
  1467. """port_mappings accepts protocols in any case (e.g. 'TCP/80') and stores the canonical value."""
  1468. self.add_permissions('ipam.add_servicetemplate')
  1469. data = {'name': 'Case Insensitive', 'port_mappings': ['TCP/80', 'UDP/53']}
  1470. response = self.client.post(self._get_list_url(), data, format='json', **self.header)
  1471. self.assertHttpStatus(response, status.HTTP_201_CREATED)
  1472. template = ServiceTemplate.objects.get(name='Case Insensitive')
  1473. self.assertEqual(template.port_mappings, ['tcp/80', 'udp/53'])
  1474. def test_both_formats_rejected(self):
  1475. """Supplying both port_mappings and the legacy protocol/ports is ambiguous and must 400."""
  1476. self.add_permissions('ipam.add_servicetemplate')
  1477. # port_mappings is a well-formed flat list so it passes field-level parsing and actually reaches
  1478. # the validate() mutual-exclusion guard (rather than 400ing on a malformed value first).
  1479. data = {
  1480. 'name': 'Both Formats',
  1481. 'port_mappings': ['udp/53'],
  1482. 'protocol': 'tcp',
  1483. 'ports': [80],
  1484. }
  1485. response = self.client.post(self._get_list_url(), data, format='json', **self.header)
  1486. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  1487. self.assertFalse(ServiceTemplate.objects.filter(name='Both Formats').exists())
  1488. def test_create_legacy_port_out_of_range_rejected(self):
  1489. """A legacy ports value outside the permitted range is rejected with a 400 (not a 500)."""
  1490. self.add_permissions('ipam.add_servicetemplate')
  1491. data = {'name': 'Legacy OOR', 'protocol': 'tcp', 'ports': [70000]}
  1492. response = self.client.post(self._get_list_url(), data, format='json', **self.header)
  1493. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  1494. def test_create_legacy_protocol_without_ports_rejected(self):
  1495. """One half of the legacy pair is ambiguous and must 400, not silently drop the input."""
  1496. self.add_permissions('ipam.add_servicetemplate')
  1497. data = {'name': 'Legacy Half', 'protocol': 'tcp'}
  1498. response = self.client.post(self._get_list_url(), data, format='json', **self.header)
  1499. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  1500. def test_update_legacy_ports_only(self):
  1501. """A partial update supplying only legacy 'ports' keeps the existing single protocol."""
  1502. self.add_permissions('ipam.change_servicetemplate')
  1503. template = ServiceTemplate.objects.create(name='Legacy Patch', port_mappings=['tcp/80'])
  1504. response = self.client.patch(
  1505. self._get_detail_url(template), {'ports': [8080]}, format='json', **self.header
  1506. )
  1507. self.assertHttpStatus(response, status.HTTP_200_OK)
  1508. template.refresh_from_db()
  1509. self.assertEqual(template.port_mappings, ['tcp/8080'])
  1510. def test_update_legacy_protocol_only(self):
  1511. """A partial update supplying only legacy 'protocol' keeps the existing ports."""
  1512. self.add_permissions('ipam.change_servicetemplate')
  1513. template = ServiceTemplate.objects.create(name='Legacy Patch', port_mappings=['tcp/80', 'tcp/443'])
  1514. response = self.client.patch(
  1515. self._get_detail_url(template), {'protocol': 'udp'}, format='json', **self.header
  1516. )
  1517. self.assertHttpStatus(response, status.HTTP_200_OK)
  1518. template.refresh_from_db()
  1519. self.assertEqual(template.port_mappings, ['udp/80', 'udp/443'])
  1520. def test_update_legacy_single_field_multiprotocol_rejected(self):
  1521. """A single legacy field can't patch a multi-protocol service (no single-protocol form)."""
  1522. self.add_permissions('ipam.change_servicetemplate')
  1523. template = ServiceTemplate.objects.create(name='Legacy Patch', port_mappings=['tcp/80', 'udp/53'])
  1524. response = self.client.patch(
  1525. self._get_detail_url(template), {'ports': [8080]}, format='json', **self.header
  1526. )
  1527. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  1528. template.refresh_from_db()
  1529. self.assertEqual(template.port_mappings, ['tcp/80', 'udp/53'])
  1530. def test_read_malformed_port_mapping_degrades(self):
  1531. """A malformed stored mapping (validation bypassed) must degrade on API read, not raise a 500."""
  1532. self.add_permissions('ipam.view_servicetemplate')
  1533. # objects.create bypasses full_clean, simulating a raw-DB/plugin write of a non-numeric port
  1534. template = ServiceTemplate.objects.create(name='Malformed', port_mappings=['tcp/80', 'tcp/abc'])
  1535. response = self.client.get(self._get_detail_url(template), **self.header)
  1536. self.assertHttpStatus(response, status.HTTP_200_OK)
  1537. # port_mappings echoes the stored values verbatim (no reformatting). The legacy view can't
  1538. # faithfully represent a mapping that fails integer coercion, so rather than silently returning
  1539. # a subset it reports ports=null — the same "not representable" signal used for multi-protocol.
  1540. self.assertEqual(response.data['port_mappings'], ['tcp/80', 'tcp/abc'])
  1541. self.assertIsNone(response.data['ports'])
  1542. self.assertIsNone(response.data['protocol'])
  1543. class ServiceTestCase(APIViewTestCases.APIViewTestCase):
  1544. model = Service
  1545. brief_fields = ['description', 'display', 'id', 'name', 'port_mappings', 'url']
  1546. bulk_update_data = {
  1547. 'description': 'New description',
  1548. }
  1549. graphql_base_name = 'service'
  1550. @classmethod
  1551. def setUpTestData(cls):
  1552. site = Site.objects.create(name='Site 1', slug='site-1')
  1553. manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
  1554. devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1')
  1555. role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
  1556. devices = (
  1557. Device(name='Device 1', site=site, device_type=devicetype, role=role),
  1558. Device(name='Device 2', site=site, device_type=devicetype, role=role),
  1559. )
  1560. Device.objects.bulk_create(devices)
  1561. Service.objects.bulk_create([
  1562. Service(parent=devices[0], name='Service 1', port_mappings=['tcp/1']),
  1563. Service(parent=devices[0], name='Service 2', port_mappings=['tcp/2']),
  1564. Service(parent=devices[0], name='Service 3', port_mappings=['tcp/3']),
  1565. ])
  1566. cls.create_data = [
  1567. {
  1568. 'parent_object_id': devices[1].pk,
  1569. 'parent_object_type': 'dcim.device',
  1570. 'name': 'Service 4',
  1571. 'port_mappings': ['tcp/4'],
  1572. },
  1573. {
  1574. 'parent_object_id': devices[1].pk,
  1575. 'parent_object_type': 'dcim.device',
  1576. 'name': 'dns',
  1577. 'port_mappings': ['tcp/53', 'udp/53'],
  1578. },
  1579. {
  1580. 'parent_object_id': devices[1].pk,
  1581. 'parent_object_type': 'dcim.device',
  1582. 'name': 'Service 6',
  1583. 'port_mappings': ['tcp/6'],
  1584. },
  1585. ]
  1586. def test_graphql_protocol_and_port_filter(self):
  1587. """Combined protocol + port filtering works over GraphQL (port mappings live in an array)."""
  1588. self.add_permissions('ipam.view_service')
  1589. url = reverse('graphql')
  1590. query = '{ service_list(filters: {protocol: [TCP], port: [1]}) { id name } }'
  1591. response = self.client.post(url, data={'query': query}, format='json', **self.header)
  1592. self.assertHttpStatus(response, status.HTTP_200_OK)
  1593. data = json.loads(response.content)
  1594. self.assertNotIn('errors', data)
  1595. self.assertEqual(len(data['data']['service_list']), 1)
  1596. self.assertEqual(data['data']['service_list'][0]['name'], 'Service 1')
  1597. def test_graphql_protocol_and_port_filter_multiprotocol(self):
  1598. """
  1599. A combined protocol+port filter must match a single mapping, not protocol and port matched
  1600. independently across different mappings on the same object (GraphQL parity with the FilterSet).
  1601. """
  1602. self.add_permissions('ipam.view_service')
  1603. device = Device.objects.first()
  1604. Service.objects.create(parent=device, name='dns-multi', port_mappings=['tcp/8080', 'udp/53'])
  1605. url = reverse('graphql')
  1606. # tcp/8080 exists on the service -> matches
  1607. query = '{ service_list(filters: {protocol: [TCP], port: [8080]}) { name } }'
  1608. response = self.client.post(url, data={'query': query}, format='json', **self.header)
  1609. self.assertHttpStatus(response, status.HTTP_200_OK)
  1610. data = json.loads(response.content)
  1611. self.assertNotIn('errors', data)
  1612. self.assertEqual([s['name'] for s in data['data']['service_list']], ['dns-multi'])
  1613. # udp/8080 does not exist, even though the service has udp (on 53) and 8080 (on tcp)
  1614. query = '{ service_list(filters: {protocol: [UDP], port: [8080]}) { name } }'
  1615. response = self.client.post(url, data={'query': query}, format='json', **self.header)
  1616. self.assertHttpStatus(response, status.HTTP_200_OK)
  1617. data = json.loads(response.content)
  1618. self.assertNotIn('errors', data)
  1619. self.assertEqual(data['data']['service_list'], [])
  1620. def test_graphql_port_mappings(self):
  1621. """port_mappings is exposed over GraphQL as a flat list of protocol/port strings."""
  1622. self.add_permissions('ipam.view_service')
  1623. device = Device.objects.first()
  1624. service = Service.objects.create(parent=device, name='GQL Mappings', port_mappings=['tcp/80', 'udp/53'])
  1625. url = reverse('graphql')
  1626. query = f'{{ service(id: {service.pk}) {{ port_mappings }} }}'
  1627. response = self.client.post(url, data={'query': query}, format='json', **self.header)
  1628. self.assertHttpStatus(response, status.HTTP_200_OK)
  1629. data = json.loads(response.content)
  1630. self.assertNotIn('errors', data)
  1631. self.assertEqual(data['data']['service']['port_mappings'], ['tcp/80', 'udp/53'])
  1632. def test_graphql_port_only_filter(self):
  1633. """A port-only GraphQL filter (no protocol) matches the port across any protocol."""
  1634. self.add_permissions('ipam.view_service')
  1635. device = Device.objects.first()
  1636. Service.objects.create(parent=device, name='udp-on-1', port_mappings=['udp/1'])
  1637. url = reverse('graphql')
  1638. query = '{ service_list(filters: {port: [1]}) { name } }'
  1639. response = self.client.post(url, data={'query': query}, format='json', **self.header)
  1640. self.assertHttpStatus(response, status.HTTP_200_OK)
  1641. data = json.loads(response.content)
  1642. self.assertNotIn('errors', data)
  1643. # Service 1 (tcp/1) and the new udp-on-1 both expose port 1, on different protocols.
  1644. self.assertEqual({s['name'] for s in data['data']['service_list']}, {'Service 1', 'udp-on-1'})
  1645. def test_graphql_port_mappings_filter(self):
  1646. """The whole-mapping GraphQL filter matches an exact protocol/port pair, OR'd across values."""
  1647. self.add_permissions('ipam.view_service')
  1648. device = Device.objects.first()
  1649. Service.objects.create(parent=device, name='udp-on-1', port_mappings=['udp/1'])
  1650. url = reverse('graphql')
  1651. # tcp/1 must not match the udp-only service, even though both expose port 1
  1652. query = '{ service_list(filters: {port_mappings: ["tcp/1"]}) { name } }'
  1653. response = self.client.post(url, data={'query': query}, format='json', **self.header)
  1654. self.assertHttpStatus(response, status.HTTP_200_OK)
  1655. data = json.loads(response.content)
  1656. self.assertNotIn('errors', data)
  1657. self.assertEqual([s['name'] for s in data['data']['service_list']], ['Service 1'])
  1658. # Multiple values are OR'd, and input is normalized ('UDP/001' -> 'udp/1')
  1659. query = '{ service_list(filters: {port_mappings: ["tcp/1", "UDP/001"]}) { name } }'
  1660. response = self.client.post(url, data={'query': query}, format='json', **self.header)
  1661. self.assertHttpStatus(response, status.HTTP_200_OK)
  1662. data = json.loads(response.content)
  1663. self.assertNotIn('errors', data)
  1664. self.assertEqual({s['name'] for s in data['data']['service_list']}, {'Service 1', 'udp-on-1'})
  1665. def test_graphql_protocol_only_filter(self):
  1666. """A protocol-only GraphQL filter matches services exposing that protocol on any port."""
  1667. self.add_permissions('ipam.view_service')
  1668. device = Device.objects.first()
  1669. Service.objects.create(parent=device, name='udp-svc', port_mappings=['udp/9'])
  1670. url = reverse('graphql')
  1671. query = '{ service_list(filters: {protocol: [UDP]}) { name } }'
  1672. response = self.client.post(url, data={'query': query}, format='json', **self.header)
  1673. self.assertHttpStatus(response, status.HTTP_200_OK)
  1674. data = json.loads(response.content)
  1675. self.assertNotIn('errors', data)
  1676. # Only the udp service matches; the seeded Service 1-3 are all tcp.
  1677. self.assertEqual([s['name'] for s in data['data']['service_list']], ['udp-svc'])
  1678. def test_graphql_port_range_lookups(self):
  1679. """The port__gt/gte/lt/lte GraphQL lookups mirror their identically-named REST counterparts."""
  1680. self.add_permissions('ipam.view_service')
  1681. url = reverse('graphql')
  1682. # Seeded services expose tcp/1, tcp/2 and tcp/3 respectively
  1683. for filters, expected in (
  1684. ('{port__gt: [2]}', {'Service 3'}),
  1685. ('{port__gte: [2]}', {'Service 2', 'Service 3'}),
  1686. ('{port__lt: [2]}', {'Service 1'}),
  1687. ('{port__lte: [2]}', {'Service 1', 'Service 2'}),
  1688. ):
  1689. query = f'{{ service_list(filters: {filters}) {{ name }} }}'
  1690. response = self.client.post(url, data={'query': query}, format='json', **self.header)
  1691. self.assertHttpStatus(response, status.HTTP_200_OK)
  1692. data = json.loads(response.content)
  1693. self.assertNotIn('errors', data)
  1694. self.assertEqual({s['name'] for s in data['data']['service_list']}, expected, msg=filters)
  1695. def test_graphql_port_range_bounds_correlated(self):
  1696. """
  1697. Combined range bounds must be satisfied by a *single* mapping, so a service straddling the range
  1698. without any port inside it does not match (GraphQL parity with the FilterSet).
  1699. """
  1700. self.add_permissions('ipam.view_service')
  1701. device = Device.objects.first()
  1702. Service.objects.create(parent=device, name='straddles', port_mappings=['tcp/500', 'tcp/5000'])
  1703. Service.objects.create(parent=device, name='inside', port_mappings=['tcp/1500'])
  1704. url = reverse('graphql')
  1705. query = '{ service_list(filters: {port__gte: [1000], port__lte: [2000]}) { name } }'
  1706. response = self.client.post(url, data={'query': query}, format='json', **self.header)
  1707. self.assertHttpStatus(response, status.HTTP_200_OK)
  1708. data = json.loads(response.content)
  1709. self.assertNotIn('errors', data)
  1710. self.assertEqual([s['name'] for s in data['data']['service_list']], ['inside'])
  1711. def test_graphql_protocol_and_port_range_correlated(self):
  1712. """A protocol combined with a range lookup must also be satisfied by a single mapping."""
  1713. self.add_permissions('ipam.view_service')
  1714. device = Device.objects.first()
  1715. Service.objects.create(parent=device, name='mixed', port_mappings=['tcp/80', 'udp/9999'])
  1716. Service.objects.create(parent=device, name='tcp-high', port_mappings=['tcp/9999'])
  1717. url = reverse('graphql')
  1718. # 'mixed' has a tcp mapping and a mapping above 1000, but no tcp mapping above 1000
  1719. query = '{ service_list(filters: {protocol: [TCP], port__gt: [1000]}) { name } }'
  1720. response = self.client.post(url, data={'query': query}, format='json', **self.header)
  1721. self.assertHttpStatus(response, status.HTTP_200_OK)
  1722. data = json.loads(response.content)
  1723. self.assertNotIn('errors', data)
  1724. self.assertEqual([s['name'] for s in data['data']['service_list']], ['tcp-high'])
  1725. def test_port_mapping_prefix_branch(self):
  1726. """
  1727. The nested-relation (prefix) branch of the shared port filter resolves matches through a
  1728. relation. No GraphQL type currently exposes a nested Service filter, so exercise the helper
  1729. directly via the IPAddress -> services reverse relation.
  1730. """
  1731. from ipam.graphql.filters import _port_mapping_prefix_q
  1732. device = Device.objects.first()
  1733. service = Service.objects.create(parent=device, name='svc-with-ip', port_mappings=['tcp/1'])
  1734. ip = IPAddress.objects.create(address='192.0.2.1/32')
  1735. service.ipaddresses.add(ip)
  1736. match = _port_mapping_prefix_q(Service, ['tcp'], [('exact', [1])], 'services__')
  1737. self.assertIn(ip, IPAddress.objects.filter(match))
  1738. miss = _port_mapping_prefix_q(Service, ['tcp'], [('exact', [999])], 'services__')
  1739. self.assertNotIn(ip, IPAddress.objects.filter(miss))
  1740. def test_update_full_body_roundtrip(self):
  1741. """
  1742. A full-object round-trip (GET then PUT of the same body, including the legacy protocol/ports the
  1743. read emitted alongside port_mappings) succeeds; only a genuine conflict is rejected.
  1744. """
  1745. self.add_permissions('ipam.view_service', 'ipam.change_service')
  1746. service = Service.objects.get(name='Service 1') # tcp/1
  1747. read = self.client.get(self._get_detail_url(service), **self.header).data
  1748. put_data = {
  1749. 'parent_object_type': 'dcim.device',
  1750. 'parent_object_id': service.parent_object_id,
  1751. 'name': service.name,
  1752. 'port_mappings': read['port_mappings'],
  1753. # The legacy protocol field reads as {value, label}; on write NetBox choice fields take the
  1754. # raw value, so a well-behaved round-trip resubmits read['protocol']['value'].
  1755. 'protocol': read['protocol']['value'],
  1756. 'ports': read['ports'],
  1757. }
  1758. response = self.client.put(self._get_detail_url(service), put_data, format='json', **self.header)
  1759. self.assertHttpStatus(response, status.HTTP_200_OK)
  1760. # A legacy field that disagrees with port_mappings is still rejected as a conflict.
  1761. put_data['protocol'] = 'udp'
  1762. response = self.client.put(self._get_detail_url(service), put_data, format='json', **self.header)
  1763. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  1764. def test_legacy_read_single_protocol(self):
  1765. """A single-protocol service reports the deprecated protocol/ports fields for compatibility."""
  1766. self.add_permissions('ipam.view_service')
  1767. service = Service.objects.get(name='Service 1') # port_mappings=['tcp/1']
  1768. response = self.client.get(self._get_detail_url(service), **self.header)
  1769. self.assertHttpStatus(response, status.HTTP_200_OK)
  1770. # The legacy protocol field keeps the standard choice-field {value, label} read shape.
  1771. self.assertEqual(response.data['protocol'], {'value': 'tcp', 'label': 'TCP'})
  1772. self.assertEqual(response.data['ports'], [1])
  1773. def test_create_via_legacy_format(self):
  1774. """The deprecated protocol/ports format is accepted on write and translated to port_mappings."""
  1775. self.add_permissions('ipam.add_service')
  1776. device = Device.objects.first()
  1777. data = {
  1778. 'parent_object_type': 'dcim.device',
  1779. 'parent_object_id': device.pk,
  1780. 'name': 'Legacy Service',
  1781. 'protocol': 'udp',
  1782. 'ports': [53, 67],
  1783. }
  1784. response = self.client.post(self._get_list_url(), data, format='json', **self.header)
  1785. self.assertHttpStatus(response, status.HTTP_201_CREATED)
  1786. service = Service.objects.get(name='Legacy Service')
  1787. self.assertEqual(service.port_mappings, ['udp/53', 'udp/67'])