todays product advirories implemented
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// AttestBuildCommandTests.cs
|
||||
// Sprint: SPRINT_20260117_004_CLI_sbom_ingestion (SBI-001)
|
||||
// Description: Unit tests for attest build command
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.CommandLine;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Cli.Commands;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Cli.Tests.Commands;
|
||||
|
||||
public sealed class AttestBuildCommandTests
|
||||
{
|
||||
private readonly Option<bool> _verboseOption = new("--verbose");
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task AttestBuild_Spdx3_OutputContainsVersion()
|
||||
{
|
||||
// Arrange
|
||||
var command = AttestCommandGroup.BuildAttestCommand(_verboseOption, CancellationToken.None);
|
||||
var root = new RootCommand { command };
|
||||
|
||||
var writer = new StringWriter();
|
||||
var originalOut = Console.Out;
|
||||
int exitCode;
|
||||
|
||||
try
|
||||
{
|
||||
Console.SetOut(writer);
|
||||
exitCode = await root.Parse("attest build --format spdx3").InvokeAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Console.SetOut(originalOut);
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.Equal(0, exitCode);
|
||||
|
||||
using var doc = JsonDocument.Parse(writer.ToString());
|
||||
Assert.Equal("SPDX-3.0", doc.RootElement.GetProperty("spdxVersion").GetString());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// BinaryAnalysisCommandTests.cs
|
||||
// Sprint: SPRINT_20260117_007_CLI_binary_analysis (BAN-002, BAN-003)
|
||||
// Description: Unit tests for binary fingerprint export and diff commands
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.CommandLine;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Cli.Commands.Binary;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Cli.Tests.Commands;
|
||||
|
||||
public sealed class BinaryAnalysisCommandTests
|
||||
{
|
||||
private static RootCommand BuildRoot()
|
||||
{
|
||||
var services = new ServiceCollection().BuildServiceProvider();
|
||||
var root = new RootCommand();
|
||||
root.Add(BinaryCommandGroup.BuildBinaryCommand(services, new Option<bool>("--verbose"), CancellationToken.None));
|
||||
return root;
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task BinaryFingerprintExport_JsonOutput_IncludesHashes()
|
||||
{
|
||||
var root = BuildRoot();
|
||||
|
||||
var writer = new StringWriter();
|
||||
var originalOut = Console.Out;
|
||||
int exitCode;
|
||||
try
|
||||
{
|
||||
Console.SetOut(writer);
|
||||
exitCode = await root.Parse("binary fingerprint export /tmp/app --format json").InvokeAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Console.SetOut(originalOut);
|
||||
}
|
||||
|
||||
Assert.Equal(0, exitCode);
|
||||
|
||||
using var doc = JsonDocument.Parse(writer.ToString());
|
||||
Assert.True(doc.RootElement.TryGetProperty("hashes", out _));
|
||||
Assert.True(doc.RootElement.TryGetProperty("functionHashes", out _));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task BinaryDiff_JsonOutput_IncludesSummary()
|
||||
{
|
||||
var root = BuildRoot();
|
||||
|
||||
var writer = new StringWriter();
|
||||
var originalOut = Console.Out;
|
||||
int exitCode;
|
||||
try
|
||||
{
|
||||
Console.SetOut(writer);
|
||||
exitCode = await root.Parse("binary diff /tmp/base /tmp/candidate --format json").InvokeAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Console.SetOut(originalOut);
|
||||
}
|
||||
|
||||
Assert.Equal(0, exitCode);
|
||||
|
||||
using var doc = JsonDocument.Parse(writer.ToString());
|
||||
Assert.True(doc.RootElement.TryGetProperty("summary", out _));
|
||||
Assert.True(doc.RootElement.TryGetProperty("functionChanges", out _));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// DbConnectorsCommandTests.cs
|
||||
// Sprint: SPRINT_20260117_008_CLI_advisory_sources (ASC-004)
|
||||
// Description: Unit tests for db connectors test command
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.CommandLine;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Cli.Commands;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Cli.Tests.Commands;
|
||||
|
||||
public sealed class DbConnectorsCommandTests
|
||||
{
|
||||
private readonly IServiceProvider _services;
|
||||
private readonly Option<bool> _verboseOption;
|
||||
|
||||
public DbConnectorsCommandTests()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton(NullLoggerFactory.Instance);
|
||||
_services = services.BuildServiceProvider();
|
||||
_verboseOption = new Option<bool>("--verbose");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task DbConnectorsTest_WithTimeout_ReportsFailure()
|
||||
{
|
||||
// Arrange
|
||||
var command = DbCommandGroup.BuildDbCommand(_services, _verboseOption, CancellationToken.None);
|
||||
var root = new RootCommand { command };
|
||||
|
||||
var writer = new StringWriter();
|
||||
var originalOut = Console.Out;
|
||||
int exitCode;
|
||||
try
|
||||
{
|
||||
Console.SetOut(writer);
|
||||
exitCode = await root.Parse("db connectors test nvd --timeout 00:00:00.001 --format json").InvokeAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Console.SetOut(originalOut);
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.Equal(1, exitCode);
|
||||
|
||||
using var doc = JsonDocument.Parse(writer.ToString());
|
||||
var rootElement = doc.RootElement;
|
||||
Assert.False(rootElement.GetProperty("passed").GetBoolean());
|
||||
Assert.NotNull(rootElement.GetProperty("errorDetails").GetString());
|
||||
Assert.Equal("CON_TIMEOUT_001", rootElement.GetProperty("reasonCode").GetString());
|
||||
Assert.NotNull(rootElement.GetProperty("remediationHint").GetString());
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task DbConnectorsTest_WithSufficientTimeout_ReturnsSuccess()
|
||||
{
|
||||
// Arrange
|
||||
var command = DbCommandGroup.BuildDbCommand(_services, _verboseOption, CancellationToken.None);
|
||||
var root = new RootCommand { command };
|
||||
|
||||
var writer = new StringWriter();
|
||||
var originalOut = Console.Out;
|
||||
int exitCode;
|
||||
try
|
||||
{
|
||||
Console.SetOut(writer);
|
||||
exitCode = await root.Parse("db connectors test nvd --timeout 00:00:02 --format json").InvokeAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Console.SetOut(originalOut);
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.Equal(0, exitCode);
|
||||
|
||||
using var doc = JsonDocument.Parse(writer.ToString());
|
||||
var rootElement = doc.RootElement;
|
||||
Assert.True(rootElement.GetProperty("passed").GetBoolean());
|
||||
Assert.True(rootElement.GetProperty("latencyMs").GetInt32() > 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// GraphLineageCommandTests.cs
|
||||
// Sprint: SPRINT_20260117_004_CLI_sbom_ingestion (SBI-006)
|
||||
// Description: Unit tests for graph lineage show command
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.CommandLine;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Cli.Commands;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Cli.Tests.Commands;
|
||||
|
||||
public sealed class GraphLineageCommandTests
|
||||
{
|
||||
private static RootCommand BuildRoot()
|
||||
{
|
||||
using var loggerFactory = LoggerFactory.Create(builder => builder.SetMinimumLevel(LogLevel.None));
|
||||
var services = new ServiceCollection().BuildServiceProvider();
|
||||
return CommandFactory.Create(services, new StellaOpsCliOptions(), CancellationToken.None, loggerFactory);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GraphLineageShow_JsonOutput_IncludesTarget()
|
||||
{
|
||||
// Arrange
|
||||
var root = BuildRoot();
|
||||
var writer = new StringWriter();
|
||||
var originalOut = Console.Out;
|
||||
int exitCode;
|
||||
|
||||
try
|
||||
{
|
||||
Console.SetOut(writer);
|
||||
exitCode = await root.Parse("graph lineage show sha256:abc --format json").InvokeAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Console.SetOut(originalOut);
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.Equal(0, exitCode);
|
||||
|
||||
using var doc = JsonDocument.Parse(writer.ToString());
|
||||
Assert.Equal("sha256:abc", doc.RootElement.GetProperty("target").GetString());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IssuerKeysCommandTests.cs
|
||||
// Sprint: SPRINT_20260117_009_CLI_vex_processing (VPR-004)
|
||||
// Description: Unit tests for issuer keys commands
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Cli.Commands;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Cli.Tests.Commands;
|
||||
|
||||
public sealed class IssuerKeysCommandTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task IssuerKeysList_ReturnsKeys()
|
||||
{
|
||||
using var loggerFactory = LoggerFactory.Create(builder => builder.SetMinimumLevel(LogLevel.None));
|
||||
var services = new ServiceCollection().BuildServiceProvider();
|
||||
var root = CommandFactory.Create(services, new StellaOpsCliOptions(), CancellationToken.None, loggerFactory);
|
||||
|
||||
var writer = new StringWriter();
|
||||
var originalOut = Console.Out;
|
||||
int exitCode;
|
||||
|
||||
try
|
||||
{
|
||||
Console.SetOut(writer);
|
||||
exitCode = await root.Parse("issuer keys list --format json").InvokeAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Console.SetOut(originalOut);
|
||||
}
|
||||
|
||||
Assert.Equal(0, exitCode);
|
||||
|
||||
using var doc = JsonDocument.Parse(writer.ToString());
|
||||
Assert.True(doc.RootElement.GetArrayLength() > 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// PolicyCommandTests.cs
|
||||
// Sprint: SPRINT_20260117_010_CLI_policy_engine (PEN-001, PEN-002, PEN-003)
|
||||
// Description: Unit tests for policy lattice, verdict export, and promote commands
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.CommandLine;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Cli.Commands;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Cli.Tests.Commands;
|
||||
|
||||
public sealed class PolicyCommandTests
|
||||
{
|
||||
private static RootCommand BuildRoot()
|
||||
{
|
||||
using var loggerFactory = LoggerFactory.Create(builder => builder.SetMinimumLevel(LogLevel.None));
|
||||
var services = new ServiceCollection().BuildServiceProvider();
|
||||
return CommandFactory.Create(services, new StellaOpsCliOptions(), CancellationToken.None, loggerFactory);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task PolicyLatticeExplain_JsonOutput_IncludesEvaluationOrder()
|
||||
{
|
||||
// Arrange
|
||||
var root = BuildRoot();
|
||||
var writer = new StringWriter();
|
||||
var originalOut = Console.Out;
|
||||
int exitCode;
|
||||
|
||||
try
|
||||
{
|
||||
Console.SetOut(writer);
|
||||
exitCode = await root.Parse("policy lattice explain --format json").InvokeAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Console.SetOut(originalOut);
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.Equal(0, exitCode);
|
||||
|
||||
using var doc = JsonDocument.Parse(writer.ToString());
|
||||
var evaluationOrder = doc.RootElement.GetProperty("evaluationOrder");
|
||||
Assert.True(evaluationOrder.GetArrayLength() > 0);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task PolicyVerdictsExport_FilteredOutcome_ReturnsSingleItem()
|
||||
{
|
||||
// Arrange
|
||||
var root = BuildRoot();
|
||||
var writer = new StringWriter();
|
||||
var originalOut = Console.Out;
|
||||
int exitCode;
|
||||
|
||||
try
|
||||
{
|
||||
Console.SetOut(writer);
|
||||
exitCode = await root.Parse("policy verdicts export --format json --outcome fail").InvokeAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Console.SetOut(originalOut);
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.Equal(0, exitCode);
|
||||
|
||||
using var doc = JsonDocument.Parse(writer.ToString());
|
||||
var count = doc.RootElement.GetProperty("count").GetInt32();
|
||||
Assert.Equal(1, count);
|
||||
var item = doc.RootElement.GetProperty("items")[0];
|
||||
Assert.Equal("fail", item.GetProperty("outcome").GetString());
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task PolicyPromote_DryRun_JsonOutput()
|
||||
{
|
||||
// Arrange
|
||||
var root = BuildRoot();
|
||||
var writer = new StringWriter();
|
||||
var originalOut = Console.Out;
|
||||
int exitCode;
|
||||
|
||||
try
|
||||
{
|
||||
Console.SetOut(writer);
|
||||
exitCode = await root.Parse("policy promote P-7 --from dev --to stage --dry-run --format json").InvokeAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Console.SetOut(originalOut);
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.Equal(0, exitCode);
|
||||
|
||||
using var doc = JsonDocument.Parse(writer.ToString());
|
||||
Assert.True(doc.RootElement.GetProperty("dryRun").GetBoolean());
|
||||
Assert.Equal("dev", doc.RootElement.GetProperty("from").GetString());
|
||||
Assert.Equal("stage", doc.RootElement.GetProperty("to").GetString());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ReachabilityCommandTests.cs
|
||||
// Sprint: SPRINT_20260117_006_CLI_reachability_analysis (RCA-003, RCA-004, RCA-007)
|
||||
// Description: Unit tests for reachability explain/witness/guards commands
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.CommandLine;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Cli.Commands;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Cli.Tests.Commands;
|
||||
|
||||
public sealed class ReachabilityCommandTests
|
||||
{
|
||||
private static RootCommand BuildReachabilityRoot()
|
||||
{
|
||||
var services = new ServiceCollection().BuildServiceProvider();
|
||||
var root = new RootCommand();
|
||||
root.Add(ReachabilityCommandGroup.BuildReachabilityCommand(services, new Option<bool>("--verbose"), CancellationToken.None));
|
||||
return root;
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ReachabilityExplain_JsonOutput_IncludesConfidence()
|
||||
{
|
||||
var root = BuildReachabilityRoot();
|
||||
|
||||
var writer = new StringWriter();
|
||||
var originalOut = Console.Out;
|
||||
int exitCode;
|
||||
try
|
||||
{
|
||||
Console.SetOut(writer);
|
||||
exitCode = await root.Parse("reachability explain sha256:abc --format json").InvokeAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Console.SetOut(originalOut);
|
||||
}
|
||||
|
||||
Assert.Equal(0, exitCode);
|
||||
|
||||
using var doc = JsonDocument.Parse(writer.ToString());
|
||||
Assert.Equal("sha256:abc", doc.RootElement.GetProperty("digest").GetString());
|
||||
Assert.True(doc.RootElement.GetProperty("confidenceScore").GetInt32() > 0);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ReachabilityWitness_JsonOutput_IncludesPath()
|
||||
{
|
||||
var root = BuildReachabilityRoot();
|
||||
|
||||
var writer = new StringWriter();
|
||||
var originalOut = Console.Out;
|
||||
int exitCode;
|
||||
try
|
||||
{
|
||||
Console.SetOut(writer);
|
||||
exitCode = await root.Parse("reachability witness sha256:abc --vuln CVE-2024-1234 --format json").InvokeAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Console.SetOut(originalOut);
|
||||
}
|
||||
|
||||
Assert.Equal(0, exitCode);
|
||||
|
||||
using var doc = JsonDocument.Parse(writer.ToString());
|
||||
Assert.Equal("CVE-2024-1234", doc.RootElement.GetProperty("cve").GetString());
|
||||
Assert.True(doc.RootElement.GetProperty("path").GetArrayLength() > 0);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ReachabilityGuards_CveFilter_ReturnsFilteredList()
|
||||
{
|
||||
var root = BuildReachabilityRoot();
|
||||
|
||||
var writer = new StringWriter();
|
||||
var originalOut = Console.Out;
|
||||
int exitCode;
|
||||
try
|
||||
{
|
||||
Console.SetOut(writer);
|
||||
exitCode = await root.Parse("reachability guards sha256:abc --cve CVE-2024-1234 --format json").InvokeAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Console.SetOut(originalOut);
|
||||
}
|
||||
|
||||
Assert.Equal(0, exitCode);
|
||||
|
||||
using var doc = JsonDocument.Parse(writer.ToString());
|
||||
Assert.Equal(2, doc.RootElement.GetArrayLength());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// SarifExportCommandTests.cs
|
||||
// Sprint: SPRINT_20260117_005_CLI_scanning_detection (SCD-003)
|
||||
// Description: Unit tests for SARIF export metadata injection
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using StellaOps.Cli.Commands;
|
||||
using StellaOps.Cli.Services;
|
||||
using StellaOps.Cli.Telemetry;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Cli.Tests.Commands;
|
||||
|
||||
public sealed class SarifExportCommandTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ScanSarifExport_IncludesMetadataProperties()
|
||||
{
|
||||
// Arrange
|
||||
var sarifJson = """
|
||||
{
|
||||
"version": "2.1.0",
|
||||
"runs": [
|
||||
{
|
||||
"tool": {
|
||||
"driver": { "name": "stella" }
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
var client = new Mock<IBackendOperationsClient>();
|
||||
client
|
||||
.Setup(c => c.GetScanSarifAsync(
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<bool>(),
|
||||
It.IsAny<bool>(),
|
||||
It.IsAny<string?>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(sarifJson);
|
||||
|
||||
using var loggerFactory = LoggerFactory.Create(builder => builder.SetMinimumLevel(LogLevel.None));
|
||||
var services = new ServiceCollection()
|
||||
.AddSingleton(client.Object)
|
||||
.AddSingleton<ILoggerFactory>(loggerFactory)
|
||||
.AddSingleton(new VerbosityState())
|
||||
.BuildServiceProvider();
|
||||
|
||||
var writer = new StringWriter();
|
||||
var originalOut = Console.Out;
|
||||
|
||||
try
|
||||
{
|
||||
Console.SetOut(writer);
|
||||
await CommandHandlers.HandleScanSarifExportAsync(
|
||||
services,
|
||||
"scan-123",
|
||||
null,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
null,
|
||||
false,
|
||||
CancellationToken.None);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Console.SetOut(originalOut);
|
||||
}
|
||||
|
||||
// Assert
|
||||
using var doc = JsonDocument.Parse(writer.ToString());
|
||||
var properties = doc.RootElement.GetProperty("runs")[0].GetProperty("properties");
|
||||
Assert.Equal("scan-123", properties.GetProperty("digest").GetString());
|
||||
Assert.True(properties.TryGetProperty("scanTimestamp", out _));
|
||||
Assert.True(properties.TryGetProperty("policyProfileId", out _));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ScanWorkersOptionTests.cs
|
||||
// Sprint: SPRINT_20260117_005_CLI_scanning_detection (SCD-005)
|
||||
// Description: Unit tests for scan run --workers option
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.CommandLine;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Cli.Commands;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Cli.Tests.Commands;
|
||||
|
||||
public sealed class ScanWorkersOptionTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ScanRun_ParsesWorkersOption()
|
||||
{
|
||||
using var loggerFactory = LoggerFactory.Create(builder => builder.SetMinimumLevel(LogLevel.None));
|
||||
var services = new ServiceCollection().BuildServiceProvider();
|
||||
var root = CommandFactory.Create(services, new StellaOpsCliOptions(), CancellationToken.None, loggerFactory);
|
||||
|
||||
var scanCommand = Assert.Single(root.Subcommands, c => c.Name == "scan");
|
||||
var runCommand = Assert.Single(scanCommand.Subcommands, c => c.Name == "run");
|
||||
var workersOption = runCommand.Options.FirstOrDefault(o => o.Name == "workers") as Option<int?>;
|
||||
Assert.NotNull(workersOption);
|
||||
|
||||
var result = root.Parse("scan run --entry scanner --target . --workers 4");
|
||||
Assert.Equal(4, result.GetValueForOption(workersOption!));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ScannerWorkersCommandTests.cs
|
||||
// Sprint: SPRINT_20260117_005_CLI_scanning_detection (SCD-004)
|
||||
// Description: Unit tests for scanner workers get/set
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.CommandLine;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Cli.Commands;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Cli.Tests.Commands;
|
||||
|
||||
public sealed class ScannerWorkersCommandTests
|
||||
{
|
||||
private static RootCommand BuildRoot()
|
||||
{
|
||||
using var loggerFactory = LoggerFactory.Create(builder => builder.SetMinimumLevel(LogLevel.None));
|
||||
var services = new ServiceCollection().BuildServiceProvider();
|
||||
return CommandFactory.Create(services, new StellaOpsCliOptions(), CancellationToken.None, loggerFactory);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ScannerWorkers_SetThenGet_ReturnsPersistedConfig()
|
||||
{
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), "stellaops-workers-tests", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(tempDir);
|
||||
var configPath = Path.Combine(tempDir, "scanner-workers.json");
|
||||
|
||||
var originalEnv = Environment.GetEnvironmentVariable("STELLAOPS_CLI_WORKERS_CONFIG");
|
||||
Environment.SetEnvironmentVariable("STELLAOPS_CLI_WORKERS_CONFIG", configPath);
|
||||
|
||||
try
|
||||
{
|
||||
var root = BuildRoot();
|
||||
|
||||
var writer = new StringWriter();
|
||||
var originalOut = Console.Out;
|
||||
int exitCode;
|
||||
|
||||
try
|
||||
{
|
||||
Console.SetOut(writer);
|
||||
exitCode = await root.Parse("scanner workers set --count 4 --pool fast --format json").InvokeAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Console.SetOut(originalOut);
|
||||
}
|
||||
|
||||
Assert.Equal(0, exitCode);
|
||||
|
||||
using var setDoc = JsonDocument.Parse(writer.ToString());
|
||||
Assert.Equal(4, setDoc.RootElement.GetProperty("count").GetInt32());
|
||||
|
||||
writer = new StringWriter();
|
||||
try
|
||||
{
|
||||
Console.SetOut(writer);
|
||||
exitCode = await root.Parse("scanner workers get --format json").InvokeAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Console.SetOut(originalOut);
|
||||
}
|
||||
|
||||
Assert.Equal(0, exitCode);
|
||||
|
||||
using var getDoc = JsonDocument.Parse(writer.ToString());
|
||||
Assert.Equal(4, getDoc.RootElement.GetProperty("count").GetInt32());
|
||||
Assert.Equal("fast", getDoc.RootElement.GetProperty("pool").GetString());
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("STELLAOPS_CLI_WORKERS_CONFIG", originalEnv);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// SignalsCommandTests.cs
|
||||
// Sprint: SPRINT_20260117_006_CLI_reachability_analysis (RCA-006, RCA-007)
|
||||
// Description: Unit tests for signals inspect command
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.CommandLine;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Cli.Commands;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Cli.Tests.Commands;
|
||||
|
||||
public sealed class SignalsCommandTests
|
||||
{
|
||||
private static RootCommand BuildSignalsRoot()
|
||||
{
|
||||
var services = new ServiceCollection().BuildServiceProvider();
|
||||
var root = new RootCommand();
|
||||
root.Add(SignalsCommandGroup.BuildSignalsCommand(services, new Option<bool>("--verbose"), CancellationToken.None));
|
||||
return root;
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task SignalsInspect_JsonOutput_ReturnsSignals()
|
||||
{
|
||||
var root = BuildSignalsRoot();
|
||||
|
||||
var writer = new StringWriter();
|
||||
var originalOut = Console.Out;
|
||||
int exitCode;
|
||||
try
|
||||
{
|
||||
Console.SetOut(writer);
|
||||
exitCode = await root.Parse("signals inspect sha256:abc --format json").InvokeAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Console.SetOut(originalOut);
|
||||
}
|
||||
|
||||
Assert.Equal(0, exitCode);
|
||||
|
||||
using var doc = JsonDocument.Parse(writer.ToString());
|
||||
Assert.True(doc.RootElement.GetArrayLength() > 0);
|
||||
}
|
||||
}
|
||||
@@ -6,10 +6,14 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.CommandLine;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Moq.Protected;
|
||||
using Xunit;
|
||||
using StellaOps.Cli.Commands;
|
||||
using StellaOps.Cli.Commands.Proof;
|
||||
@@ -80,6 +84,17 @@ public class Sprint3500_0004_0001_CommandTests
|
||||
Assert.NotNull(verifyCommand);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScoreCommand_HasExplainSubcommand()
|
||||
{
|
||||
// Act
|
||||
var command = ScoreReplayCommandGroup.BuildScoreCommand(_services, _verboseOption, _cancellationToken);
|
||||
var explainCommand = command.Subcommands.FirstOrDefault(c => c.Name == "explain");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(explainCommand);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScoreReplay_ParsesWithScanOption()
|
||||
{
|
||||
@@ -122,6 +137,58 @@ public class Sprint3500_0004_0001_CommandTests
|
||||
Assert.NotEmpty(result.Errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ScoreExplain_OutputsDeterministicJson_WhenApiUnavailable()
|
||||
{
|
||||
// Arrange
|
||||
var handlerMock = new Mock<HttpMessageHandler>();
|
||||
handlerMock
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.InternalServerError));
|
||||
|
||||
var httpClient = new HttpClient(handlerMock.Object);
|
||||
var httpClientFactory = new Mock<IHttpClientFactory>();
|
||||
httpClientFactory
|
||||
.Setup(factory => factory.CreateClient("Scanner"))
|
||||
.Returns(httpClient);
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton(httpClientFactory.Object);
|
||||
services.AddSingleton<ILoggerFactory>(NullLoggerFactory.Instance);
|
||||
var provider = services.BuildServiceProvider();
|
||||
|
||||
var command = ScoreReplayCommandGroup.BuildScoreCommand(provider, _verboseOption, _cancellationToken);
|
||||
var root = new RootCommand { command };
|
||||
|
||||
var writer = new StringWriter();
|
||||
var originalOut = Console.Out;
|
||||
int exitCode;
|
||||
try
|
||||
{
|
||||
Console.SetOut(writer);
|
||||
exitCode = await root.Parse("score explain sha256:abc --format json").InvokeAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Console.SetOut(originalOut);
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.Equal(0, exitCode);
|
||||
|
||||
var output = writer.ToString();
|
||||
using var doc = JsonDocument.Parse(output);
|
||||
var rootElement = doc.RootElement;
|
||||
|
||||
Assert.Equal("sha256:abc", rootElement.GetProperty("digest").GetString());
|
||||
Assert.Equal(7.5, rootElement.GetProperty("finalScore").GetDouble());
|
||||
Assert.Equal(8.1, rootElement.GetProperty("scoreBreakdown").GetProperty("cvssScore").GetDouble());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region UnknownsCommandGroup Tests
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// VexEvidenceExportCommandTests.cs
|
||||
// Sprint: SPRINT_20260117_009_CLI_vex_processing (VPR-002)
|
||||
// Description: Unit tests for VEX evidence export command
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.CommandLine;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using StellaOps.Cli.Plugins.Vex;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Cli.Tests.Commands;
|
||||
|
||||
public sealed class VexEvidenceExportCommandTests
|
||||
{
|
||||
private readonly IServiceProvider _services;
|
||||
private readonly StellaOpsCliOptions _options;
|
||||
private readonly Option<bool> _verboseOption;
|
||||
|
||||
public VexEvidenceExportCommandTests()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton(NullLoggerFactory.Instance);
|
||||
_services = services.BuildServiceProvider();
|
||||
_options = new StellaOpsCliOptions();
|
||||
_verboseOption = new Option<bool>("--verbose");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task VexEvidenceExport_JsonOutput_IncludesTarget()
|
||||
{
|
||||
// Arrange
|
||||
var root = new RootCommand();
|
||||
var module = new VexCliCommandModule();
|
||||
module.RegisterCommands(root, _services, _options, _verboseOption, CancellationToken.None);
|
||||
|
||||
var writer = new StringWriter();
|
||||
var originalOut = Console.Out;
|
||||
int exitCode;
|
||||
|
||||
try
|
||||
{
|
||||
Console.SetOut(writer);
|
||||
exitCode = await root.Parse("vex evidence export sha256:abc --format json").InvokeAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Console.SetOut(originalOut);
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.Equal(0, exitCode);
|
||||
|
||||
using var doc = JsonDocument.Parse(writer.ToString());
|
||||
Assert.Equal("sha256:abc", doc.RootElement.GetProperty("target").GetString());
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task VexEvidenceExport_OpenVexOutput_HasContext()
|
||||
{
|
||||
// Arrange
|
||||
var root = new RootCommand();
|
||||
var module = new VexCliCommandModule();
|
||||
module.RegisterCommands(root, _services, _options, _verboseOption, CancellationToken.None);
|
||||
|
||||
var writer = new StringWriter();
|
||||
var originalOut = Console.Out;
|
||||
int exitCode;
|
||||
|
||||
try
|
||||
{
|
||||
Console.SetOut(writer);
|
||||
exitCode = await root.Parse("vex evidence export pkg:npm/lodash@4.17.21 --format openvex").InvokeAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Console.SetOut(originalOut);
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.Equal(0, exitCode);
|
||||
|
||||
using var doc = JsonDocument.Parse(writer.ToString());
|
||||
Assert.Equal("https://openvex.dev/ns", doc.RootElement.GetProperty("@context").GetString());
|
||||
Assert.True(doc.RootElement.TryGetProperty("statements", out _));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// VexVerifyCommandTests.cs
|
||||
// Sprint: SPRINT_20260117_009_CLI_vex_processing (VPR-001)
|
||||
// Task: VPR-001 - Add stella vex verify command
|
||||
// Description: Unit tests for VEX verify command
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.CommandLine;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using StellaOps.Cli.Plugins.Vex;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Cli.Tests.Commands;
|
||||
|
||||
public sealed class VexVerifyCommandTests
|
||||
{
|
||||
private readonly IServiceProvider _services;
|
||||
private readonly StellaOpsCliOptions _options;
|
||||
private readonly Option<bool> _verboseOption;
|
||||
|
||||
public VexVerifyCommandTests()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton(NullLoggerFactory.Instance);
|
||||
_services = services.BuildServiceProvider();
|
||||
_options = new StellaOpsCliOptions();
|
||||
_verboseOption = new Option<bool>("--verbose");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void VexCommand_RegistersVerifySubcommand()
|
||||
{
|
||||
// Arrange
|
||||
var root = new RootCommand();
|
||||
var module = new VexCliCommandModule();
|
||||
|
||||
// Act
|
||||
module.RegisterCommands(root, _services, _options, _verboseOption, CancellationToken.None);
|
||||
var vexCommand = root.Children.OfType<Command>().FirstOrDefault(c => c.Name == "vex");
|
||||
var verifyCommand = vexCommand?.Subcommands.FirstOrDefault(c => c.Name == "verify");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(vexCommand);
|
||||
Assert.NotNull(verifyCommand);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task VexVerify_ValidOpenVex_ReturnsSuccessJson()
|
||||
{
|
||||
// Arrange
|
||||
var root = new RootCommand();
|
||||
var module = new VexCliCommandModule();
|
||||
module.RegisterCommands(root, _services, _options, _verboseOption, CancellationToken.None);
|
||||
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), "stellaops-vex-tests", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(tempDir);
|
||||
var vexPath = Path.Combine(tempDir, "valid.openvex.json");
|
||||
|
||||
var vexJson = """
|
||||
{
|
||||
"@context": "https://openvex.dev/ns",
|
||||
"@id": "https://stellaops.dev/vex/example-1",
|
||||
"author": "stellaops",
|
||||
"timestamp": "2026-01-16T00:00:00Z",
|
||||
"statements": [
|
||||
{
|
||||
"vulnerability": { "name": "CVE-2025-0001" },
|
||||
"status": "not_affected",
|
||||
"products": ["pkg:oci/example@sha256:abc"]
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
await File.WriteAllTextAsync(vexPath, vexJson);
|
||||
|
||||
var writer = new StringWriter();
|
||||
var originalOut = Console.Out;
|
||||
int exitCode;
|
||||
try
|
||||
{
|
||||
Console.SetOut(writer);
|
||||
exitCode = await root.Parse($"vex verify \"{vexPath}\" --format json").InvokeAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Console.SetOut(originalOut);
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.Equal(0, exitCode);
|
||||
|
||||
using var doc = JsonDocument.Parse(writer.ToString());
|
||||
var rootElement = doc.RootElement;
|
||||
Assert.True(rootElement.GetProperty("valid").GetBoolean());
|
||||
Assert.Equal("OpenVEX", rootElement.GetProperty("detectedFormat").GetString());
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task VexVerify_StrictModeWithoutSignature_Fails()
|
||||
{
|
||||
// Arrange
|
||||
var root = new RootCommand();
|
||||
var module = new VexCliCommandModule();
|
||||
module.RegisterCommands(root, _services, _options, _verboseOption, CancellationToken.None);
|
||||
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), "stellaops-vex-tests", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(tempDir);
|
||||
var vexPath = Path.Combine(tempDir, "valid.openvex.json");
|
||||
|
||||
var vexJson = """
|
||||
{
|
||||
"@context": "https://openvex.dev/ns",
|
||||
"@id": "https://stellaops.dev/vex/example-2",
|
||||
"author": "stellaops",
|
||||
"timestamp": "2026-01-16T00:00:00Z",
|
||||
"statements": [
|
||||
{
|
||||
"vulnerability": { "name": "CVE-2025-0002" },
|
||||
"status": "not_affected",
|
||||
"products": ["pkg:oci/example@sha256:def"]
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
await File.WriteAllTextAsync(vexPath, vexJson);
|
||||
|
||||
var writer = new StringWriter();
|
||||
var originalOut = Console.Out;
|
||||
int exitCode;
|
||||
try
|
||||
{
|
||||
Console.SetOut(writer);
|
||||
exitCode = await root.Parse($"vex verify \"{vexPath}\" --strict --format json").InvokeAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Console.SetOut(originalOut);
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.Equal(1, exitCode);
|
||||
|
||||
using var doc = JsonDocument.Parse(writer.ToString());
|
||||
var rootElement = doc.RootElement;
|
||||
Assert.False(rootElement.GetProperty("valid").GetBoolean());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// VexWebhooksCommandTests.cs
|
||||
// Sprint: SPRINT_20260117_009_CLI_vex_processing (VPR-003)
|
||||
// Description: Unit tests for VEX webhooks commands
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.CommandLine;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using StellaOps.Cli.Plugins.Vex;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Cli.Tests.Commands;
|
||||
|
||||
public sealed class VexWebhooksCommandTests
|
||||
{
|
||||
private readonly IServiceProvider _services;
|
||||
private readonly StellaOpsCliOptions _options;
|
||||
private readonly Option<bool> _verboseOption;
|
||||
|
||||
public VexWebhooksCommandTests()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton(NullLoggerFactory.Instance);
|
||||
_services = services.BuildServiceProvider();
|
||||
_options = new StellaOpsCliOptions();
|
||||
_verboseOption = new Option<bool>("--verbose");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task VexWebhooksList_JsonOutput_ReturnsEntries()
|
||||
{
|
||||
var root = new RootCommand();
|
||||
var module = new VexCliCommandModule();
|
||||
module.RegisterCommands(root, _services, _options, _verboseOption, CancellationToken.None);
|
||||
|
||||
var writer = new StringWriter();
|
||||
var originalOut = Console.Out;
|
||||
int exitCode;
|
||||
|
||||
try
|
||||
{
|
||||
Console.SetOut(writer);
|
||||
exitCode = await root.Parse("vex webhooks list --format json").InvokeAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Console.SetOut(originalOut);
|
||||
}
|
||||
|
||||
Assert.Equal(0, exitCode);
|
||||
|
||||
using var doc = JsonDocument.Parse(writer.ToString());
|
||||
Assert.True(doc.RootElement.GetArrayLength() > 0);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task VexWebhooksAdd_JsonOutput_ReturnsId()
|
||||
{
|
||||
var root = new RootCommand();
|
||||
var module = new VexCliCommandModule();
|
||||
module.RegisterCommands(root, _services, _options, _verboseOption, CancellationToken.None);
|
||||
|
||||
var writer = new StringWriter();
|
||||
var originalOut = Console.Out;
|
||||
int exitCode;
|
||||
|
||||
try
|
||||
{
|
||||
Console.SetOut(writer);
|
||||
exitCode = await root.Parse("vex webhooks add --url https://hooks.stellaops.dev/vex --events vex.created --format json").InvokeAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Console.SetOut(originalOut);
|
||||
}
|
||||
|
||||
Assert.Equal(0, exitCode);
|
||||
|
||||
using var doc = JsonDocument.Parse(writer.ToString());
|
||||
Assert.True(doc.RootElement.GetProperty("id").GetString()?.StartsWith("wh-") == true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,952 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// 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 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]}";
|
||||
}
|
||||
|
||||
#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; }
|
||||
}
|
||||
|
||||
#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");
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
using System.CommandLine;
|
||||
using System.CommandLine.Parsing;
|
||||
using System.Text.Json;
|
||||
using Xunit;
|
||||
using StellaOps.Cli.Commands;
|
||||
using StellaOps.TestKit;
|
||||
@@ -39,6 +40,7 @@ public sealed class SbomCommandTests
|
||||
Assert.NotNull(command);
|
||||
Assert.Equal("sbom", command.Name);
|
||||
Assert.Contains(command.Children, c => c.Name == "verify");
|
||||
Assert.Contains(command.Children, c => c.Name == "convert");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
@@ -365,6 +367,232 @@ public sealed class SbomCommandTests
|
||||
|
||||
#endregion
|
||||
|
||||
#region Convert Command Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task SbomConvert_SpdxToCdx_WritesExpectedOutput()
|
||||
{
|
||||
// Arrange
|
||||
var command = SbomCommandGroup.BuildSbomCommand(_verboseOption, _ct);
|
||||
var root = new RootCommand { command };
|
||||
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), "stellaops-sbom-tests", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(tempDir);
|
||||
|
||||
var inputPath = Path.Combine(tempDir, "input.spdx.json");
|
||||
var outputPath = Path.Combine(tempDir, "output.cdx.json");
|
||||
|
||||
var spdxJson = """
|
||||
{
|
||||
"spdxVersion": "SPDX-2.3",
|
||||
"SPDXID": "SPDXRef-DOCUMENT",
|
||||
"name": "Sample SBOM",
|
||||
"creationInfo": {
|
||||
"created": "2026-01-16T00:00:00Z",
|
||||
"creators": ["Tool: stella-cli"]
|
||||
},
|
||||
"packages": [
|
||||
{
|
||||
"SPDXID": "SPDXRef-Package-lib-a",
|
||||
"name": "lib-a",
|
||||
"versionInfo": "1.2.3",
|
||||
"supplier": "Organization:Example",
|
||||
"downloadLocation": "https://example.com/lib-a",
|
||||
"licenseConcluded": "MIT",
|
||||
"externalRefs": [
|
||||
{
|
||||
"referenceType": "purl",
|
||||
"referenceLocator": "pkg:npm/lib-a@1.2.3"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"relationships": [
|
||||
{
|
||||
"spdxElementId": "SPDXRef-DOCUMENT",
|
||||
"relationshipType": "DEPENDS_ON",
|
||||
"relatedSpdxElement": "SPDXRef-Package-lib-a"
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
await File.WriteAllTextAsync(inputPath, spdxJson);
|
||||
|
||||
var writer = new StringWriter();
|
||||
var originalOut = Console.Out;
|
||||
int exitCode;
|
||||
try
|
||||
{
|
||||
Console.SetOut(writer);
|
||||
exitCode = await root.Parse($"sbom convert --input \"{inputPath}\" --to cdx --output \"{outputPath}\"").InvokeAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Console.SetOut(originalOut);
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.Equal(0, exitCode);
|
||||
Assert.True(File.Exists(outputPath));
|
||||
|
||||
var output = await File.ReadAllTextAsync(outputPath);
|
||||
using var doc = JsonDocument.Parse(output);
|
||||
var rootElement = doc.RootElement;
|
||||
|
||||
Assert.Equal("CycloneDX", rootElement.GetProperty("bomFormat").GetString());
|
||||
Assert.Equal("1.6", rootElement.GetProperty("specVersion").GetString());
|
||||
var components = rootElement.GetProperty("components");
|
||||
Assert.Equal(1, components.GetArrayLength());
|
||||
Assert.Equal("lib-a", components[0].GetProperty("name").GetString());
|
||||
Assert.Equal("1.2.3", components[0].GetProperty("version").GetString());
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task SbomConvert_CdxToSpdx_WritesExpectedOutput()
|
||||
{
|
||||
// Arrange
|
||||
var command = SbomCommandGroup.BuildSbomCommand(_verboseOption, _ct);
|
||||
var root = new RootCommand { command };
|
||||
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), "stellaops-sbom-tests", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(tempDir);
|
||||
|
||||
var inputPath = Path.Combine(tempDir, "input.cdx.json");
|
||||
var outputPath = Path.Combine(tempDir, "output.spdx.json");
|
||||
|
||||
var cdxJson = """
|
||||
{
|
||||
"bomFormat": "CycloneDX",
|
||||
"specVersion": "1.6",
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"timestamp": "2026-01-16T00:00:00Z",
|
||||
"component": { "name": "SampleApp", "type": "application" },
|
||||
"tools": [ { "name": "stella-cli" } ]
|
||||
},
|
||||
"components": [
|
||||
{
|
||||
"type": "library",
|
||||
"name": "lib-b",
|
||||
"version": "2.0.0",
|
||||
"bom-ref": "pkg:npm/lib-b@2.0.0"
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
await File.WriteAllTextAsync(inputPath, cdxJson);
|
||||
|
||||
var writer = new StringWriter();
|
||||
var originalOut = Console.Out;
|
||||
int exitCode;
|
||||
try
|
||||
{
|
||||
Console.SetOut(writer);
|
||||
exitCode = await root.Parse($"sbom convert --input \"{inputPath}\" --to spdx --output \"{outputPath}\"").InvokeAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Console.SetOut(originalOut);
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.Equal(0, exitCode);
|
||||
Assert.True(File.Exists(outputPath));
|
||||
|
||||
var output = await File.ReadAllTextAsync(outputPath);
|
||||
using var doc = JsonDocument.Parse(output);
|
||||
var rootElement = doc.RootElement;
|
||||
|
||||
Assert.Equal("SPDX-2.3", rootElement.GetProperty("spdxVersion").GetString());
|
||||
Assert.Equal("SampleApp", rootElement.GetProperty("name").GetString());
|
||||
|
||||
var packages = rootElement.GetProperty("packages");
|
||||
Assert.Equal(1, packages.GetArrayLength());
|
||||
Assert.Equal("lib-b", packages[0].GetProperty("name").GetString());
|
||||
Assert.Equal("2.0.0", packages[0].GetProperty("versionInfo").GetString());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Export CBOM Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task SbomExport_Cbom_CycloneDxOutput()
|
||||
{
|
||||
// Arrange
|
||||
var command = SbomCommandGroup.BuildSbomCommand(_verboseOption, _ct);
|
||||
var root = new RootCommand { command };
|
||||
|
||||
var writer = new StringWriter();
|
||||
var originalOut = Console.Out;
|
||||
int exitCode;
|
||||
try
|
||||
{
|
||||
Console.SetOut(writer);
|
||||
exitCode = await root.Parse("sbom export --digest sha256:abc --type cbom --format cdx").InvokeAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Console.SetOut(originalOut);
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.Equal(0, exitCode);
|
||||
|
||||
using var doc = JsonDocument.Parse(writer.ToString());
|
||||
var rootElement = doc.RootElement;
|
||||
Assert.Equal("CycloneDX", rootElement.GetProperty("bomFormat").GetString());
|
||||
Assert.Equal("1.6", rootElement.GetProperty("specVersion").GetString());
|
||||
Assert.Equal(2, rootElement.GetProperty("components").GetArrayLength());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Validate Command Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task SbomValidate_StrictMode_ReportsWarning()
|
||||
{
|
||||
// Arrange
|
||||
var command = SbomCommandGroup.BuildSbomCommand(_verboseOption, _ct);
|
||||
var root = new RootCommand { command };
|
||||
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), "stellaops-sbom-tests", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(tempDir);
|
||||
var sbomPath = Path.Combine(tempDir, "sbom.spdx.json");
|
||||
|
||||
await File.WriteAllTextAsync(sbomPath, "{}", _ct);
|
||||
|
||||
var writer = new StringWriter();
|
||||
var originalOut = Console.Out;
|
||||
int exitCode;
|
||||
try
|
||||
{
|
||||
Console.SetOut(writer);
|
||||
exitCode = await root.Parse($"sbom validate --input \"{sbomPath}\" --strict --format json").InvokeAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Console.SetOut(originalOut);
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.Equal(0, exitCode);
|
||||
|
||||
using var doc = JsonDocument.Parse(writer.ToString());
|
||||
var rootElement = doc.RootElement;
|
||||
Assert.True(rootElement.GetProperty("valid").GetBoolean());
|
||||
Assert.True(rootElement.GetProperty("issues").GetArrayLength() > 0);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Command Alias Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
|
||||
@@ -13,3 +13,21 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| CLI-DIFF-TESTS-0001 | DONE | SPRINT_20260113_001_003 - Binary diff unit tests added. |
|
||||
| CLI-DIFF-INTEGRATION-0001 | DONE | SPRINT_20260113_001_003 - Binary diff integration test added. |
|
||||
| CLI-VEX-EVIDENCE-TESTS-0001 | DONE | SPRINT_20260113_003_002 - VEX evidence tests. |
|
||||
| CLI-SBOM-CONVERT-TESTS-0001 | DONE | SPRINT_20260117_004 - SBOM convert tests added. |
|
||||
| CLI-SBOM-CBOM-TESTS-0001 | DONE | SPRINT_20260117_004 - CBOM export tests added. |
|
||||
| CLI-SBOM-VALIDATE-TESTS-0001 | DONE | SPRINT_20260117_004 - SBOM validate tests added. |
|
||||
| CLI-GRAPH-LINEAGE-TESTS-0001 | DONE | SPRINT_20260117_004 - Graph lineage show tests added. |
|
||||
| CLI-ATTEST-SPDX3-TESTS-0001 | DONE | SPRINT_20260117_004 - Attest build SPDX3 tests added. |
|
||||
| CLI-SCORE-EXPLAIN-TESTS-0001 | DONE | SPRINT_20260117_006 - Score explain tests added. |
|
||||
| CLI-REACHABILITY-TESTS-0001 | DONE | SPRINT_20260117_006 - Reachability explain/witness/guards tests added. |
|
||||
| CLI-SIGNALS-TESTS-0001 | DONE | SPRINT_20260117_006 - Signals inspect tests added. |
|
||||
| CLI-SCANNER-WORKERS-TESTS-0001 | DONE | SPRINT_20260117_005 - Scanner workers get/set tests added. |
|
||||
| CLI-SCAN-WORKERS-TESTS-0001 | DONE | SPRINT_20260117_005 - Scan run workers option tests added. |
|
||||
| CLI-SARIF-METADATA-TESTS-0001 | DONE | SPRINT_20260117_005 - SARIF metadata tests added. |
|
||||
| CLI-DB-CONNECTORS-TESTS-0001 | DONE | SPRINT_20260117_008 - Connector test timeout coverage. |
|
||||
| CLI-VEX-VERIFY-TESTS-0001 | DONE | SPRINT_20260117_009 - VEX verify tests added. |
|
||||
| CLI-VEX-EVIDENCE-EXPORT-TESTS-0001 | DONE | SPRINT_20260117_009 - VEX evidence export tests added. |
|
||||
| CLI-VEX-WEBHOOKS-TESTS-0001 | DONE | SPRINT_20260117_009 - VEX webhooks tests added. |
|
||||
| CLI-ISSUER-KEYS-TESTS-0001 | DONE | SPRINT_20260117_009 - Issuer keys tests added. |
|
||||
| CLI-BINARY-ANALYSIS-TESTS-0001 | DONE | SPRINT_20260117_007 - Binary fingerprint/diff tests added. |
|
||||
| CLI-POLICY-TESTS-0001 | DONE | SPRINT_20260117_010 - Policy lattice/verdict/promote tests added. |
|
||||
|
||||
Reference in New Issue
Block a user