Initial commit (history squashed)
This commit is contained in:
533
src/StellaOps.Cli.Tests/Commands/CommandHandlersTests.cs
Normal file
533
src/StellaOps.Cli.Tests/Commands/CommandHandlersTests.cs
Normal file
@@ -0,0 +1,533 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.Client;
|
||||
using StellaOps.Cli.Commands;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using StellaOps.Cli.Services;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
using StellaOps.Cli.Telemetry;
|
||||
using StellaOps.Cli.Tests.Testing;
|
||||
|
||||
namespace StellaOps.Cli.Tests.Commands;
|
||||
|
||||
public sealed class CommandHandlersTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task HandleExportJobAsync_SetsExitCodeZeroOnSuccess()
|
||||
{
|
||||
var original = Environment.ExitCode;
|
||||
try
|
||||
{
|
||||
var backend = new StubBackendClient(new JobTriggerResult(true, "Accepted", "/jobs/export:json/1", null));
|
||||
var provider = BuildServiceProvider(backend);
|
||||
|
||||
await CommandHandlers.HandleExportJobAsync(
|
||||
provider,
|
||||
format: "json",
|
||||
delta: false,
|
||||
publishFull: null,
|
||||
publishDelta: null,
|
||||
includeFull: null,
|
||||
includeDelta: null,
|
||||
verbose: false,
|
||||
cancellationToken: CancellationToken.None);
|
||||
|
||||
Assert.Equal(0, Environment.ExitCode);
|
||||
Assert.Equal("export:json", backend.LastJobKind);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.ExitCode = original;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleMergeJobAsync_SetsExitCodeOnFailure()
|
||||
{
|
||||
var original = Environment.ExitCode;
|
||||
try
|
||||
{
|
||||
var backend = new StubBackendClient(new JobTriggerResult(false, "Job already running", null, null));
|
||||
var provider = BuildServiceProvider(backend);
|
||||
|
||||
await CommandHandlers.HandleMergeJobAsync(provider, verbose: false, CancellationToken.None);
|
||||
|
||||
Assert.Equal(1, Environment.ExitCode);
|
||||
Assert.Equal("merge:reconcile", backend.LastJobKind);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.ExitCode = original;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleScannerRunAsync_AutomaticallyUploadsResults()
|
||||
{
|
||||
using var tempDir = new TempDirectory();
|
||||
var resultsFile = Path.Combine(tempDir.Path, "results", "scan.json");
|
||||
var backend = new StubBackendClient(new JobTriggerResult(true, "Accepted", null, null));
|
||||
var metadataFile = Path.Combine(tempDir.Path, "results", "scan-run.json");
|
||||
var executor = new StubExecutor(new ScannerExecutionResult(0, resultsFile, metadataFile));
|
||||
var options = new StellaOpsCliOptions
|
||||
{
|
||||
ResultsDirectory = Path.Combine(tempDir.Path, "results")
|
||||
};
|
||||
|
||||
var provider = BuildServiceProvider(backend, executor, new StubInstaller(), options);
|
||||
|
||||
Directory.CreateDirectory(Path.Combine(tempDir.Path, "target"));
|
||||
|
||||
var original = Environment.ExitCode;
|
||||
try
|
||||
{
|
||||
await CommandHandlers.HandleScannerRunAsync(
|
||||
provider,
|
||||
runner: "docker",
|
||||
entry: "scanner-image",
|
||||
targetDirectory: Path.Combine(tempDir.Path, "target"),
|
||||
arguments: Array.Empty<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;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleAuthLoginAsync_UsesClientCredentialsFlow()
|
||||
{
|
||||
var original = Environment.ExitCode;
|
||||
using var tempDir = new TempDirectory();
|
||||
|
||||
try
|
||||
{
|
||||
var options = new StellaOpsCliOptions
|
||||
{
|
||||
ResultsDirectory = Path.Combine(tempDir.Path, "results"),
|
||||
Authority = new StellaOpsCliAuthorityOptions
|
||||
{
|
||||
Url = "https://authority.example",
|
||||
ClientId = "cli",
|
||||
ClientSecret = "secret",
|
||||
Scope = "feedser.jobs.trigger",
|
||||
TokenCacheDirectory = tempDir.Path
|
||||
}
|
||||
};
|
||||
|
||||
var tokenClient = new StubTokenClient();
|
||||
var provider = BuildServiceProvider(new StubBackendClient(new JobTriggerResult(true, "ok", null, null)), options: options, tokenClient: tokenClient);
|
||||
|
||||
await CommandHandlers.HandleAuthLoginAsync(provider, options, verbose: false, force: false, cancellationToken: CancellationToken.None);
|
||||
|
||||
Assert.Equal(0, Environment.ExitCode);
|
||||
Assert.Equal(1, tokenClient.ClientCredentialRequests);
|
||||
Assert.NotNull(tokenClient.CachedEntry);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.ExitCode = original;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleAuthLoginAsync_FailsWhenPasswordMissing()
|
||||
{
|
||||
var original = Environment.ExitCode;
|
||||
using var tempDir = new TempDirectory();
|
||||
|
||||
try
|
||||
{
|
||||
var options = new StellaOpsCliOptions
|
||||
{
|
||||
ResultsDirectory = Path.Combine(tempDir.Path, "results"),
|
||||
Authority = new StellaOpsCliAuthorityOptions
|
||||
{
|
||||
Url = "https://authority.example",
|
||||
ClientId = "cli",
|
||||
Username = "user",
|
||||
TokenCacheDirectory = tempDir.Path
|
||||
}
|
||||
};
|
||||
|
||||
var provider = BuildServiceProvider(new StubBackendClient(new JobTriggerResult(true, "ok", null, null)), options: options, tokenClient: new StubTokenClient());
|
||||
|
||||
await CommandHandlers.HandleAuthLoginAsync(provider, options, verbose: false, force: false, cancellationToken: CancellationToken.None);
|
||||
|
||||
Assert.Equal(1, Environment.ExitCode);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.ExitCode = original;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleAuthStatusAsync_ReportsMissingToken()
|
||||
{
|
||||
var original = Environment.ExitCode;
|
||||
using var tempDir = new TempDirectory();
|
||||
|
||||
try
|
||||
{
|
||||
var options = new StellaOpsCliOptions
|
||||
{
|
||||
ResultsDirectory = Path.Combine(tempDir.Path, "results"),
|
||||
Authority = new StellaOpsCliAuthorityOptions
|
||||
{
|
||||
Url = "https://authority.example",
|
||||
ClientId = "cli",
|
||||
TokenCacheDirectory = tempDir.Path
|
||||
}
|
||||
};
|
||||
|
||||
var provider = BuildServiceProvider(new StubBackendClient(new JobTriggerResult(true, "ok", null, null)), options: options, tokenClient: new StubTokenClient());
|
||||
|
||||
await CommandHandlers.HandleAuthStatusAsync(provider, options, verbose: false, cancellationToken: CancellationToken.None);
|
||||
|
||||
Assert.Equal(1, Environment.ExitCode);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.ExitCode = original;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleAuthStatusAsync_ReportsCachedToken()
|
||||
{
|
||||
var original = Environment.ExitCode;
|
||||
using var tempDir = new TempDirectory();
|
||||
|
||||
try
|
||||
{
|
||||
var options = new StellaOpsCliOptions
|
||||
{
|
||||
ResultsDirectory = Path.Combine(tempDir.Path, "results"),
|
||||
Authority = new StellaOpsCliAuthorityOptions
|
||||
{
|
||||
Url = "https://authority.example",
|
||||
ClientId = "cli",
|
||||
TokenCacheDirectory = tempDir.Path
|
||||
}
|
||||
};
|
||||
|
||||
var tokenClient = new StubTokenClient();
|
||||
tokenClient.CachedEntry = new StellaOpsTokenCacheEntry(
|
||||
"token",
|
||||
"Bearer",
|
||||
DateTimeOffset.UtcNow.AddMinutes(30),
|
||||
new[] { StellaOpsScopes.FeedserJobsTrigger });
|
||||
|
||||
var provider = BuildServiceProvider(new StubBackendClient(new JobTriggerResult(true, "ok", null, null)), options: options, tokenClient: tokenClient);
|
||||
|
||||
await CommandHandlers.HandleAuthStatusAsync(provider, options, verbose: true, cancellationToken: CancellationToken.None);
|
||||
|
||||
Assert.Equal(0, Environment.ExitCode);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.ExitCode = original;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleAuthWhoAmIAsync_ReturnsErrorWhenTokenMissing()
|
||||
{
|
||||
var original = Environment.ExitCode;
|
||||
using var tempDir = new TempDirectory();
|
||||
|
||||
try
|
||||
{
|
||||
var options = new StellaOpsCliOptions
|
||||
{
|
||||
ResultsDirectory = Path.Combine(tempDir.Path, "results"),
|
||||
Authority = new StellaOpsCliAuthorityOptions
|
||||
{
|
||||
Url = "https://authority.example",
|
||||
ClientId = "cli",
|
||||
TokenCacheDirectory = tempDir.Path
|
||||
}
|
||||
};
|
||||
|
||||
var provider = BuildServiceProvider(new StubBackendClient(new JobTriggerResult(true, "ok", null, null)), options: options, tokenClient: new StubTokenClient());
|
||||
|
||||
await CommandHandlers.HandleAuthWhoAmIAsync(provider, options, verbose: false, cancellationToken: CancellationToken.None);
|
||||
|
||||
Assert.Equal(1, Environment.ExitCode);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.ExitCode = original;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleAuthWhoAmIAsync_ReportsClaimsForJwtToken()
|
||||
{
|
||||
var original = Environment.ExitCode;
|
||||
using var tempDir = new TempDirectory();
|
||||
|
||||
try
|
||||
{
|
||||
var options = new StellaOpsCliOptions
|
||||
{
|
||||
ResultsDirectory = Path.Combine(tempDir.Path, "results"),
|
||||
Authority = new StellaOpsCliAuthorityOptions
|
||||
{
|
||||
Url = "https://authority.example",
|
||||
ClientId = "cli",
|
||||
TokenCacheDirectory = tempDir.Path
|
||||
}
|
||||
};
|
||||
|
||||
var tokenClient = new StubTokenClient();
|
||||
tokenClient.CachedEntry = new StellaOpsTokenCacheEntry(
|
||||
CreateUnsignedJwt(
|
||||
("sub", "cli-user"),
|
||||
("aud", "feedser"),
|
||||
("iss", "https://authority.example"),
|
||||
("iat", 1_700_000_000),
|
||||
("nbf", 1_700_000_000)),
|
||||
"Bearer",
|
||||
DateTimeOffset.UtcNow.AddMinutes(30),
|
||||
new[] { StellaOpsScopes.FeedserJobsTrigger });
|
||||
|
||||
var provider = BuildServiceProvider(new StubBackendClient(new JobTriggerResult(true, "ok", null, null)), options: options, tokenClient: tokenClient);
|
||||
|
||||
await CommandHandlers.HandleAuthWhoAmIAsync(provider, options, verbose: true, cancellationToken: CancellationToken.None);
|
||||
|
||||
Assert.Equal(0, Environment.ExitCode);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.ExitCode = original;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleAuthLogoutAsync_ClearsToken()
|
||||
{
|
||||
var original = Environment.ExitCode;
|
||||
using var tempDir = new TempDirectory();
|
||||
|
||||
try
|
||||
{
|
||||
var options = new StellaOpsCliOptions
|
||||
{
|
||||
ResultsDirectory = Path.Combine(tempDir.Path, "results"),
|
||||
Authority = new StellaOpsCliAuthorityOptions
|
||||
{
|
||||
Url = "https://authority.example",
|
||||
ClientId = "cli",
|
||||
TokenCacheDirectory = tempDir.Path
|
||||
}
|
||||
};
|
||||
|
||||
var tokenClient = new StubTokenClient();
|
||||
tokenClient.CachedEntry = new StellaOpsTokenCacheEntry(
|
||||
"token",
|
||||
"Bearer",
|
||||
DateTimeOffset.UtcNow.AddMinutes(5),
|
||||
new[] { StellaOpsScopes.FeedserJobsTrigger });
|
||||
|
||||
var provider = BuildServiceProvider(new StubBackendClient(new JobTriggerResult(true, "ok", null, null)), options: options, tokenClient: tokenClient);
|
||||
|
||||
await CommandHandlers.HandleAuthLogoutAsync(provider, options, verbose: true, cancellationToken: CancellationToken.None);
|
||||
|
||||
Assert.Null(tokenClient.CachedEntry);
|
||||
Assert.Equal(1, tokenClient.ClearRequests);
|
||||
Assert.Equal(0, Environment.ExitCode);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.ExitCode = original;
|
||||
}
|
||||
}
|
||||
|
||||
private static IServiceProvider BuildServiceProvider(
|
||||
IBackendOperationsClient backend,
|
||||
IScannerExecutor? executor = null,
|
||||
IScannerInstaller? installer = null,
|
||||
StellaOpsCliOptions? options = null,
|
||||
IStellaOpsTokenClient? tokenClient = null)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton(backend);
|
||||
services.AddSingleton<ILoggerFactory>(_ => LoggerFactory.Create(builder => builder.SetMinimumLevel(LogLevel.Debug)));
|
||||
services.AddSingleton(new VerbosityState());
|
||||
var resolvedOptions = options ?? new StellaOpsCliOptions
|
||||
{
|
||||
ResultsDirectory = Path.Combine(Path.GetTempPath(), $"stellaops-cli-results-{Guid.NewGuid():N}")
|
||||
};
|
||||
services.AddSingleton(resolvedOptions);
|
||||
|
||||
var resolvedExecutor = executor ?? CreateDefaultExecutor();
|
||||
services.AddSingleton<IScannerExecutor>(resolvedExecutor);
|
||||
services.AddSingleton<IScannerInstaller>(installer ?? new StubInstaller());
|
||||
|
||||
if (tokenClient is not null)
|
||||
{
|
||||
services.AddSingleton(tokenClient);
|
||||
}
|
||||
|
||||
return services.BuildServiceProvider();
|
||||
}
|
||||
|
||||
private static IScannerExecutor CreateDefaultExecutor()
|
||||
{
|
||||
var tempResultsFile = Path.GetTempFileName();
|
||||
var tempMetadataFile = Path.Combine(
|
||||
Path.GetDirectoryName(tempResultsFile)!,
|
||||
$"{Path.GetFileNameWithoutExtension(tempResultsFile)}-run.json");
|
||||
return new StubExecutor(new ScannerExecutionResult(0, tempResultsFile, tempMetadataFile));
|
||||
}
|
||||
|
||||
private sealed class StubBackendClient : IBackendOperationsClient
|
||||
{
|
||||
private readonly JobTriggerResult _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;
|
||||
}
|
||||
|
||||
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)!);
|
||||
if (!File.Exists(_result.ResultsPath))
|
||||
{
|
||||
File.WriteAllText(_result.ResultsPath, "{}");
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(_result.RunMetadataPath)!);
|
||||
if (!File.Exists(_result.RunMetadataPath))
|
||||
{
|
||||
File.WriteAllText(_result.RunMetadataPath, "{}");
|
||||
}
|
||||
|
||||
return Task.FromResult(_result);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class StubInstaller : IScannerInstaller
|
||||
{
|
||||
public Task InstallAsync(string artifactPath, bool verbose, CancellationToken cancellationToken)
|
||||
=> Task.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class StubTokenClient : IStellaOpsTokenClient
|
||||
{
|
||||
private readonly StellaOpsTokenResult _token;
|
||||
|
||||
public StubTokenClient()
|
||||
{
|
||||
_token = new StellaOpsTokenResult(
|
||||
"token-123",
|
||||
"Bearer",
|
||||
DateTimeOffset.UtcNow.AddMinutes(30),
|
||||
new[] { StellaOpsScopes.FeedserJobsTrigger });
|
||||
}
|
||||
|
||||
public int ClientCredentialRequests { get; private set; }
|
||||
public int PasswordRequests { get; private set; }
|
||||
public int ClearRequests { get; private set; }
|
||||
public StellaOpsTokenCacheEntry? CachedEntry { get; set; }
|
||||
|
||||
public ValueTask CacheTokenAsync(string key, StellaOpsTokenCacheEntry entry, CancellationToken cancellationToken = default)
|
||||
{
|
||||
CachedEntry = entry;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public ValueTask ClearCachedTokenAsync(string key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ClearRequests++;
|
||||
CachedEntry = null;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<JsonWebKeySet> GetJsonWebKeySetAsync(CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(new JsonWebKeySet("{\"keys\":[]}"));
|
||||
|
||||
public ValueTask<StellaOpsTokenCacheEntry?> GetCachedTokenAsync(string key, CancellationToken cancellationToken = default)
|
||||
=> ValueTask.FromResult(CachedEntry);
|
||||
|
||||
public Task<StellaOpsTokenResult> RequestClientCredentialsTokenAsync(string? scope = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ClientCredentialRequests++;
|
||||
return Task.FromResult(_token);
|
||||
}
|
||||
|
||||
public Task<StellaOpsTokenResult> RequestPasswordTokenAsync(string username, string password, string? scope = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
PasswordRequests++;
|
||||
return Task.FromResult(_token);
|
||||
}
|
||||
}
|
||||
|
||||
private static string CreateUnsignedJwt(params (string Key, object Value)[] claims)
|
||||
{
|
||||
var headerJson = "{\"alg\":\"none\",\"typ\":\"JWT\"}";
|
||||
var payload = new Dictionary<string, object?>(StringComparer.Ordinal);
|
||||
foreach (var claim in claims)
|
||||
{
|
||||
payload[claim.Key] = claim.Value;
|
||||
}
|
||||
|
||||
var payloadJson = JsonSerializer.Serialize(payload);
|
||||
return $"{Base64UrlEncode(headerJson)}.{Base64UrlEncode(payloadJson)}.";
|
||||
}
|
||||
|
||||
private static string Base64UrlEncode(string value)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(value);
|
||||
return Convert.ToBase64String(bytes)
|
||||
.TrimEnd('=')
|
||||
.Replace('+', '-')
|
||||
.Replace('/', '_');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user