diff --git a/src/DockerUpdateGuard/Components/Pages/Dashboard.razor b/src/DockerUpdateGuard/Components/Pages/Dashboard.razor
index 8e53d8b..990e808 100644
--- a/src/DockerUpdateGuard/Components/Pages/Dashboard.razor
+++ b/src/DockerUpdateGuard/Components/Pages/Dashboard.razor
@@ -118,6 +118,25 @@ else
+ @if (_dashboard.VulnerabilityConfigurationHint == VulnerabilityConfigurationHintKind.ScanningDisabled)
+ {
+
+ Vulnerability scanning is disabled. Set DockerUpdateGuard:Vulnerabilities:Enabled to true and configure a provider (Trivy or DockerScout) to see security findings.
+
+ }
+ else if (_dashboard.VulnerabilityConfigurationHint == VulnerabilityConfigurationHintKind.TrivyBaseUrlMissing)
+ {
+
+ The Trivy provider reported that it is not configured. Set DockerUpdateGuard:Vulnerabilities:TrivyBaseUrl to the address of your Trivy server.
+
+ }
+ else if (_dashboard.VulnerabilityConfigurationHint == VulnerabilityConfigurationHintKind.DockerHubCredentialsMissing)
+ {
+
+ The Docker Scout provider reported that it is not configured. Set DockerUpdateGuard:DockerHub:UserName and DockerUpdateGuard:DockerHub:Pat to valid Docker Hub credentials.
+
+ }
+
diff --git a/src/DockerUpdateGuard/Components/Pages/ScanHistory.razor b/src/DockerUpdateGuard/Components/Pages/ScanHistory.razor
index 4267fdf..de52ec8 100644
--- a/src/DockerUpdateGuard/Components/Pages/ScanHistory.razor
+++ b/src/DockerUpdateGuard/Components/Pages/ScanHistory.razor
@@ -11,23 +11,29 @@
@if (VulnerabilitiesEnabled)
{
-
- @if (_isRefreshingVulnerabilities)
+
+
+ @if (_isRefreshingVulnerabilities)
+ {
+
+ Refreshing vulnerabilities...
+ }
+ else
+ {
+ Refresh vulnerabilities
+ }
+
+ @if (_schedule?.VulnerabilityScanningEnabled == true)
{
-
- Refreshing vulnerabilities...
+ Next scheduled vulnerability refresh: @FormatNextVulnerabilityRefresh(_schedule)
}
- else
- {
- Refresh vulnerabilities
- }
-
+
}
diff --git a/src/DockerUpdateGuard/Components/Pages/ScanHistory.razor.cs b/src/DockerUpdateGuard/Components/Pages/ScanHistory.razor.cs
index 5c7896b..6f8a409 100644
--- a/src/DockerUpdateGuard/Components/Pages/ScanHistory.razor.cs
+++ b/src/DockerUpdateGuard/Components/Pages/ScanHistory.razor.cs
@@ -22,6 +22,11 @@ public sealed partial class ScanHistory : IDisposable
///
private const string ScanHistoryStateKey = "ScanHistory.List";
+ ///
+ /// Persistent-state key for the prerendered vulnerability refresh schedule
+ ///
+ private const string ScheduleStateKey = "ScanHistory.Schedule";
+
#endregion // Constants
#region Fields
@@ -36,6 +41,11 @@ public sealed partial class ScanHistory : IDisposable
///
private IReadOnlyList? _scans;
+ ///
+ /// Estimated vulnerability refresh schedule
+ ///
+ private VulnerabilityScheduleViewData? _schedule;
+
///
/// Current error message
///
@@ -106,6 +116,18 @@ private static Color GetScanStatusColor(string status)
};
}
+ ///
+ /// Format the estimated next scheduled vulnerability refresh
+ ///
+ /// Vulnerability refresh schedule
+ /// Formatted schedule value
+ private static string FormatNextVulnerabilityRefresh(VulnerabilityScheduleViewData schedule)
+ {
+ return schedule.NextVulnerabilityRefreshUtc is null
+ ? "shortly (no run recorded yet)"
+ : schedule.NextVulnerabilityRefreshUtc.GetValueOrDefault().ToLocalTime().ToString("g");
+ }
+
#endregion // Static methods
#region Methods
@@ -118,10 +140,13 @@ private async Task LoadAsync()
{
var scans = await ViewService.GetScanHistoryAsync()
.ConfigureAwait(false);
+ var schedule = await ViewService.GetVulnerabilityScheduleAsync()
+ .ConfigureAwait(false);
await InvokeAsync(() =>
{
_scans = scans;
+ _schedule = schedule;
StateHasChanged();
}).ConfigureAwait(false);
@@ -138,6 +163,11 @@ private Task PersistScanHistory()
PersistentState.PersistAsJson(ScanHistoryStateKey, _scans);
}
+ if (_schedule is not null)
+ {
+ PersistentState.PersistAsJson(ScheduleStateKey, _schedule);
+ }
+
return Task.CompletedTask;
}
@@ -196,6 +226,12 @@ protected override async Task OnInitializedAsync()
{
_persistingSubscription = PersistentState.RegisterOnPersisting(PersistScanHistory);
+ if (PersistentState.TryTakeFromJson(ScheduleStateKey, out var restoredSchedule)
+ && restoredSchedule is not null)
+ {
+ _schedule = restoredSchedule;
+ }
+
if (PersistentState.TryTakeFromJson>(ScanHistoryStateKey, out var restoredScans)
&& restoredScans is not null)
{
diff --git a/src/DockerUpdateGuard/UI/ApplicationViewService.cs b/src/DockerUpdateGuard/UI/ApplicationViewService.cs
index 64d3dbf..e93426e 100644
--- a/src/DockerUpdateGuard/UI/ApplicationViewService.cs
+++ b/src/DockerUpdateGuard/UI/ApplicationViewService.cs
@@ -1,3 +1,4 @@
+using DockerUpdateGuard.Configuration;
using DockerUpdateGuard.Data;
using DockerUpdateGuard.Data.Entities;
using DockerUpdateGuard.Data.Queries;
@@ -6,6 +7,7 @@
using DockerUpdateGuard.Images.Interfaces;
using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Options;
namespace DockerUpdateGuard.UI;
@@ -46,6 +48,11 @@ public sealed class ApplicationViewService : IApplicationViewService, IDisposabl
///
private readonly IImageReferenceParser _imageReferenceParser;
+ ///
+ /// Application options monitor
+ ///
+ private readonly IOptionsMonitor _optionsMonitor;
+
///
/// Shared-base-image query service
///
@@ -60,13 +67,16 @@ public sealed class ApplicationViewService : IApplicationViewService, IDisposabl
///
/// Database context
/// Image reference parser
+ /// Application options monitor
/// Shared base image query service
public ApplicationViewService(DockerUpdateGuardDbContext dbContext,
IImageReferenceParser imageReferenceParser,
+ IOptionsMonitor optionsMonitor,
ISharedBaseImageQueryService sharedBaseImageQueryService)
{
_dbContext = dbContext;
_imageReferenceParser = imageReferenceParser;
+ _optionsMonitor = optionsMonitor;
_sharedBaseImageQueryService = sharedBaseImageQueryService;
}
@@ -956,6 +966,71 @@ private async Task> GetScanHistoryCoreAsync(i
.ToList();
}
+ ///
+ /// Resolve the vulnerability configuration hint without re-entering the service gate
+ ///
+ /// Cancellation token
+ /// Configuration hint kind
+ private async Task GetVulnerabilityConfigurationHintCoreAsync(CancellationToken cancellationToken)
+ {
+ var vulnerabilityOptions = _optionsMonitor.CurrentValue.Vulnerabilities;
+
+ if (vulnerabilityOptions.Enabled == false)
+ {
+ return VulnerabilityConfigurationHintKind.ScanningDisabled;
+ }
+
+ var latestRun = await _dbContext.ScanRuns.Where(entity => entity.Type == ScanRunType.Vulnerability
+ && entity.CompletedAtUtc != null)
+ .OrderByDescending(entity => entity.StartedAtUtc)
+ .Select(entity => new
+ {
+ entity.Status,
+ entity.ErrorMessage,
+ })
+ .FirstOrDefaultAsync(cancellationToken)
+ .ConfigureAwait(false);
+
+ return VulnerabilityConfigurationHintResolver.Resolve(vulnerabilityOptions, latestRun?.Status, latestRun?.ErrorMessage);
+ }
+
+ ///
+ /// Estimate the next scheduled vulnerability refresh without re-entering the service gate
+ ///
+ /// Cancellation token
+ /// Vulnerability schedule view data
+ private async Task GetVulnerabilityScheduleCoreAsync(CancellationToken cancellationToken)
+ {
+ var options = _optionsMonitor.CurrentValue;
+
+ if (options.Vulnerabilities.Enabled == false)
+ {
+ return new VulnerabilityScheduleViewData();
+ }
+
+ var lastStartedAtUtc = await _dbContext.ScanRuns.Where(entity => entity.Type == ScanRunType.Vulnerability)
+ .OrderByDescending(entity => entity.StartedAtUtc)
+ .Select(entity => (DateTimeOffset?)entity.StartedAtUtc)
+ .FirstOrDefaultAsync(cancellationToken)
+ .ConfigureAwait(false);
+
+ if (lastStartedAtUtc is null)
+ {
+ return new VulnerabilityScheduleViewData
+ {
+ VulnerabilityScanningEnabled = true,
+ };
+ }
+
+ var refreshInterval = TimeSpan.FromMinutes(options.Scanning.VulnerabilityRefreshIntervalMinutes);
+
+ return new VulnerabilityScheduleViewData
+ {
+ VulnerabilityScanningEnabled = true,
+ NextVulnerabilityRefreshUtc = lastStartedAtUtc.GetValueOrDefault() + refreshInterval,
+ };
+ }
+
///
/// Map a linked runtime container projection
///
@@ -1698,6 +1773,7 @@ public async Task GetDashboardAsync(CancellationToken cancell
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
var vulnerabilitySeveritySummary = CreateSeveritySummary(activeVulnerabilitySeverityCounts.Select(entity => new KeyValuePair(entity.Severity, entity.ActiveFindingCount)));
+ var vulnerabilityConfigurationHint = await GetVulnerabilityConfigurationHintCoreAsync(cancellationToken).ConfigureAwait(false);
return new DashboardViewData
{
@@ -1710,6 +1786,8 @@ public async Task GetDashboardAsync(CancellationToken cancell
OwnImageBaseRuntimeWarningCount = ownImageBaseRuntimeWarningCount,
ActiveVulnerabilityFindingCount = vulnerabilitySeveritySummary.TotalCount,
VulnerabilitySeveritySummary = vulnerabilitySeveritySummary,
+ VulnerabilityScanningEnabled = _optionsMonitor.CurrentValue.Vulnerabilities.Enabled,
+ VulnerabilityConfigurationHint = vulnerabilityConfigurationHint,
RecentScans = recentScans,
};
},
@@ -2097,6 +2175,12 @@ public async Task> GetScanHistoryAsync(int ta
return await ExecuteSerializedAsync(() => GetScanHistoryCoreAsync(take, cancellationToken), cancellationToken).ConfigureAwait(false);
}
+ ///
+ public async Task GetVulnerabilityScheduleAsync(CancellationToken cancellationToken = default)
+ {
+ return await ExecuteSerializedAsync(() => GetVulnerabilityScheduleCoreAsync(cancellationToken), cancellationToken).ConfigureAwait(false);
+ }
+
///
public async Task> GetVulnerabilityOverviewAsync(CancellationToken cancellationToken = default)
{
diff --git a/src/DockerUpdateGuard/UI/DashboardViewData.cs b/src/DockerUpdateGuard/UI/DashboardViewData.cs
index 0e7e55a..42ee118 100644
--- a/src/DockerUpdateGuard/UI/DashboardViewData.cs
+++ b/src/DockerUpdateGuard/UI/DashboardViewData.cs
@@ -52,6 +52,16 @@ public class DashboardViewData
///
public VulnerabilitySeveritySummaryViewData VulnerabilitySeveritySummary { get; set; } = new();
+ ///
+ /// Indicates whether vulnerability scanning is enabled
+ ///
+ public bool VulnerabilityScanningEnabled { get; set; }
+
+ ///
+ /// Configuration hint that should be surfaced for the vulnerability setup
+ ///
+ public VulnerabilityConfigurationHintKind VulnerabilityConfigurationHint { get; set; }
+
///
/// Recent scan list
///
diff --git a/src/DockerUpdateGuard/UI/IApplicationViewService.cs b/src/DockerUpdateGuard/UI/IApplicationViewService.cs
index b0fe4ab..1989c07 100644
--- a/src/DockerUpdateGuard/UI/IApplicationViewService.cs
+++ b/src/DockerUpdateGuard/UI/IApplicationViewService.cs
@@ -103,6 +103,13 @@ public interface IApplicationViewService
/// Scan history entries
Task> GetScanHistoryAsync(int take = 20, CancellationToken cancellationToken = default);
+ ///
+ /// Read the estimated vulnerability refresh schedule for the scan-history page
+ ///
+ /// Cancellation token
+ /// Vulnerability schedule view data
+ Task GetVulnerabilityScheduleAsync(CancellationToken cancellationToken = default);
+
///
/// Read the fleet-wide vulnerability overview grouped by advisory
///
diff --git a/src/DockerUpdateGuard/UI/VulnerabilityConfigurationHintKind.cs b/src/DockerUpdateGuard/UI/VulnerabilityConfigurationHintKind.cs
new file mode 100644
index 0000000..d3291fa
--- /dev/null
+++ b/src/DockerUpdateGuard/UI/VulnerabilityConfigurationHintKind.cs
@@ -0,0 +1,27 @@
+namespace DockerUpdateGuard.UI;
+
+///
+/// Configuration hint surfaced when vulnerability scanning cannot produce findings
+///
+public enum VulnerabilityConfigurationHintKind
+{
+ ///
+ /// Nothing to report
+ ///
+ None = 0,
+
+ ///
+ /// Vulnerability scanning is switched off
+ ///
+ ScanningDisabled = 1,
+
+ ///
+ /// The Trivy provider is selected but its base URL is missing
+ ///
+ TrivyBaseUrlMissing = 2,
+
+ ///
+ /// The Docker Scout provider is selected but the Docker Hub credentials are missing
+ ///
+ DockerHubCredentialsMissing = 3
+}
\ No newline at end of file
diff --git a/src/DockerUpdateGuard/UI/VulnerabilityConfigurationHintResolver.cs b/src/DockerUpdateGuard/UI/VulnerabilityConfigurationHintResolver.cs
new file mode 100644
index 0000000..0b4f55b
--- /dev/null
+++ b/src/DockerUpdateGuard/UI/VulnerabilityConfigurationHintResolver.cs
@@ -0,0 +1,56 @@
+using DockerUpdateGuard.Configuration;
+using DockerUpdateGuard.Data.Entities;
+using DockerUpdateGuard.Vulnerabilities;
+
+namespace DockerUpdateGuard.UI;
+
+///
+/// Resolves the vulnerability configuration hint shown on the dashboard
+///
+public static class VulnerabilityConfigurationHintResolver
+{
+ #region Static methods
+
+ ///
+ /// Resolve the configuration hint for the current vulnerability configuration and the latest completed vulnerability scan run
+ ///
+ /// Vulnerability configuration
+ /// Status of the latest completed vulnerability scan run, or null when no run was recorded
+ /// Error message of the latest completed vulnerability scan run
+ /// Configuration hint kind
+ public static VulnerabilityConfigurationHintKind Resolve(VulnerabilityOptions vulnerabilityOptions,
+ ScanRunStatus? latestRunStatus,
+ string? latestRunErrorMessage)
+ {
+ ArgumentNullException.ThrowIfNull(vulnerabilityOptions);
+
+ if (vulnerabilityOptions.Enabled == false)
+ {
+ return VulnerabilityConfigurationHintKind.ScanningDisabled;
+ }
+
+ var reportsFailure = latestRunStatus is ScanRunStatus.Partial or ScanRunStatus.Failed;
+
+ if (reportsFailure == false
+ || string.IsNullOrWhiteSpace(latestRunErrorMessage))
+ {
+ return VulnerabilityConfigurationHintKind.None;
+ }
+
+ if (vulnerabilityOptions.Provider == VulnerabilityProviderKind.Trivy
+ && latestRunErrorMessage.Contains(VulnerabilityProviderMessages.TrivyBaseUrlNotConfigured, StringComparison.OrdinalIgnoreCase))
+ {
+ return VulnerabilityConfigurationHintKind.TrivyBaseUrlMissing;
+ }
+
+ if (vulnerabilityOptions.Provider == VulnerabilityProviderKind.DockerScout
+ && latestRunErrorMessage.Contains(VulnerabilityProviderMessages.DockerScoutCredentialsNotConfigured, StringComparison.OrdinalIgnoreCase))
+ {
+ return VulnerabilityConfigurationHintKind.DockerHubCredentialsMissing;
+ }
+
+ return VulnerabilityConfigurationHintKind.None;
+ }
+
+ #endregion // Static methods
+}
\ No newline at end of file
diff --git a/src/DockerUpdateGuard/UI/VulnerabilityScheduleViewData.cs b/src/DockerUpdateGuard/UI/VulnerabilityScheduleViewData.cs
new file mode 100644
index 0000000..5c01202
--- /dev/null
+++ b/src/DockerUpdateGuard/UI/VulnerabilityScheduleViewData.cs
@@ -0,0 +1,21 @@
+namespace DockerUpdateGuard.UI;
+
+///
+/// Vulnerability refresh schedule view data
+///
+public class VulnerabilityScheduleViewData
+{
+ #region Properties
+
+ ///
+ /// Indicates whether vulnerability scanning is enabled
+ ///
+ public bool VulnerabilityScanningEnabled { get; set; }
+
+ ///
+ /// Estimated timestamp of the next scheduled vulnerability refresh, or null when no run was recorded yet
+ ///
+ public DateTimeOffset? NextVulnerabilityRefreshUtc { get; set; }
+
+ #endregion // Properties
+}
\ No newline at end of file
diff --git a/src/DockerUpdateGuard/Vulnerabilities/DockerScoutVulnerabilityProvider.cs b/src/DockerUpdateGuard/Vulnerabilities/DockerScoutVulnerabilityProvider.cs
index 8da1907..64f9f95 100644
--- a/src/DockerUpdateGuard/Vulnerabilities/DockerScoutVulnerabilityProvider.cs
+++ b/src/DockerUpdateGuard/Vulnerabilities/DockerScoutVulnerabilityProvider.cs
@@ -385,7 +385,7 @@ public async Task>.NotConfigured("Docker Hub credentials are required for Docker Scout");
+ return ExternalOperationResult>.NotConfigured(VulnerabilityProviderMessages.DockerScoutCredentialsNotConfigured);
}
if (string.Equals(imageReference.Registry, "docker.io", StringComparison.OrdinalIgnoreCase) == false)
diff --git a/src/DockerUpdateGuard/Vulnerabilities/TrivyVulnerabilityProvider.cs b/src/DockerUpdateGuard/Vulnerabilities/TrivyVulnerabilityProvider.cs
index 7d03bfc..7d1f2cc 100644
--- a/src/DockerUpdateGuard/Vulnerabilities/TrivyVulnerabilityProvider.cs
+++ b/src/DockerUpdateGuard/Vulnerabilities/TrivyVulnerabilityProvider.cs
@@ -270,7 +270,7 @@ public async Task>.NotConfigured("TrivyBaseUrl is not configured");
+ return ExternalOperationResult>.NotConfigured(VulnerabilityProviderMessages.TrivyBaseUrlNotConfigured);
}
try
diff --git a/src/DockerUpdateGuard/Vulnerabilities/VulnerabilityProviderMessages.cs b/src/DockerUpdateGuard/Vulnerabilities/VulnerabilityProviderMessages.cs
new file mode 100644
index 0000000..084b89b
--- /dev/null
+++ b/src/DockerUpdateGuard/Vulnerabilities/VulnerabilityProviderMessages.cs
@@ -0,0 +1,22 @@
+namespace DockerUpdateGuard.Vulnerabilities;
+
+///
+/// Status messages reported by the vulnerability providers when a required setting is missing,
+/// shared so the UI can recognize them in a persisted scan run
+///
+public static class VulnerabilityProviderMessages
+{
+ #region Const fields
+
+ ///
+ /// Message reported when the Trivy provider is selected without a configured base URL
+ ///
+ public const string TrivyBaseUrlNotConfigured = "TrivyBaseUrl is not configured";
+
+ ///
+ /// Message reported when the Docker Scout provider is selected without Docker Hub credentials
+ ///
+ public const string DockerScoutCredentialsNotConfigured = "Docker Hub credentials are required for Docker Scout";
+
+ #endregion // Const fields
+}
\ No newline at end of file
diff --git a/src/Tests/DockerUpdateGuard.Tests/ApplicationViewServiceTests.cs b/src/Tests/DockerUpdateGuard.Tests/ApplicationViewServiceTests.cs
index 32478f2..9a76361 100644
--- a/src/Tests/DockerUpdateGuard.Tests/ApplicationViewServiceTests.cs
+++ b/src/Tests/DockerUpdateGuard.Tests/ApplicationViewServiceTests.cs
@@ -1,9 +1,12 @@
+using DockerUpdateGuard.Configuration;
using DockerUpdateGuard.Data;
using DockerUpdateGuard.Data.Entities;
using DockerUpdateGuard.Data.Queries;
using DockerUpdateGuard.Data.Repositories;
using DockerUpdateGuard.Images;
+using DockerUpdateGuard.Tests.Data;
using DockerUpdateGuard.UI;
+using DockerUpdateGuard.Vulnerabilities;
using Microsoft.EntityFrameworkCore;
@@ -33,6 +36,130 @@ public class ApplicationViewServiceTests
#endregion // Properties
+ #region Static methods
+
+ ///
+ /// Create an options monitor with the default application options
+ ///
+ /// Options monitor
+ private static TestOptionsMonitor CreateOptionsMonitor()
+ {
+ return new TestOptionsMonitor(new DockerUpdateGuardOptions());
+ }
+
+ ///
+ /// Create an options monitor with an explicit vulnerability configuration
+ ///
+ /// Whether vulnerability scanning is enabled
+ /// Configured vulnerability provider
+ /// Vulnerability refresh interval in minutes
+ /// Options monitor
+ private static TestOptionsMonitor CreateOptionsMonitor(bool enabled,
+ VulnerabilityProviderKind provider,
+ int refreshIntervalMinutes = 180)
+ {
+ return new TestOptionsMonitor(new DockerUpdateGuardOptions
+ {
+ Vulnerabilities = new VulnerabilityOptions
+ {
+ Enabled = enabled,
+ Provider = provider,
+ },
+ Scanning = new ScanningOptions
+ {
+ VulnerabilityRefreshIntervalMinutes = refreshIntervalMinutes,
+ },
+ });
+ }
+
+ ///
+ /// Create a completed vulnerability scan run
+ ///
+ /// Scan run status
+ /// Scan run error message
+ /// Scan run start timestamp
+ /// Vulnerability scan run
+ private static ScanRun CreateVulnerabilityScanRun(ScanRunStatus status, string? errorMessage, DateTimeOffset startedAtUtc)
+ {
+ return new ScanRun
+ {
+ Type = ScanRunType.Vulnerability,
+ Status = status,
+ TriggerSource = ScanTriggerSource.Scheduled,
+ StartedAtUtc = startedAtUtc,
+ CompletedAtUtc = startedAtUtc.AddMinutes(1),
+ ErrorMessage = errorMessage,
+ };
+ }
+
+ ///
+ /// Read the dashboard for a vulnerability configuration and an optionally seeded vulnerability scan run
+ ///
+ /// Options monitor
+ /// Vulnerability scan run to seed, or null when no run exists
+ /// Dashboard view data
+ private static async Task ReadDashboardAsync(TestOptionsMonitor optionsMonitor, ScanRun? scanRun)
+ {
+ var options = new DbContextOptionsBuilder().UseInMemoryDatabase(Guid.NewGuid().ToString())
+ .Options;
+
+ var dbContext = new DockerUpdateGuardDbContext(options);
+
+ await using (dbContext.ConfigureAwait(false))
+ {
+ if (scanRun is not null)
+ {
+ dbContext.ScanRuns.Add(scanRun);
+
+ await dbContext.SaveChangesAsync(CancellationToken.None)
+ .ConfigureAwait(false);
+ }
+
+ var service = new ApplicationViewService(dbContext,
+ new ImageReferenceParser(),
+ optionsMonitor,
+ new SharedBaseImageQueryService(dbContext));
+
+ return await service.GetDashboardAsync(CancellationToken.None)
+ .ConfigureAwait(false);
+ }
+ }
+
+ ///
+ /// Read the vulnerability schedule for a vulnerability configuration and an optionally seeded vulnerability scan run
+ ///
+ /// Options monitor
+ /// Vulnerability scan run to seed, or null when no run exists
+ /// Vulnerability schedule view data
+ private static async Task ReadVulnerabilityScheduleAsync(TestOptionsMonitor optionsMonitor, ScanRun? scanRun)
+ {
+ var options = new DbContextOptionsBuilder().UseInMemoryDatabase(Guid.NewGuid().ToString())
+ .Options;
+
+ var dbContext = new DockerUpdateGuardDbContext(options);
+
+ await using (dbContext.ConfigureAwait(false))
+ {
+ if (scanRun is not null)
+ {
+ dbContext.ScanRuns.Add(scanRun);
+
+ await dbContext.SaveChangesAsync(CancellationToken.None)
+ .ConfigureAwait(false);
+ }
+
+ var service = new ApplicationViewService(dbContext,
+ new ImageReferenceParser(),
+ optionsMonitor,
+ new SharedBaseImageQueryService(dbContext));
+
+ return await service.GetVulnerabilityScheduleAsync(CancellationToken.None)
+ .ConfigureAwait(false);
+ }
+ }
+
+ #endregion // Static methods
+
#region Methods
///
@@ -112,6 +239,7 @@ await dbContext.SaveChangesAsync(TestContext.CancellationToken)
var service = new ApplicationViewService(dbContext,
new ImageReferenceParser(),
+ CreateOptionsMonitor(),
new SharedBaseImageQueryService(dbContext));
var runtimeContainersTask = service.GetRuntimeContainersAsync(CancellationToken.None);
@@ -235,6 +363,7 @@ await dbContext.SaveChangesAsync(CancellationToken.None)
var service = new ApplicationViewService(dbContext,
new ImageReferenceParser(),
+ CreateOptionsMonitor(),
new SharedBaseImageQueryService(dbContext));
var dashboard = await service.GetDashboardAsync(CancellationToken.None)
.ConfigureAwait(false);
@@ -332,6 +461,7 @@ await dbContext.SaveChangesAsync(CancellationToken.None)
var service = new ApplicationViewService(dbContext,
new ImageReferenceParser(),
+ CreateOptionsMonitor(),
new SharedBaseImageQueryService(dbContext));
var hasBaseImages = await service.HasBaseImagesAsync(CancellationToken.None)
.ConfigureAwait(false);
@@ -525,6 +655,7 @@ await dbContext.SaveChangesAsync(CancellationToken.None)
var service = new ApplicationViewService(dbContext,
new ImageReferenceParser(),
+ CreateOptionsMonitor(),
new SharedBaseImageQueryService(dbContext));
var detail = await service.GetRuntimeContainerDetailAsync(dockerInstance.Id, "container-a", CancellationToken.None)
@@ -615,6 +746,7 @@ await dbContext.SaveChangesAsync(CancellationToken.None)
var service = new ApplicationViewService(dbContext,
new ImageReferenceParser(),
+ CreateOptionsMonitor(),
new SharedBaseImageQueryService(dbContext));
var detail = await service.GetObservedImageDetailAsync(observedImage.Id, CancellationToken.None)
@@ -701,6 +833,7 @@ await dbContext.SaveChangesAsync(CancellationToken.None)
var service = new ApplicationViewService(dbContext,
new ImageReferenceParser(),
+ CreateOptionsMonitor(),
new SharedBaseImageQueryService(dbContext));
var detail = await service.GetObservedImageDetailAsync(observedImage.Id, CancellationToken.None)
@@ -793,6 +926,7 @@ await dbContext.SaveChangesAsync(CancellationToken.None)
var service = new ApplicationViewService(dbContext,
new ImageReferenceParser(),
+ CreateOptionsMonitor(),
new SharedBaseImageQueryService(dbContext));
var detail = await service.GetObservedImageDetailAsync(observedImage.Id, CancellationToken.None)
@@ -889,6 +1023,7 @@ await dbContext.SaveChangesAsync(CancellationToken.None)
var service = new ApplicationViewService(dbContext,
new ImageReferenceParser(),
+ CreateOptionsMonitor(),
new SharedBaseImageQueryService(dbContext));
var detail = await service.GetObservedImageDetailAsync(observedImage.Id, CancellationToken.None)
@@ -960,6 +1095,7 @@ await dbContext.SaveChangesAsync(CancellationToken.None)
var service = new ApplicationViewService(dbContext,
new ImageReferenceParser(),
+ CreateOptionsMonitor(),
new SharedBaseImageQueryService(dbContext));
var detail = await service.GetObservedImageDetailAsync(observedImage.Id, CancellationToken.None)
@@ -1043,6 +1179,7 @@ await dbContext.SaveChangesAsync(CancellationToken.None)
var service = new ApplicationViewService(dbContext,
new ImageReferenceParser(),
+ CreateOptionsMonitor(),
new SharedBaseImageQueryService(dbContext));
var detail = await service.GetObservedImageDetailAsync(observedImage.Id, CancellationToken.None)
@@ -1108,6 +1245,7 @@ await dbContext.SaveChangesAsync(CancellationToken.None)
var service = new ApplicationViewService(dbContext,
new ImageReferenceParser(),
+ CreateOptionsMonitor(),
new SharedBaseImageQueryService(dbContext));
var listItems = await service.GetObservedImagesAsync(CancellationToken.None)
.ConfigureAwait(false);
@@ -1200,6 +1338,7 @@ await dbContext.SaveChangesAsync(CancellationToken.None)
var service = new ApplicationViewService(dbContext,
new ImageReferenceParser(),
+ CreateOptionsMonitor(),
new SharedBaseImageQueryService(dbContext));
var observedImages = await service.GetObservedImagesAsync(CancellationToken.None)
.ConfigureAwait(false);
@@ -1305,6 +1444,7 @@ await dbContext.SaveChangesAsync(CancellationToken.None)
var service = new ApplicationViewService(dbContext,
new ImageReferenceParser(),
+ CreateOptionsMonitor(),
new SharedBaseImageQueryService(dbContext));
var observedImages = await service.GetObservedImagesAsync(CancellationToken.None)
.ConfigureAwait(false);
@@ -1422,6 +1562,7 @@ await dbContext.SaveChangesAsync(CancellationToken.None)
var service = new ApplicationViewService(dbContext,
new ImageReferenceParser(),
+ CreateOptionsMonitor(),
new SharedBaseImageQueryService(dbContext));
var runtimeContainers = await service.GetRuntimeContainersAsync(CancellationToken.None)
.ConfigureAwait(false);
@@ -1564,6 +1705,7 @@ await dbContext.SaveChangesAsync(CancellationToken.None)
var service = new ApplicationViewService(dbContext,
new ImageReferenceParser(),
+ CreateOptionsMonitor(),
new SharedBaseImageQueryService(dbContext));
var runtimeContainers = await service.GetRuntimeContainersAsync(CancellationToken.None)
.ConfigureAwait(false);
@@ -1642,6 +1784,7 @@ await dbContext.SaveChangesAsync(CancellationToken.None)
var service = new ApplicationViewService(dbContext,
new ImageReferenceParser(),
+ CreateOptionsMonitor(),
new SharedBaseImageQueryService(dbContext));
var runtimeContainers = await service.GetRuntimeContainersAsync(CancellationToken.None)
.ConfigureAwait(false);
@@ -1738,6 +1881,7 @@ await dbContext.SaveChangesAsync(CancellationToken.None)
var service = new ApplicationViewService(dbContext,
new ImageReferenceParser(),
+ CreateOptionsMonitor(),
new SharedBaseImageQueryService(dbContext));
var runtimeContainers = await service.GetRuntimeContainersAsync(CancellationToken.None)
.ConfigureAwait(false);
@@ -1798,6 +1942,7 @@ await dbContext.SaveChangesAsync(CancellationToken.None)
var service = new ApplicationViewService(dbContext,
new ImageReferenceParser(),
+ CreateOptionsMonitor(),
new SharedBaseImageQueryService(dbContext));
var dashboardTask = service.GetDashboardAsync(CancellationToken.None);
var observedImagesTask = service.GetObservedImagesAsync(CancellationToken.None);
@@ -1900,6 +2045,7 @@ await dbContext.SaveChangesAsync(CancellationToken.None)
var service = new ApplicationViewService(dbContext,
new ImageReferenceParser(),
+ CreateOptionsMonitor(),
new SharedBaseImageQueryService(dbContext));
var observedImages = await service.GetObservedImagesAsync(CancellationToken.None)
.ConfigureAwait(false);
@@ -1968,6 +2114,7 @@ await dbContext.SaveChangesAsync(CancellationToken.None)
var service = new ApplicationViewService(dbContext,
new ImageReferenceParser(),
+ CreateOptionsMonitor(),
new SharedBaseImageQueryService(dbContext));
var manualImages = await service.GetManualObservedImagesAsync(CancellationToken.None)
.ConfigureAwait(false);
@@ -2029,6 +2176,7 @@ await dbContext.SaveChangesAsync(CancellationToken.None)
var service = new ApplicationViewService(dbContext,
new ImageReferenceParser(),
+ CreateOptionsMonitor(),
new SharedBaseImageQueryService(dbContext));
var discoveryImages = await service.GetDiscoveryObservedImagesAsync(CancellationToken.None)
.ConfigureAwait(false);
@@ -2116,6 +2264,7 @@ await dbContext.SaveChangesAsync(CancellationToken.None)
var service = new ApplicationViewService(dbContext,
new ImageReferenceParser(),
+ CreateOptionsMonitor(),
new SharedBaseImageQueryService(dbContext));
var overview = await service.GetVulnerabilityOverviewAsync(CancellationToken.None)
@@ -2186,6 +2335,7 @@ await dbContext.SaveChangesAsync(CancellationToken.None)
var service = new ApplicationViewService(dbContext,
new ImageReferenceParser(),
+ CreateOptionsMonitor(),
new SharedBaseImageQueryService(dbContext));
var overview = await service.GetVulnerabilityOverviewAsync(CancellationToken.None)
@@ -2274,6 +2424,7 @@ await dbContext.SaveChangesAsync(CancellationToken.None)
var service = new ApplicationViewService(dbContext,
new ImageReferenceParser(),
+ CreateOptionsMonitor(),
new SharedBaseImageQueryService(dbContext));
var overview = await service.GetVulnerabilityOverviewAsync(CancellationToken.None)
@@ -2358,6 +2509,7 @@ await dbContext.SaveChangesAsync(CancellationToken.None)
var service = new ApplicationViewService(dbContext,
new ImageReferenceParser(),
+ CreateOptionsMonitor(),
new SharedBaseImageQueryService(dbContext));
var overview = await service.GetVulnerabilityOverviewAsync(CancellationToken.None)
@@ -2451,6 +2603,7 @@ await dbContext.SaveChangesAsync(CancellationToken.None)
var service = new ApplicationViewService(dbContext,
new ImageReferenceParser(),
+ CreateOptionsMonitor(),
new SharedBaseImageQueryService(dbContext));
var overview = await service.GetVulnerabilityOverviewAsync(CancellationToken.None)
@@ -2536,6 +2689,7 @@ await dbContext.SaveChangesAsync(CancellationToken.None)
var service = new ApplicationViewService(dbContext,
new ImageReferenceParser(),
+ CreateOptionsMonitor(),
new SharedBaseImageQueryService(dbContext));
var overview = await service.GetVulnerabilityOverviewAsync(CancellationToken.None)
@@ -2598,6 +2752,7 @@ await dbContext.SaveChangesAsync(CancellationToken.None)
var service = new ApplicationViewService(dbContext,
new ImageReferenceParser(),
+ CreateOptionsMonitor(),
new SharedBaseImageQueryService(dbContext));
var runtimeContainers = await service.GetRuntimeContainersAsync(CancellationToken.None)
.ConfigureAwait(false);
@@ -2611,5 +2766,129 @@ await dbContext.SaveChangesAsync(CancellationToken.None)
}
}
+ ///
+ /// Verify the dashboard reports the disabled hint when vulnerability scanning is switched off
+ ///
+ /// Task
+ [TestMethod]
+ public async Task ApplicationViewServiceDashboardScanningDisabledReportsDisabledHintAsync()
+ {
+ var dashboard = await ReadDashboardAsync(CreateOptionsMonitor(enabled: false, VulnerabilityProviderKind.None), scanRun: null).ConfigureAwait(false);
+
+ Assert.IsFalse(dashboard.VulnerabilityScanningEnabled, "The dashboard must report vulnerability scanning as disabled when the option is switched off");
+ Assert.AreEqual(VulnerabilityConfigurationHintKind.ScanningDisabled,
+ dashboard.VulnerabilityConfigurationHint,
+ "Disabled vulnerability scanning must resolve to the disabled configuration hint");
+ }
+
+ ///
+ /// Verify the dashboard names the missing Trivy base URL when the latest vulnerability run reported it
+ ///
+ /// Task
+ [TestMethod]
+ public async Task ApplicationViewServiceDashboardTrivyNotConfiguredReportsTrivyBaseUrlHintAsync()
+ {
+ var scanRun = CreateVulnerabilityScanRun(ScanRunStatus.Partial,
+ VulnerabilityProviderMessages.TrivyBaseUrlNotConfigured,
+ DateTimeOffset.UtcNow.AddMinutes(-30));
+ var dashboard = await ReadDashboardAsync(CreateOptionsMonitor(enabled: true, VulnerabilityProviderKind.Trivy), scanRun).ConfigureAwait(false);
+
+ Assert.IsTrue(dashboard.VulnerabilityScanningEnabled, "The dashboard must report vulnerability scanning as enabled when the option is switched on");
+ Assert.AreEqual(VulnerabilityConfigurationHintKind.TrivyBaseUrlMissing,
+ dashboard.VulnerabilityConfigurationHint,
+ "A Trivy run reporting a missing base URL must resolve to the Trivy configuration hint");
+ }
+
+ ///
+ /// Verify the dashboard names the missing Docker Hub credentials when the latest vulnerability run reported them
+ ///
+ /// Task
+ [TestMethod]
+ public async Task ApplicationViewServiceDashboardDockerScoutNotConfiguredReportsCredentialsHintAsync()
+ {
+ var scanRun = CreateVulnerabilityScanRun(ScanRunStatus.Failed,
+ VulnerabilityProviderMessages.DockerScoutCredentialsNotConfigured,
+ DateTimeOffset.UtcNow.AddMinutes(-30));
+ var dashboard = await ReadDashboardAsync(CreateOptionsMonitor(enabled: true, VulnerabilityProviderKind.DockerScout), scanRun).ConfigureAwait(false);
+
+ Assert.AreEqual(VulnerabilityConfigurationHintKind.DockerHubCredentialsMissing,
+ dashboard.VulnerabilityConfigurationHint,
+ "A Docker Scout run reporting missing credentials must resolve to the Docker Hub configuration hint");
+ }
+
+ ///
+ /// Verify the dashboard reports no hint when the latest vulnerability run succeeded
+ ///
+ /// Task
+ [TestMethod]
+ public async Task ApplicationViewServiceDashboardConfiguredProviderReportsNoHintAsync()
+ {
+ var scanRun = CreateVulnerabilityScanRun(ScanRunStatus.Succeeded, errorMessage: null, DateTimeOffset.UtcNow.AddMinutes(-30));
+ var dashboard = await ReadDashboardAsync(CreateOptionsMonitor(enabled: true, VulnerabilityProviderKind.Trivy), scanRun).ConfigureAwait(false);
+
+ Assert.AreEqual(VulnerabilityConfigurationHintKind.None,
+ dashboard.VulnerabilityConfigurationHint,
+ "A correctly configured provider must not surface a configuration hint");
+ }
+
+ ///
+ /// Verify the dashboard ignores unrelated failures of the latest vulnerability run
+ ///
+ /// Task
+ [TestMethod]
+ public async Task ApplicationViewServiceDashboardUnrelatedFailureReportsNoHintAsync()
+ {
+ var scanRun = CreateVulnerabilityScanRun(ScanRunStatus.Failed, "Trivy scan timed out after 30 seconds", DateTimeOffset.UtcNow.AddMinutes(-30));
+ var dashboard = await ReadDashboardAsync(CreateOptionsMonitor(enabled: true, VulnerabilityProviderKind.Trivy), scanRun).ConfigureAwait(false);
+
+ Assert.AreEqual(VulnerabilityConfigurationHintKind.None,
+ dashboard.VulnerabilityConfigurationHint,
+ "A failure unrelated to the provider configuration must not surface a configuration hint");
+ }
+
+ ///
+ /// Verify the next scheduled vulnerability refresh is derived from the latest run and the configured interval
+ ///
+ /// Task
+ [TestMethod]
+ public async Task ApplicationViewServiceVulnerabilityScheduleWithRecordedRunReturnsEstimatedNextRefreshAsync()
+ {
+ var startedAtUtc = DateTimeOffset.UtcNow.AddMinutes(-45);
+ var scanRun = CreateVulnerabilityScanRun(ScanRunStatus.Succeeded, errorMessage: null, startedAtUtc);
+ var schedule = await ReadVulnerabilityScheduleAsync(CreateOptionsMonitor(enabled: true, VulnerabilityProviderKind.Trivy, refreshIntervalMinutes: 120), scanRun).ConfigureAwait(false);
+
+ Assert.IsTrue(schedule.VulnerabilityScanningEnabled, "The schedule must report vulnerability scanning as enabled when the option is switched on");
+ Assert.AreEqual(startedAtUtc.AddMinutes(120),
+ schedule.NextVulnerabilityRefreshUtc,
+ "The next scheduled refresh must be the start of the latest run plus the configured refresh interval");
+ }
+
+ ///
+ /// Verify the next scheduled vulnerability refresh stays unset when no run was recorded yet
+ ///
+ /// Task
+ [TestMethod]
+ public async Task ApplicationViewServiceVulnerabilityScheduleWithoutRecordedRunReturnsNoNextRefreshAsync()
+ {
+ var schedule = await ReadVulnerabilityScheduleAsync(CreateOptionsMonitor(enabled: true, VulnerabilityProviderKind.Trivy), scanRun: null).ConfigureAwait(false);
+
+ Assert.IsTrue(schedule.VulnerabilityScanningEnabled, "The schedule must report vulnerability scanning as enabled when the option is switched on");
+ Assert.IsNull(schedule.NextVulnerabilityRefreshUtc, "The next scheduled refresh must stay unset when no vulnerability run was recorded yet");
+ }
+
+ ///
+ /// Verify the vulnerability schedule stays empty when scanning is disabled
+ ///
+ /// Task
+ [TestMethod]
+ public async Task ApplicationViewServiceVulnerabilityScheduleWithDisabledScanningReturnsDisabledScheduleAsync()
+ {
+ var scanRun = CreateVulnerabilityScanRun(ScanRunStatus.Succeeded, errorMessage: null, DateTimeOffset.UtcNow.AddMinutes(-45));
+ var schedule = await ReadVulnerabilityScheduleAsync(CreateOptionsMonitor(enabled: false, VulnerabilityProviderKind.None), scanRun).ConfigureAwait(false);
+
+ Assert.IsFalse(schedule.VulnerabilityScanningEnabled, "The schedule must report vulnerability scanning as disabled when the option is switched off");
+ Assert.IsNull(schedule.NextVulnerabilityRefreshUtc, "The next scheduled refresh must stay unset when vulnerability scanning is disabled");
+ }
+
#endregion // Methods
}
\ No newline at end of file
diff --git a/src/Tests/DockerUpdateGuard.Tests/DashboardRenderTests.cs b/src/Tests/DockerUpdateGuard.Tests/DashboardRenderTests.cs
index c3efe7a..d5f361c 100644
--- a/src/Tests/DockerUpdateGuard.Tests/DashboardRenderTests.cs
+++ b/src/Tests/DockerUpdateGuard.Tests/DashboardRenderTests.cs
@@ -73,5 +73,97 @@ public async Task DashboardWithConfiguredAccountRendersMetricTiles()
}
}
+ ///
+ /// Verify the info banner is rendered when vulnerability scanning is disabled
+ ///
+ /// Task
+ [TestMethod]
+ public async Task DashboardWithDisabledVulnerabilityScanningRendersInfoBanner()
+ {
+ var testContext = BlazorTestContextFactory.Create();
+
+ await using (testContext)
+ {
+ var markup = RenderDashboardMarkup(testContext, VulnerabilityConfigurationHintKind.ScanningDisabled);
+
+ Assert.Contains("Vulnerability scanning is disabled",
+ markup,
+ "The dashboard must explain how to enable vulnerability scanning when it is switched off");
+ Assert.Contains("DockerUpdateGuard:Vulnerabilities:Enabled",
+ markup,
+ "The disabled banner must name the configuration key that enables vulnerability scanning");
+ }
+ }
+
+ ///
+ /// Verify the warning banner names the missing Trivy base URL
+ ///
+ /// Task
+ [TestMethod]
+ public async Task DashboardWithMissingTrivyBaseUrlRendersWarningBanner()
+ {
+ var testContext = BlazorTestContextFactory.Create();
+
+ await using (testContext)
+ {
+ var markup = RenderDashboardMarkup(testContext, VulnerabilityConfigurationHintKind.TrivyBaseUrlMissing);
+
+ Assert.Contains("DockerUpdateGuard:Vulnerabilities:TrivyBaseUrl",
+ markup,
+ "The misconfiguration banner must name the missing Trivy base URL setting");
+ Assert.DoesNotContain("Vulnerability scanning is disabled",
+ markup,
+ "The disabled banner must not be rendered while vulnerability scanning is enabled");
+ }
+ }
+
+ ///
+ /// Verify no configuration banner is rendered for a correctly configured provider
+ ///
+ /// Task
+ [TestMethod]
+ public async Task DashboardWithConfiguredVulnerabilityProviderRendersNoBanner()
+ {
+ var testContext = BlazorTestContextFactory.Create();
+
+ await using (testContext)
+ {
+ var markup = RenderDashboardMarkup(testContext, VulnerabilityConfigurationHintKind.None);
+
+ Assert.DoesNotContain("Vulnerability scanning is disabled",
+ markup,
+ "The disabled banner must not be rendered while vulnerability scanning is enabled");
+ Assert.DoesNotContain("DockerUpdateGuard:Vulnerabilities:TrivyBaseUrl",
+ markup,
+ "The misconfiguration banner must not be rendered for a correctly configured provider");
+ }
+ }
+
+ ///
+ /// Render the dashboard for a vulnerability configuration hint and return its markup
+ ///
+ /// bUnit test context
+ /// Vulnerability configuration hint kind
+ /// Rendered markup
+ private static string RenderDashboardMarkup(BunitContext testContext, VulnerabilityConfigurationHintKind hintKind)
+ {
+ var viewService = Substitute.For();
+
+ viewService.GetDashboardAsync(Arg.Any())
+ .Returns(Task.FromResult(new DashboardViewData
+ {
+ VulnerabilityScanningEnabled = hintKind != VulnerabilityConfigurationHintKind.ScanningDisabled,
+ VulnerabilityConfigurationHint = hintKind,
+ }));
+
+ testContext.Services.AddSingleton(viewService);
+ testContext.Services.AddSingleton(new DashboardRefreshState());
+ testContext.Services.AddSingleton>(Options.Create(new DockerUpdateGuardOptions()));
+
+ var component = testContext.Render();
+
+ return component.Markup;
+ }
+
#endregion // Methods
}
\ No newline at end of file
diff --git a/src/Tests/DockerUpdateGuard.Tests/ScanHistoryTests.cs b/src/Tests/DockerUpdateGuard.Tests/ScanHistoryTests.cs
index 265f7c1..37cca9c 100644
--- a/src/Tests/DockerUpdateGuard.Tests/ScanHistoryTests.cs
+++ b/src/Tests/DockerUpdateGuard.Tests/ScanHistoryTests.cs
@@ -123,6 +123,110 @@ public async Task ScanHistoryRefreshFailureShowsErrorAlert()
}
}
+ ///
+ /// Verify the estimated next scheduled vulnerability refresh is shown when scanning is enabled
+ ///
+ /// Task
+ [TestMethod]
+ public async Task ScanHistoryVulnerabilitiesEnabledShowsNextScheduledRefresh()
+ {
+ var testContext = BlazorTestContextFactory.Create();
+
+ await using (testContext)
+ {
+ var viewService = Substitute.For();
+ var nextRefreshUtc = new DateTimeOffset(2026, 7, 29, 18, 30, 0, TimeSpan.Zero);
+
+ viewService.GetScanHistoryAsync(Arg.Any(), Arg.Any())
+ .Returns(new List());
+ viewService.GetVulnerabilityScheduleAsync(Arg.Any())
+ .Returns(new VulnerabilityScheduleViewData
+ {
+ VulnerabilityScanningEnabled = true,
+ NextVulnerabilityRefreshUtc = nextRefreshUtc,
+ });
+
+ testContext.Services.AddSingleton(viewService);
+ testContext.Services.AddSingleton(Substitute.For());
+ testContext.Services.AddSingleton(new DashboardRefreshState());
+ testContext.Services.AddSingleton>(CreateOptionsMonitor(enabled: true));
+
+ var component = testContext.Render();
+
+ Assert.Contains("Next scheduled vulnerability refresh:",
+ component.Markup,
+ "The page header must show the estimated next scheduled vulnerability refresh");
+ Assert.Contains(nextRefreshUtc.ToLocalTime().ToString("g"),
+ component.Markup,
+ "The estimated next scheduled vulnerability refresh must be rendered as a local timestamp");
+ }
+ }
+
+ ///
+ /// Verify the no-run-yet variant is shown when no vulnerability run was recorded
+ ///
+ /// Task
+ [TestMethod]
+ public async Task ScanHistoryWithoutRecordedVulnerabilityRunShowsShortlyHint()
+ {
+ var testContext = BlazorTestContextFactory.Create();
+
+ await using (testContext)
+ {
+ var viewService = Substitute.For();
+
+ viewService.GetScanHistoryAsync(Arg.Any(), Arg.Any())
+ .Returns(new List());
+ viewService.GetVulnerabilityScheduleAsync(Arg.Any())
+ .Returns(new VulnerabilityScheduleViewData
+ {
+ VulnerabilityScanningEnabled = true,
+ });
+
+ testContext.Services.AddSingleton(viewService);
+ testContext.Services.AddSingleton(Substitute.For());
+ testContext.Services.AddSingleton(new DashboardRefreshState());
+ testContext.Services.AddSingleton>(CreateOptionsMonitor(enabled: true));
+
+ var component = testContext.Render();
+
+ Assert.Contains("Next scheduled vulnerability refresh: shortly (no run recorded yet)",
+ component.Markup,
+ "The page header must fall back to the no-run-yet variant when no vulnerability run was recorded");
+ }
+ }
+
+ ///
+ /// Verify the next scheduled vulnerability refresh is hidden when scanning is disabled
+ ///
+ /// Task
+ [TestMethod]
+ public async Task ScanHistoryVulnerabilitiesDisabledHidesNextScheduledRefresh()
+ {
+ var testContext = BlazorTestContextFactory.Create();
+
+ await using (testContext)
+ {
+ var viewService = Substitute.For();
+
+ viewService.GetScanHistoryAsync(Arg.Any(), Arg.Any())
+ .Returns(new List());
+ viewService.GetVulnerabilityScheduleAsync(Arg.Any())
+ .Returns(new VulnerabilityScheduleViewData());
+
+ testContext.Services.AddSingleton(viewService);
+ testContext.Services.AddSingleton(Substitute.For());
+ testContext.Services.AddSingleton(new DashboardRefreshState());
+ testContext.Services.AddSingleton>(CreateOptionsMonitor(enabled: false));
+
+ var component = testContext.Render();
+
+ Assert.DoesNotContain("Next scheduled vulnerability refresh",
+ component.Markup,
+ "The next scheduled vulnerability refresh must be hidden when vulnerability scanning is disabled");
+ }
+ }
+
///
/// Create an options monitor for the tests
///