C# formatting
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
namespace GameList.Contracts;
|
||||
|
||||
public record RegisterRequest(string Username, string Password, string? DisplayName, string? AdminKey);
|
||||
|
||||
public record LoginRequest(string Username, string Password);
|
||||
|
||||
@@ -3,13 +3,23 @@ using GameList.Domain;
|
||||
namespace GameList.Contracts;
|
||||
|
||||
public record SetNameRequest(string Name);
|
||||
|
||||
public record SuggestionRequest(string Name, string? Genre, string? Description, string? ScreenshotUrl, string? YoutubeUrl, string? GameUrl, int? MinPlayers, int? MaxPlayers);
|
||||
|
||||
public record SuggestionDto(int Id, string Name, string? Genre, string? Description, string? ScreenshotUrl, string? YoutubeUrl, string? GameUrl, int? MinPlayers, int? MaxPlayers, int? ParentSuggestionId = null, IReadOnlyList<int>? LinkedIds = null, IReadOnlyList<string>? LinkedTitles = null);
|
||||
|
||||
public record VoteRequest(int SuggestionId, int Score);
|
||||
|
||||
public record ResultsOpenRequest(bool ResultsOpen);
|
||||
|
||||
public record VoteFinalizeRequest(bool Final);
|
||||
|
||||
public record VoteStatusDto(Guid PlayerId, string Name, string Username, Phase Phase, bool Finalized, bool HasJoker, int SuggestionCount, IReadOnlyList<string> SuggestionTitles);
|
||||
|
||||
public record LinkSuggestionsRequest(int SourceSuggestionId, int TargetSuggestionId);
|
||||
|
||||
public record UnlinkSuggestionsRequest(int SuggestionId);
|
||||
|
||||
public record GrantJokerRequest(Guid PlayerId);
|
||||
|
||||
public record DeletePlayerRequest(Guid PlayerId);
|
||||
|
||||
@@ -3,12 +3,8 @@ using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace GameList.Data;
|
||||
|
||||
public class AppDbContext : DbContext
|
||||
public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
|
||||
{
|
||||
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options)
|
||||
{
|
||||
}
|
||||
|
||||
public DbSet<Player> Players => Set<Player>();
|
||||
public DbSet<Suggestion> Suggestions => Set<Suggestion>();
|
||||
public DbSet<Vote> Votes => Set<Vote>();
|
||||
@@ -29,14 +25,8 @@ public class AppDbContext : DbContext
|
||||
builder.Property(p => p.HasJoker).HasDefaultValue(false);
|
||||
builder.Property(p => p.CurrentPhase).HasDefaultValue(Phase.Suggest);
|
||||
builder.Property(p => p.VotesFinal).HasDefaultValue(false);
|
||||
builder.HasMany(p => p.Suggestions)
|
||||
.WithOne(s => s.Player!)
|
||||
.HasForeignKey(s => s.PlayerId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
builder.HasMany(p => p.Votes)
|
||||
.WithOne(v => v.Player!)
|
||||
.HasForeignKey(v => v.PlayerId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
builder.HasMany(p => p.Suggestions).WithOne(s => s.Player!).HasForeignKey(s => s.PlayerId).OnDelete(DeleteBehavior.Cascade);
|
||||
builder.HasMany(p => p.Votes).WithOne(v => v.Player!).HasForeignKey(v => v.PlayerId).OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<Suggestion>(builder =>
|
||||
@@ -50,10 +40,7 @@ public class AppDbContext : DbContext
|
||||
builder.Property(s => s.GameUrl).HasMaxLength(2048);
|
||||
builder.Property(s => s.MinPlayers);
|
||||
builder.Property(s => s.MaxPlayers);
|
||||
builder.HasOne(s => s.ParentSuggestion)
|
||||
.WithMany(p => p.LinkedSuggestions)
|
||||
.HasForeignKey(s => s.ParentSuggestionId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
builder.HasOne(s => s.ParentSuggestion).WithMany(p => p.LinkedSuggestions).HasForeignKey(s => s.ParentSuggestionId).OnDelete(DeleteBehavior.SetNull);
|
||||
builder.HasIndex(s => s.ParentSuggestionId);
|
||||
});
|
||||
|
||||
@@ -61,7 +48,11 @@ public class AppDbContext : DbContext
|
||||
{
|
||||
builder.HasKey(v => v.Id);
|
||||
builder.Property(v => v.Score).IsRequired();
|
||||
builder.HasIndex(v => new { v.PlayerId, v.SuggestionId }).IsUnique();
|
||||
builder.HasIndex(v => new
|
||||
{
|
||||
v.PlayerId,
|
||||
v.SuggestionId
|
||||
}).IsUnique();
|
||||
});
|
||||
|
||||
modelBuilder.Entity<AppState>(builder =>
|
||||
|
||||
@@ -15,8 +15,8 @@ public class Player
|
||||
[MaxLength(24)]
|
||||
public string NormalizedUsername { get; set; } = string.Empty;
|
||||
|
||||
public byte[] PasswordHash { get; set; } = Array.Empty<byte>();
|
||||
public byte[] PasswordSalt { get; set; } = Array.Empty<byte>();
|
||||
public byte[] PasswordHash { get; set; } = [];
|
||||
public byte[] PasswordSalt { get; set; } = [];
|
||||
|
||||
public DateTimeOffset? LastLoginAt { get; set; }
|
||||
public bool IsAdmin { get; set; }
|
||||
|
||||
@@ -8,6 +8,7 @@ public class Suggestion
|
||||
|
||||
[Required]
|
||||
public Guid PlayerId { get; set; }
|
||||
|
||||
public Player? Player { get; set; }
|
||||
|
||||
[Required]
|
||||
|
||||
@@ -8,10 +8,12 @@ public class Vote
|
||||
|
||||
[Required]
|
||||
public Guid PlayerId { get; set; }
|
||||
|
||||
public Player? Player { get; set; }
|
||||
|
||||
[Required]
|
||||
public int SuggestionId { get; set; }
|
||||
|
||||
public Suggestion? Suggestion { get; set; }
|
||||
|
||||
[Range(0, 10)]
|
||||
|
||||
@@ -3,7 +3,6 @@ using GameList.Domain;
|
||||
using GameList.Contracts;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Collections.Generic;
|
||||
using GameList.Infrastructure;
|
||||
|
||||
namespace GameList.Endpoints;
|
||||
@@ -12,11 +11,9 @@ public static class AdminEndpoints
|
||||
{
|
||||
public static void MapAdminEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var admin = app.MapGroup("/api/admin")
|
||||
.RequireAuthorization()
|
||||
.AddEndpointFilter<AdminOnlyFilter>();
|
||||
var admin = app.MapGroup("/api/admin").RequireAuthorization().AddEndpointFilter<AdminOnlyFilter>();
|
||||
|
||||
admin.MapPost("/results", async ([FromBody] Contracts.ResultsOpenRequest request, HttpContext ctx, AppDbContext db) =>
|
||||
admin.MapPost("/results", async ([FromBody] ResultsOpenRequest request, HttpContext _, AppDbContext db) =>
|
||||
{
|
||||
var state = await db.AppState.FirstAsync();
|
||||
state.ResultsOpen = request.ResultsOpen;
|
||||
@@ -28,40 +25,37 @@ public static class AdminEndpoints
|
||||
}
|
||||
else
|
||||
{
|
||||
await db.Players.ExecuteUpdateAsync(p => p.SetProperty(x => x.CurrentPhase, Phase.Vote)
|
||||
.SetProperty(x => x.VotesFinal, false));
|
||||
await db.Players.ExecuteUpdateAsync(p => p.SetProperty(x => x.CurrentPhase, Phase.Vote).SetProperty(x => x.VotesFinal, false));
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
var currentState = await db.AppState.AsNoTracking().FirstAsync();
|
||||
return Results.Ok(new { currentState.ResultsOpen, currentState.UpdatedAt });
|
||||
return Results.Ok(new
|
||||
{
|
||||
currentState.ResultsOpen,
|
||||
currentState.UpdatedAt
|
||||
});
|
||||
});
|
||||
|
||||
admin.MapGet("/vote-status", async (HttpContext ctx, AppDbContext db) =>
|
||||
admin.MapGet("/vote-status", async (HttpContext _, AppDbContext db) =>
|
||||
{
|
||||
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.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.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;
|
||||
return Results.Ok(new { voters, ready, waiting });
|
||||
return Results.Ok(new
|
||||
{
|
||||
voters,
|
||||
ready,
|
||||
waiting
|
||||
});
|
||||
});
|
||||
|
||||
admin.MapPost("/joker", async ([FromBody] GrantJokerRequest request, HttpContext ctx, AppDbContext db) =>
|
||||
admin.MapPost("/joker", async ([FromBody] GrantJokerRequest request, HttpContext _, AppDbContext db) =>
|
||||
{
|
||||
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == request.PlayerId);
|
||||
if (player is null) return Results.NotFound(new { error = "Player not found." });
|
||||
if (player is null)
|
||||
return Results.NotFound(new { error = "Player not found." });
|
||||
|
||||
var phase = await EndpointHelpers.GetPhase(db, player.Id);
|
||||
if (phase != Phase.Vote)
|
||||
@@ -71,15 +65,18 @@ public static class AdminEndpoints
|
||||
player.VotesFinal = false;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Results.Ok(new { player.Id, player.HasJoker });
|
||||
return Results.Ok(new
|
||||
{
|
||||
player.Id,
|
||||
player.HasJoker
|
||||
});
|
||||
});
|
||||
|
||||
admin.MapDelete("/players/{playerId:guid}", async (Guid playerId, HttpContext ctx, AppDbContext db) =>
|
||||
admin.MapDelete("/players/{playerId:guid}", async (Guid playerId, HttpContext _, AppDbContext db) =>
|
||||
{
|
||||
var player = await db.Players
|
||||
.Include(p => p.Suggestions)
|
||||
.FirstOrDefaultAsync(p => p.Id == playerId);
|
||||
if (player is null) return Results.NotFound(new { error = "Player not found." });
|
||||
var player = await db.Players.Include(p => p.Suggestions).FirstOrDefaultAsync(p => p.Id == playerId);
|
||||
if (player is null)
|
||||
return Results.NotFound(new { error = "Player not found." });
|
||||
|
||||
await using var tx = await db.Database.BeginTransactionAsync();
|
||||
|
||||
@@ -91,9 +88,7 @@ public static class AdminEndpoints
|
||||
if (suggestionIds.Count > 0)
|
||||
{
|
||||
// Break links pointing to these suggestions
|
||||
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));
|
||||
|
||||
// Remove votes for these suggestions to avoid orphaned rows
|
||||
await db.Votes.Where(v => suggestionIds.Contains(v.SuggestionId)).ExecuteDeleteAsync();
|
||||
@@ -110,7 +105,8 @@ public static class AdminEndpoints
|
||||
admin.MapPost("/link-suggestions", async ([FromBody] LinkSuggestionsRequest request, HttpContext ctx, AppDbContext db) =>
|
||||
{
|
||||
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||
if (player is null) return Results.Unauthorized();
|
||||
if (player is null)
|
||||
return Results.Unauthorized();
|
||||
|
||||
var phase = await EndpointHelpers.GetPhase(db, player.Id);
|
||||
if (phase != Phase.Vote)
|
||||
@@ -132,11 +128,12 @@ public static class AdminEndpoints
|
||||
if (sourceRoot == targetRoot)
|
||||
return Results.BadRequest(new { error = "These games are already linked." });
|
||||
|
||||
var affectedRootIds = new HashSet<int> { sourceRoot, targetRoot };
|
||||
var affectedIds = rootIndex
|
||||
.Where(kv => affectedRootIds.Contains(kv.Value))
|
||||
.Select(kv => kv.Key)
|
||||
.ToList();
|
||||
var affectedRootIds = new HashSet<int>
|
||||
{
|
||||
sourceRoot,
|
||||
targetRoot
|
||||
};
|
||||
var affectedIds = rootIndex.Where(kv => affectedRootIds.Contains(kv.Value)).Select(kv => kv.Key).ToList();
|
||||
|
||||
await using var tx = await db.Database.BeginTransactionAsync();
|
||||
|
||||
@@ -155,18 +152,13 @@ public static class AdminEndpoints
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var affectedPlayerIds = await db.Votes
|
||||
.Where(v => affectedIds.Contains(v.SuggestionId))
|
||||
.Select(v => v.PlayerId)
|
||||
.Distinct()
|
||||
.ToListAsync();
|
||||
var affectedPlayerIds = await db.Votes.Where(v => affectedIds.Contains(v.SuggestionId)).Select(v => v.PlayerId).Distinct().ToListAsync();
|
||||
|
||||
await db.Votes.Where(v => affectedIds.Contains(v.SuggestionId)).ExecuteDeleteAsync();
|
||||
|
||||
if (affectedPlayerIds.Count > 0)
|
||||
{
|
||||
await db.Players.Where(p => affectedPlayerIds.Contains(p.Id))
|
||||
.ExecuteUpdateAsync(p => p.SetProperty(x => x.VotesFinal, false));
|
||||
await db.Players.Where(p => affectedPlayerIds.Contains(p.Id)).ExecuteUpdateAsync(p => p.SetProperty(x => x.VotesFinal, false));
|
||||
}
|
||||
|
||||
await tx.CommitAsync();
|
||||
@@ -182,7 +174,8 @@ public static class AdminEndpoints
|
||||
admin.MapPost("/unlink-suggestions", async ([FromBody] UnlinkSuggestionsRequest request, HttpContext ctx, AppDbContext db) =>
|
||||
{
|
||||
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||
if (player is null) return Results.Unauthorized();
|
||||
if (player is null)
|
||||
return Results.Unauthorized();
|
||||
|
||||
var phase = await EndpointHelpers.GetPhase(db, player.Id);
|
||||
if (phase != Phase.Vote)
|
||||
@@ -191,16 +184,21 @@ public static class AdminEndpoints
|
||||
var suggestions = await db.Suggestions.ToListAsync();
|
||||
var target = suggestions.FirstOrDefault(s => s.Id == request.SuggestionId);
|
||||
if (target is null)
|
||||
return Results.Ok(new { UnlinkedSuggestionIds = Array.Empty<int>(), UnfinalizedPlayers = 0 });
|
||||
return Results.Ok(new
|
||||
{
|
||||
UnlinkedSuggestionIds = Array.Empty<int>(),
|
||||
UnfinalizedPlayers = 0
|
||||
});
|
||||
|
||||
var rootIndex = EndpointHelpers.BuildLinkRoots(suggestions.Select(s => (s.Id, s.ParentSuggestionId)));
|
||||
if (!rootIndex.TryGetValue(target.Id, out var rootId))
|
||||
return Results.Ok(new { UnlinkedSuggestionIds = Array.Empty<int>(), UnfinalizedPlayers = 0 });
|
||||
return Results.Ok(new
|
||||
{
|
||||
UnlinkedSuggestionIds = Array.Empty<int>(),
|
||||
UnfinalizedPlayers = 0
|
||||
});
|
||||
|
||||
var groupIds = rootIndex
|
||||
.Where(kv => kv.Value == rootId)
|
||||
.Select(kv => kv.Key)
|
||||
.ToList();
|
||||
var groupIds = rootIndex.Where(kv => kv.Value == rootId).Select(kv => kv.Key).ToList();
|
||||
|
||||
await using var tx = await db.Database.BeginTransactionAsync();
|
||||
|
||||
@@ -211,18 +209,13 @@ public static class AdminEndpoints
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var affectedPlayerIds = await db.Votes
|
||||
.Where(v => groupIds.Contains(v.SuggestionId))
|
||||
.Select(v => v.PlayerId)
|
||||
.Distinct()
|
||||
.ToListAsync();
|
||||
var affectedPlayerIds = await db.Votes.Where(v => groupIds.Contains(v.SuggestionId)).Select(v => v.PlayerId).Distinct().ToListAsync();
|
||||
|
||||
await db.Votes.Where(v => groupIds.Contains(v.SuggestionId)).ExecuteDeleteAsync();
|
||||
|
||||
if (affectedPlayerIds.Count > 0)
|
||||
{
|
||||
await db.Players.Where(p => affectedPlayerIds.Contains(p.Id))
|
||||
.ExecuteUpdateAsync(p => p.SetProperty(x => x.VotesFinal, false));
|
||||
await db.Players.Where(p => affectedPlayerIds.Contains(p.Id)).ExecuteUpdateAsync(p => p.SetProperty(x => x.VotesFinal, false));
|
||||
}
|
||||
|
||||
await tx.CommitAsync();
|
||||
@@ -234,23 +227,26 @@ public static class AdminEndpoints
|
||||
});
|
||||
});
|
||||
|
||||
admin.MapPost("/reset", async (HttpContext ctx, AppDbContext db) =>
|
||||
admin.MapPost("/reset", async (HttpContext _, AppDbContext db) =>
|
||||
{
|
||||
await db.Votes.ExecuteDeleteAsync();
|
||||
await db.Suggestions.ExecuteDeleteAsync();
|
||||
|
||||
await db.Players.ExecuteUpdateAsync(p => p.SetProperty(x => x.CurrentPhase, Phase.Suggest)
|
||||
.SetProperty(x => x.VotesFinal, false)
|
||||
.SetProperty(x => x.HasJoker, false));
|
||||
await db.Players.ExecuteUpdateAsync(p => p.SetProperty(x => x.CurrentPhase, Phase.Suggest).SetProperty(x => x.VotesFinal, false).SetProperty(x => x.HasJoker, false));
|
||||
var state = await db.AppState.FirstAsync();
|
||||
state.ResultsOpen = false;
|
||||
state.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Results.Ok(new { Phase = Phase.Suggest, state.ResultsOpen, state.UpdatedAt });
|
||||
return Results.Ok(new
|
||||
{
|
||||
Phase = Phase.Suggest,
|
||||
state.ResultsOpen,
|
||||
state.UpdatedAt
|
||||
});
|
||||
});
|
||||
|
||||
admin.MapPost("/factory-reset", async (HttpContext ctx, AppDbContext db) =>
|
||||
admin.MapPost("/factory-reset", async (HttpContext _, AppDbContext db) =>
|
||||
{
|
||||
await using var tx = await db.Database.BeginTransactionAsync();
|
||||
|
||||
@@ -265,7 +261,12 @@ public static class AdminEndpoints
|
||||
|
||||
await tx.CommitAsync();
|
||||
|
||||
return Results.Ok(new { Phase = Phase.Suggest, fresh.ResultsOpen, fresh.UpdatedAt });
|
||||
return Results.Ok(new
|
||||
{
|
||||
Phase = Phase.Suggest,
|
||||
fresh.ResultsOpen,
|
||||
fresh.UpdatedAt
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ public static class AuthEndpoints
|
||||
|
||||
group.MapPost("/register", async ([FromBody] RegisterRequest request, HttpContext ctx, AppDbContext db, IConfiguration config) =>
|
||||
{
|
||||
var username = request.Username?.Trim();
|
||||
var username = request.Username.Trim();
|
||||
if (string.IsNullOrWhiteSpace(username) || username.Length > 24)
|
||||
return Results.BadRequest(new { error = "Username is required and must be <= 24 characters." });
|
||||
|
||||
@@ -28,6 +28,7 @@ public static class AuthEndpoints
|
||||
var displayName = EndpointHelpers.TrimTo(request.DisplayName, 16);
|
||||
if (string.IsNullOrWhiteSpace(displayName))
|
||||
return Results.BadRequest(new { error = "Display name is required." });
|
||||
|
||||
var normalized = username.ToLowerInvariant();
|
||||
|
||||
var exists = await db.Players.AnyAsync(p => p.NormalizedUsername == normalized);
|
||||
@@ -43,6 +44,7 @@ public static class AuthEndpoints
|
||||
if (string.IsNullOrWhiteSpace(expectedAdminKey) || adminKey != expectedAdminKey)
|
||||
return Results.BadRequest(new { error = "Invalid admin key." });
|
||||
}
|
||||
|
||||
var isAdmin = wantsAdmin;
|
||||
|
||||
var player = new Player
|
||||
@@ -63,12 +65,18 @@ public static class AuthEndpoints
|
||||
|
||||
await PlayerIdentityExtensions.SignInPlayerAsync(ctx, player);
|
||||
|
||||
return Results.Ok(new { player.Id, player.Username, player.DisplayName, player.IsAdmin });
|
||||
return Results.Ok(new
|
||||
{
|
||||
player.Id,
|
||||
player.Username,
|
||||
player.DisplayName,
|
||||
player.IsAdmin
|
||||
});
|
||||
});
|
||||
|
||||
group.MapPost("/login", async ([FromBody] LoginRequest request, HttpContext ctx, AppDbContext db) =>
|
||||
{
|
||||
var username = request.Username?.Trim();
|
||||
var username = request.Username.Trim();
|
||||
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(request.Password))
|
||||
return Results.BadRequest(new { error = "Username and password are required." });
|
||||
if (username.Length > 24)
|
||||
@@ -83,12 +91,19 @@ public static class AuthEndpoints
|
||||
{
|
||||
player.DisplayName = EndpointHelpers.TrimTo(player.Username, 16);
|
||||
}
|
||||
|
||||
player.LastLoginAt = DateTimeOffset.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await PlayerIdentityExtensions.SignInPlayerAsync(ctx, player);
|
||||
|
||||
return Results.Ok(new { player.Id, player.Username, player.DisplayName, player.IsAdmin });
|
||||
return Results.Ok(new
|
||||
{
|
||||
player.Id,
|
||||
player.Username,
|
||||
player.DisplayName,
|
||||
player.IsAdmin
|
||||
});
|
||||
});
|
||||
|
||||
group.MapPost("/logout", async (HttpContext ctx) =>
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
using System.Collections.Generic;
|
||||
using GameList.Data;
|
||||
using GameList.Domain;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Security.Claims;
|
||||
|
||||
@@ -11,7 +9,8 @@ internal static class EndpointHelpers
|
||||
{
|
||||
public static async Task<Player?> GetAuthenticatedPlayer(HttpContext ctx, AppDbContext db)
|
||||
{
|
||||
if (ctx?.User?.Identity?.IsAuthenticated != true) return null;
|
||||
if (ctx.User.Identity?.IsAuthenticated != true)
|
||||
return null;
|
||||
|
||||
if (ctx.Items.TryGetValue(nameof(Player), out var cached) && cached is Player cachedPlayer)
|
||||
return cachedPlayer;
|
||||
@@ -38,7 +37,8 @@ internal static class EndpointHelpers
|
||||
public static async Task<Phase> GetPhase(AppDbContext db, Guid playerId)
|
||||
{
|
||||
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == playerId);
|
||||
if (player is null) return Phase.Suggest;
|
||||
if (player is null)
|
||||
return Phase.Suggest;
|
||||
|
||||
var state = await db.AppState.FirstAsync();
|
||||
|
||||
@@ -68,6 +68,7 @@ internal static class EndpointHelpers
|
||||
{
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
return player.CurrentPhase;
|
||||
}
|
||||
|
||||
@@ -75,58 +76,67 @@ internal static class EndpointHelpers
|
||||
Results.BadRequest(new { error = $"This endpoint is available in the {required} phase. Your current phase is {current}." });
|
||||
|
||||
public static string? TrimTo(string? input, int max) =>
|
||||
string.IsNullOrWhiteSpace(input)
|
||||
? null
|
||||
: input.Trim() is var t && t.Length > 0
|
||||
? t[..Math.Min(t.Length, max)]
|
||||
: null;
|
||||
string.IsNullOrWhiteSpace(input) ? null : input.Trim() is { Length: > 0 } t ? t[..Math.Min(t.Length, max)] : null;
|
||||
|
||||
public static bool IsValidImageUrl(string? url)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(url)) return true; // empty is acceptable
|
||||
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) return false;
|
||||
if (uri.Scheme is not ("http" or "https")) return false;
|
||||
if (string.IsNullOrWhiteSpace(url))
|
||||
return true; // empty is acceptable
|
||||
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
|
||||
return false;
|
||||
if (uri.Scheme is not ("http" or "https"))
|
||||
return false;
|
||||
|
||||
var path = uri.AbsolutePath.ToLowerInvariant();
|
||||
return path.EndsWith(".png") || path.EndsWith(".jpg") || path.EndsWith(".jpeg")
|
||||
|| path.EndsWith(".gif") || path.EndsWith(".webp") || path.EndsWith(".avif");
|
||||
return path.EndsWith(".png") || path.EndsWith(".jpg") || path.EndsWith(".jpeg") || path.EndsWith(".gif") || path.EndsWith(".webp") || path.EndsWith(".avif");
|
||||
}
|
||||
|
||||
public static async Task<bool> IsReachableImageAsync(string? url, IHttpClientFactory httpFactory, HttpMessageHandler? handler = null, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(url)) return true;
|
||||
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) return false;
|
||||
if (uri.Scheme is not ("http" or "https")) return false;
|
||||
if (!await IsSafePublicHostAsync(uri, httpFactory, ct)) return false;
|
||||
if (string.IsNullOrWhiteSpace(url))
|
||||
return true;
|
||||
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
|
||||
return false;
|
||||
if (uri.Scheme is not ("http" or "https"))
|
||||
return false;
|
||||
if (!await IsSafePublicHostAsync(uri, ct))
|
||||
return false;
|
||||
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
cts.CancelAfter(TimeSpan.FromSeconds(3));
|
||||
|
||||
var client = handler is null
|
||||
? httpFactory.CreateClient("imageValidation")
|
||||
: new HttpClient(handler, disposeHandler: false);
|
||||
var client = handler is null ? httpFactory.CreateClient("imageValidation") : new HttpClient(handler, disposeHandler: false);
|
||||
|
||||
try
|
||||
{
|
||||
using var head = new HttpRequestMessage(HttpMethod.Head, uri);
|
||||
var headResp = await client.SendAsync(head, HttpCompletionOption.ResponseHeadersRead, cts.Token);
|
||||
if (headResp.IsSuccessStatusCode && headResp.StatusCode is not System.Net.HttpStatusCode.Redirect)
|
||||
if (headResp is { IsSuccessStatusCode: true, StatusCode: not System.Net.HttpStatusCode.Redirect })
|
||||
{
|
||||
if (headResp.Content.Headers.ContentLength is long headLen && headLen > MaxImageBytes) return false;
|
||||
if (headResp.Content.Headers.ContentLength is > MaxImageBytes)
|
||||
return false;
|
||||
|
||||
var ctHeader = headResp.Content.Headers.ContentType?.MediaType;
|
||||
if (!string.IsNullOrWhiteSpace(ctHeader) && ctHeader.StartsWith("image/", StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch { /* fallback */ }
|
||||
catch
|
||||
{
|
||||
/* fallback */
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var get = new HttpRequestMessage(HttpMethod.Get, uri);
|
||||
get.Headers.Range = new System.Net.Http.Headers.RangeHeaderValue(0, 1023);
|
||||
var resp = await client.SendAsync(get, HttpCompletionOption.ResponseHeadersRead, cts.Token);
|
||||
if (!resp.IsSuccessStatusCode) return false;
|
||||
if (resp.StatusCode is System.Net.HttpStatusCode.Redirect) return false;
|
||||
if (resp.Content.Headers.ContentLength is long len && len > MaxImageBytes) return false;
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
return false;
|
||||
if (resp.StatusCode is System.Net.HttpStatusCode.Redirect)
|
||||
return false;
|
||||
if (resp.Content.Headers.ContentLength is > MaxImageBytes)
|
||||
return false;
|
||||
|
||||
var ctHeader = resp.Content.Headers.ContentType?.MediaType;
|
||||
if (!string.IsNullOrWhiteSpace(ctHeader) && ctHeader.StartsWith("image/", StringComparison.OrdinalIgnoreCase))
|
||||
@@ -137,11 +147,16 @@ internal static class EndpointHelpers
|
||||
var read = await stream.ReadAsync(rented, 0, rented.Length, cts.Token);
|
||||
var sig = new ReadOnlySpan<byte>(rented, 0, read);
|
||||
|
||||
if (IsMagic(sig, "PNG")) return true;
|
||||
if (IsMagic(sig, new byte[] { 0xFF, 0xD8 })) return true; // JPEG
|
||||
if (IsMagic(sig, "GIF8")) return true;
|
||||
if (IsRiffWithTag(sig, "WEBP")) return true;
|
||||
if (ContainsFtyp(sig, "avif")) return true;
|
||||
if (IsMagic(sig, "PNG"))
|
||||
return true;
|
||||
if (IsMagic(sig, [0xFF, 0xD8]))
|
||||
return true; // JPEG
|
||||
if (IsMagic(sig, "GIF8"))
|
||||
return true;
|
||||
if (IsRiffWithTag(sig, "WEBP"))
|
||||
return true;
|
||||
if (ContainsFtyp(sig, "avif"))
|
||||
return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -153,7 +168,7 @@ internal static class EndpointHelpers
|
||||
|
||||
private const long MaxImageBytes = 5 * 1024 * 1024; // 5 MB guard
|
||||
|
||||
private static async Task<bool> IsSafePublicHostAsync(Uri uri, IHttpClientFactory httpFactory, CancellationToken ct)
|
||||
private static async Task<bool> IsSafePublicHostAsync(Uri uri, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -163,8 +178,10 @@ internal static class EndpointHelpers
|
||||
var addresses = await System.Net.Dns.GetHostAddressesAsync(host, ct);
|
||||
foreach (var ip in addresses)
|
||||
{
|
||||
if (System.Net.IPAddress.IsLoopback(ip)) return false;
|
||||
if (IsPrivate(ip)) return false;
|
||||
if (System.Net.IPAddress.IsLoopback(ip))
|
||||
return false;
|
||||
if (IsPrivate(ip))
|
||||
return false;
|
||||
}
|
||||
}
|
||||
else
|
||||
@@ -187,11 +204,11 @@ internal static class EndpointHelpers
|
||||
var bytes = ip.GetAddressBytes();
|
||||
return bytes[0] switch
|
||||
{
|
||||
10 => true,
|
||||
10 => true,
|
||||
172 when bytes[1] >= 16 && bytes[1] <= 31 => true,
|
||||
192 when bytes[1] == 168 => true,
|
||||
127 => true,
|
||||
_ => false
|
||||
192 when bytes[1] == 168 => true,
|
||||
127 => true,
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
@@ -213,26 +230,37 @@ internal static class EndpointHelpers
|
||||
|
||||
private static bool IsRiffWithTag(ReadOnlySpan<byte> data, string tag)
|
||||
{
|
||||
if (data.Length < 12) return false;
|
||||
var riff = System.Text.Encoding.ASCII.GetBytes("RIFF");
|
||||
if (!data.StartsWith(riff)) return false;
|
||||
if (data.Length < 12)
|
||||
return false;
|
||||
|
||||
var riff = "RIFF"u8.ToArray();
|
||||
if (!data.StartsWith(riff))
|
||||
return false;
|
||||
|
||||
var tagBytes = System.Text.Encoding.ASCII.GetBytes(tag);
|
||||
return data[8..].StartsWith(tagBytes);
|
||||
}
|
||||
|
||||
private static bool ContainsFtyp(ReadOnlySpan<byte> data, string brand)
|
||||
{
|
||||
if (data.Length < 12) return false;
|
||||
var ftyp = System.Text.Encoding.ASCII.GetBytes("ftyp");
|
||||
if (!data[4..].StartsWith(ftyp)) return false;
|
||||
if (data.Length < 12)
|
||||
return false;
|
||||
|
||||
var ftyp = "ftyp"u8.ToArray();
|
||||
if (!data[4..].StartsWith(ftyp))
|
||||
return false;
|
||||
|
||||
var brandBytes = System.Text.Encoding.ASCII.GetBytes(brand);
|
||||
return data[8..].StartsWith(brandBytes);
|
||||
}
|
||||
|
||||
public static bool IsValidHttpUrl(string? url)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(url)) return true; // empty is allowed
|
||||
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) return false;
|
||||
if (string.IsNullOrWhiteSpace(url))
|
||||
return true; // empty is allowed
|
||||
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
|
||||
return false;
|
||||
|
||||
return uri.Scheme is "http" or "https";
|
||||
}
|
||||
|
||||
@@ -257,6 +285,7 @@ internal static class EndpointHelpers
|
||||
{
|
||||
roots[id] = FindRootId(id, parentMap);
|
||||
}
|
||||
|
||||
return roots;
|
||||
}
|
||||
|
||||
@@ -265,7 +294,7 @@ internal static class EndpointHelpers
|
||||
var current = suggestionId;
|
||||
var visited = new HashSet<int>();
|
||||
|
||||
while (parentMap.TryGetValue(current, out var parent) && parent is int p && !visited.Contains(p))
|
||||
while (parentMap.TryGetValue(current, out var parent) && parent is { } p && !visited.Contains(p))
|
||||
{
|
||||
visited.Add(current);
|
||||
current = p;
|
||||
@@ -276,7 +305,9 @@ internal static class EndpointHelpers
|
||||
|
||||
public static List<int> LinkedIdsFor(int suggestionId, IReadOnlyDictionary<int, int> rootIndex)
|
||||
{
|
||||
if (!rootIndex.TryGetValue(suggestionId, out var root)) return new();
|
||||
if (!rootIndex.TryGetValue(suggestionId, out var root))
|
||||
return [];
|
||||
|
||||
return rootIndex.Where(kv => kv.Value == root).Select(kv => kv.Key).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ public static class ResultsEndpoints
|
||||
s.MinPlayers,
|
||||
s.MaxPlayers,
|
||||
Total = s.Votes.Sum(v => v.Score),
|
||||
Count = s.Votes.Count,
|
||||
s.Votes.Count,
|
||||
Average = s.Votes.Count == 0 ? 0 : s.Votes.Average(v => v.Score),
|
||||
Votes = s.Votes.Select(v => v.Score).ToList(),
|
||||
MyVote = s.Votes
|
||||
@@ -85,7 +85,7 @@ public static class ResultsEndpoints
|
||||
r.ParentSuggestionId,
|
||||
LinkedIds = linkedIds,
|
||||
LinkedTitles = linkedIds
|
||||
.Where(id => nameLookup.ContainsKey(id))
|
||||
.Where(nameLookup.ContainsKey)
|
||||
.Select(id => nameLookup[id])
|
||||
.ToList()
|
||||
};
|
||||
|
||||
@@ -15,7 +15,9 @@ public static class StateEndpoints
|
||||
group.MapGet("/state", async (HttpContext ctx, AppDbContext db) =>
|
||||
{
|
||||
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||
if (player is null) return Results.Unauthorized();
|
||||
if (player is null)
|
||||
return Results.Unauthorized();
|
||||
|
||||
var phase = await EndpointHelpers.GetPhase(db, player.Id);
|
||||
|
||||
var state = await db.AppState.AsNoTracking().FirstAsync();
|
||||
@@ -36,16 +38,27 @@ public static class StateEndpoints
|
||||
group.MapGet("/me", async (HttpContext ctx, AppDbContext db) =>
|
||||
{
|
||||
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||
if (player is null) return Results.Unauthorized();
|
||||
if (player is null)
|
||||
return Results.Unauthorized();
|
||||
|
||||
var phase = await EndpointHelpers.GetPhase(db, player.Id);
|
||||
return Results.Ok(new { player.Id, player.DisplayName, player.Username, player.IsAdmin, CurrentPhase = phase, player.VotesFinal, player.HasJoker });
|
||||
return Results.Ok(new
|
||||
{
|
||||
player.Id,
|
||||
player.DisplayName,
|
||||
player.Username,
|
||||
player.IsAdmin,
|
||||
CurrentPhase = phase,
|
||||
player.VotesFinal,
|
||||
player.HasJoker
|
||||
});
|
||||
});
|
||||
|
||||
group.MapPost("/me/phase/next", async (HttpContext ctx, AppDbContext db, IConfiguration config) =>
|
||||
group.MapPost("/me/phase/next", async (HttpContext ctx, AppDbContext db, IConfiguration _) =>
|
||||
{
|
||||
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||
if (player is null) return Results.Unauthorized();
|
||||
var isAdmin = await EndpointHelpers.IsAdmin(ctx, db);
|
||||
if (player is null)
|
||||
return Results.Unauthorized();
|
||||
|
||||
var next = NextPhase(player.CurrentPhase);
|
||||
var appState = await db.AppState.FirstAsync();
|
||||
@@ -58,13 +71,19 @@ public static class StateEndpoints
|
||||
player.CurrentPhase = next;
|
||||
player.VotesFinal = false; // moving forward clears any prior finalize
|
||||
await db.SaveChangesAsync();
|
||||
return Results.Ok(new { player.CurrentPhase, appState.ResultsOpen });
|
||||
return Results.Ok(new
|
||||
{
|
||||
player.CurrentPhase,
|
||||
appState.ResultsOpen
|
||||
});
|
||||
});
|
||||
|
||||
group.MapPost("/me/phase/prev", async (HttpContext ctx, AppDbContext db, IConfiguration config) =>
|
||||
group.MapPost("/me/phase/prev", async (HttpContext ctx, AppDbContext db, IConfiguration _) =>
|
||||
{
|
||||
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||
if (player is null) return Results.Unauthorized();
|
||||
if (player is null)
|
||||
return Results.Unauthorized();
|
||||
|
||||
var isAdmin = await EndpointHelpers.IsAdmin(ctx, db);
|
||||
if (!isAdmin)
|
||||
{
|
||||
@@ -75,12 +94,16 @@ public static class StateEndpoints
|
||||
player.VotesFinal = false;
|
||||
await db.SaveChangesAsync();
|
||||
var appState = await db.AppState.AsNoTracking().FirstAsync();
|
||||
return Results.Ok(new { player.CurrentPhase, appState.ResultsOpen });
|
||||
return Results.Ok(new
|
||||
{
|
||||
player.CurrentPhase,
|
||||
appState.ResultsOpen
|
||||
});
|
||||
});
|
||||
|
||||
group.MapPost("/me/name", async ([FromBody] SetNameRequest request, HttpContext ctx, AppDbContext db) =>
|
||||
{
|
||||
if (request.Name?.Trim().Length > 16)
|
||||
if (request.Name.Trim().Length > 16)
|
||||
{
|
||||
return Results.BadRequest(new { error = "Name is required and must be <= 16 characters." });
|
||||
}
|
||||
@@ -92,27 +115,32 @@ public static class StateEndpoints
|
||||
}
|
||||
|
||||
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||
if (player is null) return Results.Unauthorized();
|
||||
if (player is null)
|
||||
return Results.Unauthorized();
|
||||
|
||||
player.DisplayName = name;
|
||||
await db.SaveChangesAsync();
|
||||
return Results.Ok(new { player.Id, player.DisplayName });
|
||||
return Results.Ok(new
|
||||
{
|
||||
player.Id,
|
||||
player.DisplayName
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private static Phase NextPhase(Phase current) => current switch
|
||||
{
|
||||
Phase.Suggest => Phase.Vote,
|
||||
Phase.Reveal => Phase.Vote, // legacy safety
|
||||
Phase.Vote => Phase.Results,
|
||||
_ => Phase.Results
|
||||
Phase.Reveal => Phase.Vote, // legacy safety
|
||||
Phase.Vote => Phase.Results,
|
||||
_ => Phase.Results
|
||||
};
|
||||
|
||||
private static Phase PrevPhase(Phase current) => current switch
|
||||
{
|
||||
Phase.Results => Phase.Vote,
|
||||
Phase.Vote => Phase.Suggest,
|
||||
Phase.Reveal => Phase.Suggest, // legacy safety
|
||||
_ => Phase.Suggest
|
||||
Phase.Vote => Phase.Suggest,
|
||||
Phase.Reveal => Phase.Suggest, // legacy safety
|
||||
_ => Phase.Suggest
|
||||
};
|
||||
}
|
||||
|
||||
@@ -16,29 +16,26 @@ public static class SuggestEndpoints
|
||||
group.MapGet("/mine", async (HttpContext ctx, AppDbContext db) =>
|
||||
{
|
||||
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||
if (player is null) return Results.Unauthorized();
|
||||
var mine = await db.Suggestions.AsNoTracking()
|
||||
.Where(s => s.PlayerId == player.Id)
|
||||
.Select(s => new
|
||||
{
|
||||
s.Id,
|
||||
s.PlayerId,
|
||||
s.Name,
|
||||
s.Genre,
|
||||
s.Description,
|
||||
s.ScreenshotUrl,
|
||||
s.YoutubeUrl,
|
||||
s.GameUrl,
|
||||
s.CreatedAt,
|
||||
s.MinPlayers,
|
||||
s.MaxPlayers,
|
||||
s.ParentSuggestionId
|
||||
})
|
||||
.ToListAsync();
|
||||
if (player is null)
|
||||
return Results.Unauthorized();
|
||||
|
||||
var ordered = mine
|
||||
.OrderBy(s => s.CreatedAt)
|
||||
.Select(s => new SuggestionDto(s.Id, s.Name, s.Genre, s.Description, s.ScreenshotUrl, s.YoutubeUrl, s.GameUrl, s.MinPlayers, s.MaxPlayers, s.ParentSuggestionId));
|
||||
var mine = await db.Suggestions.AsNoTracking().Where(s => s.PlayerId == player.Id).Select(s => new
|
||||
{
|
||||
s.Id,
|
||||
s.PlayerId,
|
||||
s.Name,
|
||||
s.Genre,
|
||||
s.Description,
|
||||
s.ScreenshotUrl,
|
||||
s.YoutubeUrl,
|
||||
s.GameUrl,
|
||||
s.CreatedAt,
|
||||
s.MinPlayers,
|
||||
s.MaxPlayers,
|
||||
s.ParentSuggestionId
|
||||
}).ToListAsync();
|
||||
|
||||
var ordered = mine.OrderBy(s => s.CreatedAt).Select(s => new SuggestionDto(s.Id, s.Name, s.Genre, s.Description, s.ScreenshotUrl, s.YoutubeUrl, s.GameUrl, s.MinPlayers, s.MaxPlayers, s.ParentSuggestionId));
|
||||
|
||||
return Results.Ok(ordered);
|
||||
});
|
||||
@@ -54,10 +51,12 @@ public static class SuggestEndpoints
|
||||
{
|
||||
return Results.BadRequest(new { error = "Screenshot URL must be http(s) and end with an image file extension." });
|
||||
}
|
||||
|
||||
if (!await EndpointHelpers.IsReachableImageAsync(request.ScreenshotUrl, http))
|
||||
{
|
||||
return Results.BadRequest(new { error = "Screenshot URL could not be validated as an image. Use a public image link (http/https, no redirects, max 5 MB)." });
|
||||
}
|
||||
|
||||
if (!EndpointHelpers.IsValidHttpUrl(request.GameUrl))
|
||||
return Results.BadRequest(new { error = "Game URL must be http or https." });
|
||||
if (!EndpointHelpers.IsValidHttpUrl(request.YoutubeUrl))
|
||||
@@ -67,11 +66,14 @@ public static class SuggestEndpoints
|
||||
return Results.BadRequest(new { error = playersError });
|
||||
|
||||
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||
if (player is null) return Results.Unauthorized();
|
||||
if (player is null)
|
||||
return Results.Unauthorized();
|
||||
|
||||
var phase = await EndpointHelpers.GetPhase(db, player.Id);
|
||||
var usingJoker = phase == Phase.Vote && player.HasJoker;
|
||||
if (phase != Phase.Suggest && !usingJoker)
|
||||
return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(player.DisplayName))
|
||||
{
|
||||
return Results.BadRequest(new { error = "Set a display name before submitting suggestions." });
|
||||
@@ -107,13 +109,14 @@ public static class SuggestEndpoints
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Results.Created($"/api/suggestions/{suggestion.Id}", new { suggestion.Id });
|
||||
})
|
||||
.AddEndpointFilter(new PhaseOrJokerFilter());
|
||||
}).AddEndpointFilter(new PhaseOrJokerFilter());
|
||||
|
||||
group.MapDelete("/{id:int}", async (int id, HttpContext ctx, AppDbContext db) =>
|
||||
{
|
||||
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||
if (player is null) return Results.Unauthorized();
|
||||
if (player is null)
|
||||
return Results.Unauthorized();
|
||||
|
||||
var isAdmin = await EndpointHelpers.IsAdmin(ctx, db);
|
||||
|
||||
if (!isAdmin)
|
||||
@@ -123,16 +126,12 @@ public static class SuggestEndpoints
|
||||
return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase);
|
||||
}
|
||||
|
||||
var suggestion = isAdmin
|
||||
? await db.Suggestions.FirstOrDefaultAsync(s => s.Id == id)
|
||||
: await db.Suggestions.FirstOrDefaultAsync(s => s.Id == id && s.PlayerId == player.Id);
|
||||
var suggestion = isAdmin ? await db.Suggestions.FirstOrDefaultAsync(s => s.Id == id) : await db.Suggestions.FirstOrDefaultAsync(s => s.Id == id && s.PlayerId == player.Id);
|
||||
if (suggestion == null)
|
||||
return Results.NotFound(new { error = "Suggestion not found." });
|
||||
|
||||
// Break any links that pointed at this suggestion
|
||||
await db.Suggestions
|
||||
.Where(s => s.ParentSuggestionId == suggestion.Id)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(x => x.ParentSuggestionId, (int?)null));
|
||||
await db.Suggestions.Where(s => s.ParentSuggestionId == suggestion.Id).ExecuteUpdateAsync(s => s.SetProperty(x => x.ParentSuggestionId, (int?)null));
|
||||
|
||||
// Remove votes for this suggestion to avoid orphaned vote rows or FK errors
|
||||
await db.Votes.Where(v => v.SuggestionId == suggestion.Id).ExecuteDeleteAsync();
|
||||
@@ -147,7 +146,8 @@ public static class SuggestEndpoints
|
||||
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||
var isAdmin = await EndpointHelpers.IsAdmin(ctx, db);
|
||||
|
||||
if (!isAdmin && player is null) return Results.Unauthorized();
|
||||
if (!isAdmin && player is null)
|
||||
return Results.Unauthorized();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Name) || request.Name.Length > 100)
|
||||
{
|
||||
@@ -158,10 +158,12 @@ public static class SuggestEndpoints
|
||||
{
|
||||
return Results.BadRequest(new { error = "Screenshot URL must be http(s) and end with an image file extension." });
|
||||
}
|
||||
|
||||
if (!await EndpointHelpers.IsReachableImageAsync(request.ScreenshotUrl, http))
|
||||
{
|
||||
return Results.BadRequest(new { error = "Screenshot URL could not be validated as an image. Use a public image link (http/https, no redirects, max 5 MB)." });
|
||||
}
|
||||
|
||||
if (!EndpointHelpers.IsValidHttpUrl(request.GameUrl))
|
||||
return Results.BadRequest(new { error = "Game URL must be http or https." });
|
||||
if (!EndpointHelpers.IsValidHttpUrl(request.YoutubeUrl))
|
||||
@@ -171,7 +173,8 @@ public static class SuggestEndpoints
|
||||
return Results.BadRequest(new { error = playersError });
|
||||
|
||||
var suggestion = await db.Suggestions.FirstOrDefaultAsync(s => s.Id == id);
|
||||
if (suggestion == null) return Results.NotFound(new { error = "Suggestion not found." });
|
||||
if (suggestion == null)
|
||||
return Results.NotFound(new { error = "Suggestion not found." });
|
||||
|
||||
if (!isAdmin)
|
||||
{
|
||||
@@ -238,14 +241,38 @@ public static class SuggestEndpoints
|
||||
group.MapGet("/all", async (HttpContext ctx, AppDbContext db) =>
|
||||
{
|
||||
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||
if (player is null) return Results.Unauthorized();
|
||||
if (player is null)
|
||||
return Results.Unauthorized();
|
||||
|
||||
var phase = await EndpointHelpers.GetPhase(db, player.Id);
|
||||
if (phase < Phase.Vote)
|
||||
return EndpointHelpers.PhaseMismatch(Phase.Vote, phase);
|
||||
|
||||
var all = await db.Suggestions.AsNoTracking()
|
||||
.Include(s => s.Player)
|
||||
.Select(s => new
|
||||
var all = await db.Suggestions.AsNoTracking().Include(s => s.Player).Select(s => new
|
||||
{
|
||||
s.Id,
|
||||
s.Name,
|
||||
s.Genre,
|
||||
s.Description,
|
||||
s.ScreenshotUrl,
|
||||
s.YoutubeUrl,
|
||||
s.GameUrl,
|
||||
s.MinPlayers,
|
||||
s.MaxPlayers,
|
||||
Author = s.Player!.DisplayName,
|
||||
s.CreatedAt,
|
||||
s.ParentSuggestionId,
|
||||
IsOwner = s.PlayerId == player.Id
|
||||
}).ToListAsync();
|
||||
|
||||
var rootIndex = EndpointHelpers.BuildLinkRoots(all.Select(s => (s.Id, s.ParentSuggestionId)));
|
||||
var nameLookup = all.ToDictionary(s => s.Id, s => s.Name);
|
||||
|
||||
var ordered = all.OrderBy(s => s.CreatedAt).Select(s =>
|
||||
{
|
||||
var linkedIds = EndpointHelpers.LinkedIdsFor(s.Id, rootIndex).Where(id => id != s.Id).ToList();
|
||||
|
||||
return new
|
||||
{
|
||||
s.Id,
|
||||
s.Name,
|
||||
@@ -256,45 +283,13 @@ public static class SuggestEndpoints
|
||||
s.GameUrl,
|
||||
s.MinPlayers,
|
||||
s.MaxPlayers,
|
||||
Author = s.Player!.DisplayName,
|
||||
s.CreatedAt,
|
||||
s.Author,
|
||||
s.ParentSuggestionId,
|
||||
IsOwner = s.PlayerId == player.Id
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
var rootIndex = EndpointHelpers.BuildLinkRoots(all.Select(s => (s.Id, s.ParentSuggestionId)));
|
||||
var nameLookup = all.ToDictionary(s => s.Id, s => s.Name);
|
||||
|
||||
var ordered = all
|
||||
.OrderBy(s => s.CreatedAt)
|
||||
.Select(s =>
|
||||
{
|
||||
var linkedIds = EndpointHelpers.LinkedIdsFor(s.Id, rootIndex)
|
||||
.Where(id => id != s.Id)
|
||||
.ToList();
|
||||
|
||||
return new
|
||||
{
|
||||
s.Id,
|
||||
s.Name,
|
||||
s.Genre,
|
||||
s.Description,
|
||||
s.ScreenshotUrl,
|
||||
s.YoutubeUrl,
|
||||
s.GameUrl,
|
||||
s.MinPlayers,
|
||||
s.MaxPlayers,
|
||||
s.Author,
|
||||
s.ParentSuggestionId,
|
||||
s.IsOwner,
|
||||
LinkedIds = linkedIds,
|
||||
LinkedTitles = linkedIds
|
||||
.Where(id => nameLookup.ContainsKey(id))
|
||||
.Select(id => nameLookup[id])
|
||||
.ToList()
|
||||
};
|
||||
});
|
||||
s.IsOwner,
|
||||
LinkedIds = linkedIds,
|
||||
LinkedTitles = linkedIds.Where(nameLookup.ContainsKey).Select(id => nameLookup[id]).ToList()
|
||||
};
|
||||
});
|
||||
|
||||
return Results.Ok(ordered);
|
||||
});
|
||||
@@ -303,15 +298,16 @@ public static class SuggestEndpoints
|
||||
private static bool ValidatePlayers(int? minPlayers, int? maxPlayers, out string? error)
|
||||
{
|
||||
error = null;
|
||||
if (minPlayers is null && maxPlayers is null) return true;
|
||||
if (minPlayers is null && maxPlayers is null)
|
||||
return true;
|
||||
|
||||
if (minPlayers is not null && (minPlayers < 1 || minPlayers > 32))
|
||||
if (minPlayers is < 1 or > 32)
|
||||
{
|
||||
error = "Min players must be between 1 and 32.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (maxPlayers is not null && (maxPlayers < 1 || maxPlayers > 32))
|
||||
if (maxPlayers is < 1 or > 32)
|
||||
{
|
||||
error = "Max players must be between 1 and 32.";
|
||||
return false;
|
||||
|
||||
@@ -11,21 +11,23 @@ public static class VoteEndpoints
|
||||
{
|
||||
public static void MapVoteEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/votes")
|
||||
.RequireAuthorization()
|
||||
.AddEndpointFilter(new PhaseRequirementFilter(Phase.Vote));
|
||||
var group = app.MapGroup("/api/votes").RequireAuthorization().AddEndpointFilter(new PhaseRequirementFilter(Phase.Vote));
|
||||
|
||||
group.MapGet("/mine", async (HttpContext ctx, AppDbContext db) =>
|
||||
{
|
||||
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||
if (player is null) return Results.Unauthorized();
|
||||
if (player is null)
|
||||
return Results.Unauthorized();
|
||||
|
||||
var phase = await EndpointHelpers.GetPhase(db, player.Id);
|
||||
if (phase != Phase.Vote)
|
||||
return EndpointHelpers.PhaseMismatch(Phase.Vote, phase);
|
||||
var votes = await db.Votes.AsNoTracking()
|
||||
.Where(v => v.PlayerId == player.Id)
|
||||
.Select(v => new { v.SuggestionId, v.Score })
|
||||
.ToListAsync();
|
||||
|
||||
var votes = await db.Votes.AsNoTracking().Where(v => v.PlayerId == player.Id).Select(v => new
|
||||
{
|
||||
v.SuggestionId,
|
||||
v.Score
|
||||
}).ToListAsync();
|
||||
|
||||
return Results.Ok(votes);
|
||||
});
|
||||
@@ -36,9 +38,11 @@ public static class VoteEndpoints
|
||||
return Results.BadRequest(new { error = "Score must be between 0 and 10." });
|
||||
|
||||
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||
if (player is null) return Results.Unauthorized();
|
||||
if (player is null)
|
||||
return Results.Unauthorized();
|
||||
if (player.VotesFinal)
|
||||
return Results.BadRequest(new { error = "Votes are finalized. Unfinalize before changing scores." });
|
||||
|
||||
var phase = await EndpointHelpers.GetPhase(db, player.Id);
|
||||
if (phase != Phase.Vote)
|
||||
return EndpointHelpers.PhaseMismatch(Phase.Vote, phase);
|
||||
@@ -46,19 +50,20 @@ public static class VoteEndpoints
|
||||
if (string.IsNullOrWhiteSpace(player.DisplayName))
|
||||
return Results.BadRequest(new { error = "Set a display name before voting." });
|
||||
|
||||
var linkMap = await db.Suggestions.AsNoTracking()
|
||||
.Select(s => new { s.Id, s.ParentSuggestionId })
|
||||
.ToListAsync();
|
||||
var linkMap = await db.Suggestions.AsNoTracking().Select(s => new
|
||||
{
|
||||
s.Id,
|
||||
s.ParentSuggestionId
|
||||
}).ToListAsync();
|
||||
var rootIndex = EndpointHelpers.BuildLinkRoots(linkMap.Select(s => (s.Id, s.ParentSuggestionId)));
|
||||
if (!rootIndex.ContainsKey(request.SuggestionId))
|
||||
return Results.BadRequest(new { error = "Suggestion not found." });
|
||||
|
||||
var linkedIds = EndpointHelpers.LinkedIdsFor(request.SuggestionId, rootIndex);
|
||||
if (linkedIds.Count == 0)
|
||||
linkedIds.Add(request.SuggestionId);
|
||||
|
||||
var existingVotes = await db.Votes
|
||||
.Where(v => v.PlayerId == player.Id && linkedIds.Contains(v.SuggestionId))
|
||||
.ToListAsync();
|
||||
var existingVotes = await db.Votes.Where(v => v.PlayerId == player.Id && linkedIds.Contains(v.SuggestionId)).ToListAsync();
|
||||
|
||||
foreach (var suggestionId in linkedIds)
|
||||
{
|
||||
@@ -79,13 +84,19 @@ public static class VoteEndpoints
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
return Results.Ok(new { SuggestionIds = linkedIds, request.Score });
|
||||
return Results.Ok(new
|
||||
{
|
||||
SuggestionIds = linkedIds,
|
||||
request.Score
|
||||
});
|
||||
});
|
||||
|
||||
group.MapPost("/finalize", async ([FromBody] VoteFinalizeRequest request, HttpContext ctx, AppDbContext db) =>
|
||||
{
|
||||
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||
if (player is null) return Results.Unauthorized();
|
||||
if (player is null)
|
||||
return Results.Unauthorized();
|
||||
|
||||
var phase = await EndpointHelpers.GetPhase(db, player.Id);
|
||||
if (phase != Phase.Vote)
|
||||
return EndpointHelpers.PhaseMismatch(Phase.Vote, phase);
|
||||
|
||||
@@ -12,7 +12,7 @@ public class AdminTests
|
||||
[Fact]
|
||||
public async Task Admin_vote_status_marks_ready_when_all_finalized()
|
||||
{
|
||||
using var factory = new TestWebApplicationFactory();
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var admin = factory.CreateClientWithCookies();
|
||||
await admin.RegisterAsync("admin", admin: true);
|
||||
await admin.PostAsJsonAsync("/api/me/phase/next", new { }); // move to Vote
|
||||
@@ -25,9 +25,17 @@ public class AdminTests
|
||||
|
||||
var s1 = await p1.CreateSuggestionAsync("A");
|
||||
await p1.PostAsJsonAsync("/api/me/phase/next", new { });
|
||||
await p1.PostAsJsonAsync("/api/votes", new { SuggestionId = s1, Score = 5 });
|
||||
await p1.PostAsJsonAsync("/api/votes", new
|
||||
{
|
||||
SuggestionId = s1,
|
||||
Score = 5
|
||||
});
|
||||
await p1.PostAsJsonAsync("/api/votes/finalize", new { Final = true });
|
||||
await p2.PostAsJsonAsync("/api/votes", new { SuggestionId = s1, Score = 7 });
|
||||
await p2.PostAsJsonAsync("/api/votes", new
|
||||
{
|
||||
SuggestionId = s1,
|
||||
Score = 7
|
||||
});
|
||||
await p2.PostAsJsonAsync("/api/votes/finalize", new { Final = true });
|
||||
await admin.PostAsJsonAsync("/api/votes/finalize", new { Final = true });
|
||||
|
||||
@@ -40,7 +48,7 @@ public class AdminTests
|
||||
[Fact]
|
||||
public async Task Grant_joker_only_in_vote_phase()
|
||||
{
|
||||
using var factory = new TestWebApplicationFactory();
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var admin = factory.CreateClientWithCookies();
|
||||
await admin.RegisterAsync("admin", admin: true);
|
||||
|
||||
@@ -54,7 +62,7 @@ public class AdminTests
|
||||
[Fact]
|
||||
public async Task Delete_player_cascades_suggestions_and_votes()
|
||||
{
|
||||
using var factory = new TestWebApplicationFactory();
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var admin = factory.CreateClientWithCookies();
|
||||
await admin.RegisterAsync("admin", admin: true);
|
||||
|
||||
@@ -63,23 +71,37 @@ public class AdminTests
|
||||
var suggestionId = await player.CreateSuggestionAsync("DeleteGame");
|
||||
|
||||
await player.PostAsJsonAsync("/api/me/phase/next", new { });
|
||||
await player.PostAsJsonAsync("/api/votes", new { SuggestionId = suggestionId, Score = 8 });
|
||||
await player.PostAsJsonAsync("/api/votes", new
|
||||
{
|
||||
SuggestionId = suggestionId,
|
||||
Score = 8
|
||||
});
|
||||
|
||||
var resp = await admin.DeleteAsync($"/api/admin/players/{await player.GetProfileIdAsync()}");
|
||||
resp.EnsureSuccessStatusCode();
|
||||
|
||||
await factory.WithDbContextAsync(async db =>
|
||||
await factory.WithDbContextAsync(db =>
|
||||
{
|
||||
Assert.Single(db.Players); // admin remains
|
||||
Assert.Empty(db.Suggestions);
|
||||
Assert.Empty(db.Votes);
|
||||
try
|
||||
{
|
||||
Assert.Single(db.Players); // admin remains
|
||||
|
||||
Assert.Empty(db.Suggestions);
|
||||
|
||||
Assert.Empty(db.Votes);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
return Task.FromException(exception);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Link_suggestions_errors_on_same_id_and_already_linked()
|
||||
{
|
||||
using var factory = new TestWebApplicationFactory();
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var admin = factory.CreateClientWithCookies();
|
||||
await admin.RegisterAsync("admin", admin: true);
|
||||
var player = factory.CreateClientWithCookies();
|
||||
@@ -91,20 +113,32 @@ public class AdminTests
|
||||
await player.PostAsJsonAsync("/api/me/phase/next", new { });
|
||||
await admin.PostAsJsonAsync("/api/me/phase/next", new { });
|
||||
|
||||
var same = await admin.PostAsJsonAsync("/api/admin/link-suggestions", new { SourceSuggestionId = a, TargetSuggestionId = a });
|
||||
var same = await admin.PostAsJsonAsync("/api/admin/link-suggestions", new
|
||||
{
|
||||
SourceSuggestionId = a,
|
||||
TargetSuggestionId = a
|
||||
});
|
||||
Assert.Equal(HttpStatusCode.BadRequest, same.StatusCode);
|
||||
|
||||
var first = await admin.PostAsJsonAsync("/api/admin/link-suggestions", new { SourceSuggestionId = a, TargetSuggestionId = b });
|
||||
var first = await admin.PostAsJsonAsync("/api/admin/link-suggestions", new
|
||||
{
|
||||
SourceSuggestionId = a,
|
||||
TargetSuggestionId = b
|
||||
});
|
||||
first.EnsureSuccessStatusCode();
|
||||
|
||||
var already = await admin.PostAsJsonAsync("/api/admin/link-suggestions", new { SourceSuggestionId = a, TargetSuggestionId = b });
|
||||
var already = await admin.PostAsJsonAsync("/api/admin/link-suggestions", new
|
||||
{
|
||||
SourceSuggestionId = a,
|
||||
TargetSuggestionId = b
|
||||
});
|
||||
Assert.Equal(HttpStatusCode.BadRequest, already.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unlink_suggestions_clears_group_votes()
|
||||
{
|
||||
using var factory = new TestWebApplicationFactory();
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var admin = factory.CreateClientWithCookies();
|
||||
await admin.RegisterAsync("admin", admin: true);
|
||||
var player = factory.CreateClientWithCookies();
|
||||
@@ -114,24 +148,41 @@ public class AdminTests
|
||||
var b = await player.CreateSuggestionAsync("Game B");
|
||||
await player.PostAsJsonAsync("/api/me/phase/next", new { });
|
||||
await admin.PostAsJsonAsync("/api/me/phase/next", new { });
|
||||
await admin.PostAsJsonAsync("/api/admin/link-suggestions", new { SourceSuggestionId = a, TargetSuggestionId = b });
|
||||
await admin.PostAsJsonAsync("/api/admin/link-suggestions", new
|
||||
{
|
||||
SourceSuggestionId = a,
|
||||
TargetSuggestionId = b
|
||||
});
|
||||
|
||||
await player.PostAsJsonAsync("/api/votes", new { SuggestionId = a, Score = 6 });
|
||||
await player.PostAsJsonAsync("/api/votes", new
|
||||
{
|
||||
SuggestionId = a,
|
||||
Score = 6
|
||||
});
|
||||
|
||||
var resp = await admin.PostAsJsonAsync("/api/admin/unlink-suggestions", new { suggestionId = a });
|
||||
resp.EnsureSuccessStatusCode();
|
||||
|
||||
await factory.WithDbContextAsync(async db =>
|
||||
await factory.WithDbContextAsync(db =>
|
||||
{
|
||||
Assert.Empty(db.Votes);
|
||||
Assert.All(db.Suggestions, s => Assert.Null(s.ParentSuggestionId));
|
||||
try
|
||||
{
|
||||
Assert.Empty(db.Votes);
|
||||
|
||||
Assert.All(db.Suggestions, s => Assert.Null(s.ParentSuggestionId));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
return Task.FromException(exception);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reset_and_factory_reset_clear_state()
|
||||
{
|
||||
using var factory = new TestWebApplicationFactory();
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var admin = factory.CreateClientWithCookies();
|
||||
await admin.RegisterAsync("admin", admin: true);
|
||||
var player = factory.CreateClientWithCookies();
|
||||
@@ -141,27 +192,46 @@ public class AdminTests
|
||||
var reset = await admin.PostAsJsonAsync("/api/admin/reset", new { });
|
||||
reset.EnsureSuccessStatusCode();
|
||||
|
||||
await factory.WithDbContextAsync(async db =>
|
||||
await factory.WithDbContextAsync(db =>
|
||||
{
|
||||
Assert.Empty(db.Suggestions);
|
||||
Assert.Empty(db.Votes);
|
||||
Assert.All(db.Players, p => Assert.Equal(Phase.Suggest, p.CurrentPhase));
|
||||
try
|
||||
{
|
||||
Assert.Empty(db.Suggestions);
|
||||
|
||||
Assert.Empty(db.Votes);
|
||||
|
||||
Assert.All(db.Players, p => Assert.Equal(Phase.Suggest, p.CurrentPhase));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
return Task.FromException(exception);
|
||||
}
|
||||
});
|
||||
|
||||
var factoryReset = await admin.PostAsJsonAsync("/api/admin/factory-reset", new { });
|
||||
factoryReset.EnsureSuccessStatusCode();
|
||||
|
||||
await factory.WithDbContextAsync(async db =>
|
||||
await factory.WithDbContextAsync(db =>
|
||||
{
|
||||
Assert.Empty(db.Players);
|
||||
Assert.Single(db.AppState);
|
||||
try
|
||||
{
|
||||
Assert.Empty(db.Players);
|
||||
|
||||
Assert.Single(db.AppState);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
return Task.FromException(exception);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Admin_results_closing_moves_back_to_vote_and_clears_finalize()
|
||||
{
|
||||
using var factory = new TestWebApplicationFactory();
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var admin = factory.CreateClientWithCookies();
|
||||
await admin.RegisterAsync("admin", admin: true);
|
||||
var player = factory.CreateClientWithCookies();
|
||||
@@ -196,7 +266,7 @@ public class AdminTests
|
||||
[Fact]
|
||||
public async Task Vote_status_lists_waiting_players()
|
||||
{
|
||||
using var factory = new TestWebApplicationFactory();
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var admin = factory.CreateClientWithCookies();
|
||||
await admin.RegisterAsync("admin", admin: true);
|
||||
await admin.PostAsJsonAsync("/api/me/phase/next", new { });
|
||||
@@ -208,7 +278,11 @@ public class AdminTests
|
||||
var s = await p1.CreateSuggestionAsync("Game");
|
||||
await p1.PostAsJsonAsync("/api/me/phase/next", new { });
|
||||
await p2.PostAsJsonAsync("/api/me/phase/next", new { });
|
||||
await p1.PostAsJsonAsync("/api/votes", new { SuggestionId = s, Score = 5 });
|
||||
await p1.PostAsJsonAsync("/api/votes", new
|
||||
{
|
||||
SuggestionId = s,
|
||||
Score = 5
|
||||
});
|
||||
await p1.PostAsJsonAsync("/api/votes/finalize", new { Final = true });
|
||||
|
||||
var status = await admin.GetFromJsonAsync<JsonElement>("/api/admin/vote-status");
|
||||
@@ -220,7 +294,7 @@ public class AdminTests
|
||||
[Fact]
|
||||
public async Task Grant_joker_in_vote_sets_flag_and_unfinalizes()
|
||||
{
|
||||
using var factory = new TestWebApplicationFactory();
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var admin = factory.CreateClientWithCookies();
|
||||
await admin.RegisterAsync("admin", admin: true);
|
||||
|
||||
@@ -243,7 +317,7 @@ public class AdminTests
|
||||
[Fact]
|
||||
public async Task Link_requires_vote_phase_and_reparents_votes_reset()
|
||||
{
|
||||
using var factory = new TestWebApplicationFactory();
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var admin = factory.CreateClientWithCookies();
|
||||
await admin.RegisterAsync("admin", admin: true);
|
||||
var player = factory.CreateClientWithCookies();
|
||||
@@ -252,16 +326,28 @@ public class AdminTests
|
||||
var a = await player.CreateSuggestionAsync("A");
|
||||
var b = await player.CreateSuggestionAsync("B");
|
||||
|
||||
var beforeVotePhase = await admin.PostAsJsonAsync("/api/admin/link-suggestions", new { SourceSuggestionId = a, TargetSuggestionId = b });
|
||||
var beforeVotePhase = await admin.PostAsJsonAsync("/api/admin/link-suggestions", new
|
||||
{
|
||||
SourceSuggestionId = a,
|
||||
TargetSuggestionId = b
|
||||
});
|
||||
Assert.Equal(HttpStatusCode.BadRequest, beforeVotePhase.StatusCode);
|
||||
|
||||
await admin.PostAsJsonAsync("/api/me/phase/next", new { });
|
||||
await player.PostAsJsonAsync("/api/me/phase/next", new { });
|
||||
|
||||
await player.PostAsJsonAsync("/api/votes", new { SuggestionId = a, Score = 3 });
|
||||
await player.PostAsJsonAsync("/api/votes", new
|
||||
{
|
||||
SuggestionId = a,
|
||||
Score = 3
|
||||
});
|
||||
await player.PostAsJsonAsync("/api/votes/finalize", new { Final = true });
|
||||
|
||||
var link = await admin.PostAsJsonAsync("/api/admin/link-suggestions", new { SourceSuggestionId = a, TargetSuggestionId = b });
|
||||
var link = await admin.PostAsJsonAsync("/api/admin/link-suggestions", new
|
||||
{
|
||||
SourceSuggestionId = a,
|
||||
TargetSuggestionId = b
|
||||
});
|
||||
link.EnsureSuccessStatusCode();
|
||||
|
||||
await factory.WithDbContextAsync(async db =>
|
||||
@@ -276,7 +362,7 @@ public class AdminTests
|
||||
[Fact]
|
||||
public async Task Unlink_not_found_returns_empty_payload()
|
||||
{
|
||||
using var factory = new TestWebApplicationFactory();
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var admin = factory.CreateClientWithCookies();
|
||||
await admin.RegisterAsync("admin", admin: true);
|
||||
await admin.PostAsJsonAsync("/api/me/phase/next", new { });
|
||||
@@ -290,7 +376,7 @@ public class AdminTests
|
||||
[Fact]
|
||||
public async Task Reset_clears_flags_and_factory_reset_seeds_defaults()
|
||||
{
|
||||
using var factory = new TestWebApplicationFactory();
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var admin = factory.CreateClientWithCookies();
|
||||
await admin.RegisterAsync("admin", admin: true);
|
||||
var p = factory.CreateClientWithCookies();
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using GameList.Data;
|
||||
using GameList.Infrastructure;
|
||||
using GameList.Tests.Support;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -13,7 +12,7 @@ public class AuthTests
|
||||
[Fact]
|
||||
public async Task Register_trims_limits_and_sets_cookie_and_normalized_username()
|
||||
{
|
||||
using var factory = new TestWebApplicationFactory();
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var client = factory.CreateClientWithCookies();
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/auth/register", new
|
||||
@@ -25,8 +24,7 @@ public class AuthTests
|
||||
});
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
Assert.True(response.Headers.TryGetValues("Set-Cookie", out var cookies) &&
|
||||
cookies.Any(c => c.Contains(PlayerIdentityExtensions.PlayerCookieName)));
|
||||
Assert.True(response.Headers.TryGetValues("Set-Cookie", out var cookies) && cookies.Any(c => c.Contains(PlayerIdentityExtensions.PlayerCookieName)));
|
||||
|
||||
await factory.WithDbContextAsync(async db =>
|
||||
{
|
||||
@@ -41,7 +39,7 @@ public class AuthTests
|
||||
[Fact]
|
||||
public async Task Register_rejects_overlength_username_or_display_name()
|
||||
{
|
||||
using var factory = new TestWebApplicationFactory();
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var client = factory.CreateClientWithCookies();
|
||||
|
||||
var tooLongUser = new string('u', 25);
|
||||
@@ -67,7 +65,7 @@ public class AuthTests
|
||||
[Fact]
|
||||
public async Task Login_sets_last_login_and_fills_missing_display_name()
|
||||
{
|
||||
using var factory = new TestWebApplicationFactory();
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var client = factory.CreateClientWithCookies();
|
||||
await client.RegisterAsync("loginfill");
|
||||
|
||||
@@ -93,7 +91,7 @@ public class AuthTests
|
||||
[Fact]
|
||||
public async Task Register_with_admin_key_sets_admin_flag()
|
||||
{
|
||||
using var factory = new TestWebApplicationFactory();
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var client = factory.CreateClientWithCookies();
|
||||
|
||||
var response = await client.RegisterAsync("adminuser", admin: true);
|
||||
@@ -106,7 +104,7 @@ public class AuthTests
|
||||
[Fact]
|
||||
public async Task Register_duplicate_username_returns_conflict()
|
||||
{
|
||||
using var factory = new TestWebApplicationFactory();
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var client = factory.CreateClientWithCookies();
|
||||
|
||||
var first = await client.RegisterAsync("duplicate");
|
||||
@@ -120,7 +118,7 @@ public class AuthTests
|
||||
[Fact]
|
||||
public async Task Login_with_wrong_password_returns_unauthorized()
|
||||
{
|
||||
using var factory = new TestWebApplicationFactory();
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var client = factory.CreateClientWithCookies();
|
||||
|
||||
await client.RegisterAsync("player1");
|
||||
@@ -133,20 +131,31 @@ public class AuthTests
|
||||
[Fact]
|
||||
public async Task Register_validates_required_fields()
|
||||
{
|
||||
using var factory = new TestWebApplicationFactory();
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var client = factory.CreateClientWithCookies();
|
||||
|
||||
var missing = await client.PostAsJsonAsync("/api/auth/register", new { Username = "", Password = "", DisplayName = "" });
|
||||
var missing = await client.PostAsJsonAsync("/api/auth/register", new
|
||||
{
|
||||
Username = "",
|
||||
Password = "",
|
||||
DisplayName = ""
|
||||
});
|
||||
Assert.Equal(HttpStatusCode.BadRequest, missing.StatusCode);
|
||||
|
||||
var badKey = await client.PostAsJsonAsync("/api/auth/register", new { Username = "u", Password = "p", DisplayName = "d", AdminKey = "wrong" });
|
||||
var badKey = await client.PostAsJsonAsync("/api/auth/register", new
|
||||
{
|
||||
Username = "u",
|
||||
Password = "p",
|
||||
DisplayName = "d",
|
||||
AdminKey = "wrong"
|
||||
});
|
||||
Assert.Equal(HttpStatusCode.BadRequest, badKey.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Non_admin_cannot_access_admin_routes()
|
||||
{
|
||||
using var factory = new TestWebApplicationFactory();
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var player = factory.CreateClientWithCookies();
|
||||
await player.RegisterAsync("regular");
|
||||
|
||||
@@ -157,7 +166,7 @@ public class AuthTests
|
||||
[Fact]
|
||||
public async Task Admin_can_access_admin_routes()
|
||||
{
|
||||
using var factory = new TestWebApplicationFactory();
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var admin = factory.CreateClientWithCookies();
|
||||
await admin.RegisterAsync("adminuser", admin: true);
|
||||
|
||||
@@ -168,7 +177,7 @@ public class AuthTests
|
||||
[Fact]
|
||||
public async Task Logout_clears_cookie()
|
||||
{
|
||||
using var factory = new TestWebApplicationFactory();
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var client = factory.CreateClientWithCookies();
|
||||
await client.RegisterAsync("logoutme");
|
||||
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
using System.IO;
|
||||
using System.Security.Claims;
|
||||
using GameList.Data;
|
||||
using GameList.Domain;
|
||||
using GameList.Infrastructure;
|
||||
using GameList.Tests.Support;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
|
||||
namespace GameList.Tests;
|
||||
|
||||
@@ -16,7 +13,7 @@ public class FiltersTests
|
||||
[Fact]
|
||||
public async Task Admin_only_filter_blocks_non_admin()
|
||||
{
|
||||
using var factory = new TestWebApplicationFactory();
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var client = factory.CreateClientWithCookies();
|
||||
await client.RegisterAsync("user");
|
||||
|
||||
@@ -29,7 +26,7 @@ public class FiltersTests
|
||||
[Fact]
|
||||
public async Task Phase_requirement_allows_admin_override_when_enabled()
|
||||
{
|
||||
using var factory = new TestWebApplicationFactory();
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var ctx = await BuildContextAsync(factory, isAdmin: true, phase: Phase.Suggest);
|
||||
var filter = new PhaseRequirementFilter(Phase.Vote, allowAdminOverride: true);
|
||||
var called = false;
|
||||
@@ -46,7 +43,7 @@ public class FiltersTests
|
||||
[Fact]
|
||||
public async Task Phase_or_joker_filter_blocks_without_joker_in_vote()
|
||||
{
|
||||
using var factory = new TestWebApplicationFactory();
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var ctx = await BuildContextAsync(factory, isAdmin: false, phase: Phase.Vote, hasJoker: false);
|
||||
var filter = new PhaseOrJokerFilter();
|
||||
var result = await filter.InvokeAsync(ctx, _ => ValueTask.FromResult<object?>(Results.Ok()));
|
||||
@@ -62,8 +59,8 @@ public class FiltersTests
|
||||
Id = Guid.NewGuid(),
|
||||
Username = $"user-{Guid.NewGuid():N}",
|
||||
NormalizedUsername = $"user-{Guid.NewGuid():N}",
|
||||
PasswordHash = new byte[] { 1 },
|
||||
PasswordSalt = new byte[] { 1 },
|
||||
PasswordHash = [1],
|
||||
PasswordSalt = [1],
|
||||
IsAdmin = isAdmin,
|
||||
CurrentPhase = phase,
|
||||
HasJoker = hasJoker,
|
||||
@@ -75,28 +72,20 @@ public class FiltersTests
|
||||
var ctx = new DefaultHttpContext
|
||||
{
|
||||
RequestServices = scope.ServiceProvider,
|
||||
User = new ClaimsPrincipal(new ClaimsIdentity(new[]
|
||||
{
|
||||
User = new ClaimsPrincipal(new ClaimsIdentity([
|
||||
new Claim(ClaimTypes.NameIdentifier, player.Id.ToString()),
|
||||
new Claim(ClaimTypes.Name, player.Username),
|
||||
new Claim(PlayerIdentityExtensions.AdminClaim, isAdmin ? "true" : "false")
|
||||
}, "cookie"))
|
||||
], "cookie"))
|
||||
};
|
||||
|
||||
return new TestInvocationContext(ctx);
|
||||
}
|
||||
|
||||
private class TestInvocationContext : EndpointFilterInvocationContext
|
||||
private class TestInvocationContext(DefaultHttpContext context) : EndpointFilterInvocationContext
|
||||
{
|
||||
private readonly DefaultHttpContext _context;
|
||||
|
||||
public TestInvocationContext(DefaultHttpContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public override HttpContext HttpContext => _context;
|
||||
public override object?[] Arguments => Array.Empty<object?>();
|
||||
public override HttpContext HttpContext => context;
|
||||
public override object?[] Arguments => [];
|
||||
public override T GetArgument<T>(int index) => throw new NotImplementedException();
|
||||
}
|
||||
|
||||
@@ -104,11 +93,9 @@ public class FiltersTests
|
||||
{
|
||||
var http = new DefaultHttpContext
|
||||
{
|
||||
RequestServices = new ServiceCollection()
|
||||
.AddLogging()
|
||||
.BuildServiceProvider()
|
||||
RequestServices = new ServiceCollection().AddLogging().BuildServiceProvider(),
|
||||
Response = { Body = new MemoryStream() }
|
||||
};
|
||||
http.Response.Body = new MemoryStream();
|
||||
await ((IResult)result!).ExecuteAsync(http);
|
||||
Assert.Equal(statusCode, http.Response.StatusCode);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Reflection;
|
||||
using GameList.Infrastructure;
|
||||
@@ -7,10 +6,7 @@ using GameList.Endpoints;
|
||||
using GameList.Tests.Support;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.FileProviders;
|
||||
using System.Linq;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using System.Text.Json;
|
||||
using System.Net.Http.Json;
|
||||
|
||||
@@ -36,9 +32,8 @@ public class HelperTests
|
||||
File.WriteAllText(index, "<meta name=\"app-base\" content=\"\">");
|
||||
|
||||
var env = new FakeEnv { WebRootPath = webRoot };
|
||||
var method = typeof(Program).GetMethods(BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public)
|
||||
.First(m => m.Name.Contains("UpdateIndexMetaBase"));
|
||||
method.Invoke(null, new object?[] { env, "/pick" });
|
||||
var method = typeof(Program).GetMethods(BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public).First(m => m.Name.Contains("UpdateIndexMetaBase"));
|
||||
method.Invoke(null, [env, "/pick"]);
|
||||
|
||||
var text = File.ReadAllText(index);
|
||||
Assert.Contains("content=\"/pick\"", text);
|
||||
@@ -53,9 +48,8 @@ public class HelperTests
|
||||
File.WriteAllText(index, "<html></html>");
|
||||
|
||||
var env = new FakeEnv { WebRootPath = webRoot };
|
||||
var method = typeof(Program).GetMethods(BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public)
|
||||
.First(m => m.Name.Contains("UpdateIndexMetaBase"));
|
||||
method.Invoke(null, new object?[] { env, "/pick" });
|
||||
var method = typeof(Program).GetMethods(BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public).First(m => m.Name.Contains("UpdateIndexMetaBase"));
|
||||
method.Invoke(null, [env, "/pick"]);
|
||||
|
||||
Assert.Equal("<html></html>", File.ReadAllText(index));
|
||||
}
|
||||
@@ -76,14 +70,15 @@ public class HelperTests
|
||||
if (req.Method == HttpMethod.Head)
|
||||
{
|
||||
var resp = new HttpResponseMessage(HttpStatusCode.OK);
|
||||
resp.Content = new ByteArrayContent(Array.Empty<byte>());
|
||||
resp.Content = new ByteArrayContent([]);
|
||||
resp.Content.Headers.ContentType = new MediaTypeHeaderValue("image/png");
|
||||
resp.Content.Headers.ContentLength = 100;
|
||||
return resp;
|
||||
}
|
||||
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new ByteArrayContent(Array.Empty<byte>())
|
||||
Content = new ByteArrayContent([])
|
||||
{
|
||||
Headers =
|
||||
{
|
||||
@@ -125,7 +120,7 @@ public class HelperTests
|
||||
handler.SetResponder(_ =>
|
||||
{
|
||||
var resp = new HttpResponseMessage(HttpStatusCode.OK);
|
||||
resp.Content = new ByteArrayContent(System.Text.Encoding.UTF8.GetBytes("not image"));
|
||||
resp.Content = new ByteArrayContent("not image"u8.ToArray());
|
||||
resp.Content.Headers.ContentType = new MediaTypeHeaderValue("text/plain");
|
||||
resp.Content.Headers.ContentLength = 9;
|
||||
return resp;
|
||||
@@ -138,7 +133,7 @@ public class HelperTests
|
||||
[Fact]
|
||||
public void Link_root_helpers_handle_groups()
|
||||
{
|
||||
var roots = EndpointHelpers.BuildLinkRoots(new[] { (1, (int?)null), (2, 1), (3, (int?)null) });
|
||||
var roots = EndpointHelpers.BuildLinkRoots([(1, null), (2, 1), (3, null)]);
|
||||
Assert.Equal(1, roots[1]);
|
||||
Assert.Equal(1, roots[2]);
|
||||
Assert.Equal(3, roots[3]);
|
||||
@@ -162,9 +157,9 @@ public class HelperTests
|
||||
{
|
||||
var parentMap = new Dictionary<int, int?>
|
||||
{
|
||||
{1, 2},
|
||||
{2, 3},
|
||||
{3, 1}
|
||||
{ 1, 2 },
|
||||
{ 2, 3 },
|
||||
{ 3, 1 }
|
||||
};
|
||||
|
||||
var root = EndpointHelpers.FindRootId(1, parentMap);
|
||||
@@ -174,16 +169,13 @@ public class HelperTests
|
||||
[Fact]
|
||||
public async Task Global_exception_handler_returns_json_error()
|
||||
{
|
||||
using var factory = new TestWebApplicationFactory().WithWebHostBuilder(builder =>
|
||||
await using var factory = new TestWebApplicationFactory().WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.Configure(app =>
|
||||
{
|
||||
app.UseGlobalExceptionLogging();
|
||||
app.UseRouting();
|
||||
app.UseEndpoints(endpoints =>
|
||||
{
|
||||
endpoints.MapGet("/boom", _ => throw new InvalidOperationException("boom"));
|
||||
});
|
||||
app.UseEndpoints(endpoints => { endpoints.MapGet("/boom", _ => throw new InvalidOperationException("boom")); });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
using System.Security.Claims;
|
||||
using GameList.Domain;
|
||||
using GameList.Infrastructure;
|
||||
using GameList.Tests.Support;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
|
||||
namespace GameList.Tests;
|
||||
|
||||
@@ -14,16 +12,16 @@ public class IdentityTests
|
||||
[Fact]
|
||||
public async Task Sign_in_sets_claims_and_cookie()
|
||||
{
|
||||
using var factory = new TestWebApplicationFactory();
|
||||
var ctx = BuildAuthContext(factory.Services);
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var ctx = BuildAuthContext();
|
||||
|
||||
var player = new Player
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Username = "claimuser",
|
||||
NormalizedUsername = "claimuser",
|
||||
PasswordHash = new byte[] { 1 },
|
||||
PasswordSalt = new byte[] { 1 },
|
||||
PasswordHash = [1],
|
||||
PasswordSalt = [1],
|
||||
DisplayName = "Claim",
|
||||
IsAdmin = true
|
||||
};
|
||||
@@ -31,15 +29,14 @@ public class IdentityTests
|
||||
await PlayerIdentityExtensions.SignInPlayerAsync(ctx, player);
|
||||
|
||||
var cookies = ctx.Response.Headers["Set-Cookie"];
|
||||
Assert.NotNull(cookies);
|
||||
Assert.Contains(cookies!, v => v.Contains(PlayerIdentityExtensions.PlayerCookieName));
|
||||
Assert.Contains(cookies, v => v != null && v.Contains(PlayerIdentityExtensions.PlayerCookieName));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Sign_out_clears_principal()
|
||||
{
|
||||
using var factory = new TestWebApplicationFactory();
|
||||
var ctx = BuildAuthContext(factory.Services);
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var ctx = BuildAuthContext();
|
||||
var player = new Player();
|
||||
await PlayerIdentityExtensions.SignInPlayerAsync(ctx, player);
|
||||
|
||||
@@ -48,15 +45,11 @@ public class IdentityTests
|
||||
Assert.False(ctx.User.Identity?.IsAuthenticated ?? false);
|
||||
}
|
||||
|
||||
private static DefaultHttpContext BuildAuthContext(IServiceProvider services)
|
||||
private static DefaultHttpContext BuildAuthContext()
|
||||
{
|
||||
var serviceCollection = new ServiceCollection();
|
||||
serviceCollection.AddSingleton<IHttpContextAccessor, HttpContextAccessor>();
|
||||
serviceCollection.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
|
||||
.AddCookie(options =>
|
||||
{
|
||||
options.Cookie.Name = PlayerIdentityExtensions.PlayerCookieName;
|
||||
});
|
||||
serviceCollection.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie(options => { options.Cookie.Name = PlayerIdentityExtensions.PlayerCookieName; });
|
||||
serviceCollection.AddLogging();
|
||||
var provider = serviceCollection.BuildServiceProvider();
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ public class MiddlewareTests
|
||||
[Fact]
|
||||
public async Task Deleted_player_cookie_is_signed_out()
|
||||
{
|
||||
using var factory = new TestWebApplicationFactory();
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var client = factory.CreateClientWithCookies();
|
||||
await client.RegisterAsync("ghost");
|
||||
|
||||
@@ -29,7 +29,7 @@ public class MiddlewareTests
|
||||
[Fact]
|
||||
public async Task Existing_player_passes_through_middleware()
|
||||
{
|
||||
using var factory = new TestWebApplicationFactory();
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var client = factory.CreateClientWithCookies();
|
||||
await client.RegisterAsync("live");
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ public class ResultsTests
|
||||
[Fact]
|
||||
public async Task Results_available_after_admin_unlocks()
|
||||
{
|
||||
using var factory = new TestWebApplicationFactory();
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var admin = factory.CreateClientWithCookies();
|
||||
await admin.RegisterAsync("admin", admin: true);
|
||||
|
||||
@@ -18,7 +18,11 @@ public class ResultsTests
|
||||
var suggestionId = await player.CreateSuggestionAsync("ResultGame");
|
||||
|
||||
await player.PostAsJsonAsync("/api/me/phase/next", new { });
|
||||
await player.PostAsJsonAsync("/api/votes", new { SuggestionId = suggestionId, Score = 8 });
|
||||
await player.PostAsJsonAsync("/api/votes", new
|
||||
{
|
||||
SuggestionId = suggestionId,
|
||||
Score = 8
|
||||
});
|
||||
await player.PostAsJsonAsync("/api/votes/finalize", new { Final = true });
|
||||
|
||||
await admin.PostAsJsonAsync("/api/admin/results", new { resultsOpen = true });
|
||||
@@ -36,7 +40,7 @@ public class ResultsTests
|
||||
[Fact]
|
||||
public async Task Results_locked_returns_error()
|
||||
{
|
||||
using var factory = new TestWebApplicationFactory();
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var client = factory.CreateClientWithCookies();
|
||||
await client.RegisterAsync("user");
|
||||
await client.PostAsJsonAsync("/api/me/phase/next", new { });
|
||||
@@ -47,7 +51,7 @@ public class ResultsTests
|
||||
[Fact]
|
||||
public async Task Results_require_results_phase_and_auth()
|
||||
{
|
||||
using var factory = new TestWebApplicationFactory();
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var admin = factory.CreateClientWithCookies();
|
||||
await admin.RegisterAsync("admin", admin: true);
|
||||
var player = factory.CreateClientWithCookies();
|
||||
@@ -65,17 +69,21 @@ public class ResultsTests
|
||||
[Fact]
|
||||
public async Task Results_payload_contains_fields_and_ordering()
|
||||
{
|
||||
using var factory = new TestWebApplicationFactory();
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var admin = factory.CreateClientWithCookies();
|
||||
await admin.RegisterAsync("admin", admin: true);
|
||||
|
||||
var player = factory.CreateClientWithCookies();
|
||||
await player.RegisterAsync("player");
|
||||
var s1 = await player.CreateSuggestionAsync("High");
|
||||
var s2 = await player.CreateSuggestionAsync("NoVotes");
|
||||
_ = await player.CreateSuggestionAsync("NoVotes");
|
||||
|
||||
await player.PostAsJsonAsync("/api/me/phase/next", new { });
|
||||
await player.PostAsJsonAsync("/api/votes", new { SuggestionId = s1, Score = 9 });
|
||||
await player.PostAsJsonAsync("/api/votes", new
|
||||
{
|
||||
SuggestionId = s1,
|
||||
Score = 9
|
||||
});
|
||||
await player.PostAsJsonAsync("/api/votes/finalize", new { Final = true });
|
||||
|
||||
await admin.PostAsJsonAsync("/api/admin/results", new { resultsOpen = true });
|
||||
@@ -83,7 +91,7 @@ public class ResultsTests
|
||||
|
||||
var results = await player.GetFromJsonAsync<List<JsonElement>>("/api/results");
|
||||
Assert.NotNull(results);
|
||||
Assert.Equal(2, results!.Count);
|
||||
Assert.Equal(2, results.Count);
|
||||
Assert.Equal("High", results[0].GetProperty("name").GetString());
|
||||
Assert.Equal(9, (int)results[0].GetProperty("average").GetDouble());
|
||||
Assert.Equal(1, results[0].GetProperty("count").GetInt32());
|
||||
|
||||
@@ -14,7 +14,7 @@ public class StateTests
|
||||
[Fact]
|
||||
public async Task State_endpoint_returns_expected_payload_for_authenticated_user()
|
||||
{
|
||||
using var factory = new TestWebApplicationFactory();
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var client = factory.CreateClientWithCookies();
|
||||
await client.RegisterAsync("payload");
|
||||
await factory.WithDbContextAsync(async db =>
|
||||
@@ -27,7 +27,7 @@ public class StateTests
|
||||
|
||||
var state = await client.GetFromJsonAsync<JsonElement>("/api/state");
|
||||
|
||||
Assert.Equal(Phase.Suggest.ToString(), state.GetProperty("currentPhase").GetString());
|
||||
Assert.Equal(nameof(Phase.Suggest), state.GetProperty("currentPhase").GetString());
|
||||
Assert.False(state.GetProperty("votesFinal").GetBoolean());
|
||||
Assert.True(state.GetProperty("hasJoker").GetBoolean());
|
||||
Assert.True(state.GetProperty("players").GetInt32() >= 1);
|
||||
@@ -38,7 +38,7 @@ public class StateTests
|
||||
[Fact]
|
||||
public async Task GetPhase_upgrades_reveal_and_resets_when_results_close()
|
||||
{
|
||||
using var factory = new TestWebApplicationFactory();
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
Guid playerId = Guid.Empty;
|
||||
await factory.WithDbContextAsync(async db =>
|
||||
{
|
||||
@@ -47,8 +47,8 @@ public class StateTests
|
||||
Id = Guid.NewGuid(),
|
||||
Username = "legacy",
|
||||
NormalizedUsername = "legacy",
|
||||
PasswordHash = new byte[] { 1 },
|
||||
PasswordSalt = new byte[] { 1 },
|
||||
PasswordHash = [1],
|
||||
PasswordSalt = [1],
|
||||
DisplayName = "Legacy",
|
||||
CurrentPhase = Phase.Reveal,
|
||||
VotesFinal = true
|
||||
@@ -63,7 +63,7 @@ public class StateTests
|
||||
using (var scope = factory.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var phase = await GameList.Endpoints.EndpointHelpers.GetPhase(db, playerId);
|
||||
var phase = await Endpoints.EndpointHelpers.GetPhase(db, playerId);
|
||||
Assert.Equal(Phase.Results, phase);
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@ public class StateTests
|
||||
using (var scope = factory.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var phase = await GameList.Endpoints.EndpointHelpers.GetPhase(db, playerId);
|
||||
var phase = await Endpoints.EndpointHelpers.GetPhase(db, playerId);
|
||||
var player = await db.Players.FindAsync(playerId);
|
||||
Assert.Equal(Phase.Vote, phase);
|
||||
Assert.False(player!.VotesFinal);
|
||||
@@ -87,7 +87,7 @@ public class StateTests
|
||||
[Fact]
|
||||
public async Task Phase_next_advances_and_clears_votesfinal()
|
||||
{
|
||||
using var factory = new TestWebApplicationFactory();
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var client = factory.CreateClientWithCookies();
|
||||
await client.RegisterAsync("advance");
|
||||
|
||||
@@ -111,13 +111,13 @@ public class StateTests
|
||||
toResults.EnsureSuccessStatusCode();
|
||||
var me = await client.GetFromJsonAsync<JsonElement>("/api/me");
|
||||
Assert.False(me.GetProperty("votesFinal").GetBoolean());
|
||||
Assert.Equal(Phase.Results.ToString(), me.GetProperty("currentPhase").GetString());
|
||||
Assert.Equal(nameof(Phase.Results), me.GetProperty("currentPhase").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Phase_prev_moves_back_and_clears_votesfinal()
|
||||
{
|
||||
using var factory = new TestWebApplicationFactory();
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var admin = factory.CreateClientWithCookies();
|
||||
await admin.RegisterAsync("admin", admin: true);
|
||||
|
||||
@@ -132,14 +132,14 @@ public class StateTests
|
||||
backToSuggest.EnsureSuccessStatusCode();
|
||||
|
||||
var me = await admin.GetFromJsonAsync<JsonElement>("/api/me");
|
||||
Assert.Equal(Phase.Suggest.ToString(), me.GetProperty("currentPhase").GetString());
|
||||
Assert.Equal(nameof(Phase.Suggest), me.GetProperty("currentPhase").GetString());
|
||||
Assert.False(me.GetProperty("votesFinal").GetBoolean());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Name_endpoint_rejects_over_16_chars()
|
||||
{
|
||||
using var factory = new TestWebApplicationFactory();
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var client = factory.CreateClientWithCookies();
|
||||
await client.RegisterAsync("namelimit");
|
||||
|
||||
@@ -150,7 +150,7 @@ public class StateTests
|
||||
[Fact]
|
||||
public async Task Cannot_advance_to_results_when_locked()
|
||||
{
|
||||
using var factory = new TestWebApplicationFactory();
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var client = factory.CreateClientWithCookies();
|
||||
await client.RegisterAsync("player");
|
||||
|
||||
@@ -165,7 +165,7 @@ public class StateTests
|
||||
[Fact]
|
||||
public async Task Admin_opening_results_moves_players_to_results_phase()
|
||||
{
|
||||
using var factory = new TestWebApplicationFactory();
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var admin = factory.CreateClientWithCookies();
|
||||
await admin.RegisterAsync("admin", admin: true);
|
||||
|
||||
@@ -177,14 +177,14 @@ public class StateTests
|
||||
|
||||
var state = await player.GetFromJsonAsync<JsonElement>("/api/state");
|
||||
|
||||
Assert.Equal(Phase.Results.ToString(), state.GetProperty("currentPhase").GetString());
|
||||
Assert.Equal(nameof(Phase.Results), state.GetProperty("currentPhase").GetString());
|
||||
Assert.True(state.GetProperty("resultsOpen").GetBoolean());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Name_endpoint_trims_and_rejects_blank()
|
||||
{
|
||||
using var factory = new TestWebApplicationFactory();
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var client = factory.CreateClientWithCookies();
|
||||
await client.RegisterAsync("nametest");
|
||||
|
||||
@@ -200,7 +200,7 @@ public class StateTests
|
||||
[Fact]
|
||||
public async Task Phase_prev_admin_only()
|
||||
{
|
||||
using var factory = new TestWebApplicationFactory();
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var player = factory.CreateClientWithCookies();
|
||||
await player.RegisterAsync("phase");
|
||||
|
||||
@@ -213,13 +213,13 @@ public class StateTests
|
||||
var back = await admin.PostAsJsonAsync("/api/me/phase/prev", new { });
|
||||
back.EnsureSuccessStatusCode();
|
||||
var me = await admin.GetFromJsonAsync<JsonElement>("/api/me");
|
||||
Assert.Equal(Phase.Suggest.ToString(), me.GetProperty("currentPhase").GetString());
|
||||
Assert.Equal(nameof(Phase.Suggest), me.GetProperty("currentPhase").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task State_endpoint_requires_auth_and_counts()
|
||||
{
|
||||
using var factory = new TestWebApplicationFactory();
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var anon = factory.CreateClient();
|
||||
var unauthorized = await anon.GetAsync("/api/state");
|
||||
Assert.NotEqual(HttpStatusCode.OK, unauthorized.StatusCode);
|
||||
@@ -237,7 +237,7 @@ public class StateTests
|
||||
[Fact]
|
||||
public async Task Health_endpoint_ok()
|
||||
{
|
||||
using var factory = new TestWebApplicationFactory();
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var resp = await factory.CreateClient().GetFromJsonAsync<JsonElement>("/health");
|
||||
Assert.Equal("ok", resp.GetProperty("status").GetString());
|
||||
}
|
||||
@@ -245,7 +245,7 @@ public class StateTests
|
||||
[Fact]
|
||||
public async Task GetPhase_aligns_to_results_when_open()
|
||||
{
|
||||
using var factory = new TestWebApplicationFactory();
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
await factory.WithDbContextAsync(async db =>
|
||||
{
|
||||
var player = new Player
|
||||
@@ -253,8 +253,8 @@ public class StateTests
|
||||
Id = Guid.NewGuid(),
|
||||
Username = "phase",
|
||||
NormalizedUsername = "phase",
|
||||
PasswordHash = new byte[] { 1 },
|
||||
PasswordSalt = new byte[] { 1 },
|
||||
PasswordHash = [1],
|
||||
PasswordSalt = [1],
|
||||
DisplayName = "phase",
|
||||
CurrentPhase = Phase.Vote
|
||||
};
|
||||
@@ -267,7 +267,7 @@ public class StateTests
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var playerId = await db.Players.Select(p => p.Id).FirstAsync();
|
||||
var phase = await GameList.Endpoints.EndpointHelpers.GetPhase(db, playerId);
|
||||
var phase = await Endpoints.EndpointHelpers.GetPhase(db, playerId);
|
||||
|
||||
Assert.Equal(Phase.Results, phase);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using GameList.Tests.Support;
|
||||
using GameList.Domain;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace GameList.Tests;
|
||||
@@ -12,7 +11,7 @@ public class SuggestionTests
|
||||
[Fact]
|
||||
public async Task Player_cannot_exceed_five_suggestions()
|
||||
{
|
||||
using var factory = new TestWebApplicationFactory();
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var client = factory.CreateClientWithCookies();
|
||||
await client.RegisterAsync("suggestor");
|
||||
|
||||
@@ -50,7 +49,7 @@ public class SuggestionTests
|
||||
[Fact]
|
||||
public async Task Rejects_invalid_image_extension_and_player_counts()
|
||||
{
|
||||
using var factory = new TestWebApplicationFactory();
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var client = factory.CreateClientWithCookies();
|
||||
await client.RegisterAsync("validate");
|
||||
|
||||
@@ -84,7 +83,7 @@ public class SuggestionTests
|
||||
[Fact]
|
||||
public async Task Joker_allows_single_extra_suggestion_and_unfinalizes_votes()
|
||||
{
|
||||
using var factory = new TestWebApplicationFactory();
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var player = factory.CreateClientWithCookies();
|
||||
await player.RegisterAsync("joker");
|
||||
var other = factory.CreateClientWithCookies();
|
||||
@@ -126,7 +125,7 @@ public class SuggestionTests
|
||||
[Fact]
|
||||
public async Task Admin_can_update_during_vote_phase()
|
||||
{
|
||||
using var factory = new TestWebApplicationFactory();
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var admin = factory.CreateClientWithCookies();
|
||||
await admin.RegisterAsync("admin", admin: true);
|
||||
|
||||
@@ -152,7 +151,7 @@ public class SuggestionTests
|
||||
[Fact]
|
||||
public async Task Phase_gate_blocks_player_update_in_vote_phase()
|
||||
{
|
||||
using var factory = new TestWebApplicationFactory();
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var player = factory.CreateClientWithCookies();
|
||||
await player.RegisterAsync("phase");
|
||||
var id = await player.CreateSuggestionAsync("Lock");
|
||||
@@ -180,7 +179,7 @@ public class SuggestionTests
|
||||
[Fact]
|
||||
public async Task Player_cannot_edit_suggestion_in_results_phase()
|
||||
{
|
||||
using var factory = new TestWebApplicationFactory();
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var player = factory.CreateClientWithCookies();
|
||||
await player.RegisterAsync("results");
|
||||
var id = await player.CreateSuggestionAsync("Frozen");
|
||||
@@ -217,7 +216,7 @@ public class SuggestionTests
|
||||
[Fact]
|
||||
public async Task Player_cannot_edit_other_players_suggestion()
|
||||
{
|
||||
using var factory = new TestWebApplicationFactory();
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var owner = factory.CreateClientWithCookies();
|
||||
await owner.RegisterAsync("owner");
|
||||
var other = factory.CreateClientWithCookies();
|
||||
@@ -243,7 +242,7 @@ public class SuggestionTests
|
||||
[Fact]
|
||||
public async Task Joker_allows_unlimited_extra_suggestions_when_granted_multiple_times()
|
||||
{
|
||||
using var factory = new TestWebApplicationFactory();
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var client = factory.CreateClientWithCookies();
|
||||
await client.RegisterAsync("sixth");
|
||||
|
||||
@@ -327,7 +326,7 @@ public class SuggestionTests
|
||||
[Fact]
|
||||
public async Task Unreachable_screenshot_url_is_rejected()
|
||||
{
|
||||
using var factory = new TestWebApplicationFactory();
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
factory.HttpHandler.SetResponder(_ => new HttpResponseMessage(HttpStatusCode.BadRequest));
|
||||
|
||||
var client = factory.CreateClientWithCookies();
|
||||
@@ -351,7 +350,7 @@ public class SuggestionTests
|
||||
[Fact]
|
||||
public async Task Get_all_requires_vote_phase()
|
||||
{
|
||||
using var factory = new TestWebApplicationFactory();
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var client = factory.CreateClientWithCookies();
|
||||
await client.RegisterAsync("viewer");
|
||||
|
||||
@@ -362,13 +361,33 @@ public class SuggestionTests
|
||||
[Fact]
|
||||
public async Task Mine_returns_ordered_list()
|
||||
{
|
||||
using var factory = new TestWebApplicationFactory();
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var client = factory.CreateClientWithCookies();
|
||||
await client.RegisterAsync("mine");
|
||||
|
||||
await client.PostAsJsonAsync("/api/suggestions", new { Name = "Second", Genre = (string?)null, Description = (string?)null, ScreenshotUrl = (string?)null, YoutubeUrl = (string?)null, GameUrl = (string?)null, MinPlayers = (int?)null, MaxPlayers = (int?)null });
|
||||
await client.PostAsJsonAsync("/api/suggestions", new
|
||||
{
|
||||
Name = "Second",
|
||||
Genre = (string?)null,
|
||||
Description = (string?)null,
|
||||
ScreenshotUrl = (string?)null,
|
||||
YoutubeUrl = (string?)null,
|
||||
GameUrl = (string?)null,
|
||||
MinPlayers = (int?)null,
|
||||
MaxPlayers = (int?)null
|
||||
});
|
||||
await Task.Delay(10);
|
||||
await client.PostAsJsonAsync("/api/suggestions", new { Name = "Third", Genre = (string?)null, Description = (string?)null, ScreenshotUrl = (string?)null, YoutubeUrl = (string?)null, GameUrl = (string?)null, MinPlayers = (int?)null, MaxPlayers = (int?)null });
|
||||
await client.PostAsJsonAsync("/api/suggestions", new
|
||||
{
|
||||
Name = "Third",
|
||||
Genre = (string?)null,
|
||||
Description = (string?)null,
|
||||
ScreenshotUrl = (string?)null,
|
||||
YoutubeUrl = (string?)null,
|
||||
GameUrl = (string?)null,
|
||||
MinPlayers = (int?)null,
|
||||
MaxPlayers = (int?)null
|
||||
});
|
||||
|
||||
var mine = await client.GetFromJsonAsync<List<JsonElement>>("/api/suggestions/mine");
|
||||
Assert.Equal("Second", mine![0].GetProperty("name").GetString());
|
||||
@@ -377,7 +396,7 @@ public class SuggestionTests
|
||||
[Fact]
|
||||
public async Task Create_requires_suggest_phase_and_display_name()
|
||||
{
|
||||
using var factory = new TestWebApplicationFactory();
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var client = factory.CreateClientWithCookies();
|
||||
await client.RegisterAsync("phasegate");
|
||||
|
||||
@@ -389,7 +408,17 @@ public class SuggestionTests
|
||||
await db.SaveChangesAsync();
|
||||
});
|
||||
|
||||
var badPhase = await client.PostAsJsonAsync("/api/suggestions", new { Name = "Nope", Genre = (string?)null, Description = (string?)null, ScreenshotUrl = (string?)null, YoutubeUrl = (string?)null, GameUrl = (string?)null, MinPlayers = (int?)null, MaxPlayers = (int?)null });
|
||||
var badPhase = await client.PostAsJsonAsync("/api/suggestions", new
|
||||
{
|
||||
Name = "Nope",
|
||||
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, badPhase.StatusCode);
|
||||
|
||||
await factory.WithDbContextAsync(async db =>
|
||||
@@ -399,37 +428,97 @@ public class SuggestionTests
|
||||
await db.SaveChangesAsync();
|
||||
});
|
||||
|
||||
var noDisplay = await client.PostAsJsonAsync("/api/suggestions", new { Name = "NoDisplay", Genre = (string?)null, Description = (string?)null, ScreenshotUrl = (string?)null, YoutubeUrl = (string?)null, GameUrl = (string?)null, MinPlayers = (int?)null, MaxPlayers = (int?)null });
|
||||
var noDisplay = await client.PostAsJsonAsync("/api/suggestions", new
|
||||
{
|
||||
Name = "NoDisplay",
|
||||
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, noDisplay.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rejects_invalid_urls_name_length_and_player_counts()
|
||||
{
|
||||
using var factory = new TestWebApplicationFactory();
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var client = factory.CreateClientWithCookies();
|
||||
await client.RegisterAsync("validate2");
|
||||
|
||||
var badGame = await client.PostAsJsonAsync("/api/suggestions", new { Name = "Bad", Genre = (string?)null, Description = (string?)null, ScreenshotUrl = (string?)null, YoutubeUrl = (string?)null, GameUrl = "ftp://bad", MinPlayers = (int?)null, MaxPlayers = (int?)null });
|
||||
var badGame = await client.PostAsJsonAsync("/api/suggestions", new
|
||||
{
|
||||
Name = "Bad",
|
||||
Genre = (string?)null,
|
||||
Description = (string?)null,
|
||||
ScreenshotUrl = (string?)null,
|
||||
YoutubeUrl = (string?)null,
|
||||
GameUrl = "ftp://bad",
|
||||
MinPlayers = (int?)null,
|
||||
MaxPlayers = (int?)null
|
||||
});
|
||||
Assert.Equal(HttpStatusCode.BadRequest, badGame.StatusCode);
|
||||
|
||||
var badYoutube = await client.PostAsJsonAsync("/api/suggestions", new { Name = "BadYt", Genre = (string?)null, Description = (string?)null, ScreenshotUrl = (string?)null, YoutubeUrl = "file://bad", GameUrl = (string?)null, MinPlayers = (int?)null, MaxPlayers = (int?)null });
|
||||
var badYoutube = await client.PostAsJsonAsync("/api/suggestions", new
|
||||
{
|
||||
Name = "BadYt",
|
||||
Genre = (string?)null,
|
||||
Description = (string?)null,
|
||||
ScreenshotUrl = (string?)null,
|
||||
YoutubeUrl = "file://bad",
|
||||
GameUrl = (string?)null,
|
||||
MinPlayers = (int?)null,
|
||||
MaxPlayers = (int?)null
|
||||
});
|
||||
Assert.Equal(HttpStatusCode.BadRequest, badYoutube.StatusCode);
|
||||
|
||||
var longName = await client.PostAsJsonAsync("/api/suggestions", new { Name = new string('x', 101), Genre = (string?)null, Description = (string?)null, ScreenshotUrl = (string?)null, YoutubeUrl = (string?)null, GameUrl = (string?)null, MinPlayers = (int?)null, MaxPlayers = (int?)null });
|
||||
var longName = await client.PostAsJsonAsync("/api/suggestions", new
|
||||
{
|
||||
Name = new string('x', 101),
|
||||
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, longName.StatusCode);
|
||||
|
||||
var minOnly = await client.PostAsJsonAsync("/api/suggestions", new { Name = "MinOnly", Genre = (string?)null, Description = (string?)null, ScreenshotUrl = (string?)null, YoutubeUrl = (string?)null, GameUrl = (string?)null, MinPlayers = 2, MaxPlayers = (int?)null });
|
||||
var minOnly = await client.PostAsJsonAsync("/api/suggestions", new
|
||||
{
|
||||
Name = "MinOnly",
|
||||
Genre = (string?)null,
|
||||
Description = (string?)null,
|
||||
ScreenshotUrl = (string?)null,
|
||||
YoutubeUrl = (string?)null,
|
||||
GameUrl = (string?)null,
|
||||
MinPlayers = 2,
|
||||
MaxPlayers = (int?)null
|
||||
});
|
||||
Assert.Equal(HttpStatusCode.BadRequest, minOnly.StatusCode);
|
||||
|
||||
var maxTooHigh = await client.PostAsJsonAsync("/api/suggestions", new { Name = "MaxHigh", Genre = (string?)null, Description = (string?)null, ScreenshotUrl = (string?)null, YoutubeUrl = (string?)null, GameUrl = (string?)null, MinPlayers = 2, MaxPlayers = 40 });
|
||||
var maxTooHigh = await client.PostAsJsonAsync("/api/suggestions", new
|
||||
{
|
||||
Name = "MaxHigh",
|
||||
Genre = (string?)null,
|
||||
Description = (string?)null,
|
||||
ScreenshotUrl = (string?)null,
|
||||
YoutubeUrl = (string?)null,
|
||||
GameUrl = (string?)null,
|
||||
MinPlayers = 2,
|
||||
MaxPlayers = 40
|
||||
});
|
||||
Assert.Equal(HttpStatusCode.BadRequest, maxTooHigh.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Trims_and_truncates_optional_fields()
|
||||
{
|
||||
using var factory = new TestWebApplicationFactory();
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var client = factory.CreateClientWithCookies();
|
||||
await client.RegisterAsync("trim");
|
||||
|
||||
@@ -460,7 +549,7 @@ public class SuggestionTests
|
||||
[Fact]
|
||||
public async Task Mine_excludes_other_players()
|
||||
{
|
||||
using var factory = new TestWebApplicationFactory();
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var a = factory.CreateClientWithCookies();
|
||||
await a.RegisterAsync("alice");
|
||||
var b = factory.CreateClientWithCookies();
|
||||
@@ -470,14 +559,15 @@ public class SuggestionTests
|
||||
await b.CreateSuggestionAsync("BobGame");
|
||||
|
||||
var mine = await a.GetFromJsonAsync<List<JsonElement>>("/api/suggestions/mine");
|
||||
Assert.Single(mine!);
|
||||
Assert.NotNull(mine);
|
||||
Assert.Single(mine);
|
||||
Assert.Equal("AliceGame", mine[0].GetProperty("name").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task All_returns_link_metadata_and_ordering()
|
||||
{
|
||||
using var factory = new TestWebApplicationFactory();
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var client = factory.CreateClientWithCookies();
|
||||
await client.RegisterAsync("owner");
|
||||
|
||||
@@ -507,7 +597,7 @@ public class SuggestionTests
|
||||
[Fact]
|
||||
public async Task Delete_respects_phase_and_clears_links_and_votes()
|
||||
{
|
||||
using var factory = new TestWebApplicationFactory();
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var owner = factory.CreateClientWithCookies();
|
||||
await owner.RegisterAsync("deleter");
|
||||
var other = factory.CreateClientWithCookies();
|
||||
@@ -524,7 +614,11 @@ public class SuggestionTests
|
||||
|
||||
await owner.PostAsJsonAsync("/api/me/phase/next", new { }); // Vote
|
||||
await other.PostAsJsonAsync("/api/me/phase/next", new { });
|
||||
await other.PostAsJsonAsync("/api/votes", new { SuggestionId = id, Score = 5 });
|
||||
await other.PostAsJsonAsync("/api/votes", new
|
||||
{
|
||||
SuggestionId = id,
|
||||
Score = 5
|
||||
});
|
||||
|
||||
var blocked = await owner.DeleteAsync($"/api/suggestions/{id}");
|
||||
Assert.Equal(HttpStatusCode.BadRequest, blocked.StatusCode);
|
||||
|
||||
@@ -1,18 +1,9 @@
|
||||
using System.Net.Http;
|
||||
|
||||
namespace GameList.Tests.Support;
|
||||
|
||||
internal class StubHttpClientFactory : IHttpClientFactory
|
||||
internal class StubHttpClientFactory(StubHttpMessageHandler handler) : IHttpClientFactory
|
||||
{
|
||||
private readonly StubHttpMessageHandler _handler;
|
||||
|
||||
public StubHttpClientFactory(StubHttpMessageHandler handler)
|
||||
{
|
||||
_handler = handler;
|
||||
}
|
||||
|
||||
public HttpClient CreateClient(string name)
|
||||
{
|
||||
return new HttpClient(_handler, disposeHandler: false);
|
||||
return new HttpClient(handler, disposeHandler: false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,24 +5,16 @@ namespace GameList.Tests.Support;
|
||||
|
||||
internal class StubHttpMessageHandler : HttpMessageHandler
|
||||
{
|
||||
private Func<HttpRequestMessage, HttpResponseMessage> _responder;
|
||||
|
||||
public StubHttpMessageHandler()
|
||||
{
|
||||
_responder = DefaultResponder;
|
||||
}
|
||||
private Func<HttpRequestMessage, HttpResponseMessage> _responder = DefaultResponder;
|
||||
|
||||
public void SetResponder(Func<HttpRequestMessage, HttpResponseMessage> responder)
|
||||
{
|
||||
_responder = responder ?? DefaultResponder;
|
||||
_responder = responder;
|
||||
}
|
||||
|
||||
private static HttpResponseMessage DefaultResponder(HttpRequestMessage _)
|
||||
{
|
||||
var response = new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new ByteArrayContent(Array.Empty<byte>())
|
||||
};
|
||||
var response = new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent([]) };
|
||||
response.Content.Headers.ContentType = new MediaTypeHeaderValue("image/png");
|
||||
response.Content.Headers.ContentLength = 0;
|
||||
return response;
|
||||
|
||||
@@ -49,5 +49,4 @@ internal static class TestClientExtensions
|
||||
var me = await client.GetFromJsonAsync<JsonElement>("/api/me");
|
||||
return Guid.Parse(me.GetProperty("id").GetString()!);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using GameList.Data;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
@@ -18,13 +16,7 @@ internal class TestWebApplicationFactory : WebApplicationFactory<Program>
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
builder.UseEnvironment("Development");
|
||||
builder.ConfigureAppConfiguration((context, config) =>
|
||||
{
|
||||
config.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["ADMIN_PASSWORD"] = "admin-key"
|
||||
});
|
||||
});
|
||||
builder.ConfigureAppConfiguration((_, config) => { config.AddInMemoryCollection(new Dictionary<string, string?> { ["ADMIN_PASSWORD"] = "admin-key" }); });
|
||||
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
@@ -37,10 +29,7 @@ internal class TestWebApplicationFactory : WebApplicationFactory<Program>
|
||||
_connection = new SqliteConnection("Data Source=:memory:;Cache=Shared");
|
||||
_connection.Open();
|
||||
|
||||
services.AddDbContext<AppDbContext>(options =>
|
||||
{
|
||||
options.UseSqlite(_connection);
|
||||
});
|
||||
services.AddDbContext<AppDbContext>(options => { options.UseSqlite(_connection); });
|
||||
|
||||
services.AddSingleton<StubHttpMessageHandler>();
|
||||
services.AddSingleton<IHttpClientFactory, StubHttpClientFactory>();
|
||||
|
||||
@@ -11,7 +11,7 @@ public class VoteTests
|
||||
[Fact]
|
||||
public async Task Finalizing_votes_blocks_further_changes()
|
||||
{
|
||||
using var factory = new TestWebApplicationFactory();
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var client = factory.CreateClientWithCookies();
|
||||
await client.RegisterAsync("voter");
|
||||
|
||||
@@ -19,13 +19,21 @@ public class VoteTests
|
||||
|
||||
await client.PostAsJsonAsync("/api/me/phase/next", new { });
|
||||
|
||||
var vote = await client.PostAsJsonAsync("/api/votes", new { SuggestionId = suggestionId, Score = 7 });
|
||||
var vote = await client.PostAsJsonAsync("/api/votes", new
|
||||
{
|
||||
SuggestionId = suggestionId,
|
||||
Score = 7
|
||||
});
|
||||
vote.EnsureSuccessStatusCode();
|
||||
|
||||
var finalize = await client.PostAsJsonAsync("/api/votes/finalize", new { Final = true });
|
||||
finalize.EnsureSuccessStatusCode();
|
||||
|
||||
var change = await client.PostAsJsonAsync("/api/votes", new { SuggestionId = suggestionId, Score = 5 });
|
||||
var change = await client.PostAsJsonAsync("/api/votes", new
|
||||
{
|
||||
SuggestionId = suggestionId,
|
||||
Score = 5
|
||||
});
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, change.StatusCode);
|
||||
}
|
||||
@@ -33,45 +41,57 @@ public class VoteTests
|
||||
[Fact]
|
||||
public async Task Score_out_of_range_rejected()
|
||||
{
|
||||
using var factory = new TestWebApplicationFactory();
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var client = factory.CreateClientWithCookies();
|
||||
await client.RegisterAsync("score");
|
||||
var id = await client.CreateSuggestionAsync("RangeGame");
|
||||
await client.PostAsJsonAsync("/api/me/phase/next", new { });
|
||||
|
||||
var resp = await client.PostAsJsonAsync("/api/votes", new { SuggestionId = id, Score = 11 });
|
||||
var resp = await client.PostAsJsonAsync("/api/votes", new
|
||||
{
|
||||
SuggestionId = id,
|
||||
Score = 11
|
||||
});
|
||||
Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Negative_score_rejected()
|
||||
{
|
||||
using var factory = new TestWebApplicationFactory();
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var client = factory.CreateClientWithCookies();
|
||||
await client.RegisterAsync("negative");
|
||||
var id = await client.CreateSuggestionAsync("RangeGame2");
|
||||
await client.PostAsJsonAsync("/api/me/phase/next", new { });
|
||||
|
||||
var resp = await client.PostAsJsonAsync("/api/votes", new { SuggestionId = id, Score = -1 });
|
||||
var resp = await client.PostAsJsonAsync("/api/votes", new
|
||||
{
|
||||
SuggestionId = id,
|
||||
Score = -1
|
||||
});
|
||||
Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invalid_suggestion_id_rejected()
|
||||
{
|
||||
using var factory = new TestWebApplicationFactory();
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var client = factory.CreateClientWithCookies();
|
||||
await client.RegisterAsync("invalid");
|
||||
await client.PostAsJsonAsync("/api/me/phase/next", new { });
|
||||
|
||||
var resp = await client.PostAsJsonAsync("/api/votes", new { SuggestionId = 9999, Score = 5 });
|
||||
var resp = await client.PostAsJsonAsync("/api/votes", new
|
||||
{
|
||||
SuggestionId = 9999,
|
||||
Score = 5
|
||||
});
|
||||
Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Votes_require_display_name()
|
||||
{
|
||||
using var factory = new TestWebApplicationFactory();
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var client = factory.CreateClientWithCookies();
|
||||
await client.RegisterAsync("anon");
|
||||
var id = await client.CreateSuggestionAsync("NeedName");
|
||||
@@ -84,14 +104,18 @@ public class VoteTests
|
||||
});
|
||||
|
||||
await client.PostAsJsonAsync("/api/me/phase/next", new { });
|
||||
var resp = await client.PostAsJsonAsync("/api/votes", new { SuggestionId = id, Score = 5 });
|
||||
var resp = await client.PostAsJsonAsync("/api/votes", new
|
||||
{
|
||||
SuggestionId = id,
|
||||
Score = 5
|
||||
});
|
||||
Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Finalize_only_in_vote_phase()
|
||||
{
|
||||
using var factory = new TestWebApplicationFactory();
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var client = factory.CreateClientWithCookies();
|
||||
await client.RegisterAsync("phase");
|
||||
|
||||
@@ -102,12 +126,16 @@ public class VoteTests
|
||||
[Fact]
|
||||
public async Task Finalize_toggle_allows_unfinalize()
|
||||
{
|
||||
using var factory = new TestWebApplicationFactory();
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var client = factory.CreateClientWithCookies();
|
||||
await client.RegisterAsync("toggle");
|
||||
var id = await client.CreateSuggestionAsync("Toggle");
|
||||
await client.PostAsJsonAsync("/api/me/phase/next", new { });
|
||||
await client.PostAsJsonAsync("/api/votes", new { SuggestionId = id, Score = 5 });
|
||||
await client.PostAsJsonAsync("/api/votes", new
|
||||
{
|
||||
SuggestionId = id,
|
||||
Score = 5
|
||||
});
|
||||
|
||||
var finalize = await client.PostAsJsonAsync("/api/votes/finalize", new { Final = true });
|
||||
finalize.EnsureSuccessStatusCode();
|
||||
@@ -121,7 +149,7 @@ public class VoteTests
|
||||
[Fact]
|
||||
public async Task Linked_votes_apply_to_all_linked_suggestions()
|
||||
{
|
||||
using var factory = new TestWebApplicationFactory();
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var admin = factory.CreateClientWithCookies();
|
||||
await admin.RegisterAsync("admin", admin: true);
|
||||
await admin.PostAsJsonAsync("/api/me/phase/next", new { });
|
||||
@@ -134,23 +162,31 @@ public class VoteTests
|
||||
|
||||
await player.PostAsJsonAsync("/api/me/phase/next", new { });
|
||||
|
||||
var linkResponse = await admin.PostAsJsonAsync("/api/admin/link-suggestions", new { SourceSuggestionId = id1, TargetSuggestionId = id2 });
|
||||
var linkResponse = await admin.PostAsJsonAsync("/api/admin/link-suggestions", new
|
||||
{
|
||||
SourceSuggestionId = id1,
|
||||
TargetSuggestionId = id2
|
||||
});
|
||||
linkResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var vote = await player.PostAsJsonAsync("/api/votes", new { SuggestionId = id1, Score = 9 });
|
||||
var vote = await player.PostAsJsonAsync("/api/votes", new
|
||||
{
|
||||
SuggestionId = id1,
|
||||
Score = 9
|
||||
});
|
||||
vote.EnsureSuccessStatusCode();
|
||||
|
||||
var mine = await player.GetFromJsonAsync<List<VoteRecord>>("/api/votes/mine");
|
||||
Assert.NotNull(mine);
|
||||
|
||||
Assert.Equal(2, mine!.Count);
|
||||
Assert.Equal(2, mine.Count);
|
||||
Assert.All(mine, v => Assert.Equal(9, v.Score));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Linked_votes_apply_across_chain()
|
||||
{
|
||||
using var factory = new TestWebApplicationFactory();
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var admin = factory.CreateClientWithCookies();
|
||||
await admin.RegisterAsync("admin", admin: true);
|
||||
await admin.PostAsJsonAsync("/api/me/phase/next", new { });
|
||||
@@ -164,22 +200,34 @@ public class VoteTests
|
||||
|
||||
await player.PostAsJsonAsync("/api/me/phase/next", new { });
|
||||
|
||||
await admin.PostAsJsonAsync("/api/admin/link-suggestions", new { SourceSuggestionId = a, TargetSuggestionId = b });
|
||||
await admin.PostAsJsonAsync("/api/admin/link-suggestions", new { SourceSuggestionId = b, TargetSuggestionId = c });
|
||||
await admin.PostAsJsonAsync("/api/admin/link-suggestions", new
|
||||
{
|
||||
SourceSuggestionId = a,
|
||||
TargetSuggestionId = b
|
||||
});
|
||||
await admin.PostAsJsonAsync("/api/admin/link-suggestions", new
|
||||
{
|
||||
SourceSuggestionId = b,
|
||||
TargetSuggestionId = c
|
||||
});
|
||||
|
||||
var vote = await player.PostAsJsonAsync("/api/votes", new { SuggestionId = c, Score = 6 });
|
||||
var vote = await player.PostAsJsonAsync("/api/votes", new
|
||||
{
|
||||
SuggestionId = c,
|
||||
Score = 6
|
||||
});
|
||||
vote.EnsureSuccessStatusCode();
|
||||
|
||||
var mine = await player.GetFromJsonAsync<List<VoteRecord>>("/api/votes/mine");
|
||||
Assert.NotNull(mine);
|
||||
Assert.Equal(3, mine!.Count);
|
||||
Assert.Equal(3, mine.Count);
|
||||
Assert.All(mine, v => Assert.Equal(6, v.Score));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Votes_mine_requires_vote_phase_and_auth()
|
||||
{
|
||||
using var factory = new TestWebApplicationFactory();
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var anon = factory.CreateClient();
|
||||
var unauth = await anon.GetAsync("/api/votes/mine");
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, unauth.StatusCode);
|
||||
@@ -190,5 +238,7 @@ public class VoteTests
|
||||
Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode);
|
||||
}
|
||||
|
||||
// ReSharper disable once NotAccessedPositionalProperty.Local
|
||||
// ReSharper disable once ClassNeverInstantiated.Local
|
||||
private record VoteRecord(int SuggestionId, int Score);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
using GameList.Data;
|
||||
using GameList.Endpoints;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
|
||||
namespace GameList.Infrastructure;
|
||||
|
||||
|
||||
@@ -3,18 +3,11 @@ using Microsoft.AspNetCore.Authentication;
|
||||
|
||||
namespace GameList.Infrastructure;
|
||||
|
||||
public class EnsurePlayerExistsMiddleware
|
||||
public class EnsurePlayerExistsMiddleware(RequestDelegate next)
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
|
||||
public EnsurePlayerExistsMiddleware(RequestDelegate next)
|
||||
{
|
||||
_next = next;
|
||||
}
|
||||
|
||||
public async Task InvokeAsync(HttpContext context, AppDbContext db)
|
||||
{
|
||||
if (context.User?.Identity?.IsAuthenticated == true)
|
||||
if (context.User.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
var id = context.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
||||
if (string.IsNullOrWhiteSpace(id) || !Guid.TryParse(id, out var playerId) || await db.Players.FindAsync(playerId) is null)
|
||||
@@ -25,6 +18,6 @@ public class EnsurePlayerExistsMiddleware
|
||||
}
|
||||
}
|
||||
|
||||
await _next(context);
|
||||
await next(context);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,18 +21,15 @@ public static class PasswordHasher
|
||||
|
||||
public static bool Verify(string password, byte[] hash, byte[] salt)
|
||||
{
|
||||
if (hash is null || salt is null || hash.Length == 0 || salt.Length == 0) return false;
|
||||
if (hash.Length == 0 || salt.Length == 0)
|
||||
return false;
|
||||
|
||||
var computed = PBKDF2(password, salt);
|
||||
return CryptographicOperations.FixedTimeEquals(computed, hash);
|
||||
}
|
||||
|
||||
private static byte[] PBKDF2(string password, byte[] salt)
|
||||
{
|
||||
return Rfc2898DeriveBytes.Pbkdf2(
|
||||
Encoding.UTF8.GetBytes(password),
|
||||
salt,
|
||||
Iterations,
|
||||
HashAlgorithmName.SHA256,
|
||||
KeySize);
|
||||
return Rfc2898DeriveBytes.Pbkdf2(Encoding.UTF8.GetBytes(password), salt, Iterations, HashAlgorithmName.SHA256, KeySize);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,8 @@ public class PhaseOrJokerFilter : IEndpointFilter
|
||||
var httpContext = context.HttpContext;
|
||||
var db = httpContext.RequestServices.GetRequiredService<AppDbContext>();
|
||||
var player = await EndpointHelpers.GetAuthenticatedPlayer(httpContext, db);
|
||||
if (player is null) return Results.Unauthorized();
|
||||
if (player is null)
|
||||
return Results.Unauthorized();
|
||||
|
||||
var phase = await EndpointHelpers.GetPhase(db, player.Id);
|
||||
var allow = phase == Phase.Suggest || (phase == Phase.Vote && player.HasJoker);
|
||||
|
||||
@@ -4,28 +4,20 @@ using GameList.Endpoints;
|
||||
|
||||
namespace GameList.Infrastructure;
|
||||
|
||||
public class PhaseRequirementFilter : IEndpointFilter
|
||||
public class PhaseRequirementFilter(Phase required, bool allowAdminOverride = false) : IEndpointFilter
|
||||
{
|
||||
private readonly Phase _required;
|
||||
private readonly bool _allowAdminOverride;
|
||||
|
||||
public PhaseRequirementFilter(Phase required, bool allowAdminOverride = false)
|
||||
{
|
||||
_required = required;
|
||||
_allowAdminOverride = allowAdminOverride;
|
||||
}
|
||||
|
||||
public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
|
||||
{
|
||||
var httpContext = context.HttpContext;
|
||||
var db = httpContext.RequestServices.GetRequiredService<AppDbContext>();
|
||||
var player = await EndpointHelpers.GetAuthenticatedPlayer(httpContext, db);
|
||||
if (player is null) return Results.Unauthorized();
|
||||
if (player is null)
|
||||
return Results.Unauthorized();
|
||||
|
||||
var phase = await EndpointHelpers.GetPhase(db, player.Id);
|
||||
if (phase != _required && !(_allowAdminOverride && player.IsAdmin))
|
||||
if (phase != required && !(allowAdminOverride && player.IsAdmin))
|
||||
{
|
||||
return EndpointHelpers.PhaseMismatch(_required, phase);
|
||||
return EndpointHelpers.PhaseMismatch(required, phase);
|
||||
}
|
||||
|
||||
return await next(context);
|
||||
|
||||
@@ -27,8 +27,7 @@ public static class PlayerIdentityExtensions
|
||||
await ctx.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal);
|
||||
}
|
||||
|
||||
public static Task SignOutPlayerAsync(HttpContext ctx)
|
||||
=> ctx.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
|
||||
public static Task SignOutPlayerAsync(HttpContext ctx) => ctx.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
|
||||
|
||||
public static IApplicationBuilder UseGlobalExceptionLogging(this IApplicationBuilder app)
|
||||
{
|
||||
|
||||
92
Program.cs
92
Program.cs
@@ -6,21 +6,19 @@ using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
var dataDirectory = Path.Combine(builder.Environment.ContentRootPath, "App_Data");
|
||||
Directory.CreateDirectory(dataDirectory);
|
||||
|
||||
var dataProtectionDirectory = Path.Combine(dataDirectory, "keys");
|
||||
Directory.CreateDirectory(dataProtectionDirectory);
|
||||
|
||||
var configuredConnection = builder.Configuration.GetConnectionString("Default");
|
||||
var dbPath = Path.Combine(dataDirectory, "gamelist.db");
|
||||
var connectionBuilder = new SqliteConnectionStringBuilder(string.IsNullOrWhiteSpace(configuredConnection)
|
||||
? $"Data Source={dbPath}"
|
||||
: configuredConnection);
|
||||
var connectionBuilder = new SqliteConnectionStringBuilder(string.IsNullOrWhiteSpace(configuredConnection) ? $"Data Source={dbPath}" : configuredConnection);
|
||||
|
||||
if (connectionBuilder.DataSource.Contains("App_Data", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
@@ -34,56 +32,41 @@ else if (!Path.IsPathRooted(connectionBuilder.DataSource))
|
||||
|
||||
var connectionString = connectionBuilder.ToString();
|
||||
|
||||
builder.Services.AddDbContext<AppDbContext>(options =>
|
||||
options.UseSqlite(connectionString));
|
||||
builder.Services.AddDbContext<AppDbContext>(options => options.UseSqlite(connectionString));
|
||||
|
||||
builder.Services.ConfigureHttpJsonOptions(options =>
|
||||
{
|
||||
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter());
|
||||
});
|
||||
builder.Services.ConfigureHttpJsonOptions(options => { options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()); });
|
||||
|
||||
builder.Services.AddHttpClient();
|
||||
builder.Services.AddDataProtection()
|
||||
.PersistKeysToFileSystem(new DirectoryInfo(dataProtectionDirectory));
|
||||
builder.Services.AddDataProtection().PersistKeysToFileSystem(new DirectoryInfo(dataProtectionDirectory));
|
||||
|
||||
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
|
||||
.AddCookie(options =>
|
||||
{
|
||||
options.Cookie.Name = PlayerIdentityExtensions.PlayerCookieName;
|
||||
options.Cookie.HttpOnly = true;
|
||||
options.Cookie.SameSite = SameSiteMode.Strict;
|
||||
options.Cookie.SecurePolicy = builder.Environment.IsDevelopment()
|
||||
? CookieSecurePolicy.SameAsRequest
|
||||
: CookieSecurePolicy.Always;
|
||||
options.SlidingExpiration = true;
|
||||
options.ExpireTimeSpan = TimeSpan.FromDays(30);
|
||||
options.Events = new CookieAuthenticationEvents
|
||||
{
|
||||
OnRedirectToLogin = ctx =>
|
||||
{
|
||||
ctx.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
OnRedirectToAccessDenied = ctx =>
|
||||
{
|
||||
ctx.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
builder.Services.AddAuthorization(options =>
|
||||
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie(options =>
|
||||
{
|
||||
options.AddPolicy(PlayerIdentityExtensions.AdminPolicy, policy =>
|
||||
policy.RequireClaim(PlayerIdentityExtensions.AdminClaim, "true"));
|
||||
options.Cookie.Name = PlayerIdentityExtensions.PlayerCookieName;
|
||||
options.Cookie.HttpOnly = true;
|
||||
options.Cookie.SameSite = SameSiteMode.Strict;
|
||||
options.Cookie.SecurePolicy = builder.Environment.IsDevelopment() ? CookieSecurePolicy.SameAsRequest : CookieSecurePolicy.Always;
|
||||
options.SlidingExpiration = true;
|
||||
options.ExpireTimeSpan = TimeSpan.FromDays(30);
|
||||
options.Events = new CookieAuthenticationEvents
|
||||
{
|
||||
OnRedirectToLogin = ctx =>
|
||||
{
|
||||
ctx.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
OnRedirectToAccessDenied = ctx =>
|
||||
{
|
||||
ctx.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
builder.Services.AddAuthorization(options => { options.AddPolicy(PlayerIdentityExtensions.AdminPolicy, policy => policy.RequireClaim(PlayerIdentityExtensions.AdminClaim, "true")); });
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.UseForwardedHeaders(new ForwardedHeadersOptions
|
||||
{
|
||||
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedHost
|
||||
});
|
||||
app.UseForwardedHeaders(new ForwardedHeadersOptions { ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedHost });
|
||||
|
||||
var basePath = builder.Configuration["BasePath"];
|
||||
if (!string.IsNullOrWhiteSpace(basePath))
|
||||
@@ -122,22 +105,29 @@ static void UpdateIndexMetaBase(IWebHostEnvironment env, string basePath)
|
||||
try
|
||||
{
|
||||
var indexPath = Path.Combine(env.WebRootPath, "index.html");
|
||||
if (!File.Exists(indexPath)) return;
|
||||
if (!File.Exists(indexPath))
|
||||
return;
|
||||
|
||||
var text = File.ReadAllText(indexPath);
|
||||
var marker = "name=\"app-base\"";
|
||||
var contentKey = "content=\"";
|
||||
var markerIndex = text.IndexOf(marker, StringComparison.OrdinalIgnoreCase);
|
||||
if (markerIndex < 0) return;
|
||||
if (markerIndex < 0)
|
||||
return;
|
||||
|
||||
var contentIndex = text.IndexOf(contentKey, markerIndex, StringComparison.OrdinalIgnoreCase);
|
||||
if (contentIndex < 0) return;
|
||||
if (contentIndex < 0)
|
||||
return;
|
||||
|
||||
var valueStart = contentIndex + contentKey.Length;
|
||||
var valueEnd = text.IndexOf('"', valueStart);
|
||||
if (valueEnd < 0) return;
|
||||
if (valueEnd < 0)
|
||||
return;
|
||||
|
||||
var current = text[valueStart..valueEnd];
|
||||
var normalized = basePath.EndsWith('/') ? basePath.TrimEnd('/') : basePath;
|
||||
if (current == normalized) return;
|
||||
if (current == normalized)
|
||||
return;
|
||||
|
||||
var updated = text[..valueStart] + normalized + text[valueEnd..];
|
||||
File.WriteAllText(indexPath, updated);
|
||||
@@ -148,4 +138,4 @@ static void UpdateIndexMetaBase(IWebHostEnvironment env, string basePath)
|
||||
}
|
||||
}
|
||||
|
||||
public partial class Program { }
|
||||
public partial class Program;
|
||||
|
||||
Reference in New Issue
Block a user