Resolve Concelier/Excititor merge conflicts
This commit is contained in:
		| @@ -1,12 +1,16 @@ | ||||
| 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; | ||||
| 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; | ||||
| @@ -19,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", | ||||
| @@ -43,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() | ||||
|     { | ||||
| @@ -81,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; | ||||
|         } | ||||
|     } | ||||
|  | ||||
| @@ -128,7 +134,7 @@ public sealed class CommandHandlersTests | ||||
|                     Url = "https://authority.example", | ||||
|                     ClientId = "cli", | ||||
|                     ClientSecret = "secret", | ||||
|                     Scope = "feedser.jobs.trigger", | ||||
|                     Scope = "concelier.jobs.trigger", | ||||
|                     TokenCacheDirectory = tempDir.Path | ||||
|                 } | ||||
|             }; | ||||
| @@ -211,6 +217,168 @@ public sealed class CommandHandlersTests | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task HandleExcititorInitAsync_CallsBackend() | ||||
|     { | ||||
|         var original = Environment.ExitCode; | ||||
|         try | ||||
|         { | ||||
|             var backend = new StubBackendClient(new JobTriggerResult(true, "accepted", null, null)); | ||||
|             var provider = BuildServiceProvider(backend); | ||||
|  | ||||
|             await CommandHandlers.HandleExcititorInitAsync( | ||||
|                 provider, | ||||
|                 new[] { "redhat" }, | ||||
|                 resume: true, | ||||
|                 verbose: false, | ||||
|                 cancellationToken: CancellationToken.None); | ||||
|  | ||||
|             Assert.Equal(0, Environment.ExitCode); | ||||
|             Assert.Equal("init", backend.LastExcititorRoute); | ||||
|             Assert.Equal(HttpMethod.Post, backend.LastExcititorMethod); | ||||
|             var payload = Assert.IsAssignableFrom<IDictionary<string, object?>>(backend.LastExcititorPayload); | ||||
|             Assert.Equal(true, payload["resume"]); | ||||
|             var providers = Assert.IsAssignableFrom<IEnumerable<string>>(payload["providers"]!); | ||||
|             Assert.Contains("redhat", providers, StringComparer.OrdinalIgnoreCase); | ||||
|         } | ||||
|         finally | ||||
|         { | ||||
|             Environment.ExitCode = original; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task HandleExcititorListProvidersAsync_WritesOutput() | ||||
|     { | ||||
|         var original = Environment.ExitCode; | ||||
|         try | ||||
|         { | ||||
|             var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null)) | ||||
|             { | ||||
|                 ProviderSummaries = new[] | ||||
|                 { | ||||
|                     new ExcititorProviderSummary("redhat", "distro", "Red Hat", "vendor", true, DateTimeOffset.UtcNow) | ||||
|                 } | ||||
|             }; | ||||
|  | ||||
|             var provider = BuildServiceProvider(backend); | ||||
|             await CommandHandlers.HandleExcititorListProvidersAsync(provider, includeDisabled: false, verbose: false, cancellationToken: CancellationToken.None); | ||||
|  | ||||
|             Assert.Equal(0, Environment.ExitCode); | ||||
|         } | ||||
|         finally | ||||
|         { | ||||
|             Environment.ExitCode = original; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task HandleExcititorVerifyAsync_FailsWithoutArguments() | ||||
|     { | ||||
|         var original = Environment.ExitCode; | ||||
|         try | ||||
|         { | ||||
|             var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null)); | ||||
|             var provider = BuildServiceProvider(backend); | ||||
|  | ||||
|             await CommandHandlers.HandleExcititorVerifyAsync(provider, null, null, null, verbose: false, cancellationToken: CancellationToken.None); | ||||
|  | ||||
|             Assert.Equal(1, Environment.ExitCode); | ||||
|         } | ||||
|         finally | ||||
|         { | ||||
|             Environment.ExitCode = original; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task HandleExcititorVerifyAsync_AttachesAttestationFile() | ||||
|     { | ||||
|         var original = Environment.ExitCode; | ||||
|         using var tempFile = new TempFile("attestation.json", Encoding.UTF8.GetBytes("{\"ok\":true}")); | ||||
|         try | ||||
|         { | ||||
|             var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null)); | ||||
|             var provider = BuildServiceProvider(backend); | ||||
|  | ||||
|             await CommandHandlers.HandleExcititorVerifyAsync( | ||||
|                 provider, | ||||
|                 exportId: "export-123", | ||||
|                 digest: "sha256:abc", | ||||
|                 attestationPath: tempFile.Path, | ||||
|                 verbose: false, | ||||
|                 cancellationToken: CancellationToken.None); | ||||
|  | ||||
|             Assert.Equal(0, Environment.ExitCode); | ||||
|             Assert.Equal("verify", backend.LastExcititorRoute); | ||||
|             var payload = Assert.IsAssignableFrom<IDictionary<string, object?>>(backend.LastExcititorPayload); | ||||
|             Assert.Equal("export-123", payload["exportId"]); | ||||
|             Assert.Equal("sha256:abc", payload["digest"]); | ||||
|             var attestation = Assert.IsAssignableFrom<IDictionary<string, object?>>(payload["attestation"]!); | ||||
|             Assert.Equal(Path.GetFileName(tempFile.Path), attestation["fileName"]); | ||||
|             Assert.NotNull(attestation["base64"]); | ||||
|         } | ||||
|         finally | ||||
|         { | ||||
|             Environment.ExitCode = original; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task HandleExcititorExportAsync_DownloadsWhenOutputProvided() | ||||
|     { | ||||
|         var original = Environment.ExitCode; | ||||
|         using var tempDir = new TempDirectory(); | ||||
|  | ||||
|         try | ||||
|         { | ||||
|             var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null)); | ||||
|             const string manifestJson = """ | ||||
|             { | ||||
|               "exportId": "exports/20251019T101530Z/abcdef1234567890", | ||||
|               "format": "openvex", | ||||
|               "createdAt": "2025-10-19T10:15:30Z", | ||||
|               "artifact": { "algorithm": "sha256", "digest": "abcdef1234567890" }, | ||||
|               "fromCache": false, | ||||
|               "sizeBytes": 2048, | ||||
|               "attestation": { | ||||
|                 "rekor": { | ||||
|                   "location": "https://rekor.example/api/v1/log/entries/123", | ||||
|                   "logIndex": "123" | ||||
|                 } | ||||
|               } | ||||
|             } | ||||
|             """; | ||||
|  | ||||
|             backend.ExcititorResult = new ExcititorOperationResult(true, "ok", null, JsonDocument.Parse(manifestJson).RootElement.Clone()); | ||||
|             var provider = BuildServiceProvider(backend); | ||||
|             var outputPath = Path.Combine(tempDir.Path, "export.json"); | ||||
|  | ||||
|             await CommandHandlers.HandleExcititorExportAsync( | ||||
|                 provider, | ||||
|                 format: "openvex", | ||||
|                 delta: false, | ||||
|                 scope: null, | ||||
|                 since: null, | ||||
|                 provider: null, | ||||
|                 outputPath: outputPath, | ||||
|                 verbose: false, | ||||
|                 cancellationToken: CancellationToken.None); | ||||
|  | ||||
|             Assert.Equal(0, Environment.ExitCode); | ||||
|             Assert.Single(backend.ExportDownloads); | ||||
|             var request = backend.ExportDownloads[0]; | ||||
|             Assert.Equal("exports/20251019T101530Z/abcdef1234567890", request.ExportId); | ||||
|             Assert.Equal(Path.GetFullPath(outputPath), request.DestinationPath); | ||||
|             Assert.Equal("sha256", request.Algorithm); | ||||
|             Assert.Equal("abcdef1234567890", request.Digest); | ||||
|         } | ||||
|         finally | ||||
|         { | ||||
|             Environment.ExitCode = original; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     [Theory] | ||||
|     [InlineData(null)] | ||||
|     [InlineData("default")] | ||||
| @@ -263,7 +431,7 @@ public sealed class CommandHandlersTests | ||||
|                 "token", | ||||
|                 "Bearer", | ||||
|                 DateTimeOffset.UtcNow.AddMinutes(30), | ||||
|                 new[] { StellaOpsScopes.FeedserJobsTrigger }); | ||||
|                 new[] { StellaOpsScopes.ConcelierJobsTrigger }); | ||||
|  | ||||
|             var provider = BuildServiceProvider(new StubBackendClient(new JobTriggerResult(true, "ok", null, null)), options: options, tokenClient: tokenClient); | ||||
|  | ||||
| @@ -331,13 +499,13 @@ public sealed class CommandHandlersTests | ||||
|             tokenClient.CachedEntry = new StellaOpsTokenCacheEntry( | ||||
|                 CreateUnsignedJwt( | ||||
|                     ("sub", "cli-user"), | ||||
|                     ("aud", "feedser"), | ||||
|                     ("aud", "concelier"), | ||||
|                     ("iss", "https://authority.example"), | ||||
|                     ("iat", 1_700_000_000), | ||||
|                     ("nbf", 1_700_000_000)), | ||||
|                 "Bearer", | ||||
|                 DateTimeOffset.UtcNow.AddMinutes(30), | ||||
|                 new[] { StellaOpsScopes.FeedserJobsTrigger }); | ||||
|                 new[] { StellaOpsScopes.ConcelierJobsTrigger }); | ||||
|  | ||||
|             var provider = BuildServiceProvider(new StubBackendClient(new JobTriggerResult(true, "ok", null, null)), options: options, tokenClient: tokenClient); | ||||
|  | ||||
| @@ -375,7 +543,7 @@ public sealed class CommandHandlersTests | ||||
|                 "token", | ||||
|                 "Bearer", | ||||
|                 DateTimeOffset.UtcNow.AddMinutes(5), | ||||
|                 new[] { StellaOpsScopes.FeedserJobsTrigger }); | ||||
|                 new[] { StellaOpsScopes.ConcelierJobsTrigger }); | ||||
|  | ||||
|             var provider = BuildServiceProvider(new StubBackendClient(new JobTriggerResult(true, "ok", null, null)), options: options, tokenClient: tokenClient); | ||||
|  | ||||
| @@ -390,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); | ||||
| @@ -501,44 +881,87 @@ 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 _result; | ||||
|  | ||||
|         public StubBackendClient(JobTriggerResult result) | ||||
|         { | ||||
|             _result = result; | ||||
|         } | ||||
|  | ||||
|         public string? LastJobKind { get; private set; } | ||||
|         public string? LastUploadPath { get; private set; } | ||||
|  | ||||
|         public Task<ScannerArtifactResult> DownloadScannerAsync(string channel, string outputPath, bool overwrite, bool verbose, CancellationToken cancellationToken) | ||||
|             => throw new NotImplementedException(); | ||||
|  | ||||
|         public Task UploadScanResultsAsync(string filePath, CancellationToken cancellationToken) | ||||
|         { | ||||
|             LastUploadPath = filePath; | ||||
|             return Task.CompletedTask; | ||||
|         } | ||||
|  | ||||
|         public Task<JobTriggerResult> TriggerJobAsync(string jobKind, IDictionary<string, object?> parameters, CancellationToken cancellationToken) | ||||
|         { | ||||
|             LastJobKind = jobKind; | ||||
|             return Task.FromResult(_result); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private sealed class StubExecutor : IScannerExecutor | ||||
|     { | ||||
|         private readonly ScannerExecutionResult _result; | ||||
|  | ||||
|         public StubExecutor(ScannerExecutionResult result) | ||||
|         { | ||||
|             _result = result; | ||||
|         } | ||||
|  | ||||
|  | ||||
|     private sealed class StubBackendClient : IBackendOperationsClient | ||||
|     { | ||||
|         private readonly JobTriggerResult _jobResult; | ||||
|         private static readonly RuntimePolicyEvaluationResult DefaultRuntimePolicyResult = | ||||
|             new RuntimePolicyEvaluationResult( | ||||
|                 0, | ||||
|                 null, | ||||
|                 null, | ||||
|                 new ReadOnlyDictionary<string, RuntimePolicyImageDecision>( | ||||
|                     new Dictionary<string, RuntimePolicyImageDecision>())); | ||||
|  | ||||
|         public StubBackendClient(JobTriggerResult result) | ||||
|         { | ||||
|             _jobResult = result; | ||||
|         } | ||||
|  | ||||
|         public string? LastJobKind { get; private set; } | ||||
|         public string? LastUploadPath { get; private set; } | ||||
|         public string? LastExcititorRoute { get; private set; } | ||||
|         public HttpMethod? LastExcititorMethod { get; private set; } | ||||
|         public object? LastExcititorPayload { get; private set; } | ||||
|         public 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(); | ||||
|  | ||||
|         public Task UploadScanResultsAsync(string filePath, CancellationToken cancellationToken) | ||||
|         { | ||||
|             LastUploadPath = filePath; | ||||
|             return Task.CompletedTask; | ||||
|         } | ||||
|  | ||||
|         public Task<JobTriggerResult> TriggerJobAsync(string jobKind, IDictionary<string, object?> parameters, CancellationToken cancellationToken) | ||||
|         { | ||||
|             LastJobKind = jobKind; | ||||
|             return Task.FromResult(_jobResult); | ||||
|         } | ||||
|  | ||||
|         public Task<ExcititorOperationResult> ExecuteExcititorOperationAsync(string route, HttpMethod method, object? payload, CancellationToken cancellationToken) | ||||
|         { | ||||
|             LastExcititorRoute = route; | ||||
|             LastExcititorMethod = method; | ||||
|             LastExcititorPayload = payload; | ||||
|             return Task.FromResult(ExcititorResult ?? new ExcititorOperationResult(true, "ok", null, null)); | ||||
|         } | ||||
|  | ||||
|         public Task<ExcititorExportDownloadResult> DownloadExcititorExportAsync(string exportId, string destinationPath, string? expectedDigestAlgorithm, string? expectedDigest, CancellationToken cancellationToken) | ||||
|         { | ||||
|             var fullPath = Path.GetFullPath(destinationPath); | ||||
|             var directory = Path.GetDirectoryName(fullPath); | ||||
|             if (!string.IsNullOrEmpty(directory)) | ||||
|             { | ||||
|                 Directory.CreateDirectory(directory); | ||||
|             } | ||||
|  | ||||
|             File.WriteAllText(fullPath, "{}"); | ||||
|             var info = new FileInfo(fullPath); | ||||
|             ExportDownloads.Add((exportId, fullPath, expectedDigestAlgorithm, expectedDigest)); | ||||
|             return Task.FromResult(new ExcititorExportDownloadResult(fullPath, info.Length, false)); | ||||
|         } | ||||
|  | ||||
|         public Task<IReadOnlyList<ExcititorProviderSummary>> GetExcititorProvidersAsync(bool includeDisabled, CancellationToken cancellationToken) | ||||
|             => Task.FromResult(ProviderSummaries); | ||||
|  | ||||
|         public Task<RuntimePolicyEvaluationResult> EvaluateRuntimePolicyAsync(RuntimePolicyEvaluationRequest request, CancellationToken cancellationToken) | ||||
|             => Task.FromResult(RuntimePolicyResult); | ||||
|     } | ||||
|  | ||||
|     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)!); | ||||
| @@ -555,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) | ||||
| @@ -573,7 +996,7 @@ public sealed class CommandHandlersTests | ||||
|                 "token-123", | ||||
|                 "Bearer", | ||||
|                 DateTimeOffset.UtcNow.AddMinutes(30), | ||||
|                 new[] { StellaOpsScopes.FeedserJobsTrigger }); | ||||
|                 new[] { StellaOpsScopes.ConcelierJobsTrigger }); | ||||
|         } | ||||
|  | ||||
|         public int ClientCredentialRequests { get; private set; } | ||||
|   | ||||
| @@ -24,7 +24,7 @@ public sealed class CliBootstrapperTests : IDisposable | ||||
|         Environment.SetEnvironmentVariable("STELLAOPS_BACKEND_URL", "https://env-backend.example"); | ||||
|         Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_URL", "https://authority.env"); | ||||
|         Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_CLIENT_ID", "cli-env"); | ||||
|         Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_SCOPE", "feedser.jobs.trigger"); | ||||
|         Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_SCOPE", "concelier.jobs.trigger"); | ||||
|         Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_ENABLE_RETRIES", "false"); | ||||
|         Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_RETRY_DELAYS", "00:00:02,00:00:05"); | ||||
|         Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_ALLOW_OFFLINE_CACHE_FALLBACK", "false"); | ||||
| @@ -38,7 +38,7 @@ public sealed class CliBootstrapperTests : IDisposable | ||||
|             Assert.Equal("https://env-backend.example", options.BackendUrl); | ||||
|             Assert.Equal("https://authority.env", options.Authority.Url); | ||||
|             Assert.Equal("cli-env", options.Authority.ClientId); | ||||
|             Assert.Equal("feedser.jobs.trigger", options.Authority.Scope); | ||||
|             Assert.Equal("concelier.jobs.trigger", options.Authority.Scope); | ||||
|  | ||||
|             Assert.NotNull(options.Authority.Resilience); | ||||
|             Assert.False(options.Authority.Resilience.EnableRetries); | ||||
| @@ -73,7 +73,7 @@ public sealed class CliBootstrapperTests : IDisposable | ||||
|                 { | ||||
|                     Url = "https://authority.file", | ||||
|                     ClientId = "cli-file", | ||||
|                     Scope = "feedser.jobs.trigger" | ||||
|                     Scope = "concelier.jobs.trigger" | ||||
|                 } | ||||
|             } | ||||
|         }); | ||||
|   | ||||
| @@ -1,4 +1,6 @@ | ||||
| using System; | ||||
| using System.Collections.ObjectModel; | ||||
| using System.Globalization; | ||||
| using System.IO; | ||||
| using System.Net; | ||||
| using System.Net.Http; | ||||
| @@ -8,10 +10,10 @@ 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 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; | ||||
| @@ -46,12 +48,12 @@ public sealed class BackendOperationsClientTests | ||||
|  | ||||
|         var httpClient = new HttpClient(handler) | ||||
|         { | ||||
|             BaseAddress = new Uri("https://feedser.example") | ||||
|             BaseAddress = new Uri("https://concelier.example") | ||||
|         }; | ||||
|  | ||||
|         var options = new StellaOpsCliOptions | ||||
|         { | ||||
|             BackendUrl = "https://feedser.example", | ||||
|             BackendUrl = "https://concelier.example", | ||||
|             ScannerCacheDirectory = temp.Path, | ||||
|             ScannerDownloadAttempts = 1 | ||||
|         }; | ||||
| @@ -92,12 +94,12 @@ public sealed class BackendOperationsClientTests | ||||
|  | ||||
|         var httpClient = new HttpClient(handler) | ||||
|         { | ||||
|             BaseAddress = new Uri("https://feedser.example") | ||||
|             BaseAddress = new Uri("https://concelier.example") | ||||
|         }; | ||||
|  | ||||
|         var options = new StellaOpsCliOptions | ||||
|         { | ||||
|             BackendUrl = "https://feedser.example", | ||||
|             BackendUrl = "https://concelier.example", | ||||
|             ScannerCacheDirectory = temp.Path, | ||||
|             ScannerDownloadAttempts = 1 | ||||
|         }; | ||||
| @@ -111,11 +113,11 @@ public sealed class BackendOperationsClientTests | ||||
|         Assert.False(File.Exists(targetPath)); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task DownloadScannerAsync_RetriesOnFailure() | ||||
|     { | ||||
|         using var temp = new TempDirectory(); | ||||
|  | ||||
|     [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; | ||||
| @@ -144,12 +146,12 @@ public sealed class BackendOperationsClientTests | ||||
|  | ||||
|         var httpClient = new HttpClient(handler) | ||||
|         { | ||||
|             BaseAddress = new Uri("https://feedser.example") | ||||
|             BaseAddress = new Uri("https://concelier.example") | ||||
|         }; | ||||
|  | ||||
|         var options = new StellaOpsCliOptions | ||||
|         { | ||||
|             BackendUrl = "https://feedser.example", | ||||
|             BackendUrl = "https://concelier.example", | ||||
|             ScannerCacheDirectory = temp.Path, | ||||
|             ScannerDownloadAttempts = 3 | ||||
|         }; | ||||
| @@ -161,94 +163,94 @@ public sealed class BackendOperationsClientTests | ||||
|         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)); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task UploadScanResultsAsync_RetriesOnRetryAfter() | ||||
|     { | ||||
|         using var temp = new TempDirectory(); | ||||
|         var filePath = Path.Combine(temp.Path, "scan.json"); | ||||
|         await File.WriteAllTextAsync(filePath, "{}"); | ||||
|  | ||||
|         var attempts = 0; | ||||
|         var handler = new StubHttpMessageHandler( | ||||
|             (request, _) => | ||||
|             { | ||||
|                 attempts++; | ||||
|                 var response = new HttpResponseMessage(HttpStatusCode.TooManyRequests) | ||||
|                 { | ||||
|                     RequestMessage = request, | ||||
|                     Content = new StringContent("busy") | ||||
|                 }; | ||||
|                 response.Headers.Add("Retry-After", "1"); | ||||
|                 return response; | ||||
|             }, | ||||
|             (request, _) => | ||||
|             { | ||||
|                 attempts++; | ||||
|                 return new HttpResponseMessage(HttpStatusCode.OK) | ||||
|                 { | ||||
|                     RequestMessage = request | ||||
|                 }; | ||||
|             }); | ||||
|  | ||||
|         var httpClient = new HttpClient(handler) | ||||
|         { | ||||
|             BaseAddress = new Uri("https://feedser.example") | ||||
|         }; | ||||
|  | ||||
|         var options = new StellaOpsCliOptions | ||||
|         { | ||||
|             BackendUrl = "https://feedser.example", | ||||
|             ScanUploadAttempts = 3 | ||||
|         }; | ||||
|  | ||||
|         var loggerFactory = LoggerFactory.Create(builder => builder.SetMinimumLevel(LogLevel.Debug)); | ||||
|         var client = new BackendOperationsClient(httpClient, options, loggerFactory.CreateLogger<BackendOperationsClient>()); | ||||
|  | ||||
|         await client.UploadScanResultsAsync(filePath, CancellationToken.None); | ||||
|  | ||||
|         Assert.Equal(2, attempts); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task UploadScanResultsAsync_ThrowsAfterMaxAttempts() | ||||
|     { | ||||
|         using var temp = new TempDirectory(); | ||||
|         var filePath = Path.Combine(temp.Path, "scan.json"); | ||||
|         await File.WriteAllTextAsync(filePath, "{}"); | ||||
|  | ||||
|         var attempts = 0; | ||||
|         var handler = new StubHttpMessageHandler( | ||||
|             (request, _) => | ||||
|             { | ||||
|                 attempts++; | ||||
|                 return new HttpResponseMessage(HttpStatusCode.BadGateway) | ||||
|                 { | ||||
|                     RequestMessage = request, | ||||
|                     Content = new StringContent("bad gateway") | ||||
|                 }; | ||||
|             }); | ||||
|  | ||||
|         var httpClient = new HttpClient(handler) | ||||
|         { | ||||
|             BaseAddress = new Uri("https://feedser.example") | ||||
|         }; | ||||
|  | ||||
|         var options = new StellaOpsCliOptions | ||||
|         { | ||||
|             BackendUrl = "https://feedser.example", | ||||
|             ScanUploadAttempts = 2 | ||||
|         }; | ||||
|  | ||||
|         var loggerFactory = LoggerFactory.Create(builder => builder.SetMinimumLevel(LogLevel.Debug)); | ||||
|         var client = new BackendOperationsClient(httpClient, options, loggerFactory.CreateLogger<BackendOperationsClient>()); | ||||
|  | ||||
|         await Assert.ThrowsAsync<InvalidOperationException>(() => client.UploadScanResultsAsync(filePath, CancellationToken.None)); | ||||
|         Assert.Equal(2, attempts); | ||||
|     } | ||||
|         Assert.False(result.FromCache); | ||||
|         Assert.True(File.Exists(targetPath)); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task UploadScanResultsAsync_RetriesOnRetryAfter() | ||||
|     { | ||||
|         using var temp = new TempDirectory(); | ||||
|         var filePath = Path.Combine(temp.Path, "scan.json"); | ||||
|         await File.WriteAllTextAsync(filePath, "{}"); | ||||
|  | ||||
|         var attempts = 0; | ||||
|         var handler = new StubHttpMessageHandler( | ||||
|             (request, _) => | ||||
|             { | ||||
|                 attempts++; | ||||
|                 var response = new HttpResponseMessage(HttpStatusCode.TooManyRequests) | ||||
|                 { | ||||
|                     RequestMessage = request, | ||||
|                     Content = new StringContent("busy") | ||||
|                 }; | ||||
|                 response.Headers.Add("Retry-After", "1"); | ||||
|                 return response; | ||||
|             }, | ||||
|             (request, _) => | ||||
|             { | ||||
|                 attempts++; | ||||
|                 return new HttpResponseMessage(HttpStatusCode.OK) | ||||
|                 { | ||||
|                     RequestMessage = request | ||||
|                 }; | ||||
|             }); | ||||
|  | ||||
|         var httpClient = new HttpClient(handler) | ||||
|         { | ||||
|             BaseAddress = new Uri("https://concelier.example") | ||||
|         }; | ||||
|  | ||||
|         var options = new StellaOpsCliOptions | ||||
|         { | ||||
|             BackendUrl = "https://concelier.example", | ||||
|             ScanUploadAttempts = 3 | ||||
|         }; | ||||
|  | ||||
|         var loggerFactory = LoggerFactory.Create(builder => builder.SetMinimumLevel(LogLevel.Debug)); | ||||
|         var client = new BackendOperationsClient(httpClient, options, loggerFactory.CreateLogger<BackendOperationsClient>()); | ||||
|  | ||||
|         await client.UploadScanResultsAsync(filePath, CancellationToken.None); | ||||
|  | ||||
|         Assert.Equal(2, attempts); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task UploadScanResultsAsync_ThrowsAfterMaxAttempts() | ||||
|     { | ||||
|         using var temp = new TempDirectory(); | ||||
|         var filePath = Path.Combine(temp.Path, "scan.json"); | ||||
|         await File.WriteAllTextAsync(filePath, "{}"); | ||||
|  | ||||
|         var attempts = 0; | ||||
|         var handler = new StubHttpMessageHandler( | ||||
|             (request, _) => | ||||
|             { | ||||
|                 attempts++; | ||||
|                 return new HttpResponseMessage(HttpStatusCode.BadGateway) | ||||
|                 { | ||||
|                     RequestMessage = request, | ||||
|                     Content = new StringContent("bad gateway") | ||||
|                 }; | ||||
|             }); | ||||
|  | ||||
|         var httpClient = new HttpClient(handler) | ||||
|         { | ||||
|             BaseAddress = new Uri("https://concelier.example") | ||||
|         }; | ||||
|  | ||||
|         var options = new StellaOpsCliOptions | ||||
|         { | ||||
|             BackendUrl = "https://concelier.example", | ||||
|             ScanUploadAttempts = 2 | ||||
|         }; | ||||
|  | ||||
|         var loggerFactory = LoggerFactory.Create(builder => builder.SetMinimumLevel(LogLevel.Debug)); | ||||
|         var client = new BackendOperationsClient(httpClient, options, loggerFactory.CreateLogger<BackendOperationsClient>()); | ||||
|  | ||||
|         await Assert.ThrowsAsync<InvalidOperationException>(() => client.UploadScanResultsAsync(filePath, CancellationToken.None)); | ||||
|         Assert.Equal(2, attempts); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task TriggerJobAsync_ReturnsAcceptedResult() | ||||
| @@ -273,10 +275,10 @@ public sealed class BackendOperationsClientTests | ||||
|  | ||||
|         var httpClient = new HttpClient(handler) | ||||
|         { | ||||
|             BaseAddress = new Uri("https://feedser.example") | ||||
|             BaseAddress = new Uri("https://concelier.example") | ||||
|         }; | ||||
|  | ||||
|         var options = new StellaOpsCliOptions { BackendUrl = "https://feedser.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>()); | ||||
|  | ||||
| @@ -288,11 +290,11 @@ public sealed class BackendOperationsClientTests | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task TriggerJobAsync_ReturnsFailureMessage() | ||||
|     { | ||||
|         var handler = new StubHttpMessageHandler((request, _) => | ||||
|         { | ||||
|             var problem = new | ||||
|     public async Task TriggerJobAsync_ReturnsFailureMessage() | ||||
|     { | ||||
|         var handler = new StubHttpMessageHandler((request, _) => | ||||
|         { | ||||
|             var problem = new | ||||
|             { | ||||
|                 title = "Job already running", | ||||
|                 detail = "export job active" | ||||
| @@ -308,110 +310,214 @@ public sealed class BackendOperationsClientTests | ||||
|  | ||||
|         var httpClient = new HttpClient(handler) | ||||
|         { | ||||
|             BaseAddress = new Uri("https://feedser.example") | ||||
|             BaseAddress = new Uri("https://concelier.example") | ||||
|         }; | ||||
|  | ||||
|         var options = new StellaOpsCliOptions { BackendUrl = "https://feedser.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); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task TriggerJobAsync_UsesAuthorityTokenWhenConfigured() | ||||
|     { | ||||
|         using var temp = new TempDirectory(); | ||||
|  | ||||
|         var handler = new StubHttpMessageHandler((request, _) => | ||||
|         { | ||||
|             Assert.NotNull(request.Headers.Authorization); | ||||
|             Assert.Equal("Bearer", request.Headers.Authorization!.Scheme); | ||||
|             Assert.Equal("token-123", request.Headers.Authorization.Parameter); | ||||
|  | ||||
|             return new HttpResponseMessage(HttpStatusCode.Accepted) | ||||
|             { | ||||
|                 RequestMessage = request, | ||||
|                 Content = JsonContent.Create(new JobRunResponse | ||||
|                 { | ||||
|                     RunId = Guid.NewGuid(), | ||||
|                     Kind = "test", | ||||
|                     Status = "Pending", | ||||
|                     Trigger = "cli", | ||||
|                     CreatedAt = DateTimeOffset.UtcNow | ||||
|                 }) | ||||
|             }; | ||||
|         }); | ||||
|  | ||||
|         var httpClient = new HttpClient(handler) | ||||
|         { | ||||
|             BaseAddress = new Uri("https://feedser.example") | ||||
|         }; | ||||
|  | ||||
|         var options = new StellaOpsCliOptions | ||||
|         { | ||||
|             BackendUrl = "https://feedser.example", | ||||
|             Authority = | ||||
|             { | ||||
|                 Url = "https://authority.example", | ||||
|                 ClientId = "cli", | ||||
|                 ClientSecret = "secret", | ||||
|                 Scope = "feedser.jobs.trigger", | ||||
|                 TokenCacheDirectory = temp.Path | ||||
|             } | ||||
|         }; | ||||
|  | ||||
|         var tokenClient = new StubTokenClient(); | ||||
|         var loggerFactory = LoggerFactory.Create(builder => builder.SetMinimumLevel(LogLevel.Debug)); | ||||
|         var client = new BackendOperationsClient(httpClient, options, loggerFactory.CreateLogger<BackendOperationsClient>(), tokenClient); | ||||
|  | ||||
|         var result = await client.TriggerJobAsync("test", new Dictionary<string, object?>(), CancellationToken.None); | ||||
|  | ||||
|         Assert.True(result.Success); | ||||
|         Assert.Equal("Accepted", result.Message); | ||||
|         Assert.True(tokenClient.Requests > 0); | ||||
|     } | ||||
|  | ||||
|     private sealed class StubTokenClient : IStellaOpsTokenClient | ||||
|     { | ||||
|         private readonly StellaOpsTokenResult _tokenResult; | ||||
|  | ||||
|         public int Requests { get; private set; } | ||||
|  | ||||
|         public StubTokenClient() | ||||
|         { | ||||
|             _tokenResult = new StellaOpsTokenResult( | ||||
|                 "token-123", | ||||
|                 "Bearer", | ||||
|                 DateTimeOffset.UtcNow.AddMinutes(5), | ||||
|                 new[] { StellaOpsScopes.FeedserJobsTrigger }); | ||||
|         } | ||||
|  | ||||
|         public ValueTask CacheTokenAsync(string key, StellaOpsTokenCacheEntry entry, CancellationToken cancellationToken = default) | ||||
|             => ValueTask.CompletedTask; | ||||
|  | ||||
|         public ValueTask ClearCachedTokenAsync(string key, CancellationToken cancellationToken = default) | ||||
|             => ValueTask.CompletedTask; | ||||
|  | ||||
|         public Task<JsonWebKeySet> GetJsonWebKeySetAsync(CancellationToken cancellationToken = default) | ||||
|             => Task.FromResult(new JsonWebKeySet("{\"keys\":[]}")); | ||||
|  | ||||
|         public ValueTask<StellaOpsTokenCacheEntry?> GetCachedTokenAsync(string key, CancellationToken cancellationToken = default) | ||||
|             => ValueTask.FromResult<StellaOpsTokenCacheEntry?>(null); | ||||
|  | ||||
|         public Task<StellaOpsTokenResult> RequestClientCredentialsTokenAsync(string? scope = null, CancellationToken cancellationToken = default) | ||||
|         { | ||||
|             Requests++; | ||||
|             return Task.FromResult(_tokenResult); | ||||
|         } | ||||
|  | ||||
|         public Task<StellaOpsTokenResult> RequestPasswordTokenAsync(string username, string password, string? scope = null, CancellationToken cancellationToken = default) | ||||
|         { | ||||
|             Requests++; | ||||
|             return Task.FromResult(_tokenResult); | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
|         Assert.False(result.Success); | ||||
|         Assert.Contains("Job already running", result.Message); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task TriggerJobAsync_UsesAuthorityTokenWhenConfigured() | ||||
|     { | ||||
|         using var temp = new TempDirectory(); | ||||
|  | ||||
|         var handler = new StubHttpMessageHandler((request, _) => | ||||
|         { | ||||
|             Assert.NotNull(request.Headers.Authorization); | ||||
|             Assert.Equal("Bearer", request.Headers.Authorization!.Scheme); | ||||
|             Assert.Equal("token-123", request.Headers.Authorization.Parameter); | ||||
|  | ||||
|             return new HttpResponseMessage(HttpStatusCode.Accepted) | ||||
|             { | ||||
|                 RequestMessage = request, | ||||
|                 Content = JsonContent.Create(new JobRunResponse | ||||
|                 { | ||||
|                     RunId = Guid.NewGuid(), | ||||
|                     Kind = "test", | ||||
|                     Status = "Pending", | ||||
|                     Trigger = "cli", | ||||
|                     CreatedAt = DateTimeOffset.UtcNow | ||||
|                 }) | ||||
|             }; | ||||
|         }); | ||||
|  | ||||
|         var httpClient = new HttpClient(handler) | ||||
|         { | ||||
|             BaseAddress = new Uri("https://concelier.example") | ||||
|         }; | ||||
|  | ||||
|         var options = new StellaOpsCliOptions | ||||
|         { | ||||
|             BackendUrl = "https://concelier.example", | ||||
|             Authority = | ||||
|             { | ||||
|                 Url = "https://authority.example", | ||||
|                 ClientId = "cli", | ||||
|                 ClientSecret = "secret", | ||||
|                 Scope = "concelier.jobs.trigger", | ||||
|                 TokenCacheDirectory = temp.Path | ||||
|             } | ||||
|         }; | ||||
|  | ||||
|         var tokenClient = new StubTokenClient(); | ||||
|         var loggerFactory = LoggerFactory.Create(builder => builder.SetMinimumLevel(LogLevel.Debug)); | ||||
|         var client = new BackendOperationsClient(httpClient, options, loggerFactory.CreateLogger<BackendOperationsClient>(), tokenClient); | ||||
|  | ||||
|         var result = await client.TriggerJobAsync("test", new Dictionary<string, object?>(), CancellationToken.None); | ||||
|  | ||||
|         Assert.True(result.Success); | ||||
|         Assert.Equal("Accepted", result.Message); | ||||
|         Assert.True(tokenClient.Requests > 0); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task EvaluateRuntimePolicyAsync_ParsesDecisionPayload() | ||||
|     { | ||||
|         var handler = new StubHttpMessageHandler((request, _) => | ||||
|         { | ||||
|             Assert.Equal(HttpMethod.Post, request.Method); | ||||
|             Assert.Equal("/api/scanner/policy/runtime", request.RequestUri!.AbsolutePath); | ||||
|  | ||||
|             var body = request.Content!.ReadAsStringAsync().GetAwaiter().GetResult(); | ||||
|             using var document = JsonDocument.Parse(body); | ||||
|             var root = document.RootElement; | ||||
|             Assert.Equal("prod", root.GetProperty("namespace").GetString()); | ||||
|             Assert.Equal("payments", root.GetProperty("labels").GetProperty("app").GetString()); | ||||
|             var images = root.GetProperty("images"); | ||||
|             Assert.Equal(2, images.GetArrayLength()); | ||||
|             Assert.Equal("ghcr.io/app@sha256:abc", images[0].GetString()); | ||||
|             Assert.Equal("ghcr.io/api@sha256:def", images[1].GetString()); | ||||
|  | ||||
|             var responseJson = @"{ | ||||
|   ""ttlSeconds"": 120, | ||||
|   ""policyRevision"": ""rev-123"", | ||||
|   ""expiresAtUtc"": ""2025-10-19T12:34:56Z"", | ||||
|   ""results"": { | ||||
|     ""ghcr.io/app@sha256:abc"": { | ||||
|       ""policyVerdict"": ""pass"", | ||||
|       ""signed"": true, | ||||
|       ""hasSbomReferrers"": true, | ||||
|       ""reasons"": [], | ||||
|       ""rekor"": { ""uuid"": ""uuid-1"", ""url"": ""https://rekor.example/uuid-1"", ""verified"": true }, | ||||
|       ""confidence"": 0.87, | ||||
|       ""quieted"": false, | ||||
|       ""metadata"": { ""note"": ""cached"" } | ||||
|     }, | ||||
|     ""ghcr.io/api@sha256:def"": { | ||||
|       ""policyVerdict"": ""fail"", | ||||
|       ""signed"": false, | ||||
|       ""hasSbomReferrers"": false, | ||||
|       ""reasons"": [""unsigned"", ""missing sbom""], | ||||
|       ""quietedBy"": ""manual-override"" | ||||
|     } | ||||
|   } | ||||
| }"; | ||||
|  | ||||
|             return new HttpResponseMessage(HttpStatusCode.OK) | ||||
|             { | ||||
|                 Content = new StringContent(responseJson, Encoding.UTF8, "application/json"), | ||||
|                 RequestMessage = request | ||||
|             }; | ||||
|         }); | ||||
|  | ||||
|         var httpClient = new HttpClient(handler) | ||||
|         { | ||||
|             BaseAddress = new Uri("https://scanner.example/") | ||||
|         }; | ||||
|  | ||||
|         var options = new StellaOpsCliOptions | ||||
|         { | ||||
|             BackendUrl = "https://scanner.example/" | ||||
|         }; | ||||
|  | ||||
|         var loggerFactory = LoggerFactory.Create(builder => builder.SetMinimumLevel(LogLevel.Debug)); | ||||
|         var client = new BackendOperationsClient(httpClient, options, loggerFactory.CreateLogger<BackendOperationsClient>()); | ||||
|  | ||||
|         var labels = new ReadOnlyDictionary<string, string>(new Dictionary<string, string> { ["app"] = "payments" }); | ||||
|         var imagesList = new ReadOnlyCollection<string>(new List<string> | ||||
|         { | ||||
|             "ghcr.io/app@sha256:abc", | ||||
|             "ghcr.io/app@sha256:abc", | ||||
|             "ghcr.io/api@sha256:def" | ||||
|         }); | ||||
|         var requestModel = new RuntimePolicyEvaluationRequest("prod", labels, imagesList); | ||||
|  | ||||
|         var result = await client.EvaluateRuntimePolicyAsync(requestModel, CancellationToken.None); | ||||
|  | ||||
|         Assert.Equal(120, result.TtlSeconds); | ||||
|         Assert.Equal("rev-123", result.PolicyRevision); | ||||
|         Assert.Equal(DateTimeOffset.Parse("2025-10-19T12:34:56Z", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal), result.ExpiresAtUtc); | ||||
|         Assert.Equal(2, result.Decisions.Count); | ||||
|  | ||||
|         var primary = result.Decisions["ghcr.io/app@sha256:abc"]; | ||||
|         Assert.Equal("pass", primary.PolicyVerdict); | ||||
|         Assert.True(primary.Signed); | ||||
|         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["quieted"])); | ||||
|         var metadataJson = Assert.IsType<string>(primary.AdditionalProperties["metadata"]); | ||||
|         using var metadataDocument = JsonDocument.Parse(metadataJson); | ||||
|         Assert.Equal("cached", metadataDocument.RootElement.GetProperty("note").GetString()); | ||||
|  | ||||
|         var secondary = result.Decisions["ghcr.io/api@sha256:def"]; | ||||
|         Assert.Equal("fail", secondary.PolicyVerdict); | ||||
|         Assert.False(secondary.Signed); | ||||
|         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 | ||||
|     { | ||||
|         private readonly StellaOpsTokenResult _tokenResult; | ||||
|  | ||||
|         public int Requests { get; private set; } | ||||
|  | ||||
|         public StubTokenClient() | ||||
|         { | ||||
|             _tokenResult = new StellaOpsTokenResult( | ||||
|                 "token-123", | ||||
|                 "Bearer", | ||||
|                 DateTimeOffset.UtcNow.AddMinutes(5), | ||||
|                 new[] { StellaOpsScopes.ConcelierJobsTrigger }); | ||||
|         } | ||||
|  | ||||
|         public ValueTask CacheTokenAsync(string key, StellaOpsTokenCacheEntry entry, CancellationToken cancellationToken = default) | ||||
|             => ValueTask.CompletedTask; | ||||
|  | ||||
|         public ValueTask ClearCachedTokenAsync(string key, CancellationToken cancellationToken = default) | ||||
|             => ValueTask.CompletedTask; | ||||
|  | ||||
|         public Task<JsonWebKeySet> GetJsonWebKeySetAsync(CancellationToken cancellationToken = default) | ||||
|             => Task.FromResult(new JsonWebKeySet("{\"keys\":[]}")); | ||||
|  | ||||
|         public ValueTask<StellaOpsTokenCacheEntry?> GetCachedTokenAsync(string key, CancellationToken cancellationToken = default) | ||||
|             => ValueTask.FromResult<StellaOpsTokenCacheEntry?>(null); | ||||
|  | ||||
|         public Task<StellaOpsTokenResult> RequestClientCredentialsTokenAsync(string? scope = null, CancellationToken cancellationToken = default) | ||||
|         { | ||||
|             Requests++; | ||||
|             return Task.FromResult(_tokenResult); | ||||
|         } | ||||
|  | ||||
|         public Task<StellaOpsTokenResult> RequestPasswordTokenAsync(string username, string password, string? scope = null, CancellationToken cancellationToken = default) | ||||
|         { | ||||
|             Requests++; | ||||
|             return Task.FromResult(_tokenResult); | ||||
|         } | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -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> | ||||
|   | ||||
| @@ -1,14 +1,15 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.IO; | ||||
| using System.Net.Http; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using System.Collections.Generic; | ||||
| using System.IO; | ||||
| using System.Net.Http; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using System.Text; | ||||
|  | ||||
| namespace StellaOps.Cli.Tests.Testing; | ||||
|  | ||||
| internal sealed class TempDirectory : IDisposable | ||||
| { | ||||
| internal sealed class TempDirectory : IDisposable | ||||
| { | ||||
|     public TempDirectory() | ||||
|     { | ||||
|         Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"stellaops-cli-tests-{Guid.NewGuid():N}"); | ||||
| @@ -31,7 +32,41 @@ internal sealed class TempDirectory : IDisposable | ||||
|             // ignored | ||||
|         } | ||||
|     } | ||||
| } | ||||
| } | ||||
|  | ||||
| internal sealed class TempFile : IDisposable | ||||
| { | ||||
|     public TempFile(string fileName, byte[] contents) | ||||
|     { | ||||
|         var directory = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"stellaops-cli-file-{Guid.NewGuid():N}"); | ||||
|         Directory.CreateDirectory(directory); | ||||
|         Path = System.IO.Path.Combine(directory, fileName); | ||||
|         File.WriteAllBytes(Path, contents); | ||||
|     } | ||||
|  | ||||
|     public string Path { get; } | ||||
|  | ||||
|     public void Dispose() | ||||
|     { | ||||
|         try | ||||
|         { | ||||
|             if (File.Exists(Path)) | ||||
|             { | ||||
|                 File.Delete(Path); | ||||
|             } | ||||
|  | ||||
|             var directory = System.IO.Path.GetDirectoryName(Path); | ||||
|             if (!string.IsNullOrEmpty(directory) && Directory.Exists(directory)) | ||||
|             { | ||||
|                 Directory.Delete(directory, recursive: true); | ||||
|             } | ||||
|         } | ||||
|         catch | ||||
|         { | ||||
|             // ignored intentionally | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| internal sealed class StubHttpMessageHandler : HttpMessageHandler | ||||
| { | ||||
|   | ||||
		Reference in New Issue
	
	Block a user