authentik.stages.invitation.tests

invitation tests

  1"""invitation tests"""
  2
  3from datetime import timedelta
  4from unittest.mock import MagicMock, patch
  5
  6from django.urls import reverse
  7from django.utils.http import urlencode
  8from django.utils.timezone import now
  9from guardian.shortcuts import get_anonymous_user
 10from rest_framework.test import APITestCase
 11
 12from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT
 13from authentik.core.tests.utils import create_test_admin_user, create_test_flow
 14from authentik.flows.markers import StageMarker
 15from authentik.flows.models import FlowDesignation, FlowStageBinding
 16from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan
 17from authentik.flows.tests import FlowTestCase
 18from authentik.flows.tests.test_executor import TO_STAGE_RESPONSE_MOCK
 19from authentik.flows.views.executor import SESSION_KEY_PLAN
 20from authentik.stages.invitation.api import InvitationSerializer
 21from authentik.stages.invitation.models import Invitation, InvitationStage
 22from authentik.stages.invitation.stage import (
 23    PLAN_CONTEXT_INVITATION_TOKEN,
 24    PLAN_CONTEXT_PROMPT,
 25    QS_INVITATION_TOKEN_KEY,
 26)
 27from authentik.stages.password import BACKEND_INBUILT
 28from authentik.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND
 29
 30
 31class TestInvitationStage(FlowTestCase):
 32    """Login tests"""
 33
 34    def setUp(self):
 35        super().setUp()
 36        self.user = create_test_admin_user()
 37        self.flow = create_test_flow(FlowDesignation.AUTHENTICATION)
 38        self.stage = InvitationStage.objects.create(name="invitation")
 39        self.binding = FlowStageBinding.objects.create(target=self.flow, stage=self.stage, order=2)
 40
 41    @patch(
 42        "authentik.flows.views.executor.to_stage_response",
 43        TO_STAGE_RESPONSE_MOCK,
 44    )
 45    def test_without_invitation_fail(self):
 46        """Test without any invitation, continue_flow_without_invitation not set."""
 47        plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
 48        plan.context[PLAN_CONTEXT_PENDING_USER] = self.user
 49        plan.context[PLAN_CONTEXT_AUTHENTICATION_BACKEND] = BACKEND_INBUILT
 50        session = self.client.session
 51        session[SESSION_KEY_PLAN] = plan
 52        session.save()
 53
 54        response = self.client.get(
 55            reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug})
 56        )
 57        self.assertStageResponse(
 58            response,
 59            flow=self.flow,
 60            component="ak-stage-access-denied",
 61        )
 62
 63    def test_without_invitation_continue(self):
 64        """Test without any invitation, continue_flow_without_invitation is set."""
 65        self.stage.continue_flow_without_invitation = True
 66        self.stage.save()
 67        plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
 68        plan.context[PLAN_CONTEXT_PENDING_USER] = self.user
 69        plan.context[PLAN_CONTEXT_AUTHENTICATION_BACKEND] = BACKEND_INBUILT
 70        session = self.client.session
 71        session[SESSION_KEY_PLAN] = plan
 72        session.save()
 73
 74        response = self.client.get(
 75            reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug})
 76        )
 77
 78        self.assertEqual(response.status_code, 200)
 79        self.assertStageRedirects(response, reverse("authentik_core:root-redirect"))
 80
 81        self.stage.continue_flow_without_invitation = False
 82        self.stage.save()
 83
 84    def test_with_invitation_expired(self):
 85        """Test with invitation, expired"""
 86        plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
 87        session = self.client.session
 88        session[SESSION_KEY_PLAN] = plan
 89        session.save()
 90
 91        data = {"foo": "bar"}
 92        invite = Invitation.objects.create(
 93            created_by=get_anonymous_user(),
 94            fixed_data=data,
 95            expires=now() - timedelta(hours=1),
 96        )
 97
 98        base_url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug})
 99        args = urlencode({QS_INVITATION_TOKEN_KEY: invite.pk.hex})
100        response = self.client.get(base_url + f"?query={args}")
101
102        self.assertEqual(response.status_code, 200)
103        self.assertStageResponse(
104            response,
105            flow=self.flow,
106            component="ak-stage-access-denied",
107        )
108
109    def test_with_invitation_get(self):
110        """Test with invitation, check data in session"""
111        plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
112        session = self.client.session
113        session[SESSION_KEY_PLAN] = plan
114        session.save()
115
116        data = {"foo": "bar"}
117        invite = Invitation.objects.create(created_by=get_anonymous_user(), fixed_data=data)
118
119        with patch("authentik.flows.views.executor.FlowExecutorView.cancel", MagicMock()):
120            base_url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug})
121            args = urlencode({QS_INVITATION_TOKEN_KEY: invite.pk.hex})
122            response = self.client.get(base_url + f"?query={args}")
123
124        session = self.client.session
125        plan: FlowPlan = session[SESSION_KEY_PLAN]
126        self.assertEqual(plan.context[PLAN_CONTEXT_PROMPT], data)
127
128        self.assertEqual(response.status_code, 200)
129        self.assertStageRedirects(response, reverse("authentik_core:root-redirect"))
130
131    def test_invalid_flow(self):
132        """Test with invitation, invalid flow limit"""
133        invalid_flow = create_test_flow(FlowDesignation.ENROLLMENT)
134        plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
135        session = self.client.session
136        session[SESSION_KEY_PLAN] = plan
137        session.save()
138
139        data = {"foo": "bar"}
140        invite = Invitation.objects.create(
141            created_by=get_anonymous_user(), fixed_data=data, flow=invalid_flow
142        )
143
144        with patch("authentik.flows.views.executor.FlowExecutorView.cancel", MagicMock()):
145            base_url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug})
146            args = urlencode({QS_INVITATION_TOKEN_KEY: invite.pk.hex})
147            response = self.client.get(base_url + f"?query={args}")
148
149        session = self.client.session
150        plan: FlowPlan = session[SESSION_KEY_PLAN]
151
152        self.assertStageResponse(
153            response,
154            flow=self.flow,
155            component="ak-stage-access-denied",
156        )
157
158    def test_with_invitation_prompt_data(self):
159        """Test with invitation, check data in session"""
160        data = {"foo": "bar"}
161        invite = Invitation.objects.create(
162            created_by=get_anonymous_user(), fixed_data=data, single_use=True
163        )
164
165        plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
166        plan.context[PLAN_CONTEXT_PROMPT] = {PLAN_CONTEXT_INVITATION_TOKEN: invite.pk.hex}
167        session = self.client.session
168        session[SESSION_KEY_PLAN] = plan
169        session.save()
170
171        with patch("authentik.flows.views.executor.FlowExecutorView.cancel", MagicMock()):
172            base_url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug})
173            response = self.client.get(base_url, follow=True)
174
175        session = self.client.session
176        plan: FlowPlan = session[SESSION_KEY_PLAN]
177        self.assertEqual(
178            plan.context[PLAN_CONTEXT_PROMPT], data | plan.context[PLAN_CONTEXT_PROMPT]
179        )
180
181        self.assertEqual(response.status_code, 200)
182        self.assertStageRedirects(response, reverse("authentik_core:root-redirect"))
183        self.assertFalse(Invitation.objects.filter(pk=invite.pk))
184
185
186class TestInvitationsAPI(APITestCase):
187    """Test Invitations API"""
188
189    def setUp(self) -> None:
190        super().setUp()
191        self.user = create_test_admin_user()
192        self.client.force_login(self.user)
193
194    def test_invite_create(self):
195        """Test Invitations creation endpoint"""
196        response = self.client.post(
197            reverse("authentik_api:invitation-list"),
198            {"name": "test-token", "fixed_data": {}},
199            format="json",
200        )
201        self.assertEqual(response.status_code, 201)
202        self.assertEqual(Invitation.objects.first().created_by, self.user)
203
204    def test_invite_create_blueprint_context(self):
205        """Test Invitations creation via blueprint context"""
206
207        flow = create_test_flow(FlowDesignation.ENROLLMENT)
208        data = {
209            "name": "test-blueprint-invitation",
210            "flow": flow.pk.hex,
211            "single_use": True,
212            "fixed_data": {"email": "test@example.com"},
213        }
214        serializer = InvitationSerializer(data=data, context={SERIALIZER_CONTEXT_BLUEPRINT: True})
215        self.assertTrue(serializer.is_valid())
216        invitation = serializer.save()
217        self.assertEqual(invitation.created_by, get_anonymous_user())
218        self.assertEqual(invitation.name, "test-blueprint-invitation")
219        self.assertEqual(invitation.fixed_data, {"email": "test@example.com"})
220
221    def test_send_email_no_addresses(self):
222        """Test send_email endpoint with no email addresses"""
223        flow = create_test_flow(FlowDesignation.ENROLLMENT)
224        invite = Invitation.objects.create(
225            name="test-invite",
226            created_by=self.user,
227            flow=flow,
228        )
229
230        response = self.client.post(
231            reverse("authentik_api:invitation-send-email", kwargs={"pk": invite.pk}),
232            {"email_addresses": []},
233            format="json",
234        )
235        self.assertEqual(response.status_code, 400)
236        self.assertIn("error", response.data)
237
238    def test_send_email_no_flow(self):
239        """Test send_email endpoint with invitation without flow"""
240        invite = Invitation.objects.create(
241            name="test-invite-no-flow",
242            created_by=self.user,
243            flow=None,
244        )
245
246        response = self.client.post(
247            reverse("authentik_api:invitation-send-email", kwargs={"pk": invite.pk}),
248            {"email_addresses": ["test@example.com"]},
249            format="json",
250        )
251        self.assertEqual(response.status_code, 400)
252        self.assertIn("error", response.data)
253
254    @patch("authentik.stages.invitation.api.BaseEvaluator.expr_send_email")
255    def test_send_email_success(self, mock_send_email: MagicMock):
256        """Test send_email endpoint successfully queues emails"""
257        flow = create_test_flow(FlowDesignation.ENROLLMENT)
258        invite = Invitation.objects.create(
259            name="test-invite",
260            created_by=self.user,
261            flow=flow,
262        )
263
264        response = self.client.post(
265            reverse("authentik_api:invitation-send-email", kwargs={"pk": invite.pk}),
266            {
267                "email_addresses": ["user1@example.com", "user2@example.com"],
268                "template": "email/invitation.html",
269            },
270            format="json",
271        )
272        self.assertEqual(response.status_code, 204)
273        self.assertEqual(mock_send_email.call_count, 2)
274
275    @patch("authentik.stages.invitation.api.BaseEvaluator.expr_send_email")
276    def test_send_email_with_cc_bcc(self, mock_send_email: MagicMock):
277        """Test send_email endpoint with CC and BCC addresses"""
278        flow = create_test_flow(FlowDesignation.ENROLLMENT)
279        invite = Invitation.objects.create(
280            name="test-invite",
281            created_by=self.user,
282            flow=flow,
283        )
284
285        response = self.client.post(
286            reverse("authentik_api:invitation-send-email", kwargs={"pk": invite.pk}),
287            {
288                "email_addresses": ["user@example.com"],
289                "cc_addresses": ["cc@example.com"],
290                "bcc_addresses": ["bcc@example.com"],
291                "template": "email/invitation.html",
292            },
293            format="json",
294        )
295        self.assertEqual(response.status_code, 204)
296        mock_send_email.assert_called_once()
297        call_kwargs = mock_send_email.call_args.kwargs
298        self.assertEqual(call_kwargs["cc"], ["cc@example.com"])
299        self.assertEqual(call_kwargs["bcc"], ["bcc@example.com"])
300
301    @patch("authentik.stages.invitation.api.BaseEvaluator.expr_send_email")
302    def test_send_email_context(self, mock_send_email: MagicMock):
303        """Test send_email endpoint passes correct context to email"""
304        flow = create_test_flow(FlowDesignation.ENROLLMENT)
305        invite = Invitation.objects.create(
306            name="test-invite",
307            created_by=self.user,
308            flow=flow,
309        )
310
311        response = self.client.post(
312            reverse("authentik_api:invitation-send-email", kwargs={"pk": invite.pk}),
313            {"email_addresses": ["user@example.com"]},
314            format="json",
315        )
316        self.assertEqual(response.status_code, 204)
317        mock_send_email.assert_called_once()
318        call_kwargs = mock_send_email.call_args.kwargs
319        self.assertIn("url", call_kwargs["context"])
320        self.assertIn(str(invite.pk), call_kwargs["context"]["url"])
321        self.assertIn(flow.slug, call_kwargs["context"]["url"])
class TestInvitationStage(authentik.flows.tests.FlowTestCase):
 32class TestInvitationStage(FlowTestCase):
 33    """Login tests"""
 34
 35    def setUp(self):
 36        super().setUp()
 37        self.user = create_test_admin_user()
 38        self.flow = create_test_flow(FlowDesignation.AUTHENTICATION)
 39        self.stage = InvitationStage.objects.create(name="invitation")
 40        self.binding = FlowStageBinding.objects.create(target=self.flow, stage=self.stage, order=2)
 41
 42    @patch(
 43        "authentik.flows.views.executor.to_stage_response",
 44        TO_STAGE_RESPONSE_MOCK,
 45    )
 46    def test_without_invitation_fail(self):
 47        """Test without any invitation, continue_flow_without_invitation not set."""
 48        plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
 49        plan.context[PLAN_CONTEXT_PENDING_USER] = self.user
 50        plan.context[PLAN_CONTEXT_AUTHENTICATION_BACKEND] = BACKEND_INBUILT
 51        session = self.client.session
 52        session[SESSION_KEY_PLAN] = plan
 53        session.save()
 54
 55        response = self.client.get(
 56            reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug})
 57        )
 58        self.assertStageResponse(
 59            response,
 60            flow=self.flow,
 61            component="ak-stage-access-denied",
 62        )
 63
 64    def test_without_invitation_continue(self):
 65        """Test without any invitation, continue_flow_without_invitation is set."""
 66        self.stage.continue_flow_without_invitation = True
 67        self.stage.save()
 68        plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
 69        plan.context[PLAN_CONTEXT_PENDING_USER] = self.user
 70        plan.context[PLAN_CONTEXT_AUTHENTICATION_BACKEND] = BACKEND_INBUILT
 71        session = self.client.session
 72        session[SESSION_KEY_PLAN] = plan
 73        session.save()
 74
 75        response = self.client.get(
 76            reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug})
 77        )
 78
 79        self.assertEqual(response.status_code, 200)
 80        self.assertStageRedirects(response, reverse("authentik_core:root-redirect"))
 81
 82        self.stage.continue_flow_without_invitation = False
 83        self.stage.save()
 84
 85    def test_with_invitation_expired(self):
 86        """Test with invitation, expired"""
 87        plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
 88        session = self.client.session
 89        session[SESSION_KEY_PLAN] = plan
 90        session.save()
 91
 92        data = {"foo": "bar"}
 93        invite = Invitation.objects.create(
 94            created_by=get_anonymous_user(),
 95            fixed_data=data,
 96            expires=now() - timedelta(hours=1),
 97        )
 98
 99        base_url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug})
100        args = urlencode({QS_INVITATION_TOKEN_KEY: invite.pk.hex})
101        response = self.client.get(base_url + f"?query={args}")
102
103        self.assertEqual(response.status_code, 200)
104        self.assertStageResponse(
105            response,
106            flow=self.flow,
107            component="ak-stage-access-denied",
108        )
109
110    def test_with_invitation_get(self):
111        """Test with invitation, check data in session"""
112        plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
113        session = self.client.session
114        session[SESSION_KEY_PLAN] = plan
115        session.save()
116
117        data = {"foo": "bar"}
118        invite = Invitation.objects.create(created_by=get_anonymous_user(), fixed_data=data)
119
120        with patch("authentik.flows.views.executor.FlowExecutorView.cancel", MagicMock()):
121            base_url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug})
122            args = urlencode({QS_INVITATION_TOKEN_KEY: invite.pk.hex})
123            response = self.client.get(base_url + f"?query={args}")
124
125        session = self.client.session
126        plan: FlowPlan = session[SESSION_KEY_PLAN]
127        self.assertEqual(plan.context[PLAN_CONTEXT_PROMPT], data)
128
129        self.assertEqual(response.status_code, 200)
130        self.assertStageRedirects(response, reverse("authentik_core:root-redirect"))
131
132    def test_invalid_flow(self):
133        """Test with invitation, invalid flow limit"""
134        invalid_flow = create_test_flow(FlowDesignation.ENROLLMENT)
135        plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
136        session = self.client.session
137        session[SESSION_KEY_PLAN] = plan
138        session.save()
139
140        data = {"foo": "bar"}
141        invite = Invitation.objects.create(
142            created_by=get_anonymous_user(), fixed_data=data, flow=invalid_flow
143        )
144
145        with patch("authentik.flows.views.executor.FlowExecutorView.cancel", MagicMock()):
146            base_url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug})
147            args = urlencode({QS_INVITATION_TOKEN_KEY: invite.pk.hex})
148            response = self.client.get(base_url + f"?query={args}")
149
150        session = self.client.session
151        plan: FlowPlan = session[SESSION_KEY_PLAN]
152
153        self.assertStageResponse(
154            response,
155            flow=self.flow,
156            component="ak-stage-access-denied",
157        )
158
159    def test_with_invitation_prompt_data(self):
160        """Test with invitation, check data in session"""
161        data = {"foo": "bar"}
162        invite = Invitation.objects.create(
163            created_by=get_anonymous_user(), fixed_data=data, single_use=True
164        )
165
166        plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
167        plan.context[PLAN_CONTEXT_PROMPT] = {PLAN_CONTEXT_INVITATION_TOKEN: invite.pk.hex}
168        session = self.client.session
169        session[SESSION_KEY_PLAN] = plan
170        session.save()
171
172        with patch("authentik.flows.views.executor.FlowExecutorView.cancel", MagicMock()):
173            base_url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug})
174            response = self.client.get(base_url, follow=True)
175
176        session = self.client.session
177        plan: FlowPlan = session[SESSION_KEY_PLAN]
178        self.assertEqual(
179            plan.context[PLAN_CONTEXT_PROMPT], data | plan.context[PLAN_CONTEXT_PROMPT]
180        )
181
182        self.assertEqual(response.status_code, 200)
183        self.assertStageRedirects(response, reverse("authentik_core:root-redirect"))
184        self.assertFalse(Invitation.objects.filter(pk=invite.pk))

Login tests

def setUp(self):
35    def setUp(self):
36        super().setUp()
37        self.user = create_test_admin_user()
38        self.flow = create_test_flow(FlowDesignation.AUTHENTICATION)
39        self.stage = InvitationStage.objects.create(name="invitation")
40        self.binding = FlowStageBinding.objects.create(target=self.flow, stage=self.stage, order=2)

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

@patch('authentik.flows.views.executor.to_stage_response', TO_STAGE_RESPONSE_MOCK)
def test_without_invitation_fail(self):
42    @patch(
43        "authentik.flows.views.executor.to_stage_response",
44        TO_STAGE_RESPONSE_MOCK,
45    )
46    def test_without_invitation_fail(self):
47        """Test without any invitation, continue_flow_without_invitation not set."""
48        plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
49        plan.context[PLAN_CONTEXT_PENDING_USER] = self.user
50        plan.context[PLAN_CONTEXT_AUTHENTICATION_BACKEND] = BACKEND_INBUILT
51        session = self.client.session
52        session[SESSION_KEY_PLAN] = plan
53        session.save()
54
55        response = self.client.get(
56            reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug})
57        )
58        self.assertStageResponse(
59            response,
60            flow=self.flow,
61            component="ak-stage-access-denied",
62        )

Test without any invitation, continue_flow_without_invitation not set.

def test_without_invitation_continue(self):
64    def test_without_invitation_continue(self):
65        """Test without any invitation, continue_flow_without_invitation is set."""
66        self.stage.continue_flow_without_invitation = True
67        self.stage.save()
68        plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
69        plan.context[PLAN_CONTEXT_PENDING_USER] = self.user
70        plan.context[PLAN_CONTEXT_AUTHENTICATION_BACKEND] = BACKEND_INBUILT
71        session = self.client.session
72        session[SESSION_KEY_PLAN] = plan
73        session.save()
74
75        response = self.client.get(
76            reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug})
77        )
78
79        self.assertEqual(response.status_code, 200)
80        self.assertStageRedirects(response, reverse("authentik_core:root-redirect"))
81
82        self.stage.continue_flow_without_invitation = False
83        self.stage.save()

Test without any invitation, continue_flow_without_invitation is set.

def test_with_invitation_expired(self):
 85    def test_with_invitation_expired(self):
 86        """Test with invitation, expired"""
 87        plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
 88        session = self.client.session
 89        session[SESSION_KEY_PLAN] = plan
 90        session.save()
 91
 92        data = {"foo": "bar"}
 93        invite = Invitation.objects.create(
 94            created_by=get_anonymous_user(),
 95            fixed_data=data,
 96            expires=now() - timedelta(hours=1),
 97        )
 98
 99        base_url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug})
100        args = urlencode({QS_INVITATION_TOKEN_KEY: invite.pk.hex})
101        response = self.client.get(base_url + f"?query={args}")
102
103        self.assertEqual(response.status_code, 200)
104        self.assertStageResponse(
105            response,
106            flow=self.flow,
107            component="ak-stage-access-denied",
108        )

Test with invitation, expired

def test_with_invitation_get(self):
110    def test_with_invitation_get(self):
111        """Test with invitation, check data in session"""
112        plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
113        session = self.client.session
114        session[SESSION_KEY_PLAN] = plan
115        session.save()
116
117        data = {"foo": "bar"}
118        invite = Invitation.objects.create(created_by=get_anonymous_user(), fixed_data=data)
119
120        with patch("authentik.flows.views.executor.FlowExecutorView.cancel", MagicMock()):
121            base_url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug})
122            args = urlencode({QS_INVITATION_TOKEN_KEY: invite.pk.hex})
123            response = self.client.get(base_url + f"?query={args}")
124
125        session = self.client.session
126        plan: FlowPlan = session[SESSION_KEY_PLAN]
127        self.assertEqual(plan.context[PLAN_CONTEXT_PROMPT], data)
128
129        self.assertEqual(response.status_code, 200)
130        self.assertStageRedirects(response, reverse("authentik_core:root-redirect"))

Test with invitation, check data in session

def test_invalid_flow(self):
132    def test_invalid_flow(self):
133        """Test with invitation, invalid flow limit"""
134        invalid_flow = create_test_flow(FlowDesignation.ENROLLMENT)
135        plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
136        session = self.client.session
137        session[SESSION_KEY_PLAN] = plan
138        session.save()
139
140        data = {"foo": "bar"}
141        invite = Invitation.objects.create(
142            created_by=get_anonymous_user(), fixed_data=data, flow=invalid_flow
143        )
144
145        with patch("authentik.flows.views.executor.FlowExecutorView.cancel", MagicMock()):
146            base_url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug})
147            args = urlencode({QS_INVITATION_TOKEN_KEY: invite.pk.hex})
148            response = self.client.get(base_url + f"?query={args}")
149
150        session = self.client.session
151        plan: FlowPlan = session[SESSION_KEY_PLAN]
152
153        self.assertStageResponse(
154            response,
155            flow=self.flow,
156            component="ak-stage-access-denied",
157        )

Test with invitation, invalid flow limit

def test_with_invitation_prompt_data(self):
159    def test_with_invitation_prompt_data(self):
160        """Test with invitation, check data in session"""
161        data = {"foo": "bar"}
162        invite = Invitation.objects.create(
163            created_by=get_anonymous_user(), fixed_data=data, single_use=True
164        )
165
166        plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
167        plan.context[PLAN_CONTEXT_PROMPT] = {PLAN_CONTEXT_INVITATION_TOKEN: invite.pk.hex}
168        session = self.client.session
169        session[SESSION_KEY_PLAN] = plan
170        session.save()
171
172        with patch("authentik.flows.views.executor.FlowExecutorView.cancel", MagicMock()):
173            base_url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug})
174            response = self.client.get(base_url, follow=True)
175
176        session = self.client.session
177        plan: FlowPlan = session[SESSION_KEY_PLAN]
178        self.assertEqual(
179            plan.context[PLAN_CONTEXT_PROMPT], data | plan.context[PLAN_CONTEXT_PROMPT]
180        )
181
182        self.assertEqual(response.status_code, 200)
183        self.assertStageRedirects(response, reverse("authentik_core:root-redirect"))
184        self.assertFalse(Invitation.objects.filter(pk=invite.pk))

Test with invitation, check data in session

class TestInvitationsAPI(rest_framework.test.APITestCase):
187class TestInvitationsAPI(APITestCase):
188    """Test Invitations API"""
189
190    def setUp(self) -> None:
191        super().setUp()
192        self.user = create_test_admin_user()
193        self.client.force_login(self.user)
194
195    def test_invite_create(self):
196        """Test Invitations creation endpoint"""
197        response = self.client.post(
198            reverse("authentik_api:invitation-list"),
199            {"name": "test-token", "fixed_data": {}},
200            format="json",
201        )
202        self.assertEqual(response.status_code, 201)
203        self.assertEqual(Invitation.objects.first().created_by, self.user)
204
205    def test_invite_create_blueprint_context(self):
206        """Test Invitations creation via blueprint context"""
207
208        flow = create_test_flow(FlowDesignation.ENROLLMENT)
209        data = {
210            "name": "test-blueprint-invitation",
211            "flow": flow.pk.hex,
212            "single_use": True,
213            "fixed_data": {"email": "test@example.com"},
214        }
215        serializer = InvitationSerializer(data=data, context={SERIALIZER_CONTEXT_BLUEPRINT: True})
216        self.assertTrue(serializer.is_valid())
217        invitation = serializer.save()
218        self.assertEqual(invitation.created_by, get_anonymous_user())
219        self.assertEqual(invitation.name, "test-blueprint-invitation")
220        self.assertEqual(invitation.fixed_data, {"email": "test@example.com"})
221
222    def test_send_email_no_addresses(self):
223        """Test send_email endpoint with no email addresses"""
224        flow = create_test_flow(FlowDesignation.ENROLLMENT)
225        invite = Invitation.objects.create(
226            name="test-invite",
227            created_by=self.user,
228            flow=flow,
229        )
230
231        response = self.client.post(
232            reverse("authentik_api:invitation-send-email", kwargs={"pk": invite.pk}),
233            {"email_addresses": []},
234            format="json",
235        )
236        self.assertEqual(response.status_code, 400)
237        self.assertIn("error", response.data)
238
239    def test_send_email_no_flow(self):
240        """Test send_email endpoint with invitation without flow"""
241        invite = Invitation.objects.create(
242            name="test-invite-no-flow",
243            created_by=self.user,
244            flow=None,
245        )
246
247        response = self.client.post(
248            reverse("authentik_api:invitation-send-email", kwargs={"pk": invite.pk}),
249            {"email_addresses": ["test@example.com"]},
250            format="json",
251        )
252        self.assertEqual(response.status_code, 400)
253        self.assertIn("error", response.data)
254
255    @patch("authentik.stages.invitation.api.BaseEvaluator.expr_send_email")
256    def test_send_email_success(self, mock_send_email: MagicMock):
257        """Test send_email endpoint successfully queues emails"""
258        flow = create_test_flow(FlowDesignation.ENROLLMENT)
259        invite = Invitation.objects.create(
260            name="test-invite",
261            created_by=self.user,
262            flow=flow,
263        )
264
265        response = self.client.post(
266            reverse("authentik_api:invitation-send-email", kwargs={"pk": invite.pk}),
267            {
268                "email_addresses": ["user1@example.com", "user2@example.com"],
269                "template": "email/invitation.html",
270            },
271            format="json",
272        )
273        self.assertEqual(response.status_code, 204)
274        self.assertEqual(mock_send_email.call_count, 2)
275
276    @patch("authentik.stages.invitation.api.BaseEvaluator.expr_send_email")
277    def test_send_email_with_cc_bcc(self, mock_send_email: MagicMock):
278        """Test send_email endpoint with CC and BCC addresses"""
279        flow = create_test_flow(FlowDesignation.ENROLLMENT)
280        invite = Invitation.objects.create(
281            name="test-invite",
282            created_by=self.user,
283            flow=flow,
284        )
285
286        response = self.client.post(
287            reverse("authentik_api:invitation-send-email", kwargs={"pk": invite.pk}),
288            {
289                "email_addresses": ["user@example.com"],
290                "cc_addresses": ["cc@example.com"],
291                "bcc_addresses": ["bcc@example.com"],
292                "template": "email/invitation.html",
293            },
294            format="json",
295        )
296        self.assertEqual(response.status_code, 204)
297        mock_send_email.assert_called_once()
298        call_kwargs = mock_send_email.call_args.kwargs
299        self.assertEqual(call_kwargs["cc"], ["cc@example.com"])
300        self.assertEqual(call_kwargs["bcc"], ["bcc@example.com"])
301
302    @patch("authentik.stages.invitation.api.BaseEvaluator.expr_send_email")
303    def test_send_email_context(self, mock_send_email: MagicMock):
304        """Test send_email endpoint passes correct context to email"""
305        flow = create_test_flow(FlowDesignation.ENROLLMENT)
306        invite = Invitation.objects.create(
307            name="test-invite",
308            created_by=self.user,
309            flow=flow,
310        )
311
312        response = self.client.post(
313            reverse("authentik_api:invitation-send-email", kwargs={"pk": invite.pk}),
314            {"email_addresses": ["user@example.com"]},
315            format="json",
316        )
317        self.assertEqual(response.status_code, 204)
318        mock_send_email.assert_called_once()
319        call_kwargs = mock_send_email.call_args.kwargs
320        self.assertIn("url", call_kwargs["context"])
321        self.assertIn(str(invite.pk), call_kwargs["context"]["url"])
322        self.assertIn(flow.slug, call_kwargs["context"]["url"])

Test Invitations API

def setUp(self) -> None:
190    def setUp(self) -> None:
191        super().setUp()
192        self.user = create_test_admin_user()
193        self.client.force_login(self.user)

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

def test_invite_create(self):
195    def test_invite_create(self):
196        """Test Invitations creation endpoint"""
197        response = self.client.post(
198            reverse("authentik_api:invitation-list"),
199            {"name": "test-token", "fixed_data": {}},
200            format="json",
201        )
202        self.assertEqual(response.status_code, 201)
203        self.assertEqual(Invitation.objects.first().created_by, self.user)

Test Invitations creation endpoint

def test_invite_create_blueprint_context(self):
205    def test_invite_create_blueprint_context(self):
206        """Test Invitations creation via blueprint context"""
207
208        flow = create_test_flow(FlowDesignation.ENROLLMENT)
209        data = {
210            "name": "test-blueprint-invitation",
211            "flow": flow.pk.hex,
212            "single_use": True,
213            "fixed_data": {"email": "test@example.com"},
214        }
215        serializer = InvitationSerializer(data=data, context={SERIALIZER_CONTEXT_BLUEPRINT: True})
216        self.assertTrue(serializer.is_valid())
217        invitation = serializer.save()
218        self.assertEqual(invitation.created_by, get_anonymous_user())
219        self.assertEqual(invitation.name, "test-blueprint-invitation")
220        self.assertEqual(invitation.fixed_data, {"email": "test@example.com"})

Test Invitations creation via blueprint context

def test_send_email_no_addresses(self):
222    def test_send_email_no_addresses(self):
223        """Test send_email endpoint with no email addresses"""
224        flow = create_test_flow(FlowDesignation.ENROLLMENT)
225        invite = Invitation.objects.create(
226            name="test-invite",
227            created_by=self.user,
228            flow=flow,
229        )
230
231        response = self.client.post(
232            reverse("authentik_api:invitation-send-email", kwargs={"pk": invite.pk}),
233            {"email_addresses": []},
234            format="json",
235        )
236        self.assertEqual(response.status_code, 400)
237        self.assertIn("error", response.data)

Test send_email endpoint with no email addresses

def test_send_email_no_flow(self):
239    def test_send_email_no_flow(self):
240        """Test send_email endpoint with invitation without flow"""
241        invite = Invitation.objects.create(
242            name="test-invite-no-flow",
243            created_by=self.user,
244            flow=None,
245        )
246
247        response = self.client.post(
248            reverse("authentik_api:invitation-send-email", kwargs={"pk": invite.pk}),
249            {"email_addresses": ["test@example.com"]},
250            format="json",
251        )
252        self.assertEqual(response.status_code, 400)
253        self.assertIn("error", response.data)

Test send_email endpoint with invitation without flow

@patch('authentik.stages.invitation.api.BaseEvaluator.expr_send_email')
def test_send_email_success(self, mock_send_email: unittest.mock.MagicMock):
255    @patch("authentik.stages.invitation.api.BaseEvaluator.expr_send_email")
256    def test_send_email_success(self, mock_send_email: MagicMock):
257        """Test send_email endpoint successfully queues emails"""
258        flow = create_test_flow(FlowDesignation.ENROLLMENT)
259        invite = Invitation.objects.create(
260            name="test-invite",
261            created_by=self.user,
262            flow=flow,
263        )
264
265        response = self.client.post(
266            reverse("authentik_api:invitation-send-email", kwargs={"pk": invite.pk}),
267            {
268                "email_addresses": ["user1@example.com", "user2@example.com"],
269                "template": "email/invitation.html",
270            },
271            format="json",
272        )
273        self.assertEqual(response.status_code, 204)
274        self.assertEqual(mock_send_email.call_count, 2)

Test send_email endpoint successfully queues emails

@patch('authentik.stages.invitation.api.BaseEvaluator.expr_send_email')
def test_send_email_with_cc_bcc(self, mock_send_email: unittest.mock.MagicMock):
276    @patch("authentik.stages.invitation.api.BaseEvaluator.expr_send_email")
277    def test_send_email_with_cc_bcc(self, mock_send_email: MagicMock):
278        """Test send_email endpoint with CC and BCC addresses"""
279        flow = create_test_flow(FlowDesignation.ENROLLMENT)
280        invite = Invitation.objects.create(
281            name="test-invite",
282            created_by=self.user,
283            flow=flow,
284        )
285
286        response = self.client.post(
287            reverse("authentik_api:invitation-send-email", kwargs={"pk": invite.pk}),
288            {
289                "email_addresses": ["user@example.com"],
290                "cc_addresses": ["cc@example.com"],
291                "bcc_addresses": ["bcc@example.com"],
292                "template": "email/invitation.html",
293            },
294            format="json",
295        )
296        self.assertEqual(response.status_code, 204)
297        mock_send_email.assert_called_once()
298        call_kwargs = mock_send_email.call_args.kwargs
299        self.assertEqual(call_kwargs["cc"], ["cc@example.com"])
300        self.assertEqual(call_kwargs["bcc"], ["bcc@example.com"])

Test send_email endpoint with CC and BCC addresses

@patch('authentik.stages.invitation.api.BaseEvaluator.expr_send_email')
def test_send_email_context(self, mock_send_email: unittest.mock.MagicMock):
302    @patch("authentik.stages.invitation.api.BaseEvaluator.expr_send_email")
303    def test_send_email_context(self, mock_send_email: MagicMock):
304        """Test send_email endpoint passes correct context to email"""
305        flow = create_test_flow(FlowDesignation.ENROLLMENT)
306        invite = Invitation.objects.create(
307            name="test-invite",
308            created_by=self.user,
309            flow=flow,
310        )
311
312        response = self.client.post(
313            reverse("authentik_api:invitation-send-email", kwargs={"pk": invite.pk}),
314            {"email_addresses": ["user@example.com"]},
315            format="json",
316        )
317        self.assertEqual(response.status_code, 204)
318        mock_send_email.assert_called_once()
319        call_kwargs = mock_send_email.call_args.kwargs
320        self.assertIn("url", call_kwargs["context"])
321        self.assertIn(str(invite.pk), call_kwargs["context"]["url"])
322        self.assertIn(flow.slug, call_kwargs["context"]["url"])

Test send_email endpoint passes correct context to email