diff --git a/src/ZB.MOM.WW.MxGateway.Server/Diagnostics/AuthStoreHealthCheck.cs b/src/ZB.MOM.WW.MxGateway.Server/Diagnostics/AuthStoreHealthCheck.cs new file mode 100644 index 0000000..59f1544 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Diagnostics/AuthStoreHealthCheck.cs @@ -0,0 +1,40 @@ +using Microsoft.Data.Sqlite; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using ZB.MOM.WW.MxGateway.Server.Security.Authentication; + +namespace ZB.MOM.WW.MxGateway.Server.Diagnostics; + +/// +/// Readiness probe: verifies the SQLite authentication store is reachable. The gateway +/// authenticates every gRPC call against this store, so its reachability gates readiness. +/// +public sealed class AuthStoreHealthCheck : IHealthCheck +{ + private readonly AuthSqliteConnectionFactory _connectionFactory; + + public AuthStoreHealthCheck(AuthSqliteConnectionFactory connectionFactory) => + _connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory)); + + public async Task CheckHealthAsync( + HealthCheckContext context, + CancellationToken cancellationToken = default) + { + try + { + await using SqliteConnection connection = + await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false); + await using SqliteCommand command = connection.CreateCommand(); + command.CommandText = "SELECT 1;"; + await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false); + return HealthCheckResult.Healthy("Auth store is reachable."); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + return HealthCheckResult.Unhealthy("Auth store is unreachable.", ex); + } + } +} diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Diagnostics/AuthStoreHealthCheckTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Diagnostics/AuthStoreHealthCheckTests.cs new file mode 100644 index 0000000..3a9aa1f --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Tests/Diagnostics/AuthStoreHealthCheckTests.cs @@ -0,0 +1,49 @@ +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Options; +using ZB.MOM.WW.MxGateway.Server.Configuration; +using ZB.MOM.WW.MxGateway.Server.Diagnostics; +using ZB.MOM.WW.MxGateway.Server.Security.Authentication; + +namespace ZB.MOM.WW.MxGateway.Tests.Diagnostics; + +public sealed class AuthStoreHealthCheckTests +{ + private static AuthSqliteConnectionFactory FactoryFor(string sqlitePath) + { + // GatewayOptions.Authentication and AuthenticationOptions.SqlitePath are both + // init-only, so populate them through object initializers. + var options = new GatewayOptions + { + Authentication = new AuthenticationOptions { SqlitePath = sqlitePath }, + }; + return new AuthSqliteConnectionFactory(Options.Create(options)); + } + + [Fact] + public async Task Healthy_WhenStoreReachable() + { + var path = Path.Combine(Path.GetTempPath(), $"authcheck-{Guid.NewGuid():N}.db"); + try + { + var check = new AuthStoreHealthCheck(FactoryFor(path)); + var result = await check.CheckHealthAsync(new HealthCheckContext()); + Assert.Equal(HealthStatus.Healthy, result.Status); + } + finally { if (File.Exists(path)) File.Delete(path); } + } + + [Fact] + public async Task Unhealthy_WhenPathUnusable() + { + // A regular file used as a parent directory forces the open to fail. + var bogus = Path.Combine(Path.GetTempPath(), $"authcheck-{Guid.NewGuid():N}"); + await File.WriteAllTextAsync(bogus, "x"); + try + { + var check = new AuthStoreHealthCheck(FactoryFor(Path.Combine(bogus, "store.db"))); + var result = await check.CheckHealthAsync(new HealthCheckContext()); + Assert.Equal(HealthStatus.Unhealthy, result.Status); + } + finally { if (File.Exists(bogus)) File.Delete(bogus); } + } +}