Add channel test providers for Email, Slack, Teams, and Webhook
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:
2025-10-19 23:29:34 +03:00
parent 8e7ce55542
commit 5fd4032c7c
239 changed files with 17245 additions and 3155 deletions

View File

@@ -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)

View File

@@ -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

View File

@@ -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>