using System.Reflection; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Routing; using Microsoft.EntityFrameworkCore; using RadicadeServer.Admin; using RadicadeServer.Clubs; using RadicadeServer.DB; using Xunit; namespace RadicadeServer.Tests; /// /// Setting a club's image from the admin panel: POST admin/clubs/upload/image and /// GET admin/clubs/set/image. /// /// Clubs are JSON in the "Clubs" server config rather than a table, so the panel's generic table /// routes cannot reach one and these two endpoints are the whole of the write path. The name they /// store is the one hands the client as MainImageName, so the /// image the panel picks is the image the game shows. /// [Collection(DbFactoryCollection.Name)] public class AdminClubImageTests { private const string Secret = "test-club-image-secret"; private const long OwnerId = 77; private static RecDBContext NewDb() => new( TestDbOptions.Create() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options); private static async Task CreateClubAsync(RecDBContext db) { var club = await ClubStore.CreateAsync(db, OwnerId, "Image Test Club", "a club"); return club.clubId; } private static IFormFile FormFile(string name, byte[] bytes) => new FormFile(new MemoryStream(bytes), 0, bytes.Length, "file", name); // A real PNG header, so the file has the bytes its extension claims. private static readonly byte[] PngBytes = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]; /// Runs an action with the admin secret and CDN image folder pointed at a temp dir. private static async Task WithAdminAsync(string imagesDirectory, Func> action) { var originalKey = DBConfig.AdminApiKey; var originalImages = DBConfig.CDNImages; DBConfig.AdminApiKey = Secret; DBConfig.CDNImages = imagesDirectory; try { return await action(); } finally { DBConfig.AdminApiKey = originalKey; DBConfig.CDNImages = originalImages; } } private static string TempImages() => Path.Combine(Path.GetTempPath(), "radicade-club-image-" + Guid.NewGuid().ToString("N")); [Fact] public void ThePanelCallsTheseRoutes() { var setImage = typeof(AdminClubAPI).GetMethod(nameof(AdminClubAPI.SetImage))!; Assert.Contains("set/image", setImage .GetCustomAttributes(typeof(HttpMethodAttribute), false) .Cast() .Select(attribute => attribute.Template)); var upload = typeof(AdminClubAPI).GetMethod(nameof(AdminClubAPI.UploadImage))!; Assert.Contains("upload/image", upload .GetCustomAttributes(typeof(HttpMethodAttribute), false) .Cast() .Select(attribute => attribute.Template)); // The controller's own prefix is what the panel's fetch URLs are built from. var prefix = typeof(AdminClubAPI).GetCustomAttributes(typeof(RouteAttribute), false) .Cast() .Single(); Assert.Equal("admin/clubs", prefix.Template); } [Fact] public async Task SetImageNamesTheImageTheGameIsServed() { var db = NewDb(); var clubId = await CreateClubAsync(db); var api = new AdminClubAPI(db); var result = await WithAdminAsync(TempImages(), () => api.SetImage(Secret, clubId, "custom-club.png")); Assert.IsType(result); var club = await ClubStore.GetAsync(db, clubId); Assert.Equal("custom-club.png", club!.imageName); // What the client is actually told is the name that matters. var served = (Dictionary)ClubStore.BuildClub(club, OwnerId); Assert.Equal("custom-club.png", served["MainImageName"]); } [Fact] public async Task AnEmptyNamePutsTheClubBackOnTheDefaultImage() { var db = NewDb(); var clubId = await CreateClubAsync(db); var api = new AdminClubAPI(db); await WithAdminAsync(TempImages(), () => api.SetImage(Secret, clubId, "something.png")); // Clearing is the same call with an empty name, which is what the panel sends. var result = await WithAdminAsync(TempImages(), () => api.SetImage(Secret, clubId, "")); Assert.IsType(result); var club = await ClubStore.GetAsync(db, clubId); Assert.Equal(ClubStore.DefaultImageName, club!.imageName); } [Fact] public async Task SetImageKeepsOnlyTheFileName() { var db = NewDb(); var clubId = await CreateClubAsync(db); var api = new AdminClubAPI(db); // An image name is a key into the CDN's image folder, so a path must not survive. await WithAdminAsync(TempImages(), () => api.SetImage(Secret, clubId, "../../elsewhere/evil.png")); var club = await ClubStore.GetAsync(db, clubId); Assert.Equal("evil.png", club!.imageName); } [Fact] public async Task SetImageRefusesAnInvalidKeyAndAnUnknownClub() { var db = NewDb(); var clubId = await CreateClubAsync(db); var api = new AdminClubAPI(db); var wrongKey = await api.SetImage("not-the-secret", clubId, "x.png"); Assert.IsType(wrongKey); var missing = await WithAdminAsync(TempImages(), () => api.SetImage(Secret, clubId + 9999, "x.png")); Assert.IsType(missing); // Neither attempt touched the club. var club = await ClubStore.GetAsync(db, clubId); Assert.Equal(ClubStore.DefaultImageName, club!.imageName); } [Fact] public async Task UploadStoresTheFileAndNamesItOnTheClub() { var directory = TempImages(); var db = NewDb(); var clubId = await CreateClubAsync(db); var api = new AdminClubAPI(db); try { var result = await WithAdminAsync(directory, () => api.UploadImage(Secret, clubId, FormFile("logo.png", PngBytes))); var ok = Assert.IsType(result); var club = await ClubStore.GetAsync(db, clubId); Assert.EndsWith(".png", club!.imageName); Assert.True(File.Exists(Path.Join(directory, club.imageName))); // The response names the URL the panel shows, so it has to point at the stored file. Assert.Contains($"/images/img/{club.imageName}", ok.Value!.ToString()); } finally { if (Directory.Exists(directory)) Directory.Delete(directory, true); } } [Fact] public async Task UploadRefusesAFileThatIsNotAnImageAndLeavesNothingBehind() { var directory = TempImages(); var db = NewDb(); var clubId = await CreateClubAsync(db); var api = new AdminClubAPI(db); try { var result = await WithAdminAsync(directory, () => api.UploadImage(Secret, clubId, FormFile("save.room", PngBytes))); Assert.IsType(result); Assert.False(Directory.Exists(directory)); var club = await ClubStore.GetAsync(db, clubId); Assert.Equal(ClubStore.DefaultImageName, club!.imageName); } finally { if (Directory.Exists(directory)) Directory.Delete(directory, true); } } [Fact] public async Task UploadRefusesAnEmptyFile() { var directory = TempImages(); var db = NewDb(); var clubId = await CreateClubAsync(db); var api = new AdminClubAPI(db); try { var result = await WithAdminAsync(directory, () => api.UploadImage(Secret, clubId, FormFile("logo.png", []))); Assert.IsType(result); var club = await ClubStore.GetAsync(db, clubId); Assert.Equal(ClubStore.DefaultImageName, club!.imageName); } finally { if (Directory.Exists(directory)) Directory.Delete(directory, true); } } [Fact] public async Task UploadOnAnUnknownClubWritesNoFile() { var directory = TempImages(); var db = NewDb(); var clubId = await CreateClubAsync(db); var api = new AdminClubAPI(db); try { // The club is looked up before the upload is stored, so a mistyped club id cannot // leave an orphaned file in the CDN. var result = await WithAdminAsync(directory, () => api.UploadImage(Secret, clubId + 9999, FormFile("logo.png", PngBytes))); Assert.IsType(result); Assert.False(Directory.Exists(directory)); } finally { if (Directory.Exists(directory)) Directory.Delete(directory, true); } } }