Desk door tests: a binary or invalid UTF-8 anywhere in a reply is a door failure
Pins bin in note, answerer, next_cursor, a card position (value, nil position, key name) and a record id, and 0xff 0xfe as text in the same places, through list, read and answer. The door already refuses both (Walk's default branch throws on Binary; the strict UTF-8 decoder throws on bad bytes), so no source changes: the mutants (Binary written as null; StrictUtf8 = new(false,false)) go red. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
39eb494a0d
commit
1fb9f62760
2 changed files with 135 additions and 0 deletions
|
|
@ -109,4 +109,44 @@ public static class DeskServe
|
|||
}
|
||||
|
||||
public static byte[] FromJson(JsonNode node) => MessagePackSerializer.ConvertFromJson(node.ToJsonString(DeskJson.Options));
|
||||
|
||||
// A list page of one entry, as JSON (the shape the door takes), from a vendored CultLib entry file.
|
||||
public static JsonObject PageOf(JsonNode entry, string mind = "gamecult-chat") =>
|
||||
JsonNode.Parse($$"""{"schemaVersion":"gamecult.fork.list.v0","mind":"{{mind}}","stamp":"s7","open_count":1,"recorded_count":0,"forks":[{{entry.ToJsonString(DeskJson.Options)}}],"next_cursor":null}""")!.AsObject();
|
||||
|
||||
public static JsonObject Entry(string file) =>
|
||||
JsonNode.Parse(MessagePackSerializer.ConvertToJson(File.ReadAllBytes(Path.Combine(DeskFeedFixturePinTests.Directory, "cultlib-3a51bbab", file + ".msgpack"))))!.AsObject();
|
||||
|
||||
// The reply payload exactly as given, asked as a list for "gamecult-chat"; throws what the door throws.
|
||||
public static async Task<DeskListOutcome> ListPayloadAsync(byte[] payload)
|
||||
{
|
||||
using var huginn = Socket(payload);
|
||||
return await new UnixSocketDeskDoor().ListAsync(huginn.Path, Context, new DeskListRequest("gamecult-chat", "open", null, 50), CancellationToken.None);
|
||||
}
|
||||
|
||||
// The reply payload exactly as given, asked as a read.
|
||||
public static async Task<DeskReadOutcome> ReadPayloadAsync(byte[] payload)
|
||||
{
|
||||
using var huginn = Socket(payload);
|
||||
return await new UnixSocketDeskDoor().ReadAsync(huginn.Path, Context, "gamecult-chat:question:slot-mark-consent", CancellationToken.None);
|
||||
}
|
||||
|
||||
public static async Task<DeskAnswerOutcome> AnswerPayloadAsync(byte[] payload)
|
||||
{
|
||||
using var huginn = Socket(payload);
|
||||
return await new UnixSocketDeskDoor().AnswerAsync(huginn.Path, Context, DeskReplay.AnswerBody(DeskReplay.ChatFork), CancellationToken.None);
|
||||
}
|
||||
|
||||
// A JSON text becomes MessagePack with the one str `marker` (at most 31 bytes) replaced by `with`: a bin, bytes that
|
||||
// are not UTF-8. JSON cannot spell either, so the marker stands where the test wants one.
|
||||
public static byte[] WithMarkerReplaced(JsonNode json, string marker, byte[] with)
|
||||
{
|
||||
var bytes = MessagePackSerializer.ConvertFromJson(json.ToJsonString(DeskJson.Options));
|
||||
var needle = new byte[] { (byte)(0xa0 | marker.Length) }.Concat(System.Text.Encoding.ASCII.GetBytes(marker)).ToArray();
|
||||
var at = bytes.AsSpan().IndexOf(needle);
|
||||
Assert.True(at >= 0 && bytes.AsSpan(at + 1).IndexOf(needle) < 0, "the marker is in the bytes exactly once");
|
||||
return [.. bytes[..at], .. with, .. bytes[(at + needle.Length)..]];
|
||||
}
|
||||
|
||||
public static byte[] Bin(int length) => [0xc4, (byte)length, .. Enumerable.Repeat((byte)'x', length)];
|
||||
}
|
||||
|
|
|
|||
95
tests/Bifrost.Web.Tests/UnixSocketDeskDoorBytesTests.cs
Normal file
95
tests/Bifrost.Web.Tests/UnixSocketDeskDoorBytesTests.cs
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
using System.Text.Json.Nodes;
|
||||
using Bifrost.Web.Features.Desk;
|
||||
using Bifrost.Web.Tests.Support;
|
||||
|
||||
namespace Bifrost.Web.Tests;
|
||||
|
||||
// The reply's bytes that JSON cannot spell: a MessagePack bin where the contract has text or nil, and text that is not
|
||||
// UTF-8. Either is a door failure wherever it sits, never a nil or a replacement character Bifrost made up.
|
||||
public sealed class UnixSocketDeskDoorBytesTests
|
||||
{
|
||||
private const string Marker = "@@MARK@@";
|
||||
|
||||
// Where a value can stand: a nullable key (note, answerer, next_cursor), a text key, and a card position.
|
||||
public static TheoryData<string> Positions() => new()
|
||||
{
|
||||
"note", "answerer", "next_cursor", "card.raised_at", "card.title", "card.options[0].text", "record.counted_answer_id", "mind", "card_key_name",
|
||||
};
|
||||
|
||||
private static JsonObject PageWithMarkerAt(string position)
|
||||
{
|
||||
var page = DeskServe.PageOf(DeskServe.Entry("entry-open-counted"));
|
||||
var entry = page["forks"]![0]!.AsObject();
|
||||
switch (position)
|
||||
{
|
||||
case "note": entry["record"]!["answers"]![0]!["note"] = Marker; break;
|
||||
case "answerer": entry["record"]!["answers"]![0]!["answerer"] = Marker; break;
|
||||
case "next_cursor": page["next_cursor"] = Marker; break;
|
||||
case "card.raised_at": entry["card"]!["raised_at"] = Marker; break;
|
||||
case "card.title": entry["card"]!["title"] = Marker; break;
|
||||
case "card.options[0].text": entry["card"]!["options"]![0]!["text"] = Marker; break;
|
||||
case "record.counted_answer_id": entry["record"]!["counted_answer_id"] = Marker; break;
|
||||
case "mind": page["mind"] = Marker; break;
|
||||
case "card_key_name":
|
||||
var card = entry["card"]!.AsObject();
|
||||
var rest = card.ToList();
|
||||
card.Clear();
|
||||
foreach (var (key, value) in rest)
|
||||
{
|
||||
card[key == "title" ? Marker : key] = value;
|
||||
}
|
||||
|
||||
break;
|
||||
default: throw new ArgumentOutOfRangeException(nameof(position));
|
||||
}
|
||||
|
||||
return page;
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(Positions))]
|
||||
public async Task A_binary_in_a_nullable_text_or_card_position_is_a_door_failure(string position)
|
||||
{
|
||||
var bytes = DeskServe.WithMarkerReplaced(PageWithMarkerAt(position), Marker, DeskServe.Bin(3));
|
||||
|
||||
await Assert.ThrowsAsync<DeskDoorException>(() => DeskServe.ListPayloadAsync(DeskServe.Forks(bytes)));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(Positions))]
|
||||
public async Task Invalid_utf8_in_text_is_a_door_failure(string position)
|
||||
{
|
||||
// 0xff 0xfe as a str of 2 bytes; Bifrost writes no replacement character.
|
||||
var bytes = DeskServe.WithMarkerReplaced(PageWithMarkerAt(position), Marker, [0xa2, 0xff, 0xfe]);
|
||||
|
||||
await Assert.ThrowsAsync<DeskDoorException>(() => DeskServe.ListPayloadAsync(DeskServe.Forks(bytes)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_same_marker_as_valid_text_decodes_so_the_failures_above_are_the_bytes_not_the_page()
|
||||
{
|
||||
foreach (var position in new[] { "note", "answerer", "next_cursor", "card.raised_at", "card.title" })
|
||||
{
|
||||
var bytes = DeskServe.WithMarkerReplaced(PageWithMarkerAt(position), Marker, [.. new byte[] { 0xa2 }, .. "ok"u8.ToArray()]);
|
||||
|
||||
Assert.IsType<DeskListOutcome.Listed>(await DeskServe.ListPayloadAsync(DeskServe.Forks(bytes)));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_binary_or_invalid_utf8_in_a_read_or_an_answer_is_a_door_failure_too()
|
||||
{
|
||||
var entry = DeskServe.Entry("entry-open-counted");
|
||||
entry["record"]!["answers"]![0]!["note"] = Marker;
|
||||
foreach (var with in new[] { DeskServe.Bin(2), new byte[] { 0xa2, 0xff, 0xfe } })
|
||||
{
|
||||
var read = DeskServe.WithMarkerReplaced(entry, Marker, with);
|
||||
await Assert.ThrowsAsync<DeskDoorException>(() => DeskServe.ReadPayloadAsync(DeskServe.Fork(read)));
|
||||
|
||||
var answer = DeskServe.Entry("entry-open-counted")["record"]!["answers"]![0]!;
|
||||
answer["note"] = Marker;
|
||||
var bytes = DeskServe.WithMarkerReplaced(answer, Marker, with);
|
||||
await Assert.ThrowsAsync<DeskDoorException>(() => DeskServe.AnswerPayloadAsync(DeskServe.Answered(bytes, "n1")));
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue