From 4fff224950aa15b6ffdd83b3842b06ba59b8e11f Mon Sep 17 00:00:00 2001 From: Ranbir Singh Date: Thu, 30 Jul 2026 11:47:57 +0530 Subject: [PATCH 1/2] fix(storage): derive content type for signed URL uploads uploadToSignedURL fell back to defaultFileOptions when no options were passed, and that constant carries contentType "text/plain;charset=UTF-8". The fallback to the path extension could therefore never run, so a PNG or JPEG uploaded through a signed URL was stored and served as text/plain. upload() and update() already default to FileOptions(), whose contentType is nil so the extension is used, and the deprecated uploadToSignedURL overloads pass FileOptions() too. Use FileOptions() here as well. cacheControl and upsert are identical between the two, so only contentType changes. The two new tests capture the request body through StorageHTTPSession rather than snapshotRequest, so they fail when the emitted content type is wrong. --- Sources/Storage/StorageFileApi.swift | 2 +- Tests/StorageTests/StorageFileAPITests.swift | 77 +++++++++++++++++++- 2 files changed, 76 insertions(+), 3 deletions(-) diff --git a/Sources/Storage/StorageFileApi.swift b/Sources/Storage/StorageFileApi.swift index 7e5a6a874..ba9d14d7a 100644 --- a/Sources/Storage/StorageFileApi.swift +++ b/Sources/Storage/StorageFileApi.swift @@ -955,7 +955,7 @@ public class StorageFileApi: StorageApi, @unchecked Sendable { file: FileUpload, options: FileOptions? ) async throws -> SignedURLUploadResponse { - let options = options ?? defaultFileOptions + let options = options ?? FileOptions() var headers = options.headers.map { HTTPFields($0) } ?? HTTPFields() headers[.xUpsert] = "\(options.upsert)" diff --git a/Tests/StorageTests/StorageFileAPITests.swift b/Tests/StorageTests/StorageFileAPITests.swift index 0be414046..d8561faf7 100644 --- a/Tests/StorageTests/StorageFileAPITests.swift +++ b/Tests/StorageTests/StorageFileAPITests.swift @@ -1410,7 +1410,7 @@ extension StorageMockerTests { curl \ --request PUT \ --header "Cache-Control: max-age=3600" \ - --header "Content-Length: 297" \ + --header "Content-Length: 283" \ --header "Content-Type: multipart/form-data; boundary=alamofire.boundary.e56f43407f772505" \ --header "X-Client-Info: storage-swift/0.0.0" \ --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ @@ -1421,7 +1421,7 @@ extension StorageMockerTests { 3600\#r --alamofire.boundary.e56f43407f772505\#r Content-Disposition: form-data; name=\"\"; filename=\"file.txt\"\#r - Content-Type: text/plain;charset=UTF-8\#r + Content-Type: text/plain\#r \#r hello world\#r --alamofire.boundary.e56f43407f772505--\#r @@ -1438,6 +1438,71 @@ extension StorageMockerTests { #expect(response.fullPath == "bucket/file.txt") } + /// Captures the request body directly instead of using `snapshotRequest`, so the assertion + /// actually fails when the emitted `Content-Type` is wrong. + private func makeBodyCapturingSUT( + body: LockIsolated + ) -> SupabaseStorageClient { + let respond: @Sendable (URLRequest) -> (Data, URLResponse) = { request in + ( + Data(#"{"Key":"bucket/cat.png"}"#.utf8), + HTTPURLResponse( + url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil + )! + ) + } + + return SupabaseStorageClient( + configuration: StorageClientConfiguration( + url: url, + headers: [:], + session: StorageHTTPSession( + fetch: { request in + body.setValue(request.httpBody ?? Data()) + return respond(request) + }, + upload: { request, data in + body.setValue(data) + return respond(request) + } + ), + logger: nil + ) + ) + } + + @Test + func uploadToSignedURLDerivesContentTypeFromPathExtensionWhenOptionsOmitted() async throws { + let body = LockIsolated(Data()) + let storage = makeBodyCapturingSUT(body: body) + + _ = try await storage.from("bucket") + .uploadToSignedURL( + "cat.png", + token: "abc.def.ghi", + data: Data("not-really-a-png".utf8) + ) + + #expect(body.value.containsBytes(of: "Content-Type: image/png")) + #expect(!body.value.containsBytes(of: "text/plain")) + } + + @Test + func uploadToSignedURLFromFileURLDerivesContentTypeWhenOptionsOmitted() async throws { + let body = LockIsolated(Data()) + let storage = makeBodyCapturingSUT(body: body) + + _ = try await storage.from("bucket") + .uploadToSignedURL( + "cat.jpg", + token: "abc.def.ghi", + fileURL: Bundle.module.url(forResource: "sadcat", withExtension: "jpg")! + ) + + #expect(body.value.containsBytes(of: "Content-Type: image/jpeg")) + #expect(!body.value.containsBytes(of: "text/plain")) + } + @Test func uploadToSignedURL_fromFileURL() async throws { let storage = makeSUT() @@ -1603,3 +1668,11 @@ extension StorageMockerTests { } } } + +extension Data { + /// Searches the raw bytes for `string`, so bodies containing non-UTF-8 file content + /// (e.g. a real JPEG) can still be asserted on. + fileprivate func containsBytes(of string: String) -> Bool { + range(of: Data(string.utf8)) != nil + } +} From 425b5356852a2880fd246eaf48d977c9ea966369 Mon Sep 17 00:00:00 2001 From: Ranbir Singh Date: Thu, 30 Jul 2026 15:38:55 +0530 Subject: [PATCH 2/2] test(storage): move body-capturing helper beside makeSUT --- Tests/StorageTests/StorageFileAPITests.swift | 67 ++++++++++---------- 1 file changed, 32 insertions(+), 35 deletions(-) diff --git a/Tests/StorageTests/StorageFileAPITests.swift b/Tests/StorageTests/StorageFileAPITests.swift index d8561faf7..b2ce4eaaf 100644 --- a/Tests/StorageTests/StorageFileAPITests.swift +++ b/Tests/StorageTests/StorageFileAPITests.swift @@ -45,6 +45,37 @@ extension StorageMockerTests { ) } + /// A client whose transport records the request body, for asserting the emitted multipart + /// headers. + private func makeBodyCapturingSUT(body: LockIsolated) -> SupabaseStorageClient { + let respond: @Sendable (URLRequest) -> (Data, URLResponse) = { request in + ( + Data(#"{"Key":"bucket/\#(request.url!.lastPathComponent)"}"#.utf8), + HTTPURLResponse( + url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil + )! + ) + } + + return SupabaseStorageClient( + configuration: StorageClientConfiguration( + url: url, + headers: [:], + session: StorageHTTPSession( + fetch: { request in + body.setValue(request.httpBody ?? Data()) + return respond(request) + }, + upload: { request, data in + body.setValue(data) + return respond(request) + } + ), + logger: nil + ) + ) + } + @Test func listFiles() async throws { let storage = makeSUT() @@ -1438,39 +1469,6 @@ extension StorageMockerTests { #expect(response.fullPath == "bucket/file.txt") } - /// Captures the request body directly instead of using `snapshotRequest`, so the assertion - /// actually fails when the emitted `Content-Type` is wrong. - private func makeBodyCapturingSUT( - body: LockIsolated - ) -> SupabaseStorageClient { - let respond: @Sendable (URLRequest) -> (Data, URLResponse) = { request in - ( - Data(#"{"Key":"bucket/cat.png"}"#.utf8), - HTTPURLResponse( - url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil - )! - ) - } - - return SupabaseStorageClient( - configuration: StorageClientConfiguration( - url: url, - headers: [:], - session: StorageHTTPSession( - fetch: { request in - body.setValue(request.httpBody ?? Data()) - return respond(request) - }, - upload: { request, data in - body.setValue(data) - return respond(request) - } - ), - logger: nil - ) - ) - } - @Test func uploadToSignedURLDerivesContentTypeFromPathExtensionWhenOptionsOmitted() async throws { let body = LockIsolated(Data()) @@ -1670,8 +1668,7 @@ extension StorageMockerTests { } extension Data { - /// Searches the raw bytes for `string`, so bodies containing non-UTF-8 file content - /// (e.g. a real JPEG) can still be asserted on. + /// Whether the raw bytes contain `string`, for bodies that are not valid UTF-8 as a whole. fileprivate func containsBytes(of string: String) -> Bool { range(of: Data(string.utf8)) != nil }