diff --git a/src/Bifrost.Web/Configuration/DeskOptions.cs b/src/Bifrost.Web/Configuration/DeskOptions.cs index 42797f4..be36641 100644 --- a/src/Bifrost.Web/Configuration/DeskOptions.cs +++ b/src/Bifrost.Web/Configuration/DeskOptions.cs @@ -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; } diff --git a/src/Bifrost.Web/Features/Desk/DeskDocuments.cs b/src/Bifrost.Web/Features/Desk/DeskDocuments.cs index 54eaece..1c17490 100644 --- a/src/Bifrost.Web/Features/Desk/DeskDocuments.cs +++ b/src/Bifrost.Web/Features/Desk/DeskDocuments.cs @@ -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); diff --git a/src/Bifrost.Web/Features/Desk/DeskEndpoints.cs b/src/Bifrost.Web/Features/Desk/DeskEndpoints.cs new file mode 100644 index 0000000..127f50f --- /dev/null +++ b/src/Bifrost.Web/Features/Desk/DeskEndpoints.cs @@ -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>().Value.Minds)); + services.AddCors(); + // Exactly the configured origin, no credentials, and only on the routes that require this policy. + services + .AddOptions() + .Configure>((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(); + app.ServiceProvider.GetRequiredService(); + + 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 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()?.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 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(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 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, + }; + } +} diff --git a/src/Bifrost.Web/Features/Desk/DeskMindRoutes.cs b/src/Bifrost.Web/Features/Desk/DeskMindRoutes.cs index ddc10b9..c4f82d3 100644 --- a/src/Bifrost.Web/Features/Desk/DeskMindRoutes.cs +++ b/src/Bifrost.Web/Features/Desk/DeskMindRoutes.cs @@ -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(raw.Length); diff --git a/src/Bifrost.Web/Features/Desk/DeskPorts.cs b/src/Bifrost.Web/Features/Desk/DeskPorts.cs new file mode 100644 index 0000000..aa439b4 --- /dev/null +++ b/src/Bifrost.Web/Features/Desk/DeskPorts.cs @@ -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 Scopes); + +public interface IDeskCredential +{ + // Null when the request carries no valid bearer token. + 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); + +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 AnswerAsync( + string socket, DeskDoorContext context, ReadOnlyMemory body, CancellationToken cancellationToken); + + Task ReadAsync( + string socket, DeskDoorContext context, string forkId, CancellationToken cancellationToken); +} diff --git a/src/Bifrost.Web/Features/Oidc/OidcEndpoints.cs b/src/Bifrost.Web/Features/Oidc/OidcEndpoints.cs index b435347..35f0297 100644 --- a/src/Bifrost.Web/Features/Oidc/OidcEndpoints.cs +++ b/src/Bifrost.Web/Features/Oidc/OidcEndpoints.cs @@ -117,18 +117,22 @@ public static class OidcEndpoints return Results.Json(claims); } - /// The one reading of "this subject is still an Active member", shared by the token and userinfo endpoints and the logout reconciler. - internal static async Task IsStillActiveMemberAsync(BifrostDbContext db, string? subject, CancellationToken cancellationToken) + /// 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. + internal static async Task IsStillActiveMemberAsync(BifrostDbContext db, string? subject, CancellationToken cancellationToken) => + await ActiveMemberIdAsync(db, subject, cancellationToken) is not null; + + /// The account id of the subject when it is an Active member now, else null. Re-read on every call. + internal static async Task 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) => diff --git a/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs b/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs index ceddbb1..8903b13 100644 --- a/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs +++ b/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs @@ -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 failures) + private void ValidateDesk(DeskOptions desk, List 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(StringComparer.Ordinal); for (var i = 0; i < desk.Minds.Count; i++) { diff --git a/src/Bifrost.Web/Program.cs b/src/Bifrost.Web/Program.cs index ca31736..c19aa96 100644 --- a/src/Bifrost.Web/Program.cs +++ b/src/Bifrost.Web/Program.cs @@ -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, diff --git a/tests/Bifrost.Web.Tests/DeskAnswerTests.cs b/tests/Bifrost.Web.Tests/DeskAnswerTests.cs new file mode 100644 index 0000000..b3d99cf --- /dev/null +++ b/tests/Bifrost.Web.Tests/DeskAnswerTests.cs @@ -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[] + { + () => 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); + } + } + } +} diff --git a/tests/Bifrost.Web.Tests/DeskAuthCaseTests.cs b/tests/Bifrost.Web.Tests/DeskAuthCaseTests.cs new file mode 100644 index 0000000..c52a131 --- /dev/null +++ b/tests/Bifrost.Web.Tests/DeskAuthCaseTests.cs @@ -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; + } + } + } +} diff --git a/tests/Bifrost.Web.Tests/DeskMindRouteTests.cs b/tests/Bifrost.Web.Tests/DeskMindRouteTests.cs new file mode 100644 index 0000000..8198530 --- /dev/null +++ b/tests/Bifrost.Web.Tests/DeskMindRouteTests.cs @@ -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); + } + } + } +} diff --git a/tests/Bifrost.Web.Tests/DeskOptionsTests.cs b/tests/Bifrost.Web.Tests/DeskOptionsTests.cs index 8b20e04..048bfdc 100644 --- a/tests/Bifrost.Web.Tests/DeskOptionsTests.cs +++ b/tests/Bifrost.Web.Tests/DeskOptionsTests.cs @@ -14,6 +14,7 @@ public sealed class DeskOptionsTests private static Dictionary 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, diff --git a/tests/Bifrost.Web.Tests/DeskScenarioTests.cs b/tests/Bifrost.Web.Tests/DeskScenarioTests.cs new file mode 100644 index 0000000..e781636 --- /dev/null +++ b/tests/Bifrost.Web.Tests/DeskScenarioTests.cs @@ -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 ReplayAsync(Action? 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(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(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(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(DeskJson.Options)!; + var result = message.Deserialize(DeskJson.Options)!; + factory.Door.Forks[(Socket, answer.ForkId)] = DeskReplay.Entry( + message["card"] is { } card ? card.Deserialize(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()); + } +} diff --git a/tests/Bifrost.Web.Tests/DeskSurfaceTests.cs b/tests/Bifrost.Web.Tests/DeskSurfaceTests.cs new file mode 100644 index 0000000..e1e5364 --- /dev/null +++ b/tests/Bifrost.Web.Tests/DeskSurfaceTests.cs @@ -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().Endpoints.OfType(); + 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(() => 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()!.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().Features.Get()!.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])); + } +} diff --git a/tests/Bifrost.Web.Tests/Support/DeskReplay.cs b/tests/Bifrost.Web.Tests/Support/DeskReplay.cs new file mode 100644 index 0000000..92eba39 --- /dev/null +++ b/tests/Bifrost.Web.Tests/Support/DeskReplay.cs @@ -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(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 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"; +} diff --git a/tests/Bifrost.Web.Tests/Support/DeskWebApplicationFactory.cs b/tests/Bifrost.Web.Tests/Support/DeskWebApplicationFactory.cs new file mode 100644 index 0000000..5e14cfb --- /dev/null +++ b/tests/Bifrost.Web.Tests/Support/DeskWebApplicationFactory.cs @@ -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? configuration; + private readonly bool registerPorts; + + public DeskWebApplicationFactory(Dictionary? 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 DefaultConfiguration() + { + var values = new Dictionary + { + ["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(); + services.AddSingleton(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(); + 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 SendAsync( + string method, string rawTarget, byte[]? body = null, string? token = null, string? origin = null, + Action? 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().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); + } +} diff --git a/tests/Bifrost.Web.Tests/Support/FakeDeskCredential.cs b/tests/Bifrost.Web.Tests/Support/FakeDeskCredential.cs new file mode 100644 index 0000000..de2592b --- /dev/null +++ b/tests/Bifrost.Web.Tests/Support/FakeDeskCredential.cs @@ -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 scopes) => + JsonSerializer.Serialize(new { sub = subject, aud = audience, scopes }); + + public ValueTask AuthenticateAsync(HttpContext context, CancellationToken cancellationToken) + { + if (!context.Request.Headers.TryGetValue(Header, out var raw)) + { + return ValueTask.FromResult(null); + } + + using var document = JsonDocument.Parse(raw.ToString()); + var root = document.RootElement; + return ValueTask.FromResult(new DeskToken( + root.GetProperty("sub").GetString(), + root.GetProperty("aud").GetString(), + root.GetProperty("scopes").EnumerateArray().Select(s => s.GetString()!).ToArray())); + } +} diff --git a/tests/Bifrost.Web.Tests/Support/FakeDeskDoor.cs b/tests/Bifrost.Web.Tests/Support/FakeDeskDoor.cs new file mode 100644 index 0000000..524ca20 --- /dev/null +++ b/tests/Bifrost.Web.Tests/Support/FakeDeskDoor.cs @@ -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 Calls { get; } = []; + + public List StoredIn { get; } = []; + + public Dictionary<(string Socket, string ForkId), ForkEntry> Forks { get; } = []; + + public Func? 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 AnswerAsync( + string socket, DeskDoorContext context, ReadOnlyMemory 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 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(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")); + } +}