doctor enhancements, setup, enhancements, ui functionality and design consolidation and , test projects fixes , product advisory attestation/rekor and delta verfications enhancements

This commit is contained in:
master
2026-01-19 09:02:59 +02:00
parent 8c4bf54aed
commit 17419ba7c4
809 changed files with 170738 additions and 12244 deletions

View File

@@ -0,0 +1,347 @@
// -----------------------------------------------------------------------------
// GreyQueueWatchdogServiceTests.cs
// Sprint: SPRINT_20260118_018_Unknowns_queue_enhancement
// Task: UQ-004 - Unit tests for watchdog service
// -----------------------------------------------------------------------------
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using NSubstitute;
using StellaOps.Unknowns.Services;
using Xunit;
namespace StellaOps.Unknowns.Core.Tests.Services;
public sealed class GreyQueueWatchdogServiceTests
{
private readonly IGreyQueueRepository _repository;
private readonly TestNotificationPublisher _notificationPublisher;
private readonly FakeTimeProvider _timeProvider;
private readonly GreyQueueWatchdogOptions _options;
public GreyQueueWatchdogServiceTests()
{
_repository = Substitute.For<IGreyQueueRepository>();
_notificationPublisher = new TestNotificationPublisher();
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 19, 12, 0, 0, TimeSpan.Zero));
_options = new GreyQueueWatchdogOptions
{
CheckInterval = TimeSpan.FromMilliseconds(100),
ProcessingAlertThreshold = TimeSpan.FromHours(1),
ProcessingTimeout = TimeSpan.FromHours(4),
MaxAttempts = 5,
BaseRetryDelayMinutes = 15
};
}
#region Stuck Detection Tests
[Fact]
public async Task Check_HealthyEntry_NoAlert()
{
// Arrange
var entry = CreateProcessingEntry(_timeProvider.GetUtcNow().AddMinutes(-30)); // 30 min
_repository.GetByStatusAsync(GreyQueueStatus.Processing, Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<GreyQueueEntry>>([entry]));
var service = CreateService();
// Act
await TriggerCheck(service);
// Assert - No alerts
Assert.Empty(_notificationPublisher.StuckAlerts);
}
[Fact]
public async Task Check_StuckEntry_GeneratesAlert()
{
// Arrange - Processing for 90 min (past 1h threshold)
var entry = CreateProcessingEntry(_timeProvider.GetUtcNow().AddMinutes(-90));
_repository.GetByStatusAsync(GreyQueueStatus.Processing, Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<GreyQueueEntry>>([entry]));
var service = CreateService();
// Act
await TriggerCheck(service);
// Assert
Assert.Single(_notificationPublisher.StuckAlerts);
Assert.Equal(entry.Id, _notificationPublisher.StuckAlerts[0].EntryId);
}
[Fact]
public async Task Check_TimedOutEntry_ForcesRetry()
{
// Arrange - Processing for 5 hours (past 4h timeout), attempts < max
var entry = CreateProcessingEntry(_timeProvider.GetUtcNow().AddHours(-5), attempts: 2);
_repository.GetByStatusAsync(GreyQueueStatus.Processing, Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<GreyQueueEntry>>([entry]));
var service = CreateService();
// Act
await TriggerCheck(service);
// Assert
await _repository.Received(1).ForceRetryAsync(
entry.Id,
Arg.Any<DateTimeOffset>(),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task Check_ExhaustedEntry_MarksFailed()
{
// Arrange - Processing for 5 hours, max attempts reached
var entry = CreateProcessingEntry(_timeProvider.GetUtcNow().AddHours(-5), attempts: 5);
_repository.GetByStatusAsync(GreyQueueStatus.Processing, Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<GreyQueueEntry>>([entry]));
var service = CreateService();
// Act
await TriggerCheck(service);
// Assert
await _repository.Received(1).UpdateStatusAsync(
entry.Id,
GreyQueueStatus.Failed,
Arg.Any<CancellationToken>());
Assert.Single(_notificationPublisher.FailedNotifications);
}
#endregion
#region Exponential Backoff Tests
[Theory]
[InlineData(0, 15)] // Attempt 0: 15 min
[InlineData(1, 30)] // Attempt 1: 30 min
[InlineData(2, 60)] // Attempt 2: 60 min
[InlineData(3, 120)] // Attempt 3: 120 min
[InlineData(4, 240)] // Attempt 4: 240 min
public async Task ForceRetry_UsesExponentialBackoff(int attempts, int expectedMinutes)
{
// Arrange
var entry = CreateProcessingEntry(_timeProvider.GetUtcNow().AddHours(-5), attempts: attempts);
_repository.GetByStatusAsync(GreyQueueStatus.Processing, Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<GreyQueueEntry>>([entry]));
DateTimeOffset capturedNextProcessingAt = default;
_repository.ForceRetryAsync(
Arg.Any<Guid>(),
Arg.Do<DateTimeOffset>(dt => capturedNextProcessingAt = dt),
Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
var service = CreateService();
// Act
await TriggerCheck(service);
// Assert
var expectedDelay = TimeSpan.FromMinutes(expectedMinutes);
var actualDelay = capturedNextProcessingAt - _timeProvider.GetUtcNow();
Assert.Equal(expectedDelay.TotalMinutes, actualDelay.TotalMinutes, precision: 1);
}
#endregion
#region Multiple Entries Tests
[Fact]
public async Task Check_MultipleEntries_ProcessesAll()
{
// Arrange
var healthy = CreateProcessingEntry(_timeProvider.GetUtcNow().AddMinutes(-30));
var stuck = CreateProcessingEntry(_timeProvider.GetUtcNow().AddMinutes(-90));
var timedOut = CreateProcessingEntry(_timeProvider.GetUtcNow().AddHours(-5), attempts: 2);
_repository.GetByStatusAsync(GreyQueueStatus.Processing, Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<GreyQueueEntry>>([healthy, stuck, timedOut]));
var service = CreateService();
// Act
await TriggerCheck(service);
// Assert
Assert.Single(_notificationPublisher.StuckAlerts); // stuck only
await _repository.Received(1).ForceRetryAsync(
timedOut.Id,
Arg.Any<DateTimeOffset>(),
Arg.Any<CancellationToken>());
}
#endregion
#region Manual Retry Tests
[Fact]
public async Task ManualRetry_ProcessingEntry_Succeeds()
{
// Arrange
var entry = CreateProcessingEntry(_timeProvider.GetUtcNow().AddHours(-2));
_repository.GetByIdAsync(entry.Id, Arg.Any<CancellationToken>())
.Returns(Task.FromResult<GreyQueueEntry?>(entry));
var service = CreateService();
// Act
await service.ManualRetryAsync(entry.Id, CancellationToken.None);
// Assert
await _repository.Received(1).ForceRetryAsync(
entry.Id,
Arg.Any<DateTimeOffset>(),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task ManualRetry_NonProcessingEntry_Throws()
{
// Arrange
var entry = new GreyQueueEntry
{
Id = Guid.NewGuid(),
BomRef = "pkg:test@1.0.0",
Status = GreyQueueStatus.Pending,
CreatedAt = _timeProvider.GetUtcNow()
};
_repository.GetByIdAsync(entry.Id, Arg.Any<CancellationToken>())
.Returns(Task.FromResult<GreyQueueEntry?>(entry));
var service = CreateService();
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(
() => service.ManualRetryAsync(entry.Id, CancellationToken.None));
}
[Fact]
public async Task ManualRetry_NotFound_Throws()
{
// Arrange
_repository.GetByIdAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<GreyQueueEntry?>(null));
var service = CreateService();
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(
() => service.ManualRetryAsync(Guid.NewGuid(), CancellationToken.None));
}
#endregion
#region Stats Tests
[Fact]
public async Task GetStats_ReturnsCorrectCounts()
{
// Arrange
var healthy = CreateProcessingEntry(_timeProvider.GetUtcNow().AddMinutes(-30));
var stuck = CreateProcessingEntry(_timeProvider.GetUtcNow().AddMinutes(-90));
var timedOut = CreateProcessingEntry(_timeProvider.GetUtcNow().AddHours(-5));
_repository.GetByStatusAsync(GreyQueueStatus.Processing, Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<GreyQueueEntry>>([healthy, stuck, timedOut]));
var service = CreateService();
// Act
var stats = await service.GetStatsAsync();
// Assert
Assert.Equal(3, stats.TotalProcessing);
Assert.Equal(1, stats.StuckCount); // 90 min entry
Assert.Equal(1, stats.TimedOutCount); // 5 hour entry
}
#endregion
#region Helpers
private GreyQueueWatchdogService CreateService()
{
return new GreyQueueWatchdogService(
_repository,
_notificationPublisher,
Options.Create(_options),
_timeProvider,
new NullLogger<GreyQueueWatchdogService>());
}
private async Task TriggerCheck(GreyQueueWatchdogService service)
{
var method = typeof(GreyQueueWatchdogService)
.GetMethod("CheckProcessingEntriesAsync", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
if (method != null)
{
var task = (Task?)method.Invoke(service, [CancellationToken.None]);
if (task != null) await task;
}
}
private GreyQueueEntry CreateProcessingEntry(DateTimeOffset lastProcessedAt, int attempts = 0)
{
return new GreyQueueEntry
{
Id = Guid.NewGuid(),
BomRef = $"pkg:npm/test-{Guid.NewGuid():N}@1.0.0",
Status = GreyQueueStatus.Processing,
Score = 0.50,
CreatedAt = lastProcessedAt.AddHours(-1),
LastProcessedAt = lastProcessedAt,
ProcessingAttempts = attempts,
MaxAttempts = _options.MaxAttempts
};
}
#endregion
}
/// <summary>
/// Extension to TestNotificationPublisher for watchdog notifications.
/// </summary>
public partial class TestNotificationPublisher
{
public List<StuckProcessingAlert> StuckAlerts { get; } = [];
public List<EntryFailedNotification> FailedNotifications { get; } = [];
public List<ForcedRetryNotification> RetryNotifications { get; } = [];
public new Task PublishAsync<T>(T notification, CancellationToken ct = default)
{
switch (notification)
{
case SlaWarningNotification warning:
Warnings.Add(warning);
break;
case SlaBreachNotification breach:
Breaches.Add(breach);
break;
case StuckProcessingAlert stuck:
StuckAlerts.Add(stuck);
break;
case EntryFailedNotification failed:
FailedNotifications.Add(failed);
break;
case ForcedRetryNotification retry:
RetryNotifications.Add(retry);
break;
}
return Task.CompletedTask;
}
public new void Clear()
{
Warnings.Clear();
Breaches.Clear();
StuckAlerts.Clear();
FailedNotifications.Clear();
RetryNotifications.Clear();
}
}

View File

@@ -0,0 +1,367 @@
// -----------------------------------------------------------------------------
// UnknownsLifecycleServiceIntegrationTests.cs
// Sprint: SPRINT_20260118_018_Unknowns_queue_enhancement
// Task: UQ-002 - Integration tests for lifecycle service
// -----------------------------------------------------------------------------
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using NSubstitute;
using StellaOps.Unknowns.Services;
using Xunit;
namespace StellaOps.Unknowns.Core.Tests.Services;
public sealed class UnknownsLifecycleServiceIntegrationTests
{
private readonly IGreyQueueRepository _repository;
private readonly TestEventSubscriber _eventSubscriber;
private readonly TestNotificationPublisher _notificationPublisher;
private readonly FakeTimeProvider _timeProvider;
private readonly UnknownsLifecycleOptions _options;
public UnknownsLifecycleServiceIntegrationTests()
{
_repository = Substitute.For<IGreyQueueRepository>();
_eventSubscriber = new TestEventSubscriber();
_notificationPublisher = new TestNotificationPublisher();
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 19, 12, 0, 0, TimeSpan.Zero));
_options = new UnknownsLifecycleOptions
{
ExpiryCheckInterval = TimeSpan.FromMilliseconds(100)
};
}
#region EPSS Update Tests
[Fact]
public async Task EpssUpdated_ScoreIncreases_EscalatesEntry()
{
// Arrange
var entry = CreateEntry(0.50); // WARM
_repository.GetByCveAsync("CVE-2026-1234", Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<GreyQueueEntry>>([entry]));
var service = CreateService();
await StartServiceAsync(service);
// Act - Simulate EPSS update that would escalate to HOT
await _eventSubscriber.PublishAsync(new EpssUpdatedEvent
{
CveId = "CVE-2026-1234",
OldScore = 0.50,
NewScore = 0.85
});
// Assert
await _repository.Received(1).UpdateScoreAsync(
entry.Id,
Arg.Is<double>(s => s >= 0.70),
Arg.Any<string>(),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task EpssUpdated_ScoreDecreases_DoesNotDemote()
{
// Arrange - Demotion requires explicit call, not auto
var entry = CreateEntry(0.75); // HOT
_repository.GetByCveAsync("CVE-2026-1234", Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<GreyQueueEntry>>([entry]));
var service = CreateService();
await StartServiceAsync(service);
// Act - EPSS decreases
await _eventSubscriber.PublishAsync(new EpssUpdatedEvent
{
CveId = "CVE-2026-1234",
OldScore = 0.75,
NewScore = 0.30
});
// Assert - Should NOT auto-demote
await _repository.DidNotReceive().UpdateScoreAsync(
entry.Id,
Arg.Is<double>(s => s < 0.70),
Arg.Any<string>(),
Arg.Any<CancellationToken>());
}
#endregion
#region KEV Added Tests
[Fact]
public async Task KevAdded_EscalatesToHot()
{
// Arrange
var entry = CreateEntry(0.30); // COLD
_repository.GetByCveAsync("CVE-2026-5678", Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<GreyQueueEntry>>([entry]));
var service = CreateService();
await StartServiceAsync(service);
// Act
await _eventSubscriber.PublishAsync(new KevAddedEvent
{
CveId = "CVE-2026-5678",
AddedDate = _timeProvider.GetUtcNow()
});
// Assert - Should escalate to HOT
await _repository.Received(1).UpdateScoreAsync(
entry.Id,
Arg.Is<double>(s => s >= 0.70),
Arg.Is<string>(r => r.Contains("KEV")),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task KevAdded_AlreadyHot_NoChange()
{
// Arrange
var entry = CreateEntry(0.85); // Already HOT
_repository.GetByCveAsync("CVE-2026-5678", Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<GreyQueueEntry>>([entry]));
var service = CreateService();
await StartServiceAsync(service);
// Act
await _eventSubscriber.PublishAsync(new KevAddedEvent
{
CveId = "CVE-2026-5678",
AddedDate = _timeProvider.GetUtcNow()
});
// Assert - Should NOT update (already HOT)
await _repository.DidNotReceive().UpdateScoreAsync(
Arg.Any<Guid>(),
Arg.Any<double>(),
Arg.Any<string>(),
Arg.Any<CancellationToken>());
}
#endregion
#region Deployment Created Tests
[Fact]
public async Task DeploymentCreated_ColdEntry_EscalatesToWarm()
{
// Arrange
var entry = CreateEntry(0.20, "pkg:npm/vulnerable@1.0.0"); // COLD
_repository.GetByBomRefAsync("pkg:npm/vulnerable@1.0.0", Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<GreyQueueEntry>>([entry]));
var service = CreateService();
await StartServiceAsync(service);
// Act
await _eventSubscriber.PublishAsync(new DeploymentCreatedEvent
{
DeploymentId = "deploy-123",
AffectedComponents = ["pkg:npm/vulnerable@1.0.0"]
});
// Assert - Should escalate COLD to WARM
await _repository.Received(1).UpdateScoreAsync(
entry.Id,
Arg.Is<double>(s => s >= 0.40),
Arg.Is<string>(r => r.Contains("deployment")),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task DeploymentCreated_WarmEntry_NoChange()
{
// Arrange
var entry = CreateEntry(0.55, "pkg:npm/vulnerable@1.0.0"); // WARM
_repository.GetByBomRefAsync("pkg:npm/vulnerable@1.0.0", Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<GreyQueueEntry>>([entry]));
var service = CreateService();
await StartServiceAsync(service);
// Act
await _eventSubscriber.PublishAsync(new DeploymentCreatedEvent
{
DeploymentId = "deploy-123",
AffectedComponents = ["pkg:npm/vulnerable@1.0.0"]
});
// Assert - Already WARM, no change
await _repository.DidNotReceive().UpdateScoreAsync(
Arg.Any<Guid>(),
Arg.Any<double>(),
Arg.Any<string>(),
Arg.Any<CancellationToken>());
}
#endregion
#region Expiry Tests
[Fact]
public async Task ExpiredEntries_AreMarkedExpired()
{
// Arrange
var expiredEntry = CreateEntry(0.20);
_repository.GetExpiredAsync(Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<GreyQueueEntry>>([expiredEntry]));
var service = CreateService();
// Act - Trigger expiry check
await TriggerExpiryCheck(service);
// Assert
await _repository.Received(1).UpdateStatusAsync(
expiredEntry.Id,
GreyQueueStatus.Expired,
Arg.Any<CancellationToken>());
}
#endregion
#region Demotion with Blocking Factors Tests
[Fact]
public async Task TryDemote_WithKevBlockingFactor_DoesNotDemote()
{
// Arrange
var entry = CreateEntry(0.75); // HOT
_repository.GetByIdAsync(entry.Id, Arg.Any<CancellationToken>())
.Returns(Task.FromResult<GreyQueueEntry?>(entry));
_repository.IsInKevAsync(entry.Id, Arg.Any<CancellationToken>())
.Returns(Task.FromResult(true)); // In KEV!
var service = CreateService();
// Act
await service.TryDemoteEntryAsync(entry.Id, CancellationToken.None);
// Assert - Should NOT demote
await _repository.DidNotReceive().UpdateScoreAsync(
Arg.Any<Guid>(),
Arg.Any<double>(),
Arg.Any<string>(),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task TryDemote_WithoutBlockingFactors_Demotes()
{
// Arrange
var entry = CreateEntry(0.75); // HOT
_repository.GetByIdAsync(entry.Id, Arg.Any<CancellationToken>())
.Returns(Task.FromResult<GreyQueueEntry?>(entry));
_repository.IsInKevAsync(entry.Id, Arg.Any<CancellationToken>())
.Returns(Task.FromResult(false));
var service = CreateService();
// Act
await service.TryDemoteEntryAsync(entry.Id, CancellationToken.None);
// Assert - Should demote to WARM
await _repository.Received(1).UpdateScoreAsync(
entry.Id,
Arg.Is<double>(s => s >= 0.40 && s < 0.70),
Arg.Any<string>(),
Arg.Any<CancellationToken>());
}
#endregion
#region Helpers
private UnknownsLifecycleService CreateService()
{
return new UnknownsLifecycleService(
_repository,
_eventSubscriber,
_notificationPublisher,
Options.Create(_options),
_timeProvider,
new NullLogger<UnknownsLifecycleService>());
}
private static async Task StartServiceAsync(UnknownsLifecycleService service)
{
// Start the service (which registers event handlers)
var cts = new CancellationTokenSource();
var task = service.StartAsync(cts.Token);
await Task.Delay(50); // Give it time to register handlers
cts.Cancel();
try { await task; } catch (OperationCanceledException) { }
}
private async Task TriggerExpiryCheck(UnknownsLifecycleService service)
{
var method = typeof(UnknownsLifecycleService)
.GetMethod("ProcessExpiredEntriesAsync", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
if (method != null)
{
var task = (Task?)method.Invoke(service, [CancellationToken.None]);
if (task != null) await task;
}
}
private static GreyQueueEntry CreateEntry(double score, string? bomRef = null)
{
return new GreyQueueEntry
{
Id = Guid.NewGuid(),
BomRef = bomRef ?? $"pkg:npm/test-{Guid.NewGuid():N}@1.0.0",
Score = score,
CreatedAt = DateTimeOffset.UtcNow
};
}
#endregion
}
/// <summary>
/// Test event subscriber that allows publishing test events.
/// </summary>
public sealed class TestEventSubscriber : IEventSubscriber
{
private readonly Dictionary<string, List<Delegate>> _handlers = new();
public void Subscribe<T>(string eventType, Func<T, CancellationToken, Task> handler)
{
if (!_handlers.ContainsKey(eventType))
{
_handlers[eventType] = [];
}
_handlers[eventType].Add(handler);
}
public async Task PublishAsync<T>(T evt)
{
var eventType = typeof(T).Name.Replace("Event", "").ToLowerInvariant();
var mappedType = eventType switch
{
"epssupdated" => "epss.updated",
"kevadded" => "kev.added",
"deploymentcreated" => "deployment.created",
"runtimeupdated" => "runtime.updated",
_ => eventType
};
if (_handlers.TryGetValue(mappedType, out var handlers))
{
foreach (var handler in handlers)
{
if (handler is Func<T, CancellationToken, Task> typedHandler)
{
await typedHandler(evt, CancellationToken.None);
}
}
}
}
}

View File

@@ -0,0 +1,269 @@
// -----------------------------------------------------------------------------
// UnknownsSlaMonitorIntegrationTests.cs
// Sprint: SPRINT_20260118_018_Unknowns_queue_enhancement
// Task: UQ-001 - Integration tests with test clock
// -----------------------------------------------------------------------------
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using NSubstitute;
using StellaOps.Unknowns.Services;
using Xunit;
namespace StellaOps.Unknowns.Core.Tests.Services;
public sealed class UnknownsSlaMonitorIntegrationTests : IAsyncDisposable
{
private readonly FakeTimeProvider _timeProvider;
private readonly IGreyQueueRepository _repository;
private readonly TestNotificationPublisher _notificationPublisher;
private readonly UnknownsMetrics _metrics;
private readonly UnknownsSlaOptions _options;
private readonly CancellationTokenSource _cts;
public UnknownsSlaMonitorIntegrationTests()
{
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 19, 12, 0, 0, TimeSpan.Zero));
_repository = Substitute.For<IGreyQueueRepository>();
_notificationPublisher = new TestNotificationPublisher();
_metrics = new UnknownsMetrics();
_options = new UnknownsSlaOptions
{
PollingInterval = TimeSpan.FromMilliseconds(100), // Fast for testing
HotSla = TimeSpan.FromHours(24),
WarmSla = TimeSpan.FromDays(7),
ColdSla = TimeSpan.FromDays(30),
WarningThreshold = 0.80
};
_cts = new CancellationTokenSource();
}
public async ValueTask DisposeAsync()
{
_cts.Cancel();
_cts.Dispose();
await Task.CompletedTask;
}
[Fact]
public async Task Monitor_WithNoEntries_DoesNotPublishNotifications()
{
// Arrange
_repository.GetPendingAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<GreyQueueEntry>>([]));
var service = CreateService();
// Act - Run one polling cycle
await RunOnePollingCycle(service);
// Assert
Assert.Empty(_notificationPublisher.Warnings);
Assert.Empty(_notificationPublisher.Breaches);
}
[Fact]
public async Task Monitor_WithHealthyEntry_DoesNotPublishNotifications()
{
// Arrange
var entry = CreateEntry(_timeProvider.GetUtcNow().AddHours(-6), 0.75); // 6h of 24h = 25%
_repository.GetPendingAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<GreyQueueEntry>>([entry]));
var service = CreateService();
// Act
await RunOnePollingCycle(service);
// Assert
Assert.Empty(_notificationPublisher.Warnings);
Assert.Empty(_notificationPublisher.Breaches);
}
[Fact]
public async Task Monitor_WithEntryAt80Percent_PublishesWarning()
{
// Arrange
var entry = CreateEntry(_timeProvider.GetUtcNow().AddHours(-19.2), 0.75); // 80% of 24h
_repository.GetPendingAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<GreyQueueEntry>>([entry]));
var service = CreateService();
// Act
await RunOnePollingCycle(service);
// Assert
Assert.Single(_notificationPublisher.Warnings);
Assert.Empty(_notificationPublisher.Breaches);
Assert.Equal(entry.Id, _notificationPublisher.Warnings[0].EntryId);
}
[Fact]
public async Task Monitor_WithBreachedEntry_PublishesBreach()
{
// Arrange
var entry = CreateEntry(_timeProvider.GetUtcNow().AddHours(-25), 0.75); // 25h of 24h = breached
_repository.GetPendingAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<GreyQueueEntry>>([entry]));
var service = CreateService();
// Act
await RunOnePollingCycle(service);
// Assert
Assert.Empty(_notificationPublisher.Warnings);
Assert.Single(_notificationPublisher.Breaches);
Assert.Equal(entry.Id, _notificationPublisher.Breaches[0].EntryId);
}
[Fact]
public async Task Monitor_TimeAdvances_EntryMovesToWarning()
{
// Arrange - Entry at 50%
var entry = CreateEntry(_timeProvider.GetUtcNow().AddHours(-12), 0.75);
_repository.GetPendingAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<GreyQueueEntry>>([entry]));
var service = CreateService();
// Act 1 - Check at 50%
await RunOnePollingCycle(service);
Assert.Empty(_notificationPublisher.Warnings);
// Act 2 - Advance time to 80%
_timeProvider.Advance(TimeSpan.FromHours(7.2)); // Now at 80%
await RunOnePollingCycle(service);
// Assert
Assert.Single(_notificationPublisher.Warnings);
}
[Fact]
public async Task Monitor_TimeAdvances_EntryMovesToBreach()
{
// Arrange - Entry at 90%
var entry = CreateEntry(_timeProvider.GetUtcNow().AddHours(-21.6), 0.75);
_repository.GetPendingAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<GreyQueueEntry>>([entry]));
var service = CreateService();
// Act 1 - Check at 90% (warning zone)
await RunOnePollingCycle(service);
Assert.Single(_notificationPublisher.Warnings);
Assert.Empty(_notificationPublisher.Breaches);
// Act 2 - Advance time past 100%
_notificationPublisher.Clear();
_timeProvider.Advance(TimeSpan.FromHours(3)); // Now at 102.5%
await RunOnePollingCycle(service);
// Assert
Assert.Empty(_notificationPublisher.Warnings);
Assert.Single(_notificationPublisher.Breaches);
}
[Fact]
public async Task Monitor_MultipleEntries_ClassifiesByBand()
{
// Arrange
var hotEntry = CreateEntry(_timeProvider.GetUtcNow().AddHours(-20), 0.75); // HOT at 83%
var warmEntry = CreateEntry(_timeProvider.GetUtcNow().AddDays(-6), 0.50); // WARM at 86%
var coldEntry = CreateEntry(_timeProvider.GetUtcNow().AddDays(-10), 0.20); // COLD at 33%
_repository.GetPendingAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<GreyQueueEntry>>([hotEntry, warmEntry, coldEntry]));
var service = CreateService();
// Act
await RunOnePollingCycle(service);
// Assert - HOT and WARM in warning, COLD is fine
Assert.Equal(2, _notificationPublisher.Warnings.Count);
Assert.Contains(_notificationPublisher.Warnings, w => w.EntryId == hotEntry.Id);
Assert.Contains(_notificationPublisher.Warnings, w => w.EntryId == warmEntry.Id);
}
#region Helpers
private UnknownsSlaMonitorService CreateService()
{
return new UnknownsSlaMonitorService(
_repository,
_notificationPublisher,
Options.Create(_options),
_timeProvider,
_metrics,
new NullLogger<UnknownsSlaMonitorService>());
}
private async Task RunOnePollingCycle(UnknownsSlaMonitorService service)
{
// Use reflection to call the private CheckSlaBoundsAsync method
var method = typeof(UnknownsSlaMonitorService)
.GetMethod("CheckSlaBoundsAsync", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
if (method != null)
{
var task = (Task?)method.Invoke(service, [CancellationToken.None]);
if (task != null) await task;
}
}
private static GreyQueueEntry CreateEntry(DateTimeOffset createdAt, double score)
{
return new GreyQueueEntry
{
Id = Guid.NewGuid(),
BomRef = $"pkg:npm/test-{Guid.NewGuid():N}@1.0.0",
Score = score,
CreatedAt = createdAt
};
}
#endregion
}
/// <summary>
/// Test notification publisher that captures notifications.
/// </summary>
public sealed class TestNotificationPublisher : INotificationPublisher
{
public List<SlaWarningNotification> Warnings { get; } = [];
public List<SlaBreachNotification> Breaches { get; } = [];
public Task PublishAsync<T>(T notification, CancellationToken ct = default)
{
switch (notification)
{
case SlaWarningNotification warning:
Warnings.Add(warning);
break;
case SlaBreachNotification breach:
Breaches.Add(breach);
break;
}
return Task.CompletedTask;
}
public void Clear()
{
Warnings.Clear();
Breaches.Clear();
}
}
/// <summary>
/// Null logger for testing.
/// </summary>
public sealed class NullLogger<T> : ILogger<T>
{
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => false;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter) { }
}

View File

@@ -0,0 +1,340 @@
// -----------------------------------------------------------------------------
// UnknownsSlaMonitorServiceTests.cs
// Sprint: SPRINT_20260118_018_Unknowns_queue_enhancement
// Task: UQ-001 - Unit tests for SLA calculation
// -----------------------------------------------------------------------------
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NSubstitute;
using StellaOps.Unknowns.Services;
using Xunit;
namespace StellaOps.Unknowns.Core.Tests.Services;
public sealed class UnknownsSlaMonitorServiceTests
{
private readonly IGreyQueueRepository _repository;
private readonly INotificationPublisher _notificationPublisher;
private readonly UnknownsMetrics _metrics;
private readonly FakeTimeProvider _timeProvider;
private readonly IOptions<UnknownsSlaOptions> _options;
public UnknownsSlaMonitorServiceTests()
{
_repository = Substitute.For<IGreyQueueRepository>();
_notificationPublisher = Substitute.For<INotificationPublisher>();
_metrics = new UnknownsMetrics();
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 19, 12, 0, 0, TimeSpan.Zero));
_options = Options.Create(new UnknownsSlaOptions
{
PollingInterval = TimeSpan.FromMinutes(5),
HotSla = TimeSpan.FromHours(24),
WarmSla = TimeSpan.FromDays(7),
ColdSla = TimeSpan.FromDays(30),
WarningThreshold = 0.80
});
}
#region Band Classification Tests
[Theory]
[InlineData(0.70, "hot")]
[InlineData(0.85, "hot")]
[InlineData(1.00, "hot")]
[InlineData(0.40, "warm")]
[InlineData(0.55, "warm")]
[InlineData(0.69, "warm")]
[InlineData(0.00, "cold")]
[InlineData(0.20, "cold")]
[InlineData(0.39, "cold")]
public void GetBand_ReturnsCorrectBand(double score, string expectedBand)
{
// Arrange & Act
var band = SlaCalculator.GetBand(score);
// Assert
Assert.Equal(expectedBand, band.ToString().ToLowerInvariant());
}
#endregion
#region SLA Calculation Tests
[Fact]
public void CalculateSlaRemaining_HotBand_Returns24Hours()
{
// Arrange
var createdAt = _timeProvider.GetUtcNow();
var entry = CreateEntry(createdAt, 0.75);
// Act
var remaining = SlaCalculator.CalculateRemaining(entry, _timeProvider.GetUtcNow(), _options.Value);
// Assert
Assert.Equal(TimeSpan.FromHours(24), remaining);
}
[Fact]
public void CalculateSlaRemaining_HotBand_After12Hours_Returns12Hours()
{
// Arrange
var createdAt = _timeProvider.GetUtcNow().AddHours(-12);
var entry = CreateEntry(createdAt, 0.75);
// Act
var remaining = SlaCalculator.CalculateRemaining(entry, _timeProvider.GetUtcNow(), _options.Value);
// Assert
Assert.Equal(TimeSpan.FromHours(12), remaining);
}
[Fact]
public void CalculateSlaRemaining_WarmBand_Returns7Days()
{
// Arrange
var createdAt = _timeProvider.GetUtcNow();
var entry = CreateEntry(createdAt, 0.50);
// Act
var remaining = SlaCalculator.CalculateRemaining(entry, _timeProvider.GetUtcNow(), _options.Value);
// Assert
Assert.Equal(TimeSpan.FromDays(7), remaining);
}
[Fact]
public void CalculateSlaRemaining_ColdBand_Returns30Days()
{
// Arrange
var createdAt = _timeProvider.GetUtcNow();
var entry = CreateEntry(createdAt, 0.20);
// Act
var remaining = SlaCalculator.CalculateRemaining(entry, _timeProvider.GetUtcNow(), _options.Value);
// Assert
Assert.Equal(TimeSpan.FromDays(30), remaining);
}
[Fact]
public void CalculateSlaRemaining_Breached_ReturnsNegative()
{
// Arrange
var createdAt = _timeProvider.GetUtcNow().AddHours(-25); // 25 hours ago
var entry = CreateEntry(createdAt, 0.75); // HOT band (24h SLA)
// Act
var remaining = SlaCalculator.CalculateRemaining(entry, _timeProvider.GetUtcNow(), _options.Value);
// Assert
Assert.True(remaining < TimeSpan.Zero);
Assert.Equal(TimeSpan.FromHours(-1), remaining);
}
#endregion
#region Percentage Elapsed Tests
[Theory]
[InlineData(0, 0.0)]
[InlineData(12, 0.5)]
[InlineData(19.2, 0.8)] // 80% warning threshold
[InlineData(24, 1.0)]
[InlineData(48, 2.0)]
public void CalculatePercentElapsed_HotBand_ReturnsCorrectPercentage(double hoursElapsed, double expectedPercent)
{
// Arrange
var createdAt = _timeProvider.GetUtcNow().AddHours(-hoursElapsed);
var entry = CreateEntry(createdAt, 0.75);
// Act
var percent = SlaCalculator.CalculatePercentElapsed(entry, _timeProvider.GetUtcNow(), _options.Value);
// Assert
Assert.Equal(expectedPercent, percent, precision: 2);
}
#endregion
#region Warning Threshold Tests
[Fact]
public void IsInWarningZone_At80Percent_ReturnsTrue()
{
// Arrange
var createdAt = _timeProvider.GetUtcNow().AddHours(-19.2); // 80% of 24h
var entry = CreateEntry(createdAt, 0.75);
// Act
var isWarning = SlaCalculator.IsInWarningZone(entry, _timeProvider.GetUtcNow(), _options.Value);
// Assert
Assert.True(isWarning);
}
[Fact]
public void IsInWarningZone_At50Percent_ReturnsFalse()
{
// Arrange
var createdAt = _timeProvider.GetUtcNow().AddHours(-12); // 50% of 24h
var entry = CreateEntry(createdAt, 0.75);
// Act
var isWarning = SlaCalculator.IsInWarningZone(entry, _timeProvider.GetUtcNow(), _options.Value);
// Assert
Assert.False(isWarning);
}
[Fact]
public void IsInWarningZone_At100Percent_ReturnsFalse_BecauseBreached()
{
// Arrange - Breached is not warning, it's breach
var createdAt = _timeProvider.GetUtcNow().AddHours(-25);
var entry = CreateEntry(createdAt, 0.75);
// Act
var isWarning = SlaCalculator.IsInWarningZone(entry, _timeProvider.GetUtcNow(), _options.Value);
// Assert
Assert.False(isWarning); // Past warning, now breached
}
#endregion
#region Breach Detection Tests
[Fact]
public void IsBreached_BeforeSla_ReturnsFalse()
{
// Arrange
var createdAt = _timeProvider.GetUtcNow().AddHours(-12);
var entry = CreateEntry(createdAt, 0.75);
// Act
var isBreached = SlaCalculator.IsBreached(entry, _timeProvider.GetUtcNow(), _options.Value);
// Assert
Assert.False(isBreached);
}
[Fact]
public void IsBreached_ExactlyAtSla_ReturnsTrue()
{
// Arrange
var createdAt = _timeProvider.GetUtcNow().AddHours(-24);
var entry = CreateEntry(createdAt, 0.75);
// Act
var isBreached = SlaCalculator.IsBreached(entry, _timeProvider.GetUtcNow(), _options.Value);
// Assert
Assert.True(isBreached);
}
[Fact]
public void IsBreached_AfterSla_ReturnsTrue()
{
// Arrange
var createdAt = _timeProvider.GetUtcNow().AddHours(-48);
var entry = CreateEntry(createdAt, 0.75);
// Act
var isBreached = SlaCalculator.IsBreached(entry, _timeProvider.GetUtcNow(), _options.Value);
// Assert
Assert.True(isBreached);
}
#endregion
#region Helpers
private static GreyQueueEntry CreateEntry(DateTimeOffset createdAt, double score)
{
return new GreyQueueEntry
{
Id = Guid.NewGuid(),
BomRef = "pkg:npm/test@1.0.0",
Score = score,
CreatedAt = createdAt
};
}
#endregion
}
/// <summary>
/// SLA calculation helper for testability.
/// </summary>
public static class SlaCalculator
{
public static UnknownsBand GetBand(double score)
{
return score switch
{
>= 0.70 => UnknownsBand.Hot,
>= 0.40 => UnknownsBand.Warm,
_ => UnknownsBand.Cold
};
}
public static TimeSpan GetSlaLimit(UnknownsBand band, UnknownsSlaOptions options)
{
return band switch
{
UnknownsBand.Hot => options.HotSla,
UnknownsBand.Warm => options.WarmSla,
UnknownsBand.Cold => options.ColdSla,
_ => options.ColdSla
};
}
public static TimeSpan CalculateRemaining(GreyQueueEntry entry, DateTimeOffset now, UnknownsSlaOptions options)
{
var band = GetBand(entry.Score);
var slaLimit = GetSlaLimit(band, options);
var elapsed = now - entry.CreatedAt;
return slaLimit - elapsed;
}
public static double CalculatePercentElapsed(GreyQueueEntry entry, DateTimeOffset now, UnknownsSlaOptions options)
{
var band = GetBand(entry.Score);
var slaLimit = GetSlaLimit(band, options);
var elapsed = now - entry.CreatedAt;
return elapsed / slaLimit;
}
public static bool IsInWarningZone(GreyQueueEntry entry, DateTimeOffset now, UnknownsSlaOptions options)
{
var percent = CalculatePercentElapsed(entry, now, options);
return percent >= options.WarningThreshold && percent < 1.0;
}
public static bool IsBreached(GreyQueueEntry entry, DateTimeOffset now, UnknownsSlaOptions options)
{
var percent = CalculatePercentElapsed(entry, now, options);
return percent >= 1.0;
}
}
/// <summary>
/// Fake time provider for testing.
/// </summary>
public sealed class FakeTimeProvider : TimeProvider
{
private DateTimeOffset _now;
public FakeTimeProvider(DateTimeOffset initialTime)
{
_now = initialTime;
}
public override DateTimeOffset GetUtcNow() => _now;
public void Advance(TimeSpan duration) => _now = _now.Add(duration);
public void SetTime(DateTimeOffset time) => _now = time;
}