test fixes and new product advisories work

This commit is contained in:
master
2026-01-28 02:30:48 +02:00
parent 82caceba56
commit 644887997c
288 changed files with 69101 additions and 375 deletions

View File

@@ -0,0 +1,616 @@
using System.Diagnostics;
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using StellaOps.ReleaseOrchestrator.Plugin.Capabilities;
using StellaOps.ReleaseOrchestrator.Plugin.Models;
namespace StellaOps.ReleaseOrchestrator.IntegrationHub.Connectors.Registry;
/// <summary>
/// JFrog Artifactory container registry connector.
/// Supports both Cloud and self-hosted Artifactory with API Key, Bearer token, and Basic auth.
/// </summary>
public sealed class JfrogArtifactoryConnector : IRegistryConnectorCapability, IDisposable
{
private HttpClient? _httpClient;
private string _artifactoryUrl = string.Empty;
private string _artifactoryHost = string.Empty;
private string? _username;
private string? _password;
private string? _apiKey;
private string? _accessToken;
private string? _repository;
private string? _repositoryType;
private bool _disposed;
/// <inheritdoc />
public ConnectorCategory Category => ConnectorCategory.Registry;
/// <inheritdoc />
public string ConnectorType => "jfrog-artifactory";
/// <inheritdoc />
public string DisplayName => "JFrog Artifactory";
/// <inheritdoc />
public IReadOnlyList<string> GetSupportedOperations() =>
["list_repos", "list_tags", "resolve_tag", "get_manifest", "pull_credentials", "aql_query"];
/// <inheritdoc />
public Task<ConfigValidationResult> ValidateConfigAsync(
JsonElement config,
CancellationToken ct)
{
var errors = new List<string>();
// Validate artifactoryUrl (required)
var hasUrl = config.TryGetProperty("artifactoryUrl", out var url) &&
url.ValueKind == JsonValueKind.String &&
!string.IsNullOrWhiteSpace(url.GetString());
if (!hasUrl)
{
errors.Add("'artifactoryUrl' is required");
}
else
{
var urlStr = url.GetString();
if (!Uri.TryCreate(urlStr, UriKind.Absolute, out _))
{
errors.Add("Invalid 'artifactoryUrl' format");
}
}
// Check for authentication: API Key OR Access Token OR username/password
var hasApiKey = config.TryGetProperty("apiKey", out var apiKey) &&
apiKey.ValueKind == JsonValueKind.String &&
!string.IsNullOrWhiteSpace(apiKey.GetString());
var hasApiKeyRef = config.TryGetProperty("apiKeySecretRef", out var apiKeyRef) &&
apiKeyRef.ValueKind == JsonValueKind.String &&
!string.IsNullOrWhiteSpace(apiKeyRef.GetString());
var hasAccessToken = config.TryGetProperty("accessToken", out var accessToken) &&
accessToken.ValueKind == JsonValueKind.String &&
!string.IsNullOrWhiteSpace(accessToken.GetString());
var hasAccessTokenRef = config.TryGetProperty("accessTokenSecretRef", out var accessTokenRef) &&
accessTokenRef.ValueKind == JsonValueKind.String &&
!string.IsNullOrWhiteSpace(accessTokenRef.GetString());
var hasUsername = config.TryGetProperty("username", out var username) &&
username.ValueKind == JsonValueKind.String &&
!string.IsNullOrWhiteSpace(username.GetString());
var hasPassword = config.TryGetProperty("password", out var password) &&
password.ValueKind == JsonValueKind.String &&
!string.IsNullOrWhiteSpace(password.GetString());
var hasPasswordRef = config.TryGetProperty("passwordSecretRef", out var passwordRef) &&
passwordRef.ValueKind == JsonValueKind.String &&
!string.IsNullOrWhiteSpace(passwordRef.GetString());
// Require at least one auth method
var hasApiKeyAuth = hasApiKey || hasApiKeyRef;
var hasTokenAuth = hasAccessToken || hasAccessTokenRef;
var hasBasicAuth = hasUsername && (hasPassword || hasPasswordRef);
if (!hasApiKeyAuth && !hasTokenAuth && !hasBasicAuth)
{
errors.Add("Authentication required: provide 'apiKey'/'apiKeySecretRef', 'accessToken'/'accessTokenSecretRef', or 'username' with 'password'/'passwordSecretRef'");
}
// Validate repository type if provided
if (config.TryGetProperty("repositoryType", out var repoType) &&
repoType.ValueKind == JsonValueKind.String)
{
var type = repoType.GetString();
if (!string.IsNullOrEmpty(type) &&
type != "local" && type != "remote" && type != "virtual")
{
errors.Add("'repositoryType' must be 'local', 'remote', or 'virtual'");
}
}
return Task.FromResult(errors.Count == 0
? ConfigValidationResult.Success()
: ConfigValidationResult.Failure([.. errors]));
}
/// <inheritdoc />
public async Task<ConnectionTestResult> TestConnectionAsync(
ConnectorContext context,
CancellationToken ct)
{
var sw = Stopwatch.StartNew();
try
{
var client = await GetClientAsync(context, ct);
// Artifactory API: GET /artifactory/api/system/ping
using var response = await client.GetAsync("artifactory/api/system/ping", ct);
if (response.StatusCode == HttpStatusCode.Unauthorized)
{
return ConnectionTestResult.Failure("Authentication failed: Invalid credentials or API key");
}
if (!response.IsSuccessStatusCode)
{
return ConnectionTestResult.Failure($"Artifactory returned: {response.StatusCode}");
}
// Try to get version info
string versionInfo = "unknown";
try
{
using var versionResponse = await client.GetAsync("artifactory/api/system/version", ct);
if (versionResponse.IsSuccessStatusCode)
{
var version = await versionResponse.Content.ReadFromJsonAsync<ArtifactoryVersion>(ct);
versionInfo = version?.Version ?? "unknown";
}
}
catch
{
// Version fetch is optional
}
return ConnectionTestResult.Success(
$"Connected to JFrog Artifactory {versionInfo} at {_artifactoryHost}",
sw.ElapsedMilliseconds);
}
catch (Exception ex)
{
return ConnectionTestResult.Failure(ex.Message);
}
}
/// <inheritdoc />
public async Task<IReadOnlyList<RegistryRepository>> ListRepositoriesAsync(
ConnectorContext context,
string? prefix = null,
CancellationToken ct = default)
{
var client = await GetClientAsync(context, ct);
var repos = new List<RegistryRepository>();
// If specific repository is configured, only return Docker repos from it
if (!string.IsNullOrEmpty(_repository))
{
return await ListDockerImagesInRepositoryAsync(client, _repository, prefix, ct);
}
// List all Docker repositories
var url = "artifactory/api/repositories?type=local&packageType=docker";
using var response = await client.GetAsync(url, ct);
if (!response.IsSuccessStatusCode)
return repos;
var repositories = await response.Content.ReadFromJsonAsync<ArtifactoryRepository[]>(ct);
if (repositories is null)
return repos;
foreach (var repo in repositories)
{
// Get images within each Docker repository
var images = await ListDockerImagesInRepositoryAsync(client, repo.Key, prefix, ct);
repos.AddRange(images);
}
return repos;
}
private async Task<IReadOnlyList<RegistryRepository>> ListDockerImagesInRepositoryAsync(
HttpClient client,
string repoKey,
string? prefix,
CancellationToken ct)
{
var repos = new List<RegistryRepository>();
// Use AQL to find Docker manifests
var aqlQuery = $@"items.find({{
""repo"": ""{repoKey}"",
""name"": ""manifest.json"",
""path"": {{""$ne"": "".""}}
}}).include(""path"", ""created"", ""modified"")";
var aqlContent = new StringContent(aqlQuery, Encoding.UTF8, "text/plain");
using var response = await client.PostAsync("artifactory/api/search/aql", aqlContent, ct);
if (!response.IsSuccessStatusCode)
return repos;
var result = await response.Content.ReadFromJsonAsync<AqlResult>(ct);
if (result?.Results is null)
return repos;
// Extract unique image paths (directories containing manifest.json)
var imagePaths = result.Results
.Select(r => r.Path)
.Where(p => !string.IsNullOrEmpty(p))
.Select(p =>
{
// Path is like "myimage/tag" - extract image name
var parts = p!.Split('/');
return parts.Length > 0 ? parts[0] : p;
})
.Distinct()
.Where(p => string.IsNullOrEmpty(prefix) ||
p.StartsWith(prefix, StringComparison.OrdinalIgnoreCase));
foreach (var imagePath in imagePaths)
{
// Count tags for this image
var tagCount = result.Results
.Count(r => r.Path?.StartsWith(imagePath + "/") == true ||
r.Path == imagePath);
var lastModified = result.Results
.Where(r => r.Path?.StartsWith(imagePath) == true)
.Max(r => r.Modified);
repos.Add(new RegistryRepository(
Name: imagePath,
FullName: $"{_artifactoryHost}/{repoKey}/{imagePath}",
TagCount: tagCount,
LastPushed: lastModified));
}
return repos;
}
/// <inheritdoc />
public async Task<IReadOnlyList<ImageTag>> ListTagsAsync(
ConnectorContext context,
string repository,
CancellationToken ct = default)
{
var client = await GetClientAsync(context, ct);
var tags = new List<ImageTag>();
// Parse repo/image from repository name
var parts = repository.Split('/', 2);
var repoKey = parts.Length > 1 ? parts[0] : (_repository ?? "docker-local");
var imagePath = parts.Length > 1 ? parts[1] : parts[0];
// Use AQL to find all manifest.json files for this image
var aqlQuery = $@"items.find({{
""repo"": ""{repoKey}"",
""path"": {{""$match"": ""{imagePath}/*""}},
""name"": ""manifest.json""
}}).include(""path"", ""created"", ""modified"", ""size"", ""sha256"")";
var aqlContent = new StringContent(aqlQuery, Encoding.UTF8, "text/plain");
using var response = await client.PostAsync("artifactory/api/search/aql", aqlContent, ct);
if (!response.IsSuccessStatusCode)
return tags;
var result = await response.Content.ReadFromJsonAsync<AqlResult>(ct);
if (result?.Results is null)
return tags;
foreach (var item in result.Results)
{
if (string.IsNullOrEmpty(item.Path))
continue;
// Extract tag from path (path is like "imagename/tagname")
var pathParts = item.Path.Split('/');
var tagName = pathParts.Length > 1 ? pathParts[^1] : item.Path;
tags.Add(new ImageTag(
Name: tagName,
Digest: !string.IsNullOrEmpty(item.Sha256) ? $"sha256:{item.Sha256}" : string.Empty,
CreatedAt: item.Created ?? item.Modified ?? DateTimeOffset.MinValue,
SizeBytes: item.Size));
}
return tags;
}
/// <inheritdoc />
public async Task<ImageDigest?> ResolveTagAsync(
ConnectorContext context,
string repository,
string tag,
CancellationToken ct = default)
{
var client = await GetClientAsync(context, ct);
// Use OCI endpoint for manifest head
using var request = new HttpRequestMessage(
HttpMethod.Head,
$"v2/{repository}/manifests/{tag}");
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(
"application/vnd.oci.image.manifest.v1+json"));
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(
"application/vnd.docker.distribution.manifest.v2+json"));
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(
"application/vnd.oci.image.index.v1+json"));
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(
"application/vnd.docker.distribution.manifest.list.v2+json"));
using var response = await client.SendAsync(request, ct);
if (response.StatusCode == HttpStatusCode.NotFound)
return null;
response.EnsureSuccessStatusCode();
var digest = response.Headers.TryGetValues("Docker-Content-Digest", out var digestValues)
? digestValues.FirstOrDefault() ?? string.Empty
: string.Empty;
return Plugin.Models.ImageDigest.Parse(digest);
}
/// <inheritdoc />
public async Task<ImageManifest?> GetManifestAsync(
ConnectorContext context,
string repository,
string reference,
CancellationToken ct = default)
{
var client = await GetClientAsync(context, ct);
using var request = new HttpRequestMessage(
HttpMethod.Get,
$"v2/{repository}/manifests/{reference}");
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(
"application/vnd.oci.image.manifest.v1+json"));
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(
"application/vnd.docker.distribution.manifest.v2+json"));
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(
"application/vnd.oci.image.index.v1+json"));
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(
"application/vnd.docker.distribution.manifest.list.v2+json"));
using var response = await client.SendAsync(request, ct);
if (response.StatusCode == HttpStatusCode.NotFound)
return null;
response.EnsureSuccessStatusCode();
var digest = response.Headers.TryGetValues("Docker-Content-Digest", out var digestValues)
? digestValues.FirstOrDefault() ?? string.Empty
: string.Empty;
var mediaType = response.Content.Headers.ContentType?.MediaType ?? string.Empty;
var content = await response.Content.ReadAsStringAsync(ct);
var layers = ExtractLayersFromManifest(content, mediaType);
return new ImageManifest(
Digest: digest,
MediaType: mediaType,
Platform: null,
SizeBytes: response.Content.Headers.ContentLength ?? content.Length,
Layers: layers,
CreatedAt: null);
}
/// <inheritdoc />
public Task<PullCredentials> GetPullCredentialsAsync(
ConnectorContext context,
string repository,
CancellationToken ct = default)
{
// Priority: Access Token > API Key > Basic Auth
string username;
string password;
if (!string.IsNullOrEmpty(_accessToken))
{
// For access tokens, use empty username with token as password
username = string.Empty;
password = _accessToken;
}
else if (!string.IsNullOrEmpty(_apiKey))
{
// For API key, use the username with API key as password
username = _username ?? string.Empty;
password = _apiKey;
}
else
{
username = _username ?? string.Empty;
password = _password ?? string.Empty;
}
return Task.FromResult(new PullCredentials(
Registry: _artifactoryHost,
Username: username,
Password: password,
ExpiresAt: null));
}
private async Task<HttpClient> GetClientAsync(
ConnectorContext context,
CancellationToken ct)
{
if (_httpClient is not null)
return _httpClient;
var config = context.Configuration;
if (!config.TryGetProperty("artifactoryUrl", out var urlProp) ||
urlProp.ValueKind != JsonValueKind.String)
{
throw new InvalidOperationException("Artifactory URL not configured");
}
_artifactoryUrl = urlProp.GetString()!.TrimEnd('/');
_artifactoryHost = new Uri(_artifactoryUrl).Host;
// Extract repository config
if (config.TryGetProperty("repository", out var repoProp) &&
repoProp.ValueKind == JsonValueKind.String)
{
_repository = repoProp.GetString();
}
if (config.TryGetProperty("repositoryType", out var repoTypeProp) &&
repoTypeProp.ValueKind == JsonValueKind.String)
{
_repositoryType = repoTypeProp.GetString();
}
// Extract auth credentials - API Key
if (config.TryGetProperty("apiKey", out var apiKeyProp) &&
apiKeyProp.ValueKind == JsonValueKind.String)
{
_apiKey = apiKeyProp.GetString();
}
else if (config.TryGetProperty("apiKeySecretRef", out var apiKeyRef) &&
apiKeyRef.ValueKind == JsonValueKind.String)
{
var secretPath = apiKeyRef.GetString();
if (!string.IsNullOrEmpty(secretPath))
{
_apiKey = await context.SecretResolver.ResolveAsync(secretPath, ct);
}
}
// Extract auth credentials - Access Token
if (config.TryGetProperty("accessToken", out var accessTokenProp) &&
accessTokenProp.ValueKind == JsonValueKind.String)
{
_accessToken = accessTokenProp.GetString();
}
else if (config.TryGetProperty("accessTokenSecretRef", out var accessTokenRef) &&
accessTokenRef.ValueKind == JsonValueKind.String)
{
var secretPath = accessTokenRef.GetString();
if (!string.IsNullOrEmpty(secretPath))
{
_accessToken = await context.SecretResolver.ResolveAsync(secretPath, ct);
}
}
// Extract auth credentials - Username/Password
if (config.TryGetProperty("username", out var userProp) &&
userProp.ValueKind == JsonValueKind.String)
{
_username = userProp.GetString();
}
if (config.TryGetProperty("password", out var passProp) &&
passProp.ValueKind == JsonValueKind.String)
{
_password = passProp.GetString();
}
else if (config.TryGetProperty("passwordSecretRef", out var passRef) &&
passRef.ValueKind == JsonValueKind.String)
{
var secretPath = passRef.GetString();
if (!string.IsNullOrEmpty(secretPath))
{
_password = await context.SecretResolver.ResolveAsync(secretPath, ct);
}
}
_httpClient = new HttpClient
{
BaseAddress = new Uri(_artifactoryUrl + "/")
};
// Set authorization header based on available auth
if (!string.IsNullOrEmpty(_accessToken))
{
_httpClient.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", _accessToken);
}
else if (!string.IsNullOrEmpty(_apiKey))
{
_httpClient.DefaultRequestHeaders.Add("X-JFrog-Art-Api", _apiKey);
}
else if (!string.IsNullOrEmpty(_username))
{
var credentials = Convert.ToBase64String(
Encoding.UTF8.GetBytes($"{_username}:{_password}"));
_httpClient.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Basic", credentials);
}
_httpClient.DefaultRequestHeaders.UserAgent.Add(
new ProductInfoHeaderValue("StellaOps", "1.0"));
return _httpClient;
}
private static IReadOnlyList<string> ExtractLayersFromManifest(string content, string mediaType)
{
try
{
using var doc = JsonDocument.Parse(content);
var root = doc.RootElement;
if (root.TryGetProperty("layers", out var layers))
{
return layers.EnumerateArray()
.Where(l => l.TryGetProperty("digest", out _))
.Select(l => l.GetProperty("digest").GetString()!)
.ToList();
}
return [];
}
catch
{
return [];
}
}
/// <inheritdoc />
public void Dispose()
{
if (_disposed)
return;
_httpClient?.Dispose();
_disposed = true;
}
}
// JFrog Artifactory API response models
internal sealed record ArtifactoryVersion(
[property: JsonPropertyName("version")] string? Version,
[property: JsonPropertyName("revision")] string? Revision,
[property: JsonPropertyName("license")] string? License);
internal sealed record ArtifactoryRepository(
[property: JsonPropertyName("key")] string Key,
[property: JsonPropertyName("type")] string? Type,
[property: JsonPropertyName("packageType")] string? PackageType,
[property: JsonPropertyName("url")] string? Url);
internal sealed record AqlResult(
[property: JsonPropertyName("results")] AqlResultItem[]? Results,
[property: JsonPropertyName("range")] AqlRange? Range);
internal sealed record AqlResultItem(
[property: JsonPropertyName("repo")] string? Repo,
[property: JsonPropertyName("path")] string? Path,
[property: JsonPropertyName("name")] string? Name,
[property: JsonPropertyName("created")] DateTimeOffset? Created,
[property: JsonPropertyName("modified")] DateTimeOffset? Modified,
[property: JsonPropertyName("size")] long? Size,
[property: JsonPropertyName("sha256")] string? Sha256);
internal sealed record AqlRange(
[property: JsonPropertyName("start_pos")] int StartPos,
[property: JsonPropertyName("end_pos")] int EndPos,
[property: JsonPropertyName("total")] int Total);

View File

@@ -0,0 +1,501 @@
using System.Diagnostics;
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using StellaOps.ReleaseOrchestrator.Plugin.Capabilities;
using StellaOps.ReleaseOrchestrator.Plugin.Models;
namespace StellaOps.ReleaseOrchestrator.IntegrationHub.Connectors.Registry;
/// <summary>
/// Quay container registry connector.
/// Supports Quay.io and Red Hat Quay with OAuth2/robot account authentication and organization-based repositories.
/// </summary>
public sealed class QuayConnector : IRegistryConnectorCapability, IDisposable
{
private HttpClient? _httpClient;
private string _quayUrl = string.Empty;
private string _quayHost = string.Empty;
private string? _username;
private string? _password;
private string? _oauth2Token;
private string? _organizationName;
private bool _disposed;
/// <inheritdoc />
public ConnectorCategory Category => ConnectorCategory.Registry;
/// <inheritdoc />
public string ConnectorType => "quay";
/// <inheritdoc />
public string DisplayName => "Quay Registry";
/// <inheritdoc />
public IReadOnlyList<string> GetSupportedOperations() =>
["list_repos", "list_tags", "resolve_tag", "get_manifest", "pull_credentials"];
/// <inheritdoc />
public Task<ConfigValidationResult> ValidateConfigAsync(
JsonElement config,
CancellationToken ct)
{
var errors = new List<string>();
// Validate quayUrl (required)
var hasUrl = config.TryGetProperty("quayUrl", out var url) &&
url.ValueKind == JsonValueKind.String &&
!string.IsNullOrWhiteSpace(url.GetString());
if (!hasUrl)
{
errors.Add("'quayUrl' is required");
}
else
{
var urlStr = url.GetString();
if (!Uri.TryCreate(urlStr, UriKind.Absolute, out _))
{
errors.Add("Invalid 'quayUrl' format");
}
}
// Check for authentication: OAuth2 token OR username/password
var hasOAuth2Token = config.TryGetProperty("oauth2Token", out var oauth2Token) &&
oauth2Token.ValueKind == JsonValueKind.String &&
!string.IsNullOrWhiteSpace(oauth2Token.GetString());
var hasOAuth2TokenRef = config.TryGetProperty("oauth2TokenSecretRef", out var oauth2TokenRef) &&
oauth2TokenRef.ValueKind == JsonValueKind.String &&
!string.IsNullOrWhiteSpace(oauth2TokenRef.GetString());
var hasUsername = config.TryGetProperty("username", out var username) &&
username.ValueKind == JsonValueKind.String &&
!string.IsNullOrWhiteSpace(username.GetString());
var hasPassword = config.TryGetProperty("password", out var password) &&
password.ValueKind == JsonValueKind.String &&
!string.IsNullOrWhiteSpace(password.GetString());
var hasPasswordRef = config.TryGetProperty("passwordSecretRef", out var passwordRef) &&
passwordRef.ValueKind == JsonValueKind.String &&
!string.IsNullOrWhiteSpace(passwordRef.GetString());
// Require either OAuth2 token OR username with password
var hasOAuth2 = hasOAuth2Token || hasOAuth2TokenRef;
var hasBasicAuth = hasUsername && (hasPassword || hasPasswordRef);
if (!hasOAuth2 && !hasBasicAuth)
{
errors.Add("Either 'oauth2Token'/'oauth2TokenSecretRef' OR 'username' with 'password'/'passwordSecretRef' is required");
}
return Task.FromResult(errors.Count == 0
? ConfigValidationResult.Success()
: ConfigValidationResult.Failure([.. errors]));
}
/// <inheritdoc />
public async Task<ConnectionTestResult> TestConnectionAsync(
ConnectorContext context,
CancellationToken ct)
{
var sw = Stopwatch.StartNew();
try
{
var client = await GetClientAsync(context, ct);
// Quay API: GET /api/v1/discovery to test connectivity
using var response = await client.GetAsync("api/v1/discovery", ct);
if (response.StatusCode == HttpStatusCode.Unauthorized)
{
return ConnectionTestResult.Failure("Authentication failed: Invalid credentials or token");
}
if (!response.IsSuccessStatusCode)
{
return ConnectionTestResult.Failure($"Quay returned: {response.StatusCode}");
}
return ConnectionTestResult.Success(
$"Connected to Quay at {_quayHost}",
sw.ElapsedMilliseconds);
}
catch (Exception ex)
{
return ConnectionTestResult.Failure(ex.Message);
}
}
/// <inheritdoc />
public async Task<IReadOnlyList<RegistryRepository>> ListRepositoriesAsync(
ConnectorContext context,
string? prefix = null,
CancellationToken ct = default)
{
var client = await GetClientAsync(context, ct);
var repos = new List<RegistryRepository>();
string? nextPage = null;
// Use organization endpoint if organization is configured, otherwise user repos
var baseUrl = !string.IsNullOrEmpty(_organizationName)
? $"api/v1/repository?namespace={Uri.EscapeDataString(_organizationName)}"
: "api/v1/repository?public=false";
if (!string.IsNullOrEmpty(prefix))
{
baseUrl += $"&filter={Uri.EscapeDataString(prefix)}";
}
var url = baseUrl;
while (true)
{
using var response = await client.GetAsync(url, ct);
if (response.StatusCode == HttpStatusCode.Unauthorized)
{
// Return empty list on auth failure for list operations
break;
}
if (!response.IsSuccessStatusCode)
break;
var result = await response.Content.ReadFromJsonAsync<QuayRepositoryList>(ct);
if (result?.Repositories is null || result.Repositories.Length == 0)
break;
foreach (var repo in result.Repositories)
{
repos.Add(new RegistryRepository(
Name: repo.Name,
FullName: $"{_quayHost}/{repo.Namespace}/{repo.Name}",
TagCount: repo.TagCount ?? 0,
LastPushed: repo.LastModified));
}
// Handle pagination
if (string.IsNullOrEmpty(result.NextPage))
break;
nextPage = result.NextPage;
url = $"{baseUrl}&next_page={Uri.EscapeDataString(nextPage)}";
}
return repos;
}
/// <inheritdoc />
public async Task<IReadOnlyList<ImageTag>> ListTagsAsync(
ConnectorContext context,
string repository,
CancellationToken ct = default)
{
var client = await GetClientAsync(context, ct);
var tags = new List<ImageTag>();
// Parse namespace/repo from repository name
var parts = repository.Split('/', 2);
if (parts.Length < 2)
{
return [];
}
var ns = parts[0];
var repo = parts[1];
var page = 1;
const int limit = 100;
while (true)
{
var url = $"api/v1/repository/{Uri.EscapeDataString(ns)}/{Uri.EscapeDataString(repo)}/tag/?page={page}&limit={limit}";
using var response = await client.GetAsync(url, ct);
if (response.StatusCode == HttpStatusCode.NotFound)
return [];
if (!response.IsSuccessStatusCode)
break;
var result = await response.Content.ReadFromJsonAsync<QuayTagList>(ct);
if (result?.Tags is null || result.Tags.Length == 0)
break;
foreach (var tag in result.Tags)
{
tags.Add(new ImageTag(
Name: tag.Name,
Digest: tag.ManifestDigest ?? string.Empty,
CreatedAt: tag.LastModified ?? DateTimeOffset.MinValue,
SizeBytes: tag.Size));
}
if (!result.HasAdditional)
break;
page++;
}
return tags;
}
/// <inheritdoc />
public async Task<ImageDigest?> ResolveTagAsync(
ConnectorContext context,
string repository,
string tag,
CancellationToken ct = default)
{
var client = await GetClientAsync(context, ct);
// Use OCI endpoint for manifest head
using var request = new HttpRequestMessage(
HttpMethod.Head,
$"v2/{repository}/manifests/{tag}");
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(
"application/vnd.oci.image.manifest.v1+json"));
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(
"application/vnd.docker.distribution.manifest.v2+json"));
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(
"application/vnd.oci.image.index.v1+json"));
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(
"application/vnd.docker.distribution.manifest.list.v2+json"));
using var response = await client.SendAsync(request, ct);
if (response.StatusCode == HttpStatusCode.NotFound)
return null;
response.EnsureSuccessStatusCode();
var digest = response.Headers.TryGetValues("Docker-Content-Digest", out var digestValues)
? digestValues.FirstOrDefault() ?? string.Empty
: string.Empty;
return Plugin.Models.ImageDigest.Parse(digest);
}
/// <inheritdoc />
public async Task<ImageManifest?> GetManifestAsync(
ConnectorContext context,
string repository,
string reference,
CancellationToken ct = default)
{
var client = await GetClientAsync(context, ct);
using var request = new HttpRequestMessage(
HttpMethod.Get,
$"v2/{repository}/manifests/{reference}");
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(
"application/vnd.oci.image.manifest.v1+json"));
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(
"application/vnd.docker.distribution.manifest.v2+json"));
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(
"application/vnd.oci.image.index.v1+json"));
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(
"application/vnd.docker.distribution.manifest.list.v2+json"));
using var response = await client.SendAsync(request, ct);
if (response.StatusCode == HttpStatusCode.NotFound)
return null;
response.EnsureSuccessStatusCode();
var digest = response.Headers.TryGetValues("Docker-Content-Digest", out var digestValues)
? digestValues.FirstOrDefault() ?? string.Empty
: string.Empty;
var mediaType = response.Content.Headers.ContentType?.MediaType ?? string.Empty;
var content = await response.Content.ReadAsStringAsync(ct);
var layers = ExtractLayersFromManifest(content, mediaType);
return new ImageManifest(
Digest: digest,
MediaType: mediaType,
Platform: null,
SizeBytes: response.Content.Headers.ContentLength ?? content.Length,
Layers: layers,
CreatedAt: null);
}
/// <inheritdoc />
public Task<PullCredentials> GetPullCredentialsAsync(
ConnectorContext context,
string repository,
CancellationToken ct = default)
{
// For OAuth2 token auth, use "oauth2accesstoken" as username convention
var username = !string.IsNullOrEmpty(_oauth2Token)
? "$oauthtoken"
: _username ?? string.Empty;
var password = !string.IsNullOrEmpty(_oauth2Token)
? _oauth2Token
: _password ?? string.Empty;
return Task.FromResult(new PullCredentials(
Registry: _quayHost,
Username: username,
Password: password,
ExpiresAt: null));
}
private async Task<HttpClient> GetClientAsync(
ConnectorContext context,
CancellationToken ct)
{
if (_httpClient is not null)
return _httpClient;
var config = context.Configuration;
if (!config.TryGetProperty("quayUrl", out var urlProp) ||
urlProp.ValueKind != JsonValueKind.String)
{
throw new InvalidOperationException("Quay URL not configured");
}
_quayUrl = urlProp.GetString()!.TrimEnd('/');
_quayHost = new Uri(_quayUrl).Host;
// Extract organization name if configured
if (config.TryGetProperty("organizationName", out var orgProp) &&
orgProp.ValueKind == JsonValueKind.String)
{
_organizationName = orgProp.GetString();
}
// Try OAuth2 token first
if (config.TryGetProperty("oauth2Token", out var oauth2TokenProp) &&
oauth2TokenProp.ValueKind == JsonValueKind.String)
{
_oauth2Token = oauth2TokenProp.GetString();
}
else if (config.TryGetProperty("oauth2TokenSecretRef", out var oauth2TokenRef) &&
oauth2TokenRef.ValueKind == JsonValueKind.String)
{
var secretPath = oauth2TokenRef.GetString();
if (!string.IsNullOrEmpty(secretPath))
{
_oauth2Token = await context.SecretResolver.ResolveAsync(secretPath, ct);
}
}
// Fall back to username/password
if (string.IsNullOrEmpty(_oauth2Token))
{
if (config.TryGetProperty("username", out var userProp) &&
userProp.ValueKind == JsonValueKind.String)
{
_username = userProp.GetString();
}
if (config.TryGetProperty("password", out var passProp) &&
passProp.ValueKind == JsonValueKind.String)
{
_password = passProp.GetString();
}
else if (config.TryGetProperty("passwordSecretRef", out var passRef) &&
passRef.ValueKind == JsonValueKind.String)
{
var secretPath = passRef.GetString();
if (!string.IsNullOrEmpty(secretPath))
{
_password = await context.SecretResolver.ResolveAsync(secretPath, ct);
}
}
}
_httpClient = new HttpClient
{
BaseAddress = new Uri(_quayUrl + "/")
};
// Set authorization header
if (!string.IsNullOrEmpty(_oauth2Token))
{
_httpClient.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", _oauth2Token);
}
else if (!string.IsNullOrEmpty(_username))
{
var credentials = Convert.ToBase64String(
Encoding.UTF8.GetBytes($"{_username}:{_password}"));
_httpClient.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Basic", credentials);
}
_httpClient.DefaultRequestHeaders.UserAgent.Add(
new ProductInfoHeaderValue("StellaOps", "1.0"));
return _httpClient;
}
private static IReadOnlyList<string> ExtractLayersFromManifest(string content, string mediaType)
{
try
{
using var doc = JsonDocument.Parse(content);
var root = doc.RootElement;
if (root.TryGetProperty("layers", out var layers))
{
return layers.EnumerateArray()
.Where(l => l.TryGetProperty("digest", out _))
.Select(l => l.GetProperty("digest").GetString()!)
.ToList();
}
return [];
}
catch
{
return [];
}
}
/// <inheritdoc />
public void Dispose()
{
if (_disposed)
return;
_httpClient?.Dispose();
_disposed = true;
}
}
// Quay API response models
internal sealed record QuayRepositoryList(
[property: JsonPropertyName("repositories")] QuayRepository[] Repositories,
[property: JsonPropertyName("next_page")] string? NextPage);
internal sealed record QuayRepository(
[property: JsonPropertyName("namespace")] string Namespace,
[property: JsonPropertyName("name")] string Name,
[property: JsonPropertyName("description")] string? Description,
[property: JsonPropertyName("is_public")] bool IsPublic,
[property: JsonPropertyName("tag_count")] int? TagCount,
[property: JsonPropertyName("last_modified")] DateTimeOffset? LastModified);
internal sealed record QuayTagList(
[property: JsonPropertyName("tags")] QuayTag[] Tags,
[property: JsonPropertyName("has_additional")] bool HasAdditional,
[property: JsonPropertyName("page")] int Page);
internal sealed record QuayTag(
[property: JsonPropertyName("name")] string Name,
[property: JsonPropertyName("manifest_digest")] string? ManifestDigest,
[property: JsonPropertyName("size")] long? Size,
[property: JsonPropertyName("last_modified")] DateTimeOffset? LastModified,
[property: JsonPropertyName("expiration")] DateTimeOffset? Expiration);

View File

@@ -0,0 +1,349 @@
using System.Text.Json;
using StellaOps.ReleaseOrchestrator.IntegrationHub.Connectors.Registry;
using StellaOps.ReleaseOrchestrator.Plugin.Models;
namespace StellaOps.ReleaseOrchestrator.IntegrationHub.Tests.Connectors.Registry;
[Trait("Category", "Unit")]
public sealed class JfrogArtifactoryConnectorTests
{
[Fact]
public void Category_ReturnsRegistry()
{
// Arrange
using var connector = new JfrogArtifactoryConnector();
// Assert
Assert.Equal(ConnectorCategory.Registry, connector.Category);
}
[Fact]
public void ConnectorType_ReturnsJfrogArtifactory()
{
// Arrange
using var connector = new JfrogArtifactoryConnector();
// Assert
Assert.Equal("jfrog-artifactory", connector.ConnectorType);
}
[Fact]
public void DisplayName_ReturnsJFrogArtifactory()
{
// Arrange
using var connector = new JfrogArtifactoryConnector();
// Assert
Assert.Equal("JFrog Artifactory", connector.DisplayName);
}
[Fact]
public void GetSupportedOperations_ReturnsExpectedOperations()
{
// Arrange
using var connector = new JfrogArtifactoryConnector();
// Act
var operations = connector.GetSupportedOperations();
// Assert
Assert.Contains("list_repos", operations);
Assert.Contains("list_tags", operations);
Assert.Contains("resolve_tag", operations);
Assert.Contains("get_manifest", operations);
Assert.Contains("pull_credentials", operations);
Assert.Contains("aql_query", operations);
}
[Fact]
public async Task ValidateConfigAsync_WithApiKey_ReturnsSuccess()
{
// Arrange
using var connector = new JfrogArtifactoryConnector();
var config = JsonDocument.Parse("""
{
"artifactoryUrl": "https://mycompany.jfrog.io",
"apiKey": "AKCp8myapikey123"
}
""").RootElement;
// Act
var result = await connector.ValidateConfigAsync(config, TestContext.Current.CancellationToken);
// Assert
Assert.True(result.IsValid);
Assert.Empty(result.Errors);
}
[Fact]
public async Task ValidateConfigAsync_WithApiKeySecretRef_ReturnsSuccess()
{
// Arrange
using var connector = new JfrogArtifactoryConnector();
var config = JsonDocument.Parse("""
{
"artifactoryUrl": "https://mycompany.jfrog.io",
"apiKeySecretRef": "vault://secrets/jfrog/apikey"
}
""").RootElement;
// Act
var result = await connector.ValidateConfigAsync(config, TestContext.Current.CancellationToken);
// Assert
Assert.True(result.IsValid);
Assert.Empty(result.Errors);
}
[Fact]
public async Task ValidateConfigAsync_WithAccessToken_ReturnsSuccess()
{
// Arrange
using var connector = new JfrogArtifactoryConnector();
var config = JsonDocument.Parse("""
{
"artifactoryUrl": "https://mycompany.jfrog.io",
"accessToken": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..."
}
""").RootElement;
// Act
var result = await connector.ValidateConfigAsync(config, TestContext.Current.CancellationToken);
// Assert
Assert.True(result.IsValid);
Assert.Empty(result.Errors);
}
[Fact]
public async Task ValidateConfigAsync_WithAccessTokenSecretRef_ReturnsSuccess()
{
// Arrange
using var connector = new JfrogArtifactoryConnector();
var config = JsonDocument.Parse("""
{
"artifactoryUrl": "https://mycompany.jfrog.io",
"accessTokenSecretRef": "vault://secrets/jfrog/token"
}
""").RootElement;
// Act
var result = await connector.ValidateConfigAsync(config, TestContext.Current.CancellationToken);
// Assert
Assert.True(result.IsValid);
Assert.Empty(result.Errors);
}
[Fact]
public async Task ValidateConfigAsync_WithUsernameAndPassword_ReturnsSuccess()
{
// Arrange
using var connector = new JfrogArtifactoryConnector();
var config = JsonDocument.Parse("""
{
"artifactoryUrl": "https://mycompany.jfrog.io",
"username": "deploy-user",
"password": "secretpassword123"
}
""").RootElement;
// Act
var result = await connector.ValidateConfigAsync(config, TestContext.Current.CancellationToken);
// Assert
Assert.True(result.IsValid);
Assert.Empty(result.Errors);
}
[Fact]
public async Task ValidateConfigAsync_WithPasswordSecretRef_ReturnsSuccess()
{
// Arrange
using var connector = new JfrogArtifactoryConnector();
var config = JsonDocument.Parse("""
{
"artifactoryUrl": "https://mycompany.jfrog.io",
"username": "deploy-user",
"passwordSecretRef": "vault://secrets/jfrog/password"
}
""").RootElement;
// Act
var result = await connector.ValidateConfigAsync(config, TestContext.Current.CancellationToken);
// Assert
Assert.True(result.IsValid);
Assert.Empty(result.Errors);
}
[Fact]
public async Task ValidateConfigAsync_WithRepository_ReturnsSuccess()
{
// Arrange
using var connector = new JfrogArtifactoryConnector();
var config = JsonDocument.Parse("""
{
"artifactoryUrl": "https://mycompany.jfrog.io",
"apiKey": "AKCp8myapikey123",
"repository": "docker-local"
}
""").RootElement;
// Act
var result = await connector.ValidateConfigAsync(config, TestContext.Current.CancellationToken);
// Assert
Assert.True(result.IsValid);
Assert.Empty(result.Errors);
}
[Theory]
[InlineData("local")]
[InlineData("remote")]
[InlineData("virtual")]
public async Task ValidateConfigAsync_WithValidRepositoryType_ReturnsSuccess(string repoType)
{
// Arrange
using var connector = new JfrogArtifactoryConnector();
var config = JsonDocument.Parse($$"""
{
"artifactoryUrl": "https://mycompany.jfrog.io",
"apiKey": "AKCp8myapikey123",
"repositoryType": "{{repoType}}"
}
""").RootElement;
// Act
var result = await connector.ValidateConfigAsync(config, TestContext.Current.CancellationToken);
// Assert
Assert.True(result.IsValid);
Assert.Empty(result.Errors);
}
[Fact]
public async Task ValidateConfigAsync_WithInvalidRepositoryType_ReturnsError()
{
// Arrange
using var connector = new JfrogArtifactoryConnector();
var config = JsonDocument.Parse("""
{
"artifactoryUrl": "https://mycompany.jfrog.io",
"apiKey": "AKCp8myapikey123",
"repositoryType": "invalid-type"
}
""").RootElement;
// Act
var result = await connector.ValidateConfigAsync(config, TestContext.Current.CancellationToken);
// Assert
Assert.False(result.IsValid);
Assert.Contains(result.Errors, e => e.Contains("repositoryType"));
}
[Fact]
public async Task ValidateConfigAsync_WithNoArtifactoryUrl_ReturnsError()
{
// Arrange
using var connector = new JfrogArtifactoryConnector();
var config = JsonDocument.Parse("""
{
"apiKey": "AKCp8myapikey123"
}
""").RootElement;
// Act
var result = await connector.ValidateConfigAsync(config, TestContext.Current.CancellationToken);
// Assert
Assert.False(result.IsValid);
Assert.Contains(result.Errors, e => e.Contains("artifactoryUrl"));
}
[Fact]
public async Task ValidateConfigAsync_WithInvalidArtifactoryUrl_ReturnsError()
{
// Arrange
using var connector = new JfrogArtifactoryConnector();
var config = JsonDocument.Parse("""
{
"artifactoryUrl": "not-a-url",
"apiKey": "AKCp8myapikey123"
}
""").RootElement;
// Act
var result = await connector.ValidateConfigAsync(config, TestContext.Current.CancellationToken);
// Assert
Assert.False(result.IsValid);
Assert.Contains(result.Errors, e => e.Contains("artifactoryUrl"));
}
[Fact]
public async Task ValidateConfigAsync_WithNoAuthentication_ReturnsError()
{
// Arrange
using var connector = new JfrogArtifactoryConnector();
var config = JsonDocument.Parse("""
{
"artifactoryUrl": "https://mycompany.jfrog.io"
}
""").RootElement;
// Act
var result = await connector.ValidateConfigAsync(config, TestContext.Current.CancellationToken);
// Assert
Assert.False(result.IsValid);
Assert.Contains(result.Errors, e => e.Contains("apiKey") || e.Contains("accessToken") || e.Contains("username"));
}
[Fact]
public async Task ValidateConfigAsync_WithUsernameButNoPassword_ReturnsError()
{
// Arrange
using var connector = new JfrogArtifactoryConnector();
var config = JsonDocument.Parse("""
{
"artifactoryUrl": "https://mycompany.jfrog.io",
"username": "deploy-user"
}
""").RootElement;
// Act
var result = await connector.ValidateConfigAsync(config, TestContext.Current.CancellationToken);
// Assert
Assert.False(result.IsValid);
// Should fail because username without password is incomplete
}
[Fact]
public async Task ValidateConfigAsync_WithEmptyConfig_ReturnsMultipleErrors()
{
// Arrange
using var connector = new JfrogArtifactoryConnector();
var config = JsonDocument.Parse("{}").RootElement;
// Act
var result = await connector.ValidateConfigAsync(config, TestContext.Current.CancellationToken);
// Assert
Assert.False(result.IsValid);
Assert.True(result.Errors.Count >= 2); // Missing artifactoryUrl and authentication
}
[Fact]
public void Dispose_CanBeCalledMultipleTimes()
{
// Arrange
var connector = new JfrogArtifactoryConnector();
// Act & Assert - should not throw
connector.Dispose();
connector.Dispose();
}
}

View File

@@ -0,0 +1,263 @@
using System.Text.Json;
using StellaOps.ReleaseOrchestrator.IntegrationHub.Connectors.Registry;
using StellaOps.ReleaseOrchestrator.Plugin.Models;
namespace StellaOps.ReleaseOrchestrator.IntegrationHub.Tests.Connectors.Registry;
[Trait("Category", "Unit")]
public sealed class QuayConnectorTests
{
[Fact]
public void Category_ReturnsRegistry()
{
// Arrange
using var connector = new QuayConnector();
// Assert
Assert.Equal(ConnectorCategory.Registry, connector.Category);
}
[Fact]
public void ConnectorType_ReturnsQuay()
{
// Arrange
using var connector = new QuayConnector();
// Assert
Assert.Equal("quay", connector.ConnectorType);
}
[Fact]
public void DisplayName_ReturnsQuayRegistry()
{
// Arrange
using var connector = new QuayConnector();
// Assert
Assert.Equal("Quay Registry", connector.DisplayName);
}
[Fact]
public void GetSupportedOperations_ReturnsExpectedOperations()
{
// Arrange
using var connector = new QuayConnector();
// Act
var operations = connector.GetSupportedOperations();
// Assert
Assert.Contains("list_repos", operations);
Assert.Contains("list_tags", operations);
Assert.Contains("resolve_tag", operations);
Assert.Contains("get_manifest", operations);
Assert.Contains("pull_credentials", operations);
}
[Fact]
public async Task ValidateConfigAsync_WithOAuth2Token_ReturnsSuccess()
{
// Arrange
using var connector = new QuayConnector();
var config = JsonDocument.Parse("""
{
"quayUrl": "https://quay.io",
"oauth2Token": "mytoken123"
}
""").RootElement;
// Act
var result = await connector.ValidateConfigAsync(config, TestContext.Current.CancellationToken);
// Assert
Assert.True(result.IsValid);
Assert.Empty(result.Errors);
}
[Fact]
public async Task ValidateConfigAsync_WithOAuth2TokenSecretRef_ReturnsSuccess()
{
// Arrange
using var connector = new QuayConnector();
var config = JsonDocument.Parse("""
{
"quayUrl": "https://quay.io",
"oauth2TokenSecretRef": "vault://secrets/quay/token"
}
""").RootElement;
// Act
var result = await connector.ValidateConfigAsync(config, TestContext.Current.CancellationToken);
// Assert
Assert.True(result.IsValid);
Assert.Empty(result.Errors);
}
[Fact]
public async Task ValidateConfigAsync_WithUsernameAndPassword_ReturnsSuccess()
{
// Arrange
using var connector = new QuayConnector();
var config = JsonDocument.Parse("""
{
"quayUrl": "https://quay.io",
"username": "robot$myorg+deploy",
"password": "robottoken123"
}
""").RootElement;
// Act
var result = await connector.ValidateConfigAsync(config, TestContext.Current.CancellationToken);
// Assert
Assert.True(result.IsValid);
Assert.Empty(result.Errors);
}
[Fact]
public async Task ValidateConfigAsync_WithPasswordSecretRef_ReturnsSuccess()
{
// Arrange
using var connector = new QuayConnector();
var config = JsonDocument.Parse("""
{
"quayUrl": "https://quay.io",
"username": "robot$myorg+deploy",
"passwordSecretRef": "vault://secrets/quay/password"
}
""").RootElement;
// Act
var result = await connector.ValidateConfigAsync(config, TestContext.Current.CancellationToken);
// Assert
Assert.True(result.IsValid);
Assert.Empty(result.Errors);
}
[Fact]
public async Task ValidateConfigAsync_WithOrganizationName_ReturnsSuccess()
{
// Arrange
using var connector = new QuayConnector();
var config = JsonDocument.Parse("""
{
"quayUrl": "https://quay.io",
"oauth2Token": "mytoken123",
"organizationName": "myorg"
}
""").RootElement;
// Act
var result = await connector.ValidateConfigAsync(config, TestContext.Current.CancellationToken);
// Assert
Assert.True(result.IsValid);
Assert.Empty(result.Errors);
}
[Fact]
public async Task ValidateConfigAsync_WithNoQuayUrl_ReturnsError()
{
// Arrange
using var connector = new QuayConnector();
var config = JsonDocument.Parse("""
{
"oauth2Token": "mytoken123"
}
""").RootElement;
// Act
var result = await connector.ValidateConfigAsync(config, TestContext.Current.CancellationToken);
// Assert
Assert.False(result.IsValid);
Assert.Contains(result.Errors, e => e.Contains("quayUrl"));
}
[Fact]
public async Task ValidateConfigAsync_WithInvalidQuayUrl_ReturnsError()
{
// Arrange
using var connector = new QuayConnector();
var config = JsonDocument.Parse("""
{
"quayUrl": "not-a-url",
"oauth2Token": "mytoken123"
}
""").RootElement;
// Act
var result = await connector.ValidateConfigAsync(config, TestContext.Current.CancellationToken);
// Assert
Assert.False(result.IsValid);
Assert.Contains(result.Errors, e => e.Contains("quayUrl"));
}
[Fact]
public async Task ValidateConfigAsync_WithNoAuthentication_ReturnsError()
{
// Arrange
using var connector = new QuayConnector();
var config = JsonDocument.Parse("""
{
"quayUrl": "https://quay.io"
}
""").RootElement;
// Act
var result = await connector.ValidateConfigAsync(config, TestContext.Current.CancellationToken);
// Assert
Assert.False(result.IsValid);
Assert.Contains(result.Errors, e => e.Contains("oauth2Token") || e.Contains("username"));
}
[Fact]
public async Task ValidateConfigAsync_WithUsernameButNoPassword_ReturnsError()
{
// Arrange
using var connector = new QuayConnector();
var config = JsonDocument.Parse("""
{
"quayUrl": "https://quay.io",
"username": "robot$myorg+deploy"
}
""").RootElement;
// Act
var result = await connector.ValidateConfigAsync(config, TestContext.Current.CancellationToken);
// Assert
Assert.False(result.IsValid);
// Should fail because username without password is incomplete
}
[Fact]
public async Task ValidateConfigAsync_WithEmptyConfig_ReturnsMultipleErrors()
{
// Arrange
using var connector = new QuayConnector();
var config = JsonDocument.Parse("{}").RootElement;
// Act
var result = await connector.ValidateConfigAsync(config, TestContext.Current.CancellationToken);
// Assert
Assert.False(result.IsValid);
Assert.True(result.Errors.Count >= 2); // Missing quayUrl and authentication
}
[Fact]
public void Dispose_CanBeCalledMultipleTimes()
{
// Arrange
var connector = new QuayConnector();
// Act & Assert - should not throw
connector.Dispose();
connector.Dispose();
}
}