Files
jobtrackingapp/JobTrackerApi.Tests/AvatarUploadTests.cs
T

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")) },
},
};
}