test_views.py 67 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835
  1. import datetime
  2. import hashlib
  3. import logging
  4. import uuid
  5. from unittest.mock import PropertyMock, patch
  6. from django.contrib.contenttypes.models import ContentType
  7. from django.contrib.messages import get_messages
  8. from django.test import tag
  9. from django.urls import reverse
  10. from django.utils.html import escape
  11. from django.utils.timezone import now
  12. from core.choices import JobStatusChoices, ManagedFileRootPathChoices
  13. from core.events import *
  14. from core.models import DataFile, DataSource, Job, ObjectType
  15. from dcim.models import DeviceType, Manufacturer, Site
  16. from extras.choices import *
  17. from extras.models import *
  18. from extras.scripts import BooleanVar, IntegerVar, MultiChoiceVar, StringVar
  19. from extras.scripts import Script as PythonClass
  20. from netbox.choices import CSVDelimiterChoices, ImportFormatChoices
  21. from users.models import Group, ObjectPermission, User
  22. from utilities.testing import TestCase, ViewTestCases
  23. class CustomFieldTestCase(ViewTestCases.PrimaryObjectViewTestCase):
  24. model = CustomField
  25. @classmethod
  26. def setUpTestData(cls):
  27. site_type = ObjectType.objects.get_for_model(Site)
  28. CustomFieldChoiceSet.objects.create(
  29. name='Choice Set 1',
  30. extra_choices=(
  31. ('A', 'A'),
  32. ('B', 'B'),
  33. ('C', 'C'),
  34. )
  35. )
  36. custom_fields = (
  37. CustomField(name='field1', label='Field 1', type=CustomFieldTypeChoices.TYPE_TEXT),
  38. CustomField(name='field2', label='Field 2', type=CustomFieldTypeChoices.TYPE_TEXT),
  39. CustomField(name='field3', label='Field 3', type=CustomFieldTypeChoices.TYPE_TEXT),
  40. )
  41. for customfield in custom_fields:
  42. customfield.save()
  43. customfield.object_types.add(site_type)
  44. cls.form_data = {
  45. 'name': 'field_x',
  46. 'label': 'Field X',
  47. 'type': 'text',
  48. 'object_types': [site_type.pk],
  49. 'search_weight': 2000,
  50. 'filter_logic': CustomFieldFilterLogicChoices.FILTER_EXACT,
  51. 'default': None,
  52. 'weight': 200,
  53. 'required': True,
  54. 'ui_visible': CustomFieldUIVisibleChoices.ALWAYS,
  55. 'ui_editable': CustomFieldUIEditableChoices.YES,
  56. }
  57. cls.csv_data = (
  58. 'name,label,type,object_types,related_object_type,weight,search_weight,filter_logic,choice_set,validation_minimum,validation_maximum,validation_regex,ui_visible,ui_editable',
  59. 'field4,Field 4,text,dcim.site,,100,1000,exact,,,,[a-z]{3},always,yes',
  60. 'field5,Field 5,integer,dcim.site,,100,2000,exact,,1,100,,always,yes',
  61. 'field6,Field 6,select,dcim.site,,100,3000,exact,Choice Set 1,,,,always,yes',
  62. 'field7,Field 7,object,dcim.site,dcim.region,100,4000,exact,,,,,always,yes',
  63. )
  64. cls.csv_update_data = (
  65. 'id,label',
  66. f'{custom_fields[0].pk},New label 1',
  67. f'{custom_fields[1].pk},New label 2',
  68. f'{custom_fields[2].pk},New label 3',
  69. )
  70. cls.bulk_edit_data = {
  71. 'required': True,
  72. 'weight': 200,
  73. }
  74. class CustomFieldChoiceSetTestCase(ViewTestCases.PrimaryObjectViewTestCase):
  75. model = CustomFieldChoiceSet
  76. @classmethod
  77. def setUpTestData(cls):
  78. choice_sets = (
  79. CustomFieldChoiceSet(
  80. name='Choice Set 1',
  81. extra_choices=(('A1', 'Choice 1'), ('A2', 'Choice 2'), ('A3', 'Choice 3'))
  82. ),
  83. CustomFieldChoiceSet(
  84. name='Choice Set 2',
  85. extra_choices=(('B1', 'Choice 1'), ('B2', 'Choice 2'), ('B3', 'Choice 3'))
  86. ),
  87. CustomFieldChoiceSet(
  88. name='Choice Set 3',
  89. extra_choices=(('C1', 'Choice 1'), ('C2', 'Choice 2'), ('C3', 'Choice 3'))
  90. ),
  91. CustomFieldChoiceSet(
  92. name='Choice Set 4',
  93. extra_choices=(('D1', 'Choice 1'), ('D2', 'Choice 2'), ('D3', 'Choice 3'))
  94. ),
  95. )
  96. CustomFieldChoiceSet.objects.bulk_create(choice_sets)
  97. cls.form_data = {
  98. 'name': 'Choice Set X',
  99. 'extra_choices': '\n'.join(['X1:Choice 1', 'X2:Choice 2', 'X3:Choice 3'])
  100. }
  101. cls.csv_data = (
  102. 'name,extra_choices',
  103. 'Choice Set 5,"D1,D2,D3"',
  104. 'Choice Set 6,"E1,E2,E3"',
  105. 'Choice Set 7,"F1,F2,F3"',
  106. 'Choice Set 8,"F1:L1,F2:L2,F3:L3"',
  107. )
  108. cls.csv_update_data = (
  109. 'id,extra_choices',
  110. f'{choice_sets[0].pk},"A,B,C"',
  111. f'{choice_sets[1].pk},"A,B,C"',
  112. f'{choice_sets[2].pk},"A,B,C"',
  113. f'{choice_sets[3].pk},"A:L1,B:L2,C:L3"',
  114. )
  115. cls.bulk_edit_data = {
  116. 'description': 'New description',
  117. }
  118. # This is here as extra_choices field splits on colon, but is returned
  119. # from DB as comma separated.
  120. def assertInstanceEqual(self, instance, data, exclude=None, api=False):
  121. if 'extra_choices' in data:
  122. data['extra_choices'] = data['extra_choices'].replace(':', ',')
  123. return super().assertInstanceEqual(instance, data, exclude, api)
  124. class CustomLinkTestCase(ViewTestCases.PrimaryObjectViewTestCase):
  125. model = CustomLink
  126. @classmethod
  127. def setUpTestData(cls):
  128. site_type = ObjectType.objects.get_for_model(Site)
  129. custom_links = (
  130. CustomLink(name='Custom Link 1', enabled=True, link_text='Link 1', link_url='http://example.com/?1'),
  131. CustomLink(name='Custom Link 2', enabled=True, link_text='Link 2', link_url='http://example.com/?2'),
  132. CustomLink(name='Custom Link 3', enabled=False, link_text='Link 3', link_url='http://example.com/?3'),
  133. )
  134. CustomLink.objects.bulk_create(custom_links)
  135. for i, custom_link in enumerate(custom_links):
  136. custom_link.object_types.set([site_type])
  137. cls.form_data = {
  138. 'name': 'Custom Link X',
  139. 'object_types': [site_type.pk],
  140. 'enabled': False,
  141. 'weight': 100,
  142. 'button_class': CustomLinkButtonClassChoices.DEFAULT,
  143. 'link_text': 'Link X',
  144. 'link_url': 'http://example.com/?x'
  145. }
  146. cls.csv_data = (
  147. "name,object_types,enabled,weight,button_class,link_text,link_url",
  148. "Custom Link 4,dcim.site,True,100,blue,Link 4,http://exmaple.com/?4",
  149. "Custom Link 5,dcim.site,True,100,blue,Link 5,http://exmaple.com/?5",
  150. "Custom Link 6,dcim.site,False,100,blue,Link 6,http://exmaple.com/?6",
  151. )
  152. cls.csv_update_data = (
  153. "id,name",
  154. f"{custom_links[0].pk},Custom Link 7",
  155. f"{custom_links[1].pk},Custom Link 8",
  156. f"{custom_links[2].pk},Custom Link 9",
  157. )
  158. cls.bulk_edit_data = {
  159. 'button_class': CustomLinkButtonClassChoices.CYAN,
  160. 'enabled': False,
  161. 'weight': 200,
  162. }
  163. class CustomLinkRenderingTestCase(TestCase):
  164. user_permissions = ['dcim.view_site', 'extras.view_customlink']
  165. def test_view_object_with_custom_link(self):
  166. customlink = CustomLink(
  167. name='Test',
  168. link_text='FOO {{ object.name }} BAR',
  169. link_url='http://example.com/?site={{ object.slug }}',
  170. new_window=False
  171. )
  172. customlink.save()
  173. customlink.object_types.set([ObjectType.objects.get_for_model(Site)])
  174. site = Site(name='Test Site', slug='test-site')
  175. site.save()
  176. response = self.client.get(site.get_absolute_url(), follow=True)
  177. self.assertEqual(response.status_code, 200)
  178. self.assertIn(f'FOO {site.name} BAR', str(response.content))
  179. def test_list_view_custom_link_column(self):
  180. # A custom link column must render in the list view when the user can view the CustomLink.
  181. customlink = CustomLink(
  182. name='Test',
  183. link_text='FOO {{ object.name }} BAR',
  184. link_url='http://example.com/?site={{ object.slug }}',
  185. new_window=False
  186. )
  187. customlink.save()
  188. customlink.object_types.set([ObjectType.objects.get_for_model(Site)])
  189. site = Site(name='Test Site', slug='test-site')
  190. site.save()
  191. # Custom link columns are hidden by default; explicitly include the column
  192. response = self.client.get(f"{reverse('dcim:site_list')}?include_columns=cl_Test")
  193. self.assertEqual(response.status_code, 200)
  194. self.assertIn(f'FOO {site.name} BAR', str(response.content))
  195. def test_list_view_custom_link_column_hidden_without_permission(self):
  196. # A custom link column must be excluded from the list view when the user cannot view the
  197. # CustomLink, even if explicitly requested (#22439).
  198. customlink = CustomLink(
  199. name='Test',
  200. link_text='FOO {{ object.name }} BAR',
  201. link_url='http://example.com/?site={{ object.slug }}',
  202. new_window=False
  203. )
  204. customlink.save()
  205. customlink.object_types.set([ObjectType.objects.get_for_model(Site)])
  206. site = Site(name='Test Site', slug='test-site')
  207. site.save()
  208. # Revoke permission to view custom links
  209. self.remove_permissions('extras.view_customlink')
  210. response = self.client.get(f"{reverse('dcim:site_list')}?include_columns=cl_Test")
  211. self.assertEqual(response.status_code, 200)
  212. self.assertNotIn(f'FOO {site.name} BAR', str(response.content))
  213. def test_list_view_custom_link_column_escapes_render_error(self):
  214. # Jinja2 includes the invalid key verbatim in UndefinedError; this test intentionally depends on that format.
  215. payload = '" ></span><script>alert(1)</script>'
  216. customlink = CustomLink(
  217. name='Test',
  218. link_text=f"{{{{ ''['{payload}'] + 1 }}}}",
  219. link_url='http://example.com/',
  220. new_window=False
  221. )
  222. customlink.save()
  223. customlink.object_types.set([ObjectType.objects.get_for_model(Site)])
  224. site = Site(name='Test Site', slug='test-site')
  225. site.save()
  226. response = self.client.get(f"{reverse('dcim:site_list')}?include_columns=cl_Test")
  227. self.assertEqual(response.status_code, 200)
  228. content = response.content.decode()
  229. # The error element must be present, but the payload must appear only in escaped form
  230. self.assertIn('<span class="text-danger" title="', content)
  231. self.assertNotIn(payload, content)
  232. self.assertIn(escape(payload), content)
  233. class SavedFilterTestCase(ViewTestCases.PrimaryObjectViewTestCase):
  234. model = SavedFilter
  235. @classmethod
  236. def setUpTestData(cls):
  237. site_type = ObjectType.objects.get_for_model(Site)
  238. users = (
  239. User(username='User 1'),
  240. User(username='User 2'),
  241. User(username='User 3'),
  242. )
  243. User.objects.bulk_create(users)
  244. saved_filters = (
  245. SavedFilter(
  246. name='Saved Filter 1',
  247. slug='saved-filter-1',
  248. user=users[0],
  249. weight=100,
  250. parameters={'status': ['active']}
  251. ),
  252. SavedFilter(
  253. name='Saved Filter 2',
  254. slug='saved-filter-2',
  255. user=users[1],
  256. weight=200,
  257. parameters={'status': ['planned']}
  258. ),
  259. SavedFilter(
  260. name='Saved Filter 3',
  261. slug='saved-filter-3',
  262. user=users[2],
  263. weight=300,
  264. parameters={'status': ['retired']}
  265. ),
  266. )
  267. SavedFilter.objects.bulk_create(saved_filters)
  268. for i, savedfilter in enumerate(saved_filters):
  269. savedfilter.object_types.set([site_type])
  270. cls.form_data = {
  271. 'name': 'Saved Filter X',
  272. 'slug': 'saved-filter-x',
  273. 'object_types': [site_type.pk],
  274. 'description': 'Foo',
  275. 'weight': 1000,
  276. 'enabled': True,
  277. 'shared': True,
  278. 'parameters': '{"foo": 123}',
  279. }
  280. cls.csv_data = (
  281. 'name,slug,object_types,weight,enabled,shared,parameters',
  282. 'Saved Filter 4,saved-filter-4,dcim.device,400,True,True,{"foo": "a"}',
  283. 'Saved Filter 5,saved-filter-5,dcim.device,500,True,True,{"foo": "b"}',
  284. 'Saved Filter 6,saved-filter-6,dcim.device,600,True,True,{"foo": "c"}',
  285. )
  286. cls.csv_update_data = (
  287. "id,name",
  288. f"{saved_filters[0].pk},Saved Filter 7",
  289. f"{saved_filters[1].pk},Saved Filter 8",
  290. f"{saved_filters[2].pk},Saved Filter 9",
  291. )
  292. cls.bulk_edit_data = {
  293. 'weight': 999,
  294. }
  295. class TableConfigTestCase(
  296. ViewTestCases.GetObjectViewTestCase,
  297. ViewTestCases.GetObjectChangelogViewTestCase,
  298. ViewTestCases.CreateObjectViewTestCase,
  299. ViewTestCases.EditObjectViewTestCase,
  300. ViewTestCases.DeleteObjectViewTestCase,
  301. ViewTestCases.ListObjectsViewTestCase,
  302. ViewTestCases.BulkEditObjectsViewTestCase,
  303. ViewTestCases.BulkDeleteObjectsViewTestCase,
  304. ):
  305. model = TableConfig
  306. # Selected columns are POSTed as a list but compared as a CSV string
  307. validation_excluded_fields = ('columns',)
  308. @classmethod
  309. def setUpTestData(cls):
  310. site_type = ObjectType.objects.get_for_model(Site)
  311. users = (
  312. User(username='User 1'),
  313. User(username='User 2'),
  314. User(username='User 3'),
  315. )
  316. User.objects.bulk_create(users)
  317. table_configs = (
  318. TableConfig(
  319. name='Table Config 1',
  320. object_type=site_type,
  321. table='SiteTable',
  322. user=users[0],
  323. columns=['name', 'status'],
  324. ),
  325. TableConfig(
  326. name='Table Config 2',
  327. object_type=site_type,
  328. table='SiteTable',
  329. user=users[1],
  330. columns=['name', 'region'],
  331. ),
  332. TableConfig(
  333. name='Table Config 3',
  334. object_type=site_type,
  335. table='SiteTable',
  336. user=users[2],
  337. columns=['name', 'tenant'],
  338. ),
  339. )
  340. TableConfig.objects.bulk_create(table_configs)
  341. cls.form_data = {
  342. 'name': 'Table Config X',
  343. 'object_type': site_type.pk,
  344. 'table': 'SiteTable',
  345. 'description': 'A table config',
  346. 'weight': 100,
  347. 'enabled': True,
  348. 'shared': True,
  349. 'columns': ['name', 'status'],
  350. 'ordering': 'name',
  351. }
  352. cls.bulk_edit_data = {
  353. 'weight': 999,
  354. }
  355. def _get_url(self, action, instance=None):
  356. url = super()._get_url(action, instance)
  357. # The add view requires the table context from the source table view
  358. if action == 'add':
  359. site_type = ObjectType.objects.get_for_model(Site)
  360. url = f'{url}?object_type={site_type.pk}&table=SiteTable'
  361. return url
  362. def test_add_view_without_table_context(self):
  363. """A GET without the table context params must redirect to the home page."""
  364. self.add_permissions('extras.add_tableconfig')
  365. response = self.client.get(reverse('extras:tableconfig_add'))
  366. self.assertRedirects(response, reverse('home'))
  367. messages_list = list(get_messages(response.wsgi_request))
  368. self.assertEqual(len(messages_list), 1)
  369. self.assertEqual(str(messages_list[0]), 'Table configurations must be created from an object list view.')
  370. def test_add_view_post_without_table_context(self):
  371. """A POST without the table context must return form errors rather than a server error."""
  372. self.add_permissions('extras.add_tableconfig')
  373. response = self.client.post(reverse('extras:tableconfig_add'), data={})
  374. self.assertHttpStatus(response, 200)
  375. class BookmarkTestCase(
  376. ViewTestCases.DeleteObjectViewTestCase,
  377. ViewTestCases.ListObjectsViewTestCase,
  378. ViewTestCases.BulkDeleteObjectsViewTestCase
  379. ):
  380. model = Bookmark
  381. @classmethod
  382. def setUpTestData(cls):
  383. site_ct = ContentType.objects.get_for_model(Site)
  384. sites = (
  385. Site(name='Site 1', slug='site-1'),
  386. Site(name='Site 2', slug='site-2'),
  387. Site(name='Site 3', slug='site-3'),
  388. Site(name='Site 4', slug='site-4'),
  389. )
  390. Site.objects.bulk_create(sites)
  391. cls.form_data = {
  392. 'object_type': site_ct.pk,
  393. 'object_id': sites[3].pk,
  394. }
  395. def setUp(self):
  396. super().setUp()
  397. sites = Site.objects.all()
  398. user = self.user
  399. bookmarks = (
  400. Bookmark(object=sites[0], user=user),
  401. Bookmark(object=sites[1], user=user),
  402. Bookmark(object=sites[2], user=user),
  403. )
  404. Bookmark.objects.bulk_create(bookmarks)
  405. def _get_url(self, action, instance=None):
  406. if action == 'list':
  407. return reverse('account:bookmarks')
  408. return super()._get_url(action, instance)
  409. def test_list_objects_anonymous(self):
  410. return
  411. def test_export_objects_anonymous(self):
  412. return
  413. def test_list_objects_with_constrained_permission(self):
  414. return
  415. class ImageAttachmentTestCase(
  416. ViewTestCases.GetObjectViewTestCase,
  417. ViewTestCases.ListObjectsViewTestCase,
  418. ViewTestCases.DeleteObjectViewTestCase,
  419. ViewTestCases.BulkDeleteObjectsViewTestCase,
  420. ):
  421. # Add/Edit/BulkEdit are omitted: ImageField.save() re-reads the file to
  422. # populate image_height / image_width, which fails when fixtures use
  423. # placeholder URLs instead of real images on disk.
  424. model = ImageAttachment
  425. def setUp(self):
  426. super().setUp()
  427. # The fixtures use placeholder image URLs with no file on disk, so rendering the thumbnail
  428. # column logs a FileNotFoundError traceback for every attachment. The missing files are
  429. # expected here, so mute the sorl-thumbnail logger to keep the test output clean.
  430. logger = logging.getLogger('sorl.thumbnail')
  431. original_level = logger.level
  432. logger.setLevel(logging.CRITICAL)
  433. self.addCleanup(logger.setLevel, original_level)
  434. @classmethod
  435. def setUpTestData(cls):
  436. ct = ContentType.objects.get_for_model(Site)
  437. site = Site.objects.create(name='Site 1', slug='site-1')
  438. ImageAttachment.objects.bulk_create(
  439. [
  440. ImageAttachment(
  441. object_type=ct,
  442. object_id=site.pk,
  443. name='Image Attachment 1',
  444. image='http://example.com/image1.png',
  445. image_height=100,
  446. image_width=100,
  447. image_size=1024,
  448. ),
  449. ImageAttachment(
  450. object_type=ct,
  451. object_id=site.pk,
  452. name='Image Attachment 2',
  453. image='http://example.com/image2.png',
  454. image_height=100,
  455. image_width=100,
  456. image_size=2048,
  457. ),
  458. ImageAttachment(
  459. object_type=ct,
  460. object_id=site.pk,
  461. name='Image Attachment 3',
  462. image='http://example.com/image3.png',
  463. image_height=100,
  464. image_width=100,
  465. image_size=4096,
  466. ),
  467. ]
  468. )
  469. class ExportTemplateTestCase(ViewTestCases.PrimaryObjectViewTestCase):
  470. model = ExportTemplate
  471. @classmethod
  472. def setUpTestData(cls):
  473. site_type = ObjectType.objects.get_for_model(Site)
  474. TEMPLATE_CODE = """{% for object in queryset %}{{ object }}{% endfor %}"""
  475. ENVIRONMENT_PARAMS = """{"trim_blocks": true}"""
  476. export_templates = (
  477. ExportTemplate(name='Export Template 1', template_code=TEMPLATE_CODE),
  478. ExportTemplate(
  479. name='Export Template 2', template_code=TEMPLATE_CODE, environment_params={"trim_blocks": True}
  480. ),
  481. ExportTemplate(name='Export Template 3', template_code=TEMPLATE_CODE, file_name='export_template_3')
  482. )
  483. ExportTemplate.objects.bulk_create(export_templates)
  484. for et in export_templates:
  485. et.object_types.set([site_type])
  486. datasource = DataSource.objects.create(
  487. name='Data Source 1',
  488. type='local',
  489. source_url='file:///tmp/netbox-datasource/',
  490. )
  491. cls.file_data = b'{% for object in queryset %}{{ object.name }}{% endfor %}'
  492. cls.datafile = DataFile.objects.create(
  493. source=datasource,
  494. path='exports/sites.j2',
  495. last_updated=now(),
  496. size=len(cls.file_data),
  497. hash=hashlib.sha256(cls.file_data).hexdigest(),
  498. data=cls.file_data,
  499. )
  500. # The same path in two sources, so a path alone cannot identify the file
  501. other_datasource = DataSource.objects.create(
  502. name='Data Source 2',
  503. type='local',
  504. source_url='file:///tmp/netbox-datasource-2/',
  505. )
  506. cls.shared_path = 'exports/shared.j2'
  507. cls.shared_datafile = DataFile.objects.create(
  508. source=datasource,
  509. path=cls.shared_path,
  510. last_updated=now(),
  511. size=len(cls.file_data),
  512. hash=hashlib.sha256(cls.file_data).hexdigest(),
  513. data=cls.file_data,
  514. )
  515. DataFile.objects.create(
  516. source=other_datasource,
  517. path=cls.shared_path,
  518. last_updated=now(),
  519. size=len(cls.file_data),
  520. hash=hashlib.sha256(cls.file_data).hexdigest(),
  521. data=cls.file_data,
  522. )
  523. cls.form_data = {
  524. 'name': 'Export Template X',
  525. 'object_types': [site_type.pk],
  526. 'template_code': TEMPLATE_CODE,
  527. 'environment_params': ENVIRONMENT_PARAMS,
  528. 'file_name': 'template_x',
  529. }
  530. cls.csv_data = {
  531. 'default': (
  532. "name,object_types,template_code,file_name",
  533. f"Export Template 4,dcim.site,{TEMPLATE_CODE},",
  534. f"Export Template 5,dcim.site,{TEMPLATE_CODE},template_5",
  535. f"Export Template 6,dcim.site,{TEMPLATE_CODE},",
  536. ),
  537. 'with_data_file': (
  538. "name,object_types,data_file,auto_sync_enabled",
  539. f"Export Template 10,dcim.site,{cls.datafile.path},true",
  540. ),
  541. 'with_duplicate_path': (
  542. "name,object_types,data_source,data_file",
  543. f"Export Template 12,dcim.site,{datasource.name},{cls.shared_path}",
  544. ),
  545. }
  546. cls.csv_update_data = (
  547. "id,name",
  548. f"{export_templates[0].pk},Export Template 7",
  549. f"{export_templates[1].pk},Export Template 8",
  550. f"{export_templates[2].pk},Export Template 9",
  551. )
  552. cls.bulk_edit_data = {
  553. 'mime_type': 'text/html',
  554. 'file_extension': 'html',
  555. 'as_attachment': True,
  556. }
  557. def test_bulk_import_objects_with_permission(self):
  558. def verify_data_file(scenario_name):
  559. if scenario_name != 'with_data_file':
  560. return
  561. export_template = ExportTemplate.objects.get(name='Export Template 10')
  562. self.assertEqual(export_template.data_file, self.datafile)
  563. self.assertEqual(export_template.data_source, self.datafile.source)
  564. self.assertEqual(export_template.data_path, self.datafile.path)
  565. self.assertEqual(export_template.template_code, self.file_data.decode('utf-8'))
  566. self.assertTrue(export_template.auto_sync_enabled)
  567. def verify_duplicate_path(scenario_name):
  568. if scenario_name != 'with_duplicate_path':
  569. return
  570. export_template = ExportTemplate.objects.get(name='Export Template 12')
  571. self.assertEqual(export_template.data_file, self.shared_datafile)
  572. def verify(scenario_name):
  573. verify_data_file(scenario_name)
  574. verify_duplicate_path(scenario_name)
  575. super().test_bulk_import_objects_with_permission(post_import_callback=verify)
  576. def test_bulk_import_without_content_or_data_file(self):
  577. """A row supplying neither template code nor a data file is rejected."""
  578. self.add_permissions('extras.add_exporttemplate')
  579. initial_count = ExportTemplate.objects.count()
  580. response = self.client.post(self._get_url('bulk_import'), {
  581. 'data': "name,object_types\nExport Template 11,dcim.site",
  582. 'format': ImportFormatChoices.CSV,
  583. 'csv_delimiter': CSVDelimiterChoices.AUTO,
  584. })
  585. self.assertHttpStatus(response, 200)
  586. self.assertEqual(ExportTemplate.objects.count(), initial_count)
  587. def test_bulk_import_with_source_id_column(self):
  588. """A data_source.id column scopes the file lookup, and a malformed id is a row error."""
  589. self.add_permissions('core.view_datafile', 'core.view_datasource', 'extras.add_exporttemplate')
  590. source_id = self.shared_datafile.source_id
  591. for label, value, expected_status in (
  592. ('valid', str(source_id), 302),
  593. ('malformed', 'not-a-number', 200),
  594. ):
  595. with self.subTest(source_id=label):
  596. response = self.client.post(self._get_url('bulk_import'), {
  597. 'data': f"name,object_types,data_source.id,data_file\n"
  598. f"Export Template {label},dcim.site,{value},{self.shared_path}",
  599. 'format': ImportFormatChoices.CSV,
  600. 'csv_delimiter': CSVDelimiterChoices.AUTO,
  601. })
  602. self.assertHttpStatus(response, expected_status)
  603. export_template = ExportTemplate.objects.get(name='Export Template valid')
  604. self.assertEqual(export_template.data_file, self.shared_datafile)
  605. self.assertFalse(ExportTemplate.objects.filter(name='Export Template malformed').exists())
  606. def test_bulk_import_update_with_source_but_no_file_column(self):
  607. """An update record naming a source but no file does not trip the scoped file lookup."""
  608. self.add_permissions(
  609. 'core.view_datafile',
  610. 'core.view_datasource',
  611. 'extras.add_exporttemplate',
  612. 'extras.change_exporttemplate',
  613. )
  614. export_template = ExportTemplate.objects.get(name='Export Template 1')
  615. response = self.client.post(self._get_url('bulk_import'), {
  616. 'data': f"id,data_source\n{export_template.pk},{self.datafile.source.name}",
  617. 'format': ImportFormatChoices.CSV,
  618. 'csv_delimiter': CSVDelimiterChoices.AUTO,
  619. })
  620. self.assertHttpStatus(response, 302)
  621. def test_bulk_import_update_cannot_blank_template_code(self):
  622. """An update row clearing template code on a template with no data file is rejected."""
  623. self.add_permissions('extras.add_exporttemplate', 'extras.change_exporttemplate')
  624. export_template = ExportTemplate.objects.get(name='Export Template 1')
  625. response = self.client.post(self._get_url('bulk_import'), {
  626. 'data': f"id,template_code\n{export_template.pk},",
  627. 'format': ImportFormatChoices.CSV,
  628. 'csv_delimiter': CSVDelimiterChoices.AUTO,
  629. })
  630. self.assertHttpStatus(response, 200)
  631. export_template.refresh_from_db()
  632. self.assertNotEqual(export_template.template_code, '')
  633. def test_content_is_not_cacheable(self):
  634. """
  635. The detail view renders the template code inline, which may have been synced from a data
  636. file containing sensitive values, so the response must not be cached by the browser.
  637. """
  638. export_template = ExportTemplate.objects.first()
  639. export_template.template_code = 'super-secret-password'
  640. export_template.save()
  641. self.add_permissions('extras.view_exporttemplate')
  642. response = self.client.get(export_template.get_absolute_url())
  643. self.assertHttpStatus(response, 200)
  644. # Confirm the template code is in fact rendered in the response
  645. self.assertIn(b'super-secret-password', response.content)
  646. self.assertNotCacheable(response)
  647. class ExportTemplateExportFlowTestCase(TestCase):
  648. """
  649. End-to-end test for ExportTemplate invocation via a list view's ?export=<name> query param.
  650. """
  651. @classmethod
  652. def setUpTestData(cls):
  653. Site.objects.bulk_create([
  654. Site(name='Site A', slug='site-a'),
  655. Site(name='Site B', slug='site-b'),
  656. ])
  657. site_type = ObjectType.objects.get_for_model(Site)
  658. ok_template = ExportTemplate.objects.create(
  659. name='Sites Export',
  660. template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
  661. mime_type='text/plain',
  662. file_extension='txt',
  663. )
  664. ok_template.object_types.set([site_type])
  665. broken_template = ExportTemplate.objects.create(
  666. name='Broken Export',
  667. template_code='{% for obj in queryset %}{{ obj.name ', # unterminated expression
  668. )
  669. broken_template.object_types.set([site_type])
  670. def test_export_template_invocation(self):
  671. self.add_permissions('dcim.view_site', 'extras.view_exporttemplate')
  672. url = reverse('dcim:site_list')
  673. response = self.client.get(f'{url}?export=Sites Export')
  674. self.assertEqual(response.status_code, 200)
  675. self.assertEqual(response['Content-Type'], 'text/plain')
  676. self.assertEqual(response['Content-Disposition'], 'attachment; filename="netbox_sites.txt"')
  677. # The rendered queryset reflects whatever ordering the list view applies. Assert on set
  678. # membership rather than line order so the test isn't coupled to Site's natural ordering.
  679. rendered_names = set(filter(None, response.content.decode().split('\n')))
  680. self.assertEqual(rendered_names, {'Site A', 'Site B'})
  681. def test_export_template_render_error_redirects(self):
  682. self.add_permissions('dcim.view_site', 'extras.view_exporttemplate')
  683. url = reverse('dcim:site_list')
  684. # A broken template surfaces an exception during render; the view catches it and redirects
  685. # back to the (filtered) list view rather than returning a 500.
  686. response = self.client.get(f'{url}?export=Broken Export')
  687. self.assertEqual(response.status_code, 302)
  688. self.assertTrue(response['Location'].startswith(url))
  689. self.assertNotIn('export=', response['Location'])
  690. class WebhookTestCase(ViewTestCases.PrimaryObjectViewTestCase):
  691. model = Webhook
  692. @classmethod
  693. def setUpTestData(cls):
  694. webhooks = (
  695. Webhook(name='Webhook 1', payload_url='http://example.com/?1', http_method='POST'),
  696. Webhook(name='Webhook 2', payload_url='http://example.com/?2', http_method='POST'),
  697. Webhook(name='Webhook 3', payload_url='http://example.com/?3', http_method='POST'),
  698. )
  699. for webhook in webhooks:
  700. webhook.save()
  701. cls.form_data = {
  702. 'name': 'Webhook X',
  703. 'payload_url': 'http://example.com/?x',
  704. 'http_method': 'GET',
  705. 'http_content_type': 'application/foo',
  706. 'timeout': 45,
  707. 'description': 'My webhook',
  708. }
  709. cls.csv_data = (
  710. "name,payload_url,http_method,http_content_type,timeout,description",
  711. "Webhook 4,http://example.com/?4,GET,application/json,15,Foo",
  712. "Webhook 5,http://example.com/?5,GET,application/json,,Bar",
  713. "Webhook 6,http://example.com/?6,GET,application/json,,Baz",
  714. )
  715. cls.csv_update_data = (
  716. "id,name,description",
  717. f"{webhooks[0].pk},Webhook 7,Foo",
  718. f"{webhooks[1].pk},Webhook 8,Bar",
  719. f"{webhooks[2].pk},Webhook 9,Baz",
  720. )
  721. cls.bulk_edit_data = {
  722. 'http_method': 'GET',
  723. 'timeout': 60,
  724. }
  725. class EventRulesTestCase(ViewTestCases.PrimaryObjectViewTestCase):
  726. model = EventRule
  727. @classmethod
  728. def setUpTestData(cls):
  729. webhooks = (
  730. Webhook(name='Webhook 1', payload_url='http://example.com/?1', http_method='POST'),
  731. Webhook(name='Webhook 2', payload_url='http://example.com/?2', http_method='POST'),
  732. Webhook(name='Webhook 3', payload_url='http://example.com/?3', http_method='POST'),
  733. )
  734. for webhook in webhooks:
  735. webhook.save()
  736. site_type = ObjectType.objects.get_for_model(Site)
  737. event_rules = (
  738. EventRule(name='EventRule 1', event_types=[OBJECT_CREATED], action_object=webhooks[0]),
  739. EventRule(name='EventRule 2', event_types=[OBJECT_CREATED], action_object=webhooks[1]),
  740. EventRule(name='EventRule 3', event_types=[OBJECT_CREATED], action_object=webhooks[2]),
  741. )
  742. for event in event_rules:
  743. event.save()
  744. event.object_types.add(site_type)
  745. webhook_ct = ContentType.objects.get_for_model(Webhook)
  746. cls.form_data = {
  747. 'name': 'Event X',
  748. 'object_types': [site_type.pk],
  749. 'event_types': [OBJECT_UPDATED, OBJECT_DELETED],
  750. 'conditions': None,
  751. 'action_type': 'webhook',
  752. 'action_object_type': webhook_ct.pk,
  753. 'action_object_id': webhooks[0].pk,
  754. 'action_choice': webhooks[0],
  755. 'description': 'New description',
  756. }
  757. cls.csv_data = (
  758. 'name,object_types,event_types,action_type,action_object',
  759. f'Webhook 4,dcim.site,"{OBJECT_CREATED},{OBJECT_UPDATED}",webhook,Webhook 1',
  760. )
  761. cls.csv_update_data = (
  762. "id,name",
  763. f"{event_rules[0].pk},Event 7",
  764. f"{event_rules[1].pk},Event 8",
  765. f"{event_rules[2].pk},Event 9",
  766. )
  767. cls.bulk_edit_data = {
  768. 'description': 'New description',
  769. }
  770. class TagTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
  771. model = Tag
  772. @classmethod
  773. def setUpTestData(cls):
  774. site_ct = ContentType.objects.get_for_model(Site)
  775. tags = (
  776. Tag(name='Tag 1', slug='tag-1'),
  777. Tag(name='Tag 2', slug='tag-2', weight=1),
  778. Tag(name='Tag 3', slug='tag-3', weight=32767),
  779. )
  780. Tag.objects.bulk_create(tags)
  781. cls.form_data = {
  782. 'name': 'Tag X',
  783. 'slug': 'tag-x',
  784. 'color': 'c0c0c0',
  785. 'comments': 'Some comments',
  786. 'object_types': [site_ct.pk],
  787. 'weight': 11,
  788. }
  789. cls.csv_data = (
  790. "name,slug,color,description,object_types,weight",
  791. "Tag 4,tag-4,ff0000,Fourth tag,dcim.interface,0",
  792. "Tag 5,tag-5,00ff00,Fifth tag,'dcim.device,dcim.site',1111",
  793. "Tag 6,tag-6,0000ff,Sixth tag,dcim.site,0",
  794. )
  795. cls.csv_update_data = (
  796. "id,name,description",
  797. f"{tags[0].pk},Tag 7,Fourth tag7",
  798. f"{tags[1].pk},Tag 8,Fifth tag8",
  799. f"{tags[2].pk},Tag 9,Sixth tag9",
  800. )
  801. cls.bulk_edit_data = {
  802. 'color': '00ff00',
  803. }
  804. class ConfigContextProfileTestCase(ViewTestCases.PrimaryObjectViewTestCase):
  805. model = ConfigContextProfile
  806. @classmethod
  807. def setUpTestData(cls):
  808. profiles = (
  809. ConfigContextProfile(
  810. name='Config Context Profile 1',
  811. schema={
  812. "properties": {
  813. "foo": {
  814. "type": "string"
  815. }
  816. },
  817. "required": [
  818. "foo"
  819. ]
  820. }
  821. ),
  822. ConfigContextProfile(
  823. name='Config Context Profile 2',
  824. schema={
  825. "properties": {
  826. "bar": {
  827. "type": "string"
  828. }
  829. },
  830. "required": [
  831. "bar"
  832. ]
  833. }
  834. ),
  835. ConfigContextProfile(
  836. name='Config Context Profile 3',
  837. schema={
  838. "properties": {
  839. "baz": {
  840. "type": "string"
  841. }
  842. },
  843. "required": [
  844. "baz"
  845. ]
  846. }
  847. ),
  848. )
  849. ConfigContextProfile.objects.bulk_create(profiles)
  850. cls.form_data = {
  851. 'name': 'Config Context Profile X',
  852. 'description': 'A new config context profile',
  853. }
  854. cls.bulk_edit_data = {
  855. 'description': 'New description',
  856. }
  857. cls.csv_data = (
  858. 'name,description',
  859. 'Config context profile 1,Foo',
  860. 'Config context profile 2,Bar',
  861. 'Config context profile 3,Baz',
  862. )
  863. cls.csv_update_data = (
  864. "id,description",
  865. f"{profiles[0].pk},New description",
  866. f"{profiles[1].pk},New description",
  867. f"{profiles[2].pk},New description",
  868. )
  869. def test_content_is_not_cacheable(self):
  870. """
  871. The detail view renders the schema inline, which may have been synced from a data file
  872. containing sensitive values, so the response must not be cached by the browser.
  873. """
  874. instance = ConfigContextProfile.objects.first()
  875. instance.schema = {
  876. 'properties': {
  877. 'password': {
  878. 'type': 'string',
  879. 'default': 'super-secret-password',
  880. }
  881. }
  882. }
  883. instance.save()
  884. self.add_permissions('extras.view_configcontextprofile')
  885. response = self.client.get(instance.get_absolute_url())
  886. self.assertHttpStatus(response, 200)
  887. # Confirm the schema is in fact rendered in the response
  888. self.assertIn(b'super-secret-password', response.content)
  889. self.assertNotCacheable(response)
  890. # TODO: Change base class to PrimaryObjectViewTestCase
  891. # Blocked by absence of standard create/edit, bulk create views
  892. class ConfigContextTestCase(
  893. ViewTestCases.GetObjectViewTestCase,
  894. ViewTestCases.GetObjectChangelogViewTestCase,
  895. ViewTestCases.DeleteObjectViewTestCase,
  896. ViewTestCases.ListObjectsViewTestCase,
  897. ViewTestCases.BulkEditObjectsViewTestCase,
  898. ViewTestCases.BulkDeleteObjectsViewTestCase
  899. ):
  900. model = ConfigContext
  901. @classmethod
  902. def setUpTestData(cls):
  903. manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
  904. devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
  905. # Create three ConfigContexts
  906. for i in range(1, 4):
  907. configcontext = ConfigContext(
  908. name='Config Context {}'.format(i),
  909. data={'foo': i}
  910. )
  911. configcontext.save()
  912. configcontext.device_types.add(devicetype)
  913. cls.form_data = {
  914. 'name': 'Config Context X',
  915. 'weight': 200,
  916. 'description': 'A new config context',
  917. 'is_active': True,
  918. 'regions': [],
  919. 'sites': [],
  920. 'roles': [],
  921. 'platforms': [],
  922. 'tenant_groups': [],
  923. 'tenants': [],
  924. 'device_types': [devicetype.id],
  925. 'tags': [],
  926. 'data': '{"foo": 123}',
  927. }
  928. cls.bulk_edit_data = {
  929. 'weight': 300,
  930. 'is_active': False,
  931. 'description': 'New description',
  932. }
  933. def test_content_is_not_cacheable(self):
  934. """
  935. The detail view renders the data inline, which may have been synced from a data
  936. file containing sensitive values, so the response must not be cached by the browser.
  937. """
  938. instance = ConfigContext.objects.first()
  939. instance.data = {'password': 'super-secret-password'}
  940. instance.save()
  941. self.add_permissions('extras.view_configcontext')
  942. response = self.client.get(instance.get_absolute_url())
  943. self.assertHttpStatus(response, 200)
  944. # Confirm the context data is in fact rendered in the response
  945. self.assertIn(b'super-secret-password', response.content)
  946. self.assertNotCacheable(response)
  947. class ConfigTemplateTestCase(
  948. ViewTestCases.GetObjectViewTestCase,
  949. ViewTestCases.GetObjectChangelogViewTestCase,
  950. ViewTestCases.DeleteObjectViewTestCase,
  951. ViewTestCases.ListObjectsViewTestCase,
  952. ViewTestCases.BulkEditObjectsViewTestCase,
  953. ViewTestCases.BulkDeleteObjectsViewTestCase
  954. ):
  955. model = ConfigTemplate
  956. @classmethod
  957. def setUpTestData(cls):
  958. TEMPLATE_CODE = """Foo: {{ foo }}"""
  959. ENVIRONMENT_PARAMS = """{"trim_blocks": true}"""
  960. config_templates = (
  961. ConfigTemplate(
  962. name='Config Template 1',
  963. template_code=TEMPLATE_CODE)
  964. ,
  965. ConfigTemplate(
  966. name='Config Template 2',
  967. template_code=TEMPLATE_CODE,
  968. environment_params={"trim_blocks": True},
  969. ),
  970. ConfigTemplate(
  971. name='Config Template 3',
  972. template_code=TEMPLATE_CODE,
  973. file_name='config_template_3',
  974. ),
  975. )
  976. ConfigTemplate.objects.bulk_create(config_templates)
  977. cls.form_data = {
  978. 'name': 'Config Template X',
  979. 'description': 'Config template',
  980. 'template_code': TEMPLATE_CODE,
  981. 'environment_params': ENVIRONMENT_PARAMS,
  982. 'file_name': 'config_x',
  983. }
  984. cls.csv_update_data = (
  985. "id,name",
  986. f"{config_templates[0].pk},Config Template 7",
  987. f"{config_templates[1].pk},Config Template 8",
  988. f"{config_templates[2].pk},Config Template 9",
  989. )
  990. cls.bulk_edit_data = {
  991. 'description': 'New description',
  992. 'mime_type': 'text/html',
  993. 'file_name': 'output',
  994. 'file_extension': 'html',
  995. 'as_attachment': True,
  996. }
  997. def test_content_is_not_cacheable(self):
  998. """
  999. The detail view renders the template code inline, which may have been synced from a data
  1000. file containing sensitive values, so the response must not be cached by the browser.
  1001. """
  1002. instance = ConfigTemplate.objects.first()
  1003. instance.template_code = 'super-secret-password'
  1004. instance.save()
  1005. self.add_permissions('extras.view_configtemplate')
  1006. response = self.client.get(instance.get_absolute_url())
  1007. self.assertHttpStatus(response, 200)
  1008. # Confirm the template code is in fact rendered in the response
  1009. self.assertIn(b'super-secret-password', response.content)
  1010. self.assertNotCacheable(response)
  1011. class JournalEntryTestCase(
  1012. # ViewTestCases.GetObjectViewTestCase,
  1013. ViewTestCases.CreateObjectViewTestCase,
  1014. ViewTestCases.EditObjectViewTestCase,
  1015. ViewTestCases.DeleteObjectViewTestCase,
  1016. ViewTestCases.ListObjectsViewTestCase,
  1017. ViewTestCases.BulkEditObjectsViewTestCase,
  1018. ViewTestCases.BulkDeleteObjectsViewTestCase
  1019. ):
  1020. model = JournalEntry
  1021. @classmethod
  1022. def setUpTestData(cls):
  1023. site_ct = ContentType.objects.get_for_model(Site)
  1024. site = Site.objects.create(name='Site 1', slug='site-1')
  1025. user = User.objects.create(username='User 1')
  1026. JournalEntry.objects.bulk_create((
  1027. JournalEntry(assigned_object=site, created_by=user, comments='First entry'),
  1028. JournalEntry(assigned_object=site, created_by=user, comments='Second entry'),
  1029. JournalEntry(assigned_object=site, created_by=user, comments='Third entry'),
  1030. ))
  1031. cls.form_data = {
  1032. 'assigned_object_type': site_ct.pk,
  1033. 'assigned_object_id': site.pk,
  1034. 'kind': 'info',
  1035. 'comments': 'A new entry',
  1036. }
  1037. cls.bulk_edit_data = {
  1038. 'kind': 'success',
  1039. 'comments': 'Overwritten',
  1040. }
  1041. class SubscriptionTestCase(
  1042. ViewTestCases.CreateObjectViewTestCase,
  1043. ViewTestCases.DeleteObjectViewTestCase,
  1044. ViewTestCases.ListObjectsViewTestCase,
  1045. ViewTestCases.BulkDeleteObjectsViewTestCase
  1046. ):
  1047. model = Subscription
  1048. @classmethod
  1049. def setUpTestData(cls):
  1050. site_ct = ContentType.objects.get_for_model(Site)
  1051. sites = (
  1052. Site(name='Site 1', slug='site-1'),
  1053. Site(name='Site 2', slug='site-2'),
  1054. Site(name='Site 3', slug='site-3'),
  1055. Site(name='Site 4', slug='site-4'),
  1056. )
  1057. Site.objects.bulk_create(sites)
  1058. cls.form_data = {
  1059. 'object_type': site_ct.pk,
  1060. 'object_id': sites[3].pk,
  1061. }
  1062. def setUp(self):
  1063. super().setUp()
  1064. sites = Site.objects.all()
  1065. user = self.user
  1066. subscriptions = (
  1067. Subscription(object=sites[0], user=user),
  1068. Subscription(object=sites[1], user=user),
  1069. Subscription(object=sites[2], user=user),
  1070. )
  1071. Subscription.objects.bulk_create(subscriptions)
  1072. def _get_url(self, action, instance=None):
  1073. if action == 'list':
  1074. return reverse('account:subscriptions')
  1075. return super()._get_url(action, instance)
  1076. def test_list_objects_anonymous(self):
  1077. self.client.logout()
  1078. url = reverse('account:subscriptions')
  1079. login_url = reverse('login')
  1080. self.assertRedirects(self.client.get(url), f'{login_url}?next={url}')
  1081. def test_export_objects_anonymous(self):
  1082. return
  1083. def test_list_objects_with_permission(self):
  1084. return
  1085. def test_list_objects_with_constrained_permission(self):
  1086. return
  1087. class NotificationGroupTestCase(ViewTestCases.PrimaryObjectViewTestCase):
  1088. model = NotificationGroup
  1089. @classmethod
  1090. def setUpTestData(cls):
  1091. users = (
  1092. User(username='User 1'),
  1093. User(username='User 2'),
  1094. User(username='User 3'),
  1095. )
  1096. User.objects.bulk_create(users)
  1097. groups = (
  1098. Group(name='Group 1'),
  1099. Group(name='Group 2'),
  1100. Group(name='Group 3'),
  1101. )
  1102. Group.objects.bulk_create(groups)
  1103. notification_groups = (
  1104. NotificationGroup(name='Notification Group 1'),
  1105. NotificationGroup(name='Notification Group 2'),
  1106. NotificationGroup(name='Notification Group 3'),
  1107. )
  1108. NotificationGroup.objects.bulk_create(notification_groups)
  1109. for i, notification_group in enumerate(notification_groups):
  1110. notification_group.users.add(users[i])
  1111. notification_group.groups.add(groups[i])
  1112. cls.form_data = {
  1113. 'name': 'Notification Group X',
  1114. 'description': 'Blah',
  1115. 'users': [users[0].pk, users[1].pk],
  1116. 'groups': [groups[0].pk, groups[1].pk],
  1117. }
  1118. cls.csv_data = (
  1119. 'name,description,users,groups',
  1120. 'Notification Group 4,Foo,"User 1,User 2","Group 1,Group 2"',
  1121. 'Notification Group 5,Bar,"User 1,User 2","Group 1,Group 2"',
  1122. 'Notification Group 6,Baz,"User 1,User 2","Group 1,Group 2"',
  1123. )
  1124. cls.csv_update_data = (
  1125. "id,name",
  1126. f"{notification_groups[0].pk},Notification Group 7",
  1127. f"{notification_groups[1].pk},Notification Group 8",
  1128. f"{notification_groups[2].pk},Notification Group 9",
  1129. )
  1130. cls.bulk_edit_data = {
  1131. 'description': 'New description',
  1132. }
  1133. class NotificationTestCase(
  1134. ViewTestCases.DeleteObjectViewTestCase,
  1135. ViewTestCases.ListObjectsViewTestCase,
  1136. ViewTestCases.BulkDeleteObjectsViewTestCase
  1137. ):
  1138. model = Notification
  1139. @classmethod
  1140. def setUpTestData(cls):
  1141. site_ct = ContentType.objects.get_for_model(Site)
  1142. sites = (
  1143. Site(name='Site 1', slug='site-1'),
  1144. Site(name='Site 2', slug='site-2'),
  1145. Site(name='Site 3', slug='site-3'),
  1146. Site(name='Site 4', slug='site-4'),
  1147. )
  1148. Site.objects.bulk_create(sites)
  1149. cls.form_data = {
  1150. 'object_type': site_ct.pk,
  1151. 'object_id': sites[3].pk,
  1152. }
  1153. def setUp(self):
  1154. super().setUp()
  1155. sites = Site.objects.all()
  1156. user = self.user
  1157. notifications = (
  1158. Notification(object=sites[0], user=user),
  1159. Notification(object=sites[1], user=user),
  1160. Notification(object=sites[2], user=user),
  1161. )
  1162. Notification.objects.bulk_create(notifications)
  1163. def _get_url(self, action, instance=None):
  1164. if action == 'list':
  1165. return reverse('account:notifications')
  1166. return super()._get_url(action, instance)
  1167. def test_list_objects_anonymous(self):
  1168. self.client.logout()
  1169. url = reverse('account:notifications')
  1170. login_url = reverse('login')
  1171. self.assertRedirects(self.client.get(url), f'{login_url}?next={url}')
  1172. def test_export_objects_anonymous(self):
  1173. return
  1174. def test_list_objects_with_permission(self):
  1175. return
  1176. def test_list_objects_with_constrained_permission(self):
  1177. return
  1178. class ScriptListViewTestCase(TestCase):
  1179. user_permissions = ['extras.view_script']
  1180. def test_script_list_embedded_parameter(self):
  1181. """Test that ScriptListView accepts embedded parameter without error"""
  1182. url = reverse('extras:script_list')
  1183. # Test normal request
  1184. response = self.client.get(url)
  1185. self.assertEqual(response.status_code, 200)
  1186. self.assertTemplateUsed(response, 'extras/script_list.html')
  1187. # Test embedded request
  1188. response = self.client.get(url, {'embedded': 'true'})
  1189. self.assertEqual(response.status_code, 200)
  1190. self.assertTemplateUsed(response, 'extras/inc/script_list_content.html')
  1191. class ScriptModuleCreateViewTestCase(TestCase):
  1192. user_permissions = ['core.add_managedfile', 'extras.add_scriptmodule']
  1193. @tag('regression')
  1194. def test_default_return_url(self):
  1195. """
  1196. The add view should fall back to the scripts list as its return URL.
  1197. """
  1198. response = self.client.get(reverse('extras:scriptmodule_add'))
  1199. self.assertEqual(response.status_code, 200)
  1200. self.assertEqual(response.context['return_url'], reverse('extras:script_list'))
  1201. class ScriptDefaultBackfillTestCase(TestCase):
  1202. """
  1203. The UI and the REST API now share prepare_script_form(), so the UI's back-filling of
  1204. declared defaults (previously inline in ScriptView.post()) must keep working after
  1205. that logic moved into the helper.
  1206. """
  1207. user_permissions = ['extras.view_script', 'extras.run_script']
  1208. class TestScriptClass(PythonClass):
  1209. class Meta:
  1210. name = 'Backfill test'
  1211. commit_default = False
  1212. label = StringVar(default='hello')
  1213. flag = BooleanVar(default=True)
  1214. picks = MultiChoiceVar(choices=(('a', 'A'), ('b', 'B'), ('c', 'C')), default=['a', 'b'])
  1215. def run(self, data, commit):
  1216. return 'Complete'
  1217. @classmethod
  1218. def setUpTestData(cls):
  1219. with patch.object(ScriptModule, 'sync_classes'):
  1220. module = ScriptModule.objects.create(
  1221. file_root=ManagedFileRootPathChoices.SCRIPTS,
  1222. file_path='backfill_script.py',
  1223. )
  1224. cls.script = Script.objects.create(module=module, name='Backfill test', is_executable=True)
  1225. def setUp(self):
  1226. super().setUp()
  1227. python_class_patch = patch.object(Script, 'python_class', new=self.TestScriptClass)
  1228. python_class_patch.start()
  1229. self.addCleanup(python_class_patch.stop)
  1230. @tag('regression')
  1231. def test_ui_backfills_declared_defaults(self):
  1232. url = reverse('extras:script', kwargs={'pk': self.script.pk})
  1233. with (
  1234. patch('extras.views.any_workers_for_queue', return_value=True),
  1235. patch('extras.jobs.ScriptJob.enqueue') as mock_enqueue,
  1236. ):
  1237. mock_enqueue.return_value.pk = 1
  1238. response = self.client.post(url, {'_commit': 'true'})
  1239. self.assertEqual(response.status_code, 302)
  1240. data = mock_enqueue.call_args.kwargs['data']
  1241. self.assertEqual(data['label'], 'hello')
  1242. self.assertIs(data['flag'], True)
  1243. # A multi-value default must be set on the QueryDict with setlist(): a plain
  1244. # assignment stores the list as one nested value, which the multi-select widget
  1245. # then rejects as a single invalid choice.
  1246. self.assertEqual(data['picks'], ['a', 'b'])
  1247. class ScriptValidationErrorTestCase(TestCase):
  1248. user_permissions = ['extras.view_script', 'extras.run_script']
  1249. class TestScriptMixin:
  1250. bar = IntegerVar(min_value=0, max_value=30)
  1251. class TestScriptClass(TestScriptMixin, PythonClass):
  1252. class Meta:
  1253. name = 'Test script'
  1254. commit_default = False
  1255. fieldsets = (("Logging", ("debug_mode",)),)
  1256. debug_mode = BooleanVar(default=False)
  1257. def run(self, data, commit):
  1258. return "Complete"
  1259. @classmethod
  1260. def setUpTestData(cls):
  1261. # Avoid trying to import a non-existent on-disk module during setup.
  1262. # This test creates the Script row explicitly and monkey-patches
  1263. # Script.python_class below.
  1264. with patch.object(ScriptModule, 'sync_classes'):
  1265. module = ScriptModule.objects.create(
  1266. file_root=ManagedFileRootPathChoices.SCRIPTS,
  1267. file_path='test_script.py',
  1268. )
  1269. cls.script = Script.objects.create(module=module, name='Test script', is_executable=True)
  1270. def setUp(self):
  1271. super().setUp()
  1272. Script.python_class = property(lambda self: ScriptValidationErrorTestCase.TestScriptClass)
  1273. @tag('regression')
  1274. def test_script_validation_error_displays_message(self):
  1275. url = reverse('extras:script', kwargs={'pk': self.script.pk})
  1276. with patch('extras.views.any_workers_for_queue', return_value=True):
  1277. response = self.client.post(url, {'debug_mode': 'true', '_commit': 'true'})
  1278. self.assertEqual(response.status_code, 200)
  1279. messages = list(response.context['messages'])
  1280. self.assertEqual(len(messages), 1)
  1281. self.assertEqual(str(messages[0]), "bar: This field is required.")
  1282. @tag('regression')
  1283. def test_script_validation_error_no_toast_for_fieldset_fields(self):
  1284. class FieldsetScript(PythonClass):
  1285. class Meta:
  1286. name = 'Fieldset test'
  1287. commit_default = False
  1288. fieldsets = (("Fields", ("required_field",)),)
  1289. required_field = IntegerVar(min_value=10)
  1290. def run(self, data, commit):
  1291. return "Complete"
  1292. url = reverse('extras:script', kwargs={'pk': self.script.pk})
  1293. with patch.object(Script, 'python_class', new_callable=PropertyMock) as mock_python_class:
  1294. mock_python_class.return_value = FieldsetScript
  1295. with patch('extras.views.any_workers_for_queue', return_value=True):
  1296. response = self.client.post(url, {'required_field': '5', '_commit': 'true'})
  1297. self.assertEqual(response.status_code, 200)
  1298. messages = list(response.context['messages'])
  1299. self.assertEqual(len(messages), 0)
  1300. class ScriptMetaValidationViewTestCase(TestCase):
  1301. """
  1302. A script whose Meta declares an invalid job_timeout or notifications_default must surface an actionable error on
  1303. the run view rather than returning an HTTP 500 (#22872).
  1304. """
  1305. user_permissions = ['extras.view_script', 'extras.run_script']
  1306. class BadTimeoutScript(PythonClass):
  1307. class Meta:
  1308. name = 'Bad Timeout'
  1309. job_timeout = 'not-a-timeout'
  1310. def run(self, data, commit):
  1311. return "Complete"
  1312. class BadNotificationsScript(PythonClass):
  1313. class Meta:
  1314. name = 'Bad Notifications'
  1315. notifications_default = 'on_error'
  1316. def run(self, data, commit):
  1317. return "Complete"
  1318. @classmethod
  1319. def setUpTestData(cls):
  1320. with patch.object(ScriptModule, 'sync_classes'):
  1321. module = ScriptModule.objects.create(
  1322. file_root=ManagedFileRootPathChoices.SCRIPTS,
  1323. file_path='bad_meta.py',
  1324. )
  1325. cls.script = Script.objects.create(module=module, name='Bad meta', is_executable=True)
  1326. def _run_and_assert(self, python_class):
  1327. url = reverse('extras:script', kwargs={'pk': self.script.pk})
  1328. with patch.object(Script, 'python_class', new_callable=PropertyMock) as mock_python_class:
  1329. mock_python_class.return_value = python_class
  1330. with patch('extras.views.any_workers_for_queue', return_value=True):
  1331. with self.captureOnCommitCallbacks(execute=True):
  1332. # Quick-run style: omit _notifications
  1333. response = self.client.post(url, {'_commit': 'true'})
  1334. # Re-render with an error message, not a 500, and no Job enqueued
  1335. self.assertEqual(response.status_code, 200)
  1336. messages = list(response.context['messages'])
  1337. self.assertEqual(len(messages), 1)
  1338. self.assertIn('Unable to run script', str(messages[0]))
  1339. self.assertEqual(Job.objects.count(), 0)
  1340. @tag('regression')
  1341. def test_invalid_job_timeout_shows_error(self):
  1342. self._run_and_assert(self.BadTimeoutScript)
  1343. @tag('regression')
  1344. def test_invalid_notifications_default_shows_error(self):
  1345. self._run_and_assert(self.BadNotificationsScript)
  1346. class ScriptDefaultValuesTestCase(TestCase):
  1347. user_permissions = ['extras.view_script', 'extras.run_script']
  1348. class TestScriptClass(PythonClass):
  1349. class Meta:
  1350. name = 'Test script'
  1351. commit_default = False
  1352. bool_default_true = BooleanVar(default=True)
  1353. bool_default_false = BooleanVar(default=False)
  1354. int_with_default = IntegerVar(default=0)
  1355. int_without_default = IntegerVar(required=False)
  1356. def run(self, data, commit):
  1357. return "Complete"
  1358. @classmethod
  1359. def setUpTestData(cls):
  1360. # Avoid trying to import a non-existent on-disk module during setup.
  1361. # This test creates the Script row explicitly and monkey-patches
  1362. # Script.python_class below.
  1363. with patch.object(ScriptModule, 'sync_classes'):
  1364. module = ScriptModule.objects.create(
  1365. file_root=ManagedFileRootPathChoices.SCRIPTS,
  1366. file_path='test_script.py',
  1367. )
  1368. cls.script = Script.objects.create(module=module, name='Test script', is_executable=True)
  1369. def setUp(self):
  1370. super().setUp()
  1371. Script.python_class = property(lambda self: ScriptDefaultValuesTestCase.TestScriptClass)
  1372. def test_default_values_are_used(self):
  1373. url = reverse('extras:script', kwargs={'pk': self.script.pk})
  1374. with patch('extras.views.any_workers_for_queue', return_value=True):
  1375. with patch('extras.jobs.ScriptJob.enqueue') as mock_enqueue:
  1376. mock_enqueue.return_value.pk = 1
  1377. self.client.post(url, {})
  1378. call_kwargs = mock_enqueue.call_args.kwargs
  1379. self.assertEqual(call_kwargs['data']['bool_default_true'], True)
  1380. self.assertEqual(call_kwargs['data']['bool_default_false'], False)
  1381. self.assertEqual(call_kwargs['data']['int_with_default'], 0)
  1382. self.assertIsNone(call_kwargs['data']['int_without_default'])
  1383. class ScriptResultViewTestCase(TestCase):
  1384. SECRET_OUTPUT = 'my secret script output'
  1385. @classmethod
  1386. def setUpTestData(cls):
  1387. with patch.object(ScriptModule, 'sync_classes'):
  1388. module = ScriptModule.objects.create(
  1389. file_root=ManagedFileRootPathChoices.SCRIPTS,
  1390. file_path='test_script.py',
  1391. )
  1392. cls.allowed_script = Script.objects.create(
  1393. module=module, name='Allowed script', is_executable=True
  1394. )
  1395. cls.secret_script = Script.objects.create(
  1396. module=module, name='Secret script', is_executable=True
  1397. )
  1398. script_type = ObjectType.objects.get_for_model(Script)
  1399. cls.allowed_job = Job.objects.create(
  1400. object_type=script_type,
  1401. object_id=cls.allowed_script.pk,
  1402. name='allowed-job',
  1403. job_id=uuid.uuid4(),
  1404. status=JobStatusChoices.STATUS_COMPLETED,
  1405. data={'log': [], 'output': 'benign output'},
  1406. )
  1407. cls.secret_job = Job.objects.create(
  1408. object_type=script_type,
  1409. object_id=cls.secret_script.pk,
  1410. name='secret-job',
  1411. job_id=uuid.uuid4(),
  1412. status=JobStatusChoices.STATUS_COMPLETED,
  1413. data={'log': [], 'output': cls.SECRET_OUTPUT},
  1414. )
  1415. def test_get_without_view_job_permission_returns_404(self):
  1416. """
  1417. A user with extras.view_script but no core.view_job must not retrieve any job result
  1418. via ScriptResultView, even for the script whose object-level permission they hold.
  1419. """
  1420. self.add_permissions('extras.view_script')
  1421. url = reverse('extras:script_result', kwargs={'job_pk': self.allowed_job.pk})
  1422. self.assertHttpStatus(self.client.get(url), 404)
  1423. url = reverse('extras:script_result', kwargs={'job_pk': self.secret_job.pk})
  1424. self.assertHttpStatus(self.client.get(url), 404)
  1425. def test_get_export_output_without_view_job_permission_returns_404(self):
  1426. """
  1427. Regression for the PoC: the ?export=output path must not leak job.data['output']
  1428. when the user lacks core.view_job.
  1429. """
  1430. self.add_permissions('extras.view_script')
  1431. url = reverse('extras:script_result', kwargs={'job_pk': self.secret_job.pk})
  1432. response = self.client.get(url, {'export': 'output'})
  1433. self.assertHttpStatus(response, 404)
  1434. self.assertNotIn(self.SECRET_OUTPUT.encode(), response.content)
  1435. def test_get_with_constrained_view_job_permission(self):
  1436. """
  1437. With core.view_job constrained to the allowed job only, the user can fetch the allowed
  1438. result but the secret result is hidden (404).
  1439. """
  1440. self.add_permissions('extras.view_script')
  1441. obj_perm = ObjectPermission(
  1442. name='View allowed job only',
  1443. constraints={'pk': self.allowed_job.pk},
  1444. actions=['view'],
  1445. )
  1446. obj_perm.save()
  1447. obj_perm.users.add(self.user)
  1448. obj_perm.object_types.add(ObjectType.objects.get_for_model(Job))
  1449. url = reverse('extras:script_result', kwargs={'job_pk': self.allowed_job.pk})
  1450. self.assertHttpStatus(self.client.get(url), 200)
  1451. url = reverse('extras:script_result', kwargs={'job_pk': self.secret_job.pk})
  1452. response = self.client.get(url, {'export': 'output'})
  1453. self.assertHttpStatus(response, 404)
  1454. self.assertNotIn(self.SECRET_OUTPUT.encode(), response.content)
  1455. class ScriptJobsRestrictionTestCase(TestCase):
  1456. """
  1457. Script views must display only the Jobs which the user is permitted to view.
  1458. """
  1459. # ScriptModule is a proxy model, so viewing it is governed by its concrete model's permission
  1460. user_permissions = ('extras.view_script', 'core.view_managedfile')
  1461. class TestScriptClass(PythonClass):
  1462. class Meta:
  1463. name = 'Restriction test'
  1464. def run(self, data, commit):
  1465. return 'Complete'
  1466. @classmethod
  1467. def setUpTestData(cls):
  1468. with patch.object(ScriptModule, 'sync_classes'):
  1469. module = ScriptModule.objects.create(
  1470. file_root=ManagedFileRootPathChoices.SCRIPTS,
  1471. file_path='restriction_script.py',
  1472. )
  1473. cls.script = Script.objects.create(module=module, name='Restriction test', is_executable=True)
  1474. cls.other_user = User.objects.create_user(username='otheruser')
  1475. # The other user's Job is the most recent, so that the script list must skip it (rather than
  1476. # hide it) to find the most recent Job the user is permitted to view
  1477. cls.system_job = cls._create_job('system-job', user=None, minutes_ago=30)
  1478. cls.other_job = cls._create_job('other-job', user=cls.other_user, minutes_ago=10)
  1479. @classmethod
  1480. def _create_job(cls, name, user, minutes_ago):
  1481. started = now() - datetime.timedelta(minutes=minutes_ago)
  1482. return Job.objects.create(
  1483. name=name,
  1484. object_type=ObjectType.objects.get_for_model(Script),
  1485. object_id=cls.script.pk,
  1486. status=JobStatusChoices.STATUS_COMPLETED,
  1487. job_id=uuid.uuid4(),
  1488. user=user,
  1489. started=started,
  1490. completed=started,
  1491. )
  1492. def setUp(self):
  1493. super().setUp()
  1494. python_class_patch = patch.object(Script, 'python_class', new=self.TestScriptClass)
  1495. python_class_patch.start()
  1496. self.addCleanup(python_class_patch.stop)
  1497. self.own_job = self._create_job('own-job', user=self.user, minutes_ago=20)
  1498. def _grant_view_job(self, constraints=None):
  1499. obj_perm = ObjectPermission(name='View jobs', constraints=constraints, actions=['view'])
  1500. obj_perm.save()
  1501. obj_perm.users.add(self.user)
  1502. obj_perm.object_types.add(ObjectType.objects.get_for_model(Job))
  1503. def _assert_script_views_show(self, expected_jobs):
  1504. """
  1505. Assert that the script's Jobs table and job count reflect only the expected Jobs.
  1506. """
  1507. response = self.client.get(reverse('extras:script_jobs', kwargs={'pk': self.script.pk}))
  1508. self.assertHttpStatus(response, 200)
  1509. self.assertEqual({row.record for row in response.context['table'].rows}, expected_jobs)
  1510. self.assertEqual(response.context['job_count'], len(expected_jobs))
  1511. for view_name in ('extras:script', 'extras:script_source'):
  1512. response = self.client.get(reverse(view_name, kwargs={'pk': self.script.pk}))
  1513. self.assertHttpStatus(response, 200)
  1514. self.assertEqual(response.context['job_count'], len(expected_jobs))
  1515. def _assert_script_list_shows(self, expected_job):
  1516. """
  1517. Assert that the script list displays the expected Job (or none) as the script's most recent result.
  1518. """
  1519. response = self.client.get(reverse('extras:script_list'))
  1520. self.assertHttpStatus(response, 200)
  1521. content = response.content.decode()
  1522. for job in (self.own_job, self.other_job, self.system_job):
  1523. result_url = reverse('extras:script_result', kwargs={'job_pk': job.pk})
  1524. if job == expected_job:
  1525. self.assertIn(result_url, content)
  1526. else:
  1527. self.assertNotIn(result_url, content)
  1528. def test_unconstrained_permission(self):
  1529. self._grant_view_job()
  1530. self._assert_script_views_show({self.own_job, self.other_job, self.system_job})
  1531. self._assert_script_list_shows(self.other_job)
  1532. def test_user_constrained_permission(self):
  1533. self._grant_view_job(constraints={'user': '$user'})
  1534. self._assert_script_views_show({self.own_job})
  1535. self._assert_script_list_shows(self.own_job)
  1536. def test_without_view_job_permission(self):
  1537. self._assert_script_views_show(set())
  1538. self._assert_script_list_shows(None)