Desk: UnixSocketDeskDoor, the production IDeskDoor over Huginn's desk socket
One connection per call, one request frame, one reply frame, 1 s deadline, 65536-byte bound each way. Request frames reproduce Huginn's golden bytes; replies are decoded by key and every failure is one DeskDoorException (503). Adds the MessagePack package for the wire codec. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
bf7dcfe235
commit
6dca0fa7d4
9 changed files with 1112 additions and 4 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>
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
462
src/Bifrost.Web/Features/Desk/UnixSocketDeskDoor.cs
Normal file
462
src/Bifrost.Web/Features/Desk/UnixSocketDeskDoor.cs
Normal file
|
|
@ -0,0 +1,462 @@
|
|||
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 9dde93e). 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. The card inside an entry is not read: it stays an opaque JsonElement.
|
||||
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":
|
||||
var answer = Document<DeskAnswerDocument>(value, "answer");
|
||||
if (answer is null || answer.SchemaVersion != DeskSchemas.DeskAnswer)
|
||||
{
|
||||
throw new DeskDoorException("the answered document is not gamecult.desk_answer.v0");
|
||||
}
|
||||
|
||||
// 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":
|
||||
var entry = Document<ForkEntry>(value, "entry");
|
||||
if (entry is null || entry.SchemaVersion != "gamecult.fork.entry.v0" || !IsWholeEntry(entry))
|
||||
{
|
||||
throw new DeskDoorException("the entry is not gamecult.fork.entry.v0");
|
||||
}
|
||||
|
||||
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":
|
||||
var list = Document<ForkList>(value, "list");
|
||||
if (list is null || list.SchemaVersion != "gamecult.fork.list.v0" || list.Mind is null || list.Stamp is null
|
||||
|| list.Forks is null || !list.Forks.All(IsWholeEntry))
|
||||
{
|
||||
throw new DeskDoorException("the list is not gamecult.fork.list.v0");
|
||||
}
|
||||
|
||||
return new DeskListOutcome.Listed(list);
|
||||
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();
|
||||
|
||||
private static bool IsWholeEntry(ForkEntry entry) =>
|
||||
entry.Card.ValueKind == JsonValueKind.Object && entry.Record is { Answers: not null };
|
||||
|
||||
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)
|
||||
{
|
||||
if (RequiredString(refusal, "schemaVersion") != DeskSchemas.Refusal)
|
||||
{
|
||||
throw new DeskDoorException("the refusal is not gamecult.fork.refusal.v0");
|
||||
}
|
||||
|
||||
var code = RequiredString(refusal, "code");
|
||||
_ = RequiredString(refusal, "error");
|
||||
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");
|
||||
}
|
||||
|
||||
var payload = new byte[(RequiredString(root, "payload").Length * 3 / 4) + 3];
|
||||
if (!Convert.TryFromBase64String(RequiredString(root, "payload"), payload, out var written))
|
||||
{
|
||||
throw new DeskDoorException("the payload is not base64");
|
||||
}
|
||||
|
||||
using var body = ToJson(payload.AsMemory(0, written));
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
private static JsonDocument ToJson(ReadOnlyMemory<byte> messagePack)
|
||||
{
|
||||
var reader = new MessagePackReader(messagePack);
|
||||
var json = new StringWriter(CultureInfo.InvariantCulture);
|
||||
MessagePackSerializer.ConvertToJson(ref reader, json);
|
||||
if (!reader.End)
|
||||
{
|
||||
throw new DeskDoorException("bytes follow the message");
|
||||
}
|
||||
|
||||
return JsonDocument.Parse(json.ToString(), new JsonDocumentOptions { MaxDepth = 64 });
|
||||
}
|
||||
|
||||
private static T? Document<T>(JsonElement map, string name)
|
||||
{
|
||||
try
|
||||
{
|
||||
return (Property(map, name) ?? throw new DeskDoorException($"{name} is missing")).Deserialize<T>(DeskJson.Options);
|
||||
}
|
||||
catch (JsonException e)
|
||||
{
|
||||
throw new DeskDoorException($"{name} does not decode", e);
|
||||
}
|
||||
}
|
||||
|
||||
// A key's value by name; a key written twice is a fault, not a choice.
|
||||
private static JsonElement? Property(JsonElement map, string name)
|
||||
{
|
||||
JsonElement? found = null;
|
||||
foreach (var property in map.EnumerateObject())
|
||||
{
|
||||
if (property.Name == name)
|
||||
{
|
||||
found = found is null ? property.Value : throw new DeskDoorException($"{name} is written twice");
|
||||
}
|
||||
}
|
||||
|
||||
return found;
|
||||
}
|
||||
|
||||
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",
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ public sealed class DeskWebApplicationFactory : TestWebApplicationFactory
|
|||
private readonly Dictionary<string, string?>? configuration;
|
||||
private readonly bool registerPorts;
|
||||
private readonly bool realCredential;
|
||||
private readonly bool fakeDoor;
|
||||
|
||||
// A configured desk needs Bifrost:Oidc (its credential validates against the local issuer): generated keys per factory.
|
||||
private readonly OidcTestKit oidcKit = new();
|
||||
|
|
@ -36,9 +37,12 @@ public sealed class DeskWebApplicationFactory : TestWebApplicationFactory
|
|||
// The chat client's secret, for a test that holds the chat client's own token.
|
||||
public string OidcSecret => oidcKit.ClientSecret;
|
||||
|
||||
public DeskWebApplicationFactory(Dictionary<string, string?>? configuration = null, bool configured = true, bool registerPorts = true, bool realCredential = false)
|
||||
// fakeDoor false leaves the door to the app's own wiring (UnixSocketDeskDoor), for tests that stand a socket up.
|
||||
public DeskWebApplicationFactory(
|
||||
Dictionary<string, string?>? configuration = null, bool configured = true, bool registerPorts = true, bool realCredential = false, bool fakeDoor = true)
|
||||
{
|
||||
this.realCredential = realCredential;
|
||||
this.fakeDoor = fakeDoor;
|
||||
this.configuration = configured ? configuration ?? DefaultConfiguration() : null;
|
||||
this.registerPorts = registerPorts;
|
||||
}
|
||||
|
|
@ -141,7 +145,10 @@ public sealed class DeskWebApplicationFactory : TestWebApplicationFactory
|
|||
builder.ConfigureServices(services =>
|
||||
{
|
||||
services.AddSingleton<IDeskCredential>(Credential);
|
||||
services.AddSingleton<IDeskDoor>(Door);
|
||||
if (fakeDoor)
|
||||
{
|
||||
services.AddSingleton<IDeskDoor>(Door);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
183
tests/Bifrost.Web.Tests/Support/FakeHuginnSocket.cs
Normal file
183
tests/Bifrost.Web.Tests/Support/FakeHuginnSocket.cs
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
using System.Buffers;
|
||||
using System.Buffers.Binary;
|
||||
using System.Net.Sockets;
|
||||
using System.Text.Json;
|
||||
using MessagePack;
|
||||
|
||||
namespace Bifrost.Web.Tests.Support;
|
||||
|
||||
// Huginn's desk door as a test double on a real unix socket: it accepts connections, reads each request frame,
|
||||
// records it, and lets the test decide what happens next (reply, dribble, stall, close).
|
||||
public sealed class FakeHuginnSocket : IDisposable
|
||||
{
|
||||
private readonly Socket listener = new(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified);
|
||||
private readonly CancellationTokenSource stop = new();
|
||||
private readonly string directory = Directory.CreateTempSubdirectory("hd").FullName;
|
||||
private readonly Func<Socket, byte[], CancellationToken, Task> handler;
|
||||
|
||||
public FakeHuginnSocket(Func<Socket, byte[], CancellationToken, Task> handler)
|
||||
{
|
||||
this.handler = handler;
|
||||
Path = System.IO.Path.Combine(directory, "d.sock");
|
||||
listener.Bind(new UnixDomainSocketEndPoint(Path));
|
||||
listener.Listen(16);
|
||||
_ = Task.Run(AcceptLoopAsync);
|
||||
}
|
||||
|
||||
public string Path { get; }
|
||||
|
||||
// The whole request frame of every connection, header included.
|
||||
public List<byte[]> Requests { get; } = [];
|
||||
|
||||
// A path no one listens on.
|
||||
public static string Absent() => System.IO.Path.Combine(Directory.CreateTempSubdirectory("hd").FullName, "absent.sock");
|
||||
|
||||
public static byte[] Fixture(string name) =>
|
||||
File.ReadAllBytes(System.IO.Path.Combine(DeskFeedFixturePinTests.Directory, name + ".frame.bin"));
|
||||
|
||||
// Writes the bytes and closes.
|
||||
public static Func<Socket, byte[], CancellationToken, Task> Replay(byte[] frame) =>
|
||||
async (socket, _, token) => await socket.SendAsync(frame, SocketFlags.None, token);
|
||||
|
||||
// Answers each request by its own message id and operation: the reply is built from the request.
|
||||
public static Func<Socket, byte[], CancellationToken, Task> Answer(Func<string, JsonDocument, string> variantJson, string status = "accepted") =>
|
||||
async (socket, request, token) =>
|
||||
{
|
||||
using var envelope = DeskWire.Envelope(request);
|
||||
var root = envelope.RootElement;
|
||||
var operation = root.GetProperty("operation").GetString()!;
|
||||
var reply = DeskWire.ResponseFrame(root.GetProperty("messageId").GetString()!, operation, status, variantJson(operation, envelope));
|
||||
await socket.SendAsync(reply, SocketFlags.None, token);
|
||||
};
|
||||
|
||||
private async Task AcceptLoopAsync()
|
||||
{
|
||||
while (!stop.IsCancellationRequested)
|
||||
{
|
||||
Socket client;
|
||||
try
|
||||
{
|
||||
client = await listener.AcceptAsync(stop.Token);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
using (client)
|
||||
{
|
||||
try
|
||||
{
|
||||
var header = new byte[4];
|
||||
await client.ReceiveAllAsync(header, stop.Token);
|
||||
var body = new byte[BinaryPrimitives.ReadUInt32BigEndian(header)];
|
||||
await client.ReceiveAllAsync(body, stop.Token);
|
||||
var request = header.Concat(body).ToArray();
|
||||
lock (Requests)
|
||||
{
|
||||
Requests.Add(request);
|
||||
}
|
||||
|
||||
await handler(client, request, stop.Token);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// The client went away, or the test ended.
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
stop.Cancel();
|
||||
listener.Dispose();
|
||||
try
|
||||
{
|
||||
Directory.Delete(directory, true);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal static class SocketReceive
|
||||
{
|
||||
public static async Task ReceiveAllAsync(this Socket socket, byte[] buffer, CancellationToken token)
|
||||
{
|
||||
for (var read = 0; read < buffer.Length;)
|
||||
{
|
||||
var count = await socket.ReceiveAsync(buffer.AsMemory(read), SocketFlags.None, token);
|
||||
if (count == 0)
|
||||
{
|
||||
throw new EndOfStreamException();
|
||||
}
|
||||
|
||||
read += count;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Frames and envelopes as the golden fixtures spell them, for building replies and reading requests.
|
||||
public static class DeskWire
|
||||
{
|
||||
public static JsonDocument Envelope(byte[] frame)
|
||||
{
|
||||
var reader = new MessagePackReader(frame.AsMemory(4));
|
||||
var json = new StringWriter();
|
||||
MessagePackSerializer.ConvertToJson(ref reader, json);
|
||||
return JsonDocument.Parse(json.ToString());
|
||||
}
|
||||
|
||||
// The base64 payload of an envelope, decoded to JSON.
|
||||
public static JsonDocument Payload(JsonDocument envelope)
|
||||
{
|
||||
var bytes = Convert.FromBase64String(envelope.RootElement.GetProperty("payload").GetString()!);
|
||||
var reader = new MessagePackReader(bytes);
|
||||
var json = new StringWriter();
|
||||
MessagePackSerializer.ConvertToJson(ref reader, json);
|
||||
return JsonDocument.Parse(json.ToString());
|
||||
}
|
||||
|
||||
// A reply frame in operation-message key order. `mutate` edits the text fields before they are written.
|
||||
public static byte[] ResponseFrame(
|
||||
string messageId, string operation, string status, string variantJson, Action<SortedDictionary<string, string>>? mutate = null)
|
||||
{
|
||||
var fields = new SortedDictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["messageId"] = messageId,
|
||||
["operation"] = operation,
|
||||
["payload"] = Convert.ToBase64String(MessagePackSerializer.ConvertFromJson(variantJson)),
|
||||
["payloadEncoding"] = "messagepack-base64",
|
||||
["payloadSchema"] = "huginn.desk_response.v1",
|
||||
["schemaVersion"] = "cultnet.operation_response.v0",
|
||||
["serviceId"] = "huginn.desk",
|
||||
["sourceRuntimeId"] = "huginn-test",
|
||||
["status"] = status,
|
||||
};
|
||||
mutate?.Invoke(fields);
|
||||
var buffer = new ArrayBufferWriter<byte>();
|
||||
var w = new MessagePackWriter(buffer);
|
||||
w.WriteMapHeader(fields.Count);
|
||||
foreach (var (key, value) in fields)
|
||||
{
|
||||
w.Write(key);
|
||||
w.Write(value);
|
||||
}
|
||||
|
||||
w.Flush();
|
||||
return Frame(buffer.WrittenSpan);
|
||||
}
|
||||
|
||||
public static byte[] Frame(ReadOnlySpan<byte> message)
|
||||
{
|
||||
var frame = new byte[4 + message.Length];
|
||||
BinaryPrimitives.WriteUInt32BigEndian(frame, (uint)message.Length);
|
||||
message.CopyTo(frame.AsSpan(4));
|
||||
return frame;
|
||||
}
|
||||
}
|
||||
401
tests/Bifrost.Web.Tests/UnixSocketDeskDoorTests.cs
Normal file
401
tests/Bifrost.Web.Tests/UnixSocketDeskDoorTests.cs
Normal file
|
|
@ -0,0 +1,401 @@
|
|||
using System.Diagnostics;
|
||||
using System.Net.Sockets;
|
||||
using System.Text.Json;
|
||||
using Bifrost.Web.Features.Desk;
|
||||
using Bifrost.Web.Tests.Support;
|
||||
|
||||
namespace Bifrost.Web.Tests;
|
||||
|
||||
// The production door against a real unix socket and Huginn's golden frames (fixtures at Huginn 9dde93e).
|
||||
public sealed class UnixSocketDeskDoorTests
|
||||
{
|
||||
private const string Fork = "eureka-substrate:question:desk-listing-authority";
|
||||
|
||||
private static readonly DeskDoorContext Context = new("eureka", "Member", "7", ["7", "12"]);
|
||||
|
||||
private static UnixSocketDeskDoor DoorWith(string messageId) => new(() => messageId);
|
||||
|
||||
private static Task<DeskListOutcome> List(UnixSocketDeskDoor door, string socket, string? status = "open", string? cursor = null, int limit = 50) =>
|
||||
door.ListAsync(socket, Context, new DeskListRequest("eureka", status, cursor, limit), CancellationToken.None);
|
||||
|
||||
private static Task<DeskReadOutcome> Read(UnixSocketDeskDoor door, string socket, string forkId = Fork) =>
|
||||
door.ReadAsync(socket, Context, forkId, CancellationToken.None);
|
||||
|
||||
private const string UnknownFork =
|
||||
"""{"NotStored":{"schemaVersion":"gamecult.fork.refusal.v0","code":"fork_unknown","error":"fork_unknown: no such fork"}}""";
|
||||
|
||||
// ---- (a) the request frames, byte for byte ----
|
||||
|
||||
[Theory]
|
||||
[InlineData("list-open.request", "m-1")]
|
||||
[InlineData("list-open-cursor.request", "m-2")]
|
||||
[InlineData("list-counts.request", "m-3")]
|
||||
[InlineData("read.request", "m-4")]
|
||||
public async Task Request_frames_are_the_golden_bytes(string fixture, string messageId)
|
||||
{
|
||||
using var huginn = new FakeHuginnSocket(FakeHuginnSocket.Answer((_, _) => UnknownFork, "rejected"));
|
||||
var door = DoorWith(messageId);
|
||||
|
||||
_ = fixture switch
|
||||
{
|
||||
"list-open.request" => await List(door, huginn.Path),
|
||||
"list-open-cursor.request" => await List(door, huginn.Path, cursor: "c42"),
|
||||
"list-counts.request" => await List(door, huginn.Path, limit: 0),
|
||||
_ => (object)await Read(door, huginn.Path),
|
||||
};
|
||||
|
||||
Assert.Equal(FakeHuginnSocket.Fixture(fixture), Assert.Single(huginn.Requests));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_answer_request_is_the_Answer_variant_with_the_clients_body_as_a_map_in_its_own_order()
|
||||
{
|
||||
using var huginn = new FakeHuginnSocket(FakeHuginnSocket.Answer((_, _) => UnknownFork, "rejected"));
|
||||
var body = DeskReplay.AnswerBody(DeskReplay.ChatFork);
|
||||
var context = new DeskDoorContext("gamecult-chat", "Steward", "9f8e", ["9f8e", "aa"]);
|
||||
|
||||
var outcome = await new UnixSocketDeskDoor().AnswerAsync(huginn.Path, context, body, CancellationToken.None);
|
||||
|
||||
Assert.Equal(new DeskAnswerOutcome.NotStored("fork_unknown"), outcome);
|
||||
using var envelope = DeskWire.Envelope(Assert.Single(huginn.Requests));
|
||||
Assert.Equal("answer", envelope.RootElement.GetProperty("operation").GetString());
|
||||
using var payload = DeskWire.Payload(envelope);
|
||||
var answer = payload.RootElement.GetProperty("Answer");
|
||||
Assert.Equal(["mind", "subject", "authority", "deciders", "request"], answer.EnumerateObject().Select(p => p.Name));
|
||||
Assert.Equal("Steward", answer.GetProperty("authority").GetString());
|
||||
// Every key, the nulls too, in the order the client wrote them.
|
||||
using var sent = JsonDocument.Parse(body);
|
||||
Assert.Equal(sent.RootElement.EnumerateObject().Select(p => p.Name), answer.GetProperty("request").EnumerateObject().Select(p => p.Name));
|
||||
foreach (var property in sent.RootElement.EnumerateObject())
|
||||
{
|
||||
var back = answer.GetProperty("request").GetProperty(property.Name);
|
||||
Assert.True(
|
||||
System.Text.Json.Nodes.JsonNode.DeepEquals(
|
||||
System.Text.Json.Nodes.JsonNode.Parse(property.Value.GetRawText()),
|
||||
System.Text.Json.Nodes.JsonNode.Parse(back.GetRawText())),
|
||||
property.Name);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- (b) each golden reply ----
|
||||
|
||||
private static (string Kind, string MessageId) Shape(string fixture)
|
||||
{
|
||||
using var envelope = DeskWire.Envelope(FakeHuginnSocket.Fixture(fixture));
|
||||
var root = envelope.RootElement;
|
||||
return root.TryGetProperty("operation", out var operation)
|
||||
? (operation.GetString() == "fork" ? "read" : "list", root.GetProperty("messageId").GetString()!)
|
||||
: ("list", "m-1"); // a CultNet Error message has neither
|
||||
}
|
||||
|
||||
private static async Task<object> Replayed(string fixture, string? readForkId = null)
|
||||
{
|
||||
var (kind, messageId) = Shape(fixture);
|
||||
using var huginn = new FakeHuginnSocket(FakeHuginnSocket.Replay(FakeHuginnSocket.Fixture(fixture)));
|
||||
var door = DoorWith(messageId);
|
||||
return kind == "read"
|
||||
? await Read(door, huginn.Path, readForkId ?? Fork)
|
||||
: await List(door, huginn.Path);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_empty_list_is_an_empty_list()
|
||||
{
|
||||
var listed = Assert.IsType<DeskListOutcome.Listed>(await Replayed("list-empty.response"));
|
||||
Assert.Equal((0, 0, 0, "s10190", "eureka"), (listed.List.OpenCount, listed.List.RecordedCount, listed.List.Forks.Length, listed.List.Stamp, listed.List.Mind));
|
||||
Assert.Null(listed.List.NextCursor);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_counts_reply_carries_the_counts()
|
||||
{
|
||||
var listed = Assert.IsType<DeskListOutcome.Listed>(await Replayed("list-counts.response"));
|
||||
Assert.Equal((3, 5, 0), (listed.List.OpenCount, listed.List.RecordedCount, listed.List.Forks.Length));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_unknown_fork_is_not_found()
|
||||
{
|
||||
Assert.Equal(new DeskReadOutcome.NotFound("fork_unknown"), await Replayed("read-unknown.response"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_list_too_large_names_its_fork_and_order_from_the_refusal_text()
|
||||
{
|
||||
Assert.Equal(new DeskListOutcome.ForkTooLarge(Fork, 41), await Replayed("list-too-large.response"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_read_too_large_names_the_routes_fork_not_the_refusal_text()
|
||||
{
|
||||
// The refusal text names the fixture's fork; the route asked for another, and the route's id is what returns.
|
||||
Assert.Equal(
|
||||
new DeskReadOutcome.ForkTooLarge("route:question:asked"),
|
||||
await Replayed("read-too-large.response", "route:question:asked"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("list-response-too-large.response")]
|
||||
[InlineData("refused-unit.response")]
|
||||
[InlineData("unavailable.response")]
|
||||
[InlineData("failure.response")]
|
||||
[InlineData("error.response")]
|
||||
public async Task A_refusal_a_failure_or_an_error_is_a_door_failure(string fixture)
|
||||
{
|
||||
var failure = await Assert.ThrowsAsync<DeskDoorException>(() => Replayed(fixture));
|
||||
Assert.DoesNotContain("deadline", failure.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_list_refusal_text_that_does_not_parse_exactly_is_a_failure_never_a_guess()
|
||||
{
|
||||
foreach (var text in new[]
|
||||
{
|
||||
"fork_too_large: fork a:question:b is past the page budget by itself",
|
||||
"fork_too_large: fork a:question:b at order 4x is past the page budget by itself",
|
||||
"fork_too_large: fork at order 4 is past the page budget by itself",
|
||||
"fork_too_large: fork a:question:b at order 04 is past the page budget by itself",
|
||||
"oops fork_too_large: fork a:question:b at order 4 is past the page budget by itself",
|
||||
})
|
||||
{
|
||||
using var huginn = new FakeHuginnSocket(FakeHuginnSocket.Answer((_, _) =>
|
||||
$$$"""{"NotStored":{"schemaVersion":"gamecult.fork.refusal.v0","code":"fork_too_large","error":"{{{text}}}"}}""", "rejected"));
|
||||
await Assert.ThrowsAsync<DeskDoorException>(() => List(new UnixSocketDeskDoor(), huginn.Path));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_one_key_refusal_an_unknown_variant_and_an_unknown_code_are_failures()
|
||||
{
|
||||
foreach (var (variant, status) in new[]
|
||||
{
|
||||
("""{"Refused":{"Unavailable":{"detail":"x"}}}""", "rejected"),
|
||||
("""{"Refused":"MissingIdentity"}""", "rejected"),
|
||||
("""{"Mystery":{"x":1}}""", "accepted"),
|
||||
("""{"NotStored":{"schemaVersion":"gamecult.fork.refusal.v0","code":"nope","error":"x"}}""", "rejected"),
|
||||
("""{"NotStored":{"schemaVersion":"gamecult.fork.refusal.v9","code":"fork_unknown","error":"x"}}""", "rejected"),
|
||||
("""{"Fork":{"entry":{},"as_of":1}}""", "accepted"),
|
||||
("""{"Forks":{"list":{"schemaVersion":"gamecult.fork.list.v0","mind":"eureka","stamp":"s1","open_count":0,"recorded_count":0,"forks":[],"next_cursor":null},"as_of":1}}""", "rejected"),
|
||||
})
|
||||
{
|
||||
using var huginn = new FakeHuginnSocket(FakeHuginnSocket.Answer((_, _) => variant, status));
|
||||
await Assert.ThrowsAsync<DeskDoorException>(() => List(new UnixSocketDeskDoor(), huginn.Path));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_reply_for_another_message_or_operation_is_a_failure()
|
||||
{
|
||||
using var huginn = new FakeHuginnSocket(FakeHuginnSocket.Replay(FakeHuginnSocket.Fixture("list-empty.response")));
|
||||
await Assert.ThrowsAsync<DeskDoorException>(() => List(DoorWith("m-9"), huginn.Path));
|
||||
|
||||
using var other = new FakeHuginnSocket(FakeHuginnSocket.Replay(FakeHuginnSocket.Fixture("list-empty.response")));
|
||||
await Assert.ThrowsAsync<DeskDoorException>(() => Read(DoorWith("m-1"), other.Path));
|
||||
}
|
||||
|
||||
// ---- (c) entries pass through, the card untouched ----
|
||||
|
||||
private const string CardWithRaisedAt =
|
||||
"""{"schemaVersion":"gamecult.fork.card.v0","fork_id":"x:question:a","title":"It's é","raised_at":"2026-11-11T09:00:00Z","options":[{"id":"a","text":"A"}],"order":7,"options_hash":"ab"}""";
|
||||
|
||||
// Reordered, no raised_at, a key no contract names yet.
|
||||
private const string CardReordered =
|
||||
"""{"order":8,"future_key":{"z":[3,null,100],"a":true},"options_hash":"cd","schemaVersion":"gamecult.fork.card.v0","fork_id":"x:question:b","title":"B","leans":null}""";
|
||||
|
||||
private static string EntryJson(string card, string forkId) =>
|
||||
$$$"""{"schemaVersion":"gamecult.fork.entry.v0","card":{{{card}}},"record":{"schemaVersion":"gamecult.fork.record.v0","fork_id":"{{{forkId}}}","options_hash":"ab","counted_answer_id":null,"answers":[]}}""";
|
||||
|
||||
[Fact]
|
||||
public async Task Cards_in_a_list_and_a_read_come_through_byte_faithful()
|
||||
{
|
||||
var list = $$$"""{"Forks":{"list":{"schemaVersion":"gamecult.fork.list.v0","mind":"eureka","stamp":"s7","open_count":2,"recorded_count":0,"forks":[{{{EntryJson(CardWithRaisedAt, "x:question:a")}}},{{{EntryJson(CardReordered, "x:question:b")}}}],"next_cursor":"c8"},"as_of":7}}""";
|
||||
using var huginn = new FakeHuginnSocket(FakeHuginnSocket.Answer((operation, _) =>
|
||||
operation == "forks" ? list : $$$"""{"Fork":{"entry":{{{EntryJson(CardReordered, "x:question:b")}}},"as_of":7}}"""));
|
||||
var door = new UnixSocketDeskDoor();
|
||||
|
||||
var listed = Assert.IsType<DeskListOutcome.Listed>(await List(door, huginn.Path));
|
||||
Assert.Equal(["x:question:a", "x:question:b"], listed.List.Forks.Select(f => f.Record.ForkId));
|
||||
Assert.Equal(CardWithRaisedAt, JsonSerializer.Serialize(listed.List.Forks[0].Card, DeskJson.Options));
|
||||
Assert.Equal(CardReordered, JsonSerializer.Serialize(listed.List.Forks[1].Card, DeskJson.Options));
|
||||
Assert.Equal("c8", listed.List.NextCursor);
|
||||
|
||||
var found = Assert.IsType<DeskReadOutcome.Found>(await Read(door, huginn.Path, "x:question:b"));
|
||||
Assert.Equal(CardReordered, JsonSerializer.Serialize(found.Entry.Card, DeskJson.Options));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_answered_reply_becomes_the_answer_result_without_a_card()
|
||||
{
|
||||
var answered = """{"Answered":{"answer":{"schemaVersion":"gamecult.desk_answer.v0","answer_id":"n1","fork_id":"x:question:a","outcome":"Counted","of":null,"retracts":null,"code":null,"options_hash":"ab","choice":"a","note":null,"answered_at":"2026-11-11T09:00:00Z","received_at":"2026-11-11T09:00:01Z","claim_in_future":false,"answerer":"7","authority":"Member","request_id":"r"},"counted_answer_id":"n1","as_of":9}}""";
|
||||
using var huginn = new FakeHuginnSocket(FakeHuginnSocket.Answer((_, _) => answered));
|
||||
|
||||
var outcome = await new UnixSocketDeskDoor().AnswerAsync(huginn.Path, Context, DeskReplay.AnswerBody("x:question:a"), CancellationToken.None);
|
||||
|
||||
var result = Assert.IsType<DeskAnswerOutcome.Answered>(outcome).Result;
|
||||
Assert.Equal((DeskSchemas.AnswerResult, "n1", "Counted", "n1", null), (result.SchemaVersion, result.Answer.AnswerId, result.Answer.Outcome, result.CountedAnswerId, result.Card));
|
||||
}
|
||||
|
||||
// ---- (d) one dead Mind fails only itself, through the HTTP edge ----
|
||||
|
||||
private static Dictionary<string, string?> ConfigurationWith(string chatSocket, string nibuSocket)
|
||||
{
|
||||
var configuration = DeskWebApplicationFactory.DefaultConfiguration();
|
||||
foreach (var key in configuration.Keys.Where(k => k.EndsWith(":Id", StringComparison.Ordinal)).ToList())
|
||||
{
|
||||
var prefix = key[..^2];
|
||||
configuration[prefix + "Socket"] = configuration[key] == "nibu" ? nibuSocket : chatSocket;
|
||||
}
|
||||
|
||||
return configuration;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_dead_Minds_socket_is_503_for_that_Mind_only_and_the_live_one_answers()
|
||||
{
|
||||
var list = $$$"""{"Forks":{"list":{"schemaVersion":"gamecult.fork.list.v0","mind":"gamecult-chat","stamp":"s7","open_count":1,"recorded_count":0,"forks":[{{{EntryJson(CardWithRaisedAt, "x:question:a")}}}],"next_cursor":null},"as_of":7}}""";
|
||||
using var huginn = new FakeHuginnSocket(FakeHuginnSocket.Answer((_, _) => list));
|
||||
using var factory = new DeskWebApplicationFactory(ConfigurationWith(huginn.Path, FakeHuginnSocket.Absent()), fakeDoor: false);
|
||||
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true);
|
||||
var token = factory.Token(DeskWebApplicationFactory.Alice, "bifrost-desk", "desk.read");
|
||||
|
||||
var live = await factory.SendAsync("GET", "/api/desk/gamecult-chat/forks", token: token);
|
||||
var dead = await factory.SendAsync("GET", "/api/desk/nibu/forks", token: token);
|
||||
var liveAgain = await factory.SendAsync("GET", "/api/desk/gamecult-chat/forks", token: token);
|
||||
|
||||
Assert.Equal(200, live.Status);
|
||||
Assert.Contains("\"card\":" + CardWithRaisedAt + ",", live.Text);
|
||||
Assert.Equal(503, dead.Status);
|
||||
Assert.Equal("30", dead.Headers.RetryAfter.ToString());
|
||||
Assert.Contains("\"code\":\"unavailable\"", dead.Text);
|
||||
Assert.Equal(200, liveAgain.Status);
|
||||
Assert.Equal(2, huginn.Requests.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_silent_door_is_503_with_retry_after_not_a_hang()
|
||||
{
|
||||
using var huginn = new FakeHuginnSocket(async (_, _, token) => await Task.Delay(Timeout.Infinite, token));
|
||||
using var factory = new DeskWebApplicationFactory(ConfigurationWith(FakeHuginnSocket.Absent(), huginn.Path), fakeDoor: false);
|
||||
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true);
|
||||
|
||||
var clock = Stopwatch.StartNew();
|
||||
var reply = await factory.SendAsync("GET", "/api/desk/nibu/forks", token: factory.Token(DeskWebApplicationFactory.Alice, "bifrost-desk", "desk.read"));
|
||||
|
||||
Assert.Equal(503, reply.Status);
|
||||
Assert.True(reply.Headers.ContainsKey("Retry-After"));
|
||||
Assert.True(clock.Elapsed < TimeSpan.FromSeconds(3), $"took {clock.Elapsed}");
|
||||
}
|
||||
|
||||
// ---- (e) deadline, bounds, partial frames ----
|
||||
|
||||
[Fact]
|
||||
public async Task A_server_that_accepts_and_never_replies_fails_at_the_deadline()
|
||||
{
|
||||
using var huginn = new FakeHuginnSocket(async (_, _, token) => await Task.Delay(Timeout.Infinite, token));
|
||||
|
||||
var clock = Stopwatch.StartNew();
|
||||
var failure = await Assert.ThrowsAsync<DeskDoorException>(() => List(new UnixSocketDeskDoor(), huginn.Path));
|
||||
|
||||
Assert.Contains("deadline", failure.Message);
|
||||
Assert.InRange(clock.Elapsed, TimeSpan.FromMilliseconds(900), TimeSpan.FromSeconds(2.5));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_caller_that_gives_up_is_cancelled_not_a_door_failure()
|
||||
{
|
||||
using var huginn = new FakeHuginnSocket(async (_, _, token) => await Task.Delay(Timeout.Infinite, token));
|
||||
using var cancel = new CancellationTokenSource(TimeSpan.FromMilliseconds(100));
|
||||
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
|
||||
new UnixSocketDeskDoor().ListAsync(huginn.Path, Context, new DeskListRequest("eureka", null, null, 50), cancel.Token));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_length_prefix_over_the_bound_is_refused_without_reading_the_body()
|
||||
{
|
||||
// The server sends only the prefix, then stalls: a client that waited for the body would run out the deadline.
|
||||
using var huginn = new FakeHuginnSocket(async (socket, _, token) =>
|
||||
{
|
||||
await socket.SendAsync(new byte[] { 0, 1, 0, 1 }, SocketFlags.None, token); // 65537
|
||||
await Task.Delay(Timeout.Infinite, token);
|
||||
});
|
||||
|
||||
var clock = Stopwatch.StartNew();
|
||||
var failure = await Assert.ThrowsAsync<DeskDoorException>(() => List(new UnixSocketDeskDoor(), huginn.Path));
|
||||
|
||||
Assert.Contains("frame bound", failure.Message);
|
||||
Assert.True(clock.Elapsed < TimeSpan.FromMilliseconds(700), $"took {clock.Elapsed}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_frame_exactly_at_the_bound_is_read_whole()
|
||||
{
|
||||
// A prefix of 65536 is within the bound; the body that follows is not a message, so it fails on content, not size.
|
||||
using var huginn = new FakeHuginnSocket(async (socket, _, token) =>
|
||||
{
|
||||
var frame = new byte[4 + 65536];
|
||||
frame[1] = 1;
|
||||
await socket.SendAsync(frame, SocketFlags.None, token);
|
||||
});
|
||||
|
||||
var failure = await Assert.ThrowsAsync<DeskDoorException>(() => List(new UnixSocketDeskDoor(), huginn.Path));
|
||||
Assert.DoesNotContain("frame bound", failure.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_request_over_the_bound_is_never_sent()
|
||||
{
|
||||
var deciders = Enumerable.Range(0, 64).Select(i => i.ToString("D4") + new string('d', 1100)).ToArray();
|
||||
var context = new DeskDoorContext("eureka", "Member", "7", deciders);
|
||||
using var huginn = new FakeHuginnSocket(FakeHuginnSocket.Answer((_, _) => UnknownFork, "rejected"));
|
||||
|
||||
var failure = await Assert.ThrowsAsync<DeskDoorException>(() =>
|
||||
new UnixSocketDeskDoor().ListAsync(huginn.Path, context, new DeskListRequest("eureka", null, null, 50), CancellationToken.None));
|
||||
|
||||
Assert.Contains("frame bound", failure.Message);
|
||||
Assert.Empty(huginn.Requests);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_truncated_frame_or_an_early_close_is_a_failure()
|
||||
{
|
||||
var golden = FakeHuginnSocket.Fixture("list-empty.response");
|
||||
foreach (var cut in new[] { 0, 2, 4, 10, golden.Length - 1 })
|
||||
{
|
||||
using var huginn = new FakeHuginnSocket(FakeHuginnSocket.Replay(golden[..cut]));
|
||||
var failure = await Assert.ThrowsAsync<DeskDoorException>(() => List(DoorWith("m-1"), huginn.Path));
|
||||
Assert.Contains("closed", failure.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_frame_that_arrives_in_pieces_is_read_whole()
|
||||
{
|
||||
var golden = FakeHuginnSocket.Fixture("list-empty.response");
|
||||
using var huginn = new FakeHuginnSocket(async (socket, _, token) =>
|
||||
{
|
||||
for (var at = 0; at < golden.Length; at += 7)
|
||||
{
|
||||
await socket.SendAsync(golden.AsMemory(at, Math.Min(7, golden.Length - at)), SocketFlags.None, token);
|
||||
await Task.Delay(2, token);
|
||||
}
|
||||
});
|
||||
|
||||
Assert.IsType<DeskListOutcome.Listed>(await List(DoorWith("m-1"), huginn.Path));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Absent_and_refusing_sockets_are_failures()
|
||||
{
|
||||
await Assert.ThrowsAsync<DeskDoorException>(() => List(new UnixSocketDeskDoor(), FakeHuginnSocket.Absent()));
|
||||
|
||||
// A socket file with nobody listening: ECONNREFUSED.
|
||||
var stale = FakeHuginnSocket.Absent();
|
||||
using (var socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified))
|
||||
{
|
||||
socket.Bind(new UnixDomainSocketEndPoint(stale));
|
||||
}
|
||||
|
||||
await Assert.ThrowsAsync<DeskDoorException>(() => List(new UnixSocketDeskDoor(), stale));
|
||||
}
|
||||
}
|
||||
|
|
@ -60,6 +60,26 @@
|
|||
"resolved": "2.4.0",
|
||||
"contentHash": "SwXsAV3sMvAU/Nn31pbjhWurYSjJ+/giI/0n6tCrYoupEK34iIHCuk3STAd9fx8yudM85KkLSVdn951vTng/vQ=="
|
||||
},
|
||||
"MessagePack": {
|
||||
"type": "Transitive",
|
||||
"resolved": "3.1.12",
|
||||
"contentHash": "m1QEsd59gUj82ji5tEQbZiyjV6y0F7cYfGVhil6pAlNZpyzR7ze9TucMu4lPpi8cZAV8aNl3D9/T9J1HbFz15Q==",
|
||||
"dependencies": {
|
||||
"MessagePack.Annotations": "3.1.12",
|
||||
"MessagePackAnalyzer": "3.1.12",
|
||||
"Microsoft.NET.StringTools": "17.11.4"
|
||||
}
|
||||
},
|
||||
"MessagePack.Annotations": {
|
||||
"type": "Transitive",
|
||||
"resolved": "3.1.12",
|
||||
"contentHash": "LdI67sY7F3KK+JsrG02+DP1XFkowaZSoyfL2M88hjm1MOOsjf8amBLAJolV+zxoPUZf3uGbKzq1cMQD8PlpSpA=="
|
||||
},
|
||||
"MessagePackAnalyzer": {
|
||||
"type": "Transitive",
|
||||
"resolved": "3.1.12",
|
||||
"contentHash": "iNSNatPh6bafRUbkQKlClExjpPpd8hiZgfZsodGf7bheIlkizGbMzsJ2mPui8jOHRe4Ky9eg5ou/fFzI7CT3fg=="
|
||||
},
|
||||
"Microsoft.AspNetCore.TestHost": {
|
||||
"type": "Transitive",
|
||||
"resolved": "8.0.0",
|
||||
|
|
@ -461,6 +481,11 @@
|
|||
"Microsoft.IdentityModel.Logging": "8.19.2"
|
||||
}
|
||||
},
|
||||
"Microsoft.NET.StringTools": {
|
||||
"type": "Transitive",
|
||||
"resolved": "17.11.4",
|
||||
"contentHash": "mudqUHhNpeqIdJoUx2YDWZO/I9uEDYVowan89R6wsomfnUJQk6HteoQTlNjZDixhT2B4IXMkMtgZtoceIjLRmA=="
|
||||
},
|
||||
"Microsoft.TestPlatform.ObjectModel": {
|
||||
"type": "Transitive",
|
||||
"resolved": "17.14.1",
|
||||
|
|
@ -671,6 +696,7 @@
|
|||
"type": "Project",
|
||||
"dependencies": {
|
||||
"BouncyCastle.Cryptography": "[2.4.0, )",
|
||||
"MessagePack": "[3.1.12, )",
|
||||
"Microsoft.Extensions.Caching.Memory": "[8.0.1, )",
|
||||
"Npgsql": "[8.0.3, )",
|
||||
"Npgsql.EntityFrameworkCore.PostgreSQL": "[8.0.0, )",
|
||||
|
|
|
|||
Loading…
Reference in a new issue