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:
parent
9382604442
commit
47db4f83f2
18 changed files with 1438 additions and 7 deletions
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
262
src/Bifrost.Web/Features/Desk/DeskEndpoints.cs
Normal file
262
src/Bifrost.Web/Features/Desk/DeskEndpoints.cs
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
46
src/Bifrost.Web/Features/Desk/DeskPorts.cs
Normal file
46
src/Bifrost.Web/Features/Desk/DeskPorts.cs
Normal 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);
|
||||
}
|
||||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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++)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
202
tests/Bifrost.Web.Tests/DeskAnswerTests.cs
Normal file
202
tests/Bifrost.Web.Tests/DeskAnswerTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
48
tests/Bifrost.Web.Tests/DeskAuthCaseTests.cs
Normal file
48
tests/Bifrost.Web.Tests/DeskAuthCaseTests.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
96
tests/Bifrost.Web.Tests/DeskMindRouteTests.cs
Normal file
96
tests/Bifrost.Web.Tests/DeskMindRouteTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
187
tests/Bifrost.Web.Tests/DeskScenarioTests.cs
Normal file
187
tests/Bifrost.Web.Tests/DeskScenarioTests.cs
Normal 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());
|
||||
}
|
||||
}
|
||||
149
tests/Bifrost.Web.Tests/DeskSurfaceTests.cs
Normal file
149
tests/Bifrost.Web.Tests/DeskSurfaceTests.cs
Normal 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]));
|
||||
}
|
||||
}
|
||||
75
tests/Bifrost.Web.Tests/Support/DeskReplay.cs
Normal file
75
tests/Bifrost.Web.Tests/Support/DeskReplay.cs
Normal 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";
|
||||
}
|
||||
170
tests/Bifrost.Web.Tests/Support/DeskWebApplicationFactory.cs
Normal file
170
tests/Bifrost.Web.Tests/Support/DeskWebApplicationFactory.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
30
tests/Bifrost.Web.Tests/Support/FakeDeskCredential.cs
Normal file
30
tests/Bifrost.Web.Tests/Support/FakeDeskCredential.cs
Normal 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()));
|
||||
}
|
||||
}
|
||||
99
tests/Bifrost.Web.Tests/Support/FakeDeskDoor.cs
Normal file
99
tests/Bifrost.Web.Tests/Support/FakeDeskDoor.cs
Normal 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"));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue