Desk: UnixSocketDeskDoor (Huginn desk door client), fixtures at 79d954c, card re-pin to CultLib 3a51bbab, card opaque #70
99 changed files with 8271 additions and 1521 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
615
src/Bifrost.Web/Features/Desk/UnixSocketDeskDoor.cs
Normal file
615
src/Bifrost.Web/Features/Desk/UnixSocketDeskDoor.cs
Normal 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"),
|
||||
};
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
172
tests/Bifrost.Web.Tests/DeskFeedFixturePinTests.cs
Normal file
172
tests/Bifrost.Web.Tests/DeskFeedFixturePinTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
89
tests/Bifrost.Web.Tests/DeskReaderViewTests.cs
Normal file
89
tests/Bifrost.Web.Tests/DeskReaderViewTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
99
tests/Bifrost.Web.Tests/DeskVectorCensusTests.cs
Normal file
99
tests/Bifrost.Web.Tests/DeskVectorCensusTests.cs
Normal 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
236
tests/Bifrost.Web.Tests/DeskVectorWireTests.cs
Normal file
236
tests/Bifrost.Web.Tests/DeskVectorWireTests.cs
Normal 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"]!);
|
||||
}
|
||||
}
|
||||
}
|
||||
3
tests/Bifrost.Web.Tests/Fixtures/.gitattributes
vendored
Normal file
3
tests/Bifrost.Web.Tests/Fixtures/.gitattributes
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
fork-card-vectors.json text eol=lf
|
||||
desk-feed/**/*.md text eol=lf
|
||||
desk-feed/*.md text eol=lf
|
||||
3
tests/Bifrost.Web.Tests/Fixtures/desk-feed/.gitattributes
vendored
Normal file
3
tests/Bifrost.Web.Tests/Fixtures/desk-feed/.gitattributes
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
*.bin binary
|
||||
*.msgpack binary
|
||||
*.hex text eol=lf
|
||||
44
tests/Bifrost.Web.Tests/Fixtures/desk-feed/README.md
Normal file
44
tests/Bifrost.Web.Tests/Fixtures/desk-feed/README.md
Normal 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.
|
||||
|
|
@ -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.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -0,0 +1 @@
|
|||
0000007385ad736368656d6156657273696f6eb063756c746e65742e6572726f722e7630a56572726f72d92f687567696e6e2e6465736b20726566757365732061206d6573736167655f6964206f76657220323536206279746573ab726f7574696e6748696e74c0a4636f6465c0a764657461696c73c0
|
||||
Binary file not shown.
|
|
@ -0,0 +1 @@
|
|||
000001708aab646961676e6f737469637391b57061796c6f61642d6e6f742d612d72657175657374a96d6573736167654964a36d2d31a96f7065726174696f6ea5666f726b73a77061796c6f6164d95c6771526a6232526c745842686557787659575174626d39304c574574636d56786457567a644b64745a584e7a5957646c76325a705a57786b49455a76636d747a4c6d787062576c304f6942766458517462325974636d46755a32553daf7061796c6f6164456e636f64696e67b26d6573736167657061636b2d626173653634ad7061796c6f6164536368656d61d92567616d6563756c742e63756c746e65742e6f7065726174696f6e5f6661696c7572652e7631ad736368656d6156657273696f6ebd63756c746e65742e6f7065726174696f6e5f726573706f6e73652e7630a9736572766963654964ab687567696e6e2e6465736baf736f7572636552756e74696d654964ad687567696e6e2d657572656b61a6737461747573a872656a6563746564
|
||||
|
|
@ -0,0 +1 @@
|
|||
82a4636f6465b57061796c6f61642d6e6f742d612d72657175657374a76d657373616765bf6669656c6420466f726b732e6c696d69743a206f75742d6f662d72616e6765
|
||||
|
|
@ -0,0 +1 @@
|
|||
うcodeオpayload-not-a-requestァmessageソfield Forks.limit: out-of-range
|
||||
Binary file not shown.
|
|
@ -0,0 +1 @@
|
|||
000001a287a96d6573736167654964a36d2d33a96f7065726174696f6ea5666f726b73a77061796c6f6164d9f06761564762334a726334616b62576c755a4b5a6c64584a6c6132476e63335669616d566a644e6b6b4e575977597a4a694d5755744f4745305a433030597a4e694c546c6c4e3259744d574579596a4e6a4e4751315a545a6d7147526c59326c6b5a584a7a6b746b6b4e575977597a4a694d5755744f4745305a433030597a4e694c546c6c4e3259744d574579596a4e6a4e4751315a545a6d32535177596a6c6b4e32557a59533032597a49784c54526d4e5467745954426b4e4330355a54686d4e324532596a566a4e47536d633352686448567a704739775a57366d5933567963323979774b5673615731706441413daf7061796c6f6164456e636f64696e67b26d6573736167657061636b2d626173653634ad7061796c6f6164536368656d61b6687567696e6e2e6465736b5f726571756573742e7631ad736368656d6156657273696f6ebc63756c746e65742e6f7065726174696f6e5f726571756573742e7630a9736572766963654964ab687567696e6e2e6465736b
|
||||
|
|
@ -0,0 +1 @@
|
|||
81a5466f726b7386a46d696e64a6657572656b61a77375626a656374d92435663063326231652d386134642d346333622d396537662d316132623363346435653666a8646563696465727392d92435663063326231652d386134642d346333622d396537662d316132623363346435653666d92430623964376533612d366332312d346635382d613064342d396538663761366235633464a6737461747573a46f70656ea6637572736f72c0a56c696d697400
|
||||
Binary file not shown.
Binary file not shown.
|
|
@ -0,0 +1 @@
|
|||
0000019289a96d6573736167654964a36d2d33a96f7065726174696f6ea5666f726b73a77061796c6f6164d9b06761564762334a7263344b6b62476c7a6449657463324e6f5a573168566d567963326c766272566e5957316c593356736443356d62334a724c6d787063335175646a436b62576c755a4b5a6c64584a6c6132476c633352686258436d637a45774d546b77716d39775a57356659323931626e5144726e4a6c593239795a47566b58324e76645735304261566d62334a7263354372626d56346446396a64584a7a62334c417057467a5832396d7a53664faf7061796c6f6164456e636f64696e67b26d6573736167657061636b2d626173653634ad7061796c6f6164536368656d61b7687567696e6e2e6465736b5f726573706f6e73652e7631ad736368656d6156657273696f6ebd63756c746e65742e6f7065726174696f6e5f726573706f6e73652e7630a9736572766963654964ab687567696e6e2e6465736baf736f7572636552756e74696d654964ad687567696e6e2d657572656b61a6737461747573a86163636570746564
|
||||
|
|
@ -0,0 +1 @@
|
|||
81a5466f726b7382a46c69737487ad736368656d6156657273696f6eb567616d6563756c742e666f726b2e6c6973742e7630a46d696e64a6657572656b61a57374616d70a6733130313930aa6f70656e5f636f756e7403ae7265636f726465645f636f756e7405a5666f726b7390ab6e6578745f637572736f72c0a561735f6f66cd27ce
|
||||
|
|
@ -0,0 +1 @@
|
|||
▼Forksうlist<EFBFBD>schemaVersionオgamecult.fork.list.v0、mindヲeureka・stampヲs10190ェopen_countョrecorded_count・forks性next_cursorタ・as_ofヘ'ホ
|
||||
Binary file not shown.
|
|
@ -0,0 +1 @@
|
|||
0000019289a96d6573736167654964a36d2d31a96f7065726174696f6ea5666f726b73a77061796c6f6164d9b06761564762334a7263344b6b62476c7a6449657463324e6f5a573168566d567963326c766272566e5957316c593356736443356d62334a724c6d787063335175646a436b62576c755a4b5a6c64584a6c6132476c633352686258436d637a45774d546b77716d39775a57356659323931626e5141726e4a6c593239795a47566b58324e7664573530414b566d62334a7263354372626d56346446396a64584a7a62334c417057467a5832396d7a53664faf7061796c6f6164456e636f64696e67b26d6573736167657061636b2d626173653634ad7061796c6f6164536368656d61b7687567696e6e2e6465736b5f726573706f6e73652e7631ad736368656d6156657273696f6ebd63756c746e65742e6f7065726174696f6e5f726573706f6e73652e7630a9736572766963654964ab687567696e6e2e6465736baf736f7572636552756e74696d654964ad687567696e6e2d657572656b61a6737461747573a86163636570746564
|
||||
|
|
@ -0,0 +1 @@
|
|||
81a5466f726b7382a46c69737487ad736368656d6156657273696f6eb567616d6563756c742e666f726b2e6c6973742e7630a46d696e64a6657572656b61a57374616d70a6733130313930aa6f70656e5f636f756e7400ae7265636f726465645f636f756e7400a5666f726b7390ab6e6578745f637572736f72c0a561735f6f66cd27ce
|
||||
Binary file not shown.
Binary file not shown.
|
|
@ -0,0 +1 @@
|
|||
000001a687a96d6573736167654964a36d2d32a96f7065726174696f6ea5666f726b73a77061796c6f6164d9f46761564762334a726334616b62576c755a4b5a6c64584a6c6132476e63335669616d566a644e6b6b4e575977597a4a694d5755744f4745305a433030597a4e694c546c6c4e3259744d574579596a4e6a4e4751315a545a6d7147526c59326c6b5a584a7a6b746b6b4e575977597a4a694d5755744f4745305a433030597a4e694c546c6c4e3259744d574579596a4e6a4e4751315a545a6d32535177596a6c6b4e32557a59533032597a49784c54526d4e5467745954426b4e4330355a54686d4e324532596a566a4e47536d633352686448567a704739775a57366d59335679633239796f324d304d715673615731706444493daf7061796c6f6164456e636f64696e67b26d6573736167657061636b2d626173653634ad7061796c6f6164536368656d61b6687567696e6e2e6465736b5f726571756573742e7631ad736368656d6156657273696f6ebc63756c746e65742e6f7065726174696f6e5f726571756573742e7630a9736572766963654964ab687567696e6e2e6465736b
|
||||
|
|
@ -0,0 +1 @@
|
|||
81a5466f726b7386a46d696e64a6657572656b61a77375626a656374d92435663063326231652d386134642d346333622d396537662d316132623363346435653666a8646563696465727392d92435663063326231652d386134642d346333622d396537662d316132623363346435653666d92430623964376533612d366332312d346635382d613064342d396538663761366235633464a6737461747573a46f70656ea6637572736f72a3633432a56c696d697432
|
||||
|
|
@ -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
|
||||
Binary file not shown.
|
|
@ -0,0 +1 @@
|
|||
000001a287a96d6573736167654964a36d2d31a96f7065726174696f6ea5666f726b73a77061796c6f6164d9f06761564762334a726334616b62576c755a4b5a6c64584a6c6132476e63335669616d566a644e6b6b4e575977597a4a694d5755744f4745305a433030597a4e694c546c6c4e3259744d574579596a4e6a4e4751315a545a6d7147526c59326c6b5a584a7a6b746b6b4e575977597a4a694d5755744f4745305a433030597a4e694c546c6c4e3259744d574579596a4e6a4e4751315a545a6d32535177596a6c6b4e32557a59533032597a49784c54526d4e5467745954426b4e4330355a54686d4e324532596a566a4e47536d633352686448567a704739775a57366d5933567963323979774b5673615731706444493daf7061796c6f6164456e636f64696e67b26d6573736167657061636b2d626173653634ad7061796c6f6164536368656d61b6687567696e6e2e6465736b5f726571756573742e7631ad736368656d6156657273696f6ebc63756c746e65742e6f7065726174696f6e5f726571756573742e7630a9736572766963654964ab687567696e6e2e6465736b
|
||||
|
|
@ -0,0 +1 @@
|
|||
81a5466f726b7386a46d696e64a6657572656b61a77375626a656374d92435663063326231652d386134642d346333622d396537662d316132623363346435653666a8646563696465727392d92435663063326231652d386134642d346333622d396537662d316132623363346435653666d92430623964376533612d366332312d346635382d613064342d396538663761366235633464a6737461747573a46f70656ea6637572736f72c0a56c696d697432
|
||||
|
|
@ -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
|
||||
Binary file not shown.
|
|
@ -0,0 +1 @@
|
|||
0000012689a96d6573736167654964a36d2d31a96f7065726174696f6ea5666f726b73a77061796c6f6164d944676164535a575a316332566b676242535a584e776232357a5a56527662307868636d646c677156696558526c63383441415246777057787062576c307a6741424141413daf7061796c6f6164456e636f64696e67b26d6573736167657061636b2d626173653634ad7061796c6f6164536368656d61b7687567696e6e2e6465736b5f726573706f6e73652e7631ad736368656d6156657273696f6ebd63756c746e65742e6f7065726174696f6e5f726573706f6e73652e7630a9736572766963654964ab687567696e6e2e6465736baf736f7572636552756e74696d654964ad687567696e6e2d657572656b61a6737461747573a872656a6563746564
|
||||
|
|
@ -0,0 +1 @@
|
|||
81a75265667573656481b0526573706f6e7365546f6f4c6172676582a56279746573ce00011170a56c696d6974ce00010000
|
||||
Binary file not shown.
Binary file not shown.
|
|
@ -0,0 +1 @@
|
|||
0000020b89a96d6573736167654964a36d2d31a96f7065726174696f6ea5666f726b73a77061796c6f6164da012867616c4f62335254644739795a57534472584e6a6147567459565a6c636e4e70623236345a3246745a574e31624851755a6d3979617935795a575a31633246734c6e597770474e765a4757755a6d39796131393062323966624746795a32576c5a584a7962334c5a6a575a76636d74666447397658327868636d646c4f69426d62334a7249475631636d56725953317a64574a7a64484a68644755366358566c63335270623234365a47567a6179317361584e306157356e4c57463164476876636d6c306553426864434276636d526c636941304d5342706379427759584e304948526f5a5342775957646c49474a315a47646c644342696553427064484e6c6247593749474e76626e5270626e566c4948647064476767593356796332397949474d304d513d3daf7061796c6f6164456e636f64696e67b26d6573736167657061636b2d626173653634ad7061796c6f6164536368656d61b7687567696e6e2e6465736b5f726573706f6e73652e7631ad736368656d6156657273696f6ebd63756c746e65742e6f7065726174696f6e5f726573706f6e73652e7630a9736572766963654964ab687567696e6e2e6465736baf736f7572636552756e74696d654964ad687567696e6e2d657572656b61a6737461747573a872656a6563746564
|
||||
|
|
@ -0,0 +1 @@
|
|||
81a94e6f7453746f72656483ad736368656d6156657273696f6eb867616d6563756c742e666f726b2e7265667573616c2e7630a4636f6465ae666f726b5f746f6f5f6c61726765a56572726f72d98d666f726b5f746f6f5f6c617267653a20666f726b20657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479206174206f7264657220343120697320706173742074686520706167652062756467657420627920697473656c663b20636f6e74696e7565207769746820637572736f7220633431
|
||||
|
|
@ -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
|
||||
Binary file not shown.
|
|
@ -0,0 +1 @@
|
|||
000001d989a96d6573736167654964a36d2d34a96f7065726174696f6ea4666f726ba77061796c6f6164d9f867616c4f62335254644739795a57534472584e6a6147567459565a6c636e4e70623236345a3246745a574e31624851755a6d3979617935795a575a31633246734c6e597770474e765a4757755a6d39796131393062323966624746795a32576c5a584a7962334c5a616d5a76636d74666447397658327868636d646c4f69426d62334a7249475631636d56725953317a64574a7a64484a68644755366358566c63335270623234365a47567a6179317361584e306157356e4c57463164476876636d6c30655342706379427759584e304948526f5a5342795a584e776232357a5a534269623356755a4342696553427064484e6c6247593daf7061796c6f6164456e636f64696e67b26d6573736167657061636b2d626173653634ad7061796c6f6164536368656d61b7687567696e6e2e6465736b5f726573706f6e73652e7631ad736368656d6156657273696f6ebd63756c746e65742e6f7065726174696f6e5f726573706f6e73652e7630a9736572766963654964ab687567696e6e2e6465736baf736f7572636552756e74696d654964ad687567696e6e2d657572656b61a6737461747573a872656a6563746564
|
||||
|
|
@ -0,0 +1 @@
|
|||
81a94e6f7453746f72656483ad736368656d6156657273696f6eb867616d6563756c742e666f726b2e7265667573616c2e7630a4636f6465ae666f726b5f746f6f5f6c61726765a56572726f72d96a666f726b5f746f6f5f6c617267653a20666f726b20657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f7269747920697320706173742074686520726573706f6e736520626f756e6420627920697473656c66
|
||||
|
|
@ -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
|
||||
Binary file not shown.
|
|
@ -0,0 +1 @@
|
|||
0000016989a96d6573736167654964a36d2d34a96f7065726174696f6ea4666f726ba77061796c6f6164d98867616c4f62335254644739795a57534472584e6a6147567459565a6c636e4e70623236345a3246745a574e31624851755a6d3979617935795a575a31633246734c6e597770474e765a4757735a6d397961313931626d74756233647570575679636d3979756d5a76636d746664573572626d3933626a6f67626d38676333566a6143426d62334a72af7061796c6f6164456e636f64696e67b26d6573736167657061636b2d626173653634ad7061796c6f6164536368656d61b7687567696e6e2e6465736b5f726573706f6e73652e7631ad736368656d6156657273696f6ebd63756c746e65742e6f7065726174696f6e5f726573706f6e73652e7630a9736572766963654964ab687567696e6e2e6465736baf736f7572636552756e74696d654964ad687567696e6e2d657572656b61a6737461747573a872656a6563746564
|
||||
|
|
@ -0,0 +1 @@
|
|||
81a94e6f7453746f72656483ad736368656d6156657273696f6eb867616d6563756c742e666f726b2e7265667573616c2e7630a4636f6465ac666f726b5f756e6b6e6f776ea56572726f72ba666f726b5f756e6b6e6f776e3a206e6f207375636820666f726b
|
||||
|
|
@ -0,0 +1 @@
|
|||
<EFBFBD>NotStored<EFBFBD>schemaVersion經amecult.fork.refusal.v0勺ode柏ork_unknown占rror榻ork_unknown: no such fork
|
||||
Binary file not shown.
|
|
@ -0,0 +1 @@
|
|||
000001ca87a96d6573736167654964a36d2d34a96f7065726174696f6ea4666f726ba77061796c6f6164da01186761524762334a72684b52746157356b706d5631636d56725961647a64574a715a574e30325351315a6a426a4d6d49785a5330345954526b4c54526a4d3249744f5755335a69307859544a694d324d305a44566c4e6d616f5a47566a6157526c636e4f53325351315a6a426a4d6d49785a5330345954526b4c54526a4d3249744f5755335a69307859544a694d324d305a44566c4e6d625a4a4442694f5751335a544e684c545a6a4d6a45744e4759314f4331684d4751304c546c6c4f47593359545a694e574d305a4b646d62334a7258326c6b3254426c64584a6c6132457463335669633352795958526c4f6e46315a584e30615739754f6d526c6332737462476c7a64476c755a7931686458526f62334a7064486b3daf7061796c6f6164456e636f64696e67b26d6573736167657061636b2d626173653634ad7061796c6f6164536368656d61b6687567696e6e2e6465736b5f726571756573742e7631ad736368656d6156657273696f6ebc63756c746e65742e6f7065726174696f6e5f726571756573742e7630a9736572766963654964ab687567696e6e2e6465736b
|
||||
|
|
@ -0,0 +1 @@
|
|||
81a4466f726b84a46d696e64a6657572656b61a77375626a656374d92435663063326231652d386134642d346333622d396537662d316132623363346435653666a8646563696465727392d92435663063326231652d386134642d346333622d396537662d316132623363346435653666d92430623964376533612d366332312d346635382d613064342d396538663761366235633464a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1 @@
|
|||
85ad736368656d6156657273696f6eb767616d6563756c742e666f726b2e7265636f72642e7630a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438b1636f756e7465645f616e737765725f6964a26e31a7616e737765727394de0010ad736368656d6156657273696f6eb767616d6563756c742e6465736b5f616e737765722e7630a9616e737765725f6964a26e31a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a76f7574636f6d65a7436f756e746564a26f66c0a87265747261637473c0a4636f6465c0ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a663686f696365ab61736b696e672d6d696e64a46e6f7465be5468652061736b6572206c6973747320686572206f776e20666f726b732eab616e7377657265645f6174b4323032362d31312d31315431373a30303a30305aab72656365697665645f6174b4323032362d31312d31315431373a30303a30305aaf636c61696d5f696e5f667574757265c2a8616e737765726572d92435663063326231652d386134642d346333622d396537662d316132623363346435653666a9617574686f72697479a64d656d626572aa726571756573745f6964d92430303030303030302d303030302d343030302d383030302d303030303030303030303031de0010ad736368656d6156657273696f6eb767616d6563756c742e6465736b5f616e737765722e7630a9616e737765725f6964a26e32a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a76f7574636f6d65a85265766973696f6ea26f66a26e31a87265747261637473c0a4636f6465c0ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a663686f696365ab61736b696e672d6d696e64a46e6f7465a74167726565642eab616e7377657265645f6174b4323032362d31312d31315431373a30353a30305aab72656365697665645f6174b4323032362d31312d31315431373a30353a30305aaf636c61696d5f696e5f667574757265c2a8616e737765726572d92430623964376533612d366332312d346635382d613064342d396538663761366235633464a9617574686f72697479a64d656d626572aa726571756573745f6964d92430303030303030302d303030302d343030302d383030302d303030303030303030303032de0010ad736368656d6156657273696f6eb767616d6563756c742e6465736b5f616e737765722e7630a9616e737765725f6964a26e33a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a76f7574636f6d65a752656675736564a26f66c0a87265747261637473c0a4636f6465ad6e6f745f615f64656369646572ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a663686f696365ab61736b696e672d6d696e64a46e6f7465a74d6520746f6f2eab616e7377657265645f6174b4323032362d31312d31315431373a30363a30305aab72656365697665645f6174b4323032362d31312d31315431373a30363a30305aaf636c61696d5f696e5f667574757265c2a8616e737765726572d92463336131663265342d376236642d346538662d396130622d316332643365346635613662a9617574686f72697479a64d656d626572aa726571756573745f6964d92430303030303030302d303030302d343030302d383030302d303030303030303030303033de0010ad736368656d6156657273696f6eb767616d6563756c742e6465736b5f616e737765722e7630a9616e737765725f6964a26e34a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a76f7574636f6d65aa52657472616374696f6ea26f66c0a87265747261637473a26e32a4636f6465c0ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a663686f696365c0a46e6f7465c0ab616e7377657265645f6174b4323032362d31312d31315431373a30373a30305aab72656365697665645f6174b4323032362d31312d31315431373a30373a30305aaf636c61696d5f696e5f667574757265c2a8616e737765726572d92430623964376533612d366332312d346635382d613064342d396538663761366235633464a9617574686f72697479a64d656d626572aa726571756573745f6964d92430303030303030302d303030302d343030302d383030302d303030303030303030303034
|
||||
Binary file not shown.
Binary file not shown.
|
|
@ -0,0 +1 @@
|
|||
0000010689a96d6573736167654964a36d2d31a96f7065726174696f6ea5666f726b73a77061796c6f6164d924676164535a575a316332566b7230317063334e70626d644a5a47567564476c3065513d3daf7061796c6f6164456e636f64696e67b26d6573736167657061636b2d626173653634ad7061796c6f6164536368656d61b7687567696e6e2e6465736b5f726573706f6e73652e7631ad736368656d6156657273696f6ebd63756c746e65742e6f7065726174696f6e5f726573706f6e73652e7630a9736572766963654964ab687567696e6e2e6465736baf736f7572636552756e74696d654964ad687567696e6e2d657572656b61a6737461747573a872656a6563746564
|
||||
|
|
@ -0,0 +1 @@
|
|||
81a752656675736564af4d697373696e674964656e74697479
|
||||
|
|
@ -0,0 +1 @@
|
|||
<EFBFBD>§Refused¯MissingIdentity
|
||||
Binary file not shown.
|
|
@ -0,0 +1 @@
|
|||
0000013689a96d6573736167654964a36d2d31a96f7065726174696f6ea5666f726b73a77061796c6f6164d954676164535a575a316332566b67617456626d463259576c7359574a735a59476d5a47563059576c73766e526f5a5342746157356b49484e3062334a6c49476c7a49473576644342795a57466b59574a735a513d3daf7061796c6f6164456e636f64696e67b26d6573736167657061636b2d626173653634ad7061796c6f6164536368656d61b7687567696e6e2e6465736b5f726573706f6e73652e7631ad736368656d6156657273696f6ebd63756c746e65742e6f7065726174696f6e5f726573706f6e73652e7630a9736572766963654964ab687567696e6e2e6465736baf736f7572636552756e74696d654964ad687567696e6e2d657572656b61a6737461747573a872656a6563746564
|
||||
|
|
@ -0,0 +1 @@
|
|||
81a75265667573656481ab556e617661696c61626c6581a664657461696cbe746865206d696e642073746f7265206973206e6f74207265616461626c65
|
||||
|
|
@ -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
|
|
@ -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)
|
||||
|
|
|
|||
152
tests/Bifrost.Web.Tests/Support/DeskServe.cs
Normal file
152
tests/Bifrost.Web.Tests/Support/DeskServe.cs
Normal 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)];
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
197
tests/Bifrost.Web.Tests/Support/FakeHuginnSocket.cs
Normal file
197
tests/Bifrost.Web.Tests/Support/FakeHuginnSocket.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
95
tests/Bifrost.Web.Tests/UnixSocketDeskDoorBytesTests.cs
Normal file
95
tests/Bifrost.Web.Tests/UnixSocketDeskDoorBytesTests.cs
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
using System.Text.Json.Nodes;
|
||||
using Bifrost.Web.Features.Desk;
|
||||
using Bifrost.Web.Tests.Support;
|
||||
|
||||
namespace Bifrost.Web.Tests;
|
||||
|
||||
// The reply's bytes that JSON cannot spell: a MessagePack bin where the contract has text or nil, and text that is not
|
||||
// UTF-8. Either is a door failure wherever it sits, never a nil or a replacement character Bifrost made up.
|
||||
public sealed class UnixSocketDeskDoorBytesTests
|
||||
{
|
||||
private const string Marker = "@@MARK@@";
|
||||
|
||||
// Where a value can stand: a nullable key (note, answerer, next_cursor), a text key, and a card position.
|
||||
public static TheoryData<string> Positions() => new()
|
||||
{
|
||||
"note", "answerer", "next_cursor", "card.raised_at", "card.title", "card.options[0].text", "record.counted_answer_id", "mind", "card_key_name",
|
||||
};
|
||||
|
||||
private static JsonObject PageWithMarkerAt(string position)
|
||||
{
|
||||
var page = DeskServe.PageOf(DeskServe.Entry("entry-open-counted"));
|
||||
var entry = page["forks"]![0]!.AsObject();
|
||||
switch (position)
|
||||
{
|
||||
case "note": entry["record"]!["answers"]![0]!["note"] = Marker; break;
|
||||
case "answerer": entry["record"]!["answers"]![0]!["answerer"] = Marker; break;
|
||||
case "next_cursor": page["next_cursor"] = Marker; break;
|
||||
case "card.raised_at": entry["card"]!["raised_at"] = Marker; break;
|
||||
case "card.title": entry["card"]!["title"] = Marker; break;
|
||||
case "card.options[0].text": entry["card"]!["options"]![0]!["text"] = Marker; break;
|
||||
case "record.counted_answer_id": entry["record"]!["counted_answer_id"] = Marker; break;
|
||||
case "mind": page["mind"] = Marker; break;
|
||||
case "card_key_name":
|
||||
var card = entry["card"]!.AsObject();
|
||||
var rest = card.ToList();
|
||||
card.Clear();
|
||||
foreach (var (key, value) in rest)
|
||||
{
|
||||
card[key == "title" ? Marker : key] = value;
|
||||
}
|
||||
|
||||
break;
|
||||
default: throw new ArgumentOutOfRangeException(nameof(position));
|
||||
}
|
||||
|
||||
return page;
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(Positions))]
|
||||
public async Task A_binary_in_a_nullable_text_or_card_position_is_a_door_failure(string position)
|
||||
{
|
||||
var bytes = DeskServe.WithMarkerReplaced(PageWithMarkerAt(position), Marker, DeskServe.Bin(3));
|
||||
|
||||
await Assert.ThrowsAsync<DeskDoorException>(() => DeskServe.ListPayloadAsync(DeskServe.Forks(bytes)));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(Positions))]
|
||||
public async Task Invalid_utf8_in_text_is_a_door_failure(string position)
|
||||
{
|
||||
// 0xff 0xfe as a str of 2 bytes; Bifrost writes no replacement character.
|
||||
var bytes = DeskServe.WithMarkerReplaced(PageWithMarkerAt(position), Marker, [0xa2, 0xff, 0xfe]);
|
||||
|
||||
await Assert.ThrowsAsync<DeskDoorException>(() => DeskServe.ListPayloadAsync(DeskServe.Forks(bytes)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_same_marker_as_valid_text_decodes_so_the_failures_above_are_the_bytes_not_the_page()
|
||||
{
|
||||
foreach (var position in new[] { "note", "answerer", "next_cursor", "card.raised_at", "card.title" })
|
||||
{
|
||||
var bytes = DeskServe.WithMarkerReplaced(PageWithMarkerAt(position), Marker, [.. new byte[] { 0xa2 }, .. "ok"u8.ToArray()]);
|
||||
|
||||
Assert.IsType<DeskListOutcome.Listed>(await DeskServe.ListPayloadAsync(DeskServe.Forks(bytes)));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_binary_or_invalid_utf8_in_a_read_or_an_answer_is_a_door_failure_too()
|
||||
{
|
||||
var entry = DeskServe.Entry("entry-open-counted");
|
||||
entry["record"]!["answers"]![0]!["note"] = Marker;
|
||||
foreach (var with in new[] { DeskServe.Bin(2), new byte[] { 0xa2, 0xff, 0xfe } })
|
||||
{
|
||||
var read = DeskServe.WithMarkerReplaced(entry, Marker, with);
|
||||
await Assert.ThrowsAsync<DeskDoorException>(() => DeskServe.ReadPayloadAsync(DeskServe.Fork(read)));
|
||||
|
||||
var answer = DeskServe.Entry("entry-open-counted")["record"]!["answers"]![0]!;
|
||||
answer["note"] = Marker;
|
||||
var bytes = DeskServe.WithMarkerReplaced(answer, Marker, with);
|
||||
await Assert.ThrowsAsync<DeskDoorException>(() => DeskServe.AnswerPayloadAsync(DeskServe.Answered(bytes, "n1")));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
281
tests/Bifrost.Web.Tests/UnixSocketDeskDoorStrictTests.cs
Normal file
281
tests/Bifrost.Web.Tests/UnixSocketDeskDoorStrictTests.cs
Normal 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());
|
||||
}
|
||||
}
|
||||
577
tests/Bifrost.Web.Tests/UnixSocketDeskDoorTests.cs
Normal file
577
tests/Bifrost.Web.Tests/UnixSocketDeskDoorTests.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
70
tests/Bifrost.Web.Tests/UnixSocketDeskDoorWrapperTests.cs
Normal file
70
tests/Bifrost.Web.Tests/UnixSocketDeskDoorWrapperTests.cs
Normal 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))));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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, )",
|
||||
|
|
|
|||
Loading…
Reference in a new issue