From 1bb34c51bf3a3c26f33f4165ec9cfd52669558b6 Mon Sep 17 00:00:00 2001 From: Frank Tovar Date: Sun, 8 Feb 2026 20:44:44 +0100 Subject: [PATCH] Removed symbol requirement for password, fix formatting. --- API.md | 2 +- Contracts/Dtos.cs | 1 + Contracts/Responses.cs | 57 ++++--------------------------- Endpoints/AdminEndpoints.cs | 1 - Endpoints/AdminWorkflowService.cs | 29 +++------------- Endpoints/AuthEndpoints.cs | 5 ++- Endpoints/AuthValidator.cs | 12 ++----- Endpoints/StateWorkflowService.cs | 22 ++---------- GameList.Tests/AdminTests.cs | 19 +++-------- GameList.Tests/AuthTests.cs | 2 +- 10 files changed, 25 insertions(+), 125 deletions(-) diff --git a/API.md b/API.md index 8e41245..c70d4a3 100644 --- a/API.md +++ b/API.md @@ -9,7 +9,7 @@ GET /api/auth/options — `{ ownerExists }` for registration UX (hide admin-key POST /api/auth/login POST /api/auth/logout Display names are set during registration and are immutable afterward. -Passwords must be 8-128 chars and contain uppercase, lowercase, number, and symbol. +Passwords must be 8-128 chars and contain uppercase, lowercase and number. The first account created with a valid `adminKey` becomes both `IsAdmin=true` and `IsOwner=true`. ## State (requires auth) diff --git a/Contracts/Dtos.cs b/Contracts/Dtos.cs index 21268d9..199424e 100644 --- a/Contracts/Dtos.cs +++ b/Contracts/Dtos.cs @@ -21,6 +21,7 @@ public record UnlinkSuggestionsRequest(int SuggestionId); public record GrantJokerRequest(Guid PlayerId); public record SetPlayerPhaseRequest(Guid PlayerId, Phase Phase); + public record SetPlayerAdminRequest(Guid PlayerId, bool IsAdmin); public record AdminPasswordRequest(string Password); diff --git a/Contracts/Responses.cs b/Contracts/Responses.cs index 254ae03..fb9b670 100644 --- a/Contracts/Responses.cs +++ b/Contracts/Responses.cs @@ -4,17 +4,7 @@ namespace GameList.Contracts; public record SuggestionCreatedResponse(int Id); -public record SuggestionUpdatedResponse( - int Id, - string Name, - string? Genre, - string? Description, - string? ScreenshotUrl, - string? YoutubeUrl, - string? GameUrl, - int? MinPlayers, - int? MaxPlayers -); +public record SuggestionUpdatedResponse(int Id, string Name, string? Genre, string? Description, string? ScreenshotUrl, string? YoutubeUrl, string? GameUrl, int? MinPlayers, int? MaxPlayers); public record VoteUpsertResponse(IReadOnlyList SuggestionIds, int Score); @@ -25,6 +15,7 @@ public record AdminResultsStateResponse(bool ResultsOpen, DateTimeOffset Updated public record AdminGrantJokerResponse(Guid Id, bool HasJoker); public record AdminSetPlayerPhaseResponse(Guid PlayerId, Phase CurrentPhase, bool VotesFinal); + public record AdminSetPlayerAdminResponse(Guid PlayerId, bool IsAdmin); public record AdminDeletePlayerResponse(Guid DeletedPlayerId); @@ -37,50 +28,14 @@ public record AdminResetStateResponse(Phase Phase, bool ResultsOpen, DateTimeOff public record VoteStatusResponse(IReadOnlyList Voters, bool Ready, IReadOnlyList Waiting); -public record ResultItemDto( - int Id, - string Name, - string? Author, - int? MinPlayers, - int? MaxPlayers, - int Total, - int Count, - double Average, - IReadOnlyList Votes, - int? MyVote, - string? ScreenshotUrl, - string? YoutubeUrl, - string? GameUrl, - string? Description, - string? Genre, - int? ParentSuggestionId, - IReadOnlyList LinkedIds, - IReadOnlyList LinkedTitles -); +public record ResultItemDto(int Id, string Name, string? Author, int? MinPlayers, int? MaxPlayers, int Total, int Count, double Average, IReadOnlyList Votes, int? MyVote, string? ScreenshotUrl, string? YoutubeUrl, string? GameUrl, string? Description, string? Genre, int? ParentSuggestionId, IReadOnlyList LinkedIds, IReadOnlyList LinkedTitles); public record AuthSessionResponse(Guid Id, string Username, string? DisplayName, bool IsAdmin); + public record AuthOptionsResponse(bool OwnerExists); -public record StateSummaryResponse( - Phase CurrentPhase, - bool VotesFinal, - bool HasJoker, - bool ResultsOpen, - DateTimeOffset UpdatedAt, - int Players, - int Suggestions, - int Votes -); +public record StateSummaryResponse(Phase CurrentPhase, bool VotesFinal, bool HasJoker, bool ResultsOpen, DateTimeOffset UpdatedAt, int Players, int Suggestions, int Votes); -public record MeResponse( - Guid Id, - string Username, - string? DisplayName, - bool IsAdmin, - bool IsOwner, - Phase CurrentPhase, - bool VotesFinal, - bool HasJoker -); +public record MeResponse(Guid Id, string Username, string? DisplayName, bool IsAdmin, bool IsOwner, Phase CurrentPhase, bool VotesFinal, bool HasJoker); public record PhaseTransitionResponse(Phase CurrentPhase, bool ResultsOpen); diff --git a/Endpoints/AdminEndpoints.cs b/Endpoints/AdminEndpoints.cs index 06e387f..7aebdcc 100644 --- a/Endpoints/AdminEndpoints.cs +++ b/Endpoints/AdminEndpoints.cs @@ -2,7 +2,6 @@ using GameList.Data; using GameList.Contracts; using Microsoft.AspNetCore.Mvc; using GameList.Infrastructure; -using Microsoft.AspNetCore.RateLimiting; namespace GameList.Endpoints; diff --git a/Endpoints/AdminWorkflowService.cs b/Endpoints/AdminWorkflowService.cs index 7b141c6..dac48e9 100644 --- a/Endpoints/AdminWorkflowService.cs +++ b/Endpoints/AdminWorkflowService.cs @@ -22,12 +22,8 @@ internal sealed class AdminWorkflowService(AppDbContext db) } else { - await db.Players - .Where(p => p.Suggestions.Any()) - .ExecuteUpdateAsync(p => p.SetProperty(x => x.CurrentPhase, Phase.Vote).SetProperty(x => x.VotesFinal, false)); - await db.Players - .Where(p => !p.Suggestions.Any()) - .ExecuteUpdateAsync(p => p.SetProperty(x => x.CurrentPhase, Phase.Suggest).SetProperty(x => x.VotesFinal, false)); + await db.Players.Where(p => p.Suggestions.Any()).ExecuteUpdateAsync(p => p.SetProperty(x => x.CurrentPhase, Phase.Vote).SetProperty(x => x.VotesFinal, false)); + await db.Players.Where(p => !p.Suggestions.Any()).ExecuteUpdateAsync(p => p.SetProperty(x => x.CurrentPhase, Phase.Suggest).SetProperty(x => x.VotesFinal, false)); } await db.SaveChangesAsync(); @@ -38,22 +34,7 @@ internal sealed class AdminWorkflowService(AppDbContext db) public async Task GetVoteStatusAsync() { - 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.IsAdmin, - p.IsOwner, - 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.IsAdmin, p.IsOwner, 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; @@ -131,9 +112,7 @@ internal sealed class AdminWorkflowService(AppDbContext db) var suggestionIds = player.Suggestions.Select(s => s.Id).ToList(); if (suggestionIds.Count > 0) { - 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)); await db.Votes.Where(v => suggestionIds.Contains(v.SuggestionId)).ExecuteDeleteAsync(); } diff --git a/Endpoints/AuthEndpoints.cs b/Endpoints/AuthEndpoints.cs index e2c124f..9c54d1e 100644 --- a/Endpoints/AuthEndpoints.cs +++ b/Endpoints/AuthEndpoints.cs @@ -3,7 +3,6 @@ using GameList.Data; using GameList.Domain; using GameList.Infrastructure; using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.RateLimiting; using Microsoft.EntityFrameworkCore; namespace GameList.Endpoints; @@ -24,7 +23,7 @@ public static class AuthEndpoints { if (!AuthValidator.TryValidateRegistration(request, out var validated, out var registrationError)) { - authAttemptMonitor.RecordFailure(ctx, "auth-register", request.Username?.Trim() ?? "unknown", "validation-failed"); + authAttemptMonitor.RecordFailure(ctx, "auth-register", request.Username.Trim(), "validation-failed"); return EndpointHelpers.BadRequestError(registrationError); } @@ -88,7 +87,7 @@ public static class AuthEndpoints { if (!AuthValidator.TryValidateLogin(request, out _, out var normalizedUsername, out var loginError)) { - authAttemptMonitor.RecordFailure(ctx, "auth-login", request.Username?.Trim() ?? "unknown", "validation-failed"); + authAttemptMonitor.RecordFailure(ctx, "auth-login", request.Username.Trim(), "validation-failed"); return EndpointHelpers.BadRequestError(loginError); } diff --git a/Endpoints/AuthValidator.cs b/Endpoints/AuthValidator.cs index e617d80..6d3dd09 100644 --- a/Endpoints/AuthValidator.cs +++ b/Endpoints/AuthValidator.cs @@ -38,11 +38,10 @@ internal static class AuthValidator var hasUpper = password.Any(char.IsUpper); var hasLower = password.Any(char.IsLower); var hasDigit = password.Any(char.IsDigit); - var hasSymbol = password.Any(ch => !char.IsLetterOrDigit(ch)); - if (!hasUpper || !hasLower || !hasDigit || !hasSymbol) + if (!hasUpper || !hasLower || !hasDigit) { validated = default; - error = "Password must include uppercase, lowercase, number, and symbol."; + error = "Password must include at least one uppercase and one lowercase characters and and digit."; return false; } @@ -95,10 +94,5 @@ internal static class AuthValidator return true; } - public readonly record struct ValidatedRegistration( - string Username, - string NormalizedUsername, - string DisplayName, - string? AdminKey - ); + public readonly record struct ValidatedRegistration(string Username, string NormalizedUsername, string DisplayName, string? AdminKey); } diff --git a/Endpoints/StateWorkflowService.cs b/Endpoints/StateWorkflowService.cs index e3c005d..8c4512f 100644 --- a/Endpoints/StateWorkflowService.cs +++ b/Endpoints/StateWorkflowService.cs @@ -11,16 +11,7 @@ internal sealed class StateWorkflowService(AppDbContext db) { var state = await db.AppState.AsNoTracking().SingleAsync(); var phase = EndpointHelpers.GetCurrentPhase(player.CurrentPhase, state.ResultsOpen); - var summary = new StateSummaryResponse( - phase, - player.VotesFinal, - player.HasJoker, - state.ResultsOpen, - state.UpdatedAt, - await db.Players.CountAsync(), - await db.Suggestions.CountAsync(), - await db.Votes.CountAsync() - ); + var summary = new StateSummaryResponse(phase, player.VotesFinal, player.HasJoker, state.ResultsOpen, state.UpdatedAt, await db.Players.CountAsync(), await db.Suggestions.CountAsync(), await db.Votes.CountAsync()); return Results.Ok(summary); } @@ -28,16 +19,7 @@ internal sealed class StateWorkflowService(AppDbContext db) { var state = await db.AppState.AsNoTracking().SingleAsync(); var phase = EndpointHelpers.GetCurrentPhase(player.CurrentPhase, state.ResultsOpen); - return Results.Ok(new MeResponse( - player.Id, - player.Username, - player.DisplayName, - player.IsAdmin, - player.IsOwner, - phase, - player.VotesFinal, - player.HasJoker - )); + return Results.Ok(new MeResponse(player.Id, player.Username, player.DisplayName, player.IsAdmin, player.IsOwner, phase, player.VotesFinal, player.HasJoker)); } public async Task NextPhaseAsync(Player player) diff --git a/GameList.Tests/AdminTests.cs b/GameList.Tests/AdminTests.cs index 662505c..4725c49 100644 --- a/GameList.Tests/AdminTests.cs +++ b/GameList.Tests/AdminTests.cs @@ -106,7 +106,7 @@ public class AdminTests var grant = await owner.PostAsJsonAsync("/api/admin/player-admin", new { - playerId = playerId, + playerId, isAdmin = true }); grant.EnsureSuccessStatusCode(); @@ -120,7 +120,7 @@ public class AdminTests var revoke = await owner.PostAsJsonAsync("/api/admin/player-admin", new { - playerId = playerId, + playerId, isAdmin = false }); revoke.EnsureSuccessStatusCode(); @@ -147,10 +147,7 @@ public class AdminTests }); Assert.Equal(HttpStatusCode.BadRequest, toggleOwner.StatusCode); - var deleteOwner = await owner.SendAsync(new HttpRequestMessage(HttpMethod.Delete, $"/api/admin/players/{ownerId}") - { - Content = JsonContent.Create(new { password = AdminPassword }) - }); + var deleteOwner = await owner.SendAsync(new HttpRequestMessage(HttpMethod.Delete, $"/api/admin/players/{ownerId}") { Content = JsonContent.Create(new { password = AdminPassword }) }); Assert.Equal(HttpStatusCode.BadRequest, deleteOwner.StatusCode); } @@ -212,10 +209,7 @@ public class AdminTests Score = 8 }); - var resp = await admin.SendAsync(new HttpRequestMessage(HttpMethod.Delete, $"/api/admin/players/{await player.GetProfileIdAsync()}") - { - Content = JsonContent.Create(new { password = AdminPassword }) - }); + var resp = await admin.SendAsync(new HttpRequestMessage(HttpMethod.Delete, $"/api/admin/players/{await player.GetProfileIdAsync()}") { Content = JsonContent.Create(new { password = AdminPassword }) }); resp.EnsureSuccessStatusCode(); await factory.WithDbContextAsync(db => @@ -630,10 +624,7 @@ public class AdminTests var playerId = await factory.WithDbContextAsync(async db => await db.Players.Where(p => p.Username == "target").Select(p => p.Id).SingleAsync()); - var deleteWrongPassword = await admin.SendAsync(new HttpRequestMessage(HttpMethod.Delete, $"/api/admin/players/{playerId}") - { - Content = JsonContent.Create(new { password = "wrong" }) - }); + var deleteWrongPassword = await admin.SendAsync(new HttpRequestMessage(HttpMethod.Delete, $"/api/admin/players/{playerId}") { Content = JsonContent.Create(new { password = "wrong" }) }); Assert.Equal(HttpStatusCode.BadRequest, deleteWrongPassword.StatusCode); } } diff --git a/GameList.Tests/AuthTests.cs b/GameList.Tests/AuthTests.cs index 7a2a8d3..46283f3 100644 --- a/GameList.Tests/AuthTests.cs +++ b/GameList.Tests/AuthTests.cs @@ -77,7 +77,7 @@ public class AuthTests Assert.Equal(HttpStatusCode.BadRequest, weak.StatusCode); var json = await weak.Content.ReadFromJsonAsync(); - Assert.Equal("Password must include uppercase, lowercase, number, and symbol.", json.GetProperty("error").GetString()); + Assert.Equal("Password must include at least one uppercase and one lowercase characters and and digit.", json.GetProperty("error").GetString()); } [Fact]