authentik.blueprints.tests.test_v1_api

Test blueprints v1 api

  1"""Test blueprints v1 api"""
  2
  3from json import dumps, loads
  4from tempfile import NamedTemporaryFile, mkdtemp
  5
  6from django.core.files.uploadedfile import SimpleUploadedFile
  7from django.urls import reverse
  8from rest_framework.test import APITestCase
  9from yaml import dump
 10
 11from authentik.core.tests.utils import create_test_admin_user
 12from authentik.flows.models import Flow
 13from authentik.lib.config import CONFIG
 14from authentik.lib.generators import generate_id
 15from authentik.stages.invitation.models import InvitationStage
 16from authentik.stages.user_write.models import UserWriteStage
 17
 18TMP = mkdtemp("authentik-blueprints")
 19
 20
 21class TestBlueprintsV1API(APITestCase):
 22    """Test Blueprints API"""
 23
 24    def setUp(self) -> None:
 25        self.user = create_test_admin_user()
 26        self.client.force_login(self.user)
 27
 28    @CONFIG.patch("blueprints_dir", TMP)
 29    def test_api_available(self):
 30        """Test valid file"""
 31        with NamedTemporaryFile(mode="w+", suffix=".yaml", dir=TMP) as file:
 32            file.write(
 33                dump(
 34                    {
 35                        "version": 1,
 36                        "entries": [],
 37                    }
 38                )
 39            )
 40            file.flush()
 41            res = self.client.get(reverse("authentik_api:blueprintinstance-available"))
 42            self.assertEqual(res.status_code, 200)
 43            response = loads(res.content.decode())
 44            self.assertEqual(len(response), 1)
 45            self.assertEqual(
 46                response[0]["hash"],
 47                (
 48                    "e52bb445b03cd36057258dc9f0ce0fbed8278498ee1470e45315293e5f026d1bd1f9b352"
 49                    "6871c0003f5c07be5c3316d9d4a08444bd8fed1b3f03294e51e44522"
 50                ),
 51            )
 52
 53    def test_api_oci(self):
 54        """Test validation with OCI path"""
 55        res = self.client.post(
 56            reverse("authentik_api:blueprintinstance-list"),
 57            data={"name": "foo", "path": "oci://foo/bar"},
 58        )
 59        self.assertEqual(res.status_code, 201)
 60
 61    def test_api_blank(self):
 62        """Test blank"""
 63        res = self.client.post(
 64            reverse("authentik_api:blueprintinstance-list"),
 65            data={
 66                "name": "foo",
 67            },
 68        )
 69        self.assertEqual(res.status_code, 400)
 70        self.assertJSONEqual(
 71            res.content.decode(), {"non_field_errors": ["Either path or content must be set."]}
 72        )
 73
 74    def test_api_content(self):
 75        """Test blank"""
 76        res = self.client.post(
 77            reverse("authentik_api:blueprintinstance-list"),
 78            data={
 79                "name": "foo",
 80                "content": '{"version": 3}',
 81            },
 82        )
 83        self.assertEqual(res.status_code, 400)
 84        self.assertJSONEqual(
 85            res.content.decode(),
 86            {"content": ["Failed to validate blueprint", "- Invalid blueprint version"]},
 87        )
 88
 89    def test_api_import_with_context(self):
 90        """Test that the import endpoint applies the supplied context to the real blueprint"""
 91        slug = f"invitation-enrollment-{generate_id()}"
 92        flow_name = f"Invitation Enrollment {generate_id()}"
 93        stage_name = f"invitation-stage-{generate_id()}"
 94        user_type = "internal"
 95        continue_without_invitation = True
 96
 97        res = self.client.post(
 98            reverse("authentik_api:blueprintinstance-import-"),
 99            data={
100                "path": "example/flows-invitation-enrollment-minimal.yaml",
101                "context": dumps(
102                    {
103                        "flow_slug": slug,
104                        "flow_name": flow_name,
105                        "stage_name": stage_name,
106                        "continue_flow_without_invitation": continue_without_invitation,
107                        "user_type": user_type,
108                    }
109                ),
110            },
111            format="multipart",
112        )
113        self.assertEqual(res.status_code, 200)
114        self.assertTrue(res.json()["success"])
115
116        flow = Flow.objects.get(slug=slug)
117        self.assertEqual(flow.name, flow_name)
118        self.assertEqual(flow.title, flow_name)
119
120        invitation_stage = InvitationStage.objects.get(name=stage_name)
121        self.assertEqual(
122            invitation_stage.continue_flow_without_invitation,
123            continue_without_invitation,
124        )
125
126        user_write_stage = UserWriteStage.objects.get(
127            name=f"invitation-enrollment-user-write-{slug}"
128        )
129        self.assertEqual(user_write_stage.user_type, user_type)
130        self.assertEqual(user_write_stage.user_path_template, f"users/{user_type}")
131
132    def test_api_import_blank_path(self):
133        """Validator returns empty path unchanged (covers api.py:53)."""
134        with NamedTemporaryFile(mode="w+", suffix=".yaml") as file:
135            file.write(dump({"version": 1, "entries": []}))
136            file.flush()
137            file.seek(0)
138            res = self.client.post(
139                reverse("authentik_api:blueprintinstance-import-"),
140                data={"path": "", "file": file},
141                format="multipart",
142            )
143        self.assertEqual(res.status_code, 200)
144
145    def test_api_import_invalid_blueprint_returns_result_payload(self):
146        """Invalid blueprint content returns a result payload instead of a 400 response."""
147        file = SimpleUploadedFile("invalid-blueprint.yaml", b'{"version": 3}')
148
149        res = self.client.post(
150            reverse("authentik_api:blueprintinstance-import-"),
151            data={"file": file},
152            format="multipart",
153        )
154
155        self.assertEqual(res.status_code, 200)
156        self.assertFalse(res.json()["success"])
157        self.assertGreater(len(res.json()["logs"]), 0)
158
159    def test_api_import_unknown_path(self):
160        """Path not in available blueprints is rejected (covers api.py:56)."""
161        res = self.client.post(
162            reverse("authentik_api:blueprintinstance-import-"),
163            data={"path": "does/not/exist.yaml"},
164            format="multipart",
165        )
166        self.assertEqual(res.status_code, 400)
167        self.assertIn("Blueprint file does not exist", res.content.decode())
168
169    def test_api_import_blank_context(self):
170        """Blank context is normalized to empty dict (covers api.py:62)."""
171        res = self.client.post(
172            reverse("authentik_api:blueprintinstance-import-"),
173            data={
174                "path": "example/flows-invitation-enrollment-minimal.yaml",
175                "context": "",
176            },
177            format="multipart",
178        )
179        self.assertEqual(res.status_code, 200)
180
181    def test_api_import_invalid_json_context(self):
182        """Malformed JSON context raises ValidationError (covers api.py:65-66)."""
183        res = self.client.post(
184            reverse("authentik_api:blueprintinstance-import-"),
185            data={
186                "path": "example/flows-invitation-enrollment-minimal.yaml",
187                "context": "{not json",
188            },
189            format="multipart",
190        )
191        self.assertEqual(res.status_code, 400)
192        self.assertIn("Context must be valid JSON", res.content.decode())
193
194    def test_api_import_non_object_context(self):
195        """JSON context that isn't an object is rejected (covers api.py:68)."""
196        res = self.client.post(
197            reverse("authentik_api:blueprintinstance-import-"),
198            data={
199                "path": "example/flows-invitation-enrollment-minimal.yaml",
200                "context": "[1, 2, 3]",
201            },
202            format="multipart",
203        )
204        self.assertEqual(res.status_code, 400)
205        self.assertIn("Context must be a JSON object", res.content.decode())
TMP = '/tmp/tmppkkuvhgzauthentik-blueprints'
class TestBlueprintsV1API(rest_framework.test.APITestCase):
 22class TestBlueprintsV1API(APITestCase):
 23    """Test Blueprints API"""
 24
 25    def setUp(self) -> None:
 26        self.user = create_test_admin_user()
 27        self.client.force_login(self.user)
 28
 29    @CONFIG.patch("blueprints_dir", TMP)
 30    def test_api_available(self):
 31        """Test valid file"""
 32        with NamedTemporaryFile(mode="w+", suffix=".yaml", dir=TMP) as file:
 33            file.write(
 34                dump(
 35                    {
 36                        "version": 1,
 37                        "entries": [],
 38                    }
 39                )
 40            )
 41            file.flush()
 42            res = self.client.get(reverse("authentik_api:blueprintinstance-available"))
 43            self.assertEqual(res.status_code, 200)
 44            response = loads(res.content.decode())
 45            self.assertEqual(len(response), 1)
 46            self.assertEqual(
 47                response[0]["hash"],
 48                (
 49                    "e52bb445b03cd36057258dc9f0ce0fbed8278498ee1470e45315293e5f026d1bd1f9b352"
 50                    "6871c0003f5c07be5c3316d9d4a08444bd8fed1b3f03294e51e44522"
 51                ),
 52            )
 53
 54    def test_api_oci(self):
 55        """Test validation with OCI path"""
 56        res = self.client.post(
 57            reverse("authentik_api:blueprintinstance-list"),
 58            data={"name": "foo", "path": "oci://foo/bar"},
 59        )
 60        self.assertEqual(res.status_code, 201)
 61
 62    def test_api_blank(self):
 63        """Test blank"""
 64        res = self.client.post(
 65            reverse("authentik_api:blueprintinstance-list"),
 66            data={
 67                "name": "foo",
 68            },
 69        )
 70        self.assertEqual(res.status_code, 400)
 71        self.assertJSONEqual(
 72            res.content.decode(), {"non_field_errors": ["Either path or content must be set."]}
 73        )
 74
 75    def test_api_content(self):
 76        """Test blank"""
 77        res = self.client.post(
 78            reverse("authentik_api:blueprintinstance-list"),
 79            data={
 80                "name": "foo",
 81                "content": '{"version": 3}',
 82            },
 83        )
 84        self.assertEqual(res.status_code, 400)
 85        self.assertJSONEqual(
 86            res.content.decode(),
 87            {"content": ["Failed to validate blueprint", "- Invalid blueprint version"]},
 88        )
 89
 90    def test_api_import_with_context(self):
 91        """Test that the import endpoint applies the supplied context to the real blueprint"""
 92        slug = f"invitation-enrollment-{generate_id()}"
 93        flow_name = f"Invitation Enrollment {generate_id()}"
 94        stage_name = f"invitation-stage-{generate_id()}"
 95        user_type = "internal"
 96        continue_without_invitation = True
 97
 98        res = self.client.post(
 99            reverse("authentik_api:blueprintinstance-import-"),
100            data={
101                "path": "example/flows-invitation-enrollment-minimal.yaml",
102                "context": dumps(
103                    {
104                        "flow_slug": slug,
105                        "flow_name": flow_name,
106                        "stage_name": stage_name,
107                        "continue_flow_without_invitation": continue_without_invitation,
108                        "user_type": user_type,
109                    }
110                ),
111            },
112            format="multipart",
113        )
114        self.assertEqual(res.status_code, 200)
115        self.assertTrue(res.json()["success"])
116
117        flow = Flow.objects.get(slug=slug)
118        self.assertEqual(flow.name, flow_name)
119        self.assertEqual(flow.title, flow_name)
120
121        invitation_stage = InvitationStage.objects.get(name=stage_name)
122        self.assertEqual(
123            invitation_stage.continue_flow_without_invitation,
124            continue_without_invitation,
125        )
126
127        user_write_stage = UserWriteStage.objects.get(
128            name=f"invitation-enrollment-user-write-{slug}"
129        )
130        self.assertEqual(user_write_stage.user_type, user_type)
131        self.assertEqual(user_write_stage.user_path_template, f"users/{user_type}")
132
133    def test_api_import_blank_path(self):
134        """Validator returns empty path unchanged (covers api.py:53)."""
135        with NamedTemporaryFile(mode="w+", suffix=".yaml") as file:
136            file.write(dump({"version": 1, "entries": []}))
137            file.flush()
138            file.seek(0)
139            res = self.client.post(
140                reverse("authentik_api:blueprintinstance-import-"),
141                data={"path": "", "file": file},
142                format="multipart",
143            )
144        self.assertEqual(res.status_code, 200)
145
146    def test_api_import_invalid_blueprint_returns_result_payload(self):
147        """Invalid blueprint content returns a result payload instead of a 400 response."""
148        file = SimpleUploadedFile("invalid-blueprint.yaml", b'{"version": 3}')
149
150        res = self.client.post(
151            reverse("authentik_api:blueprintinstance-import-"),
152            data={"file": file},
153            format="multipart",
154        )
155
156        self.assertEqual(res.status_code, 200)
157        self.assertFalse(res.json()["success"])
158        self.assertGreater(len(res.json()["logs"]), 0)
159
160    def test_api_import_unknown_path(self):
161        """Path not in available blueprints is rejected (covers api.py:56)."""
162        res = self.client.post(
163            reverse("authentik_api:blueprintinstance-import-"),
164            data={"path": "does/not/exist.yaml"},
165            format="multipart",
166        )
167        self.assertEqual(res.status_code, 400)
168        self.assertIn("Blueprint file does not exist", res.content.decode())
169
170    def test_api_import_blank_context(self):
171        """Blank context is normalized to empty dict (covers api.py:62)."""
172        res = self.client.post(
173            reverse("authentik_api:blueprintinstance-import-"),
174            data={
175                "path": "example/flows-invitation-enrollment-minimal.yaml",
176                "context": "",
177            },
178            format="multipart",
179        )
180        self.assertEqual(res.status_code, 200)
181
182    def test_api_import_invalid_json_context(self):
183        """Malformed JSON context raises ValidationError (covers api.py:65-66)."""
184        res = self.client.post(
185            reverse("authentik_api:blueprintinstance-import-"),
186            data={
187                "path": "example/flows-invitation-enrollment-minimal.yaml",
188                "context": "{not json",
189            },
190            format="multipart",
191        )
192        self.assertEqual(res.status_code, 400)
193        self.assertIn("Context must be valid JSON", res.content.decode())
194
195    def test_api_import_non_object_context(self):
196        """JSON context that isn't an object is rejected (covers api.py:68)."""
197        res = self.client.post(
198            reverse("authentik_api:blueprintinstance-import-"),
199            data={
200                "path": "example/flows-invitation-enrollment-minimal.yaml",
201                "context": "[1, 2, 3]",
202            },
203            format="multipart",
204        )
205        self.assertEqual(res.status_code, 400)
206        self.assertIn("Context must be a JSON object", res.content.decode())

Test Blueprints API

def setUp(self) -> None:
25    def setUp(self) -> None:
26        self.user = create_test_admin_user()
27        self.client.force_login(self.user)

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

@CONFIG.patch('blueprints_dir', TMP)
def test_api_available(self):
29    @CONFIG.patch("blueprints_dir", TMP)
30    def test_api_available(self):
31        """Test valid file"""
32        with NamedTemporaryFile(mode="w+", suffix=".yaml", dir=TMP) as file:
33            file.write(
34                dump(
35                    {
36                        "version": 1,
37                        "entries": [],
38                    }
39                )
40            )
41            file.flush()
42            res = self.client.get(reverse("authentik_api:blueprintinstance-available"))
43            self.assertEqual(res.status_code, 200)
44            response = loads(res.content.decode())
45            self.assertEqual(len(response), 1)
46            self.assertEqual(
47                response[0]["hash"],
48                (
49                    "e52bb445b03cd36057258dc9f0ce0fbed8278498ee1470e45315293e5f026d1bd1f9b352"
50                    "6871c0003f5c07be5c3316d9d4a08444bd8fed1b3f03294e51e44522"
51                ),
52            )

Test valid file

def test_api_oci(self):
54    def test_api_oci(self):
55        """Test validation with OCI path"""
56        res = self.client.post(
57            reverse("authentik_api:blueprintinstance-list"),
58            data={"name": "foo", "path": "oci://foo/bar"},
59        )
60        self.assertEqual(res.status_code, 201)

Test validation with OCI path

def test_api_blank(self):
62    def test_api_blank(self):
63        """Test blank"""
64        res = self.client.post(
65            reverse("authentik_api:blueprintinstance-list"),
66            data={
67                "name": "foo",
68            },
69        )
70        self.assertEqual(res.status_code, 400)
71        self.assertJSONEqual(
72            res.content.decode(), {"non_field_errors": ["Either path or content must be set."]}
73        )

Test blank

def test_api_content(self):
75    def test_api_content(self):
76        """Test blank"""
77        res = self.client.post(
78            reverse("authentik_api:blueprintinstance-list"),
79            data={
80                "name": "foo",
81                "content": '{"version": 3}',
82            },
83        )
84        self.assertEqual(res.status_code, 400)
85        self.assertJSONEqual(
86            res.content.decode(),
87            {"content": ["Failed to validate blueprint", "- Invalid blueprint version"]},
88        )

Test blank

def test_api_import_with_context(self):
 90    def test_api_import_with_context(self):
 91        """Test that the import endpoint applies the supplied context to the real blueprint"""
 92        slug = f"invitation-enrollment-{generate_id()}"
 93        flow_name = f"Invitation Enrollment {generate_id()}"
 94        stage_name = f"invitation-stage-{generate_id()}"
 95        user_type = "internal"
 96        continue_without_invitation = True
 97
 98        res = self.client.post(
 99            reverse("authentik_api:blueprintinstance-import-"),
100            data={
101                "path": "example/flows-invitation-enrollment-minimal.yaml",
102                "context": dumps(
103                    {
104                        "flow_slug": slug,
105                        "flow_name": flow_name,
106                        "stage_name": stage_name,
107                        "continue_flow_without_invitation": continue_without_invitation,
108                        "user_type": user_type,
109                    }
110                ),
111            },
112            format="multipart",
113        )
114        self.assertEqual(res.status_code, 200)
115        self.assertTrue(res.json()["success"])
116
117        flow = Flow.objects.get(slug=slug)
118        self.assertEqual(flow.name, flow_name)
119        self.assertEqual(flow.title, flow_name)
120
121        invitation_stage = InvitationStage.objects.get(name=stage_name)
122        self.assertEqual(
123            invitation_stage.continue_flow_without_invitation,
124            continue_without_invitation,
125        )
126
127        user_write_stage = UserWriteStage.objects.get(
128            name=f"invitation-enrollment-user-write-{slug}"
129        )
130        self.assertEqual(user_write_stage.user_type, user_type)
131        self.assertEqual(user_write_stage.user_path_template, f"users/{user_type}")

Test that the import endpoint applies the supplied context to the real blueprint

def test_api_import_blank_path(self):
133    def test_api_import_blank_path(self):
134        """Validator returns empty path unchanged (covers api.py:53)."""
135        with NamedTemporaryFile(mode="w+", suffix=".yaml") as file:
136            file.write(dump({"version": 1, "entries": []}))
137            file.flush()
138            file.seek(0)
139            res = self.client.post(
140                reverse("authentik_api:blueprintinstance-import-"),
141                data={"path": "", "file": file},
142                format="multipart",
143            )
144        self.assertEqual(res.status_code, 200)

Validator returns empty path unchanged (covers api.py:53).

def test_api_import_invalid_blueprint_returns_result_payload(self):
146    def test_api_import_invalid_blueprint_returns_result_payload(self):
147        """Invalid blueprint content returns a result payload instead of a 400 response."""
148        file = SimpleUploadedFile("invalid-blueprint.yaml", b'{"version": 3}')
149
150        res = self.client.post(
151            reverse("authentik_api:blueprintinstance-import-"),
152            data={"file": file},
153            format="multipart",
154        )
155
156        self.assertEqual(res.status_code, 200)
157        self.assertFalse(res.json()["success"])
158        self.assertGreater(len(res.json()["logs"]), 0)

Invalid blueprint content returns a result payload instead of a 400 response.

def test_api_import_unknown_path(self):
160    def test_api_import_unknown_path(self):
161        """Path not in available blueprints is rejected (covers api.py:56)."""
162        res = self.client.post(
163            reverse("authentik_api:blueprintinstance-import-"),
164            data={"path": "does/not/exist.yaml"},
165            format="multipart",
166        )
167        self.assertEqual(res.status_code, 400)
168        self.assertIn("Blueprint file does not exist", res.content.decode())

Path not in available blueprints is rejected (covers api.py:56).

def test_api_import_blank_context(self):
170    def test_api_import_blank_context(self):
171        """Blank context is normalized to empty dict (covers api.py:62)."""
172        res = self.client.post(
173            reverse("authentik_api:blueprintinstance-import-"),
174            data={
175                "path": "example/flows-invitation-enrollment-minimal.yaml",
176                "context": "",
177            },
178            format="multipart",
179        )
180        self.assertEqual(res.status_code, 200)

Blank context is normalized to empty dict (covers api.py:62).

def test_api_import_invalid_json_context(self):
182    def test_api_import_invalid_json_context(self):
183        """Malformed JSON context raises ValidationError (covers api.py:65-66)."""
184        res = self.client.post(
185            reverse("authentik_api:blueprintinstance-import-"),
186            data={
187                "path": "example/flows-invitation-enrollment-minimal.yaml",
188                "context": "{not json",
189            },
190            format="multipart",
191        )
192        self.assertEqual(res.status_code, 400)
193        self.assertIn("Context must be valid JSON", res.content.decode())

Malformed JSON context raises ValidationError (covers api.py:65-66).

def test_api_import_non_object_context(self):
195    def test_api_import_non_object_context(self):
196        """JSON context that isn't an object is rejected (covers api.py:68)."""
197        res = self.client.post(
198            reverse("authentik_api:blueprintinstance-import-"),
199            data={
200                "path": "example/flows-invitation-enrollment-minimal.yaml",
201                "context": "[1, 2, 3]",
202            },
203            format="multipart",
204        )
205        self.assertEqual(res.status_code, 400)
206        self.assertIn("Context must be a JSON object", res.content.decode())

JSON context that isn't an object is rejected (covers api.py:68).