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())).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(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())).ReturnsAsync(user); var file = new Mock(); 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(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()), 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())).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(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())).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(() => 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())).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(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())).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(response); Assert.Equal(stored, user.AvatarImageDataUrl); Assert.Equal("data:image/png;base64,AQID", AvatarStorage.Resolve(stored)); users.Verify(manager => manager.UpdateAsync(It.IsAny()), Times.Never); } finally { if (Directory.Exists(root)) Directory.Delete(root, true); } } private static AuthController Controller(string root, ApplicationUser user, UserManager users, JobTrackerApi.Data.JobTrackerContext db) => new(new ConfigurationBuilder().AddInMemoryCollection(new Dictionary { ["Data:Root"] = root }).Build(), users, Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of>(), Mock.Of(), db) { ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, user.Id) }, "local")) }, }, }; }