Expand test coverage to match specs

This commit is contained in:
2026-02-05 18:57:25 +01:00
parent e11cb23313
commit 67a164e53b
14 changed files with 861 additions and 32 deletions

View File

@@ -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<int>(), UnfinalizedPlayers = 0 });
var rootIndex = EndpointHelpers.BuildLinkRoots(suggestions.Select(s => (s.Id, s.ParentSuggestionId)));
if (!rootIndex.TryGetValue(target.Id, out var rootId))

View File

@@ -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." });

View File

@@ -91,7 +91,7 @@ internal static class EndpointHelpers
|| path.EndsWith(".gif") || path.EndsWith(".webp") || path.EndsWith(".avif");
}
public static async Task<bool> IsReachableImageAsync(string? url, IHttpClientFactory httpFactory, CancellationToken ct = default)
public static async Task<bool> 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 */ }

View File

@@ -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))
{

View File

@@ -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);

View File

@@ -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<JsonElement>("/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<JsonElement>();
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);
});
}
}

View File

@@ -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<byte>(), player.PasswordHash);
Assert.NotEqual(Array.Empty<byte>(), 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()
{

View File

@@ -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<byte>());
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<byte>())
{
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<JsonElement>();
Assert.Equal("Unexpected server error", json.GetProperty("error").GetString());
}
private class FakeEnv : IWebHostEnvironment
{
public string ApplicationName { get; set; } = "";

View File

@@ -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<List<JsonElement>>("/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());
}
}

View File

@@ -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<JsonElement>("/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<AppDbContext>();
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<AppDbContext>();
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<JsonElement>("/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<JsonElement>("/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()
{

View File

@@ -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<List<JsonElement>>("/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<List<JsonElement>>("/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<List<JsonElement>>("/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));
});
}
}

View File

@@ -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<JsonElement>("/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<List<VoteRecord>>("/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);
}

View File

@@ -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 =>

View File

@@ -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.