From 55b01d900247298606c7b99ef9a1b1c696b4b109 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 05:07:50 +0000 Subject: [PATCH] Surface vulnerability configuration hints and next scheduled refresh The dashboard now shows an info banner when vulnerability scanning is disabled and a warning banner naming the missing setting when the latest completed vulnerability run reported a not-configured provider. The scan-history header shows the estimated next scheduled refresh derived from the latest run and the configured interval, falling back to a no-run-yet variant. ApplicationViewService reads the application options to supply the hint kind and schedule, and both providers now report their not-configured messages from shared constants so the heuristic cannot drift. Closes #168. --- .../Components/Pages/Dashboard.razor | 19 ++ .../Components/Pages/ScanHistory.razor | 36 ++- .../Components/Pages/ScanHistory.razor.cs | 36 +++ .../UI/ApplicationViewService.cs | 84 ++++++ src/DockerUpdateGuard/UI/DashboardViewData.cs | 10 + .../UI/IApplicationViewService.cs | 7 + .../UI/VulnerabilityConfigurationHintKind.cs | 27 ++ .../VulnerabilityConfigurationHintResolver.cs | 56 ++++ .../UI/VulnerabilityScheduleViewData.cs | 21 ++ .../DockerScoutVulnerabilityProvider.cs | 2 +- .../TrivyVulnerabilityProvider.cs | 2 +- .../VulnerabilityProviderMessages.cs | 22 ++ .../ApplicationViewServiceTests.cs | 279 ++++++++++++++++++ .../DashboardRenderTests.cs | 92 ++++++ .../ScanHistoryTests.cs | 104 +++++++ 15 files changed, 780 insertions(+), 17 deletions(-) create mode 100644 src/DockerUpdateGuard/UI/VulnerabilityConfigurationHintKind.cs create mode 100644 src/DockerUpdateGuard/UI/VulnerabilityConfigurationHintResolver.cs create mode 100644 src/DockerUpdateGuard/UI/VulnerabilityScheduleViewData.cs create mode 100644 src/DockerUpdateGuard/Vulnerabilities/VulnerabilityProviderMessages.cs 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 ///