Add channel test providers for Email, Slack, Teams, and Webhook
	
		
			
	
		
	
	
		
	
		
			Some checks failed
		
		
	
	
		
			
				
	
				Docs CI / lint-and-preview (push) Has been cancelled
				
			
		
		
	
	
				
					
				
			
		
			Some checks failed
		
		
	
	Docs CI / lint-and-preview (push) Has been cancelled
				
			- Implemented EmailChannelTestProvider to generate email preview payloads. - Implemented SlackChannelTestProvider to create Slack message previews. - Implemented TeamsChannelTestProvider for generating Teams Adaptive Card previews. - Implemented WebhookChannelTestProvider to create webhook payloads. - Added INotifyChannelTestProvider interface for channel-specific preview generation. - Created ChannelTestPreviewContracts for request and response models. - Developed NotifyChannelTestService to handle test send requests and generate previews. - Added rate limit policies for test sends and delivery history. - Implemented unit tests for service registration and binding. - Updated project files to include necessary dependencies and configurations.
This commit is contained in:
		| @@ -2,6 +2,7 @@ using System; | ||||
| using System.Collections.Generic; | ||||
| using System.Collections.ObjectModel; | ||||
| using System.IO; | ||||
| using System.Linq; | ||||
| using System.Net.Http; | ||||
| using System.Security.Cryptography; | ||||
| using System.Text; | ||||
| @@ -9,6 +10,7 @@ using System.Text.Json; | ||||
| 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; | ||||
| @@ -21,20 +23,22 @@ using StellaOps.Cli.Services.Models; | ||||
| using StellaOps.Cli.Telemetry; | ||||
| using StellaOps.Cli.Tests.Testing; | ||||
| using StellaOps.Cryptography; | ||||
| 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); | ||||
|  | ||||
|  | ||||
| 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", | ||||
| @@ -45,36 +49,36 @@ public sealed class CommandHandlersTests | ||||
|                 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; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|  | ||||
|             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() | ||||
|     { | ||||
| @@ -83,34 +87,34 @@ public sealed class CommandHandlersTests | ||||
|         var backend = new StubBackendClient(new JobTriggerResult(true, "Accepted", null, null)); | ||||
|         var metadataFile = Path.Combine(tempDir.Path, "results", "scan-run.json"); | ||||
|         var executor = new StubExecutor(new ScannerExecutionResult(0, resultsFile, metadataFile)); | ||||
|         var options = new StellaOpsCliOptions | ||||
|         { | ||||
|             ResultsDirectory = Path.Combine(tempDir.Path, "results") | ||||
|         }; | ||||
|  | ||||
|         var provider = BuildServiceProvider(backend, executor, new StubInstaller(), options); | ||||
|  | ||||
|         Directory.CreateDirectory(Path.Combine(tempDir.Path, "target")); | ||||
|  | ||||
|         var original = Environment.ExitCode; | ||||
|         try | ||||
|         { | ||||
|             await CommandHandlers.HandleScannerRunAsync( | ||||
|                 provider, | ||||
|                 runner: "docker", | ||||
|                 entry: "scanner-image", | ||||
|                 targetDirectory: Path.Combine(tempDir.Path, "target"), | ||||
|                 arguments: Array.Empty<string>(), | ||||
|                 verbose: false, | ||||
|                 cancellationToken: CancellationToken.None); | ||||
|  | ||||
|             Assert.Equal(0, Environment.ExitCode); | ||||
|         var options = new StellaOpsCliOptions | ||||
|         { | ||||
|             ResultsDirectory = Path.Combine(tempDir.Path, "results") | ||||
|         }; | ||||
|  | ||||
|         var provider = BuildServiceProvider(backend, executor, new StubInstaller(), options); | ||||
|  | ||||
|         Directory.CreateDirectory(Path.Combine(tempDir.Path, "target")); | ||||
|  | ||||
|         var original = Environment.ExitCode; | ||||
|         try | ||||
|         { | ||||
|             await CommandHandlers.HandleScannerRunAsync( | ||||
|                 provider, | ||||
|                 runner: "docker", | ||||
|                 entry: "scanner-image", | ||||
|                 targetDirectory: Path.Combine(tempDir.Path, "target"), | ||||
|                 arguments: Array.Empty<string>(), | ||||
|                 verbose: false, | ||||
|                 cancellationToken: CancellationToken.None); | ||||
|  | ||||
|             Assert.Equal(0, Environment.ExitCode); | ||||
|             Assert.Equal(resultsFile, backend.LastUploadPath); | ||||
|             Assert.True(File.Exists(metadataFile)); | ||||
|         } | ||||
|         finally | ||||
|         { | ||||
|             Environment.ExitCode = original; | ||||
|         } | ||||
|         finally | ||||
|         { | ||||
|             Environment.ExitCode = original; | ||||
|         } | ||||
|     } | ||||
|  | ||||
| @@ -554,7 +558,219 @@ public sealed class CommandHandlersTests | ||||
|             Environment.ExitCode = original; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task HandleRuntimePolicyTestAsync_WritesInteractiveTable() | ||||
|     { | ||||
|         var originalExit = Environment.ExitCode; | ||||
|         var originalConsole = AnsiConsole.Console; | ||||
|  | ||||
|         var console = new TestConsole(); | ||||
|         console.Width(120); | ||||
|         console.Interactive(); | ||||
|         console.EmitAnsiSequences(); | ||||
|  | ||||
|         AnsiConsole.Console = console; | ||||
|  | ||||
|         var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null)); | ||||
|  | ||||
|         var decisions = new Dictionary<string, RuntimePolicyImageDecision>(StringComparer.Ordinal) | ||||
|         { | ||||
|             ["sha256:aaa"] = new RuntimePolicyImageDecision( | ||||
|                 "allow", | ||||
|                 true, | ||||
|                 true, | ||||
|                 Array.AsReadOnly(new[] { "trusted baseline" }), | ||||
|                 new RuntimePolicyRekorReference("uuid-allow", "https://rekor.example/entries/uuid-allow", true), | ||||
|                 new ReadOnlyDictionary<string, object?>(new Dictionary<string, object?>(StringComparer.Ordinal) | ||||
|                 { | ||||
|                     ["source"] = "baseline", | ||||
|                     ["quieted"] = false, | ||||
|                     ["confidence"] = 0.97, | ||||
|                     ["confidenceBand"] = "high" | ||||
|                 })), | ||||
|             ["sha256:bbb"] = new RuntimePolicyImageDecision( | ||||
|                 "block", | ||||
|                 false, | ||||
|                 false, | ||||
|                 Array.AsReadOnly(new[] { "missing attestation" }), | ||||
|                 new RuntimePolicyRekorReference("uuid-block", "https://rekor.example/entries/uuid-block", false), | ||||
|                 new ReadOnlyDictionary<string, object?>(new Dictionary<string, object?>(StringComparer.Ordinal) | ||||
|                 { | ||||
|                     ["source"] = "policy", | ||||
|                     ["quieted"] = false, | ||||
|                     ["confidence"] = 0.12, | ||||
|                     ["confidenceBand"] = "low" | ||||
|                 })), | ||||
|             ["sha256:ccc"] = new RuntimePolicyImageDecision( | ||||
|                 "audit", | ||||
|                 true, | ||||
|                 false, | ||||
|                 Array.AsReadOnly(new[] { "pending sbom sync" }), | ||||
|                 new RuntimePolicyRekorReference(null, null, null), | ||||
|                 new ReadOnlyDictionary<string, object?>(new Dictionary<string, object?>(StringComparer.Ordinal) | ||||
|                 { | ||||
|                     ["source"] = "mirror", | ||||
|                     ["quieted"] = true, | ||||
|                     ["quietedBy"] = "allow-temporary", | ||||
|                     ["confidence"] = 0.42, | ||||
|                     ["confidenceBand"] = "medium" | ||||
|                 })) | ||||
|         }; | ||||
|  | ||||
|         backend.RuntimePolicyResult = new RuntimePolicyEvaluationResult( | ||||
|             300, | ||||
|             DateTimeOffset.Parse("2025-10-19T12:00:00Z", CultureInfo.InvariantCulture), | ||||
|             "rev-42", | ||||
|             new ReadOnlyDictionary<string, RuntimePolicyImageDecision>(decisions)); | ||||
|  | ||||
|         var provider = BuildServiceProvider(backend); | ||||
|  | ||||
|         try | ||||
|         { | ||||
|             await CommandHandlers.HandleRuntimePolicyTestAsync( | ||||
|                 provider, | ||||
|                 namespaceValue: "prod", | ||||
|                 imageArguments: new[] { "sha256:aaa", "sha256:bbb" }, | ||||
|                 filePath: null, | ||||
|                 labelArguments: new[] { "app=frontend" }, | ||||
|                 outputJson: false, | ||||
|                 verbose: false, | ||||
|                 cancellationToken: CancellationToken.None); | ||||
|  | ||||
|             var output = console.Output; | ||||
|  | ||||
|             Assert.Equal(0, Environment.ExitCode); | ||||
|             Assert.Contains("Image", output, StringComparison.Ordinal); | ||||
|             Assert.Contains("Verdict", output, StringComparison.Ordinal); | ||||
|             Assert.Contains("SBOM Ref", output, StringComparison.Ordinal); | ||||
|             Assert.Contains("Quieted", output, StringComparison.Ordinal); | ||||
|             Assert.Contains("Confidence", output, StringComparison.Ordinal); | ||||
|             Assert.Contains("sha256:aaa", output, StringComparison.Ordinal); | ||||
|             Assert.Contains("uuid-allow", output, StringComparison.Ordinal); | ||||
|             Assert.Contains("(verified)", output, StringComparison.Ordinal); | ||||
|             Assert.Contains("0.97 (high)", output, StringComparison.Ordinal); | ||||
|             Assert.Contains("sha256:bbb", output, StringComparison.Ordinal); | ||||
|             Assert.Contains("uuid-block", output, StringComparison.Ordinal); | ||||
|             Assert.Contains("(unverified)", output, StringComparison.Ordinal); | ||||
|             Assert.Contains("sha256:ccc", output, StringComparison.Ordinal); | ||||
|             Assert.Contains("yes", output, StringComparison.Ordinal); | ||||
|             Assert.Contains("allow-temporary", output, StringComparison.Ordinal); | ||||
|             Assert.True( | ||||
|                 output.IndexOf("sha256:aaa", StringComparison.Ordinal) < | ||||
|                 output.IndexOf("sha256:ccc", StringComparison.Ordinal)); | ||||
|         } | ||||
|         finally | ||||
|         { | ||||
|             Environment.ExitCode = originalExit; | ||||
|             AnsiConsole.Console = originalConsole; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task HandleRuntimePolicyTestAsync_WritesDeterministicJson() | ||||
|     { | ||||
|         var originalExit = Environment.ExitCode; | ||||
|         var originalOut = Console.Out; | ||||
|  | ||||
|         var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null)); | ||||
|  | ||||
|         var decisions = new Dictionary<string, RuntimePolicyImageDecision>(StringComparer.Ordinal) | ||||
|         { | ||||
|             ["sha256:json-a"] = new RuntimePolicyImageDecision( | ||||
|                 "allow", | ||||
|                 true, | ||||
|                 true, | ||||
|                 Array.AsReadOnly(new[] { "baseline allow" }), | ||||
|                 new RuntimePolicyRekorReference("uuid-json-allow", "https://rekor.example/entries/uuid-json-allow", true), | ||||
|                 new ReadOnlyDictionary<string, object?>(new Dictionary<string, object?>(StringComparer.Ordinal) | ||||
|                 { | ||||
|                     ["source"] = "baseline", | ||||
|                     ["confidence"] = 0.66 | ||||
|                 })), | ||||
|             ["sha256:json-b"] = new RuntimePolicyImageDecision( | ||||
|                 "audit", | ||||
|                 true, | ||||
|                 false, | ||||
|                 Array.AsReadOnly(Array.Empty<string>()), | ||||
|                 new RuntimePolicyRekorReference(null, null, null), | ||||
|                 new ReadOnlyDictionary<string, object?>(new Dictionary<string, object?>(StringComparer.Ordinal) | ||||
|                 { | ||||
|                     ["source"] = "mirror", | ||||
|                     ["quieted"] = true, | ||||
|                     ["quietedBy"] = "risk-accepted" | ||||
|                 })) | ||||
|         }; | ||||
|  | ||||
|         backend.RuntimePolicyResult = new RuntimePolicyEvaluationResult( | ||||
|             600, | ||||
|             DateTimeOffset.Parse("2025-10-20T00:00:00Z", CultureInfo.InvariantCulture), | ||||
|             "rev-json-7", | ||||
|             new ReadOnlyDictionary<string, RuntimePolicyImageDecision>(decisions)); | ||||
|  | ||||
|         var provider = BuildServiceProvider(backend); | ||||
|  | ||||
|         using var writer = new StringWriter(); | ||||
|         Console.SetOut(writer); | ||||
|  | ||||
|         try | ||||
|         { | ||||
|             await CommandHandlers.HandleRuntimePolicyTestAsync( | ||||
|                 provider, | ||||
|                 namespaceValue: "staging", | ||||
|                 imageArguments: new[] { "sha256:json-a", "sha256:json-b" }, | ||||
|                 filePath: null, | ||||
|                 labelArguments: Array.Empty<string>(), | ||||
|                 outputJson: true, | ||||
|                 verbose: false, | ||||
|                 cancellationToken: CancellationToken.None); | ||||
|  | ||||
|             var output = writer.ToString().Trim(); | ||||
|  | ||||
|             Assert.Equal(0, Environment.ExitCode); | ||||
|             Assert.False(string.IsNullOrWhiteSpace(output)); | ||||
|  | ||||
|             using var document = JsonDocument.Parse(output); | ||||
|             var root = document.RootElement; | ||||
|  | ||||
|             Assert.Equal(600, root.GetProperty("ttlSeconds").GetInt32()); | ||||
|             Assert.Equal("rev-json-7", root.GetProperty("policyRevision").GetString()); | ||||
|             var expiresAt = root.GetProperty("expiresAtUtc").GetString(); | ||||
|             Assert.NotNull(expiresAt); | ||||
|             Assert.Equal( | ||||
|                 DateTimeOffset.Parse("2025-10-20T00:00:00Z", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal), | ||||
|                 DateTimeOffset.Parse(expiresAt!, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal)); | ||||
|  | ||||
|             var results = root.GetProperty("results"); | ||||
|             var keys = results.EnumerateObject().Select(p => p.Name).ToArray(); | ||||
|             Assert.Equal(new[] { "sha256:json-a", "sha256:json-b" }, keys); | ||||
|  | ||||
|             var first = results.GetProperty("sha256:json-a"); | ||||
|             Assert.Equal("allow", first.GetProperty("policyVerdict").GetString()); | ||||
|             Assert.True(first.GetProperty("signed").GetBoolean()); | ||||
|             Assert.True(first.GetProperty("hasSbomReferrers").GetBoolean()); | ||||
|             var rekor = first.GetProperty("rekor"); | ||||
|             Assert.Equal("uuid-json-allow", rekor.GetProperty("uuid").GetString()); | ||||
|             Assert.True(rekor.GetProperty("verified").GetBoolean()); | ||||
|             Assert.Equal("baseline", first.GetProperty("source").GetString()); | ||||
|             Assert.Equal(0.66, first.GetProperty("confidence").GetDouble(), 3); | ||||
|  | ||||
|             var second = results.GetProperty("sha256:json-b"); | ||||
|             Assert.Equal("audit", second.GetProperty("policyVerdict").GetString()); | ||||
|             Assert.True(second.GetProperty("signed").GetBoolean()); | ||||
|             Assert.False(second.GetProperty("hasSbomReferrers").GetBoolean()); | ||||
|             Assert.Equal("mirror", second.GetProperty("source").GetString()); | ||||
|             Assert.True(second.GetProperty("quieted").GetBoolean()); | ||||
|             Assert.Equal("risk-accepted", second.GetProperty("quietedBy").GetString()); | ||||
|             Assert.False(second.TryGetProperty("rekor", out _)); | ||||
|         } | ||||
|         finally | ||||
|         { | ||||
|             Console.SetOut(originalOut); | ||||
|             Environment.ExitCode = originalExit; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private static async Task<RevocationArtifactPaths> WriteRevocationArtifactsAsync(TempDirectory temp, string? providerHint) | ||||
|     { | ||||
|         var (bundleBytes, signature, keyPem) = await BuildRevocationArtifactsAsync(providerHint); | ||||
| @@ -665,10 +881,17 @@ public sealed class CommandHandlersTests | ||||
|             $"{Path.GetFileNameWithoutExtension(tempResultsFile)}-run.json"); | ||||
|         return new StubExecutor(new ScannerExecutionResult(0, tempResultsFile, tempMetadataFile)); | ||||
|     } | ||||
|  | ||||
|  | ||||
|     private sealed class StubBackendClient : IBackendOperationsClient | ||||
|     { | ||||
|         private readonly JobTriggerResult _jobResult; | ||||
|         private static readonly RuntimePolicyEvaluationResult DefaultRuntimePolicyResult = | ||||
|             new RuntimePolicyEvaluationResult( | ||||
|                 0, | ||||
|                 null, | ||||
|                 null, | ||||
|                 new ReadOnlyDictionary<string, RuntimePolicyImageDecision>( | ||||
|                     new Dictionary<string, RuntimePolicyImageDecision>())); | ||||
|  | ||||
|         public StubBackendClient(JobTriggerResult result) | ||||
|         { | ||||
| @@ -683,6 +906,7 @@ public sealed class CommandHandlersTests | ||||
|         public List<(string ExportId, string DestinationPath, string? Algorithm, string? Digest)> ExportDownloads { get; } = new(); | ||||
|         public ExcititorOperationResult? ExcititorResult { get; set; } = new ExcititorOperationResult(true, "ok", null, null); | ||||
|         public IReadOnlyList<ExcititorProviderSummary> ProviderSummaries { get; set; } = Array.Empty<ExcititorProviderSummary>(); | ||||
|         public RuntimePolicyEvaluationResult RuntimePolicyResult { get; set; } = DefaultRuntimePolicyResult; | ||||
|  | ||||
|         public Task<ScannerArtifactResult> DownloadScannerAsync(string channel, string outputPath, bool overwrite, bool verbose, CancellationToken cancellationToken) | ||||
|             => throw new NotImplementedException(); | ||||
| @@ -726,21 +950,18 @@ public sealed class CommandHandlersTests | ||||
|             => Task.FromResult(ProviderSummaries); | ||||
|  | ||||
|         public Task<RuntimePolicyEvaluationResult> EvaluateRuntimePolicyAsync(RuntimePolicyEvaluationRequest request, CancellationToken cancellationToken) | ||||
|         { | ||||
|             var empty = new ReadOnlyDictionary<string, RuntimePolicyImageDecision>(new Dictionary<string, RuntimePolicyImageDecision>()); | ||||
|             return Task.FromResult(new RuntimePolicyEvaluationResult(0, null, null, empty)); | ||||
|         } | ||||
|             => Task.FromResult(RuntimePolicyResult); | ||||
|     } | ||||
|  | ||||
|     private sealed class StubExecutor : IScannerExecutor | ||||
|     { | ||||
|         private readonly ScannerExecutionResult _result; | ||||
|  | ||||
|         public StubExecutor(ScannerExecutionResult result) | ||||
|         { | ||||
|             _result = result; | ||||
|         } | ||||
|  | ||||
|  | ||||
|     private sealed class StubExecutor : IScannerExecutor | ||||
|     { | ||||
|         private readonly ScannerExecutionResult _result; | ||||
|  | ||||
|         public StubExecutor(ScannerExecutionResult result) | ||||
|         { | ||||
|             _result = result; | ||||
|         } | ||||
|  | ||||
|         public Task<ScannerExecutionResult> RunAsync(string runner, string entry, string targetDirectory, string resultsDirectory, IReadOnlyList<string> arguments, bool verbose, CancellationToken cancellationToken) | ||||
|         { | ||||
|             Directory.CreateDirectory(Path.GetDirectoryName(_result.ResultsPath)!); | ||||
| @@ -757,8 +978,8 @@ public sealed class CommandHandlersTests | ||||
|  | ||||
|             return Task.FromResult(_result); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     } | ||||
|  | ||||
|     private sealed class StubInstaller : IScannerInstaller | ||||
|     { | ||||
|         public Task InstallAsync(string artifactPath, bool verbose, CancellationToken cancellationToken) | ||||
|   | ||||
| @@ -1,4 +1,4 @@ | ||||
| using System; | ||||
| using System; | ||||
| using System.Collections.ObjectModel; | ||||
| using System.Globalization; | ||||
| using System.IO; | ||||
| @@ -6,163 +6,163 @@ using System.Net; | ||||
| using System.Net.Http; | ||||
| using System.Net.Http.Json; | ||||
| using System.Security.Cryptography; | ||||
| using System.Text; | ||||
| using System.Text.Json; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using System.Text; | ||||
| using System.Text.Json; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using Microsoft.Extensions.Logging; | ||||
| using Microsoft.IdentityModel.Tokens; | ||||
| using StellaOps.Auth.Abstractions; | ||||
| using StellaOps.Auth.Client; | ||||
| using StellaOps.Cli.Configuration; | ||||
| using StellaOps.Cli.Services; | ||||
| using StellaOps.Cli.Services.Models; | ||||
| using StellaOps.Cli.Services.Models.Transport; | ||||
| using StellaOps.Cli.Tests.Testing; | ||||
|  | ||||
| namespace StellaOps.Cli.Tests.Services; | ||||
|  | ||||
| public sealed class BackendOperationsClientTests | ||||
| { | ||||
|     [Fact] | ||||
|     public async Task DownloadScannerAsync_VerifiesDigestAndWritesMetadata() | ||||
|     { | ||||
|         using var temp = new TempDirectory(); | ||||
|  | ||||
|         var contentBytes = Encoding.UTF8.GetBytes("scanner-blob"); | ||||
|         var digestHex = Convert.ToHexString(SHA256.HashData(contentBytes)).ToLowerInvariant(); | ||||
|  | ||||
|         var handler = new StubHttpMessageHandler((request, _) => | ||||
|         { | ||||
|             var response = new HttpResponseMessage(HttpStatusCode.OK) | ||||
|             { | ||||
|                 Content = new ByteArrayContent(contentBytes), | ||||
|                 RequestMessage = request | ||||
|             }; | ||||
|  | ||||
|             response.Headers.Add("X-StellaOps-Digest", $"sha256:{digestHex}"); | ||||
|             response.Content.Headers.LastModified = DateTimeOffset.UtcNow; | ||||
|             response.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/octet-stream"); | ||||
|             return response; | ||||
|         }); | ||||
|  | ||||
|         var httpClient = new HttpClient(handler) | ||||
|         { | ||||
|             BaseAddress = new Uri("https://concelier.example") | ||||
|         }; | ||||
|  | ||||
|         var options = new StellaOpsCliOptions | ||||
|         { | ||||
|             BackendUrl = "https://concelier.example", | ||||
|             ScannerCacheDirectory = temp.Path, | ||||
|             ScannerDownloadAttempts = 1 | ||||
|         }; | ||||
|  | ||||
|         var loggerFactory = LoggerFactory.Create(builder => builder.SetMinimumLevel(LogLevel.Debug)); | ||||
|         var client = new BackendOperationsClient(httpClient, options, loggerFactory.CreateLogger<BackendOperationsClient>()); | ||||
|  | ||||
|         var targetPath = Path.Combine(temp.Path, "scanner.tar.gz"); | ||||
|         var result = await client.DownloadScannerAsync("stable", targetPath, overwrite: false, verbose: true, CancellationToken.None); | ||||
|  | ||||
|         Assert.False(result.FromCache); | ||||
|         Assert.True(File.Exists(targetPath)); | ||||
|  | ||||
|         var metadataPath = targetPath + ".metadata.json"; | ||||
|         Assert.True(File.Exists(metadataPath)); | ||||
|  | ||||
|         using var document = JsonDocument.Parse(File.ReadAllText(metadataPath)); | ||||
|         Assert.Equal($"sha256:{digestHex}", document.RootElement.GetProperty("digest").GetString()); | ||||
|         Assert.Equal("stable", document.RootElement.GetProperty("channel").GetString()); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task DownloadScannerAsync_ThrowsOnDigestMismatch() | ||||
|     { | ||||
|         using var temp = new TempDirectory(); | ||||
|  | ||||
|         var contentBytes = Encoding.UTF8.GetBytes("scanner-data"); | ||||
|         var handler = new StubHttpMessageHandler((request, _) => | ||||
|         { | ||||
|             var response = new HttpResponseMessage(HttpStatusCode.OK) | ||||
|             { | ||||
|                 Content = new ByteArrayContent(contentBytes), | ||||
|                 RequestMessage = request | ||||
|             }; | ||||
|             response.Headers.Add("X-StellaOps-Digest", "sha256:deadbeef"); | ||||
|             return response; | ||||
|         }); | ||||
|  | ||||
|         var httpClient = new HttpClient(handler) | ||||
|         { | ||||
|             BaseAddress = new Uri("https://concelier.example") | ||||
|         }; | ||||
|  | ||||
|         var options = new StellaOpsCliOptions | ||||
|         { | ||||
|             BackendUrl = "https://concelier.example", | ||||
|             ScannerCacheDirectory = temp.Path, | ||||
|             ScannerDownloadAttempts = 1 | ||||
|         }; | ||||
|  | ||||
|         var loggerFactory = LoggerFactory.Create(builder => builder.SetMinimumLevel(LogLevel.Debug)); | ||||
|         var client = new BackendOperationsClient(httpClient, options, loggerFactory.CreateLogger<BackendOperationsClient>()); | ||||
|  | ||||
|         var targetPath = Path.Combine(temp.Path, "scanner.tar.gz"); | ||||
|  | ||||
|         await Assert.ThrowsAsync<InvalidOperationException>(() => client.DownloadScannerAsync("stable", targetPath, overwrite: true, verbose: false, CancellationToken.None)); | ||||
|         Assert.False(File.Exists(targetPath)); | ||||
|     } | ||||
|  | ||||
| using StellaOps.Cli.Configuration; | ||||
| using StellaOps.Cli.Services; | ||||
| using StellaOps.Cli.Services.Models; | ||||
| using StellaOps.Cli.Services.Models.Transport; | ||||
| using StellaOps.Cli.Tests.Testing; | ||||
|  | ||||
| namespace StellaOps.Cli.Tests.Services; | ||||
|  | ||||
| public sealed class BackendOperationsClientTests | ||||
| { | ||||
|     [Fact] | ||||
|     public async Task DownloadScannerAsync_VerifiesDigestAndWritesMetadata() | ||||
|     { | ||||
|         using var temp = new TempDirectory(); | ||||
|  | ||||
|         var contentBytes = Encoding.UTF8.GetBytes("scanner-blob"); | ||||
|         var digestHex = Convert.ToHexString(SHA256.HashData(contentBytes)).ToLowerInvariant(); | ||||
|  | ||||
|         var handler = new StubHttpMessageHandler((request, _) => | ||||
|         { | ||||
|             var response = new HttpResponseMessage(HttpStatusCode.OK) | ||||
|             { | ||||
|                 Content = new ByteArrayContent(contentBytes), | ||||
|                 RequestMessage = request | ||||
|             }; | ||||
|  | ||||
|             response.Headers.Add("X-StellaOps-Digest", $"sha256:{digestHex}"); | ||||
|             response.Content.Headers.LastModified = DateTimeOffset.UtcNow; | ||||
|             response.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/octet-stream"); | ||||
|             return response; | ||||
|         }); | ||||
|  | ||||
|         var httpClient = new HttpClient(handler) | ||||
|         { | ||||
|             BaseAddress = new Uri("https://concelier.example") | ||||
|         }; | ||||
|  | ||||
|         var options = new StellaOpsCliOptions | ||||
|         { | ||||
|             BackendUrl = "https://concelier.example", | ||||
|             ScannerCacheDirectory = temp.Path, | ||||
|             ScannerDownloadAttempts = 1 | ||||
|         }; | ||||
|  | ||||
|         var loggerFactory = LoggerFactory.Create(builder => builder.SetMinimumLevel(LogLevel.Debug)); | ||||
|         var client = new BackendOperationsClient(httpClient, options, loggerFactory.CreateLogger<BackendOperationsClient>()); | ||||
|  | ||||
|         var targetPath = Path.Combine(temp.Path, "scanner.tar.gz"); | ||||
|         var result = await client.DownloadScannerAsync("stable", targetPath, overwrite: false, verbose: true, CancellationToken.None); | ||||
|  | ||||
|         Assert.False(result.FromCache); | ||||
|         Assert.True(File.Exists(targetPath)); | ||||
|  | ||||
|         var metadataPath = targetPath + ".metadata.json"; | ||||
|         Assert.True(File.Exists(metadataPath)); | ||||
|  | ||||
|         using var document = JsonDocument.Parse(File.ReadAllText(metadataPath)); | ||||
|         Assert.Equal($"sha256:{digestHex}", document.RootElement.GetProperty("digest").GetString()); | ||||
|         Assert.Equal("stable", document.RootElement.GetProperty("channel").GetString()); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task DownloadScannerAsync_ThrowsOnDigestMismatch() | ||||
|     { | ||||
|         using var temp = new TempDirectory(); | ||||
|  | ||||
|         var contentBytes = Encoding.UTF8.GetBytes("scanner-data"); | ||||
|         var handler = new StubHttpMessageHandler((request, _) => | ||||
|         { | ||||
|             var response = new HttpResponseMessage(HttpStatusCode.OK) | ||||
|             { | ||||
|                 Content = new ByteArrayContent(contentBytes), | ||||
|                 RequestMessage = request | ||||
|             }; | ||||
|             response.Headers.Add("X-StellaOps-Digest", "sha256:deadbeef"); | ||||
|             return response; | ||||
|         }); | ||||
|  | ||||
|         var httpClient = new HttpClient(handler) | ||||
|         { | ||||
|             BaseAddress = new Uri("https://concelier.example") | ||||
|         }; | ||||
|  | ||||
|         var options = new StellaOpsCliOptions | ||||
|         { | ||||
|             BackendUrl = "https://concelier.example", | ||||
|             ScannerCacheDirectory = temp.Path, | ||||
|             ScannerDownloadAttempts = 1 | ||||
|         }; | ||||
|  | ||||
|         var loggerFactory = LoggerFactory.Create(builder => builder.SetMinimumLevel(LogLevel.Debug)); | ||||
|         var client = new BackendOperationsClient(httpClient, options, loggerFactory.CreateLogger<BackendOperationsClient>()); | ||||
|  | ||||
|         var targetPath = Path.Combine(temp.Path, "scanner.tar.gz"); | ||||
|  | ||||
|         await Assert.ThrowsAsync<InvalidOperationException>(() => client.DownloadScannerAsync("stable", targetPath, overwrite: true, verbose: false, CancellationToken.None)); | ||||
|         Assert.False(File.Exists(targetPath)); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task DownloadScannerAsync_RetriesOnFailure() | ||||
|     { | ||||
|         using var temp = new TempDirectory(); | ||||
|  | ||||
|         var successBytes = Encoding.UTF8.GetBytes("success"); | ||||
|         var digestHex = Convert.ToHexString(SHA256.HashData(successBytes)).ToLowerInvariant(); | ||||
|         var attempts = 0; | ||||
|  | ||||
|         var handler = new StubHttpMessageHandler( | ||||
|             (request, _) => | ||||
|             { | ||||
|                 attempts++; | ||||
|                 return new HttpResponseMessage(HttpStatusCode.InternalServerError) | ||||
|                 { | ||||
|                     RequestMessage = request, | ||||
|                     Content = new StringContent("error") | ||||
|                 }; | ||||
|             }, | ||||
|             (request, _) => | ||||
|             { | ||||
|                 attempts++; | ||||
|                 var response = new HttpResponseMessage(HttpStatusCode.OK) | ||||
|                 { | ||||
|                     RequestMessage = request, | ||||
|                     Content = new ByteArrayContent(successBytes) | ||||
|                 }; | ||||
|                 response.Headers.Add("X-StellaOps-Digest", $"sha256:{digestHex}"); | ||||
|                 return response; | ||||
|             }); | ||||
|  | ||||
|         var httpClient = new HttpClient(handler) | ||||
|         { | ||||
|             BaseAddress = new Uri("https://concelier.example") | ||||
|         }; | ||||
|  | ||||
|         var options = new StellaOpsCliOptions | ||||
|         { | ||||
|             BackendUrl = "https://concelier.example", | ||||
|             ScannerCacheDirectory = temp.Path, | ||||
|             ScannerDownloadAttempts = 3 | ||||
|         }; | ||||
|  | ||||
|         var loggerFactory = LoggerFactory.Create(builder => builder.SetMinimumLevel(LogLevel.Debug)); | ||||
|         var client = new BackendOperationsClient(httpClient, options, loggerFactory.CreateLogger<BackendOperationsClient>()); | ||||
|  | ||||
|         var targetPath = Path.Combine(temp.Path, "scanner.tar.gz"); | ||||
|         var result = await client.DownloadScannerAsync("stable", targetPath, overwrite: false, verbose: false, CancellationToken.None); | ||||
|  | ||||
|         Assert.Equal(2, attempts); | ||||
|         var successBytes = Encoding.UTF8.GetBytes("success"); | ||||
|         var digestHex = Convert.ToHexString(SHA256.HashData(successBytes)).ToLowerInvariant(); | ||||
|         var attempts = 0; | ||||
|  | ||||
|         var handler = new StubHttpMessageHandler( | ||||
|             (request, _) => | ||||
|             { | ||||
|                 attempts++; | ||||
|                 return new HttpResponseMessage(HttpStatusCode.InternalServerError) | ||||
|                 { | ||||
|                     RequestMessage = request, | ||||
|                     Content = new StringContent("error") | ||||
|                 }; | ||||
|             }, | ||||
|             (request, _) => | ||||
|             { | ||||
|                 attempts++; | ||||
|                 var response = new HttpResponseMessage(HttpStatusCode.OK) | ||||
|                 { | ||||
|                     RequestMessage = request, | ||||
|                     Content = new ByteArrayContent(successBytes) | ||||
|                 }; | ||||
|                 response.Headers.Add("X-StellaOps-Digest", $"sha256:{digestHex}"); | ||||
|                 return response; | ||||
|             }); | ||||
|  | ||||
|         var httpClient = new HttpClient(handler) | ||||
|         { | ||||
|             BaseAddress = new Uri("https://concelier.example") | ||||
|         }; | ||||
|  | ||||
|         var options = new StellaOpsCliOptions | ||||
|         { | ||||
|             BackendUrl = "https://concelier.example", | ||||
|             ScannerCacheDirectory = temp.Path, | ||||
|             ScannerDownloadAttempts = 3 | ||||
|         }; | ||||
|  | ||||
|         var loggerFactory = LoggerFactory.Create(builder => builder.SetMinimumLevel(LogLevel.Debug)); | ||||
|         var client = new BackendOperationsClient(httpClient, options, loggerFactory.CreateLogger<BackendOperationsClient>()); | ||||
|  | ||||
|         var targetPath = Path.Combine(temp.Path, "scanner.tar.gz"); | ||||
|         var result = await client.DownloadScannerAsync("stable", targetPath, overwrite: false, verbose: false, CancellationToken.None); | ||||
|  | ||||
|         Assert.Equal(2, attempts); | ||||
|         Assert.False(result.FromCache); | ||||
|         Assert.True(File.Exists(targetPath)); | ||||
|     } | ||||
| @@ -251,73 +251,73 @@ public sealed class BackendOperationsClientTests | ||||
|         await Assert.ThrowsAsync<InvalidOperationException>(() => client.UploadScanResultsAsync(filePath, CancellationToken.None)); | ||||
|         Assert.Equal(2, attempts); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task TriggerJobAsync_ReturnsAcceptedResult() | ||||
|     { | ||||
|         var handler = new StubHttpMessageHandler((request, _) => | ||||
|         { | ||||
|             var response = new HttpResponseMessage(HttpStatusCode.Accepted) | ||||
|             { | ||||
|                 RequestMessage = request, | ||||
|                 Content = JsonContent.Create(new JobRunResponse | ||||
|                 { | ||||
|                     RunId = Guid.NewGuid(), | ||||
|                     Status = "queued", | ||||
|                     Kind = "export:json", | ||||
|                     Trigger = "cli", | ||||
|                     CreatedAt = DateTimeOffset.UtcNow | ||||
|                 }) | ||||
|             }; | ||||
|             response.Headers.Location = new Uri("/jobs/export:json/runs/123", UriKind.Relative); | ||||
|             return response; | ||||
|         }); | ||||
|  | ||||
|         var httpClient = new HttpClient(handler) | ||||
|         { | ||||
|             BaseAddress = new Uri("https://concelier.example") | ||||
|         }; | ||||
|  | ||||
|         var options = new StellaOpsCliOptions { BackendUrl = "https://concelier.example" }; | ||||
|         var loggerFactory = LoggerFactory.Create(builder => builder.SetMinimumLevel(LogLevel.Debug)); | ||||
|         var client = new BackendOperationsClient(httpClient, options, loggerFactory.CreateLogger<BackendOperationsClient>()); | ||||
|  | ||||
|         var result = await client.TriggerJobAsync("export:json", new Dictionary<string, object?>(), CancellationToken.None); | ||||
|  | ||||
|         Assert.True(result.Success); | ||||
|         Assert.Equal("Accepted", result.Message); | ||||
|         Assert.Equal("/jobs/export:json/runs/123", result.Location); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task TriggerJobAsync_ReturnsAcceptedResult() | ||||
|     { | ||||
|         var handler = new StubHttpMessageHandler((request, _) => | ||||
|         { | ||||
|             var response = new HttpResponseMessage(HttpStatusCode.Accepted) | ||||
|             { | ||||
|                 RequestMessage = request, | ||||
|                 Content = JsonContent.Create(new JobRunResponse | ||||
|                 { | ||||
|                     RunId = Guid.NewGuid(), | ||||
|                     Status = "queued", | ||||
|                     Kind = "export:json", | ||||
|                     Trigger = "cli", | ||||
|                     CreatedAt = DateTimeOffset.UtcNow | ||||
|                 }) | ||||
|             }; | ||||
|             response.Headers.Location = new Uri("/jobs/export:json/runs/123", UriKind.Relative); | ||||
|             return response; | ||||
|         }); | ||||
|  | ||||
|         var httpClient = new HttpClient(handler) | ||||
|         { | ||||
|             BaseAddress = new Uri("https://concelier.example") | ||||
|         }; | ||||
|  | ||||
|         var options = new StellaOpsCliOptions { BackendUrl = "https://concelier.example" }; | ||||
|         var loggerFactory = LoggerFactory.Create(builder => builder.SetMinimumLevel(LogLevel.Debug)); | ||||
|         var client = new BackendOperationsClient(httpClient, options, loggerFactory.CreateLogger<BackendOperationsClient>()); | ||||
|  | ||||
|         var result = await client.TriggerJobAsync("export:json", new Dictionary<string, object?>(), CancellationToken.None); | ||||
|  | ||||
|         Assert.True(result.Success); | ||||
|         Assert.Equal("Accepted", result.Message); | ||||
|         Assert.Equal("/jobs/export:json/runs/123", result.Location); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task TriggerJobAsync_ReturnsFailureMessage() | ||||
|     { | ||||
|         var handler = new StubHttpMessageHandler((request, _) => | ||||
|         { | ||||
|             var problem = new | ||||
|             { | ||||
|                 title = "Job already running", | ||||
|                 detail = "export job active" | ||||
|             }; | ||||
|  | ||||
|             var response = new HttpResponseMessage(HttpStatusCode.Conflict) | ||||
|             { | ||||
|                 RequestMessage = request, | ||||
|                 Content = JsonContent.Create(problem) | ||||
|             }; | ||||
|             return response; | ||||
|         }); | ||||
|  | ||||
|         var httpClient = new HttpClient(handler) | ||||
|         { | ||||
|             BaseAddress = new Uri("https://concelier.example") | ||||
|         }; | ||||
|  | ||||
|         var options = new StellaOpsCliOptions { BackendUrl = "https://concelier.example" }; | ||||
|         var loggerFactory = LoggerFactory.Create(builder => builder.SetMinimumLevel(LogLevel.Debug)); | ||||
|         var client = new BackendOperationsClient(httpClient, options, loggerFactory.CreateLogger<BackendOperationsClient>()); | ||||
|  | ||||
|         var result = await client.TriggerJobAsync("export:json", new Dictionary<string, object?>(), CancellationToken.None); | ||||
|             { | ||||
|                 title = "Job already running", | ||||
|                 detail = "export job active" | ||||
|             }; | ||||
|  | ||||
|             var response = new HttpResponseMessage(HttpStatusCode.Conflict) | ||||
|             { | ||||
|                 RequestMessage = request, | ||||
|                 Content = JsonContent.Create(problem) | ||||
|             }; | ||||
|             return response; | ||||
|         }); | ||||
|  | ||||
|         var httpClient = new HttpClient(handler) | ||||
|         { | ||||
|             BaseAddress = new Uri("https://concelier.example") | ||||
|         }; | ||||
|  | ||||
|         var options = new StellaOpsCliOptions { BackendUrl = "https://concelier.example" }; | ||||
|         var loggerFactory = LoggerFactory.Create(builder => builder.SetMinimumLevel(LogLevel.Debug)); | ||||
|         var client = new BackendOperationsClient(httpClient, options, loggerFactory.CreateLogger<BackendOperationsClient>()); | ||||
|  | ||||
|         var result = await client.TriggerJobAsync("export:json", new Dictionary<string, object?>(), CancellationToken.None); | ||||
|  | ||||
|         Assert.False(result.Success); | ||||
|         Assert.Contains("Job already running", result.Message); | ||||
| @@ -403,18 +403,19 @@ public sealed class BackendOperationsClientTests | ||||
|     ""ghcr.io/app@sha256:abc"": { | ||||
|       ""policyVerdict"": ""pass"", | ||||
|       ""signed"": true, | ||||
|       ""hasSbom"": true, | ||||
|       ""hasSbomReferrers"": true, | ||||
|       ""reasons"": [], | ||||
|       ""rekor"": { ""uuid"": ""uuid-1"", ""url"": ""https://rekor.example/uuid-1"" }, | ||||
|       ""rekor"": { ""uuid"": ""uuid-1"", ""url"": ""https://rekor.example/uuid-1"", ""verified"": true }, | ||||
|       ""confidence"": 0.87, | ||||
|       ""quiet"": false, | ||||
|       ""quieted"": false, | ||||
|       ""metadata"": { ""note"": ""cached"" } | ||||
|     }, | ||||
|     ""ghcr.io/api@sha256:def"": { | ||||
|       ""policyVerdict"": ""fail"", | ||||
|       ""signed"": false, | ||||
|       ""hasSbom"": false, | ||||
|       ""reasons"": [""unsigned"", ""missing sbom""] | ||||
|       ""hasSbomReferrers"": false, | ||||
|       ""reasons"": [""unsigned"", ""missing sbom""], | ||||
|       ""quietedBy"": ""manual-override"" | ||||
|     } | ||||
|   } | ||||
| }"; | ||||
| @@ -458,13 +459,14 @@ public sealed class BackendOperationsClientTests | ||||
|         var primary = result.Decisions["ghcr.io/app@sha256:abc"]; | ||||
|         Assert.Equal("pass", primary.PolicyVerdict); | ||||
|         Assert.True(primary.Signed); | ||||
|         Assert.True(primary.HasSbom); | ||||
|         Assert.True(primary.HasSbomReferrers); | ||||
|         Assert.Empty(primary.Reasons); | ||||
|         Assert.NotNull(primary.Rekor); | ||||
|         Assert.Equal("uuid-1", primary.Rekor!.Uuid); | ||||
|         Assert.Equal("https://rekor.example/uuid-1", primary.Rekor.Url); | ||||
|         Assert.True(primary.Rekor.Verified); | ||||
|         Assert.Equal(0.87, Assert.IsType<double>(primary.AdditionalProperties["confidence"]), 3); | ||||
|         Assert.False(Assert.IsType<bool>(primary.AdditionalProperties["quiet"])); | ||||
|         Assert.False(Assert.IsType<bool>(primary.AdditionalProperties["quieted"])); | ||||
|         var metadataJson = Assert.IsType<string>(primary.AdditionalProperties["metadata"]); | ||||
|         using var metadataDocument = JsonDocument.Parse(metadataJson); | ||||
|         Assert.Equal("cached", metadataDocument.RootElement.GetProperty("note").GetString()); | ||||
| @@ -472,10 +474,11 @@ public sealed class BackendOperationsClientTests | ||||
|         var secondary = result.Decisions["ghcr.io/api@sha256:def"]; | ||||
|         Assert.Equal("fail", secondary.PolicyVerdict); | ||||
|         Assert.False(secondary.Signed); | ||||
|         Assert.False(secondary.HasSbom); | ||||
|         Assert.False(secondary.HasSbomReferrers); | ||||
|         Assert.Collection(secondary.Reasons, | ||||
|             item => Assert.Equal("unsigned", item), | ||||
|             item => Assert.Equal("missing sbom", item)); | ||||
|         Assert.Equal("manual-override", Assert.IsType<string>(secondary.AdditionalProperties["quietedBy"])); | ||||
|     } | ||||
|  | ||||
|     private sealed class StubTokenClient : IStellaOpsTokenClient | ||||
|   | ||||
| @@ -16,13 +16,14 @@ | ||||
|     <!-- https://learn.microsoft.comdotnet/core/testing/microsoft-testing-platform-extensions-code-coverage --> | ||||
|   </PropertyGroup> | ||||
|  | ||||
|   <ItemGroup> | ||||
|     <Using Include="Xunit" /> | ||||
|   </ItemGroup> | ||||
|  | ||||
|   <ItemGroup> | ||||
|     <ProjectReference Include="..\StellaOps.Cli\StellaOps.Cli.csproj" /> | ||||
|     <ProjectReference Include="..\StellaOps.Configuration\StellaOps.Configuration.csproj" /> | ||||
|   </ItemGroup> | ||||
|  | ||||
| </Project> | ||||
|   <ItemGroup> | ||||
|     <Using Include="Xunit" /> | ||||
|   </ItemGroup> | ||||
|  | ||||
|   <ItemGroup> | ||||
|     <PackageReference Include="Spectre.Console.Testing" Version="0.48.0" /> | ||||
|     <ProjectReference Include="..\StellaOps.Cli\StellaOps.Cli.csproj" /> | ||||
|     <ProjectReference Include="..\StellaOps.Configuration\StellaOps.Configuration.csproj" /> | ||||
|   </ItemGroup> | ||||
|  | ||||
| </Project> | ||||
|   | ||||
		Reference in New Issue
	
	Block a user