Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
Policy Simulation / policy-simulate (push) Has been cancelled
sdk-generator-smoke / sdk-smoke (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
297 lines
11 KiB
C#
297 lines
11 KiB
C#
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Options;
|
|
using StellaOps.Notifier.Worker.Correlation;
|
|
using StellaOps.Notifier.Worker.Digest;
|
|
using Xunit;
|
|
|
|
namespace StellaOps.Notifier.Tests.Digest;
|
|
|
|
public sealed class DigestGeneratorTests
|
|
{
|
|
private readonly InMemoryIncidentManager _incidentManager;
|
|
private readonly DigestGenerator _generator;
|
|
private readonly FakeTimeProvider _timeProvider;
|
|
|
|
public DigestGeneratorTests()
|
|
{
|
|
_timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-27T12:00:00Z"));
|
|
|
|
var incidentOptions = Options.Create(new IncidentManagerOptions
|
|
{
|
|
CorrelationWindow = TimeSpan.FromHours(1),
|
|
ReopenOnNewEvent = true
|
|
});
|
|
|
|
_incidentManager = new InMemoryIncidentManager(
|
|
incidentOptions,
|
|
_timeProvider,
|
|
new NullLogger<InMemoryIncidentManager>());
|
|
|
|
var digestOptions = Options.Create(new DigestOptions
|
|
{
|
|
MaxIncidentsPerDigest = 50,
|
|
TopAffectedCount = 5,
|
|
RenderContent = true,
|
|
RenderSlackBlocks = true,
|
|
SkipEmptyDigests = true
|
|
});
|
|
|
|
_generator = new DigestGenerator(
|
|
_incidentManager,
|
|
digestOptions,
|
|
_timeProvider,
|
|
new NullLogger<DigestGenerator>());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GenerateAsync_EmptyTenant_ReturnsEmptyDigest()
|
|
{
|
|
// Arrange
|
|
var query = DigestQuery.LastHours(24, _timeProvider.GetUtcNow());
|
|
|
|
// Act
|
|
var result = await _generator.GenerateAsync("tenant-1", query);
|
|
|
|
// Assert
|
|
Assert.NotNull(result);
|
|
Assert.Equal("tenant-1", result.TenantId);
|
|
Assert.Empty(result.Incidents);
|
|
Assert.Equal(0, result.Summary.TotalEvents);
|
|
Assert.Equal(0, result.Summary.NewIncidents);
|
|
Assert.False(result.Summary.HasActivity);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GenerateAsync_WithIncidents_ReturnsSummary()
|
|
{
|
|
// Arrange
|
|
var incident = await _incidentManager.GetOrCreateIncidentAsync(
|
|
"tenant-1", "vuln:critical:pkg-foo", "vulnerability.detected", "Critical vulnerability in pkg-foo");
|
|
await _incidentManager.RecordEventAsync("tenant-1", incident.IncidentId, "evt-1");
|
|
await _incidentManager.RecordEventAsync("tenant-1", incident.IncidentId, "evt-2");
|
|
|
|
var query = DigestQuery.LastHours(24, _timeProvider.GetUtcNow());
|
|
|
|
// Act
|
|
var result = await _generator.GenerateAsync("tenant-1", query);
|
|
|
|
// Assert
|
|
Assert.Single(result.Incidents);
|
|
Assert.Equal(2, result.Summary.TotalEvents);
|
|
Assert.Equal(1, result.Summary.NewIncidents);
|
|
Assert.Equal(1, result.Summary.OpenIncidents);
|
|
Assert.True(result.Summary.HasActivity);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GenerateAsync_MultipleIncidents_GroupsByEventKind()
|
|
{
|
|
// Arrange
|
|
var inc1 = await _incidentManager.GetOrCreateIncidentAsync(
|
|
"tenant-1", "key1", "vulnerability.detected", "Vuln 1");
|
|
await _incidentManager.RecordEventAsync("tenant-1", inc1.IncidentId, "evt-1");
|
|
|
|
var inc2 = await _incidentManager.GetOrCreateIncidentAsync(
|
|
"tenant-1", "key2", "vulnerability.detected", "Vuln 2");
|
|
await _incidentManager.RecordEventAsync("tenant-1", inc2.IncidentId, "evt-2");
|
|
|
|
var inc3 = await _incidentManager.GetOrCreateIncidentAsync(
|
|
"tenant-1", "key3", "pack.approval.required", "Approval needed");
|
|
await _incidentManager.RecordEventAsync("tenant-1", inc3.IncidentId, "evt-3");
|
|
|
|
var query = DigestQuery.LastHours(24, _timeProvider.GetUtcNow());
|
|
|
|
// Act
|
|
var result = await _generator.GenerateAsync("tenant-1", query);
|
|
|
|
// Assert
|
|
Assert.Equal(3, result.Incidents.Count);
|
|
Assert.Equal(3, result.Summary.TotalEvents);
|
|
Assert.Contains("vulnerability.detected", result.Summary.ByEventKind.Keys);
|
|
Assert.Contains("pack.approval.required", result.Summary.ByEventKind.Keys);
|
|
Assert.Equal(2, result.Summary.ByEventKind["vulnerability.detected"]);
|
|
Assert.Equal(1, result.Summary.ByEventKind["pack.approval.required"]);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GenerateAsync_RendersContent()
|
|
{
|
|
// Arrange
|
|
var incident = await _incidentManager.GetOrCreateIncidentAsync(
|
|
"tenant-1", "key", "vulnerability.detected", "Critical issue");
|
|
await _incidentManager.RecordEventAsync("tenant-1", incident.IncidentId, "evt-1");
|
|
|
|
var query = DigestQuery.LastHours(24, _timeProvider.GetUtcNow());
|
|
|
|
// Act
|
|
var result = await _generator.GenerateAsync("tenant-1", query);
|
|
|
|
// Assert
|
|
Assert.NotNull(result.Content);
|
|
Assert.NotEmpty(result.Content.PlainText!);
|
|
Assert.NotEmpty(result.Content.Markdown!);
|
|
Assert.NotEmpty(result.Content.Html!);
|
|
Assert.NotEmpty(result.Content.Json!);
|
|
Assert.NotEmpty(result.Content.SlackBlocks!);
|
|
|
|
Assert.Contains("Notification Digest", result.Content.PlainText);
|
|
Assert.Contains("tenant-1", result.Content.PlainText);
|
|
Assert.Contains("Critical issue", result.Content.PlainText);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GenerateAsync_RespectsMaxIncidents()
|
|
{
|
|
// Arrange
|
|
for (var i = 0; i < 10; i++)
|
|
{
|
|
var inc = await _incidentManager.GetOrCreateIncidentAsync(
|
|
"tenant-1", $"key-{i}", "test.event", $"Test incident {i}");
|
|
await _incidentManager.RecordEventAsync("tenant-1", inc.IncidentId, $"evt-{i}");
|
|
}
|
|
|
|
var query = new DigestQuery
|
|
{
|
|
From = _timeProvider.GetUtcNow().AddDays(-1),
|
|
To = _timeProvider.GetUtcNow(),
|
|
MaxIncidents = 5
|
|
};
|
|
|
|
// Act
|
|
var result = await _generator.GenerateAsync("tenant-1", query);
|
|
|
|
// Assert
|
|
Assert.Equal(5, result.Incidents.Count);
|
|
Assert.Equal(10, result.TotalIncidentCount);
|
|
Assert.True(result.HasMore);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GenerateAsync_FiltersResolvedIncidents()
|
|
{
|
|
// Arrange
|
|
var openInc = await _incidentManager.GetOrCreateIncidentAsync(
|
|
"tenant-1", "key-open", "test.event", "Open incident");
|
|
await _incidentManager.RecordEventAsync("tenant-1", openInc.IncidentId, "evt-1");
|
|
|
|
var resolvedInc = await _incidentManager.GetOrCreateIncidentAsync(
|
|
"tenant-1", "key-resolved", "test.event", "Resolved incident");
|
|
await _incidentManager.RecordEventAsync("tenant-1", resolvedInc.IncidentId, "evt-2");
|
|
await _incidentManager.ResolveAsync("tenant-1", resolvedInc.IncidentId, "system", "Auto-resolved");
|
|
|
|
var queryExcludeResolved = new DigestQuery
|
|
{
|
|
From = _timeProvider.GetUtcNow().AddDays(-1),
|
|
To = _timeProvider.GetUtcNow(),
|
|
IncludeResolved = false
|
|
};
|
|
|
|
var queryIncludeResolved = new DigestQuery
|
|
{
|
|
From = _timeProvider.GetUtcNow().AddDays(-1),
|
|
To = _timeProvider.GetUtcNow(),
|
|
IncludeResolved = true
|
|
};
|
|
|
|
// Act
|
|
var resultExclude = await _generator.GenerateAsync("tenant-1", queryExcludeResolved);
|
|
var resultInclude = await _generator.GenerateAsync("tenant-1", queryIncludeResolved);
|
|
|
|
// Assert
|
|
Assert.Single(resultExclude.Incidents);
|
|
Assert.Equal("Open incident", resultExclude.Incidents[0].Title);
|
|
|
|
Assert.Equal(2, resultInclude.Incidents.Count);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GenerateAsync_FiltersEventKinds()
|
|
{
|
|
// Arrange
|
|
var vulnInc = await _incidentManager.GetOrCreateIncidentAsync(
|
|
"tenant-1", "key-vuln", "vulnerability.detected", "Vulnerability");
|
|
await _incidentManager.RecordEventAsync("tenant-1", vulnInc.IncidentId, "evt-1");
|
|
|
|
var approvalInc = await _incidentManager.GetOrCreateIncidentAsync(
|
|
"tenant-1", "key-approval", "pack.approval.required", "Approval");
|
|
await _incidentManager.RecordEventAsync("tenant-1", approvalInc.IncidentId, "evt-2");
|
|
|
|
var query = new DigestQuery
|
|
{
|
|
From = _timeProvider.GetUtcNow().AddDays(-1),
|
|
To = _timeProvider.GetUtcNow(),
|
|
EventKinds = ["vulnerability.detected"]
|
|
};
|
|
|
|
// Act
|
|
var result = await _generator.GenerateAsync("tenant-1", query);
|
|
|
|
// Assert
|
|
Assert.Single(result.Incidents);
|
|
Assert.Equal("vulnerability.detected", result.Incidents[0].EventKind);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PreviewAsync_SetsIsPreviewFlag()
|
|
{
|
|
// Arrange
|
|
var incident = await _incidentManager.GetOrCreateIncidentAsync(
|
|
"tenant-1", "key", "test.event", "Test");
|
|
await _incidentManager.RecordEventAsync("tenant-1", incident.IncidentId, "evt-1");
|
|
|
|
var query = DigestQuery.LastHours(24, _timeProvider.GetUtcNow());
|
|
|
|
// Act
|
|
var result = await _generator.PreviewAsync("tenant-1", query);
|
|
|
|
// Assert
|
|
Assert.True(result.IsPreview);
|
|
}
|
|
|
|
[Fact]
|
|
public void DigestQuery_LastHours_CalculatesCorrectWindow()
|
|
{
|
|
// Arrange
|
|
var asOf = DateTimeOffset.Parse("2025-11-27T12:00:00Z");
|
|
|
|
// Act
|
|
var query = DigestQuery.LastHours(6, asOf);
|
|
|
|
// Assert
|
|
Assert.Equal(DateTimeOffset.Parse("2025-11-27T06:00:00Z"), query.From);
|
|
Assert.Equal(asOf, query.To);
|
|
}
|
|
|
|
[Fact]
|
|
public void DigestQuery_LastDays_CalculatesCorrectWindow()
|
|
{
|
|
// Arrange
|
|
var asOf = DateTimeOffset.Parse("2025-11-27T12:00:00Z");
|
|
|
|
// Act
|
|
var query = DigestQuery.LastDays(7, asOf);
|
|
|
|
// Assert
|
|
Assert.Equal(DateTimeOffset.Parse("2025-11-20T12:00:00Z"), query.From);
|
|
Assert.Equal(asOf, query.To);
|
|
}
|
|
|
|
private sealed class FakeTimeProvider : TimeProvider
|
|
{
|
|
private DateTimeOffset _utcNow;
|
|
|
|
public FakeTimeProvider(DateTimeOffset utcNow) => _utcNow = utcNow;
|
|
|
|
public override DateTimeOffset GetUtcNow() => _utcNow;
|
|
|
|
public void Advance(TimeSpan duration) => _utcNow = _utcNow.Add(duration);
|
|
}
|
|
|
|
private 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) { }
|
|
}
|
|
}
|