Skip to content

Commit af3716f

Browse files
Jino-TclaudeMaffooch
authored
fix(risk_acceptance): reinstate findings when expiration date updated via API (#15147)
* fix(risk_acceptance): reinstate findings when expiration date updated via API RiskAcceptanceSerializer.update() never called ra_helper.reinstate() when expiration_date changed, unlike the legacy Django view which does so at engagement/views.py. This caused two bugs reported together: 1. Findings stayed Active after a user updated the expiration date from a past date to a future date via the Edit Risk Acceptance form (Vue UI). reinstate() sets them back to inactive/risk_accepted. 2. Findings stayed Inactive on subsequent expiry cycles. Because reinstate() was never called, expiration_date_handled was never cleared. The Celery expiration task filters on expiration_date_handled__isnull=True, so the RA was permanently excluded from every future expiry run. Fix: capture old_expiration_date before super().update(), then call ra_helper.reinstate() when the date changes — matching the logic that already existed in the legacy view. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(risk_acceptance): regression coverage for reinstate on API expiration update Adds two API tests exercising the serializer update() reinstate path: - Extending an expired RA's expiration_date reinstates its findings (active=False / risk_accepted=True) and clears expiration_date_handled so the Celery expiry task re-processes it. - Editing a never-expired RA's date leaves findings untouched and keeps expiration_date_handled None. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Cody Maffucci <46459665+Maffooch@users.noreply.github.com>
1 parent ae9d47b commit af3716f

2 files changed

Lines changed: 90 additions & 0 deletions

File tree

dojo/risk_acceptance/api/serializer.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ def update(self, instance, validated_data):
5050
findings_to_remove = set(existing_findings) - set(new_findings)
5151
findings_to_add = Finding.objects.filter(id__in=[x.id for x in findings_to_add])
5252
findings_to_remove = Finding.objects.filter(id__in=[x.id for x in findings_to_remove])
53+
old_expiration_date = self.instance.expiration_date
5354
# Make the update in the database
5455
instance = super().update(instance, validated_data)
5556
user = getattr(self.context.get("request", None), "user", None)
@@ -59,6 +60,9 @@ def update(self, instance, validated_data):
5960
for finding in findings_to_remove:
6061
ra_helper.remove_finding_from_risk_acceptance(user, instance, finding)
6162

63+
if instance.expiration_date != old_expiration_date:
64+
ra_helper.reinstate(instance, old_expiration_date)
65+
6266
# Handle orphaned risk acceptances: link to engagement if it now has findings
6367
# This is fine as Pro has its own model + relationshop to track links with engagements.
6468
if instance.accepted_findings.exists() and not instance.engagement:

unittests/test_risk_acceptance_api.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -374,6 +374,92 @@ def test_update_risk_acceptance_add_cross_engagement_fails(self):
374374
self.assertEqual(403, response.status_code, response.content)
375375
self.assertIn("multiple engagements", str(response.data))
376376

377+
def test_update_expired_risk_acceptance_reinstates_findings(self):
378+
"""
379+
Regression test for the serializer update() reinstate bug.
380+
381+
Extending an expired risk acceptance's expiration_date via the API must call
382+
ra_helper.reinstate(): its accepted findings are set back to inactive/risk-accepted
383+
and expiration_date_handled is cleared so the Celery expiry task re-processes it.
384+
"""
385+
past_date = datetime.datetime.now(datetime.UTC) - datetime.timedelta(days=5)
386+
ra = Risk_Acceptance.objects.create(
387+
name="Expired RA",
388+
recommendation="A",
389+
decision="A",
390+
accepted_by="Test User",
391+
owner=self.user,
392+
expiration_date=past_date,
393+
expiration_date_handled=past_date,
394+
)
395+
ra.accepted_findings.add(self.finding_a1)
396+
self.engagement_a.risk_acceptance.add(ra)
397+
398+
# Simulate the post-expiry state: the expiry job already reactivated the finding
399+
self.finding_a1.active = True
400+
self.finding_a1.risk_accepted = False
401+
self.finding_a1.save()
402+
403+
future_date = datetime.datetime.now(datetime.UTC) + datetime.timedelta(days=30)
404+
payload = {
405+
"expiration_date": future_date.strftime("%Y-%m-%dT%H:%M:%SZ"),
406+
"accepted_findings": [self.finding_a1.id],
407+
}
408+
409+
response = self.client.patch(f"{self.url}{ra.id}/", payload, format="json")
410+
self.assertEqual(200, response.status_code, response.content)
411+
412+
# Finding is reinstated (only reinstate() flips these back)
413+
self.finding_a1.refresh_from_db()
414+
self.assertFalse(self.finding_a1.active)
415+
self.assertTrue(self.finding_a1.risk_accepted)
416+
417+
# Flags cleared so the RA is eligible for the next Celery expiry cycle
418+
ra.refresh_from_db()
419+
self.assertIsNone(ra.expiration_date_handled)
420+
self.assertIsNone(ra.expiration_date_warned)
421+
422+
def test_update_never_expired_risk_acceptance_leaves_findings_unchanged(self):
423+
"""
424+
Regression test companion: editing the expiration_date of a risk acceptance that
425+
never expired must not disturb its findings, and expiration_date_handled stays None.
426+
"""
427+
old_date = datetime.datetime.now(datetime.UTC) + datetime.timedelta(days=30)
428+
ra = Risk_Acceptance.objects.create(
429+
name="Active RA",
430+
recommendation="A",
431+
decision="A",
432+
accepted_by="Test User",
433+
owner=self.user,
434+
expiration_date=old_date,
435+
)
436+
ra.accepted_findings.add(self.finding_a1)
437+
self.engagement_a.risk_acceptance.add(ra)
438+
439+
# Normal accepted state for a live risk acceptance
440+
self.finding_a1.active = False
441+
self.finding_a1.risk_accepted = True
442+
self.finding_a1.save()
443+
444+
new_date = datetime.datetime.now(datetime.UTC) + datetime.timedelta(days=60)
445+
payload = {
446+
"expiration_date": new_date.strftime("%Y-%m-%dT%H:%M:%SZ"),
447+
"accepted_findings": [self.finding_a1.id],
448+
}
449+
450+
response = self.client.patch(f"{self.url}{ra.id}/", payload, format="json")
451+
self.assertEqual(200, response.status_code, response.content)
452+
453+
# Findings untouched
454+
self.finding_a1.refresh_from_db()
455+
self.assertFalse(self.finding_a1.active)
456+
self.assertTrue(self.finding_a1.risk_accepted)
457+
458+
# Never handled, so it stays None
459+
ra.refresh_from_db()
460+
self.assertIsNone(ra.expiration_date_handled)
461+
self.assertIsNone(ra.expiration_date_warned)
462+
377463
def test_risk_acceptance_created_filter(self):
378464
# 1. Create a baseline Risk Acceptance using the existing test setup
379465
risk_acceptance = self.create_risk_acceptance()

0 commit comments

Comments
 (0)