// -----------------------------------------------------------------------------
// 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