Removed symbol requirement for password, fix formatting.
This commit is contained in:
2
API.md
2
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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -2,7 +2,6 @@ using GameList.Data;
|
||||
using GameList.Contracts;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using GameList.Infrastructure;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
|
||||
namespace GameList.Endpoints;
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user