From 941496b0e936829f9f49e4e5543a61a7cadb152a Mon Sep 17 00:00:00 2001 From: Lucas Pick Date: Wed, 29 Jul 2026 22:22:05 -0500 Subject: [PATCH 01/18] fix(matchmaking): return Unauthorized() on server-ownership checks Six ownership checks called Unauthorized() and discarded the result instead of returning it, so any authenticated session could update, delete, heartbeat, start/stop, or edit the player lists of game servers owned by other clients. --- .../Controllers/UT/MatchmakingController.cs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/UT4MasterServer/Controllers/UT/MatchmakingController.cs b/UT4MasterServer/Controllers/UT/MatchmakingController.cs index be524a2a..d070402f 100644 --- a/UT4MasterServer/Controllers/UT/MatchmakingController.cs +++ b/UT4MasterServer/Controllers/UT/MatchmakingController.cs @@ -144,7 +144,7 @@ public async Task UpdateGameServer(string id, [FromBody] GameServ if (server.OwningClientID != user.Session.ClientID) { - Unauthorized(); + return Unauthorized(); } server.Update(updatedServer); @@ -192,7 +192,7 @@ public async Task DeleteGameServer(string id) if (server.OwningClientID != user.Session.ClientID) { - Unauthorized(); + return Unauthorized(); } var wasDeleted = await matchmakingService.RemoveAsync(EpicID.FromString(id)); @@ -235,7 +235,7 @@ public async Task GameServerHeartbeat(string id) if (server.OwningClientID != user.Session.ClientID) { - Unauthorized(); + return Unauthorized(); } #if false @@ -289,7 +289,7 @@ public async Task UpdateGameServerPlayers(string id, [FromBody] G if (server.OwningClientID != user.Session.ClientID) { - Unauthorized(); + return Unauthorized(); } // handle player list update @@ -337,7 +337,7 @@ public async Task RemovePlayer(string id, [FromBody] EpicID[] pla if (server.OwningClientID != user.Session.ClientID) { - Unauthorized(); + return Unauthorized(); } foreach (EpicID player in players) @@ -468,7 +468,7 @@ private async Task ChangeGameServerStarted(string id, bool starte if (server.OwningClientID != user.Session.ClientID) { - Unauthorized(); + return Unauthorized(); } server.Started = started; From 458f0872f03747b53468feec6c4e95c8d7e593bc Mon Sep 17 00:00:00 2001 From: Lucas Pick Date: Wed, 29 Jul 2026 22:22:25 -0500 Subject: [PATCH 02/18] fix(admin): replace Forbid(message) with 403 StatusCode result ControllerBase.Forbid(string...) interprets its arguments as authentication scheme names, not a message. Executing ForbidResult with a scheme like "Cannot modify reserved clients" throws (no such scheme is registered) and surfaces as a 500 instead of a 403. --- .../Controllers/AdminPanelController.cs | 146 +++++++++--------- 1 file changed, 73 insertions(+), 73 deletions(-) diff --git a/UT4MasterServer/Controllers/AdminPanelController.cs b/UT4MasterServer/Controllers/AdminPanelController.cs index f1171e41..a03984a2 100644 --- a/UT4MasterServer/Controllers/AdminPanelController.cs +++ b/UT4MasterServer/Controllers/AdminPanelController.cs @@ -218,12 +218,12 @@ public async Task ChangePassword(string id, [FromBody] AdminPanel if (!ValidationHelper.ValidatePassword(body.NewPassword)) { return BadRequest("newPassword is not a SHA512 hash"); - } - - if (account.Email != body.Email) - { - return BadRequest("Invalid email"); - } + } + + if (account.Email != body.Email) + { + return BadRequest("Invalid email"); + } if (body.IAmSure != true) { @@ -253,65 +253,65 @@ public async Task ChangePassword(string id, [FromBody] AdminPanel [HttpDelete("account/{id}")] public async Task DeleteAccountInfo(string id, [FromBody] bool? forceCheckBroken) { - (Session Session, Account Account) admin = await VerifyAccessAsync(AccountFlags.ACL_AccountsHigh | AccountFlags.ACL_Maintenance); - - var accountID = EpicID.FromString(id); - Account? account = await accountService.GetAccountUsernameAndFlagsAsync(accountID); - - LogLevel logLevel = LogLevel.Warning; - try - { - if (account is null) - { - if (!admin.Account.Flags.HasFlag(AccountFlags.Admin) && !admin.Account.Flags.HasFlag(AccountFlags.ACL_Maintenance)) - { - return NotFound(new ErrorResponse() { ErrorMessage = "Account not found" }); - } - } - else - { - if (admin.Account.Flags.HasFlag(AccountFlags.Admin)) - { - if (account.Flags.HasFlag(AccountFlags.Admin)) - { - return Unauthorized($"Cannot delete account of {nameof(AccountFlags.Admin)}. Account needs to be demoted first."); - } - } - else if (admin.Account.Flags.HasFlag(AccountFlags.ACL_AccountsHigh)) - { - if (account.Flags.HasFlag(AccountFlags.Admin)) - { - return Unauthorized($"Cannot delete account of {nameof(AccountFlags.Admin)}"); - } - - if (AccountFlagsHelper.IsACLFlag(account.Flags)) - { - return Unauthorized("Cannot delete account with ACL flag"); - } - } - else // if (admin.Account.Flags.HasFlag(AccountFlags.ACL_Maintenance)) - { - return Unauthorized("You do not possess sufficient permissions to delete an existing account"); - } - - await accountService.RemoveAccountAsync(account.ID); - } - - // remove all associated data - await sessionService.RemoveSessionsWithFilterAsync(EpicID.Empty, accountID, EpicID.Empty); - await codeService.RemoveAllByAccountAsync(accountID); - await cloudStorageService.RemoveAllByAccountAsync(accountID); - await statisticsService.RemoveAllByAccountAsync(accountID); - await ratingsService.RemoveAllByAccountAsync(accountID); - await friendService.RemoveAllByAccountAsync(accountID); - await trustedGameServerService.RemoveAllByAccountAsync(accountID); - // NOTE: missing removal of account from live servers. this should take care of itself in a relatively short time. - - logLevel = LogLevel.Information; - - return Ok(); - } - finally + (Session Session, Account Account) admin = await VerifyAccessAsync(AccountFlags.ACL_AccountsHigh | AccountFlags.ACL_Maintenance); + + var accountID = EpicID.FromString(id); + Account? account = await accountService.GetAccountUsernameAndFlagsAsync(accountID); + + LogLevel logLevel = LogLevel.Warning; + try + { + if (account is null) + { + if (!admin.Account.Flags.HasFlag(AccountFlags.Admin) && !admin.Account.Flags.HasFlag(AccountFlags.ACL_Maintenance)) + { + return NotFound(new ErrorResponse() { ErrorMessage = "Account not found" }); + } + } + else + { + if (admin.Account.Flags.HasFlag(AccountFlags.Admin)) + { + if (account.Flags.HasFlag(AccountFlags.Admin)) + { + return Unauthorized($"Cannot delete account of {nameof(AccountFlags.Admin)}. Account needs to be demoted first."); + } + } + else if (admin.Account.Flags.HasFlag(AccountFlags.ACL_AccountsHigh)) + { + if (account.Flags.HasFlag(AccountFlags.Admin)) + { + return Unauthorized($"Cannot delete account of {nameof(AccountFlags.Admin)}"); + } + + if (AccountFlagsHelper.IsACLFlag(account.Flags)) + { + return Unauthorized("Cannot delete account with ACL flag"); + } + } + else // if (admin.Account.Flags.HasFlag(AccountFlags.ACL_Maintenance)) + { + return Unauthorized("You do not possess sufficient permissions to delete an existing account"); + } + + await accountService.RemoveAccountAsync(account.ID); + } + + // remove all associated data + await sessionService.RemoveSessionsWithFilterAsync(EpicID.Empty, accountID, EpicID.Empty); + await codeService.RemoveAllByAccountAsync(accountID); + await cloudStorageService.RemoveAllByAccountAsync(accountID); + await statisticsService.RemoveAllByAccountAsync(accountID); + await ratingsService.RemoveAllByAccountAsync(accountID); + await friendService.RemoveAllByAccountAsync(accountID); + await trustedGameServerService.RemoveAllByAccountAsync(accountID); + // NOTE: missing removal of account from live servers. this should take care of itself in a relatively short time. + + logLevel = LogLevel.Information; + + return Ok(); + } + finally { logger.Log( logLevel, @@ -319,7 +319,7 @@ public async Task DeleteAccountInfo(string id, [FromBody] bool? f admin.Account, logLevel <= LogLevel.Information ? "deleted" : "was not authorized to delete", account - ); + ); } } @@ -375,7 +375,7 @@ public async Task UpdateClient(string id, [FromBody] Client clien if (IsSpecialClientID(eid)) { - return Forbid("Cannot modify reserved clients"); + return StatusCode(StatusCodes.Status403Forbidden, "Cannot modify reserved clients"); } Task? taskUpdateClient = clientService.UpdateAsync(client); @@ -396,7 +396,7 @@ public async Task DeleteClient(string id) if (IsSpecialClientID(eid)) { - return Forbid("Cannot delete reserved clients"); + return StatusCode(StatusCodes.Status403Forbidden, "Cannot delete reserved clients"); } var success = await clientService.RemoveAsync(eid); @@ -480,7 +480,7 @@ public async Task CreateTrustedServer([FromBody] TrustedGameServe } await trustedGameServerService.UpdateAsync(body); - await matchmakingService.UpdateTrustLevelAsync(body.ID, body.TrustLevel); + await matchmakingService.UpdateTrustLevelAsync(body.ID, body.TrustLevel); return Ok(); } @@ -585,13 +585,13 @@ public async Task DeleteMCPFile(string filename) if (await cloudStorageService.DeleteFileAsync(EpicID.Empty, filename) != true) { - return Forbid("Cannot delete file. Either this file is not deletable or something went wrong."); + return StatusCode(StatusCodes.Status403Forbidden, "Cannot delete file. Either this file is not deletable or something went wrong."); } return Ok(); - } - + } + #endregion - + [NonAction] private async Task<(Session Session, Account Account)> VerifyAccessAsync(params AccountFlags[] aclAny) { From a1618e259b248674ae5fc0a129b93bece5bb1a59 Mon Sep 17 00:00:00 2001 From: Lucas Pick Date: Wed, 29 Jul 2026 22:22:41 -0500 Subject: [PATCH 03/18] fix(admin): log successful password change at Information level ChangePassword never updated logLevel before returning Ok, so every successful admin password change was logged as a Warning claiming the admin 'was not authorized to change' the password (same pattern as SetAccountFlags, which does set logLevel on success). --- UT4MasterServer/Controllers/AdminPanelController.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/UT4MasterServer/Controllers/AdminPanelController.cs b/UT4MasterServer/Controllers/AdminPanelController.cs index a03984a2..f69863db 100644 --- a/UT4MasterServer/Controllers/AdminPanelController.cs +++ b/UT4MasterServer/Controllers/AdminPanelController.cs @@ -236,6 +236,8 @@ public async Task ChangePassword(string id, [FromBody] AdminPanel // as well as prevent anyone else from using this account after successful password change. await sessionService.RemoveSessionsWithFilterAsync(EpicID.Empty, account.ID, EpicID.Empty); + logLevel = LogLevel.Information; + return Ok(); } finally From 643a46d37fd9bd0926ffca49c40c3a4bf1b0fe9e Mon Sep 17 00:00:00 2001 From: Lucas Pick Date: Wed, 29 Jul 2026 22:23:04 -0500 Subject: [PATCH 04/18] fix(models): don't crash serializing unknown game server attributes ToJObject sized a fixed array and only filled slots for keys ending in _b/_i/_s, casting the value based on the key suffix. An attribute with any other key suffix left a default entry whose null key made the JsonObject constructor throw, and a value whose type mismatched its suffix threw InvalidCastException. Either way a single server registering such an attribute broke the server list for all clients. Emit by the actual stored value type instead and skip anything else. --- .../GameServerAttributes.cs | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/UT4MasterServer.Models/GameServerAttributes.cs b/UT4MasterServer.Models/GameServerAttributes.cs index a6be196e..02c69553 100644 --- a/UT4MasterServer.Models/GameServerAttributes.cs +++ b/UT4MasterServer.Models/GameServerAttributes.cs @@ -73,24 +73,26 @@ public string[] GetKeys() public JsonObject ToJObject() { - var attrs = new KeyValuePair[serverConfigs.Count]; + var attrs = new List>(serverConfigs.Count); - var i = 0; foreach (KeyValuePair kvp in serverConfigs) { - if (kvp.Key.EndsWith("_b")) + // emit based on the actual stored type. keys without a known type + // suffix or with a value that mismatches their suffix used to leave + // a null-key entry or throw an invalid cast, crashing serialization + // of the entire server list. + if (kvp.Value is bool valueBool) { - attrs[i] = new(kvp.Key, (bool)kvp.Value); + attrs.Add(new(kvp.Key, valueBool)); } - else if (kvp.Key.EndsWith("_i")) + else if (kvp.Value is int valueInt) { - attrs[i] = new(kvp.Key, (int)kvp.Value); + attrs.Add(new(kvp.Key, valueInt)); } - else if (kvp.Key.EndsWith("_s")) + else if (kvp.Value is string valueString) { - attrs[i] = new(kvp.Key, (string)kvp.Value); + attrs.Add(new(kvp.Key, valueString)); } - i++; } return new JsonObject(attrs); From 9adcc5b61467d93a7d95005d0e5f87ddc6e2b985 Mon Sep 17 00:00:00 2001 From: Lucas Pick Date: Wed, 29 Jul 2026 22:23:19 -0500 Subject: [PATCH 05/18] fix(ratings): avoid null deref in GetSelectedRankingAsync The account lookup uses SingleOrDefaultAsync but the result was dereferenced unconditionally. A rating row whose account was deleted made GET ratings/ranking/{accountId} throw a NullReferenceException. Fall back to the same Unknown user / default flag placeholders that GetRankingsAsync already uses. --- UT4MasterServer.Services/Scoped/RatingsService.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/UT4MasterServer.Services/Scoped/RatingsService.cs b/UT4MasterServer.Services/Scoped/RatingsService.cs index 1ada8733..a5a92579 100644 --- a/UT4MasterServer.Services/Scoped/RatingsService.cs +++ b/UT4MasterServer.Services/Scoped/RatingsService.cs @@ -144,9 +144,9 @@ public async Task GetAverageTeamRatingAsync(string ratingType, R return new RankingsResponse() { Rank = rank, - AccountID = account.ID, - Player = account.Username, - CountryFlag = account.CountryFlag, + AccountID = accountID, + Player = account?.Username ?? UnknownUser, + CountryFlag = account?.CountryFlag ?? DefaultCountryFlag, Rating = selectedRating.RatingValue / Rating.Precision, GamesPlayed = selectedRating.GamesPlayed }; From cfe581b095dc6ca6f8b63379b6c8ea4efbd5b910 Mon Sep 17 00:00:00 2001 From: Lucas Pick Date: Wed, 29 Jul 2026 22:23:39 -0500 Subject: [PATCH 06/18] fix(ratings): clamp skip/limit on anonymous rankings endpoint GET ratings/rankings passed caller-controlled skip/limit straight to the database: a huge or zero/negative limit dumped the entire ranking collection (limit 0 means unlimited in MongoDB) and a negative skip threw in the driver. Clamp to 0 <= skip and 1 <= limit <= 100; the website pages with limit 10 so this changes nothing for normal use. --- UT4MasterServer/Controllers/UT/RatingsController.cs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/UT4MasterServer/Controllers/UT/RatingsController.cs b/UT4MasterServer/Controllers/UT/RatingsController.cs index 419e0e8a..52c84630 100644 --- a/UT4MasterServer/Controllers/UT/RatingsController.cs +++ b/UT4MasterServer/Controllers/UT/RatingsController.cs @@ -154,6 +154,16 @@ public async Task GetRankings(string ratingType, int skip, int li return BadRequest($"'{ratingType}' is not supported rating type."); } + // this endpoint is anonymous, keep paging within sane bounds + if (skip < 0) + { + skip = 0; + } + if (limit < 1 || limit > 100) + { + limit = 100; + } + PagedResponse? response = await ratingsService.GetRankingsAsync(ratingType, skip, limit); return Ok(response); From d31a0a3712a4d4e4a0368a71c4fccbf6f829ec67 Mon Sep 17 00:00:00 2001 From: Lucas Pick Date: Wed, 29 Jul 2026 22:23:56 -0500 Subject: [PATCH 07/18] fix(account): reuse HttpClient and escape recaptcha query values RegisterAccount created a new undisposed HttpClient on every registration attempt (socket/handle leak, port exhaustion under load) and interpolated the caller-supplied recaptcha token into the query string unescaped. Use a shared static client and Uri.EscapeDataString. --- UT4MasterServer/Controllers/Epic/AccountController.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/UT4MasterServer/Controllers/Epic/AccountController.cs b/UT4MasterServer/Controllers/Epic/AccountController.cs index 98093a11..ec5e5745 100644 --- a/UT4MasterServer/Controllers/Epic/AccountController.cs +++ b/UT4MasterServer/Controllers/Epic/AccountController.cs @@ -26,6 +26,9 @@ public sealed class AccountController : JsonAPIController private readonly AccountService accountService; private readonly IOptions reCaptchaSettings; + // reuse a single HttpClient instead of creating (and leaking) one per request + private static readonly HttpClient httpClient = new(); + public AccountController(ILogger logger, AccountService accountService, SessionService sessionService, IOptions reCaptchaSettings) : base(logger) { this.accountService = accountService; @@ -201,8 +204,7 @@ public async Task RegisterAccount([FromForm] string username, [Fr return Conflict("Recaptcha token is missing"); } - var httpClient = new HttpClient(); - HttpResponseMessage httpResponse = await httpClient.GetAsync($"https://www.google.com/recaptcha/api/siteverify?secret={reCaptchaSecret}&response={recaptchaToken}"); + HttpResponseMessage httpResponse = await httpClient.GetAsync($"https://www.google.com/recaptcha/api/siteverify?secret={Uri.EscapeDataString(reCaptchaSecret)}&response={Uri.EscapeDataString(recaptchaToken)}"); if (httpResponse.StatusCode != System.Net.HttpStatusCode.OK) { return Conflict("Recaptcha validation failed"); From 5702e6dee8280ba6d526e3f3d8b6857efc710592 Mon Sep 17 00:00:00 2001 From: Lucas Pick Date: Wed, 29 Jul 2026 22:24:16 -0500 Subject: [PATCH 08/18] fix(waittimes): synchronize singleton estimate state MatchmakingWaitTimeEstimateService is registered as a singleton but mutated its Dictionary/List state from concurrent requests without any locking; simultaneous report/estimate calls could corrupt the dictionary or throw during enumeration. Guard both entry points with a lock (Clean is only called under it). --- .../MatchmakingWaitTimeEstimateService.cs | 33 +++++++++++-------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/UT4MasterServer.Services/Singleton/MatchmakingWaitTimeEstimateService.cs b/UT4MasterServer.Services/Singleton/MatchmakingWaitTimeEstimateService.cs index 16458e3c..33ac9aeb 100644 --- a/UT4MasterServer.Services/Singleton/MatchmakingWaitTimeEstimateService.cs +++ b/UT4MasterServer.Services/Singleton/MatchmakingWaitTimeEstimateService.cs @@ -14,29 +14,36 @@ public MatchmakingWaitTimeEstimateService() public void AddWaitTime(string mode, double seconds) { - if (!estimates.TryGetValue(mode, out List<(DateTime DeleteTime, double WaitTime)>? estimateValue)) + lock (estimates) { - estimateValue = new List<(DateTime, double)>(); - estimates.Add(mode, estimateValue); - } + if (!estimates.TryGetValue(mode, out List<(DateTime DeleteTime, double WaitTime)>? estimateValue)) + { + estimateValue = new List<(DateTime, double)>(); + estimates.Add(mode, estimateValue); + } - estimateValue.Add((DateTime.UtcNow + RelevantReportTimeDuration, seconds)); + estimateValue.Add((DateTime.UtcNow + RelevantReportTimeDuration, seconds)); + } } public List GetWaitTimes() { - Clean(); - var waitTimes = new List(); - foreach (KeyValuePair> estimate in estimates) + + lock (estimates) { - if (estimate.Value.Count <= 0) + Clean(); + + foreach (KeyValuePair> estimate in estimates) { - continue; - } + if (estimate.Value.Count <= 0) + { + continue; + } - var estimatedModeWait = estimate.Value.Average(x => x.WaitTime); - waitTimes.Add(new WaitTimeEstimateResponse(estimate.Key, estimatedModeWait, estimate.Value.Count)); + var estimatedModeWait = estimate.Value.Average(x => x.WaitTime); + waitTimes.Add(new WaitTimeEstimateResponse(estimate.Key, estimatedModeWait, estimate.Value.Count)); + } } return waitTimes; From 6a4c26d60e522da6bd595129a494ab650543cad4 Mon Sep 17 00:00:00 2001 From: Lucas Pick Date: Wed, 29 Jul 2026 22:24:41 -0500 Subject: [PATCH 09/18] fix(stats): make StatisticBase body parsing robust The input formatter unconditionally stripped the last character of the request body (to drop the trailing NUL the game appends). An empty body threw ArgumentOutOfRangeException and a body without a trailing NUL had its closing brace chopped; malformed json surfaced as a 500. TrimEnd the NUL instead and translate empty/invalid bodies into a 400 via InputFormatterResult.Failure. --- .../Formatters/StatisticBaseInputFormatter.cs | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/UT4MasterServer/Formatters/StatisticBaseInputFormatter.cs b/UT4MasterServer/Formatters/StatisticBaseInputFormatter.cs index 7cfbaab9..6f78a33b 100644 --- a/UT4MasterServer/Formatters/StatisticBaseInputFormatter.cs +++ b/UT4MasterServer/Formatters/StatisticBaseInputFormatter.cs @@ -17,9 +17,24 @@ public override async Task ReadRequestBodyAsync(InputForma var rawValue = await reader.ReadToEndAsync(); - StatisticBase? newObject = JsonSerializer.Deserialize(rawValue[..^1]); + // the game terminates this json body with a trailing NUL character. + // strip it only when present instead of blindly removing the last + // character, which corrupted well-formed bodies and threw on empty ones. + var json = rawValue.TrimEnd('\0'); + if (string.IsNullOrWhiteSpace(json)) + { + return InputFormatterResult.Failure(); + } - return InputFormatterResult.Success(newObject); + try + { + StatisticBase? newObject = JsonSerializer.Deserialize(json); + return InputFormatterResult.Success(newObject); + } + catch (JsonException) + { + return InputFormatterResult.Failure(); + } } protected override bool CanReadType(Type type) From c92a0be54b912a01694eabeb45b8b8aff012d119 Mon Sep 17 00:00:00 2001 From: Lucas Pick Date: Wed, 29 Jul 2026 22:25:35 -0500 Subject: [PATCH 10/18] fix(hosted): stop swallowing background cleanup exceptions The timer callback ran the cleanup pass inside Task.Run with no error handling; any exception (transient DB outage, bad timezone id in settings, etc.) faulted the task unobserved so cleanup failures were completely invisible. Extract the body into DoWorkAsync and log failures. --- .../Hosted/ApplicationBackgroundService.cs | 53 ++++++++++++------- 1 file changed, 33 insertions(+), 20 deletions(-) diff --git a/UT4MasterServer.Services/Hosted/ApplicationBackgroundService.cs b/UT4MasterServer.Services/Hosted/ApplicationBackgroundService.cs index 5018c65a..cee7749f 100644 --- a/UT4MasterServer.Services/Hosted/ApplicationBackgroundService.cs +++ b/UT4MasterServer.Services/Hosted/ApplicationBackgroundService.cs @@ -49,32 +49,45 @@ private void DoWork(object? state) { Task.Run(async () => { - using IServiceScope? scope = services.CreateScope(); - - SessionService? sessionService = scope.ServiceProvider.GetRequiredService(); - var deleteCount = await sessionService.RemoveAllExpiredSessionsAsync(); - if (deleteCount > 0) + try { - logger.LogInformation("Background task deleted {DeleteCount} expired sessions.", deleteCount); + await DoWorkAsync(); } - - CodeService? codeService = scope.ServiceProvider.GetRequiredService(); - deleteCount = await codeService.RemoveAllExpiredCodesAsync(); - if (deleteCount > 0) + catch (Exception ex) { - logger.LogInformation("Background task deleted {DeleteCount} expired codes.", deleteCount); + // without this, a failing cleanup pass faults the task and is silently discarded + logger.LogError(ex, "Background cleanup task failed."); } + }); + } - MatchmakingService? matchmakingService = scope.ServiceProvider.GetRequiredService(); - deleteCount = await matchmakingService.RemoveAllStaleAsync(); - if (deleteCount > 0) - { - logger.LogInformation("Background task deleted {DeleteCount} stale game servers.", deleteCount); - } + private async Task DoWorkAsync() + { + using IServiceScope? scope = services.CreateScope(); - await DeleteOldStatisticsAsync(scope); - await MergeOldStatisticsAsync(scope); - }); + SessionService? sessionService = scope.ServiceProvider.GetRequiredService(); + var deleteCount = await sessionService.RemoveAllExpiredSessionsAsync(); + if (deleteCount > 0) + { + logger.LogInformation("Background task deleted {DeleteCount} expired sessions.", deleteCount); + } + + CodeService? codeService = scope.ServiceProvider.GetRequiredService(); + deleteCount = await codeService.RemoveAllExpiredCodesAsync(); + if (deleteCount > 0) + { + logger.LogInformation("Background task deleted {DeleteCount} expired codes.", deleteCount); + } + + MatchmakingService? matchmakingService = scope.ServiceProvider.GetRequiredService(); + deleteCount = await matchmakingService.RemoveAllStaleAsync(); + if (deleteCount > 0) + { + logger.LogInformation("Background task deleted {DeleteCount} stale game servers.", deleteCount); + } + + await DeleteOldStatisticsAsync(scope); + await MergeOldStatisticsAsync(scope); } public void Dispose() From 99cf894037cfd706f3e9606938bb52fdfdc95c99 Mon Sep 17 00:00:00 2001 From: Lucas Pick Date: Wed, 29 Jul 2026 22:25:56 -0500 Subject: [PATCH 11/18] fix(cloudstorage): escape username in generated stats.json The fake stats.json fallback interpolated the account username into a raw json string. Username validation only limits length and a word blocklist, so names containing a double quote or backslash produced malformed json (and allowed injecting arbitrary fields). Build the document with JObject instead. --- .../Controllers/Epic/CloudStorageController.cs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/UT4MasterServer/Controllers/Epic/CloudStorageController.cs b/UT4MasterServer/Controllers/Epic/CloudStorageController.cs index a9534c79..71086e9c 100644 --- a/UT4MasterServer/Controllers/Epic/CloudStorageController.cs +++ b/UT4MasterServer/Controllers/Epic/CloudStorageController.cs @@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.StaticFiles; +using Newtonsoft.Json.Linq; using UT4MasterServer.Authentication; using UT4MasterServer.Common; using UT4MasterServer.Models.Database; @@ -76,7 +77,14 @@ public async Task GetFile(string id, string filename) playerID = account.ID; } - file = new CloudFile() { RawContent = Encoding.UTF8.GetBytes($"{{\"PlayerName\":\"{playerName}\",\"StatsID\":\"{playerID}\",\"Version\":0}}") }; + // build via JObject so a username containing '"' or '\' cannot break the json + var fakeStats = new JObject + { + { "PlayerName", playerName }, + { "StatsID", playerID.ToString() }, + { "Version", 0 } + }; + file = new CloudFile() { RawContent = Encoding.UTF8.GetBytes(fakeStats.ToString(Newtonsoft.Json.Formatting.None)) }; } if (isStatsFile) From 2043f03e1507a50c3b5632a8bdeb7130cc45bfd1 Mon Sep 17 00:00:00 2001 From: Lucas Pick Date: Wed, 29 Jul 2026 22:26:10 -0500 Subject: [PATCH 12/18] fix(startup): run authentication middleware before authorization The pipeline registered UseAuthorization before UseAuthentication, the reverse of the documented required order. Behavior is currently unaffected because all endpoints authenticate via explicit scheme attributes (the policy evaluator invokes handlers itself), but the correct order prevents subtle breakage if a default scheme or global policy is ever added. --- UT4MasterServer/Program.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/UT4MasterServer/Program.cs b/UT4MasterServer/Program.cs index 4f8b9e69..357ed64f 100644 --- a/UT4MasterServer/Program.cs +++ b/UT4MasterServer/Program.cs @@ -221,8 +221,8 @@ public static void Main(string[] args) } //app.UseHttpsRedirection(); - app.UseAuthorization(); app.UseAuthentication(); + app.UseAuthorization(); app.MapControllers(); app.UseStaticFiles(); app.UseExceptionHandler("/api/errors"); From 433ae9e591819f3a93835e65f9d76cc2d027f049 Mon Sep 17 00:00:00 2001 From: Lucas Pick Date: Wed, 29 Jul 2026 22:26:31 -0500 Subject: [PATCH 13/18] perf(matchmaking): use List.Contains/Remove for player list updates Replace Where(x => x == player).Any() enumerator allocations with Contains, and drop the redundant pre-check before Remove (Remove is already a no-op when the element is absent). Both use the same IEquatable comparison as before. --- .../Controllers/UT/MatchmakingController.cs | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/UT4MasterServer/Controllers/UT/MatchmakingController.cs b/UT4MasterServer/Controllers/UT/MatchmakingController.cs index d070402f..8966ce3e 100644 --- a/UT4MasterServer/Controllers/UT/MatchmakingController.cs +++ b/UT4MasterServer/Controllers/UT/MatchmakingController.cs @@ -295,25 +295,19 @@ public async Task UpdateGameServerPlayers(string id, [FromBody] G // handle player list update foreach (EpicID player in serverOnlyWithPlayers.PublicPlayers) { - if (!server.PublicPlayers.Where(x => x == player).Any()) + if (!server.PublicPlayers.Contains(player)) { server.PublicPlayers.Add(player); } - if (server.PrivatePlayers.Where(x => x == player).Any()) - { - server.PrivatePlayers.Remove(player); - } + server.PrivatePlayers.Remove(player); } foreach (EpicID player in serverOnlyWithPlayers.PrivatePlayers) { - if (!server.PrivatePlayers.Where(x => x == player).Any()) + if (!server.PrivatePlayers.Contains(player)) { server.PrivatePlayers.Add(player); } - if (server.PublicPlayers.Where(x => x == player).Any()) - { - server.PublicPlayers.Remove(player); - } + server.PublicPlayers.Remove(player); } await matchmakingService.UpdateAsync(server); From 7ed7e2f1a86458d0d218d7b72a225d09cb6ad6c0 Mon Sep 17 00:00:00 2001 From: Lucas Pick Date: Wed, 29 Jul 2026 22:27:30 -0500 Subject: [PATCH 14/18] fix(profile): reject negative XP amounts in GrantXP Only upper bounds were validated, so a crafted request could submit a negative XPAmount and lower an account's XP/level (a server session may grant XP to any account). Clamp negatives to zero and log them. --- UT4MasterServer/Controllers/UT/ProfileController.cs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/UT4MasterServer/Controllers/UT/ProfileController.cs b/UT4MasterServer/Controllers/UT/ProfileController.cs index 49b688ae..90b0ee6e 100644 --- a/UT4MasterServer/Controllers/UT/ProfileController.cs +++ b/UT4MasterServer/Controllers/UT/ProfileController.cs @@ -303,6 +303,13 @@ public async Task GrantXP(string id, string clientKind, [FromQuer const double maxXPPerHour = 500.0; var hoursSinceLastMatch = (DateTime.UtcNow - acc.LastMatchAt).TotalHours; + // XP can only be granted, never taken away + if (body.XPAmount < 0) + { + logger.LogWarning("{User} supposedly earned negative XP ({XP}) in a match.", acc.ToString(), body.XPAmount); + body.XPAmount = 0; + } + // this is just some hard limit on max xp allowed per request/match if (body.XPAmount > 300) { From d78fa884a9605c622f2b8a2f3954b249b6b23895 Mon Sep 17 00:00:00 2001 From: Lucas Pick Date: Wed, 29 Jul 2026 22:29:10 -0500 Subject: [PATCH 15/18] chore(admin): restore original line endings in AdminPanelController Earlier commits on this branch accidentally normalized this file's mixed CRLF/LF line endings while editing; restore the original bytes so the branch diff only contains the intended changes. --- .../Controllers/AdminPanelController.cs | 140 +++++++++--------- 1 file changed, 70 insertions(+), 70 deletions(-) diff --git a/UT4MasterServer/Controllers/AdminPanelController.cs b/UT4MasterServer/Controllers/AdminPanelController.cs index f69863db..87933545 100644 --- a/UT4MasterServer/Controllers/AdminPanelController.cs +++ b/UT4MasterServer/Controllers/AdminPanelController.cs @@ -218,12 +218,12 @@ public async Task ChangePassword(string id, [FromBody] AdminPanel if (!ValidationHelper.ValidatePassword(body.NewPassword)) { return BadRequest("newPassword is not a SHA512 hash"); - } - - if (account.Email != body.Email) - { - return BadRequest("Invalid email"); - } + } + + if (account.Email != body.Email) + { + return BadRequest("Invalid email"); + } if (body.IAmSure != true) { @@ -255,65 +255,65 @@ public async Task ChangePassword(string id, [FromBody] AdminPanel [HttpDelete("account/{id}")] public async Task DeleteAccountInfo(string id, [FromBody] bool? forceCheckBroken) { - (Session Session, Account Account) admin = await VerifyAccessAsync(AccountFlags.ACL_AccountsHigh | AccountFlags.ACL_Maintenance); - - var accountID = EpicID.FromString(id); - Account? account = await accountService.GetAccountUsernameAndFlagsAsync(accountID); - - LogLevel logLevel = LogLevel.Warning; - try - { - if (account is null) - { - if (!admin.Account.Flags.HasFlag(AccountFlags.Admin) && !admin.Account.Flags.HasFlag(AccountFlags.ACL_Maintenance)) - { - return NotFound(new ErrorResponse() { ErrorMessage = "Account not found" }); - } - } - else - { - if (admin.Account.Flags.HasFlag(AccountFlags.Admin)) - { - if (account.Flags.HasFlag(AccountFlags.Admin)) - { - return Unauthorized($"Cannot delete account of {nameof(AccountFlags.Admin)}. Account needs to be demoted first."); - } - } - else if (admin.Account.Flags.HasFlag(AccountFlags.ACL_AccountsHigh)) - { - if (account.Flags.HasFlag(AccountFlags.Admin)) - { - return Unauthorized($"Cannot delete account of {nameof(AccountFlags.Admin)}"); - } - - if (AccountFlagsHelper.IsACLFlag(account.Flags)) - { - return Unauthorized("Cannot delete account with ACL flag"); - } - } - else // if (admin.Account.Flags.HasFlag(AccountFlags.ACL_Maintenance)) - { - return Unauthorized("You do not possess sufficient permissions to delete an existing account"); - } - - await accountService.RemoveAccountAsync(account.ID); - } - - // remove all associated data - await sessionService.RemoveSessionsWithFilterAsync(EpicID.Empty, accountID, EpicID.Empty); - await codeService.RemoveAllByAccountAsync(accountID); - await cloudStorageService.RemoveAllByAccountAsync(accountID); - await statisticsService.RemoveAllByAccountAsync(accountID); - await ratingsService.RemoveAllByAccountAsync(accountID); - await friendService.RemoveAllByAccountAsync(accountID); - await trustedGameServerService.RemoveAllByAccountAsync(accountID); - // NOTE: missing removal of account from live servers. this should take care of itself in a relatively short time. - - logLevel = LogLevel.Information; - - return Ok(); - } - finally + (Session Session, Account Account) admin = await VerifyAccessAsync(AccountFlags.ACL_AccountsHigh | AccountFlags.ACL_Maintenance); + + var accountID = EpicID.FromString(id); + Account? account = await accountService.GetAccountUsernameAndFlagsAsync(accountID); + + LogLevel logLevel = LogLevel.Warning; + try + { + if (account is null) + { + if (!admin.Account.Flags.HasFlag(AccountFlags.Admin) && !admin.Account.Flags.HasFlag(AccountFlags.ACL_Maintenance)) + { + return NotFound(new ErrorResponse() { ErrorMessage = "Account not found" }); + } + } + else + { + if (admin.Account.Flags.HasFlag(AccountFlags.Admin)) + { + if (account.Flags.HasFlag(AccountFlags.Admin)) + { + return Unauthorized($"Cannot delete account of {nameof(AccountFlags.Admin)}. Account needs to be demoted first."); + } + } + else if (admin.Account.Flags.HasFlag(AccountFlags.ACL_AccountsHigh)) + { + if (account.Flags.HasFlag(AccountFlags.Admin)) + { + return Unauthorized($"Cannot delete account of {nameof(AccountFlags.Admin)}"); + } + + if (AccountFlagsHelper.IsACLFlag(account.Flags)) + { + return Unauthorized("Cannot delete account with ACL flag"); + } + } + else // if (admin.Account.Flags.HasFlag(AccountFlags.ACL_Maintenance)) + { + return Unauthorized("You do not possess sufficient permissions to delete an existing account"); + } + + await accountService.RemoveAccountAsync(account.ID); + } + + // remove all associated data + await sessionService.RemoveSessionsWithFilterAsync(EpicID.Empty, accountID, EpicID.Empty); + await codeService.RemoveAllByAccountAsync(accountID); + await cloudStorageService.RemoveAllByAccountAsync(accountID); + await statisticsService.RemoveAllByAccountAsync(accountID); + await ratingsService.RemoveAllByAccountAsync(accountID); + await friendService.RemoveAllByAccountAsync(accountID); + await trustedGameServerService.RemoveAllByAccountAsync(accountID); + // NOTE: missing removal of account from live servers. this should take care of itself in a relatively short time. + + logLevel = LogLevel.Information; + + return Ok(); + } + finally { logger.Log( logLevel, @@ -321,7 +321,7 @@ public async Task DeleteAccountInfo(string id, [FromBody] bool? f admin.Account, logLevel <= LogLevel.Information ? "deleted" : "was not authorized to delete", account - ); + ); } } @@ -482,7 +482,7 @@ public async Task CreateTrustedServer([FromBody] TrustedGameServe } await trustedGameServerService.UpdateAsync(body); - await matchmakingService.UpdateTrustLevelAsync(body.ID, body.TrustLevel); + await matchmakingService.UpdateTrustLevelAsync(body.ID, body.TrustLevel); return Ok(); } @@ -590,10 +590,10 @@ public async Task DeleteMCPFile(string filename) return StatusCode(StatusCodes.Status403Forbidden, "Cannot delete file. Either this file is not deletable or something went wrong."); } return Ok(); - } - + } + #endregion - + [NonAction] private async Task<(Session Session, Account Account)> VerifyAccessAsync(params AccountFlags[] aclAny) { From 3139c708d49d591dec080c84df6719091a693bae Mon Sep 17 00:00:00 2001 From: Lucas Pick Date: Thu, 30 Jul 2026 07:43:26 -0500 Subject: [PATCH 16/18] fix(copilot): reject null statistic body and dispose recaptcha response - StatisticBaseInputFormatter: JsonSerializer.Deserialize returns null when the request body is the json literal "null"; returning Success(null) surfaced later as a null model in the action. Treat it as Failure like the other invalid-body cases. - AccountController.RegisterAccount: dispose the recaptcha HttpResponseMessage with a using declaration so the underlying connection is returned to the pool. --- UT4MasterServer/Controllers/Epic/AccountController.cs | 2 +- UT4MasterServer/Formatters/StatisticBaseInputFormatter.cs | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/UT4MasterServer/Controllers/Epic/AccountController.cs b/UT4MasterServer/Controllers/Epic/AccountController.cs index ec5e5745..cd1f855d 100644 --- a/UT4MasterServer/Controllers/Epic/AccountController.cs +++ b/UT4MasterServer/Controllers/Epic/AccountController.cs @@ -204,7 +204,7 @@ public async Task RegisterAccount([FromForm] string username, [Fr return Conflict("Recaptcha token is missing"); } - HttpResponseMessage httpResponse = await httpClient.GetAsync($"https://www.google.com/recaptcha/api/siteverify?secret={Uri.EscapeDataString(reCaptchaSecret)}&response={Uri.EscapeDataString(recaptchaToken)}"); + using HttpResponseMessage httpResponse = await httpClient.GetAsync($"https://www.google.com/recaptcha/api/siteverify?secret={Uri.EscapeDataString(reCaptchaSecret)}&response={Uri.EscapeDataString(recaptchaToken)}"); if (httpResponse.StatusCode != System.Net.HttpStatusCode.OK) { return Conflict("Recaptcha validation failed"); diff --git a/UT4MasterServer/Formatters/StatisticBaseInputFormatter.cs b/UT4MasterServer/Formatters/StatisticBaseInputFormatter.cs index 6f78a33b..3cb51d76 100644 --- a/UT4MasterServer/Formatters/StatisticBaseInputFormatter.cs +++ b/UT4MasterServer/Formatters/StatisticBaseInputFormatter.cs @@ -29,6 +29,12 @@ public override async Task ReadRequestBodyAsync(InputForma try { StatisticBase? newObject = JsonSerializer.Deserialize(json); + if (newObject is null) + { + // the body was the json literal "null" + return InputFormatterResult.Failure(); + } + return InputFormatterResult.Success(newObject); } catch (JsonException) From 767789e9abb5762c560d76c2f46c7f0d0fb8a022 Mon Sep 17 00:00:00 2001 From: Lucas Pick Date: Thu, 30 Jul 2026 07:45:07 -0500 Subject: [PATCH 17/18] refactor: extract pure helpers for unit testing - StatisticBaseInputFormatter: move body parsing (NUL trim, empty/invalid/ null-literal rejection) into an internal static TryParse so it can be unit tested without constructing an InputFormatterContext. No behavior change. - RatingsController: move the GetRankings skip/limit clamping into an internal static ClampPaging helper. No behavior change. - Expose internals to the XUnit.Tests project. --- .../Controllers/UT/RatingsController.cs | 14 +++++++-- .../Formatters/StatisticBaseInputFormatter.cs | 29 ++++++++++++------- UT4MasterServer/UT4MasterServer.csproj | 4 +++ 3 files changed, 34 insertions(+), 13 deletions(-) diff --git a/UT4MasterServer/Controllers/UT/RatingsController.cs b/UT4MasterServer/Controllers/UT/RatingsController.cs index 52c84630..96767682 100644 --- a/UT4MasterServer/Controllers/UT/RatingsController.cs +++ b/UT4MasterServer/Controllers/UT/RatingsController.cs @@ -155,18 +155,26 @@ public async Task GetRankings(string ratingType, int skip, int li } // this endpoint is anonymous, keep paging within sane bounds + (skip, limit) = ClampPaging(skip, limit); + + PagedResponse? response = await ratingsService.GetRankingsAsync(ratingType, skip, limit); + + return Ok(response); + } + + internal static (int Skip, int Limit) ClampPaging(int skip, int limit) + { if (skip < 0) { skip = 0; } + if (limit < 1 || limit > 100) { limit = 100; } - PagedResponse? response = await ratingsService.GetRankingsAsync(ratingType, skip, limit); - - return Ok(response); + return (skip, limit); } [HttpGet("ranking/{accountId}")] diff --git a/UT4MasterServer/Formatters/StatisticBaseInputFormatter.cs b/UT4MasterServer/Formatters/StatisticBaseInputFormatter.cs index 3cb51d76..c3365959 100644 --- a/UT4MasterServer/Formatters/StatisticBaseInputFormatter.cs +++ b/UT4MasterServer/Formatters/StatisticBaseInputFormatter.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.Mvc.Formatters; +using System.Diagnostics.CodeAnalysis; using System.Text.Json; using UT4MasterServer.Models.Database; @@ -17,30 +18,38 @@ public override async Task ReadRequestBodyAsync(InputForma var rawValue = await reader.ReadToEndAsync(); + if (!TryParse(rawValue, out StatisticBase? newObject)) + { + return InputFormatterResult.Failure(); + } + + return InputFormatterResult.Success(newObject); + } + + internal static bool TryParse(string rawValue, [NotNullWhen(true)] out StatisticBase? result) + { + result = null; + // the game terminates this json body with a trailing NUL character. // strip it only when present instead of blindly removing the last // character, which corrupted well-formed bodies and threw on empty ones. var json = rawValue.TrimEnd('\0'); if (string.IsNullOrWhiteSpace(json)) { - return InputFormatterResult.Failure(); + return false; } try { - StatisticBase? newObject = JsonSerializer.Deserialize(json); - if (newObject is null) - { - // the body was the json literal "null" - return InputFormatterResult.Failure(); - } - - return InputFormatterResult.Success(newObject); + result = JsonSerializer.Deserialize(json); } catch (JsonException) { - return InputFormatterResult.Failure(); + return false; } + + // result is null when the body was the json literal "null" + return result is not null; } protected override bool CanReadType(Type type) diff --git a/UT4MasterServer/UT4MasterServer.csproj b/UT4MasterServer/UT4MasterServer.csproj index 89413a05..3bc80698 100644 --- a/UT4MasterServer/UT4MasterServer.csproj +++ b/UT4MasterServer/UT4MasterServer.csproj @@ -27,4 +27,8 @@ + + + + From fd2a6cdecf91dd8b63a0ce3f085043ce8a18f14f Mon Sep 17 00:00:00 2001 From: Lucas Pick Date: Thu, 30 Jul 2026 07:45:07 -0500 Subject: [PATCH 18/18] test: cover server attribute serialization, statistic body parsing and rankings paging - GameServerAttributeTest: replace the commented-out test (it targeted a removed Eq/Lt/Lte comparison API) with tests for ToJObject: emits by actual stored type, tolerates keys without a known type suffix and values that mismatch their key suffix (both used to crash serialization of the entire server list), and drops attributes set to null. - StatisticBaseInputFormatterTest: TryParse rejects empty/whitespace/NUL bodies, the json literal null and malformed json; parses valid bodies with and without the game's trailing NUL terminator. - RatingsControllerTest: ClampPaging boundary cases for the anonymous rankings endpoint. --- .Tests/XUnit.Tests/GameServerAttributeTest.cs | 171 +++++++----------- .Tests/XUnit.Tests/RatingsControllerTest.cs | 25 +++ .../StatisticBaseInputFormatterTest.cs | 65 +++++++ 3 files changed, 151 insertions(+), 110 deletions(-) create mode 100644 .Tests/XUnit.Tests/RatingsControllerTest.cs create mode 100644 .Tests/XUnit.Tests/StatisticBaseInputFormatterTest.cs diff --git a/.Tests/XUnit.Tests/GameServerAttributeTest.cs b/.Tests/XUnit.Tests/GameServerAttributeTest.cs index 40ad77ef..5b5f1dce 100644 --- a/.Tests/XUnit.Tests/GameServerAttributeTest.cs +++ b/.Tests/XUnit.Tests/GameServerAttributeTest.cs @@ -1,129 +1,80 @@ -/* -using System.Text; -using System.Text.Json; +using System.Text.Json.Nodes; using UT4MasterServer.Models; namespace XUnit.Tests; public class GameServerAttributeTest { - public static TheoryData TestCases = new() + [Fact] + public void ToJObject_EmitsEachStoredType() { - { "3", "3", true, false, true }, - { "3", 3, false, false, false }, - { "3", true, false, false, false }, - { "3", false, false, false, false }, - { "3", null, false, false, false }, - { "2", "3", false, true, true }, - { "4", "3", false, false, false }, - - { 3, "3", false, false, false }, - { 3, 3, true, false, true }, - { 3, true, false, false, false }, - { 3, false, false, false, false }, - { 3, null, false, false, false }, - - { true, true, true, false, true }, - { true, "true", false, false, false }, - { true, 1, false, false, false }, - { true, null, false, false, false }, - { false, false, true, false, true }, - { false, null, false, false, false }, - { true, false, false, false, false }, - { false, true, false, true, true }, - - { " ", "", false, false, false }, - { " ", " ", true, false, true }, - { "", " ", false, true, true }, - }; - - public static TheoryData TestCasesNull = new() + var attributes = new GameServerAttributes(); + attributes.Set("UT_SERVERNAME_s", "My Server"); + attributes.Set("UT_RANKED_i", 1); + attributes.Set("UT_PRIVATE_b", true); + + JsonObject obj = attributes.ToJObject(); + + Assert.Equal(3, obj.Count); + Assert.Equal("My Server", obj["UT_SERVERNAME_s"]!.GetValue()); + Assert.Equal(1, obj["UT_RANKED_i"]!.GetValue()); + Assert.True(obj["UT_PRIVATE_b"]!.GetValue()); + } + + [Fact] + public void ToJObject_KeyWithoutTypeSuffix_DoesNotThrow() { + var attributes = new GameServerAttributes(); + attributes.Set("CUSTOMKEY", "value"); + + // used to throw while serializing the server list because the key + // does not end in a known type suffix + JsonObject obj = attributes.ToJObject(); - { null, true, false, true }, - { true, false, false, false }, - { 3, false, false, false }, - { "null", false, false, false }, - }; + Assert.Equal("value", obj["CUSTOMKEY"]!.GetValue()); + } - [Theory] - [MemberData(nameof(TestCases))] - public void TestAttributesNonNull(object? attrValue, object? compareValue, bool expectedEq, bool expectedLt, bool expectedLte) + [Fact] + public void ToJObject_KeyWithMismatchedTypeSuffix_EmitsActualStoredType() { - JsonElement jsonElem = CreateJsonElement(compareValue); - var gsa = new GameServerAttributes(); - if (attrValue is string attrValueString) - { - gsa.Set("key", attrValueString); - } - else if (attrValue is int attrValueInt) - { - gsa.Set("key", attrValueInt); - } - else if (attrValue is bool attrValueBool) - { - gsa.Set("key", attrValueBool); - } - else - { - Assert.Fail("undesired test case"); - } - - Assert.Equal(expectedEq, gsa.Eq("key", jsonElem)); - Assert.Equal(expectedLt, gsa.Lt("key", jsonElem)); - Assert.Equal(expectedLte, gsa.Lte("key", jsonElem)); + var attributes = new GameServerAttributes(); + attributes.Set("UT_MISMATCH_i", "not an int"); + attributes.Set("UT_MISMATCH_s", 42); + attributes.Set("UT_MISMATCH_b", 7); + + // used to throw an invalid cast because the value type did not + // match the key suffix + JsonObject obj = attributes.ToJObject(); + + Assert.Equal("not an int", obj["UT_MISMATCH_i"]!.GetValue()); + Assert.Equal(42, obj["UT_MISMATCH_s"]!.GetValue()); + Assert.Equal(7, obj["UT_MISMATCH_b"]!.GetValue()); } - [Theory] - [MemberData(nameof(TestCasesNull))] - public void TestAttributesNull(object? compareValue, bool expectedEq, bool expectedLt, bool expectedLte) + [Fact] + public void ToJObject_MixedAttributes_SerializesToJson() { - JsonElement jsonElem = CreateJsonElement(compareValue); - var gsa = new GameServerAttributes(); - gsa.Set("key", null as string); - Assert.Equal(expectedEq, gsa.Eq("key", jsonElem)); - Assert.Equal(expectedLt, gsa.Lt("key", jsonElem)); - Assert.Equal(expectedLte, gsa.Lte("key", jsonElem)); - - gsa.Set("key", null as int?); - Assert.Equal(expectedEq, gsa.Eq("key", jsonElem)); - Assert.Equal(expectedLt, gsa.Lt("key", jsonElem)); - Assert.Equal(expectedLte, gsa.Lte("key", jsonElem)); - - gsa.Set("key", null as bool?); - Assert.Equal(expectedEq, gsa.Eq("key", jsonElem)); - Assert.Equal(expectedLt, gsa.Lt("key", jsonElem)); - Assert.Equal(expectedLte, gsa.Lte("key", jsonElem)); + var attributes = new GameServerAttributes(); + attributes.Set("UT_SERVERNAME_s", "Server"); + attributes.Set("UT_SERVERTRUSTLEVEL_i", 2); + attributes.Set("BADKEY", true); + attributes.Set("UT_WEIRD_i", "string stored under int key"); + + var json = attributes.ToJObject().ToJsonString(); + + Assert.False(string.IsNullOrEmpty(json)); } - private static JsonElement CreateJsonElement(object? obj) + [Fact] + public void ToJObject_NullValue_RemovesAttribute() { - StringBuilder sb = new(); - if (obj is null) - { - sb.Append("null"); - } - else if (obj is string objString) - { - sb.Append($"\"{objString}\""); - } - else if (obj is int objInt) - { - sb.Append(objInt.ToString()); - } - else if (obj is bool objBool) - { - sb.Append(objBool ? "true" : "false"); - } - else - { - Assert.Fail("undesired test case"); - } - - var utf8 = Encoding.UTF8.GetBytes(sb.ToString()); - - Utf8JsonReader jsonReader = new(utf8); - return JsonElement.ParseValue(ref jsonReader); + var attributes = new GameServerAttributes(); + attributes.Set("UT_SERVERNAME_s", "Server"); + attributes.Set("UT_SERVERNAME_s", (string?)null); + + JsonObject obj = attributes.ToJObject(); + + Assert.False(attributes.Contains("UT_SERVERNAME_s")); + Assert.Empty(obj); } } -*/ diff --git a/.Tests/XUnit.Tests/RatingsControllerTest.cs b/.Tests/XUnit.Tests/RatingsControllerTest.cs new file mode 100644 index 00000000..b41e65f6 --- /dev/null +++ b/.Tests/XUnit.Tests/RatingsControllerTest.cs @@ -0,0 +1,25 @@ +using UT4MasterServer.Controllers.UT; + +namespace XUnit.Tests; + +public class RatingsControllerTest +{ + [Theory] + [InlineData(0, 10, 0, 10)] // in range, unchanged + [InlineData(50, 100, 50, 100)] // upper bounds, unchanged + [InlineData(-5, 10, 0, 10)] // negative skip clamps to 0 + [InlineData(-1, 10, 0, 10)] + [InlineData(0, 0, 0, 100)] // limit below range falls back to default + [InlineData(0, -20, 0, 100)] + [InlineData(0, 101, 0, 100)] // limit above range falls back to default + [InlineData(0, 1000, 0, 100)] + [InlineData(-3, 5000, 0, 100)] // both out of range + [InlineData(0, 1, 0, 1)] // lower limit bound, unchanged + public void ClampPaging_KeepsValuesWithinSaneBounds(int skip, int limit, int expectedSkip, int expectedLimit) + { + var (clampedSkip, clampedLimit) = RatingsController.ClampPaging(skip, limit); + + Assert.Equal(expectedSkip, clampedSkip); + Assert.Equal(expectedLimit, clampedLimit); + } +} diff --git a/.Tests/XUnit.Tests/StatisticBaseInputFormatterTest.cs b/.Tests/XUnit.Tests/StatisticBaseInputFormatterTest.cs new file mode 100644 index 00000000..ef42001e --- /dev/null +++ b/.Tests/XUnit.Tests/StatisticBaseInputFormatterTest.cs @@ -0,0 +1,65 @@ +using UT4MasterServer.Formatters; +using UT4MasterServer.Models.Database; + +namespace XUnit.Tests; + +public class StatisticBaseInputFormatterTest +{ + [Theory] + [InlineData("")] + [InlineData("\0")] + [InlineData("\0\0\0")] + [InlineData(" ")] + [InlineData(" \t\r\n")] + [InlineData(" \0\0")] + [InlineData("null")] + [InlineData("null\0")] + [InlineData("{ not valid json")] + [InlineData("[]")] + [InlineData("\"just a string\"")] + public void TryParse_InvalidBody_ReturnsFalse(string rawValue) + { + var ok = StatisticBaseInputFormatter.TryParse(rawValue, out StatisticBase? result); + + Assert.False(ok); + Assert.Null(result); + } + + [Fact] + public void TryParse_ValidBodyWithTrailingNul_ReturnsParsedStatistic() + { + // the game terminates the json body with a trailing NUL character + const string rawValue = "{\"MatchesPlayed\":3,\"Kills\":15,\"Deaths\":7}\0"; + + var ok = StatisticBaseInputFormatter.TryParse(rawValue, out StatisticBase? result); + + Assert.True(ok); + Assert.NotNull(result); + Assert.Equal(3, result!.MatchesPlayed); + Assert.Equal(15, result.Kills); + Assert.Equal(7, result.Deaths); + } + + [Fact] + public void TryParse_ValidBodyWithoutTrailingNul_ReturnsParsedStatistic() + { + const string rawValue = "{\"Wins\":2,\"Losses\":1}"; + + var ok = StatisticBaseInputFormatter.TryParse(rawValue, out StatisticBase? result); + + Assert.True(ok); + Assert.NotNull(result); + Assert.Equal(2, result!.Wins); + Assert.Equal(1, result.Losses); + } + + [Fact] + public void TryParse_EmptyJsonObject_ReturnsEmptyStatistic() + { + var ok = StatisticBaseInputFormatter.TryParse("{}", out StatisticBase? result); + + Assert.True(ok); + Assert.NotNull(result); + Assert.Null(result!.MatchesPlayed); + } +}