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:
parent
73c6f50440
commit
2211a17f48
7 changed files with 17582 additions and 0 deletions
270
src/Bifrost.Web/Features/Desk/DeskBodyShape.cs
Normal file
270
src/Bifrost.Web/Features/Desk/DeskBodyShape.cs
Normal 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();
|
||||
}
|
||||
143
src/Bifrost.Web/Features/Desk/DeskDocuments.cs
Normal file
143
src/Bifrost.Web/Features/Desk/DeskDocuments.cs
Normal 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);
|
||||
}
|
||||
267
tests/Bifrost.Web.Tests/DeskBodyShapeTests.cs
Normal file
267
tests/Bifrost.Web.Tests/DeskBodyShapeTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
67
tests/Bifrost.Web.Tests/DeskDocumentTests.cs
Normal file
67
tests/Bifrost.Web.Tests/DeskDocumentTests.cs
Normal 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"]!);
|
||||
}
|
||||
}
|
||||
}
|
||||
18
tests/Bifrost.Web.Tests/DeskVectorPinTests.cs
Normal file
18
tests/Bifrost.Web.Tests/DeskVectorPinTests.cs
Normal 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.");
|
||||
}
|
||||
}
|
||||
16767
tests/Bifrost.Web.Tests/Fixtures/fork-card-vectors.json
Normal file
16767
tests/Bifrost.Web.Tests/Fixtures/fork-card-vectors.json
Normal file
File diff suppressed because one or more lines are too long
50
tests/Bifrost.Web.Tests/Support/DeskVectors.cs
Normal file
50
tests/Bifrost.Web.Tests/Support/DeskVectors.cs
Normal 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());
|
||||
}
|
||||
Loading…
Reference in a new issue