mirror of
https://github.com/JogmansonReal/Radicade.git
synced 2026-10-26 07:29:00 -07:00
Radicade Public Code
This commit is contained in:
@@ -0,0 +1,298 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user