Removed symbol requirement for password, fix formatting.

This commit is contained in:
2026-02-08 20:44:44 +01:00
parent 1c59d68a50
commit 1bb34c51bf
10 changed files with 25 additions and 125 deletions

2
API.md
View File

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

View File

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

View File

@@ -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<int> 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<VoteStatusDto> Voters, bool Ready, IReadOnlyList<string> Waiting);
public record ResultItemDto(
int Id,
string Name,
string? Author,
int? MinPlayers,
int? MaxPlayers,
int Total,
int Count,
double Average,
IReadOnlyList<int> Votes,
int? MyVote,
string? ScreenshotUrl,
string? YoutubeUrl,
string? GameUrl,
string? Description,
string? Genre,
int? ParentSuggestionId,
IReadOnlyList<int> LinkedIds,
IReadOnlyList<string> LinkedTitles
);
public record ResultItemDto(int Id, string Name, string? Author, int? MinPlayers, int? MaxPlayers, int Total, int Count, double Average, IReadOnlyList<int> Votes, int? MyVote, string? ScreenshotUrl, string? YoutubeUrl, string? GameUrl, string? Description, string? Genre, int? ParentSuggestionId, IReadOnlyList<int> LinkedIds, IReadOnlyList<string> 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);

View File

@@ -2,7 +2,6 @@ using GameList.Data;
using GameList.Contracts;
using Microsoft.AspNetCore.Mvc;
using GameList.Infrastructure;
using Microsoft.AspNetCore.RateLimiting;
namespace GameList.Endpoints;

View File

@@ -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<IResult> 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();
}

View File

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

View File

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

View File

@@ -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<IResult> NextPhaseAsync(Player player)

View File

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

View File

@@ -77,7 +77,7 @@ public class AuthTests
Assert.Equal(HttpStatusCode.BadRequest, weak.StatusCode);
var json = await weak.Content.ReadFromJsonAsync<JsonElement>();
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]