using System.Net; using System.Text.Json; using System.Text.Json.Nodes; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using RadicadeServer.Admin; using RadicadeServer.DB; using RadicadeServer.RecNet.Rooms; using RadicadeServer.Utils; using Xunit; namespace RadicadeServer.Tests; /// /// Importing a subroom is three files, not one. A subroom loads a .room save, that save /// references its data/audio blobs by name (GET /data/<name>.htr), and its Unity scene /// is fetched through /rooms/unity_assets/<name> by the name the bundle is stored as. /// /// The admin upload route used to rename whatever it was handed to a fresh GUID and only accept /// .room/.htr, so an import kept the room and lost the rest: every .htr and the /// bundle answered 404 under the name the save pointed at. These pin the three names travelling /// intact, the bundle binding, and the refusal of anything that is not one of the three. /// [Collection(DbFactoryCollection.Name)] public class AdminRoomImportTests { private const string Secret = "room-import-test-key"; private static RecDBContext NewDb() => new( TestDbOptions.Create() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options); /// A CDN directory of its own, since every one of these routes writes into the storage root. private sealed class Storage : IDisposable { private readonly string _previous = DBConfig.CDNStorage; public Storage() { Root = Directory.CreateTempSubdirectory("radicade-room-import-").FullName; DBConfig.CDNStorage = Root; } public string Root { get; } public string Write(string name, string contents = "blob") { Directory.CreateDirectory(Root); File.WriteAllText(Path.Join(Root, name), contents); return name; } public void Dispose() { DBConfig.CDNStorage = _previous; try { Directory.Delete(Root, recursive: true); } catch (IOException) { } } } private sealed class AdminKey : IDisposable { private readonly string _previous = DBConfig.AdminApiKey; public AdminKey() => DBConfig.AdminApiKey = Secret; public void Dispose() => DBConfig.AdminApiKey = _previous; } // ------------------------------------------------------------------ names [Theory] [InlineData("Showdown_0_1.assetbundle")] [InlineData("8aas7b5ivtaggn9swhe3wkgdj.assetbundle")] [InlineData(" Showdown_0_1.assetbundle ")] public void AnUploadedBundleNameIsAccepted(string requested) { using var storage = new Storage(); storage.Write("Showdown_0_1.assetbundle"); storage.Write("8aas7b5ivtaggn9swhe3wkgdj.assetbundle"); Assert.True(AdminUtils.TryResolveBundleName(requested, out var name, out var error)); Assert.Null(error); Assert.Equal(requested.Trim(), name); } [Theory] [InlineData(null)] [InlineData("")] [InlineData(" ")] [InlineData("Showdown_0_1.room")] [InlineData("Showdown_0_1")] [InlineData("Showdown_0_1.assetbundle.bak")] [InlineData("../Showdown_0_1.assetbundle")] [InlineData("sub/Showdown_0_1.assetbundle")] [InlineData("Home bundle.assetbundle")] [InlineData("missing.assetbundle")] public void AnythingThatIsNotAnUploadedBundleIsRefused(string? requested) { using var storage = new Storage(); storage.Write("Showdown_0_1.assetbundle"); Assert.False(AdminUtils.TryResolveBundleName(requested, out var name, out var error)); Assert.Null(name); Assert.NotNull(error); } [Fact] public void BundleNamesAndRoomSaveNamesAreNotInterchangeable() { using var storage = new Storage(); storage.Write("Showdown.room"); storage.Write("Showdown_0_1.assetbundle"); // A .room is a save blob, never a subroom's Unity bundle. Assert.False(AdminUtils.TryResolveBundleName("Showdown.room", out _, out _)); Assert.True(AdminUtils.TryResolveUploadName("Showdown.room", out var saveName, out _)); Assert.Equal("Showdown.room", saveName); } // ----------------------------------------------------------------- binding [Fact] public async Task BindingPointsTheSubroomAtTheBundleAndClearingTakesItBackOff() { using var storage = new Storage(); using var key = new AdminKey(); var bundle = storage.Write("Showdown_0_1.assetbundle"); storage.Write("Showdown.room"); await using var db = NewDb(); db.SubRooms.Add(new SubRoom { RoomId = 58, SubRoomId = 61, Name = "Home", DataBlob = "Showdown.room" }); await db.SaveChangesAsync(); var api = new AdminRoomAPI(db); Assert.IsType(await api.SetBundle("wrong", 58, "Home", bundle)); Assert.IsType(await api.SetBundle(Secret, 59, "Home", bundle)); Assert.IsType(await api.SetBundle(Secret, 58, "Basement", bundle)); // A blob that is not a bundle, and a bundle that was never uploaded, are both refused // rather than bound to a name the client would then fail to fetch. Assert.IsType(await api.SetBundle(Secret, 58, "Home", "Showdown.room")); Assert.IsType(await api.SetBundle(Secret, 58, "Home", "absent.assetbundle")); Assert.IsType(await api.SetBundle(Secret, 58, "Home", bundle)); db.ChangeTracker.Clear(); var stored = await db.SubRooms.SingleAsync(); Assert.Equal(bundle, stored.UnityAssetId); Assert.Equal("Showdown.room", stored.DataBlob); Assert.IsType(await api.SetBundle(Secret, 58, "Home", "")); db.ChangeTracker.Clear(); Assert.Equal("", (await db.SubRooms.SingleAsync()).UnityAssetId); } [Fact] public async Task CreatingASubroomTakesTheBundleThatWasImportedWithIt() { using var storage = new Storage(); using var key = new AdminKey(); storage.Write("Showdown_0_1.assetbundle"); await using var db = NewDb(); db.Rooms.Add(new RoomData { RoomId = 58, Name = "Showdown", CreatorAccountId = 1, MaxPlayers = 6 }); await db.SaveChangesAsync(); var api = new AdminRoomAPI(db); var created = await api.CreateSubRoom(Secret, 58, JsonNode.Parse(""" { "Name": "Home", "Accessibility": 1, "MaxPlayers": 6, "UnitySceneId": "a75f7547-79eb-47c6-8986-6767abcb4f92", "DataBlob": "Showdown.room", "UnityAssetId": "Showdown_0_1.assetbundle" } """)!); Assert.IsType(created); db.ChangeTracker.Clear(); var subRoom = await db.SubRooms.SingleAsync(); Assert.Equal("Showdown.room", subRoom.DataBlob); Assert.Equal("Showdown_0_1.assetbundle", subRoom.UnityAssetId); // A bundle that is not on disk fails the create instead of leaving a subroom pointing at // nothing. var failed = await api.CreateSubRoom(Secret, 58, JsonNode.Parse(""" { "Name": "Basement", "DataBlob": "basement.room", "UnityAssetId": "absent.assetbundle" } """)!); Assert.IsType(failed); db.ChangeTracker.Clear(); Assert.Single(await db.SubRooms.ToListAsync()); } [Fact] public async Task CreatingARoomTakesTheBundleForItsEntrySubroom() { using var storage = new Storage(); using var key = new AdminKey(); storage.Write("Showdown_0_1.assetbundle"); await using var db = NewDb(); var api = new AdminRoomAPI(db); var created = await api.CreateRoom(Secret, 2, JsonNode.Parse(""" { "Name": "Showdown", "Accessibility": 1, "MaxPlayers": 6, "SubRooms": [ { "Name": "Home", "DataBlob": "Showdown.room", "UnityAssetId": "Showdown_0_1.assetbundle" } ] } """)!); Assert.IsType(created); var subRoom = await db.SubRooms.SingleAsync(); Assert.Equal("Showdown.room", subRoom.DataBlob); Assert.Equal("Showdown_0_1.assetbundle", subRoom.UnityAssetId); } // ----------------------------------------------------------------- uploads /// The route itself, over real Kestrel and MVC model binding: whatever the admin sends is what /// the CDN ends up holding, one file at a time, under the name it was sent with. [Fact] public async Task EachRoomFileLandsInTheCdnUnderTheNameItWasUploadedAs() { using var storage = new Storage(); using var key = new AdminKey(); var builder = WebApplication.CreateBuilder(); builder.WebHost.UseKestrel().UseUrls("http://127.0.0.1:0"); builder.Logging.ClearProviders(); builder.Services.AddControllers().AddApplicationPart(typeof(AdminPanelAPI).Assembly); builder.Services.AddCors(options => options.AddPolicy("AdminPanel", policy => policy.AllowAnyOrigin())); builder.Services.Configure(options => options.MultipartBodyLengthLimit = 256L * 1024 * 1024); builder.Services.AddDbContext(options => options.UseInMemoryDatabase(Guid.NewGuid().ToString())); await using var app = builder.Build(); app.UseCors(); app.MapControllers(); await app.StartAsync(); using var client = new HttpClient { BaseAddress = new Uri(app.Urls.Single()) }; async Task<(HttpStatusCode Status, JsonElement Body)> PostAsync(string fileName, byte[] bytes, string? name = null) { using var form = new MultipartFormDataContent(); form.Add(new ByteArrayContent(bytes), "file", fileName); var query = $"/admin/panel/upload/room?key={Secret}" + (name == null ? "" : $"&name={Uri.EscapeDataString(name)}"); using var response = await client.PostAsync(query, form); var body = await response.Content.ReadAsStringAsync(); var parsed = body.StartsWith('{') ? JsonDocument.Parse(body).RootElement : default; return (response.StatusCode, parsed); } var (roomStatus, roomBody) = await PostAsync("Showdown.room", new byte[] { 1, 2, 3 }); Assert.Equal(HttpStatusCode.OK, roomStatus); Assert.Equal("room", roomBody.GetProperty("kind").GetString()); Assert.Equal("Showdown.room", roomBody.GetProperty("name").GetString()); Assert.Equal(new byte[] { 1, 2, 3 }, await File.ReadAllBytesAsync(Path.Join(storage.Root, "Showdown.room"))); var htr = "315cb6bf6af14c0b83bfe2fdc8ef0c79.htr"; var (htrStatus, htrBody) = await PostAsync(htr, new byte[] { 4, 5 }); Assert.Equal(HttpStatusCode.OK, htrStatus); Assert.Equal("htr", htrBody.GetProperty("kind").GetString()); Assert.True(File.Exists(Path.Join(storage.Root, htr))); var (bundleStatus, bundleBody) = await PostAsync("Showdown_0_1.assetbundle", new byte[] { 6 }); Assert.Equal(HttpStatusCode.OK, bundleStatus); Assert.Equal("assetbundle", bundleBody.GetProperty("kind").GetString()); Assert.True(File.Exists(Path.Join(storage.Root, "Showdown_0_1.assetbundle"))); // An explicit ?name= still wins, which is how the Data Import page stores a renamed file. var (renamedStatus, _) = await PostAsync("whatever.bin", new byte[] { 7 }, name: "renamed.room"); Assert.Equal(HttpStatusCode.OK, renamedStatus); Assert.True(File.Exists(Path.Join(storage.Root, "renamed.room"))); var (refusedStatus, _) = await PostAsync("archive.zip", new byte[] { 8 }); Assert.Equal(HttpStatusCode.BadRequest, refusedStatus); Assert.False(File.Exists(Path.Join(storage.Root, "archive.zip"))); // A name the CDN could never be asked for is refused rather than quietly renamed. var (oddNameStatus, _) = await PostAsync("spot the odd name.room", new byte[] { 9 }); Assert.Equal(HttpStatusCode.BadRequest, oddNameStatus); Assert.Equal( new[] { "renamed.room", "Showdown.room" }.OrderBy(x => x), Directory.GetFiles(storage.Root, "*.room").Select(Path.GetFileName).OrderBy(x => x)); // The uploaded bundle is now something the panel can list and bind. using var uploads = await client.GetAsync($"/admin/panel/uploads?key={Secret}"); var uploadsBody = await uploads.Content.ReadAsStringAsync(); Assert.Contains("Showdown_0_1.assetbundle", uploadsBody); Assert.Contains("assetbundles", uploadsBody); await app.StopAsync(); } }