// ----------------------------------------------------------------------------- // CompareCommandTests.cs // Sprint: SPRINT_4200_0002_0004_cli_compare // Tasks: #7 (CLI Compare Tests) // Description: Unit tests for CLI compare commands // ----------------------------------------------------------------------------- using System.CommandLine; using System.CommandLine.Parsing; using Microsoft.Extensions.DependencyInjection; using Xunit; using StellaOps.Cli.Commands.Compare; namespace StellaOps.Cli.Tests.Commands; /// /// Unit tests for compare CLI commands. /// public class CompareCommandTests { private readonly IServiceProvider _services; private readonly Option _verboseOption; private readonly CancellationToken _cancellationToken; public CompareCommandTests() { _services = new ServiceCollection() .AddSingleton() .BuildServiceProvider(); _verboseOption = new Option("--verbose", "Enable verbose output"); _verboseOption.AddAlias("-v"); _cancellationToken = CancellationToken.None; } #region Command Structure Tests [Fact] public void BuildCompareCommand_CreatesCompareCommandTree() { // Act var command = CompareCommandBuilder.BuildCompareCommand(_services, _verboseOption, _cancellationToken); // Assert Assert.Equal("compare", command.Name); Assert.Equal("Compare scan snapshots (SBOM/vulnerability diff).", command.Description); } [Fact] public void BuildCompareCommand_HasDiffSubcommand() { // Act var command = CompareCommandBuilder.BuildCompareCommand(_services, _verboseOption, _cancellationToken); var diffCommand = command.Subcommands.FirstOrDefault(c => c.Name == "diff"); // Assert Assert.NotNull(diffCommand); Assert.Equal("Compare two scan snapshots and show detailed diff.", diffCommand.Description); } [Fact] public void BuildCompareCommand_HasSummarySubcommand() { // Act var command = CompareCommandBuilder.BuildCompareCommand(_services, _verboseOption, _cancellationToken); var summaryCommand = command.Subcommands.FirstOrDefault(c => c.Name == "summary"); // Assert Assert.NotNull(summaryCommand); Assert.Equal("Show quick summary of changes between snapshots.", summaryCommand.Description); } [Fact] public void BuildCompareCommand_HasCanShipSubcommand() { // Act var command = CompareCommandBuilder.BuildCompareCommand(_services, _verboseOption, _cancellationToken); var canShipCommand = command.Subcommands.FirstOrDefault(c => c.Name == "can-ship"); // Assert Assert.NotNull(canShipCommand); Assert.Equal("Check if target snapshot can ship relative to base.", canShipCommand.Description); } [Fact] public void BuildCompareCommand_HasVulnsSubcommand() { // Act var command = CompareCommandBuilder.BuildCompareCommand(_services, _verboseOption, _cancellationToken); var vulnsCommand = command.Subcommands.FirstOrDefault(c => c.Name == "vulns"); // Assert Assert.NotNull(vulnsCommand); Assert.Equal("List vulnerability changes between snapshots.", vulnsCommand.Description); } #endregion #region Option Tests [Fact] public void DiffCommand_HasBaseOption() { // Arrange var command = CompareCommandBuilder.BuildCompareCommand(_services, _verboseOption, _cancellationToken); var diffCommand = command.Subcommands.First(c => c.Name == "diff"); // Act var baseOption = diffCommand.Options.FirstOrDefault(o => o.Name == "--base" || o.Aliases.Contains("--base") || o.Aliases.Contains("-b")); // Assert Assert.NotNull(baseOption); } [Fact] public void DiffCommand_HasTargetOption() { // Arrange var command = CompareCommandBuilder.BuildCompareCommand(_services, _verboseOption, _cancellationToken); var diffCommand = command.Subcommands.First(c => c.Name == "diff"); // Act var targetOption = diffCommand.Options.FirstOrDefault(o => o.Name == "--target" || o.Aliases.Contains("--target") || o.Aliases.Contains("-t")); // Assert Assert.NotNull(targetOption); } [Fact] public void DiffCommand_HasOutputOption() { // Arrange var command = CompareCommandBuilder.BuildCompareCommand(_services, _verboseOption, _cancellationToken); var diffCommand = command.Subcommands.First(c => c.Name == "diff"); // Act var outputOption = diffCommand.Options.FirstOrDefault(o => o.Name == "--output" || o.Aliases.Contains("--output") || o.Aliases.Contains("-o")); // Assert Assert.NotNull(outputOption); } [Fact] public void DiffCommand_HasOutputFileOption() { // Arrange var command = CompareCommandBuilder.BuildCompareCommand(_services, _verboseOption, _cancellationToken); var diffCommand = command.Subcommands.First(c => c.Name == "diff"); // Act var outputFileOption = diffCommand.Options.FirstOrDefault(o => o.Name == "--output-file" || o.Aliases.Contains("--output-file") || o.Aliases.Contains("-f")); // Assert Assert.NotNull(outputFileOption); } [Fact] public void DiffCommand_HasSeverityFilterOption() { // Arrange var command = CompareCommandBuilder.BuildCompareCommand(_services, _verboseOption, _cancellationToken); var diffCommand = command.Subcommands.First(c => c.Name == "diff"); // Act var severityOption = diffCommand.Options.FirstOrDefault(o => o.Name == "--severity" || o.Aliases.Contains("--severity") || o.Aliases.Contains("-s")); // Assert Assert.NotNull(severityOption); } [Fact] public void DiffCommand_HasIncludeUnchangedOption() { // Arrange var command = CompareCommandBuilder.BuildCompareCommand(_services, _verboseOption, _cancellationToken); var diffCommand = command.Subcommands.First(c => c.Name == "diff"); // Act var includeUnchangedOption = diffCommand.Options.FirstOrDefault(o => o.Name == "--include-unchanged" || o.Aliases.Contains("--include-unchanged")); // Assert Assert.NotNull(includeUnchangedOption); } [Fact] public void DiffCommand_HasBackendUrlOption() { // Arrange var command = CompareCommandBuilder.BuildCompareCommand(_services, _verboseOption, _cancellationToken); var diffCommand = command.Subcommands.First(c => c.Name == "diff"); // Act var backendUrlOption = diffCommand.Options.FirstOrDefault(o => o.Name == "--backend-url" || o.Aliases.Contains("--backend-url")); // Assert Assert.NotNull(backendUrlOption); } #endregion #region Parse Tests [Fact] public void CompareDiff_ParsesWithBaseAndTarget() { // Arrange var command = CompareCommandBuilder.BuildCompareCommand(_services, _verboseOption, _cancellationToken); var root = new RootCommand { command }; var parser = new Parser(root); // Act var result = parser.Parse("compare diff --base sha256:abc123 --target sha256:def456"); // Assert Assert.Empty(result.Errors); } [Fact] public void CompareDiff_ParsesWithShortOptions() { // Arrange var command = CompareCommandBuilder.BuildCompareCommand(_services, _verboseOption, _cancellationToken); var root = new RootCommand { command }; var parser = new Parser(root); // Act var result = parser.Parse("compare diff -b sha256:abc123 -t sha256:def456"); // Assert Assert.Empty(result.Errors); } [Fact] public void CompareDiff_ParsesWithJsonOutput() { // Arrange var command = CompareCommandBuilder.BuildCompareCommand(_services, _verboseOption, _cancellationToken); var root = new RootCommand { command }; var parser = new Parser(root); // Act var result = parser.Parse("compare diff -b sha256:abc123 -t sha256:def456 -o json"); // Assert Assert.Empty(result.Errors); } [Fact] public void CompareDiff_ParsesWithSarifOutput() { // Arrange var command = CompareCommandBuilder.BuildCompareCommand(_services, _verboseOption, _cancellationToken); var root = new RootCommand { command }; var parser = new Parser(root); // Act var result = parser.Parse("compare diff -b sha256:abc123 -t sha256:def456 -o sarif"); // Assert Assert.Empty(result.Errors); } [Fact] public void CompareDiff_ParsesWithOutputFile() { // Arrange var command = CompareCommandBuilder.BuildCompareCommand(_services, _verboseOption, _cancellationToken); var root = new RootCommand { command }; var parser = new Parser(root); // Act var result = parser.Parse("compare diff -b sha256:abc123 -t sha256:def456 -o json -f output.json"); // Assert Assert.Empty(result.Errors); } [Fact] public void CompareDiff_ParsesWithSeverityFilter() { // Arrange var command = CompareCommandBuilder.BuildCompareCommand(_services, _verboseOption, _cancellationToken); var root = new RootCommand { command }; var parser = new Parser(root); // Act var result = parser.Parse("compare diff -b sha256:abc123 -t sha256:def456 -s critical"); // Assert Assert.Empty(result.Errors); } [Fact] public void CompareDiff_ParsesWithIncludeUnchanged() { // Arrange var command = CompareCommandBuilder.BuildCompareCommand(_services, _verboseOption, _cancellationToken); var root = new RootCommand { command }; var parser = new Parser(root); // Act var result = parser.Parse("compare diff -b sha256:abc123 -t sha256:def456 --include-unchanged"); // Assert Assert.Empty(result.Errors); } [Fact] public void CompareDiff_FailsWithoutBase() { // Arrange var command = CompareCommandBuilder.BuildCompareCommand(_services, _verboseOption, _cancellationToken); var root = new RootCommand { command }; var parser = new Parser(root); // Act var result = parser.Parse("compare diff -t sha256:def456"); // Assert Assert.NotEmpty(result.Errors); } [Fact] public void CompareDiff_FailsWithoutTarget() { // Arrange var command = CompareCommandBuilder.BuildCompareCommand(_services, _verboseOption, _cancellationToken); var root = new RootCommand { command }; var parser = new Parser(root); // Act var result = parser.Parse("compare diff -b sha256:abc123"); // Assert Assert.NotEmpty(result.Errors); } [Fact] public void CompareSummary_ParsesWithBaseAndTarget() { // Arrange var command = CompareCommandBuilder.BuildCompareCommand(_services, _verboseOption, _cancellationToken); var root = new RootCommand { command }; var parser = new Parser(root); // Act var result = parser.Parse("compare summary -b sha256:abc123 -t sha256:def456"); // Assert Assert.Empty(result.Errors); } [Fact] public void CompareCanShip_ParsesWithBaseAndTarget() { // Arrange var command = CompareCommandBuilder.BuildCompareCommand(_services, _verboseOption, _cancellationToken); var root = new RootCommand { command }; var parser = new Parser(root); // Act var result = parser.Parse("compare can-ship -b sha256:abc123 -t sha256:def456"); // Assert Assert.Empty(result.Errors); } [Fact] public void CompareVulns_ParsesWithBaseAndTarget() { // Arrange var command = CompareCommandBuilder.BuildCompareCommand(_services, _verboseOption, _cancellationToken); var root = new RootCommand { command }; var parser = new Parser(root); // Act var result = parser.Parse("compare vulns -b sha256:abc123 -t sha256:def456"); // Assert Assert.Empty(result.Errors); } #endregion #region LocalCompareClient Tests [Fact] public async Task LocalCompareClient_CompareAsync_ReturnsResult() { // Arrange var client = new LocalCompareClient(); var request = new CompareRequest { BaseDigest = "sha256:abc123", TargetDigest = "sha256:def456" }; // Act var result = await client.CompareAsync(request); // Assert Assert.NotNull(result); Assert.Equal(request.BaseDigest, result.BaseDigest); Assert.Equal(request.TargetDigest, result.TargetDigest); Assert.NotNull(result.Summary); } [Fact] public async Task LocalCompareClient_GetSummaryAsync_ReturnsSummary() { // Arrange var client = new LocalCompareClient(); // Act var summary = await client.GetSummaryAsync("sha256:abc123", "sha256:def456", null); // Assert Assert.NotNull(summary); Assert.True(summary.CanShip); Assert.NotNull(summary.RiskDirection); } [Fact] public async Task LocalCompareClient_CompareAsync_ReturnsEmptyVulnerabilities() { // Arrange var client = new LocalCompareClient(); var request = new CompareRequest { BaseDigest = "sha256:abc123", TargetDigest = "sha256:def456" }; // Act var result = await client.CompareAsync(request); // Assert Assert.NotNull(result.Vulnerabilities); Assert.Empty(result.Vulnerabilities); } [Fact] public async Task LocalCompareClient_CompareAsync_ReturnsUnchangedDirection() { // Arrange var client = new LocalCompareClient(); var request = new CompareRequest { BaseDigest = "sha256:abc123", TargetDigest = "sha256:def456" }; // Act var result = await client.CompareAsync(request); // Assert Assert.Equal("unchanged", result.RiskDirection); } [Fact] public async Task LocalCompareClient_GetSummaryAsync_ReturnsZeroNetChange() { // Arrange var client = new LocalCompareClient(); // Act var summary = await client.GetSummaryAsync("sha256:abc123", "sha256:def456", null); // Assert Assert.Equal(0, summary.NetBlockingChange); } #endregion #region Record Model Tests [Fact] public void CompareRequest_CanBeCreated() { // Arrange & Act var request = new CompareRequest { BaseDigest = "sha256:abc", TargetDigest = "sha256:def" }; // Assert Assert.Equal("sha256:abc", request.BaseDigest); Assert.Equal("sha256:def", request.TargetDigest); Assert.False(request.IncludeUnchanged); Assert.Null(request.SeverityFilter); Assert.Null(request.BackendUrl); } [Fact] public void CompareResult_CanBeCreated() { // Arrange & Act var result = new CompareResult { BaseDigest = "sha256:abc", TargetDigest = "sha256:def", RiskDirection = "improved", Summary = new CompareSummary { CanShip = true, RiskDirection = "improved", Summary = "Test summary" }, Vulnerabilities = [] }; // Assert Assert.Equal("sha256:abc", result.BaseDigest); Assert.Equal("sha256:def", result.TargetDigest); Assert.Equal("improved", result.RiskDirection); Assert.True(result.Summary.CanShip); } [Fact] public void CompareSummary_CanBeCreated() { // Arrange & Act var summary = new CompareSummary { CanShip = false, RiskDirection = "degraded", NetBlockingChange = 5, Added = 3, Removed = 1, CriticalAdded = 2, Summary = "Risk increased" }; // Assert Assert.False(summary.CanShip); Assert.Equal("degraded", summary.RiskDirection); Assert.Equal(5, summary.NetBlockingChange); Assert.Equal(3, summary.Added); Assert.Equal(1, summary.Removed); Assert.Equal(2, summary.CriticalAdded); } [Fact] public void VulnChange_CanBeCreated() { // Arrange & Act var vuln = new VulnChange { VulnId = "CVE-2024-12345", Purl = "pkg:npm/lodash@4.17.20", ChangeType = "Added", Severity = "High" }; // Assert Assert.Equal("CVE-2024-12345", vuln.VulnId); Assert.Equal("pkg:npm/lodash@4.17.20", vuln.Purl); Assert.Equal("Added", vuln.ChangeType); Assert.Equal("High", vuln.Severity); } #endregion }