diff --git a/.github/SUPPORT.md b/.github/SUPPORT.md index 40a8e075..2d2534aa 100644 --- a/.github/SUPPORT.md +++ b/.github/SUPPORT.md @@ -4,7 +4,7 @@ This project provides a reusable ASP.NET Core application template. Support is f ## Maintenance Posture -The `2.x` line reached feature completion at release `2.10.0`. +The `2.x` line reached feature completion at release `2.10.0`, which is the latest published release. From `2.10.0` onward, the `2.x` line accepts only the changes listed as in scope below. **In scope for future releases** @@ -61,13 +61,13 @@ Users should not expect: - Backports to every historical release line. - Support for heavily modified downstream applications unless the issue reproduces from the template baseline. - Support for unsupported .NET SDK versions or package versions outside the documented release line. -- New features, options, or configuration surfaces in the `2.x` line. -- +- New features, options, or configuration surfaces in the `2.x` line after `2.10.0`. + ## Version Support Lifecycle | Version line | Support expectation | |:---|:---| -| `2.x` | Current stable line, feature-complete as of `2.10.0`. Supported for security fixes, dependency servicing, reproducible defects, and documentation fixes. | +| `2.x` | Current stable line; latest release `2.10.0`. Feature-complete from `2.10.0` onward. Supported for security fixes, dependency servicing, reproducible defects, and documentation fixes. | | `1.0.x` | Legacy stable line under the previous NuGet package identity. Best effort unless a release note states otherwise. | | Pre-1.0 releases | Best effort only. Consumers should upgrade to the current stable release when practical. | | Older stable releases after a newer minor or major release | Best effort unless a release note states otherwise. | diff --git a/.template.content/src/ProjectTemplate.Web/appsettings.json b/.template.content/src/ProjectTemplate.Web/appsettings.json index e715422b..484edcfa 100644 --- a/.template.content/src/ProjectTemplate.Web/appsettings.json +++ b/.template.content/src/ProjectTemplate.Web/appsettings.json @@ -88,6 +88,7 @@ "UseGlobalLimiter": true, "UseSharedUnknownClientPartition": false, "UnknownClientPartitionKey": "unknown-client", + "IPv6PartitionPrefixLength": 64, "GlobalFixedWindow": { "PermitLimit": 60, "WindowSeconds": 60, @@ -100,7 +101,8 @@ }, "ConcurrencyPolicy": { "PermitLimit": 10, - "QueueLimit": 0 + "QueueLimit": 0, + "PartitionByClient": true } }, "RequestLogging": { @@ -229,6 +231,9 @@ "StorageMode": "Local" } }, + "HealthChecks": { + "DatabaseReadinessCheckEnabled": true + }, "ApiVersioning": { "DefaultMajorVersion": 1, "DefaultMinorVersion": 0, diff --git a/CHANGELOG.md b/CHANGELOG.md index aa6ff26b..1e7c3cc9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,6 +66,10 @@ This project follows Semantic Versioning using the format `MAJOR.MINOR.PATCH`. * Added a fail-closed release evidence manifest and automated asset validation. * Added a dated decision record for deferred NuGet package signing with an assigned owner, mandatory review date, and re-evaluation criteria. +* Added an `application-database` readiness check, tagged `ready` and `database`, that reports whether the application database accepts connections. It is registered when EF Core data access is enabled. `ProjectTemplate:HealthChecks:DatabaseReadinessCheckEnabled` (default `true`) is read each time the check runs; when it is `false`, the check reports `Healthy` without contacting the database. Previously `/health/ready` ran no checks and always reported `Healthy`. +* Added the anonymous `/health/audit-integrity` endpoint, which runs checks tagged `audit`. +* Added `ApplicationAuditReconciliationOptions.HealthStaleRunThreshold`. It defaults to three times `Interval` and must be greater than `Interval`. +* Added `ProjectTemplate:RateLimiting:IPv6PartitionPrefixLength` (default `64`, valid range 1–128) and `ProjectTemplate:RateLimiting:ConcurrencyPolicy:PartitionByClient` (default `true`). ### Changed @@ -112,6 +116,15 @@ This project follows Semantic Versioning using the format `MAJOR.MINOR.PATCH`. * Relocated community, governance, maintainer, support, release, and asset-notice documents to `.github/` and consolidated overlapping community and maintainer files. Content is unchanged; GitHub resolves community health files from `.github/` identically to the repository root. * Reordered the README so installation commands and the default security posture precede project goals, and consolidated the AsiBackbone boundary and documentation-ownership sections into a single related-projects block. +* **Behavior change for adopters using audit reconciliation:** `application-audit-integrity` is now tagged `audit` and `integrity` instead of `ready`, `audit`, and `integrity`, and is served by `/health/audit-integrity`. A critical audit finding no longer removes every replica from load balancing through `/health/ready`. Move audit alerting to `/health/audit-integrity`. `/health` still runs every registered check. +* **Behavior change for adopters using audit reconciliation:** the audit integrity check now reports `Degraded` when reconciliation has not completed a run in the current process, or when its last successful run is older than `HealthStaleRunThreshold`. Previously a stopped or failing worker left the check reading zero findings and `Healthy` indefinitely. Freshness is evaluated only when `RunWorker` is `true`. +* **Behavior change for adopters:** `/health/ready` now fails when EF Core data access is enabled and the database is unreachable. For SQLite, a database file that has not been created yet counts as unreachable, so apply migrations before a local instance reports ready. Set `ProjectTemplate:HealthChecks:DatabaseReadinessCheckEnabled` to `false` when another component owns database readiness. +* **Behavior change for adopters:** IPv6 clients are now rate limited by /64 prefix rather than by full address, so rotating addresses within one prefix no longer bypasses per-client limits. IPv4 and IPv4-mapped IPv6 clients are still limited per IPv4 address. Set `IPv6PartitionPrefixLength` to `128` to restore per-address partitioning. +* **Behavior change for adopters using the `concurrency` policy:** permits are now partitioned by endpoint and client instead of by endpoint only, so one client can no longer exhaust an endpoint's concurrency for every other client. Set `ConcurrencyPolicy:PartitionByClient` to `false` to restore one shared pool per endpoint. +* **Behavior change for adopters:** a configured `ExcludedPathPrefixes` list for security headers or request logging now replaces the code defaults instead of being appended to them, so configuration can remove a default exclusion. Blank entries are ignored, which lets a later configuration source remove an inherited entry by setting its index to an empty string; a blank entry therefore no longer fails options validation. Previously the shipped `appsettings.json` values were appended to identical code defaults, producing duplicates, and no default could be removed through configuration. +* **Behavior change for adopters:** the authentication cookie is now named `__Host-ProjectTemplate.Web.Authentication` with `Path=/` whenever it is always `Secure`, binding the session to the exact issuing host. Existing sessions end once after upgrading because the cookie name changes. The Development-only `AllowInsecureHttp` override keeps the unprefixed `.ProjectTemplate.Web.Authentication` name. +* Updated `.github/SUPPORT.md` to reflect published release `2.10.0` as the current stable and feature-complete `2.x` baseline. + ### Fixed * Backfilled release `2.9.0` from its retained original container evidence and exact public NuGet package, with regenerated evidence explicitly labeled. diff --git a/docs/articles/audit-reconciliation.md b/docs/articles/audit-reconciliation.md index 940a1016..51d46d17 100644 --- a/docs/articles/audit-reconciliation.md +++ b/docs/articles/audit-reconciliation.md @@ -63,13 +63,15 @@ Finding keys are deterministic for a reason, mutation batch, and destination. Re ## Health checks -Registration adds `application-audit-integrity` with the tags `ready`, `audit`, and `integrity`. The existing `/health/ready` endpoint includes the check. +Registration adds `application-audit-integrity` with the tags `audit` and `integrity`. The `/health/audit-integrity` endpoint runs the check, and `/health` includes it with every other registered check. It is deliberately not tagged `ready`: an integrity finding needs operator review, and a readiness failure would take every replica out of load balancing at the same time. Alert on `/health/audit-integrity` rather than routing traffic on it. The check returns: -- **Healthy** when no open finding crosses configured thresholds. -- **Degraded** when warning-level findings, stale delivery, or dead letters require attention. -- **Unhealthy** when a critical finding or manifest verification failure exists, or the open-finding threshold is reached. +- **Healthy** when no open finding crosses configured thresholds and reconciliation has run recently. +- **Degraded** when warning-level findings, stale delivery, or dead letters require attention, or when reconciliation has not completed a run in this process or its last successful run is older than `HealthStaleRunThreshold`. +- **Unhealthy** when a critical finding or manifest verification failure exists, or the open-finding threshold is reached. This takes precedence over staleness. + +`HealthStaleRunThreshold` defaults to three times `Interval` (15 minutes with the default 5-minute interval) and must be greater than `Interval`. The last successful run time is tracked in the process that runs the scheduled loop, so freshness is evaluated only when `RunWorker` is `true`. With `RunWorker` set to `false`, the check reports `reconciliationFreshnessTracked: false` and relies on the external scheduler's own monitoring. Because the worker runs immediately at startup, a freshly started instance reports Degraded only until its first successful run. Health data is minimized to counts, ages, and timestamps. It never exposes audited values or unrestricted exception text. diff --git a/docs/articles/authentication.md b/docs/articles/authentication.md index e60c3a60..01d79de5 100644 --- a/docs/articles/authentication.md +++ b/docs/articles/authentication.md @@ -20,6 +20,7 @@ By default: - `ProjectTemplate:Authentication:Enabled` is `true`. - The default authenticate, challenge, and sign-in schemes use `Cookies`. - Cookie authentication is enabled to store an authenticated session after a configured sign-in flow succeeds. +- The session cookie is named `__Host-ProjectTemplate.Web.Authentication`, with `HttpOnly`, `Secure`, `SameSite=Lax`, `Path=/`, and no `Domain`. Browsers accept a `__Host-` cookie only under those conditions, which binds the session to the exact host that issued it. When the Development-only `ProjectTemplate:Authentication:Cookie:AllowInsecureHttp` override is active, the cookie can be sent over HTTP, so it is named `.ProjectTemplate.Web.Authentication` without the prefix. - External providers such as OpenID Connect, SAML2, Microsoft, Google, and GitHub are disabled. The default scaffold does not include ASP.NET Core Identity, local user accounts, a credential form, a seeded user, or an enabled external provider. Cookie authentication does not authenticate credentials by itself. Consequently, the default `/Account/Login` page has no sign-in action and protected routes remain unavailable to anonymous users until the consuming application enables an external provider or supplies its own identity flow. The login page states this condition explicitly instead of presenting the cookie session handler as a local login provider. diff --git a/docs/articles/health-checks.md b/docs/articles/health-checks.md index 7ca24d8a..91829283 100644 --- a/docs/articles/health-checks.md +++ b/docs/articles/health-checks.md @@ -31,14 +31,27 @@ app.MapApplicationHealthChecks(); | `/health` | General application health endpoint. | | `/health/ready` | Readiness endpoint intended for dependency-aware checks such as database, cache, or external service availability. | | `/health/live` | Liveness endpoint intended to verify that the application process can respond. | +| `/health/audit-integrity` | Audit integrity endpoint. Runs only checks tagged `audit`, such as the optional audit reconciliation check. | -The baseline application provides the readiness endpoint shape. It does not, by itself, prove database, cache, queue, or external service availability. +When EF Core data access is enabled, readiness includes the `application-database` check, which reports whether the application database accepts connections. The check is tagged `ready` and `database` and is registered only when the data access provider is not `None`. For file-backed SQLite, the check verifies that the configured database file already exists before opening the connection; it never creates a missing database as a side effect of readiness. Apply migrations before expecting a local instance to report ready. -The generated template does not add database, cache, queue, or external-service readiness checks automatically. Consuming applications must register the tagged dependency checks that define production readiness for their service. +The database readiness check can be turned off when another component already owns database readiness. The setting is read each time the check runs; when it is `false`, the registered check reports `Healthy` without contacting the database: + +```json +"ProjectTemplate": { + "HealthChecks": { + "DatabaseReadinessCheckEnabled": false + } +} +``` + +The template does not add cache, queue, or external-service readiness checks. Consuming applications must register any additional tagged dependency checks that define production readiness for their service. + +Audit integrity is intentionally kept out of readiness. An integrity finding needs operator review, but it does not stop an instance from serving traffic, and a failing readiness check would remove every replica from load balancing at the same moment. Alert on `/health/audit-integrity` instead. That endpoint returns `200` for `Healthy` and `Degraded` and `503` for `Unhealthy`. Because `/health` runs every registered check, it also reflects audit integrity; do not use `/health` as a load-balancer readiness probe. ## Access and Deployment Boundary -All three health endpoints are mapped with `.AllowAnonymous()` intentionally. This keeps container, reverse-proxy, load-balancer, and orchestration probes independent of browser login state and prevents the authenticated fallback policy from turning a failed probe into an authentication redirect. +All four health endpoints are mapped with `.AllowAnonymous()` intentionally. This keeps container, reverse-proxy, load-balancer, and orchestration probes independent of browser login state and prevents the authenticated fallback policy from turning a failed probe into an authentication redirect. Anonymous application access does not imply unrestricted Internet exposure. Production deployments should restrict health endpoint reachability through the deployment boundary appropriate to the environment, such as: @@ -49,7 +62,7 @@ Anonymous application access does not imply unrestricted Internet exposure. Prod Avoid returning secrets, configuration values, dependency connection details, exception messages, or other sensitive diagnostics from health responses. Applications that require authenticated health diagnostics should add a separate protected diagnostics endpoint rather than changing the lightweight liveness contract accidentally. -When the application starts in the `Production` environment, it emits one structured warning identifying `/health`, `/health/ready`, and `/health/live` as anonymously mapped routes. The warning does not mean anonymous health probes are inherently unsafe; it is an operational signal reminding the deployment operator to confirm that reverse-proxy, ingress, firewall, or service-mesh routing exposes those endpoints only as intended. +When the application starts in the `Production` environment, it emits one structured warning identifying `/health`, `/health/ready`, `/health/live`, and `/health/audit-integrity` as anonymously mapped routes. The warning does not mean anonymous health probes are inherently unsafe; it is an operational signal reminding the deployment operator to confirm that reverse-proxy, ingress, firewall, or service-mesh routing exposes those endpoints only as intended. Development does not emit this health-route warning. The diagnostic is startup-only and does not add request-path log noise. @@ -75,14 +88,14 @@ Should this application instance receive normal traffic? Only checks tagged `ready` are included in the readiness endpoint. This keeps dependency-aware readiness separate from process liveness. -Example future readiness check: +Example additional readiness check: ```csharp builder.Services .AddHealthChecks() - .AddCheck( - "database", - tags: new[] { "ready" }); + .AddCheck( + "cache", + tags: [ApplicationHealthCheckTags.Ready]); ``` Use readiness for dependencies that should remove an instance from rotation when unavailable, such as required database connectivity or a required local cache. Avoid adding optional integrations unless the application cannot serve useful traffic without them. @@ -122,7 +135,7 @@ The default security header configuration excludes `/health`: ] ``` -Because the exclusion is prefix-based, `/health`, `/health/ready`, and `/health/live` are all excluded from the security header middleware, except `X-Content-Type-Options: nosniff`. `Strict-Transport-Security` is registered separately and still applies to HTTPS health responses outside Development. This keeps health probe responses small and infrastructure-friendly. +Because the exclusion is prefix-based, `/health`, `/health/ready`, `/health/live`, and `/health/audit-integrity` are all excluded from the security header middleware, except `X-Content-Type-Options: nosniff`. `Strict-Transport-Security` is registered separately and still applies to HTTPS health responses outside Development. This keeps health probe responses small and infrastructure-friendly. ## Contract References diff --git a/docs/articles/rate-limiting.md b/docs/articles/rate-limiting.md index 20097dc3..f20b88c4 100644 --- a/docs/articles/rate-limiting.md +++ b/docs/articles/rate-limiting.md @@ -74,6 +74,7 @@ Rate limiting values can be configured from `appsettings.json`: "UseGlobalLimiter": true, "UseSharedUnknownClientPartition": true, "UnknownClientPartitionKey": "unknown-client", + "IPv6PartitionPrefixLength": 64, "GlobalFixedWindow": { "PermitLimit": 60, "WindowSeconds": 60, @@ -86,7 +87,8 @@ Rate limiting values can be configured from `appsettings.json`: }, "ConcurrencyPolicy": { "PermitLimit": 10, - "QueueLimit": 0 + "QueueLimit": 0, + "PartitionByClient": true } } } @@ -96,7 +98,7 @@ These defaults are intentionally conservative and should be reviewed before prod ## Client Partitioning and Unknown-Client Fallback -The fixed-window limiters partition clients by `HttpContext.Connection.RemoteIpAddress`. The template intentionally relies on ASP.NET Core Forwarded Headers Middleware to correct that value when the application runs behind a trusted reverse proxy, load balancer, ingress controller, CDN, or gateway. +The fixed-window limiters partition clients by `HttpContext.Connection.RemoteIpAddress`. IPv4 addresses, including IPv4-mapped IPv6 addresses reported by dual-stack listeners, are partitioned by the IPv4 address. Other IPv6 addresses are reduced to their network prefix, set by `IPv6PartitionPrefixLength` (default `64`), because a single IPv6 subscriber commonly controls a whole /64 and could otherwise rotate addresses to bypass per-client limits. Set it to `128` to partition by full IPv6 address, or lower it when your clients are allocated larger prefixes. Grouping by prefix also means clients that share a /64 share one budget. The template intentionally relies on ASP.NET Core Forwarded Headers Middleware to correct that value when the application runs behind a trusted reverse proxy, load balancer, ingress controller, CDN, or gateway. The rate limiter does **not** parse or trust raw `X-Forwarded-For` values directly. Raw forwarded headers are client-controllable unless ASP.NET Core has first validated them through trusted `KnownProxies` or `KnownNetworks` configuration. @@ -142,6 +144,8 @@ app.MapPost("/admin/export", () => "Export started") .RequireRateLimiting("concurrency"); ``` +The concurrency policy partitions by endpoint and client by default (`ConcurrencyPolicy:PartitionByClient` is `true`), so each client receives its own `PermitLimit` concurrent requests per endpoint and one client holding slow requests cannot exhaust an endpoint for everyone else. Set `PartitionByClient` to `false` to share one permit pool per endpoint across all clients. That caps total endpoint concurrency, but a single client can then consume every permit. When client addresses cannot be resolved, the unknown-client fallback described above applies to this policy as well. + Controller or Razor Page handlers can also use rate limiting attributes: ```csharp diff --git a/docs/articles/security-headers.md b/docs/articles/security-headers.md index 21ef457b..d0f27a13 100644 --- a/docs/articles/security-headers.md +++ b/docs/articles/security-headers.md @@ -146,7 +146,22 @@ Security headers can be configured from `appsettings.json`: |`EnableCrossOriginHeaders`|Controls whether `Cross-Origin-Opener-Policy` and `Cross-Origin-Resource-Policy` are applied.| |`ContentSecurityPolicy`|Defines the application Content Security Policy value.| |`PermissionsPolicy`|Defines the Permissions Policy value.| -|`ExcludedPathPrefixes`|Skips security header application, except `X-Content-Type-Options: nosniff`, for matching request path prefixes.| +|`ExcludedPathPrefixes`|Skips security header application, except `X-Content-Type-Options: nosniff`, for matching request path prefixes. A configured list replaces the code defaults; see the note below.| + +A configured `ExcludedPathPrefixes` list replaces the built-in defaults instead of being appended to them, so configuration can both add and remove exclusions. Configuration sources merge arrays by index, so a later source can remove an inherited entry by setting that index to an empty string. Blank entries are ignored. For example, this `appsettings.Production.json` fragment keeps `/health` excluded and applies the full security header set to `/metrics` again: + +```json +"ProjectTemplate": { + "SecurityHeaders": { + "ExcludedPathPrefixes": [ + "/health", + "" + ] + } +} +``` + +The same rules apply to `ProjectTemplate:RequestLogging:ExcludedPathPrefixes`. ## Environment-Specific Behavior diff --git a/src/ProjectTemplate.Infrastructure/Data/Auditing/ApplicationAuditReconciliationContracts.cs b/src/ProjectTemplate.Infrastructure/Data/Auditing/ApplicationAuditReconciliationContracts.cs index 88d3d5cb..fa973d59 100644 --- a/src/ProjectTemplate.Infrastructure/Data/Auditing/ApplicationAuditReconciliationContracts.cs +++ b/src/ProjectTemplate.Infrastructure/Data/Auditing/ApplicationAuditReconciliationContracts.cs @@ -57,6 +57,18 @@ public sealed class ApplicationAuditReconciliationOptions public int HealthWarningFindingCount { get; set; } = 1; public int HealthUnhealthyFindingCount { get; set; } = 10; + + /// + /// Gets or sets how long after the last successful reconciliation run the audit integrity health check reports + /// Degraded. When , three times is used. + /// + /// + /// Freshness is only evaluated when is , because the last run time is + /// tracked by the process that runs the scheduled reconciliation loop. A process that never completed a run, or + /// whose worker stopped or keeps failing, therefore reports Degraded instead of a green check that reads + /// zero findings indefinitely. + /// + public TimeSpan? HealthStaleRunThreshold { get; set; } } public interface IApplicationAuditReconciler diff --git a/src/ProjectTemplate.Infrastructure/Data/Extensions/ApplicationAuditReconciliationServiceExtensions.cs b/src/ProjectTemplate.Infrastructure/Data/Extensions/ApplicationAuditReconciliationServiceExtensions.cs index abd8ccca..df56e61f 100644 --- a/src/ProjectTemplate.Infrastructure/Data/Extensions/ApplicationAuditReconciliationServiceExtensions.cs +++ b/src/ProjectTemplate.Infrastructure/Data/Extensions/ApplicationAuditReconciliationServiceExtensions.cs @@ -33,6 +33,9 @@ public static IServiceCollection AddApplicationAuditReconciliationCore( "The warning finding threshold must not be negative.") .Validate(options => options.HealthUnhealthyFindingCount >= options.HealthWarningFindingCount, "The unhealthy finding threshold must not be less than the warning threshold.") + .Validate(options => options.HealthStaleRunThreshold is null || + options.HealthStaleRunThreshold > options.Interval, + "The health stale-run threshold must be greater than the reconciliation interval.") .ValidateOnStart(); if (configure is not null) diff --git a/src/ProjectTemplate.Web/Authentication/Extensions/AuthenticationServiceExtensions.cs b/src/ProjectTemplate.Web/Authentication/Extensions/AuthenticationServiceExtensions.cs index 69a26310..925b2957 100644 --- a/src/ProjectTemplate.Web/Authentication/Extensions/AuthenticationServiceExtensions.cs +++ b/src/ProjectTemplate.Web/Authentication/Extensions/AuthenticationServiceExtensions.cs @@ -16,6 +16,16 @@ namespace ProjectTemplate.Web.Authentication.Extensions; /// public static class AuthenticationServiceExtensions { + /// + /// The authentication cookie name used when the cookie is always sent with the Secure attribute. + /// + public const string HostPrefixedAuthenticationCookieName = "__Host-ProjectTemplate.Web.Authentication"; + + /// + /// The authentication cookie name used when the Development-only insecure HTTP override is active. + /// + public const string AuthenticationCookieName = ".ProjectTemplate.Web.Authentication"; + /// /// Adds application authentication services based on configuration using the secure cookie policy without an /// environment-specific plain HTTP override. @@ -94,13 +104,24 @@ public static IServiceCollection AddApplicationAuthentication( options.ExpireTimeSpan = TimeSpan.FromMinutes(applicationAuthenticationOptions.Cookie.ExpireMinutes); options.SlidingExpiration = applicationAuthenticationOptions.Cookie.SlidingExpiration; - options.Cookie.Name = ".ProjectTemplate.Web.Authentication"; options.Cookie.HttpOnly = true; options.Cookie.SameSite = SameSiteMode.Lax; options.Cookie.SecurePolicy = applicationAuthenticationOptions.Cookie.AllowInsecureHttp && environment?.IsDevelopment() == true ? CookieSecurePolicy.SameAsRequest : CookieSecurePolicy.Always; + + // Browsers accept a __Host- cookie only when it is Secure, has Path=/, and has no Domain, which binds + // the session cookie to this exact host. The prefix is used only when the cookie is always Secure, so + // the Development-only insecure HTTP override keeps working with an unprefixed name. + bool useHostPrefix = options.Cookie.SecurePolicy == CookieSecurePolicy.Always; + options.Cookie.Name = useHostPrefix + ? HostPrefixedAuthenticationCookieName + : AuthenticationCookieName; + if (useHostPrefix) + { + options.Cookie.Path = "/"; + } }); ApplicationAuthenticationOptions applicationAuthenticationOptions = configuration diff --git a/src/ProjectTemplate.Web/Extensions/ApplicationAuditReconciliationServiceExtensions.cs b/src/ProjectTemplate.Web/Extensions/ApplicationAuditReconciliationServiceExtensions.cs index 5cf9a972..d35ab28a 100644 --- a/src/ProjectTemplate.Web/Extensions/ApplicationAuditReconciliationServiceExtensions.cs +++ b/src/ProjectTemplate.Web/Extensions/ApplicationAuditReconciliationServiceExtensions.cs @@ -18,7 +18,7 @@ public static IServiceCollection AddApplicationAuditReconciliation( services.AddHealthChecks() .AddCheck( "application-audit-integrity", - tags: ["ready", "audit", "integrity"]); + tags: [ApplicationHealthCheckTags.Audit, ApplicationHealthCheckTags.Integrity]); return services; } } diff --git a/src/ProjectTemplate.Web/Extensions/ConfigurationListBinding.cs b/src/ProjectTemplate.Web/Extensions/ConfigurationListBinding.cs new file mode 100644 index 00000000..4c017921 --- /dev/null +++ b/src/ProjectTemplate.Web/Extensions/ConfigurationListBinding.cs @@ -0,0 +1,46 @@ +namespace ProjectTemplate.Web.Extensions; + +/// +/// Gives list options bound from configuration replace semantics instead of append semantics. +/// +/// +/// The configuration binder adds configured entries to a list that already holds code defaults, so a configured list +/// could only ever extend the defaults and never remove one. A configured list replaces the defaults here instead. +/// Blank entries are ignored, which lets a higher-precedence configuration source remove an inherited entry by +/// overriding its index with an empty value. When the section has no entries, the code defaults are kept. +/// +internal static class ConfigurationListBinding +{ + /// + /// Replaces the contents of with the non-blank values configured in + /// , when the section has any entries. + /// + /// The bound list to replace. + /// The configuration section that holds the list entries. + internal static void ReplaceWithConfiguredValues(List target, IConfigurationSection section) + { + ArgumentNullException.ThrowIfNull(target); + ArgumentNullException.ThrowIfNull(section); + + List configuredValues = []; + bool hasConfiguredEntries = false; + + foreach (IConfigurationSection child in section.GetChildren()) + { + hasConfiguredEntries = true; + + if (!string.IsNullOrWhiteSpace(child.Value)) + { + configuredValues.Add(child.Value); + } + } + + if (!hasConfiguredEntries) + { + return; + } + + target.Clear(); + target.AddRange(configuredValues); + } +} diff --git a/src/ProjectTemplate.Web/Extensions/DataAccessServiceExtensions.cs b/src/ProjectTemplate.Web/Extensions/DataAccessServiceExtensions.cs index 9a18790b..bd876c34 100644 --- a/src/ProjectTemplate.Web/Extensions/DataAccessServiceExtensions.cs +++ b/src/ProjectTemplate.Web/Extensions/DataAccessServiceExtensions.cs @@ -3,6 +3,8 @@ using ProjectTemplate.Infrastructure.Data.Extensions; using ProjectTemplate.Infrastructure.Data.Services; using ProjectTemplate.Web.Accessors; +using ProjectTemplate.Web.HealthChecks; +using ProjectTemplate.Web.Options; namespace ProjectTemplate.Web.Extensions; @@ -26,9 +28,34 @@ public static IServiceCollection AddApplicationDataAccess( services.AddScoped(); services.AddApplicationInfrastructureDataAccess(configuration); + AddApplicationDatabaseReadinessCheck(services, configuration); services.AddHostedService(); return services; } + + private static void AddApplicationDatabaseReadinessCheck( + IServiceCollection services, + IConfiguration configuration) + { + // Bound lazily and read by the check on every run, so the setting follows the final configuration, including + // sources added after service registration such as test or orchestrator overrides. + services + .AddOptions() + .Bind(configuration.GetSection(ApplicationHealthCheckOptions.SectionName)); + + // Infrastructure registers ApplicationDbContext only when a data access provider is enabled. + bool dataAccessEnabled = services.Any(descriptor => descriptor.ServiceType == typeof(ApplicationDbContext)); + + if (!dataAccessEnabled) + { + return; + } + + services.AddHealthChecks() + .AddCheck( + "application-database", + tags: [ApplicationHealthCheckTags.Ready, ApplicationHealthCheckTags.Database]); + } } diff --git a/src/ProjectTemplate.Web/Extensions/HealthCheckExtensions.cs b/src/ProjectTemplate.Web/Extensions/HealthCheckExtensions.cs index 5fd5f8c7..1d72e5ff 100644 --- a/src/ProjectTemplate.Web/Extensions/HealthCheckExtensions.cs +++ b/src/ProjectTemplate.Web/Extensions/HealthCheckExtensions.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using ProjectTemplate.Web.HealthChecks; namespace ProjectTemplate.Web.Extensions; @@ -22,6 +23,11 @@ public static IServiceCollection AddApplicationHealthChecks(this IServiceCollect /// /// Maps baseline health check endpoints for infrastructure, reverse proxies, and hosting platforms. /// + /// + /// /health/ready runs only checks tagged , and + /// /health/audit-integrity runs only checks tagged . Audit integrity is + /// reported separately so an integrity finding alerts operators without removing every replica from rotation. + /// /// The web application used to map health check endpoints. /// The original for chaining. public static WebApplication MapApplicationHealthChecks(this WebApplication app) @@ -31,7 +37,13 @@ public static WebApplication MapApplicationHealthChecks(this WebApplication app) app.MapHealthChecks("/health/ready", new HealthCheckOptions { - Predicate = healthCheck => healthCheck.Tags.Contains("ready") + Predicate = healthCheck => healthCheck.Tags.Contains(ApplicationHealthCheckTags.Ready) + }) + .AllowAnonymous(); + + app.MapHealthChecks("/health/audit-integrity", new HealthCheckOptions + { + Predicate = healthCheck => healthCheck.Tags.Contains(ApplicationHealthCheckTags.Audit) }) .AllowAnonymous(); diff --git a/src/ProjectTemplate.Web/Extensions/RateLimitingServiceExtensions.cs b/src/ProjectTemplate.Web/Extensions/RateLimitingServiceExtensions.cs index e0929c8b..f4c308c7 100644 --- a/src/ProjectTemplate.Web/Extensions/RateLimitingServiceExtensions.cs +++ b/src/ProjectTemplate.Web/Extensions/RateLimitingServiceExtensions.cs @@ -1,4 +1,6 @@ using System.Globalization; +using System.Net; +using System.Net.Sockets; using System.Threading.RateLimiting; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; @@ -19,6 +21,8 @@ public static partial class RateLimitingServiceExtensions internal const string RejectionDetail = "Too many requests were received. Please try again later."; internal const string RejectionProblemType = "https://www.rfc-editor.org/rfc/rfc6585#section-4"; + private const int _ipv6AddressBitCount = 128; + /// /// Adds the application's predefined rate limiting policies to the service collection. /// @@ -61,6 +65,8 @@ public static IServiceCollection AddApplicationRateLimiting( .Bind(configuration.GetSection(ApplicationRateLimitingOptions.SectionName)) .Validate(options => !string.IsNullOrWhiteSpace(options.UnknownClientPartitionKey), "ProjectTemplate:RateLimiting:UnknownClientPartitionKey must not be empty.") + .Validate(options => options.IPv6PartitionPrefixLength is >= 1 and <= _ipv6AddressBitCount, + "ProjectTemplate:RateLimiting:IPv6PartitionPrefixLength must be between 1 and 128.") .Validate(options => options.GlobalFixedWindow.PermitLimit > 0, "ProjectTemplate:RateLimiting:GlobalFixedWindow:PermitLimit must be greater than zero.") .Validate(options => options.GlobalFixedWindow.WindowSeconds > 0, @@ -126,7 +132,11 @@ await WriteRejectionResponseAsync(httpContext, retryAfter, cancellationToken) options.AddPolicy(ApplicationRateLimitingPolicyNames.Concurrency, httpContext => RateLimitPartition.GetConcurrencyLimiter( - partitionKey: GetEndpointPartitionKey(httpContext), + partitionKey: GetConcurrencyPartitionKey( + httpContext, + rateLimitingOptions, + CreateRateLimitingLogger(httpContext), + fallbackWarningThrottle), factory: _ => CreateConcurrencyLimiterOptions(rateLimitingOptions.ConcurrencyPolicy))); }); @@ -268,11 +278,18 @@ private static string GetClientPartitionKey( ApplicationRateLimitingOptions options, RateLimitingFallbackWarningThrottle fallbackWarningThrottle) { - ILogger logger = httpContext.RequestServices + return GetClientPartitionKey( + httpContext, + options, + CreateRateLimitingLogger(httpContext), + fallbackWarningThrottle); + } + + private static ILogger CreateRateLimitingLogger(HttpContext httpContext) + { + return httpContext.RequestServices .GetRequiredService() .CreateLogger("Template.Web.RateLimiting"); - - return GetClientPartitionKey(httpContext, options, logger, fallbackWarningThrottle); } internal static string GetClientPartitionKey( @@ -281,11 +298,11 @@ internal static string GetClientPartitionKey( ILogger logger, RateLimitingFallbackWarningThrottle? fallbackWarningThrottle = null) { - string? remoteIpAddress = httpContext.Connection.RemoteIpAddress?.ToString(); + IPAddress? remoteIpAddress = httpContext.Connection.RemoteIpAddress; - if (!string.IsNullOrWhiteSpace(remoteIpAddress)) + if (remoteIpAddress is not null) { - return remoteIpAddress; + return GetAddressPartitionKey(remoteIpAddress, options.IPv6PartitionPrefixLength); } string fallbackPartitionKey = string.IsNullOrWhiteSpace(options.UnknownClientPartitionKey) @@ -317,6 +334,63 @@ internal static string GetClientPartitionKey( return fallbackPartitionKey; } + /// + /// Returns the rate limiting partition key for a client address. + /// + /// + /// IPv4-mapped IPv6 addresses are converted to IPv4 first, because a dual-stack listener reports IPv4 clients in + /// that form and masking them as IPv6 would place every IPv4 client in one partition. Other IPv6 addresses are + /// reduced to their network prefix so a client cannot bypass the limiter by rotating addresses inside it. + /// + /// The client address. + /// The IPv6 prefix length that identifies one client. + /// The partition key. + internal static string GetAddressPartitionKey(IPAddress address, int ipv6PrefixLength) + { + ArgumentNullException.ThrowIfNull(address); + + IPAddress normalizedAddress = address.IsIPv4MappedToIPv6 ? address.MapToIPv4() : address; + + if (normalizedAddress.AddressFamily != AddressFamily.InterNetworkV6 + || ipv6PrefixLength >= _ipv6AddressBitCount) + { + return normalizedAddress.ToString(); + } + + int prefixLength = Math.Max(ipv6PrefixLength, 1); + byte[] addressBytes = normalizedAddress.GetAddressBytes(); + for (int bitIndex = prefixLength; bitIndex < _ipv6AddressBitCount; bitIndex++) + { + addressBytes[bitIndex / 8] &= (byte)~(0x80 >> (bitIndex % 8)); + } + + return string.Create(CultureInfo.InvariantCulture, $"{new IPAddress(addressBytes)}/{prefixLength}"); + } + + /// + /// Returns the partition key used by the named concurrency policy. + /// + /// The current HTTP context. + /// The rate limiting options. + /// The logger used when the client address falls back to an unknown-client partition. + /// An optional throttle for fallback warnings. + /// The endpoint key, combined with the client key when concurrency is partitioned by client. + internal static string GetConcurrencyPartitionKey( + HttpContext httpContext, + ApplicationRateLimitingOptions options, + ILogger logger, + RateLimitingFallbackWarningThrottle? fallbackWarningThrottle = null) + { + ArgumentNullException.ThrowIfNull(httpContext); + ArgumentNullException.ThrowIfNull(options); + + string endpointPartitionKey = GetEndpointPartitionKey(httpContext); + + return options.ConcurrencyPolicy.PartitionByClient + ? $"{endpointPartitionKey}|{GetClientPartitionKey(httpContext, options, logger, fallbackWarningThrottle)}" + : endpointPartitionKey; + } + private static string GetEndpointPartitionKey(HttpContext httpContext) { return httpContext.GetEndpoint()?.DisplayName diff --git a/src/ProjectTemplate.Web/Extensions/RequestLoggingExtensions.cs b/src/ProjectTemplate.Web/Extensions/RequestLoggingExtensions.cs index 61267ee6..c56ad2b9 100644 --- a/src/ProjectTemplate.Web/Extensions/RequestLoggingExtensions.cs +++ b/src/ProjectTemplate.Web/Extensions/RequestLoggingExtensions.cs @@ -23,9 +23,14 @@ public static IServiceCollection AddApplicationRequestLogging( this IServiceCollection services, IConfiguration configuration) { + IConfigurationSection section = configuration.GetSection(ApplicationRequestLoggingOptions.SectionName); + services .AddOptions() - .Bind(configuration.GetSection(ApplicationRequestLoggingOptions.SectionName)) + .Bind(section) + .Configure(options => ConfigurationListBinding.ReplaceWithConfiguredValues( + options.ExcludedPathPrefixes, + section.GetSection(nameof(ApplicationRequestLoggingOptions.ExcludedPathPrefixes)))) .Validate( options => !string.IsNullOrWhiteSpace(options.CorrelationHeaderName), "ProjectTemplate:RequestLogging:CorrelationHeaderName is required.") diff --git a/src/ProjectTemplate.Web/Extensions/SecurityHeadersExtensions.cs b/src/ProjectTemplate.Web/Extensions/SecurityHeadersExtensions.cs index de7e14ed..bc41fbc1 100644 --- a/src/ProjectTemplate.Web/Extensions/SecurityHeadersExtensions.cs +++ b/src/ProjectTemplate.Web/Extensions/SecurityHeadersExtensions.cs @@ -18,9 +18,14 @@ public static IServiceCollection AddApplicationSecurityHeaders( this IServiceCollection services, IConfiguration configuration) { + IConfigurationSection section = configuration.GetSection(ApplicationSecurityHeadersOptions.SectionName); + services .AddOptions() - .Bind(configuration.GetSection(ApplicationSecurityHeadersOptions.SectionName)) + .Bind(section) + .Configure(options => ConfigurationListBinding.ReplaceWithConfiguredValues( + options.ExcludedPathPrefixes, + section.GetSection(nameof(ApplicationSecurityHeadersOptions.ExcludedPathPrefixes)))) .Validate( options => !options.EnableContentSecurityPolicy || diff --git a/src/ProjectTemplate.Web/Extensions/StartupSecurityPostureExtensions.cs b/src/ProjectTemplate.Web/Extensions/StartupSecurityPostureExtensions.cs index 1e6ffe7f..6d99eb08 100644 --- a/src/ProjectTemplate.Web/Extensions/StartupSecurityPostureExtensions.cs +++ b/src/ProjectTemplate.Web/Extensions/StartupSecurityPostureExtensions.cs @@ -12,7 +12,7 @@ public static class StartupSecurityPostureExtensions ApplicationAuthenticationOptions.SectionName + ":Enabled"; private const string _anonymousHealthEndpoints = - "/health, /health/ready, /health/live"; + "/health, /health/ready, /health/live, /health/audit-integrity"; private const string _dataProtectionKeyRingPathConfigurationKey = ApplicationDataProtectionOptions.SectionName + ":" + nameof(ApplicationDataProtectionOptions.KeyRingPath); diff --git a/src/ProjectTemplate.Web/HealthChecks/ApplicationAuditIntegrityHealthCheck.cs b/src/ProjectTemplate.Web/HealthChecks/ApplicationAuditIntegrityHealthCheck.cs index 3146f5e1..c59b4dd5 100644 --- a/src/ProjectTemplate.Web/HealthChecks/ApplicationAuditIntegrityHealthCheck.cs +++ b/src/ProjectTemplate.Web/HealthChecks/ApplicationAuditIntegrityHealthCheck.cs @@ -4,16 +4,41 @@ namespace ProjectTemplate.Web.HealthChecks; +/// +/// Reports audit reconciliation findings, audit delivery state, and whether reconciliation is still running. +/// +/// +/// The check is registered with the tag and exposed through +/// /health/audit-integrity. It is intentionally excluded from readiness: an integrity finding requires operator review, +/// and a readiness failure would remove every replica from load balancing at the same moment. +/// public sealed class ApplicationAuditIntegrityHealthCheck( IServiceScopeFactory scopeFactory, - IOptions options) + IOptions options, + TimeProvider timeProvider) : IHealthCheck { + internal const string NeverRunDescription = + "Audit reconciliation has not completed a run in this process."; + + internal const string StaleRunDescription = + "The last successful audit reconciliation run is older than the configured stale-run threshold."; + + private const int _defaultStaleRunIntervalMultiplier = 3; + private readonly IServiceScopeFactory _scopeFactory = scopeFactory ?? throw new ArgumentNullException(nameof(scopeFactory)); private readonly ApplicationAuditReconciliationOptions _options = options?.Value ?? throw new ArgumentNullException(nameof(options)); + private readonly TimeProvider _timeProvider = + timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); + /// + /// Evaluates audit reconciliation findings, delivery health, and reconciliation freshness. + /// + /// The health check context. + /// A token that cancels the check. + /// The audit integrity health result. public async Task CheckHealthAsync( HealthCheckContext context, CancellationToken cancellationToken = default) @@ -69,6 +94,8 @@ public async Task CheckHealthAsync( } } + string? freshnessProblem = EvaluateRunFreshness(summary.LastRunUtc, data); + bool unhealthy = summary.CriticalFindingCount > 0 || summary.ManifestVerificationFailureCount > 0 || summary.OpenFindingCount >= _options.HealthUnhealthyFindingCount; @@ -79,6 +106,13 @@ public async Task CheckHealthAsync( data: data); } + // Zero findings only means something when reconciliation is actually running. A worker that never completed a + // run, stopped, or keeps failing would otherwise read as healthy indefinitely. + if (freshnessProblem is not null) + { + return HealthCheckResult.Degraded(freshnessProblem, data: data); + } + bool degraded = summary.OpenFindingCount >= _options.HealthWarningFindingCount || summary.StaleDeliveryCount > 0 || summary.DeadLetterCount > 0 || @@ -89,4 +123,40 @@ public async Task CheckHealthAsync( data: data) : HealthCheckResult.Healthy("Audit integrity and delivery state are within configured thresholds.", data); } + + /// + /// Returns the age after which a successful reconciliation run is considered stale. + /// + /// The reconciliation options. + /// The configured threshold, or three reconciliation intervals when none is configured. + internal static TimeSpan GetStaleRunThreshold(ApplicationAuditReconciliationOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + return options.HealthStaleRunThreshold ?? (options.Interval * _defaultStaleRunIntervalMultiplier); + } + + private string? EvaluateRunFreshness(DateTime? lastRunUtc, Dictionary data) + { + if (!_options.RunWorker) + { + // The scheduled loop runs in another process, so this process cannot observe when reconciliation last ran. + data["reconciliationFreshnessTracked"] = false; + return null; + } + + TimeSpan staleRunThreshold = GetStaleRunThreshold(_options); + data["reconciliationFreshnessTracked"] = true; + data["reconciliationStaleAfterSeconds"] = staleRunThreshold.TotalSeconds; + + if (!lastRunUtc.HasValue) + { + return NeverRunDescription; + } + + TimeSpan sinceLastRun = _timeProvider.GetUtcNow().UtcDateTime - lastRunUtc.Value; + data["secondsSinceLastReconciliation"] = Math.Max(sinceLastRun.TotalSeconds, 0); + + return sinceLastRun > staleRunThreshold ? StaleRunDescription : null; + } } diff --git a/src/ProjectTemplate.Web/HealthChecks/ApplicationDatabaseHealthCheck.cs b/src/ProjectTemplate.Web/HealthChecks/ApplicationDatabaseHealthCheck.cs new file mode 100644 index 00000000..5fdedb05 --- /dev/null +++ b/src/ProjectTemplate.Web/HealthChecks/ApplicationDatabaseHealthCheck.cs @@ -0,0 +1,85 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Options; +using ProjectTemplate.Infrastructure.Data; +using ProjectTemplate.Web.Options; + +namespace ProjectTemplate.Web.HealthChecks; + +/// +/// Reports whether the application database accepts connections. +/// +/// +/// Registered with the tag so an instance that cannot reach its database +/// is removed from load balancing. The result description never includes connection strings or provider exception +/// text. For SQLite, a database file that has not been created yet is reported as unavailable. When +/// is , the check +/// reports healthy without contacting the database. +/// +public sealed class ApplicationDatabaseHealthCheck( + IServiceScopeFactory scopeFactory, + IOptionsMonitor options) + : IHealthCheck +{ + internal const string AvailableDescription = "The application database is reachable."; + + internal const string UnavailableDescription = "The application database is not reachable."; + + internal const string DisabledDescription = "The application database readiness check is disabled."; + + private readonly IServiceScopeFactory _scopeFactory = + scopeFactory ?? throw new ArgumentNullException(nameof(scopeFactory)); + private readonly IOptionsMonitor _options = + options ?? throw new ArgumentNullException(nameof(options)); + + /// + /// Checks whether the application database accepts connections. + /// + /// The health check context. + /// A token that cancels the check. + /// The database health result. + public async Task CheckHealthAsync( + HealthCheckContext context, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(context); + cancellationToken.ThrowIfCancellationRequested(); + + if (!_options.CurrentValue.DatabaseReadinessCheckEnabled) + { + return HealthCheckResult.Healthy(DisabledDescription); + } + + using IServiceScope scope = _scopeFactory.CreateScope(); + ApplicationDbContext dbContext = scope.ServiceProvider.GetRequiredService(); + + if (IsMissingSqliteDatabase(dbContext)) + { + return new HealthCheckResult(context.Registration.FailureStatus, UnavailableDescription); + } + + bool canConnect = await dbContext.Database + .CanConnectAsync(cancellationToken) + .ConfigureAwait(false); + + return canConnect + ? HealthCheckResult.Healthy(AvailableDescription) + : new HealthCheckResult(context.Registration.FailureStatus, UnavailableDescription); + } + + private static bool IsMissingSqliteDatabase(ApplicationDbContext dbContext) + { + if (!string.Equals( + dbContext.Database.ProviderName, + "Microsoft.EntityFrameworkCore.Sqlite", + StringComparison.Ordinal)) + { + return false; + } + + string dataSource = dbContext.Database.GetDbConnection().DataSource; + + return !string.IsNullOrWhiteSpace(dataSource) && + !string.Equals(dataSource, ":memory:", StringComparison.OrdinalIgnoreCase) && !File.Exists(dataSource); + } +} diff --git a/src/ProjectTemplate.Web/HealthChecks/ApplicationHealthCheckTags.cs b/src/ProjectTemplate.Web/HealthChecks/ApplicationHealthCheckTags.cs new file mode 100644 index 00000000..de58dee7 --- /dev/null +++ b/src/ProjectTemplate.Web/HealthChecks/ApplicationHealthCheckTags.cs @@ -0,0 +1,29 @@ +namespace ProjectTemplate.Web.HealthChecks; + +/// +/// Defines the health check tags that select which checks each health endpoint runs. +/// +public static class ApplicationHealthCheckTags +{ + /// + /// Selects checks for /health/ready. Use it only for dependencies without which this instance cannot serve + /// normal traffic, because a failing readiness check removes the instance from load balancing. + /// + public const string Ready = "ready"; + + /// + /// Selects checks for /health/audit-integrity. Audit integrity and delivery state require operator attention, but + /// they do not determine whether an instance can serve traffic, so these checks are kept out of readiness. + /// + public const string Audit = "audit"; + + /// + /// Identifies checks that evaluate audit integrity. + /// + public const string Integrity = "integrity"; + + /// + /// Identifies checks that evaluate application database connectivity. + /// + public const string Database = "database"; +} diff --git a/src/ProjectTemplate.Web/Options/ApplicationHealthCheckOptions.cs b/src/ProjectTemplate.Web/Options/ApplicationHealthCheckOptions.cs new file mode 100644 index 00000000..029aedad --- /dev/null +++ b/src/ProjectTemplate.Web/Options/ApplicationHealthCheckOptions.cs @@ -0,0 +1,24 @@ +namespace ProjectTemplate.Web.Options; + +/// +/// Represents application health check configuration. +/// +public sealed class ApplicationHealthCheckOptions +{ + /// + /// Configuration section name for health check settings. + /// + public const string SectionName = "ProjectTemplate:HealthChecks"; + + /// + /// Gets or sets a value indicating whether /health/ready includes an application database connectivity + /// check when EF Core data access is enabled. + /// + /// + /// Defaults to so readiness reflects whether the instance can reach the database it needs to + /// serve traffic. The check is not registered when the data access provider is None. The value is read each + /// time the check runs; when it is , the registered check reports healthy without + /// contacting the database. + /// + public bool DatabaseReadinessCheckEnabled { get; set; } = true; +} diff --git a/src/ProjectTemplate.Web/Options/ApplicationRateLimitingOptions.cs b/src/ProjectTemplate.Web/Options/ApplicationRateLimitingOptions.cs index 21c117c5..6a280542 100644 --- a/src/ProjectTemplate.Web/Options/ApplicationRateLimitingOptions.cs +++ b/src/ProjectTemplate.Web/Options/ApplicationRateLimitingOptions.cs @@ -37,6 +37,16 @@ public sealed class ApplicationRateLimitingOptions /// public string UnknownClientPartitionKey { get; set; } = "unknown-client"; + /// + /// Gets or sets the IPv6 prefix length used to group client addresses into one rate limiting partition. + /// + /// + /// Defaults to 64. A single IPv6 subscriber commonly controls a whole /64 and can rotate addresses within it, + /// so partitioning by full address would let one client bypass per-client limits. Use 128 to partition by + /// full address. IPv4 and IPv4-mapped IPv6 addresses are always partitioned by their IPv4 address. + /// + public int IPv6PartitionPrefixLength { get; set; } = 64; + /// /// Gets or sets the global fixed-window rate limiting options. /// diff --git a/src/ProjectTemplate.Web/Options/ApplicationRequestLoggingOptions.cs b/src/ProjectTemplate.Web/Options/ApplicationRequestLoggingOptions.cs index 6a65fa5d..179efe89 100644 --- a/src/ProjectTemplate.Web/Options/ApplicationRequestLoggingOptions.cs +++ b/src/ProjectTemplate.Web/Options/ApplicationRequestLoggingOptions.cs @@ -50,6 +50,10 @@ public sealed class ApplicationRequestLoggingOptions /// Gets or sets path prefixes that should be excluded from normal request logging. /// Matching requests are logged at Verbose level so the default sinks suppress them. /// + /// + /// A configured list replaces these defaults rather than extending them. A blank configured entry is ignored, so a + /// later configuration source can remove an inherited entry by overriding its index with an empty value. + /// public List ExcludedPathPrefixes { get; set; } = [ "/health", diff --git a/src/ProjectTemplate.Web/Options/ApplicationSecurityHeadersOptions.cs b/src/ProjectTemplate.Web/Options/ApplicationSecurityHeadersOptions.cs index 15ac37cb..9d1c6a8a 100644 --- a/src/ProjectTemplate.Web/Options/ApplicationSecurityHeadersOptions.cs +++ b/src/ProjectTemplate.Web/Options/ApplicationSecurityHeadersOptions.cs @@ -53,6 +53,10 @@ public sealed class ApplicationSecurityHeadersOptions /// /// Gets or sets path prefixes that are excluded from applying the security headers. /// + /// + /// A configured list replaces these defaults rather than extending them. A blank configured entry is ignored, so a + /// later configuration source can remove an inherited entry by overriding its index with an empty value. + /// public List ExcludedPathPrefixes { get; set; } = [ "/health", diff --git a/src/ProjectTemplate.Web/Options/ConcurrencyRateLimitingOptions.cs b/src/ProjectTemplate.Web/Options/ConcurrencyRateLimitingOptions.cs index 0db1b685..ef64efb5 100644 --- a/src/ProjectTemplate.Web/Options/ConcurrencyRateLimitingOptions.cs +++ b/src/ProjectTemplate.Web/Options/ConcurrencyRateLimitingOptions.cs @@ -14,4 +14,14 @@ public sealed class ConcurrencyRateLimitingOptions /// The maximum number of requests allowed to wait in the queue. /// public int QueueLimit { get; set; } + + /// + /// Gets or sets a value indicating whether each client receives its own concurrency permits for an endpoint. + /// + /// + /// Defaults to , so one client holding slow requests cannot exhaust an endpoint's permits for + /// every other client. Set to to share one permit pool per endpoint across all clients, + /// which caps total endpoint concurrency but lets a single client consume every permit. + /// + public bool PartitionByClient { get; set; } = true; } diff --git a/src/ProjectTemplate.Web/appsettings.json b/src/ProjectTemplate.Web/appsettings.json index 0efdd8fb..e5227179 100644 --- a/src/ProjectTemplate.Web/appsettings.json +++ b/src/ProjectTemplate.Web/appsettings.json @@ -88,6 +88,7 @@ "UseGlobalLimiter": true, "UseSharedUnknownClientPartition": false, "UnknownClientPartitionKey": "unknown-client", + "IPv6PartitionPrefixLength": 64, "GlobalFixedWindow": { "PermitLimit": 60, "WindowSeconds": 60, @@ -100,7 +101,8 @@ }, "ConcurrencyPolicy": { "PermitLimit": 10, - "QueueLimit": 0 + "QueueLimit": 0, + "PartitionByClient": true } }, "RequestLogging": { @@ -229,6 +231,9 @@ "StorageMode": "Local" } }, + "HealthChecks": { + "DatabaseReadinessCheckEnabled": true + }, "ApiVersioning": { "DefaultMajorVersion": 1, "DefaultMinorVersion": 0, diff --git a/tests/ProjectTemplate.Web.Tests/ApplicationAuditIntegrityHealthCheckTests.cs b/tests/ProjectTemplate.Web.Tests/ApplicationAuditIntegrityHealthCheckTests.cs new file mode 100644 index 00000000..57b1f12e --- /dev/null +++ b/tests/ProjectTemplate.Web.Tests/ApplicationAuditIntegrityHealthCheckTests.cs @@ -0,0 +1,267 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using ProjectTemplate.Infrastructure.Data.Auditing; +using ProjectTemplate.Infrastructure.Data.Extensions; +using ProjectTemplate.Web.Extensions; +using ProjectTemplate.Web.HealthChecks; + +namespace ProjectTemplate.Web.Tests; + +/// +/// Provides tests for the audit integrity health check, including reconciliation freshness and endpoint placement. +/// +public sealed class ApplicationAuditIntegrityHealthCheckTests +{ + private static readonly DateTime _now = new(2026, 9, 22, 12, 0, 0, DateTimeKind.Utc); + + /// + /// Verifies that a process whose worker never completed a run reports Degraded instead of Healthy. + /// + /// A task that represents the asynchronous test operation. + [Fact] + public async Task CheckHealthAsync_NoCompletedRun_ReportsDegraded() + { + HealthCheckResult result = await CheckAsync(CreateSummary(lastRunUtc: null), CreateOptions()); + + Assert.Equal(HealthStatus.Degraded, result.Status); + Assert.Equal(ApplicationAuditIntegrityHealthCheck.NeverRunDescription, result.Description); + Assert.True((bool)result.Data["reconciliationFreshnessTracked"]); + } + + /// + /// Verifies that a recent successful run with no findings reports Healthy. + /// + /// A task that represents the asynchronous test operation. + [Fact] + public async Task CheckHealthAsync_RecentRun_ReportsHealthy() + { + HealthCheckResult result = await CheckAsync( + CreateSummary(lastRunUtc: _now.AddMinutes(-1)), + CreateOptions()); + + Assert.Equal(HealthStatus.Healthy, result.Status); + Assert.Equal(60d, (double)result.Data["secondsSinceLastReconciliation"]); + } + + /// + /// Verifies that a run older than the default threshold of three intervals reports Degraded. + /// + /// A task that represents the asynchronous test operation. + [Fact] + public async Task CheckHealthAsync_RunOlderThanDefaultThreshold_ReportsDegraded() + { + HealthCheckResult result = await CheckAsync( + CreateSummary(lastRunUtc: _now.AddMinutes(-16)), + CreateOptions()); + + Assert.Equal(HealthStatus.Degraded, result.Status); + Assert.Equal(ApplicationAuditIntegrityHealthCheck.StaleRunDescription, result.Description); + Assert.Equal(900d, (double)result.Data["reconciliationStaleAfterSeconds"]); + } + + /// + /// Verifies that a configured stale-run threshold replaces the default. + /// + /// A task that represents the asynchronous test operation. + [Fact] + public async Task CheckHealthAsync_ConfiguredThreshold_IsHonored() + { + ApplicationAuditReconciliationOptions options = CreateOptions(); + options.HealthStaleRunThreshold = TimeSpan.FromHours(1); + + HealthCheckResult result = await CheckAsync( + CreateSummary(lastRunUtc: _now.AddMinutes(-30)), + options); + + Assert.Equal(HealthStatus.Healthy, result.Status); + } + + /// + /// Verifies that freshness is not evaluated when another process runs the reconciliation loop. + /// + /// A task that represents the asynchronous test operation. + [Fact] + public async Task CheckHealthAsync_WorkerNotRunInProcess_DoesNotEvaluateFreshness() + { + ApplicationAuditReconciliationOptions options = CreateOptions(); + options.RunWorker = false; + + HealthCheckResult result = await CheckAsync(CreateSummary(lastRunUtc: null), options); + + Assert.Equal(HealthStatus.Healthy, result.Status); + Assert.False((bool)result.Data["reconciliationFreshnessTracked"]); + } + + /// + /// Verifies that a critical finding still reports Unhealthy when reconciliation is also stale. + /// + /// A task that represents the asynchronous test operation. + [Fact] + public async Task CheckHealthAsync_CriticalFindingAndStaleRun_ReportsUnhealthy() + { + HealthCheckResult result = await CheckAsync( + CreateSummary(lastRunUtc: _now.AddHours(-2), criticalFindings: 1), + CreateOptions()); + + Assert.Equal(HealthStatus.Unhealthy, result.Status); + } + + /// + /// Verifies that disabled reconciliation reports Healthy without evaluating freshness. + /// + /// A task that represents the asynchronous test operation. + [Fact] + public async Task CheckHealthAsync_ReconciliationDisabled_ReportsHealthy() + { + ApplicationAuditReconciliationOptions options = CreateOptions(); + options.Enabled = false; + + HealthCheckResult result = await CheckAsync(CreateSummary(lastRunUtc: null), options); + + Assert.Equal(HealthStatus.Healthy, result.Status); + } + + /// + /// Verifies that the default stale-run threshold is three reconciliation intervals. + /// + [Fact] + public void GetStaleRunThreshold_NotConfigured_UsesThreeIntervals() + { + ApplicationAuditReconciliationOptions options = CreateOptions(); + options.Interval = TimeSpan.FromMinutes(7); + + TimeSpan threshold = ApplicationAuditIntegrityHealthCheck.GetStaleRunThreshold(options); + + Assert.Equal(TimeSpan.FromMinutes(21), threshold); + } + + /// + /// Verifies that the audit integrity check is excluded from readiness and selected by the audit endpoint tag. + /// + [Fact] + public void AddApplicationAuditReconciliation_RegistersCheckOutsideReadiness() + { + ServiceCollection services = new(); + _ = services.AddApplicationAuditReconciliation(); + + using ServiceProvider provider = services.BuildServiceProvider(validateScopes: true); + HealthCheckRegistration registration = Assert.Single( + provider + .GetRequiredService>() + .Value + .Registrations, + candidate => candidate.Name == "application-audit-integrity"); + + Assert.Contains(ApplicationHealthCheckTags.Audit, registration.Tags); + Assert.Contains(ApplicationHealthCheckTags.Integrity, registration.Tags); + Assert.DoesNotContain(ApplicationHealthCheckTags.Ready, registration.Tags); + } + + /// + /// Verifies that startup validation rejects a stale-run threshold that is not greater than the interval. + /// + [Fact] + public void AddApplicationAuditReconciliationCore_ThresholdNotGreaterThanInterval_FailsValidation() + { + ServiceCollection services = new(); + _ = services.AddApplicationAuditReconciliationCore(options => + { + options.Interval = TimeSpan.FromMinutes(5); + options.HealthStaleRunThreshold = TimeSpan.FromMinutes(5); + }); + + using ServiceProvider provider = services.BuildServiceProvider(validateScopes: true); + + Microsoft.Extensions.Options.OptionsValidationException exception = + Assert.Throws(() => + provider + .GetRequiredService>() + .Value); + + Assert.Contains( + "The health stale-run threshold must be greater than the reconciliation interval.", + exception.Message, + StringComparison.Ordinal); + } + + private static async Task CheckAsync( + ApplicationAuditReconciliationSummary summary, + ApplicationAuditReconciliationOptions options) + { + ServiceCollection services = new(); + _ = services.AddSingleton(new StubReconciler(summary)); + + await using ServiceProvider provider = services.BuildServiceProvider(validateScopes: true); + ApplicationAuditIntegrityHealthCheck healthCheck = new( + provider.GetRequiredService(), + Microsoft.Extensions.Options.Options.Create(options), + new FixedTimeProvider(new DateTimeOffset(_now))); + + return await healthCheck.CheckHealthAsync( + new HealthCheckContext(), + TestContext.Current.CancellationToken); + } + + private static ApplicationAuditReconciliationOptions CreateOptions() + { + return new ApplicationAuditReconciliationOptions + { + Enabled = true, + RunWorker = true, + Interval = TimeSpan.FromMinutes(5) + }; + } + + private static ApplicationAuditReconciliationSummary CreateSummary( + DateTime? lastRunUtc, + long criticalFindings = 0) + { + return new ApplicationAuditReconciliationSummary( + Enabled: true, + LastRunUtc: lastRunUtc, + OpenFindingCount: criticalFindings, + ErrorFindingCount: 0, + CriticalFindingCount: criticalFindings, + ManifestVerificationFailureCount: 0, + MissingCompletionCount: 0, + StaleDeliveryCount: 0, + DeadLetterCount: 0); + } + + private sealed class StubReconciler(ApplicationAuditReconciliationSummary summary) : IApplicationAuditReconciler + { + public Task ReconcileAsync(CancellationToken cancellationToken = default) + { + return Task.FromResult(summary); + } + + public Task GetSummaryAsync( + CancellationToken cancellationToken = default) + { + return Task.FromResult(summary); + } + + public Task> QueryFindingsAsync( + ApplicationAuditReconciliationQuery request, + CancellationToken cancellationToken = default) + { + throw new NotSupportedException(); + } + + public Task RecordRemediationAsync( + Guid findingId, + ApplicationAuditReconciliationRemediationRequest request, + CancellationToken cancellationToken = default) + { + throw new NotSupportedException(); + } + } + + private sealed class FixedTimeProvider(DateTimeOffset utcNow) : TimeProvider + { + public override DateTimeOffset GetUtcNow() + { + return utcNow; + } + } +} diff --git a/tests/ProjectTemplate.Web.Tests/ApplicationDatabaseHealthCheckTests.cs b/tests/ProjectTemplate.Web.Tests/ApplicationDatabaseHealthCheckTests.cs new file mode 100644 index 00000000..6d3d8e10 --- /dev/null +++ b/tests/ProjectTemplate.Web.Tests/ApplicationDatabaseHealthCheckTests.cs @@ -0,0 +1,129 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using ProjectTemplate.Web.Extensions; +using ProjectTemplate.Web.HealthChecks; + +namespace ProjectTemplate.Web.Tests; + +/// +/// Provides tests for the application database readiness check. +/// +/// +/// These tests build the service collection directly instead of using the web application factory. The data access +/// connection string is resolved when services are registered, which is before the factory's test configuration is +/// applied, so a factory-based test cannot point the application at a specific database. +/// +public sealed class ApplicationDatabaseHealthCheckTests +{ + /// + /// Verifies that readiness is unhealthy when the SQLite database file does not exist. + /// + /// A task that represents the asynchronous test operation. + [Fact] + public async Task Readiness_DatabaseUnavailable_ReportsUnhealthy() + { + string databasePath = Path.Combine(Path.GetTempPath(), $"ncat-readiness-missing-{Guid.NewGuid():N}.db"); + + HealthReport report = await CheckReadinessAsync(CreateSqliteConfiguration(databasePath)); + + Assert.Equal(HealthStatus.Unhealthy, report.Status); + HealthReportEntry entry = Assert.Single(report.Entries).Value; + Assert.Equal(ApplicationDatabaseHealthCheck.UnavailableDescription, entry.Description); + Assert.False(File.Exists(databasePath)); + } + + /// + /// Verifies that readiness is healthy when the SQLite database file exists. + /// + /// A task that represents the asynchronous test operation. + [Fact] + public async Task Readiness_DatabaseAvailable_ReportsHealthy() + { + string databasePath = Path.Combine(Path.GetTempPath(), $"ncat-readiness-{Guid.NewGuid():N}.db"); + await File.WriteAllTextAsync(databasePath, string.Empty, TestContext.Current.CancellationToken); + + try + { + HealthReport report = await CheckReadinessAsync(CreateSqliteConfiguration(databasePath)); + + Assert.Equal(HealthStatus.Healthy, report.Status); + HealthReportEntry entry = Assert.Single(report.Entries).Value; + Assert.Equal(ApplicationDatabaseHealthCheck.AvailableDescription, entry.Description); + } + finally + { + File.Delete(databasePath); + } + } + + /// + /// Verifies that the check reports healthy without contacting the database when it is disabled. + /// + /// A task that represents the asynchronous test operation. + [Fact] + public async Task Readiness_CheckDisabled_ReportsHealthyWithoutContactingDatabase() + { + string databasePath = Path.Combine(Path.GetTempPath(), $"ncat-readiness-disabled-{Guid.NewGuid():N}.db"); + + HealthReport report = await CheckReadinessAsync( + CreateSqliteConfiguration(databasePath, readinessCheckEnabled: false)); + + Assert.Equal(HealthStatus.Healthy, report.Status); + HealthReportEntry entry = Assert.Single(report.Entries).Value; + Assert.Equal(ApplicationDatabaseHealthCheck.DisabledDescription, entry.Description); + } + + /// + /// Verifies that no database readiness check is registered when data access is disabled. + /// + /// A task that represents the asynchronous test operation. + [Fact] + public async Task Readiness_DataAccessDisabled_RegistersNoDatabaseCheck() + { + IConfiguration configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["ProjectTemplate:DataAccess:Provider"] = "None" + }) + .Build(); + + HealthReport report = await CheckReadinessAsync(configuration); + + Assert.Empty(report.Entries); + Assert.Equal(HealthStatus.Healthy, report.Status); + } + + private static async Task CheckReadinessAsync(IConfiguration configuration) + { + ServiceCollection services = new(); + _ = services.AddLogging(); + + // Mirrors Program.cs: the health check service is always registered, and data access adds the database check + // only when a provider is enabled. + _ = services.AddApplicationHealthChecks(); + _ = services.AddApplicationDataAccess(configuration); + + await using ServiceProvider provider = services.BuildServiceProvider(); + + return await provider + .GetRequiredService() + .CheckHealthAsync( + registration => registration.Tags.Contains(ApplicationHealthCheckTags.Ready), + TestContext.Current.CancellationToken); + } + + private static IConfiguration CreateSqliteConfiguration(string databasePath, bool readinessCheckEnabled = true) + { + return new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["ProjectTemplate:DataAccess:Provider"] = "Sqlite", + ["ProjectTemplate:DataAccess:ConnectionStringName"] = "ApplicationDatabase", + ["ConnectionStrings:ApplicationDatabase"] = $"Data Source={databasePath};Pooling=False", + ["ProjectTemplate:HealthChecks:DatabaseReadinessCheckEnabled"] = + readinessCheckEnabled ? "true" : "false" + }) + .Build(); + } +} diff --git a/tests/ProjectTemplate.Web.Tests/AuthenticationCookieSecurePolicyTests.cs b/tests/ProjectTemplate.Web.Tests/AuthenticationCookieSecurePolicyTests.cs index 26de2bc9..43a45770 100644 --- a/tests/ProjectTemplate.Web.Tests/AuthenticationCookieSecurePolicyTests.cs +++ b/tests/ProjectTemplate.Web.Tests/AuthenticationCookieSecurePolicyTests.cs @@ -43,6 +43,41 @@ public void CookieSecurePolicy_DevelopmentOverride_UsesSameAsRequest() Assert.Equal(CookieSecurePolicy.SameAsRequest, options.Cookie.SecurePolicy); } + [Fact] + public void CookieName_AlwaysSecure_UsesHostPrefixAndRootPath() + { + using ServiceProvider serviceProvider = CreateServiceProvider( + Environments.Production, + new Dictionary()); + + CookieAuthenticationOptions options = serviceProvider + .GetRequiredService>() + .Get(CookieAuthenticationDefaults.AuthenticationScheme); + + Assert.Equal(AuthenticationServiceExtensions.HostPrefixedAuthenticationCookieName, options.Cookie.Name); + Assert.StartsWith("__Host-", options.Cookie.Name, StringComparison.Ordinal); + Assert.Equal("/", options.Cookie.Path); + Assert.Null(options.Cookie.Domain); + } + + [Fact] + public void CookieName_DevelopmentInsecureOverride_OmitsHostPrefix() + { + using ServiceProvider serviceProvider = CreateServiceProvider( + Environments.Development, + new Dictionary + { + ["ProjectTemplate:Authentication:Cookie:AllowInsecureHttp"] = "true" + }); + + CookieAuthenticationOptions options = serviceProvider + .GetRequiredService>() + .Get(CookieAuthenticationDefaults.AuthenticationScheme); + + Assert.Equal(AuthenticationServiceExtensions.AuthenticationCookieName, options.Cookie.Name); + Assert.DoesNotContain("__Host-", options.Cookie.Name, StringComparison.Ordinal); + } + [Fact] public void CookieSecurePolicy_InsecureOverrideOutsideDevelopment_FailsValidation() { diff --git a/tests/ProjectTemplate.Web.Tests/ConfigurationListBindingTests.cs b/tests/ProjectTemplate.Web.Tests/ConfigurationListBindingTests.cs new file mode 100644 index 00000000..7b6387dc --- /dev/null +++ b/tests/ProjectTemplate.Web.Tests/ConfigurationListBindingTests.cs @@ -0,0 +1,151 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using ProjectTemplate.Web.Extensions; +using ProjectTemplate.Web.Options; + +namespace ProjectTemplate.Web.Tests; + +/// +/// Provides tests for replace semantics on configured path prefix lists. +/// +public sealed class ConfigurationListBindingTests +{ + private static readonly string[] _defaultSecurityHeaderExclusions = ["/health", "/metrics"]; + + /// + /// Verifies that configured entries replace the list contents. + /// + [Fact] + public void ReplaceWithConfiguredValues_ConfiguredEntries_ReplaceDefaults() + { + List target = ["/health", "/metrics"]; + IConfiguration configuration = CreateConfiguration(new Dictionary + { + ["List:0"] = "/status" + }); + + ConfigurationListBinding.ReplaceWithConfiguredValues(target, configuration.GetSection("List")); + + Assert.Equal("/status", Assert.Single(target)); + } + + /// + /// Verifies that the defaults are kept when the section has no entries. + /// + [Fact] + public void ReplaceWithConfiguredValues_NoEntries_KeepsDefaults() + { + List target = ["/health", "/metrics"]; + IConfiguration configuration = CreateConfiguration(new Dictionary()); + + ConfigurationListBinding.ReplaceWithConfiguredValues(target, configuration.GetSection("List")); + + Assert.Equal(_defaultSecurityHeaderExclusions, target); + } + + /// + /// Verifies that a later configuration source can remove an inherited entry by overriding it with a blank value. + /// + [Fact] + public void ReplaceWithConfiguredValues_BlankOverride_RemovesInheritedEntry() + { + List target = []; + IConfiguration configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["List:0"] = "/health", + ["List:1"] = "/metrics" + }) + .AddInMemoryCollection(new Dictionary + { + ["List:1"] = string.Empty + }) + .Build(); + + ConfigurationListBinding.ReplaceWithConfiguredValues(target, configuration.GetSection("List")); + + Assert.Equal("/health", Assert.Single(target)); + } + + /// + /// Verifies that configured security header exclusions replace the code defaults instead of being appended. + /// + [Fact] + public void AddApplicationSecurityHeaders_ConfiguredExclusions_ReplaceDefaults() + { + ApplicationSecurityHeadersOptions options = GetSecurityHeadersOptions(new Dictionary + { + ["ProjectTemplate:SecurityHeaders:ExcludedPathPrefixes:0"] = "/status" + }); + + Assert.Equal("/status", Assert.Single(options.ExcludedPathPrefixes)); + } + + /// + /// Verifies that configuring the same entries as the defaults does not duplicate them. + /// + [Fact] + public void AddApplicationSecurityHeaders_ConfiguredDefaults_AreNotDuplicated() + { + ApplicationSecurityHeadersOptions options = GetSecurityHeadersOptions(new Dictionary + { + ["ProjectTemplate:SecurityHeaders:ExcludedPathPrefixes:0"] = "/health", + ["ProjectTemplate:SecurityHeaders:ExcludedPathPrefixes:1"] = "/metrics" + }); + + Assert.Equal(_defaultSecurityHeaderExclusions, options.ExcludedPathPrefixes); + } + + /// + /// Verifies that security header exclusions keep the code defaults when nothing is configured. + /// + [Fact] + public void AddApplicationSecurityHeaders_NoConfiguredExclusions_KeepsDefaults() + { + ApplicationSecurityHeadersOptions options = GetSecurityHeadersOptions(new Dictionary()); + + Assert.Equal(_defaultSecurityHeaderExclusions, options.ExcludedPathPrefixes); + } + + /// + /// Verifies that configured request logging exclusions replace the code defaults instead of being appended. + /// + [Fact] + public void AddApplicationRequestLogging_ConfiguredExclusions_ReplaceDefaults() + { + IConfiguration configuration = CreateConfiguration(new Dictionary + { + [$"{ApplicationRequestLoggingOptions.SectionName}:ExcludedPathPrefixes:0"] = "/healthz" + }); + ServiceCollection services = new(); + _ = services.AddApplicationRequestLogging(configuration); + + using ServiceProvider provider = services.BuildServiceProvider(); + ApplicationRequestLoggingOptions options = provider + .GetRequiredService>() + .Value; + + Assert.Equal("/healthz", Assert.Single(options.ExcludedPathPrefixes)); + } + + private static ApplicationSecurityHeadersOptions GetSecurityHeadersOptions( + IReadOnlyDictionary values) + { + ServiceCollection services = new(); + _ = services.AddApplicationSecurityHeaders(CreateConfiguration(values)); + + using ServiceProvider provider = services.BuildServiceProvider(); + + return provider + .GetRequiredService>() + .Value; + } + + private static IConfiguration CreateConfiguration(IReadOnlyDictionary values) + { + return new ConfigurationBuilder() + .AddInMemoryCollection(values) + .Build(); + } +} diff --git a/tests/ProjectTemplate.Web.Tests/HealthCheckTests.cs b/tests/ProjectTemplate.Web.Tests/HealthCheckTests.cs index ffac1877..d9e7d6e2 100644 --- a/tests/ProjectTemplate.Web.Tests/HealthCheckTests.cs +++ b/tests/ProjectTemplate.Web.Tests/HealthCheckTests.cs @@ -1,4 +1,8 @@ using System.Net; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using ProjectTemplate.Web.HealthChecks; using ProjectTemplate.Web.Tests.Extensions; using ProjectTemplate.Web.Tests.Infrastructure; @@ -99,4 +103,76 @@ private static ApplicationWebApplicationFactory CreateFactory() { return new ApplicationWebApplicationFactory(new Dictionary()); } + + /// + /// Verifies that the audit health endpoint is mapped and reports healthy when no audit checks are registered. + /// + /// A task that represents the asynchronous test operation. + [Fact] + public async Task HealthAuditEndpoint_WithoutAuditChecks_ReturnsHealthy() + { + using ApplicationWebApplicationFactory factory = CreateFactory(); + using HttpClient client = factory.CreateHttpsClient(); + + using HttpResponseMessage response = await client.GetAsync("/health/audit-integrity", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + string body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + + Assert.Equal("Healthy", body); + } + + /// + /// Verifies that the audit health endpoint falls under the /health security header exclusion, which keeps + /// only X-Content-Type-Options. + /// + /// A task that represents the asynchronous test operation. + [Fact] + public async Task HealthAuditEndpoint_AppliesOnlyExcludedPathSecurityHeaders() + { + using ApplicationWebApplicationFactory factory = CreateFactory(); + using HttpClient client = factory.CreateHttpsClient(); + + using HttpResponseMessage response = await client.GetAsync("/health/audit-integrity", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.True(response.Headers.Contains("X-Content-Type-Options")); + Assert.False(response.Headers.Contains("X-Frame-Options")); + Assert.False(response.Headers.Contains("Content-Security-Policy")); + Assert.False(response.Headers.Contains("Permissions-Policy")); + } + + /// + /// Verifies that each tagged endpoint runs only checks carrying its tag, so an unhealthy check affects only the + /// endpoint that selects it. + /// + /// The tag applied to a check that always reports unhealthy. + /// The expected /health/ready status code. + /// The expected /health/audit-integrity status code. + /// A task that represents the asynchronous test operation. + [Theory] + [InlineData(ApplicationHealthCheckTags.Ready, HttpStatusCode.ServiceUnavailable, HttpStatusCode.OK)] + [InlineData(ApplicationHealthCheckTags.Audit, HttpStatusCode.OK, HttpStatusCode.ServiceUnavailable)] + public async Task TaggedHealthEndpoints_RunOnlyChecksWithTheirTag( + string unhealthyCheckTag, + HttpStatusCode readyStatusCode, + HttpStatusCode auditIntegrityStatusCode) + { + using ApplicationWebApplicationFactory factory = CreateFactory(); + using WebApplicationFactory taggedFactory = factory.WithWebHostBuilder(builder => + builder.ConfigureServices(services => services + .AddHealthChecks() + .AddCheck( + "test-unhealthy", + () => HealthCheckResult.Unhealthy(), + tags: [unhealthyCheckTag]))); + using HttpClient client = taggedFactory.CreateHttpsClient(); + + using HttpResponseMessage readyResponse = await client.GetAsync("/health/ready", TestContext.Current.CancellationToken); + using HttpResponseMessage auditIntegrityResponse = await client.GetAsync("/health/audit-integrity", TestContext.Current.CancellationToken); + + Assert.Equal(readyStatusCode, readyResponse.StatusCode); + Assert.Equal(auditIntegrityStatusCode, auditIntegrityResponse.StatusCode); + } } diff --git a/tests/ProjectTemplate.Web.Tests/Infrastructure/ApplicationWebApplicationFactory.cs b/tests/ProjectTemplate.Web.Tests/Infrastructure/ApplicationWebApplicationFactory.cs index c22f2833..34a52644 100644 --- a/tests/ProjectTemplate.Web.Tests/Infrastructure/ApplicationWebApplicationFactory.cs +++ b/tests/ProjectTemplate.Web.Tests/Infrastructure/ApplicationWebApplicationFactory.cs @@ -67,9 +67,15 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) // --authProvider none produces both it and Authentication:Enabled as false, and setting it to true there // fails options validation at startup. Tests needing anonymous access opt out through // CreateAllowingAnonymousAccess, which sets false and is valid under either configuration. + // The database readiness check is off by default here because the shipped SQLite connection string points + // at a database file that tests do not create, which the check correctly reports as not reachable. + // ApplicationDatabaseHealthCheckTests covers the check against real SQLite databases outside the web host, + // because the data access connection string is resolved at service registration, before test configuration + // sources are applied. Dictionary testConfiguration = new() { - ["ProjectTemplate:ForwardedHeaders:KnownProxies:0"] = "::1" + ["ProjectTemplate:ForwardedHeaders:KnownProxies:0"] = "::1", + ["ProjectTemplate:HealthChecks:DatabaseReadinessCheckEnabled"] = "false" }; foreach ((string key, string? value) in _configurationValues) diff --git a/tests/ProjectTemplate.Web.Tests/RateLimitingTests.cs b/tests/ProjectTemplate.Web.Tests/RateLimitingTests.cs index c34197b6..e9beaa3d 100644 --- a/tests/ProjectTemplate.Web.Tests/RateLimitingTests.cs +++ b/tests/ProjectTemplate.Web.Tests/RateLimitingTests.cs @@ -87,6 +87,9 @@ public async Task NamedConcurrencyPolicy_ReturnsTooManyRequests_WhenConcurrentLi { ["ProjectTemplate:RateLimiting:Enabled"] = "true", ["ProjectTemplate:RateLimiting:UseGlobalLimiter"] = "false", + // The test server supplies no client address. Both requests must share one fallback partition, because + // the concurrency policy now partitions by client as well as by endpoint. + ["ProjectTemplate:RateLimiting:UseSharedUnknownClientPartition"] = "true", ["ProjectTemplate:RateLimiting:ConcurrencyPolicy:PermitLimit"] = "1", ["ProjectTemplate:RateLimiting:ConcurrencyPolicy:QueueLimit"] = "0" }); @@ -504,10 +507,167 @@ public void RateLimiting_ZeroWindowSeconds_FailsStartup() StringComparison.Ordinal); } + /// + /// Verifies that IPv6 clients within one /64 share a partition so rotating addresses cannot bypass the limiter. + /// + [Fact] + public void GetAddressPartitionKey_IPv6AddressesInSameSlash64_ShareOnePartition() + { + string first = RateLimitingServiceExtensions.GetAddressPartitionKey( + IPAddress.Parse("2001:db8:1:2:aaaa::1"), + 64); + string second = RateLimitingServiceExtensions.GetAddressPartitionKey( + IPAddress.Parse("2001:db8:1:2:bbbb:cccc:dddd:2"), + 64); + + Assert.Equal("2001:db8:1:2::/64", first); + Assert.Equal(first, second); + } + + /// + /// Verifies that IPv6 clients in different /64 networks receive different partitions. + /// + [Fact] + public void GetAddressPartitionKey_IPv6AddressesInDifferentSlash64_UseDifferentPartitions() + { + string first = RateLimitingServiceExtensions.GetAddressPartitionKey( + IPAddress.Parse("2001:db8:1:2::1"), + 64); + string second = RateLimitingServiceExtensions.GetAddressPartitionKey( + IPAddress.Parse("2001:db8:1:3::1"), + 64); + + Assert.NotEqual(first, second); + } + + /// + /// Verifies that a prefix length that is not a multiple of eight masks the partial byte correctly. + /// + [Fact] + public void GetAddressPartitionKey_NonByteAlignedPrefix_MasksPartialByte() + { + string partitionKey = RateLimitingServiceExtensions.GetAddressPartitionKey( + IPAddress.Parse("2001:db8:1:2f::1"), + 60); + + Assert.Equal("2001:db8:1:20::/60", partitionKey); + } + + /// + /// Verifies that a prefix length of 128 keeps the full IPv6 address. + /// + [Fact] + public void GetAddressPartitionKey_PrefixLength128_UsesFullAddress() + { + string partitionKey = RateLimitingServiceExtensions.GetAddressPartitionKey( + IPAddress.Parse("2001:db8:1:2:aaaa::1"), + 128); + + Assert.Equal("2001:db8:1:2:aaaa::1", partitionKey); + } + + /// + /// Verifies that IPv4-mapped IPv6 addresses are partitioned by IPv4 address rather than collapsed into one + /// IPv6 prefix, since a dual-stack listener reports every IPv4 client in that form. + /// + [Fact] + public void GetAddressPartitionKey_IPv4MappedAddress_UsesIPv4Address() + { + string first = RateLimitingServiceExtensions.GetAddressPartitionKey( + IPAddress.Parse("::ffff:203.0.113.10"), + 64); + string second = RateLimitingServiceExtensions.GetAddressPartitionKey( + IPAddress.Parse("::ffff:203.0.113.11"), + 64); + + Assert.Equal("203.0.113.10", first); + Assert.Equal("203.0.113.11", second); + } + + /// + /// Verifies that client partitioning applies the configured IPv6 prefix length to the remote address. + /// + [Fact] + public void GetClientPartitionKey_IPv6RemoteAddress_UsesConfiguredPrefix() + { + DefaultHttpContext httpContext = new(); + httpContext.Connection.RemoteIpAddress = IPAddress.Parse("2001:db8:1:2:aaaa::1"); + TestLogger logger = new(); + + string partitionKey = RateLimitingServiceExtensions.GetClientPartitionKey( + httpContext, + new ApplicationRateLimitingOptions(), + logger); + + Assert.Equal("2001:db8:1:2::/64", partitionKey); + Assert.Empty(logger.Entries); + } + + /// + /// Verifies that the concurrency policy partitions by endpoint and client by default. + /// + [Fact] + public void GetConcurrencyPartitionKey_Default_CombinesEndpointAndClient() + { + DefaultHttpContext httpContext = new(); + httpContext.Request.Path = "/orders"; + httpContext.Connection.RemoteIpAddress = IPAddress.Parse("203.0.113.10"); + + string partitionKey = RateLimitingServiceExtensions.GetConcurrencyPartitionKey( + httpContext, + new ApplicationRateLimitingOptions(), + new TestLogger()); + + Assert.Equal("/orders|203.0.113.10", partitionKey); + } + + /// + /// Verifies that disabling client partitioning restores one shared permit pool per endpoint. + /// + [Fact] + public void GetConcurrencyPartitionKey_PartitionByClientDisabled_UsesEndpointOnly() + { + DefaultHttpContext httpContext = new(); + httpContext.Request.Path = "/orders"; + httpContext.Connection.RemoteIpAddress = IPAddress.Parse("203.0.113.10"); + ApplicationRateLimitingOptions options = new(); + options.ConcurrencyPolicy.PartitionByClient = false; + + string partitionKey = RateLimitingServiceExtensions.GetConcurrencyPartitionKey( + httpContext, + options, + new TestLogger()); + + Assert.Equal("/orders", partitionKey); + } + + /// + /// Verifies that startup validation rejects an IPv6 partition prefix length outside 1 through 128. + /// + /// The configured prefix length. + [Theory] + [InlineData("0")] + [InlineData("129")] + public void RateLimiting_InvalidIPv6PartitionPrefixLength_FailsStartup(string prefixLength) + { + OptionsValidationException exception = + AssertRateLimitingOptionsValidationFails( + new Dictionary + { + ["ProjectTemplate:RateLimiting:Enabled"] = "true", + ["ProjectTemplate:RateLimiting:IPv6PartitionPrefixLength"] = prefixLength + }); + + Assert.Contains( + "ProjectTemplate:RateLimiting:IPv6PartitionPrefixLength must be between 1 and 128", + exception.Message, + StringComparison.Ordinal); + } + /// /// Creates a test application factory with the supplied in-memory configuration overrides. /// - /// The configuration key/value pairs used to override application settings for a test. + /// The configuration values applied to the test host. /// A configured instance. private static ApplicationWebApplicationFactory CreateFactory(IReadOnlyDictionary configurationValues) { diff --git a/tests/ProjectTemplate.Web.Tests/RequestLoggingExtensionsBranchGapTests.cs b/tests/ProjectTemplate.Web.Tests/RequestLoggingExtensionsBranchGapTests.cs index ed13979a..221ed188 100644 --- a/tests/ProjectTemplate.Web.Tests/RequestLoggingExtensionsBranchGapTests.cs +++ b/tests/ProjectTemplate.Web.Tests/RequestLoggingExtensionsBranchGapTests.cs @@ -33,9 +33,8 @@ public void AddApplicationRequestLogging_BlankCorrelationHeaderName_FailsOptions } [Theory] - [InlineData("")] [InlineData("health")] - [InlineData(" ")] + [InlineData("metrics/")] public void AddApplicationRequestLogging_InvalidExcludedPathPrefix_FailsOptionsValidation(string excludedPrefix) { ServiceCollection services = new(); @@ -58,6 +57,32 @@ public void AddApplicationRequestLogging_InvalidExcludedPathPrefix_FailsOptionsV StringComparison.Ordinal); } + // Blank entries are ignored rather than rejected, so a later configuration source can remove an inherited + // prefix by overriding its index with an empty value. See ConfigurationListBinding. + [Theory] + [InlineData("")] + [InlineData(" ")] + public void AddApplicationRequestLogging_BlankExcludedPathPrefix_IsIgnored(string excludedPrefix) + { + ServiceCollection services = new(); + IConfiguration configuration = CreateConfiguration(new Dictionary + { + [$"{ApplicationRequestLoggingOptions.SectionName}:CorrelationHeaderName"] = "X-Correlation-ID", + [$"{ApplicationRequestLoggingOptions.SectionName}:ExcludedPathPrefixes:0"] = "/health", + [$"{ApplicationRequestLoggingOptions.SectionName}:ExcludedPathPrefixes:1"] = excludedPrefix + }); + + _ = services.AddApplicationRequestLogging(configuration); + + using ServiceProvider provider = services.BuildServiceProvider(); + + ApplicationRequestLoggingOptions options = provider + .GetRequiredService>() + .Value; + + Assert.Equal("/health", Assert.Single(options.ExcludedPathPrefixes)); + } + [Fact] public void AddApplicationRequestLogging_ValidExcludedPathPrefixes_BindsOptions() {