using System.Net; using System.IdentityModel.Tokens.Jwt; using System.Reflection; using System.Security.Claims; using System.Text; using System.Text.Json; using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Storage; using Microsoft.IdentityModel.Tokens; using RadicadeServer.API; using RadicadeServer.API.Accounts; using RadicadeServer.API.Analytics; using RadicadeServer.API.Avatars; using RadicadeServer.RecNet.Avatar; using RadicadeServer.API.Avatars.V1; using RadicadeServer.API.Avatars.V2; using RadicadeServer.API.Avatars.V3; using RadicadeServer.API.Avatars.V4; using RadicadeServer.API.Config; using RadicadeServer.API.Events; using RadicadeServer.API.Link; using RadicadeServer.API.Matchmaking; using RadicadeServer.API.Progression; using RadicadeServer.API.Progression.Objectives.V1; using RadicadeServer.API.Players.V2; using RadicadeServer.API.Storefront.V3; using RadicadeServer.Auth; using RadicadeServer.API.Moderation; using RadicadeServer.CDN; using RadicadeServer.Clubs.Announcements; using RadicadeServer.DB; using RadicadeServer.Extensions; using RadicadeServer.Leaderboard; using RadicadeServer.RecNet.Accounts; using RadicadeServer.RecNet.Accounts.Local; using RadicadeServer.RecNet.Config; using RadicadeServer.RecNet.Enums; using RadicadeServer.RecNet.Events; using RadicadeServer.RecNet.Inventions; using RadicadeServer.API.Inventions.V1; using RadicadeServer.RecNet.Enums.Players; using RadicadeServer.RecNet.Enums.Rooms; using RadicadeServer.RecNet.Leaderboards; using RadicadeServer.RecNet.Responses; using RadicadeServer.RecNet.Rooms; using RadicadeServer.Utils; using Xunit; namespace RadicadeServer.Tests; /// /// Conformance smoke-tests for the wire shapes the game client deserializes. The /// client's deserializer is strict — it logs "Failed to find key ..." when a DTO is /// missing a field, and casts (e.g. Dictionary -> List) when an endpoint returns the /// wrong root shape — so this suite pins the exact JSON each RecNet endpoint must emit. /// [Collection(DbFactoryCollection.Name)] public class RecNetShapeConformanceTests { private const long CreatorId = 42; private static readonly JsonSerializerOptions ServerJson = new() { PropertyNamingPolicy = null, WriteIndented = true, }; // The full key set the client's PlayerEvent model requires (verified against the // archived FemRec-2023 server's event schema). private static readonly string[] PlayerEventKeys = { "PlayerEventId", "Name", "Description", "ImageName", "StartTime", "EndTime", "CreatorPlayerId", "AttendeeCount", "RoomId", "SubRoomId", "Accessibility", "ClubId", "State", "Tags", "CreatedAt", }; // Search/searchlive results carry these two extra fields the client reads // (verified against the live recconned reference server). private static readonly string[] PlayerEventSearchKeys = { "PlayerCount", "IsFull", }; private static string Serialize(object? value) => JsonSerializer.Serialize(value, ServerJson); private static RecDBContext CreateDb() { var options = TestDbOptions.Create() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options; return new RecDBContext(options); } private sealed class TestDbContextFactory : IDbContextFactory { private readonly DbContextOptions options; public TestDbContextFactory() { var root = new InMemoryDatabaseRoot(); options = TestDbOptions.Create() .UseInMemoryDatabase(Guid.NewGuid().ToString(), root) .Options; } public RecDBContext CreateDbContext() => new(options); } private static EventsAPI CreateEventsController(RecDBContext db) { var identity = new ClaimsIdentity("TestAuth"); identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, CreatorId.ToString())); return new EventsAPI(db) { ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(identity) } } }; } // Gives the controller a request body (and content type) the way the client sends it. private static void SetBody(Controller controller, string? body, string contentType, string query = "") { var context = controller.ControllerContext.HttpContext; context.Request.ContentType = contentType; context.Request.QueryString = new QueryString(query); context.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes(body ?? string.Empty)); } private static void SetUser(Controller controller, long accountId = CreatorId, string platformId = "0") { var identity = new ClaimsIdentity("TestAuth"); identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, accountId.ToString())); identity.AddClaim(new Claim("rn.platid", platformId)); controller.ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(identity) } }; } private static async Task SeedRoomAsync(RecDBContext db) { var room = new RoomData { Name = "Test Room" }; db.Rooms.Add(room); await db.SaveChangesAsync(); return room.RoomId; } private static async Task CreateEventAsync(EventsAPI controller, long roomId) { var result = await controller.CreateEvent("Conformance", null, roomId, null, null, null, null); return Assert.IsType(Assert.IsType(result.Result).Value); } private static string[] TopLevelKeys(string json) { using var doc = JsonDocument.Parse(json); return doc.RootElement.EnumerateObject().Select(p => p.Name).ToArray(); } private static void AssertIsEmptyArray(string json) { using var doc = JsonDocument.Parse(json); Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind); Assert.Equal(0, doc.RootElement.GetArrayLength()); } private static string CreateBearerToken(long accountId) { if (string.IsNullOrWhiteSpace(DBConfig.JwtKey)) { DBConfig.JwtKey = "radicade-local-development-jwt-key-change-before-production"; } var key = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(DBConfig.JwtKey)); var token = new JwtSecurityToken( claims: new[] { new Claim("userId", accountId.ToString()) }, signingCredentials: new SigningCredentials(key, SecurityAlgorithms.HmacSha256)); return "Bearer " + new JwtSecurityTokenHandler().WriteToken(token); } [Fact] public async Task PlayerEvent_ContainsAllClientRequiredFields() { var db = CreateDb(); var controller = CreateEventsController(db); var roomId = await SeedRoomAsync(db); var ev = await CreateEventAsync(controller, roomId); var keys = TopLevelKeys(Serialize(ev)); foreach (var key in PlayerEventKeys) Assert.Contains(key, keys); } [Fact] public async Task EventSearch_EmptyResult_IsBareArray() { var db = CreateDb(); var controller = CreateEventsController(db); var result = await controller.SearchEvents(null, null); AssertIsEmptyArray(Serialize(result)); } [Fact] public async Task EventSearch_Result_HasPlayerCountAndIsFull() { var db = CreateDb(); var controller = CreateEventsController(db); var roomId = await SeedRoomAsync(db); await CreateEventAsync(controller, roomId); var result = await controller.SearchEvents(null, null); var json = Serialize(result); using var doc = JsonDocument.Parse(json); Assert.Equal(1, doc.RootElement.GetArrayLength()); var keys = doc.RootElement[0].EnumerateObject().Select(p => p.Name).ToArray(); foreach (var key in PlayerEventSearchKeys) Assert.Contains(key, keys); } [Fact] public async Task RoomEvents_EmptyResult_IsBareArray() { var db = CreateDb(); var controller = CreateEventsController(db); var roomId = await SeedRoomAsync(db); var result = await controller.GetEventsInRoom(roomId); AssertIsEmptyArray(Serialize(result)); } [Fact] public async Task CreatedEvents_EmptyResult_IsBareArray() { var db = CreateDb(); var controller = CreateEventsController(db); var result = await controller.GetCreatedEvents(); AssertIsEmptyArray(Serialize(result)); } [Fact] public void EventTagFilters_ReturnExpectedKeys() { var db = CreateDb(); var controller = CreateEventsController(db); var keys = TopLevelKeys(Serialize(controller.GetEventTagFilters())); Assert.Contains("PinnedFilters", keys); Assert.Contains("PopularFilters", keys); Assert.Contains("TrendingFilters", keys); } [Fact] public async Task AllEvents_ReturnCreatedAndResponsesKeys() { var db = CreateDb(); var controller = CreateEventsController(db); var keys = TopLevelKeys(Serialize(await controller.GetAllEvents())); Assert.Contains("Created", keys); Assert.Contains("Responses", keys); } [Fact] public void ClubAnnouncementDeleteRouteIsRegistered() { var delete = typeof(ClubAnnouncementsAPI).GetMethod(nameof(ClubAnnouncementsAPI.DeleteClubAnnouncement))!; var routes = delete.GetCustomAttributes(typeof(HttpDeleteAttribute), false) .Cast() .Select(attribute => attribute.Template) .ToArray(); Assert.Contains("club/{clubId:long}/{announcementId:long}", routes); Assert.DoesNotContain("~/clubs/announcements/club/{clubId:long}/{announcementId:long}", routes); } [Fact] public void Success_SpellsItsAbsentErrorAsAnEmptyString() { // The reference serves error: "" on success and never null. The 2023 client builds its // own failure exception straight from this field, so `"error": null` on a 200 turns into // `ArgumentException: Error message cannot be null` in its response continuation - the // friend's client logged exactly that after an invite it had actually sent // (docs/2023-0831-CLIENT-SWITCH.md, the invite section). using var document = JsonDocument.Parse(Serialize(new Success(true, null, null))); Assert.Equal(string.Empty, document.RootElement.GetProperty("error").GetString()); Assert.Equal(JsonValueKind.Null, document.RootElement.GetProperty("value").ValueKind); // A refusal still carries the reason it was refused. using var refusal = JsonDocument.Parse(Serialize(new Success(false, "nope", null))); Assert.False(refusal.RootElement.GetProperty("success").GetBoolean()); Assert.Equal("nope", refusal.RootElement.GetProperty("error").GetString()); } [Fact] public void ClubAnnouncements_ReturnsTheReferenceEnvelopeShape() { var api = new ClubAnnouncementsAPI(); var ok = Assert.IsType(api.GetClubAnnouncements(0)); using var document = JsonDocument.Parse(Serialize(ok.Value)); var root = document.RootElement; Assert.Equal(JsonValueKind.Object, root.ValueKind); Assert.Equal(string.Empty, root.GetProperty("error").GetString()); Assert.True(root.GetProperty("success").GetBoolean()); var value = root.GetProperty("value"); Assert.Equal(JsonValueKind.Object, value.ValueKind); Assert.Equal(JsonValueKind.Array, value.GetProperty("Announcements").ValueKind); Assert.Equal(0, value.GetProperty("ClubId").GetInt64()); Assert.Equal(JsonValueKind.Null, value.GetProperty("LastAnnouncementId").ValueKind); Assert.Equal(0, value.GetProperty("LastReadAnnouncementId").GetInt64()); } [Fact] public async Task EventResponses_ContainAllClientRequiredFields() { // The client's PlayerEventResponse model reads PlayerEventResponseId, // PlayerEventId, PlayerId, CreatedAt and Type and throws a raw // KeyNotFoundException when any are missing (verified against the EpicQuest // client metadata deserializer). var db = CreateDb(); var controller = CreateEventsController(db); var roomId = await SeedRoomAsync(db); var ev = await CreateEventAsync(controller, roomId); var result = await controller.GetResponses(ev.PlayerEventId); var responses = Assert.IsType>(Assert.IsType(result.Result).Value); var response = Assert.Single(responses); var keys = TopLevelKeys(Serialize(response)); foreach (var key in new[] { "PlayerEventResponseId", "PlayerEventId", "PlayerId", "CreatedAt", "Type" }) Assert.Contains(key, keys); } [Fact] public void UnreadAnnouncements_EmptyResult_IsBareArray() { var api = new ClubAnnouncementsAPI(); AssertIsEmptyArray(Serialize(api.GetUnreadAnnouncements())); AssertIsEmptyArray(Serialize(api.GetUnreadSubscribedAnnouncements())); } [Fact] public void EventGetByIdRoute_IsRegistered() { var method = typeof(EventsAPI).GetMethod(nameof(EventsAPI.GetEvent)); var templates = method!.GetCustomAttributes().Select(a => a.Template); Assert.Contains("v1/{eventId:long}", templates); } [Fact] public void GotoEventRoute_IsRegistered() { var method = typeof(MatchmakingAPI).GetMethod(nameof(MatchmakingAPI.GoToEvent)); var templates = method!.GetCustomAttributes().Select(a => a.Template); Assert.Contains("goto/event/{eventId}", templates); } [Fact] public void ClubAnnouncementsRoute_IsRegistered() { var method = typeof(ClubAnnouncementsAPI).GetMethod(nameof(ClubAnnouncementsAPI.PostClubAnnouncements)); var templates = method!.GetCustomAttributes().Select(a => a.Template); Assert.Contains("club/{clubId}", templates); } [Fact] public void EventResponsesAndAttendeesRoutes_AreRegistered() { var responses = typeof(EventsAPI).GetMethod(nameof(EventsAPI.GetResponses))!; var responseTemplates = responses.GetCustomAttributes().Select(a => a.Template); Assert.Contains("v1/{eventId:long}/responses", responseTemplates); var attendees = typeof(EventsAPI).GetMethod(nameof(EventsAPI.GetAttendees))!; var attendeeTemplates = attendees.GetCustomAttributes().Select(a => a.Template); Assert.Contains("v1/{eventId:long}/attendees", attendeeTemplates); } // The RRUI events service fetches club-event feeds from // /api/playerevents/v1/... (verified from the client's string literals: // "api/playerevents/v1/club/{0}", "api/playerevents/v1/clubs"). The single-club // awaiter parses the paged envelope { Events, ContinuationToken }; the multi-club // awaiter parses a bare list. Both were 404s -> "Failed to get club events". [Fact] public void ClubEventFeeds_AreRegisteredUnderPlayerevents() { var single = typeof(EventsAPI).GetMethod(nameof(EventsAPI.GetEventsByClub))!; var singleTemplates = single.GetCustomAttributes().Select(a => a.Template); Assert.Contains("v1/club/{clubId:long}", singleTemplates); Assert.Contains("v1/club_events/{clubId:long}", singleTemplates); var multi = typeof(EventsAPI).GetMethod(nameof(EventsAPI.GetEventsByClubs))!; var multiTemplates = multi.GetCustomAttributes().Select(a => a.Template); Assert.Contains("v1/clubs", multiTemplates); var upcoming = typeof(EventsAPI).GetMethod(nameof(EventsAPI.GetUpcomingEvents))!; Assert.Contains("v1/upcoming_events", upcoming.GetCustomAttributes().Select(a => a.Template)); } [Fact] public async Task ClubEventSingleFeed_ServesPagedEnvelope() { var api = CreateEventsController(CreateDb()); var result = Assert.IsType(await api.GetEventsByClub(123)); var json = Serialize(result.Value); using var doc = System.Text.Json.JsonDocument.Parse(json); Assert.Equal(System.Text.Json.JsonValueKind.Object, doc.RootElement.ValueKind); // Ordinal codec: Events (list) first, ContinuationToken (string) second. var props = doc.RootElement.EnumerateObject().ToList(); Assert.Equal("Events", props[0].Name); Assert.Equal("ContinuationToken", props[1].Name); Assert.Equal(System.Text.Json.JsonValueKind.Array, props[0].Value.ValueKind); } [Fact] public async Task ClubEventMultiFeed_ServesBareList() { var api = CreateEventsController(CreateDb()); var events = await api.GetEventsByClubs(new List { 1, 2 }); Assert.NotNull(events); Assert.Empty(events); } [Fact] public async Task AccountSearch_ReturnsBareArrayWithPlayerKeys() { // Verified against the live recconned reference server: account search is a // bare array of player DTOs with this exact lowercase key set. Missing keys // (personalPronouns/identityFlags/displayEmoji) made the client's parser // return null ("ArgumentNullException: source"). var db = CreateDb(); db.Profiles.Add(new RadicadeServer.RecNet.Auth.Profile { accountId = 7, username = "bob", displayName = "Bob", profileImage = "img.png", isJunior = false, platforms = 0, createdAt = DateTime.UtcNow, }); await db.SaveChangesAsync(); var api = new AccountsAPI(db); var result = await api.GetByName("bob"); var json = Serialize(result); using var doc = JsonDocument.Parse(json); Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind); Assert.Equal(1, doc.RootElement.GetArrayLength()); var playerKeys = doc.RootElement[0].EnumerateObject().Select(p => p.Name).ToArray(); foreach (var key in new[] { "accountId", "profileImage", "isJunior", "platforms", "username", "displayName", "createdAt", "personalPronouns", "identityFlags", "displayEmoji" }) Assert.Contains(key, playerKeys); } [Fact] public async Task AccountSearch_ReportsStoredPronounsAndIdentityFlags() { // Both are stored on the profile and settable through // /account/me/personalpronouns and /account/me/identityflags, so search results // (the invite / player card) must echo what the player chose. They used to be // hardcoded to 0 here, so every searched player showed no pronouns. var db = CreateDb(); db.Profiles.Add(new RadicadeServer.RecNet.Auth.Profile { accountId = 7, username = "bob", displayName = "Bob", profileImage = "img.png", personalPronouns = 3, identityFlags = 5, }); await db.SaveChangesAsync(); var result = await new AccountsAPI(db).GetByName("bob"); using var doc = JsonDocument.Parse(Serialize(result)); var player = doc.RootElement[0]; Assert.Equal(3, player.GetProperty("personalPronouns").GetInt32()); Assert.Equal(5, player.GetProperty("identityFlags").GetInt32()); } [Fact] public async Task SetUsername_RefusesANameAnotherAccountAlreadyUses() { // Sign-in resolves an account by username OR display name, so letting two // accounts share a name made that lookup land on whichever row came first and // locked the other account out of the web login. var db = CreateDb(); db.Profiles.Add(new RadicadeServer.RecNet.Auth.Profile { accountId = 7, username = "bob", displayName = "Bob", profileImage = "img.png" }); db.Profiles.Add(new RadicadeServer.RecNet.Auth.Profile { accountId = 8, username = "alice", displayName = "Alice", profileImage = "img.png" }); await db.SaveChangesAsync(); var api = new AccountsAPI(db); SetUser(api, 8); Assert.False((await api.SetUsername("bob")).success); Assert.False((await api.SetUsername("Bob")).success); Assert.False((await api.SetUsername(" ")).success); Assert.True((await api.SetUsername(" alice2 ")).success); var mine = await db.Profiles.SingleAsync(x => x.accountId == 8); Assert.Equal("alice2", mine.username); } [Fact] public async Task ProfileGallery_ReturnsOnlyPlayersPublicPhotos() { var db = CreateDb(); RadicadeServer.RecNet.CDN.ImageDB Photo(long id, long owner, RadicadeServer.RecNet.CDN.SavedImageAccessibility access, RadicadeServer.RecNet.CDN.SavedImageType type) => new() { Id = id, OwnerId = owner, Name = $"{id}.png", Accessibility = access, ImageType = type, CreatedAt = new DateTime(2026, 1, 1).AddDays(id) }; var publicAccess = RadicadeServer.RecNet.CDN.SavedImageAccessibility.Public; var photoType = RadicadeServer.RecNet.CDN.SavedImageType.ShareCamera; db.Images.AddRange(Photo(1, 7, publicAccess, photoType), Photo(2, 7, publicAccess, photoType), Photo(3, 8, publicAccess, photoType), Photo(4, 7, RadicadeServer.RecNet.CDN.SavedImageAccessibility.Private, photoType), Photo(5, 7, publicAccess, RadicadeServer.RecNet.CDN.SavedImageType.ProfileThumbnail)); await db.SaveChangesAsync(); var api = new ImagesAPI(db); var images = await api.GetImagesFromPlayer(7); // Reference behavior: a player's photo list serves every PUBLIC image // they own (the profile-gallery grid), not just ShareCamera shots. Assert.Equal(new long[] { 5, 2, 1 }, images.Select(image => (long)image.GetType().GetProperty("Id")!.GetValue(image)!)); Assert.All(images, image => Assert.Equal(7, (long)image.GetType().GetProperty("PlayerId")!.GetValue(image)!)); Assert.Empty(await api.GetImagesFromPlayer(99)); } [Fact] public void ProfileWishlist_HasEconRoute() { var routes = typeof(RadicadeServer.API.Wishlists.WishlistAPI) .GetCustomAttributes().Select(route => route.Template); Assert.Contains("econ/api/itemWishlists/v1", routes); } [Fact] public async Task ProfilePresence_UsesPlayersLoginVersion() { var db = CreateDb(); db.Players.Add(new RadicadeServer.RecNet.Auth.PlayerData { AccountId = 7, AppVersion = "20230301" }); await db.SaveChangesAsync(); var presence = await Presence.CreateAsync(db, new RealPresence { playerId = 7 }, null); Assert.Equal("20230301", presence.appVersion); var missing = await Presence.CreateAsync(db, new RealPresence { playerId = 99 }, null); Assert.Equal(DBConfig.GameVersion, missing.appVersion); } // A stock client's key ID is "p1" + the stock ".rec.net" tail literal; the // metadata patcher rewrites that tail to ".radicade.internal", which is what // the patched build compares against. Serving either one on the wrong route // makes the client drop the image with "Image signature malformed" before it // ever reaches RSA verification, so the two IDs are pinned here. [Fact] public void ImageRoutesServeTheKeyIdTheirClientBuildCanParse() { Assert.Equal("KEY:RSA:p1.radicade.internal", RadicadeServer.Images.ImageAPI.PatchedKeyId); Assert.Equal("KEY:RSA:p1.rec.net", RadicadeServer.Images.ImageAPI.StockKeyId); Assert.StartsWith($"key-id={RadicadeServer.Images.ImageAPI.PatchedKeyId}; data=", RadicadeServer.Images.ImageAPI.SignatureString); Assert.StartsWith($"key-id={RadicadeServer.Images.ImageAPI.StockKeyId}; data=", RadicadeServer.Images.ImageAPI.StockSignatureString); // With no override in the environment the bare route serves the stock ID; the // override exists only so a mis-mapped deploy can be corrected by a restart. Assert.Equal(RadicadeServer.Images.ImageAPI.StockKeyId, RadicadeServer.Images.ImageAPI.BareRouteKeyId); // Same placeholder signature bytes on both: only the key ID decides which // build accepts the header. Assert.Equal(RadicadeServer.Images.ImageAPI.SignatureString[ RadicadeServer.Images.ImageAPI.SignatureString.IndexOf("data=", StringComparison.Ordinal)..], RadicadeServer.Images.ImageAPI.StockSignatureString[ RadicadeServer.Images.ImageAPI.StockSignatureString.IndexOf("data=", StringComparison.Ordinal)..]); } [Theory] [InlineData("img")] [InlineData("alternate")] [InlineData("default")] public async Task ProfileImage_AllRoutesIncludeContentSignature(string route) { var originalImages = DBConfig.CDNImages; var directory = Path.Combine(Path.GetTempPath(), "radicade-image-test-" + Guid.NewGuid()); Directory.CreateDirectory(directory); try { DBConfig.CDNImages = directory; var imageName = route == "default" ? DBConfig.DefaultRoomImage : "profile.png"; await File.WriteAllBytesAsync(Path.Combine(directory, imageName), Convert.FromBase64String("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=")); var api = new RadicadeServer.Images.ImageAPI { ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() } }; var result = route switch { "img" => await api.GetImageFile(imageName, null, null, "", null), "alternate" => await api.GetImageFileAlt(imageName, null, null, "", null), _ => await api.GetDefaultImage(null, null, "", null) }; Assert.IsType(result); // The client compares the header's key ID against a literal its own // global-metadata.dat carries, so each route has to answer with the ID // its caller can parse: /images/img/ is the metadata-patched // build's own URL, and the bare route is what the BepInEx plugin's // in-flight rewrite of img.rec.net asks for (a stock client). var expectedKeyId = route == "img" ? RadicadeServer.Images.ImageAPI.PatchedKeyId : RadicadeServer.Images.ImageAPI.StockKeyId; // ValidateImageSignature splits on ';', trims the fields, and compares // the complete key ID before decoding data. var fields = api.Response.Headers["Content-Signature"].ToString() .Split(';').Select(field => field.Trim()).ToArray(); Assert.Equal($"key-id={expectedKeyId}", fields[0]); Assert.Equal(256, Convert.FromBase64String(fields[1]["data=".Length..]).Length); } finally { DBConfig.CDNImages = originalImages; Directory.Delete(directory, recursive: true); } } // A third patcher (or a player who edited their own metadata) produces a key ID // neither route default matches, and its requests are byte-identical to everyone // else's. A client that can state its own literal is answered with it verbatim; // anything malformed falls back to the route default rather than being echoed. [Theory] [InlineData("img", "KEY:RSA:p1.rec.net", "KEY:RSA:p1.rec.net")] [InlineData("bare", "KEY:RSA:p1.radicade.internal", "KEY:RSA:p1.radicade.internal")] [InlineData("bare", "KEY:RSA:p1.something.internal", "KEY:RSA:p1.something.internal")] [InlineData("bare", null, "KEY:RSA:p1.rec.net")] [InlineData("bare", "", "KEY:RSA:p1.rec.net")] [InlineData("img", null, "KEY:RSA:p1.radicade.internal")] public void StatedKeyIdWinsOverRouteDefault(string route, string clientKeyId, string expected) { var routeDefault = route == "img" ? RadicadeServer.Images.ImageAPI.PatchedKeyId : RadicadeServer.Images.ImageAPI.StockKeyId; var header = RadicadeServer.Images.ImageAPI.SignatureFor(clientKeyId, route, routeDefault); Assert.StartsWith($"key-id={expected}; data=", header); } [Theory] [InlineData("stock")] [InlineData("patched")] [InlineData("KEY:RSA:p1")] [InlineData("KEY:RSA:p1.")] [InlineData("KEY:RSA:p1.rec.net; data=steal")] [InlineData("KEY:RSA:p1.rec.net\\r\\nX-Injected: 1")] [InlineData("KEY:RSA:p2.rec.net")] [InlineData("KEY:RSA:p1.a b")] public void MalformedKeyIdsAreRefusedRatherThanEchoed(string clientKeyId) { Assert.False(RadicadeServer.Images.ImageAPI.IsWellFormedKeyId(clientKeyId)); var header = RadicadeServer.Images.ImageAPI.SignatureFor( clientKeyId, "bare", RadicadeServer.Images.ImageAPI.StockKeyId); Assert.StartsWith($"key-id={RadicadeServer.Images.ImageAPI.StockKeyId}; data=", header); Assert.DoesNotContain("Injected", header); } [Fact] public void AcceptableKeyIdsAreTheShapeTheClientBuildsItself() { Assert.True(RadicadeServer.Images.ImageAPI.IsWellFormedKeyId("KEY:RSA:p1.rec.net")); Assert.True(RadicadeServer.Images.ImageAPI.IsWellFormedKeyId("KEY:RSA:p1.radicade.internal")); Assert.True(RadicadeServer.Images.ImageAPI.IsWellFormedKeyId("KEY:RSA:p1.a-b_c.1")); } [Fact] public async Task ProfileBio_NullStoredBioReturnsEmptyString() { var db = CreateDb(); db.Profiles.Add(new RadicadeServer.RecNet.Auth.Profile { accountId = 7, username = "bob", displayName = "Bob", profileImage = "img.png", bio = null }); await db.SaveChangesAsync(); var response = await new AccountsAPI(db).GetBioFromId("7"); Assert.NotNull(response); Assert.Equal(string.Empty, response.bio); } [Fact] public async Task ProfilePrivacy_ReturnsSettingsObject() { var db = CreateDb(); db.Profiles.Add(new RadicadeServer.RecNet.Auth.Profile { accountId = 7, username = "bob", displayName = "Bob", profileImage = "img.png" }); await db.SaveChangesAsync(); var result = await new AccountsAPI(db).GetAccountPrivacySettings(7); var ok = Assert.IsType(result); Assert.Contains("accountId", Serialize(ok.Value)); } [Fact] public void Reputation_FreshAccountHasTheGateOpenAndNoCheerCounters() { // Two separate things share one payload. IsCheerful is the gate the client reads to // decide whether this player may hand cheers out at all, and the reference server // answers true for everyone. The five counters are the earned badges, and those used // to be floored at 1, which is what made the client draw all five as already earned. var api = new ReputationAPI(CreateDb()); var result = Assert.IsType(api.GetReputation(7)); var json = Serialize(result.Value); using var doc = JsonDocument.Parse(json); Assert.True(doc.RootElement.GetProperty("IsCheerful").GetBoolean()); Assert.Equal(0, doc.RootElement.GetProperty("CheerGeneral").GetInt32()); Assert.Equal(0, doc.RootElement.GetProperty("CheerHelpful").GetInt32()); } [Fact] public async Task Reputation_SelectedCheerCreatesMissingReputation() { var db = CreateDb(); var api = new ReputationAPI(db); SetUser(api, 7); SetBody(api, "Category=40", "application/x-www-form-urlencoded"); var result = await api.SetSelectedCheer(); Assert.IsType(result.Result); // 40 is Creative on the client's ladder (0/10/20/30/40); the badge has to store // the value the client sent so it renders the badge it thinks it picked. Assert.Equal(40, (await db.Reputations.SingleAsync()).SelectedCheer); } [Fact] public async Task Reputation_SelectedCheerAcceptsEveryShapeTheClientUses() { // The client posts this as a form, and [FromBody] rejected every non-JSON request // with "415 Unsupported Media Type", which is why the badge never changed. All // four shapes have to keep working. async Task SelectedCheerFor(string? body, string contentType, string query = "") { var db = CreateDb(); var api = new ReputationAPI(db); SetUser(api, 7); SetBody(api, body, contentType, query); Assert.IsType((await api.SetSelectedCheer()).Result); return (await db.Reputations.SingleAsync()).SelectedCheer; } Assert.Equal(10, await SelectedCheerFor("Category=10", "application/x-www-form-urlencoded")); Assert.Equal(20, await SelectedCheerFor("", null!, "?category=20")); Assert.Equal(30, await SelectedCheerFor("30", "text/plain")); Assert.Equal(40, await SelectedCheerFor("{\"Category\":40}", "application/json")); } [Fact] public async Task Reputation_CheerPlayerCountsTheCategoryTheClientSends() { // The client's ladder is 0/10/20/30/40. With sequential enum values none of the // four cheers below matched a switch arm, so they only moved Noteriety. var db = CreateDb(); var api = new ReputationAPI(db); SetUser(api, 7); SetBody(api, "PlayerId=9&Category=10", "application/x-www-form-urlencoded"); var result = await api.CheerPlayer(); Assert.IsType(result.Result); var reputation = await db.Reputations.SingleAsync(x => x.AccountId == 9); Assert.Equal(1, reputation.CheerHelpful); } [Fact] public async Task Reputation_SelectedCheerAcceptsTheDeveloperBadge() { // 9000 is the client's RecRoomDeveloper badge. The developer profile screen posts // it like any other badge, and a server that only knew 0/10/20/30/40 answered // with an unknown-category error instead of equipping it. var db = CreateDb(); var api = new ReputationAPI(db); SetUser(api, 7); SetBody(api, "Category=9000", "application/x-www-form-urlencoded"); Assert.IsType((await api.SetSelectedCheer()).Result); Assert.Equal(9000, (await db.Reputations.SingleAsync()).SelectedCheer); } [Theory] [InlineData(50)] // the spendable credit balance [InlineData(9000)] // the staff-only badge public async Task Reputation_CheerPlayerRefusesCategoriesThatAreNotAwardable(int category) { // Credit is the currency the client spends on cheers; a client that could buy it // by cheering would never run out. RecRoomDeveloper is staff-only. Neither is // something a player can hand out. var db = CreateDb(); var api = new ReputationAPI(db); SetUser(api, 7); SetBody(api, $"PlayerId=9&Category={category}", "application/x-www-form-urlencoded"); var result = await api.CheerPlayer(); Assert.IsType(result.Result); Assert.Empty(await db.Reputations.ToListAsync()); } [Fact] public async Task CreateActionLink_ReturnsScalarResultCode() { // The client converts the response root ("Object must implement // IConvertible" when it gets an array), so the endpoint must return a // bare scalar, not an array/object. var api = new LinkAPI(); var context = new DefaultHttpContext(); context.Request.Method = "POST"; context.Request.ContentType = "application/json"; context.Request.Body = new MemoryStream(System.Text.Encoding.UTF8.GetBytes("{\"roomId\": 10}")); api.ControllerContext = new ControllerContext { HttpContext = context }; var action = await api.CheckActionLink(); var ok = Assert.IsType(action.Result); var json = Serialize(ok.Value); using var doc = JsonDocument.Parse(json); Assert.Equal(JsonValueKind.Number, doc.RootElement.ValueKind); } [Fact] public async Task VersionCheck_AllowsPlay_WhenNoGameUpdateIsPublished() { // Wire shape pinned to the working 2023-era reference server (FemRec-2023, // archived at essentialsasset/RecRoom-Live-Servers): the client's strict // key lookups abort boot ("RecNet version check failed", error code: // student) unless ValidVersion is present, even on HTTP 200. With no Game // Update published the endpoint never gates - the client sends varying query // keys across boots (v=, version=, none) - and the version a client sends is // still never what decides. The one thing that does flip this answer is a // live Game Update announcement: see GameUpdateVersionCheckTests. var db = CreateDb(); db.ServerConfig.Add(new ServerConfigVal("GameVersion", DBConfig.GameVersion)); await db.SaveChangesAsync(); var api = new VersionCheckAPI(db); foreach (var version in new[] { "20230414", DBConfig.GameVersion, "19990101", null }) { var ok = Assert.IsType(await api.Get(version, version)); using var doc = JsonDocument.Parse(Serialize(ok.Value)); Assert.Equal(0, doc.RootElement.GetProperty("ValidVersion").GetInt32()); Assert.Equal(0, doc.RootElement.GetProperty("VersionStatus").GetInt32()); Assert.Equal(0, doc.RootElement.GetProperty("UpdateNotificationStage").GetInt32()); Assert.False(doc.RootElement.GetProperty("IsVersionIslanded").GetBoolean()); Assert.False(doc.RootElement.GetProperty("IsCrossPlayDisabled").GetBoolean()); Assert.Equal(5, doc.RootElement.EnumerateObject().Count()); } } [Fact] public void VersionCheck_IslandedVersions_IsBareEmptyArray() { var api = new VersionCheckAPI(CreateDb()); var ok = Assert.IsType(api.GetIslandedVersions()); AssertIsEmptyArray(Serialize(ok.Value)); } [Fact] public void StartupConfig_ReferencePlaceholderEndpoints_ReturnExpectedShapes() { var api = new ConfigAPI(CreateDb()); foreach (var result in new[] { api.GetAzureSpeechConfig(), api.GetBacktraceConfig(), api.GetStatsigUserProperties(), api.GetVoiceConfig(), }) { Assert.IsType(result); } var azure = Assert.IsType(api.GetAzureSpeechConfig()); var azureKeys = TopLevelKeys(Serialize(azure.Value)); Assert.Contains("Key", azureKeys); Assert.Contains("Region", azureKeys); Assert.Contains("Enabled", azureKeys); var backtrace = Assert.IsType(api.GetBacktraceConfig()); var backtraceKeys = TopLevelKeys(Serialize(backtrace.Value)); Assert.Contains("ReportBudget", backtraceKeys); Assert.Contains("VersionRegex", backtraceKeys); var amplitude = Assert.IsType(api.GetAmplitudeConfig()); using var amplitudeDoc = JsonDocument.Parse(Serialize(amplitude.Value)); Assert.Equal("e1693a1003671058b6abc356c8ba8d59", amplitudeDoc.RootElement.GetProperty("AmplitudeKey").GetString()); Assert.True(amplitudeDoc.RootElement.GetProperty("UseRudderStack").GetBoolean()); Assert.Equal("23NiJHIgu3koaGNCZIiuYvIQNCu", amplitudeDoc.RootElement.GetProperty("RudderStackKey").GetString()); Assert.True(amplitudeDoc.RootElement.GetProperty("UseStatSig").GetBoolean()); Assert.Equal("client-SBZkOrjD3r1Cat3f3W8K6sBd11WKlXZXIlCWj6l4Aje", amplitudeDoc.RootElement.GetProperty("StatSigKey").GetString()); Assert.Equal(0, amplitudeDoc.RootElement.GetProperty("StatSigEnvironment").GetInt32()); } [Fact] public void NameServer_IncludesRecRoom2023ServiceKeys() { var nameServer = DBConfig.NameServer; var keys = TopLevelKeys(Serialize(nameServer)); foreach (var key in new[] { "Accounts", "AI", "API", "Auth", "BugReporting", "Cards", "CDN", "Chat", "Clubs", "CMS", "Commerce", "Data", "DataCollection", "Discovery", "Econ", "GameLogs", "Geo", "Images", "Leaderboard", "Link", "Lists", "Locker", "Matchmaking", "Moderation", "Notifications", "PlatformNotifications", "PlayerSettings", "RoomComments", "RoomieIntegrations", "Rooms", "Storage", "Strings", "StringsCDN", "Studio", "Thorn", "Videos", "WWW", }) { Assert.Contains(key, keys); } // The dump's `Service` label is a logical route namespace: e.g. Econ routes share // the econ base while API routes share the API base, and Commerce and Leaderboard // are separate bases. Each advertised base is a host of its own with no path - the // client appends its own path literal to it and the patch turns the host back into // the route prefix (NameserverRedirect.cs). Assert.Equal(DBConfig.ServiceUrl("api"), nameServer.API); Assert.Equal(DBConfig.ServiceUrl("econ"), nameServer.Econ); Assert.Equal(DBConfig.ServiceUrl("commerce"), nameServer.Commerce); Assert.Equal(DBConfig.ServiceUrl("leaderboard"), nameServer.Leaderboard); Assert.DoesNotContain("Service", keys); } [Fact] public void EndpointServiceLabelsResolveThroughTheirConfiguredBaseHosts() { // The 2023 endpoint dump's Service column is logical classification. The // nameserver values are base URLs - hosts now, one per service - and endpoint // route templates supply the rest. var apiBase = typeof(EventsAPI).GetCustomAttributes() .Select(attribute => attribute.Template); Assert.Contains("api/playerevents", apiBase); Assert.Equal(DBConfig.ServiceUrl("api"), DBConfig.NameServer.API); var econBase = typeof(AvatarV1).GetCustomAttributes() .Select(attribute => attribute.Template); Assert.Contains("econ/api/avatar/v1", econBase); Assert.Equal(DBConfig.ServiceUrl("econ"), DBConfig.NameServer.Econ); var commerceBase = typeof(RadicadeServer.Commerce.CommercePlaceholderAPI) .GetCustomAttributes() .Select(attribute => attribute.Template); Assert.Contains("commerce", commerceBase); var catalog = typeof(RadicadeServer.Commerce.CommercePlaceholderAPI) .GetMethod("GetCatalogAll")!; Assert.Contains("api/catalog/v1/all", catalog.GetCustomAttributes() .Select(attribute => attribute.Template)); Assert.Equal(DBConfig.ServiceUrl("commerce"), DBConfig.NameServer.Commerce); var leaderboardRoutes = typeof(LeaderboardAPI) .GetCustomAttributes() .Select(attribute => attribute.Template); Assert.Contains("leaderboard/leaderboard", leaderboardRoutes); var leaderboardCompatRoutes = typeof(LeaderboardCompatAPI) .GetCustomAttributes() .Select(attribute => attribute.Template); Assert.Contains("leaderboard", leaderboardCompatRoutes); Assert.Equal(DBConfig.ServiceUrl("leaderboard"), DBConfig.NameServer.Leaderboard); } [Fact] public void AvatarV1_DefaultUnlocks_HasEconServiceRouteAlias() { var routes = typeof(AvatarV1).GetCustomAttributes().Select(a => a.Template); Assert.Contains("api/avatar/v1", routes); Assert.Contains("econ/api/avatar/v1", routes); } [Fact] public void AvatarV1_DefaultBaseAvatarItems_ReturnsReferenceShape() { var api = new AvatarV1(CreateDb()); var ok = Assert.IsType(api.GetDefaultBaseAvatarItems()); using var doc = JsonDocument.Parse(Serialize(ok.Value)); Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind); Assert.All(doc.RootElement.EnumerateArray(), item => Assert.True(item.GetProperty("IsBaseAvatarItem").GetBoolean())); } [Fact] public async Task AvatarV4_Items_FiltersExistingLegacyUnlockBlobs() { var db = CreateDb(); db.Unlocks.Add(new RadicadeServer.RecNet.Accounts.PlayerUnlocks { AccountId = 23, AvatarItems = """ [ { "AvatarItemDesc": "07116e9b-b182-40b4-90c2-99738e7aedd6,", "UnlockedLevel": 0, "PlatformMask": -1, "FriendlyName": "Parrot Purse", "Tooltip": "", "Rarity": 50 }, { "AvatarItemDesc": "d0a9262f-5504-46a7-bb10-7507503db58e,95e4cc30-cb68-473d-a395-feadf5b51512,0440f08f-ef1d-49d8-942b-523056e8bb45,", "UnlockedLevel": 0, "PlatformMask": -1, "FriendlyName": "Coach Shirt", "Tooltip": "", "Rarity": 0 } ] """ }); await db.SaveChangesAsync(); var api = new AvatarV4(db); SetUser(api, 23); var ok = Assert.IsType((await api.GetLocalAvatarItems()).Result); using var doc = JsonDocument.Parse(Serialize(ok.Value)); var descs = doc.RootElement .EnumerateArray() .Select(item => item.GetProperty("AvatarItemDesc").GetString()) .ToArray(); Assert.DoesNotContain("07116e9b-b182-40b4-90c2-99738e7aedd6,", descs); Assert.Contains("d0a9262f-5504-46a7-bb10-7507503db58e,95e4cc30-cb68-473d-a395-feadf5b51512,0440f08f-ef1d-49d8-942b-523056e8bb45,", descs); Assert.Equal(descs.Length, descs.Distinct().Count()); } [Fact] public async Task AvatarV4_Items_PreservesPlayerUnlocksBeyondCoachShirt() { var db = CreateDb(); const string goggles = "_qXaNbm4yEeyalo372QbvA,"; db.Unlocks.Add(new PlayerUnlocks { AccountId = 24, AvatarItems = """ [{"AvatarItemDesc":"_qXaNbm4yEeyalo372QbvA,,,","UnlockedLevel":0,"PlatformMask":-1,"FriendlyName":"Biker Goggles","Tooltip":"","Rarity":20}] """ }); await db.SaveChangesAsync(); var api = new AvatarV4(db); SetUser(api, 24); var ok = Assert.IsType((await api.GetLocalAvatarItems()).Result); var items = Assert.IsType>(ok.Value); Assert.Contains(items, item => item.AvatarItemDesc == goggles); } [Fact] public async Task AvatarV4_Items_ServesCatalogToUnauthenticatedWardrobeRequests() { var db = CreateDb(); var api = new AvatarV4(db); // The client asks for this list from inside a room when the customize menu opens, // and that request arrives without a usable bearer token. Answering 401 there // leaves the player with an empty wardrobe, so the route must stay anonymous. api.ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(new ClaimsIdentity()) } }; var result = await api.GetLocalAvatarItems(); Assert.IsType(result.Result); } /// /// The 2023 client saves outfits through /econ/api/avatar/v4/saved/set and expects a /// lean { Success, Slot } ack - not the outfit it just posted. A 404 here is what the /// client's "save this outfit" silently does when the route is missing. /// [Fact] public async Task AvatarV4_SavedOutfitSet_AnswersTheLeanSuccessAck() { var db = CreateDb(); var api = new AvatarV4(db); SetUser(api, 23); var result = await api.SetSavedOutfit(new Outfit { Slot = 1, PreviewImageName = "outfit-one.png", OutfitSelectionsV2 = "[\"a\",\"b\"]", CustomAvatarItems = new object[] { "item-1" }, }); var ok = Assert.IsType(result); using var doc = JsonDocument.Parse(Serialize(ok.Value)); Assert.True(doc.RootElement.GetProperty("Success").GetBoolean()); Assert.Equal(1, doc.RootElement.GetProperty("Slot").GetInt32()); } /// /// v4 and v3 are one slot table: the client saves through v4 and lists through v3, and /// "save over this outfit" is a re-save of an existing slot. Separate stores would let a /// player save an outfit that their own outfit list cannot find. /// [Fact] public async Task AvatarV4_SavedOutfitSet_IsVisibleToTheV3ListAndOverwritesItsSlot() { var db = CreateDb(); var v4 = new AvatarV4(db); SetUser(v4, 77); Assert.IsType(await v4.SetSavedOutfit(new Outfit { Slot = 0, PreviewImageName = "first.png" })); Assert.IsType(await v4.SetSavedOutfit(new Outfit { Slot = 1, PreviewImageName = "second.png" })); Assert.IsType(await v4.SetSavedOutfit(new Outfit { Slot = 0, PreviewImageName = "replaced.png" })); var v3 = new AvatarV3(db); SetUser(v3, 77); var outfits = await v3.GetSavedOutfits(); Assert.Equal(2, outfits.Count); Assert.Equal("replaced.png", outfits.Single(x => x.Slot == 0).PreviewImageName); Assert.Equal("second.png", outfits.Single(x => x.Slot == 1).PreviewImageName); } /// /// A body with no slot is refused instead of defaulted: guessing would overwrite slot 0, /// which is exactly the outfit the player did not ask to replace. /// [Fact] public async Task AvatarV4_SavedOutfitSet_RefusesABodyWithNoSlot() { var db = CreateDb(); var api = new AvatarV4(db); SetUser(api, 23); var result = await api.SetSavedOutfit(new Outfit { PreviewImageName = "orphan.png" }); Assert.IsType(result); Assert.Empty(db.Avatars); } /// /// A player who has never saved an outfit gets an empty list, not a 404 - the client's /// saved-outfits screen reads this at the same time as the wardrobe. /// [Fact] public async Task AvatarV4_SavedOutfitSet_NeverLeavesANullSlotBehind() { var db = CreateDb(); var api = new AvatarV4(db); SetUser(api, 23); // Pre-existing player data can carry the leading null the old handler stored when it // accepted an empty body. db.Avatars.Add(new PlayerAvatar { AccountId = 23, Avatar = "{}", Outfits = "[null]" }); await db.SaveChangesAsync(); Assert.IsType(await api.SetSavedOutfit(new Outfit { Slot = 0 })); var stored = await db.Avatars.AsNoTracking().SingleAsync(x => x.AccountId == 23); using var doc = JsonDocument.Parse(stored.Outfits); var entry = Assert.Single(doc.RootElement.EnumerateArray()); Assert.NotEqual(JsonValueKind.Null, entry.ValueKind); var v3 = new AvatarV3(db); SetUser(v3, 23); Assert.Single(await v3.GetSavedOutfits()); } [Fact] public void AvatarCatalog_PreservesBrowsableMetadata() { var item = new RadicadeServer.RecNet.Avatar.AvatarItem { AvatarItemDesc = "_OWVy3z6iU-M3-zbQgSLig,,,", AvatarItemId = 835, AvatarItemType = 0, FriendlyName = "Vampire Hunter Gloves (Blue)", ThumbnailImage = "cimomkml6k4toyowd1voh7hqm.png", TagList = "gloves", CreatedAt = new DateTime(2018, 11, 1, 17, 51, 50, DateTimeKind.Utc) }; var copy = Assert.Single(RadicadeServer.RecNet.Avatar.AvatarUnlockCatalog.KeepSupportedItems([item])); using var doc = JsonDocument.Parse(Serialize(copy)); Assert.Equal(835, doc.RootElement.GetProperty("AvatarItemId").GetInt32()); Assert.Equal(item.ThumbnailImage, copy.ThumbnailImage); Assert.Equal(item.TagList, copy.TagList); Assert.Equal(item.CreatedAt, copy.CreatedAt); Assert.False(doc.RootElement.GetProperty("IsBaseAvatarItem").GetBoolean()); Assert.Equal(0, doc.RootElement.GetProperty("AvatarItemType").GetInt32()); } [Fact] public void AvatarCatalog_ReadsConfiguredCatalogInsteadOfBootFallback() { var originalCatalog = DBConfig.DefaultAvatarCatalog; var originalFallback = DBConfig.DefaultAvatarUnlocks; try { DBConfig.DefaultAvatarCatalog = """ [{"AvatarItemDesc":"catalog-item,,,","UnlockedLevel":0,"PlatformMask":-1,"FriendlyName":"Catalog Item","Rarity":1}] """; DBConfig.DefaultAvatarUnlocks = """ [{"AvatarItemDesc":"fallback-item,,,","UnlockedLevel":0,"PlatformMask":-1,"FriendlyName":"Fallback","Rarity":1}] """; var items = RadicadeServer.RecNet.Avatar.AvatarUnlockCatalog.GetSafeDefaultItems(); Assert.Contains(items, item => item.AvatarItemDesc == "catalog-item,"); Assert.DoesNotContain(items, item => item.AvatarItemDesc == "fallback-item,"); } finally { DBConfig.DefaultAvatarCatalog = originalCatalog; DBConfig.DefaultAvatarUnlocks = originalFallback; } } [Fact] public async Task AvatarV2_GetLocalAvatar_RepairsShortUnsafeOutfitSelection() { var db = CreateDb(); db.Avatars.Add(new RadicadeServer.RecNet.Accounts.Local.PlayerAvatar { AccountId = 2, Avatar = """ {"OutfitSelections":"d0a9262f-5504-46a7-bb10-7507503db58e,,,,1","HairColor":"fT0t-J1A-0i_tdoogFEz-g","SkinColor":"85343b16-d58a-4091-96d8-083a81fb03ae","FaceFeatures":"{}"} """, Outfits = "[]" }); await db.SaveChangesAsync(); var api = new AvatarV2(db); SetUser(api, 2); var avatar = await api.GetLocalAvatar(); var storedAvatar = await db.Avatars.SingleAsync(x => x.AccountId == 2); Assert.NotNull(avatar); Assert.Equal( "d0a9262f-5504-46a7-bb10-7507503db58e,95e4cc30-cb68-473d-a395-feadf5b51512,0440f08f-ef1d-49d8-942b-523056e8bb45,,1", avatar.OutfitSelections); Assert.Contains("95e4cc30-cb68-473d-a395-feadf5b51512", storedAvatar.Avatar); Assert.Contains("0440f08f-ef1d-49d8-942b-523056e8bb45", storedAvatar.Avatar); } [Fact] public void AvatarUnlockCatalog_PreservesConfiguredItemsExceptKnownUnsafeEntries() { var original = DBConfig.DefaultAvatarUnlocks; try { DBConfig.DefaultAvatarUnlocks = """ [ { "AvatarItemDesc": "07116e9b-b182-40b4-90c2-99738e7aedd6,", "UnlockedLevel": 0, "PlatformMask": -1, "FriendlyName": "Parrot Purse", "Tooltip": "", "Rarity": 50 }, { "AvatarItemDesc": "11b3413f-2c18-41cb-8ef2-55fb825ed476,", "UnlockedLevel": 0, "PlatformMask": -1, "FriendlyName": "Missing 2023 Client Prefab", "Tooltip": "", "Rarity": 50 }, { "AvatarItemDesc": "_qXaNbm4yEeyalo372QbvA,,,", "UnlockedLevel": 0, "PlatformMask": -1, "FriendlyName": "Biker Goggles", "Tooltip": "", "Rarity": 20 } ] """; var items = RadicadeServer.RecNet.Avatar.AvatarUnlockCatalog.GetSafeDefaultItems(); Assert.DoesNotContain(items, item => item.AvatarItemDesc == "07116e9b-b182-40b4-90c2-99738e7aedd6,"); Assert.DoesNotContain(items, item => item.AvatarItemDesc == "11b3413f-2c18-41cb-8ef2-55fb825ed476,"); Assert.Contains(items, item => item.AvatarItemDesc == "_qXaNbm4yEeyalo372QbvA,"); Assert.Single(items); } finally { DBConfig.DefaultAvatarUnlocks = original; } } [Fact] public void EacChallenge_ReturnsJsonStringNotRawText() { var api = new EACApi(); var json = Assert.IsType(api.GetFakeEACChallenge()); var serialized = Serialize(json.Value); using var doc = JsonDocument.Parse(serialized); Assert.Equal(JsonValueKind.String, doc.RootElement.ValueKind); Assert.StartsWith("AQAAA", doc.RootElement.GetString()); } [Fact] public void RecRoom2023_ServiceRouteAliases_AreRegistered() { string[] Routes() => typeof(T).GetCustomAttributes().Select(a => a.Template ?? "").ToArray(); Assert.Contains("econ/api/avatar/v2", Routes()); Assert.Contains("econ/api/avatar/v4", Routes()); // The client saves outfits through v4 and reads them back through v3. Assert.Contains("saved/set", typeof(AvatarV4) .GetMethod(nameof(AvatarV4.SetSavedOutfit))! .GetCustomAttributes() .Select(a => a.Template)); Assert.Contains("saved", typeof(AvatarV3) .GetMethod(nameof(AvatarV3.GetSavedOutfits))! .GetCustomAttributes() .Select(a => a.Template)); Assert.Contains("econ/api/objectives/v1", Routes()); Assert.Contains("econ/econ/customAvatarItems", Routes()); Assert.Contains("econ/api/storefronts/v3", Routes()); Assert.Contains("cdn/sigs", Routes()); Assert.Contains("playersettings", Routes()); var settingsRoute = typeof(PlayerSettingsAPI) .GetMethod(nameof(PlayerSettingsAPI.GetSettings))! .GetCustomAttributes() .Select(a => a.Template); Assert.Contains("", settingsRoute); Assert.Contains("playersettings", settingsRoute); var setSettingsRoute = typeof(PlayerSettingsAPI) .GetMethod(nameof(PlayerSettingsAPI.PutSettings))! .GetCustomAttributes() .Select(a => a.Template); Assert.Contains("", setSettingsRoute); Assert.Contains("playersettings", setSettingsRoute); var exclusiveLoginRoute = typeof(MatchmakingPlayerAPI) .GetMethod(nameof(MatchmakingPlayerAPI.ExclusiveLogin))! .GetCustomAttributes() .Select(a => a.Template); Assert.Contains("exclusivelogin", exclusiveLoginRoute); } [Fact] public async Task MatchmakingPlayer_ExclusiveLogin_UsesRegularLoginShape() { var factory = new TestDbContextFactory(); RoomUtils.DbFactory = factory; MatchmakingUtils.DbFactory = factory; await using var db = factory.CreateDbContext(); db.Presences.Add(new RealPresence { playerId = 52 }); db.Rooms.Add(new RoomData { RoomId = 1, Name = "DormRoom", IsDorm = true, CreatorAccountId = 1, Accessibility = Accessibility.Private, MaxPlayers = 4, CreatedAt = DateTime.UtcNow, }); db.SubRooms.Add(new SubRoom { RoomId = 1, Name = "DormRoom", UnitySceneId = "DormRoomScene", MaxPlayers = 4, Accessibility = Accessibility.Private, DataBlob = "", }); await db.SaveChangesAsync(); var api = new MatchmakingPlayerAPI(db); SetUser(api, 52, "steam-test"); var ok = Assert.IsType( await api.ExclusiveLogin(loginLock: "lock-52")); using var doc = JsonDocument.Parse(Serialize(ok.Value)); Assert.True(doc.RootElement.GetProperty("success").GetBoolean()); Assert.Equal(52, doc.RootElement.GetProperty("playerId").GetInt64()); Assert.Equal("lock-52", doc.RootElement.GetProperty("loginLock").GetString()); Assert.NotNull((await db.Presences.FirstAsync(x => x.playerId == 52)).roomInstance); } [Fact] public async Task MatchmakingPlayerPresence_DoesNotCreateDormAsALookupSideEffect() { const long playerId = 77; var factory = new TestDbContextFactory(); RoomUtils.DbFactory = factory; MatchmakingUtils.DbFactory = factory; await using var db = factory.CreateDbContext(); db.Profiles.Add(new RadicadeServer.RecNet.Auth.Profile { accountId = playerId, username = "test-player", displayName = "TestPlayer", profileImage = "DefaultProfileImage", platforms = 0, createdAt = DateTime.UtcNow, }); db.Presences.Add(new RealPresence { playerId = playerId, isOnline = false, deviceClass = DeviceClass.Screen, statusVisibility = StatusVisibility.Public, }); db.Rooms.Add(new RoomData { RoomId = 1, Name = "DormRoom", IsDorm = true, CreatorAccountId = 1, Accessibility = Accessibility.Private, MaxPlayers = 4, CreatedAt = DateTime.UtcNow, }); db.SubRooms.Add(new SubRoom { RoomId = 1, Name = "DormRoom", UnitySceneId = "DormRoomScene", MaxPlayers = 4, Accessibility = Accessibility.Private, DataBlob = "", }); await db.SaveChangesAsync(); var api = new MatchmakingAPI(db); api.ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }; var presences = await api.GetPlayerPresence(new List { playerId }); var presence = Assert.Single(presences); var storedPresence = await db.Presences.FirstAsync(x => x.playerId == playerId); Assert.Equal(playerId, presence.playerId); Assert.False(presence.isOnline); Assert.Null(presence.roomInstance); Assert.Null(storedPresence.roomInstance); Assert.Empty(await db.RoomInstances.ToListAsync()); } [Fact] public async Task InventionsFromCreators_ReturnsCreatorOwnedItemsInNewestFirstOrder() { using var db = CreateDb(); db.Inventions.AddRange( new InventionDB { CreatorPlayerId = 7, InventionId = 1, Name = "Older creator item", CreatedAt = new DateTime(2023, 9, 1, 0, 0, 0, DateTimeKind.Utc), IsPublished = false, }, new InventionDB { CreatorPlayerId = 7, InventionId = 2, Name = "Newer creator item", CreatedAt = new DateTime(2023, 9, 2, 0, 0, 0, DateTimeKind.Utc), IsPublished = true, }, new InventionDB { CreatorPlayerId = 8, InventionId = 3, Name = "Other creator item", CreatedAt = new DateTime(2023, 9, 3, 0, 0, 0, DateTimeKind.Utc), IsPublished = true, }); await db.SaveChangesAsync(); var api = new InventionsV1(db); var route = typeof(InventionsV1).GetMethod(nameof(InventionsV1.GetInventionsFromCreators))! .GetCustomAttributes() .Select(attribute => attribute.Template); Assert.Contains("fromcreators", route); var response = Assert.IsType( await api.GetInventionsFromCreators([7], skip: 0, take: 1)); var items = Assert.IsAssignableFrom>(response.Value).ToArray(); var item = Assert.Single(items); Assert.Equal("Newer creator item", item.Name); Assert.True(item.IsPublished); var otherCreators = Assert.IsType( await api.GetInventionsFromCreators([7, 8], skip: 0, take: 10)); Assert.Equal(3, Assert.IsAssignableFrom>(otherCreators.Value).Count()); var missingCreators = Assert.IsType( await api.GetInventionsFromCreators(null)); Assert.NotNull(missingCreators.Value); } [Fact] public void DataCollection_Placeholders_ReturnSuccessShapes() { var api = new DataCollectionAPI(); Assert.Equal("{}", Serialize(Assert.IsType(api.Event()).Value)); Assert.Equal("[]", Serialize(Assert.IsType(api.Events()).Value)); Assert.Equal("{}", Serialize(Assert.IsType(api.Sampling()).Value)); } [Fact] public void CustomAvatarItems_Placeholders_ReturnReferenceShapes() { using var db = CreateDb(); var api = new CustomAvatarItemsAPI(db); SetUser(api, CreatorId); // The gate the drawing screen asks before it enables anything. Its answer has to be the // client's own two-member result, not the server's PascalCase one: a "success" body is an // unknown member to that reader, its success flag stays false, and every create/modify // option on the screen comes up disabled. var allowed = Assert.IsType(api.IsCreationAllowedForAccount()); var allowedKeys = TopLevelKeys(Serialize(allowed.Value)); Assert.Contains("ALOCDGMHFKA", allowedKeys); Assert.Contains("FBILGKFKFJE", allowedKeys); Assert.True(Assert.IsType(allowed.Value!.GetType().GetProperty("ALOCDGMHFKA")!.GetValue(allowed.Value))); Assert.Equal("true", Serialize(Assert.IsType(api.IsEnabled()).Value)); // Scalars, read into an int: the body has to be a bare number, since an object wrapper // fails that read. Both were unmapped, so the item details page logged a 404 on open. Assert.Equal("0", Serialize(Assert.IsType(api.MinPriceForPublicItem()).Value)); Assert.True(Assert.IsType(api.ItemOwnershipLimit()).Value is int limit && limit > 0); // The two bare-array routes. The client reads them with a plain-list reader, so a page // here would fail it at offset 0. Assert.Equal("[]", Serialize(Assert.IsType(api.Featured().GetAwaiter().GetResult()).Value)); // The paged { Results, TotalResults } object, not a bare array: the client reads these // routes into a paged DTO and a body it cannot bind throws inside the wardrobe's item // download, leaving the customize screen empty. var owned = Assert.IsType(api.Owned().GetAwaiter().GetResult()); var ownedBody = Serialize(owned.Value); var ownedKeys = TopLevelKeys(ownedBody); Assert.Contains("Results", ownedKeys); Assert.Contains("TotalResults", ownedKeys); Assert.StartsWith("{", ownedBody); var page = Assert.IsType(api.FromCreator(2).GetAwaiter().GetResult()); var pageKeys = TopLevelKeys(Serialize(page.Value)); Assert.Contains("Results", pageKeys); Assert.Contains("TotalResults", pageKeys); // The bare /v1 and /v1/me spellings the same client also asks for. Both were unmapped, // so every boot logged a 404 for them; they must carry the paged body, not a 404 and // not a bare array. var routes = typeof(CustomAvatarItemsAPI) .GetMethod(nameof(CustomAvatarItemsAPI.Items))! .GetCustomAttributes() .Select(a => a.Template) .ToArray(); Assert.Contains("v1", routes); Assert.Contains("v1/me", routes); var items = Assert.IsType(api.Items().GetAwaiter().GetResult()); var itemsBody = Serialize(items.Value); Assert.StartsWith("{", itemsBody); Assert.Contains("Results", TopLevelKeys(itemsBody)); Assert.Contains("TotalResults", TopLevelKeys(itemsBody)); var saves = Assert.IsType(api.CurrentSavesForLegacyAvatarItems()); Assert.Contains("customAvatarItemSavesByAvatarItemDesc", TopLevelKeys(Serialize(saves.Value))); } /// /// The create call, exactly as the client sent it on the wire: multipart form data with a /// metadata part and a thumbnail file part. This is the request the server used to /// answer with an empty array, so the item was never stored and never showed up in the /// creator's lists afterwards. /// [Fact] public async Task CustomAvatarItems_Create_StoresTheItemAndServesItBack() { using var db = CreateDb(); var api = new CustomAvatarItemsAPI(db); SetUser(api, CreatorId); const string metadata = "{\"Name\":\"Test\",\"Description\":\"Test\",\"Price\":0,\"BaseAvatarItemId\":2184,\"BaseAvatarItemColor\":\"#F55C1A\",\"Accessibility\":0}"; var thumbnail = new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 1, 2, 3, 4 }; var design = new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 5, 6, 7, 8 }; var (body, contentType) = MultipartBody(metadata, thumbnail, design); api.ControllerContext.HttpContext.Request.ContentType = contentType; api.ControllerContext.HttpContext.Request.Body = new MemoryStream(body); var created = Assert.IsType(await api.Create()); var createdBody = Serialize(created.Value); var createdKeys = TopLevelKeys(createdBody); Assert.Contains("CustomAvatarItemId", createdKeys); Assert.Contains("ImageName", createdKeys); Assert.Contains("DesignFilename", createdKeys); // The key the client's card art is actually looked up by. Without it the item lists in // the watch but every card draws the placeholder, because the reader has no thumbnail // name to fetch - which is what a create response carrying only ImageName produced. Assert.Contains("ThumbnailImageFilename", createdKeys); Assert.Contains("IsFeatured", createdKeys); using var doc = JsonDocument.Parse(createdBody); var itemId = doc.RootElement.GetProperty("CustomAvatarItemId").GetString(); Assert.True(Guid.TryParse(itemId, out _)); Assert.Equal("Test", doc.RootElement.GetProperty("Name").GetString()); Assert.Equal(2184, doc.RootElement.GetProperty("BaseAvatarItemId").GetInt32()); Assert.Equal("#F55C1A", doc.RootElement.GetProperty("BaseAvatarItemColor").GetString()); // Both uploads landed where the image route serves them from: the thumbnail is the card // art, the design is the texture the shirt is drawn with. var imageName = doc.RootElement.GetProperty("ImageName").GetString(); var designName = doc.RootElement.GetProperty("DesignFilename").GetString(); // Both spellings of the thumbnail name point at the same file, so whichever one the // client's reader binds, the card art it asks the CDN for is the file that was written. Assert.Equal(imageName, doc.RootElement.GetProperty("ThumbnailImageFilename").GetString()); var imagePath = Path.Join(DBConfig.CDNImages, imageName); var designPath = Path.Join(DBConfig.CDNImages, designName); Assert.True(File.Exists(imagePath), $"expected the thumbnail at {imagePath}"); Assert.True(File.Exists(designPath), $"expected the design at {designPath}"); Assert.NotEqual(imageName, designName); try { // The design lookup the item pages make: a bare list, with the item's record in it. var designApi = new CustomAvatarItemsAPI(db); SetUser(designApi, CreatorId); var designs = Assert.IsType(await designApi.Design()); var designsBody = Serialize(designs.Value); Assert.StartsWith("[", designsBody); Assert.Contains(designName, designsBody); // ... and the same lookup for another account stays empty while the item is private. var strangerView = new CustomAvatarItemsAPI(db); SetUser(strangerView, 999); Assert.Equal("[]", Serialize(Assert.IsType(await strangerView.Design()).Value)); // ... and the item is readable again: paged lists, single-item fetch, and the // resolve call that takes ids. var mine = Assert.IsType(await api.Items()); using var mineDoc = JsonDocument.Parse(Serialize(mine.Value)); Assert.Equal(1, mineDoc.RootElement.GetProperty("TotalResults").GetInt32()); Assert.Equal(itemId, mineDoc.RootElement.GetProperty("Results")[0].GetProperty("CustomAvatarItemId").GetString()); // The list is what the watch draws its cards from, so the thumbnail name has to // survive into it and not only into the create response. Assert.Equal(imageName, mineDoc.RootElement.GetProperty("Results")[0].GetProperty("ThumbnailImageFilename").GetString()); var single = Assert.IsType(await api.Item(Guid.Parse(itemId))); using var singleDoc = JsonDocument.Parse(Serialize(single.Value)); Assert.Equal("Test", singleDoc.RootElement.GetProperty("Name").GetString()); // Fresh controllers: the form is parsed once per request, and the create above // already consumed this context's body. var resolveApi = new CustomAvatarItemsAPI(db); SetUser(resolveApi, CreatorId); SetBody(resolveApi, $"customAvatarItemIds={itemId}", "application/x-www-form-urlencoded"); var bulk = Assert.IsType(await resolveApi.Bulk()); var bulkBody = Serialize(bulk.Value); Assert.StartsWith("[", bulkBody); Assert.Contains("Test", bulkBody); // Another player asking for the same ids gets nothing while the item is private. var strangerApi = new CustomAvatarItemsAPI(db); SetUser(strangerApi, 999); SetBody(strangerApi, $"customAvatarItemIds={itemId}", "application/x-www-form-urlencoded"); Assert.Equal("[]", Serialize(Assert.IsType(await strangerApi.Bulk()).Value)); } finally { if (File.Exists(imagePath)) File.Delete(imagePath); if (File.Exists(designPath)) File.Delete(designPath); } } /// /// The design save, which the item pages make after a create and whenever a design is edited. /// It has to attach to the right item: the id the caller names, or - which is what the create /// flow means - the item they made most recently. /// [Fact] public async Task CustomAvatarItems_SaveDesign_AttachesToTheCallersNewestItem() { using var db = CreateDb(); var older = new CustomAvatarItemDB { CustomAvatarItemId = Guid.NewGuid(), CreatorAccountId = CreatorId, Name = "Older", BaseAvatarItemColor = "#111111", CreatedAt = DateTime.UtcNow.AddMinutes(-5), ModifiedAt = DateTime.UtcNow.AddMinutes(-5), }; var newest = new CustomAvatarItemDB { CustomAvatarItemId = Guid.NewGuid(), CreatorAccountId = CreatorId, Name = "Newest", BaseAvatarItemColor = "#222222", CreatedAt = DateTime.UtcNow, ModifiedAt = DateTime.UtcNow, }; db.CustomAvatarItems.Add(older); db.CustomAvatarItems.Add(newest); await db.SaveChangesAsync(); var image = new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 9, 9, 9 }; var (body, contentType) = MultipartBody("{}", image, image); var api = new CustomAvatarItemsAPI(db); SetUser(api, CreatorId); api.ControllerContext.HttpContext.Request.ContentType = contentType; api.ControllerContext.HttpContext.Request.QueryString = new QueryString("?customAvatarItemColor=%23FF0000"); api.ControllerContext.HttpContext.Request.Body = new MemoryStream(body); var saved = Assert.IsType(await api.SaveDesign()); using var doc = JsonDocument.Parse(Serialize(saved.Value)); Assert.Equal(newest.CustomAvatarItemId.ToString(), doc.RootElement.GetProperty("CustomAvatarItemId").GetString()); Assert.Equal("#FF0000", doc.RootElement.GetProperty("CustomAvatarItemColor").GetString()); // The upload is kept where the image route serves it from, and the design lookup finds it. var designName = doc.RootElement.GetProperty("DesignFilename").GetString(); Assert.False(string.IsNullOrEmpty(designName)); var designPath = Path.Join(DBConfig.CDNImages, designName); Assert.True(File.Exists(designPath), $"expected the design at {designPath}"); try { var lookup = new CustomAvatarItemsAPI(db); SetUser(lookup, CreatorId); SetBody(lookup, null, "application/json", "?customAvatarItemId=" + newest.CustomAvatarItemId); var found = Serialize(Assert.IsType(await lookup.Design()).Value); Assert.Contains(newest.CustomAvatarItemId.ToString(), found); Assert.DoesNotContain(older.CustomAvatarItemId.ToString(), found); } finally { if (File.Exists(designPath)) File.Delete(designPath); } } /// /// Builds the multipart body BestHTTP produces: a literal form-data envelope, headers and /// all, so the controller's own parsing is what is under test. /// private static (byte[] Body, string ContentType) MultipartBody(string metadata, byte[] thumbnail, byte[] design) { const string boundary = "BestHTTP_HTTPMultiPartForm_Test"; using var stream = new MemoryStream(); void Write(string text) => stream.Write(Encoding.UTF8.GetBytes(text)); void WriteFile(string name, byte[] bytes) { Write($"--{boundary}\r\n"); Write("Content-Disposition: form-data; name=\"" + name + "\"; filename=\"file.bin\"\r\n"); Write("Content-Type: image/png\r\n\r\n"); stream.Write(bytes); Write("\r\n"); } Write($"--{boundary}\r\n"); Write("Content-Disposition: form-data; name=\"metadata\"\r\n"); Write("Content-Type: text/plain; charset=utf-8\r\n\r\n"); Write(metadata); Write("\r\n"); WriteFile("thumbnailImage", thumbnail); WriteFile("design", design); Write($"--{boundary}--\r\n"); return (stream.ToArray(), $"multipart/form-data; boundary={boundary}"); } [Fact] public void CdnSigs_ReturnNonEmptyFileFor2023FileCheck() { var api = new SigsCDN(); var result = Assert.IsType( api.GetSignature("dcfe17c0-7c51-49e8-ae74-691fc5bfc0ea")); Assert.Equal("text/plain", result.ContentType); Assert.NotEmpty(result.FileContents); var text = Encoding.ASCII.GetString(result.FileContents); Assert.Contains("RecRoom.exe:AAAAAAAAAAAAAAAAAAAAAA==:AAAAAAAAAAAAAAAAAAAAAA==", text); } [Fact] public void CdnSigs_RangeRequestsReturnPartialContentShape() { var context = new DefaultHttpContext(); context.Request.Headers.Range = "bytes=2-4"; var api = new SigsCDN { ControllerContext = new ControllerContext { HttpContext = context } }; var result = Assert.IsType( api.GetSignature("dcfe17c0-7c51-49e8-ae74-691fc5bfc0ea")); Assert.Equal(206, context.Response.StatusCode); Assert.Equal("bytes", context.Response.Headers.AcceptRanges); Assert.StartsWith("bytes 2-4/", context.Response.Headers.ContentRange.ToString()); Assert.Equal(Encoding.ASCII.GetBytes("cRo"), result.FileContents); } [Fact] public void Hile_ReturnsRawBooleanSuccessBody() { var api = new HileAPI(); var ok = Assert.IsType( api.PostHile(new RadicadeServer.RecNet.Moderation.Hile())); Assert.True(Assert.IsType(ok.Value)); } [Fact] public void GameConfigs_DisableLocal2023HashChecks() { var db = CreateDb(); db.GameConfig.Add(new ConfigVal("AndroidMobile.runHashCheck", "1")); db.GameConfig.Add(new ConfigVal("OculusQuest.enforceHashCheck", "1")); db.SaveChanges(); var api = new GameConfigAPI(db); var ok = Assert.IsType(api.GetAllGameConfigs()); var configs = Assert.IsAssignableFrom>(ok.Value); var byKey = configs.ToDictionary(x => x.Key, x => x.Value); Assert.Equal("0", byKey["AndroidMobile.runHashCheck"]); Assert.Equal("0", byKey["AndroidMobile.enforceHashCheck"]); Assert.Equal("0", byKey["Desktop_Oculus.runHashCheck"]); Assert.Equal("0", byKey["Desktop_Steam.runHashCheck"]); Assert.Equal("0", byKey["OculusQuest.enforceHashCheck"]); Assert.Equal("0", byKey["OculusQuest.runFileWatcher"]); Assert.Equal("0", byKey["Steam.runHashCheck"]); } [Theory] [InlineData(null)] [InlineData("false")] public void GameConfigs_EnableRRUIHomeScreen(string? storedValue) { var db = CreateDb(); if (storedValue != null) { db.GameConfig.Add(new ConfigVal("RRUI.UseRRUIHomeScreen", storedValue)); db.SaveChanges(); } var ok = Assert.IsType(new GameConfigAPI(db).GetAllGameConfigs()); var configs = Assert.IsAssignableFrom>(ok.Value); Assert.Equal("true", Assert.Single(configs, x => x.Key == "RRUI.UseRRUIHomeScreen").Value); // USE_NEW_HOME_SCREEN is the watch home selector the 2023 client actually honours. // Serving "false" here is what produced the old tile-grid watch, so the forced value // is "true" even when the stored DB row says otherwise. Assert.Equal("true", Assert.Single(configs, x => x.Key == "USE_NEW_HOME_SCREEN").Value); Assert.Equal("true", Assert.Single(configs, x => x.Key == "HAS_SEEN_HOME_SCREEN_CHOICE").Value); Assert.Equal("true", Assert.Single(configs, x => x.Key == "new_watch_architecture_enabled").Value); Assert.Equal("true", Assert.Single(configs, x => x.Key == "watch_layer.new_watch_architecture_enabled").Value); } [Fact] public void GameConfigs_SuppressInvalid2023SplitTestConfigs() { var db = CreateDb(); db.GameConfig.Add(new ConfigVal( "splitTestHardOverrides", "PlayMenuRRUI_2021_01_11=On")); db.GameConfig.Add(new ConfigVal( "splitTestsAvailable", "PlayMenuRRUI_2021_01_11=Off,On")); db.GameConfig.Add(new ConfigVal( "splitTestSegmentProbabilities", "{\"PlayMenuRRUI_2021_01_11\":[{\"SegmentName\":\"On\",\"Probability\":1.0}]}")); db.SaveChanges(); var api = new GameConfigAPI(db); var ok = Assert.IsType(api.GetAllGameConfigs()); var configs = Assert.IsAssignableFrom>(ok.Value); var byKey = configs.ToDictionary(x => x.Key, x => x.Value); // The 2023 client has no enum for PlayMenuRRUI_2021_01_11, so it is filtered // out of the hard overrides before serving - here that empties the list // entirely since the seeded row contains nothing else. Assert.Equal("", byKey["splitTestHardOverrides"]); // Available list and probabilities pass through from the DB. Assert.Contains("PlayMenuRRUI_2021_01_11=Off,On", byKey["splitTestsAvailable"]); Assert.Contains("PlayMenuRRUI_2021_01_11", byKey["splitTestSegmentProbabilities"]); } [Fact] public async Task Leaderboard_GetRanks_EmptyBoardReturnsSelfRow() { var factory = new TestDbContextFactory(); var response = await LeaderboardStore.GetRanksAsync(factory, new GetRanksRequest { PlayerId = 2, StatChannel = 1000, RankStart = 1, RankEnd = 10 }); Assert.Equal(1, response.Total); Assert.Equal(1, response.MyRank); var row = Assert.Single(response.Rows); Assert.Equal(2, row.PlayerId); Assert.Equal(1, row.Rank); Assert.Equal(0, row.Score); Assert.Equal(0, row.Time); } [Fact] public async Task Leaderboard_GetNearbyScores_EmptyBoardReturnsSelfRow() { var factory = new TestDbContextFactory(); var response = await LeaderboardStore.GetNearbyScoresAsync(factory, new GetNearbyScoresRequest { PlayerId = 2, StatChannel = 1000, WindowSize = 10 }); Assert.Equal(1, response.Total); Assert.Equal(1, response.MyRank); var row = Assert.Single(response.Rows); Assert.Equal(2, row.PlayerId); Assert.Equal(1, row.Rank); } [Fact] public async Task PlayersV2_ProgressionBulk_IncludesReferenceIdField() { var db = CreateDb(); var api = new PlayersV2(db); using var json = JsonDocument.Parse(Serialize(await api.GetBulkProgressionV2(new[] { 2 }))); var first = json.RootElement[0]; Assert.Equal(2, first.GetProperty("Id").GetInt64()); Assert.Equal(2, first.GetProperty("PlayerId").GetInt64()); Assert.Equal(1, first.GetProperty("Level").GetInt32()); Assert.Equal(0, first.GetProperty("XP").GetInt32()); } [Fact] public void PlayerReputationV2_Bulk_ReturnsReferenceShape() { var db = CreateDb(); var api = new ReputationAPI(db); var ok = Assert.IsType(api.GetBulkReputationV2(new long[] { 2 })); using var json = JsonDocument.Parse(Serialize(ok.Value)); var first = json.RootElement[0]; Assert.Equal(2, first.GetProperty("Id").GetInt64()); Assert.Equal(2, first.GetProperty("accountId").GetInt64()); // IsCheerful is the "may hand out cheers" gate the client reads, and the reference // server (RecFlare 2023) answers it true for every account. The badges come from // the five counters, which an uncheered account reports as 0. Assert.True(first.GetProperty("IsCheerful").GetBoolean()); Assert.Equal(0, first.GetProperty("Noteriety").GetSingle()); Assert.Equal(0, first.GetProperty("SelectedCheer").GetInt32()); Assert.Equal(20, first.GetProperty("CheerCredit").GetInt32()); Assert.Equal(0, first.GetProperty("CheerGeneral").GetInt32()); Assert.Equal(0, first.GetProperty("CheerHelpful").GetInt32()); Assert.False(first.TryGetProperty("AccountId", out _)); Assert.False(first.TryGetProperty("ReputationScore", out _)); Assert.False(first.TryGetProperty("Trust", out _)); } [Fact] public async Task PlayerSettings_PutAlias_SavesSettings() { var db = CreateDb(); db.Players.Add(new RadicadeServer.RecNet.Auth.PlayerData { AccountId = 7, Hashword = "x" }); await db.SaveChangesAsync(); var api = new PlayerSettingsAPI(db); SetUser(api, 7); api.ControllerContext.HttpContext.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes("[{\"Key\":\"MusicVolume\",\"Value\":\"0.5\"}]")); var ok = Assert.IsType(await api.PutSettings()); using var json = JsonDocument.Parse(Serialize(ok.Value)); Assert.Equal(JsonValueKind.Array, json.RootElement.ValueKind); Assert.Contains(json.RootElement.EnumerateArray(), item => item.GetProperty("Key").GetString() == "MusicVolume" && item.GetProperty("Value").GetString() == "0.5"); } [Fact] public void PlayerSettings_Defaults_UseRecRoom2023OnboardedState() { var defaults = DBConfig.DefaultSettings2023.FromJson>(); Assert.NotNull(defaults); // No AccountCreation flags here on purpose - see // PlayerSettings_GetAlias_LeavesTheOnboardingFlagsToTheAccountSeed. An account with no settings // row is one the server has never finished onboarding, and saying otherwise is what made the // client skip the new-user pages. Assert.DoesNotContain(defaults, item => item.Key.StartsWith("Recroom.AccountCreation.", StringComparison.Ordinal)); Assert.Contains(defaults, item => item.Key == "TUTORIAL_COMPLETE_MASK" && item.Value == "109"); Assert.Contains(defaults, item => item.Key == "TUTORIAL_COMPLETED_MASK" && item.Value == "109"); Assert.Contains(defaults, item => item.Key == "HAS_COMPLETED_ORIENTATION" && item.Value == "True"); } [Fact] public async Task PlayerSettings_SetV2_ReturnsReferenceEmptyArray() { var db = CreateDb(); db.Players.Add(new RadicadeServer.RecNet.Auth.PlayerData { AccountId = 9, Hashword = "x" }); await db.SaveChangesAsync(); var api = new PlayerSettingsAPI(db); SetUser(api, 9); var ok = Assert.IsType( await api.SetSetting(new Setting { Key = "MusicVolume", Value = "0.25" })); Assert.Equal("[]", Serialize(ok.Value)); } [Fact] public void PlayerSettings_GetAlias_LeavesPasswordAndFinishIncompleteWithoutAPassword() { // Missing settings must not make an account without a credential skip the password page. var db = CreateDb(); db.Players.Add(new RadicadeServer.RecNet.Auth.PlayerData { AccountId = 8, Hashword = "" }); db.SaveChanges(); var api = new PlayerSettingsAPI(db); SetUser(api, 8); var settings = api.GetSettings(); Assert.Contains(settings, item => item.Key == "Recroom.AccountCreation.HasCreatedPassword" && item.Value == "False"); Assert.Contains(settings, item => item.Key == "Recroom.AccountCreation.HasFinished" && item.Value == "False"); } [Fact] public void PlayerSettings_GetAlias_IncludesReferenceMetadataFields() { var db = CreateDb(); db.Players.Add(new RadicadeServer.RecNet.Auth.PlayerData { AccountId = 8, Hashword = "x" }); db.SaveChanges(); var api = new PlayerSettingsAPI(db); SetUser(api, 8); using var json = JsonDocument.Parse(Serialize(api.GetSettings())); var first = json.RootElement[0]; Assert.Equal(1, first.GetProperty("Id").GetInt64()); Assert.Equal(8, first.GetProperty("accountId").GetInt64()); Assert.True(first.TryGetProperty("Key", out _)); Assert.True(first.TryGetProperty("Value", out _)); } [Fact] public void PlayerSettings_GetAlias_ClearsPersistedSplitTestAssignments() { var db = CreateDb(); db.Players.Add(new RadicadeServer.RecNet.Auth.PlayerData { AccountId = 8, Hashword = "x" }); db.PlayerSettings.Add(new PlayerSettings { AccountId = 8, Settings = """ [{"Key":"SplitTestAssignedSegments","Value":"1|{\"SplitTesting+PlayMenuRRUI_2021_01_11\":\"On\"}"}] """ }); db.SaveChanges(); var api = new PlayerSettingsAPI(db); SetUser(api, 8); var settings = api.GetSettings(); Assert.Contains(settings, item => item.Key == "SplitTestAssignedSegments" && item.Value == "1|{}"); } [Fact] public async Task PlayerReporting_NoBan_ReturnsReferenceEmptyBlockDetails() { var db = CreateDb(); db.Players.Add(new RadicadeServer.RecNet.Auth.PlayerData { AccountId = 8, Hashword = "x" }); await db.SaveChangesAsync(); var api = new PlayerReportingAPI(db); SetUser(api, 8); var ok = Assert.IsType(await api.GetBlockDetails()); using var json = JsonDocument.Parse(Serialize(ok.Value)); Assert.Equal(4, json.RootElement.EnumerateObject().Count()); Assert.Equal(0, json.RootElement.GetProperty("ReportCategory").GetInt32()); Assert.Equal(0, json.RootElement.GetProperty("Duration").GetInt32()); Assert.Equal(0, json.RootElement.GetProperty("GameSessionId").GetInt64()); Assert.Equal("", json.RootElement.GetProperty("Message").GetString()); } [Fact] public void Checklist_Current_ReturnsDefaultRows() { var api = new ChecklistAPI(); var ok = Assert.IsType(api.Current()); using var doc = JsonDocument.Parse(Serialize(ok.Value)); Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind); Assert.Equal(4, doc.RootElement.GetArrayLength()); Assert.Equal(1, doc.RootElement[0].GetProperty("Order").GetInt32()); Assert.Equal(25, doc.RootElement[0].GetProperty("CreditAmount").GetInt32()); } }