Radicade Public Code

This commit is contained in:
JogmansonReal
2026-10-16 23:33:13 -07:00
commit 55175acdf6
832 changed files with 363261 additions and 0 deletions
+2196
View File
@@ -0,0 +1,2196 @@
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;
/// <summary>
/// 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.
/// </summary>
[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<RecDBContext>
{
private readonly DbContextOptions<RecDBContext> 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<long> SeedRoomAsync(RecDBContext db)
{
var room = new RoomData { Name = "Test Room" };
db.Rooms.Add(room);
await db.SaveChangesAsync();
return room.RoomId;
}
private static async Task<PlayerEvent> CreateEventAsync(EventsAPI controller, long roomId)
{
var result = await controller.CreateEvent("Conformance", null, roomId, null, null, null, null);
return Assert.IsType<PlayerEvent>(Assert.IsType<OkObjectResult>(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<HttpDeleteAttribute>()
.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<OkObjectResult>(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<List<EventResponse>>(Assert.IsType<OkObjectResult>(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<HttpGetAttribute>().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<HttpPostAttribute>().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<HttpPostAttribute>().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<HttpGetAttribute>().Select(a => a.Template);
Assert.Contains("v1/{eventId:long}/responses", responseTemplates);
var attendees = typeof(EventsAPI).GetMethod(nameof(EventsAPI.GetAttendees))!;
var attendeeTemplates = attendees.GetCustomAttributes<HttpGetAttribute>().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<HttpGetAttribute>().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<HttpGetAttribute>().Select(a => a.Template);
Assert.Contains("v1/clubs", multiTemplates);
var upcoming = typeof(EventsAPI).GetMethod(nameof(EventsAPI.GetUpcomingEvents))!;
Assert.Contains("v1/upcoming_events",
upcoming.GetCustomAttributes<HttpGetAttribute>().Select(a => a.Template));
}
[Fact]
public async Task ClubEventSingleFeed_ServesPagedEnvelope()
{
var api = CreateEventsController(CreateDb());
var result = Assert.IsType<OkObjectResult>(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<long> { 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<RouteAttribute>().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<PhysicalFileResult>(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/<name> 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<OkObjectResult>(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<OkObjectResult>(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<OkObjectResult>(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<long> 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<OkObjectResult>((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<OkObjectResult>(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<OkObjectResult>((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<BadRequestObjectResult>(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<OkObjectResult>(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<OkObjectResult>(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<OkObjectResult>(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<OkObjectResult>(result);
}
var azure = Assert.IsType<OkObjectResult>(api.GetAzureSpeechConfig());
var azureKeys = TopLevelKeys(Serialize(azure.Value));
Assert.Contains("Key", azureKeys);
Assert.Contains("Region", azureKeys);
Assert.Contains("Enabled", azureKeys);
var backtrace = Assert.IsType<OkObjectResult>(api.GetBacktraceConfig());
var backtraceKeys = TopLevelKeys(Serialize(backtrace.Value));
Assert.Contains("ReportBudget", backtraceKeys);
Assert.Contains("VersionRegex", backtraceKeys);
var amplitude = Assert.IsType<OkObjectResult>(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<RouteAttribute>()
.Select(attribute => attribute.Template);
Assert.Contains("api/playerevents", apiBase);
Assert.Equal(DBConfig.ServiceUrl("api"), DBConfig.NameServer.API);
var econBase = typeof(AvatarV1).GetCustomAttributes<RouteAttribute>()
.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<RouteAttribute>()
.Select(attribute => attribute.Template);
Assert.Contains("commerce", commerceBase);
var catalog = typeof(RadicadeServer.Commerce.CommercePlaceholderAPI)
.GetMethod("GetCatalogAll")!;
Assert.Contains("api/catalog/v1/all", catalog.GetCustomAttributes<HttpGetAttribute>()
.Select(attribute => attribute.Template));
Assert.Equal(DBConfig.ServiceUrl("commerce"), DBConfig.NameServer.Commerce);
var leaderboardRoutes = typeof(LeaderboardAPI)
.GetCustomAttributes<RouteAttribute>()
.Select(attribute => attribute.Template);
Assert.Contains("leaderboard/leaderboard", leaderboardRoutes);
var leaderboardCompatRoutes = typeof(LeaderboardCompatAPI)
.GetCustomAttributes<RouteAttribute>()
.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<RouteAttribute>().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<OkObjectResult>(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<OkObjectResult>((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<OkObjectResult>((await api.GetLocalAvatarItems()).Result);
var items = Assert.IsType<List<RadicadeServer.RecNet.Avatar.AvatarItemV4>>(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<OkObjectResult>(result.Result);
}
/// <summary>
/// The 2023 client saves outfits through <c>/econ/api/avatar/v4/saved/set</c> and expects a
/// lean <c>{ Success, Slot }</c> 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.
/// </summary>
[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<OkObjectResult>(result);
using var doc = JsonDocument.Parse(Serialize(ok.Value));
Assert.True(doc.RootElement.GetProperty("Success").GetBoolean());
Assert.Equal(1, doc.RootElement.GetProperty("Slot").GetInt32());
}
/// <summary>
/// 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.
/// </summary>
[Fact]
public async Task AvatarV4_SavedOutfitSet_IsVisibleToTheV3ListAndOverwritesItsSlot()
{
var db = CreateDb();
var v4 = new AvatarV4(db);
SetUser(v4, 77);
Assert.IsType<OkObjectResult>(await v4.SetSavedOutfit(new Outfit { Slot = 0, PreviewImageName = "first.png" }));
Assert.IsType<OkObjectResult>(await v4.SetSavedOutfit(new Outfit { Slot = 1, PreviewImageName = "second.png" }));
Assert.IsType<OkObjectResult>(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);
}
/// <summary>
/// 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.
/// </summary>
[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<BadRequestObjectResult>(result);
Assert.Empty(db.Avatars);
}
/// <summary>
/// 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.
/// </summary>
[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<OkObjectResult>(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<JsonResult>(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<T>() => typeof(T).GetCustomAttributes<RouteAttribute>().Select(a => a.Template ?? "").ToArray();
Assert.Contains("econ/api/avatar/v2", Routes<AvatarV2>());
Assert.Contains("econ/api/avatar/v4", Routes<AvatarV4>());
// The client saves outfits through v4 and reads them back through v3.
Assert.Contains("saved/set", typeof(AvatarV4)
.GetMethod(nameof(AvatarV4.SetSavedOutfit))!
.GetCustomAttributes<HttpPostAttribute>()
.Select(a => a.Template));
Assert.Contains("saved", typeof(AvatarV3)
.GetMethod(nameof(AvatarV3.GetSavedOutfits))!
.GetCustomAttributes<HttpGetAttribute>()
.Select(a => a.Template));
Assert.Contains("econ/api/objectives/v1", Routes<ObjectivesV1>());
Assert.Contains("econ/econ/customAvatarItems", Routes<CustomAvatarItemsAPI>());
Assert.Contains("econ/api/storefronts/v3", Routes<StorefrontsV3>());
Assert.Contains("cdn/sigs", Routes<SigsCDN>());
Assert.Contains("playersettings", Routes<PlayerSettingsAPI>());
var settingsRoute = typeof(PlayerSettingsAPI)
.GetMethod(nameof(PlayerSettingsAPI.GetSettings))!
.GetCustomAttributes<HttpGetAttribute>()
.Select(a => a.Template);
Assert.Contains("", settingsRoute);
Assert.Contains("playersettings", settingsRoute);
var setSettingsRoute = typeof(PlayerSettingsAPI)
.GetMethod(nameof(PlayerSettingsAPI.PutSettings))!
.GetCustomAttributes<HttpPutAttribute>()
.Select(a => a.Template);
Assert.Contains("", setSettingsRoute);
Assert.Contains("playersettings", setSettingsRoute);
var exclusiveLoginRoute = typeof(MatchmakingPlayerAPI)
.GetMethod(nameof(MatchmakingPlayerAPI.ExclusiveLogin))!
.GetCustomAttributes<HttpPostAttribute>()
.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<OkObjectResult>(
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<long> { 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<HttpGetAttribute>()
.Select(attribute => attribute.Template);
Assert.Contains("fromcreators", route);
var response = Assert.IsType<OkObjectResult>(
await api.GetInventionsFromCreators([7], skip: 0, take: 1));
var items = Assert.IsAssignableFrom<IEnumerable<Invention>>(response.Value).ToArray();
var item = Assert.Single(items);
Assert.Equal("Newer creator item", item.Name);
Assert.True(item.IsPublished);
var otherCreators = Assert.IsType<OkObjectResult>(
await api.GetInventionsFromCreators([7, 8], skip: 0, take: 10));
Assert.Equal(3, Assert.IsAssignableFrom<IEnumerable<Invention>>(otherCreators.Value).Count());
var missingCreators = Assert.IsType<BadRequestObjectResult>(
await api.GetInventionsFromCreators(null));
Assert.NotNull(missingCreators.Value);
}
[Fact]
public void DataCollection_Placeholders_ReturnSuccessShapes()
{
var api = new DataCollectionAPI();
Assert.Equal("{}", Serialize(Assert.IsType<OkObjectResult>(api.Event()).Value));
Assert.Equal("[]", Serialize(Assert.IsType<OkObjectResult>(api.Events()).Value));
Assert.Equal("{}", Serialize(Assert.IsType<OkObjectResult>(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<OkObjectResult>(api.IsCreationAllowedForAccount());
var allowedKeys = TopLevelKeys(Serialize(allowed.Value));
Assert.Contains("ALOCDGMHFKA", allowedKeys);
Assert.Contains("FBILGKFKFJE", allowedKeys);
Assert.True(Assert.IsType<bool>(allowed.Value!.GetType().GetProperty("ALOCDGMHFKA")!.GetValue(allowed.Value)));
Assert.Equal("true", Serialize(Assert.IsType<OkObjectResult>(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<OkObjectResult>(api.MinPriceForPublicItem()).Value));
Assert.True(Assert.IsType<OkObjectResult>(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<OkObjectResult>(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<OkObjectResult>(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<OkObjectResult>(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<HttpGetAttribute>()
.Select(a => a.Template)
.ToArray();
Assert.Contains("v1", routes);
Assert.Contains("v1/me", routes);
var items = Assert.IsType<OkObjectResult>(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<OkObjectResult>(api.CurrentSavesForLegacyAvatarItems());
Assert.Contains("customAvatarItemSavesByAvatarItemDesc", TopLevelKeys(Serialize(saves.Value)));
}
/// <summary>
/// The create call, exactly as the client sent it on the wire: multipart form data with a
/// <c>metadata</c> 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.
/// </summary>
[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<OkObjectResult>(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<OkObjectResult>(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<OkObjectResult>(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<OkObjectResult>(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<OkObjectResult>(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<OkObjectResult>(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<OkObjectResult>(await strangerApi.Bulk()).Value));
}
finally
{
if (File.Exists(imagePath))
File.Delete(imagePath);
if (File.Exists(designPath))
File.Delete(designPath);
}
}
/// <summary>
/// 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.
/// </summary>
[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<OkObjectResult>(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<OkObjectResult>(await lookup.Design()).Value);
Assert.Contains(newest.CustomAvatarItemId.ToString(), found);
Assert.DoesNotContain(older.CustomAvatarItemId.ToString(), found);
}
finally
{
if (File.Exists(designPath))
File.Delete(designPath);
}
}
/// <summary>
/// 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.
/// </summary>
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<FileContentResult>(
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<FileContentResult>(
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<OkObjectResult>(
api.PostHile(new RadicadeServer.RecNet.Moderation.Hile()));
Assert.True(Assert.IsType<bool>(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<OkObjectResult>(api.GetAllGameConfigs());
var configs = Assert.IsAssignableFrom<IEnumerable<ConfigVal>>(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<OkObjectResult>(new GameConfigAPI(db).GetAllGameConfigs());
var configs = Assert.IsAssignableFrom<IEnumerable<ConfigVal>>(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<OkObjectResult>(api.GetAllGameConfigs());
var configs = Assert.IsAssignableFrom<IEnumerable<ConfigVal>>(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<OkObjectResult>(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<OkObjectResult>(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<List<Setting>>();
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<OkObjectResult>(
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<OkObjectResult>(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<OkObjectResult>(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());
}
}