// SPDX-License-Identifier: AGPL-3.0-or-later // Copyright © 2025-2026 StellaOps // Sprint: SPRINT_20260109_009_005_BE_vex_decision_integration // Task: Schema validation tests for VEX documents using System.Text.Json; using System.Text.Json.Nodes; using FluentAssertions; using Xunit; namespace StellaOps.Policy.Engine.Tests.Vex; /// /// Schema validation tests for VEX documents with StellaOps evidence extensions. /// Validates OpenVEX compliance and extension schema correctness. /// [Trait("Category", "Unit")] [Trait("Sprint", "009_005")] public sealed class VexSchemaValidationTests { #region OpenVEX Schema Compliance [Fact(DisplayName = "VexStatement has required OpenVEX fields")] public void VexStatement_HasRequiredOpenVexFields() { // Arrange var statement = new VexStatement { VulnId = "CVE-2024-0001", Status = "not_affected", Justification = VexJustification.VulnerableCodeNotInExecutePath, Products = new[] { "pkg:npm/lodash@4.17.21" }, Timestamp = DateTimeOffset.UtcNow }; // Act var json = JsonSerializer.Serialize(statement, JsonOptions); var node = JsonNode.Parse(json); // Assert: Required fields present node!["vulnerability"]?.GetValue().Should().Be("CVE-2024-0001"); node["status"]?.GetValue().Should().Be("not_affected"); node["products"].Should().NotBeNull(); node["timestamp"].Should().NotBeNull(); } [Theory(DisplayName = "VEX status values are valid OpenVEX statuses")] [InlineData("affected")] [InlineData("not_affected")] [InlineData("fixed")] [InlineData("under_investigation")] public void VexStatus_IsValidOpenVexStatus(string status) { // Arrange var validStatuses = new[] { "affected", "not_affected", "fixed", "under_investigation" }; // Assert validStatuses.Should().Contain(status); } [Theory(DisplayName = "VEX justification values are valid OpenVEX justifications")] [InlineData("component_not_present")] [InlineData("vulnerable_code_not_present")] [InlineData("vulnerable_code_not_in_execute_path")] [InlineData("vulnerable_code_cannot_be_controlled_by_adversary")] [InlineData("inline_mitigations_already_exist")] public void VexJustification_IsValidOpenVexJustification(string justification) { // Arrange var validJustifications = new[] { "component_not_present", "vulnerable_code_not_present", "vulnerable_code_not_in_execute_path", "vulnerable_code_cannot_be_controlled_by_adversary", "inline_mitigations_already_exist" }; // Assert validJustifications.Should().Contain(justification); } #endregion #region StellaOps Evidence Extension Schema [Fact(DisplayName = "Evidence extension follows x- prefix convention")] public void EvidenceExtension_FollowsXPrefixConvention() { // Arrange var evidence = new VexEvidenceBlock { LatticeState = "CU", Confidence = 0.95m, HasRuntimeEvidence = true, GraphHash = "sha256:abc123" }; var statement = new VexStatement { VulnId = "CVE-2024-0001", Status = "not_affected", EvidenceBlock = evidence }; // Act var json = JsonSerializer.Serialize(statement, JsonOptions); // Assert: Extension uses x- prefix json.Should().Contain("\"x-stellaops-evidence\""); } [Fact(DisplayName = "Evidence block has all required fields")] public void EvidenceBlock_HasAllRequiredFields() { // Arrange var evidence = new VexEvidenceBlock { LatticeState = "CR", Confidence = 0.99m, HasRuntimeEvidence = true, GraphHash = "sha256:abc123def456", StaticPaths = new[] { "main->vulnerable_func" }, RuntimeObservations = new[] { "2026-01-10T12:00:00Z: call observed" } }; // Act var json = JsonSerializer.Serialize(evidence, JsonOptions); var node = JsonNode.Parse(json); // Assert: All fields present node!["lattice_state"]?.GetValue().Should().Be("CR"); node["confidence"]?.GetValue().Should().Be(0.99m); node["has_runtime_evidence"]?.GetValue().Should().BeTrue(); node["graph_hash"]?.GetValue().Should().Be("sha256:abc123def456"); node["static_paths"].Should().NotBeNull(); node["runtime_observations"].Should().NotBeNull(); } [Theory(DisplayName = "Lattice state values are valid")] [InlineData("U", true)] // Unknown [InlineData("SR", true)] // Statically Reachable [InlineData("SU", true)] // Statically Unreachable [InlineData("RO", true)] // Runtime Observed [InlineData("RU", true)] // Runtime Unobserved [InlineData("CR", true)] // Confirmed Reachable [InlineData("CU", true)] // Confirmed Unreachable [InlineData("X", true)] // Contested [InlineData("INVALID", false)] [InlineData("", false)] public void LatticeState_IsValid(string state, bool expectedValid) { // Arrange var validStates = new[] { "U", "SR", "SU", "RO", "RU", "CR", "CU", "X" }; // Act var isValid = validStates.Contains(state); // Assert isValid.Should().Be(expectedValid); } [Theory(DisplayName = "Confidence values are within valid range")] [InlineData(0.0, true)] [InlineData(0.5, true)] [InlineData(1.0, true)] [InlineData(-0.1, false)] [InlineData(1.1, false)] public void Confidence_IsWithinValidRange(decimal value, bool expectedValid) { // Act var isValid = value >= 0.0m && value <= 1.0m; // Assert isValid.Should().Be(expectedValid); } #endregion #region Document-Level Schema [Fact(DisplayName = "VexDocument has required OpenVEX document fields")] public void VexDocument_HasRequiredFields() { // Arrange var document = new VexDocument { Context = "https://openvex.dev/ns/v0.2.0", Id = "urn:uuid:12345678-1234-1234-1234-123456789012", Author = "stellaops-vex-emitter@stellaops.io", AuthorRole = "tool", Timestamp = DateTimeOffset.UtcNow, Version = 1, Statements = new[] { new VexStatement { VulnId = "CVE-2024-0001", Status = "not_affected" } } }; // Act var json = JsonSerializer.Serialize(document, JsonOptions); var node = JsonNode.Parse(json); // Assert: Required fields present node!["@context"]?.GetValue().Should().StartWith("https://openvex.dev/ns/"); node["@id"]?.GetValue().Should().StartWith("urn:uuid:"); node["author"]?.GetValue().Should().NotBeNullOrWhiteSpace(); node["timestamp"].Should().NotBeNull(); node["version"]?.GetValue().Should().BeGreaterThanOrEqualTo(1); node["statements"].Should().NotBeNull(); } [Fact(DisplayName = "Document ID is valid URN format")] public void DocumentId_IsValidUrnFormat() { // Arrange var validUrns = new[] { "urn:uuid:12345678-1234-1234-1234-123456789012", "urn:stellaops:vex:tenant:12345", "https://stellaops.io/vex/12345" }; // Assert foreach (var urn in validUrns) { var isValid = urn.StartsWith("urn:") || urn.StartsWith("https://"); isValid.Should().BeTrue($"URN '{urn}' should be valid"); } } [Fact(DisplayName = "Timestamp is ISO 8601 UTC format")] public void Timestamp_IsIso8601UtcFormat() { // Arrange var statement = new VexStatement { VulnId = "CVE-2024-0001", Status = "not_affected", Timestamp = new DateTimeOffset(2026, 1, 10, 12, 0, 0, TimeSpan.Zero) }; // Act var json = JsonSerializer.Serialize(statement, JsonOptions); // Assert: Timestamp is ISO 8601 with Z suffix json.Should().Contain("2026-01-10T12:00:00"); json.Should().MatchRegex(@"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}"); } #endregion #region Determinism Validation [Fact(DisplayName = "Serialization is deterministic")] public void Serialization_IsDeterministic() { // Arrange var evidence = new VexEvidenceBlock { LatticeState = "CU", Confidence = 0.95m, HasRuntimeEvidence = true, GraphHash = "sha256:deterministic123" }; // Act var json1 = JsonSerializer.Serialize(evidence, JsonOptions); var json2 = JsonSerializer.Serialize(evidence, JsonOptions); // Assert: Both serializations are identical json1.Should().Be(json2); } [Fact(DisplayName = "Array ordering is stable")] public void ArrayOrdering_IsStable() { // Arrange var document = new VexDocument { Context = "https://openvex.dev/ns/v0.2.0", Id = "urn:uuid:stable-order-test", Author = "test", Timestamp = DateTimeOffset.UtcNow, Version = 1, Statements = new[] { new VexStatement { VulnId = "CVE-A", Status = "affected" }, new VexStatement { VulnId = "CVE-B", Status = "not_affected" }, new VexStatement { VulnId = "CVE-C", Status = "fixed" } } }; // Act var json1 = JsonSerializer.Serialize(document, JsonOptions); var json2 = JsonSerializer.Serialize(document, JsonOptions); // Parse and verify order var node1 = JsonNode.Parse(json1)!["statements"]!.AsArray(); var node2 = JsonNode.Parse(json2)!["statements"]!.AsArray(); // Assert: Order is preserved for (var i = 0; i < node1.Count; i++) { node1[i]!["vulnerability"]?.GetValue() .Should().Be(node2[i]!["vulnerability"]?.GetValue()); } } #endregion #region Test Models (simplified for schema testing) private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, WriteIndented = false }; private sealed record VexDocument { [System.Text.Json.Serialization.JsonPropertyName("@context")] public required string Context { get; init; } [System.Text.Json.Serialization.JsonPropertyName("@id")] public required string Id { get; init; } public required string Author { get; init; } public string? AuthorRole { get; init; } public required DateTimeOffset Timestamp { get; init; } public required int Version { get; init; } public required VexStatement[] Statements { get; init; } } private sealed record VexStatement { [System.Text.Json.Serialization.JsonPropertyName("vulnerability")] public required string VulnId { get; init; } public required string Status { get; init; } public string? Justification { get; init; } public string[]? Products { get; init; } public DateTimeOffset? Timestamp { get; init; } [System.Text.Json.Serialization.JsonPropertyName("x-stellaops-evidence")] public VexEvidenceBlock? EvidenceBlock { get; init; } } private sealed record VexEvidenceBlock { [System.Text.Json.Serialization.JsonPropertyName("lattice_state")] public required string LatticeState { get; init; } public required decimal Confidence { get; init; } [System.Text.Json.Serialization.JsonPropertyName("has_runtime_evidence")] public required bool HasRuntimeEvidence { get; init; } [System.Text.Json.Serialization.JsonPropertyName("graph_hash")] public string? GraphHash { get; init; } [System.Text.Json.Serialization.JsonPropertyName("static_paths")] public string[]? StaticPaths { get; init; } [System.Text.Json.Serialization.JsonPropertyName("runtime_observations")] public string[]? RuntimeObservations { get; init; } } #endregion } /// /// Constants for VEX justification values. /// public static class VexJustification { public const string ComponentNotPresent = "component_not_present"; public const string VulnerableCodeNotPresent = "vulnerable_code_not_present"; public const string VulnerableCodeNotInExecutePath = "vulnerable_code_not_in_execute_path"; public const string VulnerableCodeCannotBeControlled = "vulnerable_code_cannot_be_controlled_by_adversary"; public const string InlineMitigationsExist = "inline_mitigations_already_exist"; }