authentik.enterprise.lifecycle.tests.test_models

  1import datetime as dt
  2from datetime import timedelta
  3from unittest.mock import patch
  4
  5from django.contrib.contenttypes.models import ContentType
  6from django.test import RequestFactory, TestCase
  7from django.utils import timezone
  8
  9from authentik.core.models import Application, Group
 10from authentik.core.tests.utils import create_test_user
 11from authentik.enterprise.lifecycle.models import (
 12    LifecycleIteration,
 13    LifecycleRule,
 14    Review,
 15    ReviewState,
 16)
 17from authentik.events.models import (
 18    Event,
 19    EventAction,
 20    NotificationSeverity,
 21    NotificationTransport,
 22)
 23from authentik.lib.generators import generate_id
 24from authentik.rbac.models import Role
 25
 26
 27class TestLifecycleModels(TestCase):
 28
 29    def setUp(self):
 30        self.factory = RequestFactory()
 31
 32    def _get_request(self):
 33        return self.factory.get("/")
 34
 35    def _create_object(self, model):
 36        if model is Application:
 37            return Application.objects.create(name=generate_id(), slug=generate_id())
 38        if model is Role:
 39            return Role.objects.create(name=generate_id())
 40        if model is Group:
 41            return Group.objects.create(name=generate_id())
 42        raise AssertionError(f"Unsupported model {model}")
 43
 44    def _create_rule_for_object(self, obj, **kwargs) -> LifecycleRule:
 45        content_type = ContentType.objects.get_for_model(obj)
 46        return LifecycleRule.objects.create(
 47            name=generate_id(),
 48            content_type=content_type,
 49            object_id=str(obj.pk),
 50            **kwargs,
 51        )
 52
 53    def _create_rule_for_type(self, model, **kwargs) -> LifecycleRule:
 54        content_type = ContentType.objects.get_for_model(model)
 55        return LifecycleRule.objects.create(
 56            name=generate_id(),
 57            content_type=content_type,
 58            object_id=None,
 59            **kwargs,
 60        )
 61
 62    def test_iteration_start_supported_objects(self):
 63        """Ensure iterations are automatically started for applications, roles, and groups."""
 64        for model in (Application, Role, Group):
 65            with self.subTest(model=model.__name__):
 66                obj = self._create_object(model)
 67                content_type = ContentType.objects.get_for_model(obj)
 68
 69                before_events = Event.objects.filter(action=EventAction.REVIEW_INITIATED).count()
 70
 71                rule = self._create_rule_for_object(obj)
 72
 73                # Verify iteration was created automatically
 74                iteration = LifecycleIteration.objects.get(
 75                    content_type=content_type, object_id=str(obj.pk), rule=rule
 76                )
 77                self.assertEqual(iteration.state, ReviewState.PENDING)
 78                self.assertEqual(iteration.object, obj)
 79                self.assertEqual(iteration.rule, rule)
 80                self.assertEqual(
 81                    Event.objects.filter(action=EventAction.REVIEW_INITIATED).count(),
 82                    before_events + 1,
 83                )
 84
 85    def test_review_requires_all_explicit_reviewers(self):
 86        obj = Group.objects.create(name=generate_id())
 87        rule = self._create_rule_for_object(obj)
 88        reviewer_one = create_test_user()
 89        reviewer_two = create_test_user()
 90        rule.reviewers.add(reviewer_one, reviewer_two)
 91
 92        content_type = ContentType.objects.get_for_model(obj)
 93
 94        iteration = LifecycleIteration.objects.get(
 95            content_type=content_type, object_id=str(obj.pk), rule=rule
 96        )
 97        request = self._get_request()
 98
 99        Review.objects.create(iteration=iteration, reviewer=reviewer_one)
100        iteration.on_review(request)
101        iteration.refresh_from_db()
102        self.assertEqual(iteration.state, ReviewState.PENDING)
103
104        Review.objects.create(iteration=iteration, reviewer=reviewer_two)
105        iteration.on_review(request)
106        iteration.refresh_from_db()
107        self.assertEqual(iteration.state, ReviewState.REVIEWED)
108        self.assertTrue(Event.objects.filter(action=EventAction.REVIEW_COMPLETED).exists())
109
110    def test_review_min_reviewers_from_groups(self):
111        """Group-based reviews complete once the minimum number of reviewers review."""
112        obj = Application.objects.create(name=generate_id(), slug=generate_id())
113        rule = self._create_rule_for_object(obj, min_reviewers=2)
114
115        reviewer_group = Group.objects.create(name=generate_id())
116        reviewer_one = create_test_user()
117        reviewer_two = create_test_user()
118        reviewer_group.users.add(reviewer_one, reviewer_two)
119        rule.reviewer_groups.add(reviewer_group)
120
121        content_type = ContentType.objects.get_for_model(obj)
122
123        iteration = LifecycleIteration.objects.get(
124            content_type=content_type, object_id=str(obj.pk), rule=rule
125        )
126        request = self._get_request()
127
128        Review.objects.create(iteration=iteration, reviewer=reviewer_one)
129        iteration.on_review(request)
130        iteration.refresh_from_db()
131        self.assertEqual(iteration.state, ReviewState.PENDING)
132
133        Review.objects.create(iteration=iteration, reviewer=reviewer_two)
134        iteration.on_review(request)
135        iteration.refresh_from_db()
136        self.assertEqual(iteration.state, ReviewState.REVIEWED)
137
138    def test_review_explicit_and_group_reviewers(self):
139        """Reviews require both explicit reviewers AND min_reviewers from groups."""
140        obj = Application.objects.create(name=generate_id(), slug=generate_id())
141        rule = self._create_rule_for_object(obj, min_reviewers=1)
142
143        reviewer_group = Group.objects.create(name=generate_id())
144        group_member = create_test_user()
145        reviewer_group.users.add(group_member)
146        rule.reviewer_groups.add(reviewer_group)
147
148        explicit_reviewer = create_test_user()
149        rule.reviewers.add(explicit_reviewer)
150
151        content_type = ContentType.objects.get_for_model(obj)
152
153        iteration = LifecycleIteration.objects.get(
154            content_type=content_type, object_id=str(obj.pk), rule=rule
155        )
156        request = self._get_request()
157
158        # Only group member reviews - not satisfied (explicit reviewer missing)
159        Review.objects.create(iteration=iteration, reviewer=group_member)
160        iteration.on_review(request)
161        iteration.refresh_from_db()
162        self.assertEqual(iteration.state, ReviewState.PENDING)
163
164        # Explicit reviewer reviews - now satisfied
165        Review.objects.create(iteration=iteration, reviewer=explicit_reviewer)
166        iteration.on_review(request)
167        iteration.refresh_from_db()
168        self.assertEqual(iteration.state, ReviewState.REVIEWED)
169
170    def test_review_min_reviewers_per_group(self):
171        obj = Application.objects.create(name=generate_id(), slug=generate_id())
172        rule = self._create_rule_for_object(obj, min_reviewers=1, min_reviewers_is_per_group=True)
173
174        group_one = Group.objects.create(name=generate_id())
175        group_two = Group.objects.create(name=generate_id())
176        member_group_one = create_test_user()
177        member_group_two = create_test_user()
178        group_one.users.add(member_group_one)
179        group_two.users.add(member_group_two)
180        rule.reviewer_groups.add(group_one, group_two)
181
182        content_type = ContentType.objects.get_for_model(obj)
183
184        iteration = LifecycleIteration.objects.get(
185            content_type=content_type, object_id=str(obj.pk), rule=rule
186        )
187        request = self._get_request()
188
189        # Only member from group_one reviews - not satisfied (need member from each group)
190        Review.objects.create(iteration=iteration, reviewer=member_group_one)
191        iteration.on_review(request)
192        iteration.refresh_from_db()
193        self.assertEqual(iteration.state, ReviewState.PENDING)
194
195        # Member from group_two reviews - now satisfied
196        Review.objects.create(iteration=iteration, reviewer=member_group_two)
197        iteration.on_review(request)
198        iteration.refresh_from_db()
199        self.assertEqual(iteration.state, ReviewState.REVIEWED)
200
201    def test_review_reviewers_from_child_groups(self):
202        obj = Application.objects.create(name=generate_id(), slug=generate_id())
203        rule = self._create_rule_for_object(obj, min_reviewers=1)
204
205        parent_group = Group.objects.create(name=generate_id())
206        child_group = Group.objects.create(name=generate_id())
207        child_group.parents.add(parent_group)
208
209        child_member = create_test_user()
210        child_group.users.add(child_member)
211
212        rule.reviewer_groups.add(parent_group)
213
214        content_type = ContentType.objects.get_for_model(obj)
215
216        iteration = LifecycleIteration.objects.get(
217            content_type=content_type, object_id=str(obj.pk), rule=rule
218        )
219        request = self._get_request()
220
221        # Child group member should be able to review
222        self.assertTrue(iteration.user_can_review(child_member))
223
224        Review.objects.create(iteration=iteration, reviewer=child_member)
225        iteration.on_review(request)
226        iteration.refresh_from_db()
227        self.assertEqual(iteration.state, ReviewState.REVIEWED)
228
229    def test_review_reviewers_from_nested_child_groups(self):
230        obj = Application.objects.create(name=generate_id(), slug=generate_id())
231        rule = self._create_rule_for_object(obj, min_reviewers=2)
232
233        grandparent = Group.objects.create(name=generate_id())
234        parent = Group.objects.create(name=generate_id())
235        child = Group.objects.create(name=generate_id())
236        parent.parents.add(grandparent)
237        child.parents.add(parent)
238
239        parent_member = create_test_user()
240        child_member = create_test_user()
241        parent.users.add(parent_member)
242        child.users.add(child_member)
243
244        rule.reviewer_groups.add(grandparent)
245
246        content_type = ContentType.objects.get_for_model(obj)
247
248        iteration = LifecycleIteration.objects.get(
249            content_type=content_type, object_id=str(obj.pk), rule=rule
250        )
251        request = self._get_request()
252
253        # Both nested members should be able to review
254        self.assertTrue(iteration.user_can_review(parent_member))
255        self.assertTrue(iteration.user_can_review(child_member))
256
257        Review.objects.create(iteration=iteration, reviewer=parent_member)
258        iteration.on_review(request)
259        iteration.refresh_from_db()
260        self.assertEqual(iteration.state, ReviewState.PENDING)
261
262        Review.objects.create(iteration=iteration, reviewer=child_member)
263        iteration.on_review(request)
264        iteration.refresh_from_db()
265        self.assertEqual(iteration.state, ReviewState.REVIEWED)
266
267    def test_notify_reviewers_send_once(self):
268        obj = Group.objects.create(name=generate_id())
269        rule = self._create_rule_for_object(obj)
270
271        reviewer_one = create_test_user()
272        reviewer_two = create_test_user()
273        rule.reviewers.add(reviewer_one, reviewer_two)
274
275        transport_once = NotificationTransport.objects.create(
276            name=generate_id(),
277            send_once=True,
278        )
279        transport_all = NotificationTransport.objects.create(
280            name=generate_id(),
281            send_once=False,
282        )
283        rule.notification_transports.add(transport_once, transport_all)
284
285        event = Event.new(EventAction.REVIEW_INITIATED, target=obj)
286        event.save()
287
288        with patch(
289            "authentik.enterprise.lifecycle.tasks.send_notification.send_with_options"
290        ) as send_with_options:
291            rule.notify_reviewers(event, NotificationSeverity.NOTICE)
292
293            reviewer_pks = {reviewer_one.pk, reviewer_two.pk}
294            self.assertEqual(send_with_options.call_count, len(reviewer_pks) + 1)
295
296            calls = [call.kwargs["args"] for call in send_with_options.call_args_list]
297            once_calls = [args for args in calls if args[0] == transport_once.pk]
298            all_calls = [args for args in calls if args[0] == transport_all.pk]
299
300            self.assertEqual(len(once_calls), 1)
301            self.assertEqual(len(all_calls), len(reviewer_pks))
302            self.assertIn(once_calls[0][2], reviewer_pks)
303            self.assertEqual({args[2] for args in all_calls}, reviewer_pks)
304
305    def test_apply_marks_overdue_and_opens_due_reviews(self):
306        app_one = Application.objects.create(name=generate_id(), slug=generate_id())
307        app_two = Application.objects.create(name=generate_id(), slug=generate_id())
308        content_type = ContentType.objects.get_for_model(Application)
309
310        rule_overdue = LifecycleRule.objects.create(
311            name=generate_id(),
312            content_type=content_type,
313            object_id=str(app_one.pk),
314            interval="days=365",
315            grace_period="days=10",
316        )
317
318        # Get the automatically created iteration and backdate it past the grace period
319        iteration = LifecycleIteration.objects.get(
320            content_type=content_type, object_id=str(app_one.pk), rule=rule_overdue
321        )
322        LifecycleIteration.objects.filter(pk=iteration.pk).update(
323            opened_on=(timezone.now() - timedelta(days=20))
324        )
325
326        # Apply again to trigger overdue logic
327        rule_overdue.apply()
328        iteration.refresh_from_db()
329        self.assertEqual(iteration.state, ReviewState.OVERDUE)
330        self.assertEqual(
331            LifecycleIteration.objects.filter(
332                content_type=content_type, object_id=str(app_one.pk)
333            ).count(),
334            1,
335        )
336
337        LifecycleRule.objects.create(
338            name=generate_id(),
339            content_type=content_type,
340            object_id=str(app_two.pk),
341            interval="days=30",
342            grace_period="days=10",
343        )
344        self.assertEqual(
345            LifecycleIteration.objects.filter(
346                content_type=content_type, object_id=str(app_two.pk)
347            ).count(),
348            1,
349        )
350        new_iteration = LifecycleIteration.objects.get(
351            content_type=content_type, object_id=str(app_two.pk)
352        )
353        self.assertEqual(new_iteration.state, ReviewState.PENDING)
354
355    def test_apply_idempotent(self):
356        app_due = Application.objects.create(name=generate_id(), slug=generate_id())
357        app_overdue = Application.objects.create(name=generate_id(), slug=generate_id())
358        content_type = ContentType.objects.get_for_model(Application)
359
360        initiated_before = Event.objects.filter(action=EventAction.REVIEW_INITIATED).count()
361        overdue_before = Event.objects.filter(action=EventAction.REVIEW_OVERDUE).count()
362
363        rule_due = LifecycleRule.objects.create(
364            name=generate_id(),
365            content_type=content_type,
366            object_id=str(app_due.pk),
367            interval="days=30",
368            grace_period="days=30",
369        )
370        reviewer = create_test_user()
371        rule_due.reviewers.add(reviewer)
372        transport = NotificationTransport.objects.create(name=generate_id())
373        rule_due.notification_transports.add(transport)
374
375        rule_overdue = LifecycleRule.objects.create(
376            name=generate_id(),
377            content_type=content_type,
378            object_id=str(app_overdue.pk),
379            interval="days=365",
380            grace_period="days=10",
381        )
382
383        overdue_iteration = LifecycleIteration.objects.get(
384            content_type=content_type, object_id=str(app_overdue.pk), rule=rule_overdue
385        )
386        LifecycleIteration.objects.filter(pk=overdue_iteration.pk).update(
387            opened_on=(timezone.now() - timedelta(days=20))
388        )
389
390        # Apply overdue rule to mark iteration as overdue
391        rule_overdue.apply()
392
393        due_iteration = LifecycleIteration.objects.get(
394            content_type=content_type, object_id=str(app_due.pk)
395        )
396        overdue_iteration.refresh_from_db()
397        self.assertEqual(due_iteration.state, ReviewState.PENDING)
398        self.assertEqual(overdue_iteration.state, ReviewState.OVERDUE)
399
400        initiated_after_first = Event.objects.filter(action=EventAction.REVIEW_INITIATED).count()
401        overdue_after_first = Event.objects.filter(action=EventAction.REVIEW_OVERDUE).count()
402        # Both rules created iterations on save
403        self.assertEqual(initiated_after_first, initiated_before + 2)
404        self.assertEqual(overdue_after_first, overdue_before + 1)
405
406        # Apply again - should be idempotent
407        rule_due.apply()
408        rule_overdue.apply()
409
410        due_iteration.refresh_from_db()
411        overdue_iteration.refresh_from_db()
412        self.assertEqual(due_iteration.state, ReviewState.PENDING)
413        self.assertEqual(overdue_iteration.state, ReviewState.OVERDUE)
414        self.assertEqual(
415            Event.objects.filter(action=EventAction.REVIEW_INITIATED).count(),
416            initiated_after_first,
417        )
418        self.assertEqual(
419            Event.objects.filter(action=EventAction.REVIEW_OVERDUE).count(),
420            overdue_after_first,
421        )
422
423    def test_rule_matches_entire_type(self):
424        """A rule with object_id=None matches all objects of that type."""
425        app_one = Application.objects.create(name=generate_id(), slug=generate_id())
426        app_two = Application.objects.create(name=generate_id(), slug=generate_id())
427        content_type = ContentType.objects.get_for_model(Application)
428
429        rule = LifecycleRule.objects.create(
430            name=generate_id(),
431            content_type=content_type,
432            object_id=None,
433            interval="days=30",
434            grace_period="days=10",
435        )
436
437        objects = list(rule.get_objects())
438        self.assertIn(app_one, objects)
439        self.assertIn(app_two, objects)
440
441    def test_rule_type_excludes_objects_with_specific_rules(self):
442        app_with_rule = Application.objects.create(name=generate_id(), slug=generate_id())
443        app_without_rule = Application.objects.create(name=generate_id(), slug=generate_id())
444        content_type = ContentType.objects.get_for_model(Application)
445
446        # Create a specific rule for app_with_rule
447        LifecycleRule.objects.create(
448            name=generate_id(),
449            content_type=content_type,
450            object_id=str(app_with_rule.pk),
451            interval="days=30",
452        )
453
454        # Create a type-level rule
455        type_rule = LifecycleRule.objects.create(
456            name=generate_id(),
457            content_type=content_type,
458            object_id=None,
459            interval="days=60",
460        )
461
462        objects = list(type_rule.get_objects())
463        self.assertNotIn(app_with_rule, objects)
464        self.assertIn(app_without_rule, objects)
465
466    def test_rule_type_apply_creates_iterations_for_all_objects(self):
467        app_one = Application.objects.create(name=generate_id(), slug=generate_id())
468        app_two = Application.objects.create(name=generate_id(), slug=generate_id())
469        content_type = ContentType.objects.get_for_model(Application)
470
471        LifecycleRule.objects.create(
472            name=generate_id(),
473            content_type=content_type,
474            object_id=None,
475            interval="days=30",
476            grace_period="days=10",
477        )
478
479        self.assertTrue(
480            LifecycleIteration.objects.filter(
481                content_type=content_type, object_id=str(app_one.pk)
482            ).exists()
483        )
484        self.assertTrue(
485            LifecycleIteration.objects.filter(
486                content_type=content_type, object_id=str(app_two.pk)
487            ).exists()
488        )
489
490    def test_delete_rule_cancels_open_iterations(self):
491        obj = Application.objects.create(name=generate_id(), slug=generate_id())
492
493        rule = self._create_rule_for_object(obj)
494        content_type = ContentType.objects.get_for_model(obj)
495
496        pending_iteration = LifecycleIteration.objects.get(
497            content_type=content_type, object_id=str(obj.pk), rule=rule
498        )
499        self.assertEqual(pending_iteration.state, ReviewState.PENDING)
500
501        overdue_iteration = LifecycleIteration.objects.create(
502            content_type=content_type,
503            object_id=str(obj.pk),
504            rule=rule,
505            state=ReviewState.OVERDUE,
506        )
507        reviewed_iteration = LifecycleIteration.objects.create(
508            content_type=content_type,
509            object_id=str(obj.pk),
510            rule=rule,
511            state=ReviewState.REVIEWED,
512        )
513
514        rule.delete()
515
516        pending_iteration.refresh_from_db()
517        overdue_iteration.refresh_from_db()
518        reviewed_iteration.refresh_from_db()
519
520        self.assertEqual(pending_iteration.state, ReviewState.CANCELED)
521        self.assertEqual(overdue_iteration.state, ReviewState.CANCELED)
522        self.assertEqual(reviewed_iteration.state, ReviewState.REVIEWED)  # Not affected
523
524    def test_update_rule_target_cancels_stale_iterations(self):
525        app_one = Application.objects.create(name=generate_id(), slug=generate_id())
526        app_two = Application.objects.create(name=generate_id(), slug=generate_id())
527        content_type = ContentType.objects.get_for_model(Application)
528
529        rule = LifecycleRule.objects.create(
530            name=generate_id(),
531            content_type=content_type,
532            object_id=str(app_one.pk),
533            interval="days=30",
534        )
535
536        iteration_for_app_one = LifecycleIteration.objects.get(
537            content_type=content_type, object_id=str(app_one.pk), rule=rule
538        )
539        self.assertEqual(iteration_for_app_one.state, ReviewState.PENDING)
540
541        # Change rule target to app_two - save() triggers apply() which cancels stale iterations
542        rule.object_id = str(app_two.pk)
543        rule.save()
544
545        iteration_for_app_one.refresh_from_db()
546        self.assertEqual(iteration_for_app_one.state, ReviewState.CANCELED)
547
548    def test_update_rule_content_type_cancels_stale_iterations(self):
549        app = Application.objects.create(name=generate_id(), slug=generate_id())
550        group = Group.objects.create(name=generate_id())
551        app_content_type = ContentType.objects.get_for_model(Application)
552        group_content_type = ContentType.objects.get_for_model(Group)
553
554        # Creating rule triggers automatic apply() which creates a iteration for app
555        rule = LifecycleRule.objects.create(
556            name=generate_id(),
557            content_type=app_content_type,
558            object_id=str(app.pk),
559            interval="days=30",
560        )
561
562        iteration = LifecycleIteration.objects.get(
563            content_type=app_content_type, object_id=str(app.pk), rule=rule
564        )
565        self.assertEqual(iteration.state, ReviewState.PENDING)
566
567        # Change content type to Group - save() triggers apply() which cancels stale iterations
568        rule.content_type = group_content_type
569        rule.object_id = str(group.pk)
570        rule.save()
571
572        iteration.refresh_from_db()
573        self.assertEqual(iteration.state, ReviewState.CANCELED)
574
575    def test_user_can_review_checks_group_hierarchy(self):
576        obj = Application.objects.create(name=generate_id(), slug=generate_id())
577        rule = self._create_rule_for_object(obj)
578
579        parent_group = Group.objects.create(name=generate_id())
580        child_group = Group.objects.create(name=generate_id())
581        child_group.parents.add(parent_group)
582
583        parent_member = create_test_user()
584        child_member = create_test_user()
585        non_member = create_test_user()
586        parent_group.users.add(parent_member)
587        child_group.users.add(child_member)
588
589        rule.reviewer_groups.add(parent_group)
590
591        content_type = ContentType.objects.get_for_model(obj)
592        # iteration is created automatically when rule is saved
593        iteration = LifecycleIteration.objects.get(
594            content_type=content_type, object_id=str(obj.pk), rule=rule
595        )
596
597        self.assertTrue(iteration.user_can_review(parent_member))
598        self.assertTrue(iteration.user_can_review(child_member))
599        self.assertFalse(iteration.user_can_review(non_member))
600
601    def test_user_cannot_review_twice(self):
602        obj = Application.objects.create(name=generate_id(), slug=generate_id())
603        rule = self._create_rule_for_object(obj)
604        reviewer = create_test_user()
605        rule.reviewers.add(reviewer)
606
607        content_type = ContentType.objects.get_for_model(obj)
608        # iteration is created automatically when rule is saved
609        iteration = LifecycleIteration.objects.get(
610            content_type=content_type, object_id=str(obj.pk), rule=rule
611        )
612
613        self.assertTrue(iteration.user_can_review(reviewer))
614
615        Review.objects.create(iteration=iteration, reviewer=reviewer)
616
617        self.assertFalse(iteration.user_can_review(reviewer))
618
619    def test_user_cannot_review_completed_iteration(self):
620        obj = Application.objects.create(name=generate_id(), slug=generate_id())
621        rule = self._create_rule_for_object(obj)
622        reviewer = create_test_user()
623        rule.reviewers.add(reviewer)
624
625        content_type = ContentType.objects.get_for_model(obj)
626
627        # Get the automatically created pending iteration and test with different states
628        iteration = LifecycleIteration.objects.get(
629            content_type=content_type, object_id=str(obj.pk), rule=rule
630        )
631
632        for state in (ReviewState.REVIEWED, ReviewState.CANCELED):
633            iteration.state = state
634            iteration.save()
635            self.assertFalse(iteration.user_can_review(reviewer))
636
637    def test_get_reviewers_includes_child_group_members(self):
638        obj = Application.objects.create(name=generate_id(), slug=generate_id())
639        rule = self._create_rule_for_object(obj)
640
641        parent_group = Group.objects.create(name=generate_id())
642        child_group = Group.objects.create(name=generate_id())
643        child_group.parents.add(parent_group)
644
645        parent_member = create_test_user()
646        child_member = create_test_user()
647        parent_group.users.add(parent_member)
648        child_group.users.add(child_member)
649
650        rule.reviewer_groups.add(parent_group)
651
652        reviewers = list(rule.get_reviewers())
653        self.assertIn(parent_member, reviewers)
654        self.assertIn(child_member, reviewers)
655
656    def test_get_reviewers_includes_explicit_reviewers(self):
657        obj = Application.objects.create(name=generate_id(), slug=generate_id())
658        rule = self._create_rule_for_object(obj)
659
660        explicit_reviewer = create_test_user()
661        rule.reviewers.add(explicit_reviewer)
662
663        group = Group.objects.create(name=generate_id())
664        group_member = create_test_user()
665        group.users.add(group_member)
666        rule.reviewer_groups.add(group)
667
668        reviewers = list(rule.get_reviewers())
669        self.assertIn(explicit_reviewer, reviewers)
670        self.assertIn(group_member, reviewers)
671
672
673class TestLifecycleDateBoundaries(TestCase):
674    """Verify that start_of_day normalization ensures correct overdue/due
675    detection regardless of exact task execution time within a day.
676
677    The daily task may run at any point during the day. The start_of_day
678    normalization in _get_newly_overdue_iterations and _get_newly_due_objects
679    ensures that the boundary is always at midnight, so millisecond variations
680    in task execution time do not affect results."""
681
682    def _create_rule_and_iteration(self, grace_period="days=1", interval="days=365"):
683        app = Application.objects.create(name=generate_id(), slug=generate_id())
684        content_type = ContentType.objects.get_for_model(Application)
685        rule = LifecycleRule.objects.create(
686            name=generate_id(),
687            content_type=content_type,
688            object_id=str(app.pk),
689            interval=interval,
690            grace_period=grace_period,
691        )
692        iteration = LifecycleIteration.objects.get(
693            content_type=content_type, object_id=str(app.pk), rule=rule
694        )
695        return app, rule, iteration
696
697    def test_overdue_iteration_opened_yesterday(self):
698        """grace_period=1 day: iteration opened yesterday at any time is overdue today."""
699        _, rule, iteration = self._create_rule_and_iteration(grace_period="days=1")
700        fixed_now = dt.datetime(2025, 6, 15, 14, 30, 0, tzinfo=dt.UTC)
701        for opened_on in [
702            dt.datetime(2025, 6, 14, 0, 0, 0, tzinfo=dt.UTC),
703            dt.datetime(2025, 6, 14, 12, 0, 0, tzinfo=dt.UTC),
704            dt.datetime(2025, 6, 14, 23, 59, 59, 999999, tzinfo=dt.UTC),
705        ]:
706            with self.subTest(opened_on=opened_on):
707                LifecycleIteration.objects.filter(pk=iteration.pk).update(
708                    opened_on=opened_on, state=ReviewState.PENDING
709                )
710                with patch("django.utils.timezone.now", return_value=fixed_now):
711                    self.assertIn(iteration, list(rule._get_newly_overdue_iterations()))
712
713    def test_not_overdue_iteration_opened_today(self):
714        """grace_period=1 day: iteration opened today at any time is NOT overdue."""
715        _, rule, iteration = self._create_rule_and_iteration(grace_period="days=1")
716        fixed_now = dt.datetime(2025, 6, 15, 14, 30, 0, tzinfo=dt.UTC)
717        for opened_on in [
718            dt.datetime(2025, 6, 15, 0, 0, 0, tzinfo=dt.UTC),
719            dt.datetime(2025, 6, 15, 14, 30, 0, tzinfo=dt.UTC),
720            dt.datetime(2025, 6, 15, 23, 59, 59, 999999, tzinfo=dt.UTC),
721        ]:
722            with self.subTest(opened_on=opened_on):
723                LifecycleIteration.objects.filter(pk=iteration.pk).update(
724                    opened_on=opened_on, state=ReviewState.PENDING
725                )
726                with patch("django.utils.timezone.now", return_value=fixed_now):
727                    self.assertNotIn(iteration, list(rule._get_newly_overdue_iterations()))
728
729    def test_overdue_independent_of_task_execution_time(self):
730        """Overdue detection gives the same result whether the task runs at 00:00:01 or 23:59:59."""
731        _, rule, iteration = self._create_rule_and_iteration(grace_period="days=1")
732        opened_on = dt.datetime(2025, 6, 14, 18, 0, 0, tzinfo=dt.UTC)
733        LifecycleIteration.objects.filter(pk=iteration.pk).update(
734            opened_on=opened_on, state=ReviewState.PENDING
735        )
736        for task_time in [
737            dt.datetime(2025, 6, 15, 0, 0, 1, tzinfo=dt.UTC),
738            dt.datetime(2025, 6, 15, 12, 0, 0, tzinfo=dt.UTC),
739            dt.datetime(2025, 6, 15, 23, 59, 59, tzinfo=dt.UTC),
740        ]:
741            with self.subTest(task_time=task_time):
742                with patch("django.utils.timezone.now", return_value=task_time):
743                    self.assertIn(iteration, list(rule._get_newly_overdue_iterations()))
744
745    def test_overdue_boundary_multi_day_grace_period(self):
746        """grace_period=30 days: overdue after 30 full days, not after 29."""
747        _, rule, iteration = self._create_rule_and_iteration(grace_period="days=30")
748        fixed_now = dt.datetime(2025, 6, 15, 14, 30, 0, tzinfo=dt.UTC)
749
750        # Opened 30 days ago (May 16), should go overdue
751        LifecycleIteration.objects.filter(pk=iteration.pk).update(
752            opened_on=dt.datetime(2025, 5, 16, 12, 0, 0, tzinfo=dt.UTC),
753            state=ReviewState.PENDING,
754        )
755        with patch("django.utils.timezone.now", return_value=fixed_now):
756            self.assertIn(iteration, list(rule._get_newly_overdue_iterations()))
757
758        # Opened 29 days ago (May 17), should NOT go overdue
759        LifecycleIteration.objects.filter(pk=iteration.pk).update(
760            opened_on=dt.datetime(2025, 5, 17, 12, 0, 0, tzinfo=dt.UTC),
761            state=ReviewState.PENDING,
762        )
763        with patch("django.utils.timezone.now", return_value=fixed_now):
764            self.assertNotIn(iteration, list(rule._get_newly_overdue_iterations()))
765
766    def test_due_object_iteration_opened_yesterday(self):
767        """interval=1 day: object with iteration opened yesterday is due for a new review."""
768        app, rule, iteration = self._create_rule_and_iteration(interval="days=1")
769        fixed_now = dt.datetime(2025, 6, 15, 14, 30, 0, tzinfo=dt.UTC)
770        for opened_on in [
771            dt.datetime(2025, 6, 14, 0, 0, 0, tzinfo=dt.UTC),
772            dt.datetime(2025, 6, 14, 12, 0, 0, tzinfo=dt.UTC),
773            dt.datetime(2025, 6, 14, 23, 59, 59, 999999, tzinfo=dt.UTC),
774        ]:
775            with self.subTest(opened_on=opened_on):
776                LifecycleIteration.objects.filter(pk=iteration.pk).update(opened_on=opened_on)
777                with patch("django.utils.timezone.now", return_value=fixed_now):
778                    self.assertIn(app, list(rule._get_newly_due_objects()))
779
780    def test_not_due_object_iteration_opened_today(self):
781        """interval=1 day: object with iteration opened today is NOT due."""
782        app, rule, iteration = self._create_rule_and_iteration(interval="days=1")
783        fixed_now = dt.datetime(2025, 6, 15, 14, 30, 0, tzinfo=dt.UTC)
784        for opened_on in [
785            dt.datetime(2025, 6, 15, 0, 0, 0, tzinfo=dt.UTC),
786            dt.datetime(2025, 6, 15, 14, 30, 0, tzinfo=dt.UTC),
787            dt.datetime(2025, 6, 15, 23, 59, 59, 999999, tzinfo=dt.UTC),
788        ]:
789            with self.subTest(opened_on=opened_on):
790                LifecycleIteration.objects.filter(pk=iteration.pk).update(opened_on=opened_on)
791                with patch("django.utils.timezone.now", return_value=fixed_now):
792                    self.assertNotIn(app, list(rule._get_newly_due_objects()))
793
794    def test_due_independent_of_task_execution_time(self):
795        """Due detection gives the same result whether the task runs at 00:00:01 or 23:59:59."""
796        app, rule, iteration = self._create_rule_and_iteration(interval="days=1")
797        opened_on = dt.datetime(2025, 6, 14, 18, 0, 0, tzinfo=dt.UTC)
798        LifecycleIteration.objects.filter(pk=iteration.pk).update(opened_on=opened_on)
799        for task_time in [
800            dt.datetime(2025, 6, 15, 0, 0, 1, tzinfo=dt.UTC),
801            dt.datetime(2025, 6, 15, 12, 0, 0, tzinfo=dt.UTC),
802            dt.datetime(2025, 6, 15, 23, 59, 59, tzinfo=dt.UTC),
803        ]:
804            with self.subTest(task_time=task_time):
805                with patch("django.utils.timezone.now", return_value=task_time):
806                    self.assertIn(app, list(rule._get_newly_due_objects()))
807
808    def test_due_boundary_multi_day_interval(self):
809        """interval=30 days: due after 30 full days, not after 29."""
810        app, rule, iteration = self._create_rule_and_iteration(interval="days=30")
811        fixed_now = dt.datetime(2025, 6, 15, 14, 30, 0, tzinfo=dt.UTC)
812
813        # Previous review opened 30 days ago (May 16), review is due for the object
814        LifecycleIteration.objects.filter(pk=iteration.pk).update(
815            opened_on=dt.datetime(2025, 5, 16, 12, 0, 0, tzinfo=dt.UTC)
816        )
817        with patch("django.utils.timezone.now", return_value=fixed_now):
818            self.assertIn(app, list(rule._get_newly_due_objects()))
819
820        # Previous review opened 29 days ago (May 17), new review is NOT due
821        LifecycleIteration.objects.filter(pk=iteration.pk).update(
822            opened_on=dt.datetime(2025, 5, 17, 12, 0, 0, tzinfo=dt.UTC)
823        )
824        with patch("django.utils.timezone.now", return_value=fixed_now):
825            self.assertNotIn(app, list(rule._get_newly_due_objects()))
826
827    def test_apply_overdue_at_boundary(self):
828        """apply() marks iteration overdue when grace period just expired,
829        regardless of what time the daily task runs."""
830        _, rule, iteration = self._create_rule_and_iteration(
831            grace_period="days=1", interval="days=365"
832        )
833        opened_on = dt.datetime(2025, 6, 14, 20, 0, 0, tzinfo=dt.UTC)
834        for task_time in [
835            dt.datetime(2025, 6, 15, 0, 0, 1, tzinfo=dt.UTC),
836            dt.datetime(2025, 6, 15, 23, 59, 59, tzinfo=dt.UTC),
837        ]:
838            with self.subTest(task_time=task_time):
839                LifecycleIteration.objects.filter(pk=iteration.pk).update(
840                    opened_on=opened_on, state=ReviewState.PENDING
841                )
842                with patch("django.utils.timezone.now", return_value=task_time):
843                    rule.apply()
844                iteration.refresh_from_db()
845                self.assertEqual(iteration.state, ReviewState.OVERDUE)
class TestLifecycleModels(django.test.testcases.TestCase):
 28class TestLifecycleModels(TestCase):
 29
 30    def setUp(self):
 31        self.factory = RequestFactory()
 32
 33    def _get_request(self):
 34        return self.factory.get("/")
 35
 36    def _create_object(self, model):
 37        if model is Application:
 38            return Application.objects.create(name=generate_id(), slug=generate_id())
 39        if model is Role:
 40            return Role.objects.create(name=generate_id())
 41        if model is Group:
 42            return Group.objects.create(name=generate_id())
 43        raise AssertionError(f"Unsupported model {model}")
 44
 45    def _create_rule_for_object(self, obj, **kwargs) -> LifecycleRule:
 46        content_type = ContentType.objects.get_for_model(obj)
 47        return LifecycleRule.objects.create(
 48            name=generate_id(),
 49            content_type=content_type,
 50            object_id=str(obj.pk),
 51            **kwargs,
 52        )
 53
 54    def _create_rule_for_type(self, model, **kwargs) -> LifecycleRule:
 55        content_type = ContentType.objects.get_for_model(model)
 56        return LifecycleRule.objects.create(
 57            name=generate_id(),
 58            content_type=content_type,
 59            object_id=None,
 60            **kwargs,
 61        )
 62
 63    def test_iteration_start_supported_objects(self):
 64        """Ensure iterations are automatically started for applications, roles, and groups."""
 65        for model in (Application, Role, Group):
 66            with self.subTest(model=model.__name__):
 67                obj = self._create_object(model)
 68                content_type = ContentType.objects.get_for_model(obj)
 69
 70                before_events = Event.objects.filter(action=EventAction.REVIEW_INITIATED).count()
 71
 72                rule = self._create_rule_for_object(obj)
 73
 74                # Verify iteration was created automatically
 75                iteration = LifecycleIteration.objects.get(
 76                    content_type=content_type, object_id=str(obj.pk), rule=rule
 77                )
 78                self.assertEqual(iteration.state, ReviewState.PENDING)
 79                self.assertEqual(iteration.object, obj)
 80                self.assertEqual(iteration.rule, rule)
 81                self.assertEqual(
 82                    Event.objects.filter(action=EventAction.REVIEW_INITIATED).count(),
 83                    before_events + 1,
 84                )
 85
 86    def test_review_requires_all_explicit_reviewers(self):
 87        obj = Group.objects.create(name=generate_id())
 88        rule = self._create_rule_for_object(obj)
 89        reviewer_one = create_test_user()
 90        reviewer_two = create_test_user()
 91        rule.reviewers.add(reviewer_one, reviewer_two)
 92
 93        content_type = ContentType.objects.get_for_model(obj)
 94
 95        iteration = LifecycleIteration.objects.get(
 96            content_type=content_type, object_id=str(obj.pk), rule=rule
 97        )
 98        request = self._get_request()
 99
100        Review.objects.create(iteration=iteration, reviewer=reviewer_one)
101        iteration.on_review(request)
102        iteration.refresh_from_db()
103        self.assertEqual(iteration.state, ReviewState.PENDING)
104
105        Review.objects.create(iteration=iteration, reviewer=reviewer_two)
106        iteration.on_review(request)
107        iteration.refresh_from_db()
108        self.assertEqual(iteration.state, ReviewState.REVIEWED)
109        self.assertTrue(Event.objects.filter(action=EventAction.REVIEW_COMPLETED).exists())
110
111    def test_review_min_reviewers_from_groups(self):
112        """Group-based reviews complete once the minimum number of reviewers review."""
113        obj = Application.objects.create(name=generate_id(), slug=generate_id())
114        rule = self._create_rule_for_object(obj, min_reviewers=2)
115
116        reviewer_group = Group.objects.create(name=generate_id())
117        reviewer_one = create_test_user()
118        reviewer_two = create_test_user()
119        reviewer_group.users.add(reviewer_one, reviewer_two)
120        rule.reviewer_groups.add(reviewer_group)
121
122        content_type = ContentType.objects.get_for_model(obj)
123
124        iteration = LifecycleIteration.objects.get(
125            content_type=content_type, object_id=str(obj.pk), rule=rule
126        )
127        request = self._get_request()
128
129        Review.objects.create(iteration=iteration, reviewer=reviewer_one)
130        iteration.on_review(request)
131        iteration.refresh_from_db()
132        self.assertEqual(iteration.state, ReviewState.PENDING)
133
134        Review.objects.create(iteration=iteration, reviewer=reviewer_two)
135        iteration.on_review(request)
136        iteration.refresh_from_db()
137        self.assertEqual(iteration.state, ReviewState.REVIEWED)
138
139    def test_review_explicit_and_group_reviewers(self):
140        """Reviews require both explicit reviewers AND min_reviewers from groups."""
141        obj = Application.objects.create(name=generate_id(), slug=generate_id())
142        rule = self._create_rule_for_object(obj, min_reviewers=1)
143
144        reviewer_group = Group.objects.create(name=generate_id())
145        group_member = create_test_user()
146        reviewer_group.users.add(group_member)
147        rule.reviewer_groups.add(reviewer_group)
148
149        explicit_reviewer = create_test_user()
150        rule.reviewers.add(explicit_reviewer)
151
152        content_type = ContentType.objects.get_for_model(obj)
153
154        iteration = LifecycleIteration.objects.get(
155            content_type=content_type, object_id=str(obj.pk), rule=rule
156        )
157        request = self._get_request()
158
159        # Only group member reviews - not satisfied (explicit reviewer missing)
160        Review.objects.create(iteration=iteration, reviewer=group_member)
161        iteration.on_review(request)
162        iteration.refresh_from_db()
163        self.assertEqual(iteration.state, ReviewState.PENDING)
164
165        # Explicit reviewer reviews - now satisfied
166        Review.objects.create(iteration=iteration, reviewer=explicit_reviewer)
167        iteration.on_review(request)
168        iteration.refresh_from_db()
169        self.assertEqual(iteration.state, ReviewState.REVIEWED)
170
171    def test_review_min_reviewers_per_group(self):
172        obj = Application.objects.create(name=generate_id(), slug=generate_id())
173        rule = self._create_rule_for_object(obj, min_reviewers=1, min_reviewers_is_per_group=True)
174
175        group_one = Group.objects.create(name=generate_id())
176        group_two = Group.objects.create(name=generate_id())
177        member_group_one = create_test_user()
178        member_group_two = create_test_user()
179        group_one.users.add(member_group_one)
180        group_two.users.add(member_group_two)
181        rule.reviewer_groups.add(group_one, group_two)
182
183        content_type = ContentType.objects.get_for_model(obj)
184
185        iteration = LifecycleIteration.objects.get(
186            content_type=content_type, object_id=str(obj.pk), rule=rule
187        )
188        request = self._get_request()
189
190        # Only member from group_one reviews - not satisfied (need member from each group)
191        Review.objects.create(iteration=iteration, reviewer=member_group_one)
192        iteration.on_review(request)
193        iteration.refresh_from_db()
194        self.assertEqual(iteration.state, ReviewState.PENDING)
195
196        # Member from group_two reviews - now satisfied
197        Review.objects.create(iteration=iteration, reviewer=member_group_two)
198        iteration.on_review(request)
199        iteration.refresh_from_db()
200        self.assertEqual(iteration.state, ReviewState.REVIEWED)
201
202    def test_review_reviewers_from_child_groups(self):
203        obj = Application.objects.create(name=generate_id(), slug=generate_id())
204        rule = self._create_rule_for_object(obj, min_reviewers=1)
205
206        parent_group = Group.objects.create(name=generate_id())
207        child_group = Group.objects.create(name=generate_id())
208        child_group.parents.add(parent_group)
209
210        child_member = create_test_user()
211        child_group.users.add(child_member)
212
213        rule.reviewer_groups.add(parent_group)
214
215        content_type = ContentType.objects.get_for_model(obj)
216
217        iteration = LifecycleIteration.objects.get(
218            content_type=content_type, object_id=str(obj.pk), rule=rule
219        )
220        request = self._get_request()
221
222        # Child group member should be able to review
223        self.assertTrue(iteration.user_can_review(child_member))
224
225        Review.objects.create(iteration=iteration, reviewer=child_member)
226        iteration.on_review(request)
227        iteration.refresh_from_db()
228        self.assertEqual(iteration.state, ReviewState.REVIEWED)
229
230    def test_review_reviewers_from_nested_child_groups(self):
231        obj = Application.objects.create(name=generate_id(), slug=generate_id())
232        rule = self._create_rule_for_object(obj, min_reviewers=2)
233
234        grandparent = Group.objects.create(name=generate_id())
235        parent = Group.objects.create(name=generate_id())
236        child = Group.objects.create(name=generate_id())
237        parent.parents.add(grandparent)
238        child.parents.add(parent)
239
240        parent_member = create_test_user()
241        child_member = create_test_user()
242        parent.users.add(parent_member)
243        child.users.add(child_member)
244
245        rule.reviewer_groups.add(grandparent)
246
247        content_type = ContentType.objects.get_for_model(obj)
248
249        iteration = LifecycleIteration.objects.get(
250            content_type=content_type, object_id=str(obj.pk), rule=rule
251        )
252        request = self._get_request()
253
254        # Both nested members should be able to review
255        self.assertTrue(iteration.user_can_review(parent_member))
256        self.assertTrue(iteration.user_can_review(child_member))
257
258        Review.objects.create(iteration=iteration, reviewer=parent_member)
259        iteration.on_review(request)
260        iteration.refresh_from_db()
261        self.assertEqual(iteration.state, ReviewState.PENDING)
262
263        Review.objects.create(iteration=iteration, reviewer=child_member)
264        iteration.on_review(request)
265        iteration.refresh_from_db()
266        self.assertEqual(iteration.state, ReviewState.REVIEWED)
267
268    def test_notify_reviewers_send_once(self):
269        obj = Group.objects.create(name=generate_id())
270        rule = self._create_rule_for_object(obj)
271
272        reviewer_one = create_test_user()
273        reviewer_two = create_test_user()
274        rule.reviewers.add(reviewer_one, reviewer_two)
275
276        transport_once = NotificationTransport.objects.create(
277            name=generate_id(),
278            send_once=True,
279        )
280        transport_all = NotificationTransport.objects.create(
281            name=generate_id(),
282            send_once=False,
283        )
284        rule.notification_transports.add(transport_once, transport_all)
285
286        event = Event.new(EventAction.REVIEW_INITIATED, target=obj)
287        event.save()
288
289        with patch(
290            "authentik.enterprise.lifecycle.tasks.send_notification.send_with_options"
291        ) as send_with_options:
292            rule.notify_reviewers(event, NotificationSeverity.NOTICE)
293
294            reviewer_pks = {reviewer_one.pk, reviewer_two.pk}
295            self.assertEqual(send_with_options.call_count, len(reviewer_pks) + 1)
296
297            calls = [call.kwargs["args"] for call in send_with_options.call_args_list]
298            once_calls = [args for args in calls if args[0] == transport_once.pk]
299            all_calls = [args for args in calls if args[0] == transport_all.pk]
300
301            self.assertEqual(len(once_calls), 1)
302            self.assertEqual(len(all_calls), len(reviewer_pks))
303            self.assertIn(once_calls[0][2], reviewer_pks)
304            self.assertEqual({args[2] for args in all_calls}, reviewer_pks)
305
306    def test_apply_marks_overdue_and_opens_due_reviews(self):
307        app_one = Application.objects.create(name=generate_id(), slug=generate_id())
308        app_two = Application.objects.create(name=generate_id(), slug=generate_id())
309        content_type = ContentType.objects.get_for_model(Application)
310
311        rule_overdue = LifecycleRule.objects.create(
312            name=generate_id(),
313            content_type=content_type,
314            object_id=str(app_one.pk),
315            interval="days=365",
316            grace_period="days=10",
317        )
318
319        # Get the automatically created iteration and backdate it past the grace period
320        iteration = LifecycleIteration.objects.get(
321            content_type=content_type, object_id=str(app_one.pk), rule=rule_overdue
322        )
323        LifecycleIteration.objects.filter(pk=iteration.pk).update(
324            opened_on=(timezone.now() - timedelta(days=20))
325        )
326
327        # Apply again to trigger overdue logic
328        rule_overdue.apply()
329        iteration.refresh_from_db()
330        self.assertEqual(iteration.state, ReviewState.OVERDUE)
331        self.assertEqual(
332            LifecycleIteration.objects.filter(
333                content_type=content_type, object_id=str(app_one.pk)
334            ).count(),
335            1,
336        )
337
338        LifecycleRule.objects.create(
339            name=generate_id(),
340            content_type=content_type,
341            object_id=str(app_two.pk),
342            interval="days=30",
343            grace_period="days=10",
344        )
345        self.assertEqual(
346            LifecycleIteration.objects.filter(
347                content_type=content_type, object_id=str(app_two.pk)
348            ).count(),
349            1,
350        )
351        new_iteration = LifecycleIteration.objects.get(
352            content_type=content_type, object_id=str(app_two.pk)
353        )
354        self.assertEqual(new_iteration.state, ReviewState.PENDING)
355
356    def test_apply_idempotent(self):
357        app_due = Application.objects.create(name=generate_id(), slug=generate_id())
358        app_overdue = Application.objects.create(name=generate_id(), slug=generate_id())
359        content_type = ContentType.objects.get_for_model(Application)
360
361        initiated_before = Event.objects.filter(action=EventAction.REVIEW_INITIATED).count()
362        overdue_before = Event.objects.filter(action=EventAction.REVIEW_OVERDUE).count()
363
364        rule_due = LifecycleRule.objects.create(
365            name=generate_id(),
366            content_type=content_type,
367            object_id=str(app_due.pk),
368            interval="days=30",
369            grace_period="days=30",
370        )
371        reviewer = create_test_user()
372        rule_due.reviewers.add(reviewer)
373        transport = NotificationTransport.objects.create(name=generate_id())
374        rule_due.notification_transports.add(transport)
375
376        rule_overdue = LifecycleRule.objects.create(
377            name=generate_id(),
378            content_type=content_type,
379            object_id=str(app_overdue.pk),
380            interval="days=365",
381            grace_period="days=10",
382        )
383
384        overdue_iteration = LifecycleIteration.objects.get(
385            content_type=content_type, object_id=str(app_overdue.pk), rule=rule_overdue
386        )
387        LifecycleIteration.objects.filter(pk=overdue_iteration.pk).update(
388            opened_on=(timezone.now() - timedelta(days=20))
389        )
390
391        # Apply overdue rule to mark iteration as overdue
392        rule_overdue.apply()
393
394        due_iteration = LifecycleIteration.objects.get(
395            content_type=content_type, object_id=str(app_due.pk)
396        )
397        overdue_iteration.refresh_from_db()
398        self.assertEqual(due_iteration.state, ReviewState.PENDING)
399        self.assertEqual(overdue_iteration.state, ReviewState.OVERDUE)
400
401        initiated_after_first = Event.objects.filter(action=EventAction.REVIEW_INITIATED).count()
402        overdue_after_first = Event.objects.filter(action=EventAction.REVIEW_OVERDUE).count()
403        # Both rules created iterations on save
404        self.assertEqual(initiated_after_first, initiated_before + 2)
405        self.assertEqual(overdue_after_first, overdue_before + 1)
406
407        # Apply again - should be idempotent
408        rule_due.apply()
409        rule_overdue.apply()
410
411        due_iteration.refresh_from_db()
412        overdue_iteration.refresh_from_db()
413        self.assertEqual(due_iteration.state, ReviewState.PENDING)
414        self.assertEqual(overdue_iteration.state, ReviewState.OVERDUE)
415        self.assertEqual(
416            Event.objects.filter(action=EventAction.REVIEW_INITIATED).count(),
417            initiated_after_first,
418        )
419        self.assertEqual(
420            Event.objects.filter(action=EventAction.REVIEW_OVERDUE).count(),
421            overdue_after_first,
422        )
423
424    def test_rule_matches_entire_type(self):
425        """A rule with object_id=None matches all objects of that type."""
426        app_one = Application.objects.create(name=generate_id(), slug=generate_id())
427        app_two = Application.objects.create(name=generate_id(), slug=generate_id())
428        content_type = ContentType.objects.get_for_model(Application)
429
430        rule = LifecycleRule.objects.create(
431            name=generate_id(),
432            content_type=content_type,
433            object_id=None,
434            interval="days=30",
435            grace_period="days=10",
436        )
437
438        objects = list(rule.get_objects())
439        self.assertIn(app_one, objects)
440        self.assertIn(app_two, objects)
441
442    def test_rule_type_excludes_objects_with_specific_rules(self):
443        app_with_rule = Application.objects.create(name=generate_id(), slug=generate_id())
444        app_without_rule = Application.objects.create(name=generate_id(), slug=generate_id())
445        content_type = ContentType.objects.get_for_model(Application)
446
447        # Create a specific rule for app_with_rule
448        LifecycleRule.objects.create(
449            name=generate_id(),
450            content_type=content_type,
451            object_id=str(app_with_rule.pk),
452            interval="days=30",
453        )
454
455        # Create a type-level rule
456        type_rule = LifecycleRule.objects.create(
457            name=generate_id(),
458            content_type=content_type,
459            object_id=None,
460            interval="days=60",
461        )
462
463        objects = list(type_rule.get_objects())
464        self.assertNotIn(app_with_rule, objects)
465        self.assertIn(app_without_rule, objects)
466
467    def test_rule_type_apply_creates_iterations_for_all_objects(self):
468        app_one = Application.objects.create(name=generate_id(), slug=generate_id())
469        app_two = Application.objects.create(name=generate_id(), slug=generate_id())
470        content_type = ContentType.objects.get_for_model(Application)
471
472        LifecycleRule.objects.create(
473            name=generate_id(),
474            content_type=content_type,
475            object_id=None,
476            interval="days=30",
477            grace_period="days=10",
478        )
479
480        self.assertTrue(
481            LifecycleIteration.objects.filter(
482                content_type=content_type, object_id=str(app_one.pk)
483            ).exists()
484        )
485        self.assertTrue(
486            LifecycleIteration.objects.filter(
487                content_type=content_type, object_id=str(app_two.pk)
488            ).exists()
489        )
490
491    def test_delete_rule_cancels_open_iterations(self):
492        obj = Application.objects.create(name=generate_id(), slug=generate_id())
493
494        rule = self._create_rule_for_object(obj)
495        content_type = ContentType.objects.get_for_model(obj)
496
497        pending_iteration = LifecycleIteration.objects.get(
498            content_type=content_type, object_id=str(obj.pk), rule=rule
499        )
500        self.assertEqual(pending_iteration.state, ReviewState.PENDING)
501
502        overdue_iteration = LifecycleIteration.objects.create(
503            content_type=content_type,
504            object_id=str(obj.pk),
505            rule=rule,
506            state=ReviewState.OVERDUE,
507        )
508        reviewed_iteration = LifecycleIteration.objects.create(
509            content_type=content_type,
510            object_id=str(obj.pk),
511            rule=rule,
512            state=ReviewState.REVIEWED,
513        )
514
515        rule.delete()
516
517        pending_iteration.refresh_from_db()
518        overdue_iteration.refresh_from_db()
519        reviewed_iteration.refresh_from_db()
520
521        self.assertEqual(pending_iteration.state, ReviewState.CANCELED)
522        self.assertEqual(overdue_iteration.state, ReviewState.CANCELED)
523        self.assertEqual(reviewed_iteration.state, ReviewState.REVIEWED)  # Not affected
524
525    def test_update_rule_target_cancels_stale_iterations(self):
526        app_one = Application.objects.create(name=generate_id(), slug=generate_id())
527        app_two = Application.objects.create(name=generate_id(), slug=generate_id())
528        content_type = ContentType.objects.get_for_model(Application)
529
530        rule = LifecycleRule.objects.create(
531            name=generate_id(),
532            content_type=content_type,
533            object_id=str(app_one.pk),
534            interval="days=30",
535        )
536
537        iteration_for_app_one = LifecycleIteration.objects.get(
538            content_type=content_type, object_id=str(app_one.pk), rule=rule
539        )
540        self.assertEqual(iteration_for_app_one.state, ReviewState.PENDING)
541
542        # Change rule target to app_two - save() triggers apply() which cancels stale iterations
543        rule.object_id = str(app_two.pk)
544        rule.save()
545
546        iteration_for_app_one.refresh_from_db()
547        self.assertEqual(iteration_for_app_one.state, ReviewState.CANCELED)
548
549    def test_update_rule_content_type_cancels_stale_iterations(self):
550        app = Application.objects.create(name=generate_id(), slug=generate_id())
551        group = Group.objects.create(name=generate_id())
552        app_content_type = ContentType.objects.get_for_model(Application)
553        group_content_type = ContentType.objects.get_for_model(Group)
554
555        # Creating rule triggers automatic apply() which creates a iteration for app
556        rule = LifecycleRule.objects.create(
557            name=generate_id(),
558            content_type=app_content_type,
559            object_id=str(app.pk),
560            interval="days=30",
561        )
562
563        iteration = LifecycleIteration.objects.get(
564            content_type=app_content_type, object_id=str(app.pk), rule=rule
565        )
566        self.assertEqual(iteration.state, ReviewState.PENDING)
567
568        # Change content type to Group - save() triggers apply() which cancels stale iterations
569        rule.content_type = group_content_type
570        rule.object_id = str(group.pk)
571        rule.save()
572
573        iteration.refresh_from_db()
574        self.assertEqual(iteration.state, ReviewState.CANCELED)
575
576    def test_user_can_review_checks_group_hierarchy(self):
577        obj = Application.objects.create(name=generate_id(), slug=generate_id())
578        rule = self._create_rule_for_object(obj)
579
580        parent_group = Group.objects.create(name=generate_id())
581        child_group = Group.objects.create(name=generate_id())
582        child_group.parents.add(parent_group)
583
584        parent_member = create_test_user()
585        child_member = create_test_user()
586        non_member = create_test_user()
587        parent_group.users.add(parent_member)
588        child_group.users.add(child_member)
589
590        rule.reviewer_groups.add(parent_group)
591
592        content_type = ContentType.objects.get_for_model(obj)
593        # iteration is created automatically when rule is saved
594        iteration = LifecycleIteration.objects.get(
595            content_type=content_type, object_id=str(obj.pk), rule=rule
596        )
597
598        self.assertTrue(iteration.user_can_review(parent_member))
599        self.assertTrue(iteration.user_can_review(child_member))
600        self.assertFalse(iteration.user_can_review(non_member))
601
602    def test_user_cannot_review_twice(self):
603        obj = Application.objects.create(name=generate_id(), slug=generate_id())
604        rule = self._create_rule_for_object(obj)
605        reviewer = create_test_user()
606        rule.reviewers.add(reviewer)
607
608        content_type = ContentType.objects.get_for_model(obj)
609        # iteration is created automatically when rule is saved
610        iteration = LifecycleIteration.objects.get(
611            content_type=content_type, object_id=str(obj.pk), rule=rule
612        )
613
614        self.assertTrue(iteration.user_can_review(reviewer))
615
616        Review.objects.create(iteration=iteration, reviewer=reviewer)
617
618        self.assertFalse(iteration.user_can_review(reviewer))
619
620    def test_user_cannot_review_completed_iteration(self):
621        obj = Application.objects.create(name=generate_id(), slug=generate_id())
622        rule = self._create_rule_for_object(obj)
623        reviewer = create_test_user()
624        rule.reviewers.add(reviewer)
625
626        content_type = ContentType.objects.get_for_model(obj)
627
628        # Get the automatically created pending iteration and test with different states
629        iteration = LifecycleIteration.objects.get(
630            content_type=content_type, object_id=str(obj.pk), rule=rule
631        )
632
633        for state in (ReviewState.REVIEWED, ReviewState.CANCELED):
634            iteration.state = state
635            iteration.save()
636            self.assertFalse(iteration.user_can_review(reviewer))
637
638    def test_get_reviewers_includes_child_group_members(self):
639        obj = Application.objects.create(name=generate_id(), slug=generate_id())
640        rule = self._create_rule_for_object(obj)
641
642        parent_group = Group.objects.create(name=generate_id())
643        child_group = Group.objects.create(name=generate_id())
644        child_group.parents.add(parent_group)
645
646        parent_member = create_test_user()
647        child_member = create_test_user()
648        parent_group.users.add(parent_member)
649        child_group.users.add(child_member)
650
651        rule.reviewer_groups.add(parent_group)
652
653        reviewers = list(rule.get_reviewers())
654        self.assertIn(parent_member, reviewers)
655        self.assertIn(child_member, reviewers)
656
657    def test_get_reviewers_includes_explicit_reviewers(self):
658        obj = Application.objects.create(name=generate_id(), slug=generate_id())
659        rule = self._create_rule_for_object(obj)
660
661        explicit_reviewer = create_test_user()
662        rule.reviewers.add(explicit_reviewer)
663
664        group = Group.objects.create(name=generate_id())
665        group_member = create_test_user()
666        group.users.add(group_member)
667        rule.reviewer_groups.add(group)
668
669        reviewers = list(rule.get_reviewers())
670        self.assertIn(explicit_reviewer, reviewers)
671        self.assertIn(group_member, reviewers)

Similar to TransactionTestCase, but use transaction.atomic() to achieve test isolation.

In most situations, TestCase should be preferred to TransactionTestCase as it allows faster execution. However, there are some situations where using TransactionTestCase might be necessary (e.g. testing some transactional behavior).

On database backends with no transaction support, TestCase behaves as TransactionTestCase.

def setUp(self):
30    def setUp(self):
31        self.factory = RequestFactory()

Hook method for setting up the test fixture before exercising it.

def test_iteration_start_supported_objects(self):
63    def test_iteration_start_supported_objects(self):
64        """Ensure iterations are automatically started for applications, roles, and groups."""
65        for model in (Application, Role, Group):
66            with self.subTest(model=model.__name__):
67                obj = self._create_object(model)
68                content_type = ContentType.objects.get_for_model(obj)
69
70                before_events = Event.objects.filter(action=EventAction.REVIEW_INITIATED).count()
71
72                rule = self._create_rule_for_object(obj)
73
74                # Verify iteration was created automatically
75                iteration = LifecycleIteration.objects.get(
76                    content_type=content_type, object_id=str(obj.pk), rule=rule
77                )
78                self.assertEqual(iteration.state, ReviewState.PENDING)
79                self.assertEqual(iteration.object, obj)
80                self.assertEqual(iteration.rule, rule)
81                self.assertEqual(
82                    Event.objects.filter(action=EventAction.REVIEW_INITIATED).count(),
83                    before_events + 1,
84                )

Ensure iterations are automatically started for applications, roles, and groups.

def test_review_requires_all_explicit_reviewers(self):
 86    def test_review_requires_all_explicit_reviewers(self):
 87        obj = Group.objects.create(name=generate_id())
 88        rule = self._create_rule_for_object(obj)
 89        reviewer_one = create_test_user()
 90        reviewer_two = create_test_user()
 91        rule.reviewers.add(reviewer_one, reviewer_two)
 92
 93        content_type = ContentType.objects.get_for_model(obj)
 94
 95        iteration = LifecycleIteration.objects.get(
 96            content_type=content_type, object_id=str(obj.pk), rule=rule
 97        )
 98        request = self._get_request()
 99
100        Review.objects.create(iteration=iteration, reviewer=reviewer_one)
101        iteration.on_review(request)
102        iteration.refresh_from_db()
103        self.assertEqual(iteration.state, ReviewState.PENDING)
104
105        Review.objects.create(iteration=iteration, reviewer=reviewer_two)
106        iteration.on_review(request)
107        iteration.refresh_from_db()
108        self.assertEqual(iteration.state, ReviewState.REVIEWED)
109        self.assertTrue(Event.objects.filter(action=EventAction.REVIEW_COMPLETED).exists())
def test_review_min_reviewers_from_groups(self):
111    def test_review_min_reviewers_from_groups(self):
112        """Group-based reviews complete once the minimum number of reviewers review."""
113        obj = Application.objects.create(name=generate_id(), slug=generate_id())
114        rule = self._create_rule_for_object(obj, min_reviewers=2)
115
116        reviewer_group = Group.objects.create(name=generate_id())
117        reviewer_one = create_test_user()
118        reviewer_two = create_test_user()
119        reviewer_group.users.add(reviewer_one, reviewer_two)
120        rule.reviewer_groups.add(reviewer_group)
121
122        content_type = ContentType.objects.get_for_model(obj)
123
124        iteration = LifecycleIteration.objects.get(
125            content_type=content_type, object_id=str(obj.pk), rule=rule
126        )
127        request = self._get_request()
128
129        Review.objects.create(iteration=iteration, reviewer=reviewer_one)
130        iteration.on_review(request)
131        iteration.refresh_from_db()
132        self.assertEqual(iteration.state, ReviewState.PENDING)
133
134        Review.objects.create(iteration=iteration, reviewer=reviewer_two)
135        iteration.on_review(request)
136        iteration.refresh_from_db()
137        self.assertEqual(iteration.state, ReviewState.REVIEWED)

Group-based reviews complete once the minimum number of reviewers review.

def test_review_explicit_and_group_reviewers(self):
139    def test_review_explicit_and_group_reviewers(self):
140        """Reviews require both explicit reviewers AND min_reviewers from groups."""
141        obj = Application.objects.create(name=generate_id(), slug=generate_id())
142        rule = self._create_rule_for_object(obj, min_reviewers=1)
143
144        reviewer_group = Group.objects.create(name=generate_id())
145        group_member = create_test_user()
146        reviewer_group.users.add(group_member)
147        rule.reviewer_groups.add(reviewer_group)
148
149        explicit_reviewer = create_test_user()
150        rule.reviewers.add(explicit_reviewer)
151
152        content_type = ContentType.objects.get_for_model(obj)
153
154        iteration = LifecycleIteration.objects.get(
155            content_type=content_type, object_id=str(obj.pk), rule=rule
156        )
157        request = self._get_request()
158
159        # Only group member reviews - not satisfied (explicit reviewer missing)
160        Review.objects.create(iteration=iteration, reviewer=group_member)
161        iteration.on_review(request)
162        iteration.refresh_from_db()
163        self.assertEqual(iteration.state, ReviewState.PENDING)
164
165        # Explicit reviewer reviews - now satisfied
166        Review.objects.create(iteration=iteration, reviewer=explicit_reviewer)
167        iteration.on_review(request)
168        iteration.refresh_from_db()
169        self.assertEqual(iteration.state, ReviewState.REVIEWED)

Reviews require both explicit reviewers AND min_reviewers from groups.

def test_review_min_reviewers_per_group(self):
171    def test_review_min_reviewers_per_group(self):
172        obj = Application.objects.create(name=generate_id(), slug=generate_id())
173        rule = self._create_rule_for_object(obj, min_reviewers=1, min_reviewers_is_per_group=True)
174
175        group_one = Group.objects.create(name=generate_id())
176        group_two = Group.objects.create(name=generate_id())
177        member_group_one = create_test_user()
178        member_group_two = create_test_user()
179        group_one.users.add(member_group_one)
180        group_two.users.add(member_group_two)
181        rule.reviewer_groups.add(group_one, group_two)
182
183        content_type = ContentType.objects.get_for_model(obj)
184
185        iteration = LifecycleIteration.objects.get(
186            content_type=content_type, object_id=str(obj.pk), rule=rule
187        )
188        request = self._get_request()
189
190        # Only member from group_one reviews - not satisfied (need member from each group)
191        Review.objects.create(iteration=iteration, reviewer=member_group_one)
192        iteration.on_review(request)
193        iteration.refresh_from_db()
194        self.assertEqual(iteration.state, ReviewState.PENDING)
195
196        # Member from group_two reviews - now satisfied
197        Review.objects.create(iteration=iteration, reviewer=member_group_two)
198        iteration.on_review(request)
199        iteration.refresh_from_db()
200        self.assertEqual(iteration.state, ReviewState.REVIEWED)
def test_review_reviewers_from_child_groups(self):
202    def test_review_reviewers_from_child_groups(self):
203        obj = Application.objects.create(name=generate_id(), slug=generate_id())
204        rule = self._create_rule_for_object(obj, min_reviewers=1)
205
206        parent_group = Group.objects.create(name=generate_id())
207        child_group = Group.objects.create(name=generate_id())
208        child_group.parents.add(parent_group)
209
210        child_member = create_test_user()
211        child_group.users.add(child_member)
212
213        rule.reviewer_groups.add(parent_group)
214
215        content_type = ContentType.objects.get_for_model(obj)
216
217        iteration = LifecycleIteration.objects.get(
218            content_type=content_type, object_id=str(obj.pk), rule=rule
219        )
220        request = self._get_request()
221
222        # Child group member should be able to review
223        self.assertTrue(iteration.user_can_review(child_member))
224
225        Review.objects.create(iteration=iteration, reviewer=child_member)
226        iteration.on_review(request)
227        iteration.refresh_from_db()
228        self.assertEqual(iteration.state, ReviewState.REVIEWED)
def test_review_reviewers_from_nested_child_groups(self):
230    def test_review_reviewers_from_nested_child_groups(self):
231        obj = Application.objects.create(name=generate_id(), slug=generate_id())
232        rule = self._create_rule_for_object(obj, min_reviewers=2)
233
234        grandparent = Group.objects.create(name=generate_id())
235        parent = Group.objects.create(name=generate_id())
236        child = Group.objects.create(name=generate_id())
237        parent.parents.add(grandparent)
238        child.parents.add(parent)
239
240        parent_member = create_test_user()
241        child_member = create_test_user()
242        parent.users.add(parent_member)
243        child.users.add(child_member)
244
245        rule.reviewer_groups.add(grandparent)
246
247        content_type = ContentType.objects.get_for_model(obj)
248
249        iteration = LifecycleIteration.objects.get(
250            content_type=content_type, object_id=str(obj.pk), rule=rule
251        )
252        request = self._get_request()
253
254        # Both nested members should be able to review
255        self.assertTrue(iteration.user_can_review(parent_member))
256        self.assertTrue(iteration.user_can_review(child_member))
257
258        Review.objects.create(iteration=iteration, reviewer=parent_member)
259        iteration.on_review(request)
260        iteration.refresh_from_db()
261        self.assertEqual(iteration.state, ReviewState.PENDING)
262
263        Review.objects.create(iteration=iteration, reviewer=child_member)
264        iteration.on_review(request)
265        iteration.refresh_from_db()
266        self.assertEqual(iteration.state, ReviewState.REVIEWED)
def test_notify_reviewers_send_once(self):
268    def test_notify_reviewers_send_once(self):
269        obj = Group.objects.create(name=generate_id())
270        rule = self._create_rule_for_object(obj)
271
272        reviewer_one = create_test_user()
273        reviewer_two = create_test_user()
274        rule.reviewers.add(reviewer_one, reviewer_two)
275
276        transport_once = NotificationTransport.objects.create(
277            name=generate_id(),
278            send_once=True,
279        )
280        transport_all = NotificationTransport.objects.create(
281            name=generate_id(),
282            send_once=False,
283        )
284        rule.notification_transports.add(transport_once, transport_all)
285
286        event = Event.new(EventAction.REVIEW_INITIATED, target=obj)
287        event.save()
288
289        with patch(
290            "authentik.enterprise.lifecycle.tasks.send_notification.send_with_options"
291        ) as send_with_options:
292            rule.notify_reviewers(event, NotificationSeverity.NOTICE)
293
294            reviewer_pks = {reviewer_one.pk, reviewer_two.pk}
295            self.assertEqual(send_with_options.call_count, len(reviewer_pks) + 1)
296
297            calls = [call.kwargs["args"] for call in send_with_options.call_args_list]
298            once_calls = [args for args in calls if args[0] == transport_once.pk]
299            all_calls = [args for args in calls if args[0] == transport_all.pk]
300
301            self.assertEqual(len(once_calls), 1)
302            self.assertEqual(len(all_calls), len(reviewer_pks))
303            self.assertIn(once_calls[0][2], reviewer_pks)
304            self.assertEqual({args[2] for args in all_calls}, reviewer_pks)
def test_apply_marks_overdue_and_opens_due_reviews(self):
306    def test_apply_marks_overdue_and_opens_due_reviews(self):
307        app_one = Application.objects.create(name=generate_id(), slug=generate_id())
308        app_two = Application.objects.create(name=generate_id(), slug=generate_id())
309        content_type = ContentType.objects.get_for_model(Application)
310
311        rule_overdue = LifecycleRule.objects.create(
312            name=generate_id(),
313            content_type=content_type,
314            object_id=str(app_one.pk),
315            interval="days=365",
316            grace_period="days=10",
317        )
318
319        # Get the automatically created iteration and backdate it past the grace period
320        iteration = LifecycleIteration.objects.get(
321            content_type=content_type, object_id=str(app_one.pk), rule=rule_overdue
322        )
323        LifecycleIteration.objects.filter(pk=iteration.pk).update(
324            opened_on=(timezone.now() - timedelta(days=20))
325        )
326
327        # Apply again to trigger overdue logic
328        rule_overdue.apply()
329        iteration.refresh_from_db()
330        self.assertEqual(iteration.state, ReviewState.OVERDUE)
331        self.assertEqual(
332            LifecycleIteration.objects.filter(
333                content_type=content_type, object_id=str(app_one.pk)
334            ).count(),
335            1,
336        )
337
338        LifecycleRule.objects.create(
339            name=generate_id(),
340            content_type=content_type,
341            object_id=str(app_two.pk),
342            interval="days=30",
343            grace_period="days=10",
344        )
345        self.assertEqual(
346            LifecycleIteration.objects.filter(
347                content_type=content_type, object_id=str(app_two.pk)
348            ).count(),
349            1,
350        )
351        new_iteration = LifecycleIteration.objects.get(
352            content_type=content_type, object_id=str(app_two.pk)
353        )
354        self.assertEqual(new_iteration.state, ReviewState.PENDING)
def test_apply_idempotent(self):
356    def test_apply_idempotent(self):
357        app_due = Application.objects.create(name=generate_id(), slug=generate_id())
358        app_overdue = Application.objects.create(name=generate_id(), slug=generate_id())
359        content_type = ContentType.objects.get_for_model(Application)
360
361        initiated_before = Event.objects.filter(action=EventAction.REVIEW_INITIATED).count()
362        overdue_before = Event.objects.filter(action=EventAction.REVIEW_OVERDUE).count()
363
364        rule_due = LifecycleRule.objects.create(
365            name=generate_id(),
366            content_type=content_type,
367            object_id=str(app_due.pk),
368            interval="days=30",
369            grace_period="days=30",
370        )
371        reviewer = create_test_user()
372        rule_due.reviewers.add(reviewer)
373        transport = NotificationTransport.objects.create(name=generate_id())
374        rule_due.notification_transports.add(transport)
375
376        rule_overdue = LifecycleRule.objects.create(
377            name=generate_id(),
378            content_type=content_type,
379            object_id=str(app_overdue.pk),
380            interval="days=365",
381            grace_period="days=10",
382        )
383
384        overdue_iteration = LifecycleIteration.objects.get(
385            content_type=content_type, object_id=str(app_overdue.pk), rule=rule_overdue
386        )
387        LifecycleIteration.objects.filter(pk=overdue_iteration.pk).update(
388            opened_on=(timezone.now() - timedelta(days=20))
389        )
390
391        # Apply overdue rule to mark iteration as overdue
392        rule_overdue.apply()
393
394        due_iteration = LifecycleIteration.objects.get(
395            content_type=content_type, object_id=str(app_due.pk)
396        )
397        overdue_iteration.refresh_from_db()
398        self.assertEqual(due_iteration.state, ReviewState.PENDING)
399        self.assertEqual(overdue_iteration.state, ReviewState.OVERDUE)
400
401        initiated_after_first = Event.objects.filter(action=EventAction.REVIEW_INITIATED).count()
402        overdue_after_first = Event.objects.filter(action=EventAction.REVIEW_OVERDUE).count()
403        # Both rules created iterations on save
404        self.assertEqual(initiated_after_first, initiated_before + 2)
405        self.assertEqual(overdue_after_first, overdue_before + 1)
406
407        # Apply again - should be idempotent
408        rule_due.apply()
409        rule_overdue.apply()
410
411        due_iteration.refresh_from_db()
412        overdue_iteration.refresh_from_db()
413        self.assertEqual(due_iteration.state, ReviewState.PENDING)
414        self.assertEqual(overdue_iteration.state, ReviewState.OVERDUE)
415        self.assertEqual(
416            Event.objects.filter(action=EventAction.REVIEW_INITIATED).count(),
417            initiated_after_first,
418        )
419        self.assertEqual(
420            Event.objects.filter(action=EventAction.REVIEW_OVERDUE).count(),
421            overdue_after_first,
422        )
def test_rule_matches_entire_type(self):
424    def test_rule_matches_entire_type(self):
425        """A rule with object_id=None matches all objects of that type."""
426        app_one = Application.objects.create(name=generate_id(), slug=generate_id())
427        app_two = Application.objects.create(name=generate_id(), slug=generate_id())
428        content_type = ContentType.objects.get_for_model(Application)
429
430        rule = LifecycleRule.objects.create(
431            name=generate_id(),
432            content_type=content_type,
433            object_id=None,
434            interval="days=30",
435            grace_period="days=10",
436        )
437
438        objects = list(rule.get_objects())
439        self.assertIn(app_one, objects)
440        self.assertIn(app_two, objects)

A rule with object_id=None matches all objects of that type.

def test_rule_type_excludes_objects_with_specific_rules(self):
442    def test_rule_type_excludes_objects_with_specific_rules(self):
443        app_with_rule = Application.objects.create(name=generate_id(), slug=generate_id())
444        app_without_rule = Application.objects.create(name=generate_id(), slug=generate_id())
445        content_type = ContentType.objects.get_for_model(Application)
446
447        # Create a specific rule for app_with_rule
448        LifecycleRule.objects.create(
449            name=generate_id(),
450            content_type=content_type,
451            object_id=str(app_with_rule.pk),
452            interval="days=30",
453        )
454
455        # Create a type-level rule
456        type_rule = LifecycleRule.objects.create(
457            name=generate_id(),
458            content_type=content_type,
459            object_id=None,
460            interval="days=60",
461        )
462
463        objects = list(type_rule.get_objects())
464        self.assertNotIn(app_with_rule, objects)
465        self.assertIn(app_without_rule, objects)
def test_rule_type_apply_creates_iterations_for_all_objects(self):
467    def test_rule_type_apply_creates_iterations_for_all_objects(self):
468        app_one = Application.objects.create(name=generate_id(), slug=generate_id())
469        app_two = Application.objects.create(name=generate_id(), slug=generate_id())
470        content_type = ContentType.objects.get_for_model(Application)
471
472        LifecycleRule.objects.create(
473            name=generate_id(),
474            content_type=content_type,
475            object_id=None,
476            interval="days=30",
477            grace_period="days=10",
478        )
479
480        self.assertTrue(
481            LifecycleIteration.objects.filter(
482                content_type=content_type, object_id=str(app_one.pk)
483            ).exists()
484        )
485        self.assertTrue(
486            LifecycleIteration.objects.filter(
487                content_type=content_type, object_id=str(app_two.pk)
488            ).exists()
489        )
def test_delete_rule_cancels_open_iterations(self):
491    def test_delete_rule_cancels_open_iterations(self):
492        obj = Application.objects.create(name=generate_id(), slug=generate_id())
493
494        rule = self._create_rule_for_object(obj)
495        content_type = ContentType.objects.get_for_model(obj)
496
497        pending_iteration = LifecycleIteration.objects.get(
498            content_type=content_type, object_id=str(obj.pk), rule=rule
499        )
500        self.assertEqual(pending_iteration.state, ReviewState.PENDING)
501
502        overdue_iteration = LifecycleIteration.objects.create(
503            content_type=content_type,
504            object_id=str(obj.pk),
505            rule=rule,
506            state=ReviewState.OVERDUE,
507        )
508        reviewed_iteration = LifecycleIteration.objects.create(
509            content_type=content_type,
510            object_id=str(obj.pk),
511            rule=rule,
512            state=ReviewState.REVIEWED,
513        )
514
515        rule.delete()
516
517        pending_iteration.refresh_from_db()
518        overdue_iteration.refresh_from_db()
519        reviewed_iteration.refresh_from_db()
520
521        self.assertEqual(pending_iteration.state, ReviewState.CANCELED)
522        self.assertEqual(overdue_iteration.state, ReviewState.CANCELED)
523        self.assertEqual(reviewed_iteration.state, ReviewState.REVIEWED)  # Not affected
def test_update_rule_target_cancels_stale_iterations(self):
525    def test_update_rule_target_cancels_stale_iterations(self):
526        app_one = Application.objects.create(name=generate_id(), slug=generate_id())
527        app_two = Application.objects.create(name=generate_id(), slug=generate_id())
528        content_type = ContentType.objects.get_for_model(Application)
529
530        rule = LifecycleRule.objects.create(
531            name=generate_id(),
532            content_type=content_type,
533            object_id=str(app_one.pk),
534            interval="days=30",
535        )
536
537        iteration_for_app_one = LifecycleIteration.objects.get(
538            content_type=content_type, object_id=str(app_one.pk), rule=rule
539        )
540        self.assertEqual(iteration_for_app_one.state, ReviewState.PENDING)
541
542        # Change rule target to app_two - save() triggers apply() which cancels stale iterations
543        rule.object_id = str(app_two.pk)
544        rule.save()
545
546        iteration_for_app_one.refresh_from_db()
547        self.assertEqual(iteration_for_app_one.state, ReviewState.CANCELED)
def test_update_rule_content_type_cancels_stale_iterations(self):
549    def test_update_rule_content_type_cancels_stale_iterations(self):
550        app = Application.objects.create(name=generate_id(), slug=generate_id())
551        group = Group.objects.create(name=generate_id())
552        app_content_type = ContentType.objects.get_for_model(Application)
553        group_content_type = ContentType.objects.get_for_model(Group)
554
555        # Creating rule triggers automatic apply() which creates a iteration for app
556        rule = LifecycleRule.objects.create(
557            name=generate_id(),
558            content_type=app_content_type,
559            object_id=str(app.pk),
560            interval="days=30",
561        )
562
563        iteration = LifecycleIteration.objects.get(
564            content_type=app_content_type, object_id=str(app.pk), rule=rule
565        )
566        self.assertEqual(iteration.state, ReviewState.PENDING)
567
568        # Change content type to Group - save() triggers apply() which cancels stale iterations
569        rule.content_type = group_content_type
570        rule.object_id = str(group.pk)
571        rule.save()
572
573        iteration.refresh_from_db()
574        self.assertEqual(iteration.state, ReviewState.CANCELED)
def test_user_can_review_checks_group_hierarchy(self):
576    def test_user_can_review_checks_group_hierarchy(self):
577        obj = Application.objects.create(name=generate_id(), slug=generate_id())
578        rule = self._create_rule_for_object(obj)
579
580        parent_group = Group.objects.create(name=generate_id())
581        child_group = Group.objects.create(name=generate_id())
582        child_group.parents.add(parent_group)
583
584        parent_member = create_test_user()
585        child_member = create_test_user()
586        non_member = create_test_user()
587        parent_group.users.add(parent_member)
588        child_group.users.add(child_member)
589
590        rule.reviewer_groups.add(parent_group)
591
592        content_type = ContentType.objects.get_for_model(obj)
593        # iteration is created automatically when rule is saved
594        iteration = LifecycleIteration.objects.get(
595            content_type=content_type, object_id=str(obj.pk), rule=rule
596        )
597
598        self.assertTrue(iteration.user_can_review(parent_member))
599        self.assertTrue(iteration.user_can_review(child_member))
600        self.assertFalse(iteration.user_can_review(non_member))
def test_user_cannot_review_twice(self):
602    def test_user_cannot_review_twice(self):
603        obj = Application.objects.create(name=generate_id(), slug=generate_id())
604        rule = self._create_rule_for_object(obj)
605        reviewer = create_test_user()
606        rule.reviewers.add(reviewer)
607
608        content_type = ContentType.objects.get_for_model(obj)
609        # iteration is created automatically when rule is saved
610        iteration = LifecycleIteration.objects.get(
611            content_type=content_type, object_id=str(obj.pk), rule=rule
612        )
613
614        self.assertTrue(iteration.user_can_review(reviewer))
615
616        Review.objects.create(iteration=iteration, reviewer=reviewer)
617
618        self.assertFalse(iteration.user_can_review(reviewer))
def test_user_cannot_review_completed_iteration(self):
620    def test_user_cannot_review_completed_iteration(self):
621        obj = Application.objects.create(name=generate_id(), slug=generate_id())
622        rule = self._create_rule_for_object(obj)
623        reviewer = create_test_user()
624        rule.reviewers.add(reviewer)
625
626        content_type = ContentType.objects.get_for_model(obj)
627
628        # Get the automatically created pending iteration and test with different states
629        iteration = LifecycleIteration.objects.get(
630            content_type=content_type, object_id=str(obj.pk), rule=rule
631        )
632
633        for state in (ReviewState.REVIEWED, ReviewState.CANCELED):
634            iteration.state = state
635            iteration.save()
636            self.assertFalse(iteration.user_can_review(reviewer))
def test_get_reviewers_includes_child_group_members(self):
638    def test_get_reviewers_includes_child_group_members(self):
639        obj = Application.objects.create(name=generate_id(), slug=generate_id())
640        rule = self._create_rule_for_object(obj)
641
642        parent_group = Group.objects.create(name=generate_id())
643        child_group = Group.objects.create(name=generate_id())
644        child_group.parents.add(parent_group)
645
646        parent_member = create_test_user()
647        child_member = create_test_user()
648        parent_group.users.add(parent_member)
649        child_group.users.add(child_member)
650
651        rule.reviewer_groups.add(parent_group)
652
653        reviewers = list(rule.get_reviewers())
654        self.assertIn(parent_member, reviewers)
655        self.assertIn(child_member, reviewers)
def test_get_reviewers_includes_explicit_reviewers(self):
657    def test_get_reviewers_includes_explicit_reviewers(self):
658        obj = Application.objects.create(name=generate_id(), slug=generate_id())
659        rule = self._create_rule_for_object(obj)
660
661        explicit_reviewer = create_test_user()
662        rule.reviewers.add(explicit_reviewer)
663
664        group = Group.objects.create(name=generate_id())
665        group_member = create_test_user()
666        group.users.add(group_member)
667        rule.reviewer_groups.add(group)
668
669        reviewers = list(rule.get_reviewers())
670        self.assertIn(explicit_reviewer, reviewers)
671        self.assertIn(group_member, reviewers)
class TestLifecycleDateBoundaries(django.test.testcases.TestCase):
674class TestLifecycleDateBoundaries(TestCase):
675    """Verify that start_of_day normalization ensures correct overdue/due
676    detection regardless of exact task execution time within a day.
677
678    The daily task may run at any point during the day. The start_of_day
679    normalization in _get_newly_overdue_iterations and _get_newly_due_objects
680    ensures that the boundary is always at midnight, so millisecond variations
681    in task execution time do not affect results."""
682
683    def _create_rule_and_iteration(self, grace_period="days=1", interval="days=365"):
684        app = Application.objects.create(name=generate_id(), slug=generate_id())
685        content_type = ContentType.objects.get_for_model(Application)
686        rule = LifecycleRule.objects.create(
687            name=generate_id(),
688            content_type=content_type,
689            object_id=str(app.pk),
690            interval=interval,
691            grace_period=grace_period,
692        )
693        iteration = LifecycleIteration.objects.get(
694            content_type=content_type, object_id=str(app.pk), rule=rule
695        )
696        return app, rule, iteration
697
698    def test_overdue_iteration_opened_yesterday(self):
699        """grace_period=1 day: iteration opened yesterday at any time is overdue today."""
700        _, rule, iteration = self._create_rule_and_iteration(grace_period="days=1")
701        fixed_now = dt.datetime(2025, 6, 15, 14, 30, 0, tzinfo=dt.UTC)
702        for opened_on in [
703            dt.datetime(2025, 6, 14, 0, 0, 0, tzinfo=dt.UTC),
704            dt.datetime(2025, 6, 14, 12, 0, 0, tzinfo=dt.UTC),
705            dt.datetime(2025, 6, 14, 23, 59, 59, 999999, tzinfo=dt.UTC),
706        ]:
707            with self.subTest(opened_on=opened_on):
708                LifecycleIteration.objects.filter(pk=iteration.pk).update(
709                    opened_on=opened_on, state=ReviewState.PENDING
710                )
711                with patch("django.utils.timezone.now", return_value=fixed_now):
712                    self.assertIn(iteration, list(rule._get_newly_overdue_iterations()))
713
714    def test_not_overdue_iteration_opened_today(self):
715        """grace_period=1 day: iteration opened today at any time is NOT overdue."""
716        _, rule, iteration = self._create_rule_and_iteration(grace_period="days=1")
717        fixed_now = dt.datetime(2025, 6, 15, 14, 30, 0, tzinfo=dt.UTC)
718        for opened_on in [
719            dt.datetime(2025, 6, 15, 0, 0, 0, tzinfo=dt.UTC),
720            dt.datetime(2025, 6, 15, 14, 30, 0, tzinfo=dt.UTC),
721            dt.datetime(2025, 6, 15, 23, 59, 59, 999999, tzinfo=dt.UTC),
722        ]:
723            with self.subTest(opened_on=opened_on):
724                LifecycleIteration.objects.filter(pk=iteration.pk).update(
725                    opened_on=opened_on, state=ReviewState.PENDING
726                )
727                with patch("django.utils.timezone.now", return_value=fixed_now):
728                    self.assertNotIn(iteration, list(rule._get_newly_overdue_iterations()))
729
730    def test_overdue_independent_of_task_execution_time(self):
731        """Overdue detection gives the same result whether the task runs at 00:00:01 or 23:59:59."""
732        _, rule, iteration = self._create_rule_and_iteration(grace_period="days=1")
733        opened_on = dt.datetime(2025, 6, 14, 18, 0, 0, tzinfo=dt.UTC)
734        LifecycleIteration.objects.filter(pk=iteration.pk).update(
735            opened_on=opened_on, state=ReviewState.PENDING
736        )
737        for task_time in [
738            dt.datetime(2025, 6, 15, 0, 0, 1, tzinfo=dt.UTC),
739            dt.datetime(2025, 6, 15, 12, 0, 0, tzinfo=dt.UTC),
740            dt.datetime(2025, 6, 15, 23, 59, 59, tzinfo=dt.UTC),
741        ]:
742            with self.subTest(task_time=task_time):
743                with patch("django.utils.timezone.now", return_value=task_time):
744                    self.assertIn(iteration, list(rule._get_newly_overdue_iterations()))
745
746    def test_overdue_boundary_multi_day_grace_period(self):
747        """grace_period=30 days: overdue after 30 full days, not after 29."""
748        _, rule, iteration = self._create_rule_and_iteration(grace_period="days=30")
749        fixed_now = dt.datetime(2025, 6, 15, 14, 30, 0, tzinfo=dt.UTC)
750
751        # Opened 30 days ago (May 16), should go overdue
752        LifecycleIteration.objects.filter(pk=iteration.pk).update(
753            opened_on=dt.datetime(2025, 5, 16, 12, 0, 0, tzinfo=dt.UTC),
754            state=ReviewState.PENDING,
755        )
756        with patch("django.utils.timezone.now", return_value=fixed_now):
757            self.assertIn(iteration, list(rule._get_newly_overdue_iterations()))
758
759        # Opened 29 days ago (May 17), should NOT go overdue
760        LifecycleIteration.objects.filter(pk=iteration.pk).update(
761            opened_on=dt.datetime(2025, 5, 17, 12, 0, 0, tzinfo=dt.UTC),
762            state=ReviewState.PENDING,
763        )
764        with patch("django.utils.timezone.now", return_value=fixed_now):
765            self.assertNotIn(iteration, list(rule._get_newly_overdue_iterations()))
766
767    def test_due_object_iteration_opened_yesterday(self):
768        """interval=1 day: object with iteration opened yesterday is due for a new review."""
769        app, rule, iteration = self._create_rule_and_iteration(interval="days=1")
770        fixed_now = dt.datetime(2025, 6, 15, 14, 30, 0, tzinfo=dt.UTC)
771        for opened_on in [
772            dt.datetime(2025, 6, 14, 0, 0, 0, tzinfo=dt.UTC),
773            dt.datetime(2025, 6, 14, 12, 0, 0, tzinfo=dt.UTC),
774            dt.datetime(2025, 6, 14, 23, 59, 59, 999999, tzinfo=dt.UTC),
775        ]:
776            with self.subTest(opened_on=opened_on):
777                LifecycleIteration.objects.filter(pk=iteration.pk).update(opened_on=opened_on)
778                with patch("django.utils.timezone.now", return_value=fixed_now):
779                    self.assertIn(app, list(rule._get_newly_due_objects()))
780
781    def test_not_due_object_iteration_opened_today(self):
782        """interval=1 day: object with iteration opened today is NOT due."""
783        app, rule, iteration = self._create_rule_and_iteration(interval="days=1")
784        fixed_now = dt.datetime(2025, 6, 15, 14, 30, 0, tzinfo=dt.UTC)
785        for opened_on in [
786            dt.datetime(2025, 6, 15, 0, 0, 0, tzinfo=dt.UTC),
787            dt.datetime(2025, 6, 15, 14, 30, 0, tzinfo=dt.UTC),
788            dt.datetime(2025, 6, 15, 23, 59, 59, 999999, tzinfo=dt.UTC),
789        ]:
790            with self.subTest(opened_on=opened_on):
791                LifecycleIteration.objects.filter(pk=iteration.pk).update(opened_on=opened_on)
792                with patch("django.utils.timezone.now", return_value=fixed_now):
793                    self.assertNotIn(app, list(rule._get_newly_due_objects()))
794
795    def test_due_independent_of_task_execution_time(self):
796        """Due detection gives the same result whether the task runs at 00:00:01 or 23:59:59."""
797        app, rule, iteration = self._create_rule_and_iteration(interval="days=1")
798        opened_on = dt.datetime(2025, 6, 14, 18, 0, 0, tzinfo=dt.UTC)
799        LifecycleIteration.objects.filter(pk=iteration.pk).update(opened_on=opened_on)
800        for task_time in [
801            dt.datetime(2025, 6, 15, 0, 0, 1, tzinfo=dt.UTC),
802            dt.datetime(2025, 6, 15, 12, 0, 0, tzinfo=dt.UTC),
803            dt.datetime(2025, 6, 15, 23, 59, 59, tzinfo=dt.UTC),
804        ]:
805            with self.subTest(task_time=task_time):
806                with patch("django.utils.timezone.now", return_value=task_time):
807                    self.assertIn(app, list(rule._get_newly_due_objects()))
808
809    def test_due_boundary_multi_day_interval(self):
810        """interval=30 days: due after 30 full days, not after 29."""
811        app, rule, iteration = self._create_rule_and_iteration(interval="days=30")
812        fixed_now = dt.datetime(2025, 6, 15, 14, 30, 0, tzinfo=dt.UTC)
813
814        # Previous review opened 30 days ago (May 16), review is due for the object
815        LifecycleIteration.objects.filter(pk=iteration.pk).update(
816            opened_on=dt.datetime(2025, 5, 16, 12, 0, 0, tzinfo=dt.UTC)
817        )
818        with patch("django.utils.timezone.now", return_value=fixed_now):
819            self.assertIn(app, list(rule._get_newly_due_objects()))
820
821        # Previous review opened 29 days ago (May 17), new review is NOT due
822        LifecycleIteration.objects.filter(pk=iteration.pk).update(
823            opened_on=dt.datetime(2025, 5, 17, 12, 0, 0, tzinfo=dt.UTC)
824        )
825        with patch("django.utils.timezone.now", return_value=fixed_now):
826            self.assertNotIn(app, list(rule._get_newly_due_objects()))
827
828    def test_apply_overdue_at_boundary(self):
829        """apply() marks iteration overdue when grace period just expired,
830        regardless of what time the daily task runs."""
831        _, rule, iteration = self._create_rule_and_iteration(
832            grace_period="days=1", interval="days=365"
833        )
834        opened_on = dt.datetime(2025, 6, 14, 20, 0, 0, tzinfo=dt.UTC)
835        for task_time in [
836            dt.datetime(2025, 6, 15, 0, 0, 1, tzinfo=dt.UTC),
837            dt.datetime(2025, 6, 15, 23, 59, 59, tzinfo=dt.UTC),
838        ]:
839            with self.subTest(task_time=task_time):
840                LifecycleIteration.objects.filter(pk=iteration.pk).update(
841                    opened_on=opened_on, state=ReviewState.PENDING
842                )
843                with patch("django.utils.timezone.now", return_value=task_time):
844                    rule.apply()
845                iteration.refresh_from_db()
846                self.assertEqual(iteration.state, ReviewState.OVERDUE)

Verify that start_of_day normalization ensures correct overdue/due detection regardless of exact task execution time within a day.

The daily task may run at any point during the day. The start_of_day normalization in _get_newly_overdue_iterations and _get_newly_due_objects ensures that the boundary is always at midnight, so millisecond variations in task execution time do not affect results.

def test_overdue_iteration_opened_yesterday(self):
698    def test_overdue_iteration_opened_yesterday(self):
699        """grace_period=1 day: iteration opened yesterday at any time is overdue today."""
700        _, rule, iteration = self._create_rule_and_iteration(grace_period="days=1")
701        fixed_now = dt.datetime(2025, 6, 15, 14, 30, 0, tzinfo=dt.UTC)
702        for opened_on in [
703            dt.datetime(2025, 6, 14, 0, 0, 0, tzinfo=dt.UTC),
704            dt.datetime(2025, 6, 14, 12, 0, 0, tzinfo=dt.UTC),
705            dt.datetime(2025, 6, 14, 23, 59, 59, 999999, tzinfo=dt.UTC),
706        ]:
707            with self.subTest(opened_on=opened_on):
708                LifecycleIteration.objects.filter(pk=iteration.pk).update(
709                    opened_on=opened_on, state=ReviewState.PENDING
710                )
711                with patch("django.utils.timezone.now", return_value=fixed_now):
712                    self.assertIn(iteration, list(rule._get_newly_overdue_iterations()))

grace_period=1 day: iteration opened yesterday at any time is overdue today.

def test_not_overdue_iteration_opened_today(self):
714    def test_not_overdue_iteration_opened_today(self):
715        """grace_period=1 day: iteration opened today at any time is NOT overdue."""
716        _, rule, iteration = self._create_rule_and_iteration(grace_period="days=1")
717        fixed_now = dt.datetime(2025, 6, 15, 14, 30, 0, tzinfo=dt.UTC)
718        for opened_on in [
719            dt.datetime(2025, 6, 15, 0, 0, 0, tzinfo=dt.UTC),
720            dt.datetime(2025, 6, 15, 14, 30, 0, tzinfo=dt.UTC),
721            dt.datetime(2025, 6, 15, 23, 59, 59, 999999, tzinfo=dt.UTC),
722        ]:
723            with self.subTest(opened_on=opened_on):
724                LifecycleIteration.objects.filter(pk=iteration.pk).update(
725                    opened_on=opened_on, state=ReviewState.PENDING
726                )
727                with patch("django.utils.timezone.now", return_value=fixed_now):
728                    self.assertNotIn(iteration, list(rule._get_newly_overdue_iterations()))

grace_period=1 day: iteration opened today at any time is NOT overdue.

def test_overdue_independent_of_task_execution_time(self):
730    def test_overdue_independent_of_task_execution_time(self):
731        """Overdue detection gives the same result whether the task runs at 00:00:01 or 23:59:59."""
732        _, rule, iteration = self._create_rule_and_iteration(grace_period="days=1")
733        opened_on = dt.datetime(2025, 6, 14, 18, 0, 0, tzinfo=dt.UTC)
734        LifecycleIteration.objects.filter(pk=iteration.pk).update(
735            opened_on=opened_on, state=ReviewState.PENDING
736        )
737        for task_time in [
738            dt.datetime(2025, 6, 15, 0, 0, 1, tzinfo=dt.UTC),
739            dt.datetime(2025, 6, 15, 12, 0, 0, tzinfo=dt.UTC),
740            dt.datetime(2025, 6, 15, 23, 59, 59, tzinfo=dt.UTC),
741        ]:
742            with self.subTest(task_time=task_time):
743                with patch("django.utils.timezone.now", return_value=task_time):
744                    self.assertIn(iteration, list(rule._get_newly_overdue_iterations()))

Overdue detection gives the same result whether the task runs at 00:00:01 or 23:59:59.

def test_overdue_boundary_multi_day_grace_period(self):
746    def test_overdue_boundary_multi_day_grace_period(self):
747        """grace_period=30 days: overdue after 30 full days, not after 29."""
748        _, rule, iteration = self._create_rule_and_iteration(grace_period="days=30")
749        fixed_now = dt.datetime(2025, 6, 15, 14, 30, 0, tzinfo=dt.UTC)
750
751        # Opened 30 days ago (May 16), should go overdue
752        LifecycleIteration.objects.filter(pk=iteration.pk).update(
753            opened_on=dt.datetime(2025, 5, 16, 12, 0, 0, tzinfo=dt.UTC),
754            state=ReviewState.PENDING,
755        )
756        with patch("django.utils.timezone.now", return_value=fixed_now):
757            self.assertIn(iteration, list(rule._get_newly_overdue_iterations()))
758
759        # Opened 29 days ago (May 17), should NOT go overdue
760        LifecycleIteration.objects.filter(pk=iteration.pk).update(
761            opened_on=dt.datetime(2025, 5, 17, 12, 0, 0, tzinfo=dt.UTC),
762            state=ReviewState.PENDING,
763        )
764        with patch("django.utils.timezone.now", return_value=fixed_now):
765            self.assertNotIn(iteration, list(rule._get_newly_overdue_iterations()))

grace_period=30 days: overdue after 30 full days, not after 29.

def test_due_object_iteration_opened_yesterday(self):
767    def test_due_object_iteration_opened_yesterday(self):
768        """interval=1 day: object with iteration opened yesterday is due for a new review."""
769        app, rule, iteration = self._create_rule_and_iteration(interval="days=1")
770        fixed_now = dt.datetime(2025, 6, 15, 14, 30, 0, tzinfo=dt.UTC)
771        for opened_on in [
772            dt.datetime(2025, 6, 14, 0, 0, 0, tzinfo=dt.UTC),
773            dt.datetime(2025, 6, 14, 12, 0, 0, tzinfo=dt.UTC),
774            dt.datetime(2025, 6, 14, 23, 59, 59, 999999, tzinfo=dt.UTC),
775        ]:
776            with self.subTest(opened_on=opened_on):
777                LifecycleIteration.objects.filter(pk=iteration.pk).update(opened_on=opened_on)
778                with patch("django.utils.timezone.now", return_value=fixed_now):
779                    self.assertIn(app, list(rule._get_newly_due_objects()))

interval=1 day: object with iteration opened yesterday is due for a new review.

def test_not_due_object_iteration_opened_today(self):
781    def test_not_due_object_iteration_opened_today(self):
782        """interval=1 day: object with iteration opened today is NOT due."""
783        app, rule, iteration = self._create_rule_and_iteration(interval="days=1")
784        fixed_now = dt.datetime(2025, 6, 15, 14, 30, 0, tzinfo=dt.UTC)
785        for opened_on in [
786            dt.datetime(2025, 6, 15, 0, 0, 0, tzinfo=dt.UTC),
787            dt.datetime(2025, 6, 15, 14, 30, 0, tzinfo=dt.UTC),
788            dt.datetime(2025, 6, 15, 23, 59, 59, 999999, tzinfo=dt.UTC),
789        ]:
790            with self.subTest(opened_on=opened_on):
791                LifecycleIteration.objects.filter(pk=iteration.pk).update(opened_on=opened_on)
792                with patch("django.utils.timezone.now", return_value=fixed_now):
793                    self.assertNotIn(app, list(rule._get_newly_due_objects()))

interval=1 day: object with iteration opened today is NOT due.

def test_due_independent_of_task_execution_time(self):
795    def test_due_independent_of_task_execution_time(self):
796        """Due detection gives the same result whether the task runs at 00:00:01 or 23:59:59."""
797        app, rule, iteration = self._create_rule_and_iteration(interval="days=1")
798        opened_on = dt.datetime(2025, 6, 14, 18, 0, 0, tzinfo=dt.UTC)
799        LifecycleIteration.objects.filter(pk=iteration.pk).update(opened_on=opened_on)
800        for task_time in [
801            dt.datetime(2025, 6, 15, 0, 0, 1, tzinfo=dt.UTC),
802            dt.datetime(2025, 6, 15, 12, 0, 0, tzinfo=dt.UTC),
803            dt.datetime(2025, 6, 15, 23, 59, 59, tzinfo=dt.UTC),
804        ]:
805            with self.subTest(task_time=task_time):
806                with patch("django.utils.timezone.now", return_value=task_time):
807                    self.assertIn(app, list(rule._get_newly_due_objects()))

Due detection gives the same result whether the task runs at 00:00:01 or 23:59:59.

def test_due_boundary_multi_day_interval(self):
809    def test_due_boundary_multi_day_interval(self):
810        """interval=30 days: due after 30 full days, not after 29."""
811        app, rule, iteration = self._create_rule_and_iteration(interval="days=30")
812        fixed_now = dt.datetime(2025, 6, 15, 14, 30, 0, tzinfo=dt.UTC)
813
814        # Previous review opened 30 days ago (May 16), review is due for the object
815        LifecycleIteration.objects.filter(pk=iteration.pk).update(
816            opened_on=dt.datetime(2025, 5, 16, 12, 0, 0, tzinfo=dt.UTC)
817        )
818        with patch("django.utils.timezone.now", return_value=fixed_now):
819            self.assertIn(app, list(rule._get_newly_due_objects()))
820
821        # Previous review opened 29 days ago (May 17), new review is NOT due
822        LifecycleIteration.objects.filter(pk=iteration.pk).update(
823            opened_on=dt.datetime(2025, 5, 17, 12, 0, 0, tzinfo=dt.UTC)
824        )
825        with patch("django.utils.timezone.now", return_value=fixed_now):
826            self.assertNotIn(app, list(rule._get_newly_due_objects()))

interval=30 days: due after 30 full days, not after 29.

def test_apply_overdue_at_boundary(self):
828    def test_apply_overdue_at_boundary(self):
829        """apply() marks iteration overdue when grace period just expired,
830        regardless of what time the daily task runs."""
831        _, rule, iteration = self._create_rule_and_iteration(
832            grace_period="days=1", interval="days=365"
833        )
834        opened_on = dt.datetime(2025, 6, 14, 20, 0, 0, tzinfo=dt.UTC)
835        for task_time in [
836            dt.datetime(2025, 6, 15, 0, 0, 1, tzinfo=dt.UTC),
837            dt.datetime(2025, 6, 15, 23, 59, 59, tzinfo=dt.UTC),
838        ]:
839            with self.subTest(task_time=task_time):
840                LifecycleIteration.objects.filter(pk=iteration.pk).update(
841                    opened_on=opened_on, state=ReviewState.PENDING
842                )
843                with patch("django.utils.timezone.now", return_value=task_time):
844                    rule.apply()
845                iteration.refresh_from_db()
846                self.assertEqual(iteration.state, ReviewState.OVERDUE)

apply() marks iteration overdue when grace period just expired, regardless of what time the daily task runs.