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

248 lines
8.9 KiB
C#

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;
/// <summary>
/// Setting a club's image from the admin panel: <c>POST admin/clubs/upload/image</c> and
/// <c>GET admin/clubs/set/image</c>.
///
/// 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 <see cref="ClubStore.BuildClub"/> hands the client as MainImageName, so the
/// image the panel picks is the image the game shows.
/// </summary>
[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<long> 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];
/// <summary>Runs an action with the admin secret and CDN image folder pointed at a temp dir.</summary>
private static async Task<T> WithAdminAsync<T>(string imagesDirectory, Func<Task<T>> 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<HttpMethodAttribute>()
.Select(attribute => attribute.Template));
var upload = typeof(AdminClubAPI).GetMethod(nameof(AdminClubAPI.UploadImage))!;
Assert.Contains("upload/image", upload
.GetCustomAttributes(typeof(HttpMethodAttribute), false)
.Cast<HttpMethodAttribute>()
.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<RouteAttribute>()
.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<OkObjectResult>(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<string, object?>)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<OkObjectResult>(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<UnauthorizedObjectResult>(wrongKey);
var missing = await WithAdminAsync(TempImages(), () => api.SetImage(Secret, clubId + 9999, "x.png"));
Assert.IsType<NotFoundObjectResult>(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<OkObjectResult>(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<BadRequestObjectResult>(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<BadRequestObjectResult>(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<NotFoundObjectResult>(result);
Assert.False(Directory.Exists(directory));
}
finally
{
if (Directory.Exists(directory)) Directory.Delete(directory, true);
}
}
}