Bifrost/tests/Bifrost.Web.Tests/UnixSocketDeskDoorStrictTests.cs

339 lines
19 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));
}
// The payload is one tagged variant. A second key beside it, whatever it is called, is not a reply the door reads.
[Theory]
[InlineData("X")]
[InlineData("Refused")]
[InlineData("Forks2")]
public async Task A_payload_with_a_second_key_beside_the_variant_is_a_failure(string second)
{
var twoKeys = Forks(ListJson($"[{EntryJson()}]"))[..^1] + $",\"{second}\":\"Unavailable\"}}";
using var huginn = ReplyingBytes(MessagePackSerializer.ConvertFromJson(twoKeys));
await Faults(() => List(huginn));
}
// The envelope is an operation response or nothing: a different schemaVersion is not read as one.
[Theory]
[InlineData("cultnet.operation_response.v1")]
[InlineData("cultnet.operation_response.v")]
[InlineData("")]
[InlineData("CULTNET.OPERATION_RESPONSE.V0")]
public async Task An_envelope_with_another_schemaVersion_is_a_failure(string schemaVersion)
{
using var huginn = ReplyingBytes(ListPayload(), fields =>
{
var at = fields.FindIndex(f => f.Key == "schemaVersion");
fields[at] = ("schemaVersion", schemaVersion);
});
await Faults(() => List(huginn));
}
// The route names the fork; an entry for another fork is not the answer to the question asked, however well formed.
[Fact]
public async Task A_read_that_returns_another_forks_entry_is_a_failure()
{
var other = Card.Replace("x:question:a", "x:question:b", StringComparison.Ordinal);
var record = RecordJson().Replace("\"fork_id\":\"x:question:a\"", "\"fork_id\":\"x:question:b\"", StringComparison.Ordinal);
using var huginn = Replying($$$"""{"Fork":{"entry":{{{EntryJson(other, record)}}},"as_of":1}}""");
await Faults(() => Read(huginn));
}
[Fact]
public async Task A_read_whose_record_disagrees_with_its_card_is_not_the_doors_rule()
{
var record = RecordJson().Replace("\"fork_id\":\"x:question:a\"", "\"fork_id\":\"x:question:b\"", StringComparison.Ordinal);
using var huginn = Replying($$$"""{"Fork":{"entry":{{{EntryJson(record: record)}}},"as_of":1}}""");
Assert.IsType<DeskReadOutcome.Found>(await Read(huginn));
}
[Fact]
public async Task A_read_of_the_named_fork_is_found()
{
using var huginn = Replying($$$"""{"Fork":{"entry":{{{EntryJson()}}},"as_of":1}}""");
Assert.IsType<DeskReadOutcome.Found>(await Read(huginn));
}
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());
}
}