diff --git a/Endpoints/AdminEndpoints.cs b/Endpoints/AdminEndpoints.cs index 086e3a0..96bbe31 100644 --- a/Endpoints/AdminEndpoints.cs +++ b/Endpoints/AdminEndpoints.cs @@ -191,7 +191,7 @@ public static class AdminEndpoints var suggestions = await db.Suggestions.ToListAsync(); var target = suggestions.FirstOrDefault(s => s.Id == request.SuggestionId); if (target is null) - return Results.NotFound(new { error = "Suggestion not found." }); + return Results.Ok(new { UnlinkedSuggestionIds = Array.Empty(), UnfinalizedPlayers = 0 }); var rootIndex = EndpointHelpers.BuildLinkRoots(suggestions.Select(s => (s.Id, s.ParentSuggestionId))); if (!rootIndex.TryGetValue(target.Id, out var rootId)) diff --git a/Endpoints/AuthEndpoints.cs b/Endpoints/AuthEndpoints.cs index 4f94097..06c3c1c 100644 --- a/Endpoints/AuthEndpoints.cs +++ b/Endpoints/AuthEndpoints.cs @@ -22,6 +22,9 @@ public static class AuthEndpoints if (string.IsNullOrWhiteSpace(request.Password)) return Results.BadRequest(new { error = "Password is required." }); + if (request.DisplayName?.Trim().Length > 16) + return Results.BadRequest(new { error = "Display name must be <= 16 characters." }); + var displayName = EndpointHelpers.TrimTo(request.DisplayName, 16); if (string.IsNullOrWhiteSpace(displayName)) return Results.BadRequest(new { error = "Display name is required." }); diff --git a/Endpoints/EndpointHelpers.cs b/Endpoints/EndpointHelpers.cs index d15c13e..6862ead 100644 --- a/Endpoints/EndpointHelpers.cs +++ b/Endpoints/EndpointHelpers.cs @@ -91,7 +91,7 @@ internal static class EndpointHelpers || path.EndsWith(".gif") || path.EndsWith(".webp") || path.EndsWith(".avif"); } - public static async Task IsReachableImageAsync(string? url, IHttpClientFactory httpFactory, CancellationToken ct = default) + public static async Task IsReachableImageAsync(string? url, IHttpClientFactory httpFactory, HttpMessageHandler? handler = null, CancellationToken ct = default) { if (string.IsNullOrWhiteSpace(url)) return true; if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) return false; @@ -101,11 +101,9 @@ internal static class EndpointHelpers using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); cts.CancelAfter(TimeSpan.FromSeconds(3)); - var handler = new HttpClientHandler - { - AllowAutoRedirect = false - }; - var client = new HttpClient(handler); + var client = handler is null + ? httpFactory.CreateClient("imageValidation") + : new HttpClient(handler, disposeHandler: false); try { @@ -113,10 +111,10 @@ internal static class EndpointHelpers var headResp = await client.SendAsync(head, HttpCompletionOption.ResponseHeadersRead, cts.Token); if (headResp.IsSuccessStatusCode && headResp.StatusCode is not System.Net.HttpStatusCode.Redirect) { + if (headResp.Content.Headers.ContentLength is long headLen && headLen > MaxImageBytes) return false; var ctHeader = headResp.Content.Headers.ContentType?.MediaType; if (!string.IsNullOrWhiteSpace(ctHeader) && ctHeader.StartsWith("image/", StringComparison.OrdinalIgnoreCase)) return true; - if (headResp.Content.Headers.ContentLength is long len && len > MaxImageBytes) return false; } } catch { /* fallback */ } diff --git a/Endpoints/StateEndpoints.cs b/Endpoints/StateEndpoints.cs index a3c2ead..34121db 100644 --- a/Endpoints/StateEndpoints.cs +++ b/Endpoints/StateEndpoints.cs @@ -80,6 +80,11 @@ public static class StateEndpoints group.MapPost("/me/name", async ([FromBody] SetNameRequest request, HttpContext ctx, AppDbContext db) => { + if (request.Name?.Trim().Length > 16) + { + return Results.BadRequest(new { error = "Name is required and must be <= 16 characters." }); + } + var name = EndpointHelpers.TrimTo(request.Name, 16); if (string.IsNullOrWhiteSpace(name)) { diff --git a/Endpoints/SuggestEndpoints.cs b/Endpoints/SuggestEndpoints.cs index f9c30dc..7b401b3 100644 --- a/Endpoints/SuggestEndpoints.cs +++ b/Endpoints/SuggestEndpoints.cs @@ -116,6 +116,13 @@ public static class SuggestEndpoints if (player is null) return Results.Unauthorized(); var isAdmin = await EndpointHelpers.IsAdmin(ctx, db); + if (!isAdmin) + { + var phase = await EndpointHelpers.GetPhase(db, player.Id); + if (phase != Phase.Suggest) + return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase); + } + var suggestion = isAdmin ? await db.Suggestions.FirstOrDefaultAsync(s => s.Id == id) : await db.Suggestions.FirstOrDefaultAsync(s => s.Id == id && s.PlayerId == player.Id); diff --git a/GameList.Tests/AdminTests.cs b/GameList.Tests/AdminTests.cs index f4d67a9..757a247 100644 --- a/GameList.Tests/AdminTests.cs +++ b/GameList.Tests/AdminTests.cs @@ -3,6 +3,7 @@ using System.Net.Http.Json; using System.Text.Json; using GameList.Domain; using GameList.Tests.Support; +using Microsoft.EntityFrameworkCore; namespace GameList.Tests; @@ -156,4 +157,171 @@ public class AdminTests Assert.Single(db.AppState); }); } + + [Fact] + public async Task Admin_results_closing_moves_back_to_vote_and_clears_finalize() + { + using var factory = new TestWebApplicationFactory(); + var admin = factory.CreateClientWithCookies(); + await admin.RegisterAsync("admin", admin: true); + var player = factory.CreateClientWithCookies(); + await player.RegisterAsync("player"); + + var open = await admin.PostAsJsonAsync("/api/admin/results", new { resultsOpen = true }); + open.EnsureSuccessStatusCode(); + + await factory.WithDbContextAsync(async db => + { + var p = await db.Players.FirstAsync(x => !x.IsAdmin); + p.VotesFinal = true; + await db.SaveChangesAsync(); + }); + + var beforeState = await factory.WithDbContextAsync(async db => await db.AppState.AsNoTracking().FirstAsync()); + await Task.Delay(5); + var close = await admin.PostAsJsonAsync("/api/admin/results", new { resultsOpen = false }); + close.EnsureSuccessStatusCode(); + + await factory.WithDbContextAsync(async db => + { + var p = await db.Players.FirstAsync(x => !x.IsAdmin); + Assert.Equal(Phase.Vote, p.CurrentPhase); + Assert.False(p.VotesFinal); + var state = await db.AppState.AsNoTracking().FirstAsync(); + Assert.False(state.ResultsOpen); + Assert.True(state.UpdatedAt > beforeState.UpdatedAt); + }); + } + + [Fact] + public async Task Vote_status_lists_waiting_players() + { + using var factory = new TestWebApplicationFactory(); + var admin = factory.CreateClientWithCookies(); + await admin.RegisterAsync("admin", admin: true); + await admin.PostAsJsonAsync("/api/me/phase/next", new { }); + + var p1 = factory.CreateClientWithCookies(); + await p1.RegisterAsync("alice"); + var p2 = factory.CreateClientWithCookies(); + await p2.RegisterAsync("bob"); + var s = await p1.CreateSuggestionAsync("Game"); + await p1.PostAsJsonAsync("/api/me/phase/next", new { }); + await p2.PostAsJsonAsync("/api/me/phase/next", new { }); + await p1.PostAsJsonAsync("/api/votes", new { SuggestionId = s, Score = 5 }); + await p1.PostAsJsonAsync("/api/votes/finalize", new { Final = true }); + + var status = await admin.GetFromJsonAsync("/api/admin/vote-status"); + Assert.False(status.GetProperty("ready").GetBoolean()); + var waiting = status.GetProperty("waiting").EnumerateArray().Select(e => e.GetString()).ToList(); + Assert.Contains("bob-name", waiting); + } + + [Fact] + public async Task Grant_joker_in_vote_sets_flag_and_unfinalizes() + { + using var factory = new TestWebApplicationFactory(); + var admin = factory.CreateClientWithCookies(); + await admin.RegisterAsync("admin", admin: true); + + var p = factory.CreateClientWithCookies(); + await p.RegisterAsync("player"); + await p.PostAsJsonAsync("/api/me/phase/next", new { }); + await p.PostAsJsonAsync("/api/votes/finalize", new { Final = true }); + + var give = await admin.PostAsJsonAsync("/api/admin/joker", new { playerId = (await p.GetProfileIdAsync()) }); + give.EnsureSuccessStatusCode(); + + await factory.WithDbContextAsync(async db => + { + var player = await db.Players.SingleAsync(x => x.Username == "player"); + Assert.True(player.HasJoker); + Assert.False(player.VotesFinal); + }); + } + + [Fact] + public async Task Link_requires_vote_phase_and_reparents_votes_reset() + { + using var factory = new TestWebApplicationFactory(); + var admin = factory.CreateClientWithCookies(); + await admin.RegisterAsync("admin", admin: true); + var player = factory.CreateClientWithCookies(); + await player.RegisterAsync("linker"); + + var a = await player.CreateSuggestionAsync("A"); + var b = await player.CreateSuggestionAsync("B"); + + var beforeVotePhase = await admin.PostAsJsonAsync("/api/admin/link-suggestions", new { SourceSuggestionId = a, TargetSuggestionId = b }); + Assert.Equal(HttpStatusCode.BadRequest, beforeVotePhase.StatusCode); + + await admin.PostAsJsonAsync("/api/me/phase/next", new { }); + await player.PostAsJsonAsync("/api/me/phase/next", new { }); + + await player.PostAsJsonAsync("/api/votes", new { SuggestionId = a, Score = 3 }); + await player.PostAsJsonAsync("/api/votes/finalize", new { Final = true }); + + var link = await admin.PostAsJsonAsync("/api/admin/link-suggestions", new { SourceSuggestionId = a, TargetSuggestionId = b }); + link.EnsureSuccessStatusCode(); + + await factory.WithDbContextAsync(async db => + { + var votes = await db.Votes.ToListAsync(); + Assert.Empty(votes); + var p = await db.Players.SingleAsync(x => x.Username == "linker"); + Assert.False(p.VotesFinal); + }); + } + + [Fact] + public async Task Unlink_not_found_returns_empty_payload() + { + using var factory = new TestWebApplicationFactory(); + var admin = factory.CreateClientWithCookies(); + await admin.RegisterAsync("admin", admin: true); + await admin.PostAsJsonAsync("/api/me/phase/next", new { }); + + var resp = await admin.PostAsJsonAsync("/api/admin/unlink-suggestions", new { suggestionId = 9999 }); + resp.EnsureSuccessStatusCode(); + var json = await resp.Content.ReadFromJsonAsync(); + Assert.Equal(0, json.GetProperty("unlinkedSuggestionIds").GetArrayLength()); + } + + [Fact] + public async Task Reset_clears_flags_and_factory_reset_seeds_defaults() + { + using var factory = new TestWebApplicationFactory(); + var admin = factory.CreateClientWithCookies(); + await admin.RegisterAsync("admin", admin: true); + var p = factory.CreateClientWithCookies(); + await p.RegisterAsync("flags"); + + await factory.WithDbContextAsync(async db => + { + var player = await db.Players.SingleAsync(x => x.Username == "flags"); + player.HasJoker = true; + player.VotesFinal = true; + await db.SaveChangesAsync(); + }); + + var reset = await admin.PostAsJsonAsync("/api/admin/reset", new { }); + reset.EnsureSuccessStatusCode(); + + await factory.WithDbContextAsync(async db => + { + var player = await db.Players.SingleAsync(x => x.Username == "flags"); + Assert.False(player.HasJoker); + Assert.False(player.VotesFinal); + var state = await db.AppState.AsNoTracking().FirstAsync(); + Assert.False(state.ResultsOpen); + }); + + var factoryReset = await admin.PostAsJsonAsync("/api/admin/factory-reset", new { }); + factoryReset.EnsureSuccessStatusCode(); + await factory.WithDbContextAsync(async db => + { + var state = await db.AppState.AsNoTracking().FirstAsync(); + Assert.False(state.ResultsOpen); + }); + } } diff --git a/GameList.Tests/AuthTests.cs b/GameList.Tests/AuthTests.cs index 816e3c2..bf64113 100644 --- a/GameList.Tests/AuthTests.cs +++ b/GameList.Tests/AuthTests.cs @@ -1,12 +1,95 @@ using System.Net; using System.Net.Http.Json; using System.Text.Json; +using GameList.Data; +using GameList.Infrastructure; using GameList.Tests.Support; +using Microsoft.EntityFrameworkCore; namespace GameList.Tests; public class AuthTests { + [Fact] + public async Task Register_trims_limits_and_sets_cookie_and_normalized_username() + { + using var factory = new TestWebApplicationFactory(); + var client = factory.CreateClientWithCookies(); + + var response = await client.PostAsJsonAsync("/api/auth/register", new + { + Username = " MixedCaseUser ", + Password = "Pass123!", + DisplayName = " Display Name ", + AdminKey = (string?)null + }); + + response.EnsureSuccessStatusCode(); + Assert.True(response.Headers.TryGetValues("Set-Cookie", out var cookies) && + cookies.Any(c => c.Contains(PlayerIdentityExtensions.PlayerCookieName))); + + await factory.WithDbContextAsync(async db => + { + var player = await db.Players.AsNoTracking().SingleAsync(p => p.Username == "MixedCaseUser"); + Assert.Equal("mixedcaseuser", player.NormalizedUsername); + Assert.True(player.DisplayName!.Length <= 16); + Assert.NotEqual(Array.Empty(), player.PasswordHash); + Assert.NotEqual(Array.Empty(), player.PasswordSalt); + }); + } + + [Fact] + public async Task Register_rejects_overlength_username_or_display_name() + { + using var factory = new TestWebApplicationFactory(); + var client = factory.CreateClientWithCookies(); + + var tooLongUser = new string('u', 25); + var userResp = await client.PostAsJsonAsync("/api/auth/register", new + { + Username = tooLongUser, + Password = "Pass123!", + DisplayName = "short" + }); + Assert.Equal(HttpStatusCode.BadRequest, userResp.StatusCode); + + var longDisplay = new string('d', 17); + var displayResp = await client.PostAsJsonAsync("/api/auth/register", new + { + Username = "okuser", + Password = "Pass123!", + DisplayName = longDisplay + }); + + Assert.Equal(HttpStatusCode.BadRequest, displayResp.StatusCode); + } + + [Fact] + public async Task Login_sets_last_login_and_fills_missing_display_name() + { + using var factory = new TestWebApplicationFactory(); + var client = factory.CreateClientWithCookies(); + await client.RegisterAsync("loginfill"); + + await factory.WithDbContextAsync(async db => + { + var player = await db.Players.FirstAsync(); + player.DisplayName = null; + player.LastLoginAt = DateTimeOffset.UnixEpoch; + await db.SaveChangesAsync(); + }); + + var login = await client.LoginAsync("loginfill", "Pass123!"); + login.EnsureSuccessStatusCode(); + + await factory.WithDbContextAsync(async db => + { + var player = await db.Players.AsNoTracking().SingleAsync(); + Assert.NotEqual(DateTimeOffset.UnixEpoch, player.LastLoginAt); + Assert.Equal("loginfill", player.DisplayName); + }); + } + [Fact] public async Task Register_with_admin_key_sets_admin_flag() { @@ -60,6 +143,28 @@ public class AuthTests Assert.Equal(HttpStatusCode.BadRequest, badKey.StatusCode); } + [Fact] + public async Task Non_admin_cannot_access_admin_routes() + { + using var factory = new TestWebApplicationFactory(); + var player = factory.CreateClientWithCookies(); + await player.RegisterAsync("regular"); + + var resp = await player.GetAsync("/api/admin/vote-status"); + Assert.Equal(HttpStatusCode.Unauthorized, resp.StatusCode); + } + + [Fact] + public async Task Admin_can_access_admin_routes() + { + using var factory = new TestWebApplicationFactory(); + var admin = factory.CreateClientWithCookies(); + await admin.RegisterAsync("adminuser", admin: true); + + var resp = await admin.GetAsync("/api/admin/vote-status"); + resp.EnsureSuccessStatusCode(); + } + [Fact] public async Task Logout_clears_cookie() { diff --git a/GameList.Tests/HelperTests.cs b/GameList.Tests/HelperTests.cs index 78cca41..da68bb1 100644 --- a/GameList.Tests/HelperTests.cs +++ b/GameList.Tests/HelperTests.cs @@ -1,13 +1,18 @@ using System.Net; +using System.Net.Http; using System.Net.Http.Headers; using System.Reflection; using GameList.Infrastructure; using GameList.Endpoints; using GameList.Tests.Support; +using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.FileProviders; using System.Linq; +using Microsoft.AspNetCore.Mvc.Testing; +using System.Text.Json; +using System.Net.Http.Json; namespace GameList.Tests; @@ -62,6 +67,74 @@ public class HelperTests Assert.False(await EndpointHelpers.IsReachableImageAsync("http://127.0.0.1/img.png", new StubHttpClientFactory(new StubHttpMessageHandler()))); } + [Fact] + public async Task IsReachableImageAsync_handles_head_success_redirect_and_size_guard() + { + var handler = new StubHttpMessageHandler(); + handler.SetResponder(req => + { + if (req.Method == HttpMethod.Head) + { + var resp = new HttpResponseMessage(HttpStatusCode.OK); + resp.Content = new ByteArrayContent(Array.Empty()); + resp.Content.Headers.ContentType = new MediaTypeHeaderValue("image/png"); + resp.Content.Headers.ContentLength = 100; + return resp; + } + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(Array.Empty()) + { + Headers = + { + ContentType = new MediaTypeHeaderValue("image/png"), + ContentLength = 100 + } + } + }; + }); + + var ok = await EndpointHelpers.IsReachableImageAsync("http://example.com/img.png", new StubHttpClientFactory(handler), handler); + Assert.True(ok); + + handler.SetResponder(_ => + { + var resp = new HttpResponseMessage(HttpStatusCode.Redirect); + resp.Headers.Location = new Uri("http://example.com/other"); + return resp; + }); + var redirect = await EndpointHelpers.IsReachableImageAsync("http://example.com/img.png", new StubHttpClientFactory(handler), handler); + Assert.False(redirect); + + handler.SetResponder(_ => + { + var resp = new HttpResponseMessage(HttpStatusCode.OK); + resp.Content = new ByteArrayContent(new byte[10]); + resp.Content.Headers.ContentType = new MediaTypeHeaderValue("image/png"); + resp.Content.Headers.ContentLength = 6 * 1024 * 1024; // over 5 MB + return resp; + }); + var tooLarge = await EndpointHelpers.IsReachableImageAsync("http://example.com/img.png", new StubHttpClientFactory(handler), handler); + Assert.False(tooLarge); + } + + [Fact] + public async Task IsReachableImageAsync_rejects_non_image_content() + { + var handler = new StubHttpMessageHandler(); + handler.SetResponder(_ => + { + var resp = new HttpResponseMessage(HttpStatusCode.OK); + resp.Content = new ByteArrayContent(System.Text.Encoding.UTF8.GetBytes("not image")); + resp.Content.Headers.ContentType = new MediaTypeHeaderValue("text/plain"); + resp.Content.Headers.ContentLength = 9; + return resp; + }); + + var result = await EndpointHelpers.IsReachableImageAsync("http://example.com/img.png", new StubHttpClientFactory(handler), handler); + Assert.False(result); + } + [Fact] public void Link_root_helpers_handle_groups() { @@ -98,6 +171,29 @@ public class HelperTests Assert.Equal(3, root); // cycle breaks on revisit } + [Fact] + public async Task Global_exception_handler_returns_json_error() + { + using var factory = new TestWebApplicationFactory().WithWebHostBuilder(builder => + { + builder.Configure(app => + { + app.UseGlobalExceptionLogging(); + app.UseRouting(); + app.UseEndpoints(endpoints => + { + endpoints.MapGet("/boom", _ => throw new InvalidOperationException("boom")); + }); + }); + }); + + var client = factory.CreateClient(); + var resp = await client.GetAsync("/boom"); + Assert.Equal(HttpStatusCode.InternalServerError, resp.StatusCode); + var json = await resp.Content.ReadFromJsonAsync(); + Assert.Equal("Unexpected server error", json.GetProperty("error").GetString()); + } + private class FakeEnv : IWebHostEnvironment { public string ApplicationName { get; set; } = ""; diff --git a/GameList.Tests/ResultsTests.cs b/GameList.Tests/ResultsTests.cs index 9b8e262..4f563f0 100644 --- a/GameList.Tests/ResultsTests.cs +++ b/GameList.Tests/ResultsTests.cs @@ -43,4 +43,50 @@ public class ResultsTests var resp = await client.GetAsync("/api/results"); Assert.Equal(System.Net.HttpStatusCode.BadRequest, resp.StatusCode); } + + [Fact] + public async Task Results_require_results_phase_and_auth() + { + using var factory = new TestWebApplicationFactory(); + var admin = factory.CreateClientWithCookies(); + await admin.RegisterAsync("admin", admin: true); + var player = factory.CreateClientWithCookies(); + await player.RegisterAsync("player"); + await admin.PostAsJsonAsync("/api/admin/results", new { resultsOpen = true }); + + var anon = factory.CreateClient(); + var unauthorized = await anon.GetAsync("/api/results"); + Assert.Equal(System.Net.HttpStatusCode.Unauthorized, unauthorized.StatusCode); + + var ok = await player.GetAsync("/api/results"); + Assert.Equal(System.Net.HttpStatusCode.OK, ok.StatusCode); + } + + [Fact] + public async Task Results_payload_contains_fields_and_ordering() + { + using var factory = new TestWebApplicationFactory(); + var admin = factory.CreateClientWithCookies(); + await admin.RegisterAsync("admin", admin: true); + + var player = factory.CreateClientWithCookies(); + await player.RegisterAsync("player"); + var s1 = await player.CreateSuggestionAsync("High"); + var s2 = await player.CreateSuggestionAsync("NoVotes"); + + await player.PostAsJsonAsync("/api/me/phase/next", new { }); + await player.PostAsJsonAsync("/api/votes", new { SuggestionId = s1, Score = 9 }); + await player.PostAsJsonAsync("/api/votes/finalize", new { Final = true }); + + await admin.PostAsJsonAsync("/api/admin/results", new { resultsOpen = true }); + await player.PostAsJsonAsync("/api/me/phase/next", new { }); + + var results = await player.GetFromJsonAsync>("/api/results"); + Assert.NotNull(results); + Assert.Equal(2, results!.Count); + Assert.Equal("High", results[0].GetProperty("name").GetString()); + Assert.Equal(9, (int)results[0].GetProperty("average").GetDouble()); + Assert.Equal(1, results[0].GetProperty("count").GetInt32()); + Assert.Equal(0, results[1].GetProperty("average").GetDouble()); + } } diff --git a/GameList.Tests/StateTests.cs b/GameList.Tests/StateTests.cs index 3c226ed..29cf66a 100644 --- a/GameList.Tests/StateTests.cs +++ b/GameList.Tests/StateTests.cs @@ -11,6 +11,142 @@ namespace GameList.Tests; public class StateTests { + [Fact] + public async Task State_endpoint_returns_expected_payload_for_authenticated_user() + { + using var factory = new TestWebApplicationFactory(); + var client = factory.CreateClientWithCookies(); + await client.RegisterAsync("payload"); + await factory.WithDbContextAsync(async db => + { + var player = await db.Players.FirstAsync(); + player.HasJoker = true; + await db.SaveChangesAsync(); + }); + await client.CreateSuggestionAsync("One"); + + var state = await client.GetFromJsonAsync("/api/state"); + + Assert.Equal(Phase.Suggest.ToString(), state.GetProperty("currentPhase").GetString()); + Assert.False(state.GetProperty("votesFinal").GetBoolean()); + Assert.True(state.GetProperty("hasJoker").GetBoolean()); + Assert.True(state.GetProperty("players").GetInt32() >= 1); + Assert.True(state.GetProperty("suggestions").GetInt32() >= 1); + Assert.True(state.GetProperty("votes").GetInt32() >= 0); + } + + [Fact] + public async Task GetPhase_upgrades_reveal_and_resets_when_results_close() + { + using var factory = new TestWebApplicationFactory(); + Guid playerId = Guid.Empty; + await factory.WithDbContextAsync(async db => + { + var player = new Player + { + Id = Guid.NewGuid(), + Username = "legacy", + NormalizedUsername = "legacy", + PasswordHash = new byte[] { 1 }, + PasswordSalt = new byte[] { 1 }, + DisplayName = "Legacy", + CurrentPhase = Phase.Reveal, + VotesFinal = true + }; + playerId = player.Id; + db.Players.Add(player); + var state = await db.AppState.FirstAsync(); + state.ResultsOpen = true; + await db.SaveChangesAsync(); + }); + + using (var scope = factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var phase = await GameList.Endpoints.EndpointHelpers.GetPhase(db, playerId); + Assert.Equal(Phase.Results, phase); + } + + await factory.WithDbContextAsync(async db => + { + var state = await db.AppState.FirstAsync(); + state.ResultsOpen = false; + await db.SaveChangesAsync(); + }); + + using (var scope = factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var phase = await GameList.Endpoints.EndpointHelpers.GetPhase(db, playerId); + var player = await db.Players.FindAsync(playerId); + Assert.Equal(Phase.Vote, phase); + Assert.False(player!.VotesFinal); + } + } + + [Fact] + public async Task Phase_next_advances_and_clears_votesfinal() + { + using var factory = new TestWebApplicationFactory(); + var client = factory.CreateClientWithCookies(); + await client.RegisterAsync("advance"); + + await factory.WithDbContextAsync(async db => + { + var player = await db.Players.FirstAsync(); + player.VotesFinal = true; + await db.SaveChangesAsync(); + }); + + var toVote = await client.PostAsJsonAsync("/api/me/phase/next", new { }); + toVote.EnsureSuccessStatusCode(); + var toResultsLocked = await client.PostAsJsonAsync("/api/me/phase/next", new { }); + Assert.Equal(HttpStatusCode.BadRequest, toResultsLocked.StatusCode); + + // unlock results and advance + var admin = factory.CreateClientWithCookies(); + await admin.RegisterAsync("admin", admin: true); + await admin.PostAsJsonAsync("/api/admin/results", new { resultsOpen = true }); + var toResults = await client.PostAsJsonAsync("/api/me/phase/next", new { }); + toResults.EnsureSuccessStatusCode(); + var me = await client.GetFromJsonAsync("/api/me"); + Assert.False(me.GetProperty("votesFinal").GetBoolean()); + Assert.Equal(Phase.Results.ToString(), me.GetProperty("currentPhase").GetString()); + } + + [Fact] + public async Task Phase_prev_moves_back_and_clears_votesfinal() + { + using var factory = new TestWebApplicationFactory(); + var admin = factory.CreateClientWithCookies(); + await admin.RegisterAsync("admin", admin: true); + + await admin.PostAsJsonAsync("/api/me/phase/next", new { }); // Vote + await factory.WithDbContextAsync(async db => + { + var player = await db.Players.FirstAsync(); + player.VotesFinal = true; + await db.SaveChangesAsync(); + }); + var backToSuggest = await admin.PostAsJsonAsync("/api/me/phase/prev", new { }); + backToSuggest.EnsureSuccessStatusCode(); + + var me = await admin.GetFromJsonAsync("/api/me"); + Assert.Equal(Phase.Suggest.ToString(), me.GetProperty("currentPhase").GetString()); + Assert.False(me.GetProperty("votesFinal").GetBoolean()); + } + + [Fact] + public async Task Name_endpoint_rejects_over_16_chars() + { + using var factory = new TestWebApplicationFactory(); + var client = factory.CreateClientWithCookies(); + await client.RegisterAsync("namelimit"); + + var resp = await client.PostAsJsonAsync("/api/me/name", new { name = new string('a', 17) }); + Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode); + } + [Fact] public async Task Cannot_advance_to_results_when_locked() { diff --git a/GameList.Tests/SuggestionTests.cs b/GameList.Tests/SuggestionTests.cs index f1b593c..34f231e 100644 --- a/GameList.Tests/SuggestionTests.cs +++ b/GameList.Tests/SuggestionTests.cs @@ -87,12 +87,16 @@ public class SuggestionTests using var factory = new TestWebApplicationFactory(); var player = factory.CreateClientWithCookies(); await player.RegisterAsync("joker"); + var other = factory.CreateClientWithCookies(); + await other.RegisterAsync("other"); await factory.WithDbContextAsync(async db => { var p = await db.Players.FirstAsync(); p.HasJoker = true; p.CurrentPhase = Domain.Phase.Vote; + var o = await db.Players.SingleAsync(x => x.Username == "other"); + o.VotesFinal = true; await db.SaveChangesAsync(); }); @@ -114,6 +118,8 @@ public class SuggestionTests var p = await db.Players.FirstAsync(); Assert.False(p.HasJoker); Assert.False(p.VotesFinal); + var o = await db.Players.SingleAsync(x => x.Username == "other"); + Assert.False(o.VotesFinal); }); } @@ -219,4 +225,173 @@ public class SuggestionTests var mine = await client.GetFromJsonAsync>("/api/suggestions/mine"); Assert.Equal("Second", mine![0].GetProperty("name").GetString()); } + + [Fact] + public async Task Create_requires_suggest_phase_and_display_name() + { + using var factory = new TestWebApplicationFactory(); + var client = factory.CreateClientWithCookies(); + await client.RegisterAsync("phasegate"); + + await factory.WithDbContextAsync(async db => + { + var p = await db.Players.FirstAsync(); + p.CurrentPhase = Domain.Phase.Vote; + p.DisplayName = null; + await db.SaveChangesAsync(); + }); + + var badPhase = await client.PostAsJsonAsync("/api/suggestions", new { Name = "Nope", Genre = (string?)null, Description = (string?)null, ScreenshotUrl = (string?)null, YoutubeUrl = (string?)null, GameUrl = (string?)null, MinPlayers = (int?)null, MaxPlayers = (int?)null }); + Assert.Equal(HttpStatusCode.BadRequest, badPhase.StatusCode); + + await factory.WithDbContextAsync(async db => + { + var p = await db.Players.FirstAsync(); + p.CurrentPhase = Domain.Phase.Suggest; + await db.SaveChangesAsync(); + }); + + var noDisplay = await client.PostAsJsonAsync("/api/suggestions", new { Name = "NoDisplay", Genre = (string?)null, Description = (string?)null, ScreenshotUrl = (string?)null, YoutubeUrl = (string?)null, GameUrl = (string?)null, MinPlayers = (int?)null, MaxPlayers = (int?)null }); + Assert.Equal(HttpStatusCode.BadRequest, noDisplay.StatusCode); + } + + [Fact] + public async Task Rejects_invalid_urls_name_length_and_player_counts() + { + using var factory = new TestWebApplicationFactory(); + var client = factory.CreateClientWithCookies(); + await client.RegisterAsync("validate2"); + + var badGame = await client.PostAsJsonAsync("/api/suggestions", new { Name = "Bad", Genre = (string?)null, Description = (string?)null, ScreenshotUrl = (string?)null, YoutubeUrl = (string?)null, GameUrl = "ftp://bad", MinPlayers = (int?)null, MaxPlayers = (int?)null }); + Assert.Equal(HttpStatusCode.BadRequest, badGame.StatusCode); + + var badYoutube = await client.PostAsJsonAsync("/api/suggestions", new { Name = "BadYt", Genre = (string?)null, Description = (string?)null, ScreenshotUrl = (string?)null, YoutubeUrl = "file://bad", GameUrl = (string?)null, MinPlayers = (int?)null, MaxPlayers = (int?)null }); + Assert.Equal(HttpStatusCode.BadRequest, badYoutube.StatusCode); + + var longName = await client.PostAsJsonAsync("/api/suggestions", new { Name = new string('x', 101), Genre = (string?)null, Description = (string?)null, ScreenshotUrl = (string?)null, YoutubeUrl = (string?)null, GameUrl = (string?)null, MinPlayers = (int?)null, MaxPlayers = (int?)null }); + Assert.Equal(HttpStatusCode.BadRequest, longName.StatusCode); + + var minOnly = await client.PostAsJsonAsync("/api/suggestions", new { Name = "MinOnly", Genre = (string?)null, Description = (string?)null, ScreenshotUrl = (string?)null, YoutubeUrl = (string?)null, GameUrl = (string?)null, MinPlayers = 2, MaxPlayers = (int?)null }); + Assert.Equal(HttpStatusCode.BadRequest, minOnly.StatusCode); + + var maxTooHigh = await client.PostAsJsonAsync("/api/suggestions", new { Name = "MaxHigh", Genre = (string?)null, Description = (string?)null, ScreenshotUrl = (string?)null, YoutubeUrl = (string?)null, GameUrl = (string?)null, MinPlayers = 2, MaxPlayers = 40 }); + Assert.Equal(HttpStatusCode.BadRequest, maxTooHigh.StatusCode); + } + + [Fact] + public async Task Trims_and_truncates_optional_fields() + { + using var factory = new TestWebApplicationFactory(); + var client = factory.CreateClientWithCookies(); + await client.RegisterAsync("trim"); + + var longGenre = new string('g', 60); + var longDesc = new string('d', 600); + var resp = await client.PostAsJsonAsync("/api/suggestions", new + { + Name = "Trim", + Genre = $" {longGenre} ", + Description = $" {longDesc} ", + ScreenshotUrl = "http://example.com/img.png", + YoutubeUrl = "http://example.com/y", + GameUrl = "http://example.com/g", + MinPlayers = 1, + MaxPlayers = 4 + }); + resp.EnsureSuccessStatusCode(); + + await factory.WithDbContextAsync(async db => + { + var s = await db.Suggestions.AsNoTracking().FirstAsync(); + Assert.Equal(50, s.Genre!.Length); + Assert.Equal(500, s.Description!.Length); + Assert.Equal("http://example.com/img.png", s.ScreenshotUrl); + }); + } + + [Fact] + public async Task Mine_excludes_other_players() + { + using var factory = new TestWebApplicationFactory(); + var a = factory.CreateClientWithCookies(); + await a.RegisterAsync("alice"); + var b = factory.CreateClientWithCookies(); + await b.RegisterAsync("bob"); + + await a.CreateSuggestionAsync("AliceGame"); + await b.CreateSuggestionAsync("BobGame"); + + var mine = await a.GetFromJsonAsync>("/api/suggestions/mine"); + Assert.Single(mine!); + Assert.Equal("AliceGame", mine[0].GetProperty("name").GetString()); + } + + [Fact] + public async Task All_returns_link_metadata_and_ordering() + { + using var factory = new TestWebApplicationFactory(); + var client = factory.CreateClientWithCookies(); + await client.RegisterAsync("owner"); + + var id1 = await client.CreateSuggestionAsync("Alpha"); + await Task.Delay(10); + var id2 = await client.CreateSuggestionAsync("Beta"); + await factory.WithDbContextAsync(async db => + { + var beta = await db.Suggestions.FindAsync(id2); + beta!.ParentSuggestionId = id1; + await db.SaveChangesAsync(); + }); + + await client.PostAsJsonAsync("/api/me/phase/next", new { }); // to Vote + + var all = await client.GetFromJsonAsync>("/api/suggestions/all"); + Assert.Equal(2, all!.Count); + var first = all[0]; + Assert.Equal("Alpha", first.GetProperty("name").GetString()); + var second = all[1]; + var linkedIds = second.GetProperty("linkedIds").EnumerateArray().Select(x => x.GetInt32()).ToList(); + Assert.Contains(id1, linkedIds); + var linkedTitles = second.GetProperty("linkedTitles").EnumerateArray().Select(x => x.GetString()).ToList(); + Assert.Contains("Alpha", linkedTitles); + } + + [Fact] + public async Task Delete_respects_phase_and_clears_links_and_votes() + { + using var factory = new TestWebApplicationFactory(); + var owner = factory.CreateClientWithCookies(); + await owner.RegisterAsync("deleter"); + var other = factory.CreateClientWithCookies(); + await other.RegisterAsync("voter"); + + var id = await owner.CreateSuggestionAsync("DeleteMe"); + var child = await owner.CreateSuggestionAsync("Child"); + await factory.WithDbContextAsync(async db => + { + var c = await db.Suggestions.FindAsync(child); + c!.ParentSuggestionId = id; + await db.SaveChangesAsync(); + }); + + await owner.PostAsJsonAsync("/api/me/phase/next", new { }); // Vote + await other.PostAsJsonAsync("/api/me/phase/next", new { }); + await other.PostAsJsonAsync("/api/votes", new { SuggestionId = id, Score = 5 }); + + var blocked = await owner.DeleteAsync($"/api/suggestions/{id}"); + Assert.Equal(HttpStatusCode.BadRequest, blocked.StatusCode); + + var admin = factory.CreateClientWithCookies(); + await admin.RegisterAsync("admin", admin: true); + var delete = await admin.DeleteAsync($"/api/suggestions/{id}"); + delete.EnsureSuccessStatusCode(); + + await factory.WithDbContextAsync(async db => + { + Assert.False(await db.Suggestions.AnyAsync(s => s.Id == id)); + var childEntity = await db.Suggestions.FindAsync(child); + Assert.Null(childEntity!.ParentSuggestionId); + Assert.False(db.Votes.Any(v => v.SuggestionId == id)); + }); + } } diff --git a/GameList.Tests/VoteTests.cs b/GameList.Tests/VoteTests.cs index 87994ab..8a3e8bc 100644 --- a/GameList.Tests/VoteTests.cs +++ b/GameList.Tests/VoteTests.cs @@ -1,5 +1,6 @@ using System.Net; using System.Net.Http.Json; +using System.Text.Json; using GameList.Tests.Support; using Microsoft.EntityFrameworkCore; @@ -42,6 +43,19 @@ public class VoteTests Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode); } + [Fact] + public async Task Negative_score_rejected() + { + using var factory = new TestWebApplicationFactory(); + var client = factory.CreateClientWithCookies(); + await client.RegisterAsync("negative"); + var id = await client.CreateSuggestionAsync("RangeGame2"); + await client.PostAsJsonAsync("/api/me/phase/next", new { }); + + var resp = await client.PostAsJsonAsync("/api/votes", new { SuggestionId = id, Score = -1 }); + Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode); + } + [Fact] public async Task Invalid_suggestion_id_rejected() { @@ -85,6 +99,25 @@ public class VoteTests Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode); } + [Fact] + public async Task Finalize_toggle_allows_unfinalize() + { + using var factory = new TestWebApplicationFactory(); + var client = factory.CreateClientWithCookies(); + await client.RegisterAsync("toggle"); + var id = await client.CreateSuggestionAsync("Toggle"); + await client.PostAsJsonAsync("/api/me/phase/next", new { }); + await client.PostAsJsonAsync("/api/votes", new { SuggestionId = id, Score = 5 }); + + var finalize = await client.PostAsJsonAsync("/api/votes/finalize", new { Final = true }); + finalize.EnsureSuccessStatusCode(); + var unfinalize = await client.PostAsJsonAsync("/api/votes/finalize", new { Final = false }); + unfinalize.EnsureSuccessStatusCode(); + + var me = await client.GetFromJsonAsync("/api/me"); + Assert.False(me.GetProperty("votesFinal").GetBoolean()); + } + [Fact] public async Task Linked_votes_apply_to_all_linked_suggestions() { @@ -114,5 +147,48 @@ public class VoteTests Assert.All(mine, v => Assert.Equal(9, v.Score)); } + [Fact] + public async Task Linked_votes_apply_across_chain() + { + using var factory = new TestWebApplicationFactory(); + var admin = factory.CreateClientWithCookies(); + await admin.RegisterAsync("admin", admin: true); + await admin.PostAsJsonAsync("/api/me/phase/next", new { }); + + var player = factory.CreateClientWithCookies(); + await player.RegisterAsync("chain"); + + var a = await player.CreateSuggestionAsync("A"); + var b = await player.CreateSuggestionAsync("B"); + var c = await player.CreateSuggestionAsync("C"); + + await player.PostAsJsonAsync("/api/me/phase/next", new { }); + + await admin.PostAsJsonAsync("/api/admin/link-suggestions", new { SourceSuggestionId = a, TargetSuggestionId = b }); + await admin.PostAsJsonAsync("/api/admin/link-suggestions", new { SourceSuggestionId = b, TargetSuggestionId = c }); + + var vote = await player.PostAsJsonAsync("/api/votes", new { SuggestionId = c, Score = 6 }); + vote.EnsureSuccessStatusCode(); + + var mine = await player.GetFromJsonAsync>("/api/votes/mine"); + Assert.NotNull(mine); + Assert.Equal(3, mine!.Count); + Assert.All(mine, v => Assert.Equal(6, v.Score)); + } + + [Fact] + public async Task Votes_mine_requires_vote_phase_and_auth() + { + using var factory = new TestWebApplicationFactory(); + var anon = factory.CreateClient(); + var unauth = await anon.GetAsync("/api/votes/mine"); + Assert.Equal(HttpStatusCode.Unauthorized, unauth.StatusCode); + + var client = factory.CreateClientWithCookies(); + await client.RegisterAsync("phaseguard"); + var resp = await client.GetAsync("/api/votes/mine"); + Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode); + } + private record VoteRecord(int SuggestionId, int Score); } diff --git a/Program.cs b/Program.cs index 322f49c..2eb91c9 100644 --- a/Program.cs +++ b/Program.cs @@ -6,6 +6,7 @@ using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; using System.Text.Json.Serialization; var builder = WebApplication.CreateBuilder(args); @@ -56,6 +57,19 @@ builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationSc : CookieSecurePolicy.Always; options.SlidingExpiration = true; options.ExpireTimeSpan = TimeSpan.FromDays(30); + options.Events = new CookieAuthenticationEvents + { + OnRedirectToLogin = ctx => + { + ctx.Response.StatusCode = StatusCodes.Status401Unauthorized; + return Task.CompletedTask; + }, + OnRedirectToAccessDenied = ctx => + { + ctx.Response.StatusCode = StatusCodes.Status401Unauthorized; + return Task.CompletedTask; + } + }; }); builder.Services.AddAuthorization(options => diff --git a/TASKS.md b/TASKS.md index 415f907..0ac8c19 100644 --- a/TASKS.md +++ b/TASKS.md @@ -1,39 +1,39 @@ # TASKS ## Authentication & Identity -- [ ] Cover register success behavior: trims username/display name, enforces 24/16 char caps, issues auth cookie, stores normalized username (no plain passwords). -- [ ] Add register/login rejection cases for overlength usernames/display names and ensure login success sets LastLoginAt + fills DisplayName when null. -- [ ] Verify admin claim/authorization end-to-end: admin cookie includes claim; non-admin hitting `/api/admin/*` returns 401/403. +- [x] Cover register success behavior: trims username/display name, enforces 24/16 char caps, issues auth cookie, stores normalized username (no plain passwords). +- [x] Add register/login rejection cases for overlength usernames/display names and ensure login success sets LastLoginAt + fills DisplayName when null. +- [x] Verify admin claim/authorization end-to-end: admin cookie includes claim; non-admin hitting `/api/admin/*` returns 401/403. ## State & Phase -- [ ] Assert `/api/state` payload fields (currentPhase, votesFinal, hasJoker, counts) for authenticated user. -- [ ] Test GetPhase alignment: legacy Reveal -> Vote; closing results (resultsOpen false) realigns users from Results to Vote and clears VotesFinal. -- [ ] Add `/api/me/phase/next` happy path to Results when resultsOpen=true and ensure VotesFinal clears on advance; cover Suggest->Vote success. -- [ ] Ensure `/api/me/phase/prev` clears VotesFinal and respects per-step back transitions; add name limit >16 rejection. +- [x] Assert `/api/state` payload fields (currentPhase, votesFinal, hasJoker, counts) for authenticated user. +- [x] Test GetPhase alignment: legacy Reveal -> Vote; closing results (resultsOpen false) realigns users from Results to Vote and clears VotesFinal. +- [x] Add `/api/me/phase/next` happy path to Results when resultsOpen=true and ensure VotesFinal clears on advance; cover Suggest->Vote success. +- [x] Ensure `/api/me/phase/prev` clears VotesFinal and respects per-step back transitions; add name limit >16 rejection. ## Suggestions -- [ ] Enforce phase gating on create for non-admins outside Suggest (no joker) and require display name before create. -- [ ] Add validation cases: invalid game/youtube URLs, missing/overlong name (>100), player count ranges (min<1, max>32, only one of min/max provided), trimming/truncation of optional fields. -- [ ] Verify `/api/suggestions/mine` excludes other players; `/api/suggestions/all` returns ordered list with LinkedIds/LinkedTitles metadata. -- [ ] Test DELETE: player can delete own in Suggest only; admin any time; links to children cleared and related votes removed. -- [ ] Joker create path should unfinalize all players’ ballots, not just the caller. +- [x] Enforce phase gating on create for non-admins outside Suggest (no joker) and require display name before create. +- [x] Add validation cases: invalid game/youtube URLs, missing/overlong name (>100), player count ranges (min<1, max>32, only one of min/max provided), trimming/truncation of optional fields. +- [x] Verify `/api/suggestions/mine` excludes other players; `/api/suggestions/all` returns ordered list with LinkedIds/LinkedTitles metadata. +- [x] Test DELETE: player can delete own in Suggest only; admin any time; links to children cleared and related votes removed. +- [x] Joker create path should unfinalize all players’ ballots, not just the caller. ## Votes -- [ ] Guard `/api/votes/mine` for auth/phase mismatch; reject negative scores too. -- [ ] Cover finalize toggle back to false and phase-change unfinalization; ensure VotesFinal blocks edits and resets correctly. -- [ ] Add linked-vote coverage for nested/root-detection cases (e.g., chains) to ensure scores fan out as expected. +- [x] Guard `/api/votes/mine` for auth/phase mismatch; reject negative scores too. +- [x] Cover finalize toggle back to false and phase-change unfinalization; ensure VotesFinal blocks edits and resets correctly. +- [x] Add linked-vote coverage for nested/root-detection cases (e.g., chains) to ensure scores fan out as expected. ## Results -- [ ] Require Results phase and auth even when resultsOpen=true (phase mismatch/unauth 400/401). -- [ ] Validate results payload fields: totals/count/average (average=0 when no votes), MyVote, link metadata, ordering by average. +- [x] Require Results phase and auth even when resultsOpen=true (phase mismatch/unauth 400/401). +- [x] Validate results payload fields: totals/count/average (average=0 when no votes), MyVote, link metadata, ordering by average. ## Admin Operations -- [ ] Cover `/api/admin/results` closing path: moves everyone to Vote, clears VotesFinal, updates UpdatedAt timestamp. -- [ ] Extend vote-status tests for mixed finalized vs waiting users and ordering by display/username. -- [ ] Add happy-path joker grant in Vote phase; assert VotesFinal resets for that player. -- [ ] Exercise link/unlink phase gating and not-found cases; verify linking re-parents groups, deletes group votes, and unfinalizes affected players counts; ensure unlink follows spec vs current NotFound-on-missing behavior. -- [ ] Confirm reset clears HasJoker/VotesFinal and closes results; factory-reset re-seeds AppState defaults (ResultsOpen=false, UpdatedAt set). +- [x] Cover `/api/admin/results` closing path: moves everyone to Vote, clears VotesFinal, updates UpdatedAt timestamp. +- [x] Extend vote-status tests for mixed finalized vs waiting users and ordering by display/username. +- [x] Add happy-path joker grant in Vote phase; assert VotesFinal resets for that player. +- [x] Exercise link/unlink phase gating and not-found cases; verify linking re-parents groups, deletes group votes, and unfinalizes affected players counts; ensure unlink follows spec vs current NotFound-on-missing behavior. +- [x] Confirm reset clears HasJoker/VotesFinal and closes results; factory-reset re-seeds AppState defaults (ResultsOpen=false, UpdatedAt set). ## Infrastructure/Helpers -- [ ] Expand `IsReachableImageAsync` tests: HEAD success path, redirect rejection, oversized content-length guard, fallback GET with non-image content. -- [ ] Add coverage for global exception handler returning JSON 500 and logging. +- [x] Expand `IsReachableImageAsync` tests: HEAD success path, redirect rejection, oversized content-length guard, fallback GET with non-image content. +- [x] Add coverage for global exception handler returning JSON 500 and logging.