Bifrost/tests/Bifrost.Web.Tests/UnixSocketDeskDoorStrictTests.cs
Heimdall e561170a0d Desk door: strict reply decoding (control characters, closed typed maps, duplicate keys, strict base64, no floats or binary)
MessagePack is walked into JSON by the reader, so every control character is escaped and keys, order and
integers survive. Every contract map (list, entry, record, answer, refusal) is closed and typed; list.mind must
equal the call's Mind; a key written twice anywhere, a float, a binary, trailing bytes, or non-canonical base64
is a DeskDoorException, as is every malformed variant (null elements, non-map values).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 18:25:28 +00:00

281 lines
16 KiB
C#

using System.Text.Json;
using System.Text.Json.Nodes;
using Bifrost.Web.Features.Desk;
using Bifrost.Web.Tests.Support;
using MessagePack;
namespace Bifrost.Web.Tests;
// The door reads Huginn's reply strictly: text survives whatever it holds, every contract map is closed and typed,
// a key written twice or a float is a failure, and every malformed reply is a DeskDoorException (never a null
// reference, never an invalid-operation).
public sealed class UnixSocketDeskDoorStrictTests
{
private static readonly DeskDoorContext Context = new("eureka", "Member", "5f0c2b1e-8a4d-4c3b-9e7f-1a2b3c4d5e6f", ["5f0c2b1e-8a4d-4c3b-9e7f-1a2b3c4d5e6f", "0b9d7e3a-6c21-4f58-a0d4-9e8f7a6b5c4d"]);
private const string Card = """{"schemaVersion":"gamecult.fork.card.v0","fork_id":"x:question:a","title":"t","order":1,"options_hash":"ab"}""";
private static string AnswerJson(string note = "null", string answerer = "\"a1\"") =>
$$$"""{"schemaVersion":"gamecult.desk_answer.v0","answer_id":"n1","fork_id":"x:question:a","outcome":"Counted","of":null,"retracts":null,"code":null,"options_hash":"ab","choice":"a","note":{{{note}}},"answered_at":"2026-11-11T09:00:00Z","received_at":"2026-11-11T09:00:01Z","claim_in_future":false,"answerer":{{{answerer}}},"authority":"Member","request_id":"r"}""";
private static string RecordJson(string answers = "[]", string extra = "") =>
$$$"""{"schemaVersion":"gamecult.fork.record.v0","fork_id":"x:question:a","options_hash":"ab","counted_answer_id":null,"answerer_count":0,"answers":{{{answers}}}{{{extra}}}}""";
private static string EntryJson(string card = Card, string? record = null, string extra = "") =>
$$$"""{"schemaVersion":"gamecult.fork.entry.v0","card":{{{card}}},"record":{{{record ?? RecordJson()}}}{{{extra}}}}""";
private static string ListJson(string forks, string mind = "\"eureka\"", string keys = "\"open_count\":1,\"recorded_count\":0,") =>
$$$"""{"schemaVersion":"gamecult.fork.list.v0","mind":{{{mind}}},"stamp":"s1",{{{keys}}}"forks":{{{forks}}},"next_cursor":null}""";
private static string Forks(string list) => $$$"""{"Forks":{"list":{{{list}}},"as_of":1}}""";
private static Task<DeskListOutcome> List(FakeHuginnSocket huginn) =>
new UnixSocketDeskDoor(() => "m-1").ListAsync(huginn.Path, Context, new DeskListRequest("eureka", "open", null, 50), CancellationToken.None);
private static Task<DeskReadOutcome> Read(FakeHuginnSocket huginn) =>
new UnixSocketDeskDoor(() => "m-1").ReadAsync(huginn.Path, Context, "x:question:a", CancellationToken.None);
private static FakeHuginnSocket Replying(string variantJson, string status = "accepted") =>
new(FakeHuginnSocket.Answer((_, _) => variantJson, status));
private static FakeHuginnSocket ReplyingBytes(byte[] payload, Action<List<(string Key, string Value)>>? mutate = null, byte[]? trailing = null, string operation = "forks") =>
new(FakeHuginnSocket.Build((id, _) => DeskWire.ResponseFrame(id, operation, "accepted", payload, mutate, trailing)));
private static async Task Faults(Func<Task> call) => await Assert.ThrowsAsync<DeskDoorException>(call);
// ---- F1: control characters in text ----
[Fact]
public async Task Text_with_control_characters_survives_the_door_and_the_edge()
{
const string Note = "n\\u0000\\u0007\\u000b\\u001b\\u001f\\u0008\\t\\n\\\"\\\\ end";
var card = Card.Replace("\"t\"", "\"title \\u001b\\u0000\"", StringComparison.Ordinal);
var entry = EntryJson(card, RecordJson($"[{AnswerJson($"\"{Note}\"")}]").Replace("\"answerer_count\":0", "\"answerer_count\":1", StringComparison.Ordinal));
using var huginn = Replying(Forks(ListJson($"[{entry}]")));
var listed = Assert.IsType<DeskListOutcome.Listed>(await List(huginn));
var expected = "n\0\a\v\u001b\u001f\b\t\n\"\\ end";
Assert.Equal(expected, Assert.Single(Assert.Single(listed.List.Forks).Record.Answers).Note);
Assert.Equal("title \u001b\0", Assert.Single(listed.List.Forks).Card.GetProperty("title").GetString());
// The edge writes it back as JSON a client can parse, the decider's note whole.
using var chat = Replying(Forks(ListJson($"[{entry}]", "\"gamecult-chat\"")));
var config = DeskWebApplicationFactory.DefaultConfiguration();
foreach (var key in config.Keys.Where(k => k.EndsWith(":Id", StringComparison.Ordinal)).ToList())
{
config[key[..^2] + "Socket"] = config[key] == "nibu" ? FakeHuginnSocket.Absent() : chat.Path;
}
using var factory = new DeskWebApplicationFactory(config, fakeDoor: false);
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true);
var reply = await factory.SendAsync("GET", "/api/desk/gamecult-chat/forks", token: factory.Token(DeskWebApplicationFactory.Alice, "bifrost-desk", "desk.read"));
Assert.Equal(200, reply.Status);
Assert.Equal(expected, (string?)JsonNode.Parse(reply.Text)!["forks"]![0]!["record"]!["answers"]![0]!["note"]);
}
// ---- F2 / F3: strict documents ----
public static TheoryData<string, string> MalformedLists()
{
var one = EntryJson();
return new TheoryData<string, string>
{
{ "missing open_count", Forks(ListJson($"[{one}]", keys: "\"recorded_count\":0,")) },
{ "missing recorded_count", Forks(ListJson($"[{one}]", keys: "\"open_count\":1,")) },
{ "missing both counts", Forks(ListJson($"[{one}]", keys: "")) },
{ "negative count", Forks(ListJson($"[{one}]", keys: "\"open_count\":-1,\"recorded_count\":0,")) },
{ "mind is another Mind's", Forks(ListJson($"[{one}]", "\"nibu\"")) },
{ "mind is not text", Forks(ListJson($"[{one}]", "7")) },
{ "missing next_cursor", Forks(ListJson($"[{one}]").Replace(",\"next_cursor\":null", "", StringComparison.Ordinal)) },
{ "unknown list key", Forks(ListJson($"[{one}]").Replace("\"stamp\"", "\"extra\":1,\"stamp\"", StringComparison.Ordinal)) },
{ "forks is null", Forks(ListJson("null")) },
{ "forks:[null]", Forks(ListJson("[null]")) },
{ "forks:[5]", Forks(ListJson("[5]")) },
{ "card is not a map", Forks(ListJson($"[{EntryJson("[]")}]")) },
{ "card is null", Forks(ListJson($"[{EntryJson("null")}]")) },
{ "record missing schemaVersion", Forks(ListJson($"[{EntryJson(record: RecordJson().Replace("\"schemaVersion\":\"gamecult.fork.record.v0\",", "", StringComparison.Ordinal))}]")) },
{ "record missing fork_id", Forks(ListJson($"[{EntryJson(record: RecordJson().Replace("\"fork_id\":\"x:question:a\",", "", StringComparison.Ordinal))}]")) },
{ "record missing options_hash", Forks(ListJson($"[{EntryJson(record: RecordJson().Replace("\"options_hash\":\"ab\",", "", StringComparison.Ordinal))}]")) },
{ "record missing answerer_count", Forks(ListJson($"[{EntryJson(record: RecordJson().Replace("\"answerer_count\":0,", "", StringComparison.Ordinal))}]")) },
{ "record answerer_count is a string", Forks(ListJson($"[{EntryJson(record: RecordJson().Replace("\"answerer_count\":0", "\"answerer_count\":\"0\"", StringComparison.Ordinal))}]")) },
{ "answers:[null]", Forks(ListJson($"[{EntryJson(record: RecordJson("[null]"))}]")) },
{ "answers is null", Forks(ListJson($"[{EntryJson(record: RecordJson("null"))}]")) },
{ "answer with only schemaVersion", Forks(ListJson($"[{EntryJson(record: RecordJson("[{\"schemaVersion\":\"gamecult.desk_answer.v0\"}]"))}]")) },
{ "answer missing answerer", Forks(ListJson($"[{EntryJson(record: RecordJson($"[{AnswerJson().Replace(",\"answerer\":\"a1\"", "", StringComparison.Ordinal)}]"))}]")) },
{ "answer claim_in_future is text", Forks(ListJson($"[{EntryJson(record: RecordJson($"[{AnswerJson().Replace("\"claim_in_future\":false", "\"claim_in_future\":\"no\"", StringComparison.Ordinal)}]"))}]")) },
{ "unknown record key", Forks(ListJson($"[{EntryJson(record: RecordJson(extra: ",\"viewer\":1"))}]")) },
{ "unknown entry key", Forks(ListJson($"[{EntryJson(extra: ",\"viewer\":1")}]")) },
{ "unknown answer key", Forks(ListJson($"[{EntryJson(record: RecordJson($"[{AnswerJson().TrimEnd('}')},\"viewer\":1}}]"))}]")) },
{ "duplicate key in the record", Forks(ListJson($"[{EntryJson(record: RecordJson(extra: ",\"fork_id\":\"other\""))}]")) },
{ "duplicate key in the card", Forks(ListJson($"[{EntryJson(Card.TrimEnd('}') + ",\"title\":\"again\"}")}]")) },
{ "duplicate key in the list", Forks(ListJson($"[{one}]").TrimEnd('}') + ",\"stamp\":\"s2\"}") },
{ "float in the card", Forks(ListJson($"[{EntryJson(Card.TrimEnd('}') + ",\"weight\":1.0}")}]")) },
{ "float in the record", Forks(ListJson($"[{EntryJson(record: RecordJson(extra: ",\"weight\":1.5"))}]")) },
{ "Forks value is a bare number", """{"Forks":5}""" },
{ "Forks value is null", """{"Forks":null}""" },
{ "Forks without list", """{"Forks":{"as_of":1}}""" },
{ "NotStored is text", """{"NotStored":"x"}""" },
{ "NotStored is null", """{"NotStored":null}""" },
{ "NotStored refusal has an extra key", """{"NotStored":{"schemaVersion":"gamecult.fork.refusal.v0","code":"fork_unknown","error":"x","z":1}}""" },
};
}
[Theory]
[MemberData(nameof(MalformedLists))]
public async Task A_malformed_list_reply_is_a_door_failure(string label, string reply)
{
_ = label;
using var huginn = Replying(reply, reply.StartsWith("{\"NotStored", StringComparison.Ordinal) ? "rejected" : "accepted");
await Faults(() => List(huginn));
}
public static TheoryData<string, string> MalformedReads() => new()
{
{ "Fork is a number", """{"Fork":5}""" },
{ "Fork is null", """{"Fork":null}""" },
{ "Fork without entry", """{"Fork":{"as_of":1}}""" },
{ "entry holds answers:[null]", $$$"""{"Fork":{"entry":{{{EntryJson(record: RecordJson("[null]"))}}},"as_of":1}}""" },
{ "entry with a nil card", $$$"""{"Fork":{"entry":{{{EntryJson("null")}}},"as_of":1}}""" },
};
[Theory]
[MemberData(nameof(MalformedReads))]
public async Task A_malformed_read_reply_is_a_door_failure(string label, string reply)
{
_ = label;
using var huginn = Replying(reply);
await Faults(() => Read(huginn));
}
[Fact]
public async Task Answered_with_only_a_schemaVersion_or_a_missing_counted_id_is_a_failure()
{
foreach (var reply in new[]
{
"""{"Answered":{"answer":{"schemaVersion":"gamecult.desk_answer.v0"},"counted_answer_id":null,"as_of":1}}""",
"""{"Answered":{"answer":null,"counted_answer_id":null,"as_of":1}}""",
"""{"Answered":5}""",
$$$"""{"Answered":{"answer":{{{AnswerJson()}}},"as_of":1}}""",
})
{
using var huginn = Replying(reply);
await Faults(() => new UnixSocketDeskDoor().AnswerAsync(huginn.Path, Context, DeskReplay.AnswerBody("x:question:a"), CancellationToken.None));
}
}
[Fact]
public async Task fork_too_large_is_never_an_answer_refusal()
{
using var huginn = Replying("""{"NotStored":{"schemaVersion":"gamecult.fork.refusal.v0","code":"fork_too_large","error":"fork_too_large: fork x:question:a is past the response bound by itself"}}""", "rejected");
await Faults(() => new UnixSocketDeskDoor().AnswerAsync(huginn.Path, Context, DeskReplay.AnswerBody("x:question:a"), CancellationToken.None));
}
[Fact]
public async Task A_nil_answerer_and_big_integers_come_through_whole()
{
var card = Card.TrimEnd('}') + ",\"big\":18446744073709551615,\"low\":-9223372036854775808,\"n\":null}";
var entry = EntryJson(card, RecordJson($"[{AnswerJson(answerer: "null")}]").Replace("\"answerer_count\":0", "\"answerer_count\":1", StringComparison.Ordinal));
using var huginn = Replying(Forks(ListJson($"[{entry}]")));
var listed = Assert.IsType<DeskListOutcome.Listed>(await List(huginn));
var fork = Assert.Single(listed.List.Forks);
Assert.Equal(card, JsonSerializer.Serialize(fork.Card, DeskJson.Options));
Assert.Null(Assert.Single(fork.Record.Answers).Answerer);
}
// ---- F4 / the survivors: the envelope and the bytes around it ----
private static byte[] ListPayload() => MessagePackSerializer.ConvertFromJson(Forks(ListJson($"[{EntryJson()}]")));
[Fact]
public async Task A_well_formed_reply_built_the_same_way_decodes()
{
using var huginn = ReplyingBytes(ListPayload());
Assert.IsType<DeskListOutcome.Listed>(await List(huginn));
}
[Fact]
public async Task Payload_base64_must_be_strict_standard_base64()
{
foreach (var mutate in new Action<List<(string Key, string Value)>>[]
{
fields => fields[fields.FindIndex(f => f.Key == "payload")] = ("payload", Insert(fields, "\n")),
fields => fields[fields.FindIndex(f => f.Key == "payload")] = ("payload", Insert(fields, " ")),
fields => fields[fields.FindIndex(f => f.Key == "payload")] = ("payload", fields.First(f => f.Key == "payload").Value.Replace('+', '-').Replace('/', '_') + "A"),
fields => fields[fields.FindIndex(f => f.Key == "payload")] = ("payload", fields.First(f => f.Key == "payload").Value.TrimEnd('=')),
})
{
using var huginn = ReplyingBytes(ListPayload(), mutate);
await Faults(() => List(huginn));
}
}
private static string Insert(List<(string Key, string Value)> fields, string text)
{
var value = fields.First(f => f.Key == "payload").Value;
return value[..8] + text + value[8..];
}
[Fact]
public async Task Bytes_after_the_payload_message_or_after_the_frame_message_are_a_failure()
{
using var afterPayload = ReplyingBytes([.. ListPayload(), 0xc0]);
await Faults(() => List(afterPayload));
using var afterFrame = ReplyingBytes(ListPayload(), trailing: [0xc0]);
await Faults(() => List(afterFrame));
}
[Fact]
public async Task A_key_written_twice_in_the_envelope_or_the_variant_is_a_failure()
{
using var envelope = ReplyingBytes(ListPayload(), fields => fields.Add(("status", "rejected")));
await Faults(() => List(envelope));
using var variant = ReplyingBytes(MessagePackSerializer.ConvertFromJson(Forks(ListJson("[]")).Replace("\"as_of\":1", "\"as_of\":1,\"as_of\":2", StringComparison.Ordinal)));
await Faults(() => List(variant));
}
private static byte[] BinaryList()
{
var w = new System.Buffers.ArrayBufferWriter<byte>();
var writer = new MessagePackWriter(w);
writer.WriteMapHeader(1);
writer.Write("Forks");
writer.WriteMapHeader(2);
writer.Write("list");
writer.Write(new byte[] { 1, 2, 3 });
writer.Write("as_of");
writer.Write(1);
writer.Flush();
return w.WrittenSpan.ToArray();
}
[Fact]
public async Task A_binary_anywhere_is_a_failure_not_a_rewritten_value()
{
using var bin = ReplyingBytes(BinaryList());
await Faults(() => List(bin));
}
// ---- the request, value by value ----
[Fact]
public async Task An_answer_request_carries_the_contexts_mind_subject_authority_and_deciders_and_the_body_as_a_map()
{
using var huginn = Replying("""{"NotStored":{"schemaVersion":"gamecult.fork.refusal.v0","code":"fork_unknown","error":"x"}}""", "rejected");
await new UnixSocketDeskDoor().AnswerAsync(huginn.Path, Context, DeskReplay.AnswerBody("x:question:a"), CancellationToken.None);
using var envelope = DeskWire.Envelope(Assert.Single(huginn.Requests));
using var payload = DeskWire.Payload(envelope);
var answer = payload.RootElement.GetProperty("Answer");
Assert.Equal(("eureka", "5f0c2b1e-8a4d-4c3b-9e7f-1a2b3c4d5e6f", "Member"), (answer.GetProperty("mind").GetString(), answer.GetProperty("subject").GetString(), answer.GetProperty("authority").GetString()));
Assert.Equal(Context.Deciders, answer.GetProperty("deciders").EnumerateArray().Select(d => d.GetString()));
Assert.Equal("x:question:a", answer.GetProperty("request").GetProperty("fork_id").GetString());
}
}