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