- Introduced `sink-detect.js` with various security sink detection patterns categorized by type (e.g., command injection, SQL injection, file operations). - Implemented functions to build a lookup map for fast sink detection and to match sink calls against known patterns. - Added `package-lock.json` for dependency management.
571 lines
17 KiB
C#
571 lines
17 KiB
C#
// -----------------------------------------------------------------------------
|
|
// 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;
|
|
|
|
/// <summary>
|
|
/// Unit tests for compare CLI commands.
|
|
/// </summary>
|
|
public class CompareCommandTests
|
|
{
|
|
private readonly IServiceProvider _services;
|
|
private readonly Option<bool> _verboseOption;
|
|
private readonly CancellationToken _cancellationToken;
|
|
|
|
public CompareCommandTests()
|
|
{
|
|
_services = new ServiceCollection()
|
|
.AddSingleton<ICompareClient, LocalCompareClient>()
|
|
.BuildServiceProvider();
|
|
_verboseOption = new Option<bool>("--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
|
|
}
|