authentik.enterprise.lifecycle.tests.test_models

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

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):
31    def setUp(self):
32        self.factory = RequestFactory()

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

@classmethod
def setUpTestData(cls):
34    @classmethod
35    def setUpTestData(cls):
36        config = apps.get_app_config("authentik_tasks_schedules")
37        config._on_startup_callback(None)

Load initial data for the TestCase.

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

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

def test_review_requires_all_explicit_reviewers(self):
 92    def test_review_requires_all_explicit_reviewers(self):
 93        obj = Group.objects.create(name=generate_id())
 94        rule = self._create_rule_for_object(obj)
 95        reviewer_one = create_test_user()
 96        reviewer_two = create_test_user()
 97        rule.reviewers.add(reviewer_one, reviewer_two)
 98
 99        content_type = ContentType.objects.get_for_model(obj)
100
101        iteration = LifecycleIteration.objects.get(
102            content_type=content_type, object_id=str(obj.pk), rule=rule
103        )
104        request = self._get_request()
105
106        Review.objects.create(iteration=iteration, reviewer=reviewer_one)
107        iteration.on_review(request)
108        iteration.refresh_from_db()
109        self.assertEqual(iteration.state, ReviewState.PENDING)
110
111        Review.objects.create(iteration=iteration, reviewer=reviewer_two)
112        iteration.on_review(request)
113        iteration.refresh_from_db()
114        self.assertEqual(iteration.state, ReviewState.REVIEWED)
115        self.assertTrue(Event.objects.filter(action=EventAction.REVIEW_COMPLETED).exists())
def test_review_min_reviewers_from_groups(self):
117    def test_review_min_reviewers_from_groups(self):
118        """Group-based reviews complete once the minimum number of reviewers review."""
119        obj = Application.objects.create(name=generate_id(), slug=generate_id())
120        rule = self._create_rule_for_object(obj, min_reviewers=2)
121
122        reviewer_group = Group.objects.create(name=generate_id())
123        reviewer_one = create_test_user()
124        reviewer_two = create_test_user()
125        reviewer_group.users.add(reviewer_one, reviewer_two)
126        rule.reviewer_groups.add(reviewer_group)
127
128        content_type = ContentType.objects.get_for_model(obj)
129
130        iteration = LifecycleIteration.objects.get(
131            content_type=content_type, object_id=str(obj.pk), rule=rule
132        )
133        request = self._get_request()
134
135        Review.objects.create(iteration=iteration, reviewer=reviewer_one)
136        iteration.on_review(request)
137        iteration.refresh_from_db()
138        self.assertEqual(iteration.state, ReviewState.PENDING)
139
140        Review.objects.create(iteration=iteration, reviewer=reviewer_two)
141        iteration.on_review(request)
142        iteration.refresh_from_db()
143        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):
145    def test_review_explicit_and_group_reviewers(self):
146        """Reviews require both explicit reviewers AND min_reviewers from groups."""
147        obj = Application.objects.create(name=generate_id(), slug=generate_id())
148        rule = self._create_rule_for_object(obj, min_reviewers=1)
149
150        reviewer_group = Group.objects.create(name=generate_id())
151        group_member = create_test_user()
152        reviewer_group.users.add(group_member)
153        rule.reviewer_groups.add(reviewer_group)
154
155        explicit_reviewer = create_test_user()
156        rule.reviewers.add(explicit_reviewer)
157
158        content_type = ContentType.objects.get_for_model(obj)
159
160        iteration = LifecycleIteration.objects.get(
161            content_type=content_type, object_id=str(obj.pk), rule=rule
162        )
163        request = self._get_request()
164
165        # Only group member reviews - not satisfied (explicit reviewer missing)
166        Review.objects.create(iteration=iteration, reviewer=group_member)
167        iteration.on_review(request)
168        iteration.refresh_from_db()
169        self.assertEqual(iteration.state, ReviewState.PENDING)
170
171        # Explicit reviewer reviews - now satisfied
172        Review.objects.create(iteration=iteration, reviewer=explicit_reviewer)
173        iteration.on_review(request)
174        iteration.refresh_from_db()
175        self.assertEqual(iteration.state, ReviewState.REVIEWED)

Reviews require both explicit reviewers AND min_reviewers from groups.

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

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

def test_rule_type_apply_creates_iterations_for_all_objects(self):
448    def test_rule_type_apply_creates_iterations_for_all_objects(self):
449        app_one = Application.objects.create(name=generate_id(), slug=generate_id())
450        app_two = Application.objects.create(name=generate_id(), slug=generate_id())
451        content_type = ContentType.objects.get_for_model(Application)
452
453        LifecycleRule.objects.create(
454            name=generate_id(),
455            content_type=content_type,
456            object_id=None,
457            interval="days=30",
458            grace_period="days=10",
459        )
460
461        self.assertTrue(
462            LifecycleIteration.objects.filter(
463                content_type=content_type, object_id=str(app_one.pk)
464            ).exists()
465        )
466        self.assertTrue(
467            LifecycleIteration.objects.filter(
468                content_type=content_type, object_id=str(app_two.pk)
469            ).exists()
470        )
def test_delete_rule_cancels_open_iterations(self):
472    def test_delete_rule_cancels_open_iterations(self):
473        obj = Application.objects.create(name=generate_id(), slug=generate_id())
474
475        rule = self._create_rule_for_object(obj)
476        content_type = ContentType.objects.get_for_model(obj)
477
478        pending_iteration = LifecycleIteration.objects.get(
479            content_type=content_type, object_id=str(obj.pk), rule=rule
480        )
481        self.assertEqual(pending_iteration.state, ReviewState.PENDING)
482
483        overdue_iteration = LifecycleIteration.objects.create(
484            content_type=content_type,
485            object_id=str(obj.pk),
486            rule=rule,
487            state=ReviewState.OVERDUE,
488        )
489        reviewed_iteration = LifecycleIteration.objects.create(
490            content_type=content_type,
491            object_id=str(obj.pk),
492            rule=rule,
493            state=ReviewState.REVIEWED,
494        )
495
496        rule.delete()
497
498        pending_iteration.refresh_from_db()
499        overdue_iteration.refresh_from_db()
500        reviewed_iteration.refresh_from_db()
501
502        self.assertEqual(pending_iteration.state, ReviewState.CANCELED)
503        self.assertEqual(overdue_iteration.state, ReviewState.CANCELED)
504        self.assertEqual(reviewed_iteration.state, ReviewState.REVIEWED)  # Not affected
def test_update_rule_target_cancels_stale_iterations(self):
506    def test_update_rule_target_cancels_stale_iterations(self):
507        app_one = Application.objects.create(name=generate_id(), slug=generate_id())
508        app_two = Application.objects.create(name=generate_id(), slug=generate_id())
509        content_type = ContentType.objects.get_for_model(Application)
510
511        rule = LifecycleRule.objects.create(
512            name=generate_id(),
513            content_type=content_type,
514            object_id=str(app_one.pk),
515            interval="days=30",
516        )
517
518        iteration_for_app_one = LifecycleIteration.objects.get(
519            content_type=content_type, object_id=str(app_one.pk), rule=rule
520        )
521        self.assertEqual(iteration_for_app_one.state, ReviewState.PENDING)
522
523        # Change rule target to app_two - save() triggers apply() which cancels stale iterations
524        rule.object_id = str(app_two.pk)
525        rule.save()
526
527        iteration_for_app_one.refresh_from_db()
528        self.assertEqual(iteration_for_app_one.state, ReviewState.CANCELED)
def test_update_rule_content_type_cancels_stale_iterations(self):
530    def test_update_rule_content_type_cancels_stale_iterations(self):
531        app = Application.objects.create(name=generate_id(), slug=generate_id())
532        group = Group.objects.create(name=generate_id())
533        app_content_type = ContentType.objects.get_for_model(Application)
534        group_content_type = ContentType.objects.get_for_model(Group)
535
536        # Creating rule triggers automatic apply() which creates a iteration for app
537        rule = LifecycleRule.objects.create(
538            name=generate_id(),
539            content_type=app_content_type,
540            object_id=str(app.pk),
541            interval="days=30",
542        )
543
544        iteration = LifecycleIteration.objects.get(
545            content_type=app_content_type, object_id=str(app.pk), rule=rule
546        )
547        self.assertEqual(iteration.state, ReviewState.PENDING)
548
549        # Change content type to Group - save() triggers apply() which cancels stale iterations
550        rule.content_type = group_content_type
551        rule.object_id = str(group.pk)
552        rule.save()
553
554        iteration.refresh_from_db()
555        self.assertEqual(iteration.state, ReviewState.CANCELED)
def test_user_can_review_checks_group_hierarchy(self):
557    def test_user_can_review_checks_group_hierarchy(self):
558        obj = Application.objects.create(name=generate_id(), slug=generate_id())
559        rule = self._create_rule_for_object(obj)
560
561        parent_group = Group.objects.create(name=generate_id())
562        child_group = Group.objects.create(name=generate_id())
563        child_group.parents.add(parent_group)
564
565        parent_member = create_test_user()
566        child_member = create_test_user()
567        non_member = create_test_user()
568        parent_group.users.add(parent_member)
569        child_group.users.add(child_member)
570
571        rule.reviewer_groups.add(parent_group)
572
573        content_type = ContentType.objects.get_for_model(obj)
574        # iteration is created automatically when rule is saved
575        iteration = LifecycleIteration.objects.get(
576            content_type=content_type, object_id=str(obj.pk), rule=rule
577        )
578
579        self.assertTrue(iteration.user_can_review(parent_member))
580        self.assertTrue(iteration.user_can_review(child_member))
581        self.assertFalse(iteration.user_can_review(non_member))
def test_user_cannot_review_twice(self):
583    def test_user_cannot_review_twice(self):
584        obj = Application.objects.create(name=generate_id(), slug=generate_id())
585        rule = self._create_rule_for_object(obj)
586        reviewer = create_test_user()
587        rule.reviewers.add(reviewer)
588
589        content_type = ContentType.objects.get_for_model(obj)
590        # iteration is created automatically when rule is saved
591        iteration = LifecycleIteration.objects.get(
592            content_type=content_type, object_id=str(obj.pk), rule=rule
593        )
594
595        self.assertTrue(iteration.user_can_review(reviewer))
596
597        Review.objects.create(iteration=iteration, reviewer=reviewer)
598
599        self.assertFalse(iteration.user_can_review(reviewer))
def test_user_cannot_review_completed_iteration(self):
601    def test_user_cannot_review_completed_iteration(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
609        # Get the automatically created pending iteration and test with different states
610        iteration = LifecycleIteration.objects.get(
611            content_type=content_type, object_id=str(obj.pk), rule=rule
612        )
613
614        for state in (ReviewState.REVIEWED, ReviewState.CANCELED):
615            iteration.state = state
616            iteration.save()
617            self.assertFalse(iteration.user_can_review(reviewer))
def test_get_reviewers_includes_child_group_members(self):
619    def test_get_reviewers_includes_child_group_members(self):
620        obj = Application.objects.create(name=generate_id(), slug=generate_id())
621        rule = self._create_rule_for_object(obj)
622
623        parent_group = Group.objects.create(name=generate_id())
624        child_group = Group.objects.create(name=generate_id())
625        child_group.parents.add(parent_group)
626
627        parent_member = create_test_user()
628        child_member = create_test_user()
629        parent_group.users.add(parent_member)
630        child_group.users.add(child_member)
631
632        rule.reviewer_groups.add(parent_group)
633
634        reviewers = list(rule.get_reviewers())
635        self.assertIn(parent_member, reviewers)
636        self.assertIn(child_member, reviewers)
def test_get_reviewers_includes_explicit_reviewers(self):
638    def test_get_reviewers_includes_explicit_reviewers(self):
639        obj = Application.objects.create(name=generate_id(), slug=generate_id())
640        rule = self._create_rule_for_object(obj)
641
642        explicit_reviewer = create_test_user()
643        rule.reviewers.add(explicit_reviewer)
644
645        group = Group.objects.create(name=generate_id())
646        group_member = create_test_user()
647        group.users.add(group_member)
648        rule.reviewer_groups.add(group)
649
650        reviewers = list(rule.get_reviewers())
651        self.assertIn(explicit_reviewer, reviewers)
652        self.assertIn(group_member, reviewers)
def test_multiple_rules_same_object_create_separate_iterations(self):
654    def test_multiple_rules_same_object_create_separate_iterations(self):
655        """Two rules targeting the same object each create their own iteration."""
656        obj = Application.objects.create(name=generate_id(), slug=generate_id())
657        content_type = ContentType.objects.get_for_model(obj)
658
659        rule_one = self._create_rule_for_object(obj, interval="days=30", grace_period="days=10")
660        rule_two = self._create_rule_for_object(obj, interval="days=60", grace_period="days=20")
661
662        iterations = LifecycleIteration.objects.filter(
663            content_type=content_type, object_id=str(obj.pk)
664        )
665        self.assertEqual(iterations.count(), 2)
666
667        iter_one = iterations.get(rule=rule_one)
668        iter_two = iterations.get(rule=rule_two)
669        self.assertEqual(iter_one.state, ReviewState.PENDING)
670        self.assertEqual(iter_two.state, ReviewState.PENDING)
671        self.assertNotEqual(iter_one.pk, iter_two.pk)

Two rules targeting the same object each create their own iteration.

def test_multiple_rules_same_object_reviewed_independently(self):
673    def test_multiple_rules_same_object_reviewed_independently(self):
674        """Reviewing one rule's iteration does not affect the other rule's iteration."""
675        obj = Application.objects.create(name=generate_id(), slug=generate_id())
676        content_type = ContentType.objects.get_for_model(obj)
677
678        reviewer = create_test_user()
679
680        rule_one = self._create_rule_for_object(obj, min_reviewers=1)
681        rule_two = self._create_rule_for_object(obj, min_reviewers=1)
682
683        group = Group.objects.create(name=generate_id())
684        group.users.add(reviewer)
685        rule_one.reviewer_groups.add(group)
686        rule_two.reviewer_groups.add(group)
687
688        iter_one = LifecycleIteration.objects.get(
689            content_type=content_type, object_id=str(obj.pk), rule=rule_one
690        )
691        iter_two = LifecycleIteration.objects.get(
692            content_type=content_type, object_id=str(obj.pk), rule=rule_two
693        )
694
695        request = self._get_request()
696
697        # Review only rule_one's iteration
698        Review.objects.create(iteration=iter_one, reviewer=reviewer)
699        iter_one.on_review(request)
700
701        iter_one.refresh_from_db()
702        iter_two.refresh_from_db()
703        self.assertEqual(iter_one.state, ReviewState.REVIEWED)
704        self.assertEqual(iter_two.state, ReviewState.PENDING)

Reviewing one rule's iteration does not affect the other rule's iteration.

def test_type_rule_and_object_rule_both_create_iterations(self):
706    def test_type_rule_and_object_rule_both_create_iterations(self):
707        """A type-level rule and an object-level rule both create iterations for the same object."""
708        obj = Application.objects.create(name=generate_id(), slug=generate_id())
709        content_type = ContentType.objects.get_for_model(obj)
710
711        object_rule = self._create_rule_for_object(obj, interval="days=30")
712        type_rule = self._create_rule_for_type(Application, interval="days=60")
713
714        iterations = LifecycleIteration.objects.filter(
715            content_type=content_type, object_id=str(obj.pk)
716        )
717        self.assertEqual(iterations.count(), 2)
718        self.assertTrue(iterations.filter(rule=object_rule).exists())
719        self.assertTrue(iterations.filter(rule=type_rule).exists())

A type-level rule and an object-level rule both create iterations for the same object.

class TestLifecycleDateBoundaries(django.test.testcases.TestCase):
722class TestLifecycleDateBoundaries(TestCase):
723    """Verify that start_of_day normalization ensures correct overdue/due
724    detection regardless of exact task execution time within a day.
725
726    The daily task may run at any point during the day. The start_of_day
727    normalization in _get_newly_overdue_iterations and _get_newly_due_objects
728    ensures that the boundary is always at midnight, so millisecond variations
729    in task execution time do not affect results."""
730
731    @classmethod
732    def setUpTestData(cls):
733        config = apps.get_app_config("authentik_tasks_schedules")
734        config._on_startup_callback(None)
735
736    def _create_rule_and_iteration(self, grace_period="days=1", interval="days=365"):
737        app = Application.objects.create(name=generate_id(), slug=generate_id())
738        content_type = ContentType.objects.get_for_model(Application)
739        rule = LifecycleRule.objects.create(
740            name=generate_id(),
741            content_type=content_type,
742            object_id=str(app.pk),
743            interval=interval,
744            grace_period=grace_period,
745        )
746        iteration = LifecycleIteration.objects.get(
747            content_type=content_type, object_id=str(app.pk), rule=rule
748        )
749        return app, rule, iteration
750
751    def test_overdue_iteration_opened_yesterday(self):
752        """grace_period=1 day: iteration opened yesterday at any time is overdue today."""
753        _, rule, iteration = self._create_rule_and_iteration(grace_period="days=1")
754        fixed_now = dt.datetime(2025, 6, 15, 14, 30, 0, tzinfo=dt.UTC)
755        for opened_on in [
756            dt.datetime(2025, 6, 14, 0, 0, 0, tzinfo=dt.UTC),
757            dt.datetime(2025, 6, 14, 12, 0, 0, tzinfo=dt.UTC),
758            dt.datetime(2025, 6, 14, 23, 59, 59, 999999, tzinfo=dt.UTC),
759        ]:
760            with self.subTest(opened_on=opened_on):
761                LifecycleIteration.objects.filter(pk=iteration.pk).update(
762                    opened_on=opened_on, state=ReviewState.PENDING
763                )
764                with patch("django.utils.timezone.now", return_value=fixed_now):
765                    self.assertIn(iteration, list(rule._get_newly_overdue_iterations()))
766
767    def test_not_overdue_iteration_opened_today(self):
768        """grace_period=1 day: iteration opened today at any time is NOT overdue."""
769        _, rule, iteration = self._create_rule_and_iteration(grace_period="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, 15, 0, 0, 0, tzinfo=dt.UTC),
773            dt.datetime(2025, 6, 15, 14, 30, 0, tzinfo=dt.UTC),
774            dt.datetime(2025, 6, 15, 23, 59, 59, 999999, tzinfo=dt.UTC),
775        ]:
776            with self.subTest(opened_on=opened_on):
777                LifecycleIteration.objects.filter(pk=iteration.pk).update(
778                    opened_on=opened_on, state=ReviewState.PENDING
779                )
780                with patch("django.utils.timezone.now", return_value=fixed_now):
781                    self.assertNotIn(iteration, list(rule._get_newly_overdue_iterations()))
782
783    def test_overdue_independent_of_task_execution_time(self):
784        """Overdue detection gives the same result whether the task runs at 00:00:01 or 23:59:59."""
785        _, rule, iteration = self._create_rule_and_iteration(grace_period="days=1")
786        opened_on = dt.datetime(2025, 6, 14, 18, 0, 0, tzinfo=dt.UTC)
787        LifecycleIteration.objects.filter(pk=iteration.pk).update(
788            opened_on=opened_on, state=ReviewState.PENDING
789        )
790        for task_time in [
791            dt.datetime(2025, 6, 15, 0, 0, 1, tzinfo=dt.UTC),
792            dt.datetime(2025, 6, 15, 12, 0, 0, tzinfo=dt.UTC),
793            dt.datetime(2025, 6, 15, 23, 59, 59, tzinfo=dt.UTC),
794        ]:
795            with self.subTest(task_time=task_time):
796                with patch("django.utils.timezone.now", return_value=task_time):
797                    self.assertIn(iteration, list(rule._get_newly_overdue_iterations()))
798
799    def test_overdue_boundary_multi_day_grace_period(self):
800        """grace_period=30 days: overdue after 30 full days, not after 29."""
801        _, rule, iteration = self._create_rule_and_iteration(grace_period="days=30")
802        fixed_now = dt.datetime(2025, 6, 15, 14, 30, 0, tzinfo=dt.UTC)
803
804        # Opened 30 days ago (May 16), should go overdue
805        LifecycleIteration.objects.filter(pk=iteration.pk).update(
806            opened_on=dt.datetime(2025, 5, 16, 12, 0, 0, tzinfo=dt.UTC),
807            state=ReviewState.PENDING,
808        )
809        with patch("django.utils.timezone.now", return_value=fixed_now):
810            self.assertIn(iteration, list(rule._get_newly_overdue_iterations()))
811
812        # Opened 29 days ago (May 17), should NOT go overdue
813        LifecycleIteration.objects.filter(pk=iteration.pk).update(
814            opened_on=dt.datetime(2025, 5, 17, 12, 0, 0, tzinfo=dt.UTC),
815            state=ReviewState.PENDING,
816        )
817        with patch("django.utils.timezone.now", return_value=fixed_now):
818            self.assertNotIn(iteration, list(rule._get_newly_overdue_iterations()))
819
820    def test_due_object_iteration_opened_yesterday(self):
821        """interval=1 day: object with iteration opened yesterday is due for a new review."""
822        app, rule, iteration = self._create_rule_and_iteration(interval="days=1")
823        fixed_now = dt.datetime(2025, 6, 15, 14, 30, 0, tzinfo=dt.UTC)
824        for opened_on in [
825            dt.datetime(2025, 6, 14, 0, 0, 0, tzinfo=dt.UTC),
826            dt.datetime(2025, 6, 14, 12, 0, 0, tzinfo=dt.UTC),
827            dt.datetime(2025, 6, 14, 23, 59, 59, 999999, tzinfo=dt.UTC),
828        ]:
829            with self.subTest(opened_on=opened_on):
830                LifecycleIteration.objects.filter(pk=iteration.pk).update(opened_on=opened_on)
831                with patch("django.utils.timezone.now", return_value=fixed_now):
832                    self.assertIn(app, list(rule._get_newly_due_objects()))
833
834    def test_not_due_object_iteration_opened_today(self):
835        """interval=1 day: object with iteration opened today is NOT due."""
836        app, rule, iteration = self._create_rule_and_iteration(interval="days=1")
837        fixed_now = dt.datetime(2025, 6, 15, 14, 30, 0, tzinfo=dt.UTC)
838        for opened_on in [
839            dt.datetime(2025, 6, 15, 0, 0, 0, tzinfo=dt.UTC),
840            dt.datetime(2025, 6, 15, 14, 30, 0, tzinfo=dt.UTC),
841            dt.datetime(2025, 6, 15, 23, 59, 59, 999999, tzinfo=dt.UTC),
842        ]:
843            with self.subTest(opened_on=opened_on):
844                LifecycleIteration.objects.filter(pk=iteration.pk).update(opened_on=opened_on)
845                with patch("django.utils.timezone.now", return_value=fixed_now):
846                    self.assertNotIn(app, list(rule._get_newly_due_objects()))
847
848    def test_due_independent_of_task_execution_time(self):
849        """Due detection gives the same result whether the task runs at 00:00:01 or 23:59:59."""
850        app, rule, iteration = self._create_rule_and_iteration(interval="days=1")
851        opened_on = dt.datetime(2025, 6, 14, 18, 0, 0, tzinfo=dt.UTC)
852        LifecycleIteration.objects.filter(pk=iteration.pk).update(opened_on=opened_on)
853        for task_time in [
854            dt.datetime(2025, 6, 15, 0, 0, 1, tzinfo=dt.UTC),
855            dt.datetime(2025, 6, 15, 12, 0, 0, tzinfo=dt.UTC),
856            dt.datetime(2025, 6, 15, 23, 59, 59, tzinfo=dt.UTC),
857        ]:
858            with self.subTest(task_time=task_time):
859                with patch("django.utils.timezone.now", return_value=task_time):
860                    self.assertIn(app, list(rule._get_newly_due_objects()))
861
862    def test_due_boundary_multi_day_interval(self):
863        """interval=30 days: due after 30 full days, not after 29."""
864        app, rule, iteration = self._create_rule_and_iteration(interval="days=30")
865        fixed_now = dt.datetime(2025, 6, 15, 14, 30, 0, tzinfo=dt.UTC)
866
867        # Previous review opened 30 days ago (May 16), review is due for the object
868        LifecycleIteration.objects.filter(pk=iteration.pk).update(
869            opened_on=dt.datetime(2025, 5, 16, 12, 0, 0, tzinfo=dt.UTC)
870        )
871        with patch("django.utils.timezone.now", return_value=fixed_now):
872            self.assertIn(app, list(rule._get_newly_due_objects()))
873
874        # Previous review opened 29 days ago (May 17), new review is NOT due
875        LifecycleIteration.objects.filter(pk=iteration.pk).update(
876            opened_on=dt.datetime(2025, 5, 17, 12, 0, 0, tzinfo=dt.UTC)
877        )
878        with patch("django.utils.timezone.now", return_value=fixed_now):
879            self.assertNotIn(app, list(rule._get_newly_due_objects()))
880
881    def test_apply_overdue_at_boundary(self):
882        """apply() marks iteration overdue when grace period just expired,
883        regardless of what time the daily task runs."""
884        _, rule, iteration = self._create_rule_and_iteration(
885            grace_period="days=1", interval="days=365"
886        )
887        opened_on = dt.datetime(2025, 6, 14, 20, 0, 0, tzinfo=dt.UTC)
888        for task_time in [
889            dt.datetime(2025, 6, 15, 0, 0, 1, tzinfo=dt.UTC),
890            dt.datetime(2025, 6, 15, 23, 59, 59, tzinfo=dt.UTC),
891        ]:
892            with self.subTest(task_time=task_time):
893                LifecycleIteration.objects.filter(pk=iteration.pk).update(
894                    opened_on=opened_on, state=ReviewState.PENDING
895                )
896                with patch("django.utils.timezone.now", return_value=task_time):
897                    rule.apply()
898                iteration.refresh_from_db()
899                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.

@classmethod
def setUpTestData(cls):
731    @classmethod
732    def setUpTestData(cls):
733        config = apps.get_app_config("authentik_tasks_schedules")
734        config._on_startup_callback(None)

Load initial data for the TestCase.

def test_overdue_iteration_opened_yesterday(self):
751    def test_overdue_iteration_opened_yesterday(self):
752        """grace_period=1 day: iteration opened yesterday at any time is overdue today."""
753        _, rule, iteration = self._create_rule_and_iteration(grace_period="days=1")
754        fixed_now = dt.datetime(2025, 6, 15, 14, 30, 0, tzinfo=dt.UTC)
755        for opened_on in [
756            dt.datetime(2025, 6, 14, 0, 0, 0, tzinfo=dt.UTC),
757            dt.datetime(2025, 6, 14, 12, 0, 0, tzinfo=dt.UTC),
758            dt.datetime(2025, 6, 14, 23, 59, 59, 999999, tzinfo=dt.UTC),
759        ]:
760            with self.subTest(opened_on=opened_on):
761                LifecycleIteration.objects.filter(pk=iteration.pk).update(
762                    opened_on=opened_on, state=ReviewState.PENDING
763                )
764                with patch("django.utils.timezone.now", return_value=fixed_now):
765                    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):
767    def test_not_overdue_iteration_opened_today(self):
768        """grace_period=1 day: iteration opened today at any time is NOT overdue."""
769        _, rule, iteration = self._create_rule_and_iteration(grace_period="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, 15, 0, 0, 0, tzinfo=dt.UTC),
773            dt.datetime(2025, 6, 15, 14, 30, 0, tzinfo=dt.UTC),
774            dt.datetime(2025, 6, 15, 23, 59, 59, 999999, tzinfo=dt.UTC),
775        ]:
776            with self.subTest(opened_on=opened_on):
777                LifecycleIteration.objects.filter(pk=iteration.pk).update(
778                    opened_on=opened_on, state=ReviewState.PENDING
779                )
780                with patch("django.utils.timezone.now", return_value=fixed_now):
781                    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):
783    def test_overdue_independent_of_task_execution_time(self):
784        """Overdue detection gives the same result whether the task runs at 00:00:01 or 23:59:59."""
785        _, rule, iteration = self._create_rule_and_iteration(grace_period="days=1")
786        opened_on = dt.datetime(2025, 6, 14, 18, 0, 0, tzinfo=dt.UTC)
787        LifecycleIteration.objects.filter(pk=iteration.pk).update(
788            opened_on=opened_on, state=ReviewState.PENDING
789        )
790        for task_time in [
791            dt.datetime(2025, 6, 15, 0, 0, 1, tzinfo=dt.UTC),
792            dt.datetime(2025, 6, 15, 12, 0, 0, tzinfo=dt.UTC),
793            dt.datetime(2025, 6, 15, 23, 59, 59, tzinfo=dt.UTC),
794        ]:
795            with self.subTest(task_time=task_time):
796                with patch("django.utils.timezone.now", return_value=task_time):
797                    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):
799    def test_overdue_boundary_multi_day_grace_period(self):
800        """grace_period=30 days: overdue after 30 full days, not after 29."""
801        _, rule, iteration = self._create_rule_and_iteration(grace_period="days=30")
802        fixed_now = dt.datetime(2025, 6, 15, 14, 30, 0, tzinfo=dt.UTC)
803
804        # Opened 30 days ago (May 16), should go overdue
805        LifecycleIteration.objects.filter(pk=iteration.pk).update(
806            opened_on=dt.datetime(2025, 5, 16, 12, 0, 0, tzinfo=dt.UTC),
807            state=ReviewState.PENDING,
808        )
809        with patch("django.utils.timezone.now", return_value=fixed_now):
810            self.assertIn(iteration, list(rule._get_newly_overdue_iterations()))
811
812        # Opened 29 days ago (May 17), should NOT go overdue
813        LifecycleIteration.objects.filter(pk=iteration.pk).update(
814            opened_on=dt.datetime(2025, 5, 17, 12, 0, 0, tzinfo=dt.UTC),
815            state=ReviewState.PENDING,
816        )
817        with patch("django.utils.timezone.now", return_value=fixed_now):
818            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):
820    def test_due_object_iteration_opened_yesterday(self):
821        """interval=1 day: object with iteration opened yesterday is due for a new review."""
822        app, rule, iteration = self._create_rule_and_iteration(interval="days=1")
823        fixed_now = dt.datetime(2025, 6, 15, 14, 30, 0, tzinfo=dt.UTC)
824        for opened_on in [
825            dt.datetime(2025, 6, 14, 0, 0, 0, tzinfo=dt.UTC),
826            dt.datetime(2025, 6, 14, 12, 0, 0, tzinfo=dt.UTC),
827            dt.datetime(2025, 6, 14, 23, 59, 59, 999999, tzinfo=dt.UTC),
828        ]:
829            with self.subTest(opened_on=opened_on):
830                LifecycleIteration.objects.filter(pk=iteration.pk).update(opened_on=opened_on)
831                with patch("django.utils.timezone.now", return_value=fixed_now):
832                    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):
834    def test_not_due_object_iteration_opened_today(self):
835        """interval=1 day: object with iteration opened today is NOT due."""
836        app, rule, iteration = self._create_rule_and_iteration(interval="days=1")
837        fixed_now = dt.datetime(2025, 6, 15, 14, 30, 0, tzinfo=dt.UTC)
838        for opened_on in [
839            dt.datetime(2025, 6, 15, 0, 0, 0, tzinfo=dt.UTC),
840            dt.datetime(2025, 6, 15, 14, 30, 0, tzinfo=dt.UTC),
841            dt.datetime(2025, 6, 15, 23, 59, 59, 999999, tzinfo=dt.UTC),
842        ]:
843            with self.subTest(opened_on=opened_on):
844                LifecycleIteration.objects.filter(pk=iteration.pk).update(opened_on=opened_on)
845                with patch("django.utils.timezone.now", return_value=fixed_now):
846                    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):
848    def test_due_independent_of_task_execution_time(self):
849        """Due detection gives the same result whether the task runs at 00:00:01 or 23:59:59."""
850        app, rule, iteration = self._create_rule_and_iteration(interval="days=1")
851        opened_on = dt.datetime(2025, 6, 14, 18, 0, 0, tzinfo=dt.UTC)
852        LifecycleIteration.objects.filter(pk=iteration.pk).update(opened_on=opened_on)
853        for task_time in [
854            dt.datetime(2025, 6, 15, 0, 0, 1, tzinfo=dt.UTC),
855            dt.datetime(2025, 6, 15, 12, 0, 0, tzinfo=dt.UTC),
856            dt.datetime(2025, 6, 15, 23, 59, 59, tzinfo=dt.UTC),
857        ]:
858            with self.subTest(task_time=task_time):
859                with patch("django.utils.timezone.now", return_value=task_time):
860                    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):
862    def test_due_boundary_multi_day_interval(self):
863        """interval=30 days: due after 30 full days, not after 29."""
864        app, rule, iteration = self._create_rule_and_iteration(interval="days=30")
865        fixed_now = dt.datetime(2025, 6, 15, 14, 30, 0, tzinfo=dt.UTC)
866
867        # Previous review opened 30 days ago (May 16), review is due for the object
868        LifecycleIteration.objects.filter(pk=iteration.pk).update(
869            opened_on=dt.datetime(2025, 5, 16, 12, 0, 0, tzinfo=dt.UTC)
870        )
871        with patch("django.utils.timezone.now", return_value=fixed_now):
872            self.assertIn(app, list(rule._get_newly_due_objects()))
873
874        # Previous review opened 29 days ago (May 17), new review is NOT due
875        LifecycleIteration.objects.filter(pk=iteration.pk).update(
876            opened_on=dt.datetime(2025, 5, 17, 12, 0, 0, tzinfo=dt.UTC)
877        )
878        with patch("django.utils.timezone.now", return_value=fixed_now):
879            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):
881    def test_apply_overdue_at_boundary(self):
882        """apply() marks iteration overdue when grace period just expired,
883        regardless of what time the daily task runs."""
884        _, rule, iteration = self._create_rule_and_iteration(
885            grace_period="days=1", interval="days=365"
886        )
887        opened_on = dt.datetime(2025, 6, 14, 20, 0, 0, tzinfo=dt.UTC)
888        for task_time in [
889            dt.datetime(2025, 6, 15, 0, 0, 1, tzinfo=dt.UTC),
890            dt.datetime(2025, 6, 15, 23, 59, 59, tzinfo=dt.UTC),
891        ]:
892            with self.subTest(task_time=task_time):
893                LifecycleIteration.objects.filter(pk=iteration.pk).update(
894                    opened_on=opened_on, state=ReviewState.PENDING
895                )
896                with patch("django.utils.timezone.now", return_value=task_time):
897                    rule.apply()
898                iteration.refresh_from_db()
899                self.assertEqual(iteration.state, ReviewState.OVERDUE)

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