finish off sprint advisories and sprints

This commit is contained in:
master
2026-01-24 00:12:43 +02:00
parent 726d70dc7f
commit c70e83719e
266 changed files with 46699 additions and 1328 deletions

View File

@@ -0,0 +1,561 @@
// -----------------------------------------------------------------------------
// AttestAttachCommandTests.cs
// Sprint: SPRINT_20260122_040_Platform_oci_delta_attestation_pipeline (040-01)
// Description: Integration tests for attest attach command wired to IOciAttestationAttacher
// -----------------------------------------------------------------------------
using System.CommandLine;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using StellaOps.Attestor.Envelope;
using StellaOps.Attestor.Oci.Services;
using StellaOps.Cli.Commands;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.Cli.Tests.Commands;
public sealed class AttestAttachCommandTests : IDisposable
{
private readonly Option<bool> _verboseOption = new("--verbose");
private readonly string _testDir;
public AttestAttachCommandTests()
{
_testDir = Path.Combine(Path.GetTempPath(), $"attest-attach-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(_testDir);
}
public void Dispose()
{
try { Directory.Delete(_testDir, recursive: true); } catch { /* cleanup best-effort */ }
}
private static string CreateDsseFile(string directory, string payloadType = "application/vnd.in-toto+json", string? filename = null)
{
var payload = Convert.ToBase64String(Encoding.UTF8.GetBytes(
"""{"predicateType":"https://slsa.dev/provenance/v1","predicate":{}}"""));
var sig = Convert.ToBase64String(Encoding.UTF8.GetBytes("fake-signature-bytes-here"));
var envelope = new
{
payloadType,
payload,
signatures = new[]
{
new { keyid = "test-key-001", sig }
}
};
var path = Path.Combine(directory, filename ?? "attestation.dsse.json");
File.WriteAllText(path, JsonSerializer.Serialize(envelope));
return path;
}
private ServiceProvider BuildServices(FakeOciAttestationAttacher? attacher = null)
{
var services = new ServiceCollection();
services.AddLogging(b => b.AddDebug());
services.AddSingleton(TimeProvider.System);
attacher ??= new FakeOciAttestationAttacher();
services.AddSingleton<IOciAttestationAttacher>(attacher);
services.AddSingleton<StellaOps.Attestor.Oci.Services.IOciRegistryClient>(
new FakeOciRegistryClient());
return services.BuildServiceProvider();
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Attach_WithValidDsse_ReturnsZeroAndCallsAttacher()
{
// Arrange
var attacher = new FakeOciAttestationAttacher();
using var sp = BuildServices(attacher);
var dsseFile = CreateDsseFile(_testDir);
var command = AttestCommandGroup.BuildAttachCommand(sp, _verboseOption, CancellationToken.None);
var root = new RootCommand { command };
var writer = new StringWriter();
var originalOut = Console.Out;
int exitCode;
try
{
Console.SetOut(writer);
exitCode = await root.Parse(
$"attach --image registry.example.com/app@sha256:aabbccdd --attestation \"{dsseFile}\"")
.InvokeAsync();
}
finally
{
Console.SetOut(originalOut);
}
// Assert
Assert.Equal(0, exitCode);
Assert.Single(attacher.AttachCalls);
var (imageRef, envelope, options) = attacher.AttachCalls[0];
Assert.Equal("registry.example.com", imageRef.Registry);
Assert.Equal("app", imageRef.Repository);
Assert.Equal("sha256:aabbccdd", imageRef.Digest);
Assert.Equal("application/vnd.in-toto+json", envelope.PayloadType);
Assert.Single(envelope.Signatures);
Assert.False(options!.ReplaceExisting);
Assert.False(options.RecordInRekor);
var output = writer.ToString();
Assert.Contains("Attestation attached to", output);
Assert.Contains("sha256:", output);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Attach_WithVerboseFlag_PrintsDetails()
{
// Arrange
using var sp = BuildServices();
var dsseFile = CreateDsseFile(_testDir);
var command = AttestCommandGroup.BuildAttachCommand(sp, _verboseOption, CancellationToken.None);
var root = new RootCommand { command };
var writer = new StringWriter();
var originalOut = Console.Out;
int exitCode;
try
{
Console.SetOut(writer);
exitCode = await root.Parse(
$"attach --image registry.example.com/app@sha256:aabbccdd --attestation \"{dsseFile}\" --verbose")
.InvokeAsync();
}
finally
{
Console.SetOut(originalOut);
}
Assert.Equal(0, exitCode);
var output = writer.ToString();
Assert.Contains("Attaching attestation to", output);
Assert.Contains("Payload type:", output);
Assert.Contains("Signatures:", output);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Attach_WithMissingFile_ReturnsOne()
{
// Arrange
using var sp = BuildServices();
var command = AttestCommandGroup.BuildAttachCommand(sp, _verboseOption, CancellationToken.None);
var root = new RootCommand { command };
var errWriter = new StringWriter();
var originalErr = Console.Error;
int exitCode;
try
{
Console.SetError(errWriter);
exitCode = await root.Parse(
"attach --image registry.example.com/app@sha256:abc --attestation /nonexistent/file.json")
.InvokeAsync();
}
finally
{
Console.SetError(originalErr);
}
Assert.Equal(1, exitCode);
Assert.Contains("not found", errWriter.ToString());
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Attach_WithInvalidJson_ReturnsTwo()
{
// Arrange
using var sp = BuildServices();
var invalidFile = Path.Combine(_testDir, "invalid.json");
File.WriteAllText(invalidFile, "not json {{{");
var command = AttestCommandGroup.BuildAttachCommand(sp, _verboseOption, CancellationToken.None);
var root = new RootCommand { command };
var errWriter = new StringWriter();
var originalErr = Console.Error;
int exitCode;
try
{
Console.SetError(errWriter);
exitCode = await root.Parse(
$"attach --image registry.example.com/app@sha256:abc --attestation \"{invalidFile}\"")
.InvokeAsync();
}
finally
{
Console.SetError(originalErr);
}
Assert.Equal(2, exitCode);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Attach_WithReplaceFlag_SetsOptionsCorrectly()
{
// Arrange
var attacher = new FakeOciAttestationAttacher();
using var sp = BuildServices(attacher);
var dsseFile = CreateDsseFile(_testDir);
var command = AttestCommandGroup.BuildAttachCommand(sp, _verboseOption, CancellationToken.None);
var root = new RootCommand { command };
var writer = new StringWriter();
var originalOut = Console.Out;
try
{
Console.SetOut(writer);
await root.Parse(
$"attach --image registry.example.com/app@sha256:aabbccdd --attestation \"{dsseFile}\" --replace")
.InvokeAsync();
}
finally
{
Console.SetOut(originalOut);
}
Assert.Single(attacher.AttachCalls);
var (_, _, options) = attacher.AttachCalls[0];
Assert.True(options!.ReplaceExisting);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Attach_WithRekorFlag_SetsOptionsCorrectly()
{
// Arrange
var attacher = new FakeOciAttestationAttacher();
using var sp = BuildServices(attacher);
var dsseFile = CreateDsseFile(_testDir);
var command = AttestCommandGroup.BuildAttachCommand(sp, _verboseOption, CancellationToken.None);
var root = new RootCommand { command };
var writer = new StringWriter();
var originalOut = Console.Out;
try
{
Console.SetOut(writer);
await root.Parse(
$"attach --image registry.example.com/app@sha256:aabbccdd --attestation \"{dsseFile}\" --rekor")
.InvokeAsync();
}
finally
{
Console.SetOut(originalOut);
}
Assert.Single(attacher.AttachCalls);
var (_, _, options) = attacher.AttachCalls[0];
Assert.True(options!.RecordInRekor);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Attach_WithTagReference_ResolvesDigest()
{
// Arrange
var registryClient = new FakeOciRegistryClient();
var attacher = new FakeOciAttestationAttacher();
var services = new ServiceCollection();
services.AddLogging(b => b.AddDebug());
services.AddSingleton(TimeProvider.System);
services.AddSingleton<IOciAttestationAttacher>(attacher);
services.AddSingleton<StellaOps.Attestor.Oci.Services.IOciRegistryClient>(registryClient);
using var sp = services.BuildServiceProvider();
var dsseFile = CreateDsseFile(_testDir);
var command = AttestCommandGroup.BuildAttachCommand(sp, _verboseOption, CancellationToken.None);
var root = new RootCommand { command };
var writer = new StringWriter();
var originalOut = Console.Out;
try
{
Console.SetOut(writer);
await root.Parse(
$"attach --image registry.example.com/app:v1.0 --attestation \"{dsseFile}\" --verbose")
.InvokeAsync();
}
finally
{
Console.SetOut(originalOut);
}
// FakeOciRegistryClient resolves tag to sha256:resolved-digest-...
Assert.Single(attacher.AttachCalls);
var (imageRef, _, _) = attacher.AttachCalls[0];
Assert.StartsWith("sha256:resolved-digest-", imageRef.Digest);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Attach_WithDuplicateAttestation_ReturnsErrorWithHint()
{
// Arrange
var attacher = new FakeOciAttestationAttacher { ThrowDuplicate = true };
using var sp = BuildServices(attacher);
var dsseFile = CreateDsseFile(_testDir);
var command = AttestCommandGroup.BuildAttachCommand(sp, _verboseOption, CancellationToken.None);
var root = new RootCommand { command };
var errWriter = new StringWriter();
var originalErr = Console.Error;
int exitCode;
try
{
Console.SetError(errWriter);
exitCode = await root.Parse(
$"attach --image registry.example.com/app@sha256:abc123 --attestation \"{dsseFile}\"")
.InvokeAsync();
}
finally
{
Console.SetError(originalErr);
}
Assert.Equal(1, exitCode);
var errOutput = errWriter.ToString();
Assert.Contains("already exists", errOutput);
Assert.Contains("--replace", errOutput);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Attach_ParsesDsseWithMultipleSignatures()
{
// Arrange
var attacher = new FakeOciAttestationAttacher();
using var sp = BuildServices(attacher);
// Create DSSE with multiple signatures
var payload = Convert.ToBase64String(Encoding.UTF8.GetBytes("""{"predicateType":"custom/type","predicate":{}}"""));
var sig1 = Convert.ToBase64String(Encoding.UTF8.GetBytes("sig-bytes-one"));
var sig2 = Convert.ToBase64String(Encoding.UTF8.GetBytes("sig-bytes-two"));
var envelope = new
{
payloadType = "application/vnd.in-toto+json",
payload,
signatures = new[]
{
new { keyid = "key-1", sig = sig1 },
new { keyid = "key-2", sig = sig2 }
}
};
var dsseFile = Path.Combine(_testDir, "multi-sig.dsse.json");
File.WriteAllText(dsseFile, JsonSerializer.Serialize(envelope));
var command = AttestCommandGroup.BuildAttachCommand(sp, _verboseOption, CancellationToken.None);
var root = new RootCommand { command };
var writer = new StringWriter();
var originalOut = Console.Out;
try
{
Console.SetOut(writer);
await root.Parse(
$"attach --image registry.example.com/app@sha256:abc123 --attestation \"{dsseFile}\"")
.InvokeAsync();
}
finally
{
Console.SetOut(originalOut);
}
Assert.Single(attacher.AttachCalls);
var (_, env, _) = attacher.AttachCalls[0];
Assert.Equal(2, env.Signatures.Count);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Attach_WithMissingPayload_ReturnsError()
{
// Arrange
using var sp = BuildServices();
var invalidFile = Path.Combine(_testDir, "no-payload.json");
File.WriteAllText(invalidFile, """{"payloadType":"test","signatures":[{"sig":"dGVzdA=="}]}""");
var command = AttestCommandGroup.BuildAttachCommand(sp, _verboseOption, CancellationToken.None);
var root = new RootCommand { command };
var errWriter = new StringWriter();
var originalErr = Console.Error;
int exitCode;
try
{
Console.SetError(errWriter);
exitCode = await root.Parse(
$"attach --image registry.example.com/app@sha256:abc --attestation \"{invalidFile}\"")
.InvokeAsync();
}
finally
{
Console.SetError(originalErr);
}
Assert.Equal(2, exitCode);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Attach_WithNoSignatures_ReturnsError()
{
// Arrange
using var sp = BuildServices();
var invalidFile = Path.Combine(_testDir, "no-sigs.json");
var payload = Convert.ToBase64String(Encoding.UTF8.GetBytes("{}"));
File.WriteAllText(invalidFile, $$"""{"payloadType":"test","payload":"{{payload}}","signatures":[]}""");
var command = AttestCommandGroup.BuildAttachCommand(sp, _verboseOption, CancellationToken.None);
var root = new RootCommand { command };
var errWriter = new StringWriter();
var originalErr = Console.Error;
int exitCode;
try
{
Console.SetError(errWriter);
exitCode = await root.Parse(
$"attach --image registry.example.com/app@sha256:abc --attestation \"{invalidFile}\"")
.InvokeAsync();
}
finally
{
Console.SetError(originalErr);
}
Assert.Equal(2, exitCode);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Attach_DockerHubShortReference_ParsesCorrectly()
{
// Arrange
var attacher = new FakeOciAttestationAttacher();
using var sp = BuildServices(attacher);
var dsseFile = CreateDsseFile(_testDir);
var command = AttestCommandGroup.BuildAttachCommand(sp, _verboseOption, CancellationToken.None);
var root = new RootCommand { command };
var writer = new StringWriter();
var originalOut = Console.Out;
try
{
Console.SetOut(writer);
await root.Parse(
$"attach --image myapp@sha256:aabbccdd --attestation \"{dsseFile}\"")
.InvokeAsync();
}
finally
{
Console.SetOut(originalOut);
}
Assert.Single(attacher.AttachCalls);
var (imageRef, _, _) = attacher.AttachCalls[0];
Assert.Equal("docker.io", imageRef.Registry);
Assert.Equal("library/myapp", imageRef.Repository);
Assert.Equal("sha256:aabbccdd", imageRef.Digest);
}
#region Test doubles
private sealed class FakeOciAttestationAttacher : IOciAttestationAttacher
{
public List<(OciReference ImageRef, DsseEnvelope Envelope, AttachmentOptions? Options)> AttachCalls { get; } = new();
public bool ThrowDuplicate { get; set; }
public Task<AttachmentResult> AttachAsync(
OciReference imageRef,
DsseEnvelope attestation,
AttachmentOptions? options = null,
CancellationToken ct = default)
{
if (ThrowDuplicate)
{
throw new InvalidOperationException(
"Attestation with predicate type 'test' already exists. Use ReplaceExisting=true to overwrite.");
}
AttachCalls.Add((imageRef, attestation, options));
return Task.FromResult(new AttachmentResult
{
AttestationDigest = "sha256:fake-attestation-digest-" + AttachCalls.Count,
AttestationRef = $"{imageRef.Registry}/{imageRef.Repository}@sha256:fake-manifest-digest",
AttachedAt = DateTimeOffset.UtcNow
});
}
public Task<IReadOnlyList<AttachedAttestation>> ListAsync(
OciReference imageRef, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<AttachedAttestation>>(new List<AttachedAttestation>());
public Task<DsseEnvelope?> FetchAsync(
OciReference imageRef, string predicateType, CancellationToken ct = default)
=> Task.FromResult<DsseEnvelope?>(null);
public Task<bool> RemoveAsync(
OciReference imageRef, string attestationDigest, CancellationToken ct = default)
=> Task.FromResult(true);
}
private sealed class FakeOciRegistryClient : StellaOps.Attestor.Oci.Services.IOciRegistryClient
{
public Task PushBlobAsync(string registry, string repository, ReadOnlyMemory<byte> content, string digest, CancellationToken ct = default)
=> Task.CompletedTask;
public Task<ReadOnlyMemory<byte>> FetchBlobAsync(string registry, string repository, string digest, CancellationToken ct = default)
=> Task.FromResult<ReadOnlyMemory<byte>>(Array.Empty<byte>());
public Task<string> PushManifestAsync(string registry, string repository, OciManifest manifest, CancellationToken ct = default)
=> Task.FromResult("sha256:pushed-manifest-digest");
public Task<OciManifest> FetchManifestAsync(string registry, string repository, string reference, CancellationToken ct = default)
=> Task.FromResult(new OciManifest
{
Config = new OciDescriptor { MediaType = "application/vnd.oci.empty.v1+json", Digest = "sha256:empty", Size = 2 },
Layers = new List<OciDescriptor>()
});
public Task<IReadOnlyList<OciDescriptor>> ListReferrersAsync(string registry, string repository, string digest, string? artifactType = null, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<OciDescriptor>>(new List<OciDescriptor>());
public Task<bool> DeleteManifestAsync(string registry, string repository, string digest, CancellationToken ct = default)
=> Task.FromResult(true);
public Task<string> ResolveTagAsync(string registry, string repository, string tag, CancellationToken ct = default)
=> Task.FromResult($"sha256:resolved-digest-for-{tag}");
}
#endregion
}

View File

@@ -6,6 +6,7 @@
using System.CommandLine;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Cli.Commands;
using StellaOps.TestKit;
using Xunit;
@@ -21,7 +22,8 @@ public sealed class AttestBuildCommandTests
public async Task AttestBuild_Spdx3_OutputContainsVersion()
{
// Arrange
var command = AttestCommandGroup.BuildAttestCommand(_verboseOption, CancellationToken.None);
var services = new ServiceCollection().BuildServiceProvider();
var command = AttestCommandGroup.BuildAttestCommand(services, _verboseOption, CancellationToken.None);
var root = new RootCommand { command };
var writer = new StringWriter();

View File

@@ -0,0 +1,618 @@
// -----------------------------------------------------------------------------
// AttestVerifyCommandTests.cs
// Sprint: SPRINT_20260122_040_Platform_oci_delta_attestation_pipeline (040-02)
// Description: Unit tests for attest oci-verify command wired to IOciAttestationAttacher
// -----------------------------------------------------------------------------
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using StellaOps.Attestor.Oci.Services;
using StellaOps.Cli.Commands;
using StellaOps.Cli.Services;
using StellaOps.Cli.Services.Models;
using StellaOps.TestKit;
using DsseEnvelope = StellaOps.Attestor.Envelope.DsseEnvelope;
using DsseSignature = StellaOps.Attestor.Envelope.DsseSignature;
using OciManifest = StellaOps.Attestor.Oci.Services.OciManifest;
using OciDescriptor = StellaOps.Attestor.Oci.Services.OciDescriptor;
using Xunit;
namespace StellaOps.Cli.Tests.Commands;
public sealed class AttestVerifyCommandTests : IDisposable
{
private readonly string _testDir;
public AttestVerifyCommandTests()
{
_testDir = Path.Combine(Path.GetTempPath(), $"attest-verify-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(_testDir);
}
public void Dispose()
{
try { Directory.Delete(_testDir, recursive: true); } catch { /* cleanup best-effort */ }
}
private static DsseEnvelope CreateTestEnvelope(
string payloadType = "application/vnd.in-toto+json",
string payloadContent = """{"predicateType":"https://slsa.dev/provenance/v1","predicate":{}}""",
int signatureCount = 1)
{
var payload = Encoding.UTF8.GetBytes(payloadContent);
var signatures = Enumerable.Range(0, signatureCount)
.Select(i => new DsseSignature(
Convert.ToBase64String(Encoding.UTF8.GetBytes($"fake-sig-{i}")),
$"key-{i}"))
.ToList();
return new DsseEnvelope(payloadType, payload, signatures);
}
private ServiceProvider BuildServices(
FakeVerifyAttacher? attacher = null,
FakeDsseSignatureVerifier? verifier = null,
FakeTrustPolicyLoader? loader = null)
{
var services = new ServiceCollection();
services.AddLogging(b => b.AddDebug());
services.AddSingleton(TimeProvider.System);
attacher ??= new FakeVerifyAttacher();
services.AddSingleton<IOciAttestationAttacher>(attacher);
services.AddSingleton<StellaOps.Attestor.Oci.Services.IOciRegistryClient>(
new FakeVerifyRegistryClient());
if (verifier is not null)
services.AddSingleton<IDsseSignatureVerifier>(verifier);
if (loader is not null)
services.AddSingleton<ITrustPolicyLoader>(loader);
return services.BuildServiceProvider();
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Verify_WithValidAttestation_ReturnsZero()
{
// Arrange
var envelope = CreateTestEnvelope();
var attacher = new FakeVerifyAttacher();
attacher.Attestations.Add(new AttachedAttestation
{
Digest = "sha256:aabb",
PredicateType = "https://slsa.dev/provenance/v1",
CreatedAt = DateTimeOffset.UtcNow
});
attacher.FetchEnvelope = envelope;
var verifier = new FakeDsseSignatureVerifier { Result = new DsseSignatureVerificationResult { IsValid = true, KeyId = "key-0" } };
using var sp = BuildServices(attacher, verifier);
var keyFile = Path.Combine(_testDir, "pub.pem");
await File.WriteAllTextAsync(keyFile, "fake-key-material");
// Act
var (exitCode, _) = await InvokeVerify(sp, "registry.example.com/app@sha256:aabb", key: keyFile);
// Assert
Assert.Equal(0, exitCode);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Verify_NoAttestationsFound_ReturnsZero()
{
// Arrange: empty attacher (no attestations)
var attacher = new FakeVerifyAttacher();
using var sp = BuildServices(attacher);
// Act - no predicate filter, so returns all (empty list)
var (exitCode, output) = await InvokeVerify(sp, "registry.example.com/app@sha256:aabb");
// Assert: 0 attestations verified = overallValid is vacuously true
Assert.Equal(0, exitCode);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Verify_PredicateFilterNoMatch_ReturnsOne()
{
// Arrange
var attacher = new FakeVerifyAttacher();
attacher.Attestations.Add(new AttachedAttestation
{
Digest = "sha256:aabb",
PredicateType = "https://slsa.dev/provenance/v1",
CreatedAt = DateTimeOffset.UtcNow
});
using var sp = BuildServices(attacher);
// Act: filter for a different type
var (exitCode, _) = await InvokeVerify(sp, "registry.example.com/app@sha256:aabb",
predicateType: "https://example.com/no-match");
// Assert
Assert.Equal(1, exitCode);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Verify_SignatureInvalid_ReturnsOne()
{
// Arrange
var envelope = CreateTestEnvelope();
var attacher = new FakeVerifyAttacher();
attacher.Attestations.Add(new AttachedAttestation
{
Digest = "sha256:aabb",
PredicateType = "https://slsa.dev/provenance/v1",
CreatedAt = DateTimeOffset.UtcNow
});
attacher.FetchEnvelope = envelope;
var verifier = new FakeDsseSignatureVerifier
{
Result = new DsseSignatureVerificationResult { IsValid = false, Error = "bad signature" }
};
var keyFile = Path.Combine(_testDir, "pub.pem");
await File.WriteAllTextAsync(keyFile, "fake-key");
using var sp = BuildServices(attacher, verifier);
// Act
var (exitCode, _) = await InvokeVerify(sp, "registry.example.com/app@sha256:aabb", key: keyFile);
// Assert
Assert.Equal(1, exitCode);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Verify_StrictMode_FailsOnErrors()
{
// Arrange: signature valid but Rekor required and missing
var envelope = CreateTestEnvelope();
var attacher = new FakeVerifyAttacher();
attacher.Attestations.Add(new AttachedAttestation
{
Digest = "sha256:aabb",
PredicateType = "https://slsa.dev/provenance/v1",
CreatedAt = DateTimeOffset.UtcNow,
Annotations = new Dictionary<string, string>() // no Rekor entry
});
attacher.FetchEnvelope = envelope;
var verifier = new FakeDsseSignatureVerifier
{
Result = new DsseSignatureVerificationResult { IsValid = true, KeyId = "key-0" }
};
var keyFile = Path.Combine(_testDir, "pub.pem");
await File.WriteAllTextAsync(keyFile, "fake-key");
using var sp = BuildServices(attacher, verifier);
// Act: strict + rekor
var (exitCode, _) = await InvokeVerify(sp, "registry.example.com/app@sha256:aabb",
key: keyFile, verifyRekor: true, strict: true);
// Assert: strict mode fails because Rekor inclusion not found
Assert.Equal(1, exitCode);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Verify_JsonFormat_OutputsValidJson()
{
// Arrange
var envelope = CreateTestEnvelope();
var attacher = new FakeVerifyAttacher();
attacher.Attestations.Add(new AttachedAttestation
{
Digest = "sha256:ccdd",
PredicateType = "https://slsa.dev/provenance/v1",
CreatedAt = DateTimeOffset.UtcNow
});
attacher.FetchEnvelope = envelope;
using var sp = BuildServices(attacher);
// Act
var (exitCode, output) = await InvokeVerify(sp, "registry.example.com/app@sha256:ccdd",
format: "json");
// Assert
Assert.Equal(0, exitCode);
using var doc = JsonDocument.Parse(output);
Assert.Equal("registry.example.com/app@sha256:ccdd", doc.RootElement.GetProperty("image").GetString());
Assert.True(doc.RootElement.GetProperty("overallValid").GetBoolean());
Assert.Equal(1, doc.RootElement.GetProperty("totalAttestations").GetInt32());
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Verify_TagReference_ResolvesDigest()
{
// Arrange
var envelope = CreateTestEnvelope();
var attacher = new FakeVerifyAttacher();
attacher.Attestations.Add(new AttachedAttestation
{
Digest = "sha256:aabb",
PredicateType = "https://slsa.dev/provenance/v1",
CreatedAt = DateTimeOffset.UtcNow
});
attacher.FetchEnvelope = envelope;
using var sp = BuildServices(attacher);
// Act: tag-based reference (will trigger ResolveTagAsync)
var (exitCode, output) = await InvokeVerify(sp, "registry.example.com/app:v2.0",
format: "json", verbose: true);
// Assert
Assert.Equal(0, exitCode);
using var doc = JsonDocument.Parse(output);
var imageDigest = doc.RootElement.GetProperty("imageDigest").GetString();
Assert.StartsWith("sha256:resolved-digest-", imageDigest);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Verify_RekorAnnotationPresent_SetsRekorIncluded()
{
// Arrange
var envelope = CreateTestEnvelope();
var attacher = new FakeVerifyAttacher();
attacher.Attestations.Add(new AttachedAttestation
{
Digest = "sha256:aabb",
PredicateType = "https://slsa.dev/provenance/v1",
CreatedAt = DateTimeOffset.UtcNow,
Annotations = new Dictionary<string, string>
{
["dev.sigstore.rekor/logIndex"] = "12345"
}
});
attacher.FetchEnvelope = envelope;
var verifier = new FakeDsseSignatureVerifier
{
Result = new DsseSignatureVerificationResult { IsValid = true, KeyId = "key-0" }
};
var keyFile = Path.Combine(_testDir, "pub.pem");
await File.WriteAllTextAsync(keyFile, "fake-key");
using var sp = BuildServices(attacher, verifier);
// Act
var (exitCode, output) = await InvokeVerify(sp, "registry.example.com/app@sha256:aabb",
key: keyFile, verifyRekor: true, format: "json");
// Assert
Assert.Equal(0, exitCode);
using var doc = JsonDocument.Parse(output);
var attestation = doc.RootElement.GetProperty("attestations")[0];
Assert.True(attestation.GetProperty("rekorIncluded").GetBoolean());
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Verify_RekorRequiredButMissing_ReturnsOne()
{
// Arrange
var envelope = CreateTestEnvelope();
var attacher = new FakeVerifyAttacher();
attacher.Attestations.Add(new AttachedAttestation
{
Digest = "sha256:aabb",
PredicateType = "https://slsa.dev/provenance/v1",
CreatedAt = DateTimeOffset.UtcNow,
Annotations = new Dictionary<string, string>() // no rekor
});
attacher.FetchEnvelope = envelope;
var verifier = new FakeDsseSignatureVerifier
{
Result = new DsseSignatureVerificationResult { IsValid = true, KeyId = "key-0" }
};
var keyFile = Path.Combine(_testDir, "pub.pem");
await File.WriteAllTextAsync(keyFile, "fake-key");
using var sp = BuildServices(attacher, verifier);
// Act: strict mode makes missing rekor a failure
var (exitCode, _) = await InvokeVerify(sp, "registry.example.com/app@sha256:aabb",
key: keyFile, verifyRekor: true, strict: true);
// Assert
Assert.Equal(1, exitCode);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Verify_NoTrustContext_PassesIfSigned()
{
// Arrange: no key, no policy → no verification, but signature presence = pass
var envelope = CreateTestEnvelope(signatureCount: 1);
var attacher = new FakeVerifyAttacher();
attacher.Attestations.Add(new AttachedAttestation
{
Digest = "sha256:aabb",
PredicateType = "https://slsa.dev/provenance/v1",
CreatedAt = DateTimeOffset.UtcNow
});
attacher.FetchEnvelope = envelope;
using var sp = BuildServices(attacher);
// Act: no key, no policy
var (exitCode, output) = await InvokeVerify(sp, "registry.example.com/app@sha256:aabb",
format: "json");
// Assert
Assert.Equal(0, exitCode);
using var doc = JsonDocument.Parse(output);
var attestation = doc.RootElement.GetProperty("attestations")[0];
Assert.True(attestation.GetProperty("signatureValid").GetBoolean());
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Verify_NullEnvelope_RecordsError()
{
// Arrange: FetchAsync returns null (envelope not found in registry)
var attacher = new FakeVerifyAttacher();
attacher.Attestations.Add(new AttachedAttestation
{
Digest = "sha256:aabb",
PredicateType = "https://slsa.dev/provenance/v1",
CreatedAt = DateTimeOffset.UtcNow
});
attacher.FetchEnvelope = null; // simulate missing envelope
using var sp = BuildServices(attacher);
// Act
var (exitCode, output) = await InvokeVerify(sp, "registry.example.com/app@sha256:aabb",
format: "json");
// Assert: signature invalid since envelope could not be fetched
Assert.Equal(1, exitCode);
using var doc = JsonDocument.Parse(output);
var errors = doc.RootElement.GetProperty("attestations")[0].GetProperty("errors");
Assert.True(errors.GetArrayLength() > 0);
Assert.Contains("Could not fetch", errors[0].GetString());
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Verify_FetchError_RecordsErrorGracefully()
{
// Arrange: attacher throws on fetch
var attacher = new FakeVerifyAttacher { ThrowOnFetch = true };
attacher.Attestations.Add(new AttachedAttestation
{
Digest = "sha256:aabb",
PredicateType = "https://slsa.dev/provenance/v1",
CreatedAt = DateTimeOffset.UtcNow
});
using var sp = BuildServices(attacher);
// Act
var (exitCode, output) = await InvokeVerify(sp, "registry.example.com/app@sha256:aabb",
format: "json");
// Assert: error recorded, signature invalid
Assert.Equal(1, exitCode);
using var doc = JsonDocument.Parse(output);
var errors = doc.RootElement.GetProperty("attestations")[0].GetProperty("errors");
Assert.True(errors.GetArrayLength() > 0);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Verify_VerboseOutput_ContainsDiagnostics()
{
// Arrange
var envelope = CreateTestEnvelope();
var attacher = new FakeVerifyAttacher();
attacher.Attestations.Add(new AttachedAttestation
{
Digest = "sha256:aabb",
PredicateType = "https://slsa.dev/provenance/v1",
CreatedAt = DateTimeOffset.UtcNow
});
attacher.FetchEnvelope = envelope;
using var sp = BuildServices(attacher);
// Act
var (exitCode, _) = await InvokeVerify(sp, "registry.example.com/app@sha256:aabb",
verbose: true);
// Assert: just passes without error - verbose output goes to AnsiConsole
Assert.Equal(0, exitCode);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Verify_OutputToFile_WritesReport()
{
// Arrange
var envelope = CreateTestEnvelope();
var attacher = new FakeVerifyAttacher();
attacher.Attestations.Add(new AttachedAttestation
{
Digest = "sha256:aabb",
PredicateType = "https://slsa.dev/provenance/v1",
CreatedAt = DateTimeOffset.UtcNow
});
attacher.FetchEnvelope = envelope;
using var sp = BuildServices(attacher);
var reportPath = Path.Combine(_testDir, "report.json");
// Act
var (exitCode, _) = await InvokeVerify(sp, "registry.example.com/app@sha256:aabb",
format: "json", outputPath: reportPath);
// Assert
Assert.Equal(0, exitCode);
Assert.True(File.Exists(reportPath));
var json = await File.ReadAllTextAsync(reportPath);
using var doc = JsonDocument.Parse(json);
Assert.True(doc.RootElement.GetProperty("overallValid").GetBoolean());
}
#region Helpers
private static async Task<(int ExitCode, string Output)> InvokeVerify(
IServiceProvider services,
string image,
string? predicateType = null,
string? policyPath = null,
string? rootPath = null,
string? key = null,
bool verifyRekor = false,
bool strict = false,
string format = "table",
string? outputPath = null,
bool verbose = false)
{
var writer = new StringWriter();
var originalOut = Console.Out;
int exitCode;
try
{
Console.SetOut(writer);
exitCode = await CommandHandlers.HandleOciAttestVerifyAsync(
services,
image,
predicateType,
policyPath,
rootPath,
key,
verifyRekor,
strict,
format,
outputPath,
verbose,
CancellationToken.None);
}
finally
{
Console.SetOut(originalOut);
}
return (exitCode, writer.ToString());
}
#endregion
#region Test doubles
private sealed class FakeVerifyAttacher : IOciAttestationAttacher
{
public List<AttachedAttestation> Attestations { get; } = new();
public DsseEnvelope? FetchEnvelope { get; set; }
public bool ThrowOnFetch { get; set; }
public Task<AttachmentResult> AttachAsync(
OciReference imageRef,
DsseEnvelope attestation,
AttachmentOptions? options = null,
CancellationToken ct = default)
{
return Task.FromResult(new AttachmentResult
{
AttestationDigest = "sha256:fake",
AttestationRef = "fake-ref",
AttachedAt = DateTimeOffset.UtcNow
});
}
public Task<IReadOnlyList<AttachedAttestation>> ListAsync(
OciReference imageRef, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<AttachedAttestation>>(Attestations);
public Task<DsseEnvelope?> FetchAsync(
OciReference imageRef, string predicateType, CancellationToken ct = default)
{
if (ThrowOnFetch)
throw new HttpRequestException("Connection refused");
return Task.FromResult(FetchEnvelope);
}
public Task<bool> RemoveAsync(
OciReference imageRef, string attestationDigest, CancellationToken ct = default)
=> Task.FromResult(true);
}
private sealed class FakeVerifyRegistryClient : StellaOps.Attestor.Oci.Services.IOciRegistryClient
{
public Task PushBlobAsync(string registry, string repository, ReadOnlyMemory<byte> content, string digest, CancellationToken ct = default)
=> Task.CompletedTask;
public Task<ReadOnlyMemory<byte>> FetchBlobAsync(string registry, string repository, string digest, CancellationToken ct = default)
=> Task.FromResult<ReadOnlyMemory<byte>>(Array.Empty<byte>());
public Task<string> PushManifestAsync(string registry, string repository, OciManifest manifest, CancellationToken ct = default)
=> Task.FromResult("sha256:pushed-manifest-digest");
public Task<OciManifest> FetchManifestAsync(string registry, string repository, string reference, CancellationToken ct = default)
=> Task.FromResult(new OciManifest
{
Config = new OciDescriptor { MediaType = "application/vnd.oci.empty.v1+json", Digest = "sha256:empty", Size = 2 },
Layers = new List<OciDescriptor>()
});
public Task<IReadOnlyList<OciDescriptor>> ListReferrersAsync(string registry, string repository, string digest, string? artifactType = null, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<OciDescriptor>>(new List<OciDescriptor>());
public Task<bool> DeleteManifestAsync(string registry, string repository, string digest, CancellationToken ct = default)
=> Task.FromResult(true);
public Task<string> ResolveTagAsync(string registry, string repository, string tag, CancellationToken ct = default)
=> Task.FromResult($"sha256:resolved-digest-for-{tag}");
}
private sealed class FakeDsseSignatureVerifier : IDsseSignatureVerifier
{
public DsseSignatureVerificationResult Result { get; set; } =
new() { IsValid = true, KeyId = "test" };
public DsseSignatureVerificationResult Verify(
string payloadType,
string payloadBase64,
IReadOnlyList<DsseSignatureInput> signatures,
TrustPolicyContext policy)
{
return Result;
}
}
private sealed class FakeTrustPolicyLoader : ITrustPolicyLoader
{
public TrustPolicyContext Context { get; set; } = new()
{
Keys = new List<TrustPolicyKeyMaterial>
{
new()
{
KeyId = "test-key",
Fingerprint = "test-fp",
Algorithm = "ed25519",
PublicKey = new byte[] { 1, 2, 3 }
}
}
};
public Task<TrustPolicyContext> LoadAsync(string path, CancellationToken cancellationToken = default)
=> Task.FromResult(Context);
}
#endregion
}

View File

@@ -0,0 +1,360 @@
// -----------------------------------------------------------------------------
// BundleVerifyReplayTests.cs
// Sprint: SPRINT_20260122_040_Platform_oci_delta_attestation_pipeline (040-06)
// Description: Unit tests for bundle verify --replay with lazy blob fetch
// -----------------------------------------------------------------------------
using System.CommandLine;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Cli.Commands;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.Cli.Tests.Commands;
public sealed class BundleVerifyReplayTests : IDisposable
{
private readonly string _testDir;
public BundleVerifyReplayTests()
{
_testDir = Path.Combine(Path.GetTempPath(), $"bundle-verify-replay-{Guid.NewGuid():N}");
Directory.CreateDirectory(_testDir);
}
public void Dispose()
{
try { Directory.Delete(_testDir, recursive: true); } catch { /* best-effort */ }
}
#region Test Helpers
private string CreateBundleDir(string exportMode = "light", List<LargeBlobTestRef>? blobs = null)
{
var bundleDir = Path.Combine(_testDir, $"bundle-{Guid.NewGuid():N}");
Directory.CreateDirectory(bundleDir);
// Create manifest.json with export mode
var manifest = new
{
schemaVersion = "2.0",
exportMode,
bundle = new { image = "test:latest", digest = "sha256:abc" },
verify = new { expectations = new { payloadTypes = new List<string>() } }
};
File.WriteAllText(
Path.Combine(bundleDir, "manifest.json"),
JsonSerializer.Serialize(manifest, new JsonSerializerOptions { WriteIndented = true }));
// Create attestations directory with DSSE envelope referencing blobs
if (blobs is not null && blobs.Count > 0)
{
var attestDir = Path.Combine(bundleDir, "attestations");
Directory.CreateDirectory(attestDir);
var largeBlobsArray = blobs.Select(b => new
{
kind = b.Kind,
digest = b.Digest,
mediaType = "application/octet-stream",
sizeBytes = b.Content.Length
}).ToList();
var predicatePayload = JsonSerializer.Serialize(new
{
_type = "https://in-toto.io/Statement/v1",
predicateType = "https://stellaops.dev/delta-sig/v1",
predicate = new
{
schemaVersion = "1.0.0",
largeBlobs = largeBlobsArray
}
});
var payloadB64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(predicatePayload));
var envelope = new
{
payloadType = "application/vnd.in-toto+json",
payload = payloadB64,
signatures = new[] { new { keyid = "test-key", sig = "fakesig" } }
};
File.WriteAllText(
Path.Combine(attestDir, "delta-sig.dsse.json"),
JsonSerializer.Serialize(envelope, new JsonSerializerOptions { WriteIndented = true }));
// For full bundles, embed the blobs
if (exportMode == "full")
{
var blobsDir = Path.Combine(bundleDir, "blobs");
Directory.CreateDirectory(blobsDir);
foreach (var blob in blobs)
{
var blobPath = Path.Combine(blobsDir, blob.Digest.Replace(":", "-"));
File.WriteAllBytes(blobPath, blob.Content);
}
}
}
return bundleDir;
}
private string CreateBlobSourceDir(List<LargeBlobTestRef> blobs)
{
var sourceDir = Path.Combine(_testDir, $"blobsource-{Guid.NewGuid():N}");
Directory.CreateDirectory(sourceDir);
foreach (var blob in blobs)
{
var blobPath = Path.Combine(sourceDir, blob.Digest.Replace(":", "-"));
File.WriteAllBytes(blobPath, blob.Content);
}
return sourceDir;
}
private static LargeBlobTestRef CreateTestBlob(string kind = "binary-patch", int size = 256)
{
var content = new byte[size];
Random.Shared.NextBytes(content);
var hash = SHA256.HashData(content);
var digest = $"sha256:{Convert.ToHexStringLower(hash)}";
return new LargeBlobTestRef(digest, kind, content);
}
private (Command command, IServiceProvider services) BuildVerifyCommand()
{
var sc = new ServiceCollection();
var services = sc.BuildServiceProvider();
var verboseOption = new Option<bool>("--verbose", ["-v"]) { Description = "Verbose" };
var command = BundleVerifyCommand.BuildVerifyBundleEnhancedCommand(
services, verboseOption, CancellationToken.None);
return (command, services);
}
private async Task<(string stdout, string stderr, int exitCode)> InvokeVerifyAsync(string args)
{
var (command, _) = BuildVerifyCommand();
var root = new RootCommand("test") { command };
var stdoutWriter = new StringWriter();
var stderrWriter = new StringWriter();
var origOut = Console.Out;
var origErr = Console.Error;
var origExitCode = Environment.ExitCode;
Environment.ExitCode = 0;
try
{
Console.SetOut(stdoutWriter);
Console.SetError(stderrWriter);
var parseResult = root.Parse($"verify {args}");
if (parseResult.Errors.Count > 0)
{
var errorMessages = string.Join("; ", parseResult.Errors.Select(e => e.Message));
return ("", $"Parse errors: {errorMessages}", 1);
}
var returnCode = await parseResult.InvokeAsync();
var exitCode = returnCode != 0 ? returnCode : Environment.ExitCode;
return (stdoutWriter.ToString(), stderrWriter.ToString(), exitCode);
}
finally
{
Console.SetOut(origOut);
Console.SetError(origErr);
Environment.ExitCode = origExitCode;
}
}
private sealed record LargeBlobTestRef(string Digest, string Kind, byte[] Content);
#endregion
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Verify_WithoutReplay_SkipsBlobVerification()
{
var blob = CreateTestBlob();
var bundleDir = CreateBundleDir("light", [blob]);
var (stdout, _, _) = await InvokeVerifyAsync(
$"--bundle \"{bundleDir}\"");
// Blob Replay step should not appear when --replay is not specified
stdout.Should().NotContain("Blob Replay");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Verify_WithReplay_NoBlobRefs_PassesSuccessfully()
{
var bundleDir = CreateBundleDir("light");
var (stdout, _, _) = await InvokeVerifyAsync(
$"--bundle \"{bundleDir}\" --replay");
// Blob replay step should appear and pass (no refs to verify)
stdout.Should().Contain("Blob Replay");
stdout.Should().Contain("Step 6: Blob Replay ✓");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Verify_FullBundle_WithReplay_VerifiesEmbeddedBlobs()
{
var blob = CreateTestBlob();
var bundleDir = CreateBundleDir("full", [blob]);
var (stdout, _, _) = await InvokeVerifyAsync(
$"--bundle \"{bundleDir}\" --replay");
// Blob replay step should appear and pass (embedded blobs match digests)
stdout.Should().Contain("Step 6: Blob Replay ✓");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Verify_FullBundle_MissingBlob_FailsVerification()
{
var blob = CreateTestBlob();
var bundleDir = CreateBundleDir("full", [blob]);
// Delete the embedded blob file
var blobPath = Path.Combine(bundleDir, "blobs", blob.Digest.Replace(":", "-"));
File.Delete(blobPath);
var (stdout, stderr, exitCode) = await InvokeVerifyAsync(
$"--bundle \"{bundleDir}\" --replay");
// Exit code will be non-zero due to blob failure
stdout.Should().Contain("Blob Replay");
stdout.Should().Contain("✗");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Verify_FullBundle_DigestMismatch_FailsVerification()
{
var blob = CreateTestBlob();
var bundleDir = CreateBundleDir("full", [blob]);
// Corrupt the embedded blob content
var blobPath = Path.Combine(bundleDir, "blobs", blob.Digest.Replace(":", "-"));
File.WriteAllBytes(blobPath, new byte[] { 0xFF, 0xFE, 0xFD });
var (stdout, stderr, exitCode) = await InvokeVerifyAsync(
$"--bundle \"{bundleDir}\" --replay");
stdout.Should().Contain("Blob Replay");
stdout.Should().Contain("✗");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Verify_LightBundle_Offline_FailsWhenBlobsFetchRequired()
{
var blob = CreateTestBlob();
var bundleDir = CreateBundleDir("light", [blob]);
var (stdout, stderr, exitCode) = await InvokeVerifyAsync(
$"--bundle \"{bundleDir}\" --replay --offline");
stdout.Should().Contain("Blob Replay");
stdout.Should().Contain("✗");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Verify_LightBundle_WithBlobSource_FetchesFromLocal()
{
var blob = CreateTestBlob();
var bundleDir = CreateBundleDir("light", [blob]);
var blobSourceDir = CreateBlobSourceDir([blob]);
var (stdout, _, _) = await InvokeVerifyAsync(
$"--bundle \"{bundleDir}\" --replay --blob-source \"{blobSourceDir}\"");
// Blob replay should pass when fetching from local source
stdout.Should().Contain("Step 6: Blob Replay ✓");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Verify_LightBundle_BlobSourceMissing_FailsGracefully()
{
var blob = CreateTestBlob();
var bundleDir = CreateBundleDir("light", [blob]);
var emptySourceDir = Path.Combine(_testDir, "empty-source");
Directory.CreateDirectory(emptySourceDir);
var (stdout, stderr, exitCode) = await InvokeVerifyAsync(
$"--bundle \"{bundleDir}\" --replay --blob-source \"{emptySourceDir}\"");
stdout.Should().Contain("Blob Replay");
stdout.Should().Contain("✗");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Verify_FullBundle_MultipleBlobs_AllVerified()
{
var blob1 = CreateTestBlob("binary-patch", 128);
var blob2 = CreateTestBlob("sbom-fragment", 512);
var bundleDir = CreateBundleDir("full", [blob1, blob2]);
var (stdout, _, _) = await InvokeVerifyAsync(
$"--bundle \"{bundleDir}\" --replay");
stdout.Should().Contain("Step 6: Blob Replay ✓");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Verify_WithReplay_Verbose_ShowsBlobDetails()
{
var blob = CreateTestBlob();
var bundleDir = CreateBundleDir("full", [blob]);
var (stdout, _, _) = await InvokeVerifyAsync(
$"--bundle \"{bundleDir}\" --replay --verbose");
stdout.Should().Contain("Found blob ref:");
stdout.Should().Contain("Blob verified:");
stdout.Should().Contain($"{blob.Content.Length} bytes");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Verify_JsonOutput_WithReplay_IncludesBlobCheck()
{
var blob = CreateTestBlob();
var bundleDir = CreateBundleDir("full", [blob]);
var (stdout, _, _) = await InvokeVerifyAsync(
$"--bundle \"{bundleDir}\" --replay --output json");
stdout.Should().Contain("blob-replay");
stdout.Should().Contain("verified successfully");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Verify_LightBundle_NoBlobSource_NoBlobsAvailable()
{
var blob = CreateTestBlob();
var bundleDir = CreateBundleDir("light", [blob]);
// No --blob-source, not --offline: should fail because no source for blobs
var (stdout, stderr, exitCode) = await InvokeVerifyAsync(
$"--bundle \"{bundleDir}\" --replay");
stdout.Should().Contain("Blob Replay");
stdout.Should().Contain("✗");
}
}

View File

@@ -0,0 +1,533 @@
// -----------------------------------------------------------------------------
// DeltaSigAttestRekorTests.cs
// Sprint: SPRINT_20260122_040_Platform_oci_delta_attestation_pipeline (040-05)
// Description: Unit tests for delta-sig attest command with Rekor submission
// -----------------------------------------------------------------------------
using System.CommandLine;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Attestor.Core.Rekor;
using StellaOps.Attestor.Core.Submission;
using StellaOps.Cli.Commands.Binary;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.Cli.Tests.Commands;
public sealed class DeltaSigAttestRekorTests : IDisposable
{
private readonly string _testDir;
public DeltaSigAttestRekorTests()
{
_testDir = Path.Combine(Path.GetTempPath(), $"deltasig-attest-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(_testDir);
}
public void Dispose()
{
try { Directory.Delete(_testDir, recursive: true); } catch { /* best-effort */ }
}
#region Test Helpers
private static string CreateMinimalPredicateJson()
{
return JsonSerializer.Serialize(new
{
schemaVersion = "1.0.0",
subject = new[]
{
new { uri = "file:///tmp/old.bin", digest = new Dictionary<string, string> { { "sha256", "aaa111" } }, arch = "linux-amd64", role = "old" },
new { uri = "file:///tmp/new.bin", digest = new Dictionary<string, string> { { "sha256", "bbb222" } }, arch = "linux-amd64", role = "new" }
},
delta = new[]
{
new
{
functionId = "main",
address = 0x1000L,
changeType = "modified",
oldHash = "abc",
newHash = "def",
oldSize = 64L,
newSize = 72L
}
},
summary = new
{
totalFunctions = 10,
functionsAdded = 0,
functionsRemoved = 0,
functionsModified = 1
},
tooling = new
{
lifter = "b2r2",
lifterVersion = "1.0.0",
canonicalIr = "b2r2-lowuir",
diffAlgorithm = "byte"
},
computedAt = DateTimeOffset.Parse("2026-01-22T00:00:00Z")
}, new JsonSerializerOptions { WriteIndented = true });
}
private string WritePredicateFile(string? content = null)
{
var path = Path.Combine(_testDir, "predicate.json");
File.WriteAllText(path, content ?? CreateMinimalPredicateJson());
return path;
}
private string WriteEcdsaKeyFile()
{
var path = Path.Combine(_testDir, "test-signing-key.pem");
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var pem = ecdsa.ExportECPrivateKeyPem();
File.WriteAllText(path, pem);
return path;
}
private string WriteRsaKeyFile()
{
var path = Path.Combine(_testDir, "test-rsa-key.pem");
using var rsa = RSA.Create(2048);
var pem = rsa.ExportRSAPrivateKeyPem();
File.WriteAllText(path, pem);
return path;
}
private (Command command, IServiceProvider services) BuildAttestCommand(IRekorClient? rekorClient = null)
{
var sc = new ServiceCollection();
if (rekorClient is not null)
sc.AddSingleton(rekorClient);
var services = sc.BuildServiceProvider();
var verboseOption = new Option<bool>("--verbose", ["-v"]) { Description = "Verbose" };
var command = DeltaSigCommandGroup.BuildDeltaSigCommand(services, verboseOption, CancellationToken.None);
return (command, services);
}
private async Task<(string stdout, string stderr, int exitCode)> InvokeAsync(
string args,
IRekorClient? rekorClient = null)
{
var (command, _) = BuildAttestCommand(rekorClient);
var root = new RootCommand("test") { command };
var stdoutWriter = new StringWriter();
var stderrWriter = new StringWriter();
var origOut = Console.Out;
var origErr = Console.Error;
var origExitCode = Environment.ExitCode;
Environment.ExitCode = 0;
try
{
Console.SetOut(stdoutWriter);
Console.SetError(stderrWriter);
var parseResult = root.Parse($"delta-sig {args}");
// If parse has errors, return them
if (parseResult.Errors.Count > 0)
{
var errorMessages = string.Join("; ", parseResult.Errors.Select(e => e.Message));
return ("", $"Parse errors: {errorMessages}", 1);
}
var returnCode = await parseResult.InvokeAsync();
var exitCode = returnCode != 0 ? returnCode : Environment.ExitCode;
return (stdoutWriter.ToString(), stderrWriter.ToString(), exitCode);
}
finally
{
Console.SetOut(origOut);
Console.SetError(origErr);
Environment.ExitCode = origExitCode;
}
}
#endregion
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Attest_WithEcdsaKey_ProducesDsseEnvelope()
{
var predicatePath = WritePredicateFile();
var keyPath = WriteEcdsaKeyFile();
var outputPath = Path.Combine(_testDir, "envelope.json");
var (stdout, stderr, exitCode) = await InvokeAsync(
$"attest \"{predicatePath}\" --key \"{keyPath}\" --output \"{outputPath}\"");
exitCode.Should().Be(0, because: $"stderr: {stderr}");
File.Exists(outputPath).Should().BeTrue();
var envelopeJson = await File.ReadAllTextAsync(outputPath);
using var doc = JsonDocument.Parse(envelopeJson);
var root = doc.RootElement;
root.GetProperty("payloadType").GetString().Should().Be("application/vnd.in-toto+json");
root.GetProperty("payload").GetString().Should().NotBeNullOrEmpty();
root.GetProperty("signatures").GetArrayLength().Should().Be(1);
root.GetProperty("signatures")[0].GetProperty("keyid").GetString().Should().Be("test-signing-key");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Attest_WithRsaKey_ProducesDsseEnvelope()
{
var predicatePath = WritePredicateFile();
var keyPath = WriteRsaKeyFile();
var outputPath = Path.Combine(_testDir, "envelope-rsa.json");
var (stdout, stderr, exitCode) = await InvokeAsync(
$"attest \"{predicatePath}\" --key \"{keyPath}\" --output \"{outputPath}\"");
exitCode.Should().Be(0, because: $"stderr: {stderr}");
File.Exists(outputPath).Should().BeTrue();
var envelopeJson = await File.ReadAllTextAsync(outputPath);
using var doc = JsonDocument.Parse(envelopeJson);
doc.RootElement.GetProperty("signatures")[0].GetProperty("keyid").GetString()
.Should().Be("test-rsa-key");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Attest_WithKeyReference_UsesHmacAndKeyAsId()
{
var predicatePath = WritePredicateFile();
var outputPath = Path.Combine(_testDir, "envelope-ref.json");
var (stdout, stderr, exitCode) = await InvokeAsync(
$"attest \"{predicatePath}\" --key \"kms://my-vault/my-key\" --output \"{outputPath}\"");
exitCode.Should().Be(0, because: $"stderr: {stderr}");
File.Exists(outputPath).Should().BeTrue();
var envelopeJson = await File.ReadAllTextAsync(outputPath);
using var doc = JsonDocument.Parse(envelopeJson);
doc.RootElement.GetProperty("signatures")[0].GetProperty("keyid").GetString()
.Should().Be("kms://my-vault/my-key");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Attest_NoKey_FailsWithExitCode1()
{
var predicatePath = WritePredicateFile();
var (stdout, stderr, exitCode) = await InvokeAsync(
$"attest \"{predicatePath}\"");
exitCode.Should().Be(1);
stderr.Should().Contain("--key is required");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Attest_InvalidPredicateJson_FailsWithExitCode1()
{
var predicatePath = WritePredicateFile("not valid json { {{");
var (stdout, stderr, exitCode) = await InvokeAsync(
$"attest \"{predicatePath}\" --key \"somekey\"");
exitCode.Should().Be(1);
stderr.Should().Contain("Failed to parse predicate file");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Attest_DryRun_DoesNotSign()
{
var predicatePath = WritePredicateFile();
var keyPath = WriteEcdsaKeyFile();
var (stdout, stderr, exitCode) = await InvokeAsync(
$"attest \"{predicatePath}\" --key \"{keyPath}\" --dry-run");
exitCode.Should().Be(0);
stdout.Should().Contain("Dry run");
stdout.Should().Contain("Payload type:");
stdout.Should().Contain("Payload size:");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Attest_NoOutput_WritesEnvelopeToStdout()
{
var predicatePath = WritePredicateFile();
var keyPath = WriteEcdsaKeyFile();
var (stdout, stderr, exitCode) = await InvokeAsync(
$"attest \"{predicatePath}\" --key \"{keyPath}\"");
exitCode.Should().Be(0);
stdout.Should().Contain("payloadType");
stdout.Should().Contain("signatures");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Attest_WithRekorUrl_SubmitsToRekorClient()
{
var predicatePath = WritePredicateFile();
var keyPath = WriteEcdsaKeyFile();
var outputPath = Path.Combine(_testDir, "envelope-rekor.json");
var fakeRekor = new FakeRekorClient();
var (stdout, stderr, exitCode) = await InvokeAsync(
$"attest \"{predicatePath}\" --key \"{keyPath}\" --output \"{outputPath}\" --rekor-url \"https://rekor.test.local\"",
fakeRekor);
exitCode.Should().Be(0, because: $"stderr: {stderr}");
fakeRekor.SubmitCallCount.Should().Be(1);
fakeRekor.LastRequest.Should().NotBeNull();
fakeRekor.LastRequest!.Bundle.Dsse.PayloadType.Should().Be("application/vnd.in-toto+json");
fakeRekor.LastBackend!.Url.Should().Be(new Uri("https://rekor.test.local"));
stdout.Should().Contain("Rekor entry created");
stdout.Should().Contain("fake-uuid-123");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Attest_RekorSubmission_SavesReceipt()
{
var predicatePath = WritePredicateFile();
var keyPath = WriteEcdsaKeyFile();
var outputPath = Path.Combine(_testDir, "envelope-receipt.json");
var receiptPath = Path.Combine(_testDir, "receipt.json");
var fakeRekor = new FakeRekorClient();
var (stdout, stderr, exitCode) = await InvokeAsync(
$"attest \"{predicatePath}\" --key \"{keyPath}\" --output \"{outputPath}\" --rekor-url \"https://rekor.test.local\" --receipt \"{receiptPath}\"",
fakeRekor);
exitCode.Should().Be(0, because: $"stderr: {stderr}");
File.Exists(receiptPath).Should().BeTrue();
var receiptJson = await File.ReadAllTextAsync(receiptPath);
using var doc = JsonDocument.Parse(receiptJson);
doc.RootElement.GetProperty("Uuid").GetString().Should().Be("fake-uuid-123");
doc.RootElement.GetProperty("Index").GetInt64().Should().Be(42);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Attest_RekorHttpError_HandlesGracefully()
{
var predicatePath = WritePredicateFile();
var keyPath = WriteEcdsaKeyFile();
var outputPath = Path.Combine(_testDir, "envelope-err.json");
var fakeRekor = new FakeRekorClient { ThrowOnSubmit = new HttpRequestException("Connection refused") };
var (stdout, stderr, exitCode) = await InvokeAsync(
$"attest \"{predicatePath}\" --key \"{keyPath}\" --output \"{outputPath}\" --rekor-url \"https://rekor.test.local\"",
fakeRekor);
exitCode.Should().Be(1);
stderr.Should().Contain("Rekor submission failed");
stderr.Should().Contain("Connection refused");
// Envelope should still have been written before submission
File.Exists(outputPath).Should().BeTrue();
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Attest_RekorTimeout_HandlesGracefully()
{
var predicatePath = WritePredicateFile();
var keyPath = WriteEcdsaKeyFile();
var outputPath = Path.Combine(_testDir, "envelope-timeout.json");
var fakeRekor = new FakeRekorClient { ThrowOnSubmit = new TaskCanceledException("Request timed out") };
var (stdout, stderr, exitCode) = await InvokeAsync(
$"attest \"{predicatePath}\" --key \"{keyPath}\" --output \"{outputPath}\" --rekor-url \"https://rekor.test.local\"",
fakeRekor);
exitCode.Should().Be(1);
stderr.Should().Contain("Rekor submission timed out");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Attest_NoRekorClient_WarnsAndSkips()
{
var predicatePath = WritePredicateFile();
var keyPath = WriteEcdsaKeyFile();
var outputPath = Path.Combine(_testDir, "envelope-nodi.json");
// Pass null rekorClient so DI won't have it registered
var (stdout, stderr, exitCode) = await InvokeAsync(
$"attest \"{predicatePath}\" --key \"{keyPath}\" --output \"{outputPath}\" --rekor-url \"https://rekor.test.local\"");
exitCode.Should().Be(0);
stderr.Should().Contain("IRekorClient not configured");
// Envelope should still be written
File.Exists(outputPath).Should().BeTrue();
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Attest_Verbose_PrintsDiagnostics()
{
var predicatePath = WritePredicateFile();
var keyPath = WriteEcdsaKeyFile();
var outputPath = Path.Combine(_testDir, "envelope-verbose.json");
var (stdout, stderr, exitCode) = await InvokeAsync(
$"attest \"{predicatePath}\" --key \"{keyPath}\" --output \"{outputPath}\" --verbose");
exitCode.Should().Be(0, because: $"stderr: {stderr}");
stdout.Should().Contain("Loaded predicate with");
stdout.Should().Contain("Signed with key:");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Attest_VerboseWithRekor_ShowsSubmissionUrl()
{
var predicatePath = WritePredicateFile();
var keyPath = WriteEcdsaKeyFile();
var outputPath = Path.Combine(_testDir, "envelope-vrekor.json");
var fakeRekor = new FakeRekorClient();
var (stdout, stderr, exitCode) = await InvokeAsync(
$"attest \"{predicatePath}\" --key \"{keyPath}\" --output \"{outputPath}\" --rekor-url \"https://rekor.test.local\" --verbose",
fakeRekor);
exitCode.Should().Be(0, because: $"stderr: {stderr}");
stdout.Should().Contain("Submitting to Rekor: https://rekor.test.local");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Attest_EnvelopePayload_ContainsValidInTotoStatement()
{
var predicatePath = WritePredicateFile();
var keyPath = WriteEcdsaKeyFile();
var outputPath = Path.Combine(_testDir, "envelope-intoto.json");
var (_, stderr, exitCode) = await InvokeAsync(
$"attest \"{predicatePath}\" --key \"{keyPath}\" --output \"{outputPath}\"");
exitCode.Should().Be(0, because: $"stderr: {stderr}");
var envelopeJson = await File.ReadAllTextAsync(outputPath);
using var doc = JsonDocument.Parse(envelopeJson);
var payloadB64 = doc.RootElement.GetProperty("payload").GetString()!;
var payloadBytes = Convert.FromBase64String(payloadB64);
var payloadStr = Encoding.UTF8.GetString(payloadBytes);
// The payload should be a valid in-toto statement with the predicate
using var payloadDoc = JsonDocument.Parse(payloadStr);
payloadDoc.RootElement.GetProperty("_type").GetString()
.Should().Be("https://in-toto.io/Statement/v1");
payloadDoc.RootElement.GetProperty("predicateType").GetString()
.Should().Contain("delta-sig");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Attest_EcdsaSignature_IsVerifiable()
{
// Generate a key, sign, then verify the signature
var predicatePath = WritePredicateFile();
var keyPath = Path.Combine(_testDir, "verify-key.pem");
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
File.WriteAllText(keyPath, ecdsa.ExportECPrivateKeyPem());
var outputPath = Path.Combine(_testDir, "envelope-verify.json");
var (_, stderr, exitCode) = await InvokeAsync(
$"attest \"{predicatePath}\" --key \"{keyPath}\" --output \"{outputPath}\"");
exitCode.Should().Be(0, because: $"stderr: {stderr}");
var envelopeJson = await File.ReadAllTextAsync(outputPath);
using var doc = JsonDocument.Parse(envelopeJson);
var sigB64 = doc.RootElement.GetProperty("signatures")[0].GetProperty("sig").GetString()!;
var payloadType = doc.RootElement.GetProperty("payloadType").GetString()!;
var payloadB64 = doc.RootElement.GetProperty("payload").GetString()!;
var payload = Convert.FromBase64String(payloadB64);
var sigBytes = Convert.FromBase64String(sigB64);
// Reconstruct PAE: "DSSEv1 <len(type)> <type> <len(body)> <body>"
var pae = BuildPae(payloadType, payload);
// Verify with the same key
var verified = ecdsa.VerifyData(pae, sigBytes, HashAlgorithmName.SHA256);
verified.Should().BeTrue("ECDSA signature should verify with the signing key");
}
#region Fake IRekorClient
private sealed class FakeRekorClient : IRekorClient
{
public int SubmitCallCount { get; private set; }
public AttestorSubmissionRequest? LastRequest { get; private set; }
public RekorBackend? LastBackend { get; private set; }
public Exception? ThrowOnSubmit { get; set; }
public Task<RekorSubmissionResponse> SubmitAsync(
AttestorSubmissionRequest request,
RekorBackend backend,
CancellationToken cancellationToken = default)
{
SubmitCallCount++;
LastRequest = request;
LastBackend = backend;
if (ThrowOnSubmit is not null)
throw ThrowOnSubmit;
return Task.FromResult(new RekorSubmissionResponse
{
Uuid = "fake-uuid-123",
Index = 42,
LogUrl = "https://rekor.test.local/api/v1/log/entries/fake-uuid-123",
Status = "included",
IntegratedTime = DateTimeOffset.UtcNow.ToUnixTimeSeconds()
});
}
public Task<RekorProofResponse?> GetProofAsync(
string rekorUuid,
RekorBackend backend,
CancellationToken cancellationToken = default)
=> Task.FromResult<RekorProofResponse?>(null);
public Task<RekorInclusionVerificationResult> VerifyInclusionAsync(
string rekorUuid,
byte[] payloadDigest,
RekorBackend backend,
CancellationToken cancellationToken = default)
=> Task.FromResult(RekorInclusionVerificationResult.Success(0, "abc", "abc"));
}
#endregion
#region PAE helper
private static byte[] BuildPae(string payloadType, byte[] payload)
{
// DSSE PAE: "DSSEv1 LEN(type) type LEN(body) body"
var typeBytes = Encoding.UTF8.GetBytes(payloadType);
var header = Encoding.UTF8.GetBytes($"DSSEv1 {typeBytes.Length} ");
var middle = Encoding.UTF8.GetBytes($" {payload.Length} ");
var pae = new byte[header.Length + typeBytes.Length + middle.Length + payload.Length];
Buffer.BlockCopy(header, 0, pae, 0, header.Length);
Buffer.BlockCopy(typeBytes, 0, pae, header.Length, typeBytes.Length);
Buffer.BlockCopy(middle, 0, pae, header.Length + typeBytes.Length, middle.Length);
Buffer.BlockCopy(payload, 0, pae, header.Length + typeBytes.Length + middle.Length, payload.Length);
return pae;
}
#endregion
}

View File

@@ -0,0 +1,379 @@
// SPDX-License-Identifier: BUSL-1.1
// Copyright (c) 2025 StellaOps
// Sprint: SPRINT_20260122_039_Scanner_runtime_linkage_verification
// Task: RLV-006 - CLI: stella function-map generate
using System.CommandLine;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using StellaOps.Cli.Commands.FunctionMap;
using Xunit;
namespace StellaOps.Cli.Tests.Commands;
/// <summary>
/// Unit tests for function-map CLI commands.
/// </summary>
[Trait("Category", "Unit")]
[Trait("Sprint", "039")]
public sealed class FunctionMapCommandTests
{
private readonly IServiceProvider _services;
private readonly Option<bool> _verboseOption;
private readonly CancellationToken _cancellationToken;
public FunctionMapCommandTests()
{
var serviceCollection = new ServiceCollection();
serviceCollection.AddSingleton<ILoggerFactory>(NullLoggerFactory.Instance);
_services = serviceCollection.BuildServiceProvider();
_verboseOption = new Option<bool>("--verbose", "-v") { Description = "Enable verbose output" };
_cancellationToken = CancellationToken.None;
}
[Fact(DisplayName = "BuildFunctionMapCommand creates command tree")]
public void BuildFunctionMapCommand_CreatesCommandTree()
{
// Act
var command = FunctionMapCommandGroup.BuildFunctionMapCommand(
_services,
_verboseOption,
_cancellationToken);
// Assert
Assert.Equal("function-map", command.Name);
Assert.Equal("Runtime linkage function map operations", command.Description);
}
[Fact(DisplayName = "BuildFunctionMapCommand has fmap alias")]
public void BuildFunctionMapCommand_HasFmapAlias()
{
// Act
var command = FunctionMapCommandGroup.BuildFunctionMapCommand(
_services,
_verboseOption,
_cancellationToken);
// Assert
Assert.Contains("fmap", command.Aliases);
}
[Fact(DisplayName = "BuildFunctionMapCommand has generate subcommand")]
public void BuildFunctionMapCommand_HasGenerateSubcommand()
{
// Act
var command = FunctionMapCommandGroup.BuildFunctionMapCommand(
_services,
_verboseOption,
_cancellationToken);
var generateCommand = command.Subcommands.FirstOrDefault(c => c.Name == "generate");
// Assert
Assert.NotNull(generateCommand);
Assert.Equal("Generate a function_map predicate from SBOM", generateCommand.Description);
}
[Fact(DisplayName = "GenerateCommand has required sbom option")]
public void GenerateCommand_HasRequiredSbomOption()
{
// Arrange
var command = FunctionMapCommandGroup.BuildFunctionMapCommand(
_services,
_verboseOption,
_cancellationToken);
var generateCommand = command.Subcommands.First(c => c.Name == "generate");
// Act
var sbomOption = generateCommand.Options.FirstOrDefault(o => o.Name == "--sbom");
// Assert
Assert.NotNull(sbomOption);
Assert.True(sbomOption.Required);
}
[Fact(DisplayName = "GenerateCommand has required service option")]
public void GenerateCommand_HasRequiredServiceOption()
{
// Arrange
var command = FunctionMapCommandGroup.BuildFunctionMapCommand(
_services,
_verboseOption,
_cancellationToken);
var generateCommand = command.Subcommands.First(c => c.Name == "generate");
// Act
var serviceOption = generateCommand.Options.FirstOrDefault(o => o.Name == "--service");
// Assert
Assert.NotNull(serviceOption);
Assert.True(serviceOption.Required);
}
[Fact(DisplayName = "GenerateCommand has hot-functions option")]
public void GenerateCommand_HasHotFunctionsOption()
{
// Arrange
var command = FunctionMapCommandGroup.BuildFunctionMapCommand(
_services,
_verboseOption,
_cancellationToken);
var generateCommand = command.Subcommands.First(c => c.Name == "generate");
// Act
var hotFunctionsOption = generateCommand.Options.FirstOrDefault(o => o.Name == "--hot-functions");
// Assert
Assert.NotNull(hotFunctionsOption);
}
[Fact(DisplayName = "GenerateCommand has min-rate option with default")]
public void GenerateCommand_HasMinRateOption()
{
// Arrange
var command = FunctionMapCommandGroup.BuildFunctionMapCommand(
_services,
_verboseOption,
_cancellationToken);
var generateCommand = command.Subcommands.First(c => c.Name == "generate");
// Act
var minRateOption = generateCommand.Options.FirstOrDefault(o => o.Name == "--min-rate");
// Assert
Assert.NotNull(minRateOption);
}
[Fact(DisplayName = "GenerateCommand has window option with default")]
public void GenerateCommand_HasWindowOption()
{
// Arrange
var command = FunctionMapCommandGroup.BuildFunctionMapCommand(
_services,
_verboseOption,
_cancellationToken);
var generateCommand = command.Subcommands.First(c => c.Name == "generate");
// Act
var windowOption = generateCommand.Options.FirstOrDefault(o => o.Name == "--window");
// Assert
Assert.NotNull(windowOption);
}
[Fact(DisplayName = "GenerateCommand has format option with allowed values")]
public void GenerateCommand_HasFormatOption()
{
// Arrange
var command = FunctionMapCommandGroup.BuildFunctionMapCommand(
_services,
_verboseOption,
_cancellationToken);
var generateCommand = command.Subcommands.First(c => c.Name == "generate");
// Act
var formatOption = generateCommand.Options.FirstOrDefault(o => o.Name == "--format");
// Assert
Assert.NotNull(formatOption);
}
[Fact(DisplayName = "GenerateCommand has sign option")]
public void GenerateCommand_HasSignOption()
{
// Arrange
var command = FunctionMapCommandGroup.BuildFunctionMapCommand(
_services,
_verboseOption,
_cancellationToken);
var generateCommand = command.Subcommands.First(c => c.Name == "generate");
// Act
var signOption = generateCommand.Options.FirstOrDefault(o => o.Name == "--sign");
// Assert
Assert.NotNull(signOption);
}
[Fact(DisplayName = "GenerateCommand has attest option")]
public void GenerateCommand_HasAttestOption()
{
// Arrange
var command = FunctionMapCommandGroup.BuildFunctionMapCommand(
_services,
_verboseOption,
_cancellationToken);
var generateCommand = command.Subcommands.First(c => c.Name == "generate");
// Act
var attestOption = generateCommand.Options.FirstOrDefault(o => o.Name == "--attest");
// Assert
Assert.NotNull(attestOption);
}
#region Verify Command Tests
[Fact(DisplayName = "BuildFunctionMapCommand has verify subcommand")]
public void BuildFunctionMapCommand_HasVerifySubcommand()
{
// Act
var command = FunctionMapCommandGroup.BuildFunctionMapCommand(
_services,
_verboseOption,
_cancellationToken);
var verifyCommand = command.Subcommands.FirstOrDefault(c => c.Name == "verify");
// Assert
Assert.NotNull(verifyCommand);
Assert.Equal("Verify runtime observations against a function_map", verifyCommand.Description);
}
[Fact(DisplayName = "VerifyCommand has required function-map option")]
public void VerifyCommand_HasRequiredFunctionMapOption()
{
// Arrange
var command = FunctionMapCommandGroup.BuildFunctionMapCommand(
_services,
_verboseOption,
_cancellationToken);
var verifyCommand = command.Subcommands.First(c => c.Name == "verify");
// Act
var fmOption = verifyCommand.Options.FirstOrDefault(o => o.Name == "--function-map");
// Assert
Assert.NotNull(fmOption);
Assert.True(fmOption.Required);
}
[Fact(DisplayName = "VerifyCommand has container option")]
public void VerifyCommand_HasContainerOption()
{
// Arrange
var command = FunctionMapCommandGroup.BuildFunctionMapCommand(
_services,
_verboseOption,
_cancellationToken);
var verifyCommand = command.Subcommands.First(c => c.Name == "verify");
// Act
var containerOption = verifyCommand.Options.FirstOrDefault(o => o.Name == "--container");
// Assert
Assert.NotNull(containerOption);
}
[Fact(DisplayName = "VerifyCommand has from and to options")]
public void VerifyCommand_HasTimeWindowOptions()
{
// Arrange
var command = FunctionMapCommandGroup.BuildFunctionMapCommand(
_services,
_verboseOption,
_cancellationToken);
var verifyCommand = command.Subcommands.First(c => c.Name == "verify");
// Act
var fromOption = verifyCommand.Options.FirstOrDefault(o => o.Name == "--from");
var toOption = verifyCommand.Options.FirstOrDefault(o => o.Name == "--to");
// Assert
Assert.NotNull(fromOption);
Assert.NotNull(toOption);
}
[Fact(DisplayName = "VerifyCommand has format option with allowed values")]
public void VerifyCommand_HasFormatOption()
{
// Arrange
var command = FunctionMapCommandGroup.BuildFunctionMapCommand(
_services,
_verboseOption,
_cancellationToken);
var verifyCommand = command.Subcommands.First(c => c.Name == "verify");
// Act
var formatOption = verifyCommand.Options.FirstOrDefault(o => o.Name == "--format");
// Assert
Assert.NotNull(formatOption);
}
[Fact(DisplayName = "VerifyCommand has strict option")]
public void VerifyCommand_HasStrictOption()
{
// Arrange
var command = FunctionMapCommandGroup.BuildFunctionMapCommand(
_services,
_verboseOption,
_cancellationToken);
var verifyCommand = command.Subcommands.First(c => c.Name == "verify");
// Act
var strictOption = verifyCommand.Options.FirstOrDefault(o => o.Name == "--strict");
// Assert
Assert.NotNull(strictOption);
}
[Fact(DisplayName = "VerifyCommand has offline and observations options")]
public void VerifyCommand_HasOfflineOptions()
{
// Arrange
var command = FunctionMapCommandGroup.BuildFunctionMapCommand(
_services,
_verboseOption,
_cancellationToken);
var verifyCommand = command.Subcommands.First(c => c.Name == "verify");
// Act
var offlineOption = verifyCommand.Options.FirstOrDefault(o => o.Name == "--offline");
var observationsOption = verifyCommand.Options.FirstOrDefault(o => o.Name == "--observations");
// Assert
Assert.NotNull(offlineOption);
Assert.NotNull(observationsOption);
}
#endregion
}
/// <summary>
/// Exit code tests for FunctionMapExitCodes.
/// </summary>
[Trait("Category", "Unit")]
[Trait("Sprint", "039")]
public sealed class FunctionMapExitCodesTests
{
[Fact(DisplayName = "Success exit code is 0")]
public void Success_IsZero()
{
Assert.Equal(0, FunctionMapExitCodes.Success);
}
[Fact(DisplayName = "FileNotFound exit code is 10")]
public void FileNotFound_IsTen()
{
Assert.Equal(10, FunctionMapExitCodes.FileNotFound);
}
[Fact(DisplayName = "ValidationFailed exit code is 20")]
public void ValidationFailed_IsTwenty()
{
Assert.Equal(20, FunctionMapExitCodes.ValidationFailed);
}
[Fact(DisplayName = "VerificationFailed exit code is 25")]
public void VerificationFailed_IsTwentyFive()
{
Assert.Equal(25, FunctionMapExitCodes.VerificationFailed);
}
[Fact(DisplayName = "SystemError exit code is 99")]
public void SystemError_IsNinetyNine()
{
Assert.Equal(99, FunctionMapExitCodes.SystemError);
}
}

View File

@@ -0,0 +1,335 @@
// SPDX-License-Identifier: BUSL-1.1
// Copyright (c) 2025 StellaOps
// Sprint: SPRINT_20260122_039_Scanner_runtime_linkage_verification
// Task: RLV-008 - CLI: stella observations query
using System.CommandLine;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Cli.Commands.Observations;
using Xunit;
namespace StellaOps.Cli.Tests.Commands;
/// <summary>
/// Unit tests for observations CLI commands.
/// </summary>
[Trait("Category", "Unit")]
[Trait("Sprint", "039")]
public sealed class ObservationsCommandTests
{
private readonly IServiceProvider _services;
private readonly Option<bool> _verboseOption;
private readonly CancellationToken _cancellationToken;
public ObservationsCommandTests()
{
var serviceCollection = new ServiceCollection();
serviceCollection.AddSingleton<ILoggerFactory>(NullLoggerFactory.Instance);
_services = serviceCollection.BuildServiceProvider();
_verboseOption = new Option<bool>("--verbose", "-v") { Description = "Enable verbose output" };
_cancellationToken = CancellationToken.None;
}
[Fact(DisplayName = "BuildObservationsCommand creates command tree")]
public void BuildObservationsCommand_CreatesCommandTree()
{
// Act
var command = ObservationsCommandGroup.BuildObservationsCommand(
_services,
_verboseOption,
_cancellationToken);
// Assert
Assert.Equal("observations", command.Name);
Assert.Equal("Runtime observation operations", command.Description);
}
[Fact(DisplayName = "BuildObservationsCommand has obs alias")]
public void BuildObservationsCommand_HasObsAlias()
{
// Act
var command = ObservationsCommandGroup.BuildObservationsCommand(
_services,
_verboseOption,
_cancellationToken);
// Assert
Assert.Contains("obs", command.Aliases);
}
[Fact(DisplayName = "BuildObservationsCommand has query subcommand")]
public void BuildObservationsCommand_HasQuerySubcommand()
{
// Act
var command = ObservationsCommandGroup.BuildObservationsCommand(
_services,
_verboseOption,
_cancellationToken);
var queryCommand = command.Subcommands.FirstOrDefault(c => c.Name == "query");
// Assert
Assert.NotNull(queryCommand);
Assert.Equal("Query historical runtime observations", queryCommand.Description);
}
#region Query Command Options Tests
[Fact(DisplayName = "QueryCommand has symbol option with short alias")]
public void QueryCommand_HasSymbolOption()
{
// Arrange
var command = ObservationsCommandGroup.BuildObservationsCommand(
_services,
_verboseOption,
_cancellationToken);
var queryCommand = command.Subcommands.First(c => c.Name == "query");
// Act
var symbolOption = queryCommand.Options.FirstOrDefault(o => o.Name == "--symbol");
// Assert
Assert.NotNull(symbolOption);
Assert.Contains("-s", symbolOption.Aliases);
}
[Fact(DisplayName = "QueryCommand has node-hash option")]
public void QueryCommand_HasNodeHashOption()
{
// Arrange
var command = ObservationsCommandGroup.BuildObservationsCommand(
_services,
_verboseOption,
_cancellationToken);
var queryCommand = command.Subcommands.First(c => c.Name == "query");
// Act
var nodeHashOption = queryCommand.Options.FirstOrDefault(o => o.Name == "--node-hash");
// Assert
Assert.NotNull(nodeHashOption);
Assert.Contains("-n", nodeHashOption.Aliases);
}
[Fact(DisplayName = "QueryCommand has container option")]
public void QueryCommand_HasContainerOption()
{
// Arrange
var command = ObservationsCommandGroup.BuildObservationsCommand(
_services,
_verboseOption,
_cancellationToken);
var queryCommand = command.Subcommands.First(c => c.Name == "query");
// Act
var containerOption = queryCommand.Options.FirstOrDefault(o => o.Name == "--container");
// Assert
Assert.NotNull(containerOption);
Assert.Contains("-c", containerOption.Aliases);
}
[Fact(DisplayName = "QueryCommand has pod option")]
public void QueryCommand_HasPodOption()
{
// Arrange
var command = ObservationsCommandGroup.BuildObservationsCommand(
_services,
_verboseOption,
_cancellationToken);
var queryCommand = command.Subcommands.First(c => c.Name == "query");
// Act
var podOption = queryCommand.Options.FirstOrDefault(o => o.Name == "--pod");
// Assert
Assert.NotNull(podOption);
Assert.Contains("-p", podOption.Aliases);
}
[Fact(DisplayName = "QueryCommand has namespace option")]
public void QueryCommand_HasNamespaceOption()
{
// Arrange
var command = ObservationsCommandGroup.BuildObservationsCommand(
_services,
_verboseOption,
_cancellationToken);
var queryCommand = command.Subcommands.First(c => c.Name == "query");
// Act
var namespaceOption = queryCommand.Options.FirstOrDefault(o => o.Name == "--namespace");
// Assert
Assert.NotNull(namespaceOption);
Assert.Contains("-N", namespaceOption.Aliases);
}
[Fact(DisplayName = "QueryCommand has probe-type option")]
public void QueryCommand_HasProbeTypeOption()
{
// Arrange
var command = ObservationsCommandGroup.BuildObservationsCommand(
_services,
_verboseOption,
_cancellationToken);
var queryCommand = command.Subcommands.First(c => c.Name == "query");
// Act
var probeTypeOption = queryCommand.Options.FirstOrDefault(o => o.Name == "--probe-type");
// Assert
Assert.NotNull(probeTypeOption);
}
[Fact(DisplayName = "QueryCommand has time window options")]
public void QueryCommand_HasTimeWindowOptions()
{
// Arrange
var command = ObservationsCommandGroup.BuildObservationsCommand(
_services,
_verboseOption,
_cancellationToken);
var queryCommand = command.Subcommands.First(c => c.Name == "query");
// Act
var fromOption = queryCommand.Options.FirstOrDefault(o => o.Name == "--from");
var toOption = queryCommand.Options.FirstOrDefault(o => o.Name == "--to");
// Assert
Assert.NotNull(fromOption);
Assert.NotNull(toOption);
}
[Fact(DisplayName = "QueryCommand has pagination options")]
public void QueryCommand_HasPaginationOptions()
{
// Arrange
var command = ObservationsCommandGroup.BuildObservationsCommand(
_services,
_verboseOption,
_cancellationToken);
var queryCommand = command.Subcommands.First(c => c.Name == "query");
// Act
var limitOption = queryCommand.Options.FirstOrDefault(o => o.Name == "--limit");
var offsetOption = queryCommand.Options.FirstOrDefault(o => o.Name == "--offset");
// Assert
Assert.NotNull(limitOption);
Assert.NotNull(offsetOption);
}
[Fact(DisplayName = "QueryCommand has format option with allowed values")]
public void QueryCommand_HasFormatOption()
{
// Arrange
var command = ObservationsCommandGroup.BuildObservationsCommand(
_services,
_verboseOption,
_cancellationToken);
var queryCommand = command.Subcommands.First(c => c.Name == "query");
// Act
var formatOption = queryCommand.Options.FirstOrDefault(o => o.Name == "--format");
// Assert
Assert.NotNull(formatOption);
Assert.Contains("-f", formatOption.Aliases);
}
[Fact(DisplayName = "QueryCommand has summary option")]
public void QueryCommand_HasSummaryOption()
{
// Arrange
var command = ObservationsCommandGroup.BuildObservationsCommand(
_services,
_verboseOption,
_cancellationToken);
var queryCommand = command.Subcommands.First(c => c.Name == "query");
// Act
var summaryOption = queryCommand.Options.FirstOrDefault(o => o.Name == "--summary");
// Assert
Assert.NotNull(summaryOption);
}
[Fact(DisplayName = "QueryCommand has output option")]
public void QueryCommand_HasOutputOption()
{
// Arrange
var command = ObservationsCommandGroup.BuildObservationsCommand(
_services,
_verboseOption,
_cancellationToken);
var queryCommand = command.Subcommands.First(c => c.Name == "query");
// Act
var outputOption = queryCommand.Options.FirstOrDefault(o => o.Name == "--output");
// Assert
Assert.NotNull(outputOption);
Assert.Contains("-o", outputOption.Aliases);
}
[Fact(DisplayName = "QueryCommand has offline mode options")]
public void QueryCommand_HasOfflineModeOptions()
{
// Arrange
var command = ObservationsCommandGroup.BuildObservationsCommand(
_services,
_verboseOption,
_cancellationToken);
var queryCommand = command.Subcommands.First(c => c.Name == "query");
// Act
var offlineOption = queryCommand.Options.FirstOrDefault(o => o.Name == "--offline");
var observationsFileOption = queryCommand.Options.FirstOrDefault(o => o.Name == "--observations-file");
// Assert
Assert.NotNull(offlineOption);
Assert.NotNull(observationsFileOption);
}
#endregion
}
/// <summary>
/// Exit code tests for ObservationsExitCodes.
/// </summary>
[Trait("Category", "Unit")]
[Trait("Sprint", "039")]
public sealed class ObservationsExitCodesTests
{
[Fact(DisplayName = "Success exit code is 0")]
public void Success_IsZero()
{
Assert.Equal(0, ObservationsExitCodes.Success);
}
[Fact(DisplayName = "InvalidArgument exit code is 10")]
public void InvalidArgument_IsTen()
{
Assert.Equal(10, ObservationsExitCodes.InvalidArgument);
}
[Fact(DisplayName = "FileNotFound exit code is 11")]
public void FileNotFound_IsEleven()
{
Assert.Equal(11, ObservationsExitCodes.FileNotFound);
}
[Fact(DisplayName = "QueryFailed exit code is 20")]
public void QueryFailed_IsTwenty()
{
Assert.Equal(20, ObservationsExitCodes.QueryFailed);
}
[Fact(DisplayName = "SystemError exit code is 99")]
public void SystemError_IsNinetyNine()
{
Assert.Equal(99, ObservationsExitCodes.SystemError);
}
}

View File

@@ -0,0 +1,448 @@
// SPDX-License-Identifier: BUSL-1.1
// Copyright (c) 2025 StellaOps
// Sprint: SPRINT_20260122_041_Policy_interop_import_export_rego
// Task: TASK-06/TASK-10 - CLI tests for policy interop commands
using System.CommandLine;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Cli.Commands.Policy;
using Xunit;
namespace StellaOps.Cli.Tests.Commands;
/// <summary>
/// Unit tests for policy interop CLI commands (stella policy export/import/validate/evaluate).
/// </summary>
[Trait("Category", "Unit")]
[Trait("Sprint", "041")]
public sealed class PolicyInteropCommandTests
{
private readonly Option<bool> _verboseOption;
private readonly CancellationToken _cancellationToken;
public PolicyInteropCommandTests()
{
_verboseOption = new Option<bool>("--verbose") { Description = "Enable verbose output" };
_cancellationToken = CancellationToken.None;
}
private static Command BuildPolicyCommand()
{
return new Command("policy", "Policy management commands");
}
#region Command Registration Tests
[Fact(DisplayName = "RegisterSubcommands adds export command")]
public void RegisterSubcommands_AddsExportCommand()
{
// Arrange
var policyCommand = BuildPolicyCommand();
// Act
PolicyInteropCommandGroup.RegisterSubcommands(policyCommand, _verboseOption, _cancellationToken);
// Assert
var exportCmd = policyCommand.Subcommands.FirstOrDefault(c => c.Name == "export");
Assert.NotNull(exportCmd);
Assert.Equal("Export a policy pack to JSON or OPA/Rego format.", exportCmd.Description);
}
[Fact(DisplayName = "RegisterSubcommands adds import command")]
public void RegisterSubcommands_AddsImportCommand()
{
// Arrange
var policyCommand = BuildPolicyCommand();
// Act
PolicyInteropCommandGroup.RegisterSubcommands(policyCommand, _verboseOption, _cancellationToken);
// Assert
var importCmd = policyCommand.Subcommands.FirstOrDefault(c => c.Name == "import");
Assert.NotNull(importCmd);
}
[Fact(DisplayName = "RegisterSubcommands adds validate command")]
public void RegisterSubcommands_AddsValidateCommand()
{
// Arrange
var policyCommand = BuildPolicyCommand();
// Act
PolicyInteropCommandGroup.RegisterSubcommands(policyCommand, _verboseOption, _cancellationToken);
// Assert
var validateCmd = policyCommand.Subcommands.FirstOrDefault(c => c.Name == "validate");
Assert.NotNull(validateCmd);
}
[Fact(DisplayName = "RegisterSubcommands adds evaluate command")]
public void RegisterSubcommands_AddsEvaluateCommand()
{
// Arrange
var policyCommand = BuildPolicyCommand();
// Act
PolicyInteropCommandGroup.RegisterSubcommands(policyCommand, _verboseOption, _cancellationToken);
// Assert
var evalCmd = policyCommand.Subcommands.FirstOrDefault(c => c.Name == "evaluate");
Assert.NotNull(evalCmd);
}
[Fact(DisplayName = "RegisterSubcommands adds all four commands")]
public void RegisterSubcommands_AddsFourCommands()
{
// Arrange
var policyCommand = BuildPolicyCommand();
// Act
PolicyInteropCommandGroup.RegisterSubcommands(policyCommand, _verboseOption, _cancellationToken);
// Assert
Assert.Equal(4, policyCommand.Subcommands.Count);
}
#endregion
#region Export Command Tests
[Fact(DisplayName = "ExportCommand has --file option")]
public void ExportCommand_HasFileOption()
{
var policyCommand = BuildPolicyCommand();
PolicyInteropCommandGroup.RegisterSubcommands(policyCommand, _verboseOption, _cancellationToken);
var exportCmd = policyCommand.Subcommands.First(c => c.Name == "export");
var fileOption = exportCmd.Options.FirstOrDefault(o => o.Name == "--file");
Assert.NotNull(fileOption);
}
[Fact(DisplayName = "ExportCommand has --format option")]
public void ExportCommand_HasFormatOption()
{
var policyCommand = BuildPolicyCommand();
PolicyInteropCommandGroup.RegisterSubcommands(policyCommand, _verboseOption, _cancellationToken);
var exportCmd = policyCommand.Subcommands.First(c => c.Name == "export");
var formatOption = exportCmd.Options.FirstOrDefault(o => o.Name == "--format");
Assert.NotNull(formatOption);
}
[Fact(DisplayName = "ExportCommand has --output-file option")]
public void ExportCommand_HasOutputFileOption()
{
var policyCommand = BuildPolicyCommand();
PolicyInteropCommandGroup.RegisterSubcommands(policyCommand, _verboseOption, _cancellationToken);
var exportCmd = policyCommand.Subcommands.First(c => c.Name == "export");
var outputOption = exportCmd.Options.FirstOrDefault(o => o.Name == "--output-file");
Assert.NotNull(outputOption);
}
[Fact(DisplayName = "ExportCommand has --environment option")]
public void ExportCommand_HasEnvironmentOption()
{
var policyCommand = BuildPolicyCommand();
PolicyInteropCommandGroup.RegisterSubcommands(policyCommand, _verboseOption, _cancellationToken);
var exportCmd = policyCommand.Subcommands.First(c => c.Name == "export");
var envOption = exportCmd.Options.FirstOrDefault(o => o.Name == "--environment");
Assert.NotNull(envOption);
}
[Fact(DisplayName = "ExportCommand has --include-remediation option")]
public void ExportCommand_HasIncludeRemediationOption()
{
var policyCommand = BuildPolicyCommand();
PolicyInteropCommandGroup.RegisterSubcommands(policyCommand, _verboseOption, _cancellationToken);
var exportCmd = policyCommand.Subcommands.First(c => c.Name == "export");
var remediationOption = exportCmd.Options.FirstOrDefault(o => o.Name == "--include-remediation");
Assert.NotNull(remediationOption);
}
#endregion
#region Import Command Tests
[Fact(DisplayName = "ImportCommand has --file option")]
public void ImportCommand_HasFileOption()
{
var policyCommand = BuildPolicyCommand();
PolicyInteropCommandGroup.RegisterSubcommands(policyCommand, _verboseOption, _cancellationToken);
var importCmd = policyCommand.Subcommands.First(c => c.Name == "import");
var fileOption = importCmd.Options.FirstOrDefault(o => o.Name == "--file");
Assert.NotNull(fileOption);
}
[Fact(DisplayName = "ImportCommand has --validate-only option")]
public void ImportCommand_HasValidateOnlyOption()
{
var policyCommand = BuildPolicyCommand();
PolicyInteropCommandGroup.RegisterSubcommands(policyCommand, _verboseOption, _cancellationToken);
var importCmd = policyCommand.Subcommands.First(c => c.Name == "import");
var validateOnlyOption = importCmd.Options.FirstOrDefault(o => o.Name == "--validate-only");
Assert.NotNull(validateOnlyOption);
}
[Fact(DisplayName = "ImportCommand has --merge-strategy option")]
public void ImportCommand_HasMergeStrategyOption()
{
var policyCommand = BuildPolicyCommand();
PolicyInteropCommandGroup.RegisterSubcommands(policyCommand, _verboseOption, _cancellationToken);
var importCmd = policyCommand.Subcommands.First(c => c.Name == "import");
var mergeOption = importCmd.Options.FirstOrDefault(o => o.Name == "--merge-strategy");
Assert.NotNull(mergeOption);
}
[Fact(DisplayName = "ImportCommand has --dry-run option")]
public void ImportCommand_HasDryRunOption()
{
var policyCommand = BuildPolicyCommand();
PolicyInteropCommandGroup.RegisterSubcommands(policyCommand, _verboseOption, _cancellationToken);
var importCmd = policyCommand.Subcommands.First(c => c.Name == "import");
var dryRunOption = importCmd.Options.FirstOrDefault(o => o.Name == "--dry-run");
Assert.NotNull(dryRunOption);
}
[Fact(DisplayName = "ImportCommand has --format option")]
public void ImportCommand_HasFormatOption()
{
var policyCommand = BuildPolicyCommand();
PolicyInteropCommandGroup.RegisterSubcommands(policyCommand, _verboseOption, _cancellationToken);
var importCmd = policyCommand.Subcommands.First(c => c.Name == "import");
var formatOption = importCmd.Options.FirstOrDefault(o => o.Name == "--format");
Assert.NotNull(formatOption);
}
#endregion
#region Validate Command Tests
[Fact(DisplayName = "ValidateCommand has --file option")]
public void ValidateCommand_HasFileOption()
{
var policyCommand = BuildPolicyCommand();
PolicyInteropCommandGroup.RegisterSubcommands(policyCommand, _verboseOption, _cancellationToken);
var validateCmd = policyCommand.Subcommands.First(c => c.Name == "validate");
var fileOption = validateCmd.Options.FirstOrDefault(o => o.Name == "--file");
Assert.NotNull(fileOption);
}
[Fact(DisplayName = "ValidateCommand has --strict option")]
public void ValidateCommand_HasStrictOption()
{
var policyCommand = BuildPolicyCommand();
PolicyInteropCommandGroup.RegisterSubcommands(policyCommand, _verboseOption, _cancellationToken);
var validateCmd = policyCommand.Subcommands.First(c => c.Name == "validate");
var strictOption = validateCmd.Options.FirstOrDefault(o => o.Name == "--strict");
Assert.NotNull(strictOption);
}
[Fact(DisplayName = "ValidateCommand has --format option")]
public void ValidateCommand_HasFormatOption()
{
var policyCommand = BuildPolicyCommand();
PolicyInteropCommandGroup.RegisterSubcommands(policyCommand, _verboseOption, _cancellationToken);
var validateCmd = policyCommand.Subcommands.First(c => c.Name == "validate");
var formatOption = validateCmd.Options.FirstOrDefault(o => o.Name == "--format");
Assert.NotNull(formatOption);
}
#endregion
#region Evaluate Command Tests
[Fact(DisplayName = "EvaluateCommand has --policy option")]
public void EvaluateCommand_HasPolicyOption()
{
var policyCommand = BuildPolicyCommand();
PolicyInteropCommandGroup.RegisterSubcommands(policyCommand, _verboseOption, _cancellationToken);
var evalCmd = policyCommand.Subcommands.First(c => c.Name == "evaluate");
var policyOption = evalCmd.Options.FirstOrDefault(o => o.Name == "--policy");
Assert.NotNull(policyOption);
}
[Fact(DisplayName = "EvaluateCommand has --input option")]
public void EvaluateCommand_HasInputOption()
{
var policyCommand = BuildPolicyCommand();
PolicyInteropCommandGroup.RegisterSubcommands(policyCommand, _verboseOption, _cancellationToken);
var evalCmd = policyCommand.Subcommands.First(c => c.Name == "evaluate");
var inputOption = evalCmd.Options.FirstOrDefault(o => o.Name == "--input");
Assert.NotNull(inputOption);
}
[Fact(DisplayName = "EvaluateCommand has --environment option")]
public void EvaluateCommand_HasEnvironmentOption()
{
var policyCommand = BuildPolicyCommand();
PolicyInteropCommandGroup.RegisterSubcommands(policyCommand, _verboseOption, _cancellationToken);
var evalCmd = policyCommand.Subcommands.First(c => c.Name == "evaluate");
var envOption = evalCmd.Options.FirstOrDefault(o => o.Name == "--environment");
Assert.NotNull(envOption);
}
[Fact(DisplayName = "EvaluateCommand has --include-remediation option")]
public void EvaluateCommand_HasIncludeRemediationOption()
{
var policyCommand = BuildPolicyCommand();
PolicyInteropCommandGroup.RegisterSubcommands(policyCommand, _verboseOption, _cancellationToken);
var evalCmd = policyCommand.Subcommands.First(c => c.Name == "evaluate");
var remediationOption = evalCmd.Options.FirstOrDefault(o => o.Name == "--include-remediation");
Assert.NotNull(remediationOption);
}
[Fact(DisplayName = "EvaluateCommand has --output option")]
public void EvaluateCommand_HasOutputOption()
{
var policyCommand = BuildPolicyCommand();
PolicyInteropCommandGroup.RegisterSubcommands(policyCommand, _verboseOption, _cancellationToken);
var evalCmd = policyCommand.Subcommands.First(c => c.Name == "evaluate");
var outputOption = evalCmd.Options.FirstOrDefault(o => o.Name == "--output");
Assert.NotNull(outputOption);
}
[Fact(DisplayName = "EvaluateCommand has --format option")]
public void EvaluateCommand_HasFormatOption()
{
var policyCommand = BuildPolicyCommand();
PolicyInteropCommandGroup.RegisterSubcommands(policyCommand, _verboseOption, _cancellationToken);
var evalCmd = policyCommand.Subcommands.First(c => c.Name == "evaluate");
var formatOption = evalCmd.Options.FirstOrDefault(o => o.Name == "--format");
Assert.NotNull(formatOption);
}
#endregion
#region Exit Codes Tests
[Fact(DisplayName = "ExitCodes defines Success as 0")]
public void ExitCodes_Success_IsZero()
{
Assert.Equal(0, PolicyInteropCommandGroup.ExitCodes.Success);
}
[Fact(DisplayName = "ExitCodes defines Warnings as 1")]
public void ExitCodes_Warnings_IsOne()
{
Assert.Equal(1, PolicyInteropCommandGroup.ExitCodes.Warnings);
}
[Fact(DisplayName = "ExitCodes defines BlockOrErrors as 2")]
public void ExitCodes_BlockOrErrors_IsTwo()
{
Assert.Equal(2, PolicyInteropCommandGroup.ExitCodes.BlockOrErrors);
}
[Fact(DisplayName = "ExitCodes defines InputError as 10")]
public void ExitCodes_InputError_IsTen()
{
Assert.Equal(10, PolicyInteropCommandGroup.ExitCodes.InputError);
}
[Fact(DisplayName = "ExitCodes defines PolicyError as 12")]
public void ExitCodes_PolicyError_IsTwelve()
{
Assert.Equal(12, PolicyInteropCommandGroup.ExitCodes.PolicyError);
}
#endregion
#region Invocation Tests (exit code on missing file)
[Fact(DisplayName = "Export with non-existent file returns InputError")]
public async Task ExportCommand_NonExistentFile_ReturnsInputError()
{
// Arrange
var policyCommand = BuildPolicyCommand();
PolicyInteropCommandGroup.RegisterSubcommands(policyCommand, _verboseOption, _cancellationToken);
var root = new RootCommand();
root.Add(policyCommand);
// Act
var writer = new StringWriter();
Console.SetOut(writer);
var exitCode = await root.Parse("policy export --file /nonexistent/policy.json --format json").InvokeAsync();
Console.SetOut(new StreamWriter(Console.OpenStandardOutput()) { AutoFlush = true });
// Assert
Assert.Equal(PolicyInteropCommandGroup.ExitCodes.InputError, exitCode);
}
[Fact(DisplayName = "Import with non-existent file returns InputError")]
public async Task ImportCommand_NonExistentFile_ReturnsInputError()
{
// Arrange
var policyCommand = BuildPolicyCommand();
PolicyInteropCommandGroup.RegisterSubcommands(policyCommand, _verboseOption, _cancellationToken);
var root = new RootCommand();
root.Add(policyCommand);
// Act
var writer = new StringWriter();
Console.SetOut(writer);
var exitCode = await root.Parse("policy import --file /nonexistent/policy.json").InvokeAsync();
Console.SetOut(new StreamWriter(Console.OpenStandardOutput()) { AutoFlush = true });
// Assert
Assert.Equal(PolicyInteropCommandGroup.ExitCodes.InputError, exitCode);
}
[Fact(DisplayName = "Validate with non-existent file returns InputError")]
public async Task ValidateCommand_NonExistentFile_ReturnsInputError()
{
// Arrange
var policyCommand = BuildPolicyCommand();
PolicyInteropCommandGroup.RegisterSubcommands(policyCommand, _verboseOption, _cancellationToken);
var root = new RootCommand();
root.Add(policyCommand);
// Act
var writer = new StringWriter();
Console.SetOut(writer);
var exitCode = await root.Parse("policy validate --file /nonexistent/policy.json").InvokeAsync();
Console.SetOut(new StreamWriter(Console.OpenStandardOutput()) { AutoFlush = true });
// Assert
Assert.Equal(PolicyInteropCommandGroup.ExitCodes.InputError, exitCode);
}
[Fact(DisplayName = "Evaluate with non-existent policy returns InputError")]
public async Task EvaluateCommand_NonExistentPolicy_ReturnsInputError()
{
// Arrange
var policyCommand = BuildPolicyCommand();
PolicyInteropCommandGroup.RegisterSubcommands(policyCommand, _verboseOption, _cancellationToken);
var root = new RootCommand();
root.Add(policyCommand);
// Act
var writer = new StringWriter();
Console.SetOut(writer);
var exitCode = await root.Parse("policy evaluate --policy /nonexistent/policy.json --input /nonexistent/input.json").InvokeAsync();
Console.SetOut(new StreamWriter(Console.OpenStandardOutput()) { AutoFlush = true });
// Assert
Assert.Equal(PolicyInteropCommandGroup.ExitCodes.InputError, exitCode);
}
#endregion
}

View File

@@ -0,0 +1,203 @@
// -----------------------------------------------------------------------------
// ScoreCommandTests.cs
// Sprint: SPRINT_20260122_037_Signals_unified_trust_score_algebra
// Task: TSF-007 - CLI `stella score` Top-Level Command
// Description: Unit tests for top-level score CLI commands
// -----------------------------------------------------------------------------
using System.CommandLine;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Cli.Commands;
using StellaOps.Cli.Configuration;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.Cli.Tests.Commands;
/// <summary>
/// Unit tests for the top-level <c>stella score</c> command group.
/// </summary>
[Trait("Category", TestCategories.Unit)]
public class ScoreCommandTests
{
private readonly IServiceProvider _services;
private readonly StellaOpsCliOptions _options;
private readonly Option<bool> _verboseOption;
public ScoreCommandTests()
{
var serviceCollection = new ServiceCollection();
serviceCollection.AddSingleton<ILoggerFactory>(NullLoggerFactory.Instance);
_services = serviceCollection.BuildServiceProvider();
_options = new StellaOpsCliOptions
{
PolicyGateway = new StellaOpsCliPolicyGatewayOptions
{
BaseUrl = "http://localhost:5080"
}
};
_verboseOption = new Option<bool>("--verbose", "-v") { Description = "Enable verbose output" };
}
#region Command Structure
[Fact]
public void BuildScoreCommand_CreatesTopLevelScoreCommand()
{
var command = ScoreCommandGroup.BuildScoreCommand(
_services, _options, _verboseOption, CancellationToken.None);
Assert.Equal("score", command.Name);
Assert.Contains("scoring", command.Description, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void BuildScoreCommand_HasComputeSubcommand()
{
var command = ScoreCommandGroup.BuildScoreCommand(
_services, _options, _verboseOption, CancellationToken.None);
var compute = command.Subcommands.FirstOrDefault(c => c.Name == "compute");
Assert.NotNull(compute);
}
[Fact]
public void BuildScoreCommand_HasExplainSubcommand()
{
var command = ScoreCommandGroup.BuildScoreCommand(
_services, _options, _verboseOption, CancellationToken.None);
var explain = command.Subcommands.FirstOrDefault(c => c.Name == "explain");
Assert.NotNull(explain);
}
[Fact]
public void BuildScoreCommand_HasReplaySubcommand()
{
var command = ScoreCommandGroup.BuildScoreCommand(
_services, _options, _verboseOption, CancellationToken.None);
var replay = command.Subcommands.FirstOrDefault(c => c.Name == "replay");
Assert.NotNull(replay);
}
[Fact]
public void BuildScoreCommand_HasVerifySubcommand()
{
var command = ScoreCommandGroup.BuildScoreCommand(
_services, _options, _verboseOption, CancellationToken.None);
var verify = command.Subcommands.FirstOrDefault(c => c.Name == "verify");
Assert.NotNull(verify);
}
#endregion
#region Compute Command Options
[Fact]
public void ComputeCommand_HasExpectedSignalOptions()
{
var command = ScoreCommandGroup.BuildScoreCommand(
_services, _options, _verboseOption, CancellationToken.None);
var compute = command.Subcommands.First(c => c.Name == "compute");
var optionNames = compute.Options.Select(o => o.Name).ToList();
Assert.Contains("--reachability", optionNames);
Assert.Contains("--runtime", optionNames);
Assert.Contains("--backport", optionNames);
Assert.Contains("--exploit", optionNames);
Assert.Contains("--source", optionNames);
Assert.Contains("--mitigation", optionNames);
}
[Fact]
public void ComputeCommand_HasIdentificationOptions()
{
var command = ScoreCommandGroup.BuildScoreCommand(
_services, _options, _verboseOption, CancellationToken.None);
var compute = command.Subcommands.First(c => c.Name == "compute");
var optionNames = compute.Options.Select(o => o.Name).ToList();
Assert.Contains("--cve", optionNames);
Assert.Contains("--purl", optionNames);
}
[Fact]
public void ComputeCommand_HasOutputOption()
{
var command = ScoreCommandGroup.BuildScoreCommand(
_services, _options, _verboseOption, CancellationToken.None);
var compute = command.Subcommands.First(c => c.Name == "compute");
var optionNames = compute.Options.Select(o => o.Name).ToList();
Assert.Contains("--output", optionNames);
}
[Fact]
public void ComputeCommand_HasAtLeastExpectedOptionCount()
{
var command = ScoreCommandGroup.BuildScoreCommand(
_services, _options, _verboseOption, CancellationToken.None);
var compute = command.Subcommands.First(c => c.Name == "compute");
// reachability, runtime, backport, exploit, source, mitigation,
// cve, purl, weights-version, breakdown, deltas, offline, output, timeout, verbose
Assert.True(compute.Options.Count >= 10,
$"Expected at least 10 options, got {compute.Options.Count}: [{string.Join(", ", compute.Options.Select(o => o.Name))}]");
}
#endregion
#region Explain Command
[Fact]
public void ExplainCommand_HasScoreIdArgument()
{
var command = ScoreCommandGroup.BuildScoreCommand(
_services, _options, _verboseOption, CancellationToken.None);
var explain = command.Subcommands.First(c => c.Name == "explain");
Assert.True(explain.Arguments.Count > 0 || explain.Options.Any(o =>
o.Name == "score-id" || o.Name == "finding-id" || o.Name == "id"));
}
#endregion
#region Replay Command
[Fact]
public void ReplayCommand_HasScoreIdArgument()
{
var command = ScoreCommandGroup.BuildScoreCommand(
_services, _options, _verboseOption, CancellationToken.None);
var replay = command.Subcommands.First(c => c.Name == "replay");
Assert.True(replay.Arguments.Count > 0 || replay.Options.Any(o =>
o.Name == "score-id" || o.Name == "id"));
}
#endregion
#region Verify Command
[Fact]
public void VerifyCommand_HasScoreIdArgument()
{
var command = ScoreCommandGroup.BuildScoreCommand(
_services, _options, _verboseOption, CancellationToken.None);
var verify = command.Subcommands.First(c => c.Name == "verify");
Assert.True(verify.Arguments.Count > 0 || verify.Options.Any(o =>
o.Name == "score-id" || o.Name == "id"));
}
#endregion
}

View File

@@ -1,8 +1,8 @@
// -----------------------------------------------------------------------------
// ScoreGateCommandTests.cs
// Sprint: SPRINT_20260118_030_LIB_verdict_rekor_gate_api
// Task: TASK-030-008 - CLI Gate Command
// Description: Unit tests for score-based gate CLI commands
// Sprint: SPRINT_20260122_037_Signals_unified_trust_score_algebra
// Task: TSF-006 - CLI `stella gate score` Enhancement
// Description: Unit tests for score-based gate CLI commands with unified scoring
// -----------------------------------------------------------------------------
using System.CommandLine;
@@ -394,6 +394,174 @@ public class ScoreGateCommandTests
#endregion
#region TSF-006: Unified Score Options Tests
[Fact]
public void EvaluateCommand_HasShowUnknownsOption()
{
// Arrange
var command = ScoreGateCommandGroup.BuildScoreCommand(
_services, _options, _verboseOption, CancellationToken.None);
var evaluateCommand = command.Subcommands.First(c => c.Name == "evaluate");
// Act
var showUnknownsOption = evaluateCommand.Options.FirstOrDefault(o =>
o.Aliases.Contains("--show-unknowns"));
// Assert
Assert.NotNull(showUnknownsOption);
Assert.Contains("unknowns", showUnknownsOption.Description, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void EvaluateCommand_HasShowDeltasOption()
{
// Arrange
var command = ScoreGateCommandGroup.BuildScoreCommand(
_services, _options, _verboseOption, CancellationToken.None);
var evaluateCommand = command.Subcommands.First(c => c.Name == "evaluate");
// Act
var showDeltasOption = evaluateCommand.Options.FirstOrDefault(o =>
o.Aliases.Contains("--show-deltas"));
// Assert
Assert.NotNull(showDeltasOption);
Assert.Contains("delta", showDeltasOption.Description, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void EvaluateCommand_HasWeightsVersionOption()
{
// Arrange
var command = ScoreGateCommandGroup.BuildScoreCommand(
_services, _options, _verboseOption, CancellationToken.None);
var evaluateCommand = command.Subcommands.First(c => c.Name == "evaluate");
// Act
var weightsVersionOption = evaluateCommand.Options.FirstOrDefault(o =>
o.Aliases.Contains("--weights-version"));
// Assert
Assert.NotNull(weightsVersionOption);
Assert.Contains("manifest", weightsVersionOption.Description, StringComparison.OrdinalIgnoreCase);
}
#endregion
#region TSF-006: Weights Subcommand Tests
[Fact]
public void BuildScoreCommand_HasWeightsSubcommand()
{
// Act
var command = ScoreGateCommandGroup.BuildScoreCommand(
_services, _options, _verboseOption, CancellationToken.None);
var weightsCommand = command.Subcommands.FirstOrDefault(c => c.Name == "weights");
// Assert
Assert.NotNull(weightsCommand);
Assert.Contains("weight", weightsCommand.Description, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void WeightsCommand_HasListSubcommand()
{
// Arrange
var command = ScoreGateCommandGroup.BuildScoreCommand(
_services, _options, _verboseOption, CancellationToken.None);
var weightsCommand = command.Subcommands.First(c => c.Name == "weights");
// Act
var listCommand = weightsCommand.Subcommands.FirstOrDefault(c => c.Name == "list");
// Assert
Assert.NotNull(listCommand);
Assert.Contains("List", listCommand.Description);
}
[Fact]
public void WeightsCommand_HasShowSubcommand()
{
// Arrange
var command = ScoreGateCommandGroup.BuildScoreCommand(
_services, _options, _verboseOption, CancellationToken.None);
var weightsCommand = command.Subcommands.First(c => c.Name == "weights");
// Act
var showCommand = weightsCommand.Subcommands.FirstOrDefault(c => c.Name == "show");
// Assert
Assert.NotNull(showCommand);
Assert.Contains("Display", showCommand.Description);
}
[Fact]
public void WeightsCommand_HasDiffSubcommand()
{
// Arrange
var command = ScoreGateCommandGroup.BuildScoreCommand(
_services, _options, _verboseOption, CancellationToken.None);
var weightsCommand = command.Subcommands.First(c => c.Name == "weights");
// Act
var diffCommand = weightsCommand.Subcommands.FirstOrDefault(c => c.Name == "diff");
// Assert
Assert.NotNull(diffCommand);
Assert.Contains("Compare", diffCommand.Description);
}
[Fact]
public void WeightsShowCommand_HasVersionArgument()
{
// Arrange
var command = ScoreGateCommandGroup.BuildScoreCommand(
_services, _options, _verboseOption, CancellationToken.None);
var weightsCommand = command.Subcommands.First(c => c.Name == "weights");
var showCommand = weightsCommand.Subcommands.First(c => c.Name == "show");
// Act
var versionArg = showCommand.Arguments.FirstOrDefault(a => a.Name == "version");
// Assert
Assert.NotNull(versionArg);
}
[Fact]
public void WeightsDiffCommand_HasTwoVersionArguments()
{
// Arrange
var command = ScoreGateCommandGroup.BuildScoreCommand(
_services, _options, _verboseOption, CancellationToken.None);
var weightsCommand = command.Subcommands.First(c => c.Name == "weights");
var diffCommand = weightsCommand.Subcommands.First(c => c.Name == "diff");
// Act & Assert
Assert.Equal(2, diffCommand.Arguments.Count);
Assert.Contains(diffCommand.Arguments, a => a.Name == "version1");
Assert.Contains(diffCommand.Arguments, a => a.Name == "version2");
}
[Fact]
public void WeightsListCommand_HasOutputOption()
{
// Arrange
var command = ScoreGateCommandGroup.BuildScoreCommand(
_services, _options, _verboseOption, CancellationToken.None);
var weightsCommand = command.Subcommands.First(c => c.Name == "weights");
var listCommand = weightsCommand.Subcommands.First(c => c.Name == "list");
// Act
var outputOption = listCommand.Options.FirstOrDefault(o =>
o.Aliases.Contains("--output") || o.Aliases.Contains("-o"));
// Assert
Assert.NotNull(outputOption);
}
#endregion
#region Integration with Gate Command Tests
[Fact]

View File

@@ -282,6 +282,69 @@ public class WitnessCommandGroupTests
Assert.NotNull(reachableOption);
}
/// <summary>
/// EBPF-003: Test for --probe-type option.
/// Sprint: SPRINT_20260122_038_Scanner_ebpf_probe_type
/// </summary>
[Fact]
public void ListCommand_HasProbeTypeOption()
{
// Arrange
var command = WitnessCommandGroup.BuildWitnessCommand(_services, _verboseOption, _cancellationToken);
var listCommand = command.Subcommands.First(c => c.Name == "list");
// Act
var probeTypeOption = listCommand.Options.FirstOrDefault(o =>
o.Aliases.Contains("--probe-type") || o.Aliases.Contains("-p"));
// Assert
Assert.NotNull(probeTypeOption);
}
/// <summary>
/// EBPF-003: Test for --probe-type option with valid values.
/// Sprint: SPRINT_20260122_038_Scanner_ebpf_probe_type
/// </summary>
[Theory]
[InlineData("kprobe")]
[InlineData("kretprobe")]
[InlineData("uprobe")]
[InlineData("uretprobe")]
[InlineData("tracepoint")]
[InlineData("usdt")]
[InlineData("fentry")]
[InlineData("fexit")]
public void ListCommand_ProbeTypeOption_AcceptsValidProbeTypes(string probeType)
{
// Arrange
var command = WitnessCommandGroup.BuildWitnessCommand(_services, _verboseOption, _cancellationToken);
var listCommand = command.Subcommands.First(c => c.Name == "list");
// Act
var parseResult = listCommand.Parse($"--scan scan-123 --probe-type {probeType}");
// Assert
Assert.Empty(parseResult.Errors);
}
/// <summary>
/// EBPF-003: Test for --probe-type option rejecting invalid values.
/// Sprint: SPRINT_20260122_038_Scanner_ebpf_probe_type
/// </summary>
[Fact]
public void ListCommand_ProbeTypeOption_RejectsInvalidProbeType()
{
// Arrange
var command = WitnessCommandGroup.BuildWitnessCommand(_services, _verboseOption, _cancellationToken);
var listCommand = command.Subcommands.First(c => c.Name == "list");
// Act
var parseResult = listCommand.Parse("--scan scan-123 --probe-type invalid_probe");
// Assert
Assert.NotEmpty(parseResult.Errors);
}
#endregion
#region Export Command Tests

View File

@@ -40,6 +40,9 @@
<ItemGroup>
<ProjectReference Include="../../StellaOps.Cli/StellaOps.Cli.csproj" />
<ProjectReference Include="../../../Attestor/__Libraries/StellaOps.Attestor.Oci/StellaOps.Attestor.Oci.csproj" />
<ProjectReference Include="../../../Attestor/StellaOps.Attestor/StellaOps.Attestor.Core/StellaOps.Attestor.Core.csproj" />
<ProjectReference Include="../../../Attestor/StellaOps.Attestor.Envelope/StellaOps.Attestor.Envelope.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.Doctor/StellaOps.Doctor.csproj" />
<ProjectReference Include="../../../Scanner/__Libraries/StellaOps.Scanner.Storage.Oci/StellaOps.Scanner.Storage.Oci.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Cli.Plugins.Aoc/StellaOps.Cli.Plugins.Aoc.csproj" />