test_views.py 71 KB

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