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