using System.Security.Claims; using System.Text; using System.Text.Json; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Primitives; using RadicadeServer.API.Avatars; using RadicadeServer.API.Notifications; using RadicadeServer.DB; using RadicadeServer.RecNet.Auth; using RadicadeServer.RecNet.Avatar; using RadicadeServer.RecNet.Notifications; using Xunit; namespace RadicadeServer.Tests; /// /// A custom avatar item is worn, so writing one has to reach the client that is wearing it. /// The routes only ever wrote the row: a shirt that was drawn, recolored, renamed or deleted /// stayed as it was on screen until the player restarted, because nothing told the client /// that the item had changed. These tests pin the missing half - each write pushes the /// account update (the same notification cheers and the selected cheer badge use) to the /// owner's own connection. /// /// The design filename changes on every redraw, so the account update makes clients resolve /// the item again and fetch the new image rather than a cached one. Whether the client /// redraws the worn shirt from that frame is a client-side question this suite cannot /// answer; what is pinned here is that the server sends it. /// [Collection(DbFactoryCollection.Name)] public class CustomAvatarItemRealtimeTests { private const long OwnerId = 61; private const string OwnerConnection = "custom-item-owner"; private sealed class TestDbContextFactory : IDbContextFactory { private readonly DbContextOptions options = TestDbOptions.Create() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options; public RecDBContext CreateDbContext() => new(options); } private sealed class RecordingProxy : ISingleClientProxy { public List<(string Method, string Json)> Messages { get; } = new(); public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default) { Messages.Add((method, Assert.IsType(args[0]))); return Task.CompletedTask; } public Task InvokeCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default) => throw new NotSupportedException(); } private sealed class RecordingClients : IHubClients { public RecordingProxy Broadcast { get; } = new(); public Dictionary Connections { get; } = new(); public RecordingProxy For(string connectionId) { if (!Connections.TryGetValue(connectionId, out var proxy)) Connections[connectionId] = proxy = new RecordingProxy(); return proxy; } public IClientProxy All => Broadcast; public IClientProxy AllExcept(IReadOnlyList excludedConnectionIds) => Broadcast; public IClientProxy Client(string connectionId) => For(connectionId); public IClientProxy Clients(IReadOnlyList connectionIds) => Broadcast; public IClientProxy Group(string groupName) => Broadcast; public IClientProxy GroupExcept(string groupName, IReadOnlyList excludedConnectionIds) => Broadcast; public IClientProxy Groups(IReadOnlyList groupNames) => Broadcast; public IClientProxy User(string userId) => Broadcast; public IClientProxy Users(IReadOnlyList userIds) => Broadcast; } private sealed class RecordingHub : IHubContext { private readonly RecordingClients clients = new(); public RecordingClients Recorder => clients; public IHubClients Clients => clients; public IGroupManager Groups => throw new NotSupportedException(); } private sealed class Fixture : IAsyncDisposable { public Fixture(RecDBContext db, TestDbContextFactory factory, RecordingHub hub) { Db = db; this.factory = factory; this.hub = hub; } private readonly TestDbContextFactory factory; private readonly RecordingHub hub; private readonly IDbContextFactory previousFactory = Notify.DbFactory; private readonly IHubContext previousContext = Notify.Context; public RecDBContext Db { get; } public RecordingProxy Owner => hub.Recorder.For(OwnerConnection); public ValueTask DisposeAsync() { lock (Notify.ClientsLock) Notify.Clients.RemoveAll(client => client.ConnectionId == OwnerConnection); Notify.DbFactory = previousFactory; Notify.Context = previousContext; return Db.DisposeAsync(); } } /// /// Opens a database holding the owner's profile and player row, registers one notification /// connection for them, and points the notifier at both. has to /// be set even though the controller already has a context: the notifier opens its own to read /// what it sends. /// private static async Task OpenAsync() { var factory = new TestDbContextFactory(); var hub = new RecordingHub(); var db = factory.CreateDbContext(); db.Profiles.Add(new Profile { accountId = OwnerId, username = $"player{OwnerId}", displayName = $"Player {OwnerId}", profileImage = string.Empty, createdAt = DateTime.UtcNow }); db.Players.Add(new PlayerData { AccountId = OwnerId }); await db.SaveChangesAsync(); await db.DisposeAsync(); lock (Notify.ClientsLock) Notify.Clients.Add(new HubClient { AccountId = OwnerId, ConnectionId = OwnerConnection }); Notify.DbFactory = factory; Notify.Context = hub; return new Fixture(factory.CreateDbContext(), factory, hub); } private static CustomAvatarItemDB ShirtFor(Guid itemId) => new() { CustomAvatarItemId = itemId, CreatorAccountId = OwnerId, Name = "Shirt", Description = string.Empty, BaseAvatarItemColor = "red", CreatedAt = DateTime.UtcNow, ModifiedAt = DateTime.UtcNow }; private static void SetUser(Controller controller, long userId) { var identity = new ClaimsIdentity("TestAuth"); identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, userId.ToString())); controller.ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(identity) } }; } private static void SetDesignForm(Controller controller, Guid itemId, string color, string fileName) { var request = controller.HttpContext.Request; request.ContentType = "multipart/form-data; boundary=radicade-test"; request.Form = new FormCollection( new Dictionary { ["customAvatarItemId"] = itemId.ToString(), ["customAvatarItemColor"] = color }, new FormFileCollection { new FormFile(new MemoryStream(Encoding.UTF8.GetBytes("png-bytes")), 0, 9, "design", fileName) { Headers = new HeaderDictionary(), ContentType = "image/png" } }); } private static void SetJsonBody(Controller controller, string json) { var request = controller.HttpContext.Request; request.ContentType = "application/json"; request.Body = new MemoryStream(Encoding.UTF8.GetBytes(json)); request.ContentLength = request.Body.Length; request.EnableBuffering(); } private static void AssertAccountUpdateReachedTheOwner(RecordingProxy owner) { Assert.Contains(owner.Messages, frame => frame.Method == "Notification" && frame.Json.Contains("\"Id\":\"AccountUpdate\"")); } [Fact] public async Task RedrawingAShirtPushesTheAccountUpdate() { await using var fixture = await OpenAsync(); var itemId = Guid.NewGuid(); fixture.Db.CustomAvatarItems.Add(ShirtFor(itemId)); await fixture.Db.SaveChangesAsync(); var api = new CustomAvatarItemsAPI(fixture.Db); SetUser(api, OwnerId); SetDesignForm(api, itemId, "blue", "shirt.png"); Assert.IsType(await api.SaveDesign()); var stored = await fixture.Db.CustomAvatarItems.AsNoTracking().FirstAsync(x => x.CustomAvatarItemId == itemId); Assert.Equal("blue", stored.BaseAvatarItemColor); Assert.EndsWith(".png", stored.DesignFileName); AssertAccountUpdateReachedTheOwner(fixture.Owner); } [Fact] public async Task RenamingAnItemPushesTheAccountUpdate() { await using var fixture = await OpenAsync(); var itemId = Guid.NewGuid(); fixture.Db.CustomAvatarItems.Add(ShirtFor(itemId)); await fixture.Db.SaveChangesAsync(); var api = new CustomAvatarItemsAPI(fixture.Db); SetUser(api, OwnerId); SetJsonBody(api, """{"Name":"Racing Shirt","BaseAvatarItemColor":"green"}"""); Assert.IsType(await api.Update(itemId)); var stored = await fixture.Db.CustomAvatarItems.AsNoTracking().FirstAsync(x => x.CustomAvatarItemId == itemId); Assert.Equal("Racing Shirt", stored.Name); Assert.Equal("green", stored.BaseAvatarItemColor); AssertAccountUpdateReachedTheOwner(fixture.Owner); } [Fact] public async Task CreatingAnItemPushesTheAccountUpdate() { await using var fixture = await OpenAsync(); var api = new CustomAvatarItemsAPI(fixture.Db); SetUser(api, OwnerId); SetJsonBody(api, """{"Name":"Fresh Shirt","Description":"","Price":0,"BaseAvatarItemId":1,"Accessibility":0}"""); Assert.IsType(await api.Create()); var stored = await fixture.Db.CustomAvatarItems.AsNoTracking() .FirstAsync(x => x.CreatorAccountId == OwnerId); Assert.Equal("Fresh Shirt", stored.Name); AssertAccountUpdateReachedTheOwner(fixture.Owner); } [Fact] public async Task DeletingAnItemPushesTheAccountUpdate() { await using var fixture = await OpenAsync(); var itemId = Guid.NewGuid(); fixture.Db.CustomAvatarItems.Add(ShirtFor(itemId)); await fixture.Db.SaveChangesAsync(); var api = new CustomAvatarItemsAPI(fixture.Db); SetUser(api, OwnerId); Assert.IsType(await api.Delete(itemId)); Assert.Empty(fixture.Db.CustomAvatarItems.Where(x => x.CustomAvatarItemId == itemId)); AssertAccountUpdateReachedTheOwner(fixture.Owner); } [Fact] public async Task AWriteFromAnotherAccountDoesNotReachTheOwner() { await using var fixture = await OpenAsync(); var itemId = Guid.NewGuid(); fixture.Db.CustomAvatarItems.Add(ShirtFor(itemId)); await fixture.Db.SaveChangesAsync(); var api = new CustomAvatarItemsAPI(fixture.Db); SetUser(api, OwnerId + 1); SetJsonBody(api, """{"Name":"Not Mine"}"""); var result = Assert.IsType(await api.Update(itemId)); Assert.Equal(StatusCodes.Status403Forbidden, result.StatusCode); Assert.Empty(fixture.Owner.Messages); var stored = await fixture.Db.CustomAvatarItems.AsNoTracking().FirstAsync(x => x.CustomAvatarItemId == itemId); Assert.Equal("Shirt", stored.Name); } }