part #2
This commit is contained in:
@@ -5,6 +5,7 @@
|
||||
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
@@ -281,6 +282,18 @@ public sealed class BinaryDiffEvidenceTests
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BinaryDiffEnums_SerializeAsStrings()
|
||||
{
|
||||
var diffType = JsonSerializer.Serialize(BinaryDiffType.Semantic);
|
||||
var operation = JsonSerializer.Serialize(BinaryDiffOperation.Modified);
|
||||
var securityType = JsonSerializer.Serialize(BinarySecurityChangeType.HardeningChange);
|
||||
|
||||
Assert.Equal("\"Semantic\"", diffType);
|
||||
Assert.Equal("\"Modified\"", operation);
|
||||
Assert.Equal("\"HardeningChange\"", securityType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SchemaVersion_UpdatedForBinaryDiff()
|
||||
{
|
||||
|
||||
@@ -9,3 +9,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| AUDIT-0281-T | DONE | Revalidated 2026-01-07; open findings tracked in audit report. |
|
||||
| AUDIT-0281-A | DONE | Waived (test project; revalidated 2026-01-07). |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
| REMED-07 | DONE | Added enum serialization coverage; dotnet test 2026-02-04 (29 tests). |
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace StellaOps.Interop.Tests.Analysis;
|
||||
|
||||
public sealed record FindingDifference(
|
||||
string Category,
|
||||
string Description,
|
||||
bool IsAcceptable,
|
||||
string? Reason = null);
|
||||
@@ -1,3 +1,6 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace StellaOps.Interop.Tests.Analysis;
|
||||
|
||||
/// <summary>
|
||||
@@ -98,20 +101,3 @@ public sealed class FindingsParityAnalyzer
|
||||
return differences;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ParityAnalysisReport
|
||||
{
|
||||
public int TotalDifferences { get; init; }
|
||||
public IReadOnlyList<FindingDifference> VersionMatchingDifferences { get; init; } = [];
|
||||
public IReadOnlyList<FindingDifference> FeedCoverageDifferences { get; init; } = [];
|
||||
public IReadOnlyList<FindingDifference> PurlDifferences { get; init; } = [];
|
||||
public IReadOnlyList<FindingDifference> VexDifferences { get; init; } = [];
|
||||
public int AcceptableDifferences { get; init; }
|
||||
public int RequiresInvestigation { get; init; }
|
||||
}
|
||||
|
||||
public sealed record FindingDifference(
|
||||
string Category,
|
||||
string Description,
|
||||
bool IsAcceptable,
|
||||
string? Reason = null);
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Interop.Tests.Analysis;
|
||||
|
||||
public sealed class ParityAnalysisReport
|
||||
{
|
||||
public int TotalDifferences { get; init; }
|
||||
public IReadOnlyList<FindingDifference> VersionMatchingDifferences { get; init; } = [];
|
||||
public IReadOnlyList<FindingDifference> FeedCoverageDifferences { get; init; } = [];
|
||||
public IReadOnlyList<FindingDifference> PurlDifferences { get; init; } = [];
|
||||
public IReadOnlyList<FindingDifference> VexDifferences { get; init; } = [];
|
||||
public int AcceptableDifferences { get; init; }
|
||||
public int RequiresInvestigation { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using Xunit;
|
||||
using Xunit.Sdk;
|
||||
|
||||
namespace StellaOps.Interop.Tests.CycloneDx;
|
||||
|
||||
public partial class CycloneDxRoundTripTests
|
||||
{
|
||||
[Theory]
|
||||
[MemberData(nameof(TestImages))]
|
||||
[Trait("Category", "Attestation")]
|
||||
public async Task CycloneDx_Attestation_RoundTripAsync(string imageRef)
|
||||
{
|
||||
if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CI")))
|
||||
{
|
||||
throw SkipException.ForSkip("Cosign attestation requires CI credentials.");
|
||||
}
|
||||
|
||||
var sbomResult = await _harness.GenerateSbomWithStellaAsync(
|
||||
imageRef, SbomFormat.CycloneDx16);
|
||||
sbomResult.Success.Should().BeTrue();
|
||||
|
||||
var attestResult = await _harness.AttestWithCosignAsync(
|
||||
sbomResult.Path!, imageRef);
|
||||
attestResult.Success.Should().BeTrue("Cosign should attest SBOM");
|
||||
|
||||
// TODO: Verify attestation
|
||||
// var verifyResult = await _harness.VerifyCosignAttestation(imageRef);
|
||||
// verifyResult.Success.Should().BeTrue();
|
||||
// var attestedDigest = verifyResult.PredicateDigest;
|
||||
// attestedDigest.Should().Be(sbomResult.Digest);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Interop.Tests.CycloneDx;
|
||||
|
||||
public partial class CycloneDxRoundTripTests
|
||||
{
|
||||
[Theory]
|
||||
[MemberData(nameof(TestImages))]
|
||||
public async Task Syft_GeneratesCycloneDx_GrypeCanConsumeAsync(string imageRef)
|
||||
{
|
||||
var sbomResult = await _harness.GenerateSbomWithSyftAsync(
|
||||
imageRef, SbomFormat.CycloneDx16);
|
||||
sbomResult.Success.Should().BeTrue("Syft should generate CycloneDX SBOM");
|
||||
|
||||
var grypeResult = await _harness.ScanWithGrypeFromSbomAsync(sbomResult.Path!);
|
||||
grypeResult.Success.Should().BeTrue("Grype should consume Syft-generated CycloneDX SBOM");
|
||||
|
||||
grypeResult.Findings.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(TestImages))]
|
||||
public async Task Stella_GeneratesCycloneDx_GrypeCanConsumeAsync(string imageRef)
|
||||
{
|
||||
var sbomResult = await _harness.GenerateSbomWithStellaAsync(
|
||||
imageRef, SbomFormat.CycloneDx16);
|
||||
sbomResult.Success.Should().BeTrue("Stella should generate CycloneDX SBOM");
|
||||
|
||||
var grypeResult = await _harness.ScanWithGrypeFromSbomAsync(sbomResult.Path!);
|
||||
grypeResult.Success.Should().BeTrue("Grype should consume Stella-generated CycloneDX SBOM");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using StellaOps.Interop.Tests;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Interop.Tests.CycloneDx;
|
||||
|
||||
public partial class CycloneDxRoundTripTests
|
||||
{
|
||||
[Theory]
|
||||
[MemberData(nameof(TestImages))]
|
||||
[Trait("Category", "Parity")]
|
||||
public async Task Stella_And_Grype_FindingsParity_Above95PercentAsync(string imageRef)
|
||||
{
|
||||
var stellaSbom = await _harness.GenerateSbomWithStellaAsync(
|
||||
imageRef, SbomFormat.CycloneDx16);
|
||||
stellaSbom.Success.Should().BeTrue();
|
||||
|
||||
var stellaFindings = new List<Finding>();
|
||||
|
||||
var grypeResult = await _harness.ScanWithGrypeFromSbomAsync(stellaSbom.Path!);
|
||||
grypeResult.Success.Should().BeTrue();
|
||||
|
||||
var comparison = FindingsComparer.Compare(
|
||||
stellaFindings,
|
||||
grypeResult.Findings!,
|
||||
tolerancePercent: 5);
|
||||
|
||||
comparison.ParityPercent.Should().BeGreaterThanOrEqualTo(95,
|
||||
$"Findings parity {comparison.ParityPercent:F2}% is below 95% threshold. " +
|
||||
$"Only in Stella: {comparison.OnlyInStella}, Only in Grype: {comparison.OnlyInGrype}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Interop.Tests.CycloneDx;
|
||||
|
||||
public partial class CycloneDxRoundTripTests
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Category", "Schema")]
|
||||
public async Task Stella_CycloneDx_ValidatesAgainstSchemaAsync()
|
||||
{
|
||||
var imageRef = "alpine:3.18";
|
||||
|
||||
var sbomResult = await _harness.GenerateSbomWithStellaAsync(
|
||||
imageRef, SbomFormat.CycloneDx16);
|
||||
sbomResult.Success.Should().BeTrue();
|
||||
|
||||
// TODO: Validate against CycloneDX 1.6 JSON schema
|
||||
sbomResult.Content.Should().NotBeNullOrEmpty();
|
||||
sbomResult.Content.Should().Contain("\"bomFormat\": \"CycloneDX\"");
|
||||
sbomResult.Content.Should().Contain("\"specVersion\": \"1.6\"");
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
namespace StellaOps.Interop.Tests.CycloneDx;
|
||||
using Xunit;
|
||||
|
||||
using Xunit.Sdk;
|
||||
namespace StellaOps.Interop.Tests.CycloneDx;
|
||||
|
||||
[Trait("Category", "Interop")]
|
||||
[Trait("Format", "CycloneDX")]
|
||||
public class CycloneDxRoundTripTests : IClassFixture<InteropTestHarness>
|
||||
public partial class CycloneDxRoundTripTests : IClassFixture<InteropTestHarness>
|
||||
{
|
||||
private readonly InteropTestHarness _harness;
|
||||
|
||||
@@ -13,112 +13,6 @@ public class CycloneDxRoundTripTests : IClassFixture<InteropTestHarness>
|
||||
_harness = harness;
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(TestImages))]
|
||||
public async Task Syft_GeneratesCycloneDx_GrypeCanConsume(string imageRef)
|
||||
{
|
||||
// Generate SBOM with Syft
|
||||
var sbomResult = await _harness.GenerateSbomWithSyft(
|
||||
imageRef, SbomFormat.CycloneDx16);
|
||||
sbomResult.Success.Should().BeTrue("Syft should generate CycloneDX SBOM");
|
||||
|
||||
// Scan from SBOM with Grype
|
||||
var grypeResult = await _harness.ScanWithGrypeFromSbom(sbomResult.Path!);
|
||||
grypeResult.Success.Should().BeTrue("Grype should consume Syft-generated CycloneDX SBOM");
|
||||
|
||||
// Grype should be able to parse and find vulnerabilities
|
||||
grypeResult.Findings.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(TestImages))]
|
||||
public async Task Stella_GeneratesCycloneDx_GrypeCanConsume(string imageRef)
|
||||
{
|
||||
// Generate SBOM with Stella
|
||||
var sbomResult = await _harness.GenerateSbomWithStella(
|
||||
imageRef, SbomFormat.CycloneDx16);
|
||||
sbomResult.Success.Should().BeTrue("Stella should generate CycloneDX SBOM");
|
||||
|
||||
// Scan from SBOM with Grype
|
||||
var grypeResult = await _harness.ScanWithGrypeFromSbom(sbomResult.Path!);
|
||||
grypeResult.Success.Should().BeTrue("Grype should consume Stella-generated CycloneDX SBOM");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(TestImages))]
|
||||
[Trait("Category", "Parity")]
|
||||
public async Task Stella_And_Grype_FindingsParity_Above95Percent(string imageRef)
|
||||
{
|
||||
// Generate SBOM with Stella
|
||||
var stellaSbom = await _harness.GenerateSbomWithStella(
|
||||
imageRef, SbomFormat.CycloneDx16);
|
||||
stellaSbom.Success.Should().BeTrue();
|
||||
|
||||
// TODO: Get Stella findings from scan result
|
||||
var stellaFindings = new List<Finding>();
|
||||
|
||||
// Scan SBOM with Grype
|
||||
var grypeResult = await _harness.ScanWithGrypeFromSbom(stellaSbom.Path!);
|
||||
grypeResult.Success.Should().BeTrue();
|
||||
|
||||
// Compare findings
|
||||
var comparison = _harness.CompareFindings(
|
||||
stellaFindings,
|
||||
grypeResult.Findings!,
|
||||
tolerancePercent: 5);
|
||||
|
||||
comparison.ParityPercent.Should().BeGreaterThanOrEqualTo(95,
|
||||
$"Findings parity {comparison.ParityPercent:F2}% is below 95% threshold. " +
|
||||
$"Only in Stella: {comparison.OnlyInStella}, Only in Grype: {comparison.OnlyInGrype}");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(TestImages))]
|
||||
[Trait("Category", "Attestation")]
|
||||
public async Task CycloneDx_Attestation_RoundTrip(string imageRef)
|
||||
{
|
||||
// Skip if not in CI - cosign requires credentials
|
||||
if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CI")))
|
||||
{
|
||||
throw SkipException.ForSkip("Cosign attestation requires CI credentials.");
|
||||
}
|
||||
|
||||
// Generate SBOM
|
||||
var sbomResult = await _harness.GenerateSbomWithStella(
|
||||
imageRef, SbomFormat.CycloneDx16);
|
||||
sbomResult.Success.Should().BeTrue();
|
||||
|
||||
// Attest with cosign
|
||||
var attestResult = await _harness.AttestWithCosign(
|
||||
sbomResult.Path!, imageRef);
|
||||
attestResult.Success.Should().BeTrue("Cosign should attest SBOM");
|
||||
|
||||
// TODO: Verify attestation
|
||||
// var verifyResult = await _harness.VerifyCosignAttestation(imageRef);
|
||||
// verifyResult.Success.Should().BeTrue();
|
||||
|
||||
// Digest should match
|
||||
// var attestedDigest = verifyResult.PredicateDigest;
|
||||
// attestedDigest.Should().Be(sbomResult.Digest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Schema")]
|
||||
public async Task Stella_CycloneDx_ValidatesAgainstSchema()
|
||||
{
|
||||
var imageRef = "alpine:3.18";
|
||||
|
||||
// Generate SBOM
|
||||
var sbomResult = await _harness.GenerateSbomWithStella(
|
||||
imageRef, SbomFormat.CycloneDx16);
|
||||
sbomResult.Success.Should().BeTrue();
|
||||
|
||||
// TODO: Validate against CycloneDX 1.6 JSON schema
|
||||
sbomResult.Content.Should().NotBeNullOrEmpty();
|
||||
sbomResult.Content.Should().Contain("\"bomFormat\": \"CycloneDX\"");
|
||||
sbomResult.Content.Should().Contain("\"specVersion\": \"1.6\"");
|
||||
}
|
||||
|
||||
public static IEnumerable<object[]> TestImages =>
|
||||
[
|
||||
["alpine:3.18"],
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace StellaOps.Interop.Tests;
|
||||
|
||||
public static class FindingsComparer
|
||||
{
|
||||
public static FindingsComparisonResult Compare(
|
||||
IReadOnlyList<Finding> stellaFindings,
|
||||
IReadOnlyList<GrypeFinding> grypeFindings,
|
||||
decimal tolerancePercent = 5)
|
||||
{
|
||||
var stellaVulns = stellaFindings
|
||||
.Select(f => (f.VulnerabilityId, f.PackagePurl))
|
||||
.ToHashSet();
|
||||
|
||||
var grypeVulns = grypeFindings
|
||||
.Select(f => (f.VulnerabilityId, f.PackagePurl))
|
||||
.ToHashSet();
|
||||
|
||||
var onlyInStella = stellaVulns.Except(grypeVulns).ToList();
|
||||
var onlyInGrype = grypeVulns.Except(stellaVulns).ToList();
|
||||
var inBoth = stellaVulns.Intersect(grypeVulns).ToList();
|
||||
|
||||
var totalUnique = stellaVulns.Union(grypeVulns).Count();
|
||||
var parityPercent = totalUnique > 0
|
||||
? (decimal)inBoth.Count / totalUnique * 100
|
||||
: 100;
|
||||
|
||||
return new FindingsComparisonResult(
|
||||
ParityPercent: parityPercent,
|
||||
IsWithinTolerance: parityPercent >= (100 - tolerancePercent),
|
||||
StellaTotalFindings: stellaFindings.Count,
|
||||
GrypeTotalFindings: grypeFindings.Count,
|
||||
MatchingFindings: inBoth.Count,
|
||||
OnlyInStella: onlyInStella.Count,
|
||||
OnlyInGrype: onlyInGrype.Count,
|
||||
OnlyInStellaDetails: onlyInStella,
|
||||
OnlyInGrypeDetails: onlyInGrype);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Interop.Tests;
|
||||
|
||||
public sealed class FindingsComparerTests
|
||||
{
|
||||
[Fact]
|
||||
public void Compare_ReturnsFullParity_WhenSetsMatch()
|
||||
{
|
||||
var stella = new[]
|
||||
{
|
||||
new Finding("CVE-2024-0001", "pkg:apk/alpine/zlib@1.2.13", "High"),
|
||||
new Finding("CVE-2024-0002", "pkg:apk/alpine/openssl@3.0.0", "Medium")
|
||||
};
|
||||
var grype = new[]
|
||||
{
|
||||
new GrypeFinding("CVE-2024-0001", "pkg:apk/alpine/zlib@1.2.13", "High"),
|
||||
new GrypeFinding("CVE-2024-0002", "pkg:apk/alpine/openssl@3.0.0", "Medium")
|
||||
};
|
||||
|
||||
var result = FindingsComparer.Compare(stella, grype);
|
||||
|
||||
result.ParityPercent.Should().Be(100);
|
||||
result.IsWithinTolerance.Should().BeTrue();
|
||||
result.MatchingFindings.Should().Be(2);
|
||||
result.OnlyInStella.Should().Be(0);
|
||||
result.OnlyInGrype.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compare_ReturnsExpectedCounts_WhenSetsDiffer()
|
||||
{
|
||||
var stella = new[]
|
||||
{
|
||||
new Finding("CVE-2024-0001", "pkg:apk/alpine/zlib@1.2.13", "High"),
|
||||
new Finding("CVE-2024-0002", "pkg:apk/alpine/openssl@3.0.0", "Medium")
|
||||
};
|
||||
var grype = new[]
|
||||
{
|
||||
new GrypeFinding("CVE-2024-0002", "pkg:apk/alpine/openssl@3.0.0", "Medium"),
|
||||
new GrypeFinding("CVE-2024-0003", "pkg:apk/alpine/busybox@1.36.0", "Low")
|
||||
};
|
||||
|
||||
var result = FindingsComparer.Compare(stella, grype);
|
||||
|
||||
var expectedParity = (decimal)1 / 3 * 100;
|
||||
result.ParityPercent.Should().Be(expectedParity);
|
||||
result.IsWithinTolerance.Should().BeFalse();
|
||||
result.MatchingFindings.Should().Be(1);
|
||||
result.OnlyInStella.Should().Be(1);
|
||||
result.OnlyInGrype.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compare_ReturnsFullParity_WhenInputsEmpty()
|
||||
{
|
||||
var result = FindingsComparer.Compare(Array.Empty<Finding>(), Array.Empty<GrypeFinding>());
|
||||
|
||||
result.ParityPercent.Should().Be(100);
|
||||
result.IsWithinTolerance.Should().BeTrue();
|
||||
result.MatchingFindings.Should().Be(0);
|
||||
result.OnlyInStella.Should().Be(0);
|
||||
result.OnlyInGrype.Should().Be(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace StellaOps.Interop.Tests;
|
||||
|
||||
public sealed record GrypeFinding(
|
||||
string VulnerabilityId,
|
||||
string PackagePurl,
|
||||
string Severity,
|
||||
string? FixedIn = null);
|
||||
|
||||
public sealed record Finding(
|
||||
string VulnerabilityId,
|
||||
string PackagePurl,
|
||||
string Severity);
|
||||
|
||||
public sealed record FindingsComparisonResult(
|
||||
decimal ParityPercent,
|
||||
bool IsWithinTolerance,
|
||||
int StellaTotalFindings,
|
||||
int GrypeTotalFindings,
|
||||
int MatchingFindings,
|
||||
int OnlyInStella,
|
||||
int OnlyInGrype,
|
||||
IReadOnlyList<(string VulnId, string Purl)> OnlyInStellaDetails,
|
||||
IReadOnlyList<(string VulnId, string Purl)> OnlyInGrypeDetails);
|
||||
@@ -0,0 +1,28 @@
|
||||
namespace StellaOps.Interop.Tests;
|
||||
|
||||
public sealed record AttestationResult(
|
||||
bool Success,
|
||||
string? ImageRef = null,
|
||||
string? Error = null)
|
||||
{
|
||||
public static AttestationResult Failed(string error) => new(false, Error: error);
|
||||
}
|
||||
|
||||
public sealed record GrypeScanResult(
|
||||
bool Success,
|
||||
IReadOnlyList<GrypeFinding>? Findings = null,
|
||||
string? RawOutput = null,
|
||||
string? Error = null)
|
||||
{
|
||||
public static GrypeScanResult Failed(string error) => new(false, Error: error);
|
||||
}
|
||||
|
||||
public sealed record ToolResult(
|
||||
bool Success,
|
||||
string Output,
|
||||
string? Error = null);
|
||||
|
||||
public sealed record VerifyResult(
|
||||
bool Success,
|
||||
string? PredicateDigest = null,
|
||||
string? Error = null);
|
||||
@@ -0,0 +1,27 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Interop.Tests;
|
||||
|
||||
public sealed partial class InteropTestHarness
|
||||
{
|
||||
/// <summary>
|
||||
/// Attest SBOM using cosign.
|
||||
/// </summary>
|
||||
public async Task<AttestationResult> AttestWithCosignAsync(
|
||||
string sbomPath,
|
||||
string imageRef,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
EnsureToolsAvailable();
|
||||
var result = await _toolManager.RunAsync(
|
||||
"cosign",
|
||||
$"attest --predicate {sbomPath} --type cyclonedx {imageRef} --yes",
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
if (!result.Success)
|
||||
return AttestationResult.Failed(result.Error ?? "Cosign attestation failed");
|
||||
|
||||
return new AttestationResult(Success: true, ImageRef: imageRef);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Interop.Tests;
|
||||
|
||||
public sealed partial class InteropTestHarness
|
||||
{
|
||||
/// <summary>
|
||||
/// Scan using Grype from SBOM (no image pull).
|
||||
/// </summary>
|
||||
public async Task<GrypeScanResult> ScanWithGrypeFromSbomAsync(
|
||||
string sbomPath,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
EnsureToolsAvailable();
|
||||
var outputPath = Path.Combine(_workDir, "grype-findings.json");
|
||||
var result = await _toolManager.RunAsync(
|
||||
"grype",
|
||||
$"sbom:{sbomPath} -o json --file {outputPath}",
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
if (!result.Success)
|
||||
return GrypeScanResult.Failed(result.Error ?? "Grype scan failed");
|
||||
|
||||
var content = await File.ReadAllTextAsync(outputPath, ct).ConfigureAwait(false);
|
||||
var findings = ParseGrypeFindings(content);
|
||||
|
||||
return new GrypeScanResult(
|
||||
Success: true,
|
||||
Findings: findings,
|
||||
RawOutput: content);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<GrypeFinding> ParseGrypeFindings(string json)
|
||||
{
|
||||
// Placeholder: In real implementation, parse Grype JSON output
|
||||
// For now, return empty list
|
||||
return Array.Empty<GrypeFinding>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Interop.Tests;
|
||||
|
||||
public sealed partial class InteropTestHarness
|
||||
{
|
||||
/// <summary>
|
||||
/// Generate SBOM using Syft.
|
||||
/// </summary>
|
||||
public async Task<SbomResult> GenerateSbomWithSyftAsync(
|
||||
string imageRef,
|
||||
SbomFormat format,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
EnsureToolsAvailable();
|
||||
var formatArg = format switch
|
||||
{
|
||||
SbomFormat.CycloneDx16 => "cyclonedx-json",
|
||||
SbomFormat.Spdx30 => "spdx-json",
|
||||
_ => throw new ArgumentException($"Unsupported format: {format}")
|
||||
};
|
||||
|
||||
var outputPath = Path.Combine(_workDir, $"sbom-syft-{format}.json");
|
||||
var result = await _toolManager.RunAsync(
|
||||
"syft",
|
||||
$"{imageRef} -o {formatArg}={outputPath}",
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
if (!result.Success)
|
||||
return SbomResult.Failed(result.Error ?? "Syft execution failed");
|
||||
|
||||
var content = await File.ReadAllTextAsync(outputPath, ct).ConfigureAwait(false);
|
||||
var digest = ComputeDigest(content);
|
||||
|
||||
return new SbomResult(
|
||||
Success: true,
|
||||
Path: outputPath,
|
||||
Format: format,
|
||||
Content: content,
|
||||
Digest: digest);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generate SBOM using Stella scanner.
|
||||
/// </summary>
|
||||
public async Task<SbomResult> GenerateSbomWithStellaAsync(
|
||||
string imageRef,
|
||||
SbomFormat format,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
EnsureToolsAvailable();
|
||||
var formatArg = format switch
|
||||
{
|
||||
SbomFormat.CycloneDx16 => "cyclonedx",
|
||||
SbomFormat.Spdx30 => "spdx",
|
||||
_ => throw new ArgumentException($"Unsupported format: {format}")
|
||||
};
|
||||
|
||||
var outputPath = Path.Combine(_workDir, $"stella-sbom-{format}.json");
|
||||
var result = await _toolManager.RunAsync(
|
||||
"stella",
|
||||
$"scan {imageRef} --sbom-format {formatArg} --sbom-output {outputPath}",
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
if (!result.Success)
|
||||
return SbomResult.Failed(result.Error ?? "Stella execution failed");
|
||||
|
||||
var content = await File.ReadAllTextAsync(outputPath, ct).ConfigureAwait(false);
|
||||
var digest = ComputeDigest(content);
|
||||
|
||||
return new SbomResult(
|
||||
Success: true,
|
||||
Path: outputPath,
|
||||
Format: format,
|
||||
Content: content,
|
||||
Digest: digest);
|
||||
}
|
||||
|
||||
private static string ComputeDigest(string content) =>
|
||||
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(content))).ToLowerInvariant();
|
||||
}
|
||||
@@ -1,16 +1,17 @@
|
||||
namespace StellaOps.Interop.Tests;
|
||||
|
||||
using System.Diagnostics;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Interop;
|
||||
using Xunit.Sdk;
|
||||
|
||||
namespace StellaOps.Interop.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Test harness for SBOM interoperability testing.
|
||||
/// Coordinates Syft, Grype, Trivy, and cosign tools.
|
||||
/// </summary>
|
||||
public sealed class InteropTestHarness : IAsyncLifetime
|
||||
public sealed partial class InteropTestHarness : IAsyncLifetime
|
||||
{
|
||||
private readonly ToolManager _toolManager;
|
||||
private readonly string _workDir;
|
||||
@@ -40,165 +41,9 @@ public sealed class InteropTestHarness : IAsyncLifetime
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify tools are available
|
||||
await _toolManager.VerifyToolAsync("syft", "--version");
|
||||
await _toolManager.VerifyToolAsync("grype", "--version");
|
||||
await _toolManager.VerifyToolAsync("cosign", "version");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generate SBOM using Syft.
|
||||
/// </summary>
|
||||
public async Task<SbomResult> GenerateSbomWithSyft(
|
||||
string imageRef,
|
||||
SbomFormat format,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
EnsureToolsAvailable();
|
||||
var formatArg = format switch
|
||||
{
|
||||
SbomFormat.CycloneDx16 => "cyclonedx-json",
|
||||
SbomFormat.Spdx30 => "spdx-json",
|
||||
_ => throw new ArgumentException($"Unsupported format: {format}")
|
||||
};
|
||||
|
||||
var outputPath = Path.Combine(_workDir, $"sbom-syft-{format}.json");
|
||||
var result = await _toolManager.RunAsync(
|
||||
"syft",
|
||||
$"{imageRef} -o {formatArg}={outputPath}",
|
||||
ct);
|
||||
|
||||
if (!result.Success)
|
||||
return SbomResult.Failed(result.Error ?? "Syft execution failed");
|
||||
|
||||
var content = await File.ReadAllTextAsync(outputPath, ct);
|
||||
var digest = ComputeDigest(content);
|
||||
|
||||
return new SbomResult(
|
||||
Success: true,
|
||||
Path: outputPath,
|
||||
Format: format,
|
||||
Content: content,
|
||||
Digest: digest);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generate SBOM using Stella scanner.
|
||||
/// </summary>
|
||||
public async Task<SbomResult> GenerateSbomWithStella(
|
||||
string imageRef,
|
||||
SbomFormat format,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
EnsureToolsAvailable();
|
||||
var formatArg = format switch
|
||||
{
|
||||
SbomFormat.CycloneDx16 => "cyclonedx",
|
||||
SbomFormat.Spdx30 => "spdx",
|
||||
_ => throw new ArgumentException($"Unsupported format: {format}")
|
||||
};
|
||||
|
||||
var outputPath = Path.Combine(_workDir, $"stella-sbom-{format}.json");
|
||||
var result = await _toolManager.RunAsync(
|
||||
"stella",
|
||||
$"scan {imageRef} --sbom-format {formatArg} --sbom-output {outputPath}",
|
||||
ct);
|
||||
|
||||
if (!result.Success)
|
||||
return SbomResult.Failed(result.Error ?? "Stella execution failed");
|
||||
|
||||
var content = await File.ReadAllTextAsync(outputPath, ct);
|
||||
var digest = ComputeDigest(content);
|
||||
|
||||
return new SbomResult(
|
||||
Success: true,
|
||||
Path: outputPath,
|
||||
Format: format,
|
||||
Content: content,
|
||||
Digest: digest);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attest SBOM using cosign.
|
||||
/// </summary>
|
||||
public async Task<AttestationResult> AttestWithCosign(
|
||||
string sbomPath,
|
||||
string imageRef,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
EnsureToolsAvailable();
|
||||
var result = await _toolManager.RunAsync(
|
||||
"cosign",
|
||||
$"attest --predicate {sbomPath} --type cyclonedx {imageRef} --yes",
|
||||
ct);
|
||||
|
||||
if (!result.Success)
|
||||
return AttestationResult.Failed(result.Error ?? "Cosign attestation failed");
|
||||
|
||||
return new AttestationResult(Success: true, ImageRef: imageRef);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scan using Grype from SBOM (no image pull).
|
||||
/// </summary>
|
||||
public async Task<GrypeScanResult> ScanWithGrypeFromSbom(
|
||||
string sbomPath,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
EnsureToolsAvailable();
|
||||
var outputPath = Path.Combine(_workDir, "grype-findings.json");
|
||||
var result = await _toolManager.RunAsync(
|
||||
"grype",
|
||||
$"sbom:{sbomPath} -o json --file {outputPath}",
|
||||
ct);
|
||||
|
||||
if (!result.Success)
|
||||
return GrypeScanResult.Failed(result.Error ?? "Grype scan failed");
|
||||
|
||||
var content = await File.ReadAllTextAsync(outputPath, ct);
|
||||
var findings = ParseGrypeFindings(content);
|
||||
|
||||
return new GrypeScanResult(
|
||||
Success: true,
|
||||
Findings: findings,
|
||||
RawOutput: content);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compare findings between Stella and Grype.
|
||||
/// </summary>
|
||||
public FindingsComparisonResult CompareFindings(
|
||||
IReadOnlyList<Finding> stellaFindings,
|
||||
IReadOnlyList<GrypeFinding> grypeFindings,
|
||||
decimal tolerancePercent = 5)
|
||||
{
|
||||
var stellaVulns = stellaFindings
|
||||
.Select(f => (f.VulnerabilityId, f.PackagePurl))
|
||||
.ToHashSet();
|
||||
|
||||
var grypeVulns = grypeFindings
|
||||
.Select(f => (f.VulnerabilityId, f.PackagePurl))
|
||||
.ToHashSet();
|
||||
|
||||
var onlyInStella = stellaVulns.Except(grypeVulns).ToList();
|
||||
var onlyInGrype = grypeVulns.Except(stellaVulns).ToList();
|
||||
var inBoth = stellaVulns.Intersect(grypeVulns).ToList();
|
||||
|
||||
var totalUnique = stellaVulns.Union(grypeVulns).Count();
|
||||
var parityPercent = totalUnique > 0
|
||||
? (decimal)inBoth.Count / totalUnique * 100
|
||||
: 100;
|
||||
|
||||
return new FindingsComparisonResult(
|
||||
ParityPercent: parityPercent,
|
||||
IsWithinTolerance: parityPercent >= (100 - tolerancePercent),
|
||||
StellaTotalFindings: stellaFindings.Count,
|
||||
GrypeTotalFindings: grypeFindings.Count,
|
||||
MatchingFindings: inBoth.Count,
|
||||
OnlyInStella: onlyInStella.Count,
|
||||
OnlyInGrype: onlyInGrype.Count,
|
||||
OnlyInStellaDetails: onlyInStella,
|
||||
OnlyInGrypeDetails: onlyInGrype);
|
||||
await _toolManager.VerifyToolAsync("syft", "--version").ConfigureAwait(false);
|
||||
await _toolManager.VerifyToolAsync("grype", "--version").ConfigureAwait(false);
|
||||
await _toolManager.VerifyToolAsync("cosign", "version").ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
@@ -208,22 +53,9 @@ public sealed class InteropTestHarness : IAsyncLifetime
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
private static string ComputeDigest(string content) =>
|
||||
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(content))).ToLowerInvariant();
|
||||
|
||||
private static IReadOnlyList<GrypeFinding> ParseGrypeFindings(string json)
|
||||
{
|
||||
// Placeholder: In real implementation, parse Grype JSON output
|
||||
// For now, return empty list
|
||||
return Array.Empty<GrypeFinding>();
|
||||
}
|
||||
|
||||
private void EnsureToolsAvailable()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(_skipReason))
|
||||
throw SkipException.ForSkip(_skipReason);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -4,11 +4,6 @@
|
||||
// Task: T1, T7 - Interop Test Harness & Project Setup
|
||||
// Description: Models for SBOM interoperability testing.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Interop.Tests;
|
||||
|
||||
public enum SbomFormat
|
||||
@@ -27,52 +22,3 @@ public sealed record SbomResult(
|
||||
{
|
||||
public static SbomResult Failed(string error) => new(false, Error: error);
|
||||
}
|
||||
|
||||
public sealed record AttestationResult(
|
||||
bool Success,
|
||||
string? ImageRef = null,
|
||||
string? Error = null)
|
||||
{
|
||||
public static AttestationResult Failed(string error) => new(false, Error: error);
|
||||
}
|
||||
|
||||
public sealed record GrypeScanResult(
|
||||
bool Success,
|
||||
IReadOnlyList<GrypeFinding>? Findings = null,
|
||||
string? RawOutput = null,
|
||||
string? Error = null)
|
||||
{
|
||||
public static GrypeScanResult Failed(string error) => new(false, Error: error);
|
||||
}
|
||||
|
||||
public sealed record GrypeFinding(
|
||||
string VulnerabilityId,
|
||||
string PackagePurl,
|
||||
string Severity,
|
||||
string? FixedIn = null);
|
||||
|
||||
public sealed record Finding(
|
||||
string VulnerabilityId,
|
||||
string PackagePurl,
|
||||
string Severity);
|
||||
|
||||
public sealed record ToolResult(
|
||||
bool Success,
|
||||
string Output,
|
||||
string? Error = null);
|
||||
|
||||
public sealed record FindingsComparisonResult(
|
||||
decimal ParityPercent,
|
||||
bool IsWithinTolerance,
|
||||
int StellaTotalFindings,
|
||||
int GrypeTotalFindings,
|
||||
int MatchingFindings,
|
||||
int OnlyInStella,
|
||||
int OnlyInGrype,
|
||||
IReadOnlyList<(string VulnId, string Purl)> OnlyInStellaDetails,
|
||||
IReadOnlyList<(string VulnId, string Purl)> OnlyInGrypeDetails);
|
||||
|
||||
public sealed record VerifyResult(
|
||||
bool Success,
|
||||
string? PredicateDigest = null,
|
||||
string? Error = null);
|
||||
|
||||
@@ -13,10 +13,10 @@ public class SpdxRoundTripTests : IClassFixture<InteropTestHarness>
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(TestImages))]
|
||||
public async Task Syft_GeneratesSpdx_CanBeParsed(string imageRef)
|
||||
public async Task Syft_GeneratesSpdx_CanBeParsedAsync(string imageRef)
|
||||
{
|
||||
// Generate SBOM with Syft
|
||||
var sbomResult = await _harness.GenerateSbomWithSyft(
|
||||
var sbomResult = await _harness.GenerateSbomWithSyftAsync(
|
||||
imageRef, SbomFormat.Spdx30);
|
||||
sbomResult.Success.Should().BeTrue("Syft should generate SPDX SBOM");
|
||||
|
||||
@@ -27,10 +27,10 @@ public class SpdxRoundTripTests : IClassFixture<InteropTestHarness>
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(TestImages))]
|
||||
public async Task Stella_GeneratesSpdx_CanBeParsed(string imageRef)
|
||||
public async Task Stella_GeneratesSpdx_CanBeParsedAsync(string imageRef)
|
||||
{
|
||||
// Generate SBOM with Stella
|
||||
var sbomResult = await _harness.GenerateSbomWithStella(
|
||||
var sbomResult = await _harness.GenerateSbomWithStellaAsync(
|
||||
imageRef, SbomFormat.Spdx30);
|
||||
sbomResult.Success.Should().BeTrue("Stella should generate SPDX SBOM");
|
||||
|
||||
@@ -42,10 +42,10 @@ public class SpdxRoundTripTests : IClassFixture<InteropTestHarness>
|
||||
[Theory]
|
||||
[MemberData(nameof(TestImages))]
|
||||
[Trait("Category", "Schema")]
|
||||
public async Task Stella_Spdx_ValidatesAgainstSchema(string imageRef)
|
||||
public async Task Stella_Spdx_ValidatesAgainstSchemaAsync(string imageRef)
|
||||
{
|
||||
// Generate SBOM
|
||||
var sbomResult = await _harness.GenerateSbomWithStella(
|
||||
var sbomResult = await _harness.GenerateSbomWithStellaAsync(
|
||||
imageRef, SbomFormat.Spdx30);
|
||||
sbomResult.Success.Should().BeTrue();
|
||||
|
||||
@@ -57,12 +57,12 @@ public class SpdxRoundTripTests : IClassFixture<InteropTestHarness>
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "EvidenceChain")]
|
||||
public async Task Spdx_IncludesEvidenceChain()
|
||||
public async Task Spdx_IncludesEvidenceChainAsync()
|
||||
{
|
||||
var imageRef = "alpine:3.18";
|
||||
|
||||
// Generate SBOM with evidence
|
||||
var sbomResult = await _harness.GenerateSbomWithStella(
|
||||
var sbomResult = await _harness.GenerateSbomWithStellaAsync(
|
||||
imageRef, SbomFormat.Spdx30);
|
||||
sbomResult.Success.Should().BeTrue();
|
||||
|
||||
@@ -73,12 +73,12 @@ public class SpdxRoundTripTests : IClassFixture<InteropTestHarness>
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Interop")]
|
||||
public async Task Spdx_CompatibleWithConsumers()
|
||||
public async Task Spdx_CompatibleWithConsumersAsync()
|
||||
{
|
||||
var imageRef = "debian:12-slim";
|
||||
|
||||
// Generate SBOM
|
||||
var sbomResult = await _harness.GenerateSbomWithStella(
|
||||
var sbomResult = await _harness.GenerateSbomWithStellaAsync(
|
||||
imageRef, SbomFormat.Spdx30);
|
||||
sbomResult.Success.Should().BeTrue();
|
||||
|
||||
|
||||
@@ -11,3 +11,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| AUDIT-TESTGAP-CORELIB-INTEROP-0001 | DONE | Added ToolManager unit tests + skip gating (2026-01-13). |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
| REMED-08 | DONE | Added stubbed ToolManager unit tests for deterministic path/process checks. |
|
||||
| REMED-09 | DONE | Async naming + file splits (<=100 lines), harness/model refactors, FindingsComparer tests added; ConfigureAwait(false) skipped in xUnit tests per xUnit1030; `dotnet test src/__Tests/interop/StellaOps.Interop.Tests/StellaOps.Interop.Tests.csproj` passed (11 tests, 38 skipped) 2026-02-04. |
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Interop.Tests;
|
||||
|
||||
public sealed partial class ToolManagerTests
|
||||
{
|
||||
private static string ResolveShellPath()
|
||||
=> OperatingSystem.IsWindows()
|
||||
? Environment.GetEnvironmentVariable("ComSpec") ?? string.Empty
|
||||
: "/bin/sh";
|
||||
|
||||
private static string WriteShellScript(string directory)
|
||||
{
|
||||
var scriptName = OperatingSystem.IsWindows() ? "interop-tool.cmd" : "interop-tool.sh";
|
||||
var scriptPath = Path.Combine(directory, scriptName);
|
||||
var content = OperatingSystem.IsWindows()
|
||||
? "@echo off\r\necho ok\r\nexit /b 0\r\n"
|
||||
: "#!/bin/sh\n\necho ok\nexit 0\n";
|
||||
|
||||
File.WriteAllText(scriptPath, content, Encoding.ASCII);
|
||||
return scriptPath;
|
||||
}
|
||||
|
||||
private static string CreateTempDirectory()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), $"interop-tool-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(path);
|
||||
return path;
|
||||
}
|
||||
|
||||
private static void DeleteDirectory(string path)
|
||||
{
|
||||
if (Directory.Exists(path))
|
||||
Directory.Delete(path, recursive: true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.Interop;
|
||||
using Xunit;
|
||||
using Xunit.Sdk;
|
||||
|
||||
namespace StellaOps.Interop.Tests;
|
||||
|
||||
public sealed partial class ToolManagerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task RunAsync_ReturnsFailure_WhenToolMissingAsync()
|
||||
{
|
||||
var workDir = CreateTempDirectory();
|
||||
try
|
||||
{
|
||||
var manager = new ToolManager(workDir);
|
||||
|
||||
var result = await manager.RunAsync("missing-tool", "--version", CancellationToken.None);
|
||||
|
||||
result.Success.Should().BeFalse();
|
||||
result.Error.Should().Contain("Tool not found");
|
||||
result.ExitCode.Should().Be(-1);
|
||||
}
|
||||
finally
|
||||
{
|
||||
DeleteDirectory(workDir);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ReturnsSuccess_WhenShellExecutesScriptAsync()
|
||||
{
|
||||
var workDir = CreateTempDirectory();
|
||||
try
|
||||
{
|
||||
var scriptPath = WriteShellScript(workDir);
|
||||
var shellPath = ResolveShellPath();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(shellPath) || !File.Exists(shellPath))
|
||||
throw SkipException.ForSkip("Shell not available for interop tool test.");
|
||||
|
||||
var toolPaths = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["shell"] = shellPath
|
||||
};
|
||||
|
||||
var args = OperatingSystem.IsWindows()
|
||||
? $"/c \"{scriptPath}\""
|
||||
: $"\"{scriptPath}\"";
|
||||
|
||||
var manager = new ToolManager(workDir, toolPaths);
|
||||
var result = await manager.RunAsync("shell", args, CancellationToken.None);
|
||||
|
||||
result.Success.Should().BeTrue();
|
||||
result.StdOut.Should().Contain("ok");
|
||||
}
|
||||
finally
|
||||
{
|
||||
DeleteDirectory(workDir);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,9 @@ using System.Text;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Interop;
|
||||
using Xunit;
|
||||
using Xunit.Sdk;
|
||||
|
||||
namespace StellaOps.Interop.Tests;
|
||||
|
||||
public sealed class ToolManagerTests
|
||||
public sealed partial class ToolManagerTests
|
||||
{
|
||||
[Fact]
|
||||
public void ResolveToolPath_UsesConfiguredPath()
|
||||
@@ -73,86 +71,4 @@ public sealed class ToolManagerTests
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ReturnsFailure_WhenToolMissing()
|
||||
{
|
||||
var workDir = CreateTempDirectory();
|
||||
try
|
||||
{
|
||||
var manager = new ToolManager(workDir);
|
||||
|
||||
var result = await manager.RunAsync("missing-tool", "--version", CancellationToken.None);
|
||||
|
||||
result.Success.Should().BeFalse();
|
||||
result.Error.Should().Contain("Tool not found");
|
||||
result.ExitCode.Should().Be(-1);
|
||||
}
|
||||
finally
|
||||
{
|
||||
DeleteDirectory(workDir);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ReturnsSuccess_WhenShellExecutesScript()
|
||||
{
|
||||
var workDir = CreateTempDirectory();
|
||||
try
|
||||
{
|
||||
var scriptPath = WriteShellScript(workDir);
|
||||
var shellPath = ResolveShellPath();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(shellPath) || !File.Exists(shellPath))
|
||||
throw SkipException.ForSkip("Shell not available for interop tool test.");
|
||||
|
||||
var toolPaths = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["shell"] = shellPath
|
||||
};
|
||||
|
||||
var args = OperatingSystem.IsWindows()
|
||||
? $"/c \"{scriptPath}\""
|
||||
: $"\"{scriptPath}\"";
|
||||
|
||||
var manager = new ToolManager(workDir, toolPaths);
|
||||
var result = await manager.RunAsync("shell", args, CancellationToken.None);
|
||||
|
||||
result.Success.Should().BeTrue();
|
||||
result.StdOut.Should().Contain("ok");
|
||||
}
|
||||
finally
|
||||
{
|
||||
DeleteDirectory(workDir);
|
||||
}
|
||||
}
|
||||
|
||||
private static string ResolveShellPath()
|
||||
=> OperatingSystem.IsWindows()
|
||||
? Environment.GetEnvironmentVariable("ComSpec") ?? string.Empty
|
||||
: "/bin/sh";
|
||||
|
||||
private static string WriteShellScript(string directory)
|
||||
{
|
||||
var scriptName = OperatingSystem.IsWindows() ? "interop-tool.cmd" : "interop-tool.sh";
|
||||
var scriptPath = Path.Combine(directory, scriptName);
|
||||
var content = OperatingSystem.IsWindows()
|
||||
? "@echo off\r\necho ok\r\nexit /b 0\r\n"
|
||||
: "#!/bin/sh\n\necho ok\nexit 0\n";
|
||||
|
||||
File.WriteAllText(scriptPath, content, Encoding.ASCII);
|
||||
return scriptPath;
|
||||
}
|
||||
|
||||
private static string CreateTempDirectory()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), $"interop-tool-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(path);
|
||||
return path;
|
||||
}
|
||||
|
||||
private static void DeleteDirectory(string path)
|
||||
{
|
||||
if (Directory.Exists(path))
|
||||
Directory.Delete(path, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user