Files
2026-10-16 23:33:13 -07:00

318 lines
12 KiB
C#

using System.Reflection;
using System.Security.Claims;
using System.Text;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Migrations.Operations;
using Microsoft.Extensions.Logging.Abstractions;
using RadicadeServer.Admin;
using RadicadeServer.API.Compatibility;
using RadicadeServer.DB;
using RadicadeServer.Migrations;
using RadicadeServer.RecNet.Rooms;
using Xunit;
namespace RadicadeServer.Tests;
/// <summary>
/// Room comments (<c>POST roomcomments/comments/create/{roomId}</c>).
///
/// The route answered 200 and stored nothing - the client considers the comment posted, drops the
/// draft, and the comment is gone on reload. Silent, and indistinguishable from the room page
/// being broken. These cover the three things that make a stored comment real: the route the
/// client actually builds, a row with the caller as its author, and a table that a migration
/// creates.
/// </summary>
public class RoomCommentTests
{
private static RecDBContext NewDb() => new(
TestDbOptions.Create()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options);
private static CompatibilityAPI NewApi(RecDBContext db, long? callerId = 205)
{
var identity = new ClaimsIdentity("TestAuth");
if (callerId != null)
identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, callerId.Value.ToString()));
return new CompatibilityAPI(db)
{
ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(identity) }
}
};
}
private static void SetBody(ControllerBase controller, string body, string contentType)
{
var request = controller.ControllerContext.HttpContext.Request;
request.ContentType = contentType;
request.Body = new MemoryStream(Encoding.UTF8.GetBytes(body));
}
private static async Task<RecDBContext> WithRoomAsync(long roomId = 42)
{
var db = NewDb();
db.Rooms.Add(new RoomData
{
RoomId = roomId,
Name = "TestRoom",
CreatorAccountId = 1,
CreatedAt = DateTime.UtcNow
});
await db.SaveChangesAsync();
return db;
}
// -----------------------------------------------------------------------------------
// The route, and who is allowed to post
// -----------------------------------------------------------------------------------
[Fact]
public void TheRouteIsTheClientsAbsolutePathAndRequiresAUser()
{
var method = typeof(CompatibilityAPI).GetMethod(nameof(CompatibilityAPI.CreateRoomComment))!;
var template = Assert.Single(method.GetCustomAttributes<HttpPostAttribute>()).Template;
Assert.Equal("/roomcomments/comments/create/{roomId:long}", template);
// Absolute: a relative template would be concatenated onto the controller's own prefix and
// answer a path no client asks for, which is this project's most repeated failure mode.
Assert.StartsWith("/", template);
// A comment has to have an author, and the author comes from the token.
Assert.NotEmpty(method.GetCustomAttributes<AuthorizeAttribute>());
}
[Fact]
public async Task AnAnonymousCommentIsRefusedRatherThanStoredUnderSomeone()
{
using var db = await WithRoomAsync();
var api = NewApi(db, callerId: null);
SetBody(api, """{"Comment":"hi"}""", "application/json");
Assert.IsType<UnauthorizedObjectResult>(await api.CreateRoomComment(42));
Assert.Empty(db.RoomComments);
}
// -----------------------------------------------------------------------------------
// Storage
// -----------------------------------------------------------------------------------
[Fact]
public async Task AJSONCommentIsStoredWithTheCallerAsItsAuthor()
{
using var db = await WithRoomAsync();
var api = NewApi(db);
SetBody(api, """{"Comment":"great room!","CommentType":"Feedback"}""", "application/json");
Assert.IsType<OkObjectResult>(await api.CreateRoomComment(42));
var comment = await db.RoomComments.SingleAsync();
Assert.Equal(42, comment.RoomId);
Assert.Equal(205, comment.CreatorAccountId); // the caller, never a body field
Assert.Equal("great room!", comment.Comment);
Assert.Equal("Feedback", comment.CommentType);
Assert.NotEqual(default, comment.CreatedAt);
}
[Fact]
public async Task TheBodySpellingsTheClientMightUseAllLandInTheSameColumn()
{
using var db = await WithRoomAsync();
var api = NewApi(db);
// A single-field DTO serializes to a bare JSON string, which is not an object at all.
SetBody(api, "\"bare string body\"", "application/json");
Assert.IsType<OkObjectResult>(await api.CreateRoomComment(42));
Assert.Equal("bare string body", (await db.RoomComments.SingleAsync()).Comment);
}
[Fact]
public async Task AFormPostIsStoredToo()
{
using var db = await WithRoomAsync();
var api = NewApi(db);
SetBody(api, "Comment=form+comment&commentType=Report", "application/x-www-form-urlencoded");
Assert.IsType<OkObjectResult>(await api.CreateRoomComment(42));
var comment = await db.RoomComments.SingleAsync();
Assert.Equal("form comment", comment.Comment);
Assert.Equal("Report", comment.CommentType);
}
[Fact]
public async Task ACommentSentInTheQueryStringIsStored()
{
using var db = await WithRoomAsync();
var api = NewApi(db);
// The client's own call carries the comment as a query parameter and posts no body at all,
// which is why a real comment came back as "rejected: empty body" - only the body was read.
api.ControllerContext.HttpContext.Request.QueryString = new QueryString("?comment=query%20comment");
Assert.IsType<OkObjectResult>(await api.CreateRoomComment(42));
Assert.Equal("query comment", (await db.RoomComments.SingleAsync()).Comment);
}
[Fact]
public async Task AQueryStringCommentStillTakesItsTypeFromTheForm()
{
using var db = await WithRoomAsync();
var api = NewApi(db);
// Both carriers at once: whichever one names the text, the type must not be lost.
api.ControllerContext.HttpContext.Request.QueryString = new QueryString("?comment=mixed");
SetBody(api, "commentType=Report", "application/x-www-form-urlencoded");
Assert.IsType<OkObjectResult>(await api.CreateRoomComment(42));
var comment = await db.RoomComments.SingleAsync();
Assert.Equal("mixed", comment.Comment);
Assert.Equal("Report", comment.CommentType);
}
[Fact]
public async Task AnEmptyCommentIsRefusedSoTheClientKeepsTheDraft()
{
using var db = await WithRoomAsync();
var api = NewApi(db);
SetBody(api, """{"Comment":" "}""", "application/json");
// Non-2xx on purpose: the client keeps what the player typed instead of dropping it.
Assert.IsType<BadRequestObjectResult>(await api.CreateRoomComment(42));
Assert.Empty(db.RoomComments);
}
[Fact]
public async Task ACommentWithNoTypeFallsBackToTheClientsOwnDefault()
{
using var db = await WithRoomAsync();
var api = NewApi(db);
SetBody(api, """{"Comment":"no type"}""", "application/json");
Assert.IsType<OkObjectResult>(await api.CreateRoomComment(42));
Assert.Equal("Feedback", (await db.RoomComments.SingleAsync()).CommentType);
}
[Fact]
public async Task ThePositionIsKeptWhenTheClientSendsOneAndNullWhenItDoesNot()
{
using var db = await WithRoomAsync();
var withPosition = NewApi(db);
SetBody(withPosition, """{"Comment":"here","Position":{"x":1.5,"y":2,"z":-3}}""", "application/json");
Assert.IsType<OkObjectResult>(await withPosition.CreateRoomComment(42));
var withoutPosition = NewApi(db);
SetBody(withoutPosition, """{"Comment":"anywhere"}""", "application/json");
Assert.IsType<OkObjectResult>(await withoutPosition.CreateRoomComment(42));
var rows = await db.RoomComments.OrderBy(c => c.Id).ToListAsync();
Assert.Equal(1.5f, rows[0].PositionX);
Assert.Equal(2f, rows[0].PositionY);
Assert.Equal(-3f, rows[0].PositionZ);
Assert.Null(rows[1].PositionX);
}
// -----------------------------------------------------------------------------------
// The table itself
// -----------------------------------------------------------------------------------
[Fact]
public void TheCommentsTableIsInTheModelAndAMigrationCreatesIt()
{
using var db = NewDb();
Assert.NotNull(db.RoomComments);
// A DbSet with no migration is a column the running database never gets: the code compiles,
// the query works in memory, and every insert fails on the server. Migrate() runs Up(), so
// running it here is the same statement the server will execute.
// Up() is protected (EF calls it itself), so it is invoked the way the framework does.
var up = typeof(AddRoomComments).GetMethod("Up", BindingFlags.NonPublic | BindingFlags.Instance)!;
var builder = new MigrationBuilder("Microsoft.EntityFrameworkCore.SqlServer");
up.Invoke(new AddRoomComments(), new object[] { builder });
var statements = builder.Operations
.OfType<SqlOperation>()
.Select(op => op.Sql)
.ToArray();
Assert.NotEmpty(statements);
Assert.Contains(statements, op => op.Contains("CREATE TABLE", StringComparison.Ordinal));
Assert.Contains(statements, op => op.Contains("[dbo].[RoomComments]", StringComparison.Ordinal));
// Guarded, like every other hand-written migration here: a second run over a database that
// already has the table is a no-op instead of a startup crash.
Assert.Contains(statements, op => op.Contains("OBJECT_ID", StringComparison.Ordinal));
}
// -----------------------------------------------------------------------------------
// The admin panel side
// -----------------------------------------------------------------------------------
// The panel's routes reject an unauthenticated caller - the branch's admin sign-in
// guards every one of them with AdminUtils.IsAdminKey - so these tests act as a
// signed-in operator by presenting the key the server has configured.
private const string TestAdminKey = "test-admin-key";
private static async Task<TResult> AsAdminAsync<TResult>(Func<Task<TResult>> call)
{
var original = DBConfig.AdminApiKey;
DBConfig.AdminApiKey = TestAdminKey;
try
{
return await call();
}
finally
{
DBConfig.AdminApiKey = original;
}
}
[Fact]
public async Task ThePanelListsWhatPlayersActuallyWrote()
{
using var db = await WithRoomAsync();
var api = NewApi(db);
SetBody(api, """{"Comment":"panel should see this"}""", "application/json");
Assert.IsType<OkObjectResult>(await api.CreateRoomComment(42));
var panel = new AdminPanelAPI(db);
var ok = Assert.IsType<OkObjectResult>(await AsAdminAsync(() => panel.List("room_comments", TestAdminKey, null, null, null, true)));
var json = System.Text.Json.JsonSerializer.SerializeToElement(ok.Value);
var row = Assert.Single(json.EnumerateArray());
Assert.Equal("panel should see this", row.GetProperty("comment").GetString());
Assert.Equal(42, row.GetProperty("room_id").GetInt64());
Assert.Equal(205, row.GetProperty("creator_account_id").GetInt64());
}
[Fact]
public async Task ThePanelCanDeleteAComment()
{
using var db = await WithRoomAsync();
var api = NewApi(db);
SetBody(api, """{"Comment":"delete me"}""", "application/json");
Assert.IsType<OkObjectResult>(await api.CreateRoomComment(42));
var id = (await db.RoomComments.SingleAsync()).Id;
var panel = new AdminPanelAPI(db);
Assert.IsType<OkObjectResult>(await AsAdminAsync(() => panel.Delete("room_comments", TestAdminKey, null, id.ToString(), "id", id.ToString(), null, null, null)));
Assert.Empty(db.RoomComments);
}
}