diff --git a/Docs/ADR/025-structured-plugin-health-contract.md b/Docs/ADR/025-structured-plugin-health-contract.md new file mode 100644 index 0000000..4e225b0 --- /dev/null +++ b/Docs/ADR/025-structured-plugin-health-contract.md @@ -0,0 +1,74 @@ +[ADR Home](../../README.md) | [Category Index](./README.md) | [Previous](./024-plugin-lifecycle-and-hosted-services.md) | [Next]() + +# [ADR-025] Expose Structured And Cancellable Plugin Health Results + +*2026-09* | Status: accepted + +**Tag:** #adr_025 + +**Date:** 2026-09-13 + +**Scope:** AuthKit.Plugins.Abstractions + Host + +## Context + +Plugins may depend on several independent components, such as database, cache, +external API, or message queue. A single boolean health value cannot preserve +which component is degraded or why check failed. Health checks may also perform +asynchronous I/O and need to stop when the request or host is cancelled. + +## Problem + +The original plugin contract returned `Task CheckHealthAsync(IServiceProvider)`. +That shape loses intermediate health states, diagnostic information, multiple +observations, and cancellation. Replacing it without a migration path would +break existing plugin implementations. + +## Decision + +`IAuthKitPlugin.CheckHealthAsync` returns: + +```csharp +Task> CheckHealthAsync( + IServiceProvider services, + CancellationToken cancellationToken = default) +``` + +`PluginHealthResult` contains the strongly typed `PluginHealthStatus`, an optional +reason, and optional plugin owned diagnostic data. `Healthy`, `Degraded`, and +`Unhealthy` remain distinct. A plugin may return one result or multiple results, +with each result representing an independent observation. + +The default interface implementation returns one `Healthy` result so plugins that +do not provide custom check remain valid. The host preserves the result list and +uses the supplied request cancellation token. Cancellation is propagated rather +than converted into fabricated health result. + +## Rejected + +- Keeping `bool` would discard degraded state and diagnostics. +- Collapsing multiple results inside the plugin would make host aggregation lossy. +- Inferring status from `Reason` or `Data` would make the contract weakly typed. +- Silently replacing cancellation with `Healthy` or `Unhealthy` would hide an + incomplete check. +- Adding second health method would leave two competing public contracts. + +## Consequences + +Health consumers must handle list of structured results and define aggregation +explicitly. The host health endpoint reports the highest severity status while +preserving every plugin result in the response. Plugin-specific diagnostic keys +remain extensible, but they do not override `Status`. + +The change is source breaking for plugins that implement the old `Task` +method; shipped plugins and the manifest example are migrated together. The +contract validator invokes the new method and rejects an empty result collection. + +## Related + +- [ADR-009](./009-dynamic-plugin-discovery.md) - plugin contract and dynamic loading +- [ADR-024](./024-plugin-lifecycle-and-hosted-services.md) - plugin lifecycle integration +- [Issue #14](https://github.com/AuthKits/AuthKit.Server/issues/14) - structured plugin health result +- [Issue #15](https://github.com/AuthKits/AuthKit.Server/issues/15) - multiple results and cancellation + +[ADR Home](../../README.md) | [Category Index](./README.md) | [Previous](./024-plugin-lifecycle-and-hosted-services.md) | [Next]() diff --git a/src/Host/Configuration/EndpointConfiguration.cs b/src/Host/Configuration/EndpointConfiguration.cs index b11dcf2..7268893 100644 --- a/src/Host/Configuration/EndpointConfiguration.cs +++ b/src/Host/Configuration/EndpointConfiguration.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using AuthKit.Plugins.Abstractions.Models; using Core.KeyManagement.Interfaces; using Host.Plugins; @@ -59,15 +60,25 @@ public static WebApplication MapAppEndpoints( { var keyStoreHealthy = keyStore.GetPublicJwks().Any(); - var pluginResults = new Dictionary(); + var pluginResults = new Dictionary>(); foreach (var lp in plugins) - pluginResults[lp.Plugin.Name] = await lp.Plugin.CheckHealthAsync(context.RequestServices); + pluginResults[lp.Plugin.Name] = await lp.Plugin.CheckHealthAsync( + context.RequestServices, + context.RequestAborted); - var healthy = keyStoreHealthy && pluginResults.Values.All(ok => ok); + var pluginStatus = pluginResults.Values + .SelectMany(results => results) + .Select(result => result.Status) + .DefaultIfEmpty(PluginHealthStatus.Healthy) + .Max(); + var status = keyStoreHealthy + ? pluginStatus + : PluginHealthStatus.Unhealthy; + var healthy = status == PluginHealthStatus.Healthy; return Results.Json(new { - status = healthy ? "Healthy" : "Unhealthy", + status = status.ToString(), time = DateTime.UtcNow, jwtKeyStore = keyStoreHealthy ? "Healthy" : "Unhealthy", plugins = pluginResults diff --git a/src/Host/KeyManagement/Security/JwtKeyStoreInitializer.cs b/src/Host/KeyManagement/Security/JwtKeyStoreInitializer.cs index 6009392..112ea11 100644 --- a/src/Host/KeyManagement/Security/JwtKeyStoreInitializer.cs +++ b/src/Host/KeyManagement/Security/JwtKeyStoreInitializer.cs @@ -54,12 +54,19 @@ public async Task StartAsync(CancellationToken cancellationToken) var stopwatch = Stopwatch.StartNew(); - await AnsiConsole.Status() - .Spinner(Spinner.Known.Dots) - .SpinnerStyle(Style.Parse("green")) - .StartAsync( - "Initializing JWT KeyStore...", - async _ => await store.InitializeAsync()); + if (System.Console.IsOutputRedirected) + { + await store.InitializeAsync(); + } + else + { + await AnsiConsole.Status() + .Spinner(Spinner.Known.Dots) + .SpinnerStyle(Style.Parse("green")) + .StartAsync( + "Initializing JWT KeyStore...", + async _ => await store.InitializeAsync()); + } stopwatch.Stop(); diff --git a/src/Plugins/Abstractions/Contracts/IAuthKitPlugin.cs b/src/Plugins/Abstractions/Contracts/IAuthKitPlugin.cs index 397faa4..631b86c 100644 --- a/src/Plugins/Abstractions/Contracts/IAuthKitPlugin.cs +++ b/src/Plugins/Abstractions/Contracts/IAuthKitPlugin.cs @@ -206,12 +206,12 @@ void ConfigureServices(IServiceCollection services, AuthKitPluginContext context ConfigureServices(services, context.Configuration); /// - /// Performs an optional health check for the plugin. + /// Performs an optional structured health check for the plugin. /// /// The root service provider of the host application. + /// A token that can cancel the health check. /// - /// true when the plugin is currently able to serve requests; - /// otherwise, false. + /// One or more structured health results reported by the plugin. /// /// /// @@ -221,18 +221,21 @@ void ConfigureServices(IServiceCollection services, AuthKitPluginContext context /// dependencies. /// /// - /// A plugin should return false when a required dependency is - /// unavailable, such as when its database or external service cannot - /// currently be reached. + /// A plugin may return separate results for independent dependencies or + /// capabilities. Cancellation must be propagated to cancellable operations + /// and is not converted into a fabricated health result. /// /// - /// The default implementation reports the plugin as healthy. Plugins - /// that do not require custom health validation therefore do not need - /// to implement this member. + /// The default implementation reports the plugin as healthy. Existing + /// plugins that do not require custom health validation therefore do not + /// need to implement this member. /// /// - Task CheckHealthAsync(IServiceProvider services) => - Task.FromResult(true); + Task> CheckHealthAsync( + IServiceProvider services, + CancellationToken cancellationToken = default) => + Task.FromResult>( + [new PluginHealthResult(PluginHealthStatus.Healthy)]); /// /// Gets the optional ASP.NET Core middleware type contributed by the plugin. diff --git a/src/Plugins/Abstractions/Models/PluginHealthResult.cs b/src/Plugins/Abstractions/Models/PluginHealthResult.cs new file mode 100644 index 0000000..1542c1f --- /dev/null +++ b/src/Plugins/Abstractions/Models/PluginHealthResult.cs @@ -0,0 +1,58 @@ +namespace AuthKit.Plugins.Abstractions.Models; + +/// +/// Represents structured health observation reported by an AuthKit plugin. +/// +/// +/// +/// provides the strongly typed health classification. The +/// optional and members add context but +/// must not redefine or override that classification. +/// +/// +/// is owned by the plugin and may contain plugin-specific +/// diagnostic values such as dependency names, endpoint information, or queue +/// depth. The host may serialize this data without assigning it health semantics. +/// +/// +public sealed record PluginHealthResult +{ + /// + /// Initializes a structured plugin health result. + /// + /// The strongly typed operational health state. + /// An optional human-readable explanation of the health state. + /// Optional plugin-owned diagnostic data. + public PluginHealthResult( + PluginHealthStatus status, + string? reason = null, + IReadOnlyDictionary? data = null) + { + Status = status; + Reason = reason; + Data = data; + } + + /// + /// Gets the strongly typed operational health state. + /// + public PluginHealthStatus Status { get; init; } + + /// + /// Gets the optional human-readable explanation of the health state. + /// + /// + /// A reason is not required when is + /// . + /// + public string? Reason { get; init; } + + /// + /// Gets optional plugin-owned diagnostic data. + /// + /// + /// Diagnostic data is extensible plugin-specific information. It must not + /// be used to replace the strongly typed value of . + /// + public IReadOnlyDictionary? Data { get; init; } +} \ No newline at end of file diff --git a/src/Plugins/Abstractions/Models/PluginHealthStatus.cs b/src/Plugins/Abstractions/Models/PluginHealthStatus.cs new file mode 100644 index 0000000..83c89ca --- /dev/null +++ b/src/Plugins/Abstractions/Models/PluginHealthStatus.cs @@ -0,0 +1,33 @@ +namespace AuthKit.Plugins.Abstractions.Models; + +/// +/// Represents the operational health state reported by an AuthKit plugin. +/// +/// +/// +/// The status is the authoritative health classification consumed by the host. +/// It must not be inferred from the optional reason or diagnostic data. +/// +/// +/// indicates that the plugin remains operational while +/// one or more non-critical capabilities or dependencies are impaired. +/// +/// +public enum PluginHealthStatus +{ + /// + /// The plugin is operating normally. + /// + Healthy = 0, + + /// + /// The plugin remains operational, but one or more capabilities or + /// non-critical dependencies are degraded. + /// + Degraded = 1, + + /// + /// The plugin cannot operate correctly, or a critical dependency has failed. + /// + Unhealthy = 2 +} \ No newline at end of file diff --git a/src/Plugins/Solutions/DevTokens/DevTokensPlugin.cs b/src/Plugins/Solutions/DevTokens/DevTokensPlugin.cs index 661847f..c2353db 100644 --- a/src/Plugins/Solutions/DevTokens/DevTokensPlugin.cs +++ b/src/Plugins/Solutions/DevTokens/DevTokensPlugin.cs @@ -3,6 +3,7 @@ using AuthKit.Plugins.Abstractions.Contracts; using AuthKit.Plugins.Abstractions.Contracts.Plugins; using AuthKit.Plugins.Abstractions.Contracts.SecuritySchemes; +using AuthKit.Plugins.Abstractions.Models; using FluentValidation; using DevTokens.Interfaces; using DevTokens.Middleware; @@ -74,20 +75,29 @@ public void ConfigureServices(IServiceCollection services, IConfiguration config public Type MiddlewareType => typeof(DeveloperTokenMiddleware); - public async Task CheckHealthAsync(IServiceProvider services) + public async Task> CheckHealthAsync( + IServiceProvider services, + CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); var store = services.GetService(); - if (store is null) return false; + if (store is null) + return [new(PluginHealthStatus.Unhealthy, "Document store is unavailable.")]; try { await using var session = store.LightweightSession(); - await session.Query().Take(1).ToListAsync(); - return true; + await session.Query().Take(1).ToListAsync(token: cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + return [new(PluginHealthStatus.Healthy, "Developer token store is available.")]; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; } catch { - return false; + return [new(PluginHealthStatus.Unhealthy, "Developer token store is unavailable.")]; } } diff --git a/src/Plugins/Solutions/DevTools/DevToolsPlugin.cs b/src/Plugins/Solutions/DevTools/DevToolsPlugin.cs index e067dbd..cb07bee 100644 --- a/src/Plugins/Solutions/DevTools/DevToolsPlugin.cs +++ b/src/Plugins/Solutions/DevTools/DevToolsPlugin.cs @@ -2,6 +2,7 @@ using AuthKit.Plugins.Abstractions; using AuthKit.Plugins.Abstractions.Contracts; using AuthKit.Plugins.Abstractions.Contracts.Plugins; +using AuthKit.Plugins.Abstractions.Models; using DevTools.Catalog; using DevTools.Middleware; using DevTools.Runtime; @@ -72,19 +73,26 @@ public void ConfigureServices(IServiceCollection services, IConfiguration config /// /// The root service provider of the host application. /// true when the catalog is available; otherwise, false. - public Task CheckHealthAsync(IServiceProvider services) + public Task> CheckHealthAsync( + IServiceProvider services, + CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); var catalog = services.GetService(); - if (catalog is null) return Task.FromResult(false); + if (catalog is null) + return Task.FromResult>( + [new(PluginHealthStatus.Unhealthy, "gRPC service catalog is unavailable.")]); try { _ = catalog.GetServices(); - return Task.FromResult(true); + return Task.FromResult>( + [new(PluginHealthStatus.Healthy, "gRPC service catalog is available.")]); } catch { - return Task.FromResult(false); + return Task.FromResult>( + [new(PluginHealthStatus.Unhealthy, "gRPC service catalog is unavailable.")]); } } } \ No newline at end of file diff --git a/tests/Host.IntegrationTests/PluginHealthEndpointIntegrationTests.cs b/tests/Host.IntegrationTests/PluginHealthEndpointIntegrationTests.cs new file mode 100644 index 0000000..a627055 --- /dev/null +++ b/tests/Host.IntegrationTests/PluginHealthEndpointIntegrationTests.cs @@ -0,0 +1,47 @@ +using System.Net; +using System.Text.Json; +using Xunit; + +namespace AuthKit.Host.IntegrationTests; + +/// +/// Verifies that the real host executes the plugin health contract through its +/// /health endpoint: per-plugin CheckHealthAsync invocation, result +/// collection, status aggregation, and serialization of the structured results. +/// +public sealed class PluginHealthEndpointIntegrationTests : IClassFixture +{ + private readonly AuthKitWebApplicationFactory _factory; + + public PluginHealthEndpointIntegrationTests(AuthKitWebApplicationFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task Health_ExecutesPluginHealthChecksThroughTheRealHost() + { + var client = _factory.CreateClient(); + + var response = await client.GetAsync("/health"); + + Assert.True( + response.StatusCode is HttpStatusCode.OK or HttpStatusCode.ServiceUnavailable, + $"Expected 200 or 503, got {(int)response.StatusCode}."); + + using var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + var root = body.RootElement; + + Assert.Contains( + root.GetProperty("status").GetString(), + new[] { "Healthy", "Degraded", "Unhealthy" }); + + Assert.Equal("Healthy", root.GetProperty("jwtKeyStore").GetString()); + + var plugins = root.GetProperty("plugins"); + Assert.Single(plugins.EnumerateObject()); + Assert.True(plugins.TryGetProperty("DevTokens", out var devTokens)); + Assert.True(devTokens.GetArrayLength() >= 1); + Assert.InRange(devTokens[0].GetProperty("status").GetInt32(), 0, 2); + } +} \ No newline at end of file diff --git a/tests/Host/PluginHealthEndpointTests.cs b/tests/Host/PluginHealthEndpointTests.cs new file mode 100644 index 0000000..0204592 --- /dev/null +++ b/tests/Host/PluginHealthEndpointTests.cs @@ -0,0 +1,178 @@ +using System.Text.Json; +using AuthKit.Plugins.Abstractions; +using AuthKit.Plugins.Abstractions.Contracts; +using AuthKit.Plugins.Abstractions.Models; +using Core.KeyManagement.DTO; +using Core.KeyManagement.Interfaces; +using Host.Configuration; +using Host.Plugins; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.IdentityModel.Tokens; +using PluginMetadataAttribute = AuthKit.Plugins.Abstractions.Contracts.Plugins.PluginMetadataAttribute; +using Xunit; + +namespace AuthKit.Host.Tests; + +/// +/// Verifies the health endpoint aggregation semantics implemented by +/// : collection of multiple +/// structured results, maximum-status aggregation, default behavior for plugins +/// without custom health checks, and key store integration. +/// +public sealed class PluginHealthEndpointTests +{ + [Fact] + public async Task HealthEndpoint_AggregatesMultipleResultsByMaximumStatus() + { + var healthy = new SingleResultPlugin( + new PluginHealthResult(PluginHealthStatus.Healthy, "accepted connections.")); + var degraded = new MultiResultPlugin( + new PluginHealthResult(PluginHealthStatus.Healthy, "database is available."), + new PluginHealthResult( + PluginHealthStatus.Degraded, + "cache is responding slowly.", + new Dictionary { ["cache-latency-ms"] = "3200" })); + + await using var app = await BuildHostAsync( + [Load(healthy), Load(degraded)], + keyStoreHealthy: true); + + using var response = await app.Client.GetAsync("/health"); + + Assert.Equal(StatusCodes.Status503ServiceUnavailable, (int)response.StatusCode); + + using var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + var root = body.RootElement; + + Assert.Equal("Degraded", root.GetProperty("status").GetString()); + Assert.Equal("Healthy", root.GetProperty("jwtKeyStore").GetString()); + + var plugins = root.GetProperty("plugins"); + + var singleEntry = plugins.GetProperty("healthy-single"); + Assert.Equal(1, singleEntry.GetArrayLength()); + Assert.Equal((int)PluginHealthStatus.Healthy, singleEntry[0].GetProperty("status").GetInt32()); + Assert.Equal("accepted connections.", singleEntry[0].GetProperty("reason").GetString()); + + var multiEntry = plugins.GetProperty("healthy-degraded"); + Assert.Equal(2, multiEntry.GetArrayLength()); + Assert.Equal((int)PluginHealthStatus.Healthy, multiEntry[0].GetProperty("status").GetInt32()); + Assert.Equal("database is available.", multiEntry[0].GetProperty("reason").GetString()); + Assert.Equal((int)PluginHealthStatus.Degraded, multiEntry[1].GetProperty("status").GetInt32()); + Assert.Equal("3200", multiEntry[1].GetProperty("data").GetProperty("cache-latency-ms").GetString()); + } + + [Fact] + public async Task HealthEndpoint_ReturnsHealthyWhenAllPluginsReportHealthy() + { + await using var app = await BuildHostAsync( + [Load(new SingleResultPlugin(new PluginHealthResult(PluginHealthStatus.Healthy, "ok")))], + keyStoreHealthy: true); + + using var response = await app.Client.GetAsync("/health"); + + Assert.Equal(StatusCodes.Status200OK, (int)response.StatusCode); + + using var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + Assert.Equal("Healthy", body.RootElement.GetProperty("status").GetString()); + } + + [Fact] + public async Task HealthEndpoint_TreatsPluginsWithoutCustomHealthChecksAsHealthy() + { + await using var app = await BuildHostAsync( + [Load(new DefaultHealthPlugin())], + keyStoreHealthy: true); + + using var response = await app.Client.GetAsync("/health"); + + Assert.Equal(StatusCodes.Status200OK, (int)response.StatusCode); + + using var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + var entry = body.RootElement.GetProperty("plugins").GetProperty("default-behavior"); + Assert.Equal(1, entry.GetArrayLength()); + Assert.Equal((int)PluginHealthStatus.Healthy, entry[0].GetProperty("status").GetInt32()); + } + + [Fact] + public async Task HealthEndpoint_ReportsUnhealthyKeyStoreDespiteHealthyPlugins() + { + await using var app = await BuildHostAsync( + [Load(new SingleResultPlugin(new PluginHealthResult(PluginHealthStatus.Healthy, "ok")))], + keyStoreHealthy: false); + + using var response = await app.Client.GetAsync("/health"); + + Assert.Equal(StatusCodes.Status503ServiceUnavailable, (int)response.StatusCode); + + using var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + var root = body.RootElement; + Assert.Equal("Unhealthy", root.GetProperty("status").GetString()); + Assert.Equal("Unhealthy", root.GetProperty("jwtKeyStore").GetString()); + var entry = root.GetProperty("plugins").GetProperty("healthy-single"); + Assert.Equal((int)PluginHealthStatus.Healthy, entry[0].GetProperty("status").GetInt32()); + } + + private static async Task BuildHostAsync( + IReadOnlyList plugins, + bool keyStoreHealthy) + { + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + builder.Services.AddControllers(); + builder.Services.AddSingleton(new StubKeyStore(keyStoreHealthy)); + builder.Services.AddSingleton(plugins); + + var app = builder.Build(); + app.MapAppEndpoints(plugins); + await app.StartAsync(); + + return new AppUnderTest(app, app.GetTestClient()); + } + + private sealed record AppUnderTest(WebApplication App, HttpClient Client) : IAsyncDisposable + { + public ValueTask DisposeAsync() => App.DisposeAsync(); + } + + private sealed class StubKeyStore(bool healthy) : IJwtKeyStore + { + public IEnumerable GetPublicJwks() => + healthy ? new[] { new PublicJwkDto() } : Array.Empty(); + + public Task InitializeAsync() => Task.CompletedTask; + public SigningCredentials GetActiveSigningCredentials() => throw new NotSupportedException(); + public SigningCredentials? GetSigningCredentialsByKid(string kid) => throw new NotSupportedException(); + public Task RotateAsync(int rsaBits = 4096) => throw new NotSupportedException(); + public Task RevokeAsync(string kid) => throw new NotSupportedException(); + public KeyMetadata? GetMetadata(string kid) => throw new NotSupportedException(); + } + + private static LoadedPlugin Load(IAuthKitPlugin plugin) => + new(plugin, plugin.GetType().Assembly, "test"); + + [PluginMetadataAttribute("healthy-single", "1.0.0", [], [], [], description: "Single result health plugin")] + private sealed class SingleResultPlugin(PluginHealthResult result) : IAuthKitPlugin + { + public Task> CheckHealthAsync( + IServiceProvider services, + CancellationToken cancellationToken = default) => + Task.FromResult>([result]); + } + + [PluginMetadataAttribute("healthy-degraded", "1.0.0", [], [], [], description: "Multi result health plugin")] + private sealed class MultiResultPlugin(params PluginHealthResult[] results) : IAuthKitPlugin + { + public Task> CheckHealthAsync( + IServiceProvider services, + CancellationToken cancellationToken = default) => + Task.FromResult>(results); + } + + [PluginMetadataAttribute("default-behavior", "1.0.0", [], [], [], description: "Default health plugin")] + private sealed class DefaultHealthPlugin : IAuthKitPlugin; +} \ No newline at end of file diff --git a/tests/Plugins/Abstractions/PluginHealthResultTests.cs b/tests/Plugins/Abstractions/PluginHealthResultTests.cs new file mode 100644 index 0000000..91b7f2a --- /dev/null +++ b/tests/Plugins/Abstractions/PluginHealthResultTests.cs @@ -0,0 +1,104 @@ +using System.Text.Json; +using AuthKit.Plugins.Abstractions.Contracts; +using AuthKit.Plugins.Abstractions.Contracts.Plugins; +using AuthKit.Plugins.Abstractions.Models; +using Xunit; + +namespace AuthKit.Plugins.Abstractions.Tests; + +public sealed class PluginHealthResultTests +{ + [Fact] + public void Statuses_AreExplicitlyDistinct() + { + Assert.NotEqual(PluginHealthStatus.Healthy, PluginHealthStatus.Degraded); + Assert.NotEqual(PluginHealthStatus.Degraded, PluginHealthStatus.Unhealthy); + Assert.NotEqual(PluginHealthStatus.Healthy, PluginHealthStatus.Unhealthy); + } + + [Fact] + public void HealthyResult_AllowsOptionalReasonAndData() + { + var result = new PluginHealthResult(PluginHealthStatus.Healthy); + + Assert.Equal(PluginHealthStatus.Healthy, result.Status); + Assert.Null(result.Reason); + Assert.Null(result.Data); + } + + [Fact] + public void Result_PreservesReasonAndDiagnosticDataThroughSerialization() + { + var result = new PluginHealthResult( + PluginHealthStatus.Degraded, + "Cache is unavailable", + new Dictionary + { + ["dependency"] = "cache", + ["retry_count"] = 2 + }); + + var json = JsonSerializer.Serialize(result); + var restored = JsonSerializer.Deserialize(json); + + Assert.NotNull(restored); + Assert.Equal(PluginHealthStatus.Degraded, restored.Status); + Assert.Equal("Cache is unavailable", restored.Reason); + Assert.NotNull(restored.Data); + Assert.Equal("cache", restored.Data["dependency"].ToString()); + Assert.Equal("2", restored.Data["retry_count"].ToString()); + } + + [Fact] + public async Task PluginHealthContract_PreservesMultipleResultsAndCancellationToken() + { + var plugin = new StructuredHealthPlugin(); + using var cancellation = new CancellationTokenSource(); + + var results = await plugin.CheckHealthAsync( + new ServiceProviderStub(), + cancellation.Token); + + Assert.Equal(2, results.Count); + Assert.Equal(PluginHealthStatus.Healthy, results[0].Status); + Assert.Equal(PluginHealthStatus.Degraded, results[1].Status); + Assert.Equal(cancellation.Token, plugin.ReceivedToken); + } + + [Fact] + public async Task PluginHealthContract_DefaultImplementationReturnsHealthyResult() + { + IAuthKitPlugin plugin = new DefaultHealthPlugin(); + + var results = await plugin.CheckHealthAsync(new ServiceProviderStub()); + + var result = Assert.Single(results); + Assert.Equal(PluginHealthStatus.Healthy, result.Status); + } + + [PluginMetadata("structured-health", "1.0.0", [], [], [], description: "Structured health test")] + private sealed class StructuredHealthPlugin : IAuthKitPlugin + { + public CancellationToken ReceivedToken { get; private set; } + + public Task> CheckHealthAsync( + IServiceProvider services, + CancellationToken cancellationToken = default) + { + ReceivedToken = cancellationToken; + return Task.FromResult>( + [ + new(PluginHealthStatus.Healthy, "Database is available."), + new(PluginHealthStatus.Degraded, "Cache is responding slowly.") + ]); + } + } + + [PluginMetadata("default-health", "1.0.0", [], [], [], description: "Default health test")] + private sealed class DefaultHealthPlugin : IAuthKitPlugin; + + private sealed class ServiceProviderStub : IServiceProvider + { + public object? GetService(Type serviceType) => null; + } +} \ No newline at end of file diff --git a/tools/AuthKit.ManifestGenerator/examples/ExamplePlugin/Program.cs b/tools/AuthKit.ManifestGenerator/examples/ExamplePlugin/Program.cs index 3bc1e4b..eca8d1a 100644 --- a/tools/AuthKit.ManifestGenerator/examples/ExamplePlugin/Program.cs +++ b/tools/AuthKit.ManifestGenerator/examples/ExamplePlugin/Program.cs @@ -65,13 +65,17 @@ public void ConfigureServices( } /// - /// Performs a health check for the plugin. + /// Performs a structured, cancellable health check for the plugin. /// - /// The used to resolve services required for the health check. - /// - /// A task containing when the plugin is healthy; - /// otherwise, . - /// - public Task CheckHealthAsync(IServiceProvider services) - => Task.FromResult(true); + /// The service provider used to resolve health check dependencies. + /// A token that can cancel the health check. + /// A task containing the plugin health results. + public Task> CheckHealthAsync( + IServiceProvider services, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult>( + new[] { new PluginHealthResult(PluginHealthStatus.Healthy) }); + } } diff --git a/tools/AuthKit.PluginContractValidator/src/Rules/HealthRule.cs b/tools/AuthKit.PluginContractValidator/src/Rules/HealthRule.cs index 15b5f8e..d70e05f 100644 --- a/tools/AuthKit.PluginContractValidator/src/Rules/HealthRule.cs +++ b/tools/AuthKit.PluginContractValidator/src/Rules/HealthRule.cs @@ -6,6 +6,7 @@ using AuthKit.Plugins.Abstractions.Contracts; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; namespace AuthKit.PluginContractValidator.Rules; @@ -58,7 +59,9 @@ public async Task> ValidateAsync( try { await using var provider = services.BuildServiceProvider(); - await plugin.Instance.CheckHealthAsync(provider); + var results = await plugin.Instance.CheckHealthAsync(provider, cancellationToken); + if (results is null || results.Count == 0) + errors.Add("health: CheckHealthAsync returned no health results."); } catch (Exception ex) {