using System.Security.Claims; using System.Security.Cryptography; using System.Text; using System.Text.Json; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Storage; using RadicadeServer; using RadicadeServer.DB; using RadicadeServer.API.Studio; using RadicadeServer.RecNet.Accounts; using RadicadeServer.RecNet.Enums; using RadicadeServer.RecNet.Enums.Rooms; using RadicadeServer.RecNet.Rooms; using RadicadeServer.RoomServer; using RadicadeServer.Utils; using Xunit; namespace RadicadeServer.Tests; /// /// Room saves are the one path where losing a request loses a creator's work, and the /// failure is silent: the client reports a successful save to the player, and the room /// reopens with nothing in it. These pin the two things that caused exactly that - the /// save body not binding, and a save with no uploaded blob being recorded anyway - plus /// the history fallback that keeps a room loadable when one blob goes missing. /// [Collection(DbFactoryCollection.Name)] public class RoomSaveTests { private const long CreatorId = 7; private const long RoomId = 1; private static readonly JsonSerializerOptions ServerJson = new() { PropertyNamingPolicy = null, WriteIndented = true, }; private static string Serialize(object? value) => JsonSerializer.Serialize(value, ServerJson); private sealed class TestDbContextFactory : IDbContextFactory { private readonly DbContextOptions options; public TestDbContextFactory() { // A unique database name isolates each test; the store is NOT given its own // InMemoryDatabaseRoot, because a distinct root makes Entity Framework build a // distinct internal service provider every time and it throws once the process // has more than twenty of them. options = TestDbOptions.Create() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options; } public RecDBContext CreateDbContext() => new(options); } private static void SetUser(Controller controller, long accountId) { var identity = new ClaimsIdentity("TestAuth"); identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, accountId.ToString())); controller.ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(identity) } }; } private static void SetJsonBody(Controller controller, string json) { var request = controller.ControllerContext.HttpContext.Request; request.ContentType = "application/json"; request.Body = new MemoryStream(Encoding.UTF8.GetBytes(json)); } private static void SetFormBody(Controller controller, string body) { var request = controller.ControllerContext.HttpContext.Request; request.ContentType = "application/x-www-form-urlencoded"; request.Body = new MemoryStream(Encoding.UTF8.GetBytes(body)); } /// Puts a blob in CDN storage, the way a completed upload would. private static string WriteBlob(string name) { Directory.CreateDirectory(DBConfig.CDNStorage); var path = Path.Join(DBConfig.CDNStorage, name); File.WriteAllBytes(path, new byte[] { 1, 2, 3 }); return path; } private static void DeleteBlob(string name) { var path = Path.Join(DBConfig.CDNStorage, name); if (File.Exists(path)) File.Delete(path); } /// /// Reads a value back from the store through a NEW context. A context that has already /// materialized an entity keeps serving its own copy, so reusing the seeding context /// would report pre-repair values even after the repair was persisted. /// private static async Task ReadPersistedSubRoomAsync(TestDbContextFactory factory, long subRoomId) { await using var db = factory.CreateDbContext(); return await db.SubRooms.AsNoTracking().FirstAsync(x => x.SubRoomId == subRoomId); } private static async Task SeedRoomAsync(RecDBContext db, long creatorId = CreatorId, string dataBlob = "") { db.Rooms.Add(new RoomData { RoomId = RoomId, Name = "Build Room", CreatorAccountId = creatorId, Accessibility = Accessibility.Public, MaxPlayers = 8, CreatedAt = DateTime.UtcNow, }); var subRoom = new SubRoom { RoomId = RoomId, Name = "Build Room", UnitySceneId = "BuildRoomScene", MaxPlayers = 8, Accessibility = Accessibility.Public, DataBlob = dataBlob, }; db.SubRooms.Add(subRoom); await db.SaveChangesAsync(); return subRoom.SubRoomId; } private static (TestDbContextFactory Factory, RecDBContext Db) CreateContext() { var factory = new TestDbContextFactory(); RoomUtils.DbFactory = factory; MatchmakingUtils.DbFactory = factory; return (factory, factory.CreateDbContext()); } [Fact] public async Task SaveAcceptsTheJsonEnvelopeThe2023ClientPosts() { var (factory, db) = CreateContext(); await using var _db = db; var subRoomId = await SeedRoomAsync(db); var api = new RoomServerAPI(db); SetUser(api, CreatorId); SetJsonBody(api, """ { "RoomData": { "Filename": "meta-1.bin" }, "SubRoomData": { "Filename": "scene-1.bin", "Hash": null }, "UnityAssetId": null, "PersistenceVersion": 4, "InventionUsage": "", "AutoPublish": true } """); WriteBlob("scene-1.bin"); try { var result = await api.UploadRoomData(RoomId, subRoomId); using var doc = JsonDocument.Parse(Serialize(result)); Assert.True(doc.RootElement.GetProperty("success").GetBoolean()); // Studio's Result.EEKDNHEOFFD requires Success == (Error == null), so unlike the // shared RecNet envelope this save endpoint must return a null error on success. Assert.Equal(JsonValueKind.Null, doc.RootElement.GetProperty("error").ValueKind); // The loader downloads the room's `CurrentSave.DataBlob`; a save that records // anything else opens empty. var saved = doc.RootElement.GetProperty("value").GetProperty("room").GetProperty("SubRooms")[0]; Assert.Equal("scene-1.bin", saved.GetProperty("CurrentSave").GetProperty("DataBlob").GetString()); // The save the response hands back is the client's OWN projection: camelCase, and // a different field set from that CurrentSave. Serving the room's save here leaves // every field the client reads back null, and its save handling NREs on them. var save = doc.RootElement.GetProperty("value").GetProperty("subRoomDataSave"); Assert.Equal("scene-1.bin", save.GetProperty("dataBlob").GetString()); Assert.Equal(subRoomId, save.GetProperty("subRoomId").GetInt64()); Assert.Equal(save.GetProperty("subRoomDataSaveId").GetInt64(), saved.GetProperty("CurrentSave").GetProperty("SubRoomDataSaveId").GetInt64()); Assert.Equal(JsonValueKind.Null, save.GetProperty("dataBlobHash").ValueKind); Assert.Equal(JsonValueKind.Null, save.GetProperty("unityAsset").ValueKind); Assert.False(save.TryGetProperty("DataBlob", out _)); // A subroom the client is shown has to name a creator: it NREs on a null one, so // the account that saved it is reported from here on. Assert.Equal(CreatorId, saved.GetProperty("CreatorAccountId").GetInt64()); // ...and both room spellings are present for the client's saver to read back. Assert.Equal(RoomId, doc.RootElement.GetProperty("value").GetProperty("room").GetProperty("RoomId").GetInt64()); Assert.Equal(RoomId, doc.RootElement.GetProperty("value").GetProperty("RoomId").GetInt64()); Assert.Equal("scene-1.bin", (await ReadPersistedSubRoomAsync(factory, subRoomId)).DataBlob); await using var verify = factory.CreateDbContext(); Assert.True(await verify.SubRoomHistory.AnyAsync(x => x.SubRoomId == subRoomId)); } finally { DeleteBlob("scene-1.bin"); } } [Fact] public async Task SaveResponseEchoesTheBlobHashItWasGiven() { var (factory, db) = CreateContext(); await using var _db = db; var subRoomId = await SeedRoomAsync(db); var api = new RoomServerAPI(db); SetUser(api, CreatorId); SetJsonBody(api, """ { "SubRoomData": { "Filename": "hashed.bin", "Hash": "9f2c1a" }, "RoomData": { "Filename": "meta-2.bin" }, "AutoPublish": true } """); WriteBlob("hashed.bin"); try { var result = await api.UploadRoomData(RoomId, subRoomId); using var doc = JsonDocument.Parse(Serialize(result)); // The hash goes out on the save the response names, not on the room: it is the // only digest either side has of the scene blob that save points at. var save = doc.RootElement.GetProperty("value").GetProperty("subRoomDataSave"); Assert.Equal("hashed.bin", save.GetProperty("dataBlob").GetString()); Assert.Equal("9f2c1a", save.GetProperty("dataBlobHash").GetString()); } finally { DeleteBlob("hashed.bin"); } } /// /// A subroom that has never been saved has no creator and says so, rather than leaving the /// key out: the client reads a missing key and a null the same way. /// [Fact] public async Task AnUnsavedSubRoomReportsNoCreator() { var (factory, db) = CreateContext(); await using var _db = db; var subRoomId = await SeedRoomAsync(db); var room = await RoomUtils.GetRoom(RoomId); Assert.NotNull(room); var subRoom = room!.SubRooms.Single(x => x.SubRoomId == subRoomId); Assert.Null(subRoom.CreatorAccountId); } [Fact] public async Task SaveAcceptsTheLegacyFormFields() { var (factory, db) = CreateContext(); await using var _db = db; var subRoomId = await SeedRoomAsync(db); var api = new RoomServerAPI(db); SetUser(api, CreatorId); SetFormBody(api, "filename=legacy.bin&inventionUsage=&savedByAccountId=999"); WriteBlob("legacy.bin"); try { var result = await api.UploadRoomData(RoomId, subRoomId); using var doc = JsonDocument.Parse(Serialize(result)); Assert.True(doc.RootElement.GetProperty("success").GetBoolean()); Assert.Equal("legacy.bin", (await ReadPersistedSubRoomAsync(factory, subRoomId)).DataBlob); } finally { DeleteBlob("legacy.bin"); } } [Fact] public async Task SaveTakesTheSaverFromTheTokenNotTheBodyOrAPresenceRow() { var (factory, db) = CreateContext(); await using var _db = db; var subRoomId = await SeedRoomAsync(db); // No presence row at all: editing a room does not require one, and the old required // `savedByAccountId` presence lookup turned an otherwise valid save into "Invalid auth". var api = new RoomServerAPI(db); SetUser(api, CreatorId); SetJsonBody(api, """{ "SubRoomData": { "Filename": "scene-2.bin" }, "SavedByAccountId": 999 }"""); WriteBlob("scene-2.bin"); try { var result = await api.UploadRoomData(RoomId, subRoomId); using var doc = JsonDocument.Parse(Serialize(result)); Assert.True(doc.RootElement.GetProperty("success").GetBoolean()); Assert.Equal(CreatorId, (await ReadPersistedSubRoomAsync(factory, subRoomId)).SavedByAccountId); } finally { DeleteBlob("scene-2.bin"); } } [Fact] public async Task StudioSourceManifestRouteReturnsHashAndHidesPrivateRoomSource() { var previousStorage = DBConfig.CDNStorage; var isolatedStorage = Path.Combine(Path.GetTempPath(), "studio-source-route-" + Guid.NewGuid()); DBConfig.CDNStorage = isolatedStorage; Directory.CreateDirectory(isolatedStorage); var (_, db) = CreateContext(); await using var _db = db; var subRoomId = await SeedRoomAsync(db); const string manifestId = "0123456789abcdef0123456789abcdef01234567"; var manifestBytes = Encoding.UTF8.GetBytes("{\"Version\":2,\"Files\":[]}"); await File.WriteAllBytesAsync(Path.Combine(isolatedStorage, manifestId), manifestBytes); var subRoom = await db.SubRooms.SingleAsync(x => x.SubRoomId == subRoomId); subRoom.UnityAssetId = manifestId; await db.SaveChangesAsync(); try { var api = new RoomServerAPI(db); SetUser(api, CreatorId); var result = Assert.IsType(await api.GetStudioUnityAsset(RoomId, subRoomId)); var asset = Assert.IsType(result.Value); Assert.Equal(manifestId, asset.UnityAssetId); Assert.Equal(manifestId, asset.Filename); Assert.Equal(Convert.ToBase64String(SHA256.HashData(manifestBytes)), asset.Hash); Assert.Empty(asset.BakedUnityAssets); var room = await db.Rooms.SingleAsync(x => x.RoomId == RoomId); room.Accessibility = Accessibility.Private; await db.SaveChangesAsync(); SetUser(api, 42); Assert.IsType(await api.GetStudioUnityAsset(RoomId, subRoomId)); } finally { DBConfig.CDNStorage = previousStorage; if (Directory.Exists(isolatedStorage)) Directory.Delete(isolatedStorage, true); } } [Fact] public async Task SaveWithAMissingStudioManifestIsRejectedWithoutMutatingCurrentSaveOrHistory() { var previousStorage = DBConfig.CDNStorage; var isolatedStorage = Path.Combine(Path.GetTempPath(), "studio-manifest-test-" + Guid.NewGuid()); DBConfig.CDNStorage = isolatedStorage; Directory.CreateDirectory(isolatedStorage); var (factory, db) = CreateContext(); await using var _db = db; var subRoomId = await SeedRoomAsync(db, dataBlob: "keep-manifest.bin"); var api = new RoomServerAPI(db); SetUser(api, CreatorId); SetJsonBody(api, """{ "SubRoomData": { "Filename": "next-manifest.bin" }, "UnityAsset": { "Filename": "0000000000000000000000000000000000000000" } }"""); WriteBlob("keep-manifest.bin"); WriteBlob("next-manifest.bin"); try { var result = await api.UploadRoomData(RoomId, subRoomId); using var doc = JsonDocument.Parse(Serialize(result)); Assert.False(doc.RootElement.GetProperty("success").GetBoolean()); Assert.Equal("Unity source manifest has not been uploaded.", doc.RootElement.GetProperty("error").GetString()); Assert.Equal("keep-manifest.bin", (await ReadPersistedSubRoomAsync(factory, subRoomId)).DataBlob); Assert.Equal(string.Empty, (await ReadPersistedSubRoomAsync(factory, subRoomId)).UnityAssetId); await using var verify = factory.CreateDbContext(); Assert.False(await verify.SubRoomHistory.AnyAsync(x => x.SubRoomId == subRoomId)); } finally { DBConfig.CDNStorage = previousStorage; if (Directory.Exists(isolatedStorage)) Directory.Delete(isolatedStorage, true); } } [Fact] public async Task SaveWithoutABlobNameIsRejectedAndLeavesTheCurrentSave() { var (factory, db) = CreateContext(); await using var _db = db; var subRoomId = await SeedRoomAsync(db, dataBlob: "keep.bin"); var api = new RoomServerAPI(db); SetUser(api, CreatorId); SetJsonBody(api, """{ "AutoPublish": true }"""); WriteBlob("keep.bin"); try { var result = await api.UploadRoomData(RoomId, subRoomId); using var doc = JsonDocument.Parse(Serialize(result)); Assert.False(doc.RootElement.GetProperty("success").GetBoolean()); Assert.Equal("keep.bin", (await ReadPersistedSubRoomAsync(factory, subRoomId)).DataBlob); await using var verify = factory.CreateDbContext(); Assert.False(await verify.SubRoomHistory.AnyAsync(x => x.SubRoomId == subRoomId)); } finally { DeleteBlob("keep.bin"); } } [Fact] public async Task SaveRequiresCoOwnerRights() { var (factory, db) = CreateContext(); await using var _db = db; var subRoomId = await SeedRoomAsync(db, creatorId: 999, dataBlob: "keep.bin"); var api = new RoomServerAPI(db); SetUser(api, CreatorId); SetJsonBody(api, """{ "SubRoomData": { "Filename": "scene-3.bin" } }"""); WriteBlob("scene-3.bin"); try { var result = await api.UploadRoomData(RoomId, subRoomId); using var doc = JsonDocument.Parse(Serialize(result)); Assert.False(doc.RootElement.GetProperty("success").GetBoolean()); Assert.Equal("keep.bin", (await ReadPersistedSubRoomAsync(factory, subRoomId)).DataBlob); } finally { DeleteBlob("scene-3.bin"); } } [Fact] public async Task RoomLoadFallsBackToTheNewestSaveStillOnDisk() { var (factory, db) = CreateContext(); await using var _db = db; var subRoomId = await SeedRoomAsync(db, dataBlob: "gone.bin"); var newer = new RestorableSubRoom { RoomId = RoomId, SubRoomId = subRoomId, DataBlob = "also-gone.bin", SavedByAccountId = CreatorId, CreatedAt = DateTime.UtcNow, }; var older = new RestorableSubRoom { RoomId = RoomId, SubRoomId = subRoomId, DataBlob = "old-1.bin", SavedByAccountId = CreatorId, CreatedAt = DateTime.UtcNow.AddHours(-1), }; db.SubRoomHistory.AddRange(newer, older); await db.SaveChangesAsync(); WriteBlob("old-1.bin"); try { var room = await RoomUtils.GetRoom(RoomId); Assert.NotNull(room); Assert.Equal("old-1.bin", room!.SubRooms.Single(x => x.SubRoomId == subRoomId).DataBlob); // The repair is persisted: it is a recovery, not a per-read patch-up. Assert.Equal("old-1.bin", (await ReadPersistedSubRoomAsync(factory, subRoomId)).DataBlob); } finally { DeleteBlob("old-1.bin"); } } /// Seeds a subroom with a live save plus two revisions behind it. private static async Task SeedSaveHistoryAsync(RecDBContext db) { var subRoomId = await SeedRoomAsync(db, dataBlob: "live.bin"); db.SubRoomHistory.AddRange( new RestorableSubRoom { RoomId = RoomId, SubRoomId = subRoomId, DataBlob = "newer.bin", SavedByAccountId = CreatorId, CreatedAt = DateTime.UtcNow.AddMinutes(-5), }, new RestorableSubRoom { RoomId = RoomId, SubRoomId = subRoomId, DataBlob = "older.bin", SavedByAccountId = CreatorId, CreatedAt = DateTime.UtcNow.AddHours(-2), }); await db.SaveChangesAsync(); return subRoomId; } private static List BlobsIn(JsonElement page) => page.GetProperty("Results").EnumerateArray() .Select(x => x.GetProperty("DataBlob").GetString()) .ToList(); /// /// The save list the client asks for on entering a room. A 404 here is the whole history /// screen broken, and any revision missing from it is a save nobody can restore. /// [Fact] public async Task SavesPageListsTheLiveRevisionAndItsHistoryNewestFirst() { var (factory, db) = CreateContext(); await using var _db = db; var subRoomId = await SeedSaveHistoryAsync(db); var api = new RoomServerAPI(db); SetUser(api, CreatorId); var ok = Assert.IsType(await api.GetSubRoomSaves(RoomId, subRoomId)); using var doc = JsonDocument.Parse(Serialize(ok.Value)); // The live save is what the room is running, so it leads; the revisions follow, // newest first. Assert.Equal(new List { "live.bin", "newer.bin", "older.bin" }, BlobsIn(doc.RootElement)); // Both spellings of the total, because the client's paged DTO and the reference // disagree on the name - and it counts the history, not the page. Assert.Equal(3, doc.RootElement.GetProperty("TotalResults").GetInt32()); Assert.Equal(3, doc.RootElement.GetProperty("TotalCount").GetInt32()); // Ids are what the client keys the list by: the live save is 1 (the id the room // payload's CurrentSave already carries) and every revision is distinct. var ids = doc.RootElement.GetProperty("Results").EnumerateArray() .Select(x => x.GetProperty("SubRoomDataSaveId").GetInt64()).ToList(); Assert.Equal(3, ids.Distinct().Count()); Assert.Equal(1, ids[0]); Assert.All(ids, id => Assert.True(id >= 1)); // A page is a window onto the history, not the whole of it. var paged = Assert.IsType(await api.GetSubRoomSaves(RoomId, subRoomId, 1, 1)); using var pagedDoc = JsonDocument.Parse(Serialize(paged.Value)); Assert.Equal(new List { "newer.bin" }, BlobsIn(pagedDoc.RootElement)); Assert.Equal(3, pagedDoc.RootElement.GetProperty("TotalResults").GetInt32()); // The lighter twin carries the same rows. var light = Assert.IsType(await api.GetSubRoomSavesWithoutUnityAssets(RoomId, subRoomId)); using var lightDoc = JsonDocument.Parse(Serialize(light.Value)); Assert.Equal(new List { "live.bin", "newer.bin", "older.bin" }, BlobsIn(lightDoc.RootElement)); Assert.False(lightDoc.RootElement.GetProperty("Results")[0].TryGetProperty("UnitySubAssets", out _)); } [Fact] public async Task SavesPageIsEmptyRatherThanMissingForAnUnknownSubroom() { var (factory, db) = CreateContext(); await using var _db = db; await SeedRoomAsync(db); var api = new RoomServerAPI(db); SetUser(api, CreatorId); // An unknown id answers an empty page, so a client holding a stale one still renders. var ok = Assert.IsType(await api.GetSubRoomSaves(RoomId, 9999)); using var doc = JsonDocument.Parse(Serialize(ok.Value)); Assert.Empty(doc.RootElement.GetProperty("Results").EnumerateArray()); Assert.Equal(0, doc.RootElement.GetProperty("TotalResults").GetInt32()); } [Fact] public async Task SavesAreReadableByAStrangerOnlyWhileTheyAreInTheRoom() { const long visitorId = 42; var (factory, db) = CreateContext(); await using var _db = db; var subRoomId = await SeedSaveHistoryAsync(db); var api = new RoomServerAPI(db); SetUser(api, visitorId); // Not the creator and holding no role: the list exposes unpublished work, so from // outside the room it is a 403. var refused = Assert.IsType(await api.GetSubRoomSaves(RoomId, subRoomId)); Assert.Equal(StatusCodes.Status403Forbidden, refused.StatusCode); // Standing in the room is its own grant: the client resolves which version to load // from this list, so a visitor who cannot read it cannot load the room. var instance = new RoomInstance { roomId = RoomId, subRoomId = subRoomId }; db.RoomInstances.Add(instance); await db.SaveChangesAsync(); db.Presences.Add(new RealPresence { playerId = visitorId, isOnline = true, roomInstance = instance.roomInstanceId }); await db.SaveChangesAsync(); var ok = Assert.IsType(await api.GetSubRoomSaves(RoomId, subRoomId)); using var doc = JsonDocument.Parse(Serialize(ok.Value)); Assert.Equal(3, doc.RootElement.GetProperty("TotalResults").GetInt32()); // A visitor in a DIFFERENT room is not in this one. instance.roomId = RoomId + 1; await db.SaveChangesAsync(); var elsewhere = Assert.IsType(await api.GetSubRoomSaves(RoomId, subRoomId)); Assert.Equal(StatusCodes.Status403Forbidden, elsewhere.StatusCode); } [Fact] public async Task RestoreKeepsTheRevisionItReplaced() { var (factory, db) = CreateContext(); await using var _db = db; var subRoomId = await SeedRoomAsync(db, dataBlob: "current.bin"); var api = new RoomServerAPI(db); SetUser(api, CreatorId); SetJsonBody(api, """{ "Filename": "older.bin" }"""); WriteBlob("older.bin"); try { var result = await api.RestoreRoomData(RoomId, subRoomId); using var doc = JsonDocument.Parse(Serialize(result)); Assert.True(doc.RootElement.GetProperty("success").GetBoolean()); // Restoring is the one path that can drop a save outright: the revision it // replaced has to be in the history afterwards, or it is gone. await using var verify = factory.CreateDbContext(); var history = await verify.SubRoomHistory.Where(x => x.SubRoomId == subRoomId).ToListAsync(); Assert.Contains(history, x => x.DataBlob == "current.bin"); Assert.Equal("older.bin", (await ReadPersistedSubRoomAsync(factory, subRoomId)).DataBlob); } finally { DeleteBlob("older.bin"); } } [Fact] public async Task RoomLoadClearsTheSaveOnlyWhenNothingIsRecoverable() { var (factory, db) = CreateContext(); await using var _db = db; var subRoomId = await SeedRoomAsync(db, dataBlob: "gone.bin"); var room = await RoomUtils.GetRoom(RoomId); Assert.NotNull(room); Assert.Equal(string.Empty, room!.SubRooms.Single(x => x.SubRoomId == subRoomId).DataBlob); } }