using System.Text.Json; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using RadicadeServer; using RadicadeServer.API.Studio; using RadicadeServer.CDN; using RadicadeServer.DB; using Xunit; namespace RadicadeServer.Tests; /// /// Step one of the client's bundle download is /// GET /rooms/unity_assets/{assetId}/{target}/{version} for a file name, step two is /// /data/{Filename} for the bytes. An id arrives in two shapes and the room load needs /// the one the subroom carries: /// /// - a Studio manifest id (40 alphanumerics), whose bundles are named in the manifest's own /// metadata; /// - a legacy bundle stored under its file name, with or without the .assetbundle suffix /// an import appends - which is every imported room, every built-in room and every bundle the /// admin panel binds. /// /// Gating both shapes on the 40-character test answered 400 {"success":false,"error":"bad /// request"} for every legacy bundle on the server, and the client reports one such answer as /// "Error downloading room assets! Try again later or on a better connection." for the whole /// room - the failure looked like a connection problem and hit every player, because it was /// server-side and room-independent. These pin both shapes and the two names that must never be /// served at all. /// [Collection(DbFactoryCollection.Name)] public class UnityAssetMetadataTests : IDisposable { private const string LegacyBundle = "Showdown_0_1.assetbundle"; private const string StudioManifestId = "0123456789abcdef0123456789abcdef01234567"; private static readonly string BakedBundle = new('a', 40); private static readonly string RejectedBundle = new('b', 40); private readonly string root = Directory.CreateTempSubdirectory("radicade-unity-assets-").FullName; private readonly string? previousStorage; public UnityAssetMetadataTests() { previousStorage = DBConfig.CDNStorage; DBConfig.CDNStorage = root; } public void Dispose() { DBConfig.CDNStorage = previousStorage!; try { Directory.Delete(root, true); } catch (IOException) { } } private string Write(string name, string contents = "bundle") { var path = Path.Join(root, name); var directory = Path.GetDirectoryName(path); if (!string.IsNullOrEmpty(directory)) Directory.CreateDirectory(directory); File.WriteAllText(path, contents); return path; } private static UnityAssetsCDN Metadata() => new() { ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() } }; private static T WithHttpContext(T controller) where T : Controller { controller.ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }; return controller; } private static JsonElement Body(IActionResult result) { var content = Assert.IsType(result); Assert.Equal("application/json", content.ContentType); return JsonSerializer.Deserialize(content.Content!); } // ---------------------------------------------------------------- legacy ids [Theory] [InlineData("Showdown_0_1.assetbundle", "Showdown_0_1.assetbundle")] [InlineData("Showdown_0_1", "Showdown_0_1.assetbundle")] [InlineData("8aas7b5ivtaggn9swhe3wkgdj.assetbundle", "8aas7b5ivtaggn9swhe3wkgdj.assetbundle")] [InlineData("8aas7b5ivtaggn9swhe3wkgdj", "8aas7b5ivtaggn9swhe3wkgdj.assetbundle")] public async Task ALegacyBundleResolvesUnderBothSpellingsOfItsId(string requested, string expected) { Write("Showdown_0_1.assetbundle"); Write("8aas7b5ivtaggn9swhe3wkgdj.assetbundle"); var json = Body(await Metadata().GetUnityAsset(requested, 0, 1)); Assert.Equal(requested, json.GetProperty("UnityAssetId").GetString()); Assert.Equal(expected, json.GetProperty("Filename").GetString()); Assert.Equal(0, json.GetProperty("Target").GetInt32()); Assert.Equal(1, json.GetProperty("Version").GetInt32()); } [Fact] public async Task TheFilenameTheClientIsHandedIsTheFileItCanDownload() { // The two steps have to agree: step two has no other way to reach the bytes. Write(LegacyBundle); var filename = Body(await Metadata().GetUnityAsset(LegacyBundle, 0, 1)).GetProperty("Filename").GetString()!; var file = Assert.IsType(new DataCDN().GetDataBlob(filename)); Assert.Equal(Path.Join(root, LegacyBundle), file.FileName); Assert.True(file.EnableRangeProcessing); } [Fact] public async Task AnUnknownBundleStillAnswersNotFound() { Write(LegacyBundle); Assert.IsType(await Metadata().GetUnityAsset("not-on-disk", 0, 1)); Assert.IsType(await Metadata().GetUnityAsset("not-on-disk.assetbundle", 0, 1)); } [Fact] public async Task TheIdsWithoutTargetOrVersionAnswersTheDefaultPair() { // /rooms/unity_assets/{assetId} is the short form; it must not silently be a 400 either. Write(LegacyBundle); var json = Body(await Metadata().GetUnityAsset(LegacyBundle)); Assert.Equal(LegacyBundle, json.GetProperty("Filename").GetString()); Assert.Equal(1, json.GetProperty("Version").GetInt32()); } // ---------------------------------------------------------------- Studio manifests [Fact] public async Task AStudioManifestStillAnswersTheBundleItsMetadataNames() { Write(StudioManifestId, "{\"Version\":2,\"MainSceneProjectRelativePath\":\"Assets/Room.unity\",\"Files\":[]}"); Write(BakedBundle); Write(RejectedBundle); await StudioUnityAssetRegistry.AddBakedAssetAsync(StudioManifestId, new StudioBakedUnityAsset(StudioManifestId, 0, 1, BakedBundle, RejectedBundle)); var json = Body(await Metadata().GetUnityAsset(StudioManifestId, 0, 1)); Assert.Equal(BakedBundle, json.GetProperty("Filename").GetString()); Assert.Equal(RejectedBundle, json.GetProperty("RejectedDependenciesFilename").GetString()); } [Fact] public async Task AStudioManifestIsNeverHandedOverAsItsOwnBundle() { // The manifest is the file stored under the id, so a target/version it does not name // must be a 404: answering with the manifest would have the client parse JSON metadata // as an asset bundle. Write(StudioManifestId, "{\"Version\":2,\"MainSceneProjectRelativePath\":\"Assets/Room.unity\",\"Files\":[]}"); Write(LegacyBundle); Assert.IsType(await Metadata().GetUnityAsset(StudioManifestId, 0, 1)); Assert.IsType(await Metadata().GetUnityAsset(StudioManifestId, 5, 9)); } [Fact] public async Task ALegacyBundleWhoseIdLooksLikeAManifestStillResolves() { // 40 alphanumerics with no manifest file behind it is a bundle name like any other, and // a 40-character id is exactly what the importer names a Studio asset, so this is the // shape a bare-id bundle arrives in. Write(BakedBundle + ".assetbundle"); var json = Body(await Metadata().GetUnityAsset(BakedBundle, 0, 1)); Assert.Equal(BakedBundle + ".assetbundle", json.GetProperty("Filename").GetString()); } // ---------------------------------------------------------------- names never served [Theory] [InlineData("sub/" + LegacyBundle)] [InlineData("..\\" + LegacyBundle)] [InlineData(LegacyBundle + ".uploading")] [InlineData("Showdown_0_1.uploading")] public async Task ANameThatMustNotBeServedIsRefused(string requested) { // Every one of these is on disk as literally as the platform allows, so the refusal has // to be the rule rather than the file being absent. Write(LegacyBundle); Write(LegacyBundle + ".uploading"); Write("Showdown_0_1.uploading"); Write("sub/" + LegacyBundle); Assert.IsType(await Metadata().GetUnityAsset(requested, 0, 1)); } [Fact] public async Task ANameThatLeavesTheStorageDirectoryIsRefused() { // This one escapes the storage root and re-enters it, so the file is genuinely reachable // by path and only the rule stands between the request and the bytes. Write(LegacyBundle); var escaping = "../" + Path.GetFileName(root) + "/" + LegacyBundle; Assert.IsType(await Metadata().GetUnityAsset(escaping, 0, 1)); } [Theory] [InlineData(256, 1)] [InlineData(-1, 1)] [InlineData(0, -1)] public async Task ATargetOrVersionOutsideTheContractIsRefused(int target, int version) { Write(LegacyBundle); Assert.IsType(await Metadata().GetUnityAsset(LegacyBundle, target, version)); } // ---------------------------------------------------------------- the bytes routes [Fact] public void TheBytesRouteAnswersBothSpellingsOfABundle() { Write(LegacyBundle); var exact = Assert.IsType( WithHttpContext(new RoomsCDN()).GetUnityAssetRaw(LegacyBundle, 0, 1)); Assert.Equal(Path.Join(root, LegacyBundle), exact.FileName); // /unity/{id} is the id with no extension, while an import stores .assetbundle. var bare = Assert.IsType( WithHttpContext(new RoomsCDN()).GetUnityAssetRaw("Showdown_0_1", 0, 1)); Assert.Equal(Path.Join(root, LegacyBundle), bare.FileName); } [Fact] public void NoBlobRouteServesAHalfImportedPart() { // Admin part uploads stage .uploading beside the final file and promote it only // when the last part lands, so a truncated bundle must be unreachable by name even // though it is sitting in the storage directory. Write(LegacyBundle); Write(LegacyBundle + ".uploading"); Assert.IsType(new DataCDN().GetDataBlob(LegacyBundle + ".uploading")); Assert.IsType(new RoomsCDN().GetDataBlob(LegacyBundle + ".uploading")); Assert.IsType(WithHttpContext(new RoomsCDN()).GetUnityAssetRaw(LegacyBundle + ".uploading", 0, 1)); Assert.IsType(new InventionCDN().GetDataBlob(LegacyBundle + ".uploading")); // ... while the promoted file beside it is served as usual. Assert.IsType(new DataCDN().GetDataBlob(LegacyBundle)); } [Fact] public void NoBlobRouteServesANameThatLeavesTheStorageDirectory() { Write(LegacyBundle); Assert.IsType(new DataCDN().GetDataBlob("../" + LegacyBundle)); Assert.IsType(new DataCDN().GetDataBlob("sub/" + LegacyBundle)); Assert.IsType(new RoomsCDN().GetDataBlob("../" + LegacyBundle)); Assert.IsType(WithHttpContext(new RoomsCDN()).GetUnityAssetRaw("../" + LegacyBundle, 0, 1)); } }