desk: the answer route on credential and door ports (H3a)

POST /api/desk/{mind}/answers behind one pipeline: raw target, bearer, audience,
scope, Active membership, body shape, {mind} lookup, door. Mapped only when the
Desk section exists; a configured desk without its credential and door ports
stops startup. Stale_fork answers carry the current card read through the door.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Heimdall 2026-11-10 19:28:52 +00:00
commit 47db4f83f2
18 changed files with 1438 additions and 7 deletions

View file

@ -11,6 +11,15 @@ public sealed class DeskOptions
public DeskAttributionOptions Attribution { get; init; } = new();
// The one origin allowed to call /api/desk from a browser (exactly this, no credentials).
public string AllowedOrigin { get; init; } = string.Empty;
// The cap on an answer body, in bytes, judged before it is parsed.
public int MaxBodyBytes { get; init; } = 16 * 1024;
// What an `unavailable` document tells the client to wait.
public int RetryAfterSeconds { get; init; } = 30;
public bool IsConfigured => Minds.Count > 0;
}

View file

@ -21,7 +21,11 @@ public static class DeskSchemas
public static class DeskJson
{
// Defaults write nulls, which map-mode needs ("every key written").
public static readonly JsonSerializerOptions Options = new();
public static readonly JsonSerializerOptions Options = new()
{
// The documents are not HTML: keep apostrophes and non-ASCII text as written.
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
};
}
public sealed record AnswerObject(
@ -94,6 +98,18 @@ public sealed record DeskAnswerDocument(
[property: JsonPropertyName("authority")] string Authority,
[property: JsonPropertyName("request_id")] string RequestId);
public sealed record ForkRecord(
[property: JsonPropertyName("schemaVersion")] string SchemaVersion,
[property: JsonPropertyName("fork_id")] string ForkId,
[property: JsonPropertyName("options_hash")] string OptionsHash,
[property: JsonPropertyName("counted_answer_id")] string? CountedAnswerId,
[property: JsonPropertyName("answers")] DeskAnswerDocument[] Answers);
public sealed record ForkEntry(
[property: JsonPropertyName("schemaVersion")] string SchemaVersion,
[property: JsonPropertyName("card")] ForkCard Card,
[property: JsonPropertyName("record")] ForkRecord Record);
public sealed record AnswerResult(
[property: JsonPropertyName("schemaVersion")] string SchemaVersion,
[property: JsonPropertyName("answer")] DeskAnswerDocument Answer,
@ -126,6 +142,8 @@ public static class DeskRefusals
[RequestIdConflict] = (409, "request_id_conflict: this request_id was used for a different body"),
};
public static bool IsKnown(string code) => Table.ContainsKey(code);
public static int StatusOf(string code) => Table[code].Status;
public static DeskRefusal Document(string code) => new(DeskSchemas.Refusal, code, Table[code].Error);

View file

@ -0,0 +1,262 @@
using System.Text.Json;
using Bifrost.Web.Configuration;
using Bifrost.Web.Data;
using Bifrost.Web.Features.Oidc;
using Microsoft.AspNetCore.Cors.Infrastructure;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Options;
using Microsoft.Net.Http.Headers;
namespace Bifrost.Web.Features.Desk;
// The fork desk's HTTP edge (fork-card.md, Transport). One catch-all under /api/desk judges, in this order and no
// other: the raw target, the bearer, its audience, its scope, Active membership (the body is unread), the body's
// shape, the {mind} lookup, the door. Every refusal is a contract document. Bifrost holds no fork state: it passes
// Huginn's outcome through, attaches the current card to a stale_fork answer, and says `unavailable` when the door
// cannot confirm an outcome.
public static class DeskEndpoints
{
public const string RoutePrefix = "/api/desk";
public const string Audience = "bifrost-desk";
public const string ReadScope = "desk.read";
public const string AnswerScope = "desk.answer";
public const string CorsPolicy = "DeskCors";
public static IServiceCollection AddBifrostDesk(this IServiceCollection services)
{
services.AddSingleton(provider => new DeskMindRoutes(provider.GetRequiredService<IOptions<DeskOptions>>().Value.Minds));
services.AddCors();
// Exactly the configured origin, no credentials, and only on the routes that require this policy.
services
.AddOptions<CorsOptions>()
.Configure<IOptions<DeskOptions>>((cors, desk) =>
cors.AddPolicy(
CorsPolicy,
policy => policy
.WithOrigins(desk.Value.AllowedOrigin)
.WithMethods("GET", "POST")
.WithHeaders(HeaderNames.Authorization, HeaderNames.ContentType)));
return services;
}
public static IEndpointConventionBuilder MapDesk(this IEndpointRouteBuilder app)
{
// A configured desk with no credential or door port would serve nothing true: stop startup instead.
app.ServiceProvider.GetRequiredService<IDeskCredential>();
app.ServiceProvider.GetRequiredService<IDeskDoor>();
return app.MapMethods(RoutePrefix + "/{**rest}", ["GET", "POST"], HandleAsync)
.RequireCors(CorsPolicy)
.AllowAnonymous();
}
private static async Task HandleAsync(
HttpContext http,
IDeskCredential credential,
IDeskDoor door,
DeskMindRoutes minds,
IOptions<DeskOptions> options,
BifrostDbContext db)
{
var cancellationToken = http.RequestAborted;
var desk = options.Value;
http.Response.Headers.Append(HeaderNames.Vary, "Origin");
http.Response.Headers.CacheControl = "no-store";
// 1. The raw target, never the decoded Request.Path.
var rawTarget = http.Features.Get<Microsoft.AspNetCore.Http.Features.IHttpRequestFeature>()?.RawTarget ?? string.Empty;
var route = DeskRoute.Parse(http.Request.Method, rawTarget);
if (route is null)
{
await WriteRefusalAsync(http, DeskRefusals.ForkUnknown);
return;
}
// 2-4. Bearer, audience, scope.
var token = await credential.AuthenticateAsync(http, cancellationToken);
if (token is null || !string.Equals(token.Audience, Audience, StringComparison.Ordinal))
{
await WriteRefusalAsync(http, DeskRefusals.Unauthenticated);
return;
}
var scope = route.Kind == DeskRouteKind.Answer ? AnswerScope : ReadScope;
if (!token.Scopes.Contains(scope, StringComparer.Ordinal))
{
await WriteRefusalAsync(http, DeskRefusals.InsufficientScope);
return;
}
// 5. Active membership, re-read now; the body has not been touched.
var accountId = await OidcEndpoints.ActiveMemberIdAsync(db, token.Subject, cancellationToken);
if (accountId is null)
{
await WriteRefusalAsync(http, DeskRefusals.Forbidden);
return;
}
// 6. The body's shape. The bytes read are the bytes forwarded.
byte[] body = [];
if (route.Kind == DeskRouteKind.Answer)
{
var read = await ReadBodyAsync(http.Request, desk.MaxBodyBytes, cancellationToken);
var verdict = read is null
? DeskBodyVerdict.Refuse(DeskRefusals.BodyInvalid)
: DeskBodyShape.Judge(read, desk.MaxBodyBytes);
if (verdict.RefusalCode is not null)
{
await WriteRefusalAsync(http, verdict.RefusalCode);
return;
}
body = read!;
}
// 7. The {mind} lookup: a miss is the same document as an unknown fork.
var mind = minds.Resolve(route.RawMind);
if (mind is null)
{
await WriteRefusalAsync(http, DeskRefusals.ForkUnknown);
return;
}
// 8-10. The door.
var subject = accountId.Value.ToString("D");
var context = new DeskDoorContext(subject, desk.Attribution.AuthorityOf(subject), mind.Deciders);
try
{
var outcome = await door.AnswerAsync(mind.Socket, context, body, cancellationToken);
switch (outcome)
{
case DeskAnswerOutcome.NotStored(var code) when DoorMayRefuse(code):
await WriteRefusalAsync(http, code);
return;
case DeskAnswerOutcome.Answered(var result) when DeskStatus.Of(result) is { } status:
if (string.Equals(result.Answer.Code, DeskStatus.StaleFork, StringComparison.Ordinal))
{
var current = await door.ReadAsync(mind.Socket, context, result.Answer.ForkId, cancellationToken);
if (current is not DeskReadOutcome.Found(var entry))
{
await WriteUnavailableAsync(http, desk);
return;
}
result = result with { Card = entry.Card };
}
await WriteJsonAsync(http, status, result);
return;
default:
await WriteUnavailableAsync(http, desk);
return;
}
}
catch (Exception) when (!cancellationToken.IsCancellationRequested)
{
await WriteUnavailableAsync(http, desk);
}
}
// Codes the door may hand back for nothing stored. The three the edge alone judges are never the door's.
private static bool DoorMayRefuse(string code) =>
code is not (DeskRefusals.Unauthenticated or DeskRefusals.InsufficientScope or DeskRefusals.Forbidden) &&
DeskRefusals.IsKnown(code);
// At most `cap` bytes. Null when the body is longer, judged by Content-Length first and then while reading.
private static async Task<byte[]?> ReadBodyAsync(HttpRequest request, int cap, CancellationToken cancellationToken)
{
if (request.ContentLength > cap)
{
return null;
}
using var buffer = new MemoryStream();
var chunk = new byte[4096];
int count;
while ((count = await request.Body.ReadAsync(chunk, cancellationToken)) > 0)
{
if (buffer.Length + count > cap)
{
return null;
}
buffer.Write(chunk, 0, count);
}
return buffer.ToArray();
}
private static Task WriteRefusalAsync(HttpContext http, string code) =>
WriteJsonAsync(http, DeskRefusals.StatusOf(code), DeskRefusals.Document(code));
private static async Task WriteUnavailableAsync(HttpContext http, DeskOptions desk)
{
http.Response.Headers.RetryAfter = desk.RetryAfterSeconds.ToString(System.Globalization.CultureInfo.InvariantCulture);
await WriteJsonAsync(http, DeskUnavailables.Status, DeskUnavailables.Document(desk.RetryAfterSeconds));
}
private static async Task WriteJsonAsync<T>(HttpContext http, int status, T document)
{
http.Response.StatusCode = status;
http.Response.ContentType = "application/json; charset=utf-8";
await JsonSerializer.SerializeAsync(http.Response.Body, document, DeskJson.Options, http.RequestAborted);
}
}
// The one place a stored outcome becomes an HTTP status. fork-card.md prose says 200 for any stored outcome; the
// vectors (results[].httpStatus) say a Refused answer carries its refusal's status, and the vectors are the check.
public static class DeskStatus
{
public const string StaleFork = "stale_fork";
private static readonly Dictionary<string, int> RefusedStatus = new(StringComparer.Ordinal)
{
[StaleFork] = 409,
["not_a_decider"] = 403,
["fork_closed"] = 409,
["choice_invalid"] = 400,
["bad_retraction"] = 400,
["already_retracted"] = 409,
};
// Null when the door sent an outcome or a refusal code this contract does not name: Bifrost then cannot vouch
// for it and says `unavailable`.
public static int? Of(AnswerResult result) => result.Answer.Outcome switch
{
"Counted" or "Revision" or "Retraction" => 200,
"Refused" when result.Answer.Code is { } code && RefusedStatus.TryGetValue(code, out var status) => status,
_ => null,
};
}
public enum DeskRouteKind
{
Answer,
}
public sealed record DeskRoute(DeskRouteKind Kind, string RawMind)
{
// The raw request target against the three routes. Segments stay percent-encoded: {mind} is decoded once, by
// the resolver, and nothing here normalises a dot segment. Any other shape is not a desk route.
public static DeskRoute? Parse(string method, string rawTarget)
{
var path = rawTarget;
var query = path.IndexOf('?');
if (query >= 0)
{
path = path[..query];
}
if (!path.StartsWith(DeskEndpoints.RoutePrefix + "/", StringComparison.Ordinal))
{
return null;
}
var segments = path[(DeskEndpoints.RoutePrefix.Length + 1)..].Split('/');
return method switch
{
"POST" when segments is [var mind, "answers"] => new DeskRoute(DeskRouteKind.Answer, mind),
_ => null,
};
}
}

View file

@ -26,7 +26,7 @@ public sealed class DeskMindRoutes
// One pass of percent-decoding. Uri.UnescapeDataString is not used: it leaves a bad escape in place and turns
// bytes that are not UTF-8 into U+FFFD instead of failing.
private static bool TryDecode(string raw, out string decoded)
internal static bool TryDecode(string raw, out string decoded)
{
decoded = string.Empty;
var bytes = new List<byte>(raw.Length);

View file

@ -0,0 +1,46 @@
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.
// 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.
public sealed record DeskToken(string? Subject, string? Audience, IReadOnlyCollection<string> Scopes);
public interface IDeskCredential
{
// Null when the request carries no valid bearer token.
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);
public abstract record DeskAnswerOutcome
{
// Huginn stored an outcome (Counted, Revision, Retraction or Refused) and returned its document.
public sealed record Answered(AnswerResult Result) : DeskAnswerOutcome;
// Nothing stored: Huginn refused with one of the contract's refusal codes.
public sealed record NotStored(string Code) : DeskAnswerOutcome;
}
public abstract record DeskReadOutcome
{
public sealed record Found(ForkEntry Entry) : DeskReadOutcome;
public sealed record NotFound(string Code) : DeskReadOutcome;
}
public interface IDeskDoor
{
// The body is exactly the bytes the client sent. Any exception means the outcome is not confirmed.
Task<DeskAnswerOutcome> AnswerAsync(
string socket, DeskDoorContext context, ReadOnlyMemory<byte> body, CancellationToken cancellationToken);
Task<DeskReadOutcome> ReadAsync(
string socket, DeskDoorContext context, string forkId, CancellationToken cancellationToken);
}

View file

@ -117,18 +117,22 @@ public static class OidcEndpoints
return Results.Json(claims);
}
/// <summary>The one reading of "this subject is still an Active member", shared by the token and userinfo endpoints and the logout reconciler.</summary>
internal static async Task<bool> IsStillActiveMemberAsync(BifrostDbContext db, string? subject, CancellationToken cancellationToken)
/// <summary>The one reading of "this subject is still an Active member", shared by the token and userinfo endpoints, the logout reconciler and the fork desk.</summary>
internal static async Task<bool> IsStillActiveMemberAsync(BifrostDbContext db, string? subject, CancellationToken cancellationToken) =>
await ActiveMemberIdAsync(db, subject, cancellationToken) is not null;
/// <summary>The account id of the subject when it is an Active member now, else null. Re-read on every call.</summary>
internal static async Task<Guid?> ActiveMemberIdAsync(BifrostDbContext db, string? subject, CancellationToken cancellationToken)
{
if (!Guid.TryParse(subject, out var accountId))
{
return false;
return null;
}
var account = await db.UserAccounts.AsNoTracking()
.Include(x => x.Membership)
.SingleOrDefaultAsync(x => x.Id == accountId, cancellationToken);
return new CurrentBifrostActor(true, account).IsActiveMember;
return new CurrentBifrostActor(true, account).IsActiveMember ? accountId : null;
}
private static IResult Deny(string error, string description) =>

View file

@ -121,13 +121,35 @@ public sealed class StartupConfigurationValidator(
// Checked in every environment: a bad Mind list or decider id would otherwise pass startup and misroute or
// mis-attribute answers. Account existence is a request-time question, not a startup one.
private static void ValidateDesk(DeskOptions desk, List<string> failures)
private void ValidateDesk(DeskOptions desk, List<string> failures)
{
if (!desk.IsConfigured)
{
if (configuration.GetSection(DeskOptions.SectionName).Exists())
{
failures.Add("Desk:Minds must list at least one Mind when the Desk section exists.");
}
return;
}
if (!Uri.TryCreate(desk.AllowedOrigin, UriKind.Absolute, out var origin) ||
origin.Scheme is not ("http" or "https") ||
!string.Equals(desk.AllowedOrigin, origin.GetLeftPart(UriPartial.Authority), StringComparison.Ordinal))
{
failures.Add("Desk:AllowedOrigin must be one http(s) origin: scheme, host and optional port, no path.");
}
if (desk.MaxBodyBytes < 1)
{
failures.Add("Desk:MaxBodyBytes must be at least 1.");
}
if (desk.RetryAfterSeconds < 1)
{
failures.Add("Desk:RetryAfterSeconds must be at least 1.");
}
var ids = new HashSet<string>(StringComparer.Ordinal);
for (var i = 0; i < desk.Minds.Count; i++)
{

View file

@ -7,6 +7,7 @@ using Bifrost.Web.Data;
using Bifrost.Web.Domain;
using Bifrost.Web.Features.Bridge;
using Bifrost.Web.Features.ConceptVotes;
using Bifrost.Web.Features.Desk;
using Bifrost.Web.Features.GitHub;
using Bifrost.Web.Features.Heimdall;
using Bifrost.Web.Features.Membership;
@ -256,6 +257,13 @@ if (oidcOptions is not null)
builder.Services.AddBifrostOidc(oidcOptions);
}
// The desk is mapped only when the Desk section exists, and a configured desk needs its credential and door ports.
var deskConfigured = builder.Configuration.GetSection(DeskOptions.SectionName).Exists();
if (deskConfigured)
{
builder.Services.AddBifrostDesk();
}
var app = builder.Build();
var appJsonSerializerOptions = CreateAppJsonSerializerOptions();
@ -303,6 +311,11 @@ if (oidcOptions is not null)
app.MapBifrostOidc();
}
if (deskConfigured)
{
app.MapDesk();
}
app.MapPost("/github/webhooks", async (
HttpRequest request,
GitHubWebhookService gitHubWebhookService,

View file

@ -0,0 +1,202 @@
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using Bifrost.Web.Features.Desk;
using Bifrost.Web.Tests.Support;
namespace Bifrost.Web.Tests;
public sealed class DeskAnswerTests
{
private static readonly string Socket = DeskWebApplicationFactory.SocketOf("gamecult-chat");
private static async Task<(DeskWebApplicationFactory Factory, string Token)> ArrangeAsync()
{
var factory = new DeskWebApplicationFactory();
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true);
return (factory, factory.Token(DeskWebApplicationFactory.Alice, "bifrost-desk", "desk.answer"));
}
private static AnswerResult Stale(string forkId) => new(
DeskSchemas.AnswerResult,
new DeskAnswerDocument(
DeskSchemas.DeskAnswer, "n1", forkId, "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);
[Fact]
public async Task Stale_fork_attaches_current_card_fresh_and_replayed()
{
var (factory, token) = await ArrangeAsync();
using (factory)
{
factory.Door.AnswerScript = (_, _, _) => new DeskAnswerOutcome.Answered(Stale(DeskReplay.ChatFork));
var target = DeskReplay.Target("answer", "gamecult-chat", null);
factory.Door.Forks[(Socket, DeskReplay.ChatFork)] = DeskReplay.Entry(DeskReplay.Card(DeskReplay.ChatFork, "first card"));
var fresh = await factory.SendAsync("POST", target, DeskReplay.AnswerBody(DeskReplay.ChatFork), token);
// The same stored answer is replayed after the card changed: nothing is stored for the card, so the reply carries the new one.
factory.Door.Forks[(Socket, DeskReplay.ChatFork)] = DeskReplay.Entry(DeskReplay.Card(DeskReplay.ChatFork, "second card"));
var replayed = await factory.SendAsync("POST", target, DeskReplay.AnswerBody(DeskReplay.ChatFork), token);
Assert.Equal(409, fresh.Status);
Assert.Equal("first card", (string?)JsonNode.Parse(fresh.Body)!["card"]!["title"]);
Assert.Equal(409, replayed.Status);
Assert.Equal("second card", (string?)JsonNode.Parse(replayed.Body)!["card"]!["title"]);
}
}
[Fact]
public async Task A_refusal_that_is_not_stale_carries_no_card_and_asks_no_read()
{
var (factory, token) = await ArrangeAsync();
using (factory)
{
var closed = Stale(DeskReplay.ChatFork);
factory.Door.AnswerScript = (_, _, _) => new DeskAnswerOutcome.Answered(closed with { Answer = closed.Answer with { Code = "fork_closed" } });
var reply = await factory.SendAsync("POST", DeskReplay.Target("answer", "gamecult-chat", null), DeskReplay.AnswerBody(DeskReplay.ChatFork), token);
Assert.Equal(409, reply.Status);
Assert.Null(JsonNode.Parse(reply.Body)!["card"]);
Assert.DoesNotContain(factory.Door.Calls, c => c.Kind == "read");
}
}
[Fact]
public async Task Card_read_failure_is_unavailable()
{
foreach (var failure in new[] { "throws", "refuses" })
{
var (factory, token) = await ArrangeAsync();
using (factory)
{
factory.Door.AnswerScript = (_, _, _) => new DeskAnswerOutcome.Answered(Stale(DeskReplay.ChatFork));
factory.Door.ThrowOnRead = failure == "throws";
// "refuses": no fork held, so the read answers fork_unknown
var reply = await factory.SendAsync("POST", DeskReplay.Target("answer", "gamecult-chat", null), DeskReplay.AnswerBody(DeskReplay.ChatFork), token);
Assert.True(503 == reply.Status, failure);
Assert.Equal("unavailable", (string?)JsonNode.Parse(reply.Body)!["code"]);
}
}
}
[Fact]
public async Task The_body_reaches_the_door_byte_for_byte()
{
var (factory, token) = await ArrangeAsync();
using (factory)
{
DeskReplay.HoldVectorForks(factory.Door);
// Whitespace, escapes and key spacing a re-serialiser would normalise.
var compact = Encoding.UTF8.GetString(DeskReplay.AnswerBody(DeskReplay.ChatFork));
var odd = compact.Replace("{", "{ \n\t", StringComparison.Ordinal).Replace(",\"", " ,\r\n \"", StringComparison.Ordinal)
.Replace("Keep", "\\u004beep", StringComparison.Ordinal) + " \n";
var bytes = Encoding.UTF8.GetBytes(odd);
var reply = await factory.SendAsync("POST", DeskReplay.Target("answer", "gamecult-chat", null), bytes, token);
Assert.Equal(200, reply.Status);
Assert.Equal(bytes, Assert.Single(factory.Door.Calls).Body);
}
}
[Fact]
public async Task The_door_context_comes_from_the_account_and_the_configuration_never_the_request()
{
var (factory, _) = await ArrangeAsync();
using (factory)
{
DeskReplay.HoldVectorForks(factory.Door);
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Steward, true);
var token = factory.Token(DeskWebApplicationFactory.Steward.ToUpperInvariant(), "bifrost-desk", "desk.answer");
// Query strings and headers that name a decider or an answerer change nothing.
var reply = await factory.SendAsync(
"POST", DeskReplay.Target("answer", "nibu", null) + "?deciders=" + DeskWebApplicationFactory.Member + "&answerer=x",
DeskReplay.AnswerBody("nibu:question:glow-order"), token,
configure: http => http.Request.Headers["X-Deciders"] = DeskWebApplicationFactory.Member);
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);
}
}
[Fact]
public async Task Body_is_judged_after_membership_and_before_the_mind()
{
var (factory, token) = await ArrangeAsync();
using (factory)
{
// A bad body for an unknown Mind is the body's refusal; a non-Active member's bad body is forbidden.
var bad = Encoding.UTF8.GetBytes("{\"answerer\":\"x\"}");
var reply = await factory.SendAsync("POST", DeskReplay.Target("answer", "ghost", null), bad, token);
Assert.Equal("body_forbidden_field", (string?)JsonNode.Parse(reply.Body)!["code"]);
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, false);
var forbidden = await factory.SendAsync("POST", DeskReplay.Target("answer", "ghost", null), bad, token);
Assert.Equal("forbidden", (string?)JsonNode.Parse(forbidden.Body)!["code"]);
Assert.Empty(factory.Door.Calls);
}
}
[Fact]
public async Task Membership_is_re_read_on_every_request()
{
var (factory, token) = await ArrangeAsync();
using (factory)
{
DeskReplay.HoldVectorForks(factory.Door);
var target = DeskReplay.Target("answer", "gamecult-chat", null);
Assert.Equal(200, (await factory.SendAsync("POST", target, DeskReplay.AnswerBody(DeskReplay.ChatFork), token)).Status);
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, false);
Assert.Equal(403, (await factory.SendAsync("POST", target, DeskReplay.AnswerBody(DeskReplay.ChatFork), token)).Status);
}
}
[Fact]
public async Task An_oversized_body_is_refused_before_it_is_read()
{
var (factory, token) = await ArrangeAsync();
using (factory)
{
var big = Encoding.UTF8.GetBytes("{\"x\":\"" + new string('a', 17 * 1024) + "\"}");
var reply = await factory.SendAsync("POST", DeskReplay.Target("answer", "gamecult-chat", null), big, token);
Assert.Equal("body_invalid", (string?)JsonNode.Parse(reply.Body)!["code"]);
// Chunked: no Content-Length to judge, so the cap applies while reading.
var chunked = await factory.SendAsync(
"POST", DeskReplay.Target("answer", "gamecult-chat", null), big, token,
configure: http => http.Request.ContentLength = null);
Assert.Equal("body_invalid", (string?)JsonNode.Parse(chunked.Body)!["code"]);
Assert.Empty(factory.Door.Calls);
}
}
[Fact]
public async Task A_door_that_sends_a_code_the_contract_does_not_name_is_unavailable()
{
var (factory, token) = await ArrangeAsync();
using (factory)
{
foreach (var script in new Func<DeskAnswerOutcome>[]
{
() => new DeskAnswerOutcome.NotStored("something_internal"),
() => new DeskAnswerOutcome.NotStored("forbidden"),
() => new DeskAnswerOutcome.Answered(Stale("x") with { Answer = Stale("x").Answer with { Code = "made_up" } }),
})
{
factory.Door.AnswerScript = (_, _, _) => script();
var reply = await factory.SendAsync("POST", DeskReplay.Target("answer", "gamecult-chat", null), DeskReplay.AnswerBody(DeskReplay.ChatFork), token);
Assert.Equal(503, reply.Status);
}
}
}
}

View file

@ -0,0 +1,48 @@
using System.Text.Json.Nodes;
using Bifrost.Web.Features.Desk;
using Bifrost.Web.Tests.Support;
namespace Bifrost.Web.Tests;
public sealed class DeskAuthCaseTests
{
[Fact]
public async Task Vector_auth_cases()
{
var cases = DeskVectors.Rows("transport", "authCases").ToList();
Assert.Equal(22, cases.Count);
foreach (var row in cases.Where(r => (string)r["route"]! == "answer"))
{
using var factory = new DeskWebApplicationFactory();
DeskReplay.HoldVectorForks(factory.Door);
factory.Door.Down = (bool)row["huginn_down"]!;
var token = await DeskReplay.TokenAsync(factory, row["token"]);
var reply = await factory.SendAsync(
"POST", DeskReplay.Target("answer", "gamecult-chat", DeskReplay.ChatFork),
DeskReplay.AnswerBody(DeskReplay.ChatFork), token);
var name = (string)row["name"]!;
var expect = (string)row["expect"]!;
var body = JsonNode.Parse(reply.Body);
switch (expect)
{
case "ok":
Assert.True(200 == reply.Status, $"{name}: {reply.Status} {reply.Text}");
Assert.Single(factory.Door.Calls);
break;
case "unavailable":
Assert.True(503 == reply.Status, $"{name}: {reply.Status}");
Assert.True(DeskVectors.SameShape(body, JsonNode.Parse(System.Text.Json.JsonSerializer.Serialize(DeskUnavailables.Document(30), DeskJson.Options))), name);
break;
default:
Assert.True(DeskRefusals.StatusOf(expect) == reply.Status, $"{name}: {reply.Status}");
Assert.True(DeskVectors.SameShape(body, JsonNode.Parse(System.Text.Json.JsonSerializer.Serialize(DeskRefusals.Document(expect), DeskJson.Options))), name);
// Auth, scope and membership are judged before Huginn is asked.
Assert.True(factory.Door.Calls.Count == 0, $"{name}: the door was called");
break;
}
}
}
}

View file

@ -0,0 +1,96 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using Bifrost.Web.Features.Desk;
using Bifrost.Web.Tests.Support;
namespace Bifrost.Web.Tests;
public sealed class DeskMindRouteTests
{
private static async Task<(DeskReply Reply, DeskWebApplicationFactory Factory)> RunAsync(JsonObject row)
{
var factory = new DeskWebApplicationFactory();
DeskReplay.HoldVectorForks(factory.Door);
var token = await DeskReplay.TokenAsync(factory, row["token"]);
var route = (string)row["route"]!;
var forkId = (string?)row["fork_id"];
var reply = await factory.SendAsync(
route == "answer" ? "POST" : "GET",
DeskReplay.Target(route, (string)row["mind"]!, forkId),
route == "answer" ? DeskReplay.AnswerBody(forkId!) : null,
token);
return (reply, factory);
}
[Fact]
public async Task Vector_route_cases()
{
var cases = DeskVectors.Rows("transport", "mindRoutes", "cases").ToList();
Assert.Equal(57, cases.Count);
foreach (var row in cases.Where(r => (string)r["route"]! == "answer"))
{
var name = (string)row["name"]!;
var (reply, factory) = await RunAsync(row);
using (factory)
{
Assert.True((int)row["httpStatus"]! == reply.Status, $"{name}: {reply.Status} {reply.Text}");
var expect = (string)row["expect"]!;
if (expect != "ok")
{
Assert.True(
DeskVectors.SameShape(JsonNode.Parse(reply.Body), JsonNode.Parse(JsonSerializer.Serialize(DeskRefusals.Document(expect), DeskJson.Options))),
name);
}
var opened = (string?)row["opened"];
if (opened is null)
{
Assert.True(factory.Door.Calls.Count == 0, $"{name}: opened {factory.Door.Calls.FirstOrDefault()?.Socket}");
}
else
{
var call = Assert.Single(factory.Door.Calls);
Assert.True(call.Socket.EndsWith("/" + opened, StringComparison.Ordinal), $"{name}: {call.Socket}");
}
var storedIn = (string?)row["stored_in"];
if (storedIn is null)
{
Assert.Empty(factory.Door.StoredIn);
}
else
{
Assert.Equal([DeskWebApplicationFactory.SocketOf(storedIn)], factory.Door.StoredIn);
}
}
}
}
[Fact]
public async Task Same_response_pairs_are_byte_equal()
{
var cases = DeskVectors.Rows("transport", "mindRoutes", "cases").ToDictionary(r => (string)r["name"]!);
var pairs = DeskVectors.Array("transport", "mindRoutes", "sameResponse");
Assert.Equal(4, pairs.Count);
foreach (var pair in pairs)
{
var first = cases[(string)pair![0]!];
var second = cases[(string)pair[1]!];
if ((string)first["route"]! != "answer" || (string)second["route"]! != "answer")
{
continue;
}
var (a, fa) = await RunAsync(first);
var (b, fb) = await RunAsync(second);
using (fa)
using (fb)
{
Assert.Equal(a.Status, b.Status);
Assert.Equal(a.Body, b.Body);
}
}
}
}

View file

@ -14,6 +14,7 @@ public sealed class DeskOptionsTests
private static Dictionary<string, string?> Good() => new()
{
["Desk:AllowedOrigin"] = "https://chat.gamecult.org",
["Desk:Minds:0:Id"] = "gamecult-chat",
["Desk:Minds:0:Socket"] = "/run/huginn/huginn-gamecult-chat.sock",
["Desk:Minds:0:Deciders:0"] = Alice,

View file

@ -0,0 +1,187 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using Bifrost.Web.Features.Desk;
using Bifrost.Web.Tests.Support;
namespace Bifrost.Web.Tests;
public sealed class DeskScenarioTests
{
private static readonly string Socket = DeskWebApplicationFactory.SocketOf("gamecult-chat");
private static readonly string[] ConfiguredDeciders = [DeskWebApplicationFactory.Alice, DeskWebApplicationFactory.Steward];
// 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.
private sealed record Counts(int Answers, int Edge, int Unavailable, int Skipped);
private static async Task<Counts> ReplayAsync(Action<string, DeskReply, DeskWebApplicationFactory, JsonObject>? observe = null)
{
int answers = 0, edge = 0, unavailable = 0, skipped = 0;
foreach (var scenario in DeskVectors.Rows("scenarios"))
{
using var factory = new DeskWebApplicationFactory();
var door = factory.Door;
door.Forks[(Socket, DeskReplay.ChatFork)] = DeskReplay.Entry(scenario["card"]!.Deserialize<ForkCard>(DeskJson.Options)!);
var scenarioName = (string)scenario["name"]!;
foreach (var step in scenario["steps"]!.AsArray().Select(s => s!.AsObject()))
{
var kind = (string)step["kind"]!;
if (kind == "matrix_event")
{
skipped++; // no Matrix ingress exists, so there is no route a Matrix event could take
continue;
}
if (kind == "card")
{
door.Forks[(Socket, DeskReplay.ChatFork)] = DeskReplay.Entry(step["card"]!.Deserialize<ForkCard>(DeskJson.Options)!);
continue;
}
var server = step["server"]!.AsObject();
var response = step["response"]!.AsObject();
var code = (string?)response["code"];
var answerer = (string?)server["answerer"];
var active = (bool?)server["active"] ?? true;
door.Calls.Clear();
door.StoredIn.Clear();
door.Down = false;
door.ThrowAfterStore = false;
door.AnswerScript = null;
if (answerer is not null)
{
await factory.EnsureAccountAsync(answerer, active);
}
var isDown = (bool?)server["door_down"] == true;
var stored = (bool?)server["stored"] == true;
if (isDown)
{
door.Down = !stored;
door.ThrowAfterStore = stored;
}
if (code is null)
{
var wire = response.Deserialize<AnswerResult>(DeskJson.Options)!;
// Huginn answers without the card; Bifrost attaches the current one for stale_fork.
door.AnswerScript = (_, _, _) => new DeskAnswerOutcome.Answered(wire with { Card = null });
}
else if (code is "request_id_conflict" or "fork_unknown")
{
door.AnswerScript = (_, _, _) => new DeskAnswerOutcome.NotStored(code);
}
var body = DeskVectors.Bytes(step["body"]!);
var token = answerer is null
? null
: factory.Token(answerer, "bifrost-desk", "desk.answer");
var reply = await factory.SendAsync("POST", DeskReplay.Target("answer", "gamecult-chat", null), body, token);
var label = $"{scenarioName}";
if (code == "unavailable")
{
unavailable++;
Assert.True(503 == reply.Status, $"{label}: {reply.Status}");
Assert.Equal("30", reply.Headers.RetryAfter.ToString());
Assert.Equal(stored ? [Socket] : [], door.StoredIn);
}
else if (code is not null && DeskReplay.IsEdgeRefusal(code))
{
edge++;
Assert.True(DeskRefusals.StatusOf(code) == reply.Status, $"{label}: {reply.Status}");
Assert.True(door.Calls.Count == 0, $"{label}: the door was called for an edge refusal");
}
else
{
answers++;
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(ConfiguredDeciders, call.Context.Deciders);
Assert.Equal(Socket, call.Socket);
}
Assert.True(
DeskVectors.SameShape(JsonNode.Parse(reply.Body), response),
$"{label}: response differs\n{reply.Text}\n{response.ToJsonString()}");
observe?.Invoke(scenarioName, reply, factory, step);
}
}
return new Counts(answers, edge, unavailable, skipped);
}
[Fact]
public async Task Every_scenario_answer_step_replays_through_http()
{
var counts = await ReplayAsync();
Assert.Equal(3, counts.Skipped);
Assert.Equal(2, counts.Unavailable);
Assert.Equal(15, counts.Edge);
Assert.Equal(100, counts.Answers);
}
[Fact]
public async Task Edge_refusals_never_call_the_door()
{
var edges = 0;
await ReplayAsync((_, reply, factory, step) =>
{
var code = (string?)step["response"]!["code"];
if (code is not null && DeskReplay.IsEdgeRefusal(code))
{
edges++;
Assert.Empty(factory.Door.Calls);
}
});
Assert.Equal(15, edges);
}
[Fact]
public async Task Answer_results_pass_through_unchanged()
{
var checkedResults = 0;
foreach (var row in DeskVectors.Rows("results"))
{
using var factory = new DeskWebApplicationFactory();
var message = row["message"]!.AsObject();
var answer = message["answer"]!.Deserialize<DeskAnswerDocument>(DeskJson.Options)!;
var result = message.Deserialize<AnswerResult>(DeskJson.Options)!;
factory.Door.Forks[(Socket, answer.ForkId)] = DeskReplay.Entry(
message["card"] is { } card ? card.Deserialize<ForkCard>(DeskJson.Options)! : DeskReplay.Card(answer.ForkId));
factory.Door.AnswerScript = (_, _, _) => new DeskAnswerOutcome.Answered(result with { Card = null });
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true);
var reply = await factory.SendAsync(
"POST", DeskReplay.Target("answer", "gamecult-chat", null), DeskReplay.AnswerBody(answer.ForkId),
factory.Token(DeskWebApplicationFactory.Alice, "bifrost-desk", "desk.answer"));
Assert.True((int)row["httpStatus"]! == reply.Status, $"{row["name"]}: {reply.Status}");
Assert.True(DeskVectors.SameShape(JsonNode.Parse(reply.Body), message), $"{row["name"]}");
checkedResults++;
}
Assert.Equal(6, checkedResults);
}
[Fact]
public async Task Unavailable_before_and_after_store()
{
// The scenarios hold both: door_down before the store, and door_down after it (the retry then replays).
var seen = new List<(bool Stored, int Status)>();
await ReplayAsync((_, reply, factory, step) =>
{
if ((string?)step["response"]!["code"] == "unavailable")
{
seen.Add(((bool?)step["server"]!["stored"] == true, reply.Status));
}
});
Assert.Equal([(false, 503), (true, 503)], seen.OrderBy(s => s.Stored).ToArray());
}
}

View file

@ -0,0 +1,149 @@
using System.Net.Sockets;
using System.Text;
using System.Text.Json.Nodes;
using Bifrost.Web.Features.Desk;
using Bifrost.Web.Tests.Support;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.Hosting.Server.Features;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace Bifrost.Web.Tests;
public sealed class DeskSurfaceTests
{
private static readonly string AnswerTarget = DeskReplay.Target("answer", "gamecult-chat", null);
[Fact]
public async Task Not_mapped_without_config()
{
using var factory = new DeskWebApplicationFactory(configured: false);
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true);
var reply = await factory.SendAsync(
"POST", AnswerTarget, DeskReplay.AnswerBody(DeskReplay.ChatFork),
factory.Token(DeskWebApplicationFactory.Alice, "bifrost-desk", "desk.answer"));
Assert.Equal(404, reply.Status);
Assert.Empty(factory.Door.Calls);
var routes = factory.Services.GetRequiredService<EndpointDataSource>().Endpoints.OfType<RouteEndpoint>();
Assert.DoesNotContain(routes, e => e.RoutePattern.RawText!.StartsWith("/api/desk", StringComparison.Ordinal));
}
[Fact]
public void A_configured_desk_without_its_ports_stops_startup()
{
using var factory = new DeskWebApplicationFactory(registerPorts: false);
var failure = Assert.ThrowsAny<Exception>(() => factory.CreateClient());
Assert.Contains("IDeskCredential", failure.ToString());
}
[Fact]
public async Task Cookie_session_does_not_authenticate()
{
using var factory = new DeskWebApplicationFactory();
DeskReplay.HoldVectorForks(factory.Door);
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true);
// The test host signs every request in as an Active-looking site member through the default scheme. The desk ignores it.
var reply = await factory.SendAsync("POST", AnswerTarget, DeskReplay.AnswerBody(DeskReplay.ChatFork));
var withCookie = await factory.SendAsync(
"POST", AnswerTarget, DeskReplay.AnswerBody(DeskReplay.ChatFork),
configure: http => http.Request.Headers.Cookie = "bifrost_session=anything");
Assert.Equal(401, reply.Status);
Assert.Equal(401, withCookie.Status);
Assert.Empty(factory.Door.Calls);
}
[Fact]
public async Task Cors_allows_exactly_the_configured_origin_without_credentials()
{
using var factory = new DeskWebApplicationFactory();
DeskReplay.HoldVectorForks(factory.Door);
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true);
var token = factory.Token(DeskWebApplicationFactory.Alice, "bifrost-desk", "desk.answer");
var allowed = await factory.SendAsync("POST", AnswerTarget, DeskReplay.AnswerBody(DeskReplay.ChatFork), token, DeskWebApplicationFactory.Origin);
var refused = await factory.SendAsync("POST", AnswerTarget, DeskReplay.AnswerBody(DeskReplay.ChatFork), token, "https://evil.example");
var lookalike = await factory.SendAsync("POST", AnswerTarget, DeskReplay.AnswerBody(DeskReplay.ChatFork), token, DeskWebApplicationFactory.Origin + ".evil.example");
var preflight = await factory.SendAsync("OPTIONS", AnswerTarget, null, null, DeskWebApplicationFactory.Origin, http =>
{
http.Request.Headers["Access-Control-Request-Method"] = "POST";
http.Request.Headers["Access-Control-Request-Headers"] = "authorization,content-type";
});
var elsewhere = await factory.SendAsync("GET", "/healthz", null, null, DeskWebApplicationFactory.Origin);
Assert.Equal(DeskWebApplicationFactory.Origin, allowed.Headers.AccessControlAllowOrigin.ToString());
Assert.False(allowed.Headers.ContainsKey("Access-Control-Allow-Credentials"));
Assert.Contains("Origin", allowed.Headers.Vary.ToString());
Assert.False(refused.Headers.ContainsKey("Access-Control-Allow-Origin"));
Assert.False(lookalike.Headers.ContainsKey("Access-Control-Allow-Origin"));
Assert.Equal(DeskWebApplicationFactory.Origin, preflight.Headers.AccessControlAllowOrigin.ToString());
Assert.False(preflight.Headers.ContainsKey("Access-Control-Allow-Credentials"));
Assert.Contains("POST", preflight.Headers.AccessControlAllowMethods.ToString());
Assert.False(elsewhere.Headers.ContainsKey("Access-Control-Allow-Origin"));
}
[Fact]
public async Task An_unrecognised_target_is_fork_unknown_and_opens_nothing()
{
using var factory = new DeskWebApplicationFactory();
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true);
var token = factory.Token(DeskWebApplicationFactory.Alice, "bifrost-desk", "desk.answer");
foreach (var target in new[]
{
"/api/desk", "/api/desk/", "/api/desk/gamecult-chat", "/api/desk/gamecult-chat/answers/", "/api/desk/a/b/answers",
"/api/desk/a/../gamecult-chat/answers", "/api/desk/./gamecult-chat/answers", "/api/desk//answers/x",
"/API/DESK/gamecult-chat/answers", "/api/deskx/gamecult-chat/answers", "http://localhost/api/desk/gamecult-chat/answers",
})
{
var reply = await factory.SendAsync("POST", target, DeskReplay.AnswerBody(DeskReplay.ChatFork), token);
Assert.True(reply.Status == 404, $"{target}: {reply.Status}");
}
Assert.Empty(factory.Door.Calls);
}
// Kestrel normalises Request.Path but leaves RawTarget as received. The desk parses RawTarget, so a dot
// segment can never be folded into a configured Mind's route.
[Fact]
public async Task Dot_segments_never_reach_the_route()
{
var builder = WebApplication.CreateBuilder();
builder.WebHost.UseUrls("http://127.0.0.1:0");
await using var app = builder.Build();
app.Run(async http =>
{
var raw = http.Features.Get<IHttpRequestFeature>()!.RawTarget;
var text = $"{http.Request.Path}|{raw}";
http.Response.ContentLength = text.Length;
await http.Response.WriteAsync(text);
});
await app.StartAsync();
var address = app.Services.GetRequiredService<IServer>().Features.Get<IServerAddressesFeature>()!.Addresses.First();
var port = new Uri(address).Port;
using var client = new TcpClient();
await client.ConnectAsync("127.0.0.1", port);
var stream = client.GetStream();
await stream.WriteAsync(Encoding.ASCII.GetBytes("GET /api/desk/ghost/../gamecult-chat/answers HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"));
using var reader = new StreamReader(stream, Encoding.ASCII);
var text = await reader.ReadToEndAsync();
var echoed = text[(text.LastIndexOf("\r\n\r\n", StringComparison.Ordinal) + 4)..].Trim();
var parts = echoed.Split('|');
Assert.Equal("/api/desk/gamecult-chat/answers", parts[0]);
Assert.Equal("/api/desk/ghost/../gamecult-chat/answers", parts[1]);
Assert.Null(DeskRoute.Parse("POST", parts[1]));
Assert.NotNull(DeskRoute.Parse("POST", parts[0]));
}
}

View file

@ -0,0 +1,75 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using Bifrost.Web.Features.Desk;
namespace Bifrost.Web.Tests.Support;
// Shared plumbing for replaying CultLib's transport vectors through HTTP.
public static class DeskReplay
{
public const string ChatFork = "gamecult-chat:question:slot-mark-consent";
private static JsonNode BaseCard => DeskVectors.Root["scenarios"]![0]!["card"]!;
public static ForkCard Card(string forkId, string title = "Slot mark consent")
{
var card = JsonNode.Parse(BaseCard.ToJsonString())!.AsObject();
card["fork_id"] = forkId;
card["title"] = title;
return card.Deserialize<ForkCard>(DeskJson.Options)!;
}
public static ForkEntry Entry(ForkCard card) => new(
"gamecult.fork.entry.v0",
card,
new ForkRecord("gamecult.fork.record.v0", card.ForkId, card.OptionsHash, null, []));
// Every fork the vectors say each Mind holds, on that Mind's socket.
public static void HoldVectorForks(FakeDeskDoor door)
{
var forks = DeskVectors.Root["transport"]!["mindRoutes"]!["forks"]!.AsObject();
foreach (var (mind, ids) in forks)
{
foreach (var id in ids!.AsArray())
{
door.Forks[(DeskWebApplicationFactory.SocketOf(mind), (string)id!)] = Entry(Card((string)id!));
}
}
}
public static byte[] AnswerBody(string forkId, string requestId = "00000001-0000-4000-8000-000000000001")
{
var body = JsonNode.Parse(DeskVectors.Root["scenarios"]![0]!["steps"]![0]!["body"]!.ToJsonString())!.AsObject();
body["fork_id"] = forkId;
body["request_id"] = requestId;
return DeskVectors.Bytes(body);
}
// The raw segment as it would travel in one path segment: a literal '/' cannot, so it is sent as %2F.
public static string SegmentOnWire(string rawMind) => rawMind.Replace("/", "%2F", StringComparison.Ordinal);
public static string Target(string route, string rawMind, string? forkId) => route switch
{
"answer" => $"/api/desk/{SegmentOnWire(rawMind)}/answers",
"list" => $"/api/desk/{SegmentOnWire(rawMind)}/forks",
"read" => $"/api/desk/{SegmentOnWire(rawMind)}/forks/{Uri.EscapeDataString(forkId!)}",
_ => throw new ArgumentOutOfRangeException(nameof(route)),
};
// Seeds the account the vector token stands for and returns the header value (null for no token).
public static async Task<string?> TokenAsync(DeskWebApplicationFactory factory, JsonNode? token)
{
if (token is null)
{
return null;
}
var active = (bool)token["active"]!;
var subject = active ? DeskWebApplicationFactory.Alice : DeskWebApplicationFactory.Member;
await factory.EnsureAccountAsync(subject, active);
return factory.Token(subject, (string)token["audience"]!, token["scopes"]!.AsArray().Select(s => (string)s!).ToArray());
}
public static bool IsEdgeRefusal(string code) =>
code is "unauthenticated" or "insufficient_scope" or "forbidden" or "body_forbidden_field" or "body_unknown_field" or "body_invalid";
}

View file

@ -0,0 +1,170 @@
using System.Text;
using Bifrost.Web.Data;
using Bifrost.Web.Domain;
using Bifrost.Web.Features.Desk;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
namespace Bifrost.Web.Tests.Support;
public sealed record DeskReply(int Status, string ContentType, byte[] Body, IHeaderDictionary Headers)
{
public string Text => Encoding.UTF8.GetString(Body);
}
// The app with the Desk section configured and the two ports faked. `Configuration` is the Desk section as
// key/value pairs; null leaves the Desk section out, `RegisterPorts` false leaves the ports unregistered.
public sealed class DeskWebApplicationFactory : TestWebApplicationFactory
{
public const string Alice = "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f";
public const string Steward = "9f8e7d6c-5b4a-4392-8170-0f1e2d3c4b5a";
public const string Member = "11111111-2222-4333-8444-555555555555";
public const string Origin = "https://chat.gamecult.org";
private readonly Dictionary<string, string?>? configuration;
private readonly bool registerPorts;
public DeskWebApplicationFactory(Dictionary<string, string?>? configuration = null, bool configured = true, bool registerPorts = true)
{
this.configuration = configured ? configuration ?? DefaultConfiguration() : null;
this.registerPorts = registerPorts;
}
public FakeDeskDoor Door { get; } = new();
public static Dictionary<string, string?> DefaultConfiguration()
{
var values = new Dictionary<string, string?>
{
["Desk:AllowedOrigin"] = Origin,
["Desk:RetryAfterSeconds"] = "30",
["Desk:Attribution:Operator"] = Alice,
["Desk:Attribution:Stewards:0"] = Steward,
};
var i = 0;
foreach (var mind in DeskVectors.Root["transport"]!["mindRoutes"]!["minds"]!.AsObject())
{
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;
i++;
}
return values;
}
public static string SocketOf(string mindId) =>
"/run/huginn/" + (string)DeskVectors.Root["transport"]!["mindRoutes"]!["minds"]![mindId]!;
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
base.ConfigureWebHost(builder);
if (configuration is not null)
{
foreach (var (key, value) in configuration)
{
builder.UseSetting(key, value);
}
}
if (registerPorts)
{
builder.ConfigureServices(services =>
{
services.AddSingleton<IDeskCredential, FakeDeskCredential>();
services.AddSingleton<IDeskDoor>(Door);
});
}
}
// Upserts the account with an Active (or Suspended) membership.
public async Task EnsureAccountAsync(string id, bool active)
{
using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
var guid = Guid.Parse(id);
var account = await db.UserAccounts.FindAsync(guid);
if (account is null)
{
account = new UserAccount { Id = guid, GitHubLogin = "desk-" + id[..8], NormalizedGitHubLogin = "desk-" + id[..8], BifrostIdentity = "desk-" + id, NormalizedBifrostIdentity = "desk-" + id };
db.UserAccounts.Add(account);
db.Memberships.Add(new Domain.Membership { UserAccountId = guid, Status = active ? MembershipStatus.Active : MembershipStatus.Suspended });
}
else
{
var membership = db.Memberships.Single(m => m.UserAccountId == guid);
membership.Status = active ? MembershipStatus.Active : MembershipStatus.Suspended;
}
await db.SaveChangesAsync();
}
public string Token(string? subject, string? audience, params string[] scopes) =>
FakeDeskCredential.Encode(subject, audience, scopes);
// What routing would see for a raw path. Kestrel refuses a NUL or a malformed escape before routing; the
// placeholder keeps the request inside /api/desk so the handler's own judgment is what the test observes.
private static PathString RoutedPath(string rawPath)
{
var schemeEnd = rawPath.IndexOf("://", StringComparison.Ordinal);
if (schemeEnd > 0)
{
var pathStart = rawPath.IndexOf('/', schemeEnd + 3);
rawPath = pathStart < 0 ? "/" : rawPath[pathStart..];
}
try
{
return PathString.FromUriComponent(rawPath);
}
catch (Exception)
{
return new PathString("/api/desk/_");
}
}
// Sends one request with the RAW target set exactly as given (TestServer's HttpClient sets none). `path` is
// what routing sees; production Kestrel would hand over the decoded path the same way.
public async Task<DeskReply> SendAsync(
string method, string rawTarget, byte[]? body = null, string? token = null, string? origin = null,
Action<HttpContext>? configure = null)
{
var context = await Server.SendAsync(http =>
{
http.Request.Method = method;
http.Request.Scheme = "https";
http.Request.Host = new HostString("localhost");
var query = rawTarget.IndexOf('?');
var rawPath = query >= 0 ? rawTarget[..query] : rawTarget;
http.Request.Path = RoutedPath(rawPath);
http.Request.QueryString = query >= 0 ? new QueryString(rawTarget[query..]) : QueryString.Empty;
http.Features.GetRequiredFeature<IHttpRequestFeature>().RawTarget = rawTarget;
if (token is not null)
{
http.Request.Headers[FakeDeskCredential.Header] = token;
}
if (origin is not null)
{
http.Request.Headers.Origin = origin;
}
if (body is not null)
{
http.Request.Body = new MemoryStream(body);
http.Request.ContentLength = body.Length;
http.Request.ContentType = "application/json";
}
configure?.Invoke(http);
});
using var buffer = new MemoryStream();
await context.Response.Body.CopyToAsync(buffer);
return new DeskReply(context.Response.StatusCode, context.Response.ContentType ?? string.Empty, buffer.ToArray(), context.Response.Headers);
}
}

View file

@ -0,0 +1,30 @@
using Microsoft.AspNetCore.Http;
using System.Text.Json;
using Bifrost.Web.Features.Desk;
namespace Bifrost.Web.Tests.Support;
// A header-driven stand-in for the bearer validation port. X-Test-Desk-Token carries {sub, aud, scopes}; no header
// means no valid token. It validates nothing: the desk's own judgments (audience, scope, membership) are the subject.
public sealed class FakeDeskCredential : IDeskCredential
{
public const string Header = "X-Test-Desk-Token";
public static string Encode(string? subject, string? audience, IEnumerable<string> scopes) =>
JsonSerializer.Serialize(new { sub = subject, aud = audience, scopes });
public ValueTask<DeskToken?> AuthenticateAsync(HttpContext context, CancellationToken cancellationToken)
{
if (!context.Request.Headers.TryGetValue(Header, out var raw))
{
return ValueTask.FromResult<DeskToken?>(null);
}
using var document = JsonDocument.Parse(raw.ToString());
var root = document.RootElement;
return ValueTask.FromResult<DeskToken?>(new DeskToken(
root.GetProperty("sub").GetString(),
root.GetProperty("aud").GetString(),
root.GetProperty("scopes").EnumerateArray().Select(s => s.GetString()!).ToArray()));
}
}

View file

@ -0,0 +1,99 @@
using System.Text.Json.Nodes;
using Bifrost.Web.Features.Desk;
namespace Bifrost.Web.Tests.Support;
public sealed record DeskDoorCall(string Kind, string Socket, DeskDoorContext Context, byte[]? Body, string? ForkId);
// The door as a test double: it records every call, holds the forks each socket's Mind holds, and by default stores
// an answer to a held fork as Counted. A test may script the outcome or make the door fail before or after storing.
public sealed class FakeDeskDoor : IDeskDoor
{
private readonly object gate = new();
public List<DeskDoorCall> Calls { get; } = [];
public List<string> StoredIn { get; } = [];
public Dictionary<(string Socket, string ForkId), ForkEntry> Forks { get; } = [];
public Func<string, DeskDoorContext, byte[], DeskAnswerOutcome>? AnswerScript { get; set; }
// Every call throws (Huginn is down).
public bool Down { get; set; }
// The answer is stored, then the door throws (a timeout after send).
public bool ThrowAfterStore { get; set; }
public bool ThrowOnRead { get; set; }
public Task<DeskAnswerOutcome> AnswerAsync(
string socket, DeskDoorContext context, ReadOnlyMemory<byte> body, CancellationToken cancellationToken)
{
var bytes = body.ToArray();
lock (gate)
{
Calls.Add(new DeskDoorCall("answer", socket, context, bytes, null));
}
if (Down)
{
throw new IOException("huginn is down");
}
var outcome = AnswerScript?.Invoke(socket, context, bytes) ?? HeldForkOutcome(socket, context, bytes);
if (outcome is DeskAnswerOutcome.Answered)
{
lock (gate)
{
StoredIn.Add(socket);
}
}
if (ThrowAfterStore)
{
throw new TimeoutException("the door timed out after send");
}
return Task.FromResult(outcome);
}
public Task<DeskReadOutcome> ReadAsync(
string socket, DeskDoorContext context, string forkId, CancellationToken cancellationToken)
{
lock (gate)
{
Calls.Add(new DeskDoorCall("read", socket, context, null, forkId));
}
if (Down || ThrowOnRead)
{
throw new IOException("huginn is down");
}
return Task.FromResult<DeskReadOutcome>(Forks.TryGetValue((socket, forkId), out var entry)
? new DeskReadOutcome.Found(entry)
: new DeskReadOutcome.NotFound(DeskRefusals.ForkUnknown));
}
private DeskAnswerOutcome HeldForkOutcome(string socket, DeskDoorContext context, byte[] body)
{
var request = JsonNode.Parse(body)!.AsObject();
var forkId = (string)request["fork_id"]!;
if (!Forks.ContainsKey((socket, forkId)))
{
return new DeskAnswerOutcome.NotStored(DeskRefusals.ForkUnknown);
}
var answer = request["answer"]?.AsObject();
return new DeskAnswerOutcome.Answered(new AnswerResult(
DeskSchemas.AnswerResult,
new DeskAnswerDocument(
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"]!),
null,
"n1"));
}
}