test fixes and new product advisories work
This commit is contained in:
@@ -0,0 +1,310 @@
|
||||
using System.Net;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins.Integration.Checks;
|
||||
using StellaOps.Doctor.Plugins.Integration.Tests.TestHelpers;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Integration.Tests.Checks;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class RegistryCapabilityProbeCheckTests
|
||||
{
|
||||
private readonly RegistryCapabilityProbeCheck _check = new();
|
||||
|
||||
[Fact]
|
||||
public void CheckId_ReturnsExpectedId()
|
||||
{
|
||||
_check.CheckId.Should().Be("check.integration.oci.capabilities");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Name_ReturnsExpectedName()
|
||||
{
|
||||
_check.Name.Should().Be("OCI Registry Capability Matrix");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultSeverity_IsInfo()
|
||||
{
|
||||
// Info because this is informational by default
|
||||
_check.DefaultSeverity.Should().Be(DoctorSeverity.Info);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tags_ContainsExpectedValues()
|
||||
{
|
||||
_check.Tags.Should().Contain("registry");
|
||||
_check.Tags.Should().Contain("oci");
|
||||
_check.Tags.Should().Contain("capabilities");
|
||||
_check.Tags.Should().Contain("compatibility");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanRun_ReturnsFalse_WhenNoRegistryUrlConfigured()
|
||||
{
|
||||
var context = DoctorPluginContextFactory.CreateWithoutHttpFactory(
|
||||
new Dictionary<string, string?>());
|
||||
|
||||
_check.CanRun(context).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanRun_ReturnsTrue_WhenRegistryUrlConfigured()
|
||||
{
|
||||
var handler = new MockHttpMessageHandler();
|
||||
var context = DoctorPluginContextFactory.Create(handler);
|
||||
|
||||
_check.CanRun(context).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ReturnsSkip_WhenHttpClientFactoryNotAvailable()
|
||||
{
|
||||
var context = DoctorPluginContextFactory.CreateWithoutHttpFactory();
|
||||
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
result.Severity.Should().Be(DoctorSeverity.Skip);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ReturnsResult_WithCapabilityEvidence()
|
||||
{
|
||||
var handler = CreateFullCapabilityHandler();
|
||||
|
||||
var context = DoctorPluginContextFactory.Create(handler);
|
||||
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Check produces a result (may be Pass, Info, or Warn depending on capabilities detected)
|
||||
result.Severity.Should().BeOneOf(DoctorSeverity.Pass, DoctorSeverity.Info, DoctorSeverity.Warn);
|
||||
result.Evidence.Data.Should().ContainKey("registry_url");
|
||||
result.Evidence.Data.Should().ContainKey("capability_score");
|
||||
result.Evidence.Data.Should().ContainKey("supports_referrers_api");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ReturnsWarn_WhenReferrersApiNotSupported()
|
||||
{
|
||||
var handler = new MockHttpMessageHandler()
|
||||
.QueueResponse(HttpStatusCode.OK, headers: new Dictionary<string, string>
|
||||
{
|
||||
["OCI-Distribution-API-Version"] = "registry/2.0"
|
||||
}) // Distribution version probe
|
||||
.QueueResponse(HttpStatusCode.MethodNotAllowed) // Referrers API - NOT supported
|
||||
.QueueAcceptedWithLocation("https://registry.example.com/v2/test/blobs/uploads/abc123") // Chunked upload
|
||||
.QueueResponse(HttpStatusCode.NoContent) // DELETE cleanup
|
||||
.QueueResponse(HttpStatusCode.Created) // Cross-repo mount
|
||||
.QueueResponse(HttpStatusCode.OK, headers: new Dictionary<string, string> { ["Allow"] = "GET, HEAD, DELETE" }) // Manifest delete
|
||||
.QueueResponse(HttpStatusCode.OK, headers: new Dictionary<string, string> { ["Allow"] = "GET, HEAD, DELETE" }); // Blob delete
|
||||
|
||||
var context = DoctorPluginContextFactory.Create(handler);
|
||||
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
result.Severity.Should().Be(DoctorSeverity.Warn);
|
||||
result.Evidence.Data["supports_referrers_api"].Should().Be("false");
|
||||
result.Diagnosis.Should().Contain("referrers");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ReturnsInfo_WhenSomeNonCriticalCapabilitiesMissing()
|
||||
{
|
||||
var handler = new MockHttpMessageHandler()
|
||||
.QueueResponse(HttpStatusCode.OK, headers: new Dictionary<string, string>
|
||||
{
|
||||
["OCI-Distribution-API-Version"] = "registry/2.0"
|
||||
}) // Distribution version probe
|
||||
.QueueOciIndexResponse() // Referrers API - supported
|
||||
.QueueAcceptedWithLocation("https://registry.example.com/v2/test/blobs/uploads/abc123") // Chunked upload
|
||||
.QueueResponse(HttpStatusCode.NoContent) // DELETE cleanup
|
||||
.QueueResponse(HttpStatusCode.NotFound) // Cross-repo mount - NOT supported
|
||||
.QueueResponse(HttpStatusCode.OK, headers: new Dictionary<string, string> { ["Allow"] = "GET, HEAD" }) // Manifest delete - NOT supported
|
||||
.QueueResponse(HttpStatusCode.OK, headers: new Dictionary<string, string> { ["Allow"] = "GET, HEAD" }); // Blob delete - NOT supported
|
||||
|
||||
var context = DoctorPluginContextFactory.Create(handler);
|
||||
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
result.Severity.Should().Be(DoctorSeverity.Info);
|
||||
result.Evidence.Data["capability_score"].Should().Be("2/5");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ProbesDistributionVersion()
|
||||
{
|
||||
var handler = CreateFullCapabilityHandler();
|
||||
|
||||
var context = DoctorPluginContextFactory.Create(handler);
|
||||
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
result.Evidence.Data["distribution_version"].Should().NotBe("unknown");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_DetectsDockerDistributionVersion()
|
||||
{
|
||||
var handler = new MockHttpMessageHandler()
|
||||
.QueueResponse(HttpStatusCode.OK, headers: new Dictionary<string, string>
|
||||
{
|
||||
["Docker-Distribution-API-Version"] = "registry/2.0"
|
||||
}) // Docker version header
|
||||
.QueueOciIndexResponse()
|
||||
.QueueAcceptedWithLocation("https://registry.example.com/v2/test/blobs/uploads/abc123")
|
||||
.QueueResponse(HttpStatusCode.NoContent)
|
||||
.QueueResponse(HttpStatusCode.Created)
|
||||
.QueueResponse(HttpStatusCode.OK, headers: new Dictionary<string, string> { ["Allow"] = "GET, HEAD, DELETE" })
|
||||
.QueueResponse(HttpStatusCode.OK, headers: new Dictionary<string, string> { ["Allow"] = "GET, HEAD, DELETE" });
|
||||
|
||||
var context = DoctorPluginContextFactory.Create(handler);
|
||||
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
result.Evidence.Data["distribution_version"].Should().Contain("Docker");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_DetectsReferrersApiSupport()
|
||||
{
|
||||
var handler = CreateFullCapabilityHandler();
|
||||
|
||||
var context = DoctorPluginContextFactory.Create(handler);
|
||||
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
result.Evidence.Data["supports_referrers_api"].Should().Be("true");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_DetectsChunkedUploadSupport()
|
||||
{
|
||||
var handler = CreateFullCapabilityHandler();
|
||||
|
||||
var context = DoctorPluginContextFactory.Create(handler);
|
||||
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
result.Evidence.Data["supports_chunked_upload"].Should().Be("true");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_DetectsCrossRepoMountSupport()
|
||||
{
|
||||
var handler = CreateFullCapabilityHandler();
|
||||
|
||||
var context = DoctorPluginContextFactory.Create(handler);
|
||||
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
result.Evidence.Data["supports_cross_repo_mount"].Should().Be("true");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_IncludesDeleteSupportEvidence()
|
||||
{
|
||||
var handler = CreateFullCapabilityHandler();
|
||||
|
||||
var context = DoctorPluginContextFactory.Create(handler);
|
||||
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Delete support evidence keys should be present
|
||||
result.Evidence.Data.Should().ContainKey("supports_manifest_delete");
|
||||
result.Evidence.Data.Should().ContainKey("supports_blob_delete");
|
||||
// Values may be "true", "false", or "unknown" depending on probe results
|
||||
result.Evidence.Data["supports_manifest_delete"].Should().BeOneOf("true", "false", "unknown");
|
||||
result.Evidence.Data["supports_blob_delete"].Should().BeOneOf("true", "false", "unknown");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_IncludesCapabilityScore()
|
||||
{
|
||||
var handler = CreateFullCapabilityHandler();
|
||||
|
||||
var context = DoctorPluginContextFactory.Create(handler);
|
||||
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
result.Evidence.Data["capability_score"].Should().MatchRegex(@"\d+/\d+");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_HandlesUnknownCapabilities()
|
||||
{
|
||||
// All probes return errors that make capability unknown
|
||||
var handler = new MockHttpMessageHandler()
|
||||
.QueueResponse(HttpStatusCode.InternalServerError) // Distribution version
|
||||
.QueueResponse(HttpStatusCode.InternalServerError) // Referrers API
|
||||
.QueueResponse(HttpStatusCode.Unauthorized) // Chunked upload
|
||||
.QueueResponse(HttpStatusCode.InternalServerError) // Cross-repo mount
|
||||
.QueueResponse(HttpStatusCode.InternalServerError) // Manifest delete
|
||||
.QueueResponse(HttpStatusCode.InternalServerError); // Blob delete
|
||||
|
||||
var context = DoctorPluginContextFactory.Create(handler);
|
||||
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Should still return a result with "unknown" values
|
||||
result.Evidence.Data.Values.Should().Contain("unknown");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_CancelsUploadSession_AfterProbe()
|
||||
{
|
||||
var handler = CreateFullCapabilityHandler();
|
||||
|
||||
var context = DoctorPluginContextFactory.Create(handler);
|
||||
|
||||
await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Should have a DELETE request after the POST for upload probe
|
||||
var methods = handler.CapturedRequests.Select(r => r.Method).ToList();
|
||||
var postIndex = methods.IndexOf(HttpMethod.Post);
|
||||
if (postIndex >= 0 && postIndex < methods.Count - 1)
|
||||
{
|
||||
methods[postIndex + 1].Should().Be(HttpMethod.Delete);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_AppliesAuthentication()
|
||||
{
|
||||
var handler = CreateFullCapabilityHandler();
|
||||
|
||||
var context = DoctorPluginContextFactory.Create(handler,
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
["OCI:RegistryUrl"] = "https://registry.example.com",
|
||||
["OCI:Username"] = "testuser",
|
||||
["OCI:Password"] = "testpass"
|
||||
});
|
||||
|
||||
await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// All requests should have auth header
|
||||
foreach (var request in handler.CapturedRequests)
|
||||
{
|
||||
request.Headers.Authorization.Should().NotBeNull();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a handler that returns success for all capability probes.
|
||||
/// </summary>
|
||||
private static MockHttpMessageHandler CreateFullCapabilityHandler()
|
||||
{
|
||||
return new MockHttpMessageHandler()
|
||||
.QueueResponse(HttpStatusCode.OK, headers: new Dictionary<string, string>
|
||||
{
|
||||
["OCI-Distribution-API-Version"] = "registry/2.0"
|
||||
}) // Distribution version probe
|
||||
.QueueOciIndexResponse() // Referrers API
|
||||
.QueueAcceptedWithLocation("https://registry.example.com/v2/test/blobs/uploads/abc123") // Chunked upload
|
||||
.QueueResponse(HttpStatusCode.NoContent) // DELETE cleanup for chunked upload
|
||||
.QueueResponse(HttpStatusCode.Created) // Cross-repo mount
|
||||
.QueueResponse(HttpStatusCode.OK, headers: new Dictionary<string, string> { ["Allow"] = "GET, HEAD, DELETE" }) // Manifest delete
|
||||
.QueueResponse(HttpStatusCode.OK, headers: new Dictionary<string, string> { ["Allow"] = "GET, HEAD, DELETE" }); // Blob delete
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
using System.Net;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins.Integration.Checks;
|
||||
using StellaOps.Doctor.Plugins.Integration.Tests.TestHelpers;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Integration.Tests.Checks;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class RegistryCredentialsCheckTests
|
||||
{
|
||||
private readonly RegistryCredentialsCheck _check = new();
|
||||
|
||||
[Fact]
|
||||
public void CheckId_ReturnsExpectedId()
|
||||
{
|
||||
_check.CheckId.Should().Be("check.integration.oci.credentials");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Name_ReturnsExpectedName()
|
||||
{
|
||||
_check.Name.Should().Be("OCI Registry Credentials");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultSeverity_IsFail()
|
||||
{
|
||||
_check.DefaultSeverity.Should().Be(DoctorSeverity.Fail);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tags_ContainsExpectedValues()
|
||||
{
|
||||
_check.Tags.Should().Contain("registry");
|
||||
_check.Tags.Should().Contain("oci");
|
||||
_check.Tags.Should().Contain("credentials");
|
||||
_check.Tags.Should().Contain("auth");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanRun_ReturnsFalse_WhenNoRegistryUrlConfigured()
|
||||
{
|
||||
var context = DoctorPluginContextFactory.CreateWithoutHttpFactory(
|
||||
new Dictionary<string, string?>());
|
||||
|
||||
_check.CanRun(context).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanRun_ReturnsTrue_WhenRegistryUrlConfigured()
|
||||
{
|
||||
var handler = new MockHttpMessageHandler();
|
||||
var context = DoctorPluginContextFactory.Create(handler,
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
["OCI:RegistryUrl"] = "https://registry.example.com"
|
||||
});
|
||||
|
||||
_check.CanRun(context).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ReturnsSkip_WhenHttpClientFactoryNotAvailable()
|
||||
{
|
||||
var context = DoctorPluginContextFactory.CreateWithoutHttpFactory();
|
||||
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
result.Severity.Should().Be(DoctorSeverity.Skip);
|
||||
result.Diagnosis.Should().Contain("IHttpClientFactory");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ReturnsFail_WhenUsernameProvidedWithoutPassword()
|
||||
{
|
||||
var handler = new MockHttpMessageHandler();
|
||||
var context = DoctorPluginContextFactory.Create(handler,
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
["OCI:RegistryUrl"] = "https://registry.example.com",
|
||||
["OCI:Username"] = "testuser"
|
||||
});
|
||||
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
result.Severity.Should().Be(DoctorSeverity.Fail);
|
||||
result.Diagnosis.Should().Contain("username provided without password");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ReturnsPass_WhenBasicAuthSucceeds()
|
||||
{
|
||||
var handler = new MockHttpMessageHandler()
|
||||
.QueueResponse(HttpStatusCode.OK);
|
||||
|
||||
var context = DoctorPluginContextFactory.Create(handler,
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
["OCI:RegistryUrl"] = "https://registry.example.com",
|
||||
["OCI:Username"] = "testuser",
|
||||
["OCI:Password"] = "testpass"
|
||||
});
|
||||
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
result.Severity.Should().Be(DoctorSeverity.Pass);
|
||||
result.Diagnosis.Should().Contain("valid");
|
||||
result.Evidence.Data["auth_method"].Should().Be("basic");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ReturnsPass_WhenBearerAuthSucceeds()
|
||||
{
|
||||
var handler = new MockHttpMessageHandler()
|
||||
.QueueResponse(HttpStatusCode.OK);
|
||||
|
||||
var context = DoctorPluginContextFactory.Create(handler,
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
["OCI:RegistryUrl"] = "https://registry.example.com",
|
||||
["OCI:Token"] = "test-bearer-token"
|
||||
});
|
||||
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
result.Severity.Should().Be(DoctorSeverity.Pass);
|
||||
result.Evidence.Data["auth_method"].Should().Be("bearer");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ReturnsPass_WhenAnonymousAccessAllowed()
|
||||
{
|
||||
var handler = new MockHttpMessageHandler()
|
||||
.QueueResponse(HttpStatusCode.OK);
|
||||
|
||||
var context = DoctorPluginContextFactory.Create(handler,
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
["OCI:RegistryUrl"] = "https://registry.example.com"
|
||||
});
|
||||
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
result.Severity.Should().Be(DoctorSeverity.Pass);
|
||||
result.Evidence.Data["auth_method"].Should().Be("anonymous");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ReturnsFail_WhenUnauthorized()
|
||||
{
|
||||
var handler = new MockHttpMessageHandler()
|
||||
.QueueResponse(HttpStatusCode.Unauthorized);
|
||||
|
||||
var context = DoctorPluginContextFactory.Create(handler,
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
["OCI:RegistryUrl"] = "https://registry.example.com",
|
||||
["OCI:Username"] = "baduser",
|
||||
["OCI:Password"] = "badpass"
|
||||
});
|
||||
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
result.Severity.Should().Be(DoctorSeverity.Fail);
|
||||
result.Diagnosis.Should().Contain("Authentication rejected");
|
||||
result.LikelyCauses.Should().Contain(c => c.Contains("invalid"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ReturnsPass_WhenOAuth2TokenExchangeRequired()
|
||||
{
|
||||
var response = new HttpResponseMessage(HttpStatusCode.Unauthorized);
|
||||
response.Headers.WwwAuthenticate.ParseAdd("Bearer realm=\"https://auth.example.com/token\"");
|
||||
|
||||
var handler = new MockHttpMessageHandler();
|
||||
handler.QueueResponse(HttpStatusCode.Unauthorized, headers: new Dictionary<string, string>
|
||||
{
|
||||
["WWW-Authenticate"] = "Bearer realm=\"https://auth.example.com/token\""
|
||||
});
|
||||
|
||||
var context = DoctorPluginContextFactory.Create(handler,
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
["OCI:RegistryUrl"] = "https://registry.example.com",
|
||||
["OCI:Username"] = "testuser",
|
||||
["OCI:Password"] = "testpass"
|
||||
});
|
||||
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// When basic auth returns 401 with Bearer WWW-Authenticate, credentials are considered valid
|
||||
// (OAuth2 token exchange is expected)
|
||||
result.Severity.Should().Be(DoctorSeverity.Pass);
|
||||
result.Diagnosis.Should().Contain("OAuth2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ReturnsFail_WhenForbidden()
|
||||
{
|
||||
var handler = new MockHttpMessageHandler()
|
||||
.QueueResponse(HttpStatusCode.Forbidden);
|
||||
|
||||
var context = DoctorPluginContextFactory.Create(handler,
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
["OCI:RegistryUrl"] = "https://registry.example.com",
|
||||
["OCI:Username"] = "testuser",
|
||||
["OCI:Password"] = "testpass"
|
||||
});
|
||||
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
result.Severity.Should().Be(DoctorSeverity.Fail);
|
||||
result.Diagnosis.Should().Contain("forbidden");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_RedactsPassword_InEvidence()
|
||||
{
|
||||
var handler = new MockHttpMessageHandler()
|
||||
.QueueResponse(HttpStatusCode.OK);
|
||||
|
||||
var context = DoctorPluginContextFactory.Create(handler,
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
["OCI:RegistryUrl"] = "https://registry.example.com",
|
||||
["OCI:Username"] = "testuser",
|
||||
["OCI:Password"] = "supersecretpassword"
|
||||
});
|
||||
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
result.Evidence.Data["password"].Should().NotBe("supersecretpassword");
|
||||
result.Evidence.Data["password"].Should().Contain("****");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_UsesAltConfigKeys()
|
||||
{
|
||||
var handler = new MockHttpMessageHandler()
|
||||
.QueueResponse(HttpStatusCode.OK);
|
||||
|
||||
var context = DoctorPluginContextFactory.Create(handler,
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
["Registry:Url"] = "https://registry.example.com",
|
||||
["Registry:Username"] = "testuser",
|
||||
["Registry:Password"] = "testpass"
|
||||
});
|
||||
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
result.Severity.Should().Be(DoctorSeverity.Pass);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
using System.Net;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins.Integration.Checks;
|
||||
using StellaOps.Doctor.Plugins.Integration.Tests.TestHelpers;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Integration.Tests.Checks;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class RegistryPullAuthorizationCheckTests
|
||||
{
|
||||
private readonly RegistryPullAuthorizationCheck _check = new();
|
||||
|
||||
[Fact]
|
||||
public void CheckId_ReturnsExpectedId()
|
||||
{
|
||||
_check.CheckId.Should().Be("check.integration.oci.pull");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Name_ReturnsExpectedName()
|
||||
{
|
||||
_check.Name.Should().Be("OCI Registry Pull Authorization");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultSeverity_IsFail()
|
||||
{
|
||||
_check.DefaultSeverity.Should().Be(DoctorSeverity.Fail);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tags_ContainsExpectedValues()
|
||||
{
|
||||
_check.Tags.Should().Contain("registry");
|
||||
_check.Tags.Should().Contain("oci");
|
||||
_check.Tags.Should().Contain("pull");
|
||||
_check.Tags.Should().Contain("authorization");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanRun_ReturnsFalse_WhenNoRegistryUrlConfigured()
|
||||
{
|
||||
var context = DoctorPluginContextFactory.CreateWithoutHttpFactory(
|
||||
new Dictionary<string, string?>());
|
||||
|
||||
_check.CanRun(context).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanRun_ReturnsTrue_WhenRegistryUrlConfigured()
|
||||
{
|
||||
var handler = new MockHttpMessageHandler();
|
||||
var context = DoctorPluginContextFactory.Create(handler);
|
||||
|
||||
_check.CanRun(context).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ReturnsSkip_WhenHttpClientFactoryNotAvailable()
|
||||
{
|
||||
var context = DoctorPluginContextFactory.CreateWithoutHttpFactory();
|
||||
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
result.Severity.Should().Be(DoctorSeverity.Skip);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ReturnsPass_WhenManifestHeadSucceeds()
|
||||
{
|
||||
var handler = new MockHttpMessageHandler()
|
||||
.QueueSuccessWithDigest("sha256:abc123");
|
||||
|
||||
var context = DoctorPluginContextFactory.Create(handler);
|
||||
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
result.Severity.Should().Be(DoctorSeverity.Pass);
|
||||
result.Diagnosis.Should().Contain("verified");
|
||||
result.Evidence.Data["pull_authorized"].Should().Be("true");
|
||||
result.Evidence.Data["manifest_digest"].Should().Be("sha256:abc123");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_UsesHeadRequest_ForNonDestructiveCheck()
|
||||
{
|
||||
var handler = new MockHttpMessageHandler()
|
||||
.QueueSuccessWithDigest("sha256:abc123");
|
||||
|
||||
var context = DoctorPluginContextFactory.Create(handler);
|
||||
|
||||
await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
handler.CapturedRequests.Should().ContainSingle();
|
||||
handler.CapturedRequests[0].Method.Should().Be(HttpMethod.Head);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ReturnsFail_WhenUnauthorized()
|
||||
{
|
||||
var handler = new MockHttpMessageHandler()
|
||||
.QueueResponse(HttpStatusCode.Unauthorized);
|
||||
|
||||
var context = DoctorPluginContextFactory.Create(handler);
|
||||
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
result.Severity.Should().Be(DoctorSeverity.Fail);
|
||||
result.Diagnosis.Should().Contain("Invalid credentials");
|
||||
result.Evidence.Data["pull_authorized"].Should().Be("false");
|
||||
result.Evidence.Data["http_status"].Should().Contain("401");
|
||||
result.LikelyCauses.Should().NotBeEmpty();
|
||||
result.Remediation.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ReturnsFail_WhenForbidden()
|
||||
{
|
||||
var handler = new MockHttpMessageHandler()
|
||||
.QueueResponse(HttpStatusCode.Forbidden);
|
||||
|
||||
var context = DoctorPluginContextFactory.Create(handler);
|
||||
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
result.Severity.Should().Be(DoctorSeverity.Fail);
|
||||
result.Diagnosis.Should().Contain("No pull permission");
|
||||
result.Evidence.Data["pull_authorized"].Should().Be("false");
|
||||
result.Evidence.Data["credentials_valid"].Should().Be("true");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ReturnsInfo_WhenImageNotFound()
|
||||
{
|
||||
var handler = new MockHttpMessageHandler()
|
||||
.QueueResponse(HttpStatusCode.NotFound);
|
||||
|
||||
var context = DoctorPluginContextFactory.Create(handler);
|
||||
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
result.Severity.Should().Be(DoctorSeverity.Info);
|
||||
result.Diagnosis.Should().Contain("test image not found");
|
||||
result.Evidence.Data["pull_authorized"].Should().Be("unknown");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_IncludesCorrectAcceptHeaders()
|
||||
{
|
||||
var handler = new MockHttpMessageHandler()
|
||||
.QueueSuccessWithDigest("sha256:abc123");
|
||||
|
||||
var context = DoctorPluginContextFactory.Create(handler);
|
||||
|
||||
await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
var request = handler.CapturedRequests[0];
|
||||
var acceptHeaders = request.Headers.Accept.Select(h => h.MediaType).ToList();
|
||||
|
||||
acceptHeaders.Should().Contain("application/vnd.oci.image.manifest.v1+json");
|
||||
acceptHeaders.Should().Contain("application/vnd.docker.distribution.manifest.v2+json");
|
||||
acceptHeaders.Should().Contain("application/vnd.oci.image.index.v1+json");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_UsesConfiguredTestRepository()
|
||||
{
|
||||
var handler = new MockHttpMessageHandler()
|
||||
.QueueSuccessWithDigest("sha256:abc123");
|
||||
|
||||
var context = DoctorPluginContextFactory.Create(handler,
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
["OCI:RegistryUrl"] = "https://registry.example.com",
|
||||
["OCI:TestRepository"] = "custom/repo",
|
||||
["OCI:TestTag"] = "v1.0"
|
||||
});
|
||||
|
||||
await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
handler.CapturedRequests[0].RequestUri!.ToString()
|
||||
.Should().Contain("custom/repo/manifests/v1.0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_AppliesBasicAuth_WhenConfigured()
|
||||
{
|
||||
var handler = new MockHttpMessageHandler()
|
||||
.QueueSuccessWithDigest("sha256:abc123");
|
||||
|
||||
var context = DoctorPluginContextFactory.Create(handler,
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
["OCI:RegistryUrl"] = "https://registry.example.com",
|
||||
["OCI:Username"] = "testuser",
|
||||
["OCI:Password"] = "testpass"
|
||||
});
|
||||
|
||||
await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
var request = handler.CapturedRequests[0];
|
||||
request.Headers.Authorization.Should().NotBeNull();
|
||||
request.Headers.Authorization!.Scheme.Should().Be("Basic");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_AppliesBearerAuth_WhenConfigured()
|
||||
{
|
||||
var handler = new MockHttpMessageHandler()
|
||||
.QueueSuccessWithDigest("sha256:abc123");
|
||||
|
||||
var context = DoctorPluginContextFactory.Create(handler,
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
["OCI:RegistryUrl"] = "https://registry.example.com",
|
||||
["OCI:Token"] = "my-bearer-token"
|
||||
});
|
||||
|
||||
await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
var request = handler.CapturedRequests[0];
|
||||
request.Headers.Authorization.Should().NotBeNull();
|
||||
request.Headers.Authorization!.Scheme.Should().Be("Bearer");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_IncludesVerificationCommand()
|
||||
{
|
||||
var handler = new MockHttpMessageHandler()
|
||||
.QueueResponse(HttpStatusCode.Unauthorized);
|
||||
|
||||
var context = DoctorPluginContextFactory.Create(handler);
|
||||
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
result.VerificationCommand.Should().Contain("check.integration.oci.pull");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
using System.Net;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins.Integration.Checks;
|
||||
using StellaOps.Doctor.Plugins.Integration.Tests.TestHelpers;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Integration.Tests.Checks;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class RegistryPushAuthorizationCheckTests
|
||||
{
|
||||
private readonly RegistryPushAuthorizationCheck _check = new();
|
||||
|
||||
[Fact]
|
||||
public void CheckId_ReturnsExpectedId()
|
||||
{
|
||||
_check.CheckId.Should().Be("check.integration.oci.push");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Name_ReturnsExpectedName()
|
||||
{
|
||||
_check.Name.Should().Be("OCI Registry Push Authorization");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultSeverity_IsFail()
|
||||
{
|
||||
_check.DefaultSeverity.Should().Be(DoctorSeverity.Fail);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tags_ContainsExpectedValues()
|
||||
{
|
||||
_check.Tags.Should().Contain("registry");
|
||||
_check.Tags.Should().Contain("oci");
|
||||
_check.Tags.Should().Contain("push");
|
||||
_check.Tags.Should().Contain("authorization");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanRun_ReturnsFalse_WhenNoRegistryUrlConfigured()
|
||||
{
|
||||
var context = DoctorPluginContextFactory.CreateWithoutHttpFactory(
|
||||
new Dictionary<string, string?>());
|
||||
|
||||
_check.CanRun(context).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanRun_ReturnsFalse_WhenNoAuthConfigured()
|
||||
{
|
||||
var handler = new MockHttpMessageHandler();
|
||||
var context = DoctorPluginContextFactory.Create(handler,
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
["OCI:RegistryUrl"] = "https://registry.example.com"
|
||||
// No auth configured
|
||||
});
|
||||
|
||||
_check.CanRun(context).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanRun_ReturnsTrue_WhenAuthConfigured()
|
||||
{
|
||||
var handler = new MockHttpMessageHandler();
|
||||
var context = DoctorPluginContextFactory.Create(handler,
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
["OCI:RegistryUrl"] = "https://registry.example.com",
|
||||
["OCI:Username"] = "testuser",
|
||||
["OCI:Password"] = "testpass"
|
||||
});
|
||||
|
||||
_check.CanRun(context).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanRun_ReturnsTrue_WhenTokenConfigured()
|
||||
{
|
||||
var handler = new MockHttpMessageHandler();
|
||||
var context = DoctorPluginContextFactory.Create(handler,
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
["OCI:RegistryUrl"] = "https://registry.example.com",
|
||||
["OCI:Token"] = "bearer-token"
|
||||
});
|
||||
|
||||
_check.CanRun(context).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ReturnsSkip_WhenHttpClientFactoryNotAvailable()
|
||||
{
|
||||
var context = DoctorPluginContextFactory.CreateWithoutHttpFactory(
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
["OCI:RegistryUrl"] = "https://registry.example.com",
|
||||
["OCI:Token"] = "bearer-token"
|
||||
});
|
||||
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
result.Severity.Should().Be(DoctorSeverity.Skip);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ReturnsPass_WhenUploadInitiationSucceeds()
|
||||
{
|
||||
var handler = new MockHttpMessageHandler()
|
||||
.QueueAcceptedWithLocation("https://registry.example.com/v2/test/blobs/uploads/abc123")
|
||||
.QueueResponse(HttpStatusCode.NoContent); // For DELETE cleanup
|
||||
|
||||
var context = DoctorPluginContextFactory.Create(handler,
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
["OCI:RegistryUrl"] = "https://registry.example.com",
|
||||
["OCI:Username"] = "testuser",
|
||||
["OCI:Password"] = "testpass"
|
||||
});
|
||||
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
result.Severity.Should().Be(DoctorSeverity.Pass);
|
||||
result.Diagnosis.Should().Contain("verified");
|
||||
result.Evidence.Data["push_authorized"].Should().Be("true");
|
||||
result.Evidence.Data["upload_session_cancelled"].Should().Be("true");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_CancelsUploadSession_AfterVerification()
|
||||
{
|
||||
var handler = new MockHttpMessageHandler()
|
||||
.QueueAcceptedWithLocation("https://registry.example.com/v2/test/blobs/uploads/abc123")
|
||||
.QueueResponse(HttpStatusCode.NoContent); // For DELETE cleanup
|
||||
|
||||
var context = DoctorPluginContextFactory.Create(handler,
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
["OCI:RegistryUrl"] = "https://registry.example.com",
|
||||
["OCI:Username"] = "testuser",
|
||||
["OCI:Password"] = "testpass"
|
||||
});
|
||||
|
||||
await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Should have POST (initiate) and DELETE (cancel) requests
|
||||
handler.CapturedRequests.Should().HaveCount(2);
|
||||
handler.CapturedRequests[0].Method.Should().Be(HttpMethod.Post);
|
||||
handler.CapturedRequests[1].Method.Should().Be(HttpMethod.Delete);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ReturnsFail_WhenUnauthorized()
|
||||
{
|
||||
var handler = new MockHttpMessageHandler()
|
||||
.QueueResponse(HttpStatusCode.Unauthorized);
|
||||
|
||||
var context = DoctorPluginContextFactory.Create(handler,
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
["OCI:RegistryUrl"] = "https://registry.example.com",
|
||||
["OCI:Username"] = "baduser",
|
||||
["OCI:Password"] = "badpass"
|
||||
});
|
||||
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
result.Severity.Should().Be(DoctorSeverity.Fail);
|
||||
result.Diagnosis.Should().Contain("Invalid credentials");
|
||||
result.Evidence.Data["push_authorized"].Should().Be("false");
|
||||
result.Evidence.Data["http_status"].Should().Contain("401");
|
||||
result.LikelyCauses.Should().Contain(c => c.Contains("invalid") || c.Contains("expired"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ReturnsFail_WhenForbidden()
|
||||
{
|
||||
var handler = new MockHttpMessageHandler()
|
||||
.QueueResponse(HttpStatusCode.Forbidden);
|
||||
|
||||
var context = DoctorPluginContextFactory.Create(handler,
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
["OCI:RegistryUrl"] = "https://registry.example.com",
|
||||
["OCI:Username"] = "testuser",
|
||||
["OCI:Password"] = "testpass"
|
||||
});
|
||||
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
result.Severity.Should().Be(DoctorSeverity.Fail);
|
||||
result.Diagnosis.Should().Contain("No push permission");
|
||||
result.Evidence.Data["push_authorized"].Should().Be("false");
|
||||
result.Evidence.Data["credentials_valid"].Should().Be("true");
|
||||
result.LikelyCauses.Should().Contain(c => c.Contains("permission"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_UsesCorrectUploadEndpoint()
|
||||
{
|
||||
var handler = new MockHttpMessageHandler()
|
||||
.QueueAcceptedWithLocation("https://registry.example.com/v2/test/blobs/uploads/abc123")
|
||||
.QueueResponse(HttpStatusCode.NoContent);
|
||||
|
||||
var context = DoctorPluginContextFactory.Create(handler,
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
["OCI:RegistryUrl"] = "https://registry.example.com",
|
||||
["OCI:TestRepository"] = "custom/push-test",
|
||||
["OCI:Username"] = "testuser",
|
||||
["OCI:Password"] = "testpass"
|
||||
});
|
||||
|
||||
await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
handler.CapturedRequests[0].RequestUri!.ToString()
|
||||
.Should().Contain("custom/push-test/blobs/uploads/");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_IncludesRemediationSteps_OnFailure()
|
||||
{
|
||||
var handler = new MockHttpMessageHandler()
|
||||
.QueueResponse(HttpStatusCode.Forbidden);
|
||||
|
||||
var context = DoctorPluginContextFactory.Create(handler,
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
["OCI:RegistryUrl"] = "https://registry.example.com",
|
||||
["OCI:Username"] = "testuser",
|
||||
["OCI:Password"] = "testpass"
|
||||
});
|
||||
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
result.Remediation.Should().NotBeNull();
|
||||
result.Remediation!.Steps.Should().NotBeEmpty();
|
||||
result.Remediation.RunbookUrl.Should().Contain("stella-ops.org");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_AppliesBasicAuth_WhenConfigured()
|
||||
{
|
||||
var handler = new MockHttpMessageHandler()
|
||||
.QueueAcceptedWithLocation("https://registry.example.com/v2/test/blobs/uploads/abc123")
|
||||
.QueueResponse(HttpStatusCode.NoContent);
|
||||
|
||||
var context = DoctorPluginContextFactory.Create(handler,
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
["OCI:RegistryUrl"] = "https://registry.example.com",
|
||||
["OCI:Username"] = "testuser",
|
||||
["OCI:Password"] = "testpass"
|
||||
});
|
||||
|
||||
await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
var request = handler.CapturedRequests[0];
|
||||
request.Headers.Authorization.Should().NotBeNull();
|
||||
request.Headers.Authorization!.Scheme.Should().Be("Basic");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_AppliesBearerAuth_WhenConfigured()
|
||||
{
|
||||
var handler = new MockHttpMessageHandler()
|
||||
.QueueAcceptedWithLocation("https://registry.example.com/v2/test/blobs/uploads/abc123")
|
||||
.QueueResponse(HttpStatusCode.NoContent);
|
||||
|
||||
var context = DoctorPluginContextFactory.Create(handler,
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
["OCI:RegistryUrl"] = "https://registry.example.com",
|
||||
["OCI:Token"] = "my-bearer-token"
|
||||
});
|
||||
|
||||
await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
var request = handler.CapturedRequests[0];
|
||||
request.Headers.Authorization.Should().NotBeNull();
|
||||
request.Headers.Authorization!.Scheme.Should().Be("Bearer");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
using System.Net;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins.Integration.Checks;
|
||||
using StellaOps.Doctor.Plugins.Integration.Tests.TestHelpers;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Integration.Tests.Checks;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class RegistryReferrersApiCheckTests
|
||||
{
|
||||
private readonly RegistryReferrersApiCheck _check = new();
|
||||
|
||||
[Fact]
|
||||
public void CheckId_ReturnsExpectedId()
|
||||
{
|
||||
_check.CheckId.Should().Be("check.integration.oci.referrers");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Name_ReturnsExpectedName()
|
||||
{
|
||||
_check.Name.Should().Be("OCI Registry Referrers API Support");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultSeverity_IsWarn()
|
||||
{
|
||||
// Warn because fallback is available
|
||||
_check.DefaultSeverity.Should().Be(DoctorSeverity.Warn);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tags_ContainsExpectedValues()
|
||||
{
|
||||
_check.Tags.Should().Contain("registry");
|
||||
_check.Tags.Should().Contain("oci");
|
||||
_check.Tags.Should().Contain("referrers");
|
||||
_check.Tags.Should().Contain("oci-1.1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanRun_ReturnsFalse_WhenNoRegistryUrlConfigured()
|
||||
{
|
||||
var context = DoctorPluginContextFactory.CreateWithoutHttpFactory(
|
||||
new Dictionary<string, string?>());
|
||||
|
||||
_check.CanRun(context).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanRun_ReturnsTrue_WhenRegistryUrlConfigured()
|
||||
{
|
||||
var handler = new MockHttpMessageHandler();
|
||||
var context = DoctorPluginContextFactory.Create(handler);
|
||||
|
||||
_check.CanRun(context).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ReturnsSkip_WhenHttpClientFactoryNotAvailable()
|
||||
{
|
||||
var context = DoctorPluginContextFactory.CreateWithoutHttpFactory();
|
||||
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
result.Severity.Should().Be(DoctorSeverity.Skip);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ReturnsInfo_WhenTestImageNotFound()
|
||||
{
|
||||
var handler = new MockHttpMessageHandler()
|
||||
.QueueResponse(HttpStatusCode.NotFound); // Manifest HEAD returns 404
|
||||
|
||||
var context = DoctorPluginContextFactory.Create(handler);
|
||||
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
result.Severity.Should().Be(DoctorSeverity.Info);
|
||||
result.Diagnosis.Should().Contain("test image not found");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ReturnsPass_WhenReferrersApiReturns200()
|
||||
{
|
||||
var handler = new MockHttpMessageHandler()
|
||||
.QueueSuccessWithDigest("sha256:abc123") // First: resolve manifest digest
|
||||
.QueueOciIndexResponse(HttpStatusCode.OK); // Then: referrers API returns index
|
||||
|
||||
var context = DoctorPluginContextFactory.Create(handler);
|
||||
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
result.Severity.Should().Be(DoctorSeverity.Pass);
|
||||
result.Diagnosis.Should().Contain("supported");
|
||||
result.Evidence.Data["referrers_supported"].Should().Be("true");
|
||||
result.Evidence.Data["fallback_required"].Should().Be("false");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ReturnsPass_When404WithOciIndex()
|
||||
{
|
||||
var handler = new MockHttpMessageHandler()
|
||||
.QueueSuccessWithDigest("sha256:abc123") // First: resolve manifest digest
|
||||
.QueueOciIndexResponse(HttpStatusCode.NotFound); // 404 but with OCI index content
|
||||
|
||||
var context = DoctorPluginContextFactory.Create(handler);
|
||||
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
result.Severity.Should().Be(DoctorSeverity.Pass);
|
||||
result.Diagnosis.Should().Contain("supported");
|
||||
result.Evidence.Data["referrers_supported"].Should().Be("true");
|
||||
result.Evidence.Data["referrers_count"].Should().Be("0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ReturnsWarn_When404WithoutOciIndex()
|
||||
{
|
||||
var handler = new MockHttpMessageHandler()
|
||||
.QueueSuccessWithDigest("sha256:abc123") // First: resolve manifest digest
|
||||
.QueueResponse(HttpStatusCode.NotFound, "{ \"errors\": [] }"); // Plain 404
|
||||
|
||||
var context = DoctorPluginContextFactory.Create(handler);
|
||||
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
result.Severity.Should().Be(DoctorSeverity.Warn);
|
||||
result.Diagnosis.Should().Contain("not supported");
|
||||
result.Diagnosis.Should().Contain("fallback");
|
||||
result.Evidence.Data["referrers_supported"].Should().Be("false");
|
||||
result.Evidence.Data["fallback_required"].Should().Be("true");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ReturnsWarn_WhenMethodNotAllowed()
|
||||
{
|
||||
var handler = new MockHttpMessageHandler()
|
||||
.QueueSuccessWithDigest("sha256:abc123") // First: resolve manifest digest
|
||||
.QueueResponse(HttpStatusCode.MethodNotAllowed); // 405
|
||||
|
||||
var context = DoctorPluginContextFactory.Create(handler);
|
||||
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
result.Severity.Should().Be(DoctorSeverity.Warn);
|
||||
result.Diagnosis.Should().Contain("not supported");
|
||||
result.Evidence.Data["referrers_supported"].Should().Be("false");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_IncludesOciVersionHeader()
|
||||
{
|
||||
var handler = new MockHttpMessageHandler()
|
||||
.QueueSuccessWithDigest("sha256:abc123");
|
||||
|
||||
// Queue response with OCI version header
|
||||
var response = new HttpResponseMessage(HttpStatusCode.OK);
|
||||
response.Content = new StringContent("""{"schemaVersion":2,"manifests":[]}""",
|
||||
System.Text.Encoding.UTF8, "application/vnd.oci.image.index.v1+json");
|
||||
response.Headers.TryAddWithoutValidation("OCI-Distribution-API-Version", "registry/2.0");
|
||||
|
||||
handler.QueueResponse(HttpStatusCode.OK, """{"schemaVersion":2,"manifests":[]}""",
|
||||
new Dictionary<string, string> { ["OCI-Distribution-API-Version"] = "registry/2.0" });
|
||||
|
||||
var context = DoctorPluginContextFactory.Create(handler);
|
||||
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
result.Evidence.Data["oci_version"].Should().Contain("registry/2.0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_IncludesRemediation_WhenApiNotSupported()
|
||||
{
|
||||
var handler = new MockHttpMessageHandler()
|
||||
.QueueSuccessWithDigest("sha256:abc123")
|
||||
.QueueResponse(HttpStatusCode.NotFound, "{ \"errors\": [] }");
|
||||
|
||||
var context = DoctorPluginContextFactory.Create(handler);
|
||||
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
result.Remediation.Should().NotBeNull();
|
||||
result.Remediation!.Steps.Should().NotBeEmpty();
|
||||
result.Remediation.RunbookUrl.Should().Contain("stella-ops.org");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ProbesCorrectEndpoint()
|
||||
{
|
||||
var handler = new MockHttpMessageHandler()
|
||||
.QueueSuccessWithDigest("sha256:abc123def456")
|
||||
.QueueOciIndexResponse();
|
||||
|
||||
var context = DoctorPluginContextFactory.Create(handler,
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
["OCI:RegistryUrl"] = "https://registry.example.com",
|
||||
["OCI:TestRepository"] = "myorg/myimage",
|
||||
["OCI:TestTag"] = "v1.0"
|
||||
});
|
||||
|
||||
await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Second request should be to referrers endpoint
|
||||
handler.CapturedRequests.Should().HaveCount(2);
|
||||
var referrersRequest = handler.CapturedRequests[1];
|
||||
referrersRequest.RequestUri!.ToString()
|
||||
.Should().Contain("/v2/myorg/myimage/referrers/sha256:abc123def456");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ResolvesManifestDigest_BeforeProbing()
|
||||
{
|
||||
var handler = new MockHttpMessageHandler()
|
||||
.QueueSuccessWithDigest("sha256:expecteddigest")
|
||||
.QueueOciIndexResponse();
|
||||
|
||||
var context = DoctorPluginContextFactory.Create(handler);
|
||||
|
||||
await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// First request should be HEAD to manifests endpoint
|
||||
var manifestRequest = handler.CapturedRequests[0];
|
||||
manifestRequest.Method.Should().Be(HttpMethod.Head);
|
||||
manifestRequest.RequestUri!.ToString().Should().Contain("/manifests/");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ReturnsFail_OnUnexpectedError()
|
||||
{
|
||||
var handler = new MockHttpMessageHandler()
|
||||
.QueueSuccessWithDigest("sha256:abc123")
|
||||
.QueueResponse(HttpStatusCode.InternalServerError);
|
||||
|
||||
var context = DoctorPluginContextFactory.Create(handler);
|
||||
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
result.Severity.Should().Be(DoctorSeverity.Fail);
|
||||
result.Diagnosis.Should().Contain("failed");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_IncludesVerificationCommand()
|
||||
{
|
||||
var handler = new MockHttpMessageHandler()
|
||||
.QueueSuccessWithDigest("sha256:abc123")
|
||||
.QueueResponse(HttpStatusCode.NotFound, "{ \"errors\": [] }");
|
||||
|
||||
var context = DoctorPluginContextFactory.Create(handler);
|
||||
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
result.VerificationCommand.Should().Contain("check.integration.oci.referrers");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_AppliesAuthentication()
|
||||
{
|
||||
var handler = new MockHttpMessageHandler()
|
||||
.QueueSuccessWithDigest("sha256:abc123")
|
||||
.QueueOciIndexResponse();
|
||||
|
||||
var context = DoctorPluginContextFactory.Create(handler,
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
["OCI:RegistryUrl"] = "https://registry.example.com",
|
||||
["OCI:Username"] = "testuser",
|
||||
["OCI:Password"] = "testpass"
|
||||
});
|
||||
|
||||
await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Both requests should have auth header
|
||||
foreach (var request in handler.CapturedRequests)
|
||||
{
|
||||
request.Headers.Authorization.Should().NotBeNull();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<IsPackable>false</IsPackable>
|
||||
<UseConcelierTestInfra>false</UseConcelierTestInfra>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Moq" />
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="coverlet.collector">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Doctor.Plugins.Integration\StellaOps.Doctor.Plugins.Integration.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,79 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Integration.Tests.TestHelpers;
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating DoctorPluginContext instances for testing.
|
||||
/// </summary>
|
||||
public static class DoctorPluginContextFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a context with mocked HTTP client factory.
|
||||
/// </summary>
|
||||
public static DoctorPluginContext Create(
|
||||
MockHttpMessageHandler httpHandler,
|
||||
IDictionary<string, string?>? configValues = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
configValues ??= new Dictionary<string, string?>
|
||||
{
|
||||
["OCI:RegistryUrl"] = "https://registry.example.com",
|
||||
["OCI:TestRepository"] = "test/image",
|
||||
["OCI:TestTag"] = "latest"
|
||||
};
|
||||
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(configValues)
|
||||
.Build();
|
||||
|
||||
var httpClient = new HttpClient(httpHandler);
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<IHttpClientFactory>(new MockHttpClientFactory(httpClient));
|
||||
|
||||
return new DoctorPluginContext
|
||||
{
|
||||
Services = services.BuildServiceProvider(),
|
||||
Configuration = configuration,
|
||||
TimeProvider = timeProvider ?? TimeProvider.System,
|
||||
Logger = NullLogger.Instance,
|
||||
EnvironmentName = "Test",
|
||||
PluginConfig = configuration.GetSection("Doctor:Plugins:Integration")
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a context without HTTP client factory (for skip tests).
|
||||
/// </summary>
|
||||
public static DoctorPluginContext CreateWithoutHttpFactory(IDictionary<string, string?>? configValues = null)
|
||||
{
|
||||
configValues ??= new Dictionary<string, string?>
|
||||
{
|
||||
["OCI:RegistryUrl"] = "https://registry.example.com"
|
||||
};
|
||||
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(configValues)
|
||||
.Build();
|
||||
|
||||
var services = new ServiceCollection();
|
||||
|
||||
return new DoctorPluginContext
|
||||
{
|
||||
Services = services.BuildServiceProvider(),
|
||||
Configuration = configuration,
|
||||
TimeProvider = TimeProvider.System,
|
||||
Logger = NullLogger.Instance,
|
||||
EnvironmentName = "Test",
|
||||
PluginConfig = configuration.GetSection("Doctor:Plugins:Integration")
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class MockHttpClientFactory(HttpClient httpClient) : IHttpClientFactory
|
||||
{
|
||||
public HttpClient CreateClient(string name) => httpClient;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
using System.Net;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Integration.Tests.TestHelpers;
|
||||
|
||||
/// <summary>
|
||||
/// Mock HTTP message handler for testing HTTP-based doctor checks.
|
||||
/// </summary>
|
||||
public sealed class MockHttpMessageHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly Queue<HttpResponseMessage> _responses = new();
|
||||
private readonly List<HttpRequestMessage> _capturedRequests = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets all captured requests for verification.
|
||||
/// </summary>
|
||||
public IReadOnlyList<HttpRequestMessage> CapturedRequests => _capturedRequests;
|
||||
|
||||
/// <summary>
|
||||
/// Queues a response to be returned for the next request.
|
||||
/// </summary>
|
||||
public MockHttpMessageHandler QueueResponse(HttpStatusCode statusCode, string? content = null, IDictionary<string, string>? headers = null)
|
||||
{
|
||||
var response = new HttpResponseMessage(statusCode);
|
||||
|
||||
if (content != null)
|
||||
{
|
||||
response.Content = new StringContent(content, System.Text.Encoding.UTF8, "application/json");
|
||||
}
|
||||
|
||||
if (headers != null)
|
||||
{
|
||||
foreach (var (key, value) in headers)
|
||||
{
|
||||
response.Headers.TryAddWithoutValidation(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
_responses.Enqueue(response);
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Queues a successful response with headers.
|
||||
/// </summary>
|
||||
public MockHttpMessageHandler QueueSuccessWithDigest(string digest)
|
||||
{
|
||||
var response = new HttpResponseMessage(HttpStatusCode.OK);
|
||||
response.Headers.TryAddWithoutValidation("Docker-Content-Digest", digest);
|
||||
response.Content = new StringContent("{}", System.Text.Encoding.UTF8, "application/vnd.oci.image.manifest.v1+json");
|
||||
_responses.Enqueue(response);
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Queues a 202 Accepted response with Location header for upload initiation.
|
||||
/// </summary>
|
||||
public MockHttpMessageHandler QueueAcceptedWithLocation(string location)
|
||||
{
|
||||
var response = new HttpResponseMessage(HttpStatusCode.Accepted);
|
||||
response.Headers.Location = new Uri(location, UriKind.RelativeOrAbsolute);
|
||||
_responses.Enqueue(response);
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Queues an OCI index response for referrers API.
|
||||
/// </summary>
|
||||
public MockHttpMessageHandler QueueOciIndexResponse(HttpStatusCode statusCode = HttpStatusCode.OK)
|
||||
{
|
||||
var ociIndex = """
|
||||
{
|
||||
"schemaVersion": 2,
|
||||
"mediaType": "application/vnd.oci.image.index.v1+json",
|
||||
"manifests": []
|
||||
}
|
||||
""";
|
||||
|
||||
var response = new HttpResponseMessage(statusCode);
|
||||
response.Content = new StringContent(ociIndex, System.Text.Encoding.UTF8, "application/vnd.oci.image.index.v1+json");
|
||||
response.Headers.TryAddWithoutValidation("OCI-Distribution-API-Version", "registry/2.0");
|
||||
_responses.Enqueue(response);
|
||||
return this;
|
||||
}
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
_capturedRequests.Add(request);
|
||||
|
||||
if (_responses.Count == 0)
|
||||
{
|
||||
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.InternalServerError)
|
||||
{
|
||||
Content = new StringContent("No more mocked responses available")
|
||||
});
|
||||
}
|
||||
|
||||
return Task.FromResult(_responses.Dequeue());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user