Desk: UnixSocketDeskDoor (Huginn desk door client), fixtures at 79d954c, card re-pin to CultLib 3a51bbab, card opaque (#70)
This commit is contained in:
parent
b1725a106f
commit
c2de290628
129 changed files with 8417 additions and 1527 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).
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ public static class DeskEndpoints
|
|||
services.TryAddSingleton<IDeskCredential, OidcDeskCredential>();
|
||||
}
|
||||
|
||||
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.
|
||||
|
|
|
|||
624
src/Bifrost.Web/Features/Desk/UnixSocketDeskDoor.cs
Normal file
624
src/Bifrost.Web/Features/Desk/UnixSocketDeskDoor.cs
Normal file
|
|
@ -0,0 +1,624 @@
|
|||
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 232764bf). 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));
|
||||
var entry = Entry(Required(value, "entry"), "entry");
|
||||
// The route asked for one fork. An entry whose card names another is not the answer to it. (That the record
|
||||
// agrees with the card is a rule of the entry, CultLib's validators' and Huginn's, not the door's.)
|
||||
if (!entry.Card.TryGetProperty("fork_id", out var cardFork) || cardFork.ValueKind != JsonValueKind.String
|
||||
|| cardFork.GetString() != forkId)
|
||||
{
|
||||
throw new DeskDoorException("the entry is not for the fork asked");
|
||||
}
|
||||
|
||||
return new DeskReadOutcome.Found(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, )",
|
||||
|
|
@ -105,6 +116,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",
|
||||
|
|
@ -306,6 +327,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>
|
||||
|
|
|
|||
|
|
@ -259,7 +259,7 @@ public sealed class DeskCredentialTests : IDisposable
|
|||
const string secretAnswerer = "0badc0de-0000-4000-8000-00000000000d"; // not on any decider list, so no card carries it
|
||||
var entry = DeskReplay.Entry(DeskReplay.Card(DeskReplay.ChatFork));
|
||||
var answer = new DeskAnswerDocument(
|
||||
DeskSchemas.DeskAnswer, "n1", DeskReplay.ChatFork, "Counted", null, null, null, entry.Card.OptionsHash, "a", "private words",
|
||||
DeskSchemas.DeskAnswer, "n1", DeskReplay.ChatFork, "Counted", null, null, null, entry.Card.GetProperty("options_hash").GetString(), "a", "private words",
|
||||
"2026-11-12T19:00:00Z", "2026-11-12T19:00:01Z", false, secretAnswerer, "Steward", "r-1");
|
||||
entry = entry with { Record = entry.Record with { Answers = [answer] } };
|
||||
factory.Door.ListScript = (_, r) => new DeskListOutcome.Listed(new ForkList("gamecult.fork.list.v0", r.Mind, "s1", 1, 0, [entry], null));
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
200
tests/Bifrost.Web.Tests/DeskFeedFixturePinTests.cs
Normal file
200
tests/Bifrost.Web.Tests/DeskFeedFixturePinTests.cs
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
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 232764bf78fa93c807d2e18b1d77f45c36159be0 (docs/desk-feed-door.md is the contract): 99 files, the
|
||||
// subdirectory cultlib-d3025d3c/ (fourteen 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 = "232764bf78fa93c807d2e18b1d77f45c36159be0";
|
||||
|
||||
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)
|
||||
{
|
||||
[".gitattributes"] = "171872d6445cb1ffef0263b613d1ac318a60398449831d4cc4f772f78a7ebcff",
|
||||
["README.md"] = "fd3429ee796ac1c3de9571bc2d09e24f0f91aae48d461f1c8eefa51c6b50a3bc",
|
||||
["cultlib-d3025d3c/README.md"] = "b0f6ce43f4c5aa9ae263071b814e2f71445bd50db1d55c6fbcdaf2714166b47a",
|
||||
["cultlib-d3025d3c/answer-counted.msgpack"] = "55d69ae3cc7ebbeeae7d501e8641640e115c3fb5aac399ea760588bca343b9e6",
|
||||
["cultlib-d3025d3c/answer-refused-stale.msgpack"] = "3183bc9c483f4a625332cda11ef22a9c8560868a7728b77daa63a1960a3cd3d6",
|
||||
["cultlib-d3025d3c/answer-retraction.msgpack"] = "89156f41ec3ea54f5d47bd3318913ff013785f4727916875be97785935cb7735",
|
||||
["cultlib-d3025d3c/answer-revision.msgpack"] = "19d7289395ae68cbaf6de1c0a1ef6c843211b2caf9462e1d2268cd0883386315",
|
||||
["cultlib-d3025d3c/answer-withheld-answerer-retraction.msgpack"] = "7d0cc555bf9b6f7c345b0da7c504bcd1ed7b46040ff51d1e57c84b2f56655213",
|
||||
["cultlib-d3025d3c/answer-withheld-answerer-revision.msgpack"] = "93f7c875f963a7cfeab2a5980b6d5ce81e6753fc9f3f8bf0d7353f3f388144b7",
|
||||
["cultlib-d3025d3c/answer-withheld-answerer.msgpack"] = "b66b4f921cdd374be04af3de34c59e254a5e542027ae8c57f196a3ea3406e427",
|
||||
["cultlib-d3025d3c/card-operator-fork.msgpack"] = "56bfdb65ded60b562001d09d0b8da6e092900f404bf5f68f7084dff29bcc34f5",
|
||||
["cultlib-d3025d3c/card-raised-at-nil.msgpack"] = "b81bb825790ffe03fabe63c3f8a61c36bd50ca268aa6dff67b5bcfad3957e58c",
|
||||
["cultlib-d3025d3c/card-withheld-deciders.msgpack"] = "d13b9b77461cfdf78f943ef56c6aeab852ab6b6f0b355e26c5b361f99c79c7f7",
|
||||
["cultlib-d3025d3c/entry-closed-with-ruling-and-a-refusal.msgpack"] = "f7859c7d04d338d6b1c634308b08ae58ae77b0ff459767859a5e40f0af2f16e5",
|
||||
["cultlib-d3025d3c/entry-open-counted.msgpack"] = "b51f499e8dd0465cf5df9d68d8fccbe94dd1339fd3157969693dd0d4ecb2549b",
|
||||
["cultlib-d3025d3c/entry-withheld-closed-with-ruling-and-a-refusal.msgpack"] = "daaa819bfa2612616826ef3217dfcf8d8d8cb71d86547d7106426c779134f827",
|
||||
["cultlib-d3025d3c/entry-withheld-open-counted.msgpack"] = "62ccad7a9f41262d6e35d0247315d0bb75d1313b3c9dabe8a99d1aa1b844607f",
|
||||
["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",
|
||||
["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-non-decider.request.frame.bin"] = "ebf8664681fcf624e4c6b82eb5e23e4c31b56d0f8a03f1cfbadc766408eaa18e",
|
||||
["list-non-decider.request.frame.hex"] = "f0d7f662425ca1c16dea4f5eb78d894898d0dda2ff04f9614663c74620371d26",
|
||||
["list-non-decider.request.payload.hex"] = "383122413f2bd276d27c53bdfcac9f65ba704ea2b1e1c18e72b89b164aad2c2d",
|
||||
["list-non-decider.request.payload.msgpack"] = "9915249f377a6afe4dda2f007c23e8f2598f759bd9d3991834c9ee162b0d6a5b",
|
||||
["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-non-decider.response.frame.bin"] = "8bf4539f305a9530ac88090bdb2f3a3c851f14f66bd2c69f8009ad4934aec3ad",
|
||||
["list-open-non-decider.response.frame.hex"] = "d377e096ce781b0fb86398a58e69ee86205770c298120facac9e739937e88e7d",
|
||||
["list-open-non-decider.response.payload.hex"] = "c99b932fb7c19fc28a8fb04f44b80951a55722cbab9c32114ca41c1cd13ef84e",
|
||||
["list-open-non-decider.response.payload.msgpack"] = "14616093522771954b34b7414849c2896b0d328c8cf0feac6d8038116d813d8c",
|
||||
["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-open.response.frame.bin"] = "82d7b9bd8a5d3a98f2d5780b3cb59fb75b598a333069a03243c1ec83faa6f099",
|
||||
["list-open.response.frame.hex"] = "ebc75b6ae8d16b1cbe0bece1ed3fa32d0ead42fc39ca939e542a8fa45e1dadee",
|
||||
["list-open.response.payload.hex"] = "618918c8020e7bf673758a7e6a58b78469449838b287402bd383a7d1258adc57",
|
||||
["list-open.response.payload.msgpack"] = "f16c74f6340054e40d0d0862381da717a1fa651a67f3250b7bd555cea2834a6e",
|
||||
["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",
|
||||
["read-non-decider.request.frame.bin"] = "3f2729eca79e3c21feebc763f22b5ee86803b333a22b39d5cc1635e2fae245b6",
|
||||
["read-non-decider.request.frame.hex"] = "9dce0664c145dff66d52e2c2d21cf12b187884392908f031a310534716646aa5",
|
||||
["read-non-decider.request.payload.hex"] = "32d77b81c10bc8accbc31a667d2ad631bf5e3404a485d3d3bed3889c50571f8e",
|
||||
["read-non-decider.request.payload.msgpack"] = "c2ec1ecb06123fbb6292ed7a409035f7cc58408885886259131226a8c0aabc08",
|
||||
["read-non-decider.response.frame.bin"] = "15da06225cdd950bf0fe61e49a7ba18d6117b073fdaa6b9732f902564e47d574",
|
||||
["read-non-decider.response.frame.hex"] = "9cdb0efaaf52f8234ea4ad9c357d563f134cedc8dc7f94ae1b297a30670dd013",
|
||||
["read-non-decider.response.payload.hex"] = "0ae37b5f6920c4a41f4f8fe7c45f03c90ec74e384991920811273a0ef4250f9c",
|
||||
["read-non-decider.response.payload.msgpack"] = "41e947355eac70e948633986bc19506fef5c2f92d7fc4242a1783dc1aa688890",
|
||||
["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",
|
||||
["read.request.frame.bin"] = "3a9bc16ce4ce706e8c839bf4cfe65d3e2fdf759992555e68be6fc7bcc15653ea",
|
||||
["read.request.frame.hex"] = "3c3d9540c4af789d230b7108f98d5d5f3dd9b291ff618064ddc374eb2a7bb082",
|
||||
["read.request.payload.hex"] = "48aed263254eac7bc156c1af1f2ee210a08dda2b453a5be2dc17083ab73800b2",
|
||||
["read.request.payload.msgpack"] = "c5447f462247703af4329e071bc0ac14dce1cb94a63c85517400791d022263ca",
|
||||
["read.response.frame.bin"] = "34acacf58f3bd10e86bad6b10f3f14b6a263709b54ff81873af7dcfaf3d95a06",
|
||||
["read.response.frame.hex"] = "600848ae27be452d9a7845c5d822c8eabc1df07b163071d35cc38f6f2ec330bb",
|
||||
["read.response.payload.hex"] = "c3322aa9989a47b9b255cd8d20d6026a159c6b9a8a5ee22f135407bf96cfd2d3",
|
||||
["read.response.payload.msgpack"] = "79fb2f9dd3fde3335bca5cc4337abcf0d7e195afc341352087e6432b7f5ae0a7",
|
||||
["record-whole.payload.hex"] = "871ee32813f46656aa372f6d791d36f885e83194294d3a9fa1fcb906cab73eb4",
|
||||
["record-whole.payload.msgpack"] = "e85e420e7450cfecc698268299b1a26e1ed256d0420143cbad74f80b5a52731d",
|
||||
["record-withheld.payload.hex"] = "a0e7afd9131387cc25d5a2ff5b5f12d546ddd38dfe2bc0d74d3c55a4439048d6",
|
||||
["record-withheld.payload.msgpack"] = "aff799fe787258c35e658cf4d6dea33b95938c47f2d4e72fcdf194f0b537bfed",
|
||||
["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_232764bf_byte_for_byte()
|
||||
{
|
||||
Assert.Equal(99, 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-d3025d3c/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-d3025d3c", "extra.msgpack"), "x");
|
||||
File.Delete(Path.Combine(copy, "read.request.frame.hex"));
|
||||
File.Delete(Path.Combine(copy, "cultlib-d3025d3c", "answer-counted.msgpack"));
|
||||
|
||||
Assert.Equal(
|
||||
["missing: cultlib-d3025d3c/answer-counted.msgpack", "missing: read.request.frame.hex", "extra: cultlib-d3025d3c/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()
|
||||
{
|
||||
|
|
@ -457,7 +430,7 @@ public sealed class DeskReadTests
|
|||
foreach (var account in new[] { DeskWebApplicationFactory.Member, DeskWebApplicationFactory.Alice })
|
||||
{
|
||||
var reply = await factory.SendAsync("GET", "/api/desk/gamecult-chat/forks", null, factory.Token(account, "bifrost-desk", "desk.read"));
|
||||
Assert.Equal(forks.Select(f => f.Card.ForkId), JsonNode.Parse(reply.Body)!["forks"]!.AsArray().Select(f => (string?)f!["card"]!["fork_id"]));
|
||||
Assert.Equal(forks.Select(f => f.Card.GetProperty("fork_id").GetString()), JsonNode.Parse(reply.Body)!["forks"]!.AsArray().Select(f => (string?)f!["card"]!["fork_id"]));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
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);
|
||||
|
||||
|
|
|
|||
|
|
@ -36,14 +36,14 @@ public sealed class DeskSurfaceTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void A_configured_desk_without_its_door_stops_startup()
|
||||
public void A_configured_desk_with_no_door_supplied_gets_the_production_unix_socket_door()
|
||||
{
|
||||
using var factory = new DeskWebApplicationFactory(registerPorts: false);
|
||||
|
||||
var failure = Assert.ThrowsAny<Exception>(() => factory.CreateClient());
|
||||
factory.CreateClient();
|
||||
|
||||
// The credential is production wiring (OpenIddict validation); the door is the port a test or Huginn supplies.
|
||||
Assert.Contains("IDeskDoor", failure.ToString());
|
||||
// The credential is production wiring (OpenIddict validation) and so is the door; a test supplies a fake only by replacing it.
|
||||
Assert.IsType<UnixSocketDeskDoor>(factory.Services.GetRequiredService<IDeskDoor>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
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
|
||||
50
tests/Bifrost.Web.Tests/Fixtures/desk-feed/README.md
Normal file
50
tests/Bifrost.Web.Tests/Fixtures/desk-feed/README.md
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
# 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-non-decider.request` | Bifrost to Huginn | `Forks` as above, from a subject who is not on the call's deciders |
|
||||
| `read-non-decider.request` | Bifrost to Huginn | `Fork`, from a subject who is not on the call's deciders |
|
||||
| `list-open.response` | Huginn to Bifrost | a decider's page of one open fork: card with `raised_at` a string, `decider_count` 2 and both ids, record with `answerer_count` 3 and every `answerer` named |
|
||||
| `list-open-non-decider.response` | Huginn to Bifrost | the same page for a non-decider: `deciders` empty, `decider_count` 2, every `answerer` nil, `answerer_count` 3; no account id anywhere in its bytes |
|
||||
| `read.response` | Huginn to Bifrost | a decider's read of one fork, `raised_at` nil |
|
||||
| `read-non-decider.response` | Huginn to Bifrost | the same read for a non-decider, `raised_at` nil, no account 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` in a decider's view: all four answer variants (Counted, Revision, Refused, Retraction) by three accounts, answerer ids and notes whole, `answerer_count` 3 (6 keys) |
|
||||
| `record-withheld` | fragment | the same record in a non-decider's view: every `answerer` nil, notes whole, `answerer_count` 3 |
|
||||
|
||||
The reader-count shapes (`raised_at`, `decider_count`, `answerer_count`, nullable `answerer`) are
|
||||
also CultLib's own bytes, vendored unedited in `cultlib-d3025d3c/` (see its README for the pinned
|
||||
commit and each vector). Huginn's types decode every one of them and re-encode it byte for byte;
|
||||
the fixtures above are what Huginn's encoders write for the same shapes.
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
# CultLib desk-feed vectors, vendored
|
||||
|
||||
Source: `GameCult/CultLib` at commit `d3025d3c19674803ad32eb2335461b8cbc22bd28` (#118, on
|
||||
`3a51bbab` #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: they are the reference Huginn's types are checked against (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 |
|
||||
| `answer-retraction` | `deskAnswer.valid` `retraction` | a `Retraction`, `answerer` a string |
|
||||
| `answer-withheld-answerer` | `deskAnswer.valid` `withheld_answerer` | `answerer` nil |
|
||||
| `answer-withheld-answerer-revision` | `deskAnswer.valid` `withheld_answerer_revision` | a `Revision`, `answerer` nil |
|
||||
| `answer-withheld-answerer-retraction` | `deskAnswer.valid` `withheld_answerer_retraction` | a `Retraction`, `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`), then decodes it into Huginn's real type and re-encodes it, byte for byte.
|
||||
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.
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 @@
|
|||
000001a287a96d6573736167654964a36d2d35a96f7065726174696f6ea5666f726b73a77061796c6f6164d9f06761564762334a726334616b62576c755a4b5a6c64584a6c6132476e63335669616d566a644e6b6b4f5755345a44646a4e6d49744e5745304f5330304d7a67794c574978595441745a6a6c6c4f475133597a5a694e5745307147526c59326c6b5a584a7a6b746b6b4e575977597a4a694d5755744f4745305a433030597a4e694c546c6c4e3259744d574579596a4e6a4e4751315a545a6d32535177596a6c6b4e32557a59533032597a49784c54526d4e5467745954426b4e4330355a54686d4e324532596a566a4e47536d633352686448567a704739775a57366d5933567963323979774b5673615731706444493daf7061796c6f6164456e636f64696e67b26d6573736167657061636b2d626173653634ad7061796c6f6164536368656d61b6687567696e6e2e6465736b5f726571756573742e7631ad736368656d6156657273696f6ebc63756c746e65742e6f7065726174696f6e5f726571756573742e7630a9736572766963654964ab687567696e6e2e6465736b
|
||||
|
|
@ -0,0 +1 @@
|
|||
81a5466f726b7386a46d696e64a6657572656b61a77375626a656374d92439653864376336622d356134392d343338322d623161302d663965386437633662356134a8646563696465727392d92435663063326231652d386134642d346333622d396537662d316132623363346435653666d92430623964376533612d366332312d346635382d613064342d396538663761366235633464a6737461747573a46f70656ea6637572736f72c0a56c696d697432
|
||||
|
|
@ -0,0 +1 @@
|
|||
<EFBFBD>¥Forks†¤mind¦eureka§subjectÙ$9e8d7c6b-5a49-4382-b1a0-f9e8d7c6b5a4¨deciders’Ù$5f0c2b1e-8a4d-4c3b-9e7f-1a2b3c4d5e6fÙ$0b9d7e3a-6c21-4f58-a0d4-9e8f7a6b5c4d¦status¤open¦cursorÀ¥limit2
|
||||
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.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
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.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
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 @@
|
|||
000001ca87a96d6573736167654964a36d2d36a96f7065726174696f6ea4666f726ba77061796c6f6164da01186761524762334a72684b52746157356b706d5631636d56725961647a64574a715a574e30325351355a54686b4e324d3259693031595451354c54517a4f444974596a46684d43316d4f5755345a44646a4e6d49315954536f5a47566a6157526c636e4f53325351315a6a426a4d6d49785a5330345954526b4c54526a4d3249744f5755335a69307859544a694d324d305a44566c4e6d625a4a4442694f5751335a544e684c545a6a4d6a45744e4759314f4331684d4751304c546c6c4f47593359545a694e574d305a4b646d62334a7258326c6b3254426c64584a6c6132457463335669633352795958526c4f6e46315a584e30615739754f6d526c6332737462476c7a64476c755a7931686458526f62334a7064486b3daf7061796c6f6164456e636f64696e67b26d6573736167657061636b2d626173653634ad7061796c6f6164536368656d61b6687567696e6e2e6465736b5f726571756573742e7631ad736368656d6156657273696f6ebc63756c746e65742e6f7065726174696f6e5f726571756573742e7630a9736572766963654964ab687567696e6e2e6465736b
|
||||
|
|
@ -0,0 +1 @@
|
|||
81a4466f726b84a46d696e64a6657572656b61a77375626a656374d92439653864376336622d356134392d343338322d623161302d663965386437633662356134a8646563696465727392d92435663063326231652d386134642d346333622d396537662d316132623363346435653666d92430623964376533612d366332312d346635382d613064342d396538663761366235633464a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479
|
||||
|
|
@ -0,0 +1 @@
|
|||
<EFBFBD>¤Fork„¤mind¦eureka§subjectÙ$9e8d7c6b-5a49-4382-b1a0-f9e8d7c6b5a4¨deciders’Ù$5f0c2b1e-8a4d-4c3b-9e7f-1a2b3c4d5e6fÙ$0b9d7e3a-6c21-4f58-a0d4-9e8f7a6b5c4d§fork_idÙ0eureka-substrate:question:desk-listing-authority
|
||||
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
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
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue