Fix all 45 audited bugs from bug-fixes.md

Verified every finding against the code, then fixed correctness (redirects,
advancement reset, duplicate heats, high-jump attempt entry, placement
ranking with tie handling, delete-dependency 500s), security (secrets out of
config, config-driven admin seed, login lockout, role authorization with
school scoping, heat-time IDOR, forwarded headers, last-admin guards),
schema integrity (nullable+filtered ExistingStudentId, unique indexes for
rounds/heats/bar heights via SchemaIntegrityFixes migration), performance
(N+1 removal in high jump/reports/standings/dashboard, SQL-side student
paging), and hygiene (duplicate notifications, auto-dismiss scope, local
bootstrap-icons, orphaned files, test-data.sql tournament creation).

FluentValidation is now registered; AutoMapper bumped to 14.0.0 (advisory
fully patched only in licence-changed 15.1.1 — documented). 11 new tests;
63/63 passing. Credential rotation and deploy-time DB_CONNECTION_STRING are
required manual follow-ups, documented in bug-fixes.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-11 08:30:53 -04:00
parent 78c526ee35
commit 810f721e48
93 changed files with 3251 additions and 1606 deletions

View File

@@ -11,5 +11,6 @@ public class HeatConfiguration : IEntityTypeConfiguration<Heat>
builder.HasKey(h => h.HeatId);
builder.Property(h => h.Status).HasConversion<string>().HasMaxLength(20);
builder.HasOne(h => h.Round).WithMany(r => r.Heats).HasForeignKey(h => h.RoundId).OnDelete(DeleteBehavior.Cascade);
builder.HasIndex(h => new { h.RoundId, h.HeatNumber }).IsUnique();
}
}

View File

@@ -11,5 +11,8 @@ public class HighJumpHeightConfiguration : IEntityTypeConfiguration<HighJumpHeig
builder.HasKey(h => h.HighJumpHeightId);
builder.Property(h => h.Height).HasPrecision(5, 2);
builder.HasOne(h => h.TournamentEventLevel).WithMany(t => t.HighJumpHeights).HasForeignKey(h => h.TournamentEventLevelId).OnDelete(DeleteBehavior.Cascade);
// The same bar can't be added twice; ordering derives from Height, so this
// also keeps the countback's "highest bar" deterministic.
builder.HasIndex(h => new { h.TournamentEventLevelId, h.Height }).IsUnique();
}
}

View File

@@ -12,5 +12,8 @@ public class RoundConfiguration : IEntityTypeConfiguration<Round>
builder.Property(r => r.RoundType).HasConversion<string>().HasMaxLength(20);
builder.Property(r => r.Status).HasConversion<string>().HasMaxLength(20);
builder.HasOne(r => r.TournamentEventLevel).WithMany(t => t.Rounds).HasForeignKey(r => r.TournamentEventLevelId).OnDelete(DeleteBehavior.Cascade);
// Round order drives next-round lookup and final-round fallback; duplicates
// would make both non-deterministic.
builder.HasIndex(r => new { r.TournamentEventLevelId, r.RoundOrder }).IsUnique();
}
}

View File

@@ -9,8 +9,10 @@ public class StudentConfiguration : IEntityTypeConfiguration<Student>
public void Configure(EntityTypeBuilder<Student> builder)
{
builder.HasKey(s => s.StudentId);
builder.Property(s => s.ExistingStudentId).IsRequired().HasMaxLength(50);
builder.HasIndex(s => s.ExistingStudentId).IsUnique();
builder.Property(s => s.ExistingStudentId).HasMaxLength(50);
// Optional field: uniqueness only applies when a value is present, so multiple
// students without an external ID don't collide on the index.
builder.HasIndex(s => s.ExistingStudentId).IsUnique().HasFilter("\"ExistingStudentId\" IS NOT NULL");
builder.Property(s => s.FirstName).IsRequired().HasMaxLength(100);
builder.Property(s => s.LastName).IsRequired().HasMaxLength(100);
builder.Property(s => s.Sex).HasConversion<string>().HasMaxLength(10);

View File

@@ -27,6 +27,7 @@ public static class DependencyInjection
options.User.RequireUniqueEmail = true;
})
.AddEntityFrameworkStores<ApplicationDbContext>()
.AddClaimsPrincipalFactory<AppClaimsPrincipalFactory>()
.AddDefaultTokenProviders();
services.ConfigureApplicationCookie(options =>

View File

@@ -0,0 +1,28 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Options;
namespace SportsDivision.Infrastructure.Identity;
/// <summary>
/// Adds the user's first/last name to the sign-in cookie so the layout can render
/// the avatar and display name from claims, without a database query per request.
/// </summary>
public class AppClaimsPrincipalFactory : UserClaimsPrincipalFactory<ApplicationUser, IdentityRole>
{
public AppClaimsPrincipalFactory(
UserManager<ApplicationUser> userManager,
RoleManager<IdentityRole> roleManager,
IOptions<IdentityOptions> options)
: base(userManager, roleManager, options) { }
protected override async Task<ClaimsIdentity> GenerateClaimsAsync(ApplicationUser user)
{
var identity = await base.GenerateClaimsAsync(user);
if (!string.IsNullOrEmpty(user.FirstName))
identity.AddClaim(new Claim(ClaimTypes.GivenName, user.FirstName));
if (!string.IsNullOrEmpty(user.LastName))
identity.AddClaim(new Claim(ClaimTypes.Surname, user.LastName));
return identity;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,122 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SportsDivision.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class SchemaIntegrityFixes : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Students_ExistingStudentId",
table: "Students");
migrationBuilder.DropIndex(
name: "IX_Rounds_TournamentEventLevelId",
table: "Rounds");
migrationBuilder.DropIndex(
name: "IX_HighJumpHeights_TournamentEventLevelId",
table: "HighJumpHeights");
migrationBuilder.DropIndex(
name: "IX_Heats_RoundId",
table: "Heats");
migrationBuilder.AlterColumn<string>(
name: "ExistingStudentId",
table: "Students",
type: "character varying(50)",
maxLength: 50,
nullable: true,
oldClrType: typeof(string),
oldType: "character varying(50)",
oldMaxLength: 50);
// The field used to be required, so students without an external ID were
// stored as "" — normalise to NULL so the filtered unique index applies.
migrationBuilder.Sql("UPDATE \"Students\" SET \"ExistingStudentId\" = NULL WHERE \"ExistingStudentId\" = '';");
migrationBuilder.CreateIndex(
name: "IX_Students_ExistingStudentId",
table: "Students",
column: "ExistingStudentId",
unique: true,
filter: "\"ExistingStudentId\" IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_Rounds_TournamentEventLevelId_RoundOrder",
table: "Rounds",
columns: new[] { "TournamentEventLevelId", "RoundOrder" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_HighJumpHeights_TournamentEventLevelId_Height",
table: "HighJumpHeights",
columns: new[] { "TournamentEventLevelId", "Height" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Heats_RoundId_HeatNumber",
table: "Heats",
columns: new[] { "RoundId", "HeatNumber" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Students_ExistingStudentId",
table: "Students");
migrationBuilder.DropIndex(
name: "IX_Rounds_TournamentEventLevelId_RoundOrder",
table: "Rounds");
migrationBuilder.DropIndex(
name: "IX_HighJumpHeights_TournamentEventLevelId_Height",
table: "HighJumpHeights");
migrationBuilder.DropIndex(
name: "IX_Heats_RoundId_HeatNumber",
table: "Heats");
migrationBuilder.AlterColumn<string>(
name: "ExistingStudentId",
table: "Students",
type: "character varying(50)",
maxLength: 50,
nullable: false,
defaultValue: "",
oldClrType: typeof(string),
oldType: "character varying(50)",
oldMaxLength: 50,
oldNullable: true);
migrationBuilder.CreateIndex(
name: "IX_Students_ExistingStudentId",
table: "Students",
column: "ExistingStudentId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Rounds_TournamentEventLevelId",
table: "Rounds",
column: "TournamentEventLevelId");
migrationBuilder.CreateIndex(
name: "IX_HighJumpHeights_TournamentEventLevelId",
table: "HighJumpHeights",
column: "TournamentEventLevelId");
migrationBuilder.CreateIndex(
name: "IX_Heats_RoundId",
table: "Heats",
column: "RoundId");
}
}
}

View File

@@ -17,7 +17,7 @@ namespace SportsDivision.Infrastructure.Migrations
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.2")
.HasAnnotation("ProductVersion", "10.0.10")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
@@ -309,7 +309,8 @@ namespace SportsDivision.Infrastructure.Migrations
b.HasKey("HeatId");
b.HasIndex("RoundId");
b.HasIndex("RoundId", "HeatNumber")
.IsUnique();
b.ToTable("Heats");
});
@@ -424,7 +425,8 @@ namespace SportsDivision.Infrastructure.Migrations
b.HasKey("HighJumpHeightId");
b.HasIndex("TournamentEventLevelId");
b.HasIndex("TournamentEventLevelId", "Height")
.IsUnique();
b.ToTable("HighJumpHeights");
});
@@ -533,7 +535,8 @@ namespace SportsDivision.Infrastructure.Migrations
b.HasKey("RoundId");
b.HasIndex("TournamentEventLevelId");
b.HasIndex("TournamentEventLevelId", "RoundOrder")
.IsUnique();
b.ToTable("Rounds");
});
@@ -662,7 +665,6 @@ namespace SportsDivision.Infrastructure.Migrations
.HasColumnType("date");
b.Property<string>("ExistingStudentId")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
@@ -690,7 +692,8 @@ namespace SportsDivision.Infrastructure.Migrations
b.HasKey("StudentId");
b.HasIndex("ExistingStudentId")
.IsUnique();
.IsUnique()
.HasFilter("\"ExistingStudentId\" IS NOT NULL");
b.HasIndex("SchoolId");

View File

@@ -9,6 +9,7 @@ public class EventRegistrationRepository : Repository<EventRegistration>, IEvent
{
public EventRegistrationRepository(ApplicationDbContext context) : base(context) { }
public async Task<IEnumerable<EventRegistration>> GetByTournamentEventLevelAsync(int tournamentEventLevelId) => await _dbSet.Where(r => r.TournamentEventLevelId == tournamentEventLevelId).Include(r => r.Student).ThenInclude(s => s!.School).Include(r => r.TournamentEventLevel).ThenInclude(t => t.Event).Include(r => r.TournamentEventLevel).ThenInclude(t => t.EventLevel).Include(r => r.Score).ToListAsync();
public async Task<IEnumerable<EventRegistration>> GetByTournamentAsync(int tournamentId) => await _dbSet.Where(r => r.TournamentEventLevel.TournamentId == tournamentId).Include(r => r.Student).ThenInclude(s => s!.School).ThenInclude(sc => sc!.Zone).Include(r => r.TournamentEventLevel).ThenInclude(t => t.Event).Include(r => r.TournamentEventLevel).ThenInclude(t => t.EventLevel).Include(r => r.Score).ToListAsync();
public async Task<IEnumerable<EventRegistration>> GetByStudentAsync(int studentId) => await _dbSet.Where(r => r.StudentId == studentId).Include(r => r.TournamentEventLevel).ThenInclude(t => t.Event).Include(r => r.TournamentEventLevel).ThenInclude(t => t.EventLevel).Include(r => r.TournamentEventLevel).ThenInclude(t => t.Tournament).Include(r => r.Score).ToListAsync();
public async Task<bool> IsStudentRegisteredAsync(int tournamentEventLevelId, int studentId) => await _dbSet.AnyAsync(r => r.TournamentEventLevelId == tournamentEventLevelId && r.StudentId == studentId);
public async Task<IEnumerable<EventRegistration>> GetBySchoolAndTournamentAsync(int schoolId, int tournamentId) => await _dbSet.Where(r => r.Student != null && r.Student.SchoolId == schoolId && r.TournamentEventLevel.TournamentId == tournamentId).Include(r => r.Student).Include(r => r.TournamentEventLevel).ThenInclude(t => t.Event).Include(r => r.TournamentEventLevel).ThenInclude(t => t.EventLevel).Include(r => r.Score).ToListAsync();

View File

@@ -8,6 +8,9 @@ namespace SportsDivision.Infrastructure.Repositories;
public class HighJumpHeightRepository : Repository<HighJumpHeight>, IHighJumpHeightRepository
{
public HighJumpHeightRepository(ApplicationDbContext context) : base(context) { }
public async Task<IEnumerable<HighJumpHeight>> GetByTournamentEventLevelAsync(int tournamentEventLevelId) => await _dbSet.Where(h => h.TournamentEventLevelId == tournamentEventLevelId).Include(h => h.Attempts).OrderBy(h => h.SortOrder).ToListAsync();
public async Task<bool> HasAttemptsForRegistrationAsync(int eventRegistrationId) => await _context.Set<HighJumpAttempt>().AnyAsync(a => a.EventRegistrationId == eventRegistrationId);
// Ordered by the bar height itself: SortOrder is assigned by a read-then-write
// that can race under concurrent inserts, while Height is unique per event level.
public async Task<IEnumerable<HighJumpHeight>> GetByTournamentEventLevelAsync(int tournamentEventLevelId) => await _dbSet.Where(h => h.TournamentEventLevelId == tournamentEventLevelId).Include(h => h.Attempts).OrderBy(h => h.Height).ToListAsync();
public async Task<HighJumpHeight?> GetWithAttemptsAsync(int heightId) => await _dbSet.Include(h => h.Attempts).ThenInclude(a => a.EventRegistration).ThenInclude(r => r.Student).ThenInclude(s => s!.School).FirstOrDefaultAsync(h => h.HighJumpHeightId == heightId);
}

View File

@@ -21,7 +21,15 @@ public class Repository<T> : IRepository<T> where T : class
public async Task<IEnumerable<T>> FindAsync(Expression<Func<T, bool>> predicate) => await _dbSet.Where(predicate).ToListAsync();
public async Task<T> AddAsync(T entity) { await _dbSet.AddAsync(entity); return entity; }
public async Task AddRangeAsync(IEnumerable<T> entities) => await _dbSet.AddRangeAsync(entities);
public void Update(T entity) => _dbSet.Update(entity);
public void Update(T entity)
{
// Entities the context already tracks have their changes detected at
// SaveChanges; calling DbSet.Update on them would mark every property (and
// the reachable graph) Modified, producing full-row UPDATEs. Only attach
// genuinely detached entities.
if (_context.Entry(entity).State == EntityState.Detached)
_dbSet.Update(entity);
}
public void Remove(T entity) => _dbSet.Remove(entity);
public async Task<bool> AnyAsync(Expression<Func<T, bool>> predicate) => await _dbSet.AnyAsync(predicate);
public async Task<int> CountAsync(Expression<Func<T, bool>>? predicate = null) => predicate == null ? await _dbSet.CountAsync() : await _dbSet.CountAsync(predicate);

View File

@@ -11,5 +11,22 @@ public class StudentRepository : Repository<Student>, IStudentRepository
public async Task<Student?> GetByExistingIdAsync(string existingStudentId) => await _dbSet.Include(s => s.School).FirstOrDefaultAsync(s => s.ExistingStudentId == existingStudentId);
public async Task<IEnumerable<Student>> GetBySchoolAsync(int schoolId) => await _dbSet.Where(s => s.SchoolId == schoolId).Include(s => s.School).OrderBy(s => s.LastName).ThenBy(s => s.FirstName).ToListAsync();
public async Task<Student?> GetWithRegistrationsAsync(int studentId) => await _dbSet.Include(s => s.School).Include(s => s.Registrations).ThenInclude(r => r.TournamentEventLevel).ThenInclude(t => t.Event).Include(s => s.Registrations).ThenInclude(r => r.TournamentEventLevel).ThenInclude(t => t.EventLevel).FirstOrDefaultAsync(s => s.StudentId == studentId);
public async Task<IEnumerable<Student>> SearchAsync(string searchTerm) => await _dbSet.Include(s => s.School).Where(s => s.FirstName.Contains(searchTerm) || s.LastName.Contains(searchTerm) || s.ExistingStudentId.Contains(searchTerm)).OrderBy(s => s.LastName).ThenBy(s => s.FirstName).Take(50).ToListAsync();
public async Task<(IReadOnlyList<Student> Items, int TotalCount)> GetPagedAsync(int? schoolId, string? searchTerm, int page, int pageSize)
{
IQueryable<Student> query = _dbSet.Include(s => s.School);
if (schoolId.HasValue)
query = query.Where(s => s.SchoolId == schoolId.Value);
if (!string.IsNullOrWhiteSpace(searchTerm))
query = query.Where(s => s.FirstName.Contains(searchTerm)
|| s.LastName.Contains(searchTerm)
|| (s.ExistingStudentId != null && s.ExistingStudentId.Contains(searchTerm)));
var total = await query.CountAsync();
query = query.OrderBy(s => s.LastName).ThenBy(s => s.FirstName);
if (pageSize > 0)
query = query.Skip((page - 1) * pageSize).Take(pageSize);
return (await query.ToListAsync(), total);
}
}

View File

@@ -8,6 +8,7 @@ namespace SportsDivision.Infrastructure.Repositories;
public class TournamentEventLevelRepository : Repository<TournamentEventLevel>, ITournamentEventLevelRepository
{
public TournamentEventLevelRepository(ApplicationDbContext context) : base(context) { }
public async Task<IEnumerable<TournamentEventLevel>> GetByCategoryAsync(Domain.Enums.EventCategory category) => await _dbSet.Where(t => t.Event.Category == category && !t.Tournament.IsArchived).Include(t => t.Event).Include(t => t.EventLevel).Include(t => t.Tournament).Include(t => t.Registrations).OrderByDescending(t => t.Tournament.StartDate).ThenBy(t => t.EventLevel.SortOrder).ThenBy(t => t.Event.Name).ToListAsync();
public async Task<TournamentEventLevel?> GetWithRegistrationsAsync(int id) => await _dbSet.Include(t => t.Registrations).ThenInclude(r => r.Student).ThenInclude(s => s!.School).Include(t => t.Event).Include(t => t.EventLevel).Include(t => t.Tournament).FirstOrDefaultAsync(t => t.TournamentEventLevelId == id);
public async Task<TournamentEventLevel?> GetWithRoundsAsync(int id) => await _dbSet.Include(t => t.Rounds).ThenInclude(r => r.Heats).ThenInclude(h => h.HeatLanes).ThenInclude(hl => hl.EventRegistration).ThenInclude(r => r.Student).Include(t => t.Event).Include(t => t.EventLevel).Include(t => t.Tournament).FirstOrDefaultAsync(t => t.TournamentEventLevelId == id);
public async Task<IEnumerable<TournamentEventLevel>> GetByTournamentAsync(int tournamentId) => await _dbSet.Where(t => t.TournamentId == tournamentId).Include(t => t.Event).Include(t => t.EventLevel).Include(t => t.Registrations).Include(t => t.Rounds).OrderBy(t => t.EventLevel.SortOrder).ThenBy(t => t.Event.Name).ToListAsync();

View File

@@ -1,5 +1,7 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using SportsDivision.Domain.Entities;
using SportsDivision.Domain.Enums;
using SportsDivision.Infrastructure.Data;
@@ -12,12 +14,21 @@ public class DatabaseSeeder
private readonly ApplicationDbContext _context;
private readonly UserManager<ApplicationUser> _userManager;
private readonly RoleManager<IdentityRole> _roleManager;
private readonly IConfiguration _configuration;
private readonly ILogger<DatabaseSeeder> _logger;
public DatabaseSeeder(ApplicationDbContext context, UserManager<ApplicationUser> userManager, RoleManager<IdentityRole> roleManager)
public DatabaseSeeder(
ApplicationDbContext context,
UserManager<ApplicationUser> userManager,
RoleManager<IdentityRole> roleManager,
IConfiguration configuration,
ILogger<DatabaseSeeder> logger)
{
_context = context;
_userManager = userManager;
_roleManager = roleManager;
_configuration = configuration;
_logger = logger;
}
public async Task SeedAsync()
@@ -43,18 +54,41 @@ public class DatabaseSeeder
private async Task SeedAdminUserAsync()
{
if (await _userManager.FindByEmailAsync("admin@sportsdivision.dm") != null) return;
// The initial admin's credentials come from configuration
// (SeedAdmin:Email / SeedAdmin:Password, e.g. the SeedAdmin__Password
// environment variable) so no password lives in the repository.
var email = _configuration["SeedAdmin:Email"] ?? "admin@sportsdivision.dm";
if (await _userManager.FindByEmailAsync(email) != null) return;
var password = _configuration["SeedAdmin:Password"];
if (string.IsNullOrWhiteSpace(password))
{
_logger.LogWarning(
"No admin account exists and SeedAdmin:Password is not configured — skipping admin seeding. " +
"Set the SeedAdmin__Password environment variable and restart to create the initial admin.");
return;
}
var admin = new ApplicationUser
{
UserName = "admin@sportsdivision.dm",
Email = "admin@sportsdivision.dm",
UserName = email,
Email = email,
FirstName = "System",
LastName = "Administrator",
EmailConfirmed = true,
IsActive = true
};
var result = await _userManager.CreateAsync(admin, "Admin@123!");
if (result.Succeeded) await _userManager.AddToRoleAsync(admin, "Admin");
var result = await _userManager.CreateAsync(admin, password);
if (result.Succeeded)
{
await _userManager.AddToRoleAsync(admin, "Admin");
_logger.LogInformation("Seeded initial admin account {Email}. Change its password after first sign-in.", email);
}
else
{
_logger.LogError("Failed to seed the admin account: {Errors}",
string.Join("; ", result.Errors.Select(e => e.Description)));
}
}
private async Task SeedZonesAsync()
@@ -175,8 +209,8 @@ public class DatabaseSeeder
new() { Name = "Under 20 Girls", Sex = Sex.Female, MaxAge = 19, SchoolLevel = SchoolLevel.Secondary, IsAgeBased = true, SortOrder = 14 },
new() { Name = "Under 21 Boys", Sex = Sex.Male, MaxAge = 20, SchoolLevel = SchoolLevel.Secondary, IsAgeBased = true, SortOrder = 15 },
new() { Name = "Under 21 Girls", Sex = Sex.Female, MaxAge = 20, SchoolLevel = SchoolLevel.Secondary, IsAgeBased = true, SortOrder = 16 },
new() { Name = "Open Boys", Sex = Sex.Male, SchoolLevel = SchoolLevel.Secondary, IsAgeBased = true, SortOrder = 17 },
new() { Name = "Open Girls", Sex = Sex.Female, SchoolLevel = SchoolLevel.Secondary, IsAgeBased = true, SortOrder = 18 },
new() { Name = "Open Boys", Sex = Sex.Male, SchoolLevel = SchoolLevel.Secondary, IsAgeBased = false, SortOrder = 17 },
new() { Name = "Open Girls", Sex = Sex.Female, SchoolLevel = SchoolLevel.Secondary, IsAgeBased = false, SortOrder = 18 },
});
await _context.SaveChangesAsync();
}