Files
git.stella-ops.org/src/__Tests/__Libraries/StellaOps.Infrastructure.Registry.Testing.Tests/ReferrersApiTests.cs
2026-01-28 02:30:48 +02:00

255 lines
9.3 KiB
C#

using System.Net;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using FluentAssertions;
using StellaOps.Infrastructure.Registry.Testing;
using Xunit;
namespace StellaOps.Infrastructure.Registry.Testing.Tests;
/// <summary>
/// OCI 1.1 Referrers API tests for registry compatibility.
/// Tests the native referrers API and fallback tag discovery.
/// </summary>
[Collection("RegistryCompatibility")]
[Trait("Category", "Integration")]
[Trait("Category", "RegistryCompatibility")]
public class ReferrersApiTests
{
private readonly RegistryCompatibilityFixture _fixture;
private readonly HttpClient _httpClient;
public ReferrersApiTests(RegistryCompatibilityFixture fixture)
{
_fixture = fixture;
_httpClient = new HttpClient();
}
[Theory]
[MemberData(nameof(RegistryTestData.ReferrersApiRegistryTypes), MemberType = typeof(RegistryTestData))]
public async Task Referrers_Endpoint_Returns_OCI_Index(string registryType)
{
var registry = GetRegistry(registryType);
if (registry == null)
{
return;
}
var repo = $"test/{registryType}/referrers";
var digest = await registry.PushTestImageAsync(repo, "base");
using var request = new HttpRequestMessage(HttpMethod.Get,
$"{registry.RegistryUrl}/v2/{repo}/referrers/{digest}");
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.oci.image.index.v1+json"));
ApplyAuth(request, registry);
var response = await _httpClient.SendAsync(request);
// Registries with referrers API should return 200 with OCI index or 404 with empty index
response.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.NotFound);
var content = await response.Content.ReadAsStringAsync();
content.Should().Contain("schemaVersion",
$"Registry {registryType} should return OCI index structure");
}
[Theory]
[MemberData(nameof(RegistryTestData.FallbackRegistryTypes), MemberType = typeof(RegistryTestData))]
public async Task Referrers_Endpoint_Not_Supported_Returns_404_Or_405(string registryType)
{
var registry = GetRegistry(registryType);
if (registry == null)
{
return;
}
var repo = $"test/{registryType}/fallback";
var digest = await registry.PushTestImageAsync(repo, "base");
using var request = new HttpRequestMessage(HttpMethod.Get,
$"{registry.RegistryUrl}/v2/{repo}/referrers/{digest}");
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.oci.image.index.v1+json"));
ApplyAuth(request, registry);
var response = await _httpClient.SendAsync(request);
// Registries without referrers API should return 404 or 405
response.StatusCode.Should().BeOneOf(
HttpStatusCode.NotFound,
HttpStatusCode.MethodNotAllowed,
HttpStatusCode.BadRequest);
}
[Theory]
[MemberData(nameof(RegistryTestData.ReferrersApiRegistryTypes), MemberType = typeof(RegistryTestData))]
public async Task Can_Push_Referrer_With_Subject(string registryType)
{
var registry = GetRegistry(registryType);
if (registry == null)
{
return;
}
var repo = $"test/{registryType}/with-referrer";
var baseDigest = await registry.PushTestImageAsync(repo, "base");
// Push a referrer artifact with subject field
var referrerDigest = await PushReferrerAsync(registry, repo, baseDigest,
"application/vnd.stellaops.test+json", "test content");
// Query referrers
using var request = new HttpRequestMessage(HttpMethod.Get,
$"{registry.RegistryUrl}/v2/{repo}/referrers/{baseDigest}");
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.oci.image.index.v1+json"));
ApplyAuth(request, registry);
var response = await _httpClient.SendAsync(request);
if (response.StatusCode == HttpStatusCode.OK)
{
var content = await response.Content.ReadAsStringAsync();
content.Should().Contain(referrerDigest.Replace("sha256:", ""),
$"Registry {registryType} referrers list should contain pushed referrer");
}
}
[Theory]
[MemberData(nameof(RegistryTestData.AllRegistryTypes), MemberType = typeof(RegistryTestData))]
public void Reports_Correct_Referrers_Api_Support(string registryType)
{
var registry = GetRegistry(registryType);
if (registry == null)
{
return;
}
var expectedSupport = registryType switch
{
"generic-oci" => false,
"zot" => true,
"distribution" => true,
"harbor" => true,
_ => false
};
registry.SupportsReferrersApi.Should().Be(expectedSupport,
$"Registry {registryType} should report correct referrers API support");
}
private IRegistryTestContainer? GetRegistry(string registryType)
{
return _fixture.Registries.FirstOrDefault(r => r.RegistryType == registryType);
}
private async Task<string> PushReferrerAsync(
IRegistryTestContainer registry,
string repo,
string subjectDigest,
string artifactType,
string content)
{
var contentBytes = Encoding.UTF8.GetBytes(content);
var contentDigest = ComputeDigest(contentBytes);
// Push blob
await PushBlobAsync(registry, repo, contentBytes, contentDigest);
// Create manifest with subject
var manifest = $$"""
{
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"artifactType": "{{artifactType}}",
"config": {
"mediaType": "application/vnd.oci.empty.v1+json",
"digest": "sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a",
"size": 2
},
"layers": [
{
"mediaType": "{{artifactType}}",
"digest": "{{contentDigest}}",
"size": {{contentBytes.Length}}
}
],
"subject": {
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"digest": "{{subjectDigest}}",
"size": 0
}
}
""";
var manifestBytes = Encoding.UTF8.GetBytes(manifest);
var manifestDigest = ComputeDigest(manifestBytes);
// Push empty config blob first
var emptyConfig = "{}"u8.ToArray();
var emptyConfigDigest = "sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a";
await PushBlobAsync(registry, repo, emptyConfig, emptyConfigDigest);
// Push manifest
using var request = new HttpRequestMessage(HttpMethod.Put,
$"{registry.RegistryUrl}/v2/{repo}/manifests/{manifestDigest}");
request.Content = new ByteArrayContent(manifestBytes);
request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/vnd.oci.image.manifest.v1+json");
ApplyAuth(request, registry);
var response = await _httpClient.SendAsync(request);
if (!response.IsSuccessStatusCode)
{
var error = await response.Content.ReadAsStringAsync();
throw new InvalidOperationException($"Failed to push referrer manifest: {response.StatusCode} - {error}");
}
return manifestDigest;
}
private async Task PushBlobAsync(IRegistryTestContainer registry, string repo, byte[] content, string digest)
{
// Initiate upload
using var initiateRequest = new HttpRequestMessage(HttpMethod.Post,
$"{registry.RegistryUrl}/v2/{repo}/blobs/uploads/");
ApplyAuth(initiateRequest, registry);
var initiateResponse = await _httpClient.SendAsync(initiateRequest);
if (initiateResponse.StatusCode != HttpStatusCode.Accepted)
{
return; // Blob might already exist
}
var location = initiateResponse.Headers.Location?.ToString();
if (location == null) return;
// Complete upload
var uploadUrl = location.Contains('?')
? $"{location}&digest={digest}"
: $"{location}?digest={digest}";
using var uploadRequest = new HttpRequestMessage(HttpMethod.Put, uploadUrl);
uploadRequest.Content = new ByteArrayContent(content);
uploadRequest.Content.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream");
ApplyAuth(uploadRequest, registry);
await _httpClient.SendAsync(uploadRequest);
}
private static string ComputeDigest(byte[] content)
{
var hash = SHA256.HashData(content);
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
}
private static void ApplyAuth(HttpRequestMessage request, IRegistryTestContainer registry)
{
if (registry.Username != null && registry.Password != null)
{
var credentials = Convert.ToBase64String(
Encoding.UTF8.GetBytes($"{registry.Username}:{registry.Password}"));
request.Headers.Authorization = new AuthenticationHeaderValue("Basic", credentials);
}
}
}