diff --git a/src/Tests/Grand.Web.Common.Tests/Infrastructure/HealthChecks/StartupHealthCheckTests.cs b/src/Tests/Grand.Web.Common.Tests/Infrastructure/HealthChecks/StartupHealthCheckTests.cs new file mode 100644 index 000000000..0d848e7be --- /dev/null +++ b/src/Tests/Grand.Web.Common.Tests/Infrastructure/HealthChecks/StartupHealthCheckTests.cs @@ -0,0 +1,89 @@ +using System.Reflection; +using Grand.Data; +using Grand.Web.Common.Infrastructure.HealthChecks; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Hosting; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Web.Common.Tests.Infrastructure.HealthChecks; + +[TestClass] +[DoNotParallelize] +public class StartupHealthCheckTests +{ + // DataSettingsManager caches DatabaseIsInstalled() on first call and its public ResetCache() + // can only force the cached value to false, never back to true - see DataSettingsManager.cs. + // Resetting the private static instance per test keeps "installed" and "not installed" cases + // independent instead of depending on test execution order. [DoNotParallelize] on this class + // stops these tests running concurrently with each other against that same process-wide state. + private static readonly FieldInfo InstanceField = typeof(DataSettingsManager) + .GetField("_instance", BindingFlags.NonPublic | BindingFlags.Static) + ?? throw new InvalidOperationException( + "DataSettingsManager._instance field not found - has the type changed?"); + + private string _settingsPath = null!; + + [TestInitialize] + public void Setup() + { + InstanceField.SetValue(null, null); + _settingsPath = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid()}.txt"); + DataSettingsManager.Initialize(_settingsPath); + } + + [TestCleanup] + public void Cleanup() + { + InstanceField.SetValue(null, null); + if (File.Exists(_settingsPath)) + File.Delete(_settingsPath); + } + + private static Mock MockLifetime(bool started) + { + var cts = new CancellationTokenSource(); + if (started) cts.Cancel(); + + var lifetime = new Mock(); + lifetime.Setup(l => l.ApplicationStarted).Returns(cts.Token); + return lifetime; + } + + [TestMethod] + public async Task CheckHealthAsync_ApplicationNotStarted_ReturnsUnhealthy() + { + DataSettingsManager.Instance.LoadDataSettings( + new DataSettings { ConnectionString = "mongodb://localhost/test", DbProvider = DbProvider.MongoDB }); + + var check = new StartupHealthCheck(MockLifetime(started: false).Object); + + var result = await check.CheckHealthAsync(new HealthCheckContext()); + + Assert.AreEqual(HealthStatus.Unhealthy, result.Status); + } + + [TestMethod] + public async Task CheckHealthAsync_StartedButDatabaseNotConfigured_ReturnsUnhealthy() + { + // no connection string loaded - DatabaseIsInstalled() evaluates to false on first call + var check = new StartupHealthCheck(MockLifetime(started: true).Object); + + var result = await check.CheckHealthAsync(new HealthCheckContext()); + + Assert.AreEqual(HealthStatus.Unhealthy, result.Status); + } + + [TestMethod] + public async Task CheckHealthAsync_StartedAndDatabaseConfigured_ReturnsHealthy() + { + DataSettingsManager.Instance.LoadDataSettings( + new DataSettings { ConnectionString = "mongodb://localhost/test", DbProvider = DbProvider.MongoDB }); + + var check = new StartupHealthCheck(MockLifetime(started: true).Object); + + var result = await check.CheckHealthAsync(new HealthCheckContext()); + + Assert.AreEqual(HealthStatus.Healthy, result.Status); + } +} diff --git a/src/Web/Grand.Web.Common/Infrastructure/ApplicationBuilderExtensions.cs b/src/Web/Grand.Web.Common/Infrastructure/ApplicationBuilderExtensions.cs index e06e3bbf1..88ad025cb 100644 --- a/src/Web/Grand.Web.Common/Infrastructure/ApplicationBuilderExtensions.cs +++ b/src/Web/Grand.Web.Common/Infrastructure/ApplicationBuilderExtensions.cs @@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Diagnostics; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.HttpOverrides; @@ -195,7 +196,16 @@ public static void UseGrandForwardedHeaders(this WebApplication application) /// Builder for configuring an application's request pipeline public static void UseGrandHealthChecks(this WebApplication application) { - application.UseHealthChecks("/health/live"); + application.UseHealthChecks("/health/live", new HealthCheckOptions { + Predicate = check => check.Tags.Contains("live") + }); + + //intentionally does not probe MongoDB or Redis - readiness here covers only the + //application process itself. Dependency probing (DB/Redis ping) is a deliberate future + //extension, not an oversight. + application.UseHealthChecks("/health/ready", new HealthCheckOptions { + Predicate = check => check.Tags.Contains("ready") + }); } /// diff --git a/src/Web/Grand.Web.Common/Infrastructure/HealthChecks/StartupHealthCheck.cs b/src/Web/Grand.Web.Common/Infrastructure/HealthChecks/StartupHealthCheck.cs new file mode 100644 index 000000000..31f39cfda --- /dev/null +++ b/src/Web/Grand.Web.Common/Infrastructure/HealthChecks/StartupHealthCheck.cs @@ -0,0 +1,37 @@ +using Grand.Data; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Hosting; + +namespace Grand.Web.Common.Infrastructure.HealthChecks; + +/// +/// Reports whether the application has finished starting and is configured with a database +/// connection. Intentionally does not probe MongoDB or Redis - readiness here covers only the +/// application process itself. Dependency probing (DB/Redis ping) is a deliberate future +/// extension, not an oversight. +/// Note: DataSettingsManager.DatabaseIsInstalled() caches its result after the first call and +/// can only be forced to false afterward, never back to true, without a process restart - so a +/// freshly-installed instance keeps returning Unhealthy here until the app restarts post-install +/// (expected: the installer already asks for a restart once setup completes). +/// +public class StartupHealthCheck : IHealthCheck +{ + private readonly IHostApplicationLifetime _applicationLifetime; + + public StartupHealthCheck(IHostApplicationLifetime applicationLifetime) + { + ArgumentNullException.ThrowIfNull(applicationLifetime); + _applicationLifetime = applicationLifetime; + } + + public Task CheckHealthAsync(HealthCheckContext context, + CancellationToken cancellationToken = default) + { + if (!_applicationLifetime.ApplicationStarted.IsCancellationRequested) + return Task.FromResult(HealthCheckResult.Unhealthy("Application has not finished starting.")); + + return Task.FromResult(DataSettingsManager.DatabaseIsInstalled() + ? HealthCheckResult.Healthy() + : HealthCheckResult.Unhealthy("Database connection is not configured.")); + } +} diff --git a/src/Web/Grand.Web.Common/Infrastructure/ServiceCollectionExtensions.cs b/src/Web/Grand.Web.Common/Infrastructure/ServiceCollectionExtensions.cs index 13f25e5bc..ec9cda3e8 100644 --- a/src/Web/Grand.Web.Common/Infrastructure/ServiceCollectionExtensions.cs +++ b/src/Web/Grand.Web.Common/Infrastructure/ServiceCollectionExtensions.cs @@ -18,6 +18,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.Extensions.WebEncoders; +using Grand.Web.Common.Infrastructure.HealthChecks; using StackExchange.Redis; using System.Text.Encodings.Web; using System.Text.Unicode; @@ -267,7 +268,14 @@ public static void AddSettings(this IServiceCollection services) public static void AddGrandHealthChecks(this IServiceCollection services) { var hcBuilder = services.AddHealthChecks(); - hcBuilder.AddCheck("self", () => HealthCheckResult.Healthy()); + //every check registered here must carry a "live" or "ready" tag - /health/live and + // /health/ready each filter by tag, so an untagged check would silently run on neither + //liveness: process can respond to a request - never touches an external dependency + hcBuilder.AddCheck("self", () => HealthCheckResult.Healthy(), tags: ["live"]); + //readiness: application finished starting and is configured. Intentionally does not probe + //MongoDB or Redis - readiness here covers only the application process itself. Dependency + //probing (DB/Redis ping) is a deliberate future extension, not an oversight. + hcBuilder.AddCheck("startup", tags: ["ready"]); } ///