From f03027d929d36d9e98f37311c4505ae2ee29bcf7 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Tue, 10 Nov 2026 20:15:05 +0000 Subject: [PATCH] desk: envelope names, decider bounds, a failing port is unavailable, hardening tests The door context carries mind, authority, subject and deciders (fork-card.md M5). Deciders are 1 to 64 unique ids per Mind, refused at startup and again before any door call. A throwing credential port or account lookup answers 503 unavailable. Startup refuses two Minds sharing one socket. Behavioural tests for the per-Mind list, the body cap to the byte, Retry-After, audience case, no-store, cancellation and the stale-fork card read. Co-Authored-By: Claude Sonnet 5.5 --- src/Bifrost.Web/Configuration/DeskOptions.cs | 9 +- .../Features/Desk/DeskEndpoints.cs | 35 +- src/Bifrost.Web/Features/Desk/DeskPorts.cs | 8 +- .../Shared/StartupConfigurationValidator.cs | 10 + tests/Bifrost.Web.Tests/DeskAnswerTests.cs | 5 +- tests/Bifrost.Web.Tests/DeskHardeningTests.cs | 310 ++++++++++++++++++ tests/Bifrost.Web.Tests/DeskOptionsTests.cs | 44 ++- tests/Bifrost.Web.Tests/DeskReadTests.cs | 2 +- tests/Bifrost.Web.Tests/DeskScenarioTests.cs | 5 +- .../Support/DeskWebApplicationFactory.cs | 18 +- .../Support/FakeDeskCredential.cs | 12 + .../Bifrost.Web.Tests/Support/FakeDeskDoor.cs | 9 +- 12 files changed, 449 insertions(+), 18 deletions(-) create mode 100644 tests/Bifrost.Web.Tests/DeskHardeningTests.cs diff --git a/src/Bifrost.Web/Configuration/DeskOptions.cs b/src/Bifrost.Web/Configuration/DeskOptions.cs index be36641..688d098 100644 --- a/src/Bifrost.Web/Configuration/DeskOptions.cs +++ b/src/Bifrost.Web/Configuration/DeskOptions.cs @@ -30,8 +30,15 @@ public sealed class DeskMindOptions // The full path of this Mind's socket on the peer-checked door. Bifrost only connects to it. public string Socket { get; init; } = string.Empty; - // Accounts that may answer this Mind's forks. Sent to Huginn on every call; no request sets or extends it. + // Accounts that may answer this Mind's forks. Sent to Huginn with every call; no request sets or extends it. public List Deciders { get; init; } = []; + + // Huginn's bound on the list a call carries (fork-card.md: one to 64 ids). + public const int MaxDeciders = 64; + + // One to 64 ids, none repeated. The startup validator and the request path both ask this one question. + public static bool DecidersWithinBounds(IReadOnlyCollection deciders) => + deciders.Count is >= 1 and <= MaxDeciders && deciders.Distinct(StringComparer.Ordinal).Count() == deciders.Count; } // The authority a stamped answer carries (fork-card.md: Operator, Steward or Member), from configuration because diff --git a/src/Bifrost.Web/Features/Desk/DeskEndpoints.cs b/src/Bifrost.Web/Features/Desk/DeskEndpoints.cs index 1d88e18..e4e54f0 100644 --- a/src/Bifrost.Web/Features/Desk/DeskEndpoints.cs +++ b/src/Bifrost.Web/Features/Desk/DeskEndpoints.cs @@ -73,7 +73,18 @@ public static class DeskEndpoints } // 2-4. Bearer, audience, scope. - var token = await credential.AuthenticateAsync(http, cancellationToken); + DeskToken? token; + try + { + token = await credential.AuthenticateAsync(http, cancellationToken); + } + catch (Exception) when (!cancellationToken.IsCancellationRequested) + { + // The bearer could not be judged: neither a pass nor a refusal of the caller. + await WriteUnavailableAsync(http, desk); + return; + } + if (token is null || !string.Equals(token.Audience, Audience, StringComparison.Ordinal)) { await WriteRefusalAsync(http, DeskRefusals.Unauthenticated); @@ -88,7 +99,17 @@ public static class DeskEndpoints } // 5. Active membership, re-read now; the body has not been touched. - var accountId = await OidcEndpoints.ActiveMemberIdAsync(db, token.Subject, cancellationToken); + Guid? accountId; + try + { + accountId = await OidcEndpoints.ActiveMemberIdAsync(db, token.Subject, cancellationToken); + } + catch (Exception) when (!cancellationToken.IsCancellationRequested) + { + await WriteUnavailableAsync(http, desk); + return; + } + if (accountId is null) { await WriteRefusalAsync(http, DeskRefusals.Forbidden); @@ -129,9 +150,15 @@ public static class DeskEndpoints return; } - // 8-10. The door. + // 8-10. The door. A decider list outside 1..64 unique ids is a configuration fault: nothing is sent. + if (!DeskMindOptions.DecidersWithinBounds(mind.Deciders)) + { + await WriteUnavailableAsync(http, desk); + return; + } + var subject = accountId.Value.ToString("D"); - var context = new DeskDoorContext(subject, desk.Attribution.AuthorityOf(subject), mind.Deciders); + var context = new DeskDoorContext(mind.Id, desk.Attribution.AuthorityOf(subject), subject, mind.Deciders.ToArray()); try { switch (route.Kind) diff --git a/src/Bifrost.Web/Features/Desk/DeskPorts.cs b/src/Bifrost.Web/Features/Desk/DeskPorts.cs index b36a687..9f33da5 100644 --- a/src/Bifrost.Web/Features/Desk/DeskPorts.cs +++ b/src/Bifrost.Web/Features/Desk/DeskPorts.cs @@ -15,9 +15,11 @@ public interface IDeskCredential ValueTask AuthenticateAsync(HttpContext context, CancellationToken cancellationToken); } -// Sent with every door call, built by Bifrost alone: the verified account, its attribution, and the decider list -// configured for the route's Mind. Nothing in the request sets any of it. -public sealed record DeskDoorContext(string Subject, string Attribution, IReadOnlyList Deciders); +// The envelope sent with every answer, read and list call (fork-card.md M5), under the contract's names, built by +// Bifrost alone: the route's configured Mind id, the verified account's authority (Operator, Steward or Member), +// the account id, and the admin-set decider list of that Mind (1 to 64 unique ids). Nothing in the request sets any +// of it, and Huginn keeps no copy of the list. +public sealed record DeskDoorContext(string Mind, string Authority, string Subject, IReadOnlyList Deciders); public abstract record DeskAnswerOutcome { diff --git a/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs b/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs index 8903b13..6be28cb 100644 --- a/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs +++ b/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs @@ -151,6 +151,7 @@ public sealed class StartupConfigurationValidator( } var ids = new HashSet(StringComparer.Ordinal); + var sockets = new HashSet(StringComparer.Ordinal); for (var i = 0; i < desk.Minds.Count; i++) { var mind = desk.Minds[i]; @@ -167,6 +168,15 @@ public sealed class StartupConfigurationValidator( { failures.Add($"Desk:Minds:{i}:Socket must be an absolute path."); } + else if (!sockets.Add(mind.Socket)) + { + failures.Add($"Desk:Minds:{i}:Socket repeats an earlier Mind's socket."); + } + + if (mind.Deciders.Count is < 1 or > DeskMindOptions.MaxDeciders) + { + failures.Add($"Desk:Minds:{i}:Deciders must hold 1 to {DeskMindOptions.MaxDeciders} account ids."); + } var deciders = new HashSet(StringComparer.Ordinal); foreach (var decider in mind.Deciders) diff --git a/tests/Bifrost.Web.Tests/DeskAnswerTests.cs b/tests/Bifrost.Web.Tests/DeskAnswerTests.cs index b3d99cf..9cf0048 100644 --- a/tests/Bifrost.Web.Tests/DeskAnswerTests.cs +++ b/tests/Bifrost.Web.Tests/DeskAnswerTests.cs @@ -124,8 +124,9 @@ public sealed class DeskAnswerTests Assert.Equal(200, reply.Status); var call = Assert.Single(factory.Door.Calls); Assert.Equal(DeskWebApplicationFactory.Steward, call.Context.Subject); - Assert.Equal("Steward", call.Context.Attribution); - Assert.Equal([DeskWebApplicationFactory.Alice, DeskWebApplicationFactory.Steward], call.Context.Deciders); + Assert.Equal("Steward", call.Context.Authority); + Assert.Equal("nibu", call.Context.Mind); + Assert.Equal(DeskWebApplicationFactory.DecidersOf("nibu"), call.Context.Deciders); } } diff --git a/tests/Bifrost.Web.Tests/DeskHardeningTests.cs b/tests/Bifrost.Web.Tests/DeskHardeningTests.cs new file mode 100644 index 0000000..f5a1cf8 --- /dev/null +++ b/tests/Bifrost.Web.Tests/DeskHardeningTests.cs @@ -0,0 +1,310 @@ +using System.Text; +using System.Text.Json.Nodes; +using Bifrost.Web.Configuration; +using Bifrost.Web.Features.Desk; +using Bifrost.Web.Tests.Support; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace Bifrost.Web.Tests; + +// The rules the desk's edge keeps that a happy path never shows: whose decider list rides each call, the body cap +// to the byte, what the Retry-After comes from, the audience's case, no-store, and what a failing port becomes. +public sealed class DeskHardeningTests +{ + private const string Nibu = "nibu"; + private const string Chat = "gamecult-chat"; + private const string NibuFork = "nibu:question:glow-order"; + + private static async Task ArrangeAsync(Dictionary? configuration = null) + { + var factory = new DeskWebApplicationFactory(configuration); + DeskReplay.HoldVectorForks(factory.Door); + await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true); + return factory; + } + + private static string Token(DeskWebApplicationFactory factory, string audience = "bifrost-desk") => + factory.Token(DeskWebApplicationFactory.Alice, audience, "desk.read", "desk.answer"); + + private static Task SendRouteAsync(DeskWebApplicationFactory factory, string route, string mind, string token) + { + var fork = mind == Nibu ? NibuFork : DeskReplay.ChatFork; + return route switch + { + "answer" => factory.SendAsync("POST", DeskReplay.Target("answer", mind, null), DeskReplay.AnswerBody(fork), token), + "read" => factory.SendAsync("GET", DeskReplay.Target("read", mind, fork), null, token), + _ => factory.SendAsync("GET", DeskReplay.Target("list", mind, null), null, token), + }; + } + + private static readonly string[] Routes = ["answer", "read", "list"]; + + private static string Code(DeskReply reply) => (string)JsonNode.Parse(reply.Body)!["code"]!; + + // (a) Each Mind's list is its own: a context built from another Mind's list changes the call. + [Fact] + public async Task Each_call_carries_the_route_minds_own_decider_list_and_envelope() + { + using var factory = await ArrangeAsync(); + Assert.NotEqual(DeskWebApplicationFactory.DecidersOf(Chat), DeskWebApplicationFactory.DecidersOf(Nibu)); + var token = Token(factory); + + foreach (var mind in new[] { Chat, Nibu }) + { + foreach (var route in Routes) + { + factory.Door.Calls.Clear(); + var reply = await SendRouteAsync(factory, route, mind, token); + Assert.True(reply.Status == 200, $"{mind} {route}: {reply.Status}"); + var call = Assert.Single(factory.Door.Calls); + Assert.True(DeskWebApplicationFactory.DecidersOf(mind).SequenceEqual(call.Context.Deciders), $"{mind} {route}: deciders"); + Assert.Equal(mind, call.Context.Mind); + Assert.Equal(DeskWebApplicationFactory.SocketOf(mind), call.Socket); + Assert.Equal(DeskWebApplicationFactory.Alice, call.Context.Subject); + Assert.Equal("Operator", call.Context.Authority); + } + } + } + + // (b) The cap to the byte, at the HTTP edge, however the length is announced. + [Theory] + [InlineData(0)] + [InlineData(4096)] + [InlineData(4097)] + [InlineData(8192)] + public async Task A_body_of_exactly_the_cap_reaches_the_shape_judge_and_one_more_byte_does_not(int cap) + { + var bare = DeskReplay.AnswerBody(DeskReplay.ChatFork); + cap = Math.Max(cap, bare.Length); + var config = DeskWebApplicationFactory.DefaultConfiguration(); + config["Desk:MaxBodyBytes"] = cap.ToString(System.Globalization.CultureInfo.InvariantCulture); + using var factory = await ArrangeAsync(config); + var token = Token(factory); + var target = DeskReplay.Target("answer", Chat, null); + byte[] Padded(int length) => [.. bare, .. Enumerable.Repeat((byte)' ', length - bare.Length)]; + + var modes = new (string Name, Action Configure)[] + { + ("content-length", _ => { }), + ("no content-length", http => http.Request.ContentLength = null), + ("chunked", http => + { + http.Request.ContentLength = null; + http.Request.Headers.TransferEncoding = "chunked"; + }), + }; + foreach (var (name, configure) in modes) + { + factory.Door.Calls.Clear(); + var exact = await factory.SendAsync("POST", target, Padded(cap), token, configure: configure); + Assert.True(exact.Status == 200, $"{name} exact: {exact.Status} {exact.Text}"); + Assert.Equal(cap, Assert.Single(factory.Door.Calls).Body!.Length); + + factory.Door.Calls.Clear(); + var over = await factory.SendAsync("POST", target, Padded(cap + 1), token, configure: configure); + Assert.True(over.Status == 400 && Code(over) == "body_invalid", $"{name} over: {over.Status} {over.Text}"); + Assert.Empty(factory.Door.Calls); + } + } + + // (c) The wait an `unavailable` names is the configured one, in the document and in the header. + [Fact] + public async Task Retry_after_comes_from_configuration_in_the_document_and_the_header() + { + var config = DeskWebApplicationFactory.DefaultConfiguration(); + config["Desk:RetryAfterSeconds"] = "7"; + using var factory = await ArrangeAsync(config); + factory.Door.Down = true; + var token = Token(factory); + + foreach (var route in Routes) + { + var reply = await SendRouteAsync(factory, route, Chat, token); + Assert.True(reply.Status == 503, route); + Assert.Equal(7, (int)JsonNode.Parse(reply.Body)!["retryAfterSeconds"]!); + Assert.Equal("7", reply.Headers.RetryAfter.ToString()); + } + } + + // (d) The audience is compared ordinally. + [Theory] + [InlineData("BIFROST-DESK")] + [InlineData("Bifrost-Desk")] + [InlineData("bifrost-desk ")] + [InlineData("bifrost-votes")] + public async Task The_audience_is_case_sensitive(string audience) + { + using var factory = await ArrangeAsync(); + + foreach (var route in Routes) + { + var reply = await SendRouteAsync(factory, route, Chat, Token(factory, audience)); + Assert.True(reply.Status == 401 && Code(reply) == "unauthenticated", $"{route}: {reply.Status}"); + } + + Assert.Empty(factory.Door.Calls); + } + + // (e) No desk response is cacheable: answers, refusals, unavailable and an unrecognised target alike. + [Fact] + public async Task Every_desk_response_is_no_store() + { + using var factory = await ArrangeAsync(); + var token = Token(factory); + var replies = new List<(string, DeskReply)>(); + foreach (var route in Routes) + { + replies.Add((route + " 200", await SendRouteAsync(factory, route, Chat, token))); + replies.Add((route + " 401", await SendRouteAsync(factory, route, Chat, ""))); + replies.Add((route + " 404", await SendRouteAsync(factory, route, "ghost", token))); + } + + factory.Door.Down = true; + replies.Add(("503", await SendRouteAsync(factory, "list", Chat, token))); + replies.Add(("unrecognised", await factory.SendAsync("GET", "/api/desk/x", null, token))); + + foreach (var (label, reply) in replies) + { + Assert.True(reply.Headers.CacheControl.ToString() == "no-store", $"{label}: '{reply.Headers.CacheControl}'"); + } + + Assert.Equal(200, replies[0].Item2.Status); + Assert.Equal(503, replies[^2].Item2.Status); + } + + // (f) A cancelled request is not `unavailable`: the exception goes on to the host. + [Fact] + public async Task A_cancelled_request_is_not_called_unavailable() + { + using var factory = await ArrangeAsync(); + using var cts = new CancellationTokenSource(); + factory.Door.AnswerScript = (_, _, _) => + { + cts.Cancel(); + throw new IOException("the client went away"); + }; + + var reply = await factory.SendAsync( + "POST", DeskReplay.Target("answer", Chat, null), DeskReplay.AnswerBody(DeskReplay.ChatFork), Token(factory), + configure: http => http.RequestAborted = cts.Token); + + // The host's exception handling answers; the desk does not claim the door was merely unavailable. + Assert.NotEqual(503, reply.Status); + Assert.DoesNotContain("unavailable", reply.Text); + } + + // (f) A stale_fork needs the current card; a card read that finds nothing, for any code, is `unavailable`. + [Theory] + [InlineData("fork_unknown")] + [InlineData("request_invalid")] + [InlineData("made_up")] + public async Task A_stale_fork_whose_card_read_finds_nothing_is_unavailable_never_a_stale_fork_without_a_card(string code) + { + using var factory = await ArrangeAsync(); + var stale = new AnswerResult( + DeskSchemas.AnswerResult, + new DeskAnswerDocument( + DeskSchemas.DeskAnswer, "n1", DeskReplay.ChatFork, "Refused", null, null, "stale_fork", "oldhash", "no", null, + "2026-11-12T19:02:00Z", "2026-11-12T19:02:05Z", false, DeskWebApplicationFactory.Alice, "Operator", + "00000001-0000-4000-8000-000000000001"), + null, + null); + factory.Door.AnswerScript = (_, _, _) => new DeskAnswerOutcome.Answered(stale); + factory.Door.ReadScript = (_, _) => new DeskReadOutcome.NotFound(code); + + var reply = await SendRouteAsync(factory, "answer", Chat, Token(factory)); + + Assert.Equal(503, reply.Status); + Assert.Equal("unavailable", Code(reply)); + Assert.Null(JsonNode.Parse(reply.Body)!["card"]); + Assert.Contains(factory.Door.Calls, c => c.Kind == "read"); + } + + // (h) A port that fails is `unavailable`, never a 500 and never a pass. + [Fact] + public async Task A_throwing_credential_port_is_unavailable_and_opens_nothing() + { + using var factory = await ArrangeAsync(); + factory.Credential.Throws = true; + + foreach (var route in Routes) + { + var reply = await SendRouteAsync(factory, route, Chat, Token(factory)); + Assert.True(reply.Status == 503 && Code(reply) == "unavailable", $"{route}: {reply.Status}"); + } + + Assert.Empty(factory.Door.Calls); + } + + [Fact] + public async Task A_throwing_account_lookup_is_unavailable_and_opens_nothing() + { + using var factory = await ArrangeAsync(); + // The request's database context is gone by the time the desk looks the account up (the database failed). + factory.Credential.OnAuthenticate = http => http.RequestServices.GetRequiredService().Dispose(); + + foreach (var route in Routes) + { + var reply = await SendRouteAsync(factory, route, Chat, Token(factory)); + Assert.True(reply.Status == 503 && Code(reply) == "unavailable", $"{route}: {reply.Status}"); + } + + Assert.Empty(factory.Door.Calls); + } + + // The runtime guard on the decider list (the startup validator is the first layer; this is the second). + private static string Id(int n) => new Guid(n, 0, 0, new byte[8]).ToString("D"); + + private static void SetDeciders(DeskWebApplicationFactory factory, string mind, IEnumerable ids) + { + var list = factory.Services.GetRequiredService>().Value.Minds.Single(m => m.Id == mind).Deciders; + list.Clear(); + list.AddRange(ids); + } + + [Fact] + public async Task The_door_is_called_with_64_deciders() + { + using var factory = await ArrangeAsync(); + var ids = Enumerable.Range(1, 64).Select(Id).ToList(); + SetDeciders(factory, Chat, ids); + + foreach (var route in Routes) + { + factory.Door.Calls.Clear(); + var reply = await SendRouteAsync(factory, route, Chat, Token(factory)); + Assert.True(reply.Status == 200, route); + Assert.Equal(ids, Assert.Single(factory.Door.Calls).Context.Deciders); + } + } + + [Theory] + [InlineData("65")] + [InlineData("0")] + [InlineData("duplicate")] + public async Task A_decider_list_out_of_bounds_is_unavailable_without_a_door_call(string fault) + { + using var factory = await ArrangeAsync(); + var ids = fault switch + { + "65" => Enumerable.Range(1, 65).Select(Id).ToList(), + "0" => [], + _ => [Id(1), Id(2), Id(1)], + }; + SetDeciders(factory, Chat, ids); + + foreach (var route in Routes) + { + var reply = await SendRouteAsync(factory, route, Chat, Token(factory)); + Assert.True(reply.Status == 503 && Code(reply) == "unavailable", $"{route}: {reply.Status}"); + } + + Assert.Empty(factory.Door.Calls); + + // Only that Mind's list is at fault: the other Mind still answers. + var other = await SendRouteAsync(factory, "list", Nibu, Token(factory)); + Assert.Equal(200, other.Status); + } +} diff --git a/tests/Bifrost.Web.Tests/DeskOptionsTests.cs b/tests/Bifrost.Web.Tests/DeskOptionsTests.cs index 048bfdc..8b09244 100644 --- a/tests/Bifrost.Web.Tests/DeskOptionsTests.cs +++ b/tests/Bifrost.Web.Tests/DeskOptionsTests.cs @@ -20,6 +20,7 @@ public sealed class DeskOptionsTests ["Desk:Minds:0:Deciders:0"] = Alice, ["Desk:Minds:1:Id"] = "nibu", ["Desk:Minds:1:Socket"] = "/run/huginn/huginn-nibu.sock", + ["Desk:Minds:1:Deciders:0"] = Bob, ["Desk:Attribution:Operator"] = Alice, ["Desk:Attribution:Stewards:0"] = Bob, }; @@ -59,7 +60,7 @@ public sealed class DeskOptionsTests Assert.Equal(["gamecult-chat", "nibu"], options.Minds.Select(m => m.Id)); Assert.Equal([Alice], options.Minds[0].Deciders); - Assert.Empty(options.Minds[1].Deciders); + Assert.Equal([Bob], options.Minds[1].Deciders); Validate(Good()); } @@ -158,6 +159,47 @@ public sealed class DeskOptionsTests Refused(repeated, "Desk:Attribution:Stewards repeats"); } + private static string Id(int n) => new Guid(n, 0, 0, new byte[8]).ToString("D"); + + private static Dictionary WithDeciders(int count) + { + var values = Good(); + for (var i = 0; i < count; i++) + { + values[$"Desk:Minds:0:Deciders:{i}"] = Id(i + 1); + } + + if (count == 0) + { + values.Remove("Desk:Minds:0:Deciders:0"); + } + + return values; + } + + [Fact] + public void Startup_takes_one_to_64_deciders_per_mind() + { + Validate(WithDeciders(1)); + Validate(WithDeciders(64)); + Refused(WithDeciders(65), "Desk:Minds:0:Deciders must hold 1 to 64"); + Refused(WithDeciders(0), "Desk:Minds:0:Deciders must hold 1 to 64"); + + // The bound is per Mind: the second Mind is judged on its own list. + var second = Good(); + second.Remove("Desk:Minds:1:Deciders:0"); + Refused(second, "Desk:Minds:1:Deciders must hold 1 to 64"); + } + + [Fact] + public void Startup_refuses_two_minds_sharing_one_socket() + { + var values = Good(); + values["Desk:Minds:1:Socket"] = values["Desk:Minds:0:Socket"]; + + Refused(values, "Desk:Minds:1:Socket repeats an earlier Mind's socket"); + } + [Fact] public void Desk_failures_are_reported_in_production_too_and_alongside_each_other() { diff --git a/tests/Bifrost.Web.Tests/DeskReadTests.cs b/tests/Bifrost.Web.Tests/DeskReadTests.cs index 9cf0f05..01a76b4 100644 --- a/tests/Bifrost.Web.Tests/DeskReadTests.cs +++ b/tests/Bifrost.Web.Tests/DeskReadTests.cs @@ -164,6 +164,6 @@ public sealed class DeskReadTests Assert.Equal("body_unknown_field", (string?)JsonNode.Parse(named.Body)!["code"]); Assert.Equal(["list", "read", "answer"], factory.Door.Calls.Select(c => c.Kind).ToArray()); - Assert.All(factory.Door.Calls, c => Assert.Equal([DeskWebApplicationFactory.Alice, DeskWebApplicationFactory.Steward], c.Context.Deciders)); + Assert.All(factory.Door.Calls, c => Assert.Equal(DeskWebApplicationFactory.DecidersOf("nibu"), c.Context.Deciders)); } } diff --git a/tests/Bifrost.Web.Tests/DeskScenarioTests.cs b/tests/Bifrost.Web.Tests/DeskScenarioTests.cs index e781636..9955bdb 100644 --- a/tests/Bifrost.Web.Tests/DeskScenarioTests.cs +++ b/tests/Bifrost.Web.Tests/DeskScenarioTests.cs @@ -9,7 +9,7 @@ public sealed class DeskScenarioTests { private static readonly string Socket = DeskWebApplicationFactory.SocketOf("gamecult-chat"); - private static readonly string[] ConfiguredDeciders = [DeskWebApplicationFactory.Alice, DeskWebApplicationFactory.Steward]; + private static readonly string[] ConfiguredDeciders = DeskWebApplicationFactory.DecidersOf("gamecult-chat"); // Replays every answer step of every scenario. The door is scripted from the vector's response; what Bifrost // must produce by itself (edge refusals, unavailable, the attached card) is compared with the vector. @@ -101,7 +101,8 @@ public sealed class DeskScenarioTests var call = Assert.Single(door.Calls.Where(c => c.Kind == "answer")); Assert.Equal(body, call.Body); Assert.Equal(answerer, call.Context.Subject); - Assert.Equal((string)server["authority"]!, call.Context.Attribution); + Assert.Equal((string)server["authority"]!, call.Context.Authority); + Assert.Equal("gamecult-chat", call.Context.Mind); Assert.Equal(ConfiguredDeciders, call.Context.Deciders); Assert.Equal(Socket, call.Socket); } diff --git a/tests/Bifrost.Web.Tests/Support/DeskWebApplicationFactory.cs b/tests/Bifrost.Web.Tests/Support/DeskWebApplicationFactory.cs index 5e14cfb..450be51 100644 --- a/tests/Bifrost.Web.Tests/Support/DeskWebApplicationFactory.cs +++ b/tests/Bifrost.Web.Tests/Support/DeskWebApplicationFactory.cs @@ -35,6 +35,9 @@ public sealed class DeskWebApplicationFactory : TestWebApplicationFactory public FakeDeskDoor Door { get; } = new(); + public FakeDeskCredential Credential { get; } = new(); + + public static Dictionary DefaultConfiguration() { var values = new Dictionary @@ -49,14 +52,23 @@ public sealed class DeskWebApplicationFactory : TestWebApplicationFactory { values[$"Desk:Minds:{i}:Id"] = mind.Key; values[$"Desk:Minds:{i}:Socket"] = SocketOf(mind.Key); - values[$"Desk:Minds:{i}:Deciders:0"] = Alice; - values[$"Desk:Minds:{i}:Deciders:1"] = Steward; + var deciders = DecidersOf(mind.Key); + for (var d = 0; d < deciders.Length; d++) + { + values[$"Desk:Minds:{i}:Deciders:{d}"] = deciders[d]; + } + i++; } return values; } + // A distinct list per Mind (different members, order and length), so a door context that carries another + // Mind's list cannot pass for the route Mind's. + public static string[] DecidersOf(string mindId) => + mindId == "nibu" ? [Steward, Alice, Member] : [Alice, Steward]; + public static string SocketOf(string mindId) => "/run/huginn/" + (string)DeskVectors.Root["transport"]!["mindRoutes"]!["minds"]![mindId]!; @@ -75,7 +87,7 @@ public sealed class DeskWebApplicationFactory : TestWebApplicationFactory { builder.ConfigureServices(services => { - services.AddSingleton(); + services.AddSingleton(Credential); services.AddSingleton(Door); }); } diff --git a/tests/Bifrost.Web.Tests/Support/FakeDeskCredential.cs b/tests/Bifrost.Web.Tests/Support/FakeDeskCredential.cs index de2592b..77910fc 100644 --- a/tests/Bifrost.Web.Tests/Support/FakeDeskCredential.cs +++ b/tests/Bifrost.Web.Tests/Support/FakeDeskCredential.cs @@ -13,8 +13,20 @@ public sealed class FakeDeskCredential : IDeskCredential public static string Encode(string? subject, string? audience, IEnumerable scopes) => JsonSerializer.Serialize(new { sub = subject, aud = audience, scopes }); + // The port fails (its key source is down), instead of answering. + public bool Throws { get; set; } + + // Runs when a request reaches the port, after the host's own middleware: a test breaks what comes next. + public Action? OnAuthenticate { get; set; } + public ValueTask AuthenticateAsync(HttpContext context, CancellationToken cancellationToken) { + if (Throws) + { + throw new InvalidOperationException("the credential port is down"); + } + + OnAuthenticate?.Invoke(context); if (!context.Request.Headers.TryGetValue(Header, out var raw)) { return ValueTask.FromResult(null); diff --git a/tests/Bifrost.Web.Tests/Support/FakeDeskDoor.cs b/tests/Bifrost.Web.Tests/Support/FakeDeskDoor.cs index 2e8af51..3211adf 100644 --- a/tests/Bifrost.Web.Tests/Support/FakeDeskDoor.cs +++ b/tests/Bifrost.Web.Tests/Support/FakeDeskDoor.cs @@ -27,6 +27,8 @@ public sealed class FakeDeskDoor : IDeskDoor public bool ThrowOnRead { get; set; } + public Func? ReadScript { get; set; } + public Func? ListScript { get; set; } public Task ListAsync( @@ -90,6 +92,11 @@ public sealed class FakeDeskDoor : IDeskDoor throw new IOException("huginn is down"); } + if (ReadScript is not null) + { + return Task.FromResult(ReadScript(socket, forkId)); + } + return Task.FromResult(Forks.TryGetValue((socket, forkId), out var entry) ? new DeskReadOutcome.Found(entry) : new DeskReadOutcome.NotFound(DeskRefusals.ForkUnknown)); @@ -111,7 +118,7 @@ public sealed class FakeDeskDoor : IDeskDoor DeskSchemas.DeskAnswer, "n1", forkId, "Counted", null, null, null, (string)request["options_hash"]!, (string?)answer?["choice"], (string?)answer?["note"], (string)request["answered_at"]!, "2026-11-12T19:02:05Z", false, - context.Subject, context.Attribution, (string)request["request_id"]!), + context.Subject, context.Authority, (string)request["request_id"]!), null, "n1")); }