From 9efb778c69fb62019dea63a2a460cf8e89a310f5 Mon Sep 17 00:00:00 2001 From: Diego Santo Date: Fri, 31 Jul 2026 19:52:48 -0300 Subject: [PATCH 1/2] feat(): put cache nonce as parameter on some route --- Storage/DownloadOptions.cs | 5 ++ .../Extensions/DownloadOptionsExtension.cs | 10 +++- Storage/Interfaces/IStorageFileApi.cs | 17 ++++-- Storage/StorageFileApi.cs | 57 +++++++++++++------ StorageTests/Files/StorageFileTests.cs | 35 ++++++++++++ .../Options/DownloadOptionsExtensionTests.cs | 8 +++ 6 files changed, 107 insertions(+), 25 deletions(-) diff --git a/Storage/DownloadOptions.cs b/Storage/DownloadOptions.cs index 44f7c00..883e95a 100644 --- a/Storage/DownloadOptions.cs +++ b/Storage/DownloadOptions.cs @@ -15,5 +15,10 @@ public class DownloadOptions ///

When field is empty, the original file name will be used. Use for quick initialized with original file names.

/// public string? FileName { get; set; } + + /// + /// Append a cache nonce parameter to the URL to invalidate the cache + /// + public string? CacheNonce { get; set; } } } \ No newline at end of file diff --git a/Storage/Extensions/DownloadOptionsExtension.cs b/Storage/Extensions/DownloadOptionsExtension.cs index 6911924..9d6135c 100644 --- a/Storage/Extensions/DownloadOptionsExtension.cs +++ b/Storage/Extensions/DownloadOptionsExtension.cs @@ -14,12 +14,16 @@ public static NameValueCollection ToQueryCollection(this DownloadOptions downloa { var query = HttpUtility.ParseQueryString(string.Empty); - if (download.FileName == null) + if (download.FileName != null) { - return query; + query.Add("download", string.IsNullOrEmpty(download.FileName) ? "true" : download.FileName); } - query.Add("download", string.IsNullOrEmpty(download.FileName) ? "true" : download.FileName); + + if (download.CacheNonce != null) + { + query.Add("cacheNonce", download.CacheNonce); + } return query; } diff --git a/Storage/Interfaces/IStorageFileApi.cs b/Storage/Interfaces/IStorageFileApi.cs index 8c7df70..f78e78e 100644 --- a/Storage/Interfaces/IStorageFileApi.cs +++ b/Storage/Interfaces/IStorageFileApi.cs @@ -20,38 +20,43 @@ Task CreateSignedUrl( int expiresIn, DownloadOptions? options = null ); - Task Download(string supabasePath, EventHandler? onProgress = null, CancellationToken cancellationToken = default); + Task Download(string supabasePath, EventHandler? onProgress = null, CancellationToken cancellationToken = default, string? cacheNonce = null); Task Download( string supabasePath, TransformOptions? transformOptions = null, EventHandler? onProgress = null, - CancellationToken cancellationToken = default + CancellationToken cancellationToken = default, + string? cacheNonce = null ); Task Download( string supabasePath, string localPath, EventHandler? onProgress = null, - CancellationToken cancellationToken = default + CancellationToken cancellationToken = default, + string? cacheNonce = null ); Task Download( string supabasePath, string localPath, TransformOptions? transformOptions = null, EventHandler? onProgress = null, - CancellationToken cancellationToken = default + CancellationToken cancellationToken = default, + string? cacheNonce = null ); Task DownloadPublicFile( string supabasePath, TransformOptions? transformOptions = null, EventHandler? onProgress = null, - CancellationToken cancellationToken = default + CancellationToken cancellationToken = default, + string? cacheNonce = null ); Task DownloadPublicFile( string supabasePath, string localPath, TransformOptions? transformOptions = null, EventHandler? onProgress = null, - CancellationToken cancellationToken = default + CancellationToken cancellationToken = default, + string? cacheNonce = null ); string GetPublicUrl( string path, diff --git a/Storage/StorageFileApi.cs b/Storage/StorageFileApi.cs index a0c29c0..73a440f 100644 --- a/Storage/StorageFileApi.cs +++ b/Storage/StorageFileApi.cs @@ -515,20 +515,22 @@ await Helpers.MakeRequest( /// /// /// + /// /// public Task Download( string supabasePath, string localPath, TransformOptions? transformOptions = null, EventHandler? onProgress = null, - CancellationToken cancellationToken = default + CancellationToken cancellationToken = default, + string? cacheNonce = null ) { var url = transformOptions != null ? $"{Url}/render/image/authenticated/{GetFinalPath(supabasePath)}" : $"{Url}/object/{GetFinalPath(supabasePath)}"; - return DownloadFile(url, localPath, transformOptions, onProgress, cancellationToken); + return DownloadFile(url, localPath, transformOptions, onProgress, cancellationToken, cacheNonce); } /// @@ -538,13 +540,15 @@ public Task Download( /// /// /// + /// /// public Task Download( string supabasePath, string localPath, EventHandler? onProgress = null, - CancellationToken cancellationToken = default - ) => Download(supabasePath, localPath, null, onProgress: onProgress, cancellationToken); + CancellationToken cancellationToken = default, + string? cacheNonce = null + ) => Download(supabasePath, localPath, null, onProgress: onProgress, cancellationToken, cacheNonce); /// /// Downloads a byte array from a private bucket to be used programmatically. For public buckets @@ -553,16 +557,18 @@ public Task Download( /// /// /// + /// /// public Task Download( string supabasePath, TransformOptions? transformOptions = null, EventHandler? onProgress = null, - CancellationToken cancellationToken = default + CancellationToken cancellationToken = default, + string? cacheNonce = null ) { var url = $"{Url}/object/{GetFinalPath(supabasePath)}"; - return DownloadBytes(url, transformOptions, onProgress, cancellationToken); + return DownloadBytes(url, transformOptions, onProgress, cancellationToken, cacheNonce); } /// @@ -571,9 +577,10 @@ public Task Download( /// /// /// + /// /// - public Task Download(string supabasePath, EventHandler? onProgress = null, CancellationToken cancellationToken = default) => - Download(supabasePath, transformOptions: null, onProgress: onProgress, cancellationToken); + public Task Download(string supabasePath, EventHandler? onProgress = null, CancellationToken cancellationToken = default, string? cacheNonce = null) => + Download(supabasePath, transformOptions: null, onProgress: onProgress, cancellationToken, cacheNonce); /// /// Downloads a public file to the filesystem. This method DOES NOT VERIFY that the file is actually public. @@ -583,17 +590,19 @@ public Task Download(string supabasePath, EventHandler? onProgres /// /// /// + /// /// public Task DownloadPublicFile( string supabasePath, string localPath, TransformOptions? transformOptions = null, EventHandler? onProgress = null, - CancellationToken cancellationToken = default + CancellationToken cancellationToken = default, + string? cacheNonce = null ) { var url = GetPublicUrl(supabasePath, transformOptions); - return DownloadFile(url, localPath, transformOptions, onProgress, cancellationToken); + return DownloadFile(url, localPath, transformOptions, onProgress, cancellationToken, cacheNonce); } /// @@ -603,16 +612,18 @@ public Task DownloadPublicFile( /// /// /// + /// /// public Task DownloadPublicFile( string supabasePath, TransformOptions? transformOptions = null, EventHandler? onProgress = null, - CancellationToken cancellationToken = default + CancellationToken cancellationToken = default, + string? cacheNonce = null ) { var url = GetPublicUrl(supabasePath, transformOptions); - return DownloadBytes(url, transformOptions, onProgress, cancellationToken); + return DownloadBytes(url, transformOptions, onProgress, cancellationToken, cacheNonce); } /// @@ -859,14 +870,21 @@ private async Task DownloadFile( string localPath, TransformOptions? transformOptions = null, EventHandler? onProgress = null, - CancellationToken cancellationToken = default + CancellationToken cancellationToken = default, + string? cacheNonce = null ) { + var query = HttpUtility.ParseQueryString(string.Empty); var builder = new UriBuilder(url); var progress = new Progress(); if (transformOptions != null) - builder.Query = transformOptions.ToQueryCollection().ToString(); + query.Add(transformOptions.ToQueryCollection()); + + if (cacheNonce != null) + query.Add("cacheNonce", cacheNonce); + + builder.Query = query.ToString(); if (onProgress != null) progress.ProgressChanged += onProgress; @@ -893,14 +911,21 @@ private async Task DownloadBytes( string url, TransformOptions? transformOptions = null, EventHandler? onProgress = null, - CancellationToken cancellationToken = default + CancellationToken cancellationToken = default, + string? cacheNonce = null ) { + var query = HttpUtility.ParseQueryString(string.Empty); var builder = new UriBuilder(url); var progress = new Progress(); if (transformOptions != null) - builder.Query = transformOptions.ToQueryCollection().ToString(); + query.Add(transformOptions.ToQueryCollection()); + + if (cacheNonce != null) + query.Add("cacheNonce", cacheNonce); + + builder.Query = query.ToString(); if (onProgress != null) progress.ProgressChanged += onProgress; diff --git a/StorageTests/Files/StorageFileTests.cs b/StorageTests/Files/StorageFileTests.cs index 44ea248..9868307 100644 --- a/StorageTests/Files/StorageFileTests.cs +++ b/StorageTests/Files/StorageFileTests.cs @@ -192,6 +192,20 @@ public async Task Download_ShouldWriteFileToDisk() await this.bucket.Remove(new List { name }); } + [TestMethod] + public async Task Download_ShouldWriteFileToDisk_GivenCacheNonce() + { + var progressed = new TaskCompletionSource(); + var name = $"{Guid.NewGuid()}.png"; + var imagePath = Path.Combine(BasePath(), "Assets", "supabase-csharp.png"); + await this.bucket.Upload(imagePath, name); + var downloadPath = Path.Combine(BasePath(), name); + await this.bucket.Download(name, downloadPath, (_, _) => progressed.TrySetResult(true), CancellationToken.None, DateTime.UtcNow.ToShortDateString()); + (await progressed.Task).Should().BeTrue(); + File.Exists(downloadPath).Should().BeTrue(); + await this.bucket.Remove(new List { name }); + } + [TestMethod] public async Task Download_ShouldCancelAndLeaveNoFile_GivenCancelledToken() { @@ -281,6 +295,16 @@ public async Task GetPublicUrl_ShouldAppendTheDownloadName_GivenDownloadOptions( await this.bucket.Remove(new List { name }); } + [TestMethod] + public async Task GetPublicUrl_ShouldAppendTheDownloadName_GivenFullFilledDownloadOptions() + { + var name = $"{Guid.NewGuid()}.bin"; + await this.bucket.Upload(new byte[] { 0x0, 0x1 }, name); + var url = this.bucket.GetPublicUrl(name, null, new DownloadOptions { FileName = "custom-file.png", CacheNonce = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ") }); + url.Should().Contain("download=custom-file.png&cacheNonce"); + await this.bucket.Remove(new List { name }); + } + [TestMethod] public async Task GetPublicUrl_ShouldAppendDownloadTrue_GivenTransformAndOriginalName() { @@ -323,6 +347,17 @@ public async Task CreateSignedUrl_ShouldAppendTheDownloadName_GivenDownloadOptio await this.bucket.Remove(new List { name }); } + [TestMethod] + public async Task CreateSignedUrl_ShouldAppendTheDownloadName_GivenFullFilledDownloadOptions() + { + var name = $"{Guid.NewGuid()}.bin"; + await this.bucket.Upload(new byte[] { 0x0, 0x1 }, name); + var url = await this.bucket.CreateSignedUrl(name, 3600, null, new DownloadOptions { FileName = "custom-file.png", CacheNonce = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ")}); + Uri.IsWellFormedUriString(url, UriKind.Absolute).Should().BeTrue(); + url.Should().Contain("download=custom-file.png&cacheNonce"); + await this.bucket.Remove(new List { name }); + } + [TestMethod] public async Task CreateSignedUrls_ShouldReturnAbsoluteUrlsWithOriginalNames() { diff --git a/StorageTests/Options/DownloadOptionsExtensionTests.cs b/StorageTests/Options/DownloadOptionsExtensionTests.cs index c6e7217..eb87e40 100644 --- a/StorageTests/Options/DownloadOptionsExtensionTests.cs +++ b/StorageTests/Options/DownloadOptionsExtensionTests.cs @@ -1,3 +1,4 @@ +using System; using FluentAssertions; using Microsoft.VisualStudio.TestTools.UnitTesting; using Supabase.Storage; @@ -34,4 +35,11 @@ public void ToQueryCollection_ShouldEmitTheFileName_GivenAName() var query = new DownloadOptions { FileName = "custom-file.png" }.ToQueryCollection(); query["download"].Should().Be("custom-file.png"); } + + [TestMethod] + public void ToQueryCollection_ShouldEmitTheCacheNonce_GivenAName() + { + var query = new DownloadOptions { CacheNonce = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ") }.ToQueryCollection(); + query["cacheNonce"].Should().Be(DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ")); + } } From 84ab45db5fd66dd870b3931bc3034e08a388fee9 Mon Sep 17 00:00:00 2001 From: tr00d Date: Tue, 4 Aug 2026 08:08:12 +0200 Subject: [PATCH 2/2] refactor: tests rearrange and rename --- .../Files/StorageFileApiContractTests.cs | 31 ++++++++++++++++ StorageTests/Files/StorageFileTests.cs | 35 ------------------- .../Options/DownloadOptionsExtensionTests.cs | 7 ++-- 3 files changed, 34 insertions(+), 39 deletions(-) diff --git a/StorageTests/Files/StorageFileApiContractTests.cs b/StorageTests/Files/StorageFileApiContractTests.cs index 7c41628..7d7f9cb 100644 --- a/StorageTests/Files/StorageFileApiContractTests.cs +++ b/StorageTests/Files/StorageFileApiContractTests.cs @@ -1,4 +1,5 @@ using System; +using System.IO; using System.Linq; using System.Collections.Generic; using System.Text; @@ -13,6 +14,7 @@ using WireMock.RequestBuilders; using WireMock.ResponseBuilders; using WireMock.Server; +using FileOptions = Supabase.Storage.FileOptions; namespace StorageTests.Files; @@ -290,6 +292,35 @@ public async Task Download_ShouldGetBytesFromTheObjectPath() } } + [TestMethod] + public async Task Download_ShouldSendTheCacheNonceInTheQuery_GivenACacheNonce() + { + this.server.Given(Request.Create().WithPath($"/storage/v1/object/{Bucket}/a.bin").UsingGet()) + .RespondWith(Response.Create().WithStatusCode(200).WithBody(Encoding.UTF8.GetBytes("file-bytes"))); + await this.client.From(Bucket).Download("a.bin", (EventHandler?) null, cacheNonce: "nonce-123"); + this.SingleRequest().Query!.Should().ContainKey("cacheNonce") + .WhoseValue.Should().Contain("nonce-123", "the nonce must ride the request so the CDN cache is bypassed"); + } + + [TestMethod] + public async Task Download_ShouldSendTheCacheNonceInTheQuery_GivenACacheNonceAndLocalPath() + { + this.server.Given(Request.Create().WithPath($"/storage/v1/object/{Bucket}/a.bin").UsingGet()) + .RespondWith(Response.Create().WithStatusCode(200).WithBody(Encoding.UTF8.GetBytes("file-bytes"))); + var localPath = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid()}.bin"); + try + { + await this.client.From(Bucket).Download("a.bin", localPath, (EventHandler?) null, cacheNonce: "nonce-123"); + this.SingleRequest().Query!.Should().ContainKey("cacheNonce") + .WhoseValue.Should().Contain("nonce-123", "the to-disk path must carry the nonce just as the byte path does"); + } + finally + { + if (File.Exists(localPath)) + File.Delete(localPath); + } + } + [TestMethod] public async Task PurgeCache_ShouldDeleteTheCdnObjectPathWithNoBodyAndReturnTheMessage() { diff --git a/StorageTests/Files/StorageFileTests.cs b/StorageTests/Files/StorageFileTests.cs index 9868307..44ea248 100644 --- a/StorageTests/Files/StorageFileTests.cs +++ b/StorageTests/Files/StorageFileTests.cs @@ -192,20 +192,6 @@ public async Task Download_ShouldWriteFileToDisk() await this.bucket.Remove(new List { name }); } - [TestMethod] - public async Task Download_ShouldWriteFileToDisk_GivenCacheNonce() - { - var progressed = new TaskCompletionSource(); - var name = $"{Guid.NewGuid()}.png"; - var imagePath = Path.Combine(BasePath(), "Assets", "supabase-csharp.png"); - await this.bucket.Upload(imagePath, name); - var downloadPath = Path.Combine(BasePath(), name); - await this.bucket.Download(name, downloadPath, (_, _) => progressed.TrySetResult(true), CancellationToken.None, DateTime.UtcNow.ToShortDateString()); - (await progressed.Task).Should().BeTrue(); - File.Exists(downloadPath).Should().BeTrue(); - await this.bucket.Remove(new List { name }); - } - [TestMethod] public async Task Download_ShouldCancelAndLeaveNoFile_GivenCancelledToken() { @@ -295,16 +281,6 @@ public async Task GetPublicUrl_ShouldAppendTheDownloadName_GivenDownloadOptions( await this.bucket.Remove(new List { name }); } - [TestMethod] - public async Task GetPublicUrl_ShouldAppendTheDownloadName_GivenFullFilledDownloadOptions() - { - var name = $"{Guid.NewGuid()}.bin"; - await this.bucket.Upload(new byte[] { 0x0, 0x1 }, name); - var url = this.bucket.GetPublicUrl(name, null, new DownloadOptions { FileName = "custom-file.png", CacheNonce = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ") }); - url.Should().Contain("download=custom-file.png&cacheNonce"); - await this.bucket.Remove(new List { name }); - } - [TestMethod] public async Task GetPublicUrl_ShouldAppendDownloadTrue_GivenTransformAndOriginalName() { @@ -347,17 +323,6 @@ public async Task CreateSignedUrl_ShouldAppendTheDownloadName_GivenDownloadOptio await this.bucket.Remove(new List { name }); } - [TestMethod] - public async Task CreateSignedUrl_ShouldAppendTheDownloadName_GivenFullFilledDownloadOptions() - { - var name = $"{Guid.NewGuid()}.bin"; - await this.bucket.Upload(new byte[] { 0x0, 0x1 }, name); - var url = await this.bucket.CreateSignedUrl(name, 3600, null, new DownloadOptions { FileName = "custom-file.png", CacheNonce = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ")}); - Uri.IsWellFormedUriString(url, UriKind.Absolute).Should().BeTrue(); - url.Should().Contain("download=custom-file.png&cacheNonce"); - await this.bucket.Remove(new List { name }); - } - [TestMethod] public async Task CreateSignedUrls_ShouldReturnAbsoluteUrlsWithOriginalNames() { diff --git a/StorageTests/Options/DownloadOptionsExtensionTests.cs b/StorageTests/Options/DownloadOptionsExtensionTests.cs index eb87e40..5e4c350 100644 --- a/StorageTests/Options/DownloadOptionsExtensionTests.cs +++ b/StorageTests/Options/DownloadOptionsExtensionTests.cs @@ -1,4 +1,3 @@ -using System; using FluentAssertions; using Microsoft.VisualStudio.TestTools.UnitTesting; using Supabase.Storage; @@ -37,9 +36,9 @@ public void ToQueryCollection_ShouldEmitTheFileName_GivenAName() } [TestMethod] - public void ToQueryCollection_ShouldEmitTheCacheNonce_GivenAName() + public void ToQueryCollection_ShouldEmitTheCacheNonce_GivenACacheNonce() { - var query = new DownloadOptions { CacheNonce = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ") }.ToQueryCollection(); - query["cacheNonce"].Should().Be(DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ")); + var query = new DownloadOptions { CacheNonce = "nonce-123" }.ToQueryCollection(); + query["cacheNonce"].Should().Be("nonce-123"); } }