Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Failing after 18m46s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Successful in 52s
648 lines
42 KiB
C#
648 lines
42 KiB
C#
#nullable enable
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Nodes;
|
|
using MessagePack;
|
|
using NUnit.Framework;
|
|
|
|
namespace GameCult.Networking.Tests
|
|
{
|
|
// contracts/cultnet/governance: the vote-API and fork-card contracts (vote-api.md, fork-card.md). The vectors are spec-authored
|
|
// (scripts/gen-governance-vectors.py); this suite re-encodes every message with MessagePack C#, must reproduce the bytes, judges
|
|
// every negative with the committed schema, and replays the fork scenarios through a reference judge written from fork-card.md.
|
|
public sealed class GovernanceVectorTests
|
|
{
|
|
private const string VoteSchema = "governance/vote-api.schema.json";
|
|
private const string ForkSchema = "governance/fork-card.schema.json";
|
|
|
|
private static string RepoRoot()
|
|
{
|
|
var directory = new DirectoryInfo(TestContext.CurrentContext.TestDirectory);
|
|
while (directory != null && !File.Exists(Path.Combine(directory.FullName, "CultLib.sln")))
|
|
directory = directory.Parent;
|
|
return directory?.FullName ?? throw new InvalidOperationException("CultLib.sln was not found above the test directory.");
|
|
}
|
|
|
|
private static JsonNode Load(string name) =>
|
|
JsonNode.Parse(File.ReadAllText(Path.Combine(RepoRoot(), "contracts", "cultnet", "governance", name)))!;
|
|
|
|
private static readonly JsonNode Vote = Load("vote-api-vectors.json");
|
|
private static readonly JsonNode Fork = Load("fork-card-vectors.json");
|
|
private static MiniJsonSchemaValidator Validator() => new(Path.Combine(RepoRoot(), "contracts", "cultnet"));
|
|
|
|
private static List<string> Errors(string file, string def, JsonNode? value)
|
|
{
|
|
var element = JsonSerializer.SerializeToElement(value);
|
|
return Validator().Validate(element, $"{file}#/$defs/{def}");
|
|
}
|
|
|
|
// MessagePack C# writes the message in the order the keys appear: shortest ints, shortest strings.
|
|
private static void Write(ref MessagePackWriter w, JsonNode? n)
|
|
{
|
|
switch (n)
|
|
{
|
|
case null: w.WriteNil(); break;
|
|
case JsonObject o:
|
|
w.WriteMapHeader(o.Count);
|
|
foreach (var kv in o) { w.Write(kv.Key); Write(ref w, kv.Value); }
|
|
break;
|
|
case JsonArray a:
|
|
w.WriteArrayHeader(a.Count);
|
|
foreach (var item in a) Write(ref w, item);
|
|
break;
|
|
case JsonValue v when v.TryGetValue<bool>(out var b): w.Write(b); break;
|
|
case JsonValue v when v.TryGetValue<int>(out var i): w.Write(i); break;
|
|
case JsonValue v when v.TryGetValue<long>(out var l): w.Write(l); break;
|
|
case JsonValue v: w.Write(v.GetValue<string>()); break;
|
|
}
|
|
}
|
|
|
|
private static string HexOf(JsonNode? message)
|
|
{
|
|
var buffer = new System.Buffers.ArrayBufferWriter<byte>();
|
|
var writer = new MessagePackWriter(buffer);
|
|
Write(ref writer, message);
|
|
writer.Flush();
|
|
return Convert.ToHexString(buffer.WrittenSpan);
|
|
}
|
|
|
|
private static string Sha(byte[] bytes) => Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
|
|
private static string S(JsonNode? n) => n!.GetValue<string>();
|
|
private static JsonNode? Clone(JsonNode? n) => n is null ? null : JsonNode.Parse(n.ToJsonString());
|
|
|
|
// Rules a JSON Schema cannot say: vote-api.md counts a reason in UTF-16 code units (as Bifrost does); maxLength counts code points.
|
|
private static IEnumerable<string> VoteRuleErrors(string def, JsonNode? message)
|
|
{
|
|
if (def == "cast" && message?["ballot"] is JsonNode ballot && S(ballot["reason"]).Length > 280) yield return "reason is over 280 UTF-16 code units";
|
|
}
|
|
|
|
[Test]
|
|
public void VoteApi_ValidDocuments_ValidateEncodeToTheVectorBytesAndDecodeBack()
|
|
{
|
|
foreach (var v in Vote["valid"]!.AsArray())
|
|
{
|
|
var name = S(v!["name"]);
|
|
Assert.That(Errors(VoteSchema, S(v["def"]), v["message"]), Is.Empty, name);
|
|
Assert.That(VoteRuleErrors(S(v["def"]), v["message"]), Is.Empty, name);
|
|
Assert.That(HexOf(v["message"]), Is.EqualTo(S(v["messagePackHex"])), name);
|
|
var decoded = JsonNode.Parse(MessagePackSerializer.ConvertToJson(Convert.FromHexString(S(v["messagePackHex"]))))!;
|
|
Assert.That(JsonNode.DeepEquals(decoded, v["message"]), Is.True, name);
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
public void VoteApi_EveryNegative_IsRefusedBySchemaAndKeepsItsBytes()
|
|
{
|
|
foreach (var v in Vote["invalid"]!.AsArray())
|
|
{
|
|
var name = S(v!["name"]);
|
|
Assert.That(Errors(VoteSchema, S(v["def"]), v["message"]).Count > 0 || VoteRuleErrors(S(v["def"]), v["message"]).Any(), Is.True, $"{name}: {S(v["why"])}");
|
|
Assert.That(HexOf(v["message"]), Is.EqualTo(S(v["messagePackHex"])), name);
|
|
}
|
|
}
|
|
|
|
private static byte[] CommitmentBytes(JsonNode b, string voter) => Encoding.UTF8.GetBytes(
|
|
$"gamecult.sealed_ballot.v0\n{S(b["motionId"])}\n{voter}\n{S(b["persona"])}\n{b["round"]!.GetValue<long>()}\n{S(b["candidate"])}\n{S(b["reason"])}\n{S(b["salt"])}");
|
|
|
|
[Test]
|
|
public void VoteApi_Commitment_IsSha256OfTheCanonicalBytes_AndRevealMatchesExactlyWhenItHashesToIt()
|
|
{
|
|
foreach (var c in Vote["commitment"]!.AsArray())
|
|
{
|
|
var bytes = CommitmentBytes(c!["ballot"]!, S(c["voterUserAccountId"]));
|
|
Assert.That(Convert.ToHexString(bytes), Is.EqualTo(S(c["canonicalHex"])), S(c["name"]));
|
|
Assert.That(Sha(bytes), Is.EqualTo(S(c["sha256"])), S(c["name"]));
|
|
}
|
|
foreach (var p in Vote["pairs"]!.AsArray())
|
|
{
|
|
var digest = Sha(CommitmentBytes(p!["reveal"]!["ballot"]!, S(p["voterUserAccountId"])));
|
|
Assert.That(digest == S(p["commit"]!["commitment"]), Is.EqualTo(p["matches"]!.GetValue<bool>()), S(p["name"]));
|
|
Assert.That(Errors(VoteSchema, "cast", p["commit"]), Is.Empty);
|
|
Assert.That(Errors(VoteSchema, "cast", p["reveal"]), Is.Empty);
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
public void VoteApi_EachRefusalCode_HasAVectorAStatusAndTheVectorBytes()
|
|
{
|
|
var schema = JsonNode.Parse(File.ReadAllText(Path.Combine(RepoRoot(), "contracts", "cultnet", VoteSchema)))!;
|
|
var codes = schema["$defs"]!["refusal"]!["properties"]!["code"]!["enum"]!.AsArray().Select(S).OrderBy(x => x, StringComparer.Ordinal);
|
|
var vectors = Vote["refusals"]!.AsArray();
|
|
Assert.That(vectors.Select(r => S(r!["message"]!["code"])).OrderBy(x => x, StringComparer.Ordinal), Is.EqualTo(codes));
|
|
foreach (var r in vectors)
|
|
{
|
|
Assert.That(Errors(VoteSchema, "refusal", r!["message"]), Is.Empty, S(r["name"]));
|
|
Assert.That(HexOf(r["message"]), Is.EqualTo(S(r["messagePackHex"])), S(r["name"]));
|
|
Assert.That(r["httpStatus"]!.GetValue<int>(), Is.GreaterThanOrEqualTo(400));
|
|
}
|
|
Assert.That(vectors.Single(r => S(r!["name"]) == "rate_limited")!["message"]!["retryAfterSeconds"]!.GetValue<int>(), Is.GreaterThanOrEqualTo(1));
|
|
}
|
|
|
|
// ---- fork card (rule source: /opt/commons/handoff/fork-desk-answer-binding-20261110.md) ----
|
|
private static string Jstr(string s)
|
|
{
|
|
var sb = new StringBuilder("\"");
|
|
foreach (var rune in s.EnumerateRunes())
|
|
{
|
|
var o = rune.Value;
|
|
sb.Append(o switch
|
|
{
|
|
'"' => "\\\"", '\\' => "\\\\", 8 => "\\b", 9 => "\\t", 10 => "\\n", 12 => "\\f", 13 => "\\r",
|
|
< 0x20 => "\\u" + o.ToString("x4"),
|
|
_ => rune.ToString()
|
|
});
|
|
}
|
|
return sb.Append('"').ToString();
|
|
}
|
|
|
|
// RFC 8785 over the five shown members, every string NFC-normalised first; keys sorted; no numbers in the member set.
|
|
private static string N(string s) => s.Normalize(NormalizationForm.FormC);
|
|
private static byte[] Canonical(JsonNode c)
|
|
{
|
|
var options = string.Join(",", c["options"]!.AsArray().Select(o => $"{{\"id\":{Jstr(N(S(o!["id"])))},\"text\":{Jstr(N(S(o["text"])))}}}"));
|
|
var depends = string.Join(",", c["depends"]!.AsArray().Select(d => Jstr(N(S(d)))));
|
|
return Encoding.UTF8.GetBytes($"{{\"context\":{Jstr(N(S(c["context"])))},\"depends\":[{depends}],\"options\":[{options}],\"recommendation\":{Jstr(N(S(c["recommendation"])))},\"title\":{Jstr(N(S(c["title"])))}}}");
|
|
}
|
|
private static string HashOf(JsonNode c) => Sha(Canonical(c));
|
|
|
|
private static IEnumerable<string> CardRuleErrors(JsonNode c)
|
|
{
|
|
var ids = c["options"]!.AsArray().Select(o => S(o!["id"])).ToList();
|
|
if (ids.Distinct().Count() != ids.Count) yield return "duplicate option ids";
|
|
if (!ids.Contains(S(c["recommendation"]))) yield return "recommendation is not an option";
|
|
if (c["leans"] is not null && !ids.Contains(S(c["leans"]))) yield return "leans is not an option";
|
|
var deciders = c["deciders"]!.AsArray().Select(S).ToList();
|
|
if (deciders.Distinct().Count() != deciders.Count) yield return "duplicate deciders";
|
|
if (c["depends"]!.AsArray().Any(d => Encoding.UTF8.GetByteCount(S(d)) > 1000)) yield return "a depends line is over 1000 UTF-8 bytes";
|
|
if (S(c["status"]) == "open" && c["ruling"] is not null) yield return "an open card names a ruling";
|
|
if (c["ruling"] is null && c["ruled_from"]!.AsArray().Count > 0) yield return "ruled_from without a ruling";
|
|
if (S(c["options_hash"]) != HashOf(c)) yield return "options_hash is not the hash of the shown members";
|
|
}
|
|
|
|
[Test]
|
|
public void ForkCard_OptionsHash_IsSha256OfTheCanonicalJson_ComputedHere_AndEveryShownMemberMovesIt()
|
|
{
|
|
foreach (var h in Fork["optionsHash"]!.AsArray())
|
|
{
|
|
var bytes = Canonical(h!);
|
|
Assert.That(Encoding.UTF8.GetString(bytes), Is.EqualTo(S(h["canonical"])), S(h["name"]));
|
|
Assert.That(Convert.ToHexString(bytes), Is.EqualTo(S(h["canonicalHex"])), S(h["name"]));
|
|
Assert.That(Sha(bytes), Is.EqualTo(S(h["sha256"])), S(h["name"]));
|
|
}
|
|
var byName = Fork["optionsHash"]!.AsArray().ToDictionary(h => S(h!["name"]), h => S(h!["sha256"]));
|
|
var baseline = byName["heimdall_A_five_members"];
|
|
Assert.That(byName["hidden_members_do_not_move_it"], Is.EqualTo(baseline));
|
|
Assert.That(byName["heimdall_E_decomposed_equals_D"], Is.EqualTo(byName["heimdall_D_nfc_escapes_astral"]), "an NFD and an NFC spelling hash alike");
|
|
foreach (var moved in new[] { "heimdall_B_recommendation_changed", "heimdall_C_option_order_changed", "heimdall_F_option_id_changed", "heimdall_G_depends_emptied",
|
|
"option_text_changed", "title_changed", "context_changed", "depends_order_changed", "depends_order_changed_reversed" })
|
|
Assert.That(byName[moved], Is.Not.EqualTo(baseline), moved);
|
|
}
|
|
|
|
[Test]
|
|
public void ForkCard_MutatingAnyShownMember_AnOptionIdIncluded_ChangesTheHash()
|
|
{
|
|
var card = Fork["card"]!["valid"]![0]!["message"]!;
|
|
var baseline = HashOf(card);
|
|
var mutations = new Action<JsonNode>[]
|
|
{
|
|
c => c["title"] = S(c["title"]) + "x", c => c["context"] = S(c["context"]) + "x",
|
|
c => c["options"]![0]!["id"] = S(c["options"]![0]!["id"]) + "x", c => c["options"]![0]!["text"] = S(c["options"]![0]!["text"]) + "x",
|
|
c => { var o = c["options"]!.AsArray(); var first = o[0]!; o.RemoveAt(0); o.Add(first); },
|
|
c => c["recommendation"] = S(c["options"]![0]!["id"]) == S(c["recommendation"]) ? S(c["options"]![1]!["id"]) : S(c["options"]![0]!["id"]),
|
|
c => c["depends"]!.AsArray().Add("a:b:c"), c => c["depends"]!.AsArray().Clear(),
|
|
};
|
|
for (var i = 0; i < mutations.Length; i++)
|
|
{
|
|
var copy = Clone(card);
|
|
mutations[i](copy);
|
|
Assert.That(HashOf(copy), Is.Not.EqualTo(baseline), $"mutation {i}");
|
|
}
|
|
var hidden = Clone(card);
|
|
hidden["fork_id"] = "other:question:id"; hidden["leans"] = "yes"; hidden["status"] = "closed"; hidden["ruled_from"] = new JsonArray("n1");
|
|
Assert.That(HashOf(hidden), Is.EqualTo(baseline), "fork_id and the unshown members stay outside the hash");
|
|
}
|
|
|
|
[Test]
|
|
public void ForkCard_DocumentsValidateAndEncodeToTheVectorBytes()
|
|
{
|
|
foreach (var v in Fork["card"]!["valid"]!.AsArray())
|
|
{
|
|
Assert.That(Errors(ForkSchema, "card", v!["message"]), Is.Empty, S(v["name"]));
|
|
Assert.That(CardRuleErrors(v["message"]!), Is.Empty, S(v["name"]));
|
|
Assert.That(HexOf(v["message"]), Is.EqualTo(S(v["messagePackHex"])), S(v["name"]));
|
|
}
|
|
foreach (var (section, def) in new[] { ("request", "answerRequest"), ("deskAnswer", "deskAnswer") })
|
|
foreach (var v in Fork[section]!["valid"]!.AsArray())
|
|
{
|
|
Assert.That(Errors(ForkSchema, def, v!["message"]), Is.Empty, S(v["name"]));
|
|
Assert.That(NoteRuleErrors(def, v["message"]), Is.Empty, S(v["name"]));
|
|
Assert.That(HexOf(v["message"]), Is.EqualTo(S(v["messagePackHex"])), S(v["name"]));
|
|
var decoded = JsonNode.Parse(MessagePackSerializer.ConvertToJson(Convert.FromHexString(S(v["messagePackHex"]))))!;
|
|
Assert.That(JsonNode.DeepEquals(decoded, v["message"]), Is.True, S(v["name"]));
|
|
}
|
|
var schema = JsonNode.Parse(File.ReadAllText(Path.Combine(RepoRoot(), "contracts", "cultnet", ForkSchema)))!;
|
|
IEnumerable<string> codesOfRefusal() => schema["$defs"]!["refusal"]!["properties"]!["code"]!["enum"]!.AsArray().Select(S);
|
|
IEnumerable<string> Codes(string def) => schema["$defs"]![def]!["properties"]!["code"]!["enum"]!.AsArray().Select(S).OrderBy(x => x, StringComparer.Ordinal);
|
|
Assert.That(Fork["results"]!.AsArray().Select(r => S(r!["name"])).OrderBy(x => x, StringComparer.Ordinal), Is.EqualTo(Codes("deskAnswerRefused")));
|
|
foreach (var v in Fork["results"]!.AsArray())
|
|
{
|
|
Assert.That(Errors(ForkSchema, "answerResult", v!["message"]), Is.Empty, S(v["name"]));
|
|
Assert.That(HexOf(v["message"]), Is.EqualTo(S(v["messagePackHex"])), S(v["name"]));
|
|
Assert.That(v["message"]!["card"] is not null, Is.EqualTo(S(v["name"]) == "stale_fork"), "only a stale refusal shows the new fork");
|
|
}
|
|
Assert.That(Fork["doorRefusals"]!.AsArray().Select(r => S(r!["name"])).OrderBy(x => x, StringComparer.Ordinal), Is.EqualTo(Codes("refusal")));
|
|
foreach (var v in Fork["unavailable"]!.AsArray())
|
|
{
|
|
Assert.That(Errors(ForkSchema, "unavailable", v!["message"]), Is.Empty, S(v["name"]));
|
|
Assert.That(HexOf(v["message"]), Is.EqualTo(S(v["messagePackHex"])), S(v["name"]));
|
|
Assert.That(v["httpStatus"]!.GetValue<int>(), Is.EqualTo(503));
|
|
Assert.That(codesOfRefusal(), Does.Not.Contain("unavailable"), "unavailable is kept apart from the refusal set");
|
|
}
|
|
foreach (var v in Fork["doorRefusals"]!.AsArray())
|
|
{
|
|
Assert.That(Errors(ForkSchema, "refusal", v!["message"]), Is.Empty, S(v["name"]));
|
|
Assert.That(HexOf(v["message"]), Is.EqualTo(S(v["messagePackHex"])), S(v["name"]));
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
public void ForkCard_EveryNegative_IsRefusedBySchemaOrAStatedRule()
|
|
{
|
|
foreach (var v in Fork["card"]!["invalid"]!.AsArray())
|
|
Assert.That(Errors(ForkSchema, "card", v!["message"]).Count > 0 || CardRuleErrors(v["message"]!).Any(), Is.True, $"{S(v["name"])}: {S(v["why"])}");
|
|
foreach (var (section, def) in new[] { ("request", "answerRequest"), ("deskAnswer", "deskAnswer") })
|
|
foreach (var v in Fork[section]!["invalid"]!.AsArray())
|
|
{
|
|
Assert.That(Errors(ForkSchema, def, v!["message"]).Count > 0 || NoteRuleErrors(def, v["message"]).Any(), Is.True, $"{S(v["name"])}: {S(v["why"])}");
|
|
Assert.That(HexOf(v["message"]), Is.EqualTo(S(v["messagePackHex"])), S(v["name"]));
|
|
}
|
|
}
|
|
|
|
// Rules a JSON Schema cannot say: a note is at most 4000 UTF-8 bytes (maxLength counts code points and is only the looser bound).
|
|
private static IEnumerable<string> NoteRuleErrors(string def, JsonNode? message)
|
|
{
|
|
var note = def switch { "answerRequest" => message?["answer"]?["note"], "deskAnswer" => message?["note"], _ => null };
|
|
if (note is not null && Encoding.UTF8.GetByteCount(S(note)) > 4000) yield return "note is over 4000 UTF-8 bytes";
|
|
}
|
|
|
|
private static readonly Dictionary<string, string> Tails = new()
|
|
{
|
|
["unauthenticated"] = "a verified session is required",
|
|
["body_forbidden_field"] = "answerer and authority are set by the receiver",
|
|
["body_unknown_field"] = "the body is a closed shape",
|
|
["body_invalid"] = "the body does not satisfy gamecult.fork.answer_request.v0",
|
|
["fork_unknown"] = "no such fork",
|
|
["forbidden"] = "only Active members may use the desk",
|
|
["request_id_conflict"] = "this request_id was used for a different body",
|
|
};
|
|
private static readonly string[] RequestKeys = { "schemaVersion", "fork_id", "options_hash", "answer", "retracted", "retracts", "answered_at", "request_id" };
|
|
|
|
private static bool HasKey(JsonNode? n, params string[] names) => n switch
|
|
{
|
|
JsonObject o => o.Any(kv => names.Contains(kv.Key) || HasKey(kv.Value, names)),
|
|
JsonArray a => a.Any(x => HasKey(x, names)),
|
|
_ => false
|
|
};
|
|
|
|
// The reference judge: Huginn's half behind the second door as fork-card.md states it. Server-set fields come from the step.
|
|
private static (List<JsonNode?> Responses, JsonNode Record) Judge(JsonNode startCard, JsonArray steps)
|
|
{
|
|
var card = startCard;
|
|
var answers = new JsonArray();
|
|
var responses = new List<JsonNode?>();
|
|
var seen = new Dictionary<string, (JsonNode Body, JsonNode Response)>(); // (answerer, request_id) -> the first stored outcome
|
|
string? Counted()
|
|
{
|
|
var hash = HashOf(card);
|
|
foreach (var a in answers)
|
|
{
|
|
if (S(a!["outcome"]) is not ("Counted" or "Revision") || S(a["options_hash"]) != hash) continue;
|
|
if (answers.Any(r => S(r!["outcome"]) == "Retraction" && S(r["retracts"]) == S(a["answer_id"]))) continue;
|
|
return S(a["answer_id"]);
|
|
}
|
|
return null;
|
|
}
|
|
foreach (var step in steps)
|
|
{
|
|
var kind = S(step!["kind"]);
|
|
if (kind == "matrix_event") { responses.Add(null); continue; } // rule 9: Matrix never changes a decision
|
|
if (kind == "card") { card = step["card"]!; responses.Add(null); continue; }
|
|
var s = step["server"]!;
|
|
var b = step["body"]!.AsObject();
|
|
JsonNode Door(string code) => new JsonObject { ["schemaVersion"] = "gamecult.fork.refusal.v0", ["code"] = code, ["error"] = $"{code}: {Tails[code]}" };
|
|
JsonNode Store(string outcome, string? link, string? code)
|
|
{
|
|
var a = b["answer"] as JsonObject;
|
|
var doc = new JsonObject
|
|
{
|
|
["schemaVersion"] = "gamecult.desk_answer.v0", ["answer_id"] = S(s["answer_id"]), ["fork_id"] = Clone(b["fork_id"]), ["outcome"] = outcome,
|
|
["of"] = outcome == "Revision" ? link : null, ["retracts"] = outcome == "Retraction" ? link : null, ["code"] = outcome == "Refused" ? code : null,
|
|
["options_hash"] = Clone(b["options_hash"]), ["choice"] = Clone(a?["choice"]), ["note"] = Clone(a?["note"]), ["answered_at"] = Clone(b["answered_at"]),
|
|
["received_at"] = Clone(s["received_at"]),
|
|
["claim_in_future"] = (DateTimeOffset.Parse(S(b["answered_at"])) - DateTimeOffset.Parse(S(s["received_at"]))).TotalSeconds > 300,
|
|
["answerer"] = Clone(s["answerer"]), ["authority"] = Clone(s["authority"]), ["request_id"] = Clone(b["request_id"])
|
|
};
|
|
answers.Add(Clone(doc));
|
|
var stored = new JsonObject { ["schemaVersion"] = "gamecult.fork.answer_result.v0", ["answer"] = doc, ["card"] = code == "stale_fork" ? Clone(card) : null };
|
|
seen[S(s["answerer"]) + "|" + S(b["request_id"])] = (Clone(b)!, Clone(stored)!);
|
|
return stored;
|
|
}
|
|
JsonNode Refuse(string code) => Store("Refused", null, code);
|
|
var deciders = card["deciders"]!.AsArray().Select(S).ToList();
|
|
var retracted = b["retracted"] is JsonValue rv && rv.TryGetValue<bool>(out var rb) && rb;
|
|
var down = s["door_down"] is not null; // the second door failed to answer; "stored" says whether Huginn had the request by then
|
|
var reached = s["stored"] is not null;
|
|
JsonNode Unavailable() => new JsonObject { ["schemaVersion"] = "gamecult.fork.unavailable.v0", ["code"] = "unavailable", ["error"] = "unavailable: the outcome is not confirmed, try again with the same request_id", ["retryAfterSeconds"] = Clone(s["retry_after_seconds"]) };
|
|
// a stored desk_answer has no card, so a replayed stale_fork carries the CURRENT card (fork-card.md, request_id)
|
|
JsonNode Replay(JsonNode stored) { var copy = Clone(stored)!; if (S(copy["answer"]!["code"] ?? "") == "stale_fork") copy["card"] = Clone(card); return copy; }
|
|
JsonNode response;
|
|
if (s["answerer"] is null) response = Door("unauthenticated");
|
|
else if (s["active"] is JsonValue activeValue && !activeValue.GetValue<bool>()) response = Door("forbidden");
|
|
else if (HasKey(b, "answerer", "authority")) response = Door("body_forbidden_field");
|
|
else if (b.Any(kv => !RequestKeys.Contains(kv.Key)) || (b["answer"] is JsonObject ao && ao.Any(kv => kv.Key is not ("choice" or "note")))) response = Door("body_unknown_field");
|
|
else if (Errors(ForkSchema, "answerRequest", b).Count > 0 || NoteRuleErrors("answerRequest", b).Any() || (!retracted && b["answer"] is null)) response = Door("body_invalid");
|
|
else if (down && !reached) response = Unavailable();
|
|
else if (seen.TryGetValue(S(s["answerer"]) + "|" + S(b["request_id"]), out var prior)) response = JsonNode.DeepEquals(prior.Body, b) ? Replay(prior.Response) : Door("request_id_conflict");
|
|
else if (S(b["fork_id"]) != S(card["fork_id"])) response = Door("fork_unknown");
|
|
else if (S(b["options_hash"]) != HashOf(card)) response = Refuse("stale_fork");
|
|
else if (!deciders.Contains(S(s["answerer"]))) response = Refuse("not_a_decider");
|
|
else if (S(card["status"]) == "closed") response = Refuse("fork_closed");
|
|
else if (retracted || b["retracts"] is not null)
|
|
{
|
|
var target = answers.FirstOrDefault(a => a is not null && b["retracts"] is not null && S(a["answer_id"]) == S(b["retracts"])
|
|
&& S(a["outcome"]) is "Counted" or "Revision" && S(a["options_hash"]) == S(b["options_hash"]));
|
|
if (!retracted || b["answer"] is not null || b["retracts"] is null || target is null) response = Refuse("bad_retraction");
|
|
else if (answers.Any(a => S(a!["outcome"]) == "Retraction" && S(a["retracts"]) == S(target["answer_id"]))) response = Refuse("already_retracted");
|
|
else response = Store("Retraction", S(target["answer_id"]), null);
|
|
}
|
|
else if (b["answer"]!["choice"] is not null && !card["options"]!.AsArray().Any(o => S(o!["id"]) == S(b["answer"]!["choice"]))) response = Refuse("choice_invalid");
|
|
else
|
|
{
|
|
var first = Counted();
|
|
response = first is null ? Store("Counted", null, null) : Store("Revision", first, null);
|
|
}
|
|
responses.Add(down && reached ? Unavailable() : response);
|
|
}
|
|
var record = new JsonObject
|
|
{
|
|
["schemaVersion"] = "gamecult.fork.record.v0", ["fork_id"] = Clone(card["fork_id"]), ["options_hash"] = HashOf(card), ["counted_answer_id"] = Counted(), ["answers"] = answers
|
|
};
|
|
return (responses, record);
|
|
}
|
|
|
|
[Test]
|
|
public void ForkCard_ARequestIdBelongsToItsAccount_AnotherAccountsIdenticalBodyGetsItsOwnAnswerNeverTheFirstOutcome()
|
|
{
|
|
var crossed = 0;
|
|
foreach (var sc in Fork["scenarios"]!.AsArray())
|
|
{
|
|
var first = new Dictionary<string, (string Answerer, JsonNode Result)>(); // request_id -> the account that first got a stored result and what it got
|
|
foreach (var step in sc!["steps"]!.AsArray())
|
|
{
|
|
if (S(step!["kind"]) != "answer" || step["response"]?["answer"] is null) continue;
|
|
var who = S(step["server"]!["answerer"]);
|
|
var result = step["response"]!;
|
|
Assert.That(S(result["answer"]!["answerer"]), Is.EqualTo(who), $"{S(sc["name"])}: a result is stamped with the account that asked");
|
|
var rid = S(step["body"]!["request_id"]);
|
|
if (!first.TryGetValue(rid, out var prior)) { first[rid] = (who, result); continue; }
|
|
if (prior.Answerer == who) continue;
|
|
crossed++;
|
|
Assert.That(S(result["answer"]!["answer_id"]), Is.Not.EqualTo(S(prior.Result["answer"]!["answer_id"])), $"{S(sc["name"])}: its own answer id");
|
|
Assert.That(JsonNode.DeepEquals(result, prior.Result), Is.False, $"{S(sc["name"])}: the first account's outcome is never returned");
|
|
}
|
|
}
|
|
Assert.That(crossed, Is.GreaterThanOrEqualTo(3), "the vectors carry the cross-account cases");
|
|
}
|
|
|
|
[Test]
|
|
public void ForkCard_EveryScenario_ReplaysToTheVectorsResponsesAndRecord_BytesIncluded()
|
|
{
|
|
var seen = new HashSet<string>();
|
|
var doorRefusals = 0;
|
|
var door = new HashSet<string> { "unauthenticated", "body_forbidden_field", "body_unknown_field", "body_invalid", "fork_unknown", "forbidden", "request_id_conflict" };
|
|
foreach (var sc in Fork["scenarios"]!.AsArray())
|
|
{
|
|
var name = S(sc!["name"]);
|
|
Assert.That(HashOf(sc["card"]!), Is.EqualTo(S(sc["optionsHash"])), name);
|
|
Assert.That(CardRuleErrors(sc["card"]!), Is.Empty, name);
|
|
var steps = sc["steps"]!.AsArray();
|
|
var (responses, record) = Judge(sc["card"]!, steps);
|
|
Assert.That(responses.Count, Is.EqualTo(steps.Count), name);
|
|
for (var i = 0; i < steps.Count; i++)
|
|
Assert.That(JsonNode.DeepEquals(responses[i], steps[i]!["response"]), Is.True, $"{name} step {i}: {responses[i]?.ToJsonString()}");
|
|
Assert.That(JsonNode.DeepEquals(record, sc["record"]), Is.True, $"{name}: {record.ToJsonString()}");
|
|
Assert.That(Errors(ForkSchema, "record", record), Is.Empty, name);
|
|
Assert.That(HexOf(record), Is.EqualTo(S(sc["recordHex"])), name);
|
|
foreach (var step in steps)
|
|
{
|
|
var r = step!["response"];
|
|
if (r is null) continue;
|
|
Assert.That(Errors(ForkSchema, "answerResult", r).Count == 0 || Errors(ForkSchema, "refusal", r).Count == 0 || Errors(ForkSchema, "unavailable", r).Count == 0, Is.True, name);
|
|
if (r["code"] is not null)
|
|
{
|
|
seen.Add(S(r["code"]));
|
|
// unavailable promises nothing: the outcome is not confirmed, so the answer is stored exactly when Huginn had it
|
|
if (S(r["code"]) == "unavailable")
|
|
Assert.That(sc["record"]!["answers"]!.AsArray().Any(a => S(a!["answer_id"]) == S(step["server"]!["answer_id"])), Is.EqualTo(step["server"]!["stored"] is not null), $"{name} unavailable");
|
|
if (door.Contains(S(r["code"])))
|
|
{
|
|
doorRefusals++;
|
|
Assert.That(sc["record"]!["answers"]!.AsArray().Any(a => S(a!["answer_id"]) == S(step["server"]!["answer_id"])), Is.False, $"{name} stored a door refusal");
|
|
}
|
|
}
|
|
else seen.Add(S(r["answer"]!["code"] ?? r["answer"]!["outcome"]));
|
|
}
|
|
foreach (var a in sc["record"]!["answers"]!.AsArray()) seen.Add(S(a!["outcome"]));
|
|
}
|
|
Assert.That(doorRefusals, Is.GreaterThanOrEqualTo(8));
|
|
foreach (var want in new[] { "Counted", "Revision", "Retraction", "Refused", "unauthenticated", "body_forbidden_field", "body_unknown_field", "body_invalid", "fork_unknown", "unavailable", "forbidden", "request_id_conflict", "stale_fork",
|
|
"not_a_decider", "fork_closed", "choice_invalid", "bad_retraction", "already_retracted" })
|
|
Assert.That(seen, Does.Contain(want));
|
|
}
|
|
|
|
// A ruling may carry authority Operator only if it cites a Counted desk_answer stamped Operator (binding file 1.3d).
|
|
private static bool AdmitRuling(JsonNode cite, JsonArray answers)
|
|
{
|
|
if (S(cite["authority"]) != "Operator") return true;
|
|
var cited = answers.FirstOrDefault(a => cite["answer_id"] is not null && S(a!["answer_id"]) == S(cite["answer_id"]));
|
|
return cited is not null && S(cited["outcome"]) == "Counted" && S(cited["authority"]) == "Operator";
|
|
}
|
|
|
|
[Test]
|
|
public void ForkCard_OperatorAuthority_NeedsACitedCountedAnswerRecord()
|
|
{
|
|
foreach (var c in Fork["rulingCites"]!.AsArray())
|
|
{
|
|
Assert.That(Errors(ForkSchema, "rulingCite", c!["message"]), Is.Empty, S(c["name"]));
|
|
Assert.That(HexOf(c["message"]), Is.EqualTo(S(c["messagePackHex"])), S(c["name"]));
|
|
Assert.That(AdmitRuling(c["message"]!, c["answers"]!.AsArray()), Is.EqualTo(c["admit"]!.GetValue<bool>()), S(c["name"]));
|
|
}
|
|
}
|
|
|
|
// ---- transport: list, read, auth (fork-card.md "Transport") ----
|
|
private static IEnumerable<string> EntryRuleErrors(JsonNode e)
|
|
{
|
|
var card = e["card"]!; var record = e["record"]!;
|
|
foreach (var error in CardRuleErrors(card)) yield return error;
|
|
if (S(record["fork_id"]) != S(card["fork_id"])) yield return "record of another fork";
|
|
if (S(record["options_hash"]) != S(card["options_hash"])) yield return "record under another hash";
|
|
var answers = record["answers"]!.AsArray();
|
|
var counted = answers.Where(a => (S(a!["outcome"]) is "Counted" or "Revision") && S(a["options_hash"]) == S(card["options_hash"])
|
|
&& !answers.Any(r => S(r!["outcome"]) == "Retraction" && S(r["retracts"]) == S(a["answer_id"]))).Select(a => S(a!["answer_id"])).FirstOrDefault();
|
|
if ((record["counted_answer_id"] is null ? null : S(record["counted_answer_id"])) != counted) yield return "counted_answer_id is not derived";
|
|
}
|
|
|
|
// The reference list judge: newest first by card order, status filter, cursor c<N> = cards below order N, a page of limit, counts over the whole set.
|
|
private static JsonNode JudgeList(JsonArray entries, long ordinal, JsonNode request)
|
|
{
|
|
long Order(JsonNode? e) => e!["card"]!["order"]!.GetValue<long>();
|
|
var rows = entries.OrderByDescending(Order).ToList();
|
|
var want = rows.Where(e => request["status"] is null || (S(e!["card"]!["status"]) == "open") == (S(request["status"]) == "open")).ToList();
|
|
if (request["cursor"] is not null) { var below = long.Parse(S(request["cursor"])[1..]); want = want.Where(e => Order(e) < below).ToList(); }
|
|
var page = want.Take(request["limit"]!.GetValue<int>()).ToList();
|
|
var more = want.Count > page.Count && page.Count > 0;
|
|
return new JsonObject
|
|
{
|
|
["schemaVersion"] = "gamecult.fork.list.v0", ["mind"] = Clone(request["mind"]), ["stamp"] = $"s{ordinal}",
|
|
["open_count"] = rows.Count(e => S(e!["card"]!["status"]) == "open"), ["recorded_count"] = rows.Count(e => S(e!["card"]!["status"]) == "closed"),
|
|
["forks"] = new JsonArray(page.Select(Clone).ToArray()), ["next_cursor"] = more ? JsonValue.Create($"c{Order(page[^1])}") : null
|
|
};
|
|
}
|
|
|
|
// Bearer, audience, scope, in that order: a votes.* token fails at the audience and a desk token without the route's scope fails at the scope.
|
|
private static string AuthJudge(string route, JsonNode? token, bool huginnDown)
|
|
{
|
|
if (token is null || S(token["audience"]) != "bifrost-desk") return "unauthenticated";
|
|
var need = route == "answer" ? "desk.answer" : "desk.read";
|
|
if (!token["scopes"]!.AsArray().Any(x => S(x) == need)) return "insufficient_scope";
|
|
if (!token["active"]!.GetValue<bool>()) return "forbidden";
|
|
return huginnDown ? "unavailable" : "ok";
|
|
}
|
|
|
|
private static string PercentDecode(string s)
|
|
{
|
|
var bytes = new List<byte>();
|
|
for (var i = 0; i < s.Length;)
|
|
{
|
|
if (s[i] == '%' && i + 2 < s.Length && Uri.IsHexDigit(s[i + 1]) && Uri.IsHexDigit(s[i + 2])) { bytes.Add(Convert.ToByte(s.Substring(i + 1, 2), 16)); i += 3; continue; }
|
|
var rune = System.Text.Rune.GetRuneAt(s, i);
|
|
bytes.AddRange(Encoding.UTF8.GetBytes(rune.ToString())); i += rune.Utf16SequenceLength;
|
|
}
|
|
return new UTF8Encoding(false, true).GetString(bytes.ToArray()); // throws DecoderFallbackException (an ArgumentException) on invalid UTF-8
|
|
}
|
|
|
|
// The {mind} segment is decoded once and compared, ordinally, only with the configured list; nothing is opened from it (fork-card.md, "The {mind} segment").
|
|
private static (string Code, string? Opened, string? StoredIn) MindRoute(JsonNode config, string route, string segment, string? fork, JsonNode? token)
|
|
{
|
|
var auth = AuthJudge(route, token, false);
|
|
if (auth != "ok") return (auth, null, null);
|
|
string mind;
|
|
try { mind = PercentDecode(segment); } catch (ArgumentException) { return ("fork_unknown", null, null); } // bytes that are not UTF-8 are a miss
|
|
var minds = config["minds"]!.AsObject();
|
|
if (!minds.TryGetPropertyValue(mind, out var socket)) return ("fork_unknown", null, null);
|
|
if (route != "list" && !config["forks"]![mind]!.AsArray().Any(f => S(f) == fork)) return ("fork_unknown", S(socket), null);
|
|
return ("ok", S(socket), route == "answer" ? mind : null);
|
|
}
|
|
|
|
[Test]
|
|
public void ForkTransport_MindSegment_IsOnlyComparedWithTheConfiguredList_AnUnknownMindIsTheUnknownForkRefusal()
|
|
{
|
|
var config = Fork["transport"]!["mindRoutes"]!;
|
|
var refusals = Fork["doorRefusals"]!.AsArray().ToDictionary(r => S(r!["name"]), r => r!);
|
|
var byName = new Dictionary<string, (string Code, string? Opened, string? StoredIn)>();
|
|
foreach (var c in config["cases"]!.AsArray())
|
|
{
|
|
var got = MindRoute(config, S(c!["route"]), S(c["mind"]), c["fork_id"] is null ? null : S(c["fork_id"]), c["token"]);
|
|
byName[S(c["name"])] = got;
|
|
Assert.That(got.Code, Is.EqualTo(S(c["expect"])), S(c["name"]));
|
|
Assert.That(got.Opened, Is.EqualTo(c["opened"] is null ? null : S(c["opened"])), $"{S(c["name"])}: the socket opened");
|
|
Assert.That(got.StoredIn, Is.EqualTo(c["stored_in"] is null ? null : S(c["stored_in"])), $"{S(c["name"])}: where an answer was stored");
|
|
Assert.That(c["httpStatus"]!.GetValue<int>(), Is.EqualTo(got.Code == "ok" ? 200 : refusals[got.Code]["httpStatus"]!.GetValue<int>()), S(c["name"]));
|
|
if (got.Code == "fork_unknown")
|
|
{
|
|
var error = S(refusals["fork_unknown"]["message"]!["error"]);
|
|
Assert.That(error.Contains(S(c["mind"])), Is.False, $"{S(c["name"])}: the refusal does not name the Mind");
|
|
Assert.That(c["fork_id"] is not null && error.Contains(S(c["fork_id"])), Is.False, $"{S(c["name"])}: the refusal does not name the fork");
|
|
}
|
|
}
|
|
foreach (var pair in config["sameResponse"]!.AsArray())
|
|
{
|
|
var a = byName[S(pair![0])]; var b = byName[S(pair[1])];
|
|
Assert.That(a.Code, Is.EqualTo(b.Code));
|
|
string Built(string code) => HexOf(new JsonObject { ["schemaVersion"] = "gamecult.fork.refusal.v0", ["code"] = code, ["error"] = $"{code}: {Tails[code]}" });
|
|
Assert.That(Built(a.Code), Is.EqualTo(Built(b.Code)).And.EqualTo(S(refusals[a.Code]["messagePackHex"])), $"{S(pair[0])} and {S(pair[1])} answer with the same bytes, built from the spec's rule");
|
|
}
|
|
Assert.That(byName.Values.Count(v => v.Code == "fork_unknown" && v.Opened is null), Is.GreaterThanOrEqualTo(39), "every bad segment opens nothing");
|
|
Assert.That(byName.Values.Where(v => v.StoredIn is not null).Select(v => v.StoredIn).Distinct().Count(), Is.EqualTo(2), "an answer is stored in the route's Mind only, and both Minds take one");
|
|
}
|
|
|
|
[Test]
|
|
public void ForkTransport_RequestsAndEntriesValidateEncodeToTheVectorBytes_AndEveryNegativeIsRefused()
|
|
{
|
|
var t = Fork["transport"]!;
|
|
foreach (var (section, def) in new[] { ("listRequest", "listRequest"), ("readRequest", "readRequest"), ("entry", "entry") })
|
|
{
|
|
foreach (var v in t[section]!["valid"]!.AsArray())
|
|
{
|
|
Assert.That(Errors(ForkSchema, def, v!["message"]), Is.Empty, S(v["name"]));
|
|
if (def == "entry") Assert.That(EntryRuleErrors(v["message"]!), Is.Empty, S(v["name"]));
|
|
Assert.That(HexOf(v["message"]), Is.EqualTo(S(v["messagePackHex"])), S(v["name"]));
|
|
}
|
|
foreach (var v in t[section]!["invalid"]!.AsArray())
|
|
{
|
|
Assert.That(Errors(ForkSchema, def, v!["message"]).Count > 0 || (def == "entry" && EntryRuleErrors(v["message"]!).Any()), Is.True, $"{S(v["name"])}: {S(v["why"])}");
|
|
Assert.That(HexOf(v["message"]), Is.EqualTo(S(v["messagePackHex"])), S(v["name"]));
|
|
}
|
|
}
|
|
foreach (var v in t["listInvalid"]!.AsArray())
|
|
{
|
|
Assert.That(Errors(ForkSchema, "list", v!["message"]), Is.Not.Empty, $"{S(v["name"])}: {S(v["why"])}");
|
|
Assert.That(HexOf(v["message"]), Is.EqualTo(S(v["messagePackHex"])), S(v["name"]));
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
public void ForkTransport_EveryListRequest_ReplaysToTheVectorsResponse_NewestFirst_PagedByTheCursor()
|
|
{
|
|
foreach (var sc in Fork["transport"]!["listScenarios"]!.AsArray())
|
|
{
|
|
var name = S(sc!["name"]);
|
|
var entries = sc["entries"]!.AsArray();
|
|
foreach (var e in entries) Assert.That(EntryRuleErrors(e!), Is.Empty, name);
|
|
JsonNode? previous = null;
|
|
foreach (var step in sc["steps"]!.AsArray())
|
|
{
|
|
var response = JudgeList(entries, sc["ordinal"]!.GetValue<long>(), step!["request"]!);
|
|
Assert.That(JsonNode.DeepEquals(response, step["response"]), Is.True, $"{name}: {response.ToJsonString()}");
|
|
Assert.That(Errors(ForkSchema, "list", response), Is.Empty, name);
|
|
Assert.That(HexOf(response), Is.EqualTo(S(step["responseHex"])), name);
|
|
var orders = response["forks"]!.AsArray().Select(f => f!["card"]!["order"]!.GetValue<long>()).ToList();
|
|
Assert.That(orders, Is.Ordered.Descending, name);
|
|
if (previous is not null) Assert.That(S(step["request"]!["cursor"]), Is.EqualTo(S(previous["next_cursor"])), $"{name}: the second request follows the first page's cursor");
|
|
previous = response;
|
|
}
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
public void ForkTransport_Auth_BearerThenAudienceThenScope_AVotesTokenTouchesNothing()
|
|
{
|
|
var schema = JsonNode.Parse(File.ReadAllText(Path.Combine(RepoRoot(), "contracts", "cultnet", ForkSchema)))!;
|
|
var codes = schema["$defs"]!["refusal"]!["properties"]!["code"]!["enum"]!.AsArray().Select(S).ToList();
|
|
var seen = new HashSet<string>();
|
|
foreach (var c in Fork["transport"]!["authCases"]!.AsArray())
|
|
{
|
|
var got = AuthJudge(S(c!["route"]), c["token"], c["huginn_down"]!.GetValue<bool>());
|
|
Assert.That(got, Is.EqualTo(S(c["expect"])), S(c["name"]));
|
|
if (got is not ("ok" or "unavailable")) Assert.That(codes, Does.Contain(got), S(c["name"]));
|
|
seen.Add(S(c["route"]) + ":" + got);
|
|
}
|
|
Assert.That(seen, Does.Contain("list:insufficient_scope").And.Contain("answer:insufficient_scope").And.Contain("read:forbidden").And.Contain("list:unavailable").And.Contain("read:unavailable").And.Contain("answer:unavailable").And.Contain("read:unauthenticated").And.Contain("answer:ok"));
|
|
}
|
|
}
|
|
}
|