test fixes and new product advisories work

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

View File

@@ -0,0 +1,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
}
}

View File

@@ -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);
}
}

View File

@@ -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");
}
}

View File

@@ -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");
}
}

View File

@@ -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();
}
}
}

View File

@@ -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>

View File

@@ -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;
}
}

View File

@@ -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());
}
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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>

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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();
}
}

View File

@@ -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);
}
}

View File

@@ -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; }
}

View File

@@ -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.

View File

@@ -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>
{
}

View File

@@ -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
];
}
}

View File

@@ -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>