From fe6a9d5da4fe228649f153087807ece32be48a10 Mon Sep 17 00:00:00 2001 From: Frank Tovar Date: Sun, 8 Feb 2026 21:37:46 +0100 Subject: [PATCH] Harden owner and suggestion invariants for concurrent writes --- API.md | 3 + Data/AppDbContext.cs | 1 + ...enOwnerAndSuggestionInvariants.Designer.cs | 255 ++++++++++++++++++ ...3323_HardenOwnerAndSuggestionInvariants.cs | 47 ++++ Data/Migrations/AppDbContextModelSnapshot.cs | 4 + Endpoints/AuthEndpoints.cs | 14 +- Endpoints/EndpointHelpers.cs | 18 ++ Endpoints/SuggestionWorkflowService.cs | 26 +- Endpoints/VoteWorkflowService.cs | 54 +++- GameList.Tests/AuthTests.cs | 30 +++ GameList.Tests/SuggestionTests.cs | 38 +++ README.md | 1 + TESTS.md | 3 + 13 files changed, 472 insertions(+), 22 deletions(-) create mode 100644 Data/Migrations/20260208203323_HardenOwnerAndSuggestionInvariants.Designer.cs create mode 100644 Data/Migrations/20260208203323_HardenOwnerAndSuggestionInvariants.cs diff --git a/API.md b/API.md index c70d4a3..043c835 100644 --- a/API.md +++ b/API.md @@ -11,6 +11,7 @@ POST /api/auth/logout Display names are set during registration and are immutable afterward. 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`. +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) 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) 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 +Suggestion limit is enforced in both app logic and DB trigger; concurrent writes that exceed limit return `400`. ## Votes (requires auth + Vote phase) 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/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) GET /api/results — leaderboard with totals, counts, averages, caller’s vote, media/links, link metadata diff --git a/Data/AppDbContext.cs b/Data/AppDbContext.cs index 768558e..0e7bda9 100644 --- a/Data/AppDbContext.cs +++ b/Data/AppDbContext.cs @@ -23,6 +23,7 @@ public class AppDbContext(DbContextOptions options) : DbContext(op builder.Property(p => p.PasswordSalt).IsRequired(); builder.Property(p => p.IsAdmin).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.CurrentPhase).HasDefaultValue(Phase.Suggest); builder.Property(p => p.VotesFinal).HasDefaultValue(false); diff --git a/Data/Migrations/20260208203323_HardenOwnerAndSuggestionInvariants.Designer.cs b/Data/Migrations/20260208203323_HardenOwnerAndSuggestionInvariants.Designer.cs new file mode 100644 index 0000000..34dde65 --- /dev/null +++ b/Data/Migrations/20260208203323_HardenOwnerAndSuggestionInvariants.Designer.cs @@ -0,0 +1,255 @@ +// +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 + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ResultsOpen") + .HasColumnType("INTEGER"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CurrentPhase") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("DisplayName") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("HasJoker") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.Property("IsAdmin") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.Property("IsOwner") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.Property("LastLoginAt") + .HasColumnType("TEXT"); + + b.Property("NormalizedUsername") + .IsRequired() + .HasMaxLength(24) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("PasswordSalt") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(24) + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("GameUrl") + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("Genre") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("MaxPlayers") + .HasColumnType("INTEGER"); + + b.Property("MinPlayers") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("ParentSuggestionId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("TEXT"); + + b.Property("ScreenshotUrl") + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("TEXT"); + + b.Property("Score") + .HasColumnType("INTEGER"); + + b.Property("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 + } + } +} diff --git a/Data/Migrations/20260208203323_HardenOwnerAndSuggestionInvariants.cs b/Data/Migrations/20260208203323_HardenOwnerAndSuggestionInvariants.cs new file mode 100644 index 0000000..2a83de1 --- /dev/null +++ b/Data/Migrations/20260208203323_HardenOwnerAndSuggestionInvariants.cs @@ -0,0 +1,47 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace GameList.Data.Migrations +{ + /// + public partial class HardenOwnerAndSuggestionInvariants : Migration + { + /// + 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; + """ + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql("DROP TRIGGER IF EXISTS TR_Suggestions_MaxFivePerPlayer;"); + + migrationBuilder.DropIndex( + name: "IX_Players_IsOwner", + table: "Players"); + } + } +} diff --git a/Data/Migrations/AppDbContextModelSnapshot.cs b/Data/Migrations/AppDbContextModelSnapshot.cs index 88e7b95..eb5115e 100644 --- a/Data/Migrations/AppDbContextModelSnapshot.cs +++ b/Data/Migrations/AppDbContextModelSnapshot.cs @@ -103,6 +103,10 @@ namespace GameList.Data.Migrations b.HasKey("Id"); + b.HasIndex("IsOwner") + .IsUnique() + .HasFilter("IsOwner = 1"); + b.HasIndex("NormalizedUsername") .IsUnique(); diff --git a/Endpoints/AuthEndpoints.cs b/Endpoints/AuthEndpoints.cs index 9c54d1e..d8c201f 100644 --- a/Endpoints/AuthEndpoints.cs +++ b/Endpoints/AuthEndpoints.cs @@ -68,7 +68,19 @@ public static class AuthEndpoints }; db.Players.Add(player); - await db.SaveChangesAsync(); + try + { + 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) authAttemptMonitor.RecordSuccess(ctx, "auth-register-admin", validated.NormalizedUsername); diff --git a/Endpoints/EndpointHelpers.cs b/Endpoints/EndpointHelpers.cs index 061abc6..b301378 100644 --- a/Endpoints/EndpointHelpers.cs +++ b/Endpoints/EndpointHelpers.cs @@ -1,5 +1,6 @@ using GameList.Data; using GameList.Domain; +using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using System.Net; using System.Net.Sockets; @@ -9,6 +10,9 @@ namespace GameList.Endpoints; internal static class EndpointHelpers { + public const string SingleOwnerIndexName = "IX_Players_IsOwner"; + public const string SuggestionLimitTriggerError = "suggestion_limit_exceeded"; + public static async Task GetAuthenticatedPlayer(HttpContext ctx, AppDbContext db) { 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 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) { return Results.Problem( diff --git a/Endpoints/SuggestionWorkflowService.cs b/Endpoints/SuggestionWorkflowService.cs index 242cc36..c1379f0 100644 --- a/Endpoints/SuggestionWorkflowService.cs +++ b/Endpoints/SuggestionWorkflowService.cs @@ -60,7 +60,7 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact if (string.IsNullOrWhiteSpace(playerState.DisplayName)) 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) return EndpointHelpers.BadRequestError("You have reached the 5 suggestion limit."); @@ -81,16 +81,24 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact db.Suggestions.Add(suggestion); - if (usingJoker) + try { - await db.Players - .Where(p => p.Id == playerId) - .ExecuteUpdateAsync(p => p.SetProperty(x => x.HasJoker, false)); - await db.Players.ExecuteUpdateAsync(p => p.SetProperty(x => x.VotesFinal, false)); - } + await db.SaveChangesAsync(); - await db.SaveChangesAsync(); - await tx.CommitAsync(); + if (usingJoker) + { + await db.Players + .Where(p => p.Id == playerId) + .ExecuteUpdateAsync(p => p.SetProperty(x => x.HasJoker, false)); + await db.Players.ExecuteUpdateAsync(p => p.SetProperty(x => x.VotesFinal, false)); + } + + 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)); } diff --git a/Endpoints/VoteWorkflowService.cs b/Endpoints/VoteWorkflowService.cs index 3aeec65..a2171c3 100644 --- a/Endpoints/VoteWorkflowService.cs +++ b/Endpoints/VoteWorkflowService.cs @@ -2,6 +2,7 @@ using GameList.Contracts; using GameList.Data; using GameList.Domain; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; namespace GameList.Endpoints; @@ -71,26 +72,46 @@ internal sealed class VoteWorkflowService(AppDbContext db) .Where(v => v.PlayerId == playerId && linkedIds.Contains(v.SuggestionId)) .ToListAsync(); - foreach (var linkedSuggestionId in linkedIds) + for (var attempt = 0; attempt < 2; attempt++) { - var vote = existingVotes.FirstOrDefault(v => v.SuggestionId == linkedSuggestionId); - if (vote == null) + foreach (var linkedSuggestionId in linkedIds) { - db.Votes.Add(new Vote + var vote = existingVotes.FirstOrDefault(v => v.SuggestionId == linkedSuggestionId); + if (vote == null) { - PlayerId = playerId, - SuggestionId = linkedSuggestionId, - Score = score - }); + db.Votes.Add(new Vote + { + PlayerId = playerId, + SuggestionId = linkedSuggestionId, + Score = score + }); + } + else + { + vote.Score = score; + } } - else + + try { - vote.Score = score; + await db.SaveChangesAsync(); + return Results.Ok(new VoteUpsertResponse(linkedIds, score)); + } + catch (DbUpdateException ex) when (attempt == 0 && EndpointHelpers.IsSqliteConstraintViolation(ex)) + { + DetachAddedVotes(db.ChangeTracker.Entries()); + + 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(); } } - await db.SaveChangesAsync(); - return Results.Ok(new VoteUpsertResponse(linkedIds, score)); + return EndpointHelpers.ConflictError("Vote update conflict. Please retry."); } public async Task SetFinalizeAsync(Guid playerId, bool final) @@ -105,4 +126,13 @@ internal sealed class VoteWorkflowService(AppDbContext db) await db.SaveChangesAsync(); return Results.Ok(new VoteFinalizeResponse(player.VotesFinal)); } + + private static void DetachAddedVotes(IEnumerable> voteEntries) + { + foreach (var entry in voteEntries) + { + if (entry.State == EntityState.Added) + entry.State = EntityState.Detached; + } + } } diff --git a/GameList.Tests/AuthTests.cs b/GameList.Tests/AuthTests.cs index 46283f3..1c24f68 100644 --- a/GameList.Tests/AuthTests.cs +++ b/GameList.Tests/AuthTests.cs @@ -1,6 +1,8 @@ using System.Net; using System.Net.Http.Json; using System.Text.Json; +using GameList.Data; +using GameList.Domain; using GameList.Infrastructure; using GameList.Tests.Support; using Microsoft.EntityFrameworkCore; @@ -247,4 +249,32 @@ public class AuthTests resp.EnsureSuccessStatusCode(); 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(() => 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); + } } diff --git a/GameList.Tests/SuggestionTests.cs b/GameList.Tests/SuggestionTests.cs index 46b6001..c107b6e 100644 --- a/GameList.Tests/SuggestionTests.cs +++ b/GameList.Tests/SuggestionTests.cs @@ -1,6 +1,7 @@ using System.Net; using System.Net.Http.Json; using System.Text.Json; +using GameList.Domain; using GameList.Tests.Support; using Microsoft.EntityFrameworkCore; @@ -626,4 +627,41 @@ public class SuggestionTests 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(() => 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); + } } diff --git a/README.md b/README.md index 87e0774..dcb69c1 100644 --- a/README.md +++ b/README.md @@ -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. - 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. +- Core invariants are DB-enforced: single owner account and non-joker suggestion cap. - Gameplay phases: `Suggest`, `Vote`, `Results`. - Storage: SQLite database under `App_Data/gamelist.db`. - Security defaults: rate-limited auth/admin routes, baseline browser security headers, production HTTPS+HSTS enforcement. diff --git a/TESTS.md b/TESTS.md index a1ff9a4..0daecb8 100644 --- a/TESTS.md +++ b/TESTS.md @@ -34,6 +34,7 @@ stateDiagram-v2 - 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. - 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. - Login success updates LastLoginAt and sets DisplayName if null; rejects wrong password/username; enforces length limits. - Logout clears cookie. @@ -50,6 +51,7 @@ stateDiagram-v2 ### 3) Suggestions - 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. +- 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. - 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. @@ -60,6 +62,7 @@ stateDiagram-v2 - 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. - 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. ### 5) Results