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
418 lines
15 KiB
C#
418 lines
15 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|