cultnet error message parity: TS type + Python builder/decoder, shared C#/Rust vectors #14
8 changed files with 442 additions and 0 deletions
134
contracts/cultnet/interop/error-vectors.json
Normal file
134
contracts/cultnet/interop/error-vectors.json
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
{
|
||||
"provenance": "Captured from MessagePack C# 3.1.7 (CultNetErrorMessage/CultNetErrorDetails, Options = Standard.WithSecurity(UntrustedData)); pinned byte-for-byte by packages/cultnet-rs/tests/error_message.rs, whose inline hex equals messagePackHex here (test error_vectors_file_matches_the_inline_csharp_captures). Rust encodes to these exact bytes, so C#-written and Rust-written bytes are one set. Every key is written on the wire, nil included.",
|
||||
"vectors": [
|
||||
{
|
||||
"name": "selection_invalid_schemas_null",
|
||||
"messagePackHex": "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D92473656C656374696F6E5F696E76616C69643A206D757374206E6F7420626520656D707479AB726F7574696E6748696E74C0A4636F6465B173656C656374696F6E5F696E76616C6964A764657461696C7384A56669656C64A7736368656D6173A576616C7565C0A461734F66C0A763757272656E74C0",
|
||||
"message": {
|
||||
"schemaVersion": "cultnet.error.v0",
|
||||
"error": "selection_invalid: must not be empty",
|
||||
"routingHint": null,
|
||||
"code": "selection_invalid",
|
||||
"details": {
|
||||
"field": "schemas",
|
||||
"value": null,
|
||||
"asOf": null,
|
||||
"current": null
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "selection_invalid_field_value",
|
||||
"messagePackHex": "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D92273656C656374696F6E5F696E76616C69643A20626C616E6B206B657920656E747279AB726F7574696E6748696E74C0A4636F6465B173656C656374696F6E5F696E76616C6964A764657461696C7384A56669656C64A46B657973A576616C7565A3202020A461734F66C0A763757272656E74C0",
|
||||
"message": {
|
||||
"schemaVersion": "cultnet.error.v0",
|
||||
"error": "selection_invalid: blank key entry",
|
||||
"routingHint": null,
|
||||
"code": "selection_invalid",
|
||||
"details": {
|
||||
"field": "keys",
|
||||
"value": " ",
|
||||
"asOf": null,
|
||||
"current": null
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "cursor_stale",
|
||||
"messagePackHex": "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72BD637572736F725F7374616C653A20637572736F72206973207374616C65AB726F7574696E6748696E74C0A4636F6465AC637572736F725F7374616C65A764657461696C7384A56669656C64C0A576616C7565C0A461734F6601A763757272656E7402",
|
||||
"message": {
|
||||
"schemaVersion": "cultnet.error.v0",
|
||||
"error": "cursor_stale: cursor is stale",
|
||||
"routingHint": null,
|
||||
"code": "cursor_stale",
|
||||
"details": {
|
||||
"field": null,
|
||||
"value": null,
|
||||
"asOf": 1,
|
||||
"current": 2
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "cursor_invalid",
|
||||
"messagePackHex": "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D923637572736F725F696E76616C69643A2063616E6E6F7420706172736520637572736F72AB726F7574696E6748696E74C0A4636F6465AE637572736F725F696E76616C6964A764657461696C73C0",
|
||||
"message": {
|
||||
"schemaVersion": "cultnet.error.v0",
|
||||
"error": "cursor_invalid: cannot parse cursor",
|
||||
"routingHint": null,
|
||||
"code": "cursor_invalid",
|
||||
"details": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "reference_outside_target",
|
||||
"messagePackHex": "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D9317265666572656E63655F6F7574736964655F7461726765743A206F757473696465206465636C6172656420746172676574AB726F7574696E6748696E74C0A4636F6465B87265666572656E63655F6F7574736964655F746172676574A764657461696C73C0",
|
||||
"message": {
|
||||
"schemaVersion": "cultnet.error.v0",
|
||||
"error": "reference_outside_target: outside declared target",
|
||||
"routingHint": null,
|
||||
"code": "reference_outside_target",
|
||||
"details": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "variant_unsupported_subscription_ended",
|
||||
"messagePackHex": "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72B876617269616E7420756E737570706F727465642068657265AB726F7574696E6748696E74C0A4636F6465B376617269616E745F756E737570706F72746564A764657461696C7384A56669656C64AE737562736372697074696F6E4964A576616C7565A57375622D31A461734F66C0A763757272656E74C0",
|
||||
"message": {
|
||||
"schemaVersion": "cultnet.error.v0",
|
||||
"error": "variant unsupported here",
|
||||
"routingHint": null,
|
||||
"code": "variant_unsupported",
|
||||
"details": {
|
||||
"field": "subscriptionId",
|
||||
"value": "sub-1",
|
||||
"asOf": null,
|
||||
"current": null
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "variant_unsupported_no_subscription",
|
||||
"messagePackHex": "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72B876617269616E7420756E737570706F727465642068657265AB726F7574696E6748696E74C0A4636F6465B376617269616E745F756E737570706F72746564A764657461696C73C0",
|
||||
"message": {
|
||||
"schemaVersion": "cultnet.error.v0",
|
||||
"error": "variant unsupported here",
|
||||
"routingHint": null,
|
||||
"code": "variant_unsupported",
|
||||
"details": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "unowned_schema",
|
||||
"messagePackHex": "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D95C756E6F776E65645F736368656D613A204E6F207368617264206F776E7320736368656D612027736F6D652E736368656D612E763127206B65792027736F6D653A6B6579273B207468652077726974652077617320726566757365642EAB726F7574696E6748696E74C0A4636F6465AE756E6F776E65645F736368656D61A764657461696C7384A56669656C64A8736368656D614964A576616C7565AE736F6D652E736368656D612E7631A461734F66C0A763757272656E74C0",
|
||||
"message": {
|
||||
"schemaVersion": "cultnet.error.v0",
|
||||
"error": "unowned_schema: No shard owns schema 'some.schema.v1' key 'some:key'; the write was refused.",
|
||||
"routingHint": null,
|
||||
"code": "unowned_schema",
|
||||
"details": {
|
||||
"field": "schemaId",
|
||||
"value": "some.schema.v1",
|
||||
"asOf": null,
|
||||
"current": null
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "write_not_authorized",
|
||||
"messagePackHex": "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D92177726974655F6E6F745F617574686F72697A65643A206E6F74206772616E746564AB726F7574696E6748696E74C0A4636F6465B477726974655F6E6F745F617574686F72697A6564A764657461696C7384A56669656C64A97265636F72644B6579A576616C7565A8736F6D653A6B6579A461734F66C0A763757272656E74C0",
|
||||
"message": {
|
||||
"schemaVersion": "cultnet.error.v0",
|
||||
"error": "write_not_authorized: not granted",
|
||||
"routingHint": null,
|
||||
"code": "write_not_authorized",
|
||||
"details": {
|
||||
"field": "recordKey",
|
||||
"value": "some:key",
|
||||
"asOf": null,
|
||||
"current": null
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -38,6 +38,7 @@ from .cultmesh_contracts import (
|
|||
)
|
||||
from .framing import decode_frame, encode_frame, read_frame, write_frame
|
||||
from .messages import (
|
||||
CULTNET_ERROR_CODES,
|
||||
CultNetMessage,
|
||||
CultNetWitnessArtifactBundle,
|
||||
compute_simulation_claim_hash,
|
||||
|
|
@ -47,6 +48,7 @@ from .messages import (
|
|||
document_delete,
|
||||
document_put_raw,
|
||||
encode_witness_artifact_bundle_payload,
|
||||
error_message,
|
||||
hello,
|
||||
login,
|
||||
login_success,
|
||||
|
|
@ -165,6 +167,7 @@ __all__ = [
|
|||
"simulation_fact_document",
|
||||
"verse_catalog_request",
|
||||
"verse_from_wire",
|
||||
"CULTNET_ERROR_CODES",
|
||||
"CultNetMessage",
|
||||
"CultNetWitnessArtifactBundle",
|
||||
"CultNetDatabaseSubscription",
|
||||
|
|
@ -247,6 +250,7 @@ __all__ = [
|
|||
"encode_witness_artifact_bundle_payload",
|
||||
"encode_frame",
|
||||
"encode_rudp_packet",
|
||||
"error_message",
|
||||
"hello",
|
||||
"INTEROP_WIRE_CONTRACT",
|
||||
"login",
|
||||
|
|
|
|||
|
|
@ -28,9 +28,61 @@ def parse_message(payload: bytes) -> CultNetMessage:
|
|||
raise ValueError("CultNet schema-v0 messages must declare schemaVersion")
|
||||
body = dict(decoded)
|
||||
del body["schemaVersion"]
|
||||
if schema_version == "cultnet.error.v0":
|
||||
_validate_error_body(body)
|
||||
return CultNetMessage(schema_version=schema_version, body=body)
|
||||
|
||||
|
||||
# Closed refusal-code set of cultnet.error.v0 (contracts/cultnet/cultnet.error.schema.json). Adding a
|
||||
# code is one coordinated change across every runtime; a peer refuses a code it does not know.
|
||||
CULTNET_ERROR_CODES: tuple[str, ...] = (
|
||||
"selection_invalid",
|
||||
"cursor_stale",
|
||||
"cursor_invalid",
|
||||
"reference_outside_target",
|
||||
"variant_unsupported",
|
||||
"unowned_schema",
|
||||
"write_not_authorized",
|
||||
)
|
||||
|
||||
_ERROR_DETAILS_KEYS = ("field", "value", "asOf", "current")
|
||||
|
||||
|
||||
def _is_uint(value: Any) -> bool:
|
||||
return isinstance(value, int) and not isinstance(value, bool) and value >= 0
|
||||
|
||||
|
||||
def _validate_error_body(body: dict[str, Any]) -> None:
|
||||
"""Refuse a cultnet.error.v0 body that is not the full-key shape the schema requires."""
|
||||
expected = ("error", "routingHint", "code", "details")
|
||||
if set(body) != set(expected):
|
||||
raise ValueError(f"cultnet.error.v0 must carry exactly {list(expected)}, got {sorted(body)}")
|
||||
if not isinstance(body["error"], str) or not body["error"]:
|
||||
raise ValueError("cultnet.error.v0 error must be a non-empty string")
|
||||
code = body["code"]
|
||||
if code is not None and (not isinstance(code, str) or code not in CULTNET_ERROR_CODES):
|
||||
raise ValueError(f"cultnet.error.v0 code {code!r} is not a recognized refusal code")
|
||||
hint = body["routingHint"]
|
||||
if hint is not None:
|
||||
if (
|
||||
not isinstance(hint, dict)
|
||||
or set(hint) != {"shard", "reason"}
|
||||
or not isinstance(hint["reason"], str)
|
||||
or not (hint["shard"] is None or isinstance(hint["shard"], dict))
|
||||
):
|
||||
raise ValueError("cultnet.error.v0 routingHint must be null or {shard, reason}")
|
||||
details = body["details"]
|
||||
if details is not None:
|
||||
if not isinstance(details, dict) or set(details) != set(_ERROR_DETAILS_KEYS):
|
||||
raise ValueError(f"cultnet.error.v0 details must be null or carry exactly {list(_ERROR_DETAILS_KEYS)}")
|
||||
for key in ("field", "value"):
|
||||
if details[key] is not None and not isinstance(details[key], str):
|
||||
raise ValueError(f"cultnet.error.v0 details.{key} must be a string or null")
|
||||
for key in ("asOf", "current"):
|
||||
if details[key] is not None and not _is_uint(details[key]):
|
||||
raise ValueError(f"cultnet.error.v0 details.{key} must be a non-negative integer or null")
|
||||
|
||||
|
||||
def hello(
|
||||
*,
|
||||
runtime_id: str,
|
||||
|
|
@ -99,6 +151,29 @@ def verify_session(
|
|||
)
|
||||
|
||||
|
||||
def error_message(
|
||||
error: str,
|
||||
*,
|
||||
code: str | None = None,
|
||||
details: dict[str, Any] | None = None,
|
||||
routing_hint: dict[str, Any] | None = None,
|
||||
) -> CultNetMessage:
|
||||
"""Build cultnet.error.v0 with every key written (nil included), as the C# map formatter does.
|
||||
|
||||
``details`` may name any of field/value/asOf/current; the rest are written as null.
|
||||
"""
|
||||
body = {
|
||||
"error": error,
|
||||
"routingHint": routing_hint,
|
||||
"code": code,
|
||||
"details": None if details is None else {key: details.get(key) for key in _ERROR_DETAILS_KEYS},
|
||||
}
|
||||
if details is not None and set(details) - set(_ERROR_DETAILS_KEYS):
|
||||
raise ValueError(f"cultnet.error.v0 details has unknown keys {sorted(set(details) - set(_ERROR_DETAILS_KEYS))}")
|
||||
_validate_error_body(body)
|
||||
return CultNetMessage("cultnet.error.v0", body)
|
||||
|
||||
|
||||
def login_success(
|
||||
*,
|
||||
nonce: str,
|
||||
|
|
|
|||
90
packages/cultnet-py/tests/test_cultnet_error.py
Normal file
90
packages/cultnet-py/tests/test_cultnet_error.py
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import msgpack
|
||||
|
||||
from cultnet_py import CULTNET_ERROR_CODES, error_message, parse_message
|
||||
|
||||
# contracts/cultnet/interop/error-vectors.json: bytes captured from MessagePack C# 3.1.7 and pinned
|
||||
# by cultnet-rs/tests/error_message.rs (Rust encodes to exactly these bytes).
|
||||
_REPO = Path(__file__).resolve().parents[3]
|
||||
_VECTORS = json.loads((_REPO / "contracts/cultnet/interop/error-vectors.json").read_text(encoding="utf-8"))["vectors"]
|
||||
_SCHEMA = json.loads((_REPO / "contracts/cultnet/cultnet.error.schema.json").read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
class CultNetErrorMessageTests(unittest.TestCase):
|
||||
def test_closed_code_set_equals_the_schema_enum(self) -> None:
|
||||
schema_codes = [c for c in _SCHEMA["properties"]["code"]["enum"] if c is not None]
|
||||
self.assertEqual(sorted(CULTNET_ERROR_CODES), sorted(schema_codes))
|
||||
|
||||
def test_vectors_cover_every_code(self) -> None:
|
||||
self.assertEqual({v["message"]["code"] for v in _VECTORS}, set(CULTNET_ERROR_CODES))
|
||||
|
||||
def test_decodes_csharp_and_rust_written_bytes_with_every_key(self) -> None:
|
||||
for vector in _VECTORS:
|
||||
message = parse_message(bytes.fromhex(vector["messagePackHex"]))
|
||||
self.assertEqual(message.to_wire(), vector["message"], vector["name"])
|
||||
self.assertEqual(
|
||||
list(message.to_wire()), ["schemaVersion", "error", "routingHint", "code", "details"], vector["name"]
|
||||
)
|
||||
|
||||
def test_reencode_is_byte_identical(self) -> None:
|
||||
for vector in _VECTORS:
|
||||
expected = bytes.fromhex(vector["messagePackHex"])
|
||||
self.assertEqual(parse_message(expected).to_bytes(), expected, vector["name"])
|
||||
|
||||
def test_builder_produces_the_csharp_bytes(self) -> None:
|
||||
by_name = {v["name"]: bytes.fromhex(v["messagePackHex"]) for v in _VECTORS}
|
||||
stale = error_message(
|
||||
"cursor_stale: cursor is stale", code="cursor_stale", details={"asOf": 1, "current": 2}
|
||||
)
|
||||
self.assertEqual(stale.to_bytes(), by_name["cursor_stale"])
|
||||
invalid = error_message("cursor_invalid: cannot parse cursor", code="cursor_invalid")
|
||||
self.assertEqual(invalid.to_bytes(), by_name["cursor_invalid"])
|
||||
selection = error_message(
|
||||
"selection_invalid: blank key entry",
|
||||
code="selection_invalid",
|
||||
details={"field": "keys", "value": " "},
|
||||
)
|
||||
self.assertEqual(selection.to_bytes(), by_name["selection_invalid_field_value"])
|
||||
|
||||
def test_builder_writes_nil_keys_and_accepts_a_null_code(self) -> None:
|
||||
wire = error_message("boom").to_wire()
|
||||
self.assertEqual(wire, {"schemaVersion": "cultnet.error.v0", "error": "boom", "routingHint": None, "code": None, "details": None})
|
||||
|
||||
def test_unknown_code_is_refused_by_builder_and_decoder(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
error_message("x", code="unsupported_schema_version")
|
||||
bad = msgpack.packb(
|
||||
{"schemaVersion": "cultnet.error.v0", "error": "x", "routingHint": None, "code": "nope", "details": None},
|
||||
use_bin_type=True,
|
||||
)
|
||||
with self.assertRaises(ValueError):
|
||||
parse_message(bad)
|
||||
|
||||
def test_dropping_code_or_details_is_refused(self) -> None:
|
||||
vector = next(v for v in _VECTORS if v["name"] == "cursor_stale")
|
||||
for dropped in ("code", "details", "routingHint"):
|
||||
wire = dict(vector["message"])
|
||||
del wire[dropped]
|
||||
with self.assertRaises(ValueError, msg=dropped):
|
||||
parse_message(msgpack.packb(wire, use_bin_type=True))
|
||||
|
||||
def test_malformed_details_are_refused(self) -> None:
|
||||
base = dict(next(v for v in _VECTORS if v["name"] == "cursor_stale")["message"])
|
||||
for details in (
|
||||
{"field": None, "value": None, "asOf": 1},
|
||||
{"field": None, "value": None, "asOf": -1, "current": 2},
|
||||
{"field": None, "value": None, "asOf": 1, "current": 2, "extra": 1},
|
||||
):
|
||||
with self.assertRaises(ValueError, msg=str(details)):
|
||||
parse_message(msgpack.packb({**base, "details": details}, use_bin_type=True))
|
||||
with self.assertRaises(ValueError):
|
||||
error_message("x", code="cursor_stale", details={"asOf": 1, "bogus": 2})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -211,3 +211,22 @@ fn round_trip_is_stable() {
|
|||
assert_eq!(decoded, message, "{}: round trip mismatch", case.name);
|
||||
}
|
||||
}
|
||||
|
||||
/// `contracts/cultnet/interop/error-vectors.json` is the file the TypeScript and Python runtimes
|
||||
/// decode; it must stay byte-identical to the inline C# captures above, or those runtimes would be
|
||||
/// pinned to bytes this crate no longer pins.
|
||||
#[test]
|
||||
fn error_vectors_file_matches_the_inline_csharp_captures() {
|
||||
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../contracts/cultnet/interop/error-vectors.json");
|
||||
let file: serde_json::Value =
|
||||
serde_json::from_str(&std::fs::read_to_string(&path).expect("read error-vectors.json"))
|
||||
.expect("parse error-vectors.json");
|
||||
let vectors = file["vectors"].as_array().expect("vectors array");
|
||||
let all = cases();
|
||||
assert_eq!(vectors.len(), all.len(), "vector count differs from the inline cases");
|
||||
for (vector, case) in vectors.iter().zip(all.iter()) {
|
||||
assert_eq!(vector["name"].as_str(), Some(case.name));
|
||||
assert_eq!(vector["messagePackHex"].as_str(), Some(case.csharp_hex), "{}", case.name);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -165,9 +165,38 @@ export interface CultNetLoginSuccessMessage {
|
|||
session: string;
|
||||
}
|
||||
|
||||
/** The closed refusal-code set of cultnet.error.v0 (contracts/cultnet/cultnet.error.schema.json). */
|
||||
export const CULTNET_ERROR_CODES = [
|
||||
"selection_invalid",
|
||||
"cursor_stale",
|
||||
"cursor_invalid",
|
||||
"reference_outside_target",
|
||||
"variant_unsupported",
|
||||
"unowned_schema",
|
||||
"write_not_authorized",
|
||||
] as const;
|
||||
|
||||
export type CultNetErrorCode = (typeof CULTNET_ERROR_CODES)[number];
|
||||
|
||||
export interface CultNetErrorRoutingHint {
|
||||
shard: Record<string, unknown> | null;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface CultNetErrorDetails {
|
||||
field: string | null;
|
||||
value: string | null;
|
||||
asOf: number | null;
|
||||
current: number | null;
|
||||
}
|
||||
|
||||
/** Every key is written on the wire, null included (the C# map formatter never omits a key). */
|
||||
export interface CultNetErrorMessage {
|
||||
schemaVersion: "cultnet.error.v0";
|
||||
error: string;
|
||||
routingHint: CultNetErrorRoutingHint | null;
|
||||
code: CultNetErrorCode | null;
|
||||
details: CultNetErrorDetails | null;
|
||||
}
|
||||
|
||||
export interface CultNetSampleChangeNameMessage {
|
||||
|
|
@ -842,6 +871,10 @@ function parseGameCultNetworkingMessage(input: unknown): CultNetMessage {
|
|||
return {
|
||||
schemaVersion: "cultnet.error.v0",
|
||||
error: requireLegacyString(payload[0], "ErrorMessage.Error"),
|
||||
// The legacy union ErrorMessage carries only Error; the R-N fields exist on cultnet.schema.v0 alone.
|
||||
routingHint: null,
|
||||
code: null,
|
||||
details: null,
|
||||
};
|
||||
case 5:
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -13,7 +13,11 @@ export {
|
|||
type CultNetDatabaseChangeRawMessage,
|
||||
type CultNetRawDocumentRecord,
|
||||
type CultNetRawPayloadEncoding,
|
||||
CULTNET_ERROR_CODES,
|
||||
type CultNetErrorCode,
|
||||
type CultNetErrorDetails,
|
||||
type CultNetErrorMessage,
|
||||
type CultNetErrorRoutingHint,
|
||||
type CultNetHelloMessage,
|
||||
type CultNetLoginMessage,
|
||||
type CultNetLoginSuccessMessage,
|
||||
|
|
|
|||
83
packages/cultnet-ts/test/cultnet-error.test.ts
Normal file
83
packages/cultnet-ts/test/cultnet-error.test.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { decode, encode } from "@msgpack/msgpack";
|
||||
|
||||
import {
|
||||
CULTNET_ERROR_CODES,
|
||||
cultNetSchemas,
|
||||
encodeCultNetMessageForWire,
|
||||
parseCultNetMessage,
|
||||
type CultNetErrorMessage,
|
||||
} from "../src";
|
||||
|
||||
// contracts/cultnet/interop/error-vectors.json: bytes captured from MessagePack C# 3.1.7 and pinned
|
||||
// by cultnet-rs/tests/error_message.rs (Rust encodes to exactly these bytes).
|
||||
const VECTORS = join(__dirname, "..", "..", "..", "..", "contracts", "cultnet", "interop", "error-vectors.json");
|
||||
const vectors = (JSON.parse(readFileSync(VECTORS, "utf8")) as {
|
||||
vectors: { name: string; messagePackHex: string; message: unknown }[];
|
||||
}).vectors;
|
||||
|
||||
const bytesOf = (hex: string): Uint8Array => Uint8Array.from(Buffer.from(hex, "hex"));
|
||||
const hexOf = (bytes: Uint8Array): string => Buffer.from(bytes).toString("hex").toUpperCase();
|
||||
const wireOf = (message: unknown): Uint8Array => encode(encodeCultNetMessageForWire(message as CultNetErrorMessage));
|
||||
|
||||
test("the error vector file carries a case for every closed code", () => {
|
||||
const codes = new Set(vectors.map((v) => (v.message as CultNetErrorMessage).code));
|
||||
for (const code of CULTNET_ERROR_CODES) {
|
||||
assert.ok(codes.has(code), `no vector for ${code}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("a null code with null routingHint and details is a valid error message (no captured bytes carry one)", () => {
|
||||
const message = parseCultNetMessage({
|
||||
schemaVersion: "cultnet.error.v0", error: "boom", routingHint: null, code: null, details: null,
|
||||
});
|
||||
assert.equal((message as CultNetErrorMessage).code, null);
|
||||
});
|
||||
|
||||
test("the closed code union equals the schema's enum", () => {
|
||||
const schemaCodes = ((cultNetSchemas.errorSchema.properties.code as { enum?: (string | null)[] }).enum ?? []).filter((c) => c !== null);
|
||||
assert.deepEqual([...CULTNET_ERROR_CODES].sort(), [...schemaCodes].sort());
|
||||
});
|
||||
|
||||
test("C#/Rust-written error bytes decode into the typed message with every key", () => {
|
||||
for (const vector of vectors) {
|
||||
const parsed = parseCultNetMessage(decode(bytesOf(vector.messagePackHex)));
|
||||
assert.deepEqual(parsed, vector.message, vector.name);
|
||||
assert.deepEqual(Object.keys(parsed), ["schemaVersion", "error", "routingHint", "code", "details"], vector.name);
|
||||
}
|
||||
});
|
||||
|
||||
test("a parsed error message re-encodes to the same bytes", () => {
|
||||
for (const vector of vectors) {
|
||||
const parsed = parseCultNetMessage(decode(bytesOf(vector.messagePackHex)));
|
||||
assert.equal(hexOf(wireOf(parsed)), vector.messagePackHex, vector.name);
|
||||
}
|
||||
});
|
||||
|
||||
test("dropping code or details breaks the round trip and the decode", () => {
|
||||
const vector = vectors.find((v) => v.name === "cursor_stale")!;
|
||||
for (const dropped of ["code", "details", "routingHint"]) {
|
||||
const copy = { ...(decode(bytesOf(vector.messagePackHex)) as Record<string, unknown>) };
|
||||
delete copy[dropped];
|
||||
assert.throws(() => parseCultNetMessage(copy), /cultnet\.error\.v0/, `missing ${dropped} must be refused`);
|
||||
assert.notEqual(hexOf(encode(copy)), vector.messagePackHex);
|
||||
}
|
||||
});
|
||||
|
||||
test("a code outside the closed enum is refused, as is a malformed details object", () => {
|
||||
const base = decode(bytesOf(vectors.find((v) => v.name === "cursor_stale")!.messagePackHex)) as Record<string, unknown>;
|
||||
assert.throws(() => parseCultNetMessage({ ...base, code: "unsupported_schema_version" }), /cultnet\.error\.v0/);
|
||||
assert.throws(() => parseCultNetMessage({ ...base, code: 7 }), /cultnet\.error\.v0/);
|
||||
assert.throws(
|
||||
() => parseCultNetMessage({ ...base, details: { field: null, value: null, asOf: 1, current: 2, extra: 1 } }),
|
||||
/cultnet\.error\.v0/,
|
||||
);
|
||||
assert.throws(
|
||||
() => parseCultNetMessage({ ...base, details: { field: null, value: null, asOf: -1, current: 2 } }),
|
||||
/cultnet\.error\.v0/,
|
||||
);
|
||||
});
|
||||
Loading…
Reference in a new issue