1443 lines
49 KiB
C#
1443 lines
49 KiB
C#
// -----------------------------------------------------------------------------
|
|
// DeterminismReplayGoldenTests.cs
|
|
// Sprint: SPRINT_20260117_014_CLI_determinism_replay
|
|
// Task: DRP-004 - Golden file tests for replay verification
|
|
// Description: Golden output tests for HLC, Timeline, and Score Explain commands
|
|
// -----------------------------------------------------------------------------
|
|
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Serialization;
|
|
using FluentAssertions;
|
|
using StellaOps.TestKit;
|
|
using Xunit;
|
|
|
|
namespace StellaOps.Cli.Tests.GoldenOutput;
|
|
|
|
/// <summary>
|
|
/// Golden output tests for determinism and replay CLI commands.
|
|
/// Verifies that HLC status, timeline query, and score explain
|
|
/// produce consistent, deterministic outputs matching frozen snapshots.
|
|
/// Task: DRP-004
|
|
///
|
|
/// HOW TO UPDATE GOLDEN FILES:
|
|
/// 1. Run tests to identify failures
|
|
/// 2. Review the actual output carefully to ensure changes are intentional
|
|
/// 3. Update the expected golden snapshot in this file
|
|
/// 4. Document the reason for the change in the commit message
|
|
/// </summary>
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[Trait("Category", "GoldenOutput")]
|
|
[Trait("Category", "Determinism")]
|
|
[Trait("Sprint", "20260117-014")]
|
|
public sealed class DeterminismReplayGoldenTests
|
|
{
|
|
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
|
{
|
|
WriteIndented = true,
|
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
|
};
|
|
|
|
// Fixed timestamp for deterministic tests
|
|
private static readonly DateTimeOffset FixedTimestamp = new(2026, 1, 15, 10, 30, 0, TimeSpan.Zero);
|
|
|
|
#region HLC Status Golden Tests (DRP-001)
|
|
|
|
/// <summary>
|
|
/// Verifies that HLC status JSON output matches golden snapshot.
|
|
/// </summary>
|
|
[Fact]
|
|
public void HlcStatus_Json_MatchesGolden()
|
|
{
|
|
// Arrange
|
|
var status = CreateFrozenHlcStatus();
|
|
|
|
// Act
|
|
var actual = JsonSerializer.Serialize(status, JsonOptions).NormalizeLf();
|
|
|
|
// Assert - Golden snapshot
|
|
var expected = """
|
|
{
|
|
"nodeId": "node-01",
|
|
"healthy": true,
|
|
"currentTimestamp": {
|
|
"physical": 1736937000000,
|
|
"logical": 42,
|
|
"nodeId": "node-01"
|
|
},
|
|
"formattedTimestamp": "2026-01-15T10:30:00.000Z:0042:node-01",
|
|
"clockDriftMs": 3.2,
|
|
"ntpServer": "time.google.com",
|
|
"lastNtpSync": "2026-01-15T10:25:00+00:00",
|
|
"clusterState": {
|
|
"totalNodes": 3,
|
|
"syncedNodes": 3,
|
|
"peers": [
|
|
{
|
|
"nodeId": "node-01",
|
|
"status": "synced",
|
|
"lastSeen": "2026-01-15T10:30:00+00:00",
|
|
"driftMs": 0
|
|
},
|
|
{
|
|
"nodeId": "node-02",
|
|
"status": "synced",
|
|
"lastSeen": "2026-01-15T10:29:58+00:00",
|
|
"driftMs": 1.5
|
|
},
|
|
{
|
|
"nodeId": "node-03",
|
|
"status": "synced",
|
|
"lastSeen": "2026-01-15T10:29:55+00:00",
|
|
"driftMs": 2.8
|
|
}
|
|
]
|
|
},
|
|
"checkedAt": "2026-01-15T10:30:00+00:00"
|
|
}
|
|
""".NormalizeLf();
|
|
|
|
actual.Should().Be(expected);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that HLC status text output matches golden snapshot.
|
|
/// </summary>
|
|
[Fact]
|
|
public void HlcStatus_Text_MatchesGolden()
|
|
{
|
|
// Arrange
|
|
var status = CreateFrozenHlcStatus();
|
|
|
|
// Act
|
|
var actual = FormatHlcStatusText(status, verbose: false).NormalizeLf();
|
|
|
|
// Assert - Golden snapshot
|
|
var expected = """
|
|
HLC Node Status
|
|
===============
|
|
|
|
Health: [OK] Healthy
|
|
Node ID: node-01
|
|
HLC Timestamp: 2026-01-15T10:30:00.000Z:0042:node-01
|
|
Clock Drift: 3.2 ms
|
|
NTP Server: time.google.com
|
|
Last NTP Sync: 2026-01-15 10:25:00Z
|
|
|
|
Cluster State:
|
|
Nodes: 3/3 synced
|
|
|
|
Checked At: 2026-01-15 10:30:00Z
|
|
""".NormalizeLf();
|
|
|
|
actual.Trim().Should().Be(expected.Trim());
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that HLC status verbose text output matches golden snapshot.
|
|
/// </summary>
|
|
[Fact]
|
|
public void HlcStatus_TextVerbose_MatchesGolden()
|
|
{
|
|
// Arrange
|
|
var status = CreateFrozenHlcStatus();
|
|
|
|
// Act
|
|
var actual = FormatHlcStatusText(status, verbose: true).NormalizeLf();
|
|
|
|
// Assert - Should contain peer table
|
|
actual.Should().Contain("Peer Status:");
|
|
actual.Should().Contain("node-01");
|
|
actual.Should().Contain("node-02");
|
|
actual.Should().Contain("node-03");
|
|
actual.Should().Contain("synced");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that HLC status produces consistent output across multiple runs.
|
|
/// </summary>
|
|
[Fact]
|
|
public void HlcStatus_SameInputs_ProducesIdenticalOutput()
|
|
{
|
|
// Arrange
|
|
var status1 = CreateFrozenHlcStatus();
|
|
var status2 = CreateFrozenHlcStatus();
|
|
|
|
// Act
|
|
var json1 = JsonSerializer.Serialize(status1, JsonOptions);
|
|
var json2 = JsonSerializer.Serialize(status2, JsonOptions);
|
|
|
|
// Assert
|
|
json1.Should().Be(json2);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Timeline Query Golden Tests (DRP-002)
|
|
|
|
/// <summary>
|
|
/// Verifies that timeline query JSON output matches golden snapshot.
|
|
/// </summary>
|
|
[Fact]
|
|
public void TimelineQuery_Json_MatchesGolden()
|
|
{
|
|
// Arrange
|
|
var result = CreateFrozenTimelineResult();
|
|
|
|
// Act
|
|
var actual = JsonSerializer.Serialize(result, JsonOptions).NormalizeLf();
|
|
|
|
// Assert - Golden snapshot
|
|
var expected = """
|
|
{
|
|
"events": [
|
|
{
|
|
"hlcTimestamp": "1737000000000000001",
|
|
"type": "scan",
|
|
"entityId": "sha256:abc123def456",
|
|
"actor": "scanner-agent-1",
|
|
"details": "SBOM generated"
|
|
},
|
|
{
|
|
"hlcTimestamp": "1737000000000000002",
|
|
"type": "attest",
|
|
"entityId": "sha256:abc123def456",
|
|
"actor": "attestor-1",
|
|
"details": "SLSA provenance created"
|
|
},
|
|
{
|
|
"hlcTimestamp": "1737000000000000003",
|
|
"type": "policy",
|
|
"entityId": "sha256:abc123def456",
|
|
"actor": "policy-engine",
|
|
"details": "Policy evaluation: PASS"
|
|
},
|
|
{
|
|
"hlcTimestamp": "1737000000000000004",
|
|
"type": "promote",
|
|
"entityId": "release-2026.01.15-001",
|
|
"actor": "ops@example.com",
|
|
"details": "Promoted from dev to stage"
|
|
}
|
|
],
|
|
"pagination": {
|
|
"offset": 0,
|
|
"limit": 50,
|
|
"total": 4,
|
|
"hasMore": false
|
|
},
|
|
"determinismHash": "sha256:a1b2c3d4e5f67890"
|
|
}
|
|
""".NormalizeLf();
|
|
|
|
actual.Should().Be(expected);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that timeline query table output matches golden snapshot.
|
|
/// </summary>
|
|
[Fact]
|
|
public void TimelineQuery_Table_MatchesGolden()
|
|
{
|
|
// Arrange
|
|
var events = CreateFrozenTimelineEvents();
|
|
|
|
// Act
|
|
var actual = FormatTimelineTable(events).NormalizeLf();
|
|
|
|
// Assert - Golden snapshot header
|
|
actual.Should().Contain("Timeline Events");
|
|
actual.Should().Contain("HLC Timestamp");
|
|
actual.Should().Contain("Type");
|
|
actual.Should().Contain("Entity");
|
|
actual.Should().Contain("Actor");
|
|
|
|
// Events should appear in HLC timestamp order
|
|
var scanIndex = actual.IndexOf("scan");
|
|
var attestIndex = actual.IndexOf("attest");
|
|
var policyIndex = actual.IndexOf("policy");
|
|
var promoteIndex = actual.IndexOf("promote");
|
|
|
|
scanIndex.Should().BeLessThan(attestIndex);
|
|
attestIndex.Should().BeLessThan(policyIndex);
|
|
policyIndex.Should().BeLessThan(promoteIndex);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that timeline events are sorted by HLC timestamp.
|
|
/// </summary>
|
|
[Fact]
|
|
public void TimelineQuery_EventsAreSortedByHlcTimestamp()
|
|
{
|
|
// Arrange - Events in random order
|
|
var events = new List<TimelineEvent>
|
|
{
|
|
new() { HlcTimestamp = "1737000000000000004", Type = "promote", EntityId = "release-001", Actor = "ops", Details = "Promoted" },
|
|
new() { HlcTimestamp = "1737000000000000001", Type = "scan", EntityId = "sha256:abc", Actor = "scanner", Details = "Scanned" },
|
|
new() { HlcTimestamp = "1737000000000000003", Type = "policy", EntityId = "sha256:abc", Actor = "policy", Details = "Evaluated" },
|
|
new() { HlcTimestamp = "1737000000000000002", Type = "attest", EntityId = "sha256:abc", Actor = "attestor", Details = "Attested" }
|
|
};
|
|
|
|
// Act - Sort as timeline query would
|
|
var sorted = events.OrderBy(e => e.HlcTimestamp).ToList();
|
|
|
|
// Assert - Events should be in ascending HLC timestamp order
|
|
sorted[0].Type.Should().Be("scan");
|
|
sorted[1].Type.Should().Be("attest");
|
|
sorted[2].Type.Should().Be("policy");
|
|
sorted[3].Type.Should().Be("promote");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that timeline determinism hash is consistent.
|
|
/// </summary>
|
|
[Fact]
|
|
public void TimelineQuery_DeterminismHashIsConsistent()
|
|
{
|
|
// Arrange
|
|
var events1 = CreateFrozenTimelineEvents();
|
|
var events2 = CreateFrozenTimelineEvents();
|
|
|
|
// Act
|
|
var hash1 = ComputeTimelineDeterminismHash(events1);
|
|
var hash2 = ComputeTimelineDeterminismHash(events2);
|
|
|
|
// Assert
|
|
hash1.Should().Be(hash2);
|
|
hash1.Should().StartWith("sha256:");
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Score Explain Golden Tests (DRP-003)
|
|
|
|
/// <summary>
|
|
/// Verifies that score explain JSON output matches golden snapshot.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ScoreExplain_Json_MatchesGolden()
|
|
{
|
|
// Arrange
|
|
var explanation = CreateFrozenScoreExplanation();
|
|
EnsureScoreExplanationDeterminism(explanation);
|
|
|
|
// Act
|
|
var actual = JsonSerializer.Serialize(explanation, JsonOptions).NormalizeLf();
|
|
|
|
// Assert - Golden snapshot
|
|
var expected = """
|
|
{
|
|
"digest": "sha256:abc123def456789012345678901234567890123456789012345678901234",
|
|
"finalScore": 7.500000,
|
|
"scoreBreakdown": {
|
|
"baseScore": 8.100000,
|
|
"cvssScore": 8.100000,
|
|
"epssAdjustment": -0.300000,
|
|
"reachabilityAdjustment": -0.200000,
|
|
"vexAdjustment": -0.100000,
|
|
"factors": [
|
|
{
|
|
"name": "CVSS Base Score",
|
|
"value": 8.100000,
|
|
"weight": 0.400000,
|
|
"contribution": 3.240000,
|
|
"source": "NVD",
|
|
"details": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N"
|
|
},
|
|
{
|
|
"name": "EPSS Probability",
|
|
"value": 0.150000,
|
|
"weight": 0.200000,
|
|
"contribution": 1.500000,
|
|
"source": "FIRST EPSS",
|
|
"details": "15th percentile exploitation probability"
|
|
},
|
|
{
|
|
"name": "KEV Status",
|
|
"value": 0.000000,
|
|
"weight": 0.050000,
|
|
"contribution": 0.000000,
|
|
"source": "CISA KEV",
|
|
"details": "Not in Known Exploited Vulnerabilities catalog"
|
|
},
|
|
{
|
|
"name": "Reachability",
|
|
"value": 0.700000,
|
|
"weight": 0.250000,
|
|
"contribution": 1.750000,
|
|
"source": "Static Analysis",
|
|
"details": "Reachable via 2 call paths; confidence 0.7"
|
|
},
|
|
{
|
|
"name": "VEX Status",
|
|
"value": 0.000000,
|
|
"weight": 0.100000,
|
|
"contribution": 0.000000,
|
|
"source": "OpenVEX",
|
|
"details": "No VEX statement available"
|
|
}
|
|
]
|
|
},
|
|
"computedAt": "2026-01-15T10:30:00+00:00",
|
|
"profileUsed": "stella-default-v1",
|
|
"determinismHash": "sha256:b3c4d5e6f7a89012"
|
|
}
|
|
""".NormalizeLf();
|
|
|
|
actual.Should().Be(expected);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that score explain factors are sorted alphabetically.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ScoreExplain_FactorsAreSortedAlphabetically()
|
|
{
|
|
// Arrange - Create explanation with unsorted factors
|
|
var explanation = CreateFrozenScoreExplanation();
|
|
|
|
// Act
|
|
EnsureScoreExplanationDeterminism(explanation);
|
|
|
|
// Assert - Factors should be sorted by name
|
|
var factorNames = explanation.ScoreBreakdown.Factors.Select(f => f.Name).ToList();
|
|
factorNames.Should().BeInAscendingOrder();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that floating-point values have stable 6-decimal precision.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ScoreExplain_FloatingPointValuesHaveStablePrecision()
|
|
{
|
|
// Arrange
|
|
var explanation = CreateFrozenScoreExplanation();
|
|
EnsureScoreExplanationDeterminism(explanation);
|
|
|
|
// Act
|
|
var json = JsonSerializer.Serialize(explanation, JsonOptions);
|
|
|
|
// Assert - Values should have 6 decimal places
|
|
json.Should().Contain("7.500000");
|
|
json.Should().Contain("8.100000");
|
|
json.Should().Contain("-0.300000");
|
|
json.Should().Contain("-0.200000");
|
|
json.Should().Contain("-0.100000");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that score explain determinism hash is consistent.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ScoreExplain_DeterminismHashIsConsistent()
|
|
{
|
|
// Arrange
|
|
var exp1 = CreateFrozenScoreExplanation();
|
|
var exp2 = CreateFrozenScoreExplanation();
|
|
|
|
// Act
|
|
EnsureScoreExplanationDeterminism(exp1);
|
|
EnsureScoreExplanationDeterminism(exp2);
|
|
|
|
// Assert
|
|
exp1.DeterminismHash.Should().Be(exp2.DeterminismHash);
|
|
exp1.DeterminismHash.Should().StartWith("sha256:");
|
|
exp1.DeterminismHash.Should().HaveLength(24); // "sha256:" + 16 hex chars
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that same inputs produce identical outputs (byte-for-byte).
|
|
/// </summary>
|
|
[Fact]
|
|
public void ScoreExplain_SameInputs_ProducesIdenticalOutput()
|
|
{
|
|
// Arrange
|
|
var exp1 = CreateFrozenScoreExplanation();
|
|
var exp2 = CreateFrozenScoreExplanation();
|
|
|
|
// Act
|
|
EnsureScoreExplanationDeterminism(exp1);
|
|
EnsureScoreExplanationDeterminism(exp2);
|
|
|
|
var json1 = JsonSerializer.Serialize(exp1, JsonOptions);
|
|
var json2 = JsonSerializer.Serialize(exp2, JsonOptions);
|
|
|
|
// Assert
|
|
json1.Should().Be(json2);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that different inputs produce different determinism hashes.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ScoreExplain_DifferentInputs_ProducesDifferentHash()
|
|
{
|
|
// Arrange
|
|
var exp1 = CreateFrozenScoreExplanation();
|
|
var exp2 = CreateFrozenScoreExplanation();
|
|
exp2.FinalScore = 8.0; // Different score
|
|
|
|
// Act
|
|
EnsureScoreExplanationDeterminism(exp1);
|
|
EnsureScoreExplanationDeterminism(exp2);
|
|
|
|
// Assert
|
|
exp1.DeterminismHash.Should().NotBe(exp2.DeterminismHash);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Explain Block Golden Tests (Sprint 026 - WHY-004)
|
|
|
|
/// <summary>
|
|
/// Verifies that explain block JSON output matches golden snapshot.
|
|
/// Sprint: SPRINT_20260117_026_CLI_why_blocked_command
|
|
/// </summary>
|
|
[Fact]
|
|
public void ExplainBlock_Json_MatchesGolden()
|
|
{
|
|
// Arrange
|
|
var explanation = CreateFrozenBlockExplanation();
|
|
|
|
// Act
|
|
var actual = JsonSerializer.Serialize(explanation, JsonOptions).NormalizeLf();
|
|
|
|
// Assert - Golden snapshot
|
|
var expected = """
|
|
{
|
|
"artifact": "sha256:abc123def456789012345678901234567890123456789012345678901234",
|
|
"status": "BLOCKED",
|
|
"gate": "VexTrust",
|
|
"reason": "Trust score below threshold (0.45 \u003C 0.70)",
|
|
"suggestion": "Obtain VEX statement from trusted issuer or add issuer to trust registry",
|
|
"evaluationTime": "2026-01-15T10:30:00+00:00",
|
|
"policyVersion": "v2.3.0",
|
|
"evidence": [
|
|
{
|
|
"type": "REACH",
|
|
"id": "reach:sha256:789abc123def456",
|
|
"source": "static-analysis",
|
|
"timestamp": "2026-01-15T08:00:00+00:00"
|
|
},
|
|
{
|
|
"type": "VEX",
|
|
"id": "vex:sha256:def456789abc123",
|
|
"source": "vendor-x",
|
|
"timestamp": "2026-01-15T09:00:00+00:00"
|
|
}
|
|
],
|
|
"replayCommand": "stella verify verdict --verdict urn:stella:verdict:sha256:abc123:v2.3.0:1737108000",
|
|
"replayToken": "urn:stella:verdict:sha256:abc123:v2.3.0:1737108000",
|
|
"evaluationTrace": [
|
|
{
|
|
"step": 1,
|
|
"gate": "SbomPresent",
|
|
"result": "PASS",
|
|
"durationMs": 15
|
|
},
|
|
{
|
|
"step": 2,
|
|
"gate": "VexTrust",
|
|
"result": "FAIL",
|
|
"durationMs": 45
|
|
},
|
|
{
|
|
"step": 3,
|
|
"gate": "VulnScan",
|
|
"result": "PASS",
|
|
"durationMs": 250
|
|
}
|
|
],
|
|
"determinismHash": "sha256:e3b0c44298fc1c14"
|
|
}
|
|
""".NormalizeLf();
|
|
|
|
actual.Should().Be(expected);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that explain block table output matches golden snapshot.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ExplainBlock_Table_MatchesGolden()
|
|
{
|
|
// Arrange
|
|
var explanation = CreateFrozenBlockExplanation();
|
|
|
|
// Act
|
|
var actual = FormatBlockExplanationTable(explanation, showEvidence: false, showTrace: false).NormalizeLf();
|
|
|
|
// Assert - Golden snapshot
|
|
var expected = """
|
|
Artifact: sha256:abc123def456789012345678901234567890123456789012345678901234
|
|
Status: BLOCKED
|
|
|
|
Gate: VexTrust
|
|
Reason: Trust score below threshold (0.45 < 0.70)
|
|
Suggestion: Obtain VEX statement from trusted issuer or add issuer to trust registry
|
|
|
|
Evidence:
|
|
[REACH ] reach:sha256...def456 static-analysis 2026-01-15T08:00:00Z
|
|
[VEX ] vex:sha256:d...bc123 vendor-x 2026-01-15T09:00:00Z
|
|
|
|
Replay: stella verify verdict --verdict urn:stella:verdict:sha256:abc123:v2.3.0:1737108000
|
|
""".NormalizeLf();
|
|
|
|
actual.Trim().Should().Be(expected.Trim());
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that explain block markdown output matches golden snapshot.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ExplainBlock_Markdown_MatchesGolden()
|
|
{
|
|
// Arrange
|
|
var explanation = CreateFrozenBlockExplanation();
|
|
|
|
// Act
|
|
var actual = FormatBlockExplanationMarkdown(explanation, showEvidence: false, showTrace: false).NormalizeLf();
|
|
|
|
// Assert - Key elements present
|
|
actual.Should().Contain("## Block Explanation");
|
|
actual.Should().Contain("**Artifact:** `sha256:abc123def456789012345678901234567890123456789012345678901234`");
|
|
actual.Should().Contain("**Status:** BLOCKED");
|
|
actual.Should().Contain("### Gate Decision");
|
|
actual.Should().Contain("| Property | Value |");
|
|
actual.Should().Contain("| Gate | VexTrust |");
|
|
actual.Should().Contain("| Reason | Trust score below threshold");
|
|
actual.Should().Contain("### Evidence");
|
|
actual.Should().Contain("| Type | ID | Source | Timestamp |");
|
|
actual.Should().Contain("### Verification");
|
|
actual.Should().Contain("```bash");
|
|
actual.Should().Contain("stella verify verdict --verdict");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that explain block with --show-trace includes evaluation trace.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ExplainBlock_WithTrace_MatchesGolden()
|
|
{
|
|
// Arrange
|
|
var explanation = CreateFrozenBlockExplanation();
|
|
|
|
// Act
|
|
var actual = FormatBlockExplanationTable(explanation, showEvidence: false, showTrace: true).NormalizeLf();
|
|
|
|
// Assert
|
|
actual.Should().Contain("Evaluation Trace:");
|
|
actual.Should().Contain("1. SbomPresent");
|
|
actual.Should().Contain("PASS");
|
|
actual.Should().Contain("2. VexTrust");
|
|
actual.Should().Contain("FAIL");
|
|
actual.Should().Contain("3. VulnScan");
|
|
actual.Should().Contain("PASS");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that same inputs produce identical outputs (byte-for-byte).
|
|
/// M2 moat requirement: Deterministic trace + referenced evidence artifacts.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ExplainBlock_SameInputs_ProducesIdenticalOutput()
|
|
{
|
|
// Arrange
|
|
var exp1 = CreateFrozenBlockExplanation();
|
|
var exp2 = CreateFrozenBlockExplanation();
|
|
|
|
// Act
|
|
var json1 = JsonSerializer.Serialize(exp1, JsonOptions);
|
|
var json2 = JsonSerializer.Serialize(exp2, JsonOptions);
|
|
var table1 = FormatBlockExplanationTable(exp1, true, true);
|
|
var table2 = FormatBlockExplanationTable(exp2, true, true);
|
|
var md1 = FormatBlockExplanationMarkdown(exp1, true, true);
|
|
var md2 = FormatBlockExplanationMarkdown(exp2, true, true);
|
|
|
|
// Assert - All formats must be identical
|
|
json1.Should().Be(json2, "JSON output must be deterministic");
|
|
table1.Should().Be(table2, "Table output must be deterministic");
|
|
md1.Should().Be(md2, "Markdown output must be deterministic");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that evidence is sorted by timestamp for deterministic ordering.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ExplainBlock_EvidenceIsSortedByTimestamp()
|
|
{
|
|
// Arrange
|
|
var explanation = CreateFrozenBlockExplanation();
|
|
|
|
// Assert - Evidence should be sorted by timestamp (ascending)
|
|
var timestamps = explanation.Evidence.Select(e => e.Timestamp).ToList();
|
|
timestamps.Should().BeInAscendingOrder();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that evaluation trace is sorted by step number.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ExplainBlock_TraceIsSortedByStep()
|
|
{
|
|
// Arrange
|
|
var explanation = CreateFrozenBlockExplanation();
|
|
|
|
// Assert - Trace should be sorted by step number
|
|
var steps = explanation.EvaluationTrace.Select(t => t.Step).ToList();
|
|
steps.Should().BeInAscendingOrder();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that not-blocked artifacts produce deterministic output.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ExplainBlock_NotBlocked_MatchesGolden()
|
|
{
|
|
// Arrange
|
|
var explanation = CreateFrozenNotBlockedExplanation();
|
|
|
|
// Act
|
|
var actual = JsonSerializer.Serialize(explanation, JsonOptions).NormalizeLf();
|
|
|
|
// Assert - Golden snapshot for not blocked
|
|
var expected = """
|
|
{
|
|
"artifact": "sha256:fedcba9876543210",
|
|
"status": "NOT_BLOCKED",
|
|
"message": "Artifact passed all policy gates",
|
|
"gatesEvaluated": 5,
|
|
"evaluationTime": "2026-01-15T10:30:00+00:00",
|
|
"policyVersion": "v2.3.0"
|
|
}
|
|
""".NormalizeLf();
|
|
|
|
actual.Should().Be(expected);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Cross-Platform Golden Tests
|
|
|
|
/// <summary>
|
|
/// Verifies that JSON output uses consistent line endings (LF).
|
|
/// </summary>
|
|
[Fact]
|
|
public void AllOutputs_UseConsistentLineEndings()
|
|
{
|
|
// Arrange
|
|
var hlcStatus = CreateFrozenHlcStatus();
|
|
var timeline = CreateFrozenTimelineResult();
|
|
var score = CreateFrozenScoreExplanation();
|
|
|
|
// Act
|
|
var hlcJson = JsonSerializer.Serialize(hlcStatus, JsonOptions);
|
|
var timelineJson = JsonSerializer.Serialize(timeline, JsonOptions);
|
|
var scoreJson = JsonSerializer.Serialize(score, JsonOptions);
|
|
|
|
// Assert - Should not contain CRLF
|
|
hlcJson.Should().NotContain("\r\n");
|
|
timelineJson.Should().NotContain("\r\n");
|
|
scoreJson.Should().NotContain("\r\n");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that timestamps use ISO 8601 format with UTC.
|
|
/// </summary>
|
|
[Fact]
|
|
public void AllOutputs_TimestampsAreIso8601Utc()
|
|
{
|
|
// Arrange
|
|
var hlcStatus = CreateFrozenHlcStatus();
|
|
var score = CreateFrozenScoreExplanation();
|
|
|
|
// Act
|
|
var hlcJson = JsonSerializer.Serialize(hlcStatus, JsonOptions);
|
|
var scoreJson = JsonSerializer.Serialize(score, JsonOptions);
|
|
|
|
// Assert - Timestamps should be ISO 8601 with UTC offset
|
|
hlcJson.Should().MatchRegex(@"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+00:00");
|
|
scoreJson.Should().MatchRegex(@"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+00:00");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that digests are lowercase hex.
|
|
/// </summary>
|
|
[Fact]
|
|
public void AllOutputs_DigestsAreLowercaseHex()
|
|
{
|
|
// Arrange
|
|
var score = CreateFrozenScoreExplanation();
|
|
EnsureScoreExplanationDeterminism(score);
|
|
|
|
// Act
|
|
var json = JsonSerializer.Serialize(score, JsonOptions);
|
|
|
|
// Assert - Digests should be lowercase
|
|
json.Should().Contain("sha256:abc123def456");
|
|
json.Should().NotMatchRegex("sha256:[A-F]");
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Test Helpers
|
|
|
|
private static HlcStatus CreateFrozenHlcStatus()
|
|
{
|
|
return new HlcStatus
|
|
{
|
|
NodeId = "node-01",
|
|
Healthy = true,
|
|
CurrentTimestamp = new HlcTimestamp
|
|
{
|
|
Physical = 1736937000000,
|
|
Logical = 42,
|
|
NodeId = "node-01"
|
|
},
|
|
FormattedTimestamp = "2026-01-15T10:30:00.000Z:0042:node-01",
|
|
ClockDriftMs = 3.2,
|
|
NtpServer = "time.google.com",
|
|
LastNtpSync = FixedTimestamp.AddMinutes(-5),
|
|
ClusterState = new HlcClusterState
|
|
{
|
|
TotalNodes = 3,
|
|
SyncedNodes = 3,
|
|
Peers =
|
|
[
|
|
new HlcPeerStatus { NodeId = "node-01", Status = "synced", LastSeen = FixedTimestamp, DriftMs = 0 },
|
|
new HlcPeerStatus { NodeId = "node-02", Status = "synced", LastSeen = FixedTimestamp.AddSeconds(-2), DriftMs = 1.5 },
|
|
new HlcPeerStatus { NodeId = "node-03", Status = "synced", LastSeen = FixedTimestamp.AddSeconds(-5), DriftMs = 2.8 }
|
|
]
|
|
},
|
|
CheckedAt = FixedTimestamp
|
|
};
|
|
}
|
|
|
|
private static string FormatHlcStatusText(HlcStatus status, bool verbose)
|
|
{
|
|
var sb = new StringBuilder();
|
|
sb.AppendLine("HLC Node Status");
|
|
sb.AppendLine("===============");
|
|
sb.AppendLine();
|
|
|
|
var healthStatus = status.Healthy ? "[OK] Healthy" : "[FAIL] Unhealthy";
|
|
sb.AppendLine($"Health: {healthStatus}");
|
|
sb.AppendLine($"Node ID: {status.NodeId}");
|
|
sb.AppendLine($"HLC Timestamp: {status.FormattedTimestamp}");
|
|
sb.AppendLine($"Clock Drift: {status.ClockDriftMs} ms");
|
|
sb.AppendLine($"NTP Server: {status.NtpServer}");
|
|
sb.AppendLine($"Last NTP Sync: {status.LastNtpSync:yyyy-MM-dd HH:mm:ssZ}");
|
|
sb.AppendLine();
|
|
sb.AppendLine("Cluster State:");
|
|
sb.AppendLine($" Nodes: {status.ClusterState.SyncedNodes}/{status.ClusterState.TotalNodes} synced");
|
|
|
|
if (verbose && status.ClusterState.Peers.Count > 0)
|
|
{
|
|
sb.AppendLine();
|
|
sb.AppendLine("Peer Status:");
|
|
foreach (var peer in status.ClusterState.Peers)
|
|
{
|
|
sb.AppendLine($" {peer.NodeId}: {peer.Status} (drift: {peer.DriftMs} ms)");
|
|
}
|
|
}
|
|
|
|
sb.AppendLine();
|
|
sb.AppendLine($"Checked At: {status.CheckedAt:yyyy-MM-dd HH:mm:ssZ}");
|
|
|
|
return sb.ToString();
|
|
}
|
|
|
|
private static List<TimelineEvent> CreateFrozenTimelineEvents()
|
|
{
|
|
return
|
|
[
|
|
new TimelineEvent { HlcTimestamp = "1737000000000000001", Type = "scan", EntityId = "sha256:abc123def456", Actor = "scanner-agent-1", Details = "SBOM generated" },
|
|
new TimelineEvent { HlcTimestamp = "1737000000000000002", Type = "attest", EntityId = "sha256:abc123def456", Actor = "attestor-1", Details = "SLSA provenance created" },
|
|
new TimelineEvent { HlcTimestamp = "1737000000000000003", Type = "policy", EntityId = "sha256:abc123def456", Actor = "policy-engine", Details = "Policy evaluation: PASS" },
|
|
new TimelineEvent { HlcTimestamp = "1737000000000000004", Type = "promote", EntityId = "release-2026.01.15-001", Actor = "ops@example.com", Details = "Promoted from dev to stage" }
|
|
];
|
|
}
|
|
|
|
private static TimelineQueryResult CreateFrozenTimelineResult()
|
|
{
|
|
var events = CreateFrozenTimelineEvents();
|
|
return new TimelineQueryResult
|
|
{
|
|
Events = events,
|
|
Pagination = new PaginationInfo
|
|
{
|
|
Offset = 0,
|
|
Limit = 50,
|
|
Total = events.Count,
|
|
HasMore = false
|
|
},
|
|
DeterminismHash = ComputeTimelineDeterminismHash(events)
|
|
};
|
|
}
|
|
|
|
private static string FormatTimelineTable(List<TimelineEvent> events)
|
|
{
|
|
var sb = new StringBuilder();
|
|
sb.AppendLine("Timeline Events");
|
|
sb.AppendLine("===============");
|
|
sb.AppendLine();
|
|
sb.AppendLine($"{"HLC Timestamp",-28} {"Type",-12} {"Entity",-25} {"Actor"}");
|
|
sb.AppendLine(new string('-', 90));
|
|
|
|
foreach (var evt in events.OrderBy(e => e.HlcTimestamp))
|
|
{
|
|
var entityTrunc = evt.EntityId.Length > 23 ? evt.EntityId[..23] + ".." : evt.EntityId;
|
|
sb.AppendLine($"{evt.HlcTimestamp,-28} {evt.Type,-12} {entityTrunc,-25} {evt.Actor}");
|
|
}
|
|
|
|
sb.AppendLine();
|
|
sb.AppendLine($"Total: {events.Count} events");
|
|
|
|
return sb.ToString();
|
|
}
|
|
|
|
private static string ComputeTimelineDeterminismHash(IEnumerable<TimelineEvent> events)
|
|
{
|
|
var combined = string.Join("|", events.OrderBy(e => e.HlcTimestamp).Select(e => $"{e.HlcTimestamp}:{e.Type}:{e.EntityId}"));
|
|
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(combined));
|
|
return $"sha256:{Convert.ToHexStringLower(hash)[..16]}";
|
|
}
|
|
|
|
private static ScoreExplanation CreateFrozenScoreExplanation()
|
|
{
|
|
return new ScoreExplanation
|
|
{
|
|
Digest = "sha256:abc123def456789012345678901234567890123456789012345678901234",
|
|
FinalScore = 7.5,
|
|
ScoreBreakdown = new ScoreBreakdown
|
|
{
|
|
BaseScore = 8.1,
|
|
CvssScore = 8.1,
|
|
EpssAdjustment = -0.3,
|
|
ReachabilityAdjustment = -0.2,
|
|
VexAdjustment = -0.1,
|
|
Factors =
|
|
[
|
|
new ScoreFactor
|
|
{
|
|
Name = "CVSS Base Score",
|
|
Value = 8.1,
|
|
Weight = 0.4,
|
|
Contribution = 3.24,
|
|
Source = "NVD",
|
|
Details = "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N"
|
|
},
|
|
new ScoreFactor
|
|
{
|
|
Name = "EPSS Probability",
|
|
Value = 0.15,
|
|
Weight = 0.2,
|
|
Contribution = 1.5,
|
|
Source = "FIRST EPSS",
|
|
Details = "15th percentile exploitation probability"
|
|
},
|
|
new ScoreFactor
|
|
{
|
|
Name = "Reachability",
|
|
Value = 0.7,
|
|
Weight = 0.25,
|
|
Contribution = 1.75,
|
|
Source = "Static Analysis",
|
|
Details = "Reachable via 2 call paths; confidence 0.7"
|
|
},
|
|
new ScoreFactor
|
|
{
|
|
Name = "VEX Status",
|
|
Value = 0,
|
|
Weight = 0.1,
|
|
Contribution = 0,
|
|
Source = "OpenVEX",
|
|
Details = "No VEX statement available"
|
|
},
|
|
new ScoreFactor
|
|
{
|
|
Name = "KEV Status",
|
|
Value = 0,
|
|
Weight = 0.05,
|
|
Contribution = 0,
|
|
Source = "CISA KEV",
|
|
Details = "Not in Known Exploited Vulnerabilities catalog"
|
|
}
|
|
]
|
|
},
|
|
ComputedAt = FixedTimestamp,
|
|
ProfileUsed = "stella-default-v1"
|
|
};
|
|
}
|
|
|
|
private static void EnsureScoreExplanationDeterminism(ScoreExplanation explanation)
|
|
{
|
|
// Sort factors alphabetically by name for deterministic output
|
|
explanation.ScoreBreakdown.Factors = [.. explanation.ScoreBreakdown.Factors.OrderBy(f => f.Name, StringComparer.Ordinal)];
|
|
|
|
// Compute determinism hash from stable representation
|
|
var hashInput = $"{explanation.Digest}|{explanation.FinalScore:F6}|{explanation.ProfileUsed}|{string.Join(",", explanation.ScoreBreakdown.Factors.Select(f => $"{f.Name}:{f.Value:F6}:{f.Weight:F6}"))}";
|
|
var hashBytes = SHA256.HashData(Encoding.UTF8.GetBytes(hashInput));
|
|
explanation.DeterminismHash = $"sha256:{Convert.ToHexStringLower(hashBytes)[..16]}";
|
|
}
|
|
|
|
// Explain Block helpers (Sprint 026 - WHY-004)
|
|
|
|
private static BlockExplanation CreateFrozenBlockExplanation()
|
|
{
|
|
return new BlockExplanation
|
|
{
|
|
Artifact = "sha256:abc123def456789012345678901234567890123456789012345678901234",
|
|
Status = "BLOCKED",
|
|
Gate = "VexTrust",
|
|
Reason = "Trust score below threshold (0.45 < 0.70)",
|
|
Suggestion = "Obtain VEX statement from trusted issuer or add issuer to trust registry",
|
|
EvaluationTime = FixedTimestamp,
|
|
PolicyVersion = "v2.3.0",
|
|
Evidence =
|
|
[
|
|
new BlockEvidence
|
|
{
|
|
Type = "REACH",
|
|
Id = "reach:sha256:789abc123def456",
|
|
Source = "static-analysis",
|
|
Timestamp = FixedTimestamp.AddHours(-2.5) // 08:00
|
|
},
|
|
new BlockEvidence
|
|
{
|
|
Type = "VEX",
|
|
Id = "vex:sha256:def456789abc123",
|
|
Source = "vendor-x",
|
|
Timestamp = FixedTimestamp.AddHours(-1.5) // 09:00
|
|
}
|
|
],
|
|
ReplayCommand = "stella verify verdict --verdict urn:stella:verdict:sha256:abc123:v2.3.0:1737108000",
|
|
ReplayToken = "urn:stella:verdict:sha256:abc123:v2.3.0:1737108000",
|
|
EvaluationTrace =
|
|
[
|
|
new BlockTraceStep { Step = 1, Gate = "SbomPresent", Result = "PASS", DurationMs = 15 },
|
|
new BlockTraceStep { Step = 2, Gate = "VexTrust", Result = "FAIL", DurationMs = 45 },
|
|
new BlockTraceStep { Step = 3, Gate = "VulnScan", Result = "PASS", DurationMs = 250 }
|
|
],
|
|
DeterminismHash = "sha256:e3b0c44298fc1c14"
|
|
};
|
|
}
|
|
|
|
private static NotBlockedExplanation CreateFrozenNotBlockedExplanation()
|
|
{
|
|
return new NotBlockedExplanation
|
|
{
|
|
Artifact = "sha256:fedcba9876543210",
|
|
Status = "NOT_BLOCKED",
|
|
Message = "Artifact passed all policy gates",
|
|
GatesEvaluated = 5,
|
|
EvaluationTime = FixedTimestamp,
|
|
PolicyVersion = "v2.3.0"
|
|
};
|
|
}
|
|
|
|
private static string FormatBlockExplanationTable(BlockExplanation exp, bool showEvidence, bool showTrace)
|
|
{
|
|
var sb = new StringBuilder();
|
|
|
|
sb.AppendLine($"Artifact: {exp.Artifact}");
|
|
sb.AppendLine($"Status: {exp.Status}");
|
|
sb.AppendLine();
|
|
sb.AppendLine($"Gate: {exp.Gate}");
|
|
sb.AppendLine($"Reason: {exp.Reason}");
|
|
sb.AppendLine($"Suggestion: {exp.Suggestion}");
|
|
sb.AppendLine();
|
|
|
|
sb.AppendLine("Evidence:");
|
|
foreach (var evidence in exp.Evidence.OrderBy(e => e.Timestamp))
|
|
{
|
|
var truncatedId = TruncateBlockId(evidence.Id);
|
|
sb.AppendLine($" [{evidence.Type,-6}] {truncatedId,-20} {evidence.Source,-15} {evidence.Timestamp:yyyy-MM-ddTHH:mm:ssZ}");
|
|
}
|
|
|
|
if (showTrace && exp.EvaluationTrace.Count > 0)
|
|
{
|
|
sb.AppendLine();
|
|
sb.AppendLine("Evaluation Trace:");
|
|
foreach (var step in exp.EvaluationTrace.OrderBy(t => t.Step))
|
|
{
|
|
sb.AppendLine($" {step.Step}. {step.Gate,-15} {step.Result,-6} ({step.DurationMs}ms)");
|
|
}
|
|
}
|
|
|
|
if (showEvidence)
|
|
{
|
|
sb.AppendLine();
|
|
sb.AppendLine("Evidence Details:");
|
|
foreach (var evidence in exp.Evidence.OrderBy(e => e.Timestamp))
|
|
{
|
|
sb.AppendLine($" - Type: {evidence.Type}");
|
|
sb.AppendLine($" ID: {evidence.Id}");
|
|
sb.AppendLine($" Source: {evidence.Source}");
|
|
sb.AppendLine($" Retrieve: stella evidence get {evidence.Id}");
|
|
sb.AppendLine();
|
|
}
|
|
}
|
|
|
|
sb.AppendLine();
|
|
sb.AppendLine($"Replay: {exp.ReplayCommand}");
|
|
|
|
return sb.ToString();
|
|
}
|
|
|
|
private static string FormatBlockExplanationMarkdown(BlockExplanation exp, bool showEvidence, bool showTrace)
|
|
{
|
|
var sb = new StringBuilder();
|
|
|
|
sb.AppendLine("## Block Explanation");
|
|
sb.AppendLine();
|
|
sb.AppendLine($"**Artifact:** `{exp.Artifact}`");
|
|
sb.AppendLine($"**Status:** {exp.Status}");
|
|
sb.AppendLine();
|
|
sb.AppendLine("### Gate Decision");
|
|
sb.AppendLine();
|
|
sb.AppendLine("| Property | Value |");
|
|
sb.AppendLine("|----------|-------|");
|
|
sb.AppendLine($"| Gate | {exp.Gate} |");
|
|
sb.AppendLine($"| Reason | {exp.Reason} |");
|
|
sb.AppendLine($"| Suggestion | {exp.Suggestion} |");
|
|
sb.AppendLine($"| Policy Version | {exp.PolicyVersion} |");
|
|
sb.AppendLine();
|
|
|
|
sb.AppendLine("### Evidence");
|
|
sb.AppendLine();
|
|
sb.AppendLine("| Type | ID | Source | Timestamp |");
|
|
sb.AppendLine("|------|-----|--------|-----------|");
|
|
foreach (var evidence in exp.Evidence.OrderBy(e => e.Timestamp))
|
|
{
|
|
var truncatedId = TruncateBlockId(evidence.Id);
|
|
sb.AppendLine($"| {evidence.Type} | `{truncatedId}` | {evidence.Source} | {evidence.Timestamp:yyyy-MM-dd HH:mm} |");
|
|
}
|
|
sb.AppendLine();
|
|
|
|
if (showTrace && exp.EvaluationTrace.Count > 0)
|
|
{
|
|
sb.AppendLine("### Evaluation Trace");
|
|
sb.AppendLine();
|
|
sb.AppendLine("| Step | Gate | Result | Duration |");
|
|
sb.AppendLine("|------|------|--------|----------|");
|
|
foreach (var step in exp.EvaluationTrace.OrderBy(t => t.Step))
|
|
{
|
|
sb.AppendLine($"| {step.Step} | {step.Gate} | {step.Result} | {step.DurationMs}ms |");
|
|
}
|
|
sb.AppendLine();
|
|
}
|
|
|
|
sb.AppendLine("### Verification");
|
|
sb.AppendLine();
|
|
sb.AppendLine("```bash");
|
|
sb.AppendLine(exp.ReplayCommand);
|
|
sb.AppendLine("```");
|
|
|
|
return sb.ToString();
|
|
}
|
|
|
|
private static string TruncateBlockId(string id)
|
|
{
|
|
if (id.Length <= 20)
|
|
{
|
|
return id;
|
|
}
|
|
|
|
var prefix = id[..12];
|
|
var suffix = id[^6..];
|
|
return $"{prefix}...{suffix}";
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Test Models
|
|
|
|
private sealed class HlcStatus
|
|
{
|
|
[JsonPropertyName("nodeId")]
|
|
public string NodeId { get; set; } = string.Empty;
|
|
|
|
[JsonPropertyName("healthy")]
|
|
public bool Healthy { get; set; }
|
|
|
|
[JsonPropertyName("currentTimestamp")]
|
|
public HlcTimestamp CurrentTimestamp { get; set; } = new();
|
|
|
|
[JsonPropertyName("formattedTimestamp")]
|
|
public string FormattedTimestamp { get; set; } = string.Empty;
|
|
|
|
[JsonPropertyName("clockDriftMs")]
|
|
public double ClockDriftMs { get; set; }
|
|
|
|
[JsonPropertyName("ntpServer")]
|
|
public string NtpServer { get; set; } = string.Empty;
|
|
|
|
[JsonPropertyName("lastNtpSync")]
|
|
public DateTimeOffset LastNtpSync { get; set; }
|
|
|
|
[JsonPropertyName("clusterState")]
|
|
public HlcClusterState ClusterState { get; set; } = new();
|
|
|
|
[JsonPropertyName("checkedAt")]
|
|
public DateTimeOffset CheckedAt { get; set; }
|
|
}
|
|
|
|
private sealed class HlcTimestamp
|
|
{
|
|
[JsonPropertyName("physical")]
|
|
public long Physical { get; set; }
|
|
|
|
[JsonPropertyName("logical")]
|
|
public int Logical { get; set; }
|
|
|
|
[JsonPropertyName("nodeId")]
|
|
public string NodeId { get; set; } = string.Empty;
|
|
}
|
|
|
|
private sealed class HlcClusterState
|
|
{
|
|
[JsonPropertyName("totalNodes")]
|
|
public int TotalNodes { get; set; }
|
|
|
|
[JsonPropertyName("syncedNodes")]
|
|
public int SyncedNodes { get; set; }
|
|
|
|
[JsonPropertyName("peers")]
|
|
public List<HlcPeerStatus> Peers { get; set; } = [];
|
|
}
|
|
|
|
private sealed class HlcPeerStatus
|
|
{
|
|
[JsonPropertyName("nodeId")]
|
|
public string NodeId { get; set; } = string.Empty;
|
|
|
|
[JsonPropertyName("status")]
|
|
public string Status { get; set; } = string.Empty;
|
|
|
|
[JsonPropertyName("lastSeen")]
|
|
public DateTimeOffset LastSeen { get; set; }
|
|
|
|
[JsonPropertyName("driftMs")]
|
|
public double DriftMs { get; set; }
|
|
}
|
|
|
|
private sealed class TimelineQueryResult
|
|
{
|
|
[JsonPropertyName("events")]
|
|
public List<TimelineEvent> Events { get; set; } = [];
|
|
|
|
[JsonPropertyName("pagination")]
|
|
public PaginationInfo Pagination { get; set; } = new();
|
|
|
|
[JsonPropertyName("determinismHash")]
|
|
public string DeterminismHash { get; set; } = string.Empty;
|
|
}
|
|
|
|
private sealed class PaginationInfo
|
|
{
|
|
[JsonPropertyName("offset")]
|
|
public int Offset { get; set; }
|
|
|
|
[JsonPropertyName("limit")]
|
|
public int Limit { get; set; }
|
|
|
|
[JsonPropertyName("total")]
|
|
public int Total { get; set; }
|
|
|
|
[JsonPropertyName("hasMore")]
|
|
public bool HasMore { get; set; }
|
|
}
|
|
|
|
private sealed class TimelineEvent
|
|
{
|
|
[JsonPropertyName("hlcTimestamp")]
|
|
public string HlcTimestamp { get; set; } = string.Empty;
|
|
|
|
[JsonPropertyName("type")]
|
|
public string Type { get; set; } = string.Empty;
|
|
|
|
[JsonPropertyName("entityId")]
|
|
public string EntityId { get; set; } = string.Empty;
|
|
|
|
[JsonPropertyName("actor")]
|
|
public string Actor { get; set; } = string.Empty;
|
|
|
|
[JsonPropertyName("details")]
|
|
public string Details { get; set; } = string.Empty;
|
|
}
|
|
|
|
private sealed class ScoreExplanation
|
|
{
|
|
[JsonPropertyName("digest")]
|
|
public string Digest { get; set; } = string.Empty;
|
|
|
|
[JsonPropertyName("finalScore")]
|
|
public double FinalScore { get; set; }
|
|
|
|
[JsonPropertyName("scoreBreakdown")]
|
|
public ScoreBreakdown ScoreBreakdown { get; set; } = new();
|
|
|
|
[JsonPropertyName("computedAt")]
|
|
public DateTimeOffset ComputedAt { get; set; }
|
|
|
|
[JsonPropertyName("profileUsed")]
|
|
public string ProfileUsed { get; set; } = string.Empty;
|
|
|
|
[JsonPropertyName("determinismHash")]
|
|
public string? DeterminismHash { get; set; }
|
|
}
|
|
|
|
private sealed class ScoreBreakdown
|
|
{
|
|
[JsonPropertyName("baseScore")]
|
|
public double BaseScore { get; set; }
|
|
|
|
[JsonPropertyName("cvssScore")]
|
|
public double CvssScore { get; set; }
|
|
|
|
[JsonPropertyName("epssAdjustment")]
|
|
public double EpssAdjustment { get; set; }
|
|
|
|
[JsonPropertyName("reachabilityAdjustment")]
|
|
public double ReachabilityAdjustment { get; set; }
|
|
|
|
[JsonPropertyName("vexAdjustment")]
|
|
public double VexAdjustment { get; set; }
|
|
|
|
[JsonPropertyName("factors")]
|
|
public List<ScoreFactor> Factors { get; set; } = [];
|
|
}
|
|
|
|
private sealed class ScoreFactor
|
|
{
|
|
[JsonPropertyName("name")]
|
|
public string Name { get; set; } = string.Empty;
|
|
|
|
[JsonPropertyName("value")]
|
|
public double Value { get; set; }
|
|
|
|
[JsonPropertyName("weight")]
|
|
public double Weight { get; set; }
|
|
|
|
[JsonPropertyName("contribution")]
|
|
public double Contribution { get; set; }
|
|
|
|
[JsonPropertyName("source")]
|
|
public string Source { get; set; } = string.Empty;
|
|
|
|
[JsonPropertyName("details")]
|
|
public string? Details { get; set; }
|
|
}
|
|
|
|
// Explain Block models (Sprint 026 - WHY-004)
|
|
|
|
private sealed class BlockExplanation
|
|
{
|
|
[JsonPropertyName("artifact")]
|
|
public string Artifact { get; set; } = string.Empty;
|
|
|
|
[JsonPropertyName("status")]
|
|
public string Status { get; set; } = string.Empty;
|
|
|
|
[JsonPropertyName("gate")]
|
|
public string Gate { get; set; } = string.Empty;
|
|
|
|
[JsonPropertyName("reason")]
|
|
public string Reason { get; set; } = string.Empty;
|
|
|
|
[JsonPropertyName("suggestion")]
|
|
public string Suggestion { get; set; } = string.Empty;
|
|
|
|
[JsonPropertyName("evaluationTime")]
|
|
public DateTimeOffset EvaluationTime { get; set; }
|
|
|
|
[JsonPropertyName("policyVersion")]
|
|
public string PolicyVersion { get; set; } = string.Empty;
|
|
|
|
[JsonPropertyName("evidence")]
|
|
public List<BlockEvidence> Evidence { get; set; } = [];
|
|
|
|
[JsonPropertyName("replayCommand")]
|
|
public string ReplayCommand { get; set; } = string.Empty;
|
|
|
|
[JsonPropertyName("replayToken")]
|
|
public string ReplayToken { get; set; } = string.Empty;
|
|
|
|
[JsonPropertyName("evaluationTrace")]
|
|
public List<BlockTraceStep> EvaluationTrace { get; set; } = [];
|
|
|
|
[JsonPropertyName("determinismHash")]
|
|
public string DeterminismHash { get; set; } = string.Empty;
|
|
}
|
|
|
|
private sealed class BlockEvidence
|
|
{
|
|
[JsonPropertyName("type")]
|
|
public string Type { get; set; } = string.Empty;
|
|
|
|
[JsonPropertyName("id")]
|
|
public string Id { get; set; } = string.Empty;
|
|
|
|
[JsonPropertyName("source")]
|
|
public string Source { get; set; } = string.Empty;
|
|
|
|
[JsonPropertyName("timestamp")]
|
|
public DateTimeOffset Timestamp { get; set; }
|
|
}
|
|
|
|
private sealed class BlockTraceStep
|
|
{
|
|
[JsonPropertyName("step")]
|
|
public int Step { get; set; }
|
|
|
|
[JsonPropertyName("gate")]
|
|
public string Gate { get; set; } = string.Empty;
|
|
|
|
[JsonPropertyName("result")]
|
|
public string Result { get; set; } = string.Empty;
|
|
|
|
[JsonPropertyName("durationMs")]
|
|
public int DurationMs { get; set; }
|
|
}
|
|
|
|
private sealed class NotBlockedExplanation
|
|
{
|
|
[JsonPropertyName("artifact")]
|
|
public string Artifact { get; set; } = string.Empty;
|
|
|
|
[JsonPropertyName("status")]
|
|
public string Status { get; set; } = string.Empty;
|
|
|
|
[JsonPropertyName("message")]
|
|
public string Message { get; set; } = string.Empty;
|
|
|
|
[JsonPropertyName("gatesEvaluated")]
|
|
public int GatesEvaluated { get; set; }
|
|
|
|
[JsonPropertyName("evaluationTime")]
|
|
public DateTimeOffset EvaluationTime { get; set; }
|
|
|
|
[JsonPropertyName("policyVersion")]
|
|
public string PolicyVersion { get; set; } = string.Empty;
|
|
}
|
|
|
|
#endregion
|
|
}
|
|
|
|
/// <summary>
|
|
/// Extension methods for string normalization in golden tests.
|
|
/// </summary>
|
|
internal static class GoldenTestStringExtensions
|
|
{
|
|
/// <summary>
|
|
/// Normalize line endings to LF for cross-platform consistency.
|
|
/// </summary>
|
|
public static string NormalizeLf(this string input)
|
|
{
|
|
return input.Replace("\r\n", "\n");
|
|
}
|
|
}
|