diff --git a/AuthKit.slnx b/AuthKit.slnx index 90ca6e1..7ba8900 100644 --- a/AuthKit.slnx +++ b/AuthKit.slnx @@ -19,6 +19,7 @@ + diff --git a/Directory.Packages.props b/Directory.Packages.props index 6985be3..42440a4 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -14,16 +14,19 @@ + + + diff --git a/Dockerfile b/Dockerfile index 9dfb642..93e6368 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,7 +20,7 @@ COPY ["src/Plugins/Solutions/DevTools/DevTools.csproj", "src/Plugins/Solutions/D COPY ["tests/Host/AuthKit.Host.Tests.csproj", "tests/Host/"] COPY ["tests/Plugins/Abstractions/AuthKit.Plugins.Abstractions.Tests.csproj", "tests/Plugins/Abstractions/"] -COPY ["tools/PluginContractValidator/PluginContractValidator.csproj", "tools/PluginContractValidator/"] +COPY ["tools/AuthKit.PluginContractValidator/AuthKit.PluginContractValidator.csproj", "tools/AuthKit.PluginContractValidator/"] RUN dotnet restore "AuthKit.slnx" @@ -35,7 +35,8 @@ WORKDIR /src RUN dotnet publish "src/Host/Host.csproj" -c Release -o /app/publish RUN dotnet publish "src/Plugins/Solutions/DevTokens/DevTokens.csproj" -c Release -o /app/publish/plugins/DevTokens RUN dotnet publish "src/Plugins/Solutions/DevTools/DevTools.csproj" -c Release -o /app/publish/plugins/DevTools -COPY src/Plugins/Solutions/DevTokens/plugin.manifest.json /app/publish/plugins/DevTokens/plugin.manifest.json +COPY src/Plugins/Solutions/DevTokens/manifest.json /app/publish/plugins/DevTokens/manifest.json +COPY src/Plugins/Solutions/DevTools/manifest.json /app/publish/plugins/DevTools/manifest.json FROM base AS final WORKDIR /app diff --git a/Docs/ADR/022-plugin-configuration-context-and-builder.md b/Docs/ADR/022-plugin-configuration-context-and-builder.md new file mode 100644 index 0000000..a659312 --- /dev/null +++ b/Docs/ADR/022-plugin-configuration-context-and-builder.md @@ -0,0 +1,62 @@ +[ADR Home](../../README.md) | [Category Index](./README.md) | [Previous](./021-swagger-serving-via-reflection.md) | [Next](./023-plugin-application-pipeline-hooks.md) + +# [ADR-022] Extend Plugin Configuration With The Host Builder And Scoped Context + +*2026-09* | Status: accepted + +**Tag:** #adr_022 + +**Date:** 2026-09-12 + +**Scope:** AuthKit.Plugins.Abstractions + Host + +## Context + +Plugins previously configured services through `ConfigureServices(IServiceCollection, IConfiguration)`. That was sufficient for registrations, but it did not expose the actual host builder or a stable plugin-specific configuration context. + +## Problem + +Adding abstract members to `IAuthKitPlugin` would break existing plugins. Passing more individual host dependencies would also make the contract difficult to evolve and would encourage plugins to depend on host internals. + +## Decision + +The plugin contract exposes additive default interface members: + +- `ConfigureServices(IHostApplicationBuilder, IConfiguration)` for plugins that need the real AuthKit host builder. +- `ConfigureServices(IServiceCollection, AuthKitPluginContext)` for plugins that need stable plugin identity and configuration context. +- The existing `ConfigureServices(IServiceCollection, IConfiguration)` remains valid for legacy plugins. + +`AuthKitPluginContext` lives in the root `AuthKit.Plugins.Abstractions` namespace and exposes: + +- stable `PluginId`; +- `PluginName`; +- plugin-scoped `Configuration` from `Plugins:{PluginId}` with a name fallback; +- read-only full `ApplicationConfiguration` for host-level settings. + +The Host uses one dispatcher. It selects context configuration first, then host-builder configuration, then the legacy overload. Only one overload is invoked for a plugin, so compatibility paths cannot register the same services twice. + +### Design Rationale + +- Default interface implementations preserve source compatibility. +- The actual `WebApplicationBuilder` is passed instead of constructing an isolated builder. +- Plugin-scoped configuration prevents one plugin from accidentally reading another plugin's settings. +- The full application configuration remains available explicitly without creating a second DI or configuration system. + +## Rejected + +- Making the new overloads abstract would break existing plugins. +- Constructing a separate host builder would disconnect registrations from the running application. +- Passing `IServiceProvider` through the context would introduce service-locator behavior. +- Invoking every overload would cause duplicate registration and ambiguous behavior. + +## Consequences + +New plugins can opt into host-aware configuration or scoped context data. Existing plugins such as DevTokens and DevTools continue to use their legacy implementation without source changes. The dispatcher is a Host concern and the public contract remains independent of the Host's internal plugin loader. + +## Related + +- [ADR-009](./009-dynamic-plugin-discovery.md) - the plugin contract and dynamic discovery +- [ADR-019](./019-plugin-metadata-attribute.md) - declarative plugin identity used by the context +- [Issue #8](https://github.com/AuthKits/AuthKit.Server/issues/8) - host builder and plugin context requirements + +[ADR Home](../../README.md) | [Category Index](./README.md) | [Previous](./021-swagger-serving-via-reflection.md) | [Next](./023-plugin-application-pipeline-hooks.md) diff --git a/Docs/ADR/023-plugin-application-pipeline-hooks.md b/Docs/ADR/023-plugin-application-pipeline-hooks.md new file mode 100644 index 0000000..4e2908e --- /dev/null +++ b/Docs/ADR/023-plugin-application-pipeline-hooks.md @@ -0,0 +1,60 @@ +[ADR Home](../../README.md) | [Category Index](./README.md) | [Previous](./022-plugin-configuration-context-and-builder.md) | [Next](./024-plugin-lifecycle-and-hosted-services.md) + +# [ADR-023] Integrate Plugin Endpoints And Middleware Through Explicit Host Pipeline Hooks + +*2026-09* | Status: accepted + +**Tag:** #adr_023 + +**Date:** 2026-09-12 + +**Scope:** AuthKit.Plugins.Abstractions + Host + +## Context + +Plugins need to contribute endpoints and request middleware, but ASP.NET Core middleware ordering is part of application behavior. Discovery order, filesystem order, or assembly load order must not decide where plugin code runs. + +## Problem + +The original contract exposed only `MiddlewareType`, which provided one implicit middleware slot and no endpoint registration hook. Plugins could not explicitly place middleware relative to routing, authentication, authorization, or endpoint execution. + +## Decision + +The contract adds optional default hooks: + +- `MapEndpoints(IEndpointRouteBuilder)` for normal ASP.NET Core endpoint routing; +- `ConfigureApplication(IApplicationBuilder)` for plugin application configuration; +- `ConfigurePipeline(IApplicationBuilder, PluginPipelinePosition)` for explicitly positioned middleware; +- `PipelinePosition`, using the strongly typed `PluginPipelinePosition` enum. + +The supported positions are `BeforeRouting`, `AfterRouting`, `BeforeAuthentication`, `AfterAuthentication`, `BeforeAuthorization`, `AfterAuthorization`, `BeforeEndpoints`, and `AfterEndpoints`. + +The Host applies hooks to the real application builder and endpoint route builder. Plugins at the same position are ordered by stable `Plugin.Id`, independently of discovery order. `AfterEndpoints` runs after REST and gRPC endpoint mapping. + +Existing `MiddlewareType` behavior remains available in its original slot. If a plugin implements `ConfigureApplication` or `ConfigurePipeline`, the Host does not also register its `MiddlewareType`, preventing accidental duplicate middleware registration. Hook exceptions and invalid positions are surfaced explicitly. + +### Design Rationale + +- Endpoint hooks use the normal ASP.NET Core routing system, preserving endpoint metadata, authorization, authentication, OpenAPI discovery, and endpoint selection. +- A finite enum exposes meaningful pipeline stages without making every internal middleware implementation a public dependency. +- Sorting by stable plugin ID makes equal-position ordering deterministic and testable. +- Default interface members keep plugins that only use `MiddlewareType` source-compatible. + +## Rejected + +- Keeping middleware order equal to plugin discovery order is nondeterministic. +- Arbitrary string positions are weakly typed and cannot be validated reliably. +- A parallel endpoint router would bypass ASP.NET Core endpoint metadata and selection. +- Silently moving invalid positions to the end would hide plugin configuration errors. + +## Consequences + +Plugins can participate in the host's endpoint and middleware pipeline without modifying host startup code. Pipeline placement is explicit and reviewable. Plugin authors must choose a supported stage when using `ConfigurePipeline`; the Host owns the stage boundaries and deterministic ordering. + +## Related + +- [ADR-009](./009-dynamic-plugin-discovery.md) - plugin discovery and contract boundary +- [ADR-010](./010-plugin-loading-from-directory.md) - plugin loading and legacy middleware slot +- [Issue #9](https://github.com/AuthKits/AuthKit.Server/issues/9) - endpoint and application pipeline requirements + +[ADR Home](../../README.md) | [Category Index](./README.md) | [Previous](./022-plugin-configuration-context-and-builder.md) | [Next](./024-plugin-lifecycle-and-hosted-services.md) diff --git a/Docs/ADR/024-plugin-lifecycle-and-hosted-services.md b/Docs/ADR/024-plugin-lifecycle-and-hosted-services.md new file mode 100644 index 0000000..4006a57 --- /dev/null +++ b/Docs/ADR/024-plugin-lifecycle-and-hosted-services.md @@ -0,0 +1,62 @@ +[ADR Home](../../README.md) | [Category Index](./README.md) | [Previous](./023-plugin-application-pipeline-hooks.md) | [Next]() + +# [ADR-024] Bridge Plugin Lifecycle Hooks To The Standard .NET Host Lifecycle + +*2026-09* | Status: accepted + +**Tag:** #adr_024 + +**Date:** 2026-09-12 + +**Scope:** AuthKit.Plugins.Abstractions + Host + +## Context + +Plugins may need to initialize runtime resources, perform work after startup, release external registrations during shutdown, or contribute background services. Static service registration cannot represent those operations safely. + +## Problem + +Without explicit lifecycle hooks, plugins would need host-specific startup code or custom hosted-service schedulers. Manually invoking plugin background services would also bypass the standard .NET host lifecycle and its cancellation semantics. + +## Decision + +`IAuthKitPlugin` exposes additive default members: + +- `OnStartingAsync(CancellationToken)`; +- `OnStartedAsync(CancellationToken)`; +- `OnStoppingAsync(CancellationToken)`; +- `GetHostedServices()` returning a non-null `IReadOnlyList`. + +The Host uses one `PluginLifecycleHostedService` bridge registered through the normal DI container. It orders plugins by stable `Plugin.Id`: + +- `OnStartingAsync` runs in ascending order during hosted-service startup; +- `OnStartedAsync` runs after `ApplicationStarted` and only after successful startup; +- `OnStoppingAsync` runs in reverse order when `ApplicationStopping` is signaled. + +Plugin-provided hosted services are registered as singleton `IHostedService` instances before host startup. Their `StartAsync` and `StopAsync` methods are therefore invoked by the standard .NET host rather than by AuthKit code. Null results, null service instances, duplicate registration, and lifecycle exceptions are rejected explicitly. Lifecycle failures include the plugin ID and lifecycle stage in the thrown exception. + +### Design Rationale + +- Standard `IHostedService` integration preserves the framework's startup, shutdown, cancellation, and disposal behavior. +- One lifecycle bridge prevents duplicate hook invocation and avoids a custom scheduler. +- Stable ID ordering makes startup and shutdown deterministic regardless of discovery order. +- Default interface members preserve compatibility for plugins that do not need lifecycle behavior. + +## Rejected + +- Calling hosted-service `StartAsync` and `StopAsync` manually would create a second lifecycle implementation. +- Creating another service provider or service scope would split plugin dependencies from the application DI container. +- Ignoring lifecycle exceptions would allow the host to report a plugin as healthy when initialization failed. +- Using discovery order would make lifecycle behavior depend on filesystem or assembly enumeration. + +## Consequences + +Plugin lifecycle failures fail explicitly through the host startup/shutdown path. Plugin authors can use cancellation-aware hooks for initialization and cleanup, while long-running work belongs in `IHostedService` implementations returned by `GetHostedServices()`. Existing plugins that return no hosted services and implement no hooks remain valid through safe defaults. + +## Related + +- [ADR-009](./009-dynamic-plugin-discovery.md) - plugin discovery and contract boundary +- [ADR-016](./016-marten-and-wolverine-infrastructure.md) - host infrastructure lifecycle +- [Issue #10](https://github.com/AuthKits/AuthKit.Server/issues/10) - lifecycle and hosted-service requirements + +[ADR Home](../../README.md) | [Category Index](./README.md) | [Previous](./023-plugin-application-pipeline-hooks.md) | [Next]() diff --git a/Docs/ADR/README.md b/Docs/ADR/README.md index 35ba5d6..2654f47 100644 --- a/Docs/ADR/README.md +++ b/Docs/ADR/README.md @@ -66,6 +66,15 @@ The table below shows the architecture areas and their current scope. | [ADR-014](./014-error-responses-via-middleware.md) | Render HTTP Errors As RFC 7807 Problem Details Via Middleware | Host | accepted | 2026-08-26 | | [ADR-015](./015-keycloak-external-jwt-authority.md) | Use Keycloak As The External JWT Authority | Host | accepted | 2026-08-26 | | [ADR-016](./016-marten-and-wolverine-infrastructure.md) | Use Marten And Wolverine As Host Infrastructure | Host | accepted | 2026-08-26 | +| [ADR-017](./017-Plugin-Contract-and-Dynamic-Loading-Architecture.md) | Define The Plugin Contract And Dynamic Loading Architecture | Plugins | accepted | 2026-09-11 | +| [ADR-017](./017-api-key-credential-extraction-strategies.md) | Define API Key Credential Extraction Strategies | Host | accepted | 2026-09-11 | +| [ADR-018](./018-security-scheme-contract-explicit-handling.md) | Handle Security Scheme Contract Values Explicitly | Plugins | accepted | 2026-09-11 | +| [ADR-019](./019-plugin-metadata-attribute.md) | Declare Plugin Identity Through The PluginMetadata Attribute | Plugins | accepted | 2026-09-11 | +| [ADR-020](./020-devtools-plugin.md) | Host Developer Tools Through A Dedicated DevTools Plugin | Plugins | accepted | 2026-09-11 | +| [ADR-021](./021-swagger-serving-via-reflection.md) | Serve Swagger Through Reflection-Based SwaggerHost In DevTools | Plugins | accepted | 2026-09-11 | +| [ADR-022](./022-plugin-configuration-context-and-builder.md) | Extend Plugin Configuration With The Host Builder And Scoped Context | Plugins | accepted | 2026-09-12 | +| [ADR-023](./023-plugin-application-pipeline-hooks.md) | Integrate Plugin Endpoints And Middleware Through Explicit Host Pipeline Hooks | Plugins | accepted | 2026-09-12 | +| [ADR-024](./024-plugin-lifecycle-and-hosted-services.md) | Bridge Plugin Lifecycle Hooks To The Standard .NET Host Lifecycle | Plugins | accepted | 2026-09-12 | ## Relationships Between Areas diff --git a/src/Host/Configuration/AppMiddlewareConfiguration.cs b/src/Host/Configuration/AppMiddlewareConfiguration.cs index ab14c50..3450d59 100644 --- a/src/Host/Configuration/AppMiddlewareConfiguration.cs +++ b/src/Host/Configuration/AppMiddlewareConfiguration.cs @@ -1,6 +1,8 @@ using Host.Plugins; using Host.Restful.Middleware.Exceptions; using Host.Security.Middleware; +using AuthKit.Plugins.Abstractions; +using AuthKit.Plugins.Abstractions.Contracts; namespace Host.Configuration; @@ -14,9 +16,11 @@ namespace Host.Configuration; /// middleware, and authentication and authorization. /// /// -/// Plugin middleware is inserted after the host exception handling middleware -/// and before authentication so plugins can participate in request processing -/// before the authenticated endpoint pipeline is reached. +/// New pipeline hooks are inserted at their strongly typed +/// . Plugins at the same position are +/// ordered by stable plugin ID. Legacy +/// middleware remains in its original slot unless the plugin opts into a new +/// application or pipeline hook. /// /// public static class AppMiddlewareConfiguration @@ -26,28 +30,32 @@ public static class AppMiddlewareConfiguration /// /// /// The plugins loaded during application startup. Plugins may optionally - /// contribute middleware through their configured middleware type. + /// contribute middleware, application hooks, or positioned pipeline hooks. /// /// The configured instance. public static WebApplication ConfigureMiddleware( this WebApplication app, IReadOnlyList plugins) { + PluginApplicationConfiguration.ConfigureApplications(app, plugins); + PluginApplicationConfiguration.ConfigurePipeline(app, plugins, PluginPipelinePosition.BeforeRouting); app.UseRouting(); + PluginApplicationConfiguration.ConfigurePipeline(app, plugins, PluginPipelinePosition.AfterRouting); app.UseMiddleware(); app.UseMiddleware(); - foreach (var plugin in plugins) - { - if (plugin.Plugin.MiddlewareType is { } middlewareType) - app.UseMiddleware(middlewareType); - } + PluginApplicationConfiguration.ConfigureLegacyMiddleware(app, plugins); + PluginApplicationConfiguration.ConfigurePipeline(app, plugins, PluginPipelinePosition.BeforeAuthentication); app.UseMiddleware(); app.UseAuthentication(); + PluginApplicationConfiguration.ConfigurePipeline(app, plugins, PluginPipelinePosition.AfterAuthentication); + PluginApplicationConfiguration.ConfigurePipeline(app, plugins, PluginPipelinePosition.BeforeAuthorization); app.UseAuthorization(); + PluginApplicationConfiguration.ConfigurePipeline(app, plugins, PluginPipelinePosition.AfterAuthorization); + PluginApplicationConfiguration.ConfigurePipeline(app, plugins, PluginPipelinePosition.BeforeEndpoints); return app; } diff --git a/src/Host/Configuration/EndpointConfiguration.cs b/src/Host/Configuration/EndpointConfiguration.cs index b40387f..b11dcf2 100644 --- a/src/Host/Configuration/EndpointConfiguration.cs +++ b/src/Host/Configuration/EndpointConfiguration.cs @@ -22,8 +22,15 @@ public static class EndpointConfiguration /// /// Maps AuthKit application endpoints to the specified web application. /// + /// + /// + /// The plugins whose endpoint hooks are invoked after host endpoint services + /// are configured and before request processing starts. + /// /// The configured instance. - public static WebApplication MapAppEndpoints(this WebApplication app) + public static WebApplication MapAppEndpoints( + this WebApplication app, + IReadOnlyList plugins) { app.MapGet("/", () => Results.Json(new { @@ -46,6 +53,7 @@ public static WebApplication MapAppEndpoints(this WebApplication app) environment = app.Environment.EnvironmentName })); app.MapControllers(); + PluginApplicationConfiguration.MapEndpoints(app, plugins); app.MapGet("/health", async (HttpContext context, IJwtKeyStore keyStore, IReadOnlyList plugins) => { diff --git a/src/Host/Configuration/InfrastructureConfiguration.cs b/src/Host/Configuration/InfrastructureConfiguration.cs index 2ab457b..cf35063 100644 --- a/src/Host/Configuration/InfrastructureConfiguration.cs +++ b/src/Host/Configuration/InfrastructureConfiguration.cs @@ -38,6 +38,12 @@ public static void ConfigureWolverine( opts.UseFluentValidation(); opts.IncludeEventHandlers(plugins); + if (builder.Configuration.GetValue("AuthKit:SkipStorageMigrationOnStartup")) + { + opts.AutoBuildMessageStorageOnStartup = AutoCreate.None; + opts.Durability.Mode = DurabilityMode.MediatorOnly; + } + opts.Policies.MessageExecutionLogLevel(LogLevel.None); opts.Policies.MessageSuccessLogLevel(LogLevel.None); }); diff --git a/src/Host/Host.csproj b/src/Host/Host.csproj index a098d35..e4f228a 100644 --- a/src/Host/Host.csproj +++ b/src/Host/Host.csproj @@ -5,6 +5,10 @@ enable enable + + + + diff --git a/src/Host/KeyManagement/Security/JwtKeyStoreInitializer.cs b/src/Host/KeyManagement/Security/JwtKeyStoreInitializer.cs index a066f4e..6009392 100644 --- a/src/Host/KeyManagement/Security/JwtKeyStoreInitializer.cs +++ b/src/Host/KeyManagement/Security/JwtKeyStoreInitializer.cs @@ -29,14 +29,17 @@ public sealed class JwtKeyStoreInitializer( ILogger logger) : IHostedService, IAsyncDisposable { + private IJwtKeyStore? _store; + /// /// Initializes the JWT key store during application startup. /// /// Token that can be used to signal cancellation of the startup operation. /// /// - /// A temporary asynchronous service scope is created to resolve the - /// instance. + /// The is registered as a singleton, so it is + /// resolved directly from the provider and held for the lifetime of the + /// initializer so its cryptographic resources can be released on shutdown. /// /// /// The initialization duration is measured using @@ -46,10 +49,8 @@ public sealed class JwtKeyStoreInitializer( /// public async Task StartAsync(CancellationToken cancellationToken) { - await using var scope = provider.CreateAsyncScope(); - - var store = - scope.ServiceProvider.GetRequiredService(); + var store = provider.GetRequiredService(); + _store = store; var stopwatch = Stopwatch.StartNew(); @@ -83,12 +84,7 @@ public Task StopAsync(CancellationToken cancellationToken) public async ValueTask DisposeAsync() { - await using var scope = provider.CreateAsyncScope(); - - if (scope.ServiceProvider.GetService() - is IAsyncDisposable asyncStore) - { + if (_store is IAsyncDisposable asyncStore) await asyncStore.DisposeAsync(); - } } } \ No newline at end of file diff --git a/src/Host/Plugins/PluginApplicationConfiguration.cs b/src/Host/Plugins/PluginApplicationConfiguration.cs new file mode 100644 index 0000000..9a5498b --- /dev/null +++ b/src/Host/Plugins/PluginApplicationConfiguration.cs @@ -0,0 +1,186 @@ +using System.Reflection; +using AuthKit.Plugins.Abstractions; +using AuthKit.Plugins.Abstractions.Contracts; + +namespace Host.Plugins; + +/// +/// Applies plugin endpoint and application pipeline hooks deterministically. +/// +/// +/// +/// Plugins are invoked in a stable order regardless of the order they were +/// discovered: first by and then by +/// plugin identifier using an ordinal comparison. +/// +/// +/// Plugins that do not implement a given hook are skipped. The newer +/// ConfigureApplication and ConfigurePipeline hooks take +/// precedence over the legacy MiddlewareType entry point, which is +/// applied only as a compatibility fallback. +/// +/// +internal static class PluginApplicationConfiguration +{ + /// + /// Invokes the + /// hook of every plugin that implements it, after the application pipeline + /// has been fully assembled. + /// + /// The exposing the + /// application pipeline. + /// The plugins loaded during application startup. + public static void ConfigureApplications(IApplicationBuilder application, IReadOnlyList plugins) + { + foreach (var loadedPlugin in Ordered(plugins)) + { + var plugin = loadedPlugin.Plugin; + if (HasImplementation(plugin, nameof(IAuthKitPlugin.ConfigureApplication), typeof(IApplicationBuilder))) + plugin.ConfigureApplication(application); + } + } + + /// + /// Invokes the + /// hook of every plugin that implements it for one explicit pipeline position. + /// + /// + /// Only plugins whose PipelinePosition matches the requested position are + /// invoked. All plugins are validated first so that a declarative unsupported + /// position fails before any hook runs. + /// + /// The exposing the + /// application pipeline. + /// The plugins loaded during application startup. + /// The pipeline position to run hooks for. + /// + /// Thrown when is not a defined + /// value. + /// + /// + /// Thrown when a plugin that implements the pipeline hook declares a + /// PipelinePosition that is not a defined enum value. + /// + public static void ConfigurePipeline( + IApplicationBuilder application, + IReadOnlyList plugins, + PluginPipelinePosition position) + { + if (!Enum.IsDefined(position)) + throw new ArgumentOutOfRangeException(nameof(position), position, "Unsupported plugin pipeline position."); + + ValidatePluginPositions(plugins); + + foreach (var loadedPlugin in Ordered(plugins)) + { + var plugin = loadedPlugin.Plugin; + if (!HasImplementation(plugin, nameof(IAuthKitPlugin.ConfigurePipeline), + typeof(IApplicationBuilder), typeof(PluginPipelinePosition)) + || plugin.PipelinePosition != position) + continue; + + plugin.ConfigurePipeline(application, position); + } + } + + /// + /// Maps the hook + /// of every plugin that implements it onto the application's route builder. + /// + /// The used to map + /// plugin endpoints. + /// The plugins loaded during application startup. + public static void MapEndpoints(IEndpointRouteBuilder endpoints, IReadOnlyList plugins) + { + foreach (var loadedPlugin in Ordered(plugins)) + { + var plugin = loadedPlugin.Plugin; + if (HasImplementation(plugin, nameof(IAuthKitPlugin.MapEndpoints), typeof(IEndpointRouteBuilder))) + plugin.MapEndpoints(endpoints); + } + } + + /// + /// Applies the legacy for plugins that + /// do not opt into the newer application or pipeline hooks. + /// + /// + /// + /// Middleware is applied only when the plugin declares it and does not implement + /// either + /// or . + /// + /// + /// This preserves the original middleware-based integration for existing plugins + /// while routing new plugins through the deterministic hook model. + /// + /// + /// The configured by the host. + /// The plugins loaded during application startup. + public static void ConfigureLegacyMiddleware(WebApplication application, IReadOnlyList plugins) + { + foreach (var loadedPlugin in Ordered(plugins)) + { + var plugin = loadedPlugin.Plugin; + if (plugin.MiddlewareType is null + || HasImplementation(plugin, nameof(IAuthKitPlugin.ConfigureApplication), typeof(IApplicationBuilder)) + || HasImplementation(plugin, nameof(IAuthKitPlugin.ConfigurePipeline), + typeof(IApplicationBuilder), typeof(PluginPipelinePosition))) + continue; + + application.UseMiddleware(plugin.MiddlewareType); + } + } + + /// + /// Orders plugins by pipeline position and then by plugin identifier. + /// + private static IEnumerable Ordered(IReadOnlyList plugins) => + plugins.OrderBy(plugin => plugin.Plugin.PipelinePosition) + .ThenBy(plugin => plugin.Plugin.Id, StringComparer.Ordinal); + + /// + /// Ensures every plugin implementing the pipeline hook declares a valid + /// before any hook is invoked. + /// + /// + /// Thrown when a plugin implements ConfigurePipeline but declares an + /// unsupported PipelinePosition. + /// + private static void ValidatePluginPositions(IReadOnlyList plugins) + { + foreach (var loadedPlugin in plugins) + { + var plugin = loadedPlugin.Plugin; + if (HasImplementation(plugin, nameof(IAuthKitPlugin.ConfigurePipeline), + typeof(IApplicationBuilder), typeof(PluginPipelinePosition)) + && !Enum.IsDefined(plugin.PipelinePosition)) + { + throw new InvalidOperationException( + $"Plugin '{plugin.Id}' declares unsupported pipeline position '{plugin.PipelinePosition}'."); + } + } + } + + /// + /// Determines whether a plugin provides a concrete implementation of the given + /// hook rather than inheriting the interface's default implementation. + /// + /// The plugin to inspect. + /// The name of the interface method to look up. + /// The parameter types that identify the overload. + /// + /// true when the plugin overrides the hook; otherwise, false. + /// + private static bool HasImplementation(IAuthKitPlugin plugin, string methodName, params Type[] parameterTypes) + { + var method = plugin.GetType().GetMethod( + methodName, + BindingFlags.Instance | BindingFlags.Public, + binder: null, + types: parameterTypes, + modifiers: null); + + return method is not null && method.DeclaringType != typeof(IAuthKitPlugin); + } +} \ No newline at end of file diff --git a/src/Host/Plugins/PluginConfigurationInvoker.cs b/src/Host/Plugins/PluginConfigurationInvoker.cs new file mode 100644 index 0000000..d10d15c --- /dev/null +++ b/src/Host/Plugins/PluginConfigurationInvoker.cs @@ -0,0 +1,99 @@ +using System.Reflection; +using AuthKit.Plugins.Abstractions.Contracts; + +namespace Host.Plugins; + +/// +/// Selects and invokes one compatible plugin configuration overload. +/// +/// +/// +/// Plugins may implement any single supported ConfigureServices overload. +/// The invoker picks the most specific overload implemented by the plugin instead +/// of requiring all plugins to adopt a single signature. +/// +/// +/// The default interface implementations of IAuthKitPlugin.ConfigureServices +/// forward to one another, so only the most specific overload actually overridden +/// by the plugin is invoked. +/// +/// +internal static class PluginConfigurationInvoker +{ + /// + /// Invokes the most specific configuration overload implemented by a plugin. + /// + /// The plugin being configured. + /// The host builder used by AuthKit. + /// The application configuration. + public static void Configure( + IAuthKitPlugin plugin, + IHostApplicationBuilder builder, + IConfiguration configuration) + { + var pluginType = plugin.GetType(); + + if (HasImplementation(pluginType, typeof(IServiceCollection), typeof(AuthKitPluginContext))) + { + plugin.ConfigureServices( + builder.Services, + new AuthKitPluginContext( + plugin.Id, + plugin.Name, + GetPluginConfiguration(configuration, plugin), + configuration)); + return; + } + + if (HasImplementation(pluginType, typeof(IHostApplicationBuilder), typeof(IConfiguration))) + { + plugin.ConfigureServices(builder, configuration); + return; + } + + plugin.ConfigureServices(builder.Services, configuration); + } + + /// + /// Determines whether a plugin provides a concrete implementation of the + /// ConfigureServices overload identified by the given parameter types. + /// + /// The plugin type to inspect. + /// The parameter types that identify the overload. + /// + /// true when the plugin overrides the overload; otherwise, false. + /// + private static bool HasImplementation(Type pluginType, params Type[] parameterTypes) + { + var method = pluginType.GetMethod( + nameof(IAuthKitPlugin.ConfigureServices), + BindingFlags.Instance | BindingFlags.Public, + binder: null, + types: parameterTypes, + modifiers: null); + + return method is not null && method.DeclaringType != typeof(IAuthKitPlugin); + } + + /// + /// Resolves the plugin scoped configuration section passed through + /// . + /// + /// The application configuration. + /// The plugin being configured. + /// + /// The Plugins configuration section keyed by the plugin identifier when + /// present otherwise, the section keyed by the plugin name. + /// + private static IConfiguration GetPluginConfiguration( + IConfiguration configuration, + IAuthKitPlugin plugin) + { + var plugins = configuration.GetSection("Plugins"); + var byId = plugins.GetSection(plugin.Id); + + return byId.GetChildren().Any() + ? byId + : plugins.GetSection(plugin.Name); + } +} \ No newline at end of file diff --git a/src/Host/Plugins/PluginHostedServiceRegistration.cs b/src/Host/Plugins/PluginHostedServiceRegistration.cs new file mode 100644 index 0000000..0b0417d --- /dev/null +++ b/src/Host/Plugins/PluginHostedServiceRegistration.cs @@ -0,0 +1,70 @@ +namespace Host.Plugins; + +/// +/// Registers plugin lifecycle orchestration and plugin owned hosted services. +/// +/// +/// +/// Registers the singleton that bridges +/// plugin lifecycle hooks to the standard host lifecycle, followed by every hosted +/// service returned by the loaded plugins. +/// +/// +/// The registration is idempotent by contract: calling Register a second time +/// on the same service collection is rejected so that plugin hosted services are +/// registered exactly once before the host startup. +/// +/// +internal static class PluginHostedServiceRegistration +{ + /// + /// Registers each plugin hosted service exactly once before the host startup. + /// + /// + /// + /// Each distinct hosted service instance is deduplicated using reference + /// equality, and is registered as an singleton so + /// the host starts and stops it once. + /// + /// + /// The lifecycle orchestration host is always registered, even when no plugin + /// returns any hosted services, so lifecycle hooks are invoked consistently. + /// + /// + /// The service collection the plugin services are added to. + /// The plugins loaded during application startup. + /// + /// Thrown when the registration was already performed, when a plugin returns + /// null from GetHostedServices, or when a plugin returns the same + /// hosted service instance more than once. + /// + public static void Register( + IServiceCollection services, + IReadOnlyList plugins) + { + if (services.Any(descriptor => + descriptor.ImplementationType == typeof(PluginLifecycleHostedService))) + throw new InvalidOperationException("Plugin hosted services have already been registered."); + + var hostedServices = new HashSet(ReferenceEqualityComparer.Instance); + + services.AddSingleton(); + + foreach (var loadedPlugin in plugins.OrderBy(plugin => plugin.Plugin.Id, StringComparer.Ordinal)) + { + var pluginServices = loadedPlugin.Plugin.GetHostedServices() + ?? throw new InvalidOperationException( + $"Plugin '{loadedPlugin.Plugin.Id}' returned null from GetHostedServices()."); + + foreach (var hostedService in pluginServices) + { + ArgumentNullException.ThrowIfNull(hostedService); + if (!hostedServices.Add(hostedService)) + throw new InvalidOperationException( + $"Plugin '{loadedPlugin.Plugin.Id}' returned the same hosted service instance more than once."); + + services.AddSingleton(typeof(IHostedService), hostedService); + } + } + } +} \ No newline at end of file diff --git a/src/Host/Plugins/PluginLifecycleHostedService.cs b/src/Host/Plugins/PluginLifecycleHostedService.cs new file mode 100644 index 0000000..2cd4292 --- /dev/null +++ b/src/Host/Plugins/PluginLifecycleHostedService.cs @@ -0,0 +1,114 @@ +using AuthKit.Plugins.Abstractions.Contracts; + +namespace Host.Plugins; + +/// +/// Bridges plugin lifecycle hooks to the standard .NET host lifecycle. +/// +/// +/// +/// Plugins are ordered by stable identifier. Startup and started hooks run in +/// ascending order stopping hooks run in reverse order. +/// +/// +/// OnStarting runs when the host starts, while OnStarted and +/// OnStopping are registered against +/// callbacks so they reflect the same ordering guarantees as the rest of the host. +/// +/// +/// Lifecycle exceptions are wrapped with plugin and stage information and +/// rethrown to the host so that startup or shutdown failures surface loudly. +/// +/// +internal sealed class PluginLifecycleHostedService( + IReadOnlyList plugins, + IHostApplicationLifetime lifetime, + ILogger logger) : IHostedService +{ + private readonly IReadOnlyList _plugins = plugins + .OrderBy(plugin => plugin.Plugin.Id, StringComparer.Ordinal) + .ToArray(); + + /// + /// Invokes plugin lifecycle hooks as the host begins and registers the + /// remaining hooks against the host application lifetime. + /// + /// + /// + /// Plugin OnStarting hooks are invoked synchronously, in ascending + /// plugin identifier order, before the host is considered started. + /// + /// + /// OnStarted hooks are invoked when + /// fires, and OnStopping hooks when + /// fires, in reverse plugin order. + /// + /// + /// Token that can be used to signal cancellation of the startup operation. + /// Thrown when any plugin hook fails during a lifecycle stage. + public Task StartAsync(CancellationToken cancellationToken) + { + foreach (var loadedPlugin in _plugins) + { + var plugin = loadedPlugin.Plugin; + logger.LogDebug("Starting plugin '{PluginId}'.", plugin.Id); + Invoke(plugin, "OnStarting", () => plugin.OnStartingAsync(cancellationToken)); + } + + lifetime.ApplicationStarted.Register(() => + { + foreach (var loadedPlugin in _plugins) + { + var plugin = loadedPlugin.Plugin; + logger.LogDebug("Plugin '{PluginId}' started.", plugin.Id); + Invoke(plugin, "OnStarted", () => plugin.OnStartedAsync(lifetime.ApplicationStopping)); + } + }); + + lifetime.ApplicationStopping.Register(() => + { + foreach (var loadedPlugin in _plugins.Reverse()) + { + var plugin = loadedPlugin.Plugin; + logger.LogDebug("Stopping plugin '{PluginId}'.", plugin.Id); + Invoke(plugin, "OnStopping", () => plugin.OnStoppingAsync(lifetime.ApplicationStopping)); + } + }); + + return Task.CompletedTask; + } + + /// + /// Stops the lifecycle orchestration service. Hook-based shutdown is handled + /// through . + /// + /// Token that can be used to signal cancellation of the shutdown operation. + /// + /// A representing the shutdown operation. + /// + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + /// + /// Invokes a single lifecycle hook, wrapping any failure with plugin and stage + /// information. + /// + /// The plugin whose hook is being invoked. + /// The lifecycle stage, used in the error message. + /// The hook operation to execute. + /// + /// Thrown when throws, wrapping the original + /// exception. + /// + private static void Invoke(IAuthKitPlugin plugin, string stage, Func operation) + { + try + { + operation().GetAwaiter().GetResult(); + } + catch (Exception ex) + { + throw new InvalidOperationException( + $"Plugin '{plugin.Id}' failed during {stage}.", ex); + } + } +} \ No newline at end of file diff --git a/src/Host/Program.cs b/src/Host/Program.cs index 8f21772..bfd6ab2 100644 --- a/src/Host/Program.cs +++ b/src/Host/Program.cs @@ -32,7 +32,9 @@ .AddKeycloakServices(); foreach (var lp in plugins) - lp.Plugin.ConfigureServices(builder.Services, builder.Configuration); + PluginConfigurationInvoker.Configure(lp.Plugin, builder, builder.Configuration); + +PluginHostedServiceRegistration.Register(builder.Services, plugins); builder.ConfigureWolverine(plugins); builder.Services.ConfigureMarten(builder.Configuration); @@ -49,7 +51,18 @@ var app = builder.Build(); app.ConfigureMiddleware(plugins) - .MapAppEndpoints() + .MapAppEndpoints(plugins) .MapGrpcEndpoints(); +PluginApplicationConfiguration.ConfigurePipeline( + app, plugins, PluginPipelinePosition.AfterEndpoints); + app.Run(); + +/// +/// Exposes the application entry point to test hosts such as +/// WebApplicationFactory. +/// +public partial class Program +{ +} diff --git a/src/Plugins/Abstractions/AuthKit.Plugins.Abstractions.csproj b/src/Plugins/Abstractions/AuthKit.Plugins.Abstractions.csproj index 7065a0f..e045dc1 100644 --- a/src/Plugins/Abstractions/AuthKit.Plugins.Abstractions.csproj +++ b/src/Plugins/Abstractions/AuthKit.Plugins.Abstractions.csproj @@ -8,14 +8,19 @@ false false + + + + + \ No newline at end of file diff --git a/src/Plugins/Abstractions/Contracts/AuthKitPluginContext.cs b/src/Plugins/Abstractions/Contracts/AuthKitPluginContext.cs new file mode 100644 index 0000000..2ed8781 --- /dev/null +++ b/src/Plugins/Abstractions/Contracts/AuthKitPluginContext.cs @@ -0,0 +1,53 @@ +using Microsoft.Extensions.Configuration; + +namespace AuthKit.Plugins.Abstractions.Contracts; + +/// +/// Provides stable host and plugin information during plugin service configuration. +/// +public sealed record AuthKitPluginContext +{ + /// + /// Initializes a new plugin configuration context. + /// + /// The stable ID of the plugin being configured. + /// The display name of the plugin being configured. + /// The configuration section scoped to the plugin. + /// The full application configuration. + public AuthKitPluginContext( + string pluginId, + string pluginName, + IConfiguration configuration, + IConfiguration applicationConfiguration) + { + ArgumentException.ThrowIfNullOrWhiteSpace(pluginId); + ArgumentException.ThrowIfNullOrWhiteSpace(pluginName); + ArgumentNullException.ThrowIfNull(configuration); + ArgumentNullException.ThrowIfNull(applicationConfiguration); + + PluginId = pluginId; + PluginName = pluginName; + Configuration = configuration; + ApplicationConfiguration = applicationConfiguration; + } + + /// + /// Gets the stable ID of the plugin being configured. + /// + public string PluginId { get; } + + /// + /// Gets the display name of the plugin being configured. + /// + public string PluginName { get; } + + /// + /// Gets the configuration section scoped to the plugin ID, or plugin name when no ID section exists. + /// + public IConfiguration Configuration { get; } + + /// + /// Gets the full application configuration when host-level settings are required. + /// + public IConfiguration ApplicationConfiguration { get; } +} \ No newline at end of file diff --git a/src/Plugins/Abstractions/Contracts/IAuthKitPlugin.cs b/src/Plugins/Abstractions/Contracts/IAuthKitPlugin.cs index bb4652c..c39ba13 100644 --- a/src/Plugins/Abstractions/Contracts/IAuthKitPlugin.cs +++ b/src/Plugins/Abstractions/Contracts/IAuthKitPlugin.cs @@ -1,9 +1,13 @@ using AuthKit.Plugins.Abstractions.Contracts.Plugins; using AuthKit.Plugins.Abstractions.Contracts.SecuritySchemes; using AuthKit.Plugins.Abstractions.Models; +using AuthKit.Plugins.Abstractions; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; using System.Reflection; namespace AuthKit.Plugins.Abstractions.Contracts; @@ -176,7 +180,37 @@ public interface IAuthKitPlugin /// void ConfigureServices( IServiceCollection services, - IConfiguration configuration); + IConfiguration configuration) => + throw new NotSupportedException( + $"Plugin '{GetType().Name}' must implement a supported ConfigureServices overload."); + + /// + /// Configures plugin services using the host application builder. + /// + /// The host application builder used by AuthKit. + /// The application configuration. + /// + /// This overload is optional. Its default implementation delegates to the + /// legacy service collection overload for existing plugins. + /// + void ConfigureServices( + IHostApplicationBuilder builder, + IConfiguration configuration) => + ConfigureServices(builder.Services, configuration); + + /// + /// Configures plugin services with stable plugin context information. + /// + /// The service collection used by the host. + /// The context for the plugin being configured. + /// + /// This overload is optional. Its default implementation delegates to the + /// legacy service collection overload for existing plugins. + /// + void ConfigureServices( + IServiceCollection services, + AuthKitPluginContext context) => + ConfigureServices(services, context.Configuration); /// /// Performs an optional health check for the plugin. @@ -228,6 +262,50 @@ Task CheckHealthAsync(IServiceProvider services) => /// Type? MiddlewareType => null; + /// + /// Registers plugin-owned endpoints during host endpoint configuration. + /// + /// The application's endpoint route builder. + /// + /// This optional hook runs after host services are configured and before + /// the application starts processing requests. Exceptions are propagated. + /// + void MapEndpoints(IEndpointRouteBuilder endpoints) + { + } + + /// + /// Configures plugin application middleware on the actual host application. + /// + /// The application's live builder. + /// + /// When implemented, this hook takes precedence over + /// to prevent accidental duplicate middleware registration. + /// + void ConfigureApplication(IApplicationBuilder application) + { + } + + /// + /// Gets the explicit pipeline position used by . + /// + PluginPipelinePosition PipelinePosition => PluginPipelinePosition.BeforeAuthentication; + + /// + /// Configures plugin middleware at the declared pipeline position. + /// + /// The application's live builder. + /// The position currently being configured. + /// + /// The host invokes this hook once at . + /// Plugins at the same position are ordered by stable plugin ID. + /// + void ConfigurePipeline( + IApplicationBuilder application, + PluginPipelinePosition position) + { + } + /// /// Gets the minimum host version required to load this plugin. /// @@ -264,4 +342,31 @@ Task CheckHealthAsync(IServiceProvider services) => /// readonly dictionary keyed by the security scheme name. IReadOnlyDictionary GetSecuritySchemes() => new Dictionary(); + + /// + /// Initializes plugin runtime resources before the host is considered started. + /// + /// The host startup cancellation token. + /// A task that completes when initialization is complete. + Task OnStartingAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + /// + /// Notifies the plugin after the host has started successfully. + /// + /// The host lifecycle cancellation token. + /// A task that completes when post-start work is complete. + Task OnStartedAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + /// + /// Releases plugin runtime resources during graceful host shutdown. + /// + /// The host shutdown cancellation token. + /// A task that completes when shutdown preparation is complete. + Task OnStoppingAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + /// + /// Gets hosted services owned by this plugin. + /// + /// A non-null collection of services registered in the host DI container. + IReadOnlyList GetHostedServices() => Array.Empty(); } diff --git a/src/Plugins/Abstractions/Contracts/PluginValidator.cs b/src/Plugins/Abstractions/Contracts/PluginValidator.cs index 5789c18..18eb7fd 100644 --- a/src/Plugins/Abstractions/Contracts/PluginValidator.cs +++ b/src/Plugins/Abstractions/Contracts/PluginValidator.cs @@ -77,7 +77,7 @@ public static void ValidateDependsOn(IReadOnlyList dependsOn, string plu } } - // Check for self-dependency + // Check for self dependency if (dependsOn.Contains(pluginId, StringComparer.OrdinalIgnoreCase)) { throw new InvalidOperationException("Plugin cannot depend on itself."); diff --git a/src/Plugins/Abstractions/PluginPipelinePosition.cs b/src/Plugins/Abstractions/PluginPipelinePosition.cs new file mode 100644 index 0000000..f72ced4 --- /dev/null +++ b/src/Plugins/Abstractions/PluginPipelinePosition.cs @@ -0,0 +1,31 @@ +namespace AuthKit.Plugins.Abstractions; + +/// +/// Defines the supported locations for plugin application middleware. +/// +public enum PluginPipelinePosition +{ + /// Before ASP.NET Core routing. + BeforeRouting = 0, + + /// After ASP.NET Core routing. + AfterRouting = 10, + + /// Before host authentication. + BeforeAuthentication = 20, + + /// After host authentication. + AfterAuthentication = 30, + + /// Before host authorization. + BeforeAuthorization = 40, + + /// After host authorization. + AfterAuthorization = 50, + + /// Before endpoint execution. + BeforeEndpoints = 60, + + /// After endpoint configuration. + AfterEndpoints = 70 +} \ No newline at end of file diff --git a/src/Plugins/Solutions/DevTokens/Taskfile.yml b/src/Plugins/Solutions/DevTokens/Taskfile.yml index e056a9c..3dc806d 100644 --- a/src/Plugins/Solutions/DevTokens/Taskfile.yml +++ b/src/Plugins/Solutions/DevTokens/Taskfile.yml @@ -39,9 +39,9 @@ tasks: - echo "Manifest generated at {{.MANIFEST_OUTPUT}}" build-manifest-generator: - desc: Builds the AuthKit.ManifestGenerator tool + desc: Restores and builds the AuthKit.ManifestGenerator tool cmds: - - cd {{.MANIFEST_GENERATOR}} && dotnet build --configuration {{.CONFIGURATION}} --no-restore + - cd {{.MANIFEST_GENERATOR}} && dotnet build --configuration {{.CONFIGURATION}} validate: desc: Cleans and builds the project diff --git a/tests/Host.IntegrationTests/AuthKit.Host.IntegrationTests.csproj b/tests/Host.IntegrationTests/AuthKit.Host.IntegrationTests.csproj new file mode 100644 index 0000000..5ce4103 --- /dev/null +++ b/tests/Host.IntegrationTests/AuthKit.Host.IntegrationTests.csproj @@ -0,0 +1,45 @@ + + + + net10.0 + preview + enable + enable + false + + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + <_DevTokensStageFiles Include="$(MSBuildProjectDirectory)/../../src/Plugins/Solutions/DevTokens/bin/$(Configuration)/net10.0/DevTokens.dll" /> + <_DevTokensStageFiles Include="$(MSBuildProjectDirectory)/../../src/Plugins/Solutions/DevTokens/bin/$(Configuration)/net10.0/DevTokens.pdb" /> + + + + + \ No newline at end of file diff --git a/tests/Host.IntegrationTests/AuthKitWebApplicationFactory.cs b/tests/Host.IntegrationTests/AuthKitWebApplicationFactory.cs new file mode 100644 index 0000000..7d21f59 --- /dev/null +++ b/tests/Host.IntegrationTests/AuthKitWebApplicationFactory.cs @@ -0,0 +1,55 @@ +using Core.KeyManagement.Interfaces; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace AuthKit.Host.IntegrationTests; + +/// +/// Hosts the real AuthKit Program over an in-memory test server with +/// production plugin discovery configured against the integration test output. +/// +public sealed class AuthKitWebApplicationFactory : WebApplicationFactory +{ + /// + /// Points plugin discovery and Marten at sandboxes: the staged plugin + /// directory under the test output and a fake PostgreSQL connection string + /// (Marten connects lazily, so requests that do not touch document sessions + /// are unaffected). + /// + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + var pluginsPath = Path.Join(AppContext.BaseDirectory, "plugins"); + + builder.UseSetting("AuthKit:PluginsPath", pluginsPath); + builder.UseSetting("AuthKit:SkipStorageMigrationOnStartup", "true"); + builder.UseSetting( + "ConnectionStrings:Marten", + "Host=localhost;Port=5432;Database=authkit_test;Username=authkit;Password=authkit"); + + builder.ConfigureServices(services => + { + services.RemoveAll(); + services.AddSingleton(); + }); + } + + /// + /// In-memory stand-in for the Marten-backed keystore persistence, so the + /// real host lifecycle runs without an external PostgreSQL database. + /// + private sealed class InMemoryKeyStoreRepository : IKeyStoreRepository + { + private byte[]? _data; + + public Task> LoadAsync() + => Task.FromResult>(_data ?? Memory.Empty); + + public Task SaveAsync(ReadOnlyMemory data) + { + _data = data.ToArray(); + return Task.CompletedTask; + } + } +} \ No newline at end of file diff --git a/tests/Host.IntegrationTests/DevTokensHostLoadTests.cs b/tests/Host.IntegrationTests/DevTokensHostLoadTests.cs new file mode 100644 index 0000000..3f1b532 --- /dev/null +++ b/tests/Host.IntegrationTests/DevTokensHostLoadTests.cs @@ -0,0 +1,61 @@ +using System.Net; +using AuthKit.Plugins.Abstractions; +using AuthKit.Plugins.Abstractions.Contracts.SecuritySchemes; +using Host.Plugins; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace AuthKit.Host.IntegrationTests; + +/// +/// Verifies that the unmodified DevTokens plugin ships, is discovered through +/// the real host entry point, passes contract validation, and participates in +/// the running application. +/// +public sealed class DevTokensHostLoadTests : IClassFixture +{ + private readonly AuthKitWebApplicationFactory _factory; + + public DevTokensHostLoadTests(AuthKitWebApplicationFactory factory) + { + _factory = factory; + } + + [Fact] + public void DevTokens_IsLoadedThroughTheRealHostEntryPoint() + { + _ = _factory.CreateClient(); + + var plugins = _factory.Services.GetRequiredService>(); + var devTokens = Assert.Single(plugins, plugin => plugin.Plugin.Id == "authkit.devtokens"); + + Assert.Equal("DevTokens", devTokens.Plugin.Name); + Assert.Equal("Developer Tokens", devTokens.Plugin.DisplayName); + Assert.Equal("1.0.0", devTokens.Plugin.Version.ToString()); + Assert.Equal("DevTokens", devTokens.Assembly.GetName().Name); + } + + [Fact] + public async Task DevTokens_DeclaredSecurityScheme_SurvivesContractValidation() + { + _ = _factory.CreateClient(); + + var plugins = _factory.Services.GetRequiredService>(); + var devTokens = Assert.Single(plugins, plugin => plugin.Plugin.Id == "authkit.devtokens"); + + var scheme = Assert.Single(devTokens.Plugin.GetSecuritySchemes()); + Assert.Equal("X-Developer-Token", scheme.Key); + Assert.Equal(AuthKitSecuritySchemeType.ApiKey, scheme.Value.Type); + Assert.Equal(AuthKitApiKeyLocation.Header, scheme.Value.In); + } + + [Fact] + public async Task Host_ServesRequestsWithDevTokensLoaded() + { + var client = _factory.CreateClient(); + + var response = await client.GetAsync("/"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } +} \ No newline at end of file diff --git a/tests/Host.IntegrationTests/PluginLifecycleIntegrationTests.cs b/tests/Host.IntegrationTests/PluginLifecycleIntegrationTests.cs new file mode 100644 index 0000000..b04ead9 --- /dev/null +++ b/tests/Host.IntegrationTests/PluginLifecycleIntegrationTests.cs @@ -0,0 +1,192 @@ +using AuthKit.Plugins.Abstractions; +using AuthKit.Plugins.Abstractions.Contracts; +using AuthKit.Plugins.Abstractions.Contracts.Plugins; +using Host.Plugins; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Xunit; + +namespace AuthKit.Host.IntegrationTests; + +/// +/// Verifies plugin lifecycle hooks and plugin-owned hosted services against the +/// real .NET generic host lifecycle (not a mocked lifetime). +/// +public sealed class PluginLifecycleIntegrationTests +{ + [Fact] + public async Task LifecycleHooks_And_HostedServices_FollowStandardHostLifecycle() + { + var events = new List(); + var pluginA = new LifecycleRecordingPlugin(events, "plugin.a", "Plugin A"); + var pluginB = new LifecycleRecordingPlugin(events, "plugin.b", "Plugin B"); + var hosted = new RecordingHostedService(events); + var hostedServicePlugin = new HostedServicePlugin(hosted); + + using var host = CreateHost([pluginA, pluginB, hostedServicePlugin]); + + await host.StartAsync(); + await host.StopAsync(); + + Assert.Equal( + [ + "starting:plugin.a", + "starting:plugin.b", + "hosted:start", + "started:plugin.a", + "started:plugin.b", + "stopping:plugin.b", + "stopping:plugin.a", + "hosted:stop" + ], events); + } + + [Fact] + public async Task LifecycleHooks_AreInvokedExactlyOnce() + { + var events = new List(); + var plugin = new LifecycleRecordingPlugin(events, "plugin.a", "Plugin A"); + + using var host = CreateHost([plugin]); + + await host.StartAsync(); + await host.StopAsync(); + + Assert.Single(events, entry => entry == "starting:plugin.a"); + Assert.Single(events, entry => entry == "started:plugin.a"); + Assert.Single(events, entry => entry == "stopping:plugin.a"); + } + + [Fact] + public async Task LifecycleHooks_ReceiveTheHostCancellationTokens() + { + var plugin = new LifecycleRecordingPlugin([], "plugin.a", "Plugin A"); + + using var host = CreateHost([plugin]); + + await host.StartAsync(); + Assert.False(plugin.StartingToken.IsCancellationRequested); + + await host.StopAsync(); + Assert.True(plugin.StoppingToken.IsCancellationRequested); + } + + [Fact] + public void DuplicateHostedServiceRegistration_IsRejected() + { + var hosted = new RecordingHostedService([]); + var plugin = new HostedServicePlugin(hosted); + var loaded = Load(plugin); + var services = new ServiceCollection(); + services.AddSingleton>([loaded]); + + PluginHostedServiceRegistration.Register(services, [loaded]); + + Assert.Throws(() => + PluginHostedServiceRegistration.Register(services, [loaded])); + } + + [Fact] + public void SameHostedServiceReturnedTwice_IsRejected() + { + var hosted = new RecordingHostedService([]); + var plugin = new DuplicateHostedServicePlugin(hosted); + var services = new ServiceCollection(); + services.AddSingleton>([Load(plugin)]); + + Assert.Throws(() => + PluginHostedServiceRegistration.Register(services, [Load(plugin)])); + } + + [Fact] + public void NullHostedServiceResult_IsRejected() + { + var plugin = new NullHostedServicePlugin(); + var services = new ServiceCollection(); + services.AddSingleton>([Load(plugin)]); + + Assert.Throws(() => + PluginHostedServiceRegistration.Register(services, [Load(plugin)])); + } + + private static IHost CreateHost(params IAuthKitPlugin[] plugins) + { + var loaded = plugins.Select(Load).ToArray(); + var builder = Microsoft.Extensions.Hosting.Host.CreateApplicationBuilder(); + builder.Logging.ClearProviders(); + + builder.Services.AddSingleton>(loaded); + PluginHostedServiceRegistration.Register(builder.Services, loaded); + + return builder.Build(); + } + + private static LoadedPlugin Load(IAuthKitPlugin plugin) => + new(plugin, plugin.GetType().Assembly, "test"); + + [PluginMetadata("plugin.a", "1.0.0", [], [], [], name: "Plugin A", description: "Lifecycle test")] + private sealed class LifecycleRecordingPlugin(List events, string id, string name) : IAuthKitPlugin + { + public string Id { get; } = id; + public string Name { get; } = name; + + public CancellationToken StartingToken { get; private set; } + public CancellationToken StartedToken { get; private set; } + public CancellationToken StoppingToken { get; private set; } + + public Task OnStartingAsync(CancellationToken cancellationToken) + { + StartingToken = cancellationToken; + events.Add($"starting:{Id}"); + return Task.CompletedTask; + } + + public Task OnStartedAsync(CancellationToken cancellationToken) + { + StartedToken = cancellationToken; + events.Add($"started:{Id}"); + return Task.CompletedTask; + } + + public Task OnStoppingAsync(CancellationToken cancellationToken) + { + StoppingToken = cancellationToken; + events.Add($"stopping:{Id}"); + return Task.CompletedTask; + } + } + + [PluginMetadata("plugin.hosted", "1.0.0", [], [], [], name: "Hosted Service Plugin", description: "Hosted service test")] + private sealed class HostedServicePlugin(IHostedService hostedService) : IAuthKitPlugin + { + public IReadOnlyList GetHostedServices() => [hostedService]; + } + + [PluginMetadata("plugin.duplicate", "1.0.0", [], [], [], name: "Duplicate Hosted Service Plugin", description: "Duplicate test")] + private sealed class DuplicateHostedServicePlugin(IHostedService hostedService) : IAuthKitPlugin + { + public IReadOnlyList GetHostedServices() => [hostedService, hostedService]; + } + + [PluginMetadata("plugin.null", "1.0.0", [], [], [], name: "Null Hosted Service Plugin", description: "Null test")] + private sealed class NullHostedServicePlugin : IAuthKitPlugin + { + public IReadOnlyList GetHostedServices() => null!; + } + + private sealed class RecordingHostedService(List events) : IHostedService + { + public Task StartAsync(CancellationToken cancellationToken) + { + events.Add("hosted:start"); + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) + { + events.Add("hosted:stop"); + return Task.CompletedTask; + } + } +} \ No newline at end of file diff --git a/tests/Host/PluginApplicationConfigurationTests.cs b/tests/Host/PluginApplicationConfigurationTests.cs new file mode 100644 index 0000000..765d03f --- /dev/null +++ b/tests/Host/PluginApplicationConfigurationTests.cs @@ -0,0 +1,193 @@ +using AuthKit.Plugins.Abstractions; +using AuthKit.Plugins.Abstractions.Contracts; +using AuthKit.Plugins.Abstractions.Contracts.Plugins; +using Host.Plugins; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace AuthKit.Host.Tests; + +/// +/// Verifies plugin endpoint and application pipeline integration. +/// +public sealed class PluginApplicationConfigurationTests +{ + [Fact] + public void MapEndpoints_UsesTheApplicationEndpointRouteBuilder() + { + var app = WebApplication.CreateBuilder().Build(); + var plugin = new EndpointPlugin(); + var loaded = Load(plugin); + + PluginApplicationConfiguration.MapEndpoints(app, [loaded]); + + var dataSources = ((IEndpointRouteBuilder)app).DataSources; + + Assert.Contains(dataSources.SelectMany(source => source.Endpoints), endpoint => + endpoint.DisplayName?.Contains("PluginEndpoint", StringComparison.Ordinal) == true); + } + + [Fact] + public void ConfigureApplication_ReceivesTheActualApplicationBuilder() + { + var app = WebApplication.CreateBuilder().Build(); + var plugin = new ApplicationPlugin(); + + PluginApplicationConfiguration.ConfigureApplications(app, [Load(plugin)]); + + Assert.Same(app, plugin.Application); + Assert.Equal(1, plugin.Calls); + } + + [Fact] + public void PipelineOrdering_IsStableByPluginId() + { + var app = WebApplication.CreateBuilder().Build(); + var calls = new List(); + var first = new PipelinePlugin("plugin.b", calls); + var second = new PipelinePlugin("plugin.a", calls); + + PluginApplicationConfiguration.ConfigurePipeline( + app, + [Load(first), Load(second)], + PluginPipelinePosition.AfterAuthentication); + + Assert.Equal(["plugin.a", "plugin.b"], calls); + } + + [Fact] + public async Task PipelineOrdering_IsVisibleInTheExecutedRequestPipeline() + { + var services = new ServiceCollection().BuildServiceProvider(); + var application = new ApplicationBuilder(services); + var markers = new List(); + var first = new MiddlewarePlugin("plugin.b", markers); + var second = new MiddlewarePlugin("plugin.a", markers); + + PluginApplicationConfiguration.ConfigurePipeline( + application, + [Load(first), Load(second)], + PluginPipelinePosition.AfterAuthentication); + application.Run(context => + { + markers.Add("endpoint"); + return Task.CompletedTask; + }); + + var pipeline = application.Build(); + await pipeline(new DefaultHttpContext()); + + Assert.Equal(["plugin.a", "plugin.b", "endpoint"], markers); + } + + [Fact] + public void InvalidPipelinePosition_IsRejected() + { + var app = WebApplication.CreateBuilder().Build(); + var plugin = new InvalidPositionPlugin(); + + Assert.Throws(() => + PluginApplicationConfiguration.ConfigurePipeline( + app, [Load(plugin)], PluginPipelinePosition.BeforeRouting)); + } + + [Fact] + public void ConfigureApplication_TakesPrecedenceOverLegacyMiddlewareType() + { + var app = WebApplication.CreateBuilder().Build(); + + PluginApplicationConfiguration.ConfigureLegacyMiddleware( + app, [Load(new ApplicationAndLegacyMiddlewarePlugin())]); + } + + private static LoadedPlugin Load(IAuthKitPlugin plugin) => + new(plugin, plugin.GetType().Assembly, "test"); + + [PluginMetadata("endpoint-plugin", "1.0.0", [], [], [], description: "Endpoint test")] + private sealed class EndpointPlugin : IAuthKitPlugin + { + public void MapEndpoints(IEndpointRouteBuilder endpoints) => + endpoints.MapGet("/plugin-endpoint", () => Results.Ok()).WithDisplayName("PluginEndpoint"); + } + + [PluginMetadata("application-plugin", "1.0.0", [], [], [], description: "Application test")] + private sealed class ApplicationPlugin : IAuthKitPlugin + { + public IApplicationBuilder? Application { get; private set; } + public int Calls { get; private set; } + + public void ConfigureApplication(IApplicationBuilder application) + { + Calls++; + Application = application; + } + } + + [PluginMetadata("pipeline-plugin", "1.0.0", [], [], [], description: "Pipeline test")] + private sealed class PipelinePlugin : IAuthKitPlugin + { + private readonly string _id; + private readonly List _calls; + + public PipelinePlugin(string id, List calls) + { + _id = id; + _calls = calls; + } + + public string Id => _id; + public PluginPipelinePosition PipelinePosition => PluginPipelinePosition.AfterAuthentication; + + public void ConfigurePipeline(IApplicationBuilder application, PluginPipelinePosition position) => + _calls.Add(_id); + } + + [PluginMetadata("middleware-plugin", "1.0.0", [], [], [], description: "Middleware ordering test")] + private sealed class MiddlewarePlugin : IAuthKitPlugin + { + private readonly string _id; + private readonly List _markers; + + public MiddlewarePlugin(string id, List markers) + { + _id = id; + _markers = markers; + } + + public string Id => _id; + public PluginPipelinePosition PipelinePosition => PluginPipelinePosition.AfterAuthentication; + + public void ConfigurePipeline(IApplicationBuilder application, PluginPipelinePosition position) + { + application.Use(async (_, next) => + { + _markers.Add(_id); + await next(); + }); + } + } + + [PluginMetadata("invalid-position-plugin", "1.0.0", [], [], [], description: "Invalid position test")] + private sealed class InvalidPositionPlugin : IAuthKitPlugin + { + public PluginPipelinePosition PipelinePosition => (PluginPipelinePosition)999; + + public void ConfigurePipeline(IApplicationBuilder application, PluginPipelinePosition position) + { + } + } + + [PluginMetadata("application-and-legacy-plugin", "1.0.0", [], [], [], description: "Duplicate registration test")] + private sealed class ApplicationAndLegacyMiddlewarePlugin : IAuthKitPlugin + { + public Type MiddlewareType => typeof(string); + + public void ConfigureApplication(IApplicationBuilder application) + { + } + } +} \ No newline at end of file diff --git a/tests/Host/PluginConfigurationInvokerTests.cs b/tests/Host/PluginConfigurationInvokerTests.cs new file mode 100644 index 0000000..21a80ae --- /dev/null +++ b/tests/Host/PluginConfigurationInvokerTests.cs @@ -0,0 +1,159 @@ +using AuthKit.Plugins.Abstractions; +using AuthKit.Plugins.Abstractions.Contracts; +using AuthKit.Plugins.Abstractions.Contracts.Plugins; +using Host.Plugins; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Xunit; + +namespace AuthKit.Host.Tests; + +/// +/// Verifies plugin configuration dispatch and context isolation. +/// +public sealed class PluginConfigurationInvokerTests +{ + [Fact] + public void LegacyPlugin_UsesLegacyOverloadOnce() + { + var plugin = new LegacyPlugin(); + var builder = CreateBuilder(); + + PluginConfigurationInvoker.Configure(plugin, builder, builder.Configuration); + + Assert.Equal(1, plugin.LegacyCalls); + } + + [Fact] + public void BuilderPlugin_ReceivesActualBuilderOnce() + { + var plugin = new BuilderPlugin(); + var builder = CreateBuilder(); + + PluginConfigurationInvoker.Configure(plugin, builder, builder.Configuration); + + Assert.Same(builder.Services, plugin.Services); + Assert.Equal(1, plugin.BuilderCalls); + Assert.Equal(0, plugin.LegacyCalls); + } + + [Fact] + public void ContextPlugin_ReceivesScopedConfiguration() + { + var builder = CreateBuilder( + ("Plugins:context-plugin:Value", "context-value"), + ("Plugins:other-plugin:Value", "other-value"), + ("HostValue", "host-value")); + var plugin = new ContextPlugin(); + + PluginConfigurationInvoker.Configure(plugin, builder, builder.Configuration); + + Assert.Equal(1, plugin.ContextCalls); + Assert.Equal("context-plugin", plugin.Context!.PluginId); + Assert.Equal("Context Plugin", plugin.Context.PluginName); + Assert.Equal("context-value", plugin.Context.Configuration["Value"]); + Assert.Null(plugin.Context.Configuration["HostValue"]); + Assert.Equal("host-value", plugin.Context.ApplicationConfiguration["HostValue"]); + } + + [Fact] + public void ContextOverload_HasPriorityAndIsInvokedOnlyOnce() + { + var builder = CreateBuilder(("Plugins:both-plugin:Value", "context-value")); + var plugin = new ContextAndBuilderPlugin(); + + PluginConfigurationInvoker.Configure(plugin, builder, builder.Configuration); + + Assert.Equal(1, plugin.ContextCalls); + Assert.Equal(0, plugin.BuilderCalls); + Assert.Equal(0, plugin.LegacyCalls); + } + + [Fact] + public void MultiplePlugins_ReceiveIndependentContexts() + { + var builder = CreateBuilder( + ("Plugins:context-plugin:Value", "first"), + ("Plugins:other-plugin:Value", "second")); + var first = new ContextPlugin(); + var second = new OtherContextPlugin(); + + PluginConfigurationInvoker.Configure(first, builder, builder.Configuration); + PluginConfigurationInvoker.Configure(second, builder, builder.Configuration); + + Assert.Equal("first", first.Context!.Configuration["Value"]); + Assert.Equal("second", second.Context!.Configuration["Value"]); + Assert.NotSame(first.Context.Configuration, second.Context.Configuration); + } + + private static HostApplicationBuilder CreateBuilder(params (string Key, string Value)[] values) + { + var builder = Microsoft.Extensions.Hosting.Host.CreateApplicationBuilder(); + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(values.Select(value => + new KeyValuePair(value.Key, value.Value))) + .Build(); + + builder.Configuration.AddConfiguration(configuration); + return builder; + } + + [PluginMetadata("legacy-plugin", "1.0.0", [], [], [], name: "Legacy Plugin", description: "Test plugin")] + private sealed class LegacyPlugin : IAuthKitPlugin + { + public int LegacyCalls { get; private set; } + + public void ConfigureServices(IServiceCollection services, IConfiguration configuration) => LegacyCalls++; + } + + [PluginMetadata("builder-plugin", "1.0.0", [], [], [], name: "Builder Plugin", description: "Test plugin")] + private sealed class BuilderPlugin : IAuthKitPlugin + { + public int LegacyCalls { get; private set; } + public int BuilderCalls { get; private set; } + public IServiceCollection? Services { get; private set; } + + public void ConfigureServices(IServiceCollection services, IConfiguration configuration) => LegacyCalls++; + + public void ConfigureServices(IHostApplicationBuilder builder, IConfiguration configuration) + { + BuilderCalls++; + Services = builder.Services; + } + + } + + [PluginMetadata("context-plugin", "1.0.0", [], [], [], name: "Context Plugin", description: "Test plugin")] + private sealed class ContextPlugin : IAuthKitPlugin + { + public int ContextCalls { get; private set; } + public AuthKitPluginContext? Context { get; private set; } + + public void ConfigureServices(IServiceCollection services, AuthKitPluginContext context) + { + ContextCalls++; + Context = context; + } + } + + [PluginMetadata("both-plugin", "1.0.0", [], [], [], name: "Both Plugin", description: "Test plugin")] + private sealed class ContextAndBuilderPlugin : IAuthKitPlugin + { + public int LegacyCalls { get; private set; } + public int BuilderCalls { get; private set; } + public int ContextCalls { get; private set; } + + public void ConfigureServices(IServiceCollection services, IConfiguration configuration) => LegacyCalls++; + public void ConfigureServices(IHostApplicationBuilder builder, IConfiguration configuration) => BuilderCalls++; + public void ConfigureServices(IServiceCollection services, AuthKitPluginContext context) => ContextCalls++; + } + + [PluginMetadata("other-plugin", "1.0.0", [], [], [], name: "Other Plugin", description: "Test plugin")] + private sealed class OtherContextPlugin : IAuthKitPlugin + { + public AuthKitPluginContext? Context { get; private set; } + + public void ConfigureServices(IServiceCollection services, AuthKitPluginContext context) => Context = context; + } +} \ No newline at end of file diff --git a/tests/Host/PluginContractValidatorTests.cs b/tests/Host/PluginContractValidatorTests.cs index f98aa70..c6146e1 100644 --- a/tests/Host/PluginContractValidatorTests.cs +++ b/tests/Host/PluginContractValidatorTests.cs @@ -26,7 +26,7 @@ private sealed class FakePlugin(AuthKitSecuritySchemeDescriptor descriptor) : IA }; public string Name => "Fake"; - public string Version => "1.0.0"; + public SemanticVersion Version => new(1, 0, 0); public void ConfigureServices(IServiceCollection services, IConfiguration configuration) { } public IReadOnlyDictionary GetSecuritySchemes() => _schemes; } diff --git a/tests/Host/PluginLifecycleHostedServiceTests.cs b/tests/Host/PluginLifecycleHostedServiceTests.cs new file mode 100644 index 0000000..e7939c6 --- /dev/null +++ b/tests/Host/PluginLifecycleHostedServiceTests.cs @@ -0,0 +1,150 @@ +using AuthKit.Plugins.Abstractions; +using AuthKit.Plugins.Abstractions.Contracts; +using AuthKit.Plugins.Abstractions.Contracts.Plugins; +using Host.Plugins; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace AuthKit.Host.Tests; + +/// +/// Verifies plugin lifecycle ordering, cancellation and hosted service registration. +/// +public sealed class PluginLifecycleHostedServiceTests +{ + [Fact] + public void LifecycleHooks_AreOrderedAndStoppingRunsInReverse() + { + var events = new List(); + var lifetime = new TestLifetime(); + var pluginA = new LifecyclePlugin("plugin.a", events); + var pluginB = new LifecyclePlugin("plugin.b", events); + var service = new PluginLifecycleHostedService( + [Load(pluginB), Load(pluginA)], lifetime, NullLogger.Instance); + + service.StartAsync(CancellationToken.None).GetAwaiter().GetResult(); + lifetime.Started.Cancel(); + lifetime.Stopping.Cancel(); + + Assert.Equal( + ["plugin.a:starting", "plugin.b:starting", "plugin.a:started", "plugin.b:started", + "plugin.b:stopping", "plugin.a:stopping"], + events); + } + + [Fact] + public void LifecycleHooks_ReceiveHostCancellationTokens() + { + var lifetime = new TestLifetime(); + var plugin = new LifecyclePlugin("plugin", []); + var service = new PluginLifecycleHostedService( + [Load(plugin)], lifetime, NullLogger.Instance); + using var startupCancellation = new CancellationTokenSource(); + + service.StartAsync(startupCancellation.Token).GetAwaiter().GetResult(); + startupCancellation.Cancel(); + lifetime.Started.Cancel(); + lifetime.Stopping.Cancel(); + + Assert.True(plugin.StartingToken.IsCancellationRequested); + Assert.True(plugin.StartedToken.IsCancellationRequested); + Assert.True(plugin.StoppingToken.IsCancellationRequested); + } + + [Fact] + public void HostedServices_AreRegisteredInStandardDi() + { + var hostedService = new RecordingHostedService(); + var plugin = new HostedServicePlugin(hostedService); + var services = new ServiceCollection(); + services.AddSingleton>([Load(plugin)]); + services.AddSingleton(new TestLifetime()); + services.AddSingleton>( + NullLogger.Instance); + + PluginHostedServiceRegistration.Register(services, [Load(plugin)]); + + var provider = services.BuildServiceProvider(); + Assert.Contains(hostedService, provider.GetServices()); + } + + [Fact] + public void LifecycleFailure_IsSurfacedWithPluginAndStage() + { + var plugin = new FailingLifecyclePlugin(); + var service = new PluginLifecycleHostedService( + [Load(plugin)], new TestLifetime(), NullLogger.Instance); + + var exception = Assert.Throws(() => + service.StartAsync(CancellationToken.None).GetAwaiter().GetResult()); + + Assert.Contains("failing-plugin", exception.Message); + Assert.Contains("OnStarting", exception.Message); + } + + [PluginMetadata("lifecycle-plugin", "1.0.0", [], [], [], description: "Lifecycle test")] + private sealed class LifecyclePlugin(string id, List events) : IAuthKitPlugin + { + public string Id { get; } = id; + public CancellationToken StartingToken { get; private set; } + public CancellationToken StartedToken { get; private set; } + public CancellationToken StoppingToken { get; private set; } + + public Task OnStartingAsync(CancellationToken cancellationToken) + { + StartingToken = cancellationToken; + events.Add($"{Id}:starting"); + return Task.CompletedTask; + } + + public Task OnStartedAsync(CancellationToken cancellationToken) + { + StartedToken = cancellationToken; + events.Add($"{Id}:started"); + return Task.CompletedTask; + } + + public Task OnStoppingAsync(CancellationToken cancellationToken) + { + StoppingToken = cancellationToken; + events.Add($"{Id}:stopping"); + return Task.CompletedTask; + } + } + + private static LoadedPlugin Load(IAuthKitPlugin plugin) => + new(plugin, plugin.GetType().Assembly, "test"); + + [PluginMetadata("hosted-service-plugin", "1.0.0", [], [], [], description: "Hosted service test")] + private sealed class HostedServicePlugin(IHostedService hostedService) : IAuthKitPlugin + { + public IReadOnlyList GetHostedServices() => [hostedService]; + } + + private sealed class RecordingHostedService : IHostedService + { + public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask; + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + } + + [PluginMetadata("failing-plugin", "1.0.0", [], [], [], description: "Failure test")] + private sealed class FailingLifecyclePlugin : IAuthKitPlugin + { + public Task OnStartingAsync(CancellationToken cancellationToken) => + Task.FromException(new InvalidOperationException("startup failure")); + } + + private sealed class TestLifetime : IHostApplicationLifetime + { + public CancellationTokenSource Started { get; } = new(); + public CancellationTokenSource Stopping { get; } = new(); + + public CancellationToken ApplicationStarted => Started.Token; + public CancellationToken ApplicationStopping => Stopping.Token; + public CancellationToken ApplicationStopped => CancellationToken.None; + public void StopApplication() => Stopping.Cancel(); + } +} \ No newline at end of file