diff --git a/.github/workflows/release-to-octopus.yml b/.github/workflows/release-to-octopus.yml deleted file mode 100644 index 6343937..0000000 --- a/.github/workflows/release-to-octopus.yml +++ /dev/null @@ -1,56 +0,0 @@ -name: Release to Octopus - -on: - workflow_dispatch: - inputs: - skipRelease: - description: 'Specify anything but "No" to skip releasing automatically to selected environment..' - required: true - default: 'No' - -jobs: - build-and-deploy: - runs-on: ubuntu-latest - strategy: - matrix: - node-version: [ 20.x ] - dotnet-version: [ 8.0.x ] - outputs: - version: ${{ steps.image_tag.outputs.version }} - steps: - - name: ⬇️ Checkout - uses: actions/checkout@v6 - with: - fetch-depth: 0 # we want to checkout other branches (like master), see https://github.com/actions/checkout#Fetch-all-history-for-all-tags-and-branches - - - name: 👷‍♂️Setup NodeJs - uses: actions/setup-node@v6 - with: - node-version: ${{ matrix.node-version }} - - - name: 👀 Check master is merged in - run: | - npm ci --no-progress - npm run verify-up-to-date - - - name: 👷‍♂️Setup .NET - uses: actions/setup-dotnet@v5 - with: - dotnet-version: ${{ matrix.dotnet-version }} - - - name: 🐙 Octopus Deploy tasks - id: octopus-deploy - uses: ./.github/actions/octopus-deploy - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - skip-release: ${{ inputs.skipRelease }} - octopus-url: ${{ vars.OCTOPUS_SERVER_URL }} - octopus-api-key: ${{ secrets.OCTOPUS_SERVER_API_KEY }} - env: - OCTOPUS_URL: ${{ vars.OCTOPUS_SERVER_URL }} - OCTOPUS_API_KEY: ${{ secrets.OCTOPUS_SERVER_API_KEY }} - OCTOPUS_SPACE: "Default" - - - name: 🐙 Octopus - Package Info - run: | - echo "Published package with name: ${{ steps.octopus-deploy.outputs.package-name }}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index a871106..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,55 +0,0 @@ -# Sample release workflow to push a new Docker Image to AWS ECR - -name: IRAAS - Publish to AWS ECR - -on: - workflow_dispatch: - -jobs: - build-and-push: - runs-on: ubuntu-latest - strategy: - matrix: - node-version: [ 20.x ] - dotnet-version: [ 8.0.x ] - outputs: - version: ${{ steps.image_tag.outputs.version }} - steps: - - name: ⬇️ Checkout - uses: actions/checkout@v3 - with: - fetch-depth: 0 # we want to checkout other branches (like master), see https://github.com/actions/checkout#Fetch-all-history-for-all-tags-and-branches - - - name: 👷‍♂️Setup NodeJs - uses: actions/setup-node@v3 - with: - node-version: ${{ matrix.node-version }} - - - name: 👀 Check master is merged in - run: | - npm ci --no-progress - npm run verify-up-to-date - - - name: 👷‍♂️Setup .NET - uses: actions/setup-dotnet@v1 - with: - dotnet-version: ${{ matrix.dotnet-version }} - - - name: 🪪 Configure AWS credentials - uses: aws-actions/configure-aws-credentials@v1 - with: - aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} - aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - aws-region: ${{ secrets.AWS_REGION }} - - - name: 🔖 Get Image Tag - run: echo "version=$(echo $(git symbolic-ref --short HEAD | sed -e "s/\//-/g")-$(git rev-parse --short HEAD))" >> $GITHUB_OUTPUT - id: image_tag - - - name: 🔨 Build and Push - env: - IMAGE_TAG: ${{ steps.image_tag.outputs.version }} - AMAZON_ACCOUNT_ID: ${{ secrets.AWS_ACCOUNT_ID }} - AMAZON_REGION: ${{ secrets.AWS_REGION }} - run: | - npm run build-and-push-image 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 928dbf6..3b78517 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", @@ -15,8 +16,9 @@ "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-stress-test-server": "cd src/IRAAS.StressTest.ImageServer/bin/Debug/net7.0 && dotnet IRAAS.StressTest.ImageServer.dll", + "start": "cd src/IRAAS/bin/Debug/net8.0 && dotnet IRAAS.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.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 743e815..9902519 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/CompositionRootTests.cs b/src/IRAAS.Tests/CompositionRootTests.cs new file mode 100644 index 0000000..851a869 --- /dev/null +++ b/src/IRAAS.Tests/CompositionRootTests.cs @@ -0,0 +1,35 @@ +using DryIoc; +using IRAAS.Tests.ImageProcessing; +using Microsoft.AspNetCore.Http; +using NSubstitute; +using NUnit.Framework; + +namespace IRAAS.Tests; + +public class CompositionRootTests +{ + [Test] + public void ShouldBootstrapOnProvidedContainer() + { + // Arrange + var container = new Container(); + var appSettings = Substitute.For() + .WithDefaultSettings(); + // Act + Create(container, appSettings); + + // Assert + Expect(container.Resolve()) + .To.Be(appSettings); + Expect(container.Resolve()) + .Not.To.Be.Null(); + } + + private static CompositionRoot Create( + IContainer container, + IAppSettings appSettings + ) + { + return new(container, appSettings); + } +} \ No newline at end of file diff --git a/src/IRAAS.Tests/Controllers/ControllerMatchers.cs b/src/IRAAS.Tests/Controllers/ControllerMatchers.cs deleted file mode 100644 index 79d6fc4..0000000 --- a/src/IRAAS.Tests/Controllers/ControllerMatchers.cs +++ /dev/null @@ -1,164 +0,0 @@ -using System; -using System.Linq; -using System.Net.Http; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.Routing; -using NExpect.Interfaces; -using NExpect.MatcherLogic; -using PeanutButter.Utils; - -namespace IRAAS.Tests.Controllers; - -public static class ControllerMatchers -{ - public static SupportingExtension Method( - this IHave have, - string method - ) - { - var result = new SupportingExtension(); - have.AddMatcher( - actual => - { - result.Member = method; - result.Continuation = have; - var passed = actual.GetMethod(method) != null; - return new MatcherResult( - passed, - () => $"Expected type {actual} to have method {method}"); - }); - return result; - } - - public static IMore Route( - this IHave have, - string expected) - { - return have.Compose( - actual => - { - var attribs = actual.GetCustomAttributes(false).OfType(); - Expect(attribs).To.Contain.Exactly(1) - .Matched.By( - a => a.Template == expected, - () => $"Expected {actual.Name} to have route {expected}"); - }); - } - - public static IMore Route( - this IHave have, - string member, - string expected) - { - return have.Compose( - actual => - { - var method = actual.GetMethod(member); - Expect(method) - .Not.To.Be.Null(() => $"Expected to find method {actual}.{method}"); - var attribs = method.GetCustomAttributes(false).OfType(); - Expect(attribs).To.Contain.Exactly(1) - .Matched.By( - a => a.Template == expected, - () => $"Expected {actual}.{method} to have route {expected}"); - }); - } - - public class SupportingExtension: TestBase - { - public string Member { get; set; } - public IHave Continuation { get; set; } - - public AndSupportingExtension Supporting(HttpMethod method) - { - Continuation.AddMatcher( - controllerType => - { - var supportedMethods = controllerType.GetMethod(Member) - ?.GetCustomAttributes(false) - .Select(attrib => attrib as IActionHttpMethodProvider) - .Where(a => a != null) - .SelectMany(a => a.HttpMethods) - .Distinct() - .ToArray(); - var passed = supportedMethods - ?.Any(m => m.Equals(method.Method, StringComparison.OrdinalIgnoreCase)) - ?? false; - return new MatcherResult( - passed, - () => $"Expected {controllerType}.{Member} to support HttpMethod {method}" - ); - }); - return Next(); - } - - public SupportingExtension With => this; - - public SupportingExtension Route(string expected) - { - Continuation.AddMatcher( - controllerType => - { - var routes = controllerType.GetMethod(Member) - ?.GetCustomAttributes(false) - .OfType() - .Select(a => a.Template) - .ToArray(); - var passed = routes.Contains(expected); - return new MatcherResult( - passed, - () => - { - var start = $"Expected {controllerType}.{Member} to have route '{expected}'"; - var count = routes.Count(); - var no = count == 0 - ? "no " - : ""; - var s = count == 1 - ? "" - : "s"; - var colon = count > 0 - ? ":" - : ""; - return new[] - { - start, - $"Have {no}route{s}{colon}" - } - .Concat(routes.Select(r => $" - {r}")) - .JoinWith("\n"); - } - ); - }); - return this; - } - - private AndSupportingExtension Next() - { - return new AndSupportingExtension(Continuation, Member); - } - } - - public class AndSupportingExtension - { - public string Member { get; set; } - public IHave Continuation { get; set; } - - public AndSupportingExtension( - IHave continuation, - string member) - { - Continuation = continuation; - Member = member; - } - - public AndSupportingExtension And(HttpMethod method) - { - return (new SupportingExtension() - { - Member = Member, - Continuation = Continuation - }).Supporting(method); - } - } -} diff --git a/src/IRAAS.Tests/Controllers/TestHealthController.cs b/src/IRAAS.Tests/Controllers/TestHealthController.cs new file mode 100644 index 0000000..e2eb92f --- /dev/null +++ b/src/IRAAS.Tests/Controllers/TestHealthController.cs @@ -0,0 +1,86 @@ +using IRAAS.Controllers; +using IRAAS.Middleware; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http; +using NUnit.Framework; + +// ReSharper disable RedundantBoolCompare +namespace IRAAS.Tests.Controllers; + +[TestFixture] +public class TestHealthController +{ + [TestCase(Routes.HEALTH)] + public void ShouldHaveRoute_( + string expected + ) + { + // Arrange + var sut = typeof(HealthController); + + // Act + Expect(sut) + .To.Have.Attribute( + a => a.Template == Routes.HEALTH + ); + + // Assert + } + + [TestFixture] + public class GetHealthTests + { + [Test] + public void ShouldAcceptHttpGetOnEmptySubRoute() + { + // Arrange + var sut = typeof(HealthController); + + // Act + Expect(sut) + .To.Have.Method( + nameof(HealthController.GetHealth) + ).With.Route("") + .Supporting(HttpMethod.Get); + // Assert + } + + [Test] + public void ShouldNotAllowResponseCache() + { + // Arrange + var sut = typeof(HealthController); + + // Act + Expect(sut) + .To.Have.Method( + nameof(HealthController.GetHealth) + ).With.Attribute( + a => a.NoStore == true && + a.Location == ResponseCacheLocation.None + ); + // Assert + } + + [Test] + public void ShouldReturnOk() + { + // Arrange + var sut = Create(); + + // Act + var result = sut.GetHealth(); + + // Assert + Expect(result) + .To.Be.An.Instance.Of(); + Expect(result.StatusCode) + .To.Equal(200); + } + } + + private static HealthController Create() + { + return new(); + } +} \ No newline at end of file diff --git a/src/IRAAS.Tests/Controllers/TestImageResizeController.cs b/src/IRAAS.Tests/Controllers/TestImageResizeController.cs index cc51702..751caf3 100644 --- a/src/IRAAS.Tests/Controllers/TestImageResizeController.cs +++ b/src/IRAAS.Tests/Controllers/TestImageResizeController.cs @@ -1,14 +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; @@ -30,34 +30,17 @@ public void Controller_ShouldHaveEmptyRoute() } [TestFixture] - public class Resize : TestBase + public class ResizeByUrl : TestBase { [Test] - public void ShouldHaveEmptyRoute() + public void ShouldHaveEmptyRouteForGetRequest() { // Arrange // Act Expect(typeof(ImageResizeController)) - .To.Have.Route(nameof(ImageResizeController.Resize), ""); - // Assert - } - - [Test] - public void ShouldThrowImageSourceNotAllowedWhenWhitelistSaysNotAllowed() - { - // Arrange - var whiteList = Substitute.For(); - whiteList.IsAllowed(Arg.Any()).Returns(false); - var sut = Create(whiteList: whiteList); - var options = new ImageResizeParameters() - { - Url = GetRandomHttpUrl() - }; - // Act - Expect(() => sut.Resize(options)) - .To.Throw() - .With.Property(e => e.Url) - .Equal.To(options.Url); + .To.Have.Method(nameof(ImageResizeController.ResizeByUrl)) + .With.Route("") + .Supporting(HttpMethod.Get); // Assert } @@ -66,12 +49,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,20 +69,24 @@ 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 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(); + 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 +105,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 +114,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 +135,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 +145,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 +162,7 @@ public async Task ShouldSetResponseHeadersFromResizer() [key] = value }; var resizer = Substitute.For(); - var options = GetRandom(); + var options = GetRandom(); var imageStream = new MemoryStream(); resizer.Resize( options, @@ -187,12 +174,12 @@ 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); // Act - await sut.Resize(options); + await sut.ResizeByUrl(options); // Assert var responseHeaders = accessor.HttpContext!.Response.Headers.ToDictionary(); Expect(responseHeaders[key]) @@ -218,17 +205,21 @@ 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(); ImageResizeParameters.SetDefaults(defaults); - var options = new ImageResizeParameters() + var options = new ImageUrlResizeParameters() { Url = server.GetFullUrlFor($"/{imageName}") }; var resizer = Substitute.For() .With( o => o.Resize( - Arg.Any(), + Arg.Any(), Arg.Any>() ).Returns(_ => expected) ); @@ -237,10 +228,10 @@ 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 ImageResizeParameters(); + var expectedOptions = new ImageUrlResizeParameters(); options.CopyPropertiesTo(expectedOptions); defaults.CopyPropertiesTo(expectedOptions); var expectedHeaders = httpContext.Response.Headers @@ -256,7 +247,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) @@ -273,44 +264,123 @@ await Expect(resizer) .To.Equal(expectedMimeType); } } + } - private static ImageResizeController Create( - IImageResizer resizer = null, - IImageMimeTypeProvider mimeTypeProvider = null, - IWhitelist whiteList = null, - IHttpContextAccessor httpContextAccessor = null - ) + [TestFixture] + public class ResizeByImageData + { + [Test] + public void ShouldHaveEmptyRouteForPostRequest() { - ImageResizeParameters.SetDefaults(null); - return new ImageResizeController( - resizer ?? Substitute.For(), - mimeTypeProvider ?? CreateFakeMimeTypeProvider(), - whiteList ?? CreateAllowingWhitelist(), - httpContextAccessor ?? CreateFakeHttpContextAccessor() - ); + // Arrange + // Act + Expect(typeof(ImageResizeController)) + .To.Have.Method(nameof(ImageResizeController.ResizeByImageData)) + .With.Route("") + .Supporting(HttpMethod.Post); + // Assert } - private static IHttpContextAccessor CreateFakeHttpContextAccessor() + [Test] + public async Task ShouldResizeImageAndReturnResultWithDeterminedMimeType() { - var result = Substitute.For(); - result.HttpContext.Returns(new FakeHttpContext()); - return result; - } + // 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); - private static IWhitelist CreateAllowingWhitelist() - { - var result = Substitute.For(); - result.IsAllowed(Arg.Any()) - .Returns(true); - return result; + // 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(); } - private static IImageMimeTypeProvider CreateFakeMimeTypeProvider() + [Test] + public void ShouldThrowArgumentExceptionWhenInputDataExceedsConfiguredMaxInputImageSize() { - var result = Substitute.For(); - result.DetermineMimeTypeFor(Arg.Any()) - .Returns("image/jpeg"); - return result; + // 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, + IHttpContextAccessor httpContextAccessor = null, + IAppSettings appSettings = null + ) + { + ImageResizeParameters.SetDefaults(null); + return new ImageResizeController( + resizer ?? Substitute.For(), + mimeTypeProvider ?? CreateFakeMimeTypeProvider(), + httpContextAccessor ?? CreateFakeHttpContextAccessor(), + appSettings ?? Substitute.For() + .WithDefaultSettings() + ); + } + + private static IHttpContextAccessor CreateFakeHttpContextAccessor() + { + var result = Substitute.For(); + result.HttpContext.Returns(HttpContextBuilder.BuildDefault()); + 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..a6082ae 100644 --- a/src/IRAAS.Tests/Controllers/TestTestController.cs +++ b/src/IRAAS.Tests/Controllers/TestTestController.cs @@ -1,17 +1,24 @@ using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; 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; +// ReSharper disable RedundantBoolCompare namespace IRAAS.Tests.Controllers; [TestFixture] -public class TestTestController: TestBase +public class TestTestController : TestBase { - [TestCase("test")] + [TestCase("")] public void ControllerShouldHaveRoute_(string expected) { // Arrange @@ -21,28 +28,39 @@ public void ControllerShouldHaveRoute_(string expected) // Assert } - [Test] - public void WhenDisabled_Should404() + [TestCase(nameof(TestController.Test))] + [TestCase(nameof(TestController.FileSize))] + public void ShouldDisableResponseCachingOn_( + string method + ) { // Arrange - var appSettings = CreateAppSettings(false); + var sut = typeof(TestController); + // Act - Expect(() => Create(appSettings)) - .To.Throw(); + Expect(sut) + .To.Have.Method(method) + .With.Attribute( + a => a.NoStore == true && + a.Location == ResponseCacheLocation.None + ); + // 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,36 +82,130 @@ public void WhenEnabled_ShouldReturnViewResult() } [TestFixture] - public class FileSize: TestBase + public class FileSize : TestBase { - [Test] - [Ignore("TODO")] - public void ShouldFetchUsingUrlFetcher() + [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 ShouldReportSizeFromFetchedStream() + 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 headers = GetRandom>(); + Expect(headers) + .Not.To.Be.Empty(); + var req = HttpRequestBuilder.Create() + .WithMethod(HttpMethod.Get) + .WithRandomUrl() + .WithHeaders(headers) + .Build(); + 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); + + // 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.ContainsAllOf(headers.ToArray()) + ) + ); } + [Test] + 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) @@ -103,4 +215,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 4617f68..7f18296 100644 --- a/src/IRAAS.Tests/IRAAS.Tests.csproj +++ b/src/IRAAS.Tests/IRAAS.Tests.csproj @@ -6,33 +6,35 @@ NU1701 false - Debug;Release;ReleaseForDocker + Debug;Release;ReleaseForDocker;Debug.WebRequest;Release.WebRequest latest AnyCPU - + - + - + - - - + + + + - - - - - - + + + + + + - + + diff --git a/src/IRAAS.Tests/ImageProcessing/AppSettingsExtensions.cs b/src/IRAAS.Tests/ImageProcessing/AppSettingsExtensions.cs new file mode 100644 index 0000000..7b54131 --- /dev/null +++ b/src/IRAAS.Tests/ImageProcessing/AppSettingsExtensions.cs @@ -0,0 +1,162 @@ +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("*") + .WithTestPageDisabled() + // 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/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 ed60485..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/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 e9d3d39..24a9ad8 100644 --- a/src/IRAAS.Tests/ImageProcessing/TestAppSettingsProvider.cs +++ b/src/IRAAS.Tests/ImageProcessing/TestAppSettingsProvider.cs @@ -7,27 +7,12 @@ using PeanutButter.Utils; // ReSharper disable AccessToDisposedClosure - namespace IRAAS.Tests.ImageProcessing; [TestFixture] [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) { @@ -61,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 @@ -220,6 +234,65 @@ public void ShouldSetToZero() } } + [TestFixture] + public class AllowingInvalidSslCertificates : TestBase + { + [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 +335,9 @@ private static string MakeSettings( "EnableConnectionKeepAlive": "{{settings.EnableConnectionKeepAlive}}", "LogFolder": "{{settings.LogFolder}}", "SuppressErrorDiagnostics": "{{settings.SuppressErrorDiagnostics}}", + "AllowInvalidSSLCertificates": "{{settings.AllowInvalidSslCertificates}}", + "AllowPostRequests": "{{settings.AllowPostRequests}}", + "PostAuthTokens": "{{settings.PostAuthTokens}}", "Verbose": "{{settings.Verbose}}" }, "DefaultParameters": { @@ -296,68 +372,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 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 string MakeSettingsWithoutLogFolder( + private static string MakeSettingsWithoutLogFolder( IAppSettings settings ) { @@ -385,7 +523,8 @@ IAppSettings settings "EnableTestPage": "{{settings.EnableTestPage}}", "DomainWhitelist": "{{settings.DomainWhitelist}}", "ShareConcurrentRequests": "{{settings.ShareConcurrentRequests}}", - "EnableConnectionKeepAlive": "{{settings.EnableConnectionKeepAlive}}" + "EnableConnectionKeepAlive": "{{settings.EnableConnectionKeepAlive}}", + "AllowInvalidSSLCertificates": "{{settings.AllowInvalidSslCertificates}}" }, "DefaultParameters": { "*": { @@ -444,4 +583,7 @@ public class AppSettings : IAppSettings public LogLevel IRAASLogLevel { get; set; } 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/TestDefaultImageResizeParameters.cs b/src/IRAAS.Tests/ImageProcessing/TestDefaultImageResizeParameters.cs index 1fcdffb..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; @@ -17,16 +19,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 +141,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; } @@ -112,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 7ecdedd..c681d6c 100644 --- a/src/IRAAS.Tests/ImageProcessing/TestImageResizer.cs +++ b/src/IRAAS.Tests/ImageProcessing/TestImageResizer.cs @@ -39,832 +39,900 @@ 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 ImageUrlResizeParameters() + { + Url = url + }, + NoHeaders + ) + ).To.Throw(); // Assert - Expect(result) - .Not.To.Be.Null(); - await Expect(fetcher).To.Have.Received(1).Fetch(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 ImageUrlResizeParameters() + { + 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 ImageUrlResizeParameters() + { + 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 ImageUrlResizeParameters() + { + 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 ImageUrlResizeParameters() + { + 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 ImageUrlResizeParameters() + { + 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 ImageUrlResizeParameters() + { + 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 ImageUrlResizeParameters() + { + 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 ImageUrlResizeParameters() + { + 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 ImageUrlResizeParameters() + { + 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 ImageUrlResizeParameters() + { + 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(); + } + } - string DumpDebugInfo() + [Test] + public async Task GivenOnlyWidthAndDevicePixelRatioResultingInExplodingImage_NotResizeBeyondOriginalSize() { - return new + // 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 ImageUrlResizeParameters() + { + 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 ImageUrlResizeParameters() + { + 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); + } + + [Test] + public async Task GivenOnlyHeightAndDevicePixelRatio_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 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 ImageUrlResizeParameters() + { + 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 ImageUrlResizeParameters() + { + 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 ImageUrlResizeParameters() { - 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 ImageUrlResizeParameters() { - 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 ImageUrlResizeParameters() + { + 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 ImageUrlResizeParameters() + { + 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 ImageUrlResizeParameters() + { + Width = 200, + Url = url + }; + var opts1 = new ImageUrlResizeParameters() + { + Width = 200, + Sampler = "bicubic", + Url = url + }; + var opts2 = new ImageUrlResizeParameters() + { + 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 ImageUrlResizeParameters() { + 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 ImageUrlResizeParameters() + { + Width = width, + Quantizer = "wu", + Url = url, + Format = "PNG" + }; + var opts2 = new ImageUrlResizeParameters() { + 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() + [TestFixture] + public class OperationTimingsHeaders { - 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); - } + [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 ImageUrlResizeParameters() + { + 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 + } - [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() - { - 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()) + public static IEnumerable EnumerateTimingHeaders() { - var servedPath = "/fluffy-cat.png"; - server.ServeFile(servedPath, () => png); - var fetcher = new UrlFetcher( - appSettings, - Substitute.For>() + return typeof(TimingHeaders) + .GetFields(BindingFlags.Static | BindingFlags.Public) + .Where(f => f.Name != nameof(TimingHeaders.PREFIX)) + .Select(f => f.GetValue(null) as string) + .ToArray(); + } + + [TestCaseSource(nameof(EnumerateTimingHeaders))] + [Test] + public async Task ShouldIncludeTimingHeader_(string header) + { + // Arrange + var fetcher = CreateFetcherFor( + Resources.Data.FluffyCatBmp ); var sut = Create(fetcher); - var options = new ImageResizeParameters() + var options = new ImageUrlResizeParameters() { - Url = server.GetFullUrlFor(servedPath), - Width = 200 + Width = Resources.Images.FluffyCatBmp.Width, + Url = GetRandomHttpUrlWithPath(), + Format = "PNG" }; + Expect(options.Url) + .Not.To.Be.Null.Or.Empty($"got: {options.Url}"); // Act - var result = await sut.Resize(options, NoHeaders); + var result = await sut.Resize(options, 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(header); } } - } - [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() + [Test] + public void ShouldThrowIfOutputWillExceedMaxOutputSize() { - 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"); - } + // 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 ImageUrlResizeParameters() + { + Url = url + }; + // Act + Expect(() => sut.Resize(options, NoHeaders)) + .To.Throw(); + // Assert + } - [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() + private static IUrlFetcher CreateFetcherFor( + byte[] data, + string url = null, + IDictionary headers = null + ) { - Width = width, - Quantizer = "wu", - Url = url, - Format = "PNG" - }; - var opts2 = new ImageResizeParameters() + return CreateFetcherFor( + () => new MemoryStream(data), + url, + headers + ); + } + + private static IUrlFetcher CreateFetcherFor( + Stream data, + string url = null, + IDictionary headers = null + ) { - 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); + return CreateFetcherFor( + () => data, + url, + headers + ); + } - // 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"); + 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; + } } [TestFixture] - public class OperationTimingsHeaders + public class ResizingImageViaPostedData { [Test] - public async Task ShouldIncludeTimeToFetchImage() + public void ShouldThrowForNullResizeParameters() { // 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 sut = Create(); + // Act - var result = await sut.Resize( - new ImageResizeParameters() - { - Width = Resources.Images.FluffyCatBmp.Width, - Url = GetRandomHttpUrlWithPath(), - Format = "PNG" - }, - new Dictionary() - ); + Expect(async () => await sut.Resize(null)) + .To.Throw() + .For("resizeParameters"); + // 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() + [Test] + public void ShouldThrowForNullData() { - return typeof(TimingHeaders) - .GetFields(BindingFlags.Static | BindingFlags.Public) - .Where(f => f.Name != nameof(TimingHeaders.PREFIX)) - .Select(f => f.GetValue(null) as string) - .ToArray(); + // 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 } - [TestCaseSource(nameof(EnumerateTimingHeaders))] [Test] - public async Task ShouldIncludeTimingHeader_(string header) + public void ShouldThrowForEmptyData() { // Arrange - var fetcher = CreateFetcherFor( - Resources.Data.FluffyCatBmp - ); - var sut = Create(fetcher); - var options = new ImageResizeParameters() + var sut = Create(); + var resizeParameters = new ImageDataResizeParameters() { - Width = Resources.Images.FluffyCatBmp.Width, - Url = GetRandomHttpUrlWithPath(), - Format = "PNG" + ImageData = [] }; - Expect(options.Url) - .Not.To.Be.Null.Or.Empty($"got: {options.Url}"); + // Act - var result = await sut.Resize(options, new Dictionary()); + Expect(async () => await sut.Resize(resizeParameters)) + .To.Throw() + .For("ImageData") + .With.Message.Containing("empty"); + // 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() - { - 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); - } - - 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( - IUrlFetcher fetcher, + IUrlFetcher fetcher = null, IAppSettings appSettings = null ) { return new ImageResizer( - fetcher, + fetcher ?? Substitute.For(), appSettings ?? CreateDefaultAppSettingsWithVerboseEnabled() ); } diff --git a/src/IRAAS.Tests/ImageProcessing/TestImageResizeParameters.cs b/src/IRAAS.Tests/ImageProcessing/TestImageUrlResizeParameters.cs similarity index 88% rename from src/IRAAS.Tests/ImageProcessing/TestImageResizeParameters.cs rename to src/IRAAS.Tests/ImageProcessing/TestImageUrlResizeParameters.cs index 6900c2e..c787d6b 100644 --- a/src/IRAAS.Tests/ImageProcessing/TestImageResizeParameters.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; @@ -15,7 +12,7 @@ namespace IRAAS.Tests.ImageProcessing; [TestFixture] -public class TestImageResizeParameters : TestBase +public class TestImageUrlResizeParameters : TestBase { [Test] public void ShouldBeAbleToConstructWithNoParameters() @@ -24,7 +21,7 @@ public void ShouldBeAbleToConstructWithNoParameters() // attempt to populate // Arrange // Act - Expect(() => Activator.CreateInstance(typeof(ImageResizeParameters))) + Expect(() => Activator.CreateInstance(typeof(ImageUrlResizeParameters))) .Not.To.Throw(); // Assert } @@ -141,16 +138,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] @@ -313,7 +312,7 @@ public class ApplyDefaultsFor public class WhenSamplerNotSet { [TestFixture] - public class AndHaveNoPerFormatDefaults + public class AndHaveNoPerFormatDefaults : TestBase { [Test] public void ShouldSetDefaultSampler() @@ -321,14 +320,11 @@ public void ShouldSetDefaultSampler() // Arrange var defaults = GetRandom() .With(o => o.Sampler = GetRandomString()); - using var _ = AutoResetter.Create( - () => ImageResizeParameters.SetDefaults(defaults), - ImageResizeParameters.ClearDefaults - ); - var options = GetRandom() + 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); @@ -336,7 +332,7 @@ public void ShouldSetDefaultSampler() } [TestFixture] - public class AndHavePerFormatDefaults + public class AndHavePerFormatDefaults : TestBase { [Test] public void ShouldSetDefaultForFormat() @@ -346,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}"); @@ -354,11 +350,8 @@ public void ShouldSetDefaultForFormat() inputFormat, formatDefaultProps ); - using var _ = AutoResetter.Create( - () => ImageResizeParameters.SetDefaults(defaults), - ImageResizeParameters.ClearDefaults - ); - var options = GetRandom() + ImageResizeParameters.SetDefaults(defaults); + var options = GetRandom() .With(o => o.Sampler = null); Expect(options.Sampler) .To.Be.Null(); @@ -374,8 +367,8 @@ public void ShouldSetDefaultForFormat() } } - private static ImageResizeParameters Create() + private static ImageUrlResizeParameters Create() { - return new ImageResizeParameters(); + return new ImageUrlResizeParameters(); } } \ No newline at end of file diff --git a/src/IRAAS.Tests/ImageProcessing/TestUrlFetcher.cs b/src/IRAAS.Tests/ImageProcessing/TestUrlFetcher.cs index 7939146..c6a4694 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,78 @@ await sut.Fetch( } } + [TestFixture] + public class AllowingInvalidSslCertificatesForDev + { + [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 + { + [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 + } + } + } + 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() - { - 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; - } } \ No newline at end of file diff --git a/src/IRAAS.Tests/IntegrationTests.cs b/src/IRAAS.Tests/IntegrationTests.cs new file mode 100644 index 0000000..74e3325 --- /dev/null +++ b/src/IRAAS.Tests/IntegrationTests.cs @@ -0,0 +1,473 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Hosting; +using NUnit.Framework; +using PeanutButter.SimpleHTTPServer; +using PeanutButter.Utils; +using SixLabors.ImageSharp; + +namespace IRAAS.Tests; + +/// +/// Drives the assembled application over real HTTP: real Kestrel, the real +/// middleware pipeline, real routing, real controllers. Everything below this +/// level can be green while the composed app is broken - all three of the +/// defects these cover were invisible to the unit suite. +/// +[TestFixture] +[Parallelizable(ParallelScope.None)] +public class IntegrationTests : TestBase +{ + [TestFixture] + [Parallelizable(ParallelScope.None)] + public class ResizingByUrl : TestBase + { + [Test] + public async Task ShouldServeAResizedImageFromAWhitelistedSource() + { + // proves the container actually composes: a null IAppSettings + // registration fails here even though every unit test passes + // Arrange + using var upstream = TestEnvironment.BorrowHttpServer(); + var sourceUrl = ServeFluffyCat(upstream.Instance); + using var app = await StartApp(); + + // Act + using var result = await app.Client.GetAsync( + $"/?url={Uri.EscapeDataString(sourceUrl)}&width=100" + ); + + // Assert + Expect(result.StatusCode) + .To.Equal(HttpStatusCode.OK); + var body = await result.Content.ReadAsByteArrayAsync(); + var image = Image.Load(body); + Expect(image.Width) + .To.Equal(100, "should have been resized to the requested width"); + } + + [Test] + public async Task ShouldRefuseASourceOutsideTheDomainWhitelist() + { + // Arrange + using var upstream = TestEnvironment.BorrowHttpServer(); + var sourceUrl = ServeFluffyCat(upstream.Instance); + using var app = await StartApp( + o => o.DomainWhitelist = "*.somewhere-else.com" + ); + + // Act + using var result = await app.Client.GetAsync( + $"/?url={Uri.EscapeDataString(sourceUrl)}&width=100" + ); + + // Assert + Expect(result.StatusCode) + .To.Equal(HttpStatusCode.Forbidden); + } + + [Test] + public async Task ShouldRejectAMalformedUrlWithoutFallingOver() + { + // Arrange + using var app = await StartApp(); + + // Act + using var result = await app.Client.GetAsync("/?url=not%20a%20url"); + + // Assert + Expect(result.StatusCode) + .To.Equal( + HttpStatusCode.BadRequest, + "an unparseable url should not reach the unhandled-exception handler" + ); + } + } + + [TestFixture] + [Parallelizable(ParallelScope.None)] + public class TheTestPage : TestBase + { + // the gate decides by path string; routing normalises. Every spelling + // routing accepts has to be one the gate also recognises. + [TestCase("/test")] + [TestCase("/test/")] + [TestCase("/TEST")] + public async Task ShouldBeUnreachableWhenDisabled_(string path) + { + // Arrange + using var app = await StartApp(o => o.EnableTestPage = false); + + // Act + using var result = await app.Client.GetAsync(path); + + // Assert + Expect(result.StatusCode) + .To.Equal(HttpStatusCode.NotFound); + } + + [TestCase("/size")] + [TestCase("/size/")] + public async Task ShouldNotExposeTheSizeEndpointWhenDisabled_(string path) + { + // Arrange + using var upstream = TestEnvironment.BorrowHttpServer(); + var sourceUrl = ServeFluffyCat(upstream.Instance); + using var app = await StartApp(o => o.EnableTestPage = false); + + // Act + using var result = await app.Client.GetAsync( + $"{path}?url={Uri.EscapeDataString(sourceUrl)}" + ); + + // Assert + Expect(result.StatusCode) + .To.Equal( + HttpStatusCode.NotFound, + "a disabled endpoint should look absent, not merely refuse" + ); + } + + [Test] + public async Task ShouldServeTheTestPageWhenEnabled() + { + // Arrange + using var app = await StartApp(o => o.EnableTestPage = true); + + // Act + using var result = await app.Client.GetAsync("/test"); + + // Assert + Expect(result.StatusCode) + .To.Equal(HttpStatusCode.OK); + } + + [Test] + public async Task ShouldReportImageSizeWhenEnabled() + { + // Arrange + using var upstream = TestEnvironment.BorrowHttpServer(); + var sourceUrl = ServeFluffyCat(upstream.Instance); + var expectedLength = Resources.Data.FluffyCatJpeg.Length; + using var app = await StartApp(o => o.EnableTestPage = true); + + // Act + using var result = await app.Client.GetAsync( + $"/size?url={Uri.EscapeDataString(sourceUrl)}" + ); + + // Assert + Expect(result.StatusCode) + .To.Equal(HttpStatusCode.OK); + var reported = long.Parse(await result.Content.ReadAsStringAsync()); + Expect(reported) + .To.Equal(expectedLength); + } + } + + [TestFixture] + [Parallelizable(ParallelScope.None)] + public class ResizingByPostedImageData : TestBase + { + [Test] + public async Task ShouldRefusePostsWhenNotEnabled() + { + // Arrange + var imageData = Resources.Data.FluffyCatJpeg; + using var app = await StartApp(o => o.AllowPostRequests = false); + + // Act + using var result = await Post(app, imageData); + + // Assert + Expect(result.StatusCode) + .To.Equal(HttpStatusCode.NotFound); + } + + [Test] + public async Task ShouldRefusePostsBearingAnUnknownToken() + { + // Arrange + var token = $"{Guid.NewGuid()}"; + var imageData = Resources.Data.FluffyCatJpeg; + using var app = await StartApp( + o => + { + o.AllowPostRequests = true; + o.PostAuthTokens = token; + } + ); + + // Act + using var result = await Post( + app, + imageData, + token: $"{Guid.NewGuid()}" + ); + + // Assert + Expect(result.StatusCode) + .To.Equal(HttpStatusCode.NotFound); + } + + [Test] + public async Task ShouldResizePostedImageDataForAKnownToken() + { + // Arrange + var token = $"{Guid.NewGuid()}"; + var imageData = Resources.Data.FluffyCatJpeg; + using var app = await StartApp( + o => + { + o.AllowPostRequests = true; + o.PostAuthTokens = token; + } + ); + + // Act + using var result = await Post( + app, + imageData, + token: token, + width: 100 + ); + + // Assert + Expect(result.StatusCode) + .To.Equal(HttpStatusCode.OK); + var image = Image.Load(await result.Content.ReadAsByteArrayAsync()); + Expect(image.Width) + .To.Equal(100); + } + + [Test] + public async Task ShouldRejectImageDataOverMaxInputImageSizeWithBadRequest() + { + // sits inside the derived kestrel body limit (x1.5) but over our own + // limit, so the request reaches application code and gets a 400 + // Arrange + var maxInputImageSize = 200_000; + using var app = await StartApp( + o => + { + o.AllowPostRequests = true; + o.PostAuthTokens = "*"; + o.MaxInputImageSize = maxInputImageSize; + } + ); + + // Act + using var result = await Post( + app, + GetRandomBytes(maxInputImageSize + 1, maxInputImageSize + 100) + ); + + // Assert + Expect(result.StatusCode) + .To.Equal(HttpStatusCode.BadRequest); + } + + [Test] + public async Task ShouldNotReportSuccessForGrosslyOversizedPayloads() + { + // beyond the derived kestrel body limit, so kestrel aborts the read + // before any of our code runs. Asserting "not a success" rather than + // a specific code: 413 would be ideal, 500 is acceptable, but the + // client must not be told the upload worked. + // Arrange + var maxInputImageSize = 200_000; + using var app = await StartApp( + o => + { + o.AllowPostRequests = true; + o.PostAuthTokens = "*"; + o.MaxInputImageSize = maxInputImageSize; + } + ); + + // Act + using var result = await Post( + app, + GetRandomBytes(maxInputImageSize * 3, maxInputImageSize * 3 + 100) + ); + + // Assert + Expect(result.IsSuccessStatusCode) + .To.Be.False( + $"an over-limit upload reported {(int)result.StatusCode} {result.StatusCode}" + ); + } + + private static async Task Post( + RunningApp app, + byte[] imageData, + string token = null, + int? width = null + ) + { + var payload = new Dictionary + { + ["imageData"] = imageData + }; + if (width is not null) + { + payload["width"] = width; + } + + using var request = new HttpRequestMessage(HttpMethod.Post, "/") + { + Content = new StringContent( + JsonSerializer.Serialize(payload), + Encoding.UTF8, + new MediaTypeHeaderValue("application/json") + ) + }; + if (token is not null) + { + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + } + + return await app.Client.SendAsync(request); + } + } + + private static string ServeFluffyCat(IHttpServer server) + { + var path = $"/{GetRandomString(8)}.jpg"; + server.ServeFile( + path, + Resources.Data.FluffyCatJpeg, + "image/jpeg" + ); + return server.GetFullUrlFor(path); + } + + private static Task StartApp( + Action configure = null + ) + { + var options = new AppOptions(); + configure?.Invoke(options); + return RunningApp.Start(options); + } + + public class AppOptions + { + public bool EnableTestPage { get; set; } + public bool AllowPostRequests { get; set; } + public string PostAuthTokens { get; set; } = ""; + public string DomainWhitelist { get; set; } = "*"; + public int MaxInputImageSize { get; set; } = 41943040; + } + + /// + /// The real application, listening on a real port, configured from a real + /// appsettings.json - which is the only way to exercise the host wiring and + /// the kestrel limits, since both are established while the host is built. + /// + private class RunningApp : IDisposable + { + public HttpClient Client { get; private init; } + + private IWebHost _host; + private AutoTempFolder _folder; + private AutoResetter _cwd; + + public static async Task Start(AppOptions options) + { + var folder = new AutoTempFolder(); + var port = PortFinder.FindOpenPort(); + var baseUrl = $"http://127.0.0.1:{port}"; + File.WriteAllText( + Path.Combine(folder.Path, AppSettingsProvider.BASE_CONFIG), + GenerateAppSettings(options, baseUrl) + ); + + var cwd = new AutoResetter( + () => + { + var prior = Directory.GetCurrentDirectory(); + Directory.SetCurrentDirectory(folder.Path); + return prior; + }, + Directory.SetCurrentDirectory + ); + + try + { + var host = Program.CreateWebHostBuilder([]).Build(); + await host.StartAsync(); + return new RunningApp + { + _host = host, + _folder = folder, + _cwd = cwd, + Client = new HttpClient + { + BaseAddress = new Uri(baseUrl), + Timeout = TimeSpan.FromSeconds(30) + } + }; + } + catch + { + cwd.Dispose(); + folder.Dispose(); + throw; + } + } + + private static string GenerateAppSettings( + AppOptions options, + string baseUrl + ) + { + return $$""" + { + "Urls": "{{baseUrl}}", + "Logging": { + "LogLevel": { + "Default": "None", + "IRAAS": "None" + } + }, + "Settings": { + "MaxInputImageSize": "{{options.MaxInputImageSize}}", + "EnableTestPage": "{{options.EnableTestPage.ToString().ToLowerInvariant()}}", + "AllowPostRequests": "{{options.AllowPostRequests.ToString().ToLowerInvariant()}}", + "PostAuthTokens": "{{options.PostAuthTokens}}", + "DomainWhitelist": "{{options.DomainWhitelist}}", + "MaxImageFetchTimeInMilliseconds": "10000" + } + } + """; + } + + public void Dispose() + { + Client?.Dispose(); + try + { + _host?.StopAsync().Wait(TimeSpan.FromSeconds(10)); + } + catch + { + // a host that will not stop cleanly shouldn't fail the assertion + } + + _host?.Dispose(); + _cwd?.Dispose(); + _folder?.Dispose(); + _host = null; + _cwd = null; + _folder = null; + } + } +} 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 new file mode 100644 index 0000000..0a58a0a --- /dev/null +++ b/src/IRAAS.Tests/Middleware/TestAuthorizationMiddleware.cs @@ -0,0 +1,767 @@ +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; +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")] + [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", GetRandomHttpsUrlWithPath()) + .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 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")] + public void ShouldContinueToTestPage( + string requestPath + ) + { + // Arrange + HttpContext captured = null; + var (ctx, next) = RequestDelegateTestArenaBuilder.Create() + .WithContext( + HttpContextBuilder.Create() + .WithRequestMethod(HttpMethods.Get) + .WithRequestPath(requestPath) + .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 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(); + } + } + } + } + + [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 appSettings = Substitute.For() + .WithDefaultSettings(); + var sut = Create( + appSettings, + 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 appSettings = Substitute.For() + .WithDefaultSettings(); + var sut = Create( + appSettings, + 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 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(); + 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 + { + [TestFixture] + public class OnBaseUrl + { + [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] + public class OnHealthUrl + { + [TestCase("/health")] + [TestCase("/health/")] + public void ShouldNotThrow_( + string path + ) + { + // Arrange + HttpContext captured = null; + var (ctx, next) = RequestDelegateTestArenaBuilder.Create() + .WithContext( + HttpContextBuilder.Create() + .WithRequestMethod(HttpMethods.Get) + .WithRequestQueryParameters( + GetRandomDictionary() + ).WithRequestPath(path) + .Build() + ).WithDelegateLogic(c => captured = c) + .Build(); + var whitelist = CreateAllowingWhitelist(); + var sut = Create(whitelist: whitelist); + + // Act + Expect(async () => await sut.InvokeAsync(ctx, next)) + .Not.To.Throw(); + + // Assert + Expect(captured) + .Not.To.Be.Null(); + Expect(whitelist) + .Not.To.Have.Been.Called(); + } + } + } + } + + [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))] + 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() + .WithDefaultSettings(); + 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 ButConfiguredAuthTokensIsEmpty + { + [TestCase("")] + [TestCase(" ")] + [TestCase(null)] + public void ShouldThrowNotImplemented_( + string postTokens + ) + { + // Arrange + var appSettings = Substitute.For() + .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(); + var sut = Create(appSettings); + + // Act + Expect(async () => await sut.InvokeAsync(ctx, next)) + .To.Throw(); + + // Assert + Expect(captured) + .To.Be.Null(); + } + } + + [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 + { + [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 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] + 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() + .WithDefaultSettings() + .WithTestPageDisabled(), + 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.Tests/Middleware/TestConcurrencyMiddleware.cs b/src/IRAAS.Tests/Middleware/TestConcurrencyMiddleware.cs index ebaf0e2..5e3d9a6 100644 --- a/src/IRAAS.Tests/Middleware/TestConcurrencyMiddleware.cs +++ b/src/IRAAS.Tests/Middleware/TestConcurrencyMiddleware.cs @@ -4,35 +4,37 @@ 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; [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() { // Arrange - var context = new FakeHttpContext(); + var context = HttpContextBuilder.BuildDefault(); 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() { @@ -59,40 +61,46 @@ 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); 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,13 +115,312 @@ public void ShouldOnlyCallNextOnce() .To.Equal(1); } + [Test] + public void ShouldNotEjectAnInProgressCacheableRequestWhenAConcurrentNoStoreRequestCompletes() + { + // Arrange + 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); + 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 + { + [Test] + public void ShouldCallNextForEachRequest() + { + // Arrange + 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); + 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 WhenRequestIncludesHeader_CacheControlWithMultipleDirectivesIncludingNoStore + { + [Test] + public void ShouldTreatTheRequestAsNoStore() + { + // Arrange + 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"); + 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() { // 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"; @@ -122,30 +429,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 +486,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) - { - failed = true; - } - else + var next = new Func( + ctx => { - 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); } @@ -211,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] @@ -285,4 +598,4 @@ private static IAppSettings CreateAppSettings( result.ShareConcurrentRequests.Returns(shareRequests ?? true); return result; } -} +} \ No newline at end of file diff --git a/src/IRAAS.Tests/Middleware/TestImageProviderErrorMiddleware.cs b/src/IRAAS.Tests/Middleware/TestImageProviderErrorMiddleware.cs index b1ed098..b20a6e5 100644 --- a/src/IRAAS.Tests/Middleware/TestImageProviderErrorMiddleware.cs +++ b/src/IRAAS.Tests/Middleware/TestImageProviderErrorMiddleware.cs @@ -4,18 +4,18 @@ 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; 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() @@ -23,15 +23,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 @@ -41,7 +38,7 @@ await sut.InvokeAsync( } [TestFixture] - public class WhenAnotherExceptionIsThrown: TestBase + public class WhenAnotherExceptionIsThrown : TestBase { [Test] public void ShouldNotInterfere() @@ -49,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[] { @@ -71,23 +68,23 @@ public void ShouldNotInterfere() } [TestFixture] - public class WhenImageProviderErrorExceptionThrown: TestBase + public class WhenImageProviderErrorExceptionThrown : TestBase { [Test] 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); - var statusCode = GetRandom(); + var statusCode = GetAnother(HttpStatusCode.OK); var headers = new WebHeaderCollection() { { 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/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.Tests/TestBase.cs b/src/IRAAS.Tests/TestBase.cs index 1eb9b10..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,12 +7,12 @@ public class TestBase [SetUp] public void SetupBase() { - ImageResizeParameters.ClearDefaults(); + TestCacheClearer.ClearAllStaticFieldCaches(); } [TearDown] public void TearDownBase() { - ImageResizeParameters.ClearDefaults(); + TestCacheClearer.ClearAllStaticFieldCaches(); } } \ No newline at end of file diff --git a/src/IRAAS.Tests/TestBootstrapper.cs b/src/IRAAS.Tests/TestBootstrapper.cs index beca402..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); @@ -139,12 +153,29 @@ public void ShouldSetImageResizeParameterDefaults() .To.Be.Null(); var sut = Create(); // Act - sut.Bootstrap(new Container()); + sut.Bootstrap(new Container(), Substitute.For()); // Assert 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..7dd451a --- /dev/null +++ b/src/IRAAS.Tests/TestStartup.cs @@ -0,0 +1,386 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using IRAAS.ImageProcessing; +using IRAAS.Middleware; +using IRAAS.Security; +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.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(); + } + } + + [TestFixture] + [Parallelizable(ParallelScope.None)] + public class MiddlewareTests : TestBase + { + /// + /// The order the pipeline is assembled in is load-bearing, and several of + /// these constraints were learned the hard way: + /// - ProductionFallbackExceptionHandlerMiddleware must be outermost; it is + /// the last-resort 500 for anything nothing else claimed. + /// - BadHttpRequestExceptionMiddleware must sit OUTSIDE ConcurrencyMiddleware. + /// Concurrency swaps the response body for a buffer and only restores it in + /// its finally, so a handler inside it would write to the discarded buffer. + /// - ArgumentExceptionMiddleware must sit OUTSIDE ArgumentNullExceptionMiddleware. + /// ArgumentNullException derives from ArgumentException, so the general + /// handler swallows both if it is the inner one. + /// - AuthorizationMiddleware must be innermost of these, so the exceptions it + /// throws are translated by the handlers above it rather than escaping. + /// Re-ordering is not necessarily wrong - but it should be deliberate, so if + /// this test fails, work out which of the above you are changing before + /// updating the list. + /// + private static readonly Type[] ExpectedPipeline = + [ + typeof(ProductionFallbackExceptionHandlerMiddleware), + typeof(MaxClientsMiddleware), + typeof(BadHttpRequestExceptionMiddleware), + typeof(ConcurrencyMiddleware), + typeof(InvalidProcessingOptionsExceptionMiddleware), + typeof(NotImplementedExceptionMiddleware), + typeof(ImageSourceNotAllowedExceptionMiddleware), + typeof(ImageProviderErrorMiddleware), + typeof(RedirectTimedOutRequestsMiddleware), + typeof(NotModifiedExceptionMiddleware), + typeof(ArgumentExceptionMiddleware), + typeof(ArgumentNullExceptionMiddleware), + typeof(AuthorizationMiddleware) + ]; + + [Test] + public async Task ShouldRunProjectMiddlewareInTheExpectedOrder() + { + // Arrange + // Act + var result = await ObserveMiddlewarePipeline(); + + // Assert + Expect(result) + .To.Equal(ExpectedPipeline); + } + + [Test] + public async Task ShouldRunEveryMiddlewareTheProjectDefines() + { + // catches the "wrote the middleware, forgot to wire it up" mistake, + // which is invisible to a unit test of the middleware itself + // Arrange + var defined = typeof(Startup).Assembly + .GetTypes() + .Where(t => t.IsClass) + .Where(t => !t.IsAbstract) + .Where(t => typeof(IMiddleware).IsAssignableFrom(t)) + .OrderBy(t => t.Name) + .ToArray(); + Expect(defined) + .Not.To.Be.Empty(); + + // Act + var result = await ObserveMiddlewarePipeline(); + + // Assert + Expect(result.OrderBy(t => t.Name).ToArray()) + .To.Equal( + defined, + () => "every IMiddleware in the IRAAS assembly should be in the pipeline:\n" + + $" missing: {Describe(defined.Except(result))}\n" + + $" unexpected: {Describe(result.Except(defined))}" + ); + } + + private static string Describe(IEnumerable types) + { + var names = types.Select(t => t.Name).ToArray(); + return names.Any() + ? string.Join(", ", names) + : "(none)"; + } + + /// + /// Middleware registered via UseMiddleware<T> is instantiated through + /// IMiddlewareFactory, in pipeline order, on the way in. Swapping in a + /// recording factory therefore observes the real, assembled order rather + /// than re-reading the source. + /// + private static async Task ObserveMiddlewarePipeline() + { + var recorded = new List(); + using var tempFolder = new AutoTempFolder(); + WriteAppSettings( + tempFolder, + 10_000_000 + ); + using var _ = InFolder(tempFolder.Path); + using var host = Program.CreateWebHostBuilder([]) + .ConfigureServices( + services => services.AddSingleton( + provider => new RecordingMiddlewareFactory(provider, recorded) + ) + ) + .Build(); + await host.StartAsync(); + try + { + using var client = new HttpClient + { + BaseAddress = new Uri(BaseUrlOf(host)) + }; + // any request reaching the innermost middleware will do - every + // middleware ahead of it is constructed on the way in, so this + // does not need to be a request that actually succeeds + using var response = await client.GetAsync("/"); + Expect(response.StatusCode) + .To.Equal( + HttpStatusCode.NotFound, + "a url-less resize request should be rejected by AuthorizationMiddleware" + ); + } + finally + { + await host.StopAsync(); + } + + return recorded.ToArray(); + } + + private static string BaseUrlOf(IWebHost host) + { + return host.Services + .GetRequiredService() + .Features + .Get()! + .Addresses + .First(); + } + + private class RecordingMiddlewareFactory : IMiddlewareFactory + { + private readonly IServiceProvider _provider; + private readonly List _recorded; + + public RecordingMiddlewareFactory( + IServiceProvider provider, + List recorded + ) + { + _provider = provider; + _recorded = recorded; + } + + public IMiddleware Create(Type middlewareType) + { + _recorded.Add(middlewareType); + return _provider.GetRequiredService(middlewareType) as IMiddleware; + } + + public void Release(IMiddleware middleware) + { + } + } + } + + 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}}" + } + }, + """; + // most of these tests only Build() the host, but the ones that Start() it + // need a port of their own - a fixed one would collide with a locally-running + // instance or another test run on the same machine + File.WriteAllText( + Path.Combine(folder.Path, AppSettingsProvider.BASE_CONFIG), + $$""" + { + "Urls": "http://127.0.0.1:{{PortFinder.FindOpenPort()}}", + "Logging": { + "LogLevel": { + "Default": "None", + "IRAAS": "None" + } + }, + {{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 269f997..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}" @@ -34,6 +35,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 +56,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 +74,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 +92,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 +110,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 7311394..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) @@ -96,11 +92,15 @@ IConfigurationRoot config var result = merged.FuzzyDuckAs( throwOnError: true ); - if (result.MaxUrlFetchRetries < 0) + if (result.AllowInvalidSslCertificates) { - // alter the underlying dictionary of settings - // -> the IAppSettings interface is read-only - providedConfig[nameof(result.MaxUrlFetchRetries)] = "0"; + Console.WriteLine( + """ + --------------------------------------------------------------- + WARNING: invalid ssl certificates allowed for remote image urls + --------------------------------------------------------------- + """ + ); } return result; @@ -133,10 +133,11 @@ IConfigurationRoot config { continue; } + var sub = LoadSection(config, $"DefaultParameters:{item.Key}"); result.RegisterPerFormatDefaultsFor(item.Key, sub); } - + return result; } catch (UnDuckableException ex) @@ -155,7 +156,8 @@ string sectionName .GetChildren() .ToDictionary( o => o.Key, - o => o.Value + o => o.Value, + StringComparer.OrdinalIgnoreCase ); } @@ -165,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); + .ToDictionary( + o => o.Name, + o => o.Value, + StringComparer.OrdinalIgnoreCase + ); } private static string FindLogLevelFor( @@ -190,9 +196,23 @@ 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, + [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 ) @@ -202,8 +222,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 : ""; } @@ -250,6 +270,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, @@ -278,7 +311,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 @@ -287,10 +320,14 @@ 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_POST_AUTH_TOKENS = nameof(IAppSettings.PostAuthTokens); + + 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 diff --git a/src/IRAAS/Bootstrapper.cs b/src/IRAAS/Bootstrapper.cs index 5329ab1..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); 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/HealthController.cs b/src/IRAAS/Controllers/HealthController.cs index a8b15b6..ab4edad 100644 --- a/src/IRAAS/Controllers/HealthController.cs +++ b/src/IRAAS/Controllers/HealthController.cs @@ -1,11 +1,13 @@ -using Microsoft.AspNetCore.Mvc; +using IRAAS.Middleware; +using Microsoft.AspNetCore.Mvc; namespace IRAAS.Controllers; -[Route("health")] +[Route(Routes.HEALTH)] public class HealthController : ControllerBase { [HttpGet] [Route("")] + [ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)] public OkResult GetHealth() { return Ok(); diff --git a/src/IRAAS/Controllers/ImageResizeController.cs b/src/IRAAS/Controllers/ImageResizeController.cs index 7f82ad5..af97a85 100644 --- a/src/IRAAS/Controllers/ImageResizeController.cs +++ b/src/IRAAS/Controllers/ImageResizeController.cs @@ -1,5 +1,5 @@ +using System; using System.Threading.Tasks; -using IRAAS.Exceptions; using IRAAS.ImageProcessing; using IRAAS.Security; using Microsoft.AspNetCore.Http; @@ -13,33 +13,28 @@ 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 + IHttpContextAccessor httpContextAccessor, + IAppSettings appSettings ) { _imageResizer = imageResizer; _mimeTypeProvider = mimeTypeProvider; - _whitelist = whitelist; _httpContextAccessor = httpContextAccessor; + _appSettings = appSettings; } [Route("")] [HttpGet] - public async Task Resize( - [FromQuery] ImageResizeParameters resizeParameters = null + 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() @@ -56,4 +51,49 @@ public async Task Resize( contentType ); } + + /// + /// 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 + ) + { + 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 + ); + var contentType = _mimeTypeProvider.DetermineMimeTypeFor( + result.Stream + ); + return new FileStreamResult( + result.Stream, + contentType + ); + } } \ No newline at end of file diff --git a/src/IRAAS/Controllers/TestController.cs b/src/IRAAS/Controllers/TestController.cs index a898560..977bc49 100644 --- a/src/IRAAS/Controllers/TestController.cs +++ b/src/IRAAS/Controllers/TestController.cs @@ -1,11 +1,11 @@ -using System; using System.Threading.Tasks; using IRAAS.ImageProcessing; +using IRAAS.Middleware; using Microsoft.AspNetCore.Mvc; namespace IRAAS.Controllers; -[Route("test")] +[Route("")] public class TestController : Controller { private readonly IAppSettings _settings; @@ -16,36 +16,27 @@ public TestController( IUrlFetcher fetcher ) { - if (!settings.EnableTestPage) - { - throw new NotImplementedException(); - } - _settings = settings; _fetcher = fetcher; } - [ResponseCache(NoStore = true)] - [Route("")] + [ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)] + [Route(Routes.TEST)] [HttpGet] public ActionResult Test() { return View(_settings); } - [Route("")] - [HttpPost] - public async Task FileSize([FromBody] FileSizeRequest req) + [ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)] + [Route(Routes.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 5b8ca6e..3a1c280 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"; @@ -78,4 +79,13 @@ public interface IAppSettings [DefaultSetting(FALSE)] bool Verbose { get; } -} \ No newline at end of file + + [DefaultSetting(FALSE)] + bool AllowInvalidSslCertificates { get; } + + [DefaultSetting(FALSE)] + bool AllowPostRequests { get; } + + [DefaultSetting("")] + string PostAuthTokens { get; } +} diff --git a/src/IRAAS/IRAAS.csproj b/src/IRAAS/IRAAS.csproj index 1a46fca..44a8fab 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..0b1dc3c 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/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 d2a2d49..85e4597 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( + ImageDataResizeParameters resizeParameters + ); } public class ImageResizer : IImageResizer @@ -45,7 +48,7 @@ IAppSettings appSettings } public async Task Resize( - ImageResizeParameters resizeParameters, + IUrlImageResizeParameters resizeParameters, IDictionary requestHeaders ) { @@ -54,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( @@ -62,6 +65,57 @@ IDictionary requestHeaders requestHeaders ) ); + return ResizeImage( + resizeParameters, + timer, + src + ); + } + + private Timer CreateTimer() + { + return new Timer(_appSettings); + } + + 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( + resizeParameters.ImageData + ), + new Dictionary() + ); + return Task.FromResult( + ResizeImage( + resizeParameters, + timer, + src + ) + ); + } + + private StreamAndHeaders ResizeImage( + IActiveImageResizeParameters resizeParameters, + Timer timer, + StreamAndHeaders src + ) + { IImageFormat sourceFormat = null; try { @@ -78,11 +132,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 +198,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 +215,9 @@ resizeParameters.ReplaceTransparencyWith is not null && ); } - private IResampler DetermineSamplerFor(ImageResizeParameters resizeParameters) + private IResampler DetermineSamplerFor( + IActiveImageResizeParameters resizeParameters + ) { if (resizeParameters.Sampler is null) { @@ -160,19 +229,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 +254,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 +270,7 @@ private static Dictionary> Gener ); } - private static readonly Dictionary> QuantizerFactories + private static readonly Dictionary> QuantizerFactories = new() { [typeof(WuQuantizer)] = CreateWuQuantizer, @@ -210,29 +279,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 +323,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 +343,7 @@ private readonly Dictionary private static void ReEncodeAsWebp( Image source, Stream target, - ImageResizeParameters resizeParameters + IImageResizeParameters resizeParameters ) { source.SaveAsWebp( @@ -286,7 +361,7 @@ ImageResizeParameters resizeParameters private static void ReEncodeAsBmp( Image source, Stream target, - ImageResizeParameters resizeParameters + IImageResizeParameters resizeParameters ) { source.SaveAsBmp( @@ -301,7 +376,7 @@ ImageResizeParameters resizeParameters private static void ReEncodeAsGif( Image source, Stream target, - ImageResizeParameters resizeParameters + IImageResizeParameters resizeParameters ) { source.SaveAsGif( @@ -317,7 +392,7 @@ ImageResizeParameters resizeParameters private static void ReEncodeAsPng( Image source, Stream target, - ImageResizeParameters resizeParameters + IImageResizeParameters resizeParameters ) { source.SaveAsPng( @@ -338,7 +413,7 @@ ImageResizeParameters resizeParameters private static void ReEncodeAsJpeg( Image source, Stream target, - ImageResizeParameters resizeParameters + IImageResizeParameters resizeParameters ) { source.SaveAsJpeg( @@ -410,7 +485,7 @@ this int? compressionLevel try { - return (PngCompressionLevel) compressionLevel.Value; + return (PngCompressionLevel)compressionLevel.Value; } catch { @@ -419,7 +494,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/ImageUrlResizeParameters.cs similarity index 83% rename from src/IRAAS/ImageProcessing/ImageResizeParameters.cs rename to src/IRAAS/ImageProcessing/ImageUrlResizeParameters.cs index 7c40b94..b7b6376 100644 --- a/src/IRAAS/ImageProcessing/ImageResizeParameters.cs +++ b/src/IRAAS/ImageProcessing/ImageUrlResizeParameters.cs @@ -18,14 +18,31 @@ namespace IRAAS.ImageProcessing; -public class ImageResizeParameters - : IImageResizeParameters +public interface IUrlImageResizeParameters : IActiveImageResizeParameters { - public static void ClearDefaults() - { - SetDefaults(null); - } + 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 SetDefaults( IDefaultImageResizeParameters parameters ) @@ -34,44 +51,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 +215,6 @@ public int Quality } } - [Min(0)] [Max(2048)] [Default(400)] @@ -134,7 +240,6 @@ public int? Height } private int? _height; - public ResizeMode? ResizeMode { get; set; } [Obsolete("JpegColorType was renamed upstream to JpegEncodingColor")] @@ -145,17 +250,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 +268,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 +298,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 +326,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 ImageUrlResizeParameters + : 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/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(); 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 ); 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 new file mode 100644 index 0000000..817001a --- /dev/null +++ b/src/IRAAS/Middleware/AuthorizationMiddleware.cs @@ -0,0 +1,175 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using IRAAS.Exceptions; +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; + private readonly bool _openPostAllowed; + + public AuthorizationMiddleware( + IAppSettings appSettings, + IWhitelist whitelist + ) + { + _appSettings = appSettings; + _whitelist = whitelist; + _knownTokens = Split(appSettings.PostAuthTokens ?? "", ",") + .AsHashSet(); + _openPostAllowed = (appSettings.PostAuthTokens ?? "").Trim() == "*"; + } + + 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); + + return next(context); + } + + private void VerifyGetRequestAllowed( + HttpContext context + ) + { + if (IsAllowedHealthRequest(context.Request)) + { + return; + } + + if (IsAllowedTestPageRequest(context.Request)) + { + return; + } + + VerifyHaveValidUrlParameter(context.Request); + } + + private bool IsAllowedHealthRequest( + HttpRequest request + ) + { + return Routes.HasHealthPagePath(request); + } + + private void VerifyHaveValidUrlParameter( + HttpRequest req + ) + { + if ( + !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 ImageSourceNotAllowedException(requestedImageUrl); + } + } + + private static bool IsInvalidUrl( + string url + ) + { + return !Uri.TryCreate( + url, + UriKind.Absolute, + out _ + ); + } + + private bool IsAllowedTestPageRequest( + HttpRequest req + ) + { + var isSizePath = Routes.HasSizeEndpointPath(req); + var isTestPath = Routes.HasTestPagePath(req); + + if (!isSizePath && !isTestPath) + { + return false; + } + + if (!_appSettings.EnableTestPage) + { + throw new NotImplementedException(); + } + + if (isSizePath) + { + VerifyHaveValidUrlParameter(req); + } + + return true; + } + + private void VerifyPostRequestAllowed( + HttpContext context + ) + { + if (!_appSettings.AllowPostRequests) + { + throw new NotImplementedException(); + } + + if (_openPostAllowed) + { + return; + } + + 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/Middleware/BadHttpRequestExceptionMiddleware.cs b/src/IRAAS/Middleware/BadHttpRequestExceptionMiddleware.cs new file mode 100644 index 0000000..2e4d10a --- /dev/null +++ b/src/IRAAS/Middleware/BadHttpRequestExceptionMiddleware.cs @@ -0,0 +1,18 @@ +using System.Net; +using Microsoft.AspNetCore.Http; + +namespace IRAAS.Middleware; + +public class BadHttpRequestExceptionMiddleware + : ExceptionHandlerMiddleware +{ + public BadHttpRequestExceptionMiddleware( + IAppSettings appSettings + ) : base( + e => (HttpStatusCode)e.StatusCode, + (e, _) => e.Message, + appSettings + ) + { + } +} \ No newline at end of file diff --git a/src/IRAAS/Middleware/ConcurrencyMiddleware.cs b/src/IRAAS/Middleware/ConcurrencyMiddleware.cs index e3e0196..ddd0894 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,20 @@ 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) || + HasTestPagePath(context.Request) || + context.Request.Method == HttpMethods.Post; + 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 +113,8 @@ await PerformFullRequestWith( context, next, queryString, - completionSource + completionSource, + cacheProhibited ); } finally @@ -116,14 +123,44 @@ await PerformFullRequestWith( } } - private async Task PerformFullRequestWith( - HttpContext context, + private bool HasTestPagePath(HttpRequest req) + { + return Routes.HasTestPagePath(req) || + Routes.HasSizeEndpointPath(req); + } + + private bool HasNoStoreCacheControlHeader( + HttpRequest request + ) + { + var cacheControl = request.Headers["Cache-Control"]; + if (cacheControl == StringValues.Empty) + { + return false; + } + + return cacheControl.Any( + s => s.Split(',').Any( + part => part.Trim().Equals( + "no-store", + StringComparison.OrdinalIgnoreCase + ) + ) + ); + } + + 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 @@ -162,12 +199,18 @@ private async Task PerformFullRequestWith( ex, $"Error whilst attempting reuse of result for concurrent request ({queryString})" ); + completionSource.TrySetException(ex); + throw; } finally { // this request is no longer "current" // -> remove from collection - CurrentRequests.TryRemove(queryString, out _); + if (!cacheProhibited) + { + CurrentRequests.TryRemove(queryString, out _); + } + context.Response.Body = originalBody; } } @@ -187,13 +230,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 +247,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 +257,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", diff --git a/src/IRAAS/Middleware/ExceptionHandlerMiddleware.cs b/src/IRAAS/Middleware/ExceptionHandlerMiddleware.cs index d3ed1a4..915486a 100644 --- a/src/IRAAS/Middleware/ExceptionHandlerMiddleware.cs +++ b/src/IRAAS/Middleware/ExceptionHandlerMiddleware.cs @@ -16,23 +16,30 @@ public abstract class ExceptionHandlerMiddleware private readonly Func _errorMessageGenerator; private readonly IAppSettings _appSettings; private readonly Func _errorCodeGenerator; - public LogLevel LogLevel { get; } public ExceptionHandlerMiddleware( int errorCode, string errorMessage, IAppSettings appSettings - ) : this(errorCode, (e, c) => errorMessage, appSettings) + ) : this( + errorCode, + (e, c) => errorMessage, + appSettings + ) { } - public ExceptionHandlerMiddleware( int errorCode, Func errorMessageGenerator, IAppSettings appSettings - ) : this(errorCode, errorMessageGenerator, appSettings, LogLevel.Error) + ) : this( + errorCode, + errorMessageGenerator, + appSettings, + LogLevel.Error + ) { } @@ -41,7 +48,12 @@ public ExceptionHandlerMiddleware( Func errorMessageGenerator, IAppSettings appSettings, LogLevel logLevel - ) : this(_ => errorCode, errorMessageGenerator, appSettings, logLevel) + ) : this( + _ => errorCode, + errorMessageGenerator, + appSettings, + logLevel + ) { } @@ -50,7 +62,7 @@ public ExceptionHandlerMiddleware( Func errorMessageGenerator, IAppSettings appSettings ) : this( - e => (int) statusCodeGenerator(e), + e => (int)statusCodeGenerator(e), errorMessageGenerator, appSettings, LogLevel.Error @@ -64,7 +76,7 @@ public ExceptionHandlerMiddleware( IAppSettings appSettings, LogLevel logLevel ) : this( - e => (int) statusCodeGenerator(e), + e => (int)statusCodeGenerator(e), errorMessageGenerator, appSettings, logLevel @@ -96,6 +108,15 @@ RequestDelegate next } catch (T ex) { + if (context.Response.HasStarted) + { + // we can't modify the request once it has + // started - if an exception is thrown whilst + // streaming, handle it accordingly + TryLogException(context, ex); + return; + } + context.Response.StatusCode = _errorCodeGenerator(ex); var content = _appSettings.SuppressErrorDiagnostics ? "" @@ -104,7 +125,11 @@ RequestDelegate next var asBytes = Encoding.UTF8.GetBytes(content); context.Response.Headers["Content-Type"] = "text/plain; charset=utf-8"; - await context.Response.Body.WriteAsync(asBytes, 0, asBytes.Length); + await context.Response.Body.WriteAsync( + asBytes, + 0, + asBytes.Length + ); TryLogException(context, ex); } } 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/Middleware/ProductionFallbackExceptionHandlerMiddleware.cs b/src/IRAAS/Middleware/ProductionFallbackExceptionHandlerMiddleware.cs index c0c0e36..f1be638 100644 --- a/src/IRAAS/Middleware/ProductionFallbackExceptionHandlerMiddleware.cs +++ b/src/IRAAS/Middleware/ProductionFallbackExceptionHandlerMiddleware.cs @@ -27,7 +27,14 @@ public async Task InvokeAsync(HttpContext context, RequestDelegate next) _logger.LogError( $"Unhandled exception servicing:\n{context.Request.QueryString}\n{ex.Message}\n{ex.StackTrace}" ); - context.Response.StatusCode = 500; + if (!context.Response.HasStarted) + { + // we can't change the status code once + // the response has started, so if the + // exception caught here happened during + // content streaming, this set would fail + context.Response.StatusCode = 500; + } } } } \ No newline at end of file diff --git a/src/IRAAS/Middleware/Routes.cs b/src/IRAAS/Middleware/Routes.cs new file mode 100644 index 0000000..d2da21e --- /dev/null +++ b/src/IRAAS/Middleware/Routes.cs @@ -0,0 +1,66 @@ +using System; +using Microsoft.AspNetCore.Http; + +namespace IRAAS.Middleware; + +public static class Routes +{ + public const string SIZE = "size"; + public const string TEST = "test"; + public const string HEALTH = "health"; + private static readonly string SizePathBare = $"/{SIZE}"; + private static readonly string TestPathBare = $"/{TEST}"; + private static readonly string HealthPathBare = $"/{HEALTH}"; + private static readonly string SizePathTrailingSlash = $"{SizePathBare}/"; + private static readonly string TestPathTrailingSlash = $"{TestPathBare}/"; + private static readonly string HealthPathTrailingSlash = $"{HealthPathBare}/"; + + public static bool HasHealthPagePath( + HttpRequest request + ) + { + return AnyPathMatches( + request, + HealthPathBare, + HealthPathTrailingSlash + ); + } + + 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/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 1508e12..25655ec 100644 --- a/src/IRAAS/Startup.cs +++ b/src/IRAAS/Startup.cs @@ -1,19 +1,38 @@ using System; using System.Collections; +using System.Text.Json.Serialization; using DryIoc; using DryIoc.Microsoft.DependencyInjection; using IRAAS.Middleware; 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; +using PeanutButter.Utils; 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, @@ -21,8 +40,7 @@ public void Configure( ILoggerFactory loggerFactory ) { - var config = AppSettingsProvider.CreateAppSettings(); - SetupLog4NetWith(loggerFactory, config); + SetupLog4NetWith(loggerFactory, AppSettings); app.Use( (context, next) => { @@ -31,7 +49,7 @@ ILoggerFactory loggerFactory } ); if (env.IsDevelopment() || - config.UseDeveloperExceptionPage) + AppSettings.UseDeveloperExceptionPage) { app.UseDeveloperExceptionPage(); } @@ -40,13 +58,14 @@ ILoggerFactory loggerFactory app.UseMiddleware(); } - if (config.UseHttps) + if (AppSettings.UseHttps) { app.UseHttpsRedirection(); app.UseHsts(); } app.UseMiddleware(); + app.UseMiddleware(); app.UseMiddleware(); app.UseMiddleware(); app.UseMiddleware(); @@ -54,10 +73,16 @@ ILoggerFactory loggerFactory app.UseMiddleware(); app.UseMiddleware(); app.UseMiddleware(); + app.UseMiddleware(); + app.UseMiddleware(); + app.UseMiddleware(); app.UseRouting(); app.UseEndpoints(e => e.MapControllers()); - DumpEnvironmentVariables(); + if (Environment.GetEnvironmentVariable("DUMP_ENVIRONMENT").AsBoolean()) + { + DumpEnvironmentVariables(); + } } private void SetupLog4NetWith( @@ -79,8 +104,20 @@ public IServiceProvider ConfigureServices( IServiceCollection services ) { - services.AddMvc(); - + services.AddMvc() + .AddJsonOptions( + opts => + { + opts.JsonSerializerOptions.Converters.Add( + new JsonStringEnumConverter() + ); + } + ); + services.Configure( + opts => { opts.Limits.MaxRequestBodySize = ResolveMaxRequestBodySize(); } + ); + services.AddSingleton(AppSettings); + var container = new Container( Rules.Default .With(FactoryMethod.ConstructorWithResolvableArguments) @@ -92,6 +129,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 b4111d8..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 @@