mirror of
https://github.com/JogmansonReal/Radicade.git
synced 2026-10-26 07:29:00 -07:00
Radicade Public Code
This commit is contained in:
@@ -0,0 +1,271 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user