Coverage for product_risk_suite/product/tests.py: 100%

546 statements  

« prev     ^ index     » next       coverage.py v7.15.0, created at 2026-07-07 12:45 +0000

1from django.contrib import admin 

2from django.test import TestCase, RequestFactory 

3from collections import OrderedDict 

4from unittest.mock import MagicMock 

5 

6from guardian.shortcuts import assign_perm 

7 

8from .models import * 

9from .admin import ProductRiskEntryAdmin 

10from risk_assessment.models import * 

11from threat_model.shared_models import ThreatModelConnectionName 

12 

13# Create your tests here. 

14class ProductRiskEntryTest(TestCase): 

15 custom_id = "pre-test-001" 

16 risk_title = "PRE test risk" 

17 def setUp(self): 

18 os = Asset.objects.create(name="OS") 

19 Stride.objects.create(id=1, name="S") 

20 usb = Origin.objects.create(name="USB") 

21 lc = LifeCycle.objects.create(name="in use") 

22 risk = Risk(id=0, custom_id=self.custom_id, asset=os, origin=usb, life_cycle=lc, title=self.risk_title, description="more info") 

23 risk.save() 

24 s = Stride.objects.get(id=1) 

25 risk.stride.set([s]) 

26 risk.save() 

27 

28 self.risk_rating_init = RiskRating.objects.create(likelihood_of_occurrence=2, severity_of_impact=5) 

29 self.init_risk = 10 

30 self.risk_rating_miti = RiskRating.objects.create(likelihood_of_occurrence=1, severity_of_impact=5) 

31 self.mit_risk = 5 

32 

33 self.secReq1 = SecurityRequirement.objects.create(norm_short="CRA", norm_long="CRA long", description_short="Part 1.1", description_long="details 1.1", slug="cra-1.1") 

34 self.secReq2 = SecurityRequirement.objects.create(norm_short="CRA", norm_long="CRA long", description_short="Part 1.2", description_long="details 1.2", slug="cra-1.2") 

35 rm = RiskMitigation(id=0, mitigation="We fixed it", rational="We fixed it good") 

36 rm.save() 

37 rm.security_requirements.set([self.secReq1]) 

38 rm.save() 

39 self.rm = rm 

40 

41 self.u = User.objects.create(username="testuser") 

42 self.s1 = Status.objects.create(id=1, status="On going") 

43 self.ev = Evidence.objects.create(id=1, due_date="2026-04-23", responsible=self.u, status=self.s1, evidence="[REQ-001] the evidence req", evidence_link="https://example.com/requirement-001") 

44 

45 def test_product_risk_entry_str(self): 

46 risk = Risk.objects.get(id=0) 

47 acc = ProductRiskEntry.objects.create(id=0, risk=risk, risk_accepted = True, risk_rating_initial=self.risk_rating_init) 

48 mit_evidence = ProductRiskEntry.objects.create(id=1, risk=risk, risk_accepted = False, risk_rating_initial=self.risk_rating_init, risk_mitigation=self.rm, risk_rating_after_mitigation=self.risk_rating_miti) 

49 mit_evidence.evidences.set([self.ev]) 

50 mit_no_evidence = ProductRiskEntry.objects.create(id=2, risk=risk, risk_accepted = False, risk_rating_initial=self.risk_rating_init, risk_mitigation=self.rm, risk_rating_after_mitigation=self.risk_rating_miti) 

51 unmit = ProductRiskEntry.objects.create(id=3, risk=risk, risk_accepted = False, risk_rating_initial=self.risk_rating_init) 

52 

53 self.assertEqual(str(acc), f"Accepted Risk: {self.custom_id} - {self.risk_title}") 

54 self.assertEqual(str(mit_evidence), f"Mitigated Risk: {self.custom_id} - {self.risk_title} Risk: {self.mit_risk}") 

55 self.assertEqual(str(mit_no_evidence), f"Mitigated Risk: {self.custom_id} - {self.risk_title} Risk: {self.mit_risk}") 

56 self.assertEqual(str(unmit), f"*Un*mitigated Risk: {self.custom_id} - {self.risk_title} Open risk: {self.init_risk}") 

57 

58 def test_list_svg_ids_empty_when_none_assigned(self): 

59 risk = Risk.objects.get(id=0) 

60 entry = ProductRiskEntry.objects.create(risk=risk, risk_accepted=False, risk_rating_initial=self.risk_rating_init) 

61 self.assertEqual(entry.list_svg_ids, []) 

62 

63 def test_list_svg_ids_returns_tech_names_of_assigned_connections(self): 

64 product = Product.objects.create(title="SVG Test Product", description="d", slug="svg-test-product") 

65 cn1 = ThreatModelConnectionName.objects.create(product=product, tech_name="cell-001") 

66 cn2 = ThreatModelConnectionName.objects.create(product=product, tech_name="cell-002") 

67 risk = Risk.objects.get(id=0) 

68 entry = ProductRiskEntry.objects.create(risk=risk, risk_accepted=False, risk_rating_initial=self.risk_rating_init) 

69 entry.svg_id.set([cn1, cn2]) 

70 self.assertCountEqual(entry.list_svg_ids, ["cell-001", "cell-002"]) 

71 

72class ProductRiskAnalysisTest(TestCase): 

73 custom_id = "pra-test-002" 

74 risk_title = "PRA test risk" 

75 

76 def setUp(self): 

77 os = Asset.objects.create(name="OS") 

78 Stride.objects.create(id=1, name="S") 

79 usb = Origin.objects.create(name="USB") 

80 lc = LifeCycle.objects.create(name="in use") 

81 risk = Risk(id=0, custom_id=self.custom_id, asset=os, origin=usb, life_cycle=lc, title=self.risk_title, description="more info") 

82 risk.save() 

83 s = Stride.objects.get(id=1) 

84 risk.stride.set([s]) 

85 risk.save() 

86 

87 self.risk_rating_init = RiskRating.objects.create(likelihood_of_occurrence=2, severity_of_impact=5) 

88 self.init_risk = 10 

89 self.risk_rating_miti = RiskRating.objects.create(likelihood_of_occurrence=1, severity_of_impact=5) 

90 self.mit_risk = 5 

91 

92 self.secReq1 = SecurityRequirement.objects.create(norm_short="CRA", norm_long="CRA long", description_short="Part 1.1", description_long="details 1.1", slug="cra-1.1") 

93 self.secReq2 = SecurityRequirement.objects.create(norm_short="CRA", norm_long="CRA long", description_short="Part 1.2", description_long="details 1.2", slug="cra-1.2") 

94 rm = RiskMitigation(id=0, mitigation="We fixed it", rational="We fixed it good") 

95 rm.save() 

96 rm.security_requirements.set([self.secReq1]) 

97 rm.save() 

98 self.rm = rm 

99 

100 self.u = User.objects.create(username="testuser") 

101 self.s1 = Status.objects.create(id=1, status="On going") 

102 self.ev = Evidence.objects.create(id=1, due_date="2026-04-23", responsible=self.u, status=self.s1, evidence="[REQ-001] the evidence req", evidence_link="https://example.com/requirement-001") 

103 

104 acc = ProductRiskEntry.objects.create(id=0, risk=risk, risk_accepted = True, risk_rating_initial=self.risk_rating_init) 

105 mit_evidence = ProductRiskEntry.objects.create(id=1, risk=risk, risk_accepted = False, risk_rating_initial=self.risk_rating_init, risk_mitigation=self.rm, risk_rating_after_mitigation=self.risk_rating_miti) 

106 mit_evidence.evidences.set([self.ev]) 

107 mit_evidence.save() 

108 mit_no_evidence = ProductRiskEntry.objects.create(id=2, risk=risk, risk_accepted = False, risk_rating_initial=self.risk_rating_init, risk_mitigation=self.rm, risk_rating_after_mitigation=self.risk_rating_miti) 

109 unmit = ProductRiskEntry.objects.create(id=3, risk=risk, risk_accepted = False, risk_rating_initial=self.risk_rating_init) 

110 

111 def test_str(self): 

112 pra = ProductRiskAnalysis.objects.create(id=0, name="Our first analysis", slug="our-first-analysis") 

113 pra.risk_entries.set([ProductRiskEntry.objects.get(id=0), ProductRiskEntry.objects.get(id=1), ProductRiskEntry.objects.get(id=2), ProductRiskEntry.objects.get(id=3)]) 

114 pra.save() 

115 

116 self.assertEqual(str(ProductRiskAnalysis.objects.get(id=0)), "Our first analysis") 

117 

118 def test_get_risk_summary(self): 

119 pra = ProductRiskAnalysis.objects.create(id=0, name="Our first analysis", slug="our-first-analysis") 

120 pra.risk_entries.set([ProductRiskEntry.objects.get(id=0), ProductRiskEntry.objects.get(id=1), ProductRiskEntry.objects.get(id=2), ProductRiskEntry.objects.get(id=3)]) 

121 pra.save() 

122 

123 risk_summary = ProductRiskAnalysis.get_risk_summary(pra.risk_entries) 

124 self.assertEqual(risk_summary["risk_severities_before_mitigation"][Risk5x5.LOW.name], 0) 

125 self.assertEqual(risk_summary["risk_severities_before_mitigation"][Risk5x5.MID.name], 4) 

126 self.assertEqual(risk_summary["risk_severities_before_mitigation"][Risk5x5.HIGH.name], 0) 

127 

128 self.assertEqual(risk_summary["risk_severities_after_mitigation"][Risk5x5.LOW.name], 2) 

129 self.assertEqual(risk_summary["risk_severities_after_mitigation"][Risk5x5.MID.name], 2) 

130 self.assertEqual(risk_summary["risk_severities_after_mitigation"][Risk5x5.HIGH.name], 0) 

131 

132 self.assertEqual(risk_summary["risk_severity_colors"][Risk5x5.LOW.name], "#d0e6dc") 

133 self.assertEqual(risk_summary["risk_severity_colors"][Risk5x5.MID.name], "#fff3cc") 

134 self.assertEqual(risk_summary["risk_severity_colors"][Risk5x5.HIGH.name], "#f8d6d9") 

135 

136 self.assertEqual(risk_summary["n_risks"], 4) 

137 self.assertEqual(len(risk_summary["unmitigated_risks"]), 2) 

138 self.assertEqual(risk_summary["unmitigated_risks"][0], "pra-test-002") 

139 self.assertEqual(risk_summary["unmitigated_risks"][1], "pra-test-002") 

140 self.assertEqual(risk_summary["mitigation_stati"], { 

141 "open": 0, 

142 "on_going": 1, 

143 "finished": 0, 

144 "rejected": 0, 

145 }) 

146 

147 self.assertEqual(risk_summary["covered_sec_reqs"], OrderedDict( 

148 {"CRA: Part 1.1": "cra-1.1"}, 

149 )) 

150 

151class ProductTest(TestCase): 

152 def setUp(self): 

153 Product.objects.create(id=0, title="My test project", description="A real cool test project") 

154 

155 def test_str(self): 

156 p = Product.objects.get(id=0) 

157 self.assertEqual(str(p), "My test project") 

158 

159 

160class ProductRiskEntryAdminTest(TestCase): 

161 custom_id = "admin-test-001" 

162 risk_title = "Admin test risk" 

163 

164 def setUp(self): 

165 asset = Asset.objects.create(name="OS") 

166 Stride.objects.create(id=1, name="S") 

167 usb = Origin.objects.create(name="USB") 

168 lc = LifeCycle.objects.create(name="in use") 

169 risk = Risk(custom_id=self.custom_id, asset=asset, origin=usb, life_cycle=lc, title=self.risk_title, description="info") 

170 risk.save() 

171 risk.stride.set([Stride.objects.get(id=1)]) 

172 risk.save() 

173 self.risk = risk 

174 

175 self.rr_init = RiskRating.objects.create(likelihood_of_occurrence=2, severity_of_impact=5) 

176 self.rr_miti = RiskRating.objects.create(likelihood_of_occurrence=1, severity_of_impact=5) 

177 

178 rm = RiskMitigation(mitigation="We fixed it", rational="We fixed it good") 

179 rm.save() 

180 self.rm = rm 

181 

182 u = User.objects.create(username="testuser") 

183 s1 = Status.objects.create(id=1, status="On going") 

184 self.ev = Evidence.objects.create(due_date="2026-04-23", responsible=u, status=s1, evidence="[REQ-001]", evidence_link="https://example.com/req") 

185 

186 self.entry_unmit = ProductRiskEntry.objects.create(risk=risk, risk_accepted=False, risk_rating_initial=self.rr_init) 

187 self.entry_mit = ProductRiskEntry.objects.create(risk=risk, risk_accepted=False, risk_rating_initial=self.rr_init, risk_mitigation=rm, risk_rating_after_mitigation=self.rr_miti) 

188 self.entry_acc = ProductRiskEntry.objects.create(risk=risk, risk_accepted=True, risk_rating_initial=self.rr_init) 

189 

190 self.superuser = User.objects.create_superuser(username="admin", password="admin123") 

191 self.client.force_login(self.superuser) 

192 

193 self.product = Product.objects.create(title="Test Product", description="desc", slug="test-product") 

194 pra = ProductRiskAnalysis.objects.create(name="Test Analysis", slug="test-analysis") 

195 pra.risk_entries.set([self.entry_unmit]) 

196 self.product.analyzes.set([pra]) 

197 

198 self.cn1 = ThreatModelConnectionName.objects.create(product=self.product, tech_name="conn-a") 

199 self.cn2 = ThreatModelConnectionName.objects.create(product=self.product, tech_name="conn-b") 

200 other_product = Product.objects.create(title="Other Product", description="d", slug="other-product") 

201 self.cn_other = ThreatModelConnectionName.objects.create(product=other_product, tech_name="conn-other") 

202 

203 def _admin(self): 

204 return ProductRiskEntryAdmin(ProductRiskEntry, admin.site) 

205 

206 # --- HTTP view smoke tests --- 

207 

208 def test_changelist_view_returns_200(self): 

209 response = self.client.get('/admin/product/productriskentry/') 

210 self.assertEqual(response.status_code, 200) 

211 

212 def test_add_view_returns_200(self): 

213 response = self.client.get('/admin/product/productriskentry/add/') 

214 self.assertEqual(response.status_code, 200) 

215 

216 def test_change_view_returns_200(self): 

217 response = self.client.get(f'/admin/product/productriskentry/{self.entry_unmit.pk}/change/') 

218 self.assertEqual(response.status_code, 200) 

219 

220 def test_add_view_help_text_for_new_entry(self): 

221 response = self.client.get('/admin/product/productriskentry/add/') 

222 self.assertContains(response, "Here all available IDs are listed") 

223 

224 def test_change_view_help_text_for_existing_entry(self): 

225 response = self.client.get(f'/admin/product/productriskentry/{self.entry_unmit.pk}/change/') 

226 self.assertContains(response, "Select one or more threat model connections") 

227 

228 # --- Custom list_display methods --- 

229 

230 def test_display_risk_id(self): 

231 self.assertEqual(self._admin().risk_Id(self.entry_unmit), self.custom_id) 

232 

233 def test_display_risk_title(self): 

234 self.assertEqual(self._admin().risk_title(self.entry_unmit), self.risk_title) 

235 

236 def test_display_risk_asset(self): 

237 self.assertEqual(str(self._admin().risk_asset(self.entry_unmit)), "OS") 

238 

239 def test_display_risk_origin(self): 

240 self.assertEqual(str(self._admin().risk_origin(self.entry_unmit)), "USB") 

241 

242 def test_display_risk_stride(self): 

243 self.assertEqual(self._admin().risk_stride(self.entry_unmit), "S") 

244 

245 def test_display_risk_initial(self): 

246 self.assertEqual(self._admin().risk_initial(self.entry_unmit), 10) 

247 

248 def test_display_risk_accepted_false(self): 

249 self.assertFalse(self._admin().risk_accepted(self.entry_unmit)) 

250 

251 def test_display_risk_accepted_true(self): 

252 self.assertTrue(self._admin().risk_accepted(self.entry_acc)) 

253 

254 def test_display_risk_mitigated_false(self): 

255 self.assertFalse(self._admin().risk_mitigated(self.entry_unmit)) 

256 

257 def test_display_risk_mitigated_true(self): 

258 self.assertTrue(self._admin().risk_mitigated(self.entry_mit)) 

259 

260 def test_display_risk_after_mitigation_none_when_unmitigated(self): 

261 self.assertIsNone(self._admin().risk_after_mitigation(self.entry_unmit)) 

262 

263 def test_display_risk_after_mitigation_value_when_mitigated(self): 

264 self.assertEqual(self._admin().risk_after_mitigation(self.entry_mit), 5) 

265 

266 def test_display_tm_linked_false_without_svg_id(self): 

267 self.assertFalse(self._admin().tm_linked(self.entry_unmit)) 

268 

269 def test_display_tm_linked_true_with_svg_id(self): 

270 self.entry_unmit.svg_id.set([self.cn1]) 

271 self.assertTrue(self._admin().tm_linked(self.entry_unmit)) 

272 

273 # --- formfield_for_manytomany: svg_id queryset filtering --- 

274 

275 def test_svg_id_queryset_filtered_to_product_connection_names(self): 

276 from threat_model.models import ThreatModel 

277 tm = ThreatModel.objects.create(product=self.product, slug="test-tm", title="TM", svg='') 

278 tm.connection_names.set([self.cn1, self.cn2]) 

279 

280 request = RequestFactory().get(f'/admin/product/productriskentry/{self.entry_unmit.pk}/change/') 

281 request.resolver_match = MagicMock() 

282 request.resolver_match.kwargs = {'object_id': str(self.entry_unmit.pk)} 

283 

284 svg_id_field = ProductRiskEntry._meta.get_field('svg_id') 

285 formfield = self._admin().formfield_for_manytomany(svg_id_field, request) 

286 

287 qs = list(formfield.queryset) 

288 self.assertIn(self.cn1, qs) 

289 self.assertIn(self.cn2, qs) 

290 self.assertNotIn(self.cn_other, qs) 

291 

292 def test_svg_id_queryset_unfiltered_for_new_entry(self): 

293 request = RequestFactory().get('/admin/product/productriskentry/add/') 

294 request.resolver_match = MagicMock() 

295 request.resolver_match.kwargs = {} 

296 

297 svg_id_field = ProductRiskEntry._meta.get_field('svg_id') 

298 formfield = self._admin().formfield_for_manytomany(svg_id_field, request) 

299 

300 qs = list(formfield.queryset) 

301 self.assertIn(self.cn1, qs) 

302 self.assertIn(self.cn2, qs) 

303 self.assertIn(self.cn_other, qs) 

304 

305 

306class ProductAnalysisViewTest(TestCase): 

307 URL = "/product/{product_slug}/{analysis_slug}/" 

308 

309 def setUp(self): 

310 asset = Asset.objects.create(name="OS") 

311 Stride.objects.create(id=1, name="S") 

312 usb = Origin.objects.create(name="USB") 

313 lc = LifeCycle.objects.create(name="in use") 

314 

315 def make_risk(custom_id, title): 

316 r = Risk(custom_id=custom_id, asset=asset, origin=usb, life_cycle=lc, title=title, description="d") 

317 r.save() 

318 r.stride.set([Stride.objects.get(id=1)]) 

319 r.save() 

320 return r 

321 

322 rr_high = RiskRating.objects.create(likelihood_of_occurrence=5, severity_of_impact=5) 

323 rr_low = RiskRating.objects.create(likelihood_of_occurrence=1, severity_of_impact=1) 

324 

325 self.entry_a = ProductRiskEntry.objects.create(risk=make_risk("ZZZ-002", "Risk Z"), risk_accepted=False, risk_rating_initial=rr_high) 

326 self.entry_b = ProductRiskEntry.objects.create(risk=make_risk("AAA-001", "Risk A"), risk_accepted=True, risk_rating_initial=rr_low) 

327 

328 self.analysis = ProductRiskAnalysis.objects.create(name="Test Analysis", slug="test-analysis") 

329 self.analysis.risk_entries.set([self.entry_a, self.entry_b]) 

330 

331 self.product = Product.objects.create(title="Test Product", description="desc", slug="test-product") 

332 self.product.analyzes.set([self.analysis]) 

333 

334 self.viewer = User.objects.create_user(username="viewer", password="pw") 

335 self.editor = User.objects.create_user(username="editor", password="pw") 

336 assign_perm("product.view_product", self.viewer, self.product) 

337 assign_perm("product.view_product", self.editor, self.product) 

338 assign_perm("product.change_product", self.editor, self.product) 

339 

340 def _url(self): 

341 return self.URL.format(product_slug=self.product.slug, analysis_slug=self.analysis.slug) 

342 

343 # --- Authentication and permissions --- 

344 

345 def test_unauthenticated_redirects_to_login(self): 

346 response = self.client.get(self._url()) 

347 self.assertRedirects(response, f"/login?next={self._url()}", fetch_redirect_response=False) 

348 

349 def test_no_permission_returns_403(self): 

350 no_perm_user = User.objects.create_user(username="noperm", password="pw") 

351 self.client.force_login(no_perm_user) 

352 self.assertEqual(self.client.get(self._url()).status_code, 403) 

353 

354 def test_with_view_permission_returns_200(self): 

355 self.client.force_login(self.viewer) 

356 self.assertEqual(self.client.get(self._url()).status_code, 200) 

357 

358 def test_nonexistent_product_returns_404(self): 

359 self.client.force_login(self.viewer) 

360 url = self.URL.format(product_slug="does-not-exist", analysis_slug=self.analysis.slug) 

361 self.assertEqual(self.client.get(url).status_code, 404) 

362 

363 def test_nonexistent_analysis_returns_404(self): 

364 self.client.force_login(self.viewer) 

365 url = self.URL.format(product_slug=self.product.slug, analysis_slug="does-not-exist") 

366 self.assertEqual(self.client.get(url).status_code, 404) 

367 

368 # --- Context variables --- 

369 

370 def test_context_contains_correct_product(self): 

371 self.client.force_login(self.viewer) 

372 response = self.client.get(self._url()) 

373 self.assertEqual(response.context["product"], self.product) 

374 

375 def test_context_contains_correct_analysis(self): 

376 self.client.force_login(self.viewer) 

377 response = self.client.get(self._url()) 

378 self.assertEqual(response.context["product_analysis"], self.analysis) 

379 

380 def test_context_entries_match_analysis(self): 

381 self.client.force_login(self.viewer) 

382 response = self.client.get(self._url()) 

383 entries = list(response.context["product_analysis_entries"]) 

384 self.assertIn(self.entry_a, entries) 

385 self.assertIn(self.entry_b, entries) 

386 self.assertEqual(len(entries), 2) 

387 

388 def test_context_entries_ordered_by_custom_id(self): 

389 self.client.force_login(self.viewer) 

390 response = self.client.get(self._url()) 

391 entries = list(response.context["product_analysis_entries"]) 

392 self.assertEqual(entries[0], self.entry_b) # AAA-001 before ZZZ-002 

393 self.assertEqual(entries[1], self.entry_a) 

394 

395 def test_context_risk_summary_has_expected_keys(self): 

396 self.client.force_login(self.viewer) 

397 response = self.client.get(self._url()) 

398 summary = response.context["risk_summery"] 

399 for key in ("risk_severities_before_mitigation", "risk_severities_after_mitigation", "n_risks", "unmitigated_risks", "mitigation_stati", "covered_sec_reqs"): 

400 self.assertIn(key, summary) 

401 

402 def test_context_risk_summary_counts_entries(self): 

403 self.client.force_login(self.viewer) 

404 response = self.client.get(self._url()) 

405 self.assertEqual(response.context["risk_summery"]["n_risks"], 2) 

406 

407 # --- product_edit flag --- 

408 

409 def test_product_edit_false_for_viewer(self): 

410 self.client.force_login(self.viewer) 

411 response = self.client.get(self._url()) 

412 self.assertFalse(response.context["product_edit"]) 

413 

414 def test_product_edit_true_for_editor(self): 

415 self.client.force_login(self.editor) 

416 response = self.client.get(self._url()) 

417 self.assertTrue(response.context["product_edit"]) 

418 

419 

420class ProductRiskAnalysisAdminTest(TestCase): 

421 def setUp(self): 

422 asset = Asset.objects.create(name="OS") 

423 Stride.objects.create(id=1, name="S") 

424 usb = Origin.objects.create(name="USB") 

425 lc = LifeCycle.objects.create(name="in use") 

426 risk = Risk(custom_id="pra-admin-001", asset=asset, origin=usb, life_cycle=lc, title="Admin PRA risk", description="d") 

427 risk.save() 

428 risk.stride.set([Stride.objects.get(id=1)]) 

429 risk.save() 

430 

431 rr = RiskRating.objects.create(likelihood_of_occurrence=2, severity_of_impact=3) 

432 self.entry1 = ProductRiskEntry.objects.create(risk=risk, risk_accepted=False, risk_rating_initial=rr) 

433 self.entry2 = ProductRiskEntry.objects.create(risk=risk, risk_accepted=True, risk_rating_initial=rr) 

434 

435 self.analysis = ProductRiskAnalysis.objects.create(name="Admin Test Analysis", slug="admin-test-analysis") 

436 self.analysis.risk_entries.set([self.entry1, self.entry2]) 

437 

438 self.superuser = User.objects.create_superuser(username="admin", password="admin123") 

439 self.client.force_login(self.superuser) 

440 

441 def _admin(self): 

442 from product.admin import ProductRiskAnalysisAdmin 

443 return ProductRiskAnalysisAdmin(ProductRiskAnalysis, admin.site) 

444 

445 # --- HTTP smoke tests --- 

446 

447 def test_changelist_returns_200(self): 

448 self.assertEqual(self.client.get('/admin/product/productriskanalysis/').status_code, 200) 

449 

450 def test_add_view_returns_200(self): 

451 self.assertEqual(self.client.get('/admin/product/productriskanalysis/add/').status_code, 200) 

452 

453 def test_change_view_returns_200(self): 

454 url = f'/admin/product/productriskanalysis/{self.analysis.pk}/change/' 

455 self.assertEqual(self.client.get(url).status_code, 200) 

456 

457 # --- n_entries display method --- 

458 

459 def test_n_entries_with_two_entries(self): 

460 self.assertEqual(self._admin().n_entries(self.analysis), 2) 

461 

462 def test_n_entries_with_empty_analysis(self): 

463 empty = ProductRiskAnalysis.objects.create(name="Empty Analysis", slug="empty-analysis") 

464 self.assertEqual(self._admin().n_entries(empty), 0) 

465 

466 def test_n_entries_reflected_in_changelist(self): 

467 response = self.client.get('/admin/product/productriskanalysis/') 

468 self.assertContains(response, "2") 

469 

470 

471# --------------------------------------------------------------------------- 

472# Template coverage tests 

473# --------------------------------------------------------------------------- 

474 

475class ProductIndexTemplateTest(TestCase): 

476 def setUp(self): 

477 self.user = User.objects.create_user(username="viewer", password="pw") 

478 self.product = Product.objects.create(title="Index Product", description="d", slug="index-product") 

479 assign_perm("product.view_product", self.user, self.product) 

480 

481 def test_product_index_template_no_missing_vars(self): 

482 self.client.force_login(self.user) 

483 response = self.client.get('/product/') 

484 self.assertEqual(response.status_code, 200) 

485 self.assertNotContains(response, 'MISSING_VAR') 

486 

487 

488class ProductViewTemplateTest(TestCase): 

489 def setUp(self): 

490 self.user = User.objects.create_user(username="viewer", password="pw") 

491 self.product = Product.objects.create(title="View Product", description="A product", slug="view-product") 

492 self.analysis = ProductRiskAnalysis.objects.create(name="View Analysis", slug="view-analysis") 

493 self.product.analyzes.set([self.analysis]) 

494 assign_perm("product.view_product", self.user, self.product) 

495 

496 def test_product_template_no_missing_vars(self): 

497 self.client.force_login(self.user) 

498 response = self.client.get(f'/product/{self.product.slug}/') 

499 self.assertEqual(response.status_code, 200) 

500 self.assertNotContains(response, 'MISSING_VAR') 

501 

502 

503class ProductAnalysisTemplateTest(TestCase): 

504 """Template coverage test: every variable access in product_analysis.html executes.""" 

505 

506 def setUp(self): 

507 asset = Asset.objects.create(name="TmplAsset") 

508 origin = Origin.objects.create(name="TmplOrigin") 

509 lc = LifeCycle.objects.create(name="TmplLC") 

510 stride = Stride.objects.create(name="S") 

511 

512 sug = SuggestedMitigationValidation.objects.create( 

513 suggested_mitigation="Use firmware signature", 

514 suggested_validation="<ol><li>Verify signature</li></ol>", 

515 ) 

516 sec_req = SecurityRequirement.objects.create( 

517 norm_short="CRA", norm_long="CRA long", 

518 description_short="1.1", description_long="Details 1.1", slug="tmpl-cra-1-1", 

519 ) 

520 risk = Risk(custom_id="TMPL-001", asset=asset, origin=origin, life_cycle=lc, 

521 title="Template risk", description="Full coverage", 

522 suggested_mitigation_validation=sug) 

523 risk.save() 

524 risk.stride.set([stride]) 

525 risk.save() 

526 

527 rr_init = RiskRating.objects.create(likelihood_of_occurrence=3, severity_of_impact=4) 

528 rr_after = RiskRating.objects.create(likelihood_of_occurrence=1, severity_of_impact=2) 

529 

530 rm = RiskMitigation(mitigation="We patched it", rational="Security first", title="Patch") 

531 rm.save() 

532 rm.security_requirements.set([sec_req]) 

533 rm.save() 

534 

535 resp_user = User.objects.create_user(username="responsible", password="pw") 

536 status = Status.objects.create(status="On going") 

537 evidence = Evidence.objects.create( 

538 due_date="2026-12-31", responsible=resp_user, status=status, 

539 evidence="[CERT-001] Evidence text", evidence_link="https://example.com/cert", 

540 ) 

541 

542 self.product = Product.objects.create(title="Tmpl Product", description="Full desc", slug="tmpl-product") 

543 cn = ThreatModelConnectionName.objects.create(product=self.product, tech_name="tmpl-cn-1") 

544 entry = ProductRiskEntry.objects.create( 

545 risk=risk, risk_accepted=False, 

546 risk_rating_initial=rr_init, 

547 risk_mitigation=rm, 

548 risk_rating_after_mitigation=rr_after, 

549 ) 

550 entry.evidences.set([evidence]) 

551 entry.svg_id.set([cn]) 

552 

553 self.analysis = ProductRiskAnalysis.objects.create(name="Tmpl Analysis", slug="tmpl-analysis") 

554 self.analysis.risk_entries.set([entry]) 

555 self.product.analyzes.set([self.analysis]) 

556 

557 self.editor = User.objects.create_user(username="editor", password="pw") 

558 assign_perm("product.view_product", self.editor, self.product) 

559 assign_perm("product.change_product", self.editor, self.product) 

560 

561 def test_product_analysis_template_no_missing_vars(self): 

562 self.client.force_login(self.editor) 

563 response = self.client.get(f'/product/{self.product.slug}/{self.analysis.slug}/') 

564 self.assertEqual(response.status_code, 200) 

565 self.assertNotContains(response, 'MISSING_VAR') 

566 

567 

568# --------------------------------------------------------------------------- 

569# Risk versioning — shared setUp helper 

570# --------------------------------------------------------------------------- 

571 

572def _make_versioned_risk(custom_id, version, is_current, asset, origin, lc, stride): 

573 r = Risk(custom_id=custom_id, version=version, is_current=is_current, 

574 asset=asset, origin=origin, life_cycle=lc, 

575 title=f"{custom_id} v{version}", description="d") 

576 r.save() 

577 r.stride.set([stride]) 

578 r.save() 

579 return r 

580 

581 

582# --------------------------------------------------------------------------- 

583# Risk versioning — admin action: upgrade_to_latest_risk_version 

584# --------------------------------------------------------------------------- 

585 

586class UpgradeRiskVersionAdminActionTest(TestCase): 

587 def setUp(self): 

588 self.asset = Asset.objects.create(name="UpgAsset") 

589 self.origin = Origin.objects.create(name="UpgOrigin") 

590 self.lc = LifeCycle.objects.create(name="UpgLC") 

591 self.stride = Stride.objects.create(name="S") 

592 rr = RiskRating.objects.create(likelihood_of_occurrence=2, severity_of_impact=3) 

593 

594 self.old_risk = _make_versioned_risk("R-UPG-001", 1, False, self.asset, self.origin, self.lc, self.stride) 

595 self.new_risk = _make_versioned_risk("R-UPG-001", 2, True, self.asset, self.origin, self.lc, self.stride) 

596 self.current_risk = _make_versioned_risk("R-CUR-001", 1, True, self.asset, self.origin, self.lc, self.stride) 

597 

598 self.entry_outdated = ProductRiskEntry.objects.create( 

599 risk=self.old_risk, risk_accepted=False, risk_rating_initial=rr) 

600 self.entry_current = ProductRiskEntry.objects.create( 

601 risk=self.current_risk, risk_accepted=False, risk_rating_initial=rr) 

602 

603 self.superuser = User.objects.create_superuser(username="admin", password="pw") 

604 self.client.force_login(self.superuser) 

605 

606 def _post_action(self, pks): 

607 return self.client.post('/admin/product/productriskentry/', { 

608 'action': 'upgrade_to_latest_risk_version', 

609 '_selected_action': pks, 

610 }) 

611 

612 def test_upgrades_entry_pointing_to_outdated_risk(self): 

613 self._post_action([self.entry_outdated.pk]) 

614 self.entry_outdated.refresh_from_db() 

615 self.assertEqual(self.entry_outdated.risk, self.new_risk) 

616 

617 def test_leaves_entry_unchanged_when_already_current(self): 

618 self._post_action([self.entry_current.pk]) 

619 self.entry_current.refresh_from_db() 

620 self.assertEqual(self.entry_current.risk, self.current_risk) 

621 

622 def test_handles_mix_of_outdated_and_current_entries(self): 

623 self._post_action([self.entry_outdated.pk, self.entry_current.pk]) 

624 self.entry_outdated.refresh_from_db() 

625 self.entry_current.refresh_from_db() 

626 self.assertEqual(self.entry_outdated.risk, self.new_risk) 

627 self.assertEqual(self.entry_current.risk, self.current_risk) 

628 

629 

630# --------------------------------------------------------------------------- 

631# Risk versioning — admin display: risk_outdated column 

632# --------------------------------------------------------------------------- 

633 

634class RiskOutdatedAdminDisplayTest(TestCase): 

635 def setUp(self): 

636 asset = Asset.objects.create(name="DispAsset") 

637 origin = Origin.objects.create(name="DispOrigin") 

638 lc = LifeCycle.objects.create(name="DispLC") 

639 stride = Stride.objects.create(name="S") 

640 rr = RiskRating.objects.create(likelihood_of_occurrence=2, severity_of_impact=3) 

641 

642 old_risk = _make_versioned_risk("R-DISP-001", 1, False, asset, origin, lc, stride) 

643 current_risk = _make_versioned_risk("R-DISP-001", 2, True, asset, origin, lc, stride) 

644 

645 self.entry_outdated = ProductRiskEntry.objects.create( 

646 risk=old_risk, risk_accepted=False, risk_rating_initial=rr) 

647 self.entry_current = ProductRiskEntry.objects.create( 

648 risk=current_risk, risk_accepted=False, risk_rating_initial=rr) 

649 

650 def _admin(self): 

651 from product.admin import ProductRiskEntryAdmin 

652 return ProductRiskEntryAdmin(ProductRiskEntry, admin.site) 

653 

654 def test_risk_outdated_false_when_not_current(self): 

655 self.assertFalse(self._admin().risk_outdated(self.entry_outdated)) 

656 

657 def test_risk_outdated_true_when_current(self): 

658 self.assertTrue(self._admin().risk_outdated(self.entry_current)) 

659 

660 

661# --------------------------------------------------------------------------- 

662# Risk versioning — template: outdated icon visibility 

663# --------------------------------------------------------------------------- 

664 

665class RiskOutdatedIconTemplateTest(TestCase): 

666 def setUp(self): 

667 asset = Asset.objects.create(name="IconAsset") 

668 origin = Origin.objects.create(name="IconOrigin") 

669 lc = LifeCycle.objects.create(name="IconLC") 

670 stride = Stride.objects.create(name="S") 

671 rr = RiskRating.objects.create(likelihood_of_occurrence=2, severity_of_impact=3) 

672 

673 old_risk = _make_versioned_risk("R-ICON-001", 1, False, asset, origin, lc, stride) 

674 _new_risk = _make_versioned_risk("R-ICON-001", 2, True, asset, origin, lc, stride) 

675 current_risk = _make_versioned_risk("R-CUR-ICON", 1, True, asset, origin, lc, stride) 

676 

677 entry_outdated = ProductRiskEntry.objects.create( 

678 risk=old_risk, risk_accepted=False, risk_rating_initial=rr) 

679 self.entry_current = ProductRiskEntry.objects.create( 

680 risk=current_risk, risk_accepted=False, risk_rating_initial=rr) 

681 

682 self.analysis_mixed = ProductRiskAnalysis.objects.create( 

683 name="Mixed Analysis", slug="mixed-analysis") 

684 self.analysis_mixed.risk_entries.set([entry_outdated, self.entry_current]) 

685 

686 self.analysis_current_only = ProductRiskAnalysis.objects.create( 

687 name="Current Only", slug="current-only") 

688 self.analysis_current_only.risk_entries.set([self.entry_current]) 

689 

690 self.product = Product.objects.create( 

691 title="Icon Product", description="d", slug="icon-product") 

692 self.product.analyzes.set([self.analysis_mixed, self.analysis_current_only]) 

693 

694 self.user = User.objects.create_user(username="viewer", password="pw") 

695 assign_perm("product.view_product", self.user, self.product) 

696 self.client.force_login(self.user) 

697 

698 def test_outdated_icon_shown_when_entry_has_outdated_risk(self): 

699 response = self.client.get(f'/product/{self.product.slug}/mixed-analysis/') 

700 self.assertContains(response, 'fa-circle-exclamation') 

701 

702 def test_outdated_icon_is_a_link_to_diff_view(self): 

703 response = self.client.get(f'/product/{self.product.slug}/mixed-analysis/') 

704 self.assertContains(response, 'risk-diff/') 

705 

706 def test_outdated_icon_absent_when_all_risks_are_current(self): 

707 response = self.client.get(f'/product/{self.product.slug}/current-only/') 

708 self.assertNotContains(response, 'fa-circle-exclamation') 

709 

710 

711# --------------------------------------------------------------------------- 

712# Risk versioning — risk diff view 

713# --------------------------------------------------------------------------- 

714 

715class RiskDiffViewTest(TestCase): 

716 def setUp(self): 

717 asset = Asset.objects.create(name="DiffAsset") 

718 origin = Origin.objects.create(name="DiffOrigin") 

719 lc = LifeCycle.objects.create(name="DiffLC") 

720 stride = Stride.objects.create(name="S") 

721 rr = RiskRating.objects.create(likelihood_of_occurrence=2, severity_of_impact=3) 

722 

723 self.old_risk = _make_versioned_risk("R-DIFF-001", 1, False, asset, origin, lc, stride) 

724 self.new_risk = _make_versioned_risk("R-DIFF-001", 2, True, asset, origin, lc, stride) 

725 

726 self.old_risk.title = "Old title" 

727 self.old_risk.save() 

728 self.new_risk.title = "New title" 

729 self.new_risk.save() 

730 

731 self.current_risk = _make_versioned_risk("R-CUR-DIFF", 1, True, asset, origin, lc, stride) 

732 

733 self.entry_outdated = ProductRiskEntry.objects.create( 

734 risk=self.old_risk, risk_accepted=False, risk_rating_initial=rr) 

735 self.entry_current = ProductRiskEntry.objects.create( 

736 risk=self.current_risk, risk_accepted=False, risk_rating_initial=rr) 

737 

738 self.product = Product.objects.create( 

739 title="Diff Product", description="d", slug="diff-product") 

740 analysis = ProductRiskAnalysis.objects.create(name="Diff Analysis", slug="diff-analysis") 

741 analysis.risk_entries.set([self.entry_outdated, self.entry_current]) 

742 self.product.analyzes.set([analysis]) 

743 

744 self.viewer = User.objects.create_user(username="viewer", password="pw") 

745 self.noperm = User.objects.create_user(username="noperm", password="pw") 

746 assign_perm("product.view_product", self.viewer, self.product) 

747 

748 def _url(self, entry=None): 

749 entry = entry or self.entry_outdated 

750 return f'/product/{self.product.slug}/risk-diff/{entry.pk}/' 

751 

752 def test_unauthenticated_redirects_to_login(self): 

753 response = self.client.get(self._url()) 

754 self.assertRedirects(response, f'/login?next={self._url()}', 

755 fetch_redirect_response=False) 

756 

757 def test_no_product_permission_returns_403(self): 

758 self.client.force_login(self.noperm) 

759 self.assertEqual(self.client.get(self._url()).status_code, 403) 

760 

761 def test_unknown_entry_returns_404(self): 

762 self.client.force_login(self.viewer) 

763 self.assertEqual( 

764 self.client.get(f'/product/{self.product.slug}/risk-diff/99999/').status_code, 

765 404, 

766 ) 

767 

768 def test_authorized_user_gets_200(self): 

769 self.client.force_login(self.viewer) 

770 self.assertEqual(self.client.get(self._url()).status_code, 200) 

771 

772 def test_shows_both_version_numbers(self): 

773 self.client.force_login(self.viewer) 

774 response = self.client.get(self._url()) 

775 self.assertContains(response, f'v{self.old_risk.version}') 

776 self.assertContains(response, f'v{self.new_risk.version}') 

777 

778 def test_shows_old_and_new_title(self): 

779 self.client.force_login(self.viewer) 

780 response = self.client.get(self._url()) 

781 self.assertContains(response, 'Old title') 

782 self.assertContains(response, 'New title') 

783 

784 def test_changed_field_marker_present(self): 

785 self.client.force_login(self.viewer) 

786 response = self.client.get(self._url()) 

787 self.assertContains(response, 'class="diff-changed"') 

788 

789 def test_entry_already_current_shows_no_diff_message(self): 

790 self.client.force_login(self.viewer) 

791 response = self.client.get(self._url(self.entry_current)) 

792 self.assertNotContains(response, 'class="diff-changed"') 

793 self.assertContains(response, 'latest version')