Harden owner and suggestion invariants for concurrent writes
This commit is contained in:
3
API.md
3
API.md
@@ -11,6 +11,7 @@ POST /api/auth/logout
|
|||||||
Display names are set during registration and are immutable afterward.
|
Display names are set during registration and are immutable afterward.
|
||||||
Passwords must be 8-128 chars and contain uppercase, lowercase and number.
|
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`.
|
The first account created with a valid `adminKey` becomes both `IsAdmin=true` and `IsOwner=true`.
|
||||||
|
Owner bootstrap is also enforced by a database uniqueness constraint (`IsOwner=true` can only exist once), so concurrent owner registration races fail safely with `400`.
|
||||||
|
|
||||||
## State (requires auth)
|
## State (requires auth)
|
||||||
GET /api/state — returns currentPhase (for caller), votesFinal, resultsOpen, updatedAt, counts (players/suggestions/votes)
|
GET /api/state — returns currentPhase (for caller), votesFinal, resultsOpen, updatedAt, counts (players/suggestions/votes)
|
||||||
@@ -26,11 +27,13 @@ POST /api/suggestions — create (name required ≤100; max 5 per player; valida
|
|||||||
PUT /api/suggestions/{id} — update (non-admin: own suggestion; title locked after Suggest)
|
PUT /api/suggestions/{id} — update (non-admin: own suggestion; title locked after Suggest)
|
||||||
DELETE /api/suggestions/{id} — delete (non-admin only in Suggest; admin any time)
|
DELETE /api/suggestions/{id} — delete (non-admin only in Suggest; admin any time)
|
||||||
GET /api/suggestions/all — all suggestions (from Vote onward), includes author, link metadata
|
GET /api/suggestions/all — all suggestions (from Vote onward), includes author, link metadata
|
||||||
|
Suggestion limit is enforced in both app logic and DB trigger; concurrent writes that exceed limit return `400`.
|
||||||
|
|
||||||
## Votes (requires auth + Vote phase)
|
## Votes (requires auth + Vote phase)
|
||||||
GET /api/votes/mine
|
GET /api/votes/mine
|
||||||
POST /api/votes — upsert vote; if suggestion is in a linked group, applies the same score to all linked siblings
|
POST /api/votes — upsert vote; if suggestion is in a linked group, applies the same score to all linked siblings
|
||||||
POST /api/votes/finalize — `{ final: bool }` toggles caller’s finalized status (blocks further vote edits when true)
|
POST /api/votes/finalize — `{ final: bool }` toggles caller’s finalized status (blocks further vote edits when true)
|
||||||
|
Vote upsert includes conflict handling for concurrent writes against the unique `(PlayerId, SuggestionId)` index.
|
||||||
|
|
||||||
## Results (requires auth + Results phase + resultsOpen)
|
## Results (requires auth + Results phase + resultsOpen)
|
||||||
GET /api/results — leaderboard with totals, counts, averages, caller’s vote, media/links, link metadata
|
GET /api/results — leaderboard with totals, counts, averages, caller’s vote, media/links, link metadata
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(op
|
|||||||
builder.Property(p => p.PasswordSalt).IsRequired();
|
builder.Property(p => p.PasswordSalt).IsRequired();
|
||||||
builder.Property(p => p.IsAdmin).HasDefaultValue(false);
|
builder.Property(p => p.IsAdmin).HasDefaultValue(false);
|
||||||
builder.Property(p => p.IsOwner).HasDefaultValue(false);
|
builder.Property(p => p.IsOwner).HasDefaultValue(false);
|
||||||
|
builder.HasIndex(p => p.IsOwner).HasFilter($"{nameof(Player.IsOwner)} = 1").IsUnique();
|
||||||
builder.Property(p => p.HasJoker).HasDefaultValue(false);
|
builder.Property(p => p.HasJoker).HasDefaultValue(false);
|
||||||
builder.Property(p => p.CurrentPhase).HasDefaultValue(Phase.Suggest);
|
builder.Property(p => p.CurrentPhase).HasDefaultValue(Phase.Suggest);
|
||||||
builder.Property(p => p.VotesFinal).HasDefaultValue(false);
|
builder.Property(p => p.VotesFinal).HasDefaultValue(false);
|
||||||
|
|||||||
255
Data/Migrations/20260208203323_HardenOwnerAndSuggestionInvariants.Designer.cs
generated
Normal file
255
Data/Migrations/20260208203323_HardenOwnerAndSuggestionInvariants.Designer.cs
generated
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using GameList.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace GameList.Data.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(AppDbContext))]
|
||||||
|
[Migration("20260208203323_HardenOwnerAndSuggestionInvariants")]
|
||||||
|
partial class HardenOwnerAndSuggestionInvariants
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder.HasAnnotation("ProductVersion", "10.0.2");
|
||||||
|
|
||||||
|
modelBuilder.Entity("GameList.Domain.AppState", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<bool>("ResultsOpen")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UpdatedAt")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("AppState");
|
||||||
|
|
||||||
|
b.HasData(
|
||||||
|
new
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
ResultsOpen = false,
|
||||||
|
UpdatedAt = new DateTimeOffset(new DateTime(1970, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0))
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("GameList.Domain.Player", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("CurrentPhase")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(0);
|
||||||
|
|
||||||
|
b.Property<string>("DisplayName")
|
||||||
|
.HasMaxLength(16)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("HasJoker")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(false);
|
||||||
|
|
||||||
|
b.Property<bool>("IsAdmin")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(false);
|
||||||
|
|
||||||
|
b.Property<bool>("IsOwner")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(false);
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("LastLoginAt")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("NormalizedUsername")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(24)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<byte[]>("PasswordHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("BLOB");
|
||||||
|
|
||||||
|
b.Property<byte[]>("PasswordSalt")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("BLOB");
|
||||||
|
|
||||||
|
b.Property<string>("Username")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(24)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("VotesFinal")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(false);
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("IsOwner")
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("IsOwner = 1");
|
||||||
|
|
||||||
|
b.HasIndex("NormalizedUsername")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Players");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("GameList.Domain.Suggestion", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("GameUrl")
|
||||||
|
.HasMaxLength(2048)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Genre")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int?>("MaxPlayers")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int?>("MinPlayers")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int?>("ParentSuggestionId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<Guid>("PlayerId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("ScreenshotUrl")
|
||||||
|
.HasMaxLength(2048)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("YoutubeUrl")
|
||||||
|
.HasMaxLength(2048)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("ParentSuggestionId");
|
||||||
|
|
||||||
|
b.HasIndex("PlayerId");
|
||||||
|
|
||||||
|
b.ToTable("Suggestions");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("GameList.Domain.Vote", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("PlayerId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("Score")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("SuggestionId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("SuggestionId");
|
||||||
|
|
||||||
|
b.HasIndex("PlayerId", "SuggestionId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Votes");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("GameList.Domain.Suggestion", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("GameList.Domain.Suggestion", "ParentSuggestion")
|
||||||
|
.WithMany("LinkedSuggestions")
|
||||||
|
.HasForeignKey("ParentSuggestionId")
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
|
b.HasOne("GameList.Domain.Player", "Player")
|
||||||
|
.WithMany("Suggestions")
|
||||||
|
.HasForeignKey("PlayerId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("ParentSuggestion");
|
||||||
|
|
||||||
|
b.Navigation("Player");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("GameList.Domain.Vote", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("GameList.Domain.Player", "Player")
|
||||||
|
.WithMany("Votes")
|
||||||
|
.HasForeignKey("PlayerId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("GameList.Domain.Suggestion", "Suggestion")
|
||||||
|
.WithMany("Votes")
|
||||||
|
.HasForeignKey("SuggestionId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Player");
|
||||||
|
|
||||||
|
b.Navigation("Suggestion");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("GameList.Domain.Player", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Suggestions");
|
||||||
|
|
||||||
|
b.Navigation("Votes");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("GameList.Domain.Suggestion", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("LinkedSuggestions");
|
||||||
|
|
||||||
|
b.Navigation("Votes");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace GameList.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class HardenOwnerAndSuggestionInvariants : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Players_IsOwner",
|
||||||
|
table: "Players",
|
||||||
|
column: "IsOwner",
|
||||||
|
unique: true,
|
||||||
|
filter: "IsOwner = 1");
|
||||||
|
|
||||||
|
migrationBuilder.Sql(
|
||||||
|
"""
|
||||||
|
CREATE TRIGGER IF NOT EXISTS TR_Suggestions_MaxFivePerPlayer
|
||||||
|
BEFORE INSERT ON Suggestions
|
||||||
|
WHEN
|
||||||
|
(SELECT COUNT(1) FROM Suggestions WHERE PlayerId = NEW.PlayerId) >= 5
|
||||||
|
AND (
|
||||||
|
COALESCE((SELECT HasJoker FROM Players WHERE Id = NEW.PlayerId), 0) = 0
|
||||||
|
OR COALESCE((SELECT CurrentPhase FROM Players WHERE Id = NEW.PlayerId), 0) != 2
|
||||||
|
)
|
||||||
|
BEGIN
|
||||||
|
SELECT RAISE(ABORT, 'suggestion_limit_exceeded');
|
||||||
|
END;
|
||||||
|
"""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.Sql("DROP TRIGGER IF EXISTS TR_Suggestions_MaxFivePerPlayer;");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Players_IsOwner",
|
||||||
|
table: "Players");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -103,6 +103,10 @@ namespace GameList.Data.Migrations
|
|||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("IsOwner")
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("IsOwner = 1");
|
||||||
|
|
||||||
b.HasIndex("NormalizedUsername")
|
b.HasIndex("NormalizedUsername")
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
|
|
||||||
|
|||||||
@@ -68,7 +68,19 @@ public static class AuthEndpoints
|
|||||||
};
|
};
|
||||||
|
|
||||||
db.Players.Add(player);
|
db.Players.Add(player);
|
||||||
|
try
|
||||||
|
{
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
catch (DbUpdateException ex) when (isOwner && EndpointHelpers.IsSqliteConstraintViolation(ex, EndpointHelpers.SingleOwnerIndexName))
|
||||||
|
{
|
||||||
|
authAttemptMonitor.RecordFailure(ctx, "auth-register-admin", validated.NormalizedUsername, "bootstrap-admin-race");
|
||||||
|
return EndpointHelpers.BadRequestError("Admin registration via admin key is disabled once an owner account exists.");
|
||||||
|
}
|
||||||
|
catch (DbUpdateException ex) when (EndpointHelpers.IsSqliteConstraintViolation(ex, "IX_Players_NormalizedUsername"))
|
||||||
|
{
|
||||||
|
return EndpointHelpers.ConflictError("Username already taken.");
|
||||||
|
}
|
||||||
|
|
||||||
if (isAdmin)
|
if (isAdmin)
|
||||||
authAttemptMonitor.RecordSuccess(ctx, "auth-register-admin", validated.NormalizedUsername);
|
authAttemptMonitor.RecordSuccess(ctx, "auth-register-admin", validated.NormalizedUsername);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using GameList.Data;
|
using GameList.Data;
|
||||||
using GameList.Domain;
|
using GameList.Domain;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Net.Sockets;
|
using System.Net.Sockets;
|
||||||
@@ -9,6 +10,9 @@ namespace GameList.Endpoints;
|
|||||||
|
|
||||||
internal static class EndpointHelpers
|
internal static class EndpointHelpers
|
||||||
{
|
{
|
||||||
|
public const string SingleOwnerIndexName = "IX_Players_IsOwner";
|
||||||
|
public const string SuggestionLimitTriggerError = "suggestion_limit_exceeded";
|
||||||
|
|
||||||
public static async Task<Player?> GetAuthenticatedPlayer(HttpContext ctx, AppDbContext db)
|
public static async Task<Player?> GetAuthenticatedPlayer(HttpContext ctx, AppDbContext db)
|
||||||
{
|
{
|
||||||
if (ctx.User.Identity?.IsAuthenticated != true)
|
if (ctx.User.Identity?.IsAuthenticated != true)
|
||||||
@@ -108,6 +112,20 @@ internal static class EndpointHelpers
|
|||||||
|
|
||||||
public static IResult UnauthorizedError(string detail = "Unauthorized") => Problem(StatusCodes.Status401Unauthorized, "Unauthorized", detail);
|
public static IResult UnauthorizedError(string detail = "Unauthorized") => Problem(StatusCodes.Status401Unauthorized, "Unauthorized", detail);
|
||||||
|
|
||||||
|
public static bool IsSqliteConstraintViolation(DbUpdateException ex)
|
||||||
|
{
|
||||||
|
return ex.InnerException is SqliteException sqliteEx
|
||||||
|
&& sqliteEx.SqliteErrorCode == 19;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool IsSqliteConstraintViolation(DbUpdateException ex, string containsMessage)
|
||||||
|
{
|
||||||
|
if (!IsSqliteConstraintViolation(ex))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
return ex.InnerException?.Message.Contains(containsMessage, StringComparison.OrdinalIgnoreCase) == true;
|
||||||
|
}
|
||||||
|
|
||||||
private static IResult Problem(int statusCode, string title, string detail)
|
private static IResult Problem(int statusCode, string title, string detail)
|
||||||
{
|
{
|
||||||
return Results.Problem(
|
return Results.Problem(
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact
|
|||||||
if (string.IsNullOrWhiteSpace(playerState.DisplayName))
|
if (string.IsNullOrWhiteSpace(playerState.DisplayName))
|
||||||
return EndpointHelpers.BadRequestError("Set a display name before submitting suggestions.");
|
return EndpointHelpers.BadRequestError("Set a display name before submitting suggestions.");
|
||||||
|
|
||||||
var existingCount = await db.Suggestions.CountAsync(s => s.PlayerId == playerId);
|
var existingCount = await db.Suggestions.AsNoTracking().CountAsync(s => s.PlayerId == playerId);
|
||||||
if (!usingJoker && existingCount >= 5)
|
if (!usingJoker && existingCount >= 5)
|
||||||
return EndpointHelpers.BadRequestError("You have reached the 5 suggestion limit.");
|
return EndpointHelpers.BadRequestError("You have reached the 5 suggestion limit.");
|
||||||
|
|
||||||
@@ -81,6 +81,10 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact
|
|||||||
|
|
||||||
db.Suggestions.Add(suggestion);
|
db.Suggestions.Add(suggestion);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
if (usingJoker)
|
if (usingJoker)
|
||||||
{
|
{
|
||||||
await db.Players
|
await db.Players
|
||||||
@@ -89,8 +93,12 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact
|
|||||||
await db.Players.ExecuteUpdateAsync(p => p.SetProperty(x => x.VotesFinal, false));
|
await db.Players.ExecuteUpdateAsync(p => p.SetProperty(x => x.VotesFinal, false));
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.SaveChangesAsync();
|
|
||||||
await tx.CommitAsync();
|
await tx.CommitAsync();
|
||||||
|
}
|
||||||
|
catch (DbUpdateException ex) when (EndpointHelpers.IsSqliteConstraintViolation(ex, EndpointHelpers.SuggestionLimitTriggerError))
|
||||||
|
{
|
||||||
|
return EndpointHelpers.BadRequestError("You have reached the 5 suggestion limit.");
|
||||||
|
}
|
||||||
|
|
||||||
return Results.Created($"/api/suggestions/{suggestion.Id}", new SuggestionCreatedResponse(suggestion.Id));
|
return Results.Created($"/api/suggestions/{suggestion.Id}", new SuggestionCreatedResponse(suggestion.Id));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using GameList.Contracts;
|
|||||||
using GameList.Data;
|
using GameList.Data;
|
||||||
using GameList.Domain;
|
using GameList.Domain;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.ChangeTracking;
|
||||||
|
|
||||||
namespace GameList.Endpoints;
|
namespace GameList.Endpoints;
|
||||||
|
|
||||||
@@ -71,6 +72,8 @@ internal sealed class VoteWorkflowService(AppDbContext db)
|
|||||||
.Where(v => v.PlayerId == playerId && linkedIds.Contains(v.SuggestionId))
|
.Where(v => v.PlayerId == playerId && linkedIds.Contains(v.SuggestionId))
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
|
for (var attempt = 0; attempt < 2; attempt++)
|
||||||
|
{
|
||||||
foreach (var linkedSuggestionId in linkedIds)
|
foreach (var linkedSuggestionId in linkedIds)
|
||||||
{
|
{
|
||||||
var vote = existingVotes.FirstOrDefault(v => v.SuggestionId == linkedSuggestionId);
|
var vote = existingVotes.FirstOrDefault(v => v.SuggestionId == linkedSuggestionId);
|
||||||
@@ -89,9 +92,27 @@ internal sealed class VoteWorkflowService(AppDbContext db)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
return Results.Ok(new VoteUpsertResponse(linkedIds, score));
|
return Results.Ok(new VoteUpsertResponse(linkedIds, score));
|
||||||
}
|
}
|
||||||
|
catch (DbUpdateException ex) when (attempt == 0 && EndpointHelpers.IsSqliteConstraintViolation(ex))
|
||||||
|
{
|
||||||
|
DetachAddedVotes(db.ChangeTracker.Entries<Vote>());
|
||||||
|
|
||||||
|
await db.Votes
|
||||||
|
.Where(v => v.PlayerId == playerId && linkedIds.Contains(v.SuggestionId))
|
||||||
|
.ExecuteUpdateAsync(v => v.SetProperty(x => x.Score, score));
|
||||||
|
|
||||||
|
existingVotes = await db.Votes
|
||||||
|
.Where(v => v.PlayerId == playerId && linkedIds.Contains(v.SuggestionId))
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return EndpointHelpers.ConflictError("Vote update conflict. Please retry.");
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<IResult> SetFinalizeAsync(Guid playerId, bool final)
|
public async Task<IResult> SetFinalizeAsync(Guid playerId, bool final)
|
||||||
{
|
{
|
||||||
@@ -105,4 +126,13 @@ internal sealed class VoteWorkflowService(AppDbContext db)
|
|||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
return Results.Ok(new VoteFinalizeResponse(player.VotesFinal));
|
return Results.Ok(new VoteFinalizeResponse(player.VotesFinal));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void DetachAddedVotes(IEnumerable<EntityEntry<Vote>> voteEntries)
|
||||||
|
{
|
||||||
|
foreach (var entry in voteEntries)
|
||||||
|
{
|
||||||
|
if (entry.State == EntityState.Added)
|
||||||
|
entry.State = EntityState.Detached;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using GameList.Data;
|
||||||
|
using GameList.Domain;
|
||||||
using GameList.Infrastructure;
|
using GameList.Infrastructure;
|
||||||
using GameList.Tests.Support;
|
using GameList.Tests.Support;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -247,4 +249,32 @@ public class AuthTests
|
|||||||
resp.EnsureSuccessStatusCode();
|
resp.EnsureSuccessStatusCode();
|
||||||
Assert.True(resp.Headers.TryGetValues("Set-Cookie", out var cookies) && cookies.Any(c => c.Contains("player")));
|
Assert.True(resp.Headers.TryGetValues("Set-Cookie", out var cookies) && cookies.Any(c => c.Contains("player")));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Owner_uniqueness_is_enforced_by_database_constraint()
|
||||||
|
{
|
||||||
|
await using var factory = new TestWebApplicationFactory();
|
||||||
|
var ownerClient = factory.CreateClientWithCookies();
|
||||||
|
await ownerClient.RegisterAsync("owner1", admin: true);
|
||||||
|
|
||||||
|
var thrown = await Assert.ThrowsAsync<DbUpdateException>(() => factory.WithDbContextAsync(async db =>
|
||||||
|
{
|
||||||
|
var (hash, salt) = PasswordHasher.HashPassword("Pass123!");
|
||||||
|
db.Players.Add(new Player
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Username = "owner2",
|
||||||
|
NormalizedUsername = "owner2",
|
||||||
|
PasswordHash = hash,
|
||||||
|
PasswordSalt = salt,
|
||||||
|
DisplayName = "Owner2",
|
||||||
|
IsOwner = true,
|
||||||
|
IsAdmin = true
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}));
|
||||||
|
|
||||||
|
Assert.Contains("Players.IsOwner", thrown.InnerException?.Message ?? thrown.Message, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using GameList.Domain;
|
||||||
using GameList.Tests.Support;
|
using GameList.Tests.Support;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
@@ -626,4 +627,41 @@ public class SuggestionTests
|
|||||||
Assert.False(db.Votes.Any(v => v.SuggestionId == id));
|
Assert.False(db.Votes.Any(v => v.SuggestionId == id));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Suggestion_limit_is_enforced_by_database_trigger_without_joker()
|
||||||
|
{
|
||||||
|
await using var factory = new TestWebApplicationFactory();
|
||||||
|
var client = factory.CreateClientWithCookies();
|
||||||
|
await client.RegisterAsync("dbcap");
|
||||||
|
|
||||||
|
var playerId = await factory.WithDbContextAsync(async db => await db.Players.Select(p => p.Id).SingleAsync());
|
||||||
|
|
||||||
|
await factory.WithDbContextAsync(async db =>
|
||||||
|
{
|
||||||
|
for (var i = 0; i < 5; i++)
|
||||||
|
{
|
||||||
|
db.Suggestions.Add(new Suggestion
|
||||||
|
{
|
||||||
|
PlayerId = playerId,
|
||||||
|
Name = $"Seed {i}"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
});
|
||||||
|
|
||||||
|
var thrown = await Assert.ThrowsAsync<DbUpdateException>(() => factory.WithDbContextAsync(async db =>
|
||||||
|
{
|
||||||
|
db.Suggestions.Add(new Suggestion
|
||||||
|
{
|
||||||
|
PlayerId = playerId,
|
||||||
|
Name = "Blocked by trigger"
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}));
|
||||||
|
|
||||||
|
Assert.Contains("suggestion_limit_exceeded", thrown.InnerException?.Message ?? thrown.Message, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ Pick'n'Play is a .NET 10 ASP.NET Core Minimal API app with a static HTML/CSS/JS
|
|||||||
- Authentication: username/password with HttpOnly `player` cookie.
|
- Authentication: username/password with HttpOnly `player` cookie.
|
||||||
- Admin authorization: authenticated account with `IsAdmin=true`.
|
- Admin authorization: authenticated account with `IsAdmin=true`.
|
||||||
- Owner model: first valid admin-key registration becomes `owner`; admins can grant/revoke admin role for non-owner accounts.
|
- Owner model: first valid admin-key registration becomes `owner`; admins can grant/revoke admin role for non-owner accounts.
|
||||||
|
- Core invariants are DB-enforced: single owner account and non-joker suggestion cap.
|
||||||
- Gameplay phases: `Suggest`, `Vote`, `Results`.
|
- Gameplay phases: `Suggest`, `Vote`, `Results`.
|
||||||
- Storage: SQLite database under `App_Data/gamelist.db`.
|
- Storage: SQLite database under `App_Data/gamelist.db`.
|
||||||
- Security defaults: rate-limited auth/admin routes, baseline browser security headers, production HTTPS+HSTS enforcement.
|
- Security defaults: rate-limited auth/admin routes, baseline browser security headers, production HTTPS+HSTS enforcement.
|
||||||
|
|||||||
3
TESTS.md
3
TESTS.md
@@ -34,6 +34,7 @@ stateDiagram-v2
|
|||||||
- Register success (player, admin key path) issues cookie, trims fields, stores normalized username, hashes password.
|
- Register success (player, admin key path) issues cookie, trims fields, stores normalized username, hashes password.
|
||||||
- Register rejects missing/long username, weak password policy violations, missing display name, duplicate username, bad admin key, >24 chars username, >16 display name.
|
- Register rejects missing/long username, weak password policy violations, missing display name, duplicate username, bad admin key, >24 chars username, >16 display name.
|
||||||
- Bootstrap-admin key path only works until the owner account exists; bootstrap admin is marked as owner.
|
- Bootstrap-admin key path only works until the owner account exists; bootstrap admin is marked as owner.
|
||||||
|
- Database uniqueness guard enforces single owner row (`IsOwner=true`) even if writes bypass endpoint-level checks.
|
||||||
- `/api/auth/options` reports owner presence for registration UI behavior.
|
- `/api/auth/options` reports owner presence for registration UI behavior.
|
||||||
- Login success updates LastLoginAt and sets DisplayName if null; rejects wrong password/username; enforces length limits.
|
- Login success updates LastLoginAt and sets DisplayName if null; rejects wrong password/username; enforces length limits.
|
||||||
- Logout clears cookie.
|
- Logout clears cookie.
|
||||||
@@ -50,6 +51,7 @@ stateDiagram-v2
|
|||||||
### 3) Suggestions
|
### 3) Suggestions
|
||||||
- GET /mine returns only caller’s suggestions ordered by CreatedAt.
|
- GET /mine returns only caller’s suggestions ordered by CreatedAt.
|
||||||
- POST /: success with valid data; enforces ≤5 per player; trims optional fields; requires display name; rejects bad image URL/ext, unreachable image (mocked), invalid game/youtube URLs, invalid player counts, missing name/too long.
|
- POST /: success with valid data; enforces ≤5 per player; trims optional fields; requires display name; rejects bad image URL/ext, unreachable image (mocked), invalid game/youtube URLs, invalid player counts, missing name/too long.
|
||||||
|
- DB trigger also enforces suggestion cap for non-joker inserts, protecting against concurrent over-limit writes.
|
||||||
- Joker path: when phase=Vote and HasJoker=true allows creation, consumes joker, resets VotesFinal for all players.
|
- Joker path: when phase=Vote and HasJoker=true allows creation, consumes joker, resets VotesFinal for all players.
|
||||||
- Phase gating: non-admin cannot create/update/delete outside Suggest (except joker create); admin bypasses phase checks for update/delete.
|
- Phase gating: non-admin cannot create/update/delete outside Suggest (except joker create); admin bypasses phase checks for update/delete.
|
||||||
- PUT /{id}: player can edit own in Suggest; name locked outside Suggest; admin can edit any time; validation mirrors create.
|
- PUT /{id}: player can edit own in Suggest; name locked outside Suggest; admin can edit any time; validation mirrors create.
|
||||||
@@ -60,6 +62,7 @@ stateDiagram-v2
|
|||||||
- GET /mine: only in Vote, returns player votes; unauthorized/phase mismatch handled.
|
- GET /mine: only in Vote, returns player votes; unauthorized/phase mismatch handled.
|
||||||
- POST /: creates or updates vote; rejects score outside 0–10; rejects when VotesFinal=true; enforces display name requirement and phase gating.
|
- POST /: creates or updates vote; rejects score outside 0–10; rejects when VotesFinal=true; enforces display name requirement and phase gating.
|
||||||
- Linked votes: when suggestions are linked, a single post updates all linked IDs; invalid suggestionId returns 400; linking root detection works for nested links.
|
- Linked votes: when suggestions are linked, a single post updates all linked IDs; invalid suggestionId returns 400; linking root detection works for nested links.
|
||||||
|
- Concurrent vote upserts are handled with retry logic around unique-key conflicts to avoid server errors.
|
||||||
- Finalize: POST /finalize toggles VotesFinal flag; allowed only in Vote.
|
- Finalize: POST /finalize toggles VotesFinal flag; allowed only in Vote.
|
||||||
|
|
||||||
### 5) Results
|
### 5) Results
|
||||||
|
|||||||
Reference in New Issue
Block a user