Files
Radicade/ServerTests/AdminRoomImportTests.cs
2026-10-16 23:33:13 -07:00

309 lines
13 KiB
C#

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;
/// <summary>
/// Importing a subroom is three files, not one. A subroom loads a <c>.room</c> save, that save
/// references its data/audio blobs by name (<c>GET /data/&lt;name&gt;.htr</c>), and its Unity scene
/// is fetched through <c>/rooms/unity_assets/&lt;name&gt;</c> 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
/// <c>.room</c>/<c>.htr</c>, so an import kept the room and lost the rest: every <c>.htr</c> 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.
/// </summary>
[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<UnauthorizedObjectResult>(await api.SetBundle("wrong", 58, "Home", bundle));
Assert.IsType<NotFoundObjectResult>(await api.SetBundle(Secret, 59, "Home", bundle));
Assert.IsType<NotFoundObjectResult>(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<BadRequestObjectResult>(await api.SetBundle(Secret, 58, "Home", "Showdown.room"));
Assert.IsType<BadRequestObjectResult>(await api.SetBundle(Secret, 58, "Home", "absent.assetbundle"));
Assert.IsType<OkObjectResult>(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<OkObjectResult>(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<OkObjectResult>(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<BadRequestObjectResult>(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<OkObjectResult>(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<FormOptions>(options => options.MultipartBodyLengthLimit = 256L * 1024 * 1024);
builder.Services.AddDbContext<RecDBContext>(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();
}
}