Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
- Added RuntimeFactsNdjsonReader for reading NDJSON formatted runtime facts. - Introduced IRuntimeFactsIngestionService interface and its implementation. - Enhanced Program.cs to register new services and endpoints for runtime facts. - Updated CallgraphIngestionService to include CAS URI in stored artifacts. - Created RuntimeFactsValidationException for validation errors during ingestion. - Added tests for RuntimeFactsIngestionService and RuntimeFactsNdjsonReader. - Implemented SignalsSealedModeMonitor for compliance checks in sealed mode. - Updated project dependencies for testing utilities.
4010 lines
151 KiB
C#
4010 lines
151 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Collections.Immutable;
|
|
using System.Collections.ObjectModel;
|
|
using System.IO;
|
|
using System.IO.Compression;
|
|
using System.Linq;
|
|
using System.Net;
|
|
using System.Net.Http;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Nodes;
|
|
using System.Text.Json.Serialization;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using System.Globalization;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.IdentityModel.Tokens;
|
|
using StellaOps.Auth.Abstractions;
|
|
using StellaOps.Auth.Client;
|
|
using StellaOps.Cli.Commands;
|
|
using StellaOps.Cli.Configuration;
|
|
using StellaOps.Cli.Services;
|
|
using StellaOps.Cli.Services.Models;
|
|
using StellaOps.Cli.Services.Models.AdvisoryAi;
|
|
using StellaOps.Cli.Services.Models.Ruby;
|
|
using StellaOps.Cli.Telemetry;
|
|
using StellaOps.Cli.Tests.Testing;
|
|
using StellaOps.Cryptography;
|
|
using StellaOps.Cryptography.Kms;
|
|
using StellaOps.Scanner.EntryTrace;
|
|
using Spectre.Console;
|
|
using Spectre.Console.Testing;
|
|
|
|
namespace StellaOps.Cli.Tests.Commands;
|
|
|
|
public sealed class CommandHandlersTests
|
|
{
|
|
[Fact]
|
|
public async Task HandleExportJobAsync_SetsExitCodeZeroOnSuccess()
|
|
{
|
|
var original = Environment.ExitCode;
|
|
try
|
|
{
|
|
var backend = new StubBackendClient(new JobTriggerResult(true, "Accepted", "/jobs/export:json/1", null));
|
|
var provider = BuildServiceProvider(backend);
|
|
|
|
await CommandHandlers.HandleExportJobAsync(
|
|
provider,
|
|
format: "json",
|
|
delta: false,
|
|
publishFull: null,
|
|
publishDelta: null,
|
|
includeFull: null,
|
|
includeDelta: null,
|
|
verbose: false,
|
|
cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Equal(0, Environment.ExitCode);
|
|
Assert.Equal("export:json", backend.LastJobKind);
|
|
}
|
|
finally
|
|
{
|
|
Environment.ExitCode = original;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HandleMergeJobAsync_SetsExitCodeOnFailure()
|
|
{
|
|
var original = Environment.ExitCode;
|
|
try
|
|
{
|
|
var backend = new StubBackendClient(new JobTriggerResult(false, "Job already running", null, null));
|
|
var provider = BuildServiceProvider(backend);
|
|
|
|
await CommandHandlers.HandleMergeJobAsync(provider, verbose: false, CancellationToken.None);
|
|
|
|
Assert.Equal(1, Environment.ExitCode);
|
|
Assert.Equal("merge:reconcile", backend.LastJobKind);
|
|
}
|
|
finally
|
|
{
|
|
Environment.ExitCode = original;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HandleScannerRunAsync_AutomaticallyUploadsResults()
|
|
{
|
|
using var tempDir = new TempDirectory();
|
|
var resultsFile = Path.Combine(tempDir.Path, "results", "scan.json");
|
|
var backend = new StubBackendClient(new JobTriggerResult(true, "Accepted", null, null));
|
|
var metadataFile = Path.Combine(tempDir.Path, "results", "scan-run.json");
|
|
var executor = new StubExecutor(new ScannerExecutionResult(0, resultsFile, metadataFile));
|
|
var options = new StellaOpsCliOptions
|
|
{
|
|
ResultsDirectory = Path.Combine(tempDir.Path, "results")
|
|
};
|
|
|
|
var provider = BuildServiceProvider(backend, executor, new StubInstaller(), options);
|
|
|
|
Directory.CreateDirectory(Path.Combine(tempDir.Path, "target"));
|
|
|
|
var original = Environment.ExitCode;
|
|
try
|
|
{
|
|
await CommandHandlers.HandleScannerRunAsync(
|
|
provider,
|
|
runner: "docker",
|
|
entry: "scanner-image",
|
|
targetDirectory: Path.Combine(tempDir.Path, "target"),
|
|
arguments: Array.Empty<string>(),
|
|
verbose: false,
|
|
cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Equal(0, Environment.ExitCode);
|
|
Assert.Equal(resultsFile, backend.LastUploadPath);
|
|
Assert.True(File.Exists(metadataFile));
|
|
}
|
|
finally
|
|
{
|
|
Environment.ExitCode = original;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HandleScanEntryTraceAsync_RendersPlansAndNdjson()
|
|
{
|
|
var originalExit = Environment.ExitCode;
|
|
var console = new TestConsole();
|
|
var originalConsole = AnsiConsole.Console;
|
|
|
|
var graph = new EntryTraceGraph(
|
|
EntryTraceOutcome.Resolved,
|
|
ImmutableArray<EntryTraceNode>.Empty,
|
|
ImmutableArray<EntryTraceEdge>.Empty,
|
|
ImmutableArray<EntryTraceDiagnostic>.Empty,
|
|
ImmutableArray.Create(new EntryTracePlan(
|
|
ImmutableArray.Create("/usr/bin/python", "app.py"),
|
|
ImmutableDictionary<string, string>.Empty,
|
|
"/workspace",
|
|
"appuser",
|
|
"/usr/bin/python",
|
|
EntryTraceTerminalType.Managed,
|
|
"python",
|
|
0.95,
|
|
ImmutableDictionary<string, string>.Empty)),
|
|
ImmutableArray.Create(new EntryTraceTerminal(
|
|
"/usr/bin/python",
|
|
EntryTraceTerminalType.Managed,
|
|
"python",
|
|
0.95,
|
|
ImmutableDictionary<string, string>.Empty,
|
|
"appuser",
|
|
"/workspace",
|
|
ImmutableArray<string>.Empty)));
|
|
|
|
var backend = new StubBackendClient(new JobTriggerResult(true, "Accepted", null, null))
|
|
{
|
|
EntryTraceResponse = new EntryTraceResponseModel(
|
|
"scan-123",
|
|
"sha256:deadbeef",
|
|
DateTimeOffset.Parse("2025-11-02T12:00:00Z", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal),
|
|
graph,
|
|
new[] { "{\"type\":\"terminal\"}" })
|
|
};
|
|
|
|
var provider = BuildServiceProvider(backend);
|
|
AnsiConsole.Console = console;
|
|
|
|
try
|
|
{
|
|
await CommandHandlers.HandleScanEntryTraceAsync(
|
|
provider,
|
|
"scan-123",
|
|
includeNdjson: true,
|
|
verbose: false,
|
|
cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Equal(0, Environment.ExitCode);
|
|
Assert.Equal("scan-123", backend.LastEntryTraceScanId);
|
|
|
|
var output = console.Output;
|
|
Assert.Contains("scan-123", output, StringComparison.OrdinalIgnoreCase);
|
|
Assert.Contains("NDJSON Output", output, StringComparison.OrdinalIgnoreCase);
|
|
Assert.Contains("{\"type\":\"terminal\"}", output, StringComparison.Ordinal);
|
|
Assert.Contains("/usr/bin/python", output, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
finally
|
|
{
|
|
Environment.ExitCode = originalExit;
|
|
AnsiConsole.Console = originalConsole;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HandleScanEntryTraceAsync_WarnsWhenResultMissing()
|
|
{
|
|
var originalExit = Environment.ExitCode;
|
|
var backend = new StubBackendClient(new JobTriggerResult(true, "Accepted", null, null));
|
|
var loggerProvider = new TestLoggerProvider();
|
|
var provider = BuildServiceProvider(backend, loggerProvider: loggerProvider);
|
|
|
|
try
|
|
{
|
|
var output = await CaptureTestConsoleAsync(console => CommandHandlers.HandleScanEntryTraceAsync(
|
|
provider,
|
|
"scan-missing",
|
|
includeNdjson: false,
|
|
verbose: false,
|
|
cancellationToken: CancellationToken.None));
|
|
|
|
Assert.Equal(1, Environment.ExitCode);
|
|
Assert.Equal("scan-missing", backend.LastEntryTraceScanId);
|
|
Assert.Contains("No EntryTrace data", output.Combined, StringComparison.OrdinalIgnoreCase);
|
|
|
|
var warning = Assert.Single(loggerProvider.Entries.Where(entry => entry.Level == LogLevel.Warning));
|
|
Assert.Contains("No EntryTrace data", warning.Message, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
finally
|
|
{
|
|
Environment.ExitCode = originalExit;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HandleNodeLockValidateAsync_RendersDeclaredOnlyAndMissingLock()
|
|
{
|
|
var originalExit = Environment.ExitCode;
|
|
using var fixture = new TempDirectory();
|
|
|
|
try
|
|
{
|
|
CreateNodeLockFixture(fixture.Path);
|
|
var provider = BuildServiceProvider(new StubBackendClient(new JobTriggerResult(true, "ok", null, null)));
|
|
|
|
var output = await CaptureTestConsoleAsync(async _ =>
|
|
{
|
|
await CommandHandlers.HandleNodeLockValidateAsync(
|
|
provider,
|
|
fixture.Path,
|
|
"json",
|
|
verbose: false,
|
|
cancellationToken: CancellationToken.None);
|
|
});
|
|
|
|
Assert.Equal(1, Environment.ExitCode);
|
|
|
|
using var document = JsonDocument.Parse(output.PlainBuffer);
|
|
var root = document.RootElement;
|
|
var declared = root.GetProperty("declaredOnly");
|
|
var missing = root.GetProperty("missingLockMetadata");
|
|
|
|
Assert.Contains(declared.EnumerateArray(), entry =>
|
|
string.Equals(entry.GetProperty("name").GetString(), "declared-only", StringComparison.OrdinalIgnoreCase));
|
|
|
|
Assert.Contains(missing.EnumerateArray(), entry =>
|
|
string.Equals(entry.GetProperty("name").GetString(), "runtime-only", StringComparison.OrdinalIgnoreCase));
|
|
}
|
|
finally
|
|
{
|
|
Environment.ExitCode = originalExit;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HandleNodeLockValidateAsync_SetsExitCodeWhenDirectoryMissing()
|
|
{
|
|
var originalExit = Environment.ExitCode;
|
|
var provider = BuildServiceProvider(new StubBackendClient(new JobTriggerResult(true, "ok", null, null)));
|
|
var missingPath = Path.Combine(Path.GetTempPath(), $"stellaops-missing-{Guid.NewGuid():N}");
|
|
|
|
try
|
|
{
|
|
await CaptureTestConsoleAsync(async _ =>
|
|
{
|
|
await CommandHandlers.HandleNodeLockValidateAsync(
|
|
provider,
|
|
missingPath,
|
|
"json",
|
|
verbose: false,
|
|
cancellationToken: CancellationToken.None);
|
|
});
|
|
|
|
Assert.Equal(71, Environment.ExitCode);
|
|
}
|
|
finally
|
|
{
|
|
Environment.ExitCode = originalExit;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HandlePythonLockValidateAsync_RendersDeclaredOnlyAndMissingLock()
|
|
{
|
|
var originalExit = Environment.ExitCode;
|
|
using var fixture = new TempDirectory();
|
|
|
|
try
|
|
{
|
|
await CreatePythonLockFixtureAsync(fixture.Path, CancellationToken.None);
|
|
var provider = BuildServiceProvider(new StubBackendClient(new JobTriggerResult(true, "ok", null, null)));
|
|
|
|
var output = await CaptureTestConsoleAsync(async _ =>
|
|
{
|
|
await CommandHandlers.HandlePythonLockValidateAsync(
|
|
provider,
|
|
fixture.Path,
|
|
"json",
|
|
verbose: false,
|
|
cancellationToken: CancellationToken.None);
|
|
});
|
|
|
|
Assert.Equal(1, Environment.ExitCode);
|
|
|
|
using var document = JsonDocument.Parse(output.PlainBuffer);
|
|
var root = document.RootElement;
|
|
var declared = root.GetProperty("declaredOnly");
|
|
var missing = root.GetProperty("missingLockMetadata");
|
|
|
|
Assert.Contains(declared.EnumerateArray(), entry =>
|
|
string.Equals(entry.GetProperty("name").GetString(), "declared-only", StringComparison.OrdinalIgnoreCase));
|
|
|
|
Assert.Contains(missing.EnumerateArray(), entry =>
|
|
string.Equals(entry.GetProperty("name").GetString(), "runtime-only", StringComparison.OrdinalIgnoreCase));
|
|
}
|
|
finally
|
|
{
|
|
Environment.ExitCode = originalExit;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HandlePythonLockValidateAsync_SetsExitCodeWhenDirectoryMissing()
|
|
{
|
|
var originalExit = Environment.ExitCode;
|
|
var provider = BuildServiceProvider(new StubBackendClient(new JobTriggerResult(true, "ok", null, null)));
|
|
var missingPath = Path.Combine(Path.GetTempPath(), $"stellaops-missing-{Guid.NewGuid():N}");
|
|
|
|
try
|
|
{
|
|
await CaptureTestConsoleAsync(async _ =>
|
|
{
|
|
await CommandHandlers.HandlePythonLockValidateAsync(
|
|
provider,
|
|
missingPath,
|
|
"json",
|
|
verbose: false,
|
|
cancellationToken: CancellationToken.None);
|
|
});
|
|
|
|
Assert.Equal(71, Environment.ExitCode);
|
|
}
|
|
finally
|
|
{
|
|
Environment.ExitCode = originalExit;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HandleJavaLockValidateAsync_RendersDeclaredOnlyAndMissingLock()
|
|
{
|
|
var originalExit = Environment.ExitCode;
|
|
using var fixture = new TempDirectory();
|
|
|
|
try
|
|
{
|
|
CreateJavaLockFixture(fixture.Path);
|
|
var provider = BuildServiceProvider(new StubBackendClient(new JobTriggerResult(true, "ok", null, null)));
|
|
|
|
var output = await CaptureTestConsoleAsync(async _ =>
|
|
{
|
|
await CommandHandlers.HandleJavaLockValidateAsync(
|
|
provider,
|
|
fixture.Path,
|
|
"json",
|
|
verbose: false,
|
|
cancellationToken: CancellationToken.None);
|
|
});
|
|
|
|
Assert.Equal(1, Environment.ExitCode);
|
|
|
|
using var document = JsonDocument.Parse(output.PlainBuffer);
|
|
var root = document.RootElement;
|
|
var declared = root.GetProperty("declaredOnly");
|
|
var missing = root.GetProperty("missingLockMetadata");
|
|
|
|
Assert.Contains(declared.EnumerateArray(), entry =>
|
|
string.Equals(entry.GetProperty("name").GetString(), "declared-only", StringComparison.OrdinalIgnoreCase));
|
|
|
|
Assert.Contains(missing.EnumerateArray(), entry =>
|
|
string.Equals(entry.GetProperty("name").GetString(), "runtime-only", StringComparison.OrdinalIgnoreCase));
|
|
}
|
|
finally
|
|
{
|
|
Environment.ExitCode = originalExit;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HandleJavaLockValidateAsync_SetsExitCodeWhenDirectoryMissing()
|
|
{
|
|
var originalExit = Environment.ExitCode;
|
|
var provider = BuildServiceProvider(new StubBackendClient(new JobTriggerResult(true, "ok", null, null)));
|
|
var missingPath = Path.Combine(Path.GetTempPath(), $"stellaops-missing-{Guid.NewGuid():N}");
|
|
|
|
try
|
|
{
|
|
await CaptureTestConsoleAsync(async _ =>
|
|
{
|
|
await CommandHandlers.HandleJavaLockValidateAsync(
|
|
provider,
|
|
missingPath,
|
|
"json",
|
|
verbose: false,
|
|
cancellationToken: CancellationToken.None);
|
|
});
|
|
|
|
Assert.Equal(71, Environment.ExitCode);
|
|
}
|
|
finally
|
|
{
|
|
Environment.ExitCode = originalExit;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HandleRubyInspectAsync_RendersPackagesAndRuntime()
|
|
{
|
|
var originalExit = Environment.ExitCode;
|
|
using var fixture = new TempDirectory();
|
|
CreateRubyWorkspace(fixture.Path);
|
|
var provider = BuildServiceProvider(new StubBackendClient(new JobTriggerResult(true, "ok", null, null)));
|
|
|
|
try
|
|
{
|
|
var output = await CaptureTestConsoleAsync(async _ =>
|
|
{
|
|
await CommandHandlers.HandleRubyInspectAsync(
|
|
provider,
|
|
fixture.Path,
|
|
"json",
|
|
verbose: false,
|
|
cancellationToken: CancellationToken.None);
|
|
});
|
|
|
|
Assert.Equal(0, Environment.ExitCode);
|
|
|
|
using var document = JsonDocument.Parse(output.PlainBuffer);
|
|
var packages = document.RootElement.GetProperty("packages");
|
|
|
|
Assert.Contains(packages.EnumerateArray(), entry =>
|
|
string.Equals(entry.GetProperty("name").GetString(), "rack", StringComparison.OrdinalIgnoreCase)
|
|
&& string.Equals(entry.GetProperty("lockfile").GetString(), "Gemfile.lock", StringComparison.OrdinalIgnoreCase));
|
|
|
|
Assert.Contains(packages.EnumerateArray(), entry =>
|
|
string.Equals(entry.GetProperty("name").GetString(), "zeitwerk", StringComparison.OrdinalIgnoreCase)
|
|
&& entry.GetProperty("runtimeFiles").EnumerateArray().Any());
|
|
}
|
|
finally
|
|
{
|
|
Environment.ExitCode = originalExit;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HandleRubyInspectAsync_WritesJson()
|
|
{
|
|
var originalExit = Environment.ExitCode;
|
|
using var fixture = new TempDirectory();
|
|
CreateRubyWorkspace(fixture.Path);
|
|
var provider = BuildServiceProvider(new StubBackendClient(new JobTriggerResult(true, "ok", null, null)));
|
|
|
|
try
|
|
{
|
|
var output = await CaptureTestConsoleAsync(async _ =>
|
|
{
|
|
await CommandHandlers.HandleRubyInspectAsync(
|
|
provider,
|
|
fixture.Path,
|
|
"json",
|
|
verbose: false,
|
|
cancellationToken: CancellationToken.None);
|
|
});
|
|
|
|
Assert.Equal(0, Environment.ExitCode);
|
|
using var document = JsonDocument.Parse(output.PlainBuffer);
|
|
var packages = document.RootElement.GetProperty("packages");
|
|
Assert.NotEmpty(packages.EnumerateArray());
|
|
|
|
var entry = packages.EnumerateArray().First(p => string.Equals(p.GetProperty("name").GetString(), "rack", StringComparison.OrdinalIgnoreCase));
|
|
Assert.True(entry.GetProperty("usedByEntrypoint").GetBoolean());
|
|
Assert.Contains(
|
|
"app.rb",
|
|
entry.GetProperty("runtimeEntrypoints").EnumerateArray().Select(e => e.GetString() ?? string.Empty),
|
|
StringComparer.OrdinalIgnoreCase);
|
|
}
|
|
finally
|
|
{
|
|
Environment.ExitCode = originalExit;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HandleRubyResolveAsync_RendersGroupedPackages()
|
|
{
|
|
var originalExit = Environment.ExitCode;
|
|
var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null))
|
|
{
|
|
RubyPackages = new[]
|
|
{
|
|
CreateRubyPackageArtifact("pkg-rack", "rack", "3.1.0", new[] { "default", "web" }, runtimeUsed: true),
|
|
CreateRubyPackageArtifact("pkg-sidekiq", "sidekiq", "7.2.1", groups: null, runtimeUsed: false, metadataOverrides: new Dictionary<string, string?>
|
|
{
|
|
["groups"] = "jobs",
|
|
["runtime.entrypoints"] = "config/jobs.rb",
|
|
["runtime.files"] = "config/jobs.rb"
|
|
})
|
|
}
|
|
};
|
|
var provider = BuildServiceProvider(backend);
|
|
|
|
try
|
|
{
|
|
var output = await CaptureTestConsoleAsync(async _ =>
|
|
{
|
|
await CommandHandlers.HandleRubyResolveAsync(
|
|
provider,
|
|
imageReference: null,
|
|
scanId: "scan-ruby",
|
|
format: "table",
|
|
verbose: false,
|
|
cancellationToken: CancellationToken.None);
|
|
});
|
|
|
|
Assert.Equal(0, Environment.ExitCode);
|
|
Assert.Equal("scan-ruby", backend.LastRubyPackagesScanId);
|
|
Assert.Contains("scan-ruby", output.Combined, StringComparison.OrdinalIgnoreCase);
|
|
Assert.Contains("rack", output.Combined, StringComparison.OrdinalIgnoreCase);
|
|
Assert.Contains("jobs", output.Combined, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
finally
|
|
{
|
|
Environment.ExitCode = originalExit;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HandleRubyResolveAsync_WritesJson()
|
|
{
|
|
var originalExit = Environment.ExitCode;
|
|
var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null))
|
|
{
|
|
RubyPackages = new[]
|
|
{
|
|
CreateRubyPackageArtifact("pkg-rack-json", "rack", "3.1.0", new[] { "default" }, runtimeUsed: true)
|
|
}
|
|
};
|
|
var provider = BuildServiceProvider(backend);
|
|
const string identifier = "ruby-scan-json";
|
|
|
|
try
|
|
{
|
|
var output = await CaptureTestConsoleAsync(async _ =>
|
|
{
|
|
await CommandHandlers.HandleRubyResolveAsync(
|
|
provider,
|
|
imageReference: identifier,
|
|
scanId: null,
|
|
format: "json",
|
|
verbose: false,
|
|
cancellationToken: CancellationToken.None);
|
|
});
|
|
|
|
Assert.Equal(0, Environment.ExitCode);
|
|
Assert.Equal(identifier, backend.LastRubyPackagesScanId);
|
|
|
|
using var document = JsonDocument.Parse(output.PlainBuffer);
|
|
Assert.Equal(identifier, document.RootElement.GetProperty("scanId").GetString());
|
|
|
|
var group = document.RootElement.GetProperty("groups")[0];
|
|
Assert.Equal("default", group.GetProperty("group").GetString());
|
|
Assert.Equal("-", group.GetProperty("platform").GetString());
|
|
var package = group.GetProperty("packages")[0];
|
|
Assert.Equal("rubygems", package.GetProperty("source").GetString());
|
|
Assert.Equal("Gemfile.lock", package.GetProperty("lockfile").GetString());
|
|
var packageGroups = package.GetProperty("groups")
|
|
.EnumerateArray()
|
|
.Select(static p => p.GetString())
|
|
.Where(static g => !string.IsNullOrEmpty(g))
|
|
.Select(static g => g!)
|
|
.ToArray();
|
|
Assert.Contains("default", packageGroups, StringComparer.OrdinalIgnoreCase);
|
|
Assert.True(package.GetProperty("runtimeUsed").GetBoolean());
|
|
Assert.Contains("app.rb", package.GetProperty("runtimeEntrypoints")[0].GetString(), StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
finally
|
|
{
|
|
Environment.ExitCode = originalExit;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HandleAdviseRunAsync_WritesOutputAndSetsExitCode()
|
|
{
|
|
var originalExit = Environment.ExitCode;
|
|
var originalConsole = AnsiConsole.Console;
|
|
var testConsole = new TestConsole();
|
|
|
|
try
|
|
{
|
|
Environment.ExitCode = 0;
|
|
AnsiConsole.Console = testConsole;
|
|
|
|
var planResponse = new AdvisoryPipelinePlanResponseModel
|
|
{
|
|
TaskType = AdvisoryAiTaskType.Summary.ToString(),
|
|
CacheKey = "cache-123",
|
|
PromptTemplate = "prompts/advisory/summary.liquid",
|
|
Budget = new AdvisoryTaskBudgetModel
|
|
{
|
|
PromptTokens = 512,
|
|
CompletionTokens = 128
|
|
},
|
|
Chunks = new[]
|
|
{
|
|
new PipelineChunkSummaryModel
|
|
{
|
|
DocumentId = "doc-1",
|
|
ChunkId = "chunk-1",
|
|
Section = "Summary",
|
|
DisplaySection = "Summary"
|
|
}
|
|
},
|
|
Vectors = new[]
|
|
{
|
|
new PipelineVectorSummaryModel
|
|
{
|
|
Query = "summary query",
|
|
Matches = new[]
|
|
{
|
|
new PipelineVectorMatchSummaryModel
|
|
{
|
|
ChunkId = "chunk-1",
|
|
Score = 0.9
|
|
}
|
|
}
|
|
}
|
|
},
|
|
Metadata = new Dictionary<string, string>
|
|
{
|
|
["profile"] = "default"
|
|
}
|
|
};
|
|
|
|
var outputResponse = new AdvisoryPipelineOutputModel
|
|
{
|
|
CacheKey = planResponse.CacheKey,
|
|
TaskType = planResponse.TaskType,
|
|
Profile = "default",
|
|
Prompt = "Summary result",
|
|
Citations = new[]
|
|
{
|
|
new AdvisoryOutputCitationModel
|
|
{
|
|
Index = 0,
|
|
DocumentId = "doc-1",
|
|
ChunkId = "chunk-1"
|
|
}
|
|
},
|
|
Metadata = new Dictionary<string, string>
|
|
{
|
|
["confidence"] = "high"
|
|
},
|
|
Guardrail = new AdvisoryOutputGuardrailModel
|
|
{
|
|
Blocked = false,
|
|
SanitizedPrompt = "Summary result",
|
|
Violations = Array.Empty<AdvisoryOutputGuardrailViolationModel>(),
|
|
Metadata = new Dictionary<string, string>()
|
|
},
|
|
Provenance = new AdvisoryOutputProvenanceModel
|
|
{
|
|
InputDigest = "sha256:aaa",
|
|
OutputHash = "sha256:bbb",
|
|
Signatures = Array.Empty<string>()
|
|
},
|
|
GeneratedAtUtc = DateTimeOffset.Parse("2025-11-06T12:00:00Z", CultureInfo.InvariantCulture),
|
|
PlanFromCache = false
|
|
};
|
|
|
|
var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null))
|
|
{
|
|
AdvisoryPlanResponse = planResponse,
|
|
AdvisoryOutputResponse = outputResponse
|
|
};
|
|
|
|
var provider = BuildServiceProvider(backend);
|
|
|
|
await CommandHandlers.HandleAdviseRunAsync(
|
|
provider,
|
|
AdvisoryAiTaskType.Summary,
|
|
" ADV-1 ",
|
|
null,
|
|
null,
|
|
null,
|
|
"default",
|
|
new[] { "impact", "impact " },
|
|
forceRefresh: false,
|
|
timeoutSeconds: 0,
|
|
verbose: false,
|
|
cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Equal(0, Environment.ExitCode);
|
|
Assert.Single(backend.AdvisoryPlanRequests);
|
|
var request = backend.AdvisoryPlanRequests[0];
|
|
Assert.Equal(AdvisoryAiTaskType.Summary, request.TaskType);
|
|
Assert.Equal("ADV-1", request.Request.AdvisoryKey);
|
|
Assert.NotNull(request.Request.PreferredSections);
|
|
Assert.Single(request.Request.PreferredSections!);
|
|
Assert.Equal("impact", request.Request.PreferredSections![0]);
|
|
|
|
Assert.Single(backend.AdvisoryOutputRequests);
|
|
Assert.Equal(planResponse.CacheKey, backend.AdvisoryOutputRequests[0].CacheKey);
|
|
Assert.Equal("default", backend.AdvisoryOutputRequests[0].Profile);
|
|
|
|
var output = testConsole.Output;
|
|
Assert.Contains("Advisory Output", output, StringComparison.OrdinalIgnoreCase);
|
|
Assert.Contains(planResponse.CacheKey, output, StringComparison.Ordinal);
|
|
Assert.Contains("Summary result", output, StringComparison.Ordinal);
|
|
}
|
|
finally
|
|
{
|
|
AnsiConsole.Console = originalConsole;
|
|
Environment.ExitCode = originalExit;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HandleAdviseRunAsync_ReturnsGuardrailExitCodeOnBlock()
|
|
{
|
|
var originalExit = Environment.ExitCode;
|
|
var originalConsole = AnsiConsole.Console;
|
|
var testConsole = new TestConsole();
|
|
|
|
try
|
|
{
|
|
Environment.ExitCode = 0;
|
|
AnsiConsole.Console = testConsole;
|
|
|
|
var planResponse = new AdvisoryPipelinePlanResponseModel
|
|
{
|
|
TaskType = AdvisoryAiTaskType.Remediation.ToString(),
|
|
CacheKey = "cache-guard",
|
|
PromptTemplate = "prompts/advisory/remediation.liquid",
|
|
Budget = new AdvisoryTaskBudgetModel
|
|
{
|
|
PromptTokens = 256,
|
|
CompletionTokens = 64
|
|
},
|
|
Chunks = Array.Empty<PipelineChunkSummaryModel>(),
|
|
Vectors = Array.Empty<PipelineVectorSummaryModel>(),
|
|
Metadata = new Dictionary<string, string>()
|
|
};
|
|
|
|
var outputResponse = new AdvisoryPipelineOutputModel
|
|
{
|
|
CacheKey = planResponse.CacheKey,
|
|
TaskType = planResponse.TaskType,
|
|
Profile = "default",
|
|
Prompt = "Blocked output",
|
|
Citations = Array.Empty<AdvisoryOutputCitationModel>(),
|
|
Metadata = new Dictionary<string, string>(),
|
|
Guardrail = new AdvisoryOutputGuardrailModel
|
|
{
|
|
Blocked = true,
|
|
SanitizedPrompt = "Blocked output",
|
|
Violations = new[]
|
|
{
|
|
new AdvisoryOutputGuardrailViolationModel
|
|
{
|
|
Code = "PROMPT_INJECTION",
|
|
Message = "Detected prompt injection attempt."
|
|
}
|
|
},
|
|
Metadata = new Dictionary<string, string>()
|
|
},
|
|
Provenance = new AdvisoryOutputProvenanceModel
|
|
{
|
|
InputDigest = "sha256:ccc",
|
|
OutputHash = "sha256:ddd",
|
|
Signatures = Array.Empty<string>()
|
|
},
|
|
GeneratedAtUtc = DateTimeOffset.Parse("2025-11-06T13:05:00Z", CultureInfo.InvariantCulture),
|
|
PlanFromCache = true
|
|
};
|
|
|
|
var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null))
|
|
{
|
|
AdvisoryPlanResponse = planResponse,
|
|
AdvisoryOutputResponse = outputResponse
|
|
};
|
|
|
|
var provider = BuildServiceProvider(backend);
|
|
|
|
await CommandHandlers.HandleAdviseRunAsync(
|
|
provider,
|
|
AdvisoryAiTaskType.Remediation,
|
|
"ADV-2",
|
|
null,
|
|
null,
|
|
null,
|
|
"default",
|
|
Array.Empty<string>(),
|
|
forceRefresh: true,
|
|
timeoutSeconds: 0,
|
|
verbose: false,
|
|
cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Equal(65, Environment.ExitCode);
|
|
Assert.Contains("Guardrail Violations", testConsole.Output, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
finally
|
|
{
|
|
AnsiConsole.Console = originalConsole;
|
|
Environment.ExitCode = originalExit;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HandleAdviseRunAsync_TimesOutWhenOutputMissing()
|
|
{
|
|
var originalExit = Environment.ExitCode;
|
|
var originalConsole = AnsiConsole.Console;
|
|
|
|
try
|
|
{
|
|
Environment.ExitCode = 0;
|
|
AnsiConsole.Console = new TestConsole();
|
|
|
|
var planResponse = new AdvisoryPipelinePlanResponseModel
|
|
{
|
|
TaskType = AdvisoryAiTaskType.Conflict.ToString(),
|
|
CacheKey = "cache-timeout",
|
|
PromptTemplate = "prompts/advisory/conflict.liquid",
|
|
Budget = new AdvisoryTaskBudgetModel
|
|
{
|
|
PromptTokens = 128,
|
|
CompletionTokens = 32
|
|
},
|
|
Chunks = Array.Empty<PipelineChunkSummaryModel>(),
|
|
Vectors = Array.Empty<PipelineVectorSummaryModel>(),
|
|
Metadata = new Dictionary<string, string>()
|
|
};
|
|
|
|
var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null))
|
|
{
|
|
AdvisoryPlanResponse = planResponse,
|
|
AdvisoryOutputResponse = null
|
|
};
|
|
|
|
var provider = BuildServiceProvider(backend);
|
|
|
|
await CommandHandlers.HandleAdviseRunAsync(
|
|
provider,
|
|
AdvisoryAiTaskType.Conflict,
|
|
"ADV-3",
|
|
null,
|
|
null,
|
|
null,
|
|
"default",
|
|
Array.Empty<string>(),
|
|
forceRefresh: false,
|
|
timeoutSeconds: 0,
|
|
verbose: false,
|
|
cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Equal(70, Environment.ExitCode);
|
|
Assert.Single(backend.AdvisoryOutputRequests);
|
|
}
|
|
finally
|
|
{
|
|
AnsiConsole.Console = originalConsole;
|
|
Environment.ExitCode = originalExit;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HandleAuthLoginAsync_UsesClientCredentialsFlow()
|
|
{
|
|
var original = Environment.ExitCode;
|
|
using var tempDir = new TempDirectory();
|
|
|
|
try
|
|
{
|
|
var options = new StellaOpsCliOptions
|
|
{
|
|
ResultsDirectory = Path.Combine(tempDir.Path, "results"),
|
|
Authority = new StellaOpsCliAuthorityOptions
|
|
{
|
|
Url = "https://authority.example",
|
|
ClientId = "cli",
|
|
ClientSecret = "secret",
|
|
Scope = "concelier.jobs.trigger",
|
|
TokenCacheDirectory = tempDir.Path
|
|
}
|
|
};
|
|
|
|
var tokenClient = new StubTokenClient();
|
|
var provider = BuildServiceProvider(new StubBackendClient(new JobTriggerResult(true, "ok", null, null)), options: options, tokenClient: tokenClient);
|
|
|
|
await CommandHandlers.HandleAuthLoginAsync(provider, options, verbose: false, force: false, cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Equal(0, Environment.ExitCode);
|
|
Assert.Equal(1, tokenClient.ClientCredentialRequests);
|
|
Assert.NotNull(tokenClient.CachedEntry);
|
|
}
|
|
finally
|
|
{
|
|
Environment.ExitCode = original;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HandleAuthLoginAsync_FailsWhenPasswordMissing()
|
|
{
|
|
var original = Environment.ExitCode;
|
|
using var tempDir = new TempDirectory();
|
|
|
|
try
|
|
{
|
|
var options = new StellaOpsCliOptions
|
|
{
|
|
ResultsDirectory = Path.Combine(tempDir.Path, "results"),
|
|
Authority = new StellaOpsCliAuthorityOptions
|
|
{
|
|
Url = "https://authority.example",
|
|
ClientId = "cli",
|
|
Username = "user",
|
|
TokenCacheDirectory = tempDir.Path
|
|
}
|
|
};
|
|
|
|
var provider = BuildServiceProvider(new StubBackendClient(new JobTriggerResult(true, "ok", null, null)), options: options, tokenClient: new StubTokenClient());
|
|
|
|
await CommandHandlers.HandleAuthLoginAsync(provider, options, verbose: false, force: false, cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Equal(1, Environment.ExitCode);
|
|
}
|
|
finally
|
|
{
|
|
Environment.ExitCode = original;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HandleAuthStatusAsync_ReportsMissingToken()
|
|
{
|
|
var original = Environment.ExitCode;
|
|
using var tempDir = new TempDirectory();
|
|
|
|
try
|
|
{
|
|
var options = new StellaOpsCliOptions
|
|
{
|
|
ResultsDirectory = Path.Combine(tempDir.Path, "results"),
|
|
Authority = new StellaOpsCliAuthorityOptions
|
|
{
|
|
Url = "https://authority.example",
|
|
ClientId = "cli",
|
|
TokenCacheDirectory = tempDir.Path
|
|
}
|
|
};
|
|
|
|
var provider = BuildServiceProvider(new StubBackendClient(new JobTriggerResult(true, "ok", null, null)), options: options, tokenClient: new StubTokenClient());
|
|
|
|
await CommandHandlers.HandleAuthStatusAsync(provider, options, verbose: false, cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Equal(1, Environment.ExitCode);
|
|
}
|
|
finally
|
|
{
|
|
Environment.ExitCode = original;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HandleExcititorInitAsync_CallsBackend()
|
|
{
|
|
var original = Environment.ExitCode;
|
|
try
|
|
{
|
|
var backend = new StubBackendClient(new JobTriggerResult(true, "accepted", null, null));
|
|
var provider = BuildServiceProvider(backend);
|
|
|
|
await CommandHandlers.HandleExcititorInitAsync(
|
|
provider,
|
|
new[] { "redhat" },
|
|
resume: true,
|
|
verbose: false,
|
|
cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Equal(0, Environment.ExitCode);
|
|
Assert.Equal("init", backend.LastExcititorRoute);
|
|
Assert.Equal(HttpMethod.Post, backend.LastExcititorMethod);
|
|
var payload = Assert.IsAssignableFrom<IDictionary<string, object?>>(backend.LastExcititorPayload);
|
|
Assert.Equal(true, payload["resume"]);
|
|
var providers = Assert.IsAssignableFrom<IEnumerable<string>>(payload["providers"]!);
|
|
Assert.Contains("redhat", providers, StringComparer.OrdinalIgnoreCase);
|
|
}
|
|
finally
|
|
{
|
|
Environment.ExitCode = original;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HandleExcititorListProvidersAsync_WritesOutput()
|
|
{
|
|
var original = Environment.ExitCode;
|
|
try
|
|
{
|
|
var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null))
|
|
{
|
|
ProviderSummaries = new[]
|
|
{
|
|
new ExcititorProviderSummary("redhat", "distro", "Red Hat", "vendor", true, DateTimeOffset.UtcNow)
|
|
}
|
|
};
|
|
|
|
var provider = BuildServiceProvider(backend);
|
|
await CommandHandlers.HandleExcititorListProvidersAsync(provider, includeDisabled: false, verbose: false, cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Equal(0, Environment.ExitCode);
|
|
}
|
|
finally
|
|
{
|
|
Environment.ExitCode = original;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HandleExcititorVerifyAsync_FailsWithoutArguments()
|
|
{
|
|
var original = Environment.ExitCode;
|
|
try
|
|
{
|
|
var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null));
|
|
var provider = BuildServiceProvider(backend);
|
|
|
|
await CommandHandlers.HandleExcititorVerifyAsync(provider, null, null, null, verbose: false, cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Equal(1, Environment.ExitCode);
|
|
}
|
|
finally
|
|
{
|
|
Environment.ExitCode = original;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HandleExcititorVerifyAsync_AttachesAttestationFile()
|
|
{
|
|
var original = Environment.ExitCode;
|
|
using var tempFile = new TempFile("attestation.json", Encoding.UTF8.GetBytes("{\"ok\":true}"));
|
|
try
|
|
{
|
|
var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null));
|
|
var provider = BuildServiceProvider(backend);
|
|
|
|
await CommandHandlers.HandleExcititorVerifyAsync(
|
|
provider,
|
|
exportId: "export-123",
|
|
digest: "sha256:abc",
|
|
attestationPath: tempFile.Path,
|
|
verbose: false,
|
|
cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Equal(0, Environment.ExitCode);
|
|
Assert.Equal("verify", backend.LastExcititorRoute);
|
|
var payload = Assert.IsAssignableFrom<IDictionary<string, object?>>(backend.LastExcititorPayload);
|
|
Assert.Equal("export-123", payload["exportId"]);
|
|
Assert.Equal("sha256:abc", payload["digest"]);
|
|
var attestation = Assert.IsAssignableFrom<IDictionary<string, object?>>(payload["attestation"]!);
|
|
Assert.Equal(Path.GetFileName(tempFile.Path), attestation["fileName"]);
|
|
Assert.NotNull(attestation["base64"]);
|
|
}
|
|
finally
|
|
{
|
|
Environment.ExitCode = original;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HandleExcititorExportAsync_DownloadsWhenOutputProvided()
|
|
{
|
|
var original = Environment.ExitCode;
|
|
using var tempDir = new TempDirectory();
|
|
|
|
try
|
|
{
|
|
var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null));
|
|
const string manifestJson = """
|
|
{
|
|
"exportId": "exports/20251019T101530Z/abcdef1234567890",
|
|
"format": "openvex",
|
|
"createdAt": "2025-10-19T10:15:30Z",
|
|
"artifact": { "algorithm": "sha256", "digest": "abcdef1234567890" },
|
|
"fromCache": false,
|
|
"sizeBytes": 2048,
|
|
"attestation": {
|
|
"rekor": {
|
|
"location": "https://rekor.example/api/v1/log/entries/123",
|
|
"logIndex": "123"
|
|
}
|
|
}
|
|
}
|
|
""";
|
|
|
|
backend.ExcititorResult = new ExcititorOperationResult(true, "ok", null, JsonDocument.Parse(manifestJson).RootElement.Clone());
|
|
var provider = BuildServiceProvider(backend);
|
|
var outputPath = Path.Combine(tempDir.Path, "export.json");
|
|
|
|
await CommandHandlers.HandleExcititorExportAsync(
|
|
provider,
|
|
format: "openvex",
|
|
delta: false,
|
|
scope: null,
|
|
since: null,
|
|
provider: null,
|
|
outputPath: outputPath,
|
|
verbose: false,
|
|
cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Equal(0, Environment.ExitCode);
|
|
Assert.Single(backend.ExportDownloads);
|
|
var request = backend.ExportDownloads[0];
|
|
Assert.Equal("exports/20251019T101530Z/abcdef1234567890", request.ExportId);
|
|
Assert.Equal(Path.GetFullPath(outputPath), request.DestinationPath);
|
|
Assert.Equal("sha256", request.Algorithm);
|
|
Assert.Equal("abcdef1234567890", request.Digest);
|
|
}
|
|
finally
|
|
{
|
|
Environment.ExitCode = original;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HandleVulnObservationsAsync_WritesTableOutput()
|
|
{
|
|
var originalExit = Environment.ExitCode;
|
|
var response = new AdvisoryObservationsResponse
|
|
{
|
|
Observations = new[]
|
|
{
|
|
new AdvisoryObservationDocument
|
|
{
|
|
ObservationId = "tenant-a:ghsa:alpha:1",
|
|
Tenant = "tenant-a",
|
|
Source = new AdvisoryObservationSource
|
|
{
|
|
Vendor = "ghsa",
|
|
Stream = "advisories",
|
|
Api = "https://example.test/api"
|
|
},
|
|
Upstream = new AdvisoryObservationUpstream
|
|
{
|
|
UpstreamId = "GHSA-abcd-efgh"
|
|
},
|
|
Linkset = new AdvisoryObservationLinkset
|
|
{
|
|
Aliases = new[] { "cve-2025-0001" },
|
|
Purls = new[] { "pkg:npm/package-a@1.0.0" },
|
|
Cpes = new[] { "cpe:/a:vendor:product:1.0" }
|
|
},
|
|
CreatedAt = new DateTimeOffset(2025, 10, 27, 6, 0, 0, TimeSpan.Zero)
|
|
}
|
|
},
|
|
Linkset = new AdvisoryObservationLinksetAggregate
|
|
{
|
|
Aliases = new[] { "cve-2025-0001" },
|
|
Purls = new[] { "pkg:npm/package-a@1.0.0" },
|
|
Cpes = new[] { "cpe:/a:vendor:product:1.0" },
|
|
References = Array.Empty<AdvisoryObservationReference>()
|
|
}
|
|
};
|
|
|
|
var stubClient = new StubConcelierObservationsClient(response);
|
|
var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null));
|
|
var provider = BuildServiceProvider(backend, concelierClient: stubClient);
|
|
|
|
var console = new TestConsole();
|
|
var originalConsole = AnsiConsole.Console;
|
|
AnsiConsole.Console = console;
|
|
|
|
try
|
|
{
|
|
await CommandHandlers.HandleVulnObservationsAsync(
|
|
provider,
|
|
tenant: "Tenant-A ",
|
|
observationIds: new[] { "tenant-a:ghsa:alpha:1 " },
|
|
aliases: new[] { " CVE-2025-0001 " },
|
|
purls: new[] { " pkg:npm/package-a@1.0.0 " },
|
|
cpes: Array.Empty<string>(),
|
|
limit: null,
|
|
cursor: null,
|
|
emitJson: false,
|
|
verbose: false,
|
|
cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Equal(0, Environment.ExitCode);
|
|
}
|
|
finally
|
|
{
|
|
Environment.ExitCode = originalExit;
|
|
AnsiConsole.Console = originalConsole;
|
|
}
|
|
|
|
Assert.NotNull(stubClient.LastQuery);
|
|
var query = stubClient.LastQuery!;
|
|
Assert.Equal("tenant-a", query.Tenant);
|
|
Assert.Contains("cve-2025-0001", query.Aliases);
|
|
Assert.Contains("pkg:npm/package-a@1.0.0", query.Purls);
|
|
Assert.Null(query.Limit);
|
|
Assert.Null(query.Cursor);
|
|
|
|
var output = console.Output;
|
|
Assert.False(string.IsNullOrWhiteSpace(output));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HandleVulnObservationsAsync_WritesJsonOutput()
|
|
{
|
|
var originalExit = Environment.ExitCode;
|
|
var response = new AdvisoryObservationsResponse
|
|
{
|
|
Observations = new[]
|
|
{
|
|
new AdvisoryObservationDocument
|
|
{
|
|
ObservationId = "tenant-a:osv:beta:2",
|
|
Tenant = "tenant-a",
|
|
Source = new AdvisoryObservationSource
|
|
{
|
|
Vendor = "osv",
|
|
Stream = "osv",
|
|
Api = "https://example.test/osv"
|
|
},
|
|
Upstream = new AdvisoryObservationUpstream
|
|
{
|
|
UpstreamId = "OSV-2025-XYZ"
|
|
},
|
|
Linkset = new AdvisoryObservationLinkset
|
|
{
|
|
Aliases = new[] { "cve-2025-0101" },
|
|
Purls = new[] { "pkg:pypi/package-b@2.0.0" },
|
|
Cpes = Array.Empty<string>(),
|
|
References = new[]
|
|
{
|
|
new AdvisoryObservationReference { Type = "advisory", Url = "https://example.test/advisory" }
|
|
}
|
|
},
|
|
CreatedAt = new DateTimeOffset(2025, 10, 27, 7, 30, 0, TimeSpan.Zero)
|
|
}
|
|
},
|
|
Linkset = new AdvisoryObservationLinksetAggregate
|
|
{
|
|
Aliases = new[] { "cve-2025-0101" },
|
|
Purls = new[] { "pkg:pypi/package-b@2.0.0" },
|
|
Cpes = Array.Empty<string>(),
|
|
References = new[]
|
|
{
|
|
new AdvisoryObservationReference { Type = "advisory", Url = "https://example.test/advisory" }
|
|
}
|
|
}
|
|
};
|
|
|
|
var stubClient = new StubConcelierObservationsClient(response);
|
|
var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null));
|
|
var provider = BuildServiceProvider(backend, concelierClient: stubClient);
|
|
|
|
var writer = new StringWriter();
|
|
var originalOut = Console.Out;
|
|
Console.SetOut(writer);
|
|
|
|
try
|
|
{
|
|
await CommandHandlers.HandleVulnObservationsAsync(
|
|
provider,
|
|
tenant: "tenant-a",
|
|
observationIds: Array.Empty<string>(),
|
|
aliases: Array.Empty<string>(),
|
|
purls: Array.Empty<string>(),
|
|
cpes: Array.Empty<string>(),
|
|
limit: null,
|
|
cursor: null,
|
|
emitJson: true,
|
|
verbose: false,
|
|
cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Equal(0, Environment.ExitCode);
|
|
}
|
|
finally
|
|
{
|
|
Environment.ExitCode = originalExit;
|
|
Console.SetOut(originalOut);
|
|
}
|
|
|
|
var json = writer.ToString();
|
|
using var document = JsonDocument.Parse(json);
|
|
var root = document.RootElement;
|
|
Assert.True(root.TryGetProperty("observations", out var observations));
|
|
Assert.Equal("tenant-a:osv:beta:2", observations[0].GetProperty("observationId").GetString());
|
|
Assert.Equal("pkg:pypi/package-b@2.0.0", observations[0].GetProperty("linkset").GetProperty("purls")[0].GetString());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HandleVulnObservationsAsync_WhenHasMore_PrintsCursorHint()
|
|
{
|
|
var originalExit = Environment.ExitCode;
|
|
var response = new AdvisoryObservationsResponse
|
|
{
|
|
Observations = new[]
|
|
{
|
|
new AdvisoryObservationDocument
|
|
{
|
|
ObservationId = "tenant-a:source:1",
|
|
Tenant = "tenant-a",
|
|
Linkset = new AdvisoryObservationLinkset(),
|
|
Source = new AdvisoryObservationSource(),
|
|
Upstream = new AdvisoryObservationUpstream(),
|
|
CreatedAt = DateTimeOffset.UtcNow
|
|
}
|
|
},
|
|
Linkset = new AdvisoryObservationLinksetAggregate(),
|
|
HasMore = true,
|
|
NextCursor = "cursor-token"
|
|
};
|
|
|
|
var stubClient = new StubConcelierObservationsClient(response);
|
|
var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null));
|
|
var provider = BuildServiceProvider(backend, concelierClient: stubClient);
|
|
|
|
var console = new TestConsole();
|
|
var originalConsole = AnsiConsole.Console;
|
|
AnsiConsole.Console = console;
|
|
|
|
try
|
|
{
|
|
await CommandHandlers.HandleVulnObservationsAsync(
|
|
provider,
|
|
tenant: "tenant-a",
|
|
observationIds: Array.Empty<string>(),
|
|
aliases: Array.Empty<string>(),
|
|
purls: Array.Empty<string>(),
|
|
cpes: Array.Empty<string>(),
|
|
limit: 1,
|
|
cursor: null,
|
|
emitJson: false,
|
|
verbose: false,
|
|
cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Equal(0, Environment.ExitCode);
|
|
}
|
|
finally
|
|
{
|
|
Environment.ExitCode = originalExit;
|
|
AnsiConsole.Console = originalConsole;
|
|
}
|
|
|
|
var output = console.Output;
|
|
Assert.Contains("--cursor", output, StringComparison.OrdinalIgnoreCase);
|
|
Assert.Contains("cursor-token", output, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(null)]
|
|
[InlineData("default")]
|
|
[InlineData("libsodium")]
|
|
public async Task HandleAuthRevokeVerifyAsync_VerifiesBundlesUsingProviderRegistry(string? providerHint)
|
|
{
|
|
var original = Environment.ExitCode;
|
|
using var tempDir = new TempDirectory();
|
|
|
|
try
|
|
{
|
|
var artifacts = await WriteRevocationArtifactsAsync(tempDir, providerHint);
|
|
|
|
await CommandHandlers.HandleAuthRevokeVerifyAsync(
|
|
artifacts.BundlePath,
|
|
artifacts.SignaturePath,
|
|
artifacts.KeyPath,
|
|
verbose: true,
|
|
cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Equal(0, Environment.ExitCode);
|
|
}
|
|
finally
|
|
{
|
|
Environment.ExitCode = original;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HandleAuthStatusAsync_ReportsCachedToken()
|
|
{
|
|
var original = Environment.ExitCode;
|
|
using var tempDir = new TempDirectory();
|
|
|
|
try
|
|
{
|
|
var options = new StellaOpsCliOptions
|
|
{
|
|
ResultsDirectory = Path.Combine(tempDir.Path, "results"),
|
|
Authority = new StellaOpsCliAuthorityOptions
|
|
{
|
|
Url = "https://authority.example",
|
|
ClientId = "cli",
|
|
TokenCacheDirectory = tempDir.Path
|
|
}
|
|
};
|
|
|
|
var tokenClient = new StubTokenClient();
|
|
tokenClient.CachedEntry = new StellaOpsTokenCacheEntry(
|
|
"token",
|
|
"Bearer",
|
|
DateTimeOffset.UtcNow.AddMinutes(30),
|
|
new[] { StellaOpsScopes.ConcelierJobsTrigger });
|
|
|
|
var provider = BuildServiceProvider(new StubBackendClient(new JobTriggerResult(true, "ok", null, null)), options: options, tokenClient: tokenClient);
|
|
|
|
await CommandHandlers.HandleAuthStatusAsync(provider, options, verbose: true, cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Equal(0, Environment.ExitCode);
|
|
}
|
|
finally
|
|
{
|
|
Environment.ExitCode = original;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HandleAuthWhoAmIAsync_ReturnsErrorWhenTokenMissing()
|
|
{
|
|
var original = Environment.ExitCode;
|
|
using var tempDir = new TempDirectory();
|
|
|
|
try
|
|
{
|
|
var options = new StellaOpsCliOptions
|
|
{
|
|
ResultsDirectory = Path.Combine(tempDir.Path, "results"),
|
|
Authority = new StellaOpsCliAuthorityOptions
|
|
{
|
|
Url = "https://authority.example",
|
|
ClientId = "cli",
|
|
TokenCacheDirectory = tempDir.Path
|
|
}
|
|
};
|
|
|
|
var provider = BuildServiceProvider(new StubBackendClient(new JobTriggerResult(true, "ok", null, null)), options: options, tokenClient: new StubTokenClient());
|
|
|
|
await CommandHandlers.HandleAuthWhoAmIAsync(provider, options, verbose: false, cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Equal(1, Environment.ExitCode);
|
|
}
|
|
finally
|
|
{
|
|
Environment.ExitCode = original;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HandleAuthWhoAmIAsync_ReportsClaimsForJwtToken()
|
|
{
|
|
var original = Environment.ExitCode;
|
|
using var tempDir = new TempDirectory();
|
|
|
|
try
|
|
{
|
|
var options = new StellaOpsCliOptions
|
|
{
|
|
ResultsDirectory = Path.Combine(tempDir.Path, "results"),
|
|
Authority = new StellaOpsCliAuthorityOptions
|
|
{
|
|
Url = "https://authority.example",
|
|
ClientId = "cli",
|
|
TokenCacheDirectory = tempDir.Path
|
|
}
|
|
};
|
|
|
|
var tokenClient = new StubTokenClient();
|
|
tokenClient.CachedEntry = new StellaOpsTokenCacheEntry(
|
|
CreateUnsignedJwt(
|
|
("sub", "cli-user"),
|
|
("aud", "concelier"),
|
|
("iss", "https://authority.example"),
|
|
("iat", 1_700_000_000),
|
|
("nbf", 1_700_000_000)),
|
|
"Bearer",
|
|
DateTimeOffset.UtcNow.AddMinutes(30),
|
|
new[] { StellaOpsScopes.ConcelierJobsTrigger });
|
|
|
|
var provider = BuildServiceProvider(new StubBackendClient(new JobTriggerResult(true, "ok", null, null)), options: options, tokenClient: tokenClient);
|
|
|
|
await CommandHandlers.HandleAuthWhoAmIAsync(provider, options, verbose: true, cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Equal(0, Environment.ExitCode);
|
|
}
|
|
finally
|
|
{
|
|
Environment.ExitCode = original;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HandleAuthLogoutAsync_ClearsToken()
|
|
{
|
|
var original = Environment.ExitCode;
|
|
using var tempDir = new TempDirectory();
|
|
|
|
try
|
|
{
|
|
var options = new StellaOpsCliOptions
|
|
{
|
|
ResultsDirectory = Path.Combine(tempDir.Path, "results"),
|
|
Authority = new StellaOpsCliAuthorityOptions
|
|
{
|
|
Url = "https://authority.example",
|
|
ClientId = "cli",
|
|
TokenCacheDirectory = tempDir.Path
|
|
}
|
|
};
|
|
|
|
var tokenClient = new StubTokenClient();
|
|
tokenClient.CachedEntry = new StellaOpsTokenCacheEntry(
|
|
"token",
|
|
"Bearer",
|
|
DateTimeOffset.UtcNow.AddMinutes(5),
|
|
new[] { StellaOpsScopes.ConcelierJobsTrigger });
|
|
|
|
var provider = BuildServiceProvider(new StubBackendClient(new JobTriggerResult(true, "ok", null, null)), options: options, tokenClient: tokenClient);
|
|
|
|
await CommandHandlers.HandleAuthLogoutAsync(provider, options, verbose: true, cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Null(tokenClient.CachedEntry);
|
|
Assert.Equal(1, tokenClient.ClearRequests);
|
|
Assert.Equal(0, Environment.ExitCode);
|
|
}
|
|
finally
|
|
{
|
|
Environment.ExitCode = original;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HandleRuntimePolicyTestAsync_WritesInteractiveTable()
|
|
{
|
|
var originalExit = Environment.ExitCode;
|
|
var originalConsole = AnsiConsole.Console;
|
|
|
|
var console = new TestConsole();
|
|
console.Width(120);
|
|
console.Interactive();
|
|
console.EmitAnsiSequences();
|
|
|
|
AnsiConsole.Console = console;
|
|
|
|
var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null));
|
|
|
|
var decisions = new Dictionary<string, RuntimePolicyImageDecision>(StringComparer.Ordinal)
|
|
{
|
|
["sha256:aaa"] = new RuntimePolicyImageDecision(
|
|
"allow",
|
|
true,
|
|
true,
|
|
Array.AsReadOnly(new[] { "trusted baseline" }),
|
|
new RuntimePolicyRekorReference("uuid-allow", "https://rekor.example/entries/uuid-allow", true),
|
|
new ReadOnlyDictionary<string, object?>(new Dictionary<string, object?>(StringComparer.Ordinal)
|
|
{
|
|
["source"] = "baseline",
|
|
["quieted"] = false,
|
|
["confidence"] = 0.97,
|
|
["confidenceBand"] = "high"
|
|
})),
|
|
["sha256:bbb"] = new RuntimePolicyImageDecision(
|
|
"block",
|
|
false,
|
|
false,
|
|
Array.AsReadOnly(new[] { "missing attestation" }),
|
|
new RuntimePolicyRekorReference("uuid-block", "https://rekor.example/entries/uuid-block", false),
|
|
new ReadOnlyDictionary<string, object?>(new Dictionary<string, object?>(StringComparer.Ordinal)
|
|
{
|
|
["source"] = "policy",
|
|
["quieted"] = false,
|
|
["confidence"] = 0.12,
|
|
["confidenceBand"] = "low"
|
|
})),
|
|
["sha256:ccc"] = new RuntimePolicyImageDecision(
|
|
"audit",
|
|
true,
|
|
false,
|
|
Array.AsReadOnly(new[] { "pending sbom sync" }),
|
|
new RuntimePolicyRekorReference(null, null, null),
|
|
new ReadOnlyDictionary<string, object?>(new Dictionary<string, object?>(StringComparer.Ordinal)
|
|
{
|
|
["source"] = "mirror",
|
|
["quieted"] = true,
|
|
["quietedBy"] = "allow-temporary",
|
|
["confidence"] = 0.42,
|
|
["confidenceBand"] = "medium"
|
|
}))
|
|
};
|
|
|
|
backend.RuntimePolicyResult = new RuntimePolicyEvaluationResult(
|
|
300,
|
|
DateTimeOffset.Parse("2025-10-19T12:00:00Z", CultureInfo.InvariantCulture),
|
|
"rev-42",
|
|
new ReadOnlyDictionary<string, RuntimePolicyImageDecision>(decisions));
|
|
|
|
var provider = BuildServiceProvider(backend);
|
|
|
|
try
|
|
{
|
|
await CommandHandlers.HandleRuntimePolicyTestAsync(
|
|
provider,
|
|
namespaceValue: "prod",
|
|
imageArguments: new[] { "sha256:aaa", "sha256:bbb" },
|
|
filePath: null,
|
|
labelArguments: new[] { "app=frontend" },
|
|
outputJson: false,
|
|
verbose: false,
|
|
cancellationToken: CancellationToken.None);
|
|
|
|
var output = console.Output;
|
|
|
|
Assert.Equal(0, Environment.ExitCode);
|
|
Assert.Contains("Image", output, StringComparison.Ordinal);
|
|
Assert.Contains("Verdict", output, StringComparison.Ordinal);
|
|
Assert.Contains("SBOM Ref", output, StringComparison.Ordinal);
|
|
Assert.Contains("Quieted", output, StringComparison.Ordinal);
|
|
Assert.Contains("Confidence", output, StringComparison.Ordinal);
|
|
Assert.Contains("sha256:aaa", output, StringComparison.Ordinal);
|
|
Assert.Contains("uuid-allow", output, StringComparison.Ordinal);
|
|
Assert.Contains("(verified)", output, StringComparison.Ordinal);
|
|
Assert.Contains("0.97 (high)", output, StringComparison.Ordinal);
|
|
Assert.Contains("sha256:bbb", output, StringComparison.Ordinal);
|
|
Assert.Contains("uuid-block", output, StringComparison.Ordinal);
|
|
Assert.Contains("(unverified)", output, StringComparison.Ordinal);
|
|
Assert.Contains("sha256:ccc", output, StringComparison.Ordinal);
|
|
Assert.Contains("yes", output, StringComparison.Ordinal);
|
|
Assert.Contains("allow-temporary", output, StringComparison.Ordinal);
|
|
Assert.True(
|
|
output.IndexOf("sha256:aaa", StringComparison.Ordinal) <
|
|
output.IndexOf("sha256:ccc", StringComparison.Ordinal));
|
|
}
|
|
finally
|
|
{
|
|
Environment.ExitCode = originalExit;
|
|
AnsiConsole.Console = originalConsole;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HandleRuntimePolicyTestAsync_WritesDeterministicJson()
|
|
{
|
|
var originalExit = Environment.ExitCode;
|
|
var originalOut = Console.Out;
|
|
|
|
var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null));
|
|
|
|
var decisions = new Dictionary<string, RuntimePolicyImageDecision>(StringComparer.Ordinal)
|
|
{
|
|
["sha256:json-a"] = new RuntimePolicyImageDecision(
|
|
"allow",
|
|
true,
|
|
true,
|
|
Array.AsReadOnly(new[] { "baseline allow" }),
|
|
new RuntimePolicyRekorReference("uuid-json-allow", "https://rekor.example/entries/uuid-json-allow", true),
|
|
new ReadOnlyDictionary<string, object?>(new Dictionary<string, object?>(StringComparer.Ordinal)
|
|
{
|
|
["source"] = "baseline",
|
|
["confidence"] = 0.66
|
|
})),
|
|
["sha256:json-b"] = new RuntimePolicyImageDecision(
|
|
"audit",
|
|
true,
|
|
false,
|
|
Array.AsReadOnly(Array.Empty<string>()),
|
|
new RuntimePolicyRekorReference(null, null, null),
|
|
new ReadOnlyDictionary<string, object?>(new Dictionary<string, object?>(StringComparer.Ordinal)
|
|
{
|
|
["source"] = "mirror",
|
|
["quieted"] = true,
|
|
["quietedBy"] = "risk-accepted"
|
|
}))
|
|
};
|
|
|
|
backend.RuntimePolicyResult = new RuntimePolicyEvaluationResult(
|
|
600,
|
|
DateTimeOffset.Parse("2025-10-20T00:00:00Z", CultureInfo.InvariantCulture),
|
|
"rev-json-7",
|
|
new ReadOnlyDictionary<string, RuntimePolicyImageDecision>(decisions));
|
|
|
|
var provider = BuildServiceProvider(backend);
|
|
|
|
using var writer = new StringWriter();
|
|
Console.SetOut(writer);
|
|
|
|
try
|
|
{
|
|
await CommandHandlers.HandleRuntimePolicyTestAsync(
|
|
provider,
|
|
namespaceValue: "staging",
|
|
imageArguments: new[] { "sha256:json-a", "sha256:json-b" },
|
|
filePath: null,
|
|
labelArguments: Array.Empty<string>(),
|
|
outputJson: true,
|
|
verbose: false,
|
|
cancellationToken: CancellationToken.None);
|
|
|
|
var output = writer.ToString().Trim();
|
|
|
|
Assert.Equal(0, Environment.ExitCode);
|
|
Assert.False(string.IsNullOrWhiteSpace(output));
|
|
|
|
using var document = JsonDocument.Parse(output);
|
|
var root = document.RootElement;
|
|
|
|
Assert.Equal(600, root.GetProperty("ttlSeconds").GetInt32());
|
|
Assert.Equal("rev-json-7", root.GetProperty("policyRevision").GetString());
|
|
var expiresAt = root.GetProperty("expiresAtUtc").GetString();
|
|
Assert.NotNull(expiresAt);
|
|
Assert.Equal(
|
|
DateTimeOffset.Parse("2025-10-20T00:00:00Z", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal),
|
|
DateTimeOffset.Parse(expiresAt!, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal));
|
|
|
|
var results = root.GetProperty("results");
|
|
var keys = results.EnumerateObject().Select(p => p.Name).ToArray();
|
|
Assert.Equal(new[] { "sha256:json-a", "sha256:json-b" }, keys);
|
|
|
|
var first = results.GetProperty("sha256:json-a");
|
|
Assert.Equal("allow", first.GetProperty("policyVerdict").GetString());
|
|
Assert.True(first.GetProperty("signed").GetBoolean());
|
|
Assert.True(first.GetProperty("hasSbomReferrers").GetBoolean());
|
|
var rekor = first.GetProperty("rekor");
|
|
Assert.Equal("uuid-json-allow", rekor.GetProperty("uuid").GetString());
|
|
Assert.True(rekor.GetProperty("verified").GetBoolean());
|
|
Assert.Equal("baseline", first.GetProperty("source").GetString());
|
|
Assert.Equal(0.66, first.GetProperty("confidence").GetDouble(), 3);
|
|
|
|
var second = results.GetProperty("sha256:json-b");
|
|
Assert.Equal("audit", second.GetProperty("policyVerdict").GetString());
|
|
Assert.True(second.GetProperty("signed").GetBoolean());
|
|
Assert.False(second.GetProperty("hasSbomReferrers").GetBoolean());
|
|
Assert.Equal("mirror", second.GetProperty("source").GetString());
|
|
Assert.True(second.GetProperty("quieted").GetBoolean());
|
|
Assert.Equal("risk-accepted", second.GetProperty("quietedBy").GetString());
|
|
Assert.False(second.TryGetProperty("rekor", out _));
|
|
}
|
|
finally
|
|
{
|
|
Console.SetOut(originalOut);
|
|
Environment.ExitCode = originalExit;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HandlePolicyFindingsListAsync_WritesInteractiveTable()
|
|
{
|
|
var originalExit = Environment.ExitCode;
|
|
var originalConsole = AnsiConsole.Console;
|
|
|
|
var console = new TestConsole();
|
|
console.Interactive();
|
|
console.EmitAnsiSequences();
|
|
console.Width(140);
|
|
AnsiConsole.Console = console;
|
|
|
|
var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null))
|
|
{
|
|
FindingsPage = new PolicyFindingsPage(
|
|
new[]
|
|
{
|
|
new PolicyFindingDocument(
|
|
"P-7:S-42:pkg:npm/lodash@4.17.21:CVE-2021-23337",
|
|
"affected",
|
|
new PolicyFindingSeverity("High", 7.5),
|
|
"sbom:S-42",
|
|
new[] { "CVE-2021-23337", "GHSA-xxxx-yyyy" },
|
|
new PolicyFindingVexMetadata("VendorX-123", "vendor-x", "not_affected"),
|
|
4,
|
|
DateTimeOffset.Parse("2025-10-26T14:06:01Z", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal),
|
|
"run:P-7:2025-10-26:auto")
|
|
},
|
|
"cursor-42",
|
|
10)
|
|
};
|
|
var provider = BuildServiceProvider(backend);
|
|
|
|
try
|
|
{
|
|
await CommandHandlers.HandlePolicyFindingsListAsync(
|
|
provider,
|
|
" P-7 ",
|
|
new[] { " sbom:S-42 " },
|
|
new[] { "Affected", "QUIETED" },
|
|
new[] { "High", "Critical" },
|
|
"2025-10-25T00:00:00Z",
|
|
" cursor-0 ",
|
|
page: 2,
|
|
pageSize: 100,
|
|
format: "table",
|
|
outputPath: null,
|
|
verbose: false,
|
|
cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Equal(0, Environment.ExitCode);
|
|
Assert.NotNull(backend.LastFindingsQuery);
|
|
var query = backend.LastFindingsQuery!;
|
|
Assert.Equal("P-7", query.PolicyId);
|
|
Assert.Contains("sbom:S-42", query.SbomIds);
|
|
Assert.Contains("affected", query.Statuses);
|
|
Assert.Contains("quieted", query.Statuses);
|
|
Assert.Contains("High", query.Severities);
|
|
Assert.Contains("Critical", query.Severities);
|
|
Assert.Equal(2, query.Page);
|
|
Assert.Equal(100, query.PageSize);
|
|
Assert.Equal("cursor-0", query.Cursor);
|
|
Assert.Equal(DateTimeOffset.Parse("2025-10-25T00:00:00Z", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal), query.Since);
|
|
|
|
var output = console.Output;
|
|
Assert.Contains("P-7:S-42", output, StringComparison.Ordinal);
|
|
Assert.Contains("High", output, StringComparison.Ordinal);
|
|
}
|
|
finally
|
|
{
|
|
AnsiConsole.Console = originalConsole;
|
|
Environment.ExitCode = originalExit;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HandlePolicyFindingsListAsync_WritesJson()
|
|
{
|
|
var originalExit = Environment.ExitCode;
|
|
var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null))
|
|
{
|
|
FindingsPage = new PolicyFindingsPage(
|
|
new[]
|
|
{
|
|
new PolicyFindingDocument(
|
|
"finding-1",
|
|
"quieted",
|
|
new PolicyFindingSeverity("Medium", 5.1),
|
|
"sbom:S-99",
|
|
Array.Empty<string>(),
|
|
null,
|
|
3,
|
|
DateTimeOffset.MinValue,
|
|
null)
|
|
},
|
|
null,
|
|
null)
|
|
};
|
|
var provider = BuildServiceProvider(backend);
|
|
using var writer = new StringWriter();
|
|
var originalOut = Console.Out;
|
|
Console.SetOut(writer);
|
|
|
|
try
|
|
{
|
|
await CommandHandlers.HandlePolicyFindingsListAsync(
|
|
provider,
|
|
"P-9",
|
|
Array.Empty<string>(),
|
|
Array.Empty<string>(),
|
|
Array.Empty<string>(),
|
|
null,
|
|
null,
|
|
page: null,
|
|
pageSize: null,
|
|
format: "json",
|
|
outputPath: null,
|
|
verbose: false,
|
|
cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Equal(0, Environment.ExitCode);
|
|
using var document = JsonDocument.Parse(writer.ToString());
|
|
var root = document.RootElement;
|
|
Assert.Equal("P-9", root.GetProperty("policyId").GetString());
|
|
var items = root.GetProperty("items");
|
|
Assert.Equal(1, items.GetArrayLength());
|
|
var first = items[0];
|
|
Assert.Equal("finding-1", first.GetProperty("findingId").GetString());
|
|
Assert.Equal("quieted", first.GetProperty("status").GetString());
|
|
Assert.Equal("Medium", first.GetProperty("severity").GetProperty("normalized").GetString());
|
|
}
|
|
finally
|
|
{
|
|
Console.SetOut(originalOut);
|
|
Environment.ExitCode = originalExit;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HandlePolicyFindingsGetAsync_WritesInteractiveTable()
|
|
{
|
|
var originalExit = Environment.ExitCode;
|
|
var originalConsole = AnsiConsole.Console;
|
|
|
|
var console = new TestConsole();
|
|
console.Interactive();
|
|
console.EmitAnsiSequences();
|
|
console.Width(120);
|
|
AnsiConsole.Console = console;
|
|
|
|
var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null))
|
|
{
|
|
FindingDocument = new PolicyFindingDocument(
|
|
"P-9:S-1:pkg:npm/leftpad@1.0.0:CVE-1111",
|
|
"affected",
|
|
new PolicyFindingSeverity("Critical", 9.1),
|
|
"sbom:S-1",
|
|
new[] { "CVE-1111" },
|
|
new PolicyFindingVexMetadata("VendorY-9", null, "affected"),
|
|
7,
|
|
DateTimeOffset.Parse("2025-10-26T12:34:56Z", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal),
|
|
"run:P-9:1234")
|
|
};
|
|
var provider = BuildServiceProvider(backend);
|
|
|
|
try
|
|
{
|
|
await CommandHandlers.HandlePolicyFindingsGetAsync(
|
|
provider,
|
|
"P-9",
|
|
"P-9:S-1:pkg:npm/leftpad@1.0.0:CVE-1111",
|
|
format: "table",
|
|
outputPath: null,
|
|
verbose: false,
|
|
cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Equal(0, Environment.ExitCode);
|
|
Assert.Equal(("P-9", "P-9:S-1:pkg:npm/leftpad@1.0.0:CVE-1111"), backend.LastFindingGet);
|
|
var output = console.Output;
|
|
Assert.Contains("Critical", output);
|
|
Assert.Contains("run:P-9:1234", output);
|
|
}
|
|
finally
|
|
{
|
|
AnsiConsole.Console = originalConsole;
|
|
Environment.ExitCode = originalExit;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HandlePolicyFindingsExplainAsync_WritesInteractiveTable()
|
|
{
|
|
var originalExit = Environment.ExitCode;
|
|
var originalConsole = AnsiConsole.Console;
|
|
|
|
var console = new TestConsole();
|
|
console.Interactive();
|
|
console.EmitAnsiSequences();
|
|
console.Width(140);
|
|
AnsiConsole.Console = console;
|
|
|
|
var steps = new[]
|
|
{
|
|
new PolicyFindingExplainStep(
|
|
"rule-block-critical",
|
|
"blocked",
|
|
"block",
|
|
9.1,
|
|
new ReadOnlyDictionary<string, string>(new Dictionary<string, string>
|
|
{
|
|
["severity"] = "Critical",
|
|
["sealed"] = "false"
|
|
}),
|
|
new ReadOnlyDictionary<string, string>(new Dictionary<string, string>
|
|
{
|
|
["vex"] = "VendorY-9"
|
|
}))
|
|
};
|
|
var hints = new[]
|
|
{
|
|
new PolicyFindingExplainHint("Using cached EPSS percentile from bundle 2025-10-20")
|
|
};
|
|
|
|
var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null))
|
|
{
|
|
ExplainResult = new PolicyFindingExplainResult(
|
|
"P-9:S-1:pkg:npm/leftpad@1.0.0:CVE-1111",
|
|
7,
|
|
new ReadOnlyCollection<PolicyFindingExplainStep>(steps),
|
|
new ReadOnlyCollection<PolicyFindingExplainHint>(hints))
|
|
};
|
|
var provider = BuildServiceProvider(backend);
|
|
|
|
try
|
|
{
|
|
await CommandHandlers.HandlePolicyFindingsExplainAsync(
|
|
provider,
|
|
"P-9",
|
|
"P-9:S-1:pkg:npm/leftpad@1.0.0:CVE-1111",
|
|
mode: "verbose",
|
|
format: "table",
|
|
outputPath: null,
|
|
verbose: false,
|
|
cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Equal(0, Environment.ExitCode);
|
|
Assert.Equal(("P-9", "P-9:S-1:pkg:npm/leftpad@1.0.0:CVE-1111", "verbose"), backend.LastFindingExplain);
|
|
var output = console.Output;
|
|
Assert.Contains("rule-block-critical", output);
|
|
Assert.Contains("EPSS percentile", output);
|
|
}
|
|
finally
|
|
{
|
|
AnsiConsole.Console = originalConsole;
|
|
Environment.ExitCode = originalExit;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HandlePolicySimulateAsync_WritesInteractiveSummary()
|
|
{
|
|
var originalExit = Environment.ExitCode;
|
|
var originalConsole = AnsiConsole.Console;
|
|
|
|
var console = new TestConsole();
|
|
console.Width(120);
|
|
console.Interactive();
|
|
console.EmitAnsiSequences();
|
|
AnsiConsole.Console = console;
|
|
|
|
var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null));
|
|
|
|
var severity = new ReadOnlyDictionary<string, PolicySimulationSeverityDelta>(new Dictionary<string, PolicySimulationSeverityDelta>(StringComparer.Ordinal)
|
|
{
|
|
["critical"] = new PolicySimulationSeverityDelta(1, null),
|
|
["high"] = new PolicySimulationSeverityDelta(null, 2)
|
|
});
|
|
var ruleHits = new ReadOnlyCollection<PolicySimulationRuleDelta>(new List<PolicySimulationRuleDelta>
|
|
{
|
|
new("rule-block-critical", "Block Critical", 1, 0),
|
|
new("rule-quiet-low", "Quiet Low", null, 2)
|
|
});
|
|
|
|
backend.SimulationResult = new PolicySimulationResult(
|
|
new PolicySimulationDiff(
|
|
"scheduler.policy-diff-summary@1",
|
|
2,
|
|
1,
|
|
10,
|
|
severity,
|
|
ruleHits),
|
|
"blob://policy/P-7/simulation.json");
|
|
|
|
var provider = BuildServiceProvider(backend);
|
|
|
|
try
|
|
{
|
|
await CommandHandlers.HandlePolicySimulateAsync(
|
|
provider,
|
|
policyId: "P-7",
|
|
baseVersion: 3,
|
|
candidateVersion: 4,
|
|
sbomArguments: new[] { "sbom:A", "sbom:B" },
|
|
environmentArguments: new[] { "sealed=false", "exposure=internet" },
|
|
format: "table",
|
|
outputPath: null,
|
|
explain: true,
|
|
failOnDiff: false,
|
|
verbose: false,
|
|
cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Equal(0, Environment.ExitCode);
|
|
Assert.NotNull(backend.LastPolicySimulation);
|
|
var simulation = backend.LastPolicySimulation!.Value;
|
|
Assert.Equal("P-7", simulation.PolicyId);
|
|
Assert.Equal(3, simulation.Input.BaseVersion);
|
|
Assert.Equal(4, simulation.Input.CandidateVersion);
|
|
Assert.True(simulation.Input.Explain);
|
|
Assert.Equal(new[] { "sbom:A", "sbom:B" }, simulation.Input.SbomSet);
|
|
Assert.True(simulation.Input.Environment.TryGetValue("sealed", out var sealedValue) && sealedValue is bool sealedFlag && sealedFlag == false);
|
|
Assert.True(simulation.Input.Environment.TryGetValue("exposure", out var exposureValue) && string.Equals(exposureValue as string, "internet", StringComparison.Ordinal));
|
|
|
|
var output = console.Output;
|
|
Assert.Contains("Severity", output, StringComparison.Ordinal);
|
|
Assert.Contains("critical", output, StringComparison.OrdinalIgnoreCase);
|
|
Assert.Contains("Rule", output, StringComparison.Ordinal);
|
|
Assert.Contains("Block Critical", output, StringComparison.Ordinal);
|
|
}
|
|
finally
|
|
{
|
|
Environment.ExitCode = originalExit;
|
|
AnsiConsole.Console = originalConsole;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HandlePolicySimulateAsync_WritesJsonOutput()
|
|
{
|
|
var originalExit = Environment.ExitCode;
|
|
var originalOut = Console.Out;
|
|
|
|
var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null));
|
|
backend.SimulationResult = new PolicySimulationResult(
|
|
new PolicySimulationDiff(
|
|
"scheduler.policy-diff-summary@1",
|
|
0,
|
|
0,
|
|
5,
|
|
new ReadOnlyDictionary<string, PolicySimulationSeverityDelta>(new Dictionary<string, PolicySimulationSeverityDelta>(0, StringComparer.Ordinal)),
|
|
new ReadOnlyCollection<PolicySimulationRuleDelta>(Array.Empty<PolicySimulationRuleDelta>())),
|
|
null);
|
|
|
|
var provider = BuildServiceProvider(backend);
|
|
|
|
using var writer = new StringWriter();
|
|
Console.SetOut(writer);
|
|
|
|
try
|
|
{
|
|
await CommandHandlers.HandlePolicySimulateAsync(
|
|
provider,
|
|
policyId: "P-9",
|
|
baseVersion: null,
|
|
candidateVersion: 5,
|
|
sbomArguments: Array.Empty<string>(),
|
|
environmentArguments: new[] { "sealed=true", "threshold=0.8" },
|
|
format: "json",
|
|
outputPath: null,
|
|
explain: false,
|
|
failOnDiff: false,
|
|
verbose: false,
|
|
cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Equal(0, Environment.ExitCode);
|
|
using var document = JsonDocument.Parse(writer.ToString());
|
|
var root = document.RootElement;
|
|
Assert.Equal("P-9", root.GetProperty("policyId").GetString());
|
|
Assert.Equal(5, root.GetProperty("candidateVersion").GetInt32());
|
|
Assert.True(root.TryGetProperty("environment", out var envElement) && envElement.TryGetProperty("sealed", out var sealedElement) && sealedElement.GetBoolean());
|
|
Assert.True(envElement.TryGetProperty("threshold", out var thresholdElement) && Math.Abs(thresholdElement.GetDouble() - 0.8) < 0.0001);
|
|
}
|
|
finally
|
|
{
|
|
Console.SetOut(originalOut);
|
|
Environment.ExitCode = originalExit;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HandlePolicySimulateAsync_FailOnDiffSetsExitCode20()
|
|
{
|
|
var originalExit = Environment.ExitCode;
|
|
|
|
var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null));
|
|
backend.SimulationResult = new PolicySimulationResult(
|
|
new PolicySimulationDiff(
|
|
null,
|
|
1,
|
|
0,
|
|
0,
|
|
new ReadOnlyDictionary<string, PolicySimulationSeverityDelta>(new Dictionary<string, PolicySimulationSeverityDelta>(0, StringComparer.Ordinal)),
|
|
new ReadOnlyCollection<PolicySimulationRuleDelta>(Array.Empty<PolicySimulationRuleDelta>())),
|
|
null);
|
|
|
|
var provider = BuildServiceProvider(backend);
|
|
|
|
try
|
|
{
|
|
await CommandHandlers.HandlePolicySimulateAsync(
|
|
provider,
|
|
policyId: "P-11",
|
|
baseVersion: null,
|
|
candidateVersion: null,
|
|
sbomArguments: Array.Empty<string>(),
|
|
environmentArguments: Array.Empty<string>(),
|
|
format: "json",
|
|
outputPath: null,
|
|
explain: false,
|
|
failOnDiff: true,
|
|
verbose: false,
|
|
cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Equal(20, Environment.ExitCode);
|
|
}
|
|
finally
|
|
{
|
|
Environment.ExitCode = originalExit;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HandlePolicySimulateAsync_MapsErrorCodes()
|
|
{
|
|
var originalExit = Environment.ExitCode;
|
|
var originalOut = Console.Out;
|
|
|
|
var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null))
|
|
{
|
|
SimulationException = new PolicyApiException("Missing inputs", HttpStatusCode.BadRequest, "ERR_POL_003")
|
|
};
|
|
var provider = BuildServiceProvider(backend);
|
|
|
|
using var writer = new StringWriter();
|
|
Console.SetOut(writer);
|
|
|
|
try
|
|
{
|
|
await CommandHandlers.HandlePolicySimulateAsync(
|
|
provider,
|
|
policyId: "P-12",
|
|
baseVersion: null,
|
|
candidateVersion: null,
|
|
sbomArguments: Array.Empty<string>(),
|
|
environmentArguments: Array.Empty<string>(),
|
|
format: "json",
|
|
outputPath: null,
|
|
explain: false,
|
|
failOnDiff: false,
|
|
verbose: false,
|
|
cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Equal(21, Environment.ExitCode);
|
|
}
|
|
finally
|
|
{
|
|
Console.SetOut(originalOut);
|
|
Environment.ExitCode = originalExit;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HandleTaskRunnerSimulateAsync_WritesInteractiveSummary()
|
|
{
|
|
var originalExit = Environment.ExitCode;
|
|
var originalConsole = AnsiConsole.Console;
|
|
|
|
var console = new TestConsole();
|
|
console.Width(120);
|
|
console.Interactive();
|
|
console.EmitAnsiSequences();
|
|
AnsiConsole.Console = console;
|
|
|
|
const string manifest = """
|
|
apiVersion: stellaops.io/pack.v1
|
|
kind: TaskPack
|
|
metadata:
|
|
name: sample-pack
|
|
spec:
|
|
steps:
|
|
- id: prepare
|
|
run:
|
|
uses: builtin:prepare
|
|
- id: approval
|
|
gate:
|
|
approval:
|
|
id: security-review
|
|
message: Security approval required.
|
|
""";
|
|
|
|
using var manifestFile = new TempFile("pack.yaml", Encoding.UTF8.GetBytes(manifest));
|
|
|
|
var simulationResult = new TaskRunnerSimulationResult(
|
|
"hash-abc123",
|
|
new TaskRunnerSimulationFailurePolicy(3, 15, false),
|
|
new[]
|
|
{
|
|
new TaskRunnerSimulationStep(
|
|
"prepare",
|
|
"prepare",
|
|
"Run",
|
|
true,
|
|
"succeeded",
|
|
null,
|
|
"builtin:prepare",
|
|
null,
|
|
null,
|
|
null,
|
|
false,
|
|
Array.Empty<TaskRunnerSimulationStep>()),
|
|
new TaskRunnerSimulationStep(
|
|
"approval",
|
|
"approval",
|
|
"GateApproval",
|
|
true,
|
|
"pending",
|
|
"requires-approval",
|
|
null,
|
|
"security-review",
|
|
"Security approval required.",
|
|
null,
|
|
false,
|
|
Array.Empty<TaskRunnerSimulationStep>())
|
|
},
|
|
new[]
|
|
{
|
|
new TaskRunnerSimulationOutput("bundlePath", "file", false, "artifacts/report.json", null)
|
|
},
|
|
true);
|
|
|
|
var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null))
|
|
{
|
|
TaskRunnerSimulationResult = simulationResult
|
|
};
|
|
var provider = BuildServiceProvider(backend);
|
|
|
|
try
|
|
{
|
|
await CommandHandlers.HandleTaskRunnerSimulateAsync(
|
|
provider,
|
|
manifestFile.Path,
|
|
inputsPath: null,
|
|
format: null,
|
|
outputPath: null,
|
|
verbose: false,
|
|
cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Equal(0, Environment.ExitCode);
|
|
Assert.NotNull(backend.LastTaskRunnerSimulationRequest);
|
|
Assert.Contains("approval", console.Output, StringComparison.OrdinalIgnoreCase);
|
|
Assert.Contains("Plan Hash", console.Output, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
finally
|
|
{
|
|
AnsiConsole.Console = originalConsole;
|
|
Environment.ExitCode = originalExit;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HandleTaskRunnerSimulateAsync_WritesJsonOutput()
|
|
{
|
|
var originalExit = Environment.ExitCode;
|
|
var originalOut = Console.Out;
|
|
|
|
const string manifest = """
|
|
apiVersion: stellaops.io/pack.v1
|
|
kind: TaskPack
|
|
metadata:
|
|
name: sample-pack
|
|
spec:
|
|
steps:
|
|
- id: prepare
|
|
run:
|
|
uses: builtin:prepare
|
|
""";
|
|
|
|
using var manifestFile = new TempFile("pack.yaml", Encoding.UTF8.GetBytes(manifest));
|
|
using var inputsFile = new TempFile("inputs.json", Encoding.UTF8.GetBytes("{\"dryRun\":false}"));
|
|
using var outputDirectory = new TempDirectory();
|
|
var outputPath = Path.Combine(outputDirectory.Path, "simulation.json");
|
|
|
|
var simulationResult = new TaskRunnerSimulationResult(
|
|
"hash-xyz789",
|
|
new TaskRunnerSimulationFailurePolicy(2, 10, true),
|
|
Array.Empty<TaskRunnerSimulationStep>(),
|
|
Array.Empty<TaskRunnerSimulationOutput>(),
|
|
false);
|
|
|
|
var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null))
|
|
{
|
|
TaskRunnerSimulationResult = simulationResult
|
|
};
|
|
var provider = BuildServiceProvider(backend);
|
|
|
|
using var writer = new StringWriter();
|
|
Console.SetOut(writer);
|
|
|
|
try
|
|
{
|
|
await CommandHandlers.HandleTaskRunnerSimulateAsync(
|
|
provider,
|
|
manifestFile.Path,
|
|
inputsFile.Path,
|
|
format: "json",
|
|
outputPath: outputPath,
|
|
verbose: false,
|
|
cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Equal(0, Environment.ExitCode);
|
|
Assert.NotNull(backend.LastTaskRunnerSimulationRequest);
|
|
|
|
var consoleOutput = writer.ToString();
|
|
using (var consoleJson = JsonDocument.Parse(consoleOutput))
|
|
{
|
|
Assert.Equal("hash-xyz789", consoleJson.RootElement.GetProperty("planHash").GetString());
|
|
}
|
|
|
|
var fileOutput = await File.ReadAllTextAsync(outputPath);
|
|
using (var fileJson = JsonDocument.Parse(fileOutput))
|
|
{
|
|
Assert.Equal("hash-xyz789", fileJson.RootElement.GetProperty("planHash").GetString());
|
|
}
|
|
|
|
Assert.True(backend.LastTaskRunnerSimulationRequest!.Inputs!.TryGetPropertyValue("dryRun", out var dryRunNode));
|
|
Assert.False(dryRunNode!.GetValue<bool>());
|
|
}
|
|
finally
|
|
{
|
|
Console.SetOut(originalOut);
|
|
Environment.ExitCode = originalExit;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HandlePolicyActivateAsync_DisplaysInteractiveSummary()
|
|
{
|
|
var originalExit = Environment.ExitCode;
|
|
var originalConsole = AnsiConsole.Console;
|
|
|
|
var console = new TestConsole();
|
|
console.Width(120);
|
|
console.Interactive();
|
|
console.EmitAnsiSequences();
|
|
AnsiConsole.Console = console;
|
|
|
|
var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null));
|
|
backend.ActivationResult = new PolicyActivationResult(
|
|
"activated",
|
|
new PolicyActivationRevision(
|
|
"P-7",
|
|
4,
|
|
"active",
|
|
true,
|
|
DateTimeOffset.Parse("2025-10-27T00:00:00Z", CultureInfo.InvariantCulture),
|
|
DateTimeOffset.Parse("2025-10-27T01:15:00Z", CultureInfo.InvariantCulture),
|
|
new ReadOnlyCollection<PolicyActivationApproval>(new List<PolicyActivationApproval>
|
|
{
|
|
new("user:alice", DateTimeOffset.Parse("2025-10-27T01:10:00Z", CultureInfo.InvariantCulture), "Primary"),
|
|
new("user:bob", DateTimeOffset.Parse("2025-10-27T01:12:00Z", CultureInfo.InvariantCulture), null)
|
|
})));
|
|
|
|
var provider = BuildServiceProvider(backend);
|
|
|
|
try
|
|
{
|
|
await CommandHandlers.HandlePolicyActivateAsync(
|
|
provider,
|
|
policyId: "P-7",
|
|
version: 4,
|
|
note: "Rolling forward",
|
|
runNow: true,
|
|
scheduledAt: null,
|
|
priority: "high",
|
|
rollback: false,
|
|
incidentId: "INC-204",
|
|
verbose: false,
|
|
cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Equal(0, Environment.ExitCode);
|
|
Assert.NotNull(backend.LastPolicyActivation);
|
|
var activation = backend.LastPolicyActivation!.Value;
|
|
Assert.Equal("P-7", activation.PolicyId);
|
|
Assert.Equal(4, activation.Version);
|
|
Assert.True(activation.Request.RunNow);
|
|
Assert.Null(activation.Request.ScheduledAt);
|
|
Assert.Equal("high", activation.Request.Priority);
|
|
Assert.Equal("INC-204", activation.Request.IncidentId);
|
|
Assert.Equal("Rolling forward", activation.Request.Comment);
|
|
|
|
var output = console.Output;
|
|
Assert.Contains("activated", output, StringComparison.OrdinalIgnoreCase);
|
|
Assert.Contains("user:alice", output, StringComparison.Ordinal);
|
|
Assert.Contains("Rolling forward", output, StringComparison.Ordinal);
|
|
}
|
|
finally
|
|
{
|
|
Environment.ExitCode = originalExit;
|
|
AnsiConsole.Console = originalConsole;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HandlePolicyActivateAsync_PendingSecondApprovalSetsExitCode()
|
|
{
|
|
var originalExit = Environment.ExitCode;
|
|
|
|
var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null));
|
|
backend.ActivationResult = new PolicyActivationResult(
|
|
"pending_second_approval",
|
|
new PolicyActivationRevision(
|
|
"P-7",
|
|
4,
|
|
"approved",
|
|
true,
|
|
DateTimeOffset.UtcNow,
|
|
null,
|
|
new ReadOnlyCollection<PolicyActivationApproval>(new List<PolicyActivationApproval>
|
|
{
|
|
new("user:alice", DateTimeOffset.UtcNow, "Primary")
|
|
})));
|
|
|
|
var provider = BuildServiceProvider(backend);
|
|
|
|
try
|
|
{
|
|
await CommandHandlers.HandlePolicyActivateAsync(
|
|
provider,
|
|
policyId: "P-7",
|
|
version: 4,
|
|
note: null,
|
|
runNow: false,
|
|
scheduledAt: null,
|
|
priority: null,
|
|
rollback: false,
|
|
incidentId: null,
|
|
verbose: false,
|
|
cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Equal(75, Environment.ExitCode);
|
|
}
|
|
finally
|
|
{
|
|
Environment.ExitCode = originalExit;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HandlePolicyActivateAsync_ParsesScheduledTimestamp()
|
|
{
|
|
var originalExit = Environment.ExitCode;
|
|
var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null));
|
|
backend.ActivationResult = new PolicyActivationResult(
|
|
"scheduled",
|
|
new PolicyActivationRevision(
|
|
"P-8",
|
|
5,
|
|
"approved",
|
|
false,
|
|
DateTimeOffset.Parse("2025-12-01T00:30:00Z", CultureInfo.InvariantCulture),
|
|
null,
|
|
new ReadOnlyCollection<PolicyActivationApproval>(Array.Empty<PolicyActivationApproval>())));
|
|
|
|
var provider = BuildServiceProvider(backend);
|
|
|
|
try
|
|
{
|
|
const string scheduledValue = "2025-12-01T03:00:00+02:00";
|
|
await CommandHandlers.HandlePolicyActivateAsync(
|
|
provider,
|
|
policyId: "P-8",
|
|
version: 5,
|
|
note: null,
|
|
runNow: false,
|
|
scheduledAt: scheduledValue,
|
|
priority: null,
|
|
rollback: false,
|
|
incidentId: null,
|
|
verbose: false,
|
|
cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Equal(0, Environment.ExitCode);
|
|
Assert.NotNull(backend.LastPolicyActivation);
|
|
var activation = backend.LastPolicyActivation!.Value;
|
|
Assert.False(activation.Request.RunNow);
|
|
var expected = DateTimeOffset.Parse(
|
|
scheduledValue,
|
|
CultureInfo.InvariantCulture,
|
|
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal);
|
|
Assert.Equal(expected, activation.Request.ScheduledAt);
|
|
}
|
|
finally
|
|
{
|
|
Environment.ExitCode = originalExit;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HandlePolicyActivateAsync_MapsErrorCodes()
|
|
{
|
|
var originalExit = Environment.ExitCode;
|
|
|
|
var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null))
|
|
{
|
|
ActivationException = new PolicyApiException("Revision not approved", HttpStatusCode.BadRequest, "ERR_POL_002")
|
|
};
|
|
|
|
var provider = BuildServiceProvider(backend);
|
|
|
|
try
|
|
{
|
|
await CommandHandlers.HandlePolicyActivateAsync(
|
|
provider,
|
|
policyId: "P-9",
|
|
version: 2,
|
|
note: null,
|
|
runNow: false,
|
|
scheduledAt: null,
|
|
priority: null,
|
|
rollback: false,
|
|
incidentId: null,
|
|
verbose: false,
|
|
cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Equal(70, Environment.ExitCode);
|
|
}
|
|
finally
|
|
{
|
|
Environment.ExitCode = originalExit;
|
|
}
|
|
}
|
|
|
|
private static async Task<RevocationArtifactPaths> WriteRevocationArtifactsAsync(TempDirectory temp, string? providerHint)
|
|
{
|
|
var (bundleBytes, signature, keyPem) = await BuildRevocationArtifactsAsync(providerHint);
|
|
|
|
var bundlePath = Path.Combine(temp.Path, "revocation-bundle.json");
|
|
var signaturePath = Path.Combine(temp.Path, "revocation-bundle.json.jws");
|
|
var keyPath = Path.Combine(temp.Path, "revocation-key.pem");
|
|
|
|
await File.WriteAllBytesAsync(bundlePath, bundleBytes);
|
|
await File.WriteAllTextAsync(signaturePath, signature);
|
|
await File.WriteAllTextAsync(keyPath, keyPem);
|
|
|
|
return new RevocationArtifactPaths(bundlePath, signaturePath, keyPath);
|
|
}
|
|
|
|
private static async Task<(byte[] Bundle, string Signature, string KeyPem)> BuildRevocationArtifactsAsync(string? providerHint)
|
|
{
|
|
var bundleBytes = Encoding.UTF8.GetBytes("{\"revocations\":[]}");
|
|
|
|
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
|
var parameters = ecdsa.ExportParameters(includePrivateParameters: true);
|
|
|
|
var signingKey = new CryptoSigningKey(
|
|
new CryptoKeyReference("revocation-test"),
|
|
SignatureAlgorithms.Es256,
|
|
privateParameters: in parameters,
|
|
createdAt: DateTimeOffset.UtcNow);
|
|
|
|
var provider = new DefaultCryptoProvider();
|
|
provider.UpsertSigningKey(signingKey);
|
|
var signer = provider.GetSigner(SignatureAlgorithms.Es256, signingKey.Reference);
|
|
|
|
var header = new Dictionary<string, object>
|
|
{
|
|
["alg"] = SignatureAlgorithms.Es256,
|
|
["kid"] = signingKey.Reference.KeyId,
|
|
["typ"] = "application/vnd.stellaops.revocation-bundle+jws",
|
|
["b64"] = false,
|
|
["crit"] = new[] { "b64" }
|
|
};
|
|
|
|
if (!string.IsNullOrWhiteSpace(providerHint))
|
|
{
|
|
header["provider"] = providerHint;
|
|
}
|
|
|
|
var serializerOptions = new JsonSerializerOptions
|
|
{
|
|
PropertyNamingPolicy = null,
|
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
|
};
|
|
|
|
var headerJson = JsonSerializer.Serialize(header, serializerOptions);
|
|
var encodedHeader = Base64UrlEncoder.Encode(Encoding.UTF8.GetBytes(headerJson));
|
|
|
|
var signingInput = new byte[encodedHeader.Length + 1 + bundleBytes.Length];
|
|
var headerBytes = Encoding.ASCII.GetBytes(encodedHeader);
|
|
Buffer.BlockCopy(headerBytes, 0, signingInput, 0, headerBytes.Length);
|
|
signingInput[headerBytes.Length] = (byte)'.';
|
|
Buffer.BlockCopy(bundleBytes, 0, signingInput, headerBytes.Length + 1, bundleBytes.Length);
|
|
|
|
var signatureBytes = await signer.SignAsync(signingInput);
|
|
var encodedSignature = Base64UrlEncoder.Encode(signatureBytes);
|
|
var jws = string.Concat(encodedHeader, "..", encodedSignature);
|
|
|
|
var publicKeyBytes = ecdsa.ExportSubjectPublicKeyInfo();
|
|
var keyPem = new string(PemEncoding.Write("PUBLIC KEY", publicKeyBytes));
|
|
|
|
return (bundleBytes, jws, keyPem);
|
|
}
|
|
|
|
private sealed record RevocationArtifactPaths(string BundlePath, string SignaturePath, string KeyPath);
|
|
|
|
[Fact]
|
|
public async Task HandleSourcesIngestAsync_NoViolations_WritesJsonReport()
|
|
{
|
|
var originalExitCode = Environment.ExitCode;
|
|
var originalTenant = Environment.GetEnvironmentVariable("STELLA_TENANT");
|
|
using var tempDir = new TempDirectory();
|
|
|
|
var originalConsole = AnsiConsole.Console;
|
|
var console = new TestConsole();
|
|
var originalOut = Console.Out;
|
|
using var writer = new StringWriter();
|
|
|
|
try
|
|
{
|
|
Environment.SetEnvironmentVariable("STELLA_TENANT", "tenant-alpha");
|
|
AnsiConsole.Console = console;
|
|
Console.SetOut(writer);
|
|
|
|
var inputPath = Path.Combine(tempDir.Path, "payload.json");
|
|
await File.WriteAllTextAsync(inputPath, "{ \"id\": 1 }");
|
|
|
|
var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null))
|
|
{
|
|
DryRunResponse = new AocIngestDryRunResponse
|
|
{
|
|
Source = "redhat",
|
|
Tenant = "tenant-alpha",
|
|
Status = "ok",
|
|
Document = new AocIngestDryRunDocumentResult
|
|
{
|
|
ContentHash = "sha256:test"
|
|
},
|
|
Violations = Array.Empty<AocIngestDryRunViolation>()
|
|
}
|
|
};
|
|
|
|
var provider = BuildServiceProvider(backend);
|
|
var outputPath = Path.Combine(tempDir.Path, "dry-run.json");
|
|
|
|
await CommandHandlers.HandleSourcesIngestAsync(
|
|
provider,
|
|
dryRun: true,
|
|
source: "RedHat",
|
|
input: inputPath,
|
|
tenantOverride: null,
|
|
format: "json",
|
|
disableColor: true,
|
|
output: outputPath,
|
|
verbose: false,
|
|
cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Equal(0, Environment.ExitCode);
|
|
Assert.True(File.Exists(outputPath));
|
|
|
|
Assert.NotNull(backend.LastDryRunRequest);
|
|
var request = backend.LastDryRunRequest!;
|
|
Assert.Equal("tenant-alpha", request.Tenant);
|
|
Assert.Equal("RedHat", request.Source);
|
|
Assert.Equal("payload.json", request.Document.Name);
|
|
Assert.Equal("application/json", request.Document.ContentType);
|
|
Assert.Null(request.Document.ContentEncoding);
|
|
using (var document = JsonDocument.Parse(request.Document.Content))
|
|
{
|
|
Assert.Equal(1, document.RootElement.GetProperty("id").GetInt32());
|
|
}
|
|
|
|
var consoleJson = writer.ToString();
|
|
Assert.Contains("\"status\": \"ok\"", consoleJson);
|
|
}
|
|
finally
|
|
{
|
|
Environment.ExitCode = originalExitCode;
|
|
Environment.SetEnvironmentVariable("STELLA_TENANT", originalTenant);
|
|
AnsiConsole.Console = originalConsole;
|
|
Console.SetOut(originalOut);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HandleSourcesIngestAsync_ViolationMapsExitCode()
|
|
{
|
|
var originalExitCode = Environment.ExitCode;
|
|
var originalTenant = Environment.GetEnvironmentVariable("STELLA_TENANT");
|
|
using var tempDir = new TempDirectory();
|
|
|
|
var originalConsole = AnsiConsole.Console;
|
|
var console = new TestConsole();
|
|
|
|
try
|
|
{
|
|
Environment.SetEnvironmentVariable("STELLA_TENANT", "tenant-beta");
|
|
AnsiConsole.Console = console;
|
|
|
|
var inputPath = Path.Combine(tempDir.Path, "payload.json");
|
|
await File.WriteAllTextAsync(inputPath, "{ \"id\": 2 }");
|
|
|
|
var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null))
|
|
{
|
|
DryRunResponse = new AocIngestDryRunResponse
|
|
{
|
|
Status = "error",
|
|
Violations = new[]
|
|
{
|
|
new AocIngestDryRunViolation
|
|
{
|
|
Code = "ERR_AOC_002",
|
|
Message = "merge detected",
|
|
Path = "/content/derived"
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
var provider = BuildServiceProvider(backend);
|
|
|
|
await CommandHandlers.HandleSourcesIngestAsync(
|
|
provider,
|
|
dryRun: true,
|
|
source: "osv",
|
|
input: inputPath,
|
|
tenantOverride: null,
|
|
format: "table",
|
|
disableColor: true,
|
|
output: null,
|
|
verbose: false,
|
|
cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Equal(12, Environment.ExitCode);
|
|
var output = console.Output;
|
|
Assert.Contains("ERR_AOC_002", output);
|
|
Assert.Contains("/content/derived", output);
|
|
}
|
|
finally
|
|
{
|
|
Environment.ExitCode = originalExitCode;
|
|
Environment.SetEnvironmentVariable("STELLA_TENANT", originalTenant);
|
|
AnsiConsole.Console = originalConsole;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HandleSourcesIngestAsync_MissingTenant_ReturnsUsageError()
|
|
{
|
|
var originalExitCode = Environment.ExitCode;
|
|
var originalTenant = Environment.GetEnvironmentVariable("STELLA_TENANT");
|
|
using var tempDir = new TempDirectory();
|
|
|
|
try
|
|
{
|
|
Environment.SetEnvironmentVariable("STELLA_TENANT", null);
|
|
|
|
var inputPath = Path.Combine(tempDir.Path, "payload.json");
|
|
await File.WriteAllTextAsync(inputPath, "{ \"id\": 3 }");
|
|
|
|
var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null));
|
|
var provider = BuildServiceProvider(backend);
|
|
|
|
await CommandHandlers.HandleSourcesIngestAsync(
|
|
provider,
|
|
dryRun: true,
|
|
source: "osv",
|
|
input: inputPath,
|
|
tenantOverride: null,
|
|
format: "table",
|
|
disableColor: true,
|
|
output: null,
|
|
verbose: false,
|
|
cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Equal(70, Environment.ExitCode);
|
|
}
|
|
finally
|
|
{
|
|
Environment.ExitCode = originalExitCode;
|
|
Environment.SetEnvironmentVariable("STELLA_TENANT", originalTenant);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HandleAocVerifyAsync_NoViolations_WritesReportAndReturnsZero()
|
|
{
|
|
var originalExitCode = Environment.ExitCode;
|
|
var originalTenant = Environment.GetEnvironmentVariable("STELLA_TENANT");
|
|
using var tempDir = new TempDirectory();
|
|
|
|
var originalConsole = AnsiConsole.Console;
|
|
var console = new TestConsole();
|
|
var originalOut = Console.Out;
|
|
using var writer = new StringWriter();
|
|
|
|
try
|
|
{
|
|
AnsiConsole.Console = console;
|
|
Console.SetOut(writer);
|
|
Environment.SetEnvironmentVariable("STELLA_TENANT", "tenant-a");
|
|
|
|
var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null))
|
|
{
|
|
VerifyResponse = new AocVerifyResponse
|
|
{
|
|
Tenant = "tenant-a",
|
|
Window = new AocVerifyWindow
|
|
{
|
|
From = DateTimeOffset.Parse("2025-10-25T12:00:00Z", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal),
|
|
To = DateTimeOffset.Parse("2025-10-26T12:00:00Z", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal)
|
|
},
|
|
Checked = new AocVerifyChecked { Advisories = 4, Vex = 1 },
|
|
Metrics = new AocVerifyMetrics { IngestionWriteTotal = 5, AocViolationTotal = 0 },
|
|
Violations = Array.Empty<AocVerifyViolation>(),
|
|
Truncated = false
|
|
}
|
|
};
|
|
|
|
var provider = BuildServiceProvider(backend);
|
|
var exportPath = Path.Combine(tempDir.Path, "verify.json");
|
|
|
|
await CommandHandlers.HandleAocVerifyAsync(
|
|
provider,
|
|
sinceOption: "2025-10-25T12:00:00Z",
|
|
limitOption: 10,
|
|
sourcesOption: "RedHat,Ubuntu",
|
|
codesOption: "err_aoc_001",
|
|
format: "json",
|
|
exportPath: exportPath,
|
|
tenantOverride: null,
|
|
disableColor: true,
|
|
verbose: false,
|
|
cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Equal(0, Environment.ExitCode);
|
|
Assert.True(File.Exists(exportPath));
|
|
|
|
Assert.NotNull(backend.LastVerifyRequest);
|
|
Assert.Equal("tenant-a", backend.LastVerifyRequest!.Tenant);
|
|
var expectedSince = DateTimeOffset.Parse("2025-10-25T12:00:00Z", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal);
|
|
var actualSince = DateTimeOffset.Parse(backend.LastVerifyRequest.Since!, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal);
|
|
Assert.Equal(expectedSince, actualSince);
|
|
Assert.Equal(10, backend.LastVerifyRequest.Limit);
|
|
Assert.Equal(new[] { "redhat", "ubuntu" }, backend.LastVerifyRequest.Sources);
|
|
Assert.Equal(new[] { "ERR_AOC_001" }, backend.LastVerifyRequest.Codes);
|
|
|
|
var jsonOutput = writer.ToString();
|
|
Assert.Contains("\"tenant\": \"tenant-a\"", jsonOutput);
|
|
Assert.Contains("\"ingestion_write_total\": 5", jsonOutput);
|
|
}
|
|
finally
|
|
{
|
|
Environment.ExitCode = originalExitCode;
|
|
Environment.SetEnvironmentVariable("STELLA_TENANT", originalTenant);
|
|
Console.SetOut(originalOut);
|
|
AnsiConsole.Console = originalConsole;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HandleAocVerifyAsync_WithViolations_MapsExitCode()
|
|
{
|
|
var originalExitCode = Environment.ExitCode;
|
|
var originalTenant = Environment.GetEnvironmentVariable("STELLA_TENANT");
|
|
|
|
var originalConsole = AnsiConsole.Console;
|
|
var console = new TestConsole();
|
|
|
|
try
|
|
{
|
|
AnsiConsole.Console = console;
|
|
Environment.SetEnvironmentVariable("STELLA_TENANT", "tenant-b");
|
|
|
|
var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null))
|
|
{
|
|
VerifyResponse = new AocVerifyResponse
|
|
{
|
|
Violations = new[]
|
|
{
|
|
new AocVerifyViolation
|
|
{
|
|
Code = "ERR_AOC_003",
|
|
Count = 2,
|
|
Examples = new[]
|
|
{
|
|
new AocVerifyViolationExample
|
|
{
|
|
Source = "redhat",
|
|
DocumentId = "doc-1",
|
|
Path = "/content/raw"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
var provider = BuildServiceProvider(backend);
|
|
|
|
var capturedBefore = DateTimeOffset.UtcNow;
|
|
|
|
await CommandHandlers.HandleAocVerifyAsync(
|
|
provider,
|
|
sinceOption: "24h",
|
|
limitOption: null,
|
|
sourcesOption: null,
|
|
codesOption: null,
|
|
format: "table",
|
|
exportPath: null,
|
|
tenantOverride: null,
|
|
disableColor: true,
|
|
verbose: false,
|
|
cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Equal(13, Environment.ExitCode);
|
|
Assert.NotNull(backend.LastVerifyRequest);
|
|
Assert.Equal(20, backend.LastVerifyRequest!.Limit);
|
|
Assert.Null(backend.LastVerifyRequest.Sources);
|
|
Assert.Null(backend.LastVerifyRequest.Codes);
|
|
|
|
var parsedSince = DateTimeOffset.Parse(backend.LastVerifyRequest.Since!, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal);
|
|
var expectedSince = capturedBefore.AddHours(-24);
|
|
Assert.InRange((expectedSince - parsedSince).Duration(), TimeSpan.Zero, TimeSpan.FromSeconds(10));
|
|
|
|
var output = console.Output;
|
|
Assert.Contains("ERR_AOC_003", output);
|
|
Assert.Contains("doc-1", output);
|
|
}
|
|
finally
|
|
{
|
|
Environment.ExitCode = originalExitCode;
|
|
Environment.SetEnvironmentVariable("STELLA_TENANT", originalTenant);
|
|
AnsiConsole.Console = originalConsole;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HandleAocVerifyAsync_TruncatedWithoutViolations_ReturnsExitCode18()
|
|
{
|
|
var originalExitCode = Environment.ExitCode;
|
|
var originalTenant = Environment.GetEnvironmentVariable("STELLA_TENANT");
|
|
|
|
var originalConsole = AnsiConsole.Console;
|
|
var console = new TestConsole();
|
|
|
|
try
|
|
{
|
|
AnsiConsole.Console = console;
|
|
Environment.SetEnvironmentVariable("STELLA_TENANT", "tenant-c");
|
|
|
|
var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null))
|
|
{
|
|
VerifyResponse = new AocVerifyResponse
|
|
{
|
|
Violations = Array.Empty<AocVerifyViolation>(),
|
|
Truncated = true
|
|
}
|
|
};
|
|
|
|
var provider = BuildServiceProvider(backend);
|
|
|
|
await CommandHandlers.HandleAocVerifyAsync(
|
|
provider,
|
|
sinceOption: "2025-01-01T00:00:00Z",
|
|
limitOption: 0,
|
|
sourcesOption: null,
|
|
codesOption: null,
|
|
format: "table",
|
|
exportPath: null,
|
|
tenantOverride: null,
|
|
disableColor: true,
|
|
verbose: false,
|
|
cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Equal(18, Environment.ExitCode);
|
|
|
|
var output = console.Output;
|
|
Assert.Contains("Truncated", output);
|
|
}
|
|
finally
|
|
{
|
|
Environment.ExitCode = originalExitCode;
|
|
Environment.SetEnvironmentVariable("STELLA_TENANT", originalTenant);
|
|
AnsiConsole.Console = originalConsole;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HandleAocVerifyAsync_MissingTenant_ReturnsUsageError()
|
|
{
|
|
var originalExitCode = Environment.ExitCode;
|
|
var originalTenant = Environment.GetEnvironmentVariable("STELLA_TENANT");
|
|
|
|
try
|
|
{
|
|
Environment.SetEnvironmentVariable("STELLA_TENANT", null);
|
|
|
|
var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null));
|
|
var provider = BuildServiceProvider(backend);
|
|
|
|
await CommandHandlers.HandleAocVerifyAsync(
|
|
provider,
|
|
sinceOption: "24h",
|
|
limitOption: null,
|
|
sourcesOption: null,
|
|
codesOption: null,
|
|
format: "table",
|
|
exportPath: null,
|
|
tenantOverride: null,
|
|
disableColor: true,
|
|
verbose: false,
|
|
cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Equal(71, Environment.ExitCode);
|
|
}
|
|
finally
|
|
{
|
|
Environment.ExitCode = originalExitCode;
|
|
Environment.SetEnvironmentVariable("STELLA_TENANT", originalTenant);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HandleKmsExportAsync_WritesKeyBundle()
|
|
{
|
|
using var kmsRoot = new TempDirectory();
|
|
using var exportRoot = new TempDirectory();
|
|
const string passphrase = "P@ssw0rd!";
|
|
|
|
using (var client = new FileKmsClient(new FileKmsOptions
|
|
{
|
|
RootPath = kmsRoot.Path,
|
|
Password = passphrase
|
|
}))
|
|
{
|
|
await client.RotateAsync("cli-export-key");
|
|
}
|
|
|
|
var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null));
|
|
var provider = BuildServiceProvider(backend);
|
|
var outputPath = Path.Combine(exportRoot.Path, "export.json");
|
|
var originalExit = Environment.ExitCode;
|
|
|
|
try
|
|
{
|
|
await CommandHandlers.HandleKmsExportAsync(
|
|
provider,
|
|
kmsRoot.Path,
|
|
keyId: "cli-export-key",
|
|
versionId: null,
|
|
outputPath: outputPath,
|
|
overwrite: false,
|
|
passphrase: passphrase,
|
|
verbose: false,
|
|
cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Equal(0, Environment.ExitCode);
|
|
Assert.True(File.Exists(outputPath));
|
|
|
|
var json = await File.ReadAllTextAsync(outputPath);
|
|
var material = JsonSerializer.Deserialize<KmsKeyMaterial>(json, new JsonSerializerOptions(JsonSerializerDefaults.Web));
|
|
Assert.NotNull(material);
|
|
Assert.Equal("cli-export-key", material!.KeyId);
|
|
Assert.False(string.IsNullOrWhiteSpace(material.VersionId));
|
|
Assert.NotNull(material.D);
|
|
}
|
|
finally
|
|
{
|
|
Environment.ExitCode = originalExit;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HandleKmsImportAsync_ImportsKeyBundle()
|
|
{
|
|
using var sourceRoot = new TempDirectory();
|
|
using var targetRoot = new TempDirectory();
|
|
const string passphrase = "AnotherP@ssw0rd!";
|
|
|
|
KmsKeyMaterial exported;
|
|
using (var sourceClient = new FileKmsClient(new FileKmsOptions
|
|
{
|
|
RootPath = sourceRoot.Path,
|
|
Password = passphrase
|
|
}))
|
|
{
|
|
await sourceClient.RotateAsync("cli-import-key");
|
|
exported = await sourceClient.ExportAsync("cli-import-key", null);
|
|
}
|
|
|
|
var exportPath = Path.Combine(sourceRoot.Path, "import.json");
|
|
var exportJson = JsonSerializer.Serialize(exported, new JsonSerializerOptions(JsonSerializerDefaults.Web) { WriteIndented = true });
|
|
await File.WriteAllTextAsync(exportPath, exportJson);
|
|
|
|
var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null));
|
|
var provider = BuildServiceProvider(backend);
|
|
var originalExit = Environment.ExitCode;
|
|
|
|
try
|
|
{
|
|
await CommandHandlers.HandleKmsImportAsync(
|
|
provider,
|
|
targetRoot.Path,
|
|
keyId: "cli-import-key",
|
|
inputPath: exportPath,
|
|
versionOverride: null,
|
|
passphrase: passphrase,
|
|
verbose: false,
|
|
cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Equal(0, Environment.ExitCode);
|
|
|
|
using var importedClient = new FileKmsClient(new FileKmsOptions
|
|
{
|
|
RootPath = targetRoot.Path,
|
|
Password = passphrase
|
|
});
|
|
|
|
var metadata = await importedClient.GetMetadataAsync("cli-import-key");
|
|
Assert.Equal(KmsKeyState.Active, metadata.State);
|
|
Assert.Single(metadata.Versions);
|
|
Assert.Equal(exported.VersionId, metadata.Versions[0].VersionId);
|
|
}
|
|
finally
|
|
{
|
|
Environment.ExitCode = originalExit;
|
|
}
|
|
}
|
|
|
|
private static void CreateJavaLockFixture(string root)
|
|
{
|
|
Directory.CreateDirectory(root);
|
|
|
|
var jarPath = Path.Combine(root, "runtime-only-1.0.0.jar");
|
|
CreateJavaJar(jarPath, "com.example", "runtime-only", "1.0.0");
|
|
|
|
var gradleLock = string.Join(
|
|
Environment.NewLine,
|
|
"# Gradle lockfile",
|
|
"com.example:declared-only:2.0.0=runtimeClasspath");
|
|
File.WriteAllText(Path.Combine(root, "gradle.lockfile"), gradleLock);
|
|
}
|
|
|
|
private static void CreateJavaJar(string path, string groupId, string artifactId, string version)
|
|
{
|
|
if (File.Exists(path))
|
|
{
|
|
File.Delete(path);
|
|
}
|
|
|
|
using var archive = ZipFile.Open(path, ZipArchiveMode.Create);
|
|
var pomEntryPath = $"META-INF/maven/{groupId}/{artifactId}/pom.properties";
|
|
var pomEntry = archive.CreateEntry(pomEntryPath);
|
|
using (var writer = new StreamWriter(pomEntry.Open(), Encoding.UTF8))
|
|
{
|
|
writer.WriteLine($"groupId={groupId}");
|
|
writer.WriteLine($"artifactId={artifactId}");
|
|
writer.WriteLine($"version={version}");
|
|
writer.WriteLine("packaging=jar");
|
|
writer.WriteLine($"name={artifactId}");
|
|
}
|
|
|
|
var manifestEntry = archive.CreateEntry("META-INF/MANIFEST.MF");
|
|
using (var writer = new StreamWriter(manifestEntry.Open(), Encoding.UTF8))
|
|
{
|
|
writer.WriteLine("Manifest-Version: 1.0");
|
|
writer.WriteLine($"Implementation-Title: {artifactId}");
|
|
writer.WriteLine($"Implementation-Version: {version}");
|
|
writer.WriteLine($"Implementation-Vendor: {groupId}");
|
|
}
|
|
|
|
var classEntry = archive.CreateEntry($"{artifactId.Replace('-', '_')}/Main.class");
|
|
using var classStream = classEntry.Open();
|
|
classStream.Write(new byte[] { 0xCA, 0xFE, 0xBA, 0xBE });
|
|
}
|
|
|
|
private static void CreateNodeLockFixture(string root)
|
|
{
|
|
Directory.CreateDirectory(root);
|
|
|
|
var packageJson = """
|
|
{
|
|
"name": "workspace-app",
|
|
"version": "1.0.0",
|
|
"dependencies": {
|
|
"declared-only": "9.9.9",
|
|
"runtime-only": "1.0.0"
|
|
}
|
|
}
|
|
""";
|
|
File.WriteAllText(Path.Combine(root, "package.json"), packageJson);
|
|
|
|
var runtimeDir = Path.Combine(root, "node_modules", "runtime-only");
|
|
Directory.CreateDirectory(runtimeDir);
|
|
var runtimePackageJson = """
|
|
{
|
|
"name": "runtime-only",
|
|
"version": "1.0.0"
|
|
}
|
|
""";
|
|
File.WriteAllText(Path.Combine(runtimeDir, "package.json"), runtimePackageJson);
|
|
|
|
var packageLock = """
|
|
{
|
|
"name": "workspace-app",
|
|
"version": "1.0.0",
|
|
"lockfileVersion": 3,
|
|
"packages": {
|
|
"": {
|
|
"name": "workspace-app",
|
|
"version": "1.0.0"
|
|
},
|
|
"node_modules/declared-only": {
|
|
"name": "declared-only",
|
|
"version": "9.9.9",
|
|
"resolved": "https://registry.example/declared-only-9.9.9.tgz",
|
|
"integrity": "sha512-DECLAREDONLY"
|
|
}
|
|
}
|
|
}
|
|
""";
|
|
File.WriteAllText(Path.Combine(root, "package-lock.json"), packageLock);
|
|
}
|
|
|
|
private static IServiceProvider BuildServiceProvider(
|
|
IBackendOperationsClient backend,
|
|
IScannerExecutor? executor = null,
|
|
IScannerInstaller? installer = null,
|
|
StellaOpsCliOptions? options = null,
|
|
IStellaOpsTokenClient? tokenClient = null,
|
|
IConcelierObservationsClient? concelierClient = null,
|
|
ILoggerProvider? loggerProvider = null)
|
|
{
|
|
var services = new ServiceCollection();
|
|
services.AddSingleton(backend);
|
|
services.AddSingleton<ILoggerFactory>(_ => LoggerFactory.Create(builder =>
|
|
{
|
|
builder.SetMinimumLevel(LogLevel.Debug);
|
|
if (loggerProvider is not null)
|
|
{
|
|
builder.AddProvider(loggerProvider);
|
|
}
|
|
}));
|
|
services.AddSingleton(new VerbosityState());
|
|
services.AddHttpClient();
|
|
var resolvedOptions = options ?? new StellaOpsCliOptions
|
|
{
|
|
ResultsDirectory = Path.Combine(Path.GetTempPath(), $"stellaops-cli-results-{Guid.NewGuid():N}")
|
|
};
|
|
services.AddSingleton(resolvedOptions);
|
|
|
|
var resolvedExecutor = executor ?? CreateDefaultExecutor();
|
|
services.AddSingleton<IScannerExecutor>(resolvedExecutor);
|
|
services.AddSingleton<IScannerInstaller>(installer ?? new StubInstaller());
|
|
|
|
if (tokenClient is not null)
|
|
{
|
|
services.AddSingleton(tokenClient);
|
|
}
|
|
|
|
services.AddSingleton<IConcelierObservationsClient>(
|
|
concelierClient ?? new StubConcelierObservationsClient());
|
|
|
|
return services.BuildServiceProvider();
|
|
}
|
|
|
|
private static async Task<CapturedConsoleOutput> CaptureTestConsoleAsync(Func<TestConsole, Task> action)
|
|
{
|
|
var testConsole = new TestConsole();
|
|
var originalConsole = AnsiConsole.Console;
|
|
var originalOut = Console.Out;
|
|
using var writer = new StringWriter();
|
|
|
|
try
|
|
{
|
|
AnsiConsole.Console = testConsole;
|
|
Console.SetOut(writer);
|
|
|
|
await action(testConsole).ConfigureAwait(false);
|
|
return new CapturedConsoleOutput(testConsole.Output.ToString(), writer.ToString());
|
|
}
|
|
finally
|
|
{
|
|
Console.SetOut(originalOut);
|
|
AnsiConsole.Console = originalConsole;
|
|
}
|
|
}
|
|
|
|
private static async Task CreatePythonLockFixtureAsync(string root, CancellationToken cancellationToken)
|
|
{
|
|
await CreatePythonPackageAsync(root, "locked", "1.0.0", cancellationToken).ConfigureAwait(false);
|
|
await CreatePythonPackageAsync(root, "runtime-only", "2.0.0", cancellationToken).ConfigureAwait(false);
|
|
|
|
var requirements = new StringBuilder()
|
|
.AppendLine("locked==1.0.0")
|
|
.AppendLine("declared-only==3.0.0")
|
|
.ToString();
|
|
var path = Path.Combine(root, "requirements.txt");
|
|
await File.WriteAllTextAsync(path, requirements, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
private static async Task CreatePythonPackageAsync(string root, string name, string version, CancellationToken cancellationToken)
|
|
{
|
|
var sitePackages = Path.Combine(root, "lib", "python3.11", "site-packages");
|
|
Directory.CreateDirectory(sitePackages);
|
|
|
|
var packageDir = Path.Combine(sitePackages, name);
|
|
Directory.CreateDirectory(packageDir);
|
|
|
|
var modulePath = Path.Combine(packageDir, "__init__.py");
|
|
var moduleContent = $"__version__ = \"{version}\"{Environment.NewLine}";
|
|
await File.WriteAllTextAsync(modulePath, moduleContent, cancellationToken).ConfigureAwait(false);
|
|
|
|
var distInfoDir = Path.Combine(sitePackages, $"{name}-{version}.dist-info");
|
|
Directory.CreateDirectory(distInfoDir);
|
|
|
|
var metadataPath = Path.Combine(distInfoDir, "METADATA");
|
|
var metadataContent = $"Metadata-Version: 2.1{Environment.NewLine}Name: {name}{Environment.NewLine}Version: {version}{Environment.NewLine}";
|
|
await File.WriteAllTextAsync(metadataPath, metadataContent, cancellationToken).ConfigureAwait(false);
|
|
|
|
var wheelPath = Path.Combine(distInfoDir, "WHEEL");
|
|
await File.WriteAllTextAsync(wheelPath, "Wheel-Version: 1.0", cancellationToken).ConfigureAwait(false);
|
|
|
|
var entryPointsPath = Path.Combine(distInfoDir, "entry_points.txt");
|
|
await File.WriteAllTextAsync(entryPointsPath, string.Empty, cancellationToken).ConfigureAwait(false);
|
|
|
|
var recordPath = Path.Combine(distInfoDir, "RECORD");
|
|
var recordContent = new StringBuilder()
|
|
.AppendLine($"{name}/__init__.py,sha256={ComputeSha256Base64(modulePath)},{new FileInfo(modulePath).Length}")
|
|
.AppendLine($"{name}-{version}.dist-info/METADATA,sha256={ComputeSha256Base64(metadataPath)},{new FileInfo(metadataPath).Length}")
|
|
.AppendLine($"{name}-{version}.dist-info/RECORD,,")
|
|
.AppendLine($"{name}-{version}.dist-info/WHEEL,sha256={ComputeSha256Base64(wheelPath)},{new FileInfo(wheelPath).Length}")
|
|
.AppendLine($"{name}-{version}.dist-info/entry_points.txt,sha256={ComputeSha256Base64(entryPointsPath)},{new FileInfo(entryPointsPath).Length}")
|
|
.ToString();
|
|
await File.WriteAllTextAsync(recordPath, recordContent, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
private static void CreateRubyWorkspace(string root)
|
|
{
|
|
Directory.CreateDirectory(root);
|
|
|
|
var gemfile = string.Join(
|
|
Environment.NewLine,
|
|
"source \"https://rubygems.org\"",
|
|
string.Empty,
|
|
"gem \"rack\", \"~> 3.1\"",
|
|
"gem \"zeitwerk\"");
|
|
File.WriteAllText(Path.Combine(root, "Gemfile"), gemfile);
|
|
|
|
var gemfileLock = string.Join(
|
|
Environment.NewLine,
|
|
"GEM",
|
|
" remote: https://rubygems.org/",
|
|
" specs:",
|
|
" rack (3.1.0)",
|
|
" zeitwerk (2.6.13)",
|
|
string.Empty,
|
|
"PLATFORMS",
|
|
" ruby",
|
|
string.Empty,
|
|
"DEPENDENCIES",
|
|
" rack",
|
|
" zeitwerk",
|
|
string.Empty,
|
|
"BUNDLED WITH",
|
|
" 2.5.4");
|
|
File.WriteAllText(Path.Combine(root, "Gemfile.lock"), gemfileLock);
|
|
|
|
var app = string.Join(
|
|
Environment.NewLine,
|
|
"require 'rack'",
|
|
string.Empty,
|
|
"Rack::Handler::WEBrick.run ->(env) { [200, { 'Content-Type' => 'text/plain' }, ['ok']] }");
|
|
File.WriteAllText(Path.Combine(root, "app.rb"), app);
|
|
}
|
|
|
|
private static RubyPackageArtifactModel CreateRubyPackageArtifact(
|
|
string id,
|
|
string name,
|
|
string version,
|
|
IReadOnlyList<string>? groups = null,
|
|
bool runtimeUsed = false,
|
|
string? platform = null,
|
|
IDictionary<string, string?>? metadataOverrides = null)
|
|
{
|
|
var normalizedGroups = groups?.Where(static g => !string.IsNullOrWhiteSpace(g)).Select(static g => g.Trim()).ToArray();
|
|
|
|
var metadata = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
["source"] = "rubygems",
|
|
["lockfile"] = "Gemfile.lock"
|
|
};
|
|
|
|
if (!string.IsNullOrWhiteSpace(platform))
|
|
{
|
|
metadata["platform"] = platform;
|
|
}
|
|
|
|
if (normalizedGroups is { Length: > 0 })
|
|
{
|
|
metadata["groups"] = string.Join(';', normalizedGroups);
|
|
}
|
|
|
|
if (runtimeUsed)
|
|
{
|
|
metadata["runtime.used"] = "true";
|
|
metadata["runtime.entrypoints"] = "app.rb";
|
|
metadata["runtime.files"] = "app.rb";
|
|
metadata["runtime.reasons"] = "require-static";
|
|
}
|
|
|
|
var mergedMetadata = new Dictionary<string, string?>(metadata, StringComparer.OrdinalIgnoreCase);
|
|
if (metadataOverrides is not null)
|
|
{
|
|
foreach (var pair in metadataOverrides)
|
|
{
|
|
mergedMetadata[pair.Key] = pair.Value;
|
|
}
|
|
}
|
|
|
|
var runtime = runtimeUsed
|
|
? new RubyPackageRuntime(new[] { "app.rb" }, new[] { "app.rb" }, new[] { "require-static" })
|
|
: null;
|
|
|
|
return new RubyPackageArtifactModel(
|
|
id,
|
|
name,
|
|
version,
|
|
"rubygems",
|
|
platform,
|
|
normalizedGroups,
|
|
DeclaredOnly: false,
|
|
RuntimeUsed: runtimeUsed,
|
|
new RubyPackageProvenance("rubygems", "Gemfile.lock", $"specs/{name}"),
|
|
runtime,
|
|
mergedMetadata);
|
|
}
|
|
|
|
|
|
private static string ComputeSha256Base64(string path)
|
|
{
|
|
using var sha = SHA256.Create();
|
|
using var stream = File.OpenRead(path);
|
|
var hash = sha.ComputeHash(stream);
|
|
return Convert.ToBase64String(hash);
|
|
}
|
|
|
|
private sealed record CapturedConsoleOutput(string SpectreBuffer, string PlainBuffer)
|
|
{
|
|
public string Combined => string.Concat(SpectreBuffer, PlainBuffer);
|
|
}
|
|
|
|
private sealed class TestLoggerProvider : ILoggerProvider
|
|
{
|
|
private readonly List<LogEntry> _entries = new();
|
|
|
|
public IReadOnlyList<LogEntry> Entries => _entries;
|
|
|
|
public ILogger CreateLogger(string categoryName) => new TestLogger(categoryName, _entries);
|
|
|
|
public void Dispose()
|
|
{
|
|
}
|
|
|
|
private sealed class TestLogger : ILogger
|
|
{
|
|
private readonly string _category;
|
|
private readonly List<LogEntry> _entries;
|
|
|
|
public TestLogger(string category, List<LogEntry> entries)
|
|
{
|
|
_category = category;
|
|
_entries = entries;
|
|
}
|
|
|
|
public IDisposable? BeginScope<TState>(TState state) => null;
|
|
|
|
public bool IsEnabled(LogLevel logLevel) => true;
|
|
|
|
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
|
|
{
|
|
var message = formatter(state, exception);
|
|
_entries.Add(new LogEntry(logLevel, _category, eventId, message, exception));
|
|
}
|
|
}
|
|
|
|
public sealed record LogEntry(LogLevel Level, string Category, EventId EventId, string Message, Exception? Exception);
|
|
}
|
|
|
|
private static IScannerExecutor CreateDefaultExecutor()
|
|
{
|
|
var tempResultsFile = Path.GetTempFileName();
|
|
var tempMetadataFile = Path.Combine(
|
|
Path.GetDirectoryName(tempResultsFile)!,
|
|
$"{Path.GetFileNameWithoutExtension(tempResultsFile)}-run.json");
|
|
return new StubExecutor(new ScannerExecutionResult(0, tempResultsFile, tempMetadataFile));
|
|
}
|
|
|
|
private sealed class StubBackendClient : IBackendOperationsClient
|
|
{
|
|
private readonly JobTriggerResult _jobResult;
|
|
private static readonly RuntimePolicyEvaluationResult DefaultRuntimePolicyResult =
|
|
new RuntimePolicyEvaluationResult(
|
|
0,
|
|
null,
|
|
null,
|
|
new ReadOnlyDictionary<string, RuntimePolicyImageDecision>(
|
|
new Dictionary<string, RuntimePolicyImageDecision>()));
|
|
|
|
public StubBackendClient(JobTriggerResult result)
|
|
{
|
|
_jobResult = result;
|
|
}
|
|
|
|
public string? LastJobKind { get; private set; }
|
|
public string? LastUploadPath { get; private set; }
|
|
public string? LastExcititorRoute { get; private set; }
|
|
public HttpMethod? LastExcititorMethod { get; private set; }
|
|
public object? LastExcititorPayload { get; private set; }
|
|
public IReadOnlyList<RubyPackageArtifactModel> RubyPackages { get; set; } = Array.Empty<RubyPackageArtifactModel>();
|
|
public Exception? RubyPackagesException { get; set; }
|
|
public string? LastRubyPackagesScanId { get; private set; }
|
|
public List<(string ExportId, string DestinationPath, string? Algorithm, string? Digest)> ExportDownloads { get; } = new();
|
|
public ExcititorOperationResult? ExcititorResult { get; set; } = new ExcititorOperationResult(true, "ok", null, null);
|
|
public IReadOnlyList<ExcititorProviderSummary> ProviderSummaries { get; set; } = Array.Empty<ExcititorProviderSummary>();
|
|
public RuntimePolicyEvaluationResult RuntimePolicyResult { get; set; } = DefaultRuntimePolicyResult;
|
|
public PolicySimulationResult SimulationResult { get; set; } = new PolicySimulationResult(
|
|
new PolicySimulationDiff(
|
|
null,
|
|
0,
|
|
0,
|
|
0,
|
|
new ReadOnlyDictionary<string, PolicySimulationSeverityDelta>(new Dictionary<string, PolicySimulationSeverityDelta>(0, StringComparer.Ordinal)),
|
|
new ReadOnlyCollection<PolicySimulationRuleDelta>(Array.Empty<PolicySimulationRuleDelta>())),
|
|
null);
|
|
public PolicyApiException? SimulationException { get; set; }
|
|
public (string PolicyId, PolicySimulationInput Input)? LastPolicySimulation { get; private set; }
|
|
public TaskRunnerSimulationRequest? LastTaskRunnerSimulationRequest { get; private set; }
|
|
public TaskRunnerSimulationResult TaskRunnerSimulationResult { get; set; } = new(
|
|
string.Empty,
|
|
new TaskRunnerSimulationFailurePolicy(1, 0, false),
|
|
Array.Empty<TaskRunnerSimulationStep>(),
|
|
Array.Empty<TaskRunnerSimulationOutput>(),
|
|
false);
|
|
public Exception? TaskRunnerSimulationException { get; set; }
|
|
public PolicyActivationResult ActivationResult { get; set; } = new PolicyActivationResult(
|
|
"activated",
|
|
new PolicyActivationRevision(
|
|
"P-0",
|
|
1,
|
|
"active",
|
|
false,
|
|
DateTimeOffset.UtcNow,
|
|
DateTimeOffset.UtcNow,
|
|
new ReadOnlyCollection<PolicyActivationApproval>(Array.Empty<PolicyActivationApproval>())));
|
|
public PolicyApiException? ActivationException { get; set; }
|
|
public (string PolicyId, int Version, PolicyActivationRequest Request)? LastPolicyActivation { get; private set; }
|
|
public AocIngestDryRunResponse DryRunResponse { get; set; } = new();
|
|
public Exception? DryRunException { get; set; }
|
|
public AocIngestDryRunRequest? LastDryRunRequest { get; private set; }
|
|
public AocVerifyResponse VerifyResponse { get; set; } = new();
|
|
public Exception? VerifyException { get; set; }
|
|
public AocVerifyRequest? LastVerifyRequest { get; private set; }
|
|
public PolicyFindingsPage FindingsPage { get; set; } = new PolicyFindingsPage(Array.Empty<PolicyFindingDocument>(), null, null);
|
|
public PolicyFindingsQuery? LastFindingsQuery { get; private set; }
|
|
public PolicyApiException? FindingsListException { get; set; }
|
|
public PolicyFindingDocument FindingDocument { get; set; } = new PolicyFindingDocument(
|
|
"finding-default",
|
|
"affected",
|
|
new PolicyFindingSeverity("High", 7.5),
|
|
"sbom:default",
|
|
Array.Empty<string>(),
|
|
null,
|
|
1,
|
|
DateTimeOffset.UtcNow,
|
|
null);
|
|
public (string PolicyId, string FindingId)? LastFindingGet { get; private set; }
|
|
public PolicyApiException? FindingGetException { get; set; }
|
|
public PolicyFindingExplainResult ExplainResult { get; set; } = new PolicyFindingExplainResult(
|
|
"finding-default",
|
|
1,
|
|
new ReadOnlyCollection<PolicyFindingExplainStep>(Array.Empty<PolicyFindingExplainStep>()),
|
|
new ReadOnlyCollection<PolicyFindingExplainHint>(Array.Empty<PolicyFindingExplainHint>()));
|
|
public (string PolicyId, string FindingId, string? Mode)? LastFindingExplain { get; private set; }
|
|
public PolicyApiException? FindingExplainException { get; set; }
|
|
public EntryTraceResponseModel? EntryTraceResponse { get; set; }
|
|
public Exception? EntryTraceException { get; set; }
|
|
public string? LastEntryTraceScanId { get; private set; }
|
|
public List<(AdvisoryAiTaskType TaskType, AdvisoryPipelinePlanRequestModel Request)> AdvisoryPlanRequests { get; } = new();
|
|
public AdvisoryPipelinePlanResponseModel? AdvisoryPlanResponse { get; set; }
|
|
public Exception? AdvisoryPlanException { get; set; }
|
|
public Queue<AdvisoryPipelineOutputModel?> AdvisoryOutputQueue { get; } = new();
|
|
public AdvisoryPipelineOutputModel? AdvisoryOutputResponse { get; set; }
|
|
public Exception? AdvisoryOutputException { get; set; }
|
|
public List<(string CacheKey, AdvisoryAiTaskType TaskType, string Profile)> AdvisoryOutputRequests { get; } = new();
|
|
|
|
public Task<ScannerArtifactResult> DownloadScannerAsync(string channel, string outputPath, bool overwrite, bool verbose, CancellationToken cancellationToken)
|
|
=> throw new NotImplementedException();
|
|
|
|
public Task UploadScanResultsAsync(string filePath, CancellationToken cancellationToken)
|
|
{
|
|
LastUploadPath = filePath;
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
public Task<JobTriggerResult> TriggerJobAsync(string jobKind, IDictionary<string, object?> parameters, CancellationToken cancellationToken)
|
|
{
|
|
LastJobKind = jobKind;
|
|
return Task.FromResult(_jobResult);
|
|
}
|
|
|
|
public Task<ExcititorOperationResult> ExecuteExcititorOperationAsync(string route, HttpMethod method, object? payload, CancellationToken cancellationToken)
|
|
{
|
|
LastExcititorRoute = route;
|
|
LastExcititorMethod = method;
|
|
LastExcititorPayload = payload;
|
|
return Task.FromResult(ExcititorResult ?? new ExcititorOperationResult(true, "ok", null, null));
|
|
}
|
|
|
|
public Task<ExcititorExportDownloadResult> DownloadExcititorExportAsync(string exportId, string destinationPath, string? expectedDigestAlgorithm, string? expectedDigest, CancellationToken cancellationToken)
|
|
{
|
|
var fullPath = Path.GetFullPath(destinationPath);
|
|
var directory = Path.GetDirectoryName(fullPath);
|
|
if (!string.IsNullOrEmpty(directory))
|
|
{
|
|
Directory.CreateDirectory(directory);
|
|
}
|
|
|
|
File.WriteAllText(fullPath, "{}");
|
|
var info = new FileInfo(fullPath);
|
|
ExportDownloads.Add((exportId, fullPath, expectedDigestAlgorithm, expectedDigest));
|
|
return Task.FromResult(new ExcititorExportDownloadResult(fullPath, info.Length, false));
|
|
}
|
|
|
|
public Task<IReadOnlyList<ExcititorProviderSummary>> GetExcititorProvidersAsync(bool includeDisabled, CancellationToken cancellationToken)
|
|
=> Task.FromResult(ProviderSummaries);
|
|
|
|
public Task<RuntimePolicyEvaluationResult> EvaluateRuntimePolicyAsync(RuntimePolicyEvaluationRequest request, CancellationToken cancellationToken)
|
|
=> Task.FromResult(RuntimePolicyResult);
|
|
|
|
public Task<PolicySimulationResult> SimulatePolicyAsync(string policyId, PolicySimulationInput input, CancellationToken cancellationToken)
|
|
{
|
|
LastPolicySimulation = (policyId, input);
|
|
if (SimulationException is not null)
|
|
{
|
|
throw SimulationException;
|
|
}
|
|
|
|
return Task.FromResult(SimulationResult);
|
|
}
|
|
|
|
public Task<TaskRunnerSimulationResult> SimulateTaskRunnerAsync(TaskRunnerSimulationRequest request, CancellationToken cancellationToken)
|
|
{
|
|
LastTaskRunnerSimulationRequest = request;
|
|
if (TaskRunnerSimulationException is not null)
|
|
{
|
|
throw TaskRunnerSimulationException;
|
|
}
|
|
|
|
return Task.FromResult(TaskRunnerSimulationResult);
|
|
}
|
|
|
|
public Task<PolicyActivationResult> ActivatePolicyRevisionAsync(string policyId, int version, PolicyActivationRequest request, CancellationToken cancellationToken)
|
|
{
|
|
LastPolicyActivation = (policyId, version, request);
|
|
if (ActivationException is not null)
|
|
{
|
|
throw ActivationException;
|
|
}
|
|
|
|
return Task.FromResult(ActivationResult);
|
|
}
|
|
|
|
public Task<AocIngestDryRunResponse> ExecuteAocIngestDryRunAsync(AocIngestDryRunRequest request, CancellationToken cancellationToken)
|
|
{
|
|
LastDryRunRequest = request;
|
|
if (DryRunException is not null)
|
|
{
|
|
throw DryRunException;
|
|
}
|
|
|
|
return Task.FromResult(DryRunResponse);
|
|
}
|
|
|
|
public Task<AocVerifyResponse> ExecuteAocVerifyAsync(AocVerifyRequest request, CancellationToken cancellationToken)
|
|
{
|
|
LastVerifyRequest = request;
|
|
if (VerifyException is not null)
|
|
{
|
|
throw VerifyException;
|
|
}
|
|
|
|
return Task.FromResult(VerifyResponse);
|
|
}
|
|
|
|
public Task<PolicyFindingsPage> GetPolicyFindingsAsync(PolicyFindingsQuery query, CancellationToken cancellationToken)
|
|
{
|
|
LastFindingsQuery = query;
|
|
if (FindingsListException is not null)
|
|
{
|
|
throw FindingsListException;
|
|
}
|
|
|
|
return Task.FromResult(FindingsPage);
|
|
}
|
|
|
|
public Task<PolicyFindingDocument> GetPolicyFindingAsync(string policyId, string findingId, CancellationToken cancellationToken)
|
|
{
|
|
LastFindingGet = (policyId, findingId);
|
|
if (FindingGetException is not null)
|
|
{
|
|
throw FindingGetException;
|
|
}
|
|
|
|
return Task.FromResult(FindingDocument);
|
|
}
|
|
|
|
public Task<PolicyFindingExplainResult> GetPolicyFindingExplainAsync(string policyId, string findingId, string? mode, CancellationToken cancellationToken)
|
|
{
|
|
LastFindingExplain = (policyId, findingId, mode);
|
|
if (FindingExplainException is not null)
|
|
{
|
|
throw FindingExplainException;
|
|
}
|
|
|
|
return Task.FromResult(ExplainResult);
|
|
}
|
|
|
|
public Task<OfflineKitDownloadResult> DownloadOfflineKitAsync(string? bundleId, string destinationDirectory, bool overwrite, bool resume, CancellationToken cancellationToken)
|
|
=> throw new NotSupportedException();
|
|
|
|
public Task<OfflineKitImportResult> ImportOfflineKitAsync(OfflineKitImportRequest request, CancellationToken cancellationToken)
|
|
=> throw new NotSupportedException();
|
|
|
|
public Task<OfflineKitStatus> GetOfflineKitStatusAsync(CancellationToken cancellationToken)
|
|
=> throw new NotSupportedException();
|
|
|
|
public Task<EntryTraceResponseModel?> GetEntryTraceAsync(string scanId, CancellationToken cancellationToken)
|
|
{
|
|
LastEntryTraceScanId = scanId;
|
|
if (EntryTraceException is not null)
|
|
{
|
|
throw EntryTraceException;
|
|
}
|
|
|
|
return Task.FromResult(EntryTraceResponse);
|
|
}
|
|
|
|
public Task<IReadOnlyList<RubyPackageArtifactModel>> GetRubyPackagesAsync(string scanId, CancellationToken cancellationToken)
|
|
{
|
|
LastRubyPackagesScanId = scanId;
|
|
if (RubyPackagesException is not null)
|
|
{
|
|
throw RubyPackagesException;
|
|
}
|
|
|
|
return Task.FromResult(RubyPackages);
|
|
}
|
|
|
|
public Task<AdvisoryPipelinePlanResponseModel> CreateAdvisoryPipelinePlanAsync(AdvisoryAiTaskType taskType, AdvisoryPipelinePlanRequestModel request, CancellationToken cancellationToken)
|
|
{
|
|
AdvisoryPlanRequests.Add((taskType, request));
|
|
if (AdvisoryPlanException is not null)
|
|
{
|
|
throw AdvisoryPlanException;
|
|
}
|
|
|
|
var response = AdvisoryPlanResponse ?? new AdvisoryPipelinePlanResponseModel
|
|
{
|
|
TaskType = taskType.ToString(),
|
|
CacheKey = "stub-cache-key",
|
|
PromptTemplate = "prompts/advisory/stub.liquid",
|
|
Budget = new AdvisoryTaskBudgetModel
|
|
{
|
|
PromptTokens = 0,
|
|
CompletionTokens = 0
|
|
},
|
|
Chunks = Array.Empty<PipelineChunkSummaryModel>(),
|
|
Vectors = Array.Empty<PipelineVectorSummaryModel>(),
|
|
Metadata = new Dictionary<string, string>(StringComparer.Ordinal)
|
|
};
|
|
|
|
return Task.FromResult(response);
|
|
}
|
|
|
|
public Task<AdvisoryPipelineOutputModel?> TryGetAdvisoryPipelineOutputAsync(string cacheKey, AdvisoryAiTaskType taskType, string profile, CancellationToken cancellationToken)
|
|
{
|
|
AdvisoryOutputRequests.Add((cacheKey, taskType, profile));
|
|
if (AdvisoryOutputException is not null)
|
|
{
|
|
throw AdvisoryOutputException;
|
|
}
|
|
|
|
if (AdvisoryOutputQueue.Count > 0)
|
|
{
|
|
return Task.FromResult(AdvisoryOutputQueue.Dequeue());
|
|
}
|
|
|
|
return Task.FromResult(AdvisoryOutputResponse);
|
|
}
|
|
}
|
|
|
|
private sealed class StubExecutor : IScannerExecutor
|
|
{
|
|
private readonly ScannerExecutionResult _result;
|
|
|
|
public StubExecutor(ScannerExecutionResult result)
|
|
{
|
|
_result = result;
|
|
}
|
|
|
|
public Task<ScannerExecutionResult> RunAsync(string runner, string entry, string targetDirectory, string resultsDirectory, IReadOnlyList<string> arguments, bool verbose, CancellationToken cancellationToken)
|
|
{
|
|
Directory.CreateDirectory(Path.GetDirectoryName(_result.ResultsPath)!);
|
|
if (!File.Exists(_result.ResultsPath))
|
|
{
|
|
File.WriteAllText(_result.ResultsPath, "{}");
|
|
}
|
|
|
|
Directory.CreateDirectory(Path.GetDirectoryName(_result.RunMetadataPath)!);
|
|
if (!File.Exists(_result.RunMetadataPath))
|
|
{
|
|
File.WriteAllText(_result.RunMetadataPath, "{}");
|
|
}
|
|
|
|
return Task.FromResult(_result);
|
|
}
|
|
}
|
|
|
|
private sealed class StubInstaller : IScannerInstaller
|
|
{
|
|
public Task InstallAsync(string artifactPath, bool verbose, CancellationToken cancellationToken)
|
|
=> Task.CompletedTask;
|
|
}
|
|
|
|
private sealed class StubTokenClient : IStellaOpsTokenClient
|
|
{
|
|
private readonly StellaOpsTokenResult _token;
|
|
|
|
public StubTokenClient()
|
|
{
|
|
_token = new StellaOpsTokenResult(
|
|
"token-123",
|
|
"Bearer",
|
|
DateTimeOffset.UtcNow.AddMinutes(30),
|
|
new[] { StellaOpsScopes.ConcelierJobsTrigger });
|
|
}
|
|
|
|
public int ClientCredentialRequests { get; private set; }
|
|
public IReadOnlyDictionary<string, string>? LastAdditionalParameters { get; private set; }
|
|
public int PasswordRequests { get; private set; }
|
|
public int ClearRequests { get; private set; }
|
|
public StellaOpsTokenCacheEntry? CachedEntry { get; set; }
|
|
|
|
public ValueTask CacheTokenAsync(string key, StellaOpsTokenCacheEntry entry, CancellationToken cancellationToken = default)
|
|
{
|
|
CachedEntry = entry;
|
|
return ValueTask.CompletedTask;
|
|
}
|
|
|
|
public ValueTask ClearCachedTokenAsync(string key, CancellationToken cancellationToken = default)
|
|
{
|
|
ClearRequests++;
|
|
CachedEntry = null;
|
|
return ValueTask.CompletedTask;
|
|
}
|
|
|
|
public Task<JsonWebKeySet> GetJsonWebKeySetAsync(CancellationToken cancellationToken = default)
|
|
=> Task.FromResult(new JsonWebKeySet("{\"keys\":[]}"));
|
|
|
|
public ValueTask<StellaOpsTokenCacheEntry?> GetCachedTokenAsync(string key, CancellationToken cancellationToken = default)
|
|
=> ValueTask.FromResult(CachedEntry);
|
|
|
|
public Task<StellaOpsTokenResult> RequestClientCredentialsTokenAsync(string? scope = null, IReadOnlyDictionary<string, string>? additionalParameters = null, CancellationToken cancellationToken = default)
|
|
{
|
|
ClientCredentialRequests++;
|
|
LastAdditionalParameters = additionalParameters;
|
|
return Task.FromResult(_token);
|
|
}
|
|
|
|
public Task<StellaOpsTokenResult> RequestPasswordTokenAsync(string username, string password, string? scope = null, IReadOnlyDictionary<string, string>? additionalParameters = null, CancellationToken cancellationToken = default)
|
|
{
|
|
PasswordRequests++;
|
|
LastAdditionalParameters = additionalParameters;
|
|
return Task.FromResult(_token);
|
|
}
|
|
}
|
|
|
|
private static string CreateUnsignedJwt(params (string Key, object Value)[] claims)
|
|
{
|
|
var headerJson = "{\"alg\":\"none\",\"typ\":\"JWT\"}";
|
|
var payload = new Dictionary<string, object?>(StringComparer.Ordinal);
|
|
foreach (var claim in claims)
|
|
{
|
|
payload[claim.Key] = claim.Value;
|
|
}
|
|
|
|
var payloadJson = JsonSerializer.Serialize(payload);
|
|
return $"{Base64UrlEncode(headerJson)}.{Base64UrlEncode(payloadJson)}.";
|
|
}
|
|
|
|
private static string Base64UrlEncode(string value)
|
|
{
|
|
var bytes = Encoding.UTF8.GetBytes(value);
|
|
return Convert.ToBase64String(bytes)
|
|
.TrimEnd('=')
|
|
.Replace('+', '-')
|
|
.Replace('/', '_');
|
|
}
|
|
|
|
private sealed class StubConcelierObservationsClient : IConcelierObservationsClient
|
|
{
|
|
private readonly AdvisoryObservationsResponse _response;
|
|
|
|
public StubConcelierObservationsClient(AdvisoryObservationsResponse? response = null)
|
|
{
|
|
_response = response ?? new AdvisoryObservationsResponse();
|
|
}
|
|
|
|
public AdvisoryObservationsQuery? LastQuery { get; private set; }
|
|
|
|
public Task<AdvisoryObservationsResponse> GetObservationsAsync(
|
|
AdvisoryObservationsQuery query,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
LastQuery = query;
|
|
return Task.FromResult(_response);
|
|
}
|
|
}
|
|
}
|