From 9096e089abfa618f7b32c78faf5507eac51c8e0e Mon Sep 17 00:00:00 2001 From: Frank Tovar Date: Thu, 5 Feb 2026 18:28:44 +0100 Subject: [PATCH] Increase coverage with identity and filter tests --- GameList.Tests/FiltersTests.cs | 115 ++++++++++++++++++++++++++++++ GameList.Tests/HelperTests.cs | 31 +++++++- GameList.Tests/IdentityTests.cs | 69 ++++++++++++++++++ GameList.Tests/MiddlewareTests.cs | 11 +++ GameList.Tests/StateTests.cs | 33 +++++++++ 5 files changed, 258 insertions(+), 1 deletion(-) create mode 100644 GameList.Tests/FiltersTests.cs create mode 100644 GameList.Tests/IdentityTests.cs diff --git a/GameList.Tests/FiltersTests.cs b/GameList.Tests/FiltersTests.cs new file mode 100644 index 0000000..9ff5606 --- /dev/null +++ b/GameList.Tests/FiltersTests.cs @@ -0,0 +1,115 @@ +using System.IO; +using System.Security.Claims; +using GameList.Data; +using GameList.Domain; +using GameList.Infrastructure; +using GameList.Tests.Support; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace GameList.Tests; + +public class FiltersTests +{ + [Fact] + public async Task Admin_only_filter_blocks_non_admin() + { + using var factory = new TestWebApplicationFactory(); + var client = factory.CreateClientWithCookies(); + await client.RegisterAsync("user"); + + var ctx = await BuildContextAsync(factory, isAdmin: false); + var filter = new AdminOnlyFilter(); + var result = await filter.InvokeAsync(ctx, _ => ValueTask.FromResult(Results.Ok())); + await AssertStatusAsync(result, StatusCodes.Status401Unauthorized); + } + + [Fact] + public async Task Phase_requirement_allows_admin_override_when_enabled() + { + using var factory = new TestWebApplicationFactory(); + var ctx = await BuildContextAsync(factory, isAdmin: true, phase: Phase.Suggest); + var filter = new PhaseRequirementFilter(Phase.Vote, allowAdminOverride: true); + var called = false; + var result = await filter.InvokeAsync(ctx, _ => + { + called = true; + return ValueTask.FromResult(Results.Ok()); + }); + + Assert.True(called); + await AssertStatusAsync(result, StatusCodes.Status200OK); + } + + [Fact] + public async Task Phase_or_joker_filter_blocks_without_joker_in_vote() + { + using var factory = new TestWebApplicationFactory(); + var ctx = await BuildContextAsync(factory, isAdmin: false, phase: Phase.Vote, hasJoker: false); + var filter = new PhaseOrJokerFilter(); + var result = await filter.InvokeAsync(ctx, _ => ValueTask.FromResult(Results.Ok())); + await AssertStatusAsync(result, StatusCodes.Status400BadRequest); + } + + private static async Task BuildContextAsync(TestWebApplicationFactory factory, bool isAdmin, Phase phase = Phase.Vote, bool hasJoker = false) + { + var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var player = new Player + { + Id = Guid.NewGuid(), + Username = $"user-{Guid.NewGuid():N}", + NormalizedUsername = $"user-{Guid.NewGuid():N}", + PasswordHash = new byte[] { 1 }, + PasswordSalt = new byte[] { 1 }, + IsAdmin = isAdmin, + CurrentPhase = phase, + HasJoker = hasJoker, + DisplayName = "User" + }; + db.Players.Add(player); + await db.SaveChangesAsync(); + + var ctx = new DefaultHttpContext + { + RequestServices = scope.ServiceProvider, + User = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim(ClaimTypes.NameIdentifier, player.Id.ToString()), + new Claim(ClaimTypes.Name, player.Username), + new Claim(PlayerIdentityExtensions.AdminClaim, isAdmin ? "true" : "false") + }, "cookie")) + }; + + return new TestInvocationContext(ctx); + } + + private class TestInvocationContext : EndpointFilterInvocationContext + { + private readonly DefaultHttpContext _context; + + public TestInvocationContext(DefaultHttpContext context) + { + _context = context; + } + + public override HttpContext HttpContext => _context; + public override object?[] Arguments => Array.Empty(); + public override T GetArgument(int index) => throw new NotImplementedException(); + } + + private static async Task AssertStatusAsync(object? result, int statusCode) + { + var http = new DefaultHttpContext + { + RequestServices = new ServiceCollection() + .AddLogging() + .BuildServiceProvider() + }; + http.Response.Body = new MemoryStream(); + await ((IResult)result!).ExecuteAsync(http); + Assert.Equal(statusCode, http.Response.StatusCode); + } +} diff --git a/GameList.Tests/HelperTests.cs b/GameList.Tests/HelperTests.cs index 16f3740..78cca41 100644 --- a/GameList.Tests/HelperTests.cs +++ b/GameList.Tests/HelperTests.cs @@ -39,11 +39,26 @@ public class HelperTests Assert.Contains("content=\"/pick\"", text); } + [Fact] + public void UpdateIndexMetaBase_no_marker_no_change() + { + var webRoot = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + Directory.CreateDirectory(webRoot); + var index = Path.Combine(webRoot, "index.html"); + File.WriteAllText(index, ""); + + var env = new FakeEnv { WebRootPath = webRoot }; + var method = typeof(Program).GetMethods(BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public) + .First(m => m.Name.Contains("UpdateIndexMetaBase")); + method.Invoke(null, new object?[] { env, "/pick" }); + + Assert.Equal("", File.ReadAllText(index)); + } + [Fact] public async Task IsReachableImageAsync_rejects_redirect_and_accepts_image() { Assert.True(await EndpointHelpers.IsReachableImageAsync(null, new StubHttpClientFactory(new StubHttpMessageHandler()))); - // Private host should be rejected before network call Assert.False(await EndpointHelpers.IsReachableImageAsync("http://127.0.0.1/img.png", new StubHttpClientFactory(new StubHttpMessageHandler()))); } @@ -69,6 +84,20 @@ public class HelperTests Assert.False(EndpointHelpers.IsValidHttpUrl("file://x")); } + [Fact] + public void Find_root_handles_cycles() + { + var parentMap = new Dictionary + { + {1, 2}, + {2, 3}, + {3, 1} + }; + + var root = EndpointHelpers.FindRootId(1, parentMap); + Assert.Equal(3, root); // cycle breaks on revisit + } + private class FakeEnv : IWebHostEnvironment { public string ApplicationName { get; set; } = ""; diff --git a/GameList.Tests/IdentityTests.cs b/GameList.Tests/IdentityTests.cs new file mode 100644 index 0000000..22abd7c --- /dev/null +++ b/GameList.Tests/IdentityTests.cs @@ -0,0 +1,69 @@ +using System.Security.Claims; +using GameList.Domain; +using GameList.Infrastructure; +using GameList.Tests.Support; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace GameList.Tests; + +public class IdentityTests +{ + [Fact] + public async Task Sign_in_sets_claims_and_cookie() + { + using var factory = new TestWebApplicationFactory(); + var ctx = BuildAuthContext(factory.Services); + + var player = new Player + { + Id = Guid.NewGuid(), + Username = "claimuser", + NormalizedUsername = "claimuser", + PasswordHash = new byte[] { 1 }, + PasswordSalt = new byte[] { 1 }, + DisplayName = "Claim", + IsAdmin = true + }; + + await PlayerIdentityExtensions.SignInPlayerAsync(ctx, player); + + var cookies = ctx.Response.Headers["Set-Cookie"]; + Assert.NotNull(cookies); + Assert.Contains(cookies!, v => v.Contains(PlayerIdentityExtensions.PlayerCookieName)); + } + + [Fact] + public async Task Sign_out_clears_principal() + { + using var factory = new TestWebApplicationFactory(); + var ctx = BuildAuthContext(factory.Services); + var player = new Player(); + await PlayerIdentityExtensions.SignInPlayerAsync(ctx, player); + + await PlayerIdentityExtensions.SignOutPlayerAsync(ctx); + + Assert.False(ctx.User.Identity?.IsAuthenticated ?? false); + } + + private static DefaultHttpContext BuildAuthContext(IServiceProvider services) + { + var serviceCollection = new ServiceCollection(); + serviceCollection.AddSingleton(); + serviceCollection.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) + .AddCookie(options => + { + options.Cookie.Name = PlayerIdentityExtensions.PlayerCookieName; + }); + serviceCollection.AddLogging(); + var provider = serviceCollection.BuildServiceProvider(); + + return new DefaultHttpContext + { + RequestServices = provider, + Response = { Body = new MemoryStream() } + }; + } +} diff --git a/GameList.Tests/MiddlewareTests.cs b/GameList.Tests/MiddlewareTests.cs index 8b69c79..40f534c 100644 --- a/GameList.Tests/MiddlewareTests.cs +++ b/GameList.Tests/MiddlewareTests.cs @@ -25,4 +25,15 @@ public class MiddlewareTests Assert.Equal(HttpStatusCode.Unauthorized, resp.StatusCode); Assert.Contains(resp.Headers, h => h.Key.Equals("Set-Cookie", StringComparison.OrdinalIgnoreCase)); } + + [Fact] + public async Task Existing_player_passes_through_middleware() + { + using var factory = new TestWebApplicationFactory(); + var client = factory.CreateClientWithCookies(); + await client.RegisterAsync("live"); + + var resp = await client.GetAsync("/api/state"); + Assert.Equal(HttpStatusCode.OK, resp.StatusCode); + } } diff --git a/GameList.Tests/StateTests.cs b/GameList.Tests/StateTests.cs index 7afa02b..3c226ed 100644 --- a/GameList.Tests/StateTests.cs +++ b/GameList.Tests/StateTests.cs @@ -3,6 +3,9 @@ using System.Net.Http.Json; using System.Text.Json; using GameList.Domain; using GameList.Tests.Support; +using Microsoft.EntityFrameworkCore; +using GameList.Data; +using Microsoft.Extensions.DependencyInjection; namespace GameList.Tests; @@ -102,4 +105,34 @@ public class StateTests var resp = await factory.CreateClient().GetFromJsonAsync("/health"); Assert.Equal("ok", resp.GetProperty("status").GetString()); } + + [Fact] + public async Task GetPhase_aligns_to_results_when_open() + { + using var factory = new TestWebApplicationFactory(); + await factory.WithDbContextAsync(async db => + { + var player = new Player + { + Id = Guid.NewGuid(), + Username = "phase", + NormalizedUsername = "phase", + PasswordHash = new byte[] { 1 }, + PasswordSalt = new byte[] { 1 }, + DisplayName = "phase", + CurrentPhase = Phase.Vote + }; + 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 playerId = await db.Players.Select(p => p.Id).FirstAsync(); + var phase = await GameList.Endpoints.EndpointHelpers.GetPhase(db, playerId); + + Assert.Equal(Phase.Results, phase); + } }