authentik.admin.files.backends.tests.test_s3_backend

  1from unittest import skipUnless
  2
  3from botocore.exceptions import UnsupportedSignatureVersionError
  4from django.test import TestCase
  5
  6from authentik.admin.files.tests.utils import FileTestS3BackendMixin, s3_test_server_available
  7from authentik.admin.files.usage import FileUsage
  8from authentik.lib.config import CONFIG
  9
 10
 11@skipUnless(s3_test_server_available(), "S3 test server not available")
 12class TestS3Backend(FileTestS3BackendMixin, TestCase):
 13    """Test S3 backend functionality"""
 14
 15    def setUp(self):
 16        super().setUp()
 17
 18    def test_base_path(self):
 19        """Test base_path property generates correct S3 key prefix"""
 20        expected = "media/public"
 21        self.assertEqual(self.media_s3_backend.base_path, expected)
 22
 23    def test_supports_file_path_s3(self):
 24        """Test supports_file_path returns True for s3 backend"""
 25        self.assertTrue(self.media_s3_backend.supports_file("path/to/any-file.png"))
 26        self.assertTrue(self.media_s3_backend.supports_file("any-file.png"))
 27
 28    def test_list_files(self):
 29        """Test list_files returns relative paths"""
 30        self.media_s3_backend.client.put_object(
 31            Bucket=self.media_s3_bucket_name,
 32            Key="media/public/file1.png",
 33            Body=b"test content",
 34            ACL="private",
 35        )
 36        self.media_s3_backend.client.put_object(
 37            Bucket=self.media_s3_bucket_name,
 38            Key="media/other/file1.png",
 39            Body=b"test content",
 40            ACL="private",
 41        )
 42
 43        files = list(self.media_s3_backend.list_files())
 44
 45        self.assertEqual(len(files), 1)
 46        self.assertIn("file1.png", files)
 47
 48    def test_list_files_empty(self):
 49        """Test list_files with no files"""
 50        files = list(self.media_s3_backend.list_files())
 51
 52        self.assertEqual(len(files), 0)
 53
 54    def test_save_file(self):
 55        """Test save_file uploads to S3"""
 56        content = b"test file content"
 57        self.media_s3_backend.save_file("test.png", content)
 58
 59    def test_save_file_stream(self):
 60        """Test save_file_stream uploads to S3 using context manager"""
 61        with self.media_s3_backend.save_file_stream("test.csv") as f:
 62            f.write(b"header1,header2\n")
 63            f.write(b"value1,value2\n")
 64
 65    def test_delete_file(self):
 66        """Test delete_file removes from S3"""
 67        self.media_s3_backend.client.put_object(
 68            Bucket=self.media_s3_bucket_name,
 69            Key="media/public/test.png",
 70            Body=b"test content",
 71            ACL="private",
 72        )
 73        self.media_s3_backend.delete_file("test.png")
 74
 75    @CONFIG.patch("storage.s3.secure_urls", True)
 76    @CONFIG.patch("storage.s3.custom_domain", None)
 77    def test_file_url_basic(self):
 78        """Test file_url generates presigned URL with AWS signature format"""
 79        url = self.media_s3_backend.file_url("test.png")
 80
 81        self.assertIn("X-Amz-Algorithm=AWS4-HMAC-SHA256", url)
 82        self.assertIn("X-Amz-Signature=", url)
 83        self.assertIn("test.png", url)
 84
 85    def test_client_signature_version_default_v4(self):
 86        """Test S3 client defaults to v4 signature when not configured."""
 87        self.assertEqual(self.media_s3_backend.client.meta.config.signature_version, "s3v4")
 88
 89    @CONFIG.patch("storage.s3.signature_version", "s3")
 90    def test_client_signature_version_global_override(self):
 91        """Test S3 client respects globally configured signature version."""
 92        self.assertEqual(self.media_s3_backend.client.meta.config.signature_version, "s3")
 93
 94    @CONFIG.patch("storage.s3.signature_version", "s3v4")
 95    @CONFIG.patch("storage.media.s3.signature_version", "s3")
 96    def test_client_signature_version_media_override(self):
 97        """Test usage-specific signature version takes precedence over global."""
 98        self.assertEqual(self.media_s3_backend.client.meta.config.signature_version, "s3")
 99
100    @CONFIG.patch("storage.media.s3.signature_version", "not-a-real-signature")
101    def test_client_signature_version_unsupported(self):
102        """Test unsupported signature version raises botocore error."""
103        with self.assertRaises(UnsupportedSignatureVersionError):
104            self.media_s3_backend.file_url("test.png", use_cache=False)
105
106    @CONFIG.patch("storage.s3.bucket_name", "test-bucket")
107    def test_file_exists_true(self):
108        """Test file_exists returns True for existing file"""
109        self.media_s3_backend.client.put_object(
110            Bucket=self.media_s3_bucket_name,
111            Key="media/public/test.png",
112            Body=b"test content",
113            ACL="private",
114        )
115
116        exists = self.media_s3_backend.file_exists("test.png")
117
118        self.assertTrue(exists)
119
120    @CONFIG.patch("storage.s3.bucket_name", "test-bucket")
121    def test_file_exists_false(self):
122        """Test file_exists returns False for non-existent file"""
123        exists = self.media_s3_backend.file_exists("nonexistent.png")
124
125        self.assertFalse(exists)
126
127    def test_allowed_usages(self):
128        """Test that S3Backend supports all usage types"""
129        self.assertEqual(self.media_s3_backend.allowed_usages, list(FileUsage))
130
131    def test_reports_usage(self):
132        """Test S3Backend with REPORTS usage"""
133        self.assertEqual(self.reports_s3_backend.usage, FileUsage.REPORTS)
134        self.assertEqual(self.reports_s3_backend.base_path, "reports/public")
135
136    @CONFIG.patch("storage.s3.secure_urls", True)
137    @CONFIG.patch("storage.s3.addressing_style", "path")
138    def test_file_url_custom_domain_with_bucket_no_duplicate(self):
139        """Test file_url doesn't duplicate bucket name when custom_domain includes bucket.
140
141        Regression test for https://github.com/goauthentik/authentik/issues/19521
142
143        When using:
144        - Path-style addressing (bucket name goes in URL path, not subdomain)
145        - Custom domain that includes the bucket name (e.g., s3.example.com/bucket-name)
146
147        The bucket name should NOT appear twice in the final URL.
148
149        Example of the bug:
150        - custom_domain = "s3.example.com/authentik-media"
151        - boto3 presigned URL = "http://s3.example.com/authentik-media/media/public/file.png?..."
152        - Buggy result = "https://s3.example.com/authentik-media/authentik-media/media/public/file.png?..."
153        """
154        bucket_name = self.media_s3_bucket_name
155
156        # Custom domain includes the bucket name
157        custom_domain = f"localhost:8020/{bucket_name}"
158
159        with CONFIG.patch("storage.media.s3.custom_domain", custom_domain):
160            url = self.media_s3_backend.file_url("application-icons/test.svg", use_cache=False)
161
162        # The bucket name should appear exactly once in the URL path, not twice
163        bucket_occurrences = url.count(bucket_name)
164        self.assertEqual(
165            bucket_occurrences,
166            1,
167            f"Bucket name '{bucket_name}' appears {bucket_occurrences} times in URL, expected 1. "
168            f"URL: {url}",
169        )
170
171    def test_themed_urls_without_theme_variable(self):
172        """Test themed_urls returns None when filename has no %(theme)s"""
173        result = self.media_s3_backend.themed_urls("logo.png")
174        self.assertIsNone(result)
175
176    def test_themed_urls_with_theme_variable(self):
177        """Test themed_urls returns dict of presigned URLs for each theme"""
178        result = self.media_s3_backend.themed_urls("logo-%(theme)s.png")
179
180        self.assertIsInstance(result, dict)
181        self.assertIn("light", result)
182        self.assertIn("dark", result)
183
184        # Check URLs are valid presigned URLs with correct file paths
185        self.assertIn("logo-light.png", result["light"])
186        self.assertIn("logo-dark.png", result["dark"])
187        self.assertIn("X-Amz-Signature=", result["light"])
188        self.assertIn("X-Amz-Signature=", result["dark"])
189
190    def test_themed_urls_multiple_theme_variables(self):
191        """Test themed_urls with multiple %(theme)s in path"""
192        result = self.media_s3_backend.themed_urls("%(theme)s/logo-%(theme)s.svg")
193
194        self.assertIsInstance(result, dict)
195        self.assertIn("light/logo-light.svg", result["light"])
196        self.assertIn("dark/logo-dark.svg", result["dark"])
197
198    def test_save_file_sets_content_type_svg(self):
199        """Test save_file sets correct ContentType for SVG files"""
200        self.media_s3_backend.save_file("test.svg", b"<svg></svg>")
201
202        response = self.media_s3_backend.client.head_object(
203            Bucket=self.media_s3_bucket_name,
204            Key="media/public/test.svg",
205        )
206        self.assertEqual(response["ContentType"], "image/svg+xml")
207
208    def test_save_file_sets_content_type_png(self):
209        """Test save_file sets correct ContentType for PNG files"""
210        self.media_s3_backend.save_file("test.png", b"\x89PNG\r\n\x1a\n")
211
212        response = self.media_s3_backend.client.head_object(
213            Bucket=self.media_s3_bucket_name,
214            Key="media/public/test.png",
215        )
216        self.assertEqual(response["ContentType"], "image/png")
217
218    def test_save_file_stream_sets_content_type(self):
219        """Test save_file_stream sets correct ContentType"""
220        with self.media_s3_backend.save_file_stream("test.css") as f:
221            f.write(b"body { color: red; }")
222
223        response = self.media_s3_backend.client.head_object(
224            Bucket=self.media_s3_bucket_name,
225            Key="media/public/test.css",
226        )
227        self.assertEqual(response["ContentType"], "text/css")
228
229    def test_save_file_unknown_extension_octet_stream(self):
230        """Test save_file sets octet-stream for unknown extensions"""
231        self.media_s3_backend.save_file("test.unknownext123", b"data")
232
233        response = self.media_s3_backend.client.head_object(
234            Bucket=self.media_s3_bucket_name,
235            Key="media/public/test.unknownext123",
236        )
237        self.assertEqual(response["ContentType"], "application/octet-stream")
@skipUnless(s3_test_server_available(), 'S3 test server not available')
class TestS3Backend(authentik.admin.files.tests.utils.FileTestS3BackendMixin, django.test.testcases.TestCase):
 12@skipUnless(s3_test_server_available(), "S3 test server not available")
 13class TestS3Backend(FileTestS3BackendMixin, TestCase):
 14    """Test S3 backend functionality"""
 15
 16    def setUp(self):
 17        super().setUp()
 18
 19    def test_base_path(self):
 20        """Test base_path property generates correct S3 key prefix"""
 21        expected = "media/public"
 22        self.assertEqual(self.media_s3_backend.base_path, expected)
 23
 24    def test_supports_file_path_s3(self):
 25        """Test supports_file_path returns True for s3 backend"""
 26        self.assertTrue(self.media_s3_backend.supports_file("path/to/any-file.png"))
 27        self.assertTrue(self.media_s3_backend.supports_file("any-file.png"))
 28
 29    def test_list_files(self):
 30        """Test list_files returns relative paths"""
 31        self.media_s3_backend.client.put_object(
 32            Bucket=self.media_s3_bucket_name,
 33            Key="media/public/file1.png",
 34            Body=b"test content",
 35            ACL="private",
 36        )
 37        self.media_s3_backend.client.put_object(
 38            Bucket=self.media_s3_bucket_name,
 39            Key="media/other/file1.png",
 40            Body=b"test content",
 41            ACL="private",
 42        )
 43
 44        files = list(self.media_s3_backend.list_files())
 45
 46        self.assertEqual(len(files), 1)
 47        self.assertIn("file1.png", files)
 48
 49    def test_list_files_empty(self):
 50        """Test list_files with no files"""
 51        files = list(self.media_s3_backend.list_files())
 52
 53        self.assertEqual(len(files), 0)
 54
 55    def test_save_file(self):
 56        """Test save_file uploads to S3"""
 57        content = b"test file content"
 58        self.media_s3_backend.save_file("test.png", content)
 59
 60    def test_save_file_stream(self):
 61        """Test save_file_stream uploads to S3 using context manager"""
 62        with self.media_s3_backend.save_file_stream("test.csv") as f:
 63            f.write(b"header1,header2\n")
 64            f.write(b"value1,value2\n")
 65
 66    def test_delete_file(self):
 67        """Test delete_file removes from S3"""
 68        self.media_s3_backend.client.put_object(
 69            Bucket=self.media_s3_bucket_name,
 70            Key="media/public/test.png",
 71            Body=b"test content",
 72            ACL="private",
 73        )
 74        self.media_s3_backend.delete_file("test.png")
 75
 76    @CONFIG.patch("storage.s3.secure_urls", True)
 77    @CONFIG.patch("storage.s3.custom_domain", None)
 78    def test_file_url_basic(self):
 79        """Test file_url generates presigned URL with AWS signature format"""
 80        url = self.media_s3_backend.file_url("test.png")
 81
 82        self.assertIn("X-Amz-Algorithm=AWS4-HMAC-SHA256", url)
 83        self.assertIn("X-Amz-Signature=", url)
 84        self.assertIn("test.png", url)
 85
 86    def test_client_signature_version_default_v4(self):
 87        """Test S3 client defaults to v4 signature when not configured."""
 88        self.assertEqual(self.media_s3_backend.client.meta.config.signature_version, "s3v4")
 89
 90    @CONFIG.patch("storage.s3.signature_version", "s3")
 91    def test_client_signature_version_global_override(self):
 92        """Test S3 client respects globally configured signature version."""
 93        self.assertEqual(self.media_s3_backend.client.meta.config.signature_version, "s3")
 94
 95    @CONFIG.patch("storage.s3.signature_version", "s3v4")
 96    @CONFIG.patch("storage.media.s3.signature_version", "s3")
 97    def test_client_signature_version_media_override(self):
 98        """Test usage-specific signature version takes precedence over global."""
 99        self.assertEqual(self.media_s3_backend.client.meta.config.signature_version, "s3")
100
101    @CONFIG.patch("storage.media.s3.signature_version", "not-a-real-signature")
102    def test_client_signature_version_unsupported(self):
103        """Test unsupported signature version raises botocore error."""
104        with self.assertRaises(UnsupportedSignatureVersionError):
105            self.media_s3_backend.file_url("test.png", use_cache=False)
106
107    @CONFIG.patch("storage.s3.bucket_name", "test-bucket")
108    def test_file_exists_true(self):
109        """Test file_exists returns True for existing file"""
110        self.media_s3_backend.client.put_object(
111            Bucket=self.media_s3_bucket_name,
112            Key="media/public/test.png",
113            Body=b"test content",
114            ACL="private",
115        )
116
117        exists = self.media_s3_backend.file_exists("test.png")
118
119        self.assertTrue(exists)
120
121    @CONFIG.patch("storage.s3.bucket_name", "test-bucket")
122    def test_file_exists_false(self):
123        """Test file_exists returns False for non-existent file"""
124        exists = self.media_s3_backend.file_exists("nonexistent.png")
125
126        self.assertFalse(exists)
127
128    def test_allowed_usages(self):
129        """Test that S3Backend supports all usage types"""
130        self.assertEqual(self.media_s3_backend.allowed_usages, list(FileUsage))
131
132    def test_reports_usage(self):
133        """Test S3Backend with REPORTS usage"""
134        self.assertEqual(self.reports_s3_backend.usage, FileUsage.REPORTS)
135        self.assertEqual(self.reports_s3_backend.base_path, "reports/public")
136
137    @CONFIG.patch("storage.s3.secure_urls", True)
138    @CONFIG.patch("storage.s3.addressing_style", "path")
139    def test_file_url_custom_domain_with_bucket_no_duplicate(self):
140        """Test file_url doesn't duplicate bucket name when custom_domain includes bucket.
141
142        Regression test for https://github.com/goauthentik/authentik/issues/19521
143
144        When using:
145        - Path-style addressing (bucket name goes in URL path, not subdomain)
146        - Custom domain that includes the bucket name (e.g., s3.example.com/bucket-name)
147
148        The bucket name should NOT appear twice in the final URL.
149
150        Example of the bug:
151        - custom_domain = "s3.example.com/authentik-media"
152        - boto3 presigned URL = "http://s3.example.com/authentik-media/media/public/file.png?..."
153        - Buggy result = "https://s3.example.com/authentik-media/authentik-media/media/public/file.png?..."
154        """
155        bucket_name = self.media_s3_bucket_name
156
157        # Custom domain includes the bucket name
158        custom_domain = f"localhost:8020/{bucket_name}"
159
160        with CONFIG.patch("storage.media.s3.custom_domain", custom_domain):
161            url = self.media_s3_backend.file_url("application-icons/test.svg", use_cache=False)
162
163        # The bucket name should appear exactly once in the URL path, not twice
164        bucket_occurrences = url.count(bucket_name)
165        self.assertEqual(
166            bucket_occurrences,
167            1,
168            f"Bucket name '{bucket_name}' appears {bucket_occurrences} times in URL, expected 1. "
169            f"URL: {url}",
170        )
171
172    def test_themed_urls_without_theme_variable(self):
173        """Test themed_urls returns None when filename has no %(theme)s"""
174        result = self.media_s3_backend.themed_urls("logo.png")
175        self.assertIsNone(result)
176
177    def test_themed_urls_with_theme_variable(self):
178        """Test themed_urls returns dict of presigned URLs for each theme"""
179        result = self.media_s3_backend.themed_urls("logo-%(theme)s.png")
180
181        self.assertIsInstance(result, dict)
182        self.assertIn("light", result)
183        self.assertIn("dark", result)
184
185        # Check URLs are valid presigned URLs with correct file paths
186        self.assertIn("logo-light.png", result["light"])
187        self.assertIn("logo-dark.png", result["dark"])
188        self.assertIn("X-Amz-Signature=", result["light"])
189        self.assertIn("X-Amz-Signature=", result["dark"])
190
191    def test_themed_urls_multiple_theme_variables(self):
192        """Test themed_urls with multiple %(theme)s in path"""
193        result = self.media_s3_backend.themed_urls("%(theme)s/logo-%(theme)s.svg")
194
195        self.assertIsInstance(result, dict)
196        self.assertIn("light/logo-light.svg", result["light"])
197        self.assertIn("dark/logo-dark.svg", result["dark"])
198
199    def test_save_file_sets_content_type_svg(self):
200        """Test save_file sets correct ContentType for SVG files"""
201        self.media_s3_backend.save_file("test.svg", b"<svg></svg>")
202
203        response = self.media_s3_backend.client.head_object(
204            Bucket=self.media_s3_bucket_name,
205            Key="media/public/test.svg",
206        )
207        self.assertEqual(response["ContentType"], "image/svg+xml")
208
209    def test_save_file_sets_content_type_png(self):
210        """Test save_file sets correct ContentType for PNG files"""
211        self.media_s3_backend.save_file("test.png", b"\x89PNG\r\n\x1a\n")
212
213        response = self.media_s3_backend.client.head_object(
214            Bucket=self.media_s3_bucket_name,
215            Key="media/public/test.png",
216        )
217        self.assertEqual(response["ContentType"], "image/png")
218
219    def test_save_file_stream_sets_content_type(self):
220        """Test save_file_stream sets correct ContentType"""
221        with self.media_s3_backend.save_file_stream("test.css") as f:
222            f.write(b"body { color: red; }")
223
224        response = self.media_s3_backend.client.head_object(
225            Bucket=self.media_s3_bucket_name,
226            Key="media/public/test.css",
227        )
228        self.assertEqual(response["ContentType"], "text/css")
229
230    def test_save_file_unknown_extension_octet_stream(self):
231        """Test save_file sets octet-stream for unknown extensions"""
232        self.media_s3_backend.save_file("test.unknownext123", b"data")
233
234        response = self.media_s3_backend.client.head_object(
235            Bucket=self.media_s3_bucket_name,
236            Key="media/public/test.unknownext123",
237        )
238        self.assertEqual(response["ContentType"], "application/octet-stream")

Test S3 backend functionality

def setUp(self):
16    def setUp(self):
17        super().setUp()

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

def test_base_path(self):
19    def test_base_path(self):
20        """Test base_path property generates correct S3 key prefix"""
21        expected = "media/public"
22        self.assertEqual(self.media_s3_backend.base_path, expected)

Test base_path property generates correct S3 key prefix

def test_supports_file_path_s3(self):
24    def test_supports_file_path_s3(self):
25        """Test supports_file_path returns True for s3 backend"""
26        self.assertTrue(self.media_s3_backend.supports_file("path/to/any-file.png"))
27        self.assertTrue(self.media_s3_backend.supports_file("any-file.png"))

Test supports_file_path returns True for s3 backend

def test_list_files(self):
29    def test_list_files(self):
30        """Test list_files returns relative paths"""
31        self.media_s3_backend.client.put_object(
32            Bucket=self.media_s3_bucket_name,
33            Key="media/public/file1.png",
34            Body=b"test content",
35            ACL="private",
36        )
37        self.media_s3_backend.client.put_object(
38            Bucket=self.media_s3_bucket_name,
39            Key="media/other/file1.png",
40            Body=b"test content",
41            ACL="private",
42        )
43
44        files = list(self.media_s3_backend.list_files())
45
46        self.assertEqual(len(files), 1)
47        self.assertIn("file1.png", files)

Test list_files returns relative paths

def test_list_files_empty(self):
49    def test_list_files_empty(self):
50        """Test list_files with no files"""
51        files = list(self.media_s3_backend.list_files())
52
53        self.assertEqual(len(files), 0)

Test list_files with no files

def test_save_file(self):
55    def test_save_file(self):
56        """Test save_file uploads to S3"""
57        content = b"test file content"
58        self.media_s3_backend.save_file("test.png", content)

Test save_file uploads to S3

def test_save_file_stream(self):
60    def test_save_file_stream(self):
61        """Test save_file_stream uploads to S3 using context manager"""
62        with self.media_s3_backend.save_file_stream("test.csv") as f:
63            f.write(b"header1,header2\n")
64            f.write(b"value1,value2\n")

Test save_file_stream uploads to S3 using context manager

def test_delete_file(self):
66    def test_delete_file(self):
67        """Test delete_file removes from S3"""
68        self.media_s3_backend.client.put_object(
69            Bucket=self.media_s3_bucket_name,
70            Key="media/public/test.png",
71            Body=b"test content",
72            ACL="private",
73        )
74        self.media_s3_backend.delete_file("test.png")

Test delete_file removes from S3

@CONFIG.patch('storage.s3.secure_urls', True)
@CONFIG.patch('storage.s3.custom_domain', None)
def test_file_url_basic(self):
76    @CONFIG.patch("storage.s3.secure_urls", True)
77    @CONFIG.patch("storage.s3.custom_domain", None)
78    def test_file_url_basic(self):
79        """Test file_url generates presigned URL with AWS signature format"""
80        url = self.media_s3_backend.file_url("test.png")
81
82        self.assertIn("X-Amz-Algorithm=AWS4-HMAC-SHA256", url)
83        self.assertIn("X-Amz-Signature=", url)
84        self.assertIn("test.png", url)

Test file_url generates presigned URL with AWS signature format

def test_client_signature_version_default_v4(self):
86    def test_client_signature_version_default_v4(self):
87        """Test S3 client defaults to v4 signature when not configured."""
88        self.assertEqual(self.media_s3_backend.client.meta.config.signature_version, "s3v4")

Test S3 client defaults to v4 signature when not configured.

@CONFIG.patch('storage.s3.signature_version', 's3')
def test_client_signature_version_global_override(self):
90    @CONFIG.patch("storage.s3.signature_version", "s3")
91    def test_client_signature_version_global_override(self):
92        """Test S3 client respects globally configured signature version."""
93        self.assertEqual(self.media_s3_backend.client.meta.config.signature_version, "s3")

Test S3 client respects globally configured signature version.

@CONFIG.patch('storage.s3.signature_version', 's3v4')
@CONFIG.patch('storage.media.s3.signature_version', 's3')
def test_client_signature_version_media_override(self):
95    @CONFIG.patch("storage.s3.signature_version", "s3v4")
96    @CONFIG.patch("storage.media.s3.signature_version", "s3")
97    def test_client_signature_version_media_override(self):
98        """Test usage-specific signature version takes precedence over global."""
99        self.assertEqual(self.media_s3_backend.client.meta.config.signature_version, "s3")

Test usage-specific signature version takes precedence over global.

@CONFIG.patch('storage.media.s3.signature_version', 'not-a-real-signature')
def test_client_signature_version_unsupported(self):
101    @CONFIG.patch("storage.media.s3.signature_version", "not-a-real-signature")
102    def test_client_signature_version_unsupported(self):
103        """Test unsupported signature version raises botocore error."""
104        with self.assertRaises(UnsupportedSignatureVersionError):
105            self.media_s3_backend.file_url("test.png", use_cache=False)

Test unsupported signature version raises botocore error.

@CONFIG.patch('storage.s3.bucket_name', 'test-bucket')
def test_file_exists_true(self):
107    @CONFIG.patch("storage.s3.bucket_name", "test-bucket")
108    def test_file_exists_true(self):
109        """Test file_exists returns True for existing file"""
110        self.media_s3_backend.client.put_object(
111            Bucket=self.media_s3_bucket_name,
112            Key="media/public/test.png",
113            Body=b"test content",
114            ACL="private",
115        )
116
117        exists = self.media_s3_backend.file_exists("test.png")
118
119        self.assertTrue(exists)

Test file_exists returns True for existing file

@CONFIG.patch('storage.s3.bucket_name', 'test-bucket')
def test_file_exists_false(self):
121    @CONFIG.patch("storage.s3.bucket_name", "test-bucket")
122    def test_file_exists_false(self):
123        """Test file_exists returns False for non-existent file"""
124        exists = self.media_s3_backend.file_exists("nonexistent.png")
125
126        self.assertFalse(exists)

Test file_exists returns False for non-existent file

def test_allowed_usages(self):
128    def test_allowed_usages(self):
129        """Test that S3Backend supports all usage types"""
130        self.assertEqual(self.media_s3_backend.allowed_usages, list(FileUsage))

Test that S3Backend supports all usage types

def test_reports_usage(self):
132    def test_reports_usage(self):
133        """Test S3Backend with REPORTS usage"""
134        self.assertEqual(self.reports_s3_backend.usage, FileUsage.REPORTS)
135        self.assertEqual(self.reports_s3_backend.base_path, "reports/public")

Test S3Backend with REPORTS usage

@CONFIG.patch('storage.s3.secure_urls', True)
@CONFIG.patch('storage.s3.addressing_style', 'path')
def test_file_url_custom_domain_with_bucket_no_duplicate(self):
137    @CONFIG.patch("storage.s3.secure_urls", True)
138    @CONFIG.patch("storage.s3.addressing_style", "path")
139    def test_file_url_custom_domain_with_bucket_no_duplicate(self):
140        """Test file_url doesn't duplicate bucket name when custom_domain includes bucket.
141
142        Regression test for https://github.com/goauthentik/authentik/issues/19521
143
144        When using:
145        - Path-style addressing (bucket name goes in URL path, not subdomain)
146        - Custom domain that includes the bucket name (e.g., s3.example.com/bucket-name)
147
148        The bucket name should NOT appear twice in the final URL.
149
150        Example of the bug:
151        - custom_domain = "s3.example.com/authentik-media"
152        - boto3 presigned URL = "http://s3.example.com/authentik-media/media/public/file.png?..."
153        - Buggy result = "https://s3.example.com/authentik-media/authentik-media/media/public/file.png?..."
154        """
155        bucket_name = self.media_s3_bucket_name
156
157        # Custom domain includes the bucket name
158        custom_domain = f"localhost:8020/{bucket_name}"
159
160        with CONFIG.patch("storage.media.s3.custom_domain", custom_domain):
161            url = self.media_s3_backend.file_url("application-icons/test.svg", use_cache=False)
162
163        # The bucket name should appear exactly once in the URL path, not twice
164        bucket_occurrences = url.count(bucket_name)
165        self.assertEqual(
166            bucket_occurrences,
167            1,
168            f"Bucket name '{bucket_name}' appears {bucket_occurrences} times in URL, expected 1. "
169            f"URL: {url}",
170        )

Test file_url doesn't duplicate bucket name when custom_domain includes bucket.

Regression test for https://github.com/goauthentik/authentik/issues/19521

When using:

  • Path-style addressing (bucket name goes in URL path, not subdomain)
  • Custom domain that includes the bucket name (e.g., s3.example.com/bucket-name)

The bucket name should NOT appear twice in the final URL.

Example of the bug:

  • custom_domain = "s3.example.com/authentik-media"
  • boto3 presigned URL = "http://s3.example.com/authentik-media/media/public/file.png?..."
  • Buggy result = "https://s3.example.com/authentik-media/authentik-media/media/public/file.png?..."
def test_themed_urls_without_theme_variable(self):
172    def test_themed_urls_without_theme_variable(self):
173        """Test themed_urls returns None when filename has no %(theme)s"""
174        result = self.media_s3_backend.themed_urls("logo.png")
175        self.assertIsNone(result)

Test themed_urls returns None when filename has no %(theme)s

def test_themed_urls_with_theme_variable(self):
177    def test_themed_urls_with_theme_variable(self):
178        """Test themed_urls returns dict of presigned URLs for each theme"""
179        result = self.media_s3_backend.themed_urls("logo-%(theme)s.png")
180
181        self.assertIsInstance(result, dict)
182        self.assertIn("light", result)
183        self.assertIn("dark", result)
184
185        # Check URLs are valid presigned URLs with correct file paths
186        self.assertIn("logo-light.png", result["light"])
187        self.assertIn("logo-dark.png", result["dark"])
188        self.assertIn("X-Amz-Signature=", result["light"])
189        self.assertIn("X-Amz-Signature=", result["dark"])

Test themed_urls returns dict of presigned URLs for each theme

def test_themed_urls_multiple_theme_variables(self):
191    def test_themed_urls_multiple_theme_variables(self):
192        """Test themed_urls with multiple %(theme)s in path"""
193        result = self.media_s3_backend.themed_urls("%(theme)s/logo-%(theme)s.svg")
194
195        self.assertIsInstance(result, dict)
196        self.assertIn("light/logo-light.svg", result["light"])
197        self.assertIn("dark/logo-dark.svg", result["dark"])

Test themed_urls with multiple %(theme)s in path

def test_save_file_sets_content_type_svg(self):
199    def test_save_file_sets_content_type_svg(self):
200        """Test save_file sets correct ContentType for SVG files"""
201        self.media_s3_backend.save_file("test.svg", b"<svg></svg>")
202
203        response = self.media_s3_backend.client.head_object(
204            Bucket=self.media_s3_bucket_name,
205            Key="media/public/test.svg",
206        )
207        self.assertEqual(response["ContentType"], "image/svg+xml")

Test save_file sets correct ContentType for SVG files

def test_save_file_sets_content_type_png(self):
209    def test_save_file_sets_content_type_png(self):
210        """Test save_file sets correct ContentType for PNG files"""
211        self.media_s3_backend.save_file("test.png", b"\x89PNG\r\n\x1a\n")
212
213        response = self.media_s3_backend.client.head_object(
214            Bucket=self.media_s3_bucket_name,
215            Key="media/public/test.png",
216        )
217        self.assertEqual(response["ContentType"], "image/png")

Test save_file sets correct ContentType for PNG files

def test_save_file_stream_sets_content_type(self):
219    def test_save_file_stream_sets_content_type(self):
220        """Test save_file_stream sets correct ContentType"""
221        with self.media_s3_backend.save_file_stream("test.css") as f:
222            f.write(b"body { color: red; }")
223
224        response = self.media_s3_backend.client.head_object(
225            Bucket=self.media_s3_bucket_name,
226            Key="media/public/test.css",
227        )
228        self.assertEqual(response["ContentType"], "text/css")

Test save_file_stream sets correct ContentType

def test_save_file_unknown_extension_octet_stream(self):
230    def test_save_file_unknown_extension_octet_stream(self):
231        """Test save_file sets octet-stream for unknown extensions"""
232        self.media_s3_backend.save_file("test.unknownext123", b"data")
233
234        response = self.media_s3_backend.client.head_object(
235            Bucket=self.media_s3_bucket_name,
236            Key="media/public/test.unknownext123",
237        )
238        self.assertEqual(response["ContentType"], "application/octet-stream")

Test save_file sets octet-stream for unknown extensions