Initial commit (history squashed)
Some checks failed
Build Test Deploy / authority-container (push) Has been cancelled
Build Test Deploy / docs (push) Has been cancelled
Build Test Deploy / deploy (push) Has been cancelled
Build Test Deploy / build-test (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled

This commit is contained in:
2025-10-07 10:14:21 +03:00
commit b97fc7685a
1132 changed files with 117842 additions and 0 deletions

View 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('/', '_');
}
}

View File

@@ -0,0 +1,110 @@
using System;
using System.IO;
using System.Text.Json;
using StellaOps.Cli.Configuration;
using Xunit;
namespace StellaOps.Cli.Tests.Configuration;
public sealed class CliBootstrapperTests : IDisposable
{
private readonly string _originalDirectory = Directory.GetCurrentDirectory();
private readonly string _tempDirectory = Path.Combine(Path.GetTempPath(), $"stellaops-cli-tests-{Guid.NewGuid():N}");
public CliBootstrapperTests()
{
Directory.CreateDirectory(_tempDirectory);
Directory.SetCurrentDirectory(_tempDirectory);
}
[Fact]
public void Build_UsesEnvironmentVariablesWhenPresent()
{
Environment.SetEnvironmentVariable("API_KEY", "env-key");
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_ENABLE_RETRIES", "false");
Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_RETRY_DELAYS", "00:00:02,00:00:05");
Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_ALLOW_OFFLINE_CACHE_FALLBACK", "false");
Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_OFFLINE_CACHE_TOLERANCE", "00:20:00");
try
{
var (options, _) = CliBootstrapper.Build(Array.Empty<string>());
Assert.Equal("env-key", options.ApiKey);
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.NotNull(options.Authority.Resilience);
Assert.False(options.Authority.Resilience.EnableRetries);
Assert.Equal(new[] { TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(5) }, options.Authority.Resilience.RetryDelays);
Assert.False(options.Authority.Resilience.AllowOfflineCacheFallback);
Assert.Equal(TimeSpan.FromMinutes(20), options.Authority.Resilience.OfflineCacheTolerance);
}
finally
{
Environment.SetEnvironmentVariable("API_KEY", null);
Environment.SetEnvironmentVariable("STELLAOPS_BACKEND_URL", null);
Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_URL", null);
Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_CLIENT_ID", null);
Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_SCOPE", null);
Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_ENABLE_RETRIES", null);
Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_RETRY_DELAYS", null);
Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_ALLOW_OFFLINE_CACHE_FALLBACK", null);
Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_OFFLINE_CACHE_TOLERANCE", null);
}
}
[Fact]
public void Build_FallsBackToAppSettings()
{
WriteAppSettings(new
{
StellaOps = new
{
ApiKey = "file-key",
BackendUrl = "https://file-backend.example",
Authority = new
{
Url = "https://authority.file",
ClientId = "cli-file",
Scope = "feedser.jobs.trigger"
}
}
});
var (options, _) = CliBootstrapper.Build(Array.Empty<string>());
Assert.Equal("file-key", options.ApiKey);
Assert.Equal("https://file-backend.example", options.BackendUrl);
Assert.Equal("https://authority.file", options.Authority.Url);
Assert.Equal("cli-file", options.Authority.ClientId);
}
public void Dispose()
{
Directory.SetCurrentDirectory(_originalDirectory);
if (Directory.Exists(_tempDirectory))
{
try
{
Directory.Delete(_tempDirectory, recursive: true);
}
catch
{
// Ignored.
}
}
}
private static void WriteAppSettings<T>(T payload)
{
var json = JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true });
File.WriteAllText("appsettings.json", json);
}
}

View File

@@ -0,0 +1,417 @@
using System;
using System.IO;
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 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://feedser.example")
};
var options = new StellaOpsCliOptions
{
BackendUrl = "https://feedser.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://feedser.example")
};
var options = new StellaOpsCliOptions
{
BackendUrl = "https://feedser.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://feedser.example")
};
var options = new StellaOpsCliOptions
{
BackendUrl = "https://feedser.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));
}
[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);
}
[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://feedser.example")
};
var options = new StellaOpsCliOptions { BackendUrl = "https://feedser.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://feedser.example")
};
var options = new StellaOpsCliOptions { BackendUrl = "https://feedser.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);
}
}
}

View File

@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<!-- To enable Microsoft.Testing.Platform, uncomment the following line. -->
<!-- <UseMicrosoftTestingPlatformRunner>true</UseMicrosoftTestingPlatformRunner> -->
<!-- Note: to use Microsoft.Testing.Platform correctly with dotnet test: -->
<!-- 1. You must add dotnet.config specifying the test runner to be Microsoft.Testing.Platform -->
<!-- 2. You must use .NET 10 SDK or later -->
<!-- For more information, see https://aka.ms/dotnet-test/mtp and https://xunit.net/docs/getting-started/v3/microsoft-testing-platform -->
<!-- To enable code coverage with Microsoft.Testing.Platform, add a package reference to Microsoft.Testing.Extensions.CodeCoverage -->
<!-- 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>

View File

@@ -0,0 +1,55 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Cli.Tests.Testing;
internal sealed class TempDirectory : IDisposable
{
public TempDirectory()
{
Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"stellaops-cli-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(Path);
}
public string Path { get; }
public void Dispose()
{
try
{
if (Directory.Exists(Path))
{
Directory.Delete(Path, recursive: true);
}
}
catch
{
// ignored
}
}
}
internal sealed class StubHttpMessageHandler : HttpMessageHandler
{
private readonly Queue<Func<HttpRequestMessage, CancellationToken, HttpResponseMessage>> _responses;
public StubHttpMessageHandler(params Func<HttpRequestMessage, CancellationToken, HttpResponseMessage>[] handlers)
{
if (handlers is null || handlers.Length == 0)
{
throw new ArgumentException("At least one handler must be provided.", nameof(handlers));
}
_responses = new Queue<Func<HttpRequestMessage, CancellationToken, HttpResponseMessage>>(handlers);
}
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var factory = _responses.Count > 1 ? _responses.Dequeue() : _responses.Peek();
return Task.FromResult(factory(request, cancellationToken));
}
}

View File

@@ -0,0 +1,10 @@
namespace StellaOps.Cli.Tests;
public class UnitTest1
{
[Fact]
public void Test1()
{
}
}

View File

@@ -0,0 +1,3 @@
{
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json"
}