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(), 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.Empty, ImmutableArray.Empty, ImmutableArray.Empty, ImmutableArray.Create(new EntryTracePlan( ImmutableArray.Create("/usr/bin/python", "app.py"), ImmutableDictionary.Empty, "/workspace", "appuser", "/usr/bin/python", EntryTraceTerminalType.Managed, "python", 0.95, ImmutableDictionary.Empty)), ImmutableArray.Create(new EntryTraceTerminal( "/usr/bin/python", EntryTraceTerminalType.Managed, "python", 0.95, ImmutableDictionary.Empty, "appuser", "/workspace", ImmutableArray.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) && entry.GetProperty("runtimeEntrypoints").EnumerateArray().Any(value => string.Equals(value.GetString(), "app.rb", StringComparison.OrdinalIgnoreCase))); var observation = document.RootElement.GetProperty("observation"); Assert.Equal("2.5.4", observation.GetProperty("bundlerVersion").GetString()); Assert.Equal(packages.GetArrayLength(), observation.GetProperty("packageCount").GetInt32()); } 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); var observation = document.RootElement.GetProperty("observation"); Assert.True(observation.GetProperty("runtimeEdgeCount").GetInt32() >= 1); } finally { Environment.ExitCode = originalExit; } } [Fact] public async Task HandleRubyResolveAsync_RendersGroupedPackages() { var originalExit = Environment.ExitCode; var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null)) { RubyInventory = CreateRubyInventory( "scan-ruby", 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 { ["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; const string identifier = "ruby-scan-json"; var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null)) { RubyInventory = CreateRubyInventory( identifier, new[] { CreateRubyPackageArtifact("pkg-rack-json", "rack", "3.1.0", new[] { "default" }, runtimeUsed: true) }) }; var provider = BuildServiceProvider(backend); 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 HandleRubyResolveAsync_NotifiesWhenInventoryMissing() { var originalExit = Environment.ExitCode; var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null)); var provider = BuildServiceProvider(backend); try { var output = await CaptureTestConsoleAsync(async _ => { await CommandHandlers.HandleRubyResolveAsync( provider, imageReference: null, scanId: "scan-missing", format: "table", verbose: false, cancellationToken: CancellationToken.None); }); Assert.Equal(0, Environment.ExitCode); Assert.Contains("not available", output.Combined, 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 { ["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 { ["confidence"] = "high" }, Guardrail = new AdvisoryOutputGuardrailModel { Blocked = false, SanitizedPrompt = "Summary result", Violations = Array.Empty(), Metadata = new Dictionary() }, Provenance = new AdvisoryOutputProvenanceModel { InputDigest = "sha256:aaa", OutputHash = "sha256:bbb", Signatures = Array.Empty() }, 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, outputFormat: AdvisoryOutputFormat.Table, outputPath: null, 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_WritesMarkdownWithCitations() { var originalExit = Environment.ExitCode; var originalConsole = AnsiConsole.Console; using var tempDir = new TempDirectory(); var outputPath = Path.Combine(tempDir.Path, "advisory.md"); var testConsole = new TestConsole(); try { Environment.ExitCode = 0; AnsiConsole.Console = testConsole; var planResponse = new AdvisoryPipelinePlanResponseModel { TaskType = AdvisoryAiTaskType.Summary.ToString(), CacheKey = "cache-markdown", PromptTemplate = "prompts/advisory/summary.liquid", Budget = new AdvisoryTaskBudgetModel { PromptTokens = 256, CompletionTokens = 64 }, Chunks = Array.Empty(), Vectors = Array.Empty(), Metadata = new Dictionary() }; var outputResponse = new AdvisoryPipelineOutputModel { CacheKey = planResponse.CacheKey, TaskType = planResponse.TaskType, Profile = "default", Prompt = "Sanitized prompt", Response = "Rendered summary body.", Citations = new[] { new AdvisoryOutputCitationModel { Index = 1, DocumentId = "doc-9", ChunkId = "chunk-9" } }, Metadata = new Dictionary(), Guardrail = new AdvisoryOutputGuardrailModel { Blocked = false, SanitizedPrompt = "Sanitized prompt", Violations = Array.Empty(), Metadata = new Dictionary() }, Provenance = new AdvisoryOutputProvenanceModel { InputDigest = "sha256:markdown-in", OutputHash = "sha256:markdown-out", Signatures = Array.Empty() }, 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-4", null, null, null, "default", Array.Empty(), forceRefresh: false, timeoutSeconds: 0, outputFormat: AdvisoryOutputFormat.Markdown, outputPath: outputPath, verbose: false, cancellationToken: CancellationToken.None); var markdown = await File.ReadAllTextAsync(outputPath); Assert.Contains("Citations", markdown, StringComparison.OrdinalIgnoreCase); Assert.Contains("doc-9", markdown, StringComparison.OrdinalIgnoreCase); Assert.Contains("chunk-9", markdown, StringComparison.OrdinalIgnoreCase); Assert.True(File.Exists(outputPath)); Assert.Contains("Rendered summary body", markdown, StringComparison.OrdinalIgnoreCase); Assert.Equal(0, Environment.ExitCode); Assert.Contains("Citations", testConsole.Output, StringComparison.OrdinalIgnoreCase); } finally { AnsiConsole.Console = originalConsole; Environment.ExitCode = originalExit; } } [Fact] public async Task HandleAdviseRunAsync_WritesMarkdownWithCitations_ForExplain() { var originalExit = Environment.ExitCode; var originalConsole = AnsiConsole.Console; var testConsole = new TestConsole(); try { Environment.ExitCode = 0; AnsiConsole.Console = testConsole; var planResponse = new AdvisoryPipelinePlanResponseModel { TaskType = "Conflict", CacheKey = "plan-conflict", PromptTemplate = "prompts/advisory/conflict.liquid", Budget = new AdvisoryTaskBudgetModel { PromptTokens = 128, CompletionTokens = 64 }, Chunks = Array.Empty(), Vectors = Array.Empty(), Metadata = new Dictionary() }; var outputResponse = new AdvisoryPipelineOutputModel { CacheKey = planResponse.CacheKey, TaskType = planResponse.TaskType, Profile = "default", Prompt = "Sanitized prompt", Response = "Rendered conflict body.", Citations = new[] { new AdvisoryOutputCitationModel { Index = 1, DocumentId = "doc-42", ChunkId = "chunk-42" } }, Metadata = new Dictionary(), Guardrail = new AdvisoryOutputGuardrailModel { Blocked = false, SanitizedPrompt = "Sanitized prompt", Violations = Array.Empty(), Metadata = new Dictionary() }, Provenance = new AdvisoryOutputProvenanceModel { InputDigest = "sha256:conflict-in", OutputHash = "sha256:conflict-out", Signatures = Array.Empty() }, 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); var outputPath = Path.GetTempFileName(); await CommandHandlers.HandleAdviseRunAsync( provider, AdvisoryAiTaskType.Conflict, "ADV-42", null, null, null, "default", Array.Empty(), forceRefresh: false, timeoutSeconds: 0, outputFormat: AdvisoryOutputFormat.Markdown, outputPath: outputPath, verbose: false, cancellationToken: CancellationToken.None); var markdown = await File.ReadAllTextAsync(outputPath); Assert.Contains("Conflict", markdown, StringComparison.OrdinalIgnoreCase); Assert.Contains("Rendered conflict body", markdown, StringComparison.OrdinalIgnoreCase); Assert.Contains("doc-42", markdown, StringComparison.OrdinalIgnoreCase); Assert.Contains("chunk-42", markdown, StringComparison.OrdinalIgnoreCase); Assert.Contains("Citations", markdown, StringComparison.OrdinalIgnoreCase); Assert.Equal(0, Environment.ExitCode); Assert.Contains("Conflict", testConsole.Output, StringComparison.OrdinalIgnoreCase); Assert.Equal(AdvisoryAiTaskType.Conflict, backend.AdvisoryPlanRequests.Last().TaskType); } finally { AnsiConsole.Console = originalConsole; Environment.ExitCode = originalExit; } } [Fact] public async Task HandleAdviseRunAsync_WritesMarkdownWithCitations_ForRemediation() { var originalExit = Environment.ExitCode; var originalConsole = AnsiConsole.Console; var testConsole = new TestConsole(); try { Environment.ExitCode = 0; AnsiConsole.Console = testConsole; var planResponse = new AdvisoryPipelinePlanResponseModel { TaskType = "Remediation", CacheKey = "plan-remediation", PromptTemplate = "prompts/advisory/remediation.liquid", Budget = new AdvisoryTaskBudgetModel { PromptTokens = 192, CompletionTokens = 96 }, Chunks = Array.Empty(), Vectors = Array.Empty(), Metadata = new Dictionary() }; var outputResponse = new AdvisoryPipelineOutputModel { CacheKey = planResponse.CacheKey, TaskType = planResponse.TaskType, Profile = "default", Prompt = "Sanitized prompt", Response = "Rendered remediation body.", Citations = new[] { new AdvisoryOutputCitationModel { Index = 1, DocumentId = "doc-77", ChunkId = "chunk-77" } }, Metadata = new Dictionary(), Guardrail = new AdvisoryOutputGuardrailModel { Blocked = false, SanitizedPrompt = "Sanitized prompt", Violations = Array.Empty(), Metadata = new Dictionary() }, Provenance = new AdvisoryOutputProvenanceModel { InputDigest = "sha256:remediation-in", OutputHash = "sha256:remediation-out", Signatures = Array.Empty() }, 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); var outputPath = Path.GetTempFileName(); await CommandHandlers.HandleAdviseRunAsync( provider, AdvisoryAiTaskType.Remediation, "ADV-77", null, null, null, "default", Array.Empty(), forceRefresh: false, timeoutSeconds: 0, outputFormat: AdvisoryOutputFormat.Markdown, outputPath: outputPath, verbose: false, cancellationToken: CancellationToken.None); var markdown = await File.ReadAllTextAsync(outputPath); Assert.Contains("Remediation", markdown, StringComparison.OrdinalIgnoreCase); Assert.Contains("Rendered remediation body", markdown, StringComparison.OrdinalIgnoreCase); Assert.Contains("doc-77", markdown, StringComparison.OrdinalIgnoreCase); Assert.Contains("chunk-77", markdown, StringComparison.OrdinalIgnoreCase); Assert.Contains("Citations", markdown, StringComparison.OrdinalIgnoreCase); Assert.Equal(0, Environment.ExitCode); Assert.Contains("Remediation", testConsole.Output, StringComparison.OrdinalIgnoreCase); Assert.Equal(AdvisoryAiTaskType.Remediation, backend.AdvisoryPlanRequests.Last().TaskType); } 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(), Vectors = Array.Empty(), Metadata = new Dictionary() }; var outputResponse = new AdvisoryPipelineOutputModel { CacheKey = planResponse.CacheKey, TaskType = planResponse.TaskType, Profile = "default", Prompt = "Blocked output", Citations = Array.Empty(), Metadata = new Dictionary(), Guardrail = new AdvisoryOutputGuardrailModel { Blocked = true, SanitizedPrompt = "Blocked output", Violations = new[] { new AdvisoryOutputGuardrailViolationModel { Code = "PROMPT_INJECTION", Message = "Detected prompt injection attempt." } }, Metadata = new Dictionary() }, Provenance = new AdvisoryOutputProvenanceModel { InputDigest = "sha256:ccc", OutputHash = "sha256:ddd", Signatures = Array.Empty() }, 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(), forceRefresh: true, timeoutSeconds: 0, outputFormat: AdvisoryOutputFormat.Table, outputPath: null, 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(), Vectors = Array.Empty(), Metadata = new Dictionary() }; 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(), forceRefresh: false, timeoutSeconds: 0, outputFormat: AdvisoryOutputFormat.Table, outputPath: null, 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>(backend.LastExcititorPayload); Assert.Equal(true, payload["resume"]); var providers = Assert.IsAssignableFrom>(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>(backend.LastExcititorPayload); Assert.Equal("export-123", payload["exportId"]); Assert.Equal("sha256:abc", payload["digest"]); var attestation = Assert.IsAssignableFrom>(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() } }; 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(), 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(), 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(), 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(), aliases: Array.Empty(), purls: Array.Empty(), cpes: Array.Empty(), 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(), aliases: Array.Empty(), purls: Array.Empty(), cpes: Array.Empty(), 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(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(new Dictionary(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(new Dictionary(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(new Dictionary(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(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(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(new Dictionary(StringComparer.Ordinal) { ["source"] = "baseline", ["confidence"] = 0.66 })), ["sha256:json-b"] = new RuntimePolicyImageDecision( "audit", true, false, Array.AsReadOnly(Array.Empty()), new RuntimePolicyRekorReference(null, null, null), new ReadOnlyDictionary(new Dictionary(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(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(), 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(), 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(), Array.Empty(), Array.Empty(), 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(new Dictionary { ["severity"] = "Critical", ["sealed"] = "false" }), new ReadOnlyDictionary(new Dictionary { ["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(steps), new ReadOnlyCollection(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(new Dictionary(StringComparer.Ordinal) { ["critical"] = new PolicySimulationSeverityDelta(1, null), ["high"] = new PolicySimulationSeverityDelta(null, 2) }); var ruleHits = new ReadOnlyCollection(new List { 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(new Dictionary(0, StringComparer.Ordinal)), new ReadOnlyCollection(Array.Empty())), 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(), 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(new Dictionary(0, StringComparer.Ordinal)), new ReadOnlyCollection(Array.Empty())), null); var provider = BuildServiceProvider(backend); try { await CommandHandlers.HandlePolicySimulateAsync( provider, policyId: "P-11", baseVersion: null, candidateVersion: null, sbomArguments: Array.Empty(), environmentArguments: Array.Empty(), 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(), environmentArguments: Array.Empty(), 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()), new TaskRunnerSimulationStep( "approval", "approval", "GateApproval", true, "pending", "requires-approval", null, "security-review", "Security approval required.", null, false, Array.Empty()) }, 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(), Array.Empty(), 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()); } 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(new List { 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(new List { 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(Array.Empty()))); 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 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 { ["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() } }; 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(), 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(), 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(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(_ => 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(resolvedExecutor); services.AddSingleton(installer ?? new StubInstaller()); if (tokenClient is not null) { services.AddSingleton(tokenClient); } services.AddSingleton( concelierClient ?? new StubConcelierObservationsClient()); return services.BuildServiceProvider(); } private static async Task CaptureTestConsoleAsync(Func 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? groups = null, bool runtimeUsed = false, string? platform = null, IDictionary? metadataOverrides = null) { var normalizedGroups = groups?.Where(static g => !string.IsNullOrWhiteSpace(g)).Select(static g => g.Trim()).ToArray(); var metadata = new Dictionary(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(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 RubyPackageInventoryModel CreateRubyInventory( string scanId, IReadOnlyList packages, string? imageDigest = null) { return new RubyPackageInventoryModel( scanId, imageDigest ?? "sha256:inventory", DateTimeOffset.UtcNow, packages); } 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 _entries = new(); public IReadOnlyList 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 _entries; public TestLogger(string category, List entries) { _category = category; _entries = entries; } public IDisposable? BeginScope(TState state) => null; public bool IsEnabled(LogLevel logLevel) => true; public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func 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( new Dictionary())); 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 RubyPackageInventoryModel? RubyInventory { get; set; } public Exception? RubyInventoryException { 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 ProviderSummaries { get; set; } = Array.Empty(); public RuntimePolicyEvaluationResult RuntimePolicyResult { get; set; } = DefaultRuntimePolicyResult; public PolicySimulationResult SimulationResult { get; set; } = new PolicySimulationResult( new PolicySimulationDiff( null, 0, 0, 0, new ReadOnlyDictionary(new Dictionary(0, StringComparer.Ordinal)), new ReadOnlyCollection(Array.Empty())), 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(), Array.Empty(), false); public Exception? TaskRunnerSimulationException { get; set; } public OfflineKitStatus? OfflineStatus { get; set; } public PolicyActivationResult ActivationResult { get; set; } = new PolicyActivationResult( "activated", new PolicyActivationRevision( "P-0", 1, "active", false, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, new ReadOnlyCollection(Array.Empty()))); 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(), 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(), 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(Array.Empty()), new ReadOnlyCollection(Array.Empty())); 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 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 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 TriggerJobAsync(string jobKind, IDictionary parameters, CancellationToken cancellationToken) { LastJobKind = jobKind; return Task.FromResult(_jobResult); } public Task 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 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> GetExcititorProvidersAsync(bool includeDisabled, CancellationToken cancellationToken) => Task.FromResult(ProviderSummaries); public Task EvaluateRuntimePolicyAsync(RuntimePolicyEvaluationRequest request, CancellationToken cancellationToken) => Task.FromResult(RuntimePolicyResult); public Task SimulatePolicyAsync(string policyId, PolicySimulationInput input, CancellationToken cancellationToken) { LastPolicySimulation = (policyId, input); if (SimulationException is not null) { throw SimulationException; } return Task.FromResult(SimulationResult); } public Task SimulateTaskRunnerAsync(TaskRunnerSimulationRequest request, CancellationToken cancellationToken) { LastTaskRunnerSimulationRequest = request; if (TaskRunnerSimulationException is not null) { throw TaskRunnerSimulationException; } return Task.FromResult(TaskRunnerSimulationResult); } public Task 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 ExecuteAocIngestDryRunAsync(AocIngestDryRunRequest request, CancellationToken cancellationToken) { LastDryRunRequest = request; if (DryRunException is not null) { throw DryRunException; } return Task.FromResult(DryRunResponse); } public Task ExecuteAocVerifyAsync(AocVerifyRequest request, CancellationToken cancellationToken) { LastVerifyRequest = request; if (VerifyException is not null) { throw VerifyException; } return Task.FromResult(VerifyResponse); } public Task GetPolicyFindingsAsync(PolicyFindingsQuery query, CancellationToken cancellationToken) { LastFindingsQuery = query; if (FindingsListException is not null) { throw FindingsListException; } return Task.FromResult(FindingsPage); } public Task GetPolicyFindingAsync(string policyId, string findingId, CancellationToken cancellationToken) { LastFindingGet = (policyId, findingId); if (FindingGetException is not null) { throw FindingGetException; } return Task.FromResult(FindingDocument); } public Task 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 DownloadOfflineKitAsync(string? bundleId, string destinationDirectory, bool overwrite, bool resume, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task ImportOfflineKitAsync(OfflineKitImportRequest request, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task GetOfflineKitStatusAsync(CancellationToken cancellationToken) { return Task.FromResult(OfflineStatus ?? new OfflineKitStatus( null, null, null, false, null, null, null, null, null, Array.Empty())); } public Task GetEntryTraceAsync(string scanId, CancellationToken cancellationToken) { LastEntryTraceScanId = scanId; if (EntryTraceException is not null) { throw EntryTraceException; } return Task.FromResult(EntryTraceResponse); } public Task GetRubyPackagesAsync(string scanId, CancellationToken cancellationToken) { LastRubyPackagesScanId = scanId; if (RubyInventoryException is not null) { throw RubyInventoryException; } return Task.FromResult(RubyInventory); } public Task 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(), Vectors = Array.Empty(), Metadata = new Dictionary(StringComparer.Ordinal) }; return Task.FromResult(response); } public Task 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 RunAsync(string runner, string entry, string targetDirectory, string resultsDirectory, IReadOnlyList 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? 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 GetJsonWebKeySetAsync(CancellationToken cancellationToken = default) => Task.FromResult(new JsonWebKeySet("{\"keys\":[]}")); public ValueTask GetCachedTokenAsync(string key, CancellationToken cancellationToken = default) => ValueTask.FromResult(CachedEntry); public Task RequestClientCredentialsTokenAsync(string? scope = null, IReadOnlyDictionary? additionalParameters = null, CancellationToken cancellationToken = default) { ClientCredentialRequests++; LastAdditionalParameters = additionalParameters; return Task.FromResult(_token); } public Task RequestPasswordTokenAsync(string username, string password, string? scope = null, IReadOnlyDictionary? 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(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 GetObservationsAsync( AdvisoryObservationsQuery query, CancellationToken cancellationToken) { LastQuery = query; return Task.FromResult(_response); } } [Fact] public async Task HandleOfflineKitStatusAsync_AsJsonRendersPayload() { var originalExit = Environment.ExitCode; var originalConsole = AnsiConsole.Console; try { Environment.ExitCode = 0; var console = new TestConsole(); AnsiConsole.Console = console; var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null)) { OfflineStatus = new OfflineKitStatus( "bundle-123", "stable", "kit", false, null, DateTimeOffset.Parse("2025-11-03T00:00:00Z", CultureInfo.InvariantCulture), DateTimeOffset.Parse("2025-11-04T00:00:00Z", CultureInfo.InvariantCulture), "sha256:deadbeef", 1024, new[] { new OfflineKitComponentStatus("scanner", "1.0.0", "abc", DateTimeOffset.Parse("2025-11-03T00:00:00Z", CultureInfo.InvariantCulture), 512) }) }; var provider = BuildServiceProvider(backend); await CommandHandlers.HandleOfflineKitStatusAsync( provider, asJson: true, verbose: false, cancellationToken: CancellationToken.None); Assert.Equal(0, Environment.ExitCode); Assert.Contains("bundle-123", console.Output, StringComparison.OrdinalIgnoreCase); Assert.Contains("scanner", console.Output, StringComparison.OrdinalIgnoreCase); } finally { Environment.ExitCode = originalExit; AnsiConsole.Console = originalConsole; } } [Fact] public async Task HandleOfflineKitStatusAsync_AsJsonHandlesEmptyStatus() { var originalExit = Environment.ExitCode; var originalConsole = AnsiConsole.Console; try { Environment.ExitCode = 0; var console = new TestConsole(); AnsiConsole.Console = console; var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null)) { OfflineStatus = new OfflineKitStatus(null, null, null, false, null, null, null, null, null, Array.Empty()) }; var provider = BuildServiceProvider(backend); await CommandHandlers.HandleOfflineKitStatusAsync( provider, asJson: true, verbose: false, cancellationToken: CancellationToken.None); Assert.Equal(0, Environment.ExitCode); Assert.Contains("\"bundleId\": null", console.Output, StringComparison.OrdinalIgnoreCase); } finally { Environment.ExitCode = originalExit; AnsiConsole.Console = originalConsole; } } }