//
// Copyright (c) Stella Operations. Licensed under AGPL-3.0-or-later.
//
using System;
using System.IO;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using StellaOps.Cli.Commands;
using Xunit;
namespace StellaOps.Cli.Tests.Commands;
///
/// Tests for CLI verify bundle command (E2E-007).
/// Sprint: SPRINT_20251229_004_005_E2E
///
public sealed class VerifyBundleCommandTests : IDisposable
{
private readonly ServiceProvider _services;
private readonly string _tempDir;
public VerifyBundleCommandTests()
{
var services = new ServiceCollection();
services.AddLogging(builder => builder.AddConsole().SetMinimumLevel(LogLevel.Debug));
_services = services.BuildServiceProvider();
_tempDir = Path.Combine(Path.GetTempPath(), $"stellaops-test-{Guid.NewGuid()}");
Directory.CreateDirectory(_tempDir);
}
public void Dispose()
{
_services.Dispose();
if (Directory.Exists(_tempDir))
{
Directory.Delete(_tempDir, recursive: true);
}
}
[Fact]
public async Task HandleVerifyBundleAsync_WithMissingBundlePath_ReturnsError()
{
// Arrange
var cts = new CancellationTokenSource();
// Act
await CommandHandlers.HandleVerifyBundleAsync(
_services,
string.Empty,
skipReplay: false,
verbose: false,
outputFormat: "json",
cts.Token);
// Assert
Environment.ExitCode.Should().Be(CliExitCodes.GeneralError);
}
[Fact]
public async Task HandleVerifyBundleAsync_WithNonExistentDirectory_ReturnsFileNotFound()
{
// Arrange
var nonExistentPath = Path.Combine(_tempDir, "does-not-exist");
var cts = new CancellationTokenSource();
// Act
await CommandHandlers.HandleVerifyBundleAsync(
_services,
nonExistentPath,
skipReplay: false,
verbose: false,
outputFormat: "json",
cts.Token);
// Assert
Environment.ExitCode.Should().Be(CliExitCodes.FileNotFound);
}
[Fact]
public async Task HandleVerifyBundleAsync_WithMissingManifest_ReturnsFileNotFound()
{
// Arrange
var bundleDir = Path.Combine(_tempDir, "bundle-missing-manifest");
Directory.CreateDirectory(bundleDir);
var cts = new CancellationTokenSource();
// Act
await CommandHandlers.HandleVerifyBundleAsync(
_services,
bundleDir,
skipReplay: false,
verbose: false,
outputFormat: "json",
cts.Token);
// Assert
Environment.ExitCode.Should().Be(CliExitCodes.FileNotFound);
}
[Fact]
public async Task HandleVerifyBundleAsync_WithValidBundle_ValidatesInputHashes()
{
// Arrange
var bundleDir = Path.Combine(_tempDir, "bundle-valid");
Directory.CreateDirectory(bundleDir);
Directory.CreateDirectory(Path.Combine(bundleDir, "inputs"));
Directory.CreateDirectory(Path.Combine(bundleDir, "outputs"));
// Create SBOM file
var sbomPath = Path.Combine(bundleDir, "inputs", "sbom.cdx.json");
var sbomContent = """
{
"bomFormat": "CycloneDX",
"specVersion": "1.6",
"components": []
}
""";
await File.WriteAllTextAsync(sbomPath, sbomContent);
// Compute SHA-256 of SBOM
using var sha256 = System.Security.Cryptography.SHA256.Create();
var sbomBytes = System.Text.Encoding.UTF8.GetBytes(sbomContent);
var sbomHash = Convert.ToHexString(sha256.ComputeHash(sbomBytes)).ToLowerInvariant();
// Create manifest
var manifest = new
{
schemaVersion = "2.0",
bundleId = "test-bundle-001",
description = "Test bundle",
createdAt = "2025-12-29T00:00:00Z",
scan = new
{
id = "test-scan",
imageDigest = "sha256:abc123",
policyDigest = "sha256:policy123",
scorePolicyDigest = "sha256:score123",
feedSnapshotDigest = "sha256:feeds123",
toolchain = "test",
analyzerSetDigest = "sha256:analyzers123"
},
inputs = new
{
sbom = new
{
path = "inputs/sbom.cdx.json",
sha256 = $"sha256:{sbomHash}"
},
feeds = (object?)null,
vex = (object?)null,
policy = (object?)null
},
expectedOutputs = new
{
verdict = new
{
path = "outputs/verdict.json",
sha256 = "sha256:to-be-computed"
},
verdictHash = "sha256:verdict-hash"
},
notes = "Test bundle"
};
var manifestPath = Path.Combine(bundleDir, "manifest.json");
await File.WriteAllTextAsync(manifestPath, JsonSerializer.Serialize(manifest, new JsonSerializerOptions
{
WriteIndented = true
}));
var cts = new CancellationTokenSource();
// Act
await CommandHandlers.HandleVerifyBundleAsync(
_services,
bundleDir,
skipReplay: true, // Skip replay for this test
verbose: true,
outputFormat: "json",
cts.Token);
// Assert
// Since replay is stubbed and DSSE is stubbed, we expect violations but not a hard failure
// The test validates that the command runs without crashing
Environment.ExitCode.Should().BeOneOf(CliExitCodes.Success, CliExitCodes.GeneralError);
}
[Fact]
public async Task HandleVerifyBundleAsync_WithHashMismatch_ReportsViolation()
{
// Arrange
var bundleDir = Path.Combine(_tempDir, "bundle-hash-mismatch");
Directory.CreateDirectory(bundleDir);
Directory.CreateDirectory(Path.Combine(bundleDir, "inputs"));
// Create SBOM file
var sbomPath = Path.Combine(bundleDir, "inputs", "sbom.cdx.json");
await File.WriteAllTextAsync(sbomPath, """{"bomFormat": "CycloneDX"}""");
// Create manifest with WRONG hash
var manifest = new
{
schemaVersion = "2.0",
bundleId = "test-bundle-mismatch",
description = "Test bundle with hash mismatch",
createdAt = "2025-12-29T00:00:00Z",
scan = new
{
id = "test-scan",
imageDigest = "sha256:abc123",
policyDigest = "sha256:policy123",
scorePolicyDigest = "sha256:score123",
feedSnapshotDigest = "sha256:feeds123",
toolchain = "test",
analyzerSetDigest = "sha256:analyzers123"
},
inputs = new
{
sbom = new
{
path = "inputs/sbom.cdx.json",
sha256 = "sha256:wronghashwronghashwronghashwronghashwronghashwronghashwron" // Invalid hash
}
},
expectedOutputs = new
{
verdict = new
{
path = "outputs/verdict.json",
sha256 = "sha256:verdict"
},
verdictHash = "sha256:verdict-hash"
}
};
var manifestPath = Path.Combine(bundleDir, "manifest.json");
await File.WriteAllTextAsync(manifestPath, JsonSerializer.Serialize(manifest, new JsonSerializerOptions
{
WriteIndented = true
}));
var cts = new CancellationTokenSource();
// Act
await CommandHandlers.HandleVerifyBundleAsync(
_services,
bundleDir,
skipReplay: true,
verbose: false,
outputFormat: "json",
cts.Token);
// Assert
Environment.ExitCode.Should().Be(CliExitCodes.GeneralError); // Violation should cause failure
}
[Fact]
public async Task HandleVerifyBundleAsync_WithTarGz_ReturnsNotImplemented()
{
// Arrange
var tarGzPath = Path.Combine(_tempDir, "bundle.tar.gz");
await File.WriteAllTextAsync(tarGzPath, "fake tar.gz"); // Create empty file
var cts = new CancellationTokenSource();
// Act
await CommandHandlers.HandleVerifyBundleAsync(
_services,
tarGzPath,
skipReplay: false,
verbose: false,
outputFormat: "json",
cts.Token);
// Assert
Environment.ExitCode.Should().Be(CliExitCodes.NotImplemented);
}
}