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)
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.
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.
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.
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())
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.
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.
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)
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)
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)
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)
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)
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 )
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.
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 )
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
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)
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)
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))
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))
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))
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)
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)
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.