Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 122 additions & 0 deletions test/HealthChecks/PhotoGalleryHealthCheckTests.cs
Original file line number Diff line number Diff line change
@@ -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<byte>());

private static HealthCheckContext CreateContext(PhotoGalleryHealthCheck sut) => new()
{
Registration = new HealthCheckRegistration("photo-gallery", sut, null, null)
};

private static async Task<HealthCheckResult> 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);
}
}
}
12 changes: 12 additions & 0 deletions web/Classes/HealthChecks/HealthCheckExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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().
Expand Down
104 changes: 104 additions & 0 deletions web/Classes/HealthChecks/PhotoGalleryHealthCheck.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
using Microsoft.Extensions.Diagnostics.HealthChecks;

namespace Viper.Classes.HealthChecks
{
/// <summary>
/// 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 "&lt;mailId&gt;.jpg" (see PhotoService), so a
/// top-level *.jpg scan is sufficient.
/// </summary>
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;

/// <summary>
/// Creates a check for the given ID-card photo directory.
/// </summary>
/// <param name="photoPath">
/// Directory holding the ID-card photos (PhotoGallery:IDCardPhotoPath).
/// </param>
/// <param name="healthyWhenMissing">
/// 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.
/// </param>
public PhotoGalleryHealthCheck(string? photoPath, bool healthyWhenMissing = false)
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
{
_photoPath = photoPath;
_healthyWhenMissing = healthyWhenMissing;
}

public Task<HealthCheckResult> 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();
}
Comment thread
rlorenzo marked this conversation as resolved.
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<string, object>
{
["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));
Comment thread
rlorenzo marked this conversation as resolved.
}
}
}
Loading