// ----------------------------------------------------------------------------- // FindingEvidenceContractsTests.cs // Sprint: SPRINT_4300_0001_0002_findings_evidence_api // Description: Unit tests for JSON serialization of evidence API contracts. // ----------------------------------------------------------------------------- using System; using System.Collections.Generic; using System.Text.Json; using StellaOps.Scanner.WebService.Contracts; using Xunit; namespace StellaOps.Scanner.WebService.Tests; public class FindingEvidenceContractsTests { private static readonly JsonSerializerOptions SerializerOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, WriteIndented = false }; [Fact] public void FindingEvidenceResponse_SerializesToSnakeCase() { var response = new FindingEvidenceResponse { FindingId = "finding-123", Cve = "CVE-2021-44228", Component = new ComponentInfo { Name = "log4j-core", Version = "2.14.1", Purl = "pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1", Ecosystem = "maven" }, ReachablePath = new[] { "com.example.App.main", "org.apache.log4j.Logger.log" }, LastSeen = new DateTimeOffset(2025, 12, 18, 12, 0, 0, TimeSpan.Zero), AttestationRefs = new[] { "dsse:sha256:abc123" }, Freshness = new FreshnessInfo { IsStale = false } }; var json = JsonSerializer.Serialize(response, SerializerOptions); Assert.Contains("\"finding_id\":\"finding-123\"", json); Assert.Contains("\"cve\":\"CVE-2021-44228\"", json); Assert.Contains("\"component\":", json); Assert.Contains("\"reachable_path\":", json); Assert.Contains("\"freshness\":", json); } [Fact] public void FindingEvidenceResponse_RoundTripsCorrectly() { var original = new FindingEvidenceResponse { FindingId = "finding-456", Cve = "CVE-2023-12345", Component = new ComponentInfo { Name = "lodash", Version = "4.17.20", Purl = "pkg:npm/lodash@4.17.20", Ecosystem = "npm" }, Entrypoint = new EntrypointInfo { Type = "http", Route = "/api/v1/users", Method = "POST", Auth = "jwt:write" }, Score = new ScoreInfo { RiskScore = 75, Contributions = new[] { new ScoreContribution { Factor = "reachability", Value = 25, Reason = "Reachable from entrypoint" } } }, LastSeen = DateTimeOffset.UtcNow, Freshness = new FreshnessInfo { IsStale = false } }; var json = JsonSerializer.Serialize(original, SerializerOptions); var deserialized = JsonSerializer.Deserialize(json, SerializerOptions); Assert.NotNull(deserialized); Assert.Equal(original.FindingId, deserialized.FindingId); Assert.Equal(original.Cve, deserialized.Cve); Assert.Equal(original.Component.Purl, deserialized.Component.Purl); Assert.Equal(original.Entrypoint?.Type, deserialized.Entrypoint?.Type); Assert.Equal(original.Score?.RiskScore, deserialized.Score?.RiskScore); } [Fact] public void ComponentInfo_SerializesAllFields() { var component = new ComponentInfo { Name = "Newtonsoft.Json", Version = "13.0.1", Purl = "pkg:nuget/Newtonsoft.Json@13.0.1", Ecosystem = "nuget" }; var json = JsonSerializer.Serialize(component, SerializerOptions); Assert.Contains("\"name\":\"Newtonsoft.Json\"", json); Assert.Contains("\"version\":\"13.0.1\"", json); Assert.Contains("\"purl\":\"pkg:nuget/Newtonsoft.Json@13.0.1\"", json); Assert.Contains("\"ecosystem\":\"nuget\"", json); } [Fact] public void EntrypointInfo_SerializesAllFields() { var entrypoint = new EntrypointInfo { Type = "grpc", Route = "grpc.UserService.GetUser", Method = "CALL", Auth = "mtls" }; var json = JsonSerializer.Serialize(entrypoint, SerializerOptions); Assert.Contains("\"type\":\"grpc\"", json); Assert.Contains("\"route\":\"grpc.UserService.GetUser\"", json); Assert.Contains("\"method\":\"CALL\"", json); Assert.Contains("\"auth\":\"mtls\"", json); } [Fact] public void BoundaryInfo_SerializesWithControls() { var boundary = new BoundaryInfo { Surface = "api", Exposure = "internet", Controls = new[] { "waf", "rate_limit" } }; var json = JsonSerializer.Serialize(boundary, SerializerOptions); Assert.Contains("\"surface\":\"api\"", json); Assert.Contains("\"exposure\":\"internet\"", json); Assert.Contains("\"controls\":[\"waf\",\"rate_limit\"]", json); } [Fact] public void VexStatusInfo_SerializesCorrectly() { var vex = new VexStatusInfo { Status = "not_affected", Justification = "vulnerable_code_not_in_execute_path", Timestamp = new DateTimeOffset(2025, 12, 1, 0, 0, 0, TimeSpan.Zero), Issuer = "vendor" }; var json = JsonSerializer.Serialize(vex, SerializerOptions); Assert.Contains("\"status\":\"not_affected\"", json); Assert.Contains("\"justification\":\"vulnerable_code_not_in_execute_path\"", json); Assert.Contains("\"issuer\":\"vendor\"", json); } [Fact] public void ScoreInfo_SerializesContributions() { var score = new ScoreInfo { RiskScore = 62, Contributions = new[] { new ScoreContribution { Factor = "cvss_base", Value = 40, Reason = "Critical CVSS base score" }, new ScoreContribution { Factor = "reachability", Value = 22, Reason = "Reachable from HTTP entrypoint" } } }; var json = JsonSerializer.Serialize(score, SerializerOptions); Assert.Contains("\"risk_score\":62", json); Assert.Contains("\"factor\":\"cvss_base\"", json); Assert.Contains("\"factor\":\"reachability\"", json); } [Fact] public void FreshnessInfo_SerializesCorrectly() { var freshness = new FreshnessInfo { IsStale = true, ExpiresAt = new DateTimeOffset(2025, 12, 31, 0, 0, 0, TimeSpan.Zero), TtlRemainingHours = 0 }; var json = JsonSerializer.Serialize(freshness, SerializerOptions); Assert.Contains("\"is_stale\":true", json); Assert.Contains("\"expires_at\":", json); Assert.Contains("\"ttl_remaining_hours\":0", json); } [Fact] public void NullOptionalFields_AreOmittedOrNullInJson() { var response = new FindingEvidenceResponse { FindingId = "finding-minimal", Cve = "CVE-2025-0001", Component = new ComponentInfo { Name = "unknown", Version = "unknown" }, LastSeen = DateTimeOffset.UtcNow, Freshness = new FreshnessInfo { IsStale = false } }; var json = JsonSerializer.Serialize(response, SerializerOptions); var deserialized = JsonSerializer.Deserialize(json, SerializerOptions); Assert.NotNull(deserialized); Assert.Null(deserialized.Entrypoint); Assert.Null(deserialized.Vex); Assert.Null(deserialized.Score); Assert.Null(deserialized.Boundary); } }