From c7ff3a0336d8417c8122c6a6e031697991323c68 Mon Sep 17 00:00:00 2001 From: Rex Lorenzo Date: Wed, 10 Jun 2026 21:53:25 -0700 Subject: [PATCH 1/2] feat(health): add photo gallery directory health check Adds a photo-gallery check that complements disk-space-photos: it confirms the ID-card photo share is reachable and holds more than one photo, catching a mounted-but-empty or wrong share that a free-space check would pass. - Severity tracks user impact: an unreachable share is Degraded (the app still works, falling back to the default image) while a reachable but near-empty share is Unhealthy (the photo data itself is gone) - Wrapped in adaptive polling so the SMB directory scan runs hourly while healthy and every 5 min once failing, instead of on every 5-min poll - Skipped (Healthy) in Development, where the share is not mounted --- .../PhotoGalleryHealthCheckTests.cs | 122 ++++++++++++++++++ .../HealthChecks/HealthCheckExtensions.cs | 12 ++ .../HealthChecks/PhotoGalleryHealthCheck.cs | 104 +++++++++++++++ 3 files changed, 238 insertions(+) create mode 100644 test/HealthChecks/PhotoGalleryHealthCheckTests.cs create mode 100644 web/Classes/HealthChecks/PhotoGalleryHealthCheck.cs diff --git a/test/HealthChecks/PhotoGalleryHealthCheckTests.cs b/test/HealthChecks/PhotoGalleryHealthCheckTests.cs new file mode 100644 index 00000000..92bb8953 --- /dev/null +++ b/test/HealthChecks/PhotoGalleryHealthCheckTests.cs @@ -0,0 +1,122 @@ +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Viper.Classes.HealthChecks; + +namespace Viper.test.HealthChecks +{ + public sealed class PhotoGalleryHealthCheckTests : IDisposable + { + private readonly string _tempDir; + + public PhotoGalleryHealthCheckTests() + { + _tempDir = Path.Join(Path.GetTempPath(), "photo-gallery-test-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_tempDir); + } + + public void Dispose() + { + if (Directory.Exists(_tempDir)) + { + Directory.Delete(_tempDir, recursive: true); + } + } + + private void WriteFile(string name) => File.WriteAllBytes(Path.Join(_tempDir, name), Array.Empty()); + + private static HealthCheckContext CreateContext(PhotoGalleryHealthCheck sut) => new() + { + Registration = new HealthCheckRegistration("photo-gallery", sut, null, null) + }; + + private static async Task RunAsync(PhotoGalleryHealthCheck sut) => + await sut.CheckHealthAsync(CreateContext(sut), TestContext.Current.CancellationToken); + + [Fact] + public async Task CheckHealthAsync_HealthyWhenDirectoryHasPhotos() + { + WriteFile("alice.jpg"); + WriteFile("bob.jpg"); + + var result = await RunAsync(new PhotoGalleryHealthCheck(_tempDir)); + + Assert.Equal(HealthStatus.Healthy, result.Status); + Assert.Contains("Photo gallery OK", result.Description); + Assert.Equal(2, result.Data["photo_count"]); + Assert.Equal(_tempDir, result.Data["path"]); + } + + [Fact] + public async Task CheckHealthAsync_UnhealthyWhenDirectoryEmpty() + { + var result = await RunAsync(new PhotoGalleryHealthCheck(_tempDir)); + + Assert.Equal(HealthStatus.Unhealthy, result.Status); + Assert.Contains("too few photos", result.Description); + Assert.Equal(0, result.Data["photo_count"]); + } + + [Fact] + public async Task CheckHealthAsync_UnhealthyWhenOnlyOnePhoto() + { + // A lone photo (e.g. just the nopic.jpg placeholder) is not a healthy gallery. + WriteFile("lonely.jpg"); + + var result = await RunAsync(new PhotoGalleryHealthCheck(_tempDir)); + + Assert.Equal(HealthStatus.Unhealthy, result.Status); + Assert.Contains("too few photos", result.Description); + Assert.Equal(1, result.Data["photo_count"]); + } + + [Fact] + public async Task CheckHealthAsync_OnlyCountsExactJpgExtension() + { + WriteFile("alice.jpg"); + WriteFile("bob.jpg"); + // 3-char "*.jpg" pattern would otherwise match these on Windows. + WriteFile("notaphoto.jpginfo"); + WriteFile("readme.txt"); + + var result = await RunAsync(new PhotoGalleryHealthCheck(_tempDir)); + + Assert.Equal(HealthStatus.Healthy, result.Status); + Assert.Equal(2, result.Data["photo_count"]); + } + + [Fact] + public async Task CheckHealthAsync_DegradedWhenDirectoryUnreachable() + { + // Unreachable share is Degraded, not Unhealthy: the app still works, + // falling back to the default image. + var missing = Path.Join(_tempDir, "does-not-exist"); + + var result = await RunAsync(new PhotoGalleryHealthCheck(missing)); + + Assert.Equal(HealthStatus.Degraded, result.Status); + Assert.Contains("not reachable", result.Description); + } + + [Fact] + public async Task CheckHealthAsync_HealthyWhenMissingAndDirectoryMissing() + { + var missing = Path.Join(_tempDir, "does-not-exist"); + + var result = await RunAsync(new PhotoGalleryHealthCheck(missing, healthyWhenMissing: true)); + + Assert.Equal(HealthStatus.Healthy, result.Status); + Assert.Contains("skipped", result.Description); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task CheckHealthAsync_UnhealthyWhenPathNotConfigured(string? path) + { + var result = await RunAsync(new PhotoGalleryHealthCheck(path)); + + Assert.Equal(HealthStatus.Unhealthy, result.Status); + Assert.Contains("not configured", result.Description); + } + } +} diff --git a/web/Classes/HealthChecks/HealthCheckExtensions.cs b/web/Classes/HealthChecks/HealthCheckExtensions.cs index 65cd7ac7..9a1409bb 100644 --- a/web/Classes/HealthChecks/HealthCheckExtensions.cs +++ b/web/Classes/HealthChecks/HealthCheckExtensions.cs @@ -72,6 +72,18 @@ public static IServiceCollection AddViperHealthChecks( tags: new[] { "ready" }); } + // Content check distinct from the disk-space probe above. A reachable + // directory with more than one photo is Healthy; an unreachable one is + // Degraded; a reachable but near-empty share is Unhealthy. Registered + // unconditionally so a missing/blank IDCardPhotoPath surfaces as + // Unhealthy rather than silently dropping the check. + builder.AddCheck( + "photo-gallery", + WithAdaptivePolling(new PhotoGalleryHealthCheck( + photoPath, + healthyWhenMissing: environment.IsDevelopment())), + tags: new[] { "ready" }); + // CMS files drive. Same pattern as photos - the drive (S:\) is a network // share unmounted on developer machines, so skip in dev. Path mirrors // Areas/CMS/Data/CMS.GetRootFileFolder(). diff --git a/web/Classes/HealthChecks/PhotoGalleryHealthCheck.cs b/web/Classes/HealthChecks/PhotoGalleryHealthCheck.cs new file mode 100644 index 00000000..720f0dbc --- /dev/null +++ b/web/Classes/HealthChecks/PhotoGalleryHealthCheck.cs @@ -0,0 +1,104 @@ +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace Viper.Classes.HealthChecks +{ + /// + /// Verifies the ID-card photo directory is reachable and holds more than one + /// photo. Complements disk-space-photos: that check only reports free space on + /// the hosting drive and would happily pass an empty or wrong-but-mounted share + /// (failed sync, remounted blank, misconfigured path). This check catches the + /// "drive is fine but there are no photos" failure that free space misses. + /// Severity reflects user impact: an unreachable directory is Degraded (the app + /// still works, falling back to the default image), while a reachable but + /// near-empty directory is Unhealthy (the photo data itself is gone). + /// Photos are stored flat as "<mailId>.jpg" (see PhotoService), so a + /// top-level *.jpg scan is sufficient. + /// + public class PhotoGalleryHealthCheck : IHealthCheck + { + private readonly string? _photoPath; + private readonly bool _healthyWhenMissing; + + // A healthy gallery holds real student photos, not just the placeholder + // (nopic.jpg). Requiring more than one rejects a share that is empty or + // holds only the default image. + private const int MinimumPhotoCount = 2; + + /// + /// Creates a check for the given ID-card photo directory. + /// + /// + /// Directory holding the ID-card photos (PhotoGallery:IDCardPhotoPath). + /// + /// + /// If true, a missing directory returns Healthy with a "skipped" + /// description rather than Unhealthy. Use in Development, where the photo + /// share is a network path not mounted on developer machines. + /// + public PhotoGalleryHealthCheck(string? photoPath, bool healthyWhenMissing = false) + { + _photoPath = photoPath; + _healthyWhenMissing = healthyWhenMissing; + } + + public Task CheckHealthAsync( + HealthCheckContext context, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(_photoPath)) + { + return Task.FromResult(HealthCheckResult.Unhealthy( + "Photo path is not configured.")); + } + + if (!Directory.Exists(_photoPath)) + { + return Task.FromResult(_healthyWhenMissing + ? HealthCheckResult.Healthy($"Photo directory '{_photoPath}' not mounted (skipped).") + : HealthCheckResult.Degraded($"Photo directory '{_photoPath}' is not reachable; serving the default image.")); + } + + int photoCount; + try + { + // MatchType.Simple avoids the Win32 quirk where "*.jpg" also matches + // longer extensions (e.g. ".jpginfo"); CaseInsensitive keeps ".JPG" + // counted regardless of the platform default. + var options = new EnumerationOptions + { + MatchType = MatchType.Simple, + MatchCasing = MatchCasing.CaseInsensitive, + }; + photoCount = Directory.EnumerateFiles(_photoPath, "*.jpg", options).Count(); + } + catch (UnauthorizedAccessException) + { + // Can't read the share, so we can't judge the photo count - treat + // as unreachable (Degraded), not empty (Unhealthy). + return Task.FromResult(HealthCheckResult.Degraded( + $"Photo directory '{_photoPath}' not readable: access denied.")); + } + catch (IOException ex) + { + return Task.FromResult(HealthCheckResult.Degraded( + $"Photo directory '{_photoPath}' not readable: {ex.Message}")); + } + + var data = new Dictionary + { + ["path"] = _photoPath, + ["photo_count"] = photoCount, + }; + + if (photoCount < MinimumPhotoCount) + { + return Task.FromResult(HealthCheckResult.Unhealthy( + $"Photo directory '{_photoPath}' has too few photos: {photoCount} (expected at least {MinimumPhotoCount}).", + data: data)); + } + + return Task.FromResult(HealthCheckResult.Healthy( + $"Photo gallery OK: {photoCount} photos.", data: data)); + } + } +} From c4782f728f5f4bd7630d1921fb60427734537c5e Mon Sep 17 00:00:00 2001 From: Rex Lorenzo Date: Thu, 11 Jun 2026 09:33:37 -0700 Subject: [PATCH 2/2] refactor(health): remove obsolete disk-space-photos health check --- .../HealthChecks/HealthCheckExtensions.cs | 20 +++---------------- .../HealthChecks/PhotoGalleryHealthCheck.cs | 5 +---- 2 files changed, 4 insertions(+), 21 deletions(-) diff --git a/web/Classes/HealthChecks/HealthCheckExtensions.cs b/web/Classes/HealthChecks/HealthCheckExtensions.cs index 9a1409bb..4a41bd02 100644 --- a/web/Classes/HealthChecks/HealthCheckExtensions.cs +++ b/web/Classes/HealthChecks/HealthCheckExtensions.cs @@ -57,25 +57,11 @@ public static IServiceCollection AddViperHealthChecks( .AddDbContextCheck("db-viper", tags: new[] { "ready" }) .AddCheck("disk-space-app", new DiskSpaceHealthCheck(), tags: new[] { "ready" }); - // Photo gallery drive. Always registered so operators can see the check - // exists; in Development the drive is a network share not mounted locally, - // so healthyWhenMissing=true treats "drive absent" as a pass (with a - // "skipped" description) rather than a permanent Unhealthy in dev. var photoPath = configuration["PhotoGallery:IDCardPhotoPath"]; - if (!string.IsNullOrWhiteSpace(photoPath)) - { - builder.AddCheck( - "disk-space-photos", - new DiskSpaceHealthCheck( - explicitDrivePath: photoPath, - healthyWhenMissing: environment.IsDevelopment()), - tags: new[] { "ready" }); - } - // Content check distinct from the disk-space probe above. A reachable - // directory with more than one photo is Healthy; an unreachable one is - // Degraded; a reachable but near-empty share is Unhealthy. Registered - // unconditionally so a missing/blank IDCardPhotoPath surfaces as + // Content check. A reachable directory with more than one photo is Healthy; + // an unreachable one is Degraded; a reachable but near-empty share is Unhealthy. + // Registered unconditionally so a missing/blank IDCardPhotoPath surfaces as // Unhealthy rather than silently dropping the check. builder.AddCheck( "photo-gallery", diff --git a/web/Classes/HealthChecks/PhotoGalleryHealthCheck.cs b/web/Classes/HealthChecks/PhotoGalleryHealthCheck.cs index 720f0dbc..b640eead 100644 --- a/web/Classes/HealthChecks/PhotoGalleryHealthCheck.cs +++ b/web/Classes/HealthChecks/PhotoGalleryHealthCheck.cs @@ -4,10 +4,7 @@ namespace Viper.Classes.HealthChecks { /// /// Verifies the ID-card photo directory is reachable and holds more than one - /// photo. Complements disk-space-photos: that check only reports free space on - /// the hosting drive and would happily pass an empty or wrong-but-mounted share - /// (failed sync, remounted blank, misconfigured path). This check catches the - /// "drive is fine but there are no photos" failure that free space misses. + /// photo. /// Severity reflects user impact: an unreachable directory is Degraded (the app /// still works, falling back to the default image), while a reachable but /// near-empty directory is Unhealthy (the photo data itself is gone).