diff --git a/contracts/cultnet/cultnet.raw-document-header.schema.json b/contracts/cultnet/cultnet.raw-document-header.schema.json new file mode 100644 index 00000000..c85023c9 --- /dev/null +++ b/contracts/cultnet/cultnet.raw-document-header.schema.json @@ -0,0 +1,35 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.raw-document-header.schema.json", + "title": "CultNet Raw Document Header", + "description": "R-S (docs/cultnet-selection-cut.md, fix batch 3): CultNetRawDocumentRecord minus payload/payloadEncoding - CultNetRawDocumentHeader has no such members at all, so a header must not $ref the record schema (which requires them). Matches MessagePack-CSharp's map-mode output: every declared key is written, nil included, so every property here is `required` even where its value may be null.", + "type": "object", + "required": [ + "schemaId", + "schemaName", + "schemaVersion", + "schemaContentHash", + "recordKey", + "storedAt", + "sourceRuntimeId", + "sourceAgentId", + "sourceRole", + "tags" + ], + "additionalProperties": false, + "properties": { + "schemaId": { "type": "string", "minLength": 1 }, + "schemaName": { "type": ["string", "null"], "minLength": 1 }, + "schemaVersion": { "type": ["string", "null"], "minLength": 1 }, + "schemaContentHash": { "type": ["string", "null"], "minLength": 1 }, + "recordKey": { "type": "string", "minLength": 1 }, + "storedAt": { "type": "string", "minLength": 1 }, + "sourceRuntimeId": { "type": ["string", "null"], "minLength": 1 }, + "sourceAgentId": { "type": ["string", "null"], "minLength": 1 }, + "sourceRole": { "type": ["string", "null"], "minLength": 1 }, + "tags": { + "type": ["array", "null"], + "items": { "type": "string", "minLength": 1 } + } + } +} diff --git a/contracts/cultnet/cultnet.raw-document-record.schema.json b/contracts/cultnet/cultnet.raw-document-record.schema.json index 2892a717..445cf8f1 100644 --- a/contracts/cultnet/cultnet.raw-document-record.schema.json +++ b/contracts/cultnet/cultnet.raw-document-record.schema.json @@ -2,17 +2,28 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.raw-document-record.schema.json", "title": "CultNet Raw Document Record", + "description": "R-S (docs/cultnet-selection-cut.md, fix batch 3): matches MessagePack-CSharp's map-mode output for CultNetRawDocumentRecord - every declared key is written, nil included, so every property here is `required` (present on the wire) even where its value may be null.", "type": "object", "required": [ "schemaId", + "schemaName", + "schemaVersion", + "schemaContentHash", "recordKey", "storedAt", "payloadEncoding", - "payload" + "payload", + "sourceRuntimeId", + "sourceAgentId", + "sourceRole", + "tags" ], "additionalProperties": false, "properties": { "schemaId": { "type": "string", "minLength": 1 }, + "schemaName": { "type": ["string", "null"], "minLength": 1 }, + "schemaVersion": { "type": ["string", "null"], "minLength": 1 }, + "schemaContentHash": { "type": ["string", "null"], "minLength": 1 }, "recordKey": { "type": "string", "minLength": 1 }, "storedAt": { "type": "string", "minLength": 1 }, "payloadEncoding": { "const": "messagepack" }, @@ -21,11 +32,11 @@ "contentEncoding": "base64", "x-cult-runtime-type": "bytes" }, - "sourceRuntimeId": { "type": "string", "minLength": 1 }, - "sourceAgentId": { "type": "string", "minLength": 1 }, - "sourceRole": { "type": "string", "minLength": 1 }, + "sourceRuntimeId": { "type": ["string", "null"], "minLength": 1 }, + "sourceAgentId": { "type": ["string", "null"], "minLength": 1 }, + "sourceRole": { "type": ["string", "null"], "minLength": 1 }, "tags": { - "type": "array", + "type": ["array", "null"], "items": { "type": "string", "minLength": 1 } } } diff --git a/contracts/cultnet/cultnet.selection.schema.json b/contracts/cultnet/cultnet.selection.schema.json index 4ae06173..f082e891 100644 --- a/contracts/cultnet/cultnet.selection.schema.json +++ b/contracts/cultnet/cultnet.selection.schema.json @@ -66,8 +66,16 @@ } }, "oneOf": [ - { "required": ["values"], "properties": { "op": { "const": "any_of" } } }, - { "required": ["number"], "properties": { "op": { "enum": ["lt", "le", "ge", "gt"] } } } + { + "required": ["values"], + "not": { "required": ["number"] }, + "properties": { "op": { "const": "any_of" } } + }, + { + "required": ["number"], + "not": { "required": ["values"] }, + "properties": { "op": { "enum": ["lt", "le", "ge", "gt"] } } + } ] }, "citation": { diff --git a/contracts/cultnet/cultnet.snapshot-response-raw.v1.schema.json b/contracts/cultnet/cultnet.snapshot-response-raw.v1.schema.json index 144bd377..63b54405 100644 --- a/contracts/cultnet/cultnet.snapshot-response-raw.v1.schema.json +++ b/contracts/cultnet/cultnet.snapshot-response-raw.v1.schema.json @@ -2,44 +2,58 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.snapshot-response-raw.v1.schema.json", "title": "CultNet Snapshot Response Raw Message v1", + "description": "R-S (docs/cultnet-selection-cut.md, fix batch 3): matches MessagePack-CSharp's map-mode output for CultNetSnapshotResponseRawV1Message - every declared key is written, nil included, so every property is `required` (present on the wire) even where its value may be null. `headers`/`documents` are still exactly one populated by projection (C5), but the OTHER key is present too, as an explicit null, not absent - the prior `oneOf: [{required:[headers]},{required:[documents]}]` matched both branches on every real page (both keys always present) and so never validated at all.", "type": "object", - "required": ["schemaVersion", "messageId", "matched", "asOf"], + "required": [ + "schemaVersion", "messageId", "matched", "asOf", "next", + "headers", "documents", "edges", "shardId", "shardEpoch", "shardLogSequence" + ], "additionalProperties": false, "oneOf": [ - { "required": ["headers"] }, - { "required": ["documents"] } + { + "required": ["headers", "documents"], + "properties": { "headers": { "type": "array" }, "documents": { "type": "null" } } + }, + { + "required": ["headers", "documents"], + "properties": { "headers": { "type": "null" }, "documents": { "type": "array" } } + } ], "properties": { "schemaVersion": { "const": "cultnet.snapshot_response_raw.v1" }, "messageId": { "type": "string", "minLength": 1 }, "matched": { "type": "integer", "minimum": 0 }, "asOf": { "type": "integer", "minimum": 0 }, - "next": { "type": "string" }, + "next": { "type": ["string", "null"] }, "headers": { - "type": "array", - "items": { "$ref": "cultnet.raw-document-record.schema.json" } + "type": ["array", "null"], + "items": { "$ref": "cultnet.raw-document-header.schema.json" } }, "documents": { - "type": "array", + "type": ["array", "null"], "items": { "$ref": "cultnet.raw-document-record.schema.json" } }, "edges": { - "type": "array", + "type": ["array", "null"], "items": { "type": "object", - "required": ["from", "role", "to"], + "required": ["from", "role", "to", "payloadEncoding", "payload"], "additionalProperties": false, "properties": { "from": { "$ref": "cultnet.selection.schema.json#/$defs/recordRef" }, "role": { "type": "string", "minLength": 1 }, "to": { "$ref": "cultnet.selection.schema.json#/$defs/recordRef" }, - "payloadEncoding": { "type": "string" }, - "payload": { "type": "string" } + "payloadEncoding": { "type": ["string", "null"] }, + "payload": { + "type": ["string", "null"], + "contentEncoding": "base64", + "x-cult-runtime-type": "bytes" + } } } }, - "shardId": { "type": "string", "minLength": 1 }, - "shardEpoch": { "type": "integer" }, - "shardLogSequence": { "type": "integer" } + "shardId": { "type": ["string", "null"], "minLength": 1 }, + "shardEpoch": { "type": ["integer", "null"] }, + "shardLogSequence": { "type": ["integer", "null"] } } } diff --git a/tests/GameCult.Networking.Tests/CultNetSchemaContractTests.cs b/tests/GameCult.Networking.Tests/CultNetSchemaContractTests.cs new file mode 100644 index 00000000..8e3d60a4 --- /dev/null +++ b/tests/GameCult.Networking.Tests/CultNetSchemaContractTests.cs @@ -0,0 +1,165 @@ +#nullable enable +using System; +using System.IO; +using System.Text.Json; +using System.Threading.Tasks; +using GameCult.Caching; +using MessagePack; +using NUnit.Framework; + +namespace GameCult.Networking.Tests +{ + // R-S (docs/cultnet-selection-cut.md, "Self's rulings for fix batch 3", F12): the committed + // contracts/cultnet/*.schema.json files describe the bytes MessagePack-CSharp actually writes - + // headers get their own schema (no payload/payloadEncoding, which CultNetRawDocumentHeader has + // no members for at all), the snapshot page's shape matches map-mode output (every declared key + // present, nil included), and fieldPredicate refuses values/number together. This decodes real + // wire bytes from a live CultNetDatabaseServer and checks them against the committed schema + // files with MiniJsonSchemaValidator, rather than asserting on the C# objects alone - a schema + // file can drift from the bytes even while the C# types stay internally consistent, which is + // exactly what F12 was. + public sealed class CultNetSchemaContractTests + { + private static readonly ServerSecurityOptions DevelopmentServerSecurity = ServerSecurityOptions.Development(); + + private static string SchemaDir() => Path.Combine(RepoRoot(), "contracts", "cultnet"); + + 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 JsonElement ToJson(T message) where T : ICultNetSchemaMessage + { + var bytes = CultNetSchemaMessageSerialization.Serialize(message); + var json = MessagePackSerializer.ConvertToJson(bytes); + return JsonDocument.Parse(json).RootElement; + } + + [Test] + public async Task SnapshotResponseRawV1_HeaderProjectionRealBytes_MatchesItsSchema() + { + var cache = new CultCache(); + var database = new CultNetDatabase(cache); + await database.PutAsync(new CultRecordKey("v1:weapon"), new CultNetSelectionEvaluatorTests.SelLeafA + { + Name = "sword", + Kind = "weapon", + Mass = 3 + }); + using var server = new Server(cache, DevelopmentServerSecurity); + using var databaseServer = new CultNetDatabaseServer(server, database); + + var response = databaseServer.CreateSelectionResponse(new CultNetSnapshotRequestV1Message + { + MessageId = "header-projection", + Selection = new CultNetSelection() // default projection is "header" + }); + Assert.That(response.Headers, Is.Not.Null); + Assert.That(response.Documents, Is.Null); + + var validator = new MiniJsonSchemaValidator(SchemaDir()); + validator.AssertValid(ToJson(response), "cultnet.snapshot-response-raw.v1.schema.json"); + } + + [Test] + public async Task SnapshotResponseRawV1_DocumentProjectionRealBytes_MatchesItsSchema() + { + var cache = new CultCache(); + var database = new CultNetDatabase(cache); + await database.PutAsync(new CultRecordKey("v1:weapon"), new CultNetSelectionEvaluatorTests.SelLeafA + { + Name = "sword", + Kind = "weapon", + Mass = 3 + }); + using var server = new Server(cache, DevelopmentServerSecurity); + using var databaseServer = new CultNetDatabaseServer(server, database); + + var response = databaseServer.CreateSelectionResponse(new CultNetSnapshotRequestV1Message + { + MessageId = "document-projection", + Selection = new CultNetSelection { Projection = CultNetSelectionProjections.Document } + }); + Assert.That(response.Documents, Is.Not.Null); + Assert.That(response.Headers, Is.Null); + + var validator = new MiniJsonSchemaValidator(SchemaDir()); + validator.AssertValid(ToJson(response), "cultnet.snapshot-response-raw.v1.schema.json"); + } + + [Test] + public async Task SnapshotResponseRawV1_WithEdgesRealBytes_MatchesItsSchema() + { + var cache = new CultCache(); + var database = new CultNetDatabase(cache); + var target = new CultNetSelectionEvaluatorTests.SelLeafA { Name = "target", Kind = "k", Mass = 1 }; + await database.PutAsync(new CultRecordKey("target"), target); + await database.PutAsync(new CultRecordKey("citer"), new CultNetSelectionEvaluatorTests.SelCiter + { + Name = "citer", + Design = new CultRecordRef(new CultRecordKey("target")) + }); + using var server = new Server(cache, DevelopmentServerSecurity); + using var databaseServer = new CultNetDatabaseServer(server, database); + + var response = databaseServer.CreateSelectionResponse(new CultNetSnapshotRequestV1Message + { + MessageId = "with-edges", + Selection = new CultNetSelection + { + Cited = new CultNetIncoming { Role = "Design", Exists = true }, + Projection = CultNetSelectionProjections.Document + } + }); + Assert.That(response.Edges, Is.Not.Null.And.Length.GreaterThan(0)); + + var validator = new MiniJsonSchemaValidator(SchemaDir()); + validator.AssertValid(ToJson(response), "cultnet.snapshot-response-raw.v1.schema.json"); + } + + // R-S: fieldPredicate is now exclusive - values and number may not both be present, even + // though each alone satisfies one oneOf branch's `required`. Hand-built JSON, not a C# object, + // because CultNetFieldPredicate itself has no door stopping this shape from being constructed + // in memory - the door is CultNetSelectionValidation, a different layer; this test is about the + // wire schema's own oneOf, independent of that door. + [Test] + public void FieldPredicate_ValuesAndNumberTogether_FailsItsSchema() + { + var instance = JsonDocument.Parse( + """{"index":"mass","op":"any_of","values":["1"],"number":"1"}""").RootElement; + + var validator = new MiniJsonSchemaValidator(SchemaDir()); + var errors = validator.Validate(instance, "cultnet.selection.schema.json#/$defs/fieldPredicate"); + + Assert.That(errors, Is.Not.Empty); + } + + [Test] + public void FieldPredicate_ValuesOnly_MatchesItsSchema() + { + var instance = JsonDocument.Parse( + """{"index":"kind","op":"any_of","values":["weapon"]}""").RootElement; + + var validator = new MiniJsonSchemaValidator(SchemaDir()); + var errors = validator.Validate(instance, "cultnet.selection.schema.json#/$defs/fieldPredicate"); + + Assert.That(errors, Is.Empty, string.Join("; ", errors)); + } + + [Test] + public void FieldPredicate_NumberOnly_MatchesItsSchema() + { + var instance = JsonDocument.Parse( + """{"index":"mass","op":"gt","number":"1"}""").RootElement; + + var validator = new MiniJsonSchemaValidator(SchemaDir()); + var errors = validator.Validate(instance, "cultnet.selection.schema.json#/$defs/fieldPredicate"); + + Assert.That(errors, Is.Empty, string.Join("; ", errors)); + } + } +} diff --git a/tests/GameCult.Networking.Tests/MiniJsonSchemaValidator.cs b/tests/GameCult.Networking.Tests/MiniJsonSchemaValidator.cs new file mode 100644 index 00000000..904e4ecb --- /dev/null +++ b/tests/GameCult.Networking.Tests/MiniJsonSchemaValidator.cs @@ -0,0 +1,226 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace GameCult.Networking.Tests +{ + /// + /// A deliberately small JSON Schema (draft 2020-12) validator covering only the constructs + /// contracts/cultnet/*.schema.json actually use: object/array/string/integer/boolean/null types + /// (plain or as a ["type","null"] union), required, additionalProperties: false, properties, + /// items, const, enum, pattern, minLength, minItems, oneOf, and $ref - same-document + /// ("#/$defs/name"), cross-document ("other.schema.json"), and both ("other.schema.json#/$defs/name"). + /// Not a general validator: it exists for R-S (docs/cultnet-selection-cut.md, fix batch 3) to + /// decode real C# wire bytes and check them against the committed schema files, which no + /// dependency in this repo already does - see CultNetSchemaContractTests. + /// + internal sealed class MiniJsonSchemaValidator + { + private readonly string _schemaDir; + private readonly Dictionary _docCache = new(StringComparer.Ordinal); + + public MiniJsonSchemaValidator(string schemaDir) + { + _schemaDir = schemaDir; + } + + /// Validates against the named schema file's root schema. Throws with every collected error when invalid. + public void AssertValid(JsonElement instance, string schemaFileName) + { + var errors = Validate(instance, schemaFileName); + if (errors.Count > 0) + { + throw new InvalidOperationException( + $"{instance} does not satisfy {schemaFileName}:{Environment.NewLine}{string.Join(Environment.NewLine, errors)}"); + } + } + + /// Validates against a $defs entry of the named schema file, e.g. "cultnet.selection.schema.json#/$defs/fieldPredicate". + public List Validate(JsonElement instance, string refText) + { + var errors = new List(); + var (schema, file) = Resolve(refText, currentFile: ""); + ValidateAgainst(instance, schema, file, "$", errors); + return errors; + } + + private JsonElement LoadDoc(string fileName) + { + if (_docCache.TryGetValue(fileName, out var cached)) return cached; + var path = Path.Combine(_schemaDir, fileName); + var doc = JsonDocument.Parse(File.ReadAllText(path)).RootElement; + _docCache[fileName] = doc; + return doc; + } + + private (JsonElement Schema, string File) Resolve(string refText, string currentFile) + { + var hash = refText.IndexOf('#'); + string file; + string pointer; + if (hash < 0) + { + file = refText; + pointer = ""; + } + else + { + var filePart = refText[..hash]; + file = filePart.Length == 0 ? currentFile : filePart; + pointer = refText[(hash + 1)..]; + } + + var doc = LoadDoc(file); + if (string.IsNullOrEmpty(pointer) || pointer == "/") return (doc, file); + + var current = doc; + foreach (var segment in pointer.TrimStart('/').Split('/')) + { + var key = segment.Replace("~1", "/").Replace("~0", "~"); + current = current.GetProperty(key); + } + + return (current, file); + } + + private void ValidateAgainst(JsonElement instance, JsonElement schema, string file, string path, List errors) + { + if (schema.TryGetProperty("$ref", out var refProp)) + { + var (resolved, resolvedFile) = Resolve(refProp.GetString()!, file); + ValidateAgainst(instance, resolved, resolvedFile, path, errors); + return; + } + + if (schema.TryGetProperty("oneOf", out var oneOf)) + { + var matches = 0; + var branchErrors = new List(); + foreach (var branch in oneOf.EnumerateArray()) + { + var local = new List(); + ValidateAgainst(instance, branch, file, path, local); + if (local.Count == 0) matches++; + else branchErrors.AddRange(local); + } + + if (matches != 1) + { + errors.Add($"{path}: oneOf matched {matches} branches (want exactly 1); branch errors: {string.Join(" | ", branchErrors)}"); + } + } + + if (schema.TryGetProperty("const", out var constEl)) + { + if (!JsonElementDeepEquals(instance, constEl)) + errors.Add($"{path}: expected const {constEl}, got {instance}"); + } + + if (schema.TryGetProperty("enum", out var enumEl)) + { + if (!enumEl.EnumerateArray().Any(candidate => JsonElementDeepEquals(instance, candidate))) + errors.Add($"{path}: value {instance} is not one of the declared enum values"); + } + + if (schema.TryGetProperty("type", out var typeEl)) + { + var allowed = typeEl.ValueKind == JsonValueKind.Array + ? typeEl.EnumerateArray().Select(t => t.GetString()!).ToArray() + : new[] { typeEl.GetString()! }; + if (!allowed.Any(t => MatchesType(instance, t))) + errors.Add($"{path}: kind {instance.ValueKind} does not satisfy type [{string.Join(",", allowed)}]"); + } + + // Null short-circuits the value-shaped checks below (a nilled key still satisfies a + // ["...","null"] type, but has no length/properties/items of its own to check). + if (instance.ValueKind == JsonValueKind.Null) + { + return; + } + + if (instance.ValueKind == JsonValueKind.String) + { + var value = instance.GetString()!; + if (schema.TryGetProperty("minLength", out var minLength) && value.Length < minLength.GetInt32()) + errors.Add($"{path}: string shorter than minLength {minLength.GetInt32()}"); + if (schema.TryGetProperty("pattern", out var pattern) && !Regex.IsMatch(value, pattern.GetString()!)) + errors.Add($"{path}: \"{value}\" does not match pattern {pattern.GetString()}"); + } + + if (instance.ValueKind == JsonValueKind.Array) + { + var items = instance.EnumerateArray().ToArray(); + if (schema.TryGetProperty("minItems", out var minItems) && items.Length < minItems.GetInt32()) + errors.Add($"{path}: array shorter than minItems {minItems.GetInt32()}"); + if (schema.TryGetProperty("items", out var itemSchema)) + { + for (var i = 0; i < items.Length; i++) + ValidateAgainst(items[i], itemSchema, file, $"{path}[{i}]", errors); + } + } + + if (instance.ValueKind == JsonValueKind.Object) + { + var propertyNames = instance.EnumerateObject().Select(p => p.Name).ToHashSet(StringComparer.Ordinal); + if (schema.TryGetProperty("required", out var required)) + { + foreach (var name in required.EnumerateArray().Select(r => r.GetString()!)) + { + if (!propertyNames.Contains(name)) + errors.Add($"{path}: missing required property \"{name}\""); + } + } + + var declared = schema.TryGetProperty("properties", out var properties) + ? properties.EnumerateObject().Select(p => p.Name).ToHashSet(StringComparer.Ordinal) + : new HashSet(StringComparer.Ordinal); + if (schema.TryGetProperty("additionalProperties", out var additional) && + additional.ValueKind == JsonValueKind.False) + { + foreach (var name in propertyNames) + { + if (!declared.Contains(name)) + errors.Add($"{path}: property \"{name}\" is not declared and additionalProperties is false"); + } + } + + if (properties.ValueKind == JsonValueKind.Object) + { + foreach (var propertySchema in properties.EnumerateObject()) + { + if (instance.TryGetProperty(propertySchema.Name, out var propertyValue)) + ValidateAgainst(propertyValue, propertySchema.Value, file, $"{path}.{propertySchema.Name}", errors); + } + } + } + } + + private static bool MatchesType(JsonElement instance, string type) => type switch + { + "object" => instance.ValueKind == JsonValueKind.Object, + "array" => instance.ValueKind == JsonValueKind.Array, + "string" => instance.ValueKind == JsonValueKind.String, + "boolean" => instance.ValueKind is JsonValueKind.True or JsonValueKind.False, + "null" => instance.ValueKind == JsonValueKind.Null, + "integer" => instance.ValueKind == JsonValueKind.Number && instance.TryGetInt64(out _), + "number" => instance.ValueKind == JsonValueKind.Number, + _ => throw new NotSupportedException($"Unsupported schema type \"{type}\".") + }; + + private static bool JsonElementDeepEquals(JsonElement a, JsonElement b) + { + if (a.ValueKind != b.ValueKind) return false; + return a.ValueKind switch + { + JsonValueKind.String => a.GetString() == b.GetString(), + JsonValueKind.Number => a.GetRawText() == b.GetRawText(), + JsonValueKind.True or JsonValueKind.False or JsonValueKind.Null => true, + _ => a.GetRawText() == b.GetRawText() + }; + } + } +}