test_api.py 65 KB

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