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