Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 12 additions & 4 deletions Refresh.Database/GameDatabaseContext.Registration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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) ||
Expand Down
9 changes: 8 additions & 1 deletion Refresh.Database/GameDatabaseContext.Users.cs
Original file line number Diff line number Diff line change
Expand Up @@ -356,14 +356,21 @@ 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));
}
}

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();

Expand Down
1 change: 1 addition & 0 deletions Refresh.Database/GameDatabaseContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ public partial class GameDatabaseContext : DbContext, IDatabaseContext
private readonly Logger _logger;

internal DbSet<GameUser> GameUsers { get; set; }
internal DbSet<PreviousUsername> PreviousUsernames { get; set; }
internal DbSet<GameUserStatistics> GameUserStatistics { get; set; }
internal DbSet<Token> Tokens { get; set; }
internal DbSet<GameLevel> GameLevels { get; set; }
Expand Down
49 changes: 49 additions & 0 deletions Refresh.Database/Migrations/20260312104531_TrackUsernameChanges.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
using System;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;

#nullable disable

namespace Refresh.Database.Migrations
{
/// <inheritdoc />
[DbContext(typeof(GameDatabaseContext))]
[Migration("20260312104531_TrackUsernameChanges")]
public partial class TrackUsernameChanges : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "PreviousUsernames",
columns: table => new
{
Username = table.Column<string>(type: "text", nullable: false),
ReplacedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
UserId = table.Column<string>(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");
}

/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "PreviousUsernames");
}
}
}
37 changes: 36 additions & 1 deletion Refresh.Database/Migrations/GameDatabaseContextModelSnapshot.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1715,6 +1715,25 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.ToTable("GameUsers");
});

modelBuilder.Entity("Refresh.Database.Models.Users.PreviousUsername", b =>
{
b.Property<string>("Username")
.HasColumnType("text");

b.Property<DateTimeOffset>("ReplacedAt")
.HasColumnType("timestamp with time zone");

b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");

b.HasKey("Username", "ReplacedAt");

b.HasIndex("UserId");

b.ToTable("PreviousUsernames");
});

modelBuilder.Entity("Refresh.Database.Models.Users.QueuedRegistration", b =>
{
b.Property<string>("RegistrationId")
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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");
Expand Down
22 changes: 22 additions & 0 deletions Refresh.Database/Models/Users/PreviousUsername.cs
Original file line number Diff line number Diff line change
@@ -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; }

/// <summary>
/// When the user's name was changed to no longer use this username
/// </summary>
public DateTimeOffset ReplacedAt { get; set; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ public ApiResponse<ApiExtendedGameUserResponse> 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);
Expand Down
2 changes: 1 addition & 1 deletion Refresh.Interfaces.APIv3/Endpoints/ContestApiEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ public class ContestApiEndpoints : EndpointGroup
public ApiListResponse<ApiContestResponse> GetAllContests(RequestContext context, GameDatabaseContext database,
DataContext dataContext)
{
return new ApiListResponse<ApiContestResponse>(ApiContestResponse.FromOldList(database.GetAllContests(), dataContext));
return new ApiListResponse<ApiContestResponse>(ApiContestResponse.FromOldList(database.GetAllContests().ToArray(), dataContext));
}

[ApiV3Endpoint("contests/{id}"), Authentication(false)]
Expand Down
96 changes: 96 additions & 0 deletions RefreshTests.GameServer/Tests/ApiV3/AdminUserEditApiTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,102 @@ 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<ApiExtendedGameUserResponse>? response = client.PatchData<ApiExtendedGameUserResponse>($"/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<ApiExtendedGameUserResponse>? response = client.PatchData<ApiExtendedGameUserResponse>($"/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.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);
Assert.That(modifiedOwner!.Username, Is.EqualTo("original"));
}

[Test]
[TestCase("!jeff", true)]
[TestCase("dddd", true)]
Expand Down
25 changes: 25 additions & 0 deletions RefreshTests.GameServer/Tests/ApiV3/UserApiTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ApiAuthenticationResponse>? response = context.Http.PostData<ApiAuthenticationResponse>("/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")]
Expand Down
Loading