From ac9520849612a609b82a53b9556ebc30218e22b5 Mon Sep 17 00:00:00 2001 From: Toaster2 Date: Thu, 12 Mar 2026 12:00:28 +0100 Subject: [PATCH 1/3] Start tracking user renames, dont allow users to take usernames someone else once used --- .../GameDatabaseContext.Registration.cs | 16 +++- Refresh.Database/GameDatabaseContext.Users.cs | 9 +- Refresh.Database/GameDatabaseContext.cs | 1 + .../20260312104531_TrackUsernameChanges.cs | 49 ++++++++++ .../GameDatabaseContextModelSnapshot.cs | 37 +++++++- .../Models/Users/PreviousUsername.cs | 22 +++++ .../Endpoints/Admin/AdminUserApiEndpoints.cs | 2 +- .../Tests/ApiV3/AdminUserEditApiTests.cs | 90 +++++++++++++++++++ .../Tests/ApiV3/UserApiTests.cs | 25 ++++++ 9 files changed, 244 insertions(+), 7 deletions(-) create mode 100644 Refresh.Database/Migrations/20260312104531_TrackUsernameChanges.cs create mode 100644 Refresh.Database/Models/Users/PreviousUsername.cs diff --git a/Refresh.Database/GameDatabaseContext.Registration.cs b/Refresh.Database/GameDatabaseContext.Registration.cs index 89e315d9..c6c87283 100644 --- a/Refresh.Database/GameDatabaseContext.Registration.cs +++ b/Refresh.Database/GameDatabaseContext.Registration.cs @@ -96,12 +96,20 @@ public bool IsEmailQueued(string emailAddress) return this.QueuedRegistrations.Any(r => r.EmailAddress == emailAddress); } - public bool IsUsernameTaken(string username) + public bool IsUsernameTaken(string username, GameUser? userToName = null) { - return this.GameUsers.Any(u => u.Username == username) || - this.QueuedRegistrations.Any(r => r.Username == username); + if (this.GameUsers.Any(u => u.Username == username)) return true; + if (this.QueuedRegistrations.Any(r => r.Username == username)) return true; + + PreviousUsername? previous = this.PreviousUsernames.FirstOrDefault(p => p.Username == username); + // no one has ever had this name before + if (previous == null) return false; + // this is not the initial owner of the name (only previous owners may be renamed back) + if (userToName == null || userToName.UserId != previous.UserId) return true; + + return false; } - + public bool IsEmailTaken(string emailAddress) { return this.GameUsers.Any(u => u.EmailAddress == emailAddress) || diff --git a/Refresh.Database/GameDatabaseContext.Users.cs b/Refresh.Database/GameDatabaseContext.Users.cs index 1dadca80..5a2145cf 100644 --- a/Refresh.Database/GameDatabaseContext.Users.cs +++ b/Refresh.Database/GameDatabaseContext.Users.cs @@ -356,7 +356,7 @@ public void RenameUser(GameUser user, string newUsername, bool force = false) throw new ArgumentException("Username is invalid!", nameof(newUsername)); } - if (this.IsUsernameTaken(newUsername)) + if (this.IsUsernameTaken(newUsername, user)) { throw new ArgumentException("Username is already taken!", nameof(newUsername)); } @@ -364,6 +364,13 @@ public void RenameUser(GameUser user, string newUsername, bool force = false) string oldUsername = user.Username; user.Username = newUsername; + + this.PreviousUsernames.Add(new() + { + Username = oldUsername, + User = user, + ReplacedAt = this._time.Now, + }); this.GameUsers.Update(user); this.SaveChanges(); diff --git a/Refresh.Database/GameDatabaseContext.cs b/Refresh.Database/GameDatabaseContext.cs index 338416d5..7129719d 100644 --- a/Refresh.Database/GameDatabaseContext.cs +++ b/Refresh.Database/GameDatabaseContext.cs @@ -34,6 +34,7 @@ public partial class GameDatabaseContext : DbContext, IDatabaseContext private readonly Logger _logger; internal DbSet GameUsers { get; set; } + internal DbSet PreviousUsernames { get; set; } internal DbSet GameUserStatistics { get; set; } internal DbSet Tokens { get; set; } internal DbSet GameLevels { get; set; } diff --git a/Refresh.Database/Migrations/20260312104531_TrackUsernameChanges.cs b/Refresh.Database/Migrations/20260312104531_TrackUsernameChanges.cs new file mode 100644 index 00000000..9de200c6 --- /dev/null +++ b/Refresh.Database/Migrations/20260312104531_TrackUsernameChanges.cs @@ -0,0 +1,49 @@ +using System; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Refresh.Database.Migrations +{ + /// + [DbContext(typeof(GameDatabaseContext))] + [Migration("20260312104531_TrackUsernameChanges")] + public partial class TrackUsernameChanges : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "PreviousUsernames", + columns: table => new + { + Username = table.Column(type: "text", nullable: false), + ReplacedAt = table.Column(type: "timestamp with time zone", nullable: false), + UserId = table.Column(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_PreviousUsernames", x => new { x.Username, x.ReplacedAt }); + table.ForeignKey( + name: "FK_PreviousUsernames_GameUsers_UserId", + column: x => x.UserId, + principalTable: "GameUsers", + principalColumn: "UserId", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_PreviousUsernames_UserId", + table: "PreviousUsernames", + column: "UserId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "PreviousUsernames"); + } + } +} diff --git a/Refresh.Database/Migrations/GameDatabaseContextModelSnapshot.cs b/Refresh.Database/Migrations/GameDatabaseContextModelSnapshot.cs index 25abfcc5..d59ff565 100644 --- a/Refresh.Database/Migrations/GameDatabaseContextModelSnapshot.cs +++ b/Refresh.Database/Migrations/GameDatabaseContextModelSnapshot.cs @@ -1715,6 +1715,25 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("GameUsers"); }); + modelBuilder.Entity("Refresh.Database.Models.Users.PreviousUsername", b => + { + b.Property("Username") + .HasColumnType("text"); + + b.Property("ReplacedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Username", "ReplacedAt"); + + b.HasIndex("UserId"); + + b.ToTable("PreviousUsernames"); + }); + modelBuilder.Entity("Refresh.Database.Models.Users.QueuedRegistration", b => { b.Property("RegistrationId") @@ -2080,7 +2099,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("Refresh.Database.Models.Photos.GamePhotoSubject", b => { b.HasOne("Refresh.Database.Models.Photos.GamePhoto", "Photo") - .WithMany() + .WithMany("Subjects") .HasForeignKey("PhotoId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); @@ -2464,6 +2483,22 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Statistics"); }); + modelBuilder.Entity("Refresh.Database.Models.Users.PreviousUsername", b => + { + b.HasOne("Refresh.Database.Models.Users.GameUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Refresh.Database.Models.Photos.GamePhoto", b => + { + b.Navigation("Subjects"); + }); + modelBuilder.Entity("Refresh.Database.Models.Reports.GriefReport", b => { b.Navigation("Players"); diff --git a/Refresh.Database/Models/Users/PreviousUsername.cs b/Refresh.Database/Models/Users/PreviousUsername.cs new file mode 100644 index 00000000..d08850bc --- /dev/null +++ b/Refresh.Database/Models/Users/PreviousUsername.cs @@ -0,0 +1,22 @@ +using MongoDB.Bson; + +namespace Refresh.Database.Models.Users; + +#nullable disable + +[PrimaryKey(nameof(Username), nameof(ReplacedAt))] +public partial class PreviousUsername +{ + public string Username { get; set; } + + [Required] + public ObjectId UserId { get; set; } + + [Required, ForeignKey(nameof(UserId))] + public GameUser User { get; set; } + + /// + /// When the user's name was changed to no longer use this username + /// + public DateTimeOffset ReplacedAt { get; set; } +} \ No newline at end of file diff --git a/Refresh.Interfaces.APIv3/Endpoints/Admin/AdminUserApiEndpoints.cs b/Refresh.Interfaces.APIv3/Endpoints/Admin/AdminUserApiEndpoints.cs index e5f92e63..44470215 100644 --- a/Refresh.Interfaces.APIv3/Endpoints/Admin/AdminUserApiEndpoints.cs +++ b/Refresh.Interfaces.APIv3/Endpoints/Admin/AdminUserApiEndpoints.cs @@ -173,7 +173,7 @@ public ApiResponse UpdateUser(RequestContext contex return new ApiValidationError(ApiValidationError.InvalidUsernameErrorWhen + " Are you sure you used a PSN/RPCN username, or prepended it with ! if it's a fake user?"); - if (database.IsUsernameTaken(body.Username)) + if (database.IsUsernameTaken(body.Username, targetUser)) return ApiValidationError.UsernameTakenError; database.RenameUser(targetUser, body.Username); diff --git a/RefreshTests.GameServer/Tests/ApiV3/AdminUserEditApiTests.cs b/RefreshTests.GameServer/Tests/ApiV3/AdminUserEditApiTests.cs index 4426961a..9352b0d0 100644 --- a/RefreshTests.GameServer/Tests/ApiV3/AdminUserEditApiTests.cs +++ b/RefreshTests.GameServer/Tests/ApiV3/AdminUserEditApiTests.cs @@ -268,6 +268,96 @@ public void CannotRenameToTakenUsername() Assert.That(updated!.Username, Is.Not.EqualTo(takenUsername)); } + [Test] + public void CannotRenameToOtherUsersPreviousName() + { + using TestContext context = this.GetServer(); + + GameUser mod = context.CreateUser(null, GameUserRole.Moderator); + GameUser owner = context.CreateUser("original", GameUserRole.User); + GameUser target = context.CreateUser("stinker", GameUserRole.User); + + context.Database.RenameUser(owner, "original_2"); + GameUser? modifiedOwner = context.Database.GetUserByObjectId(owner.UserId); + Assert.That(modifiedOwner, Is.Not.Null); + Assert.That(modifiedOwner!.Username, Is.EqualTo("original_2")); + + HttpClient client = context.GetAuthenticatedClient(TokenType.Api, mod); + ApiAdminUpdateUserRequest request = new() + { + Username = "original" + }; + + ApiResponse? response = client.PatchData($"/api/v3/admin/users/uuid/{target.UserId}", request, false, true); + Assert.That(response?.Error, Is.Not.Null); + Assert.That(response!.Error!.StatusCode, Is.EqualTo(BadRequest)); + + context.Database.Refresh(); + + GameUser? modifiedTarget = context.Database.GetUserByObjectId(target.UserId); + Assert.That(modifiedTarget, Is.Not.Null); + Assert.That(modifiedTarget!.Username, Is.EqualTo("stinker")); + } + + [Test] + public void CanRenameUserBackToTheirPreviousName() + { + using TestContext context = this.GetServer(); + + GameUser mod = context.CreateUser(null, GameUserRole.Moderator); + GameUser owner = context.CreateUser("original", GameUserRole.User); + + context.Database.RenameUser(owner, "original_2"); + GameUser? modifiedOwner = context.Database.GetUserByObjectId(owner.UserId); + Assert.That(modifiedOwner, Is.Not.Null); + Assert.That(modifiedOwner!.Username, Is.EqualTo("original_2")); + + HttpClient client = context.GetAuthenticatedClient(TokenType.Api, mod); + ApiAdminUpdateUserRequest request = new() + { + Username = "original" + }; + + ApiResponse? response = client.PatchData($"/api/v3/admin/users/uuid/{owner.UserId}", request); + Assert.That(response?.Data, Is.Not.Null); + Assert.That(response!.Data!.Username == "original"); + + context.Database.Refresh(); + + GameUser? modifiedOwner2 = context.Database.GetUserByObjectId(owner.UserId); + Assert.That(modifiedOwner2, Is.Not.Null); + Assert.That(modifiedOwner2!.Username, Is.EqualTo("original")); + } + + [Test] + public void CanRenameUserBackAndForth() + { + using TestContext context = this.GetServer(); + + GameUser mod = context.CreateUser(null, GameUserRole.Moderator); + GameUser owner = context.CreateUser("original", GameUserRole.User); + + context.Database.RenameUser(owner, "original_2"); + GameUser? modifiedOwner = context.Database.GetUserByObjectId(owner.UserId); + Assert.That(modifiedOwner, Is.Not.Null); + Assert.That(modifiedOwner!.Username, Is.EqualTo("original_2")); + + context.Database.RenameUser(owner, "original"); + modifiedOwner = context.Database.GetUserByObjectId(owner.UserId); + Assert.That(modifiedOwner, Is.Not.Null); + Assert.That(modifiedOwner!.Username, Is.EqualTo("original")); + + context.Database.RenameUser(owner, "original_2"); + modifiedOwner = context.Database.GetUserByObjectId(owner.UserId); + Assert.That(modifiedOwner, Is.Not.Null); + Assert.That(modifiedOwner!.Username, Is.EqualTo("original_2")); + + context.Database.RenameUser(owner, "original"); + modifiedOwner = context.Database.GetUserByObjectId(owner.UserId); + Assert.That(modifiedOwner, Is.Not.Null); + Assert.That(modifiedOwner!.Username, Is.EqualTo("original")); + } + [Test] [TestCase("!jeff", true)] [TestCase("dddd", true)] diff --git a/RefreshTests.GameServer/Tests/ApiV3/UserApiTests.cs b/RefreshTests.GameServer/Tests/ApiV3/UserApiTests.cs index 154e8118..f1d13e93 100644 --- a/RefreshTests.GameServer/Tests/ApiV3/UserApiTests.cs +++ b/RefreshTests.GameServer/Tests/ApiV3/UserApiTests.cs @@ -68,6 +68,31 @@ public void CannotRegisterAccountWithDisallowedUsername() context.Database.Refresh(); Assert.That(context.Database.GetUserByUsername(username), Is.EqualTo(null)); } + + [Test] + public void CannotRegisterAccountWithPreviouslyTakenUsername() + { + using TestContext context = this.GetServer(); + GameUser owner = context.CreateUser("original", GameUserRole.User); + + context.Database.RenameUser(owner, "original_2"); + GameUser? modifiedOwner = context.Database.GetUserByObjectId(owner.UserId); + Assert.That(modifiedOwner, Is.Not.Null); + Assert.That(modifiedOwner!.Username, Is.EqualTo("original_2")); + + ApiResponse? response = context.Http.PostData("/api/v3/register", new ApiRegisterRequest + { + Username = "original", + EmailAddress = "guy@lil.com", + PasswordSha512 = "ee26b0dd4af7e749aa1a8ee3c10ae9923f618980772e473f8819a5d4940e0db27ac185f8a0e1d5f84f88bc887fd67b143732c304cc5fa9ad8e6f57f50028a8ff", + }, false, true); + Assert.That(response, Is.Not.Null); + Assert.That(response!.Error, Is.Not.EqualTo(null)); + Assert.That(response.Error!.Name, Is.EqualTo("ApiAuthenticationError")); + + context.Database.Refresh(); + Assert.That(context.Database.GetTotalUserCount(), Is.EqualTo(1)); + } [TestCase("4")] [TestCase("44444444444444444444444444444444444444")] From cbe8614f1a33d76478533e7330138c182f25bc12 Mon Sep 17 00:00:00 2001 From: Toaster2 Date: Thu, 12 Mar 2026 12:28:08 +0100 Subject: [PATCH 2/3] Fix contest list fetching --- Refresh.Interfaces.APIv3/Endpoints/ContestApiEndpoints.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Refresh.Interfaces.APIv3/Endpoints/ContestApiEndpoints.cs b/Refresh.Interfaces.APIv3/Endpoints/ContestApiEndpoints.cs index bc3479ec..b8a85a50 100644 --- a/Refresh.Interfaces.APIv3/Endpoints/ContestApiEndpoints.cs +++ b/Refresh.Interfaces.APIv3/Endpoints/ContestApiEndpoints.cs @@ -27,7 +27,7 @@ public class ContestApiEndpoints : EndpointGroup public ApiListResponse GetAllContests(RequestContext context, GameDatabaseContext database, DataContext dataContext) { - return new ApiListResponse(ApiContestResponse.FromOldList(database.GetAllContests(), dataContext)); + return new ApiListResponse(ApiContestResponse.FromOldList(database.GetAllContests().ToArray(), dataContext)); } [ApiV3Endpoint("contests/{id}"), Authentication(false)] From 89a34d8f21650dfc97e9366674639ebf6dd3b87e Mon Sep 17 00:00:00 2001 From: Toaster2 Date: Thu, 12 Mar 2026 13:17:46 +0100 Subject: [PATCH 3/3] Fix CanRenameUserBackAndForth test --- .../Tests/ApiV3/AdminUserEditApiTests.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/RefreshTests.GameServer/Tests/ApiV3/AdminUserEditApiTests.cs b/RefreshTests.GameServer/Tests/ApiV3/AdminUserEditApiTests.cs index 9352b0d0..da984a89 100644 --- a/RefreshTests.GameServer/Tests/ApiV3/AdminUserEditApiTests.cs +++ b/RefreshTests.GameServer/Tests/ApiV3/AdminUserEditApiTests.cs @@ -342,16 +342,22 @@ public void CanRenameUserBackAndForth() Assert.That(modifiedOwner, Is.Not.Null); Assert.That(modifiedOwner!.Username, Is.EqualTo("original_2")); + context.Time.TimestampMilliseconds += 1000; + context.Database.RenameUser(owner, "original"); modifiedOwner = context.Database.GetUserByObjectId(owner.UserId); Assert.That(modifiedOwner, Is.Not.Null); Assert.That(modifiedOwner!.Username, Is.EqualTo("original")); + context.Time.TimestampMilliseconds += 1000; + context.Database.RenameUser(owner, "original_2"); modifiedOwner = context.Database.GetUserByObjectId(owner.UserId); Assert.That(modifiedOwner, Is.Not.Null); Assert.That(modifiedOwner!.Username, Is.EqualTo("original_2")); + context.Time.TimestampMilliseconds += 1000; + context.Database.RenameUser(owner, "original"); modifiedOwner = context.Database.GetUserByObjectId(owner.UserId); Assert.That(modifiedOwner, Is.Not.Null);