Files
git.stella-ops.org/src/Policy/__Tests/StellaOps.Policy.Engine.Tests/Vex/VexSchemaValidationTests.cs
master 7f7eb8b228 Complete batch 012 (golden set diff) and 013 (advisory chat), fix build errors
Sprints completed:
- SPRINT_20260110_012_* (golden set diff layer - 10 sprints)
- SPRINT_20260110_013_* (advisory chat - 4 sprints)

Build fixes applied:
- Fix namespace conflicts with Microsoft.Extensions.Options.Options.Create
- Fix VexDecisionReachabilityIntegrationTests API drift (major rewrite)
- Fix VexSchemaValidationTests FluentAssertions method name
- Fix FixChainGateIntegrationTests ambiguous type references
- Fix AdvisoryAI test files required properties and namespace aliases
- Add stub types for CveMappingController (ICveSymbolMappingService)
- Fix VerdictBuilderService static context issue

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 10:09:07 +02:00

387 lines
13 KiB
C#

// 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;
/// <summary>
/// Schema validation tests for VEX documents with StellaOps evidence extensions.
/// Validates OpenVEX compliance and extension schema correctness.
/// </summary>
[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<string>().Should().Be("CVE-2024-0001");
node["status"]?.GetValue<string>().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<string>().Should().Be("CR");
node["confidence"]?.GetValue<decimal>().Should().Be(0.99m);
node["has_runtime_evidence"]?.GetValue<bool>().Should().BeTrue();
node["graph_hash"]?.GetValue<string>().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<string>().Should().StartWith("https://openvex.dev/ns/");
node["@id"]?.GetValue<string>().Should().StartWith("urn:uuid:");
node["author"]?.GetValue<string>().Should().NotBeNullOrWhiteSpace();
node["timestamp"].Should().NotBeNull();
node["version"]?.GetValue<int>().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<string>()
.Should().Be(node2[i]!["vulnerability"]?.GetValue<string>());
}
}
#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
}
/// <summary>
/// Constants for VEX justification values.
/// </summary>
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";
}