test fixes and new product advisories work
This commit is contained in:
@@ -0,0 +1,310 @@
|
||||
using System.Net;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins.Integration.Checks;
|
||||
using StellaOps.Doctor.Plugins.Integration.Tests.TestHelpers;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Integration.Tests.Checks;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class RegistryCapabilityProbeCheckTests
|
||||
{
|
||||
private readonly RegistryCapabilityProbeCheck _check = new();
|
||||
|
||||
[Fact]
|
||||
public void CheckId_ReturnsExpectedId()
|
||||
{
|
||||
_check.CheckId.Should().Be("check.integration.oci.capabilities");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Name_ReturnsExpectedName()
|
||||
{
|
||||
_check.Name.Should().Be("OCI Registry Capability Matrix");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultSeverity_IsInfo()
|
||||
{
|
||||
// Info because this is informational by default
|
||||
_check.DefaultSeverity.Should().Be(DoctorSeverity.Info);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tags_ContainsExpectedValues()
|
||||
{
|
||||
_check.Tags.Should().Contain("registry");
|
||||
_check.Tags.Should().Contain("oci");
|
||||
_check.Tags.Should().Contain("capabilities");
|
||||
_check.Tags.Should().Contain("compatibility");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanRun_ReturnsFalse_WhenNoRegistryUrlConfigured()
|
||||
{
|
||||
var context = DoctorPluginContextFactory.CreateWithoutHttpFactory(
|
||||
new Dictionary<string, string?>());
|
||||
|
||||
_check.CanRun(context).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanRun_ReturnsTrue_WhenRegistryUrlConfigured()
|
||||
{
|
||||
var handler = new MockHttpMessageHandler();
|
||||
var context = DoctorPluginContextFactory.Create(handler);
|
||||
|
||||
_check.CanRun(context).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ReturnsSkip_WhenHttpClientFactoryNotAvailable()
|
||||
{
|
||||
var context = DoctorPluginContextFactory.CreateWithoutHttpFactory();
|
||||
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
result.Severity.Should().Be(DoctorSeverity.Skip);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ReturnsResult_WithCapabilityEvidence()
|
||||
{
|
||||
var handler = CreateFullCapabilityHandler();
|
||||
|
||||
var context = DoctorPluginContextFactory.Create(handler);
|
||||
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Check produces a result (may be Pass, Info, or Warn depending on capabilities detected)
|
||||
result.Severity.Should().BeOneOf(DoctorSeverity.Pass, DoctorSeverity.Info, DoctorSeverity.Warn);
|
||||
result.Evidence.Data.Should().ContainKey("registry_url");
|
||||
result.Evidence.Data.Should().ContainKey("capability_score");
|
||||
result.Evidence.Data.Should().ContainKey("supports_referrers_api");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ReturnsWarn_WhenReferrersApiNotSupported()
|
||||
{
|
||||
var handler = new MockHttpMessageHandler()
|
||||
.QueueResponse(HttpStatusCode.OK, headers: new Dictionary<string, string>
|
||||
{
|
||||
["OCI-Distribution-API-Version"] = "registry/2.0"
|
||||
}) // Distribution version probe
|
||||
.QueueResponse(HttpStatusCode.MethodNotAllowed) // Referrers API - NOT supported
|
||||
.QueueAcceptedWithLocation("https://registry.example.com/v2/test/blobs/uploads/abc123") // Chunked upload
|
||||
.QueueResponse(HttpStatusCode.NoContent) // DELETE cleanup
|
||||
.QueueResponse(HttpStatusCode.Created) // Cross-repo mount
|
||||
.QueueResponse(HttpStatusCode.OK, headers: new Dictionary<string, string> { ["Allow"] = "GET, HEAD, DELETE" }) // Manifest delete
|
||||
.QueueResponse(HttpStatusCode.OK, headers: new Dictionary<string, string> { ["Allow"] = "GET, HEAD, DELETE" }); // Blob delete
|
||||
|
||||
var context = DoctorPluginContextFactory.Create(handler);
|
||||
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
result.Severity.Should().Be(DoctorSeverity.Warn);
|
||||
result.Evidence.Data["supports_referrers_api"].Should().Be("false");
|
||||
result.Diagnosis.Should().Contain("referrers");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ReturnsInfo_WhenSomeNonCriticalCapabilitiesMissing()
|
||||
{
|
||||
var handler = new MockHttpMessageHandler()
|
||||
.QueueResponse(HttpStatusCode.OK, headers: new Dictionary<string, string>
|
||||
{
|
||||
["OCI-Distribution-API-Version"] = "registry/2.0"
|
||||
}) // Distribution version probe
|
||||
.QueueOciIndexResponse() // Referrers API - supported
|
||||
.QueueAcceptedWithLocation("https://registry.example.com/v2/test/blobs/uploads/abc123") // Chunked upload
|
||||
.QueueResponse(HttpStatusCode.NoContent) // DELETE cleanup
|
||||
.QueueResponse(HttpStatusCode.NotFound) // Cross-repo mount - NOT supported
|
||||
.QueueResponse(HttpStatusCode.OK, headers: new Dictionary<string, string> { ["Allow"] = "GET, HEAD" }) // Manifest delete - NOT supported
|
||||
.QueueResponse(HttpStatusCode.OK, headers: new Dictionary<string, string> { ["Allow"] = "GET, HEAD" }); // Blob delete - NOT supported
|
||||
|
||||
var context = DoctorPluginContextFactory.Create(handler);
|
||||
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
result.Severity.Should().Be(DoctorSeverity.Info);
|
||||
result.Evidence.Data["capability_score"].Should().Be("2/5");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ProbesDistributionVersion()
|
||||
{
|
||||
var handler = CreateFullCapabilityHandler();
|
||||
|
||||
var context = DoctorPluginContextFactory.Create(handler);
|
||||
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
result.Evidence.Data["distribution_version"].Should().NotBe("unknown");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_DetectsDockerDistributionVersion()
|
||||
{
|
||||
var handler = new MockHttpMessageHandler()
|
||||
.QueueResponse(HttpStatusCode.OK, headers: new Dictionary<string, string>
|
||||
{
|
||||
["Docker-Distribution-API-Version"] = "registry/2.0"
|
||||
}) // Docker version header
|
||||
.QueueOciIndexResponse()
|
||||
.QueueAcceptedWithLocation("https://registry.example.com/v2/test/blobs/uploads/abc123")
|
||||
.QueueResponse(HttpStatusCode.NoContent)
|
||||
.QueueResponse(HttpStatusCode.Created)
|
||||
.QueueResponse(HttpStatusCode.OK, headers: new Dictionary<string, string> { ["Allow"] = "GET, HEAD, DELETE" })
|
||||
.QueueResponse(HttpStatusCode.OK, headers: new Dictionary<string, string> { ["Allow"] = "GET, HEAD, DELETE" });
|
||||
|
||||
var context = DoctorPluginContextFactory.Create(handler);
|
||||
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
result.Evidence.Data["distribution_version"].Should().Contain("Docker");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_DetectsReferrersApiSupport()
|
||||
{
|
||||
var handler = CreateFullCapabilityHandler();
|
||||
|
||||
var context = DoctorPluginContextFactory.Create(handler);
|
||||
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
result.Evidence.Data["supports_referrers_api"].Should().Be("true");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_DetectsChunkedUploadSupport()
|
||||
{
|
||||
var handler = CreateFullCapabilityHandler();
|
||||
|
||||
var context = DoctorPluginContextFactory.Create(handler);
|
||||
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
result.Evidence.Data["supports_chunked_upload"].Should().Be("true");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_DetectsCrossRepoMountSupport()
|
||||
{
|
||||
var handler = CreateFullCapabilityHandler();
|
||||
|
||||
var context = DoctorPluginContextFactory.Create(handler);
|
||||
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
result.Evidence.Data["supports_cross_repo_mount"].Should().Be("true");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_IncludesDeleteSupportEvidence()
|
||||
{
|
||||
var handler = CreateFullCapabilityHandler();
|
||||
|
||||
var context = DoctorPluginContextFactory.Create(handler);
|
||||
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Delete support evidence keys should be present
|
||||
result.Evidence.Data.Should().ContainKey("supports_manifest_delete");
|
||||
result.Evidence.Data.Should().ContainKey("supports_blob_delete");
|
||||
// Values may be "true", "false", or "unknown" depending on probe results
|
||||
result.Evidence.Data["supports_manifest_delete"].Should().BeOneOf("true", "false", "unknown");
|
||||
result.Evidence.Data["supports_blob_delete"].Should().BeOneOf("true", "false", "unknown");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_IncludesCapabilityScore()
|
||||
{
|
||||
var handler = CreateFullCapabilityHandler();
|
||||
|
||||
var context = DoctorPluginContextFactory.Create(handler);
|
||||
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
result.Evidence.Data["capability_score"].Should().MatchRegex(@"\d+/\d+");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_HandlesUnknownCapabilities()
|
||||
{
|
||||
// All probes return errors that make capability unknown
|
||||
var handler = new MockHttpMessageHandler()
|
||||
.QueueResponse(HttpStatusCode.InternalServerError) // Distribution version
|
||||
.QueueResponse(HttpStatusCode.InternalServerError) // Referrers API
|
||||
.QueueResponse(HttpStatusCode.Unauthorized) // Chunked upload
|
||||
.QueueResponse(HttpStatusCode.InternalServerError) // Cross-repo mount
|
||||
.QueueResponse(HttpStatusCode.InternalServerError) // Manifest delete
|
||||
.QueueResponse(HttpStatusCode.InternalServerError); // Blob delete
|
||||
|
||||
var context = DoctorPluginContextFactory.Create(handler);
|
||||
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Should still return a result with "unknown" values
|
||||
result.Evidence.Data.Values.Should().Contain("unknown");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_CancelsUploadSession_AfterProbe()
|
||||
{
|
||||
var handler = CreateFullCapabilityHandler();
|
||||
|
||||
var context = DoctorPluginContextFactory.Create(handler);
|
||||
|
||||
await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Should have a DELETE request after the POST for upload probe
|
||||
var methods = handler.CapturedRequests.Select(r => r.Method).ToList();
|
||||
var postIndex = methods.IndexOf(HttpMethod.Post);
|
||||
if (postIndex >= 0 && postIndex < methods.Count - 1)
|
||||
{
|
||||
methods[postIndex + 1].Should().Be(HttpMethod.Delete);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_AppliesAuthentication()
|
||||
{
|
||||
var handler = CreateFullCapabilityHandler();
|
||||
|
||||
var context = DoctorPluginContextFactory.Create(handler,
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
["OCI:RegistryUrl"] = "https://registry.example.com",
|
||||
["OCI:Username"] = "testuser",
|
||||
["OCI:Password"] = "testpass"
|
||||
});
|
||||
|
||||
await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// All requests should have auth header
|
||||
foreach (var request in handler.CapturedRequests)
|
||||
{
|
||||
request.Headers.Authorization.Should().NotBeNull();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a handler that returns success for all capability probes.
|
||||
/// </summary>
|
||||
private static MockHttpMessageHandler CreateFullCapabilityHandler()
|
||||
{
|
||||
return new MockHttpMessageHandler()
|
||||
.QueueResponse(HttpStatusCode.OK, headers: new Dictionary<string, string>
|
||||
{
|
||||
["OCI-Distribution-API-Version"] = "registry/2.0"
|
||||
}) // Distribution version probe
|
||||
.QueueOciIndexResponse() // Referrers API
|
||||
.QueueAcceptedWithLocation("https://registry.example.com/v2/test/blobs/uploads/abc123") // Chunked upload
|
||||
.QueueResponse(HttpStatusCode.NoContent) // DELETE cleanup for chunked upload
|
||||
.QueueResponse(HttpStatusCode.Created) // Cross-repo mount
|
||||
.QueueResponse(HttpStatusCode.OK, headers: new Dictionary<string, string> { ["Allow"] = "GET, HEAD, DELETE" }) // Manifest delete
|
||||
.QueueResponse(HttpStatusCode.OK, headers: new Dictionary<string, string> { ["Allow"] = "GET, HEAD, DELETE" }); // Blob delete
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
using System.Net;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins.Integration.Checks;
|
||||
using StellaOps.Doctor.Plugins.Integration.Tests.TestHelpers;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Integration.Tests.Checks;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class RegistryCredentialsCheckTests
|
||||
{
|
||||
private readonly RegistryCredentialsCheck _check = new();
|
||||
|
||||
[Fact]
|
||||
public void CheckId_ReturnsExpectedId()
|
||||
{
|
||||
_check.CheckId.Should().Be("check.integration.oci.credentials");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Name_ReturnsExpectedName()
|
||||
{
|
||||
_check.Name.Should().Be("OCI Registry Credentials");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultSeverity_IsFail()
|
||||
{
|
||||
_check.DefaultSeverity.Should().Be(DoctorSeverity.Fail);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tags_ContainsExpectedValues()
|
||||
{
|
||||
_check.Tags.Should().Contain("registry");
|
||||
_check.Tags.Should().Contain("oci");
|
||||
_check.Tags.Should().Contain("credentials");
|
||||
_check.Tags.Should().Contain("auth");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanRun_ReturnsFalse_WhenNoRegistryUrlConfigured()
|
||||
{
|
||||
var context = DoctorPluginContextFactory.CreateWithoutHttpFactory(
|
||||
new Dictionary<string, string?>());
|
||||
|
||||
_check.CanRun(context).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanRun_ReturnsTrue_WhenRegistryUrlConfigured()
|
||||
{
|
||||
var handler = new MockHttpMessageHandler();
|
||||
var context = DoctorPluginContextFactory.Create(handler,
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
["OCI:RegistryUrl"] = "https://registry.example.com"
|
||||
});
|
||||
|
||||
_check.CanRun(context).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ReturnsSkip_WhenHttpClientFactoryNotAvailable()
|
||||
{
|
||||
var context = DoctorPluginContextFactory.CreateWithoutHttpFactory();
|
||||
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
result.Severity.Should().Be(DoctorSeverity.Skip);
|
||||
result.Diagnosis.Should().Contain("IHttpClientFactory");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ReturnsFail_WhenUsernameProvidedWithoutPassword()
|
||||
{
|
||||
var handler = new MockHttpMessageHandler();
|
||||
var context = DoctorPluginContextFactory.Create(handler,
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
["OCI:RegistryUrl"] = "https://registry.example.com",
|
||||
["OCI:Username"] = "testuser"
|
||||
});
|
||||
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
result.Severity.Should().Be(DoctorSeverity.Fail);
|
||||
result.Diagnosis.Should().Contain("username provided without password");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ReturnsPass_WhenBasicAuthSucceeds()
|
||||
{
|
||||
var handler = new MockHttpMessageHandler()
|
||||
.QueueResponse(HttpStatusCode.OK);
|
||||
|
||||
var context = DoctorPluginContextFactory.Create(handler,
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
["OCI:RegistryUrl"] = "https://registry.example.com",
|
||||
["OCI:Username"] = "testuser",
|
||||
["OCI:Password"] = "testpass"
|
||||
});
|
||||
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
result.Severity.Should().Be(DoctorSeverity.Pass);
|
||||
result.Diagnosis.Should().Contain("valid");
|
||||
result.Evidence.Data["auth_method"].Should().Be("basic");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ReturnsPass_WhenBearerAuthSucceeds()
|
||||
{
|
||||
var handler = new MockHttpMessageHandler()
|
||||
.QueueResponse(HttpStatusCode.OK);
|
||||
|
||||
var context = DoctorPluginContextFactory.Create(handler,
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
["OCI:RegistryUrl"] = "https://registry.example.com",
|
||||
["OCI:Token"] = "test-bearer-token"
|
||||
});
|
||||
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
result.Severity.Should().Be(DoctorSeverity.Pass);
|
||||
result.Evidence.Data["auth_method"].Should().Be("bearer");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ReturnsPass_WhenAnonymousAccessAllowed()
|
||||
{
|
||||
var handler = new MockHttpMessageHandler()
|
||||
.QueueResponse(HttpStatusCode.OK);
|
||||
|
||||
var context = DoctorPluginContextFactory.Create(handler,
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
["OCI:RegistryUrl"] = "https://registry.example.com"
|
||||
});
|
||||
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
result.Severity.Should().Be(DoctorSeverity.Pass);
|
||||
result.Evidence.Data["auth_method"].Should().Be("anonymous");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ReturnsFail_WhenUnauthorized()
|
||||
{
|
||||
var handler = new MockHttpMessageHandler()
|
||||
.QueueResponse(HttpStatusCode.Unauthorized);
|
||||
|
||||
var context = DoctorPluginContextFactory.Create(handler,
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
["OCI:RegistryUrl"] = "https://registry.example.com",
|
||||
["OCI:Username"] = "baduser",
|
||||
["OCI:Password"] = "badpass"
|
||||
});
|
||||
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
result.Severity.Should().Be(DoctorSeverity.Fail);
|
||||
result.Diagnosis.Should().Contain("Authentication rejected");
|
||||
result.LikelyCauses.Should().Contain(c => c.Contains("invalid"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ReturnsPass_WhenOAuth2TokenExchangeRequired()
|
||||
{
|
||||
var response = new HttpResponseMessage(HttpStatusCode.Unauthorized);
|
||||
response.Headers.WwwAuthenticate.ParseAdd("Bearer realm=\"https://auth.example.com/token\"");
|
||||
|
||||
var handler = new MockHttpMessageHandler();
|
||||
handler.QueueResponse(HttpStatusCode.Unauthorized, headers: new Dictionary<string, string>
|
||||
{
|
||||
["WWW-Authenticate"] = "Bearer realm=\"https://auth.example.com/token\""
|
||||
});
|
||||
|
||||
var context = DoctorPluginContextFactory.Create(handler,
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
["OCI:RegistryUrl"] = "https://registry.example.com",
|
||||
["OCI:Username"] = "testuser",
|
||||
["OCI:Password"] = "testpass"
|
||||
});
|
||||
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// When basic auth returns 401 with Bearer WWW-Authenticate, credentials are considered valid
|
||||
// (OAuth2 token exchange is expected)
|
||||
result.Severity.Should().Be(DoctorSeverity.Pass);
|
||||
result.Diagnosis.Should().Contain("OAuth2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ReturnsFail_WhenForbidden()
|
||||
{
|
||||
var handler = new MockHttpMessageHandler()
|
||||
.QueueResponse(HttpStatusCode.Forbidden);
|
||||
|
||||
var context = DoctorPluginContextFactory.Create(handler,
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
["OCI:RegistryUrl"] = "https://registry.example.com",
|
||||
["OCI:Username"] = "testuser",
|
||||
["OCI:Password"] = "testpass"
|
||||
});
|
||||
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
result.Severity.Should().Be(DoctorSeverity.Fail);
|
||||
result.Diagnosis.Should().Contain("forbidden");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_RedactsPassword_InEvidence()
|
||||
{
|
||||
var handler = new MockHttpMessageHandler()
|
||||
.QueueResponse(HttpStatusCode.OK);
|
||||
|
||||
var context = DoctorPluginContextFactory.Create(handler,
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
["OCI:RegistryUrl"] = "https://registry.example.com",
|
||||
["OCI:Username"] = "testuser",
|
||||
["OCI:Password"] = "supersecretpassword"
|
||||
});
|
||||
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
result.Evidence.Data["password"].Should().NotBe("supersecretpassword");
|
||||
result.Evidence.Data["password"].Should().Contain("****");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_UsesAltConfigKeys()
|
||||
{
|
||||
var handler = new MockHttpMessageHandler()
|
||||
.QueueResponse(HttpStatusCode.OK);
|
||||
|
||||
var context = DoctorPluginContextFactory.Create(handler,
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
["Registry:Url"] = "https://registry.example.com",
|
||||
["Registry:Username"] = "testuser",
|
||||
["Registry:Password"] = "testpass"
|
||||
});
|
||||
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
result.Severity.Should().Be(DoctorSeverity.Pass);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
using System.Net;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins.Integration.Checks;
|
||||
using StellaOps.Doctor.Plugins.Integration.Tests.TestHelpers;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Integration.Tests.Checks;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class RegistryPullAuthorizationCheckTests
|
||||
{
|
||||
private readonly RegistryPullAuthorizationCheck _check = new();
|
||||
|
||||
[Fact]
|
||||
public void CheckId_ReturnsExpectedId()
|
||||
{
|
||||
_check.CheckId.Should().Be("check.integration.oci.pull");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Name_ReturnsExpectedName()
|
||||
{
|
||||
_check.Name.Should().Be("OCI Registry Pull Authorization");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultSeverity_IsFail()
|
||||
{
|
||||
_check.DefaultSeverity.Should().Be(DoctorSeverity.Fail);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tags_ContainsExpectedValues()
|
||||
{
|
||||
_check.Tags.Should().Contain("registry");
|
||||
_check.Tags.Should().Contain("oci");
|
||||
_check.Tags.Should().Contain("pull");
|
||||
_check.Tags.Should().Contain("authorization");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanRun_ReturnsFalse_WhenNoRegistryUrlConfigured()
|
||||
{
|
||||
var context = DoctorPluginContextFactory.CreateWithoutHttpFactory(
|
||||
new Dictionary<string, string?>());
|
||||
|
||||
_check.CanRun(context).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanRun_ReturnsTrue_WhenRegistryUrlConfigured()
|
||||
{
|
||||
var handler = new MockHttpMessageHandler();
|
||||
var context = DoctorPluginContextFactory.Create(handler);
|
||||
|
||||
_check.CanRun(context).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ReturnsSkip_WhenHttpClientFactoryNotAvailable()
|
||||
{
|
||||
var context = DoctorPluginContextFactory.CreateWithoutHttpFactory();
|
||||
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
result.Severity.Should().Be(DoctorSeverity.Skip);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ReturnsPass_WhenManifestHeadSucceeds()
|
||||
{
|
||||
var handler = new MockHttpMessageHandler()
|
||||
.QueueSuccessWithDigest("sha256:abc123");
|
||||
|
||||
var context = DoctorPluginContextFactory.Create(handler);
|
||||
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
result.Severity.Should().Be(DoctorSeverity.Pass);
|
||||
result.Diagnosis.Should().Contain("verified");
|
||||
result.Evidence.Data["pull_authorized"].Should().Be("true");
|
||||
result.Evidence.Data["manifest_digest"].Should().Be("sha256:abc123");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_UsesHeadRequest_ForNonDestructiveCheck()
|
||||
{
|
||||
var handler = new MockHttpMessageHandler()
|
||||
.QueueSuccessWithDigest("sha256:abc123");
|
||||
|
||||
var context = DoctorPluginContextFactory.Create(handler);
|
||||
|
||||
await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
handler.CapturedRequests.Should().ContainSingle();
|
||||
handler.CapturedRequests[0].Method.Should().Be(HttpMethod.Head);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ReturnsFail_WhenUnauthorized()
|
||||
{
|
||||
var handler = new MockHttpMessageHandler()
|
||||
.QueueResponse(HttpStatusCode.Unauthorized);
|
||||
|
||||
var context = DoctorPluginContextFactory.Create(handler);
|
||||
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
result.Severity.Should().Be(DoctorSeverity.Fail);
|
||||
result.Diagnosis.Should().Contain("Invalid credentials");
|
||||
result.Evidence.Data["pull_authorized"].Should().Be("false");
|
||||
result.Evidence.Data["http_status"].Should().Contain("401");
|
||||
result.LikelyCauses.Should().NotBeEmpty();
|
||||
result.Remediation.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ReturnsFail_WhenForbidden()
|
||||
{
|
||||
var handler = new MockHttpMessageHandler()
|
||||
.QueueResponse(HttpStatusCode.Forbidden);
|
||||
|
||||
var context = DoctorPluginContextFactory.Create(handler);
|
||||
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
result.Severity.Should().Be(DoctorSeverity.Fail);
|
||||
result.Diagnosis.Should().Contain("No pull permission");
|
||||
result.Evidence.Data["pull_authorized"].Should().Be("false");
|
||||
result.Evidence.Data["credentials_valid"].Should().Be("true");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ReturnsInfo_WhenImageNotFound()
|
||||
{
|
||||
var handler = new MockHttpMessageHandler()
|
||||
.QueueResponse(HttpStatusCode.NotFound);
|
||||
|
||||
var context = DoctorPluginContextFactory.Create(handler);
|
||||
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
result.Severity.Should().Be(DoctorSeverity.Info);
|
||||
result.Diagnosis.Should().Contain("test image not found");
|
||||
result.Evidence.Data["pull_authorized"].Should().Be("unknown");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_IncludesCorrectAcceptHeaders()
|
||||
{
|
||||
var handler = new MockHttpMessageHandler()
|
||||
.QueueSuccessWithDigest("sha256:abc123");
|
||||
|
||||
var context = DoctorPluginContextFactory.Create(handler);
|
||||
|
||||
await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
var request = handler.CapturedRequests[0];
|
||||
var acceptHeaders = request.Headers.Accept.Select(h => h.MediaType).ToList();
|
||||
|
||||
acceptHeaders.Should().Contain("application/vnd.oci.image.manifest.v1+json");
|
||||
acceptHeaders.Should().Contain("application/vnd.docker.distribution.manifest.v2+json");
|
||||
acceptHeaders.Should().Contain("application/vnd.oci.image.index.v1+json");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_UsesConfiguredTestRepository()
|
||||
{
|
||||
var handler = new MockHttpMessageHandler()
|
||||
.QueueSuccessWithDigest("sha256:abc123");
|
||||
|
||||
var context = DoctorPluginContextFactory.Create(handler,
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
["OCI:RegistryUrl"] = "https://registry.example.com",
|
||||
["OCI:TestRepository"] = "custom/repo",
|
||||
["OCI:TestTag"] = "v1.0"
|
||||
});
|
||||
|
||||
await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
handler.CapturedRequests[0].RequestUri!.ToString()
|
||||
.Should().Contain("custom/repo/manifests/v1.0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_AppliesBasicAuth_WhenConfigured()
|
||||
{
|
||||
var handler = new MockHttpMessageHandler()
|
||||
.QueueSuccessWithDigest("sha256:abc123");
|
||||
|
||||
var context = DoctorPluginContextFactory.Create(handler,
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
["OCI:RegistryUrl"] = "https://registry.example.com",
|
||||
["OCI:Username"] = "testuser",
|
||||
["OCI:Password"] = "testpass"
|
||||
});
|
||||
|
||||
await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
var request = handler.CapturedRequests[0];
|
||||
request.Headers.Authorization.Should().NotBeNull();
|
||||
request.Headers.Authorization!.Scheme.Should().Be("Basic");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_AppliesBearerAuth_WhenConfigured()
|
||||
{
|
||||
var handler = new MockHttpMessageHandler()
|
||||
.QueueSuccessWithDigest("sha256:abc123");
|
||||
|
||||
var context = DoctorPluginContextFactory.Create(handler,
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
["OCI:RegistryUrl"] = "https://registry.example.com",
|
||||
["OCI:Token"] = "my-bearer-token"
|
||||
});
|
||||
|
||||
await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
var request = handler.CapturedRequests[0];
|
||||
request.Headers.Authorization.Should().NotBeNull();
|
||||
request.Headers.Authorization!.Scheme.Should().Be("Bearer");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_IncludesVerificationCommand()
|
||||
{
|
||||
var handler = new MockHttpMessageHandler()
|
||||
.QueueResponse(HttpStatusCode.Unauthorized);
|
||||
|
||||
var context = DoctorPluginContextFactory.Create(handler);
|
||||
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
result.VerificationCommand.Should().Contain("check.integration.oci.pull");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
using System.Net;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins.Integration.Checks;
|
||||
using StellaOps.Doctor.Plugins.Integration.Tests.TestHelpers;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Integration.Tests.Checks;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class RegistryPushAuthorizationCheckTests
|
||||
{
|
||||
private readonly RegistryPushAuthorizationCheck _check = new();
|
||||
|
||||
[Fact]
|
||||
public void CheckId_ReturnsExpectedId()
|
||||
{
|
||||
_check.CheckId.Should().Be("check.integration.oci.push");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Name_ReturnsExpectedName()
|
||||
{
|
||||
_check.Name.Should().Be("OCI Registry Push Authorization");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultSeverity_IsFail()
|
||||
{
|
||||
_check.DefaultSeverity.Should().Be(DoctorSeverity.Fail);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tags_ContainsExpectedValues()
|
||||
{
|
||||
_check.Tags.Should().Contain("registry");
|
||||
_check.Tags.Should().Contain("oci");
|
||||
_check.Tags.Should().Contain("push");
|
||||
_check.Tags.Should().Contain("authorization");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanRun_ReturnsFalse_WhenNoRegistryUrlConfigured()
|
||||
{
|
||||
var context = DoctorPluginContextFactory.CreateWithoutHttpFactory(
|
||||
new Dictionary<string, string?>());
|
||||
|
||||
_check.CanRun(context).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanRun_ReturnsFalse_WhenNoAuthConfigured()
|
||||
{
|
||||
var handler = new MockHttpMessageHandler();
|
||||
var context = DoctorPluginContextFactory.Create(handler,
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
["OCI:RegistryUrl"] = "https://registry.example.com"
|
||||
// No auth configured
|
||||
});
|
||||
|
||||
_check.CanRun(context).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanRun_ReturnsTrue_WhenAuthConfigured()
|
||||
{
|
||||
var handler = new MockHttpMessageHandler();
|
||||
var context = DoctorPluginContextFactory.Create(handler,
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
["OCI:RegistryUrl"] = "https://registry.example.com",
|
||||
["OCI:Username"] = "testuser",
|
||||
["OCI:Password"] = "testpass"
|
||||
});
|
||||
|
||||
_check.CanRun(context).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanRun_ReturnsTrue_WhenTokenConfigured()
|
||||
{
|
||||
var handler = new MockHttpMessageHandler();
|
||||
var context = DoctorPluginContextFactory.Create(handler,
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
["OCI:RegistryUrl"] = "https://registry.example.com",
|
||||
["OCI:Token"] = "bearer-token"
|
||||
});
|
||||
|
||||
_check.CanRun(context).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ReturnsSkip_WhenHttpClientFactoryNotAvailable()
|
||||
{
|
||||
var context = DoctorPluginContextFactory.CreateWithoutHttpFactory(
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
["OCI:RegistryUrl"] = "https://registry.example.com",
|
||||
["OCI:Token"] = "bearer-token"
|
||||
});
|
||||
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
result.Severity.Should().Be(DoctorSeverity.Skip);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ReturnsPass_WhenUploadInitiationSucceeds()
|
||||
{
|
||||
var handler = new MockHttpMessageHandler()
|
||||
.QueueAcceptedWithLocation("https://registry.example.com/v2/test/blobs/uploads/abc123")
|
||||
.QueueResponse(HttpStatusCode.NoContent); // For DELETE cleanup
|
||||
|
||||
var context = DoctorPluginContextFactory.Create(handler,
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
["OCI:RegistryUrl"] = "https://registry.example.com",
|
||||
["OCI:Username"] = "testuser",
|
||||
["OCI:Password"] = "testpass"
|
||||
});
|
||||
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
result.Severity.Should().Be(DoctorSeverity.Pass);
|
||||
result.Diagnosis.Should().Contain("verified");
|
||||
result.Evidence.Data["push_authorized"].Should().Be("true");
|
||||
result.Evidence.Data["upload_session_cancelled"].Should().Be("true");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_CancelsUploadSession_AfterVerification()
|
||||
{
|
||||
var handler = new MockHttpMessageHandler()
|
||||
.QueueAcceptedWithLocation("https://registry.example.com/v2/test/blobs/uploads/abc123")
|
||||
.QueueResponse(HttpStatusCode.NoContent); // For DELETE cleanup
|
||||
|
||||
var context = DoctorPluginContextFactory.Create(handler,
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
["OCI:RegistryUrl"] = "https://registry.example.com",
|
||||
["OCI:Username"] = "testuser",
|
||||
["OCI:Password"] = "testpass"
|
||||
});
|
||||
|
||||
await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Should have POST (initiate) and DELETE (cancel) requests
|
||||
handler.CapturedRequests.Should().HaveCount(2);
|
||||
handler.CapturedRequests[0].Method.Should().Be(HttpMethod.Post);
|
||||
handler.CapturedRequests[1].Method.Should().Be(HttpMethod.Delete);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ReturnsFail_WhenUnauthorized()
|
||||
{
|
||||
var handler = new MockHttpMessageHandler()
|
||||
.QueueResponse(HttpStatusCode.Unauthorized);
|
||||
|
||||
var context = DoctorPluginContextFactory.Create(handler,
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
["OCI:RegistryUrl"] = "https://registry.example.com",
|
||||
["OCI:Username"] = "baduser",
|
||||
["OCI:Password"] = "badpass"
|
||||
});
|
||||
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
result.Severity.Should().Be(DoctorSeverity.Fail);
|
||||
result.Diagnosis.Should().Contain("Invalid credentials");
|
||||
result.Evidence.Data["push_authorized"].Should().Be("false");
|
||||
result.Evidence.Data["http_status"].Should().Contain("401");
|
||||
result.LikelyCauses.Should().Contain(c => c.Contains("invalid") || c.Contains("expired"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ReturnsFail_WhenForbidden()
|
||||
{
|
||||
var handler = new MockHttpMessageHandler()
|
||||
.QueueResponse(HttpStatusCode.Forbidden);
|
||||
|
||||
var context = DoctorPluginContextFactory.Create(handler,
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
["OCI:RegistryUrl"] = "https://registry.example.com",
|
||||
["OCI:Username"] = "testuser",
|
||||
["OCI:Password"] = "testpass"
|
||||
});
|
||||
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
result.Severity.Should().Be(DoctorSeverity.Fail);
|
||||
result.Diagnosis.Should().Contain("No push permission");
|
||||
result.Evidence.Data["push_authorized"].Should().Be("false");
|
||||
result.Evidence.Data["credentials_valid"].Should().Be("true");
|
||||
result.LikelyCauses.Should().Contain(c => c.Contains("permission"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_UsesCorrectUploadEndpoint()
|
||||
{
|
||||
var handler = new MockHttpMessageHandler()
|
||||
.QueueAcceptedWithLocation("https://registry.example.com/v2/test/blobs/uploads/abc123")
|
||||
.QueueResponse(HttpStatusCode.NoContent);
|
||||
|
||||
var context = DoctorPluginContextFactory.Create(handler,
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
["OCI:RegistryUrl"] = "https://registry.example.com",
|
||||
["OCI:TestRepository"] = "custom/push-test",
|
||||
["OCI:Username"] = "testuser",
|
||||
["OCI:Password"] = "testpass"
|
||||
});
|
||||
|
||||
await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
handler.CapturedRequests[0].RequestUri!.ToString()
|
||||
.Should().Contain("custom/push-test/blobs/uploads/");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_IncludesRemediationSteps_OnFailure()
|
||||
{
|
||||
var handler = new MockHttpMessageHandler()
|
||||
.QueueResponse(HttpStatusCode.Forbidden);
|
||||
|
||||
var context = DoctorPluginContextFactory.Create(handler,
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
["OCI:RegistryUrl"] = "https://registry.example.com",
|
||||
["OCI:Username"] = "testuser",
|
||||
["OCI:Password"] = "testpass"
|
||||
});
|
||||
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
result.Remediation.Should().NotBeNull();
|
||||
result.Remediation!.Steps.Should().NotBeEmpty();
|
||||
result.Remediation.RunbookUrl.Should().Contain("stella-ops.org");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_AppliesBasicAuth_WhenConfigured()
|
||||
{
|
||||
var handler = new MockHttpMessageHandler()
|
||||
.QueueAcceptedWithLocation("https://registry.example.com/v2/test/blobs/uploads/abc123")
|
||||
.QueueResponse(HttpStatusCode.NoContent);
|
||||
|
||||
var context = DoctorPluginContextFactory.Create(handler,
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
["OCI:RegistryUrl"] = "https://registry.example.com",
|
||||
["OCI:Username"] = "testuser",
|
||||
["OCI:Password"] = "testpass"
|
||||
});
|
||||
|
||||
await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
var request = handler.CapturedRequests[0];
|
||||
request.Headers.Authorization.Should().NotBeNull();
|
||||
request.Headers.Authorization!.Scheme.Should().Be("Basic");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_AppliesBearerAuth_WhenConfigured()
|
||||
{
|
||||
var handler = new MockHttpMessageHandler()
|
||||
.QueueAcceptedWithLocation("https://registry.example.com/v2/test/blobs/uploads/abc123")
|
||||
.QueueResponse(HttpStatusCode.NoContent);
|
||||
|
||||
var context = DoctorPluginContextFactory.Create(handler,
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
["OCI:RegistryUrl"] = "https://registry.example.com",
|
||||
["OCI:Token"] = "my-bearer-token"
|
||||
});
|
||||
|
||||
await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
var request = handler.CapturedRequests[0];
|
||||
request.Headers.Authorization.Should().NotBeNull();
|
||||
request.Headers.Authorization!.Scheme.Should().Be("Bearer");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
using System.Net;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins.Integration.Checks;
|
||||
using StellaOps.Doctor.Plugins.Integration.Tests.TestHelpers;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Integration.Tests.Checks;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class RegistryReferrersApiCheckTests
|
||||
{
|
||||
private readonly RegistryReferrersApiCheck _check = new();
|
||||
|
||||
[Fact]
|
||||
public void CheckId_ReturnsExpectedId()
|
||||
{
|
||||
_check.CheckId.Should().Be("check.integration.oci.referrers");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Name_ReturnsExpectedName()
|
||||
{
|
||||
_check.Name.Should().Be("OCI Registry Referrers API Support");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultSeverity_IsWarn()
|
||||
{
|
||||
// Warn because fallback is available
|
||||
_check.DefaultSeverity.Should().Be(DoctorSeverity.Warn);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tags_ContainsExpectedValues()
|
||||
{
|
||||
_check.Tags.Should().Contain("registry");
|
||||
_check.Tags.Should().Contain("oci");
|
||||
_check.Tags.Should().Contain("referrers");
|
||||
_check.Tags.Should().Contain("oci-1.1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanRun_ReturnsFalse_WhenNoRegistryUrlConfigured()
|
||||
{
|
||||
var context = DoctorPluginContextFactory.CreateWithoutHttpFactory(
|
||||
new Dictionary<string, string?>());
|
||||
|
||||
_check.CanRun(context).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanRun_ReturnsTrue_WhenRegistryUrlConfigured()
|
||||
{
|
||||
var handler = new MockHttpMessageHandler();
|
||||
var context = DoctorPluginContextFactory.Create(handler);
|
||||
|
||||
_check.CanRun(context).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ReturnsSkip_WhenHttpClientFactoryNotAvailable()
|
||||
{
|
||||
var context = DoctorPluginContextFactory.CreateWithoutHttpFactory();
|
||||
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
result.Severity.Should().Be(DoctorSeverity.Skip);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ReturnsInfo_WhenTestImageNotFound()
|
||||
{
|
||||
var handler = new MockHttpMessageHandler()
|
||||
.QueueResponse(HttpStatusCode.NotFound); // Manifest HEAD returns 404
|
||||
|
||||
var context = DoctorPluginContextFactory.Create(handler);
|
||||
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
result.Severity.Should().Be(DoctorSeverity.Info);
|
||||
result.Diagnosis.Should().Contain("test image not found");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ReturnsPass_WhenReferrersApiReturns200()
|
||||
{
|
||||
var handler = new MockHttpMessageHandler()
|
||||
.QueueSuccessWithDigest("sha256:abc123") // First: resolve manifest digest
|
||||
.QueueOciIndexResponse(HttpStatusCode.OK); // Then: referrers API returns index
|
||||
|
||||
var context = DoctorPluginContextFactory.Create(handler);
|
||||
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
result.Severity.Should().Be(DoctorSeverity.Pass);
|
||||
result.Diagnosis.Should().Contain("supported");
|
||||
result.Evidence.Data["referrers_supported"].Should().Be("true");
|
||||
result.Evidence.Data["fallback_required"].Should().Be("false");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ReturnsPass_When404WithOciIndex()
|
||||
{
|
||||
var handler = new MockHttpMessageHandler()
|
||||
.QueueSuccessWithDigest("sha256:abc123") // First: resolve manifest digest
|
||||
.QueueOciIndexResponse(HttpStatusCode.NotFound); // 404 but with OCI index content
|
||||
|
||||
var context = DoctorPluginContextFactory.Create(handler);
|
||||
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
result.Severity.Should().Be(DoctorSeverity.Pass);
|
||||
result.Diagnosis.Should().Contain("supported");
|
||||
result.Evidence.Data["referrers_supported"].Should().Be("true");
|
||||
result.Evidence.Data["referrers_count"].Should().Be("0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ReturnsWarn_When404WithoutOciIndex()
|
||||
{
|
||||
var handler = new MockHttpMessageHandler()
|
||||
.QueueSuccessWithDigest("sha256:abc123") // First: resolve manifest digest
|
||||
.QueueResponse(HttpStatusCode.NotFound, "{ \"errors\": [] }"); // Plain 404
|
||||
|
||||
var context = DoctorPluginContextFactory.Create(handler);
|
||||
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
result.Severity.Should().Be(DoctorSeverity.Warn);
|
||||
result.Diagnosis.Should().Contain("not supported");
|
||||
result.Diagnosis.Should().Contain("fallback");
|
||||
result.Evidence.Data["referrers_supported"].Should().Be("false");
|
||||
result.Evidence.Data["fallback_required"].Should().Be("true");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ReturnsWarn_WhenMethodNotAllowed()
|
||||
{
|
||||
var handler = new MockHttpMessageHandler()
|
||||
.QueueSuccessWithDigest("sha256:abc123") // First: resolve manifest digest
|
||||
.QueueResponse(HttpStatusCode.MethodNotAllowed); // 405
|
||||
|
||||
var context = DoctorPluginContextFactory.Create(handler);
|
||||
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
result.Severity.Should().Be(DoctorSeverity.Warn);
|
||||
result.Diagnosis.Should().Contain("not supported");
|
||||
result.Evidence.Data["referrers_supported"].Should().Be("false");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_IncludesOciVersionHeader()
|
||||
{
|
||||
var handler = new MockHttpMessageHandler()
|
||||
.QueueSuccessWithDigest("sha256:abc123");
|
||||
|
||||
// Queue response with OCI version header
|
||||
var response = new HttpResponseMessage(HttpStatusCode.OK);
|
||||
response.Content = new StringContent("""{"schemaVersion":2,"manifests":[]}""",
|
||||
System.Text.Encoding.UTF8, "application/vnd.oci.image.index.v1+json");
|
||||
response.Headers.TryAddWithoutValidation("OCI-Distribution-API-Version", "registry/2.0");
|
||||
|
||||
handler.QueueResponse(HttpStatusCode.OK, """{"schemaVersion":2,"manifests":[]}""",
|
||||
new Dictionary<string, string> { ["OCI-Distribution-API-Version"] = "registry/2.0" });
|
||||
|
||||
var context = DoctorPluginContextFactory.Create(handler);
|
||||
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
result.Evidence.Data["oci_version"].Should().Contain("registry/2.0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_IncludesRemediation_WhenApiNotSupported()
|
||||
{
|
||||
var handler = new MockHttpMessageHandler()
|
||||
.QueueSuccessWithDigest("sha256:abc123")
|
||||
.QueueResponse(HttpStatusCode.NotFound, "{ \"errors\": [] }");
|
||||
|
||||
var context = DoctorPluginContextFactory.Create(handler);
|
||||
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
result.Remediation.Should().NotBeNull();
|
||||
result.Remediation!.Steps.Should().NotBeEmpty();
|
||||
result.Remediation.RunbookUrl.Should().Contain("stella-ops.org");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ProbesCorrectEndpoint()
|
||||
{
|
||||
var handler = new MockHttpMessageHandler()
|
||||
.QueueSuccessWithDigest("sha256:abc123def456")
|
||||
.QueueOciIndexResponse();
|
||||
|
||||
var context = DoctorPluginContextFactory.Create(handler,
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
["OCI:RegistryUrl"] = "https://registry.example.com",
|
||||
["OCI:TestRepository"] = "myorg/myimage",
|
||||
["OCI:TestTag"] = "v1.0"
|
||||
});
|
||||
|
||||
await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Second request should be to referrers endpoint
|
||||
handler.CapturedRequests.Should().HaveCount(2);
|
||||
var referrersRequest = handler.CapturedRequests[1];
|
||||
referrersRequest.RequestUri!.ToString()
|
||||
.Should().Contain("/v2/myorg/myimage/referrers/sha256:abc123def456");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ResolvesManifestDigest_BeforeProbing()
|
||||
{
|
||||
var handler = new MockHttpMessageHandler()
|
||||
.QueueSuccessWithDigest("sha256:expecteddigest")
|
||||
.QueueOciIndexResponse();
|
||||
|
||||
var context = DoctorPluginContextFactory.Create(handler);
|
||||
|
||||
await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// First request should be HEAD to manifests endpoint
|
||||
var manifestRequest = handler.CapturedRequests[0];
|
||||
manifestRequest.Method.Should().Be(HttpMethod.Head);
|
||||
manifestRequest.RequestUri!.ToString().Should().Contain("/manifests/");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ReturnsFail_OnUnexpectedError()
|
||||
{
|
||||
var handler = new MockHttpMessageHandler()
|
||||
.QueueSuccessWithDigest("sha256:abc123")
|
||||
.QueueResponse(HttpStatusCode.InternalServerError);
|
||||
|
||||
var context = DoctorPluginContextFactory.Create(handler);
|
||||
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
result.Severity.Should().Be(DoctorSeverity.Fail);
|
||||
result.Diagnosis.Should().Contain("failed");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_IncludesVerificationCommand()
|
||||
{
|
||||
var handler = new MockHttpMessageHandler()
|
||||
.QueueSuccessWithDigest("sha256:abc123")
|
||||
.QueueResponse(HttpStatusCode.NotFound, "{ \"errors\": [] }");
|
||||
|
||||
var context = DoctorPluginContextFactory.Create(handler);
|
||||
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
result.VerificationCommand.Should().Contain("check.integration.oci.referrers");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_AppliesAuthentication()
|
||||
{
|
||||
var handler = new MockHttpMessageHandler()
|
||||
.QueueSuccessWithDigest("sha256:abc123")
|
||||
.QueueOciIndexResponse();
|
||||
|
||||
var context = DoctorPluginContextFactory.Create(handler,
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
["OCI:RegistryUrl"] = "https://registry.example.com",
|
||||
["OCI:Username"] = "testuser",
|
||||
["OCI:Password"] = "testpass"
|
||||
});
|
||||
|
||||
await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Both requests should have auth header
|
||||
foreach (var request in handler.CapturedRequests)
|
||||
{
|
||||
request.Headers.Authorization.Should().NotBeNull();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<IsPackable>false</IsPackable>
|
||||
<UseConcelierTestInfra>false</UseConcelierTestInfra>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Moq" />
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="coverlet.collector">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Doctor.Plugins.Integration\StellaOps.Doctor.Plugins.Integration.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,79 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Integration.Tests.TestHelpers;
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating DoctorPluginContext instances for testing.
|
||||
/// </summary>
|
||||
public static class DoctorPluginContextFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a context with mocked HTTP client factory.
|
||||
/// </summary>
|
||||
public static DoctorPluginContext Create(
|
||||
MockHttpMessageHandler httpHandler,
|
||||
IDictionary<string, string?>? configValues = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
configValues ??= new Dictionary<string, string?>
|
||||
{
|
||||
["OCI:RegistryUrl"] = "https://registry.example.com",
|
||||
["OCI:TestRepository"] = "test/image",
|
||||
["OCI:TestTag"] = "latest"
|
||||
};
|
||||
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(configValues)
|
||||
.Build();
|
||||
|
||||
var httpClient = new HttpClient(httpHandler);
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<IHttpClientFactory>(new MockHttpClientFactory(httpClient));
|
||||
|
||||
return new DoctorPluginContext
|
||||
{
|
||||
Services = services.BuildServiceProvider(),
|
||||
Configuration = configuration,
|
||||
TimeProvider = timeProvider ?? TimeProvider.System,
|
||||
Logger = NullLogger.Instance,
|
||||
EnvironmentName = "Test",
|
||||
PluginConfig = configuration.GetSection("Doctor:Plugins:Integration")
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a context without HTTP client factory (for skip tests).
|
||||
/// </summary>
|
||||
public static DoctorPluginContext CreateWithoutHttpFactory(IDictionary<string, string?>? configValues = null)
|
||||
{
|
||||
configValues ??= new Dictionary<string, string?>
|
||||
{
|
||||
["OCI:RegistryUrl"] = "https://registry.example.com"
|
||||
};
|
||||
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(configValues)
|
||||
.Build();
|
||||
|
||||
var services = new ServiceCollection();
|
||||
|
||||
return new DoctorPluginContext
|
||||
{
|
||||
Services = services.BuildServiceProvider(),
|
||||
Configuration = configuration,
|
||||
TimeProvider = TimeProvider.System,
|
||||
Logger = NullLogger.Instance,
|
||||
EnvironmentName = "Test",
|
||||
PluginConfig = configuration.GetSection("Doctor:Plugins:Integration")
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class MockHttpClientFactory(HttpClient httpClient) : IHttpClientFactory
|
||||
{
|
||||
public HttpClient CreateClient(string name) => httpClient;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
using System.Net;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Integration.Tests.TestHelpers;
|
||||
|
||||
/// <summary>
|
||||
/// Mock HTTP message handler for testing HTTP-based doctor checks.
|
||||
/// </summary>
|
||||
public sealed class MockHttpMessageHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly Queue<HttpResponseMessage> _responses = new();
|
||||
private readonly List<HttpRequestMessage> _capturedRequests = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets all captured requests for verification.
|
||||
/// </summary>
|
||||
public IReadOnlyList<HttpRequestMessage> CapturedRequests => _capturedRequests;
|
||||
|
||||
/// <summary>
|
||||
/// Queues a response to be returned for the next request.
|
||||
/// </summary>
|
||||
public MockHttpMessageHandler QueueResponse(HttpStatusCode statusCode, string? content = null, IDictionary<string, string>? headers = null)
|
||||
{
|
||||
var response = new HttpResponseMessage(statusCode);
|
||||
|
||||
if (content != null)
|
||||
{
|
||||
response.Content = new StringContent(content, System.Text.Encoding.UTF8, "application/json");
|
||||
}
|
||||
|
||||
if (headers != null)
|
||||
{
|
||||
foreach (var (key, value) in headers)
|
||||
{
|
||||
response.Headers.TryAddWithoutValidation(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
_responses.Enqueue(response);
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Queues a successful response with headers.
|
||||
/// </summary>
|
||||
public MockHttpMessageHandler QueueSuccessWithDigest(string digest)
|
||||
{
|
||||
var response = new HttpResponseMessage(HttpStatusCode.OK);
|
||||
response.Headers.TryAddWithoutValidation("Docker-Content-Digest", digest);
|
||||
response.Content = new StringContent("{}", System.Text.Encoding.UTF8, "application/vnd.oci.image.manifest.v1+json");
|
||||
_responses.Enqueue(response);
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Queues a 202 Accepted response with Location header for upload initiation.
|
||||
/// </summary>
|
||||
public MockHttpMessageHandler QueueAcceptedWithLocation(string location)
|
||||
{
|
||||
var response = new HttpResponseMessage(HttpStatusCode.Accepted);
|
||||
response.Headers.Location = new Uri(location, UriKind.RelativeOrAbsolute);
|
||||
_responses.Enqueue(response);
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Queues an OCI index response for referrers API.
|
||||
/// </summary>
|
||||
public MockHttpMessageHandler QueueOciIndexResponse(HttpStatusCode statusCode = HttpStatusCode.OK)
|
||||
{
|
||||
var ociIndex = """
|
||||
{
|
||||
"schemaVersion": 2,
|
||||
"mediaType": "application/vnd.oci.image.index.v1+json",
|
||||
"manifests": []
|
||||
}
|
||||
""";
|
||||
|
||||
var response = new HttpResponseMessage(statusCode);
|
||||
response.Content = new StringContent(ociIndex, System.Text.Encoding.UTF8, "application/vnd.oci.image.index.v1+json");
|
||||
response.Headers.TryAddWithoutValidation("OCI-Distribution-API-Version", "registry/2.0");
|
||||
_responses.Enqueue(response);
|
||||
return this;
|
||||
}
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
_capturedRequests.Add(request);
|
||||
|
||||
if (_responses.Count == 0)
|
||||
{
|
||||
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.InternalServerError)
|
||||
{
|
||||
Content = new StringContent("No more mocked responses available")
|
||||
});
|
||||
}
|
||||
|
||||
return Task.FromResult(_responses.Dequeue());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Infrastructure.Registry.Testing;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Infrastructure.Registry.Testing.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// OCI Distribution Spec compliance tests for all registry types.
|
||||
/// These tests verify basic OCI registry functionality across different implementations.
|
||||
/// </summary>
|
||||
[Collection("RegistryCompatibility")]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Category", "RegistryCompatibility")]
|
||||
public class OciComplianceTests
|
||||
{
|
||||
private readonly RegistryCompatibilityFixture _fixture;
|
||||
private readonly HttpClient _httpClient;
|
||||
|
||||
public OciComplianceTests(RegistryCompatibilityFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
_httpClient = new HttpClient();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(RegistryTestData.AllRegistryTypes), MemberType = typeof(RegistryTestData))]
|
||||
public async Task V2_Endpoint_Returns_200_Or_401(string registryType)
|
||||
{
|
||||
var registry = GetRegistry(registryType);
|
||||
if (registry == null)
|
||||
{
|
||||
return; // Skip if registry not available
|
||||
}
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, $"{registry.RegistryUrl}/v2/");
|
||||
ApplyAuth(request, registry);
|
||||
|
||||
var response = await _httpClient.SendAsync(request);
|
||||
|
||||
// 200 OK (authenticated) or 401 Unauthorized (needs auth) are both valid
|
||||
response.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.Unauthorized);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(RegistryTestData.AllRegistryTypes), MemberType = typeof(RegistryTestData))]
|
||||
public async Task Can_Push_Test_Image(string registryType)
|
||||
{
|
||||
var registry = GetRegistry(registryType);
|
||||
if (registry == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var digest = await registry.PushTestImageAsync($"test/{registryType}/compliance", "v1.0");
|
||||
|
||||
digest.Should().StartWith("sha256:", $"Registry {registryType} should return valid digest");
|
||||
digest.Should().HaveLength(71, $"SHA256 digest should be 'sha256:' + 64 hex chars");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(RegistryTestData.AllRegistryTypes), MemberType = typeof(RegistryTestData))]
|
||||
public async Task Can_Resolve_Manifest_Digest(string registryType)
|
||||
{
|
||||
var registry = GetRegistry(registryType);
|
||||
if (registry == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var repo = $"test/{registryType}/manifest";
|
||||
var tag = "latest";
|
||||
|
||||
// Push first
|
||||
var pushedDigest = await registry.PushTestImageAsync(repo, tag);
|
||||
|
||||
// Then resolve
|
||||
var resolvedDigest = await registry.GetManifestDigestAsync(repo, tag);
|
||||
|
||||
resolvedDigest.Should().Be(pushedDigest,
|
||||
$"Registry {registryType} should return same digest on resolution");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(RegistryTestData.AllRegistryTypes), MemberType = typeof(RegistryTestData))]
|
||||
public async Task Manifest_Head_Returns_Content_Digest_Header(string registryType)
|
||||
{
|
||||
var registry = GetRegistry(registryType);
|
||||
if (registry == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var repo = $"test/{registryType}/headers";
|
||||
var tag = "v1";
|
||||
|
||||
await registry.PushTestImageAsync(repo, tag);
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Head, $"{registry.RegistryUrl}/v2/{repo}/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"));
|
||||
ApplyAuth(request, registry);
|
||||
|
||||
var response = await _httpClient.SendAsync(request);
|
||||
|
||||
response.IsSuccessStatusCode.Should().BeTrue($"Registry {registryType} HEAD should succeed");
|
||||
response.Headers.Should().Contain(h => h.Key == "Docker-Content-Digest",
|
||||
$"Registry {registryType} should return Docker-Content-Digest header");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(RegistryTestData.AllRegistryTypes), MemberType = typeof(RegistryTestData))]
|
||||
public async Task Tags_List_Returns_Pushed_Tag(string registryType)
|
||||
{
|
||||
var registry = GetRegistry(registryType);
|
||||
if (registry == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var repo = $"test/{registryType}/tags";
|
||||
var tag = "test-tag-123";
|
||||
|
||||
await registry.PushTestImageAsync(repo, tag);
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, $"{registry.RegistryUrl}/v2/{repo}/tags/list");
|
||||
ApplyAuth(request, registry);
|
||||
|
||||
var response = await _httpClient.SendAsync(request);
|
||||
|
||||
response.IsSuccessStatusCode.Should().BeTrue($"Registry {registryType} should support tag listing");
|
||||
|
||||
var content = await response.Content.ReadAsStringAsync();
|
||||
content.Should().Contain(tag, $"Registry {registryType} tag list should contain pushed tag");
|
||||
}
|
||||
|
||||
private IRegistryTestContainer? GetRegistry(string registryType)
|
||||
{
|
||||
return _fixture.Registries.FirstOrDefault(r => r.RegistryType == registryType);
|
||||
}
|
||||
|
||||
private static void ApplyAuth(HttpRequestMessage request, IRegistryTestContainer registry)
|
||||
{
|
||||
if (registry.Username != null && registry.Password != null)
|
||||
{
|
||||
var credentials = Convert.ToBase64String(
|
||||
System.Text.Encoding.UTF8.GetBytes($"{registry.Username}:{registry.Password}"));
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Basic", credentials);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Infrastructure.Registry.Testing;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Infrastructure.Registry.Testing.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Authentication tests for registry compatibility.
|
||||
/// Verifies proper handling of authentication methods and error responses.
|
||||
/// </summary>
|
||||
[Collection("RegistryCompatibility")]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Category", "RegistryCompatibility")]
|
||||
public class RegistryAuthTests
|
||||
{
|
||||
private readonly RegistryCompatibilityFixture _fixture;
|
||||
private readonly HttpClient _httpClient;
|
||||
|
||||
public RegistryAuthTests(RegistryCompatibilityFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
_httpClient = new HttpClient();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(RegistryTestData.AllRegistryTypes), MemberType = typeof(RegistryTestData))]
|
||||
public async Task Anonymous_Access_Returns_200_Or_401(string registryType)
|
||||
{
|
||||
var registry = GetRegistry(registryType);
|
||||
if (registry == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Request without credentials
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, $"{registry.RegistryUrl}/v2/");
|
||||
// No auth applied
|
||||
|
||||
var response = await _httpClient.SendAsync(request);
|
||||
|
||||
// Anonymous access should either succeed (200) or require auth (401)
|
||||
response.StatusCode.Should().BeOneOf(
|
||||
HttpStatusCode.OK,
|
||||
HttpStatusCode.Unauthorized);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(RegistryTestData.AllRegistryTypes), MemberType = typeof(RegistryTestData))]
|
||||
public async Task Invalid_Credentials_Returns_401(string registryType)
|
||||
{
|
||||
var registry = GetRegistry(registryType);
|
||||
if (registry == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// First check if registry requires auth
|
||||
using var anonRequest = new HttpRequestMessage(HttpMethod.Get, $"{registry.RegistryUrl}/v2/");
|
||||
var anonResponse = await _httpClient.SendAsync(anonRequest);
|
||||
|
||||
if (anonResponse.StatusCode == HttpStatusCode.OK)
|
||||
{
|
||||
// Registry allows anonymous access, skip invalid auth test
|
||||
return;
|
||||
}
|
||||
|
||||
// Request with invalid credentials
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, $"{registry.RegistryUrl}/v2/");
|
||||
var invalidCreds = Convert.ToBase64String(
|
||||
Encoding.UTF8.GetBytes("invalid-user:invalid-password"));
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Basic", invalidCreds);
|
||||
|
||||
var response = await _httpClient.SendAsync(request);
|
||||
|
||||
// Invalid credentials should return 401 Unauthorized
|
||||
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized,
|
||||
$"Registry {registryType} should return 401 for invalid credentials");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(RegistryTestData.AllRegistryTypes), MemberType = typeof(RegistryTestData))]
|
||||
public async Task Valid_Credentials_Allow_V2_Access(string registryType)
|
||||
{
|
||||
var registry = GetRegistry(registryType);
|
||||
if (registry == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, $"{registry.RegistryUrl}/v2/");
|
||||
ApplyAuth(request, registry);
|
||||
|
||||
var response = await _httpClient.SendAsync(request);
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK,
|
||||
$"Registry {registryType} should return 200 for valid credentials");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(RegistryTestData.AllRegistryTypes), MemberType = typeof(RegistryTestData))]
|
||||
public async Task Auth_Required_Returns_WWW_Authenticate_Header(string registryType)
|
||||
{
|
||||
var registry = GetRegistry(registryType);
|
||||
if (registry == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Request without credentials
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, $"{registry.RegistryUrl}/v2/");
|
||||
|
||||
var response = await _httpClient.SendAsync(request);
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.Unauthorized)
|
||||
{
|
||||
// Should include WWW-Authenticate header for auth challenge
|
||||
response.Headers.Should().Contain(h => h.Key.Equals("WWW-Authenticate", StringComparison.OrdinalIgnoreCase),
|
||||
$"Registry {registryType} should return WWW-Authenticate header on 401");
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(RegistryTestData.AllRegistryTypes), MemberType = typeof(RegistryTestData))]
|
||||
public async Task Forbidden_Returns_403_For_Unauthorized_Repo(string registryType)
|
||||
{
|
||||
var registry = GetRegistry(registryType);
|
||||
if (registry == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to access a protected system repository (if exists)
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get,
|
||||
$"{registry.RegistryUrl}/v2/_catalog");
|
||||
// Intentionally no auth to test forbidden response
|
||||
|
||||
var response = await _httpClient.SendAsync(request);
|
||||
|
||||
// Should return 401 (needs auth) or 403 (forbidden) or 200 (catalog access allowed)
|
||||
response.StatusCode.Should().BeOneOf(
|
||||
HttpStatusCode.OK,
|
||||
HttpStatusCode.Unauthorized,
|
||||
HttpStatusCode.Forbidden,
|
||||
HttpStatusCode.NotFound); // Some registries don't support catalog
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(RegistryTestData.AllRegistryTypes), MemberType = typeof(RegistryTestData))]
|
||||
public async Task Basic_Auth_Scheme_Works(string registryType)
|
||||
{
|
||||
var registry = GetRegistry(registryType);
|
||||
if (registry == null || registry.Username == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, $"{registry.RegistryUrl}/v2/");
|
||||
|
||||
// Apply basic auth
|
||||
var credentials = Convert.ToBase64String(
|
||||
Encoding.UTF8.GetBytes($"{registry.Username}:{registry.Password}"));
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Basic", credentials);
|
||||
|
||||
var response = await _httpClient.SendAsync(request);
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK,
|
||||
$"Registry {registryType} should accept Basic auth");
|
||||
}
|
||||
|
||||
private IRegistryTestContainer? GetRegistry(string registryType)
|
||||
{
|
||||
return _fixture.Registries.FirstOrDefault(r => r.RegistryType == registryType);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Infrastructure.Registry.Testing;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Infrastructure.Registry.Testing.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Registry capability tests for the compatibility matrix.
|
||||
/// Tests advanced OCI features like chunked upload, cross-repo mount, and delete.
|
||||
/// </summary>
|
||||
[Collection("RegistryCompatibility")]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Category", "RegistryCompatibility")]
|
||||
public class RegistryCapabilityTests
|
||||
{
|
||||
private readonly RegistryCompatibilityFixture _fixture;
|
||||
private readonly HttpClient _httpClient;
|
||||
|
||||
public RegistryCapabilityTests(RegistryCompatibilityFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
_httpClient = new HttpClient();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(RegistryTestData.AllRegistryTypes), MemberType = typeof(RegistryTestData))]
|
||||
public async Task Supports_Chunked_Upload(string registryType)
|
||||
{
|
||||
var registry = GetRegistry(registryType);
|
||||
if (registry == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var repo = $"test/{registryType}/chunked";
|
||||
|
||||
// Initiate upload
|
||||
using var initiateRequest = new HttpRequestMessage(HttpMethod.Post,
|
||||
$"{registry.RegistryUrl}/v2/{repo}/blobs/uploads/");
|
||||
ApplyAuth(initiateRequest, registry);
|
||||
|
||||
var initiateResponse = await _httpClient.SendAsync(initiateRequest);
|
||||
|
||||
// 202 Accepted indicates chunked upload support
|
||||
initiateResponse.StatusCode.Should().Be(HttpStatusCode.Accepted,
|
||||
$"Registry {registryType} should support chunked uploads (202 Accepted)");
|
||||
|
||||
var location = initiateResponse.Headers.Location;
|
||||
location.Should().NotBeNull($"Registry {registryType} should return Location header");
|
||||
|
||||
// Clean up - cancel the upload
|
||||
if (location != null)
|
||||
{
|
||||
using var cancelRequest = new HttpRequestMessage(HttpMethod.Delete, location);
|
||||
ApplyAuth(cancelRequest, registry);
|
||||
await _httpClient.SendAsync(cancelRequest);
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(RegistryTestData.AllRegistryTypes), MemberType = typeof(RegistryTestData))]
|
||||
public async Task Supports_Manifest_Delete(string registryType)
|
||||
{
|
||||
var registry = GetRegistry(registryType);
|
||||
if (registry == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var repo = $"test/{registryType}/delete-manifest";
|
||||
var tag = "to-delete";
|
||||
|
||||
// Push an image first
|
||||
var digest = await registry.PushTestImageAsync(repo, tag);
|
||||
|
||||
// Try to delete the manifest
|
||||
using var deleteRequest = new HttpRequestMessage(HttpMethod.Delete,
|
||||
$"{registry.RegistryUrl}/v2/{repo}/manifests/{digest}");
|
||||
ApplyAuth(deleteRequest, registry);
|
||||
|
||||
var deleteResponse = await _httpClient.SendAsync(deleteRequest);
|
||||
|
||||
// 202 Accepted or 204 No Content indicates delete support
|
||||
// 405 Method Not Allowed or 400 Bad Request indicates no delete support
|
||||
deleteResponse.StatusCode.Should().BeOneOf(
|
||||
HttpStatusCode.Accepted,
|
||||
HttpStatusCode.NoContent,
|
||||
HttpStatusCode.MethodNotAllowed,
|
||||
HttpStatusCode.BadRequest,
|
||||
HttpStatusCode.NotFound); // Already deleted or not found is OK
|
||||
|
||||
var supportsDelete = deleteResponse.StatusCode is HttpStatusCode.Accepted or HttpStatusCode.NoContent;
|
||||
|
||||
// Most registries should support delete when enabled
|
||||
if (registryType is "generic-oci" or "zot" or "distribution" or "harbor")
|
||||
{
|
||||
supportsDelete.Should().BeTrue(
|
||||
$"Registry {registryType} should support manifest delete (got {deleteResponse.StatusCode})");
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(RegistryTestData.AllRegistryTypes), MemberType = typeof(RegistryTestData))]
|
||||
public async Task Returns_Distribution_Version_Header(string registryType)
|
||||
{
|
||||
var registry = GetRegistry(registryType);
|
||||
if (registry == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, $"{registry.RegistryUrl}/v2/");
|
||||
ApplyAuth(request, registry);
|
||||
|
||||
var response = await _httpClient.SendAsync(request);
|
||||
|
||||
// Check for either OCI or Docker distribution version header
|
||||
var hasOciVersion = response.Headers.Contains("OCI-Distribution-API-Version");
|
||||
var hasDockerVersion = response.Headers.Contains("Docker-Distribution-API-Version");
|
||||
|
||||
(hasOciVersion || hasDockerVersion).Should().BeTrue(
|
||||
$"Registry {registryType} should return distribution version header");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(RegistryTestData.AllRegistryTypes), MemberType = typeof(RegistryTestData))]
|
||||
public async Task Generates_Capability_Report(string registryType)
|
||||
{
|
||||
var registry = GetRegistry(registryType);
|
||||
if (registry == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Collect all capabilities
|
||||
var capabilities = new Dictionary<string, string>
|
||||
{
|
||||
["registry_type"] = registryType,
|
||||
["registry_url"] = registry.RegistryUrl,
|
||||
["supports_referrers_api"] = registry.SupportsReferrersApi.ToString().ToLowerInvariant()
|
||||
};
|
||||
|
||||
// Test chunked upload
|
||||
capabilities["supports_chunked_upload"] = await TestChunkedUploadAsync(registry);
|
||||
|
||||
// Test delete
|
||||
capabilities["supports_manifest_delete"] = await TestManifestDeleteAsync(registry);
|
||||
|
||||
// Test distribution version
|
||||
capabilities["distribution_version"] = await GetDistributionVersionAsync(registry);
|
||||
|
||||
// Output capability report (useful for CI reporting)
|
||||
var report = string.Join(", ", capabilities.Select(kv => $"{kv.Key}={kv.Value}"));
|
||||
|
||||
// This assertion always passes - the purpose is to generate the report
|
||||
report.Should().NotBeNullOrEmpty($"Registry {registryType} capability report generated: {report}");
|
||||
}
|
||||
|
||||
private async Task<string> TestChunkedUploadAsync(IRegistryTestContainer registry)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post,
|
||||
$"{registry.RegistryUrl}/v2/test/capability-probe/blobs/uploads/");
|
||||
ApplyAuth(request, registry);
|
||||
|
||||
var response = await _httpClient.SendAsync(request);
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.Accepted)
|
||||
{
|
||||
// Clean up
|
||||
var location = response.Headers.Location;
|
||||
if (location != null)
|
||||
{
|
||||
using var cancel = new HttpRequestMessage(HttpMethod.Delete, location);
|
||||
ApplyAuth(cancel, registry);
|
||||
await _httpClient.SendAsync(cancel);
|
||||
}
|
||||
return "true";
|
||||
}
|
||||
|
||||
return "false";
|
||||
}
|
||||
catch
|
||||
{
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string> TestManifestDeleteAsync(IRegistryTestContainer registry)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Use OPTIONS to check if DELETE is allowed (non-destructive)
|
||||
using var request = new HttpRequestMessage(HttpMethod.Options,
|
||||
$"{registry.RegistryUrl}/v2/test/capability-probe/manifests/latest");
|
||||
ApplyAuth(request, registry);
|
||||
|
||||
var response = await _httpClient.SendAsync(request);
|
||||
|
||||
if (response.Headers.TryGetValues("Allow", out var allowValues))
|
||||
{
|
||||
var allow = string.Join(",", allowValues);
|
||||
return allow.Contains("DELETE", StringComparison.OrdinalIgnoreCase) ? "true" : "false";
|
||||
}
|
||||
|
||||
return "unknown";
|
||||
}
|
||||
catch
|
||||
{
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string> GetDistributionVersionAsync(IRegistryTestContainer registry)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, $"{registry.RegistryUrl}/v2/");
|
||||
ApplyAuth(request, registry);
|
||||
|
||||
var response = await _httpClient.SendAsync(request);
|
||||
|
||||
if (response.Headers.TryGetValues("OCI-Distribution-API-Version", out var ociValues))
|
||||
{
|
||||
return $"OCI {string.Join(",", ociValues)}";
|
||||
}
|
||||
|
||||
if (response.Headers.TryGetValues("Docker-Distribution-API-Version", out var dockerValues))
|
||||
{
|
||||
return $"Docker {string.Join(",", dockerValues)}";
|
||||
}
|
||||
|
||||
return "unknown";
|
||||
}
|
||||
catch
|
||||
{
|
||||
return "error";
|
||||
}
|
||||
}
|
||||
|
||||
private IRegistryTestContainer? GetRegistry(string registryType)
|
||||
{
|
||||
return _fixture.Registries.FirstOrDefault(r => r.RegistryType == registryType);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" ?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<IsPackable>false</IsPackable>
|
||||
<RootNamespace>StellaOps.Infrastructure.Registry.Testing.Tests</RootNamespace>
|
||||
<UseConcelierTestInfra>false</UseConcelierTestInfra>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="coverlet.collector">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Infrastructure.Registry.Testing\StellaOps.Infrastructure.Registry.Testing.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,53 @@
|
||||
using DotNet.Testcontainers.Builders;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Infrastructure.Registry.Testing.Containers;
|
||||
|
||||
/// <summary>
|
||||
/// CNCF Distribution registry container - the reference implementation.
|
||||
/// Uses the edge tag which includes partial referrers API support.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Distribution is the reference implementation for OCI Distribution Spec.
|
||||
/// The edge version includes experimental referrers API support.
|
||||
/// </remarks>
|
||||
public sealed class DistributionRegistryContainer : RegistryTestContainerBase
|
||||
{
|
||||
private const string DefaultImage = "distribution/distribution:edge";
|
||||
private const int RegistryPort = 5000;
|
||||
|
||||
private readonly string _image;
|
||||
private int _mappedPort;
|
||||
|
||||
public DistributionRegistryContainer(string image = DefaultImage, ILogger? logger = null)
|
||||
: base(logger)
|
||||
{
|
||||
_image = image;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string RegistryType => "distribution";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string RegistryUrl => $"http://localhost:{_mappedPort}";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override bool SupportsReferrersApi => true; // Edge version has partial support
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task StartAsync(CancellationToken ct = default)
|
||||
{
|
||||
Container = new ContainerBuilder()
|
||||
.WithImage(_image)
|
||||
.WithPortBinding(RegistryPort, true)
|
||||
.WithEnvironment("REGISTRY_STORAGE_DELETE_ENABLED", "true")
|
||||
.WithWaitStrategy(Wait.ForUnixContainer().UntilHttpRequestIsSucceeded(r => r.ForPath("/v2/").ForPort(RegistryPort)))
|
||||
.Build();
|
||||
|
||||
await Container.StartAsync(ct);
|
||||
|
||||
_mappedPort = Container.GetMappedPublicPort(RegistryPort);
|
||||
|
||||
Logger.LogInformation("Started Distribution registry at {Url}", RegistryUrl);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using DotNet.Testcontainers.Builders;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Infrastructure.Registry.Testing.Containers;
|
||||
|
||||
/// <summary>
|
||||
/// Generic OCI registry container using the official Docker Registry (registry:2).
|
||||
/// Good for baseline OCI compliance testing.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Note: registry:2 does NOT support the OCI referrers API.
|
||||
/// </remarks>
|
||||
public sealed class GenericOciRegistryContainer : RegistryTestContainerBase
|
||||
{
|
||||
private const string DefaultImage = "registry:2.8";
|
||||
private const int RegistryPort = 5000;
|
||||
|
||||
private readonly string _image;
|
||||
private int _mappedPort;
|
||||
|
||||
public GenericOciRegistryContainer(string image = DefaultImage, ILogger? logger = null)
|
||||
: base(logger)
|
||||
{
|
||||
_image = image;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string RegistryType => "generic-oci";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string RegistryUrl => $"http://localhost:{_mappedPort}";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override bool SupportsReferrersApi => false;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task StartAsync(CancellationToken ct = default)
|
||||
{
|
||||
Container = new ContainerBuilder()
|
||||
.WithImage(_image)
|
||||
.WithPortBinding(RegistryPort, true)
|
||||
.WithEnvironment("REGISTRY_STORAGE_DELETE_ENABLED", "true")
|
||||
.WithWaitStrategy(Wait.ForUnixContainer().UntilHttpRequestIsSucceeded(r => r.ForPath("/v2/").ForPort(RegistryPort)))
|
||||
.Build();
|
||||
|
||||
await Container.StartAsync(ct);
|
||||
|
||||
_mappedPort = Container.GetMappedPublicPort(RegistryPort);
|
||||
|
||||
Logger.LogInformation("Started Generic OCI registry at {Url}", RegistryUrl);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
using System.Net;
|
||||
using DotNet.Testcontainers.Builders;
|
||||
using DotNet.Testcontainers.Containers;
|
||||
using DotNet.Testcontainers.Networks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Infrastructure.Registry.Testing.Containers;
|
||||
|
||||
/// <summary>
|
||||
/// Harbor registry container - enterprise-grade OCI registry.
|
||||
/// Uses a simplified single-container setup for testing.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Harbor is a complex multi-service registry. For integration testing,
|
||||
/// we use a simplified setup. For full Harbor testing, use docker-compose.
|
||||
///
|
||||
/// Harbor 2.6+ supports the OCI referrers API natively.
|
||||
/// </remarks>
|
||||
public sealed class HarborRegistryContainer : RegistryTestContainerBase
|
||||
{
|
||||
// Note: Harbor is complex and typically requires multiple containers.
|
||||
// For simplified testing, we use the harbor-registry-only image which
|
||||
// provides basic OCI registry functionality without the full Harbor stack.
|
||||
// For full Harbor testing, consider using docker-compose with Testcontainers.
|
||||
|
||||
private const string DefaultImage = "goharbor/registry-photon:v2.10.3";
|
||||
private const int RegistryPort = 5000;
|
||||
|
||||
private readonly string _image;
|
||||
private int _mappedPort;
|
||||
|
||||
public HarborRegistryContainer(string image = DefaultImage, ILogger? logger = null)
|
||||
: base(logger)
|
||||
{
|
||||
_image = image;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string RegistryType => "harbor";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string RegistryUrl => $"http://localhost:{_mappedPort}";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override bool SupportsReferrersApi => true; // Harbor 2.6+ supports referrers
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task StartAsync(CancellationToken ct = default)
|
||||
{
|
||||
// Harbor registry component with OCI support enabled
|
||||
Container = new ContainerBuilder()
|
||||
.WithImage(_image)
|
||||
.WithPortBinding(RegistryPort, true)
|
||||
.WithEnvironment("REGISTRY_STORAGE_DELETE_ENABLED", "true")
|
||||
.WithWaitStrategy(Wait.ForUnixContainer().UntilHttpRequestIsSucceeded(r => r.ForPath("/v2/").ForPort(RegistryPort)))
|
||||
.Build();
|
||||
|
||||
await Container.StartAsync(ct);
|
||||
|
||||
_mappedPort = Container.GetMappedPublicPort(RegistryPort);
|
||||
|
||||
Logger.LogInformation("Started Harbor registry at {Url}", RegistryUrl);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Full Harbor stack container using docker-compose.
|
||||
/// Use this for complete Harbor integration testing with all features.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This implementation starts the full Harbor stack including:
|
||||
/// - Core API
|
||||
/// - Portal (UI)
|
||||
/// - Registry
|
||||
/// - Database
|
||||
/// - Redis
|
||||
/// - Job Service
|
||||
///
|
||||
/// This requires more resources and takes longer to start.
|
||||
/// </remarks>
|
||||
public sealed class HarborFullStackContainer : RegistryTestContainerBase
|
||||
{
|
||||
private const int HarborPort = 80;
|
||||
private const string DefaultAdminPassword = "Harbor12345";
|
||||
|
||||
private INetwork? _network;
|
||||
private IContainer? _dbContainer;
|
||||
private IContainer? _redisContainer;
|
||||
private IContainer? _registryContainer;
|
||||
private IContainer? _coreContainer;
|
||||
private int _mappedPort;
|
||||
|
||||
public HarborFullStackContainer(ILogger? logger = null)
|
||||
: base(logger)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string RegistryType => "harbor-full";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string RegistryUrl => $"http://localhost:{_mappedPort}";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string? Username => "admin";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string? Password => DefaultAdminPassword;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override bool SupportsReferrersApi => true;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task StartAsync(CancellationToken ct = default)
|
||||
{
|
||||
// Create network for Harbor components
|
||||
_network = new NetworkBuilder()
|
||||
.WithName($"harbor-test-{Guid.NewGuid():N}")
|
||||
.Build();
|
||||
|
||||
await _network.CreateAsync(ct);
|
||||
|
||||
// Start PostgreSQL for Harbor
|
||||
_dbContainer = new ContainerBuilder()
|
||||
.WithImage("postgres:15-alpine")
|
||||
.WithNetwork(_network)
|
||||
.WithNetworkAliases("harbor-db")
|
||||
.WithEnvironment("POSTGRES_DB", "registry")
|
||||
.WithEnvironment("POSTGRES_USER", "postgres")
|
||||
.WithEnvironment("POSTGRES_PASSWORD", "root123")
|
||||
.WithWaitStrategy(Wait.ForUnixContainer().UntilCommandIsCompleted("pg_isready"))
|
||||
.Build();
|
||||
|
||||
await _dbContainer.StartAsync(ct);
|
||||
|
||||
// Start Redis for Harbor
|
||||
_redisContainer = new ContainerBuilder()
|
||||
.WithImage("redis:7-alpine")
|
||||
.WithNetwork(_network)
|
||||
.WithNetworkAliases("harbor-redis")
|
||||
.WithWaitStrategy(Wait.ForUnixContainer().UntilCommandIsCompleted("redis-cli", "ping"))
|
||||
.Build();
|
||||
|
||||
await _redisContainer.StartAsync(ct);
|
||||
|
||||
// Start Harbor registry component
|
||||
_registryContainer = new ContainerBuilder()
|
||||
.WithImage("goharbor/registry-photon:v2.10.3")
|
||||
.WithNetwork(_network)
|
||||
.WithNetworkAliases("harbor-registry")
|
||||
.WithEnvironment("REGISTRY_STORAGE_DELETE_ENABLED", "true")
|
||||
.Build();
|
||||
|
||||
await _registryContainer.StartAsync(ct);
|
||||
|
||||
// Start Harbor core
|
||||
_coreContainer = new ContainerBuilder()
|
||||
.WithImage("goharbor/harbor-core:v2.10.3")
|
||||
.WithNetwork(_network)
|
||||
.WithNetworkAliases("harbor-core")
|
||||
.WithPortBinding(HarborPort, true)
|
||||
.WithEnvironment("CORE_SECRET", Guid.NewGuid().ToString("N"))
|
||||
.WithEnvironment("HARBOR_ADMIN_PASSWORD", DefaultAdminPassword)
|
||||
.WithEnvironment("DATABASE_TYPE", "postgresql")
|
||||
.WithEnvironment("POSTGRESQL_HOST", "harbor-db")
|
||||
.WithEnvironment("POSTGRESQL_PORT", "5432")
|
||||
.WithEnvironment("POSTGRESQL_DATABASE", "registry")
|
||||
.WithEnvironment("POSTGRESQL_USERNAME", "postgres")
|
||||
.WithEnvironment("POSTGRESQL_PASSWORD", "root123")
|
||||
.WithEnvironment("REDIS_URL", "redis://harbor-redis:6379")
|
||||
.WithEnvironment("REGISTRY_URL", "http://harbor-registry:5000")
|
||||
.Build();
|
||||
|
||||
await _coreContainer.StartAsync(ct);
|
||||
|
||||
Container = _coreContainer;
|
||||
_mappedPort = _coreContainer.GetMappedPublicPort(HarborPort);
|
||||
|
||||
Logger.LogInformation("Started Harbor full stack at {Url}", RegistryUrl);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_coreContainer != null) await _coreContainer.DisposeAsync();
|
||||
if (_registryContainer != null) await _registryContainer.DisposeAsync();
|
||||
if (_redisContainer != null) await _redisContainer.DisposeAsync();
|
||||
if (_dbContainer != null) await _dbContainer.DisposeAsync();
|
||||
if (_network != null) await _network.DeleteAsync();
|
||||
|
||||
await base.DisposeAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
using DotNet.Testcontainers.Builders;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Infrastructure.Registry.Testing.Containers;
|
||||
|
||||
/// <summary>
|
||||
/// Zot registry container - OCI-native registry with full OCI 1.1 support.
|
||||
/// Excellent for testing referrers API and OCI compliance.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Zot is a vendor-neutral OCI registry that supports:
|
||||
/// - OCI Distribution Spec 1.1 (referrers API)
|
||||
/// - OCI Image Spec 1.1 (artifact type)
|
||||
/// - Full OCI compliance
|
||||
/// </remarks>
|
||||
public sealed class ZotRegistryContainer : RegistryTestContainerBase
|
||||
{
|
||||
private const string DefaultImage = "ghcr.io/project-zot/zot-linux-amd64:v2.1.1";
|
||||
private const int RegistryPort = 5000;
|
||||
|
||||
private readonly string _image;
|
||||
private int _mappedPort;
|
||||
|
||||
public ZotRegistryContainer(string image = DefaultImage, ILogger? logger = null)
|
||||
: base(logger)
|
||||
{
|
||||
_image = image;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string RegistryType => "zot";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string RegistryUrl => $"http://localhost:{_mappedPort}";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override bool SupportsReferrersApi => true;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task StartAsync(CancellationToken ct = default)
|
||||
{
|
||||
// Zot uses a config file - create minimal config
|
||||
var configJson = """
|
||||
{
|
||||
"distSpecVersion": "1.1.0",
|
||||
"storage": {
|
||||
"rootDirectory": "/var/lib/registry"
|
||||
},
|
||||
"http": {
|
||||
"address": "0.0.0.0",
|
||||
"port": "5000"
|
||||
},
|
||||
"log": {
|
||||
"level": "info"
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var configBytes = System.Text.Encoding.UTF8.GetBytes(configJson);
|
||||
|
||||
Container = new ContainerBuilder()
|
||||
.WithImage(_image)
|
||||
.WithPortBinding(RegistryPort, true)
|
||||
.WithResourceMapping(configBytes, "/etc/zot/config.json")
|
||||
.WithWaitStrategy(Wait.ForUnixContainer().UntilHttpRequestIsSucceeded(r => r.ForPath("/v2/").ForPort(RegistryPort)))
|
||||
.Build();
|
||||
|
||||
await Container.StartAsync(ct);
|
||||
|
||||
_mappedPort = Container.GetMappedPublicPort(RegistryPort);
|
||||
|
||||
Logger.LogInformation("Started Zot registry at {Url}", RegistryUrl);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
namespace StellaOps.Infrastructure.Registry.Testing;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for OCI registry test containers.
|
||||
/// Provides a common abstraction for different registry implementations.
|
||||
/// </summary>
|
||||
public interface IRegistryTestContainer : IAsyncDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the registry type identifier.
|
||||
/// </summary>
|
||||
string RegistryType { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the registry URL for API access.
|
||||
/// </summary>
|
||||
string RegistryUrl { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the username for authentication (if required).
|
||||
/// </summary>
|
||||
string? Username { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the password for authentication (if required).
|
||||
/// </summary>
|
||||
string? Password { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether the registry supports the OCI referrers API.
|
||||
/// </summary>
|
||||
bool SupportsReferrersApi { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether the registry is ready for use.
|
||||
/// </summary>
|
||||
bool IsReady { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Starts the registry container.
|
||||
/// </summary>
|
||||
Task StartAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Waits for the registry to be ready to accept requests.
|
||||
/// </summary>
|
||||
Task<bool> WaitForReadyAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Pushes a test image to the registry for testing purposes.
|
||||
/// </summary>
|
||||
/// <param name="repository">Repository name (e.g., "test/image").</param>
|
||||
/// <param name="tag">Tag name (e.g., "latest").</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The digest of the pushed image.</returns>
|
||||
Task<string> PushTestImageAsync(string repository, string tag, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the manifest digest for an image.
|
||||
/// </summary>
|
||||
Task<string?> GetManifestDigestAsync(string repository, string tag, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registry capability information for test matrix reporting.
|
||||
/// </summary>
|
||||
public sealed record RegistryCapabilities
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the registry supports the OCI referrers API.
|
||||
/// </summary>
|
||||
public bool SupportsReferrersApi { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the registry supports chunked uploads.
|
||||
/// </summary>
|
||||
public bool SupportsChunkedUpload { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the registry supports cross-repository blob mounting.
|
||||
/// </summary>
|
||||
public bool SupportsCrossRepoMount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the registry supports manifest deletion.
|
||||
/// </summary>
|
||||
public bool SupportsManifestDelete { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the registry supports blob deletion.
|
||||
/// </summary>
|
||||
public bool SupportsBlobDelete { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The OCI Distribution version string.
|
||||
/// </summary>
|
||||
public string? DistributionVersion { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
# StellaOps.Infrastructure.Registry.Testing
|
||||
|
||||
OCI Registry test infrastructure for StellaOps integration tests using Testcontainers.
|
||||
|
||||
## Overview
|
||||
|
||||
This library provides Testcontainers-based infrastructure for testing against multiple OCI registry implementations. It enables comprehensive registry compatibility testing across different registry types.
|
||||
|
||||
## Supported Registries
|
||||
|
||||
| Registry | Class | Image | Referrers API |
|
||||
|----------|-------|-------|---------------|
|
||||
| Generic OCI | `GenericOciRegistryContainer` | `registry:2.8` | No |
|
||||
| Zot | `ZotRegistryContainer` | `ghcr.io/project-zot/zot-linux-amd64:v2.1.1` | Yes |
|
||||
| Distribution | `DistributionRegistryContainer` | `distribution/distribution:edge` | Partial |
|
||||
| Harbor | `HarborRegistryContainer` | `goharbor/registry-photon:v2.10.3` | Yes |
|
||||
|
||||
## Usage
|
||||
|
||||
### Single Registry Test
|
||||
|
||||
```csharp
|
||||
public class MyRegistryTests : IAsyncLifetime
|
||||
{
|
||||
private readonly ZotRegistryContainer _registry = new();
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
await _registry.StartAsync();
|
||||
await _registry.WaitForReadyAsync();
|
||||
}
|
||||
|
||||
public async Task DisposeAsync() => await _registry.DisposeAsync();
|
||||
|
||||
[Fact]
|
||||
public async Task CanPushAndPullImage()
|
||||
{
|
||||
var digest = await _registry.PushTestImageAsync("test/image", "v1.0");
|
||||
var resolvedDigest = await _registry.GetManifestDigestAsync("test/image", "v1.0");
|
||||
Assert.Equal(digest, resolvedDigest);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Compatibility Matrix Tests
|
||||
|
||||
```csharp
|
||||
[Collection("RegistryCompatibility")]
|
||||
public class CompatibilityTests
|
||||
{
|
||||
private readonly RegistryCompatibilityFixture _fixture;
|
||||
|
||||
public CompatibilityTests(RegistryCompatibilityFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(RegistryTestData.AllRegistryTypes), MemberType = typeof(RegistryTestData))]
|
||||
public async Task AllRegistries_SupportBasicPushPull(string registryType)
|
||||
{
|
||||
var registry = _fixture.Registries.First(r => r.RegistryType == registryType);
|
||||
var digest = await registry.PushTestImageAsync("test/compat", "latest");
|
||||
Assert.NotNull(digest);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Adding a New Registry Type
|
||||
|
||||
1. Create a new class inheriting from `RegistryTestContainerBase`:
|
||||
|
||||
```csharp
|
||||
public sealed class MyRegistryContainer : RegistryTestContainerBase
|
||||
{
|
||||
private const string DefaultImage = "myregistry:latest";
|
||||
private const int RegistryPort = 5000;
|
||||
private int _mappedPort;
|
||||
|
||||
public MyRegistryContainer(string image = DefaultImage, ILogger? logger = null)
|
||||
: base(logger)
|
||||
{
|
||||
}
|
||||
|
||||
public override string RegistryType => "my-registry";
|
||||
public override string RegistryUrl => $"http://localhost:{_mappedPort}";
|
||||
public override bool SupportsReferrersApi => true; // Set based on registry capability
|
||||
|
||||
public override async Task StartAsync(CancellationToken ct = default)
|
||||
{
|
||||
Container = new ContainerBuilder()
|
||||
.WithImage(DefaultImage)
|
||||
.WithPortBinding(RegistryPort, true)
|
||||
.WithWaitStrategy(Wait.ForUnixContainer()
|
||||
.UntilHttpRequestIsSucceeded(r => r.ForPath("/v2/").ForPort(RegistryPort)))
|
||||
.Build();
|
||||
|
||||
await Container.StartAsync(ct);
|
||||
_mappedPort = Container.GetMappedPublicPort(RegistryPort);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. If authentication is required, override the credential properties:
|
||||
|
||||
```csharp
|
||||
public override string? Username => "admin";
|
||||
public override string? Password => "secret";
|
||||
```
|
||||
|
||||
3. Add the new container to `RegistryCompatibilityFixture.InitializeAsync()`:
|
||||
|
||||
```csharp
|
||||
var containers = new IRegistryTestContainer[]
|
||||
{
|
||||
// ... existing containers ...
|
||||
new MyRegistryContainer(logger: _logger)
|
||||
};
|
||||
```
|
||||
|
||||
4. Add test data entries if needed:
|
||||
|
||||
```csharp
|
||||
public static TheoryData<string> AllRegistryTypes => new()
|
||||
{
|
||||
// ... existing types ...
|
||||
"my-registry"
|
||||
};
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Custom Images
|
||||
|
||||
Pass a custom image to any container:
|
||||
|
||||
```csharp
|
||||
var registry = new ZotRegistryContainer(image: "ghcr.io/project-zot/zot-linux-amd64:v2.0.0");
|
||||
```
|
||||
|
||||
### Logging
|
||||
|
||||
Pass an ILogger for diagnostic output:
|
||||
|
||||
```csharp
|
||||
var logger = loggerFactory.CreateLogger<MyTests>();
|
||||
var registry = new GenericOciRegistryContainer(logger: logger);
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
- Docker Desktop or compatible container runtime
|
||||
- .NET 10.0+
|
||||
- Testcontainers package
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Docker Not Available
|
||||
|
||||
Tests will be skipped with a message if Docker is not available. Ensure Docker Desktop is running.
|
||||
|
||||
### Port Conflicts
|
||||
|
||||
The library uses dynamic port binding. If you see connection errors, ensure no firewall rules are blocking Docker port mapping.
|
||||
|
||||
### Slow Startup
|
||||
|
||||
Registry containers may take 10-30 seconds to become ready. The `WaitForReadyAsync()` method handles this automatically.
|
||||
@@ -0,0 +1,211 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Infrastructure.Registry.Testing.Containers;
|
||||
using Xunit;
|
||||
using Xunit.Sdk;
|
||||
|
||||
namespace StellaOps.Infrastructure.Registry.Testing;
|
||||
|
||||
/// <summary>
|
||||
/// Test fixture that starts multiple registry containers for compatibility testing.
|
||||
/// Implements IAsyncLifetime for xUnit integration.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This fixture starts all configured registries in parallel for efficient testing.
|
||||
/// Use the <see cref="Registries"/> property to access running registry containers.
|
||||
/// </remarks>
|
||||
public class RegistryCompatibilityFixture : IAsyncLifetime
|
||||
{
|
||||
private readonly List<IRegistryTestContainer> _registries = [];
|
||||
private readonly ILogger _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the list of running registry containers.
|
||||
/// </summary>
|
||||
public IReadOnlyList<IRegistryTestContainer> Registries => _registries;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the generic OCI registry container.
|
||||
/// </summary>
|
||||
public IRegistryTestContainer? GenericOci => _registries.FirstOrDefault(r => r.RegistryType == "generic-oci");
|
||||
|
||||
/// <summary>
|
||||
/// Gets the Zot registry container.
|
||||
/// </summary>
|
||||
public IRegistryTestContainer? Zot => _registries.FirstOrDefault(r => r.RegistryType == "zot");
|
||||
|
||||
/// <summary>
|
||||
/// Gets the Distribution registry container.
|
||||
/// </summary>
|
||||
public IRegistryTestContainer? Distribution => _registries.FirstOrDefault(r => r.RegistryType == "distribution");
|
||||
|
||||
/// <summary>
|
||||
/// Gets the Harbor registry container.
|
||||
/// </summary>
|
||||
public IRegistryTestContainer? Harbor => _registries.FirstOrDefault(r => r.RegistryType == "harbor");
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new registry compatibility fixture.
|
||||
/// </summary>
|
||||
public RegistryCompatibilityFixture()
|
||||
: this(NullLogger.Instance)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new registry compatibility fixture with the specified logger.
|
||||
/// </summary>
|
||||
public RegistryCompatibilityFixture(ILogger logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts all registry containers.
|
||||
/// </summary>
|
||||
public virtual async ValueTask InitializeAsync()
|
||||
{
|
||||
var containers = new IRegistryTestContainer[]
|
||||
{
|
||||
new GenericOciRegistryContainer(logger: _logger),
|
||||
new ZotRegistryContainer(logger: _logger),
|
||||
new DistributionRegistryContainer(logger: _logger),
|
||||
new HarborRegistryContainer(logger: _logger)
|
||||
};
|
||||
|
||||
// Start all containers in parallel
|
||||
var startTasks = containers.Select(StartContainerAsync).ToList();
|
||||
|
||||
try
|
||||
{
|
||||
await Task.WhenAll(startTasks);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Some registry containers failed to start");
|
||||
|
||||
// Dispose any successfully started containers
|
||||
foreach (var container in _registries.ToList())
|
||||
{
|
||||
try
|
||||
{
|
||||
await container.DisposeAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore cleanup failures
|
||||
}
|
||||
}
|
||||
|
||||
_registries.Clear();
|
||||
|
||||
// Check if Docker is available
|
||||
if (ex.Message.Contains("Docker", StringComparison.OrdinalIgnoreCase) ||
|
||||
ex.InnerException?.Message.Contains("Docker", StringComparison.OrdinalIgnoreCase) == true)
|
||||
{
|
||||
throw SkipException.ForSkip(
|
||||
$"Registry compatibility tests require Docker. Skipping: {ex.Message}");
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task StartContainerAsync(IRegistryTestContainer container)
|
||||
{
|
||||
try
|
||||
{
|
||||
await container.StartAsync();
|
||||
var ready = await container.WaitForReadyAsync();
|
||||
|
||||
if (ready)
|
||||
{
|
||||
lock (_registries)
|
||||
{
|
||||
_registries.Add(container);
|
||||
}
|
||||
_logger.LogInformation("Registry {Type} started successfully at {Url}",
|
||||
container.RegistryType, container.RegistryUrl);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Registry {Type} failed to become ready, disposing",
|
||||
container.RegistryType);
|
||||
await container.DisposeAsync();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to start registry {Type}", container.RegistryType);
|
||||
await container.DisposeAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops and disposes all registry containers.
|
||||
/// </summary>
|
||||
public virtual async ValueTask DisposeAsync()
|
||||
{
|
||||
var disposeTasks = _registries.Select(async r =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await r.DisposeAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Error disposing registry {Type}", r.RegistryType);
|
||||
}
|
||||
});
|
||||
|
||||
await Task.WhenAll(disposeTasks);
|
||||
_registries.Clear();
|
||||
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Provides test data for registry compatibility matrix tests.
|
||||
/// </summary>
|
||||
public static class RegistryTestData
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets all registry types for Theory tests.
|
||||
/// </summary>
|
||||
public static TheoryData<string> AllRegistryTypes => new()
|
||||
{
|
||||
"generic-oci",
|
||||
"zot",
|
||||
"distribution",
|
||||
"harbor"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Gets registry types that support the referrers API.
|
||||
/// </summary>
|
||||
public static TheoryData<string> ReferrersApiRegistryTypes => new()
|
||||
{
|
||||
"zot",
|
||||
"distribution",
|
||||
"harbor"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Gets registry types that require fallback for referrers.
|
||||
/// </summary>
|
||||
public static TheoryData<string> FallbackRegistryTypes => new()
|
||||
{
|
||||
"generic-oci"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Collection definition for registry compatibility tests.
|
||||
/// Use [Collection("RegistryCompatibility")] on test classes.
|
||||
/// </summary>
|
||||
[CollectionDefinition("RegistryCompatibility")]
|
||||
public class RegistryCompatibilityCollection : ICollectionFixture<RegistryCompatibilityFixture>
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using DotNet.Testcontainers.Containers;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace StellaOps.Infrastructure.Registry.Testing;
|
||||
|
||||
/// <summary>
|
||||
/// Base class for OCI registry test containers.
|
||||
/// </summary>
|
||||
public abstract class RegistryTestContainerBase : IRegistryTestContainer
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private bool _isReady;
|
||||
|
||||
protected IContainer? Container { get; set; }
|
||||
protected ILogger Logger { get; }
|
||||
|
||||
protected RegistryTestContainerBase(ILogger? logger = null)
|
||||
{
|
||||
Logger = logger ?? NullLogger.Instance;
|
||||
_httpClient = new HttpClient();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public abstract string RegistryType { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public abstract string RegistryUrl { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public virtual string? Username => null;
|
||||
|
||||
/// <inheritdoc />
|
||||
public virtual string? Password => null;
|
||||
|
||||
/// <inheritdoc />
|
||||
public abstract bool SupportsReferrersApi { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsReady => _isReady;
|
||||
|
||||
/// <inheritdoc />
|
||||
public abstract Task StartAsync(CancellationToken ct = default);
|
||||
|
||||
/// <inheritdoc />
|
||||
public virtual async Task<bool> WaitForReadyAsync(CancellationToken ct = default)
|
||||
{
|
||||
const int maxRetries = 30;
|
||||
const int delayMs = 1000;
|
||||
|
||||
for (var i = 0; i < maxRetries; i++)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, $"{RegistryUrl}/v2/");
|
||||
ApplyAuthentication(request);
|
||||
|
||||
var response = await _httpClient.SendAsync(request, ct);
|
||||
|
||||
// 200 OK or 401 Unauthorized both indicate the registry is up
|
||||
if (response.StatusCode is HttpStatusCode.OK or HttpStatusCode.Unauthorized)
|
||||
{
|
||||
_isReady = true;
|
||||
Logger.LogInformation("Registry {Type} at {Url} is ready", RegistryType, RegistryUrl);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch (HttpRequestException)
|
||||
{
|
||||
// Not ready yet
|
||||
}
|
||||
|
||||
await Task.Delay(delayMs, ct);
|
||||
}
|
||||
|
||||
Logger.LogWarning("Registry {Type} at {Url} failed to become ready after {Retries} retries",
|
||||
RegistryType, RegistryUrl, maxRetries);
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public virtual async Task<string> PushTestImageAsync(string repository, string tag, CancellationToken ct = default)
|
||||
{
|
||||
// Create a minimal OCI image config
|
||||
var configContent = """{"architecture":"amd64","os":"linux","config":{}}""";
|
||||
var configBytes = Encoding.UTF8.GetBytes(configContent);
|
||||
var configDigest = ComputeDigest(configBytes);
|
||||
|
||||
// Push config blob
|
||||
await PushBlobAsync(repository, configBytes, configDigest, ct);
|
||||
|
||||
// Create a minimal layer (empty tar.gz)
|
||||
var layerBytes = CreateEmptyTarGz();
|
||||
var layerDigest = ComputeDigest(layerBytes);
|
||||
|
||||
// Push layer blob
|
||||
await PushBlobAsync(repository, layerBytes, layerDigest, ct);
|
||||
|
||||
// Create and push manifest
|
||||
var manifest = $$"""
|
||||
{
|
||||
"schemaVersion": 2,
|
||||
"mediaType": "application/vnd.oci.image.manifest.v1+json",
|
||||
"config": {
|
||||
"mediaType": "application/vnd.oci.image.config.v1+json",
|
||||
"digest": "{{configDigest}}",
|
||||
"size": {{configBytes.Length}}
|
||||
},
|
||||
"layers": [
|
||||
{
|
||||
"mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
|
||||
"digest": "{{layerDigest}}",
|
||||
"size": {{layerBytes.Length}}
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
var manifestBytes = Encoding.UTF8.GetBytes(manifest);
|
||||
var manifestDigest = ComputeDigest(manifestBytes);
|
||||
|
||||
await PushManifestAsync(repository, tag, manifestBytes, ct);
|
||||
|
||||
Logger.LogInformation("Pushed test image {Repository}:{Tag} with digest {Digest}",
|
||||
repository, tag, manifestDigest);
|
||||
|
||||
return manifestDigest;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public virtual async Task<string?> GetManifestDigestAsync(string repository, string tag, CancellationToken ct = default)
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Head, $"{RegistryUrl}/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"));
|
||||
ApplyAuthentication(request);
|
||||
|
||||
var response = await _httpClient.SendAsync(request, ct);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return null;
|
||||
|
||||
return response.Headers.TryGetValues("Docker-Content-Digest", out var values)
|
||||
? values.FirstOrDefault()
|
||||
: null;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public virtual async ValueTask DisposeAsync()
|
||||
{
|
||||
_httpClient.Dispose();
|
||||
|
||||
if (Container != null)
|
||||
{
|
||||
await Container.DisposeAsync();
|
||||
}
|
||||
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies authentication headers to the request.
|
||||
/// </summary>
|
||||
protected virtual void ApplyAuthentication(HttpRequestMessage request)
|
||||
{
|
||||
if (Username != null && Password != null)
|
||||
{
|
||||
var credentials = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{Username}:{Password}"));
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Basic", credentials);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task PushBlobAsync(string repository, byte[] content, string digest, CancellationToken ct)
|
||||
{
|
||||
// Initiate upload
|
||||
using var initiateRequest = new HttpRequestMessage(HttpMethod.Post, $"{RegistryUrl}/v2/{repository}/blobs/uploads/");
|
||||
ApplyAuthentication(initiateRequest);
|
||||
|
||||
var initiateResponse = await _httpClient.SendAsync(initiateRequest, ct);
|
||||
|
||||
if (initiateResponse.StatusCode != HttpStatusCode.Accepted)
|
||||
{
|
||||
throw new InvalidOperationException($"Failed to initiate blob upload: {initiateResponse.StatusCode}");
|
||||
}
|
||||
|
||||
var location = initiateResponse.Headers.Location?.ToString()
|
||||
?? throw new InvalidOperationException("No location header in upload response");
|
||||
|
||||
// Complete upload with content
|
||||
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");
|
||||
ApplyAuthentication(uploadRequest);
|
||||
|
||||
var uploadResponse = await _httpClient.SendAsync(uploadRequest, ct);
|
||||
|
||||
if (uploadResponse.StatusCode != HttpStatusCode.Created)
|
||||
{
|
||||
throw new InvalidOperationException($"Failed to upload blob: {uploadResponse.StatusCode}");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task PushManifestAsync(string repository, string tag, byte[] content, CancellationToken ct)
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Put, $"{RegistryUrl}/v2/{repository}/manifests/{tag}");
|
||||
request.Content = new ByteArrayContent(content);
|
||||
request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/vnd.oci.image.manifest.v1+json");
|
||||
ApplyAuthentication(request);
|
||||
|
||||
var response = await _httpClient.SendAsync(request, ct);
|
||||
|
||||
if (response.StatusCode != HttpStatusCode.Created)
|
||||
{
|
||||
throw new InvalidOperationException($"Failed to push manifest: {response.StatusCode}");
|
||||
}
|
||||
}
|
||||
|
||||
private static string ComputeDigest(byte[] content)
|
||||
{
|
||||
var hash = SHA256.HashData(content);
|
||||
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
private static byte[] CreateEmptyTarGz()
|
||||
{
|
||||
// Minimal gzip-compressed empty tar archive
|
||||
// This is a valid but empty tar.gz file
|
||||
return
|
||||
[
|
||||
0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x03, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" ?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<OutputType>Exe</OutputType>
|
||||
<UseAppHost>true</UseAppHost>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
<RootNamespace>StellaOps.Infrastructure.Registry.Testing</RootNamespace>
|
||||
<AssemblyName>StellaOps.Infrastructure.Registry.Testing</AssemblyName>
|
||||
<Description>OCI Registry test infrastructure for StellaOps integration tests using Testcontainers</Description>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Testcontainers" />
|
||||
<PackageReference Include="xunit.v3.assert" PrivateAssets="all" />
|
||||
<PackageReference Include="xunit.v3.core" PrivateAssets="all" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user