Resolve Concelier/Excititor merge conflicts

This commit is contained in:
root
2025-10-20 14:19:25 +03:00
2687 changed files with 212646 additions and 85913 deletions

View File

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

View File

@@ -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"
}
}
});

View File

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

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>

View File

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