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

272 lines
11 KiB
C#

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;
/// <summary>
/// Step one of the client's bundle download is
/// <c>GET /rooms/unity_assets/{assetId}/{target}/{version}</c> for a file name, step two is
/// <c>/data/{Filename}</c> 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 <c>.assetbundle</c> 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 <c>400 {"success":false,"error":"bad
/// request"}</c> 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.
/// </summary>
[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>(T controller) where T : Controller
{
controller.ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() };
return controller;
}
private static JsonElement Body(IActionResult result)
{
var content = Assert.IsType<ContentResult>(result);
Assert.Equal("application/json", content.ContentType);
return JsonSerializer.Deserialize<JsonElement>(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<PhysicalFileResult>(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<NotFoundResult>(await Metadata().GetUnityAsset("not-on-disk", 0, 1));
Assert.IsType<NotFoundResult>(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<NotFoundResult>(await Metadata().GetUnityAsset(StudioManifestId, 0, 1));
Assert.IsType<NotFoundResult>(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<BadRequestResult>(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<BadRequestResult>(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<BadRequestResult>(await Metadata().GetUnityAsset(LegacyBundle, target, version));
}
// ---------------------------------------------------------------- the bytes routes
[Fact]
public void TheBytesRouteAnswersBothSpellingsOfABundle()
{
Write(LegacyBundle);
var exact = Assert.IsType<PhysicalFileResult>(
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 <id>.assetbundle.
var bare = Assert.IsType<PhysicalFileResult>(
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 <name>.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<BadRequestResult>(new DataCDN().GetDataBlob(LegacyBundle + ".uploading"));
Assert.IsType<BadRequestResult>(new RoomsCDN().GetDataBlob(LegacyBundle + ".uploading"));
Assert.IsType<BadRequestResult>(WithHttpContext(new RoomsCDN()).GetUnityAssetRaw(LegacyBundle + ".uploading", 0, 1));
Assert.IsType<BadRequestResult>(new InventionCDN().GetDataBlob(LegacyBundle + ".uploading"));
// ... while the promoted file beside it is served as usual.
Assert.IsType<PhysicalFileResult>(new DataCDN().GetDataBlob(LegacyBundle));
}
[Fact]
public void NoBlobRouteServesANameThatLeavesTheStorageDirectory()
{
Write(LegacyBundle);
Assert.IsType<BadRequestResult>(new DataCDN().GetDataBlob("../" + LegacyBundle));
Assert.IsType<BadRequestResult>(new DataCDN().GetDataBlob("sub/" + LegacyBundle));
Assert.IsType<BadRequestResult>(new RoomsCDN().GetDataBlob("../" + LegacyBundle));
Assert.IsType<BadRequestResult>(WithHttpContext(new RoomsCDN()).GetUnityAssetRaw("../" + LegacyBundle, 0, 1));
}
}