using System.Security.Claims; using System.Text; 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 Xunit; namespace JobTrackerApi.Tests; public sealed class ClientErrorsControllerTests { [Fact] public void Report_logs_sanitized_payload_instead_of_raw_stacks() { var logger = new ListLogger(); var controller = new ClientErrorsController(logger); var stack = "TypeError: bad\n at render(App.tsx:10)\nextra-secret-line"; var componentStack = "at Widget\n at Dashboard"; var result = controller.Report(new ClientErrorsController.ClientErrorReport( ErrorId: " err-1 ", Message: " boom ", Stack: stack, ComponentStack: componentStack, Url: " https://jobtracker.test/jobs ", UserAgent: " Browser\nAgent ", At: " 2026-04-10T18:00:00Z ")); Assert.IsType(result); var entry = Assert.Single(logger.Entries); Assert.Contains("stackHash=", entry.Message); Assert.Contains("componentHash=", entry.Message); Assert.Contains("TypeError: bad | at render(App.tsx:10)", entry.Message); Assert.DoesNotContain(stack, entry.Message); Assert.DoesNotContain(componentStack, entry.Message); Assert.DoesNotContain("extra-secret-line", entry.Message); Assert.DoesNotContain("Browser\nAgent", entry.Message); } [Fact] public async Task Upload_avatar_rejects_file_when_extension_or_detected_bytes_are_not_supported() { var user = new ApplicationUser { Id = "user-1", Email = "person@example.com", UserName = "person@example.com" }; var userManager = TestHostFactory.CreateUserManager(); userManager.Setup(x => x.GetUserAsync(It.IsAny())).ReturnsAsync(user); var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of>()) { ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, "user-1") }, "local")) } } }; await using var stream = new MemoryStream(Encoding.UTF8.GetBytes("not really a png")); IFormFile file = new FormFile(stream, 0, stream.Length, "file", "avatar.png") { Headers = new HeaderDictionary(), ContentType = "image/png" }; var result = await controller.UploadAvatar(file); var badRequest = Assert.IsType(result); Assert.Equal("Only PNG, JPEG, or WebP images are supported.", badRequest.Value); userManager.Verify(x => x.UpdateAsync(It.IsAny()), Times.Never); } private static IConfiguration BuildConfig() { return new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary()) .Build(); } private sealed class ListLogger : ILogger { public List Entries { get; } = new(); public IDisposable BeginScope(TState state) where TState : notnull => NullScope.Instance; public bool IsEnabled(LogLevel logLevel) => true; public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) { Entries.Add(new LogEntry(logLevel, formatter(state, exception))); } } private sealed record LogEntry(LogLevel Level, string Message); private sealed class NullScope : IDisposable { public static NullScope Instance { get; } = new(); public void Dispose() { } } }