Bifrost/tests/Bifrost.Web.Tests/DeskAnswerTests.cs

295 lines
14 KiB
C#

using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using Bifrost.Web.Features.Desk;
using Bifrost.Web.Tests.Support;
namespace Bifrost.Web.Tests;
public sealed class DeskAnswerTests
{
private static readonly string Socket = DeskWebApplicationFactory.SocketOf("gamecult-chat");
private static async Task<(DeskWebApplicationFactory Factory, string Token)> ArrangeAsync()
{
var factory = new DeskWebApplicationFactory();
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true);
return (factory, factory.Token(DeskWebApplicationFactory.Alice, "bifrost-desk", "desk.answer"));
}
private static AnswerResult Stale(string forkId) => new(
DeskSchemas.AnswerResult,
new DeskAnswerDocument(
DeskSchemas.DeskAnswer, "n1", forkId, "Refused", null, null, "stale_fork", "oldhash", "no", null,
"2026-11-12T19:02:00Z", "2026-11-12T19:02:05Z", false, DeskWebApplicationFactory.Alice, "Operator",
"00000001-0000-4000-8000-000000000001"),
null,
null);
[Fact]
public async Task Stale_fork_attaches_current_card_fresh_and_replayed()
{
var (factory, token) = await ArrangeAsync();
using (factory)
{
factory.Door.AnswerScript = (_, _, _) => new DeskAnswerOutcome.Answered(Stale(DeskReplay.ChatFork));
var target = DeskReplay.Target("answer", "gamecult-chat", null);
factory.Door.Forks[(Socket, DeskReplay.ChatFork)] = DeskReplay.Entry(DeskReplay.Card(DeskReplay.ChatFork, "first card"));
var fresh = await factory.SendAsync("POST", target, DeskReplay.AnswerBody(DeskReplay.ChatFork), token);
// The same stored answer is replayed after the card changed: nothing is stored for the card, so the reply carries the new one.
factory.Door.Forks[(Socket, DeskReplay.ChatFork)] = DeskReplay.Entry(DeskReplay.Card(DeskReplay.ChatFork, "second card"));
var replayed = await factory.SendAsync("POST", target, DeskReplay.AnswerBody(DeskReplay.ChatFork), token);
Assert.Equal(409, fresh.Status);
Assert.Equal("first card", (string?)JsonNode.Parse(fresh.Body)!["card"]!["title"]);
Assert.Equal(409, replayed.Status);
Assert.Equal("second card", (string?)JsonNode.Parse(replayed.Body)!["card"]!["title"]);
}
}
[Fact]
public async Task A_refusal_that_is_not_stale_carries_no_card_and_asks_no_read()
{
var (factory, token) = await ArrangeAsync();
using (factory)
{
var closed = Stale(DeskReplay.ChatFork);
factory.Door.AnswerScript = (_, _, _) => new DeskAnswerOutcome.Answered(closed with { Answer = closed.Answer with { Code = "fork_closed" } });
var reply = await factory.SendAsync("POST", DeskReplay.Target("answer", "gamecult-chat", null), DeskReplay.AnswerBody(DeskReplay.ChatFork), token);
Assert.Equal(409, reply.Status);
Assert.Null(JsonNode.Parse(reply.Body)!["card"]);
Assert.DoesNotContain(factory.Door.Calls, c => c.Kind == "read");
}
}
[Fact]
public async Task A_stale_answers_card_read_of_an_oversize_fork_is_422_fork_too_large_naming_the_fork()
{
var (factory, token) = await ArrangeAsync();
using (factory)
{
factory.Door.AnswerScript = (_, _, _) => new DeskAnswerOutcome.Answered(Stale(DeskReplay.ChatFork));
factory.Door.ReadScript = (_, id) => new DeskReadOutcome.ForkTooLarge(id);
var reply = await factory.SendAsync("POST", DeskReplay.Target("answer", "gamecult-chat", null), DeskReplay.AnswerBody(DeskReplay.ChatFork), token);
var doc = JsonNode.Parse(reply.Body)!;
Assert.Equal(422, reply.Status);
Assert.Equal("fork_too_large", (string?)doc["code"]);
Assert.Contains(DeskReplay.ChatFork, (string)doc["error"]!);
Assert.Null(doc["card"]);
}
}
[Fact]
public async Task A_replayed_request_id_that_is_stale_again_takes_the_same_reread_and_gets_the_same_422()
{
var (factory, token) = await ArrangeAsync();
using (factory)
{
// The door's stored Refused(stale_fork) for this request_id, returned on every replay while the fork stays oversize.
factory.Door.AnswerScript = (_, _, _) => new DeskAnswerOutcome.Answered(Stale(DeskReplay.ChatFork));
factory.Door.ReadScript = (_, id) => new DeskReadOutcome.ForkTooLarge(id);
var body = DeskReplay.AnswerBody(DeskReplay.ChatFork);
var target = DeskReplay.Target("answer", "gamecult-chat", null);
var first = await factory.SendAsync("POST", target, body, token);
var replay = await factory.SendAsync("POST", target, body, token);
Assert.Equal(422, first.Status);
Assert.Equal(first.Status, replay.Status);
Assert.Equal(first.Body, replay.Body);
Assert.Equal("fork_too_large", (string?)JsonNode.Parse(replay.Body)!["code"]);
Assert.Contains(DeskReplay.ChatFork, (string)JsonNode.Parse(replay.Body)!["error"]!);
Assert.Equal(2, factory.Door.Calls.Count(c => c.Kind == "answer"));
Assert.Equal(2, factory.Door.Calls.Count(c => c.Kind == "read"));
}
}
[Fact]
public async Task A_stale_answer_over_an_oversize_fork_follows_the_vector_fresh_and_replayed()
{
var rows = DeskVectors.Rows("transport", "answerTooLarge").ToList();
Assert.Single(rows);
foreach (var row in rows)
{
var (factory, token) = await ArrangeAsync();
using (factory)
{
var request = row["request"]!;
var forkId = (string)row["fork_id"]!;
Assert.Equal(forkId, (string)request["fork_id"]!);
var stale = new AnswerResult(
DeskSchemas.AnswerResult,
new DeskAnswerDocument(
DeskSchemas.DeskAnswer, "n1", forkId, (string)row["outcome"]!, null, null, (string)row["code"]!,
(string)request["options_hash"]!, (string?)request["answer"]!["choice"], (string?)request["answer"]!["note"],
(string)request["answered_at"]!, "2026-11-12T19:02:05Z", false, DeskWebApplicationFactory.Alice, "Operator",
(string)request["request_id"]!),
null,
null);
factory.Door.AnswerScript = (_, _, _) => new DeskAnswerOutcome.Answered(stale);
factory.Door.ReadScript = (_, id) => new DeskReadOutcome.ForkTooLarge(id);
var body = DeskVectors.Bytes(request);
var target = DeskReplay.Target("answer", "gamecult-chat", null);
var fresh = await factory.SendAsync("POST", target, body, token);
var replay = await factory.SendAsync("POST", target, body, token);
foreach (var reply in new[] { fresh, replay })
{
Assert.Equal((int)row["httpStatus"]!, reply.Status);
var doc = JsonNode.Parse(reply.Body);
Assert.True(DeskVectors.SameRefusal(doc, row["refusal"], forkId, null), (string)row["name"]!);
Assert.Null(doc!["card"]);
}
Assert.Equal(fresh.Body, replay.Body);
// The door got the client's bytes untouched, stored nothing counted, and was asked for the card twice.
Assert.All(factory.Door.Calls.Where(c => c.Kind == "answer"), c => Assert.Equal(body, c.Body));
Assert.Equal(forkId, factory.Door.Calls.First(c => c.Kind == "read").ForkId);
Assert.False((bool)row["counted"]!);
Assert.NotEqual("Counted", stale.Answer.Outcome);
}
}
}
[Fact]
public async Task Card_read_failure_is_unavailable()
{
foreach (var failure in new[] { "throws", "refuses" })
{
var (factory, token) = await ArrangeAsync();
using (factory)
{
factory.Door.AnswerScript = (_, _, _) => new DeskAnswerOutcome.Answered(Stale(DeskReplay.ChatFork));
factory.Door.ThrowOnRead = failure == "throws";
// "refuses": no fork held, so the read answers fork_unknown
var reply = await factory.SendAsync("POST", DeskReplay.Target("answer", "gamecult-chat", null), DeskReplay.AnswerBody(DeskReplay.ChatFork), token);
Assert.True(503 == reply.Status, failure);
Assert.Equal("unavailable", (string?)JsonNode.Parse(reply.Body)!["code"]);
}
}
}
[Fact]
public async Task The_body_reaches_the_door_byte_for_byte()
{
var (factory, token) = await ArrangeAsync();
using (factory)
{
DeskReplay.HoldVectorForks(factory.Door);
// Whitespace, escapes and key spacing a re-serialiser would normalise.
var compact = Encoding.UTF8.GetString(DeskReplay.AnswerBody(DeskReplay.ChatFork));
var odd = compact.Replace("{", "{ \n\t", StringComparison.Ordinal).Replace(",\"", " ,\r\n \"", StringComparison.Ordinal)
.Replace("Keep", "\\u004beep", StringComparison.Ordinal) + " \n";
var bytes = Encoding.UTF8.GetBytes(odd);
var reply = await factory.SendAsync("POST", DeskReplay.Target("answer", "gamecult-chat", null), bytes, token);
Assert.Equal(200, reply.Status);
Assert.Equal(bytes, Assert.Single(factory.Door.Calls).Body);
}
}
[Fact]
public async Task The_door_context_comes_from_the_account_and_the_configuration_never_the_request()
{
var (factory, _) = await ArrangeAsync();
using (factory)
{
DeskReplay.HoldVectorForks(factory.Door);
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Steward, true);
var token = factory.Token(DeskWebApplicationFactory.Steward.ToUpperInvariant(), "bifrost-desk", "desk.answer");
// Query strings and headers that name a decider or an answerer change nothing.
var reply = await factory.SendAsync(
"POST", DeskReplay.Target("answer", "nibu", null) + "?deciders=" + DeskWebApplicationFactory.Member + "&answerer=x",
DeskReplay.AnswerBody("nibu:question:glow-order"), token,
configure: http => http.Request.Headers["X-Deciders"] = DeskWebApplicationFactory.Member);
Assert.Equal(200, reply.Status);
var call = Assert.Single(factory.Door.Calls);
Assert.Equal(DeskWebApplicationFactory.Steward, call.Context.Subject);
Assert.Equal("Steward", call.Context.Authority);
Assert.Equal("nibu", call.Context.Mind);
Assert.Equal(DeskWebApplicationFactory.DecidersOf("nibu"), call.Context.Deciders);
}
}
[Fact]
public async Task Body_is_judged_after_membership_and_before_the_mind()
{
var (factory, token) = await ArrangeAsync();
using (factory)
{
// A bad body for an unknown Mind is the body's refusal; a non-Active member's bad body is forbidden.
var bad = Encoding.UTF8.GetBytes("{\"answerer\":\"x\"}");
var reply = await factory.SendAsync("POST", DeskReplay.Target("answer", "ghost", null), bad, token);
Assert.Equal("body_forbidden_field", (string?)JsonNode.Parse(reply.Body)!["code"]);
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, false);
var forbidden = await factory.SendAsync("POST", DeskReplay.Target("answer", "ghost", null), bad, token);
Assert.Equal("forbidden", (string?)JsonNode.Parse(forbidden.Body)!["code"]);
Assert.Empty(factory.Door.Calls);
}
}
[Fact]
public async Task Membership_is_re_read_on_every_request()
{
var (factory, token) = await ArrangeAsync();
using (factory)
{
DeskReplay.HoldVectorForks(factory.Door);
var target = DeskReplay.Target("answer", "gamecult-chat", null);
Assert.Equal(200, (await factory.SendAsync("POST", target, DeskReplay.AnswerBody(DeskReplay.ChatFork), token)).Status);
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, false);
Assert.Equal(403, (await factory.SendAsync("POST", target, DeskReplay.AnswerBody(DeskReplay.ChatFork), token)).Status);
}
}
[Fact]
public async Task An_oversized_body_is_refused_before_it_is_read()
{
var (factory, token) = await ArrangeAsync();
using (factory)
{
var big = Encoding.UTF8.GetBytes("{\"x\":\"" + new string('a', 17 * 1024) + "\"}");
var reply = await factory.SendAsync("POST", DeskReplay.Target("answer", "gamecult-chat", null), big, token);
Assert.Equal("body_invalid", (string?)JsonNode.Parse(reply.Body)!["code"]);
// Chunked: no Content-Length to judge, so the cap applies while reading.
var chunked = await factory.SendAsync(
"POST", DeskReplay.Target("answer", "gamecult-chat", null), big, token,
configure: http => http.Request.ContentLength = null);
Assert.Equal("body_invalid", (string?)JsonNode.Parse(chunked.Body)!["code"]);
Assert.Empty(factory.Door.Calls);
}
}
[Fact]
public async Task A_door_that_sends_a_code_the_contract_does_not_name_is_unavailable()
{
var (factory, token) = await ArrangeAsync();
using (factory)
{
foreach (var script in new Func<DeskAnswerOutcome>[]
{
() => new DeskAnswerOutcome.NotStored("something_internal"),
() => new DeskAnswerOutcome.NotStored("forbidden"),
() => new DeskAnswerOutcome.Answered(Stale("x") with { Answer = Stale("x").Answer with { Code = "made_up" } }),
})
{
factory.Door.AnswerScript = (_, _, _) => script();
var reply = await factory.SendAsync("POST", DeskReplay.Target("answer", "gamecult-chat", null), DeskReplay.AnswerBody(DeskReplay.ChatFork), token);
Assert.Equal(503, reply.Status);
}
}
}
}