From 2474b26f5d27787b14f0eef96346e88a847113fe Mon Sep 17 00:00:00 2001 From: Davyd McColl Date: Thu, 17 Sep 2026 16:50:08 +0200 Subject: [PATCH 01/18] :construction: working on allowing invalid ssl certs for https fetches when configured to do so - this would allow testing against a running local https asp.net application. Default is off, but that's up to the consumer to decide anyway --- package.json | 2 +- src/IRAAS.StressTest/IRAAS.StressTest.csproj | 2 +- src/IRAAS.Tests/IRAAS.Tests.csproj | 16 +- .../TestAppSettingsProvider.cs | 239 +++++++++++++----- .../ImageProcessing/TestUrlFetcher.cs | 239 ++++++++++++++---- src/IRAAS/AppSettingsProvider.cs | 5 +- src/IRAAS/IAppSettings.cs | 4 + src/IRAAS/IRAAS.csproj | 4 +- src/IRAAS/appsettings.deploy.json | 1 + src/IRAAS/appsettings.json | 1 + 10 files changed, 393 insertions(+), 120 deletions(-) diff --git a/package.json b/package.json index 928dbf6..d60abdf 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "deploy-debug": "cross-env BUILD_CONFIGURATION=Debug zarro deploy", "zarro": "cross-env zarro", "prestart": "run-s build", - "start": "cd src/IRAAS/bin/Debug/net7.0 && dotnet IRAAS.dll", + "start": "cd src/IRAAS/bin/Debug/net8.0 && dotnet IRAAS.dll", "start-stress-test-server": "cd src/IRAAS.StressTest.ImageServer/bin/Debug/net7.0 && dotnet IRAAS.StressTest.ImageServer.dll", "add-ip-to-security-group": "zarro @", "remove-ip-from-security-group": "zarro @", diff --git a/src/IRAAS.StressTest/IRAAS.StressTest.csproj b/src/IRAAS.StressTest/IRAAS.StressTest.csproj index 743e815..dbaddde 100644 --- a/src/IRAAS.StressTest/IRAAS.StressTest.csproj +++ b/src/IRAAS.StressTest/IRAAS.StressTest.csproj @@ -11,7 +11,7 @@ - + diff --git a/src/IRAAS.Tests/IRAAS.Tests.csproj b/src/IRAAS.Tests/IRAAS.Tests.csproj index 4617f68..29a5773 100644 --- a/src/IRAAS.Tests/IRAAS.Tests.csproj +++ b/src/IRAAS.Tests/IRAAS.Tests.csproj @@ -20,17 +20,17 @@ - - + + - - - - - - + + + + + + diff --git a/src/IRAAS.Tests/ImageProcessing/TestAppSettingsProvider.cs b/src/IRAAS.Tests/ImageProcessing/TestAppSettingsProvider.cs index e9d3d39..d9238ab 100644 --- a/src/IRAAS.Tests/ImageProcessing/TestAppSettingsProvider.cs +++ b/src/IRAAS.Tests/ImageProcessing/TestAppSettingsProvider.cs @@ -7,7 +7,6 @@ using PeanutButter.Utils; // ReSharper disable AccessToDisposedClosure - namespace IRAAS.Tests.ImageProcessing; [TestFixture] @@ -220,6 +219,65 @@ public void ShouldSetToZero() } } + [TestFixture] + public class AllowingInvalidSSLCertificates + { + [Test] + public void ShouldDefaultToFalseWhenNotSetInConfig() + { + // Arrange + var appSettings = Substitute.For() + .WithDefaultSettings(); + var json = MakeSettingsWithoutSSLSetting(appSettings); + Expect(json) + .Not.To.Contain("AllowInvalidSSL", StringComparison.OrdinalIgnoreCase); + using var tempFolder = new AutoTempFolder(); + using var _ = AutoResetter.Create( + () => ChDir(tempFolder.Path), + prior => ChDir(prior) + ); + File.WriteAllText( + Path.Combine(tempFolder.Path, "appsettings.json"), + json + ); + + // Act + var result = AppSettingsProvider.CreateAppSettings(); + + // Assert + Expect(result.AllowInvalidSslCertificates) + .To.Be.False(); + } + + [Test] + public void ShouldComeFromConfigWhenSet() + { + // Arrange + var appSettings = Substitute.For() + .WithDefaultSettings() + .WithInvalidSslCertificatesAllowed(); + var json = MakeSettings(appSettings); + Expect(json) + .To.Contain("AllowInvalidSSLCertificates", StringComparison.OrdinalIgnoreCase); + using var tempFolder = new AutoTempFolder(); + using var _ = AutoResetter.Create( + () => ChDir(tempFolder.Path), + prior => ChDir(prior) + ); + File.WriteAllText( + Path.Combine(tempFolder.Path, "appsettings.json"), + json + ); + + // Act + var result = AppSettingsProvider.CreateAppSettings(); + + // Assert + Expect(result.AllowInvalidSslCertificates) + .To.Be.True(); + } + } + private const string UNSUBSTITUTED_JSON = @"{ ""Settings"": { ""UseHttps"": ""#{UseHttps}"" @@ -262,6 +320,7 @@ private static string MakeSettings( "EnableConnectionKeepAlive": "{{settings.EnableConnectionKeepAlive}}", "LogFolder": "{{settings.LogFolder}}", "SuppressErrorDiagnostics": "{{settings.SuppressErrorDiagnostics}}", + "AllowInvalidSSLCertificates": "{{settings.AllowInvalidSslCertificates}}", "Verbose": "{{settings.Verbose}}" }, "DefaultParameters": { @@ -296,68 +355,130 @@ private static string MakeSettings( """; } - private string MakeSettingsWithoutConcurrency( + private static string MakeSettingsWithoutConcurrency( IAppSettings settings ) { return - $$$""" - { - "LogLevel": { - "Default": "Warning", - "IRAAS": "{{{settings.IRAASLogLevel}}}" - }, - "Kestrel": { - "Endpoints": { - "Http": { - "Url": "http://0.0.0.0:8080" - } - } - }, - "Settings": { - "MaxInputImageSize": "{{settings.MaxInputImageSize}}", - "MaxOutputImageSize": "{{settings.MaxOutputImageSize}}", - "UseDeveloperExceptionPage": "{{settings.UseDeveloperExceptionPage}}", - "UseHttps": "{{settings.UseHttps}}", - "EnableTestPage": "{{settings.EnableTestPage}}", - "DomainWhitelist": "{{settings.DomainWhitelist}}", - "ShareConcurrentRequests": "{{settings.ShareConcurrentRequests}}", - "EnableConnectionKeepAlive": "{{settings.EnableConnectionKeepAlive}}", - "LogFolder": "{{settings.LogFolder}}" - }, - "DefaultParameters": { - "*": { - "ReplaceTransparencyWith": null, - "Format": null, - "Quality": "85", - "Width": null, - "Height": null, - "ResizeMode": null, - "JpegColorType": null, - "JpegEncodingColor": null, - "Gamma": null, - "Quantizer": null, - "TransparencyThreshold": null, - "BitDepth": null, - "PngColorType": null, - "CompressionLevel": null, - "PngFilterMethod": null, - "Sampler": null, - "GifColorTableMode": null, - "MaxColors": null, - "Dither": null, - "DevicePixelRatio": 1 - }, - "png": { - "Sampler": "Bicubic" - } - }, - "AllowedHosts": "*" - } - """; + $$""" + { + "LogLevel": { + "Default": "Warning", + "IRAAS": "{{settings.IRAASLogLevel}}" + }, + "Kestrel": { + "Endpoints": { + "Http": { + "Url": "http://0.0.0.0:8080" + } + } + }, + "Settings": { + "MaxInputImageSize": "{{settings.MaxInputImageSize}}", + "MaxOutputImageSize": "{{settings.MaxOutputImageSize}}", + "UseDeveloperExceptionPage": "{{settings.UseDeveloperExceptionPage}}", + "UseHttps": "{{settings.UseHttps}}", + "EnableTestPage": "{{settings.EnableTestPage}}", + "DomainWhitelist": "{{settings.DomainWhitelist}}", + "ShareConcurrentRequests": "{{settings.ShareConcurrentRequests}}", + "EnableConnectionKeepAlive": "{{settings.EnableConnectionKeepAlive}}", + "AllowInvalidSSLCertificates": "{{settings.AllowInvalidSslCertificates}}" + "LogFolder": "{{settings.LogFolder}}" + }, + "DefaultParameters": { + "*": { + "ReplaceTransparencyWith": null, + "Format": null, + "Quality": "85", + "Width": null, + "Height": null, + "ResizeMode": null, + "JpegColorType": null, + "JpegEncodingColor": null, + "Gamma": null, + "Quantizer": null, + "TransparencyThreshold": null, + "BitDepth": null, + "PngColorType": null, + "CompressionLevel": null, + "PngFilterMethod": null, + "Sampler": null, + "GifColorTableMode": null, + "MaxColors": null, + "Dither": null, + "DevicePixelRatio": 1 + }, + "png": { + "Sampler": "Bicubic" + } + }, + "AllowedHosts": "*" + } + """; } - private string MakeSettingsWithoutLogFolder( + private static string MakeSettingsWithoutSSLSetting( + IAppSettings settings + ) + { + return + $$""" + { + "LogLevel": { + "Default": "Warning", + "IRAAS": "{{settings.IRAASLogLevel}}" + }, + "Kestrel": { + "Endpoints": { + "Http": { + "Url": "http://0.0.0.0:8080" + } + } + }, + "Settings": { + "MaxInputImageSize": "{{settings.MaxInputImageSize}}", + "MaxOutputImageSize": "{{settings.MaxOutputImageSize}}", + "UseDeveloperExceptionPage": "{{settings.UseDeveloperExceptionPage}}", + "UseHttps": "{{settings.UseHttps}}", + "EnableTestPage": "{{settings.EnableTestPage}}", + "DomainWhitelist": "{{settings.DomainWhitelist}}", + "ShareConcurrentRequests": "{{settings.ShareConcurrentRequests}}", + "EnableConnectionKeepAlive": "{{settings.EnableConnectionKeepAlive}}", + "LogFolder": "{{settings.LogFolder}}" + }, + "DefaultParameters": { + "*": { + "ReplaceTransparencyWith": null, + "Format": null, + "Quality": "85", + "Width": null, + "Height": null, + "ResizeMode": null, + "JpegColorType": null, + "JpegEncodingColor": null, + "Gamma": null, + "Quantizer": null, + "TransparencyThreshold": null, + "BitDepth": null, + "PngColorType": null, + "CompressionLevel": null, + "PngFilterMethod": null, + "Sampler": null, + "GifColorTableMode": null, + "MaxColors": null, + "Dither": null, + "DevicePixelRatio": 1 + }, + "png": { + "Sampler": "Bicubic" + } + }, + "AllowedHosts": "*" + } + """; + } + + private static string MakeSettingsWithoutLogFolder( IAppSettings settings ) { @@ -385,7 +506,8 @@ IAppSettings settings "EnableTestPage": "{{settings.EnableTestPage}}", "DomainWhitelist": "{{settings.DomainWhitelist}}", "ShareConcurrentRequests": "{{settings.ShareConcurrentRequests}}", - "EnableConnectionKeepAlive": "{{settings.EnableConnectionKeepAlive}}" + "EnableConnectionKeepAlive": "{{settings.EnableConnectionKeepAlive}}", + "AllowInvalidSSLCertificates": "{{settings.AllowInvalidSslCertificates}}" }, "DefaultParameters": { "*": { @@ -444,4 +566,5 @@ public class AppSettings : IAppSettings public LogLevel IRAASLogLevel { get; set; } public int MaxUrlFetchRetries { get; set; } public bool Verbose { get; set; } + public bool AllowInvalidSslCertificates { get; } } \ No newline at end of file diff --git a/src/IRAAS.Tests/ImageProcessing/TestUrlFetcher.cs b/src/IRAAS.Tests/ImageProcessing/TestUrlFetcher.cs index 7939146..ca87eaa 100644 --- a/src/IRAAS.Tests/ImageProcessing/TestUrlFetcher.cs +++ b/src/IRAAS.Tests/ImageProcessing/TestUrlFetcher.cs @@ -5,7 +5,6 @@ using System.Threading; using System.Threading.Tasks; using IRAAS.ImageProcessing; -using IRAAS.Tests.TestUtils; using Microsoft.Extensions.Logging; using NSubstitute; using NUnit.Framework; @@ -14,7 +13,6 @@ // ReSharper disable RedundantAssignment // ReSharper disable AccessToDisposedClosure - namespace IRAAS.Tests.ImageProcessing; [TestFixture] @@ -46,13 +44,21 @@ public async Task ShouldBeAbleToFetchExistingImage() // Act using var result = await sut.Fetch(url, new Dictionary()); // Assert - var buffer = new byte[1024]; + var buffer = new byte[ 1024 ]; using var memStream = new MemoryStream(); var readCount = 0; do { - readCount = result.Stream.Read(buffer, 0, 1024); - memStream.Write(buffer, 0, readCount); + readCount = result.Stream.Read( + buffer, + 0, + 1024 + ); + memStream.Write( + buffer, + 0, + readCount + ); } while (readCount > 0); var resultBytes = memStream.ToArray(); @@ -398,13 +404,21 @@ public async Task ShouldDownloadFromLocationHeaderOn_(HttpStatusCode code) .To.Be.True("original cat was not requested"); Expect(newCatRequested) .To.Be.True("new-cat was not requested"); - var buffer = new byte[1024]; + var buffer = new byte[ 1024 ]; await using var memStream = new MemoryStream(); var readCount = 0; do { - readCount = result.Stream.Read(buffer, 0, 1024); - memStream.Write(buffer, 0, readCount); + readCount = result.Stream.Read( + buffer, + 0, + 1024 + ); + memStream.Write( + buffer, + 0, + readCount + ); } while (readCount > 0); var resultBytes = memStream.ToArray(); @@ -474,8 +488,9 @@ public void ShouldTimeOutRequestsAsPerConfig() { // Arrange - var config = CreateDefaultAppSettings(); - config.MaxImageFetchTimeInMilliseconds.Returns(1000); + var config = Substitute.For() + .WithDefaultSettings() + .WithMaxImageFetchTimeInMilliseconds(1000); using var lease = TestEnvironment.BorrowHttpServer(); var server = lease.Instance; server.AddHandler( @@ -508,8 +523,9 @@ public class AndNotSetInProvidedHeaders public async Task ShouldDisableKeepAlive() { // Arrange - var appSettings = CreateDefaultAppSettings(); - appSettings.EnableConnectionKeepAlive.Returns(false); + var appSettings = Substitute.For() + .WithDefaultSettings() + .WithConnectionKeepAliveDisabled(); var path = $"/{GetRandomString(2)}.jpg"; var connectionHeaderValue = null as string; using var lease = TestEnvironment.BorrowHttpServer(); @@ -518,10 +534,7 @@ public async Task ShouldDisableKeepAlive() path, Resources.Data.FluffyCatJpeg, "image/jpeg", - (p, _) => - { - connectionHeaderValue = p.HttpHeaders["Connection"]; - } + (p, _) => { connectionHeaderValue = p.HttpHeaders["Connection"]; } ); var sut = Create(appSettings); // Act @@ -542,8 +555,9 @@ public class AndSetInHeaders : WhenDisabledInConfig public async Task ShouldDisableKeepAlive() { // Arrange - var appSettings = CreateDefaultAppSettings(); - appSettings.EnableConnectionKeepAlive.Returns(false); + var appSettings = Substitute.For() + .WithDefaultSettings() + .WithConnectionKeepAliveDisabled(); using var lease = TestEnvironment.BorrowHttpServer(); var server = lease.Instance; var path = $"/{GetRandomString(2)}.jpg"; @@ -552,10 +566,7 @@ public async Task ShouldDisableKeepAlive() path, Resources.Data.FluffyCatJpeg, "image/jpeg", - (p, _) => - { - connectionHeaderValue = p.HttpHeaders["Connection"]; - } + (p, _) => { connectionHeaderValue = p.HttpHeaders["Connection"]; } ); var sut = Create(appSettings); // Act @@ -583,8 +594,9 @@ public class AndNotSetInProvidedHeaders public async Task ShouldDisableKeepAlive() { // Arrange - var appSettings = CreateDefaultAppSettings(); - appSettings.EnableConnectionKeepAlive.Returns(true); + var appSettings = Substitute.For() + .WithDefaultSettings() + .WithConnectionKeepAliveEnabled(); var path = $"/{GetRandomString(2)}.jpg"; var connectionHeaderValue = null as string; using var lease = TestEnvironment.BorrowHttpServer(); @@ -593,10 +605,7 @@ public async Task ShouldDisableKeepAlive() path, Resources.Data.FluffyCatJpeg, "image/jpeg", - (p, _) => - { - connectionHeaderValue = p.HttpHeaders["Connection"]; - } + (p, _) => { connectionHeaderValue = p.HttpHeaders["Connection"]; } ); var sut = Create(appSettings); // Act @@ -617,8 +626,9 @@ public class AndSetInHeaders public async Task ShouldDisableKeepAlive() { // Arrange - var appSettings = CreateDefaultAppSettings(); - appSettings.EnableConnectionKeepAlive.Returns(true); + var appSettings = Substitute.For() + .WithDefaultSettings() + .WithConnectionKeepAliveEnabled(); var path = $"/{GetRandomString(2)}.jpg"; var connectionHeaderValue = null as string; using var lease = TestEnvironment.BorrowHttpServer(); @@ -627,10 +637,7 @@ public async Task ShouldDisableKeepAlive() path, Resources.Data.FluffyCatJpeg, "image/jpeg", - (p, _) => - { - connectionHeaderValue = p.HttpHeaders["Connection"]; - } + (p, _) => { connectionHeaderValue = p.HttpHeaders["Connection"]; } ); var sut = Create(appSettings); // Act @@ -659,7 +666,8 @@ public class WhenConfiguredRetriesValueIsZero : TestBase public void ShouldOnlyAttemptOnce() { // Arrange - var config = CreateDefaultAppSettings(); + var config = Substitute.For() + .WithDefaultSettings(); Expect(config.MaxUrlFetchRetries) .To.Equal(0); var attempts = 0; @@ -714,8 +722,9 @@ public void ShouldAttemptOnceAndRetryUpToLimit() { // Arrange var maxRetries = GetRandomInt(3, 6); - var config = CreateDefaultAppSettings() - .With(o => o.MaxUrlFetchRetries.Returns(maxRetries)); + var config = Substitute.For() + .WithDefaultSettings() + .WithMaxUrlFetchRetries(maxRetries); var attempts = 0; var path = $"/{GetRandomString(2)}.jpg"; using var lease = TestEnvironment.BorrowHttpServer(); @@ -750,28 +759,162 @@ await sut.Fetch( } } + [TestFixture] + public class AllowingInvalidSSLCertificatesForDev + { + [Test] + [Explicit("requires something already running with a self-signed certificate")] + public void ShouldDownloadTheFile() + { + // Arrange + var url = "https://localhost:7263/api/resource/?id=43&ts=1444912007000"; + var appSettings = Substitute.For() + .WithDefaultSettings() + .WithInvalidSslCertificatesAllowed(); + var sut = Create(); + + // Act + // Assert + } + } + private static IUrlFetcher Create( IAppSettings appSettings = null, ILogger logger = null ) { return new UrlFetcher( - appSettings ?? CreateDefaultAppSettings(), + appSettings ?? Substitute.For() + .WithDefaultSettings(), logger ?? Substitute.For>() ); } +} - private static IAppSettings CreateDefaultAppSettings() +public static class AppSettingsExtensions +{ + public static IAppSettings WithDefaultSettings( + this IAppSettings appSettings + ) { - var result = Substitute.For(); var _40mb = 40 * 1024 * 1024; - result.MaxInputImageSize.Returns(_40mb); - result.MaxOutputImageSize.Returns(_40mb); - result.MaxImageFetchTimeInMilliseconds.Returns(10000); - result.MaxUrlFetchRetries.Returns(0); - result.DomainWhitelist.Returns("*"); - result.EnableTestPage.Returns(true); - result.EnableConnectionKeepAlive.Returns(false); - return result; + return appSettings + .WithMaxInputImageSize(_40mb) + .WithMaxOutputImageSize(_40mb) + // allow a long fetch time so tests don't time out + .WithMaxImageFetchTimeInMilliseconds(10000) + .WithMaxUrlFetchRetries(0) + // don't block anything by default + .WithDomainWhitelist("*") + // enable the test page for testing + .WithTestPageEnabled() + // don't enable keep-alive: let test connections close + .WithConnectionKeepAliveDisabled() + // allow invalid certs for tests by default + .WithInvalidSslCertificatesAllowed(); + } + + public static IAppSettings WithInvalidSslCertificatesAllowed( + this IAppSettings appSettings + ) + { + return appSettings.With( + o => o.AllowInvalidSslCertificates.Returns(_ => true) + ); + } + + public static IAppSettings WithInvalidSslCertificatesForbidden( + this IAppSettings appSettings + ) + { + return appSettings.With( + o => o.AllowInvalidSslCertificates.Returns(_ => false) + ); + } + + public static IAppSettings WithConnectionKeepAliveEnabled( + this IAppSettings appSettings + ) + { + return appSettings.With( + o => o.EnableConnectionKeepAlive.Returns(_ => true) + ); + } + + public static IAppSettings WithConnectionKeepAliveDisabled( + this IAppSettings appSettings + ) + { + return appSettings.With( + o => o.EnableConnectionKeepAlive.Returns(_ => false) + ); + } + + public static IAppSettings WithMaxInputImageSize( + this IAppSettings appSettings, + int maxBytes + ) + { + return appSettings.With( + o => o.MaxInputImageSize.Returns(_ => maxBytes) + ); + } + + public static IAppSettings WithMaxOutputImageSize( + this IAppSettings appSettings, + int maxBytes + ) + { + return appSettings.With( + o => o.MaxOutputImageSize.Returns(_ => maxBytes) + ); + } + + public static IAppSettings WithMaxImageFetchTimeInMilliseconds( + this IAppSettings appSettings, + int milliseconds + ) + { + return appSettings.With( + o => o.MaxImageFetchTimeInMilliseconds.Returns(_ => milliseconds) + ); + } + + public static IAppSettings WithMaxUrlFetchRetries( + this IAppSettings appSettings, + int maxRetries + ) + { + return appSettings.With( + o => o.MaxUrlFetchRetries.Returns(_ => maxRetries) + ); + } + + public static IAppSettings WithDomainWhitelist( + this IAppSettings appSettings, + string whitelist + ) + { + return appSettings.With( + o => o.DomainWhitelist.Returns(_ => whitelist) + ); + } + + public static IAppSettings WithTestPageEnabled( + this IAppSettings appSettings + ) + { + return appSettings.With( + o => o.EnableTestPage.Returns(_ => true) + ); + } + + public static IAppSettings WithTestPageDisabled( + this IAppSettings appSettings + ) + { + return appSettings.With( + o => o.EnableTestPage.Returns(_ => false) + ); } } \ No newline at end of file diff --git a/src/IRAAS/AppSettingsProvider.cs b/src/IRAAS/AppSettingsProvider.cs index 7311394..03ac43a 100644 --- a/src/IRAAS/AppSettingsProvider.cs +++ b/src/IRAAS/AppSettingsProvider.cs @@ -155,7 +155,8 @@ string sectionName .GetChildren() .ToDictionary( o => o.Key, - o => o.Value + o => o.Value, + StringComparer.OrdinalIgnoreCase ); } @@ -168,7 +169,7 @@ private static Dictionary CreateDefaultConfig() .OfType() .FirstOrDefault()?.Value) ) - .ToDictionary(o => o.Name, o => o.Value); + .ToDictionary(o => o.Name, o => o.Value, StringComparer.OrdinalIgnoreCase); } private static string FindLogLevelFor( diff --git a/src/IRAAS/IAppSettings.cs b/src/IRAAS/IAppSettings.cs index 5b8ca6e..ef86112 100644 --- a/src/IRAAS/IAppSettings.cs +++ b/src/IRAAS/IAppSettings.cs @@ -23,6 +23,7 @@ public static class DefaultSettingValues public const string ONE_SECOND = "1000"; public const string UNLIMITED_CLIENTS = "0"; public const string LOG_LEVEL_WARNING = "Warning"; + public const string ALLOW_INVALID_SSL_CERTIFICATES = "false"; } public interface IAppSettings @@ -78,4 +79,7 @@ public interface IAppSettings [DefaultSetting(FALSE)] bool Verbose { get; } + + [DefaultSetting(ALLOW_INVALID_SSL_CERTIFICATES)] + bool AllowInvalidSslCertificates { get; } } \ No newline at end of file diff --git a/src/IRAAS/IRAAS.csproj b/src/IRAAS/IRAAS.csproj index 1a46fca..bdf2ec5 100644 --- a/src/IRAAS/IRAAS.csproj +++ b/src/IRAAS/IRAAS.csproj @@ -24,8 +24,8 @@ - - + + diff --git a/src/IRAAS/appsettings.deploy.json b/src/IRAAS/appsettings.deploy.json index 2a37319..0f65fdd 100644 --- a/src/IRAAS/appsettings.deploy.json +++ b/src/IRAAS/appsettings.deploy.json @@ -20,6 +20,7 @@ "EnableConnectionKeepAlive": "#{ENABLE_CONNECTION_KEEP_ALIVE}", "SuppressErrorDiagnostics": "#{SUPPRESS_ERROR_DIAGNOSTICS}", "MaxUrlFetchRetries": "#{MAX_URL_FETCH_RETRIES}", + "AllowInvalidSSLCertificates": "#{ALLOW_INVALID_SSL_CERTIFICATES}", "Verbose": "#{VERBOSE}" }, "DefaultParameters": { diff --git a/src/IRAAS/appsettings.json b/src/IRAAS/appsettings.json index 1d47993..0eb7ea4 100644 --- a/src/IRAAS/appsettings.json +++ b/src/IRAAS/appsettings.json @@ -20,6 +20,7 @@ "EnableConnectionKeepAlive": "false", "SuppressErrorDiagnostics": "false", "MaxUrlFetchRetries": "1", + "AllowInvalidSSLCertificates": "true", "Verbose": "true" }, "DefaultParameters": { From b3bf19b657326404d7725c7ca35476aeca630182 Mon Sep 17 00:00:00 2001 From: Davyd McColl Date: Fri, 18 Sep 2026 10:37:13 +0200 Subject: [PATCH 02/18] :sparkles: fix issues with appsettings parsing, add tests, fix some flappy tests, make https-invalid-cert test always runnable --- package.json | 2 + .../IRAAS.StressTest.ImageServer.csproj | 2 +- src/IRAAS.StressTest/IRAAS.StressTest.csproj | 4 +- src/IRAAS.Tests/IRAAS.Tests.csproj | 20 +- .../ImageProcessing/SelfSignedCertificate.cs | 57 +++++ .../ImageProcessing/SelfSignedImageServer.cs | 79 ++++++ .../TestAppSettingsProvider.cs | 26 +- .../TestDefaultImageResizeParameters.cs | 82 ++++++- .../TestImageResizeParameters.cs | 10 +- .../ImageProcessing/TestImageResizer.cs | 21 +- .../ImageProcessing/TestUrlFetcher.cs | 72 ++++-- .../TestImageProviderErrorMiddleware.cs | 20 +- src/IRAAS.Tests/TestBase.cs | 2 + src/IRAAS.sln | 18 ++ src/IRAAS/AppSettingsProvider.cs | 16 +- src/IRAAS/IAppSettings.cs | 3 +- src/IRAAS/IRAAS.csproj | 18 +- .../DefaultImageResizeParameters.cs | 10 +- src/IRAAS/ImageProcessing/UrlFetcher.cs | 231 ++++++++++++------ 19 files changed, 527 insertions(+), 166 deletions(-) create mode 100644 src/IRAAS.Tests/ImageProcessing/SelfSignedCertificate.cs create mode 100644 src/IRAAS.Tests/ImageProcessing/SelfSignedImageServer.cs diff --git a/package.json b/package.json index d60abdf..ea45dbd 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "version": "1.0.0", "description": "", "scripts": { + "pretest": "run-s build", "test": "zarro test-dotnet", "build": "zarro @", "build-release": "cross-env BUILD_CONFIGURATION=Release run-s build", @@ -16,6 +17,7 @@ "zarro": "cross-env zarro", "prestart": "run-s build", "start": "cd src/IRAAS/bin/Debug/net8.0 && dotnet IRAAS.dll", + "start-dev": "cross-env Settings__AllowInvalidSSLCertificates=true run-s start", "start-stress-test-server": "cd src/IRAAS.StressTest.ImageServer/bin/Debug/net7.0 && dotnet IRAAS.StressTest.ImageServer.dll", "add-ip-to-security-group": "zarro @", "remove-ip-from-security-group": "zarro @", diff --git a/src/IRAAS.StressTest.ImageServer/IRAAS.StressTest.ImageServer.csproj b/src/IRAAS.StressTest.ImageServer/IRAAS.StressTest.ImageServer.csproj index 6287d71..ad92aa0 100644 --- a/src/IRAAS.StressTest.ImageServer/IRAAS.StressTest.ImageServer.csproj +++ b/src/IRAAS.StressTest.ImageServer/IRAAS.StressTest.ImageServer.csproj @@ -5,7 +5,7 @@ enable disable true - Debug;Release;ReleaseForDocker + Debug;Release;ReleaseForDocker;Debug.WebRequest;Release.WebRequest AnyCPU diff --git a/src/IRAAS.StressTest/IRAAS.StressTest.csproj b/src/IRAAS.StressTest/IRAAS.StressTest.csproj index dbaddde..fd3608b 100644 --- a/src/IRAAS.StressTest/IRAAS.StressTest.csproj +++ b/src/IRAAS.StressTest/IRAAS.StressTest.csproj @@ -6,12 +6,12 @@ IRAAS.StressTest latest false - Debug;Release;ReleaseForDocker + Debug;Release;ReleaseForDocker;Debug.WebRequest;Release.WebRequest AnyCPU - + diff --git a/src/IRAAS.Tests/IRAAS.Tests.csproj b/src/IRAAS.Tests/IRAAS.Tests.csproj index 29a5773..d647360 100644 --- a/src/IRAAS.Tests/IRAAS.Tests.csproj +++ b/src/IRAAS.Tests/IRAAS.Tests.csproj @@ -6,7 +6,7 @@ NU1701 false - Debug;Release;ReleaseForDocker + Debug;Release;ReleaseForDocker;Debug.WebRequest;Release.WebRequest latest AnyCPU @@ -18,21 +18,21 @@ - + - + - - - - - - + + + + + + - + diff --git a/src/IRAAS.Tests/ImageProcessing/SelfSignedCertificate.cs b/src/IRAAS.Tests/ImageProcessing/SelfSignedCertificate.cs new file mode 100644 index 0000000..ea0d9e4 --- /dev/null +++ b/src/IRAAS.Tests/ImageProcessing/SelfSignedCertificate.cs @@ -0,0 +1,57 @@ +using System; +using System.Net; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; + +namespace IRAAS.Tests.ImageProcessing; + +public static class SelfSignedCertificate +{ + public static X509Certificate2 CreateForLoopback() + { + using var key = RSA.Create(2048); + + var request = new CertificateRequest( + new X500DistinguishedName("CN=localhost"), + key, + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1 + ); + + request.CertificateExtensions.Add( + new X509BasicConstraintsExtension( + false, + false, + 0, + false + ) + ); + request.CertificateExtensions.Add( + new X509KeyUsageExtension( + X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment, + false + ) + ); + request.CertificateExtensions.Add( + new X509EnhancedKeyUsageExtension( + new OidCollection { new Oid("1.3.6.1.5.5.7.3.1") }, // id-kp-serverAuth + false + ) + ); + + var sanBuilder = new SubjectAlternativeNameBuilder(); + sanBuilder.AddDnsName("localhost"); + sanBuilder.AddIpAddress(IPAddress.Loopback); + request.CertificateExtensions.Add(sanBuilder.Build()); + + using var certificate = request.CreateSelfSigned( + DateTimeOffset.UtcNow.AddDays(-1), + DateTimeOffset.UtcNow.AddDays(1) + ); + + // Round-trip through PKCS#12 so the private key is usable by SslStream. + return new X509Certificate2( + certificate.Export(X509ContentType.Pfx) + ); + } +} \ No newline at end of file diff --git a/src/IRAAS.Tests/ImageProcessing/SelfSignedImageServer.cs b/src/IRAAS.Tests/ImageProcessing/SelfSignedImageServer.cs new file mode 100644 index 0000000..ecef6ad --- /dev/null +++ b/src/IRAAS.Tests/ImageProcessing/SelfSignedImageServer.cs @@ -0,0 +1,79 @@ +using System; +using System.Linq; +using System.Net; +using System.Security.Cryptography.X509Certificates; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Server.Kestrel.Core; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using PeanutButter.Utils; + +namespace IRAAS.Tests.ImageProcessing; + +public sealed class SelfSignedImageServer : IAsyncDisposable +{ + private readonly WebApplication _app; + private readonly X509Certificate2 _certificate; + + public string BaseUrl { get; } + + private SelfSignedImageServer( + WebApplication app, + X509Certificate2 certificate, + string baseUrl + ) + { + _app = app; + _certificate = certificate; + BaseUrl = baseUrl; + } + + public static async Task Start( + byte[] payload, + string contentType = "image/png" + ) + { + var certificate = SelfSignedCertificate.CreateForLoopback(); + + var builder = WebApplication.CreateSlimBuilder(); + builder.Logging.ClearProviders(); + var port = PortFinder.FindOpenPort(); + builder.WebHost.ConfigureKestrel( + options => + { + options.Listen( + IPAddress.Loopback, + port, + (ListenOptions listenOptions) => listenOptions.UseHttps(certificate) + ); + } + ); + + var app = builder.Build(); + app.MapGet("/image", () => Results.Bytes(payload, contentType)); + + await app.StartAsync(); + + var address = app.Services + .GetRequiredService() + .Features.Get()! + .Addresses + .First(); + + return new SelfSignedImageServer(app, certificate, address); + } + + public string ImageUrl => $"{BaseUrl}/image"; + + public async ValueTask DisposeAsync() + { + await _app.StopAsync(); + await _app.DisposeAsync(); + _certificate.Dispose(); + } +} \ No newline at end of file diff --git a/src/IRAAS.Tests/ImageProcessing/TestAppSettingsProvider.cs b/src/IRAAS.Tests/ImageProcessing/TestAppSettingsProvider.cs index d9238ab..2bdc287 100644 --- a/src/IRAAS.Tests/ImageProcessing/TestAppSettingsProvider.cs +++ b/src/IRAAS.Tests/ImageProcessing/TestAppSettingsProvider.cs @@ -13,20 +13,6 @@ namespace IRAAS.Tests.ImageProcessing; [Parallelizable(ParallelScope.None)] public class TestAppSettingsProvider : TestBase { - [SetUp] - public void Setup() - { - // don't let tests interact - AppSettingsProvider.ClearCachedSettings(); - } - - [OneTimeTearDown] - public void Teardown() - { - // don't poison app settings for anyone else - AppSettingsProvider.ClearCachedSettings(); - } - [TestCase("appsettings.json")] public void ShouldLoadFromCWD_(string filename) { @@ -220,7 +206,7 @@ public void ShouldSetToZero() } [TestFixture] - public class AllowingInvalidSSLCertificates + public class AllowingInvalidSslCertificates: TestBase { [Test] public void ShouldDefaultToFalseWhenNotSetInConfig() @@ -240,10 +226,10 @@ public void ShouldDefaultToFalseWhenNotSetInConfig() Path.Combine(tempFolder.Path, "appsettings.json"), json ); - + // Act var result = AppSettingsProvider.CreateAppSettings(); - + // Assert Expect(result.AllowInvalidSslCertificates) .To.Be.False(); @@ -268,10 +254,10 @@ public void ShouldComeFromConfigWhenSet() Path.Combine(tempFolder.Path, "appsettings.json"), json ); - + // Act var result = AppSettingsProvider.CreateAppSettings(); - + // Assert Expect(result.AllowInvalidSslCertificates) .To.Be.True(); @@ -382,7 +368,7 @@ IAppSettings settings "DomainWhitelist": "{{settings.DomainWhitelist}}", "ShareConcurrentRequests": "{{settings.ShareConcurrentRequests}}", "EnableConnectionKeepAlive": "{{settings.EnableConnectionKeepAlive}}", - "AllowInvalidSSLCertificates": "{{settings.AllowInvalidSslCertificates}}" + "AllowInvalidSSLCertificates": "{{settings.AllowInvalidSslCertificates}}", "LogFolder": "{{settings.LogFolder}}" }, "DefaultParameters": { diff --git a/src/IRAAS.Tests/ImageProcessing/TestDefaultImageResizeParameters.cs b/src/IRAAS.Tests/ImageProcessing/TestDefaultImageResizeParameters.cs index 1fcdffb..ed16402 100644 --- a/src/IRAAS.Tests/ImageProcessing/TestDefaultImageResizeParameters.cs +++ b/src/IRAAS.Tests/ImageProcessing/TestDefaultImageResizeParameters.cs @@ -17,16 +17,94 @@ public class TestDefaultImageResizeParameters : TestBase public void ShouldDuckDefaults() { // Arrange - var expected = GetRandom(); + var expected = GetRandom() + .With(o => o.Quality = GetRandomInt(10, 90)); var defaults = GenerateStringStringDictionaryFrom(expected); // Act var result = DefaultImageResizeParameters.From(defaults); + // Assert Expect(result) .To.Deep.Equal(expected); } + [Test] + public void ShouldSanitiseQuality() + { + // Arrange + var input = GetRandom() + .With(o => o.Quality = 0); + var defaults = GenerateStringStringDictionaryFrom(input); + + // Act + var result = DefaultImageResizeParameters.From(defaults); + + // Assert + Expect(result.Quality) + .To.Equal(85); + } + + [TestCase("", "Bicubic")] + [TestCase(" ", "Bicubic")] + [TestCase(null, "Bicubic")] + public void ShouldDefaultSamplerWhenSetTo_( + string setting, + string expected + ) + { + // Arrange + var input = GetRandom() + .With(o => o.Sampler = setting); + var defaults = GenerateStringStringDictionaryFrom(input); + + // Act + var result = DefaultImageResizeParameters.From(defaults); + + // Assert + Expect(result.Sampler) + .To.Equal(expected); + } + + [TestCase("", "Wu")] + [TestCase(" ", "Wu")] + [TestCase(null, "Wu")] + public void ShouldDefaultQuantizer_( + string setting, + string expected + ) + { + // Arrange + var input = GetRandom() + .With(o => o.Quantizer = setting); + var defaults = GenerateStringStringDictionaryFrom(input); + + // Act + var result = DefaultImageResizeParameters.From(defaults); + + // Assert + Expect(result.Quantizer) + .To.Equal(expected); + } + + [Test] + public void ShouldDefaultEchoToFalseWhenNotSet() + { + // Arrange + var input = GetRandom(); + var defaults = GenerateStringStringDictionaryFrom(input); + defaults.Remove(nameof(DefaultImageResizeParameters.Echo)); + Expect(defaults.TryGetValue(nameof(DefaultImageResizeParameters.Echo), out _)) + .To.Be.False(); + + // Act + var result = DefaultImageResizeParameters.From(defaults); + + // Assert + Expect(result.Echo) + .To.Be.False(); + } + [TestFixture] public class PerFormatOverrides { @@ -61,10 +139,8 @@ public interface IImageResizeParametersWithoutSampler { string ReplaceTransparencyWith { get; set; } string Format { get; set; } - [Default(85)] int Quality { get; set; } - int? Width { get; set; } int? Height { get; set; } ResizeMode? ResizeMode { get; set; } diff --git a/src/IRAAS.Tests/ImageProcessing/TestImageResizeParameters.cs b/src/IRAAS.Tests/ImageProcessing/TestImageResizeParameters.cs index 6900c2e..224fce3 100644 --- a/src/IRAAS.Tests/ImageProcessing/TestImageResizeParameters.cs +++ b/src/IRAAS.Tests/ImageProcessing/TestImageResizeParameters.cs @@ -141,16 +141,18 @@ public class Url : TestBase public void ShouldBeAbleToSetAndGetValidUriWithPath() { // Arrange - var expected = GetRandomHttpUrlWithPath(); - var uri = new Uri(expected); + var url = GetRandomHttpUrlWithPath(); + var uri = new Uri(url); Expect(uri.AbsolutePath) .Not.To.Be.Null.Or.Whitespace(); var sut = Create(); + // Act - sut.Url = expected; + sut.Url = url; + // Assert Expect(sut.Url) - .To.Equal(expected); + .To.Equal(uri.ToString()); } [Test] diff --git a/src/IRAAS.Tests/ImageProcessing/TestImageResizer.cs b/src/IRAAS.Tests/ImageProcessing/TestImageResizer.cs index 7ecdedd..ed48837 100644 --- a/src/IRAAS.Tests/ImageProcessing/TestImageResizer.cs +++ b/src/IRAAS.Tests/ImageProcessing/TestImageResizer.cs @@ -183,7 +183,12 @@ public async Task GivenOnlyUrl_ShouldFetchAndReOptimiseAsJpegAtSameSizeWithQuali // Assert Expect(result) .Not.To.Be.Null(); - await Expect(fetcher).To.Have.Received(1).Fetch(url, Arg.Any>()); + await Expect(fetcher) + .To.Have.Received(1) + .Fetch( + Arg.Is(s => new Uri(s) == new Uri(url)), + Arg.Any>() + ); var image = await Image.LoadAsync(result.Stream); Expect(image.Metadata.GetFormatMetadata(JpegFormat.Instance).Quality) .To.Equal(85); @@ -319,8 +324,8 @@ public async Task GivenOnlyWidthAndDevicePixelRatio_ShouldResizeToFitWidthAndMai // -> if width is specified as 400, implies height of 300 var effectiveWidth = 400; var devicePixelRatio = GetRandomDevicePixelRatio(); - var expectedWidth = (int) Math.Ceiling(effectiveWidth * devicePixelRatio); - var expectedHeight = (int) Math.Ceiling(300 * devicePixelRatio); + var expectedWidth = (int)Math.Ceiling(effectiveWidth * devicePixelRatio); + var expectedHeight = (int)Math.Ceiling(300 * devicePixelRatio); // Act var result = await sut.Resize( new ImageResizeParameters() @@ -435,8 +440,8 @@ public async Task GivenOnlyHeightAndDevicePixelRatio_ShouldResizeToFitWidthAndMa // -> if height is specified as 150, implies width of 200 var effectiveHeight = 150; var devicePixelRatio = GetRandomDevicePixelRatio(); - var expectedWidth = (int) Math.Ceiling(200 * devicePixelRatio); - var expectedHeight = (int) Math.Ceiling(effectiveHeight * devicePixelRatio); + var expectedWidth = (int)Math.Ceiling(200 * devicePixelRatio); + var expectedHeight = (int)Math.Ceiling(effectiveHeight * devicePixelRatio); // Act var result = await sut.Resize( new ImageResizeParameters() @@ -838,7 +843,11 @@ private static IUrlFetcher CreateFetcherFor( IDictionary headers = null ) { - return CreateFetcherFor(() => data, url, headers); + return CreateFetcherFor( + () => data, + url, + headers + ); } private static IUrlFetcher CreateFetcherFor( diff --git a/src/IRAAS.Tests/ImageProcessing/TestUrlFetcher.cs b/src/IRAAS.Tests/ImageProcessing/TestUrlFetcher.cs index ca87eaa..ca573bc 100644 --- a/src/IRAAS.Tests/ImageProcessing/TestUrlFetcher.cs +++ b/src/IRAAS.Tests/ImageProcessing/TestUrlFetcher.cs @@ -760,21 +760,65 @@ await sut.Fetch( } [TestFixture] - public class AllowingInvalidSSLCertificatesForDev + public class AllowingInvalidSslCertificatesForDev { - [Test] - [Explicit("requires something already running with a self-signed certificate")] - public void ShouldDownloadTheFile() + [TestFixture] + public class WhenAllowed + { + [Test] + public async Task ShouldDownloadTheFile() + { + // Arrange + await using var server = await SelfSignedImageServer.Start( + Resources.Data.FluffyCatJpeg + ); + var appSettings = Substitute.For() + .WithDefaultSettings() + .WithInvalidSslCertificatesAllowed(); + var sut = Create(appSettings); + + // Act + var result = await sut.Fetch( + server.ImageUrl, + new Dictionary() + ); + + // Assert + Expect(result) + .Not.To.Be.Null(); + Expect(result.Stream) + .Not.To.Be.Null(); + var bytes = await result.Stream.ReadAllBytesAsync(); + Expect(bytes) + .Not.To.Be.Empty(); + } + } + + [TestFixture] + public class WhenNotAllowed { - // Arrange - var url = "https://localhost:7263/api/resource/?id=43&ts=1444912007000"; - var appSettings = Substitute.For() - .WithDefaultSettings() - .WithInvalidSslCertificatesAllowed(); - var sut = Create(); - - // Act - // Assert + [Test] + public async Task ShouldThrow() + { + // Arrange + await using var server = await SelfSignedImageServer.Start( + Resources.Data.FluffyCatJpeg + ); + var appSettings = Substitute.For() + .WithDefaultSettings() + .WithInvalidSslCertificatesForbidden(); + var sut = Create(appSettings); + + // Act + Expect( + async () => await sut.Fetch( + server.ImageUrl, + new Dictionary() + ) + ).To.Throw(); + + // Assert + } } } @@ -811,7 +855,7 @@ this IAppSettings appSettings // don't enable keep-alive: let test connections close .WithConnectionKeepAliveDisabled() // allow invalid certs for tests by default - .WithInvalidSslCertificatesAllowed(); + .WithInvalidSslCertificatesForbidden(); } public static IAppSettings WithInvalidSslCertificatesAllowed( diff --git a/src/IRAAS.Tests/Middleware/TestImageProviderErrorMiddleware.cs b/src/IRAAS.Tests/Middleware/TestImageProviderErrorMiddleware.cs index b1ed098..74c1b85 100644 --- a/src/IRAAS.Tests/Middleware/TestImageProviderErrorMiddleware.cs +++ b/src/IRAAS.Tests/Middleware/TestImageProviderErrorMiddleware.cs @@ -12,10 +12,10 @@ namespace IRAAS.Tests.Middleware; [TestFixture] -public class TestImageProviderErrorMiddleware: TestBase +public class TestImageProviderErrorMiddleware : TestBase { [TestFixture] - public class WhenNoExceptionThrown: TestBase + public class WhenNoExceptionThrown : TestBase { [Test] public async Task ShouldNotInterfereWithTheResponse() @@ -28,10 +28,7 @@ public async Task ShouldNotInterfereWithTheResponse() await sut.InvokeAsync( context, ctx => Task.Run( - () => - { - ctx.Response.StatusCode = expected; - } + () => { ctx.Response.StatusCode = expected; } ) ); // Assert @@ -41,7 +38,7 @@ await sut.InvokeAsync( } [TestFixture] - public class WhenAnotherExceptionIsThrown: TestBase + public class WhenAnotherExceptionIsThrown : TestBase { [Test] public void ShouldNotInterfere() @@ -71,7 +68,7 @@ public void ShouldNotInterfere() } [TestFixture] - public class WhenImageProviderErrorExceptionThrown: TestBase + public class WhenImageProviderErrorExceptionThrown : TestBase { [Test] public async Task ShouldSetResultStatusCodeToUpstreamCodeWhenAvailable() @@ -87,7 +84,7 @@ public async Task ShouldSetResultStatusCodeToUpstreamCodeWhenAvailable() { { expectedResponseHeader, expectedResponseHeaderValue } }; - var expected = (int) statusCode; + var expected = (int)statusCode; Expect(context.Response.StatusCode) .Not.To.Equal(expected); #pragma warning disable SYSLIB0014 @@ -122,16 +119,15 @@ await sut.InvokeAsync( ); Expect(body) .To.Contain("Unable to retrieve image") - .And.To.Contain(url) + .And.To.Contain(new Uri(url).ToString()) .And.To.Contain("request headers:") .Then($"{expectedRequestHeader}: {expectedRequestHeaderValue}") - .Then($"response status: {(int) statusCode}") + .Then($"response status: {(int)statusCode}") .Then("response headers:") .Then($"{expectedResponseHeader}: {expectedResponseHeaderValue}"); } } - private static ImageProviderErrorMiddleware Create() { return new ImageProviderErrorMiddleware(Substitute.For()); diff --git a/src/IRAAS.Tests/TestBase.cs b/src/IRAAS.Tests/TestBase.cs index 1eb9b10..458f2f3 100644 --- a/src/IRAAS.Tests/TestBase.cs +++ b/src/IRAAS.Tests/TestBase.cs @@ -9,11 +9,13 @@ public class TestBase public void SetupBase() { ImageResizeParameters.ClearDefaults(); + AppSettingsProvider.ClearCachedSettings(); } [TearDown] public void TearDownBase() { ImageResizeParameters.ClearDefaults(); + AppSettingsProvider.ClearCachedSettings(); } } \ No newline at end of file diff --git a/src/IRAAS.sln b/src/IRAAS.sln index 269f997..9bc3ac2 100644 --- a/src/IRAAS.sln +++ b/src/IRAAS.sln @@ -34,6 +34,8 @@ Global Release|x64 = Release|x64 Release|x86 = Release|x86 ReleaseForDocker|Any CPU = ReleaseForDocker|Any CPU + Debug.WebRequest|Any CPU = Debug.WebRequest|Any CPU + Release.WebRequest|Any CPU = Release.WebRequest|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -53,6 +55,10 @@ Global {602C8FF3-C096-4FDF-BB9E-F645401FA938}.Release|x86.Build.0 = Release|Any CPU {602C8FF3-C096-4FDF-BB9E-F645401FA938}.ReleaseForDocker|Any CPU.ActiveCfg = ReleaseForDocker|Any CPU {602C8FF3-C096-4FDF-BB9E-F645401FA938}.ReleaseForDocker|Any CPU.Build.0 = ReleaseForDocker|Any CPU + {602C8FF3-C096-4FDF-BB9E-F645401FA938}.Debug.WebRequest|Any CPU.ActiveCfg = Debug.WebRequest|Any CPU + {602C8FF3-C096-4FDF-BB9E-F645401FA938}.Debug.WebRequest|Any CPU.Build.0 = Debug.WebRequest|Any CPU + {602C8FF3-C096-4FDF-BB9E-F645401FA938}.Release.WebRequest|Any CPU.ActiveCfg = Release.WebRequest|Any CPU + {602C8FF3-C096-4FDF-BB9E-F645401FA938}.Release.WebRequest|Any CPU.Build.0 = Release.WebRequest|Any CPU {74C3EBAC-0C85-4414-ABB6-E8CDAC4D6A0A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {74C3EBAC-0C85-4414-ABB6-E8CDAC4D6A0A}.Debug|Any CPU.Build.0 = Debug|Any CPU {74C3EBAC-0C85-4414-ABB6-E8CDAC4D6A0A}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -67,6 +73,10 @@ Global {74C3EBAC-0C85-4414-ABB6-E8CDAC4D6A0A}.Release|x86.Build.0 = Release|Any CPU {74C3EBAC-0C85-4414-ABB6-E8CDAC4D6A0A}.ReleaseForDocker|Any CPU.ActiveCfg = ReleaseForDocker|Any CPU {74C3EBAC-0C85-4414-ABB6-E8CDAC4D6A0A}.ReleaseForDocker|Any CPU.Build.0 = ReleaseForDocker|Any CPU + {74C3EBAC-0C85-4414-ABB6-E8CDAC4D6A0A}.Debug.WebRequest|Any CPU.ActiveCfg = Debug.WebRequest|Any CPU + {74C3EBAC-0C85-4414-ABB6-E8CDAC4D6A0A}.Debug.WebRequest|Any CPU.Build.0 = Debug.WebRequest|Any CPU + {74C3EBAC-0C85-4414-ABB6-E8CDAC4D6A0A}.Release.WebRequest|Any CPU.ActiveCfg = Release.WebRequest|Any CPU + {74C3EBAC-0C85-4414-ABB6-E8CDAC4D6A0A}.Release.WebRequest|Any CPU.Build.0 = Release.WebRequest|Any CPU {70C616D4-C4EF-4BA9-9F7A-4DA75614A333}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {70C616D4-C4EF-4BA9-9F7A-4DA75614A333}.Debug|Any CPU.Build.0 = Debug|Any CPU {70C616D4-C4EF-4BA9-9F7A-4DA75614A333}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -81,6 +91,10 @@ Global {70C616D4-C4EF-4BA9-9F7A-4DA75614A333}.Release|x86.Build.0 = Release|Any CPU {70C616D4-C4EF-4BA9-9F7A-4DA75614A333}.ReleaseForDocker|Any CPU.ActiveCfg = ReleaseForDocker|Any CPU {70C616D4-C4EF-4BA9-9F7A-4DA75614A333}.ReleaseForDocker|Any CPU.Build.0 = ReleaseForDocker|Any CPU + {70C616D4-C4EF-4BA9-9F7A-4DA75614A333}.Debug.WebRequest|Any CPU.ActiveCfg = Debug.WebRequest|Any CPU + {70C616D4-C4EF-4BA9-9F7A-4DA75614A333}.Debug.WebRequest|Any CPU.Build.0 = Debug.WebRequest|Any CPU + {70C616D4-C4EF-4BA9-9F7A-4DA75614A333}.Release.WebRequest|Any CPU.ActiveCfg = Release.WebRequest|Any CPU + {70C616D4-C4EF-4BA9-9F7A-4DA75614A333}.Release.WebRequest|Any CPU.Build.0 = Release.WebRequest|Any CPU {5489BE03-DAC7-4D7D-80FC-628258DD9FF2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {5489BE03-DAC7-4D7D-80FC-628258DD9FF2}.Debug|Any CPU.Build.0 = Debug|Any CPU {5489BE03-DAC7-4D7D-80FC-628258DD9FF2}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -95,6 +109,10 @@ Global {5489BE03-DAC7-4D7D-80FC-628258DD9FF2}.Release|x86.Build.0 = Release|Any CPU {5489BE03-DAC7-4D7D-80FC-628258DD9FF2}.ReleaseForDocker|Any CPU.ActiveCfg = ReleaseForDocker|Any CPU {5489BE03-DAC7-4D7D-80FC-628258DD9FF2}.ReleaseForDocker|Any CPU.Build.0 = ReleaseForDocker|Any CPU + {5489BE03-DAC7-4D7D-80FC-628258DD9FF2}.Debug.WebRequest|Any CPU.ActiveCfg = Debug.WebRequest|Any CPU + {5489BE03-DAC7-4D7D-80FC-628258DD9FF2}.Debug.WebRequest|Any CPU.Build.0 = Debug.WebRequest|Any CPU + {5489BE03-DAC7-4D7D-80FC-628258DD9FF2}.Release.WebRequest|Any CPU.ActiveCfg = Release.WebRequest|Any CPU + {5489BE03-DAC7-4D7D-80FC-628258DD9FF2}.Release.WebRequest|Any CPU.Build.0 = Release.WebRequest|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {C55FCB13-C06D-43B6-846C-10BCFA05DC64} = {93FEC7FE-990B-4539-AA21-0ADF681732AA} diff --git a/src/IRAAS/AppSettingsProvider.cs b/src/IRAAS/AppSettingsProvider.cs index 03ac43a..ff5216c 100644 --- a/src/IRAAS/AppSettingsProvider.cs +++ b/src/IRAAS/AppSettingsProvider.cs @@ -133,10 +133,11 @@ IConfigurationRoot config { continue; } + var sub = LoadSection(config, $"DefaultParameters:{item.Key}"); result.RegisterPerFormatDefaultsFor(item.Key, sub); } - + return result; } catch (UnDuckableException ex) @@ -166,10 +167,14 @@ private static Dictionary CreateDefaultConfig() .GetProperties() .Select( pi => (pi.Name, pi.GetCustomAttributes(true) - .OfType() - .FirstOrDefault()?.Value) + .OfType() + .FirstOrDefault()?.Value) ) - .ToDictionary(o => o.Name, o => o.Value, StringComparer.OrdinalIgnoreCase); + .ToDictionary( + o => o.Name, + o => o.Value, + StringComparer.OrdinalIgnoreCase + ); } private static string FindLogLevelFor( @@ -279,7 +284,7 @@ private static bool TryChangeType(string value, out T result) result = default(T); try { - result = (T) Convert.ChangeType(value, typeof(T)); + result = (T)Convert.ChangeType(value, typeof(T)); return true; } catch @@ -291,7 +296,6 @@ private static bool TryChangeType(string value, out T result) private const string SETTING_MAX_CONCURRENCY = "MaxConcurrency"; private const string SETTING_MAX_IMAGE_FETCH_TIME_IN_MILLISECONDS = "MaxImageFetchTimeInMilliseconds"; private const string SETTING_LOG_FOLDER = "LogFolder"; - private const int DEFAULT_MAX_IMAGE_FETCH_TIME_IN_MILLISECONDS = 1000; private static readonly int DEFAULT_MAX_CONCURRENCY = Environment.ProcessorCount; } \ No newline at end of file diff --git a/src/IRAAS/IAppSettings.cs b/src/IRAAS/IAppSettings.cs index ef86112..f921a3d 100644 --- a/src/IRAAS/IAppSettings.cs +++ b/src/IRAAS/IAppSettings.cs @@ -16,7 +16,8 @@ public DefaultSettingAttribute(string value) public static class DefaultSettingValues { - public const string FORTY_MEGABYTES = "${40 * 1024 * 1024}"; + // 40mb default + public const string FORTY_MEGABYTES = "41943040"; public const string FALSE = "false"; public const string TRUE = "true"; public const string HOST_MACHINE_CPU_COUNT = "0"; diff --git a/src/IRAAS/IRAAS.csproj b/src/IRAAS/IRAAS.csproj index bdf2ec5..85d47cc 100644 --- a/src/IRAAS/IRAAS.csproj +++ b/src/IRAAS/IRAAS.csproj @@ -7,10 +7,18 @@ true disable iraas - Debug;Release;ReleaseForDocker + Debug;Release;ReleaseForDocker;Debug.WebRequest;Release.WebRequest AnyCPU true + + + USE_WEBREQUEST;DEBUG + + + + USE_WEBREQUEST + true @@ -21,12 +29,12 @@ - + - - - + + + diff --git a/src/IRAAS/ImageProcessing/DefaultImageResizeParameters.cs b/src/IRAAS/ImageProcessing/DefaultImageResizeParameters.cs index 9529292..dfa0907 100644 --- a/src/IRAAS/ImageProcessing/DefaultImageResizeParameters.cs +++ b/src/IRAAS/ImageProcessing/DefaultImageResizeParameters.cs @@ -69,7 +69,7 @@ value is null { target[nameof(Echo)] = $"{result.Echo}"; } - + Sanitise(result); return result; @@ -94,7 +94,7 @@ public IImageResizeParameters For(string imageFormat) /// public static T Sanitise( T result - ) where T: IImageResizeParameters + ) where T : IImageResizeParameters { if (result.Quality < 1) { @@ -106,7 +106,11 @@ T result result.Sampler = "Bicubic"; } - result.Quantizer ??= "Wu"; + if (string.IsNullOrWhiteSpace(result.Quantizer)) + { + result.Quantizer = "Wu"; + } + result.Echo ??= false; return result; diff --git a/src/IRAAS/ImageProcessing/UrlFetcher.cs b/src/IRAAS/ImageProcessing/UrlFetcher.cs index 244bfe7..2222dc7 100644 --- a/src/IRAAS/ImageProcessing/UrlFetcher.cs +++ b/src/IRAAS/ImageProcessing/UrlFetcher.cs @@ -10,15 +10,17 @@ using System; using System.Collections.Generic; using System.Net; +using System.Net.Http; +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using PeanutButter.Utils; + #if USE_WEBREQUEST #else -using System.Net.Http; using System.Linq; using System.Threading; - #endif namespace IRAAS.ImageProcessing; @@ -32,7 +34,10 @@ public class UrlFetcher : IUrlFetcher { private readonly IAppSettings _appSettings; private readonly ILogger _logger; - +#if USE_WEBREQUEST +#else + private readonly HttpClient HttpClient; +#endif public UrlFetcher( IAppSettings appSettings, @@ -41,106 +46,170 @@ ILogger logger { _appSettings = appSettings; _logger = logger; - } - #if USE_WEBREQUEST #else - private static readonly HttpClient HttpClient; - static UrlFetcher() + if (appSettings.AllowInvalidSslCertificates) + { + HttpClient = new HttpClient( + new HttpClientHandler() + { + ServerCertificateCustomValidationCallback = AllowInvalidSslCallback + } + ); + } + else + { + HttpClient = new HttpClient(); + } +#endif + } + + private bool AllowInvalidSslCallback( + HttpRequestMessage request, + X509Certificate2 _, + X509Chain __, + SslPolicyErrors errors + ) { - HttpClient = new HttpClient(); + if (errors != SslPolicyErrors.None) + { + _logger.LogWarning( + $"Accepting invalid SSL certificate for request: { + request.RequestUri + }" + ); + } + + return true; } -#endif #if USE_WEBREQUEST - public async Task Fetch( - string url, - IDictionary headers) + private bool WebRequestSslHandler( + object sender, + X509Certificate certificate, + X509Chain chain, + SslPolicyErrors sslPolicyErrors + ) + { + if (sslPolicyErrors == SslPolicyErrors.None) { - var req = WebRequest.Create(url); - req.Timeout = _appSettings.MaxImageFetchTimeInMilliseconds; - SetRequestHeaders(url, headers, req); + return true; + } - try - { - var res = await req.GetResponseAsync(); - return new StreamAndHeaders( - new WebResponseStream(res, _appSettings), - res.Headers.ToDictionary() - ); - } - catch (WebException ex) when (ex.Status == WebExceptionStatus.Timeout) - { - throw new RequestTimedOutException( - url, - ex.Response - ); - } - catch (WebException ex) - { - if (!(ex.Response is HttpWebResponse httpResponse)) - { - throw; - } + if (sender is not HttpWebRequest httpRequest) + { + return true; + } - if (HttpHandlers.TryGetValue(httpResponse.StatusCode, out var handler)) - { - return handler(req, httpResponse); - } + _logger.LogWarning( + $"Accepting invalid SSL certificate for request: { + httpRequest.RequestUri + }" + ); - throw new ImageProviderErrorException( - httpResponse.StatusCode, - url, - req.Headers.ToDictionary().Clone(), - ex.Response.Headers.ToDictionary().Clone() - ); - } - } + return true; + } - private Dictionary> HttpHandlers = - new Dictionary>() + public async Task Fetch( + string url, + IDictionary headers) + { +#pragma warning disable SYSLIB0014 + var req = WebRequest.Create(url); +#pragma warning restore SYSLIB0014 + req.Timeout = _appSettings.MaxImageFetchTimeInMilliseconds; + if (_appSettings.AllowInvalidSslCertificates) + { + if (req is HttpWebRequest httpRequest) { - [HttpStatusCode.NotModified] = ThrowNotModifiedException, - }; + httpRequest.ServerCertificateValidationCallback = WebRequestSslHandler; + } + } + SetRequestHeaders( + url, + headers, + req + ); - private static StreamAndHeaders ThrowNotModifiedException( - WebRequest request, - HttpWebResponse response - ) + try { - throw new NotModifiedException(); + var res = await req.GetResponseAsync(); + return new StreamAndHeaders( + new WebResponseStream(res, _appSettings), + res.Headers.ToDictionary() + ); } - - private void SetRequestHeaders( - string url, - IDictionary headers, - WebRequest req) + catch (WebException ex) when (ex.Status == WebExceptionStatus.Timeout) { - headers?.ForEach( - kvp => req.Headers[kvp.Key] = kvp.Value + throw new RequestTimedOutException( + url, + ex.Response ); - OverrideUnreasonableHeaders(url, req); } - - private void OverrideUnreasonableHeaders( - string url, - WebRequest req) + catch (WebException ex) { - var host = new Uri(url).Host; - req.Headers["Host"] = host; - req.Headers["Referrer"] = host; - req.Headers["Origin"] = host; - req.Headers.Remove(HttpRequestHeader.Connection); // do not honor keep-alive from caller - if (req is HttpWebRequest httpWebRequest) + if (!(ex.Response is HttpWebResponse httpResponse)) { - httpWebRequest.KeepAlive = - _appSettings.EnableConnectionKeepAlive; // ensure we don't add our own keep-alive + throw; } - req.Headers["Accept"] = "image/*"; + if (HttpHandlers.TryGetValue(httpResponse.StatusCode, out var handler)) + { + return handler(req, httpResponse); + } + + throw new ImageProviderErrorException( + httpResponse.StatusCode, + url, + req.Headers.ToDictionary().Clone(), + ex.Response.Headers.ToDictionary().Clone() + ); } + } + private Dictionary> HttpHandlers = + new Dictionary>() + { + [HttpStatusCode.NotModified] = ThrowNotModifiedException, + }; + + private static StreamAndHeaders ThrowNotModifiedException( + WebRequest request, + HttpWebResponse response + ) + { + throw new NotModifiedException(); + } + + private void SetRequestHeaders( + string url, + IDictionary headers, + WebRequest req) + { + headers?.ForEach( + kvp => req.Headers[kvp.Key] = kvp.Value + ); + OverrideUnreasonableHeaders(url, req); + } + + private void OverrideUnreasonableHeaders( + string url, + WebRequest req) + { + var host = new Uri(url).Host; + req.Headers["Host"] = host; + req.Headers["Referrer"] = host; + req.Headers["Origin"] = host; + req.Headers.Remove(HttpRequestHeader.Connection); // do not honor keep-alive from caller + if (req is HttpWebRequest httpWebRequest) + { + httpWebRequest.KeepAlive = + _appSettings.EnableConnectionKeepAlive; // ensure we don't add our own keep-alive + } + + req.Headers["Accept"] = "image/*"; + } #else public async Task Fetch( string url, @@ -175,7 +244,11 @@ IDictionary headers { RequestUri = new Uri(url), }; - SetRequestHeaders(url, headers, request); + SetRequestHeaders( + url, + headers, + request + ); var cancellationTokenSource = new CancellationTokenSource( _appSettings.MaxImageFetchTimeInMilliseconds ); From 537fc1a38a880442ebc7a9ada0b90afbba03f13d Mon Sep 17 00:00:00 2001 From: Davyd McColl Date: Fri, 18 Sep 2026 10:58:33 +0200 Subject: [PATCH 03/18] :recycle: consolidate all fallbacks for app config --- .../TestAppSettingsProvider.cs | 31 ++++++++++++++++- src/IRAAS/AppSettingsProvider.cs | 33 ++++++++++++------- 2 files changed, 52 insertions(+), 12 deletions(-) diff --git a/src/IRAAS.Tests/ImageProcessing/TestAppSettingsProvider.cs b/src/IRAAS.Tests/ImageProcessing/TestAppSettingsProvider.cs index 2bdc287..aee6eff 100644 --- a/src/IRAAS.Tests/ImageProcessing/TestAppSettingsProvider.cs +++ b/src/IRAAS.Tests/ImageProcessing/TestAppSettingsProvider.cs @@ -46,6 +46,35 @@ public void ShouldDefaultMaxConcurrencyToProcessorCount_(string filename) ); // Arrange var json = MakeSettingsWithoutConcurrency(expected); + Expect(json) + .Not.To.Contain(nameof(IAppSettings.MaxConcurrency)); + File.WriteAllText( + filename, + json + ); + // Act + var result = AppSettingsProvider.CreateAppSettings(); + // Assert + Expect(result.MaxConcurrency) + .To.Equal(Environment.ProcessorCount); + } + + [TestCase("appsettings.json")] + public void ShouldDefaultMaxConcurrencyToProcessorCountWhenConfiguredLessThan1_( + string filename + ) + { + var input = GetRandom() + .With(o => o.MaxConcurrency.Returns(_ => 0)); + using var tempFolder = new AutoTempFolder(); + using var _ = new AutoResetter( + () => ChDir(tempFolder.Path), + prior => ChDir(prior) + ); + // Arrange + var json = MakeSettings(input); + Expect(json) + .To.Contain(nameof(IAppSettings.MaxConcurrency)); File.WriteAllText( filename, json @@ -206,7 +235,7 @@ public void ShouldSetToZero() } [TestFixture] - public class AllowingInvalidSslCertificates: TestBase + public class AllowingInvalidSslCertificates : TestBase { [Test] public void ShouldDefaultToFalseWhenNotSetInConfig() diff --git a/src/IRAAS/AppSettingsProvider.cs b/src/IRAAS/AppSettingsProvider.cs index ff5216c..3268047 100644 --- a/src/IRAAS/AppSettingsProvider.cs +++ b/src/IRAAS/AppSettingsProvider.cs @@ -96,13 +96,6 @@ IConfigurationRoot config var result = merged.FuzzyDuckAs( throwOnError: true ); - if (result.MaxUrlFetchRetries < 0) - { - // alter the underlying dictionary of settings - // -> the IAppSettings interface is read-only - providedConfig[nameof(result.MaxUrlFetchRetries)] = "0"; - } - return result; } catch (UnDuckableException ex) @@ -196,7 +189,8 @@ private static readonly Dictionary, str { [SETTING_MAX_CONCURRENCY] = ResolveMaxConcurrency, [SETTING_MAX_IMAGE_FETCH_TIME_IN_MILLISECONDS] = ResolveMaxImageFetchTimeInMilliseconds, - [SETTING_LOG_FOLDER] = ResolveDefaultLogFolder + [SETTING_LOG_FOLDER] = ResolveDefaultLogFolder, + [SETTING_MAX_URL_FETCH_RETRIES] = ResolveMaxUrlFetchRetries }; private static string ResolveDefaultLogFolder( @@ -256,6 +250,19 @@ IDictionary config ); } + private static string ResolveMaxUrlFetchRetries( + IDictionary config + ) + { + return ResolveSetting( + config, + SETTING_MAX_URL_FETCH_RETRIES, + i => i > 0, + // when not set or set negative, set to 0 + 0 + ); + } + private static string ResolveSetting( IDictionary config, string key, @@ -293,9 +300,13 @@ private static bool TryChangeType(string value, out T result) } } - private const string SETTING_MAX_CONCURRENCY = "MaxConcurrency"; - private const string SETTING_MAX_IMAGE_FETCH_TIME_IN_MILLISECONDS = "MaxImageFetchTimeInMilliseconds"; - private const string SETTING_LOG_FOLDER = "LogFolder"; + private const string SETTING_MAX_CONCURRENCY = nameof(IAppSettings.MaxConcurrency); + + private const string SETTING_MAX_IMAGE_FETCH_TIME_IN_MILLISECONDS = + nameof(IAppSettings.MaxImageFetchTimeInMilliseconds); + + private const string SETTING_LOG_FOLDER = nameof(IAppSettings.LogFolder); + private const string SETTING_MAX_URL_FETCH_RETRIES = nameof(IAppSettings.MaxUrlFetchRetries); private const int DEFAULT_MAX_IMAGE_FETCH_TIME_IN_MILLISECONDS = 1000; private static readonly int DEFAULT_MAX_CONCURRENCY = Environment.ProcessorCount; } \ No newline at end of file From 9393565d7db88dc9b93e63991bcb208a978c931b Mon Sep 17 00:00:00 2001 From: Davyd McColl Date: Fri, 18 Sep 2026 16:22:24 +0200 Subject: [PATCH 04/18] :sparkles: do not re-use requests with Cache-Control: no-cache headers --- .../Middleware/TestConcurrencyMiddleware.cs | 220 ++++++++++++------ src/IRAAS/Middleware/ConcurrencyMiddleware.cs | 75 ++++-- 2 files changed, 203 insertions(+), 92 deletions(-) diff --git a/src/IRAAS.Tests/Middleware/TestConcurrencyMiddleware.cs b/src/IRAAS.Tests/Middleware/TestConcurrencyMiddleware.cs index ebaf0e2..28ddd60 100644 --- a/src/IRAAS.Tests/Middleware/TestConcurrencyMiddleware.cs +++ b/src/IRAAS.Tests/Middleware/TestConcurrencyMiddleware.cs @@ -14,11 +14,11 @@ namespace IRAAS.Tests.Middleware; [TestFixture] -public class TestConcurrencyMiddleware: TestBase +public class TestConcurrencyMiddleware : TestBase { [TestFixture] [Parallelizable(ParallelScope.None)] - public class WhenNoConcurrentRequestMatchingQueryString: TestBase + public class WhenNoConcurrentRequestMatchingQueryString : TestBase { [Test] public async Task ShouldRunNext() @@ -28,11 +28,13 @@ public async Task ShouldRunNext() var queryString = "?url=http://foo.bar"; context.Request.QueryString = new QueryString(queryString); var invoked = false; - var next = new Func(ctx => - { - invoked = true; - return Task.CompletedTask; - }); + var next = new Func( + ctx => + { + invoked = true; + return Task.CompletedTask; + } + ); var sut = Create(); // Act @@ -45,7 +47,7 @@ public async Task ShouldRunNext() [TestFixture] [Parallelizable(ParallelScope.None)] - public class WhenMatchingRequestAlreadyInProgress: TestBase + public class WhenMatchingRequestAlreadyInProgress : TestBase { public static IEnumerable TestRange() { @@ -67,32 +69,38 @@ public void ShouldOnlyCallNextOnce() var invoked = 0; var startBarrier = new Barrier(3); var completionBarrier = new Barrier(2); - var next1 = new Func(ctx => - { - startBarrier.SignalAndWait(); - Thread.Sleep(1000); - invoked++; - completionBarrier.SignalAndWait(); - return Task.CompletedTask; - }); - var next2 = new Func(ctx => - { - Thread.Sleep(1000); - invoked++; - completionBarrier.SignalAndWait(); - return Task.CompletedTask; - }); - var appSettings = CreateAppSettings(1, false); + var next1 = new Func( + ctx => + { + startBarrier.SignalAndWait(); + Thread.Sleep(1000); + invoked++; + completionBarrier.SignalAndWait(); + return Task.CompletedTask; + } + ); + var next2 = new Func( + ctx => + { + Thread.Sleep(1000); + invoked++; + completionBarrier.SignalAndWait(); + return Task.CompletedTask; + } + ); + var appSettings = CreateAppSettings(1, true); var sut = Create(appSettings); // Act // #pragma warning disable 4014 Task.Run(async () => await sut.InvokeAsync(context1, new RequestDelegate(next1))); - Task.Run(async () => - { - startBarrier.SignalAndWait(); - await sut.InvokeAsync(context2, new RequestDelegate(next2)); - }); + Task.Run( + async () => + { + startBarrier.SignalAndWait(); + await sut.InvokeAsync(context2, new RequestDelegate(next2)); + } + ); // #pragma warning restore 4014 var timeout = 10000; @@ -107,6 +115,72 @@ public void ShouldOnlyCallNextOnce() .To.Equal(1); } + [TestFixture] + public class WhenRequestIncludesHeader_CacheControlNoStore + { + [Test] + public void ShouldCallNextForEachRequest() + { + // Arrange + var context1 = new FakeHttpContext(); + var context2 = new FakeHttpContext(); + var queryString = "?url=http://foo.bar"; + context1.Request.QueryString = new QueryString(queryString); + context1.Request.Headers.Append("Cache-Control", "no-store"); + context2.Request.QueryString = new QueryString(queryString); + context2.Request.Headers.Append("Cache-Control", "no-store"); + var invoked = 0; + var startBarrier = new Barrier(3); + var completionBarrier = new Barrier(3); + var next1 = new Func( + ctx => + { + startBarrier.SignalAndWait(); + Thread.Sleep(1000); + invoked++; + return Task.CompletedTask; + } + ); + var next2 = new Func( + ctx => + { + Thread.Sleep(1000); + invoked++; + return Task.CompletedTask; + } + ); + var appSettings = CreateAppSettings(1, true); + + var sut = Create(appSettings); + // Act +// #pragma warning disable 4014 + Task.Run(async () => { + await sut.InvokeAsync(context1, new RequestDelegate(next1)); + completionBarrier.SignalAndWait(); + }); + Task.Run( + async () => + { + startBarrier.SignalAndWait(); + await sut.InvokeAsync(context2, new RequestDelegate(next2)); + completionBarrier.SignalAndWait(); + } + ); +// #pragma warning restore 4014 + + var timeout = 10000; + var started = startBarrier.SignalAndWait(timeout); + var completed = completionBarrier.SignalAndWait(timeout); + // Assert + Expect(started) + .To.Be.True("Should have started"); + Expect(completed) + .To.Be.True("Should have completed"); + Expect(invoked) + .To.Equal(2); + } + } + [Test] [Parallelizable(ParallelScope.None)] public void ShouldHaveTheSameResult() @@ -122,30 +196,36 @@ public void ShouldHaveTheSameResult() var invoked = 0; var startBarrier = new Barrier(3); var completionBarrier = new Barrier(2); - var next1 = new Func(ctx => - { - startBarrier.SignalAndWait(); - Thread.Sleep(1000); - invoked++; - completionBarrier.SignalAndWait(); - return Task.CompletedTask; - }); - var next2 = new Func(ctx => - { - Thread.Sleep(1000); - invoked++; - completionBarrier.SignalAndWait(); - return Task.CompletedTask; - }); + var next1 = new Func( + ctx => + { + startBarrier.SignalAndWait(); + Thread.Sleep(1000); + invoked++; + completionBarrier.SignalAndWait(); + return Task.CompletedTask; + } + ); + var next2 = new Func( + ctx => + { + Thread.Sleep(1000); + invoked++; + completionBarrier.SignalAndWait(); + return Task.CompletedTask; + } + ); var sut = Create(); // Act Task.Run(() => sut.InvokeAsync(context1, new RequestDelegate(next1))); - Task.Run(() => - { - startBarrier.SignalAndWait(); - sut.InvokeAsync(context2, new RequestDelegate(next2)); - }); + Task.Run( + () => + { + startBarrier.SignalAndWait(); + sut.InvokeAsync(context2, new RequestDelegate(next2)); + } + ); var started = startBarrier.SignalAndWait(5000); var completed = completionBarrier.SignalAndWait(5000); @@ -173,31 +253,35 @@ public void ShouldLimitConcurrencyPerConfiguration() var requests = GetRandomInt(10, 20); var running = false; var failed = false; - var next = new Func(ctx => - { - Thread.Sleep(GetRandomInt(0, 50)); - if (running) + var next = new Func( + ctx => { - failed = true; - } - else - { - running = true; - Thread.Sleep(GetRandomInt(100, 500)); - running = false; - } + Thread.Sleep(GetRandomInt(0, 50)); + if (running) + { + failed = true; + } + else + { + running = true; + Thread.Sleep(GetRandomInt(100, 500)); + running = false; + } - return Task.CompletedTask; - }); + return Task.CompletedTask; + } + ); // Act var threads = new List(); for (var i = 0; i < requests; i++) { - var t = new Thread(() => sut.InvokeAsync( - CreateContext(), - next.AsRequestDelegate() - )); + var t = new Thread( + () => sut.InvokeAsync( + CreateContext(), + next.AsRequestDelegate() + ) + ); threads.Add(t); } @@ -285,4 +369,4 @@ private static IAppSettings CreateAppSettings( result.ShareConcurrentRequests.Returns(shareRequests ?? true); return result; } -} +} \ No newline at end of file diff --git a/src/IRAAS/Middleware/ConcurrencyMiddleware.cs b/src/IRAAS/Middleware/ConcurrencyMiddleware.cs index e3e0196..ec09dbe 100644 --- a/src/IRAAS/Middleware/ConcurrencyMiddleware.cs +++ b/src/IRAAS/Middleware/ConcurrencyMiddleware.cs @@ -7,6 +7,7 @@ using System.Web; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Primitives; using PeanutButter.Utils; namespace IRAAS.Middleware; @@ -54,7 +55,6 @@ public ConcurrencyMiddleware( private static readonly ConcurrentDictionary> CurrentRequests = new(); - private async Task Throttle( HttpContext context, RequestDelegate next) @@ -90,14 +90,18 @@ private async Task ShareConcurrentRequestResult( { var queryString = context.Request.QueryString.ToString(); var completionSource = new TaskCompletionSource(); - // look for an existing current query with the same parameters - if (!CurrentRequests.TryAdd(queryString, completionSource) && - CurrentRequests.TryGetValue(queryString, out var src)) + var cacheProhibited = HasNoStoreCacheControlHeader(context.Request); + if (!cacheProhibited) { - // a request is currently underway for this query - // -> subscribe to the completed result - await ReuseResult(context, src); - return; + // look for an existing current query with the same parameters + if (!CurrentRequests.TryAdd(queryString, completionSource) && + CurrentRequests.TryGetValue(queryString, out var src)) + { + // a request is currently underway for this query + // -> subscribe to the completed result + await ReuseResult(context, src); + return; + } } await _concurrencyLimiter.WaitAsync(); @@ -107,7 +111,8 @@ await PerformFullRequestWith( context, next, queryString, - completionSource + completionSource, + cacheProhibited ); } finally @@ -116,14 +121,31 @@ await PerformFullRequestWith( } } - private async Task PerformFullRequestWith( - HttpContext context, + private bool HasNoStoreCacheControlHeader( + HttpRequest request + ) + { + var cacheControl = request.Headers["Cache-Control"]; + if (cacheControl == StringValues.Empty) + { + return false; + } + + return cacheControl.ToString() == "no-store"; + } + + private async Task PerformFullRequestWith(HttpContext context, RequestDelegate next, string queryString, - TaskCompletionSource completionSource) + TaskCompletionSource completionSource, + bool cacheProhibited + ) { var originalBody = context.Response.Body; - CurrentRequests.TryAdd(queryString, completionSource); + if (!cacheProhibited) + { + CurrentRequests.TryAdd(queryString, completionSource); + } await using var memStream = new MemoryStream(); try @@ -187,13 +209,15 @@ private void LogRequest(string queryString) queryString = queryString.Substring(1); // remove leading ? var parameters = queryString.Split("&") - .Select(part => - { - var sub = part.Split('='); - var key = sub.First(); - var value = string.Join("&", sub.Skip(1)); - return (key, value); - }) + .Select( + part => + { + var sub = part.Split('='); + var key = sub.First(); + var value = string.Join("&", sub.Skip(1)); + return (key, value); + } + ) .ToDictionary(p => p.key, p => p.value); if (!parameters.TryGetValue("url", out var url)) { @@ -202,7 +226,8 @@ private void LogRequest(string queryString) parameters.Remove("url"); var longest = parameters.Keys.Aggregate( - 3, (acc, cur) => cur.Length > acc + 3, + (acc, cur) => cur.Length > acc ? cur.Length : acc ); @@ -211,9 +236,11 @@ private void LogRequest(string queryString) { "Serviced request:", $"{"url".PadRight(longest)}: {HttpUtility.UrlDecode(url)}" - }.Concat(parameters.Select( - kvp => $"{kvp.Key.PadRight(longest)}: {HttpUtility.UrlDecode(kvp.Value)}" - )); + }.Concat( + parameters.Select( + kvp => $"{kvp.Key.PadRight(longest)}: {HttpUtility.UrlDecode(kvp.Value)}" + ) + ); _logger.LogInformation( string.Join( "\n", From 980e41c32fbd2f4d66cfe0f27ed9c263abdfa998 Mon Sep 17 00:00:00 2001 From: Davyd McColl Date: Mon, 21 Sep 2026 10:13:44 +0200 Subject: [PATCH 05/18] :ok_hand: result of claude review - fix some issues - add tests around the fixes --- .../TestAppSettingsProvider.cs | 2 +- .../ImageProcessing/TestUrlFetcher.cs | 3 +- .../Middleware/TestConcurrencyMiddleware.cs | 164 ++++++++++++++++++ src/IRAAS/AppSettingsProvider.cs | 10 ++ src/IRAAS/Middleware/ConcurrencyMiddleware.cs | 15 +- src/IRAAS/Startup.cs | 6 +- src/IRAAS/appsettings.json | 2 +- 7 files changed, 196 insertions(+), 6 deletions(-) diff --git a/src/IRAAS.Tests/ImageProcessing/TestAppSettingsProvider.cs b/src/IRAAS.Tests/ImageProcessing/TestAppSettingsProvider.cs index aee6eff..eb7b2b0 100644 --- a/src/IRAAS.Tests/ImageProcessing/TestAppSettingsProvider.cs +++ b/src/IRAAS.Tests/ImageProcessing/TestAppSettingsProvider.cs @@ -581,5 +581,5 @@ public class AppSettings : IAppSettings public LogLevel IRAASLogLevel { get; set; } public int MaxUrlFetchRetries { get; set; } public bool Verbose { get; set; } - public bool AllowInvalidSslCertificates { get; } + public bool AllowInvalidSslCertificates { get; set; } } \ No newline at end of file diff --git a/src/IRAAS.Tests/ImageProcessing/TestUrlFetcher.cs b/src/IRAAS.Tests/ImageProcessing/TestUrlFetcher.cs index ca573bc..5f315ba 100644 --- a/src/IRAAS.Tests/ImageProcessing/TestUrlFetcher.cs +++ b/src/IRAAS.Tests/ImageProcessing/TestUrlFetcher.cs @@ -854,7 +854,8 @@ this IAppSettings appSettings .WithTestPageEnabled() // don't enable keep-alive: let test connections close .WithConnectionKeepAliveDisabled() - // allow invalid certs for tests by default + // don't allow invalid ssl certs by default + // -> mirrors most likely deployed config .WithInvalidSslCertificatesForbidden(); } diff --git a/src/IRAAS.Tests/Middleware/TestConcurrencyMiddleware.cs b/src/IRAAS.Tests/Middleware/TestConcurrencyMiddleware.cs index 28ddd60..acbe882 100644 --- a/src/IRAAS.Tests/Middleware/TestConcurrencyMiddleware.cs +++ b/src/IRAAS.Tests/Middleware/TestConcurrencyMiddleware.cs @@ -115,6 +115,101 @@ public void ShouldOnlyCallNextOnce() .To.Equal(1); } + [Test] + public void ShouldNotEjectAnInProgressCacheableRequestWhenAConcurrentNoStoreRequestCompletes() + { + // Arrange + var context1 = new FakeHttpContext(); + var context2 = new FakeHttpContext(); + var context3 = new FakeHttpContext(); + var queryString = "?url=http://foo.bar"; + context1.Request.QueryString = new QueryString(queryString); + context2.Request.QueryString = new QueryString(queryString); + context2.Request.Headers.Append("Cache-Control", "no-store"); + context3.Request.QueryString = new QueryString(queryString); + + var invoked1 = 0; + var invoked2 = 0; + var invoked3 = 0; + var expectedBody = GetRandomBytes(16, 32); + + using var request1Started = new ManualResetEventSlim(false); + using var releaseRequest1 = new ManualResetEventSlim(false); + + var next1 = new Func( + async ctx => + { + // by the time this runs, request1 has already registered + // itself in CurrentRequests + request1Started.Set(); + invoked1++; + await ctx.Response.Body.WriteAsync(expectedBody, 0, expectedBody.Length); + releaseRequest1.Wait(10000); + } + ); + var next2 = new Func( + ctx => + { + invoked2++; + return Task.CompletedTask; + } + ); + var next3 = new Func( + ctx => + { + invoked3++; + return Task.CompletedTask; + } + ); + + // allow request1 (held open, simulating "still in-flight") and + // request2 (no-store) to run at the same time + var appSettings = CreateAppSettings(2, true); + var sut = Create(appSettings); + + // Act + var task1 = Task.Run(() => sut.InvokeAsync(context1, new RequestDelegate(next1))); + Expect(request1Started.Wait(10000)) + .To.Be.True("request1 should have started (and registered itself) by now"); + + var task2 = Task.Run(() => sut.InvokeAsync(context2, new RequestDelegate(next2))); + Expect(task2.Wait(10000)) + .To.Be.True("the no-store request should complete independently of request1"); + + // request1 is still in-flight at this point: a 3rd request for the + // same query should join it rather than triggering a fresh fetch + var task3 = Task.Run(() => sut.InvokeAsync(context3, new RequestDelegate(next3))); + // give request3 a chance to (incorrectly) run to completion if + // request2's no-store handling wrongly evicted request1's entry + Thread.Sleep(200); + Expect(invoked3) + .To.Equal( + 0, + "request3 should be waiting on request1's in-progress result, not performing its own fetch" + ); + + releaseRequest1.Set(); + Expect(task1.Wait(10000)) + .To.Be.True(); + Expect(task3.Wait(10000)) + .To.Be.True(); + + // Assert + Expect(invoked1) + .To.Equal(1); + Expect(invoked2) + .To.Equal(1); + Expect(invoked3) + .To.Equal(0); + + var result1 = context1.Response.Body.ReadAllBytes(); + var result3 = context3.Response.Body.ReadAllBytes(); + Expect(result1) + .To.Equal(expectedBody); + Expect(result3) + .To.Equal(expectedBody); + } + [TestFixture] public class WhenRequestIncludesHeader_CacheControlNoStore { @@ -181,6 +276,75 @@ public void ShouldCallNextForEachRequest() } } + [TestFixture] + public class WhenRequestIncludesHeader_CacheControlWithMultipleDirectivesIncludingNoStore + { + [Test] + public void ShouldTreatTheRequestAsNoStore() + { + // Arrange + var context1 = new FakeHttpContext(); + var context2 = new FakeHttpContext(); + var queryString = "?url=http://foo.bar"; + context1.Request.QueryString = new QueryString(queryString); + context1.Request.Headers.Append("Cache-Control", "no-cache, no-store"); + context2.Request.QueryString = new QueryString(queryString); + context2.Request.Headers.Append("Cache-Control", "no-cache, no-store"); + var invoked = 0; + var startBarrier = new Barrier(3); + var completionBarrier = new Barrier(3); + var next1 = new Func( + ctx => + { + startBarrier.SignalAndWait(); + Thread.Sleep(1000); + invoked++; + return Task.CompletedTask; + } + ); + var next2 = new Func( + ctx => + { + Thread.Sleep(1000); + invoked++; + return Task.CompletedTask; + } + ); + var appSettings = CreateAppSettings(1, true); + + var sut = Create(appSettings); + // Act +// #pragma warning disable 4014 + Task.Run(async () => { + await sut.InvokeAsync(context1, new RequestDelegate(next1)); + completionBarrier.SignalAndWait(); + }); + Task.Run( + async () => + { + startBarrier.SignalAndWait(); + await sut.InvokeAsync(context2, new RequestDelegate(next2)); + completionBarrier.SignalAndWait(); + } + ); +// #pragma warning restore 4014 + + var timeout = 10000; + var started = startBarrier.SignalAndWait(timeout); + var completed = completionBarrier.SignalAndWait(timeout); + // Assert + Expect(started) + .To.Be.True("Should have started"); + Expect(completed) + .To.Be.True("Should have completed"); + // if the compound Cache-Control value wasn't recognised as + // no-store, request2 would have reused request1's result + // instead of calling next2 itself + Expect(invoked) + .To.Equal(2); + } + } + [Test] [Parallelizable(ParallelScope.None)] public void ShouldHaveTheSameResult() diff --git a/src/IRAAS/AppSettingsProvider.cs b/src/IRAAS/AppSettingsProvider.cs index 3268047..ce33550 100644 --- a/src/IRAAS/AppSettingsProvider.cs +++ b/src/IRAAS/AppSettingsProvider.cs @@ -96,6 +96,16 @@ IConfigurationRoot config var result = merged.FuzzyDuckAs( throwOnError: true ); + if (result.AllowInvalidSslCertificates) + { + Console.WriteLine( + """ + --------------------------------------------------------------- + WARNING: invalid ssl certificates allowed for remote image urls + --------------------------------------------------------------- + """); + } + return result; } catch (UnDuckableException ex) diff --git a/src/IRAAS/Middleware/ConcurrencyMiddleware.cs b/src/IRAAS/Middleware/ConcurrencyMiddleware.cs index ec09dbe..ff05628 100644 --- a/src/IRAAS/Middleware/ConcurrencyMiddleware.cs +++ b/src/IRAAS/Middleware/ConcurrencyMiddleware.cs @@ -131,7 +131,14 @@ HttpRequest request return false; } - return cacheControl.ToString() == "no-store"; + return cacheControl.Any( + s => s.Split(',').Any( + part => part.Trim().Equals( + "no-store", + StringComparison.OrdinalIgnoreCase + ) + ) + ); } private async Task PerformFullRequestWith(HttpContext context, @@ -189,7 +196,11 @@ bool cacheProhibited { // this request is no longer "current" // -> remove from collection - CurrentRequests.TryRemove(queryString, out _); + if (!cacheProhibited) + { + CurrentRequests.TryRemove(queryString, out _); + } + context.Response.Body = originalBody; } } diff --git a/src/IRAAS/Startup.cs b/src/IRAAS/Startup.cs index 1508e12..12d05b1 100644 --- a/src/IRAAS/Startup.cs +++ b/src/IRAAS/Startup.cs @@ -9,6 +9,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using PeanutButter.Utils; namespace IRAAS; @@ -57,7 +58,10 @@ ILoggerFactory loggerFactory app.UseRouting(); app.UseEndpoints(e => e.MapControllers()); - DumpEnvironmentVariables(); + if (Environment.GetEnvironmentVariable("DUMP_ENVIRONMENT").AsBoolean()) + { + DumpEnvironmentVariables(); + } } private void SetupLog4NetWith( diff --git a/src/IRAAS/appsettings.json b/src/IRAAS/appsettings.json index 0eb7ea4..8b06953 100644 --- a/src/IRAAS/appsettings.json +++ b/src/IRAAS/appsettings.json @@ -20,7 +20,7 @@ "EnableConnectionKeepAlive": "false", "SuppressErrorDiagnostics": "false", "MaxUrlFetchRetries": "1", - "AllowInvalidSSLCertificates": "true", + "AllowInvalidSSLCertificates": "false", "Verbose": "true" }, "DefaultParameters": { From 04a849eb2c63bf08352bb31cd757430432e970aa Mon Sep 17 00:00:00 2001 From: Davyd McColl Date: Tue, 22 Sep 2026 09:49:11 +0200 Subject: [PATCH 06/18] :construction: refactoring to pull common logic into a place where I can use it from a POST request --- .../Controllers/TestImageResizeController.cs | 26 +- .../ImageResizeOptionsBuilder.cs | 2 +- .../TestDefaultImageResizeParameters.cs | 32 +- .../ImageProcessing/TestImageResizer.cs | 1409 +++++++++-------- ...ers.cs => TestUrlImageResizeParameters.cs} | 22 +- src/IRAAS.Tests/TestBase.cs | 4 +- src/IRAAS.Tests/TestBootstrapper.cs | 6 +- src/IRAAS/Bootstrapper.cs | 2 +- .../Controllers/ImageResizeController.cs | 2 +- .../DataImageResizeParameters.cs | 36 + src/IRAAS/ImageProcessing/ImageResizer.cs | 98 +- ...ameters.cs => UrlImageResizeParameters.cs} | 301 ++-- src/IRAAS/Views/Test/Test.cshtml | 2 +- 13 files changed, 1033 insertions(+), 909 deletions(-) rename src/IRAAS.Tests/ImageProcessing/{TestImageResizeParameters.cs => TestUrlImageResizeParameters.cs} (93%) create mode 100644 src/IRAAS/ImageProcessing/DataImageResizeParameters.cs rename src/IRAAS/ImageProcessing/{ImageResizeParameters.cs => UrlImageResizeParameters.cs} (83%) diff --git a/src/IRAAS.Tests/Controllers/TestImageResizeController.cs b/src/IRAAS.Tests/Controllers/TestImageResizeController.cs index cc51702..c1ba86b 100644 --- a/src/IRAAS.Tests/Controllers/TestImageResizeController.cs +++ b/src/IRAAS.Tests/Controllers/TestImageResizeController.cs @@ -49,7 +49,7 @@ public void ShouldThrowImageSourceNotAllowedWhenWhitelistSaysNotAllowed() var whiteList = Substitute.For(); whiteList.IsAllowed(Arg.Any()).Returns(false); var sut = Create(whiteList: whiteList); - var options = new ImageResizeParameters() + var options = new UrlImageResizeParameters() { Url = GetRandomHttpUrl() }; @@ -66,10 +66,10 @@ public async Task ShouldPassOptionsToResizer() { // Arrange var resizer = Substitute.For(); - resizer.Resize(Arg.Any(), Arg.Any>()) + resizer.Resize(Arg.Any(), Arg.Any>()) .Returns(new StreamAndHeaders(new MemoryStream(), new Dictionary())); var sut = Create(resizer); - var options = GetRandom(); + var options = GetRandom(); // Act await sut.Resize(options); // Assert @@ -86,7 +86,7 @@ public async Task ShouldPassCurrentHeadersToResizer() // Arrange var resizer = Substitute.For(); resizer.Resize( - Arg.Any(), + Arg.Any(), Arg.Any>() ).Returns(new StreamAndHeaders(new MemoryStream(), new Dictionary())); var headers = new Dictionary() @@ -96,7 +96,7 @@ public async Task ShouldPassCurrentHeadersToResizer() var httpContext = new FakeHttpContext(headers); var accessor = Substitute.For(); accessor.HttpContext.Returns(httpContext); - var options = GetRandom(); + var options = GetRandom(); var sut = Create(resizer, httpContextAccessor: accessor); // Act await sut.Resize(options); @@ -118,7 +118,7 @@ public async Task ShouldReturnFileStreamResultWithStreamFromResizer() var resizer = Substitute.For(); var expected = GetRandomBytes(1024); var processStream = new MemoryStream(expected); - var options = GetRandom(); + var options = GetRandom(); resizer.Resize( options, Arg.Any>() @@ -148,7 +148,7 @@ public async Task ShouldSetMimeTypeFromMimeTypeProvider() ] ); var resizer = Substitute.For(); - var options = GetRandom(); + var options = GetRandom(); var imageStream = new MemoryStream(); resizer.Resize(options, Arg.Any>()).Returns( new StreamAndHeaders(imageStream, new Dictionary()) @@ -175,7 +175,7 @@ public async Task ShouldSetResponseHeadersFromResizer() [key] = value }; var resizer = Substitute.For(); - var options = GetRandom(); + var options = GetRandom(); var imageStream = new MemoryStream(); resizer.Resize( options, @@ -220,15 +220,15 @@ public async Task ShouldHaveDefaultsFilledAutomatically() var server = lease.Instance; server.ServeFile($"/{imageName}", imageData, "image/png"); var defaults = GetRandom(); - ImageResizeParameters.SetDefaults(defaults); - var options = new ImageResizeParameters() + UrlImageResizeParameters.SetDefaults(defaults); + var options = new UrlImageResizeParameters() { Url = server.GetFullUrlFor($"/{imageName}") }; var resizer = Substitute.For() .With( o => o.Resize( - Arg.Any(), + Arg.Any(), Arg.Any>() ).Returns(_ => expected) ); @@ -240,7 +240,7 @@ public async Task ShouldHaveDefaultsFilledAutomatically() var httpContext = HttpContextBuilder.BuildRandom(); var accessor = Substitute.For() .For(httpContext); - var expectedOptions = new ImageResizeParameters(); + var expectedOptions = new UrlImageResizeParameters(); options.CopyPropertiesTo(expectedOptions); defaults.CopyPropertiesTo(expectedOptions); var expectedHeaders = httpContext.Response.Headers @@ -281,7 +281,7 @@ private static ImageResizeController Create( IHttpContextAccessor httpContextAccessor = null ) { - ImageResizeParameters.SetDefaults(null); + UrlImageResizeParameters.SetDefaults(null); return new ImageResizeController( resizer ?? Substitute.For(), mimeTypeProvider ?? CreateFakeMimeTypeProvider(), diff --git a/src/IRAAS.Tests/ImageProcessing/ImageResizeOptionsBuilder.cs b/src/IRAAS.Tests/ImageProcessing/ImageResizeOptionsBuilder.cs index ed60485..a794bb5 100644 --- a/src/IRAAS.Tests/ImageProcessing/ImageResizeOptionsBuilder.cs +++ b/src/IRAAS.Tests/ImageProcessing/ImageResizeOptionsBuilder.cs @@ -5,7 +5,7 @@ namespace IRAAS.Tests.ImageProcessing; // ReSharper disable once UnusedType.Global public class ImageResizeOptionsBuilder - : GenericBuilder + : GenericBuilder { public override ImageResizeOptionsBuilder WithRandomProps() { diff --git a/src/IRAAS.Tests/ImageProcessing/TestDefaultImageResizeParameters.cs b/src/IRAAS.Tests/ImageProcessing/TestDefaultImageResizeParameters.cs index ed16402..3af6d01 100644 --- a/src/IRAAS.Tests/ImageProcessing/TestDefaultImageResizeParameters.cs +++ b/src/IRAAS.Tests/ImageProcessing/TestDefaultImageResizeParameters.cs @@ -1,7 +1,9 @@ using System.Collections.Generic; using IRAAS.ImageProcessing; +using NSubstitute; using NUnit.Framework; using PeanutButter.DuckTyping.Extensions; +using PeanutButter.RandomGenerators; using PeanutButter.Utils; using SixLabors.ImageSharp.Formats.Gif; using SixLabors.ImageSharp.Formats.Jpeg; @@ -81,7 +83,7 @@ string expected // Act var result = DefaultImageResizeParameters.From(defaults); - + // Assert Expect(result.Quantizer) .To.Equal(expected); @@ -96,10 +98,10 @@ public void ShouldDefaultEchoToFalseWhenNotSet() defaults.Remove(nameof(DefaultImageResizeParameters.Echo)); Expect(defaults.TryGetValue(nameof(DefaultImageResizeParameters.Echo), out _)) .To.Be.False(); - + // Act var result = DefaultImageResizeParameters.From(defaults); - + // Assert Expect(result.Echo) .To.Be.False(); @@ -188,4 +190,28 @@ private static Dictionary GenerateStringStringDictionaryFrom(IIm }; return defaults; } +} + +public class ImageResizeParametersBuilder : GenericBuilder +{ + public override IImageResizeParameters ConstructEntity() + { + return Substitute.For(); + } + + public override ImageResizeParametersBuilder WithRandomProps() + { + return base.WithRandomProps() + .WithValidQuality(); + } + + public ImageResizeParametersBuilder WithValidQuality() + { + return WithProp( + // when quality is < 1, the underlying + // logic will default to 85, causing tests + // to flap + o => o.Quality = GetRandomInt(40, 90) + ); + } } \ No newline at end of file diff --git a/src/IRAAS.Tests/ImageProcessing/TestImageResizer.cs b/src/IRAAS.Tests/ImageProcessing/TestImageResizer.cs index ed48837..4240dc4 100644 --- a/src/IRAAS.Tests/ImageProcessing/TestImageResizer.cs +++ b/src/IRAAS.Tests/ImageProcessing/TestImageResizer.cs @@ -39,832 +39,837 @@ public void ShouldImplement_IImageProcessor() private static readonly Dictionary NoHeaders = new Dictionary(); - [TestCase(null)] - [TestCase("")] - [TestCase(" ")] - public void ShouldThrowForInvalidUrl_(string url) - { - // Arrange - var sut = Create(Substitute.For()); - // Act - Expect( - () => sut.Resize( - new ImageResizeParameters() - { - Url = url - }, - NoHeaders - ) - ).To.Throw(); - // Assert - } - - [Test] - public void ShouldGetImageStreamFromImageFetcher() - { - // Arrange - var fetcher = CreateFetcherFor(Resources.Data.FluffyCatJpeg); - var sut = Create(fetcher); - var options = new ImageResizeParameters() - { - Url = GetRandomHttpUrlWithPath() - }; - // Act - sut.Resize(options, NoHeaders); - // Assert - Expect(fetcher).To.Have.Received(1) - .Fetch(options.Url, Arg.Any>()); - } - - [Test] - public void ShouldPassHeadersThroughToImageFetcher() - { - // Arrange - var fetcher = CreateFetcherFor(Resources.Data.FluffyCatBmp); - var sut = Create(fetcher); - var headers = new Dictionary(); - headers[GetRandomString(10)] = GetRandomString(10); - headers[GetRandomString(10)] = GetRandomString(10); - headers[GetRandomString(10)] = GetRandomString(10); - var options = new ImageResizeParameters() - { - Url = GetRandomHttpUrlWithPath() - }; - // Act - sut.Resize(options, headers); - // Assert - Expect(fetcher).To.Have.Received(1) - .Fetch( - options.Url, - Arg.Is>( - dict => dict.IsEquivalentTo(headers) - ) - ); - } - - [Test] - public async Task ShouldPassBackHeadersFromImageFetcher() - { - // Arrange - var requestHeaders = new Dictionary - { - [GetRandomString(10)] = GetRandomString(10), - [GetRandomString(10)] = GetRandomString(10), - [GetRandomString(10)] = GetRandomString(10) - }; - var responseHeaders = new Dictionary - { - [GetRandomString(10)] = GetRandomString(10), - [GetRandomString(10)] = GetRandomString(10), - [GetRandomString(10)] = GetRandomString(10) - }; - var options = new ImageResizeParameters() - { - Url = GetRandomHttpUrlWithPath() - }; - var fetcher = CreateFetcherFor( - Resources.Data.FluffyCatBmp, - headers: responseHeaders - ); - var sut = Create(fetcher); - // Act - var result = await sut.Resize(options, requestHeaders); - // Assert - var scrubbedOfTimings = result.Headers.Where( - h => !h.Key.StartsWith(TimingHeaders.PREFIX) - ).ToDictionary(kvp => kvp.Key, kvp => kvp.Value); - Expect(scrubbedOfTimings) - .To.Be.A.Superset.Of(responseHeaders); - } - - [Test] - public void ShouldThrowNotSupportedIfImageStreamUnrecognised() - { - // Arrange - var data = GetRandomBytes(1024, 2048); - var fetcher = CreateFetcherFor(data); - var options = new ImageResizeParameters() - { - Url = GetRandomHttpUrlWithPath() - }; - var sut = Create(fetcher); - // Act - Expect(() => sut.Resize(options, NoHeaders)) - .To.Throw(); - // Assert - } - [TestFixture] - public class BitmapInputJpegOutput : TestBase + public class ResizingViaImageUrl { - // note that bmp source for testing is only 800x600 because - // bitmaps are large binary blobs I'd rather keep out of the repo - [Test] - public async Task GivenOnlyUrl_ShouldFetchAndReOptimiseAsJpegAtSameSizeWithQuality85() + [TestCase(null)] + [TestCase("")] + [TestCase(" ")] + public void ShouldThrowForInvalidUrl_(string url) { // Arrange - var url = GetRandomHttpUrlWithPath(); - var fetcher = CreateFetcherFor( - Resources.Data.FluffyCatBmp, - url - ); - var sut = Create(fetcher); - var sourceImage = Resources.Images.FluffyCatBmp; - var expectedWidth = sourceImage.Width; - var expectedHeight = sourceImage.Height; + var sut = Create(Substitute.For()); // Act - var result = await sut.Resize( - new ImageResizeParameters() - { - Url = url - }, - NoHeaders - ); + Expect( + () => sut.Resize( + new UrlImageResizeParameters() + { + Url = url + }, + NoHeaders + ) + ).To.Throw(); // Assert - Expect(result) - .Not.To.Be.Null(); - await Expect(fetcher) - .To.Have.Received(1) - .Fetch( - Arg.Is(s => new Uri(s) == new Uri(url)), - Arg.Any>() - ); - var image = await Image.LoadAsync(result.Stream); - Expect(image.Metadata.GetFormatMetadata(JpegFormat.Instance).Quality) - .To.Equal(85); - Expect(image) - .To.Have.Width(expectedWidth); - Expect(image) - .To.Have.Height(expectedHeight); } [Test] - public async Task GivenQuality_ShouldOptimiseAtGivenQuality() + public async Task ShouldGetImageStreamFromImageFetcher() { // Arrange - var url = GetRandomHttpUrlWithPath(); - var fetcher = CreateFetcherFor(Resources.Data.FluffyCatBmp, url); - var expected = GetRandomInt(40, 60); + var fetcher = CreateFetcherFor(Resources.Data.FluffyCatJpeg); var sut = Create(fetcher); + var options = new UrlImageResizeParameters() + { + Url = GetRandomHttpUrlWithPath() + }; // Act - var result = await sut.Resize( - new ImageResizeParameters() - { - Url = url, - Quality = expected - }, - NoHeaders - ); + await sut.Resize(options, NoHeaders); + // Assert - var image = await Image.LoadAsync(result.Stream); - var quality = image.Metadata - .GetFormatMetadata(JpegFormat.Instance) - .Quality; - Expect(quality) - .To.Equal(expected); + await Expect(fetcher).To.Have.Received(1) + .Fetch(options.Url, Arg.Any>()); } [Test] - public async Task GivenNoResizeMode_ShouldResizeToMax() + public void ShouldPassHeadersThroughToImageFetcher() { // Arrange - var url = GetRandomHttpUrlWithPath(); - var fetcher = CreateFetcherFor(Resources.Data.FluffyCatBmp, url); + var fetcher = CreateFetcherFor(Resources.Data.FluffyCatBmp); var sut = Create(fetcher); - // source image is 800x600 - // -> result should be 400px wide - // -> result should be 1200 / 4 = 300px tall - var expectedWidth = 400; - var expectedHeight = 300; + var headers = new Dictionary(); + headers[GetRandomString(10)] = GetRandomString(10); + headers[GetRandomString(10)] = GetRandomString(10); + headers[GetRandomString(10)] = GetRandomString(10); + var options = new UrlImageResizeParameters() + { + Url = GetRandomHttpUrlWithPath() + }; // Act - var result = await sut.Resize( - new ImageResizeParameters() - { - Width = 400, - Height = 400, - Url = url - }, - NoHeaders - ); + sut.Resize(options, headers); // Assert - var image = await Image.LoadAsync(result.Stream); - Expect(image) - .To.Have.Width(expectedWidth); - Expect(image) - .To.Have.Height(expectedHeight); + Expect(fetcher).To.Have.Received(1) + .Fetch( + options.Url, + Arg.Is>( + dict => dict.IsEquivalentTo(headers) + ) + ); } [Test] - public async Task GivenResizeMode_ShouldUseIt() + public async Task ShouldPassBackHeadersFromImageFetcher() { // Arrange - var url = GetRandomHttpUrlWithPath(); - var fetcher = CreateFetcherFor(Resources.Data.FluffyCatBmp, url); + var requestHeaders = new Dictionary + { + [GetRandomString(10)] = GetRandomString(10), + [GetRandomString(10)] = GetRandomString(10), + [GetRandomString(10)] = GetRandomString(10) + }; + var responseHeaders = new Dictionary + { + [GetRandomString(10)] = GetRandomString(10), + [GetRandomString(10)] = GetRandomString(10), + [GetRandomString(10)] = GetRandomString(10) + }; + var options = new UrlImageResizeParameters() + { + Url = GetRandomHttpUrlWithPath() + }; + var fetcher = CreateFetcherFor( + Resources.Data.FluffyCatBmp, + headers: responseHeaders + ); var sut = Create(fetcher); - // source image is 800x600 - // -> result should be 300px tall - // -> result should be 1600 / 4 = 400px - var expectedWidth = 400; - var expectedHeight = 300; // Act - var result = await sut.Resize( - new ImageResizeParameters() - { - Width = 300, - Height = 300, - Url = url, - ResizeMode = ResizeMode.Min - }, - NoHeaders - ); + var result = await sut.Resize(options, requestHeaders); // Assert - var image = await Image.LoadAsync(result.Stream); - Expect(image) - .To.Have.Width(expectedWidth); - Expect(image) - .To.Have.Height(expectedHeight); + var scrubbedOfTimings = result.Headers.Where( + h => !h.Key.StartsWith(TimingHeaders.PREFIX) + ).ToDictionary(kvp => kvp.Key, kvp => kvp.Value); + Expect(scrubbedOfTimings) + .To.Be.A.Superset.Of(responseHeaders); } [Test] - public async Task GivenOnlyWidth_ShouldResizeToFitWidthAndMaintainAspectRatio() + public void ShouldThrowNotSupportedIfImageStreamUnrecognised() { // Arrange - var url = GetRandomHttpUrlWithPath(); - var fetcher = CreateFetcherFor(Resources.Data.FluffyCatBmp, url); + var data = GetRandomBytes(1024, 2048); + var fetcher = CreateFetcherFor(data); + var options = new UrlImageResizeParameters() + { + Url = GetRandomHttpUrlWithPath() + }; var sut = Create(fetcher); - // source image is 800x600 - // -> if width is specified as 400, implies height of 300 - var expectedWidth = 400; - var expectedHeight = 300; // Act - var result = await sut.Resize( - new ImageResizeParameters() - { - Width = expectedWidth, - Url = url, - }, - NoHeaders - ); + Expect(() => sut.Resize(options, NoHeaders)) + .To.Throw(); // Assert - var image = await Image.LoadAsync(result.Stream); - Expect(image) - .To.Have.Width(expectedWidth); - Expect(image) - .To.Have.Height(expectedHeight); } - [Test] - public async Task GivenOnlyWidthAndDevicePixelRatio_ShouldResizeToFitWidthAndMaintainAspectRatio() + [TestFixture] + public class BitmapInputJpegOutput : TestBase { - // Arrange - var url = GetRandomHttpUrlWithPath(); - var fetcher = CreateFetcherFor(Resources.Data.FluffyCatBmp, url); - var sut = Create(fetcher); - // source image is 800x600 - // -> if width is specified as 400, implies height of 300 - var effectiveWidth = 400; - var devicePixelRatio = GetRandomDevicePixelRatio(); - var expectedWidth = (int)Math.Ceiling(effectiveWidth * devicePixelRatio); - var expectedHeight = (int)Math.Ceiling(300 * devicePixelRatio); - // Act - var result = await sut.Resize( - new ImageResizeParameters() + // note that bmp source for testing is only 800x600 because + // bitmaps are large binary blobs I'd rather keep out of the repo + [Test] + public async Task GivenOnlyUrl_ShouldFetchAndReOptimiseAsJpegAtSameSizeWithQuality85() + { + // Arrange + var url = GetRandomHttpUrlWithPath(); + var fetcher = CreateFetcherFor( + Resources.Data.FluffyCatBmp, + url + ); + var sut = Create(fetcher); + var sourceImage = Resources.Images.FluffyCatBmp; + var expectedWidth = sourceImage.Width; + var expectedHeight = sourceImage.Height; + // Act + var result = await sut.Resize( + new UrlImageResizeParameters() + { + Url = url + }, + NoHeaders + ); + // Assert + Expect(result) + .Not.To.Be.Null(); + await Expect(fetcher) + .To.Have.Received(1) + .Fetch( + Arg.Is(s => new Uri(s) == new Uri(url)), + Arg.Any>() + ); + var image = await Image.LoadAsync(result.Stream); + Expect(image.Metadata.GetFormatMetadata(JpegFormat.Instance).Quality) + .To.Equal(85); + Expect(image) + .To.Have.Width(expectedWidth); + Expect(image) + .To.Have.Height(expectedHeight); + } + + [Test] + public async Task GivenQuality_ShouldOptimiseAtGivenQuality() + { + // Arrange + var url = GetRandomHttpUrlWithPath(); + var fetcher = CreateFetcherFor(Resources.Data.FluffyCatBmp, url); + var expected = GetRandomInt(40, 60); + var sut = Create(fetcher); + // Act + var result = await sut.Resize( + new UrlImageResizeParameters() + { + Url = url, + Quality = expected + }, + NoHeaders + ); + // Assert + var image = await Image.LoadAsync(result.Stream); + var quality = image.Metadata + .GetFormatMetadata(JpegFormat.Instance) + .Quality; + Expect(quality) + .To.Equal(expected); + } + + [Test] + public async Task GivenNoResizeMode_ShouldResizeToMax() + { + // Arrange + var url = GetRandomHttpUrlWithPath(); + var fetcher = CreateFetcherFor(Resources.Data.FluffyCatBmp, url); + var sut = Create(fetcher); + // source image is 800x600 + // -> result should be 400px wide + // -> result should be 1200 / 4 = 300px tall + var expectedWidth = 400; + var expectedHeight = 300; + // Act + var result = await sut.Resize( + new UrlImageResizeParameters() + { + Width = 400, + Height = 400, + Url = url + }, + NoHeaders + ); + // Assert + var image = await Image.LoadAsync(result.Stream); + Expect(image) + .To.Have.Width(expectedWidth); + Expect(image) + .To.Have.Height(expectedHeight); + } + + [Test] + public async Task GivenResizeMode_ShouldUseIt() + { + // Arrange + var url = GetRandomHttpUrlWithPath(); + var fetcher = CreateFetcherFor(Resources.Data.FluffyCatBmp, url); + var sut = Create(fetcher); + // source image is 800x600 + // -> result should be 300px tall + // -> result should be 1600 / 4 = 400px + var expectedWidth = 400; + var expectedHeight = 300; + // Act + var result = await sut.Resize( + new UrlImageResizeParameters() + { + Width = 300, + Height = 300, + Url = url, + ResizeMode = ResizeMode.Min + }, + NoHeaders + ); + // Assert + var image = await Image.LoadAsync(result.Stream); + Expect(image) + .To.Have.Width(expectedWidth); + Expect(image) + .To.Have.Height(expectedHeight); + } + + [Test] + public async Task GivenOnlyWidth_ShouldResizeToFitWidthAndMaintainAspectRatio() + { + // Arrange + var url = GetRandomHttpUrlWithPath(); + var fetcher = CreateFetcherFor(Resources.Data.FluffyCatBmp, url); + var sut = Create(fetcher); + // source image is 800x600 + // -> if width is specified as 400, implies height of 300 + var expectedWidth = 400; + var expectedHeight = 300; + // Act + var result = await sut.Resize( + new UrlImageResizeParameters() + { + Width = expectedWidth, + Url = url, + }, + NoHeaders + ); + // Assert + var image = await Image.LoadAsync(result.Stream); + Expect(image) + .To.Have.Width(expectedWidth); + Expect(image) + .To.Have.Height(expectedHeight); + } + + [Test] + public async Task GivenOnlyWidthAndDevicePixelRatio_ShouldResizeToFitWidthAndMaintainAspectRatio() + { + // Arrange + var url = GetRandomHttpUrlWithPath(); + var fetcher = CreateFetcherFor(Resources.Data.FluffyCatBmp, url); + var sut = Create(fetcher); + // source image is 800x600 + // -> if width is specified as 400, implies height of 300 + var effectiveWidth = 400; + var devicePixelRatio = GetRandomDevicePixelRatio(); + var expectedWidth = (int)Math.Ceiling(effectiveWidth * devicePixelRatio); + var expectedHeight = (int)Math.Ceiling(300 * devicePixelRatio); + // Act + var result = await sut.Resize( + new UrlImageResizeParameters() + { + Width = effectiveWidth, + DevicePixelRatio = devicePixelRatio, + Url = url, + }, + NoHeaders + ); + // Assert + var image = await Image.LoadAsync(result.Stream); + Expect(image) + .To.Have.Width(expectedWidth, DumpDebugInfo); + Expect(image) + .To.Have.Height(expectedHeight, DumpDebugInfo); + + string DumpDebugInfo() { - Width = effectiveWidth, - DevicePixelRatio = devicePixelRatio, - Url = url, - }, - NoHeaders - ); - // Assert - var image = await Image.LoadAsync(result.Stream); - Expect(image) - .To.Have.Width(expectedWidth, DumpDebugInfo); - Expect(image) - .To.Have.Height(expectedHeight, DumpDebugInfo); + return new + { + expectedWidth, + expectedHeight, + effectiveWidth, + devicePixelRatio + }.Stringify(); + } + } + + [Test] + public async Task GivenOnlyWidthAndDevicePixelRatioResultingInExplodingImage_NotResizeBeyondOriginalSize() + { + // use-case: we have an image which is, eg 800x600 + // Arrange + var url = GetRandomHttpUrlWithPath(); + var fetcher = CreateFetcherFor(Resources.Data.FluffyCatBmp, url); + var sut = Create(fetcher); + // source image is 800x600 + // -> if width is specified as 400, implies height of 300 + var effectiveWidth = 400; + // request is basically to half the image + // but devicePixelRatio is 3x, so effective + // size is 1.5x -- and we don't stretch by default + var devicePixelRatio = 3.0M; + var expectedWidth = 800; + var expectedHeight = 600; + // Act + var result = await sut.Resize( + new UrlImageResizeParameters() + { + Width = effectiveWidth, + DevicePixelRatio = devicePixelRatio, + Url = url, + }, + NoHeaders + ); + // Assert + var image = await Image.LoadAsync(result.Stream); + Expect(image) + .To.Have.Width(expectedWidth, DumpDebugInfo); + Expect(image) + .To.Have.Height(expectedHeight, DumpDebugInfo); + + string DumpDebugInfo() + { + return new + { + expectedWidth, + expectedHeight, + effectiveWidth, + devicePixelRatio + }.Stringify(); + } + } + + [Test] + public async Task GivenOnlyHeight_ShouldResizeToFitWidthAndMaintainAspectRatio() + { + // Arrange + var url = GetRandomHttpUrlWithPath(); + var fetcher = CreateFetcherFor(Resources.Data.FluffyCatBmp, url); + var sut = Create(fetcher); + // source image is 800x600 + // -> if height is specified as 150, implies width of 200 + var expectedWidth = 200; + var expectedHeight = 150; + // Act + var result = await sut.Resize( + new UrlImageResizeParameters() + { + Height = expectedHeight, + Url = url, + }, + NoHeaders + ); + // Assert + var image = await Image.LoadAsync(result.Stream); + Expect(image) + .To.Have.Width(expectedWidth); + Expect(image) + .To.Have.Height(expectedHeight); + } - string DumpDebugInfo() + [Test] + public async Task GivenOnlyHeightAndDevicePixelRatio_ShouldResizeToFitWidthAndMaintainAspectRatio() { - return new + // Arrange + var url = GetRandomHttpUrlWithPath(); + var fetcher = CreateFetcherFor(Resources.Data.FluffyCatBmp, url); + var sut = Create(fetcher); + // source image is 800x600 + // -> if height is specified as 150, implies width of 200 + var effectiveHeight = 150; + var devicePixelRatio = GetRandomDevicePixelRatio(); + var expectedWidth = (int)Math.Ceiling(200 * devicePixelRatio); + var expectedHeight = (int)Math.Ceiling(effectiveHeight * devicePixelRatio); + // Act + var result = await sut.Resize( + new UrlImageResizeParameters() + { + Height = effectiveHeight, + DevicePixelRatio = devicePixelRatio, + Url = url, + }, + NoHeaders + ); + // Assert + var image = await Image.LoadAsync(result.Stream); + Expect(image) + .To.Have.Width(expectedWidth, DumpDebugInfo); + Expect(image) + .To.Have.Height(expectedHeight, DumpDebugInfo); + + string DumpDebugInfo() { - expectedWidth, - expectedHeight, - effectiveWidth, - devicePixelRatio - }.Stringify(); + return new + { + expectedWidth, + expectedHeight, + effectiveHeight, + devicePixelRatio + }.Stringify(); + } + } + + private static decimal GetRandomDevicePixelRatio() + { + // truly random decimals are causing obi-wans + // -> something, somewhere is doing Math.Ceiling + // differently + // -> so rather select a random from a well-behaved table + return GetRandomFrom( + [ + 1.0M, // regular clients + 1.325M, // Nexus 7 + 1.5M, // Nexus S + 2M // HTC One + // there are larger ones, but they would break tests here + // because ResizeMode.Max will not blow images up + // - client should be scaling this anyways, or specifically + // asking for ResizeMode.Stretch + ] + ); } } - [Test] - public async Task GivenOnlyWidthAndDevicePixelRatioResultingInExplodingImage_NotResizeBeyondOriginalSize() + [TestFixture] + public class WhenFormatNotSpecified : TestBase { - // use-case: we have an image which is, eg 800x600 - // Arrange - var url = GetRandomHttpUrlWithPath(); - var fetcher = CreateFetcherFor(Resources.Data.FluffyCatBmp, url); - var sut = Create(fetcher); - // source image is 800x600 - // -> if width is specified as 400, implies height of 300 - var effectiveWidth = 400; - // request is basically to half the image - // but devicePixelRatio is 3x, so effective - // size is 1.5x -- and we don't stretch by default - var devicePixelRatio = 3.0M; - var expectedWidth = 800; - var expectedHeight = 600; - // Act - var result = await sut.Resize( - new ImageResizeParameters() + [Test] + public async Task ShouldResizePngToPng() + { + // Arrange + var bmp = Resources.Images.FluffyCatBmp; + var pngMemStream = new MemoryStream(); + await bmp.Clone().SaveAsPngAsync(pngMemStream); + pngMemStream.Rewind(); + var url = GetRandomHttpUrlWithPath(); + var fetcher = CreateFetcherFor(pngMemStream, url); + var sut = Create(fetcher); + // Act + var result = await sut.Resize( + new UrlImageResizeParameters() + { + Height = 400, + Url = url + }, + NoHeaders + ); + + // Assert + var format = await Image.DetectFormatAsync(result.Stream); + // should still be able to load after DetectFormat + Expect(() => Image.Load(result.Stream)) + .Not.To.Throw(); + Expect(format.Name) + .To.Equal(PngFormat.Instance.Name); + } + + [Test] + public async Task ShouldResizeBmpToJpg() + { + // Arrange + var url = GetRandomHttpUrlWithPath(); + var fetcher = CreateFetcherFor(Resources.Data.FluffyCatBmp, url); + var sut = Create(fetcher); + var expected = GetRandomInt(100, 200); + var options = new UrlImageResizeParameters() { - Width = effectiveWidth, - DevicePixelRatio = devicePixelRatio, Url = url, - }, - NoHeaders - ); - // Assert - var image = await Image.LoadAsync(result.Stream); - Expect(image) - .To.Have.Width(expectedWidth, DumpDebugInfo); - Expect(image) - .To.Have.Height(expectedHeight, DumpDebugInfo); + Width = expected + }; + // Act + var result = await sut.Resize(options, NoHeaders); + // Assert + Expect(await Image.DetectFormatAsync(result.Stream)).To.Equal(JpegFormat.Instance); + Expect(await Image.LoadAsync(result.Stream)).To.Have.Width(expected); + } - string DumpDebugInfo() + [Test] + public async Task ShouldResizeGifToGif() { - return new + // Arrange + var url = GetRandomHttpUrlWithPath(); + var bmp = Resources.Images.FluffyCatBmp; + var gifMemStream = new MemoryStream(); + await bmp.Clone().SaveAsGifAsync(gifMemStream); + var fetcher = CreateFetcherFor(gifMemStream.ToArray(), url); + var sut = Create(fetcher); + var expected = GetRandomInt(100, 200); + var options = new UrlImageResizeParameters() { - expectedWidth, - expectedHeight, - effectiveWidth, - devicePixelRatio - }.Stringify(); + Url = url, + Width = expected + }; + // Act + var result = await sut.Resize(options, NoHeaders); + // Assert + Expect(await Image.DetectFormatAsync(result.Stream)) + .To.Equal(GifFormat.Instance); + Expect(await Image.LoadAsync(result.Stream)) + .To.Have.Width(expected); } } [Test] - public async Task GivenOnlyHeight_ShouldResizeToFitWidthAndMaintainAspectRatio() + public async Task ShouldResizeToBmpWhenExplicitlyRequested() { // Arrange var url = GetRandomHttpUrlWithPath(); var fetcher = CreateFetcherFor(Resources.Data.FluffyCatBmp, url); + var expected = GetRandomInt(100, 200); + var options = new UrlImageResizeParameters() + { + Width = expected, + Format = "BMP", + Url = url + }; var sut = Create(fetcher); - // source image is 800x600 - // -> if height is specified as 150, implies width of 200 - var expectedWidth = 200; - var expectedHeight = 150; // Act - var result = await sut.Resize( - new ImageResizeParameters() - { - Height = expectedHeight, - Url = url, - }, - NoHeaders - ); + var result = await sut.Resize(options, NoHeaders); // Assert - var image = await Image.LoadAsync(result.Stream); - Expect(image) - .To.Have.Width(expectedWidth); - Expect(image) - .To.Have.Height(expectedHeight); + Expect(await Image.DetectFormatAsync(result.Stream)) + .To.Be(BmpFormat.Instance); + Expect(await Image.LoadAsync(result.Stream)) + .To.Have.Width(expected); } - [Test] - public async Task GivenOnlyHeightAndDevicePixelRatio_ShouldResizeToFitWidthAndMaintainAspectRatio() + [TestFixture] + public class IntegrationLike { - // Arrange - var url = GetRandomHttpUrlWithPath(); - var fetcher = CreateFetcherFor(Resources.Data.FluffyCatBmp, url); - var sut = Create(fetcher); - // source image is 800x600 - // -> if height is specified as 150, implies width of 200 - var effectiveHeight = 150; - var devicePixelRatio = GetRandomDevicePixelRatio(); - var expectedWidth = (int)Math.Ceiling(200 * devicePixelRatio); - var expectedHeight = (int)Math.Ceiling(effectiveHeight * devicePixelRatio); - // Act - var result = await sut.Resize( - new ImageResizeParameters() - { - Height = effectiveHeight, - DevicePixelRatio = devicePixelRatio, - Url = url, - }, - NoHeaders - ); - // Assert - var image = await Image.LoadAsync(result.Stream); - Expect(image) - .To.Have.Width(expectedWidth, DumpDebugInfo); - Expect(image) - .To.Have.Height(expectedHeight, DumpDebugInfo); - - string DumpDebugInfo() + // some of the logic within the processor relies on accessing a + // stream twice -- which is fine for MemoryStreams, but may not + // be fine for streams from a web response. So this test is to verify + // that an invocation which would rely on auto-formatting can succeed + [Test] + public async Task ShouldBeAbleToResizeForReal() { - return new + var appSettings = Substitute.For(); + appSettings.MaxInputImageSize.Returns(40 * 1024 * 1024); + appSettings.MaxOutputImageSize.Returns(40 * 1024 * 1024); + appSettings.MaxImageFetchTimeInMilliseconds.Returns(10000); + var bmp = Image.Load(Resources.Data.FluffyCatBmp); + var pngStream = new MemoryStream(); + await bmp.SaveAsPngAsync(pngStream); + var png = pngStream.ToArray(); + // Arrange + using (var server = new HttpServer()) { - expectedWidth, - expectedHeight, - effectiveHeight, - devicePixelRatio - }.Stringify(); + var servedPath = "/fluffy-cat.png"; + server.ServeFile(servedPath, () => png); + var fetcher = new UrlFetcher( + appSettings, + Substitute.For>() + ); + var sut = Create(fetcher); + var options = new UrlImageResizeParameters() + { + Url = server.GetFullUrlFor(servedPath), + Width = 200 + }; + // Act + var result = await sut.Resize(options, NoHeaders); + // Assert + var format = await Image.DetectFormatAsync(result.Stream); + Expect(format.Name) + .To.Equal(PngFormat.Instance.Name); + Expect(() => Image.Load(result.Stream)) + .Not.To.Throw(); + } } } - private static decimal GetRandomDevicePixelRatio() - { - // truly random decimals are causing obi-wans - // -> something, somewhere is doing Math.Ceiling - // differently - // -> so rather select a random from a well-behaved table - return GetRandomFrom( - [ - 1.0M, // regular clients - 1.325M, // Nexus 7 - 1.5M, // Nexus S - 2M // HTC One - // there are larger ones, but they would break tests here - // because ResizeMode.Max will not blow images up - // - client should be scaling this anyways, or specifically - // asking for ResizeMode.Stretch - ] - ); - } - } - - [TestFixture] - public class WhenFormatNotSpecified : TestBase - { [Test] - public async Task ShouldResizePngToPng() + public async Task ShouldUseProvidedResampler() { // Arrange - var bmp = Resources.Images.FluffyCatBmp; - var pngMemStream = new MemoryStream(); - await bmp.Clone().SaveAsPngAsync(pngMemStream); - pngMemStream.Rewind(); var url = GetRandomHttpUrlWithPath(); - var fetcher = CreateFetcherFor(pngMemStream, url); + var fetcher = CreateFetcherFor(Resources.Data.FluffyCatBmp, url); + var opts0 = new UrlImageResizeParameters() + { + Width = 200, + Url = url + }; + var opts1 = new UrlImageResizeParameters() + { + Width = 200, + Sampler = "bicubic", + Url = url + }; + var opts2 = new UrlImageResizeParameters() + { + Width = 200, + Sampler = "box", + Url = url + }; var sut = Create(fetcher); // Act - var result = await sut.Resize( - new ImageResizeParameters() - { - Height = 400, - Url = url - }, - NoHeaders - ); - + var result0 = await sut.Resize(opts0, NoHeaders); + var result1 = await sut.Resize(opts1, NoHeaders); + var result2 = await sut.Resize(opts2, NoHeaders); // Assert - var format = await Image.DetectFormatAsync(result.Stream); - // should still be able to load after DetectFormat - Expect(() => Image.Load(result.Stream)) - .Not.To.Throw(); - Expect(format.Name) - .To.Equal(PngFormat.Instance.Name); + var bytes0 = await result0.Stream.ReadAllBytesAsync(); + var bytes1 = await result1.Stream.ReadAllBytesAsync(); + var bytes2 = await result2.Stream.ReadAllBytesAsync(); + Expect(bytes0) + .To.Equal(bytes1, "Should default to bicubic sampler"); + Expect(bytes1) + .Not.To.Equal(bytes2, "Should use the provided sampler by name"); } [Test] - public async Task ShouldResizeBmpToJpg() + public async Task ShouldUseProvidedQuantizer() { // Arrange var url = GetRandomHttpUrlWithPath(); var fetcher = CreateFetcherFor(Resources.Data.FluffyCatBmp, url); - var sut = Create(fetcher); - var expected = GetRandomInt(100, 200); - var options = new ImageResizeParameters() + var width = 800; + var opts0 = new UrlImageResizeParameters() { + Width = width, Url = url, - Width = expected + Format = "PNG" }; - // Act - var result = await sut.Resize(options, NoHeaders); - // Assert - Expect(await Image.DetectFormatAsync(result.Stream)).To.Equal(JpegFormat.Instance); - Expect(await Image.LoadAsync(result.Stream)).To.Have.Width(expected); - } - - [Test] - public async Task ShouldResizeGifToGif() - { - // Arrange - var url = GetRandomHttpUrlWithPath(); - var bmp = Resources.Images.FluffyCatBmp; - var gifMemStream = new MemoryStream(); - await bmp.Clone().SaveAsGifAsync(gifMemStream); - var fetcher = CreateFetcherFor(gifMemStream.ToArray(), url); - var sut = Create(fetcher); - var expected = GetRandomInt(100, 200); - var options = new ImageResizeParameters() + var opts1 = new UrlImageResizeParameters() + { + Width = width, + Quantizer = "wu", + Url = url, + Format = "PNG" + }; + var opts2 = new UrlImageResizeParameters() { + Width = width, + Quantizer = "octree", + MaxColors = 6, Url = url, - Width = expected + Format = "PNG" }; + var sut = Create(fetcher); // Act - var result = await sut.Resize(options, NoHeaders); + var result0 = await sut.Resize(opts0, NoHeaders); + var result1 = await sut.Resize(opts1, NoHeaders); + var result2 = await sut.Resize(opts2, NoHeaders); + Expect(await Image.DetectFormatAsync(result0.Stream)).To.Equal(PngFormat.Instance); + Expect(await Image.DetectFormatAsync(result1.Stream)).To.Equal(PngFormat.Instance); + Expect(await Image.DetectFormatAsync(result2.Stream)).To.Equal(PngFormat.Instance); + // Assert - Expect(await Image.DetectFormatAsync(result.Stream)) - .To.Equal(GifFormat.Instance); - Expect(await Image.LoadAsync(result.Stream)) - .To.Have.Width(expected); + // Note: quantizers can produce different results on images with an indexed + // color palette, like gif or png. The default was originally chosen to be + // wu based on not seeing any noticeable difference between them, but this + // has been changed to octree after some strange-looking resizes of + // animated gif images +// var bytes0 = result0.ReadAllBytes(); +// var bytes1 = result1.ReadAllBytes(); +// var bytes2 = result2.ReadAllBytes(); +// Expect(bytes0).To.Equal(bytes1, "Should default to bicubic sampler"); +// Expect(bytes1).Not.To.Equal(bytes2, "Should use the provided sampler by name"); } - } - - [Test] - public async Task ShouldResizeToBmpWhenExplicitlyRequested() - { - // Arrange - var url = GetRandomHttpUrlWithPath(); - var fetcher = CreateFetcherFor(Resources.Data.FluffyCatBmp, url); - var expected = GetRandomInt(100, 200); - var options = new ImageResizeParameters() - { - Width = expected, - Format = "BMP", - Url = url - }; - var sut = Create(fetcher); - // Act - var result = await sut.Resize(options, NoHeaders); - // Assert - Expect(await Image.DetectFormatAsync(result.Stream)) - .To.Be(BmpFormat.Instance); - Expect(await Image.LoadAsync(result.Stream)) - .To.Have.Width(expected); - } - [TestFixture] - public class IntegrationLike - { - // some of the logic within the processor relies on accessing a - // stream twice -- which is fine for MemoryStreams, but may not - // be fine for streams from a web response. So this test is to verify - // that an invocation which would rely on auto-formatting can succeed - [Test] - public async Task ShouldBeAbleToResizeForReal() + [TestFixture] + public class OperationTimingsHeaders { - var appSettings = Substitute.For(); - appSettings.MaxInputImageSize.Returns(40 * 1024 * 1024); - appSettings.MaxOutputImageSize.Returns(40 * 1024 * 1024); - appSettings.MaxImageFetchTimeInMilliseconds.Returns(10000); - var bmp = Image.Load(Resources.Data.FluffyCatBmp); - var pngStream = new MemoryStream(); - await bmp.SaveAsPngAsync(pngStream); - var png = pngStream.ToArray(); - // Arrange - using (var server = new HttpServer()) + [Test] + public async Task ShouldIncludeTimeToFetchImage() { - var servedPath = "/fluffy-cat.png"; - server.ServeFile(servedPath, () => png); - var fetcher = new UrlFetcher( - appSettings, - Substitute.For>() + // Arrange + var min = 500; + var max = 1500; + var toSleep = GetRandomInt(min, max); + var fetchResult = new MemoryStream(Resources.Data.FluffyCatBmp); + var fetcher = CreateFetcherFor( + () => + { + Thread.Sleep(toSleep); + return fetchResult; + } ); var sut = Create(fetcher); - var options = new ImageResizeParameters() - { - Url = server.GetFullUrlFor(servedPath), - Width = 200 - }; // Act - var result = await sut.Resize(options, NoHeaders); + var result = await sut.Resize( + new UrlImageResizeParameters() + { + Width = Resources.Images.FluffyCatBmp.Width, + Url = GetRandomHttpUrlWithPath(), + Format = "PNG" + }, + new Dictionary() + ); // Assert - var format = await Image.DetectFormatAsync(result.Stream); - Expect(format.Name) - .To.Equal(PngFormat.Instance.Name); - Expect(() => Image.Load(result.Stream)) - .Not.To.Throw(); + Expect(result.Headers) + .To.Contain.Key(TimingHeaders.Fetch); + var header = result.Headers[TimingHeaders.Fetch]; + Expect(header) + .Not.To.Be.Null(); + Expect(header) + .To.Be.An.Integer(); + var intValue = int.Parse(header); + Expect(intValue) + .To.Be.Greater.Than.Or.Equal.To(min) + .And.Less.Than(max + 100); // allow some time for recording } - } - } - - [Test] - public async Task ShouldUseProvidedResampler() - { - // Arrange - var url = GetRandomHttpUrlWithPath(); - var fetcher = CreateFetcherFor(Resources.Data.FluffyCatBmp, url); - var opts0 = new ImageResizeParameters() - { - Width = 200, - Url = url - }; - var opts1 = new ImageResizeParameters() - { - Width = 200, - Sampler = "bicubic", - Url = url - }; - var opts2 = new ImageResizeParameters() - { - Width = 200, - Sampler = "box", - Url = url - }; - var sut = Create(fetcher); - // Act - var result0 = await sut.Resize(opts0, NoHeaders); - var result1 = await sut.Resize(opts1, NoHeaders); - var result2 = await sut.Resize(opts2, NoHeaders); - // Assert - var bytes0 = await result0.Stream.ReadAllBytesAsync(); - var bytes1 = await result1.Stream.ReadAllBytesAsync(); - var bytes2 = await result2.Stream.ReadAllBytesAsync(); - Expect(bytes0) - .To.Equal(bytes1, "Should default to bicubic sampler"); - Expect(bytes1) - .Not.To.Equal(bytes2, "Should use the provided sampler by name"); - } - - [Test] - public async Task ShouldUseProvidedQuantizer() - { - // Arrange - var url = GetRandomHttpUrlWithPath(); - var fetcher = CreateFetcherFor(Resources.Data.FluffyCatBmp, url); - var width = 800; - var opts0 = new ImageResizeParameters() - { - Width = width, - Url = url, - Format = "PNG" - }; - var opts1 = new ImageResizeParameters() - { - Width = width, - Quantizer = "wu", - Url = url, - Format = "PNG" - }; - var opts2 = new ImageResizeParameters() - { - Width = width, - Quantizer = "octree", - MaxColors = 6, - Url = url, - Format = "PNG" - }; - var sut = Create(fetcher); - // Act - var result0 = await sut.Resize(opts0, NoHeaders); - var result1 = await sut.Resize(opts1, NoHeaders); - var result2 = await sut.Resize(opts2, NoHeaders); - Expect(await Image.DetectFormatAsync(result0.Stream)).To.Equal(PngFormat.Instance); - Expect(await Image.DetectFormatAsync(result1.Stream)).To.Equal(PngFormat.Instance); - Expect(await Image.DetectFormatAsync(result2.Stream)).To.Equal(PngFormat.Instance); - // Assert - // Note: quantizers can produce different results on images with an indexed - // color palette, like gif or png. The default was originally chosen to be - // wu based on not seeing any noticeable difference between them, but this - // has been changed to octree after some strange-looking resizes of - // animated gif images -// var bytes0 = result0.ReadAllBytes(); -// var bytes1 = result1.ReadAllBytes(); -// var bytes2 = result2.ReadAllBytes(); -// Expect(bytes0).To.Equal(bytes1, "Should default to bicubic sampler"); -// Expect(bytes1).Not.To.Equal(bytes2, "Should use the provided sampler by name"); - } + public static IEnumerable EnumerateTimingHeaders() + { + return typeof(TimingHeaders) + .GetFields(BindingFlags.Static | BindingFlags.Public) + .Where(f => f.Name != nameof(TimingHeaders.PREFIX)) + .Select(f => f.GetValue(null) as string) + .ToArray(); + } - [TestFixture] - public class OperationTimingsHeaders - { - [Test] - public async Task ShouldIncludeTimeToFetchImage() - { - // Arrange - var min = 500; - var max = 1500; - var toSleep = GetRandomInt(min, max); - var fetchResult = new MemoryStream(Resources.Data.FluffyCatBmp); - var fetcher = CreateFetcherFor( - () => - { - Thread.Sleep(toSleep); - return fetchResult; - } - ); - var sut = Create(fetcher); - // Act - var result = await sut.Resize( - new ImageResizeParameters() + [TestCaseSource(nameof(EnumerateTimingHeaders))] + [Test] + public async Task ShouldIncludeTimingHeader_(string header) + { + // Arrange + var fetcher = CreateFetcherFor( + Resources.Data.FluffyCatBmp + ); + var sut = Create(fetcher); + var options = new UrlImageResizeParameters() { Width = Resources.Images.FluffyCatBmp.Width, Url = GetRandomHttpUrlWithPath(), Format = "PNG" - }, - new Dictionary() - ); - // Assert - Expect(result.Headers) - .To.Contain.Key(TimingHeaders.Fetch); - var header = result.Headers[TimingHeaders.Fetch]; - Expect(header) - .Not.To.Be.Null(); - Expect(header) - .To.Be.An.Integer(); - var intValue = int.Parse(header); - Expect(intValue) - .To.Be.Greater.Than.Or.Equal.To(min) - .And.Less.Than(max + 100); // allow some time for recording - } - - public static IEnumerable EnumerateTimingHeaders() - { - return typeof(TimingHeaders) - .GetFields(BindingFlags.Static | BindingFlags.Public) - .Where(f => f.Name != nameof(TimingHeaders.PREFIX)) - .Select(f => f.GetValue(null) as string) - .ToArray(); + }; + Expect(options.Url) + .Not.To.Be.Null.Or.Empty($"got: {options.Url}"); + // Act + var result = await sut.Resize(options, new Dictionary()); + // Assert + Expect(result.Headers) + .To.Contain.Key(header); + } } - [TestCaseSource(nameof(EnumerateTimingHeaders))] [Test] - public async Task ShouldIncludeTimingHeader_(string header) + public void ShouldThrowIfOutputWillExceedMaxOutputSize() { // Arrange - var fetcher = CreateFetcherFor( - Resources.Data.FluffyCatBmp - ); - var sut = Create(fetcher); - var options = new ImageResizeParameters() + var appSettings = Substitute.For(); + appSettings.MaxOutputImageSize.Returns(1024); + appSettings.MaxImageFetchTimeInMilliseconds.Returns(10000); + var url = GetRandomHttpUrlWithPath(); + var fetcher = CreateFetcherFor(Resources.Streams.FluffyCatBmp, url); + var sut = Create(fetcher, appSettings); + var options = new UrlImageResizeParameters() { - Width = Resources.Images.FluffyCatBmp.Width, - Url = GetRandomHttpUrlWithPath(), - Format = "PNG" + Url = url }; - Expect(options.Url) - .Not.To.Be.Null.Or.Empty($"got: {options.Url}"); // Act - var result = await sut.Resize(options, new Dictionary()); + Expect(() => sut.Resize(options, NoHeaders)) + .To.Throw(); // Assert - Expect(result.Headers) - .To.Contain.Key(header); } - } - [Test] - public void ShouldThrowIfOutputWillExceedMaxOutputSize() - { - // Arrange - var appSettings = Substitute.For(); - appSettings.MaxOutputImageSize.Returns(1024); - appSettings.MaxImageFetchTimeInMilliseconds.Returns(10000); - var url = GetRandomHttpUrlWithPath(); - var fetcher = CreateFetcherFor(Resources.Streams.FluffyCatBmp, url); - var sut = Create(fetcher, appSettings); - var options = new ImageResizeParameters() + private static IUrlFetcher CreateFetcherFor( + byte[] data, + string url = null, + IDictionary headers = null + ) { - Url = url - }; - // Act - Expect(() => sut.Resize(options, NoHeaders)) - .To.Throw(); - // Assert - } - - private static IUrlFetcher CreateFetcherFor( - byte[] data, - string url = null, - IDictionary headers = null - ) - { - return CreateFetcherFor( - () => new MemoryStream(data), - url, - headers - ); - } - - private static IUrlFetcher CreateFetcherFor( - Stream data, - string url = null, - IDictionary headers = null - ) - { - return CreateFetcherFor( - () => data, - url, - headers - ); - } + return CreateFetcherFor( + () => new MemoryStream(data), + url, + headers + ); + } - private static IUrlFetcher CreateFetcherFor( - Func dataProvider, - string url = null, - IDictionary responseHeaders = null - ) - { - var result = Substitute.For(); - result.Fetch(url ?? Arg.Any(), Arg.Any>()) - .ReturnsForAnyArgs( - _ => new StreamAndHeaders( - dataProvider(), - responseHeaders ?? new Dictionary() - ) + private static IUrlFetcher CreateFetcherFor( + Stream data, + string url = null, + IDictionary headers = null + ) + { + return CreateFetcherFor( + () => data, + url, + headers ); - return result; + } + + private static IUrlFetcher CreateFetcherFor( + Func dataProvider, + string url = null, + IDictionary responseHeaders = null + ) + { + var result = Substitute.For(); + result.Fetch(url ?? Arg.Any(), Arg.Any>()) + .ReturnsForAnyArgs( + _ => new StreamAndHeaders( + dataProvider(), + responseHeaders ?? new Dictionary() + ) + ); + return result; + } } private static ImageResizer Create( diff --git a/src/IRAAS.Tests/ImageProcessing/TestImageResizeParameters.cs b/src/IRAAS.Tests/ImageProcessing/TestUrlImageResizeParameters.cs similarity index 93% rename from src/IRAAS.Tests/ImageProcessing/TestImageResizeParameters.cs rename to src/IRAAS.Tests/ImageProcessing/TestUrlImageResizeParameters.cs index 224fce3..0bc88f1 100644 --- a/src/IRAAS.Tests/ImageProcessing/TestImageResizeParameters.cs +++ b/src/IRAAS.Tests/ImageProcessing/TestUrlImageResizeParameters.cs @@ -15,7 +15,7 @@ namespace IRAAS.Tests.ImageProcessing; [TestFixture] -public class TestImageResizeParameters : TestBase +public class TestUrlImageResizeParameters : TestBase { [Test] public void ShouldBeAbleToConstructWithNoParameters() @@ -24,7 +24,7 @@ public void ShouldBeAbleToConstructWithNoParameters() // attempt to populate // Arrange // Act - Expect(() => Activator.CreateInstance(typeof(ImageResizeParameters))) + Expect(() => Activator.CreateInstance(typeof(UrlImageResizeParameters))) .Not.To.Throw(); // Assert } @@ -102,7 +102,7 @@ public void ShouldDefaultTo85() Expect(result) .To.Equal(85); Expect(result) - .To.Equal(ImageResizeParameters.DEFAULT_QUALITY); + .To.Equal(UrlImageResizeParameters.DEFAULT_QUALITY); } [Test] @@ -324,10 +324,10 @@ public void ShouldSetDefaultSampler() var defaults = GetRandom() .With(o => o.Sampler = GetRandomString()); using var _ = AutoResetter.Create( - () => ImageResizeParameters.SetDefaults(defaults), - ImageResizeParameters.ClearDefaults + () => UrlImageResizeParameters.SetDefaults(defaults), + UrlImageResizeParameters.ClearDefaults ); - var options = GetRandom() + var options = GetRandom() .With(o => o.Sampler = null); // Act options.ApplyDefaultsFor(GetRandomFrom(["jpg", "png", "bmp", "gif"])); @@ -357,10 +357,10 @@ public void ShouldSetDefaultForFormat() formatDefaultProps ); using var _ = AutoResetter.Create( - () => ImageResizeParameters.SetDefaults(defaults), - ImageResizeParameters.ClearDefaults + () => UrlImageResizeParameters.SetDefaults(defaults), + UrlImageResizeParameters.ClearDefaults ); - var options = GetRandom() + var options = GetRandom() .With(o => o.Sampler = null); Expect(options.Sampler) .To.Be.Null(); @@ -376,8 +376,8 @@ public void ShouldSetDefaultForFormat() } } - private static ImageResizeParameters Create() + private static UrlImageResizeParameters Create() { - return new ImageResizeParameters(); + return new UrlImageResizeParameters(); } } \ No newline at end of file diff --git a/src/IRAAS.Tests/TestBase.cs b/src/IRAAS.Tests/TestBase.cs index 458f2f3..ed58731 100644 --- a/src/IRAAS.Tests/TestBase.cs +++ b/src/IRAAS.Tests/TestBase.cs @@ -8,14 +8,14 @@ public class TestBase [SetUp] public void SetupBase() { - ImageResizeParameters.ClearDefaults(); + UrlImageResizeParameters.ClearDefaults(); AppSettingsProvider.ClearCachedSettings(); } [TearDown] public void TearDownBase() { - ImageResizeParameters.ClearDefaults(); + UrlImageResizeParameters.ClearDefaults(); AppSettingsProvider.ClearCachedSettings(); } } \ No newline at end of file diff --git a/src/IRAAS.Tests/TestBootstrapper.cs b/src/IRAAS.Tests/TestBootstrapper.cs index beca402..335aca2 100644 --- a/src/IRAAS.Tests/TestBootstrapper.cs +++ b/src/IRAAS.Tests/TestBootstrapper.cs @@ -134,14 +134,14 @@ public void ShouldRegisterTransient_(Type serviceType, Type implementationType) public void ShouldSetImageResizeParameterDefaults() { // Arrange - ImageResizeParameters.SetDefaults(null); - Expect(ImageResizeParameters.Defaults) + UrlImageResizeParameters.SetDefaults(null); + Expect(UrlImageResizeParameters.Defaults) .To.Be.Null(); var sut = Create(); // Act sut.Bootstrap(new Container()); // Assert - Expect(ImageResizeParameters.Defaults) + Expect(UrlImageResizeParameters.Defaults) .Not.To.Be.Null(); } diff --git a/src/IRAAS/Bootstrapper.cs b/src/IRAAS/Bootstrapper.cs index 5329ab1..07db3e2 100644 --- a/src/IRAAS/Bootstrapper.cs +++ b/src/IRAAS/Bootstrapper.cs @@ -22,7 +22,7 @@ public void Bootstrap(IContainer container) container.RegisterInstance(AppSettingsProvider.CreateAppSettings()); var defaults = AppSettingsProvider.CreateDefaultParameters(); container.RegisterInstance(defaults); - ImageResizeParameters.SetDefaults(defaults); + UrlImageResizeParameters.SetDefaults(defaults); container.RegisterAllMiddlewareSingleton(); diff --git a/src/IRAAS/Controllers/ImageResizeController.cs b/src/IRAAS/Controllers/ImageResizeController.cs index 7f82ad5..2d25e63 100644 --- a/src/IRAAS/Controllers/ImageResizeController.cs +++ b/src/IRAAS/Controllers/ImageResizeController.cs @@ -32,7 +32,7 @@ IHttpContextAccessor httpContextAccessor [Route("")] [HttpGet] public async Task Resize( - [FromQuery] ImageResizeParameters resizeParameters = null + [FromQuery] UrlImageResizeParameters resizeParameters = null ) { if (!_whitelist.IsAllowed(resizeParameters?.Url)) diff --git a/src/IRAAS/ImageProcessing/DataImageResizeParameters.cs b/src/IRAAS/ImageProcessing/DataImageResizeParameters.cs new file mode 100644 index 0000000..9885e38 --- /dev/null +++ b/src/IRAAS/ImageProcessing/DataImageResizeParameters.cs @@ -0,0 +1,36 @@ +using SixLabors.ImageSharp.Formats.Gif; +using SixLabors.ImageSharp.Formats.Jpeg; +using SixLabors.ImageSharp.Formats.Png; +using SixLabors.ImageSharp.Processing; + +namespace IRAAS.ImageProcessing; + +public interface IDataImageResizeParameters : IImageResizeParameters +{ + public byte[] ImageData { get; set; } +} + +public class DataImageResizeParameters : IImageResizeParameters +{ + public string ReplaceTransparencyWith { get; set; } + public string Format { get; set; } + public int Quality { get; set; } + public int? Width { get; set; } + public int? Height { get; set; } + public ResizeMode? ResizeMode { get; set; } + public JpegEncodingColor? JpegColorType { get; set; } + public JpegEncodingColor? JpegEncodingColor { get; set; } + public float? Gamma { get; set; } + public string Quantizer { get; set; } + public byte? TransparencyThreshold { get; set; } + public int? BitDepth { get; set; } + public PngColorType? PngColorType { get; set; } + public int? CompressionLevel { get; set; } + public PngFilterMethod? PngFilterMethod { get; set; } + public string Sampler { get; set; } + public GifColorTableMode? GifColorTableMode { get; set; } + public int? MaxColors { get; set; } + public bool? Dither { get; set; } + public decimal DevicePixelRatio { get; set; } + public bool? Echo { get; set; } +} \ No newline at end of file diff --git a/src/IRAAS/ImageProcessing/ImageResizer.cs b/src/IRAAS/ImageProcessing/ImageResizer.cs index d2a2d49..d78fad4 100644 --- a/src/IRAAS/ImageProcessing/ImageResizer.cs +++ b/src/IRAAS/ImageProcessing/ImageResizer.cs @@ -19,15 +19,18 @@ using SixLabors.ImageSharp.Processing.Processors.Transforms; // ReSharper disable AccessToDisposedClosure - namespace IRAAS.ImageProcessing; public interface IImageResizer { Task Resize( - ImageResizeParameters resizeParameters, + IUrlImageResizeParameters resizeParameters, IDictionary requestHeaders ); + + Task Resize( + DataImageResizeParameters resizeParameters + ); } public class ImageResizer : IImageResizer @@ -45,7 +48,7 @@ IAppSettings appSettings } public async Task Resize( - ImageResizeParameters resizeParameters, + IUrlImageResizeParameters resizeParameters, IDictionary requestHeaders ) { @@ -62,6 +65,15 @@ IDictionary requestHeaders requestHeaders ) ); + return ResizeImage( + resizeParameters, + timer, + src + ); + } + + private StreamAndHeaders ResizeImage(IActiveImageResizeParameters resizeParameters, Timer timer, StreamAndHeaders src) + { IImageFormat sourceFormat = null; try { @@ -78,11 +90,20 @@ IDictionary requestHeaders if (sourceFormat is null) { - throw new NotSupportedException( - $"Data source at {resizeParameters.Url} is not a supported image format" - ); + if (resizeParameters is IUrlImageResizeParameters urlImageResizeParameters) + { + throw new NotSupportedException( + $"Data source at {urlImageResizeParameters.Url} is not a supported image format" + ); + } + else + { + throw new NotSupportedException( + "Unsupported image format" + ); + } } - + resizeParameters.ApplyDefaultsFor(sourceFormat.Name); timer.Time( @@ -135,7 +156,11 @@ resizeParameters.ReplaceTransparencyWith is not null && timer.Time( TimingHeaders.EncodeOutput, - () => reEncoder(clone, targetStream, resizeParameters) + () => reEncoder( + clone, + targetStream, + resizeParameters + ) ); targetStream.Rewind(); return new StreamAndHeaders( @@ -148,7 +173,14 @@ resizeParameters.ReplaceTransparencyWith is not null && ); } - private IResampler DetermineSamplerFor(ImageResizeParameters resizeParameters) + public Task Resize(DataImageResizeParameters resizeParameters) + { + throw new NotImplementedException(); + } + + private IResampler DetermineSamplerFor( + IActiveImageResizeParameters resizeParameters + ) { if (resizeParameters.Sampler is null) { @@ -160,19 +192,19 @@ private IResampler DetermineSamplerFor(ImageResizeParameters resizeParameters) : CreateDefaultResampler(); } - private static readonly Dictionary> Resamplers + private static readonly Dictionary> Resamplers = GenerateResamplerLookup(); public static string[] SamplerNames { get; } = Resamplers.Keys.ToArray(); - private static Dictionary> GenerateResamplerLookup() + private static Dictionary> GenerateResamplerLookup() { return GenerateLookupFor( type => type.Name.RegexReplace("Resampler$", "") ); } - private static IQuantizer DetermineQuantizerFor(ImageResizeParameters resizeParameters) + private static IQuantizer DetermineQuantizerFor(IImageResizeParameters resizeParameters) { if (resizeParameters.Quantizer is null) { @@ -185,12 +217,12 @@ private static IQuantizer DetermineQuantizerFor(ImageResizeParameters resizePara return result; } - private static readonly Dictionary> Quantizers + private static readonly Dictionary> Quantizers = GenerateQuantizerLookup(); public static string[] QuantizerNames { get; } = Quantizers.Keys.ToArray(); - private static Dictionary> GenerateQuantizerLookup() + private static Dictionary> GenerateQuantizerLookup() { return GenerateLookupFor( type => type.Name.RegexReplace("Quantizer$", ""), @@ -201,7 +233,7 @@ private static Dictionary> Gener ); } - private static readonly Dictionary> QuantizerFactories + private static readonly Dictionary> QuantizerFactories = new() { [typeof(WuQuantizer)] = CreateWuQuantizer, @@ -210,29 +242,35 @@ private static readonly Dictionary [typeof(WebSafePaletteQuantizer)] = CreateWebSafePaletteQuantizer }; - private static IQuantizer CreateWebSafePaletteQuantizer(ImageResizeParameters arg) + private static IQuantizer CreateWebSafePaletteQuantizer( + IImageResizeParameters arg + ) { return new WebSafePaletteQuantizer(arg.AsQuantizerOptions()); } - private static IQuantizer CreateOctreeQuantizer(ImageResizeParameters arg) + private static IQuantizer CreateOctreeQuantizer( + IImageResizeParameters arg + ) { return new OctreeQuantizer(arg.AsQuantizerOptions()); } - private static IQuantizer CreateWernerPaletteQuantizer(ImageResizeParameters arg) + private static IQuantizer CreateWernerPaletteQuantizer( + IImageResizeParameters arg + ) { return new WernerPaletteQuantizer(arg.AsQuantizerOptions()); } - private static IQuantizer CreateWuQuantizer(ImageResizeParameters arg) + private static IQuantizer CreateWuQuantizer(IImageResizeParameters arg) { return new WuQuantizer(arg.AsQuantizerOptions()); } - private static Dictionary> GenerateLookupFor( + private static Dictionary> GenerateLookupFor( Func keyGenerator, - Func generator = null, + Func generator = null, Func filter = null ) where T : class { @@ -248,12 +286,12 @@ private static Dictionary> GenerateLookup .Where(filter) .ToDictionary( keyGenerator, - type => new Func(opts => generator(type, opts)), + type => new Func(opts => generator(type, opts)), StringComparer.OrdinalIgnoreCase ); } - private readonly Dictionary> + private readonly Dictionary> _reEncoders = new(StringComparer.OrdinalIgnoreCase) { @@ -268,7 +306,7 @@ private readonly Dictionary private static void ReEncodeAsWebp( Image source, Stream target, - ImageResizeParameters resizeParameters + IImageResizeParameters resizeParameters ) { source.SaveAsWebp( @@ -286,7 +324,7 @@ ImageResizeParameters resizeParameters private static void ReEncodeAsBmp( Image source, Stream target, - ImageResizeParameters resizeParameters + IImageResizeParameters resizeParameters ) { source.SaveAsBmp( @@ -301,7 +339,7 @@ ImageResizeParameters resizeParameters private static void ReEncodeAsGif( Image source, Stream target, - ImageResizeParameters resizeParameters + IImageResizeParameters resizeParameters ) { source.SaveAsGif( @@ -317,7 +355,7 @@ ImageResizeParameters resizeParameters private static void ReEncodeAsPng( Image source, Stream target, - ImageResizeParameters resizeParameters + IImageResizeParameters resizeParameters ) { source.SaveAsPng( @@ -338,7 +376,7 @@ ImageResizeParameters resizeParameters private static void ReEncodeAsJpeg( Image source, Stream target, - ImageResizeParameters resizeParameters + IImageResizeParameters resizeParameters ) { source.SaveAsJpeg( @@ -410,7 +448,7 @@ this int? compressionLevel try { - return (PngCompressionLevel) compressionLevel.Value; + return (PngCompressionLevel)compressionLevel.Value; } catch { @@ -419,7 +457,7 @@ this int? compressionLevel } public static QuantizerOptions AsQuantizerOptions( - this ImageResizeParameters arg + this IImageResizeParameters arg ) { var opts = new QuantizerOptions(); diff --git a/src/IRAAS/ImageProcessing/ImageResizeParameters.cs b/src/IRAAS/ImageProcessing/UrlImageResizeParameters.cs similarity index 83% rename from src/IRAAS/ImageProcessing/ImageResizeParameters.cs rename to src/IRAAS/ImageProcessing/UrlImageResizeParameters.cs index 7c40b94..bb98230 100644 --- a/src/IRAAS/ImageProcessing/ImageResizeParameters.cs +++ b/src/IRAAS/ImageProcessing/UrlImageResizeParameters.cs @@ -18,8 +18,30 @@ namespace IRAAS.ImageProcessing; -public class ImageResizeParameters - : IImageResizeParameters +public interface IUrlImageResizeParameters : IActiveImageResizeParameters +{ + string Url { get; set; } +} + +public interface IActiveImageResizeParameters: IImageResizeParameters +{ + bool OutputFormatSpecified { get; } + bool PngOutputRequested { get; } + int? EffectiveWidth { get; } + int? EffectiveHeight { get; } + + void ApplyDefaultsFor( + string sourceFormat + ); + + void DetermineOutputFormatIfNotSpecified( + IImageFormat sourceFormat + ); + + Dictionary DumpIf(bool enabledGlobally); +} + +public abstract class ImageResizeParameters : IActiveImageResizeParameters { public static void ClearDefaults() { @@ -34,44 +56,134 @@ IDefaultImageResizeParameters parameters } public static IDefaultImageResizeParameters Defaults => _defaultParameters; - private static IDefaultImageResizeParameters _defaultParameters; - public const int DEFAULT_QUALITY = 85; + + public bool OutputFormatSpecified => + !string.IsNullOrWhiteSpace(Format); + + public const string PNG_FORMAT_SPECIFIER = "png"; + public bool PngOutputRequested => + string.Equals( + Format, + PNG_FORMAT_SPECIFIER, + StringComparison.OrdinalIgnoreCase + ); + + public void DetermineOutputFormatIfNotSpecified( + IImageFormat sourceFormat + ) + { + if (Format is not null) + { + return; + } - public string Url + Format = sourceFormat.Equals(BmpFormat.Instance) + ? JpegFormat.Instance.Name + : sourceFormat.Name; + if (AutoConversionTable.TryGetValue(sourceFormat.Name, out var convertTo)) + { + Format = convertTo; + } + } + + protected static readonly Dictionary AutoConversionTable = new(StringComparer.OrdinalIgnoreCase) { - get => _url; - set + [BmpFormat.Instance.Name] = JpegFormat.Instance.Name, + [QoiFormat.Instance.Name] = JpegFormat.Instance.Name, + // unoptimised image format which supports frames + // - I've seen icons with this format + [PbmFormat.Instance.Name] = PngFormat.Instance.Name, + // commonly used in game assets, this is an old format + // which often has natural images in it + [TgaFormat.Instance.Name] = JpegFormat.Instance.Name, + // format used by, eg, scanners - provides for + // multiple pages within the format, but not typically + // animated + [TiffFormat.Instance.Name] = PngFormat.Instance.Name + }; + + public void ApplyDefaultsFor( + string sourceFormat + ) + { + if (_defaultParameters is null) { - try - { - var uri = new Uri(value); - _url = uri.HasPath() || uri.HasParameters() - ? uri.ToString() - : null; - } - catch + DefaultImageResizeParameters.Sanitise(this); + return; + } + + var source = _defaultParameters.For(sourceFormat); + var target = this as IImageResizeParameters; + foreach (var kvp in ImageResizeParametersProperties) + { + var (prop, defaultValue) = (kvp.Key, kvp.Value); + var current = prop.GetValue(target); + if (AreEqual(current, defaultValue)) { - _url = null; + var sourceValue = prop.GetValue(source); + prop.SetValue(target, sourceValue); } } } + + protected static readonly Dictionary ImageResizeParametersProperties = + typeof(IImageResizeParameters) + .GetProperties(BindingFlags.Public | BindingFlags.Instance) + .ToDictionary(o => o, o => o.PropertyType.DefaultValue()); + protected static readonly Dictionary Empty = new(); - private string _url; + protected static readonly Dictionary DumpProperties = + ImageResizeParametersProperties + .Where(kvp => kvp.Key.Name != nameof(Echo)) + .ToDictionary(prop => prop.Key, prop => prop.Value); - public const string PNG_FORMAT_SPECIFIER = "png"; + private static bool AreEqual( + object left, + object right + ) + { + if (left is null && right is null) + { + return true; + } - public bool OutputFormatSpecified => - !string.IsNullOrWhiteSpace(Format); + if (left is null || right is null) + { + return false; + } - public bool PngOutputRequested => - string.Equals(Format, PNG_FORMAT_SPECIFIER, StringComparison.OrdinalIgnoreCase); + return left.Equals(right); + } + + public Dictionary DumpIf(bool enabledGlobally) + { + var enabled = enabledGlobally || (Echo ?? false); + if (!enabled) + { + return Empty; + } + + var result = new Dictionary(); + foreach (var kvp in DumpProperties) + { + result[$"IRAAS-Resize-Parameter-{kvp.Key.Name}"] = $"{kvp.Key.GetValue(this)}"; + } + + return result; + } public string ReplaceTransparencyWith { get; set; } - [Options("", "jpeg", "png", "gif", "bmp")] + [Options( + "", + "jpeg", + "png", + "gif", + "bmp" + )] public string Format { get => _format; @@ -108,7 +220,6 @@ public int Quality } } - [Min(0)] [Max(2048)] [Default(400)] @@ -134,7 +245,6 @@ public int? Height } private int? _height; - public ResizeMode? ResizeMode { get; set; } [Obsolete("JpegColorType was renamed upstream to JpegEncodingColor")] @@ -145,17 +255,13 @@ public JpegEncodingColor? JpegColorType } public JpegEncodingColor? JpegEncodingColor { get; set; } - [Min(0)] [Max(1)] [Step(0.1)] public float? Gamma { get; set; } - [OptionsFrom(typeof(ImageResizer), nameof(ImageResizer.QuantizerNames))] public string Quantizer { get; set; } - public byte? TransparencyThreshold { get; set; } - [Options( "", "1", @@ -167,24 +273,21 @@ public JpegEncodingColor? JpegColorType "32" )] public int? BitDepth { get; set; } - public PngColorType? PngColorType { get; set; } - [Min(1)] [Max(9)] public int? CompressionLevel { get; set; } - public PngFilterMethod? PngFilterMethod { get; set; } - [OptionsFrom(typeof(ImageResizer), nameof(ImageResizer.SamplerNames))] public string Sampler { get; set; } - public GifColorTableMode? GifColorTableMode { get; set; } - [Min(0)] public int? MaxColors { get; set; } - - [Options("", "true", "false")] + [Options( + "", + "true", + "false" + )] public bool? Dither { get; set; } [Min(1)] @@ -200,23 +303,18 @@ public decimal DevicePixelRatio [JsonIgnore] public int? EffectiveWidth => Width.HasValue - ? (int?) Math.Ceiling(Width.Value * DevicePixelRatio) + ? (int?)Math.Ceiling(Width.Value * DevicePixelRatio) : null; - [JsonIgnore] public int? EffectiveHeight => Height.HasValue - ? (int?) Math.Ceiling(Height.Value * DevicePixelRatio) + ? (int?)Math.Ceiling(Height.Value * DevicePixelRatio) : null; - /// /// when set to true, the final resize parameters /// are echoed back as headers /// public bool? Echo { get; set; } - private decimal? _devicePixelRatio; - - private int? _quality; private static readonly Dictionary FormatLookup @@ -233,117 +331,38 @@ private static Dictionary GenerateFormatLookup() PngFormat.Instance.Name, WebpFormat.Instance.Name }.ForEach( - format => - { - result[format] = format; - } + format => { result[format] = format; } ); result["jpg"] = JpegFormat.Instance.Name; return result; } private string _format; +} - public void DetermineOutputFormatIfNotSpecified( - IImageFormat sourceFormat - ) - { - if (Format is not null) - { - return; - } - - Format = sourceFormat.Equals(BmpFormat.Instance) - ? JpegFormat.Instance.Name - : sourceFormat.Name; - if (AutoConversionTable.TryGetValue(sourceFormat.Name, out var convertTo)) - { - Format = convertTo; - } - } - - private static readonly Dictionary AutoConversionTable = new(StringComparer.OrdinalIgnoreCase) - { - [BmpFormat.Instance.Name] = JpegFormat.Instance.Name, - [QoiFormat.Instance.Name] = JpegFormat.Instance.Name, - // unoptimised image format which supports frames - // - I've seen icons with this format - [PbmFormat.Instance.Name] = PngFormat.Instance.Name, - // commonly used in game assets, this is an old format - // which often has natural images in it - [TgaFormat.Instance.Name] = JpegFormat.Instance.Name, - // format used by, eg, scanners - provides for - // multiple pages within the format, but not typically - // animated - [TiffFormat.Instance.Name] = PngFormat.Instance.Name - }; +public class UrlImageResizeParameters + : ImageResizeParameters, IUrlImageResizeParameters +{ - public void ApplyDefaultsFor( - string sourceFormat - ) + public string Url { - if (_defaultParameters is null) - { - DefaultImageResizeParameters.Sanitise(this); - return; - } - - var source = _defaultParameters.For(sourceFormat); - var target = this as IImageResizeParameters; - foreach (var kvp in ImageResizeParametersProperties) + get => _url; + set { - var (prop, defaultValue) = (kvp.Key, kvp.Value); - var current = prop.GetValue(target); - if (AreEqual(current, defaultValue)) + try { - var sourceValue = prop.GetValue(source); - prop.SetValue(target, sourceValue); + var uri = new Uri(value); + _url = uri.HasPath() || uri.HasParameters() + ? uri.ToString() + : null; + } + catch + { + _url = null; } } } - private static bool AreEqual( - object left, - object right - ) - { - if (left is null && right is null) - { - return true; - } - - if (left is null || right is null) - { - return false; - } - - return left.Equals(right); - } - - private static readonly Dictionary ImageResizeParametersProperties = - typeof(IImageResizeParameters) - .GetProperties(BindingFlags.Public | BindingFlags.Instance) - .ToDictionary(o => o, o => o.PropertyType.DefaultValue()); - - private static readonly Dictionary Empty = new(); - - private static readonly Dictionary DumpProperties = - ImageResizeParametersProperties - .Where(kvp => kvp.Key.Name != nameof(Echo)) - .ToDictionary(prop => prop.Key, prop => prop.Value); - public Dictionary DumpIf(bool enabledGlobally) - { - var enabled = enabledGlobally || (Echo ?? false); - if (!enabled) - { - return Empty; - } + private string _url; - var result = new Dictionary(); - foreach (var kvp in DumpProperties) - { - result[$"IRAAS-Resize-Parameter-{kvp.Key.Name}"] = $"{kvp.Key.GetValue(this)}"; - } - return result; - } } \ No newline at end of file diff --git a/src/IRAAS/Views/Test/Test.cshtml b/src/IRAAS/Views/Test/Test.cshtml index b4111d8..02cf2c3 100644 --- a/src/IRAAS/Views/Test/Test.cshtml +++ b/src/IRAAS/Views/Test/Test.cshtml @@ -213,7 +213,7 @@
@{ - var optionProps = typeof(ImageResizeParameters).GetProperties() + var optionProps = typeof(UrlImageResizeParameters).GetProperties() .Where(pi => pi.CanWrite); @foreach (var prop in optionProps) { From 50a63c4fe1466e66495c1f77257f8259b79189a1 Mon Sep 17 00:00:00 2001 From: Davyd McColl Date: Tue, 22 Sep 2026 11:02:17 +0200 Subject: [PATCH 07/18] :construction: implementing settings around image posts - AllowPostRequests (boolean) - PostAuthTokens (string) --- .../Controllers/TestImageResizeController.cs | 42 +++++++------- .../ImageProcessing/IAppSettingsBuilder.cs | 10 +++- .../ImageResizeOptionsBuilder.cs | 2 +- .../TestAppSettingsProvider.cs | 4 ++ .../ImageProcessing/TestImageResizer.cs | 56 +++++++++---------- ...ers.cs => TestImageUrlResizeParameters.cs} | 22 ++++---- src/IRAAS.Tests/TestBase.cs | 4 +- src/IRAAS.Tests/TestBootstrapper.cs | 6 +- src/IRAAS/AppSettingsProvider.cs | 23 ++++++-- src/IRAAS/Bootstrapper.cs | 2 +- .../Controllers/ImageResizeController.cs | 31 +++++++++- src/IRAAS/IAppSettings.cs | 8 ++- .../DataImageResizeParameters.cs | 36 ------------ .../DefaultImageResizeParameters.cs | 2 +- .../ImageDataResizeParameters.cs | 12 ++++ src/IRAAS/ImageProcessing/ImageResizer.cs | 40 ++++++++++--- ...ameters.cs => ImageUrlResizeParameters.cs} | 2 +- src/IRAAS/Middleware/ConcurrencyMiddleware.cs | 3 +- src/IRAAS/Startup.cs | 11 +++- src/IRAAS/Views/Test/Test.cshtml | 2 +- 20 files changed, 193 insertions(+), 125 deletions(-) rename src/IRAAS.Tests/ImageProcessing/{TestUrlImageResizeParameters.cs => TestImageUrlResizeParameters.cs} (94%) delete mode 100644 src/IRAAS/ImageProcessing/DataImageResizeParameters.cs create mode 100644 src/IRAAS/ImageProcessing/ImageDataResizeParameters.cs rename src/IRAAS/ImageProcessing/{UrlImageResizeParameters.cs => ImageUrlResizeParameters.cs} (99%) diff --git a/src/IRAAS.Tests/Controllers/TestImageResizeController.cs b/src/IRAAS.Tests/Controllers/TestImageResizeController.cs index c1ba86b..e4fd2e1 100644 --- a/src/IRAAS.Tests/Controllers/TestImageResizeController.cs +++ b/src/IRAAS.Tests/Controllers/TestImageResizeController.cs @@ -38,7 +38,7 @@ public void ShouldHaveEmptyRoute() // Arrange // Act Expect(typeof(ImageResizeController)) - .To.Have.Route(nameof(ImageResizeController.Resize), ""); + .To.Have.Route(nameof(ImageResizeController.ResizeByUrl), ""); // Assert } @@ -49,12 +49,12 @@ public void ShouldThrowImageSourceNotAllowedWhenWhitelistSaysNotAllowed() var whiteList = Substitute.For(); whiteList.IsAllowed(Arg.Any()).Returns(false); var sut = Create(whiteList: whiteList); - var options = new UrlImageResizeParameters() + var options = new ImageUrlResizeParameters() { Url = GetRandomHttpUrl() }; // Act - Expect(() => sut.Resize(options)) + Expect(() => sut.ResizeByUrl(options)) .To.Throw() .With.Property(e => e.Url) .Equal.To(options.Url); @@ -66,12 +66,12 @@ public async Task ShouldPassOptionsToResizer() { // Arrange var resizer = Substitute.For(); - resizer.Resize(Arg.Any(), Arg.Any>()) + resizer.Resize(Arg.Any(), Arg.Any>()) .Returns(new StreamAndHeaders(new MemoryStream(), new Dictionary())); var sut = Create(resizer); - var options = GetRandom(); + var options = GetRandom(); // Act - await sut.Resize(options); + await sut.ResizeByUrl(options); // Assert await Expect(resizer).To.Have.Received(1) .Resize( @@ -86,7 +86,7 @@ public async Task ShouldPassCurrentHeadersToResizer() // Arrange var resizer = Substitute.For(); resizer.Resize( - Arg.Any(), + Arg.Any(), Arg.Any>() ).Returns(new StreamAndHeaders(new MemoryStream(), new Dictionary())); var headers = new Dictionary() @@ -96,10 +96,10 @@ public async Task ShouldPassCurrentHeadersToResizer() var httpContext = new FakeHttpContext(headers); var accessor = Substitute.For(); accessor.HttpContext.Returns(httpContext); - var options = GetRandom(); + var options = GetRandom(); var sut = Create(resizer, httpContextAccessor: accessor); // Act - await sut.Resize(options); + await sut.ResizeByUrl(options); // Assert await Expect(resizer) .To.Have.Received(1) @@ -118,7 +118,7 @@ public async Task ShouldReturnFileStreamResultWithStreamFromResizer() var resizer = Substitute.For(); var expected = GetRandomBytes(1024); var processStream = new MemoryStream(expected); - var options = GetRandom(); + var options = GetRandom(); resizer.Resize( options, Arg.Any>() @@ -127,7 +127,7 @@ public async Task ShouldReturnFileStreamResultWithStreamFromResizer() ); var sut = Create(resizer); // Act - var result = await sut.Resize(options); + var result = await sut.ResizeByUrl(options); // Assert var resultStream = new MemoryStream(); await result.FileStream.CopyToAsync(resultStream); @@ -148,7 +148,7 @@ public async Task ShouldSetMimeTypeFromMimeTypeProvider() ] ); var resizer = Substitute.For(); - var options = GetRandom(); + var options = GetRandom(); var imageStream = new MemoryStream(); resizer.Resize(options, Arg.Any>()).Returns( new StreamAndHeaders(imageStream, new Dictionary()) @@ -158,7 +158,7 @@ public async Task ShouldSetMimeTypeFromMimeTypeProvider() .Returns(expected); var sut = Create(resizer, mimeTypeProvider); // Act - var result = await sut.Resize(options); + var result = await sut.ResizeByUrl(options); // Assert Expect(result.ContentType) .To.Equal(expected); @@ -175,7 +175,7 @@ public async Task ShouldSetResponseHeadersFromResizer() [key] = value }; var resizer = Substitute.For(); - var options = GetRandom(); + var options = GetRandom(); var imageStream = new MemoryStream(); resizer.Resize( options, @@ -192,7 +192,7 @@ public async Task ShouldSetResponseHeadersFromResizer() accessor.HttpContext.Returns(httpContext); var sut = Create(resizer, httpContextAccessor: accessor); // Act - await sut.Resize(options); + await sut.ResizeByUrl(options); // Assert var responseHeaders = accessor.HttpContext!.Response.Headers.ToDictionary(); Expect(responseHeaders[key]) @@ -220,15 +220,15 @@ public async Task ShouldHaveDefaultsFilledAutomatically() var server = lease.Instance; server.ServeFile($"/{imageName}", imageData, "image/png"); var defaults = GetRandom(); - UrlImageResizeParameters.SetDefaults(defaults); - var options = new UrlImageResizeParameters() + ImageUrlResizeParameters.SetDefaults(defaults); + var options = new ImageUrlResizeParameters() { Url = server.GetFullUrlFor($"/{imageName}") }; var resizer = Substitute.For() .With( o => o.Resize( - Arg.Any(), + Arg.Any(), Arg.Any>() ).Returns(_ => expected) ); @@ -240,7 +240,7 @@ public async Task ShouldHaveDefaultsFilledAutomatically() var httpContext = HttpContextBuilder.BuildRandom(); var accessor = Substitute.For() .For(httpContext); - var expectedOptions = new UrlImageResizeParameters(); + var expectedOptions = new ImageUrlResizeParameters(); options.CopyPropertiesTo(expectedOptions); defaults.CopyPropertiesTo(expectedOptions); var expectedHeaders = httpContext.Response.Headers @@ -256,7 +256,7 @@ public async Task ShouldHaveDefaultsFilledAutomatically() httpContextAccessor: accessor ); // Act - var result = await sut.Resize(options); + var result = await sut.ResizeByUrl(options); // Assert await Expect(resizer) .To.Have.Received(1) @@ -281,7 +281,7 @@ private static ImageResizeController Create( IHttpContextAccessor httpContextAccessor = null ) { - UrlImageResizeParameters.SetDefaults(null); + ImageUrlResizeParameters.SetDefaults(null); return new ImageResizeController( resizer ?? Substitute.For(), mimeTypeProvider ?? CreateFakeMimeTypeProvider(), diff --git a/src/IRAAS.Tests/ImageProcessing/IAppSettingsBuilder.cs b/src/IRAAS.Tests/ImageProcessing/IAppSettingsBuilder.cs index be67d65..f8a84f6 100644 --- a/src/IRAAS.Tests/ImageProcessing/IAppSettingsBuilder.cs +++ b/src/IRAAS.Tests/ImageProcessing/IAppSettingsBuilder.cs @@ -17,7 +17,15 @@ public override IAppSettingsBuilder WithRandomProps() { return base.WithRandomProps() .WithNonZeroConcurrency() - .WithValidMaxImageFetchTime(); + .WithValidMaxImageFetchTime() + .WithEmptyPostAuthTokens(); + } + + public IAppSettingsBuilder WithEmptyPostAuthTokens() + { + return WithProp( + o => o.PostAuthTokens.Returns("") + ); } public IAppSettingsBuilder WithValidMaxImageFetchTime() diff --git a/src/IRAAS.Tests/ImageProcessing/ImageResizeOptionsBuilder.cs b/src/IRAAS.Tests/ImageProcessing/ImageResizeOptionsBuilder.cs index a794bb5..a6a30e0 100644 --- a/src/IRAAS.Tests/ImageProcessing/ImageResizeOptionsBuilder.cs +++ b/src/IRAAS.Tests/ImageProcessing/ImageResizeOptionsBuilder.cs @@ -5,7 +5,7 @@ namespace IRAAS.Tests.ImageProcessing; // ReSharper disable once UnusedType.Global public class ImageResizeOptionsBuilder - : GenericBuilder + : GenericBuilder { public override ImageResizeOptionsBuilder WithRandomProps() { diff --git a/src/IRAAS.Tests/ImageProcessing/TestAppSettingsProvider.cs b/src/IRAAS.Tests/ImageProcessing/TestAppSettingsProvider.cs index eb7b2b0..24a9ad8 100644 --- a/src/IRAAS.Tests/ImageProcessing/TestAppSettingsProvider.cs +++ b/src/IRAAS.Tests/ImageProcessing/TestAppSettingsProvider.cs @@ -336,6 +336,8 @@ private static string MakeSettings( "LogFolder": "{{settings.LogFolder}}", "SuppressErrorDiagnostics": "{{settings.SuppressErrorDiagnostics}}", "AllowInvalidSSLCertificates": "{{settings.AllowInvalidSslCertificates}}", + "AllowPostRequests": "{{settings.AllowPostRequests}}", + "PostAuthTokens": "{{settings.PostAuthTokens}}", "Verbose": "{{settings.Verbose}}" }, "DefaultParameters": { @@ -582,4 +584,6 @@ public class AppSettings : IAppSettings public int MaxUrlFetchRetries { get; set; } public bool Verbose { get; set; } public bool AllowInvalidSslCertificates { get; set; } + public bool AllowPostRequests { get; set; } + public string PostAuthTokens { get; set; } } \ No newline at end of file diff --git a/src/IRAAS.Tests/ImageProcessing/TestImageResizer.cs b/src/IRAAS.Tests/ImageProcessing/TestImageResizer.cs index 4240dc4..b6f4b60 100644 --- a/src/IRAAS.Tests/ImageProcessing/TestImageResizer.cs +++ b/src/IRAAS.Tests/ImageProcessing/TestImageResizer.cs @@ -52,7 +52,7 @@ public void ShouldThrowForInvalidUrl_(string url) // Act Expect( () => sut.Resize( - new UrlImageResizeParameters() + new ImageUrlResizeParameters() { Url = url }, @@ -68,7 +68,7 @@ public async Task ShouldGetImageStreamFromImageFetcher() // Arrange var fetcher = CreateFetcherFor(Resources.Data.FluffyCatJpeg); var sut = Create(fetcher); - var options = new UrlImageResizeParameters() + var options = new ImageUrlResizeParameters() { Url = GetRandomHttpUrlWithPath() }; @@ -90,7 +90,7 @@ public void ShouldPassHeadersThroughToImageFetcher() headers[GetRandomString(10)] = GetRandomString(10); headers[GetRandomString(10)] = GetRandomString(10); headers[GetRandomString(10)] = GetRandomString(10); - var options = new UrlImageResizeParameters() + var options = new ImageUrlResizeParameters() { Url = GetRandomHttpUrlWithPath() }; @@ -122,7 +122,7 @@ public async Task ShouldPassBackHeadersFromImageFetcher() [GetRandomString(10)] = GetRandomString(10), [GetRandomString(10)] = GetRandomString(10) }; - var options = new UrlImageResizeParameters() + var options = new ImageUrlResizeParameters() { Url = GetRandomHttpUrlWithPath() }; @@ -147,7 +147,7 @@ public void ShouldThrowNotSupportedIfImageStreamUnrecognised() // Arrange var data = GetRandomBytes(1024, 2048); var fetcher = CreateFetcherFor(data); - var options = new UrlImageResizeParameters() + var options = new ImageUrlResizeParameters() { Url = GetRandomHttpUrlWithPath() }; @@ -178,7 +178,7 @@ public async Task GivenOnlyUrl_ShouldFetchAndReOptimiseAsJpegAtSameSizeWithQuali var expectedHeight = sourceImage.Height; // Act var result = await sut.Resize( - new UrlImageResizeParameters() + new ImageUrlResizeParameters() { Url = url }, @@ -212,7 +212,7 @@ public async Task GivenQuality_ShouldOptimiseAtGivenQuality() var sut = Create(fetcher); // Act var result = await sut.Resize( - new UrlImageResizeParameters() + new ImageUrlResizeParameters() { Url = url, Quality = expected @@ -242,7 +242,7 @@ public async Task GivenNoResizeMode_ShouldResizeToMax() var expectedHeight = 300; // Act var result = await sut.Resize( - new UrlImageResizeParameters() + new ImageUrlResizeParameters() { Width = 400, Height = 400, @@ -272,7 +272,7 @@ public async Task GivenResizeMode_ShouldUseIt() var expectedHeight = 300; // Act var result = await sut.Resize( - new UrlImageResizeParameters() + new ImageUrlResizeParameters() { Width = 300, Height = 300, @@ -302,7 +302,7 @@ public async Task GivenOnlyWidth_ShouldResizeToFitWidthAndMaintainAspectRatio() var expectedHeight = 300; // Act var result = await sut.Resize( - new UrlImageResizeParameters() + new ImageUrlResizeParameters() { Width = expectedWidth, Url = url, @@ -332,7 +332,7 @@ public async Task GivenOnlyWidthAndDevicePixelRatio_ShouldResizeToFitWidthAndMai var expectedHeight = (int)Math.Ceiling(300 * devicePixelRatio); // Act var result = await sut.Resize( - new UrlImageResizeParameters() + new ImageUrlResizeParameters() { Width = effectiveWidth, DevicePixelRatio = devicePixelRatio, @@ -378,7 +378,7 @@ public async Task GivenOnlyWidthAndDevicePixelRatioResultingInExplodingImage_Not var expectedHeight = 600; // Act var result = await sut.Resize( - new UrlImageResizeParameters() + new ImageUrlResizeParameters() { Width = effectiveWidth, DevicePixelRatio = devicePixelRatio, @@ -418,7 +418,7 @@ public async Task GivenOnlyHeight_ShouldResizeToFitWidthAndMaintainAspectRatio() var expectedHeight = 150; // Act var result = await sut.Resize( - new UrlImageResizeParameters() + new ImageUrlResizeParameters() { Height = expectedHeight, Url = url, @@ -448,7 +448,7 @@ public async Task GivenOnlyHeightAndDevicePixelRatio_ShouldResizeToFitWidthAndMa var expectedHeight = (int)Math.Ceiling(effectiveHeight * devicePixelRatio); // Act var result = await sut.Resize( - new UrlImageResizeParameters() + new ImageUrlResizeParameters() { Height = effectiveHeight, DevicePixelRatio = devicePixelRatio, @@ -512,7 +512,7 @@ public async Task ShouldResizePngToPng() var sut = Create(fetcher); // Act var result = await sut.Resize( - new UrlImageResizeParameters() + new ImageUrlResizeParameters() { Height = 400, Url = url @@ -537,7 +537,7 @@ public async Task ShouldResizeBmpToJpg() var fetcher = CreateFetcherFor(Resources.Data.FluffyCatBmp, url); var sut = Create(fetcher); var expected = GetRandomInt(100, 200); - var options = new UrlImageResizeParameters() + var options = new ImageUrlResizeParameters() { Url = url, Width = expected @@ -560,7 +560,7 @@ public async Task ShouldResizeGifToGif() var fetcher = CreateFetcherFor(gifMemStream.ToArray(), url); var sut = Create(fetcher); var expected = GetRandomInt(100, 200); - var options = new UrlImageResizeParameters() + var options = new ImageUrlResizeParameters() { Url = url, Width = expected @@ -582,7 +582,7 @@ public async Task ShouldResizeToBmpWhenExplicitlyRequested() var url = GetRandomHttpUrlWithPath(); var fetcher = CreateFetcherFor(Resources.Data.FluffyCatBmp, url); var expected = GetRandomInt(100, 200); - var options = new UrlImageResizeParameters() + var options = new ImageUrlResizeParameters() { Width = expected, Format = "BMP", @@ -626,7 +626,7 @@ public async Task ShouldBeAbleToResizeForReal() Substitute.For>() ); var sut = Create(fetcher); - var options = new UrlImageResizeParameters() + var options = new ImageUrlResizeParameters() { Url = server.GetFullUrlFor(servedPath), Width = 200 @@ -649,18 +649,18 @@ public async Task ShouldUseProvidedResampler() // Arrange var url = GetRandomHttpUrlWithPath(); var fetcher = CreateFetcherFor(Resources.Data.FluffyCatBmp, url); - var opts0 = new UrlImageResizeParameters() + var opts0 = new ImageUrlResizeParameters() { Width = 200, Url = url }; - var opts1 = new UrlImageResizeParameters() + var opts1 = new ImageUrlResizeParameters() { Width = 200, Sampler = "bicubic", Url = url }; - var opts2 = new UrlImageResizeParameters() + var opts2 = new ImageUrlResizeParameters() { Width = 200, Sampler = "box", @@ -688,20 +688,20 @@ public async Task ShouldUseProvidedQuantizer() var url = GetRandomHttpUrlWithPath(); var fetcher = CreateFetcherFor(Resources.Data.FluffyCatBmp, url); var width = 800; - var opts0 = new UrlImageResizeParameters() + var opts0 = new ImageUrlResizeParameters() { Width = width, Url = url, Format = "PNG" }; - var opts1 = new UrlImageResizeParameters() + var opts1 = new ImageUrlResizeParameters() { Width = width, Quantizer = "wu", Url = url, Format = "PNG" }; - var opts2 = new UrlImageResizeParameters() + var opts2 = new ImageUrlResizeParameters() { Width = width, Quantizer = "octree", @@ -752,7 +752,7 @@ public async Task ShouldIncludeTimeToFetchImage() var sut = Create(fetcher); // Act var result = await sut.Resize( - new UrlImageResizeParameters() + new ImageUrlResizeParameters() { Width = Resources.Images.FluffyCatBmp.Width, Url = GetRandomHttpUrlWithPath(), @@ -792,7 +792,7 @@ public async Task ShouldIncludeTimingHeader_(string header) Resources.Data.FluffyCatBmp ); var sut = Create(fetcher); - var options = new UrlImageResizeParameters() + var options = new ImageUrlResizeParameters() { Width = Resources.Images.FluffyCatBmp.Width, Url = GetRandomHttpUrlWithPath(), @@ -818,7 +818,7 @@ public void ShouldThrowIfOutputWillExceedMaxOutputSize() var url = GetRandomHttpUrlWithPath(); var fetcher = CreateFetcherFor(Resources.Streams.FluffyCatBmp, url); var sut = Create(fetcher, appSettings); - var options = new UrlImageResizeParameters() + var options = new ImageUrlResizeParameters() { Url = url }; diff --git a/src/IRAAS.Tests/ImageProcessing/TestUrlImageResizeParameters.cs b/src/IRAAS.Tests/ImageProcessing/TestImageUrlResizeParameters.cs similarity index 94% rename from src/IRAAS.Tests/ImageProcessing/TestUrlImageResizeParameters.cs rename to src/IRAAS.Tests/ImageProcessing/TestImageUrlResizeParameters.cs index 0bc88f1..13a7f37 100644 --- a/src/IRAAS.Tests/ImageProcessing/TestUrlImageResizeParameters.cs +++ b/src/IRAAS.Tests/ImageProcessing/TestImageUrlResizeParameters.cs @@ -15,7 +15,7 @@ namespace IRAAS.Tests.ImageProcessing; [TestFixture] -public class TestUrlImageResizeParameters : TestBase +public class TestImageUrlResizeParameters : TestBase { [Test] public void ShouldBeAbleToConstructWithNoParameters() @@ -24,7 +24,7 @@ public void ShouldBeAbleToConstructWithNoParameters() // attempt to populate // Arrange // Act - Expect(() => Activator.CreateInstance(typeof(UrlImageResizeParameters))) + Expect(() => Activator.CreateInstance(typeof(ImageUrlResizeParameters))) .Not.To.Throw(); // Assert } @@ -102,7 +102,7 @@ public void ShouldDefaultTo85() Expect(result) .To.Equal(85); Expect(result) - .To.Equal(UrlImageResizeParameters.DEFAULT_QUALITY); + .To.Equal(ImageUrlResizeParameters.DEFAULT_QUALITY); } [Test] @@ -324,10 +324,10 @@ public void ShouldSetDefaultSampler() var defaults = GetRandom() .With(o => o.Sampler = GetRandomString()); using var _ = AutoResetter.Create( - () => UrlImageResizeParameters.SetDefaults(defaults), - UrlImageResizeParameters.ClearDefaults + () => ImageUrlResizeParameters.SetDefaults(defaults), + ImageUrlResizeParameters.ClearDefaults ); - var options = GetRandom() + var options = GetRandom() .With(o => o.Sampler = null); // Act options.ApplyDefaultsFor(GetRandomFrom(["jpg", "png", "bmp", "gif"])); @@ -357,10 +357,10 @@ public void ShouldSetDefaultForFormat() formatDefaultProps ); using var _ = AutoResetter.Create( - () => UrlImageResizeParameters.SetDefaults(defaults), - UrlImageResizeParameters.ClearDefaults + () => ImageUrlResizeParameters.SetDefaults(defaults), + ImageUrlResizeParameters.ClearDefaults ); - var options = GetRandom() + var options = GetRandom() .With(o => o.Sampler = null); Expect(options.Sampler) .To.Be.Null(); @@ -376,8 +376,8 @@ public void ShouldSetDefaultForFormat() } } - private static UrlImageResizeParameters Create() + private static ImageUrlResizeParameters Create() { - return new UrlImageResizeParameters(); + return new ImageUrlResizeParameters(); } } \ No newline at end of file diff --git a/src/IRAAS.Tests/TestBase.cs b/src/IRAAS.Tests/TestBase.cs index ed58731..fbda83b 100644 --- a/src/IRAAS.Tests/TestBase.cs +++ b/src/IRAAS.Tests/TestBase.cs @@ -8,14 +8,14 @@ public class TestBase [SetUp] public void SetupBase() { - UrlImageResizeParameters.ClearDefaults(); + ImageUrlResizeParameters.ClearDefaults(); AppSettingsProvider.ClearCachedSettings(); } [TearDown] public void TearDownBase() { - UrlImageResizeParameters.ClearDefaults(); + ImageUrlResizeParameters.ClearDefaults(); AppSettingsProvider.ClearCachedSettings(); } } \ No newline at end of file diff --git a/src/IRAAS.Tests/TestBootstrapper.cs b/src/IRAAS.Tests/TestBootstrapper.cs index 335aca2..967fc0a 100644 --- a/src/IRAAS.Tests/TestBootstrapper.cs +++ b/src/IRAAS.Tests/TestBootstrapper.cs @@ -134,14 +134,14 @@ public void ShouldRegisterTransient_(Type serviceType, Type implementationType) public void ShouldSetImageResizeParameterDefaults() { // Arrange - UrlImageResizeParameters.SetDefaults(null); - Expect(UrlImageResizeParameters.Defaults) + ImageUrlResizeParameters.SetDefaults(null); + Expect(ImageUrlResizeParameters.Defaults) .To.Be.Null(); var sut = Create(); // Act sut.Bootstrap(new Container()); // Assert - Expect(UrlImageResizeParameters.Defaults) + Expect(ImageUrlResizeParameters.Defaults) .Not.To.Be.Null(); } diff --git a/src/IRAAS/AppSettingsProvider.cs b/src/IRAAS/AppSettingsProvider.cs index ce33550..90e9aa6 100644 --- a/src/IRAAS/AppSettingsProvider.cs +++ b/src/IRAAS/AppSettingsProvider.cs @@ -103,7 +103,8 @@ IConfigurationRoot config --------------------------------------------------------------- WARNING: invalid ssl certificates allowed for remote image urls --------------------------------------------------------------- - """); + """ + ); } return result; @@ -200,9 +201,22 @@ private static readonly Dictionary, str [SETTING_MAX_CONCURRENCY] = ResolveMaxConcurrency, [SETTING_MAX_IMAGE_FETCH_TIME_IN_MILLISECONDS] = ResolveMaxImageFetchTimeInMilliseconds, [SETTING_LOG_FOLDER] = ResolveDefaultLogFolder, - [SETTING_MAX_URL_FETCH_RETRIES] = ResolveMaxUrlFetchRetries + [SETTING_MAX_URL_FETCH_RETRIES] = ResolveMaxUrlFetchRetries, + [SETTING_POST_AUTH_TOKENS] = ResolvePostAuthTokens }; + private static string ResolvePostAuthTokens( + IDictionary arg + ) + { + return arg.TryGetValue( + SETTING_POST_AUTH_TOKENS, + out var value + ) + ? value ?? "" + : ""; + } + private static string ResolveDefaultLogFolder( IDictionary arg ) @@ -212,8 +226,8 @@ IDictionary arg // - we just need to set a value so that duck-typing won't // throw - return arg.ContainsKey(SETTING_LOG_FOLDER) - ? arg[SETTING_LOG_FOLDER] + return arg.TryGetValue(SETTING_LOG_FOLDER, out var value) + ? value : ""; } @@ -311,6 +325,7 @@ private static bool TryChangeType(string value, out T result) } private const string SETTING_MAX_CONCURRENCY = nameof(IAppSettings.MaxConcurrency); + private const string SETTING_POST_AUTH_TOKENS = nameof(IAppSettings.PostAuthTokens); private const string SETTING_MAX_IMAGE_FETCH_TIME_IN_MILLISECONDS = nameof(IAppSettings.MaxImageFetchTimeInMilliseconds); diff --git a/src/IRAAS/Bootstrapper.cs b/src/IRAAS/Bootstrapper.cs index 07db3e2..83a44fd 100644 --- a/src/IRAAS/Bootstrapper.cs +++ b/src/IRAAS/Bootstrapper.cs @@ -22,7 +22,7 @@ public void Bootstrap(IContainer container) container.RegisterInstance(AppSettingsProvider.CreateAppSettings()); var defaults = AppSettingsProvider.CreateDefaultParameters(); container.RegisterInstance(defaults); - UrlImageResizeParameters.SetDefaults(defaults); + ImageUrlResizeParameters.SetDefaults(defaults); container.RegisterAllMiddlewareSingleton(); diff --git a/src/IRAAS/Controllers/ImageResizeController.cs b/src/IRAAS/Controllers/ImageResizeController.cs index 2d25e63..8b772fb 100644 --- a/src/IRAAS/Controllers/ImageResizeController.cs +++ b/src/IRAAS/Controllers/ImageResizeController.cs @@ -1,3 +1,6 @@ +using System; +using System.IO; +using System.Text; using System.Threading.Tasks; using IRAAS.Exceptions; using IRAAS.ImageProcessing; @@ -31,8 +34,8 @@ IHttpContextAccessor httpContextAccessor [Route("")] [HttpGet] - public async Task Resize( - [FromQuery] UrlImageResizeParameters resizeParameters = null + public async Task ResizeByUrl( + [FromQuery] ImageUrlResizeParameters resizeParameters = null ) { if (!_whitelist.IsAllowed(resizeParameters?.Url)) @@ -56,4 +59,28 @@ public async Task Resize( contentType ); } + + // Experimental code ahead! + // TODO: add appsetting to enable POST resizing (defaulted false) + // TODO: add appsetting to hold a list of tokens which are authorised to POST + // TODO: add precursor to block the request if it doesn't contain an auth header + // with a known token + [Route("")] + [HttpPost] + public async Task ResizeByPostData( + [FromBody] ImageDataResizeParameters resizeParameters + ) + { + ArgumentNullException.ThrowIfNull(resizeParameters); + var result = await _imageResizer.Resize( + resizeParameters + ); + var contentType = _mimeTypeProvider.DetermineMimeTypeFor( + result.Stream + ); + return new FileStreamResult( + result.Stream, + contentType + ); + } } \ No newline at end of file diff --git a/src/IRAAS/IAppSettings.cs b/src/IRAAS/IAppSettings.cs index f921a3d..81113b7 100644 --- a/src/IRAAS/IAppSettings.cs +++ b/src/IRAAS/IAppSettings.cs @@ -24,7 +24,6 @@ public static class DefaultSettingValues public const string ONE_SECOND = "1000"; public const string UNLIMITED_CLIENTS = "0"; public const string LOG_LEVEL_WARNING = "Warning"; - public const string ALLOW_INVALID_SSL_CERTIFICATES = "false"; } public interface IAppSettings @@ -81,6 +80,11 @@ public interface IAppSettings [DefaultSetting(FALSE)] bool Verbose { get; } - [DefaultSetting(ALLOW_INVALID_SSL_CERTIFICATES)] + [DefaultSetting(FALSE)] bool AllowInvalidSslCertificates { get; } + + [DefaultSetting(FALSE)] + bool AllowPostRequests { get; } + + string PostAuthTokens { get; } } \ No newline at end of file diff --git a/src/IRAAS/ImageProcessing/DataImageResizeParameters.cs b/src/IRAAS/ImageProcessing/DataImageResizeParameters.cs deleted file mode 100644 index 9885e38..0000000 --- a/src/IRAAS/ImageProcessing/DataImageResizeParameters.cs +++ /dev/null @@ -1,36 +0,0 @@ -using SixLabors.ImageSharp.Formats.Gif; -using SixLabors.ImageSharp.Formats.Jpeg; -using SixLabors.ImageSharp.Formats.Png; -using SixLabors.ImageSharp.Processing; - -namespace IRAAS.ImageProcessing; - -public interface IDataImageResizeParameters : IImageResizeParameters -{ - public byte[] ImageData { get; set; } -} - -public class DataImageResizeParameters : IImageResizeParameters -{ - public string ReplaceTransparencyWith { get; set; } - public string Format { get; set; } - public int Quality { get; set; } - public int? Width { get; set; } - public int? Height { get; set; } - public ResizeMode? ResizeMode { get; set; } - public JpegEncodingColor? JpegColorType { get; set; } - public JpegEncodingColor? JpegEncodingColor { get; set; } - public float? Gamma { get; set; } - public string Quantizer { get; set; } - public byte? TransparencyThreshold { get; set; } - public int? BitDepth { get; set; } - public PngColorType? PngColorType { get; set; } - public int? CompressionLevel { get; set; } - public PngFilterMethod? PngFilterMethod { get; set; } - public string Sampler { get; set; } - public GifColorTableMode? GifColorTableMode { get; set; } - public int? MaxColors { get; set; } - public bool? Dither { get; set; } - public decimal DevicePixelRatio { get; set; } - public bool? Echo { get; set; } -} \ No newline at end of file diff --git a/src/IRAAS/ImageProcessing/DefaultImageResizeParameters.cs b/src/IRAAS/ImageProcessing/DefaultImageResizeParameters.cs index dfa0907..0b1dc3c 100644 --- a/src/IRAAS/ImageProcessing/DefaultImageResizeParameters.cs +++ b/src/IRAAS/ImageProcessing/DefaultImageResizeParameters.cs @@ -110,7 +110,7 @@ T result { result.Quantizer = "Wu"; } - + result.Echo ??= false; return result; diff --git a/src/IRAAS/ImageProcessing/ImageDataResizeParameters.cs b/src/IRAAS/ImageProcessing/ImageDataResizeParameters.cs new file mode 100644 index 0000000..bf3678c --- /dev/null +++ b/src/IRAAS/ImageProcessing/ImageDataResizeParameters.cs @@ -0,0 +1,12 @@ +namespace IRAAS.ImageProcessing; + +public interface IDataImageResizeParameters : IActiveImageResizeParameters +{ + public byte[] ImageData { get; set; } +} + +public class ImageDataResizeParameters + : ImageResizeParameters, IDataImageResizeParameters +{ + public byte[] ImageData { get; set; } +} \ No newline at end of file diff --git a/src/IRAAS/ImageProcessing/ImageResizer.cs b/src/IRAAS/ImageProcessing/ImageResizer.cs index d78fad4..21df09d 100644 --- a/src/IRAAS/ImageProcessing/ImageResizer.cs +++ b/src/IRAAS/ImageProcessing/ImageResizer.cs @@ -29,7 +29,7 @@ IDictionary requestHeaders ); Task Resize( - DataImageResizeParameters resizeParameters + ImageDataResizeParameters resizeParameters ); } @@ -57,7 +57,7 @@ IDictionary requestHeaders throw new InvalidProcessingOptionsException($"Url is required (received: {resizeParameters.Url})"); } - var timer = new Timer(_appSettings); + var timer = CreateTimer(); using var src = await timer.Time( TimingHeaders.Fetch, () => _fetcher.Fetch( @@ -72,7 +72,36 @@ IDictionary requestHeaders ); } - private StreamAndHeaders ResizeImage(IActiveImageResizeParameters resizeParameters, Timer timer, StreamAndHeaders src) + private Timer CreateTimer() + { + return new Timer(_appSettings); + } + + public Task Resize( + ImageDataResizeParameters resizeParameters + ) + { + var timer = CreateTimer(); + var src = new StreamAndHeaders( + new MemoryStream( + resizeParameters.ImageData + ), + new Dictionary() + ); + return Task.FromResult( + ResizeImage( + resizeParameters, + timer, + src + ) + ); + } + + private StreamAndHeaders ResizeImage( + IActiveImageResizeParameters resizeParameters, + Timer timer, + StreamAndHeaders src + ) { IImageFormat sourceFormat = null; try @@ -173,11 +202,6 @@ resizeParameters.ReplaceTransparencyWith is not null && ); } - public Task Resize(DataImageResizeParameters resizeParameters) - { - throw new NotImplementedException(); - } - private IResampler DetermineSamplerFor( IActiveImageResizeParameters resizeParameters ) diff --git a/src/IRAAS/ImageProcessing/UrlImageResizeParameters.cs b/src/IRAAS/ImageProcessing/ImageUrlResizeParameters.cs similarity index 99% rename from src/IRAAS/ImageProcessing/UrlImageResizeParameters.cs rename to src/IRAAS/ImageProcessing/ImageUrlResizeParameters.cs index bb98230..b084d0f 100644 --- a/src/IRAAS/ImageProcessing/UrlImageResizeParameters.cs +++ b/src/IRAAS/ImageProcessing/ImageUrlResizeParameters.cs @@ -340,7 +340,7 @@ private static Dictionary GenerateFormatLookup() private string _format; } -public class UrlImageResizeParameters +public class ImageUrlResizeParameters : ImageResizeParameters, IUrlImageResizeParameters { diff --git a/src/IRAAS/Middleware/ConcurrencyMiddleware.cs b/src/IRAAS/Middleware/ConcurrencyMiddleware.cs index ff05628..b6a12e9 100644 --- a/src/IRAAS/Middleware/ConcurrencyMiddleware.cs +++ b/src/IRAAS/Middleware/ConcurrencyMiddleware.cs @@ -90,7 +90,8 @@ private async Task ShareConcurrentRequestResult( { var queryString = context.Request.QueryString.ToString(); var completionSource = new TaskCompletionSource(); - var cacheProhibited = HasNoStoreCacheControlHeader(context.Request); + var cacheProhibited = HasNoStoreCacheControlHeader(context.Request) || + context.Request.Method == HttpMethods.Post; if (!cacheProhibited) { // look for an existing current query with the same parameters diff --git a/src/IRAAS/Startup.cs b/src/IRAAS/Startup.cs index 12d05b1..cc96aba 100644 --- a/src/IRAAS/Startup.cs +++ b/src/IRAAS/Startup.cs @@ -1,5 +1,6 @@ using System; using System.Collections; +using System.Text.Json.Serialization; using DryIoc; using DryIoc.Microsoft.DependencyInjection; using IRAAS.Middleware; @@ -83,7 +84,15 @@ public IServiceProvider ConfigureServices( IServiceCollection services ) { - services.AddMvc(); + services.AddMvc() + .AddJsonOptions( + opts => + { + opts.JsonSerializerOptions.Converters.Add( + new JsonStringEnumConverter() + ); + } + ); var container = new Container( Rules.Default diff --git a/src/IRAAS/Views/Test/Test.cshtml b/src/IRAAS/Views/Test/Test.cshtml index 02cf2c3..11c23d2 100644 --- a/src/IRAAS/Views/Test/Test.cshtml +++ b/src/IRAAS/Views/Test/Test.cshtml @@ -213,7 +213,7 @@ @{ - var optionProps = typeof(UrlImageResizeParameters).GetProperties() + var optionProps = typeof(ImageUrlResizeParameters).GetProperties() .Where(pi => pi.CanWrite); @foreach (var prop in optionProps) { From 19ed452b95cfc14cb8b326a968ced14bce2b8e58 Mon Sep 17 00:00:00 2001 From: Davyd McColl Date: Tue, 22 Sep 2026 16:40:49 +0200 Subject: [PATCH 08/18] :construction: adding opt-in POST support --- src/IRAAS.StressTest/IRAAS.StressTest.csproj | 2 +- .../Controllers/TestImageResizeController.cs | 109 +++-- .../Controllers/TestTestController.cs | 99 ++++- src/IRAAS.Tests/IRAAS.Tests.csproj | 12 +- .../Middleware/TestAuthorizationMiddleware.cs | 378 ++++++++++++++++++ .../Controllers/ImageResizeController.cs | 25 +- src/IRAAS/Controllers/TestController.cs | 22 +- src/IRAAS/IAppSettings.cs | 2 +- src/IRAAS/IRAAS.csproj | 4 +- .../Middleware/AuthorizationMiddleware.cs | 131 ++++++ src/IRAAS/Startup.cs | 1 + src/IRAAS/Views/Test/Test.cshtml | 15 +- 12 files changed, 673 insertions(+), 127 deletions(-) create mode 100644 src/IRAAS.Tests/Middleware/TestAuthorizationMiddleware.cs create mode 100644 src/IRAAS/Middleware/AuthorizationMiddleware.cs diff --git a/src/IRAAS.StressTest/IRAAS.StressTest.csproj b/src/IRAAS.StressTest/IRAAS.StressTest.csproj index fd3608b..f77c72d 100644 --- a/src/IRAAS.StressTest/IRAAS.StressTest.csproj +++ b/src/IRAAS.StressTest/IRAAS.StressTest.csproj @@ -11,7 +11,7 @@ - + diff --git a/src/IRAAS.Tests/Controllers/TestImageResizeController.cs b/src/IRAAS.Tests/Controllers/TestImageResizeController.cs index e4fd2e1..ee8b6b6 100644 --- a/src/IRAAS.Tests/Controllers/TestImageResizeController.cs +++ b/src/IRAAS.Tests/Controllers/TestImageResizeController.cs @@ -1,4 +1,3 @@ -using System; using System.Collections.Generic; using System.IO; using System.Threading.Tasks; @@ -30,7 +29,7 @@ public void Controller_ShouldHaveEmptyRoute() } [TestFixture] - public class Resize : TestBase + public class ResizeByUrl : TestBase { [Test] public void ShouldHaveEmptyRoute() @@ -42,25 +41,6 @@ public void ShouldHaveEmptyRoute() // Assert } - [Test] - public void ShouldThrowImageSourceNotAllowedWhenWhitelistSaysNotAllowed() - { - // Arrange - var whiteList = Substitute.For(); - whiteList.IsAllowed(Arg.Any()).Returns(false); - var sut = Create(whiteList: whiteList); - var options = new ImageUrlResizeParameters() - { - Url = GetRandomHttpUrl() - }; - // Act - Expect(() => sut.ResizeByUrl(options)) - .To.Throw() - .With.Property(e => e.Url) - .Equal.To(options.Url); - // Assert - } - [Test] public async Task ShouldPassOptionsToResizer() { @@ -218,9 +198,13 @@ public async Task ShouldHaveDefaultsFilledAutomatically() ); using var lease = TestEnvironment.BorrowHttpServer(); var server = lease.Instance; - server.ServeFile($"/{imageName}", imageData, "image/png"); + server.ServeFile( + $"/{imageName}", + imageData, + "image/png" + ); var defaults = GetRandom(); - ImageUrlResizeParameters.SetDefaults(defaults); + ImageResizeParameters.SetDefaults(defaults); var options = new ImageUrlResizeParameters() { Url = server.GetFullUrlFor($"/{imageName}") @@ -273,44 +257,51 @@ await Expect(resizer) .To.Equal(expectedMimeType); } } + } - private static ImageResizeController Create( - IImageResizer resizer = null, - IImageMimeTypeProvider mimeTypeProvider = null, - IWhitelist whiteList = null, - IHttpContextAccessor httpContextAccessor = null - ) - { - ImageUrlResizeParameters.SetDefaults(null); - return new ImageResizeController( - resizer ?? Substitute.For(), - mimeTypeProvider ?? CreateFakeMimeTypeProvider(), - whiteList ?? CreateAllowingWhitelist(), - httpContextAccessor ?? CreateFakeHttpContextAccessor() - ); - } + [TestFixture] + public class ResizeByImageData + { + } - private static IHttpContextAccessor CreateFakeHttpContextAccessor() - { - var result = Substitute.For(); - result.HttpContext.Returns(new FakeHttpContext()); - return result; - } + private static ImageResizeController Create( + IImageResizer resizer = null, + IImageMimeTypeProvider mimeTypeProvider = null, + IWhitelist whiteList = null, + IHttpContextAccessor httpContextAccessor = null, + IAppSettings appSettings = null + ) + { + ImageResizeParameters.SetDefaults(null); + return new ImageResizeController( + resizer ?? Substitute.For(), + mimeTypeProvider ?? CreateFakeMimeTypeProvider(), + whiteList ?? CreateAllowingWhitelist(), + httpContextAccessor ?? CreateFakeHttpContextAccessor(), + appSettings ?? Substitute.For() + ); + } - private static IWhitelist CreateAllowingWhitelist() - { - var result = Substitute.For(); - result.IsAllowed(Arg.Any()) - .Returns(true); - return result; - } + private static IHttpContextAccessor CreateFakeHttpContextAccessor() + { + var result = Substitute.For(); + result.HttpContext.Returns(new FakeHttpContext()); + return result; + } - private static IImageMimeTypeProvider CreateFakeMimeTypeProvider() - { - var result = Substitute.For(); - result.DetermineMimeTypeFor(Arg.Any()) - .Returns("image/jpeg"); - return result; - } + private static IWhitelist CreateAllowingWhitelist() + { + var result = Substitute.For(); + result.IsAllowed(Arg.Any()) + .Returns(true); + return result; + } + + private static IImageMimeTypeProvider CreateFakeMimeTypeProvider() + { + var result = Substitute.For(); + result.DetermineMimeTypeFor(Arg.Any()) + .Returns("image/jpeg"); + return result; } -} \ No newline at end of file +} diff --git a/src/IRAAS.Tests/Controllers/TestTestController.cs b/src/IRAAS.Tests/Controllers/TestTestController.cs index b37d8fa..48ae464 100644 --- a/src/IRAAS.Tests/Controllers/TestTestController.cs +++ b/src/IRAAS.Tests/Controllers/TestTestController.cs @@ -1,17 +1,22 @@ using System; +using System.Collections.Generic; +using System.IO; using System.Net.Http; +using System.Threading.Tasks; using IRAAS.Controllers; using IRAAS.ImageProcessing; using Microsoft.AspNetCore.Mvc; using NUnit.Framework; using NSubstitute; +using PeanutButter.TestUtils.AspNetCore.Builders; +using PeanutButter.Utils; namespace IRAAS.Tests.Controllers; [TestFixture] -public class TestTestController: TestBase +public class TestTestController : TestBase { - [TestCase("test")] + [TestCase("")] public void ControllerShouldHaveRoute_(string expected) { // Arrange @@ -21,28 +26,19 @@ public void ControllerShouldHaveRoute_(string expected) // Assert } - [Test] - public void WhenDisabled_Should404() - { - // Arrange - var appSettings = CreateAppSettings(false); - // Act - Expect(() => Create(appSettings)) - .To.Throw(); - // Assert - } - [TestFixture] - public class Test: TestBase + public class Test : TestBase { - [Test] - public void ShouldHaveEmptyRouteForGET() + [TestCase("test")] + public void ShouldHaveRouteForGET_( + string expected + ) { // Arrange // Act Expect(typeof(TestController)) .To.Have.Method(nameof(TestController.Test)) - .With.Route("") + .With.Route(expected) .Supporting(HttpMethod.Get); // Assert } @@ -64,15 +60,75 @@ public void WhenEnabled_ShouldReturnViewResult() } [TestFixture] - public class FileSize: TestBase + public class FileSize : TestBase { + [TestCase("size")] + public void ShouldHaveRoute_( + string expected + ) + { + // Arrange + // Act + Expect(typeof(TestController)) + .To.Have.Method(nameof(TestController.FileSize)) + .With.Route(expected) + .Supporting(HttpMethod.Get); + // Assert + } + [Test] - [Ignore("TODO")] - public void ShouldFetchUsingUrlFetcher() + [Ignore("WIP: fixing an issue in PB where request objects are being clobbered")] + public async Task ShouldFetchUsingUrlFetcher() { // Arrange + var url = GetRandomHttpsUrlWithPath(); + var data = GetRandomBytes(); + var stream = new MemoryStream(data); + var fetchResult = new StreamAndHeaders( + stream, + new Dictionary() + ); + var fetcher = Substitute.For() + .With( + o => o.Fetch(url, Arg.Any>()) + .Returns(_ => fetchResult) + ); + var sut = Create( + Substitute.For(), + fetcher + ); + var headers = GetRandom>(); + Expect(headers) + .Not.To.Be.Empty(); + var req = HttpRequestBuilder.Create() + .WithMethod(HttpMethod.Get) + .WithRandomUrl() + .WithHeaders(headers) + .Build(); + var ctx = ControllerContextBuilder.Create() + .WithRequest(req) + .Build(); + sut.ControllerContext = ctx; + Expect(sut.Request) + .To.Be(ctx.HttpContext.Request); + + // Act + var result = await sut.FileSize(url); + // Assert + Expect(result) + .To.Equal(data.Length); + await Expect(fetcher) + .To.Have.Received(1) + .Fetch( + Arg.Is( + s => s.Equals(url, StringComparison.OrdinalIgnoreCase) + ), + Arg.Is>( + o => o.DeepEquals(headers) + ) + ); } [Test] @@ -83,7 +139,6 @@ public void ShouldReportSizeFromFetchedStream() // Act // Assert } - } private static TestController Create( @@ -103,4 +158,4 @@ private static IAppSettings CreateAppSettings(bool enabled) result.MaxImageFetchTimeInMilliseconds.Returns(10000); return result; } -} +} \ No newline at end of file diff --git a/src/IRAAS.Tests/IRAAS.Tests.csproj b/src/IRAAS.Tests/IRAAS.Tests.csproj index d647360..1ccd9b7 100644 --- a/src/IRAAS.Tests/IRAAS.Tests.csproj +++ b/src/IRAAS.Tests/IRAAS.Tests.csproj @@ -25,12 +25,12 @@ - - - - - - + + + + + + diff --git a/src/IRAAS.Tests/Middleware/TestAuthorizationMiddleware.cs b/src/IRAAS.Tests/Middleware/TestAuthorizationMiddleware.cs new file mode 100644 index 0000000..88bf4d5 --- /dev/null +++ b/src/IRAAS.Tests/Middleware/TestAuthorizationMiddleware.cs @@ -0,0 +1,378 @@ +using System; +using System.Collections.Generic; +using IRAAS.Middleware; +using IRAAS.Security; +using Microsoft.AspNetCore.Http; +using NSubstitute; +using NUnit.Framework; +using PeanutButter.TestUtils.AspNetCore.Builders; +using PeanutButter.TestUtils.AspNetCore.Utils; +using PeanutButter.Utils; + +namespace IRAAS.Tests.Middleware; + +[TestFixture] +public class TestAuthorizationMiddleware +{ + [TestFixture] + public class WhenRequestIsGetRequest + { + [TestFixture] + public class AndIsRequestToTestView + { + [TestFixture] + public class WhenTestViewDisabled + { + [TestCase("/test")] + [TestCase("/size")] + public void ShouldThrowNotImplemented( + string requestPath + ) + { + // Arrange + HttpContext captured = null; + var (ctx, next) = RequestDelegateTestArenaBuilder.Create() + .WithContext( + HttpContextBuilder.Create() + .WithRequestMethod(HttpMethods.Get) + .WithRequestMutator(req => { req.Path = requestPath; }) + .Build() + ).WithDelegateLogic(c => captured = c) + .Build(); + var whitelist = CreateAllowingWhitelist(); + var appSettings = Substitute.For() + .With(o => o.EnableTestPage.Returns(false)); + var sut = Create(appSettings, whitelist); + + // Act + Expect(async () => await sut.InvokeAsync(ctx, next)) + .To.Throw(); + + // Assert + Expect(captured) + .To.Be(null); + } + } + + [TestFixture] + public class WhenTestViewEnabled + { + [TestCase("/test")] + [TestCase("/size")] + public void ShouldContinue( + string requestPath + ) + { + // Arrange + HttpContext captured = null; + var (ctx, next) = RequestDelegateTestArenaBuilder.Create() + .WithContext( + HttpContextBuilder.Create() + .WithRequestMethod(HttpMethods.Get) + .WithRequestMutator(req => { req.Path = requestPath; }) + .Build() + ).WithDelegateLogic(c => captured = c) + .Build(); + var whitelist = CreateAllowingWhitelist(); + var appSettings = Substitute.For() + .With(o => o.EnableTestPage.Returns(true)); + var sut = Create(appSettings, whitelist); + + // Act + Expect(async () => await sut.InvokeAsync(ctx, next)) + .Not.To.Throw(); + + // Assert + Expect(captured) + .To.Be(ctx); + } + } + } + + [TestFixture] + public class AndRequestedUrlIsWhitelisted + { + [Test] + public void ShouldContinue() + { + // Arrange + HttpContext captured = null; + var requestUrlParameter = GetRandomHttpsUrlWithPath(); + var (ctx, next) = RequestDelegateTestArenaBuilder.Create() + .WithContext( + HttpContextBuilder.Create() + .WithRequestMethod(HttpMethods.Get) + .WithRequestQueryParameter( + "url", + requestUrlParameter + ) + .Build() + ).WithDelegateLogic(c => captured = c) + .Build(); + var whitelist = CreateAllowingWhitelist(); + var sut = Create(whitelist: whitelist); + + // Act + sut.InvokeAsync(ctx, next); + + // Assert + Expect(captured) + .To.Be(ctx); + Expect(whitelist) + .To.Have.Received(1) + .IsAllowed(requestUrlParameter); + } + } + + [TestFixture] + public class AndRequestedUrlNotIsWhitelisted + { + [Test] + public void ShouldContinue() + { + // Arrange + HttpContext captured = null; + var requestUrlParameter = GetRandomHttpsUrlWithPath(); + var (ctx, next) = RequestDelegateTestArenaBuilder.Create() + .WithContext( + HttpContextBuilder.Create() + .WithRequestMethod(HttpMethods.Get) + .WithRequestQueryParameter( + "url", + requestUrlParameter + ) + .Build() + ).WithDelegateLogic(c => captured = c) + .Build(); + var whitelist = CreateDisallowingWhitelist(); + var sut = Create(whitelist: whitelist); + + // Act + Expect(async () => await sut.InvokeAsync(ctx, next)) + .To.Throw(); + + // Assert + Expect(captured) + .To.Be(null); + Expect(whitelist) + .To.Have.Received(1) + .IsAllowed(requestUrlParameter); + } + } + } + + [TestFixture] + public class WhenRequestIsNotGetOrPost + { + public static IEnumerable TestCaseGenerator() + { + yield return HttpMethods.Put; + yield return HttpMethods.Patch; + yield return HttpMethods.Connect; + yield return HttpMethods.Delete; + yield return HttpMethods.Head; + yield return HttpMethods.Trace; + } + + [TestCaseSource(nameof(TestCaseGenerator))] + [Test] + public void ShouldThrowNotImplemented( + string method + ) + { + // Arrange + HttpContext captured = null; + var (ctx, next) = RequestDelegateTestArenaBuilder.Create() + .WithContext( + HttpContextBuilder.Create() + .WithRequestMethod(method) + .Build() + ).WithDelegateLogic(c => captured = c) + .Build(); + var sut = Create(); + + // Act + Expect(async () => await sut.InvokeAsync(ctx, next)) + .To.Throw(); + + // Assert + Expect(captured) + .To.Be.Null(); + } + } + + [TestFixture] + public class WhenRequestIsPost + { + [TestFixture] + public class WhenPostIsNotAllowed + { + [Test] + public void ShouldThrowNotImplemented() + { + // Arrange + var appSettings = Substitute.For(); + Expect(appSettings.AllowPostRequests) + .To.Be.False(); + HttpContext captured = null; + var (ctx, next) = RequestDelegateTestArenaBuilder.Create() + .WithContext( + HttpContextBuilder.Create() + .WithRequestMethod(HttpMethods.Post) + .Build() + ).WithDelegateLogic(c => captured = c) + .Build(); + var sut = Create(appSettings); + + // Act + Expect(async () => await sut.InvokeAsync(ctx, next)) + .To.Throw(); + + // Assert + Expect(captured) + .To.Be.Null(); + } + } + + [TestFixture] + public class WhenPostIsAllowed + { + [TestFixture] + public class ButAuthTokensIsEmpty + { + [TestCase("")] + [TestCase(" ")] + [TestCase(null)] + public void ShouldThrowNotImplemented_( + string postTokens + ) + { + // Arrange + var appSettings = Substitute.For() + .With(o => o.AllowPostRequests.Returns(true)) + .With(o => o.PostAuthTokens.Returns(postTokens)); + HttpContext captured = null; + var (ctx, next) = RequestDelegateTestArenaBuilder.Create() + .WithContext( + HttpContextBuilder.Create() + .WithRequestMethod(HttpMethods.Post) + .Build() + ).WithDelegateLogic(c => captured = c) + .Build(); + var sut = Create(appSettings); + + // Act + Expect(async () => await sut.InvokeAsync(ctx, next)) + .To.Throw(); + + // Assert + Expect(captured) + .To.Be.Null(); + } + } + + [TestFixture] + public class AndAuthTokenProvided + { + [TestFixture] + public class ButTokenIsUnknown + { + [Test] + public void ShouldThrowNotImplemented() + { + // Arrange + var invalidToken = $"{Guid.NewGuid()}"; + var appSettings = Substitute.For() + .With(o => o.AllowPostRequests.Returns(true)) + .With(o => o.PostAuthTokens.Returns($"{Guid.NewGuid()},{Guid.NewGuid()}")); + HttpContext captured = null; + var (ctx, next) = RequestDelegateTestArenaBuilder.Create() + .WithContext( + HttpContextBuilder.Create() + .WithRequestMethod(HttpMethods.Post) + .WithRequestHeader( + "Authorization", + $"Bearer {invalidToken}" + ) + .Build() + ).WithDelegateLogic(c => captured = c) + .Build(); + var sut = Create(appSettings); + + // Act + Expect(async () => await sut.InvokeAsync(ctx, next)) + .To.Throw(); + + // Assert + Expect(captured) + .To.Be.Null(); + } + } + + [TestFixture] + public class AndAuthTokenIsKnown + { + [Test] + public void ShouldContinue() + { + // Arrange + var validToken = $"{Guid.NewGuid()}"; + var appSettings = Substitute.For() + .With(o => o.AllowPostRequests.Returns(true)) + .With(o => o.PostAuthTokens.Returns($"{validToken},{Guid.NewGuid()}")); + HttpContext captured = null; + var (ctx, next) = RequestDelegateTestArenaBuilder.Create() + .WithContext( + HttpContextBuilder.Create() + .WithRequestMethod(HttpMethods.Post) + .WithRequestHeader( + "Authorization", + $"Bearer {validToken}" + ) + .Build() + ).WithDelegateLogic(c => captured = c) + .Build(); + var sut = Create(appSettings); + + // Act + Expect(async () => await sut.InvokeAsync(ctx, next)) + .Not.To.Throw(); + + // Assert + Expect(captured) + .To.Be(ctx); + } + } + } + } + } + + private static AuthorizationMiddleware Create( + IAppSettings appSettings = null, + IWhitelist whitelist = null + ) + { + return new( + appSettings ?? Substitute.For(), + whitelist ?? CreateAllowingWhitelist() + ); + } + + private static IWhitelist CreateAllowingWhitelist() + { + return Substitute.For() + .With( + o => o.IsAllowed(Arg.Any()) + .Returns(_ => true) + ); + } + + private static IWhitelist CreateDisallowingWhitelist() + { + return Substitute.For() + .With( + o => o.IsAllowed(Arg.Any()) + .Returns(_ => false) + ); + } +} \ No newline at end of file diff --git a/src/IRAAS/Controllers/ImageResizeController.cs b/src/IRAAS/Controllers/ImageResizeController.cs index 8b772fb..aa8a625 100644 --- a/src/IRAAS/Controllers/ImageResizeController.cs +++ b/src/IRAAS/Controllers/ImageResizeController.cs @@ -1,8 +1,5 @@ using System; -using System.IO; -using System.Text; using System.Threading.Tasks; -using IRAAS.Exceptions; using IRAAS.ImageProcessing; using IRAAS.Security; using Microsoft.AspNetCore.Http; @@ -18,18 +15,21 @@ public class ImageResizeController private readonly IImageMimeTypeProvider _mimeTypeProvider; private readonly IWhitelist _whitelist; private readonly IHttpContextAccessor _httpContextAccessor; + private readonly IAppSettings _appSettings; public ImageResizeController( IImageResizer imageResizer, IImageMimeTypeProvider mimeTypeProvider, IWhitelist whitelist, - IHttpContextAccessor httpContextAccessor + IHttpContextAccessor httpContextAccessor, + IAppSettings appSettings ) { _imageResizer = imageResizer; _mimeTypeProvider = mimeTypeProvider; _whitelist = whitelist; _httpContextAccessor = httpContextAccessor; + _appSettings = appSettings; } [Route("")] @@ -38,11 +38,6 @@ public async Task ResizeByUrl( [FromQuery] ImageUrlResizeParameters resizeParameters = null ) { - if (!_whitelist.IsAllowed(resizeParameters?.Url)) - { - throw new ImageSourceNotAllowedException(resizeParameters?.Url); - } - var result = await _imageResizer.Resize( resizeParameters, _httpContextAccessor.HttpContext!.Request.Headers.ToDictionary() @@ -67,10 +62,11 @@ public async Task ResizeByUrl( // with a known token [Route("")] [HttpPost] - public async Task ResizeByPostData( + public async Task ResizeByImageData( [FromBody] ImageDataResizeParameters resizeParameters ) { + VerifyCanAcceptPostRequest(); ArgumentNullException.ThrowIfNull(resizeParameters); var result = await _imageResizer.Resize( resizeParameters @@ -83,4 +79,13 @@ [FromBody] ImageDataResizeParameters resizeParameters contentType ); } + + // Middleware converts NotImplementedExceptions to 404s + private void VerifyCanAcceptPostRequest() + { + if (!_appSettings.AllowPostRequests) + { + throw new NotImplementedException(); + } + } } \ No newline at end of file diff --git a/src/IRAAS/Controllers/TestController.cs b/src/IRAAS/Controllers/TestController.cs index a898560..9f10179 100644 --- a/src/IRAAS/Controllers/TestController.cs +++ b/src/IRAAS/Controllers/TestController.cs @@ -5,7 +5,7 @@ namespace IRAAS.Controllers; -[Route("test")] +[Route("")] public class TestController : Controller { private readonly IAppSettings _settings; @@ -16,36 +16,26 @@ public TestController( IUrlFetcher fetcher ) { - if (!settings.EnableTestPage) - { - throw new NotImplementedException(); - } - _settings = settings; _fetcher = fetcher; } [ResponseCache(NoStore = true)] - [Route("")] + [Route("test")] [HttpGet] public ActionResult Test() { return View(_settings); } - [Route("")] - [HttpPost] - public async Task FileSize([FromBody] FileSizeRequest req) + [Route("size")] + [HttpGet] + public async Task FileSize([FromQuery] string url) { using var result = await _fetcher.Fetch( - req.Url, + url, Request.Headers.ToDictionary() ); return result.Stream.Length; } - - public class FileSizeRequest - { - public string Url { get; set; } - } } \ No newline at end of file diff --git a/src/IRAAS/IAppSettings.cs b/src/IRAAS/IAppSettings.cs index 81113b7..f463db9 100644 --- a/src/IRAAS/IAppSettings.cs +++ b/src/IRAAS/IAppSettings.cs @@ -87,4 +87,4 @@ public interface IAppSettings bool AllowPostRequests { get; } string PostAuthTokens { get; } -} \ No newline at end of file +} diff --git a/src/IRAAS/IRAAS.csproj b/src/IRAAS/IRAAS.csproj index 85d47cc..d2aed15 100644 --- a/src/IRAAS/IRAAS.csproj +++ b/src/IRAAS/IRAAS.csproj @@ -32,8 +32,8 @@ - - + + diff --git a/src/IRAAS/Middleware/AuthorizationMiddleware.cs b/src/IRAAS/Middleware/AuthorizationMiddleware.cs new file mode 100644 index 0000000..b2def19 --- /dev/null +++ b/src/IRAAS/Middleware/AuthorizationMiddleware.cs @@ -0,0 +1,131 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using IRAAS.Security; +using Microsoft.AspNetCore.Http; +using PeanutButter.Utils; + +namespace IRAAS.Middleware; + +public class AuthorizationMiddleware : IMiddleware +{ + private readonly IAppSettings _appSettings; + private readonly IWhitelist _whitelist; + private readonly HashSet _knownTokens; + + public AuthorizationMiddleware( + IAppSettings appSettings, + IWhitelist whitelist + ) + { + _appSettings = appSettings; + _whitelist = whitelist; + _knownTokens = Split(appSettings.PostAuthTokens ?? "", ",") + .AsHashSet(); + } + + public Task InvokeAsync( + HttpContext context, + RequestDelegate next + ) + { + if (context.Request.Method == HttpMethods.Get) + { + VerifyGetRequestAllowed(context); + return next(context); + } + + if (context.Request.Method != HttpMethods.Post) + { + throw new NotImplementedException(); + } + + VerifyPostRequestAllowed(context); + + // fixme: should verify: + // 1. can post + // 2. post allowed by token + return next(context); + } + + private void VerifyGetRequestAllowed( + HttpContext context + ) + { + if (IsAllowedTestPageRequest(context.Request)) + { + return; + } + + if ( + !context.Request.Query.TryGetValue("url", out var requestedImageUrl) + || requestedImageUrl.IsEmpty() + ) + { + throw new NotImplementedException(); + } + + if (!_whitelist.IsAllowed(requestedImageUrl)) + { + throw new NotImplementedException(); + } + } + + private bool IsAllowedTestPageRequest( + HttpRequest contextRequest + ) + { + if (!_appSettings.EnableTestPage) + { + return false; + } + + if (IsTestPath(contextRequest.Path)) + { + return true; + } + + return false; + } + + private bool IsTestPath(string path) + { + return "/test".Equals(path, StringComparison.OrdinalIgnoreCase) || + "/size".Equals(path, StringComparison.OrdinalIgnoreCase); + } + + private void VerifyPostRequestAllowed( + HttpContext context + ) + { + if (!_appSettings.AllowPostRequests) + { + throw new NotImplementedException(); + } + + var authHeader = context.Request.Headers.Authorization.FirstOrDefault() + ?? throw new NotImplementedException(); + var parts = Split(authHeader, " "); + var authScheme = parts.FirstOrDefault() ?? throw new NotImplementedException(); + var token = parts.Skip(1).FirstOrDefault() ?? throw new NotImplementedException(); + if (!authScheme.Equals("bearer", StringComparison.OrdinalIgnoreCase)) + { + throw new NotImplementedException(); + } + + if (!_knownTokens.Contains(token)) + { + throw new NotImplementedException(); + } + } + + private string[] Split(string str, string delimiter) + { + return (str ?? "") + .Split( + delimiter, + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries + ); + } +} \ No newline at end of file diff --git a/src/IRAAS/Startup.cs b/src/IRAAS/Startup.cs index cc96aba..f42e4a5 100644 --- a/src/IRAAS/Startup.cs +++ b/src/IRAAS/Startup.cs @@ -56,6 +56,7 @@ ILoggerFactory loggerFactory app.UseMiddleware(); app.UseMiddleware(); app.UseMiddleware(); + app.UseMiddleware(); app.UseRouting(); app.UseEndpoints(e => e.MapControllers()); diff --git a/src/IRAAS/Views/Test/Test.cshtml b/src/IRAAS/Views/Test/Test.cshtml index 11c23d2..e808b16 100644 --- a/src/IRAAS/Views/Test/Test.cshtml +++ b/src/IRAAS/Views/Test/Test.cshtml @@ -146,14 +146,9 @@ } var img = change.target; console.log("fetching size of image"); - fetch("", { - method: "POST", - headers: { - "Content-Type": "application/json" - }, - body: JSON.stringify({ - url: img.src - }) + var params = new URLSearchParams({url: img.src}) + fetch(`size?${params}`, { + method: "GET" }).then(function (response) { response.json().then(function (data) { $$("#" + img.id + "-size").innerText = data + " bytes"; @@ -184,10 +179,10 @@ fieldset.classList.add("no-border"); } } - + document.addEventListener("DOMContentLoaded", () => { $$("#iraas-configuration").addEventListener( - "click", + "click", toggleConfigurationVisibility ); }); From 177e0708250022e113fc81f0279e8a1c4a6ea5de Mon Sep 17 00:00:00 2001 From: Davyd McColl Date: Wed, 23 Sep 2026 08:22:26 +0200 Subject: [PATCH 09/18] :white_check_mark: fill out tests left with TODO --- src/IRAAS.StressTest/IRAAS.StressTest.csproj | 2 +- .../Controllers/TestTestController.cs | 50 +++++++++++++++---- src/IRAAS.Tests/IRAAS.Tests.csproj | 13 ++--- src/IRAAS/Controllers/TestController.cs | 1 - src/IRAAS/IRAAS.csproj | 4 +- src/IRAAS/ImageProcessing/StreamAndHeaders.cs | 18 ++++++- 6 files changed, 68 insertions(+), 20 deletions(-) diff --git a/src/IRAAS.StressTest/IRAAS.StressTest.csproj b/src/IRAAS.StressTest/IRAAS.StressTest.csproj index f77c72d..9902519 100644 --- a/src/IRAAS.StressTest/IRAAS.StressTest.csproj +++ b/src/IRAAS.StressTest/IRAAS.StressTest.csproj @@ -11,7 +11,7 @@ - + diff --git a/src/IRAAS.Tests/Controllers/TestTestController.cs b/src/IRAAS.Tests/Controllers/TestTestController.cs index 48ae464..3edb7dd 100644 --- a/src/IRAAS.Tests/Controllers/TestTestController.cs +++ b/src/IRAAS.Tests/Controllers/TestTestController.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.IO.MemoryMappedFiles; using System.Net.Http; using System.Threading.Tasks; using IRAAS.Controllers; @@ -93,10 +94,6 @@ public async Task ShouldFetchUsingUrlFetcher() o => o.Fetch(url, Arg.Any>()) .Returns(_ => fetchResult) ); - var sut = Create( - Substitute.For(), - fetcher - ); var headers = GetRandom>(); Expect(headers) .Not.To.Be.Empty(); @@ -108,7 +105,11 @@ public async Task ShouldFetchUsingUrlFetcher() var ctx = ControllerContextBuilder.Create() .WithRequest(req) .Build(); - sut.ControllerContext = ctx; + var sut = Create( + Substitute.For(), + fetcher, + ctx + ); Expect(sut.Request) .To.Be(ctx.HttpContext.Request); @@ -132,23 +133,54 @@ await Expect(fetcher) } [Test] - [Ignore("TODO")] - public void ShouldReportSizeFromFetchedStream() + public async Task ShouldReportSizeFromFetchedStream() { // Arrange + var data = GetRandomBytes(); + var url = GetRandomHttpsUrl(); + var resource = new StreamAndHeaders(data); + var fetcher = Substitute.For() + .With( + o => o.Fetch(url, Arg.Any>()) + .Returns(_ => resource) + ); + var sut = Create( + Substitute.For(), + fetcher + ); + // Act + var result = await sut.FileSize(url); + // Assert + Expect(result) + .To.Equal(data.Length); + await Expect(fetcher) + .To.Have.Received(1) + .Fetch( + url, + Arg.Is>( + o => o.DeepEquals(sut.Request.Headers.ToDictionary()) + ) + ); } } private static TestController Create( IAppSettings settings, - IUrlFetcher fetcher = null) + IUrlFetcher fetcher = null, + ControllerContext ctx = null + ) { return new TestController( settings, fetcher ?? Substitute.For() - ); + ) + { + ControllerContext = ctx ?? ControllerContextBuilder.Create() + .WithRequestHeader(GetRandomString(10), GetRandomString(10)) + .Build() + }; } private static IAppSettings CreateAppSettings(bool enabled) diff --git a/src/IRAAS.Tests/IRAAS.Tests.csproj b/src/IRAAS.Tests/IRAAS.Tests.csproj index 1ccd9b7..041e222 100644 --- a/src/IRAAS.Tests/IRAAS.Tests.csproj +++ b/src/IRAAS.Tests/IRAAS.Tests.csproj @@ -25,14 +25,15 @@ - - - - - - + + + + + + + diff --git a/src/IRAAS/Controllers/TestController.cs b/src/IRAAS/Controllers/TestController.cs index 9f10179..aa82c06 100644 --- a/src/IRAAS/Controllers/TestController.cs +++ b/src/IRAAS/Controllers/TestController.cs @@ -1,4 +1,3 @@ -using System; using System.Threading.Tasks; using IRAAS.ImageProcessing; using Microsoft.AspNetCore.Mvc; diff --git a/src/IRAAS/IRAAS.csproj b/src/IRAAS/IRAAS.csproj index d2aed15..44a8fab 100644 --- a/src/IRAAS/IRAAS.csproj +++ b/src/IRAAS/IRAAS.csproj @@ -32,8 +32,8 @@ - - + + diff --git a/src/IRAAS/ImageProcessing/StreamAndHeaders.cs b/src/IRAAS/ImageProcessing/StreamAndHeaders.cs index 4f72ae7..81e187f 100644 --- a/src/IRAAS/ImageProcessing/StreamAndHeaders.cs +++ b/src/IRAAS/ImageProcessing/StreamAndHeaders.cs @@ -11,12 +11,28 @@ public class StreamAndHeaders : IDisposable public StreamAndHeaders( Stream stream, - IDictionary headers) + IDictionary headers + ) { Headers = headers; Stream = stream; } + public StreamAndHeaders( + byte[] data + ) : this(data, new Dictionary()) + { + } + + public StreamAndHeaders( + byte[] data, + IDictionary headers + ) + { + Headers = headers; + Stream = new MemoryStream(data); + } + public void Dispose() { Stream?.Dispose(); From a78f6ab6e1626973dacae8b35c0591af5b7a17f7 Mon Sep 17 00:00:00 2001 From: Davyd McColl Date: Wed, 23 Sep 2026 12:30:57 +0200 Subject: [PATCH 10/18] :construction: refactoring from review --- package.json | 2 +- .../Controllers/TestImageResizeController.cs | 119 +++++- .../ImageProcessing/AppSettingsExtensions.cs | 163 +++++++ .../ImageProcessing/TestImageResizer.cs | 60 ++- .../ImageProcessing/TestUrlFetcher.cs | 129 ------ .../TestArgumentNullExceptionMiddleware.cs | 77 ++++ .../Middleware/TestAuthorizationMiddleware.cs | 396 +++++++++++++++++- .../Middleware/TestConcurrencyMiddleware.cs | 115 +++-- .../TestImageProviderErrorMiddleware.cs | 8 +- ...mageSourceNotAllowedExceptionMiddleware.cs | 23 +- .../Security/TestLimitedMemoryStream.cs | 16 + .../Controllers/ImageResizeController.cs | 46 +- src/IRAAS/ImageProcessing/ImageResizer.cs | 13 + .../Middleware/ArgumentExceptionMiddleware.cs | 17 + .../ArgumentNullExceptionMiddleware.cs | 18 + .../Middleware/AuthorizationMiddleware.cs | 76 +++- src/IRAAS/Middleware/ConcurrencyMiddleware.cs | 7 + ...mageSourceNotAllowedExceptionMiddleware.cs | 6 +- src/IRAAS/Security/LimitedMemoryStream.cs | 10 + src/IRAAS/Startup.cs | 2 + src/IRAAS/appsettings.deploy.json | 2 + src/IRAAS/appsettings.json | 2 + 22 files changed, 1057 insertions(+), 250 deletions(-) create mode 100644 src/IRAAS.Tests/ImageProcessing/AppSettingsExtensions.cs create mode 100644 src/IRAAS.Tests/Middleware/TestArgumentNullExceptionMiddleware.cs create mode 100644 src/IRAAS/Middleware/ArgumentExceptionMiddleware.cs create mode 100644 src/IRAAS/Middleware/ArgumentNullExceptionMiddleware.cs diff --git a/package.json b/package.json index ea45dbd..405f278 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "zarro": "cross-env zarro", "prestart": "run-s build", "start": "cd src/IRAAS/bin/Debug/net8.0 && dotnet IRAAS.dll", - "start-dev": "cross-env Settings__AllowInvalidSSLCertificates=true run-s start", + "start-dev": "cross-env Settings__AllowInvalidSSLCertificates=true Settings__AllowPostRequests=true Settings__PostAuthTokens='*' run-s start", "start-stress-test-server": "cd src/IRAAS.StressTest.ImageServer/bin/Debug/net7.0 && dotnet IRAAS.StressTest.ImageServer.dll", "add-ip-to-security-group": "zarro @", "remove-ip-from-security-group": "zarro @", diff --git a/src/IRAAS.Tests/Controllers/TestImageResizeController.cs b/src/IRAAS.Tests/Controllers/TestImageResizeController.cs index ee8b6b6..751caf3 100644 --- a/src/IRAAS.Tests/Controllers/TestImageResizeController.cs +++ b/src/IRAAS.Tests/Controllers/TestImageResizeController.cs @@ -1,13 +1,14 @@ +using System; using System.Collections.Generic; using System.IO; +using System.Net.Http; using System.Threading.Tasks; using IRAAS.Controllers; -using IRAAS.Exceptions; using IRAAS.ImageProcessing; -using IRAAS.Security; using IRAAS.Tests.Fakes; using IRAAS.Tests.ImageProcessing; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; using NUnit.Framework; using NSubstitute; using PeanutButter.TestUtils.AspNetCore.Builders; @@ -32,12 +33,14 @@ public void Controller_ShouldHaveEmptyRoute() public class ResizeByUrl : TestBase { [Test] - public void ShouldHaveEmptyRoute() + public void ShouldHaveEmptyRouteForGetRequest() { // Arrange // Act Expect(typeof(ImageResizeController)) - .To.Have.Route(nameof(ImageResizeController.ResizeByUrl), ""); + .To.Have.Method(nameof(ImageResizeController.ResizeByUrl)) + .With.Route("") + .Supporting(HttpMethod.Get); // Assert } @@ -69,11 +72,15 @@ public async Task ShouldPassCurrentHeadersToResizer() Arg.Any(), Arg.Any>() ).Returns(new StreamAndHeaders(new MemoryStream(), new Dictionary())); + var headerKey = GetRandomString(10); + var headerValue = GetRandomString(10); var headers = new Dictionary() { - [GetRandomString(10)] = GetRandomString(10) + [headerKey] = headerValue }; - var httpContext = new FakeHttpContext(headers); + var httpContext = HttpContextBuilder.Create() + .WithRequestHeader(headerKey, headerValue) + .Build(); var accessor = Substitute.For(); accessor.HttpContext.Returns(httpContext); var options = GetRandom(); @@ -167,7 +174,7 @@ public async Task ShouldSetResponseHeadersFromResizer() headers ) ); - var httpContext = new FakeHttpContext(); + var httpContext = HttpContextBuilder.BuildDefault(); var accessor = Substitute.For(); accessor.HttpContext.Returns(httpContext); var sut = Create(resizer, httpContextAccessor: accessor); @@ -221,7 +228,7 @@ public async Task ShouldHaveDefaultsFilledAutomatically() o => o.DetermineMimeTypeFor(expected.Stream) .Returns(_ => expectedMimeType) ); - var httpContext = HttpContextBuilder.BuildRandom(); + var httpContext = HttpContextBuilder.BuildDefault(); var accessor = Substitute.For() .For(httpContext); var expectedOptions = new ImageUrlResizeParameters(); @@ -262,12 +269,92 @@ await Expect(resizer) [TestFixture] public class ResizeByImageData { + [Test] + public void ShouldHaveEmptyRouteForPostRequest() + { + // Arrange + // Act + Expect(typeof(ImageResizeController)) + .To.Have.Method(nameof(ImageResizeController.ResizeByImageData)) + .With.Route("") + .Supporting(HttpMethod.Post); + // Assert + } + + [Test] + public async Task ShouldResizeImageAndReturnResultWithDeterminedMimeType() + { + // Arrange + var parameters = GetRandom(); + var stream = new MemoryStream(GetRandomBytes()); + var expectedMimeType = GetRandomMimeType(); + var streamAndHeaders = new StreamAndHeaders( + stream, + GetRandom>() + ); + Expect(stream.CanRead) + .To.Be.True(); + Expect(stream.CanWrite) + .To.Be.True(); + var imageResizer = Substitute.For() + .With( + o => o.Resize(Arg.Any()) + .Returns(_ => Task.FromResult(streamAndHeaders)) + ); + var mimeTypeProvider = Substitute.For() + .With( + o => o.DetermineMimeTypeFor(Arg.Any()) + .Returns(_ => expectedMimeType) + ); + var sut = Create(imageResizer, mimeTypeProvider); + + // Act + var result = await sut.ResizeByImageData(parameters); + + // Assert + Expect(result) + .To.Be.An.Instance.Of(); + Expect(result.FileStream) + .To.Be(stream); + Expect(result.ContentType) + .To.Equal(expectedMimeType); + + // verify that the stream was not disposed + // -> the returned FileStreamResult will dispose + // of the internal stream + Expect(stream.CanRead) + .To.Be.True(); + Expect(stream.CanWrite) + .To.Be.True(); + } + + [Test] + public void ShouldThrowArgumentExceptionWhenInputDataExceedsConfiguredMaxInputImageSize() + { + // Arrange + var parameters = new ImageDataResizeParameters() + { + ImageData = GetRandomBytes(1000, 2000) + }; + var appSettings = Substitute.For() + .With(o => o.AllowPostRequests.Returns(true)) + .With(o => o.PostAuthTokens.Returns("*")) + .With(o => o.MaxInputImageSize.Returns(_ => parameters.ImageData.Length - 1)); + var resizer = Substitute.For(); + var sut = Create(resizer, appSettings: appSettings); + + // Act + Expect(async () => await sut.ResizeByImageData(parameters)) + .To.Throw() + .For(nameof(parameters.ImageData)); + + // Assert + } } private static ImageResizeController Create( IImageResizer resizer = null, IImageMimeTypeProvider mimeTypeProvider = null, - IWhitelist whiteList = null, IHttpContextAccessor httpContextAccessor = null, IAppSettings appSettings = null ) @@ -276,24 +363,16 @@ private static ImageResizeController Create( return new ImageResizeController( resizer ?? Substitute.For(), mimeTypeProvider ?? CreateFakeMimeTypeProvider(), - whiteList ?? CreateAllowingWhitelist(), httpContextAccessor ?? CreateFakeHttpContextAccessor(), appSettings ?? Substitute.For() + .WithDefaultSettings() ); } private static IHttpContextAccessor CreateFakeHttpContextAccessor() { var result = Substitute.For(); - result.HttpContext.Returns(new FakeHttpContext()); - return result; - } - - private static IWhitelist CreateAllowingWhitelist() - { - var result = Substitute.For(); - result.IsAllowed(Arg.Any()) - .Returns(true); + result.HttpContext.Returns(HttpContextBuilder.BuildDefault()); return result; } @@ -304,4 +383,4 @@ private static IImageMimeTypeProvider CreateFakeMimeTypeProvider() .Returns("image/jpeg"); return result; } -} +} \ No newline at end of file diff --git a/src/IRAAS.Tests/ImageProcessing/AppSettingsExtensions.cs b/src/IRAAS.Tests/ImageProcessing/AppSettingsExtensions.cs new file mode 100644 index 0000000..96d0759 --- /dev/null +++ b/src/IRAAS.Tests/ImageProcessing/AppSettingsExtensions.cs @@ -0,0 +1,163 @@ +using NSubstitute; +using PeanutButter.Utils; + +namespace IRAAS.Tests.ImageProcessing; + +public static class AppSettingsExtensions +{ + public static IAppSettings WithDefaultSettings( + this IAppSettings appSettings + ) + { + var _40mb = 40 * 1024 * 1024; + return appSettings + .WithMaxInputImageSize(_40mb) + .WithMaxOutputImageSize(_40mb) + // allow a long fetch time so tests don't time out + .WithMaxImageFetchTimeInMilliseconds(10000) + .WithMaxUrlFetchRetries(0) + // don't block anything by default + .WithDomainWhitelist("*") + // enable the test page for testing + .WithTestPageEnabled() + // don't enable keep-alive: let test connections close + .WithConnectionKeepAliveDisabled() + // don't allow invalid ssl certs by default + // -> mirrors most likely deployed config + .WithInvalidSslCertificatesForbidden() + .WithPostRequestsForbidden() + .WithPostAuthTokens(""); + } + + public static IAppSettings WithPostRequestsAllowed( + this IAppSettings appSettings + ) + { + return appSettings.With( + o => o.AllowPostRequests.Returns(true) + ); + } + + public static IAppSettings WithPostRequestsForbidden( + this IAppSettings appSettings + ) + { + return appSettings.With( + o => o.AllowPostRequests.Returns(false) + ); + } + + public static IAppSettings WithPostAuthTokens( + this IAppSettings appSettings, + string tokens + ) + { + return appSettings.With( + o => o.PostAuthTokens.Returns(tokens) + ); + } + + public static IAppSettings WithInvalidSslCertificatesAllowed( + this IAppSettings appSettings + ) + { + return appSettings.With( + o => o.AllowInvalidSslCertificates.Returns(_ => true) + ); + } + + public static IAppSettings WithInvalidSslCertificatesForbidden( + this IAppSettings appSettings + ) + { + return appSettings.With( + o => o.AllowInvalidSslCertificates.Returns(_ => false) + ); + } + + public static IAppSettings WithConnectionKeepAliveEnabled( + this IAppSettings appSettings + ) + { + return appSettings.With( + o => o.EnableConnectionKeepAlive.Returns(_ => true) + ); + } + + public static IAppSettings WithConnectionKeepAliveDisabled( + this IAppSettings appSettings + ) + { + return appSettings.With( + o => o.EnableConnectionKeepAlive.Returns(_ => false) + ); + } + + public static IAppSettings WithMaxInputImageSize( + this IAppSettings appSettings, + int maxBytes + ) + { + return appSettings.With( + o => o.MaxInputImageSize.Returns(_ => maxBytes) + ); + } + + public static IAppSettings WithMaxOutputImageSize( + this IAppSettings appSettings, + int maxBytes + ) + { + return appSettings.With( + o => o.MaxOutputImageSize.Returns(_ => maxBytes) + ); + } + + public static IAppSettings WithMaxImageFetchTimeInMilliseconds( + this IAppSettings appSettings, + int milliseconds + ) + { + return appSettings.With( + o => o.MaxImageFetchTimeInMilliseconds.Returns(_ => milliseconds) + ); + } + + public static IAppSettings WithMaxUrlFetchRetries( + this IAppSettings appSettings, + int maxRetries + ) + { + return appSettings.With( + o => o.MaxUrlFetchRetries.Returns(_ => maxRetries) + ); + } + + public static IAppSettings WithDomainWhitelist( + this IAppSettings appSettings, + string whitelist + ) + { + return appSettings.With( + o => o.DomainWhitelist.Returns(_ => whitelist) + ); + } + + public static IAppSettings WithTestPageEnabled( + this IAppSettings appSettings + ) + { + return appSettings.With( + o => o.EnableTestPage.Returns(_ => true) + ); + } + + public static IAppSettings WithTestPageDisabled( + this IAppSettings appSettings + ) + { + return appSettings.With( + o => o.EnableTestPage.Returns(_ => false) + ); + } +} \ No newline at end of file diff --git a/src/IRAAS.Tests/ImageProcessing/TestImageResizer.cs b/src/IRAAS.Tests/ImageProcessing/TestImageResizer.cs index b6f4b60..c681d6c 100644 --- a/src/IRAAS.Tests/ImageProcessing/TestImageResizer.cs +++ b/src/IRAAS.Tests/ImageProcessing/TestImageResizer.cs @@ -74,7 +74,7 @@ public async Task ShouldGetImageStreamFromImageFetcher() }; // Act await sut.Resize(options, NoHeaders); - + // Assert await Expect(fetcher).To.Have.Received(1) .Fetch(options.Url, Arg.Any>()); @@ -872,13 +872,67 @@ private static IUrlFetcher CreateFetcherFor( } } + [TestFixture] + public class ResizingImageViaPostedData + { + [Test] + public void ShouldThrowForNullResizeParameters() + { + // Arrange + var sut = Create(); + + // Act + Expect(async () => await sut.Resize(null)) + .To.Throw() + .For("resizeParameters"); + + // Assert + } + + [Test] + public void ShouldThrowForNullData() + { + // Arrange + var sut = Create(); + var resizeParameters = new ImageDataResizeParameters(); + Expect(resizeParameters.ImageData) + .To.Be.Null(); + + // Act + Expect(async () => await sut.Resize(resizeParameters)) + .To.Throw() + .For("ImageData"); + + // Assert + } + + [Test] + public void ShouldThrowForEmptyData() + { + // Arrange + var sut = Create(); + var resizeParameters = new ImageDataResizeParameters() + { + ImageData = [] + }; + + // Act + Expect(async () => await sut.Resize(resizeParameters)) + .To.Throw() + .For("ImageData") + .With.Message.Containing("empty"); + + // Assert + } + } + private static ImageResizer Create( - IUrlFetcher fetcher, + IUrlFetcher fetcher = null, IAppSettings appSettings = null ) { return new ImageResizer( - fetcher, + fetcher ?? Substitute.For(), appSettings ?? CreateDefaultAppSettingsWithVerboseEnabled() ); } diff --git a/src/IRAAS.Tests/ImageProcessing/TestUrlFetcher.cs b/src/IRAAS.Tests/ImageProcessing/TestUrlFetcher.cs index 5f315ba..c6a4694 100644 --- a/src/IRAAS.Tests/ImageProcessing/TestUrlFetcher.cs +++ b/src/IRAAS.Tests/ImageProcessing/TestUrlFetcher.cs @@ -833,133 +833,4 @@ private static IUrlFetcher Create( logger ?? Substitute.For>() ); } -} - -public static class AppSettingsExtensions -{ - public static IAppSettings WithDefaultSettings( - this IAppSettings appSettings - ) - { - var _40mb = 40 * 1024 * 1024; - return appSettings - .WithMaxInputImageSize(_40mb) - .WithMaxOutputImageSize(_40mb) - // allow a long fetch time so tests don't time out - .WithMaxImageFetchTimeInMilliseconds(10000) - .WithMaxUrlFetchRetries(0) - // don't block anything by default - .WithDomainWhitelist("*") - // enable the test page for testing - .WithTestPageEnabled() - // don't enable keep-alive: let test connections close - .WithConnectionKeepAliveDisabled() - // don't allow invalid ssl certs by default - // -> mirrors most likely deployed config - .WithInvalidSslCertificatesForbidden(); - } - - public static IAppSettings WithInvalidSslCertificatesAllowed( - this IAppSettings appSettings - ) - { - return appSettings.With( - o => o.AllowInvalidSslCertificates.Returns(_ => true) - ); - } - - public static IAppSettings WithInvalidSslCertificatesForbidden( - this IAppSettings appSettings - ) - { - return appSettings.With( - o => o.AllowInvalidSslCertificates.Returns(_ => false) - ); - } - - public static IAppSettings WithConnectionKeepAliveEnabled( - this IAppSettings appSettings - ) - { - return appSettings.With( - o => o.EnableConnectionKeepAlive.Returns(_ => true) - ); - } - - public static IAppSettings WithConnectionKeepAliveDisabled( - this IAppSettings appSettings - ) - { - return appSettings.With( - o => o.EnableConnectionKeepAlive.Returns(_ => false) - ); - } - - public static IAppSettings WithMaxInputImageSize( - this IAppSettings appSettings, - int maxBytes - ) - { - return appSettings.With( - o => o.MaxInputImageSize.Returns(_ => maxBytes) - ); - } - - public static IAppSettings WithMaxOutputImageSize( - this IAppSettings appSettings, - int maxBytes - ) - { - return appSettings.With( - o => o.MaxOutputImageSize.Returns(_ => maxBytes) - ); - } - - public static IAppSettings WithMaxImageFetchTimeInMilliseconds( - this IAppSettings appSettings, - int milliseconds - ) - { - return appSettings.With( - o => o.MaxImageFetchTimeInMilliseconds.Returns(_ => milliseconds) - ); - } - - public static IAppSettings WithMaxUrlFetchRetries( - this IAppSettings appSettings, - int maxRetries - ) - { - return appSettings.With( - o => o.MaxUrlFetchRetries.Returns(_ => maxRetries) - ); - } - - public static IAppSettings WithDomainWhitelist( - this IAppSettings appSettings, - string whitelist - ) - { - return appSettings.With( - o => o.DomainWhitelist.Returns(_ => whitelist) - ); - } - - public static IAppSettings WithTestPageEnabled( - this IAppSettings appSettings - ) - { - return appSettings.With( - o => o.EnableTestPage.Returns(_ => true) - ); - } - - public static IAppSettings WithTestPageDisabled( - this IAppSettings appSettings - ) - { - return appSettings.With( - o => o.EnableTestPage.Returns(_ => false) - ); - } } \ No newline at end of file diff --git a/src/IRAAS.Tests/Middleware/TestArgumentNullExceptionMiddleware.cs b/src/IRAAS.Tests/Middleware/TestArgumentNullExceptionMiddleware.cs new file mode 100644 index 0000000..02ee0db --- /dev/null +++ b/src/IRAAS.Tests/Middleware/TestArgumentNullExceptionMiddleware.cs @@ -0,0 +1,77 @@ +using System; +using System.Text; +using System.Threading.Tasks; +using IRAAS.Middleware; +using NSubstitute; +using NUnit.Framework; +using PeanutButter.TestUtils.AspNetCore.Builders; +using PeanutButter.Utils; + +namespace IRAAS.Tests.Middleware; + +[TestFixture] +public class TestArgumentNullExceptionMiddleware +{ + [Test] + public async Task ShouldReturnBadRequest() + { + // Arrange + var sut = Create(); + var context = HttpContextBuilder.BuildDefault(); + var paramName = GetRandomString(); + + // Act + await sut.InvokeAsync( + context, + _ => Task.FromException(new ArgumentNullException(paramName)) + ); + + // Assert + Expect(context.Response.StatusCode) + .To.Equal(400); + Expect( + await context.Response.Body.ReadAllTextAsync() + ).To.Equal(paramName); + } + + private static ArgumentNullExceptionMiddleware Create() + { + return new ArgumentNullExceptionMiddleware( + Substitute.For() + ); + } +} + +[TestFixture] +public class TestArgumentExceptionMiddleware +{ + [Test] + public async Task ShouldReturnBadRequest() + { + // Arrange + var sut = Create(); + var context = HttpContextBuilder.BuildDefault(); + var paramName = GetRandomString(); + var message = GetRandomWords(); + + // Act + await sut.InvokeAsync( + context, + _ => Task.FromException(new ArgumentException(message, paramName)) + ); + + // Assert + Expect(context.Response.StatusCode) + .To.Equal(400); + Expect( + await context.Response.Body.ReadAllTextAsync() + ).To.Equal($"{message} (Parameter '{paramName}')"); + } + + private static ArgumentExceptionMiddleware Create() + { + return new ArgumentExceptionMiddleware( + Substitute.For() + ); + } +} \ No newline at end of file diff --git a/src/IRAAS.Tests/Middleware/TestAuthorizationMiddleware.cs b/src/IRAAS.Tests/Middleware/TestAuthorizationMiddleware.cs index 88bf4d5..f0083a8 100644 --- a/src/IRAAS.Tests/Middleware/TestAuthorizationMiddleware.cs +++ b/src/IRAAS.Tests/Middleware/TestAuthorizationMiddleware.cs @@ -1,7 +1,9 @@ using System; using System.Collections.Generic; +using IRAAS.Exceptions; using IRAAS.Middleware; using IRAAS.Security; +using IRAAS.Tests.ImageProcessing; using Microsoft.AspNetCore.Http; using NSubstitute; using NUnit.Framework; @@ -35,13 +37,14 @@ string requestPath .WithContext( HttpContextBuilder.Create() .WithRequestMethod(HttpMethods.Get) - .WithRequestMutator(req => { req.Path = requestPath; }) + .WithRequestPath(requestPath) + .WithRequestQueryParameter("url", GetRandomHttpsUrlWithPath()) .Build() ).WithDelegateLogic(c => captured = c) .Build(); var whitelist = CreateAllowingWhitelist(); var appSettings = Substitute.For() - .With(o => o.EnableTestPage.Returns(false)); + .WithTestPageDisabled(); var sut = Create(appSettings, whitelist); // Act @@ -52,14 +55,48 @@ string requestPath Expect(captured) .To.Be(null); } + + [TestFixture] + public class AndIsGivenInvalidUrl + { + [TestCase("/test")] + [TestCase("/size")] + public void ShouldThrowNotImplemented( + string requestPath + ) + { + // Arrange + HttpContext captured = null; + var (ctx, next) = RequestDelegateTestArenaBuilder.Create() + .WithContext( + HttpContextBuilder.Create() + .WithRequestMethod(HttpMethods.Get) + .WithRequestPath(requestPath) + .WithRequestQueryParameter("url", "not a valid url") + .Build() + ).WithDelegateLogic(c => captured = c) + .Build(); + var whitelist = CreateAllowingWhitelist(); + var appSettings = Substitute.For() + .WithTestPageDisabled(); + var sut = Create(appSettings, whitelist); + + // Act + Expect(async () => await sut.InvokeAsync(ctx, next)) + .To.Throw(); + + // Assert + Expect(captured) + .To.Be(null); + } + } } [TestFixture] public class WhenTestViewEnabled { [TestCase("/test")] - [TestCase("/size")] - public void ShouldContinue( + public void ShouldContinueToTestPage( string requestPath ) { @@ -69,13 +106,14 @@ string requestPath .WithContext( HttpContextBuilder.Create() .WithRequestMethod(HttpMethods.Get) - .WithRequestMutator(req => { req.Path = requestPath; }) + .WithRequestPath(requestPath) .Build() ).WithDelegateLogic(c => captured = c) .Build(); var whitelist = CreateAllowingWhitelist(); var appSettings = Substitute.For() - .With(o => o.EnableTestPage.Returns(true)); + .WithDefaultSettings() + .WithTestPageEnabled(); var sut = Create(appSettings, whitelist); // Act @@ -86,6 +124,76 @@ string requestPath Expect(captured) .To.Be(ctx); } + + [TestFixture] + public class WhenHaveValidUrlParameter + { + [TestCase("/size")] + public void ShouldContinueToSizePage( + string requestPath + ) + { + // Arrange + HttpContext captured = null; + var (ctx, next) = RequestDelegateTestArenaBuilder.Create() + .WithContext( + HttpContextBuilder.Create() + .WithRequestMethod(HttpMethods.Get) + .WithRequestPath(requestPath) + .WithRequestQueryParameter("url", GetRandomHttpsUrlWithPath()) + .Build() + ).WithDelegateLogic(c => captured = c) + .Build(); + var whitelist = CreateAllowingWhitelist(); + var appSettings = Substitute.For() + .WithDefaultSettings() + .WithTestPageEnabled(); + var sut = Create(appSettings, whitelist); + + // Act + Expect(async () => await sut.InvokeAsync(ctx, next)) + .Not.To.Throw(); + + // Assert + Expect(captured) + .To.Be(ctx); + } + } + + [TestFixture] + public class WhenHaveInvalidUrlParameter + { + [TestCase("/size")] + public void ShouldThrowInvalidProcessingOptions( + string requestPath + ) + { + // Arrange + HttpContext captured = null; + var (ctx, next) = RequestDelegateTestArenaBuilder.Create() + .WithContext( + HttpContextBuilder.Create() + .WithRequestMethod(HttpMethods.Get) + .WithRequestPath(requestPath) + .WithRequestQueryParameter("url", "not a valid url") + .Build() + ).WithDelegateLogic(c => captured = c) + .Build(); + var whitelist = CreateAllowingWhitelist(); + var appSettings = Substitute.For() + .WithDefaultSettings() + .WithTestPageEnabled(); + var sut = Create(appSettings, whitelist); + + // Act + Expect(async () => await sut.InvokeAsync(ctx, next)) + .To.Throw(); + + // Assert + Expect(captured) + .To.Be.Null(); + } + } } } @@ -102,15 +210,18 @@ public void ShouldContinue() .WithContext( HttpContextBuilder.Create() .WithRequestMethod(HttpMethods.Get) - .WithRequestQueryParameter( - "url", - requestUrlParameter - ) + .WithRequestQueryParameter("url", requestUrlParameter) .Build() ).WithDelegateLogic(c => captured = c) .Build(); var whitelist = CreateAllowingWhitelist(); - var sut = Create(whitelist: whitelist); + var appSettings = Substitute.For() + .WithDefaultSettings() + .WithTestPageEnabled(); + var sut = Create( + appSettings, + whitelist + ); // Act sut.InvokeAsync(ctx, next); @@ -137,19 +248,22 @@ public void ShouldContinue() .WithContext( HttpContextBuilder.Create() .WithRequestMethod(HttpMethods.Get) - .WithRequestQueryParameter( - "url", - requestUrlParameter - ) + .WithRequestQueryParameter("url", requestUrlParameter) .Build() ).WithDelegateLogic(c => captured = c) .Build(); var whitelist = CreateDisallowingWhitelist(); - var sut = Create(whitelist: whitelist); + var appSettings = Substitute.For() + .WithDefaultSettings() + .WithTestPageEnabled(); + var sut = Create( + appSettings, + whitelist + ); // Act Expect(async () => await sut.InvokeAsync(ctx, next)) - .To.Throw(); + .To.Throw(); // Assert Expect(captured) @@ -159,6 +273,73 @@ public void ShouldContinue() .IsAllowed(requestUrlParameter); } } + + [TestFixture] + public class AndRequestUrlIsInvalid + { + [Test] + public void ShouldThrowInvalidProcessingOptions() + { + // Arrange + HttpContext captured = null; + var requestUrlParameter = "not a valid url"; + var (ctx, next) = RequestDelegateTestArenaBuilder.Create() + .WithContext( + HttpContextBuilder.Create() + .WithRequestMethod(HttpMethods.Get) + .WithRequestQueryParameter("url", requestUrlParameter) + .Build() + ).WithDelegateLogic(c => captured = c) + .Build(); + var whitelist = CreateDisallowingWhitelist(); + var appSettings = Substitute.For() + .WithDefaultSettings() + .WithTestPageEnabled(); + var sut = Create( + appSettings, + whitelist + ); + + // Act + Expect(async () => await sut.InvokeAsync(ctx, next)) + .To.Throw(); + + // Assert + Expect(captured) + .To.Be.Null(); + } + } + + [TestFixture] + public class AndNoUrlParameterProvided + { + [Test] + public void ShouldThrowNotImplemented() + { + // Arrange + HttpContext captured = null; + var (ctx, next) = RequestDelegateTestArenaBuilder.Create() + .WithContext( + HttpContextBuilder.Create() + .WithRequestMethod(HttpMethods.Get) + .Build() + ).WithDelegateLogic(c => captured = c) + .Build(); + var whitelist = CreateAllowingWhitelist(); + var sut = Create(whitelist: whitelist); + + // Act + Expect(async () => await sut.InvokeAsync(ctx, next)) + .To.Throw(); + + // Assert + Expect(captured) + .To.Be.Null(); + Expect(whitelist) + .Not.To.Have.Received() + .IsAllowed(Arg.Any()); + } + } } [TestFixture] @@ -175,7 +356,6 @@ public static IEnumerable TestCaseGenerator() } [TestCaseSource(nameof(TestCaseGenerator))] - [Test] public void ShouldThrowNotImplemented( string method ) @@ -211,7 +391,8 @@ public class WhenPostIsNotAllowed public void ShouldThrowNotImplemented() { // Arrange - var appSettings = Substitute.For(); + var appSettings = Substitute.For() + .WithDefaultSettings(); Expect(appSettings.AllowPostRequests) .To.Be.False(); HttpContext captured = null; @@ -238,7 +419,7 @@ public void ShouldThrowNotImplemented() public class WhenPostIsAllowed { [TestFixture] - public class ButAuthTokensIsEmpty + public class ButConfiguredAuthTokensIsEmpty { [TestCase("")] [TestCase(" ")] @@ -249,13 +430,17 @@ string postTokens { // Arrange var appSettings = Substitute.For() - .With(o => o.AllowPostRequests.Returns(true)) - .With(o => o.PostAuthTokens.Returns(postTokens)); + .WithPostRequestsAllowed() + .WithPostAuthTokens(postTokens); HttpContext captured = null; var (ctx, next) = RequestDelegateTestArenaBuilder.Create() .WithContext( HttpContextBuilder.Create() .WithRequestMethod(HttpMethods.Post) + .WithRequestHeader( + "Authorization", + $"Bearer {Guid.NewGuid()}" + ) .Build() ).WithDelegateLogic(c => captured = c) .Build(); @@ -271,9 +456,137 @@ string postTokens } } + [TestFixture] + public class WhenAllTokensAllowed + { + [Test] + public void ShouldContinueWhenNoTokenProvided() + { + // Arrange + var appSettings = Substitute.For() + .With(o => o.AllowPostRequests.Returns(true)) + .With(o => o.PostAuthTokens.Returns("*")); + HttpContext captured = null; + var (ctx, next) = RequestDelegateTestArenaBuilder.Create() + .WithContext( + HttpContextBuilder.Create() + .WithRequestMethod(HttpMethods.Post) + .Build() + ).WithDelegateLogic(c => captured = c) + .Build(); + var sut = Create(appSettings); + + // Act + Expect(async () => await sut.InvokeAsync(ctx, next)) + .Not.To.Throw(); + + // Assert + Expect(captured) + .To.Be(ctx); + } + + [Test] + public void ShouldContinueWhenAnyTokenProvided() + { + // Arrange + var appSettings = Substitute.For() + .With(o => o.AllowPostRequests.Returns(true)) + .With(o => o.PostAuthTokens.Returns("*")); + HttpContext captured = null; + var (ctx, next) = RequestDelegateTestArenaBuilder.Create() + .WithContext( + HttpContextBuilder.Create() + .WithRequestMethod(HttpMethods.Post) + .WithRequestHeader( + "Authorization", + $"Bearer {Guid.NewGuid()}" + ) + .Build() + ).WithDelegateLogic(c => captured = c) + .Build(); + var sut = Create(appSettings); + + // Act + Expect(async () => await sut.InvokeAsync(ctx, next)) + .Not.To.Throw(); + + // Assert + Expect(captured) + .To.Be(ctx); + } + } + [TestFixture] public class AndAuthTokenProvided { + [TestFixture] + public class ButNoAuthorizationHeaderSent + { + [Test] + public void ShouldThrowNotImplemented() + { + // Arrange + var appSettings = Substitute.For() + .With(o => o.AllowPostRequests.Returns(true)) + .With(o => o.PostAuthTokens.Returns($"{Guid.NewGuid()}")); + HttpContext captured = null; + var (ctx, next) = RequestDelegateTestArenaBuilder.Create() + .WithContext( + HttpContextBuilder.Create() + .WithRequestMethod(HttpMethods.Post) + .Build() + ).WithDelegateLogic(c => captured = c) + .Build(); + var sut = Create(appSettings); + + // Act + Expect(async () => await sut.InvokeAsync(ctx, next)) + .To.Throw(); + + // Assert + Expect(captured) + .To.Be.Null(); + } + } + + [TestFixture] + public class ButAuthSchemeIsNotBearer + { + [TestCase("Basic")] + [TestCase("Digest")] + public void ShouldThrowNotImplemented_( + string scheme + ) + { + // Arrange + var validToken = $"{Guid.NewGuid()}"; + var appSettings = Substitute.For() + .With(o => o.AllowPostRequests.Returns(true)) + .With(o => o.PostAuthTokens.Returns(validToken)); + HttpContext captured = null; + var (ctx, next) = RequestDelegateTestArenaBuilder.Create() + .WithContext( + HttpContextBuilder.Create() + .WithRequestMethod(HttpMethods.Post) + .WithRequestHeader( + "Authorization", + $"{scheme} {validToken}" + ) + .Build() + ).WithDelegateLogic(c => captured = c) + .Build(); + var sut = Create(appSettings); + + // Act + Expect(async () => await sut.InvokeAsync(ctx, next)) + .To.Throw(); + + // Assert + Expect(captured) + .To.Be.Null(); + } + } + [TestFixture] public class ButTokenIsUnknown { @@ -307,6 +620,41 @@ public void ShouldThrowNotImplemented() Expect(captured) .To.Be.Null(); } + + [TestFixture] + public class WhenAllTokensAllowed + { + [Test] + public void ShouldContinue() + { + // Arrange + var token = $"{Guid.NewGuid()}"; + var appSettings = Substitute.For() + .With(o => o.AllowPostRequests.Returns(true)) + .With(o => o.PostAuthTokens.Returns("*")); + HttpContext captured = null; + var (ctx, next) = RequestDelegateTestArenaBuilder.Create() + .WithContext( + HttpContextBuilder.Create() + .WithRequestMethod(HttpMethods.Post) + .WithRequestHeader( + "Authorization", + $"Bearer {token}" + ) + .Build() + ).WithDelegateLogic(c => captured = c) + .Build(); + var sut = Create(appSettings); + + // Act + Expect(async () => await sut.InvokeAsync(ctx, next)) + .Not.To.Throw(); + + // Assert + Expect(captured) + .To.Be(ctx); + } + } } [TestFixture] @@ -353,7 +701,9 @@ private static AuthorizationMiddleware Create( ) { return new( - appSettings ?? Substitute.For(), + appSettings ?? Substitute.For() + .WithDefaultSettings() + .WithTestPageDisabled(), whitelist ?? CreateAllowingWhitelist() ); } diff --git a/src/IRAAS.Tests/Middleware/TestConcurrencyMiddleware.cs b/src/IRAAS.Tests/Middleware/TestConcurrencyMiddleware.cs index acbe882..5e3d9a6 100644 --- a/src/IRAAS.Tests/Middleware/TestConcurrencyMiddleware.cs +++ b/src/IRAAS.Tests/Middleware/TestConcurrencyMiddleware.cs @@ -4,11 +4,11 @@ using System.Threading; using System.Threading.Tasks; using IRAAS.Middleware; -using IRAAS.Tests.Fakes; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using NUnit.Framework; using NSubstitute; +using PeanutButter.TestUtils.AspNetCore.Builders; using PeanutButter.Utils; namespace IRAAS.Tests.Middleware; @@ -24,7 +24,7 @@ public class WhenNoConcurrentRequestMatchingQueryString : TestBase public async Task ShouldRunNext() { // Arrange - var context = new FakeHttpContext(); + var context = HttpContextBuilder.BuildDefault(); var queryString = "?url=http://foo.bar"; context.Request.QueryString = new QueryString(queryString); var invoked = false; @@ -61,8 +61,8 @@ public static IEnumerable TestRange() public void ShouldOnlyCallNextOnce() { // Arrange - var context1 = new FakeHttpContext(); - var context2 = new FakeHttpContext(); + var context1 = HttpContextBuilder.BuildDefault(); + var context2 = HttpContextBuilder.BuildDefault(); var queryString = "?url=http://foo.bar"; context1.Request.QueryString = new QueryString(queryString); context2.Request.QueryString = new QueryString(queryString); @@ -119,9 +119,9 @@ public void ShouldOnlyCallNextOnce() public void ShouldNotEjectAnInProgressCacheableRequestWhenAConcurrentNoStoreRequestCompletes() { // Arrange - var context1 = new FakeHttpContext(); - var context2 = new FakeHttpContext(); - var context3 = new FakeHttpContext(); + var context1 = HttpContextBuilder.BuildDefault(); + var context2 = HttpContextBuilder.BuildDefault(); + var context3 = HttpContextBuilder.BuildDefault(); var queryString = "?url=http://foo.bar"; context1.Request.QueryString = new QueryString(queryString); context2.Request.QueryString = new QueryString(queryString); @@ -217,13 +217,82 @@ public class WhenRequestIncludesHeader_CacheControlNoStore public void ShouldCallNextForEachRequest() { // Arrange - var context1 = new FakeHttpContext(); - var context2 = new FakeHttpContext(); - var queryString = "?url=http://foo.bar"; - context1.Request.QueryString = new QueryString(queryString); - context1.Request.Headers.Append("Cache-Control", "no-store"); - context2.Request.QueryString = new QueryString(queryString); - context2.Request.Headers.Append("Cache-Control", "no-store"); + var requestedUrl = GetRandomHttpsUrlWithPath(); + var context1 = HttpContextBuilder.Create() + .WithRequestQueryParameter("url", requestedUrl) + .WithRequestHeader("Cache-Control", "no-store") + .Build(); + var context2 = HttpContextBuilder.Create() + .WithRequestQueryParameter("url", requestedUrl) + .WithRequestHeader("Cache-Control", "no-store") + .Build(); + var invoked = 0; + var startBarrier = new Barrier(3); + var completionBarrier = new Barrier(3); + var next1 = new Func( + ctx => + { + startBarrier.SignalAndWait(); + Thread.Sleep(1000); + invoked++; + return Task.CompletedTask; + } + ); + var next2 = new Func( + ctx => + { + Thread.Sleep(1000); + invoked++; + return Task.CompletedTask; + } + ); + var appSettings = CreateAppSettings(1, true); + + var sut = Create(appSettings); + // Act +// #pragma warning disable 4014 + Task.Run(async () => { + await sut.InvokeAsync(context1, new RequestDelegate(next1)); + completionBarrier.SignalAndWait(); + }); + Task.Run( + async () => + { + startBarrier.SignalAndWait(); + await sut.InvokeAsync(context2, new RequestDelegate(next2)); + completionBarrier.SignalAndWait(); + } + ); +// #pragma warning restore 4014 + + var timeout = 10000; + var started = startBarrier.SignalAndWait(timeout); + var completed = completionBarrier.SignalAndWait(timeout); + // Assert + Expect(started) + .To.Be.True("Should have started"); + Expect(completed) + .To.Be.True("Should have completed"); + Expect(invoked) + .To.Equal(2); + } + } + [TestFixture] + public class WhenRequestIsSizeRequest + { + [Test] + public void ShouldCallNextForEachRequest() + { + // Arrange + var requestedUrl = GetRandomHttpsUrlWithPath(); + var context1 = HttpContextBuilder.Create() + .WithRequestPath("/size") + .WithRequestQueryParameter("url", requestedUrl) + .Build(); + var context2 = HttpContextBuilder.Create() + .WithRequestPath("/size") + .WithRequestQueryParameter("url", requestedUrl) + .Build(); var invoked = 0; var startBarrier = new Barrier(3); var completionBarrier = new Barrier(3); @@ -283,8 +352,8 @@ public class WhenRequestIncludesHeader_CacheControlWithMultipleDirectivesIncludi public void ShouldTreatTheRequestAsNoStore() { // Arrange - var context1 = new FakeHttpContext(); - var context2 = new FakeHttpContext(); + var context1 = HttpContextBuilder.BuildDefault(); + var context2 = HttpContextBuilder.BuildDefault(); var queryString = "?url=http://foo.bar"; context1.Request.QueryString = new QueryString(queryString); context1.Request.Headers.Append("Cache-Control", "no-cache, no-store"); @@ -350,8 +419,8 @@ public void ShouldTreatTheRequestAsNoStore() public void ShouldHaveTheSameResult() { // Arrange - var context1 = new FakeHttpContext(); - var context2 = new FakeHttpContext(); + var context1 = HttpContextBuilder.BuildDefault(); + var context2 = HttpContextBuilder.BuildDefault(); context1.Response.Body = new MemoryStream(); context2.Response.Body = new MemoryStream(); var queryString = "?url=http://foo.bar"; @@ -459,13 +528,9 @@ public void ShouldLimitConcurrencyPerConfiguration() private static HttpContext CreateContext() { - return new FakeHttpContext() - { - Request = - { - QueryString = new QueryString($"?url={GetRandomString(10)}") - } - }; + return HttpContextBuilder.Create() + .WithRequestQueryParameter("url", GetRandomString(10)) + .Build(); } [TestFixture] diff --git a/src/IRAAS.Tests/Middleware/TestImageProviderErrorMiddleware.cs b/src/IRAAS.Tests/Middleware/TestImageProviderErrorMiddleware.cs index 74c1b85..fc3e4a6 100644 --- a/src/IRAAS.Tests/Middleware/TestImageProviderErrorMiddleware.cs +++ b/src/IRAAS.Tests/Middleware/TestImageProviderErrorMiddleware.cs @@ -4,8 +4,8 @@ using System.Threading.Tasks; using IRAAS.ImageProcessing; using IRAAS.Middleware; -using IRAAS.Tests.Fakes; using NUnit.Framework; +using PeanutButter.TestUtils.AspNetCore.Builders; using NSubstitute; using PeanutButter.Utils; @@ -23,7 +23,7 @@ public async Task ShouldNotInterfereWithTheResponse() // Arrange var sut = Create(); var expected = GetRandomInt(200, 299); - var context = new FakeHttpContext(); + var context = HttpContextBuilder.BuildDefault(); // Act await sut.InvokeAsync( context, @@ -46,7 +46,7 @@ public void ShouldNotInterfere() // Arrange var sut = Create(); var expected = GetRandomInt(200, 299); - var context = new FakeHttpContext(); + var context = HttpContextBuilder.BuildDefault(); var ex = GetRandomFrom( new Exception[] { @@ -75,7 +75,7 @@ public async Task ShouldSetResultStatusCodeToUpstreamCodeWhenAvailable() { // Arrange var sut = Create(); - var context = new FakeHttpContext(); + var context = HttpContextBuilder.BuildDefault(); var url = GetRandomHttpUrl(); var expectedResponseHeader = GetRandomString(1); var expectedResponseHeaderValue = GetRandomString(1); diff --git a/src/IRAAS.Tests/Middleware/TestImageSourceNotAllowedExceptionMiddleware.cs b/src/IRAAS.Tests/Middleware/TestImageSourceNotAllowedExceptionMiddleware.cs index f49a8e7..eba6091 100644 --- a/src/IRAAS.Tests/Middleware/TestImageSourceNotAllowedExceptionMiddleware.cs +++ b/src/IRAAS.Tests/Middleware/TestImageSourceNotAllowedExceptionMiddleware.cs @@ -3,18 +3,18 @@ using System.Threading.Tasks; using IRAAS.Exceptions; using IRAAS.Middleware; -using IRAAS.Tests.Fakes; using NSubstitute; using NUnit.Framework; +using PeanutButter.TestUtils.AspNetCore.Builders; using PeanutButter.Utils; namespace IRAAS.Tests.Middleware; [TestFixture] -public class TestImageSourceNotAllowedExceptionMiddleware: TestBase +public class TestImageSourceNotAllowedExceptionMiddleware : TestBase { [TestFixture] - public class WhenNoExceptionThrown: TestBase + public class WhenNoExceptionThrown : TestBase { [Test] public async Task ShouldNotInterfereWithTheResponse() @@ -22,15 +22,12 @@ public async Task ShouldNotInterfereWithTheResponse() // Arrange var sut = Create(); var expected = GetRandomInt(200, 299); - var context = new FakeHttpContext(); + var context = HttpContextBuilder.BuildDefault(); // Act await sut.InvokeAsync( context, ctx => Task.Run( - () => - { - ctx.Response.StatusCode = expected; - } + () => { ctx.Response.StatusCode = expected; } ) ); // Assert @@ -40,7 +37,7 @@ await sut.InvokeAsync( } [TestFixture] - public class WhenAnotherExceptionIsThrown: TestBase + public class WhenAnotherExceptionIsThrown : TestBase { [Test] public void ShouldNotInterfere() @@ -48,7 +45,7 @@ public void ShouldNotInterfere() // Arrange var sut = Create(); var expected = GetRandomInt(200, 299); - var context = new FakeHttpContext(); + var context = HttpContextBuilder.BuildDefault(); var ex = GetRandomFrom( new Exception[] { @@ -70,14 +67,14 @@ public void ShouldNotInterfere() } [TestFixture] - public class WhenImageSourceNotAllowedExceptionThrown: TestBase + public class WhenImageSourceNotAllowedExceptionThrown : TestBase { [Test] public async Task ShouldSetResultStatusCodeTo_403() { // Arrange var sut = Create(); - var context = new FakeHttpContext(); + var context = HttpContextBuilder.BuildDefault(); var expected = 403; var url = GetRandomHttpUrl(); Expect(context.Response.StatusCode) @@ -93,7 +90,7 @@ await sut.InvokeAsync( context.Response.Body.Rewind(); Expect( Encoding.UTF8.GetString( - context.Response.Body.ReadAllBytes() + await context.Response.Body.ReadAllBytesAsync() ) ).To.Contain(url); } diff --git a/src/IRAAS.Tests/Security/TestLimitedMemoryStream.cs b/src/IRAAS.Tests/Security/TestLimitedMemoryStream.cs index a7435a5..8c55111 100644 --- a/src/IRAAS.Tests/Security/TestLimitedMemoryStream.cs +++ b/src/IRAAS.Tests/Security/TestLimitedMemoryStream.cs @@ -169,6 +169,22 @@ public void SeekFromCurrentPastLimit_ShouldThrow() } } + [Test] + public void ShouldDisposeUnderlyingStreamWhenDisposed() + { + // Arrange + var data = GetRandomBytes(); + var sut = Create(data.Length); + sut.Write(data, 0, data.Length); + // Act + sut.Dispose(); + + // Assert + var target = new byte[10]; + Expect(() => sut.Read(target, 0, 10)) + .To.Throw(); + } + private static LimitedMemoryStream Create( long? maxSize = null) { diff --git a/src/IRAAS/Controllers/ImageResizeController.cs b/src/IRAAS/Controllers/ImageResizeController.cs index aa8a625..af97a85 100644 --- a/src/IRAAS/Controllers/ImageResizeController.cs +++ b/src/IRAAS/Controllers/ImageResizeController.cs @@ -13,21 +13,18 @@ public class ImageResizeController { private readonly IImageResizer _imageResizer; private readonly IImageMimeTypeProvider _mimeTypeProvider; - private readonly IWhitelist _whitelist; private readonly IHttpContextAccessor _httpContextAccessor; private readonly IAppSettings _appSettings; public ImageResizeController( IImageResizer imageResizer, IImageMimeTypeProvider mimeTypeProvider, - IWhitelist whitelist, IHttpContextAccessor httpContextAccessor, IAppSettings appSettings ) { _imageResizer = imageResizer; _mimeTypeProvider = mimeTypeProvider; - _whitelist = whitelist; _httpContextAccessor = httpContextAccessor; _appSettings = appSettings; } @@ -55,19 +52,39 @@ public async Task ResizeByUrl( ); } - // Experimental code ahead! - // TODO: add appsetting to enable POST resizing (defaulted false) - // TODO: add appsetting to hold a list of tokens which are authorised to POST - // TODO: add precursor to block the request if it doesn't contain an auth header - // with a known token + /// + /// this method is only reachable if allowed by AuthorizationMiddleware: + /// - appsettings must have AllowPostRequest set to "true" + /// - appsettings must either have tokens set in PostAuthTokens (comma-separated string) + /// or PostAuthTokens can equal "*" for open posting + /// + /// + /// + /// [Route("")] [HttpPost] public async Task ResizeByImageData( [FromBody] ImageDataResizeParameters resizeParameters ) { - VerifyCanAcceptPostRequest(); - ArgumentNullException.ThrowIfNull(resizeParameters); + if ((resizeParameters?.ImageData?.Length ?? 0) > _appSettings.MaxInputImageSize) + { + // The max input size check is done here rather than in + // middleware because otherwise the model would be deserialized + // twice - once in the middleware, and again in asp.net model-binding + // to get the model here. + throw new ArgumentException( + "Input image size exceeds allowed size", + nameof( + ImageDataResizeParameters.ImageData + ) + ); + } + + // intentionally do not dispose of the result here + // -> the stream is the bit that needs disposal + // and asp.net should dispose of it when finishing + // the request var result = await _imageResizer.Resize( resizeParameters ); @@ -79,13 +96,4 @@ [FromBody] ImageDataResizeParameters resizeParameters contentType ); } - - // Middleware converts NotImplementedExceptions to 404s - private void VerifyCanAcceptPostRequest() - { - if (!_appSettings.AllowPostRequests) - { - throw new NotImplementedException(); - } - } } \ No newline at end of file diff --git a/src/IRAAS/ImageProcessing/ImageResizer.cs b/src/IRAAS/ImageProcessing/ImageResizer.cs index 21df09d..85e4597 100644 --- a/src/IRAAS/ImageProcessing/ImageResizer.cs +++ b/src/IRAAS/ImageProcessing/ImageResizer.cs @@ -81,6 +81,19 @@ public Task Resize( ImageDataResizeParameters resizeParameters ) { + ArgumentNullException.ThrowIfNull(resizeParameters); + ArgumentNullException.ThrowIfNull( + resizeParameters.ImageData, + nameof(resizeParameters.ImageData) + ); + if (resizeParameters.ImageData.Length < 1) + { + throw new ArgumentException( + "ImageData is empty", + nameof(resizeParameters.ImageData) + ); + } + var timer = CreateTimer(); var src = new StreamAndHeaders( new MemoryStream( diff --git a/src/IRAAS/Middleware/ArgumentExceptionMiddleware.cs b/src/IRAAS/Middleware/ArgumentExceptionMiddleware.cs new file mode 100644 index 0000000..d7cb921 --- /dev/null +++ b/src/IRAAS/Middleware/ArgumentExceptionMiddleware.cs @@ -0,0 +1,17 @@ +using System; + +namespace IRAAS.Middleware; + +public class ArgumentExceptionMiddleware + : ExceptionHandlerMiddleware +{ + public ArgumentExceptionMiddleware( + IAppSettings appSettings + ) : base( + 400, + (e, _) => e.Message, + appSettings + ) + { + } +} \ No newline at end of file diff --git a/src/IRAAS/Middleware/ArgumentNullExceptionMiddleware.cs b/src/IRAAS/Middleware/ArgumentNullExceptionMiddleware.cs new file mode 100644 index 0000000..e3f510b --- /dev/null +++ b/src/IRAAS/Middleware/ArgumentNullExceptionMiddleware.cs @@ -0,0 +1,18 @@ +using System; +using Microsoft.AspNetCore.Http; + +namespace IRAAS.Middleware; + +public class ArgumentNullExceptionMiddleware + : ExceptionHandlerMiddleware +{ + public ArgumentNullExceptionMiddleware( + IAppSettings appSettings + ) : base( + 400, + (e, _) => e.ParamName ?? "", + appSettings + ) + { + } +} \ No newline at end of file diff --git a/src/IRAAS/Middleware/AuthorizationMiddleware.cs b/src/IRAAS/Middleware/AuthorizationMiddleware.cs index b2def19..0f6e306 100644 --- a/src/IRAAS/Middleware/AuthorizationMiddleware.cs +++ b/src/IRAAS/Middleware/AuthorizationMiddleware.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using IRAAS.Exceptions; using IRAAS.Security; using Microsoft.AspNetCore.Http; using PeanutButter.Utils; @@ -13,6 +14,7 @@ public class AuthorizationMiddleware : IMiddleware private readonly IAppSettings _appSettings; private readonly IWhitelist _whitelist; private readonly HashSet _knownTokens; + private readonly bool _openPostAllowed; public AuthorizationMiddleware( IAppSettings appSettings, @@ -23,6 +25,7 @@ IWhitelist whitelist _whitelist = whitelist; _knownTokens = Split(appSettings.PostAuthTokens ?? "", ",") .AsHashSet(); + _openPostAllowed = (appSettings.PostAuthTokens ?? "").Trim() == "*"; } public Task InvokeAsync( @@ -43,9 +46,6 @@ RequestDelegate next VerifyPostRequestAllowed(context); - // fixme: should verify: - // 1. can post - // 2. post allowed by token return next(context); } @@ -57,42 +57,89 @@ HttpContext context { return; } + + VerifyHaveValidUrlParameter(context.Request); + } + private bool VerifyHaveValidUrlParameter( + HttpRequest req + ) + { if ( - !context.Request.Query.TryGetValue("url", out var requestedImageUrl) + !req.Query.TryGetValue("url", out var requestedImageUrl) || requestedImageUrl.IsEmpty() ) { throw new NotImplementedException(); } + if (IsInvalidUrl(requestedImageUrl)) + { + throw new InvalidProcessingOptionsException( + $"Invalid url provided: '{requestedImageUrl}'" + ); + } + if (!_whitelist.IsAllowed(requestedImageUrl)) { - throw new NotImplementedException(); + throw new ImageSourceNotAllowedException(requestedImageUrl); } + + return true; + } + + private static bool IsInvalidUrl( + string url + ) + { + return !Uri.TryCreate( + url, + UriKind.Absolute, + out _ + ); } private bool IsAllowedTestPageRequest( HttpRequest contextRequest ) { - if (!_appSettings.EnableTestPage) + if (IsTestPagePath(contextRequest.Path)) { - return false; + return _appSettings.EnableTestPage + ? true + : throw new NotImplementedException(); } - if (IsTestPath(contextRequest.Path)) + if (IsValidSizeRequest(contextRequest)) { - return true; + if (!_appSettings.EnableTestPage) + { + throw new NotImplementedException(); + } + + return _appSettings.EnableTestPage + ? VerifyHaveValidUrlParameter(contextRequest) + : throw new NotImplementedException(); } return false; } - private bool IsTestPath(string path) + private bool IsValidSizeRequest( + HttpRequest req + ) { - return "/test".Equals(path, StringComparison.OrdinalIgnoreCase) || - "/size".Equals(path, StringComparison.OrdinalIgnoreCase); + if (!"/size".Equals(req.Path, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + return true; + } + + private bool IsTestPagePath(string path) + { + return "/test".Equals(path, StringComparison.OrdinalIgnoreCase); } private void VerifyPostRequestAllowed( @@ -104,6 +151,11 @@ HttpContext context throw new NotImplementedException(); } + if (_openPostAllowed) + { + return; + } + var authHeader = context.Request.Headers.Authorization.FirstOrDefault() ?? throw new NotImplementedException(); var parts = Split(authHeader, " "); diff --git a/src/IRAAS/Middleware/ConcurrencyMiddleware.cs b/src/IRAAS/Middleware/ConcurrencyMiddleware.cs index b6a12e9..95cdb37 100644 --- a/src/IRAAS/Middleware/ConcurrencyMiddleware.cs +++ b/src/IRAAS/Middleware/ConcurrencyMiddleware.cs @@ -91,6 +91,7 @@ private async Task ShareConcurrentRequestResult( var queryString = context.Request.QueryString.ToString(); var completionSource = new TaskCompletionSource(); var cacheProhibited = HasNoStoreCacheControlHeader(context.Request) || + HasTestPagePath(context.Request) || context.Request.Method == HttpMethods.Post; if (!cacheProhibited) { @@ -122,6 +123,12 @@ await PerformFullRequestWith( } } + private bool HasTestPagePath(HttpRequest req) + { + return "/test".Equals(req.Path, StringComparison.OrdinalIgnoreCase) || + "/size".Equals(req.Path, StringComparison.OrdinalIgnoreCase); + } + private bool HasNoStoreCacheControlHeader( HttpRequest request ) diff --git a/src/IRAAS/Middleware/ImageSourceNotAllowedExceptionMiddleware.cs b/src/IRAAS/Middleware/ImageSourceNotAllowedExceptionMiddleware.cs index 136065f..5f99477 100644 --- a/src/IRAAS/Middleware/ImageSourceNotAllowedExceptionMiddleware.cs +++ b/src/IRAAS/Middleware/ImageSourceNotAllowedExceptionMiddleware.cs @@ -7,7 +7,11 @@ public class ImageSourceNotAllowedExceptionMiddleware { public ImageSourceNotAllowedExceptionMiddleware( IAppSettings appSettings) - : base(403, (e, _) => $"Image source not allowed: {e?.Url}", appSettings) + : base( + 403, + (e, _) => $"Image source not allowed: {e?.Url}", + appSettings + ) { } } \ No newline at end of file diff --git a/src/IRAAS/Security/LimitedMemoryStream.cs b/src/IRAAS/Security/LimitedMemoryStream.cs index 1992058..519620d 100644 --- a/src/IRAAS/Security/LimitedMemoryStream.cs +++ b/src/IRAAS/Security/LimitedMemoryStream.cs @@ -81,4 +81,14 @@ public byte[] ToArray() { return _actual.ToArray(); } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _actual.Dispose(); + } + + base.Dispose(disposing); + } } \ No newline at end of file diff --git a/src/IRAAS/Startup.cs b/src/IRAAS/Startup.cs index f42e4a5..601e1fe 100644 --- a/src/IRAAS/Startup.cs +++ b/src/IRAAS/Startup.cs @@ -56,6 +56,8 @@ ILoggerFactory loggerFactory app.UseMiddleware(); app.UseMiddleware(); app.UseMiddleware(); + app.UseMiddleware(); + app.UseMiddleware(); app.UseMiddleware(); app.UseRouting(); diff --git a/src/IRAAS/appsettings.deploy.json b/src/IRAAS/appsettings.deploy.json index 0f65fdd..a92bd60 100644 --- a/src/IRAAS/appsettings.deploy.json +++ b/src/IRAAS/appsettings.deploy.json @@ -21,6 +21,8 @@ "SuppressErrorDiagnostics": "#{SUPPRESS_ERROR_DIAGNOSTICS}", "MaxUrlFetchRetries": "#{MAX_URL_FETCH_RETRIES}", "AllowInvalidSSLCertificates": "#{ALLOW_INVALID_SSL_CERTIFICATES}", + "AllowPostRequests": "#{ALLOW_POST_REQUESTS}", + "PostAuthTokens": "#{POST_AUTH_TOKENS}", "Verbose": "#{VERBOSE}" }, "DefaultParameters": { diff --git a/src/IRAAS/appsettings.json b/src/IRAAS/appsettings.json index 8b06953..23a1b7b 100644 --- a/src/IRAAS/appsettings.json +++ b/src/IRAAS/appsettings.json @@ -21,6 +21,8 @@ "SuppressErrorDiagnostics": "false", "MaxUrlFetchRetries": "1", "AllowInvalidSSLCertificates": "false", + "AllowPostRequests": "false", + "PostAuthTokens": "", "Verbose": "true" }, "DefaultParameters": { From 5831071048e1e16dab6b7e4070e39af71db0ec09 Mon Sep 17 00:00:00 2001 From: Davyd McColl Date: Wed, 23 Sep 2026 15:55:23 +0200 Subject: [PATCH 11/18] :recycle: improve test harnessing, only create appsettings once during app startup --- README.md | 27 ++- package.json | 4 +- .../Controllers/TestTestController.cs | 12 +- .../ImageProcessing/AppSettingsExtensions.cs | 3 +- .../TestImageUrlResizeParameters.cs | 27 +-- .../Middleware/TestAuthorizationMiddleware.cs | 11 +- src/IRAAS.Tests/TestBase.cs | 9 +- src/IRAAS.Tests/TestBootstrapper.cs | 56 +++-- src/IRAAS.Tests/TestCacheClearer.cs | 59 ++++++ src/IRAAS.Tests/TestStartup.cs | 197 ++++++++++++++++++ src/IRAAS.sln | 1 + src/IRAAS/AppSettingsProvider.cs | 28 ++- src/IRAAS/Bootstrapper.cs | 13 +- src/IRAAS/CompositionRoot.cs | 10 +- src/IRAAS/Controllers/TestController.cs | 5 +- src/IRAAS/IAppSettings.cs | 1 + .../ImageUrlResizeParameters.cs | 5 - .../Middleware/AuthorizationMiddleware.cs | 44 ++-- src/IRAAS/Middleware/ConcurrencyMiddleware.cs | 4 +- src/IRAAS/Middleware/Routes.cs | 52 +++++ src/IRAAS/Startup.cs | 47 ++++- src/IRAAS/Views/Test/Test.cshtml | 25 +-- src/IRAAS/appsettings.json | 2 +- 23 files changed, 508 insertions(+), 134 deletions(-) create mode 100644 src/IRAAS.Tests/TestCacheClearer.cs create mode 100644 src/IRAAS.Tests/TestStartup.cs create mode 100644 src/IRAAS/Middleware/Routes.cs diff --git a/README.md b/README.md index 8b518b2..2379f1d 100644 --- a/README.md +++ b/README.md @@ -39,12 +39,12 @@ IRAAS has a few configuration options, explained below: Option | Default | Notes --------------------------------|------------------------------|------ -MaxInputImageSize | 41943040 (40mb) | The maximum size, in bytes, that will be accepted for resizing an image, preventing DDoS via gigantic images +MaxInputImageSize | 41943040 (40mb) | The maximum size, in bytes, that will be accepted for resizing an image, preventing DDoS via gigantic images. MaxOutputImageSize | 41943040 (40mb) | The maximum output size, in bytes, to allow. Outputs which are larger than this are discarded UseDeveloperExceptionPage | false | Flag: when enabled, if IRAAS encounters an error, it will show the developer exception page instead of the default short response UseHttps | false | Flag: when enabled, requests coming in on http will be redirected to the https url you configure in `Urls` EnableTestPage | false | Flag: when enabled, the path `/test` will serve a (very basic) web page to test resizing images -DomainWhitelist | * (all requests are honored) | Comma-delimited list of domains or domain globs which are allowed as image sources, eg `*.mydomain.com` +DomainWhitelist | * | Comma-delimited list of domains or domain globs which are allowed as image sources, eg `*.mydomain.com`. Setting this to "*" allows all domains. MaxConcurrency | 0 | The maximum number of image resize operations to perform concurrently. When set to 0, will default to using the number of processors on the host MaxImageFetchTimeInMilliseconds | 10000 (10s) | The maximum time to wait for a remote service to provide a requested image, in milliseconds MaxClients | 0 | The maximum number of requests to service at one time. Image resize requests are queued in order, and results for exactly the same parameters are re-used, so requests will queue once MaxConcurrency is reached and images will be handled when resources are available. When set to 0, this instructs IRAAS not to throttle incoming requests, but to handle them as and when it can @@ -52,6 +52,9 @@ ShareConcurrentRequests | true | Flag: when set EnableConnectionKeepAlive | false | Flag: when set to true, connections to remote image servers are kept open with http keep-alive SuppressErrorDiagnostics | false | Flag: when set to true, error responses will be secretive about what happened, so as not to leak details to an attacker MaxUrlFetchRetries | 1 | The number of times to _retry_ a failed image fetch request. If set to zero, only the initial request is performed. Setting this to a low number like 1 or 2 may work around transient errors from remote image servers +AllowInvalidSSLCertificates | false | Flag: when set to true, invalid ssl certificates are ignored when fetching images to resize (useful for dev-testing against self-signed certificates) +AllowPostRequests | false | Flag: when set to true, clients may post raw image data and resize parameters to IRAAS and receive the resize result, if they pass other limitations +PostAuthTokens | "" | A comma-separated list of allowed tokens for Bearer authentication when POST requests are allowed. When it's empty, no POST request will be allowed. You may allow all POST requests by setting this to '*'. ## Operation notes 1. Remember: garbage in, garbage out - IRAAS can't make your images prettier and won't resize them larger @@ -62,6 +65,26 @@ MaxUrlFetchRetries | 1 | The number of t 4. IRAAS will echo any provided request headers to the remote image service, and echo any response headers from that remote image service back to the client. So if you have custom headers on your image responses, they will get back to the client +5. POST requests are disabled by default, but may be enabled via AllowPostRequests - however, you will also + need to set PostAuthTokens: when this setting is empty, all POST requests are still denied. PostAuthTokens + is a comma-separated list of tokens which are allowed in a client's Authorization header (using the Bearer scheme). + Examples, with AllowPostRequests set to "true": + - config has "PostAuthTokens": "" -> all POST requests will fail, even though AllowPostRequests is set true + - config has "PostAuthTokens": "token1,token2,token3" -> a client with the header "Authorization: Bearer token2" will + be allowed to proceed. It's suggested to use something like a GUID for tokens. + - config has "PostAuthTokens": "*" -> any POST request is allowed, even if it doesn't have an Authorization header + - MaxInputImageSize still applies, but is bounded by the kestrel limits (if set), IIS, or any + reverse-proxy upstream. If this is not set in kestrel config, then it is determined from the MaxInputImageSize + when IRAAS starts up, allowing 50% headroom for base64 encoded inputs, and any resize parameters involved. + Base64-encoded binary data typically bloats by around 33%, so the extra 17% allows some headroom in which a slightly + oversized request will 400 (warning about hitting limits). Severely oversized requests will receive + a 413 response from Kestrel (or whatever response IIS or upstream reverse-proxies provide). + _NB: It is highly recommended to only allow POST requests over SSL (via your own upstream reverse-proxy) or on a + secure network, otherwise all tokens will be visible to anything between the client and IRAAS._ + +If you're configuring via environment variables and would like to verify that they are being set correctly, +set the environment variable DUMP_ENVIRONMENT to "1" before starting the server and the full environment +available to the server will be dumped out during startup. ## Running direct-from-code ``` diff --git a/package.json b/package.json index 405f278..3b78517 100644 --- a/package.json +++ b/package.json @@ -17,8 +17,8 @@ "zarro": "cross-env zarro", "prestart": "run-s build", "start": "cd src/IRAAS/bin/Debug/net8.0 && dotnet IRAAS.dll", - "start-dev": "cross-env Settings__AllowInvalidSSLCertificates=true Settings__AllowPostRequests=true Settings__PostAuthTokens='*' run-s start", - "start-stress-test-server": "cd src/IRAAS.StressTest.ImageServer/bin/Debug/net7.0 && dotnet IRAAS.StressTest.ImageServer.dll", + "start-dev": "cross-env Settings__EnableTestPage=true Settings__AllowInvalidSSLCertificates=true Settings__AllowPostRequests=true Settings__PostAuthTokens='*' run-s start", + "start-stress-test-server": "cd src/IRAAS.StressTest.ImageServer/bin/Debug/net8.0 && dotnet IRAAS.StressTest.ImageServer.dll", "add-ip-to-security-group": "zarro @", "remove-ip-from-security-group": "zarro @", "build-and-push-image": "zarro @", diff --git a/src/IRAAS.Tests/Controllers/TestTestController.cs b/src/IRAAS.Tests/Controllers/TestTestController.cs index 3edb7dd..114d9b2 100644 --- a/src/IRAAS.Tests/Controllers/TestTestController.cs +++ b/src/IRAAS.Tests/Controllers/TestTestController.cs @@ -1,7 +1,7 @@ using System; using System.Collections.Generic; using System.IO; -using System.IO.MemoryMappedFiles; +using System.Linq; using System.Net.Http; using System.Threading.Tasks; using IRAAS.Controllers; @@ -78,7 +78,6 @@ string expected } [Test] - [Ignore("WIP: fixing an issue in PB where request objects are being clobbered")] public async Task ShouldFetchUsingUrlFetcher() { // Arrange @@ -102,14 +101,19 @@ public async Task ShouldFetchUsingUrlFetcher() .WithRandomUrl() .WithHeaders(headers) .Build(); - var ctx = ControllerContextBuilder.Create() + var httpContext = HttpContextBuilder.Create() .WithRequest(req) .Build(); + var ctx = ControllerContextBuilder.Create() + .WithHttpContext(httpContext) + .Build(); var sut = Create( Substitute.For(), fetcher, ctx ); + Expect(ctx.HttpContext.Request.Headers.ToDictionary()) + .To.Contain.All.Of(headers); Expect(sut.Request) .To.Be(ctx.HttpContext.Request); @@ -127,7 +131,7 @@ await Expect(fetcher) s => s.Equals(url, StringComparison.OrdinalIgnoreCase) ), Arg.Is>( - o => o.DeepEquals(headers) + o => o.ContainsAllOf(headers.ToArray()) ) ); } diff --git a/src/IRAAS.Tests/ImageProcessing/AppSettingsExtensions.cs b/src/IRAAS.Tests/ImageProcessing/AppSettingsExtensions.cs index 96d0759..7b54131 100644 --- a/src/IRAAS.Tests/ImageProcessing/AppSettingsExtensions.cs +++ b/src/IRAAS.Tests/ImageProcessing/AppSettingsExtensions.cs @@ -18,8 +18,7 @@ this IAppSettings appSettings .WithMaxUrlFetchRetries(0) // don't block anything by default .WithDomainWhitelist("*") - // enable the test page for testing - .WithTestPageEnabled() + .WithTestPageDisabled() // don't enable keep-alive: let test connections close .WithConnectionKeepAliveDisabled() // don't allow invalid ssl certs by default diff --git a/src/IRAAS.Tests/ImageProcessing/TestImageUrlResizeParameters.cs b/src/IRAAS.Tests/ImageProcessing/TestImageUrlResizeParameters.cs index 13a7f37..c787d6b 100644 --- a/src/IRAAS.Tests/ImageProcessing/TestImageUrlResizeParameters.cs +++ b/src/IRAAS.Tests/ImageProcessing/TestImageUrlResizeParameters.cs @@ -1,12 +1,9 @@ using System; -using System.Collections.Generic; using System.Linq; using IRAAS.ImageProcessing; -using NSubstitute; using NUnit.Framework; using PeanutButter.Utils; using PeanutButter.Utils.Dictionaries; -using SixLabors.ImageSharp.Formats; using SixLabors.ImageSharp.Formats.Bmp; using SixLabors.ImageSharp.Formats.Gif; using SixLabors.ImageSharp.Formats.Jpeg; @@ -102,7 +99,7 @@ public void ShouldDefaultTo85() Expect(result) .To.Equal(85); Expect(result) - .To.Equal(ImageUrlResizeParameters.DEFAULT_QUALITY); + .To.Equal(ImageResizeParameters.DEFAULT_QUALITY); } [Test] @@ -146,10 +143,10 @@ public void ShouldBeAbleToSetAndGetValidUriWithPath() Expect(uri.AbsolutePath) .Not.To.Be.Null.Or.Whitespace(); var sut = Create(); - + // Act sut.Url = url; - + // Assert Expect(sut.Url) .To.Equal(uri.ToString()); @@ -315,7 +312,7 @@ public class ApplyDefaultsFor public class WhenSamplerNotSet { [TestFixture] - public class AndHaveNoPerFormatDefaults + public class AndHaveNoPerFormatDefaults : TestBase { [Test] public void ShouldSetDefaultSampler() @@ -323,14 +320,11 @@ public void ShouldSetDefaultSampler() // Arrange var defaults = GetRandom() .With(o => o.Sampler = GetRandomString()); - using var _ = AutoResetter.Create( - () => ImageUrlResizeParameters.SetDefaults(defaults), - ImageUrlResizeParameters.ClearDefaults - ); + ImageResizeParameters.SetDefaults(defaults); var options = GetRandom() .With(o => o.Sampler = null); // Act - options.ApplyDefaultsFor(GetRandomFrom(["jpg", "png", "bmp", "gif"])); + options.ApplyDefaultsFor(GetRandomFrom([ "jpg", "png", "bmp", "gif" ])); // Assert Expect(options.Sampler) .To.Equal(defaults.Sampler); @@ -338,7 +332,7 @@ public void ShouldSetDefaultSampler() } [TestFixture] - public class AndHavePerFormatDefaults + public class AndHavePerFormatDefaults : TestBase { [Test] public void ShouldSetDefaultForFormat() @@ -348,7 +342,7 @@ public void ShouldSetDefaultForFormat() .With(o => o.Sampler = "default sampler"); var formatDefaults = GetRandom() .With(o => o.Sampler = "format sampler"); - var inputFormat = GetRandomFrom(["jpg", "png", "bmp", "gif"]); + var inputFormat = GetRandomFrom([ "jpg", "png", "bmp", "gif" ]); var formatDefaultProps = new DictionaryWrappingObject(formatDefaults) .ToArray() .ToDictionary(kvp => kvp.Key, kvp => $"{kvp.Value}"); @@ -356,10 +350,7 @@ public void ShouldSetDefaultForFormat() inputFormat, formatDefaultProps ); - using var _ = AutoResetter.Create( - () => ImageUrlResizeParameters.SetDefaults(defaults), - ImageUrlResizeParameters.ClearDefaults - ); + ImageResizeParameters.SetDefaults(defaults); var options = GetRandom() .With(o => o.Sampler = null); Expect(options.Sampler) diff --git a/src/IRAAS.Tests/Middleware/TestAuthorizationMiddleware.cs b/src/IRAAS.Tests/Middleware/TestAuthorizationMiddleware.cs index f0083a8..14d0ee2 100644 --- a/src/IRAAS.Tests/Middleware/TestAuthorizationMiddleware.cs +++ b/src/IRAAS.Tests/Middleware/TestAuthorizationMiddleware.cs @@ -27,6 +27,8 @@ public class WhenTestViewDisabled { [TestCase("/test")] [TestCase("/size")] + [TestCase("/test/")] + [TestCase("/size/")] public void ShouldThrowNotImplemented( string requestPath ) @@ -216,8 +218,7 @@ public void ShouldContinue() .Build(); var whitelist = CreateAllowingWhitelist(); var appSettings = Substitute.For() - .WithDefaultSettings() - .WithTestPageEnabled(); + .WithDefaultSettings(); var sut = Create( appSettings, whitelist @@ -254,8 +255,7 @@ public void ShouldContinue() .Build(); var whitelist = CreateDisallowingWhitelist(); var appSettings = Substitute.For() - .WithDefaultSettings() - .WithTestPageEnabled(); + .WithDefaultSettings(); var sut = Create( appSettings, whitelist @@ -293,8 +293,7 @@ public void ShouldThrowInvalidProcessingOptions() .Build(); var whitelist = CreateDisallowingWhitelist(); var appSettings = Substitute.For() - .WithDefaultSettings() - .WithTestPageEnabled(); + .WithDefaultSettings(); var sut = Create( appSettings, whitelist diff --git a/src/IRAAS.Tests/TestBase.cs b/src/IRAAS.Tests/TestBase.cs index fbda83b..b804651 100644 --- a/src/IRAAS.Tests/TestBase.cs +++ b/src/IRAAS.Tests/TestBase.cs @@ -1,5 +1,4 @@ -using IRAAS.ImageProcessing; -using NUnit.Framework; +using NUnit.Framework; namespace IRAAS.Tests; @@ -8,14 +7,12 @@ public class TestBase [SetUp] public void SetupBase() { - ImageUrlResizeParameters.ClearDefaults(); - AppSettingsProvider.ClearCachedSettings(); + TestCacheClearer.ClearAllStaticFieldCaches(); } [TearDown] public void TearDownBase() { - ImageUrlResizeParameters.ClearDefaults(); - AppSettingsProvider.ClearCachedSettings(); + TestCacheClearer.ClearAllStaticFieldCaches(); } } \ No newline at end of file diff --git a/src/IRAAS.Tests/TestBootstrapper.cs b/src/IRAAS.Tests/TestBootstrapper.cs index 967fc0a..484fbc6 100644 --- a/src/IRAAS.Tests/TestBootstrapper.cs +++ b/src/IRAAS.Tests/TestBootstrapper.cs @@ -5,6 +5,7 @@ using IRAAS.Logging; using IRAAS.Middleware; using IRAAS.Security; +using IRAAS.Tests.ImageProcessing; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -14,7 +15,7 @@ namespace IRAAS.Tests; [TestFixture] -public class TestBootstrapper: TestBase +public class TestBootstrapper : TestBase { [TestCase(typeof(IImageResizer), typeof(ImageResizer))] [TestCase(typeof(IImageMimeTypeProvider), typeof(ImageMimeTypeProvider))] @@ -28,7 +29,11 @@ public void ShouldRegisterSingletonFor_( // Arrange var container = new Container(); var sut = Create(); - sut.Bootstrap(container); + sut.Bootstrap( + container, + Substitute.For() + .WithDefaultSettings() + ); SetupLoggersOn(container); // Act var result1 = container.Resolve(serviceType); @@ -47,7 +52,10 @@ public void ShouldRegisterAppSettingsAsSingleton() var container = new Container(); var sut = Create(); // Act - sut.Bootstrap(container); + sut.Bootstrap( + container, + Substitute.For() + ); // Assert Expect(() => container.Resolve()) .Not.To.Throw(); @@ -69,7 +77,10 @@ public void ShouldRegisterNotImplementedExceptionMiddleware(Type middlewareType) var container = new Container(); var sut = Create(); // Act - sut.Bootstrap(container); + sut.Bootstrap( + container, + Substitute.For() + ); var result = container.Resolve(middlewareType); // Assert Expect(result) @@ -88,7 +99,10 @@ public void ShouldRegisterTransient_(Type serviceType, Type implementationType) object fourth; var sut = Create(); // Act - sut.Bootstrap(container); + sut.Bootstrap( + container, + Substitute.For() + ); using (var scope = container.CreateScope()) { first = scope.ServiceProvider.GetService(serviceType); @@ -134,17 +148,34 @@ public void ShouldRegisterTransient_(Type serviceType, Type implementationType) public void ShouldSetImageResizeParameterDefaults() { // Arrange - ImageUrlResizeParameters.SetDefaults(null); - Expect(ImageUrlResizeParameters.Defaults) + ImageResizeParameters.SetDefaults(null); + Expect(ImageResizeParameters.Defaults) .To.Be.Null(); var sut = Create(); // Act - sut.Bootstrap(new Container()); + sut.Bootstrap(new Container(), Substitute.For()); // Assert - Expect(ImageUrlResizeParameters.Defaults) + Expect(ImageResizeParameters.Defaults) .Not.To.Be.Null(); } + [Test] + public void ShouldRegisterProvidedAppSettings() + { + // Arrange + var sut = Create(); + var container = new Container(); + var appSettings = Substitute.For() + .WithDefaultSettings(); + + // Act + sut.Bootstrap(container, appSettings); + + // Assert + Expect(container.Resolve()) + .To.Be(appSettings); + } + private Bootstrapper Create() { return new Bootstrapper(); @@ -153,7 +184,8 @@ private Bootstrapper Create() private void SetupLoggersOn(Container container) { // asp.net will set up logging -- we have to fake it here - container.RegisterInstance>( - Substitute.For>()); + container.RegisterInstance( + Substitute.For>() + ); } -} +} \ No newline at end of file diff --git a/src/IRAAS.Tests/TestCacheClearer.cs b/src/IRAAS.Tests/TestCacheClearer.cs new file mode 100644 index 0000000..4857f0f --- /dev/null +++ b/src/IRAAS.Tests/TestCacheClearer.cs @@ -0,0 +1,59 @@ +using System; +using System.Reflection; +using IRAAS.ImageProcessing; + +namespace IRAAS.Tests; + +internal static class TestCacheClearer +{ + private static readonly BindingFlags PrivateStatic = BindingFlags.Static | BindingFlags.NonPublic; + + public static void ClearAllStaticFieldCaches() + { + ClearDefaultImageParameters(); + ClearCachedAppConfig(); + ClearStartupCache(); + } + + public static void ClearDefaultImageParameters() + { + NullifyPrivateStaticField( + typeof(ImageResizeParameters), + "_defaultParameters" + ); + } + + public static void ClearCachedAppConfig() + { + NullifyPrivateStaticField( + typeof(AppSettingsProvider), + "_cachedConfig" + ); + } + + public static void ClearStartupCache() + { + NullifyPrivateStaticField( + typeof(Startup), + "_appSettings" + ); + NullifyPrivateStaticField( + typeof(Startup), + "_appConfig" + ); + } + + private static void NullifyPrivateStaticField( + Type onType, + string fieldName + ) + { + var field = onType.GetField(fieldName, PrivateStatic); + if (field is null) + { + throw new InvalidOperationException($"field '{fieldName}' not found on type {onType}"); + } + + field.SetValue(null, null); + } +} \ No newline at end of file diff --git a/src/IRAAS.Tests/TestStartup.cs b/src/IRAAS.Tests/TestStartup.cs new file mode 100644 index 0000000..e38dbcd --- /dev/null +++ b/src/IRAAS.Tests/TestStartup.cs @@ -0,0 +1,197 @@ +using System.IO; +using IRAAS.ImageProcessing; +using IRAAS.Security; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Server.Kestrel.Core; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using NUnit.Framework; +using PeanutButter.Utils; + +namespace IRAAS.Tests; + +[TestFixture] +[Parallelizable(ParallelScope.None)] +public class TestStartup : TestBase +{ + [TestFixture] + [Parallelizable(ParallelScope.None)] + public class KestrelMaxRequestBodySize : TestBase + { + [Test] + public void ShouldBeDerivedFromMaxInputImageSizeWhenNotConfigured() + { + // Arrange + // -> POST payloads carry the image base64-encoded (4/3 inflation) plus + // the resize parameters, so the transport limit has to sit above + // MaxInputImageSize or that setting can never be reached + var maxInputImageSize = 10_000_000; + var expected = 15_000_000L; // maxInputImageSize + 50% + using var tempFolder = new AutoTempFolder(); + WriteAppSettings( + tempFolder, + maxInputImageSize + ); + using var _ = InFolder(tempFolder.Path); + using var host = BuildHost(); + + // Act + var result = MaxRequestBodySizeOf(host); + + // Assert + Expect(result) + .To.Equal(expected); + } + + [Test] + public void ShouldFallOverToExplicitKestrelConfigurationWhenProvided() + { + // Arrange + var maxInputImageSize = 10_000_000; + var derived = 15_000_000L; + var configured = 99_999_999L; + Expect(configured) + .Not.To.Equal(derived, "the test can't tell the two apart otherwise"); + using var tempFolder = new AutoTempFolder(); + WriteAppSettings( + tempFolder, + maxInputImageSize, + configured + ); + using var _ = InFolder(tempFolder.Path); + using var host = BuildHost(); + + // Act + var result = MaxRequestBodySizeOf(host); + + // Assert + Expect(result) + .To.Equal(configured); + Expect(result) + .Not.To.Equal(derived, "explicit kestrel config should win over the derived value"); + } + } + + [TestFixture] + [Parallelizable(ParallelScope.None)] + public class Composition : TestBase + { + [Test] + public void ShouldResolveAppSettingsFromTheComposedHost() + { + // Arrange + using var tempFolder = new AutoTempFolder(); + var maxInputImageSize = 10_000_000; + WriteAppSettings( + tempFolder, + maxInputImageSize + ); + using var _ = InFolder(tempFolder.Path); + using var host = BuildHost(); + + // Act + var result = host.Services.GetService(); + + // Assert + Expect(result) + .Not.To.Be.Null( + "CompositionRoot must hand the settings it was given to Bootstrapper" + ); + Expect(result.MaxInputImageSize) + .To.Equal( + maxInputImageSize, + "the registered settings should be the ones built from config" + ); + } + + [TestCase(typeof(IImageResizer))] + [TestCase(typeof(IUrlFetcher))] + [TestCase(typeof(IWhitelist))] + public void ShouldResolveServicesDependingOnAppSettings_( + System.Type serviceType + ) + { + // everything below takes IAppSettings, so a null registration + // surfaces here as a NullReferenceException at resolve time + // rather than anywhere near the mistake that caused it + + // Arrange + using var tempFolder = new AutoTempFolder(); + WriteAppSettings( + tempFolder, + 10_000_000 + ); + using var _ = InFolder(tempFolder.Path); + using var host = BuildHost(); + + // Act + var result = host.Services.GetService(serviceType); + + // Assert + Expect(result) + .Not.To.Be.Null(); + } + } + + private static IWebHost BuildHost() + { + return Program.CreateWebHostBuilder([]).Build(); + } + + private static long? MaxRequestBodySizeOf(IWebHost host) + { + return host.Services + .GetRequiredService>() + .Value + .Limits + .MaxRequestBodySize; + } + + private static void WriteAppSettings( + AutoTempFolder folder, + int maxInputImageSize, + long? kestrelMaxRequestBodySize = null + ) + { + var kestrelSection = kestrelMaxRequestBodySize is null + ? "" + : $$""" + "Kestrel": { + "Limits": { + "MaxRequestBodySize": "{{kestrelMaxRequestBodySize}}" + } + }, + """; + File.WriteAllText( + Path.Combine(folder.Path, AppSettingsProvider.BASE_CONFIG), + $$""" + { + "Urls": "http://127.0.0.1:5000", + {{kestrelSection}} + "Settings": { + "MaxInputImageSize": "{{maxInputImageSize}}" + } + } + """ + ); + } + + /// + /// Configuration is built against the current working directory and then cached, + /// so a host must be built from the folder holding the appsettings.json it should + /// read. TestBase clears the caches around every test, so moving is all that is + /// needed here - but only one host per test. + /// + private static AutoResetter InFolder(string path) + { + return new AutoResetter( + () => + { + var prior = Directory.GetCurrentDirectory(); + Directory.SetCurrentDirectory(path); + return prior; + }, + Directory.SetCurrentDirectory + ); + } +} diff --git a/src/IRAAS.sln b/src/IRAAS.sln index 9bc3ac2..ffde454 100644 --- a/src/IRAAS.sln +++ b/src/IRAAS.sln @@ -15,6 +15,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".config", ".config", "{5FEB ProjectSection(SolutionItems) = preProject ..\.zarro-defaults = ..\.zarro-defaults ..\package.json = ..\package.json + ..\README.md = ..\README.md EndProjectSection EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".github", ".github", "{93FEC7FE-990B-4539-AA21-0ADF681732AA}" diff --git a/src/IRAAS/AppSettingsProvider.cs b/src/IRAAS/AppSettingsProvider.cs index 90e9aa6..ecdc5a8 100644 --- a/src/IRAAS/AppSettingsProvider.cs +++ b/src/IRAAS/AppSettingsProvider.cs @@ -17,34 +17,30 @@ public static class AppSettingsProvider { public const string BASE_CONFIG = "appsettings.json"; public const string DEPLOY_CONFIG = "appsettings.deploy.json"; + + private static IConfigurationRoot _cachedConfig; public static IAppSettings CreateAppSettings() { - return _cachedSettings ??= GenerateSettingsFrom( - _cachedConfig ??= CreateConfig() + return GenerateSettingsFrom( + CreateConfig() ); } public static IDefaultImageResizeParameters CreateDefaultParameters() { - return _cachedDefaultParameters ??= GenerateParametersFrom( - _cachedConfig ??= CreateConfig() + return GenerateParametersFrom( + CreateConfig() ); } - public static void ClearCachedSettings() - { - _cachedSettings = null; - _cachedConfig = null; - _cachedDefaultParameters = null; - } - - private static IAppSettings _cachedSettings; - private static IDefaultImageResizeParameters _cachedDefaultParameters; - private static IConfigurationRoot _cachedConfig; - public static IConfigurationRoot CreateConfig() { + if (_cachedConfig is not null) + { + return _cachedConfig; + } + var builder = new ConfigurationBuilder() .SetBasePath(Directory.GetCurrentDirectory()) .AddJsonFile(BASE_CONFIG); @@ -55,7 +51,7 @@ public static IConfigurationRoot CreateConfig() builder.AddEnvironmentVariables(); - return builder.Build(); + return _cachedConfig = builder.Build(); } private static bool CanLoad(string deployConfig) diff --git a/src/IRAAS/Bootstrapper.cs b/src/IRAAS/Bootstrapper.cs index 83a44fd..9c36515 100644 --- a/src/IRAAS/Bootstrapper.cs +++ b/src/IRAAS/Bootstrapper.cs @@ -5,13 +5,16 @@ using IRAAS.Security; using Microsoft.AspNetCore.Http; using PeanutButter.Utils; -// ReSharper disable RedundantTypeArgumentsOfMethod +// ReSharper disable RedundantTypeArgumentsOfMethod namespace IRAAS; public class Bootstrapper { - public void Bootstrap(IContainer container) + public void Bootstrap( + IContainer container, + IAppSettings appSettings + ) { container .RegisterSingleton() @@ -19,11 +22,11 @@ public void Bootstrap(IContainer container) .RegisterSingleton() .RegisterSingleton() .RegisterSingleton(); - container.RegisterInstance(AppSettingsProvider.CreateAppSettings()); + container.RegisterInstance(appSettings); var defaults = AppSettingsProvider.CreateDefaultParameters(); container.RegisterInstance(defaults); - ImageUrlResizeParameters.SetDefaults(defaults); - + ImageResizeParameters.SetDefaults(defaults); + container.RegisterAllMiddlewareSingleton(); container.Register(); diff --git a/src/IRAAS/CompositionRoot.cs b/src/IRAAS/CompositionRoot.cs index daa694b..0a27c71 100644 --- a/src/IRAAS/CompositionRoot.cs +++ b/src/IRAAS/CompositionRoot.cs @@ -5,9 +5,15 @@ namespace IRAAS; // ReSharper disable once ClassNeverInstantiated.Global public class CompositionRoot { - public CompositionRoot(IContainer container) + public CompositionRoot( + IContainer container, + IAppSettings appSettings + ) { var bootstrapper = new Bootstrapper(); - bootstrapper.Bootstrap(container); + bootstrapper.Bootstrap( + container, + appSettings + ); } } \ No newline at end of file diff --git a/src/IRAAS/Controllers/TestController.cs b/src/IRAAS/Controllers/TestController.cs index aa82c06..ef8a002 100644 --- a/src/IRAAS/Controllers/TestController.cs +++ b/src/IRAAS/Controllers/TestController.cs @@ -1,5 +1,6 @@ using System.Threading.Tasks; using IRAAS.ImageProcessing; +using IRAAS.Middleware; using Microsoft.AspNetCore.Mvc; namespace IRAAS.Controllers; @@ -20,14 +21,14 @@ IUrlFetcher fetcher } [ResponseCache(NoStore = true)] - [Route("test")] + [Route(Routes.TEST)] [HttpGet] public ActionResult Test() { return View(_settings); } - [Route("size")] + [Route(Routes.SIZE)] [HttpGet] public async Task FileSize([FromQuery] string url) { diff --git a/src/IRAAS/IAppSettings.cs b/src/IRAAS/IAppSettings.cs index f463db9..3a1c280 100644 --- a/src/IRAAS/IAppSettings.cs +++ b/src/IRAAS/IAppSettings.cs @@ -86,5 +86,6 @@ public interface IAppSettings [DefaultSetting(FALSE)] bool AllowPostRequests { get; } + [DefaultSetting("")] string PostAuthTokens { get; } } diff --git a/src/IRAAS/ImageProcessing/ImageUrlResizeParameters.cs b/src/IRAAS/ImageProcessing/ImageUrlResizeParameters.cs index b084d0f..b7b6376 100644 --- a/src/IRAAS/ImageProcessing/ImageUrlResizeParameters.cs +++ b/src/IRAAS/ImageProcessing/ImageUrlResizeParameters.cs @@ -43,11 +43,6 @@ IImageFormat sourceFormat public abstract class ImageResizeParameters : IActiveImageResizeParameters { - public static void ClearDefaults() - { - SetDefaults(null); - } - public static void SetDefaults( IDefaultImageResizeParameters parameters ) diff --git a/src/IRAAS/Middleware/AuthorizationMiddleware.cs b/src/IRAAS/Middleware/AuthorizationMiddleware.cs index 0f6e306..56af77f 100644 --- a/src/IRAAS/Middleware/AuthorizationMiddleware.cs +++ b/src/IRAAS/Middleware/AuthorizationMiddleware.cs @@ -57,11 +57,11 @@ HttpContext context { return; } - + VerifyHaveValidUrlParameter(context.Request); } - private bool VerifyHaveValidUrlParameter( + private void VerifyHaveValidUrlParameter( HttpRequest req ) { @@ -84,8 +84,6 @@ HttpRequest req { throw new ImageSourceNotAllowedException(requestedImageUrl); } - - return true; } private static bool IsInvalidUrl( @@ -100,48 +98,30 @@ out _ } private bool IsAllowedTestPageRequest( - HttpRequest contextRequest + HttpRequest req ) { - if (IsTestPagePath(contextRequest.Path)) + var isSizePath = Routes.HasSizeEndpointPath(req); + var isTestPath = Routes.HasTestPagePath(req); + + if (!isSizePath && !isTestPath) { - return _appSettings.EnableTestPage - ? true - : throw new NotImplementedException(); + return false; } - if (IsValidSizeRequest(contextRequest)) + if (!_appSettings.EnableTestPage) { - if (!_appSettings.EnableTestPage) - { - throw new NotImplementedException(); - } - - return _appSettings.EnableTestPage - ? VerifyHaveValidUrlParameter(contextRequest) - : throw new NotImplementedException(); + throw new NotImplementedException(); } - return false; - } - - private bool IsValidSizeRequest( - HttpRequest req - ) - { - if (!"/size".Equals(req.Path, StringComparison.OrdinalIgnoreCase)) + if (isSizePath) { - return false; + VerifyHaveValidUrlParameter(req); } return true; } - private bool IsTestPagePath(string path) - { - return "/test".Equals(path, StringComparison.OrdinalIgnoreCase); - } - private void VerifyPostRequestAllowed( HttpContext context ) diff --git a/src/IRAAS/Middleware/ConcurrencyMiddleware.cs b/src/IRAAS/Middleware/ConcurrencyMiddleware.cs index 95cdb37..1dcdb43 100644 --- a/src/IRAAS/Middleware/ConcurrencyMiddleware.cs +++ b/src/IRAAS/Middleware/ConcurrencyMiddleware.cs @@ -125,8 +125,8 @@ await PerformFullRequestWith( private bool HasTestPagePath(HttpRequest req) { - return "/test".Equals(req.Path, StringComparison.OrdinalIgnoreCase) || - "/size".Equals(req.Path, StringComparison.OrdinalIgnoreCase); + return Routes.HasTestPagePath(req) || + Routes.HasSizeEndpointPath(req); } private bool HasNoStoreCacheControlHeader( diff --git a/src/IRAAS/Middleware/Routes.cs b/src/IRAAS/Middleware/Routes.cs new file mode 100644 index 0000000..820c3d6 --- /dev/null +++ b/src/IRAAS/Middleware/Routes.cs @@ -0,0 +1,52 @@ +using System; +using Microsoft.AspNetCore.Http; + +namespace IRAAS.Middleware; + +public static class Routes +{ + public const string SIZE = "size"; + public const string TEST = "test"; + private static readonly string SizePathBare = $"/{SIZE}"; + private static readonly string TestPathBare = $"/{TEST}"; + private static readonly string SizePathTrailingSlash = $"{SizePathBare}/"; + private static readonly string TestPathTrailingSlash = $"{TestPathBare}/"; + + public static bool HasTestPagePath( + HttpRequest request + ) + { + return AnyPathMatches( + request, + TestPathBare, + TestPathTrailingSlash + ); + } + + public static bool HasSizeEndpointPath( + HttpRequest request + ) + { + return AnyPathMatches( + request, + SizePathBare, + SizePathTrailingSlash + ); + } + + private static bool AnyPathMatches( + HttpRequest request, + params string[] paths + ) + { + foreach (var path in paths) + { + if (path.Equals(request?.Path, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } +} \ No newline at end of file diff --git a/src/IRAAS/Startup.cs b/src/IRAAS/Startup.cs index 601e1fe..4ed96f1 100644 --- a/src/IRAAS/Startup.cs +++ b/src/IRAAS/Startup.cs @@ -7,6 +7,8 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Server.Kestrel.Core; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; @@ -16,6 +18,21 @@ namespace IRAAS; public class Startup { + private static IAppSettings AppSettings => +#pragma warning disable CS0618 // Type or member is obsolete + _appSettings ??= AppSettingsProvider.CreateAppSettings(); +#pragma warning restore CS0618 // Type or member is obsolete + + [Obsolete("Use the property, Luke")] + private static IAppSettings _appSettings; + + private static IConfigurationRoot AppConfig => +#pragma warning disable CS0618 // Type or member is obsolete + _appConfig ??= AppSettingsProvider.CreateConfig(); +#pragma warning restore CS0618 // Type or member is obsolete + [Obsolete("Use the property, Luke")] + private static IConfigurationRoot _appConfig; + // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. public void Configure( IApplicationBuilder app, @@ -23,8 +40,7 @@ public void Configure( ILoggerFactory loggerFactory ) { - var config = AppSettingsProvider.CreateAppSettings(); - SetupLog4NetWith(loggerFactory, config); + SetupLog4NetWith(loggerFactory, AppSettings); app.Use( (context, next) => { @@ -33,7 +49,7 @@ ILoggerFactory loggerFactory } ); if (env.IsDevelopment() || - config.UseDeveloperExceptionPage) + AppSettings.UseDeveloperExceptionPage) { app.UseDeveloperExceptionPage(); } @@ -42,7 +58,7 @@ ILoggerFactory loggerFactory app.UseMiddleware(); } - if (config.UseHttps) + if (AppSettings.UseHttps) { app.UseHttpsRedirection(); app.UseHsts(); @@ -96,7 +112,11 @@ IServiceCollection services ); } ); - + services.Configure( + opts => { opts.Limits.MaxRequestBodySize = ResolveMaxRequestBodySize(); } + ); + services.AddSingleton(AppSettings); + var container = new Container( Rules.Default .With(FactoryMethod.ConstructorWithResolvableArguments) @@ -108,6 +128,23 @@ IServiceCollection services .ConfigureServiceProvider(); } + private static long ResolveMaxRequestBodySize() + { + var configured = AppConfig["Kestrel:Limits:MaxRequestBodySize"]; + if (long.TryParse(configured, out var explicitLimit)) + { + return explicitLimit; + } + + // POST requests will expect the image data to come through + // base64-encoded, so we have to allow at least 33% headroom + // -> allowing 50% to be sure. So some over-sized requests will + // make it into application logic where they will be rejected, + // but any massively-oversized request will be blocked at the + // kestrel layer + return (long)Math.Round(AppSettings.MaxInputImageSize * 1.5); + } + private void DumpEnvironmentVariables() { Dump( diff --git a/src/IRAAS/Views/Test/Test.cshtml b/src/IRAAS/Views/Test/Test.cshtml index e808b16..450f358 100644 --- a/src/IRAAS/Views/Test/Test.cshtml +++ b/src/IRAAS/Views/Test/Test.cshtml @@ -1,5 +1,6 @@ @using IRAAS @using IRAAS.ImageProcessing +@using IRAAS.Middleware @addTagHelper "*, Microsoft.AspNet.Mvc.TagHelpers" @addTagHelper "*, IRAAS" @model IRAAS.IAppSettings @@ -64,7 +65,7 @@