finish off sprint advisories and sprints
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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("✗");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user