test_api.py 102 KB

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