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