// ----------------------------------------------------------------------------- // 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; /// /// Golden output tests for the `stellaops scan` command. /// Verifies that stdout output matches expected snapshots. /// Implements Model CLI1 test requirements (CLI-5100-005). /// [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 /// /// Verifies that scan SBOM summary output matches golden snapshot (JSON format). /// [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()); } /// /// Verifies that scan SBOM summary output matches golden snapshot (table format). /// [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 } /// /// Verifies that scan with zero vulnerabilities produces correct 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 /// /// Verifies that scan vulnerability list output matches golden snapshot. /// [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"); } /// /// Verifies that vulnerability list table output is properly formatted. /// [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> { 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 /// /// Verifies that package list output is deterministically ordered. /// [Fact] public async Task ScanCommand_PackageList_DeterministicOrder() { // Arrange var packages = CreateTestPackageList(); var renderer = new OutputRenderer(OutputFormat.Json); var outputs = new List(); // 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"); } /// /// Verifies that packages are sorted alphabetically by name. /// [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 /// /// Verifies JSON output uses snake_case property naming. /// [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"); } /// /// Verifies JSON output is properly indented. /// [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 } /// /// Verifies timestamps are ISO-8601 UTC format. /// [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 /// /// Verifies scan error output matches golden snapshot. /// [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 /// /// SBOM summary output model for scan command. /// 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; } = ""; } /// /// Vulnerability list output model. /// public sealed class VulnerabilityListOutput { public List Vulnerabilities { get; set; } = []; } /// /// Single vulnerability entry. /// 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; } } /// /// Package list output model. /// public sealed class PackageListOutput { public List Packages { get; set; } = []; } /// /// Single package entry. /// public sealed class PackageEntry { public string Name { get; set; } = ""; public string Version { get; set; } = ""; public string Ecosystem { get; set; } = ""; } /// /// Scan error output model. /// 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