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()); 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()); var targetPath = Path.Combine(temp.Path, "scanner.tar.gz"); await Assert.ThrowsAsync(() => 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()); 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()); 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()); await Assert.ThrowsAsync(() => 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()); var result = await client.TriggerJobAsync("export:json", new Dictionary(), 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()); var result = await client.TriggerJobAsync("export:json", new Dictionary(), 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(), tokenClient); var result = await client.TriggerJobAsync("test", new Dictionary(), 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 GetJsonWebKeySetAsync(CancellationToken cancellationToken = default) => Task.FromResult(new JsonWebKeySet("{\"keys\":[]}")); public ValueTask GetCachedTokenAsync(string key, CancellationToken cancellationToken = default) => ValueTask.FromResult(null); public Task RequestClientCredentialsTokenAsync(string? scope = null, CancellationToken cancellationToken = default) { Requests++; return Task.FromResult(_tokenResult); } public Task RequestPasswordTokenAsync(string username, string password, string? scope = null, CancellationToken cancellationToken = default) { Requests++; return Task.FromResult(_tokenResult); } } }