test_api.py 83 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237
  1. import datetime
  2. import hashlib
  3. import io
  4. import json
  5. from contextlib import contextmanager
  6. from unittest.mock import MagicMock, patch
  7. from django.contrib.contenttypes.models import ContentType
  8. from django.core.files.uploadedfile import SimpleUploadedFile
  9. from django.db import IntegrityError
  10. from django.urls import reverse
  11. from django.utils.timezone import make_aware, now
  12. from rest_framework import status
  13. from core.choices import ManagedFileRootPathChoices
  14. from core.events import *
  15. from core.models import DataFile, DataSource, ObjectType
  16. from dcim.models import Device, DeviceRole, DeviceType, Location, Manufacturer, Rack, RackRole, Site
  17. from extras.choices import *
  18. from extras.models import *
  19. from extras.scripts import BooleanVar, IntegerVar, StringVar
  20. from extras.scripts import Script as PythonClass
  21. from users.constants import TOKEN_PREFIX
  22. from users.models import Group, ObjectPermission, Token, User
  23. from utilities.tables import get_table_for_model
  24. from utilities.testing import APITestCase, APIViewTestCases
  25. class AppTestCase(APITestCase):
  26. def test_root(self):
  27. url = reverse('extras-api:api-root')
  28. response = self.client.get('{}?format=api'.format(url), **self.header)
  29. self.assertEqual(response.status_code, 200)
  30. class WebhookTestCase(APIViewTestCases.APIViewTestCase):
  31. model = Webhook
  32. brief_fields = ['description', 'display', 'id', 'name', 'url']
  33. create_data = [
  34. {
  35. 'name': 'Webhook 4',
  36. 'payload_url': 'http://example.com/?4',
  37. },
  38. {
  39. 'name': 'Webhook 5',
  40. 'payload_url': 'http://example.com/?5',
  41. },
  42. {
  43. 'name': 'Webhook 6',
  44. 'payload_url': 'http://example.com/?6',
  45. },
  46. ]
  47. bulk_update_data = {
  48. 'description': 'New description',
  49. 'ssl_verification': False,
  50. }
  51. @classmethod
  52. def setUpTestData(cls):
  53. webhooks = (
  54. Webhook(
  55. name='Webhook 1',
  56. payload_url='http://example.com/?1',
  57. ),
  58. Webhook(
  59. name='Webhook 2',
  60. payload_url='http://example.com/?1',
  61. ),
  62. Webhook(
  63. name='Webhook 3',
  64. payload_url='http://example.com/?1',
  65. ),
  66. )
  67. Webhook.objects.bulk_create(webhooks)
  68. class EventRuleTestCase(APIViewTestCases.APIViewTestCase):
  69. model = EventRule
  70. brief_fields = ['description', 'display', 'id', 'name', 'url']
  71. bulk_update_data = {
  72. 'enabled': False,
  73. 'description': 'New description',
  74. }
  75. update_data = {
  76. 'name': 'Event Rule X',
  77. 'enabled': False,
  78. 'description': 'New description',
  79. }
  80. @classmethod
  81. def setUpTestData(cls):
  82. webhooks = (
  83. Webhook(
  84. name='Webhook 1',
  85. payload_url='http://example.com/?1',
  86. ),
  87. Webhook(
  88. name='Webhook 2',
  89. payload_url='http://example.com/?1',
  90. ),
  91. Webhook(
  92. name='Webhook 3',
  93. payload_url='http://example.com/?1',
  94. ),
  95. Webhook(
  96. name='Webhook 4',
  97. payload_url='http://example.com/?1',
  98. ),
  99. Webhook(
  100. name='Webhook 5',
  101. payload_url='http://example.com/?1',
  102. ),
  103. Webhook(
  104. name='Webhook 6',
  105. payload_url='http://example.com/?1',
  106. ),
  107. )
  108. Webhook.objects.bulk_create(webhooks)
  109. event_rules = (
  110. EventRule(name='EventRule 1', event_types=[OBJECT_CREATED], action_object=webhooks[0]),
  111. EventRule(name='EventRule 2', event_types=[OBJECT_CREATED], action_object=webhooks[1]),
  112. EventRule(name='EventRule 3', event_types=[OBJECT_CREATED], action_object=webhooks[2]),
  113. )
  114. EventRule.objects.bulk_create(event_rules)
  115. cls.create_data = [
  116. {
  117. 'name': 'EventRule 4',
  118. 'object_types': ['dcim.device', 'dcim.devicetype'],
  119. 'event_types': [OBJECT_CREATED],
  120. 'action_type': EventRuleActionChoices.WEBHOOK,
  121. 'action_object_type': 'extras.webhook',
  122. 'action_object_id': webhooks[3].pk,
  123. },
  124. {
  125. 'name': 'EventRule 5',
  126. 'object_types': ['dcim.device', 'dcim.devicetype'],
  127. 'event_types': [OBJECT_CREATED],
  128. 'action_type': EventRuleActionChoices.WEBHOOK,
  129. 'action_object_type': 'extras.webhook',
  130. 'action_object_id': webhooks[4].pk,
  131. },
  132. {
  133. 'name': 'EventRule 6',
  134. 'object_types': ['dcim.device', 'dcim.devicetype'],
  135. 'event_types': [OBJECT_CREATED],
  136. 'action_type': EventRuleActionChoices.WEBHOOK,
  137. 'action_object_type': 'extras.webhook',
  138. 'action_object_id': webhooks[5].pk,
  139. },
  140. ]
  141. class CustomFieldTestCase(APIViewTestCases.APIViewTestCase):
  142. model = CustomField
  143. brief_fields = ['description', 'display', 'id', 'name', 'url']
  144. create_data = [
  145. {
  146. 'object_types': ['dcim.site'],
  147. 'name': 'cf4',
  148. 'type': 'date',
  149. },
  150. {
  151. 'object_types': ['dcim.site'],
  152. 'name': 'cf5',
  153. 'type': 'url',
  154. },
  155. {
  156. 'object_types': ['dcim.site'],
  157. 'name': 'cf6',
  158. 'type': 'text',
  159. },
  160. ]
  161. bulk_update_data = {
  162. 'description': 'New description',
  163. }
  164. update_data = {
  165. 'object_types': ['dcim.device'],
  166. 'name': 'New_Name',
  167. 'description': 'New description',
  168. }
  169. @classmethod
  170. def setUpTestData(cls):
  171. site_ct = ObjectType.objects.get_for_model(Site)
  172. custom_fields = (
  173. CustomField(
  174. name='cf1',
  175. type='text'
  176. ),
  177. CustomField(
  178. name='cf2',
  179. type='integer'
  180. ),
  181. CustomField(
  182. name='cf3',
  183. type='boolean'
  184. ),
  185. )
  186. CustomField.objects.bulk_create(custom_fields)
  187. for cf in custom_fields:
  188. cf.object_types.add(site_ct)
  189. class CustomFieldChoiceSetTestCase(APIViewTestCases.APIViewTestCase):
  190. model = CustomFieldChoiceSet
  191. brief_fields = ['choices_count', 'description', 'display', 'id', 'name', 'url']
  192. create_data = [
  193. {
  194. 'name': 'Choice Set 4',
  195. 'extra_choices': [
  196. ['4A', 'Choice 1'],
  197. ['4B', 'Choice 2'],
  198. ['4C', 'Choice 3'],
  199. ],
  200. 'choice_colors': {
  201. '4A': 'red',
  202. '4B': 'green',
  203. },
  204. },
  205. {
  206. 'name': 'Choice Set 5',
  207. 'extra_choices': [
  208. ['5A', 'Choice 1'],
  209. ['5B', 'Choice 2'],
  210. ['5C', 'Choice 3'],
  211. ],
  212. 'choice_colors': {
  213. '5C': 'blue',
  214. },
  215. },
  216. {
  217. 'name': 'Choice Set 6',
  218. 'extra_choices': [
  219. ['6A', 'Choice 1'],
  220. ['6B', 'Choice 2'],
  221. ['6C', 'Choice 3'],
  222. ],
  223. },
  224. ]
  225. bulk_update_data = {
  226. 'description': 'New description',
  227. }
  228. update_data = {
  229. 'name': 'Choice Set X',
  230. 'extra_choices': [
  231. ['X1', 'Choice 1'],
  232. ['X2', 'Choice 2'],
  233. ['X3', 'Choice 3'],
  234. ],
  235. 'choice_colors': {
  236. 'X1': 'red',
  237. 'X3': 'green',
  238. },
  239. 'description': 'New description',
  240. }
  241. @classmethod
  242. def setUpTestData(cls):
  243. choice_sets = (
  244. CustomFieldChoiceSet(
  245. name='Choice Set 1',
  246. extra_choices=[['1A', '1A'], ['1B', '1B'], ['1C', '1C'], ['1D', '1D'], ['1E', '1E']],
  247. ),
  248. CustomFieldChoiceSet(
  249. name='Choice Set 2',
  250. extra_choices=[['2A', '2A'], ['2B', '2B'], ['2C', '2C'], ['2D', '2D'], ['2E', '2E']],
  251. ),
  252. CustomFieldChoiceSet(
  253. name='Choice Set 3',
  254. extra_choices=[['3A', '3A'], ['3B', '3B'], ['3C', '3C'], ['3D', '3D'], ['3E', '3E']],
  255. ),
  256. )
  257. CustomFieldChoiceSet.objects.bulk_create(choice_sets)
  258. def test_invalid_choice_items(self):
  259. """
  260. Attempting to define each choice as a single-item list should return a 400 error.
  261. """
  262. self.add_permissions('extras.add_customfieldchoiceset')
  263. data = {
  264. "name": "test",
  265. "extra_choices": [
  266. ["choice1"],
  267. ["choice2"],
  268. ["choice3"],
  269. ]
  270. }
  271. response = self.client.post(self._get_list_url(), data, format='json', **self.header)
  272. self.assertEqual(response.status_code, 400)
  273. def test_invalid_choice_color(self):
  274. self.add_permissions('extras.add_customfieldchoiceset')
  275. data = {
  276. 'name': 'test',
  277. 'extra_choices': [
  278. ['choice1', 'Choice 1'],
  279. ['choice2', 'Choice 2'],
  280. ],
  281. 'choice_colors': {
  282. 'choice1': 'magenta',
  283. },
  284. }
  285. response = self.client.post(self._get_list_url(), data, format='json', **self.header)
  286. self.assertEqual(response.status_code, 400)
  287. def test_invalid_choice_color_reference(self):
  288. self.add_permissions('extras.add_customfieldchoiceset')
  289. data = {
  290. 'name': 'test',
  291. 'extra_choices': [
  292. ['choice1', 'Choice 1'],
  293. ['choice2', 'Choice 2'],
  294. ],
  295. 'choice_colors': {
  296. 'choice3': 'red',
  297. },
  298. }
  299. response = self.client.post(self._get_list_url(), data, format='json', **self.header)
  300. self.assertEqual(response.status_code, 400)
  301. def test_graphql_filter_extra_choices(self):
  302. """Filter choice sets by choice value and by number of choices."""
  303. self.add_permissions('extras.view_customfieldchoiceset')
  304. # '1A' appears here only as a label, so it must not match contains
  305. CustomFieldChoiceSet.objects.create(
  306. name='Choice Set Labels',
  307. extra_choices=[['sel1', 'Selection 1'], ['other', '1A']],
  308. )
  309. def run(lookup):
  310. query = '{ custom_field_choice_set_list(filters: {extra_choices: ' + lookup + '}) { name } }'
  311. response = self.client.post(reverse('graphql'), data={'query': query}, format='json', **self.header)
  312. self.assertHttpStatus(response, status.HTTP_200_OK)
  313. data = response.json()
  314. self.assertNotIn('errors', data)
  315. return sorted(row['name'] for row in data['data']['custom_field_choice_set_list'])
  316. # contains matches choice values only, never labels
  317. self.assertEqual(run('{contains: "1A"}'), ['Choice Set 1'])
  318. self.assertEqual(run('{contains: "sel1"}'), ['Choice Set Labels'])
  319. self.assertEqual(run('{contains: "Selection 1"}'), [])
  320. # length is the number of [value, label] pairs
  321. self.assertEqual(run('{length: 2}'), ['Choice Set Labels'])
  322. self.assertEqual(run('{length: 1}'), [])
  323. def test_graphql_filter_extra_choices_rejects_array_operands(self):
  324. """The legacy flat and nested array operand shapes fail schema validation."""
  325. self.add_permissions('extras.view_customfieldchoiceset')
  326. def run_invalid(lookup):
  327. query = '{ custom_field_choice_set_list(filters: {extra_choices: ' + lookup + '}) { name } }'
  328. response = self.client.post(reverse('graphql'), data={'query': query}, format='json', **self.header)
  329. self.assertHttpStatus(response, status.HTTP_200_OK)
  330. self.assertIn('errors', response.json())
  331. # shapes advertised or attempted before #22324
  332. run_invalid('{contains: ["1A"]}')
  333. run_invalid('{contains: [["1A", "Choice 1A"]]}')
  334. def test_graphql_filter_extra_choices_via_relation(self):
  335. """The extra_choices lookup composes through the choice_set relation prefix."""
  336. self.add_permissions('extras.view_customfield')
  337. for choice_set in CustomFieldChoiceSet.objects.filter(name__in=['Choice Set 1', 'Choice Set 2']):
  338. CustomField.objects.create(
  339. name=f'cf_{choice_set.name[-1]}',
  340. type=CustomFieldTypeChoices.TYPE_SELECT,
  341. choice_set=choice_set,
  342. )
  343. query = '{ custom_field_list(filters: {choice_set: {extra_choices: {contains: "1A"}}}) { name } }'
  344. response = self.client.post(reverse('graphql'), data={'query': query}, format='json', **self.header)
  345. self.assertHttpStatus(response, status.HTTP_200_OK)
  346. data = response.json()
  347. self.assertNotIn('errors', data)
  348. self.assertEqual([row['name'] for row in data['data']['custom_field_list']], ['cf_1'])
  349. class CustomLinkTestCase(APIViewTestCases.APIViewTestCase):
  350. model = CustomLink
  351. brief_fields = ['display', 'id', 'name', 'url']
  352. create_data = [
  353. {
  354. 'object_types': ['dcim.site'],
  355. 'name': 'Custom Link 4',
  356. 'enabled': True,
  357. 'link_text': 'Link 4',
  358. 'link_url': 'http://example.com/?4',
  359. },
  360. {
  361. 'object_types': ['dcim.site'],
  362. 'name': 'Custom Link 5',
  363. 'enabled': True,
  364. 'link_text': 'Link 5',
  365. 'link_url': 'http://example.com/?5',
  366. },
  367. {
  368. 'object_types': ['dcim.site'],
  369. 'name': 'Custom Link 6',
  370. 'enabled': False,
  371. 'link_text': 'Link 6',
  372. 'link_url': 'http://example.com/?6',
  373. },
  374. ]
  375. bulk_update_data = {
  376. 'new_window': True,
  377. 'enabled': False,
  378. }
  379. @classmethod
  380. def setUpTestData(cls):
  381. site_type = ObjectType.objects.get_for_model(Site)
  382. custom_links = (
  383. CustomLink(
  384. name='Custom Link 1',
  385. enabled=True,
  386. link_text='Link 1',
  387. link_url='http://example.com/?1',
  388. ),
  389. CustomLink(
  390. name='Custom Link 2',
  391. enabled=True,
  392. link_text='Link 2',
  393. link_url='http://example.com/?2',
  394. ),
  395. CustomLink(
  396. name='Custom Link 3',
  397. enabled=False,
  398. link_text='Link 3',
  399. link_url='http://example.com/?3',
  400. ),
  401. )
  402. CustomLink.objects.bulk_create(custom_links)
  403. for i, custom_link in enumerate(custom_links):
  404. custom_link.object_types.set([site_type])
  405. class SharedObjectAPITestMixin:
  406. """
  407. Helpers for testing the shared/owner visibility enforced on SavedFilter and TableConfig.
  408. """
  409. def _grant_view_permission_and_authenticate(self, user, model):
  410. """
  411. Grant `user` an unconstrained view permission on `model`, create an API token, and return the
  412. corresponding authentication header.
  413. """
  414. obj_perm = ObjectPermission(name=f'{model._meta.model_name} view', actions=['view'])
  415. obj_perm.save()
  416. obj_perm.users.add(user)
  417. obj_perm.object_types.add(ObjectType.objects.get_for_model(model))
  418. token = Token.objects.create(user=user)
  419. return {'HTTP_AUTHORIZATION': f'Bearer {TOKEN_PREFIX}{token.key}.{token.token}'}
  420. class SavedFilterTestCase(SharedObjectAPITestMixin, APIViewTestCases.APIViewTestCase):
  421. model = SavedFilter
  422. brief_fields = ['description', 'display', 'id', 'name', 'slug', 'url']
  423. create_data = [
  424. {
  425. 'object_types': ['dcim.site'],
  426. 'name': 'Saved Filter 4',
  427. 'slug': 'saved-filter-4',
  428. 'weight': 100,
  429. 'enabled': True,
  430. 'shared': True,
  431. 'parameters': {'status': ['active']},
  432. },
  433. {
  434. 'object_types': ['dcim.site'],
  435. 'name': 'Saved Filter 5',
  436. 'slug': 'saved-filter-5',
  437. 'weight': 200,
  438. 'enabled': True,
  439. 'shared': True,
  440. 'parameters': {'status': ['planned']},
  441. },
  442. {
  443. 'object_types': ['dcim.site'],
  444. 'name': 'Saved Filter 6',
  445. 'slug': 'saved-filter-6',
  446. 'weight': 300,
  447. 'enabled': True,
  448. 'shared': True,
  449. 'parameters': {'status': ['retired']},
  450. },
  451. ]
  452. bulk_update_data = {
  453. 'weight': 1000,
  454. 'enabled': False,
  455. 'shared': False,
  456. }
  457. @classmethod
  458. def setUpTestData(cls):
  459. site_type = ObjectType.objects.get_for_model(Site)
  460. saved_filters = (
  461. SavedFilter(
  462. name='Saved Filter 1',
  463. slug='saved-filter-1',
  464. weight=100,
  465. enabled=True,
  466. shared=True,
  467. parameters={'status': ['active']}
  468. ),
  469. SavedFilter(
  470. name='Saved Filter 2',
  471. slug='saved-filter-2',
  472. weight=200,
  473. enabled=True,
  474. shared=True,
  475. parameters={'status': ['planned']}
  476. ),
  477. SavedFilter(
  478. name='Saved Filter 3',
  479. slug='saved-filter-3',
  480. weight=300,
  481. enabled=True,
  482. shared=True,
  483. parameters={'status': ['retired']}
  484. ),
  485. )
  486. SavedFilter.objects.bulk_create(saved_filters)
  487. for i, savedfilter in enumerate(saved_filters):
  488. savedfilter.object_types.set([site_type])
  489. def test_private_filter_not_visible_to_other_users(self):
  490. """
  491. A private (shared=False) SavedFilter owned by another user must not be exposed via the REST API, even to
  492. a user holding an unconstrained view permission.
  493. """
  494. site_type = ObjectType.objects.get_for_model(Site)
  495. owner = User.objects.create_user(username='filter-owner')
  496. private_filter = SavedFilter.objects.create(
  497. name='Private Filter',
  498. slug='private-filter',
  499. user=owner,
  500. shared=False,
  501. parameters={'status': ['active']},
  502. )
  503. private_filter.object_types.set([site_type])
  504. # Grant an unconstrained view permission (the common case)
  505. self.add_permissions('extras.view_savedfilter')
  506. # The private filter must not appear in the list
  507. response = self.client.get(self._get_list_url(), **self.header)
  508. self.assertHttpStatus(response, status.HTTP_200_OK)
  509. returned_ids = [obj['id'] for obj in response.data['results']]
  510. self.assertNotIn(private_filter.pk, returned_ids)
  511. # The private filter must not be retrievable directly
  512. response = self.client.get(self._get_detail_url(private_filter), **self.header)
  513. self.assertHttpStatus(response, status.HTTP_404_NOT_FOUND)
  514. # The private filter must not be exposed via GraphQL either
  515. query = '{ saved_filter_list { id } }'
  516. response = self.client.post(reverse('graphql'), data={'query': query}, format='json', **self.header)
  517. self.assertHttpStatus(response, status.HTTP_200_OK)
  518. data = json.loads(response.content)
  519. returned_ids = [int(obj['id']) for obj in data['data']['saved_filter_list']]
  520. self.assertNotIn(private_filter.pk, returned_ids)
  521. # The owner, however, must still be able to access their own private filter
  522. owner_header = self._grant_view_permission_and_authenticate(owner, SavedFilter)
  523. response = self.client.get(self._get_detail_url(private_filter), **owner_header)
  524. self.assertHttpStatus(response, status.HTTP_200_OK)
  525. response = self.client.post(reverse('graphql'), data={'query': query}, format='json', **owner_header)
  526. self.assertHttpStatus(response, status.HTTP_200_OK)
  527. data = json.loads(response.content)
  528. returned_ids = [int(obj['id']) for obj in data['data']['saved_filter_list']]
  529. self.assertIn(private_filter.pk, returned_ids)
  530. class TableConfigTestCase(SharedObjectAPITestMixin, APIViewTestCases.APIViewTestCase):
  531. model = TableConfig
  532. brief_fields = ['description', 'display', 'id', 'name', 'object_type', 'table', 'url']
  533. bulk_update_data = {
  534. 'description': 'New description',
  535. 'weight': 999,
  536. 'enabled': False,
  537. 'shared': False,
  538. }
  539. @classmethod
  540. def setUpTestData(cls):
  541. site_type = ObjectType.objects.get_for_model(Site)
  542. site_table_name = get_table_for_model(Site).__name__
  543. users = (
  544. User(username='User 1'),
  545. User(username='User 2'),
  546. User(username='User 3'),
  547. )
  548. User.objects.bulk_create(users)
  549. table_configs = (
  550. TableConfig(
  551. name='Table Config 1',
  552. object_type=site_type,
  553. table=site_table_name,
  554. user=users[0],
  555. shared=True,
  556. columns=['name', 'status'],
  557. ),
  558. TableConfig(
  559. name='Table Config 2',
  560. object_type=site_type,
  561. table=site_table_name,
  562. user=users[1],
  563. shared=True,
  564. columns=['name', 'region'],
  565. ),
  566. TableConfig(
  567. name='Table Config 3',
  568. object_type=site_type,
  569. table=site_table_name,
  570. user=users[2],
  571. shared=True,
  572. columns=['name', 'tenant'],
  573. ),
  574. )
  575. TableConfig.objects.bulk_create(table_configs)
  576. cls.create_data = [
  577. {
  578. 'object_type': 'dcim.site',
  579. 'table': site_table_name,
  580. 'name': 'Table Config 4',
  581. 'columns': ['name', 'status'],
  582. 'ordering': ['name'],
  583. },
  584. {
  585. 'object_type': 'dcim.site',
  586. 'table': site_table_name,
  587. 'name': 'Table Config 5',
  588. 'columns': ['name', 'region'],
  589. 'ordering': ['-name'],
  590. },
  591. {
  592. 'object_type': 'dcim.site',
  593. 'table': site_table_name,
  594. 'name': 'Table Config 6',
  595. 'columns': ['name', 'tenant'],
  596. },
  597. ]
  598. def test_private_table_config_not_visible_to_other_users(self):
  599. """
  600. A private (shared=False) TableConfig owned by another user must not be exposed via the REST API, even to
  601. a user holding an unconstrained view permission.
  602. """
  603. site_type = ObjectType.objects.get_for_model(Site)
  604. site_table_name = get_table_for_model(Site).__name__
  605. owner = User.objects.create_user(username='tableconfig-owner')
  606. private_config = TableConfig.objects.create(
  607. name='Private Table Config',
  608. object_type=site_type,
  609. table=site_table_name,
  610. user=owner,
  611. shared=False,
  612. columns=['name', 'status'],
  613. )
  614. # Grant an unconstrained view permission (the common case)
  615. self.add_permissions('extras.view_tableconfig')
  616. # The private table config must not appear in the list
  617. response = self.client.get(self._get_list_url(), **self.header)
  618. self.assertHttpStatus(response, status.HTTP_200_OK)
  619. returned_ids = [obj['id'] for obj in response.data['results']]
  620. self.assertNotIn(private_config.pk, returned_ids)
  621. # The private table config must not be retrievable directly
  622. response = self.client.get(self._get_detail_url(private_config), **self.header)
  623. self.assertHttpStatus(response, status.HTTP_404_NOT_FOUND)
  624. # The private table config must not be exposed via GraphQL either
  625. query = '{ table_config_list { id } }'
  626. response = self.client.post(reverse('graphql'), data={'query': query}, format='json', **self.header)
  627. self.assertHttpStatus(response, status.HTTP_200_OK)
  628. data = json.loads(response.content)
  629. returned_ids = [int(obj['id']) for obj in data['data']['table_config_list']]
  630. self.assertNotIn(private_config.pk, returned_ids)
  631. # The owner, however, must still be able to access their own private table config
  632. owner_header = self._grant_view_permission_and_authenticate(owner, TableConfig)
  633. response = self.client.get(self._get_detail_url(private_config), **owner_header)
  634. self.assertHttpStatus(response, status.HTTP_200_OK)
  635. response = self.client.post(reverse('graphql'), data={'query': query}, format='json', **owner_header)
  636. self.assertHttpStatus(response, status.HTTP_200_OK)
  637. data = json.loads(response.content)
  638. returned_ids = [int(obj['id']) for obj in data['data']['table_config_list']]
  639. self.assertIn(private_config.pk, returned_ids)
  640. class BookmarkTestCase(
  641. APIViewTestCases.GetObjectViewTestCase,
  642. APIViewTestCases.ListObjectsViewTestCase,
  643. APIViewTestCases.CreateObjectViewTestCase,
  644. APIViewTestCases.DeleteObjectViewTestCase
  645. ):
  646. model = Bookmark
  647. brief_fields = ['display', 'id', 'object_id', 'object_type', 'url']
  648. @classmethod
  649. def setUpTestData(cls):
  650. sites = (
  651. Site(name='Site 1', slug='site-1'),
  652. Site(name='Site 2', slug='site-2'),
  653. Site(name='Site 3', slug='site-3'),
  654. Site(name='Site 4', slug='site-4'),
  655. Site(name='Site 5', slug='site-5'),
  656. Site(name='Site 6', slug='site-6'),
  657. )
  658. Site.objects.bulk_create(sites)
  659. def setUp(self):
  660. super().setUp()
  661. sites = Site.objects.all()
  662. bookmarks = (
  663. Bookmark(object=sites[0], user=self.user),
  664. Bookmark(object=sites[1], user=self.user),
  665. Bookmark(object=sites[2], user=self.user),
  666. )
  667. Bookmark.objects.bulk_create(bookmarks)
  668. self.create_data = [
  669. {
  670. 'object_type': 'dcim.site',
  671. 'object_id': sites[3].pk,
  672. 'user': self.user.pk,
  673. },
  674. {
  675. 'object_type': 'dcim.site',
  676. 'object_id': sites[4].pk,
  677. 'user': self.user.pk,
  678. },
  679. {
  680. 'object_type': 'dcim.site',
  681. 'object_id': sites[5].pk,
  682. 'user': self.user.pk,
  683. },
  684. ]
  685. class ExportTemplateTestCase(APIViewTestCases.APIViewTestCase):
  686. model = ExportTemplate
  687. brief_fields = ['description', 'display', 'id', 'name', 'url']
  688. create_data = [
  689. {
  690. 'object_types': ['dcim.device'],
  691. 'name': 'Test Export Template 4',
  692. 'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
  693. },
  694. {
  695. 'object_types': ['dcim.device'],
  696. 'name': 'Test Export Template 5',
  697. 'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
  698. },
  699. {
  700. 'object_types': ['dcim.device'],
  701. 'name': 'Test Export Template 6',
  702. 'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
  703. 'file_name': 'test_export_template_6',
  704. },
  705. ]
  706. bulk_update_data = {
  707. 'description': 'New description',
  708. }
  709. @classmethod
  710. def setUpTestData(cls):
  711. export_templates = (
  712. ExportTemplate(
  713. name='Export Template 1',
  714. template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}'
  715. ),
  716. ExportTemplate(
  717. name='Export Template 2',
  718. template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
  719. file_name='export_template_2',
  720. file_extension='test',
  721. ),
  722. ExportTemplate(
  723. name='Export Template 3',
  724. template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}'
  725. ),
  726. )
  727. ExportTemplate.objects.bulk_create(export_templates)
  728. device_object_type = ObjectType.objects.get_for_model(Device)
  729. for et in export_templates:
  730. et.object_types.set([device_object_type])
  731. class TagTestCase(APIViewTestCases.APIViewTestCase):
  732. model = Tag
  733. brief_fields = ['color', 'description', 'display', 'id', 'name', 'slug', 'url']
  734. create_data = [
  735. {
  736. 'name': 'Tag 4',
  737. 'slug': 'tag-4',
  738. 'weight': 1000,
  739. },
  740. {
  741. 'name': 'Tag 5',
  742. 'slug': 'tag-5',
  743. },
  744. {
  745. 'name': 'Tag 6',
  746. 'slug': 'tag-6',
  747. },
  748. ]
  749. bulk_update_data = {
  750. 'description': 'New description',
  751. }
  752. @classmethod
  753. def setUpTestData(cls):
  754. tags = (
  755. Tag(name='Tag 1', slug='tag-1'),
  756. Tag(name='Tag 2', slug='tag-2'),
  757. Tag(name='Tag 3', slug='tag-3', weight=26),
  758. )
  759. Tag.objects.bulk_create(tags)
  760. class TaggedItemTestCase(
  761. APIViewTestCases.GetObjectViewTestCase,
  762. APIViewTestCases.ListObjectsViewTestCase
  763. ):
  764. model = TaggedItem
  765. brief_fields = ['display', 'id', 'object', 'object_id', 'object_type', 'tag', 'url']
  766. @classmethod
  767. def setUpTestData(cls):
  768. tags = (
  769. Tag(name='Tag 1', slug='tag-1'),
  770. Tag(name='Tag 2', slug='tag-2'),
  771. Tag(name='Tag 3', slug='tag-3'),
  772. )
  773. Tag.objects.bulk_create(tags)
  774. sites = (
  775. Site(name='Site 1', slug='site-1'),
  776. Site(name='Site 2', slug='site-2'),
  777. Site(name='Site 3', slug='site-3'),
  778. )
  779. Site.objects.bulk_create(sites)
  780. sites[0].tags.set([tags[0], tags[1]])
  781. sites[1].tags.set([tags[1], tags[2]])
  782. sites[2].tags.set([tags[2], tags[0]])
  783. # TODO: Standardize to APIViewTestCase (needs create & update tests)
  784. class ImageAttachmentTestCase(
  785. APIViewTestCases.GetObjectViewTestCase,
  786. APIViewTestCases.ListObjectsViewTestCase,
  787. APIViewTestCases.DeleteObjectViewTestCase,
  788. APIViewTestCases.GraphQLTestCase
  789. ):
  790. model = ImageAttachment
  791. brief_fields = ['description', 'display', 'id', 'image', 'name', 'url']
  792. @classmethod
  793. def setUpTestData(cls):
  794. ct = ContentType.objects.get_for_model(Site)
  795. site = Site.objects.create(name='Site 1', slug='site-1')
  796. image_attachments = (
  797. ImageAttachment(
  798. object_type=ct,
  799. object_id=site.pk,
  800. name='Image Attachment 1',
  801. image='http://example.com/image1.png',
  802. image_height=100,
  803. image_width=100,
  804. image_size=1024
  805. ),
  806. ImageAttachment(
  807. object_type=ct,
  808. object_id=site.pk,
  809. name='Image Attachment 2',
  810. image='http://example.com/image2.png',
  811. image_height=100,
  812. image_width=100,
  813. image_size=2048
  814. ),
  815. ImageAttachment(
  816. object_type=ct,
  817. object_id=site.pk,
  818. name='Image Attachment 3',
  819. image='http://example.com/image3.png',
  820. image_height=100,
  821. image_width=100,
  822. image_size=4096
  823. )
  824. )
  825. ImageAttachment.objects.bulk_create(image_attachments)
  826. class JournalEntryTestCase(APIViewTestCases.APIViewTestCase):
  827. model = JournalEntry
  828. brief_fields = ['created', 'display', 'id', 'url']
  829. bulk_update_data = {
  830. 'comments': 'Overwritten',
  831. }
  832. @classmethod
  833. def setUpTestData(cls):
  834. users = (
  835. User(username='User 1'),
  836. User(username='User 2'),
  837. )
  838. User.objects.bulk_create(users)
  839. user = User.objects.first()
  840. site = Site.objects.create(name='Site 1', slug='site-1')
  841. journal_entries = (
  842. JournalEntry(
  843. created_by=user,
  844. assigned_object=site,
  845. comments='Fourth entry',
  846. ),
  847. JournalEntry(
  848. created_by=user,
  849. assigned_object=site,
  850. comments='Fifth entry',
  851. ),
  852. JournalEntry(
  853. created_by=user,
  854. assigned_object=site,
  855. comments='Sixth entry',
  856. ),
  857. )
  858. JournalEntry.objects.bulk_create(journal_entries)
  859. cls.create_data = [
  860. {
  861. 'assigned_object_type': 'dcim.site',
  862. 'assigned_object_id': site.pk,
  863. 'comments': 'First entry',
  864. },
  865. {
  866. 'assigned_object_type': 'dcim.site',
  867. 'assigned_object_id': site.pk,
  868. 'comments': 'Second entry',
  869. },
  870. {
  871. 'assigned_object_type': 'dcim.site',
  872. 'assigned_object_id': site.pk,
  873. 'comments': 'Third entry',
  874. },
  875. ]
  876. def test_immutable_created_by(self):
  877. """
  878. Verify that created_by can't be changed for existing objects
  879. """
  880. entry = JournalEntry.objects.first()
  881. created_by_before = entry.created_by_id
  882. # select user different from the one currently set
  883. change_user = User.objects.exclude(pk=created_by_before).only('id').first()
  884. url = reverse('extras-api:journalentry-detail', kwargs={'pk': entry.pk})
  885. self.add_permissions('extras.change_journalentry')
  886. response = self.client.patch(url, {'created_by': change_user.id}, format='json', **self.header)
  887. self.assertEqual(response.status_code, 200)
  888. entry.refresh_from_db()
  889. created_by_after = entry.created_by_id
  890. self.assertEqual(created_by_before, created_by_after)
  891. class ConfigContextProfileTestCase(APIViewTestCases.APIViewTestCase):
  892. model = ConfigContextProfile
  893. brief_fields = ['description', 'display', 'id', 'name', 'url']
  894. create_data = [
  895. {
  896. 'name': 'Config Context Profile 4',
  897. },
  898. {
  899. 'name': 'Config Context Profile 5',
  900. },
  901. {
  902. 'name': 'Config Context Profile 6',
  903. },
  904. ]
  905. bulk_update_data = {
  906. 'description': 'New description',
  907. }
  908. @classmethod
  909. def setUpTestData(cls):
  910. profiles = (
  911. ConfigContextProfile(
  912. name='Config Context Profile 1',
  913. schema={
  914. "properties": {
  915. "foo": {
  916. "type": "string"
  917. }
  918. },
  919. "required": [
  920. "foo"
  921. ]
  922. }
  923. ),
  924. ConfigContextProfile(
  925. name='Config Context Profile 2',
  926. schema={
  927. "properties": {
  928. "bar": {
  929. "type": "string"
  930. }
  931. },
  932. "required": [
  933. "bar"
  934. ]
  935. }
  936. ),
  937. ConfigContextProfile(
  938. name='Config Context Profile 3',
  939. schema={
  940. "properties": {
  941. "baz": {
  942. "type": "string"
  943. }
  944. },
  945. "required": [
  946. "baz"
  947. ]
  948. }
  949. ),
  950. )
  951. ConfigContextProfile.objects.bulk_create(profiles)
  952. def test_update_data_source_and_data_file(self):
  953. """
  954. Regression test: Ensure data_source and data_file can be assigned via the API.
  955. This specifically covers PATCHing a ConfigContext with integer IDs for both fields.
  956. """
  957. self.add_permissions(
  958. 'core.view_datafile',
  959. 'core.view_datasource',
  960. 'extras.view_configcontextprofile',
  961. 'extras.change_configcontextprofile',
  962. )
  963. config_context_profile = ConfigContextProfile.objects.first()
  964. # Create a data source and file
  965. datasource = DataSource.objects.create(
  966. name='Data Source 1',
  967. type='local',
  968. source_url='file:///tmp/netbox-datasource/',
  969. )
  970. # Generate a valid dummy YAML file
  971. file_data = b'profile: configcontext\n'
  972. datafile = DataFile.objects.create(
  973. source=datasource,
  974. path='dir1/file1.yml',
  975. last_updated=now(),
  976. size=len(file_data),
  977. hash=hashlib.sha256(file_data).hexdigest(),
  978. data=file_data,
  979. )
  980. url = self._get_detail_url(config_context_profile)
  981. payload = {
  982. 'data_source': datasource.pk,
  983. 'data_file': datafile.pk,
  984. }
  985. response = self.client.patch(url, payload, format='json', **self.header)
  986. self.assertHttpStatus(response, status.HTTP_200_OK)
  987. config_context_profile.refresh_from_db()
  988. self.assertEqual(config_context_profile.data_source_id, datasource.pk)
  989. self.assertEqual(config_context_profile.data_file_id, datafile.pk)
  990. self.assertEqual(response.data['data_source']['id'], datasource.pk)
  991. self.assertEqual(response.data['data_file']['id'], datafile.pk)
  992. class ConfigContextTestCase(APIViewTestCases.APIViewTestCase):
  993. model = ConfigContext
  994. brief_fields = ['description', 'display', 'id', 'name', 'url']
  995. create_data = [
  996. {
  997. 'name': 'Config Context 4',
  998. 'data': {'more_foo': True},
  999. },
  1000. {
  1001. 'name': 'Config Context 5',
  1002. 'data': {'more_bar': False},
  1003. },
  1004. {
  1005. 'name': 'Config Context 6',
  1006. 'data': {'more_baz': None},
  1007. },
  1008. ]
  1009. bulk_update_data = {
  1010. 'description': 'New description',
  1011. }
  1012. @classmethod
  1013. def setUpTestData(cls):
  1014. config_contexts = (
  1015. ConfigContext(name='Config Context 1', weight=100, data={'foo': 123}),
  1016. ConfigContext(name='Config Context 2', weight=200, data={'bar': 456}),
  1017. ConfigContext(name='Config Context 3', weight=300, data={'baz': 789}),
  1018. )
  1019. ConfigContext.objects.bulk_create(config_contexts)
  1020. def test_render_configcontext_for_object(self):
  1021. """
  1022. Test rendering config context data for a device.
  1023. """
  1024. manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
  1025. devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
  1026. role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
  1027. site = Site.objects.create(name='Site-1', slug='site-1')
  1028. device = Device.objects.create(name='Device 1', device_type=devicetype, role=role, site=site)
  1029. # Test default config contexts (created at test setup)
  1030. rendered_context = device.get_config_context()
  1031. self.assertEqual(rendered_context['foo'], 123)
  1032. self.assertEqual(rendered_context['bar'], 456)
  1033. self.assertEqual(rendered_context['baz'], 789)
  1034. # Add another context specific to the site
  1035. configcontext4 = ConfigContext(
  1036. name='Config Context 4',
  1037. data={'site_data': 'ABC'}
  1038. )
  1039. configcontext4.save()
  1040. configcontext4.sites.add(site)
  1041. rendered_context = device.get_config_context()
  1042. self.assertEqual(rendered_context['site_data'], 'ABC')
  1043. # Override one of the default contexts
  1044. configcontext5 = ConfigContext(
  1045. name='Config Context 5',
  1046. weight=2000,
  1047. data={'foo': 999}
  1048. )
  1049. configcontext5.save()
  1050. configcontext5.sites.add(site)
  1051. rendered_context = device.get_config_context()
  1052. self.assertEqual(rendered_context['foo'], 999)
  1053. # Add a context which does NOT match our device and ensure it does not apply
  1054. site2 = Site.objects.create(name='Site 2', slug='site-2')
  1055. configcontext6 = ConfigContext(
  1056. name='Config Context 6',
  1057. weight=2000,
  1058. data={'bar': 999}
  1059. )
  1060. configcontext6.save()
  1061. configcontext6.sites.add(site2)
  1062. rendered_context = device.get_config_context()
  1063. self.assertEqual(rendered_context['bar'], 456)
  1064. def test_update_data_source_and_data_file(self):
  1065. """
  1066. Regression test: Ensure data_source and data_file can be assigned via the API.
  1067. This specifically covers PATCHing a ConfigContext with integer IDs for both fields.
  1068. """
  1069. self.add_permissions(
  1070. 'core.view_datafile',
  1071. 'core.view_datasource',
  1072. 'extras.view_configcontext',
  1073. 'extras.change_configcontext',
  1074. )
  1075. config_context = ConfigContext.objects.first()
  1076. # Create a data source and file
  1077. datasource = DataSource.objects.create(
  1078. name='Data Source 1',
  1079. type='local',
  1080. source_url='file:///tmp/netbox-datasource/',
  1081. )
  1082. # Generate a valid dummy YAML file
  1083. file_data = b'context: config\n'
  1084. datafile = DataFile.objects.create(
  1085. source=datasource,
  1086. path='dir1/file1.yml',
  1087. last_updated=now(),
  1088. size=len(file_data),
  1089. hash=hashlib.sha256(file_data).hexdigest(),
  1090. data=file_data,
  1091. )
  1092. url = self._get_detail_url(config_context)
  1093. payload = {
  1094. 'data_source': datasource.pk,
  1095. 'data_file': datafile.pk,
  1096. }
  1097. response = self.client.patch(url, payload, format='json', **self.header)
  1098. self.assertHttpStatus(response, status.HTTP_200_OK)
  1099. config_context.refresh_from_db()
  1100. self.assertEqual(config_context.data_source_id, datasource.pk)
  1101. self.assertEqual(config_context.data_file_id, datafile.pk)
  1102. self.assertEqual(response.data['data_source']['id'], datasource.pk)
  1103. self.assertEqual(response.data['data_file']['id'], datafile.pk)
  1104. class ConfigTemplateTestCase(APIViewTestCases.APIViewTestCase):
  1105. model = ConfigTemplate
  1106. brief_fields = ['description', 'display', 'id', 'name', 'url']
  1107. create_data = [
  1108. {
  1109. 'name': 'Config Template 4',
  1110. 'template_code': 'Foo: {{ foo }}',
  1111. 'mime_type': 'text/plain',
  1112. 'file_name': 'output4',
  1113. 'file_extension': 'txt',
  1114. 'as_attachment': True,
  1115. },
  1116. {
  1117. 'name': 'Config Template 5',
  1118. 'template_code': 'Bar: {{ bar }}',
  1119. },
  1120. {
  1121. 'name': 'Config Template 6',
  1122. 'template_code': 'Baz: {{ baz }}',
  1123. },
  1124. ]
  1125. bulk_update_data = {
  1126. 'description': 'New description',
  1127. }
  1128. @classmethod
  1129. def setUpTestData(cls):
  1130. config_templates = (
  1131. ConfigTemplate(
  1132. name='Config Template 1',
  1133. template_code='Foo: {{ foo }}'
  1134. ),
  1135. ConfigTemplate(
  1136. name='Config Template 2',
  1137. template_code='Bar: {{ bar }}',
  1138. ),
  1139. ConfigTemplate(
  1140. name='Config Template 3',
  1141. template_code='Baz: {{ baz }}'
  1142. ),
  1143. )
  1144. ConfigTemplate.objects.bulk_create(config_templates)
  1145. def test_render(self):
  1146. configtemplate = ConfigTemplate.objects.first()
  1147. self.add_permissions('extras.render_configtemplate', 'extras.view_configtemplate')
  1148. url = reverse('extras-api:configtemplate-render', kwargs={'pk': configtemplate.pk})
  1149. response = self.client.post(url, {'foo': 'bar'}, format='json', **self.header)
  1150. self.assertHttpStatus(response, status.HTTP_200_OK)
  1151. self.assertEqual(response.data['content'], 'Foo: bar')
  1152. def test_render_without_permission(self):
  1153. configtemplate = ConfigTemplate.objects.first()
  1154. # No permissions added - user has no render permission
  1155. url = reverse('extras-api:configtemplate-render', kwargs={'pk': configtemplate.pk})
  1156. response = self.client.post(url, {'foo': 'bar'}, format='json', **self.header)
  1157. self.assertHttpStatus(response, status.HTTP_404_NOT_FOUND)
  1158. def test_render_token_write_enabled(self):
  1159. configtemplate = ConfigTemplate.objects.first()
  1160. self.add_permissions('extras.render_configtemplate', 'extras.view_configtemplate')
  1161. url = reverse('extras-api:configtemplate-render', kwargs={'pk': configtemplate.pk})
  1162. # Request without token auth should fail with PermissionDenied
  1163. response = self.client.post(url, {'foo': 'bar'}, format='json')
  1164. self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN)
  1165. # Create token with write_enabled=False
  1166. token = Token.objects.create(version=2, user=self.user, write_enabled=False)
  1167. token_header = f'Bearer {TOKEN_PREFIX}{token.key}.{token.token}'
  1168. # Request with write-disabled token should fail
  1169. response = self.client.post(url, {'foo': 'bar'}, format='json', HTTP_AUTHORIZATION=token_header)
  1170. self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN)
  1171. # Enable write and retry
  1172. token.write_enabled = True
  1173. token.save()
  1174. response = self.client.post(url, {'foo': 'bar'}, format='json', HTTP_AUTHORIZATION=token_header)
  1175. self.assertHttpStatus(response, status.HTTP_200_OK)
  1176. class ScriptTestCase(APITestCase):
  1177. class TestScriptClass(PythonClass):
  1178. class Meta:
  1179. name = 'Test script'
  1180. commit = True
  1181. scheduling_enabled = True
  1182. var1 = StringVar()
  1183. var2 = IntegerVar()
  1184. var3 = BooleanVar()
  1185. def run(self, data, commit=True):
  1186. self.log_info(data['var1'])
  1187. self.log_success(data['var2'])
  1188. self.log_failure(data['var3'])
  1189. return 'Script complete'
  1190. @classmethod
  1191. def setUpTestData(cls):
  1192. # Avoid trying to import a non-existent on-disk module during setup.
  1193. # This test creates the Script row explicitly and monkey-patches
  1194. # Script.python_class below.
  1195. with patch.object(ScriptModule, 'sync_classes'):
  1196. module = ScriptModule.objects.create(
  1197. file_root=ManagedFileRootPathChoices.SCRIPTS,
  1198. file_path='script.py',
  1199. )
  1200. script = Script.objects.create(
  1201. module=module,
  1202. name='Test script',
  1203. is_executable=True,
  1204. )
  1205. cls.url = reverse('extras-api:script-detail', kwargs={'pk': script.pk})
  1206. @property
  1207. def python_class(self):
  1208. return self.TestScriptClass
  1209. def setUp(self):
  1210. super().setUp()
  1211. self.add_permissions('extras.view_script')
  1212. # Monkey-patch the Script model to return our TestScriptClass above
  1213. Script.python_class = self.python_class
  1214. # The script-run endpoint gates on a live RQ worker. Tests run without
  1215. # one, so bypass the check to exercise validation and the enqueue path.
  1216. worker_patch = patch('extras.api.views.any_workers_for_queue', return_value=True)
  1217. worker_patch.start()
  1218. self.addCleanup(worker_patch.stop)
  1219. def test_get_script(self):
  1220. response = self.client.get(self.url, **self.header)
  1221. self.assertEqual(response.data['name'], self.TestScriptClass.Meta.name)
  1222. self.assertEqual(response.data['vars']['var1'], 'StringVar')
  1223. self.assertEqual(response.data['vars']['var2'], 'IntegerVar')
  1224. self.assertEqual(response.data['vars']['var3'], 'BooleanVar')
  1225. def test_schedule_script_past_time_rejected(self):
  1226. """
  1227. Scheduling with past schedule_at should fail.
  1228. """
  1229. self.add_permissions('extras.run_script')
  1230. payload = {
  1231. 'data': {'var1': 'hello', 'var2': 1, 'var3': False},
  1232. 'commit': True,
  1233. 'schedule_at': now() - datetime.timedelta(hours=1),
  1234. }
  1235. response = self.client.post(self.url, payload, format='json', **self.header)
  1236. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  1237. self.assertIn('schedule_at', response.data)
  1238. # Be tolerant of exact wording but ensure we failed on schedule_at being in the past
  1239. self.assertIn('future', str(response.data['schedule_at']).lower())
  1240. def test_schedule_script_interval_only(self):
  1241. """
  1242. Interval without schedule_at should auto-set schedule_at now.
  1243. """
  1244. self.add_permissions('extras.run_script')
  1245. payload = {
  1246. 'data': {'var1': 'hello', 'var2': 1, 'var3': False},
  1247. 'commit': True,
  1248. 'interval': 60,
  1249. }
  1250. response = self.client.post(self.url, payload, format='json', **self.header)
  1251. self.assertHttpStatus(response, status.HTTP_200_OK)
  1252. # The latest job is returned in the script detail serializer under "result"
  1253. self.assertIn('result', response.data)
  1254. self.assertEqual(response.data['result']['interval'], 60)
  1255. # Ensure a start time was autopopulated
  1256. self.assertIsNotNone(response.data['result']['scheduled'])
  1257. def test_schedule_script_when_disabled(self):
  1258. """
  1259. Scheduling should fail when script.scheduling_enabled=False.
  1260. """
  1261. self.add_permissions('extras.run_script')
  1262. # Temporarily disable scheduling on the in-test Python class
  1263. original = getattr(self.TestScriptClass.Meta, 'scheduling_enabled', True)
  1264. self.TestScriptClass.Meta.scheduling_enabled = False
  1265. base = {
  1266. 'data': {'var1': 'hello', 'var2': 1, 'var3': False},
  1267. 'commit': True,
  1268. }
  1269. # Check both schedule_at and interval paths
  1270. cases = [
  1271. {**base, 'schedule_at': now() + datetime.timedelta(minutes=5)},
  1272. {**base, 'interval': 60},
  1273. ]
  1274. try:
  1275. for case in cases:
  1276. with self.subTest(case=list(case.keys())):
  1277. response = self.client.post(self.url, case, format='json', **self.header)
  1278. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  1279. # Error should be attached to whichever field we used
  1280. key = 'schedule_at' if 'schedule_at' in case else 'interval'
  1281. self.assertIn(key, response.data)
  1282. self.assertIn('scheduling is not enabled', str(response.data[key]).lower())
  1283. finally:
  1284. # Restore the original setting for other tests
  1285. self.TestScriptClass.Meta.scheduling_enabled = original
  1286. class CreatedUpdatedFilterTestCase(APITestCase):
  1287. @classmethod
  1288. def setUpTestData(cls):
  1289. site1 = Site.objects.create(name='Site 1', slug='site-1')
  1290. location1 = Location.objects.create(site=site1, name='Location 1', slug='location-1')
  1291. rackrole1 = RackRole.objects.create(name='Rack Role 1', slug='rack-role-1', color='ff0000')
  1292. racks = (
  1293. Rack(site=site1, location=location1, role=rackrole1, name='Rack 1', u_height=42),
  1294. Rack(site=site1, location=location1, role=rackrole1, name='Rack 2', u_height=42)
  1295. )
  1296. Rack.objects.bulk_create(racks)
  1297. # Change the created and last_updated of the second rack
  1298. Rack.objects.filter(pk=racks[1].pk).update(
  1299. last_updated=make_aware(datetime.datetime(2001, 2, 3, 1, 2, 3, 4)),
  1300. created=make_aware(datetime.datetime(2001, 2, 3))
  1301. )
  1302. def test_get_rack_created(self):
  1303. rack2 = Rack.objects.get(name='Rack 2')
  1304. self.add_permissions('dcim.view_rack')
  1305. url = reverse('dcim-api:rack-list')
  1306. response = self.client.get('{}?created=2001-02-03'.format(url), **self.header)
  1307. self.assertEqual(response.data['count'], 1)
  1308. self.assertEqual(response.data['results'][0]['id'], rack2.pk)
  1309. def test_get_rack_created_gte(self):
  1310. rack1 = Rack.objects.get(name='Rack 1')
  1311. self.add_permissions('dcim.view_rack')
  1312. url = reverse('dcim-api:rack-list')
  1313. response = self.client.get('{}?created__gte=2001-02-04'.format(url), **self.header)
  1314. self.assertEqual(response.data['count'], 1)
  1315. self.assertEqual(response.data['results'][0]['id'], rack1.pk)
  1316. def test_get_rack_created_lte(self):
  1317. rack2 = Rack.objects.get(name='Rack 2')
  1318. self.add_permissions('dcim.view_rack')
  1319. url = reverse('dcim-api:rack-list')
  1320. response = self.client.get('{}?created__lte=2001-02-04'.format(url), **self.header)
  1321. self.assertEqual(response.data['count'], 1)
  1322. self.assertEqual(response.data['results'][0]['id'], rack2.pk)
  1323. def test_get_rack_last_updated(self):
  1324. rack2 = Rack.objects.get(name='Rack 2')
  1325. self.add_permissions('dcim.view_rack')
  1326. url = reverse('dcim-api:rack-list')
  1327. response = self.client.get('{}?last_updated=2001-02-03%2001:02:03.000004'.format(url), **self.header)
  1328. self.assertEqual(response.data['count'], 1)
  1329. self.assertEqual(response.data['results'][0]['id'], rack2.pk)
  1330. def test_get_rack_last_updated_gte(self):
  1331. rack1 = Rack.objects.get(name='Rack 1')
  1332. self.add_permissions('dcim.view_rack')
  1333. url = reverse('dcim-api:rack-list')
  1334. response = self.client.get('{}?last_updated__gte=2001-02-04%2001:02:03.000004'.format(url), **self.header)
  1335. self.assertEqual(response.data['count'], 1)
  1336. self.assertEqual(response.data['results'][0]['id'], rack1.pk)
  1337. def test_get_rack_last_updated_lte(self):
  1338. rack2 = Rack.objects.get(name='Rack 2')
  1339. self.add_permissions('dcim.view_rack')
  1340. url = reverse('dcim-api:rack-list')
  1341. response = self.client.get('{}?last_updated__lte=2001-02-04%2001:02:03.000004'.format(url), **self.header)
  1342. self.assertEqual(response.data['count'], 1)
  1343. self.assertEqual(response.data['results'][0]['id'], rack2.pk)
  1344. class SubscriptionTestCase(APIViewTestCases.APIViewTestCase):
  1345. model = Subscription
  1346. brief_fields = ['display', 'id', 'object_id', 'object_type', 'url', 'user']
  1347. graphql_filter = {
  1348. 'id': {'lookup': 'gt', 'value': '0'},
  1349. }
  1350. @classmethod
  1351. def setUpTestData(cls):
  1352. users = (
  1353. User(username='User 1'),
  1354. User(username='User 2'),
  1355. User(username='User 3'),
  1356. User(username='User 4'),
  1357. )
  1358. User.objects.bulk_create(users)
  1359. sites = (
  1360. Site(name='Site 1', slug='site-1'),
  1361. Site(name='Site 2', slug='site-2'),
  1362. Site(name='Site 3', slug='site-3'),
  1363. )
  1364. Site.objects.bulk_create(sites)
  1365. subscriptions = (
  1366. Subscription(
  1367. object=sites[0],
  1368. user=users[0],
  1369. ),
  1370. Subscription(
  1371. object=sites[1],
  1372. user=users[1],
  1373. ),
  1374. Subscription(
  1375. object=sites[2],
  1376. user=users[2],
  1377. ),
  1378. )
  1379. Subscription.objects.bulk_create(subscriptions)
  1380. cls.create_data = [
  1381. {
  1382. 'object_type': 'dcim.site',
  1383. 'object_id': sites[0].pk,
  1384. 'user': users[3].pk,
  1385. },
  1386. {
  1387. 'object_type': 'dcim.site',
  1388. 'object_id': sites[1].pk,
  1389. 'user': users[3].pk,
  1390. },
  1391. {
  1392. 'object_type': 'dcim.site',
  1393. 'object_id': sites[2].pk,
  1394. 'user': users[3].pk,
  1395. },
  1396. ]
  1397. cls.bulk_update_data = {
  1398. 'user': users[3].pk,
  1399. }
  1400. class NotificationGroupTestCase(APIViewTestCases.APIViewTestCase):
  1401. model = NotificationGroup
  1402. brief_fields = ['description', 'display', 'id', 'name', 'url']
  1403. create_data = [
  1404. {
  1405. 'object_types': ['dcim.site'],
  1406. 'name': 'Custom Link 4',
  1407. 'enabled': True,
  1408. 'link_text': 'Link 4',
  1409. 'link_url': 'http://example.com/?4',
  1410. },
  1411. {
  1412. 'object_types': ['dcim.site'],
  1413. 'name': 'Custom Link 5',
  1414. 'enabled': True,
  1415. 'link_text': 'Link 5',
  1416. 'link_url': 'http://example.com/?5',
  1417. },
  1418. {
  1419. 'object_types': ['dcim.site'],
  1420. 'name': 'Custom Link 6',
  1421. 'enabled': False,
  1422. 'link_text': 'Link 6',
  1423. 'link_url': 'http://example.com/?6',
  1424. },
  1425. ]
  1426. bulk_update_data = {
  1427. 'description': 'New description',
  1428. }
  1429. @classmethod
  1430. def setUpTestData(cls):
  1431. users = (
  1432. User(username='User 1'),
  1433. User(username='User 2'),
  1434. User(username='User 3'),
  1435. )
  1436. User.objects.bulk_create(users)
  1437. groups = (
  1438. Group(name='Group 1'),
  1439. Group(name='Group 2'),
  1440. Group(name='Group 3'),
  1441. )
  1442. Group.objects.bulk_create(groups)
  1443. notification_groups = (
  1444. NotificationGroup(name='Notification Group 1'),
  1445. NotificationGroup(name='Notification Group 2'),
  1446. NotificationGroup(name='Notification Group 3'),
  1447. )
  1448. NotificationGroup.objects.bulk_create(notification_groups)
  1449. for i, notification_group in enumerate(notification_groups):
  1450. notification_group.users.add(users[i])
  1451. notification_group.groups.add(groups[i])
  1452. cls.create_data = [
  1453. {
  1454. 'name': 'Notification Group 4',
  1455. 'description': 'Foo',
  1456. 'users': [users[0].pk],
  1457. 'groups': [groups[0].pk],
  1458. },
  1459. {
  1460. 'name': 'Notification Group 5',
  1461. 'description': 'Bar',
  1462. 'users': [users[1].pk],
  1463. 'groups': [groups[1].pk],
  1464. },
  1465. {
  1466. 'name': 'Notification Group 6',
  1467. 'description': 'Baz',
  1468. 'users': [users[2].pk],
  1469. 'groups': [groups[2].pk],
  1470. },
  1471. ]
  1472. class NotificationTestCase(APIViewTestCases.APIViewTestCase):
  1473. model = Notification
  1474. brief_fields = ['display', 'event_type', 'id', 'object_id', 'object_type', 'read', 'url', 'user']
  1475. bulk_update_data = {
  1476. 'read': now(),
  1477. }
  1478. graphql_filter = {
  1479. 'event_type': {'lookup': 'exact', 'value': OBJECT_CREATED},
  1480. }
  1481. @classmethod
  1482. def setUpTestData(cls):
  1483. users = (
  1484. User(username='User 1'),
  1485. User(username='User 2'),
  1486. User(username='User 3'),
  1487. User(username='User 4'),
  1488. )
  1489. User.objects.bulk_create(users)
  1490. sites = (
  1491. Site(name='Site 1', slug='site-1'),
  1492. Site(name='Site 2', slug='site-2'),
  1493. Site(name='Site 3', slug='site-3'),
  1494. )
  1495. Site.objects.bulk_create(sites)
  1496. notifications = (
  1497. Notification(
  1498. object=sites[0],
  1499. event_type=OBJECT_CREATED,
  1500. user=users[0],
  1501. ),
  1502. Notification(
  1503. object=sites[1],
  1504. event_type=OBJECT_UPDATED,
  1505. user=users[1],
  1506. ),
  1507. Notification(
  1508. object=sites[2],
  1509. event_type=OBJECT_DELETED,
  1510. user=users[2],
  1511. ),
  1512. )
  1513. Notification.objects.bulk_create(notifications)
  1514. cls.create_data = [
  1515. {
  1516. 'object_type': 'dcim.site',
  1517. 'object_id': sites[0].pk,
  1518. 'user': users[3].pk,
  1519. 'event_type': OBJECT_CREATED,
  1520. },
  1521. {
  1522. 'object_type': 'dcim.site',
  1523. 'object_id': sites[1].pk,
  1524. 'user': users[3].pk,
  1525. 'event_type': OBJECT_UPDATED,
  1526. },
  1527. {
  1528. 'object_type': 'dcim.site',
  1529. 'object_id': sites[2].pk,
  1530. 'user': users[3].pk,
  1531. 'event_type': OBJECT_DELETED,
  1532. },
  1533. ]
  1534. class _InMemoryScriptStorage:
  1535. """Stateful stand-in for the scripts storage backend; mirrors its allow_overwrite option."""
  1536. def __init__(self, allow_overwrite=True):
  1537. self.files = {}
  1538. self.allow_overwrite = allow_overwrite
  1539. def save(self, name, content):
  1540. if not self.allow_overwrite and name in self.files:
  1541. name = f'{name}.1'
  1542. content.seek(0)
  1543. self.files[name] = content.read()
  1544. return name
  1545. def open(self, name, mode='rb'):
  1546. if name not in self.files:
  1547. raise FileNotFoundError(name)
  1548. return io.BytesIO(self.files[name])
  1549. def delete(self, name):
  1550. self.files.pop(name, None)
  1551. def exists(self, name):
  1552. return name in self.files
  1553. class ScriptModuleTestCase(APITestCase):
  1554. """
  1555. Tests for the POST /api/extras/scripts/upload/ endpoint.
  1556. ScriptModule is a proxy of core.ManagedFile (a different app) so the standard
  1557. APIViewTestCases mixins cannot be used directly. All tests use add_permissions()
  1558. with explicit Django model-level permissions.
  1559. """
  1560. def setUp(self):
  1561. super().setUp()
  1562. self.url = reverse('extras-api:scriptmodule-list') # /api/extras/scripts/upload/
  1563. @contextmanager
  1564. def _patched_script_storage(self, fake_storage):
  1565. """Patch both storage entry points (serializer writes, module imports) to the given fake."""
  1566. with (
  1567. patch('extras.api.serializers_.scripts.storages') as serializer_storages,
  1568. patch('extras.models.mixins.storages') as module_storages,
  1569. ):
  1570. serializer_storages.create_storage.return_value = fake_storage
  1571. serializer_storages.backends = {'scripts': {}}
  1572. module_storages.__getitem__.return_value = fake_storage
  1573. yield
  1574. def test_upload_script_module_without_permission(self):
  1575. script_content = b"from extras.scripts import Script\nclass TestScript(Script):\n pass\n"
  1576. upload_file = SimpleUploadedFile('test_upload.py', script_content, content_type='text/plain')
  1577. response = self.client.post(
  1578. self.url,
  1579. {'file': upload_file},
  1580. format='multipart',
  1581. **self.header,
  1582. )
  1583. self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN)
  1584. def test_upload_script_module(self):
  1585. # ScriptModule is a proxy of core.ManagedFile; both permissions required.
  1586. self.add_permissions('extras.add_scriptmodule', 'core.add_managedfile')
  1587. script_content = b"from extras.scripts import Script\nclass TestScript(Script):\n pass\n"
  1588. upload_file = SimpleUploadedFile('test_upload.py', script_content, content_type='text/plain')
  1589. mock_storage = MagicMock()
  1590. mock_storage.save.return_value = 'test_upload.py'
  1591. # The upload serializer writes the file via storages.create_storage(...).save(),
  1592. # but ScriptModule.sync_classes() later imports it via storages["scripts"].open().
  1593. # Provide both behaviors so the uploaded module can actually be loaded during the test.
  1594. mock_storage.open.side_effect = lambda *args, **kwargs: io.BytesIO(script_content)
  1595. with (
  1596. patch('extras.api.serializers_.scripts.storages') as mock_serializer_storages,
  1597. patch('extras.models.mixins.storages') as mock_module_storages,
  1598. ):
  1599. mock_serializer_storages.create_storage.return_value = mock_storage
  1600. mock_serializer_storages.backends = {'scripts': {}}
  1601. mock_module_storages.__getitem__.return_value = mock_storage
  1602. response = self.client.post(
  1603. self.url,
  1604. {'file': upload_file},
  1605. format='multipart',
  1606. **self.header,
  1607. )
  1608. self.assertHttpStatus(response, status.HTTP_201_CREATED)
  1609. self.assertEqual(response.data['file_path'], 'test_upload.py')
  1610. mock_storage.save.assert_called_once()
  1611. self.assertTrue(ScriptModule.objects.filter(file_path='test_upload.py').exists())
  1612. self.assertTrue(Script.objects.filter(module__file_path='test_upload.py', name='TestScript').exists())
  1613. def test_upload_faulty_script_module(self):
  1614. """Uploading a script with an import error should return 400 and not create a DB record."""
  1615. self.add_permissions('extras.add_scriptmodule', 'core.add_managedfile')
  1616. # 'extras.script' is invalid; the correct module is 'extras.scripts'
  1617. script_content = b"from extras.script import Script\nclass TestScript(Script):\n pass\n"
  1618. upload_file = SimpleUploadedFile('test_faulty.py', script_content, content_type='text/plain')
  1619. response = self.client.post(
  1620. self.url,
  1621. {'file': upload_file},
  1622. format='multipart',
  1623. **self.header,
  1624. )
  1625. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  1626. self.assertFalse(ScriptModule.objects.filter(file_path='test_faulty.py').exists())
  1627. def test_upload_duplicate_script_module_preserves_existing_file(self):
  1628. """A duplicate-filename upload returns 400 and leaves the existing file unchanged."""
  1629. self.add_permissions('extras.add_scriptmodule', 'core.add_managedfile')
  1630. original_content = (
  1631. b"from extras.scripts import Script\n\n\n"
  1632. b"class ProbeScript(Script):\n def run(self, data, commit):\n return 'v1'\n"
  1633. )
  1634. updated_content = original_content.replace(b"'v1'", b"'v2'")
  1635. fake_storage = _InMemoryScriptStorage()
  1636. with (
  1637. patch('extras.api.serializers_.scripts.storages') as mock_serializer_storages,
  1638. patch('extras.models.mixins.storages') as mock_module_storages,
  1639. ):
  1640. mock_serializer_storages.create_storage.return_value = fake_storage
  1641. mock_serializer_storages.backends = {'scripts': {}}
  1642. mock_module_storages.__getitem__.return_value = fake_storage
  1643. # First upload succeeds and writes the file
  1644. response = self.client.post(
  1645. self.url,
  1646. {'file': SimpleUploadedFile('zz_probe.py', original_content, content_type='text/plain')},
  1647. format='multipart',
  1648. **self.header,
  1649. )
  1650. self.assertHttpStatus(response, status.HTTP_201_CREATED)
  1651. self.assertEqual(fake_storage.files['zz_probe.py'], original_content)
  1652. # Re-uploading the same filename with different content must be rejected
  1653. response = self.client.post(
  1654. self.url,
  1655. {'file': SimpleUploadedFile('zz_probe.py', updated_content, content_type='text/plain')},
  1656. format='multipart',
  1657. **self.header,
  1658. )
  1659. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  1660. self.assertIn('already exists', str(response.data))
  1661. # Existing file must survive intact: neither deleted nor overwritten with v2
  1662. self.assertTrue(fake_storage.exists('zz_probe.py'))
  1663. self.assertEqual(fake_storage.files['zz_probe.py'], original_content)
  1664. # Exactly one ScriptModule remains, still pointing at the original file
  1665. self.assertEqual(ScriptModule.objects.filter(file_path='zz_probe.py').count(), 1)
  1666. def test_upload_script_module_without_file_fails(self):
  1667. self.add_permissions('extras.add_scriptmodule', 'core.add_managedfile')
  1668. response = self.client.post(self.url, {}, format='json', **self.header)
  1669. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  1670. def test_update_script_module(self):
  1671. """A PATCH with a new file replaces the stored content and re-syncs the module's scripts."""
  1672. self.add_permissions(
  1673. 'extras.add_scriptmodule', 'core.add_managedfile',
  1674. 'extras.change_scriptmodule', 'core.change_managedfile',
  1675. )
  1676. original_content = (
  1677. b"from extras.scripts import Script\n\n\n"
  1678. b"class ProbeScript(Script):\n def run(self, data, commit):\n return 'v1'\n"
  1679. )
  1680. updated_content = (
  1681. b"from extras.scripts import Script\n\n\n"
  1682. b"class ProbeScriptV2(Script):\n def run(self, data, commit):\n return 'v2'\n"
  1683. )
  1684. fake_storage = _InMemoryScriptStorage()
  1685. with self._patched_script_storage(fake_storage):
  1686. response = self.client.post(
  1687. self.url,
  1688. {'file': SimpleUploadedFile('zz_update.py', original_content, content_type='text/plain')},
  1689. format='multipart',
  1690. **self.header,
  1691. )
  1692. self.assertHttpStatus(response, status.HTTP_201_CREATED)
  1693. module_id = response.data['id']
  1694. detail_url = reverse('extras-api:scriptmodule-detail', kwargs={'pk': module_id})
  1695. response = self.client.patch(
  1696. detail_url,
  1697. {'file': SimpleUploadedFile('zz_update.py', updated_content, content_type='text/plain')},
  1698. format='multipart',
  1699. **self.header,
  1700. )
  1701. self.assertHttpStatus(response, status.HTTP_200_OK)
  1702. self.assertEqual(response.data['id'], module_id)
  1703. self.assertEqual(response.data['file_path'], 'zz_update.py')
  1704. self.assertIsNotNone(response.data['last_updated'])
  1705. self.assertEqual(fake_storage.files['zz_update.py'], updated_content)
  1706. # Script classes are re-synced from the new content
  1707. self.assertFalse(Script.objects.filter(module_id=module_id, name='ProbeScript').exists())
  1708. self.assertTrue(Script.objects.filter(module_id=module_id, name='ProbeScriptV2').exists())
  1709. self.assertEqual(ScriptModule.objects.filter(file_path='zz_update.py').count(), 1)
  1710. def test_update_script_module_without_file_fails(self):
  1711. """A PATCH without a file upload is rejected and leaves the stored content unchanged."""
  1712. self.add_permissions(
  1713. 'extras.add_scriptmodule', 'core.add_managedfile',
  1714. 'extras.change_scriptmodule', 'core.change_managedfile',
  1715. )
  1716. script_content = b"from extras.scripts import Script\nclass TestScript(Script):\n pass\n"
  1717. fake_storage = _InMemoryScriptStorage()
  1718. with self._patched_script_storage(fake_storage):
  1719. response = self.client.post(
  1720. self.url,
  1721. {'file': SimpleUploadedFile('zz_nofile.py', script_content, content_type='text/plain')},
  1722. format='multipart',
  1723. **self.header,
  1724. )
  1725. self.assertHttpStatus(response, status.HTTP_201_CREATED)
  1726. detail_url = reverse('extras-api:scriptmodule-detail', kwargs={'pk': response.data['id']})
  1727. response = self.client.patch(detail_url, {}, format='json', **self.header)
  1728. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  1729. self.assertIn('file', response.data)
  1730. self.assertEqual(fake_storage.files['zz_nofile.py'], script_content)
  1731. def test_update_script_module_without_permission(self):
  1732. """A PATCH without change permissions returns 403 and leaves the stored content unchanged."""
  1733. self.add_permissions('extras.add_scriptmodule', 'core.add_managedfile')
  1734. script_content = b"from extras.scripts import Script\nclass TestScript(Script):\n pass\n"
  1735. fake_storage = _InMemoryScriptStorage()
  1736. with self._patched_script_storage(fake_storage):
  1737. response = self.client.post(
  1738. self.url,
  1739. {'file': SimpleUploadedFile('zz_forbidden.py', script_content, content_type='text/plain')},
  1740. format='multipart',
  1741. **self.header,
  1742. )
  1743. self.assertHttpStatus(response, status.HTTP_201_CREATED)
  1744. detail_url = reverse('extras-api:scriptmodule-detail', kwargs={'pk': response.data['id']})
  1745. response = self.client.patch(
  1746. detail_url,
  1747. {'file': SimpleUploadedFile('zz_forbidden.py', script_content, content_type='text/plain')},
  1748. format='multipart',
  1749. **self.header,
  1750. )
  1751. self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN)
  1752. self.assertEqual(fake_storage.files['zz_forbidden.py'], script_content)
  1753. def test_update_script_module_by_file_name(self):
  1754. """A module can be updated by addressing it with its file name instead of its numeric ID."""
  1755. self.add_permissions(
  1756. 'extras.add_scriptmodule', 'core.add_managedfile',
  1757. 'extras.change_scriptmodule', 'core.change_managedfile',
  1758. )
  1759. original_content = (
  1760. b"from extras.scripts import Script\n\n\n"
  1761. b"class ProbeScript(Script):\n def run(self, data, commit):\n return 'v1'\n"
  1762. )
  1763. updated_content = original_content.replace(b"'v1'", b"'v2'")
  1764. fake_storage = _InMemoryScriptStorage()
  1765. with self._patched_script_storage(fake_storage):
  1766. response = self.client.post(
  1767. self.url,
  1768. {'file': SimpleUploadedFile('zz_by_name.py', original_content, content_type='text/plain')},
  1769. format='multipart',
  1770. **self.header,
  1771. )
  1772. self.assertHttpStatus(response, status.HTTP_201_CREATED)
  1773. module_id = response.data['id']
  1774. detail_url = reverse('extras-api:scriptmodule-detail', kwargs={'pk': 'zz_by_name.py'})
  1775. response = self.client.put(
  1776. detail_url,
  1777. {'file': SimpleUploadedFile('zz_by_name.py', updated_content, content_type='text/plain')},
  1778. format='multipart',
  1779. **self.header,
  1780. )
  1781. self.assertHttpStatus(response, status.HTTP_200_OK)
  1782. self.assertEqual(response.data['id'], module_id)
  1783. self.assertEqual(fake_storage.files['zz_by_name.py'], updated_content)
  1784. def test_update_script_module_rejects_mismatched_file_name(self):
  1785. """An update whose uploaded file name differs from the module's file path is rejected."""
  1786. self.add_permissions(
  1787. 'extras.add_scriptmodule', 'core.add_managedfile',
  1788. 'extras.change_scriptmodule', 'core.change_managedfile',
  1789. )
  1790. original_content = (
  1791. b"from extras.scripts import Script\n\n\n"
  1792. b"class ProbeScript(Script):\n def run(self, data, commit):\n return 'v1'\n"
  1793. )
  1794. fake_storage = _InMemoryScriptStorage()
  1795. with self._patched_script_storage(fake_storage):
  1796. response = self.client.post(
  1797. self.url,
  1798. {'file': SimpleUploadedFile('zz_mismatch.py', original_content, content_type='text/plain')},
  1799. format='multipart',
  1800. **self.header,
  1801. )
  1802. self.assertHttpStatus(response, status.HTTP_201_CREATED)
  1803. detail_url = reverse('extras-api:scriptmodule-detail', kwargs={'pk': response.data['id']})
  1804. response = self.client.patch(
  1805. detail_url,
  1806. {'file': SimpleUploadedFile('zz_other.py', original_content, content_type='text/plain')},
  1807. format='multipart',
  1808. **self.header,
  1809. )
  1810. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  1811. self.assertEqual(fake_storage.files['zz_mismatch.py'], original_content)
  1812. self.assertNotIn('zz_other.py', fake_storage.files)
  1813. def test_update_script_module_storage_name_mismatch_fails(self):
  1814. """If the storage backend saves under an alternate name, the update is rejected and rolled back."""
  1815. self.add_permissions(
  1816. 'extras.add_scriptmodule', 'core.add_managedfile',
  1817. 'extras.change_scriptmodule', 'core.change_managedfile',
  1818. )
  1819. original_content = (
  1820. b"from extras.scripts import Script\n\n\n"
  1821. b"class ProbeScript(Script):\n def run(self, data, commit):\n return 'v1'\n"
  1822. )
  1823. updated_content = original_content.replace(b"'v1'", b"'v2'")
  1824. fake_storage = _InMemoryScriptStorage(allow_overwrite=False)
  1825. with self._patched_script_storage(fake_storage):
  1826. response = self.client.post(
  1827. self.url,
  1828. {'file': SimpleUploadedFile('zz_suffix.py', original_content, content_type='text/plain')},
  1829. format='multipart',
  1830. **self.header,
  1831. )
  1832. self.assertHttpStatus(response, status.HTTP_201_CREATED)
  1833. module = ScriptModule.objects.get(file_path='zz_suffix.py')
  1834. detail_url = reverse('extras-api:scriptmodule-detail', kwargs={'pk': module.pk})
  1835. response = self.client.patch(
  1836. detail_url,
  1837. {'file': SimpleUploadedFile('zz_suffix.py', updated_content, content_type='text/plain')},
  1838. format='multipart',
  1839. **self.header,
  1840. )
  1841. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  1842. # Original file intact, alternate-name orphan removed
  1843. self.assertEqual(fake_storage.files['zz_suffix.py'], original_content)
  1844. self.assertNotIn('zz_suffix.py.1', fake_storage.files)
  1845. module.refresh_from_db()
  1846. self.assertEqual(module.file_path, 'zz_suffix.py')
  1847. def test_update_faulty_script_module_preserves_existing_module(self):
  1848. """An update with invalid script content is rejected before storage or Script rows change."""
  1849. self.add_permissions(
  1850. 'extras.add_scriptmodule', 'core.add_managedfile',
  1851. 'extras.change_scriptmodule', 'core.change_managedfile',
  1852. )
  1853. original_content = (
  1854. b"from extras.scripts import Script\n\n\n"
  1855. b"class ProbeScript(Script):\n def run(self, data, commit):\n return 'v1'\n"
  1856. )
  1857. # 'extras.script' is invalid; the correct module is 'extras.scripts'
  1858. faulty_content = b"from extras.script import Script\nclass TestScript(Script):\n pass\n"
  1859. fake_storage = _InMemoryScriptStorage()
  1860. with self._patched_script_storage(fake_storage):
  1861. response = self.client.post(
  1862. self.url,
  1863. {'file': SimpleUploadedFile('zz_faulty_update.py', original_content, content_type='text/plain')},
  1864. format='multipart',
  1865. **self.header,
  1866. )
  1867. self.assertHttpStatus(response, status.HTTP_201_CREATED)
  1868. module_id = response.data['id']
  1869. detail_url = reverse('extras-api:scriptmodule-detail', kwargs={'pk': module_id})
  1870. response = self.client.patch(
  1871. detail_url,
  1872. {'file': SimpleUploadedFile('zz_faulty_update.py', faulty_content, content_type='text/plain')},
  1873. format='multipart',
  1874. **self.header,
  1875. )
  1876. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  1877. self.assertEqual(fake_storage.files['zz_faulty_update.py'], original_content)
  1878. self.assertTrue(Script.objects.filter(module_id=module_id, name='ProbeScript').exists())
  1879. def test_update_script_module_not_found(self):
  1880. """An update addressing a nonexistent module returns 404 for both lookup styles."""
  1881. self.add_permissions('extras.change_scriptmodule', 'core.change_managedfile')
  1882. for lookup in ('999999', 'zz_missing.py', '½'):
  1883. detail_url = reverse('extras-api:scriptmodule-detail', kwargs={'pk': lookup})
  1884. response = self.client.patch(detail_url, {}, format='json', **self.header)
  1885. self.assertHttpStatus(response, status.HTTP_404_NOT_FOUND)
  1886. def test_update_script_module_via_put_without_file_fails(self):
  1887. """A PUT without a file upload is rejected by the field-level required check."""
  1888. self.add_permissions(
  1889. 'extras.add_scriptmodule', 'core.add_managedfile',
  1890. 'extras.change_scriptmodule', 'core.change_managedfile',
  1891. )
  1892. script_content = b"from extras.scripts import Script\nclass TestScript(Script):\n pass\n"
  1893. fake_storage = _InMemoryScriptStorage()
  1894. with self._patched_script_storage(fake_storage):
  1895. response = self.client.post(
  1896. self.url,
  1897. {'file': SimpleUploadedFile('zz_put_nofile.py', script_content, content_type='text/plain')},
  1898. format='multipart',
  1899. **self.header,
  1900. )
  1901. self.assertHttpStatus(response, status.HTTP_201_CREATED)
  1902. detail_url = reverse('extras-api:scriptmodule-detail', kwargs={'pk': response.data['id']})
  1903. response = self.client.put(detail_url, {}, format='json', **self.header)
  1904. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  1905. self.assertIn('file', response.data)
  1906. self.assertEqual(fake_storage.files['zz_put_nofile.py'], script_content)
  1907. def test_update_script_module_rolls_back_scripts_on_save_failure(self):
  1908. """A failed Script sync during an update rolls back all Script row changes."""
  1909. self.add_permissions(
  1910. 'extras.add_scriptmodule', 'core.add_managedfile',
  1911. 'extras.change_scriptmodule', 'core.change_managedfile',
  1912. )
  1913. original_content = (
  1914. b"from extras.scripts import Script\n\n\n"
  1915. b"class ProbeScript(Script):\n def run(self, data, commit):\n return 'v1'\n"
  1916. )
  1917. updated_content = (
  1918. b"from extras.scripts import Script\n\n\n"
  1919. b"class ProbeScriptV2(Script):\n def run(self, data, commit):\n return 'v2'\n"
  1920. )
  1921. fake_storage = _InMemoryScriptStorage()
  1922. with self._patched_script_storage(fake_storage):
  1923. response = self.client.post(
  1924. self.url,
  1925. {'file': SimpleUploadedFile('zz_rollback.py', original_content, content_type='text/plain')},
  1926. format='multipart',
  1927. **self.header,
  1928. )
  1929. self.assertHttpStatus(response, status.HTTP_201_CREATED)
  1930. module_id = response.data['id']
  1931. detail_url = reverse('extras-api:scriptmodule-detail', kwargs={'pk': module_id})
  1932. # Fail the sync mid-way: the old Script row is already deleted when create() raises
  1933. with patch(
  1934. 'extras.models.scripts.Script.objects.create',
  1935. side_effect=IntegrityError('Simulated database error'),
  1936. ):
  1937. with self.assertRaises(IntegrityError):
  1938. self.client.patch(
  1939. detail_url,
  1940. {'file': SimpleUploadedFile('zz_rollback.py', updated_content, content_type='text/plain')},
  1941. format='multipart',
  1942. **self.header,
  1943. )
  1944. # DB is all-or-nothing; storage keeps the new content and a retried update re-syncs the rows
  1945. self.assertTrue(Script.objects.filter(module_id=module_id, name='ProbeScript').exists())
  1946. self.assertFalse(Script.objects.filter(module_id=module_id, name='ProbeScriptV2').exists())
  1947. self.assertEqual(fake_storage.files['zz_rollback.py'], updated_content)
  1948. def test_update_script_module_with_missing_stored_file(self):
  1949. """An update succeeds when the stored file is missing, re-creating it from the upload."""
  1950. self.add_permissions(
  1951. 'extras.add_scriptmodule', 'core.add_managedfile',
  1952. 'extras.change_scriptmodule', 'core.change_managedfile',
  1953. )
  1954. original_content = (
  1955. b"from extras.scripts import Script\n\n\n"
  1956. b"class ProbeScript(Script):\n def run(self, data, commit):\n return 'v1'\n"
  1957. )
  1958. updated_content = original_content.replace(b"'v1'", b"'v2'")
  1959. fake_storage = _InMemoryScriptStorage()
  1960. with self._patched_script_storage(fake_storage):
  1961. response = self.client.post(
  1962. self.url,
  1963. {'file': SimpleUploadedFile('zz_lost_file.py', original_content, content_type='text/plain')},
  1964. format='multipart',
  1965. **self.header,
  1966. )
  1967. self.assertHttpStatus(response, status.HTTP_201_CREATED)
  1968. detail_url = reverse('extras-api:scriptmodule-detail', kwargs={'pk': response.data['id']})
  1969. # Simulate a file removed from storage outside NetBox
  1970. del fake_storage.files['zz_lost_file.py']
  1971. response = self.client.patch(
  1972. detail_url,
  1973. {'file': SimpleUploadedFile('zz_lost_file.py', updated_content, content_type='text/plain')},
  1974. format='multipart',
  1975. **self.header,
  1976. )
  1977. self.assertHttpStatus(response, status.HTTP_200_OK)
  1978. self.assertEqual(fake_storage.files['zz_lost_file.py'], updated_content)