710 lines
20 KiB
C#
710 lines
20 KiB
C#
// -----------------------------------------------------------------------------
|
|
// AuditVerifyCommandTests.cs
|
|
// Sprint: SPRINT_20260117_027_CLI_audit_bundle_command
|
|
// Task: AUD-006 - Tests
|
|
// Description: Unit tests for stella audit verify command
|
|
// -----------------------------------------------------------------------------
|
|
|
|
using System.IO.Compression;
|
|
using Xunit;
|
|
|
|
namespace StellaOps.Cli.Tests.Commands;
|
|
|
|
/// <summary>
|
|
/// Tests for the stella audit verify command.
|
|
/// Validates bundle integrity verification and content validation.
|
|
/// </summary>
|
|
public sealed class AuditVerifyCommandTests
|
|
{
|
|
#region Checksum Verification Tests
|
|
|
|
[Fact]
|
|
public void VerifyChecksum_ValidSha256_ReturnsTrue()
|
|
{
|
|
// Arrange
|
|
var content = "test content for hashing"u8.ToArray();
|
|
var expectedHash = ComputeSha256Hash(content);
|
|
|
|
// Act
|
|
var result = VerifyChecksumForTest(content, expectedHash);
|
|
|
|
// Assert
|
|
Assert.True(result);
|
|
}
|
|
|
|
[Fact]
|
|
public void VerifyChecksum_InvalidHash_ReturnsFalse()
|
|
{
|
|
// Arrange
|
|
var content = "test content"u8.ToArray();
|
|
var wrongHash = "sha256:0000000000000000000000000000000000000000000000000000000000000000";
|
|
|
|
// Act
|
|
var result = VerifyChecksumForTest(content, wrongHash);
|
|
|
|
// Assert
|
|
Assert.False(result);
|
|
}
|
|
|
|
[Fact]
|
|
public void VerifyChecksum_EmptyContent_ComputesCorrectHash()
|
|
{
|
|
// Arrange
|
|
var content = Array.Empty<byte>();
|
|
var expectedHash = ComputeSha256Hash(content);
|
|
|
|
// Act
|
|
var result = VerifyChecksumForTest(content, expectedHash);
|
|
|
|
// Assert
|
|
Assert.True(result);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Bundle Structure Verification Tests
|
|
|
|
[Fact]
|
|
public void VerifyBundleStructure_ValidBundle_ReturnsTrue()
|
|
{
|
|
// Arrange
|
|
var bundlePath = CreateValidTestBundle();
|
|
|
|
try
|
|
{
|
|
// Act
|
|
var result = VerifyBundleStructureForTest(bundlePath);
|
|
|
|
// Assert
|
|
Assert.True(result.IsValid);
|
|
Assert.Empty(result.Errors);
|
|
}
|
|
finally
|
|
{
|
|
CleanupTestBundle(bundlePath);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void VerifyBundleStructure_MissingManifest_ReturnsFalse()
|
|
{
|
|
// Arrange
|
|
var bundlePath = CreateTestBundleWithoutManifest();
|
|
|
|
try
|
|
{
|
|
// Act
|
|
var result = VerifyBundleStructureForTest(bundlePath);
|
|
|
|
// Assert
|
|
Assert.False(result.IsValid);
|
|
Assert.Contains("manifest.json", result.Errors[0], StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
finally
|
|
{
|
|
CleanupTestBundle(bundlePath);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void VerifyBundleStructure_MissingReadme_ReturnsFalse()
|
|
{
|
|
// Arrange
|
|
var bundlePath = CreateTestBundleWithoutReadme();
|
|
|
|
try
|
|
{
|
|
// Act
|
|
var result = VerifyBundleStructureForTest(bundlePath);
|
|
|
|
// Assert
|
|
Assert.False(result.IsValid);
|
|
Assert.Contains("README.md", result.Errors[0], StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
finally
|
|
{
|
|
CleanupTestBundle(bundlePath);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void VerifyBundleStructure_MissingEvidenceFolder_ReturnsFalse()
|
|
{
|
|
// Arrange
|
|
var bundlePath = CreateTestBundleWithoutEvidence();
|
|
|
|
try
|
|
{
|
|
// Act
|
|
var result = VerifyBundleStructureForTest(bundlePath);
|
|
|
|
// Assert
|
|
Assert.False(result.IsValid);
|
|
Assert.Contains("evidence", result.Errors[0], StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
finally
|
|
{
|
|
CleanupTestBundle(bundlePath);
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Manifest Verification Tests
|
|
|
|
[Fact]
|
|
public void VerifyManifest_ValidManifest_ReturnsTrue()
|
|
{
|
|
// Arrange
|
|
var manifest = CreateValidManifest();
|
|
|
|
// Act
|
|
var result = VerifyManifestForTest(manifest);
|
|
|
|
// Assert
|
|
Assert.True(result.IsValid);
|
|
}
|
|
|
|
[Fact]
|
|
public void VerifyManifest_MissingVersion_ReturnsFalse()
|
|
{
|
|
// Arrange
|
|
var manifest = """{"artifactDigest": "sha256:abc123"}""";
|
|
|
|
// Act
|
|
var result = VerifyManifestForTest(manifest);
|
|
|
|
// Assert
|
|
Assert.False(result.IsValid);
|
|
Assert.Contains("version", result.Errors[0], StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
[Fact]
|
|
public void VerifyManifest_MissingArtifactDigest_ReturnsFalse()
|
|
{
|
|
// Arrange
|
|
var manifest = """{"version": "1.0"}""";
|
|
|
|
// Act
|
|
var result = VerifyManifestForTest(manifest);
|
|
|
|
// Assert
|
|
Assert.False(result.IsValid);
|
|
Assert.Contains("artifactDigest", result.Errors[0], StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
[Fact]
|
|
public void VerifyManifest_InvalidJson_ReturnsFalse()
|
|
{
|
|
// Arrange
|
|
var manifest = "not valid json {";
|
|
|
|
// Act
|
|
var result = VerifyManifestForTest(manifest);
|
|
|
|
// Assert
|
|
Assert.False(result.IsValid);
|
|
Assert.Contains("JSON", result.Errors[0], StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Evidence Verification Tests
|
|
|
|
[Fact]
|
|
public void VerifyEvidence_AllFilesPresent_ReturnsTrue()
|
|
{
|
|
// Arrange
|
|
var bundlePath = CreateValidTestBundle();
|
|
|
|
try
|
|
{
|
|
// Act
|
|
var result = VerifyEvidenceForTest(bundlePath);
|
|
|
|
// Assert
|
|
Assert.True(result.IsValid);
|
|
Assert.True(result.FilesVerified > 0);
|
|
}
|
|
finally
|
|
{
|
|
CleanupTestBundle(bundlePath);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void VerifyEvidence_MissingReferencedFile_ReturnsFalse()
|
|
{
|
|
// Arrange
|
|
var bundlePath = CreateBundleWithMissingEvidence();
|
|
|
|
try
|
|
{
|
|
// Act
|
|
var result = VerifyEvidenceForTest(bundlePath);
|
|
|
|
// Assert
|
|
Assert.False(result.IsValid);
|
|
Assert.Contains("missing", result.Errors[0], StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
finally
|
|
{
|
|
CleanupTestBundle(bundlePath);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void VerifyEvidence_CorruptedFile_ReturnsFalse()
|
|
{
|
|
// Arrange
|
|
var bundlePath = CreateBundleWithCorruptedEvidence();
|
|
|
|
try
|
|
{
|
|
// Act
|
|
var result = VerifyEvidenceForTest(bundlePath);
|
|
|
|
// Assert
|
|
Assert.False(result.IsValid);
|
|
Assert.Contains("checksum", result.Errors[0], StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
finally
|
|
{
|
|
CleanupTestBundle(bundlePath);
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Archive Format Tests
|
|
|
|
[Fact]
|
|
public void VerifyArchive_ValidZip_ReturnsTrue()
|
|
{
|
|
// Arrange
|
|
var archivePath = CreateValidZipArchive();
|
|
|
|
try
|
|
{
|
|
// Act
|
|
var result = VerifyArchiveForTest(archivePath, ArchiveFormat.Zip);
|
|
|
|
// Assert
|
|
Assert.True(result.IsValid);
|
|
}
|
|
finally
|
|
{
|
|
File.Delete(archivePath);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void VerifyArchive_ValidTarGz_ReturnsTrue()
|
|
{
|
|
// Arrange
|
|
var archivePath = CreateValidTarGzArchive();
|
|
|
|
try
|
|
{
|
|
// Act
|
|
var result = VerifyArchiveForTest(archivePath, ArchiveFormat.TarGz);
|
|
|
|
// Assert
|
|
Assert.True(result.IsValid);
|
|
}
|
|
finally
|
|
{
|
|
File.Delete(archivePath);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void VerifyArchive_CorruptedZip_ReturnsFalse()
|
|
{
|
|
// Arrange
|
|
var archivePath = Path.GetTempFileName();
|
|
File.WriteAllBytes(archivePath, [0x50, 0x4B, 0x00, 0x00]); // Invalid ZIP
|
|
|
|
try
|
|
{
|
|
// Act
|
|
var result = VerifyArchiveForTest(archivePath, ArchiveFormat.Zip);
|
|
|
|
// Assert
|
|
Assert.False(result.IsValid);
|
|
}
|
|
finally
|
|
{
|
|
File.Delete(archivePath);
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Exit Code Tests
|
|
|
|
[Fact]
|
|
public void DetermineExitCode_ValidBundle_ReturnsZero()
|
|
{
|
|
// Act
|
|
var exitCode = DetermineExitCodeForTest(isValid: true, hasErrors: false);
|
|
|
|
// Assert
|
|
Assert.Equal(0, exitCode);
|
|
}
|
|
|
|
[Fact]
|
|
public void DetermineExitCode_InvalidBundle_ReturnsOne()
|
|
{
|
|
// Act
|
|
var exitCode = DetermineExitCodeForTest(isValid: false, hasErrors: false);
|
|
|
|
// Assert
|
|
Assert.Equal(1, exitCode);
|
|
}
|
|
|
|
[Fact]
|
|
public void DetermineExitCode_ProcessingError_ReturnsTwo()
|
|
{
|
|
// Act
|
|
var exitCode = DetermineExitCodeForTest(isValid: false, hasErrors: true);
|
|
|
|
// Assert
|
|
Assert.Equal(2, exitCode);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Output Format Tests
|
|
|
|
[Fact]
|
|
public void RenderResult_TableFormat_ContainsRequiredFields()
|
|
{
|
|
// Arrange
|
|
var result = CreateValidVerificationResult();
|
|
|
|
// Act
|
|
var output = RenderResultForTest(result, "table");
|
|
|
|
// Assert
|
|
Assert.Contains("Status:", output);
|
|
Assert.Contains("VALID", output);
|
|
Assert.Contains("Files verified:", output);
|
|
}
|
|
|
|
[Fact]
|
|
public void RenderResult_JsonFormat_IsValidJson()
|
|
{
|
|
// Arrange
|
|
var result = CreateValidVerificationResult();
|
|
|
|
// Act
|
|
var output = RenderResultForTest(result, "json");
|
|
|
|
// Assert
|
|
var parsed = System.Text.Json.JsonDocument.Parse(output);
|
|
Assert.NotNull(parsed);
|
|
Assert.True(parsed.RootElement.GetProperty("isValid").GetBoolean());
|
|
}
|
|
|
|
[Fact]
|
|
public void RenderResult_MarkdownFormat_ContainsHeaders()
|
|
{
|
|
// Arrange
|
|
var result = CreateValidVerificationResult();
|
|
|
|
// Act
|
|
var output = RenderResultForTest(result, "markdown");
|
|
|
|
// Assert
|
|
Assert.Contains("# Audit Bundle Verification", output);
|
|
Assert.Contains("## Summary", output);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Test Helpers
|
|
|
|
private static string ComputeSha256Hash(byte[] content)
|
|
{
|
|
using var sha256 = System.Security.Cryptography.SHA256.Create();
|
|
var hash = sha256.ComputeHash(content);
|
|
return "sha256:" + Convert.ToHexString(hash).ToLowerInvariant();
|
|
}
|
|
|
|
private static bool VerifyChecksumForTest(byte[] content, string expectedHash)
|
|
{
|
|
var actualHash = ComputeSha256Hash(content);
|
|
return string.Equals(actualHash, expectedHash, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
private static string CreateValidTestBundle()
|
|
{
|
|
var bundlePath = Path.Combine(Path.GetTempPath(), $"audit-test-{Guid.NewGuid()}");
|
|
Directory.CreateDirectory(bundlePath);
|
|
Directory.CreateDirectory(Path.Combine(bundlePath, "evidence"));
|
|
Directory.CreateDirectory(Path.Combine(bundlePath, "policies"));
|
|
|
|
File.WriteAllText(Path.Combine(bundlePath, "manifest.json"), CreateValidManifest());
|
|
File.WriteAllText(Path.Combine(bundlePath, "README.md"), "# Audit Bundle\n\nTest bundle.");
|
|
File.WriteAllText(Path.Combine(bundlePath, "replay-instructions.md"), "# Replay Instructions\n\nTest.");
|
|
File.WriteAllText(Path.Combine(bundlePath, "evidence", "verdict.json"), """{"decision": "PASS"}""");
|
|
|
|
return bundlePath;
|
|
}
|
|
|
|
private static string CreateTestBundleWithoutManifest()
|
|
{
|
|
var bundlePath = Path.Combine(Path.GetTempPath(), $"audit-test-{Guid.NewGuid()}");
|
|
Directory.CreateDirectory(bundlePath);
|
|
Directory.CreateDirectory(Path.Combine(bundlePath, "evidence"));
|
|
File.WriteAllText(Path.Combine(bundlePath, "README.md"), "# Test");
|
|
return bundlePath;
|
|
}
|
|
|
|
private static string CreateTestBundleWithoutReadme()
|
|
{
|
|
var bundlePath = Path.Combine(Path.GetTempPath(), $"audit-test-{Guid.NewGuid()}");
|
|
Directory.CreateDirectory(bundlePath);
|
|
Directory.CreateDirectory(Path.Combine(bundlePath, "evidence"));
|
|
File.WriteAllText(Path.Combine(bundlePath, "manifest.json"), CreateValidManifest());
|
|
return bundlePath;
|
|
}
|
|
|
|
private static string CreateTestBundleWithoutEvidence()
|
|
{
|
|
var bundlePath = Path.Combine(Path.GetTempPath(), $"audit-test-{Guid.NewGuid()}");
|
|
Directory.CreateDirectory(bundlePath);
|
|
File.WriteAllText(Path.Combine(bundlePath, "manifest.json"), CreateValidManifest());
|
|
File.WriteAllText(Path.Combine(bundlePath, "README.md"), "# Test");
|
|
return bundlePath;
|
|
}
|
|
|
|
private static string CreateBundleWithMissingEvidence()
|
|
{
|
|
var bundlePath = CreateValidTestBundle();
|
|
// Create manifest referencing non-existent file
|
|
var manifest = """
|
|
{
|
|
"version": "1.0",
|
|
"artifactDigest": "sha256:abc123",
|
|
"evidence": [
|
|
{"path": "evidence/missing.json", "checksum": "sha256:000"}
|
|
]
|
|
}
|
|
""";
|
|
File.WriteAllText(Path.Combine(bundlePath, "manifest.json"), manifest);
|
|
return bundlePath;
|
|
}
|
|
|
|
private static string CreateBundleWithCorruptedEvidence()
|
|
{
|
|
var bundlePath = CreateValidTestBundle();
|
|
// Create manifest with wrong checksum
|
|
var manifest = """
|
|
{
|
|
"version": "1.0",
|
|
"artifactDigest": "sha256:abc123",
|
|
"evidence": [
|
|
{"path": "evidence/verdict.json", "checksum": "sha256:wrong"}
|
|
]
|
|
}
|
|
""";
|
|
File.WriteAllText(Path.Combine(bundlePath, "manifest.json"), manifest);
|
|
return bundlePath;
|
|
}
|
|
|
|
private static string CreateValidManifest()
|
|
{
|
|
return """
|
|
{
|
|
"version": "1.0",
|
|
"artifactDigest": "sha256:abc123def456789",
|
|
"generatedAt": "2026-01-17T12:00:00Z",
|
|
"generatedBy": "stella-cli/1.0",
|
|
"evidence": []
|
|
}
|
|
""";
|
|
}
|
|
|
|
private static string CreateValidZipArchive()
|
|
{
|
|
var bundlePath = CreateValidTestBundle();
|
|
var archivePath = Path.Combine(Path.GetTempPath(), $"audit-{Guid.NewGuid()}.zip");
|
|
ZipFile.CreateFromDirectory(bundlePath, archivePath);
|
|
CleanupTestBundle(bundlePath);
|
|
return archivePath;
|
|
}
|
|
|
|
private static string CreateValidTarGzArchive()
|
|
{
|
|
// For testing, we'll create a simple gzip file
|
|
var archivePath = Path.Combine(Path.GetTempPath(), $"audit-{Guid.NewGuid()}.tar.gz");
|
|
using var fs = File.Create(archivePath);
|
|
using var gzip = new System.IO.Compression.GZipStream(fs, CompressionLevel.Optimal);
|
|
gzip.WriteByte(0); // Minimal content
|
|
return archivePath;
|
|
}
|
|
|
|
private static void CleanupTestBundle(string bundlePath)
|
|
{
|
|
if (Directory.Exists(bundlePath))
|
|
{
|
|
Directory.Delete(bundlePath, recursive: true);
|
|
}
|
|
}
|
|
|
|
private static (bool IsValid, List<string> Errors) VerifyBundleStructureForTest(string bundlePath)
|
|
{
|
|
var errors = new List<string>();
|
|
|
|
if (!File.Exists(Path.Combine(bundlePath, "manifest.json")))
|
|
errors.Add("Missing required file: manifest.json");
|
|
|
|
if (!File.Exists(Path.Combine(bundlePath, "README.md")))
|
|
errors.Add("Missing required file: README.md");
|
|
|
|
if (!Directory.Exists(Path.Combine(bundlePath, "evidence")))
|
|
errors.Add("Missing required directory: evidence");
|
|
|
|
return (errors.Count == 0, errors);
|
|
}
|
|
|
|
private static (bool IsValid, List<string> Errors) VerifyManifestForTest(string manifestJson)
|
|
{
|
|
var errors = new List<string>();
|
|
|
|
try
|
|
{
|
|
var doc = System.Text.Json.JsonDocument.Parse(manifestJson);
|
|
if (!doc.RootElement.TryGetProperty("version", out _))
|
|
errors.Add("Missing required field: version");
|
|
if (!doc.RootElement.TryGetProperty("artifactDigest", out _))
|
|
errors.Add("Missing required field: artifactDigest");
|
|
}
|
|
catch (System.Text.Json.JsonException)
|
|
{
|
|
errors.Add("Invalid JSON format");
|
|
}
|
|
|
|
return (errors.Count == 0, errors);
|
|
}
|
|
|
|
private static (bool IsValid, int FilesVerified, List<string> Errors) VerifyEvidenceForTest(string bundlePath)
|
|
{
|
|
var errors = new List<string>();
|
|
var filesVerified = 0;
|
|
|
|
var manifestPath = Path.Combine(bundlePath, "manifest.json");
|
|
if (!File.Exists(manifestPath))
|
|
{
|
|
return (false, 0, ["manifest.json missing"]);
|
|
}
|
|
|
|
var manifest = System.Text.Json.JsonDocument.Parse(File.ReadAllText(manifestPath));
|
|
if (manifest.RootElement.TryGetProperty("evidence", out var evidence))
|
|
{
|
|
foreach (var item in evidence.EnumerateArray())
|
|
{
|
|
var path = item.GetProperty("path").GetString();
|
|
var checksum = item.GetProperty("checksum").GetString();
|
|
|
|
var fullPath = Path.Combine(bundlePath, path!);
|
|
if (!File.Exists(fullPath))
|
|
{
|
|
errors.Add($"Missing evidence file: {path}");
|
|
continue;
|
|
}
|
|
|
|
var content = File.ReadAllBytes(fullPath);
|
|
var actualChecksum = ComputeSha256Hash(content);
|
|
if (!string.Equals(actualChecksum, checksum, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
errors.Add($"Checksum mismatch for {path}");
|
|
continue;
|
|
}
|
|
|
|
filesVerified++;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
filesVerified = Directory.GetFiles(Path.Combine(bundlePath, "evidence")).Length;
|
|
}
|
|
|
|
return (errors.Count == 0, filesVerified, errors);
|
|
}
|
|
|
|
private static (bool IsValid, List<string> Errors) VerifyArchiveForTest(string archivePath, ArchiveFormat format)
|
|
{
|
|
var errors = new List<string>();
|
|
|
|
try
|
|
{
|
|
if (format == ArchiveFormat.Zip)
|
|
{
|
|
using var archive = ZipFile.OpenRead(archivePath);
|
|
// Valid if we can open it
|
|
}
|
|
else if (format == ArchiveFormat.TarGz)
|
|
{
|
|
using var fs = File.OpenRead(archivePath);
|
|
using var gzip = new GZipStream(fs, CompressionMode.Decompress);
|
|
gzip.ReadByte(); // Try to read
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
errors.Add($"Invalid archive: {ex.Message}");
|
|
}
|
|
|
|
return (errors.Count == 0, errors);
|
|
}
|
|
|
|
private static int DetermineExitCodeForTest(bool isValid, bool hasErrors)
|
|
{
|
|
if (hasErrors) return 2;
|
|
return isValid ? 0 : 1;
|
|
}
|
|
|
|
private static VerificationResult CreateValidVerificationResult()
|
|
{
|
|
return new VerificationResult
|
|
{
|
|
IsValid = true,
|
|
FilesVerified = 5,
|
|
Errors = []
|
|
};
|
|
}
|
|
|
|
private static string RenderResultForTest(VerificationResult result, string format)
|
|
{
|
|
return format switch
|
|
{
|
|
"json" => System.Text.Json.JsonSerializer.Serialize(result),
|
|
"markdown" => $"""
|
|
# Audit Bundle Verification
|
|
|
|
## Summary
|
|
- **Status:** {(result.IsValid ? "VALID" : "INVALID")}
|
|
- **Files verified:** {result.FilesVerified}
|
|
""",
|
|
_ => $"""
|
|
Status: {(result.IsValid ? "VALID" : "INVALID")}
|
|
Files verified: {result.FilesVerified}
|
|
"""
|
|
};
|
|
}
|
|
|
|
private enum ArchiveFormat { Zip, TarGz }
|
|
|
|
private sealed class VerificationResult
|
|
{
|
|
public bool IsValid { get; init; }
|
|
public int FilesVerified { get; init; }
|
|
public List<string> Errors { get; init; } = [];
|
|
}
|
|
|
|
#endregion
|
|
}
|