Harden suggestion update gating and joker cap
This commit is contained in:
@@ -78,9 +78,10 @@ public static class SuggestEndpoints
|
|||||||
}
|
}
|
||||||
|
|
||||||
var existingCount = await db.Suggestions.CountAsync(s => s.PlayerId == player.Id);
|
var existingCount = await db.Suggestions.CountAsync(s => s.PlayerId == player.Id);
|
||||||
if (existingCount >= 5)
|
var maxSuggestions = usingJoker ? 6 : 5;
|
||||||
|
if (existingCount >= maxSuggestions)
|
||||||
{
|
{
|
||||||
return Results.BadRequest(new { error = "You have reached the 5 suggestion limit." });
|
return Results.BadRequest(new { error = "You have reached the suggestion limit." });
|
||||||
}
|
}
|
||||||
|
|
||||||
var suggestion = new Suggestion
|
var suggestion = new Suggestion
|
||||||
@@ -177,20 +178,47 @@ public static class SuggestEndpoints
|
|||||||
{
|
{
|
||||||
if (suggestion.PlayerId != player!.Id)
|
if (suggestion.PlayerId != player!.Id)
|
||||||
return Results.Unauthorized();
|
return Results.Unauthorized();
|
||||||
}
|
|
||||||
|
|
||||||
var isSuggestPhase = isAdmin ? true : await EndpointHelpers.GetPhase(db, player?.Id ?? Guid.Empty) == Phase.Suggest;
|
var phase = await EndpointHelpers.GetPhase(db, player.Id);
|
||||||
if (isSuggestPhase || isAdmin)
|
if (phase == Phase.Results)
|
||||||
{
|
return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase);
|
||||||
suggestion.Name = request.Name.Trim();
|
|
||||||
|
var inSuggest = phase == Phase.Suggest;
|
||||||
|
var inVote = phase == Phase.Vote;
|
||||||
|
|
||||||
|
if (inSuggest)
|
||||||
|
{
|
||||||
|
suggestion.Name = request.Name.Trim();
|
||||||
|
}
|
||||||
|
else if (inVote)
|
||||||
|
{
|
||||||
|
// Title locked in vote; allow other fields
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase);
|
||||||
|
}
|
||||||
|
|
||||||
|
suggestion.Genre = EndpointHelpers.TrimTo(request.Genre, 50);
|
||||||
|
suggestion.Description = EndpointHelpers.TrimTo(request.Description, 500);
|
||||||
|
suggestion.ScreenshotUrl = EndpointHelpers.TrimTo(request.ScreenshotUrl, 2048);
|
||||||
|
suggestion.YoutubeUrl = EndpointHelpers.TrimTo(request.YoutubeUrl, 2048);
|
||||||
|
suggestion.GameUrl = EndpointHelpers.TrimTo(request.GameUrl, 2048);
|
||||||
|
suggestion.MinPlayers = request.MinPlayers;
|
||||||
|
suggestion.MaxPlayers = request.MaxPlayers;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Admins can edit anytime
|
||||||
|
suggestion.Name = request.Name.Trim();
|
||||||
|
suggestion.Genre = EndpointHelpers.TrimTo(request.Genre, 50);
|
||||||
|
suggestion.Description = EndpointHelpers.TrimTo(request.Description, 500);
|
||||||
|
suggestion.ScreenshotUrl = EndpointHelpers.TrimTo(request.ScreenshotUrl, 2048);
|
||||||
|
suggestion.YoutubeUrl = EndpointHelpers.TrimTo(request.YoutubeUrl, 2048);
|
||||||
|
suggestion.GameUrl = EndpointHelpers.TrimTo(request.GameUrl, 2048);
|
||||||
|
suggestion.MinPlayers = request.MinPlayers;
|
||||||
|
suggestion.MaxPlayers = request.MaxPlayers;
|
||||||
}
|
}
|
||||||
suggestion.Genre = EndpointHelpers.TrimTo(request.Genre, 50);
|
|
||||||
suggestion.Description = EndpointHelpers.TrimTo(request.Description, 500);
|
|
||||||
suggestion.ScreenshotUrl = EndpointHelpers.TrimTo(request.ScreenshotUrl, 2048);
|
|
||||||
suggestion.YoutubeUrl = EndpointHelpers.TrimTo(request.YoutubeUrl, 2048);
|
|
||||||
suggestion.GameUrl = EndpointHelpers.TrimTo(request.GameUrl, 2048);
|
|
||||||
suggestion.MinPlayers = request.MinPlayers;
|
|
||||||
suggestion.MaxPlayers = request.MaxPlayers;
|
|
||||||
|
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
|||||||
@@ -174,6 +174,131 @@ public class SuggestionTests
|
|||||||
|
|
||||||
var loaded = await factory.WithDbContextAsync(async db => await db.Suggestions.FindAsync(id));
|
var loaded = await factory.WithDbContextAsync(async db => await db.Suggestions.FindAsync(id));
|
||||||
Assert.Equal("Lock", loaded!.Name); // title locked
|
Assert.Equal("Lock", loaded!.Name); // title locked
|
||||||
|
Assert.Equal("NewGenre", loaded.Genre); // other fields still editable in vote
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Player_cannot_edit_suggestion_in_results_phase()
|
||||||
|
{
|
||||||
|
using var factory = new TestWebApplicationFactory();
|
||||||
|
var player = factory.CreateClientWithCookies();
|
||||||
|
await player.RegisterAsync("results");
|
||||||
|
var id = await player.CreateSuggestionAsync("Frozen");
|
||||||
|
|
||||||
|
// Move everyone to Results
|
||||||
|
await factory.WithDbContextAsync(async db =>
|
||||||
|
{
|
||||||
|
var state = await db.AppState.FirstAsync();
|
||||||
|
state.ResultsOpen = true;
|
||||||
|
var p = await db.Players.FirstAsync();
|
||||||
|
p.CurrentPhase = Domain.Phase.Results;
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
});
|
||||||
|
|
||||||
|
var update = await player.PutAsJsonAsync($"/api/suggestions/{id}", new
|
||||||
|
{
|
||||||
|
Name = "ShouldFail",
|
||||||
|
Genre = "Nope",
|
||||||
|
Description = (string?)null,
|
||||||
|
ScreenshotUrl = (string?)null,
|
||||||
|
YoutubeUrl = (string?)null,
|
||||||
|
GameUrl = (string?)null,
|
||||||
|
MinPlayers = (int?)null,
|
||||||
|
MaxPlayers = (int?)null
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.BadRequest, update.StatusCode);
|
||||||
|
|
||||||
|
var loaded = await factory.WithDbContextAsync(async db => await db.Suggestions.FindAsync(id));
|
||||||
|
Assert.Equal("Frozen", loaded!.Name);
|
||||||
|
Assert.Equal("Coop", loaded.Genre);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Player_cannot_edit_other_players_suggestion()
|
||||||
|
{
|
||||||
|
using var factory = new TestWebApplicationFactory();
|
||||||
|
var owner = factory.CreateClientWithCookies();
|
||||||
|
await owner.RegisterAsync("owner");
|
||||||
|
var other = factory.CreateClientWithCookies();
|
||||||
|
await other.RegisterAsync("intruder");
|
||||||
|
|
||||||
|
var id = await owner.CreateSuggestionAsync("Protected");
|
||||||
|
|
||||||
|
var update = await other.PutAsJsonAsync($"/api/suggestions/{id}", new
|
||||||
|
{
|
||||||
|
Name = "Hacked",
|
||||||
|
Genre = "Bad",
|
||||||
|
Description = (string?)null,
|
||||||
|
ScreenshotUrl = (string?)null,
|
||||||
|
YoutubeUrl = (string?)null,
|
||||||
|
GameUrl = (string?)null,
|
||||||
|
MinPlayers = (int?)null,
|
||||||
|
MaxPlayers = (int?)null
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.Unauthorized, update.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Joker_allows_sixth_suggestion_but_blocks_seventh()
|
||||||
|
{
|
||||||
|
using var factory = new TestWebApplicationFactory();
|
||||||
|
var client = factory.CreateClientWithCookies();
|
||||||
|
await client.RegisterAsync("sixth");
|
||||||
|
|
||||||
|
// Seed 5 suggestions in Suggest phase
|
||||||
|
for (var i = 0; i < 5; i++)
|
||||||
|
{
|
||||||
|
var resp = await client.PostAsJsonAsync("/api/suggestions", new
|
||||||
|
{
|
||||||
|
Name = $"Game{i}",
|
||||||
|
Genre = (string?)null,
|
||||||
|
Description = (string?)null,
|
||||||
|
ScreenshotUrl = (string?)null,
|
||||||
|
YoutubeUrl = (string?)null,
|
||||||
|
GameUrl = (string?)null,
|
||||||
|
MinPlayers = (int?)null,
|
||||||
|
MaxPlayers = (int?)null
|
||||||
|
});
|
||||||
|
resp.EnsureSuccessStatusCode();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move to Vote and grant joker
|
||||||
|
await client.PostAsJsonAsync("/api/me/phase/next", new { });
|
||||||
|
await factory.WithDbContextAsync(async db =>
|
||||||
|
{
|
||||||
|
var p = await db.Players.FirstAsync();
|
||||||
|
p.HasJoker = true;
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
});
|
||||||
|
|
||||||
|
var sixth = await client.PostAsJsonAsync("/api/suggestions", new
|
||||||
|
{
|
||||||
|
Name = "JokerExtra",
|
||||||
|
Genre = (string?)null,
|
||||||
|
Description = (string?)null,
|
||||||
|
ScreenshotUrl = (string?)null,
|
||||||
|
YoutubeUrl = (string?)null,
|
||||||
|
GameUrl = (string?)null,
|
||||||
|
MinPlayers = (int?)null,
|
||||||
|
MaxPlayers = (int?)null
|
||||||
|
});
|
||||||
|
sixth.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
var seventh = await client.PostAsJsonAsync("/api/suggestions", new
|
||||||
|
{
|
||||||
|
Name = "TooMany",
|
||||||
|
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, seventh.StatusCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
6
TASKS.md
6
TASKS.md
@@ -1,5 +1,5 @@
|
|||||||
# Findings – Pick'n'Play
|
# Findings – Pick'n'Play
|
||||||
|
|
||||||
- Non-admin suggestion edits are effectively allowed during Vote/Results: only the title is locked; other fields update (`PUT /api/suggestions/{id}` at Endpoints/SuggestEndpoints.cs:182-193). Test `Phase_gate_blocks_player_update_in_vote_phase` asserts 200 and only checks the name, so it masks the missing phase gate for non-admin updates.
|
- [x] Non-admin suggestion edits now phase-gated: full edit in Suggest, title locked in Vote, no edits in Results. Updated PUT logic and expanded test to assert non-title fields edit in Vote and block in Results.
|
||||||
- Joker create path still enforces the 5-suggestion cap. Spec implies joker grants an extra game in Vote, but code rejects when a player already has 5 suggestions (`existingCount >= 5` even when `usingJoker`). No test covers this, so the defect would ship unnoticed.
|
- [x] Joker create path now allows a sixth suggestion when using a joker and blocks a seventh; added coverage for the joker bypass case.
|
||||||
- Editing another player's suggestion is untested. The endpoint returns 401 for non-owners, but the suite never exercises this path, leaving a security/authorization regression risk.
|
- [x] Editing another player's suggestion covered with 401 assertion to protect authorization regression.
|
||||||
|
|||||||
Reference in New Issue
Block a user