Desk: UnixSocketDeskDoor (Huginn desk door client), fixtures at 79d954c, card re-pin to CultLib 3a51bbab, card opaque #70

Open
heimdall wants to merge 12 commits from heimdall/desk-unix-door into main
99 changed files with 8271 additions and 1521 deletions

View file

@ -7,6 +7,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MessagePack" Version="3.1.12" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>

View file

@ -4,9 +4,13 @@ using System.Text.Json.Serialization;
namespace Bifrost.Web.Features.Desk;
// The fork-desk documents Bifrost reads or writes, spelled as CultLib's contracts/cultnet/governance/fork-card.md
// (commit de8a062a) spells them: snake_case keys, every key written (nil where absent), declared in the
// (commit d3025d3c) spells them: snake_case keys, every key written (nil where absent), declared in the
// contract's key order. Bifrost authors only DeskRefusal and DeskUnavailable. AnswerRequest is the client's body;
// ForkCard, DeskAnswerDocument and AnswerResult are Huginn's documents, typed here so they pass through unchanged.
// DeskAnswerDocument, ForkList and AnswerResult are Huginn's documents, typed here so they pass through unchanged.
// The card (gamecult.fork.card.v0) is deliberately NOT typed: the Huginn door owns what a reader's card holds (the
// decider view carries ids, the non-decider view withholds them and keeps decider_count), and Bifrost neither reads
// nor edits it, so it is an opaque JsonElement that keeps every key, key order and integer. The door refuses a float,
// a binary or an extension value anywhere in a reply (the contract has none), so no number is ever rewritten.
public static class DeskSchemas
{
@ -53,32 +57,6 @@ public sealed record DeskUnavailable(
[property: JsonPropertyName("error")] string Error,
[property: JsonPropertyName("retryAfterSeconds")] int? RetryAfterSeconds);
public sealed record ForkOption(
[property: JsonPropertyName("id")] string Id,
[property: JsonPropertyName("text")] string Text);
public sealed record ForkRef(
[property: JsonPropertyName("kind")] string Kind,
[property: JsonPropertyName("id")] string Id);
public sealed record ForkCard(
[property: JsonPropertyName("schemaVersion")] string SchemaVersion,
[property: JsonPropertyName("fork_id")] string ForkId,
[property: JsonPropertyName("title")] string Title,
[property: JsonPropertyName("context")] string Context,
[property: JsonPropertyName("options")] ForkOption[] Options,
[property: JsonPropertyName("recommendation")] string Recommendation,
[property: JsonPropertyName("depends")] string[] Depends,
[property: JsonPropertyName("leans")] string? Leans,
[property: JsonPropertyName("flag")] string? Flag,
[property: JsonPropertyName("raised_in")] ForkRef? RaisedIn,
[property: JsonPropertyName("deciders")] string[] Deciders,
[property: JsonPropertyName("status")] string Status,
[property: JsonPropertyName("ruling")] string? Ruling,
[property: JsonPropertyName("ruled_from")] string[] RuledFrom,
[property: JsonPropertyName("order")] long Order,
[property: JsonPropertyName("options_hash")] string OptionsHash);
// One stored answer: Counted, Revision, Retraction or Refused, told apart by Outcome and Code.
public sealed record DeskAnswerDocument(
[property: JsonPropertyName("schemaVersion")] string SchemaVersion,
@ -94,7 +72,9 @@ public sealed record DeskAnswerDocument(
[property: JsonPropertyName("answered_at")] string AnsweredAt,
[property: JsonPropertyName("received_at")] string ReceivedAt,
[property: JsonPropertyName("claim_in_future")] bool ClaimInFuture,
[property: JsonPropertyName("answerer")] string Answerer,
// Nil in a non-decider's view (Huginn withholds the ids). The key is required, and the door's wire walker (Closed in
// UnixSocketDeskDoor) is the one owner of that: a missing key is a door fault, never a nil Bifrost invented.
[property: JsonPropertyName("answerer")] string? Answerer,
[property: JsonPropertyName("authority")] string Authority,
[property: JsonPropertyName("request_id")] string RequestId);
@ -103,11 +83,12 @@ public sealed record ForkRecord(
[property: JsonPropertyName("fork_id")] string ForkId,
[property: JsonPropertyName("options_hash")] string OptionsHash,
[property: JsonPropertyName("counted_answer_id")] string? CountedAnswerId,
[property: JsonPropertyName("answerer_count")] int AnswererCount,
[property: JsonPropertyName("answers")] DeskAnswerDocument[] Answers);
public sealed record ForkEntry(
[property: JsonPropertyName("schemaVersion")] string SchemaVersion,
[property: JsonPropertyName("card")] ForkCard Card,
[property: JsonPropertyName("card")] JsonElement Card,
[property: JsonPropertyName("record")] ForkRecord Record);
public sealed record ForkList(
@ -122,7 +103,7 @@ public sealed record ForkList(
public sealed record AnswerResult(
[property: JsonPropertyName("schemaVersion")] string SchemaVersion,
[property: JsonPropertyName("answer")] DeskAnswerDocument Answer,
[property: JsonPropertyName("card")] ForkCard? Card,
[property: JsonPropertyName("card")] JsonElement? Card,
[property: JsonPropertyName("counted_answer_id")] string? CountedAnswerId);
// The refusal codes Bifrost itself writes, each with its one status and message (vectors doorRefusals).

View file

@ -4,6 +4,7 @@ using Bifrost.Web.Data;
using Bifrost.Web.Features.Oidc;
using Microsoft.AspNetCore.Cors.Infrastructure;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
using Microsoft.Net.Http.Headers;
@ -24,6 +25,7 @@ public static class DeskEndpoints
public static IServiceCollection AddBifrostDesk(this IServiceCollection services)
{
services.TryAddSingleton<IDeskDoor>(_ => new UnixSocketDeskDoor());
services.AddSingleton(provider => new DeskMindRoutes(provider.GetRequiredService<IOptions<DeskOptions>>().Value.Minds));
services.AddCors();
// Exactly the configured origin, no credentials, and only on the routes that require this policy.

View file

@ -2,8 +2,9 @@ namespace Bifrost.Web.Features.Desk;
// The two ports the desk stands on. Bifrost owns the edge's order and judgments; these two own what is not
// Bifrost's yet: validating a bearer token (the OIDC validation scheme) and speaking to a Mind's Huginn on the
// peer-checked door. No production implementation is registered here: with the Desk section configured and a port
// missing, startup fails (DeskEndpoints.MapDesk) instead of serving.
// peer-checked door. The door's production implementation is UnixSocketDeskDoor (registered by AddBifrostDesk unless
// a test or host registered its own); the credential has none registered here, so with the Desk section configured
// and a port missing, startup fails (DeskEndpoints.MapDesk) instead of serving.
// What a validated bearer token claims. The credential port verifies the token; the desk judges audience, scope and
// membership. A null Subject or Audience means the token did not carry one.

View file

@ -0,0 +1,615 @@
using System.Buffers;
using System.Buffers.Binary;
using System.Globalization;
using System.Net.Sockets;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using MessagePack;
namespace Bifrost.Web.Features.Desk;
// The door could not confirm an outcome: no connection, no whole reply in time, or a reply that is not the contract's
// (an error, a failure, a Mind refusal, an unknown variant). The desk edge maps any exception from the door to 503
// `unavailable`; this is the one type the client throws for every one of those, so a failure is never an empty page.
public sealed class DeskDoorException(string message, Exception? inner = null) : Exception(message, inner);
// The production IDeskDoor: a client of Huginn's desk door (Huginn docs/desk-feed-door.md at b299d6f7). One new
// connection per call, one request frame, one reply frame, closed after, all inside one deadline. The client holds no
// connection state, so a dead socket fails only the calls addressed to that Mind.
//
// frame = u32 big-endian length || CultNet message (MessagePack map), at most 65536 bytes each way
// payload = standard base64 of the named MessagePack of Huginn's externally tagged DeskRequest / DeskResponse
//
// The reply is decoded by key, never by position, and strictly: every contract document is a closed map whose keys are
// all present with the right type (the variants' own maps too: as_of is an unsigned integer, as Huginn's DeskResponse
// declares), a key written twice anywhere is a failure, and so is a float, a binary or an extension value (the
// contract has none; integers keep every digit). The card inside an entry is not read beyond
// being a map: it stays an opaque JsonElement, so whatever keys and integers Huginn wrote leave unchanged.
public sealed partial class UnixSocketDeskDoor : IDeskDoor
{
public const int MaxFrameBytes = 65536;
public static readonly TimeSpan Deadline = TimeSpan.FromSeconds(1);
private const string ServiceId = "huginn.desk";
private const string RequestSchema = "huginn.desk_request.v1";
private const string ResponseSchema = "huginn.desk_response.v1";
private const string Encoding64 = "messagepack-base64";
private const int MaxMessageIdBytes = 256;
private static readonly UTF8Encoding StrictUtf8 = new(false, true);
private readonly Func<string> messageIds;
public UnixSocketDeskDoor()
: this(() => Guid.NewGuid().ToString("N"))
{
}
public UnixSocketDeskDoor(Func<string> messageIds) => this.messageIds = messageIds;
public async Task<DeskAnswerOutcome> AnswerAsync(
string socket, DeskDoorContext context, ReadOnlyMemory<byte> body, CancellationToken cancellationToken)
{
byte[] request;
try
{
// Every key, null and key order of the client's body, as MessagePack.
request = MessagePackSerializer.ConvertFromJson(StrictUtf8.GetString(body.Span));
}
catch (Exception e) when (e is not OperationCanceledException)
{
throw new DeskDoorException("the answer body does not convert to the request map", e);
}
var payload = Payload("Answer", 5, (ref MessagePackWriter w) =>
{
w.Write("mind");
w.Write(context.Mind);
w.Write("subject");
w.Write(context.Subject);
w.Write("authority");
w.Write(context.Authority);
w.Write("deciders");
Strings(ref w, context.Deciders);
w.Write("request");
w.WriteRaw(request);
});
var (variant, value) = await ExchangeAsync(socket, "answer", payload, cancellationToken);
switch (variant)
{
case "Answered":
Closed(value, "Answered", string.Empty, ("answer", Kind.Map), ("counted_answer_id", Kind.TextOrNull), ("as_of", Kind.Unsigned));
var answer = AnswerDocument(value, "answer");
// Huginn's reply carries no card: a stale_fork answer gets the current card from the edge's read.
return new DeskAnswerOutcome.Answered(new AnswerResult(
DeskSchemas.AnswerResult, answer, null, OptionalString(value, "counted_answer_id")));
case "NotStored":
var code = RefusalCode(value);
return code is DeskRefusals.ForkTooLarge
? throw new DeskDoorException("fork_too_large is not an answer refusal")
: new DeskAnswerOutcome.NotStored(code);
default:
throw Unexpected(variant);
}
}
public async Task<DeskReadOutcome> ReadAsync(
string socket, DeskDoorContext context, string forkId, CancellationToken cancellationToken)
{
var payload = Payload("Fork", 4, (ref MessagePackWriter w) =>
{
w.Write("mind");
w.Write(context.Mind);
w.Write("subject");
w.Write(context.Subject);
w.Write("deciders");
Strings(ref w, context.Deciders);
w.Write("fork_id");
w.Write(forkId);
});
var (variant, value) = await ExchangeAsync(socket, "fork", payload, cancellationToken);
switch (variant)
{
case "Fork":
Closed(value, "Fork", string.Empty, ("entry", Kind.Map), ("as_of", Kind.Unsigned));
return new DeskReadOutcome.Found(Entry(Required(value, "entry"), "entry"));
case "NotStored":
var code = RefusalCode(value);
// The route's fork id, never the refusal's informational text.
return code == DeskRefusals.ForkTooLarge
? new DeskReadOutcome.ForkTooLarge(forkId)
: new DeskReadOutcome.NotFound(code);
default:
throw Unexpected(variant);
}
}
public async Task<DeskListOutcome> ListAsync(
string socket, DeskDoorContext context, DeskListRequest request, CancellationToken cancellationToken)
{
var payload = Payload("Forks", 6, (ref MessagePackWriter w) =>
{
w.Write("mind");
w.Write(context.Mind);
w.Write("subject");
w.Write(context.Subject);
w.Write("deciders");
Strings(ref w, context.Deciders);
w.Write("status");
w.Write(request.Status);
w.Write("cursor");
w.Write(request.Cursor);
w.Write("limit");
w.Write(request.Limit);
});
var (variant, value) = await ExchangeAsync(socket, "forks", payload, cancellationToken);
switch (variant)
{
case "Forks":
Closed(value, "Forks", string.Empty, ("list", Kind.Map), ("as_of", Kind.Unsigned));
var list = List(Required(value, "list"));
return list.Mind == context.Mind
? new DeskListOutcome.Listed(list)
: throw new DeskDoorException("the list is for another Mind");
case "NotStored":
var code = RefusalCode(value);
if (code != DeskRefusals.ForkTooLarge)
{
return new DeskListOutcome.NotListed(code);
}
// The fork and its order exist only in the informational text; anything but the exact form is a fault.
var text = RequiredString(value, "error");
var match = ListTooLarge().Match(text);
if (!match.Success || !int.TryParse(match.Groups["order"].ValueSpan, NumberStyles.None, CultureInfo.InvariantCulture, out var order))
{
throw new DeskDoorException("fork_too_large does not name its fork and order in the list form");
}
return new DeskListOutcome.ForkTooLarge(match.Groups["id"].Value, order);
default:
throw Unexpected(variant);
}
}
// fork_too_large: fork <id> at order <N> is past the page budget by itself; ...
[GeneratedRegex(@"\Afork_too_large: fork (?<id>\S+) at order (?<order>0|[1-9][0-9]*) is past the page budget(?:[ ;][^\r\n]*)?\z", RegexOptions.CultureInvariant)]
private static partial Regex ListTooLarge();
// ---- strict documents: a closed map, every key present with its type, then bound to the typed record ----
private enum Kind
{
Text,
TextOrNull,
Flag,
Count,
Unsigned,
Map,
Items,
}
private static readonly (string, Kind)[] AnswerKeys =
[
("schemaVersion", Kind.Text), ("answer_id", Kind.Text), ("fork_id", Kind.Text), ("outcome", Kind.Text),
("of", Kind.TextOrNull), ("retracts", Kind.TextOrNull), ("code", Kind.TextOrNull), ("options_hash", Kind.Text),
("choice", Kind.TextOrNull), ("note", Kind.TextOrNull), ("answered_at", Kind.Text), ("received_at", Kind.Text),
("claim_in_future", Kind.Flag), ("answerer", Kind.TextOrNull), ("authority", Kind.Text), ("request_id", Kind.Text),
];
private static void Closed(JsonElement map, string what, string schema, params (string Key, Kind Kind)[] keys)
{
if (map.ValueKind != JsonValueKind.Object || map.EnumerateObject().Count() != keys.Length)
{
throw new DeskDoorException($"{what} is not a closed map of {keys.Length} keys");
}
foreach (var (key, kind) in keys)
{
var ok = Property(map, key) is { } v && kind switch
{
Kind.Text => v.ValueKind == JsonValueKind.String,
Kind.TextOrNull => v.ValueKind is JsonValueKind.String or JsonValueKind.Null,
Kind.Flag => v.ValueKind is JsonValueKind.True or JsonValueKind.False,
Kind.Count => v.ValueKind == JsonValueKind.Number && v.TryGetInt32(out var n) && n >= 0,
Kind.Unsigned => v.ValueKind == JsonValueKind.Number && v.TryGetUInt64(out _),
Kind.Map => v.ValueKind == JsonValueKind.Object,
_ => v.ValueKind == JsonValueKind.Array,
};
if (!ok)
{
throw new DeskDoorException($"{what}.{key} is missing or not the contract's type");
}
}
if (schema.Length > 0 && Property(map, "schemaVersion")!.Value.GetString() != schema)
{
throw new DeskDoorException($"{what} is not {schema}");
}
}
private static DeskAnswerDocument AnswerDocument(JsonElement value, string what)
{
var map = Required(value, what);
Closed(map, what, DeskSchemas.DeskAnswer, AnswerKeys);
return Bind<DeskAnswerDocument>(map, what);
}
private static ForkEntry Entry(JsonElement map, string what)
{
Closed(map, what, "gamecult.fork.entry.v0", ("schemaVersion", Kind.Text), ("card", Kind.Map), ("record", Kind.Map));
var record = Property(map, "record")!.Value;
Closed(record, what + ".record", "gamecult.fork.record.v0",
("schemaVersion", Kind.Text), ("fork_id", Kind.Text), ("options_hash", Kind.Text), ("counted_answer_id", Kind.TextOrNull),
("answerer_count", Kind.Count), ("answers", Kind.Items));
foreach (var answer in Property(record, "answers")!.Value.EnumerateArray())
{
Closed(answer, what + ".record.answers[]", DeskSchemas.DeskAnswer, AnswerKeys);
}
return Bind<ForkEntry>(map, what);
}
private static ForkList List(JsonElement map)
{
Closed(map, "list", "gamecult.fork.list.v0",
("schemaVersion", Kind.Text), ("mind", Kind.Text), ("stamp", Kind.Text), ("open_count", Kind.Count),
("recorded_count", Kind.Count), ("forks", Kind.Items), ("next_cursor", Kind.TextOrNull));
foreach (var entry in Property(map, "forks")!.Value.EnumerateArray())
{
Entry(entry, "list.forks[]");
}
return Bind<ForkList>(map, "list");
}
private static T Bind<T>(JsonElement map, string what)
{
try
{
return map.Deserialize<T>(DeskJson.Options) ?? throw new DeskDoorException($"{what} does not decode");
}
catch (JsonException e)
{
throw new DeskDoorException($"{what} does not decode", e);
}
}
private static JsonElement Required(JsonElement map, string name) =>
Property(map, name) ?? throw new DeskDoorException($"{name} is missing");
private static DeskDoorException Unexpected(string variant) =>
new($"the door answered with {(variant == "Refused" ? "a Mind refusal" : "a variant this call does not take")}");
private static string RefusalCode(JsonElement refusal)
{
Closed(refusal, "the refusal", DeskSchemas.Refusal, ("schemaVersion", Kind.Text), ("code", Kind.Text), ("error", Kind.Text));
var code = RequiredString(refusal, "code");
return code is DeskRefusals.ForkUnknown or DeskRefusals.BodyInvalid or DeskRefusals.RequestIdConflict or DeskRefusals.ForkTooLarge
? code
: throw new DeskDoorException("the refusal code is not one the contract names");
}
// ---- request ----
private delegate void FieldWriter(ref MessagePackWriter writer);
private static byte[] Payload(string variant, int fields, FieldWriter write)
{
var buffer = new ArrayBufferWriter<byte>();
var w = new MessagePackWriter(buffer);
w.WriteMapHeader(1);
w.Write(variant);
w.WriteMapHeader(fields);
write(ref w);
w.Flush();
return buffer.WrittenSpan.ToArray();
}
private static void Strings(ref MessagePackWriter w, IReadOnlyList<string> values)
{
w.WriteArrayHeader(values.Count);
foreach (var value in values)
{
w.Write(value);
}
}
// Operation messages write their keys sorted, absent optionals omitted.
private static byte[] RequestMessage(string messageId, string operation, byte[] payload)
{
var buffer = new ArrayBufferWriter<byte>();
var w = new MessagePackWriter(buffer);
w.WriteMapHeader(7);
w.Write("messageId");
w.Write(messageId);
w.Write("operation");
w.Write(operation);
w.Write("payload");
w.Write(Convert.ToBase64String(payload));
w.Write("payloadEncoding");
w.Write(Encoding64);
w.Write("payloadSchema");
w.Write(RequestSchema);
w.Write("schemaVersion");
w.Write("cultnet.operation_request.v0");
w.Write("serviceId");
w.Write(ServiceId);
w.Flush();
return buffer.WrittenSpan.ToArray();
}
// ---- exchange ----
private async Task<(string Variant, JsonElement Value)> ExchangeAsync(
string socketPath, string operation, byte[] payload, CancellationToken cancellationToken)
{
var messageId = messageIds();
if (messageId.Length == 0 || StrictUtf8.GetByteCount(messageId) > MaxMessageIdBytes)
{
throw new DeskDoorException("the message id is empty or over 256 bytes");
}
var message = RequestMessage(messageId, operation, payload);
if (message.Length > MaxFrameBytes)
{
throw new DeskDoorException("the request is past the frame bound");
}
var frame = new byte[4 + message.Length];
BinaryPrimitives.WriteUInt32BigEndian(frame, (uint)message.Length);
message.CopyTo(frame, 4);
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
deadline.CancelAfter(Deadline);
byte[] reply;
try
{
using var socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified);
await socket.ConnectAsync(new UnixDomainSocketEndPoint(socketPath), deadline.Token);
for (var sent = 0; sent < frame.Length;)
{
sent += await socket.SendAsync(frame.AsMemory(sent), SocketFlags.None, deadline.Token);
}
var header = new byte[4];
await ReadWholeAsync(socket, header, deadline.Token);
var length = BinaryPrimitives.ReadUInt32BigEndian(header);
if (length == 0 || length > MaxFrameBytes)
{
// Refused on the prefix alone: the body is never read.
throw new DeskDoorException("the reply's length prefix is outside the frame bound");
}
reply = new byte[length];
await ReadWholeAsync(socket, reply, deadline.Token);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
throw new DeskDoorException("no whole reply within the deadline");
}
catch (Exception e) when (e is SocketException or IOException or ObjectDisposedException or ArgumentException)
{
throw new DeskDoorException("the desk socket failed", e);
}
return Decode(reply, messageId, operation);
}
// Loops until the buffer is whole: a stream socket may hand it over in pieces. A peer closing early is a failure.
private static async Task ReadWholeAsync(Socket socket, byte[] buffer, CancellationToken cancellationToken)
{
for (var read = 0; read < buffer.Length;)
{
var count = await socket.ReceiveAsync(buffer.AsMemory(read), SocketFlags.None, cancellationToken);
if (count == 0)
{
throw new DeskDoorException("the peer closed before a whole reply frame");
}
read += count;
}
}
// ---- reply ----
// The envelope judged by key; the payload's one variant returned with its value. Everything else throws.
private static (string Variant, JsonElement Value) Decode(byte[] frame, string messageId, string operation)
{
try
{
using var envelope = ToJson(frame);
var root = envelope.RootElement;
if (root.ValueKind != JsonValueKind.Object)
{
throw new DeskDoorException("the reply is not a map");
}
// A CultNet Error message is not an operation reply (and its keys are not in operation order).
if (RequiredString(root, "schemaVersion") != "cultnet.operation_response.v0")
{
throw new DeskDoorException("the reply is not an operation response");
}
if (RequiredString(root, "serviceId") != ServiceId
|| RequiredString(root, "operation") != operation
|| RequiredString(root, "messageId") != messageId)
{
throw new DeskDoorException("the reply is not for this request");
}
var status = RequiredString(root, "status");
if (RequiredString(root, "payloadSchema") != ResponseSchema || RequiredString(root, "payloadEncoding") != Encoding64)
{
// Including gamecult.cultnet.operation_failure.v1: the envelope or payload did not decode.
throw new DeskDoorException("the reply carries no desk response");
}
// Strict standard base64: Convert would skip whitespace, so the text must be the canonical encoding of what it decodes to.
var text = RequiredString(root, "payload");
byte[] payload;
try
{
payload = Convert.FromBase64String(text);
}
catch (FormatException e)
{
throw new DeskDoorException("the payload is not base64", e);
}
if (Convert.ToBase64String(payload) != text)
{
throw new DeskDoorException("the payload is not strict standard base64");
}
using var body = ToJson(payload);
if (body.RootElement.ValueKind != JsonValueKind.Object)
{
throw new DeskDoorException("the payload is not a tagged variant");
}
var variants = body.RootElement.EnumerateObject().ToList();
if (variants.Count != 1)
{
throw new DeskDoorException("the payload is not a one-key variant");
}
var (variant, expectedStatus) = (variants[0].Name, variants[0].Name switch
{
"Answered" or "Forks" or "Fork" => "accepted",
"NotStored" or "Refused" => "rejected",
_ => null,
});
var takesVariant = operation switch
{
"answer" => variant == "Answered",
"forks" => variant == "Forks",
"fork" => variant == "Fork",
_ => false,
} || variant is "NotStored" or "Refused";
if (expectedStatus is null || !takesVariant || status != expectedStatus)
{
throw new DeskDoorException("the reply's variant or status does not fit the operation");
}
return (variant, variants[0].Value.Clone());
}
catch (Exception e) when (e is not (DeskDoorException or OperationCanceledException))
{
throw new DeskDoorException("the reply does not decode", e);
}
}
// MessagePack to JSON text by walking the reader: the JSON writer escapes every control character, keys keep their
// order, integers keep every digit. A map key that is not text, a key written twice, a float, a binary, an
// extension, invalid UTF-8 or nesting past 64 is a failure; none is in the contract.
private static JsonDocument ToJson(ReadOnlyMemory<byte> messagePack)
{
var reader = new MessagePackReader(messagePack);
var json = new ArrayBufferWriter<byte>();
using (var writer = new Utf8JsonWriter(json, new JsonWriterOptions { MaxDepth = 64 }))
{
Walk(ref reader, writer, 0);
}
if (!reader.End)
{
throw new DeskDoorException("bytes follow the message");
}
return JsonDocument.Parse(json.WrittenMemory, new JsonDocumentOptions { MaxDepth = 64 });
}
private static void Walk(ref MessagePackReader reader, Utf8JsonWriter writer, int depth)
{
if (depth > 64)
{
throw new DeskDoorException("the message nests past 64");
}
switch (reader.NextMessagePackType)
{
case MessagePackType.Nil:
reader.ReadNil();
writer.WriteNullValue();
break;
case MessagePackType.Boolean:
writer.WriteBooleanValue(reader.ReadBoolean());
break;
case MessagePackType.Integer:
if (reader.NextCode == MessagePackCode.UInt64)
{
writer.WriteNumberValue(reader.ReadUInt64());
}
else
{
writer.WriteNumberValue(reader.ReadInt64());
}
break;
case MessagePackType.String:
writer.WriteStringValue(Text(ref reader));
break;
case MessagePackType.Array:
var items = reader.ReadArrayHeader();
writer.WriteStartArray();
for (var i = 0; i < items; i++)
{
Walk(ref reader, writer, depth + 1);
}
writer.WriteEndArray();
break;
case MessagePackType.Map:
var pairs = reader.ReadMapHeader();
var seen = new HashSet<string>(StringComparer.Ordinal);
writer.WriteStartObject();
for (var i = 0; i < pairs; i++)
{
if (reader.NextMessagePackType != MessagePackType.String)
{
throw new DeskDoorException("a map key is not text");
}
var key = Text(ref reader);
if (!seen.Add(key))
{
throw new DeskDoorException($"{key} is written twice");
}
writer.WritePropertyName(key);
Walk(ref reader, writer, depth + 1);
}
writer.WriteEndObject();
break;
default:
throw new DeskDoorException("the message holds a float, binary or extension value, which the contract does not use");
}
}
private static string Text(ref MessagePackReader reader) =>
StrictUtf8.GetString(reader.ReadStringSequence() ?? throw new DeskDoorException("text is nil"));
// A key's value by name; a key written twice never gets here (the walk refuses it).
private static JsonElement? Property(JsonElement map, string name) =>
map.ValueKind == JsonValueKind.Object && map.TryGetProperty(name, out var value) ? value : null;
private static string RequiredString(JsonElement map, string name) =>
Property(map, name) is { ValueKind: JsonValueKind.String } value
? value.GetString()!
: throw new DeskDoorException($"{name} is missing or not text");
private static string? OptionalString(JsonElement map, string name) =>
Property(map, name) switch
{
{ ValueKind: JsonValueKind.String } value => value.GetString(),
{ ValueKind: JsonValueKind.Null } => null,
_ => throw new DeskDoorException($"{name} is missing or not text"),
};
}

View file

@ -8,6 +8,17 @@
"resolved": "2.4.0",
"contentHash": "SwXsAV3sMvAU/Nn31pbjhWurYSjJ+/giI/0n6tCrYoupEK34iIHCuk3STAd9fx8yudM85KkLSVdn951vTng/vQ=="
},
"MessagePack": {
"type": "Direct",
"requested": "[3.1.12, )",
"resolved": "3.1.12",
"contentHash": "m1QEsd59gUj82ji5tEQbZiyjV6y0F7cYfGVhil6pAlNZpyzR7ze9TucMu4lPpi8cZAV8aNl3D9/T9J1HbFz15Q==",
"dependencies": {
"MessagePack.Annotations": "3.1.12",
"MessagePackAnalyzer": "3.1.12",
"Microsoft.NET.StringTools": "17.11.4"
}
},
"Microsoft.EntityFrameworkCore.Design": {
"type": "Direct",
"requested": "[8.0.0, )",
@ -86,6 +97,16 @@
"resolved": "2.14.1",
"contentHash": "lQKvtaTDOXnoVJ20ibTuSIOf2i0uO0MPbDhd1jm238I+U/2ZnRENj0cktKZhtchBMtCUSRQ5v4xBCUbKNmyVMw=="
},
"MessagePack.Annotations": {
"type": "Transitive",
"resolved": "3.1.12",
"contentHash": "LdI67sY7F3KK+JsrG02+DP1XFkowaZSoyfL2M88hjm1MOOsjf8amBLAJolV+zxoPUZf3uGbKzq1cMQD8PlpSpA=="
},
"MessagePackAnalyzer": {
"type": "Transitive",
"resolved": "3.1.12",
"contentHash": "iNSNatPh6bafRUbkQKlClExjpPpd8hiZgfZsodGf7bheIlkizGbMzsJ2mPui8jOHRe4Ky9eg5ou/fFzI7CT3fg=="
},
"Microsoft.Bcl.AsyncInterfaces": {
"type": "Transitive",
"resolved": "6.0.0",
@ -279,6 +300,11 @@
"Microsoft.IdentityModel.Logging": "8.19.2"
}
},
"Microsoft.NET.StringTools": {
"type": "Transitive",
"resolved": "17.11.4",
"contentHash": "mudqUHhNpeqIdJoUx2YDWZO/I9uEDYVowan89R6wsomfnUJQk6HteoQTlNjZDixhT2B4IXMkMtgZtoceIjLRmA=="
},
"Mono.TextTemplating": {
"type": "Transitive",
"resolved": "2.2.1",

View file

@ -23,6 +23,7 @@
<ItemGroup>
<None Include="Fixtures\*.json" CopyToOutputDirectory="PreserveNewest" />
<None Include="Fixtures\*.cjs" CopyToOutputDirectory="PreserveNewest" />
<None Include="Fixtures\desk-feed\**" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>

View file

@ -64,12 +64,31 @@ public sealed class DeskDocumentTests
public void Cards_round_trip_with_their_spelling()
{
var rows = DeskVectors.Rows("card", "valid").ToList();
Assert.Equal(5, rows.Count);
Assert.Equal(7, rows.Count);
foreach (var row in rows)
{
var message = row["message"]!;
var card = message.Deserialize<ForkCard>(DeskJson.Options)!;
var card = message.Deserialize<JsonElement>(DeskJson.Options);
Assert.True(DeskVectors.SameShape(message, Write(card)), (string)row["name"]!);
}
}
// The card is opaque: whatever keys, in whatever order, with whatever numbers, leave as they came.
[Fact]
public void An_unknown_card_passes_through_byte_for_byte()
{
const string card =
"{\"order\":7,\"raised_at\":\"2026-11-11T09:00:00Z\",\"title\":\"It's \u00e9\",\"fork_id\":\"x:question:y\"," +
"\"future\":{\"z\":1.50,\"a\":[3,null,1e2],\"big\":12345678901234567890},\"options_hash\":\"ab\",\"leans\":null}";
var entryJson =
"{\"schemaVersion\":\"gamecult.fork.entry.v0\",\"card\":" + card +
",\"record\":{\"schemaVersion\":\"gamecult.fork.record.v0\",\"fork_id\":\"x:question:y\",\"options_hash\":\"ab\",\"counted_answer_id\":null,\"answerer_count\":0,\"answers\":[]}}";
var entry = JsonSerializer.Deserialize<ForkEntry>(entryJson, DeskJson.Options)!;
var written = JsonSerializer.Serialize(entry, DeskJson.Options);
Assert.Equal(entryJson, written);
var result = new AnswerResult(DeskSchemas.AnswerResult, null!, entry.Card, null);
Assert.Contains("\"card\":" + card + ",", JsonSerializer.Serialize(result, DeskJson.Options));
}
}

View file

@ -0,0 +1,172 @@
using System.Security.Cryptography;
namespace Bifrost.Web.Tests;
// Huginn's desk-feed golden bytes, vendored byte for byte from crates/huginn-mind/tests/fixtures/desk-feed/ at Huginn
// main b299d6f7320cd0879c93d5cc1d1adb0dbed7339b (#58; docs/desk-feed-door.md is the contract): 71 files, the
// subdirectory cultlib-3a51bbab/ (twelve CultLib vectors, unedited) included. The pin is the exact file list and each
// sha256; a changed byte, an extra file or a missing file fails, and the verifier is proved to catch each.
// A silent re-vendor fails here: move the pin on purpose, with the contract and the door's tests.
public sealed class DeskFeedFixturePinTests
{
public const string HuginnCommit = "b299d6f7320cd0879c93d5cc1d1adb0dbed7339b";
public static readonly string Directory = Path.Combine(AppContext.BaseDirectory, "Fixtures", "desk-feed");
public static readonly IReadOnlyDictionary<string, string> Sha256 = new Dictionary<string, string>(StringComparer.Ordinal)
{
["cultlib-3a51bbab/answer-counted.msgpack"] = "55d69ae3cc7ebbeeae7d501e8641640e115c3fb5aac399ea760588bca343b9e6",
["cultlib-3a51bbab/answer-refused-stale.msgpack"] = "3183bc9c483f4a625332cda11ef22a9c8560868a7728b77daa63a1960a3cd3d6",
["cultlib-3a51bbab/answer-retraction.msgpack"] = "89156f41ec3ea54f5d47bd3318913ff013785f4727916875be97785935cb7735",
["cultlib-3a51bbab/answer-revision.msgpack"] = "19d7289395ae68cbaf6de1c0a1ef6c843211b2caf9462e1d2268cd0883386315",
["cultlib-3a51bbab/answer-withheld-answerer.msgpack"] = "b66b4f921cdd374be04af3de34c59e254a5e542027ae8c57f196a3ea3406e427",
["cultlib-3a51bbab/card-operator-fork.msgpack"] = "56bfdb65ded60b562001d09d0b8da6e092900f404bf5f68f7084dff29bcc34f5",
["cultlib-3a51bbab/card-raised-at-nil.msgpack"] = "b81bb825790ffe03fabe63c3f8a61c36bd50ca268aa6dff67b5bcfad3957e58c",
["cultlib-3a51bbab/card-withheld-deciders.msgpack"] = "d13b9b77461cfdf78f943ef56c6aeab852ab6b6f0b355e26c5b361f99c79c7f7",
["cultlib-3a51bbab/entry-closed-with-ruling-and-a-refusal.msgpack"] = "f7859c7d04d338d6b1c634308b08ae58ae77b0ff459767859a5e40f0af2f16e5",
["cultlib-3a51bbab/entry-open-counted.msgpack"] = "b51f499e8dd0465cf5df9d68d8fccbe94dd1339fd3157969693dd0d4ecb2549b",
["cultlib-3a51bbab/entry-withheld-closed-with-ruling-and-a-refusal.msgpack"] = "daaa819bfa2612616826ef3217dfcf8d8d8cb71d86547d7106426c779134f827",
["cultlib-3a51bbab/entry-withheld-open-counted.msgpack"] = "62ccad7a9f41262d6e35d0247315d0bb75d1313b3c9dabe8a99d1aa1b844607f",
["cultlib-3a51bbab/README.md"] = "9262509aab2cb3c3dbdbb4994a959669685cac0fff0f927b790da769787cc42b",
["error.response.frame.bin"] = "fa03f44b82d7c4796f45dd36086a54342dc1de7e660a58cf3ddff4ff59d85a4c",
["error.response.frame.hex"] = "21c75b6e1d000ddc1a5455a9cc4d6c5b37bd1640c22e1055e96ff85bdff461bb",
["failure.response.frame.bin"] = "edc2277943334744d3057902800c25bbc913ff8ed336b4612f1a19772ec46dc9",
["failure.response.frame.hex"] = "b3c262ac506b51529e8063b2d38cc8c0047b431b75fe6ede1a91adebefd014d3",
["failure.response.payload.hex"] = "13ca85b5868451892746d2531833d394b66d25b5069cd0208169c5fcc94bf350",
["failure.response.payload.msgpack"] = "4bf7c80eb2d75b9559ab430757a98ebfd424745ed3fc1888d21bc6e41ed99403",
[".gitattributes"] = "171872d6445cb1ffef0263b613d1ac318a60398449831d4cc4f772f78a7ebcff",
["list-counts.request.frame.bin"] = "be338fc06f127f9c20f82952708ffe95f8b6b8de7c5098834eb8cb257893637f",
["list-counts.request.frame.hex"] = "496bbc58292a1d029361bd553eef2ccf786cfa6520b1a3ad97c831893c36cf50",
["list-counts.request.payload.hex"] = "5ec076a5d8006a0290ad269deffb2bfe45ac7ee8b6a11ea2d4968a6ac8603e4b",
["list-counts.request.payload.msgpack"] = "52f244c3d704067f79561c8d46f4069ac3f4452a73d916fb92154d28b28ea6c7",
["list-counts.response.frame.bin"] = "bf51c933fbbaa61934907cb66f5e07ac813de48e849ac80bdf3ba4f75d3952a3",
["list-counts.response.frame.hex"] = "3f395ae2302d0882fb2456fe94512bba045a1c967f98b4ebf6b306dd5e3d74a3",
["list-counts.response.payload.hex"] = "9a6f6b0be985d6ff8d313fa9e12ffe6662a59ff7305e574f4bfe8bf9a3ce2291",
["list-counts.response.payload.msgpack"] = "1f83a7c9ab1ce1af7c245a8f11f54ea67ef273fc2533554db3a1711e3f650c13",
["list-empty.response.frame.bin"] = "faf3c5008bd50c7f76121a3f27c5c4f944d1c7ad32d8bbd4e67d025c45b3f4f1",
["list-empty.response.frame.hex"] = "9b0f15bf8948e44f9e43718914f6621402c98c06e0d21bf90640c521ab7f1a45",
["list-empty.response.payload.hex"] = "d93cd65c1d9f4fc052b9c7f17d16ea6e468a7af296cafd6a31d671ae855d2860",
["list-empty.response.payload.msgpack"] = "7b1a26f303d3bdd0317d24a0eb4a2d9144ada0a878c927905a1a345efabf3a9f",
["list-open-cursor.request.frame.bin"] = "13671e1c83c10350d86b149a86a98b69763b9a3e40eae0024c10f3e70b96ef8b",
["list-open-cursor.request.frame.hex"] = "e2270f26d122374554855bf60686edbc5d4abfd63374cde08ab479a5f310aa12",
["list-open-cursor.request.payload.hex"] = "437f4c0cbdc712e9b3f8e1f8666cf8ffe88061cce8d0da3a64e648eb7b6bb27f",
["list-open-cursor.request.payload.msgpack"] = "05889e90f8143aa1a1375539f6e76593a682413b70cb40a8aaa3c7db2882622a",
["list-open.request.frame.bin"] = "2af34627a45d7bfca034b880bf61f5dfd78022a8df425f418c0a084181663903",
["list-open.request.frame.hex"] = "03f74ba78996b2ccb3fcf5fb712ae1d19f7594099ba04250ee0223c8631b9a24",
["list-open.request.payload.hex"] = "a58e8583d41d4a04cbd983c6b5a8bdae26105f2eb7f25dfb7d5f86aa81847e93",
["list-open.request.payload.msgpack"] = "431e000c3f002c254adc57273557c8111e815ca42a8dd4272762d3e7697d7f0a",
["list-response-too-large.response.frame.bin"] = "b95218e48c1f8e4162f515b97a85c324b9ccfe2b3216780ed0eca1d2e2066f19",
["list-response-too-large.response.frame.hex"] = "b4bedb2c59b1362e5059caf6dbea63952b8fe9c47b681da3825422bc677f8632",
["list-response-too-large.response.payload.hex"] = "4f5f18915e0e536c387e659eb2021570a5b0bc5e0f530521ca226dc687d6780d",
["list-response-too-large.response.payload.msgpack"] = "9749f0dc7a3ec69aba7f45ba62ef61332461747cf42b188b84998da7bbd4d5d9",
["list-too-large.response.frame.bin"] = "1210fb82189a23d7ee4eb3851d4b83013f4f2a2298533734df348156097b5c60",
["list-too-large.response.frame.hex"] = "aad762b1e1deea39330223eb9ea7ebd2bb0fdd3326e259383b90846c1310456f",
["list-too-large.response.payload.hex"] = "880ae2c51454a3d6b06d2e827e44b6990365d8cfa304b55e25eb898641117762",
["list-too-large.response.payload.msgpack"] = "710d145c1ded7e7d02343342becd61e4520da979580f13dbebe6f0eda5aabec6",
["README.md"] = "67158e6a566ad03e648c317d751ca59e6ea125daef56f60f5288bef1f8142ca1",
["read.request.frame.bin"] = "3a9bc16ce4ce706e8c839bf4cfe65d3e2fdf759992555e68be6fc7bcc15653ea",
["read.request.frame.hex"] = "3c3d9540c4af789d230b7108f98d5d5f3dd9b291ff618064ddc374eb2a7bb082",
["read.request.payload.hex"] = "48aed263254eac7bc156c1af1f2ee210a08dda2b453a5be2dc17083ab73800b2",
["read.request.payload.msgpack"] = "c5447f462247703af4329e071bc0ac14dce1cb94a63c85517400791d022263ca",
["read-too-large.response.frame.bin"] = "5995c03914851ea95aaff4b75fa3ebaee4f19a2c7ce101eab85b7475515690bd",
["read-too-large.response.frame.hex"] = "b150aa192bf3fa415b5175f8b9f43731c5b73c2a2153c87da2dc3a4b6d986ee2",
["read-too-large.response.payload.hex"] = "18b7ad6c617ee1d9a3aa726a012e9a3af8ca9c14b8af774d082cf17879c85b90",
["read-too-large.response.payload.msgpack"] = "4a9135ede7d6588ba65e49b372b99d5da956fab9c693f1aa018e0ebe1406246a",
["read-unknown.response.frame.bin"] = "a62293ff79c0bbe626d020140f4c8cbe3f62a87ac0f730f3ba3c2a7acd3d49a5",
["read-unknown.response.frame.hex"] = "97ab11a04a2d62917186b23666ed26ef09df220ffb1c3e188d919985085733e2",
["read-unknown.response.payload.hex"] = "ca8d4d1fa4d40915b6e062767967e62343b360ce669801da96d99509e24787fb",
["read-unknown.response.payload.msgpack"] = "0d4fe47159db88b5048bc1f42a0e72857b568eac48572777a0c896ee94f7c208",
["record-whole.payload.hex"] = "bf2530550ff5d2a7bf5c650e9af9d73dc8bcd4e4328fdc797ae6167b9e3d1a25",
["record-whole.payload.msgpack"] = "07f2687e9e9f169dded3e61c645fcd6d0a766f64a2b9d469eabb7ae98c6e254f",
["refused-unit.response.frame.bin"] = "4f86943a6947dd3c6041ee7d1b52d3f73697604dd224ca5dcf5b66064b0de1f1",
["refused-unit.response.frame.hex"] = "f25f35911d242d6fdb7ac4e1ecb3fe01901aa4b816d957104bde6f21b29a1bed",
["refused-unit.response.payload.hex"] = "858d49c8c66ce1b9c32160e2aecb320a8c00166728c80f5a02d62ec90b4f410d",
["refused-unit.response.payload.msgpack"] = "f2eedbece230b164adf1897c3a047317a4e3074b2d5fd4c5abef3fbb98336c32",
["unavailable.response.frame.bin"] = "0cb241568bb20d0ccf62f337f4daa01ca3dd82cedd476fb4c1f9e7dc3272cabe",
["unavailable.response.frame.hex"] = "86f0e5bb2d3e0ea39ae2f0dbc2c1b62e5a77a35bffc6f4adfa87d280d718bf44",
["unavailable.response.payload.hex"] = "5a8e7d6e3cd7abf3649941d6b55a511d025c2baf2d8dde08e505e57e9cb4fd22",
["unavailable.response.payload.msgpack"] = "dfa613abaf5f23e6aff5bcb0a59b3a1784f2fbf6d12cbdfa82f10e80963f0a4e",
};
// Every way the directory differs from the pin, by relative path (forward slashes).
public static List<string> Problems(string directory)
{
var present = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var file in new DirectoryInfo(directory).EnumerateFiles("*", SearchOption.AllDirectories))
{
var name = Path.GetRelativePath(directory, file.FullName).Replace('\\', '/');
present[name] = Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(file.FullName))).ToLowerInvariant();
}
var problems = new List<string>();
problems.AddRange(Sha256.Keys.Except(present.Keys, StringComparer.Ordinal).Order(StringComparer.Ordinal).Select(n => $"missing: {n}"));
problems.AddRange(present.Keys.Except(Sha256.Keys, StringComparer.Ordinal).Order(StringComparer.Ordinal).Select(n => $"extra: {n}"));
problems.AddRange(Sha256.Where(p => present.TryGetValue(p.Key, out var actual) && actual != p.Value).Select(p => $"changed: {p.Key}"));
return problems;
}
[Fact]
public void Fixtures_are_huginn_b299d6f7_byte_for_byte()
{
Assert.Equal(71, Sha256.Count);
Assert.True(Problems(Directory).Count == 0,
$"not Huginn {HuginnCommit}:crates/huginn-mind/tests/fixtures/desk-feed/: {string.Join("; ", Problems(Directory))}. " +
"Re-vendor from that commit or move the pin on purpose.");
}
private static string Copy()
{
var copy = System.IO.Directory.CreateTempSubdirectory("pin").FullName;
foreach (var file in new DirectoryInfo(Directory).EnumerateFiles("*", SearchOption.AllDirectories))
{
var to = Path.Combine(copy, Path.GetRelativePath(Directory, file.FullName));
System.IO.Directory.CreateDirectory(Path.GetDirectoryName(to)!);
file.CopyTo(to);
}
return copy;
}
[Theory]
[InlineData("list-open.request.frame.bin", "changed")]
[InlineData("cultlib-3a51bbab/entry-open-counted.msgpack", "changed")]
[InlineData("README.md", "changed")]
public void A_changed_byte_fails_the_pin(string name, string kind)
{
var copy = Copy();
try
{
var path = Path.Combine(copy, name);
var bytes = File.ReadAllBytes(path);
bytes[^1] ^= 1;
File.WriteAllBytes(path, bytes);
Assert.Equal([$"{kind}: {name}"], Problems(copy));
}
finally
{
System.IO.Directory.Delete(copy, true);
}
}
[Fact]
public void An_extra_or_missing_file_fails_the_pin_at_either_level()
{
var copy = Copy();
try
{
File.WriteAllText(Path.Combine(copy, "extra.hex"), "00\n");
File.WriteAllText(Path.Combine(copy, "cultlib-3a51bbab", "extra.msgpack"), "x");
File.Delete(Path.Combine(copy, "read.request.frame.hex"));
File.Delete(Path.Combine(copy, "cultlib-3a51bbab", "answer-counted.msgpack"));
Assert.Equal(
["missing: cultlib-3a51bbab/answer-counted.msgpack", "missing: read.request.frame.hex", "extra: cultlib-3a51bbab/extra.msgpack", "extra: extra.hex"],
Problems(copy));
}
finally
{
System.IO.Directory.Delete(copy, true);
}
}
}

View file

@ -78,33 +78,6 @@ public sealed class DeskReadTests
Assert.Equal(200, bare.Status);
}
[Fact]
public async Task List_scenarios_pass_through()
{
var steps = 0;
foreach (var scenario in DeskVectors.Rows("transport", "listScenarios"))
{
using var factory = new DeskWebApplicationFactory();
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true);
foreach (var step in scenario["steps"]!.AsArray().Select(s => s!.AsObject()))
{
var request = step["request"]!;
var response = step["response"]!.AsObject();
var list = response.Deserialize<ForkList>(DeskJson.Options)!;
factory.Door.ListScript = (_, _) => new DeskListOutcome.Listed(list);
var query = $"?status={(string?)request["status"]}&cursor={(string?)request["cursor"]}&limit={(int)request["limit"]!}";
var reply = await factory.SendAsync("GET", $"/api/desk/{(string)request["mind"]!}/forks" + query, null, ReadToken(factory));
Assert.Equal(200, reply.Status);
Assert.True(DeskVectors.SameShape(JsonNode.Parse(reply.Body), response), (string)scenario["name"]!);
steps++;
}
}
Assert.Equal(9, steps);
}
[Fact]
public async Task Read_returns_the_entry_unchanged_and_an_unknown_fork_is_refused()
{

View file

@ -0,0 +1,89 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using Bifrost.Web.Features.Desk;
using Bifrost.Web.Tests.Support;
namespace Bifrost.Web.Tests;
// CultLib d3025d3c's reader views (append-only on 3a51bbab), as Bifrost meets them: the 18-key card, DE0012 (raised_at, decider_count) stays opaque,
// the record is typed with answerer_count, and answerer is nil in a non-decider's view. Entries, records, list pages and
// stored answers are replayed from their wire bytes through the real door in DeskVectorWireTests.
public sealed class DeskReaderViewTests
{
private static readonly string[] CardKeys =
[
"schemaVersion", "fork_id", "title", "context", "options", "recommendation", "depends", "leans", "flag", "raised_in",
"raised_at", "deciders", "decider_count", "status", "ruling", "ruled_from", "order", "options_hash",
];
[Fact]
public void Valid_cards_are_the_17_key_card_with_both_views_and_pass_through_unchanged()
{
var rows = DeskVectors.Rows("card", "valid").ToList();
Assert.Equal(7, rows.Count);
foreach (var row in rows)
{
var message = row["message"]!.AsObject();
Assert.Equal(CardKeys, message.Select(p => p.Key));
Assert.StartsWith("DE0012", (string)row["messagePackHex"]!, StringComparison.Ordinal);
Assert.True(message["raised_at"] is null || message["raised_at"]!.GetValueKind() == JsonValueKind.String);
var count = (int)message["decider_count"]!;
Assert.InRange(count, 1, 64);
var deciders = message["deciders"]!.AsArray();
Assert.True(deciders.Count == 0 || deciders.Count == count, (string)row["name"]!);
var card = message.Deserialize<JsonElement>(DeskJson.Options);
Assert.True(DeskVectors.SameShape(message, JsonSerializer.SerializeToNode(card, DeskJson.Options)), (string)row["name"]!);
}
Assert.Contains(rows, r => r["message"]!["raised_at"] is null);
Assert.Contains(rows, r => r["message"]!["deciders"]!.AsArray().Count == 0 && (string)r["name"]! == "withheld_deciders");
}
[Fact]
public void The_options_hash_vectors_are_the_15_and_Bifrost_computes_no_hash()
{
Assert.Equal(15, DeskVectors.Array("optionsHash").Count);
Assert.Equal(9, DeskVectors.Array("bodyDigests").Count);
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null && !Directory.Exists(Path.Combine(directory.FullName, "src", "Bifrost.Web", "Features", "Desk")))
{
directory = directory.Parent;
}
Assert.NotNull(directory);
foreach (var file in Directory.GetFiles(Path.Combine(directory.FullName, "src", "Bifrost.Web", "Features", "Desk"), "*.cs"))
{
var text = File.ReadAllText(file);
Assert.DoesNotContain("System.Security.Cryptography", text, StringComparison.Ordinal);
Assert.DoesNotContain("SHA256", text, StringComparison.Ordinal);
}
}
[Fact]
public void Stored_answers_round_trip_and_every_variant_carries_a_nil_answerer_with_the_key_written()
{
var rows = DeskVectors.Rows("deskAnswer", "valid").ToList();
Assert.Equal(8, rows.Count);
Assert.Contains(rows, r => (string)r["name"]! == "withheld_answerer");
Assert.Contains(rows, r => (string)r["name"]! == "withheld_answerer_revision" && (string)r["message"]!["outcome"]! == "Revision");
Assert.Contains(rows, r => (string)r["name"]! == "withheld_answerer_retraction" && (string)r["message"]!["outcome"]! == "Retraction");
var outcomes = new HashSet<string>();
foreach (var row in rows)
{
var message = row["message"]!.AsObject();
Assert.StartsWith("DE0010", (string)row["messagePackHex"]!, StringComparison.Ordinal);
outcomes.Add((string)message["outcome"]!);
Assert.True(DeskVectors.SameShape(message, JsonSerializer.SerializeToNode(message.Deserialize<DeskAnswerDocument>(DeskJson.Options), DeskJson.Options)), (string)row["name"]!);
var withheld = JsonNode.Parse(message.ToJsonString())!.AsObject();
withheld["answerer"] = null;
var back = JsonSerializer.SerializeToNode(withheld.Deserialize<DeskAnswerDocument>(DeskJson.Options), DeskJson.Options)!.AsObject();
Assert.True(back.ContainsKey("answerer"));
Assert.Null(back["answerer"]);
Assert.True(DeskVectors.SameShape(withheld, back), (string)row["name"]!);
}
Assert.Superset(new HashSet<string> { "Counted", "Revision", "Retraction", "Refused" }, outcomes);
}
}

View file

@ -22,7 +22,7 @@ public sealed class DeskScenarioTests
{
using var factory = new DeskWebApplicationFactory();
var door = factory.Door;
door.Forks[(Socket, DeskReplay.ChatFork)] = DeskReplay.Entry(scenario["card"]!.Deserialize<ForkCard>(DeskJson.Options)!);
door.Forks[(Socket, DeskReplay.ChatFork)] = DeskReplay.Entry(scenario["card"]!.Deserialize<JsonElement>(DeskJson.Options));
var scenarioName = (string)scenario["name"]!;
foreach (var step in scenario["steps"]!.AsArray().Select(s => s!.AsObject()))
@ -36,7 +36,7 @@ public sealed class DeskScenarioTests
if (kind == "card")
{
door.Forks[(Socket, DeskReplay.ChatFork)] = DeskReplay.Entry(step["card"]!.Deserialize<ForkCard>(DeskJson.Options)!);
door.Forks[(Socket, DeskReplay.ChatFork)] = DeskReplay.Entry(step["card"]!.Deserialize<JsonElement>(DeskJson.Options));
continue;
}
@ -155,7 +155,7 @@ public sealed class DeskScenarioTests
var answer = message["answer"]!.Deserialize<DeskAnswerDocument>(DeskJson.Options)!;
var result = message.Deserialize<AnswerResult>(DeskJson.Options)!;
factory.Door.Forks[(Socket, answer.ForkId)] = DeskReplay.Entry(
message["card"] is { } card ? card.Deserialize<ForkCard>(DeskJson.Options)! : DeskReplay.Card(answer.ForkId));
message["card"] is { } card ? card.Deserialize<JsonElement>(DeskJson.Options) : DeskReplay.Card(answer.ForkId));
factory.Door.AnswerScript = (_, _, _) => new DeskAnswerOutcome.Answered(result with { Card = null });
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true);

View file

@ -0,0 +1,99 @@
using System.Reflection;
using System.Text.Json.Nodes;
using Bifrost.Web.Tests.Support;
namespace Bifrost.Web.Tests;
// Every section of the vendored vectors file (each top-level key, and each key under an object section down to depth 3) is
// classified here: replayed by a named test of Bifrost's, or not judged by Bifrost because the rule belongs to CultLib's
// validators and Huginn's door. A re-pin that adds a section fails until someone classifies it; a section that goes
// fails too. A replayed section names the test that replays it, and the test must exist.
public sealed class DeskVectorCensusTests
{
private const string Wire = nameof(DeskVectorWireTests);
// path -> "Class.Method" of a test that replays it.
private static readonly Dictionary<string, string> Replayed = new(StringComparer.Ordinal)
{
["card.valid"] = $"{Wire}.{nameof(DeskVectorWireTests.Every_valid_card_leaves_the_door_inside_an_entry_unchanged)}",
["request.valid"] = "DeskBodyShapeTests.Valid_request_vectors_pass",
["request.invalid"] = "DeskBodyShapeTests.Invalid_request_vectors_refuse_with_their_code",
["deskAnswer.valid"] = $"{Wire}.{nameof(DeskVectorWireTests.Every_valid_stored_answer_leaves_an_answered_reply_unchanged_the_withheld_variants_included)}",
["results"] = "DeskScenarioTests.Answer_results_pass_through_unchanged",
["doorRefusals"] = "DeskDocumentTests.Refusal_and_unavailable_documents_match_vectors",
["unavailable"] = "DeskDocumentTests.Refusal_and_unavailable_documents_match_vectors",
["scenarios"] = "DeskScenarioTests.Every_scenario_answer_step_replays_through_http",
["transport.listRequest.valid"] = "DeskReadTests.Valid_list_queries_reach_the_door_as_typed_requests",
["transport.listRequest.invalid"] = "DeskReadTests.List_and_read_request_invalid_vectors",
["transport.entry.valid"] = $"{Wire}.{nameof(DeskVectorWireTests.Every_valid_entry_leaves_a_read_unchanged_in_both_views)}",
["transport.entry.invalid"] = $"{Wire}.{nameof(DeskVectorWireTests.The_door_refuses_the_entry_negatives_that_break_its_typing_and_judges_no_rule_of_the_contract)}",
["transport.listInvalid"] = $"{Wire}.{nameof(DeskVectorWireTests.The_door_refuses_a_negative_count_and_judges_no_other_list_rule)}",
["transport.listScenarios"] = $"{Wire}.{nameof(DeskVectorWireTests.Every_list_page_in_the_vectors_leaves_the_door_as_the_vectors_message)}",
["transport.listShortPages"] = $"{Wire}.{nameof(DeskVectorWireTests.Every_list_page_in_the_vectors_leaves_the_edge_as_the_vectors_message_for_its_reader)}",
["transport.readTooLarge"] = "DeskReadTests.A_read_past_the_cap_follows_the_vectors",
["transport.answerTooLarge"] = "DeskAnswerTests.A_stale_answer_over_an_oversize_fork_follows_the_vector_fresh_and_replayed",
["transport.doorFaults"] = "DeskReadTests.Door_faults_follow_the_vectors_per_route",
["transport.mindRoutes.minds"] = "DeskMindRoutesTests.Every_vector_segment_resolves_or_misses",
["transport.mindRoutes.forks"] = "DeskMindRouteTests.Vector_route_cases",
["transport.mindRoutes.cases"] = "DeskMindRouteTests.Vector_route_cases",
["transport.mindRoutes.sameResponse"] = "DeskMindRouteTests.Same_response_pairs_are_byte_equal",
["transport.authCases"] = "DeskAuthCaseTests.Vector_auth_cases",
};
// path -> why Bifrost does not judge it.
private static readonly Dictionary<string, string> NotJudged = new(StringComparer.Ordinal)
{
["provenance"] = "metadata text, not a vector",
["optionsHash"] = "Bifrost computes no hash of a card (DeskReaderViewTests pins the 15 and that the Desk source holds no SHA256); CultLib's validators and Huginn's door own the options hash",
["bodyDigests"] = "the body digest behind request_id_conflict is the receiver's (Huginn's door); Bifrost forwards the body as written (DeskReaderViewTests pins the 9)",
["card.invalid"] = "the card is an opaque map to Bifrost; every card rule (R-card, depends, options, deciders, hash) is CultLib's validators' and Huginn's door's",
["deskAnswer.invalid"] = "outcome, code and target pairing are CultLib's and Huginn's; the door judges only the closed 16-key typed shape (all twelve have it; DeskVectorWireTests passes them through)",
["resultsInvalid"] = "R-answer-result (the sender's own answer carries their account) is Huginn's door's rule; DeskVectorWireTests reads the three as any answer",
["rulingCites"] = "which answer a ruling may cite is a rule of CultLib's validators and the ruling's author; Bifrost forwards answers and rules on nothing",
["transport.readRequest.valid"] = "the read request is the route's own path segments (mind, fork id); the golden read.request frame pins what the door sends",
["transport.readRequest.invalid"] = "an empty fork id or mind is a routing miss before any request exists (DeskReadTests, DeskMindRoutesTests)",
};
private static IEnumerable<string> Sections(JsonNode node, string path, int depth)
{
if (node is JsonObject map && depth < 3)
{
foreach (var (key, value) in map)
{
foreach (var section in Sections(value!, path.Length == 0 ? key : path + "." + key, depth + 1))
{
yield return section;
}
}
}
else
{
yield return path;
}
}
[Fact]
public void Every_section_of_the_vendored_vectors_is_classified_replayed_or_not_judged_and_no_section_is_both()
{
var sections = Sections(DeskVectors.Root, string.Empty, 0).Order(StringComparer.Ordinal).ToList();
Assert.Empty(Replayed.Keys.Intersect(NotJudged.Keys));
Assert.Equal(sections, Replayed.Keys.Concat(NotJudged.Keys).Order(StringComparer.Ordinal).ToList());
Assert.Contains("transport.entry.invalid", sections);
Assert.Contains("resultsInvalid", sections);
}
[Fact]
public void Every_replayed_section_names_a_test_that_exists()
{
var assembly = typeof(DeskVectorCensusTests).Assembly;
foreach (var (section, test) in Replayed)
{
var (className, method) = (test[..test.IndexOf('.', StringComparison.Ordinal)], test[(test.IndexOf('.', StringComparison.Ordinal) + 1)..]);
var type = assembly.GetType("Bifrost.Web.Tests." + className);
Assert.True(type is not null, $"{section}: no test class {className}");
var found = type.GetMethods(BindingFlags.Public | BindingFlags.Instance).FirstOrDefault(m => m.Name == method);
Assert.True(found is not null && found.GetCustomAttributes<FactAttribute>().Any(), $"{section}: no test {test}");
}
}
}

View file

@ -6,8 +6,9 @@ namespace Bifrost.Web.Tests;
public sealed class DeskVectorPinTests
{
[Fact]
public void Vectors_are_cultlib_de8a062a()
public void Vectors_are_cultlib_d3025d3c()
{
Assert.StartsWith("d3025d3c", DeskVectors.SourceCommit, StringComparison.Ordinal);
var sha = Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(DeskVectors.Path))).ToLowerInvariant();
Assert.True(

View file

@ -0,0 +1,236 @@
using System.Text.Json.Nodes;
using Bifrost.Web.Features.Desk;
using Bifrost.Web.Tests.Support;
namespace Bifrost.Web.Tests;
// CultLib's vectors, replayed from their WIRE form (responseHex / messagePackHex) through the real UnixSocketDeskDoor on
// a socket double, then compared with the vector's `message`: same keys in the same order, integers unchanged
// (compared as JSON text; the vector side is parsed JSON, so an escaped apostrophe cannot differ). Only what Huginn
// sends is served here. The refusals, 503 documents and edge-authored results in the vectors are Bifrost's own
// output and are replayed on the edge (DeskReadTests, DeskAnswerTests, DeskDocumentTests).
//
// What the door judges of a reply (the single owner is the wire walker's Closed): every contract document is a closed
// map with every key present and typed; the card inside an entry is an opaque map. What it does not judge: every
// rule a schema cannot say (R-card, R-record, R-entry, R-list, R-answer-result, option and hash rules, ordering,
// the reader-view agreement between deciders and answerers). Those belong to CultLib's validators and to Huginn's door;
// Bifrost holds no audience rule and masks nothing it cannot read.
public sealed class DeskVectorWireTests
{
private static string Mind(JsonNode request) => (string)request["mind"]!;
private static IEnumerable<(string Name, JsonNode Request, JsonNode Response, string Hex)> ListSteps()
{
foreach (var scenario in DeskVectors.Rows("transport", "listScenarios").Concat(DeskVectors.Rows("transport", "listShortPages")))
{
foreach (var step in scenario["steps"]!.AsArray().Select(s => s!.AsObject()).Where(s => s.ContainsKey("responseHex")))
{
yield return ((string)scenario["name"]!, step["request"]!, step["response"]!, (string)step["responseHex"]!);
}
}
}
[Fact]
public async Task Every_list_page_in_the_vectors_leaves_the_door_as_the_vectors_message()
{
var steps = 0;
foreach (var (name, request, response, hex) in ListSteps())
{
var list = await DeskServe.ListAsync(Convert.FromHexString(hex), Mind(request));
Assert.True(DeskServe.Text(response) == DeskServe.Text(list), name);
steps++;
}
Assert.Equal(16, steps);
}
[Fact]
public async Task Every_list_page_in_the_vectors_leaves_the_edge_as_the_vectors_message_for_its_reader()
{
var steps = 0;
foreach (var (name, request, response, hex) in ListSteps())
{
var scenario = DeskVectors.Rows("transport", "listScenarios").FirstOrDefault(s => (string)s["name"]! == name);
var nonDecider = scenario is not null && scenario.ContainsKey("twin");
using var huginn = DeskServe.Socket(DeskServe.Forks(Convert.FromHexString(hex)));
using var factory = new DeskWebApplicationFactory(DeskServe.ConfigurationOn(Mind(request), huginn.Path), fakeDoor: false);
var reader = nonDecider ? DeskWebApplicationFactory.Member : DeskWebApplicationFactory.Alice;
await factory.EnsureAccountAsync(reader, true);
var query = $"?status={(string?)request["status"]}&cursor={(string?)request["cursor"]}&limit={(int)request["limit"]!}";
var reply = await factory.SendAsync("GET", $"/api/desk/{Mind(request)}/forks" + query, null, factory.Token(reader, "bifrost-desk", "desk.read"));
// The edge's one change for a non-decider: every note nil, the key kept.
var expected = JsonNode.Parse(response.ToJsonString())!;
if (nonDecider)
{
foreach (var answer in expected["forks"]!.AsArray().SelectMany(f => f!["record"]!["answers"]!.AsArray()))
{
answer!["note"] = null;
}
}
Assert.Equal(200, reply.Status);
Assert.True(DeskServe.Text(expected) == DeskServe.Text(JsonNode.Parse(reply.Body)!), name);
steps++;
}
Assert.Equal(16, steps);
}
[Fact]
public async Task Every_valid_entry_leaves_a_read_unchanged_in_both_views()
{
var rows = DeskVectors.Rows("transport", "entry", "valid").ToList();
Assert.Equal(8, rows.Count);
foreach (var row in rows)
{
var entry = await DeskServe.ReadAsync(Convert.FromHexString((string)row["messagePackHex"]!));
Assert.True(DeskServe.Text(row["message"]!) == DeskServe.Text(entry), (string)row["name"]!);
}
}
[Fact]
public async Task Every_valid_card_leaves_the_door_inside_an_entry_unchanged()
{
var rows = DeskVectors.Rows("card", "valid").ToList();
Assert.Equal(7, rows.Count);
foreach (var row in rows)
{
var card = row["message"]!;
var record = DeskServe.FromJson(JsonNode.Parse($$"""{"schemaVersion":"gamecult.fork.record.v0","fork_id":"{{(string)card["fork_id"]!}}","options_hash":"{{(string)card["options_hash"]!}}","counted_answer_id":null,"answerer_count":0,"answers":[]}""")!);
var entry = await DeskServe.ReadAsync(DeskServe.EntryOf(Convert.FromHexString((string)row["messagePackHex"]!), record));
Assert.True(DeskServe.Text(card) == DeskServe.Text(entry.Card), (string)row["name"]!);
}
}
[Fact]
public async Task Every_scenario_record_leaves_the_door_unchanged_the_6_key_record()
{
var count = 0;
foreach (var scenario in DeskVectors.Rows("scenarios"))
{
var hex = (string)scenario["recordHex"]!;
Assert.StartsWith("86", hex, StringComparison.Ordinal);
var entry = await DeskServe.ReadAsync(DeskServe.EntryOf(DeskServe.FromJson(scenario["card"]!), Convert.FromHexString(hex)));
Assert.True(DeskServe.Text(scenario["record"]!) == DeskServe.Text(entry.Record), (string)scenario["name"]!);
count++;
}
Assert.Equal(63, count);
}
[Fact]
public async Task Every_valid_stored_answer_leaves_an_answered_reply_unchanged_the_withheld_variants_included()
{
var rows = DeskVectors.Rows("deskAnswer", "valid").ToList();
Assert.Equal(8, rows.Count);
foreach (var row in rows)
{
var answer = await DeskServe.AnswerAsync(Convert.FromHexString((string)row["messagePackHex"]!));
Assert.True(DeskServe.Text(row["message"]!) == DeskServe.Text(answer), (string)row["name"]!);
}
Assert.Contains(rows, r => (string)r["name"]! == "withheld_answerer_revision" && (string)r["message"]!["outcome"]! == "Revision" && r["message"]!["answerer"] is null);
Assert.Contains(rows, r => (string)r["name"]! == "withheld_answerer_retraction" && (string)r["message"]!["outcome"]! == "Retraction" && r["message"]!["answerer"] is null);
}
// The entry negatives on the wire. The door refuses the four that break its closed-map typing: an entry without its
// record, and a missing, negative or nil answerer_count (never a count Bifrost may invent). The other fourteen are
// rules a schema cannot say (R-record, R-entry, the options-hash and derivation rules; the two new ones pin a
// withheld card beside a named Retraction and a count over the answers): CultLib's validators and Huginn's door
// judge them, Bifrost reads a closed map and passes it, and a change to either side shows here.
private static readonly HashSet<string> EntryNegativesTheDoorRefuses =
["entry_without_record", "answerer_count_missing", "answerer_count_negative", "answerer_count_null"];
[Fact]
public async Task The_door_refuses_the_entry_negatives_that_break_its_typing_and_judges_no_rule_of_the_contract()
{
var rows = DeskVectors.Rows("transport", "entry", "invalid").ToList();
Assert.Equal(18, rows.Count);
Assert.Contains(rows, r => (string)r["name"]! == "withheld_revision_and_retraction_beside_a_named_retraction");
Assert.Contains(rows, r => (string)r["name"]! == "withheld_revision_and_retraction_count_over_the_answers");
foreach (var row in rows)
{
var name = (string)row["name"]!;
var served = Task.Run(() => DeskServe.ReadAsync(Convert.FromHexString((string)row["messagePackHex"]!)));
if (EntryNegativesTheDoorRefuses.Contains(name))
{
await Assert.ThrowsAsync<DeskDoorException>(() => served);
}
else
{
var entry = await served;
Assert.True(DeskServe.Text(row["message"]!) == DeskServe.Text(entry), name);
}
}
}
// The list negatives on the wire. The door refuses count_negative (a count is an unsigned integer in its closed typing).
// The others are rules a schema cannot say or text grammars the door does not hold: a cursor is c<N> and a stamp is s<N>
// (Bifrost passes both on, opaque), a page is one reader's view (R-list), and an order is unique among a Mind's cards.
// CultLib's validators and Huginn's door judge them.
[Fact]
public async Task The_door_refuses_a_negative_count_and_judges_no_other_list_rule()
{
var rows = DeskVectors.Rows("transport", "listInvalid").ToList();
Assert.Equal(5, rows.Count);
foreach (var row in rows)
{
var name = (string)row["name"]!;
var served = Task.Run(() => DeskServe.ListAsync(Convert.FromHexString((string)row["messagePackHex"]!)));
if (name == "count_negative")
{
await Assert.ThrowsAsync<DeskDoorException>(() => served);
}
else
{
Assert.True(DeskServe.Text(row["message"]!) == DeskServe.Text(await served), name);
}
}
}
// The stored-answer negatives and the answer_result negatives. The door judges that an answer is a closed 16-key map with
// every key typed; all twelve deskAnswer negatives have the 16 keys, so none is refused here. They are rules (outcome
// and code pairing, targets, case, and "no_answerer": a nil answerer on a Counted answer is a withheld view only a
// reader can tell from a fault) that the contract's validators and Huginn's door own. A MISSING answerer key is the
// door's fault, pinned in UnixSocketDeskDoorWireTests. The three resultsInvalid vectors are R-answer-result (the
// sender's own answer carries their account, never nil): Huginn's rule; for Bifrost a nil answerer in an Answered
// reply is a string-or-nil key like any other. Whether the edge should add that check is not decided here.
[Fact]
public async Task The_door_judges_no_deskAnswer_rule_of_the_contract_only_its_closed_typed_shape()
{
var rows = DeskVectors.Rows("deskAnswer", "invalid").ToList();
Assert.Equal(12, rows.Count);
foreach (var row in rows)
{
var answer = await DeskServe.AnswerAsync(Convert.FromHexString((string)row["messagePackHex"]!));
Assert.True(DeskServe.Text(row["message"]!) == DeskServe.Text(answer), (string)row["name"]!);
}
}
[Fact]
public async Task The_three_answer_result_negatives_carry_an_answer_the_door_reads_as_any_answer()
{
var rows = DeskVectors.Rows("resultsInvalid").ToList();
Assert.Equal(3, rows.Count);
foreach (var row in rows)
{
var answer = row["message"]!["answer"]!;
var served = await DeskServe.AnswerAsync(DeskServe.FromJson(answer));
Assert.Null(served.Answerer);
Assert.True(DeskServe.Text(answer) == DeskServe.Text(served), (string)row["name"]!);
}
}
}

View file

@ -0,0 +1,3 @@
fork-card-vectors.json text eol=lf
desk-feed/**/*.md text eol=lf
desk-feed/*.md text eol=lf

View file

@ -0,0 +1,3 @@
*.bin binary
*.msgpack binary
*.hex text eol=lf

View file

@ -0,0 +1,44 @@
# Desk feed golden fixtures
The bytes on a Mind's desk socket for a list or a read. The contract is `docs/desk-feed-door.md`.
`crates/huginn-mind/tests/desk_feed_fixtures.rs` writes these files with Huginn's own encoders and
checks them on every `cargo test -p huginn-mind --test desk_feed_fixtures`. Set
`HUGINN_WRITE_DESK_FIXTURES=1` to rewrite them. Do not edit them by hand.
Each case has up to four files:
- `<case>.frame.bin` and `<case>.frame.hex`: the whole frame on the socket (u32 big-endian length,
then the CultNet message).
- `<case>.payload.msgpack` and `<case>.payload.hex`: the base64-decoded `payload` of an operation
message (named MessagePack). The `record-*` cases are bare payload fragments with no frame.
Hex is lowercase with one trailing newline. The fork id is a question Eureka's mind holds. The
record's answers, notes and hash are synthetic.
**The listing gate is not live.** On `main` every showable question goes to every reader. Do not
put a Mind that holds private questions behind a live receiver before `cut-desk-feed-listing`
lands (see `docs/desk-feed-door.md`).
| case | direction | content |
|---|---|---|
| `list-open.request` | Bifrost to Huginn | `Forks`, status open, no cursor, limit 50 |
| `list-open-cursor.request` | Bifrost to Huginn | `Forks`, status open, cursor `c42`, limit 50 |
| `list-counts.request` | Bifrost to Huginn | `Forks`, status open, limit 0 (counts and stamp only) |
| `read.request` | Bifrost to Huginn | `Fork` for one fork id |
| `list-empty.response` | Huginn to Bifrost | an empty Mind, or nothing listed to this reader: counts 0, `forks: []` |
| `list-counts.response` | Huginn to Bifrost | the limit-0 answer: open 3, recorded 5, `forks: []` |
| `read-unknown.response` | Huginn to Bifrost | `NotStored` `fork_unknown` (Bifrost: 404) |
| `list-too-large.response` | Huginn to Bifrost | `NotStored` `fork_too_large` naming the cursor to continue (Bifrost: 422) |
| `read-too-large.response` | Huginn to Bifrost | `NotStored` `fork_too_large`, read form: names the fork, no order or cursor (Bifrost: 422) |
| `list-response-too-large.response` | Huginn to Bifrost | `Refused` `ResponseTooLarge` (Bifrost: 503) |
| `refused-unit.response` | Huginn to Bifrost | `Refused` with a unit variant: a bare string `"MissingIdentity"` (shape only; Bifrost: 503) |
| `unavailable.response` | Huginn to Bifrost | `Refused` `Unavailable` (Bifrost: 503 for this Mind) |
| `failure.response` | Huginn to Bifrost | operation failure, `payloadSchema` `gamecult.cultnet.operation_failure.v1` (Bifrost: 503) |
| `error.response` | Huginn to Bifrost | CultNet `Error` message, no payload (Bifrost: 503) |
| `record-whole` | fragment | `gamecult.fork.record.v0` with all four answer variants (Counted, Revision, Refused, Retraction) by three accounts, answerer ids and notes whole: what the door sends today (5 keys, no `answerer_count`) |
The reader-count shapes (`raised_at`, `decider_count`, `answerer_count`, nullable `answerer`) are
CultLib's own bytes, vendored unedited in `cultlib-3a51bbab/` (see its README for the pinned commit and
each vector). They are not Huginn's output: Huginn's types do not carry those shapes yet.
`cut-desk-feed-listing` and `cut-desk-feed-raised-at` replace them with entry-bearing fixtures the
encoders write, after decoding these bytes into the real types.

View file

@ -0,0 +1,26 @@
# CultLib desk-feed vectors, vendored
Source: `GameCult/CultLib` at commit `3a51bbabad244a56ec4521080530d88744001ce4` (#117, on
`af914253` #115), file `contracts/cultnet/governance/fork-card-vectors.json`. Each `.msgpack` file is
the vector's `messagePackHex` decoded to bytes, nothing else changed. Do not edit them or rewrite them
from Huginn's encoders: Huginn's own types do not carry these shapes yet (see
`docs/desk-feed-door.md`). To re-pin, take the new commit's vectors and update the sha here, in
`docs/desk-feed-door.md` and in `CULTLIB_SHA` in `desk_feed_fixtures.rs`.
| file | upstream vector | what it pins |
|---|---|---|
| `card-operator-fork` | `card.valid` `operator_fork` | 18 keys (`DE 00 12`), `raised_at` a string, a decider's `deciders` and `decider_count` 1 |
| `card-raised-at-nil` | `card.valid` `raised_at_nil` | `raised_at` nil, key kept |
| `card-withheld-deciders` | `card.valid` `withheld_deciders` | empty `deciders`, `decider_count` 2 |
| `answer-counted` | `deskAnswer.valid` `counted` | 16 keys (`DE 00 10`), `answerer` a string |
| `answer-revision` | `deskAnswer.valid` `revision` | a `Revision`, `answerer` a string (no CultLib vector nulls a Revision) |
| `answer-retraction` | `deskAnswer.valid` `retraction` | a `Retraction`, `answerer` a string (no CultLib vector nulls a Retraction) |
| `answer-withheld-answerer` | `deskAnswer.valid` `withheld_answerer` | `answerer` nil |
| `answer-refused-stale` | `deskAnswer.valid` `refused_stale` | a `Refused` answer, `answerer` a string |
| `entry-open-counted` | `transport.entry.valid` `open_counted` | decider view: entry `83`, record `86`, `answerer_count` 1 |
| `entry-withheld-open-counted` | `transport.entry.valid` `withheld_open_counted` | non-decider twin: `deciders` empty, `answerer` nil, count 1 |
| `entry-closed-with-ruling-and-a-refusal` | `transport.entry.valid` `closed_with_ruling_and_a_refusal` | decider view, a refusal among the answers, `answerer_count` 2 |
| `entry-withheld-closed-with-ruling-and-a-refusal` | `transport.entry.valid` `withheld_closed_with_ruling_and_a_refusal` | its non-decider twin, count 2 |
The test `desk_feed_fixtures.rs` checks each file's SHA-256, decodes it with a decoder of its own
(not `rmp_serde`), and then tries Huginn's real types.

View file

@ -0,0 +1 @@
0000007385ad736368656d6156657273696f6eb063756c746e65742e6572726f722e7630a56572726f72d92f687567696e6e2e6465736b20726566757365732061206d6573736167655f6964206f76657220323536206279746573ab726f7574696e6748696e74c0a4636f6465c0a764657461696c73c0

View file

@ -0,0 +1 @@
000001708aab646961676e6f737469637391b57061796c6f61642d6e6f742d612d72657175657374a96d6573736167654964a36d2d31a96f7065726174696f6ea5666f726b73a77061796c6f6164d95c6771526a6232526c745842686557787659575174626d39304c574574636d56786457567a644b64745a584e7a5957646c76325a705a57786b49455a76636d747a4c6d787062576c304f6942766458517462325974636d46755a32553daf7061796c6f6164456e636f64696e67b26d6573736167657061636b2d626173653634ad7061796c6f6164536368656d61d92567616d6563756c742e63756c746e65742e6f7065726174696f6e5f6661696c7572652e7631ad736368656d6156657273696f6ebd63756c746e65742e6f7065726174696f6e5f726573706f6e73652e7630a9736572766963654964ab687567696e6e2e6465736baf736f7572636552756e74696d654964ad687567696e6e2d657572656b61a6737461747573a872656a6563746564

View file

@ -0,0 +1 @@
82a4636f6465b57061796c6f61642d6e6f742d612d72657175657374a76d657373616765bf6669656c6420466f726b732e6c696d69743a206f75742d6f662d72616e6765

View file

@ -0,0 +1 @@
うcodeオpayload-not-a-requestァmessageソfield Forks.limit: out-of-range

View file

@ -0,0 +1 @@
000001a287a96d6573736167654964a36d2d33a96f7065726174696f6ea5666f726b73a77061796c6f6164d9f06761564762334a726334616b62576c755a4b5a6c64584a6c6132476e63335669616d566a644e6b6b4e575977597a4a694d5755744f4745305a433030597a4e694c546c6c4e3259744d574579596a4e6a4e4751315a545a6d7147526c59326c6b5a584a7a6b746b6b4e575977597a4a694d5755744f4745305a433030597a4e694c546c6c4e3259744d574579596a4e6a4e4751315a545a6d32535177596a6c6b4e32557a59533032597a49784c54526d4e5467745954426b4e4330355a54686d4e324532596a566a4e47536d633352686448567a704739775a57366d5933567963323979774b5673615731706441413daf7061796c6f6164456e636f64696e67b26d6573736167657061636b2d626173653634ad7061796c6f6164536368656d61b6687567696e6e2e6465736b5f726571756573742e7631ad736368656d6156657273696f6ebc63756c746e65742e6f7065726174696f6e5f726571756573742e7630a9736572766963654964ab687567696e6e2e6465736b

View file

@ -0,0 +1 @@
81a5466f726b7386a46d696e64a6657572656b61a77375626a656374d92435663063326231652d386134642d346333622d396537662d316132623363346435653666a8646563696465727392d92435663063326231652d386134642d346333622d396537662d316132623363346435653666d92430623964376533612d366332312d346635382d613064342d396538663761366235633464a6737461747573a46f70656ea6637572736f72c0a56c696d697400

View file

@ -0,0 +1 @@
0000019289a96d6573736167654964a36d2d33a96f7065726174696f6ea5666f726b73a77061796c6f6164d9b06761564762334a7263344b6b62476c7a6449657463324e6f5a573168566d567963326c766272566e5957316c593356736443356d62334a724c6d787063335175646a436b62576c755a4b5a6c64584a6c6132476c633352686258436d637a45774d546b77716d39775a57356659323931626e5144726e4a6c593239795a47566b58324e76645735304261566d62334a7263354372626d56346446396a64584a7a62334c417057467a5832396d7a53664faf7061796c6f6164456e636f64696e67b26d6573736167657061636b2d626173653634ad7061796c6f6164536368656d61b7687567696e6e2e6465736b5f726573706f6e73652e7631ad736368656d6156657273696f6ebd63756c746e65742e6f7065726174696f6e5f726573706f6e73652e7630a9736572766963654964ab687567696e6e2e6465736baf736f7572636552756e74696d654964ad687567696e6e2d657572656b61a6737461747573a86163636570746564

View file

@ -0,0 +1 @@
81a5466f726b7382a46c69737487ad736368656d6156657273696f6eb567616d6563756c742e666f726b2e6c6973742e7630a46d696e64a6657572656b61a57374616d70a6733130313930aa6f70656e5f636f756e7403ae7265636f726465645f636f756e7405a5666f726b7390ab6e6578745f637572736f72c0a561735f6f66cd27ce

View file

@ -0,0 +1 @@
▼Forksうlist<EFBFBD>schemaVersionオgamecult.fork.list.v0、mindヲeureka・stampヲs10190ェopen_countョrecorded_count・forks性next_cursorタ・as_ofヘ'ホ

View file

@ -0,0 +1 @@
0000019289a96d6573736167654964a36d2d31a96f7065726174696f6ea5666f726b73a77061796c6f6164d9b06761564762334a7263344b6b62476c7a6449657463324e6f5a573168566d567963326c766272566e5957316c593356736443356d62334a724c6d787063335175646a436b62576c755a4b5a6c64584a6c6132476c633352686258436d637a45774d546b77716d39775a57356659323931626e5141726e4a6c593239795a47566b58324e7664573530414b566d62334a7263354372626d56346446396a64584a7a62334c417057467a5832396d7a53664faf7061796c6f6164456e636f64696e67b26d6573736167657061636b2d626173653634ad7061796c6f6164536368656d61b7687567696e6e2e6465736b5f726573706f6e73652e7631ad736368656d6156657273696f6ebd63756c746e65742e6f7065726174696f6e5f726573706f6e73652e7630a9736572766963654964ab687567696e6e2e6465736baf736f7572636552756e74696d654964ad687567696e6e2d657572656b61a6737461747573a86163636570746564

View file

@ -0,0 +1 @@
81a5466f726b7382a46c69737487ad736368656d6156657273696f6eb567616d6563756c742e666f726b2e6c6973742e7630a46d696e64a6657572656b61a57374616d70a6733130313930aa6f70656e5f636f756e7400ae7265636f726465645f636f756e7400a5666f726b7390ab6e6578745f637572736f72c0a561735f6f66cd27ce

View file

@ -0,0 +1 @@
000001a687a96d6573736167654964a36d2d32a96f7065726174696f6ea5666f726b73a77061796c6f6164d9f46761564762334a726334616b62576c755a4b5a6c64584a6c6132476e63335669616d566a644e6b6b4e575977597a4a694d5755744f4745305a433030597a4e694c546c6c4e3259744d574579596a4e6a4e4751315a545a6d7147526c59326c6b5a584a7a6b746b6b4e575977597a4a694d5755744f4745305a433030597a4e694c546c6c4e3259744d574579596a4e6a4e4751315a545a6d32535177596a6c6b4e32557a59533032597a49784c54526d4e5467745954426b4e4330355a54686d4e324532596a566a4e47536d633352686448567a704739775a57366d59335679633239796f324d304d715673615731706444493daf7061796c6f6164456e636f64696e67b26d6573736167657061636b2d626173653634ad7061796c6f6164536368656d61b6687567696e6e2e6465736b5f726571756573742e7631ad736368656d6156657273696f6ebc63756c746e65742e6f7065726174696f6e5f726571756573742e7630a9736572766963654964ab687567696e6e2e6465736b

View file

@ -0,0 +1 @@
81a5466f726b7386a46d696e64a6657572656b61a77375626a656374d92435663063326231652d386134642d346333622d396537662d316132623363346435653666a8646563696465727392d92435663063326231652d386134642d346333622d396537662d316132623363346435653666d92430623964376533612d366332312d346635382d613064342d396538663761366235633464a6737461747573a46f70656ea6637572736f72a3633432a56c696d697432

View file

@ -0,0 +1 @@
<EFBFBD>¥Forks†¤mind¦eureka§subjectÙ$5f0c2b1e-8a4d-4c3b-9e7f-1a2b3c4d5e6f¨deciders’Ù$5f0c2b1e-8a4d-4c3b-9e7f-1a2b3c4d5e6fÙ$0b9d7e3a-6c21-4f58-a0d4-9e8f7a6b5c4d¦status¤open¦cursor£c42¥limit2

View file

@ -0,0 +1 @@
000001a287a96d6573736167654964a36d2d31a96f7065726174696f6ea5666f726b73a77061796c6f6164d9f06761564762334a726334616b62576c755a4b5a6c64584a6c6132476e63335669616d566a644e6b6b4e575977597a4a694d5755744f4745305a433030597a4e694c546c6c4e3259744d574579596a4e6a4e4751315a545a6d7147526c59326c6b5a584a7a6b746b6b4e575977597a4a694d5755744f4745305a433030597a4e694c546c6c4e3259744d574579596a4e6a4e4751315a545a6d32535177596a6c6b4e32557a59533032597a49784c54526d4e5467745954426b4e4330355a54686d4e324532596a566a4e47536d633352686448567a704739775a57366d5933567963323979774b5673615731706444493daf7061796c6f6164456e636f64696e67b26d6573736167657061636b2d626173653634ad7061796c6f6164536368656d61b6687567696e6e2e6465736b5f726571756573742e7631ad736368656d6156657273696f6ebc63756c746e65742e6f7065726174696f6e5f726571756573742e7630a9736572766963654964ab687567696e6e2e6465736b

View file

@ -0,0 +1 @@
81a5466f726b7386a46d696e64a6657572656b61a77375626a656374d92435663063326231652d386134642d346333622d396537662d316132623363346435653666a8646563696465727392d92435663063326231652d386134642d346333622d396537662d316132623363346435653666d92430623964376533612d366332312d346635382d613064342d396538663761366235633464a6737461747573a46f70656ea6637572736f72c0a56c696d697432

View file

@ -0,0 +1 @@
<EFBFBD>¥Forks†¤mind¦eureka§subjectÙ$5f0c2b1e-8a4d-4c3b-9e7f-1a2b3c4d5e6f¨deciders’Ù$5f0c2b1e-8a4d-4c3b-9e7f-1a2b3c4d5e6fÙ$0b9d7e3a-6c21-4f58-a0d4-9e8f7a6b5c4d¦status¤open¦cursorÀ¥limit2

View file

@ -0,0 +1 @@
0000012689a96d6573736167654964a36d2d31a96f7065726174696f6ea5666f726b73a77061796c6f6164d944676164535a575a316332566b676242535a584e776232357a5a56527662307868636d646c677156696558526c63383441415246777057787062576c307a6741424141413daf7061796c6f6164456e636f64696e67b26d6573736167657061636b2d626173653634ad7061796c6f6164536368656d61b7687567696e6e2e6465736b5f726573706f6e73652e7631ad736368656d6156657273696f6ebd63756c746e65742e6f7065726174696f6e5f726573706f6e73652e7630a9736572766963654964ab687567696e6e2e6465736baf736f7572636552756e74696d654964ad687567696e6e2d657572656b61a6737461747573a872656a6563746564

View file

@ -0,0 +1 @@
81a75265667573656481b0526573706f6e7365546f6f4c6172676582a56279746573ce00011170a56c696d6974ce00010000

View file

@ -0,0 +1 @@
0000020b89a96d6573736167654964a36d2d31a96f7065726174696f6ea5666f726b73a77061796c6f6164da012867616c4f62335254644739795a57534472584e6a6147567459565a6c636e4e70623236345a3246745a574e31624851755a6d3979617935795a575a31633246734c6e597770474e765a4757755a6d39796131393062323966624746795a32576c5a584a7962334c5a6a575a76636d74666447397658327868636d646c4f69426d62334a7249475631636d56725953317a64574a7a64484a68644755366358566c63335270623234365a47567a6179317361584e306157356e4c57463164476876636d6c306553426864434276636d526c636941304d5342706379427759584e304948526f5a5342775957646c49474a315a47646c644342696553427064484e6c6247593749474e76626e5270626e566c4948647064476767593356796332397949474d304d513d3daf7061796c6f6164456e636f64696e67b26d6573736167657061636b2d626173653634ad7061796c6f6164536368656d61b7687567696e6e2e6465736b5f726573706f6e73652e7631ad736368656d6156657273696f6ebd63756c746e65742e6f7065726174696f6e5f726573706f6e73652e7630a9736572766963654964ab687567696e6e2e6465736baf736f7572636552756e74696d654964ad687567696e6e2d657572656b61a6737461747573a872656a6563746564

View file

@ -0,0 +1 @@
81a94e6f7453746f72656483ad736368656d6156657273696f6eb867616d6563756c742e666f726b2e7265667573616c2e7630a4636f6465ae666f726b5f746f6f5f6c61726765a56572726f72d98d666f726b5f746f6f5f6c617267653a20666f726b20657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479206174206f7264657220343120697320706173742074686520706167652062756467657420627920697473656c663b20636f6e74696e7565207769746820637572736f7220633431

View file

@ -0,0 +1 @@
<EFBFBD>NotStored<EFBFBD>schemaVersion經amecult.fork.refusal.v0勺ode宸ork_too_large占rror<6F>fork_too_large: fork eureka-substrate:question:desk-listing-authority at order 41 is past the page budget by itself; continue with cursor c41

View file

@ -0,0 +1 @@
000001d989a96d6573736167654964a36d2d34a96f7065726174696f6ea4666f726ba77061796c6f6164d9f867616c4f62335254644739795a57534472584e6a6147567459565a6c636e4e70623236345a3246745a574e31624851755a6d3979617935795a575a31633246734c6e597770474e765a4757755a6d39796131393062323966624746795a32576c5a584a7962334c5a616d5a76636d74666447397658327868636d646c4f69426d62334a7249475631636d56725953317a64574a7a64484a68644755366358566c63335270623234365a47567a6179317361584e306157356e4c57463164476876636d6c30655342706379427759584e304948526f5a5342795a584e776232357a5a534269623356755a4342696553427064484e6c6247593daf7061796c6f6164456e636f64696e67b26d6573736167657061636b2d626173653634ad7061796c6f6164536368656d61b7687567696e6e2e6465736b5f726573706f6e73652e7631ad736368656d6156657273696f6ebd63756c746e65742e6f7065726174696f6e5f726573706f6e73652e7630a9736572766963654964ab687567696e6e2e6465736baf736f7572636552756e74696d654964ad687567696e6e2d657572656b61a6737461747573a872656a6563746564

View file

@ -0,0 +1 @@
81a94e6f7453746f72656483ad736368656d6156657273696f6eb867616d6563756c742e666f726b2e7265667573616c2e7630a4636f6465ae666f726b5f746f6f5f6c61726765a56572726f72d96a666f726b5f746f6f5f6c617267653a20666f726b20657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f7269747920697320706173742074686520726573706f6e736520626f756e6420627920697473656c66

View file

@ -0,0 +1 @@
<EFBFBD>NotStored<EFBFBD>schemaVersion經amecult.fork.refusal.v0勺ode宸ork_too_large占rror揂fork_too_large: fork eureka-substrate:question:desk-listing-authority is past the response bound by itself

View file

@ -0,0 +1 @@
0000016989a96d6573736167654964a36d2d34a96f7065726174696f6ea4666f726ba77061796c6f6164d98867616c4f62335254644739795a57534472584e6a6147567459565a6c636e4e70623236345a3246745a574e31624851755a6d3979617935795a575a31633246734c6e597770474e765a4757735a6d397961313931626d74756233647570575679636d3979756d5a76636d746664573572626d3933626a6f67626d38676333566a6143426d62334a72af7061796c6f6164456e636f64696e67b26d6573736167657061636b2d626173653634ad7061796c6f6164536368656d61b7687567696e6e2e6465736b5f726573706f6e73652e7631ad736368656d6156657273696f6ebd63756c746e65742e6f7065726174696f6e5f726573706f6e73652e7630a9736572766963654964ab687567696e6e2e6465736baf736f7572636552756e74696d654964ad687567696e6e2d657572656b61a6737461747573a872656a6563746564

View file

@ -0,0 +1 @@
81a94e6f7453746f72656483ad736368656d6156657273696f6eb867616d6563756c742e666f726b2e7265667573616c2e7630a4636f6465ac666f726b5f756e6b6e6f776ea56572726f72ba666f726b5f756e6b6e6f776e3a206e6f207375636820666f726b

View file

@ -0,0 +1 @@
<EFBFBD>NotStored<EFBFBD>schemaVersion經amecult.fork.refusal.v0勺ode柏ork_unknown占rror榻ork_unknown: no such fork

View file

@ -0,0 +1 @@
000001ca87a96d6573736167654964a36d2d34a96f7065726174696f6ea4666f726ba77061796c6f6164da01186761524762334a72684b52746157356b706d5631636d56725961647a64574a715a574e30325351315a6a426a4d6d49785a5330345954526b4c54526a4d3249744f5755335a69307859544a694d324d305a44566c4e6d616f5a47566a6157526c636e4f53325351315a6a426a4d6d49785a5330345954526b4c54526a4d3249744f5755335a69307859544a694d324d305a44566c4e6d625a4a4442694f5751335a544e684c545a6a4d6a45744e4759314f4331684d4751304c546c6c4f47593359545a694e574d305a4b646d62334a7258326c6b3254426c64584a6c6132457463335669633352795958526c4f6e46315a584e30615739754f6d526c6332737462476c7a64476c755a7931686458526f62334a7064486b3daf7061796c6f6164456e636f64696e67b26d6573736167657061636b2d626173653634ad7061796c6f6164536368656d61b6687567696e6e2e6465736b5f726571756573742e7631ad736368656d6156657273696f6ebc63756c746e65742e6f7065726174696f6e5f726571756573742e7630a9736572766963654964ab687567696e6e2e6465736b

View file

@ -0,0 +1 @@
81a4466f726b84a46d696e64a6657572656b61a77375626a656374d92435663063326231652d386134642d346333622d396537662d316132623363346435653666a8646563696465727392d92435663063326231652d386134642d346333622d396537662d316132623363346435653666d92430623964376533612d366332312d346635382d613064342d396538663761366235633464a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479

View file

@ -0,0 +1 @@
<EFBFBD>¤Fork„¤mind¦eureka§subjectÙ$5f0c2b1e-8a4d-4c3b-9e7f-1a2b3c4d5e6f¨deciders’Ù$5f0c2b1e-8a4d-4c3b-9e7f-1a2b3c4d5e6fÙ$0b9d7e3a-6c21-4f58-a0d4-9e8f7a6b5c4d§fork_idÙ0eureka-substrate:question:desk-listing-authority

View file

@ -0,0 +1 @@
85ad736368656d6156657273696f6eb767616d6563756c742e666f726b2e7265636f72642e7630a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438b1636f756e7465645f616e737765725f6964a26e31a7616e737765727394de0010ad736368656d6156657273696f6eb767616d6563756c742e6465736b5f616e737765722e7630a9616e737765725f6964a26e31a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a76f7574636f6d65a7436f756e746564a26f66c0a87265747261637473c0a4636f6465c0ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a663686f696365ab61736b696e672d6d696e64a46e6f7465be5468652061736b6572206c6973747320686572206f776e20666f726b732eab616e7377657265645f6174b4323032362d31312d31315431373a30303a30305aab72656365697665645f6174b4323032362d31312d31315431373a30303a30305aaf636c61696d5f696e5f667574757265c2a8616e737765726572d92435663063326231652d386134642d346333622d396537662d316132623363346435653666a9617574686f72697479a64d656d626572aa726571756573745f6964d92430303030303030302d303030302d343030302d383030302d303030303030303030303031de0010ad736368656d6156657273696f6eb767616d6563756c742e6465736b5f616e737765722e7630a9616e737765725f6964a26e32a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a76f7574636f6d65a85265766973696f6ea26f66a26e31a87265747261637473c0a4636f6465c0ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a663686f696365ab61736b696e672d6d696e64a46e6f7465a74167726565642eab616e7377657265645f6174b4323032362d31312d31315431373a30353a30305aab72656365697665645f6174b4323032362d31312d31315431373a30353a30305aaf636c61696d5f696e5f667574757265c2a8616e737765726572d92430623964376533612d366332312d346635382d613064342d396538663761366235633464a9617574686f72697479a64d656d626572aa726571756573745f6964d92430303030303030302d303030302d343030302d383030302d303030303030303030303032de0010ad736368656d6156657273696f6eb767616d6563756c742e6465736b5f616e737765722e7630a9616e737765725f6964a26e33a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a76f7574636f6d65a752656675736564a26f66c0a87265747261637473c0a4636f6465ad6e6f745f615f64656369646572ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a663686f696365ab61736b696e672d6d696e64a46e6f7465a74d6520746f6f2eab616e7377657265645f6174b4323032362d31312d31315431373a30363a30305aab72656365697665645f6174b4323032362d31312d31315431373a30363a30305aaf636c61696d5f696e5f667574757265c2a8616e737765726572d92463336131663265342d376236642d346538662d396130622d316332643365346635613662a9617574686f72697479a64d656d626572aa726571756573745f6964d92430303030303030302d303030302d343030302d383030302d303030303030303030303033de0010ad736368656d6156657273696f6eb767616d6563756c742e6465736b5f616e737765722e7630a9616e737765725f6964a26e34a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a76f7574636f6d65aa52657472616374696f6ea26f66c0a87265747261637473a26e32a4636f6465c0ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a663686f696365c0a46e6f7465c0ab616e7377657265645f6174b4323032362d31312d31315431373a30373a30305aab72656365697665645f6174b4323032362d31312d31315431373a30373a30305aaf636c61696d5f696e5f667574757265c2a8616e737765726572d92430623964376533612d366332312d346635382d613064342d396538663761366235633464a9617574686f72697479a64d656d626572aa726571756573745f6964d92430303030303030302d303030302d343030302d383030302d303030303030303030303034

View file

@ -0,0 +1 @@
0000010689a96d6573736167654964a36d2d31a96f7065726174696f6ea5666f726b73a77061796c6f6164d924676164535a575a316332566b7230317063334e70626d644a5a47567564476c3065513d3daf7061796c6f6164456e636f64696e67b26d6573736167657061636b2d626173653634ad7061796c6f6164536368656d61b7687567696e6e2e6465736b5f726573706f6e73652e7631ad736368656d6156657273696f6ebd63756c746e65742e6f7065726174696f6e5f726573706f6e73652e7630a9736572766963654964ab687567696e6e2e6465736baf736f7572636552756e74696d654964ad687567696e6e2d657572656b61a6737461747573a872656a6563746564

View file

@ -0,0 +1 @@
81a752656675736564af4d697373696e674964656e74697479

View file

@ -0,0 +1 @@
<EFBFBD>§Refused¯MissingIdentity

View file

@ -0,0 +1 @@
0000013689a96d6573736167654964a36d2d31a96f7065726174696f6ea5666f726b73a77061796c6f6164d954676164535a575a316332566b67617456626d463259576c7359574a735a59476d5a47563059576c73766e526f5a5342746157356b49484e3062334a6c49476c7a49473576644342795a57466b59574a735a513d3daf7061796c6f6164456e636f64696e67b26d6573736167657061636b2d626173653634ad7061796c6f6164536368656d61b7687567696e6e2e6465736b5f726573706f6e73652e7631ad736368656d6156657273696f6ebd63756c746e65742e6f7065726174696f6e5f726573706f6e73652e7630a9736572766963654964ab687567696e6e2e6465736baf736f7572636552756e74696d654964ad687567696e6e2d657572656b61a6737461747573a872656a6563746564

View file

@ -0,0 +1 @@
81a75265667573656481ab556e617661696c61626c6581a664657461696cbe746865206d696e642073746f7265206973206e6f74207265616461626c65

View file

@ -0,0 +1 @@
<EFBFBD>§Refused<EFBFBD>«Unavailable<EFBFBD>¦detail¾the mind store is not readable

File diff suppressed because one or more lines are too long

View file

@ -11,18 +11,18 @@ public static class DeskReplay
private static JsonNode BaseCard => DeskVectors.Root["scenarios"]![0]!["card"]!;
public static ForkCard Card(string forkId, string title = "Slot mark consent")
public static JsonElement Card(string forkId, string title = "Slot mark consent")
{
var card = JsonNode.Parse(BaseCard.ToJsonString())!.AsObject();
card["fork_id"] = forkId;
card["title"] = title;
return card.Deserialize<ForkCard>(DeskJson.Options)!;
return card.Deserialize<JsonElement>(DeskJson.Options);
}
public static ForkEntry Entry(ForkCard card) => new(
public static ForkEntry Entry(JsonElement card) => new(
"gamecult.fork.entry.v0",
card,
new ForkRecord("gamecult.fork.record.v0", card.ForkId, card.OptionsHash, null, []));
new ForkRecord("gamecult.fork.record.v0", card.GetProperty("fork_id").GetString()!, card.GetProperty("options_hash").GetString()!, null, 0, []));
// Every fork the vectors say each Mind holds, on that Mind's socket.
public static void HoldVectorForks(FakeDeskDoor door)

View file

@ -0,0 +1,152 @@
using System.Buffers;
using System.Text.Json;
using System.Text.Json.Nodes;
using Bifrost.Web.Features.Desk;
using MessagePack;
namespace Bifrost.Web.Tests.Support;
// Serves a vector's own wire form (messagePackHex) through the real UnixSocketDeskDoor on a socket double: Huginn's
// externally tagged DeskResponse with the vector's bytes spliced in unchanged as the document.
public static class DeskServe
{
public static byte[] Hex(JsonNode? hex) => Convert.FromHexString((string)hex!);
// {variant: {key: <raw bytes>, ..., "as_of": asOf}}: the raw bytes ride as they are.
public static byte[] Variant(string variant, (string Key, byte[] Raw)[] fields, ulong asOf = 1)
{
var buffer = new ArrayBufferWriter<byte>();
var w = new MessagePackWriter(buffer);
w.WriteMapHeader(1);
w.Write(variant);
w.WriteMapHeader(fields.Length + 1);
foreach (var (key, raw) in fields)
{
w.Write(key);
w.WriteRaw(raw);
}
w.Write("as_of");
w.Write(asOf);
w.Flush();
return buffer.WrittenSpan.ToArray();
}
public static byte[] Str(string? value)
{
var buffer = new ArrayBufferWriter<byte>();
var w = new MessagePackWriter(buffer);
w.Write(value);
w.Flush();
return buffer.WrittenSpan.ToArray();
}
public static byte[] Forks(byte[] list) => Variant("Forks", [("list", list)]);
public static byte[] Fork(byte[] entry) => Variant("Fork", [("entry", entry)]);
public static byte[] Answered(byte[] answer, string? counted = null) =>
Variant("Answered", [("answer", answer), ("counted_answer_id", Str(counted))]);
// A socket that answers every request with these payload bytes, built into a frame from the request's id and operation.
public static FakeHuginnSocket Socket(byte[] payload, string status = "accepted") =>
new(FakeHuginnSocket.Build((id, operation) => DeskWire.ResponseFrame(id, operation, status, payload)));
private static readonly DeskDoorContext Context = new("gamecult-chat", "Member", "5f0c2b1e-8a4d-4c3b-9e7f-1a2b3c4d5e6f", ["5f0c2b1e-8a4d-4c3b-9e7f-1a2b3c4d5e6f"]);
public static async Task<ForkList> ListAsync(byte[] listBytes, string mind = "gamecult-chat")
{
using var huginn = Socket(Forks(listBytes));
var outcome = await new UnixSocketDeskDoor().ListAsync(huginn.Path, Context with { Mind = mind }, new DeskListRequest(mind, "open", null, 50), CancellationToken.None);
return Assert.IsType<DeskListOutcome.Listed>(outcome).List;
}
public static async Task<ForkEntry> ReadAsync(byte[] entryBytes)
{
using var huginn = Socket(Fork(entryBytes));
var outcome = await new UnixSocketDeskDoor().ReadAsync(huginn.Path, Context, "gamecult-chat:question:slot-mark-consent", CancellationToken.None);
return Assert.IsType<DeskReadOutcome.Found>(outcome).Entry;
}
public static async Task<DeskAnswerDocument> AnswerAsync(byte[] answerBytes)
{
using var huginn = Socket(Answered(answerBytes));
var outcome = await new UnixSocketDeskDoor().AnswerAsync(huginn.Path, Context, DeskReplay.AnswerBody(DeskReplay.ChatFork), CancellationToken.None);
return Assert.IsType<DeskAnswerOutcome.Answered>(outcome).Result.Answer;
}
// What leaves the door as JSON text, written the way the edge writes it.
public static string Text<T>(T value) => JsonSerializer.SerializeToNode(value, DeskJson.Options)!.ToJsonString(DeskJson.Options);
public static string Text(JsonNode node) => node.ToJsonString(DeskJson.Options);
// The app's own configuration with one Mind on the socket double and every other Mind on a path no one listens on.
public static Dictionary<string, string?> ConfigurationOn(string mind, string socket)
{
var configuration = DeskWebApplicationFactory.DefaultConfiguration();
foreach (var key in configuration.Keys.Where(k => k.EndsWith(":Id", StringComparison.Ordinal)).ToList())
{
configuration[key[..^2] + "Socket"] = configuration[key] == mind ? socket : FakeHuginnSocket.Absent();
}
return configuration;
}
// An entry map whose card and record bytes are the given ones, untouched.
public static byte[] EntryOf(byte[] card, byte[] record)
{
var buffer = new ArrayBufferWriter<byte>();
var w = new MessagePackWriter(buffer);
w.WriteMapHeader(3);
w.Write("schemaVersion");
w.Write("gamecult.fork.entry.v0");
w.Write("card");
w.WriteRaw(card);
w.Write("record");
w.WriteRaw(record);
w.Flush();
return buffer.WrittenSpan.ToArray();
}
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)];
}

View file

@ -3,12 +3,13 @@ using System.Text.Json.Nodes;
namespace Bifrost.Web.Tests.Support;
// CultLib's fork-card vectors, vendored at commit de8a062ab9d8c510c8b36e634fae8f108a570aff.
// CultLib's fork-card vectors, vendored at commit d3025d3c19674803ad32eb2335461b8cbc22bd28 (#118: append-only on 3a51bbab,
// card DE0012 and record 86 unchanged).
public static class DeskVectors
{
public const string SourceCommit = "de8a062ab9d8c510c8b36e634fae8f108a570aff";
public const string SourceCommit = "d3025d3c19674803ad32eb2335461b8cbc22bd28";
public const string FileSha256 = "a7129eee00cb38c602481b0de7de0182f83122685b69ff441e3123adc3e0f774";
public const string FileSha256 = "c2b215a81e6708855b2424f8383f1dff0f3877b7fc9264102ca61d302b00c24c";
public static string Path => System.IO.Path.Combine(AppContext.BaseDirectory, "Fixtures", "fork-card-vectors.json");

View file

@ -26,9 +26,13 @@ public sealed class DeskWebApplicationFactory : TestWebApplicationFactory
private readonly Dictionary<string, string?>? configuration;
private readonly bool registerPorts;
private readonly bool fakeDoor;
public DeskWebApplicationFactory(Dictionary<string, string?>? configuration = null, bool configured = true, bool registerPorts = true)
// fakeDoor false leaves the door to the app's own wiring (UnixSocketDeskDoor), for tests that stand a socket up.
public DeskWebApplicationFactory(
Dictionary<string, string?>? configuration = null, bool configured = true, bool registerPorts = true, bool fakeDoor = true)
{
this.fakeDoor = fakeDoor;
this.configuration = configured ? configuration ?? DefaultConfiguration() : null;
this.registerPorts = registerPorts;
}
@ -88,7 +92,10 @@ public sealed class DeskWebApplicationFactory : TestWebApplicationFactory
builder.ConfigureServices(services =>
{
services.AddSingleton<IDeskCredential>(Credential);
services.AddSingleton<IDeskDoor>(Door);
if (fakeDoor)
{
services.AddSingleton<IDeskDoor>(Door);
}
});
}
}

View file

@ -0,0 +1,197 @@
using System.Buffers;
using System.Buffers.Binary;
using System.Net.Sockets;
using System.Text.Json;
using MessagePack;
namespace Bifrost.Web.Tests.Support;
// Huginn's desk door as a test double on a real unix socket: it accepts connections, reads each request frame,
// records it, and lets the test decide what happens next (reply, dribble, stall, close).
public sealed class FakeHuginnSocket : IDisposable
{
private readonly Socket listener = new(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified);
private readonly CancellationTokenSource stop = new();
private readonly string directory = Directory.CreateTempSubdirectory("hd").FullName;
private readonly Func<Socket, byte[], CancellationToken, Task> handler;
public FakeHuginnSocket(Func<Socket, byte[], CancellationToken, Task> handler)
{
this.handler = handler;
Path = System.IO.Path.Combine(directory, "d.sock");
listener.Bind(new UnixDomainSocketEndPoint(Path));
listener.Listen(16);
_ = Task.Run(AcceptLoopAsync);
}
public string Path { get; }
// The whole request frame of every connection, header included.
public List<byte[]> Requests { get; } = [];
// A path no one listens on.
public static string Absent() => System.IO.Path.Combine(Directory.CreateTempSubdirectory("hd").FullName, "absent.sock");
public static byte[] Fixture(string name) =>
File.ReadAllBytes(System.IO.Path.Combine(DeskFeedFixturePinTests.Directory, name + ".frame.bin"));
// Writes the bytes and closes.
public static Func<Socket, byte[], CancellationToken, Task> Replay(byte[] frame) =>
async (socket, _, token) => await socket.SendAsync(frame, SocketFlags.None, token);
// Answers each request by its own message id and operation: the reply is built from the request.
public static Func<Socket, byte[], CancellationToken, Task> Answer(Func<string, JsonDocument, string> variantJson, string status = "accepted") =>
async (socket, request, token) =>
{
using var envelope = DeskWire.Envelope(request);
var root = envelope.RootElement;
var operation = root.GetProperty("operation").GetString()!;
var reply = DeskWire.ResponseFrame(root.GetProperty("messageId").GetString()!, operation, status, variantJson(operation, envelope));
await socket.SendAsync(reply, SocketFlags.None, token);
};
// Answers each request with a frame the test builds from the request's message id and operation.
public static Func<Socket, byte[], CancellationToken, Task> Build(Func<string, string, byte[]> frameOf) =>
async (socket, request, token) =>
{
using var envelope = DeskWire.Envelope(request);
var root = envelope.RootElement;
await socket.SendAsync(frameOf(root.GetProperty("messageId").GetString()!, root.GetProperty("operation").GetString()!), SocketFlags.None, token);
};
private async Task AcceptLoopAsync()
{
while (!stop.IsCancellationRequested)
{
Socket client;
try
{
client = await listener.AcceptAsync(stop.Token);
}
catch (Exception)
{
return;
}
_ = Task.Run(async () =>
{
using (client)
{
try
{
var header = new byte[4];
await client.ReceiveAllAsync(header, stop.Token);
var body = new byte[BinaryPrimitives.ReadUInt32BigEndian(header)];
await client.ReceiveAllAsync(body, stop.Token);
var request = header.Concat(body).ToArray();
lock (Requests)
{
Requests.Add(request);
}
await handler(client, request, stop.Token);
}
catch (Exception)
{
// The client went away, or the test ended.
}
}
});
}
}
public void Dispose()
{
stop.Cancel();
listener.Dispose();
try
{
Directory.Delete(directory, true);
}
catch (IOException)
{
}
}
}
internal static class SocketReceive
{
public static async Task ReceiveAllAsync(this Socket socket, byte[] buffer, CancellationToken token)
{
for (var read = 0; read < buffer.Length;)
{
var count = await socket.ReceiveAsync(buffer.AsMemory(read), SocketFlags.None, token);
if (count == 0)
{
throw new EndOfStreamException();
}
read += count;
}
}
}
// Frames and envelopes as the golden fixtures spell them, for building replies and reading requests.
public static class DeskWire
{
public static JsonDocument Envelope(byte[] frame)
{
var reader = new MessagePackReader(frame.AsMemory(4));
var json = new StringWriter();
MessagePackSerializer.ConvertToJson(ref reader, json);
return JsonDocument.Parse(json.ToString());
}
// The base64 payload of an envelope, decoded to JSON.
public static JsonDocument Payload(JsonDocument envelope)
{
var bytes = Convert.FromBase64String(envelope.RootElement.GetProperty("payload").GetString()!);
var reader = new MessagePackReader(bytes);
var json = new StringWriter();
MessagePackSerializer.ConvertToJson(ref reader, json);
return JsonDocument.Parse(json.ToString());
}
// A reply frame in operation-message key order. `mutate` edits the text fields (a pair can be added twice) before
// they are written; `trailing` rides inside the frame's one message, after the map.
public static byte[] ResponseFrame(
string messageId, string operation, string status, string variantJson, Action<List<(string Key, string Value)>>? mutate = null) =>
ResponseFrame(messageId, operation, status, MessagePackSerializer.ConvertFromJson(variantJson), mutate);
public static byte[] ResponseFrame(
string messageId, string operation, string status, byte[] payload, Action<List<(string Key, string Value)>>? mutate = null, byte[]? trailing = null)
{
var fields = new List<(string Key, string Value)>
{
("messageId", messageId),
("operation", operation),
("payload", Convert.ToBase64String(payload)),
("payloadEncoding", "messagepack-base64"),
("payloadSchema", "huginn.desk_response.v1"),
("schemaVersion", "cultnet.operation_response.v0"),
("serviceId", "huginn.desk"),
("sourceRuntimeId", "huginn-test"),
("status", status),
};
mutate?.Invoke(fields);
var buffer = new ArrayBufferWriter<byte>();
var w = new MessagePackWriter(buffer);
w.WriteMapHeader(fields.Count);
foreach (var (key, value) in fields)
{
w.Write(key);
w.Write(value);
}
w.Flush();
return Frame([.. buffer.WrittenSpan, .. trailing ?? []]);
}
public static byte[] Frame(ReadOnlySpan<byte> message)
{
var frame = new byte[4 + message.Length];
BinaryPrimitives.WriteUInt32BigEndian(frame, (uint)message.Length);
message.CopyTo(frame.AsSpan(4));
return frame;
}
}

View 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")));
}
}
}

View file

@ -0,0 +1,96 @@
using System.Text.Json.Nodes;
using Bifrost.Web.Features.Desk;
using Bifrost.Web.Tests.Support;
namespace Bifrost.Web.Tests;
// The wire walker's closed maps (Closed in UnixSocketDeskDoor) are the single owner of "every contract key present with
// its type": the typed records carry no JsonRequired of their own. Each key of each contract document, taken from a real
// CultLib entry, is removed and, where the contract does not allow nil, nulled: every one is a door failure.
public sealed class UnixSocketDeskDoorRequiredKeysTests
{
private static readonly HashSet<string> Nullable = ["next_cursor", "counted_answer_id", "of", "retracts", "code", "choice", "note", "answerer"];
private static JsonObject Level(JsonObject page, string level) => level switch
{
"list" => page,
"entry" => page["forks"]![0]!.AsObject(),
"record" => page["forks"]![0]!["record"]!.AsObject(),
"answer" => page["forks"]![0]!["record"]!["answers"]![0]!.AsObject(),
_ => throw new ArgumentOutOfRangeException(nameof(level)),
};
public static TheoryData<string, string> Keys()
{
var page = DeskServe.PageOf(DeskServe.Entry("entry-open-counted"));
var data = new TheoryData<string, string>();
foreach (var level in new[] { "list", "entry", "record", "answer" })
{
foreach (var key in Level(page, level).Select(p => p.Key))
{
data.Add(level, key);
}
}
return data;
}
private static JsonObject Page() => DeskServe.PageOf(DeskServe.Entry("entry-open-counted"));
[Theory]
[MemberData(nameof(Keys))]
public async Task A_missing_required_key_is_a_door_failure(string level, string key)
{
var page = Page();
Level(page, level).Remove(key);
await Assert.ThrowsAsync<DeskDoorException>(() => DeskServe.ListPayloadAsync(DeskServe.Forks(DeskServe.FromJson(page))));
}
[Theory]
[MemberData(nameof(Keys))]
public async Task A_nil_where_the_contract_has_no_nil_is_a_door_failure(string level, string key)
{
var page = Page();
Level(page, level)[key] = null;
var failing = !Nullable.Contains(key);
var served = DeskServe.ListPayloadAsync(DeskServe.Forks(DeskServe.FromJson(page)));
if (failing)
{
await Assert.ThrowsAsync<DeskDoorException>(() => served);
}
else
{
Assert.IsType<DeskListOutcome.Listed>(await served);
}
}
[Theory]
[InlineData("answerer")]
[InlineData("answerer_count_is_not_an_answer_key")]
public async Task A_stored_answer_in_an_answered_reply_needs_the_same_keys(string key)
{
var answer = DeskServe.Entry("entry-open-counted")["record"]!["answers"]![0]!.AsObject();
if (answer.ContainsKey(key))
{
answer.Remove(key);
}
else
{
answer[key] = 1;
}
await Assert.ThrowsAsync<DeskDoorException>(() => DeskServe.AnswerPayloadAsync(DeskServe.Answered(DeskServe.FromJson(answer), "n1")));
}
[Fact]
public void The_typed_records_declare_no_required_keys_of_their_own()
{
var typed = new[] { typeof(DeskAnswerDocument), typeof(ForkRecord), typeof(ForkEntry), typeof(ForkList), typeof(AnswerResult) };
var required = typed.SelectMany(t => t.GetProperties()).Where(p => p.GetCustomAttributes(typeof(System.Text.Json.Serialization.JsonRequiredAttribute), true).Length > 0);
Assert.Empty(required.Select(p => p.DeclaringType!.Name + "." + p.Name));
}
}

View file

@ -0,0 +1,281 @@
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());
}
}

View file

@ -0,0 +1,577 @@
using System.Diagnostics;
using System.Net.Sockets;
using System.Text.Json;
using System.Text.Json.Nodes;
using MessagePack;
using Bifrost.Web.Features.Desk;
using Bifrost.Web.Tests.Support;
namespace Bifrost.Web.Tests;
// The production door against a real unix socket and Huginn's golden frames (fixtures at Huginn b299d6f7, vectors at CultLib d3025d3c).
public sealed class UnixSocketDeskDoorTests
{
private const string Fork = "eureka-substrate:question:desk-listing-authority";
// The fixtures' account ids: lowercase D-format UUIDs, synthetic.
private const string Decider = "5f0c2b1e-8a4d-4c3b-9e7f-1a2b3c4d5e6f";
private static readonly DeskDoorContext Context = new("eureka", "Member", Decider, [Decider, "0b9d7e3a-6c21-4f58-a0d4-9e8f7a6b5c4d"]);
private static UnixSocketDeskDoor DoorWith(string messageId) => new(() => messageId);
private static Task<DeskListOutcome> List(UnixSocketDeskDoor door, string socket, string? status = "open", string? cursor = null, int limit = 50) =>
door.ListAsync(socket, Context, new DeskListRequest("eureka", status, cursor, limit), CancellationToken.None);
private static Task<DeskReadOutcome> Read(UnixSocketDeskDoor door, string socket, string forkId = Fork) =>
door.ReadAsync(socket, Context, forkId, CancellationToken.None);
private const string UnknownFork =
"""{"NotStored":{"schemaVersion":"gamecult.fork.refusal.v0","code":"fork_unknown","error":"fork_unknown: no such fork"}}""";
// ---- (a) the request frames, byte for byte ----
[Theory]
[InlineData("list-open.request", "m-1")]
[InlineData("list-open-cursor.request", "m-2")]
[InlineData("list-counts.request", "m-3")]
[InlineData("read.request", "m-4")]
public async Task Request_frames_are_the_golden_bytes(string fixture, string messageId)
{
using var huginn = new FakeHuginnSocket(FakeHuginnSocket.Answer((_, _) => UnknownFork, "rejected"));
var door = DoorWith(messageId);
_ = fixture switch
{
"list-open.request" => await List(door, huginn.Path),
"list-open-cursor.request" => await List(door, huginn.Path, cursor: "c42"),
"list-counts.request" => await List(door, huginn.Path, limit: 0),
_ => (object)await Read(door, huginn.Path),
};
Assert.Equal(FakeHuginnSocket.Fixture(fixture), Assert.Single(huginn.Requests));
}
[Fact]
public async Task An_answer_request_is_the_Answer_variant_with_the_clients_body_as_a_map_in_its_own_order()
{
using var huginn = new FakeHuginnSocket(FakeHuginnSocket.Answer((_, _) => UnknownFork, "rejected"));
var body = DeskReplay.AnswerBody(DeskReplay.ChatFork);
var context = new DeskDoorContext("gamecult-chat", "Steward", "9f8e", ["9f8e", "aa"]);
var outcome = await new UnixSocketDeskDoor().AnswerAsync(huginn.Path, context, body, CancellationToken.None);
Assert.Equal(new DeskAnswerOutcome.NotStored("fork_unknown"), outcome);
using var envelope = DeskWire.Envelope(Assert.Single(huginn.Requests));
Assert.Equal("answer", envelope.RootElement.GetProperty("operation").GetString());
using var payload = DeskWire.Payload(envelope);
var answer = payload.RootElement.GetProperty("Answer");
Assert.Equal(["mind", "subject", "authority", "deciders", "request"], answer.EnumerateObject().Select(p => p.Name));
Assert.Equal("Steward", answer.GetProperty("authority").GetString());
// Every key, the nulls too, in the order the client wrote them.
using var sent = JsonDocument.Parse(body);
Assert.Equal(sent.RootElement.EnumerateObject().Select(p => p.Name), answer.GetProperty("request").EnumerateObject().Select(p => p.Name));
foreach (var property in sent.RootElement.EnumerateObject())
{
var back = answer.GetProperty("request").GetProperty(property.Name);
Assert.True(
System.Text.Json.Nodes.JsonNode.DeepEquals(
System.Text.Json.Nodes.JsonNode.Parse(property.Value.GetRawText()),
System.Text.Json.Nodes.JsonNode.Parse(back.GetRawText())),
property.Name);
}
}
// ---- (b) each golden reply ----
private static (string Kind, string MessageId) Shape(string fixture)
{
using var envelope = DeskWire.Envelope(FakeHuginnSocket.Fixture(fixture));
var root = envelope.RootElement;
return root.TryGetProperty("operation", out var operation)
? (operation.GetString() == "fork" ? "read" : "list", root.GetProperty("messageId").GetString()!)
: ("list", "m-1"); // a CultNet Error message has neither
}
private static async Task<object> Replayed(string fixture, string? readForkId = null)
{
var (kind, messageId) = Shape(fixture);
using var huginn = new FakeHuginnSocket(FakeHuginnSocket.Replay(FakeHuginnSocket.Fixture(fixture)));
var door = DoorWith(messageId);
return kind == "read"
? await Read(door, huginn.Path, readForkId ?? Fork)
: await List(door, huginn.Path);
}
[Fact]
public async Task An_empty_list_is_an_empty_list()
{
var listed = Assert.IsType<DeskListOutcome.Listed>(await Replayed("list-empty.response"));
Assert.Equal((0, 0, 0, "s10190", "eureka"), (listed.List.OpenCount, listed.List.RecordedCount, listed.List.Forks.Length, listed.List.Stamp, listed.List.Mind));
Assert.Null(listed.List.NextCursor);
}
[Fact]
public async Task The_counts_reply_carries_the_counts()
{
var listed = Assert.IsType<DeskListOutcome.Listed>(await Replayed("list-counts.response"));
Assert.Equal((3, 5, 0), (listed.List.OpenCount, listed.List.RecordedCount, listed.List.Forks.Length));
}
[Fact]
public async Task An_unknown_fork_is_not_found()
{
Assert.Equal(new DeskReadOutcome.NotFound("fork_unknown"), await Replayed("read-unknown.response"));
}
[Fact]
public async Task A_list_too_large_names_its_fork_and_order_from_the_refusal_text()
{
Assert.Equal(new DeskListOutcome.ForkTooLarge(Fork, 41), await Replayed("list-too-large.response"));
}
[Fact]
public async Task A_read_too_large_names_the_routes_fork_not_the_refusal_text()
{
// The refusal text names the fixture's fork; the route asked for another, and the route's id is what returns.
Assert.Equal(
new DeskReadOutcome.ForkTooLarge("route:question:asked"),
await Replayed("read-too-large.response", "route:question:asked"));
}
[Theory]
[InlineData("list-response-too-large.response")]
[InlineData("refused-unit.response")]
[InlineData("unavailable.response")]
[InlineData("failure.response")]
[InlineData("error.response")]
public async Task A_refusal_a_failure_or_an_error_is_a_door_failure(string fixture)
{
var failure = await Assert.ThrowsAsync<DeskDoorException>(() => Replayed(fixture));
Assert.DoesNotContain("deadline", failure.Message);
}
[Fact]
public async Task A_list_refusal_text_that_does_not_parse_exactly_is_a_failure_never_a_guess()
{
foreach (var text in new[]
{
"fork_too_large: fork a:question:b is past the page budget by itself",
"fork_too_large: fork a:question:b at order 4x is past the page budget by itself",
"fork_too_large: fork at order 4 is past the page budget by itself",
"fork_too_large: fork a:question:b at order 04 is past the page budget by itself",
"oops fork_too_large: fork a:question:b at order 4 is past the page budget by itself",
})
{
using var huginn = new FakeHuginnSocket(FakeHuginnSocket.Answer((_, _) =>
$$$"""{"NotStored":{"schemaVersion":"gamecult.fork.refusal.v0","code":"fork_too_large","error":"{{{text}}}"}}""", "rejected"));
await Assert.ThrowsAsync<DeskDoorException>(() => List(new UnixSocketDeskDoor(), huginn.Path));
}
}
[Fact]
public async Task A_one_key_refusal_an_unknown_variant_and_an_unknown_code_are_failures()
{
foreach (var (variant, status) in new[]
{
("""{"Refused":{"Unavailable":{"detail":"x"}}}""", "rejected"),
("""{"Refused":"MissingIdentity"}""", "rejected"),
("""{"Mystery":{"x":1}}""", "accepted"),
("""{"NotStored":{"schemaVersion":"gamecult.fork.refusal.v0","code":"nope","error":"x"}}""", "rejected"),
("""{"NotStored":{"schemaVersion":"gamecult.fork.refusal.v9","code":"fork_unknown","error":"x"}}""", "rejected"),
("""{"Fork":{"entry":{},"as_of":1}}""", "accepted"),
("""{"Forks":{"list":{"schemaVersion":"gamecult.fork.list.v0","mind":"eureka","stamp":"s1","open_count":0,"recorded_count":0,"forks":[],"next_cursor":null},"as_of":1}}""", "rejected"),
})
{
using var huginn = new FakeHuginnSocket(FakeHuginnSocket.Answer((_, _) => variant, status));
await Assert.ThrowsAsync<DeskDoorException>(() => List(new UnixSocketDeskDoor(), huginn.Path));
}
}
[Fact]
public async Task A_reply_for_another_message_or_operation_is_a_failure()
{
using var huginn = new FakeHuginnSocket(FakeHuginnSocket.Replay(FakeHuginnSocket.Fixture("list-empty.response")));
await Assert.ThrowsAsync<DeskDoorException>(() => List(DoorWith("m-9"), huginn.Path));
using var other = new FakeHuginnSocket(FakeHuginnSocket.Replay(FakeHuginnSocket.Fixture("list-empty.response")));
await Assert.ThrowsAsync<DeskDoorException>(() => Read(DoorWith("m-1"), other.Path));
}
// ---- (c) entries pass through, the card untouched ----
private const string CardWithRaisedAt =
"""{"schemaVersion":"gamecult.fork.card.v0","fork_id":"x:question:a","title":"It's é","raised_at":"2026-11-11T09:00:00Z","options":[{"id":"a","text":"A"}],"order":7,"options_hash":"ab"}""";
// Reordered, no raised_at, a key no contract names yet.
private const string CardReordered =
"""{"order":8,"future_key":{"z":[3,null,100],"a":true},"options_hash":"cd","schemaVersion":"gamecult.fork.card.v0","fork_id":"x:question:b","title":"B","leans":null}""";
private static string EntryJson(string card, string forkId) =>
$$$"""{"schemaVersion":"gamecult.fork.entry.v0","card":{{{card}}},"record":{"schemaVersion":"gamecult.fork.record.v0","fork_id":"{{{forkId}}}","options_hash":"ab","counted_answer_id":null,"answerer_count":0,"answers":[]}}""";
[Fact]
public async Task Cards_in_a_list_and_a_read_come_through_byte_faithful()
{
var list = $$$"""{"Forks":{"list":{"schemaVersion":"gamecult.fork.list.v0","mind":"eureka","stamp":"s7","open_count":2,"recorded_count":0,"forks":[{{{EntryJson(CardWithRaisedAt, "x:question:a")}}},{{{EntryJson(CardReordered, "x:question:b")}}}],"next_cursor":"c8"},"as_of":7}}""";
using var huginn = new FakeHuginnSocket(FakeHuginnSocket.Answer((operation, _) =>
operation == "forks" ? list : $$$"""{"Fork":{"entry":{{{EntryJson(CardReordered, "x:question:b")}}},"as_of":7}}"""));
var door = new UnixSocketDeskDoor();
var listed = Assert.IsType<DeskListOutcome.Listed>(await List(door, huginn.Path));
Assert.Equal(["x:question:a", "x:question:b"], listed.List.Forks.Select(f => f.Record.ForkId));
Assert.Equal(CardWithRaisedAt, JsonSerializer.Serialize(listed.List.Forks[0].Card, DeskJson.Options));
Assert.Equal(CardReordered, JsonSerializer.Serialize(listed.List.Forks[1].Card, DeskJson.Options));
Assert.Equal("c8", listed.List.NextCursor);
var found = Assert.IsType<DeskReadOutcome.Found>(await Read(door, huginn.Path, "x:question:b"));
Assert.Equal(CardReordered, JsonSerializer.Serialize(found.Entry.Card, DeskJson.Options));
}
[Fact]
public async Task An_answered_reply_becomes_the_answer_result_without_a_card()
{
var answered = """{"Answered":{"answer":{"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":null,"answered_at":"2026-11-11T09:00:00Z","received_at":"2026-11-11T09:00:01Z","claim_in_future":false,"answerer":"7","authority":"Member","request_id":"r"},"counted_answer_id":"n1","as_of":9}}""";
using var huginn = new FakeHuginnSocket(FakeHuginnSocket.Answer((_, _) => answered));
var outcome = await new UnixSocketDeskDoor().AnswerAsync(huginn.Path, Context, DeskReplay.AnswerBody("x:question:a"), CancellationToken.None);
var result = Assert.IsType<DeskAnswerOutcome.Answered>(outcome).Result;
Assert.Equal((DeskSchemas.AnswerResult, "n1", "Counted", "n1", null), (result.SchemaVersion, result.Answer.AnswerId, result.Answer.Outcome, result.CountedAnswerId, result.Card));
}
// ---- (c2) both reader views through the door and the edge ----
// What each piece of the fixtures directory is (its two READMEs):
// - record-whole is HUGINN's own encoder output at b299d6f7: gamecult.fork.record.v0 with all four answer variants
// by three accounts, notes and ids whole. It has 5 keys and NO answerer_count: what the door sends today.
// - cultlib-3a51bbab/*.msgpack are CULTLIB's bytes (twelve vectors pinned to 3a51bbab, each a vector's
// messagePackHex decoded), vendored unedited. They are NOT Huginn output; Huginn's types do not carry those
// shapes yet. Bifrost reads them as the contract (vectors file d3025d3c, append-only on 3a51bbab) spells them.
private static JsonObject Fragment(string name) =>
JsonNode.Parse(MessagePackSerializer.ConvertToJson(File.ReadAllBytes(
Path.Combine(DeskFeedFixturePinTests.Directory, name + ".payload.msgpack"))))!.AsObject();
private static byte[] CultLib(string name) =>
File.ReadAllBytes(Path.Combine(DeskFeedFixturePinTests.Directory, "cultlib-3a51bbab", name + ".msgpack"));
private static JsonObject CultLibJson(string name) => JsonNode.Parse(MessagePackSerializer.ConvertToJson(CultLib(name)))!.AsObject();
// file, vector section, vector name (the vector whose messagePackHex the file holds)
public static TheoryData<string, string, string> CultLibFiles() => new()
{
{ "card-operator-fork", "card/valid", "operator_fork" },
{ "card-raised-at-nil", "card/valid", "raised_at_nil" },
{ "card-withheld-deciders", "card/valid", "withheld_deciders" },
{ "answer-counted", "deskAnswer/valid", "counted" },
{ "answer-revision", "deskAnswer/valid", "revision" },
{ "answer-retraction", "deskAnswer/valid", "retraction" },
{ "answer-withheld-answerer", "deskAnswer/valid", "withheld_answerer" },
{ "answer-refused-stale", "deskAnswer/valid", "refused_stale" },
{ "entry-open-counted", "transport/entry/valid", "open_counted" },
{ "entry-withheld-open-counted", "transport/entry/valid", "withheld_open_counted" },
{ "entry-closed-with-ruling-and-a-refusal", "transport/entry/valid", "closed_with_ruling_and_a_refusal" },
{ "entry-withheld-closed-with-ruling-and-a-refusal", "transport/entry/valid", "withheld_closed_with_ruling_and_a_refusal" },
};
[Theory]
[MemberData(nameof(CultLibFiles))]
public void Each_CultLib_file_is_its_vectors_wire_bytes_in_the_vendored_vectors_file(string file, string section, string name)
{
var vector = DeskVectors.Rows(section.Split('/')).Single(r => (string)r["name"]! == name);
Assert.Equal((string)vector["messagePackHex"]!, Convert.ToHexString(CultLib(file)), ignoreCase: true);
Assert.True(DeskVectors.SameShape(vector["message"], CultLibJson(file)), file);
}
[Fact]
public void The_CultLib_files_are_the_shapes_their_README_names()
{
// Card: 18 keys (DE 00 12), raised_at a string / nil with the key kept, deciders whole or withheld with the count.
Assert.Equal([0xde, 0x00, 0x12], CultLib("card-operator-fork")[..3]);
Assert.Equal(1, (int)CultLibJson("card-operator-fork")["decider_count"]!);
Assert.NotEmpty(CultLibJson("card-operator-fork")["deciders"]!.AsArray());
Assert.Equal(JsonValueKind.String, CultLibJson("card-operator-fork")["raised_at"]!.GetValueKind());
Assert.True(CultLibJson("card-raised-at-nil").ContainsKey("raised_at"));
Assert.Null(CultLibJson("card-raised-at-nil")["raised_at"]);
Assert.Empty(CultLibJson("card-withheld-deciders")["deciders"]!.AsArray());
Assert.Equal(2, (int)CultLibJson("card-withheld-deciders")["decider_count"]!);
// Answers: 16 keys (DE 00 10), answerer a string except the withheld one.
foreach (var answer in new[] { "answer-counted", "answer-revision", "answer-retraction", "answer-refused-stale", "answer-withheld-answerer" })
{
Assert.Equal([0xde, 0x00, 0x10], CultLib(answer)[..3]);
Assert.Equal(answer == "answer-withheld-answerer", CultLibJson(answer)["answerer"] is null);
}
// Entries: header 83, record header 86 with answerer_count the fifth key; the decider view names every answerer,
// its non-decider twin none, and the count is the same in both.
foreach (var (decider, withheld) in new[] { ("entry-open-counted", "entry-withheld-open-counted"), ("entry-closed-with-ruling-and-a-refusal", "entry-withheld-closed-with-ruling-and-a-refusal") })
{
Assert.Equal(0x83, CultLib(decider)[0]);
var (whole, hidden) = (CultLibJson(decider), CultLibJson(withheld));
Assert.Equal("answerer_count", whole["record"]!.AsObject().ElementAt(4).Key);
Assert.Equal(6, whole["record"]!.AsObject().Count);
Assert.NotEmpty(whole["card"]!["deciders"]!.AsArray());
Assert.All(whole["record"]!["answers"]!.AsArray(), a => Assert.NotNull(a!["answerer"]));
Assert.Empty(hidden["card"]!["deciders"]!.AsArray());
Assert.All(hidden["record"]!["answers"]!.AsArray(), a => Assert.Null(a!["answerer"]));
Assert.Equal((int)whole["record"]!["answerer_count"]!, (int)hidden["record"]!["answerer_count"]!);
Assert.Equal((int)whole["card"]!["decider_count"]!, (int)hidden["card"]!["decider_count"]!);
}
Assert.Equal(1, (int)CultLibJson("entry-open-counted")["record"]!["answerer_count"]!);
Assert.Equal(2, (int)CultLibJson("entry-closed-with-ruling-and-a-refusal")["record"]!["answerer_count"]!);
}
[Fact]
public void Every_account_id_in_record_whole_is_a_lowercase_D_format_uuid_and_the_record_is_what_the_README_says()
{
var whole = Fragment("record-whole");
// Huginn's own record: 5 keys, no answerer_count (the README says so; the contract wants a sixth).
Assert.Equal(["schemaVersion", "fork_id", "options_hash", "counted_answer_id", "answers"], whole.Select(p => p.Key));
Assert.Equal(["Counted", "Revision", "Refused", "Retraction"], whole["answers"]!.AsArray().Select(a => (string)a!["outcome"]!).Distinct());
var grammar = new System.Text.RegularExpressions.Regex("^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$");
var accounts = whole["answers"]!.AsArray().Select(a => (string)a!["answerer"]!).ToList();
Assert.All(accounts, id => Assert.Matches(grammar, id));
Assert.Equal(3, accounts.Distinct().Count());
}
// A decider's entry and its non-decider twin, each served through the real door as a list page: the card (18 keys,
// raised_at, decider_count) and the record (answerer_count) leave exactly as CultLib wrote them.
[Theory]
[InlineData("entry-open-counted")]
[InlineData("entry-withheld-open-counted")]
[InlineData("entry-closed-with-ruling-and-a-refusal")]
[InlineData("entry-withheld-closed-with-ruling-and-a-refusal")]
public async Task A_CultLib_entry_in_either_view_comes_through_the_door_unchanged(string file)
{
var entry = CultLibJson(file);
using var huginn = new FakeHuginnSocket(FakeHuginnSocket.Answer((_, _) => ListOf(entry.ToJsonString(DeskJson.Options))));
var listed = Assert.IsType<DeskListOutcome.Listed>(await List(new UnixSocketDeskDoor(), huginn.Path));
var got = Assert.Single(listed.List.Forks);
Assert.Equal(DeskServe.Text(entry), DeskServe.Text(got));
var withheld = file.Contains("withheld", StringComparison.Ordinal);
Assert.Equal(withheld, got.Card.GetProperty("deciders").GetArrayLength() == 0);
Assert.All(got.Record.Answers, a => Assert.Equal(withheld, a.Answerer is null));
}
private static string ListOf(string entry, string mind = "eureka") =>
$$$"""{"Forks":{"list":{"schemaVersion":"gamecult.fork.list.v0","mind":"{{{mind}}}","stamp":"s7","open_count":1,"recorded_count":0,"forks":[{{{entry}}}],"next_cursor":null},"as_of":7}}""";
[Fact]
public async Task A_decider_entry_keeps_its_ids_and_notes_and_a_non_decider_entry_has_nil_answerers_the_count_and_no_notes_through_the_edge()
{
var withheld = CultLibJson("entry-withheld-closed-with-ruling-and-a-refusal");
using var huginn = new FakeHuginnSocket(FakeHuginnSocket.Answer((_, _) => ListOf(withheld.ToJsonString(DeskJson.Options), "gamecult-chat")));
using var factory = new DeskWebApplicationFactory(ConfigurationWith(huginn.Path, FakeHuginnSocket.Absent()), fakeDoor: false);
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Member, true);
var reply = await factory.SendAsync("GET", "/api/desk/gamecult-chat/forks", token: factory.Token(DeskWebApplicationFactory.Member, "bifrost-desk", "desk.read"));
// Huginn withheld the ids; Bifrost invents none, and nils the notes (the one thing it strips).
Assert.Equal(200, reply.Status);
var shown = JsonNode.Parse(reply.Text)!["forks"]![0]!;
Assert.Equal(2, (int)shown["record"]!["answerer_count"]!);
Assert.All(shown["record"]!["answers"]!.AsArray(), a =>
{
Assert.True(a!.AsObject().ContainsKey("answerer"));
Assert.Null(a["answerer"]);
Assert.Null(a["note"]);
});
Assert.Empty(shown["card"]!["deciders"]!.AsArray());
Assert.Equal((int)withheld["card"]!["decider_count"]!, (int)shown["card"]!["decider_count"]!);
// The decider's view keeps every id and every note.
var decider = CultLibJson("entry-closed-with-ruling-and-a-refusal");
using var huginn2 = new FakeHuginnSocket(FakeHuginnSocket.Answer((_, _) => ListOf(decider.ToJsonString(DeskJson.Options), "gamecult-chat")));
using var factory2 = new DeskWebApplicationFactory(ConfigurationWith(huginn2.Path, FakeHuginnSocket.Absent()), fakeDoor: false);
await factory2.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true);
var own = await factory2.SendAsync("GET", "/api/desk/gamecult-chat/forks", token: factory2.Token(DeskWebApplicationFactory.Alice, "bifrost-desk", "desk.read"));
Assert.Equal(200, own.Status);
Assert.Equal(DeskServe.Text(decider), DeskServe.Text(JsonNode.Parse(own.Text)!["forks"]![0]!));
}
// Huginn's record-whole at b299d6f7 still has no answerer_count, which the contract requires (the README says "what
// the door sends today"; the real types follow in cut-desk-feed-listing). The door refuses it as a reply that does
// not decode; it does not write the 0 it could have made up. When Huginn's record gains the key this test is the
// one to retire.
[Fact]
public async Task Huginns_record_without_answerer_count_is_a_fault_not_a_zero()
{
var card = CultLibJson("entry-open-counted")["card"]!;
var entry = $$$"""{"schemaVersion":"gamecult.fork.entry.v0","card":{{{card.ToJsonString(DeskJson.Options)}}},"record":{{{Fragment("record-whole").ToJsonString(DeskJson.Options)}}}}""";
using var huginn = new FakeHuginnSocket(FakeHuginnSocket.Answer((_, _) => ListOf(entry)));
var failure = await Assert.ThrowsAsync<DeskDoorException>(() => List(new UnixSocketDeskDoor(), huginn.Path));
Assert.Contains("closed map", failure.Message);
}
// ---- (d) one dead Mind fails only itself, through the HTTP edge ----
private static Dictionary<string, string?> ConfigurationWith(string chatSocket, string nibuSocket)
{
var configuration = DeskWebApplicationFactory.DefaultConfiguration();
foreach (var key in configuration.Keys.Where(k => k.EndsWith(":Id", StringComparison.Ordinal)).ToList())
{
var prefix = key[..^2];
configuration[prefix + "Socket"] = configuration[key] == "nibu" ? nibuSocket : chatSocket;
}
return configuration;
}
[Fact]
public async Task A_dead_Minds_socket_is_503_for_that_Mind_only_and_the_live_one_answers()
{
var list = $$$"""{"Forks":{"list":{"schemaVersion":"gamecult.fork.list.v0","mind":"gamecult-chat","stamp":"s7","open_count":1,"recorded_count":0,"forks":[{{{EntryJson(CardWithRaisedAt, "x:question:a")}}}],"next_cursor":null},"as_of":7}}""";
using var huginn = new FakeHuginnSocket(FakeHuginnSocket.Answer((_, _) => list));
using var factory = new DeskWebApplicationFactory(ConfigurationWith(huginn.Path, FakeHuginnSocket.Absent()), fakeDoor: false);
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true);
var token = factory.Token(DeskWebApplicationFactory.Alice, "bifrost-desk", "desk.read");
var live = await factory.SendAsync("GET", "/api/desk/gamecult-chat/forks", token: token);
var dead = await factory.SendAsync("GET", "/api/desk/nibu/forks", token: token);
var liveAgain = await factory.SendAsync("GET", "/api/desk/gamecult-chat/forks", token: token);
Assert.Equal(200, live.Status);
Assert.Contains("\"card\":" + CardWithRaisedAt + ",", live.Text);
Assert.Equal(503, dead.Status);
Assert.Equal("30", dead.Headers.RetryAfter.ToString());
Assert.Contains("\"code\":\"unavailable\"", dead.Text);
Assert.Equal(200, liveAgain.Status);
Assert.Equal(2, huginn.Requests.Count);
}
[Fact]
public async Task A_silent_door_is_503_with_retry_after_not_a_hang()
{
using var huginn = new FakeHuginnSocket(async (_, _, token) => await Task.Delay(Timeout.Infinite, token));
using var factory = new DeskWebApplicationFactory(ConfigurationWith(FakeHuginnSocket.Absent(), huginn.Path), fakeDoor: false);
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true);
var clock = Stopwatch.StartNew();
var reply = await factory.SendAsync("GET", "/api/desk/nibu/forks", token: factory.Token(DeskWebApplicationFactory.Alice, "bifrost-desk", "desk.read"));
Assert.Equal(503, reply.Status);
Assert.True(reply.Headers.ContainsKey("Retry-After"));
Assert.True(clock.Elapsed < TimeSpan.FromSeconds(3), $"took {clock.Elapsed}");
}
// ---- (e) deadline, bounds, partial frames ----
[Fact]
public async Task A_server_that_accepts_and_never_replies_fails_at_the_deadline()
{
using var huginn = new FakeHuginnSocket(async (_, _, token) => await Task.Delay(Timeout.Infinite, token));
var clock = Stopwatch.StartNew();
var failure = await Assert.ThrowsAsync<DeskDoorException>(() => List(new UnixSocketDeskDoor(), huginn.Path));
Assert.Contains("deadline", failure.Message);
Assert.InRange(clock.Elapsed, TimeSpan.FromMilliseconds(900), TimeSpan.FromSeconds(2.5));
}
[Fact]
public async Task A_caller_that_gives_up_is_cancelled_not_a_door_failure()
{
using var huginn = new FakeHuginnSocket(async (_, _, token) => await Task.Delay(Timeout.Infinite, token));
using var cancel = new CancellationTokenSource(TimeSpan.FromMilliseconds(100));
await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
new UnixSocketDeskDoor().ListAsync(huginn.Path, Context, new DeskListRequest("eureka", null, null, 50), cancel.Token));
}
[Fact]
public async Task A_length_prefix_over_the_bound_is_refused_without_reading_the_body()
{
// The server sends only the prefix, then stalls: a client that waited for the body would run out the deadline.
using var huginn = new FakeHuginnSocket(async (socket, _, token) =>
{
await socket.SendAsync(new byte[] { 0, 1, 0, 1 }, SocketFlags.None, token); // 65537
await Task.Delay(Timeout.Infinite, token);
});
var clock = Stopwatch.StartNew();
var failure = await Assert.ThrowsAsync<DeskDoorException>(() => List(new UnixSocketDeskDoor(), huginn.Path));
Assert.Contains("frame bound", failure.Message);
Assert.True(clock.Elapsed < TimeSpan.FromMilliseconds(700), $"took {clock.Elapsed}");
}
[Fact]
public async Task A_frame_exactly_at_the_bound_is_read_whole()
{
// A prefix of 65536 is within the bound; the body that follows is not a message, so it fails on content, not size.
using var huginn = new FakeHuginnSocket(async (socket, _, token) =>
{
var frame = new byte[4 + 65536];
frame[1] = 1;
await socket.SendAsync(frame, SocketFlags.None, token);
});
var failure = await Assert.ThrowsAsync<DeskDoorException>(() => List(new UnixSocketDeskDoor(), huginn.Path));
Assert.DoesNotContain("frame bound", failure.Message);
}
[Fact]
public async Task A_request_over_the_bound_is_never_sent()
{
var deciders = Enumerable.Range(0, 64).Select(i => i.ToString("D4") + new string('d', 1100)).ToArray();
var context = new DeskDoorContext("eureka", "Member", "7", deciders);
using var huginn = new FakeHuginnSocket(FakeHuginnSocket.Answer((_, _) => UnknownFork, "rejected"));
var failure = await Assert.ThrowsAsync<DeskDoorException>(() =>
new UnixSocketDeskDoor().ListAsync(huginn.Path, context, new DeskListRequest("eureka", null, null, 50), CancellationToken.None));
Assert.Contains("frame bound", failure.Message);
Assert.Empty(huginn.Requests);
}
[Fact]
public async Task A_truncated_frame_or_an_early_close_is_a_failure()
{
var golden = FakeHuginnSocket.Fixture("list-empty.response");
foreach (var cut in new[] { 0, 2, 4, 10, golden.Length - 1 })
{
using var huginn = new FakeHuginnSocket(FakeHuginnSocket.Replay(golden[..cut]));
var failure = await Assert.ThrowsAsync<DeskDoorException>(() => List(DoorWith("m-1"), huginn.Path));
Assert.Contains("closed", failure.Message);
}
}
[Fact]
public async Task A_frame_that_arrives_in_pieces_is_read_whole()
{
var golden = FakeHuginnSocket.Fixture("list-empty.response");
using var huginn = new FakeHuginnSocket(async (socket, _, token) =>
{
for (var at = 0; at < golden.Length; at += 7)
{
await socket.SendAsync(golden.AsMemory(at, Math.Min(7, golden.Length - at)), SocketFlags.None, token);
await Task.Delay(2, token);
}
});
Assert.IsType<DeskListOutcome.Listed>(await List(DoorWith("m-1"), huginn.Path));
}
[Fact]
public async Task Absent_and_refusing_sockets_are_failures()
{
await Assert.ThrowsAsync<DeskDoorException>(() => List(new UnixSocketDeskDoor(), FakeHuginnSocket.Absent()));
// A socket file with nobody listening: ECONNREFUSED.
var stale = FakeHuginnSocket.Absent();
using (var socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified))
{
socket.Bind(new UnixDomainSocketEndPoint(stale));
}
await Assert.ThrowsAsync<DeskDoorException>(() => List(new UnixSocketDeskDoor(), stale));
}
}

View file

@ -0,0 +1,70 @@
using System.Text.Json.Nodes;
using MessagePack;
using Bifrost.Web.Features.Desk;
using Bifrost.Web.Tests.Support;
namespace Bifrost.Web.Tests;
// Huginn's DeskResponse variants are maps with a fixed key set (desk_wire.rs): Answered { answer, counted_answer_id,
// as_of: u64 }, Forks { list, as_of: u64 }, Fork { entry, as_of: u64 }. The door closes them like every other contract map:
// every key present, no other key, as_of an unsigned integer. (Huginn's own golden replies, list-empty and list-counts,
// carry exactly list and as_of.)
public sealed class UnixSocketDeskDoorWrapperTests
{
private static string Entry => DeskServe.Entry("entry-open-counted").ToJsonString(DeskJson.Options);
private static string Page => DeskServe.PageOf(DeskServe.Entry("entry-open-counted")).ToJsonString(DeskJson.Options);
private static string Answer => DeskServe.Entry("entry-open-counted")["record"]!["answers"]![0]!.ToJsonString(DeskJson.Options);
// variant, call, and a function from the wrapper's tail to the reply JSON.
public static TheoryData<string> Variants() => ["Forks", "Fork", "Answered"];
private static string Reply(string variant, string tail) => variant switch
{
"Forks" => "{\"Forks\":{\"list\":" + Page + tail + "}}",
"Fork" => "{\"Fork\":{\"entry\":" + Entry + tail + "}}",
_ => "{\"Answered\":{\"answer\":" + Answer + ",\"counted_answer_id\":\"n1\"" + tail + "}}",
};
private static async Task<object> Serve(string variant, byte[] payload) => variant switch
{
"Forks" => await DeskServe.ListPayloadAsync(payload),
"Fork" => await DeskServe.ReadPayloadAsync(payload),
_ => await DeskServe.AnswerPayloadAsync(payload),
};
private static byte[] Pack(string json) => MessagePackSerializer.ConvertFromJson(json);
[Theory]
[MemberData(nameof(Variants))]
public async Task A_wrapper_with_as_of_alone_beside_its_value_decodes_and_as_of_may_be_any_u64(string variant)
{
await Serve(variant, Pack(Reply(variant, ",\"as_of\":0")));
await Serve(variant, Pack(Reply(variant, ",\"as_of\":4294967296")));
await Serve(variant, Pack(Reply(variant, ",\"as_of\":18446744073709551615")));
}
[Theory]
[MemberData(nameof(Variants))]
public async Task A_wrapper_without_as_of_or_with_one_that_is_not_an_unsigned_integer_is_a_door_failure(string variant)
{
foreach (var tail in new[]
{
"", ",\"as_of\":null", ",\"as_of\":-1", ",\"as_of\":\"1\"", ",\"as_of\":true", ",\"as_of\":1.5", ",\"as_of\":[1]", ",\"as_of\":{}",
})
{
await Assert.ThrowsAsync<DeskDoorException>(() => Serve(variant, Pack(Reply(variant, tail))));
}
}
[Theory]
[MemberData(nameof(Variants))]
public async Task A_wrapper_with_a_key_beyond_the_variants_declared_ones_is_a_door_failure(string variant)
{
foreach (var tail in new[] { ",\"as_of\":1,\"viewer\":\"x\"", ",\"viewer\":1,\"as_of\":1", ",\"as_of\":1,\"as_of_extra\":null" })
{
await Assert.ThrowsAsync<DeskDoorException>(() => Serve(variant, Pack(Reply(variant, tail))));
}
}
}

View file

@ -60,6 +60,26 @@
"resolved": "2.4.0",
"contentHash": "SwXsAV3sMvAU/Nn31pbjhWurYSjJ+/giI/0n6tCrYoupEK34iIHCuk3STAd9fx8yudM85KkLSVdn951vTng/vQ=="
},
"MessagePack": {
"type": "Transitive",
"resolved": "3.1.12",
"contentHash": "m1QEsd59gUj82ji5tEQbZiyjV6y0F7cYfGVhil6pAlNZpyzR7ze9TucMu4lPpi8cZAV8aNl3D9/T9J1HbFz15Q==",
"dependencies": {
"MessagePack.Annotations": "3.1.12",
"MessagePackAnalyzer": "3.1.12",
"Microsoft.NET.StringTools": "17.11.4"
}
},
"MessagePack.Annotations": {
"type": "Transitive",
"resolved": "3.1.12",
"contentHash": "LdI67sY7F3KK+JsrG02+DP1XFkowaZSoyfL2M88hjm1MOOsjf8amBLAJolV+zxoPUZf3uGbKzq1cMQD8PlpSpA=="
},
"MessagePackAnalyzer": {
"type": "Transitive",
"resolved": "3.1.12",
"contentHash": "iNSNatPh6bafRUbkQKlClExjpPpd8hiZgfZsodGf7bheIlkizGbMzsJ2mPui8jOHRe4Ky9eg5ou/fFzI7CT3fg=="
},
"Microsoft.AspNetCore.TestHost": {
"type": "Transitive",
"resolved": "8.0.0",
@ -453,6 +473,11 @@
"Microsoft.IdentityModel.Logging": "8.19.2"
}
},
"Microsoft.NET.StringTools": {
"type": "Transitive",
"resolved": "17.11.4",
"contentHash": "mudqUHhNpeqIdJoUx2YDWZO/I9uEDYVowan89R6wsomfnUJQk6HteoQTlNjZDixhT2B4IXMkMtgZtoceIjLRmA=="
},
"Microsoft.TestPlatform.ObjectModel": {
"type": "Transitive",
"resolved": "17.14.1",
@ -635,6 +660,7 @@
"type": "Project",
"dependencies": {
"BouncyCastle.Cryptography": "[2.4.0, )",
"MessagePack": "[3.1.12, )",
"Microsoft.Extensions.Caching.Memory": "[8.0.1, )",
"Npgsql": "[8.0.3, )",
"Npgsql.EntityFrameworkCore.PostgreSQL": "[8.0.0, )",