472 lines
15 KiB
C#
472 lines
15 KiB
C#
// -----------------------------------------------------------------------------
|
|
// ScanCommandGoldenTests.cs
|
|
// Sprint: SPRINT_5100_0009_0010_cli_tests
|
|
// Task: CLI-5100-005
|
|
// Description: Golden output tests for `stellaops scan` command stdout snapshot.
|
|
// -----------------------------------------------------------------------------
|
|
|
|
using System.Text;
|
|
using FluentAssertions;
|
|
using StellaOps.Cli.Output;
|
|
using Xunit;
|
|
|
|
namespace StellaOps.Cli.Tests.GoldenOutput;
|
|
|
|
/// <summary>
|
|
/// Golden output tests for the `stellaops scan` command.
|
|
/// Verifies that stdout output matches expected snapshots.
|
|
/// Implements Model CLI1 test requirements (CLI-5100-005).
|
|
/// </summary>
|
|
[Trait("Category", "Unit")]
|
|
[Trait("Category", "GoldenOutput")]
|
|
[Trait("Sprint", "5100-0009-0010")]
|
|
public sealed class ScanCommandGoldenTests
|
|
{
|
|
private static readonly DateTimeOffset FixedTimestamp = new(2025, 12, 24, 12, 0, 0, TimeSpan.Zero);
|
|
|
|
#region SBOM Summary Output Tests
|
|
|
|
/// <summary>
|
|
/// Verifies that scan SBOM summary output matches golden snapshot (JSON format).
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ScanCommand_SbomSummary_Json_MatchesGolden()
|
|
{
|
|
// Arrange
|
|
var summary = CreateTestSbomSummary();
|
|
var renderer = new OutputRenderer(OutputFormat.Json);
|
|
var writer = new StringWriter();
|
|
|
|
// Act
|
|
await renderer.RenderAsync(summary, writer);
|
|
var actual = writer.ToString().Trim();
|
|
|
|
// Assert - Golden snapshot
|
|
var expected = """
|
|
{
|
|
"image_digest": "sha256:abc123def456",
|
|
"image_tag": "alpine:3.18",
|
|
"scan_id": "scan-001",
|
|
"timestamp": "2025-12-24T12:00:00+00:00",
|
|
"package_count": 42,
|
|
"vulnerability_count": 5,
|
|
"critical_count": 1,
|
|
"high_count": 2,
|
|
"medium_count": 2,
|
|
"low_count": 0,
|
|
"sbom_format": "spdx-3.0.1",
|
|
"scanner_version": "1.0.0"
|
|
}
|
|
""";
|
|
|
|
actual.Should().Be(expected.Trim());
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that scan SBOM summary output matches golden snapshot (table format).
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ScanCommand_SbomSummary_Table_MatchesGolden()
|
|
{
|
|
// Arrange
|
|
var summary = CreateTestSbomSummary();
|
|
var renderer = new OutputRenderer(OutputFormat.Table);
|
|
var writer = new StringWriter();
|
|
|
|
// Act
|
|
await renderer.RenderAsync(summary, writer);
|
|
var actual = writer.ToString();
|
|
|
|
// Assert - Table output should contain key fields
|
|
actual.Should().Contain("alpine:3.18");
|
|
actual.Should().Contain("sha256:abc123def456");
|
|
actual.Should().Contain("42"); // package count
|
|
actual.Should().Contain("5"); // vulnerability count
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that scan with zero vulnerabilities produces correct summary.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ScanCommand_SbomSummary_ZeroVulns_MatchesGolden()
|
|
{
|
|
// Arrange
|
|
var summary = CreateTestSbomSummary(vulnCount: 0);
|
|
var renderer = new OutputRenderer(OutputFormat.Json);
|
|
var writer = new StringWriter();
|
|
|
|
// Act
|
|
await renderer.RenderAsync(summary, writer);
|
|
var actual = writer.ToString().Trim();
|
|
|
|
// Assert
|
|
actual.Should().Contain("\"vulnerability_count\": 0");
|
|
actual.Should().Contain("\"critical_count\": 0");
|
|
actual.Should().Contain("\"high_count\": 0");
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Vulnerability List Output Tests
|
|
|
|
/// <summary>
|
|
/// Verifies that scan vulnerability list output matches golden snapshot.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ScanCommand_VulnList_Json_MatchesGolden()
|
|
{
|
|
// Arrange
|
|
var vulns = CreateTestVulnerabilityList();
|
|
var renderer = new OutputRenderer(OutputFormat.Json);
|
|
var writer = new StringWriter();
|
|
|
|
// Act
|
|
await renderer.RenderAsync(vulns, writer);
|
|
var actual = writer.ToString();
|
|
|
|
// Assert - Vulnerabilities should be ordered by severity (critical first)
|
|
var criticalIndex = actual.IndexOf("CVE-2024-0001", StringComparison.Ordinal);
|
|
var highIndex = actual.IndexOf("CVE-2024-0002", StringComparison.Ordinal);
|
|
var mediumIndex = actual.IndexOf("CVE-2024-0003", StringComparison.Ordinal);
|
|
|
|
criticalIndex.Should().BeLessThan(highIndex, "critical vulns should appear before high");
|
|
highIndex.Should().BeLessThan(mediumIndex, "high vulns should appear before medium");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that vulnerability list table output is properly formatted.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ScanCommand_VulnList_Table_ProperlyFormatted()
|
|
{
|
|
// Arrange
|
|
var vulns = CreateTestVulnerabilityList();
|
|
var renderer = new OutputRenderer(OutputFormat.Table);
|
|
var writer = new StringWriter();
|
|
var columns = new List<ColumnDefinition<VulnerabilityEntry>>
|
|
{
|
|
new("CVE", v => v.CveId),
|
|
new("Severity", v => v.Severity),
|
|
new("Package", v => v.PackageName),
|
|
new("Fixed", v => v.FixedVersion ?? "none")
|
|
};
|
|
|
|
// Act
|
|
await renderer.RenderTableAsync(vulns, writer, columns);
|
|
var actual = writer.ToString();
|
|
|
|
// Assert
|
|
actual.Should().Contain("CVE");
|
|
actual.Should().Contain("Severity");
|
|
actual.Should().Contain("Package");
|
|
actual.Should().Contain("Fixed");
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region SBOM Package List Output Tests
|
|
|
|
/// <summary>
|
|
/// Verifies that package list output is deterministically ordered.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ScanCommand_PackageList_DeterministicOrder()
|
|
{
|
|
// Arrange
|
|
var packages = CreateTestPackageList();
|
|
var renderer = new OutputRenderer(OutputFormat.Json);
|
|
var outputs = new List<string>();
|
|
|
|
// Act - Run twice to verify determinism
|
|
for (int i = 0; i < 2; i++)
|
|
{
|
|
var writer = new StringWriter();
|
|
await renderer.RenderAsync(packages, writer);
|
|
outputs.Add(writer.ToString());
|
|
}
|
|
|
|
// Assert - Same output each time
|
|
outputs[0].Should().Be(outputs[1], "output should be deterministic");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that packages are sorted alphabetically by name.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ScanCommand_PackageList_SortedByName()
|
|
{
|
|
// Arrange
|
|
var packages = new PackageListOutput
|
|
{
|
|
Packages =
|
|
[
|
|
new PackageEntry { Name = "zlib", Version = "1.2.13", Ecosystem = "alpine" },
|
|
new PackageEntry { Name = "apk-tools", Version = "2.14.0", Ecosystem = "alpine" },
|
|
new PackageEntry { Name = "musl", Version = "1.2.4", Ecosystem = "alpine" }
|
|
]
|
|
};
|
|
|
|
// Sort for deterministic output
|
|
packages.Packages = [.. packages.Packages.OrderBy(p => p.Name)];
|
|
|
|
var renderer = new OutputRenderer(OutputFormat.Json);
|
|
var writer = new StringWriter();
|
|
|
|
// Act
|
|
await renderer.RenderAsync(packages, writer);
|
|
var actual = writer.ToString();
|
|
|
|
// Assert - Should be alphabetically sorted
|
|
var apkIndex = actual.IndexOf("apk-tools", StringComparison.Ordinal);
|
|
var muslIndex = actual.IndexOf("musl", StringComparison.Ordinal);
|
|
var zlibIndex = actual.IndexOf("zlib", StringComparison.Ordinal);
|
|
|
|
apkIndex.Should().BeLessThan(muslIndex);
|
|
muslIndex.Should().BeLessThan(zlibIndex);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Output Format Tests
|
|
|
|
/// <summary>
|
|
/// Verifies JSON output uses snake_case property naming.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ScanCommand_JsonOutput_UsesSnakeCase()
|
|
{
|
|
// Arrange
|
|
var summary = CreateTestSbomSummary();
|
|
var renderer = new OutputRenderer(OutputFormat.Json);
|
|
var writer = new StringWriter();
|
|
|
|
// Act
|
|
await renderer.RenderAsync(summary, writer);
|
|
var actual = writer.ToString();
|
|
|
|
// Assert - Properties should be snake_case
|
|
actual.Should().Contain("image_digest");
|
|
actual.Should().Contain("image_tag");
|
|
actual.Should().Contain("scan_id");
|
|
actual.Should().Contain("package_count");
|
|
actual.Should().Contain("vulnerability_count");
|
|
actual.Should().NotContain("ImageDigest");
|
|
actual.Should().NotContain("imageDigest");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies JSON output is properly indented.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ScanCommand_JsonOutput_IsIndented()
|
|
{
|
|
// Arrange
|
|
var summary = CreateTestSbomSummary();
|
|
var renderer = new OutputRenderer(OutputFormat.Json);
|
|
var writer = new StringWriter();
|
|
|
|
// Act
|
|
await renderer.RenderAsync(summary, writer);
|
|
var actual = writer.ToString();
|
|
|
|
// Assert - Should contain newlines and indentation
|
|
actual.Should().Contain("\n");
|
|
actual.Should().Contain(" "); // 2-space indent
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies timestamps are ISO-8601 UTC format.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ScanCommand_Timestamps_AreIso8601Utc()
|
|
{
|
|
// Arrange
|
|
var summary = CreateTestSbomSummary();
|
|
var renderer = new OutputRenderer(OutputFormat.Json);
|
|
var writer = new StringWriter();
|
|
|
|
// Act
|
|
await renderer.RenderAsync(summary, writer);
|
|
var actual = writer.ToString();
|
|
|
|
// Assert - ISO-8601 format with timezone
|
|
actual.Should().Contain("2025-12-24T12:00:00");
|
|
actual.Should().MatchRegex(@"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}");
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Error Output Tests
|
|
|
|
/// <summary>
|
|
/// Verifies scan error output matches golden snapshot.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ScanCommand_Error_MatchesGolden()
|
|
{
|
|
// Arrange
|
|
var error = new ScanErrorOutput
|
|
{
|
|
ErrorCode = "SCAN_FAILED",
|
|
Message = "Unable to scan image: registry timeout",
|
|
ImageReference = "alpine:3.18",
|
|
Timestamp = FixedTimestamp
|
|
};
|
|
var renderer = new OutputRenderer(OutputFormat.Json);
|
|
var writer = new StringWriter();
|
|
|
|
// Act
|
|
await renderer.RenderAsync(error, writer);
|
|
var actual = writer.ToString().Trim();
|
|
|
|
// Assert
|
|
actual.Should().Contain("\"error_code\": \"SCAN_FAILED\"");
|
|
actual.Should().Contain("Unable to scan image: registry timeout");
|
|
actual.Should().Contain("alpine:3.18");
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Test Data Factory Methods
|
|
|
|
private static SbomSummaryOutput CreateTestSbomSummary(int vulnCount = 5)
|
|
{
|
|
return new SbomSummaryOutput
|
|
{
|
|
ImageDigest = "sha256:abc123def456",
|
|
ImageTag = "alpine:3.18",
|
|
ScanId = "scan-001",
|
|
Timestamp = FixedTimestamp,
|
|
PackageCount = 42,
|
|
VulnerabilityCount = vulnCount,
|
|
CriticalCount = vulnCount > 0 ? 1 : 0,
|
|
HighCount = vulnCount > 0 ? 2 : 0,
|
|
MediumCount = vulnCount > 0 ? 2 : 0,
|
|
LowCount = 0,
|
|
SbomFormat = "spdx-3.0.1",
|
|
ScannerVersion = "1.0.0"
|
|
};
|
|
}
|
|
|
|
private static VulnerabilityListOutput CreateTestVulnerabilityList()
|
|
{
|
|
return new VulnerabilityListOutput
|
|
{
|
|
Vulnerabilities =
|
|
[
|
|
new VulnerabilityEntry
|
|
{
|
|
CveId = "CVE-2024-0001",
|
|
Severity = "CRITICAL",
|
|
PackageName = "openssl",
|
|
PackageVersion = "1.1.1t",
|
|
FixedVersion = "1.1.1u"
|
|
},
|
|
new VulnerabilityEntry
|
|
{
|
|
CveId = "CVE-2024-0002",
|
|
Severity = "HIGH",
|
|
PackageName = "curl",
|
|
PackageVersion = "8.0.0",
|
|
FixedVersion = "8.0.1"
|
|
},
|
|
new VulnerabilityEntry
|
|
{
|
|
CveId = "CVE-2024-0003",
|
|
Severity = "MEDIUM",
|
|
PackageName = "zlib",
|
|
PackageVersion = "1.2.13",
|
|
FixedVersion = null
|
|
}
|
|
]
|
|
};
|
|
}
|
|
|
|
private static PackageListOutput CreateTestPackageList()
|
|
{
|
|
return new PackageListOutput
|
|
{
|
|
Packages =
|
|
[
|
|
new PackageEntry { Name = "openssl", Version = "1.1.1t", Ecosystem = "alpine" },
|
|
new PackageEntry { Name = "curl", Version = "8.0.0", Ecosystem = "alpine" },
|
|
new PackageEntry { Name = "zlib", Version = "1.2.13", Ecosystem = "alpine" }
|
|
]
|
|
};
|
|
}
|
|
|
|
#endregion
|
|
}
|
|
|
|
#region Output Models
|
|
|
|
/// <summary>
|
|
/// SBOM summary output model for scan command.
|
|
/// </summary>
|
|
public sealed class SbomSummaryOutput
|
|
{
|
|
public string ImageDigest { get; set; } = "";
|
|
public string ImageTag { get; set; } = "";
|
|
public string ScanId { get; set; } = "";
|
|
public DateTimeOffset Timestamp { get; set; }
|
|
public int PackageCount { get; set; }
|
|
public int VulnerabilityCount { get; set; }
|
|
public int CriticalCount { get; set; }
|
|
public int HighCount { get; set; }
|
|
public int MediumCount { get; set; }
|
|
public int LowCount { get; set; }
|
|
public string SbomFormat { get; set; } = "";
|
|
public string ScannerVersion { get; set; } = "";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Vulnerability list output model.
|
|
/// </summary>
|
|
public sealed class VulnerabilityListOutput
|
|
{
|
|
public List<VulnerabilityEntry> Vulnerabilities { get; set; } = [];
|
|
}
|
|
|
|
/// <summary>
|
|
/// Single vulnerability entry.
|
|
/// </summary>
|
|
public sealed class VulnerabilityEntry
|
|
{
|
|
public string CveId { get; set; } = "";
|
|
public string Severity { get; set; } = "";
|
|
public string PackageName { get; set; } = "";
|
|
public string PackageVersion { get; set; } = "";
|
|
public string? FixedVersion { get; set; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Package list output model.
|
|
/// </summary>
|
|
public sealed class PackageListOutput
|
|
{
|
|
public List<PackageEntry> Packages { get; set; } = [];
|
|
}
|
|
|
|
/// <summary>
|
|
/// Single package entry.
|
|
/// </summary>
|
|
public sealed class PackageEntry
|
|
{
|
|
public string Name { get; set; } = "";
|
|
public string Version { get; set; } = "";
|
|
public string Ecosystem { get; set; } = "";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Scan error output model.
|
|
/// </summary>
|
|
public sealed class ScanErrorOutput
|
|
{
|
|
public string ErrorCode { get; set; } = "";
|
|
public string Message { get; set; } = "";
|
|
public string ImageReference { get; set; } = "";
|
|
public DateTimeOffset Timestamp { get; set; }
|
|
}
|
|
|
|
#endregion
|