authentik.core.tests.test_users_api

Test Users API

  1"""Test Users API"""
  2
  3from datetime import datetime, timedelta
  4from json import loads
  5
  6from django.urls.base import reverse
  7from django.utils.timezone import now
  8from rest_framework.test import APITestCase
  9
 10from authentik.brands.models import Brand
 11from authentik.core.models import (
 12    USER_ATTRIBUTE_TOKEN_EXPIRING,
 13    AuthenticatedSession,
 14    Session,
 15    Token,
 16    User,
 17    UserTypes,
 18)
 19from authentik.core.tests.utils import (
 20    create_test_admin_user,
 21    create_test_brand,
 22    create_test_flow,
 23    create_test_user,
 24)
 25from authentik.flows.models import FlowAuthenticationRequirement, FlowDesignation
 26from authentik.lib.generators import generate_id, generate_key
 27from authentik.stages.email.models import EmailStage
 28
 29
 30class TestUsersAPI(APITestCase):
 31    """Test Users API"""
 32
 33    def setUp(self) -> None:
 34        self.admin = create_test_admin_user()
 35        self.user = create_test_user()
 36
 37    def test_filter_type(self):
 38        """Test API filtering by type"""
 39        self.client.force_login(self.admin)
 40        user = create_test_admin_user(type=UserTypes.EXTERNAL)
 41        response = self.client.get(
 42            reverse("authentik_api:user-list"),
 43            data={
 44                "type": UserTypes.EXTERNAL,
 45                "username": user.username,
 46            },
 47        )
 48        self.assertEqual(response.status_code, 200)
 49
 50    def test_filter_is_superuser(self):
 51        """Test API filtering by superuser status"""
 52        User.objects.all().delete()
 53        admin = create_test_admin_user()
 54        self.client.force_login(admin)
 55        # Test superuser
 56        response = self.client.get(
 57            reverse("authentik_api:user-list"),
 58            data={
 59                "is_superuser": True,
 60            },
 61        )
 62        self.assertEqual(response.status_code, 200)
 63        body = loads(response.content)
 64        self.assertEqual(len(body["results"]), 1)
 65        self.assertEqual(body["results"][0]["username"], admin.username)
 66        # Test non-superuser
 67        user = create_test_user()
 68        response = self.client.get(
 69            reverse("authentik_api:user-list"),
 70            data={
 71                "is_superuser": False,
 72            },
 73        )
 74        self.assertEqual(response.status_code, 200)
 75        body = loads(response.content)
 76        self.assertEqual(len(body["results"]), 1, body)
 77        self.assertEqual(body["results"][0]["username"], user.username)
 78
 79    def test_list_with_groups(self):
 80        """Test listing with groups"""
 81        self.client.force_login(self.admin)
 82        response = self.client.get(reverse("authentik_api:user-list"), {"include_groups": "true"})
 83        self.assertEqual(response.status_code, 200)
 84
 85    def test_recovery_no_flow(self):
 86        """Test user recovery link (no recovery flow set)"""
 87        self.client.force_login(self.admin)
 88        response = self.client.post(
 89            reverse("authentik_api:user-recovery", kwargs={"pk": self.user.pk})
 90        )
 91        self.assertEqual(response.status_code, 400)
 92        self.assertJSONEqual(response.content, {"non_field_errors": "No recovery flow set."})
 93
 94    def test_set_password(self):
 95        """Test Direct password set"""
 96        self.client.force_login(self.admin)
 97        new_pw = generate_key()
 98        response = self.client.post(
 99            reverse("authentik_api:user-set-password", kwargs={"pk": self.admin.pk}),
100            data={"password": new_pw},
101        )
102        self.assertEqual(response.status_code, 204)
103        self.admin.refresh_from_db()
104        self.assertTrue(self.admin.check_password(new_pw))
105
106    def test_set_password_blank(self):
107        """Test Direct password set"""
108        self.client.force_login(self.admin)
109        response = self.client.post(
110            reverse("authentik_api:user-set-password", kwargs={"pk": self.admin.pk}),
111            data={"password": ""},
112        )
113        self.assertEqual(response.status_code, 400)
114        self.assertJSONEqual(response.content, {"password": ["This field may not be blank."]})
115
116    def test_recovery(self):
117        """Test user recovery link"""
118        flow = create_test_flow(
119            FlowDesignation.RECOVERY,
120            authentication=FlowAuthenticationRequirement.REQUIRE_UNAUTHENTICATED,
121        )
122        brand: Brand = create_test_brand()
123        brand.flow_recovery = flow
124        brand.save()
125        self.client.force_login(self.admin)
126        response = self.client.post(
127            reverse("authentik_api:user-recovery", kwargs={"pk": self.user.pk})
128        )
129        self.assertEqual(response.status_code, 200)
130
131    def test_recovery_duration(self):
132        """Test user recovery token duration"""
133        Token.objects.all().delete()
134        flow = create_test_flow(
135            FlowDesignation.RECOVERY,
136            authentication=FlowAuthenticationRequirement.REQUIRE_UNAUTHENTICATED,
137        )
138        brand: Brand = create_test_brand()
139        brand.flow_recovery = flow
140        brand.save()
141        self.client.force_login(self.admin)
142        response = self.client.post(
143            reverse("authentik_api:user-recovery", kwargs={"pk": self.user.pk}),
144            data={"token_duration": "days=33"},
145        )
146        self.assertEqual(response.status_code, 200)
147        expires = Token.objects.first().expires
148        expected_expires = now() + timedelta(days=33)
149        self.assertTrue(timedelta(minutes=-1) < expected_expires - expires < timedelta(minutes=1))
150
151    def test_recovery_duration_update(self):
152        """Test user recovery token duration update"""
153        Token.objects.all().delete()
154        flow = create_test_flow(
155            FlowDesignation.RECOVERY,
156            authentication=FlowAuthenticationRequirement.REQUIRE_UNAUTHENTICATED,
157        )
158        brand: Brand = create_test_brand()
159        brand.flow_recovery = flow
160        brand.save()
161        self.client.force_login(self.admin)
162        response = self.client.post(
163            reverse("authentik_api:user-recovery", kwargs={"pk": self.user.pk}),
164            data={"token_duration": "days=33"},
165        )
166        self.assertEqual(response.status_code, 200)
167        expires = Token.objects.first().expires
168        expected_expires = now() + timedelta(days=33)
169        self.assertTrue(timedelta(minutes=-1) < expected_expires - expires < timedelta(minutes=1))
170        response = self.client.post(
171            reverse("authentik_api:user-recovery", kwargs={"pk": self.user.pk}),
172            data={"token_duration": "days=66"},
173        )
174        expires = Token.objects.first().expires
175        expected_expires = now() + timedelta(days=66)
176        self.assertTrue(timedelta(minutes=-1) < expected_expires - expires < timedelta(minutes=1))
177
178    def test_recovery_email_no_flow(self):
179        """Test user recovery link (no recovery flow set)"""
180        self.client.force_login(self.admin)
181        self.user.email = ""
182        self.user.save()
183        stage = EmailStage.objects.create(name="email")
184        response = self.client.post(
185            reverse("authentik_api:user-recovery-email", kwargs={"pk": self.user.pk}),
186            data={"email_stage": stage.pk},
187        )
188        self.assertEqual(response.status_code, 400)
189        self.assertJSONEqual(
190            response.content, {"non_field_errors": "User does not have an email address set."}
191        )
192        self.user.email = "foo@bar.baz"
193        self.user.save()
194        response = self.client.post(
195            reverse("authentik_api:user-recovery-email", kwargs={"pk": self.user.pk}),
196            data={"email_stage": stage.pk},
197        )
198        self.assertEqual(response.status_code, 400)
199        self.assertJSONEqual(response.content, {"non_field_errors": "No recovery flow set."})
200
201    def test_recovery_email_no_stage(self):
202        """Test user recovery link (no email stage)"""
203        self.user.email = "foo@bar.baz"
204        self.user.save()
205        flow = create_test_flow(designation=FlowDesignation.RECOVERY)
206        brand: Brand = create_test_brand()
207        brand.flow_recovery = flow
208        brand.save()
209        self.client.force_login(self.admin)
210        response = self.client.post(
211            reverse("authentik_api:user-recovery-email", kwargs={"pk": self.user.pk})
212        )
213        self.assertEqual(response.status_code, 400)
214        self.assertJSONEqual(response.content, {"email_stage": ["This field is required."]})
215
216    def test_recovery_email(self):
217        """Test user recovery link"""
218        self.user.email = "foo@bar.baz"
219        self.user.save()
220        flow = create_test_flow(FlowDesignation.RECOVERY)
221        brand: Brand = create_test_brand()
222        brand.flow_recovery = flow
223        brand.save()
224
225        stage = EmailStage.objects.create(name="email")
226
227        self.client.force_login(self.admin)
228        response = self.client.post(
229            reverse(
230                "authentik_api:user-recovery-email",
231                kwargs={"pk": self.user.pk},
232            ),
233            data={"email_stage": stage.pk},
234        )
235        self.assertEqual(response.status_code, 204)
236
237    def test_service_account(self):
238        """Service account creation"""
239        self.client.force_login(self.admin)
240        response = self.client.post(reverse("authentik_api:user-service-account"))
241        self.assertEqual(response.status_code, 400)
242        response = self.client.post(
243            reverse("authentik_api:user-service-account"),
244            data={
245                "name": "test-sa",
246                "create_group": True,
247            },
248        )
249        self.assertEqual(response.status_code, 200)
250
251        user_filter = User.objects.filter(
252            username="test-sa",
253            type=UserTypes.SERVICE_ACCOUNT,
254            attributes={USER_ATTRIBUTE_TOKEN_EXPIRING: True},
255        )
256        self.assertTrue(user_filter.exists())
257        user: User = user_filter.first()
258        self.assertFalse(user.has_usable_password())
259
260        token_filter = Token.objects.filter(user=user)
261        self.assertTrue(token_filter.exists())
262        self.assertTrue(token_filter.first().expiring)
263
264    def test_service_account_no_expire(self):
265        """Service account creation without token expiration"""
266        self.client.force_login(self.admin)
267        response = self.client.post(
268            reverse("authentik_api:user-service-account"),
269            data={
270                "name": "test-sa",
271                "create_group": True,
272                "expiring": False,
273            },
274        )
275        self.assertEqual(response.status_code, 200)
276
277        user_filter = User.objects.filter(
278            username="test-sa",
279            type=UserTypes.SERVICE_ACCOUNT,
280            attributes={USER_ATTRIBUTE_TOKEN_EXPIRING: False},
281        )
282        self.assertTrue(user_filter.exists())
283        user: User = user_filter.first()
284        self.assertFalse(user.has_usable_password())
285
286        token_filter = Token.objects.filter(user=user)
287        self.assertTrue(token_filter.exists())
288        self.assertFalse(token_filter.first().expiring)
289
290    def test_service_account_with_custom_expire(self):
291        """Service account creation with custom token expiration date"""
292        self.client.force_login(self.admin)
293        expire_on = datetime(2050, 11, 11, 11, 11, 11).astimezone()
294        response = self.client.post(
295            reverse("authentik_api:user-service-account"),
296            data={
297                "name": "test-sa",
298                "create_group": True,
299                "expires": expire_on.isoformat(),
300            },
301        )
302        self.assertEqual(response.status_code, 200)
303
304        user_filter = User.objects.filter(
305            username="test-sa",
306            type=UserTypes.SERVICE_ACCOUNT,
307            attributes={USER_ATTRIBUTE_TOKEN_EXPIRING: True},
308        )
309        self.assertTrue(user_filter.exists())
310        user: User = user_filter.first()
311        self.assertFalse(user.has_usable_password())
312
313        token_filter = Token.objects.filter(user=user)
314        self.assertTrue(token_filter.exists())
315        token = token_filter.first()
316        self.assertTrue(token.expiring)
317        self.assertEqual(token.expires, expire_on)
318
319    def test_service_account_invalid(self):
320        """Service account creation (twice with same name, expect error)"""
321        self.client.force_login(self.admin)
322        response = self.client.post(
323            reverse("authentik_api:user-service-account"),
324            data={
325                "name": "test-sa",
326                "create_group": True,
327            },
328        )
329        self.assertEqual(response.status_code, 200)
330
331        user_filter = User.objects.filter(
332            username="test-sa",
333            type=UserTypes.SERVICE_ACCOUNT,
334            attributes={USER_ATTRIBUTE_TOKEN_EXPIRING: True},
335        )
336        self.assertTrue(user_filter.exists())
337        user: User = user_filter.first()
338        self.assertFalse(user.has_usable_password())
339
340        token_filter = Token.objects.filter(user=user)
341        self.assertTrue(token_filter.exists())
342        self.assertTrue(token_filter.first().expiring)
343
344        response = self.client.post(
345            reverse("authentik_api:user-service-account"),
346            data={
347                "name": "test-sa",
348                "create_group": True,
349            },
350        )
351        self.assertEqual(response.status_code, 400)
352
353    def test_paths(self):
354        """Test path"""
355        self.client.force_login(self.admin)
356        response = self.client.get(
357            reverse("authentik_api:user-paths"),
358        )
359        self.assertEqual(response.status_code, 200)
360        expected = list(
361            User.objects.all()
362            .values("path")
363            .distinct()
364            .order_by("path")
365            .values_list("path", flat=True)
366        )
367        self.assertJSONEqual(response.content.decode(), {"paths": expected})
368
369    def test_path_valid(self):
370        """Test path"""
371        self.client.force_login(self.admin)
372        response = self.client.post(
373            reverse("authentik_api:user-list"),
374            data={"name": generate_id(), "username": generate_id(), "groups": [], "path": "foo"},
375        )
376        self.assertEqual(response.status_code, 201)
377
378    def test_path_invalid(self):
379        """Test path (invalid)"""
380        self.client.force_login(self.admin)
381        response = self.client.post(
382            reverse("authentik_api:user-list"),
383            data={"name": generate_id(), "username": generate_id(), "groups": [], "path": "/foo"},
384        )
385        self.assertEqual(response.status_code, 400)
386        self.assertJSONEqual(
387            response.content.decode(), {"path": ["No leading or trailing slashes allowed."]}
388        )
389
390        self.client.force_login(self.admin)
391        response = self.client.post(
392            reverse("authentik_api:user-list"),
393            data={"name": generate_id(), "username": generate_id(), "groups": [], "path": ""},
394        )
395        self.assertEqual(response.status_code, 400)
396        self.assertJSONEqual(response.content.decode(), {"path": ["This field may not be blank."]})
397
398        response = self.client.post(
399            reverse("authentik_api:user-list"),
400            data={"name": generate_id(), "username": generate_id(), "groups": [], "path": "foo/"},
401        )
402        self.assertEqual(response.status_code, 400)
403        self.assertJSONEqual(
404            response.content.decode(), {"path": ["No leading or trailing slashes allowed."]}
405        )
406
407        response = self.client.post(
408            reverse("authentik_api:user-list"),
409            data={
410                "name": generate_id(),
411                "username": generate_id(),
412                "groups": [],
413                "path": "fos//o",
414            },
415        )
416        self.assertEqual(response.status_code, 400)
417        self.assertJSONEqual(
418            response.content.decode(), {"path": ["No empty segments in user path allowed."]}
419        )
420
421    def test_me(self):
422        """Test user's me endpoint"""
423        self.client.force_login(self.admin)
424        response = self.client.get(reverse("authentik_api:user-me"))
425        self.assertEqual(response.status_code, 200)
426
427    def test_session_delete(self):
428        """Ensure sessions are deleted when a user is deactivated"""
429        user = create_test_admin_user()
430        session_id = generate_id()
431        session = Session.objects.create(
432            session_key=session_id,
433            last_ip="255.255.255.255",
434            last_user_agent="",
435        )
436        AuthenticatedSession.objects.create(
437            session=session,
438            user=user,
439        )
440
441        self.client.force_login(self.admin)
442        response = self.client.patch(
443            reverse("authentik_api:user-detail", kwargs={"pk": user.pk}),
444            data={
445                "is_active": False,
446            },
447        )
448        self.assertEqual(response.status_code, 200)
449
450        self.assertFalse(Session.objects.filter(session_key=session_id).exists())
451        self.assertFalse(
452            AuthenticatedSession.objects.filter(session__session_key=session_id).exists()
453        )
454
455    def test_sort_by_last_updated(self):
456        """Test API sorting by last_updated"""
457        User.objects.all().delete()
458        admin = create_test_admin_user()
459        self.client.force_login(admin)
460
461        user = create_test_user()
462        admin.first_name = "Sample change"
463        admin.last_name = "To trigger an update"
464        admin.save()
465
466        # Ascending
467        response = self.client.get(
468            reverse("authentik_api:user-list"),
469            data={
470                "ordering": "last_updated",
471            },
472        )
473        self.assertEqual(response.status_code, 200)
474
475        body = loads(response.content)
476        self.assertEqual(len(body["results"]), 2)
477        self.assertEqual(body["results"][0]["pk"], user.pk)
478
479        # Descending
480        response = self.client.get(
481            reverse("authentik_api:user-list"),
482            data={
483                "ordering": "-last_updated",
484            },
485        )
486        self.assertEqual(response.status_code, 200)
487
488        body = loads(response.content)
489        self.assertEqual(len(body["results"]), 2)
490        self.assertEqual(body["results"][0]["pk"], admin.pk)
491
492    def test_sort_by_date_joined(self):
493        """Test API sorting by date_joined"""
494        User.objects.all().delete()
495        admin = create_test_admin_user()
496        self.client.force_login(admin)
497
498        user = create_test_user()
499
500        response = self.client.get(
501            reverse("authentik_api:user-list"),
502            data={
503                "ordering": "date_joined",
504            },
505        )
506        self.assertEqual(response.status_code, 200)
507
508        body = loads(response.content)
509        self.assertEqual(len(body["results"]), 2)
510        self.assertEqual(body["results"][0]["pk"], admin.pk)
511
512        response = self.client.get(
513            reverse("authentik_api:user-list"),
514            data={
515                "ordering": "-date_joined",
516            },
517        )
518        self.assertEqual(response.status_code, 200)
519
520        body = loads(response.content)
521        self.assertEqual(len(body["results"]), 2)
522        self.assertEqual(body["results"][0]["pk"], user.pk)
523
524    def test_service_account_validation_empty_username(self):
525        """Test service account creation with empty/blank username validation"""
526        self.client.force_login(self.admin)
527
528        # Test with empty string
529        response = self.client.post(
530            reverse("authentik_api:user-service-account"),
531            data={
532                "name": "",
533                "create_group": True,
534            },
535        )
536        self.assertEqual(response.status_code, 400)
537        self.assertJSONEqual(
538            response.content,
539            {"name": ["This field may not be blank."]},
540        )
541
542        # Test with only whitespace
543        response = self.client.post(
544            reverse("authentik_api:user-service-account"),
545            data={
546                "name": "   ",
547                "create_group": True,
548            },
549        )
550        self.assertEqual(response.status_code, 400)
551        self.assertJSONEqual(
552            response.content,
553            {"name": ["This field may not be blank."]},
554        )
555
556        # Test with tab and newline characters
557        response = self.client.post(
558            reverse("authentik_api:user-service-account"),
559            data={
560                "name": "\t\n",
561                "create_group": True,
562            },
563        )
564        self.assertEqual(response.status_code, 400)
565        self.assertJSONEqual(
566            response.content,
567            {"name": ["This field may not be blank."]},
568        )
569
570    def test_service_account_validation_valid_username(self):
571        """Test service account creation with valid username"""
572        self.client.force_login(self.admin)
573
574        # Test with valid username
575        response = self.client.post(
576            reverse("authentik_api:user-service-account"),
577            data={
578                "name": "valid-service-account",
579                "create_group": True,
580            },
581        )
582        self.assertEqual(response.status_code, 200)
583
584        # Verify response structure
585        body = loads(response.content)
586        self.assertIn("username", body)
587        self.assertIn("user_uid", body)
588        self.assertIn("user_pk", body)
589        self.assertIn("group_pk", body)  # Should exist since create_group=True
590        self.assertIn("token", body)
591
592        # Verify field types
593        self.assertEqual(body["username"], "valid-service-account")
594        self.assertIsInstance(body["user_pk"], int)
595        self.assertIsInstance(body["user_uid"], str)
596        self.assertIsInstance(body["token"], str)
597        self.assertIsInstance(body["group_pk"], str)
598
599    def test_service_account_validation_without_group(self):
600        """Test service account creation without creating a group"""
601        self.client.force_login(self.admin)
602
603        response = self.client.post(
604            reverse("authentik_api:user-service-account"),
605            data={
606                "name": "no-group-service-account",
607                "create_group": False,
608            },
609        )
610        self.assertEqual(response.status_code, 200)
611
612        body = loads(response.content)
613        self.assertIn("username", body)
614        self.assertIn("user_uid", body)
615        self.assertIn("user_pk", body)
616        self.assertIn("token", body)
617        # Should NOT have group_pk when create_group=False
618        self.assertNotIn("group_pk", body)
619
620    def test_service_account_validation_duplicate_username(self):
621        """Test service account creation with duplicate username"""
622        self.client.force_login(self.admin)
623
624        # Create first service account
625        response = self.client.post(
626            reverse("authentik_api:user-service-account"),
627            data={
628                "name": "duplicate-test",
629                "create_group": True,
630            },
631        )
632        self.assertEqual(response.status_code, 200)
633
634        # Attempt to create second with same username
635        response = self.client.post(
636            reverse("authentik_api:user-service-account"),
637            data={
638                "name": "duplicate-test",
639                "create_group": True,
640            },
641        )
642        self.assertEqual(response.status_code, 400)
643        self.assertJSONEqual(
644            response.content,
645            {"name": ["This field must be unique."]},
646        )
647
648    def test_service_account_validation_invalid_create_group(self):
649        """Test service account creation with invalid create_group field"""
650        self.client.force_login(self.admin)
651
652        # Test with string instead of boolean
653        response = self.client.post(
654            reverse("authentik_api:user-service-account"),
655            data={
656                "name": "test-sa",
657                "create_group": "invalid",
658            },
659        )
660        self.assertEqual(response.status_code, 400)
661        self.assertJSONEqual(
662            response.content,
663            {"create_group": ["Must be a valid boolean."]},
664        )
665
666        # Test with number instead of boolean
667        response = self.client.post(
668            reverse("authentik_api:user-service-account"),
669            data={
670                "name": "test-sa",
671                "create_group": 123,
672            },
673        )
674        self.assertEqual(response.status_code, 400)
675        self.assertJSONEqual(
676            response.content,
677            {"create_group": ["Must be a valid boolean."]},
678        )
679
680    def test_service_account_validation_invalid_expiring(self):
681        """Test service account creation with invalid expiring field"""
682        self.client.force_login(self.admin)
683
684        # Test with string instead of boolean
685        response = self.client.post(
686            reverse("authentik_api:user-service-account"),
687            data={
688                "name": "test-sa",
689                "expiring": "invalid",
690            },
691        )
692        self.assertEqual(response.status_code, 400)
693        self.assertJSONEqual(
694            response.content,
695            {"expiring": ["Must be a valid boolean."]},
696        )
697
698    def test_service_account_validation_invalid_expires(self):
699        """Test service account creation with invalid expires field"""
700        self.client.force_login(self.admin)
701
702        # Test with invalid datetime string
703        response = self.client.post(
704            reverse("authentik_api:user-service-account"),
705            data={
706                "name": "test-sa",
707                "expires": "invalid-datetime",
708            },
709        )
710        self.assertEqual(response.status_code, 400)
711        self.assertJSONEqual(
712            response.content,
713            {
714                "expires": [
715                    "Datetime has wrong format. Use one of these formats instead: "
716                    "YYYY-MM-DDThh:mm[:ss[.uuuuuu]][+HH:MM|-HH:MM|Z]."
717                ]
718            },
719        )
720
721        # Test with invalid format
722        response = self.client.post(
723            reverse("authentik_api:user-service-account"),
724            data={
725                "name": "test-sa",
726                "expires": "2024-13-45",  # Invalid month/day
727            },
728        )
729        self.assertEqual(response.status_code, 400)
730        self.assertJSONEqual(
731            response.content,
732            {
733                "expires": [
734                    "Datetime has wrong format. Use one of these formats instead: "
735                    "YYYY-MM-DDThh:mm[:ss[.uuuuuu]][+HH:MM|-HH:MM|Z]."
736                ]
737            },
738        )
739
740    def test_service_account_validation_multiple_errors(self):
741        """Test service account creation with multiple validation errors"""
742        self.client.force_login(self.admin)
743
744        response = self.client.post(
745            reverse("authentik_api:user-service-account"),
746            data={
747                "name": "",  # Empty username
748                "create_group": "invalid",  # Invalid boolean
749                "expiring": 123,  # Invalid boolean
750                "expires": "not-a-date",  # Invalid datetime
751            },
752        )
753        self.assertEqual(response.status_code, 400)
754        self.assertJSONEqual(
755            response.content,
756            {
757                "name": ["This field may not be blank."],
758                "create_group": ["Must be a valid boolean."],
759                "expiring": ["Must be a valid boolean."],
760                "expires": [
761                    "Datetime has wrong format. Use one of these formats instead: "
762                    "YYYY-MM-DDThh:mm[:ss[.uuuuuu]][+HH:MM|-HH:MM|Z]."
763                ],
764            },
765        )
766
767    def test_service_account_validation_user_friendly_duplicate_error(self):
768        """Test that duplicate username returns user-friendly error, not database error"""
769        self.client.force_login(self.admin)
770
771        # Create first service account
772        response = self.client.post(
773            reverse("authentik_api:user-service-account"),
774            data={
775                "name": "duplicate-username-test",
776                "create_group": True,
777            },
778        )
779        self.assertEqual(response.status_code, 200)
780
781        # Attempt to create second with same username
782        response = self.client.post(
783            reverse("authentik_api:user-service-account"),
784            data={
785                "name": "duplicate-username-test",
786                "create_group": True,
787            },
788        )
789        self.assertEqual(response.status_code, 400)
790        self.assertJSONEqual(
791            response.content,
792            {"name": ["This field must be unique."]},
793        )
794
795    def test_filter_last_login(self):
796        """Test API filtering by last_login"""
797        from datetime import timedelta
798
799        from django.utils import timezone
800
801        User.objects.all().delete()
802        admin = create_test_admin_user()
803        self.client.force_login(admin)
804
805        # Create users with different last_login values
806        user_recent = create_test_user()
807        user_recent.last_login = timezone.now()
808        user_recent.save()
809
810        user_old = create_test_user()
811        user_old.last_login = timezone.now() - timedelta(days=400)  # Over 1 year ago
812        user_old.save()
813
814        user_never = create_test_user()
815        user_never.last_login = None  # Never logged in
816        user_never.save()
817
818        # Filter users who logged in before 1 year ago
819        one_year_ago = (timezone.now() - timedelta(days=365)).isoformat()
820        response = self.client.get(
821            reverse("authentik_api:user-list"),
822            data={"last_login__lt": one_year_ago},
823        )
824        self.assertEqual(response.status_code, 200)
825        body = loads(response.content)
826        self.assertEqual(len(body["results"]), 1)
827        self.assertEqual(body["results"][0]["pk"], user_old.pk)
828
829        # Filter users who have never logged in
830        response = self.client.get(
831            reverse("authentik_api:user-list"),
832            data={"last_login__isnull": True},
833        )
834        self.assertEqual(response.status_code, 200)
835        body = loads(response.content)
836        # Should include user_never and admin (who hasn't logged in via the app)
837        pks = [r["pk"] for r in body["results"]]
838        self.assertIn(user_never.pk, pks)
839
840    def test_sort_by_last_login(self):
841        """Test API sorting by last_login"""
842        from datetime import timedelta
843
844        from django.utils import timezone
845
846        User.objects.all().delete()
847        admin = create_test_admin_user()
848        self.client.force_login(admin)
849
850        user1 = create_test_user()
851        user1.last_login = timezone.now() - timedelta(days=10)
852        user1.save()
853
854        user2 = create_test_user()
855        user2.last_login = timezone.now() - timedelta(days=5)
856        user2.save()
857
858        # Ascending order (oldest first)
859        response = self.client.get(
860            reverse("authentik_api:user-list"),
861            data={"ordering": "last_login"},
862        )
863        self.assertEqual(response.status_code, 200)
864        body = loads(response.content)
865        # Users with null last_login come first, then user1 (older), then user2 (newer)
866        self.assertEqual(len(body["results"]), 3)
867
868        # Descending order (newest first)
869        response = self.client.get(
870            reverse("authentik_api:user-list"),
871            data={"ordering": "-last_login"},
872        )
873        self.assertEqual(response.status_code, 200)
874        body = loads(response.content)
875        # user2 should come before user1 (more recent login)
876        pks = [r["pk"] for r in body["results"]]
877        self.assertIn(user1.pk, pks)
878        self.assertIn(user2.pk, pks)
879        # Verify user2 comes before user1 in descending order
880        self.assertLess(pks.index(user2.pk), pks.index(user1.pk))
class TestUsersAPI(rest_framework.test.APITestCase):
 31class TestUsersAPI(APITestCase):
 32    """Test Users API"""
 33
 34    def setUp(self) -> None:
 35        self.admin = create_test_admin_user()
 36        self.user = create_test_user()
 37
 38    def test_filter_type(self):
 39        """Test API filtering by type"""
 40        self.client.force_login(self.admin)
 41        user = create_test_admin_user(type=UserTypes.EXTERNAL)
 42        response = self.client.get(
 43            reverse("authentik_api:user-list"),
 44            data={
 45                "type": UserTypes.EXTERNAL,
 46                "username": user.username,
 47            },
 48        )
 49        self.assertEqual(response.status_code, 200)
 50
 51    def test_filter_is_superuser(self):
 52        """Test API filtering by superuser status"""
 53        User.objects.all().delete()
 54        admin = create_test_admin_user()
 55        self.client.force_login(admin)
 56        # Test superuser
 57        response = self.client.get(
 58            reverse("authentik_api:user-list"),
 59            data={
 60                "is_superuser": True,
 61            },
 62        )
 63        self.assertEqual(response.status_code, 200)
 64        body = loads(response.content)
 65        self.assertEqual(len(body["results"]), 1)
 66        self.assertEqual(body["results"][0]["username"], admin.username)
 67        # Test non-superuser
 68        user = create_test_user()
 69        response = self.client.get(
 70            reverse("authentik_api:user-list"),
 71            data={
 72                "is_superuser": False,
 73            },
 74        )
 75        self.assertEqual(response.status_code, 200)
 76        body = loads(response.content)
 77        self.assertEqual(len(body["results"]), 1, body)
 78        self.assertEqual(body["results"][0]["username"], user.username)
 79
 80    def test_list_with_groups(self):
 81        """Test listing with groups"""
 82        self.client.force_login(self.admin)
 83        response = self.client.get(reverse("authentik_api:user-list"), {"include_groups": "true"})
 84        self.assertEqual(response.status_code, 200)
 85
 86    def test_recovery_no_flow(self):
 87        """Test user recovery link (no recovery flow set)"""
 88        self.client.force_login(self.admin)
 89        response = self.client.post(
 90            reverse("authentik_api:user-recovery", kwargs={"pk": self.user.pk})
 91        )
 92        self.assertEqual(response.status_code, 400)
 93        self.assertJSONEqual(response.content, {"non_field_errors": "No recovery flow set."})
 94
 95    def test_set_password(self):
 96        """Test Direct password set"""
 97        self.client.force_login(self.admin)
 98        new_pw = generate_key()
 99        response = self.client.post(
100            reverse("authentik_api:user-set-password", kwargs={"pk": self.admin.pk}),
101            data={"password": new_pw},
102        )
103        self.assertEqual(response.status_code, 204)
104        self.admin.refresh_from_db()
105        self.assertTrue(self.admin.check_password(new_pw))
106
107    def test_set_password_blank(self):
108        """Test Direct password set"""
109        self.client.force_login(self.admin)
110        response = self.client.post(
111            reverse("authentik_api:user-set-password", kwargs={"pk": self.admin.pk}),
112            data={"password": ""},
113        )
114        self.assertEqual(response.status_code, 400)
115        self.assertJSONEqual(response.content, {"password": ["This field may not be blank."]})
116
117    def test_recovery(self):
118        """Test user recovery link"""
119        flow = create_test_flow(
120            FlowDesignation.RECOVERY,
121            authentication=FlowAuthenticationRequirement.REQUIRE_UNAUTHENTICATED,
122        )
123        brand: Brand = create_test_brand()
124        brand.flow_recovery = flow
125        brand.save()
126        self.client.force_login(self.admin)
127        response = self.client.post(
128            reverse("authentik_api:user-recovery", kwargs={"pk": self.user.pk})
129        )
130        self.assertEqual(response.status_code, 200)
131
132    def test_recovery_duration(self):
133        """Test user recovery token duration"""
134        Token.objects.all().delete()
135        flow = create_test_flow(
136            FlowDesignation.RECOVERY,
137            authentication=FlowAuthenticationRequirement.REQUIRE_UNAUTHENTICATED,
138        )
139        brand: Brand = create_test_brand()
140        brand.flow_recovery = flow
141        brand.save()
142        self.client.force_login(self.admin)
143        response = self.client.post(
144            reverse("authentik_api:user-recovery", kwargs={"pk": self.user.pk}),
145            data={"token_duration": "days=33"},
146        )
147        self.assertEqual(response.status_code, 200)
148        expires = Token.objects.first().expires
149        expected_expires = now() + timedelta(days=33)
150        self.assertTrue(timedelta(minutes=-1) < expected_expires - expires < timedelta(minutes=1))
151
152    def test_recovery_duration_update(self):
153        """Test user recovery token duration update"""
154        Token.objects.all().delete()
155        flow = create_test_flow(
156            FlowDesignation.RECOVERY,
157            authentication=FlowAuthenticationRequirement.REQUIRE_UNAUTHENTICATED,
158        )
159        brand: Brand = create_test_brand()
160        brand.flow_recovery = flow
161        brand.save()
162        self.client.force_login(self.admin)
163        response = self.client.post(
164            reverse("authentik_api:user-recovery", kwargs={"pk": self.user.pk}),
165            data={"token_duration": "days=33"},
166        )
167        self.assertEqual(response.status_code, 200)
168        expires = Token.objects.first().expires
169        expected_expires = now() + timedelta(days=33)
170        self.assertTrue(timedelta(minutes=-1) < expected_expires - expires < timedelta(minutes=1))
171        response = self.client.post(
172            reverse("authentik_api:user-recovery", kwargs={"pk": self.user.pk}),
173            data={"token_duration": "days=66"},
174        )
175        expires = Token.objects.first().expires
176        expected_expires = now() + timedelta(days=66)
177        self.assertTrue(timedelta(minutes=-1) < expected_expires - expires < timedelta(minutes=1))
178
179    def test_recovery_email_no_flow(self):
180        """Test user recovery link (no recovery flow set)"""
181        self.client.force_login(self.admin)
182        self.user.email = ""
183        self.user.save()
184        stage = EmailStage.objects.create(name="email")
185        response = self.client.post(
186            reverse("authentik_api:user-recovery-email", kwargs={"pk": self.user.pk}),
187            data={"email_stage": stage.pk},
188        )
189        self.assertEqual(response.status_code, 400)
190        self.assertJSONEqual(
191            response.content, {"non_field_errors": "User does not have an email address set."}
192        )
193        self.user.email = "foo@bar.baz"
194        self.user.save()
195        response = self.client.post(
196            reverse("authentik_api:user-recovery-email", kwargs={"pk": self.user.pk}),
197            data={"email_stage": stage.pk},
198        )
199        self.assertEqual(response.status_code, 400)
200        self.assertJSONEqual(response.content, {"non_field_errors": "No recovery flow set."})
201
202    def test_recovery_email_no_stage(self):
203        """Test user recovery link (no email stage)"""
204        self.user.email = "foo@bar.baz"
205        self.user.save()
206        flow = create_test_flow(designation=FlowDesignation.RECOVERY)
207        brand: Brand = create_test_brand()
208        brand.flow_recovery = flow
209        brand.save()
210        self.client.force_login(self.admin)
211        response = self.client.post(
212            reverse("authentik_api:user-recovery-email", kwargs={"pk": self.user.pk})
213        )
214        self.assertEqual(response.status_code, 400)
215        self.assertJSONEqual(response.content, {"email_stage": ["This field is required."]})
216
217    def test_recovery_email(self):
218        """Test user recovery link"""
219        self.user.email = "foo@bar.baz"
220        self.user.save()
221        flow = create_test_flow(FlowDesignation.RECOVERY)
222        brand: Brand = create_test_brand()
223        brand.flow_recovery = flow
224        brand.save()
225
226        stage = EmailStage.objects.create(name="email")
227
228        self.client.force_login(self.admin)
229        response = self.client.post(
230            reverse(
231                "authentik_api:user-recovery-email",
232                kwargs={"pk": self.user.pk},
233            ),
234            data={"email_stage": stage.pk},
235        )
236        self.assertEqual(response.status_code, 204)
237
238    def test_service_account(self):
239        """Service account creation"""
240        self.client.force_login(self.admin)
241        response = self.client.post(reverse("authentik_api:user-service-account"))
242        self.assertEqual(response.status_code, 400)
243        response = self.client.post(
244            reverse("authentik_api:user-service-account"),
245            data={
246                "name": "test-sa",
247                "create_group": True,
248            },
249        )
250        self.assertEqual(response.status_code, 200)
251
252        user_filter = User.objects.filter(
253            username="test-sa",
254            type=UserTypes.SERVICE_ACCOUNT,
255            attributes={USER_ATTRIBUTE_TOKEN_EXPIRING: True},
256        )
257        self.assertTrue(user_filter.exists())
258        user: User = user_filter.first()
259        self.assertFalse(user.has_usable_password())
260
261        token_filter = Token.objects.filter(user=user)
262        self.assertTrue(token_filter.exists())
263        self.assertTrue(token_filter.first().expiring)
264
265    def test_service_account_no_expire(self):
266        """Service account creation without token expiration"""
267        self.client.force_login(self.admin)
268        response = self.client.post(
269            reverse("authentik_api:user-service-account"),
270            data={
271                "name": "test-sa",
272                "create_group": True,
273                "expiring": False,
274            },
275        )
276        self.assertEqual(response.status_code, 200)
277
278        user_filter = User.objects.filter(
279            username="test-sa",
280            type=UserTypes.SERVICE_ACCOUNT,
281            attributes={USER_ATTRIBUTE_TOKEN_EXPIRING: False},
282        )
283        self.assertTrue(user_filter.exists())
284        user: User = user_filter.first()
285        self.assertFalse(user.has_usable_password())
286
287        token_filter = Token.objects.filter(user=user)
288        self.assertTrue(token_filter.exists())
289        self.assertFalse(token_filter.first().expiring)
290
291    def test_service_account_with_custom_expire(self):
292        """Service account creation with custom token expiration date"""
293        self.client.force_login(self.admin)
294        expire_on = datetime(2050, 11, 11, 11, 11, 11).astimezone()
295        response = self.client.post(
296            reverse("authentik_api:user-service-account"),
297            data={
298                "name": "test-sa",
299                "create_group": True,
300                "expires": expire_on.isoformat(),
301            },
302        )
303        self.assertEqual(response.status_code, 200)
304
305        user_filter = User.objects.filter(
306            username="test-sa",
307            type=UserTypes.SERVICE_ACCOUNT,
308            attributes={USER_ATTRIBUTE_TOKEN_EXPIRING: True},
309        )
310        self.assertTrue(user_filter.exists())
311        user: User = user_filter.first()
312        self.assertFalse(user.has_usable_password())
313
314        token_filter = Token.objects.filter(user=user)
315        self.assertTrue(token_filter.exists())
316        token = token_filter.first()
317        self.assertTrue(token.expiring)
318        self.assertEqual(token.expires, expire_on)
319
320    def test_service_account_invalid(self):
321        """Service account creation (twice with same name, expect error)"""
322        self.client.force_login(self.admin)
323        response = self.client.post(
324            reverse("authentik_api:user-service-account"),
325            data={
326                "name": "test-sa",
327                "create_group": True,
328            },
329        )
330        self.assertEqual(response.status_code, 200)
331
332        user_filter = User.objects.filter(
333            username="test-sa",
334            type=UserTypes.SERVICE_ACCOUNT,
335            attributes={USER_ATTRIBUTE_TOKEN_EXPIRING: True},
336        )
337        self.assertTrue(user_filter.exists())
338        user: User = user_filter.first()
339        self.assertFalse(user.has_usable_password())
340
341        token_filter = Token.objects.filter(user=user)
342        self.assertTrue(token_filter.exists())
343        self.assertTrue(token_filter.first().expiring)
344
345        response = self.client.post(
346            reverse("authentik_api:user-service-account"),
347            data={
348                "name": "test-sa",
349                "create_group": True,
350            },
351        )
352        self.assertEqual(response.status_code, 400)
353
354    def test_paths(self):
355        """Test path"""
356        self.client.force_login(self.admin)
357        response = self.client.get(
358            reverse("authentik_api:user-paths"),
359        )
360        self.assertEqual(response.status_code, 200)
361        expected = list(
362            User.objects.all()
363            .values("path")
364            .distinct()
365            .order_by("path")
366            .values_list("path", flat=True)
367        )
368        self.assertJSONEqual(response.content.decode(), {"paths": expected})
369
370    def test_path_valid(self):
371        """Test path"""
372        self.client.force_login(self.admin)
373        response = self.client.post(
374            reverse("authentik_api:user-list"),
375            data={"name": generate_id(), "username": generate_id(), "groups": [], "path": "foo"},
376        )
377        self.assertEqual(response.status_code, 201)
378
379    def test_path_invalid(self):
380        """Test path (invalid)"""
381        self.client.force_login(self.admin)
382        response = self.client.post(
383            reverse("authentik_api:user-list"),
384            data={"name": generate_id(), "username": generate_id(), "groups": [], "path": "/foo"},
385        )
386        self.assertEqual(response.status_code, 400)
387        self.assertJSONEqual(
388            response.content.decode(), {"path": ["No leading or trailing slashes allowed."]}
389        )
390
391        self.client.force_login(self.admin)
392        response = self.client.post(
393            reverse("authentik_api:user-list"),
394            data={"name": generate_id(), "username": generate_id(), "groups": [], "path": ""},
395        )
396        self.assertEqual(response.status_code, 400)
397        self.assertJSONEqual(response.content.decode(), {"path": ["This field may not be blank."]})
398
399        response = self.client.post(
400            reverse("authentik_api:user-list"),
401            data={"name": generate_id(), "username": generate_id(), "groups": [], "path": "foo/"},
402        )
403        self.assertEqual(response.status_code, 400)
404        self.assertJSONEqual(
405            response.content.decode(), {"path": ["No leading or trailing slashes allowed."]}
406        )
407
408        response = self.client.post(
409            reverse("authentik_api:user-list"),
410            data={
411                "name": generate_id(),
412                "username": generate_id(),
413                "groups": [],
414                "path": "fos//o",
415            },
416        )
417        self.assertEqual(response.status_code, 400)
418        self.assertJSONEqual(
419            response.content.decode(), {"path": ["No empty segments in user path allowed."]}
420        )
421
422    def test_me(self):
423        """Test user's me endpoint"""
424        self.client.force_login(self.admin)
425        response = self.client.get(reverse("authentik_api:user-me"))
426        self.assertEqual(response.status_code, 200)
427
428    def test_session_delete(self):
429        """Ensure sessions are deleted when a user is deactivated"""
430        user = create_test_admin_user()
431        session_id = generate_id()
432        session = Session.objects.create(
433            session_key=session_id,
434            last_ip="255.255.255.255",
435            last_user_agent="",
436        )
437        AuthenticatedSession.objects.create(
438            session=session,
439            user=user,
440        )
441
442        self.client.force_login(self.admin)
443        response = self.client.patch(
444            reverse("authentik_api:user-detail", kwargs={"pk": user.pk}),
445            data={
446                "is_active": False,
447            },
448        )
449        self.assertEqual(response.status_code, 200)
450
451        self.assertFalse(Session.objects.filter(session_key=session_id).exists())
452        self.assertFalse(
453            AuthenticatedSession.objects.filter(session__session_key=session_id).exists()
454        )
455
456    def test_sort_by_last_updated(self):
457        """Test API sorting by last_updated"""
458        User.objects.all().delete()
459        admin = create_test_admin_user()
460        self.client.force_login(admin)
461
462        user = create_test_user()
463        admin.first_name = "Sample change"
464        admin.last_name = "To trigger an update"
465        admin.save()
466
467        # Ascending
468        response = self.client.get(
469            reverse("authentik_api:user-list"),
470            data={
471                "ordering": "last_updated",
472            },
473        )
474        self.assertEqual(response.status_code, 200)
475
476        body = loads(response.content)
477        self.assertEqual(len(body["results"]), 2)
478        self.assertEqual(body["results"][0]["pk"], user.pk)
479
480        # Descending
481        response = self.client.get(
482            reverse("authentik_api:user-list"),
483            data={
484                "ordering": "-last_updated",
485            },
486        )
487        self.assertEqual(response.status_code, 200)
488
489        body = loads(response.content)
490        self.assertEqual(len(body["results"]), 2)
491        self.assertEqual(body["results"][0]["pk"], admin.pk)
492
493    def test_sort_by_date_joined(self):
494        """Test API sorting by date_joined"""
495        User.objects.all().delete()
496        admin = create_test_admin_user()
497        self.client.force_login(admin)
498
499        user = create_test_user()
500
501        response = self.client.get(
502            reverse("authentik_api:user-list"),
503            data={
504                "ordering": "date_joined",
505            },
506        )
507        self.assertEqual(response.status_code, 200)
508
509        body = loads(response.content)
510        self.assertEqual(len(body["results"]), 2)
511        self.assertEqual(body["results"][0]["pk"], admin.pk)
512
513        response = self.client.get(
514            reverse("authentik_api:user-list"),
515            data={
516                "ordering": "-date_joined",
517            },
518        )
519        self.assertEqual(response.status_code, 200)
520
521        body = loads(response.content)
522        self.assertEqual(len(body["results"]), 2)
523        self.assertEqual(body["results"][0]["pk"], user.pk)
524
525    def test_service_account_validation_empty_username(self):
526        """Test service account creation with empty/blank username validation"""
527        self.client.force_login(self.admin)
528
529        # Test with empty string
530        response = self.client.post(
531            reverse("authentik_api:user-service-account"),
532            data={
533                "name": "",
534                "create_group": True,
535            },
536        )
537        self.assertEqual(response.status_code, 400)
538        self.assertJSONEqual(
539            response.content,
540            {"name": ["This field may not be blank."]},
541        )
542
543        # Test with only whitespace
544        response = self.client.post(
545            reverse("authentik_api:user-service-account"),
546            data={
547                "name": "   ",
548                "create_group": True,
549            },
550        )
551        self.assertEqual(response.status_code, 400)
552        self.assertJSONEqual(
553            response.content,
554            {"name": ["This field may not be blank."]},
555        )
556
557        # Test with tab and newline characters
558        response = self.client.post(
559            reverse("authentik_api:user-service-account"),
560            data={
561                "name": "\t\n",
562                "create_group": True,
563            },
564        )
565        self.assertEqual(response.status_code, 400)
566        self.assertJSONEqual(
567            response.content,
568            {"name": ["This field may not be blank."]},
569        )
570
571    def test_service_account_validation_valid_username(self):
572        """Test service account creation with valid username"""
573        self.client.force_login(self.admin)
574
575        # Test with valid username
576        response = self.client.post(
577            reverse("authentik_api:user-service-account"),
578            data={
579                "name": "valid-service-account",
580                "create_group": True,
581            },
582        )
583        self.assertEqual(response.status_code, 200)
584
585        # Verify response structure
586        body = loads(response.content)
587        self.assertIn("username", body)
588        self.assertIn("user_uid", body)
589        self.assertIn("user_pk", body)
590        self.assertIn("group_pk", body)  # Should exist since create_group=True
591        self.assertIn("token", body)
592
593        # Verify field types
594        self.assertEqual(body["username"], "valid-service-account")
595        self.assertIsInstance(body["user_pk"], int)
596        self.assertIsInstance(body["user_uid"], str)
597        self.assertIsInstance(body["token"], str)
598        self.assertIsInstance(body["group_pk"], str)
599
600    def test_service_account_validation_without_group(self):
601        """Test service account creation without creating a group"""
602        self.client.force_login(self.admin)
603
604        response = self.client.post(
605            reverse("authentik_api:user-service-account"),
606            data={
607                "name": "no-group-service-account",
608                "create_group": False,
609            },
610        )
611        self.assertEqual(response.status_code, 200)
612
613        body = loads(response.content)
614        self.assertIn("username", body)
615        self.assertIn("user_uid", body)
616        self.assertIn("user_pk", body)
617        self.assertIn("token", body)
618        # Should NOT have group_pk when create_group=False
619        self.assertNotIn("group_pk", body)
620
621    def test_service_account_validation_duplicate_username(self):
622        """Test service account creation with duplicate username"""
623        self.client.force_login(self.admin)
624
625        # Create first service account
626        response = self.client.post(
627            reverse("authentik_api:user-service-account"),
628            data={
629                "name": "duplicate-test",
630                "create_group": True,
631            },
632        )
633        self.assertEqual(response.status_code, 200)
634
635        # Attempt to create second with same username
636        response = self.client.post(
637            reverse("authentik_api:user-service-account"),
638            data={
639                "name": "duplicate-test",
640                "create_group": True,
641            },
642        )
643        self.assertEqual(response.status_code, 400)
644        self.assertJSONEqual(
645            response.content,
646            {"name": ["This field must be unique."]},
647        )
648
649    def test_service_account_validation_invalid_create_group(self):
650        """Test service account creation with invalid create_group field"""
651        self.client.force_login(self.admin)
652
653        # Test with string instead of boolean
654        response = self.client.post(
655            reverse("authentik_api:user-service-account"),
656            data={
657                "name": "test-sa",
658                "create_group": "invalid",
659            },
660        )
661        self.assertEqual(response.status_code, 400)
662        self.assertJSONEqual(
663            response.content,
664            {"create_group": ["Must be a valid boolean."]},
665        )
666
667        # Test with number instead of boolean
668        response = self.client.post(
669            reverse("authentik_api:user-service-account"),
670            data={
671                "name": "test-sa",
672                "create_group": 123,
673            },
674        )
675        self.assertEqual(response.status_code, 400)
676        self.assertJSONEqual(
677            response.content,
678            {"create_group": ["Must be a valid boolean."]},
679        )
680
681    def test_service_account_validation_invalid_expiring(self):
682        """Test service account creation with invalid expiring field"""
683        self.client.force_login(self.admin)
684
685        # Test with string instead of boolean
686        response = self.client.post(
687            reverse("authentik_api:user-service-account"),
688            data={
689                "name": "test-sa",
690                "expiring": "invalid",
691            },
692        )
693        self.assertEqual(response.status_code, 400)
694        self.assertJSONEqual(
695            response.content,
696            {"expiring": ["Must be a valid boolean."]},
697        )
698
699    def test_service_account_validation_invalid_expires(self):
700        """Test service account creation with invalid expires field"""
701        self.client.force_login(self.admin)
702
703        # Test with invalid datetime string
704        response = self.client.post(
705            reverse("authentik_api:user-service-account"),
706            data={
707                "name": "test-sa",
708                "expires": "invalid-datetime",
709            },
710        )
711        self.assertEqual(response.status_code, 400)
712        self.assertJSONEqual(
713            response.content,
714            {
715                "expires": [
716                    "Datetime has wrong format. Use one of these formats instead: "
717                    "YYYY-MM-DDThh:mm[:ss[.uuuuuu]][+HH:MM|-HH:MM|Z]."
718                ]
719            },
720        )
721
722        # Test with invalid format
723        response = self.client.post(
724            reverse("authentik_api:user-service-account"),
725            data={
726                "name": "test-sa",
727                "expires": "2024-13-45",  # Invalid month/day
728            },
729        )
730        self.assertEqual(response.status_code, 400)
731        self.assertJSONEqual(
732            response.content,
733            {
734                "expires": [
735                    "Datetime has wrong format. Use one of these formats instead: "
736                    "YYYY-MM-DDThh:mm[:ss[.uuuuuu]][+HH:MM|-HH:MM|Z]."
737                ]
738            },
739        )
740
741    def test_service_account_validation_multiple_errors(self):
742        """Test service account creation with multiple validation errors"""
743        self.client.force_login(self.admin)
744
745        response = self.client.post(
746            reverse("authentik_api:user-service-account"),
747            data={
748                "name": "",  # Empty username
749                "create_group": "invalid",  # Invalid boolean
750                "expiring": 123,  # Invalid boolean
751                "expires": "not-a-date",  # Invalid datetime
752            },
753        )
754        self.assertEqual(response.status_code, 400)
755        self.assertJSONEqual(
756            response.content,
757            {
758                "name": ["This field may not be blank."],
759                "create_group": ["Must be a valid boolean."],
760                "expiring": ["Must be a valid boolean."],
761                "expires": [
762                    "Datetime has wrong format. Use one of these formats instead: "
763                    "YYYY-MM-DDThh:mm[:ss[.uuuuuu]][+HH:MM|-HH:MM|Z]."
764                ],
765            },
766        )
767
768    def test_service_account_validation_user_friendly_duplicate_error(self):
769        """Test that duplicate username returns user-friendly error, not database error"""
770        self.client.force_login(self.admin)
771
772        # Create first service account
773        response = self.client.post(
774            reverse("authentik_api:user-service-account"),
775            data={
776                "name": "duplicate-username-test",
777                "create_group": True,
778            },
779        )
780        self.assertEqual(response.status_code, 200)
781
782        # Attempt to create second with same username
783        response = self.client.post(
784            reverse("authentik_api:user-service-account"),
785            data={
786                "name": "duplicate-username-test",
787                "create_group": True,
788            },
789        )
790        self.assertEqual(response.status_code, 400)
791        self.assertJSONEqual(
792            response.content,
793            {"name": ["This field must be unique."]},
794        )
795
796    def test_filter_last_login(self):
797        """Test API filtering by last_login"""
798        from datetime import timedelta
799
800        from django.utils import timezone
801
802        User.objects.all().delete()
803        admin = create_test_admin_user()
804        self.client.force_login(admin)
805
806        # Create users with different last_login values
807        user_recent = create_test_user()
808        user_recent.last_login = timezone.now()
809        user_recent.save()
810
811        user_old = create_test_user()
812        user_old.last_login = timezone.now() - timedelta(days=400)  # Over 1 year ago
813        user_old.save()
814
815        user_never = create_test_user()
816        user_never.last_login = None  # Never logged in
817        user_never.save()
818
819        # Filter users who logged in before 1 year ago
820        one_year_ago = (timezone.now() - timedelta(days=365)).isoformat()
821        response = self.client.get(
822            reverse("authentik_api:user-list"),
823            data={"last_login__lt": one_year_ago},
824        )
825        self.assertEqual(response.status_code, 200)
826        body = loads(response.content)
827        self.assertEqual(len(body["results"]), 1)
828        self.assertEqual(body["results"][0]["pk"], user_old.pk)
829
830        # Filter users who have never logged in
831        response = self.client.get(
832            reverse("authentik_api:user-list"),
833            data={"last_login__isnull": True},
834        )
835        self.assertEqual(response.status_code, 200)
836        body = loads(response.content)
837        # Should include user_never and admin (who hasn't logged in via the app)
838        pks = [r["pk"] for r in body["results"]]
839        self.assertIn(user_never.pk, pks)
840
841    def test_sort_by_last_login(self):
842        """Test API sorting by last_login"""
843        from datetime import timedelta
844
845        from django.utils import timezone
846
847        User.objects.all().delete()
848        admin = create_test_admin_user()
849        self.client.force_login(admin)
850
851        user1 = create_test_user()
852        user1.last_login = timezone.now() - timedelta(days=10)
853        user1.save()
854
855        user2 = create_test_user()
856        user2.last_login = timezone.now() - timedelta(days=5)
857        user2.save()
858
859        # Ascending order (oldest first)
860        response = self.client.get(
861            reverse("authentik_api:user-list"),
862            data={"ordering": "last_login"},
863        )
864        self.assertEqual(response.status_code, 200)
865        body = loads(response.content)
866        # Users with null last_login come first, then user1 (older), then user2 (newer)
867        self.assertEqual(len(body["results"]), 3)
868
869        # Descending order (newest first)
870        response = self.client.get(
871            reverse("authentik_api:user-list"),
872            data={"ordering": "-last_login"},
873        )
874        self.assertEqual(response.status_code, 200)
875        body = loads(response.content)
876        # user2 should come before user1 (more recent login)
877        pks = [r["pk"] for r in body["results"]]
878        self.assertIn(user1.pk, pks)
879        self.assertIn(user2.pk, pks)
880        # Verify user2 comes before user1 in descending order
881        self.assertLess(pks.index(user2.pk), pks.index(user1.pk))

Test Users API

def setUp(self) -> None:
34    def setUp(self) -> None:
35        self.admin = create_test_admin_user()
36        self.user = create_test_user()

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

def test_filter_type(self):
38    def test_filter_type(self):
39        """Test API filtering by type"""
40        self.client.force_login(self.admin)
41        user = create_test_admin_user(type=UserTypes.EXTERNAL)
42        response = self.client.get(
43            reverse("authentik_api:user-list"),
44            data={
45                "type": UserTypes.EXTERNAL,
46                "username": user.username,
47            },
48        )
49        self.assertEqual(response.status_code, 200)

Test API filtering by type

def test_filter_is_superuser(self):
51    def test_filter_is_superuser(self):
52        """Test API filtering by superuser status"""
53        User.objects.all().delete()
54        admin = create_test_admin_user()
55        self.client.force_login(admin)
56        # Test superuser
57        response = self.client.get(
58            reverse("authentik_api:user-list"),
59            data={
60                "is_superuser": True,
61            },
62        )
63        self.assertEqual(response.status_code, 200)
64        body = loads(response.content)
65        self.assertEqual(len(body["results"]), 1)
66        self.assertEqual(body["results"][0]["username"], admin.username)
67        # Test non-superuser
68        user = create_test_user()
69        response = self.client.get(
70            reverse("authentik_api:user-list"),
71            data={
72                "is_superuser": False,
73            },
74        )
75        self.assertEqual(response.status_code, 200)
76        body = loads(response.content)
77        self.assertEqual(len(body["results"]), 1, body)
78        self.assertEqual(body["results"][0]["username"], user.username)

Test API filtering by superuser status

def test_list_with_groups(self):
80    def test_list_with_groups(self):
81        """Test listing with groups"""
82        self.client.force_login(self.admin)
83        response = self.client.get(reverse("authentik_api:user-list"), {"include_groups": "true"})
84        self.assertEqual(response.status_code, 200)

Test listing with groups

def test_recovery_no_flow(self):
86    def test_recovery_no_flow(self):
87        """Test user recovery link (no recovery flow set)"""
88        self.client.force_login(self.admin)
89        response = self.client.post(
90            reverse("authentik_api:user-recovery", kwargs={"pk": self.user.pk})
91        )
92        self.assertEqual(response.status_code, 400)
93        self.assertJSONEqual(response.content, {"non_field_errors": "No recovery flow set."})

Test user recovery link (no recovery flow set)

def test_set_password(self):
 95    def test_set_password(self):
 96        """Test Direct password set"""
 97        self.client.force_login(self.admin)
 98        new_pw = generate_key()
 99        response = self.client.post(
100            reverse("authentik_api:user-set-password", kwargs={"pk": self.admin.pk}),
101            data={"password": new_pw},
102        )
103        self.assertEqual(response.status_code, 204)
104        self.admin.refresh_from_db()
105        self.assertTrue(self.admin.check_password(new_pw))

Test Direct password set

def test_set_password_blank(self):
107    def test_set_password_blank(self):
108        """Test Direct password set"""
109        self.client.force_login(self.admin)
110        response = self.client.post(
111            reverse("authentik_api:user-set-password", kwargs={"pk": self.admin.pk}),
112            data={"password": ""},
113        )
114        self.assertEqual(response.status_code, 400)
115        self.assertJSONEqual(response.content, {"password": ["This field may not be blank."]})

Test Direct password set

def test_recovery(self):
117    def test_recovery(self):
118        """Test user recovery link"""
119        flow = create_test_flow(
120            FlowDesignation.RECOVERY,
121            authentication=FlowAuthenticationRequirement.REQUIRE_UNAUTHENTICATED,
122        )
123        brand: Brand = create_test_brand()
124        brand.flow_recovery = flow
125        brand.save()
126        self.client.force_login(self.admin)
127        response = self.client.post(
128            reverse("authentik_api:user-recovery", kwargs={"pk": self.user.pk})
129        )
130        self.assertEqual(response.status_code, 200)

Test user recovery link

def test_recovery_duration(self):
132    def test_recovery_duration(self):
133        """Test user recovery token duration"""
134        Token.objects.all().delete()
135        flow = create_test_flow(
136            FlowDesignation.RECOVERY,
137            authentication=FlowAuthenticationRequirement.REQUIRE_UNAUTHENTICATED,
138        )
139        brand: Brand = create_test_brand()
140        brand.flow_recovery = flow
141        brand.save()
142        self.client.force_login(self.admin)
143        response = self.client.post(
144            reverse("authentik_api:user-recovery", kwargs={"pk": self.user.pk}),
145            data={"token_duration": "days=33"},
146        )
147        self.assertEqual(response.status_code, 200)
148        expires = Token.objects.first().expires
149        expected_expires = now() + timedelta(days=33)
150        self.assertTrue(timedelta(minutes=-1) < expected_expires - expires < timedelta(minutes=1))

Test user recovery token duration

def test_recovery_duration_update(self):
152    def test_recovery_duration_update(self):
153        """Test user recovery token duration update"""
154        Token.objects.all().delete()
155        flow = create_test_flow(
156            FlowDesignation.RECOVERY,
157            authentication=FlowAuthenticationRequirement.REQUIRE_UNAUTHENTICATED,
158        )
159        brand: Brand = create_test_brand()
160        brand.flow_recovery = flow
161        brand.save()
162        self.client.force_login(self.admin)
163        response = self.client.post(
164            reverse("authentik_api:user-recovery", kwargs={"pk": self.user.pk}),
165            data={"token_duration": "days=33"},
166        )
167        self.assertEqual(response.status_code, 200)
168        expires = Token.objects.first().expires
169        expected_expires = now() + timedelta(days=33)
170        self.assertTrue(timedelta(minutes=-1) < expected_expires - expires < timedelta(minutes=1))
171        response = self.client.post(
172            reverse("authentik_api:user-recovery", kwargs={"pk": self.user.pk}),
173            data={"token_duration": "days=66"},
174        )
175        expires = Token.objects.first().expires
176        expected_expires = now() + timedelta(days=66)
177        self.assertTrue(timedelta(minutes=-1) < expected_expires - expires < timedelta(minutes=1))

Test user recovery token duration update

def test_recovery_email_no_flow(self):
179    def test_recovery_email_no_flow(self):
180        """Test user recovery link (no recovery flow set)"""
181        self.client.force_login(self.admin)
182        self.user.email = ""
183        self.user.save()
184        stage = EmailStage.objects.create(name="email")
185        response = self.client.post(
186            reverse("authentik_api:user-recovery-email", kwargs={"pk": self.user.pk}),
187            data={"email_stage": stage.pk},
188        )
189        self.assertEqual(response.status_code, 400)
190        self.assertJSONEqual(
191            response.content, {"non_field_errors": "User does not have an email address set."}
192        )
193        self.user.email = "foo@bar.baz"
194        self.user.save()
195        response = self.client.post(
196            reverse("authentik_api:user-recovery-email", kwargs={"pk": self.user.pk}),
197            data={"email_stage": stage.pk},
198        )
199        self.assertEqual(response.status_code, 400)
200        self.assertJSONEqual(response.content, {"non_field_errors": "No recovery flow set."})

Test user recovery link (no recovery flow set)

def test_recovery_email_no_stage(self):
202    def test_recovery_email_no_stage(self):
203        """Test user recovery link (no email stage)"""
204        self.user.email = "foo@bar.baz"
205        self.user.save()
206        flow = create_test_flow(designation=FlowDesignation.RECOVERY)
207        brand: Brand = create_test_brand()
208        brand.flow_recovery = flow
209        brand.save()
210        self.client.force_login(self.admin)
211        response = self.client.post(
212            reverse("authentik_api:user-recovery-email", kwargs={"pk": self.user.pk})
213        )
214        self.assertEqual(response.status_code, 400)
215        self.assertJSONEqual(response.content, {"email_stage": ["This field is required."]})

Test user recovery link (no email stage)

def test_recovery_email(self):
217    def test_recovery_email(self):
218        """Test user recovery link"""
219        self.user.email = "foo@bar.baz"
220        self.user.save()
221        flow = create_test_flow(FlowDesignation.RECOVERY)
222        brand: Brand = create_test_brand()
223        brand.flow_recovery = flow
224        brand.save()
225
226        stage = EmailStage.objects.create(name="email")
227
228        self.client.force_login(self.admin)
229        response = self.client.post(
230            reverse(
231                "authentik_api:user-recovery-email",
232                kwargs={"pk": self.user.pk},
233            ),
234            data={"email_stage": stage.pk},
235        )
236        self.assertEqual(response.status_code, 204)

Test user recovery link

def test_service_account(self):
238    def test_service_account(self):
239        """Service account creation"""
240        self.client.force_login(self.admin)
241        response = self.client.post(reverse("authentik_api:user-service-account"))
242        self.assertEqual(response.status_code, 400)
243        response = self.client.post(
244            reverse("authentik_api:user-service-account"),
245            data={
246                "name": "test-sa",
247                "create_group": True,
248            },
249        )
250        self.assertEqual(response.status_code, 200)
251
252        user_filter = User.objects.filter(
253            username="test-sa",
254            type=UserTypes.SERVICE_ACCOUNT,
255            attributes={USER_ATTRIBUTE_TOKEN_EXPIRING: True},
256        )
257        self.assertTrue(user_filter.exists())
258        user: User = user_filter.first()
259        self.assertFalse(user.has_usable_password())
260
261        token_filter = Token.objects.filter(user=user)
262        self.assertTrue(token_filter.exists())
263        self.assertTrue(token_filter.first().expiring)

Service account creation

def test_service_account_no_expire(self):
265    def test_service_account_no_expire(self):
266        """Service account creation without token expiration"""
267        self.client.force_login(self.admin)
268        response = self.client.post(
269            reverse("authentik_api:user-service-account"),
270            data={
271                "name": "test-sa",
272                "create_group": True,
273                "expiring": False,
274            },
275        )
276        self.assertEqual(response.status_code, 200)
277
278        user_filter = User.objects.filter(
279            username="test-sa",
280            type=UserTypes.SERVICE_ACCOUNT,
281            attributes={USER_ATTRIBUTE_TOKEN_EXPIRING: False},
282        )
283        self.assertTrue(user_filter.exists())
284        user: User = user_filter.first()
285        self.assertFalse(user.has_usable_password())
286
287        token_filter = Token.objects.filter(user=user)
288        self.assertTrue(token_filter.exists())
289        self.assertFalse(token_filter.first().expiring)

Service account creation without token expiration

def test_service_account_with_custom_expire(self):
291    def test_service_account_with_custom_expire(self):
292        """Service account creation with custom token expiration date"""
293        self.client.force_login(self.admin)
294        expire_on = datetime(2050, 11, 11, 11, 11, 11).astimezone()
295        response = self.client.post(
296            reverse("authentik_api:user-service-account"),
297            data={
298                "name": "test-sa",
299                "create_group": True,
300                "expires": expire_on.isoformat(),
301            },
302        )
303        self.assertEqual(response.status_code, 200)
304
305        user_filter = User.objects.filter(
306            username="test-sa",
307            type=UserTypes.SERVICE_ACCOUNT,
308            attributes={USER_ATTRIBUTE_TOKEN_EXPIRING: True},
309        )
310        self.assertTrue(user_filter.exists())
311        user: User = user_filter.first()
312        self.assertFalse(user.has_usable_password())
313
314        token_filter = Token.objects.filter(user=user)
315        self.assertTrue(token_filter.exists())
316        token = token_filter.first()
317        self.assertTrue(token.expiring)
318        self.assertEqual(token.expires, expire_on)

Service account creation with custom token expiration date

def test_service_account_invalid(self):
320    def test_service_account_invalid(self):
321        """Service account creation (twice with same name, expect error)"""
322        self.client.force_login(self.admin)
323        response = self.client.post(
324            reverse("authentik_api:user-service-account"),
325            data={
326                "name": "test-sa",
327                "create_group": True,
328            },
329        )
330        self.assertEqual(response.status_code, 200)
331
332        user_filter = User.objects.filter(
333            username="test-sa",
334            type=UserTypes.SERVICE_ACCOUNT,
335            attributes={USER_ATTRIBUTE_TOKEN_EXPIRING: True},
336        )
337        self.assertTrue(user_filter.exists())
338        user: User = user_filter.first()
339        self.assertFalse(user.has_usable_password())
340
341        token_filter = Token.objects.filter(user=user)
342        self.assertTrue(token_filter.exists())
343        self.assertTrue(token_filter.first().expiring)
344
345        response = self.client.post(
346            reverse("authentik_api:user-service-account"),
347            data={
348                "name": "test-sa",
349                "create_group": True,
350            },
351        )
352        self.assertEqual(response.status_code, 400)

Service account creation (twice with same name, expect error)

def test_paths(self):
354    def test_paths(self):
355        """Test path"""
356        self.client.force_login(self.admin)
357        response = self.client.get(
358            reverse("authentik_api:user-paths"),
359        )
360        self.assertEqual(response.status_code, 200)
361        expected = list(
362            User.objects.all()
363            .values("path")
364            .distinct()
365            .order_by("path")
366            .values_list("path", flat=True)
367        )
368        self.assertJSONEqual(response.content.decode(), {"paths": expected})

Test path

def test_path_valid(self):
370    def test_path_valid(self):
371        """Test path"""
372        self.client.force_login(self.admin)
373        response = self.client.post(
374            reverse("authentik_api:user-list"),
375            data={"name": generate_id(), "username": generate_id(), "groups": [], "path": "foo"},
376        )
377        self.assertEqual(response.status_code, 201)

Test path

def test_path_invalid(self):
379    def test_path_invalid(self):
380        """Test path (invalid)"""
381        self.client.force_login(self.admin)
382        response = self.client.post(
383            reverse("authentik_api:user-list"),
384            data={"name": generate_id(), "username": generate_id(), "groups": [], "path": "/foo"},
385        )
386        self.assertEqual(response.status_code, 400)
387        self.assertJSONEqual(
388            response.content.decode(), {"path": ["No leading or trailing slashes allowed."]}
389        )
390
391        self.client.force_login(self.admin)
392        response = self.client.post(
393            reverse("authentik_api:user-list"),
394            data={"name": generate_id(), "username": generate_id(), "groups": [], "path": ""},
395        )
396        self.assertEqual(response.status_code, 400)
397        self.assertJSONEqual(response.content.decode(), {"path": ["This field may not be blank."]})
398
399        response = self.client.post(
400            reverse("authentik_api:user-list"),
401            data={"name": generate_id(), "username": generate_id(), "groups": [], "path": "foo/"},
402        )
403        self.assertEqual(response.status_code, 400)
404        self.assertJSONEqual(
405            response.content.decode(), {"path": ["No leading or trailing slashes allowed."]}
406        )
407
408        response = self.client.post(
409            reverse("authentik_api:user-list"),
410            data={
411                "name": generate_id(),
412                "username": generate_id(),
413                "groups": [],
414                "path": "fos//o",
415            },
416        )
417        self.assertEqual(response.status_code, 400)
418        self.assertJSONEqual(
419            response.content.decode(), {"path": ["No empty segments in user path allowed."]}
420        )

Test path (invalid)

def test_me(self):
422    def test_me(self):
423        """Test user's me endpoint"""
424        self.client.force_login(self.admin)
425        response = self.client.get(reverse("authentik_api:user-me"))
426        self.assertEqual(response.status_code, 200)

Test user's me endpoint

def test_session_delete(self):
428    def test_session_delete(self):
429        """Ensure sessions are deleted when a user is deactivated"""
430        user = create_test_admin_user()
431        session_id = generate_id()
432        session = Session.objects.create(
433            session_key=session_id,
434            last_ip="255.255.255.255",
435            last_user_agent="",
436        )
437        AuthenticatedSession.objects.create(
438            session=session,
439            user=user,
440        )
441
442        self.client.force_login(self.admin)
443        response = self.client.patch(
444            reverse("authentik_api:user-detail", kwargs={"pk": user.pk}),
445            data={
446                "is_active": False,
447            },
448        )
449        self.assertEqual(response.status_code, 200)
450
451        self.assertFalse(Session.objects.filter(session_key=session_id).exists())
452        self.assertFalse(
453            AuthenticatedSession.objects.filter(session__session_key=session_id).exists()
454        )

Ensure sessions are deleted when a user is deactivated

def test_sort_by_last_updated(self):
456    def test_sort_by_last_updated(self):
457        """Test API sorting by last_updated"""
458        User.objects.all().delete()
459        admin = create_test_admin_user()
460        self.client.force_login(admin)
461
462        user = create_test_user()
463        admin.first_name = "Sample change"
464        admin.last_name = "To trigger an update"
465        admin.save()
466
467        # Ascending
468        response = self.client.get(
469            reverse("authentik_api:user-list"),
470            data={
471                "ordering": "last_updated",
472            },
473        )
474        self.assertEqual(response.status_code, 200)
475
476        body = loads(response.content)
477        self.assertEqual(len(body["results"]), 2)
478        self.assertEqual(body["results"][0]["pk"], user.pk)
479
480        # Descending
481        response = self.client.get(
482            reverse("authentik_api:user-list"),
483            data={
484                "ordering": "-last_updated",
485            },
486        )
487        self.assertEqual(response.status_code, 200)
488
489        body = loads(response.content)
490        self.assertEqual(len(body["results"]), 2)
491        self.assertEqual(body["results"][0]["pk"], admin.pk)

Test API sorting by last_updated

def test_sort_by_date_joined(self):
493    def test_sort_by_date_joined(self):
494        """Test API sorting by date_joined"""
495        User.objects.all().delete()
496        admin = create_test_admin_user()
497        self.client.force_login(admin)
498
499        user = create_test_user()
500
501        response = self.client.get(
502            reverse("authentik_api:user-list"),
503            data={
504                "ordering": "date_joined",
505            },
506        )
507        self.assertEqual(response.status_code, 200)
508
509        body = loads(response.content)
510        self.assertEqual(len(body["results"]), 2)
511        self.assertEqual(body["results"][0]["pk"], admin.pk)
512
513        response = self.client.get(
514            reverse("authentik_api:user-list"),
515            data={
516                "ordering": "-date_joined",
517            },
518        )
519        self.assertEqual(response.status_code, 200)
520
521        body = loads(response.content)
522        self.assertEqual(len(body["results"]), 2)
523        self.assertEqual(body["results"][0]["pk"], user.pk)

Test API sorting by date_joined

def test_service_account_validation_empty_username(self):
525    def test_service_account_validation_empty_username(self):
526        """Test service account creation with empty/blank username validation"""
527        self.client.force_login(self.admin)
528
529        # Test with empty string
530        response = self.client.post(
531            reverse("authentik_api:user-service-account"),
532            data={
533                "name": "",
534                "create_group": True,
535            },
536        )
537        self.assertEqual(response.status_code, 400)
538        self.assertJSONEqual(
539            response.content,
540            {"name": ["This field may not be blank."]},
541        )
542
543        # Test with only whitespace
544        response = self.client.post(
545            reverse("authentik_api:user-service-account"),
546            data={
547                "name": "   ",
548                "create_group": True,
549            },
550        )
551        self.assertEqual(response.status_code, 400)
552        self.assertJSONEqual(
553            response.content,
554            {"name": ["This field may not be blank."]},
555        )
556
557        # Test with tab and newline characters
558        response = self.client.post(
559            reverse("authentik_api:user-service-account"),
560            data={
561                "name": "\t\n",
562                "create_group": True,
563            },
564        )
565        self.assertEqual(response.status_code, 400)
566        self.assertJSONEqual(
567            response.content,
568            {"name": ["This field may not be blank."]},
569        )

Test service account creation with empty/blank username validation

def test_service_account_validation_valid_username(self):
571    def test_service_account_validation_valid_username(self):
572        """Test service account creation with valid username"""
573        self.client.force_login(self.admin)
574
575        # Test with valid username
576        response = self.client.post(
577            reverse("authentik_api:user-service-account"),
578            data={
579                "name": "valid-service-account",
580                "create_group": True,
581            },
582        )
583        self.assertEqual(response.status_code, 200)
584
585        # Verify response structure
586        body = loads(response.content)
587        self.assertIn("username", body)
588        self.assertIn("user_uid", body)
589        self.assertIn("user_pk", body)
590        self.assertIn("group_pk", body)  # Should exist since create_group=True
591        self.assertIn("token", body)
592
593        # Verify field types
594        self.assertEqual(body["username"], "valid-service-account")
595        self.assertIsInstance(body["user_pk"], int)
596        self.assertIsInstance(body["user_uid"], str)
597        self.assertIsInstance(body["token"], str)
598        self.assertIsInstance(body["group_pk"], str)

Test service account creation with valid username

def test_service_account_validation_without_group(self):
600    def test_service_account_validation_without_group(self):
601        """Test service account creation without creating a group"""
602        self.client.force_login(self.admin)
603
604        response = self.client.post(
605            reverse("authentik_api:user-service-account"),
606            data={
607                "name": "no-group-service-account",
608                "create_group": False,
609            },
610        )
611        self.assertEqual(response.status_code, 200)
612
613        body = loads(response.content)
614        self.assertIn("username", body)
615        self.assertIn("user_uid", body)
616        self.assertIn("user_pk", body)
617        self.assertIn("token", body)
618        # Should NOT have group_pk when create_group=False
619        self.assertNotIn("group_pk", body)

Test service account creation without creating a group

def test_service_account_validation_duplicate_username(self):
621    def test_service_account_validation_duplicate_username(self):
622        """Test service account creation with duplicate username"""
623        self.client.force_login(self.admin)
624
625        # Create first service account
626        response = self.client.post(
627            reverse("authentik_api:user-service-account"),
628            data={
629                "name": "duplicate-test",
630                "create_group": True,
631            },
632        )
633        self.assertEqual(response.status_code, 200)
634
635        # Attempt to create second with same username
636        response = self.client.post(
637            reverse("authentik_api:user-service-account"),
638            data={
639                "name": "duplicate-test",
640                "create_group": True,
641            },
642        )
643        self.assertEqual(response.status_code, 400)
644        self.assertJSONEqual(
645            response.content,
646            {"name": ["This field must be unique."]},
647        )

Test service account creation with duplicate username

def test_service_account_validation_invalid_create_group(self):
649    def test_service_account_validation_invalid_create_group(self):
650        """Test service account creation with invalid create_group field"""
651        self.client.force_login(self.admin)
652
653        # Test with string instead of boolean
654        response = self.client.post(
655            reverse("authentik_api:user-service-account"),
656            data={
657                "name": "test-sa",
658                "create_group": "invalid",
659            },
660        )
661        self.assertEqual(response.status_code, 400)
662        self.assertJSONEqual(
663            response.content,
664            {"create_group": ["Must be a valid boolean."]},
665        )
666
667        # Test with number instead of boolean
668        response = self.client.post(
669            reverse("authentik_api:user-service-account"),
670            data={
671                "name": "test-sa",
672                "create_group": 123,
673            },
674        )
675        self.assertEqual(response.status_code, 400)
676        self.assertJSONEqual(
677            response.content,
678            {"create_group": ["Must be a valid boolean."]},
679        )

Test service account creation with invalid create_group field

def test_service_account_validation_invalid_expiring(self):
681    def test_service_account_validation_invalid_expiring(self):
682        """Test service account creation with invalid expiring field"""
683        self.client.force_login(self.admin)
684
685        # Test with string instead of boolean
686        response = self.client.post(
687            reverse("authentik_api:user-service-account"),
688            data={
689                "name": "test-sa",
690                "expiring": "invalid",
691            },
692        )
693        self.assertEqual(response.status_code, 400)
694        self.assertJSONEqual(
695            response.content,
696            {"expiring": ["Must be a valid boolean."]},
697        )

Test service account creation with invalid expiring field

def test_service_account_validation_invalid_expires(self):
699    def test_service_account_validation_invalid_expires(self):
700        """Test service account creation with invalid expires field"""
701        self.client.force_login(self.admin)
702
703        # Test with invalid datetime string
704        response = self.client.post(
705            reverse("authentik_api:user-service-account"),
706            data={
707                "name": "test-sa",
708                "expires": "invalid-datetime",
709            },
710        )
711        self.assertEqual(response.status_code, 400)
712        self.assertJSONEqual(
713            response.content,
714            {
715                "expires": [
716                    "Datetime has wrong format. Use one of these formats instead: "
717                    "YYYY-MM-DDThh:mm[:ss[.uuuuuu]][+HH:MM|-HH:MM|Z]."
718                ]
719            },
720        )
721
722        # Test with invalid format
723        response = self.client.post(
724            reverse("authentik_api:user-service-account"),
725            data={
726                "name": "test-sa",
727                "expires": "2024-13-45",  # Invalid month/day
728            },
729        )
730        self.assertEqual(response.status_code, 400)
731        self.assertJSONEqual(
732            response.content,
733            {
734                "expires": [
735                    "Datetime has wrong format. Use one of these formats instead: "
736                    "YYYY-MM-DDThh:mm[:ss[.uuuuuu]][+HH:MM|-HH:MM|Z]."
737                ]
738            },
739        )

Test service account creation with invalid expires field

def test_service_account_validation_multiple_errors(self):
741    def test_service_account_validation_multiple_errors(self):
742        """Test service account creation with multiple validation errors"""
743        self.client.force_login(self.admin)
744
745        response = self.client.post(
746            reverse("authentik_api:user-service-account"),
747            data={
748                "name": "",  # Empty username
749                "create_group": "invalid",  # Invalid boolean
750                "expiring": 123,  # Invalid boolean
751                "expires": "not-a-date",  # Invalid datetime
752            },
753        )
754        self.assertEqual(response.status_code, 400)
755        self.assertJSONEqual(
756            response.content,
757            {
758                "name": ["This field may not be blank."],
759                "create_group": ["Must be a valid boolean."],
760                "expiring": ["Must be a valid boolean."],
761                "expires": [
762                    "Datetime has wrong format. Use one of these formats instead: "
763                    "YYYY-MM-DDThh:mm[:ss[.uuuuuu]][+HH:MM|-HH:MM|Z]."
764                ],
765            },
766        )

Test service account creation with multiple validation errors

def test_service_account_validation_user_friendly_duplicate_error(self):
768    def test_service_account_validation_user_friendly_duplicate_error(self):
769        """Test that duplicate username returns user-friendly error, not database error"""
770        self.client.force_login(self.admin)
771
772        # Create first service account
773        response = self.client.post(
774            reverse("authentik_api:user-service-account"),
775            data={
776                "name": "duplicate-username-test",
777                "create_group": True,
778            },
779        )
780        self.assertEqual(response.status_code, 200)
781
782        # Attempt to create second with same username
783        response = self.client.post(
784            reverse("authentik_api:user-service-account"),
785            data={
786                "name": "duplicate-username-test",
787                "create_group": True,
788            },
789        )
790        self.assertEqual(response.status_code, 400)
791        self.assertJSONEqual(
792            response.content,
793            {"name": ["This field must be unique."]},
794        )

Test that duplicate username returns user-friendly error, not database error

def test_filter_last_login(self):
796    def test_filter_last_login(self):
797        """Test API filtering by last_login"""
798        from datetime import timedelta
799
800        from django.utils import timezone
801
802        User.objects.all().delete()
803        admin = create_test_admin_user()
804        self.client.force_login(admin)
805
806        # Create users with different last_login values
807        user_recent = create_test_user()
808        user_recent.last_login = timezone.now()
809        user_recent.save()
810
811        user_old = create_test_user()
812        user_old.last_login = timezone.now() - timedelta(days=400)  # Over 1 year ago
813        user_old.save()
814
815        user_never = create_test_user()
816        user_never.last_login = None  # Never logged in
817        user_never.save()
818
819        # Filter users who logged in before 1 year ago
820        one_year_ago = (timezone.now() - timedelta(days=365)).isoformat()
821        response = self.client.get(
822            reverse("authentik_api:user-list"),
823            data={"last_login__lt": one_year_ago},
824        )
825        self.assertEqual(response.status_code, 200)
826        body = loads(response.content)
827        self.assertEqual(len(body["results"]), 1)
828        self.assertEqual(body["results"][0]["pk"], user_old.pk)
829
830        # Filter users who have never logged in
831        response = self.client.get(
832            reverse("authentik_api:user-list"),
833            data={"last_login__isnull": True},
834        )
835        self.assertEqual(response.status_code, 200)
836        body = loads(response.content)
837        # Should include user_never and admin (who hasn't logged in via the app)
838        pks = [r["pk"] for r in body["results"]]
839        self.assertIn(user_never.pk, pks)

Test API filtering by last_login

def test_sort_by_last_login(self):
841    def test_sort_by_last_login(self):
842        """Test API sorting by last_login"""
843        from datetime import timedelta
844
845        from django.utils import timezone
846
847        User.objects.all().delete()
848        admin = create_test_admin_user()
849        self.client.force_login(admin)
850
851        user1 = create_test_user()
852        user1.last_login = timezone.now() - timedelta(days=10)
853        user1.save()
854
855        user2 = create_test_user()
856        user2.last_login = timezone.now() - timedelta(days=5)
857        user2.save()
858
859        # Ascending order (oldest first)
860        response = self.client.get(
861            reverse("authentik_api:user-list"),
862            data={"ordering": "last_login"},
863        )
864        self.assertEqual(response.status_code, 200)
865        body = loads(response.content)
866        # Users with null last_login come first, then user1 (older), then user2 (newer)
867        self.assertEqual(len(body["results"]), 3)
868
869        # Descending order (newest first)
870        response = self.client.get(
871            reverse("authentik_api:user-list"),
872            data={"ordering": "-last_login"},
873        )
874        self.assertEqual(response.status_code, 200)
875        body = loads(response.content)
876        # user2 should come before user1 (more recent login)
877        pks = [r["pk"] for r in body["results"]]
878        self.assertIn(user1.pk, pks)
879        self.assertIn(user2.pk, pks)
880        # Verify user2 comes before user1 in descending order
881        self.assertLess(pks.index(user2.pk), pks.index(user1.pk))

Test API sorting by last_login