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; /// /// OCI 1.1 Referrers API tests for registry compatibility. /// Tests the native referrers API and fallback tag discovery. /// [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 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); } } }