255 lines
9.3 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|