desk: fork-desk documents, body shape judge and CultLib vectors (H1)

Records for the CultLib fork-card contract (answer_request, refusal,
unavailable, card, desk_answer, answer_result), DeskBodyShape (cap, forbidden
keys at any depth, closed keys, types and formats, UTF-8 byte note limit) and
fork-card-vectors.json vendored from CultLib 6231ecad with a sha256 pin. Tests
replay the vector arrays row by row. Nothing is mapped yet.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Heimdall 2026-11-10 19:07:25 +00:00
commit 2211a17f48
7 changed files with 17582 additions and 0 deletions

View file

@ -0,0 +1,270 @@
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace Bifrost.Web.Features.Desk;
// The edge's judgment of an answer body (fork-card.md, answer_request, steps 1 and 2). It checks the shape only:
// whether the answer is legal (a retraction's target, the choice, the fork) is Huginn's. A body that passes is
// forwarded as the bytes received; the parsed request is returned for the door's context and for tests.
public sealed record DeskBodyVerdict(AnswerRequest? Request, string? RefusalCode)
{
public static DeskBodyVerdict Pass(AnswerRequest request) => new(request, null);
public static DeskBodyVerdict Refuse(string code) => new(null, code);
}
public static partial class DeskBodyShape
{
public const int MaxNoteBytes = 4000;
// Two keys the receiver alone stamps. Refused whole at any depth, never stripped.
private static readonly string[] ForbiddenKeys = ["answerer", "authority"];
private static readonly string[] TopKeys =
["schemaVersion", "fork_id", "options_hash", "answer", "retracted", "retracts", "answered_at", "request_id"];
private static readonly string[] AnswerKeys = ["choice", "note"];
private static readonly UTF8Encoding StrictUtf8 = new(false, true);
private static readonly JsonDocumentOptions ParseOptions = new() { MaxDepth = 32 };
public static DeskBodyVerdict Judge(ReadOnlySpan<byte> body, int maxBodyBytes)
{
if (body.Length > maxBodyBytes)
{
return DeskBodyVerdict.Refuse(DeskRefusals.BodyInvalid);
}
JsonDocument document;
try
{
document = JsonDocument.Parse(body.ToArray(), ParseOptions);
}
catch (JsonException)
{
return DeskBodyVerdict.Refuse(DeskRefusals.BodyInvalid);
}
using (document)
{
try
{
return JudgeParsed(document.RootElement);
}
catch (InvalidOperationException)
{
// A string the parser accepted but cannot decode: invalid UTF-8 or a lone surrogate.
return DeskBodyVerdict.Refuse(DeskRefusals.BodyInvalid);
}
}
}
private static DeskBodyVerdict JudgeParsed(JsonElement root)
{
if (HasForbiddenKey(root))
{
return DeskBodyVerdict.Refuse(DeskRefusals.BodyForbiddenField);
}
if (root.ValueKind != JsonValueKind.Object)
{
return DeskBodyVerdict.Refuse(DeskRefusals.BodyInvalid);
}
var top = Members(root);
if (top is null)
{
return DeskBodyVerdict.Refuse(DeskRefusals.BodyInvalid);
}
// Unknown keys, including inside the answer object, before any value is judged.
if (top.Keys.Any(key => !TopKeys.Contains(key, StringComparer.Ordinal)))
{
return DeskBodyVerdict.Refuse(DeskRefusals.BodyUnknownField);
}
Dictionary<string, JsonElement>? answerMembers = null;
if (top.TryGetValue("answer", out var answerElement) && answerElement.ValueKind == JsonValueKind.Object)
{
answerMembers = Members(answerElement);
if (answerMembers is not null &&
answerMembers.Keys.Any(key => !AnswerKeys.Contains(key, StringComparer.Ordinal)))
{
return DeskBodyVerdict.Refuse(DeskRefusals.BodyUnknownField);
}
}
return Shape(top, answerMembers);
}
private static DeskBodyVerdict Shape(Dictionary<string, JsonElement> top, Dictionary<string, JsonElement>? answerMembers)
{
var invalid = DeskBodyVerdict.Refuse(DeskRefusals.BodyInvalid);
// Map-mode: every key is written.
if (TopKeys.Any(key => !top.ContainsKey(key)))
{
return invalid;
}
if (!IsString(top["schemaVersion"], out var schemaVersion) || schemaVersion != DeskSchemas.AnswerRequest ||
!IsString(top["fork_id"], out var forkId) || forkId.Length == 0 ||
!IsString(top["options_hash"], out var optionsHash) || !Hex64().IsMatch(optionsHash) ||
!IsString(top["answered_at"], out var answeredAt) || !Instant().IsMatch(answeredAt) ||
!IsString(top["request_id"], out var requestId) || !RequestId().IsMatch(requestId))
{
return invalid;
}
var retractedElement = top["retracted"];
if (retractedElement.ValueKind is not (JsonValueKind.True or JsonValueKind.False))
{
return invalid;
}
var retracted = retractedElement.ValueKind == JsonValueKind.True;
string? retracts = null;
if (top["retracts"].ValueKind != JsonValueKind.Null)
{
if (!IsString(top["retracts"], out var target) || !AnswerId().IsMatch(target))
{
return invalid;
}
retracts = target;
}
AnswerObject? answer = null;
if (top["answer"].ValueKind != JsonValueKind.Null)
{
if (answerMembers is null || AnswerKeys.Any(key => !answerMembers.ContainsKey(key)))
{
return invalid;
}
string? choice = null;
if (answerMembers["choice"].ValueKind != JsonValueKind.Null)
{
if (!IsString(answerMembers["choice"], out var picked) || picked.Length == 0)
{
return invalid;
}
choice = picked;
}
string? note = null;
if (answerMembers["note"].ValueKind != JsonValueKind.Null)
{
if (!IsString(answerMembers["note"], out var words) || !IsWords(words))
{
return invalid;
}
note = words;
}
if (choice is null && note is null)
{
return invalid;
}
answer = new AnswerObject(choice, note);
}
// No answer and no retraction is no request. The illegal retraction forms (a target-less retraction, a
// retraction carrying an answer, a target on a plain answer) pass here: Huginn stores them as bad_retraction.
if (answer is null && !retracted)
{
return invalid;
}
return DeskBodyVerdict.Pass(new AnswerRequest(
schemaVersion, forkId, optionsHash, answer, retracted, retracts, answeredAt, requestId));
}
// A note is 1 to 4000 UTF-8 bytes and not whitespace only. Bytes, not characters or UTF-16 units.
private static bool IsWords(string value)
{
int bytes;
try
{
bytes = StrictUtf8.GetByteCount(value);
}
catch (EncoderFallbackException)
{
return false;
}
return bytes is >= 1 and <= MaxNoteBytes && !value.All(IsSchemaSpace);
}
// The whitespace set of the schema's `words` pattern.
private static bool IsSchemaSpace(char c) =>
c is (>= (char)0x09 and <= (char)0x0d) or (char)0x20 or (char)0x85 or (char)0xa0 or (char)0x1680
or (>= (char)0x2000 and <= (char)0x200a) or (char)0x2028 or (char)0x2029 or (char)0x202f or (char)0x205f or (char)0x3000;
private static bool IsString(JsonElement element, out string value)
{
value = string.Empty;
if (element.ValueKind != JsonValueKind.String)
{
return false;
}
value = element.GetString() ?? string.Empty;
return true;
}
// Object members by unescaped name; null when a name repeats, because the forwarded bytes would then mean
// different things to different parsers.
private static Dictionary<string, JsonElement>? Members(JsonElement element)
{
var members = new Dictionary<string, JsonElement>(StringComparer.Ordinal);
foreach (var property in element.EnumerateObject())
{
if (!members.TryAdd(property.Name, property.Value))
{
return null;
}
}
return members;
}
private static bool HasForbiddenKey(JsonElement element)
{
switch (element.ValueKind)
{
case JsonValueKind.Object:
foreach (var property in element.EnumerateObject())
{
if (ForbiddenKeys.Contains(property.Name, StringComparer.Ordinal) || HasForbiddenKey(property.Value))
{
return true;
}
}
return false;
case JsonValueKind.Array:
return element.EnumerateArray().Any(HasForbiddenKey);
default:
return false;
}
}
[GeneratedRegex(@"^[0-9a-f]{64}\z")]
private static partial Regex Hex64();
[GeneratedRegex(@"^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]{1,7})?Z\z")]
private static partial Regex Instant();
[GeneratedRegex(@"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z")]
private static partial Regex RequestId();
[GeneratedRegex(@"^n[1-9][0-9]*\z")]
private static partial Regex AnswerId();
}

View file

@ -0,0 +1,143 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Bifrost.Web.Features.Desk;
// The fork-desk documents Bifrost reads or writes, spelled as CultLib's contracts/cultnet/governance/fork-card.md
// (commit 6231ecad) spells them: snake_case keys, every key written (nil where absent), declared in the
// contract's key order. Bifrost authors only DeskRefusal and DeskUnavailable. AnswerRequest is the client's body;
// ForkCard, DeskAnswerDocument and AnswerResult are Huginn's documents, typed here so they pass through unchanged.
public static class DeskSchemas
{
public const string AnswerRequest = "gamecult.fork.answer_request.v0";
public const string AnswerResult = "gamecult.fork.answer_result.v0";
public const string Card = "gamecult.fork.card.v0";
public const string DeskAnswer = "gamecult.desk_answer.v0";
public const string Refusal = "gamecult.fork.refusal.v0";
public const string Unavailable = "gamecult.fork.unavailable.v0";
}
public static class DeskJson
{
// Defaults write nulls, which map-mode needs ("every key written").
public static readonly JsonSerializerOptions Options = new();
}
public sealed record AnswerObject(
[property: JsonPropertyName("choice")] string? Choice,
[property: JsonPropertyName("note")] string? Note);
public sealed record AnswerRequest(
[property: JsonPropertyName("schemaVersion")] string SchemaVersion,
[property: JsonPropertyName("fork_id")] string ForkId,
[property: JsonPropertyName("options_hash")] string OptionsHash,
[property: JsonPropertyName("answer")] AnswerObject? Answer,
[property: JsonPropertyName("retracted")] bool Retracted,
[property: JsonPropertyName("retracts")] string? Retracts,
[property: JsonPropertyName("answered_at")] string AnsweredAt,
[property: JsonPropertyName("request_id")] string RequestId);
public sealed record DeskRefusal(
[property: JsonPropertyName("schemaVersion")] string SchemaVersion,
[property: JsonPropertyName("code")] string Code,
[property: JsonPropertyName("error")] string Error);
public sealed record DeskUnavailable(
[property: JsonPropertyName("schemaVersion")] string SchemaVersion,
[property: JsonPropertyName("code")] string Code,
[property: JsonPropertyName("error")] string Error,
[property: JsonPropertyName("retryAfterSeconds")] int? RetryAfterSeconds);
public sealed record ForkOption(
[property: JsonPropertyName("id")] string Id,
[property: JsonPropertyName("text")] string Text);
public sealed record ForkRef(
[property: JsonPropertyName("kind")] string Kind,
[property: JsonPropertyName("id")] string Id);
public sealed record ForkCard(
[property: JsonPropertyName("schemaVersion")] string SchemaVersion,
[property: JsonPropertyName("fork_id")] string ForkId,
[property: JsonPropertyName("title")] string Title,
[property: JsonPropertyName("context")] string Context,
[property: JsonPropertyName("options")] ForkOption[] Options,
[property: JsonPropertyName("recommendation")] string Recommendation,
[property: JsonPropertyName("depends")] string[] Depends,
[property: JsonPropertyName("leans")] string? Leans,
[property: JsonPropertyName("flag")] string? Flag,
[property: JsonPropertyName("raised_in")] ForkRef? RaisedIn,
[property: JsonPropertyName("deciders")] string[] Deciders,
[property: JsonPropertyName("status")] string Status,
[property: JsonPropertyName("ruling")] string? Ruling,
[property: JsonPropertyName("ruled_from")] string[] RuledFrom,
[property: JsonPropertyName("order")] long Order,
[property: JsonPropertyName("options_hash")] string OptionsHash);
// One stored answer: Counted, Revision, Retraction or Refused, told apart by Outcome and Code.
public sealed record DeskAnswerDocument(
[property: JsonPropertyName("schemaVersion")] string SchemaVersion,
[property: JsonPropertyName("answer_id")] string AnswerId,
[property: JsonPropertyName("fork_id")] string ForkId,
[property: JsonPropertyName("outcome")] string Outcome,
[property: JsonPropertyName("of")] string? Of,
[property: JsonPropertyName("retracts")] string? Retracts,
[property: JsonPropertyName("code")] string? Code,
[property: JsonPropertyName("options_hash")] string OptionsHash,
[property: JsonPropertyName("choice")] string? Choice,
[property: JsonPropertyName("note")] string? Note,
[property: JsonPropertyName("answered_at")] string AnsweredAt,
[property: JsonPropertyName("received_at")] string ReceivedAt,
[property: JsonPropertyName("claim_in_future")] bool ClaimInFuture,
[property: JsonPropertyName("answerer")] string Answerer,
[property: JsonPropertyName("authority")] string Authority,
[property: JsonPropertyName("request_id")] string RequestId);
public sealed record AnswerResult(
[property: JsonPropertyName("schemaVersion")] string SchemaVersion,
[property: JsonPropertyName("answer")] DeskAnswerDocument Answer,
[property: JsonPropertyName("card")] ForkCard? Card,
[property: JsonPropertyName("counted_answer_id")] string? CountedAnswerId);
// The refusal codes Bifrost itself writes, each with its one status and message (vectors doorRefusals).
public static class DeskRefusals
{
public const string Unauthenticated = "unauthenticated";
public const string BodyForbiddenField = "body_forbidden_field";
public const string BodyUnknownField = "body_unknown_field";
public const string BodyInvalid = "body_invalid";
public const string ForkUnknown = "fork_unknown";
public const string InsufficientScope = "insufficient_scope";
public const string Forbidden = "forbidden";
public const string RequestInvalid = "request_invalid";
public const string RequestIdConflict = "request_id_conflict";
private static readonly Dictionary<string, (int Status, string Error)> Table = new(StringComparer.Ordinal)
{
[Unauthenticated] = (401, "unauthenticated: a verified session is required"),
[BodyForbiddenField] = (400, "body_forbidden_field: answerer and authority are set by the receiver"),
[BodyUnknownField] = (400, "body_unknown_field: the body is a closed shape"),
[BodyInvalid] = (400, "body_invalid: the body does not satisfy gamecult.fork.answer_request.v0"),
[ForkUnknown] = (404, "fork_unknown: no such fork"),
[InsufficientScope] = (403, "insufficient_scope: the token lacks the scope this route needs"),
[Forbidden] = (403, "forbidden: only Active members may use the desk"),
[RequestInvalid] = (400, "request_invalid: the request does not satisfy its document"),
[RequestIdConflict] = (409, "request_id_conflict: this request_id was used for a different body"),
};
public static int StatusOf(string code) => Table[code].Status;
public static DeskRefusal Document(string code) => new(DeskSchemas.Refusal, code, Table[code].Error);
}
public static class DeskUnavailables
{
public const int Status = 503;
public static DeskUnavailable Document(int? retryAfterSeconds) => new(
DeskSchemas.Unavailable,
"unavailable",
"unavailable: the outcome is not confirmed, try again with the same request_id",
retryAfterSeconds);
}

View file

@ -0,0 +1,267 @@
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 DeskBodyShapeTests
{
private const int Cap = 16 * 1024;
private static DeskBodyVerdict Judge(JsonNode body, int cap = Cap) => DeskBodyShape.Judge(DeskVectors.Bytes(body), cap);
private static DeskBodyVerdict Judge(string json, int cap = Cap) => DeskBodyShape.Judge(Encoding.UTF8.GetBytes(json), cap);
private static JsonObject ValidBody() =>
DeskVectors.Rows("request", "valid").First(row => (string)row["name"]! == "choice_and_note")["message"]!.DeepClone().AsObject();
private static string ExpectedCode(string name) => name switch
{
"answerer_key" or "authority_key" => DeskRefusals.BodyForbiddenField,
"answer_object_with_a_key" => DeskRefusals.BodyUnknownField,
_ => DeskRefusals.BodyInvalid,
};
public static IEnumerable<object[]> ValidRows() =>
DeskVectors.Rows("request", "valid").Select(row => new object[] { (string)row["name"]! });
public static IEnumerable<object[]> InvalidRows() =>
DeskVectors.Rows("request", "invalid").Select(row => new object[] { (string)row["name"]! });
[Theory]
[MemberData(nameof(ValidRows))]
public void Valid_request_vectors_pass(string name)
{
var message = DeskVectors.Rows("request", "valid").First(row => (string)row["name"]! == name)["message"]!;
var verdict = Judge(message);
Assert.Null(verdict.RefusalCode);
Assert.NotNull(verdict.Request);
// The parsed request is the document: same keys, same order, same values.
var echoed = JsonSerializer.SerializeToNode(verdict.Request, DeskJson.Options);
Assert.True(DeskVectors.SameShape(message, echoed), $"{name} did not round-trip: {echoed}");
}
[Theory]
[MemberData(nameof(InvalidRows))]
public void Invalid_request_vectors_refuse_with_their_code(string name)
{
var message = DeskVectors.Rows("request", "invalid").First(row => (string)row["name"]! == name)["message"]!;
var verdict = Judge(message);
Assert.Null(verdict.Request);
Assert.Equal(ExpectedCode(name), verdict.RefusalCode);
}
[Fact]
public void Forbidden_key_at_any_depth_beats_unknown_key()
{
foreach (var key in new[] { "answerer", "authority" })
{
var nested = ValidBody();
nested["answer"]!["detail"] = new JsonObject { ["deep"] = new JsonArray(new JsonObject { [key] = "x" }) };
nested["extra"] = 1;
Assert.Equal(DeskRefusals.BodyForbiddenField, Judge(nested).RefusalCode);
var top = ValidBody();
top["unknown"] = 1;
top[key] = "someone";
Assert.Equal(DeskRefusals.BodyForbiddenField, Judge(top).RefusalCode);
// Written with an escape: the name is the same once decoded, and still refused whole.
var escaped = DeskVectors.Bytes(ValidBody()).ToArray();
var text = Encoding.UTF8.GetString(escaped).TrimEnd('}') + $",\"{key[..3]}\\u{(int)key[3]:x4}{key[4..]}\":\"x\"}}";
Assert.Equal(DeskRefusals.BodyForbiddenField, Judge(text).RefusalCode);
}
// The key name is matched exactly: a near miss is only an unknown key.
var near = ValidBody();
near["Answerer"] = "x";
Assert.Equal(DeskRefusals.BodyUnknownField, Judge(near).RefusalCode);
}
[Fact]
public void Mind_and_deciders_in_the_body_are_unknown_fields()
{
foreach (var key in new[] { "mind", "deciders" })
{
var body = ValidBody();
body[key] = "x";
Assert.Equal(DeskRefusals.BodyUnknownField, Judge(body).RefusalCode);
}
}
[Fact]
public void Illegal_retraction_forms_reach_the_door()
{
var forms = new (string Answer, string Retracted, string Retracts)[]
{
("null", "true", "null"), // a retraction with no target
("{\"choice\":\"no\",\"note\":null}", "true", "\"n1\""), // a retraction carrying an answer
("{\"choice\":\"no\",\"note\":null}", "false", "\"n1\""), // a plain answer with a target
("null", "true", "\"n1\""), // the legal retraction
};
foreach (var (answer, retracted, retracts) in forms)
{
var body = ValidBody();
body["answer"] = JsonNode.Parse(answer);
body["retracted"] = JsonNode.Parse(retracted);
body["retracts"] = JsonNode.Parse(retracts);
var verdict = Judge(body);
Assert.True(verdict.RefusalCode is null, $"answer={answer} retracted={retracted} retracts={retracts}: {verdict.RefusalCode}");
Assert.NotNull(verdict.Request);
}
}
[Fact]
public void Neither_answer_nor_retraction_is_body_invalid()
{
foreach (var retracts in new[] { "null", "\"n1\"" })
{
var body = ValidBody();
body["answer"] = null;
body["retracted"] = false;
body["retracts"] = JsonNode.Parse(retracts);
Assert.Equal(DeskRefusals.BodyInvalid, Judge(body).RefusalCode);
}
}
[Fact]
public void Note_limits_are_utf8_bytes()
{
string Note(string text) => Judge(WithNote(text)).RefusalCode ?? "pass";
string Repeat(string unit, int count) => string.Concat(Enumerable.Repeat(unit, count));
var astral = char.ConvertFromUtf32(0x1F600); // 4 bytes
var combining = "e" + (char)0x0301; // 3 bytes
var twoByte = ((char)0xe9).ToString(); // 2 bytes
var spaces = new string(new[] { (char)9, (char)0xa0, (char)0x3000, (char)0x2028 });
Assert.Equal("pass", Note(new string('x', 4000)));
Assert.Equal(DeskRefusals.BodyInvalid, Note(new string('x', 4001)));
Assert.Equal("pass", Note(Repeat(astral, 1000)));
Assert.Equal(DeskRefusals.BodyInvalid, Note(Repeat(astral, 1001)));
Assert.Equal("pass", Note(Repeat(combining, 1333) + "x"));
Assert.Equal(DeskRefusals.BodyInvalid, Note(Repeat(combining, 1334)));
Assert.Equal(DeskRefusals.BodyInvalid, Note(Repeat(twoByte, 1999) + "xxx"));
Assert.Equal("pass", Note("x"));
Assert.Equal(DeskRefusals.BodyInvalid, Note(""));
Assert.Equal(DeskRefusals.BodyInvalid, Note(spaces));
Assert.Equal("pass", Note(" a "));
static JsonNode WithNote(string text)
{
var body = ValidBody();
body["answer"] = new JsonObject { ["choice"] = null, ["note"] = text };
return body;
}
}
[Fact]
public void Lone_surrogate_in_a_note_is_body_invalid()
{
var json = DeskVectors.Bytes(ValidBody()).ToArray();
var text = Encoding.UTF8.GetString(json).Replace("\"note\":\"", "\"note\":\"\\ud800");
Assert.Equal(DeskRefusals.BodyInvalid, Judge(text).RefusalCode);
}
[Fact]
public void Over_cap_refused_before_parse()
{
var body = ValidBody();
var bytes = DeskVectors.Bytes(body);
Assert.Null(DeskBodyShape.Judge(bytes, bytes.Length).RefusalCode);
Assert.Equal(DeskRefusals.BodyInvalid, DeskBodyShape.Judge(bytes, bytes.Length - 1).RefusalCode);
// Over the cap the forbidden key is never found, because the body is never parsed.
body["answerer"] = "x";
var forbidden = DeskVectors.Bytes(body);
Assert.Equal(DeskRefusals.BodyForbiddenField, DeskBodyShape.Judge(forbidden, forbidden.Length).RefusalCode);
Assert.Equal(DeskRefusals.BodyInvalid, DeskBodyShape.Judge(forbidden, forbidden.Length - 1).RefusalCode);
}
[Theory]
[InlineData("")]
[InlineData("not json")]
[InlineData("[]")]
[InlineData("null")]
[InlineData("\"text\"")]
[InlineData("{")]
public void Non_object_or_broken_json_is_body_invalid(string json) =>
Assert.Equal(DeskRefusals.BodyInvalid, Judge(json).RefusalCode);
[Fact]
public void Invalid_utf8_is_body_invalid()
{
var bytes = DeskVectors.Bytes(ValidBody());
var bad = bytes.Take(bytes.Length - 2).Concat(new byte[] { 0xff }).Concat(bytes.Skip(bytes.Length - 2)).ToArray();
Assert.Equal(DeskRefusals.BodyInvalid, DeskBodyShape.Judge(bad, Cap).RefusalCode);
}
[Fact]
public void Repeated_key_is_body_invalid()
{
var text = Encoding.UTF8.GetString(DeskVectors.Bytes(ValidBody())).TrimEnd('}') + ",\"fork_id\":\"other\"}";
Assert.Equal(DeskRefusals.BodyInvalid, Judge(text).RefusalCode);
var answer = Encoding.UTF8.GetString(DeskVectors.Bytes(ValidBody())).Replace("\"choice\":\"no\"", "\"choice\":\"no\",\"choice\":\"yes\"");
Assert.Equal(DeskRefusals.BodyInvalid, Judge(answer).RefusalCode);
}
[Fact]
public void Trailing_newline_does_not_rescue_a_bad_pattern()
{
var body = ValidBody();
body["request_id"] = ((string)body["request_id"]!) + "\n";
Assert.Equal(DeskRefusals.BodyInvalid, Judge(body).RefusalCode);
body = ValidBody();
body["options_hash"] = ((string)body["options_hash"]!) + "\n";
Assert.Equal(DeskRefusals.BodyInvalid, Judge(body).RefusalCode);
}
[Fact]
public void Wrong_schema_version_and_bad_retracts_are_body_invalid()
{
var body = ValidBody();
body["schemaVersion"] = "gamecult.fork.answer_request.v1";
Assert.Equal(DeskRefusals.BodyInvalid, Judge(body).RefusalCode);
foreach (var retracts in new[] { "n0", "n01", "x1", "n" })
{
body = ValidBody();
body["answer"] = null;
body["retracted"] = true;
body["retracts"] = retracts;
Assert.Equal(DeskRefusals.BodyInvalid, Judge(body).RefusalCode);
}
}
[Fact]
public void Missing_key_is_body_invalid_for_each_key()
{
foreach (var key in ValidBody().Select(p => p.Key).ToList())
{
var body = ValidBody();
body.Remove(key);
Assert.Equal(DeskRefusals.BodyInvalid, Judge(body).RefusalCode);
}
}
[Fact]
public void Answer_object_must_hold_both_keys()
{
var body = ValidBody();
body["answer"] = new JsonObject { ["choice"] = "no" };
Assert.Equal(DeskRefusals.BodyInvalid, Judge(body).RefusalCode);
}
}

View file

@ -0,0 +1,67 @@
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 DeskDocumentTests
{
private static JsonNode? Write<T>(T document) => JsonSerializer.SerializeToNode(document, DeskJson.Options);
[Fact]
public void Refusal_and_unavailable_documents_match_vectors()
{
var rows = DeskVectors.Rows("doorRefusals").ToList();
Assert.Equal(9, rows.Count);
foreach (var row in rows)
{
var code = (string)row["message"]!["code"]!;
Assert.True(DeskVectors.SameShape(row["message"], Write(DeskRefusals.Document(code))), $"refusal {code}");
Assert.Equal((int)row["httpStatus"]!, DeskRefusals.StatusOf(code));
}
var unavailable = DeskVectors.Rows("unavailable").ToList();
Assert.Equal(2, unavailable.Count);
foreach (var row in unavailable)
{
var retry = (int?)row["message"]!["retryAfterSeconds"];
Assert.True(DeskVectors.SameShape(row["message"], Write(DeskUnavailables.Document(retry))), (string)row["name"]!);
Assert.Equal(DeskUnavailables.Status, (int)row["httpStatus"]!);
}
}
[Fact]
public void Every_refusal_code_in_the_schema_has_a_document()
{
var codes = DeskVectors.Rows("doorRefusals").Select(row => (string)row["message"]!["code"]!).ToHashSet();
var written = typeof(DeskRefusals).GetFields().Where(f => f.IsLiteral).Select(f => (string)f.GetRawConstantValue()!).ToHashSet();
Assert.Equal(codes, written);
}
[Fact]
public void Answer_results_round_trip_with_their_spelling()
{
var rows = DeskVectors.Rows("results").ToList();
Assert.Equal(6, rows.Count);
foreach (var row in rows)
{
var message = row["message"]!;
var result = message.Deserialize<AnswerResult>(DeskJson.Options)!;
Assert.True(DeskVectors.SameShape(message, Write(result)), (string)row["name"]!);
}
}
[Fact]
public void Cards_round_trip_with_their_spelling()
{
var rows = DeskVectors.Rows("card", "valid").ToList();
Assert.Equal(4, rows.Count);
foreach (var row in rows)
{
var message = row["message"]!;
var card = message.Deserialize<ForkCard>(DeskJson.Options)!;
Assert.True(DeskVectors.SameShape(message, Write(card)), (string)row["name"]!);
}
}
}

View file

@ -0,0 +1,18 @@
using System.Security.Cryptography;
using Bifrost.Web.Tests.Support;
namespace Bifrost.Web.Tests;
public sealed class DeskVectorPinTests
{
[Fact]
public void Vectors_are_cultlib_6231ecad()
{
var sha = Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(DeskVectors.Path))).ToLowerInvariant();
Assert.True(
DeskVectors.FileSha256 == sha,
$"fork-card-vectors.json is not CultLib {DeskVectors.SourceCommit}:contracts/cultnet/governance/fork-card-vectors.json (sha256 {sha}). " +
"Re-vendor from that commit or move the pin on purpose, with the whole contract.");
}
}

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,50 @@
using System.Text.Json;
using System.Text.Json.Nodes;
namespace Bifrost.Web.Tests.Support;
// CultLib's fork-card vectors, vendored at commit 6231ecad5dc72dc75d858958fb8ec66c771a3b5e.
public static class DeskVectors
{
public const string SourceCommit = "6231ecad5dc72dc75d858958fb8ec66c771a3b5e";
public const string FileSha256 = "bf9444fbea753ce218b58989a896ff622927cedde376d1188526f9b8645d3588";
public static string Path => System.IO.Path.Combine(AppContext.BaseDirectory, "Fixtures", "fork-card-vectors.json");
public static JsonNode Root { get; } = JsonNode.Parse(File.ReadAllBytes(Path))!;
public static JsonArray Array(params string[] path)
{
JsonNode node = Root;
foreach (var step in path)
{
node = node[step]!;
}
return node.AsArray();
}
public static IEnumerable<JsonObject> Rows(params string[] path) => Array(path).Select(row => row!.AsObject());
// True when both are the same JSON with the same keys in the same order.
public static bool SameShape(JsonNode? left, JsonNode? right)
{
switch (left, right)
{
case (null, null):
return true;
case (JsonObject a, JsonObject b):
return a.Select(p => p.Key).SequenceEqual(b.Select(p => p.Key)) &&
a.All(p => SameShape(p.Value, b[p.Key]));
case (JsonArray a, JsonArray b):
return a.Count == b.Count && a.Zip(b).All(pair => SameShape(pair.First, pair.Second));
case (JsonValue, JsonValue):
return JsonNode.DeepEquals(left, right);
default:
return false;
}
}
public static byte[] Bytes(JsonNode node) => System.Text.Encoding.UTF8.GetBytes(node.ToJsonString());
}