mirror of
https://github.com/JogmansonReal/Radicade.git
synced 2026-10-25 15:09:26 -07:00
299 lines
13 KiB
C#
299 lines
13 KiB
C#
using System.Security.Claims;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using RadicadeServer.API.Accounts;
|
|
using RadicadeServer.API.Events;
|
|
using RadicadeServer.API.Relationships.V1;
|
|
using RadicadeServer.API;
|
|
using RadicadeServer.Clubs;
|
|
using RadicadeServer.DB;
|
|
using RadicadeServer.RecNet.Auth;
|
|
using RadicadeServer.RecNet.Events;
|
|
using RadicadeServer.RecNet.Messages;
|
|
using RadicadeServer.RecNet.Relationships;
|
|
using Xunit;
|
|
|
|
namespace RadicadeServer.Tests;
|
|
|
|
/// <summary>
|
|
/// The routes a single player report named, each of which failed in a different way:
|
|
/// a missing route (404), a route registered under another verb (405), and a handler
|
|
/// that refused a request whose id arrived somewhere it did not look (400).
|
|
///
|
|
/// The tests below pin the behaviour rather than the implementation: a request of the
|
|
/// shape the client sends has to reach a handler, find its record, and act on it.
|
|
/// </summary>
|
|
public class PlayerReportedRouteTests
|
|
{
|
|
private const long CallerId = 42;
|
|
private const long OtherId = 77;
|
|
|
|
private static RecDBContext CreateDb()
|
|
{
|
|
var options = TestDbOptions.Create()
|
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
|
.Options;
|
|
return new RecDBContext(options);
|
|
}
|
|
|
|
private static T CreateController<T>(RecDBContext db, long? userId = CallerId) where T : Controller
|
|
{
|
|
var identity = new ClaimsIdentity("TestAuth");
|
|
if (userId.HasValue)
|
|
identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, userId.Value.ToString()));
|
|
|
|
var controller = (T)Activator.CreateInstance(typeof(T), db)!;
|
|
controller.ControllerContext = new ControllerContext
|
|
{
|
|
HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(identity) }
|
|
};
|
|
return controller;
|
|
}
|
|
|
|
private static void SetJsonBody(Controller controller, string jsonBody)
|
|
{
|
|
var bytes = Encoding.UTF8.GetBytes(jsonBody);
|
|
controller.HttpContext.Request.Body = new MemoryStream(bytes);
|
|
controller.HttpContext.Request.ContentType = "application/json";
|
|
controller.HttpContext.Request.ContentLength = bytes.Length;
|
|
}
|
|
|
|
private static ChatThread ThreadWith(params long[] playerIds) => new()
|
|
{
|
|
chatThreadName = "",
|
|
messages = "[]",
|
|
playerIds = JsonSerializer.Serialize(playerIds)
|
|
};
|
|
|
|
// ---- POST /chat/thread/party -> 405 ----------------------------------------------------
|
|
|
|
[Fact]
|
|
public void PartyThreadIsRegisteredForBothGetAndPost()
|
|
{
|
|
var attributes = typeof(ThreadAPI).GetMethod(nameof(ThreadAPI.GetPartyThread))!
|
|
.GetCustomAttributes(typeof(Microsoft.AspNetCore.Mvc.Routing.HttpMethodAttribute), true)
|
|
.Cast<Microsoft.AspNetCore.Mvc.Routing.HttpMethodAttribute>()
|
|
.ToList();
|
|
|
|
// The client's party lookup POSTs; with only the GET registered that is a 405,
|
|
// which reads in the log as the party simply never loading.
|
|
Assert.Contains(attributes, a => a.HttpMethods.Contains("GET"));
|
|
Assert.Contains(attributes, a => a.HttpMethods.Contains("POST"));
|
|
|
|
// And both verbs answer the same empty list, which is the "no party" spelling.
|
|
var controller = CreateController<ThreadAPI>(CreateDb());
|
|
var result = Assert.IsType<OkObjectResult>(controller.GetPartyThread());
|
|
Assert.Empty(Assert.IsAssignableFrom<System.Collections.IEnumerable>(result.Value).Cast<object>());
|
|
}
|
|
|
|
// ---- PUT /chat/thread/1/favorite -> 404 ------------------------------------------------
|
|
|
|
[Fact]
|
|
public async Task FavoriteThreadRouteExistsAndIsReportedOnTheThread()
|
|
{
|
|
// The route the star uses. It had no attribute at all, so the call 404ed.
|
|
var methods = typeof(ThreadAPI).GetMethod(nameof(ThreadAPI.FavoriteThread))!
|
|
.GetCustomAttributes(typeof(Microsoft.AspNetCore.Mvc.Routing.HttpMethodAttribute), true)
|
|
.Cast<Microsoft.AspNetCore.Mvc.Routing.HttpMethodAttribute>()
|
|
.SelectMany(a => a.HttpMethods)
|
|
.ToList();
|
|
Assert.Contains("PUT", methods);
|
|
|
|
using var db = CreateDb();
|
|
var thread = ThreadWith(CallerId, OtherId);
|
|
db.Chats.Add(thread);
|
|
await db.SaveChangesAsync();
|
|
|
|
var controller = CreateController<ThreadAPI>(db);
|
|
SetJsonBody(controller, """{ "isFavorited": true }""");
|
|
Assert.IsType<OkObjectResult>(await controller.FavoriteThread(thread.chatThreadId));
|
|
|
|
var stored = await db.Chats.SingleAsync(x => x.chatThreadId == thread.chatThreadId);
|
|
Assert.Contains(CallerId, stored.GetFavoritedBy());
|
|
|
|
// The star has to show up on the read that renders the conversation, or the button
|
|
// never lights even though the write worked.
|
|
var read = await controller.GetThreadById(thread.chatThreadId);
|
|
using var doc = JsonDocument.Parse(JsonSerializer.Serialize(
|
|
Assert.IsType<OkObjectResult>(read).Value));
|
|
Assert.True(doc.RootElement.GetProperty("isFavorited").GetBoolean());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UnfavoritingClearsTheStarAndLeavesTheOtherMemberAlone()
|
|
{
|
|
using var db = CreateDb();
|
|
var thread = ThreadWith(CallerId, OtherId);
|
|
thread.SetFavoritedBy([CallerId, OtherId]);
|
|
db.Chats.Add(thread);
|
|
await db.SaveChangesAsync();
|
|
|
|
var controller = CreateController<ThreadAPI>(db);
|
|
controller.HttpContext.Request.Method = "DELETE";
|
|
Assert.IsType<OkObjectResult>(await controller.FavoriteThread(thread.chatThreadId));
|
|
|
|
var stored = await db.Chats.SingleAsync(x => x.chatThreadId == thread.chatThreadId);
|
|
Assert.DoesNotContain(CallerId, stored.GetFavoritedBy());
|
|
Assert.Contains(OtherId, stored.GetFavoritedBy());
|
|
}
|
|
|
|
// ---- POST /api/relationships/v1/ignore and /mute -> 400 --------------------------------
|
|
|
|
[Fact]
|
|
public async Task IgnoreAndMuteAcceptThePlayerIdFromTheBody()
|
|
{
|
|
using var db = CreateDb();
|
|
var controller = CreateController<RelationshipsV1>(db);
|
|
// The client posts the target in the body; the handler bound `id` from the route
|
|
// only, so this resolved to 0 and answered "invalid player id" (400).
|
|
SetJsonBody(controller, $$"""{ "playerId": {{OtherId}} }""");
|
|
|
|
Assert.IsType<OkObjectResult>(await controller.Ignore());
|
|
Assert.IsType<OkObjectResult>(await controller.Mute());
|
|
|
|
var mine = await db.Relationships.SingleAsync(x => x.AccountId == CallerId && x.PlayerId == OtherId);
|
|
Assert.NotEqual(ReciprocalStatus.None, mine.Ignored);
|
|
Assert.NotEqual(ReciprocalStatus.None, mine.Muted);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RelationshipFlagsStillReadTheIdFromTheRoute()
|
|
{
|
|
using var db = CreateDb();
|
|
var controller = CreateController<RelationshipsV1>(db);
|
|
// The `ignore/{id}` spelling binds the id into the action's own route data.
|
|
controller.ControllerContext.RouteData = new Microsoft.AspNetCore.Routing.RouteData();
|
|
controller.ControllerContext.RouteData.Values["id"] = OtherId.ToString();
|
|
|
|
Assert.IsType<OkObjectResult>(await controller.Ignore());
|
|
|
|
var mine = await db.Relationships.SingleAsync(x => x.AccountId == CallerId && x.PlayerId == OtherId);
|
|
Assert.NotEqual(ReciprocalStatus.None, mine.Ignored);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RelationshipFlagWithoutAnyIdIsStillRefused()
|
|
{
|
|
using var db = CreateDb();
|
|
var controller = CreateController<RelationshipsV1>(db);
|
|
Assert.IsType<BadRequestObjectResult>(await controller.Ignore());
|
|
}
|
|
|
|
// ---- PUT /clubs/club/1783155111/members/invite -> 400 ----------------------------------
|
|
|
|
[Fact]
|
|
public void NamedMembershipTypeIsAcceptedByTheInvite()
|
|
{
|
|
// The invite screen sends the tier by NAME ("Member"/"Moderator"/"Coowner").
|
|
// Reading only the number made the whole invite 400 with "Invalid membershipType"
|
|
// even though the account id was fine.
|
|
var parse = typeof(ClubsAPI).GetMethod("ParseInviteMembershipType",
|
|
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static)!;
|
|
|
|
Assert.Equal(ClubMembershipTypes.Member, (int)parse.Invoke(null, ["Member"])!);
|
|
Assert.Equal(ClubMembershipTypes.Moderator, (int)parse.Invoke(null, ["moderator"])!);
|
|
Assert.Equal(ClubMembershipTypes.Coowner, (int)parse.Invoke(null, ["CoOwner"])!);
|
|
|
|
// The numeric spelling still works, and anything unknown is still refused.
|
|
Assert.Equal(ClubMembershipTypes.Moderator, (int)parse.Invoke(null, ["20"])!);
|
|
Assert.Equal(-1, (int)parse.Invoke(null, ["Banned"])!);
|
|
}
|
|
|
|
// ---- POST /api/playerevents/v2/delete/1 -> 404 -----------------------------------------
|
|
|
|
[Fact]
|
|
public async Task EventDeleteReadsTheIdFromTheBodyWhenItIsNotInTheRoute()
|
|
{
|
|
using var db = CreateDb();
|
|
db.PlayerEvents.Add(new PlayerEventDB { PlayerEventId = 1, Name = "Doomed", CreatorPlayerId = CallerId });
|
|
await db.SaveChangesAsync();
|
|
|
|
var controller = CreateController<EventsAPI>(db);
|
|
// The delete screen posts the id in the body. With the route-only binding this
|
|
// resolved to 0 and 404ed for an event that existed.
|
|
SetJsonBody(controller, """{ "PlayerEventId": 1 }""");
|
|
|
|
Assert.IsType<OkObjectResult>(await controller.DeleteEventV2());
|
|
Assert.Empty(await db.PlayerEvents.ToListAsync());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task EventDeleteOfAMissingEventAnswersSuccessSoAStaleCardCanBeCleared()
|
|
{
|
|
// The client drops an event card only when the PlayerEventDeleted (82) frame reaches it,
|
|
// so a card left behind by a frame it could not read is retried as another delete.
|
|
// Answering that retry with the result envelope - rather than a 404 it can only render as
|
|
// an error - is what lets the card finally go away. An event that is somebody else's is
|
|
// still refused; see the test below.
|
|
using var db = CreateDb();
|
|
var controller = CreateController<EventsAPI>(db);
|
|
SetJsonBody(controller, """{ "PlayerEventId": 999 }""");
|
|
|
|
var result = Assert.IsType<OkObjectResult>(await controller.DeleteEventV2());
|
|
using var document = JsonDocument.Parse(JsonSerializer.Serialize(result.Value));
|
|
Assert.Equal(999, document.RootElement.GetProperty("PlayerEvent")
|
|
.GetProperty("PlayerEventId").GetInt64());
|
|
Assert.Equal(0, document.RootElement.GetProperty("Result").GetInt32());
|
|
Assert.Empty(await db.PlayerEvents.ToListAsync());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task EventDeleteStillRefusesSomeoneElsesEvent()
|
|
{
|
|
using var db = CreateDb();
|
|
db.PlayerEvents.Add(new PlayerEventDB { PlayerEventId = 1, Name = "Not yours", CreatorPlayerId = OtherId });
|
|
await db.SaveChangesAsync();
|
|
|
|
var controller = CreateController<EventsAPI>(db, CallerId);
|
|
Assert.IsType<ForbidResult>(await controller.DeleteEventV2(1));
|
|
}
|
|
|
|
// ---- PUT /accounts/account/me/emoji -> 404 --------------------------------------------
|
|
|
|
[Fact]
|
|
public async Task AccountEmojiRouteExistsAndPersists()
|
|
{
|
|
var methods = typeof(AccountsAPI).GetMethod(nameof(AccountsAPI.SetDisplayEmoji))!
|
|
.GetCustomAttributes(typeof(Microsoft.AspNetCore.Mvc.Routing.HttpMethodAttribute), true)
|
|
.Cast<Microsoft.AspNetCore.Mvc.Routing.HttpMethodAttribute>()
|
|
.SelectMany(a => a.HttpMethods)
|
|
.ToList();
|
|
Assert.Contains("PUT", methods);
|
|
|
|
using var db = CreateDb();
|
|
db.Profiles.Add(new Profile { accountId = CallerId, username = "tester", displayName = "Tester", profileImage = "" });
|
|
db.Players.Add(new PlayerData { AccountId = CallerId, Hashword = "x" });
|
|
await db.SaveChangesAsync();
|
|
|
|
var controller = CreateController<AccountsAPI>(db);
|
|
SetJsonBody(controller, """{ "displayEmoji": "😀" }""");
|
|
var answer = await controller.SetDisplayEmoji();
|
|
|
|
Assert.NotNull(answer);
|
|
Assert.Equal("😀", answer!.displayEmoji);
|
|
Assert.Equal("😀", (await db.Profiles.SingleAsync(x => x.accountId == CallerId)).displayEmoji);
|
|
|
|
// An empty value clears it rather than storing an empty string.
|
|
SetJsonBody(controller, """{ "displayEmoji": "" }""");
|
|
var cleared = await controller.SetDisplayEmoji();
|
|
Assert.Null(cleared!.displayEmoji);
|
|
Assert.Null((await db.Profiles.SingleAsync(x => x.accountId == CallerId)).displayEmoji);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AccountEmojiIsReportedOnTheAccountTheClientReads()
|
|
{
|
|
using var db = CreateDb();
|
|
db.Profiles.Add(new Profile { accountId = CallerId, username = "tester", displayName = "Tester", profileImage = "", displayEmoji = "🔥" });
|
|
db.Players.Add(new PlayerData { AccountId = CallerId, Hashword = "x" });
|
|
await db.SaveChangesAsync();
|
|
|
|
var controller = CreateController<AccountsAPI>(db);
|
|
var account = await controller.GetProfile();
|
|
Assert.Equal("🔥", account!.displayEmoji);
|
|
}
|
|
}
|