using System; using System.IO; using RadicadeServer.Images; using Xunit; namespace RadicadeServer.Tests; /// /// The image route answers by name, and the name a client asks for is not always the name on /// disk. Two real cases drove this: a custom-avatar design is stored as /// <guid>.bin while its bytes are a PNG, and the default room image ships as /// DefaultRoomImage.png while the server asks for the bare DefaultRoomImage. /// Both resolved to nothing, and nothing is not "no picture" -- it is an empty 404 body, which /// the 2023 client turns into a broken surface (and, for a room image, a fatal load failure). /// public class ImageAssetResolutionTests : IDisposable { private readonly string root = Directory.CreateTempSubdirectory("radicade-images-test-").FullName; public void Dispose() { try { Directory.Delete(root, true); } catch (IOException) { } } private string Write(string name, string content = "x") { var path = Path.Join(root, name); File.WriteAllText(path, content); return path; } [Fact] public void TheRequestedNameIsFoundFirst() { var expected = Write("photo.png"); Assert.Equal(expected, ImageAPI.ResolveAsset("photo.png", new[] { root })); } [Fact] public void ADesignStoredAsBinIsFoundByTheNameTheClientAsksFor() { // Exactly what the customizer wrote before designs moved to the image CDN: the bytes // are a PNG, the name on disk ends in .bin, and the client asks for that name. var expected = Write("a07188bb-9f99-442d-a2ff-2aebad2ca7fe.bin"); Assert.Equal(expected, ImageAPI.ResolveAsset("a07188bb-9f99-442d-a2ff-2aebad2ca7fe.bin", new[] { root })); } [Fact] public void AMissingExtensionIsTriedOnTheSameStem() { // The default-image case: the server asks for "DefaultRoomImage", the deployment ships // "DefaultRoomImage.png", and the extension search used to be skipped for this name. var expected = Write("DefaultRoomImage.png"); Assert.Equal(expected, ImageAPI.ResolveAsset("DefaultRoomImage", new[] { root })); } [Fact] public void AnExtensionThatDoesNotDescribeTheContentStillFindsTheFile() { var expected = Write("design.png"); Assert.Equal(expected, ImageAPI.ResolveAsset("design.bin", new[] { root })); Assert.Equal(expected, ImageAPI.ResolveAsset("design.jpeg", new[] { root })); } [Fact] public void RootsAreSearchedInOrder() { var first = Directory.CreateDirectory(Path.Join(root, "images")).FullName; var second = Directory.CreateDirectory(Path.Join(root, "custom")).FullName; var expected = Path.Join(first, "shirt.png"); File.WriteAllText(expected, "x"); File.WriteAllText(Path.Join(second, "shirt.png"), "x"); Assert.Equal(expected, ImageAPI.ResolveAsset("shirt.png", new[] { first, second })); } [Fact] public void ANamedRootIsPreferredOverALaterRootsVariant() { var images = Directory.CreateDirectory(Path.Join(root, "images")).FullName; var custom = Directory.CreateDirectory(Path.Join(root, "custom")).FullName; var expected = Path.Join(custom, "shirt.bin"); File.WriteAllText(expected, "x"); File.WriteAllText(Path.Join(images, "shirt.png"), "x"); Assert.Equal(expected, ImageAPI.ResolveAsset("shirt.bin", new[] { images, custom })); } [Theory] [InlineData("")] [InlineData(".")] [InlineData("..")] [InlineData("../outside.png")] [InlineData("..\\outside.png")] [InlineData("sub/../outside.png")] public void NamesThatDoNotNameAFileInsideARootResolveToNothing(string name) { Assert.Null(ImageAPI.ResolveAsset(name, new[] { root })); } [Fact] public void AnUnknownNameResolvesToNothing() { Assert.Null(ImageAPI.ResolveAsset("definitely-missing.png", new[] { root })); Assert.Null(ImageAPI.ResolveAsset("definitely-missing.bin", new[] { root })); } [Fact] public void TheServedRootsStillIncludeTheDesignRoot() { // A design written before designs moved to the image CDN only stays reachable while // CDN/Custom is still searched, so dropping it here would silently blank old shirts. var roots = ImageAPI.AssetRoots(); Assert.Contains(roots, r => r.EndsWith(Path.Join("CDN", "Images"))); Assert.Contains(roots, r => r.EndsWith(Path.Join("CDN", "Custom"))); Assert.Contains(roots, r => r.EndsWith(Path.Join("CDN", "Storage"))); } }