271 lines
14 KiB
C#
271 lines
14 KiB
C#
using System.Security.Claims;
|
|
using JobTrackerApi.Controllers;
|
|
using JobTrackerApi.Models;
|
|
using JobTrackerApi.Services;
|
|
using JobTrackerApi.Tests.TestSupport;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.Identity;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.Extensions.Configuration;
|
|
using Microsoft.Extensions.Logging;
|
|
using Moq;
|
|
using SkiaSharp;
|
|
using Xunit;
|
|
|
|
namespace JobTrackerApi.Tests;
|
|
|
|
public sealed class AvatarUploadTests
|
|
{
|
|
[Theory]
|
|
[InlineData(SKEncodedImageFormat.Png)]
|
|
[InlineData(SKEncodedImageFormat.Jpeg)]
|
|
[InlineData(SKEncodedImageFormat.Webp)]
|
|
public void Actual_supported_images_are_decoded_and_normalized_to_metadata_free_png(SKEncodedImageFormat format)
|
|
{
|
|
var source = Image(format, 12, 8).Concat(System.Text.Encoding.UTF8.GetBytes("private-camera-metadata")).ToArray();
|
|
var normalized = AvatarImageProcessor.Normalize(source);
|
|
Assert.NotNull(normalized);
|
|
Assert.True(normalized.Length <= 1_000_000);
|
|
Assert.DoesNotContain("private-camera-metadata", System.Text.Encoding.UTF8.GetString(normalized));
|
|
using var codec = SKCodec.Create(new MemoryStream(normalized));
|
|
Assert.Equal(SKEncodedImageFormat.Png, codec.EncodedFormat);
|
|
Assert.Equal(12, codec.Info.Width);
|
|
Assert.Equal(8, codec.Info.Height);
|
|
using var decoded = SKBitmap.Decode(normalized);
|
|
Assert.NotNull(decoded);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(2049, 1)]
|
|
[InlineData(1, 2049)]
|
|
[InlineData(2001, 2000)]
|
|
public void Oversized_dimensions_are_rejected(int width, int height) =>
|
|
Assert.Null(AvatarImageProcessor.Normalize(Image(SKEncodedImageFormat.Png, width, height)));
|
|
|
|
[Fact]
|
|
public void Truncated_pixels_and_unsupported_formats_are_rejected()
|
|
{
|
|
var png = Image(SKEncodedImageFormat.Png, 12, 8);
|
|
Assert.Null(AvatarImageProcessor.Normalize(png[..40]));
|
|
Assert.Null(AvatarImageProcessor.Normalize(Convert.FromBase64String("R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7")));
|
|
}
|
|
|
|
[Fact]
|
|
public void Animated_webp_is_rejected_instead_of_silently_selecting_a_frame()
|
|
{
|
|
using var first = new SKBitmap(12, 8);
|
|
using var second = new SKBitmap(12, 8);
|
|
first.Erase(SKColors.Red);
|
|
second.Erase(SKColors.Blue);
|
|
using var animated = SKWebpEncoder.EncodeAnimated(new SKWebpEncoderFrame[]
|
|
{
|
|
new(first, TimeSpan.FromMilliseconds(100)), new(second, TimeSpan.FromMilliseconds(100)),
|
|
}, SKWebpEncoderOptions.Default);
|
|
Assert.NotNull(animated);
|
|
Assert.Null(AvatarImageProcessor.Normalize(animated.ToArray()));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(1)] [InlineData(2)] [InlineData(3)] [InlineData(4)]
|
|
[InlineData(5)] [InlineData(6)] [InlineData(7)] [InlineData(8)]
|
|
public void Exif_orientation_is_applied_before_metadata_is_removed(int orientation)
|
|
{
|
|
var jpeg = Image(SKEncodedImageFormat.Jpeg, 12, 8);
|
|
using var original = SKBitmap.Decode(jpeg);
|
|
var normalized = AvatarImageProcessor.Normalize(WithOrientation(jpeg, orientation));
|
|
Assert.NotNull(normalized);
|
|
using var result = SKBitmap.Decode(normalized);
|
|
Assert.Equal(orientation >= 5 ? original.Height : original.Width, result.Width);
|
|
Assert.Equal(orientation >= 5 ? original.Width : original.Height, result.Height);
|
|
for (var y = 0; y < result.Height; y++)
|
|
for (var x = 0; x < result.Width; x++)
|
|
{
|
|
var source = orientation switch
|
|
{
|
|
2 => (original.Width - 1 - x, y),
|
|
3 => (original.Width - 1 - x, original.Height - 1 - y),
|
|
4 => (x, original.Height - 1 - y),
|
|
5 => (y, x),
|
|
6 => (y, original.Height - 1 - x),
|
|
7 => (original.Width - 1 - y, original.Height - 1 - x),
|
|
8 => (original.Width - 1 - y, x),
|
|
_ => (x, y),
|
|
};
|
|
Assert.Equal(original.GetPixel(source.Item1, source.Item2), result.GetPixel(x, y));
|
|
}
|
|
using var codec = SKCodec.Create(new MemoryStream(normalized));
|
|
Assert.Equal(SKEncodedOrigin.TopLeft, codec.EncodedOrigin);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Identity_failure_preserves_previous_file_and_removes_staged_image()
|
|
{
|
|
var root = Path.Combine(Path.GetTempPath(), "avatar-identity-" + Guid.NewGuid().ToString("N"));
|
|
try
|
|
{
|
|
var stored = await AvatarStorage.StoreAsync(root, "owner", new byte[] { 1, 2, 3 }, "image/jpeg", default);
|
|
var user = new ApplicationUser { Id = "owner", AvatarImageDataUrl = stored };
|
|
var users = TestHostFactory.CreateUserManager();
|
|
users.Setup(manager => manager.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
|
|
users.Setup(manager => manager.UpdateAsync(user)).ReturnsAsync(IdentityResult.Failed(new IdentityError { Description = "private failure" }));
|
|
await using var db = TestHostFactory.CreateInMemoryDb();
|
|
var controller = Controller(root, user, users.Object, db);
|
|
await using var stream = new MemoryStream(Image(SKEncodedImageFormat.Png, 12, 8));
|
|
var result = Assert.IsType<BadRequestObjectResult>(await controller.UploadAvatar(new FormFile(stream, 0, stream.Length, "file", "portrait.png")));
|
|
Assert.DoesNotContain("private failure", result.Value?.ToString() ?? string.Empty);
|
|
Assert.Equal(stored, user.AvatarImageDataUrl);
|
|
Assert.Equal("data:image/jpeg;base64,AQID", AvatarStorage.Resolve(stored));
|
|
Assert.Single(Directory.GetFiles(root, "*", SearchOption.AllDirectories));
|
|
}
|
|
finally { if (Directory.Exists(root)) Directory.Delete(root, true); }
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Actual_stream_bytes_are_bounded_even_when_declared_length_is_small()
|
|
{
|
|
var user = new ApplicationUser { Id = "owner" };
|
|
var users = TestHostFactory.CreateUserManager();
|
|
users.Setup(manager => manager.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
|
|
var file = new Mock<IFormFile>();
|
|
file.SetupGet(value => value.Length).Returns(8);
|
|
file.SetupGet(value => value.FileName).Returns("portrait.png");
|
|
file.Setup(value => value.OpenReadStream()).Returns(new MemoryStream(new byte[1_000_001]));
|
|
await using var db = TestHostFactory.CreateInMemoryDb();
|
|
var result = Assert.IsType<BadRequestObjectResult>(await Controller(Path.GetTempPath(), user, users.Object, db).UploadAvatar(file.Object));
|
|
Assert.Equal("Avatar image is too large.", result.Value);
|
|
users.Verify(manager => manager.UpdateAsync(It.IsAny<ApplicationUser>()), Times.Never);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Successful_identity_ack_is_not_reported_as_failed_when_old_file_cleanup_is_blocked()
|
|
{
|
|
var root = Path.Combine(Path.GetTempPath(), "avatar-cleanup-" + Guid.NewGuid().ToString("N"));
|
|
try
|
|
{
|
|
var stored = await AvatarStorage.StoreAsync(root, "owner", new byte[] { 1, 2, 3 }, "image/jpeg", default);
|
|
var user = new ApplicationUser { Id = "owner", AvatarImageDataUrl = stored };
|
|
var users = TestHostFactory.CreateUserManager();
|
|
users.Setup(manager => manager.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
|
|
users.Setup(manager => manager.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);
|
|
await using var db = TestHostFactory.CreateInMemoryDb();
|
|
await using var locked = new FileStream(stored[5..], FileMode.Open, FileAccess.Read, FileShare.None);
|
|
await using var stream = new MemoryStream(Image(SKEncodedImageFormat.Png, 12, 8));
|
|
var response = await Controller(root, user, users.Object, db).UploadAvatar(new FormFile(stream, 0, stream.Length, "file", "portrait.png"));
|
|
Assert.IsType<OkObjectResult>(response);
|
|
Assert.NotEqual(stored, user.AvatarImageDataUrl);
|
|
Assert.NotNull(AvatarStorage.Resolve(user.AvatarImageDataUrl));
|
|
}
|
|
finally { if (Directory.Exists(root)) Directory.Delete(root, true); }
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Ambiguous_identity_exception_retains_staged_target_for_reference_aware_reconciliation()
|
|
{
|
|
var root = Path.Combine(Path.GetTempPath(), "avatar-ambiguous-" + Guid.NewGuid().ToString("N"));
|
|
try
|
|
{
|
|
var stored = await AvatarStorage.StoreAsync(root, "owner", new byte[] { 1, 2, 3 }, "image/jpeg", default);
|
|
var user = new ApplicationUser { Id = "owner", AvatarImageDataUrl = stored };
|
|
var users = TestHostFactory.CreateUserManager();
|
|
users.Setup(manager => manager.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
|
|
string? committedTarget = null;
|
|
users.Setup(manager => manager.UpdateAsync(user)).Callback(() => committedTarget = user.AvatarImageDataUrl)
|
|
.ThrowsAsync(new IOException("Synthetic acknowledgement loss after commit"));
|
|
await using var db = TestHostFactory.CreateInMemoryDb();
|
|
await using var stream = new MemoryStream(Image(SKEncodedImageFormat.Png, 12, 8));
|
|
await Assert.ThrowsAsync<IOException>(() => Controller(root, user, users.Object, db)
|
|
.UploadAvatar(new FormFile(stream, 0, stream.Length, "file", "portrait.png")));
|
|
Assert.NotNull(committedTarget);
|
|
Assert.NotEqual(stored, committedTarget);
|
|
Assert.NotNull(AvatarStorage.Resolve(committedTarget));
|
|
Assert.Equal(stored, user.AvatarImageDataUrl);
|
|
Assert.Equal("data:image/jpeg;base64,AQID", AvatarStorage.Resolve(stored));
|
|
}
|
|
finally { if (Directory.Exists(root)) Directory.Delete(root, true); }
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Removal_is_acknowledged_when_identity_succeeded_but_file_cleanup_is_blocked()
|
|
{
|
|
var root = Path.Combine(Path.GetTempPath(), "avatar-remove-" + Guid.NewGuid().ToString("N"));
|
|
try
|
|
{
|
|
var stored = await AvatarStorage.StoreAsync(root, "owner", new byte[] { 1, 2, 3 }, "image/jpeg", default);
|
|
var user = new ApplicationUser { Id = "owner", AvatarImageDataUrl = stored };
|
|
var users = TestHostFactory.CreateUserManager();
|
|
users.Setup(manager => manager.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
|
|
users.Setup(manager => manager.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);
|
|
await using var db = TestHostFactory.CreateInMemoryDb();
|
|
await using var locked = new FileStream(stored[5..], FileMode.Open, FileAccess.Read, FileShare.None);
|
|
Assert.IsType<NoContentResult>(await Controller(root, user, users.Object, db).DeleteAvatar());
|
|
Assert.Null(user.AvatarImageDataUrl);
|
|
}
|
|
finally { if (Directory.Exists(root)) Directory.Delete(root, true); }
|
|
}
|
|
|
|
private static byte[] Image(SKEncodedImageFormat format, int width, int height)
|
|
{
|
|
using var bitmap = new SKBitmap(width, height);
|
|
using (var canvas = new SKCanvas(bitmap)) canvas.Clear(SKColors.Navy);
|
|
bitmap.SetPixel(0, 0, SKColors.Red);
|
|
bitmap.SetPixel(width - 1, 0, SKColors.Green);
|
|
bitmap.SetPixel(0, height - 1, SKColors.Yellow);
|
|
using var image = SKImage.FromBitmap(bitmap);
|
|
using var data = image.Encode(format, 100);
|
|
return data.ToArray();
|
|
}
|
|
|
|
private static byte[] WithOrientation(byte[] jpeg, int orientation)
|
|
{
|
|
using var payload = new MemoryStream();
|
|
using (var writer = new BinaryWriter(payload, System.Text.Encoding.UTF8, true))
|
|
{
|
|
writer.Write(new byte[] { 69, 120, 105, 102, 0, 0, 73, 73 });
|
|
writer.Write((ushort)42); writer.Write(8); writer.Write((ushort)1);
|
|
writer.Write((ushort)0x112); writer.Write((ushort)3); writer.Write(1);
|
|
writer.Write((ushort)orientation); writer.Write((ushort)0); writer.Write(0);
|
|
}
|
|
var bytes = payload.ToArray();
|
|
return jpeg[..2].Concat(new byte[] { 0xff, 0xe1, (byte)((bytes.Length + 2) >> 8), (byte)(bytes.Length + 2) })
|
|
.Concat(bytes).Concat(jpeg[2..]).ToArray();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Signature_only_png_is_rejected_without_replacing_the_previous_portrait()
|
|
{
|
|
var root = Path.Combine(Path.GetTempPath(), "avatar-validation-" + Guid.NewGuid().ToString("N"));
|
|
try
|
|
{
|
|
var originalBytes = new byte[] { 1, 2, 3 };
|
|
var stored = await AvatarStorage.StoreAsync(root, "owner", originalBytes, "image/png", default);
|
|
var user = new ApplicationUser { Id = "owner", AvatarImageDataUrl = stored };
|
|
var users = TestHostFactory.CreateUserManager();
|
|
users.Setup(manager => manager.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
|
|
users.Setup(manager => manager.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);
|
|
await using var db = TestHostFactory.CreateInMemoryDb();
|
|
var controller = Controller(root, user, users.Object, db);
|
|
await using var stream = new MemoryStream(new byte[] { 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a });
|
|
var file = new FormFile(stream, 0, stream.Length, "file", "portrait.png");
|
|
|
|
var response = await controller.UploadAvatar(file);
|
|
|
|
Assert.IsType<BadRequestObjectResult>(response);
|
|
Assert.Equal(stored, user.AvatarImageDataUrl);
|
|
Assert.Equal("data:image/png;base64,AQID", AvatarStorage.Resolve(stored));
|
|
users.Verify(manager => manager.UpdateAsync(It.IsAny<ApplicationUser>()), Times.Never);
|
|
}
|
|
finally { if (Directory.Exists(root)) Directory.Delete(root, true); }
|
|
}
|
|
|
|
private static AuthController Controller(string root, ApplicationUser user, UserManager<ApplicationUser> users, JobTrackerApi.Data.JobTrackerContext db) =>
|
|
new(new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?> { ["Data:Root"] = root }).Build(),
|
|
users, Mock.Of<ITokenService>(), Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(),
|
|
Mock.Of<IMicrosoftTokenValidator>(), Mock.Of<ILogger<AuthController>>(), Mock.Of<ITwoFactorPendingTokenService>(), db)
|
|
{
|
|
ControllerContext = new ControllerContext
|
|
{
|
|
HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, user.Id) }, "local")) },
|
|
},
|
|
};
|
|
}
|