diff --git a/Refresh.Database/GameDatabaseContext.Users.cs b/Refresh.Database/GameDatabaseContext.Users.cs index 002ff3be0..021cc7862 100644 --- a/Refresh.Database/GameDatabaseContext.Users.cs +++ b/Refresh.Database/GameDatabaseContext.Users.cs @@ -128,7 +128,15 @@ public DatabaseList GetMostFavouritedUsers(int skip, int count) public DatabaseList GetPreviousUsernameRecordsByName(string username, int skip, int count) { return new(this.PreviousUsernamesIncluded - .Where(u => u.Username == username), skip, count); + .Where(u => u.Username == username) + .OrderByDescending(u => u.ReplacedAt), skip, count); + } + + public DatabaseList GetPreviousUsernameRecordsByUser(GameUser user, int skip, int count) + { + return new(this.PreviousUsernamesIncluded + .Where(u => u.UserId == user.UserId) + .OrderByDescending(u => u.ReplacedAt), skip, count); } public void UpdateUserData(GameUser user, ISerializedEditUser data, TokenGame game) diff --git a/Refresh.Interfaces.APIv3/Endpoints/Admin/AdminUserApiEndpoints.cs b/Refresh.Interfaces.APIv3/Endpoints/Admin/AdminUserApiEndpoints.cs index e5f92e636..886e9b24c 100644 --- a/Refresh.Interfaces.APIv3/Endpoints/Admin/AdminUserApiEndpoints.cs +++ b/Refresh.Interfaces.APIv3/Endpoints/Admin/AdminUserApiEndpoints.cs @@ -50,6 +50,31 @@ public ApiListResponse GetExtendedUsers(RequestCont return list; } + [ApiV3Endpoint("admin/previousUsernames/byName/{username}"), MinimumRole(GameUserRole.Moderator)] + [DocSummary("Gets all users (with extended information) who have once used the specified username.")] + [DocUsesPageData] + public ApiListResponse GetExtendedPreviousUsernamesByUsername(RequestContext context, + GameDatabaseContext database, IDataStore dataStore, DataContext dataContext, string username) + { + (int skip, int count) = context.GetPageData(); + DatabaseList previousNames = database.GetPreviousUsernameRecordsByName(username, skip, count); + return DatabaseListExtensions.FromOldList(previousNames, dataContext); + } + + [ApiV3Endpoint("admin/previousUsernames/byUser/{idType}/{id}"), MinimumRole(GameUserRole.Moderator)] + [DocSummary("Gets all previous usernames which have once been used by the specified user.")] + [DocUsesPageData] + public ApiListResponse GetExtendedPreviousUsernamesByUser(RequestContext context, + GameDatabaseContext database, IDataStore dataStore, DataContext dataContext, string idType, string id) + { + GameUser? user = database.GetUserByIdAndType(idType, id); + if (user == null) return ApiNotFoundError.UserMissingError; + + (int skip, int count) = context.GetPageData(); + DatabaseList previousNames = database.GetPreviousUsernameRecordsByUser(user, skip, count); + return DatabaseListExtensions.FromOldList(previousNames, dataContext); + } + [ApiV3Endpoint("admin/users/{idType}/{id}/resetPassword", HttpMethods.Put), MinimumRole(GameUserRole.Moderator)] [DocSummary("Resets a user's password by their UUID or username.")] [DocError(typeof(ApiNotFoundError), ApiNotFoundError.UserMissingErrorWhen)] diff --git a/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Users/ApiExtendedPreviousUsernameResponse.cs b/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Users/ApiExtendedPreviousUsernameResponse.cs new file mode 100644 index 000000000..e288c57b9 --- /dev/null +++ b/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Users/ApiExtendedPreviousUsernameResponse.cs @@ -0,0 +1,23 @@ +using Refresh.Core.Types.Data; +using Refresh.Database.Models.Users; + +namespace Refresh.Interfaces.APIv3.Endpoints.DataTypes.Response.Users; + +[JsonObject(NamingStrategyType = typeof(CamelCaseNamingStrategy))] +public class ApiExtendedPreviousUsernameResponse : ApiPreviousUsernameResponse, IDataConvertableFrom +{ + public new static ApiExtendedPreviousUsernameResponse? FromOld(PreviousUsername? old, DataContext dataContext) + { + if (old == null) return null; + + return new ApiExtendedPreviousUsernameResponse + { + Username = old.Username, + User = ApiExtendedGameUserResponse.FromOld(old.User, dataContext)!, + ReplacedAt = old.ReplacedAt, + }; + } + + public new static IEnumerable FromOldList(IEnumerable oldList, DataContext dataContext) + => oldList.Select(old => FromOld(old, dataContext)).ToList()!; +} \ No newline at end of file diff --git a/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Users/ApiPreviousUsernameResponse.cs b/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Users/ApiPreviousUsernameResponse.cs new file mode 100644 index 000000000..9ea6ba4b5 --- /dev/null +++ b/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Users/ApiPreviousUsernameResponse.cs @@ -0,0 +1,28 @@ +using Refresh.Core.Types.Data; +using Refresh.Database.Models.Users; + +namespace Refresh.Interfaces.APIv3.Endpoints.DataTypes.Response.Users; + +[JsonObject(NamingStrategyType = typeof(CamelCaseNamingStrategy))] +public class ApiPreviousUsernameResponse : IApiResponse, IDataConvertableFrom +{ + public required string Username { get; set; } + public required ApiGameUserResponse User { get; set; } + public required DateTimeOffset ReplacedAt { get; set; } + + public static ApiPreviousUsernameResponse? FromOld(PreviousUsername? old, DataContext dataContext) + { + if (old == null) return null; + + return new ApiPreviousUsernameResponse + { + Username = old.Username, + User = ApiGameUserResponse.FromOld(old.User, dataContext)!, + ReplacedAt = old.ReplacedAt, + }; + } + + public static IEnumerable FromOldList(IEnumerable oldList, DataContext dataContext) + => oldList.Select(old => FromOld(old, dataContext)).ToList()!; +} + diff --git a/Refresh.Interfaces.APIv3/Endpoints/UserApiEndpoints.cs b/Refresh.Interfaces.APIv3/Endpoints/UserApiEndpoints.cs index f500de340..e6f9d6509 100644 --- a/Refresh.Interfaces.APIv3/Endpoints/UserApiEndpoints.cs +++ b/Refresh.Interfaces.APIv3/Endpoints/UserApiEndpoints.cs @@ -2,7 +2,9 @@ using Bunkum.Core; using Bunkum.Core.Endpoints; using Bunkum.Core.RateLimit; +using Bunkum.Core.Responses; using Bunkum.Core.Storage; +using Bunkum.Listener.Protocol; using Bunkum.Protocols.Http; using Refresh.Common.Constants; using Refresh.Core.Authentication.Permission; @@ -15,6 +17,7 @@ using Refresh.Database.Models.Authentication; using Refresh.Database.Models.Pins; using Refresh.Database.Models.Users; +using Refresh.Interfaces.APIv3.Documentation.Attributes; using Refresh.Interfaces.APIv3.Documentation.Descriptions; using Refresh.Interfaces.APIv3.Endpoints.ApiTypes; using Refresh.Interfaces.APIv3.Endpoints.ApiTypes.Errors; @@ -41,7 +44,6 @@ public ApiResponse GetUser(RequestContext context, GameData return ApiGameUserResponse.FromOld(user, dataContext); } - // TODO: Also allow specifying user by username [ApiV3Endpoint("users/{idType}/{id}/heart", HttpMethods.Post)] [DocSummary("Hearts a user by their name or UUID")] [DocError(typeof(ApiNotFoundError), ApiNotFoundError.UserMissingErrorWhen)] diff --git a/RefreshTests.GameServer/Tests/ApiV3/AdminUserEditApiTests.cs b/RefreshTests.GameServer/Tests/ApiV3/AdminUserEditApiTests.cs index 7c1f63e37..82033faf2 100644 --- a/RefreshTests.GameServer/Tests/ApiV3/AdminUserEditApiTests.cs +++ b/RefreshTests.GameServer/Tests/ApiV3/AdminUserEditApiTests.cs @@ -354,6 +354,97 @@ public void CanRenameUserBackToTheirOwnPreviousName() Assert.That(originalHistory.Items.Count, Is.EqualTo(1)); Assert.That(originalHistory.Items.First().UserId.ToString(), Is.EqualTo(owner.UserId.ToString())); } + + [Test] + public void PreviousUsernameAdminEndpointsRequireAuth() + { + using TestContext context = this.GetServer(); + GameUser target = context.CreateUser("theName"); + + // test with at least one actual rename + context.Database.RenameUser(target, "theCoolerName"); + + // cannot access + HttpResponseMessage response = context.Http.GetAsync($"/api/v3/admin/previousUsernames/byUser/uuid/{target.UserId}").Result; + Assert.That(response.StatusCode, Is.EqualTo(Forbidden)); + + response = context.Http.GetAsync($"/api/v3/admin/previousUsernames/byUser/name/{target.Username}").Result; + Assert.That(response.StatusCode, Is.EqualTo(Forbidden)); + + response = context.Http.GetAsync($"/api/v3/admin/previousUsernames/byName/theName").Result; + Assert.That(response.StatusCode, Is.EqualTo(Forbidden)); + } + + [Test] + [TestCase(GameUserRole.Restricted, false)] + [TestCase(GameUserRole.User, false)] + [TestCase(GameUserRole.Trusted, false)] + [TestCase(GameUserRole.Curator, false)] + [TestCase(GameUserRole.Moderator, true)] + [TestCase(GameUserRole.Admin, true)] + public void PreviousUsernameAdminEndpointsAreGuardedByRole(GameUserRole accessorRole, bool mayAccess) + { + using TestContext context = this.GetServer(); + GameUser accessor = context.CreateUser("accessor", accessorRole); + HttpClient client = context.GetAuthenticatedClient(TokenType.Api, accessor); + + // Prepare + GameUser target1 = context.CreateUser("coolName1"); + GameUser target2 = context.CreateUser("coolName2"); + context.Database.RenameUser(target1, "rename1"); + context.Database.RenameUser(target2, "rename2"); + + Action?, ApiListResponse?> assertionCB + = delegate(ApiListResponse? response1, ApiListResponse? response2) + { + if (mayAccess) + { + Assert.That(response1?.Data, Is.Not.Null); + Assert.That(response1?.ListInfo, Is.Not.Null); + + Assert.That(response1!.Data!.Count, Is.EqualTo(1)); + Assert.That(response1!.ListInfo!.TotalItems, Is.EqualTo(1)); + + Assert.That(response1.Data!.First().Username, Is.EqualTo("coolName1")); + Assert.That(response1.Data!.First().User.Username, Is.EqualTo("rename1")); + Assert.That(response1.Data!.First().User.UserId.ToString(), Is.EqualTo(target1.UserId.ToString())); + + Assert.That(response2?.Data, Is.Not.Null); + Assert.That(response2?.ListInfo, Is.Not.Null); + + Assert.That(response2!.Data!.Count, Is.EqualTo(1)); + Assert.That(response2!.ListInfo!.TotalItems, Is.EqualTo(1)); + + Assert.That(response2.Data!.First().Username, Is.EqualTo("coolName2")); + Assert.That(response2.Data!.First().User.Username, Is.EqualTo("rename2")); + Assert.That(response2.Data!.First().User.UserId.ToString(), Is.EqualTo(target2.UserId.ToString())); + } + else + { + Assert.That(response1, Is.Null); + Assert.That(response2, Is.Null); + } + }; + + // Now test + ApiListResponse? response1 = client.GetList( + $"/api/v3/admin/previousUsernames/byUser/uuid/{target1.UserId}", mayAccess, !mayAccess); + ApiListResponse? response2 = client.GetList( + $"/api/v3/admin/previousUsernames/byUser/uuid/{target2.UserId}", mayAccess, !mayAccess); + assertionCB(response1, response2); + + ApiListResponse? response3 = client.GetList( + $"/api/v3/admin/previousUsernames/byUser/name/{target1.Username}", mayAccess, !mayAccess); + ApiListResponse? response4 = client.GetList( + $"/api/v3/admin/previousUsernames/byUser/name/{target2.Username}", mayAccess, !mayAccess); + assertionCB(response3, response4); + + ApiListResponse? response5 = client.GetList( + $"/api/v3/admin/previousUsernames/byName/coolName1", mayAccess, !mayAccess); + ApiListResponse? response6 = client.GetList( + $"/api/v3/admin/previousUsernames/byName/coolName2", mayAccess, !mayAccess); + assertionCB(response5, response6); + } [Test] public void CanRenameUserBackAndForth()