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 <noreply@anthropic.com>
This commit is contained in:
parent
a530aaa716
commit
f03027d929
12 changed files with 449 additions and 18 deletions
|
|
@ -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<string> 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<string> 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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -15,9 +15,11 @@ public interface IDeskCredential
|
|||
ValueTask<DeskToken?> 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<string> 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<string> Deciders);
|
||||
|
||||
public abstract record DeskAnswerOutcome
|
||||
{
|
||||
|
|
|
|||
|
|
@ -151,6 +151,7 @@ public sealed class StartupConfigurationValidator(
|
|||
}
|
||||
|
||||
var ids = new HashSet<string>(StringComparer.Ordinal);
|
||||
var sockets = new HashSet<string>(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<string>(StringComparer.Ordinal);
|
||||
foreach (var decider in mind.Deciders)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
310
tests/Bifrost.Web.Tests/DeskHardeningTests.cs
Normal file
310
tests/Bifrost.Web.Tests/DeskHardeningTests.cs
Normal file
|
|
@ -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<DeskWebApplicationFactory> ArrangeAsync(Dictionary<string, string?>? 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<DeskReply> 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<HttpContext> 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<Bifrost.Web.Data.BifrostDbContext>().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<string> ids)
|
||||
{
|
||||
var list = factory.Services.GetRequiredService<IOptions<DeskOptions>>().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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<string, string?> 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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,6 +35,9 @@ public sealed class DeskWebApplicationFactory : TestWebApplicationFactory
|
|||
|
||||
public FakeDeskDoor Door { get; } = new();
|
||||
|
||||
public FakeDeskCredential Credential { get; } = new();
|
||||
|
||||
|
||||
public static Dictionary<string, string?> DefaultConfiguration()
|
||||
{
|
||||
var values = new Dictionary<string, string?>
|
||||
|
|
@ -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<IDeskCredential, FakeDeskCredential>();
|
||||
services.AddSingleton<IDeskCredential>(Credential);
|
||||
services.AddSingleton<IDeskDoor>(Door);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,8 +13,20 @@ public sealed class FakeDeskCredential : IDeskCredential
|
|||
public static string Encode(string? subject, string? audience, IEnumerable<string> 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<HttpContext>? OnAuthenticate { get; set; }
|
||||
|
||||
public ValueTask<DeskToken?> 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<DeskToken?>(null);
|
||||
|
|
|
|||
|
|
@ -27,6 +27,8 @@ public sealed class FakeDeskDoor : IDeskDoor
|
|||
|
||||
public bool ThrowOnRead { get; set; }
|
||||
|
||||
public Func<string, string, DeskReadOutcome>? ReadScript { get; set; }
|
||||
|
||||
public Func<string, DeskListRequest, DeskListOutcome>? ListScript { get; set; }
|
||||
|
||||
public Task<DeskListOutcome> 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<DeskReadOutcome>(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"));
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue