Increase coverage with identity and filter tests

This commit is contained in:
2026-02-05 18:28:44 +01:00
parent 875b12db3f
commit 9096e089ab
5 changed files with 258 additions and 1 deletions

View File

@@ -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<object?>(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<object?>(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<object?>(Results.Ok()));
await AssertStatusAsync(result, StatusCodes.Status400BadRequest);
}
private static async Task<TestInvocationContext> BuildContextAsync(TestWebApplicationFactory factory, bool isAdmin, Phase phase = Phase.Vote, bool hasJoker = false)
{
var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
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<object?>();
public override T GetArgument<T>(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);
}
}

View File

@@ -39,11 +39,26 @@ public class HelperTests
Assert.Contains("content=\"/pick\"", text); 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, "<html></html>");
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("<html></html>", File.ReadAllText(index));
}
[Fact] [Fact]
public async Task IsReachableImageAsync_rejects_redirect_and_accepts_image() public async Task IsReachableImageAsync_rejects_redirect_and_accepts_image()
{ {
Assert.True(await EndpointHelpers.IsReachableImageAsync(null, new StubHttpClientFactory(new StubHttpMessageHandler()))); 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()))); 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")); Assert.False(EndpointHelpers.IsValidHttpUrl("file://x"));
} }
[Fact]
public void Find_root_handles_cycles()
{
var parentMap = new Dictionary<int, int?>
{
{1, 2},
{2, 3},
{3, 1}
};
var root = EndpointHelpers.FindRootId(1, parentMap);
Assert.Equal(3, root); // cycle breaks on revisit
}
private class FakeEnv : IWebHostEnvironment private class FakeEnv : IWebHostEnvironment
{ {
public string ApplicationName { get; set; } = ""; public string ApplicationName { get; set; } = "";

View File

@@ -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<IHttpContextAccessor, HttpContextAccessor>();
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() }
};
}
}

View File

@@ -25,4 +25,15 @@ public class MiddlewareTests
Assert.Equal(HttpStatusCode.Unauthorized, resp.StatusCode); Assert.Equal(HttpStatusCode.Unauthorized, resp.StatusCode);
Assert.Contains(resp.Headers, h => h.Key.Equals("Set-Cookie", StringComparison.OrdinalIgnoreCase)); 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);
}
} }

View File

@@ -3,6 +3,9 @@ using System.Net.Http.Json;
using System.Text.Json; using System.Text.Json;
using GameList.Domain; using GameList.Domain;
using GameList.Tests.Support; using GameList.Tests.Support;
using Microsoft.EntityFrameworkCore;
using GameList.Data;
using Microsoft.Extensions.DependencyInjection;
namespace GameList.Tests; namespace GameList.Tests;
@@ -102,4 +105,34 @@ public class StateTests
var resp = await factory.CreateClient().GetFromJsonAsync<JsonElement>("/health"); var resp = await factory.CreateClient().GetFromJsonAsync<JsonElement>("/health");
Assert.Equal("ok", resp.GetProperty("status").GetString()); 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<AppDbContext>();
var playerId = await db.Players.Select(p => p.Id).FirstAsync();
var phase = await GameList.Endpoints.EndpointHelpers.GetPhase(db, playerId);
Assert.Equal(Phase.Results, phase);
}
} }