C# formatting

This commit is contained in:
2026-02-05 20:39:12 +01:00
parent 78cdbfe51e
commit c0756ff2c6
34 changed files with 830 additions and 582 deletions

View File

@@ -1,4 +1,5 @@
namespace GameList.Contracts; namespace GameList.Contracts;
public record RegisterRequest(string Username, string Password, string? DisplayName, string? AdminKey); public record RegisterRequest(string Username, string Password, string? DisplayName, string? AdminKey);
public record LoginRequest(string Username, string Password); public record LoginRequest(string Username, string Password);

View File

@@ -3,13 +3,23 @@ using GameList.Domain;
namespace GameList.Contracts; namespace GameList.Contracts;
public record SetNameRequest(string Name); public record SetNameRequest(string Name);
public record SuggestionRequest(string Name, string? Genre, string? Description, string? ScreenshotUrl, string? YoutubeUrl, string? GameUrl, int? MinPlayers, int? MaxPlayers); public record SuggestionRequest(string Name, string? Genre, string? Description, string? ScreenshotUrl, string? YoutubeUrl, string? GameUrl, int? MinPlayers, int? MaxPlayers);
public record SuggestionDto(int Id, string Name, string? Genre, string? Description, string? ScreenshotUrl, string? YoutubeUrl, string? GameUrl, int? MinPlayers, int? MaxPlayers, int? ParentSuggestionId = null, IReadOnlyList<int>? LinkedIds = null, IReadOnlyList<string>? LinkedTitles = null); public record SuggestionDto(int Id, string Name, string? Genre, string? Description, string? ScreenshotUrl, string? YoutubeUrl, string? GameUrl, int? MinPlayers, int? MaxPlayers, int? ParentSuggestionId = null, IReadOnlyList<int>? LinkedIds = null, IReadOnlyList<string>? LinkedTitles = null);
public record VoteRequest(int SuggestionId, int Score); public record VoteRequest(int SuggestionId, int Score);
public record ResultsOpenRequest(bool ResultsOpen); public record ResultsOpenRequest(bool ResultsOpen);
public record VoteFinalizeRequest(bool Final); public record VoteFinalizeRequest(bool Final);
public record VoteStatusDto(Guid PlayerId, string Name, string Username, Phase Phase, bool Finalized, bool HasJoker, int SuggestionCount, IReadOnlyList<string> SuggestionTitles); public record VoteStatusDto(Guid PlayerId, string Name, string Username, Phase Phase, bool Finalized, bool HasJoker, int SuggestionCount, IReadOnlyList<string> SuggestionTitles);
public record LinkSuggestionsRequest(int SourceSuggestionId, int TargetSuggestionId); public record LinkSuggestionsRequest(int SourceSuggestionId, int TargetSuggestionId);
public record UnlinkSuggestionsRequest(int SuggestionId); public record UnlinkSuggestionsRequest(int SuggestionId);
public record GrantJokerRequest(Guid PlayerId); public record GrantJokerRequest(Guid PlayerId);
public record DeletePlayerRequest(Guid PlayerId); public record DeletePlayerRequest(Guid PlayerId);

View File

@@ -3,12 +3,8 @@ using Microsoft.EntityFrameworkCore;
namespace GameList.Data; namespace GameList.Data;
public class AppDbContext : DbContext public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
{ {
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options)
{
}
public DbSet<Player> Players => Set<Player>(); public DbSet<Player> Players => Set<Player>();
public DbSet<Suggestion> Suggestions => Set<Suggestion>(); public DbSet<Suggestion> Suggestions => Set<Suggestion>();
public DbSet<Vote> Votes => Set<Vote>(); public DbSet<Vote> Votes => Set<Vote>();
@@ -29,14 +25,8 @@ public class AppDbContext : DbContext
builder.Property(p => p.HasJoker).HasDefaultValue(false); builder.Property(p => p.HasJoker).HasDefaultValue(false);
builder.Property(p => p.CurrentPhase).HasDefaultValue(Phase.Suggest); builder.Property(p => p.CurrentPhase).HasDefaultValue(Phase.Suggest);
builder.Property(p => p.VotesFinal).HasDefaultValue(false); builder.Property(p => p.VotesFinal).HasDefaultValue(false);
builder.HasMany(p => p.Suggestions) builder.HasMany(p => p.Suggestions).WithOne(s => s.Player!).HasForeignKey(s => s.PlayerId).OnDelete(DeleteBehavior.Cascade);
.WithOne(s => s.Player!) builder.HasMany(p => p.Votes).WithOne(v => v.Player!).HasForeignKey(v => v.PlayerId).OnDelete(DeleteBehavior.Cascade);
.HasForeignKey(s => s.PlayerId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasMany(p => p.Votes)
.WithOne(v => v.Player!)
.HasForeignKey(v => v.PlayerId)
.OnDelete(DeleteBehavior.Cascade);
}); });
modelBuilder.Entity<Suggestion>(builder => modelBuilder.Entity<Suggestion>(builder =>
@@ -50,10 +40,7 @@ public class AppDbContext : DbContext
builder.Property(s => s.GameUrl).HasMaxLength(2048); builder.Property(s => s.GameUrl).HasMaxLength(2048);
builder.Property(s => s.MinPlayers); builder.Property(s => s.MinPlayers);
builder.Property(s => s.MaxPlayers); builder.Property(s => s.MaxPlayers);
builder.HasOne(s => s.ParentSuggestion) builder.HasOne(s => s.ParentSuggestion).WithMany(p => p.LinkedSuggestions).HasForeignKey(s => s.ParentSuggestionId).OnDelete(DeleteBehavior.SetNull);
.WithMany(p => p.LinkedSuggestions)
.HasForeignKey(s => s.ParentSuggestionId)
.OnDelete(DeleteBehavior.SetNull);
builder.HasIndex(s => s.ParentSuggestionId); builder.HasIndex(s => s.ParentSuggestionId);
}); });
@@ -61,7 +48,11 @@ public class AppDbContext : DbContext
{ {
builder.HasKey(v => v.Id); builder.HasKey(v => v.Id);
builder.Property(v => v.Score).IsRequired(); builder.Property(v => v.Score).IsRequired();
builder.HasIndex(v => new { v.PlayerId, v.SuggestionId }).IsUnique(); builder.HasIndex(v => new
{
v.PlayerId,
v.SuggestionId
}).IsUnique();
}); });
modelBuilder.Entity<AppState>(builder => modelBuilder.Entity<AppState>(builder =>

View File

@@ -15,8 +15,8 @@ public class Player
[MaxLength(24)] [MaxLength(24)]
public string NormalizedUsername { get; set; } = string.Empty; public string NormalizedUsername { get; set; } = string.Empty;
public byte[] PasswordHash { get; set; } = Array.Empty<byte>(); public byte[] PasswordHash { get; set; } = [];
public byte[] PasswordSalt { get; set; } = Array.Empty<byte>(); public byte[] PasswordSalt { get; set; } = [];
public DateTimeOffset? LastLoginAt { get; set; } public DateTimeOffset? LastLoginAt { get; set; }
public bool IsAdmin { get; set; } public bool IsAdmin { get; set; }

View File

@@ -8,6 +8,7 @@ public class Suggestion
[Required] [Required]
public Guid PlayerId { get; set; } public Guid PlayerId { get; set; }
public Player? Player { get; set; } public Player? Player { get; set; }
[Required] [Required]

View File

@@ -8,10 +8,12 @@ public class Vote
[Required] [Required]
public Guid PlayerId { get; set; } public Guid PlayerId { get; set; }
public Player? Player { get; set; } public Player? Player { get; set; }
[Required] [Required]
public int SuggestionId { get; set; } public int SuggestionId { get; set; }
public Suggestion? Suggestion { get; set; } public Suggestion? Suggestion { get; set; }
[Range(0, 10)] [Range(0, 10)]

View File

@@ -3,7 +3,6 @@ using GameList.Domain;
using GameList.Contracts; using GameList.Contracts;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using System.Collections.Generic;
using GameList.Infrastructure; using GameList.Infrastructure;
namespace GameList.Endpoints; namespace GameList.Endpoints;
@@ -12,11 +11,9 @@ public static class AdminEndpoints
{ {
public static void MapAdminEndpoints(this IEndpointRouteBuilder app) public static void MapAdminEndpoints(this IEndpointRouteBuilder app)
{ {
var admin = app.MapGroup("/api/admin") var admin = app.MapGroup("/api/admin").RequireAuthorization().AddEndpointFilter<AdminOnlyFilter>();
.RequireAuthorization()
.AddEndpointFilter<AdminOnlyFilter>();
admin.MapPost("/results", async ([FromBody] Contracts.ResultsOpenRequest request, HttpContext ctx, AppDbContext db) => admin.MapPost("/results", async ([FromBody] ResultsOpenRequest request, HttpContext _, AppDbContext db) =>
{ {
var state = await db.AppState.FirstAsync(); var state = await db.AppState.FirstAsync();
state.ResultsOpen = request.ResultsOpen; state.ResultsOpen = request.ResultsOpen;
@@ -28,40 +25,37 @@ public static class AdminEndpoints
} }
else else
{ {
await db.Players.ExecuteUpdateAsync(p => p.SetProperty(x => x.CurrentPhase, Phase.Vote) await db.Players.ExecuteUpdateAsync(p => p.SetProperty(x => x.CurrentPhase, Phase.Vote).SetProperty(x => x.VotesFinal, false));
.SetProperty(x => x.VotesFinal, false));
} }
await db.SaveChangesAsync(); await db.SaveChangesAsync();
var currentState = await db.AppState.AsNoTracking().FirstAsync(); var currentState = await db.AppState.AsNoTracking().FirstAsync();
return Results.Ok(new { currentState.ResultsOpen, currentState.UpdatedAt }); return Results.Ok(new
{
currentState.ResultsOpen,
currentState.UpdatedAt
});
}); });
admin.MapGet("/vote-status", async (HttpContext ctx, AppDbContext db) => admin.MapGet("/vote-status", async (HttpContext _, AppDbContext db) =>
{ {
var voters = await db.Players var voters = await db.Players.AsNoTracking().Include(p => p.Suggestions).OrderBy(p => p.DisplayName ?? p.Username).Select(p => new VoteStatusDto(p.Id, p.DisplayName ?? p.Username, p.Username, p.CurrentPhase, p.VotesFinal, p.HasJoker, p.Suggestions.Count, p.Suggestions.Select(s => s.Name).ToList())).ToListAsync();
.AsNoTracking()
.Include(p => p.Suggestions)
.OrderBy(p => p.DisplayName ?? p.Username)
.Select(p => new VoteStatusDto(p.Id,
p.DisplayName ?? p.Username,
p.Username,
p.CurrentPhase,
p.VotesFinal,
p.HasJoker,
p.Suggestions.Count,
p.Suggestions.Select(s => s.Name).ToList()))
.ToListAsync();
var waiting = voters.Where(v => !v.Finalized).Select(v => v.Name).ToList(); var waiting = voters.Where(v => !v.Finalized).Select(v => v.Name).ToList();
var ready = waiting.Count == 0; var ready = waiting.Count == 0;
return Results.Ok(new { voters, ready, waiting }); return Results.Ok(new
{
voters,
ready,
waiting
});
}); });
admin.MapPost("/joker", async ([FromBody] GrantJokerRequest request, HttpContext ctx, AppDbContext db) => admin.MapPost("/joker", async ([FromBody] GrantJokerRequest request, HttpContext _, AppDbContext db) =>
{ {
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == request.PlayerId); var player = await db.Players.FirstOrDefaultAsync(p => p.Id == request.PlayerId);
if (player is null) return Results.NotFound(new { error = "Player not found." }); if (player is null)
return Results.NotFound(new { error = "Player not found." });
var phase = await EndpointHelpers.GetPhase(db, player.Id); var phase = await EndpointHelpers.GetPhase(db, player.Id);
if (phase != Phase.Vote) if (phase != Phase.Vote)
@@ -71,15 +65,18 @@ public static class AdminEndpoints
player.VotesFinal = false; player.VotesFinal = false;
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return Results.Ok(new { player.Id, player.HasJoker }); return Results.Ok(new
{
player.Id,
player.HasJoker
});
}); });
admin.MapDelete("/players/{playerId:guid}", async (Guid playerId, HttpContext ctx, AppDbContext db) => admin.MapDelete("/players/{playerId:guid}", async (Guid playerId, HttpContext _, AppDbContext db) =>
{ {
var player = await db.Players var player = await db.Players.Include(p => p.Suggestions).FirstOrDefaultAsync(p => p.Id == playerId);
.Include(p => p.Suggestions) if (player is null)
.FirstOrDefaultAsync(p => p.Id == playerId); return Results.NotFound(new { error = "Player not found." });
if (player is null) return Results.NotFound(new { error = "Player not found." });
await using var tx = await db.Database.BeginTransactionAsync(); await using var tx = await db.Database.BeginTransactionAsync();
@@ -91,9 +88,7 @@ public static class AdminEndpoints
if (suggestionIds.Count > 0) if (suggestionIds.Count > 0)
{ {
// Break links pointing to these suggestions // Break links pointing to these suggestions
await db.Suggestions await db.Suggestions.Where(s => s.ParentSuggestionId != null && suggestionIds.Contains(s.ParentSuggestionId.Value)).ExecuteUpdateAsync(s => s.SetProperty(x => x.ParentSuggestionId, (int?)null));
.Where(s => s.ParentSuggestionId != null && suggestionIds.Contains(s.ParentSuggestionId.Value))
.ExecuteUpdateAsync(s => s.SetProperty(x => x.ParentSuggestionId, (int?)null));
// Remove votes for these suggestions to avoid orphaned rows // Remove votes for these suggestions to avoid orphaned rows
await db.Votes.Where(v => suggestionIds.Contains(v.SuggestionId)).ExecuteDeleteAsync(); await db.Votes.Where(v => suggestionIds.Contains(v.SuggestionId)).ExecuteDeleteAsync();
@@ -110,7 +105,8 @@ public static class AdminEndpoints
admin.MapPost("/link-suggestions", async ([FromBody] LinkSuggestionsRequest request, HttpContext ctx, AppDbContext db) => admin.MapPost("/link-suggestions", async ([FromBody] LinkSuggestionsRequest request, HttpContext ctx, AppDbContext db) =>
{ {
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db); var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
if (player is null) return Results.Unauthorized(); if (player is null)
return Results.Unauthorized();
var phase = await EndpointHelpers.GetPhase(db, player.Id); var phase = await EndpointHelpers.GetPhase(db, player.Id);
if (phase != Phase.Vote) if (phase != Phase.Vote)
@@ -132,11 +128,12 @@ public static class AdminEndpoints
if (sourceRoot == targetRoot) if (sourceRoot == targetRoot)
return Results.BadRequest(new { error = "These games are already linked." }); return Results.BadRequest(new { error = "These games are already linked." });
var affectedRootIds = new HashSet<int> { sourceRoot, targetRoot }; var affectedRootIds = new HashSet<int>
var affectedIds = rootIndex {
.Where(kv => affectedRootIds.Contains(kv.Value)) sourceRoot,
.Select(kv => kv.Key) targetRoot
.ToList(); };
var affectedIds = rootIndex.Where(kv => affectedRootIds.Contains(kv.Value)).Select(kv => kv.Key).ToList();
await using var tx = await db.Database.BeginTransactionAsync(); await using var tx = await db.Database.BeginTransactionAsync();
@@ -155,18 +152,13 @@ public static class AdminEndpoints
await db.SaveChangesAsync(); await db.SaveChangesAsync();
var affectedPlayerIds = await db.Votes var affectedPlayerIds = await db.Votes.Where(v => affectedIds.Contains(v.SuggestionId)).Select(v => v.PlayerId).Distinct().ToListAsync();
.Where(v => affectedIds.Contains(v.SuggestionId))
.Select(v => v.PlayerId)
.Distinct()
.ToListAsync();
await db.Votes.Where(v => affectedIds.Contains(v.SuggestionId)).ExecuteDeleteAsync(); await db.Votes.Where(v => affectedIds.Contains(v.SuggestionId)).ExecuteDeleteAsync();
if (affectedPlayerIds.Count > 0) if (affectedPlayerIds.Count > 0)
{ {
await db.Players.Where(p => affectedPlayerIds.Contains(p.Id)) await db.Players.Where(p => affectedPlayerIds.Contains(p.Id)).ExecuteUpdateAsync(p => p.SetProperty(x => x.VotesFinal, false));
.ExecuteUpdateAsync(p => p.SetProperty(x => x.VotesFinal, false));
} }
await tx.CommitAsync(); await tx.CommitAsync();
@@ -182,7 +174,8 @@ public static class AdminEndpoints
admin.MapPost("/unlink-suggestions", async ([FromBody] UnlinkSuggestionsRequest request, HttpContext ctx, AppDbContext db) => admin.MapPost("/unlink-suggestions", async ([FromBody] UnlinkSuggestionsRequest request, HttpContext ctx, AppDbContext db) =>
{ {
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db); var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
if (player is null) return Results.Unauthorized(); if (player is null)
return Results.Unauthorized();
var phase = await EndpointHelpers.GetPhase(db, player.Id); var phase = await EndpointHelpers.GetPhase(db, player.Id);
if (phase != Phase.Vote) if (phase != Phase.Vote)
@@ -191,16 +184,21 @@ public static class AdminEndpoints
var suggestions = await db.Suggestions.ToListAsync(); var suggestions = await db.Suggestions.ToListAsync();
var target = suggestions.FirstOrDefault(s => s.Id == request.SuggestionId); var target = suggestions.FirstOrDefault(s => s.Id == request.SuggestionId);
if (target is null) if (target is null)
return Results.Ok(new { UnlinkedSuggestionIds = Array.Empty<int>(), UnfinalizedPlayers = 0 }); return Results.Ok(new
{
UnlinkedSuggestionIds = Array.Empty<int>(),
UnfinalizedPlayers = 0
});
var rootIndex = EndpointHelpers.BuildLinkRoots(suggestions.Select(s => (s.Id, s.ParentSuggestionId))); var rootIndex = EndpointHelpers.BuildLinkRoots(suggestions.Select(s => (s.Id, s.ParentSuggestionId)));
if (!rootIndex.TryGetValue(target.Id, out var rootId)) if (!rootIndex.TryGetValue(target.Id, out var rootId))
return Results.Ok(new { UnlinkedSuggestionIds = Array.Empty<int>(), UnfinalizedPlayers = 0 }); return Results.Ok(new
{
UnlinkedSuggestionIds = Array.Empty<int>(),
UnfinalizedPlayers = 0
});
var groupIds = rootIndex var groupIds = rootIndex.Where(kv => kv.Value == rootId).Select(kv => kv.Key).ToList();
.Where(kv => kv.Value == rootId)
.Select(kv => kv.Key)
.ToList();
await using var tx = await db.Database.BeginTransactionAsync(); await using var tx = await db.Database.BeginTransactionAsync();
@@ -211,18 +209,13 @@ public static class AdminEndpoints
await db.SaveChangesAsync(); await db.SaveChangesAsync();
var affectedPlayerIds = await db.Votes var affectedPlayerIds = await db.Votes.Where(v => groupIds.Contains(v.SuggestionId)).Select(v => v.PlayerId).Distinct().ToListAsync();
.Where(v => groupIds.Contains(v.SuggestionId))
.Select(v => v.PlayerId)
.Distinct()
.ToListAsync();
await db.Votes.Where(v => groupIds.Contains(v.SuggestionId)).ExecuteDeleteAsync(); await db.Votes.Where(v => groupIds.Contains(v.SuggestionId)).ExecuteDeleteAsync();
if (affectedPlayerIds.Count > 0) if (affectedPlayerIds.Count > 0)
{ {
await db.Players.Where(p => affectedPlayerIds.Contains(p.Id)) await db.Players.Where(p => affectedPlayerIds.Contains(p.Id)).ExecuteUpdateAsync(p => p.SetProperty(x => x.VotesFinal, false));
.ExecuteUpdateAsync(p => p.SetProperty(x => x.VotesFinal, false));
} }
await tx.CommitAsync(); await tx.CommitAsync();
@@ -234,23 +227,26 @@ public static class AdminEndpoints
}); });
}); });
admin.MapPost("/reset", async (HttpContext ctx, AppDbContext db) => admin.MapPost("/reset", async (HttpContext _, AppDbContext db) =>
{ {
await db.Votes.ExecuteDeleteAsync(); await db.Votes.ExecuteDeleteAsync();
await db.Suggestions.ExecuteDeleteAsync(); await db.Suggestions.ExecuteDeleteAsync();
await db.Players.ExecuteUpdateAsync(p => p.SetProperty(x => x.CurrentPhase, Phase.Suggest) await db.Players.ExecuteUpdateAsync(p => p.SetProperty(x => x.CurrentPhase, Phase.Suggest).SetProperty(x => x.VotesFinal, false).SetProperty(x => x.HasJoker, false));
.SetProperty(x => x.VotesFinal, false)
.SetProperty(x => x.HasJoker, false));
var state = await db.AppState.FirstAsync(); var state = await db.AppState.FirstAsync();
state.ResultsOpen = false; state.ResultsOpen = false;
state.UpdatedAt = DateTimeOffset.UtcNow; state.UpdatedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return Results.Ok(new { Phase = Phase.Suggest, state.ResultsOpen, state.UpdatedAt }); return Results.Ok(new
{
Phase = Phase.Suggest,
state.ResultsOpen,
state.UpdatedAt
});
}); });
admin.MapPost("/factory-reset", async (HttpContext ctx, AppDbContext db) => admin.MapPost("/factory-reset", async (HttpContext _, AppDbContext db) =>
{ {
await using var tx = await db.Database.BeginTransactionAsync(); await using var tx = await db.Database.BeginTransactionAsync();
@@ -265,7 +261,12 @@ public static class AdminEndpoints
await tx.CommitAsync(); await tx.CommitAsync();
return Results.Ok(new { Phase = Phase.Suggest, fresh.ResultsOpen, fresh.UpdatedAt }); return Results.Ok(new
{
Phase = Phase.Suggest,
fresh.ResultsOpen,
fresh.UpdatedAt
});
}); });
} }
} }

View File

@@ -15,7 +15,7 @@ public static class AuthEndpoints
group.MapPost("/register", async ([FromBody] RegisterRequest request, HttpContext ctx, AppDbContext db, IConfiguration config) => group.MapPost("/register", async ([FromBody] RegisterRequest request, HttpContext ctx, AppDbContext db, IConfiguration config) =>
{ {
var username = request.Username?.Trim(); var username = request.Username.Trim();
if (string.IsNullOrWhiteSpace(username) || username.Length > 24) if (string.IsNullOrWhiteSpace(username) || username.Length > 24)
return Results.BadRequest(new { error = "Username is required and must be <= 24 characters." }); return Results.BadRequest(new { error = "Username is required and must be <= 24 characters." });
@@ -28,6 +28,7 @@ public static class AuthEndpoints
var displayName = EndpointHelpers.TrimTo(request.DisplayName, 16); var displayName = EndpointHelpers.TrimTo(request.DisplayName, 16);
if (string.IsNullOrWhiteSpace(displayName)) if (string.IsNullOrWhiteSpace(displayName))
return Results.BadRequest(new { error = "Display name is required." }); return Results.BadRequest(new { error = "Display name is required." });
var normalized = username.ToLowerInvariant(); var normalized = username.ToLowerInvariant();
var exists = await db.Players.AnyAsync(p => p.NormalizedUsername == normalized); var exists = await db.Players.AnyAsync(p => p.NormalizedUsername == normalized);
@@ -43,6 +44,7 @@ public static class AuthEndpoints
if (string.IsNullOrWhiteSpace(expectedAdminKey) || adminKey != expectedAdminKey) if (string.IsNullOrWhiteSpace(expectedAdminKey) || adminKey != expectedAdminKey)
return Results.BadRequest(new { error = "Invalid admin key." }); return Results.BadRequest(new { error = "Invalid admin key." });
} }
var isAdmin = wantsAdmin; var isAdmin = wantsAdmin;
var player = new Player var player = new Player
@@ -63,12 +65,18 @@ public static class AuthEndpoints
await PlayerIdentityExtensions.SignInPlayerAsync(ctx, player); await PlayerIdentityExtensions.SignInPlayerAsync(ctx, player);
return Results.Ok(new { player.Id, player.Username, player.DisplayName, player.IsAdmin }); return Results.Ok(new
{
player.Id,
player.Username,
player.DisplayName,
player.IsAdmin
});
}); });
group.MapPost("/login", async ([FromBody] LoginRequest request, HttpContext ctx, AppDbContext db) => group.MapPost("/login", async ([FromBody] LoginRequest request, HttpContext ctx, AppDbContext db) =>
{ {
var username = request.Username?.Trim(); var username = request.Username.Trim();
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(request.Password)) if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(request.Password))
return Results.BadRequest(new { error = "Username and password are required." }); return Results.BadRequest(new { error = "Username and password are required." });
if (username.Length > 24) if (username.Length > 24)
@@ -83,12 +91,19 @@ public static class AuthEndpoints
{ {
player.DisplayName = EndpointHelpers.TrimTo(player.Username, 16); player.DisplayName = EndpointHelpers.TrimTo(player.Username, 16);
} }
player.LastLoginAt = DateTimeOffset.UtcNow; player.LastLoginAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync(); await db.SaveChangesAsync();
await PlayerIdentityExtensions.SignInPlayerAsync(ctx, player); await PlayerIdentityExtensions.SignInPlayerAsync(ctx, player);
return Results.Ok(new { player.Id, player.Username, player.DisplayName, player.IsAdmin }); return Results.Ok(new
{
player.Id,
player.Username,
player.DisplayName,
player.IsAdmin
});
}); });
group.MapPost("/logout", async (HttpContext ctx) => group.MapPost("/logout", async (HttpContext ctx) =>

View File

@@ -1,7 +1,5 @@
using System.Collections.Generic;
using GameList.Data; using GameList.Data;
using GameList.Domain; using GameList.Domain;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using System.Security.Claims; using System.Security.Claims;
@@ -11,7 +9,8 @@ internal static class EndpointHelpers
{ {
public static async Task<Player?> GetAuthenticatedPlayer(HttpContext ctx, AppDbContext db) public static async Task<Player?> GetAuthenticatedPlayer(HttpContext ctx, AppDbContext db)
{ {
if (ctx?.User?.Identity?.IsAuthenticated != true) return null; if (ctx.User.Identity?.IsAuthenticated != true)
return null;
if (ctx.Items.TryGetValue(nameof(Player), out var cached) && cached is Player cachedPlayer) if (ctx.Items.TryGetValue(nameof(Player), out var cached) && cached is Player cachedPlayer)
return cachedPlayer; return cachedPlayer;
@@ -38,7 +37,8 @@ internal static class EndpointHelpers
public static async Task<Phase> GetPhase(AppDbContext db, Guid playerId) public static async Task<Phase> GetPhase(AppDbContext db, Guid playerId)
{ {
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == playerId); var player = await db.Players.FirstOrDefaultAsync(p => p.Id == playerId);
if (player is null) return Phase.Suggest; if (player is null)
return Phase.Suggest;
var state = await db.AppState.FirstAsync(); var state = await db.AppState.FirstAsync();
@@ -68,6 +68,7 @@ internal static class EndpointHelpers
{ {
await db.SaveChangesAsync(); await db.SaveChangesAsync();
} }
return player.CurrentPhase; return player.CurrentPhase;
} }
@@ -75,58 +76,67 @@ internal static class EndpointHelpers
Results.BadRequest(new { error = $"This endpoint is available in the {required} phase. Your current phase is {current}." }); Results.BadRequest(new { error = $"This endpoint is available in the {required} phase. Your current phase is {current}." });
public static string? TrimTo(string? input, int max) => public static string? TrimTo(string? input, int max) =>
string.IsNullOrWhiteSpace(input) string.IsNullOrWhiteSpace(input) ? null : input.Trim() is { Length: > 0 } t ? t[..Math.Min(t.Length, max)] : null;
? null
: input.Trim() is var t && t.Length > 0
? t[..Math.Min(t.Length, max)]
: null;
public static bool IsValidImageUrl(string? url) public static bool IsValidImageUrl(string? url)
{ {
if (string.IsNullOrWhiteSpace(url)) return true; // empty is acceptable if (string.IsNullOrWhiteSpace(url))
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) return false; return true; // empty is acceptable
if (uri.Scheme is not ("http" or "https")) return false; if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
return false;
if (uri.Scheme is not ("http" or "https"))
return false;
var path = uri.AbsolutePath.ToLowerInvariant(); var path = uri.AbsolutePath.ToLowerInvariant();
return path.EndsWith(".png") || path.EndsWith(".jpg") || path.EndsWith(".jpeg") return path.EndsWith(".png") || path.EndsWith(".jpg") || path.EndsWith(".jpeg") || path.EndsWith(".gif") || path.EndsWith(".webp") || path.EndsWith(".avif");
|| path.EndsWith(".gif") || path.EndsWith(".webp") || path.EndsWith(".avif");
} }
public static async Task<bool> IsReachableImageAsync(string? url, IHttpClientFactory httpFactory, HttpMessageHandler? handler = null, 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 (string.IsNullOrWhiteSpace(url))
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) return false; return true;
if (uri.Scheme is not ("http" or "https")) return false; if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
if (!await IsSafePublicHostAsync(uri, httpFactory, ct)) return false; return false;
if (uri.Scheme is not ("http" or "https"))
return false;
if (!await IsSafePublicHostAsync(uri, ct))
return false;
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(TimeSpan.FromSeconds(3)); cts.CancelAfter(TimeSpan.FromSeconds(3));
var client = handler is null var client = handler is null ? httpFactory.CreateClient("imageValidation") : new HttpClient(handler, disposeHandler: false);
? httpFactory.CreateClient("imageValidation")
: new HttpClient(handler, disposeHandler: false);
try try
{ {
using var head = new HttpRequestMessage(HttpMethod.Head, uri); using var head = new HttpRequestMessage(HttpMethod.Head, uri);
var headResp = await client.SendAsync(head, HttpCompletionOption.ResponseHeadersRead, cts.Token); var headResp = await client.SendAsync(head, HttpCompletionOption.ResponseHeadersRead, cts.Token);
if (headResp.IsSuccessStatusCode && headResp.StatusCode is not System.Net.HttpStatusCode.Redirect) if (headResp is { IsSuccessStatusCode: true, StatusCode: not System.Net.HttpStatusCode.Redirect })
{ {
if (headResp.Content.Headers.ContentLength is long headLen && headLen > MaxImageBytes) return false; if (headResp.Content.Headers.ContentLength is > MaxImageBytes)
return false;
var ctHeader = headResp.Content.Headers.ContentType?.MediaType; var ctHeader = headResp.Content.Headers.ContentType?.MediaType;
if (!string.IsNullOrWhiteSpace(ctHeader) && ctHeader.StartsWith("image/", StringComparison.OrdinalIgnoreCase)) if (!string.IsNullOrWhiteSpace(ctHeader) && ctHeader.StartsWith("image/", StringComparison.OrdinalIgnoreCase))
return true; return true;
} }
} }
catch { /* fallback */ } catch
{
/* fallback */
}
try try
{ {
using var get = new HttpRequestMessage(HttpMethod.Get, uri); using var get = new HttpRequestMessage(HttpMethod.Get, uri);
get.Headers.Range = new System.Net.Http.Headers.RangeHeaderValue(0, 1023); get.Headers.Range = new System.Net.Http.Headers.RangeHeaderValue(0, 1023);
var resp = await client.SendAsync(get, HttpCompletionOption.ResponseHeadersRead, cts.Token); var resp = await client.SendAsync(get, HttpCompletionOption.ResponseHeadersRead, cts.Token);
if (!resp.IsSuccessStatusCode) return false; if (!resp.IsSuccessStatusCode)
if (resp.StatusCode is System.Net.HttpStatusCode.Redirect) return false; return false;
if (resp.Content.Headers.ContentLength is long len && len > MaxImageBytes) return false; if (resp.StatusCode is System.Net.HttpStatusCode.Redirect)
return false;
if (resp.Content.Headers.ContentLength is > MaxImageBytes)
return false;
var ctHeader = resp.Content.Headers.ContentType?.MediaType; var ctHeader = resp.Content.Headers.ContentType?.MediaType;
if (!string.IsNullOrWhiteSpace(ctHeader) && ctHeader.StartsWith("image/", StringComparison.OrdinalIgnoreCase)) if (!string.IsNullOrWhiteSpace(ctHeader) && ctHeader.StartsWith("image/", StringComparison.OrdinalIgnoreCase))
@@ -137,11 +147,16 @@ internal static class EndpointHelpers
var read = await stream.ReadAsync(rented, 0, rented.Length, cts.Token); var read = await stream.ReadAsync(rented, 0, rented.Length, cts.Token);
var sig = new ReadOnlySpan<byte>(rented, 0, read); var sig = new ReadOnlySpan<byte>(rented, 0, read);
if (IsMagic(sig, "PNG")) return true; if (IsMagic(sig, "PNG"))
if (IsMagic(sig, new byte[] { 0xFF, 0xD8 })) return true; // JPEG return true;
if (IsMagic(sig, "GIF8")) return true; if (IsMagic(sig, [0xFF, 0xD8]))
if (IsRiffWithTag(sig, "WEBP")) return true; return true; // JPEG
if (ContainsFtyp(sig, "avif")) return true; if (IsMagic(sig, "GIF8"))
return true;
if (IsRiffWithTag(sig, "WEBP"))
return true;
if (ContainsFtyp(sig, "avif"))
return true;
return false; return false;
} }
@@ -153,7 +168,7 @@ internal static class EndpointHelpers
private const long MaxImageBytes = 5 * 1024 * 1024; // 5 MB guard private const long MaxImageBytes = 5 * 1024 * 1024; // 5 MB guard
private static async Task<bool> IsSafePublicHostAsync(Uri uri, IHttpClientFactory httpFactory, CancellationToken ct) private static async Task<bool> IsSafePublicHostAsync(Uri uri, CancellationToken ct)
{ {
try try
{ {
@@ -163,8 +178,10 @@ internal static class EndpointHelpers
var addresses = await System.Net.Dns.GetHostAddressesAsync(host, ct); var addresses = await System.Net.Dns.GetHostAddressesAsync(host, ct);
foreach (var ip in addresses) foreach (var ip in addresses)
{ {
if (System.Net.IPAddress.IsLoopback(ip)) return false; if (System.Net.IPAddress.IsLoopback(ip))
if (IsPrivate(ip)) return false; return false;
if (IsPrivate(ip))
return false;
} }
} }
else else
@@ -213,26 +230,37 @@ internal static class EndpointHelpers
private static bool IsRiffWithTag(ReadOnlySpan<byte> data, string tag) private static bool IsRiffWithTag(ReadOnlySpan<byte> data, string tag)
{ {
if (data.Length < 12) return false; if (data.Length < 12)
var riff = System.Text.Encoding.ASCII.GetBytes("RIFF"); return false;
if (!data.StartsWith(riff)) return false;
var riff = "RIFF"u8.ToArray();
if (!data.StartsWith(riff))
return false;
var tagBytes = System.Text.Encoding.ASCII.GetBytes(tag); var tagBytes = System.Text.Encoding.ASCII.GetBytes(tag);
return data[8..].StartsWith(tagBytes); return data[8..].StartsWith(tagBytes);
} }
private static bool ContainsFtyp(ReadOnlySpan<byte> data, string brand) private static bool ContainsFtyp(ReadOnlySpan<byte> data, string brand)
{ {
if (data.Length < 12) return false; if (data.Length < 12)
var ftyp = System.Text.Encoding.ASCII.GetBytes("ftyp"); return false;
if (!data[4..].StartsWith(ftyp)) return false;
var ftyp = "ftyp"u8.ToArray();
if (!data[4..].StartsWith(ftyp))
return false;
var brandBytes = System.Text.Encoding.ASCII.GetBytes(brand); var brandBytes = System.Text.Encoding.ASCII.GetBytes(brand);
return data[8..].StartsWith(brandBytes); return data[8..].StartsWith(brandBytes);
} }
public static bool IsValidHttpUrl(string? url) public static bool IsValidHttpUrl(string? url)
{ {
if (string.IsNullOrWhiteSpace(url)) return true; // empty is allowed if (string.IsNullOrWhiteSpace(url))
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) return false; return true; // empty is allowed
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
return false;
return uri.Scheme is "http" or "https"; return uri.Scheme is "http" or "https";
} }
@@ -257,6 +285,7 @@ internal static class EndpointHelpers
{ {
roots[id] = FindRootId(id, parentMap); roots[id] = FindRootId(id, parentMap);
} }
return roots; return roots;
} }
@@ -265,7 +294,7 @@ internal static class EndpointHelpers
var current = suggestionId; var current = suggestionId;
var visited = new HashSet<int>(); var visited = new HashSet<int>();
while (parentMap.TryGetValue(current, out var parent) && parent is int p && !visited.Contains(p)) while (parentMap.TryGetValue(current, out var parent) && parent is { } p && !visited.Contains(p))
{ {
visited.Add(current); visited.Add(current);
current = p; current = p;
@@ -276,7 +305,9 @@ internal static class EndpointHelpers
public static List<int> LinkedIdsFor(int suggestionId, IReadOnlyDictionary<int, int> rootIndex) public static List<int> LinkedIdsFor(int suggestionId, IReadOnlyDictionary<int, int> rootIndex)
{ {
if (!rootIndex.TryGetValue(suggestionId, out var root)) return new(); if (!rootIndex.TryGetValue(suggestionId, out var root))
return [];
return rootIndex.Where(kv => kv.Value == root).Select(kv => kv.Key).ToList(); return rootIndex.Where(kv => kv.Value == root).Select(kv => kv.Key).ToList();
} }
} }

View File

@@ -39,7 +39,7 @@ public static class ResultsEndpoints
s.MinPlayers, s.MinPlayers,
s.MaxPlayers, s.MaxPlayers,
Total = s.Votes.Sum(v => v.Score), Total = s.Votes.Sum(v => v.Score),
Count = s.Votes.Count, s.Votes.Count,
Average = s.Votes.Count == 0 ? 0 : s.Votes.Average(v => v.Score), Average = s.Votes.Count == 0 ? 0 : s.Votes.Average(v => v.Score),
Votes = s.Votes.Select(v => v.Score).ToList(), Votes = s.Votes.Select(v => v.Score).ToList(),
MyVote = s.Votes MyVote = s.Votes
@@ -85,7 +85,7 @@ public static class ResultsEndpoints
r.ParentSuggestionId, r.ParentSuggestionId,
LinkedIds = linkedIds, LinkedIds = linkedIds,
LinkedTitles = linkedIds LinkedTitles = linkedIds
.Where(id => nameLookup.ContainsKey(id)) .Where(nameLookup.ContainsKey)
.Select(id => nameLookup[id]) .Select(id => nameLookup[id])
.ToList() .ToList()
}; };

View File

@@ -15,7 +15,9 @@ public static class StateEndpoints
group.MapGet("/state", async (HttpContext ctx, AppDbContext db) => group.MapGet("/state", async (HttpContext ctx, AppDbContext db) =>
{ {
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db); var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
if (player is null) return Results.Unauthorized(); if (player is null)
return Results.Unauthorized();
var phase = await EndpointHelpers.GetPhase(db, player.Id); var phase = await EndpointHelpers.GetPhase(db, player.Id);
var state = await db.AppState.AsNoTracking().FirstAsync(); var state = await db.AppState.AsNoTracking().FirstAsync();
@@ -36,16 +38,27 @@ public static class StateEndpoints
group.MapGet("/me", async (HttpContext ctx, AppDbContext db) => group.MapGet("/me", async (HttpContext ctx, AppDbContext db) =>
{ {
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db); var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
if (player is null) return Results.Unauthorized(); if (player is null)
return Results.Unauthorized();
var phase = await EndpointHelpers.GetPhase(db, player.Id); var phase = await EndpointHelpers.GetPhase(db, player.Id);
return Results.Ok(new { player.Id, player.DisplayName, player.Username, player.IsAdmin, CurrentPhase = phase, player.VotesFinal, player.HasJoker }); return Results.Ok(new
{
player.Id,
player.DisplayName,
player.Username,
player.IsAdmin,
CurrentPhase = phase,
player.VotesFinal,
player.HasJoker
});
}); });
group.MapPost("/me/phase/next", async (HttpContext ctx, AppDbContext db, IConfiguration config) => group.MapPost("/me/phase/next", async (HttpContext ctx, AppDbContext db, IConfiguration _) =>
{ {
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db); var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
if (player is null) return Results.Unauthorized(); if (player is null)
var isAdmin = await EndpointHelpers.IsAdmin(ctx, db); return Results.Unauthorized();
var next = NextPhase(player.CurrentPhase); var next = NextPhase(player.CurrentPhase);
var appState = await db.AppState.FirstAsync(); var appState = await db.AppState.FirstAsync();
@@ -58,13 +71,19 @@ public static class StateEndpoints
player.CurrentPhase = next; player.CurrentPhase = next;
player.VotesFinal = false; // moving forward clears any prior finalize player.VotesFinal = false; // moving forward clears any prior finalize
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return Results.Ok(new { player.CurrentPhase, appState.ResultsOpen }); return Results.Ok(new
{
player.CurrentPhase,
appState.ResultsOpen
});
}); });
group.MapPost("/me/phase/prev", async (HttpContext ctx, AppDbContext db, IConfiguration config) => group.MapPost("/me/phase/prev", async (HttpContext ctx, AppDbContext db, IConfiguration _) =>
{ {
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db); var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
if (player is null) return Results.Unauthorized(); if (player is null)
return Results.Unauthorized();
var isAdmin = await EndpointHelpers.IsAdmin(ctx, db); var isAdmin = await EndpointHelpers.IsAdmin(ctx, db);
if (!isAdmin) if (!isAdmin)
{ {
@@ -75,12 +94,16 @@ public static class StateEndpoints
player.VotesFinal = false; player.VotesFinal = false;
await db.SaveChangesAsync(); await db.SaveChangesAsync();
var appState = await db.AppState.AsNoTracking().FirstAsync(); var appState = await db.AppState.AsNoTracking().FirstAsync();
return Results.Ok(new { player.CurrentPhase, appState.ResultsOpen }); return Results.Ok(new
{
player.CurrentPhase,
appState.ResultsOpen
});
}); });
group.MapPost("/me/name", async ([FromBody] SetNameRequest request, HttpContext ctx, AppDbContext db) => group.MapPost("/me/name", async ([FromBody] SetNameRequest request, HttpContext ctx, AppDbContext db) =>
{ {
if (request.Name?.Trim().Length > 16) if (request.Name.Trim().Length > 16)
{ {
return Results.BadRequest(new { error = "Name is required and must be <= 16 characters." }); return Results.BadRequest(new { error = "Name is required and must be <= 16 characters." });
} }
@@ -92,11 +115,16 @@ public static class StateEndpoints
} }
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db); var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
if (player is null) return Results.Unauthorized(); if (player is null)
return Results.Unauthorized();
player.DisplayName = name; player.DisplayName = name;
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return Results.Ok(new { player.Id, player.DisplayName }); return Results.Ok(new
{
player.Id,
player.DisplayName
});
}); });
} }

View File

@@ -16,10 +16,10 @@ public static class SuggestEndpoints
group.MapGet("/mine", async (HttpContext ctx, AppDbContext db) => group.MapGet("/mine", async (HttpContext ctx, AppDbContext db) =>
{ {
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db); var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
if (player is null) return Results.Unauthorized(); if (player is null)
var mine = await db.Suggestions.AsNoTracking() return Results.Unauthorized();
.Where(s => s.PlayerId == player.Id)
.Select(s => new var mine = await db.Suggestions.AsNoTracking().Where(s => s.PlayerId == player.Id).Select(s => new
{ {
s.Id, s.Id,
s.PlayerId, s.PlayerId,
@@ -33,12 +33,9 @@ public static class SuggestEndpoints
s.MinPlayers, s.MinPlayers,
s.MaxPlayers, s.MaxPlayers,
s.ParentSuggestionId s.ParentSuggestionId
}) }).ToListAsync();
.ToListAsync();
var ordered = mine var ordered = mine.OrderBy(s => s.CreatedAt).Select(s => new SuggestionDto(s.Id, s.Name, s.Genre, s.Description, s.ScreenshotUrl, s.YoutubeUrl, s.GameUrl, s.MinPlayers, s.MaxPlayers, s.ParentSuggestionId));
.OrderBy(s => s.CreatedAt)
.Select(s => new SuggestionDto(s.Id, s.Name, s.Genre, s.Description, s.ScreenshotUrl, s.YoutubeUrl, s.GameUrl, s.MinPlayers, s.MaxPlayers, s.ParentSuggestionId));
return Results.Ok(ordered); return Results.Ok(ordered);
}); });
@@ -54,10 +51,12 @@ public static class SuggestEndpoints
{ {
return Results.BadRequest(new { error = "Screenshot URL must be http(s) and end with an image file extension." }); return Results.BadRequest(new { error = "Screenshot URL must be http(s) and end with an image file extension." });
} }
if (!await EndpointHelpers.IsReachableImageAsync(request.ScreenshotUrl, http)) if (!await EndpointHelpers.IsReachableImageAsync(request.ScreenshotUrl, http))
{ {
return Results.BadRequest(new { error = "Screenshot URL could not be validated as an image. Use a public image link (http/https, no redirects, max 5 MB)." }); return Results.BadRequest(new { error = "Screenshot URL could not be validated as an image. Use a public image link (http/https, no redirects, max 5 MB)." });
} }
if (!EndpointHelpers.IsValidHttpUrl(request.GameUrl)) if (!EndpointHelpers.IsValidHttpUrl(request.GameUrl))
return Results.BadRequest(new { error = "Game URL must be http or https." }); return Results.BadRequest(new { error = "Game URL must be http or https." });
if (!EndpointHelpers.IsValidHttpUrl(request.YoutubeUrl)) if (!EndpointHelpers.IsValidHttpUrl(request.YoutubeUrl))
@@ -67,11 +66,14 @@ public static class SuggestEndpoints
return Results.BadRequest(new { error = playersError }); return Results.BadRequest(new { error = playersError });
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db); var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
if (player is null) return Results.Unauthorized(); if (player is null)
return Results.Unauthorized();
var phase = await EndpointHelpers.GetPhase(db, player.Id); var phase = await EndpointHelpers.GetPhase(db, player.Id);
var usingJoker = phase == Phase.Vote && player.HasJoker; var usingJoker = phase == Phase.Vote && player.HasJoker;
if (phase != Phase.Suggest && !usingJoker) if (phase != Phase.Suggest && !usingJoker)
return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase); return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase);
if (string.IsNullOrWhiteSpace(player.DisplayName)) if (string.IsNullOrWhiteSpace(player.DisplayName))
{ {
return Results.BadRequest(new { error = "Set a display name before submitting suggestions." }); return Results.BadRequest(new { error = "Set a display name before submitting suggestions." });
@@ -107,13 +109,14 @@ public static class SuggestEndpoints
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return Results.Created($"/api/suggestions/{suggestion.Id}", new { suggestion.Id }); return Results.Created($"/api/suggestions/{suggestion.Id}", new { suggestion.Id });
}) }).AddEndpointFilter(new PhaseOrJokerFilter());
.AddEndpointFilter(new PhaseOrJokerFilter());
group.MapDelete("/{id:int}", async (int id, HttpContext ctx, AppDbContext db) => group.MapDelete("/{id:int}", async (int id, HttpContext ctx, AppDbContext db) =>
{ {
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db); var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
if (player is null) return Results.Unauthorized(); if (player is null)
return Results.Unauthorized();
var isAdmin = await EndpointHelpers.IsAdmin(ctx, db); var isAdmin = await EndpointHelpers.IsAdmin(ctx, db);
if (!isAdmin) if (!isAdmin)
@@ -123,16 +126,12 @@ public static class SuggestEndpoints
return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase); return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase);
} }
var suggestion = isAdmin var suggestion = isAdmin ? await db.Suggestions.FirstOrDefaultAsync(s => s.Id == id) : await db.Suggestions.FirstOrDefaultAsync(s => s.Id == id && s.PlayerId == player.Id);
? await db.Suggestions.FirstOrDefaultAsync(s => s.Id == id)
: await db.Suggestions.FirstOrDefaultAsync(s => s.Id == id && s.PlayerId == player.Id);
if (suggestion == null) if (suggestion == null)
return Results.NotFound(new { error = "Suggestion not found." }); return Results.NotFound(new { error = "Suggestion not found." });
// Break any links that pointed at this suggestion // Break any links that pointed at this suggestion
await db.Suggestions await db.Suggestions.Where(s => s.ParentSuggestionId == suggestion.Id).ExecuteUpdateAsync(s => s.SetProperty(x => x.ParentSuggestionId, (int?)null));
.Where(s => s.ParentSuggestionId == suggestion.Id)
.ExecuteUpdateAsync(s => s.SetProperty(x => x.ParentSuggestionId, (int?)null));
// Remove votes for this suggestion to avoid orphaned vote rows or FK errors // Remove votes for this suggestion to avoid orphaned vote rows or FK errors
await db.Votes.Where(v => v.SuggestionId == suggestion.Id).ExecuteDeleteAsync(); await db.Votes.Where(v => v.SuggestionId == suggestion.Id).ExecuteDeleteAsync();
@@ -147,7 +146,8 @@ public static class SuggestEndpoints
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db); var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
var isAdmin = await EndpointHelpers.IsAdmin(ctx, db); var isAdmin = await EndpointHelpers.IsAdmin(ctx, db);
if (!isAdmin && player is null) return Results.Unauthorized(); if (!isAdmin && player is null)
return Results.Unauthorized();
if (string.IsNullOrWhiteSpace(request.Name) || request.Name.Length > 100) if (string.IsNullOrWhiteSpace(request.Name) || request.Name.Length > 100)
{ {
@@ -158,10 +158,12 @@ public static class SuggestEndpoints
{ {
return Results.BadRequest(new { error = "Screenshot URL must be http(s) and end with an image file extension." }); return Results.BadRequest(new { error = "Screenshot URL must be http(s) and end with an image file extension." });
} }
if (!await EndpointHelpers.IsReachableImageAsync(request.ScreenshotUrl, http)) if (!await EndpointHelpers.IsReachableImageAsync(request.ScreenshotUrl, http))
{ {
return Results.BadRequest(new { error = "Screenshot URL could not be validated as an image. Use a public image link (http/https, no redirects, max 5 MB)." }); return Results.BadRequest(new { error = "Screenshot URL could not be validated as an image. Use a public image link (http/https, no redirects, max 5 MB)." });
} }
if (!EndpointHelpers.IsValidHttpUrl(request.GameUrl)) if (!EndpointHelpers.IsValidHttpUrl(request.GameUrl))
return Results.BadRequest(new { error = "Game URL must be http or https." }); return Results.BadRequest(new { error = "Game URL must be http or https." });
if (!EndpointHelpers.IsValidHttpUrl(request.YoutubeUrl)) if (!EndpointHelpers.IsValidHttpUrl(request.YoutubeUrl))
@@ -171,7 +173,8 @@ public static class SuggestEndpoints
return Results.BadRequest(new { error = playersError }); return Results.BadRequest(new { error = playersError });
var suggestion = await db.Suggestions.FirstOrDefaultAsync(s => s.Id == id); var suggestion = await db.Suggestions.FirstOrDefaultAsync(s => s.Id == id);
if (suggestion == null) return Results.NotFound(new { error = "Suggestion not found." }); if (suggestion == null)
return Results.NotFound(new { error = "Suggestion not found." });
if (!isAdmin) if (!isAdmin)
{ {
@@ -238,14 +241,14 @@ public static class SuggestEndpoints
group.MapGet("/all", async (HttpContext ctx, AppDbContext db) => group.MapGet("/all", async (HttpContext ctx, AppDbContext db) =>
{ {
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db); var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
if (player is null) return Results.Unauthorized(); if (player is null)
return Results.Unauthorized();
var phase = await EndpointHelpers.GetPhase(db, player.Id); var phase = await EndpointHelpers.GetPhase(db, player.Id);
if (phase < Phase.Vote) if (phase < Phase.Vote)
return EndpointHelpers.PhaseMismatch(Phase.Vote, phase); return EndpointHelpers.PhaseMismatch(Phase.Vote, phase);
var all = await db.Suggestions.AsNoTracking() var all = await db.Suggestions.AsNoTracking().Include(s => s.Player).Select(s => new
.Include(s => s.Player)
.Select(s => new
{ {
s.Id, s.Id,
s.Name, s.Name,
@@ -260,19 +263,14 @@ public static class SuggestEndpoints
s.CreatedAt, s.CreatedAt,
s.ParentSuggestionId, s.ParentSuggestionId,
IsOwner = s.PlayerId == player.Id IsOwner = s.PlayerId == player.Id
}) }).ToListAsync();
.ToListAsync();
var rootIndex = EndpointHelpers.BuildLinkRoots(all.Select(s => (s.Id, s.ParentSuggestionId))); var rootIndex = EndpointHelpers.BuildLinkRoots(all.Select(s => (s.Id, s.ParentSuggestionId)));
var nameLookup = all.ToDictionary(s => s.Id, s => s.Name); var nameLookup = all.ToDictionary(s => s.Id, s => s.Name);
var ordered = all var ordered = all.OrderBy(s => s.CreatedAt).Select(s =>
.OrderBy(s => s.CreatedAt)
.Select(s =>
{ {
var linkedIds = EndpointHelpers.LinkedIdsFor(s.Id, rootIndex) var linkedIds = EndpointHelpers.LinkedIdsFor(s.Id, rootIndex).Where(id => id != s.Id).ToList();
.Where(id => id != s.Id)
.ToList();
return new return new
{ {
@@ -289,10 +287,7 @@ public static class SuggestEndpoints
s.ParentSuggestionId, s.ParentSuggestionId,
s.IsOwner, s.IsOwner,
LinkedIds = linkedIds, LinkedIds = linkedIds,
LinkedTitles = linkedIds LinkedTitles = linkedIds.Where(nameLookup.ContainsKey).Select(id => nameLookup[id]).ToList()
.Where(id => nameLookup.ContainsKey(id))
.Select(id => nameLookup[id])
.ToList()
}; };
}); });
@@ -303,15 +298,16 @@ public static class SuggestEndpoints
private static bool ValidatePlayers(int? minPlayers, int? maxPlayers, out string? error) private static bool ValidatePlayers(int? minPlayers, int? maxPlayers, out string? error)
{ {
error = null; error = null;
if (minPlayers is null && maxPlayers is null) return true; if (minPlayers is null && maxPlayers is null)
return true;
if (minPlayers is not null && (minPlayers < 1 || minPlayers > 32)) if (minPlayers is < 1 or > 32)
{ {
error = "Min players must be between 1 and 32."; error = "Min players must be between 1 and 32.";
return false; return false;
} }
if (maxPlayers is not null && (maxPlayers < 1 || maxPlayers > 32)) if (maxPlayers is < 1 or > 32)
{ {
error = "Max players must be between 1 and 32."; error = "Max players must be between 1 and 32.";
return false; return false;

View File

@@ -11,21 +11,23 @@ public static class VoteEndpoints
{ {
public static void MapVoteEndpoints(this IEndpointRouteBuilder app) public static void MapVoteEndpoints(this IEndpointRouteBuilder app)
{ {
var group = app.MapGroup("/api/votes") var group = app.MapGroup("/api/votes").RequireAuthorization().AddEndpointFilter(new PhaseRequirementFilter(Phase.Vote));
.RequireAuthorization()
.AddEndpointFilter(new PhaseRequirementFilter(Phase.Vote));
group.MapGet("/mine", async (HttpContext ctx, AppDbContext db) => group.MapGet("/mine", async (HttpContext ctx, AppDbContext db) =>
{ {
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db); var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
if (player is null) return Results.Unauthorized(); if (player is null)
return Results.Unauthorized();
var phase = await EndpointHelpers.GetPhase(db, player.Id); var phase = await EndpointHelpers.GetPhase(db, player.Id);
if (phase != Phase.Vote) if (phase != Phase.Vote)
return EndpointHelpers.PhaseMismatch(Phase.Vote, phase); return EndpointHelpers.PhaseMismatch(Phase.Vote, phase);
var votes = await db.Votes.AsNoTracking()
.Where(v => v.PlayerId == player.Id) var votes = await db.Votes.AsNoTracking().Where(v => v.PlayerId == player.Id).Select(v => new
.Select(v => new { v.SuggestionId, v.Score }) {
.ToListAsync(); v.SuggestionId,
v.Score
}).ToListAsync();
return Results.Ok(votes); return Results.Ok(votes);
}); });
@@ -36,9 +38,11 @@ public static class VoteEndpoints
return Results.BadRequest(new { error = "Score must be between 0 and 10." }); return Results.BadRequest(new { error = "Score must be between 0 and 10." });
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db); var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
if (player is null) return Results.Unauthorized(); if (player is null)
return Results.Unauthorized();
if (player.VotesFinal) if (player.VotesFinal)
return Results.BadRequest(new { error = "Votes are finalized. Unfinalize before changing scores." }); return Results.BadRequest(new { error = "Votes are finalized. Unfinalize before changing scores." });
var phase = await EndpointHelpers.GetPhase(db, player.Id); var phase = await EndpointHelpers.GetPhase(db, player.Id);
if (phase != Phase.Vote) if (phase != Phase.Vote)
return EndpointHelpers.PhaseMismatch(Phase.Vote, phase); return EndpointHelpers.PhaseMismatch(Phase.Vote, phase);
@@ -46,19 +50,20 @@ public static class VoteEndpoints
if (string.IsNullOrWhiteSpace(player.DisplayName)) if (string.IsNullOrWhiteSpace(player.DisplayName))
return Results.BadRequest(new { error = "Set a display name before voting." }); return Results.BadRequest(new { error = "Set a display name before voting." });
var linkMap = await db.Suggestions.AsNoTracking() var linkMap = await db.Suggestions.AsNoTracking().Select(s => new
.Select(s => new { s.Id, s.ParentSuggestionId }) {
.ToListAsync(); s.Id,
s.ParentSuggestionId
}).ToListAsync();
var rootIndex = EndpointHelpers.BuildLinkRoots(linkMap.Select(s => (s.Id, s.ParentSuggestionId))); var rootIndex = EndpointHelpers.BuildLinkRoots(linkMap.Select(s => (s.Id, s.ParentSuggestionId)));
if (!rootIndex.ContainsKey(request.SuggestionId)) if (!rootIndex.ContainsKey(request.SuggestionId))
return Results.BadRequest(new { error = "Suggestion not found." }); return Results.BadRequest(new { error = "Suggestion not found." });
var linkedIds = EndpointHelpers.LinkedIdsFor(request.SuggestionId, rootIndex); var linkedIds = EndpointHelpers.LinkedIdsFor(request.SuggestionId, rootIndex);
if (linkedIds.Count == 0) if (linkedIds.Count == 0)
linkedIds.Add(request.SuggestionId); linkedIds.Add(request.SuggestionId);
var existingVotes = await db.Votes var existingVotes = await db.Votes.Where(v => v.PlayerId == player.Id && linkedIds.Contains(v.SuggestionId)).ToListAsync();
.Where(v => v.PlayerId == player.Id && linkedIds.Contains(v.SuggestionId))
.ToListAsync();
foreach (var suggestionId in linkedIds) foreach (var suggestionId in linkedIds)
{ {
@@ -79,13 +84,19 @@ public static class VoteEndpoints
} }
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return Results.Ok(new { SuggestionIds = linkedIds, request.Score }); return Results.Ok(new
{
SuggestionIds = linkedIds,
request.Score
});
}); });
group.MapPost("/finalize", async ([FromBody] VoteFinalizeRequest request, HttpContext ctx, AppDbContext db) => group.MapPost("/finalize", async ([FromBody] VoteFinalizeRequest request, HttpContext ctx, AppDbContext db) =>
{ {
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db); var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
if (player is null) return Results.Unauthorized(); if (player is null)
return Results.Unauthorized();
var phase = await EndpointHelpers.GetPhase(db, player.Id); var phase = await EndpointHelpers.GetPhase(db, player.Id);
if (phase != Phase.Vote) if (phase != Phase.Vote)
return EndpointHelpers.PhaseMismatch(Phase.Vote, phase); return EndpointHelpers.PhaseMismatch(Phase.Vote, phase);

View File

@@ -12,7 +12,7 @@ public class AdminTests
[Fact] [Fact]
public async Task Admin_vote_status_marks_ready_when_all_finalized() public async Task Admin_vote_status_marks_ready_when_all_finalized()
{ {
using var factory = new TestWebApplicationFactory(); await using var factory = new TestWebApplicationFactory();
var admin = factory.CreateClientWithCookies(); var admin = factory.CreateClientWithCookies();
await admin.RegisterAsync("admin", admin: true); await admin.RegisterAsync("admin", admin: true);
await admin.PostAsJsonAsync("/api/me/phase/next", new { }); // move to Vote await admin.PostAsJsonAsync("/api/me/phase/next", new { }); // move to Vote
@@ -25,9 +25,17 @@ public class AdminTests
var s1 = await p1.CreateSuggestionAsync("A"); var s1 = await p1.CreateSuggestionAsync("A");
await p1.PostAsJsonAsync("/api/me/phase/next", new { }); await p1.PostAsJsonAsync("/api/me/phase/next", new { });
await p1.PostAsJsonAsync("/api/votes", new { SuggestionId = s1, Score = 5 }); await p1.PostAsJsonAsync("/api/votes", new
{
SuggestionId = s1,
Score = 5
});
await p1.PostAsJsonAsync("/api/votes/finalize", new { Final = true }); await p1.PostAsJsonAsync("/api/votes/finalize", new { Final = true });
await p2.PostAsJsonAsync("/api/votes", new { SuggestionId = s1, Score = 7 }); await p2.PostAsJsonAsync("/api/votes", new
{
SuggestionId = s1,
Score = 7
});
await p2.PostAsJsonAsync("/api/votes/finalize", new { Final = true }); await p2.PostAsJsonAsync("/api/votes/finalize", new { Final = true });
await admin.PostAsJsonAsync("/api/votes/finalize", new { Final = true }); await admin.PostAsJsonAsync("/api/votes/finalize", new { Final = true });
@@ -40,7 +48,7 @@ public class AdminTests
[Fact] [Fact]
public async Task Grant_joker_only_in_vote_phase() public async Task Grant_joker_only_in_vote_phase()
{ {
using var factory = new TestWebApplicationFactory(); await using var factory = new TestWebApplicationFactory();
var admin = factory.CreateClientWithCookies(); var admin = factory.CreateClientWithCookies();
await admin.RegisterAsync("admin", admin: true); await admin.RegisterAsync("admin", admin: true);
@@ -54,7 +62,7 @@ public class AdminTests
[Fact] [Fact]
public async Task Delete_player_cascades_suggestions_and_votes() public async Task Delete_player_cascades_suggestions_and_votes()
{ {
using var factory = new TestWebApplicationFactory(); await using var factory = new TestWebApplicationFactory();
var admin = factory.CreateClientWithCookies(); var admin = factory.CreateClientWithCookies();
await admin.RegisterAsync("admin", admin: true); await admin.RegisterAsync("admin", admin: true);
@@ -63,23 +71,37 @@ public class AdminTests
var suggestionId = await player.CreateSuggestionAsync("DeleteGame"); var suggestionId = await player.CreateSuggestionAsync("DeleteGame");
await player.PostAsJsonAsync("/api/me/phase/next", new { }); await player.PostAsJsonAsync("/api/me/phase/next", new { });
await player.PostAsJsonAsync("/api/votes", new { SuggestionId = suggestionId, Score = 8 }); await player.PostAsJsonAsync("/api/votes", new
{
SuggestionId = suggestionId,
Score = 8
});
var resp = await admin.DeleteAsync($"/api/admin/players/{await player.GetProfileIdAsync()}"); var resp = await admin.DeleteAsync($"/api/admin/players/{await player.GetProfileIdAsync()}");
resp.EnsureSuccessStatusCode(); resp.EnsureSuccessStatusCode();
await factory.WithDbContextAsync(async db => await factory.WithDbContextAsync(db =>
{
try
{ {
Assert.Single(db.Players); // admin remains Assert.Single(db.Players); // admin remains
Assert.Empty(db.Suggestions); Assert.Empty(db.Suggestions);
Assert.Empty(db.Votes); Assert.Empty(db.Votes);
return Task.CompletedTask;
}
catch (Exception exception)
{
return Task.FromException(exception);
}
}); });
} }
[Fact] [Fact]
public async Task Link_suggestions_errors_on_same_id_and_already_linked() public async Task Link_suggestions_errors_on_same_id_and_already_linked()
{ {
using var factory = new TestWebApplicationFactory(); await using var factory = new TestWebApplicationFactory();
var admin = factory.CreateClientWithCookies(); var admin = factory.CreateClientWithCookies();
await admin.RegisterAsync("admin", admin: true); await admin.RegisterAsync("admin", admin: true);
var player = factory.CreateClientWithCookies(); var player = factory.CreateClientWithCookies();
@@ -91,20 +113,32 @@ public class AdminTests
await player.PostAsJsonAsync("/api/me/phase/next", new { }); await player.PostAsJsonAsync("/api/me/phase/next", new { });
await admin.PostAsJsonAsync("/api/me/phase/next", new { }); await admin.PostAsJsonAsync("/api/me/phase/next", new { });
var same = await admin.PostAsJsonAsync("/api/admin/link-suggestions", new { SourceSuggestionId = a, TargetSuggestionId = a }); var same = await admin.PostAsJsonAsync("/api/admin/link-suggestions", new
{
SourceSuggestionId = a,
TargetSuggestionId = a
});
Assert.Equal(HttpStatusCode.BadRequest, same.StatusCode); Assert.Equal(HttpStatusCode.BadRequest, same.StatusCode);
var first = await admin.PostAsJsonAsync("/api/admin/link-suggestions", new { SourceSuggestionId = a, TargetSuggestionId = b }); var first = await admin.PostAsJsonAsync("/api/admin/link-suggestions", new
{
SourceSuggestionId = a,
TargetSuggestionId = b
});
first.EnsureSuccessStatusCode(); first.EnsureSuccessStatusCode();
var already = await admin.PostAsJsonAsync("/api/admin/link-suggestions", new { SourceSuggestionId = a, TargetSuggestionId = b }); var already = await admin.PostAsJsonAsync("/api/admin/link-suggestions", new
{
SourceSuggestionId = a,
TargetSuggestionId = b
});
Assert.Equal(HttpStatusCode.BadRequest, already.StatusCode); Assert.Equal(HttpStatusCode.BadRequest, already.StatusCode);
} }
[Fact] [Fact]
public async Task Unlink_suggestions_clears_group_votes() public async Task Unlink_suggestions_clears_group_votes()
{ {
using var factory = new TestWebApplicationFactory(); await using var factory = new TestWebApplicationFactory();
var admin = factory.CreateClientWithCookies(); var admin = factory.CreateClientWithCookies();
await admin.RegisterAsync("admin", admin: true); await admin.RegisterAsync("admin", admin: true);
var player = factory.CreateClientWithCookies(); var player = factory.CreateClientWithCookies();
@@ -114,24 +148,41 @@ public class AdminTests
var b = await player.CreateSuggestionAsync("Game B"); var b = await player.CreateSuggestionAsync("Game B");
await player.PostAsJsonAsync("/api/me/phase/next", new { }); await player.PostAsJsonAsync("/api/me/phase/next", new { });
await admin.PostAsJsonAsync("/api/me/phase/next", new { }); await admin.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 = a,
TargetSuggestionId = b
});
await player.PostAsJsonAsync("/api/votes", new { SuggestionId = a, Score = 6 }); await player.PostAsJsonAsync("/api/votes", new
{
SuggestionId = a,
Score = 6
});
var resp = await admin.PostAsJsonAsync("/api/admin/unlink-suggestions", new { suggestionId = a }); var resp = await admin.PostAsJsonAsync("/api/admin/unlink-suggestions", new { suggestionId = a });
resp.EnsureSuccessStatusCode(); resp.EnsureSuccessStatusCode();
await factory.WithDbContextAsync(async db => await factory.WithDbContextAsync(db =>
{
try
{ {
Assert.Empty(db.Votes); Assert.Empty(db.Votes);
Assert.All(db.Suggestions, s => Assert.Null(s.ParentSuggestionId)); Assert.All(db.Suggestions, s => Assert.Null(s.ParentSuggestionId));
return Task.CompletedTask;
}
catch (Exception exception)
{
return Task.FromException(exception);
}
}); });
} }
[Fact] [Fact]
public async Task Reset_and_factory_reset_clear_state() public async Task Reset_and_factory_reset_clear_state()
{ {
using var factory = new TestWebApplicationFactory(); await using var factory = new TestWebApplicationFactory();
var admin = factory.CreateClientWithCookies(); var admin = factory.CreateClientWithCookies();
await admin.RegisterAsync("admin", admin: true); await admin.RegisterAsync("admin", admin: true);
var player = factory.CreateClientWithCookies(); var player = factory.CreateClientWithCookies();
@@ -141,27 +192,46 @@ public class AdminTests
var reset = await admin.PostAsJsonAsync("/api/admin/reset", new { }); var reset = await admin.PostAsJsonAsync("/api/admin/reset", new { });
reset.EnsureSuccessStatusCode(); reset.EnsureSuccessStatusCode();
await factory.WithDbContextAsync(async db => await factory.WithDbContextAsync(db =>
{
try
{ {
Assert.Empty(db.Suggestions); Assert.Empty(db.Suggestions);
Assert.Empty(db.Votes); Assert.Empty(db.Votes);
Assert.All(db.Players, p => Assert.Equal(Phase.Suggest, p.CurrentPhase)); Assert.All(db.Players, p => Assert.Equal(Phase.Suggest, p.CurrentPhase));
return Task.CompletedTask;
}
catch (Exception exception)
{
return Task.FromException(exception);
}
}); });
var factoryReset = await admin.PostAsJsonAsync("/api/admin/factory-reset", new { }); var factoryReset = await admin.PostAsJsonAsync("/api/admin/factory-reset", new { });
factoryReset.EnsureSuccessStatusCode(); factoryReset.EnsureSuccessStatusCode();
await factory.WithDbContextAsync(async db => await factory.WithDbContextAsync(db =>
{
try
{ {
Assert.Empty(db.Players); Assert.Empty(db.Players);
Assert.Single(db.AppState); Assert.Single(db.AppState);
return Task.CompletedTask;
}
catch (Exception exception)
{
return Task.FromException(exception);
}
}); });
} }
[Fact] [Fact]
public async Task Admin_results_closing_moves_back_to_vote_and_clears_finalize() public async Task Admin_results_closing_moves_back_to_vote_and_clears_finalize()
{ {
using var factory = new TestWebApplicationFactory(); await using var factory = new TestWebApplicationFactory();
var admin = factory.CreateClientWithCookies(); var admin = factory.CreateClientWithCookies();
await admin.RegisterAsync("admin", admin: true); await admin.RegisterAsync("admin", admin: true);
var player = factory.CreateClientWithCookies(); var player = factory.CreateClientWithCookies();
@@ -196,7 +266,7 @@ public class AdminTests
[Fact] [Fact]
public async Task Vote_status_lists_waiting_players() public async Task Vote_status_lists_waiting_players()
{ {
using var factory = new TestWebApplicationFactory(); await using var factory = new TestWebApplicationFactory();
var admin = factory.CreateClientWithCookies(); var admin = factory.CreateClientWithCookies();
await admin.RegisterAsync("admin", admin: true); await admin.RegisterAsync("admin", admin: true);
await admin.PostAsJsonAsync("/api/me/phase/next", new { }); await admin.PostAsJsonAsync("/api/me/phase/next", new { });
@@ -208,7 +278,11 @@ public class AdminTests
var s = await p1.CreateSuggestionAsync("Game"); var s = await p1.CreateSuggestionAsync("Game");
await p1.PostAsJsonAsync("/api/me/phase/next", new { }); await p1.PostAsJsonAsync("/api/me/phase/next", new { });
await p2.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", new
{
SuggestionId = s,
Score = 5
});
await p1.PostAsJsonAsync("/api/votes/finalize", new { Final = true }); await p1.PostAsJsonAsync("/api/votes/finalize", new { Final = true });
var status = await admin.GetFromJsonAsync<JsonElement>("/api/admin/vote-status"); var status = await admin.GetFromJsonAsync<JsonElement>("/api/admin/vote-status");
@@ -220,7 +294,7 @@ public class AdminTests
[Fact] [Fact]
public async Task Grant_joker_in_vote_sets_flag_and_unfinalizes() public async Task Grant_joker_in_vote_sets_flag_and_unfinalizes()
{ {
using var factory = new TestWebApplicationFactory(); await using var factory = new TestWebApplicationFactory();
var admin = factory.CreateClientWithCookies(); var admin = factory.CreateClientWithCookies();
await admin.RegisterAsync("admin", admin: true); await admin.RegisterAsync("admin", admin: true);
@@ -243,7 +317,7 @@ public class AdminTests
[Fact] [Fact]
public async Task Link_requires_vote_phase_and_reparents_votes_reset() public async Task Link_requires_vote_phase_and_reparents_votes_reset()
{ {
using var factory = new TestWebApplicationFactory(); await using var factory = new TestWebApplicationFactory();
var admin = factory.CreateClientWithCookies(); var admin = factory.CreateClientWithCookies();
await admin.RegisterAsync("admin", admin: true); await admin.RegisterAsync("admin", admin: true);
var player = factory.CreateClientWithCookies(); var player = factory.CreateClientWithCookies();
@@ -252,16 +326,28 @@ public class AdminTests
var a = await player.CreateSuggestionAsync("A"); var a = await player.CreateSuggestionAsync("A");
var b = await player.CreateSuggestionAsync("B"); var b = await player.CreateSuggestionAsync("B");
var beforeVotePhase = await admin.PostAsJsonAsync("/api/admin/link-suggestions", new { SourceSuggestionId = a, TargetSuggestionId = b }); var beforeVotePhase = await admin.PostAsJsonAsync("/api/admin/link-suggestions", new
{
SourceSuggestionId = a,
TargetSuggestionId = b
});
Assert.Equal(HttpStatusCode.BadRequest, beforeVotePhase.StatusCode); Assert.Equal(HttpStatusCode.BadRequest, beforeVotePhase.StatusCode);
await admin.PostAsJsonAsync("/api/me/phase/next", new { }); await admin.PostAsJsonAsync("/api/me/phase/next", new { });
await player.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", new
{
SuggestionId = a,
Score = 3
});
await player.PostAsJsonAsync("/api/votes/finalize", new { Final = true }); await player.PostAsJsonAsync("/api/votes/finalize", new { Final = true });
var link = await admin.PostAsJsonAsync("/api/admin/link-suggestions", new { SourceSuggestionId = a, TargetSuggestionId = b }); var link = await admin.PostAsJsonAsync("/api/admin/link-suggestions", new
{
SourceSuggestionId = a,
TargetSuggestionId = b
});
link.EnsureSuccessStatusCode(); link.EnsureSuccessStatusCode();
await factory.WithDbContextAsync(async db => await factory.WithDbContextAsync(async db =>
@@ -276,7 +362,7 @@ public class AdminTests
[Fact] [Fact]
public async Task Unlink_not_found_returns_empty_payload() public async Task Unlink_not_found_returns_empty_payload()
{ {
using var factory = new TestWebApplicationFactory(); await using var factory = new TestWebApplicationFactory();
var admin = factory.CreateClientWithCookies(); var admin = factory.CreateClientWithCookies();
await admin.RegisterAsync("admin", admin: true); await admin.RegisterAsync("admin", admin: true);
await admin.PostAsJsonAsync("/api/me/phase/next", new { }); await admin.PostAsJsonAsync("/api/me/phase/next", new { });
@@ -290,7 +376,7 @@ public class AdminTests
[Fact] [Fact]
public async Task Reset_clears_flags_and_factory_reset_seeds_defaults() public async Task Reset_clears_flags_and_factory_reset_seeds_defaults()
{ {
using var factory = new TestWebApplicationFactory(); await using var factory = new TestWebApplicationFactory();
var admin = factory.CreateClientWithCookies(); var admin = factory.CreateClientWithCookies();
await admin.RegisterAsync("admin", admin: true); await admin.RegisterAsync("admin", admin: true);
var p = factory.CreateClientWithCookies(); var p = factory.CreateClientWithCookies();

View File

@@ -1,7 +1,6 @@
using System.Net; using System.Net;
using System.Net.Http.Json; using System.Net.Http.Json;
using System.Text.Json; using System.Text.Json;
using GameList.Data;
using GameList.Infrastructure; using GameList.Infrastructure;
using GameList.Tests.Support; using GameList.Tests.Support;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -13,7 +12,7 @@ public class AuthTests
[Fact] [Fact]
public async Task Register_trims_limits_and_sets_cookie_and_normalized_username() public async Task Register_trims_limits_and_sets_cookie_and_normalized_username()
{ {
using var factory = new TestWebApplicationFactory(); await using var factory = new TestWebApplicationFactory();
var client = factory.CreateClientWithCookies(); var client = factory.CreateClientWithCookies();
var response = await client.PostAsJsonAsync("/api/auth/register", new var response = await client.PostAsJsonAsync("/api/auth/register", new
@@ -25,8 +24,7 @@ public class AuthTests
}); });
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
Assert.True(response.Headers.TryGetValues("Set-Cookie", out var cookies) && Assert.True(response.Headers.TryGetValues("Set-Cookie", out var cookies) && cookies.Any(c => c.Contains(PlayerIdentityExtensions.PlayerCookieName)));
cookies.Any(c => c.Contains(PlayerIdentityExtensions.PlayerCookieName)));
await factory.WithDbContextAsync(async db => await factory.WithDbContextAsync(async db =>
{ {
@@ -41,7 +39,7 @@ public class AuthTests
[Fact] [Fact]
public async Task Register_rejects_overlength_username_or_display_name() public async Task Register_rejects_overlength_username_or_display_name()
{ {
using var factory = new TestWebApplicationFactory(); await using var factory = new TestWebApplicationFactory();
var client = factory.CreateClientWithCookies(); var client = factory.CreateClientWithCookies();
var tooLongUser = new string('u', 25); var tooLongUser = new string('u', 25);
@@ -67,7 +65,7 @@ public class AuthTests
[Fact] [Fact]
public async Task Login_sets_last_login_and_fills_missing_display_name() public async Task Login_sets_last_login_and_fills_missing_display_name()
{ {
using var factory = new TestWebApplicationFactory(); await using var factory = new TestWebApplicationFactory();
var client = factory.CreateClientWithCookies(); var client = factory.CreateClientWithCookies();
await client.RegisterAsync("loginfill"); await client.RegisterAsync("loginfill");
@@ -93,7 +91,7 @@ public class AuthTests
[Fact] [Fact]
public async Task Register_with_admin_key_sets_admin_flag() public async Task Register_with_admin_key_sets_admin_flag()
{ {
using var factory = new TestWebApplicationFactory(); await using var factory = new TestWebApplicationFactory();
var client = factory.CreateClientWithCookies(); var client = factory.CreateClientWithCookies();
var response = await client.RegisterAsync("adminuser", admin: true); var response = await client.RegisterAsync("adminuser", admin: true);
@@ -106,7 +104,7 @@ public class AuthTests
[Fact] [Fact]
public async Task Register_duplicate_username_returns_conflict() public async Task Register_duplicate_username_returns_conflict()
{ {
using var factory = new TestWebApplicationFactory(); await using var factory = new TestWebApplicationFactory();
var client = factory.CreateClientWithCookies(); var client = factory.CreateClientWithCookies();
var first = await client.RegisterAsync("duplicate"); var first = await client.RegisterAsync("duplicate");
@@ -120,7 +118,7 @@ public class AuthTests
[Fact] [Fact]
public async Task Login_with_wrong_password_returns_unauthorized() public async Task Login_with_wrong_password_returns_unauthorized()
{ {
using var factory = new TestWebApplicationFactory(); await using var factory = new TestWebApplicationFactory();
var client = factory.CreateClientWithCookies(); var client = factory.CreateClientWithCookies();
await client.RegisterAsync("player1"); await client.RegisterAsync("player1");
@@ -133,20 +131,31 @@ public class AuthTests
[Fact] [Fact]
public async Task Register_validates_required_fields() public async Task Register_validates_required_fields()
{ {
using var factory = new TestWebApplicationFactory(); await using var factory = new TestWebApplicationFactory();
var client = factory.CreateClientWithCookies(); var client = factory.CreateClientWithCookies();
var missing = await client.PostAsJsonAsync("/api/auth/register", new { Username = "", Password = "", DisplayName = "" }); var missing = await client.PostAsJsonAsync("/api/auth/register", new
{
Username = "",
Password = "",
DisplayName = ""
});
Assert.Equal(HttpStatusCode.BadRequest, missing.StatusCode); Assert.Equal(HttpStatusCode.BadRequest, missing.StatusCode);
var badKey = await client.PostAsJsonAsync("/api/auth/register", new { Username = "u", Password = "p", DisplayName = "d", AdminKey = "wrong" }); var badKey = await client.PostAsJsonAsync("/api/auth/register", new
{
Username = "u",
Password = "p",
DisplayName = "d",
AdminKey = "wrong"
});
Assert.Equal(HttpStatusCode.BadRequest, badKey.StatusCode); Assert.Equal(HttpStatusCode.BadRequest, badKey.StatusCode);
} }
[Fact] [Fact]
public async Task Non_admin_cannot_access_admin_routes() public async Task Non_admin_cannot_access_admin_routes()
{ {
using var factory = new TestWebApplicationFactory(); await using var factory = new TestWebApplicationFactory();
var player = factory.CreateClientWithCookies(); var player = factory.CreateClientWithCookies();
await player.RegisterAsync("regular"); await player.RegisterAsync("regular");
@@ -157,7 +166,7 @@ public class AuthTests
[Fact] [Fact]
public async Task Admin_can_access_admin_routes() public async Task Admin_can_access_admin_routes()
{ {
using var factory = new TestWebApplicationFactory(); await using var factory = new TestWebApplicationFactory();
var admin = factory.CreateClientWithCookies(); var admin = factory.CreateClientWithCookies();
await admin.RegisterAsync("adminuser", admin: true); await admin.RegisterAsync("adminuser", admin: true);
@@ -168,7 +177,7 @@ public class AuthTests
[Fact] [Fact]
public async Task Logout_clears_cookie() public async Task Logout_clears_cookie()
{ {
using var factory = new TestWebApplicationFactory(); await using var factory = new TestWebApplicationFactory();
var client = factory.CreateClientWithCookies(); var client = factory.CreateClientWithCookies();
await client.RegisterAsync("logoutme"); await client.RegisterAsync("logoutme");

View File

@@ -1,13 +1,10 @@
using System.IO;
using System.Security.Claims; using System.Security.Claims;
using GameList.Data; using GameList.Data;
using GameList.Domain; using GameList.Domain;
using GameList.Infrastructure; using GameList.Infrastructure;
using GameList.Tests.Support; using GameList.Tests.Support;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace GameList.Tests; namespace GameList.Tests;
@@ -16,7 +13,7 @@ public class FiltersTests
[Fact] [Fact]
public async Task Admin_only_filter_blocks_non_admin() public async Task Admin_only_filter_blocks_non_admin()
{ {
using var factory = new TestWebApplicationFactory(); await using var factory = new TestWebApplicationFactory();
var client = factory.CreateClientWithCookies(); var client = factory.CreateClientWithCookies();
await client.RegisterAsync("user"); await client.RegisterAsync("user");
@@ -29,7 +26,7 @@ public class FiltersTests
[Fact] [Fact]
public async Task Phase_requirement_allows_admin_override_when_enabled() public async Task Phase_requirement_allows_admin_override_when_enabled()
{ {
using var factory = new TestWebApplicationFactory(); await using var factory = new TestWebApplicationFactory();
var ctx = await BuildContextAsync(factory, isAdmin: true, phase: Phase.Suggest); var ctx = await BuildContextAsync(factory, isAdmin: true, phase: Phase.Suggest);
var filter = new PhaseRequirementFilter(Phase.Vote, allowAdminOverride: true); var filter = new PhaseRequirementFilter(Phase.Vote, allowAdminOverride: true);
var called = false; var called = false;
@@ -46,7 +43,7 @@ public class FiltersTests
[Fact] [Fact]
public async Task Phase_or_joker_filter_blocks_without_joker_in_vote() public async Task Phase_or_joker_filter_blocks_without_joker_in_vote()
{ {
using var factory = new TestWebApplicationFactory(); await using var factory = new TestWebApplicationFactory();
var ctx = await BuildContextAsync(factory, isAdmin: false, phase: Phase.Vote, hasJoker: false); var ctx = await BuildContextAsync(factory, isAdmin: false, phase: Phase.Vote, hasJoker: false);
var filter = new PhaseOrJokerFilter(); var filter = new PhaseOrJokerFilter();
var result = await filter.InvokeAsync(ctx, _ => ValueTask.FromResult<object?>(Results.Ok())); var result = await filter.InvokeAsync(ctx, _ => ValueTask.FromResult<object?>(Results.Ok()));
@@ -62,8 +59,8 @@ public class FiltersTests
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
Username = $"user-{Guid.NewGuid():N}", Username = $"user-{Guid.NewGuid():N}",
NormalizedUsername = $"user-{Guid.NewGuid():N}", NormalizedUsername = $"user-{Guid.NewGuid():N}",
PasswordHash = new byte[] { 1 }, PasswordHash = [1],
PasswordSalt = new byte[] { 1 }, PasswordSalt = [1],
IsAdmin = isAdmin, IsAdmin = isAdmin,
CurrentPhase = phase, CurrentPhase = phase,
HasJoker = hasJoker, HasJoker = hasJoker,
@@ -75,28 +72,20 @@ public class FiltersTests
var ctx = new DefaultHttpContext var ctx = new DefaultHttpContext
{ {
RequestServices = scope.ServiceProvider, RequestServices = scope.ServiceProvider,
User = new ClaimsPrincipal(new ClaimsIdentity(new[] User = new ClaimsPrincipal(new ClaimsIdentity([
{
new Claim(ClaimTypes.NameIdentifier, player.Id.ToString()), new Claim(ClaimTypes.NameIdentifier, player.Id.ToString()),
new Claim(ClaimTypes.Name, player.Username), new Claim(ClaimTypes.Name, player.Username),
new Claim(PlayerIdentityExtensions.AdminClaim, isAdmin ? "true" : "false") new Claim(PlayerIdentityExtensions.AdminClaim, isAdmin ? "true" : "false")
}, "cookie")) ], "cookie"))
}; };
return new TestInvocationContext(ctx); return new TestInvocationContext(ctx);
} }
private class TestInvocationContext : EndpointFilterInvocationContext private class TestInvocationContext(DefaultHttpContext context) : EndpointFilterInvocationContext
{ {
private readonly DefaultHttpContext _context; public override HttpContext HttpContext => context;
public override object?[] Arguments => [];
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(); public override T GetArgument<T>(int index) => throw new NotImplementedException();
} }
@@ -104,11 +93,9 @@ public class FiltersTests
{ {
var http = new DefaultHttpContext var http = new DefaultHttpContext
{ {
RequestServices = new ServiceCollection() RequestServices = new ServiceCollection().AddLogging().BuildServiceProvider(),
.AddLogging() Response = { Body = new MemoryStream() }
.BuildServiceProvider()
}; };
http.Response.Body = new MemoryStream();
await ((IResult)result!).ExecuteAsync(http); await ((IResult)result!).ExecuteAsync(http);
Assert.Equal(statusCode, http.Response.StatusCode); Assert.Equal(statusCode, http.Response.StatusCode);
} }

View File

@@ -1,5 +1,4 @@
using System.Net; using System.Net;
using System.Net.Http;
using System.Net.Http.Headers; using System.Net.Http.Headers;
using System.Reflection; using System.Reflection;
using GameList.Infrastructure; using GameList.Infrastructure;
@@ -7,10 +6,7 @@ using GameList.Endpoints;
using GameList.Tests.Support; using GameList.Tests.Support;
using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.FileProviders;
using System.Linq;
using Microsoft.AspNetCore.Mvc.Testing;
using System.Text.Json; using System.Text.Json;
using System.Net.Http.Json; using System.Net.Http.Json;
@@ -36,9 +32,8 @@ public class HelperTests
File.WriteAllText(index, "<meta name=\"app-base\" content=\"\">"); File.WriteAllText(index, "<meta name=\"app-base\" content=\"\">");
var env = new FakeEnv { WebRootPath = webRoot }; var env = new FakeEnv { WebRootPath = webRoot };
var method = typeof(Program).GetMethods(BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public) var method = typeof(Program).GetMethods(BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public).First(m => m.Name.Contains("UpdateIndexMetaBase"));
.First(m => m.Name.Contains("UpdateIndexMetaBase")); method.Invoke(null, [env, "/pick"]);
method.Invoke(null, new object?[] { env, "/pick" });
var text = File.ReadAllText(index); var text = File.ReadAllText(index);
Assert.Contains("content=\"/pick\"", text); Assert.Contains("content=\"/pick\"", text);
@@ -53,9 +48,8 @@ public class HelperTests
File.WriteAllText(index, "<html></html>"); File.WriteAllText(index, "<html></html>");
var env = new FakeEnv { WebRootPath = webRoot }; var env = new FakeEnv { WebRootPath = webRoot };
var method = typeof(Program).GetMethods(BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public) var method = typeof(Program).GetMethods(BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public).First(m => m.Name.Contains("UpdateIndexMetaBase"));
.First(m => m.Name.Contains("UpdateIndexMetaBase")); method.Invoke(null, [env, "/pick"]);
method.Invoke(null, new object?[] { env, "/pick" });
Assert.Equal("<html></html>", File.ReadAllText(index)); Assert.Equal("<html></html>", File.ReadAllText(index));
} }
@@ -76,14 +70,15 @@ public class HelperTests
if (req.Method == HttpMethod.Head) if (req.Method == HttpMethod.Head)
{ {
var resp = new HttpResponseMessage(HttpStatusCode.OK); var resp = new HttpResponseMessage(HttpStatusCode.OK);
resp.Content = new ByteArrayContent(Array.Empty<byte>()); resp.Content = new ByteArrayContent([]);
resp.Content.Headers.ContentType = new MediaTypeHeaderValue("image/png"); resp.Content.Headers.ContentType = new MediaTypeHeaderValue("image/png");
resp.Content.Headers.ContentLength = 100; resp.Content.Headers.ContentLength = 100;
return resp; return resp;
} }
return new HttpResponseMessage(HttpStatusCode.OK) return new HttpResponseMessage(HttpStatusCode.OK)
{ {
Content = new ByteArrayContent(Array.Empty<byte>()) Content = new ByteArrayContent([])
{ {
Headers = Headers =
{ {
@@ -125,7 +120,7 @@ public class HelperTests
handler.SetResponder(_ => handler.SetResponder(_ =>
{ {
var resp = new HttpResponseMessage(HttpStatusCode.OK); var resp = new HttpResponseMessage(HttpStatusCode.OK);
resp.Content = new ByteArrayContent(System.Text.Encoding.UTF8.GetBytes("not image")); resp.Content = new ByteArrayContent("not image"u8.ToArray());
resp.Content.Headers.ContentType = new MediaTypeHeaderValue("text/plain"); resp.Content.Headers.ContentType = new MediaTypeHeaderValue("text/plain");
resp.Content.Headers.ContentLength = 9; resp.Content.Headers.ContentLength = 9;
return resp; return resp;
@@ -138,7 +133,7 @@ public class HelperTests
[Fact] [Fact]
public void Link_root_helpers_handle_groups() public void Link_root_helpers_handle_groups()
{ {
var roots = EndpointHelpers.BuildLinkRoots(new[] { (1, (int?)null), (2, 1), (3, (int?)null) }); var roots = EndpointHelpers.BuildLinkRoots([(1, null), (2, 1), (3, null)]);
Assert.Equal(1, roots[1]); Assert.Equal(1, roots[1]);
Assert.Equal(1, roots[2]); Assert.Equal(1, roots[2]);
Assert.Equal(3, roots[3]); Assert.Equal(3, roots[3]);
@@ -174,16 +169,13 @@ public class HelperTests
[Fact] [Fact]
public async Task Global_exception_handler_returns_json_error() public async Task Global_exception_handler_returns_json_error()
{ {
using var factory = new TestWebApplicationFactory().WithWebHostBuilder(builder => await using var factory = new TestWebApplicationFactory().WithWebHostBuilder(builder =>
{ {
builder.Configure(app => builder.Configure(app =>
{ {
app.UseGlobalExceptionLogging(); app.UseGlobalExceptionLogging();
app.UseRouting(); app.UseRouting();
app.UseEndpoints(endpoints => app.UseEndpoints(endpoints => { endpoints.MapGet("/boom", _ => throw new InvalidOperationException("boom")); });
{
endpoints.MapGet("/boom", _ => throw new InvalidOperationException("boom"));
});
}); });
}); });

View File

@@ -1,11 +1,9 @@
using System.Security.Claims;
using GameList.Domain; using GameList.Domain;
using GameList.Infrastructure; using GameList.Infrastructure;
using GameList.Tests.Support; using GameList.Tests.Support;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace GameList.Tests; namespace GameList.Tests;
@@ -14,16 +12,16 @@ public class IdentityTests
[Fact] [Fact]
public async Task Sign_in_sets_claims_and_cookie() public async Task Sign_in_sets_claims_and_cookie()
{ {
using var factory = new TestWebApplicationFactory(); await using var factory = new TestWebApplicationFactory();
var ctx = BuildAuthContext(factory.Services); var ctx = BuildAuthContext();
var player = new Player var player = new Player
{ {
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
Username = "claimuser", Username = "claimuser",
NormalizedUsername = "claimuser", NormalizedUsername = "claimuser",
PasswordHash = new byte[] { 1 }, PasswordHash = [1],
PasswordSalt = new byte[] { 1 }, PasswordSalt = [1],
DisplayName = "Claim", DisplayName = "Claim",
IsAdmin = true IsAdmin = true
}; };
@@ -31,15 +29,14 @@ public class IdentityTests
await PlayerIdentityExtensions.SignInPlayerAsync(ctx, player); await PlayerIdentityExtensions.SignInPlayerAsync(ctx, player);
var cookies = ctx.Response.Headers["Set-Cookie"]; var cookies = ctx.Response.Headers["Set-Cookie"];
Assert.NotNull(cookies); Assert.Contains(cookies, v => v != null && v.Contains(PlayerIdentityExtensions.PlayerCookieName));
Assert.Contains(cookies!, v => v.Contains(PlayerIdentityExtensions.PlayerCookieName));
} }
[Fact] [Fact]
public async Task Sign_out_clears_principal() public async Task Sign_out_clears_principal()
{ {
using var factory = new TestWebApplicationFactory(); await using var factory = new TestWebApplicationFactory();
var ctx = BuildAuthContext(factory.Services); var ctx = BuildAuthContext();
var player = new Player(); var player = new Player();
await PlayerIdentityExtensions.SignInPlayerAsync(ctx, player); await PlayerIdentityExtensions.SignInPlayerAsync(ctx, player);
@@ -48,15 +45,11 @@ public class IdentityTests
Assert.False(ctx.User.Identity?.IsAuthenticated ?? false); Assert.False(ctx.User.Identity?.IsAuthenticated ?? false);
} }
private static DefaultHttpContext BuildAuthContext(IServiceProvider services) private static DefaultHttpContext BuildAuthContext()
{ {
var serviceCollection = new ServiceCollection(); var serviceCollection = new ServiceCollection();
serviceCollection.AddSingleton<IHttpContextAccessor, HttpContextAccessor>(); serviceCollection.AddSingleton<IHttpContextAccessor, HttpContextAccessor>();
serviceCollection.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) serviceCollection.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie(options => { options.Cookie.Name = PlayerIdentityExtensions.PlayerCookieName; });
.AddCookie(options =>
{
options.Cookie.Name = PlayerIdentityExtensions.PlayerCookieName;
});
serviceCollection.AddLogging(); serviceCollection.AddLogging();
var provider = serviceCollection.BuildServiceProvider(); var provider = serviceCollection.BuildServiceProvider();

View File

@@ -8,7 +8,7 @@ public class MiddlewareTests
[Fact] [Fact]
public async Task Deleted_player_cookie_is_signed_out() public async Task Deleted_player_cookie_is_signed_out()
{ {
using var factory = new TestWebApplicationFactory(); await using var factory = new TestWebApplicationFactory();
var client = factory.CreateClientWithCookies(); var client = factory.CreateClientWithCookies();
await client.RegisterAsync("ghost"); await client.RegisterAsync("ghost");
@@ -29,7 +29,7 @@ public class MiddlewareTests
[Fact] [Fact]
public async Task Existing_player_passes_through_middleware() public async Task Existing_player_passes_through_middleware()
{ {
using var factory = new TestWebApplicationFactory(); await using var factory = new TestWebApplicationFactory();
var client = factory.CreateClientWithCookies(); var client = factory.CreateClientWithCookies();
await client.RegisterAsync("live"); await client.RegisterAsync("live");

View File

@@ -9,7 +9,7 @@ public class ResultsTests
[Fact] [Fact]
public async Task Results_available_after_admin_unlocks() public async Task Results_available_after_admin_unlocks()
{ {
using var factory = new TestWebApplicationFactory(); await using var factory = new TestWebApplicationFactory();
var admin = factory.CreateClientWithCookies(); var admin = factory.CreateClientWithCookies();
await admin.RegisterAsync("admin", admin: true); await admin.RegisterAsync("admin", admin: true);
@@ -18,7 +18,11 @@ public class ResultsTests
var suggestionId = await player.CreateSuggestionAsync("ResultGame"); var suggestionId = await player.CreateSuggestionAsync("ResultGame");
await player.PostAsJsonAsync("/api/me/phase/next", new { }); await player.PostAsJsonAsync("/api/me/phase/next", new { });
await player.PostAsJsonAsync("/api/votes", new { SuggestionId = suggestionId, Score = 8 }); await player.PostAsJsonAsync("/api/votes", new
{
SuggestionId = suggestionId,
Score = 8
});
await player.PostAsJsonAsync("/api/votes/finalize", new { Final = true }); await player.PostAsJsonAsync("/api/votes/finalize", new { Final = true });
await admin.PostAsJsonAsync("/api/admin/results", new { resultsOpen = true }); await admin.PostAsJsonAsync("/api/admin/results", new { resultsOpen = true });
@@ -36,7 +40,7 @@ public class ResultsTests
[Fact] [Fact]
public async Task Results_locked_returns_error() public async Task Results_locked_returns_error()
{ {
using var factory = new TestWebApplicationFactory(); await using var factory = new TestWebApplicationFactory();
var client = factory.CreateClientWithCookies(); var client = factory.CreateClientWithCookies();
await client.RegisterAsync("user"); await client.RegisterAsync("user");
await client.PostAsJsonAsync("/api/me/phase/next", new { }); await client.PostAsJsonAsync("/api/me/phase/next", new { });
@@ -47,7 +51,7 @@ public class ResultsTests
[Fact] [Fact]
public async Task Results_require_results_phase_and_auth() public async Task Results_require_results_phase_and_auth()
{ {
using var factory = new TestWebApplicationFactory(); await using var factory = new TestWebApplicationFactory();
var admin = factory.CreateClientWithCookies(); var admin = factory.CreateClientWithCookies();
await admin.RegisterAsync("admin", admin: true); await admin.RegisterAsync("admin", admin: true);
var player = factory.CreateClientWithCookies(); var player = factory.CreateClientWithCookies();
@@ -65,17 +69,21 @@ public class ResultsTests
[Fact] [Fact]
public async Task Results_payload_contains_fields_and_ordering() public async Task Results_payload_contains_fields_and_ordering()
{ {
using var factory = new TestWebApplicationFactory(); await using var factory = new TestWebApplicationFactory();
var admin = factory.CreateClientWithCookies(); var admin = factory.CreateClientWithCookies();
await admin.RegisterAsync("admin", admin: true); await admin.RegisterAsync("admin", admin: true);
var player = factory.CreateClientWithCookies(); var player = factory.CreateClientWithCookies();
await player.RegisterAsync("player"); await player.RegisterAsync("player");
var s1 = await player.CreateSuggestionAsync("High"); var s1 = await player.CreateSuggestionAsync("High");
var s2 = await player.CreateSuggestionAsync("NoVotes"); _ = await player.CreateSuggestionAsync("NoVotes");
await player.PostAsJsonAsync("/api/me/phase/next", new { }); await player.PostAsJsonAsync("/api/me/phase/next", new { });
await player.PostAsJsonAsync("/api/votes", new { SuggestionId = s1, Score = 9 }); await player.PostAsJsonAsync("/api/votes", new
{
SuggestionId = s1,
Score = 9
});
await player.PostAsJsonAsync("/api/votes/finalize", new { Final = true }); await player.PostAsJsonAsync("/api/votes/finalize", new { Final = true });
await admin.PostAsJsonAsync("/api/admin/results", new { resultsOpen = true }); await admin.PostAsJsonAsync("/api/admin/results", new { resultsOpen = true });
@@ -83,7 +91,7 @@ public class ResultsTests
var results = await player.GetFromJsonAsync<List<JsonElement>>("/api/results"); var results = await player.GetFromJsonAsync<List<JsonElement>>("/api/results");
Assert.NotNull(results); Assert.NotNull(results);
Assert.Equal(2, results!.Count); Assert.Equal(2, results.Count);
Assert.Equal("High", results[0].GetProperty("name").GetString()); Assert.Equal("High", results[0].GetProperty("name").GetString());
Assert.Equal(9, (int)results[0].GetProperty("average").GetDouble()); Assert.Equal(9, (int)results[0].GetProperty("average").GetDouble());
Assert.Equal(1, results[0].GetProperty("count").GetInt32()); Assert.Equal(1, results[0].GetProperty("count").GetInt32());

View File

@@ -14,7 +14,7 @@ public class StateTests
[Fact] [Fact]
public async Task State_endpoint_returns_expected_payload_for_authenticated_user() public async Task State_endpoint_returns_expected_payload_for_authenticated_user()
{ {
using var factory = new TestWebApplicationFactory(); await using var factory = new TestWebApplicationFactory();
var client = factory.CreateClientWithCookies(); var client = factory.CreateClientWithCookies();
await client.RegisterAsync("payload"); await client.RegisterAsync("payload");
await factory.WithDbContextAsync(async db => await factory.WithDbContextAsync(async db =>
@@ -27,7 +27,7 @@ public class StateTests
var state = await client.GetFromJsonAsync<JsonElement>("/api/state"); var state = await client.GetFromJsonAsync<JsonElement>("/api/state");
Assert.Equal(Phase.Suggest.ToString(), state.GetProperty("currentPhase").GetString()); Assert.Equal(nameof(Phase.Suggest), state.GetProperty("currentPhase").GetString());
Assert.False(state.GetProperty("votesFinal").GetBoolean()); Assert.False(state.GetProperty("votesFinal").GetBoolean());
Assert.True(state.GetProperty("hasJoker").GetBoolean()); Assert.True(state.GetProperty("hasJoker").GetBoolean());
Assert.True(state.GetProperty("players").GetInt32() >= 1); Assert.True(state.GetProperty("players").GetInt32() >= 1);
@@ -38,7 +38,7 @@ public class StateTests
[Fact] [Fact]
public async Task GetPhase_upgrades_reveal_and_resets_when_results_close() public async Task GetPhase_upgrades_reveal_and_resets_when_results_close()
{ {
using var factory = new TestWebApplicationFactory(); await using var factory = new TestWebApplicationFactory();
Guid playerId = Guid.Empty; Guid playerId = Guid.Empty;
await factory.WithDbContextAsync(async db => await factory.WithDbContextAsync(async db =>
{ {
@@ -47,8 +47,8 @@ public class StateTests
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
Username = "legacy", Username = "legacy",
NormalizedUsername = "legacy", NormalizedUsername = "legacy",
PasswordHash = new byte[] { 1 }, PasswordHash = [1],
PasswordSalt = new byte[] { 1 }, PasswordSalt = [1],
DisplayName = "Legacy", DisplayName = "Legacy",
CurrentPhase = Phase.Reveal, CurrentPhase = Phase.Reveal,
VotesFinal = true VotesFinal = true
@@ -63,7 +63,7 @@ public class StateTests
using (var scope = factory.Services.CreateScope()) using (var scope = factory.Services.CreateScope())
{ {
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>(); var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var phase = await GameList.Endpoints.EndpointHelpers.GetPhase(db, playerId); var phase = await Endpoints.EndpointHelpers.GetPhase(db, playerId);
Assert.Equal(Phase.Results, phase); Assert.Equal(Phase.Results, phase);
} }
@@ -77,7 +77,7 @@ public class StateTests
using (var scope = factory.Services.CreateScope()) using (var scope = factory.Services.CreateScope())
{ {
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>(); var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var phase = await GameList.Endpoints.EndpointHelpers.GetPhase(db, playerId); var phase = await Endpoints.EndpointHelpers.GetPhase(db, playerId);
var player = await db.Players.FindAsync(playerId); var player = await db.Players.FindAsync(playerId);
Assert.Equal(Phase.Vote, phase); Assert.Equal(Phase.Vote, phase);
Assert.False(player!.VotesFinal); Assert.False(player!.VotesFinal);
@@ -87,7 +87,7 @@ public class StateTests
[Fact] [Fact]
public async Task Phase_next_advances_and_clears_votesfinal() public async Task Phase_next_advances_and_clears_votesfinal()
{ {
using var factory = new TestWebApplicationFactory(); await using var factory = new TestWebApplicationFactory();
var client = factory.CreateClientWithCookies(); var client = factory.CreateClientWithCookies();
await client.RegisterAsync("advance"); await client.RegisterAsync("advance");
@@ -111,13 +111,13 @@ public class StateTests
toResults.EnsureSuccessStatusCode(); toResults.EnsureSuccessStatusCode();
var me = await client.GetFromJsonAsync<JsonElement>("/api/me"); var me = await client.GetFromJsonAsync<JsonElement>("/api/me");
Assert.False(me.GetProperty("votesFinal").GetBoolean()); Assert.False(me.GetProperty("votesFinal").GetBoolean());
Assert.Equal(Phase.Results.ToString(), me.GetProperty("currentPhase").GetString()); Assert.Equal(nameof(Phase.Results), me.GetProperty("currentPhase").GetString());
} }
[Fact] [Fact]
public async Task Phase_prev_moves_back_and_clears_votesfinal() public async Task Phase_prev_moves_back_and_clears_votesfinal()
{ {
using var factory = new TestWebApplicationFactory(); await using var factory = new TestWebApplicationFactory();
var admin = factory.CreateClientWithCookies(); var admin = factory.CreateClientWithCookies();
await admin.RegisterAsync("admin", admin: true); await admin.RegisterAsync("admin", admin: true);
@@ -132,14 +132,14 @@ public class StateTests
backToSuggest.EnsureSuccessStatusCode(); backToSuggest.EnsureSuccessStatusCode();
var me = await admin.GetFromJsonAsync<JsonElement>("/api/me"); var me = await admin.GetFromJsonAsync<JsonElement>("/api/me");
Assert.Equal(Phase.Suggest.ToString(), me.GetProperty("currentPhase").GetString()); Assert.Equal(nameof(Phase.Suggest), me.GetProperty("currentPhase").GetString());
Assert.False(me.GetProperty("votesFinal").GetBoolean()); Assert.False(me.GetProperty("votesFinal").GetBoolean());
} }
[Fact] [Fact]
public async Task Name_endpoint_rejects_over_16_chars() public async Task Name_endpoint_rejects_over_16_chars()
{ {
using var factory = new TestWebApplicationFactory(); await using var factory = new TestWebApplicationFactory();
var client = factory.CreateClientWithCookies(); var client = factory.CreateClientWithCookies();
await client.RegisterAsync("namelimit"); await client.RegisterAsync("namelimit");
@@ -150,7 +150,7 @@ public class StateTests
[Fact] [Fact]
public async Task Cannot_advance_to_results_when_locked() public async Task Cannot_advance_to_results_when_locked()
{ {
using var factory = new TestWebApplicationFactory(); await using var factory = new TestWebApplicationFactory();
var client = factory.CreateClientWithCookies(); var client = factory.CreateClientWithCookies();
await client.RegisterAsync("player"); await client.RegisterAsync("player");
@@ -165,7 +165,7 @@ public class StateTests
[Fact] [Fact]
public async Task Admin_opening_results_moves_players_to_results_phase() public async Task Admin_opening_results_moves_players_to_results_phase()
{ {
using var factory = new TestWebApplicationFactory(); await using var factory = new TestWebApplicationFactory();
var admin = factory.CreateClientWithCookies(); var admin = factory.CreateClientWithCookies();
await admin.RegisterAsync("admin", admin: true); await admin.RegisterAsync("admin", admin: true);
@@ -177,14 +177,14 @@ public class StateTests
var state = await player.GetFromJsonAsync<JsonElement>("/api/state"); var state = await player.GetFromJsonAsync<JsonElement>("/api/state");
Assert.Equal(Phase.Results.ToString(), state.GetProperty("currentPhase").GetString()); Assert.Equal(nameof(Phase.Results), state.GetProperty("currentPhase").GetString());
Assert.True(state.GetProperty("resultsOpen").GetBoolean()); Assert.True(state.GetProperty("resultsOpen").GetBoolean());
} }
[Fact] [Fact]
public async Task Name_endpoint_trims_and_rejects_blank() public async Task Name_endpoint_trims_and_rejects_blank()
{ {
using var factory = new TestWebApplicationFactory(); await using var factory = new TestWebApplicationFactory();
var client = factory.CreateClientWithCookies(); var client = factory.CreateClientWithCookies();
await client.RegisterAsync("nametest"); await client.RegisterAsync("nametest");
@@ -200,7 +200,7 @@ public class StateTests
[Fact] [Fact]
public async Task Phase_prev_admin_only() public async Task Phase_prev_admin_only()
{ {
using var factory = new TestWebApplicationFactory(); await using var factory = new TestWebApplicationFactory();
var player = factory.CreateClientWithCookies(); var player = factory.CreateClientWithCookies();
await player.RegisterAsync("phase"); await player.RegisterAsync("phase");
@@ -213,13 +213,13 @@ public class StateTests
var back = await admin.PostAsJsonAsync("/api/me/phase/prev", new { }); var back = await admin.PostAsJsonAsync("/api/me/phase/prev", new { });
back.EnsureSuccessStatusCode(); back.EnsureSuccessStatusCode();
var me = await admin.GetFromJsonAsync<JsonElement>("/api/me"); var me = await admin.GetFromJsonAsync<JsonElement>("/api/me");
Assert.Equal(Phase.Suggest.ToString(), me.GetProperty("currentPhase").GetString()); Assert.Equal(nameof(Phase.Suggest), me.GetProperty("currentPhase").GetString());
} }
[Fact] [Fact]
public async Task State_endpoint_requires_auth_and_counts() public async Task State_endpoint_requires_auth_and_counts()
{ {
using var factory = new TestWebApplicationFactory(); await using var factory = new TestWebApplicationFactory();
var anon = factory.CreateClient(); var anon = factory.CreateClient();
var unauthorized = await anon.GetAsync("/api/state"); var unauthorized = await anon.GetAsync("/api/state");
Assert.NotEqual(HttpStatusCode.OK, unauthorized.StatusCode); Assert.NotEqual(HttpStatusCode.OK, unauthorized.StatusCode);
@@ -237,7 +237,7 @@ public class StateTests
[Fact] [Fact]
public async Task Health_endpoint_ok() public async Task Health_endpoint_ok()
{ {
using var factory = new TestWebApplicationFactory(); await using var factory = new TestWebApplicationFactory();
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());
} }
@@ -245,7 +245,7 @@ public class StateTests
[Fact] [Fact]
public async Task GetPhase_aligns_to_results_when_open() public async Task GetPhase_aligns_to_results_when_open()
{ {
using var factory = new TestWebApplicationFactory(); await using var factory = new TestWebApplicationFactory();
await factory.WithDbContextAsync(async db => await factory.WithDbContextAsync(async db =>
{ {
var player = new Player var player = new Player
@@ -253,8 +253,8 @@ public class StateTests
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
Username = "phase", Username = "phase",
NormalizedUsername = "phase", NormalizedUsername = "phase",
PasswordHash = new byte[] { 1 }, PasswordHash = [1],
PasswordSalt = new byte[] { 1 }, PasswordSalt = [1],
DisplayName = "phase", DisplayName = "phase",
CurrentPhase = Phase.Vote CurrentPhase = Phase.Vote
}; };
@@ -267,7 +267,7 @@ public class StateTests
using var scope = factory.Services.CreateScope(); using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>(); var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var playerId = await db.Players.Select(p => p.Id).FirstAsync(); var playerId = await db.Players.Select(p => p.Id).FirstAsync();
var phase = await GameList.Endpoints.EndpointHelpers.GetPhase(db, playerId); var phase = await Endpoints.EndpointHelpers.GetPhase(db, playerId);
Assert.Equal(Phase.Results, phase); Assert.Equal(Phase.Results, phase);
} }

View File

@@ -2,7 +2,6 @@ using System.Net;
using System.Net.Http.Json; using System.Net.Http.Json;
using System.Text.Json; using System.Text.Json;
using GameList.Tests.Support; using GameList.Tests.Support;
using GameList.Domain;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace GameList.Tests; namespace GameList.Tests;
@@ -12,7 +11,7 @@ public class SuggestionTests
[Fact] [Fact]
public async Task Player_cannot_exceed_five_suggestions() public async Task Player_cannot_exceed_five_suggestions()
{ {
using var factory = new TestWebApplicationFactory(); await using var factory = new TestWebApplicationFactory();
var client = factory.CreateClientWithCookies(); var client = factory.CreateClientWithCookies();
await client.RegisterAsync("suggestor"); await client.RegisterAsync("suggestor");
@@ -50,7 +49,7 @@ public class SuggestionTests
[Fact] [Fact]
public async Task Rejects_invalid_image_extension_and_player_counts() public async Task Rejects_invalid_image_extension_and_player_counts()
{ {
using var factory = new TestWebApplicationFactory(); await using var factory = new TestWebApplicationFactory();
var client = factory.CreateClientWithCookies(); var client = factory.CreateClientWithCookies();
await client.RegisterAsync("validate"); await client.RegisterAsync("validate");
@@ -84,7 +83,7 @@ public class SuggestionTests
[Fact] [Fact]
public async Task Joker_allows_single_extra_suggestion_and_unfinalizes_votes() public async Task Joker_allows_single_extra_suggestion_and_unfinalizes_votes()
{ {
using var factory = new TestWebApplicationFactory(); await using var factory = new TestWebApplicationFactory();
var player = factory.CreateClientWithCookies(); var player = factory.CreateClientWithCookies();
await player.RegisterAsync("joker"); await player.RegisterAsync("joker");
var other = factory.CreateClientWithCookies(); var other = factory.CreateClientWithCookies();
@@ -126,7 +125,7 @@ public class SuggestionTests
[Fact] [Fact]
public async Task Admin_can_update_during_vote_phase() public async Task Admin_can_update_during_vote_phase()
{ {
using var factory = new TestWebApplicationFactory(); await using var factory = new TestWebApplicationFactory();
var admin = factory.CreateClientWithCookies(); var admin = factory.CreateClientWithCookies();
await admin.RegisterAsync("admin", admin: true); await admin.RegisterAsync("admin", admin: true);
@@ -152,7 +151,7 @@ public class SuggestionTests
[Fact] [Fact]
public async Task Phase_gate_blocks_player_update_in_vote_phase() public async Task Phase_gate_blocks_player_update_in_vote_phase()
{ {
using var factory = new TestWebApplicationFactory(); await using var factory = new TestWebApplicationFactory();
var player = factory.CreateClientWithCookies(); var player = factory.CreateClientWithCookies();
await player.RegisterAsync("phase"); await player.RegisterAsync("phase");
var id = await player.CreateSuggestionAsync("Lock"); var id = await player.CreateSuggestionAsync("Lock");
@@ -180,7 +179,7 @@ public class SuggestionTests
[Fact] [Fact]
public async Task Player_cannot_edit_suggestion_in_results_phase() public async Task Player_cannot_edit_suggestion_in_results_phase()
{ {
using var factory = new TestWebApplicationFactory(); await using var factory = new TestWebApplicationFactory();
var player = factory.CreateClientWithCookies(); var player = factory.CreateClientWithCookies();
await player.RegisterAsync("results"); await player.RegisterAsync("results");
var id = await player.CreateSuggestionAsync("Frozen"); var id = await player.CreateSuggestionAsync("Frozen");
@@ -217,7 +216,7 @@ public class SuggestionTests
[Fact] [Fact]
public async Task Player_cannot_edit_other_players_suggestion() public async Task Player_cannot_edit_other_players_suggestion()
{ {
using var factory = new TestWebApplicationFactory(); await using var factory = new TestWebApplicationFactory();
var owner = factory.CreateClientWithCookies(); var owner = factory.CreateClientWithCookies();
await owner.RegisterAsync("owner"); await owner.RegisterAsync("owner");
var other = factory.CreateClientWithCookies(); var other = factory.CreateClientWithCookies();
@@ -243,7 +242,7 @@ public class SuggestionTests
[Fact] [Fact]
public async Task Joker_allows_unlimited_extra_suggestions_when_granted_multiple_times() public async Task Joker_allows_unlimited_extra_suggestions_when_granted_multiple_times()
{ {
using var factory = new TestWebApplicationFactory(); await using var factory = new TestWebApplicationFactory();
var client = factory.CreateClientWithCookies(); var client = factory.CreateClientWithCookies();
await client.RegisterAsync("sixth"); await client.RegisterAsync("sixth");
@@ -327,7 +326,7 @@ public class SuggestionTests
[Fact] [Fact]
public async Task Unreachable_screenshot_url_is_rejected() public async Task Unreachable_screenshot_url_is_rejected()
{ {
using var factory = new TestWebApplicationFactory(); await using var factory = new TestWebApplicationFactory();
factory.HttpHandler.SetResponder(_ => new HttpResponseMessage(HttpStatusCode.BadRequest)); factory.HttpHandler.SetResponder(_ => new HttpResponseMessage(HttpStatusCode.BadRequest));
var client = factory.CreateClientWithCookies(); var client = factory.CreateClientWithCookies();
@@ -351,7 +350,7 @@ public class SuggestionTests
[Fact] [Fact]
public async Task Get_all_requires_vote_phase() public async Task Get_all_requires_vote_phase()
{ {
using var factory = new TestWebApplicationFactory(); await using var factory = new TestWebApplicationFactory();
var client = factory.CreateClientWithCookies(); var client = factory.CreateClientWithCookies();
await client.RegisterAsync("viewer"); await client.RegisterAsync("viewer");
@@ -362,13 +361,33 @@ public class SuggestionTests
[Fact] [Fact]
public async Task Mine_returns_ordered_list() public async Task Mine_returns_ordered_list()
{ {
using var factory = new TestWebApplicationFactory(); await using var factory = new TestWebApplicationFactory();
var client = factory.CreateClientWithCookies(); var client = factory.CreateClientWithCookies();
await client.RegisterAsync("mine"); await client.RegisterAsync("mine");
await client.PostAsJsonAsync("/api/suggestions", new { Name = "Second", Genre = (string?)null, Description = (string?)null, ScreenshotUrl = (string?)null, YoutubeUrl = (string?)null, GameUrl = (string?)null, MinPlayers = (int?)null, MaxPlayers = (int?)null }); await client.PostAsJsonAsync("/api/suggestions", new
{
Name = "Second",
Genre = (string?)null,
Description = (string?)null,
ScreenshotUrl = (string?)null,
YoutubeUrl = (string?)null,
GameUrl = (string?)null,
MinPlayers = (int?)null,
MaxPlayers = (int?)null
});
await Task.Delay(10); await Task.Delay(10);
await client.PostAsJsonAsync("/api/suggestions", new { Name = "Third", Genre = (string?)null, Description = (string?)null, ScreenshotUrl = (string?)null, YoutubeUrl = (string?)null, GameUrl = (string?)null, MinPlayers = (int?)null, MaxPlayers = (int?)null }); await client.PostAsJsonAsync("/api/suggestions", new
{
Name = "Third",
Genre = (string?)null,
Description = (string?)null,
ScreenshotUrl = (string?)null,
YoutubeUrl = (string?)null,
GameUrl = (string?)null,
MinPlayers = (int?)null,
MaxPlayers = (int?)null
});
var mine = await client.GetFromJsonAsync<List<JsonElement>>("/api/suggestions/mine"); var mine = await client.GetFromJsonAsync<List<JsonElement>>("/api/suggestions/mine");
Assert.Equal("Second", mine![0].GetProperty("name").GetString()); Assert.Equal("Second", mine![0].GetProperty("name").GetString());
@@ -377,7 +396,7 @@ public class SuggestionTests
[Fact] [Fact]
public async Task Create_requires_suggest_phase_and_display_name() public async Task Create_requires_suggest_phase_and_display_name()
{ {
using var factory = new TestWebApplicationFactory(); await using var factory = new TestWebApplicationFactory();
var client = factory.CreateClientWithCookies(); var client = factory.CreateClientWithCookies();
await client.RegisterAsync("phasegate"); await client.RegisterAsync("phasegate");
@@ -389,7 +408,17 @@ public class SuggestionTests
await db.SaveChangesAsync(); 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 }); 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); Assert.Equal(HttpStatusCode.BadRequest, badPhase.StatusCode);
await factory.WithDbContextAsync(async db => await factory.WithDbContextAsync(async db =>
@@ -399,37 +428,97 @@ public class SuggestionTests
await db.SaveChangesAsync(); 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 }); 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); Assert.Equal(HttpStatusCode.BadRequest, noDisplay.StatusCode);
} }
[Fact] [Fact]
public async Task Rejects_invalid_urls_name_length_and_player_counts() public async Task Rejects_invalid_urls_name_length_and_player_counts()
{ {
using var factory = new TestWebApplicationFactory(); await using var factory = new TestWebApplicationFactory();
var client = factory.CreateClientWithCookies(); var client = factory.CreateClientWithCookies();
await client.RegisterAsync("validate2"); 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 }); 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); 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 }); 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); 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 }); 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); 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 }); 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); 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 }); 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); Assert.Equal(HttpStatusCode.BadRequest, maxTooHigh.StatusCode);
} }
[Fact] [Fact]
public async Task Trims_and_truncates_optional_fields() public async Task Trims_and_truncates_optional_fields()
{ {
using var factory = new TestWebApplicationFactory(); await using var factory = new TestWebApplicationFactory();
var client = factory.CreateClientWithCookies(); var client = factory.CreateClientWithCookies();
await client.RegisterAsync("trim"); await client.RegisterAsync("trim");
@@ -460,7 +549,7 @@ public class SuggestionTests
[Fact] [Fact]
public async Task Mine_excludes_other_players() public async Task Mine_excludes_other_players()
{ {
using var factory = new TestWebApplicationFactory(); await using var factory = new TestWebApplicationFactory();
var a = factory.CreateClientWithCookies(); var a = factory.CreateClientWithCookies();
await a.RegisterAsync("alice"); await a.RegisterAsync("alice");
var b = factory.CreateClientWithCookies(); var b = factory.CreateClientWithCookies();
@@ -470,14 +559,15 @@ public class SuggestionTests
await b.CreateSuggestionAsync("BobGame"); await b.CreateSuggestionAsync("BobGame");
var mine = await a.GetFromJsonAsync<List<JsonElement>>("/api/suggestions/mine"); var mine = await a.GetFromJsonAsync<List<JsonElement>>("/api/suggestions/mine");
Assert.Single(mine!); Assert.NotNull(mine);
Assert.Single(mine);
Assert.Equal("AliceGame", mine[0].GetProperty("name").GetString()); Assert.Equal("AliceGame", mine[0].GetProperty("name").GetString());
} }
[Fact] [Fact]
public async Task All_returns_link_metadata_and_ordering() public async Task All_returns_link_metadata_and_ordering()
{ {
using var factory = new TestWebApplicationFactory(); await using var factory = new TestWebApplicationFactory();
var client = factory.CreateClientWithCookies(); var client = factory.CreateClientWithCookies();
await client.RegisterAsync("owner"); await client.RegisterAsync("owner");
@@ -507,7 +597,7 @@ public class SuggestionTests
[Fact] [Fact]
public async Task Delete_respects_phase_and_clears_links_and_votes() public async Task Delete_respects_phase_and_clears_links_and_votes()
{ {
using var factory = new TestWebApplicationFactory(); await using var factory = new TestWebApplicationFactory();
var owner = factory.CreateClientWithCookies(); var owner = factory.CreateClientWithCookies();
await owner.RegisterAsync("deleter"); await owner.RegisterAsync("deleter");
var other = factory.CreateClientWithCookies(); var other = factory.CreateClientWithCookies();
@@ -524,7 +614,11 @@ public class SuggestionTests
await owner.PostAsJsonAsync("/api/me/phase/next", new { }); // Vote await owner.PostAsJsonAsync("/api/me/phase/next", new { }); // Vote
await other.PostAsJsonAsync("/api/me/phase/next", new { }); await other.PostAsJsonAsync("/api/me/phase/next", new { });
await other.PostAsJsonAsync("/api/votes", new { SuggestionId = id, Score = 5 }); await other.PostAsJsonAsync("/api/votes", new
{
SuggestionId = id,
Score = 5
});
var blocked = await owner.DeleteAsync($"/api/suggestions/{id}"); var blocked = await owner.DeleteAsync($"/api/suggestions/{id}");
Assert.Equal(HttpStatusCode.BadRequest, blocked.StatusCode); Assert.Equal(HttpStatusCode.BadRequest, blocked.StatusCode);

View File

@@ -1,18 +1,9 @@
using System.Net.Http;
namespace GameList.Tests.Support; namespace GameList.Tests.Support;
internal class StubHttpClientFactory : IHttpClientFactory internal class StubHttpClientFactory(StubHttpMessageHandler handler) : IHttpClientFactory
{ {
private readonly StubHttpMessageHandler _handler;
public StubHttpClientFactory(StubHttpMessageHandler handler)
{
_handler = handler;
}
public HttpClient CreateClient(string name) public HttpClient CreateClient(string name)
{ {
return new HttpClient(_handler, disposeHandler: false); return new HttpClient(handler, disposeHandler: false);
} }
} }

View File

@@ -5,24 +5,16 @@ namespace GameList.Tests.Support;
internal class StubHttpMessageHandler : HttpMessageHandler internal class StubHttpMessageHandler : HttpMessageHandler
{ {
private Func<HttpRequestMessage, HttpResponseMessage> _responder; private Func<HttpRequestMessage, HttpResponseMessage> _responder = DefaultResponder;
public StubHttpMessageHandler()
{
_responder = DefaultResponder;
}
public void SetResponder(Func<HttpRequestMessage, HttpResponseMessage> responder) public void SetResponder(Func<HttpRequestMessage, HttpResponseMessage> responder)
{ {
_responder = responder ?? DefaultResponder; _responder = responder;
} }
private static HttpResponseMessage DefaultResponder(HttpRequestMessage _) private static HttpResponseMessage DefaultResponder(HttpRequestMessage _)
{ {
var response = new HttpResponseMessage(HttpStatusCode.OK) var response = new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent([]) };
{
Content = new ByteArrayContent(Array.Empty<byte>())
};
response.Content.Headers.ContentType = new MediaTypeHeaderValue("image/png"); response.Content.Headers.ContentType = new MediaTypeHeaderValue("image/png");
response.Content.Headers.ContentLength = 0; response.Content.Headers.ContentLength = 0;
return response; return response;

View File

@@ -49,5 +49,4 @@ internal static class TestClientExtensions
var me = await client.GetFromJsonAsync<JsonElement>("/api/me"); var me = await client.GetFromJsonAsync<JsonElement>("/api/me");
return Guid.Parse(me.GetProperty("id").GetString()!); return Guid.Parse(me.GetProperty("id").GetString()!);
} }
} }

View File

@@ -1,5 +1,3 @@
using System.Collections.Generic;
using System.Linq;
using GameList.Data; using GameList.Data;
using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.Mvc.Testing;
@@ -18,13 +16,7 @@ internal class TestWebApplicationFactory : WebApplicationFactory<Program>
protected override void ConfigureWebHost(IWebHostBuilder builder) protected override void ConfigureWebHost(IWebHostBuilder builder)
{ {
builder.UseEnvironment("Development"); builder.UseEnvironment("Development");
builder.ConfigureAppConfiguration((context, config) => builder.ConfigureAppConfiguration((_, config) => { config.AddInMemoryCollection(new Dictionary<string, string?> { ["ADMIN_PASSWORD"] = "admin-key" }); });
{
config.AddInMemoryCollection(new Dictionary<string, string?>
{
["ADMIN_PASSWORD"] = "admin-key"
});
});
builder.ConfigureServices(services => builder.ConfigureServices(services =>
{ {
@@ -37,10 +29,7 @@ internal class TestWebApplicationFactory : WebApplicationFactory<Program>
_connection = new SqliteConnection("Data Source=:memory:;Cache=Shared"); _connection = new SqliteConnection("Data Source=:memory:;Cache=Shared");
_connection.Open(); _connection.Open();
services.AddDbContext<AppDbContext>(options => services.AddDbContext<AppDbContext>(options => { options.UseSqlite(_connection); });
{
options.UseSqlite(_connection);
});
services.AddSingleton<StubHttpMessageHandler>(); services.AddSingleton<StubHttpMessageHandler>();
services.AddSingleton<IHttpClientFactory, StubHttpClientFactory>(); services.AddSingleton<IHttpClientFactory, StubHttpClientFactory>();

View File

@@ -11,7 +11,7 @@ public class VoteTests
[Fact] [Fact]
public async Task Finalizing_votes_blocks_further_changes() public async Task Finalizing_votes_blocks_further_changes()
{ {
using var factory = new TestWebApplicationFactory(); await using var factory = new TestWebApplicationFactory();
var client = factory.CreateClientWithCookies(); var client = factory.CreateClientWithCookies();
await client.RegisterAsync("voter"); await client.RegisterAsync("voter");
@@ -19,13 +19,21 @@ public class VoteTests
await client.PostAsJsonAsync("/api/me/phase/next", new { }); await client.PostAsJsonAsync("/api/me/phase/next", new { });
var vote = await client.PostAsJsonAsync("/api/votes", new { SuggestionId = suggestionId, Score = 7 }); var vote = await client.PostAsJsonAsync("/api/votes", new
{
SuggestionId = suggestionId,
Score = 7
});
vote.EnsureSuccessStatusCode(); vote.EnsureSuccessStatusCode();
var finalize = await client.PostAsJsonAsync("/api/votes/finalize", new { Final = true }); var finalize = await client.PostAsJsonAsync("/api/votes/finalize", new { Final = true });
finalize.EnsureSuccessStatusCode(); finalize.EnsureSuccessStatusCode();
var change = await client.PostAsJsonAsync("/api/votes", new { SuggestionId = suggestionId, Score = 5 }); var change = await client.PostAsJsonAsync("/api/votes", new
{
SuggestionId = suggestionId,
Score = 5
});
Assert.Equal(HttpStatusCode.BadRequest, change.StatusCode); Assert.Equal(HttpStatusCode.BadRequest, change.StatusCode);
} }
@@ -33,45 +41,57 @@ public class VoteTests
[Fact] [Fact]
public async Task Score_out_of_range_rejected() public async Task Score_out_of_range_rejected()
{ {
using var factory = new TestWebApplicationFactory(); await using var factory = new TestWebApplicationFactory();
var client = factory.CreateClientWithCookies(); var client = factory.CreateClientWithCookies();
await client.RegisterAsync("score"); await client.RegisterAsync("score");
var id = await client.CreateSuggestionAsync("RangeGame"); var id = await client.CreateSuggestionAsync("RangeGame");
await client.PostAsJsonAsync("/api/me/phase/next", new { }); await client.PostAsJsonAsync("/api/me/phase/next", new { });
var resp = await client.PostAsJsonAsync("/api/votes", new { SuggestionId = id, Score = 11 }); var resp = await client.PostAsJsonAsync("/api/votes", new
{
SuggestionId = id,
Score = 11
});
Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode); Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode);
} }
[Fact] [Fact]
public async Task Negative_score_rejected() public async Task Negative_score_rejected()
{ {
using var factory = new TestWebApplicationFactory(); await using var factory = new TestWebApplicationFactory();
var client = factory.CreateClientWithCookies(); var client = factory.CreateClientWithCookies();
await client.RegisterAsync("negative"); await client.RegisterAsync("negative");
var id = await client.CreateSuggestionAsync("RangeGame2"); var id = await client.CreateSuggestionAsync("RangeGame2");
await client.PostAsJsonAsync("/api/me/phase/next", new { }); await client.PostAsJsonAsync("/api/me/phase/next", new { });
var resp = await client.PostAsJsonAsync("/api/votes", new { SuggestionId = id, Score = -1 }); var resp = await client.PostAsJsonAsync("/api/votes", new
{
SuggestionId = id,
Score = -1
});
Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode); Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode);
} }
[Fact] [Fact]
public async Task Invalid_suggestion_id_rejected() public async Task Invalid_suggestion_id_rejected()
{ {
using var factory = new TestWebApplicationFactory(); await using var factory = new TestWebApplicationFactory();
var client = factory.CreateClientWithCookies(); var client = factory.CreateClientWithCookies();
await client.RegisterAsync("invalid"); await client.RegisterAsync("invalid");
await client.PostAsJsonAsync("/api/me/phase/next", new { }); await client.PostAsJsonAsync("/api/me/phase/next", new { });
var resp = await client.PostAsJsonAsync("/api/votes", new { SuggestionId = 9999, Score = 5 }); var resp = await client.PostAsJsonAsync("/api/votes", new
{
SuggestionId = 9999,
Score = 5
});
Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode); Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode);
} }
[Fact] [Fact]
public async Task Votes_require_display_name() public async Task Votes_require_display_name()
{ {
using var factory = new TestWebApplicationFactory(); await using var factory = new TestWebApplicationFactory();
var client = factory.CreateClientWithCookies(); var client = factory.CreateClientWithCookies();
await client.RegisterAsync("anon"); await client.RegisterAsync("anon");
var id = await client.CreateSuggestionAsync("NeedName"); var id = await client.CreateSuggestionAsync("NeedName");
@@ -84,14 +104,18 @@ public class VoteTests
}); });
await client.PostAsJsonAsync("/api/me/phase/next", new { }); await client.PostAsJsonAsync("/api/me/phase/next", new { });
var resp = await client.PostAsJsonAsync("/api/votes", new { SuggestionId = id, Score = 5 }); var resp = await client.PostAsJsonAsync("/api/votes", new
{
SuggestionId = id,
Score = 5
});
Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode); Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode);
} }
[Fact] [Fact]
public async Task Finalize_only_in_vote_phase() public async Task Finalize_only_in_vote_phase()
{ {
using var factory = new TestWebApplicationFactory(); await using var factory = new TestWebApplicationFactory();
var client = factory.CreateClientWithCookies(); var client = factory.CreateClientWithCookies();
await client.RegisterAsync("phase"); await client.RegisterAsync("phase");
@@ -102,12 +126,16 @@ public class VoteTests
[Fact] [Fact]
public async Task Finalize_toggle_allows_unfinalize() public async Task Finalize_toggle_allows_unfinalize()
{ {
using var factory = new TestWebApplicationFactory(); await using var factory = new TestWebApplicationFactory();
var client = factory.CreateClientWithCookies(); var client = factory.CreateClientWithCookies();
await client.RegisterAsync("toggle"); await client.RegisterAsync("toggle");
var id = await client.CreateSuggestionAsync("Toggle"); var id = await client.CreateSuggestionAsync("Toggle");
await client.PostAsJsonAsync("/api/me/phase/next", new { }); await client.PostAsJsonAsync("/api/me/phase/next", new { });
await client.PostAsJsonAsync("/api/votes", new { SuggestionId = id, Score = 5 }); await client.PostAsJsonAsync("/api/votes", new
{
SuggestionId = id,
Score = 5
});
var finalize = await client.PostAsJsonAsync("/api/votes/finalize", new { Final = true }); var finalize = await client.PostAsJsonAsync("/api/votes/finalize", new { Final = true });
finalize.EnsureSuccessStatusCode(); finalize.EnsureSuccessStatusCode();
@@ -121,7 +149,7 @@ public class VoteTests
[Fact] [Fact]
public async Task Linked_votes_apply_to_all_linked_suggestions() public async Task Linked_votes_apply_to_all_linked_suggestions()
{ {
using var factory = new TestWebApplicationFactory(); await using var factory = new TestWebApplicationFactory();
var admin = factory.CreateClientWithCookies(); var admin = factory.CreateClientWithCookies();
await admin.RegisterAsync("admin", admin: true); await admin.RegisterAsync("admin", admin: true);
await admin.PostAsJsonAsync("/api/me/phase/next", new { }); await admin.PostAsJsonAsync("/api/me/phase/next", new { });
@@ -134,23 +162,31 @@ public class VoteTests
await player.PostAsJsonAsync("/api/me/phase/next", new { }); await player.PostAsJsonAsync("/api/me/phase/next", new { });
var linkResponse = await admin.PostAsJsonAsync("/api/admin/link-suggestions", new { SourceSuggestionId = id1, TargetSuggestionId = id2 }); var linkResponse = await admin.PostAsJsonAsync("/api/admin/link-suggestions", new
{
SourceSuggestionId = id1,
TargetSuggestionId = id2
});
linkResponse.EnsureSuccessStatusCode(); linkResponse.EnsureSuccessStatusCode();
var vote = await player.PostAsJsonAsync("/api/votes", new { SuggestionId = id1, Score = 9 }); var vote = await player.PostAsJsonAsync("/api/votes", new
{
SuggestionId = id1,
Score = 9
});
vote.EnsureSuccessStatusCode(); vote.EnsureSuccessStatusCode();
var mine = await player.GetFromJsonAsync<List<VoteRecord>>("/api/votes/mine"); var mine = await player.GetFromJsonAsync<List<VoteRecord>>("/api/votes/mine");
Assert.NotNull(mine); Assert.NotNull(mine);
Assert.Equal(2, mine!.Count); Assert.Equal(2, mine.Count);
Assert.All(mine, v => Assert.Equal(9, v.Score)); Assert.All(mine, v => Assert.Equal(9, v.Score));
} }
[Fact] [Fact]
public async Task Linked_votes_apply_across_chain() public async Task Linked_votes_apply_across_chain()
{ {
using var factory = new TestWebApplicationFactory(); await using var factory = new TestWebApplicationFactory();
var admin = factory.CreateClientWithCookies(); var admin = factory.CreateClientWithCookies();
await admin.RegisterAsync("admin", admin: true); await admin.RegisterAsync("admin", admin: true);
await admin.PostAsJsonAsync("/api/me/phase/next", new { }); await admin.PostAsJsonAsync("/api/me/phase/next", new { });
@@ -164,22 +200,34 @@ public class VoteTests
await player.PostAsJsonAsync("/api/me/phase/next", new { }); 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
await admin.PostAsJsonAsync("/api/admin/link-suggestions", new { SourceSuggestionId = b, TargetSuggestionId = c }); {
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 }); var vote = await player.PostAsJsonAsync("/api/votes", new
{
SuggestionId = c,
Score = 6
});
vote.EnsureSuccessStatusCode(); vote.EnsureSuccessStatusCode();
var mine = await player.GetFromJsonAsync<List<VoteRecord>>("/api/votes/mine"); var mine = await player.GetFromJsonAsync<List<VoteRecord>>("/api/votes/mine");
Assert.NotNull(mine); Assert.NotNull(mine);
Assert.Equal(3, mine!.Count); Assert.Equal(3, mine.Count);
Assert.All(mine, v => Assert.Equal(6, v.Score)); Assert.All(mine, v => Assert.Equal(6, v.Score));
} }
[Fact] [Fact]
public async Task Votes_mine_requires_vote_phase_and_auth() public async Task Votes_mine_requires_vote_phase_and_auth()
{ {
using var factory = new TestWebApplicationFactory(); await using var factory = new TestWebApplicationFactory();
var anon = factory.CreateClient(); var anon = factory.CreateClient();
var unauth = await anon.GetAsync("/api/votes/mine"); var unauth = await anon.GetAsync("/api/votes/mine");
Assert.Equal(HttpStatusCode.Unauthorized, unauth.StatusCode); Assert.Equal(HttpStatusCode.Unauthorized, unauth.StatusCode);
@@ -190,5 +238,7 @@ public class VoteTests
Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode); Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode);
} }
// ReSharper disable once NotAccessedPositionalProperty.Local
// ReSharper disable once ClassNeverInstantiated.Local
private record VoteRecord(int SuggestionId, int Score); private record VoteRecord(int SuggestionId, int Score);
} }

View File

@@ -1,6 +1,5 @@
using GameList.Data; using GameList.Data;
using GameList.Endpoints; using GameList.Endpoints;
using Microsoft.AspNetCore.Authorization;
namespace GameList.Infrastructure; namespace GameList.Infrastructure;

View File

@@ -3,18 +3,11 @@ using Microsoft.AspNetCore.Authentication;
namespace GameList.Infrastructure; namespace GameList.Infrastructure;
public class EnsurePlayerExistsMiddleware public class EnsurePlayerExistsMiddleware(RequestDelegate next)
{ {
private readonly RequestDelegate _next;
public EnsurePlayerExistsMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(HttpContext context, AppDbContext db) public async Task InvokeAsync(HttpContext context, AppDbContext db)
{ {
if (context.User?.Identity?.IsAuthenticated == true) if (context.User.Identity?.IsAuthenticated == true)
{ {
var id = context.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value; var id = context.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
if (string.IsNullOrWhiteSpace(id) || !Guid.TryParse(id, out var playerId) || await db.Players.FindAsync(playerId) is null) if (string.IsNullOrWhiteSpace(id) || !Guid.TryParse(id, out var playerId) || await db.Players.FindAsync(playerId) is null)
@@ -25,6 +18,6 @@ public class EnsurePlayerExistsMiddleware
} }
} }
await _next(context); await next(context);
} }
} }

View File

@@ -21,18 +21,15 @@ public static class PasswordHasher
public static bool Verify(string password, byte[] hash, byte[] salt) public static bool Verify(string password, byte[] hash, byte[] salt)
{ {
if (hash is null || salt is null || hash.Length == 0 || salt.Length == 0) return false; if (hash.Length == 0 || salt.Length == 0)
return false;
var computed = PBKDF2(password, salt); var computed = PBKDF2(password, salt);
return CryptographicOperations.FixedTimeEquals(computed, hash); return CryptographicOperations.FixedTimeEquals(computed, hash);
} }
private static byte[] PBKDF2(string password, byte[] salt) private static byte[] PBKDF2(string password, byte[] salt)
{ {
return Rfc2898DeriveBytes.Pbkdf2( return Rfc2898DeriveBytes.Pbkdf2(Encoding.UTF8.GetBytes(password), salt, Iterations, HashAlgorithmName.SHA256, KeySize);
Encoding.UTF8.GetBytes(password),
salt,
Iterations,
HashAlgorithmName.SHA256,
KeySize);
} }
} }

View File

@@ -15,7 +15,8 @@ public class PhaseOrJokerFilter : IEndpointFilter
var httpContext = context.HttpContext; var httpContext = context.HttpContext;
var db = httpContext.RequestServices.GetRequiredService<AppDbContext>(); var db = httpContext.RequestServices.GetRequiredService<AppDbContext>();
var player = await EndpointHelpers.GetAuthenticatedPlayer(httpContext, db); var player = await EndpointHelpers.GetAuthenticatedPlayer(httpContext, db);
if (player is null) return Results.Unauthorized(); if (player is null)
return Results.Unauthorized();
var phase = await EndpointHelpers.GetPhase(db, player.Id); var phase = await EndpointHelpers.GetPhase(db, player.Id);
var allow = phase == Phase.Suggest || (phase == Phase.Vote && player.HasJoker); var allow = phase == Phase.Suggest || (phase == Phase.Vote && player.HasJoker);

View File

@@ -4,28 +4,20 @@ using GameList.Endpoints;
namespace GameList.Infrastructure; namespace GameList.Infrastructure;
public class PhaseRequirementFilter : IEndpointFilter public class PhaseRequirementFilter(Phase required, bool allowAdminOverride = false) : IEndpointFilter
{ {
private readonly Phase _required;
private readonly bool _allowAdminOverride;
public PhaseRequirementFilter(Phase required, bool allowAdminOverride = false)
{
_required = required;
_allowAdminOverride = allowAdminOverride;
}
public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
{ {
var httpContext = context.HttpContext; var httpContext = context.HttpContext;
var db = httpContext.RequestServices.GetRequiredService<AppDbContext>(); var db = httpContext.RequestServices.GetRequiredService<AppDbContext>();
var player = await EndpointHelpers.GetAuthenticatedPlayer(httpContext, db); var player = await EndpointHelpers.GetAuthenticatedPlayer(httpContext, db);
if (player is null) return Results.Unauthorized(); if (player is null)
return Results.Unauthorized();
var phase = await EndpointHelpers.GetPhase(db, player.Id); var phase = await EndpointHelpers.GetPhase(db, player.Id);
if (phase != _required && !(_allowAdminOverride && player.IsAdmin)) if (phase != required && !(allowAdminOverride && player.IsAdmin))
{ {
return EndpointHelpers.PhaseMismatch(_required, phase); return EndpointHelpers.PhaseMismatch(required, phase);
} }
return await next(context); return await next(context);

View File

@@ -27,8 +27,7 @@ public static class PlayerIdentityExtensions
await ctx.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal); await ctx.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal);
} }
public static Task SignOutPlayerAsync(HttpContext ctx) public static Task SignOutPlayerAsync(HttpContext ctx) => ctx.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
=> ctx.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
public static IApplicationBuilder UseGlobalExceptionLogging(this IApplicationBuilder app) public static IApplicationBuilder UseGlobalExceptionLogging(this IApplicationBuilder app)
{ {

View File

@@ -6,21 +6,19 @@ using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.Data.Sqlite; using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
var dataDirectory = Path.Combine(builder.Environment.ContentRootPath, "App_Data"); var dataDirectory = Path.Combine(builder.Environment.ContentRootPath, "App_Data");
Directory.CreateDirectory(dataDirectory); Directory.CreateDirectory(dataDirectory);
var dataProtectionDirectory = Path.Combine(dataDirectory, "keys"); var dataProtectionDirectory = Path.Combine(dataDirectory, "keys");
Directory.CreateDirectory(dataProtectionDirectory); Directory.CreateDirectory(dataProtectionDirectory);
var configuredConnection = builder.Configuration.GetConnectionString("Default"); var configuredConnection = builder.Configuration.GetConnectionString("Default");
var dbPath = Path.Combine(dataDirectory, "gamelist.db"); var dbPath = Path.Combine(dataDirectory, "gamelist.db");
var connectionBuilder = new SqliteConnectionStringBuilder(string.IsNullOrWhiteSpace(configuredConnection) var connectionBuilder = new SqliteConnectionStringBuilder(string.IsNullOrWhiteSpace(configuredConnection) ? $"Data Source={dbPath}" : configuredConnection);
? $"Data Source={dbPath}"
: configuredConnection);
if (connectionBuilder.DataSource.Contains("App_Data", StringComparison.OrdinalIgnoreCase)) if (connectionBuilder.DataSource.Contains("App_Data", StringComparison.OrdinalIgnoreCase))
{ {
@@ -34,27 +32,19 @@ else if (!Path.IsPathRooted(connectionBuilder.DataSource))
var connectionString = connectionBuilder.ToString(); var connectionString = connectionBuilder.ToString();
builder.Services.AddDbContext<AppDbContext>(options => builder.Services.AddDbContext<AppDbContext>(options => options.UseSqlite(connectionString));
options.UseSqlite(connectionString));
builder.Services.ConfigureHttpJsonOptions(options => builder.Services.ConfigureHttpJsonOptions(options => { options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()); });
{
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter());
});
builder.Services.AddHttpClient(); builder.Services.AddHttpClient();
builder.Services.AddDataProtection() builder.Services.AddDataProtection().PersistKeysToFileSystem(new DirectoryInfo(dataProtectionDirectory));
.PersistKeysToFileSystem(new DirectoryInfo(dataProtectionDirectory));
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie(options =>
.AddCookie(options =>
{ {
options.Cookie.Name = PlayerIdentityExtensions.PlayerCookieName; options.Cookie.Name = PlayerIdentityExtensions.PlayerCookieName;
options.Cookie.HttpOnly = true; options.Cookie.HttpOnly = true;
options.Cookie.SameSite = SameSiteMode.Strict; options.Cookie.SameSite = SameSiteMode.Strict;
options.Cookie.SecurePolicy = builder.Environment.IsDevelopment() options.Cookie.SecurePolicy = builder.Environment.IsDevelopment() ? CookieSecurePolicy.SameAsRequest : CookieSecurePolicy.Always;
? CookieSecurePolicy.SameAsRequest
: CookieSecurePolicy.Always;
options.SlidingExpiration = true; options.SlidingExpiration = true;
options.ExpireTimeSpan = TimeSpan.FromDays(30); options.ExpireTimeSpan = TimeSpan.FromDays(30);
options.Events = new CookieAuthenticationEvents options.Events = new CookieAuthenticationEvents
@@ -72,18 +62,11 @@ builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationSc
}; };
}); });
builder.Services.AddAuthorization(options => builder.Services.AddAuthorization(options => { options.AddPolicy(PlayerIdentityExtensions.AdminPolicy, policy => policy.RequireClaim(PlayerIdentityExtensions.AdminClaim, "true")); });
{
options.AddPolicy(PlayerIdentityExtensions.AdminPolicy, policy =>
policy.RequireClaim(PlayerIdentityExtensions.AdminClaim, "true"));
});
var app = builder.Build(); var app = builder.Build();
app.UseForwardedHeaders(new ForwardedHeadersOptions app.UseForwardedHeaders(new ForwardedHeadersOptions { ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedHost });
{
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedHost
});
var basePath = builder.Configuration["BasePath"]; var basePath = builder.Configuration["BasePath"];
if (!string.IsNullOrWhiteSpace(basePath)) if (!string.IsNullOrWhiteSpace(basePath))
@@ -122,22 +105,29 @@ static void UpdateIndexMetaBase(IWebHostEnvironment env, string basePath)
try try
{ {
var indexPath = Path.Combine(env.WebRootPath, "index.html"); var indexPath = Path.Combine(env.WebRootPath, "index.html");
if (!File.Exists(indexPath)) return; if (!File.Exists(indexPath))
return;
var text = File.ReadAllText(indexPath); var text = File.ReadAllText(indexPath);
var marker = "name=\"app-base\""; var marker = "name=\"app-base\"";
var contentKey = "content=\""; var contentKey = "content=\"";
var markerIndex = text.IndexOf(marker, StringComparison.OrdinalIgnoreCase); var markerIndex = text.IndexOf(marker, StringComparison.OrdinalIgnoreCase);
if (markerIndex < 0) return; if (markerIndex < 0)
return;
var contentIndex = text.IndexOf(contentKey, markerIndex, StringComparison.OrdinalIgnoreCase); var contentIndex = text.IndexOf(contentKey, markerIndex, StringComparison.OrdinalIgnoreCase);
if (contentIndex < 0) return; if (contentIndex < 0)
return;
var valueStart = contentIndex + contentKey.Length; var valueStart = contentIndex + contentKey.Length;
var valueEnd = text.IndexOf('"', valueStart); var valueEnd = text.IndexOf('"', valueStart);
if (valueEnd < 0) return; if (valueEnd < 0)
return;
var current = text[valueStart..valueEnd]; var current = text[valueStart..valueEnd];
var normalized = basePath.EndsWith('/') ? basePath.TrimEnd('/') : basePath; var normalized = basePath.EndsWith('/') ? basePath.TrimEnd('/') : basePath;
if (current == normalized) return; if (current == normalized)
return;
var updated = text[..valueStart] + normalized + text[valueEnd..]; var updated = text[..valueStart] + normalized + text[valueEnd..];
File.WriteAllText(indexPath, updated); File.WriteAllText(indexPath, updated);
@@ -148,4 +138,4 @@ static void UpdateIndexMetaBase(IWebHostEnvironment env, string basePath)
} }
} }
public partial class Program { } public partial class Program;