product advisories, stella router improval, tests streghthening
This commit is contained in:
@@ -0,0 +1,471 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// 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
|
||||
Reference in New Issue
Block a user