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.
|
||||
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
|
||||
|
||||
@@ -23,6 +23,7 @@ public class AppDbContext(DbContextOptions<AppDbContext> 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);
|
||||
|
||||
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.HasIndex("IsOwner")
|
||||
.IsUnique()
|
||||
.HasFilter("IsOwner = 1");
|
||||
|
||||
b.HasIndex("NormalizedUsername")
|
||||
.IsUnique();
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<Player?> 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(
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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<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();
|
||||
}
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
return Results.Ok(new VoteUpsertResponse(linkedIds, score));
|
||||
return EndpointHelpers.ConflictError("Vote update conflict. Please retry.");
|
||||
}
|
||||
|
||||
public async Task<IResult> 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<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.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<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.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<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.
|
||||
- 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.
|
||||
|
||||
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 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
|
||||
|
||||
Reference in New Issue
Block a user