diff --git a/Contracts/AuthRequests.cs b/Contracts/AuthRequests.cs index af7d2fa..24fb124 100644 --- a/Contracts/AuthRequests.cs +++ b/Contracts/AuthRequests.cs @@ -1,4 +1,5 @@ namespace GameList.Contracts; public record RegisterRequest(string Username, string Password, string? DisplayName, string? AdminKey); + public record LoginRequest(string Username, string Password); diff --git a/Contracts/Dtos.cs b/Contracts/Dtos.cs index bff2d11..c6b108d 100644 --- a/Contracts/Dtos.cs +++ b/Contracts/Dtos.cs @@ -3,13 +3,23 @@ using GameList.Domain; namespace GameList.Contracts; 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 SuggestionDto(int Id, string Name, string? Genre, string? Description, string? ScreenshotUrl, string? YoutubeUrl, string? GameUrl, int? MinPlayers, int? MaxPlayers, int? ParentSuggestionId = null, IReadOnlyList? LinkedIds = null, IReadOnlyList? LinkedTitles = null); + public record VoteRequest(int SuggestionId, int Score); + public record ResultsOpenRequest(bool ResultsOpen); + public record VoteFinalizeRequest(bool Final); + public record VoteStatusDto(Guid PlayerId, string Name, string Username, Phase Phase, bool Finalized, bool HasJoker, int SuggestionCount, IReadOnlyList SuggestionTitles); + public record LinkSuggestionsRequest(int SourceSuggestionId, int TargetSuggestionId); + public record UnlinkSuggestionsRequest(int SuggestionId); + public record GrantJokerRequest(Guid PlayerId); + public record DeletePlayerRequest(Guid PlayerId); diff --git a/Data/AppDbContext.cs b/Data/AppDbContext.cs index eced9c0..5ebdffb 100644 --- a/Data/AppDbContext.cs +++ b/Data/AppDbContext.cs @@ -3,12 +3,8 @@ using Microsoft.EntityFrameworkCore; namespace GameList.Data; -public class AppDbContext : DbContext +public class AppDbContext(DbContextOptions options) : DbContext(options) { - public AppDbContext(DbContextOptions options) : base(options) - { - } - public DbSet Players => Set(); public DbSet Suggestions => Set(); public DbSet Votes => Set(); @@ -29,14 +25,8 @@ public class AppDbContext : DbContext builder.Property(p => p.HasJoker).HasDefaultValue(false); builder.Property(p => p.CurrentPhase).HasDefaultValue(Phase.Suggest); builder.Property(p => p.VotesFinal).HasDefaultValue(false); - builder.HasMany(p => p.Suggestions) - .WithOne(s => s.Player!) - .HasForeignKey(s => s.PlayerId) - .OnDelete(DeleteBehavior.Cascade); - builder.HasMany(p => p.Votes) - .WithOne(v => v.Player!) - .HasForeignKey(v => v.PlayerId) - .OnDelete(DeleteBehavior.Cascade); + builder.HasMany(p => p.Suggestions).WithOne(s => s.Player!).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(builder => @@ -50,10 +40,7 @@ public class AppDbContext : DbContext builder.Property(s => s.GameUrl).HasMaxLength(2048); builder.Property(s => s.MinPlayers); builder.Property(s => s.MaxPlayers); - builder.HasOne(s => s.ParentSuggestion) - .WithMany(p => p.LinkedSuggestions) - .HasForeignKey(s => s.ParentSuggestionId) - .OnDelete(DeleteBehavior.SetNull); + builder.HasOne(s => s.ParentSuggestion).WithMany(p => p.LinkedSuggestions).HasForeignKey(s => s.ParentSuggestionId).OnDelete(DeleteBehavior.SetNull); builder.HasIndex(s => s.ParentSuggestionId); }); @@ -61,7 +48,11 @@ public class AppDbContext : DbContext { builder.HasKey(v => v.Id); 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(builder => diff --git a/Domain/Player.cs b/Domain/Player.cs index 34069d2..bcdee37 100644 --- a/Domain/Player.cs +++ b/Domain/Player.cs @@ -15,8 +15,8 @@ public class Player [MaxLength(24)] public string NormalizedUsername { get; set; } = string.Empty; - public byte[] PasswordHash { get; set; } = Array.Empty(); - public byte[] PasswordSalt { get; set; } = Array.Empty(); + public byte[] PasswordHash { get; set; } = []; + public byte[] PasswordSalt { get; set; } = []; public DateTimeOffset? LastLoginAt { get; set; } public bool IsAdmin { get; set; } diff --git a/Domain/Suggestion.cs b/Domain/Suggestion.cs index b465ae6..292c2d8 100644 --- a/Domain/Suggestion.cs +++ b/Domain/Suggestion.cs @@ -8,6 +8,7 @@ public class Suggestion [Required] public Guid PlayerId { get; set; } + public Player? Player { get; set; } [Required] diff --git a/Domain/Vote.cs b/Domain/Vote.cs index cf263aa..53de690 100644 --- a/Domain/Vote.cs +++ b/Domain/Vote.cs @@ -8,10 +8,12 @@ public class Vote [Required] public Guid PlayerId { get; set; } + public Player? Player { get; set; } [Required] public int SuggestionId { get; set; } + public Suggestion? Suggestion { get; set; } [Range(0, 10)] diff --git a/Endpoints/AdminEndpoints.cs b/Endpoints/AdminEndpoints.cs index 96bbe31..0d05036 100644 --- a/Endpoints/AdminEndpoints.cs +++ b/Endpoints/AdminEndpoints.cs @@ -3,7 +3,6 @@ using GameList.Domain; using GameList.Contracts; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; -using System.Collections.Generic; using GameList.Infrastructure; namespace GameList.Endpoints; @@ -12,11 +11,9 @@ public static class AdminEndpoints { public static void MapAdminEndpoints(this IEndpointRouteBuilder app) { - var admin = app.MapGroup("/api/admin") - .RequireAuthorization() - .AddEndpointFilter(); + var admin = app.MapGroup("/api/admin").RequireAuthorization().AddEndpointFilter(); - 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(); state.ResultsOpen = request.ResultsOpen; @@ -28,40 +25,37 @@ public static class AdminEndpoints } else { - await db.Players.ExecuteUpdateAsync(p => p.SetProperty(x => x.CurrentPhase, Phase.Vote) - .SetProperty(x => x.VotesFinal, false)); + await db.Players.ExecuteUpdateAsync(p => p.SetProperty(x => x.CurrentPhase, Phase.Vote).SetProperty(x => x.VotesFinal, false)); } await db.SaveChangesAsync(); 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 - .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 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(); var waiting = voters.Where(v => !v.Finalized).Select(v => v.Name).ToList(); 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); - 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); if (phase != Phase.Vote) @@ -71,15 +65,18 @@ public static class AdminEndpoints player.VotesFinal = false; 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 - .Include(p => p.Suggestions) - .FirstOrDefaultAsync(p => p.Id == playerId); - if (player is null) return Results.NotFound(new { error = "Player not found." }); + var player = await db.Players.Include(p => p.Suggestions).FirstOrDefaultAsync(p => p.Id == playerId); + if (player is null) + return Results.NotFound(new { error = "Player not found." }); await using var tx = await db.Database.BeginTransactionAsync(); @@ -91,9 +88,7 @@ public static class AdminEndpoints if (suggestionIds.Count > 0) { // Break links pointing to these suggestions - await db.Suggestions - .Where(s => s.ParentSuggestionId != null && suggestionIds.Contains(s.ParentSuggestionId.Value)) - .ExecuteUpdateAsync(s => s.SetProperty(x => x.ParentSuggestionId, (int?)null)); + await db.Suggestions.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 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) => { 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); if (phase != Phase.Vote) @@ -132,11 +128,12 @@ public static class AdminEndpoints if (sourceRoot == targetRoot) return Results.BadRequest(new { error = "These games are already linked." }); - var affectedRootIds = new HashSet { sourceRoot, targetRoot }; - var affectedIds = rootIndex - .Where(kv => affectedRootIds.Contains(kv.Value)) - .Select(kv => kv.Key) - .ToList(); + var affectedRootIds = new HashSet + { + sourceRoot, + targetRoot + }; + var affectedIds = rootIndex.Where(kv => affectedRootIds.Contains(kv.Value)).Select(kv => kv.Key).ToList(); await using var tx = await db.Database.BeginTransactionAsync(); @@ -155,18 +152,13 @@ public static class AdminEndpoints await db.SaveChangesAsync(); - var affectedPlayerIds = await db.Votes - .Where(v => affectedIds.Contains(v.SuggestionId)) - .Select(v => v.PlayerId) - .Distinct() - .ToListAsync(); + var affectedPlayerIds = await db.Votes.Where(v => affectedIds.Contains(v.SuggestionId)).Select(v => v.PlayerId).Distinct().ToListAsync(); await db.Votes.Where(v => affectedIds.Contains(v.SuggestionId)).ExecuteDeleteAsync(); if (affectedPlayerIds.Count > 0) { - await db.Players.Where(p => affectedPlayerIds.Contains(p.Id)) - .ExecuteUpdateAsync(p => p.SetProperty(x => x.VotesFinal, false)); + await db.Players.Where(p => affectedPlayerIds.Contains(p.Id)).ExecuteUpdateAsync(p => p.SetProperty(x => x.VotesFinal, false)); } await tx.CommitAsync(); @@ -182,7 +174,8 @@ public static class AdminEndpoints admin.MapPost("/unlink-suggestions", async ([FromBody] UnlinkSuggestionsRequest request, HttpContext ctx, AppDbContext 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); if (phase != Phase.Vote) @@ -191,16 +184,21 @@ public static class AdminEndpoints var suggestions = await db.Suggestions.ToListAsync(); var target = suggestions.FirstOrDefault(s => s.Id == request.SuggestionId); if (target is null) - return Results.Ok(new { UnlinkedSuggestionIds = Array.Empty(), UnfinalizedPlayers = 0 }); + return Results.Ok(new + { + UnlinkedSuggestionIds = Array.Empty(), + UnfinalizedPlayers = 0 + }); var rootIndex = EndpointHelpers.BuildLinkRoots(suggestions.Select(s => (s.Id, s.ParentSuggestionId))); if (!rootIndex.TryGetValue(target.Id, out var rootId)) - return Results.Ok(new { UnlinkedSuggestionIds = Array.Empty(), UnfinalizedPlayers = 0 }); + return Results.Ok(new + { + UnlinkedSuggestionIds = Array.Empty(), + UnfinalizedPlayers = 0 + }); - var groupIds = rootIndex - .Where(kv => kv.Value == rootId) - .Select(kv => kv.Key) - .ToList(); + var groupIds = rootIndex.Where(kv => kv.Value == rootId).Select(kv => kv.Key).ToList(); await using var tx = await db.Database.BeginTransactionAsync(); @@ -211,18 +209,13 @@ public static class AdminEndpoints await db.SaveChangesAsync(); - var affectedPlayerIds = await db.Votes - .Where(v => groupIds.Contains(v.SuggestionId)) - .Select(v => v.PlayerId) - .Distinct() - .ToListAsync(); + var affectedPlayerIds = await db.Votes.Where(v => groupIds.Contains(v.SuggestionId)).Select(v => v.PlayerId).Distinct().ToListAsync(); await db.Votes.Where(v => groupIds.Contains(v.SuggestionId)).ExecuteDeleteAsync(); if (affectedPlayerIds.Count > 0) { - await db.Players.Where(p => affectedPlayerIds.Contains(p.Id)) - .ExecuteUpdateAsync(p => p.SetProperty(x => x.VotesFinal, false)); + await db.Players.Where(p => affectedPlayerIds.Contains(p.Id)).ExecuteUpdateAsync(p => p.SetProperty(x => x.VotesFinal, false)); } 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.Suggestions.ExecuteDeleteAsync(); - await db.Players.ExecuteUpdateAsync(p => p.SetProperty(x => x.CurrentPhase, Phase.Suggest) - .SetProperty(x => x.VotesFinal, false) - .SetProperty(x => x.HasJoker, false)); + await db.Players.ExecuteUpdateAsync(p => p.SetProperty(x => x.CurrentPhase, Phase.Suggest).SetProperty(x => x.VotesFinal, false).SetProperty(x => x.HasJoker, false)); var state = await db.AppState.FirstAsync(); state.ResultsOpen = false; state.UpdatedAt = DateTimeOffset.UtcNow; 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(); @@ -265,7 +261,12 @@ public static class AdminEndpoints 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 + }); }); } } diff --git a/Endpoints/AuthEndpoints.cs b/Endpoints/AuthEndpoints.cs index 06c3c1c..eb9ecfc 100644 --- a/Endpoints/AuthEndpoints.cs +++ b/Endpoints/AuthEndpoints.cs @@ -15,7 +15,7 @@ public static class AuthEndpoints 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) 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); if (string.IsNullOrWhiteSpace(displayName)) return Results.BadRequest(new { error = "Display name is required." }); + var normalized = username.ToLowerInvariant(); var exists = await db.Players.AnyAsync(p => p.NormalizedUsername == normalized); @@ -43,6 +44,7 @@ public static class AuthEndpoints if (string.IsNullOrWhiteSpace(expectedAdminKey) || adminKey != expectedAdminKey) return Results.BadRequest(new { error = "Invalid admin key." }); } + var isAdmin = wantsAdmin; var player = new Player @@ -63,12 +65,18 @@ public static class AuthEndpoints 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) => { - var username = request.Username?.Trim(); + var username = request.Username.Trim(); if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(request.Password)) return Results.BadRequest(new { error = "Username and password are required." }); if (username.Length > 24) @@ -83,12 +91,19 @@ public static class AuthEndpoints { player.DisplayName = EndpointHelpers.TrimTo(player.Username, 16); } + player.LastLoginAt = DateTimeOffset.UtcNow; await db.SaveChangesAsync(); 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) => diff --git a/Endpoints/EndpointHelpers.cs b/Endpoints/EndpointHelpers.cs index 6862ead..6258c48 100644 --- a/Endpoints/EndpointHelpers.cs +++ b/Endpoints/EndpointHelpers.cs @@ -1,7 +1,5 @@ -using System.Collections.Generic; using GameList.Data; using GameList.Domain; -using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using System.Security.Claims; @@ -11,7 +9,8 @@ internal static class EndpointHelpers { public static async Task 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) return cachedPlayer; @@ -38,7 +37,8 @@ internal static class EndpointHelpers public static async Task GetPhase(AppDbContext db, Guid 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(); @@ -68,6 +68,7 @@ internal static class EndpointHelpers { await db.SaveChangesAsync(); } + 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}." }); public static string? TrimTo(string? input, int max) => - string.IsNullOrWhiteSpace(input) - ? null - : input.Trim() is var t && t.Length > 0 - ? t[..Math.Min(t.Length, max)] - : null; + string.IsNullOrWhiteSpace(input) ? null : input.Trim() is { Length: > 0 } t ? t[..Math.Min(t.Length, max)] : null; public static bool IsValidImageUrl(string? url) { - if (string.IsNullOrWhiteSpace(url)) return true; // empty is acceptable - if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) return false; - if (uri.Scheme is not ("http" or "https")) return false; + if (string.IsNullOrWhiteSpace(url)) + return true; // empty is acceptable + 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(); - return path.EndsWith(".png") || path.EndsWith(".jpg") || path.EndsWith(".jpeg") - || path.EndsWith(".gif") || path.EndsWith(".webp") || path.EndsWith(".avif"); + return path.EndsWith(".png") || path.EndsWith(".jpg") || path.EndsWith(".jpeg") || path.EndsWith(".gif") || path.EndsWith(".webp") || path.EndsWith(".avif"); } public static async Task IsReachableImageAsync(string? url, IHttpClientFactory httpFactory, HttpMessageHandler? handler = null, CancellationToken ct = default) { - if (string.IsNullOrWhiteSpace(url)) return true; - if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) return false; - if (uri.Scheme is not ("http" or "https")) return false; - if (!await IsSafePublicHostAsync(uri, httpFactory, ct)) return false; + if (string.IsNullOrWhiteSpace(url)) + return true; + if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) + 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); cts.CancelAfter(TimeSpan.FromSeconds(3)); - var client = handler is null - ? httpFactory.CreateClient("imageValidation") - : new HttpClient(handler, disposeHandler: false); + var client = handler is null ? httpFactory.CreateClient("imageValidation") : new HttpClient(handler, disposeHandler: false); try { using var head = new HttpRequestMessage(HttpMethod.Head, uri); 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; if (!string.IsNullOrWhiteSpace(ctHeader) && ctHeader.StartsWith("image/", StringComparison.OrdinalIgnoreCase)) return true; } } - catch { /* fallback */ } + catch + { + /* fallback */ + } try { using var get = new HttpRequestMessage(HttpMethod.Get, uri); get.Headers.Range = new System.Net.Http.Headers.RangeHeaderValue(0, 1023); var resp = await client.SendAsync(get, HttpCompletionOption.ResponseHeadersRead, cts.Token); - if (!resp.IsSuccessStatusCode) return false; - if (resp.StatusCode is System.Net.HttpStatusCode.Redirect) return false; - if (resp.Content.Headers.ContentLength is long len && len > MaxImageBytes) return false; + if (!resp.IsSuccessStatusCode) + 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; 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 sig = new ReadOnlySpan(rented, 0, read); - if (IsMagic(sig, "PNG")) return true; - if (IsMagic(sig, new byte[] { 0xFF, 0xD8 })) return true; // JPEG - if (IsMagic(sig, "GIF8")) return true; - if (IsRiffWithTag(sig, "WEBP")) return true; - if (ContainsFtyp(sig, "avif")) return true; + if (IsMagic(sig, "PNG")) + return true; + if (IsMagic(sig, [0xFF, 0xD8])) + return true; // JPEG + if (IsMagic(sig, "GIF8")) + return true; + if (IsRiffWithTag(sig, "WEBP")) + return true; + if (ContainsFtyp(sig, "avif")) + return true; return false; } @@ -153,7 +168,7 @@ internal static class EndpointHelpers private const long MaxImageBytes = 5 * 1024 * 1024; // 5 MB guard - private static async Task IsSafePublicHostAsync(Uri uri, IHttpClientFactory httpFactory, CancellationToken ct) + private static async Task IsSafePublicHostAsync(Uri uri, CancellationToken ct) { try { @@ -163,8 +178,10 @@ internal static class EndpointHelpers var addresses = await System.Net.Dns.GetHostAddressesAsync(host, ct); foreach (var ip in addresses) { - if (System.Net.IPAddress.IsLoopback(ip)) return false; - if (IsPrivate(ip)) return false; + if (System.Net.IPAddress.IsLoopback(ip)) + return false; + if (IsPrivate(ip)) + return false; } } else @@ -187,11 +204,11 @@ internal static class EndpointHelpers var bytes = ip.GetAddressBytes(); return bytes[0] switch { - 10 => true, + 10 => true, 172 when bytes[1] >= 16 && bytes[1] <= 31 => true, - 192 when bytes[1] == 168 => true, - 127 => true, - _ => false + 192 when bytes[1] == 168 => true, + 127 => true, + _ => false }; } @@ -213,26 +230,37 @@ internal static class EndpointHelpers private static bool IsRiffWithTag(ReadOnlySpan data, string tag) { - if (data.Length < 12) return false; - var riff = System.Text.Encoding.ASCII.GetBytes("RIFF"); - if (!data.StartsWith(riff)) return false; + if (data.Length < 12) + return false; + + var riff = "RIFF"u8.ToArray(); + if (!data.StartsWith(riff)) + return false; + var tagBytes = System.Text.Encoding.ASCII.GetBytes(tag); return data[8..].StartsWith(tagBytes); } private static bool ContainsFtyp(ReadOnlySpan data, string brand) { - if (data.Length < 12) return false; - var ftyp = System.Text.Encoding.ASCII.GetBytes("ftyp"); - if (!data[4..].StartsWith(ftyp)) return false; + if (data.Length < 12) + return false; + + var ftyp = "ftyp"u8.ToArray(); + if (!data[4..].StartsWith(ftyp)) + return false; + var brandBytes = System.Text.Encoding.ASCII.GetBytes(brand); return data[8..].StartsWith(brandBytes); } public static bool IsValidHttpUrl(string? url) { - if (string.IsNullOrWhiteSpace(url)) return true; // empty is allowed - if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) return false; + if (string.IsNullOrWhiteSpace(url)) + return true; // empty is allowed + if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) + return false; + return uri.Scheme is "http" or "https"; } @@ -257,6 +285,7 @@ internal static class EndpointHelpers { roots[id] = FindRootId(id, parentMap); } + return roots; } @@ -265,7 +294,7 @@ internal static class EndpointHelpers var current = suggestionId; var visited = new HashSet(); - 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); current = p; @@ -276,7 +305,9 @@ internal static class EndpointHelpers public static List LinkedIdsFor(int suggestionId, IReadOnlyDictionary 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(); } } diff --git a/Endpoints/ResultsEndpoints.cs b/Endpoints/ResultsEndpoints.cs index 7af6a0d..3903fd5 100644 --- a/Endpoints/ResultsEndpoints.cs +++ b/Endpoints/ResultsEndpoints.cs @@ -39,7 +39,7 @@ public static class ResultsEndpoints s.MinPlayers, s.MaxPlayers, 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), Votes = s.Votes.Select(v => v.Score).ToList(), MyVote = s.Votes @@ -85,7 +85,7 @@ public static class ResultsEndpoints r.ParentSuggestionId, LinkedIds = linkedIds, LinkedTitles = linkedIds - .Where(id => nameLookup.ContainsKey(id)) + .Where(nameLookup.ContainsKey) .Select(id => nameLookup[id]) .ToList() }; diff --git a/Endpoints/StateEndpoints.cs b/Endpoints/StateEndpoints.cs index 34121db..d5b3869 100644 --- a/Endpoints/StateEndpoints.cs +++ b/Endpoints/StateEndpoints.cs @@ -15,7 +15,9 @@ public static class StateEndpoints group.MapGet("/state", async (HttpContext ctx, AppDbContext 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 state = await db.AppState.AsNoTracking().FirstAsync(); @@ -36,16 +38,27 @@ public static class StateEndpoints group.MapGet("/me", async (HttpContext ctx, AppDbContext 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); - 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); - if (player is null) return Results.Unauthorized(); - var isAdmin = await EndpointHelpers.IsAdmin(ctx, db); + if (player is null) + return Results.Unauthorized(); var next = NextPhase(player.CurrentPhase); var appState = await db.AppState.FirstAsync(); @@ -58,13 +71,19 @@ public static class StateEndpoints player.CurrentPhase = next; player.VotesFinal = false; // moving forward clears any prior finalize 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); - if (player is null) return Results.Unauthorized(); + if (player is null) + return Results.Unauthorized(); + var isAdmin = await EndpointHelpers.IsAdmin(ctx, db); if (!isAdmin) { @@ -75,12 +94,16 @@ public static class StateEndpoints player.VotesFinal = false; await db.SaveChangesAsync(); 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) => { - 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." }); } @@ -92,27 +115,32 @@ public static class StateEndpoints } var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db); - if (player is null) return Results.Unauthorized(); + if (player is null) + return Results.Unauthorized(); player.DisplayName = name; await db.SaveChangesAsync(); - return Results.Ok(new { player.Id, player.DisplayName }); + return Results.Ok(new + { + player.Id, + player.DisplayName + }); }); } private static Phase NextPhase(Phase current) => current switch { Phase.Suggest => Phase.Vote, - Phase.Reveal => Phase.Vote, // legacy safety - Phase.Vote => Phase.Results, - _ => Phase.Results + Phase.Reveal => Phase.Vote, // legacy safety + Phase.Vote => Phase.Results, + _ => Phase.Results }; private static Phase PrevPhase(Phase current) => current switch { Phase.Results => Phase.Vote, - Phase.Vote => Phase.Suggest, - Phase.Reveal => Phase.Suggest, // legacy safety - _ => Phase.Suggest + Phase.Vote => Phase.Suggest, + Phase.Reveal => Phase.Suggest, // legacy safety + _ => Phase.Suggest }; } diff --git a/Endpoints/SuggestEndpoints.cs b/Endpoints/SuggestEndpoints.cs index 1c5d8bb..0f99cfd 100644 --- a/Endpoints/SuggestEndpoints.cs +++ b/Endpoints/SuggestEndpoints.cs @@ -16,29 +16,26 @@ public static class SuggestEndpoints group.MapGet("/mine", async (HttpContext ctx, AppDbContext db) => { var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db); - if (player is null) return Results.Unauthorized(); - var mine = await db.Suggestions.AsNoTracking() - .Where(s => s.PlayerId == player.Id) - .Select(s => new - { - s.Id, - s.PlayerId, - s.Name, - s.Genre, - s.Description, - s.ScreenshotUrl, - s.YoutubeUrl, - s.GameUrl, - s.CreatedAt, - s.MinPlayers, - s.MaxPlayers, - s.ParentSuggestionId - }) - .ToListAsync(); + if (player is null) + return Results.Unauthorized(); - 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)); + var mine = await db.Suggestions.AsNoTracking().Where(s => s.PlayerId == player.Id).Select(s => new + { + s.Id, + s.PlayerId, + s.Name, + s.Genre, + s.Description, + s.ScreenshotUrl, + s.YoutubeUrl, + s.GameUrl, + s.CreatedAt, + s.MinPlayers, + s.MaxPlayers, + s.ParentSuggestionId + }).ToListAsync(); + + 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)); 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." }); } + 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)." }); } + if (!EndpointHelpers.IsValidHttpUrl(request.GameUrl)) return Results.BadRequest(new { error = "Game URL must be http or https." }); if (!EndpointHelpers.IsValidHttpUrl(request.YoutubeUrl)) @@ -67,11 +66,14 @@ public static class SuggestEndpoints return Results.BadRequest(new { error = playersError }); 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 usingJoker = phase == Phase.Vote && player.HasJoker; if (phase != Phase.Suggest && !usingJoker) return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase); + if (string.IsNullOrWhiteSpace(player.DisplayName)) { return Results.BadRequest(new { error = "Set a display name before submitting suggestions." }); @@ -107,13 +109,14 @@ public static class SuggestEndpoints await db.SaveChangesAsync(); 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) => { 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); if (!isAdmin) @@ -123,16 +126,12 @@ public static class SuggestEndpoints return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase); } - var suggestion = isAdmin - ? await db.Suggestions.FirstOrDefaultAsync(s => s.Id == id) - : await db.Suggestions.FirstOrDefaultAsync(s => s.Id == id && s.PlayerId == player.Id); + var suggestion = isAdmin ? await db.Suggestions.FirstOrDefaultAsync(s => s.Id == id) : await db.Suggestions.FirstOrDefaultAsync(s => s.Id == id && s.PlayerId == player.Id); if (suggestion == null) return Results.NotFound(new { error = "Suggestion not found." }); // Break any links that pointed at this suggestion - await db.Suggestions - .Where(s => s.ParentSuggestionId == suggestion.Id) - .ExecuteUpdateAsync(s => s.SetProperty(x => x.ParentSuggestionId, (int?)null)); + await db.Suggestions.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 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 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) { @@ -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." }); } + 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)." }); } + if (!EndpointHelpers.IsValidHttpUrl(request.GameUrl)) return Results.BadRequest(new { error = "Game URL must be http or https." }); if (!EndpointHelpers.IsValidHttpUrl(request.YoutubeUrl)) @@ -171,7 +173,8 @@ public static class SuggestEndpoints return Results.BadRequest(new { error = playersError }); 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) { @@ -238,14 +241,38 @@ public static class SuggestEndpoints group.MapGet("/all", async (HttpContext ctx, AppDbContext 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); if (phase < Phase.Vote) return EndpointHelpers.PhaseMismatch(Phase.Vote, phase); - var all = await db.Suggestions.AsNoTracking() - .Include(s => s.Player) - .Select(s => new + var all = await db.Suggestions.AsNoTracking().Include(s => s.Player).Select(s => new + { + s.Id, + s.Name, + s.Genre, + s.Description, + s.ScreenshotUrl, + s.YoutubeUrl, + s.GameUrl, + s.MinPlayers, + s.MaxPlayers, + Author = s.Player!.DisplayName, + s.CreatedAt, + s.ParentSuggestionId, + IsOwner = s.PlayerId == player.Id + }).ToListAsync(); + + var rootIndex = EndpointHelpers.BuildLinkRoots(all.Select(s => (s.Id, s.ParentSuggestionId))); + var nameLookup = all.ToDictionary(s => s.Id, s => s.Name); + + var ordered = all.OrderBy(s => s.CreatedAt).Select(s => + { + var linkedIds = EndpointHelpers.LinkedIdsFor(s.Id, rootIndex).Where(id => id != s.Id).ToList(); + + return new { s.Id, s.Name, @@ -256,45 +283,13 @@ public static class SuggestEndpoints s.GameUrl, s.MinPlayers, s.MaxPlayers, - Author = s.Player!.DisplayName, - s.CreatedAt, + s.Author, s.ParentSuggestionId, - IsOwner = s.PlayerId == player.Id - }) - .ToListAsync(); - - var rootIndex = EndpointHelpers.BuildLinkRoots(all.Select(s => (s.Id, s.ParentSuggestionId))); - var nameLookup = all.ToDictionary(s => s.Id, s => s.Name); - - var ordered = all - .OrderBy(s => s.CreatedAt) - .Select(s => - { - var linkedIds = EndpointHelpers.LinkedIdsFor(s.Id, rootIndex) - .Where(id => id != s.Id) - .ToList(); - - return new - { - s.Id, - s.Name, - s.Genre, - s.Description, - s.ScreenshotUrl, - s.YoutubeUrl, - s.GameUrl, - s.MinPlayers, - s.MaxPlayers, - s.Author, - s.ParentSuggestionId, - s.IsOwner, - LinkedIds = linkedIds, - LinkedTitles = linkedIds - .Where(id => nameLookup.ContainsKey(id)) - .Select(id => nameLookup[id]) - .ToList() - }; - }); + s.IsOwner, + LinkedIds = linkedIds, + LinkedTitles = linkedIds.Where(nameLookup.ContainsKey).Select(id => nameLookup[id]).ToList() + }; + }); return Results.Ok(ordered); }); @@ -303,15 +298,16 @@ public static class SuggestEndpoints private static bool ValidatePlayers(int? minPlayers, int? maxPlayers, out string? error) { 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."; 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."; return false; diff --git a/Endpoints/VoteEndpoints.cs b/Endpoints/VoteEndpoints.cs index 5f13577..e3bd772 100644 --- a/Endpoints/VoteEndpoints.cs +++ b/Endpoints/VoteEndpoints.cs @@ -11,21 +11,23 @@ public static class VoteEndpoints { public static void MapVoteEndpoints(this IEndpointRouteBuilder app) { - var group = app.MapGroup("/api/votes") - .RequireAuthorization() - .AddEndpointFilter(new PhaseRequirementFilter(Phase.Vote)); + var group = app.MapGroup("/api/votes").RequireAuthorization().AddEndpointFilter(new PhaseRequirementFilter(Phase.Vote)); group.MapGet("/mine", async (HttpContext ctx, AppDbContext 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); if (phase != Phase.Vote) return EndpointHelpers.PhaseMismatch(Phase.Vote, phase); - var votes = await db.Votes.AsNoTracking() - .Where(v => v.PlayerId == player.Id) - .Select(v => new { v.SuggestionId, v.Score }) - .ToListAsync(); + + var votes = await db.Votes.AsNoTracking().Where(v => v.PlayerId == player.Id).Select(v => new + { + v.SuggestionId, + v.Score + }).ToListAsync(); return Results.Ok(votes); }); @@ -36,9 +38,11 @@ public static class VoteEndpoints return Results.BadRequest(new { error = "Score must be between 0 and 10." }); var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db); - if (player is null) return Results.Unauthorized(); + if (player is null) + return Results.Unauthorized(); if (player.VotesFinal) return Results.BadRequest(new { error = "Votes are finalized. Unfinalize before changing scores." }); + var phase = await EndpointHelpers.GetPhase(db, player.Id); if (phase != Phase.Vote) return EndpointHelpers.PhaseMismatch(Phase.Vote, phase); @@ -46,19 +50,20 @@ public static class VoteEndpoints if (string.IsNullOrWhiteSpace(player.DisplayName)) return Results.BadRequest(new { error = "Set a display name before voting." }); - var linkMap = await db.Suggestions.AsNoTracking() - .Select(s => new { s.Id, s.ParentSuggestionId }) - .ToListAsync(); + var linkMap = await db.Suggestions.AsNoTracking().Select(s => new + { + s.Id, + s.ParentSuggestionId + }).ToListAsync(); var rootIndex = EndpointHelpers.BuildLinkRoots(linkMap.Select(s => (s.Id, s.ParentSuggestionId))); if (!rootIndex.ContainsKey(request.SuggestionId)) return Results.BadRequest(new { error = "Suggestion not found." }); + var linkedIds = EndpointHelpers.LinkedIdsFor(request.SuggestionId, rootIndex); if (linkedIds.Count == 0) linkedIds.Add(request.SuggestionId); - var existingVotes = await db.Votes - .Where(v => v.PlayerId == player.Id && linkedIds.Contains(v.SuggestionId)) - .ToListAsync(); + var existingVotes = await db.Votes.Where(v => v.PlayerId == player.Id && linkedIds.Contains(v.SuggestionId)).ToListAsync(); foreach (var suggestionId in linkedIds) { @@ -79,13 +84,19 @@ public static class VoteEndpoints } 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) => { 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); if (phase != Phase.Vote) return EndpointHelpers.PhaseMismatch(Phase.Vote, phase); diff --git a/GameList.Tests/AdminTests.cs b/GameList.Tests/AdminTests.cs index 757a247..cb01b02 100644 --- a/GameList.Tests/AdminTests.cs +++ b/GameList.Tests/AdminTests.cs @@ -12,7 +12,7 @@ public class AdminTests [Fact] 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(); await admin.RegisterAsync("admin", admin: true); await admin.PostAsJsonAsync("/api/me/phase/next", new { }); // move to Vote @@ -25,9 +25,17 @@ public class AdminTests var s1 = await p1.CreateSuggestionAsync("A"); 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 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 admin.PostAsJsonAsync("/api/votes/finalize", new { Final = true }); @@ -40,7 +48,7 @@ public class AdminTests [Fact] public async Task Grant_joker_only_in_vote_phase() { - using var factory = new TestWebApplicationFactory(); + await using var factory = new TestWebApplicationFactory(); var admin = factory.CreateClientWithCookies(); await admin.RegisterAsync("admin", admin: true); @@ -54,7 +62,7 @@ public class AdminTests [Fact] public async Task Delete_player_cascades_suggestions_and_votes() { - using var factory = new TestWebApplicationFactory(); + await using var factory = new TestWebApplicationFactory(); var admin = factory.CreateClientWithCookies(); await admin.RegisterAsync("admin", admin: true); @@ -63,23 +71,37 @@ public class AdminTests var suggestionId = await player.CreateSuggestionAsync("DeleteGame"); 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()}"); resp.EnsureSuccessStatusCode(); - await factory.WithDbContextAsync(async db => + await factory.WithDbContextAsync(db => { - Assert.Single(db.Players); // admin remains - Assert.Empty(db.Suggestions); - Assert.Empty(db.Votes); + try + { + Assert.Single(db.Players); // admin remains + + Assert.Empty(db.Suggestions); + + Assert.Empty(db.Votes); + return Task.CompletedTask; + } + catch (Exception exception) + { + return Task.FromException(exception); + } }); } [Fact] 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(); await admin.RegisterAsync("admin", admin: true); var player = factory.CreateClientWithCookies(); @@ -91,20 +113,32 @@ public class AdminTests await player.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); - 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(); - 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); } [Fact] public async Task Unlink_suggestions_clears_group_votes() { - using var factory = new TestWebApplicationFactory(); + await using var factory = new TestWebApplicationFactory(); var admin = factory.CreateClientWithCookies(); await admin.RegisterAsync("admin", admin: true); var player = factory.CreateClientWithCookies(); @@ -114,24 +148,41 @@ public class AdminTests var b = await player.CreateSuggestionAsync("Game B"); await player.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 }); resp.EnsureSuccessStatusCode(); - await factory.WithDbContextAsync(async db => + await factory.WithDbContextAsync(db => { - Assert.Empty(db.Votes); - Assert.All(db.Suggestions, s => Assert.Null(s.ParentSuggestionId)); + try + { + Assert.Empty(db.Votes); + + Assert.All(db.Suggestions, s => Assert.Null(s.ParentSuggestionId)); + return Task.CompletedTask; + } + catch (Exception exception) + { + return Task.FromException(exception); + } }); } [Fact] public async Task Reset_and_factory_reset_clear_state() { - using var factory = new TestWebApplicationFactory(); + await using var factory = new TestWebApplicationFactory(); var admin = factory.CreateClientWithCookies(); await admin.RegisterAsync("admin", admin: true); var player = factory.CreateClientWithCookies(); @@ -141,27 +192,46 @@ public class AdminTests var reset = await admin.PostAsJsonAsync("/api/admin/reset", new { }); reset.EnsureSuccessStatusCode(); - await factory.WithDbContextAsync(async db => + await factory.WithDbContextAsync(db => { - Assert.Empty(db.Suggestions); - Assert.Empty(db.Votes); - Assert.All(db.Players, p => Assert.Equal(Phase.Suggest, p.CurrentPhase)); + try + { + Assert.Empty(db.Suggestions); + + Assert.Empty(db.Votes); + + 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 { }); factoryReset.EnsureSuccessStatusCode(); - await factory.WithDbContextAsync(async db => + await factory.WithDbContextAsync(db => { - Assert.Empty(db.Players); - Assert.Single(db.AppState); + try + { + Assert.Empty(db.Players); + + Assert.Single(db.AppState); + return Task.CompletedTask; + } + catch (Exception exception) + { + return Task.FromException(exception); + } }); } [Fact] 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(); await admin.RegisterAsync("admin", admin: true); var player = factory.CreateClientWithCookies(); @@ -196,7 +266,7 @@ public class AdminTests [Fact] public async Task Vote_status_lists_waiting_players() { - using var factory = new TestWebApplicationFactory(); + await using var factory = new TestWebApplicationFactory(); var admin = factory.CreateClientWithCookies(); await admin.RegisterAsync("admin", admin: true); await admin.PostAsJsonAsync("/api/me/phase/next", new { }); @@ -208,7 +278,11 @@ public class AdminTests var s = await p1.CreateSuggestionAsync("Game"); await p1.PostAsJsonAsync("/api/me/phase/next", new { }); await p2.PostAsJsonAsync("/api/me/phase/next", new { }); - await p1.PostAsJsonAsync("/api/votes", new { SuggestionId = s, Score = 5 }); + await p1.PostAsJsonAsync("/api/votes", new + { + SuggestionId = s, + Score = 5 + }); await p1.PostAsJsonAsync("/api/votes/finalize", new { Final = true }); var status = await admin.GetFromJsonAsync("/api/admin/vote-status"); @@ -220,7 +294,7 @@ public class AdminTests [Fact] 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(); await admin.RegisterAsync("admin", admin: true); @@ -243,7 +317,7 @@ public class AdminTests [Fact] 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(); await admin.RegisterAsync("admin", admin: true); var player = factory.CreateClientWithCookies(); @@ -252,16 +326,28 @@ public class AdminTests var a = await player.CreateSuggestionAsync("A"); var b = await player.CreateSuggestionAsync("B"); - var beforeVotePhase = await admin.PostAsJsonAsync("/api/admin/link-suggestions", new { SourceSuggestionId = a, TargetSuggestionId = b }); + var beforeVotePhase = await admin.PostAsJsonAsync("/api/admin/link-suggestions", new + { + SourceSuggestionId = a, + TargetSuggestionId = b + }); Assert.Equal(HttpStatusCode.BadRequest, beforeVotePhase.StatusCode); await admin.PostAsJsonAsync("/api/me/phase/next", new { }); await player.PostAsJsonAsync("/api/me/phase/next", new { }); - await player.PostAsJsonAsync("/api/votes", new { SuggestionId = a, Score = 3 }); + await player.PostAsJsonAsync("/api/votes", new + { + SuggestionId = a, + Score = 3 + }); await player.PostAsJsonAsync("/api/votes/finalize", new { Final = true }); - var link = await admin.PostAsJsonAsync("/api/admin/link-suggestions", new { SourceSuggestionId = a, TargetSuggestionId = b }); + var link = await admin.PostAsJsonAsync("/api/admin/link-suggestions", new + { + SourceSuggestionId = a, + TargetSuggestionId = b + }); link.EnsureSuccessStatusCode(); await factory.WithDbContextAsync(async db => @@ -276,7 +362,7 @@ public class AdminTests [Fact] public async Task Unlink_not_found_returns_empty_payload() { - using var factory = new TestWebApplicationFactory(); + await using var factory = new TestWebApplicationFactory(); var admin = factory.CreateClientWithCookies(); await admin.RegisterAsync("admin", admin: true); await admin.PostAsJsonAsync("/api/me/phase/next", new { }); @@ -290,7 +376,7 @@ public class AdminTests [Fact] 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(); await admin.RegisterAsync("admin", admin: true); var p = factory.CreateClientWithCookies(); diff --git a/GameList.Tests/AuthTests.cs b/GameList.Tests/AuthTests.cs index bf64113..93e587d 100644 --- a/GameList.Tests/AuthTests.cs +++ b/GameList.Tests/AuthTests.cs @@ -1,7 +1,6 @@ using System.Net; using System.Net.Http.Json; using System.Text.Json; -using GameList.Data; using GameList.Infrastructure; using GameList.Tests.Support; using Microsoft.EntityFrameworkCore; @@ -13,7 +12,7 @@ public class AuthTests [Fact] 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 response = await client.PostAsJsonAsync("/api/auth/register", new @@ -25,8 +24,7 @@ public class AuthTests }); response.EnsureSuccessStatusCode(); - Assert.True(response.Headers.TryGetValues("Set-Cookie", out var cookies) && - cookies.Any(c => c.Contains(PlayerIdentityExtensions.PlayerCookieName))); + Assert.True(response.Headers.TryGetValues("Set-Cookie", out var cookies) && cookies.Any(c => c.Contains(PlayerIdentityExtensions.PlayerCookieName))); await factory.WithDbContextAsync(async db => { @@ -41,7 +39,7 @@ public class AuthTests [Fact] 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 tooLongUser = new string('u', 25); @@ -67,7 +65,7 @@ public class AuthTests [Fact] 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(); await client.RegisterAsync("loginfill"); @@ -93,7 +91,7 @@ public class AuthTests [Fact] 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 response = await client.RegisterAsync("adminuser", admin: true); @@ -106,7 +104,7 @@ public class AuthTests [Fact] public async Task Register_duplicate_username_returns_conflict() { - using var factory = new TestWebApplicationFactory(); + await using var factory = new TestWebApplicationFactory(); var client = factory.CreateClientWithCookies(); var first = await client.RegisterAsync("duplicate"); @@ -120,7 +118,7 @@ public class AuthTests [Fact] public async Task Login_with_wrong_password_returns_unauthorized() { - using var factory = new TestWebApplicationFactory(); + await using var factory = new TestWebApplicationFactory(); var client = factory.CreateClientWithCookies(); await client.RegisterAsync("player1"); @@ -133,20 +131,31 @@ public class AuthTests [Fact] public async Task Register_validates_required_fields() { - using var factory = new TestWebApplicationFactory(); + await using var factory = new TestWebApplicationFactory(); 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); - 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); } [Fact] public async Task Non_admin_cannot_access_admin_routes() { - using var factory = new TestWebApplicationFactory(); + await using var factory = new TestWebApplicationFactory(); var player = factory.CreateClientWithCookies(); await player.RegisterAsync("regular"); @@ -157,7 +166,7 @@ public class AuthTests [Fact] public async Task Admin_can_access_admin_routes() { - using var factory = new TestWebApplicationFactory(); + await using var factory = new TestWebApplicationFactory(); var admin = factory.CreateClientWithCookies(); await admin.RegisterAsync("adminuser", admin: true); @@ -168,7 +177,7 @@ public class AuthTests [Fact] public async Task Logout_clears_cookie() { - using var factory = new TestWebApplicationFactory(); + await using var factory = new TestWebApplicationFactory(); var client = factory.CreateClientWithCookies(); await client.RegisterAsync("logoutme"); diff --git a/GameList.Tests/FiltersTests.cs b/GameList.Tests/FiltersTests.cs index 9ff5606..8251e2a 100644 --- a/GameList.Tests/FiltersTests.cs +++ b/GameList.Tests/FiltersTests.cs @@ -1,13 +1,10 @@ -using System.IO; using System.Security.Claims; using GameList.Data; using GameList.Domain; using GameList.Infrastructure; using GameList.Tests.Support; using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.DependencyInjection.Extensions; namespace GameList.Tests; @@ -16,7 +13,7 @@ public class FiltersTests [Fact] public async Task Admin_only_filter_blocks_non_admin() { - using var factory = new TestWebApplicationFactory(); + await using var factory = new TestWebApplicationFactory(); var client = factory.CreateClientWithCookies(); await client.RegisterAsync("user"); @@ -29,7 +26,7 @@ public class FiltersTests [Fact] 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 filter = new PhaseRequirementFilter(Phase.Vote, allowAdminOverride: true); var called = false; @@ -46,7 +43,7 @@ public class FiltersTests [Fact] 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 filter = new PhaseOrJokerFilter(); var result = await filter.InvokeAsync(ctx, _ => ValueTask.FromResult(Results.Ok())); @@ -62,8 +59,8 @@ public class FiltersTests Id = Guid.NewGuid(), Username = $"user-{Guid.NewGuid():N}", NormalizedUsername = $"user-{Guid.NewGuid():N}", - PasswordHash = new byte[] { 1 }, - PasswordSalt = new byte[] { 1 }, + PasswordHash = [1], + PasswordSalt = [1], IsAdmin = isAdmin, CurrentPhase = phase, HasJoker = hasJoker, @@ -75,28 +72,20 @@ public class FiltersTests var ctx = new DefaultHttpContext { RequestServices = scope.ServiceProvider, - User = new ClaimsPrincipal(new ClaimsIdentity(new[] - { + User = new ClaimsPrincipal(new ClaimsIdentity([ new Claim(ClaimTypes.NameIdentifier, player.Id.ToString()), new Claim(ClaimTypes.Name, player.Username), new Claim(PlayerIdentityExtensions.AdminClaim, isAdmin ? "true" : "false") - }, "cookie")) + ], "cookie")) }; return new TestInvocationContext(ctx); } - private class TestInvocationContext : EndpointFilterInvocationContext + private class TestInvocationContext(DefaultHttpContext context) : EndpointFilterInvocationContext { - private readonly DefaultHttpContext _context; - - public TestInvocationContext(DefaultHttpContext context) - { - _context = context; - } - - public override HttpContext HttpContext => _context; - public override object?[] Arguments => Array.Empty(); + public override HttpContext HttpContext => context; + public override object?[] Arguments => []; public override T GetArgument(int index) => throw new NotImplementedException(); } @@ -104,11 +93,9 @@ public class FiltersTests { var http = new DefaultHttpContext { - RequestServices = new ServiceCollection() - .AddLogging() - .BuildServiceProvider() + RequestServices = new ServiceCollection().AddLogging().BuildServiceProvider(), + Response = { Body = new MemoryStream() } }; - http.Response.Body = new MemoryStream(); await ((IResult)result!).ExecuteAsync(http); Assert.Equal(statusCode, http.Response.StatusCode); } diff --git a/GameList.Tests/HelperTests.cs b/GameList.Tests/HelperTests.cs index da68bb1..4f8d487 100644 --- a/GameList.Tests/HelperTests.cs +++ b/GameList.Tests/HelperTests.cs @@ -1,5 +1,4 @@ using System.Net; -using System.Net.Http; using System.Net.Http.Headers; using System.Reflection; using GameList.Infrastructure; @@ -7,10 +6,7 @@ using GameList.Endpoints; using GameList.Tests.Support; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.FileProviders; -using System.Linq; -using Microsoft.AspNetCore.Mvc.Testing; using System.Text.Json; using System.Net.Http.Json; @@ -36,9 +32,8 @@ public class HelperTests File.WriteAllText(index, ""); var env = new FakeEnv { WebRootPath = webRoot }; - var method = typeof(Program).GetMethods(BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public) - .First(m => m.Name.Contains("UpdateIndexMetaBase")); - method.Invoke(null, new object?[] { env, "/pick" }); + var method = typeof(Program).GetMethods(BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public).First(m => m.Name.Contains("UpdateIndexMetaBase")); + method.Invoke(null, [env, "/pick"]); var text = File.ReadAllText(index); Assert.Contains("content=\"/pick\"", text); @@ -53,9 +48,8 @@ public class HelperTests File.WriteAllText(index, ""); var env = new FakeEnv { WebRootPath = webRoot }; - var method = typeof(Program).GetMethods(BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public) - .First(m => m.Name.Contains("UpdateIndexMetaBase")); - method.Invoke(null, new object?[] { env, "/pick" }); + var method = typeof(Program).GetMethods(BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public).First(m => m.Name.Contains("UpdateIndexMetaBase")); + method.Invoke(null, [env, "/pick"]); Assert.Equal("", File.ReadAllText(index)); } @@ -76,14 +70,15 @@ public class HelperTests if (req.Method == HttpMethod.Head) { var resp = new HttpResponseMessage(HttpStatusCode.OK); - resp.Content = new ByteArrayContent(Array.Empty()); + resp.Content = new ByteArrayContent([]); resp.Content.Headers.ContentType = new MediaTypeHeaderValue("image/png"); resp.Content.Headers.ContentLength = 100; return resp; } + return new HttpResponseMessage(HttpStatusCode.OK) { - Content = new ByteArrayContent(Array.Empty()) + Content = new ByteArrayContent([]) { Headers = { @@ -125,7 +120,7 @@ public class HelperTests handler.SetResponder(_ => { 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.ContentLength = 9; return resp; @@ -138,7 +133,7 @@ public class HelperTests [Fact] 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[2]); Assert.Equal(3, roots[3]); @@ -162,9 +157,9 @@ public class HelperTests { var parentMap = new Dictionary { - {1, 2}, - {2, 3}, - {3, 1} + { 1, 2 }, + { 2, 3 }, + { 3, 1 } }; var root = EndpointHelpers.FindRootId(1, parentMap); @@ -174,16 +169,13 @@ public class HelperTests [Fact] 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 => { app.UseGlobalExceptionLogging(); app.UseRouting(); - app.UseEndpoints(endpoints => - { - endpoints.MapGet("/boom", _ => throw new InvalidOperationException("boom")); - }); + app.UseEndpoints(endpoints => { endpoints.MapGet("/boom", _ => throw new InvalidOperationException("boom")); }); }); }); diff --git a/GameList.Tests/IdentityTests.cs b/GameList.Tests/IdentityTests.cs index 22abd7c..530d719 100644 --- a/GameList.Tests/IdentityTests.cs +++ b/GameList.Tests/IdentityTests.cs @@ -1,11 +1,9 @@ -using System.Security.Claims; using GameList.Domain; using GameList.Infrastructure; using GameList.Tests.Support; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; using Microsoft.AspNetCore.Authentication.Cookies; -using Microsoft.Extensions.DependencyInjection.Extensions; namespace GameList.Tests; @@ -14,16 +12,16 @@ public class IdentityTests [Fact] public async Task Sign_in_sets_claims_and_cookie() { - using var factory = new TestWebApplicationFactory(); - var ctx = BuildAuthContext(factory.Services); + await using var factory = new TestWebApplicationFactory(); + var ctx = BuildAuthContext(); var player = new Player { Id = Guid.NewGuid(), Username = "claimuser", NormalizedUsername = "claimuser", - PasswordHash = new byte[] { 1 }, - PasswordSalt = new byte[] { 1 }, + PasswordHash = [1], + PasswordSalt = [1], DisplayName = "Claim", IsAdmin = true }; @@ -31,15 +29,14 @@ public class IdentityTests await PlayerIdentityExtensions.SignInPlayerAsync(ctx, player); var cookies = ctx.Response.Headers["Set-Cookie"]; - Assert.NotNull(cookies); - Assert.Contains(cookies!, v => v.Contains(PlayerIdentityExtensions.PlayerCookieName)); + Assert.Contains(cookies, v => v != null && v.Contains(PlayerIdentityExtensions.PlayerCookieName)); } [Fact] public async Task Sign_out_clears_principal() { - using var factory = new TestWebApplicationFactory(); - var ctx = BuildAuthContext(factory.Services); + await using var factory = new TestWebApplicationFactory(); + var ctx = BuildAuthContext(); var player = new Player(); await PlayerIdentityExtensions.SignInPlayerAsync(ctx, player); @@ -48,15 +45,11 @@ public class IdentityTests Assert.False(ctx.User.Identity?.IsAuthenticated ?? false); } - private static DefaultHttpContext BuildAuthContext(IServiceProvider services) + private static DefaultHttpContext BuildAuthContext() { var serviceCollection = new ServiceCollection(); serviceCollection.AddSingleton(); - serviceCollection.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) - .AddCookie(options => - { - options.Cookie.Name = PlayerIdentityExtensions.PlayerCookieName; - }); + serviceCollection.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie(options => { options.Cookie.Name = PlayerIdentityExtensions.PlayerCookieName; }); serviceCollection.AddLogging(); var provider = serviceCollection.BuildServiceProvider(); diff --git a/GameList.Tests/MiddlewareTests.cs b/GameList.Tests/MiddlewareTests.cs index 40f534c..6a2d9b7 100644 --- a/GameList.Tests/MiddlewareTests.cs +++ b/GameList.Tests/MiddlewareTests.cs @@ -8,7 +8,7 @@ public class MiddlewareTests [Fact] public async Task Deleted_player_cookie_is_signed_out() { - using var factory = new TestWebApplicationFactory(); + await using var factory = new TestWebApplicationFactory(); var client = factory.CreateClientWithCookies(); await client.RegisterAsync("ghost"); @@ -29,7 +29,7 @@ public class MiddlewareTests [Fact] public async Task Existing_player_passes_through_middleware() { - using var factory = new TestWebApplicationFactory(); + await using var factory = new TestWebApplicationFactory(); var client = factory.CreateClientWithCookies(); await client.RegisterAsync("live"); diff --git a/GameList.Tests/ResultsTests.cs b/GameList.Tests/ResultsTests.cs index 4f563f0..2576aee 100644 --- a/GameList.Tests/ResultsTests.cs +++ b/GameList.Tests/ResultsTests.cs @@ -9,7 +9,7 @@ public class ResultsTests [Fact] public async Task Results_available_after_admin_unlocks() { - using var factory = new TestWebApplicationFactory(); + await using var factory = new TestWebApplicationFactory(); var admin = factory.CreateClientWithCookies(); await admin.RegisterAsync("admin", admin: true); @@ -18,7 +18,11 @@ public class ResultsTests var suggestionId = await player.CreateSuggestionAsync("ResultGame"); 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 admin.PostAsJsonAsync("/api/admin/results", new { resultsOpen = true }); @@ -36,7 +40,7 @@ public class ResultsTests [Fact] public async Task Results_locked_returns_error() { - using var factory = new TestWebApplicationFactory(); + await using var factory = new TestWebApplicationFactory(); var client = factory.CreateClientWithCookies(); await client.RegisterAsync("user"); await client.PostAsJsonAsync("/api/me/phase/next", new { }); @@ -47,7 +51,7 @@ public class ResultsTests [Fact] public async Task Results_require_results_phase_and_auth() { - using var factory = new TestWebApplicationFactory(); + await using var factory = new TestWebApplicationFactory(); var admin = factory.CreateClientWithCookies(); await admin.RegisterAsync("admin", admin: true); var player = factory.CreateClientWithCookies(); @@ -65,17 +69,21 @@ public class ResultsTests [Fact] public async Task Results_payload_contains_fields_and_ordering() { - using var factory = new TestWebApplicationFactory(); + await using var factory = new TestWebApplicationFactory(); var admin = factory.CreateClientWithCookies(); await admin.RegisterAsync("admin", admin: true); var player = factory.CreateClientWithCookies(); await player.RegisterAsync("player"); var s1 = await player.CreateSuggestionAsync("High"); - var s2 = await player.CreateSuggestionAsync("NoVotes"); + _ = await player.CreateSuggestionAsync("NoVotes"); await player.PostAsJsonAsync("/api/me/phase/next", new { }); - await player.PostAsJsonAsync("/api/votes", new { SuggestionId = s1, Score = 9 }); + await player.PostAsJsonAsync("/api/votes", new + { + SuggestionId = s1, + Score = 9 + }); await player.PostAsJsonAsync("/api/votes/finalize", new { Final = true }); await admin.PostAsJsonAsync("/api/admin/results", new { resultsOpen = true }); @@ -83,7 +91,7 @@ public class ResultsTests var results = await player.GetFromJsonAsync>("/api/results"); Assert.NotNull(results); - Assert.Equal(2, results!.Count); + Assert.Equal(2, results.Count); Assert.Equal("High", results[0].GetProperty("name").GetString()); Assert.Equal(9, (int)results[0].GetProperty("average").GetDouble()); Assert.Equal(1, results[0].GetProperty("count").GetInt32()); diff --git a/GameList.Tests/StateTests.cs b/GameList.Tests/StateTests.cs index 29cf66a..5ccae8f 100644 --- a/GameList.Tests/StateTests.cs +++ b/GameList.Tests/StateTests.cs @@ -14,7 +14,7 @@ public class StateTests [Fact] 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(); await client.RegisterAsync("payload"); await factory.WithDbContextAsync(async db => @@ -27,7 +27,7 @@ public class StateTests var state = await client.GetFromJsonAsync("/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.True(state.GetProperty("hasJoker").GetBoolean()); Assert.True(state.GetProperty("players").GetInt32() >= 1); @@ -38,7 +38,7 @@ public class StateTests [Fact] 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; await factory.WithDbContextAsync(async db => { @@ -47,8 +47,8 @@ public class StateTests Id = Guid.NewGuid(), Username = "legacy", NormalizedUsername = "legacy", - PasswordHash = new byte[] { 1 }, - PasswordSalt = new byte[] { 1 }, + PasswordHash = [1], + PasswordSalt = [1], DisplayName = "Legacy", CurrentPhase = Phase.Reveal, VotesFinal = true @@ -63,7 +63,7 @@ public class StateTests using (var scope = factory.Services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); - var phase = await GameList.Endpoints.EndpointHelpers.GetPhase(db, playerId); + var phase = await Endpoints.EndpointHelpers.GetPhase(db, playerId); Assert.Equal(Phase.Results, phase); } @@ -77,7 +77,7 @@ public class StateTests using (var scope = factory.Services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); - var phase = await GameList.Endpoints.EndpointHelpers.GetPhase(db, playerId); + var phase = await Endpoints.EndpointHelpers.GetPhase(db, playerId); var player = await db.Players.FindAsync(playerId); Assert.Equal(Phase.Vote, phase); Assert.False(player!.VotesFinal); @@ -87,7 +87,7 @@ public class StateTests [Fact] public async Task Phase_next_advances_and_clears_votesfinal() { - using var factory = new TestWebApplicationFactory(); + await using var factory = new TestWebApplicationFactory(); var client = factory.CreateClientWithCookies(); await client.RegisterAsync("advance"); @@ -111,13 +111,13 @@ public class StateTests toResults.EnsureSuccessStatusCode(); var me = await client.GetFromJsonAsync("/api/me"); 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] 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(); await admin.RegisterAsync("admin", admin: true); @@ -132,14 +132,14 @@ public class StateTests backToSuggest.EnsureSuccessStatusCode(); var me = await admin.GetFromJsonAsync("/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()); } [Fact] public async Task Name_endpoint_rejects_over_16_chars() { - using var factory = new TestWebApplicationFactory(); + await using var factory = new TestWebApplicationFactory(); var client = factory.CreateClientWithCookies(); await client.RegisterAsync("namelimit"); @@ -150,7 +150,7 @@ public class StateTests [Fact] public async Task Cannot_advance_to_results_when_locked() { - using var factory = new TestWebApplicationFactory(); + await using var factory = new TestWebApplicationFactory(); var client = factory.CreateClientWithCookies(); await client.RegisterAsync("player"); @@ -165,7 +165,7 @@ public class StateTests [Fact] 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(); await admin.RegisterAsync("admin", admin: true); @@ -177,14 +177,14 @@ public class StateTests var state = await player.GetFromJsonAsync("/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()); } [Fact] public async Task Name_endpoint_trims_and_rejects_blank() { - using var factory = new TestWebApplicationFactory(); + await using var factory = new TestWebApplicationFactory(); var client = factory.CreateClientWithCookies(); await client.RegisterAsync("nametest"); @@ -200,7 +200,7 @@ public class StateTests [Fact] public async Task Phase_prev_admin_only() { - using var factory = new TestWebApplicationFactory(); + await using var factory = new TestWebApplicationFactory(); var player = factory.CreateClientWithCookies(); await player.RegisterAsync("phase"); @@ -213,13 +213,13 @@ public class StateTests var back = await admin.PostAsJsonAsync("/api/me/phase/prev", new { }); back.EnsureSuccessStatusCode(); var me = await admin.GetFromJsonAsync("/api/me"); - Assert.Equal(Phase.Suggest.ToString(), me.GetProperty("currentPhase").GetString()); + Assert.Equal(nameof(Phase.Suggest), me.GetProperty("currentPhase").GetString()); } [Fact] 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 unauthorized = await anon.GetAsync("/api/state"); Assert.NotEqual(HttpStatusCode.OK, unauthorized.StatusCode); @@ -237,7 +237,7 @@ public class StateTests [Fact] public async Task Health_endpoint_ok() { - using var factory = new TestWebApplicationFactory(); + await using var factory = new TestWebApplicationFactory(); var resp = await factory.CreateClient().GetFromJsonAsync("/health"); Assert.Equal("ok", resp.GetProperty("status").GetString()); } @@ -245,7 +245,7 @@ public class StateTests [Fact] 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 => { var player = new Player @@ -253,8 +253,8 @@ public class StateTests Id = Guid.NewGuid(), Username = "phase", NormalizedUsername = "phase", - PasswordHash = new byte[] { 1 }, - PasswordSalt = new byte[] { 1 }, + PasswordHash = [1], + PasswordSalt = [1], DisplayName = "phase", CurrentPhase = Phase.Vote }; @@ -267,7 +267,7 @@ public class StateTests using var scope = factory.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var playerId = await db.Players.Select(p => p.Id).FirstAsync(); - var phase = await GameList.Endpoints.EndpointHelpers.GetPhase(db, playerId); + var phase = await Endpoints.EndpointHelpers.GetPhase(db, playerId); Assert.Equal(Phase.Results, phase); } diff --git a/GameList.Tests/SuggestionTests.cs b/GameList.Tests/SuggestionTests.cs index b20a226..1fdfa6d 100644 --- a/GameList.Tests/SuggestionTests.cs +++ b/GameList.Tests/SuggestionTests.cs @@ -2,7 +2,6 @@ using System.Net; using System.Net.Http.Json; using System.Text.Json; using GameList.Tests.Support; -using GameList.Domain; using Microsoft.EntityFrameworkCore; namespace GameList.Tests; @@ -12,7 +11,7 @@ public class SuggestionTests [Fact] public async Task Player_cannot_exceed_five_suggestions() { - using var factory = new TestWebApplicationFactory(); + await using var factory = new TestWebApplicationFactory(); var client = factory.CreateClientWithCookies(); await client.RegisterAsync("suggestor"); @@ -50,7 +49,7 @@ public class SuggestionTests [Fact] 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(); await client.RegisterAsync("validate"); @@ -84,7 +83,7 @@ public class SuggestionTests [Fact] 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(); await player.RegisterAsync("joker"); var other = factory.CreateClientWithCookies(); @@ -126,7 +125,7 @@ public class SuggestionTests [Fact] public async Task Admin_can_update_during_vote_phase() { - using var factory = new TestWebApplicationFactory(); + await using var factory = new TestWebApplicationFactory(); var admin = factory.CreateClientWithCookies(); await admin.RegisterAsync("admin", admin: true); @@ -152,7 +151,7 @@ public class SuggestionTests [Fact] 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(); await player.RegisterAsync("phase"); var id = await player.CreateSuggestionAsync("Lock"); @@ -180,7 +179,7 @@ public class SuggestionTests [Fact] 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(); await player.RegisterAsync("results"); var id = await player.CreateSuggestionAsync("Frozen"); @@ -217,7 +216,7 @@ public class SuggestionTests [Fact] public async Task Player_cannot_edit_other_players_suggestion() { - using var factory = new TestWebApplicationFactory(); + await using var factory = new TestWebApplicationFactory(); var owner = factory.CreateClientWithCookies(); await owner.RegisterAsync("owner"); var other = factory.CreateClientWithCookies(); @@ -243,7 +242,7 @@ public class SuggestionTests [Fact] 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(); await client.RegisterAsync("sixth"); @@ -327,7 +326,7 @@ public class SuggestionTests [Fact] 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)); var client = factory.CreateClientWithCookies(); @@ -351,7 +350,7 @@ public class SuggestionTests [Fact] public async Task Get_all_requires_vote_phase() { - using var factory = new TestWebApplicationFactory(); + await using var factory = new TestWebApplicationFactory(); var client = factory.CreateClientWithCookies(); await client.RegisterAsync("viewer"); @@ -362,13 +361,33 @@ public class SuggestionTests [Fact] public async Task Mine_returns_ordered_list() { - using var factory = new TestWebApplicationFactory(); + await using var factory = new TestWebApplicationFactory(); var client = factory.CreateClientWithCookies(); 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 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>("/api/suggestions/mine"); Assert.Equal("Second", mine![0].GetProperty("name").GetString()); @@ -377,7 +396,7 @@ public class SuggestionTests [Fact] 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(); await client.RegisterAsync("phasegate"); @@ -389,7 +408,17 @@ public class SuggestionTests 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); await factory.WithDbContextAsync(async db => @@ -399,37 +428,97 @@ public class SuggestionTests 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); } [Fact] 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(); 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); - 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); - 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); - 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); - 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); } [Fact] public async Task Trims_and_truncates_optional_fields() { - using var factory = new TestWebApplicationFactory(); + await using var factory = new TestWebApplicationFactory(); var client = factory.CreateClientWithCookies(); await client.RegisterAsync("trim"); @@ -460,7 +549,7 @@ public class SuggestionTests [Fact] public async Task Mine_excludes_other_players() { - using var factory = new TestWebApplicationFactory(); + await using var factory = new TestWebApplicationFactory(); var a = factory.CreateClientWithCookies(); await a.RegisterAsync("alice"); var b = factory.CreateClientWithCookies(); @@ -470,14 +559,15 @@ public class SuggestionTests await b.CreateSuggestionAsync("BobGame"); var mine = await a.GetFromJsonAsync>("/api/suggestions/mine"); - Assert.Single(mine!); + Assert.NotNull(mine); + Assert.Single(mine); Assert.Equal("AliceGame", mine[0].GetProperty("name").GetString()); } [Fact] public async Task All_returns_link_metadata_and_ordering() { - using var factory = new TestWebApplicationFactory(); + await using var factory = new TestWebApplicationFactory(); var client = factory.CreateClientWithCookies(); await client.RegisterAsync("owner"); @@ -507,7 +597,7 @@ public class SuggestionTests [Fact] 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(); await owner.RegisterAsync("deleter"); var other = factory.CreateClientWithCookies(); @@ -524,7 +614,11 @@ public class SuggestionTests await owner.PostAsJsonAsync("/api/me/phase/next", new { }); // Vote await other.PostAsJsonAsync("/api/me/phase/next", new { }); - await other.PostAsJsonAsync("/api/votes", new { SuggestionId = id, Score = 5 }); + await other.PostAsJsonAsync("/api/votes", new + { + SuggestionId = id, + Score = 5 + }); var blocked = await owner.DeleteAsync($"/api/suggestions/{id}"); Assert.Equal(HttpStatusCode.BadRequest, blocked.StatusCode); diff --git a/GameList.Tests/Support/StubHttpClientFactory.cs b/GameList.Tests/Support/StubHttpClientFactory.cs index 766fc6a..c609bcd 100644 --- a/GameList.Tests/Support/StubHttpClientFactory.cs +++ b/GameList.Tests/Support/StubHttpClientFactory.cs @@ -1,18 +1,9 @@ -using System.Net.Http; - 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) { - return new HttpClient(_handler, disposeHandler: false); + return new HttpClient(handler, disposeHandler: false); } } diff --git a/GameList.Tests/Support/StubHttpMessageHandler.cs b/GameList.Tests/Support/StubHttpMessageHandler.cs index dfc8c7d..56c7ee5 100644 --- a/GameList.Tests/Support/StubHttpMessageHandler.cs +++ b/GameList.Tests/Support/StubHttpMessageHandler.cs @@ -5,24 +5,16 @@ namespace GameList.Tests.Support; internal class StubHttpMessageHandler : HttpMessageHandler { - private Func _responder; - - public StubHttpMessageHandler() - { - _responder = DefaultResponder; - } + private Func _responder = DefaultResponder; public void SetResponder(Func responder) { - _responder = responder ?? DefaultResponder; + _responder = responder; } private static HttpResponseMessage DefaultResponder(HttpRequestMessage _) { - var response = new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new ByteArrayContent(Array.Empty()) - }; + var response = new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent([]) }; response.Content.Headers.ContentType = new MediaTypeHeaderValue("image/png"); response.Content.Headers.ContentLength = 0; return response; diff --git a/GameList.Tests/Support/TestClientExtensions.cs b/GameList.Tests/Support/TestClientExtensions.cs index bfcb3a4..276b65c 100644 --- a/GameList.Tests/Support/TestClientExtensions.cs +++ b/GameList.Tests/Support/TestClientExtensions.cs @@ -49,5 +49,4 @@ internal static class TestClientExtensions var me = await client.GetFromJsonAsync("/api/me"); return Guid.Parse(me.GetProperty("id").GetString()!); } - } diff --git a/GameList.Tests/Support/TestWebApplicationFactory.cs b/GameList.Tests/Support/TestWebApplicationFactory.cs index 34869c6..5a4e1c3 100644 --- a/GameList.Tests/Support/TestWebApplicationFactory.cs +++ b/GameList.Tests/Support/TestWebApplicationFactory.cs @@ -1,5 +1,3 @@ -using System.Collections.Generic; -using System.Linq; using GameList.Data; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; @@ -18,13 +16,7 @@ internal class TestWebApplicationFactory : WebApplicationFactory protected override void ConfigureWebHost(IWebHostBuilder builder) { builder.UseEnvironment("Development"); - builder.ConfigureAppConfiguration((context, config) => - { - config.AddInMemoryCollection(new Dictionary - { - ["ADMIN_PASSWORD"] = "admin-key" - }); - }); + builder.ConfigureAppConfiguration((_, config) => { config.AddInMemoryCollection(new Dictionary { ["ADMIN_PASSWORD"] = "admin-key" }); }); builder.ConfigureServices(services => { @@ -37,10 +29,7 @@ internal class TestWebApplicationFactory : WebApplicationFactory _connection = new SqliteConnection("Data Source=:memory:;Cache=Shared"); _connection.Open(); - services.AddDbContext(options => - { - options.UseSqlite(_connection); - }); + services.AddDbContext(options => { options.UseSqlite(_connection); }); services.AddSingleton(); services.AddSingleton(); diff --git a/GameList.Tests/VoteTests.cs b/GameList.Tests/VoteTests.cs index 8a3e8bc..e0517df 100644 --- a/GameList.Tests/VoteTests.cs +++ b/GameList.Tests/VoteTests.cs @@ -11,7 +11,7 @@ public class VoteTests [Fact] public async Task Finalizing_votes_blocks_further_changes() { - using var factory = new TestWebApplicationFactory(); + await using var factory = new TestWebApplicationFactory(); var client = factory.CreateClientWithCookies(); await client.RegisterAsync("voter"); @@ -19,13 +19,21 @@ public class VoteTests 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(); var finalize = await client.PostAsJsonAsync("/api/votes/finalize", new { Final = true }); 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); } @@ -33,45 +41,57 @@ public class VoteTests [Fact] public async Task Score_out_of_range_rejected() { - using var factory = new TestWebApplicationFactory(); + await using var factory = new TestWebApplicationFactory(); var client = factory.CreateClientWithCookies(); await client.RegisterAsync("score"); var id = await client.CreateSuggestionAsync("RangeGame"); 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); } [Fact] public async Task Negative_score_rejected() { - using var factory = new TestWebApplicationFactory(); + await using var factory = new TestWebApplicationFactory(); var client = factory.CreateClientWithCookies(); await client.RegisterAsync("negative"); var id = await client.CreateSuggestionAsync("RangeGame2"); await client.PostAsJsonAsync("/api/me/phase/next", new { }); - var resp = await client.PostAsJsonAsync("/api/votes", new { SuggestionId = id, Score = -1 }); + var resp = await client.PostAsJsonAsync("/api/votes", new + { + SuggestionId = id, + Score = -1 + }); Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode); } [Fact] public async Task Invalid_suggestion_id_rejected() { - using var factory = new TestWebApplicationFactory(); + await using var factory = new TestWebApplicationFactory(); var client = factory.CreateClientWithCookies(); await client.RegisterAsync("invalid"); 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); } [Fact] public async Task Votes_require_display_name() { - using var factory = new TestWebApplicationFactory(); + await using var factory = new TestWebApplicationFactory(); var client = factory.CreateClientWithCookies(); await client.RegisterAsync("anon"); var id = await client.CreateSuggestionAsync("NeedName"); @@ -84,14 +104,18 @@ public class VoteTests }); 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); } [Fact] public async Task Finalize_only_in_vote_phase() { - using var factory = new TestWebApplicationFactory(); + await using var factory = new TestWebApplicationFactory(); var client = factory.CreateClientWithCookies(); await client.RegisterAsync("phase"); @@ -102,12 +126,16 @@ public class VoteTests [Fact] public async Task Finalize_toggle_allows_unfinalize() { - using var factory = new TestWebApplicationFactory(); + await using var factory = new TestWebApplicationFactory(); var client = factory.CreateClientWithCookies(); await client.RegisterAsync("toggle"); var id = await client.CreateSuggestionAsync("Toggle"); await client.PostAsJsonAsync("/api/me/phase/next", new { }); - await client.PostAsJsonAsync("/api/votes", new { SuggestionId = id, Score = 5 }); + await client.PostAsJsonAsync("/api/votes", new + { + SuggestionId = id, + Score = 5 + }); var finalize = await client.PostAsJsonAsync("/api/votes/finalize", new { Final = true }); finalize.EnsureSuccessStatusCode(); @@ -121,7 +149,7 @@ public class VoteTests [Fact] 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(); await admin.RegisterAsync("admin", admin: true); await admin.PostAsJsonAsync("/api/me/phase/next", new { }); @@ -134,23 +162,31 @@ public class VoteTests 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(); - 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(); var mine = await player.GetFromJsonAsync>("/api/votes/mine"); Assert.NotNull(mine); - Assert.Equal(2, mine!.Count); + Assert.Equal(2, mine.Count); Assert.All(mine, v => Assert.Equal(9, v.Score)); } [Fact] public async Task Linked_votes_apply_across_chain() { - using var factory = new TestWebApplicationFactory(); + await using var factory = new TestWebApplicationFactory(); var admin = factory.CreateClientWithCookies(); await admin.RegisterAsync("admin", admin: true); await admin.PostAsJsonAsync("/api/me/phase/next", new { }); @@ -164,22 +200,34 @@ public class VoteTests await player.PostAsJsonAsync("/api/me/phase/next", new { }); - await admin.PostAsJsonAsync("/api/admin/link-suggestions", new { SourceSuggestionId = a, TargetSuggestionId = b }); - await admin.PostAsJsonAsync("/api/admin/link-suggestions", new { SourceSuggestionId = b, TargetSuggestionId = c }); + await admin.PostAsJsonAsync("/api/admin/link-suggestions", new + { + SourceSuggestionId = a, + TargetSuggestionId = b + }); + await admin.PostAsJsonAsync("/api/admin/link-suggestions", new + { + SourceSuggestionId = b, + TargetSuggestionId = c + }); - var vote = await player.PostAsJsonAsync("/api/votes", new { SuggestionId = c, Score = 6 }); + var vote = await player.PostAsJsonAsync("/api/votes", new + { + SuggestionId = c, + Score = 6 + }); vote.EnsureSuccessStatusCode(); var mine = await player.GetFromJsonAsync>("/api/votes/mine"); Assert.NotNull(mine); - Assert.Equal(3, mine!.Count); + Assert.Equal(3, mine.Count); Assert.All(mine, v => Assert.Equal(6, v.Score)); } [Fact] public async Task Votes_mine_requires_vote_phase_and_auth() { - using var factory = new TestWebApplicationFactory(); + await using var factory = new TestWebApplicationFactory(); var anon = factory.CreateClient(); var unauth = await anon.GetAsync("/api/votes/mine"); Assert.Equal(HttpStatusCode.Unauthorized, unauth.StatusCode); @@ -190,5 +238,7 @@ public class VoteTests Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode); } + // ReSharper disable once NotAccessedPositionalProperty.Local + // ReSharper disable once ClassNeverInstantiated.Local private record VoteRecord(int SuggestionId, int Score); } diff --git a/Infrastructure/AdminOnlyFilter.cs b/Infrastructure/AdminOnlyFilter.cs index be76265..0d30132 100644 --- a/Infrastructure/AdminOnlyFilter.cs +++ b/Infrastructure/AdminOnlyFilter.cs @@ -1,6 +1,5 @@ using GameList.Data; using GameList.Endpoints; -using Microsoft.AspNetCore.Authorization; namespace GameList.Infrastructure; diff --git a/Infrastructure/EnsurePlayerExistsMiddleware.cs b/Infrastructure/EnsurePlayerExistsMiddleware.cs index ad98321..ef19a7e 100644 --- a/Infrastructure/EnsurePlayerExistsMiddleware.cs +++ b/Infrastructure/EnsurePlayerExistsMiddleware.cs @@ -3,18 +3,11 @@ using Microsoft.AspNetCore.Authentication; 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) { - if (context.User?.Identity?.IsAuthenticated == true) + if (context.User.Identity?.IsAuthenticated == true) { 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) @@ -25,6 +18,6 @@ public class EnsurePlayerExistsMiddleware } } - await _next(context); + await next(context); } } diff --git a/Infrastructure/PasswordHasher.cs b/Infrastructure/PasswordHasher.cs index 45b53fb..1367566 100644 --- a/Infrastructure/PasswordHasher.cs +++ b/Infrastructure/PasswordHasher.cs @@ -21,18 +21,15 @@ public static class PasswordHasher 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); return CryptographicOperations.FixedTimeEquals(computed, hash); } private static byte[] PBKDF2(string password, byte[] salt) { - return Rfc2898DeriveBytes.Pbkdf2( - Encoding.UTF8.GetBytes(password), - salt, - Iterations, - HashAlgorithmName.SHA256, - KeySize); + return Rfc2898DeriveBytes.Pbkdf2(Encoding.UTF8.GetBytes(password), salt, Iterations, HashAlgorithmName.SHA256, KeySize); } } diff --git a/Infrastructure/PhaseOrJokerFilter.cs b/Infrastructure/PhaseOrJokerFilter.cs index f26d0e2..f5324cc 100644 --- a/Infrastructure/PhaseOrJokerFilter.cs +++ b/Infrastructure/PhaseOrJokerFilter.cs @@ -15,7 +15,8 @@ public class PhaseOrJokerFilter : IEndpointFilter var httpContext = context.HttpContext; var db = httpContext.RequestServices.GetRequiredService(); 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 allow = phase == Phase.Suggest || (phase == Phase.Vote && player.HasJoker); diff --git a/Infrastructure/PhaseRequirementFilter.cs b/Infrastructure/PhaseRequirementFilter.cs index 6b8e726..8ecc1f5 100644 --- a/Infrastructure/PhaseRequirementFilter.cs +++ b/Infrastructure/PhaseRequirementFilter.cs @@ -4,28 +4,20 @@ using GameList.Endpoints; 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 InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) { var httpContext = context.HttpContext; var db = httpContext.RequestServices.GetRequiredService(); 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); - 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); diff --git a/Infrastructure/PlayerIdentityExtensions.cs b/Infrastructure/PlayerIdentityExtensions.cs index 51e3192..6e3a07d 100644 --- a/Infrastructure/PlayerIdentityExtensions.cs +++ b/Infrastructure/PlayerIdentityExtensions.cs @@ -27,8 +27,7 @@ public static class PlayerIdentityExtensions await ctx.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal); } - public static Task SignOutPlayerAsync(HttpContext ctx) - => ctx.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); + public static Task SignOutPlayerAsync(HttpContext ctx) => ctx.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); public static IApplicationBuilder UseGlobalExceptionLogging(this IApplicationBuilder app) { diff --git a/Program.cs b/Program.cs index 2eb91c9..0e9058d 100644 --- a/Program.cs +++ b/Program.cs @@ -6,21 +6,19 @@ using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; using System.Text.Json.Serialization; var builder = WebApplication.CreateBuilder(args); var dataDirectory = Path.Combine(builder.Environment.ContentRootPath, "App_Data"); Directory.CreateDirectory(dataDirectory); + var dataProtectionDirectory = Path.Combine(dataDirectory, "keys"); Directory.CreateDirectory(dataProtectionDirectory); var configuredConnection = builder.Configuration.GetConnectionString("Default"); var dbPath = Path.Combine(dataDirectory, "gamelist.db"); -var connectionBuilder = new SqliteConnectionStringBuilder(string.IsNullOrWhiteSpace(configuredConnection) - ? $"Data Source={dbPath}" - : configuredConnection); +var connectionBuilder = new SqliteConnectionStringBuilder(string.IsNullOrWhiteSpace(configuredConnection) ? $"Data Source={dbPath}" : configuredConnection); if (connectionBuilder.DataSource.Contains("App_Data", StringComparison.OrdinalIgnoreCase)) { @@ -34,56 +32,41 @@ else if (!Path.IsPathRooted(connectionBuilder.DataSource)) var connectionString = connectionBuilder.ToString(); -builder.Services.AddDbContext(options => - options.UseSqlite(connectionString)); +builder.Services.AddDbContext(options => options.UseSqlite(connectionString)); -builder.Services.ConfigureHttpJsonOptions(options => -{ - options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()); -}); +builder.Services.ConfigureHttpJsonOptions(options => { options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()); }); builder.Services.AddHttpClient(); -builder.Services.AddDataProtection() - .PersistKeysToFileSystem(new DirectoryInfo(dataProtectionDirectory)); +builder.Services.AddDataProtection().PersistKeysToFileSystem(new DirectoryInfo(dataProtectionDirectory)); -builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) - .AddCookie(options => - { - options.Cookie.Name = PlayerIdentityExtensions.PlayerCookieName; - options.Cookie.HttpOnly = true; - options.Cookie.SameSite = SameSiteMode.Strict; - options.Cookie.SecurePolicy = builder.Environment.IsDevelopment() - ? CookieSecurePolicy.SameAsRequest - : CookieSecurePolicy.Always; - options.SlidingExpiration = true; - options.ExpireTimeSpan = TimeSpan.FromDays(30); - options.Events = new CookieAuthenticationEvents - { - OnRedirectToLogin = ctx => - { - ctx.Response.StatusCode = StatusCodes.Status401Unauthorized; - return Task.CompletedTask; - }, - OnRedirectToAccessDenied = ctx => - { - ctx.Response.StatusCode = StatusCodes.Status401Unauthorized; - return Task.CompletedTask; - } - }; - }); - -builder.Services.AddAuthorization(options => +builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie(options => { - options.AddPolicy(PlayerIdentityExtensions.AdminPolicy, policy => - policy.RequireClaim(PlayerIdentityExtensions.AdminClaim, "true")); + options.Cookie.Name = PlayerIdentityExtensions.PlayerCookieName; + options.Cookie.HttpOnly = true; + options.Cookie.SameSite = SameSiteMode.Strict; + options.Cookie.SecurePolicy = builder.Environment.IsDevelopment() ? CookieSecurePolicy.SameAsRequest : CookieSecurePolicy.Always; + options.SlidingExpiration = true; + options.ExpireTimeSpan = TimeSpan.FromDays(30); + options.Events = new CookieAuthenticationEvents + { + OnRedirectToLogin = ctx => + { + ctx.Response.StatusCode = StatusCodes.Status401Unauthorized; + return Task.CompletedTask; + }, + OnRedirectToAccessDenied = ctx => + { + ctx.Response.StatusCode = StatusCodes.Status401Unauthorized; + return Task.CompletedTask; + } + }; }); +builder.Services.AddAuthorization(options => { options.AddPolicy(PlayerIdentityExtensions.AdminPolicy, policy => policy.RequireClaim(PlayerIdentityExtensions.AdminClaim, "true")); }); + var app = builder.Build(); -app.UseForwardedHeaders(new ForwardedHeadersOptions -{ - ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedHost -}); +app.UseForwardedHeaders(new ForwardedHeadersOptions { ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedHost }); var basePath = builder.Configuration["BasePath"]; if (!string.IsNullOrWhiteSpace(basePath)) @@ -122,22 +105,29 @@ static void UpdateIndexMetaBase(IWebHostEnvironment env, string basePath) try { var indexPath = Path.Combine(env.WebRootPath, "index.html"); - if (!File.Exists(indexPath)) return; + if (!File.Exists(indexPath)) + return; var text = File.ReadAllText(indexPath); var marker = "name=\"app-base\""; var contentKey = "content=\""; var markerIndex = text.IndexOf(marker, StringComparison.OrdinalIgnoreCase); - if (markerIndex < 0) return; + if (markerIndex < 0) + return; + var contentIndex = text.IndexOf(contentKey, markerIndex, StringComparison.OrdinalIgnoreCase); - if (contentIndex < 0) return; + if (contentIndex < 0) + return; + var valueStart = contentIndex + contentKey.Length; var valueEnd = text.IndexOf('"', valueStart); - if (valueEnd < 0) return; + if (valueEnd < 0) + return; var current = text[valueStart..valueEnd]; var normalized = basePath.EndsWith('/') ? basePath.TrimEnd('/') : basePath; - if (current == normalized) return; + if (current == normalized) + return; var updated = text[..valueStart] + normalized + text[valueEnd..]; File.WriteAllText(indexPath, updated); @@ -148,4 +138,4 @@ static void UpdateIndexMetaBase(IWebHostEnvironment env, string basePath) } } -public partial class Program { } +public partial class Program;