mirror of
https://github.com/JogmansonReal/Radicade.git
synced 2026-10-25 23:18:56 -07:00
651 lines
26 KiB
C#
651 lines
26 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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<RecDBContext>
|
|
{
|
|
private readonly DbContextOptions<RecDBContext> 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));
|
|
}
|
|
|
|
/// <summary>Puts a blob in CDN storage, the way a completed upload would.</summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
private static async Task<SubRoom> 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<long> 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"); }
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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<OkObjectResult>(await api.GetStudioUnityAsset(RoomId, subRoomId));
|
|
var asset = Assert.IsType<StudioUnityAsset>(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<NotFoundResult>(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"); }
|
|
}
|
|
|
|
/// <summary>Seeds a subroom with a live save plus two revisions behind it.</summary>
|
|
private static async Task<long> 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<string?> BlobsIn(JsonElement page) =>
|
|
page.GetProperty("Results").EnumerateArray()
|
|
.Select(x => x.GetProperty("DataBlob").GetString())
|
|
.ToList();
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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<OkObjectResult>(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<string?> { "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<OkObjectResult>(await api.GetSubRoomSaves(RoomId, subRoomId, 1, 1));
|
|
using var pagedDoc = JsonDocument.Parse(Serialize(paged.Value));
|
|
Assert.Equal(new List<string?> { "newer.bin" }, BlobsIn(pagedDoc.RootElement));
|
|
Assert.Equal(3, pagedDoc.RootElement.GetProperty("TotalResults").GetInt32());
|
|
|
|
// The lighter twin carries the same rows.
|
|
var light = Assert.IsType<OkObjectResult>(await api.GetSubRoomSavesWithoutUnityAssets(RoomId, subRoomId));
|
|
using var lightDoc = JsonDocument.Parse(Serialize(light.Value));
|
|
Assert.Equal(new List<string?> { "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<OkObjectResult>(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<StatusCodeResult>(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<OkObjectResult>(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<StatusCodeResult>(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);
|
|
}
|
|
}
|