test_api.py 85 KB

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