From 6dca0fa7d415e64b46979d8e02f98e546fa35762 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Wed, 11 Nov 2026 17:40:47 +0000 Subject: [PATCH] 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 --- src/Bifrost.Web/Bifrost.Web.csproj | 1 + .../Features/Desk/DeskEndpoints.cs | 1 + src/Bifrost.Web/Features/Desk/DeskPorts.cs | 5 +- .../Features/Desk/UnixSocketDeskDoor.cs | 462 ++++++++++++++++++ src/Bifrost.Web/packages.lock.json | 26 + .../Support/DeskWebApplicationFactory.cs | 11 +- .../Support/FakeHuginnSocket.cs | 183 +++++++ .../UnixSocketDeskDoorTests.cs | 401 +++++++++++++++ tests/Bifrost.Web.Tests/packages.lock.json | 26 + 9 files changed, 1112 insertions(+), 4 deletions(-) create mode 100644 src/Bifrost.Web/Features/Desk/UnixSocketDeskDoor.cs create mode 100644 tests/Bifrost.Web.Tests/Support/FakeHuginnSocket.cs create mode 100644 tests/Bifrost.Web.Tests/UnixSocketDeskDoorTests.cs diff --git a/src/Bifrost.Web/Bifrost.Web.csproj b/src/Bifrost.Web/Bifrost.Web.csproj index f69b885..177c194 100644 --- a/src/Bifrost.Web/Bifrost.Web.csproj +++ b/src/Bifrost.Web/Bifrost.Web.csproj @@ -7,6 +7,7 @@ + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/Bifrost.Web/Features/Desk/DeskEndpoints.cs b/src/Bifrost.Web/Features/Desk/DeskEndpoints.cs index 2108840..67185cc 100644 --- a/src/Bifrost.Web/Features/Desk/DeskEndpoints.cs +++ b/src/Bifrost.Web/Features/Desk/DeskEndpoints.cs @@ -46,6 +46,7 @@ public static class DeskEndpoints services.TryAddSingleton(); } + services.TryAddSingleton(_ => new UnixSocketDeskDoor()); services.AddSingleton(provider => new DeskMindRoutes(provider.GetRequiredService>().Value.Minds)); services.AddCors(); // Exactly the configured origin, no credentials, and only on the routes that require this policy. diff --git a/src/Bifrost.Web/Features/Desk/DeskPorts.cs b/src/Bifrost.Web/Features/Desk/DeskPorts.cs index 385f1bb..0769cb2 100644 --- a/src/Bifrost.Web/Features/Desk/DeskPorts.cs +++ b/src/Bifrost.Web/Features/Desk/DeskPorts.cs @@ -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. diff --git a/src/Bifrost.Web/Features/Desk/UnixSocketDeskDoor.cs b/src/Bifrost.Web/Features/Desk/UnixSocketDeskDoor.cs new file mode 100644 index 0000000..afa5878 --- /dev/null +++ b/src/Bifrost.Web/Features/Desk/UnixSocketDeskDoor.cs @@ -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 messageIds; + + public UnixSocketDeskDoor() + : this(() => Guid.NewGuid().ToString("N")) + { + } + + public UnixSocketDeskDoor(Func messageIds) => this.messageIds = messageIds; + + public async Task AnswerAsync( + string socket, DeskDoorContext context, ReadOnlyMemory 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(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 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(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 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(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 at order is past the page budget by itself; ... + [GeneratedRegex(@"\Afork_too_large: fork (?\S+) at 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(); + 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 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(); + 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 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(JsonElement map, string name) + { + try + { + return (Property(map, name) ?? throw new DeskDoorException($"{name} is missing")).Deserialize(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"), + }; +} diff --git a/src/Bifrost.Web/packages.lock.json b/src/Bifrost.Web/packages.lock.json index fc903ba..d969a56 100644 --- a/src/Bifrost.Web/packages.lock.json +++ b/src/Bifrost.Web/packages.lock.json @@ -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", diff --git a/tests/Bifrost.Web.Tests/Support/DeskWebApplicationFactory.cs b/tests/Bifrost.Web.Tests/Support/DeskWebApplicationFactory.cs index c5c81ea..1247f99 100644 --- a/tests/Bifrost.Web.Tests/Support/DeskWebApplicationFactory.cs +++ b/tests/Bifrost.Web.Tests/Support/DeskWebApplicationFactory.cs @@ -29,6 +29,7 @@ public sealed class DeskWebApplicationFactory : TestWebApplicationFactory private readonly Dictionary? 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? 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? 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(Credential); - services.AddSingleton(Door); + if (fakeDoor) + { + services.AddSingleton(Door); + } }); } } diff --git a/tests/Bifrost.Web.Tests/Support/FakeHuginnSocket.cs b/tests/Bifrost.Web.Tests/Support/FakeHuginnSocket.cs new file mode 100644 index 0000000..0207e84 --- /dev/null +++ b/tests/Bifrost.Web.Tests/Support/FakeHuginnSocket.cs @@ -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 handler; + + public FakeHuginnSocket(Func 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 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 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 Answer(Func 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>? mutate = null) + { + var fields = new SortedDictionary(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(); + 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 message) + { + var frame = new byte[4 + message.Length]; + BinaryPrimitives.WriteUInt32BigEndian(frame, (uint)message.Length); + message.CopyTo(frame.AsSpan(4)); + return frame; + } +} diff --git a/tests/Bifrost.Web.Tests/UnixSocketDeskDoorTests.cs b/tests/Bifrost.Web.Tests/UnixSocketDeskDoorTests.cs new file mode 100644 index 0000000..a938912 --- /dev/null +++ b/tests/Bifrost.Web.Tests/UnixSocketDeskDoorTests.cs @@ -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 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 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 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(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(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(() => 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(() => 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(() => 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(() => List(DoorWith("m-9"), huginn.Path)); + + using var other = new FakeHuginnSocket(FakeHuginnSocket.Replay(FakeHuginnSocket.Fixture("list-empty.response"))); + await Assert.ThrowsAsync(() => 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(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(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(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 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(() => 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(() => + 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(() => 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(() => 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(() => + 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(() => 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(await List(DoorWith("m-1"), huginn.Path)); + } + + [Fact] + public async Task Absent_and_refusing_sockets_are_failures() + { + await Assert.ThrowsAsync(() => 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(() => List(new UnixSocketDeskDoor(), stale)); + } +} diff --git a/tests/Bifrost.Web.Tests/packages.lock.json b/tests/Bifrost.Web.Tests/packages.lock.json index 14c75c5..b81762f 100644 --- a/tests/Bifrost.Web.Tests/packages.lock.json +++ b/tests/Bifrost.Web.Tests/packages.lock.json @@ -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, )",