mirror of
https://github.com/JogmansonReal/Radicade.git
synced 2026-10-25 15:09:26 -07:00
318 lines
12 KiB
C#
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);
|
|
}
|
|
}
|