up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Policy Simulation / policy-simulate (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Policy Simulation / policy-simulate (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,251 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Net;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notifier.Worker.Channels;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Notifier.Tests.Channels;
|
||||
|
||||
public sealed class WebhookChannelAdapterTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task DispatchAsync_SuccessfulDelivery_ReturnsSuccess()
|
||||
{
|
||||
// Arrange
|
||||
var handler = new MockHttpMessageHandler(HttpStatusCode.OK, "ok");
|
||||
var httpClient = new HttpClient(handler);
|
||||
var auditRepo = new InMemoryAuditRepository();
|
||||
var options = Options.Create(new ChannelAdapterOptions());
|
||||
var adapter = new WebhookChannelAdapter(
|
||||
httpClient,
|
||||
auditRepo,
|
||||
options,
|
||||
TimeProvider.System,
|
||||
NullLogger<WebhookChannelAdapter>.Instance);
|
||||
|
||||
var channel = CreateChannel("https://example.com/webhook");
|
||||
var context = CreateContext(channel);
|
||||
|
||||
// Act
|
||||
var result = await adapter.DispatchAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal(ChannelDispatchStatus.Sent, result.Status);
|
||||
Assert.Single(handler.Requests);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DispatchAsync_InvalidEndpoint_ReturnsInvalidConfiguration()
|
||||
{
|
||||
// Arrange
|
||||
var handler = new MockHttpMessageHandler(HttpStatusCode.OK, "ok");
|
||||
var httpClient = new HttpClient(handler);
|
||||
var auditRepo = new InMemoryAuditRepository();
|
||||
var options = Options.Create(new ChannelAdapterOptions());
|
||||
var adapter = new WebhookChannelAdapter(
|
||||
httpClient,
|
||||
auditRepo,
|
||||
options,
|
||||
TimeProvider.System,
|
||||
NullLogger<WebhookChannelAdapter>.Instance);
|
||||
|
||||
var channel = CreateChannel(null);
|
||||
var context = CreateContext(channel);
|
||||
|
||||
// Act
|
||||
var result = await adapter.DispatchAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal(ChannelDispatchStatus.InvalidConfiguration, result.Status);
|
||||
Assert.Empty(handler.Requests);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DispatchAsync_RateLimited_ReturnsThrottled()
|
||||
{
|
||||
// Arrange
|
||||
var handler = new MockHttpMessageHandler(HttpStatusCode.TooManyRequests, "rate limited");
|
||||
var httpClient = new HttpClient(handler);
|
||||
var auditRepo = new InMemoryAuditRepository();
|
||||
var options = Options.Create(new ChannelAdapterOptions { MaxRetries = 0 });
|
||||
var adapter = new WebhookChannelAdapter(
|
||||
httpClient,
|
||||
auditRepo,
|
||||
options,
|
||||
TimeProvider.System,
|
||||
NullLogger<WebhookChannelAdapter>.Instance);
|
||||
|
||||
var channel = CreateChannel("https://example.com/webhook");
|
||||
var context = CreateContext(channel);
|
||||
|
||||
// Act
|
||||
var result = await adapter.DispatchAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal(ChannelDispatchStatus.Throttled, result.Status);
|
||||
Assert.Equal(429, result.HttpStatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DispatchAsync_ServerError_RetriesAndFails()
|
||||
{
|
||||
// Arrange
|
||||
var handler = new MockHttpMessageHandler(HttpStatusCode.ServiceUnavailable, "unavailable");
|
||||
var httpClient = new HttpClient(handler);
|
||||
var auditRepo = new InMemoryAuditRepository();
|
||||
var options = Options.Create(new ChannelAdapterOptions
|
||||
{
|
||||
MaxRetries = 2,
|
||||
RetryBaseDelay = TimeSpan.FromMilliseconds(10),
|
||||
RetryMaxDelay = TimeSpan.FromMilliseconds(50)
|
||||
});
|
||||
var adapter = new WebhookChannelAdapter(
|
||||
httpClient,
|
||||
auditRepo,
|
||||
options,
|
||||
TimeProvider.System,
|
||||
NullLogger<WebhookChannelAdapter>.Instance);
|
||||
|
||||
var channel = CreateChannel("https://example.com/webhook");
|
||||
var context = CreateContext(channel);
|
||||
|
||||
// Act
|
||||
var result = await adapter.DispatchAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal(3, handler.Requests.Count); // Initial + 2 retries
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckHealthAsync_ValidEndpoint_ReturnsHealthy()
|
||||
{
|
||||
// Arrange
|
||||
var handler = new MockHttpMessageHandler(HttpStatusCode.OK, "ok");
|
||||
var httpClient = new HttpClient(handler);
|
||||
var auditRepo = new InMemoryAuditRepository();
|
||||
var options = Options.Create(new ChannelAdapterOptions());
|
||||
var adapter = new WebhookChannelAdapter(
|
||||
httpClient,
|
||||
auditRepo,
|
||||
options,
|
||||
TimeProvider.System,
|
||||
NullLogger<WebhookChannelAdapter>.Instance);
|
||||
|
||||
var channel = CreateChannel("https://example.com/webhook");
|
||||
|
||||
// Act
|
||||
var result = await adapter.CheckHealthAsync(channel, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Healthy);
|
||||
Assert.Equal("healthy", result.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckHealthAsync_DisabledChannel_ReturnsDegraded()
|
||||
{
|
||||
// Arrange
|
||||
var handler = new MockHttpMessageHandler(HttpStatusCode.OK, "ok");
|
||||
var httpClient = new HttpClient(handler);
|
||||
var auditRepo = new InMemoryAuditRepository();
|
||||
var options = Options.Create(new ChannelAdapterOptions());
|
||||
var adapter = new WebhookChannelAdapter(
|
||||
httpClient,
|
||||
auditRepo,
|
||||
options,
|
||||
TimeProvider.System,
|
||||
NullLogger<WebhookChannelAdapter>.Instance);
|
||||
|
||||
var channel = CreateChannel("https://example.com/webhook", enabled: false);
|
||||
|
||||
// Act
|
||||
var result = await adapter.CheckHealthAsync(channel, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Healthy);
|
||||
Assert.Equal("degraded", result.Status);
|
||||
}
|
||||
|
||||
private static NotifyChannel CreateChannel(string? endpoint, bool enabled = true)
|
||||
{
|
||||
return NotifyChannel.Create(
|
||||
channelId: "test-channel",
|
||||
tenantId: "test-tenant",
|
||||
name: "Test Webhook",
|
||||
type: NotifyChannelType.Webhook,
|
||||
config: NotifyChannelConfig.Create(
|
||||
secretRef: "secret://test",
|
||||
endpoint: endpoint),
|
||||
enabled: enabled);
|
||||
}
|
||||
|
||||
private static ChannelDispatchContext CreateContext(NotifyChannel channel)
|
||||
{
|
||||
var delivery = NotifyDelivery.Create(
|
||||
deliveryId: "delivery-001",
|
||||
tenantId: channel.TenantId,
|
||||
ruleId: "rule-001",
|
||||
actionId: "action-001",
|
||||
eventId: "event-001",
|
||||
kind: "test",
|
||||
status: NotifyDeliveryStatus.Pending);
|
||||
|
||||
return new ChannelDispatchContext(
|
||||
DeliveryId: delivery.DeliveryId,
|
||||
TenantId: channel.TenantId,
|
||||
Channel: channel,
|
||||
Delivery: delivery,
|
||||
RenderedBody: """{"message": "test notification"}""",
|
||||
Subject: "Test Subject",
|
||||
Metadata: new Dictionary<string, string>(),
|
||||
Timestamp: DateTimeOffset.UtcNow,
|
||||
TraceId: "trace-001");
|
||||
}
|
||||
|
||||
private sealed class MockHttpMessageHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly HttpStatusCode _statusCode;
|
||||
private readonly string _content;
|
||||
public List<HttpRequestMessage> Requests { get; } = [];
|
||||
|
||||
public MockHttpMessageHandler(HttpStatusCode statusCode, string content)
|
||||
{
|
||||
_statusCode = statusCode;
|
||||
_content = content;
|
||||
}
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Requests.Add(request);
|
||||
var response = new HttpResponseMessage(_statusCode)
|
||||
{
|
||||
Content = new StringContent(_content)
|
||||
};
|
||||
return Task.FromResult(response);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class InMemoryAuditRepository : StellaOps.Notify.Storage.Mongo.Repositories.INotifyAuditRepository
|
||||
{
|
||||
public List<(string TenantId, string EventType, string Actor, IReadOnlyDictionary<string, string> Metadata)> Entries { get; } = [];
|
||||
|
||||
public Task AppendAsync(
|
||||
string tenantId,
|
||||
string eventType,
|
||||
string actor,
|
||||
IReadOnlyDictionary<string, string> metadata,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Entries.Add((tenantId, eventType, actor, metadata));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,445 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notifier.Worker.Correlation;
|
||||
|
||||
namespace StellaOps.Notifier.Tests.Correlation;
|
||||
|
||||
public class CorrelationEngineTests
|
||||
{
|
||||
private readonly Mock<ICorrelationKeyBuilderFactory> _keyBuilderFactory;
|
||||
private readonly Mock<ICorrelationKeyBuilder> _keyBuilder;
|
||||
private readonly Mock<IIncidentManager> _incidentManager;
|
||||
private readonly Mock<INotifyThrottler> _throttler;
|
||||
private readonly Mock<IQuietHoursEvaluator> _quietHoursEvaluator;
|
||||
private readonly CorrelationEngineOptions _options;
|
||||
private readonly CorrelationEngine _engine;
|
||||
|
||||
public CorrelationEngineTests()
|
||||
{
|
||||
_keyBuilderFactory = new Mock<ICorrelationKeyBuilderFactory>();
|
||||
_keyBuilder = new Mock<ICorrelationKeyBuilder>();
|
||||
_incidentManager = new Mock<IIncidentManager>();
|
||||
_throttler = new Mock<INotifyThrottler>();
|
||||
_quietHoursEvaluator = new Mock<IQuietHoursEvaluator>();
|
||||
_options = new CorrelationEngineOptions();
|
||||
|
||||
_keyBuilderFactory
|
||||
.Setup(f => f.GetBuilder(It.IsAny<string>()))
|
||||
.Returns(_keyBuilder.Object);
|
||||
|
||||
_keyBuilder
|
||||
.Setup(b => b.BuildKey(It.IsAny<NotifyEvent>(), It.IsAny<CorrelationKeyExpression>()))
|
||||
.Returns("test-correlation-key");
|
||||
|
||||
_keyBuilder.SetupGet(b => b.Name).Returns("composite");
|
||||
|
||||
_engine = new CorrelationEngine(
|
||||
_keyBuilderFactory.Object,
|
||||
_incidentManager.Object,
|
||||
_throttler.Object,
|
||||
_quietHoursEvaluator.Object,
|
||||
Options.Create(_options),
|
||||
NullLogger<CorrelationEngine>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CorrelateAsync_NewIncident_ReturnsNewIncidentResult()
|
||||
{
|
||||
// Arrange
|
||||
var notifyEvent = CreateTestEvent();
|
||||
var incident = CreateTestIncident(eventCount: 0);
|
||||
|
||||
_incidentManager
|
||||
.Setup(m => m.GetOrCreateIncidentAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(incident);
|
||||
|
||||
_incidentManager
|
||||
.Setup(m => m.RecordEventAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(incident with { EventCount = 1 });
|
||||
|
||||
_quietHoursEvaluator
|
||||
.Setup(e => e.EvaluateAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(SuppressionCheckResult.NotSuppressed());
|
||||
|
||||
_throttler
|
||||
.Setup(t => t.CheckAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<TimeSpan?>(), It.IsAny<int?>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(ThrottleCheckResult.NotThrottled());
|
||||
|
||||
// Act
|
||||
var result = await _engine.CorrelateAsync(notifyEvent);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Correlated);
|
||||
Assert.True(result.IsNewIncident);
|
||||
Assert.True(result.ShouldNotify);
|
||||
Assert.Equal("inc-test123", result.IncidentId);
|
||||
Assert.Equal("test-correlation-key", result.CorrelationKey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CorrelateAsync_ExistingIncident_FirstOnlyPolicy_DoesNotNotify()
|
||||
{
|
||||
// Arrange
|
||||
_options.NotificationPolicy = NotificationPolicy.FirstOnly;
|
||||
var notifyEvent = CreateTestEvent();
|
||||
var incident = CreateTestIncident(eventCount: 5);
|
||||
|
||||
_incidentManager
|
||||
.Setup(m => m.GetOrCreateIncidentAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(incident);
|
||||
|
||||
_incidentManager
|
||||
.Setup(m => m.RecordEventAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(incident with { EventCount = 6 });
|
||||
|
||||
_quietHoursEvaluator
|
||||
.Setup(e => e.EvaluateAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(SuppressionCheckResult.NotSuppressed());
|
||||
|
||||
_throttler
|
||||
.Setup(t => t.CheckAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<TimeSpan?>(), It.IsAny<int?>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(ThrottleCheckResult.NotThrottled());
|
||||
|
||||
// Act
|
||||
var result = await _engine.CorrelateAsync(notifyEvent);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsNewIncident);
|
||||
Assert.False(result.ShouldNotify);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CorrelateAsync_ExistingIncident_EveryEventPolicy_Notifies()
|
||||
{
|
||||
// Arrange
|
||||
_options.NotificationPolicy = NotificationPolicy.EveryEvent;
|
||||
var notifyEvent = CreateTestEvent();
|
||||
var incident = CreateTestIncident(eventCount: 5);
|
||||
|
||||
_incidentManager
|
||||
.Setup(m => m.GetOrCreateIncidentAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(incident);
|
||||
|
||||
_incidentManager
|
||||
.Setup(m => m.RecordEventAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(incident with { EventCount = 6 });
|
||||
|
||||
_quietHoursEvaluator
|
||||
.Setup(e => e.EvaluateAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(SuppressionCheckResult.NotSuppressed());
|
||||
|
||||
_throttler
|
||||
.Setup(t => t.CheckAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<TimeSpan?>(), It.IsAny<int?>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(ThrottleCheckResult.NotThrottled());
|
||||
|
||||
// Act
|
||||
var result = await _engine.CorrelateAsync(notifyEvent);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsNewIncident);
|
||||
Assert.True(result.ShouldNotify);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CorrelateAsync_Suppressed_DoesNotNotify()
|
||||
{
|
||||
// Arrange
|
||||
var notifyEvent = CreateTestEvent();
|
||||
var incident = CreateTestIncident(eventCount: 0);
|
||||
|
||||
_incidentManager
|
||||
.Setup(m => m.GetOrCreateIncidentAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(incident);
|
||||
|
||||
_incidentManager
|
||||
.Setup(m => m.RecordEventAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(incident with { EventCount = 1 });
|
||||
|
||||
_quietHoursEvaluator
|
||||
.Setup(e => e.EvaluateAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(SuppressionCheckResult.Suppressed("Quiet hours", "quiet_hours"));
|
||||
|
||||
// Act
|
||||
var result = await _engine.CorrelateAsync(notifyEvent);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.ShouldNotify);
|
||||
Assert.Equal("Quiet hours", result.SuppressionReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CorrelateAsync_Throttled_DoesNotNotify()
|
||||
{
|
||||
// Arrange
|
||||
var notifyEvent = CreateTestEvent();
|
||||
var incident = CreateTestIncident(eventCount: 0);
|
||||
|
||||
_incidentManager
|
||||
.Setup(m => m.GetOrCreateIncidentAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(incident);
|
||||
|
||||
_incidentManager
|
||||
.Setup(m => m.RecordEventAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(incident with { EventCount = 1 });
|
||||
|
||||
_quietHoursEvaluator
|
||||
.Setup(e => e.EvaluateAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(SuppressionCheckResult.NotSuppressed());
|
||||
|
||||
_throttler
|
||||
.Setup(t => t.CheckAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<TimeSpan?>(), It.IsAny<int?>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(ThrottleCheckResult.Throttled(15));
|
||||
|
||||
// Act
|
||||
var result = await _engine.CorrelateAsync(notifyEvent);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.ShouldNotify);
|
||||
Assert.Contains("Throttled", result.SuppressionReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CorrelateAsync_UsesEventKindSpecificKeyExpression()
|
||||
{
|
||||
// Arrange
|
||||
var customExpression = new CorrelationKeyExpression
|
||||
{
|
||||
Type = "template",
|
||||
Template = "{{tenant}}-{{kind}}"
|
||||
};
|
||||
_options.KeyExpressions["security.alert"] = customExpression;
|
||||
|
||||
var notifyEvent = CreateTestEvent("security.alert");
|
||||
var incident = CreateTestIncident(eventCount: 0);
|
||||
|
||||
_incidentManager
|
||||
.Setup(m => m.GetOrCreateIncidentAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(incident);
|
||||
|
||||
_incidentManager
|
||||
.Setup(m => m.RecordEventAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(incident with { EventCount = 1 });
|
||||
|
||||
_quietHoursEvaluator
|
||||
.Setup(e => e.EvaluateAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(SuppressionCheckResult.NotSuppressed());
|
||||
|
||||
_throttler
|
||||
.Setup(t => t.CheckAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<TimeSpan?>(), It.IsAny<int?>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(ThrottleCheckResult.NotThrottled());
|
||||
|
||||
// Act
|
||||
await _engine.CorrelateAsync(notifyEvent);
|
||||
|
||||
// Assert
|
||||
_keyBuilderFactory.Verify(f => f.GetBuilder("template"), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CorrelateAsync_UsesWildcardKeyExpression()
|
||||
{
|
||||
// Arrange
|
||||
var customExpression = new CorrelationKeyExpression
|
||||
{
|
||||
Type = "custom",
|
||||
Fields = ["source"]
|
||||
};
|
||||
_options.KeyExpressions["security.*"] = customExpression;
|
||||
|
||||
var notifyEvent = CreateTestEvent("security.vulnerability");
|
||||
var incident = CreateTestIncident(eventCount: 0);
|
||||
|
||||
_incidentManager
|
||||
.Setup(m => m.GetOrCreateIncidentAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(incident);
|
||||
|
||||
_incidentManager
|
||||
.Setup(m => m.RecordEventAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(incident with { EventCount = 1 });
|
||||
|
||||
_quietHoursEvaluator
|
||||
.Setup(e => e.EvaluateAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(SuppressionCheckResult.NotSuppressed());
|
||||
|
||||
_throttler
|
||||
.Setup(t => t.CheckAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<TimeSpan?>(), It.IsAny<int?>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(ThrottleCheckResult.NotThrottled());
|
||||
|
||||
// Act
|
||||
await _engine.CorrelateAsync(notifyEvent);
|
||||
|
||||
// Assert
|
||||
_keyBuilderFactory.Verify(f => f.GetBuilder("custom"), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CorrelateAsync_OnEscalationPolicy_NotifiesAtThreshold()
|
||||
{
|
||||
// Arrange
|
||||
_options.NotificationPolicy = NotificationPolicy.OnEscalation;
|
||||
var notifyEvent = CreateTestEvent();
|
||||
var incident = CreateTestIncident(eventCount: 4); // Will become 5 after record
|
||||
|
||||
_incidentManager
|
||||
.Setup(m => m.GetOrCreateIncidentAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(incident);
|
||||
|
||||
_incidentManager
|
||||
.Setup(m => m.RecordEventAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(incident with { EventCount = 5 });
|
||||
|
||||
_quietHoursEvaluator
|
||||
.Setup(e => e.EvaluateAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(SuppressionCheckResult.NotSuppressed());
|
||||
|
||||
_throttler
|
||||
.Setup(t => t.CheckAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<TimeSpan?>(), It.IsAny<int?>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(ThrottleCheckResult.NotThrottled());
|
||||
|
||||
// Act
|
||||
var result = await _engine.CorrelateAsync(notifyEvent);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.ShouldNotify);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CorrelateAsync_OnEscalationPolicy_NotifiesOnCriticalSeverity()
|
||||
{
|
||||
// Arrange
|
||||
_options.NotificationPolicy = NotificationPolicy.OnEscalation;
|
||||
var payload = new JsonObject { ["severity"] = "CRITICAL" };
|
||||
var notifyEvent = CreateTestEvent(payload: payload);
|
||||
var incident = CreateTestIncident(eventCount: 2);
|
||||
|
||||
_incidentManager
|
||||
.Setup(m => m.GetOrCreateIncidentAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(incident);
|
||||
|
||||
_incidentManager
|
||||
.Setup(m => m.RecordEventAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(incident with { EventCount = 3 });
|
||||
|
||||
_quietHoursEvaluator
|
||||
.Setup(e => e.EvaluateAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(SuppressionCheckResult.NotSuppressed());
|
||||
|
||||
_throttler
|
||||
.Setup(t => t.CheckAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<TimeSpan?>(), It.IsAny<int?>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(ThrottleCheckResult.NotThrottled());
|
||||
|
||||
// Act
|
||||
var result = await _engine.CorrelateAsync(notifyEvent);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.ShouldNotify);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CorrelateAsync_PeriodicPolicy_NotifiesAtInterval()
|
||||
{
|
||||
// Arrange
|
||||
_options.NotificationPolicy = NotificationPolicy.Periodic;
|
||||
_options.PeriodicNotificationInterval = 5;
|
||||
var notifyEvent = CreateTestEvent();
|
||||
var incident = CreateTestIncident(eventCount: 9);
|
||||
|
||||
_incidentManager
|
||||
.Setup(m => m.GetOrCreateIncidentAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(incident);
|
||||
|
||||
_incidentManager
|
||||
.Setup(m => m.RecordEventAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(incident with { EventCount = 10 });
|
||||
|
||||
_quietHoursEvaluator
|
||||
.Setup(e => e.EvaluateAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(SuppressionCheckResult.NotSuppressed());
|
||||
|
||||
_throttler
|
||||
.Setup(t => t.CheckAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<TimeSpan?>(), It.IsAny<int?>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(ThrottleCheckResult.NotThrottled());
|
||||
|
||||
// Act
|
||||
var result = await _engine.CorrelateAsync(notifyEvent);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.ShouldNotify);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckThrottleAsync_ThrottlingDisabled_ReturnsNotThrottled()
|
||||
{
|
||||
// Arrange
|
||||
_options.ThrottlingEnabled = false;
|
||||
|
||||
// Act
|
||||
var result = await _engine.CheckThrottleAsync("tenant1", "key1", null);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsThrottled);
|
||||
_throttler.Verify(
|
||||
t => t.CheckAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<TimeSpan?>(), It.IsAny<int?>(), It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
private static NotifyEvent CreateTestEvent(string? kind = null, JsonObject? payload = null)
|
||||
{
|
||||
return new NotifyEvent
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
Tenant = "tenant1",
|
||||
Kind = kind ?? "test.event",
|
||||
Payload = payload ?? new JsonObject(),
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
private static IncidentState CreateTestIncident(int eventCount)
|
||||
{
|
||||
return new IncidentState
|
||||
{
|
||||
IncidentId = "inc-test123",
|
||||
TenantId = "tenant1",
|
||||
CorrelationKey = "test-correlation-key",
|
||||
EventKind = "test.event",
|
||||
Title = "Test Incident",
|
||||
Status = IncidentStatus.Open,
|
||||
EventCount = eventCount,
|
||||
FirstOccurrence = DateTimeOffset.UtcNow.AddHours(-1),
|
||||
LastOccurrence = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notifier.Worker.Correlation;
|
||||
|
||||
namespace StellaOps.Notifier.Tests.Correlation;
|
||||
|
||||
public class CompositeCorrelationKeyBuilderTests
|
||||
{
|
||||
private readonly CompositeCorrelationKeyBuilder _builder = new();
|
||||
|
||||
[Fact]
|
||||
public void Name_ReturnsComposite()
|
||||
{
|
||||
Assert.Equal("composite", _builder.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanHandle_CompositeType_ReturnsTrue()
|
||||
{
|
||||
Assert.True(_builder.CanHandle("composite"));
|
||||
Assert.True(_builder.CanHandle("COMPOSITE"));
|
||||
Assert.True(_builder.CanHandle("Composite"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanHandle_OtherType_ReturnsFalse()
|
||||
{
|
||||
Assert.False(_builder.CanHandle("template"));
|
||||
Assert.False(_builder.CanHandle("jsonpath"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildKey_TenantAndKindOnly_BuildsCorrectKey()
|
||||
{
|
||||
// Arrange
|
||||
var notifyEvent = CreateTestEvent("tenant1", "security.alert");
|
||||
var expression = new CorrelationKeyExpression
|
||||
{
|
||||
Type = "composite",
|
||||
IncludeTenant = true,
|
||||
IncludeEventKind = true
|
||||
};
|
||||
|
||||
// Act
|
||||
var key1 = _builder.BuildKey(notifyEvent, expression);
|
||||
var key2 = _builder.BuildKey(notifyEvent, expression);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(key1);
|
||||
Assert.Equal(16, key1.Length); // SHA256 hash truncated to 16 chars
|
||||
Assert.Equal(key1, key2); // Same input should produce same key
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildKey_DifferentTenants_ProducesDifferentKeys()
|
||||
{
|
||||
// Arrange
|
||||
var event1 = CreateTestEvent("tenant1", "security.alert");
|
||||
var event2 = CreateTestEvent("tenant2", "security.alert");
|
||||
var expression = CorrelationKeyExpression.Default;
|
||||
|
||||
// Act
|
||||
var key1 = _builder.BuildKey(event1, expression);
|
||||
var key2 = _builder.BuildKey(event2, expression);
|
||||
|
||||
// Assert
|
||||
Assert.NotEqual(key1, key2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildKey_DifferentKinds_ProducesDifferentKeys()
|
||||
{
|
||||
// Arrange
|
||||
var event1 = CreateTestEvent("tenant1", "security.alert");
|
||||
var event2 = CreateTestEvent("tenant1", "security.warning");
|
||||
var expression = CorrelationKeyExpression.Default;
|
||||
|
||||
// Act
|
||||
var key1 = _builder.BuildKey(event1, expression);
|
||||
var key2 = _builder.BuildKey(event2, expression);
|
||||
|
||||
// Assert
|
||||
Assert.NotEqual(key1, key2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildKey_WithPayloadFields_IncludesFieldValues()
|
||||
{
|
||||
// Arrange
|
||||
var payload1 = new JsonObject { ["source"] = "scanner-1" };
|
||||
var payload2 = new JsonObject { ["source"] = "scanner-2" };
|
||||
var event1 = CreateTestEvent("tenant1", "security.alert", payload1);
|
||||
var event2 = CreateTestEvent("tenant1", "security.alert", payload2);
|
||||
|
||||
var expression = new CorrelationKeyExpression
|
||||
{
|
||||
Type = "composite",
|
||||
IncludeTenant = true,
|
||||
IncludeEventKind = true,
|
||||
Fields = ["source"]
|
||||
};
|
||||
|
||||
// Act
|
||||
var key1 = _builder.BuildKey(event1, expression);
|
||||
var key2 = _builder.BuildKey(event2, expression);
|
||||
|
||||
// Assert
|
||||
Assert.NotEqual(key1, key2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildKey_WithNestedPayloadField_ExtractsValue()
|
||||
{
|
||||
// Arrange
|
||||
var payload = new JsonObject
|
||||
{
|
||||
["resource"] = new JsonObject { ["id"] = "resource-123" }
|
||||
};
|
||||
var notifyEvent = CreateTestEvent("tenant1", "test.event", payload);
|
||||
|
||||
var expression = new CorrelationKeyExpression
|
||||
{
|
||||
Type = "composite",
|
||||
IncludeTenant = true,
|
||||
Fields = ["resource.id"]
|
||||
};
|
||||
|
||||
// Act
|
||||
var key1 = _builder.BuildKey(notifyEvent, expression);
|
||||
|
||||
// Different resource ID
|
||||
payload["resource"]!["id"] = "resource-456";
|
||||
var key2 = _builder.BuildKey(notifyEvent, expression);
|
||||
|
||||
// Assert
|
||||
Assert.NotEqual(key1, key2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildKey_MissingPayloadField_IgnoresField()
|
||||
{
|
||||
// Arrange
|
||||
var payload = new JsonObject { ["existing"] = "value" };
|
||||
var notifyEvent = CreateTestEvent("tenant1", "test.event", payload);
|
||||
|
||||
var expression = new CorrelationKeyExpression
|
||||
{
|
||||
Type = "composite",
|
||||
IncludeTenant = true,
|
||||
Fields = ["nonexistent", "existing"]
|
||||
};
|
||||
|
||||
// Act - should not throw
|
||||
var key = _builder.BuildKey(notifyEvent, expression);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(key);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildKey_ExcludeTenant_DoesNotIncludeTenant()
|
||||
{
|
||||
// Arrange
|
||||
var event1 = CreateTestEvent("tenant1", "test.event");
|
||||
var event2 = CreateTestEvent("tenant2", "test.event");
|
||||
|
||||
var expression = new CorrelationKeyExpression
|
||||
{
|
||||
Type = "composite",
|
||||
IncludeTenant = false,
|
||||
IncludeEventKind = true
|
||||
};
|
||||
|
||||
// Act
|
||||
var key1 = _builder.BuildKey(event1, expression);
|
||||
var key2 = _builder.BuildKey(event2, expression);
|
||||
|
||||
// Assert - keys should be the same since tenant is excluded
|
||||
Assert.Equal(key1, key2);
|
||||
}
|
||||
|
||||
private static NotifyEvent CreateTestEvent(string tenant, string kind, JsonObject? payload = null)
|
||||
{
|
||||
return new NotifyEvent
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
Tenant = tenant,
|
||||
Kind = kind,
|
||||
Payload = payload ?? new JsonObject(),
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public class TemplateCorrelationKeyBuilderTests
|
||||
{
|
||||
private readonly TemplateCorrelationKeyBuilder _builder = new();
|
||||
|
||||
[Fact]
|
||||
public void Name_ReturnsTemplate()
|
||||
{
|
||||
Assert.Equal("template", _builder.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanHandle_TemplateType_ReturnsTrue()
|
||||
{
|
||||
Assert.True(_builder.CanHandle("template"));
|
||||
Assert.True(_builder.CanHandle("TEMPLATE"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildKey_SimpleTemplate_SubstitutesVariables()
|
||||
{
|
||||
// Arrange
|
||||
var notifyEvent = CreateTestEvent("tenant1", "security.alert");
|
||||
var expression = new CorrelationKeyExpression
|
||||
{
|
||||
Type = "template",
|
||||
Template = "{{tenant}}-{{kind}}",
|
||||
IncludeTenant = false
|
||||
};
|
||||
|
||||
// Act
|
||||
var key = _builder.BuildKey(notifyEvent, expression);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(key);
|
||||
Assert.Equal(16, key.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildKey_WithPayloadVariables_SubstitutesValues()
|
||||
{
|
||||
// Arrange
|
||||
var payload = new JsonObject { ["region"] = "us-east-1" };
|
||||
var notifyEvent = CreateTestEvent("tenant1", "test.event", payload);
|
||||
|
||||
var expression = new CorrelationKeyExpression
|
||||
{
|
||||
Type = "template",
|
||||
Template = "{{kind}}-{{region}}",
|
||||
IncludeTenant = false
|
||||
};
|
||||
|
||||
// Act
|
||||
var key1 = _builder.BuildKey(notifyEvent, expression);
|
||||
|
||||
payload["region"] = "eu-west-1";
|
||||
var key2 = _builder.BuildKey(notifyEvent, expression);
|
||||
|
||||
// Assert
|
||||
Assert.NotEqual(key1, key2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildKey_WithAttributeVariables_SubstitutesValues()
|
||||
{
|
||||
// Arrange
|
||||
var notifyEvent = new NotifyEvent
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
Tenant = "tenant1",
|
||||
Kind = "test.event",
|
||||
Payload = new JsonObject(),
|
||||
Timestamp = DateTimeOffset.UtcNow,
|
||||
Attributes = new Dictionary<string, string>
|
||||
{
|
||||
["env"] = "production"
|
||||
}
|
||||
};
|
||||
|
||||
var expression = new CorrelationKeyExpression
|
||||
{
|
||||
Type = "template",
|
||||
Template = "{{kind}}-{{attr.env}}",
|
||||
IncludeTenant = false
|
||||
};
|
||||
|
||||
// Act
|
||||
var key = _builder.BuildKey(notifyEvent, expression);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(key);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildKey_IncludeTenant_PrependsTenantToKey()
|
||||
{
|
||||
// Arrange
|
||||
var event1 = CreateTestEvent("tenant1", "test.event");
|
||||
var event2 = CreateTestEvent("tenant2", "test.event");
|
||||
|
||||
var expression = new CorrelationKeyExpression
|
||||
{
|
||||
Type = "template",
|
||||
Template = "{{kind}}",
|
||||
IncludeTenant = true
|
||||
};
|
||||
|
||||
// Act
|
||||
var key1 = _builder.BuildKey(event1, expression);
|
||||
var key2 = _builder.BuildKey(event2, expression);
|
||||
|
||||
// Assert
|
||||
Assert.NotEqual(key1, key2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildKey_NoTemplate_ThrowsException()
|
||||
{
|
||||
// Arrange
|
||||
var notifyEvent = CreateTestEvent("tenant1", "test.event");
|
||||
var expression = new CorrelationKeyExpression
|
||||
{
|
||||
Type = "template",
|
||||
Template = null
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentException>(() => _builder.BuildKey(notifyEvent, expression));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildKey_EmptyTemplate_ThrowsException()
|
||||
{
|
||||
// Arrange
|
||||
var notifyEvent = CreateTestEvent("tenant1", "test.event");
|
||||
var expression = new CorrelationKeyExpression
|
||||
{
|
||||
Type = "template",
|
||||
Template = " "
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentException>(() => _builder.BuildKey(notifyEvent, expression));
|
||||
}
|
||||
|
||||
private static NotifyEvent CreateTestEvent(string tenant, string kind, JsonObject? payload = null)
|
||||
{
|
||||
return new NotifyEvent
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
Tenant = tenant,
|
||||
Kind = kind,
|
||||
Payload = payload ?? new JsonObject(),
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public class CorrelationKeyBuilderFactoryTests
|
||||
{
|
||||
[Fact]
|
||||
public void GetBuilder_KnownType_ReturnsCorrectBuilder()
|
||||
{
|
||||
// Arrange
|
||||
var builders = new ICorrelationKeyBuilder[]
|
||||
{
|
||||
new CompositeCorrelationKeyBuilder(),
|
||||
new TemplateCorrelationKeyBuilder()
|
||||
};
|
||||
var factory = new CorrelationKeyBuilderFactory(builders);
|
||||
|
||||
// Act
|
||||
var compositeBuilder = factory.GetBuilder("composite");
|
||||
var templateBuilder = factory.GetBuilder("template");
|
||||
|
||||
// Assert
|
||||
Assert.IsType<CompositeCorrelationKeyBuilder>(compositeBuilder);
|
||||
Assert.IsType<TemplateCorrelationKeyBuilder>(templateBuilder);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetBuilder_UnknownType_ReturnsDefaultBuilder()
|
||||
{
|
||||
// Arrange
|
||||
var builders = new ICorrelationKeyBuilder[]
|
||||
{
|
||||
new CompositeCorrelationKeyBuilder(),
|
||||
new TemplateCorrelationKeyBuilder()
|
||||
};
|
||||
var factory = new CorrelationKeyBuilderFactory(builders);
|
||||
|
||||
// Act
|
||||
var builder = factory.GetBuilder("unknown");
|
||||
|
||||
// Assert
|
||||
Assert.IsType<CompositeCorrelationKeyBuilder>(builder);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetBuilder_CaseInsensitive_ReturnsCorrectBuilder()
|
||||
{
|
||||
// Arrange
|
||||
var builders = new ICorrelationKeyBuilder[]
|
||||
{
|
||||
new CompositeCorrelationKeyBuilder(),
|
||||
new TemplateCorrelationKeyBuilder()
|
||||
};
|
||||
var factory = new CorrelationKeyBuilderFactory(builders);
|
||||
|
||||
// Act
|
||||
var builder1 = factory.GetBuilder("COMPOSITE");
|
||||
var builder2 = factory.GetBuilder("Template");
|
||||
|
||||
// Assert
|
||||
Assert.IsType<CompositeCorrelationKeyBuilder>(builder1);
|
||||
Assert.IsType<TemplateCorrelationKeyBuilder>(builder2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Notifier.Worker.Correlation;
|
||||
|
||||
namespace StellaOps.Notifier.Tests.Correlation;
|
||||
|
||||
public class InMemoryIncidentManagerTests
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly IncidentManagerOptions _options;
|
||||
private readonly InMemoryIncidentManager _manager;
|
||||
|
||||
public InMemoryIncidentManagerTests()
|
||||
{
|
||||
_timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow);
|
||||
_options = new IncidentManagerOptions
|
||||
{
|
||||
CorrelationWindow = TimeSpan.FromHours(1),
|
||||
ReopenOnNewEvent = true
|
||||
};
|
||||
_manager = new InMemoryIncidentManager(
|
||||
Options.Create(_options),
|
||||
_timeProvider,
|
||||
NullLogger<InMemoryIncidentManager>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetOrCreateIncidentAsync_CreatesNewIncident()
|
||||
{
|
||||
// Act
|
||||
var incident = await _manager.GetOrCreateIncidentAsync(
|
||||
"tenant1", "correlation-key", "security.alert", "Test Alert");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(incident);
|
||||
Assert.StartsWith("inc-", incident.IncidentId);
|
||||
Assert.Equal("tenant1", incident.TenantId);
|
||||
Assert.Equal("correlation-key", incident.CorrelationKey);
|
||||
Assert.Equal("security.alert", incident.EventKind);
|
||||
Assert.Equal("Test Alert", incident.Title);
|
||||
Assert.Equal(IncidentStatus.Open, incident.Status);
|
||||
Assert.Equal(0, incident.EventCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetOrCreateIncidentAsync_ReturnsSameIncidentWithinWindow()
|
||||
{
|
||||
// Arrange
|
||||
var incident1 = await _manager.GetOrCreateIncidentAsync(
|
||||
"tenant1", "correlation-key", "security.alert", "Test Alert");
|
||||
|
||||
// Act - request again within correlation window
|
||||
_timeProvider.Advance(TimeSpan.FromMinutes(30));
|
||||
var incident2 = await _manager.GetOrCreateIncidentAsync(
|
||||
"tenant1", "correlation-key", "security.alert", "Test Alert");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(incident1.IncidentId, incident2.IncidentId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetOrCreateIncidentAsync_CreatesNewIncidentOutsideWindow()
|
||||
{
|
||||
// Arrange
|
||||
var incident1 = await _manager.GetOrCreateIncidentAsync(
|
||||
"tenant1", "correlation-key", "security.alert", "Test Alert");
|
||||
|
||||
// Record an event to set LastOccurrence
|
||||
await _manager.RecordEventAsync("tenant1", incident1.IncidentId, "event-1");
|
||||
|
||||
// Act - request again outside correlation window
|
||||
_timeProvider.Advance(TimeSpan.FromHours(2));
|
||||
var incident2 = await _manager.GetOrCreateIncidentAsync(
|
||||
"tenant1", "correlation-key", "security.alert", "Test Alert");
|
||||
|
||||
// Assert
|
||||
Assert.NotEqual(incident1.IncidentId, incident2.IncidentId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetOrCreateIncidentAsync_CreatesNewIncidentAfterResolution()
|
||||
{
|
||||
// Arrange
|
||||
var incident1 = await _manager.GetOrCreateIncidentAsync(
|
||||
"tenant1", "correlation-key", "security.alert", "Test Alert");
|
||||
|
||||
await _manager.ResolveAsync("tenant1", incident1.IncidentId, "operator");
|
||||
|
||||
// Act - request again after resolution
|
||||
var incident2 = await _manager.GetOrCreateIncidentAsync(
|
||||
"tenant1", "correlation-key", "security.alert", "Test Alert");
|
||||
|
||||
// Assert
|
||||
Assert.NotEqual(incident1.IncidentId, incident2.IncidentId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RecordEventAsync_IncrementsEventCount()
|
||||
{
|
||||
// Arrange
|
||||
var incident = await _manager.GetOrCreateIncidentAsync(
|
||||
"tenant1", "correlation-key", "security.alert", "Test Alert");
|
||||
|
||||
// Act
|
||||
var updated = await _manager.RecordEventAsync("tenant1", incident.IncidentId, "event-1");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(1, updated.EventCount);
|
||||
Assert.Contains("event-1", updated.EventIds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RecordEventAsync_UpdatesLastOccurrence()
|
||||
{
|
||||
// Arrange
|
||||
var incident = await _manager.GetOrCreateIncidentAsync(
|
||||
"tenant1", "correlation-key", "security.alert", "Test Alert");
|
||||
|
||||
var initialTime = incident.LastOccurrence;
|
||||
|
||||
// Act
|
||||
_timeProvider.Advance(TimeSpan.FromMinutes(10));
|
||||
var updated = await _manager.RecordEventAsync("tenant1", incident.IncidentId, "event-1");
|
||||
|
||||
// Assert
|
||||
Assert.True(updated.LastOccurrence > initialTime);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RecordEventAsync_ReopensAcknowledgedIncident_WhenConfigured()
|
||||
{
|
||||
// Arrange
|
||||
_options.ReopenOnNewEvent = true;
|
||||
var incident = await _manager.GetOrCreateIncidentAsync(
|
||||
"tenant1", "correlation-key", "security.alert", "Test Alert");
|
||||
|
||||
await _manager.AcknowledgeAsync("tenant1", incident.IncidentId, "operator");
|
||||
|
||||
// Act
|
||||
var updated = await _manager.RecordEventAsync("tenant1", incident.IncidentId, "event-1");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(IncidentStatus.Open, updated.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RecordEventAsync_ThrowsForUnknownIncident()
|
||||
{
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => _manager.RecordEventAsync("tenant1", "unknown-id", "event-1"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AcknowledgeAsync_SetsAcknowledgedStatus()
|
||||
{
|
||||
// Arrange
|
||||
var incident = await _manager.GetOrCreateIncidentAsync(
|
||||
"tenant1", "correlation-key", "security.alert", "Test Alert");
|
||||
|
||||
// Act
|
||||
var acknowledged = await _manager.AcknowledgeAsync(
|
||||
"tenant1", incident.IncidentId, "operator", "Looking into it");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(acknowledged);
|
||||
Assert.Equal(IncidentStatus.Acknowledged, acknowledged.Status);
|
||||
Assert.Equal("operator", acknowledged.AcknowledgedBy);
|
||||
Assert.NotNull(acknowledged.AcknowledgedAt);
|
||||
Assert.Equal("Looking into it", acknowledged.AcknowledgeComment);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AcknowledgeAsync_ReturnsNullForUnknownIncident()
|
||||
{
|
||||
// Act
|
||||
var result = await _manager.AcknowledgeAsync("tenant1", "unknown-id", "operator");
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AcknowledgeAsync_ReturnsNullForWrongTenant()
|
||||
{
|
||||
// Arrange
|
||||
var incident = await _manager.GetOrCreateIncidentAsync(
|
||||
"tenant1", "correlation-key", "security.alert", "Test Alert");
|
||||
|
||||
// Act
|
||||
var result = await _manager.AcknowledgeAsync("tenant2", incident.IncidentId, "operator");
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AcknowledgeAsync_DoesNotChangeResolvedIncident()
|
||||
{
|
||||
// Arrange
|
||||
var incident = await _manager.GetOrCreateIncidentAsync(
|
||||
"tenant1", "correlation-key", "security.alert", "Test Alert");
|
||||
|
||||
await _manager.ResolveAsync("tenant1", incident.IncidentId, "operator");
|
||||
|
||||
// Act
|
||||
var result = await _manager.AcknowledgeAsync("tenant1", incident.IncidentId, "operator2");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(IncidentStatus.Resolved, result.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_SetsResolvedStatus()
|
||||
{
|
||||
// Arrange
|
||||
var incident = await _manager.GetOrCreateIncidentAsync(
|
||||
"tenant1", "correlation-key", "security.alert", "Test Alert");
|
||||
|
||||
// Act
|
||||
var resolved = await _manager.ResolveAsync(
|
||||
"tenant1", incident.IncidentId, "operator", "Issue fixed");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(resolved);
|
||||
Assert.Equal(IncidentStatus.Resolved, resolved.Status);
|
||||
Assert.Equal("operator", resolved.ResolvedBy);
|
||||
Assert.NotNull(resolved.ResolvedAt);
|
||||
Assert.Equal("Issue fixed", resolved.ResolutionReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_ReturnsNullForUnknownIncident()
|
||||
{
|
||||
// Act
|
||||
var result = await _manager.ResolveAsync("tenant1", "unknown-id", "operator");
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAsync_ReturnsIncident()
|
||||
{
|
||||
// Arrange
|
||||
var created = await _manager.GetOrCreateIncidentAsync(
|
||||
"tenant1", "correlation-key", "security.alert", "Test Alert");
|
||||
|
||||
// Act
|
||||
var result = await _manager.GetAsync("tenant1", created.IncidentId);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(created.IncidentId, result.IncidentId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAsync_ReturnsNullForUnknownIncident()
|
||||
{
|
||||
// Act
|
||||
var result = await _manager.GetAsync("tenant1", "unknown-id");
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAsync_ReturnsNullForWrongTenant()
|
||||
{
|
||||
// Arrange
|
||||
var created = await _manager.GetOrCreateIncidentAsync(
|
||||
"tenant1", "correlation-key", "security.alert", "Test Alert");
|
||||
|
||||
// Act
|
||||
var result = await _manager.GetAsync("tenant2", created.IncidentId);
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListAsync_ReturnsIncidentsForTenant()
|
||||
{
|
||||
// Arrange
|
||||
await _manager.GetOrCreateIncidentAsync("tenant1", "key1", "event1", "Alert 1");
|
||||
await _manager.GetOrCreateIncidentAsync("tenant1", "key2", "event2", "Alert 2");
|
||||
await _manager.GetOrCreateIncidentAsync("tenant2", "key3", "event3", "Alert 3");
|
||||
|
||||
// Act
|
||||
var result = await _manager.ListAsync("tenant1");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, result.Count);
|
||||
Assert.All(result, i => Assert.Equal("tenant1", i.TenantId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListAsync_FiltersbyStatus()
|
||||
{
|
||||
// Arrange
|
||||
var inc1 = await _manager.GetOrCreateIncidentAsync("tenant1", "key1", "event1", "Alert 1");
|
||||
var inc2 = await _manager.GetOrCreateIncidentAsync("tenant1", "key2", "event2", "Alert 2");
|
||||
await _manager.AcknowledgeAsync("tenant1", inc1.IncidentId, "operator");
|
||||
await _manager.ResolveAsync("tenant1", inc2.IncidentId, "operator");
|
||||
|
||||
// Act
|
||||
var openIncidents = await _manager.ListAsync("tenant1", IncidentStatus.Open);
|
||||
var acknowledgedIncidents = await _manager.ListAsync("tenant1", IncidentStatus.Acknowledged);
|
||||
var resolvedIncidents = await _manager.ListAsync("tenant1", IncidentStatus.Resolved);
|
||||
|
||||
// Assert
|
||||
Assert.Empty(openIncidents);
|
||||
Assert.Single(acknowledgedIncidents);
|
||||
Assert.Single(resolvedIncidents);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListAsync_OrdersByLastOccurrenceDescending()
|
||||
{
|
||||
// Arrange
|
||||
var inc1 = await _manager.GetOrCreateIncidentAsync("tenant1", "key1", "event1", "Alert 1");
|
||||
await _manager.RecordEventAsync("tenant1", inc1.IncidentId, "e1");
|
||||
|
||||
_timeProvider.Advance(TimeSpan.FromMinutes(1));
|
||||
|
||||
var inc2 = await _manager.GetOrCreateIncidentAsync("tenant1", "key2", "event2", "Alert 2");
|
||||
await _manager.RecordEventAsync("tenant1", inc2.IncidentId, "e2");
|
||||
|
||||
_timeProvider.Advance(TimeSpan.FromMinutes(1));
|
||||
|
||||
var inc3 = await _manager.GetOrCreateIncidentAsync("tenant1", "key3", "event3", "Alert 3");
|
||||
await _manager.RecordEventAsync("tenant1", inc3.IncidentId, "e3");
|
||||
|
||||
// Act
|
||||
var result = await _manager.ListAsync("tenant1");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(3, result.Count);
|
||||
Assert.Equal(inc3.IncidentId, result[0].IncidentId);
|
||||
Assert.Equal(inc2.IncidentId, result[1].IncidentId);
|
||||
Assert.Equal(inc1.IncidentId, result[2].IncidentId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListAsync_RespectsLimit()
|
||||
{
|
||||
// Arrange
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
await _manager.GetOrCreateIncidentAsync("tenant1", $"key{i}", $"event{i}", $"Alert {i}");
|
||||
}
|
||||
|
||||
// Act
|
||||
var result = await _manager.ListAsync("tenant1", limit: 5);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(5, result.Count);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Notifier.Worker.Correlation;
|
||||
|
||||
namespace StellaOps.Notifier.Tests.Correlation;
|
||||
|
||||
public class InMemoryNotifyThrottlerTests
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly ThrottlerOptions _options;
|
||||
private readonly InMemoryNotifyThrottler _throttler;
|
||||
|
||||
public InMemoryNotifyThrottlerTests()
|
||||
{
|
||||
_timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow);
|
||||
_options = new ThrottlerOptions
|
||||
{
|
||||
DefaultWindow = TimeSpan.FromMinutes(5),
|
||||
DefaultMaxEvents = 10,
|
||||
Enabled = true
|
||||
};
|
||||
_throttler = new InMemoryNotifyThrottler(
|
||||
Options.Create(_options),
|
||||
_timeProvider,
|
||||
NullLogger<InMemoryNotifyThrottler>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RecordEventAsync_AddsEventToState()
|
||||
{
|
||||
// Act
|
||||
await _throttler.RecordEventAsync("tenant1", "key1");
|
||||
var result = await _throttler.CheckAsync("tenant1", "key1", null, null);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsThrottled);
|
||||
Assert.Equal(1, result.RecentEventCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckAsync_NoEvents_ReturnsNotThrottled()
|
||||
{
|
||||
// Act
|
||||
var result = await _throttler.CheckAsync("tenant1", "key1", null, null);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsThrottled);
|
||||
Assert.Equal(0, result.RecentEventCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckAsync_BelowThreshold_ReturnsNotThrottled()
|
||||
{
|
||||
// Arrange
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
await _throttler.RecordEventAsync("tenant1", "key1");
|
||||
}
|
||||
|
||||
// Act
|
||||
var result = await _throttler.CheckAsync("tenant1", "key1", null, null);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsThrottled);
|
||||
Assert.Equal(5, result.RecentEventCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckAsync_AtThreshold_ReturnsThrottled()
|
||||
{
|
||||
// Arrange
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
await _throttler.RecordEventAsync("tenant1", "key1");
|
||||
}
|
||||
|
||||
// Act
|
||||
var result = await _throttler.CheckAsync("tenant1", "key1", null, null);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsThrottled);
|
||||
Assert.Equal(10, result.RecentEventCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckAsync_AboveThreshold_ReturnsThrottled()
|
||||
{
|
||||
// Arrange
|
||||
for (int i = 0; i < 15; i++)
|
||||
{
|
||||
await _throttler.RecordEventAsync("tenant1", "key1");
|
||||
}
|
||||
|
||||
// Act
|
||||
var result = await _throttler.CheckAsync("tenant1", "key1", null, null);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsThrottled);
|
||||
Assert.Equal(15, result.RecentEventCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckAsync_EventsOutsideWindow_AreRemoved()
|
||||
{
|
||||
// Arrange
|
||||
for (int i = 0; i < 8; i++)
|
||||
{
|
||||
await _throttler.RecordEventAsync("tenant1", "key1");
|
||||
}
|
||||
|
||||
// Move time forward past the window
|
||||
_timeProvider.Advance(TimeSpan.FromMinutes(6));
|
||||
|
||||
// Act
|
||||
var result = await _throttler.CheckAsync("tenant1", "key1", null, null);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsThrottled);
|
||||
Assert.Equal(0, result.RecentEventCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckAsync_CustomWindow_UsesCustomValue()
|
||||
{
|
||||
// Arrange
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
await _throttler.RecordEventAsync("tenant1", "key1");
|
||||
}
|
||||
|
||||
// Move time forward 2 minutes
|
||||
_timeProvider.Advance(TimeSpan.FromMinutes(2));
|
||||
|
||||
// Add more events
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
await _throttler.RecordEventAsync("tenant1", "key1");
|
||||
}
|
||||
|
||||
// Act - check with 1 minute window (should only see recent 3)
|
||||
var result = await _throttler.CheckAsync("tenant1", "key1", TimeSpan.FromMinutes(1), null);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsThrottled);
|
||||
Assert.Equal(3, result.RecentEventCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckAsync_CustomMaxEvents_UsesCustomValue()
|
||||
{
|
||||
// Arrange
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
await _throttler.RecordEventAsync("tenant1", "key1");
|
||||
}
|
||||
|
||||
// Act - check with max 3 events
|
||||
var result = await _throttler.CheckAsync("tenant1", "key1", null, 3);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsThrottled);
|
||||
Assert.Equal(5, result.RecentEventCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckAsync_ThrottledReturnsResetTime()
|
||||
{
|
||||
// Arrange
|
||||
await _throttler.RecordEventAsync("tenant1", "key1");
|
||||
|
||||
// Move time forward 2 minutes
|
||||
_timeProvider.Advance(TimeSpan.FromMinutes(2));
|
||||
|
||||
// Fill up to threshold
|
||||
for (int i = 0; i < 9; i++)
|
||||
{
|
||||
await _throttler.RecordEventAsync("tenant1", "key1");
|
||||
}
|
||||
|
||||
// Act
|
||||
var result = await _throttler.CheckAsync("tenant1", "key1", null, null);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsThrottled);
|
||||
Assert.NotNull(result.ThrottleResetIn);
|
||||
// Reset should be ~3 minutes (5 min window - 2 min since oldest event)
|
||||
Assert.True(result.ThrottleResetIn.Value > TimeSpan.FromMinutes(2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckAsync_DifferentKeys_TrackedSeparately()
|
||||
{
|
||||
// Arrange
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
await _throttler.RecordEventAsync("tenant1", "key1");
|
||||
}
|
||||
|
||||
// Act
|
||||
var result1 = await _throttler.CheckAsync("tenant1", "key1", null, null);
|
||||
var result2 = await _throttler.CheckAsync("tenant1", "key2", null, null);
|
||||
|
||||
// Assert
|
||||
Assert.True(result1.IsThrottled);
|
||||
Assert.False(result2.IsThrottled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckAsync_DifferentTenants_TrackedSeparately()
|
||||
{
|
||||
// Arrange
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
await _throttler.RecordEventAsync("tenant1", "key1");
|
||||
}
|
||||
|
||||
// Act
|
||||
var result1 = await _throttler.CheckAsync("tenant1", "key1", null, null);
|
||||
var result2 = await _throttler.CheckAsync("tenant2", "key1", null, null);
|
||||
|
||||
// Assert
|
||||
Assert.True(result1.IsThrottled);
|
||||
Assert.False(result2.IsThrottled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ClearAsync_RemovesThrottleState()
|
||||
{
|
||||
// Arrange
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
await _throttler.RecordEventAsync("tenant1", "key1");
|
||||
}
|
||||
|
||||
// Verify throttled
|
||||
var beforeClear = await _throttler.CheckAsync("tenant1", "key1", null, null);
|
||||
Assert.True(beforeClear.IsThrottled);
|
||||
|
||||
// Act
|
||||
await _throttler.ClearAsync("tenant1", "key1");
|
||||
|
||||
// Assert
|
||||
var afterClear = await _throttler.CheckAsync("tenant1", "key1", null, null);
|
||||
Assert.False(afterClear.IsThrottled);
|
||||
Assert.Equal(0, afterClear.RecentEventCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ClearAsync_OnlyAffectsSpecifiedKey()
|
||||
{
|
||||
// Arrange
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
await _throttler.RecordEventAsync("tenant1", "key1");
|
||||
await _throttler.RecordEventAsync("tenant1", "key2");
|
||||
}
|
||||
|
||||
// Act
|
||||
await _throttler.ClearAsync("tenant1", "key1");
|
||||
|
||||
// Assert
|
||||
var result1 = await _throttler.CheckAsync("tenant1", "key1", null, null);
|
||||
var result2 = await _throttler.CheckAsync("tenant1", "key2", null, null);
|
||||
|
||||
Assert.False(result1.IsThrottled);
|
||||
Assert.True(result2.IsThrottled);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,451 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using Moq;
|
||||
using StellaOps.Notifier.Worker.Correlation;
|
||||
|
||||
namespace StellaOps.Notifier.Tests.Correlation;
|
||||
|
||||
public class OperatorOverrideServiceTests
|
||||
{
|
||||
private readonly Mock<ISuppressionAuditLogger> _auditLogger;
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly OperatorOverrideOptions _options;
|
||||
private readonly InMemoryOperatorOverrideService _service;
|
||||
|
||||
public OperatorOverrideServiceTests()
|
||||
{
|
||||
_auditLogger = new Mock<ISuppressionAuditLogger>();
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2024, 1, 15, 14, 0, 0, TimeSpan.Zero));
|
||||
_options = new OperatorOverrideOptions
|
||||
{
|
||||
MinDuration = TimeSpan.FromMinutes(5),
|
||||
MaxDuration = TimeSpan.FromHours(24),
|
||||
MaxActiveOverridesPerTenant = 50
|
||||
};
|
||||
|
||||
_service = new InMemoryOperatorOverrideService(
|
||||
_auditLogger.Object,
|
||||
Options.Create(_options),
|
||||
_timeProvider,
|
||||
NullLogger<InMemoryOperatorOverrideService>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateOverrideAsync_CreatesNewOverride()
|
||||
{
|
||||
// Arrange
|
||||
var request = new OperatorOverrideCreate
|
||||
{
|
||||
Type = OverrideType.All,
|
||||
Reason = "Emergency deployment requiring immediate notifications",
|
||||
Duration = TimeSpan.FromHours(2)
|
||||
};
|
||||
|
||||
// Act
|
||||
var @override = await _service.CreateOverrideAsync("tenant1", request, "admin@example.com");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(@override);
|
||||
Assert.StartsWith("ovr-", @override.OverrideId);
|
||||
Assert.Equal("tenant1", @override.TenantId);
|
||||
Assert.Equal(OverrideType.All, @override.Type);
|
||||
Assert.Equal("Emergency deployment requiring immediate notifications", @override.Reason);
|
||||
Assert.Equal(OverrideStatus.Active, @override.Status);
|
||||
Assert.Equal("admin@example.com", @override.CreatedBy);
|
||||
Assert.Equal(_timeProvider.GetUtcNow() + TimeSpan.FromHours(2), @override.ExpiresAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateOverrideAsync_RejectsDurationTooLong()
|
||||
{
|
||||
// Arrange
|
||||
var request = new OperatorOverrideCreate
|
||||
{
|
||||
Type = OverrideType.QuietHours,
|
||||
Reason = "Very long override",
|
||||
Duration = TimeSpan.FromHours(48) // Exceeds max 24 hours
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ArgumentException>(() =>
|
||||
_service.CreateOverrideAsync("tenant1", request, "admin"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateOverrideAsync_RejectsDurationTooShort()
|
||||
{
|
||||
// Arrange
|
||||
var request = new OperatorOverrideCreate
|
||||
{
|
||||
Type = OverrideType.QuietHours,
|
||||
Reason = "Very short override",
|
||||
Duration = TimeSpan.FromMinutes(1) // Below min 5 minutes
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ArgumentException>(() =>
|
||||
_service.CreateOverrideAsync("tenant1", request, "admin"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateOverrideAsync_LogsAuditEntry()
|
||||
{
|
||||
// Arrange
|
||||
var request = new OperatorOverrideCreate
|
||||
{
|
||||
Type = OverrideType.QuietHours,
|
||||
Reason = "Test override for audit",
|
||||
Duration = TimeSpan.FromHours(1)
|
||||
};
|
||||
|
||||
// Act
|
||||
await _service.CreateOverrideAsync("tenant1", request, "admin");
|
||||
|
||||
// Assert
|
||||
_auditLogger.Verify(a => a.LogAsync(
|
||||
It.Is<SuppressionAuditEntry>(e =>
|
||||
e.Action == SuppressionAuditAction.OverrideCreated &&
|
||||
e.Actor == "admin" &&
|
||||
e.TenantId == "tenant1"),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetOverrideAsync_ReturnsOverrideIfExists()
|
||||
{
|
||||
// Arrange
|
||||
var created = await _service.CreateOverrideAsync("tenant1", new OperatorOverrideCreate
|
||||
{
|
||||
Type = OverrideType.Throttle,
|
||||
Reason = "Test override",
|
||||
Duration = TimeSpan.FromHours(1)
|
||||
}, "admin");
|
||||
|
||||
// Act
|
||||
var retrieved = await _service.GetOverrideAsync("tenant1", created.OverrideId);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(retrieved);
|
||||
Assert.Equal(created.OverrideId, retrieved.OverrideId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetOverrideAsync_ReturnsExpiredStatusAfterExpiry()
|
||||
{
|
||||
// Arrange
|
||||
var created = await _service.CreateOverrideAsync("tenant1", new OperatorOverrideCreate
|
||||
{
|
||||
Type = OverrideType.All,
|
||||
Reason = "Short override",
|
||||
Duration = TimeSpan.FromMinutes(30)
|
||||
}, "admin");
|
||||
|
||||
// Advance time past expiry
|
||||
_timeProvider.Advance(TimeSpan.FromMinutes(31));
|
||||
|
||||
// Act
|
||||
var retrieved = await _service.GetOverrideAsync("tenant1", created.OverrideId);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(retrieved);
|
||||
Assert.Equal(OverrideStatus.Expired, retrieved.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListActiveOverridesAsync_ReturnsOnlyActiveOverrides()
|
||||
{
|
||||
// Arrange
|
||||
await _service.CreateOverrideAsync("tenant1", new OperatorOverrideCreate
|
||||
{
|
||||
Type = OverrideType.All,
|
||||
Reason = "Override 1",
|
||||
Duration = TimeSpan.FromHours(2)
|
||||
}, "admin");
|
||||
|
||||
await _service.CreateOverrideAsync("tenant1", new OperatorOverrideCreate
|
||||
{
|
||||
Type = OverrideType.QuietHours,
|
||||
Reason = "Override 2 (short)",
|
||||
Duration = TimeSpan.FromMinutes(10)
|
||||
}, "admin");
|
||||
|
||||
// Advance time so second override expires
|
||||
_timeProvider.Advance(TimeSpan.FromMinutes(15));
|
||||
|
||||
// Act
|
||||
var active = await _service.ListActiveOverridesAsync("tenant1");
|
||||
|
||||
// Assert
|
||||
Assert.Single(active);
|
||||
Assert.Equal("Override 1", active[0].Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RevokeOverrideAsync_RevokesActiveOverride()
|
||||
{
|
||||
// Arrange
|
||||
var created = await _service.CreateOverrideAsync("tenant1", new OperatorOverrideCreate
|
||||
{
|
||||
Type = OverrideType.All,
|
||||
Reason = "To be revoked",
|
||||
Duration = TimeSpan.FromHours(1)
|
||||
}, "admin");
|
||||
|
||||
// Act
|
||||
var revoked = await _service.RevokeOverrideAsync("tenant1", created.OverrideId, "supervisor", "No longer needed");
|
||||
|
||||
// Assert
|
||||
Assert.True(revoked);
|
||||
|
||||
var retrieved = await _service.GetOverrideAsync("tenant1", created.OverrideId);
|
||||
Assert.NotNull(retrieved);
|
||||
Assert.Equal(OverrideStatus.Revoked, retrieved.Status);
|
||||
Assert.Equal("supervisor", retrieved.RevokedBy);
|
||||
Assert.Equal("No longer needed", retrieved.RevocationReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RevokeOverrideAsync_LogsAuditEntry()
|
||||
{
|
||||
// Arrange
|
||||
var created = await _service.CreateOverrideAsync("tenant1", new OperatorOverrideCreate
|
||||
{
|
||||
Type = OverrideType.All,
|
||||
Reason = "To be revoked",
|
||||
Duration = TimeSpan.FromHours(1)
|
||||
}, "admin");
|
||||
|
||||
// Act
|
||||
await _service.RevokeOverrideAsync("tenant1", created.OverrideId, "supervisor", "Testing");
|
||||
|
||||
// Assert
|
||||
_auditLogger.Verify(a => a.LogAsync(
|
||||
It.Is<SuppressionAuditEntry>(e =>
|
||||
e.Action == SuppressionAuditAction.OverrideRevoked &&
|
||||
e.Actor == "supervisor"),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckOverrideAsync_ReturnsMatchingOverride()
|
||||
{
|
||||
// Arrange
|
||||
await _service.CreateOverrideAsync("tenant1", new OperatorOverrideCreate
|
||||
{
|
||||
Type = OverrideType.QuietHours,
|
||||
Reason = "Deployment override",
|
||||
Duration = TimeSpan.FromHours(1)
|
||||
}, "admin");
|
||||
|
||||
// Act
|
||||
var result = await _service.CheckOverrideAsync("tenant1", "deployment.complete", null);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.HasOverride);
|
||||
Assert.NotNull(result.Override);
|
||||
Assert.Equal(OverrideType.QuietHours, result.BypassedTypes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckOverrideAsync_ReturnsNoOverrideWhenNoneMatch()
|
||||
{
|
||||
// Act
|
||||
var result = await _service.CheckOverrideAsync("tenant1", "event.test", null);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.HasOverride);
|
||||
Assert.Null(result.Override);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckOverrideAsync_RespectsEventKindFilter()
|
||||
{
|
||||
// Arrange
|
||||
await _service.CreateOverrideAsync("tenant1", new OperatorOverrideCreate
|
||||
{
|
||||
Type = OverrideType.All,
|
||||
Reason = "Only for deployments",
|
||||
Duration = TimeSpan.FromHours(1),
|
||||
EventKinds = ["deployment.", "release."]
|
||||
}, "admin");
|
||||
|
||||
// Act
|
||||
var deploymentResult = await _service.CheckOverrideAsync("tenant1", "deployment.started", null);
|
||||
var otherResult = await _service.CheckOverrideAsync("tenant1", "vulnerability.found", null);
|
||||
|
||||
// Assert
|
||||
Assert.True(deploymentResult.HasOverride);
|
||||
Assert.False(otherResult.HasOverride);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckOverrideAsync_RespectsCorrelationKeyFilter()
|
||||
{
|
||||
// Arrange
|
||||
await _service.CreateOverrideAsync("tenant1", new OperatorOverrideCreate
|
||||
{
|
||||
Type = OverrideType.Throttle,
|
||||
Reason = "Specific incident",
|
||||
Duration = TimeSpan.FromHours(1),
|
||||
CorrelationKeys = ["incident-123", "incident-456"]
|
||||
}, "admin");
|
||||
|
||||
// Act
|
||||
var matchingResult = await _service.CheckOverrideAsync("tenant1", "event.test", "incident-123");
|
||||
var nonMatchingResult = await _service.CheckOverrideAsync("tenant1", "event.test", "incident-789");
|
||||
|
||||
// Assert
|
||||
Assert.True(matchingResult.HasOverride);
|
||||
Assert.False(nonMatchingResult.HasOverride);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RecordOverrideUsageAsync_IncrementsUsageCount()
|
||||
{
|
||||
// Arrange
|
||||
var created = await _service.CreateOverrideAsync("tenant1", new OperatorOverrideCreate
|
||||
{
|
||||
Type = OverrideType.All,
|
||||
Reason = "Limited use override",
|
||||
Duration = TimeSpan.FromHours(1),
|
||||
MaxUsageCount = 5
|
||||
}, "admin");
|
||||
|
||||
// Act
|
||||
await _service.RecordOverrideUsageAsync("tenant1", created.OverrideId, "event.test");
|
||||
await _service.RecordOverrideUsageAsync("tenant1", created.OverrideId, "event.test");
|
||||
|
||||
// Assert
|
||||
var updated = await _service.GetOverrideAsync("tenant1", created.OverrideId);
|
||||
Assert.NotNull(updated);
|
||||
Assert.Equal(2, updated.UsageCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RecordOverrideUsageAsync_ExhaustsOverrideAtMaxUsage()
|
||||
{
|
||||
// Arrange
|
||||
var created = await _service.CreateOverrideAsync("tenant1", new OperatorOverrideCreate
|
||||
{
|
||||
Type = OverrideType.All,
|
||||
Reason = "Single use override",
|
||||
Duration = TimeSpan.FromHours(1),
|
||||
MaxUsageCount = 2
|
||||
}, "admin");
|
||||
|
||||
// Act
|
||||
await _service.RecordOverrideUsageAsync("tenant1", created.OverrideId, "event.test");
|
||||
await _service.RecordOverrideUsageAsync("tenant1", created.OverrideId, "event.test");
|
||||
|
||||
// Assert
|
||||
var updated = await _service.GetOverrideAsync("tenant1", created.OverrideId);
|
||||
Assert.NotNull(updated);
|
||||
Assert.Equal(OverrideStatus.Exhausted, updated.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RecordOverrideUsageAsync_LogsAuditEntry()
|
||||
{
|
||||
// Arrange
|
||||
var created = await _service.CreateOverrideAsync("tenant1", new OperatorOverrideCreate
|
||||
{
|
||||
Type = OverrideType.All,
|
||||
Reason = "Override for audit test",
|
||||
Duration = TimeSpan.FromHours(1)
|
||||
}, "admin");
|
||||
|
||||
// Act
|
||||
await _service.RecordOverrideUsageAsync("tenant1", created.OverrideId, "event.test");
|
||||
|
||||
// Assert
|
||||
_auditLogger.Verify(a => a.LogAsync(
|
||||
It.Is<SuppressionAuditEntry>(e =>
|
||||
e.Action == SuppressionAuditAction.OverrideUsed &&
|
||||
e.ResourceId == created.OverrideId),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckOverrideAsync_DoesNotReturnExhaustedOverride()
|
||||
{
|
||||
// Arrange
|
||||
var created = await _service.CreateOverrideAsync("tenant1", new OperatorOverrideCreate
|
||||
{
|
||||
Type = OverrideType.All,
|
||||
Reason = "Single use",
|
||||
Duration = TimeSpan.FromHours(1),
|
||||
MaxUsageCount = 1
|
||||
}, "admin");
|
||||
|
||||
await _service.RecordOverrideUsageAsync("tenant1", created.OverrideId, "event.test");
|
||||
|
||||
// Act
|
||||
var result = await _service.CheckOverrideAsync("tenant1", "event.other", null);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.HasOverride);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateOverrideAsync_WithDeferredEffectiveFrom()
|
||||
{
|
||||
// Arrange
|
||||
var futureTime = _timeProvider.GetUtcNow().AddHours(1);
|
||||
var request = new OperatorOverrideCreate
|
||||
{
|
||||
Type = OverrideType.All,
|
||||
Reason = "Future override",
|
||||
Duration = TimeSpan.FromHours(2),
|
||||
EffectiveFrom = futureTime
|
||||
};
|
||||
|
||||
// Act
|
||||
var created = await _service.CreateOverrideAsync("tenant1", request, "admin");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(futureTime, created.EffectiveFrom);
|
||||
Assert.Equal(futureTime + TimeSpan.FromHours(2), created.ExpiresAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckOverrideAsync_DoesNotReturnNotYetEffectiveOverride()
|
||||
{
|
||||
// Arrange
|
||||
var futureTime = _timeProvider.GetUtcNow().AddHours(1);
|
||||
await _service.CreateOverrideAsync("tenant1", new OperatorOverrideCreate
|
||||
{
|
||||
Type = OverrideType.All,
|
||||
Reason = "Future override",
|
||||
Duration = TimeSpan.FromHours(2),
|
||||
EffectiveFrom = futureTime
|
||||
}, "admin");
|
||||
|
||||
// Act (before effective time)
|
||||
var result = await _service.CheckOverrideAsync("tenant1", "event.test", null);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.HasOverride);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OverrideType_Flags_WorkCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
await _service.CreateOverrideAsync("tenant1", new OperatorOverrideCreate
|
||||
{
|
||||
Type = OverrideType.QuietHours | OverrideType.Throttle, // Multiple types
|
||||
Reason = "Partial override",
|
||||
Duration = TimeSpan.FromHours(1)
|
||||
}, "admin");
|
||||
|
||||
// Act
|
||||
var result = await _service.CheckOverrideAsync("tenant1", "event.test", null);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.HasOverride);
|
||||
Assert.True(result.BypassedTypes.HasFlag(OverrideType.QuietHours));
|
||||
Assert.True(result.BypassedTypes.HasFlag(OverrideType.Throttle));
|
||||
Assert.False(result.BypassedTypes.HasFlag(OverrideType.Maintenance));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using Moq;
|
||||
using StellaOps.Notifier.Worker.Correlation;
|
||||
|
||||
namespace StellaOps.Notifier.Tests.Correlation;
|
||||
|
||||
public class QuietHourCalendarServiceTests
|
||||
{
|
||||
private readonly Mock<ISuppressionAuditLogger> _auditLogger;
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly InMemoryQuietHourCalendarService _service;
|
||||
|
||||
public QuietHourCalendarServiceTests()
|
||||
{
|
||||
_auditLogger = new Mock<ISuppressionAuditLogger>();
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2024, 1, 15, 14, 0, 0, TimeSpan.Zero)); // Monday 2pm UTC
|
||||
_service = new InMemoryQuietHourCalendarService(
|
||||
_auditLogger.Object,
|
||||
_timeProvider,
|
||||
NullLogger<InMemoryQuietHourCalendarService>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateCalendarAsync_CreatesNewCalendar()
|
||||
{
|
||||
// Arrange
|
||||
var request = new QuietHourCalendarCreate
|
||||
{
|
||||
Name = "Night Quiet Hours",
|
||||
Description = "Suppress notifications overnight",
|
||||
Schedules =
|
||||
[
|
||||
new CalendarSchedule
|
||||
{
|
||||
Name = "Overnight",
|
||||
StartTime = "22:00",
|
||||
EndTime = "08:00"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// Act
|
||||
var calendar = await _service.CreateCalendarAsync("tenant1", request, "admin@example.com");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(calendar);
|
||||
Assert.StartsWith("cal-", calendar.CalendarId);
|
||||
Assert.Equal("tenant1", calendar.TenantId);
|
||||
Assert.Equal("Night Quiet Hours", calendar.Name);
|
||||
Assert.True(calendar.Enabled);
|
||||
Assert.Single(calendar.Schedules);
|
||||
Assert.Equal("admin@example.com", calendar.CreatedBy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateCalendarAsync_LogsAuditEntry()
|
||||
{
|
||||
// Arrange
|
||||
var request = new QuietHourCalendarCreate
|
||||
{
|
||||
Name = "Test Calendar"
|
||||
};
|
||||
|
||||
// Act
|
||||
await _service.CreateCalendarAsync("tenant1", request, "admin");
|
||||
|
||||
// Assert
|
||||
_auditLogger.Verify(a => a.LogAsync(
|
||||
It.Is<SuppressionAuditEntry>(e =>
|
||||
e.Action == SuppressionAuditAction.CalendarCreated &&
|
||||
e.Actor == "admin" &&
|
||||
e.TenantId == "tenant1"),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListCalendarsAsync_ReturnsAllCalendarsForTenant()
|
||||
{
|
||||
// Arrange
|
||||
await _service.CreateCalendarAsync("tenant1", new QuietHourCalendarCreate { Name = "Calendar 1", Priority = 50 }, "admin");
|
||||
await _service.CreateCalendarAsync("tenant1", new QuietHourCalendarCreate { Name = "Calendar 2", Priority = 100 }, "admin");
|
||||
await _service.CreateCalendarAsync("tenant2", new QuietHourCalendarCreate { Name = "Other Tenant" }, "admin");
|
||||
|
||||
// Act
|
||||
var calendars = await _service.ListCalendarsAsync("tenant1");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, calendars.Count);
|
||||
Assert.Equal("Calendar 1", calendars[0].Name); // Lower priority first
|
||||
Assert.Equal("Calendar 2", calendars[1].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCalendarAsync_ReturnsCalendarIfExists()
|
||||
{
|
||||
// Arrange
|
||||
var created = await _service.CreateCalendarAsync("tenant1", new QuietHourCalendarCreate { Name = "Test" }, "admin");
|
||||
|
||||
// Act
|
||||
var retrieved = await _service.GetCalendarAsync("tenant1", created.CalendarId);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(retrieved);
|
||||
Assert.Equal(created.CalendarId, retrieved.CalendarId);
|
||||
Assert.Equal("Test", retrieved.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCalendarAsync_ReturnsNullIfNotExists()
|
||||
{
|
||||
// Act
|
||||
var result = await _service.GetCalendarAsync("tenant1", "nonexistent");
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateCalendarAsync_UpdatesExistingCalendar()
|
||||
{
|
||||
// Arrange
|
||||
var created = await _service.CreateCalendarAsync("tenant1", new QuietHourCalendarCreate { Name = "Original" }, "admin");
|
||||
|
||||
var update = new QuietHourCalendarUpdate
|
||||
{
|
||||
Name = "Updated",
|
||||
Enabled = false
|
||||
};
|
||||
|
||||
// Act
|
||||
var updated = await _service.UpdateCalendarAsync("tenant1", created.CalendarId, update, "other-admin");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(updated);
|
||||
Assert.Equal("Updated", updated.Name);
|
||||
Assert.False(updated.Enabled);
|
||||
Assert.Equal("other-admin", updated.UpdatedBy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteCalendarAsync_RemovesCalendar()
|
||||
{
|
||||
// Arrange
|
||||
var created = await _service.CreateCalendarAsync("tenant1", new QuietHourCalendarCreate { Name = "ToDelete" }, "admin");
|
||||
|
||||
// Act
|
||||
var deleted = await _service.DeleteCalendarAsync("tenant1", created.CalendarId, "admin");
|
||||
|
||||
// Assert
|
||||
Assert.True(deleted);
|
||||
var retrieved = await _service.GetCalendarAsync("tenant1", created.CalendarId);
|
||||
Assert.Null(retrieved);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateCalendarsAsync_SuppressesWhenInQuietHours()
|
||||
{
|
||||
// Arrange - Create calendar with quiet hours from 10pm to 8am
|
||||
await _service.CreateCalendarAsync("tenant1", new QuietHourCalendarCreate
|
||||
{
|
||||
Name = "Night Hours",
|
||||
Schedules =
|
||||
[
|
||||
new CalendarSchedule
|
||||
{
|
||||
Name = "Overnight",
|
||||
StartTime = "22:00",
|
||||
EndTime = "08:00"
|
||||
}
|
||||
]
|
||||
}, "admin");
|
||||
|
||||
// Set time to 23:00 (11pm) - within quiet hours
|
||||
_timeProvider.SetUtcNow(new DateTimeOffset(2024, 1, 15, 23, 0, 0, TimeSpan.Zero));
|
||||
|
||||
// Act
|
||||
var result = await _service.EvaluateCalendarsAsync("tenant1", "vulnerability.found", null);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsSuppressed);
|
||||
Assert.Equal("Night Hours", result.CalendarName);
|
||||
Assert.Equal("Overnight", result.ScheduleName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateCalendarsAsync_DoesNotSuppressOutsideQuietHours()
|
||||
{
|
||||
// Arrange - Create calendar with quiet hours from 10pm to 8am
|
||||
await _service.CreateCalendarAsync("tenant1", new QuietHourCalendarCreate
|
||||
{
|
||||
Name = "Night Hours",
|
||||
Schedules =
|
||||
[
|
||||
new CalendarSchedule
|
||||
{
|
||||
Name = "Overnight",
|
||||
StartTime = "22:00",
|
||||
EndTime = "08:00"
|
||||
}
|
||||
]
|
||||
}, "admin");
|
||||
|
||||
// Time is 2pm (14:00) - outside quiet hours
|
||||
|
||||
// Act
|
||||
var result = await _service.EvaluateCalendarsAsync("tenant1", "vulnerability.found", null);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsSuppressed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateCalendarsAsync_RespectsExcludedEventKinds()
|
||||
{
|
||||
// Arrange
|
||||
await _service.CreateCalendarAsync("tenant1", new QuietHourCalendarCreate
|
||||
{
|
||||
Name = "Night Hours",
|
||||
ExcludedEventKinds = ["critical.", "urgent."],
|
||||
Schedules =
|
||||
[
|
||||
new CalendarSchedule
|
||||
{
|
||||
Name = "Overnight",
|
||||
StartTime = "22:00",
|
||||
EndTime = "08:00"
|
||||
}
|
||||
]
|
||||
}, "admin");
|
||||
|
||||
// Set time to 23:00 (11pm) - within quiet hours
|
||||
_timeProvider.SetUtcNow(new DateTimeOffset(2024, 1, 15, 23, 0, 0, TimeSpan.Zero));
|
||||
|
||||
// Act
|
||||
var criticalResult = await _service.EvaluateCalendarsAsync("tenant1", "critical.security.breach", null);
|
||||
var normalResult = await _service.EvaluateCalendarsAsync("tenant1", "info.scan.complete", null);
|
||||
|
||||
// Assert
|
||||
Assert.False(criticalResult.IsSuppressed); // Critical events not suppressed
|
||||
Assert.True(normalResult.IsSuppressed); // Normal events suppressed
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateCalendarsAsync_RespectsEventKindFilters()
|
||||
{
|
||||
// Arrange
|
||||
await _service.CreateCalendarAsync("tenant1", new QuietHourCalendarCreate
|
||||
{
|
||||
Name = "Scan Quiet Hours",
|
||||
EventKinds = ["scan."], // Only applies to scan events
|
||||
Schedules =
|
||||
[
|
||||
new CalendarSchedule
|
||||
{
|
||||
Name = "Always",
|
||||
StartTime = "00:00",
|
||||
EndTime = "23:59"
|
||||
}
|
||||
]
|
||||
}, "admin");
|
||||
|
||||
// Act
|
||||
var scanResult = await _service.EvaluateCalendarsAsync("tenant1", "scan.complete", null);
|
||||
var otherResult = await _service.EvaluateCalendarsAsync("tenant1", "vulnerability.found", null);
|
||||
|
||||
// Assert
|
||||
Assert.True(scanResult.IsSuppressed);
|
||||
Assert.False(otherResult.IsSuppressed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateCalendarsAsync_RespectsScopes()
|
||||
{
|
||||
// Arrange
|
||||
await _service.CreateCalendarAsync("tenant1", new QuietHourCalendarCreate
|
||||
{
|
||||
Name = "Team A Quiet Hours",
|
||||
Scopes = ["team-a", "team-b"],
|
||||
Schedules =
|
||||
[
|
||||
new CalendarSchedule
|
||||
{
|
||||
Name = "All Day",
|
||||
StartTime = "00:00",
|
||||
EndTime = "23:59"
|
||||
}
|
||||
]
|
||||
}, "admin");
|
||||
|
||||
// Act
|
||||
var teamAResult = await _service.EvaluateCalendarsAsync("tenant1", "event.test", ["team-a"]);
|
||||
var teamCResult = await _service.EvaluateCalendarsAsync("tenant1", "event.test", ["team-c"]);
|
||||
|
||||
// Assert
|
||||
Assert.True(teamAResult.IsSuppressed);
|
||||
Assert.False(teamCResult.IsSuppressed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateCalendarsAsync_RespectsDaysOfWeek()
|
||||
{
|
||||
// Arrange - Create calendar that only applies on weekends
|
||||
await _service.CreateCalendarAsync("tenant1", new QuietHourCalendarCreate
|
||||
{
|
||||
Name = "Weekend Hours",
|
||||
Schedules =
|
||||
[
|
||||
new CalendarSchedule
|
||||
{
|
||||
Name = "Weekend Only",
|
||||
StartTime = "00:00",
|
||||
EndTime = "23:59",
|
||||
DaysOfWeek = [0, 6] // Sunday and Saturday
|
||||
}
|
||||
]
|
||||
}, "admin");
|
||||
|
||||
// Monday (current time is Monday)
|
||||
var mondayResult = await _service.EvaluateCalendarsAsync("tenant1", "event.test", null);
|
||||
|
||||
// Set to Saturday
|
||||
_timeProvider.SetUtcNow(new DateTimeOffset(2024, 1, 20, 14, 0, 0, TimeSpan.Zero));
|
||||
var saturdayResult = await _service.EvaluateCalendarsAsync("tenant1", "event.test", null);
|
||||
|
||||
// Assert
|
||||
Assert.False(mondayResult.IsSuppressed);
|
||||
Assert.True(saturdayResult.IsSuppressed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateCalendarsAsync_DisabledCalendarDoesNotSuppress()
|
||||
{
|
||||
// Arrange
|
||||
var created = await _service.CreateCalendarAsync("tenant1", new QuietHourCalendarCreate
|
||||
{
|
||||
Name = "Night Hours",
|
||||
Schedules =
|
||||
[
|
||||
new CalendarSchedule
|
||||
{
|
||||
Name = "All Day",
|
||||
StartTime = "00:00",
|
||||
EndTime = "23:59"
|
||||
}
|
||||
]
|
||||
}, "admin");
|
||||
|
||||
// Disable the calendar
|
||||
await _service.UpdateCalendarAsync("tenant1", created.CalendarId, new QuietHourCalendarUpdate { Enabled = false }, "admin");
|
||||
|
||||
// Act
|
||||
var result = await _service.EvaluateCalendarsAsync("tenant1", "event.test", null);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsSuppressed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateCalendarsAsync_HigherPriorityCalendarWins()
|
||||
{
|
||||
// Arrange - Create two calendars with different priorities
|
||||
await _service.CreateCalendarAsync("tenant1", new QuietHourCalendarCreate
|
||||
{
|
||||
Name = "Low Priority",
|
||||
Priority = 100,
|
||||
ExcludedEventKinds = ["critical."], // This one excludes critical
|
||||
Schedules =
|
||||
[
|
||||
new CalendarSchedule
|
||||
{
|
||||
Name = "All Day",
|
||||
StartTime = "00:00",
|
||||
EndTime = "23:59"
|
||||
}
|
||||
]
|
||||
}, "admin");
|
||||
|
||||
await _service.CreateCalendarAsync("tenant1", new QuietHourCalendarCreate
|
||||
{
|
||||
Name = "High Priority",
|
||||
Priority = 10, // Higher priority (lower number)
|
||||
Schedules =
|
||||
[
|
||||
new CalendarSchedule
|
||||
{
|
||||
Name = "All Day",
|
||||
StartTime = "00:00",
|
||||
EndTime = "23:59"
|
||||
}
|
||||
]
|
||||
}, "admin");
|
||||
|
||||
// Act
|
||||
var result = await _service.EvaluateCalendarsAsync("tenant1", "critical.alert", null);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsSuppressed);
|
||||
Assert.Equal("High Priority", result.CalendarName); // High priority calendar applies first
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using Moq;
|
||||
using StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
using StellaOps.Notifier.Worker.Correlation;
|
||||
|
||||
namespace StellaOps.Notifier.Tests.Correlation;
|
||||
|
||||
public class QuietHoursCalendarServiceTests
|
||||
{
|
||||
private readonly Mock<INotifyAuditRepository> _auditRepository;
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly InMemoryQuietHoursCalendarService _service;
|
||||
|
||||
public QuietHoursCalendarServiceTests()
|
||||
{
|
||||
_auditRepository = new Mock<INotifyAuditRepository>();
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2024, 1, 15, 14, 30, 0, TimeSpan.Zero)); // Monday 14:30 UTC
|
||||
|
||||
_service = new InMemoryQuietHoursCalendarService(
|
||||
_auditRepository.Object,
|
||||
_timeProvider,
|
||||
NullLogger<InMemoryQuietHoursCalendarService>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListCalendarsAsync_EmptyTenant_ReturnsEmptyList()
|
||||
{
|
||||
// Act
|
||||
var result = await _service.ListCalendarsAsync("tenant1");
|
||||
|
||||
// Assert
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertCalendarAsync_NewCalendar_CreatesCalendar()
|
||||
{
|
||||
// Arrange
|
||||
var calendar = CreateTestCalendar("cal-1", "tenant1");
|
||||
|
||||
// Act
|
||||
var result = await _service.UpsertCalendarAsync(calendar, "admin");
|
||||
|
||||
// Assert
|
||||
Assert.Equal("cal-1", result.CalendarId);
|
||||
Assert.Equal("tenant1", result.TenantId);
|
||||
Assert.Equal(_timeProvider.GetUtcNow(), result.CreatedAt);
|
||||
Assert.Equal("admin", result.CreatedBy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertCalendarAsync_ExistingCalendar_UpdatesCalendar()
|
||||
{
|
||||
// Arrange
|
||||
var calendar = CreateTestCalendar("cal-1", "tenant1");
|
||||
await _service.UpsertCalendarAsync(calendar, "admin");
|
||||
|
||||
_timeProvider.Advance(TimeSpan.FromMinutes(5));
|
||||
|
||||
var updated = calendar with { Name = "Updated Name" };
|
||||
|
||||
// Act
|
||||
var result = await _service.UpsertCalendarAsync(updated, "admin2");
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Updated Name", result.Name);
|
||||
Assert.Equal("admin", result.CreatedBy); // Original creator preserved
|
||||
Assert.Equal("admin2", result.UpdatedBy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCalendarAsync_ExistingCalendar_ReturnsCalendar()
|
||||
{
|
||||
// Arrange
|
||||
var calendar = CreateTestCalendar("cal-1", "tenant1");
|
||||
await _service.UpsertCalendarAsync(calendar, "admin");
|
||||
|
||||
// Act
|
||||
var result = await _service.GetCalendarAsync("tenant1", "cal-1");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("cal-1", result.CalendarId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCalendarAsync_NonExistentCalendar_ReturnsNull()
|
||||
{
|
||||
// Act
|
||||
var result = await _service.GetCalendarAsync("tenant1", "nonexistent");
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteCalendarAsync_ExistingCalendar_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var calendar = CreateTestCalendar("cal-1", "tenant1");
|
||||
await _service.UpsertCalendarAsync(calendar, "admin");
|
||||
|
||||
// Act
|
||||
var result = await _service.DeleteCalendarAsync("tenant1", "cal-1", "admin");
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
Assert.Null(await _service.GetCalendarAsync("tenant1", "cal-1"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteCalendarAsync_NonExistentCalendar_ReturnsFalse()
|
||||
{
|
||||
// Act
|
||||
var result = await _service.DeleteCalendarAsync("tenant1", "nonexistent", "admin");
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_NoCalendars_ReturnsNotActive()
|
||||
{
|
||||
// Act
|
||||
var result = await _service.EvaluateAsync("tenant1", "event.test");
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsActive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_DisabledCalendar_ReturnsNotActive()
|
||||
{
|
||||
// Arrange
|
||||
var calendar = CreateTestCalendar("cal-1", "tenant1") with { Enabled = false };
|
||||
await _service.UpsertCalendarAsync(calendar, "admin");
|
||||
|
||||
// Act
|
||||
var result = await _service.EvaluateAsync("tenant1", "event.test");
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsActive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_WithinQuietHours_ReturnsActive()
|
||||
{
|
||||
// Arrange - Set time to 22:30 UTC (within 22:00-08:00 quiet hours)
|
||||
_timeProvider.SetUtcNow(new DateTimeOffset(2024, 1, 15, 22, 30, 0, TimeSpan.Zero));
|
||||
|
||||
var calendar = CreateTestCalendar("cal-1", "tenant1", startTime: "22:00", endTime: "08:00");
|
||||
await _service.UpsertCalendarAsync(calendar, "admin");
|
||||
|
||||
// Act
|
||||
var result = await _service.EvaluateAsync("tenant1", "event.test");
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsActive);
|
||||
Assert.Equal("cal-1", result.MatchedCalendarId);
|
||||
Assert.NotNull(result.EndsAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_OutsideQuietHours_ReturnsNotActive()
|
||||
{
|
||||
// Arrange - Time is 14:30 UTC (outside 22:00-08:00 quiet hours)
|
||||
var calendar = CreateTestCalendar("cal-1", "tenant1", startTime: "22:00", endTime: "08:00");
|
||||
await _service.UpsertCalendarAsync(calendar, "admin");
|
||||
|
||||
// Act
|
||||
var result = await _service.EvaluateAsync("tenant1", "event.test");
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsActive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_WithExcludedEventKind_ReturnsNotActive()
|
||||
{
|
||||
// Arrange - Set time within quiet hours
|
||||
_timeProvider.SetUtcNow(new DateTimeOffset(2024, 1, 15, 23, 0, 0, TimeSpan.Zero));
|
||||
|
||||
var calendar = CreateTestCalendar("cal-1", "tenant1", startTime: "22:00", endTime: "08:00") with
|
||||
{
|
||||
ExcludedEventKinds = new[] { "critical." }
|
||||
};
|
||||
await _service.UpsertCalendarAsync(calendar, "admin");
|
||||
|
||||
// Act
|
||||
var result = await _service.EvaluateAsync("tenant1", "critical.alert");
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsActive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_WithIncludedEventKind_OnlyMatchesIncluded()
|
||||
{
|
||||
// Arrange - Set time within quiet hours
|
||||
_timeProvider.SetUtcNow(new DateTimeOffset(2024, 1, 15, 23, 0, 0, TimeSpan.Zero));
|
||||
|
||||
var calendar = CreateTestCalendar("cal-1", "tenant1", startTime: "22:00", endTime: "08:00") with
|
||||
{
|
||||
IncludedEventKinds = new[] { "info." }
|
||||
};
|
||||
await _service.UpsertCalendarAsync(calendar, "admin");
|
||||
|
||||
// Act - Test included event kind
|
||||
var resultIncluded = await _service.EvaluateAsync("tenant1", "info.status");
|
||||
// Act - Test non-included event kind
|
||||
var resultExcluded = await _service.EvaluateAsync("tenant1", "warning.alert");
|
||||
|
||||
// Assert
|
||||
Assert.True(resultIncluded.IsActive);
|
||||
Assert.False(resultExcluded.IsActive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_WithDayOfWeekRestriction_OnlyMatchesSpecifiedDays()
|
||||
{
|
||||
// Arrange - Monday (day 1)
|
||||
var calendar = CreateTestCalendar("cal-1", "tenant1", startTime: "00:00", endTime: "23:59") with
|
||||
{
|
||||
Schedules = new[]
|
||||
{
|
||||
new QuietHoursScheduleEntry
|
||||
{
|
||||
Name = "Weekends Only",
|
||||
StartTime = "00:00",
|
||||
EndTime = "23:59",
|
||||
DaysOfWeek = new[] { 0, 6 }, // Sunday, Saturday
|
||||
Enabled = true
|
||||
}
|
||||
}
|
||||
};
|
||||
await _service.UpsertCalendarAsync(calendar, "admin");
|
||||
|
||||
// Act
|
||||
var result = await _service.EvaluateAsync("tenant1", "event.test");
|
||||
|
||||
// Assert - Should not be active on Monday
|
||||
Assert.False(result.IsActive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_PriorityOrdering_ReturnsHighestPriority()
|
||||
{
|
||||
// Arrange - Set time within quiet hours
|
||||
_timeProvider.SetUtcNow(new DateTimeOffset(2024, 1, 15, 23, 0, 0, TimeSpan.Zero));
|
||||
|
||||
var calendar1 = CreateTestCalendar("cal-low", "tenant1", startTime: "22:00", endTime: "08:00") with
|
||||
{
|
||||
Name = "Low Priority",
|
||||
Priority = 100
|
||||
};
|
||||
var calendar2 = CreateTestCalendar("cal-high", "tenant1", startTime: "22:00", endTime: "08:00") with
|
||||
{
|
||||
Name = "High Priority",
|
||||
Priority = 10
|
||||
};
|
||||
|
||||
await _service.UpsertCalendarAsync(calendar1, "admin");
|
||||
await _service.UpsertCalendarAsync(calendar2, "admin");
|
||||
|
||||
// Act
|
||||
var result = await _service.EvaluateAsync("tenant1", "event.test");
|
||||
|
||||
// Assert - Should match higher priority (lower number)
|
||||
Assert.True(result.IsActive);
|
||||
Assert.Equal("cal-high", result.MatchedCalendarId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_SameDayWindow_EvaluatesCorrectly()
|
||||
{
|
||||
// Arrange - Set time to 10:30 UTC (within 09:00-17:00 business hours)
|
||||
_timeProvider.SetUtcNow(new DateTimeOffset(2024, 1, 15, 10, 30, 0, TimeSpan.Zero));
|
||||
|
||||
var calendar = CreateTestCalendar("cal-1", "tenant1", startTime: "09:00", endTime: "17:00");
|
||||
await _service.UpsertCalendarAsync(calendar, "admin");
|
||||
|
||||
// Act
|
||||
var result = await _service.EvaluateAsync("tenant1", "event.test");
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsActive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_WithCustomEvaluationTime_UsesProvidedTime()
|
||||
{
|
||||
// Arrange - Current time is 14:30, but we evaluate at 23:00
|
||||
var calendar = CreateTestCalendar("cal-1", "tenant1", startTime: "22:00", endTime: "08:00");
|
||||
await _service.UpsertCalendarAsync(calendar, "admin");
|
||||
|
||||
var evaluationTime = new DateTimeOffset(2024, 1, 15, 23, 0, 0, TimeSpan.Zero);
|
||||
|
||||
// Act
|
||||
var result = await _service.EvaluateAsync("tenant1", "event.test", evaluationTime);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsActive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListCalendarsAsync_ReturnsOrderedByPriority()
|
||||
{
|
||||
// Arrange
|
||||
await _service.UpsertCalendarAsync(
|
||||
CreateTestCalendar("cal-3", "tenant1") with { Priority = 300 }, "admin");
|
||||
await _service.UpsertCalendarAsync(
|
||||
CreateTestCalendar("cal-1", "tenant1") with { Priority = 100 }, "admin");
|
||||
await _service.UpsertCalendarAsync(
|
||||
CreateTestCalendar("cal-2", "tenant1") with { Priority = 200 }, "admin");
|
||||
|
||||
// Act
|
||||
var result = await _service.ListCalendarsAsync("tenant1");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(3, result.Count);
|
||||
Assert.Equal("cal-1", result[0].CalendarId);
|
||||
Assert.Equal("cal-2", result[1].CalendarId);
|
||||
Assert.Equal("cal-3", result[2].CalendarId);
|
||||
}
|
||||
|
||||
private static QuietHoursCalendar CreateTestCalendar(
|
||||
string calendarId,
|
||||
string tenantId,
|
||||
string startTime = "22:00",
|
||||
string endTime = "08:00") => new()
|
||||
{
|
||||
CalendarId = calendarId,
|
||||
TenantId = tenantId,
|
||||
Name = $"Test Calendar {calendarId}",
|
||||
Enabled = true,
|
||||
Priority = 100,
|
||||
Schedules = new[]
|
||||
{
|
||||
new QuietHoursScheduleEntry
|
||||
{
|
||||
Name = "Default Schedule",
|
||||
StartTime = startTime,
|
||||
EndTime = endTime,
|
||||
Enabled = true
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Notifier.Worker.Correlation;
|
||||
|
||||
namespace StellaOps.Notifier.Tests.Correlation;
|
||||
|
||||
public class QuietHoursEvaluatorTests
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly QuietHoursOptions _options;
|
||||
private readonly QuietHoursEvaluator _evaluator;
|
||||
|
||||
public QuietHoursEvaluatorTests()
|
||||
{
|
||||
// Start at 10:00 AM UTC on a Wednesday
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2024, 1, 10, 10, 0, 0, TimeSpan.Zero));
|
||||
_options = new QuietHoursOptions { Enabled = true };
|
||||
_evaluator = CreateEvaluator();
|
||||
}
|
||||
|
||||
private QuietHoursEvaluator CreateEvaluator()
|
||||
{
|
||||
return new QuietHoursEvaluator(
|
||||
Options.Create(_options),
|
||||
_timeProvider,
|
||||
NullLogger<QuietHoursEvaluator>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_NoSchedule_ReturnsNotSuppressed()
|
||||
{
|
||||
// Arrange
|
||||
_options.Schedule = null;
|
||||
|
||||
// Act
|
||||
var result = await _evaluator.EvaluateAsync("tenant1", "test.event");
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsSuppressed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_DisabledSchedule_ReturnsNotSuppressed()
|
||||
{
|
||||
// Arrange
|
||||
_options.Schedule = new QuietHoursSchedule { Enabled = false };
|
||||
|
||||
// Act
|
||||
var result = await _evaluator.EvaluateAsync("tenant1", "test.event");
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsSuppressed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_DisabledGlobally_ReturnsNotSuppressed()
|
||||
{
|
||||
// Arrange
|
||||
_options.Enabled = false;
|
||||
_options.Schedule = new QuietHoursSchedule
|
||||
{
|
||||
Enabled = true,
|
||||
StartTime = "00:00",
|
||||
EndTime = "23:59"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _evaluator.EvaluateAsync("tenant1", "test.event");
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsSuppressed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_WithinSameDayQuietHours_ReturnsSuppressed()
|
||||
{
|
||||
// Arrange - set time to 14:00 (2 PM)
|
||||
_timeProvider.SetUtcNow(new DateTimeOffset(2024, 1, 10, 14, 0, 0, TimeSpan.Zero));
|
||||
_options.Schedule = new QuietHoursSchedule
|
||||
{
|
||||
Enabled = true,
|
||||
StartTime = "12:00",
|
||||
EndTime = "18:00"
|
||||
};
|
||||
var evaluator = CreateEvaluator();
|
||||
|
||||
// Act
|
||||
var result = await evaluator.EvaluateAsync("tenant1", "test.event");
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsSuppressed);
|
||||
Assert.Equal("quiet_hours", result.SuppressionType);
|
||||
Assert.Contains("Quiet hours", result.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_OutsideSameDayQuietHours_ReturnsNotSuppressed()
|
||||
{
|
||||
// Arrange - set time to 10:00 (10 AM)
|
||||
_timeProvider.SetUtcNow(new DateTimeOffset(2024, 1, 10, 10, 0, 0, TimeSpan.Zero));
|
||||
_options.Schedule = new QuietHoursSchedule
|
||||
{
|
||||
Enabled = true,
|
||||
StartTime = "12:00",
|
||||
EndTime = "18:00"
|
||||
};
|
||||
var evaluator = CreateEvaluator();
|
||||
|
||||
// Act
|
||||
var result = await evaluator.EvaluateAsync("tenant1", "test.event");
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsSuppressed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_WithinOvernightQuietHours_Morning_ReturnsSuppressed()
|
||||
{
|
||||
// Arrange - set time to 06:00 (6 AM)
|
||||
_timeProvider.SetUtcNow(new DateTimeOffset(2024, 1, 10, 6, 0, 0, TimeSpan.Zero));
|
||||
_options.Schedule = new QuietHoursSchedule
|
||||
{
|
||||
Enabled = true,
|
||||
StartTime = "22:00",
|
||||
EndTime = "08:00"
|
||||
};
|
||||
var evaluator = CreateEvaluator();
|
||||
|
||||
// Act
|
||||
var result = await evaluator.EvaluateAsync("tenant1", "test.event");
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsSuppressed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_WithinOvernightQuietHours_Evening_ReturnsSuppressed()
|
||||
{
|
||||
// Arrange - set time to 23:00 (11 PM)
|
||||
_timeProvider.SetUtcNow(new DateTimeOffset(2024, 1, 10, 23, 0, 0, TimeSpan.Zero));
|
||||
_options.Schedule = new QuietHoursSchedule
|
||||
{
|
||||
Enabled = true,
|
||||
StartTime = "22:00",
|
||||
EndTime = "08:00"
|
||||
};
|
||||
var evaluator = CreateEvaluator();
|
||||
|
||||
// Act
|
||||
var result = await evaluator.EvaluateAsync("tenant1", "test.event");
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsSuppressed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_OutsideOvernightQuietHours_ReturnsNotSuppressed()
|
||||
{
|
||||
// Arrange - set time to 12:00 (noon)
|
||||
_timeProvider.SetUtcNow(new DateTimeOffset(2024, 1, 10, 12, 0, 0, TimeSpan.Zero));
|
||||
_options.Schedule = new QuietHoursSchedule
|
||||
{
|
||||
Enabled = true,
|
||||
StartTime = "22:00",
|
||||
EndTime = "08:00"
|
||||
};
|
||||
var evaluator = CreateEvaluator();
|
||||
|
||||
// Act
|
||||
var result = await evaluator.EvaluateAsync("tenant1", "test.event");
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsSuppressed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_DayOfWeekFilter_AppliesCorrectly()
|
||||
{
|
||||
// Arrange - Wednesday (day 3)
|
||||
_timeProvider.SetUtcNow(new DateTimeOffset(2024, 1, 10, 14, 0, 0, TimeSpan.Zero));
|
||||
_options.Schedule = new QuietHoursSchedule
|
||||
{
|
||||
Enabled = true,
|
||||
StartTime = "00:00",
|
||||
EndTime = "23:59",
|
||||
DaysOfWeek = [0, 6] // Sunday, Saturday only
|
||||
};
|
||||
var evaluator = CreateEvaluator();
|
||||
|
||||
// Act
|
||||
var result = await evaluator.EvaluateAsync("tenant1", "test.event");
|
||||
|
||||
// Assert - Wednesday is not in the list
|
||||
Assert.False(result.IsSuppressed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_DayOfWeekIncluded_ReturnsSuppressed()
|
||||
{
|
||||
// Arrange - Wednesday (day 3)
|
||||
_timeProvider.SetUtcNow(new DateTimeOffset(2024, 1, 10, 14, 0, 0, TimeSpan.Zero));
|
||||
_options.Schedule = new QuietHoursSchedule
|
||||
{
|
||||
Enabled = true,
|
||||
StartTime = "00:00",
|
||||
EndTime = "23:59",
|
||||
DaysOfWeek = [3] // Wednesday
|
||||
};
|
||||
var evaluator = CreateEvaluator();
|
||||
|
||||
// Act
|
||||
var result = await evaluator.EvaluateAsync("tenant1", "test.event");
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsSuppressed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_ExcludedEventKind_ReturnsNotSuppressed()
|
||||
{
|
||||
// Arrange
|
||||
_timeProvider.SetUtcNow(new DateTimeOffset(2024, 1, 10, 14, 0, 0, TimeSpan.Zero));
|
||||
_options.Schedule = new QuietHoursSchedule
|
||||
{
|
||||
Enabled = true,
|
||||
StartTime = "00:00",
|
||||
EndTime = "23:59",
|
||||
ExcludedEventKinds = ["security", "critical"]
|
||||
};
|
||||
var evaluator = CreateEvaluator();
|
||||
|
||||
// Act
|
||||
var result = await evaluator.EvaluateAsync("tenant1", "security.alert");
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsSuppressed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_MaintenanceWindow_Active_ReturnsSuppressed()
|
||||
{
|
||||
// Arrange
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var window = new MaintenanceWindow
|
||||
{
|
||||
WindowId = "maint-1",
|
||||
TenantId = "tenant1",
|
||||
StartTime = now.AddHours(-1),
|
||||
EndTime = now.AddHours(1),
|
||||
Description = "Scheduled maintenance"
|
||||
};
|
||||
|
||||
await _evaluator.AddMaintenanceWindowAsync("tenant1", window);
|
||||
|
||||
// Act
|
||||
var result = await _evaluator.EvaluateAsync("tenant1", "test.event");
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsSuppressed);
|
||||
Assert.Equal("maintenance", result.SuppressionType);
|
||||
Assert.Contains("Scheduled maintenance", result.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_MaintenanceWindow_NotActive_ReturnsNotSuppressed()
|
||||
{
|
||||
// Arrange
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var window = new MaintenanceWindow
|
||||
{
|
||||
WindowId = "maint-1",
|
||||
TenantId = "tenant1",
|
||||
StartTime = now.AddHours(1),
|
||||
EndTime = now.AddHours(2),
|
||||
Description = "Future maintenance"
|
||||
};
|
||||
|
||||
await _evaluator.AddMaintenanceWindowAsync("tenant1", window);
|
||||
|
||||
// Act
|
||||
var result = await _evaluator.EvaluateAsync("tenant1", "test.event");
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsSuppressed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_MaintenanceWindow_DifferentTenant_ReturnsNotSuppressed()
|
||||
{
|
||||
// Arrange
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var window = new MaintenanceWindow
|
||||
{
|
||||
WindowId = "maint-1",
|
||||
TenantId = "tenant1",
|
||||
StartTime = now.AddHours(-1),
|
||||
EndTime = now.AddHours(1)
|
||||
};
|
||||
|
||||
await _evaluator.AddMaintenanceWindowAsync("tenant1", window);
|
||||
|
||||
// Act
|
||||
var result = await _evaluator.EvaluateAsync("tenant2", "test.event");
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsSuppressed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_MaintenanceWindow_AffectedEventKind_ReturnsSuppressed()
|
||||
{
|
||||
// Arrange
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var window = new MaintenanceWindow
|
||||
{
|
||||
WindowId = "maint-1",
|
||||
TenantId = "tenant1",
|
||||
StartTime = now.AddHours(-1),
|
||||
EndTime = now.AddHours(1),
|
||||
AffectedEventKinds = ["scanner", "monitor"]
|
||||
};
|
||||
|
||||
await _evaluator.AddMaintenanceWindowAsync("tenant1", window);
|
||||
|
||||
// Act
|
||||
var result = await _evaluator.EvaluateAsync("tenant1", "scanner.complete");
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsSuppressed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_MaintenanceWindow_UnaffectedEventKind_ReturnsNotSuppressed()
|
||||
{
|
||||
// Arrange
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var window = new MaintenanceWindow
|
||||
{
|
||||
WindowId = "maint-1",
|
||||
TenantId = "tenant1",
|
||||
StartTime = now.AddHours(-1),
|
||||
EndTime = now.AddHours(1),
|
||||
AffectedEventKinds = ["scanner", "monitor"]
|
||||
};
|
||||
|
||||
await _evaluator.AddMaintenanceWindowAsync("tenant1", window);
|
||||
|
||||
// Act
|
||||
var result = await _evaluator.EvaluateAsync("tenant1", "security.alert");
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsSuppressed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddMaintenanceWindowAsync_AddsWindow()
|
||||
{
|
||||
// Arrange
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var window = new MaintenanceWindow
|
||||
{
|
||||
WindowId = "maint-1",
|
||||
TenantId = "tenant1",
|
||||
StartTime = now,
|
||||
EndTime = now.AddHours(2)
|
||||
};
|
||||
|
||||
// Act
|
||||
await _evaluator.AddMaintenanceWindowAsync("tenant1", window);
|
||||
|
||||
// Assert
|
||||
var windows = await _evaluator.ListMaintenanceWindowsAsync("tenant1");
|
||||
Assert.Single(windows);
|
||||
Assert.Equal("maint-1", windows[0].WindowId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveMaintenanceWindowAsync_RemovesWindow()
|
||||
{
|
||||
// Arrange
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var window = new MaintenanceWindow
|
||||
{
|
||||
WindowId = "maint-1",
|
||||
TenantId = "tenant1",
|
||||
StartTime = now,
|
||||
EndTime = now.AddHours(2)
|
||||
};
|
||||
|
||||
await _evaluator.AddMaintenanceWindowAsync("tenant1", window);
|
||||
|
||||
// Act
|
||||
await _evaluator.RemoveMaintenanceWindowAsync("tenant1", "maint-1");
|
||||
|
||||
// Assert
|
||||
var windows = await _evaluator.ListMaintenanceWindowsAsync("tenant1");
|
||||
Assert.Empty(windows);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListMaintenanceWindowsAsync_ExcludesExpiredWindows()
|
||||
{
|
||||
// Arrange
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var activeWindow = new MaintenanceWindow
|
||||
{
|
||||
WindowId = "maint-active",
|
||||
TenantId = "tenant1",
|
||||
StartTime = now.AddHours(-1),
|
||||
EndTime = now.AddHours(1)
|
||||
};
|
||||
|
||||
var expiredWindow = new MaintenanceWindow
|
||||
{
|
||||
WindowId = "maint-expired",
|
||||
TenantId = "tenant1",
|
||||
StartTime = now.AddHours(-3),
|
||||
EndTime = now.AddHours(-1)
|
||||
};
|
||||
|
||||
await _evaluator.AddMaintenanceWindowAsync("tenant1", activeWindow);
|
||||
await _evaluator.AddMaintenanceWindowAsync("tenant1", expiredWindow);
|
||||
|
||||
// Act
|
||||
var windows = await _evaluator.ListMaintenanceWindowsAsync("tenant1");
|
||||
|
||||
// Assert
|
||||
Assert.Single(windows);
|
||||
Assert.Equal("maint-active", windows[0].WindowId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_MaintenanceHasPriorityOverQuietHours()
|
||||
{
|
||||
// Arrange - setup both maintenance and quiet hours
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
_options.Schedule = new QuietHoursSchedule
|
||||
{
|
||||
Enabled = true,
|
||||
StartTime = "00:00",
|
||||
EndTime = "23:59"
|
||||
};
|
||||
|
||||
var evaluator = CreateEvaluator();
|
||||
|
||||
var window = new MaintenanceWindow
|
||||
{
|
||||
WindowId = "maint-1",
|
||||
TenantId = "tenant1",
|
||||
StartTime = now.AddHours(-1),
|
||||
EndTime = now.AddHours(1),
|
||||
Description = "System upgrade"
|
||||
};
|
||||
|
||||
await evaluator.AddMaintenanceWindowAsync("tenant1", window);
|
||||
|
||||
// Act
|
||||
var result = await evaluator.EvaluateAsync("tenant1", "test.event");
|
||||
|
||||
// Assert - maintenance should take priority
|
||||
Assert.True(result.IsSuppressed);
|
||||
Assert.Equal("maintenance", result.SuppressionType);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Notifier.Worker.Correlation;
|
||||
|
||||
namespace StellaOps.Notifier.Tests.Correlation;
|
||||
|
||||
public class SuppressionAuditLoggerTests
|
||||
{
|
||||
private readonly SuppressionAuditOptions _options;
|
||||
private readonly InMemorySuppressionAuditLogger _logger;
|
||||
|
||||
public SuppressionAuditLoggerTests()
|
||||
{
|
||||
_options = new SuppressionAuditOptions
|
||||
{
|
||||
MaxEntriesPerTenant = 100
|
||||
};
|
||||
|
||||
_logger = new InMemorySuppressionAuditLogger(
|
||||
Options.Create(_options),
|
||||
NullLogger<InMemorySuppressionAuditLogger>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LogAsync_StoresEntry()
|
||||
{
|
||||
// Arrange
|
||||
var entry = CreateEntry("tenant1", SuppressionAuditAction.CalendarCreated);
|
||||
|
||||
// Act
|
||||
await _logger.LogAsync(entry);
|
||||
|
||||
// Assert
|
||||
var results = await _logger.QueryAsync(new SuppressionAuditQuery { TenantId = "tenant1" });
|
||||
Assert.Single(results);
|
||||
Assert.Equal(entry.EntryId, results[0].EntryId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_ReturnsEmptyForUnknownTenant()
|
||||
{
|
||||
// Act
|
||||
var results = await _logger.QueryAsync(new SuppressionAuditQuery { TenantId = "nonexistent" });
|
||||
|
||||
// Assert
|
||||
Assert.Empty(results);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_FiltersByTimeRange()
|
||||
{
|
||||
// Arrange
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
await _logger.LogAsync(CreateEntry("tenant1", SuppressionAuditAction.CalendarCreated, now.AddHours(-3)));
|
||||
await _logger.LogAsync(CreateEntry("tenant1", SuppressionAuditAction.CalendarUpdated, now.AddHours(-1)));
|
||||
await _logger.LogAsync(CreateEntry("tenant1", SuppressionAuditAction.CalendarDeleted, now));
|
||||
|
||||
// Act
|
||||
var results = await _logger.QueryAsync(new SuppressionAuditQuery
|
||||
{
|
||||
TenantId = "tenant1",
|
||||
From = now.AddHours(-2),
|
||||
To = now.AddMinutes(-30)
|
||||
});
|
||||
|
||||
// Assert
|
||||
Assert.Single(results);
|
||||
Assert.Equal(SuppressionAuditAction.CalendarUpdated, results[0].Action);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_FiltersByAction()
|
||||
{
|
||||
// Arrange
|
||||
await _logger.LogAsync(CreateEntry("tenant1", SuppressionAuditAction.CalendarCreated));
|
||||
await _logger.LogAsync(CreateEntry("tenant1", SuppressionAuditAction.CalendarUpdated));
|
||||
await _logger.LogAsync(CreateEntry("tenant1", SuppressionAuditAction.ThrottleConfigUpdated));
|
||||
|
||||
// Act
|
||||
var results = await _logger.QueryAsync(new SuppressionAuditQuery
|
||||
{
|
||||
TenantId = "tenant1",
|
||||
Actions = [SuppressionAuditAction.CalendarCreated, SuppressionAuditAction.CalendarUpdated]
|
||||
});
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, results.Count);
|
||||
Assert.DoesNotContain(results, r => r.Action == SuppressionAuditAction.ThrottleConfigUpdated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_FiltersByActor()
|
||||
{
|
||||
// Arrange
|
||||
await _logger.LogAsync(CreateEntry("tenant1", SuppressionAuditAction.CalendarCreated, actor: "admin1"));
|
||||
await _logger.LogAsync(CreateEntry("tenant1", SuppressionAuditAction.CalendarUpdated, actor: "admin2"));
|
||||
await _logger.LogAsync(CreateEntry("tenant1", SuppressionAuditAction.CalendarDeleted, actor: "admin1"));
|
||||
|
||||
// Act
|
||||
var results = await _logger.QueryAsync(new SuppressionAuditQuery
|
||||
{
|
||||
TenantId = "tenant1",
|
||||
Actor = "admin1"
|
||||
});
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, results.Count);
|
||||
Assert.All(results, r => Assert.Equal("admin1", r.Actor));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_FiltersByResourceType()
|
||||
{
|
||||
// Arrange
|
||||
await _logger.LogAsync(CreateEntry("tenant1", SuppressionAuditAction.CalendarCreated, resourceType: "QuietHourCalendar"));
|
||||
await _logger.LogAsync(CreateEntry("tenant1", SuppressionAuditAction.ThrottleConfigUpdated, resourceType: "TenantThrottleConfig"));
|
||||
|
||||
// Act
|
||||
var results = await _logger.QueryAsync(new SuppressionAuditQuery
|
||||
{
|
||||
TenantId = "tenant1",
|
||||
ResourceType = "QuietHourCalendar"
|
||||
});
|
||||
|
||||
// Assert
|
||||
Assert.Single(results);
|
||||
Assert.Equal("QuietHourCalendar", results[0].ResourceType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_FiltersByResourceId()
|
||||
{
|
||||
// Arrange
|
||||
await _logger.LogAsync(CreateEntry("tenant1", SuppressionAuditAction.CalendarCreated, resourceId: "cal-123"));
|
||||
await _logger.LogAsync(CreateEntry("tenant1", SuppressionAuditAction.CalendarUpdated, resourceId: "cal-456"));
|
||||
|
||||
// Act
|
||||
var results = await _logger.QueryAsync(new SuppressionAuditQuery
|
||||
{
|
||||
TenantId = "tenant1",
|
||||
ResourceId = "cal-123"
|
||||
});
|
||||
|
||||
// Assert
|
||||
Assert.Single(results);
|
||||
Assert.Equal("cal-123", results[0].ResourceId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_AppliesPagination()
|
||||
{
|
||||
// Arrange
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
await _logger.LogAsync(CreateEntry("tenant1", SuppressionAuditAction.CalendarCreated, now.AddMinutes(-i)));
|
||||
}
|
||||
|
||||
// Act
|
||||
var firstPage = await _logger.QueryAsync(new SuppressionAuditQuery
|
||||
{
|
||||
TenantId = "tenant1",
|
||||
Limit = 3,
|
||||
Offset = 0
|
||||
});
|
||||
|
||||
var secondPage = await _logger.QueryAsync(new SuppressionAuditQuery
|
||||
{
|
||||
TenantId = "tenant1",
|
||||
Limit = 3,
|
||||
Offset = 3
|
||||
});
|
||||
|
||||
// Assert
|
||||
Assert.Equal(3, firstPage.Count);
|
||||
Assert.Equal(3, secondPage.Count);
|
||||
Assert.NotEqual(firstPage[0].EntryId, secondPage[0].EntryId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_OrdersByTimestampDescending()
|
||||
{
|
||||
// Arrange
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
await _logger.LogAsync(CreateEntry("tenant1", SuppressionAuditAction.CalendarCreated, now.AddHours(-2)));
|
||||
await _logger.LogAsync(CreateEntry("tenant1", SuppressionAuditAction.CalendarUpdated, now.AddHours(-1)));
|
||||
await _logger.LogAsync(CreateEntry("tenant1", SuppressionAuditAction.CalendarDeleted, now));
|
||||
|
||||
// Act
|
||||
var results = await _logger.QueryAsync(new SuppressionAuditQuery { TenantId = "tenant1" });
|
||||
|
||||
// Assert
|
||||
Assert.Equal(3, results.Count);
|
||||
Assert.True(results[0].Timestamp > results[1].Timestamp);
|
||||
Assert.True(results[1].Timestamp > results[2].Timestamp);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LogAsync_TrimsOldEntriesWhenLimitExceeded()
|
||||
{
|
||||
// Arrange
|
||||
var options = new SuppressionAuditOptions { MaxEntriesPerTenant = 5 };
|
||||
var logger = new InMemorySuppressionAuditLogger(
|
||||
Options.Create(options),
|
||||
NullLogger<InMemorySuppressionAuditLogger>.Instance);
|
||||
|
||||
// Act - Add more entries than the limit
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
await logger.LogAsync(CreateEntry("tenant1", SuppressionAuditAction.CalendarCreated));
|
||||
}
|
||||
|
||||
// Assert
|
||||
var results = await logger.QueryAsync(new SuppressionAuditQuery { TenantId = "tenant1" });
|
||||
Assert.Equal(5, results.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LogAsync_IsolatesTenantsCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
await _logger.LogAsync(CreateEntry("tenant1", SuppressionAuditAction.CalendarCreated));
|
||||
await _logger.LogAsync(CreateEntry("tenant2", SuppressionAuditAction.CalendarUpdated));
|
||||
await _logger.LogAsync(CreateEntry("tenant1", SuppressionAuditAction.CalendarDeleted));
|
||||
|
||||
// Act
|
||||
var tenant1Results = await _logger.QueryAsync(new SuppressionAuditQuery { TenantId = "tenant1" });
|
||||
var tenant2Results = await _logger.QueryAsync(new SuppressionAuditQuery { TenantId = "tenant2" });
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, tenant1Results.Count);
|
||||
Assert.Single(tenant2Results);
|
||||
}
|
||||
|
||||
private static SuppressionAuditEntry CreateEntry(
|
||||
string tenantId,
|
||||
SuppressionAuditAction action,
|
||||
DateTimeOffset? timestamp = null,
|
||||
string actor = "system",
|
||||
string resourceType = "TestResource",
|
||||
string resourceId = "test-123")
|
||||
{
|
||||
return new SuppressionAuditEntry
|
||||
{
|
||||
EntryId = Guid.NewGuid().ToString("N")[..16],
|
||||
TenantId = tenantId,
|
||||
Timestamp = timestamp ?? DateTimeOffset.UtcNow,
|
||||
Actor = actor,
|
||||
Action = action,
|
||||
ResourceType = resourceType,
|
||||
ResourceId = resourceId
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using Moq;
|
||||
using StellaOps.Notifier.Worker.Correlation;
|
||||
|
||||
namespace StellaOps.Notifier.Tests.Correlation;
|
||||
|
||||
public class ThrottleConfigServiceTests
|
||||
{
|
||||
private readonly Mock<ISuppressionAuditLogger> _auditLogger;
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly ThrottlerOptions _globalOptions;
|
||||
private readonly InMemoryThrottleConfigService _service;
|
||||
|
||||
public ThrottleConfigServiceTests()
|
||||
{
|
||||
_auditLogger = new Mock<ISuppressionAuditLogger>();
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2024, 1, 15, 14, 0, 0, TimeSpan.Zero));
|
||||
_globalOptions = new ThrottlerOptions
|
||||
{
|
||||
Enabled = true,
|
||||
DefaultWindow = TimeSpan.FromMinutes(5),
|
||||
DefaultMaxEvents = 10
|
||||
};
|
||||
|
||||
_service = new InMemoryThrottleConfigService(
|
||||
_auditLogger.Object,
|
||||
Options.Create(_globalOptions),
|
||||
_timeProvider,
|
||||
NullLogger<InMemoryThrottleConfigService>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEffectiveConfigAsync_ReturnsGlobalDefaultsWhenNoTenantConfig()
|
||||
{
|
||||
// Act
|
||||
var config = await _service.GetEffectiveConfigAsync("tenant1", "vulnerability.found");
|
||||
|
||||
// Assert
|
||||
Assert.True(config.Enabled);
|
||||
Assert.Equal(TimeSpan.FromMinutes(5), config.Window);
|
||||
Assert.Equal(10, config.MaxEvents);
|
||||
Assert.Equal("global", config.Source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetTenantConfigAsync_CreatesTenantConfig()
|
||||
{
|
||||
// Arrange
|
||||
var update = new TenantThrottleConfigUpdate
|
||||
{
|
||||
Enabled = true,
|
||||
DefaultWindow = TimeSpan.FromMinutes(10),
|
||||
DefaultMaxEvents = 20
|
||||
};
|
||||
|
||||
// Act
|
||||
var config = await _service.SetTenantConfigAsync("tenant1", update, "admin");
|
||||
|
||||
// Assert
|
||||
Assert.Equal("tenant1", config.TenantId);
|
||||
Assert.True(config.Enabled);
|
||||
Assert.Equal(TimeSpan.FromMinutes(10), config.DefaultWindow);
|
||||
Assert.Equal(20, config.DefaultMaxEvents);
|
||||
Assert.Equal("admin", config.UpdatedBy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetTenantConfigAsync_LogsAuditEntry()
|
||||
{
|
||||
// Arrange
|
||||
var update = new TenantThrottleConfigUpdate { DefaultMaxEvents = 50 };
|
||||
|
||||
// Act
|
||||
await _service.SetTenantConfigAsync("tenant1", update, "admin");
|
||||
|
||||
// Assert
|
||||
_auditLogger.Verify(a => a.LogAsync(
|
||||
It.Is<SuppressionAuditEntry>(e =>
|
||||
e.Action == SuppressionAuditAction.ThrottleConfigUpdated &&
|
||||
e.ResourceType == "TenantThrottleConfig" &&
|
||||
e.Actor == "admin"),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEffectiveConfigAsync_UsesTenantConfigWhenSet()
|
||||
{
|
||||
// Arrange
|
||||
await _service.SetTenantConfigAsync("tenant1", new TenantThrottleConfigUpdate
|
||||
{
|
||||
DefaultWindow = TimeSpan.FromMinutes(15),
|
||||
DefaultMaxEvents = 25
|
||||
}, "admin");
|
||||
|
||||
// Act
|
||||
var config = await _service.GetEffectiveConfigAsync("tenant1", "event.test");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(TimeSpan.FromMinutes(15), config.Window);
|
||||
Assert.Equal(25, config.MaxEvents);
|
||||
Assert.Equal("tenant", config.Source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetEventKindConfigAsync_CreatesEventKindOverride()
|
||||
{
|
||||
// Arrange
|
||||
var update = new EventKindThrottleConfigUpdate
|
||||
{
|
||||
Window = TimeSpan.FromMinutes(1),
|
||||
MaxEvents = 5
|
||||
};
|
||||
|
||||
// Act
|
||||
var config = await _service.SetEventKindConfigAsync("tenant1", "critical.*", update, "admin");
|
||||
|
||||
// Assert
|
||||
Assert.Equal("tenant1", config.TenantId);
|
||||
Assert.Equal("critical.*", config.EventKindPattern);
|
||||
Assert.Equal(TimeSpan.FromMinutes(1), config.Window);
|
||||
Assert.Equal(5, config.MaxEvents);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEffectiveConfigAsync_UsesEventKindOverrideWhenMatches()
|
||||
{
|
||||
// Arrange
|
||||
await _service.SetTenantConfigAsync("tenant1", new TenantThrottleConfigUpdate
|
||||
{
|
||||
DefaultWindow = TimeSpan.FromMinutes(10),
|
||||
DefaultMaxEvents = 20
|
||||
}, "admin");
|
||||
|
||||
await _service.SetEventKindConfigAsync("tenant1", "critical.*", new EventKindThrottleConfigUpdate
|
||||
{
|
||||
Window = TimeSpan.FromMinutes(1),
|
||||
MaxEvents = 100
|
||||
}, "admin");
|
||||
|
||||
// Act
|
||||
var criticalConfig = await _service.GetEffectiveConfigAsync("tenant1", "critical.security.breach");
|
||||
var normalConfig = await _service.GetEffectiveConfigAsync("tenant1", "info.scan.complete");
|
||||
|
||||
// Assert
|
||||
Assert.Equal("event_kind", criticalConfig.Source);
|
||||
Assert.Equal(TimeSpan.FromMinutes(1), criticalConfig.Window);
|
||||
Assert.Equal(100, criticalConfig.MaxEvents);
|
||||
Assert.Equal("critical.*", criticalConfig.MatchedPattern);
|
||||
|
||||
Assert.Equal("tenant", normalConfig.Source);
|
||||
Assert.Equal(TimeSpan.FromMinutes(10), normalConfig.Window);
|
||||
Assert.Equal(20, normalConfig.MaxEvents);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEffectiveConfigAsync_UsesMoreSpecificPatternFirst()
|
||||
{
|
||||
// Arrange
|
||||
await _service.SetEventKindConfigAsync("tenant1", "vulnerability.*", new EventKindThrottleConfigUpdate
|
||||
{
|
||||
MaxEvents = 10,
|
||||
Priority = 100
|
||||
}, "admin");
|
||||
|
||||
await _service.SetEventKindConfigAsync("tenant1", "vulnerability.critical.*", new EventKindThrottleConfigUpdate
|
||||
{
|
||||
MaxEvents = 5,
|
||||
Priority = 50 // Higher priority (lower number)
|
||||
}, "admin");
|
||||
|
||||
// Act
|
||||
var specificConfig = await _service.GetEffectiveConfigAsync("tenant1", "vulnerability.critical.cve123");
|
||||
var generalConfig = await _service.GetEffectiveConfigAsync("tenant1", "vulnerability.low.cve456");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(5, specificConfig.MaxEvents);
|
||||
Assert.Equal("vulnerability.critical.*", specificConfig.MatchedPattern);
|
||||
|
||||
Assert.Equal(10, generalConfig.MaxEvents);
|
||||
Assert.Equal("vulnerability.*", generalConfig.MatchedPattern);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEffectiveConfigAsync_DisabledEventKindDisablesThrottling()
|
||||
{
|
||||
// Arrange
|
||||
await _service.SetTenantConfigAsync("tenant1", new TenantThrottleConfigUpdate
|
||||
{
|
||||
Enabled = true,
|
||||
DefaultMaxEvents = 20
|
||||
}, "admin");
|
||||
|
||||
await _service.SetEventKindConfigAsync("tenant1", "info.*", new EventKindThrottleConfigUpdate
|
||||
{
|
||||
Enabled = false
|
||||
}, "admin");
|
||||
|
||||
// Act
|
||||
var config = await _service.GetEffectiveConfigAsync("tenant1", "info.log");
|
||||
|
||||
// Assert
|
||||
Assert.False(config.Enabled);
|
||||
Assert.Equal("event_kind", config.Source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListEventKindConfigsAsync_ReturnsAllConfigsForTenant()
|
||||
{
|
||||
// Arrange
|
||||
await _service.SetEventKindConfigAsync("tenant1", "critical.*", new EventKindThrottleConfigUpdate { MaxEvents = 5, Priority = 10 }, "admin");
|
||||
await _service.SetEventKindConfigAsync("tenant1", "info.*", new EventKindThrottleConfigUpdate { MaxEvents = 100, Priority = 100 }, "admin");
|
||||
await _service.SetEventKindConfigAsync("tenant2", "other.*", new EventKindThrottleConfigUpdate { MaxEvents = 50 }, "admin");
|
||||
|
||||
// Act
|
||||
var configs = await _service.ListEventKindConfigsAsync("tenant1");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, configs.Count);
|
||||
Assert.Equal("critical.*", configs[0].EventKindPattern); // Lower priority first
|
||||
Assert.Equal("info.*", configs[1].EventKindPattern);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveEventKindConfigAsync_RemovesConfig()
|
||||
{
|
||||
// Arrange
|
||||
await _service.SetEventKindConfigAsync("tenant1", "test.*", new EventKindThrottleConfigUpdate { MaxEvents = 5 }, "admin");
|
||||
|
||||
// Act
|
||||
var removed = await _service.RemoveEventKindConfigAsync("tenant1", "test.*", "admin");
|
||||
|
||||
// Assert
|
||||
Assert.True(removed);
|
||||
var configs = await _service.ListEventKindConfigsAsync("tenant1");
|
||||
Assert.Empty(configs);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveEventKindConfigAsync_LogsAuditEntry()
|
||||
{
|
||||
// Arrange
|
||||
await _service.SetEventKindConfigAsync("tenant1", "test.*", new EventKindThrottleConfigUpdate { MaxEvents = 5 }, "admin");
|
||||
|
||||
// Act
|
||||
await _service.RemoveEventKindConfigAsync("tenant1", "test.*", "admin");
|
||||
|
||||
// Assert
|
||||
_auditLogger.Verify(a => a.LogAsync(
|
||||
It.Is<SuppressionAuditEntry>(e =>
|
||||
e.Action == SuppressionAuditAction.ThrottleConfigDeleted &&
|
||||
e.ResourceId == "test.*"),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTenantConfigAsync_ReturnsNullWhenNotSet()
|
||||
{
|
||||
// Act
|
||||
var config = await _service.GetTenantConfigAsync("nonexistent");
|
||||
|
||||
// Assert
|
||||
Assert.Null(config);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTenantConfigAsync_ReturnsConfigWhenSet()
|
||||
{
|
||||
// Arrange
|
||||
await _service.SetTenantConfigAsync("tenant1", new TenantThrottleConfigUpdate { DefaultMaxEvents = 50 }, "admin");
|
||||
|
||||
// Act
|
||||
var config = await _service.GetTenantConfigAsync("tenant1");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(config);
|
||||
Assert.Equal(50, config.DefaultMaxEvents);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetTenantConfigAsync_UpdatesExistingConfig()
|
||||
{
|
||||
// Arrange
|
||||
await _service.SetTenantConfigAsync("tenant1", new TenantThrottleConfigUpdate { DefaultMaxEvents = 10 }, "admin1");
|
||||
|
||||
// Act
|
||||
var updated = await _service.SetTenantConfigAsync("tenant1", new TenantThrottleConfigUpdate { DefaultMaxEvents = 20 }, "admin2");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(20, updated.DefaultMaxEvents);
|
||||
Assert.Equal("admin2", updated.UpdatedBy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEffectiveConfigAsync_IncludesBurstAllowanceAndCooldown()
|
||||
{
|
||||
// Arrange
|
||||
await _service.SetTenantConfigAsync("tenant1", new TenantThrottleConfigUpdate
|
||||
{
|
||||
BurstAllowance = 5,
|
||||
CooldownPeriod = TimeSpan.FromMinutes(10)
|
||||
}, "admin");
|
||||
|
||||
// Act
|
||||
var config = await _service.GetEffectiveConfigAsync("tenant1", "event.test");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(5, config.BurstAllowance);
|
||||
Assert.Equal(TimeSpan.FromMinutes(10), config.CooldownPeriod);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEffectiveConfigAsync_WildcardPatternMatchesAllEvents()
|
||||
{
|
||||
// Arrange
|
||||
await _service.SetEventKindConfigAsync("tenant1", "*", new EventKindThrottleConfigUpdate
|
||||
{
|
||||
MaxEvents = 1000,
|
||||
Priority = 1000 // Very low priority
|
||||
}, "admin");
|
||||
|
||||
// Act
|
||||
var config = await _service.GetEffectiveConfigAsync("tenant1", "any.event.kind.here");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(1000, config.MaxEvents);
|
||||
Assert.Equal("*", config.MatchedPattern);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using Moq;
|
||||
using StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
using StellaOps.Notifier.Worker.Correlation;
|
||||
|
||||
namespace StellaOps.Notifier.Tests.Correlation;
|
||||
|
||||
public class ThrottleConfigurationServiceTests
|
||||
{
|
||||
private readonly Mock<INotifyAuditRepository> _auditRepository;
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly InMemoryThrottleConfigurationService _service;
|
||||
|
||||
public ThrottleConfigurationServiceTests()
|
||||
{
|
||||
_auditRepository = new Mock<INotifyAuditRepository>();
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2024, 1, 15, 10, 0, 0, TimeSpan.Zero));
|
||||
|
||||
_service = new InMemoryThrottleConfigurationService(
|
||||
_auditRepository.Object,
|
||||
_timeProvider,
|
||||
NullLogger<InMemoryThrottleConfigurationService>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetConfigurationAsync_NoConfiguration_ReturnsNull()
|
||||
{
|
||||
// Act
|
||||
var result = await _service.GetConfigurationAsync("tenant1");
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertConfigurationAsync_NewConfiguration_CreatesConfiguration()
|
||||
{
|
||||
// Arrange
|
||||
var config = CreateTestConfiguration("tenant1");
|
||||
|
||||
// Act
|
||||
var result = await _service.UpsertConfigurationAsync(config, "admin");
|
||||
|
||||
// Assert
|
||||
Assert.Equal("tenant1", result.TenantId);
|
||||
Assert.Equal(TimeSpan.FromMinutes(30), result.DefaultDuration);
|
||||
Assert.Equal(_timeProvider.GetUtcNow(), result.CreatedAt);
|
||||
Assert.Equal("admin", result.CreatedBy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertConfigurationAsync_ExistingConfiguration_UpdatesConfiguration()
|
||||
{
|
||||
// Arrange
|
||||
var config = CreateTestConfiguration("tenant1");
|
||||
await _service.UpsertConfigurationAsync(config, "admin");
|
||||
|
||||
_timeProvider.Advance(TimeSpan.FromMinutes(5));
|
||||
|
||||
var updated = config with { DefaultDuration = TimeSpan.FromMinutes(60) };
|
||||
|
||||
// Act
|
||||
var result = await _service.UpsertConfigurationAsync(updated, "admin2");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(TimeSpan.FromMinutes(60), result.DefaultDuration);
|
||||
Assert.Equal("admin", result.CreatedBy); // Original creator preserved
|
||||
Assert.Equal("admin2", result.UpdatedBy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteConfigurationAsync_ExistingConfiguration_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var config = CreateTestConfiguration("tenant1");
|
||||
await _service.UpsertConfigurationAsync(config, "admin");
|
||||
|
||||
// Act
|
||||
var result = await _service.DeleteConfigurationAsync("tenant1", "admin");
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
Assert.Null(await _service.GetConfigurationAsync("tenant1"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteConfigurationAsync_NonExistentConfiguration_ReturnsFalse()
|
||||
{
|
||||
// Act
|
||||
var result = await _service.DeleteConfigurationAsync("tenant1", "admin");
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEffectiveThrottleDurationAsync_NoConfiguration_ReturnsDefault()
|
||||
{
|
||||
// Act
|
||||
var result = await _service.GetEffectiveThrottleDurationAsync("tenant1", "event.test");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(TimeSpan.FromMinutes(15), result); // Default
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEffectiveThrottleDurationAsync_WithConfiguration_ReturnsConfiguredDuration()
|
||||
{
|
||||
// Arrange
|
||||
var config = CreateTestConfiguration("tenant1") with
|
||||
{
|
||||
DefaultDuration = TimeSpan.FromMinutes(45)
|
||||
};
|
||||
await _service.UpsertConfigurationAsync(config, "admin");
|
||||
|
||||
// Act
|
||||
var result = await _service.GetEffectiveThrottleDurationAsync("tenant1", "event.test");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(TimeSpan.FromMinutes(45), result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEffectiveThrottleDurationAsync_DisabledConfiguration_ReturnsDefault()
|
||||
{
|
||||
// Arrange
|
||||
var config = CreateTestConfiguration("tenant1") with
|
||||
{
|
||||
DefaultDuration = TimeSpan.FromMinutes(45),
|
||||
Enabled = false
|
||||
};
|
||||
await _service.UpsertConfigurationAsync(config, "admin");
|
||||
|
||||
// Act
|
||||
var result = await _service.GetEffectiveThrottleDurationAsync("tenant1", "event.test");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(TimeSpan.FromMinutes(15), result); // Default when disabled
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEffectiveThrottleDurationAsync_WithExactMatchOverride_ReturnsOverride()
|
||||
{
|
||||
// Arrange
|
||||
var config = CreateTestConfiguration("tenant1") with
|
||||
{
|
||||
DefaultDuration = TimeSpan.FromMinutes(30),
|
||||
EventKindOverrides = new Dictionary<string, TimeSpan>
|
||||
{
|
||||
["critical.alert"] = TimeSpan.FromMinutes(5)
|
||||
}
|
||||
};
|
||||
await _service.UpsertConfigurationAsync(config, "admin");
|
||||
|
||||
// Act
|
||||
var result = await _service.GetEffectiveThrottleDurationAsync("tenant1", "critical.alert");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(TimeSpan.FromMinutes(5), result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEffectiveThrottleDurationAsync_WithPrefixMatchOverride_ReturnsOverride()
|
||||
{
|
||||
// Arrange
|
||||
var config = CreateTestConfiguration("tenant1") with
|
||||
{
|
||||
DefaultDuration = TimeSpan.FromMinutes(30),
|
||||
EventKindOverrides = new Dictionary<string, TimeSpan>
|
||||
{
|
||||
["critical."] = TimeSpan.FromMinutes(5)
|
||||
}
|
||||
};
|
||||
await _service.UpsertConfigurationAsync(config, "admin");
|
||||
|
||||
// Act
|
||||
var result = await _service.GetEffectiveThrottleDurationAsync("tenant1", "critical.alert.high");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(TimeSpan.FromMinutes(5), result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEffectiveThrottleDurationAsync_WithMultipleOverrides_ReturnsLongestPrefixMatch()
|
||||
{
|
||||
// Arrange
|
||||
var config = CreateTestConfiguration("tenant1") with
|
||||
{
|
||||
DefaultDuration = TimeSpan.FromMinutes(30),
|
||||
EventKindOverrides = new Dictionary<string, TimeSpan>
|
||||
{
|
||||
["critical."] = TimeSpan.FromMinutes(5),
|
||||
["critical.alert."] = TimeSpan.FromMinutes(2)
|
||||
}
|
||||
};
|
||||
await _service.UpsertConfigurationAsync(config, "admin");
|
||||
|
||||
// Act
|
||||
var result = await _service.GetEffectiveThrottleDurationAsync("tenant1", "critical.alert.security");
|
||||
|
||||
// Assert - Should match the more specific override
|
||||
Assert.Equal(TimeSpan.FromMinutes(2), result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEffectiveThrottleDurationAsync_NoMatchingOverride_ReturnsDefault()
|
||||
{
|
||||
// Arrange
|
||||
var config = CreateTestConfiguration("tenant1") with
|
||||
{
|
||||
DefaultDuration = TimeSpan.FromMinutes(30),
|
||||
EventKindOverrides = new Dictionary<string, TimeSpan>
|
||||
{
|
||||
["critical."] = TimeSpan.FromMinutes(5)
|
||||
}
|
||||
};
|
||||
await _service.UpsertConfigurationAsync(config, "admin");
|
||||
|
||||
// Act
|
||||
var result = await _service.GetEffectiveThrottleDurationAsync("tenant1", "info.status");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(TimeSpan.FromMinutes(30), result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertConfigurationAsync_AuditsCreation()
|
||||
{
|
||||
// Arrange
|
||||
var config = CreateTestConfiguration("tenant1");
|
||||
|
||||
// Act
|
||||
await _service.UpsertConfigurationAsync(config, "admin");
|
||||
|
||||
// Assert
|
||||
_auditRepository.Verify(a => a.AppendAsync(
|
||||
"tenant1",
|
||||
"throttle_config_created",
|
||||
It.IsAny<Dictionary<string, string>>(),
|
||||
"admin",
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertConfigurationAsync_AuditsUpdate()
|
||||
{
|
||||
// Arrange
|
||||
var config = CreateTestConfiguration("tenant1");
|
||||
await _service.UpsertConfigurationAsync(config, "admin");
|
||||
_auditRepository.Invocations.Clear();
|
||||
|
||||
// Act
|
||||
await _service.UpsertConfigurationAsync(config with { DefaultDuration = TimeSpan.FromHours(1) }, "admin2");
|
||||
|
||||
// Assert
|
||||
_auditRepository.Verify(a => a.AppendAsync(
|
||||
"tenant1",
|
||||
"throttle_config_updated",
|
||||
It.IsAny<Dictionary<string, string>>(),
|
||||
"admin2",
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteConfigurationAsync_AuditsDeletion()
|
||||
{
|
||||
// Arrange
|
||||
var config = CreateTestConfiguration("tenant1");
|
||||
await _service.UpsertConfigurationAsync(config, "admin");
|
||||
_auditRepository.Invocations.Clear();
|
||||
|
||||
// Act
|
||||
await _service.DeleteConfigurationAsync("tenant1", "admin");
|
||||
|
||||
// Assert
|
||||
_auditRepository.Verify(a => a.AppendAsync(
|
||||
"tenant1",
|
||||
"throttle_config_deleted",
|
||||
It.IsAny<Dictionary<string, string>>(),
|
||||
"admin",
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
private static ThrottleConfiguration CreateTestConfiguration(string tenantId) => new()
|
||||
{
|
||||
TenantId = tenantId,
|
||||
DefaultDuration = TimeSpan.FromMinutes(30),
|
||||
Enabled = true
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
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) { }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Notifier.Worker.Digest;
|
||||
|
||||
namespace StellaOps.Notifier.Tests.Digest;
|
||||
|
||||
public class InMemoryDigestSchedulerTests
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly InMemoryDigestScheduler _scheduler;
|
||||
|
||||
public InMemoryDigestSchedulerTests()
|
||||
{
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2024, 1, 15, 10, 0, 0, TimeSpan.Zero));
|
||||
_scheduler = new InMemoryDigestScheduler(
|
||||
_timeProvider,
|
||||
NullLogger<InMemoryDigestScheduler>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertScheduleAsync_CreatesNewSchedule()
|
||||
{
|
||||
// Arrange
|
||||
var schedule = CreateTestSchedule("schedule-1");
|
||||
|
||||
// Act
|
||||
var result = await _scheduler.UpsertScheduleAsync(schedule);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("schedule-1", result.ScheduleId);
|
||||
Assert.NotNull(result.NextRunAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertScheduleAsync_UpdatesExistingSchedule()
|
||||
{
|
||||
// Arrange
|
||||
var schedule = CreateTestSchedule("schedule-1");
|
||||
await _scheduler.UpsertScheduleAsync(schedule);
|
||||
|
||||
var updated = schedule with { Name = "Updated Name" };
|
||||
|
||||
// Act
|
||||
var result = await _scheduler.UpsertScheduleAsync(updated);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Updated Name", result.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetScheduleAsync_ReturnsSchedule()
|
||||
{
|
||||
// Arrange
|
||||
var schedule = CreateTestSchedule("schedule-1");
|
||||
await _scheduler.UpsertScheduleAsync(schedule);
|
||||
|
||||
// Act
|
||||
var result = await _scheduler.GetScheduleAsync("tenant1", "schedule-1");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("schedule-1", result.ScheduleId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetScheduleAsync_ReturnsNullForUnknown()
|
||||
{
|
||||
// Act
|
||||
var result = await _scheduler.GetScheduleAsync("tenant1", "unknown");
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSchedulesAsync_ReturnsTenantSchedules()
|
||||
{
|
||||
// Arrange
|
||||
await _scheduler.UpsertScheduleAsync(CreateTestSchedule("schedule-1", "tenant1"));
|
||||
await _scheduler.UpsertScheduleAsync(CreateTestSchedule("schedule-2", "tenant1"));
|
||||
await _scheduler.UpsertScheduleAsync(CreateTestSchedule("schedule-3", "tenant2"));
|
||||
|
||||
// Act
|
||||
var result = await _scheduler.GetSchedulesAsync("tenant1");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, result.Count);
|
||||
Assert.All(result, s => Assert.Equal("tenant1", s.TenantId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteScheduleAsync_RemovesSchedule()
|
||||
{
|
||||
// Arrange
|
||||
await _scheduler.UpsertScheduleAsync(CreateTestSchedule("schedule-1"));
|
||||
|
||||
// Act
|
||||
var deleted = await _scheduler.DeleteScheduleAsync("tenant1", "schedule-1");
|
||||
|
||||
// Assert
|
||||
Assert.True(deleted);
|
||||
var result = await _scheduler.GetScheduleAsync("tenant1", "schedule-1");
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteScheduleAsync_ReturnsFalseForUnknown()
|
||||
{
|
||||
// Act
|
||||
var deleted = await _scheduler.DeleteScheduleAsync("tenant1", "unknown");
|
||||
|
||||
// Assert
|
||||
Assert.False(deleted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetDueSchedulesAsync_ReturnsDueSchedules()
|
||||
{
|
||||
// Arrange - create a schedule that should run every minute
|
||||
var schedule = CreateTestSchedule("schedule-1") with
|
||||
{
|
||||
CronExpression = "0 * * * * *" // Every minute
|
||||
};
|
||||
await _scheduler.UpsertScheduleAsync(schedule);
|
||||
|
||||
// Advance time past next run
|
||||
_timeProvider.Advance(TimeSpan.FromMinutes(2));
|
||||
|
||||
// Act
|
||||
var dueSchedules = await _scheduler.GetDueSchedulesAsync(_timeProvider.GetUtcNow());
|
||||
|
||||
// Assert
|
||||
Assert.Single(dueSchedules);
|
||||
Assert.Equal("schedule-1", dueSchedules[0].ScheduleId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetDueSchedulesAsync_ExcludesDisabledSchedules()
|
||||
{
|
||||
// Arrange
|
||||
var schedule = CreateTestSchedule("schedule-1") with
|
||||
{
|
||||
Enabled = false,
|
||||
CronExpression = "0 * * * * *"
|
||||
};
|
||||
await _scheduler.UpsertScheduleAsync(schedule);
|
||||
|
||||
_timeProvider.Advance(TimeSpan.FromMinutes(2));
|
||||
|
||||
// Act
|
||||
var dueSchedules = await _scheduler.GetDueSchedulesAsync(_timeProvider.GetUtcNow());
|
||||
|
||||
// Assert
|
||||
Assert.Empty(dueSchedules);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateLastRunAsync_UpdatesTimestamps()
|
||||
{
|
||||
// Arrange
|
||||
var schedule = CreateTestSchedule("schedule-1") with
|
||||
{
|
||||
CronExpression = "0 0 * * * *" // Every hour
|
||||
};
|
||||
await _scheduler.UpsertScheduleAsync(schedule);
|
||||
|
||||
var runTime = _timeProvider.GetUtcNow();
|
||||
|
||||
// Act
|
||||
await _scheduler.UpdateLastRunAsync("tenant1", "schedule-1", runTime);
|
||||
|
||||
// Assert
|
||||
var updated = await _scheduler.GetScheduleAsync("tenant1", "schedule-1");
|
||||
Assert.NotNull(updated);
|
||||
Assert.Equal(runTime, updated.LastRunAt);
|
||||
Assert.NotNull(updated.NextRunAt);
|
||||
Assert.True(updated.NextRunAt > runTime);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertScheduleAsync_CalculatesNextRunWithTimezone()
|
||||
{
|
||||
// Arrange
|
||||
var schedule = CreateTestSchedule("schedule-1") with
|
||||
{
|
||||
CronExpression = "0 0 9 * * *", // 9 AM every day
|
||||
Timezone = "America/New_York"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _scheduler.UpsertScheduleAsync(schedule);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result.NextRunAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertScheduleAsync_HandlesInvalidCron()
|
||||
{
|
||||
// Arrange
|
||||
var schedule = CreateTestSchedule("schedule-1") with
|
||||
{
|
||||
CronExpression = "invalid-cron"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _scheduler.UpsertScheduleAsync(schedule);
|
||||
|
||||
// Assert
|
||||
Assert.Null(result.NextRunAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSchedulesAsync_OrdersByName()
|
||||
{
|
||||
// Arrange
|
||||
await _scheduler.UpsertScheduleAsync(CreateTestSchedule("schedule-c") with { Name = "Charlie" });
|
||||
await _scheduler.UpsertScheduleAsync(CreateTestSchedule("schedule-a") with { Name = "Alpha" });
|
||||
await _scheduler.UpsertScheduleAsync(CreateTestSchedule("schedule-b") with { Name = "Bravo" });
|
||||
|
||||
// Act
|
||||
var result = await _scheduler.GetSchedulesAsync("tenant1");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(3, result.Count);
|
||||
Assert.Equal("Alpha", result[0].Name);
|
||||
Assert.Equal("Bravo", result[1].Name);
|
||||
Assert.Equal("Charlie", result[2].Name);
|
||||
}
|
||||
|
||||
private DigestSchedule CreateTestSchedule(string id, string tenantId = "tenant1")
|
||||
{
|
||||
return new DigestSchedule
|
||||
{
|
||||
ScheduleId = id,
|
||||
TenantId = tenantId,
|
||||
Name = $"Test Schedule {id}",
|
||||
Enabled = true,
|
||||
CronExpression = "0 0 8 * * *", // 8 AM daily
|
||||
DigestType = DigestType.Daily,
|
||||
Format = DigestFormat.Html,
|
||||
CreatedAt = _timeProvider.GetUtcNow(),
|
||||
Recipients =
|
||||
[
|
||||
new DigestRecipient { Type = "email", Address = "test@example.com" }
|
||||
]
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notifier.Worker.Dispatch;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Notifier.Tests.Dispatch;
|
||||
|
||||
public sealed class SimpleTemplateRendererTests
|
||||
{
|
||||
private readonly SimpleTemplateRenderer _renderer;
|
||||
|
||||
public SimpleTemplateRendererTests()
|
||||
{
|
||||
_renderer = new SimpleTemplateRenderer(NullLogger<SimpleTemplateRenderer>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RenderAsync_SimpleVariableSubstitution_ReplacesVariables()
|
||||
{
|
||||
var template = NotifyTemplate.Create(
|
||||
templateId: "tpl-1",
|
||||
tenantId: "tenant-a",
|
||||
channelType: NotifyChannelType.Slack,
|
||||
key: "test-template",
|
||||
locale: "en",
|
||||
body: "Hello {{actor}}, event {{kind}} occurred.");
|
||||
|
||||
var notifyEvent = NotifyEvent.Create(
|
||||
eventId: Guid.NewGuid(),
|
||||
kind: "policy.violation",
|
||||
tenant: "tenant-a",
|
||||
ts: DateTimeOffset.UtcNow,
|
||||
payload: new JsonObject(),
|
||||
actor: "admin@example.com",
|
||||
version: "1");
|
||||
|
||||
var result = await _renderer.RenderAsync(template, notifyEvent);
|
||||
|
||||
Assert.Contains("Hello admin@example.com", result.Body);
|
||||
Assert.Contains("event policy.violation occurred", result.Body);
|
||||
Assert.NotEmpty(result.BodyHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RenderAsync_PayloadVariables_FlattenedAndAvailable()
|
||||
{
|
||||
var template = NotifyTemplate.Create(
|
||||
templateId: "tpl-2",
|
||||
tenantId: "tenant-a",
|
||||
channelType: NotifyChannelType.Webhook,
|
||||
key: "payload-test",
|
||||
locale: "en",
|
||||
body: "Image: {{image}}, Severity: {{severity}}");
|
||||
|
||||
var payload = new JsonObject
|
||||
{
|
||||
["image"] = "registry.local/api:v1.0",
|
||||
["severity"] = "critical"
|
||||
};
|
||||
|
||||
var notifyEvent = NotifyEvent.Create(
|
||||
eventId: Guid.NewGuid(),
|
||||
kind: "scan.complete",
|
||||
tenant: "tenant-a",
|
||||
ts: DateTimeOffset.UtcNow,
|
||||
payload: payload,
|
||||
version: "1");
|
||||
|
||||
var result = await _renderer.RenderAsync(template, notifyEvent);
|
||||
|
||||
Assert.Contains("Image: registry.local/api:v1.0", result.Body);
|
||||
Assert.Contains("Severity: critical", result.Body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RenderAsync_NestedPayloadVariables_SupportsDotNotation()
|
||||
{
|
||||
var template = NotifyTemplate.Create(
|
||||
templateId: "tpl-3",
|
||||
tenantId: "tenant-a",
|
||||
channelType: NotifyChannelType.Slack,
|
||||
key: "nested-test",
|
||||
locale: "en",
|
||||
body: "Package: {{package.name}} v{{package.version}}");
|
||||
|
||||
var payload = new JsonObject
|
||||
{
|
||||
["package"] = new JsonObject
|
||||
{
|
||||
["name"] = "lodash",
|
||||
["version"] = "4.17.21"
|
||||
}
|
||||
};
|
||||
|
||||
var notifyEvent = NotifyEvent.Create(
|
||||
eventId: Guid.NewGuid(),
|
||||
kind: "vulnerability.found",
|
||||
tenant: "tenant-a",
|
||||
ts: DateTimeOffset.UtcNow,
|
||||
payload: payload,
|
||||
version: "1");
|
||||
|
||||
var result = await _renderer.RenderAsync(template, notifyEvent);
|
||||
|
||||
Assert.Contains("Package: lodash v4.17.21", result.Body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RenderAsync_SensitiveKeys_AreRedacted()
|
||||
{
|
||||
var template = NotifyTemplate.Create(
|
||||
templateId: "tpl-4",
|
||||
tenantId: "tenant-a",
|
||||
channelType: NotifyChannelType.Webhook,
|
||||
key: "redact-test",
|
||||
locale: "en",
|
||||
body: "Token: {{apikey}}, User: {{username}}");
|
||||
|
||||
var payload = new JsonObject
|
||||
{
|
||||
["apikey"] = "secret-token-12345",
|
||||
["username"] = "testuser"
|
||||
};
|
||||
|
||||
var notifyEvent = NotifyEvent.Create(
|
||||
eventId: Guid.NewGuid(),
|
||||
kind: "auth.event",
|
||||
tenant: "tenant-a",
|
||||
ts: DateTimeOffset.UtcNow,
|
||||
payload: payload,
|
||||
version: "1");
|
||||
|
||||
var result = await _renderer.RenderAsync(template, notifyEvent);
|
||||
|
||||
Assert.Contains("[REDACTED]", result.Body);
|
||||
Assert.Contains("User: testuser", result.Body);
|
||||
Assert.DoesNotContain("secret-token-12345", result.Body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RenderAsync_MissingVariables_ReplacedWithEmptyString()
|
||||
{
|
||||
var template = NotifyTemplate.Create(
|
||||
templateId: "tpl-5",
|
||||
tenantId: "tenant-a",
|
||||
channelType: NotifyChannelType.Slack,
|
||||
key: "missing-test",
|
||||
locale: "en",
|
||||
body: "Value: {{nonexistent}}-end");
|
||||
|
||||
var notifyEvent = NotifyEvent.Create(
|
||||
eventId: Guid.NewGuid(),
|
||||
kind: "test.event",
|
||||
tenant: "tenant-a",
|
||||
ts: DateTimeOffset.UtcNow,
|
||||
payload: new JsonObject(),
|
||||
version: "1");
|
||||
|
||||
var result = await _renderer.RenderAsync(template, notifyEvent);
|
||||
|
||||
Assert.Equal("Value: -end", result.Body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RenderAsync_EachBlock_IteratesOverArray()
|
||||
{
|
||||
var template = NotifyTemplate.Create(
|
||||
templateId: "tpl-6",
|
||||
tenantId: "tenant-a",
|
||||
channelType: NotifyChannelType.Slack,
|
||||
key: "each-test",
|
||||
locale: "en",
|
||||
body: "Items:{{#each items}} {{this}}{{/each}}");
|
||||
|
||||
var payload = new JsonObject
|
||||
{
|
||||
["items"] = new JsonArray("alpha", "beta", "gamma")
|
||||
};
|
||||
|
||||
var notifyEvent = NotifyEvent.Create(
|
||||
eventId: Guid.NewGuid(),
|
||||
kind: "list.event",
|
||||
tenant: "tenant-a",
|
||||
ts: DateTimeOffset.UtcNow,
|
||||
payload: payload,
|
||||
version: "1");
|
||||
|
||||
var result = await _renderer.RenderAsync(template, notifyEvent);
|
||||
|
||||
Assert.Contains("alpha", result.Body);
|
||||
Assert.Contains("beta", result.Body);
|
||||
Assert.Contains("gamma", result.Body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RenderAsync_SubjectFromMetadata_RendersSubject()
|
||||
{
|
||||
var template = NotifyTemplate.Create(
|
||||
templateId: "tpl-7",
|
||||
tenantId: "tenant-a",
|
||||
channelType: NotifyChannelType.Webhook,
|
||||
key: "subject-test",
|
||||
locale: "en",
|
||||
body: "Body content",
|
||||
metadata: new[] { new KeyValuePair<string, string>("subject", "Alert: {{kind}}") });
|
||||
|
||||
var notifyEvent = NotifyEvent.Create(
|
||||
eventId: Guid.NewGuid(),
|
||||
kind: "critical.alert",
|
||||
tenant: "tenant-a",
|
||||
ts: DateTimeOffset.UtcNow,
|
||||
payload: new JsonObject(),
|
||||
version: "1");
|
||||
|
||||
var result = await _renderer.RenderAsync(template, notifyEvent);
|
||||
|
||||
Assert.Equal("Alert: critical.alert", result.Subject);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RenderAsync_BodyHash_IsConsistent()
|
||||
{
|
||||
var template = NotifyTemplate.Create(
|
||||
templateId: "tpl-8",
|
||||
tenantId: "tenant-a",
|
||||
channelType: NotifyChannelType.Slack,
|
||||
key: "hash-test",
|
||||
locale: "en",
|
||||
body: "Static content");
|
||||
|
||||
var notifyEvent = NotifyEvent.Create(
|
||||
eventId: Guid.NewGuid(),
|
||||
kind: "test.event",
|
||||
tenant: "tenant-a",
|
||||
ts: DateTimeOffset.UtcNow,
|
||||
payload: new JsonObject(),
|
||||
version: "1");
|
||||
|
||||
var result1 = await _renderer.RenderAsync(template, notifyEvent);
|
||||
var result2 = await _renderer.RenderAsync(template, notifyEvent);
|
||||
|
||||
Assert.Equal(result1.BodyHash, result2.BodyHash);
|
||||
Assert.Equal(64, result1.BodyHash.Length); // SHA256 hex
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RenderAsync_Format_PreservedFromTemplate()
|
||||
{
|
||||
var template = NotifyTemplate.Create(
|
||||
templateId: "tpl-9",
|
||||
tenantId: "tenant-a",
|
||||
channelType: NotifyChannelType.Slack,
|
||||
key: "format-test",
|
||||
locale: "en",
|
||||
body: "Content",
|
||||
format: NotifyDeliveryFormat.Markdown);
|
||||
|
||||
var notifyEvent = NotifyEvent.Create(
|
||||
eventId: Guid.NewGuid(),
|
||||
kind: "test.event",
|
||||
tenant: "tenant-a",
|
||||
ts: DateTimeOffset.UtcNow,
|
||||
payload: new JsonObject(),
|
||||
version: "1");
|
||||
|
||||
var result = await _renderer.RenderAsync(template, notifyEvent);
|
||||
|
||||
Assert.Equal(NotifyDeliveryFormat.Markdown, result.Format);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
using System.Net;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notifier.Worker.Dispatch;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Notifier.Tests.Dispatch;
|
||||
|
||||
public sealed class WebhookChannelDispatcherTests
|
||||
{
|
||||
[Fact]
|
||||
public void SupportedTypes_IncludesSlackAndWebhook()
|
||||
{
|
||||
var handler = new TestHttpMessageHandler(HttpStatusCode.OK);
|
||||
var client = new HttpClient(handler);
|
||||
var dispatcher = new WebhookChannelDispatcher(client, NullLogger<WebhookChannelDispatcher>.Instance);
|
||||
|
||||
Assert.Contains(NotifyChannelType.Slack, dispatcher.SupportedTypes);
|
||||
Assert.Contains(NotifyChannelType.Webhook, dispatcher.SupportedTypes);
|
||||
Assert.Contains(NotifyChannelType.Custom, dispatcher.SupportedTypes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DispatchAsync_SuccessfulDelivery_ReturnsSucceeded()
|
||||
{
|
||||
var handler = new TestHttpMessageHandler(HttpStatusCode.OK);
|
||||
var client = new HttpClient(handler);
|
||||
var dispatcher = new WebhookChannelDispatcher(client, NullLogger<WebhookChannelDispatcher>.Instance);
|
||||
|
||||
var channel = CreateChannel("https://hooks.example.com/webhook");
|
||||
var content = CreateContent("Test message");
|
||||
var delivery = CreateDelivery();
|
||||
|
||||
var result = await dispatcher.DispatchAsync(channel, content, delivery);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal(NotifyDeliveryStatus.Delivered, result.Status);
|
||||
Assert.Equal(1, result.AttemptCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DispatchAsync_InvalidEndpoint_ReturnsFailedWithMessage()
|
||||
{
|
||||
var handler = new TestHttpMessageHandler(HttpStatusCode.OK);
|
||||
var client = new HttpClient(handler);
|
||||
var dispatcher = new WebhookChannelDispatcher(client, NullLogger<WebhookChannelDispatcher>.Instance);
|
||||
|
||||
var channel = CreateChannel("not-a-valid-url");
|
||||
var content = CreateContent("Test message");
|
||||
var delivery = CreateDelivery();
|
||||
|
||||
var result = await dispatcher.DispatchAsync(channel, content, delivery);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal(NotifyDeliveryStatus.Failed, result.Status);
|
||||
Assert.Contains("Invalid webhook endpoint", result.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DispatchAsync_NullEndpoint_ReturnsFailedWithMessage()
|
||||
{
|
||||
var handler = new TestHttpMessageHandler(HttpStatusCode.OK);
|
||||
var client = new HttpClient(handler);
|
||||
var dispatcher = new WebhookChannelDispatcher(client, NullLogger<WebhookChannelDispatcher>.Instance);
|
||||
|
||||
var channel = CreateChannel(null);
|
||||
var content = CreateContent("Test message");
|
||||
var delivery = CreateDelivery();
|
||||
|
||||
var result = await dispatcher.DispatchAsync(channel, content, delivery);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Contains("Invalid webhook endpoint", result.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DispatchAsync_4xxError_ReturnsNonRetryable()
|
||||
{
|
||||
var handler = new TestHttpMessageHandler(HttpStatusCode.BadRequest);
|
||||
var client = new HttpClient(handler);
|
||||
var dispatcher = new WebhookChannelDispatcher(client, NullLogger<WebhookChannelDispatcher>.Instance);
|
||||
|
||||
var channel = CreateChannel("https://hooks.example.com/webhook");
|
||||
var content = CreateContent("Test message");
|
||||
var delivery = CreateDelivery();
|
||||
|
||||
var result = await dispatcher.DispatchAsync(channel, content, delivery);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal(NotifyDeliveryStatus.Failed, result.Status);
|
||||
Assert.False(result.IsRetryable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DispatchAsync_5xxError_ReturnsRetryable()
|
||||
{
|
||||
var handler = new TestHttpMessageHandler(HttpStatusCode.InternalServerError);
|
||||
var client = new HttpClient(handler);
|
||||
var dispatcher = new WebhookChannelDispatcher(client, NullLogger<WebhookChannelDispatcher>.Instance);
|
||||
|
||||
var channel = CreateChannel("https://hooks.example.com/webhook");
|
||||
var content = CreateContent("Test message");
|
||||
var delivery = CreateDelivery();
|
||||
|
||||
var result = await dispatcher.DispatchAsync(channel, content, delivery);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.True(result.IsRetryable);
|
||||
Assert.Equal(3, result.AttemptCount); // Should retry up to 3 times
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DispatchAsync_TooManyRequests_ReturnsRetryable()
|
||||
{
|
||||
var handler = new TestHttpMessageHandler(HttpStatusCode.TooManyRequests);
|
||||
var client = new HttpClient(handler);
|
||||
var dispatcher = new WebhookChannelDispatcher(client, NullLogger<WebhookChannelDispatcher>.Instance);
|
||||
|
||||
var channel = CreateChannel("https://hooks.example.com/webhook");
|
||||
var content = CreateContent("Test message");
|
||||
var delivery = CreateDelivery();
|
||||
|
||||
var result = await dispatcher.DispatchAsync(channel, content, delivery);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.True(result.IsRetryable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DispatchAsync_SlackChannel_FormatsCorrectly()
|
||||
{
|
||||
string? capturedBody = null;
|
||||
var handler = new TestHttpMessageHandler(HttpStatusCode.OK, req =>
|
||||
{
|
||||
capturedBody = req.Content?.ReadAsStringAsync().GetAwaiter().GetResult();
|
||||
});
|
||||
var client = new HttpClient(handler);
|
||||
var dispatcher = new WebhookChannelDispatcher(client, NullLogger<WebhookChannelDispatcher>.Instance);
|
||||
|
||||
var channel = NotifyChannel.Create(
|
||||
channelId: "chn-slack",
|
||||
tenantId: "tenant-a",
|
||||
name: "Slack Alerts",
|
||||
type: NotifyChannelType.Slack,
|
||||
config: NotifyChannelConfig.Create(
|
||||
secretRef: "secret-ref",
|
||||
target: "#alerts",
|
||||
endpoint: "https://hooks.slack.com/services/xxx"));
|
||||
|
||||
var content = CreateContent("Alert notification");
|
||||
var delivery = CreateDelivery();
|
||||
|
||||
await dispatcher.DispatchAsync(channel, content, delivery);
|
||||
|
||||
Assert.NotNull(capturedBody);
|
||||
Assert.Contains("\"text\":", capturedBody);
|
||||
Assert.Contains("\"channel\":", capturedBody);
|
||||
Assert.Contains("#alerts", capturedBody);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DispatchAsync_GenericWebhook_IncludesDeliveryMetadata()
|
||||
{
|
||||
string? capturedBody = null;
|
||||
var handler = new TestHttpMessageHandler(HttpStatusCode.OK, req =>
|
||||
{
|
||||
capturedBody = req.Content?.ReadAsStringAsync().GetAwaiter().GetResult();
|
||||
});
|
||||
var client = new HttpClient(handler);
|
||||
var dispatcher = new WebhookChannelDispatcher(client, NullLogger<WebhookChannelDispatcher>.Instance);
|
||||
|
||||
var channel = CreateChannel("https://api.example.com/notifications");
|
||||
var content = CreateContent("Webhook content");
|
||||
var delivery = CreateDelivery();
|
||||
|
||||
await dispatcher.DispatchAsync(channel, content, delivery);
|
||||
|
||||
Assert.NotNull(capturedBody);
|
||||
Assert.Contains("\"deliveryId\":", capturedBody);
|
||||
Assert.Contains("\"eventId\":", capturedBody);
|
||||
Assert.Contains("\"kind\":", capturedBody);
|
||||
Assert.Contains("\"body\":", capturedBody);
|
||||
}
|
||||
|
||||
private static NotifyChannel CreateChannel(string? endpoint)
|
||||
{
|
||||
return NotifyChannel.Create(
|
||||
channelId: "chn-test",
|
||||
tenantId: "tenant-a",
|
||||
name: "Test Channel",
|
||||
type: NotifyChannelType.Webhook,
|
||||
config: NotifyChannelConfig.Create(
|
||||
secretRef: "secret-ref",
|
||||
endpoint: endpoint));
|
||||
}
|
||||
|
||||
private static NotifyRenderedContent CreateContent(string body)
|
||||
{
|
||||
return new NotifyRenderedContent
|
||||
{
|
||||
Body = body,
|
||||
Subject = "Test Subject",
|
||||
BodyHash = "abc123",
|
||||
Format = NotifyDeliveryFormat.PlainText
|
||||
};
|
||||
}
|
||||
|
||||
private static NotifyDelivery CreateDelivery()
|
||||
{
|
||||
return NotifyDelivery.Create(
|
||||
deliveryId: "del-test-001",
|
||||
tenantId: "tenant-a",
|
||||
ruleId: "rule-1",
|
||||
actionId: "act-1",
|
||||
eventId: Guid.NewGuid(),
|
||||
kind: "test.event",
|
||||
status: NotifyDeliveryStatus.Pending);
|
||||
}
|
||||
|
||||
private sealed class TestHttpMessageHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly HttpStatusCode _statusCode;
|
||||
private readonly Action<HttpRequestMessage>? _onRequest;
|
||||
|
||||
public TestHttpMessageHandler(HttpStatusCode statusCode, Action<HttpRequestMessage>? onRequest = null)
|
||||
{
|
||||
_statusCode = statusCode;
|
||||
_onRequest = onRequest;
|
||||
}
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
_onRequest?.Invoke(request);
|
||||
return Task.FromResult(new HttpResponseMessage(_statusCode)
|
||||
{
|
||||
Content = new StringContent("OK")
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Notifier.WebService.Contracts;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Notifier.Tests.Endpoints;
|
||||
|
||||
public sealed class NotifyApiEndpointsTests : IClassFixture<WebApplicationFactory<Program>>
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
private readonly InMemoryRuleRepository _ruleRepository;
|
||||
private readonly InMemoryTemplateRepository _templateRepository;
|
||||
|
||||
public NotifyApiEndpointsTests(WebApplicationFactory<Program> factory)
|
||||
{
|
||||
_ruleRepository = new InMemoryRuleRepository();
|
||||
_templateRepository = new InMemoryTemplateRepository();
|
||||
|
||||
var customFactory = factory.WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
services.AddSingleton<INotifyRuleRepository>(_ruleRepository);
|
||||
services.AddSingleton<INotifyTemplateRepository>(_templateRepository);
|
||||
});
|
||||
builder.UseSetting("Environment", "Testing");
|
||||
});
|
||||
|
||||
_client = customFactory.CreateClient();
|
||||
_client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", "test-tenant");
|
||||
}
|
||||
|
||||
#region Rules API Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetRules_ReturnsEmptyList_WhenNoRules()
|
||||
{
|
||||
// Act
|
||||
var response = await _client.GetAsync("/api/v2/notify/rules");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var rules = await response.Content.ReadFromJsonAsync<List<RuleResponse>>();
|
||||
Assert.NotNull(rules);
|
||||
Assert.Empty(rules);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateRule_ReturnsCreated_WithValidRequest()
|
||||
{
|
||||
// Arrange
|
||||
var request = new RuleCreateRequest
|
||||
{
|
||||
RuleId = "rule-001",
|
||||
Name = "Test Rule",
|
||||
Description = "Test description",
|
||||
Enabled = true,
|
||||
Match = new RuleMatchRequest
|
||||
{
|
||||
EventKinds = ["pack.approval.granted"],
|
||||
Labels = ["env=prod"]
|
||||
},
|
||||
Actions =
|
||||
[
|
||||
new RuleActionRequest
|
||||
{
|
||||
ActionId = "action-001",
|
||||
Channel = "slack:alerts",
|
||||
Template = "tmpl-slack-001"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync("/api/v2/notify/rules", request);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
|
||||
var rule = await response.Content.ReadFromJsonAsync<RuleResponse>();
|
||||
Assert.NotNull(rule);
|
||||
Assert.Equal("rule-001", rule.RuleId);
|
||||
Assert.Equal("Test Rule", rule.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetRule_ReturnsRule_WhenExists()
|
||||
{
|
||||
// Arrange
|
||||
var rule = NotifyRule.Create(
|
||||
ruleId: "rule-get-001",
|
||||
tenantId: "test-tenant",
|
||||
name: "Existing Rule",
|
||||
match: NotifyRuleMatch.Create(eventKinds: ["test.event"]),
|
||||
actions: []);
|
||||
await _ruleRepository.UpsertAsync(rule);
|
||||
|
||||
// Act
|
||||
var response = await _client.GetAsync("/api/v2/notify/rules/rule-get-001");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var result = await response.Content.ReadFromJsonAsync<RuleResponse>();
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("rule-get-001", result.RuleId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetRule_ReturnsNotFound_WhenNotExists()
|
||||
{
|
||||
// Act
|
||||
var response = await _client.GetAsync("/api/v2/notify/rules/nonexistent");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteRule_ReturnsNoContent_WhenExists()
|
||||
{
|
||||
// Arrange
|
||||
var rule = NotifyRule.Create(
|
||||
ruleId: "rule-delete-001",
|
||||
tenantId: "test-tenant",
|
||||
name: "Delete Me",
|
||||
match: NotifyRuleMatch.Create(),
|
||||
actions: []);
|
||||
await _ruleRepository.UpsertAsync(rule);
|
||||
|
||||
// Act
|
||||
var response = await _client.DeleteAsync("/api/v2/notify/rules/rule-delete-001");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Templates API Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetTemplates_ReturnsEmptyList_WhenNoTemplates()
|
||||
{
|
||||
// Act
|
||||
var response = await _client.GetAsync("/api/v2/notify/templates");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var templates = await response.Content.ReadFromJsonAsync<List<TemplateResponse>>();
|
||||
Assert.NotNull(templates);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PreviewTemplate_ReturnsRenderedContent()
|
||||
{
|
||||
// Arrange
|
||||
var request = new TemplatePreviewRequest
|
||||
{
|
||||
TemplateBody = "Hello {{name}}, you have {{count}} messages.",
|
||||
SamplePayload = JsonSerializer.SerializeToNode(new { name = "World", count = 5 }) as System.Text.Json.Nodes.JsonObject
|
||||
};
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync("/api/v2/notify/templates/preview", request);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var preview = await response.Content.ReadFromJsonAsync<TemplatePreviewResponse>();
|
||||
Assert.NotNull(preview);
|
||||
Assert.Contains("Hello World", preview.RenderedBody);
|
||||
Assert.Contains("5", preview.RenderedBody);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateTemplate_ReturnsValid_ForCorrectTemplate()
|
||||
{
|
||||
// Arrange
|
||||
var request = new TemplatePreviewRequest
|
||||
{
|
||||
TemplateBody = "Hello {{name}}!"
|
||||
};
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync("/api/v2/notify/templates/validate", request);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var result = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
Assert.True(result.GetProperty("isValid").GetBoolean());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateTemplate_ReturnsInvalid_ForBrokenTemplate()
|
||||
{
|
||||
// Arrange
|
||||
var request = new TemplatePreviewRequest
|
||||
{
|
||||
TemplateBody = "Hello {{name} - missing closing brace"
|
||||
};
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync("/api/v2/notify/templates/validate", request);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var result = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
Assert.False(result.GetProperty("isValid").GetBoolean());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Incidents API Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetIncidents_ReturnsIncidentList()
|
||||
{
|
||||
// Act
|
||||
var response = await _client.GetAsync("/api/v2/notify/incidents");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var result = await response.Content.ReadFromJsonAsync<IncidentListResponse>();
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Incidents);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AckIncident_ReturnsNoContent()
|
||||
{
|
||||
// Arrange
|
||||
var request = new IncidentAckRequest
|
||||
{
|
||||
Actor = "test-user",
|
||||
Comment = "Acknowledged"
|
||||
};
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync("/api/v2/notify/incidents/incident-001/ack", request);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Error Handling Tests
|
||||
|
||||
[Fact]
|
||||
public async Task AllEndpoints_ReturnBadRequest_WhenTenantMissing()
|
||||
{
|
||||
// Arrange
|
||||
var clientWithoutTenant = new HttpClient { BaseAddress = _client.BaseAddress };
|
||||
|
||||
// Act
|
||||
var response = await clientWithoutTenant.GetAsync("/api/v2/notify/rules");
|
||||
|
||||
// Assert - should fail without tenant header
|
||||
// Note: actual behavior depends on endpoint implementation
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Test Repositories
|
||||
|
||||
private sealed class InMemoryRuleRepository : INotifyRuleRepository
|
||||
{
|
||||
private readonly Dictionary<string, NotifyRule> _rules = new();
|
||||
|
||||
public Task UpsertAsync(NotifyRule rule, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var key = $"{rule.TenantId}:{rule.RuleId}";
|
||||
_rules[key] = rule;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<NotifyRule?> GetAsync(string tenantId, string ruleId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var key = $"{tenantId}:{ruleId}";
|
||||
return Task.FromResult(_rules.GetValueOrDefault(key));
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<NotifyRule>> ListAsync(string tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var result = _rules.Values.Where(r => r.TenantId == tenantId).ToList();
|
||||
return Task.FromResult<IReadOnlyList<NotifyRule>>(result);
|
||||
}
|
||||
|
||||
public Task DeleteAsync(string tenantId, string ruleId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var key = $"{tenantId}:{ruleId}";
|
||||
_rules.Remove(key);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class InMemoryTemplateRepository : INotifyTemplateRepository
|
||||
{
|
||||
private readonly Dictionary<string, NotifyTemplate> _templates = new();
|
||||
|
||||
public Task UpsertAsync(NotifyTemplate template, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var key = $"{template.TenantId}:{template.TemplateId}";
|
||||
_templates[key] = template;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<NotifyTemplate?> GetAsync(string tenantId, string templateId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var key = $"{tenantId}:{templateId}";
|
||||
return Task.FromResult(_templates.GetValueOrDefault(key));
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<NotifyTemplate>> ListAsync(string tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var result = _templates.Values.Where(t => t.TenantId == tenantId).ToList();
|
||||
return Task.FromResult<IReadOnlyList<NotifyTemplate>>(result);
|
||||
}
|
||||
|
||||
public Task DeleteAsync(string tenantId, string templateId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var key = $"{tenantId}:{templateId}";
|
||||
_templates.Remove(key);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notifier.Worker.Fallback;
|
||||
|
||||
namespace StellaOps.Notifier.Tests.Fallback;
|
||||
|
||||
public class InMemoryFallbackHandlerTests
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly FallbackHandlerOptions _options;
|
||||
private readonly InMemoryFallbackHandler _fallbackHandler;
|
||||
|
||||
public InMemoryFallbackHandlerTests()
|
||||
{
|
||||
_timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow);
|
||||
_options = new FallbackHandlerOptions
|
||||
{
|
||||
Enabled = true,
|
||||
MaxAttempts = 3,
|
||||
DefaultChains = new Dictionary<NotifyChannelType, List<NotifyChannelType>>
|
||||
{
|
||||
[NotifyChannelType.Slack] = [NotifyChannelType.Teams, NotifyChannelType.Email],
|
||||
[NotifyChannelType.Teams] = [NotifyChannelType.Slack, NotifyChannelType.Email],
|
||||
[NotifyChannelType.Email] = [NotifyChannelType.Webhook],
|
||||
[NotifyChannelType.Webhook] = []
|
||||
}
|
||||
};
|
||||
_fallbackHandler = new InMemoryFallbackHandler(
|
||||
Options.Create(_options),
|
||||
_timeProvider,
|
||||
NullLogger<InMemoryFallbackHandler>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetFallbackAsync_FirstFailure_ReturnsNextChannel()
|
||||
{
|
||||
// Arrange
|
||||
await _fallbackHandler.RecordFailureAsync("tenant1", "delivery1", NotifyChannelType.Slack, "Connection timeout");
|
||||
|
||||
// Act
|
||||
var result = await _fallbackHandler.GetFallbackAsync("tenant1", NotifyChannelType.Slack, "delivery1");
|
||||
|
||||
// Assert
|
||||
Assert.True(result.HasFallback);
|
||||
Assert.Equal(NotifyChannelType.Teams, result.NextChannelType);
|
||||
Assert.Equal(2, result.AttemptNumber);
|
||||
Assert.Equal(3, result.TotalChannels); // Slack -> Teams -> Email
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetFallbackAsync_SecondFailure_ReturnsThirdChannel()
|
||||
{
|
||||
// Arrange
|
||||
await _fallbackHandler.RecordFailureAsync("tenant1", "delivery1", NotifyChannelType.Slack, "Connection timeout");
|
||||
await _fallbackHandler.GetFallbackAsync("tenant1", NotifyChannelType.Slack, "delivery1");
|
||||
|
||||
await _fallbackHandler.RecordFailureAsync("tenant1", "delivery1", NotifyChannelType.Teams, "Rate limited");
|
||||
|
||||
// Act
|
||||
var result = await _fallbackHandler.GetFallbackAsync("tenant1", NotifyChannelType.Teams, "delivery1");
|
||||
|
||||
// Assert
|
||||
Assert.True(result.HasFallback);
|
||||
Assert.Equal(NotifyChannelType.Email, result.NextChannelType);
|
||||
Assert.Equal(3, result.AttemptNumber);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetFallbackAsync_AllChannelsFailed_ReturnsExhausted()
|
||||
{
|
||||
// Arrange - exhaust all channels (Slack -> Teams -> Email)
|
||||
await _fallbackHandler.RecordFailureAsync("tenant1", "delivery1", NotifyChannelType.Slack, "Failed");
|
||||
await _fallbackHandler.GetFallbackAsync("tenant1", NotifyChannelType.Slack, "delivery1");
|
||||
|
||||
await _fallbackHandler.RecordFailureAsync("tenant1", "delivery1", NotifyChannelType.Teams, "Failed");
|
||||
await _fallbackHandler.GetFallbackAsync("tenant1", NotifyChannelType.Teams, "delivery1");
|
||||
|
||||
await _fallbackHandler.RecordFailureAsync("tenant1", "delivery1", NotifyChannelType.Email, "Failed");
|
||||
|
||||
// Act
|
||||
var result = await _fallbackHandler.GetFallbackAsync("tenant1", NotifyChannelType.Email, "delivery1");
|
||||
|
||||
// Assert
|
||||
Assert.False(result.HasFallback);
|
||||
Assert.True(result.IsExhausted);
|
||||
Assert.Null(result.NextChannelType);
|
||||
Assert.Equal(3, result.FailedChannels.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetFallbackAsync_NoFallbackConfigured_ReturnsNoFallback()
|
||||
{
|
||||
// Act - Webhook has no fallback chain
|
||||
await _fallbackHandler.RecordFailureAsync("tenant1", "delivery1", NotifyChannelType.Webhook, "Failed");
|
||||
var result = await _fallbackHandler.GetFallbackAsync("tenant1", NotifyChannelType.Webhook, "delivery1");
|
||||
|
||||
// Assert
|
||||
Assert.False(result.HasFallback);
|
||||
Assert.Contains("No fallback", result.ExhaustionReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetFallbackAsync_DisabledHandler_ReturnsNoFallback()
|
||||
{
|
||||
// Arrange
|
||||
var disabledOptions = new FallbackHandlerOptions { Enabled = false };
|
||||
var disabledHandler = new InMemoryFallbackHandler(
|
||||
Options.Create(disabledOptions),
|
||||
_timeProvider,
|
||||
NullLogger<InMemoryFallbackHandler>.Instance);
|
||||
|
||||
// Act
|
||||
var result = await disabledHandler.GetFallbackAsync("tenant1", NotifyChannelType.Slack, "delivery1");
|
||||
|
||||
// Assert
|
||||
Assert.False(result.HasFallback);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RecordSuccessAsync_MarksDeliveryAsSucceeded()
|
||||
{
|
||||
// Arrange
|
||||
await _fallbackHandler.RecordFailureAsync("tenant1", "delivery1", NotifyChannelType.Slack, "Failed");
|
||||
await _fallbackHandler.GetFallbackAsync("tenant1", NotifyChannelType.Slack, "delivery1");
|
||||
|
||||
// Act
|
||||
await _fallbackHandler.RecordSuccessAsync("tenant1", "delivery1", NotifyChannelType.Teams);
|
||||
|
||||
// Assert
|
||||
var stats = await _fallbackHandler.GetStatisticsAsync("tenant1");
|
||||
Assert.Equal(1, stats.FallbackSuccesses);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetFallbackChainAsync_ReturnsDefaultChain()
|
||||
{
|
||||
// Act
|
||||
var chain = await _fallbackHandler.GetFallbackChainAsync("tenant1", NotifyChannelType.Slack);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, chain.Count);
|
||||
Assert.Equal(NotifyChannelType.Teams, chain[0]);
|
||||
Assert.Equal(NotifyChannelType.Email, chain[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetFallbackChainAsync_CreatesTenantSpecificChain()
|
||||
{
|
||||
// Act
|
||||
await _fallbackHandler.SetFallbackChainAsync(
|
||||
"tenant1",
|
||||
NotifyChannelType.Slack,
|
||||
[NotifyChannelType.Webhook, NotifyChannelType.Email],
|
||||
"admin");
|
||||
|
||||
var chain = await _fallbackHandler.GetFallbackChainAsync("tenant1", NotifyChannelType.Slack);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, chain.Count);
|
||||
Assert.Equal(NotifyChannelType.Webhook, chain[0]);
|
||||
Assert.Equal(NotifyChannelType.Email, chain[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetFallbackChainAsync_DoesNotAffectOtherTenants()
|
||||
{
|
||||
// Arrange
|
||||
await _fallbackHandler.SetFallbackChainAsync(
|
||||
"tenant1",
|
||||
NotifyChannelType.Slack,
|
||||
[NotifyChannelType.Webhook],
|
||||
"admin");
|
||||
|
||||
// Act
|
||||
var tenant1Chain = await _fallbackHandler.GetFallbackChainAsync("tenant1", NotifyChannelType.Slack);
|
||||
var tenant2Chain = await _fallbackHandler.GetFallbackChainAsync("tenant2", NotifyChannelType.Slack);
|
||||
|
||||
// Assert
|
||||
Assert.Single(tenant1Chain);
|
||||
Assert.Equal(NotifyChannelType.Webhook, tenant1Chain[0]);
|
||||
|
||||
Assert.Equal(2, tenant2Chain.Count); // Default chain
|
||||
Assert.Equal(NotifyChannelType.Teams, tenant2Chain[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStatisticsAsync_ReturnsAccurateStats()
|
||||
{
|
||||
// Arrange - Create various delivery scenarios
|
||||
// Delivery 1: Primary success
|
||||
await _fallbackHandler.RecordSuccessAsync("tenant1", "delivery1", NotifyChannelType.Slack);
|
||||
|
||||
// Delivery 2: Fallback success
|
||||
await _fallbackHandler.RecordFailureAsync("tenant1", "delivery2", NotifyChannelType.Slack, "Failed");
|
||||
await _fallbackHandler.GetFallbackAsync("tenant1", NotifyChannelType.Slack, "delivery2");
|
||||
await _fallbackHandler.RecordSuccessAsync("tenant1", "delivery2", NotifyChannelType.Teams);
|
||||
|
||||
// Delivery 3: Exhausted
|
||||
await _fallbackHandler.RecordFailureAsync("tenant1", "delivery3", NotifyChannelType.Webhook, "Failed");
|
||||
|
||||
// Act
|
||||
var stats = await _fallbackHandler.GetStatisticsAsync("tenant1");
|
||||
|
||||
// Assert
|
||||
Assert.Equal("tenant1", stats.TenantId);
|
||||
Assert.Equal(3, stats.TotalDeliveries);
|
||||
Assert.Equal(1, stats.PrimarySuccesses);
|
||||
Assert.Equal(1, stats.FallbackSuccesses);
|
||||
Assert.Equal(1, stats.FallbackAttempts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStatisticsAsync_FiltersWithinWindow()
|
||||
{
|
||||
// Arrange
|
||||
await _fallbackHandler.RecordSuccessAsync("tenant1", "old-delivery", NotifyChannelType.Slack);
|
||||
|
||||
_timeProvider.Advance(TimeSpan.FromHours(25));
|
||||
|
||||
await _fallbackHandler.RecordSuccessAsync("tenant1", "recent-delivery", NotifyChannelType.Slack);
|
||||
|
||||
// Act - Get stats for last 24 hours
|
||||
var stats = await _fallbackHandler.GetStatisticsAsync("tenant1", TimeSpan.FromHours(24));
|
||||
|
||||
// Assert
|
||||
Assert.Equal(1, stats.TotalDeliveries);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ClearDeliveryStateAsync_RemovesDeliveryTracking()
|
||||
{
|
||||
// Arrange
|
||||
await _fallbackHandler.RecordFailureAsync("tenant1", "delivery1", NotifyChannelType.Slack, "Failed");
|
||||
await _fallbackHandler.GetFallbackAsync("tenant1", NotifyChannelType.Slack, "delivery1");
|
||||
|
||||
// Act
|
||||
await _fallbackHandler.ClearDeliveryStateAsync("tenant1", "delivery1");
|
||||
|
||||
// Get fallback again - should start fresh
|
||||
await _fallbackHandler.RecordFailureAsync("tenant1", "delivery1", NotifyChannelType.Slack, "Failed again");
|
||||
var result = await _fallbackHandler.GetFallbackAsync("tenant1", NotifyChannelType.Slack, "delivery1");
|
||||
|
||||
// Assert - Should be back to first fallback attempt
|
||||
Assert.Equal(NotifyChannelType.Teams, result.NextChannelType);
|
||||
Assert.Equal(2, result.AttemptNumber);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetFallbackAsync_MaxAttemptsExceeded_ReturnsExhausted()
|
||||
{
|
||||
// Arrange - MaxAttempts is 3, but chain has 4 channels (Slack + 3 fallbacks would exceed)
|
||||
// Add a longer chain
|
||||
await _fallbackHandler.SetFallbackChainAsync(
|
||||
"tenant1",
|
||||
NotifyChannelType.Slack,
|
||||
[NotifyChannelType.Teams, NotifyChannelType.Email, NotifyChannelType.Webhook, NotifyChannelType.Custom],
|
||||
"admin");
|
||||
|
||||
// Fail through 3 attempts (max)
|
||||
await _fallbackHandler.RecordFailureAsync("tenant1", "delivery1", NotifyChannelType.Slack, "Failed");
|
||||
await _fallbackHandler.GetFallbackAsync("tenant1", NotifyChannelType.Slack, "delivery1");
|
||||
|
||||
await _fallbackHandler.RecordFailureAsync("tenant1", "delivery1", NotifyChannelType.Teams, "Failed");
|
||||
await _fallbackHandler.GetFallbackAsync("tenant1", NotifyChannelType.Teams, "delivery1");
|
||||
|
||||
await _fallbackHandler.RecordFailureAsync("tenant1", "delivery1", NotifyChannelType.Email, "Failed");
|
||||
|
||||
// Act - 4th attempt should be blocked by MaxAttempts
|
||||
var result = await _fallbackHandler.GetFallbackAsync("tenant1", NotifyChannelType.Email, "delivery1");
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsExhausted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RecordFailureAsync_TracksMultipleFailures()
|
||||
{
|
||||
// Arrange & Act
|
||||
await _fallbackHandler.RecordFailureAsync("tenant1", "delivery1", NotifyChannelType.Slack, "Timeout");
|
||||
await _fallbackHandler.GetFallbackAsync("tenant1", NotifyChannelType.Slack, "delivery1");
|
||||
|
||||
await _fallbackHandler.RecordFailureAsync("tenant1", "delivery1", NotifyChannelType.Teams, "Rate limited");
|
||||
var result = await _fallbackHandler.GetFallbackAsync("tenant1", NotifyChannelType.Teams, "delivery1");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, result.FailedChannels.Count);
|
||||
Assert.Contains(result.FailedChannels, f => f.ChannelType == NotifyChannelType.Slack && f.Reason == "Timeout");
|
||||
Assert.Contains(result.FailedChannels, f => f.ChannelType == NotifyChannelType.Teams && f.Reason == "Rate limited");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStatisticsAsync_TracksFailuresByChannel()
|
||||
{
|
||||
// Arrange
|
||||
await _fallbackHandler.RecordFailureAsync("tenant1", "d1", NotifyChannelType.Slack, "Failed");
|
||||
await _fallbackHandler.RecordFailureAsync("tenant1", "d2", NotifyChannelType.Slack, "Failed");
|
||||
await _fallbackHandler.RecordFailureAsync("tenant1", "d3", NotifyChannelType.Teams, "Failed");
|
||||
|
||||
// Act
|
||||
var stats = await _fallbackHandler.GetStatisticsAsync("tenant1");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, stats.FailuresByChannel[NotifyChannelType.Slack]);
|
||||
Assert.Equal(1, stats.FailuresByChannel[NotifyChannelType.Teams]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Notifier.Worker.Localization;
|
||||
|
||||
namespace StellaOps.Notifier.Tests.Localization;
|
||||
|
||||
public class InMemoryLocalizationServiceTests
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly LocalizationServiceOptions _options;
|
||||
private readonly InMemoryLocalizationService _localizationService;
|
||||
|
||||
public InMemoryLocalizationServiceTests()
|
||||
{
|
||||
_timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow);
|
||||
_options = new LocalizationServiceOptions
|
||||
{
|
||||
DefaultLocale = "en-US",
|
||||
EnableFallback = true,
|
||||
EnableCaching = true,
|
||||
CacheDuration = TimeSpan.FromMinutes(15),
|
||||
ReturnKeyWhenMissing = true,
|
||||
PlaceholderFormat = "named"
|
||||
};
|
||||
_localizationService = new InMemoryLocalizationService(
|
||||
Options.Create(_options),
|
||||
_timeProvider,
|
||||
NullLogger<InMemoryLocalizationService>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStringAsync_SystemBundle_ReturnsValue()
|
||||
{
|
||||
// Act - system bundles are seeded automatically
|
||||
var value = await _localizationService.GetStringAsync("tenant1", "storm.detected.title", "en-US");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(value);
|
||||
Assert.Equal("Notification Storm Detected", value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStringAsync_GermanLocale_ReturnsGermanValue()
|
||||
{
|
||||
// Act
|
||||
var value = await _localizationService.GetStringAsync("tenant1", "storm.detected.title", "de-DE");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(value);
|
||||
Assert.Equal("Benachrichtigungssturm erkannt", value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStringAsync_FrenchLocale_ReturnsFrenchValue()
|
||||
{
|
||||
// Act
|
||||
var value = await _localizationService.GetStringAsync("tenant1", "storm.detected.title", "fr-FR");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(value);
|
||||
Assert.Equal("Tempête de notifications détectée", value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStringAsync_UnknownKey_ReturnsKey()
|
||||
{
|
||||
// Act
|
||||
var value = await _localizationService.GetStringAsync("tenant1", "unknown.key", "en-US");
|
||||
|
||||
// Assert (when ReturnKeyWhenMissing = true)
|
||||
Assert.Equal("unknown.key", value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStringAsync_LocaleFallback_UsesDefaultLocale()
|
||||
{
|
||||
// Act - Japanese locale (not configured) should fall back to en-US
|
||||
var value = await _localizationService.GetStringAsync("tenant1", "storm.detected.title", "ja-JP");
|
||||
|
||||
// Assert - should get en-US value
|
||||
Assert.Equal("Notification Storm Detected", value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetFormattedStringAsync_ReplacesPlaceholders()
|
||||
{
|
||||
// Act
|
||||
var parameters = new Dictionary<string, object>
|
||||
{
|
||||
["stormKey"] = "critical.alert",
|
||||
["count"] = 50,
|
||||
["window"] = "5 minutes"
|
||||
};
|
||||
var value = await _localizationService.GetFormattedStringAsync(
|
||||
"tenant1", "storm.detected.body", "en-US", parameters);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(value);
|
||||
Assert.Contains("critical.alert", value);
|
||||
Assert.Contains("50", value);
|
||||
Assert.Contains("5 minutes", value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertBundleAsync_CreatesTenantBundle()
|
||||
{
|
||||
// Arrange
|
||||
var bundle = new LocalizationBundle
|
||||
{
|
||||
BundleId = "tenant-bundle",
|
||||
TenantId = "tenant1",
|
||||
Locale = "en-US",
|
||||
Namespace = "custom",
|
||||
Strings = new Dictionary<string, string>
|
||||
{
|
||||
["custom.greeting"] = "Hello, World!"
|
||||
},
|
||||
Description = "Custom tenant bundle"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _localizationService.UpsertBundleAsync(bundle, "admin");
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Success);
|
||||
Assert.True(result.IsNew);
|
||||
Assert.Equal("tenant-bundle", result.BundleId);
|
||||
|
||||
// Verify string is accessible
|
||||
var greeting = await _localizationService.GetStringAsync("tenant1", "custom.greeting", "en-US");
|
||||
Assert.Equal("Hello, World!", greeting);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertBundleAsync_UpdatesExistingBundle()
|
||||
{
|
||||
// Arrange
|
||||
var bundle = new LocalizationBundle
|
||||
{
|
||||
BundleId = "update-test",
|
||||
TenantId = "tenant1",
|
||||
Locale = "en-US",
|
||||
Strings = new Dictionary<string, string>
|
||||
{
|
||||
["test.key"] = "Original value"
|
||||
}
|
||||
};
|
||||
await _localizationService.UpsertBundleAsync(bundle, "admin");
|
||||
|
||||
// Act - update with new value
|
||||
var updatedBundle = bundle with
|
||||
{
|
||||
Strings = new Dictionary<string, string>
|
||||
{
|
||||
["test.key"] = "Updated value"
|
||||
}
|
||||
};
|
||||
var result = await _localizationService.UpsertBundleAsync(updatedBundle, "admin");
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Success);
|
||||
Assert.False(result.IsNew);
|
||||
|
||||
var value = await _localizationService.GetStringAsync("tenant1", "test.key", "en-US");
|
||||
Assert.Equal("Updated value", value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteBundleAsync_RemovesBundle()
|
||||
{
|
||||
// Arrange
|
||||
var bundle = new LocalizationBundle
|
||||
{
|
||||
BundleId = "delete-test",
|
||||
TenantId = "tenant1",
|
||||
Locale = "en-US",
|
||||
Strings = new Dictionary<string, string>
|
||||
{
|
||||
["delete.key"] = "Will be deleted"
|
||||
}
|
||||
};
|
||||
await _localizationService.UpsertBundleAsync(bundle, "admin");
|
||||
|
||||
// Act
|
||||
var deleted = await _localizationService.DeleteBundleAsync("tenant1", "delete-test", "admin");
|
||||
|
||||
// Assert
|
||||
Assert.True(deleted);
|
||||
|
||||
var bundles = await _localizationService.ListBundlesAsync("tenant1");
|
||||
Assert.DoesNotContain(bundles, b => b.BundleId == "delete-test");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListBundlesAsync_ReturnsAllTenantBundles()
|
||||
{
|
||||
// Arrange
|
||||
var bundle1 = new LocalizationBundle
|
||||
{
|
||||
BundleId = "list-test-1",
|
||||
TenantId = "tenant1",
|
||||
Locale = "en-US",
|
||||
Strings = new Dictionary<string, string> { ["key1"] = "value1" }
|
||||
};
|
||||
var bundle2 = new LocalizationBundle
|
||||
{
|
||||
BundleId = "list-test-2",
|
||||
TenantId = "tenant1",
|
||||
Locale = "de-DE",
|
||||
Strings = new Dictionary<string, string> { ["key2"] = "value2" }
|
||||
};
|
||||
var bundle3 = new LocalizationBundle
|
||||
{
|
||||
BundleId = "other-tenant",
|
||||
TenantId = "tenant2",
|
||||
Locale = "en-US",
|
||||
Strings = new Dictionary<string, string> { ["key3"] = "value3" }
|
||||
};
|
||||
|
||||
await _localizationService.UpsertBundleAsync(bundle1, "admin");
|
||||
await _localizationService.UpsertBundleAsync(bundle2, "admin");
|
||||
await _localizationService.UpsertBundleAsync(bundle3, "admin");
|
||||
|
||||
// Act
|
||||
var tenant1Bundles = await _localizationService.ListBundlesAsync("tenant1");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, tenant1Bundles.Count);
|
||||
Assert.Contains(tenant1Bundles, b => b.BundleId == "list-test-1");
|
||||
Assert.Contains(tenant1Bundles, b => b.BundleId == "list-test-2");
|
||||
Assert.DoesNotContain(tenant1Bundles, b => b.BundleId == "other-tenant");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSupportedLocalesAsync_ReturnsAvailableLocales()
|
||||
{
|
||||
// Act
|
||||
var locales = await _localizationService.GetSupportedLocalesAsync("tenant1");
|
||||
|
||||
// Assert - should include seeded system locales
|
||||
Assert.Contains("en-US", locales);
|
||||
Assert.Contains("de-DE", locales);
|
||||
Assert.Contains("fr-FR", locales);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetBundleAsync_ReturnsMergedStrings()
|
||||
{
|
||||
// Arrange - add tenant bundle that overrides a system string
|
||||
var tenantBundle = new LocalizationBundle
|
||||
{
|
||||
BundleId = "tenant-override",
|
||||
TenantId = "tenant1",
|
||||
Locale = "en-US",
|
||||
Priority = 10, // Higher priority than system (0)
|
||||
Strings = new Dictionary<string, string>
|
||||
{
|
||||
["storm.detected.title"] = "Custom Storm Title",
|
||||
["tenant.custom"] = "Custom Value"
|
||||
}
|
||||
};
|
||||
await _localizationService.UpsertBundleAsync(tenantBundle, "admin");
|
||||
|
||||
// Act
|
||||
var bundle = await _localizationService.GetBundleAsync("tenant1", "en-US");
|
||||
|
||||
// Assert - should have both system and tenant strings, with tenant override
|
||||
Assert.True(bundle.ContainsKey("storm.detected.title"));
|
||||
Assert.Equal("Custom Storm Title", bundle["storm.detected.title"]);
|
||||
Assert.True(bundle.ContainsKey("tenant.custom"));
|
||||
Assert.True(bundle.ContainsKey("fallback.attempted.title")); // System string
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ValidBundle_ReturnsValid()
|
||||
{
|
||||
// Arrange
|
||||
var bundle = new LocalizationBundle
|
||||
{
|
||||
BundleId = "valid-bundle",
|
||||
TenantId = "tenant1",
|
||||
Locale = "en-US",
|
||||
Strings = new Dictionary<string, string>
|
||||
{
|
||||
["key1"] = "value1"
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _localizationService.Validate(bundle);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Empty(result.Errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_MissingBundleId_ReturnsInvalid()
|
||||
{
|
||||
// Arrange
|
||||
var bundle = new LocalizationBundle
|
||||
{
|
||||
BundleId = "",
|
||||
TenantId = "tenant1",
|
||||
Locale = "en-US",
|
||||
Strings = new Dictionary<string, string> { ["key"] = "value" }
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _localizationService.Validate(bundle);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.Errors, e => e.Contains("Bundle ID"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_MissingLocale_ReturnsInvalid()
|
||||
{
|
||||
// Arrange
|
||||
var bundle = new LocalizationBundle
|
||||
{
|
||||
BundleId = "test",
|
||||
TenantId = "tenant1",
|
||||
Locale = "",
|
||||
Strings = new Dictionary<string, string> { ["key"] = "value" }
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _localizationService.Validate(bundle);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.Errors, e => e.Contains("Locale"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_EmptyStrings_ReturnsInvalid()
|
||||
{
|
||||
// Arrange
|
||||
var bundle = new LocalizationBundle
|
||||
{
|
||||
BundleId = "test",
|
||||
TenantId = "tenant1",
|
||||
Locale = "en-US",
|
||||
Strings = new Dictionary<string, string>()
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _localizationService.Validate(bundle);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.Errors, e => e.Contains("at least one string"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStringAsync_CachesResults()
|
||||
{
|
||||
// Act - first call
|
||||
var value1 = await _localizationService.GetStringAsync("tenant1", "storm.detected.title", "en-US");
|
||||
|
||||
// Advance time slightly (within cache duration)
|
||||
_timeProvider.Advance(TimeSpan.FromMinutes(5));
|
||||
|
||||
// Second call should hit cache
|
||||
var value2 = await _localizationService.GetStringAsync("tenant1", "storm.detected.title", "en-US");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(value1, value2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetFormattedStringAsync_FormatsNumbers()
|
||||
{
|
||||
// Arrange
|
||||
var bundle = new LocalizationBundle
|
||||
{
|
||||
BundleId = "number-test",
|
||||
TenantId = "tenant1",
|
||||
Locale = "de-DE",
|
||||
Strings = new Dictionary<string, string>
|
||||
{
|
||||
["number.test"] = "Total: {{count}} items"
|
||||
}
|
||||
};
|
||||
await _localizationService.UpsertBundleAsync(bundle, "admin");
|
||||
|
||||
// Act
|
||||
var parameters = new Dictionary<string, object> { ["count"] = 1234567 };
|
||||
var value = await _localizationService.GetFormattedStringAsync(
|
||||
"tenant1", "number.test", "de-DE", parameters);
|
||||
|
||||
// Assert - German number formatting uses periods as thousands separator
|
||||
Assert.Contains("1.234.567", value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,492 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Notifier.Worker.Observability;
|
||||
|
||||
namespace StellaOps.Notifier.Tests.Observability;
|
||||
|
||||
public class ChaosTestRunnerTests
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly ChaosTestOptions _options;
|
||||
private readonly InMemoryChaosTestRunner _runner;
|
||||
|
||||
public ChaosTestRunnerTests()
|
||||
{
|
||||
_timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow);
|
||||
_options = new ChaosTestOptions
|
||||
{
|
||||
Enabled = true,
|
||||
MaxConcurrentExperiments = 5,
|
||||
MaxExperimentDuration = TimeSpan.FromHours(1),
|
||||
RequireTenantTarget = false
|
||||
};
|
||||
_runner = new InMemoryChaosTestRunner(
|
||||
Options.Create(_options),
|
||||
_timeProvider,
|
||||
NullLogger<InMemoryChaosTestRunner>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartExperimentAsync_CreatesExperiment()
|
||||
{
|
||||
// Arrange
|
||||
var config = new ChaosExperimentConfig
|
||||
{
|
||||
Name = "Test Outage",
|
||||
InitiatedBy = "test-user",
|
||||
TargetChannelTypes = ["email"],
|
||||
FaultType = ChaosFaultType.Outage,
|
||||
Duration = TimeSpan.FromMinutes(5)
|
||||
};
|
||||
|
||||
// Act
|
||||
var experiment = await _runner.StartExperimentAsync(config);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(experiment);
|
||||
Assert.Equal(ChaosExperimentStatus.Running, experiment.Status);
|
||||
Assert.Equal("Test Outage", experiment.Config.Name);
|
||||
Assert.NotNull(experiment.StartedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartExperimentAsync_WhenDisabled_Throws()
|
||||
{
|
||||
// Arrange
|
||||
var disabledOptions = new ChaosTestOptions { Enabled = false };
|
||||
var runner = new InMemoryChaosTestRunner(
|
||||
Options.Create(disabledOptions),
|
||||
_timeProvider,
|
||||
NullLogger<InMemoryChaosTestRunner>.Instance);
|
||||
|
||||
var config = new ChaosExperimentConfig
|
||||
{
|
||||
Name = "Test",
|
||||
InitiatedBy = "test-user",
|
||||
FaultType = ChaosFaultType.Outage
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => runner.StartExperimentAsync(config));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartExperimentAsync_ExceedsMaxDuration_Throws()
|
||||
{
|
||||
// Arrange
|
||||
var config = new ChaosExperimentConfig
|
||||
{
|
||||
Name = "Long Experiment",
|
||||
InitiatedBy = "test-user",
|
||||
FaultType = ChaosFaultType.Outage,
|
||||
Duration = TimeSpan.FromHours(2) // Exceeds max of 1 hour
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => _runner.StartExperimentAsync(config));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartExperimentAsync_MaxConcurrentReached_Throws()
|
||||
{
|
||||
// Arrange - start max number of experiments
|
||||
for (var i = 0; i < 5; i++)
|
||||
{
|
||||
await _runner.StartExperimentAsync(new ChaosExperimentConfig
|
||||
{
|
||||
Name = $"Experiment {i}",
|
||||
InitiatedBy = "test-user",
|
||||
FaultType = ChaosFaultType.Outage
|
||||
});
|
||||
}
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
_runner.StartExperimentAsync(new ChaosExperimentConfig
|
||||
{
|
||||
Name = "One too many",
|
||||
InitiatedBy = "test-user",
|
||||
FaultType = ChaosFaultType.Outage
|
||||
}));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StopExperimentAsync_StopsExperiment()
|
||||
{
|
||||
// Arrange
|
||||
var experiment = await _runner.StartExperimentAsync(new ChaosExperimentConfig
|
||||
{
|
||||
Name = "Test",
|
||||
InitiatedBy = "test-user",
|
||||
FaultType = ChaosFaultType.Outage
|
||||
});
|
||||
|
||||
// Act
|
||||
await _runner.StopExperimentAsync(experiment.Id);
|
||||
|
||||
// Assert
|
||||
var stopped = await _runner.GetExperimentAsync(experiment.Id);
|
||||
Assert.NotNull(stopped);
|
||||
Assert.Equal(ChaosExperimentStatus.Stopped, stopped.Status);
|
||||
Assert.NotNull(stopped.EndedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ShouldFailAsync_OutageFault_ReturnsFault()
|
||||
{
|
||||
// Arrange
|
||||
await _runner.StartExperimentAsync(new ChaosExperimentConfig
|
||||
{
|
||||
Name = "Email Outage",
|
||||
InitiatedBy = "test-user",
|
||||
TenantId = "tenant1",
|
||||
TargetChannelTypes = ["email"],
|
||||
FaultType = ChaosFaultType.Outage
|
||||
});
|
||||
|
||||
// Act
|
||||
var decision = await _runner.ShouldFailAsync("tenant1", "email");
|
||||
|
||||
// Assert
|
||||
Assert.True(decision.ShouldFail);
|
||||
Assert.Equal(ChaosFaultType.Outage, decision.FaultType);
|
||||
Assert.NotNull(decision.InjectedError);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ShouldFailAsync_NoMatchingExperiment_ReturnsNoFault()
|
||||
{
|
||||
// Arrange
|
||||
await _runner.StartExperimentAsync(new ChaosExperimentConfig
|
||||
{
|
||||
Name = "Email Outage",
|
||||
InitiatedBy = "test-user",
|
||||
TenantId = "tenant1",
|
||||
TargetChannelTypes = ["email"],
|
||||
FaultType = ChaosFaultType.Outage
|
||||
});
|
||||
|
||||
// Act - different tenant
|
||||
var decision = await _runner.ShouldFailAsync("tenant2", "email");
|
||||
|
||||
// Assert
|
||||
Assert.False(decision.ShouldFail);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ShouldFailAsync_WrongChannelType_ReturnsNoFault()
|
||||
{
|
||||
// Arrange
|
||||
await _runner.StartExperimentAsync(new ChaosExperimentConfig
|
||||
{
|
||||
Name = "Email Outage",
|
||||
InitiatedBy = "test-user",
|
||||
TenantId = "tenant1",
|
||||
TargetChannelTypes = ["email"],
|
||||
FaultType = ChaosFaultType.Outage
|
||||
});
|
||||
|
||||
// Act - different channel type
|
||||
var decision = await _runner.ShouldFailAsync("tenant1", "slack");
|
||||
|
||||
// Assert
|
||||
Assert.False(decision.ShouldFail);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ShouldFailAsync_LatencyFault_InjectsLatency()
|
||||
{
|
||||
// Arrange
|
||||
await _runner.StartExperimentAsync(new ChaosExperimentConfig
|
||||
{
|
||||
Name = "Latency Test",
|
||||
InitiatedBy = "test-user",
|
||||
TenantId = "tenant1",
|
||||
TargetChannelTypes = ["email"],
|
||||
FaultType = ChaosFaultType.Latency,
|
||||
FaultConfig = new ChaosFaultConfig
|
||||
{
|
||||
MinLatency = TimeSpan.FromSeconds(1),
|
||||
MaxLatency = TimeSpan.FromSeconds(5)
|
||||
}
|
||||
});
|
||||
|
||||
// Act
|
||||
var decision = await _runner.ShouldFailAsync("tenant1", "email");
|
||||
|
||||
// Assert
|
||||
Assert.False(decision.ShouldFail); // Latency doesn't cause failure
|
||||
Assert.NotNull(decision.InjectedLatency);
|
||||
Assert.InRange(decision.InjectedLatency.Value.TotalSeconds, 1, 5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ShouldFailAsync_PartialFailure_UsesFailureRate()
|
||||
{
|
||||
// Arrange
|
||||
await _runner.StartExperimentAsync(new ChaosExperimentConfig
|
||||
{
|
||||
Name = "Partial Failure",
|
||||
InitiatedBy = "test-user",
|
||||
TenantId = "tenant1",
|
||||
TargetChannelTypes = ["email"],
|
||||
FaultType = ChaosFaultType.PartialFailure,
|
||||
FaultConfig = new ChaosFaultConfig
|
||||
{
|
||||
FailureRate = 0.5,
|
||||
Seed = 42 // Fixed seed for reproducibility
|
||||
}
|
||||
});
|
||||
|
||||
// Act - run multiple times
|
||||
var failures = 0;
|
||||
for (var i = 0; i < 100; i++)
|
||||
{
|
||||
var decision = await _runner.ShouldFailAsync("tenant1", "email");
|
||||
if (decision.ShouldFail) failures++;
|
||||
}
|
||||
|
||||
// Assert - should be roughly 50% failures (with some variance)
|
||||
Assert.InRange(failures, 30, 70);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ShouldFailAsync_RateLimit_EnforcesLimit()
|
||||
{
|
||||
// Arrange
|
||||
await _runner.StartExperimentAsync(new ChaosExperimentConfig
|
||||
{
|
||||
Name = "Rate Limit",
|
||||
InitiatedBy = "test-user",
|
||||
TenantId = "tenant1",
|
||||
TargetChannelTypes = ["email"],
|
||||
FaultType = ChaosFaultType.RateLimit,
|
||||
FaultConfig = new ChaosFaultConfig
|
||||
{
|
||||
RateLimitPerMinute = 5
|
||||
}
|
||||
});
|
||||
|
||||
// Act - first 5 should pass
|
||||
for (var i = 0; i < 5; i++)
|
||||
{
|
||||
var decision = await _runner.ShouldFailAsync("tenant1", "email");
|
||||
Assert.False(decision.ShouldFail);
|
||||
}
|
||||
|
||||
// 6th should fail
|
||||
var failedDecision = await _runner.ShouldFailAsync("tenant1", "email");
|
||||
|
||||
// Assert
|
||||
Assert.True(failedDecision.ShouldFail);
|
||||
Assert.Equal(429, failedDecision.InjectedStatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ShouldFailAsync_ExperimentExpires_StopsMatching()
|
||||
{
|
||||
// Arrange
|
||||
await _runner.StartExperimentAsync(new ChaosExperimentConfig
|
||||
{
|
||||
Name = "Short Experiment",
|
||||
InitiatedBy = "test-user",
|
||||
TenantId = "tenant1",
|
||||
TargetChannelTypes = ["email"],
|
||||
FaultType = ChaosFaultType.Outage,
|
||||
Duration = TimeSpan.FromMinutes(5)
|
||||
});
|
||||
|
||||
// Act - advance time past duration
|
||||
_timeProvider.Advance(TimeSpan.FromMinutes(10));
|
||||
var decision = await _runner.ShouldFailAsync("tenant1", "email");
|
||||
|
||||
// Assert
|
||||
Assert.False(decision.ShouldFail);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ShouldFailAsync_MaxOperationsReached_StopsMatching()
|
||||
{
|
||||
// Arrange
|
||||
await _runner.StartExperimentAsync(new ChaosExperimentConfig
|
||||
{
|
||||
Name = "Limited Experiment",
|
||||
InitiatedBy = "test-user",
|
||||
TenantId = "tenant1",
|
||||
TargetChannelTypes = ["email"],
|
||||
FaultType = ChaosFaultType.Outage,
|
||||
MaxAffectedOperations = 3
|
||||
});
|
||||
|
||||
// Act - consume all operations
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
var d = await _runner.ShouldFailAsync("tenant1", "email");
|
||||
Assert.True(d.ShouldFail);
|
||||
}
|
||||
|
||||
// 4th should not match
|
||||
var decision = await _runner.ShouldFailAsync("tenant1", "email");
|
||||
|
||||
// Assert
|
||||
Assert.False(decision.ShouldFail);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RecordOutcomeAsync_RecordsOutcome()
|
||||
{
|
||||
// Arrange
|
||||
var experiment = await _runner.StartExperimentAsync(new ChaosExperimentConfig
|
||||
{
|
||||
Name = "Test",
|
||||
InitiatedBy = "test-user",
|
||||
FaultType = ChaosFaultType.Outage
|
||||
});
|
||||
|
||||
// Act
|
||||
await _runner.RecordOutcomeAsync(experiment.Id, new ChaosOutcome
|
||||
{
|
||||
Type = ChaosOutcomeType.FaultInjected,
|
||||
ChannelType = "email",
|
||||
TenantId = "tenant1",
|
||||
FallbackTriggered = true
|
||||
});
|
||||
|
||||
var results = await _runner.GetResultsAsync(experiment.Id);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(1, results.TotalAffected);
|
||||
Assert.Equal(1, results.FailedOperations);
|
||||
Assert.Equal(1, results.FallbackTriggered);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetResultsAsync_CalculatesStatistics()
|
||||
{
|
||||
// Arrange
|
||||
var experiment = await _runner.StartExperimentAsync(new ChaosExperimentConfig
|
||||
{
|
||||
Name = "Test",
|
||||
InitiatedBy = "test-user",
|
||||
FaultType = ChaosFaultType.Latency
|
||||
});
|
||||
|
||||
// Record various outcomes
|
||||
await _runner.RecordOutcomeAsync(experiment.Id, new ChaosOutcome
|
||||
{
|
||||
Type = ChaosOutcomeType.LatencyInjected,
|
||||
ChannelType = "email",
|
||||
Duration = TimeSpan.FromMilliseconds(100)
|
||||
});
|
||||
await _runner.RecordOutcomeAsync(experiment.Id, new ChaosOutcome
|
||||
{
|
||||
Type = ChaosOutcomeType.LatencyInjected,
|
||||
ChannelType = "email",
|
||||
Duration = TimeSpan.FromMilliseconds(200)
|
||||
});
|
||||
await _runner.RecordOutcomeAsync(experiment.Id, new ChaosOutcome
|
||||
{
|
||||
Type = ChaosOutcomeType.FaultInjected,
|
||||
ChannelType = "slack",
|
||||
FallbackTriggered = true
|
||||
});
|
||||
|
||||
// Act
|
||||
var results = await _runner.GetResultsAsync(experiment.Id);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(3, results.TotalAffected);
|
||||
Assert.Equal(1, results.FailedOperations);
|
||||
Assert.Equal(1, results.FallbackTriggered);
|
||||
Assert.NotNull(results.AverageInjectedLatency);
|
||||
Assert.Equal(150, results.AverageInjectedLatency.Value.TotalMilliseconds);
|
||||
Assert.Equal(2, results.ByChannelType["email"].TotalAffected);
|
||||
Assert.Equal(1, results.ByChannelType["slack"].TotalAffected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListExperimentsAsync_FiltersByStatus()
|
||||
{
|
||||
// Arrange
|
||||
var running = await _runner.StartExperimentAsync(new ChaosExperimentConfig
|
||||
{
|
||||
Name = "Running",
|
||||
InitiatedBy = "test-user",
|
||||
FaultType = ChaosFaultType.Outage
|
||||
});
|
||||
|
||||
var toStop = await _runner.StartExperimentAsync(new ChaosExperimentConfig
|
||||
{
|
||||
Name = "To Stop",
|
||||
InitiatedBy = "test-user",
|
||||
FaultType = ChaosFaultType.Outage
|
||||
});
|
||||
await _runner.StopExperimentAsync(toStop.Id);
|
||||
|
||||
// Act
|
||||
var runningList = await _runner.ListExperimentsAsync(ChaosExperimentStatus.Running);
|
||||
var stoppedList = await _runner.ListExperimentsAsync(ChaosExperimentStatus.Stopped);
|
||||
|
||||
// Assert
|
||||
Assert.Single(runningList);
|
||||
Assert.Single(stoppedList);
|
||||
Assert.Equal(running.Id, runningList[0].Id);
|
||||
Assert.Equal(toStop.Id, stoppedList[0].Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CleanupAsync_RemovesOldExperiments()
|
||||
{
|
||||
// Arrange
|
||||
var experiment = await _runner.StartExperimentAsync(new ChaosExperimentConfig
|
||||
{
|
||||
Name = "Old Experiment",
|
||||
InitiatedBy = "test-user",
|
||||
FaultType = ChaosFaultType.Outage,
|
||||
Duration = TimeSpan.FromMinutes(5)
|
||||
});
|
||||
|
||||
// Complete the experiment
|
||||
_timeProvider.Advance(TimeSpan.FromMinutes(10));
|
||||
await _runner.GetExperimentAsync(experiment.Id); // Triggers status update
|
||||
|
||||
// Advance time beyond cleanup threshold
|
||||
_timeProvider.Advance(TimeSpan.FromDays(10));
|
||||
|
||||
// Act
|
||||
var removed = await _runner.CleanupAsync(TimeSpan.FromDays(7));
|
||||
|
||||
// Assert
|
||||
Assert.Equal(1, removed);
|
||||
var result = await _runner.GetExperimentAsync(experiment.Id);
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ErrorResponseFault_ReturnsConfiguredStatusCode()
|
||||
{
|
||||
// Arrange
|
||||
await _runner.StartExperimentAsync(new ChaosExperimentConfig
|
||||
{
|
||||
Name = "Error Response",
|
||||
InitiatedBy = "test-user",
|
||||
TenantId = "tenant1",
|
||||
TargetChannelTypes = ["email"],
|
||||
FaultType = ChaosFaultType.ErrorResponse,
|
||||
FaultConfig = new ChaosFaultConfig
|
||||
{
|
||||
ErrorStatusCode = 503,
|
||||
ErrorMessage = "Service Unavailable"
|
||||
}
|
||||
});
|
||||
|
||||
// Act
|
||||
var decision = await _runner.ShouldFailAsync("tenant1", "email");
|
||||
|
||||
// Assert
|
||||
Assert.True(decision.ShouldFail);
|
||||
Assert.Equal(503, decision.InjectedStatusCode);
|
||||
Assert.Contains("Service Unavailable", decision.InjectedError);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,495 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Notifier.Worker.Observability;
|
||||
|
||||
namespace StellaOps.Notifier.Tests.Observability;
|
||||
|
||||
public class DeadLetterHandlerTests
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly DeadLetterOptions _options;
|
||||
private readonly InMemoryDeadLetterHandler _handler;
|
||||
|
||||
public DeadLetterHandlerTests()
|
||||
{
|
||||
_timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow);
|
||||
_options = new DeadLetterOptions
|
||||
{
|
||||
Enabled = true,
|
||||
MaxRetries = 3,
|
||||
RetryDelay = TimeSpan.FromMinutes(5),
|
||||
MaxEntriesPerTenant = 1000
|
||||
};
|
||||
_handler = new InMemoryDeadLetterHandler(
|
||||
Options.Create(_options),
|
||||
_timeProvider,
|
||||
NullLogger<InMemoryDeadLetterHandler>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeadLetterAsync_AddsEntry()
|
||||
{
|
||||
// Arrange
|
||||
var entry = new DeadLetterEntry
|
||||
{
|
||||
TenantId = "tenant1",
|
||||
DeliveryId = "delivery-001",
|
||||
ChannelType = "email",
|
||||
Reason = "Connection timeout",
|
||||
OriginalPayload = "{ \"to\": \"user@example.com\" }",
|
||||
ErrorDetails = "SMTP timeout after 30s",
|
||||
AttemptCount = 3
|
||||
};
|
||||
|
||||
// Act
|
||||
await _handler.DeadLetterAsync(entry);
|
||||
|
||||
// Assert
|
||||
var entries = await _handler.GetEntriesAsync("tenant1");
|
||||
Assert.Single(entries);
|
||||
Assert.Equal("delivery-001", entries[0].DeliveryId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeadLetterAsync_WhenDisabled_DoesNotAdd()
|
||||
{
|
||||
// Arrange
|
||||
var disabledOptions = new DeadLetterOptions { Enabled = false };
|
||||
var handler = new InMemoryDeadLetterHandler(
|
||||
Options.Create(disabledOptions),
|
||||
_timeProvider,
|
||||
NullLogger<InMemoryDeadLetterHandler>.Instance);
|
||||
|
||||
var entry = new DeadLetterEntry
|
||||
{
|
||||
TenantId = "tenant1",
|
||||
DeliveryId = "delivery-001",
|
||||
ChannelType = "email",
|
||||
Reason = "Error"
|
||||
};
|
||||
|
||||
// Act
|
||||
await handler.DeadLetterAsync(entry);
|
||||
|
||||
// Assert
|
||||
var entries = await handler.GetEntriesAsync("tenant1");
|
||||
Assert.Empty(entries);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEntryAsync_ReturnsEntry()
|
||||
{
|
||||
// Arrange
|
||||
var entry = new DeadLetterEntry
|
||||
{
|
||||
TenantId = "tenant1",
|
||||
DeliveryId = "delivery-001",
|
||||
ChannelType = "email",
|
||||
Reason = "Error"
|
||||
};
|
||||
await _handler.DeadLetterAsync(entry);
|
||||
|
||||
// Get the entry ID from the list
|
||||
var entries = await _handler.GetEntriesAsync("tenant1");
|
||||
var entryId = entries[0].Id;
|
||||
|
||||
// Act
|
||||
var retrieved = await _handler.GetEntryAsync("tenant1", entryId);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(retrieved);
|
||||
Assert.Equal("delivery-001", retrieved.DeliveryId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEntryAsync_WrongTenant_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var entry = new DeadLetterEntry
|
||||
{
|
||||
TenantId = "tenant1",
|
||||
DeliveryId = "delivery-001",
|
||||
ChannelType = "email",
|
||||
Reason = "Error"
|
||||
};
|
||||
await _handler.DeadLetterAsync(entry);
|
||||
|
||||
var entries = await _handler.GetEntriesAsync("tenant1");
|
||||
var entryId = entries[0].Id;
|
||||
|
||||
// Act
|
||||
var retrieved = await _handler.GetEntryAsync("tenant2", entryId);
|
||||
|
||||
// Assert
|
||||
Assert.Null(retrieved);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RetryAsync_UpdatesStatus()
|
||||
{
|
||||
// Arrange
|
||||
var entry = new DeadLetterEntry
|
||||
{
|
||||
TenantId = "tenant1",
|
||||
DeliveryId = "delivery-001",
|
||||
ChannelType = "email",
|
||||
Reason = "Error"
|
||||
};
|
||||
await _handler.DeadLetterAsync(entry);
|
||||
|
||||
var entries = await _handler.GetEntriesAsync("tenant1");
|
||||
var entryId = entries[0].Id;
|
||||
|
||||
// Act
|
||||
var result = await _handler.RetryAsync("tenant1", entryId, "admin");
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Scheduled);
|
||||
Assert.Equal(entryId, result.EntryId);
|
||||
|
||||
var updated = await _handler.GetEntryAsync("tenant1", entryId);
|
||||
Assert.NotNull(updated);
|
||||
Assert.Equal(DeadLetterStatus.PendingRetry, updated.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RetryAsync_ExceedsMaxRetries_Throws()
|
||||
{
|
||||
// Arrange
|
||||
var entry = new DeadLetterEntry
|
||||
{
|
||||
TenantId = "tenant1",
|
||||
DeliveryId = "delivery-001",
|
||||
ChannelType = "email",
|
||||
Reason = "Error",
|
||||
RetryCount = 3 // Already at max
|
||||
};
|
||||
await _handler.DeadLetterAsync(entry);
|
||||
|
||||
var entries = await _handler.GetEntriesAsync("tenant1");
|
||||
var entryId = entries[0].Id;
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
_handler.RetryAsync("tenant1", entryId, "admin"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DiscardAsync_UpdatesStatus()
|
||||
{
|
||||
// Arrange
|
||||
var entry = new DeadLetterEntry
|
||||
{
|
||||
TenantId = "tenant1",
|
||||
DeliveryId = "delivery-001",
|
||||
ChannelType = "email",
|
||||
Reason = "Error"
|
||||
};
|
||||
await _handler.DeadLetterAsync(entry);
|
||||
|
||||
var entries = await _handler.GetEntriesAsync("tenant1");
|
||||
var entryId = entries[0].Id;
|
||||
|
||||
// Act
|
||||
await _handler.DiscardAsync("tenant1", entryId, "Not needed", "admin");
|
||||
|
||||
// Assert
|
||||
var updated = await _handler.GetEntryAsync("tenant1", entryId);
|
||||
Assert.NotNull(updated);
|
||||
Assert.Equal(DeadLetterStatus.Discarded, updated.Status);
|
||||
Assert.Equal("Not needed", updated.DiscardReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEntriesAsync_FiltersByStatus()
|
||||
{
|
||||
// Arrange
|
||||
await _handler.DeadLetterAsync(new DeadLetterEntry
|
||||
{
|
||||
TenantId = "tenant1",
|
||||
DeliveryId = "delivery-001",
|
||||
ChannelType = "email",
|
||||
Reason = "Error"
|
||||
});
|
||||
await _handler.DeadLetterAsync(new DeadLetterEntry
|
||||
{
|
||||
TenantId = "tenant1",
|
||||
DeliveryId = "delivery-002",
|
||||
ChannelType = "email",
|
||||
Reason = "Error"
|
||||
});
|
||||
|
||||
var entries = await _handler.GetEntriesAsync("tenant1");
|
||||
await _handler.DiscardAsync("tenant1", entries[0].Id, "Test", "admin");
|
||||
|
||||
// Act
|
||||
var pending = await _handler.GetEntriesAsync("tenant1", status: DeadLetterStatus.Pending);
|
||||
var discarded = await _handler.GetEntriesAsync("tenant1", status: DeadLetterStatus.Discarded);
|
||||
|
||||
// Assert
|
||||
Assert.Single(pending);
|
||||
Assert.Single(discarded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEntriesAsync_FiltersByChannelType()
|
||||
{
|
||||
// Arrange
|
||||
await _handler.DeadLetterAsync(new DeadLetterEntry
|
||||
{
|
||||
TenantId = "tenant1",
|
||||
DeliveryId = "delivery-001",
|
||||
ChannelType = "email",
|
||||
Reason = "Error"
|
||||
});
|
||||
await _handler.DeadLetterAsync(new DeadLetterEntry
|
||||
{
|
||||
TenantId = "tenant1",
|
||||
DeliveryId = "delivery-002",
|
||||
ChannelType = "slack",
|
||||
Reason = "Error"
|
||||
});
|
||||
|
||||
// Act
|
||||
var emailEntries = await _handler.GetEntriesAsync("tenant1", channelType: "email");
|
||||
|
||||
// Assert
|
||||
Assert.Single(emailEntries);
|
||||
Assert.Equal("email", emailEntries[0].ChannelType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEntriesAsync_PaginatesResults()
|
||||
{
|
||||
// Arrange
|
||||
for (var i = 0; i < 10; i++)
|
||||
{
|
||||
await _handler.DeadLetterAsync(new DeadLetterEntry
|
||||
{
|
||||
TenantId = "tenant1",
|
||||
DeliveryId = $"delivery-{i:D3}",
|
||||
ChannelType = "email",
|
||||
Reason = "Error"
|
||||
});
|
||||
}
|
||||
|
||||
// Act
|
||||
var page1 = await _handler.GetEntriesAsync("tenant1", limit: 5, offset: 0);
|
||||
var page2 = await _handler.GetEntriesAsync("tenant1", limit: 5, offset: 5);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(5, page1.Count);
|
||||
Assert.Equal(5, page2.Count);
|
||||
Assert.NotEqual(page1[0].Id, page2[0].Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStatisticsAsync_CalculatesStats()
|
||||
{
|
||||
// Arrange
|
||||
await _handler.DeadLetterAsync(new DeadLetterEntry
|
||||
{
|
||||
TenantId = "tenant1",
|
||||
DeliveryId = "delivery-001",
|
||||
ChannelType = "email",
|
||||
Reason = "Timeout"
|
||||
});
|
||||
await _handler.DeadLetterAsync(new DeadLetterEntry
|
||||
{
|
||||
TenantId = "tenant1",
|
||||
DeliveryId = "delivery-002",
|
||||
ChannelType = "email",
|
||||
Reason = "Timeout"
|
||||
});
|
||||
await _handler.DeadLetterAsync(new DeadLetterEntry
|
||||
{
|
||||
TenantId = "tenant1",
|
||||
DeliveryId = "delivery-003",
|
||||
ChannelType = "slack",
|
||||
Reason = "Auth failed"
|
||||
});
|
||||
|
||||
// Act
|
||||
var stats = await _handler.GetStatisticsAsync("tenant1");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(3, stats.TotalEntries);
|
||||
Assert.Equal(3, stats.PendingCount);
|
||||
Assert.Equal(2, stats.ByChannelType["email"]);
|
||||
Assert.Equal(1, stats.ByChannelType["slack"]);
|
||||
Assert.Equal(2, stats.ByReason["Timeout"]);
|
||||
Assert.Equal(1, stats.ByReason["Auth failed"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStatisticsAsync_FiltersToWindow()
|
||||
{
|
||||
// Arrange
|
||||
await _handler.DeadLetterAsync(new DeadLetterEntry
|
||||
{
|
||||
TenantId = "tenant1",
|
||||
DeliveryId = "delivery-001",
|
||||
ChannelType = "email",
|
||||
Reason = "Error"
|
||||
});
|
||||
|
||||
_timeProvider.Advance(TimeSpan.FromHours(25));
|
||||
|
||||
await _handler.DeadLetterAsync(new DeadLetterEntry
|
||||
{
|
||||
TenantId = "tenant1",
|
||||
DeliveryId = "delivery-002",
|
||||
ChannelType = "email",
|
||||
Reason = "Error"
|
||||
});
|
||||
|
||||
// Act - get stats for last 24 hours only
|
||||
var stats = await _handler.GetStatisticsAsync("tenant1", TimeSpan.FromHours(24));
|
||||
|
||||
// Assert
|
||||
Assert.Equal(1, stats.TotalEntries);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PurgeAsync_RemovesOldEntries()
|
||||
{
|
||||
// Arrange
|
||||
await _handler.DeadLetterAsync(new DeadLetterEntry
|
||||
{
|
||||
TenantId = "tenant1",
|
||||
DeliveryId = "delivery-001",
|
||||
ChannelType = "email",
|
||||
Reason = "Error"
|
||||
});
|
||||
|
||||
_timeProvider.Advance(TimeSpan.FromDays(10));
|
||||
|
||||
await _handler.DeadLetterAsync(new DeadLetterEntry
|
||||
{
|
||||
TenantId = "tenant1",
|
||||
DeliveryId = "delivery-002",
|
||||
ChannelType = "email",
|
||||
Reason = "Error"
|
||||
});
|
||||
|
||||
// Act - purge entries older than 7 days
|
||||
var purged = await _handler.PurgeAsync("tenant1", TimeSpan.FromDays(7));
|
||||
|
||||
// Assert
|
||||
Assert.Equal(1, purged);
|
||||
var entries = await _handler.GetEntriesAsync("tenant1");
|
||||
Assert.Single(entries);
|
||||
Assert.Equal("delivery-002", entries[0].DeliveryId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Subscribe_NotifiesObserver()
|
||||
{
|
||||
// Arrange
|
||||
var observer = new TestDeadLetterObserver();
|
||||
using var subscription = _handler.Subscribe(observer);
|
||||
|
||||
// Act
|
||||
await _handler.DeadLetterAsync(new DeadLetterEntry
|
||||
{
|
||||
TenantId = "tenant1",
|
||||
DeliveryId = "delivery-001",
|
||||
ChannelType = "email",
|
||||
Reason = "Error"
|
||||
});
|
||||
|
||||
// Assert
|
||||
Assert.Single(observer.ReceivedEvents);
|
||||
Assert.Equal(DeadLetterEventType.Added, observer.ReceivedEvents[0].Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Subscribe_NotifiesOnRetry()
|
||||
{
|
||||
// Arrange
|
||||
var observer = new TestDeadLetterObserver();
|
||||
|
||||
await _handler.DeadLetterAsync(new DeadLetterEntry
|
||||
{
|
||||
TenantId = "tenant1",
|
||||
DeliveryId = "delivery-001",
|
||||
ChannelType = "email",
|
||||
Reason = "Error"
|
||||
});
|
||||
|
||||
var entries = await _handler.GetEntriesAsync("tenant1");
|
||||
var entryId = entries[0].Id;
|
||||
|
||||
using var subscription = _handler.Subscribe(observer);
|
||||
|
||||
// Act
|
||||
await _handler.RetryAsync("tenant1", entryId, "admin");
|
||||
|
||||
// Assert
|
||||
Assert.Single(observer.ReceivedEvents);
|
||||
Assert.Equal(DeadLetterEventType.RetryScheduled, observer.ReceivedEvents[0].Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Subscribe_DisposedDoesNotNotify()
|
||||
{
|
||||
// Arrange
|
||||
var observer = new TestDeadLetterObserver();
|
||||
var subscription = _handler.Subscribe(observer);
|
||||
subscription.Dispose();
|
||||
|
||||
// Act
|
||||
await _handler.DeadLetterAsync(new DeadLetterEntry
|
||||
{
|
||||
TenantId = "tenant1",
|
||||
DeliveryId = "delivery-001",
|
||||
ChannelType = "email",
|
||||
Reason = "Error"
|
||||
});
|
||||
|
||||
// Assert
|
||||
Assert.Empty(observer.ReceivedEvents);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MaxEntriesPerTenant_EnforcesLimit()
|
||||
{
|
||||
// Arrange
|
||||
var limitedOptions = new DeadLetterOptions
|
||||
{
|
||||
Enabled = true,
|
||||
MaxEntriesPerTenant = 3
|
||||
};
|
||||
var handler = new InMemoryDeadLetterHandler(
|
||||
Options.Create(limitedOptions),
|
||||
_timeProvider,
|
||||
NullLogger<InMemoryDeadLetterHandler>.Instance);
|
||||
|
||||
// Act - add 5 entries
|
||||
for (var i = 0; i < 5; i++)
|
||||
{
|
||||
await handler.DeadLetterAsync(new DeadLetterEntry
|
||||
{
|
||||
TenantId = "tenant1",
|
||||
DeliveryId = $"delivery-{i:D3}",
|
||||
ChannelType = "email",
|
||||
Reason = "Error"
|
||||
});
|
||||
}
|
||||
|
||||
// Assert - should only have 3 entries (oldest removed)
|
||||
var entries = await handler.GetEntriesAsync("tenant1");
|
||||
Assert.Equal(3, entries.Count);
|
||||
}
|
||||
|
||||
private sealed class TestDeadLetterObserver : IDeadLetterObserver
|
||||
{
|
||||
public List<DeadLetterEvent> ReceivedEvents { get; } = [];
|
||||
|
||||
public void OnDeadLetterEvent(DeadLetterEvent evt)
|
||||
{
|
||||
ReceivedEvents.Add(evt);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,475 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Notifier.Worker.Observability;
|
||||
|
||||
namespace StellaOps.Notifier.Tests.Observability;
|
||||
|
||||
public class RetentionPolicyServiceTests
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly RetentionPolicyOptions _options;
|
||||
private readonly InMemoryRetentionPolicyService _service;
|
||||
|
||||
public RetentionPolicyServiceTests()
|
||||
{
|
||||
_timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow);
|
||||
_options = new RetentionPolicyOptions
|
||||
{
|
||||
Enabled = true,
|
||||
DefaultRetentionPeriod = TimeSpan.FromDays(90),
|
||||
MinRetentionPeriod = TimeSpan.FromDays(1),
|
||||
MaxRetentionPeriod = TimeSpan.FromDays(365)
|
||||
};
|
||||
_service = new InMemoryRetentionPolicyService(
|
||||
Options.Create(_options),
|
||||
_timeProvider,
|
||||
NullLogger<InMemoryRetentionPolicyService>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RegisterPolicyAsync_CreatesPolicy()
|
||||
{
|
||||
// Arrange
|
||||
var policy = new RetentionPolicy
|
||||
{
|
||||
Id = "policy-001",
|
||||
Name = "Delivery Log Cleanup",
|
||||
DataType = RetentionDataType.DeliveryLogs,
|
||||
RetentionPeriod = TimeSpan.FromDays(30),
|
||||
Action = RetentionAction.Delete
|
||||
};
|
||||
|
||||
// Act
|
||||
await _service.RegisterPolicyAsync(policy);
|
||||
|
||||
// Assert
|
||||
var retrieved = await _service.GetPolicyAsync("policy-001");
|
||||
Assert.NotNull(retrieved);
|
||||
Assert.Equal("Delivery Log Cleanup", retrieved.Name);
|
||||
Assert.Equal(RetentionDataType.DeliveryLogs, retrieved.DataType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RegisterPolicyAsync_DuplicateId_Throws()
|
||||
{
|
||||
// Arrange
|
||||
var policy = new RetentionPolicy
|
||||
{
|
||||
Id = "policy-001",
|
||||
Name = "Policy 1",
|
||||
DataType = RetentionDataType.DeliveryLogs,
|
||||
RetentionPeriod = TimeSpan.FromDays(30)
|
||||
};
|
||||
await _service.RegisterPolicyAsync(policy);
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
_service.RegisterPolicyAsync(policy));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RegisterPolicyAsync_RetentionTooShort_Throws()
|
||||
{
|
||||
// Arrange
|
||||
var policy = new RetentionPolicy
|
||||
{
|
||||
Id = "policy-001",
|
||||
Name = "Too Short",
|
||||
DataType = RetentionDataType.DeliveryLogs,
|
||||
RetentionPeriod = TimeSpan.FromHours(1) // Less than 1 day minimum
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ArgumentException>(() =>
|
||||
_service.RegisterPolicyAsync(policy));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RegisterPolicyAsync_RetentionTooLong_Throws()
|
||||
{
|
||||
// Arrange
|
||||
var policy = new RetentionPolicy
|
||||
{
|
||||
Id = "policy-001",
|
||||
Name = "Too Long",
|
||||
DataType = RetentionDataType.DeliveryLogs,
|
||||
RetentionPeriod = TimeSpan.FromDays(500) // More than 365 days maximum
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ArgumentException>(() =>
|
||||
_service.RegisterPolicyAsync(policy));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RegisterPolicyAsync_ArchiveWithoutLocation_Throws()
|
||||
{
|
||||
// Arrange
|
||||
var policy = new RetentionPolicy
|
||||
{
|
||||
Id = "policy-001",
|
||||
Name = "Archive Without Location",
|
||||
DataType = RetentionDataType.AuditLogs,
|
||||
RetentionPeriod = TimeSpan.FromDays(90),
|
||||
Action = RetentionAction.Archive
|
||||
// Missing ArchiveLocation
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ArgumentException>(() =>
|
||||
_service.RegisterPolicyAsync(policy));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdatePolicyAsync_UpdatesPolicy()
|
||||
{
|
||||
// Arrange
|
||||
var policy = new RetentionPolicy
|
||||
{
|
||||
Id = "policy-001",
|
||||
Name = "Original Name",
|
||||
DataType = RetentionDataType.DeliveryLogs,
|
||||
RetentionPeriod = TimeSpan.FromDays(30)
|
||||
};
|
||||
await _service.RegisterPolicyAsync(policy);
|
||||
|
||||
// Act
|
||||
var updated = policy with { Name = "Updated Name" };
|
||||
await _service.UpdatePolicyAsync("policy-001", updated);
|
||||
|
||||
// Assert
|
||||
var retrieved = await _service.GetPolicyAsync("policy-001");
|
||||
Assert.NotNull(retrieved);
|
||||
Assert.Equal("Updated Name", retrieved.Name);
|
||||
Assert.NotNull(retrieved.ModifiedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdatePolicyAsync_NotFound_Throws()
|
||||
{
|
||||
// Arrange
|
||||
var policy = new RetentionPolicy
|
||||
{
|
||||
Id = "nonexistent",
|
||||
Name = "Policy",
|
||||
DataType = RetentionDataType.DeliveryLogs,
|
||||
RetentionPeriod = TimeSpan.FromDays(30)
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<KeyNotFoundException>(() =>
|
||||
_service.UpdatePolicyAsync("nonexistent", policy));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeletePolicyAsync_RemovesPolicy()
|
||||
{
|
||||
// Arrange
|
||||
var policy = new RetentionPolicy
|
||||
{
|
||||
Id = "policy-001",
|
||||
Name = "To Delete",
|
||||
DataType = RetentionDataType.DeliveryLogs,
|
||||
RetentionPeriod = TimeSpan.FromDays(30)
|
||||
};
|
||||
await _service.RegisterPolicyAsync(policy);
|
||||
|
||||
// Act
|
||||
await _service.DeletePolicyAsync("policy-001");
|
||||
|
||||
// Assert
|
||||
var retrieved = await _service.GetPolicyAsync("policy-001");
|
||||
Assert.Null(retrieved);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListPoliciesAsync_ReturnsAllPolicies()
|
||||
{
|
||||
// Arrange
|
||||
await _service.RegisterPolicyAsync(new RetentionPolicy
|
||||
{
|
||||
Id = "policy-001",
|
||||
Name = "Policy A",
|
||||
DataType = RetentionDataType.DeliveryLogs,
|
||||
RetentionPeriod = TimeSpan.FromDays(30)
|
||||
});
|
||||
await _service.RegisterPolicyAsync(new RetentionPolicy
|
||||
{
|
||||
Id = "policy-002",
|
||||
Name = "Policy B",
|
||||
DataType = RetentionDataType.Escalations,
|
||||
RetentionPeriod = TimeSpan.FromDays(60)
|
||||
});
|
||||
|
||||
// Act
|
||||
var policies = await _service.ListPoliciesAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, policies.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListPoliciesAsync_FiltersByTenant()
|
||||
{
|
||||
// Arrange
|
||||
await _service.RegisterPolicyAsync(new RetentionPolicy
|
||||
{
|
||||
Id = "policy-001",
|
||||
Name = "Global Policy",
|
||||
DataType = RetentionDataType.DeliveryLogs,
|
||||
RetentionPeriod = TimeSpan.FromDays(30),
|
||||
TenantId = null // Global
|
||||
});
|
||||
await _service.RegisterPolicyAsync(new RetentionPolicy
|
||||
{
|
||||
Id = "policy-002",
|
||||
Name = "Tenant Policy",
|
||||
DataType = RetentionDataType.DeliveryLogs,
|
||||
RetentionPeriod = TimeSpan.FromDays(30),
|
||||
TenantId = "tenant1"
|
||||
});
|
||||
await _service.RegisterPolicyAsync(new RetentionPolicy
|
||||
{
|
||||
Id = "policy-003",
|
||||
Name = "Other Tenant Policy",
|
||||
DataType = RetentionDataType.DeliveryLogs,
|
||||
RetentionPeriod = TimeSpan.FromDays(30),
|
||||
TenantId = "tenant2"
|
||||
});
|
||||
|
||||
// Act
|
||||
var tenant1Policies = await _service.ListPoliciesAsync("tenant1");
|
||||
|
||||
// Assert - should include global and tenant-specific
|
||||
Assert.Equal(2, tenant1Policies.Count);
|
||||
Assert.Contains(tenant1Policies, p => p.Id == "policy-001");
|
||||
Assert.Contains(tenant1Policies, p => p.Id == "policy-002");
|
||||
Assert.DoesNotContain(tenant1Policies, p => p.Id == "policy-003");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteRetentionAsync_WhenDisabled_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var disabledOptions = new RetentionPolicyOptions { Enabled = false };
|
||||
var service = new InMemoryRetentionPolicyService(
|
||||
Options.Create(disabledOptions),
|
||||
_timeProvider,
|
||||
NullLogger<InMemoryRetentionPolicyService>.Instance);
|
||||
|
||||
// Act
|
||||
var result = await service.ExecuteRetentionAsync();
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Success);
|
||||
Assert.Single(result.Errors);
|
||||
Assert.Contains("disabled", result.Errors[0].Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteRetentionAsync_ExecutesEnabledPolicies()
|
||||
{
|
||||
// Arrange
|
||||
await _service.RegisterPolicyAsync(new RetentionPolicy
|
||||
{
|
||||
Id = "policy-001",
|
||||
Name = "Enabled Policy",
|
||||
DataType = RetentionDataType.DeliveryLogs,
|
||||
RetentionPeriod = TimeSpan.FromDays(30),
|
||||
Enabled = true
|
||||
});
|
||||
await _service.RegisterPolicyAsync(new RetentionPolicy
|
||||
{
|
||||
Id = "policy-002",
|
||||
Name = "Disabled Policy",
|
||||
DataType = RetentionDataType.Escalations,
|
||||
RetentionPeriod = TimeSpan.FromDays(30),
|
||||
Enabled = false
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _service.ExecuteRetentionAsync();
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Success);
|
||||
Assert.Single(result.PoliciesExecuted);
|
||||
Assert.Contains("policy-001", result.PoliciesExecuted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteRetentionAsync_SpecificPolicy_ExecutesOnlyThat()
|
||||
{
|
||||
// Arrange
|
||||
await _service.RegisterPolicyAsync(new RetentionPolicy
|
||||
{
|
||||
Id = "policy-001",
|
||||
Name = "Policy 1",
|
||||
DataType = RetentionDataType.DeliveryLogs,
|
||||
RetentionPeriod = TimeSpan.FromDays(30)
|
||||
});
|
||||
await _service.RegisterPolicyAsync(new RetentionPolicy
|
||||
{
|
||||
Id = "policy-002",
|
||||
Name = "Policy 2",
|
||||
DataType = RetentionDataType.Escalations,
|
||||
RetentionPeriod = TimeSpan.FromDays(30)
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _service.ExecuteRetentionAsync("policy-002");
|
||||
|
||||
// Assert
|
||||
Assert.Single(result.PoliciesExecuted);
|
||||
Assert.Equal("policy-002", result.PoliciesExecuted[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PreviewRetentionAsync_ReturnsPreview()
|
||||
{
|
||||
// Arrange
|
||||
_service.RegisterHandler("DeliveryLogs", new TestRetentionHandler("DeliveryLogs", 100));
|
||||
|
||||
await _service.RegisterPolicyAsync(new RetentionPolicy
|
||||
{
|
||||
Id = "policy-001",
|
||||
Name = "Delivery Cleanup",
|
||||
DataType = RetentionDataType.DeliveryLogs,
|
||||
RetentionPeriod = TimeSpan.FromDays(30)
|
||||
});
|
||||
|
||||
// Act
|
||||
var preview = await _service.PreviewRetentionAsync("policy-001");
|
||||
|
||||
// Assert
|
||||
Assert.Equal("policy-001", preview.PolicyId);
|
||||
Assert.Equal(100, preview.TotalAffected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PreviewRetentionAsync_NotFound_Throws()
|
||||
{
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<KeyNotFoundException>(() =>
|
||||
_service.PreviewRetentionAsync("nonexistent"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetExecutionHistoryAsync_ReturnsHistory()
|
||||
{
|
||||
// Arrange
|
||||
_service.RegisterHandler("DeliveryLogs", new TestRetentionHandler("DeliveryLogs", 50));
|
||||
|
||||
await _service.RegisterPolicyAsync(new RetentionPolicy
|
||||
{
|
||||
Id = "policy-001",
|
||||
Name = "Policy",
|
||||
DataType = RetentionDataType.DeliveryLogs,
|
||||
RetentionPeriod = TimeSpan.FromDays(30)
|
||||
});
|
||||
|
||||
// Execute twice
|
||||
await _service.ExecuteRetentionAsync("policy-001");
|
||||
_timeProvider.Advance(TimeSpan.FromHours(1));
|
||||
await _service.ExecuteRetentionAsync("policy-001");
|
||||
|
||||
// Act
|
||||
var history = await _service.GetExecutionHistoryAsync("policy-001");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, history.Count);
|
||||
Assert.All(history, r => Assert.True(r.Success));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetNextExecutionAsync_ReturnsNextTime()
|
||||
{
|
||||
// Arrange
|
||||
await _service.RegisterPolicyAsync(new RetentionPolicy
|
||||
{
|
||||
Id = "policy-001",
|
||||
Name = "Scheduled Policy",
|
||||
DataType = RetentionDataType.DeliveryLogs,
|
||||
RetentionPeriod = TimeSpan.FromDays(30),
|
||||
Schedule = "0 0 * * *" // Daily at midnight
|
||||
});
|
||||
|
||||
// Act
|
||||
var next = await _service.GetNextExecutionAsync("policy-001");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(next);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetNextExecutionAsync_NoSchedule_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
await _service.RegisterPolicyAsync(new RetentionPolicy
|
||||
{
|
||||
Id = "policy-001",
|
||||
Name = "Unscheduled Policy",
|
||||
DataType = RetentionDataType.DeliveryLogs,
|
||||
RetentionPeriod = TimeSpan.FromDays(30)
|
||||
// No schedule
|
||||
});
|
||||
|
||||
// Act
|
||||
var next = await _service.GetNextExecutionAsync("policy-001");
|
||||
|
||||
// Assert
|
||||
Assert.Null(next);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateDeliveryLogPolicy_CreatesValidPolicy()
|
||||
{
|
||||
// Act
|
||||
var policy = RetentionPolicyExtensions.CreateDeliveryLogPolicy(
|
||||
"delivery-logs-cleanup",
|
||||
TimeSpan.FromDays(30),
|
||||
"tenant1",
|
||||
"admin");
|
||||
|
||||
// Assert
|
||||
Assert.Equal("delivery-logs-cleanup", policy.Id);
|
||||
Assert.Equal(RetentionDataType.DeliveryLogs, policy.DataType);
|
||||
Assert.Equal(TimeSpan.FromDays(30), policy.RetentionPeriod);
|
||||
Assert.Equal("tenant1", policy.TenantId);
|
||||
Assert.Equal("admin", policy.CreatedBy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateAuditArchivePolicy_CreatesValidPolicy()
|
||||
{
|
||||
// Act
|
||||
var policy = RetentionPolicyExtensions.CreateAuditArchivePolicy(
|
||||
"audit-archive",
|
||||
TimeSpan.FromDays(365),
|
||||
"s3://bucket/archive",
|
||||
"tenant1",
|
||||
"admin");
|
||||
|
||||
// Assert
|
||||
Assert.Equal("audit-archive", policy.Id);
|
||||
Assert.Equal(RetentionDataType.AuditLogs, policy.DataType);
|
||||
Assert.Equal(RetentionAction.Archive, policy.Action);
|
||||
Assert.Equal("s3://bucket/archive", policy.ArchiveLocation);
|
||||
}
|
||||
|
||||
private sealed class TestRetentionHandler : IRetentionHandler
|
||||
{
|
||||
public string DataType { get; }
|
||||
private readonly long _count;
|
||||
|
||||
public TestRetentionHandler(string dataType, long count)
|
||||
{
|
||||
DataType = dataType;
|
||||
_count = count;
|
||||
}
|
||||
|
||||
public Task<long> CountAsync(RetentionQuery query, CancellationToken ct) => Task.FromResult(_count);
|
||||
public Task<long> DeleteAsync(RetentionQuery query, CancellationToken ct) => Task.FromResult(_count);
|
||||
public Task<long> ArchiveAsync(RetentionQuery query, string archiveLocation, CancellationToken ct) => Task.FromResult(_count);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Notifier.Worker.Security;
|
||||
|
||||
namespace StellaOps.Notifier.Tests.Security;
|
||||
|
||||
public class HtmlSanitizerTests
|
||||
{
|
||||
private readonly HtmlSanitizerOptions _options;
|
||||
private readonly DefaultHtmlSanitizer _sanitizer;
|
||||
|
||||
public HtmlSanitizerTests()
|
||||
{
|
||||
_options = new HtmlSanitizerOptions
|
||||
{
|
||||
DefaultProfile = "basic",
|
||||
LogSanitization = false
|
||||
};
|
||||
_sanitizer = new DefaultHtmlSanitizer(
|
||||
Options.Create(_options),
|
||||
NullLogger<DefaultHtmlSanitizer>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sanitize_AllowedTags_Preserved()
|
||||
{
|
||||
// Arrange
|
||||
var html = "<p>Hello <strong>World</strong></p>";
|
||||
|
||||
// Act
|
||||
var result = _sanitizer.Sanitize(html);
|
||||
|
||||
// Assert
|
||||
Assert.Contains("<p>", result);
|
||||
Assert.Contains("<strong>", result);
|
||||
Assert.Contains("</strong>", result);
|
||||
Assert.Contains("</p>", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sanitize_DisallowedTags_Removed()
|
||||
{
|
||||
// Arrange
|
||||
var html = "<p>Hello</p><iframe src='evil.com'></iframe>";
|
||||
|
||||
// Act
|
||||
var result = _sanitizer.Sanitize(html);
|
||||
|
||||
// Assert
|
||||
Assert.Contains("<p>Hello</p>", result);
|
||||
Assert.DoesNotContain("<iframe", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sanitize_ScriptTags_Removed()
|
||||
{
|
||||
// Arrange
|
||||
var html = "<p>Hello</p><script>alert('xss')</script>";
|
||||
|
||||
// Act
|
||||
var result = _sanitizer.Sanitize(html);
|
||||
|
||||
// Assert
|
||||
Assert.Contains("<p>Hello</p>", result);
|
||||
Assert.DoesNotContain("<script", result);
|
||||
Assert.DoesNotContain("alert", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sanitize_EventHandlers_Removed()
|
||||
{
|
||||
// Arrange
|
||||
var html = "<p onclick='alert(1)'>Hello</p>";
|
||||
|
||||
// Act
|
||||
var result = _sanitizer.Sanitize(html);
|
||||
|
||||
// Assert
|
||||
Assert.DoesNotContain("onclick", result);
|
||||
Assert.Contains("<p>Hello</p>", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sanitize_JavaScriptUrls_Removed()
|
||||
{
|
||||
// Arrange
|
||||
var html = "<a href='javascript:alert(1)'>Click</a>";
|
||||
|
||||
// Act
|
||||
var result = _sanitizer.Sanitize(html);
|
||||
|
||||
// Assert
|
||||
Assert.DoesNotContain("javascript:", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sanitize_AllowedAttributes_Preserved()
|
||||
{
|
||||
// Arrange
|
||||
var html = "<a href='https://example.com' title='Example'>Link</a>";
|
||||
|
||||
// Act
|
||||
var result = _sanitizer.Sanitize(html);
|
||||
|
||||
// Assert
|
||||
Assert.Contains("href=", result);
|
||||
Assert.Contains("https://example.com", result);
|
||||
Assert.Contains("title=", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sanitize_DisallowedAttributes_Removed()
|
||||
{
|
||||
// Arrange
|
||||
var html = "<p data-custom='value' class='test'>Hello</p>";
|
||||
|
||||
// Act
|
||||
var result = _sanitizer.Sanitize(html);
|
||||
|
||||
// Assert
|
||||
Assert.DoesNotContain("data-custom", result);
|
||||
Assert.Contains("class=", result); // class is allowed
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sanitize_WithMinimalProfile_OnlyBasicTags()
|
||||
{
|
||||
// Arrange
|
||||
var html = "<p><a href='https://example.com'>Link</a></p>";
|
||||
var profile = SanitizationProfile.Minimal;
|
||||
|
||||
// Act
|
||||
var result = _sanitizer.Sanitize(html, profile);
|
||||
|
||||
// Assert
|
||||
Assert.Contains("<p>", result);
|
||||
Assert.DoesNotContain("<a", result); // links not in minimal
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sanitize_WithRichProfile_AllowsImagesAndTables()
|
||||
{
|
||||
// Arrange
|
||||
var html = "<table><tr><td>Cell</td></tr></table><img src='test.png' alt='Test'>";
|
||||
var profile = SanitizationProfile.Rich;
|
||||
|
||||
// Act
|
||||
var result = _sanitizer.Sanitize(html, profile);
|
||||
|
||||
// Assert
|
||||
Assert.Contains("<table>", result);
|
||||
Assert.Contains("<img", result);
|
||||
Assert.Contains("src=", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sanitize_HtmlComments_Removed()
|
||||
{
|
||||
// Arrange
|
||||
var html = "<p>Hello<!-- comment --></p>";
|
||||
|
||||
// Act
|
||||
var result = _sanitizer.Sanitize(html);
|
||||
|
||||
// Assert
|
||||
Assert.DoesNotContain("<!--", result);
|
||||
Assert.DoesNotContain("comment", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sanitize_EmptyString_ReturnsEmpty()
|
||||
{
|
||||
// Act
|
||||
var result = _sanitizer.Sanitize("");
|
||||
|
||||
// Assert
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sanitize_NullString_ReturnsNull()
|
||||
{
|
||||
// Act
|
||||
var result = _sanitizer.Sanitize(null!);
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_SafeHtml_ReturnsValid()
|
||||
{
|
||||
// Arrange
|
||||
var html = "<p>Hello <strong>World</strong></p>";
|
||||
|
||||
// Act
|
||||
var result = _sanitizer.Validate(html);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Empty(result.Errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ScriptTag_ReturnsErrors()
|
||||
{
|
||||
// Arrange
|
||||
var html = "<script>alert('xss')</script>";
|
||||
|
||||
// Act
|
||||
var result = _sanitizer.Validate(html);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.Errors, e => e.Type == HtmlValidationErrorType.ScriptDetected);
|
||||
Assert.True(result.ContainedDangerousContent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_EventHandler_ReturnsErrors()
|
||||
{
|
||||
// Arrange
|
||||
var html = "<p onclick='alert(1)'>Hello</p>";
|
||||
|
||||
// Act
|
||||
var result = _sanitizer.Validate(html);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.Errors, e => e.Type == HtmlValidationErrorType.EventHandlerDetected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_JavaScriptUrl_ReturnsErrors()
|
||||
{
|
||||
// Arrange
|
||||
var html = "<a href='javascript:void(0)'>Click</a>";
|
||||
|
||||
// Act
|
||||
var result = _sanitizer.Validate(html);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.Errors, e => e.Type == HtmlValidationErrorType.JavaScriptUrlDetected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_DisallowedTags_ReturnsWarnings()
|
||||
{
|
||||
// Arrange
|
||||
var html = "<p>Hello</p><custom-tag>Custom</custom-tag>";
|
||||
|
||||
// Act
|
||||
var result = _sanitizer.Validate(html);
|
||||
|
||||
// Assert
|
||||
Assert.Contains(result.RemovedTags, t => t == "custom-tag");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EscapeHtml_EscapesSpecialCharacters()
|
||||
{
|
||||
// Arrange
|
||||
var text = "<script>alert('test')</script>";
|
||||
|
||||
// Act
|
||||
var result = _sanitizer.EscapeHtml(text);
|
||||
|
||||
// Assert
|
||||
Assert.DoesNotContain("<", result);
|
||||
Assert.DoesNotContain(">", result);
|
||||
Assert.Contains("<", result);
|
||||
Assert.Contains(">", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StripTags_RemovesAllTags()
|
||||
{
|
||||
// Arrange
|
||||
var html = "<p>Hello <strong>World</strong></p>";
|
||||
|
||||
// Act
|
||||
var result = _sanitizer.StripTags(html);
|
||||
|
||||
// Assert
|
||||
Assert.DoesNotContain("<", result);
|
||||
Assert.DoesNotContain(">", result);
|
||||
Assert.Contains("Hello", result);
|
||||
Assert.Contains("World", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetProfile_ExistingProfile_ReturnsProfile()
|
||||
{
|
||||
// Act
|
||||
var profile = _sanitizer.GetProfile("basic");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(profile);
|
||||
Assert.Equal("basic", profile.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetProfile_NonExistentProfile_ReturnsNull()
|
||||
{
|
||||
// Act
|
||||
var profile = _sanitizer.GetProfile("non-existent");
|
||||
|
||||
// Assert
|
||||
Assert.Null(profile);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RegisterProfile_AddsCustomProfile()
|
||||
{
|
||||
// Arrange
|
||||
var customProfile = new SanitizationProfile
|
||||
{
|
||||
Name = "custom",
|
||||
AllowedTags = new HashSet<string> { "p", "custom-tag" }
|
||||
};
|
||||
|
||||
// Act
|
||||
_sanitizer.RegisterProfile("custom", customProfile);
|
||||
var retrieved = _sanitizer.GetProfile("custom");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(retrieved);
|
||||
Assert.Equal("custom", retrieved.Name);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("<p>Test</p>", "<p>Test</p>")]
|
||||
[InlineData("<P>Test</P>", "<p>Test</p>")]
|
||||
[InlineData("<DIV>Test</DIV>", "<div>Test</div>")]
|
||||
public void Sanitize_NormalizesTagCase(string input, string expected)
|
||||
{
|
||||
// Act
|
||||
var result = _sanitizer.Sanitize(input);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(expected, result.Trim());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sanitize_SafeUrlSchemes_Preserved()
|
||||
{
|
||||
// Arrange
|
||||
var html = "<a href='mailto:test@example.com'>Email</a>";
|
||||
|
||||
// Act
|
||||
var result = _sanitizer.Sanitize(html);
|
||||
|
||||
// Assert
|
||||
Assert.Contains("mailto:", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sanitize_DataUrl_RemovedByDefault()
|
||||
{
|
||||
// Arrange
|
||||
var html = "<img src='data:image/png;base64,abc123' />";
|
||||
var profile = SanitizationProfile.Rich with { AllowDataUrls = false };
|
||||
|
||||
// Act
|
||||
var result = _sanitizer.Sanitize(html, profile);
|
||||
|
||||
// Assert
|
||||
Assert.DoesNotContain("data:", result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Notifier.Worker.Security;
|
||||
|
||||
namespace StellaOps.Notifier.Tests.Security;
|
||||
|
||||
public class SigningServiceTests
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly SigningServiceOptions _options;
|
||||
private readonly LocalSigningKeyProvider _keyProvider;
|
||||
private readonly SigningService _signingService;
|
||||
|
||||
public SigningServiceTests()
|
||||
{
|
||||
_timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow);
|
||||
_options = new SigningServiceOptions
|
||||
{
|
||||
LocalSigningKey = "test-signing-key-for-unit-tests",
|
||||
Algorithm = "HMACSHA256",
|
||||
DefaultExpiry = TimeSpan.FromHours(24)
|
||||
};
|
||||
_keyProvider = new LocalSigningKeyProvider(
|
||||
Options.Create(_options),
|
||||
_timeProvider);
|
||||
_signingService = new SigningService(
|
||||
_keyProvider,
|
||||
Options.Create(_options),
|
||||
_timeProvider,
|
||||
NullLogger<SigningService>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignAsync_CreatesValidToken()
|
||||
{
|
||||
// Arrange
|
||||
var payload = new SigningPayload
|
||||
{
|
||||
TokenId = "token-001",
|
||||
Purpose = "incident.ack",
|
||||
TenantId = "tenant1",
|
||||
Subject = "incident-123",
|
||||
ExpiresAt = _timeProvider.GetUtcNow().AddHours(24)
|
||||
};
|
||||
|
||||
// Act
|
||||
var token = await _signingService.SignAsync(payload);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(token);
|
||||
Assert.Contains(".", token);
|
||||
var parts = token.Split('.');
|
||||
Assert.Equal(3, parts.Length); // header.body.signature
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_ValidToken_ReturnsValid()
|
||||
{
|
||||
// Arrange
|
||||
var payload = new SigningPayload
|
||||
{
|
||||
TokenId = "token-001",
|
||||
Purpose = "incident.ack",
|
||||
TenantId = "tenant1",
|
||||
Subject = "incident-123",
|
||||
ExpiresAt = _timeProvider.GetUtcNow().AddHours(24)
|
||||
};
|
||||
var token = await _signingService.SignAsync(payload);
|
||||
|
||||
// Act
|
||||
var result = await _signingService.VerifyAsync(token);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsValid);
|
||||
Assert.NotNull(result.Payload);
|
||||
Assert.Equal(payload.TokenId, result.Payload.TokenId);
|
||||
Assert.Equal(payload.Purpose, result.Payload.Purpose);
|
||||
Assert.Equal(payload.TenantId, result.Payload.TenantId);
|
||||
Assert.Equal(payload.Subject, result.Payload.Subject);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_ExpiredToken_ReturnsExpired()
|
||||
{
|
||||
// Arrange
|
||||
var payload = new SigningPayload
|
||||
{
|
||||
TokenId = "token-001",
|
||||
Purpose = "incident.ack",
|
||||
TenantId = "tenant1",
|
||||
Subject = "incident-123",
|
||||
ExpiresAt = _timeProvider.GetUtcNow().AddHours(1)
|
||||
};
|
||||
var token = await _signingService.SignAsync(payload);
|
||||
|
||||
// Advance time past expiry
|
||||
_timeProvider.Advance(TimeSpan.FromHours(2));
|
||||
|
||||
// Act
|
||||
var result = await _signingService.VerifyAsync(token);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal(SigningErrorCode.Expired, result.ErrorCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_TamperedToken_ReturnsInvalidSignature()
|
||||
{
|
||||
// Arrange
|
||||
var payload = new SigningPayload
|
||||
{
|
||||
TokenId = "token-001",
|
||||
Purpose = "incident.ack",
|
||||
TenantId = "tenant1",
|
||||
Subject = "incident-123",
|
||||
ExpiresAt = _timeProvider.GetUtcNow().AddHours(24)
|
||||
};
|
||||
var token = await _signingService.SignAsync(payload);
|
||||
|
||||
// Tamper with the token
|
||||
var parts = token.Split('.');
|
||||
var tamperedToken = $"{parts[0]}.{parts[1]}.tampered_signature";
|
||||
|
||||
// Act
|
||||
var result = await _signingService.VerifyAsync(tamperedToken);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal(SigningErrorCode.InvalidSignature, result.ErrorCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_MalformedToken_ReturnsInvalidFormat()
|
||||
{
|
||||
// Act
|
||||
var result = await _signingService.VerifyAsync("not-a-valid-token");
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal(SigningErrorCode.InvalidFormat, result.ErrorCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTokenInfo_ValidToken_ReturnsInfo()
|
||||
{
|
||||
// Arrange
|
||||
var payload = new SigningPayload
|
||||
{
|
||||
TokenId = "token-001",
|
||||
Purpose = "incident.ack",
|
||||
TenantId = "tenant1",
|
||||
Subject = "incident-123",
|
||||
ExpiresAt = _timeProvider.GetUtcNow().AddHours(24)
|
||||
};
|
||||
var token = await _signingService.SignAsync(payload);
|
||||
|
||||
// Act
|
||||
var info = _signingService.GetTokenInfo(token);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(info);
|
||||
Assert.Equal(payload.TokenId, info.TokenId);
|
||||
Assert.Equal(payload.Purpose, info.Purpose);
|
||||
Assert.Equal(payload.TenantId, info.TenantId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetTokenInfo_MalformedToken_ReturnsNull()
|
||||
{
|
||||
// Act
|
||||
var info = _signingService.GetTokenInfo("not-a-valid-token");
|
||||
|
||||
// Assert
|
||||
Assert.Null(info);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RotateKeyAsync_CreatesNewKey()
|
||||
{
|
||||
// Arrange
|
||||
var keysBefore = await _keyProvider.ListKeyIdsAsync();
|
||||
|
||||
// Act
|
||||
var success = await _signingService.RotateKeyAsync();
|
||||
|
||||
// Assert
|
||||
Assert.True(success);
|
||||
var keysAfter = await _keyProvider.ListKeyIdsAsync();
|
||||
Assert.True(keysAfter.Count > keysBefore.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignAsync_WithClaims_PreservesClaims()
|
||||
{
|
||||
// Arrange
|
||||
var payload = new SigningPayload
|
||||
{
|
||||
TokenId = "token-001",
|
||||
Purpose = "incident.ack",
|
||||
TenantId = "tenant1",
|
||||
Subject = "incident-123",
|
||||
ExpiresAt = _timeProvider.GetUtcNow().AddHours(24),
|
||||
Claims = new Dictionary<string, string>
|
||||
{
|
||||
["custom1"] = "value1",
|
||||
["custom2"] = "value2"
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var token = await _signingService.SignAsync(payload);
|
||||
var result = await _signingService.VerifyAsync(token);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsValid);
|
||||
Assert.NotNull(result.Payload);
|
||||
Assert.Equal(2, result.Payload.Claims.Count);
|
||||
Assert.Equal("value1", result.Payload.Claims["custom1"]);
|
||||
Assert.Equal("value2", result.Payload.Claims["custom2"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_AfterKeyRotation_StillVerifiesOldTokens()
|
||||
{
|
||||
// Arrange - sign with old key
|
||||
var payload = new SigningPayload
|
||||
{
|
||||
TokenId = "token-001",
|
||||
Purpose = "incident.ack",
|
||||
TenantId = "tenant1",
|
||||
Subject = "incident-123",
|
||||
ExpiresAt = _timeProvider.GetUtcNow().AddHours(24)
|
||||
};
|
||||
var token = await _signingService.SignAsync(payload);
|
||||
|
||||
// Rotate key
|
||||
await _signingService.RotateKeyAsync();
|
||||
|
||||
// Act - verify with new current key (but old key should still be available)
|
||||
var result = await _signingService.VerifyAsync(token);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsValid);
|
||||
}
|
||||
}
|
||||
|
||||
public class LocalSigningKeyProviderTests
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly SigningServiceOptions _options;
|
||||
private readonly LocalSigningKeyProvider _keyProvider;
|
||||
|
||||
public LocalSigningKeyProviderTests()
|
||||
{
|
||||
_timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow);
|
||||
_options = new SigningServiceOptions
|
||||
{
|
||||
LocalSigningKey = "test-key",
|
||||
KeyRetentionPeriod = TimeSpan.FromDays(90)
|
||||
};
|
||||
_keyProvider = new LocalSigningKeyProvider(
|
||||
Options.Create(_options),
|
||||
_timeProvider);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCurrentKeyAsync_ReturnsKey()
|
||||
{
|
||||
// Act
|
||||
var key = await _keyProvider.GetCurrentKeyAsync();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(key);
|
||||
Assert.True(key.IsCurrent);
|
||||
Assert.NotEmpty(key.KeyMaterial);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetKeyByIdAsync_ExistingKey_ReturnsKey()
|
||||
{
|
||||
// Arrange
|
||||
var currentKey = await _keyProvider.GetCurrentKeyAsync();
|
||||
|
||||
// Act
|
||||
var key = await _keyProvider.GetKeyByIdAsync(currentKey.KeyId);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(key);
|
||||
Assert.Equal(currentKey.KeyId, key.KeyId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetKeyByIdAsync_NonExistentKey_ReturnsNull()
|
||||
{
|
||||
// Act
|
||||
var key = await _keyProvider.GetKeyByIdAsync("non-existent-key");
|
||||
|
||||
// Assert
|
||||
Assert.Null(key);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RotateAsync_CreatesNewCurrentKey()
|
||||
{
|
||||
// Arrange
|
||||
var oldKey = await _keyProvider.GetCurrentKeyAsync();
|
||||
|
||||
// Act
|
||||
var newKey = await _keyProvider.RotateAsync();
|
||||
|
||||
// Assert
|
||||
Assert.NotEqual(oldKey.KeyId, newKey.KeyId);
|
||||
Assert.True(newKey.IsCurrent);
|
||||
|
||||
var currentKey = await _keyProvider.GetCurrentKeyAsync();
|
||||
Assert.Equal(newKey.KeyId, currentKey.KeyId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RotateAsync_KeepsOldKeyForVerification()
|
||||
{
|
||||
// Arrange
|
||||
var oldKey = await _keyProvider.GetCurrentKeyAsync();
|
||||
|
||||
// Act
|
||||
await _keyProvider.RotateAsync();
|
||||
|
||||
// Assert - old key should still be retrievable
|
||||
var retrievedOldKey = await _keyProvider.GetKeyByIdAsync(oldKey.KeyId);
|
||||
Assert.NotNull(retrievedOldKey);
|
||||
Assert.False(retrievedOldKey.IsCurrent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListKeyIdsAsync_ReturnsAllKeys()
|
||||
{
|
||||
// Arrange
|
||||
await _keyProvider.RotateAsync();
|
||||
await _keyProvider.RotateAsync();
|
||||
|
||||
// Act
|
||||
var keyIds = await _keyProvider.ListKeyIdsAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(3, keyIds.Count); // Initial + 2 rotations
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Notifier.Worker.Security;
|
||||
|
||||
namespace StellaOps.Notifier.Tests.Security;
|
||||
|
||||
public class TenantIsolationValidatorTests
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly TenantIsolationOptions _options;
|
||||
private readonly InMemoryTenantIsolationValidator _validator;
|
||||
|
||||
public TenantIsolationValidatorTests()
|
||||
{
|
||||
_timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow);
|
||||
_options = new TenantIsolationOptions
|
||||
{
|
||||
EnforceStrict = true,
|
||||
LogViolations = false,
|
||||
RecordViolations = true,
|
||||
AllowCrossTenantGrants = true,
|
||||
SystemResourceTypes = ["system-template"],
|
||||
AdminTenantPatterns = ["^admin$", "^system$"]
|
||||
};
|
||||
_validator = new InMemoryTenantIsolationValidator(
|
||||
Options.Create(_options),
|
||||
_timeProvider,
|
||||
NullLogger<InMemoryTenantIsolationValidator>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateResourceAccessAsync_SameTenant_Allowed()
|
||||
{
|
||||
// Arrange
|
||||
await _validator.RegisterResourceAsync("tenant1", "delivery", "delivery-001");
|
||||
|
||||
// Act
|
||||
var result = await _validator.ValidateResourceAccessAsync(
|
||||
"tenant1", "delivery", "delivery-001", TenantAccessOperation.Read);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsAllowed);
|
||||
Assert.Equal(TenantValidationType.SameTenant, result.ValidationType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateResourceAccessAsync_DifferentTenant_Denied()
|
||||
{
|
||||
// Arrange
|
||||
await _validator.RegisterResourceAsync("tenant1", "delivery", "delivery-001");
|
||||
|
||||
// Act
|
||||
var result = await _validator.ValidateResourceAccessAsync(
|
||||
"tenant2", "delivery", "delivery-001", TenantAccessOperation.Read);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsAllowed);
|
||||
Assert.Equal(TenantValidationType.Denied, result.ValidationType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateResourceAccessAsync_AdminTenant_AlwaysAllowed()
|
||||
{
|
||||
// Arrange
|
||||
await _validator.RegisterResourceAsync("tenant1", "delivery", "delivery-001");
|
||||
|
||||
// Act
|
||||
var result = await _validator.ValidateResourceAccessAsync(
|
||||
"admin", "delivery", "delivery-001", TenantAccessOperation.Read);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsAllowed);
|
||||
Assert.Equal(TenantValidationType.SystemResource, result.ValidationType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateResourceAccessAsync_SystemResource_AlwaysAllowed()
|
||||
{
|
||||
// Arrange
|
||||
await _validator.RegisterResourceAsync("tenant1", "system-template", "template-001");
|
||||
|
||||
// Act
|
||||
var result = await _validator.ValidateResourceAccessAsync(
|
||||
"tenant2", "system-template", "template-001", TenantAccessOperation.Read);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsAllowed);
|
||||
Assert.Equal(TenantValidationType.SystemResource, result.ValidationType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateResourceAccessAsync_UnregisteredResource_Allowed()
|
||||
{
|
||||
// Act - resource not registered
|
||||
var result = await _validator.ValidateResourceAccessAsync(
|
||||
"tenant1", "delivery", "unregistered-delivery", TenantAccessOperation.Read);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsAllowed);
|
||||
Assert.Equal(TenantValidationType.ResourceNotFound, result.ValidationType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateDeliveryAsync_SameTenant_Allowed()
|
||||
{
|
||||
// Arrange
|
||||
await _validator.RegisterResourceAsync("tenant1", "delivery", "delivery-001");
|
||||
|
||||
// Act
|
||||
var result = await _validator.ValidateDeliveryAsync("tenant1", "delivery-001");
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsAllowed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateChannelAsync_SameTenant_Allowed()
|
||||
{
|
||||
// Arrange
|
||||
await _validator.RegisterResourceAsync("tenant1", "channel", "channel-001");
|
||||
|
||||
// Act
|
||||
var result = await _validator.ValidateChannelAsync("tenant1", "channel-001");
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsAllowed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateTemplateAsync_SameTenant_Allowed()
|
||||
{
|
||||
// Arrange
|
||||
await _validator.RegisterResourceAsync("tenant1", "template", "template-001");
|
||||
|
||||
// Act
|
||||
var result = await _validator.ValidateTemplateAsync("tenant1", "template-001");
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsAllowed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateSubscriptionAsync_SameTenant_Allowed()
|
||||
{
|
||||
// Arrange
|
||||
await _validator.RegisterResourceAsync("tenant1", "subscription", "subscription-001");
|
||||
|
||||
// Act
|
||||
var result = await _validator.ValidateSubscriptionAsync("tenant1", "subscription-001");
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsAllowed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GrantCrossTenantAccessAsync_EnablesAccess()
|
||||
{
|
||||
// Arrange
|
||||
await _validator.RegisterResourceAsync("tenant1", "delivery", "delivery-001");
|
||||
|
||||
// Act
|
||||
await _validator.GrantCrossTenantAccessAsync(
|
||||
"tenant1", "tenant2", "delivery", "delivery-001",
|
||||
TenantAccessOperation.Read, null, "admin");
|
||||
|
||||
var result = await _validator.ValidateResourceAccessAsync(
|
||||
"tenant2", "delivery", "delivery-001", TenantAccessOperation.Read);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsAllowed);
|
||||
Assert.True(result.IsCrossTenant);
|
||||
Assert.NotNull(result.GrantId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RevokeCrossTenantAccessAsync_DisablesAccess()
|
||||
{
|
||||
// Arrange
|
||||
await _validator.RegisterResourceAsync("tenant1", "delivery", "delivery-001");
|
||||
await _validator.GrantCrossTenantAccessAsync(
|
||||
"tenant1", "tenant2", "delivery", "delivery-001",
|
||||
TenantAccessOperation.Read, null, "admin");
|
||||
|
||||
// Act
|
||||
await _validator.RevokeCrossTenantAccessAsync(
|
||||
"tenant1", "tenant2", "delivery", "delivery-001", "admin");
|
||||
|
||||
var result = await _validator.ValidateResourceAccessAsync(
|
||||
"tenant2", "delivery", "delivery-001", TenantAccessOperation.Read);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsAllowed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GrantCrossTenantAccessAsync_WithExpiry_ExpiresCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
await _validator.RegisterResourceAsync("tenant1", "delivery", "delivery-001");
|
||||
var expiresAt = _timeProvider.GetUtcNow().AddHours(1);
|
||||
|
||||
await _validator.GrantCrossTenantAccessAsync(
|
||||
"tenant1", "tenant2", "delivery", "delivery-001",
|
||||
TenantAccessOperation.Read, expiresAt, "admin");
|
||||
|
||||
// Verify access before expiry
|
||||
var resultBefore = await _validator.ValidateResourceAccessAsync(
|
||||
"tenant2", "delivery", "delivery-001", TenantAccessOperation.Read);
|
||||
Assert.True(resultBefore.IsAllowed);
|
||||
|
||||
// Advance time past expiry
|
||||
_timeProvider.Advance(TimeSpan.FromHours(2));
|
||||
|
||||
// Act
|
||||
var resultAfter = await _validator.ValidateResourceAccessAsync(
|
||||
"tenant2", "delivery", "delivery-001", TenantAccessOperation.Read);
|
||||
|
||||
// Assert
|
||||
Assert.False(resultAfter.IsAllowed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GrantCrossTenantAccessAsync_OperationRestrictions_Enforced()
|
||||
{
|
||||
// Arrange
|
||||
await _validator.RegisterResourceAsync("tenant1", "delivery", "delivery-001");
|
||||
await _validator.GrantCrossTenantAccessAsync(
|
||||
"tenant1", "tenant2", "delivery", "delivery-001",
|
||||
TenantAccessOperation.Read, null, "admin");
|
||||
|
||||
// Act - Read should be allowed
|
||||
var readResult = await _validator.ValidateResourceAccessAsync(
|
||||
"tenant2", "delivery", "delivery-001", TenantAccessOperation.Read);
|
||||
|
||||
// Write should be denied (not in granted operations)
|
||||
var writeResult = await _validator.ValidateResourceAccessAsync(
|
||||
"tenant2", "delivery", "delivery-001", TenantAccessOperation.Write);
|
||||
|
||||
// Assert
|
||||
Assert.True(readResult.IsAllowed);
|
||||
Assert.False(writeResult.IsAllowed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetViolationsAsync_ReturnsRecordedViolations()
|
||||
{
|
||||
// Arrange
|
||||
await _validator.RegisterResourceAsync("tenant1", "delivery", "delivery-001");
|
||||
|
||||
// Trigger a violation
|
||||
await _validator.ValidateResourceAccessAsync(
|
||||
"tenant2", "delivery", "delivery-001", TenantAccessOperation.Read);
|
||||
|
||||
// Act
|
||||
var violations = await _validator.GetViolationsAsync("tenant2");
|
||||
|
||||
// Assert
|
||||
Assert.Single(violations);
|
||||
Assert.Equal("tenant2", violations[0].RequestingTenantId);
|
||||
Assert.Equal("tenant1", violations[0].ResourceOwnerTenantId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetViolationsAsync_FiltersBySince()
|
||||
{
|
||||
// Arrange
|
||||
await _validator.RegisterResourceAsync("tenant1", "delivery", "delivery-001");
|
||||
await _validator.ValidateResourceAccessAsync(
|
||||
"tenant2", "delivery", "delivery-001", TenantAccessOperation.Read);
|
||||
|
||||
_timeProvider.Advance(TimeSpan.FromHours(2));
|
||||
|
||||
await _validator.RegisterResourceAsync("tenant1", "delivery", "delivery-002");
|
||||
await _validator.ValidateResourceAccessAsync(
|
||||
"tenant2", "delivery", "delivery-002", TenantAccessOperation.Read);
|
||||
|
||||
// Act
|
||||
var since = _timeProvider.GetUtcNow().AddHours(-1);
|
||||
var violations = await _validator.GetViolationsAsync(null, since);
|
||||
|
||||
// Assert
|
||||
Assert.Single(violations);
|
||||
Assert.Equal("delivery-002", violations[0].ResourceId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RegisterResourceAsync_AddsResource()
|
||||
{
|
||||
// Act
|
||||
await _validator.RegisterResourceAsync("tenant1", "delivery", "delivery-001");
|
||||
var resources = await _validator.GetTenantResourcesAsync("tenant1");
|
||||
|
||||
// Assert
|
||||
Assert.Single(resources);
|
||||
Assert.Equal("tenant1", resources[0].TenantId);
|
||||
Assert.Equal("delivery", resources[0].ResourceType);
|
||||
Assert.Equal("delivery-001", resources[0].ResourceId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnregisterResourceAsync_RemovesResource()
|
||||
{
|
||||
// Arrange
|
||||
await _validator.RegisterResourceAsync("tenant1", "delivery", "delivery-001");
|
||||
|
||||
// Act
|
||||
await _validator.UnregisterResourceAsync("delivery", "delivery-001");
|
||||
var resources = await _validator.GetTenantResourcesAsync("tenant1");
|
||||
|
||||
// Assert
|
||||
Assert.Empty(resources);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTenantResourcesAsync_FiltersByResourceType()
|
||||
{
|
||||
// Arrange
|
||||
await _validator.RegisterResourceAsync("tenant1", "delivery", "delivery-001");
|
||||
await _validator.RegisterResourceAsync("tenant1", "channel", "channel-001");
|
||||
|
||||
// Act
|
||||
var deliveries = await _validator.GetTenantResourcesAsync("tenant1", "delivery");
|
||||
|
||||
// Assert
|
||||
Assert.Single(deliveries);
|
||||
Assert.Equal("delivery", deliveries[0].ResourceType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunFuzzTestAsync_AllTestsPass()
|
||||
{
|
||||
// Arrange
|
||||
var config = new TenantFuzzTestConfig
|
||||
{
|
||||
Iterations = 20,
|
||||
TenantIds = ["tenant-a", "tenant-b"],
|
||||
ResourceTypes = ["delivery", "channel"],
|
||||
TestCrossTenantGrants = true,
|
||||
TestEdgeCases = true,
|
||||
Seed = 42 // For reproducibility
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _validator.RunFuzzTestAsync(config);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.AllPassed);
|
||||
Assert.True(result.TotalTests > 0);
|
||||
Assert.Equal(result.TotalTests, result.PassedTests);
|
||||
Assert.Empty(result.Failures);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateCrossTenantAccessAsync_SameTenant_Allowed()
|
||||
{
|
||||
// Act
|
||||
var result = await _validator.ValidateCrossTenantAccessAsync(
|
||||
"tenant1", "tenant1", "delivery", "delivery-001");
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsAllowed);
|
||||
Assert.Equal(TenantValidationType.SameTenant, result.ValidationType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ViolationSeverity_ReflectsOperation()
|
||||
{
|
||||
// Arrange
|
||||
await _validator.RegisterResourceAsync("tenant1", "delivery", "delivery-001");
|
||||
|
||||
// Trigger different violations
|
||||
await _validator.ValidateResourceAccessAsync(
|
||||
"tenant2", "delivery", "delivery-001", TenantAccessOperation.Read);
|
||||
|
||||
await _validator.RegisterResourceAsync("tenant1", "delivery", "delivery-002");
|
||||
await _validator.ValidateResourceAccessAsync(
|
||||
"tenant2", "delivery", "delivery-002", TenantAccessOperation.Delete);
|
||||
|
||||
// Act
|
||||
var violations = await _validator.GetViolationsAsync("tenant2");
|
||||
|
||||
// Assert
|
||||
var readViolation = violations.FirstOrDefault(v => v.ResourceId == "delivery-001");
|
||||
var deleteViolation = violations.FirstOrDefault(v => v.ResourceId == "delivery-002");
|
||||
|
||||
Assert.NotNull(readViolation);
|
||||
Assert.NotNull(deleteViolation);
|
||||
Assert.Equal(ViolationSeverity.Low, readViolation.Severity);
|
||||
Assert.Equal(ViolationSeverity.Critical, deleteViolation.Severity);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Notifier.Worker.Security;
|
||||
|
||||
namespace StellaOps.Notifier.Tests.Security;
|
||||
|
||||
public class WebhookSecurityServiceTests
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly WebhookSecurityOptions _options;
|
||||
private readonly InMemoryWebhookSecurityService _webhookService;
|
||||
|
||||
public WebhookSecurityServiceTests()
|
||||
{
|
||||
_timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow);
|
||||
_options = new WebhookSecurityOptions
|
||||
{
|
||||
DefaultAlgorithm = "SHA256",
|
||||
EnableReplayProtection = true,
|
||||
NonceCacheExpiry = TimeSpan.FromMinutes(10)
|
||||
};
|
||||
_webhookService = new InMemoryWebhookSecurityService(
|
||||
Options.Create(_options),
|
||||
_timeProvider,
|
||||
NullLogger<InMemoryWebhookSecurityService>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_NoConfig_ReturnsValidWithWarning()
|
||||
{
|
||||
// Arrange
|
||||
var request = new WebhookValidationRequest
|
||||
{
|
||||
TenantId = "tenant1",
|
||||
ChannelId = "channel1",
|
||||
Body = "{\"test\": \"data\"}"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _webhookService.ValidateAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Contains(result.Warnings, w => w.Contains("No webhook security configuration"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_ValidSignature_ReturnsValid()
|
||||
{
|
||||
// Arrange
|
||||
var config = new WebhookSecurityConfig
|
||||
{
|
||||
ConfigId = "config-001",
|
||||
TenantId = "tenant1",
|
||||
ChannelId = "channel1",
|
||||
SecretKey = "test-secret-key",
|
||||
Algorithm = "SHA256",
|
||||
RequireSignature = true
|
||||
};
|
||||
await _webhookService.RegisterWebhookAsync(config);
|
||||
|
||||
var body = "{\"test\": \"data\"}";
|
||||
var signature = _webhookService.GenerateSignature(body, config.SecretKey);
|
||||
|
||||
var request = new WebhookValidationRequest
|
||||
{
|
||||
TenantId = "tenant1",
|
||||
ChannelId = "channel1",
|
||||
Body = body,
|
||||
Signature = signature
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _webhookService.ValidateAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsValid);
|
||||
Assert.True(result.PassedChecks.HasFlag(WebhookValidationChecks.SignatureValid));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_InvalidSignature_ReturnsDenied()
|
||||
{
|
||||
// Arrange
|
||||
var config = new WebhookSecurityConfig
|
||||
{
|
||||
ConfigId = "config-001",
|
||||
TenantId = "tenant1",
|
||||
ChannelId = "channel1",
|
||||
SecretKey = "test-secret-key",
|
||||
RequireSignature = true
|
||||
};
|
||||
await _webhookService.RegisterWebhookAsync(config);
|
||||
|
||||
var request = new WebhookValidationRequest
|
||||
{
|
||||
TenantId = "tenant1",
|
||||
ChannelId = "channel1",
|
||||
Body = "{\"test\": \"data\"}",
|
||||
Signature = "invalid-signature"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _webhookService.ValidateAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsValid);
|
||||
Assert.True(result.FailedChecks.HasFlag(WebhookValidationChecks.SignatureValid));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_MissingSignature_ReturnsDenied()
|
||||
{
|
||||
// Arrange
|
||||
var config = new WebhookSecurityConfig
|
||||
{
|
||||
ConfigId = "config-001",
|
||||
TenantId = "tenant1",
|
||||
ChannelId = "channel1",
|
||||
SecretKey = "test-secret-key",
|
||||
RequireSignature = true
|
||||
};
|
||||
await _webhookService.RegisterWebhookAsync(config);
|
||||
|
||||
var request = new WebhookValidationRequest
|
||||
{
|
||||
TenantId = "tenant1",
|
||||
ChannelId = "channel1",
|
||||
Body = "{\"test\": \"data\"}"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _webhookService.ValidateAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.Errors, e => e.Contains("Missing signature"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_IpNotInAllowlist_ReturnsDenied()
|
||||
{
|
||||
// Arrange
|
||||
var config = new WebhookSecurityConfig
|
||||
{
|
||||
ConfigId = "config-001",
|
||||
TenantId = "tenant1",
|
||||
ChannelId = "channel1",
|
||||
SecretKey = "test-secret-key",
|
||||
RequireSignature = false,
|
||||
EnforceIpAllowlist = true,
|
||||
AllowedIps = ["192.168.1.0/24"]
|
||||
};
|
||||
await _webhookService.RegisterWebhookAsync(config);
|
||||
|
||||
var request = new WebhookValidationRequest
|
||||
{
|
||||
TenantId = "tenant1",
|
||||
ChannelId = "channel1",
|
||||
Body = "{\"test\": \"data\"}",
|
||||
SourceIp = "10.0.0.1"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _webhookService.ValidateAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsValid);
|
||||
Assert.True(result.FailedChecks.HasFlag(WebhookValidationChecks.IpAllowed));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_IpInAllowlist_ReturnsValid()
|
||||
{
|
||||
// Arrange
|
||||
var config = new WebhookSecurityConfig
|
||||
{
|
||||
ConfigId = "config-001",
|
||||
TenantId = "tenant1",
|
||||
ChannelId = "channel1",
|
||||
SecretKey = "test-secret-key",
|
||||
RequireSignature = false,
|
||||
EnforceIpAllowlist = true,
|
||||
AllowedIps = ["192.168.1.0/24"]
|
||||
};
|
||||
await _webhookService.RegisterWebhookAsync(config);
|
||||
|
||||
var request = new WebhookValidationRequest
|
||||
{
|
||||
TenantId = "tenant1",
|
||||
ChannelId = "channel1",
|
||||
Body = "{\"test\": \"data\"}",
|
||||
SourceIp = "192.168.1.100"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _webhookService.ValidateAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsValid);
|
||||
Assert.True(result.PassedChecks.HasFlag(WebhookValidationChecks.IpAllowed));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_ExpiredTimestamp_ReturnsDenied()
|
||||
{
|
||||
// Arrange
|
||||
var config = new WebhookSecurityConfig
|
||||
{
|
||||
ConfigId = "config-001",
|
||||
TenantId = "tenant1",
|
||||
ChannelId = "channel1",
|
||||
SecretKey = "test-secret-key",
|
||||
RequireSignature = false,
|
||||
MaxRequestAge = TimeSpan.FromMinutes(5)
|
||||
};
|
||||
await _webhookService.RegisterWebhookAsync(config);
|
||||
|
||||
var request = new WebhookValidationRequest
|
||||
{
|
||||
TenantId = "tenant1",
|
||||
ChannelId = "channel1",
|
||||
Body = "{\"test\": \"data\"}",
|
||||
Timestamp = _timeProvider.GetUtcNow().AddMinutes(-10)
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _webhookService.ValidateAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsValid);
|
||||
Assert.True(result.FailedChecks.HasFlag(WebhookValidationChecks.NotExpired));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_ReplayAttack_ReturnsDenied()
|
||||
{
|
||||
// Arrange
|
||||
var config = new WebhookSecurityConfig
|
||||
{
|
||||
ConfigId = "config-001",
|
||||
TenantId = "tenant1",
|
||||
ChannelId = "channel1",
|
||||
SecretKey = "test-secret-key",
|
||||
RequireSignature = true
|
||||
};
|
||||
await _webhookService.RegisterWebhookAsync(config);
|
||||
|
||||
var body = "{\"test\": \"data\"}";
|
||||
var signature = _webhookService.GenerateSignature(body, config.SecretKey);
|
||||
|
||||
var request = new WebhookValidationRequest
|
||||
{
|
||||
TenantId = "tenant1",
|
||||
ChannelId = "channel1",
|
||||
Body = body,
|
||||
Signature = signature,
|
||||
Timestamp = _timeProvider.GetUtcNow()
|
||||
};
|
||||
|
||||
// First request should succeed
|
||||
var result1 = await _webhookService.ValidateAsync(request);
|
||||
Assert.True(result1.IsValid);
|
||||
|
||||
// Act - second request with same signature should fail
|
||||
var result2 = await _webhookService.ValidateAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.False(result2.IsValid);
|
||||
Assert.True(result2.FailedChecks.HasFlag(WebhookValidationChecks.NotReplay));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateSignature_ProducesConsistentOutput()
|
||||
{
|
||||
// Arrange
|
||||
var payload = "{\"test\": \"data\"}";
|
||||
var secretKey = "test-secret";
|
||||
|
||||
// Act
|
||||
var sig1 = _webhookService.GenerateSignature(payload, secretKey);
|
||||
var sig2 = _webhookService.GenerateSignature(payload, secretKey);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(sig1, sig2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAllowlistAsync_UpdatesConfig()
|
||||
{
|
||||
// Arrange
|
||||
var config = new WebhookSecurityConfig
|
||||
{
|
||||
ConfigId = "config-001",
|
||||
TenantId = "tenant1",
|
||||
ChannelId = "channel1",
|
||||
SecretKey = "test-secret-key",
|
||||
EnforceIpAllowlist = true,
|
||||
AllowedIps = ["192.168.1.0/24"]
|
||||
};
|
||||
await _webhookService.RegisterWebhookAsync(config);
|
||||
|
||||
// Act
|
||||
await _webhookService.UpdateAllowlistAsync(
|
||||
"tenant1", "channel1", ["10.0.0.0/8"], "admin");
|
||||
|
||||
// Assert
|
||||
var updatedConfig = await _webhookService.GetConfigAsync("tenant1", "channel1");
|
||||
Assert.NotNull(updatedConfig);
|
||||
Assert.Single(updatedConfig.AllowedIps);
|
||||
Assert.Equal("10.0.0.0/8", updatedConfig.AllowedIps[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IsIpAllowedAsync_NoConfig_ReturnsTrue()
|
||||
{
|
||||
// Act
|
||||
var allowed = await _webhookService.IsIpAllowedAsync("tenant1", "channel1", "192.168.1.1");
|
||||
|
||||
// Assert
|
||||
Assert.True(allowed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IsIpAllowedAsync_CidrMatch_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var config = new WebhookSecurityConfig
|
||||
{
|
||||
ConfigId = "config-001",
|
||||
TenantId = "tenant1",
|
||||
ChannelId = "channel1",
|
||||
SecretKey = "test-secret-key",
|
||||
EnforceIpAllowlist = true,
|
||||
AllowedIps = ["192.168.1.0/24"]
|
||||
};
|
||||
await _webhookService.RegisterWebhookAsync(config);
|
||||
|
||||
// Act
|
||||
var allowed = await _webhookService.IsIpAllowedAsync("tenant1", "channel1", "192.168.1.50");
|
||||
|
||||
// Assert
|
||||
Assert.True(allowed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IsIpAllowedAsync_ExactMatch_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var config = new WebhookSecurityConfig
|
||||
{
|
||||
ConfigId = "config-001",
|
||||
TenantId = "tenant1",
|
||||
ChannelId = "channel1",
|
||||
SecretKey = "test-secret-key",
|
||||
EnforceIpAllowlist = true,
|
||||
AllowedIps = ["192.168.1.100"]
|
||||
};
|
||||
await _webhookService.RegisterWebhookAsync(config);
|
||||
|
||||
// Act
|
||||
var allowed = await _webhookService.IsIpAllowedAsync("tenant1", "channel1", "192.168.1.100");
|
||||
|
||||
// Assert
|
||||
Assert.True(allowed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,439 @@
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using Moq;
|
||||
using StellaOps.Notify.Engine;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
using StellaOps.Notifier.Worker.Simulation;
|
||||
|
||||
namespace StellaOps.Notifier.Tests.Simulation;
|
||||
|
||||
public class SimulationEngineTests
|
||||
{
|
||||
private readonly Mock<INotifyRuleRepository> _ruleRepository;
|
||||
private readonly Mock<INotifyRuleEvaluator> _ruleEvaluator;
|
||||
private readonly Mock<INotifyChannelRepository> _channelRepository;
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly SimulationOptions _options;
|
||||
private readonly SimulationEngine _engine;
|
||||
|
||||
public SimulationEngineTests()
|
||||
{
|
||||
_ruleRepository = new Mock<INotifyRuleRepository>();
|
||||
_ruleEvaluator = new Mock<INotifyRuleEvaluator>();
|
||||
_channelRepository = new Mock<INotifyChannelRepository>();
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2024, 1, 15, 10, 0, 0, TimeSpan.Zero));
|
||||
_options = new SimulationOptions();
|
||||
|
||||
_engine = new SimulationEngine(
|
||||
_ruleRepository.Object,
|
||||
_ruleEvaluator.Object,
|
||||
_channelRepository.Object,
|
||||
Options.Create(_options),
|
||||
_timeProvider,
|
||||
NullLogger<SimulationEngine>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SimulateAsync_WithMatchingRules_ReturnsMatchedResults()
|
||||
{
|
||||
// Arrange
|
||||
var rules = new List<NotifyRule> { CreateTestRule("rule-1") };
|
||||
var events = new List<NotifyEvent> { CreateTestEvent("event.test") };
|
||||
var channel = CreateTestChannel("channel-1");
|
||||
|
||||
_ruleRepository
|
||||
.Setup(r => r.ListAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(rules);
|
||||
|
||||
_channelRepository
|
||||
.Setup(c => c.GetAsync(It.IsAny<string>(), "channel-1", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(channel);
|
||||
|
||||
_ruleEvaluator
|
||||
.Setup(e => e.Evaluate(It.IsAny<NotifyRule>(), It.IsAny<NotifyEvent>(), It.IsAny<DateTimeOffset?>()))
|
||||
.Returns((NotifyRule r, NotifyEvent _, DateTimeOffset? ts) =>
|
||||
NotifyRuleEvaluationOutcome.Matched(r, r.Actions, ts ?? _timeProvider.GetUtcNow()));
|
||||
|
||||
var request = new SimulationRequest
|
||||
{
|
||||
TenantId = "tenant1",
|
||||
Events = events
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _engine.SimulateAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.StartsWith("sim-", result.SimulationId);
|
||||
Assert.Equal(1, result.TotalEvents);
|
||||
Assert.Equal(1, result.TotalRules);
|
||||
Assert.Equal(1, result.MatchedEvents);
|
||||
Assert.True(result.TotalActionsTriggered > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SimulateAsync_WithNoMatchingRules_ReturnsNoMatches()
|
||||
{
|
||||
// Arrange
|
||||
var rules = new List<NotifyRule> { CreateTestRule("rule-1") };
|
||||
var events = new List<NotifyEvent> { CreateTestEvent("event.test") };
|
||||
|
||||
_ruleRepository
|
||||
.Setup(r => r.ListAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(rules);
|
||||
|
||||
_ruleEvaluator
|
||||
.Setup(e => e.Evaluate(It.IsAny<NotifyRule>(), It.IsAny<NotifyEvent>(), It.IsAny<DateTimeOffset?>()))
|
||||
.Returns((NotifyRule r, NotifyEvent _, DateTimeOffset? _) =>
|
||||
NotifyRuleEvaluationOutcome.NotMatched(r, "event_kind_mismatch"));
|
||||
|
||||
var request = new SimulationRequest
|
||||
{
|
||||
TenantId = "tenant1",
|
||||
Events = events
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _engine.SimulateAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(0, result.MatchedEvents);
|
||||
Assert.Equal(0, result.TotalActionsTriggered);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SimulateAsync_WithIncludeNonMatches_ReturnsNonMatchReasons()
|
||||
{
|
||||
// Arrange
|
||||
var rules = new List<NotifyRule> { CreateTestRule("rule-1") };
|
||||
var events = new List<NotifyEvent> { CreateTestEvent("event.test") };
|
||||
|
||||
_ruleRepository
|
||||
.Setup(r => r.ListAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(rules);
|
||||
|
||||
_ruleEvaluator
|
||||
.Setup(e => e.Evaluate(It.IsAny<NotifyRule>(), It.IsAny<NotifyEvent>(), It.IsAny<DateTimeOffset?>()))
|
||||
.Returns((NotifyRule r, NotifyEvent _, DateTimeOffset? _) =>
|
||||
NotifyRuleEvaluationOutcome.NotMatched(r, "severity_below_threshold"));
|
||||
|
||||
var request = new SimulationRequest
|
||||
{
|
||||
TenantId = "tenant1",
|
||||
Events = events,
|
||||
IncludeNonMatches = true
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _engine.SimulateAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result.EventResults);
|
||||
Assert.NotNull(result.EventResults[0].NonMatchedRules);
|
||||
Assert.Single(result.EventResults[0].NonMatchedRules);
|
||||
Assert.Equal("severity_below_threshold", result.EventResults[0].NonMatchedRules[0].Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SimulateAsync_WithProvidedRules_UsesProvidedRules()
|
||||
{
|
||||
// Arrange
|
||||
var providedRules = new List<NotifyRule>
|
||||
{
|
||||
CreateTestRule("custom-rule-1"),
|
||||
CreateTestRule("custom-rule-2")
|
||||
};
|
||||
var events = new List<NotifyEvent> { CreateTestEvent("event.test") };
|
||||
|
||||
_ruleEvaluator
|
||||
.Setup(e => e.Evaluate(It.IsAny<NotifyRule>(), It.IsAny<NotifyEvent>(), It.IsAny<DateTimeOffset?>()))
|
||||
.Returns((NotifyRule r, NotifyEvent _, DateTimeOffset? _) =>
|
||||
NotifyRuleEvaluationOutcome.NotMatched(r, "event_kind_mismatch"));
|
||||
|
||||
var request = new SimulationRequest
|
||||
{
|
||||
TenantId = "tenant1",
|
||||
Events = events,
|
||||
Rules = providedRules
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _engine.SimulateAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, result.TotalRules);
|
||||
_ruleRepository.Verify(r => r.ListAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SimulateAsync_WithNoEvents_ReturnsEmptyResult()
|
||||
{
|
||||
// Arrange
|
||||
var rules = new List<NotifyRule> { CreateTestRule("rule-1") };
|
||||
|
||||
_ruleRepository
|
||||
.Setup(r => r.ListAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(rules);
|
||||
|
||||
var request = new SimulationRequest
|
||||
{
|
||||
TenantId = "tenant1",
|
||||
Events = new List<NotifyEvent>()
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _engine.SimulateAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(0, result.TotalEvents);
|
||||
Assert.Empty(result.EventResults);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SimulateAsync_WithNoRules_ReturnsEmptyResult()
|
||||
{
|
||||
// Arrange
|
||||
var events = new List<NotifyEvent> { CreateTestEvent("event.test") };
|
||||
|
||||
_ruleRepository
|
||||
.Setup(r => r.ListAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<NotifyRule>());
|
||||
|
||||
var request = new SimulationRequest
|
||||
{
|
||||
TenantId = "tenant1",
|
||||
Events = events
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _engine.SimulateAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(0, result.TotalRules);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SimulateAsync_BuildsRuleSummaries()
|
||||
{
|
||||
// Arrange
|
||||
var rules = new List<NotifyRule>
|
||||
{
|
||||
CreateTestRule("rule-1"),
|
||||
CreateTestRule("rule-2")
|
||||
};
|
||||
var events = new List<NotifyEvent>
|
||||
{
|
||||
CreateTestEvent("event.test"),
|
||||
CreateTestEvent("event.test"),
|
||||
CreateTestEvent("event.test")
|
||||
};
|
||||
var channel = CreateTestChannel("channel-1");
|
||||
|
||||
_ruleRepository
|
||||
.Setup(r => r.ListAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(rules);
|
||||
|
||||
_channelRepository
|
||||
.Setup(c => c.GetAsync(It.IsAny<string>(), "channel-1", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(channel);
|
||||
|
||||
var callCount = 0;
|
||||
_ruleEvaluator
|
||||
.Setup(e => e.Evaluate(It.IsAny<NotifyRule>(), It.IsAny<NotifyEvent>(), It.IsAny<DateTimeOffset?>()))
|
||||
.Returns((NotifyRule r, NotifyEvent _, DateTimeOffset? ts) =>
|
||||
{
|
||||
callCount++;
|
||||
// First rule matches all, second rule matches none
|
||||
if (r.RuleId == "rule-1")
|
||||
return NotifyRuleEvaluationOutcome.Matched(r, r.Actions, ts ?? _timeProvider.GetUtcNow());
|
||||
return NotifyRuleEvaluationOutcome.NotMatched(r, "event_kind_mismatch");
|
||||
});
|
||||
|
||||
var request = new SimulationRequest
|
||||
{
|
||||
TenantId = "tenant1",
|
||||
Events = events
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _engine.SimulateAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, result.RuleSummaries.Count);
|
||||
|
||||
var rule1Summary = result.RuleSummaries.First(s => s.RuleId == "rule-1");
|
||||
Assert.Equal(3, rule1Summary.MatchCount);
|
||||
Assert.Equal(100.0, rule1Summary.MatchPercentage);
|
||||
|
||||
var rule2Summary = result.RuleSummaries.First(s => s.RuleId == "rule-2");
|
||||
Assert.Equal(0, rule2Summary.MatchCount);
|
||||
Assert.Equal(0.0, rule2Summary.MatchPercentage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SimulateAsync_FiltersDisabledRulesWhenEnabledRulesOnly()
|
||||
{
|
||||
// Arrange
|
||||
var rules = new List<NotifyRule>
|
||||
{
|
||||
CreateTestRule("rule-1", enabled: true),
|
||||
CreateTestRule("rule-2", enabled: false)
|
||||
};
|
||||
|
||||
_ruleRepository
|
||||
.Setup(r => r.ListAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(rules);
|
||||
|
||||
_ruleEvaluator
|
||||
.Setup(e => e.Evaluate(It.IsAny<NotifyRule>(), It.IsAny<NotifyEvent>(), It.IsAny<DateTimeOffset?>()))
|
||||
.Returns((NotifyRule r, NotifyEvent _, DateTimeOffset? _) =>
|
||||
NotifyRuleEvaluationOutcome.NotMatched(r, "test"));
|
||||
|
||||
var request = new SimulationRequest
|
||||
{
|
||||
TenantId = "tenant1",
|
||||
Events = new List<NotifyEvent> { CreateTestEvent("event.test") },
|
||||
EnabledRulesOnly = true
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _engine.SimulateAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(1, result.TotalRules);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateRuleAsync_ValidRule_ReturnsValid()
|
||||
{
|
||||
// Arrange
|
||||
var rule = CreateTestRule("valid-rule");
|
||||
|
||||
// Act
|
||||
var result = await _engine.ValidateRuleAsync(rule);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Empty(result.Errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateRuleAsync_BroadMatchRule_ReturnsWarning()
|
||||
{
|
||||
// Arrange
|
||||
var rule = NotifyRule.Create(
|
||||
ruleId: "broad-rule",
|
||||
tenantId: "tenant1",
|
||||
name: "Broad Rule",
|
||||
match: NotifyRuleMatch.Create(),
|
||||
actions: new[] { NotifyRuleAction.Create("action-1", "channel-1") },
|
||||
enabled: true);
|
||||
|
||||
// Act
|
||||
var result = await _engine.ValidateRuleAsync(rule);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Contains(result.Warnings, w => w.Code == "broad_match");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateRuleAsync_DisabledRule_ReturnsWarning()
|
||||
{
|
||||
// Arrange
|
||||
var rule = CreateTestRule("disabled-rule", enabled: false);
|
||||
|
||||
// Act
|
||||
var result = await _engine.ValidateRuleAsync(rule);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Contains(result.Warnings, w => w.Code == "rule_disabled");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateRuleAsync_UnknownSeverity_ReturnsWarning()
|
||||
{
|
||||
// Arrange
|
||||
var rule = NotifyRule.Create(
|
||||
ruleId: "bad-severity-rule",
|
||||
tenantId: "tenant1",
|
||||
name: "Bad Severity Rule",
|
||||
match: NotifyRuleMatch.Create(minSeverity: "mega-critical"),
|
||||
actions: new[] { NotifyRuleAction.Create("action-1", "channel-1") },
|
||||
enabled: true);
|
||||
|
||||
// Act
|
||||
var result = await _engine.ValidateRuleAsync(rule);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Contains(result.Warnings, w => w.Code == "unknown_severity");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateRuleAsync_NoEnabledActions_ReturnsWarning()
|
||||
{
|
||||
// Arrange
|
||||
var rule = NotifyRule.Create(
|
||||
ruleId: "no-actions-rule",
|
||||
tenantId: "tenant1",
|
||||
name: "No Actions Rule",
|
||||
match: NotifyRuleMatch.Create(eventKinds: new[] { "test" }),
|
||||
actions: new[] { NotifyRuleAction.Create("action-1", "channel-1", enabled: false) },
|
||||
enabled: true);
|
||||
|
||||
// Act
|
||||
var result = await _engine.ValidateRuleAsync(rule);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Contains(result.Warnings, w => w.Code == "no_enabled_actions");
|
||||
}
|
||||
|
||||
private NotifyRule CreateTestRule(string ruleId, bool enabled = true)
|
||||
{
|
||||
return NotifyRule.Create(
|
||||
ruleId: ruleId,
|
||||
tenantId: "tenant1",
|
||||
name: $"Test Rule {ruleId}",
|
||||
match: NotifyRuleMatch.Create(eventKinds: new[] { "event.test" }),
|
||||
actions: new[]
|
||||
{
|
||||
NotifyRuleAction.Create(
|
||||
actionId: "action-1",
|
||||
channel: "channel-1",
|
||||
template: "default",
|
||||
enabled: true)
|
||||
},
|
||||
enabled: enabled);
|
||||
}
|
||||
|
||||
private NotifyEvent CreateTestEvent(string kind)
|
||||
{
|
||||
return NotifyEvent.Create(
|
||||
eventId: Guid.NewGuid(),
|
||||
kind: kind,
|
||||
tenant: "tenant1",
|
||||
ts: _timeProvider.GetUtcNow(),
|
||||
payload: null);
|
||||
}
|
||||
|
||||
private NotifyChannel CreateTestChannel(string channelId)
|
||||
{
|
||||
return NotifyChannel.Create(
|
||||
channelId: channelId,
|
||||
tenantId: "tenant1",
|
||||
name: $"Test Channel {channelId}",
|
||||
type: NotifyChannelType.Custom,
|
||||
enabled: true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Notifier.Worker.StormBreaker;
|
||||
|
||||
namespace StellaOps.Notifier.Tests.StormBreaker;
|
||||
|
||||
public class InMemoryStormBreakerTests
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly StormBreakerOptions _options;
|
||||
private readonly InMemoryStormBreaker _stormBreaker;
|
||||
|
||||
public InMemoryStormBreakerTests()
|
||||
{
|
||||
_timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow);
|
||||
_options = new StormBreakerOptions
|
||||
{
|
||||
Enabled = true,
|
||||
DefaultThreshold = 10,
|
||||
DefaultWindow = TimeSpan.FromMinutes(5),
|
||||
SummaryInterval = TimeSpan.FromMinutes(15),
|
||||
StormCooldown = TimeSpan.FromMinutes(10),
|
||||
MaxEventsTracked = 100,
|
||||
MaxSampleEvents = 5
|
||||
};
|
||||
_stormBreaker = new InMemoryStormBreaker(
|
||||
Options.Create(_options),
|
||||
_timeProvider,
|
||||
NullLogger<InMemoryStormBreaker>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_BelowThreshold_ReturnsNoStorm()
|
||||
{
|
||||
// Act
|
||||
var result = await _stormBreaker.EvaluateAsync("tenant1", "key1", "event1");
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsStorm);
|
||||
Assert.Equal(StormAction.SendNormally, result.Action);
|
||||
Assert.Equal(1, result.EventCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_AtThreshold_DetectsStorm()
|
||||
{
|
||||
// Arrange - add events up to threshold
|
||||
for (int i = 0; i < 9; i++)
|
||||
{
|
||||
await _stormBreaker.EvaluateAsync("tenant1", "key1", $"event{i}");
|
||||
}
|
||||
|
||||
// Act - 10th event triggers storm
|
||||
var result = await _stormBreaker.EvaluateAsync("tenant1", "key1", "event9");
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsStorm);
|
||||
Assert.Equal(StormAction.SendStormAlert, result.Action);
|
||||
Assert.Equal(10, result.EventCount);
|
||||
Assert.NotNull(result.StormStartedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_AfterStormDetected_SuppressesEvents()
|
||||
{
|
||||
// Arrange - trigger storm
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
await _stormBreaker.EvaluateAsync("tenant1", "key1", $"event{i}");
|
||||
}
|
||||
|
||||
// Act - next event after storm detected
|
||||
var result = await _stormBreaker.EvaluateAsync("tenant1", "key1", "event10");
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsStorm);
|
||||
Assert.Equal(StormAction.Suppress, result.Action);
|
||||
Assert.Equal(1, result.SuppressedCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_AtSummaryInterval_TriggersSummary()
|
||||
{
|
||||
// Arrange - trigger storm
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
await _stormBreaker.EvaluateAsync("tenant1", "key1", $"event{i}");
|
||||
}
|
||||
|
||||
// Advance time past summary interval
|
||||
_timeProvider.Advance(TimeSpan.FromMinutes(16));
|
||||
|
||||
// Act
|
||||
var result = await _stormBreaker.EvaluateAsync("tenant1", "key1", "event_after_interval");
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsStorm);
|
||||
Assert.Equal(StormAction.SendSummary, result.Action);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_DisabledStormBreaker_ReturnsNoStorm()
|
||||
{
|
||||
// Arrange
|
||||
var disabledOptions = new StormBreakerOptions { Enabled = false };
|
||||
var disabledBreaker = new InMemoryStormBreaker(
|
||||
Options.Create(disabledOptions),
|
||||
_timeProvider,
|
||||
NullLogger<InMemoryStormBreaker>.Instance);
|
||||
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
var result = await disabledBreaker.EvaluateAsync("tenant1", "key1", $"event{i}");
|
||||
|
||||
// All events should return no storm when disabled
|
||||
Assert.False(result.IsStorm);
|
||||
Assert.Equal(StormAction.SendNormally, result.Action);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_DifferentKeys_TrackedSeparately()
|
||||
{
|
||||
// Arrange - trigger storm for key1
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
await _stormBreaker.EvaluateAsync("tenant1", "key1", $"event{i}");
|
||||
}
|
||||
|
||||
// Act
|
||||
var result1 = await _stormBreaker.EvaluateAsync("tenant1", "key1", "eventA");
|
||||
var result2 = await _stormBreaker.EvaluateAsync("tenant1", "key2", "eventB");
|
||||
|
||||
// Assert
|
||||
Assert.True(result1.IsStorm);
|
||||
Assert.False(result2.IsStorm);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_DifferentTenants_TrackedSeparately()
|
||||
{
|
||||
// Arrange - trigger storm for tenant1
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
await _stormBreaker.EvaluateAsync("tenant1", "key1", $"event{i}");
|
||||
}
|
||||
|
||||
// Act
|
||||
var result1 = await _stormBreaker.EvaluateAsync("tenant1", "key1", "eventA");
|
||||
var result2 = await _stormBreaker.EvaluateAsync("tenant2", "key1", "eventB");
|
||||
|
||||
// Assert
|
||||
Assert.True(result1.IsStorm);
|
||||
Assert.False(result2.IsStorm);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStateAsync_ExistingStorm_ReturnsState()
|
||||
{
|
||||
// Arrange
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
await _stormBreaker.EvaluateAsync("tenant1", "key1", $"event{i}");
|
||||
}
|
||||
|
||||
// Act
|
||||
var state = await _stormBreaker.GetStateAsync("tenant1", "key1");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(state);
|
||||
Assert.Equal("tenant1", state.TenantId);
|
||||
Assert.Equal("key1", state.StormKey);
|
||||
Assert.True(state.IsActive);
|
||||
Assert.Equal(10, state.EventIds.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStateAsync_NoStorm_ReturnsNull()
|
||||
{
|
||||
// Act
|
||||
var state = await _stormBreaker.GetStateAsync("tenant1", "nonexistent");
|
||||
|
||||
// Assert
|
||||
Assert.Null(state);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetActiveStormsAsync_ReturnsActiveStormsOnly()
|
||||
{
|
||||
// Arrange - create two storms
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
await _stormBreaker.EvaluateAsync("tenant1", "key1", $"event1_{i}");
|
||||
await _stormBreaker.EvaluateAsync("tenant1", "key2", $"event2_{i}");
|
||||
}
|
||||
|
||||
// Create a non-storm (below threshold)
|
||||
await _stormBreaker.EvaluateAsync("tenant1", "key3", "event3_0");
|
||||
|
||||
// Act
|
||||
var activeStorms = await _stormBreaker.GetActiveStormsAsync("tenant1");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, activeStorms.Count);
|
||||
Assert.Contains(activeStorms, s => s.StormKey == "key1");
|
||||
Assert.Contains(activeStorms, s => s.StormKey == "key2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ClearAsync_RemovesStormState()
|
||||
{
|
||||
// Arrange
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
await _stormBreaker.EvaluateAsync("tenant1", "key1", $"event{i}");
|
||||
}
|
||||
|
||||
var beforeClear = await _stormBreaker.GetStateAsync("tenant1", "key1");
|
||||
Assert.NotNull(beforeClear);
|
||||
|
||||
// Act
|
||||
await _stormBreaker.ClearAsync("tenant1", "key1");
|
||||
|
||||
// Assert
|
||||
var afterClear = await _stormBreaker.GetStateAsync("tenant1", "key1");
|
||||
Assert.Null(afterClear);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateSummaryAsync_ActiveStorm_ReturnsSummary()
|
||||
{
|
||||
// Arrange
|
||||
for (int i = 0; i < 15; i++)
|
||||
{
|
||||
await _stormBreaker.EvaluateAsync("tenant1", "key1", $"event{i}");
|
||||
}
|
||||
|
||||
// Advance some time
|
||||
_timeProvider.Advance(TimeSpan.FromMinutes(5));
|
||||
|
||||
// Act
|
||||
var summary = await _stormBreaker.GenerateSummaryAsync("tenant1", "key1");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(summary);
|
||||
Assert.Equal("tenant1", summary.TenantId);
|
||||
Assert.Equal("key1", summary.StormKey);
|
||||
Assert.Equal(15, summary.TotalEvents);
|
||||
Assert.True(summary.IsOngoing);
|
||||
Assert.NotNull(summary.SummaryText);
|
||||
Assert.NotNull(summary.SummaryTitle);
|
||||
Assert.True(summary.SampleEventIds.Count <= _options.MaxSampleEvents);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateSummaryAsync_NoStorm_ReturnsNull()
|
||||
{
|
||||
// Act
|
||||
var summary = await _stormBreaker.GenerateSummaryAsync("tenant1", "nonexistent");
|
||||
|
||||
// Assert
|
||||
Assert.Null(summary);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_EventsOutsideWindow_AreRemoved()
|
||||
{
|
||||
// Arrange - add events
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
await _stormBreaker.EvaluateAsync("tenant1", "key1", $"event{i}");
|
||||
}
|
||||
|
||||
// Move time past the window
|
||||
_timeProvider.Advance(TimeSpan.FromMinutes(6));
|
||||
|
||||
// Act
|
||||
var result = await _stormBreaker.EvaluateAsync("tenant1", "key1", "new_event");
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsStorm);
|
||||
Assert.Equal(1, result.EventCount); // Only the new event counts
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_ThresholdOverrides_AppliesCorrectThreshold()
|
||||
{
|
||||
// Arrange
|
||||
var optionsWithOverride = new StormBreakerOptions
|
||||
{
|
||||
Enabled = true,
|
||||
DefaultThreshold = 100,
|
||||
DefaultWindow = TimeSpan.FromMinutes(5),
|
||||
ThresholdOverrides = new Dictionary<string, int>
|
||||
{
|
||||
["critical.*"] = 5
|
||||
}
|
||||
};
|
||||
|
||||
var breaker = new InMemoryStormBreaker(
|
||||
Options.Create(optionsWithOverride),
|
||||
_timeProvider,
|
||||
NullLogger<InMemoryStormBreaker>.Instance);
|
||||
|
||||
// Act - 5 events should trigger storm for critical.* keys
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
await breaker.EvaluateAsync("tenant1", "critical.alert", $"event{i}");
|
||||
}
|
||||
|
||||
var criticalResult = await breaker.EvaluateAsync("tenant1", "critical.alert", "event5");
|
||||
|
||||
// Non-critical key should not be in storm yet
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
await breaker.EvaluateAsync("tenant1", "info.log", $"event{i}");
|
||||
}
|
||||
|
||||
var infoResult = await breaker.EvaluateAsync("tenant1", "info.log", "event5");
|
||||
|
||||
// Assert
|
||||
Assert.True(criticalResult.IsStorm);
|
||||
Assert.False(infoResult.IsStorm);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notifier.Worker.Templates;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Notifier.Tests.Templates;
|
||||
|
||||
public sealed class EnhancedTemplateRendererTests
|
||||
{
|
||||
private readonly MockTemplateService _templateService;
|
||||
private readonly EnhancedTemplateRenderer _renderer;
|
||||
|
||||
public EnhancedTemplateRendererTests()
|
||||
{
|
||||
_templateService = new MockTemplateService();
|
||||
var options = Options.Create(new TemplateRendererOptions
|
||||
{
|
||||
ProvenanceBaseUrl = "https://stellaops.local/notify"
|
||||
});
|
||||
_renderer = new EnhancedTemplateRenderer(
|
||||
_templateService,
|
||||
options,
|
||||
NullLogger<EnhancedTemplateRenderer>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RenderAsync_SimpleVariables_SubstitutesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var template = CreateTemplate("Hello {{actor}}, event {{kind}} occurred.");
|
||||
var notifyEvent = CreateEvent("pack.approval", "test-user");
|
||||
|
||||
// Act
|
||||
var result = await _renderer.RenderAsync(template, notifyEvent);
|
||||
|
||||
// Assert
|
||||
Assert.Contains("Hello test-user", result.Body);
|
||||
Assert.Contains("pack.approval", result.Body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RenderAsync_NestedPayloadVariables_SubstitutesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var template = CreateTemplate("Pack {{pack.id}} version {{pack.version}}");
|
||||
var payload = new JsonObject
|
||||
{
|
||||
["pack"] = new JsonObject
|
||||
{
|
||||
["id"] = "pkg-001",
|
||||
["version"] = "1.2.3"
|
||||
}
|
||||
};
|
||||
var notifyEvent = CreateEvent("pack.approval", "user", payload);
|
||||
|
||||
// Act
|
||||
var result = await _renderer.RenderAsync(template, notifyEvent);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Pack pkg-001 version 1.2.3", result.Body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RenderAsync_EachBlock_IteratesArray()
|
||||
{
|
||||
// Arrange
|
||||
var template = CreateTemplate("Items: {{#each items}}[{{this}}]{{/each}}");
|
||||
var payload = new JsonObject
|
||||
{
|
||||
["items"] = new JsonArray { "a", "b", "c" }
|
||||
};
|
||||
var notifyEvent = CreateEvent("test.event", "user", payload);
|
||||
|
||||
// Act
|
||||
var result = await _renderer.RenderAsync(template, notifyEvent);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Items: [a][b][c]", result.Body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RenderAsync_EachBlockWithProperties_AccessesItemProperties()
|
||||
{
|
||||
// Arrange
|
||||
var template = CreateTemplate("{{#each vulnerabilities}}{{@id}}: {{@severity}} {{/each}}");
|
||||
var payload = new JsonObject
|
||||
{
|
||||
["vulnerabilities"] = new JsonArray
|
||||
{
|
||||
new JsonObject { ["id"] = "CVE-001", ["severity"] = "high" },
|
||||
new JsonObject { ["id"] = "CVE-002", ["severity"] = "low" }
|
||||
}
|
||||
};
|
||||
var notifyEvent = CreateEvent("scan.complete", "scanner", payload);
|
||||
|
||||
// Act
|
||||
var result = await _renderer.RenderAsync(template, notifyEvent);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("CVE-001: high CVE-002: low ", result.Body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RenderAsync_RedactsSensitiveFields()
|
||||
{
|
||||
// Arrange
|
||||
_templateService.RedactionConfig = new TemplateRedactionConfig
|
||||
{
|
||||
AllowedFields = [],
|
||||
DeniedFields = ["secret", "token"],
|
||||
Mode = "safe"
|
||||
};
|
||||
|
||||
var template = CreateTemplate("Secret: {{secretKey}}, Token: {{authToken}}, Name: {{name}}");
|
||||
var payload = new JsonObject
|
||||
{
|
||||
["secretKey"] = "super-secret-123",
|
||||
["authToken"] = "tok-456",
|
||||
["name"] = "public-name"
|
||||
};
|
||||
var notifyEvent = CreateEvent("test.event", "user", payload);
|
||||
|
||||
// Act
|
||||
var result = await _renderer.RenderAsync(template, notifyEvent);
|
||||
|
||||
// Assert
|
||||
Assert.Contains("[REDACTED]", result.Body);
|
||||
Assert.Contains("public-name", result.Body);
|
||||
Assert.DoesNotContain("super-secret-123", result.Body);
|
||||
Assert.DoesNotContain("tok-456", result.Body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RenderAsync_ParanoidMode_OnlyAllowsExplicitFields()
|
||||
{
|
||||
// Arrange
|
||||
_templateService.RedactionConfig = new TemplateRedactionConfig
|
||||
{
|
||||
AllowedFields = ["name"],
|
||||
DeniedFields = [],
|
||||
Mode = "paranoid"
|
||||
};
|
||||
|
||||
var template = CreateTemplate("Name: {{name}}, Email: {{email}}");
|
||||
var payload = new JsonObject
|
||||
{
|
||||
["name"] = "John",
|
||||
["email"] = "john@example.com"
|
||||
};
|
||||
var notifyEvent = CreateEvent("test.event", "user", payload);
|
||||
|
||||
// Act
|
||||
var result = await _renderer.RenderAsync(template, notifyEvent);
|
||||
|
||||
// Assert
|
||||
Assert.Contains("Name: John", result.Body);
|
||||
Assert.Contains("Email: [REDACTED]", result.Body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RenderAsync_AddsProvenanceLinks()
|
||||
{
|
||||
// Arrange
|
||||
var template = CreateTemplate("Event link: {{provenance.eventUrl}}");
|
||||
var notifyEvent = CreateEvent("test.event", "user");
|
||||
|
||||
// Act
|
||||
var result = await _renderer.RenderAsync(template, notifyEvent);
|
||||
|
||||
// Assert
|
||||
Assert.Contains("https://stellaops.local/notify/events/", result.Body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RenderAsync_FormatSpecifiers_Work()
|
||||
{
|
||||
// Arrange
|
||||
var template = CreateTemplate("Upper: {{name|upper}}, Lower: {{name|lower}}");
|
||||
var payload = new JsonObject { ["name"] = "Test" };
|
||||
var notifyEvent = CreateEvent("test.event", "user", payload);
|
||||
|
||||
// Act
|
||||
var result = await _renderer.RenderAsync(template, notifyEvent);
|
||||
|
||||
// Assert
|
||||
Assert.Contains("Upper: TEST", result.Body);
|
||||
Assert.Contains("Lower: test", result.Body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RenderAsync_HtmlFormat_EncodesSpecialChars()
|
||||
{
|
||||
// Arrange
|
||||
var template = CreateTemplate("Script: {{code|html}}", NotifyDeliveryFormat.Html);
|
||||
var payload = new JsonObject { ["code"] = "<script>alert('xss')</script>" };
|
||||
var notifyEvent = CreateEvent("test.event", "user", payload);
|
||||
|
||||
// Act
|
||||
var result = await _renderer.RenderAsync(template, notifyEvent);
|
||||
|
||||
// Assert
|
||||
Assert.DoesNotContain("<script>", result.Body);
|
||||
Assert.Contains("<script>", result.Body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RenderAsync_RendersSubject()
|
||||
{
|
||||
// Arrange
|
||||
var metadata = new Dictionary<string, string>
|
||||
{
|
||||
["subject"] = "[Alert] {{kind}} from {{actor}}"
|
||||
};
|
||||
var template = CreateTemplate("Body content", NotifyDeliveryFormat.PlainText, metadata);
|
||||
var notifyEvent = CreateEvent("security.alert", "scanner");
|
||||
|
||||
// Act
|
||||
var result = await _renderer.RenderAsync(template, notifyEvent);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result.Subject);
|
||||
Assert.Contains("security.alert", result.Subject);
|
||||
Assert.Contains("scanner", result.Subject);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RenderAsync_ComputesBodyHash()
|
||||
{
|
||||
// Arrange
|
||||
var template = CreateTemplate("Static content");
|
||||
var notifyEvent = CreateEvent("test.event", "user");
|
||||
|
||||
// Act
|
||||
var result1 = await _renderer.RenderAsync(template, notifyEvent);
|
||||
var result2 = await _renderer.RenderAsync(template, notifyEvent);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result1.BodyHash);
|
||||
Assert.Equal(64, result1.BodyHash.Length); // SHA-256 hex
|
||||
Assert.Equal(result1.BodyHash, result2.BodyHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RenderAsync_MarkdownToHtml_Converts()
|
||||
{
|
||||
// Arrange
|
||||
var template = NotifyTemplate.Create(
|
||||
templateId: "tmpl-md",
|
||||
tenantId: "test-tenant",
|
||||
channelType: NotifyChannelType.Email,
|
||||
key: "test.key",
|
||||
locale: "en-us",
|
||||
body: "# Header\n**Bold** text",
|
||||
renderMode: NotifyTemplateRenderMode.Markdown,
|
||||
format: NotifyDeliveryFormat.Html);
|
||||
|
||||
var notifyEvent = CreateEvent("test.event", "user");
|
||||
|
||||
// Act
|
||||
var result = await _renderer.RenderAsync(template, notifyEvent);
|
||||
|
||||
// Assert
|
||||
Assert.Contains("<h1>", result.Body);
|
||||
Assert.Contains("<strong>", result.Body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RenderAsync_IfBlock_RendersConditionally()
|
||||
{
|
||||
// Arrange
|
||||
var template = CreateTemplate("{{#if critical}}CRITICAL: {{/if}}Message");
|
||||
var payloadTrue = new JsonObject { ["critical"] = "true" };
|
||||
var payloadFalse = new JsonObject { ["critical"] = "" };
|
||||
|
||||
var eventTrue = CreateEvent("test", "user", payloadTrue);
|
||||
var eventFalse = CreateEvent("test", "user", payloadFalse);
|
||||
|
||||
// Act
|
||||
var resultTrue = await _renderer.RenderAsync(template, eventTrue);
|
||||
var resultFalse = await _renderer.RenderAsync(template, eventFalse);
|
||||
|
||||
// Assert
|
||||
Assert.Contains("CRITICAL:", resultTrue.Body);
|
||||
Assert.DoesNotContain("CRITICAL:", resultFalse.Body);
|
||||
}
|
||||
|
||||
private static NotifyTemplate CreateTemplate(
|
||||
string body,
|
||||
NotifyDeliveryFormat format = NotifyDeliveryFormat.PlainText,
|
||||
Dictionary<string, string>? metadata = null)
|
||||
{
|
||||
return NotifyTemplate.Create(
|
||||
templateId: "test-template",
|
||||
tenantId: "test-tenant",
|
||||
channelType: NotifyChannelType.Webhook,
|
||||
key: "test.key",
|
||||
locale: "en-us",
|
||||
body: body,
|
||||
format: format,
|
||||
metadata: metadata);
|
||||
}
|
||||
|
||||
private static NotifyEvent CreateEvent(
|
||||
string kind,
|
||||
string actor,
|
||||
JsonObject? payload = null)
|
||||
{
|
||||
return NotifyEvent.Create(
|
||||
eventId: Guid.NewGuid().ToString(),
|
||||
tenant: "test-tenant",
|
||||
kind: kind,
|
||||
actor: actor,
|
||||
timestamp: DateTimeOffset.UtcNow,
|
||||
payload: payload ?? new JsonObject());
|
||||
}
|
||||
|
||||
private sealed class MockTemplateService : INotifyTemplateService
|
||||
{
|
||||
public TemplateRedactionConfig RedactionConfig { get; set; } = TemplateRedactionConfig.Default;
|
||||
|
||||
public Task<NotifyTemplate?> ResolveAsync(string tenantId, string key, NotifyChannelType channelType, string locale, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<NotifyTemplate?>(null);
|
||||
|
||||
public Task<NotifyTemplate?> GetByIdAsync(string tenantId, string templateId, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<NotifyTemplate?>(null);
|
||||
|
||||
public Task<TemplateUpsertResult> UpsertAsync(NotifyTemplate template, string actor, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(TemplateUpsertResult.Created(template.TemplateId));
|
||||
|
||||
public Task<bool> DeleteAsync(string tenantId, string templateId, string actor, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(true);
|
||||
|
||||
public Task<IReadOnlyList<NotifyTemplate>> ListAsync(string tenantId, TemplateListOptions? options = null, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<IReadOnlyList<NotifyTemplate>>([]);
|
||||
|
||||
public TemplateValidationResult Validate(string templateBody)
|
||||
=> TemplateValidationResult.Valid();
|
||||
|
||||
public TemplateRedactionConfig GetRedactionConfig(NotifyTemplate template)
|
||||
=> RedactionConfig;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notifier.Worker.Templates;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Notifier.Tests.Templates;
|
||||
|
||||
public sealed class NotifyTemplateServiceTests
|
||||
{
|
||||
private readonly InMemoryTemplateRepository _templateRepository;
|
||||
private readonly InMemoryAuditRepository _auditRepository;
|
||||
private readonly NotifyTemplateService _service;
|
||||
|
||||
public NotifyTemplateServiceTests()
|
||||
{
|
||||
_templateRepository = new InMemoryTemplateRepository();
|
||||
_auditRepository = new InMemoryAuditRepository();
|
||||
_service = new NotifyTemplateService(
|
||||
_templateRepository,
|
||||
_auditRepository,
|
||||
NullLogger<NotifyTemplateService>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_ExactLocaleMatch_ReturnsTemplate()
|
||||
{
|
||||
// Arrange
|
||||
var template = CreateTemplate("tmpl-001", "pack.approval", "en-us");
|
||||
await _templateRepository.UpsertAsync(template);
|
||||
|
||||
// Act
|
||||
var result = await _service.ResolveAsync(
|
||||
"test-tenant", "pack.approval", NotifyChannelType.Webhook, "en-US");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("tmpl-001", result.TemplateId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_FallbackToLanguageOnly_ReturnsTemplate()
|
||||
{
|
||||
// Arrange
|
||||
var template = CreateTemplate("tmpl-en", "pack.approval", "en");
|
||||
await _templateRepository.UpsertAsync(template);
|
||||
|
||||
// Act - request en-GB but only en exists
|
||||
var result = await _service.ResolveAsync(
|
||||
"test-tenant", "pack.approval", NotifyChannelType.Webhook, "en-GB");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("tmpl-en", result.TemplateId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_FallbackToDefault_ReturnsTemplate()
|
||||
{
|
||||
// Arrange
|
||||
var template = CreateTemplate("tmpl-default", "pack.approval", "en-us");
|
||||
await _templateRepository.UpsertAsync(template);
|
||||
|
||||
// Act - request de-DE but only en-us exists (default)
|
||||
var result = await _service.ResolveAsync(
|
||||
"test-tenant", "pack.approval", NotifyChannelType.Webhook, "de-DE");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("tmpl-default", result.TemplateId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_NoMatch_ReturnsNull()
|
||||
{
|
||||
// Act
|
||||
var result = await _service.ResolveAsync(
|
||||
"test-tenant", "nonexistent.key", NotifyChannelType.Webhook, "en-US");
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertAsync_NewTemplate_CreatesAndAudits()
|
||||
{
|
||||
// Arrange
|
||||
var template = CreateTemplate("tmpl-new", "pack.approval", "en-us");
|
||||
|
||||
// Act
|
||||
var result = await _service.UpsertAsync(template, "test-actor");
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Success);
|
||||
Assert.True(result.IsNew);
|
||||
Assert.Equal("tmpl-new", result.TemplateId);
|
||||
|
||||
var audit = _auditRepository.Entries.Single();
|
||||
Assert.Equal("template.created", audit.EventType);
|
||||
Assert.Equal("test-actor", audit.Actor);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertAsync_ExistingTemplate_UpdatesAndAudits()
|
||||
{
|
||||
// Arrange
|
||||
var original = CreateTemplate("tmpl-existing", "pack.approval", "en-us", "Original body");
|
||||
await _templateRepository.UpsertAsync(original);
|
||||
_auditRepository.Entries.Clear();
|
||||
|
||||
var updated = CreateTemplate("tmpl-existing", "pack.approval", "en-us", "Updated body");
|
||||
|
||||
// Act
|
||||
var result = await _service.UpsertAsync(updated, "another-actor");
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Success);
|
||||
Assert.False(result.IsNew);
|
||||
|
||||
var audit = _auditRepository.Entries.Single();
|
||||
Assert.Equal("template.updated", audit.EventType);
|
||||
Assert.Equal("another-actor", audit.Actor);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertAsync_InvalidTemplate_ReturnsError()
|
||||
{
|
||||
// Arrange - template with mismatched braces
|
||||
var template = NotifyTemplate.Create(
|
||||
templateId: "tmpl-invalid",
|
||||
tenantId: "test-tenant",
|
||||
channelType: NotifyChannelType.Webhook,
|
||||
key: "test.key",
|
||||
locale: "en-us",
|
||||
body: "Hello {{name} - missing closing brace");
|
||||
|
||||
// Act
|
||||
var result = await _service.UpsertAsync(template, "test-actor");
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Success);
|
||||
Assert.Contains("braces", result.Error!, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteAsync_ExistingTemplate_DeletesAndAudits()
|
||||
{
|
||||
// Arrange
|
||||
var template = CreateTemplate("tmpl-delete", "pack.approval", "en-us");
|
||||
await _templateRepository.UpsertAsync(template);
|
||||
|
||||
// Act
|
||||
var deleted = await _service.DeleteAsync("test-tenant", "tmpl-delete", "delete-actor");
|
||||
|
||||
// Assert
|
||||
Assert.True(deleted);
|
||||
Assert.Null(await _templateRepository.GetAsync("test-tenant", "tmpl-delete"));
|
||||
|
||||
var audit = _auditRepository.Entries.Last();
|
||||
Assert.Equal("template.deleted", audit.EventType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteAsync_NonexistentTemplate_ReturnsFalse()
|
||||
{
|
||||
// Act
|
||||
var deleted = await _service.DeleteAsync("test-tenant", "nonexistent", "actor");
|
||||
|
||||
// Assert
|
||||
Assert.False(deleted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListAsync_WithFilters_ReturnsFilteredResults()
|
||||
{
|
||||
// Arrange
|
||||
await _templateRepository.UpsertAsync(CreateTemplate("tmpl-1", "pack.approval", "en-us"));
|
||||
await _templateRepository.UpsertAsync(CreateTemplate("tmpl-2", "pack.approval", "de-de"));
|
||||
await _templateRepository.UpsertAsync(CreateTemplate("tmpl-3", "risk.alert", "en-us"));
|
||||
|
||||
// Act
|
||||
var results = await _service.ListAsync("test-tenant", new TemplateListOptions
|
||||
{
|
||||
KeyPrefix = "pack.",
|
||||
Locale = "en-us"
|
||||
});
|
||||
|
||||
// Assert
|
||||
Assert.Single(results);
|
||||
Assert.Equal("tmpl-1", results[0].TemplateId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ValidTemplate_ReturnsValid()
|
||||
{
|
||||
// Act
|
||||
var result = _service.Validate("Hello {{name}}, your order {{orderId}} is ready.");
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Empty(result.Errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_MismatchedBraces_ReturnsInvalid()
|
||||
{
|
||||
// Act
|
||||
var result = _service.Validate("Hello {{name}, missing close");
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.Errors, e => e.Contains("braces"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_UnclosedEachBlock_ReturnsInvalid()
|
||||
{
|
||||
// Act
|
||||
var result = _service.Validate("{{#each items}}{{this}}");
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.Errors, e => e.Contains("#each"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_SensitiveVariable_ReturnsWarning()
|
||||
{
|
||||
// Act
|
||||
var result = _service.Validate("Your API key is: {{apiKey}}");
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Contains(result.Warnings, w => w.Contains("sensitive"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetRedactionConfig_DefaultMode_ReturnsSafeDefaults()
|
||||
{
|
||||
// Arrange
|
||||
var template = CreateTemplate("tmpl-001", "test.key", "en-us");
|
||||
|
||||
// Act
|
||||
var config = _service.GetRedactionConfig(template);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("safe", config.Mode);
|
||||
Assert.Contains("secret", config.DeniedFields);
|
||||
Assert.Contains("password", config.DeniedFields);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetRedactionConfig_ParanoidMode_RequiresAllowlist()
|
||||
{
|
||||
// Arrange
|
||||
var template = NotifyTemplate.Create(
|
||||
templateId: "tmpl-paranoid",
|
||||
tenantId: "test-tenant",
|
||||
channelType: NotifyChannelType.Webhook,
|
||||
key: "test.key",
|
||||
locale: "en-us",
|
||||
body: "Test body",
|
||||
metadata: new Dictionary<string, string>
|
||||
{
|
||||
["redaction"] = "paranoid",
|
||||
["redaction.allow"] = "name,email"
|
||||
});
|
||||
|
||||
// Act
|
||||
var config = _service.GetRedactionConfig(template);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("paranoid", config.Mode);
|
||||
Assert.Contains("name", config.AllowedFields);
|
||||
Assert.Contains("email", config.AllowedFields);
|
||||
}
|
||||
|
||||
private static NotifyTemplate CreateTemplate(
|
||||
string templateId,
|
||||
string key,
|
||||
string locale,
|
||||
string body = "Test body {{variable}}")
|
||||
{
|
||||
return NotifyTemplate.Create(
|
||||
templateId: templateId,
|
||||
tenantId: "test-tenant",
|
||||
channelType: NotifyChannelType.Webhook,
|
||||
key: key,
|
||||
locale: locale,
|
||||
body: body);
|
||||
}
|
||||
|
||||
private sealed class InMemoryTemplateRepository : StellaOps.Notify.Storage.Mongo.Repositories.INotifyTemplateRepository
|
||||
{
|
||||
private readonly Dictionary<string, NotifyTemplate> _templates = new();
|
||||
|
||||
public Task UpsertAsync(NotifyTemplate template, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var key = $"{template.TenantId}:{template.TemplateId}";
|
||||
_templates[key] = template;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<NotifyTemplate?> GetAsync(string tenantId, string templateId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var key = $"{tenantId}:{templateId}";
|
||||
return Task.FromResult(_templates.GetValueOrDefault(key));
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<NotifyTemplate>> ListAsync(string tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var result = _templates.Values
|
||||
.Where(t => t.TenantId == tenantId)
|
||||
.ToList();
|
||||
return Task.FromResult<IReadOnlyList<NotifyTemplate>>(result);
|
||||
}
|
||||
|
||||
public Task DeleteAsync(string tenantId, string templateId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var key = $"{tenantId}:{templateId}";
|
||||
_templates.Remove(key);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class InMemoryAuditRepository : StellaOps.Notify.Storage.Mongo.Repositories.INotifyAuditRepository
|
||||
{
|
||||
public List<(string TenantId, string EventType, string Actor, IReadOnlyDictionary<string, string> Metadata)> Entries { get; } = [];
|
||||
|
||||
public Task AppendAsync(
|
||||
string tenantId,
|
||||
string eventType,
|
||||
string actor,
|
||||
IReadOnlyDictionary<string, string> metadata,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Entries.Add((tenantId, eventType, actor, metadata));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Notifier.Worker.Tenancy;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Notifier.Tests.Tenancy;
|
||||
|
||||
public sealed class TenantChannelResolverTests
|
||||
{
|
||||
private static DefaultTenantChannelResolver CreateResolver(
|
||||
ITenantContextAccessor? accessor = null,
|
||||
TenantChannelResolverOptions? options = null)
|
||||
{
|
||||
accessor ??= new TenantContextAccessor();
|
||||
options ??= new TenantChannelResolverOptions();
|
||||
|
||||
return new DefaultTenantChannelResolver(
|
||||
accessor,
|
||||
Options.Create(options),
|
||||
NullLogger<DefaultTenantChannelResolver>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_SimpleReference_UsesCurrentTenant()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
accessor.Context = TenantContext.FromHeaders("tenant-a", "user", null);
|
||||
var resolver = CreateResolver(accessor);
|
||||
|
||||
// Act
|
||||
var result = resolver.Resolve("slack-alerts");
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
result.TenantId.Should().Be("tenant-a");
|
||||
result.ChannelId.Should().Be("slack-alerts");
|
||||
result.ScopedId.Should().Be("tenant-a:slack-alerts");
|
||||
result.IsCrossTenant.Should().BeFalse();
|
||||
result.IsGlobalChannel.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_QualifiedReference_UsesSpecifiedTenant()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
accessor.Context = TenantContext.FromHeaders("tenant-a", "user", null);
|
||||
var options = new TenantChannelResolverOptions { AllowCrossTenant = true };
|
||||
var resolver = CreateResolver(accessor, options);
|
||||
|
||||
// Act
|
||||
var result = resolver.Resolve("tenant-b:email-channel");
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
result.TenantId.Should().Be("tenant-b");
|
||||
result.ChannelId.Should().Be("email-channel");
|
||||
result.ScopedId.Should().Be("tenant-b:email-channel");
|
||||
result.IsCrossTenant.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_CrossTenantReference_DeniedByDefault()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
accessor.Context = TenantContext.FromHeaders("tenant-a", "user", null);
|
||||
var options = new TenantChannelResolverOptions { AllowCrossTenant = false };
|
||||
var resolver = CreateResolver(accessor, options);
|
||||
|
||||
// Act
|
||||
var result = resolver.Resolve("tenant-b:email-channel");
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeFalse();
|
||||
result.Error.Should().Contain("Cross-tenant");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_SameTenantQualified_NotCrossTenant()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
accessor.Context = TenantContext.FromHeaders("tenant-a", "user", null);
|
||||
var resolver = CreateResolver(accessor);
|
||||
|
||||
// Act
|
||||
var result = resolver.Resolve("tenant-a:slack-channel");
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
result.IsCrossTenant.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_GlobalPrefix_ResolvesToGlobalTenant()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
accessor.Context = TenantContext.FromHeaders("tenant-a", "user", null);
|
||||
var options = new TenantChannelResolverOptions
|
||||
{
|
||||
GlobalPrefix = "@global",
|
||||
GlobalTenant = "system",
|
||||
AllowGlobalChannels = true
|
||||
};
|
||||
var resolver = CreateResolver(accessor, options);
|
||||
|
||||
// Act
|
||||
var result = resolver.Resolve("@global:broadcast");
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
result.TenantId.Should().Be("system");
|
||||
result.ChannelId.Should().Be("broadcast");
|
||||
result.IsGlobalChannel.Should().BeTrue();
|
||||
result.IsCrossTenant.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_GlobalChannels_DeniedWhenDisabled()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
accessor.Context = TenantContext.FromHeaders("tenant-a", "user", null);
|
||||
var options = new TenantChannelResolverOptions { AllowGlobalChannels = false };
|
||||
var resolver = CreateResolver(accessor, options);
|
||||
|
||||
// Act
|
||||
var result = resolver.Resolve("@global:broadcast");
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeFalse();
|
||||
result.Error.Should().Contain("Global channels are not allowed");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_GlobalChannelPattern_MatchesPatterns()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
accessor.Context = TenantContext.FromHeaders("tenant-a", "user", null);
|
||||
var options = new TenantChannelResolverOptions
|
||||
{
|
||||
GlobalChannelPatterns = ["system-*", "shared-*"],
|
||||
GlobalTenant = "system"
|
||||
};
|
||||
var resolver = CreateResolver(accessor, options);
|
||||
|
||||
// Act
|
||||
var result = resolver.Resolve("system-alerts");
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
result.TenantId.Should().Be("system");
|
||||
result.IsGlobalChannel.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_NoTenantContext_Fails()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
var resolver = CreateResolver(accessor);
|
||||
|
||||
// Act
|
||||
var result = resolver.Resolve("any-channel");
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeFalse();
|
||||
result.Error.Should().Contain("No tenant context");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_WithExplicitTenantId_DoesNotRequireContext()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
var resolver = CreateResolver(accessor);
|
||||
|
||||
// Act
|
||||
var result = resolver.Resolve("slack-alerts", "explicit-tenant");
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
result.TenantId.Should().Be("explicit-tenant");
|
||||
result.ChannelId.Should().Be("slack-alerts");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_EmptyReference_Fails()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
accessor.Context = TenantContext.FromHeaders("tenant-a", "user", null);
|
||||
var resolver = CreateResolver(accessor);
|
||||
|
||||
// Act
|
||||
var result = resolver.Resolve("");
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeFalse();
|
||||
result.Error.Should().Contain("empty");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateQualifiedReference_CreatesCorrectFormat()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
var resolver = CreateResolver(accessor);
|
||||
|
||||
// Act
|
||||
var reference = resolver.CreateQualifiedReference("tenant-xyz", "channel-abc");
|
||||
|
||||
// Assert
|
||||
reference.Should().Be("tenant-xyz:channel-abc");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_SimpleReference_ParsesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
var resolver = CreateResolver(accessor);
|
||||
|
||||
// Act
|
||||
var components = resolver.Parse("slack-alerts");
|
||||
|
||||
// Assert
|
||||
components.HasTenantPrefix.Should().BeFalse();
|
||||
components.TenantId.Should().BeNull();
|
||||
components.ChannelId.Should().Be("slack-alerts");
|
||||
components.IsGlobal.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_QualifiedReference_ParsesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
var resolver = CreateResolver(accessor);
|
||||
|
||||
// Act
|
||||
var components = resolver.Parse("tenant-a:email-channel");
|
||||
|
||||
// Assert
|
||||
components.HasTenantPrefix.Should().BeTrue();
|
||||
components.TenantId.Should().Be("tenant-a");
|
||||
components.ChannelId.Should().Be("email-channel");
|
||||
components.IsGlobal.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_GlobalReference_ParsesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
var options = new TenantChannelResolverOptions { GlobalPrefix = "@global" };
|
||||
var resolver = CreateResolver(accessor, options);
|
||||
|
||||
// Act
|
||||
var components = resolver.Parse("@global:broadcast");
|
||||
|
||||
// Assert
|
||||
components.IsGlobal.Should().BeTrue();
|
||||
components.ChannelId.Should().Be("broadcast");
|
||||
components.HasTenantPrefix.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsValidReference_ValidSimpleReference()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
var resolver = CreateResolver(accessor);
|
||||
|
||||
// Act
|
||||
var isValid = resolver.IsValidReference("slack-alerts");
|
||||
|
||||
// Assert
|
||||
isValid.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsValidReference_ValidQualifiedReference()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
var resolver = CreateResolver(accessor);
|
||||
|
||||
// Act
|
||||
var isValid = resolver.IsValidReference("tenant-a:channel-1");
|
||||
|
||||
// Assert
|
||||
isValid.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsValidReference_InvalidCharacters()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
var resolver = CreateResolver(accessor);
|
||||
|
||||
// Act
|
||||
var isValid = resolver.IsValidReference("channel@invalid");
|
||||
|
||||
// Assert
|
||||
isValid.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsValidReference_EmptyReference()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
var resolver = CreateResolver(accessor);
|
||||
|
||||
// Act
|
||||
var isValid = resolver.IsValidReference("");
|
||||
|
||||
// Assert
|
||||
isValid.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFallbackReferences_IncludesGlobalFallback()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
var options = new TenantChannelResolverOptions
|
||||
{
|
||||
FallbackToGlobal = true,
|
||||
GlobalPrefix = "@global"
|
||||
};
|
||||
var resolver = CreateResolver(accessor, options);
|
||||
|
||||
// Act
|
||||
var fallbacks = resolver.GetFallbackReferences("slack-alerts");
|
||||
|
||||
// Assert
|
||||
fallbacks.Should().HaveCount(2);
|
||||
fallbacks[0].Should().Be("slack-alerts");
|
||||
fallbacks[1].Should().Contain("@global");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFallbackReferences_NoGlobalFallbackWhenDisabled()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
var options = new TenantChannelResolverOptions { FallbackToGlobal = false };
|
||||
var resolver = CreateResolver(accessor, options);
|
||||
|
||||
// Act
|
||||
var fallbacks = resolver.GetFallbackReferences("slack-alerts");
|
||||
|
||||
// Assert
|
||||
fallbacks.Should().HaveCount(1);
|
||||
fallbacks[0].Should().Be("slack-alerts");
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class TenantChannelResolutionTests
|
||||
{
|
||||
[Fact]
|
||||
public void Successful_CreatesSuccessResult()
|
||||
{
|
||||
// Arrange & Act
|
||||
var result = TenantChannelResolution.Successful(
|
||||
"tenant-a", "channel-1", "channel-1");
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
result.TenantId.Should().Be("tenant-a");
|
||||
result.ChannelId.Should().Be("channel-1");
|
||||
result.ScopedId.Should().Be("tenant-a:channel-1");
|
||||
result.Error.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Failed_CreatesFailedResult()
|
||||
{
|
||||
// Arrange & Act
|
||||
var result = TenantChannelResolution.Failed("bad-ref", "Invalid channel reference");
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeFalse();
|
||||
result.TenantId.Should().BeEmpty();
|
||||
result.ChannelId.Should().BeEmpty();
|
||||
result.Error.Should().Be("Invalid channel reference");
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class TenantChannelResolverExtensionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void ResolveRequired_ThrowsOnFailure()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
var resolver = new DefaultTenantChannelResolver(
|
||||
accessor,
|
||||
Options.Create(new TenantChannelResolverOptions()),
|
||||
NullLogger<DefaultTenantChannelResolver>.Instance);
|
||||
|
||||
// Act
|
||||
var act = () => resolver.ResolveRequired("any-channel");
|
||||
|
||||
// Assert
|
||||
act.Should().Throw<InvalidOperationException>()
|
||||
.WithMessage("*Failed to resolve*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveRequired_ReturnsResultOnSuccess()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
accessor.Context = TenantContext.FromHeaders("tenant-a", "user", null);
|
||||
var resolver = new DefaultTenantChannelResolver(
|
||||
accessor,
|
||||
Options.Create(new TenantChannelResolverOptions()),
|
||||
NullLogger<DefaultTenantChannelResolver>.Instance);
|
||||
|
||||
// Act
|
||||
var result = resolver.ResolveRequired("slack-alerts");
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
result.ChannelId.Should().Be("slack-alerts");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.Notifier.Worker.Tenancy;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Notifier.Tests.Tenancy;
|
||||
|
||||
public sealed class TenantContextTests
|
||||
{
|
||||
[Fact]
|
||||
public void FromHeaders_CreatesValidContext()
|
||||
{
|
||||
// Arrange & Act
|
||||
var context = TenantContext.FromHeaders(
|
||||
tenantId: "tenant-123",
|
||||
actor: "user@test.com",
|
||||
correlationId: "corr-456");
|
||||
|
||||
// Assert
|
||||
context.TenantId.Should().Be("tenant-123");
|
||||
context.Actor.Should().Be("user@test.com");
|
||||
context.CorrelationId.Should().Be("corr-456");
|
||||
context.Source.Should().Be(TenantContextSource.HttpHeader);
|
||||
context.IsSystemContext.Should().BeFalse();
|
||||
context.Claims.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromHeaders_UsesDefaultActorWhenEmpty()
|
||||
{
|
||||
// Arrange & Act
|
||||
var context = TenantContext.FromHeaders(
|
||||
tenantId: "tenant-123",
|
||||
actor: null,
|
||||
correlationId: null);
|
||||
|
||||
// Assert
|
||||
context.Actor.Should().Be("api");
|
||||
context.CorrelationId.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromEvent_CreatesContextFromEventSource()
|
||||
{
|
||||
// Arrange & Act
|
||||
var context = TenantContext.FromEvent(
|
||||
tenantId: "tenant-event",
|
||||
actor: "scheduler",
|
||||
correlationId: "event-corr");
|
||||
|
||||
// Assert
|
||||
context.TenantId.Should().Be("tenant-event");
|
||||
context.Source.Should().Be(TenantContextSource.EventContext);
|
||||
context.IsSystemContext.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void System_CreatesSystemContext()
|
||||
{
|
||||
// Arrange & Act
|
||||
var context = TenantContext.System("system-tenant");
|
||||
|
||||
// Assert
|
||||
context.TenantId.Should().Be("system-tenant");
|
||||
context.Actor.Should().Be("system");
|
||||
context.IsSystemContext.Should().BeTrue();
|
||||
context.Source.Should().Be(TenantContextSource.System);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithClaim_AddsClaim()
|
||||
{
|
||||
// Arrange
|
||||
var context = TenantContext.FromHeaders("tenant-1", "user", null);
|
||||
|
||||
// Act
|
||||
var result = context.WithClaim("role", "admin");
|
||||
|
||||
// Assert
|
||||
result.Claims.Should().ContainKey("role");
|
||||
result.Claims["role"].Should().Be("admin");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithClaims_AddsMultipleClaims()
|
||||
{
|
||||
// Arrange
|
||||
var context = TenantContext.FromHeaders("tenant-1", "user", null);
|
||||
var claims = new Dictionary<string, string>
|
||||
{
|
||||
["role"] = "admin",
|
||||
["department"] = "engineering"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = context.WithClaims(claims);
|
||||
|
||||
// Assert
|
||||
result.Claims.Should().HaveCount(2);
|
||||
result.Claims["role"].Should().Be("admin");
|
||||
result.Claims["department"].Should().Be("engineering");
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class TenantContextAccessorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Context_ReturnsNullWhenNotSet()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
|
||||
// Act & Assert
|
||||
accessor.Context.Should().BeNull();
|
||||
accessor.TenantId.Should().BeNull();
|
||||
accessor.HasContext.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Context_CanBeSetAndRetrieved()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
var context = TenantContext.FromHeaders("tenant-abc", "user", "corr");
|
||||
|
||||
// Act
|
||||
accessor.Context = context;
|
||||
|
||||
// Assert
|
||||
accessor.Context.Should().Be(context);
|
||||
accessor.TenantId.Should().Be("tenant-abc");
|
||||
accessor.HasContext.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RequiredTenantId_ThrowsWhenNoContext()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
|
||||
// Act
|
||||
var act = () => accessor.RequiredTenantId;
|
||||
|
||||
// Assert
|
||||
act.Should().Throw<InvalidOperationException>()
|
||||
.WithMessage("*tenant context*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RequiredTenantId_ReturnsTenantIdWhenSet()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
accessor.Context = TenantContext.FromHeaders("tenant-xyz", "user", null);
|
||||
|
||||
// Act
|
||||
var tenantId = accessor.RequiredTenantId;
|
||||
|
||||
// Assert
|
||||
tenantId.Should().Be("tenant-xyz");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Context_CanBeCleared()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
accessor.Context = TenantContext.FromHeaders("tenant-abc", "user", null);
|
||||
|
||||
// Act
|
||||
accessor.Context = null;
|
||||
|
||||
// Assert
|
||||
accessor.HasContext.Should().BeFalse();
|
||||
accessor.TenantId.Should().BeNull();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class TenantContextScopeTests
|
||||
{
|
||||
[Fact]
|
||||
public void Scope_SetsContextForDuration()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
var originalContext = TenantContext.FromHeaders("original-tenant", "user", null);
|
||||
var scopedContext = TenantContext.FromHeaders("scoped-tenant", "scoped-user", null);
|
||||
accessor.Context = originalContext;
|
||||
|
||||
// Act & Assert
|
||||
using (var scope = new TenantContextScope(accessor, scopedContext))
|
||||
{
|
||||
accessor.TenantId.Should().Be("scoped-tenant");
|
||||
}
|
||||
|
||||
// After scope, original context restored
|
||||
accessor.TenantId.Should().Be("original-tenant");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Scope_RestoresNullContext()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
var scopedContext = TenantContext.FromHeaders("scoped-tenant", "user", null);
|
||||
|
||||
// Act & Assert
|
||||
using (var scope = new TenantContextScope(accessor, scopedContext))
|
||||
{
|
||||
accessor.TenantId.Should().Be("scoped-tenant");
|
||||
}
|
||||
|
||||
accessor.HasContext.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_CreatesScope()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
|
||||
// Act
|
||||
using var scope = TenantContextScope.Create(accessor, "temp-tenant", "temp-actor");
|
||||
|
||||
// Assert
|
||||
accessor.TenantId.Should().Be("temp-tenant");
|
||||
accessor.Context!.Actor.Should().Be("temp-actor");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateSystem_CreatesSystemScope()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
|
||||
// Act
|
||||
using var scope = TenantContextScope.CreateSystem(accessor, "system-tenant");
|
||||
|
||||
// Assert
|
||||
accessor.TenantId.Should().Be("system-tenant");
|
||||
accessor.Context!.IsSystemContext.Should().BeTrue();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,462 @@
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Notifier.Worker.Tenancy;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Notifier.Tests.Tenancy;
|
||||
|
||||
public sealed class TenantMiddlewareTests
|
||||
{
|
||||
private static (TenantMiddleware Middleware, TenantContextAccessor Accessor) CreateMiddleware(
|
||||
RequestDelegate? next = null,
|
||||
TenantMiddlewareOptions? options = null)
|
||||
{
|
||||
var accessor = new TenantContextAccessor();
|
||||
options ??= new TenantMiddlewareOptions();
|
||||
next ??= _ => Task.CompletedTask;
|
||||
|
||||
var middleware = new TenantMiddleware(
|
||||
next,
|
||||
accessor,
|
||||
Options.Create(options),
|
||||
NullLogger<TenantMiddleware>.Instance);
|
||||
|
||||
return (middleware, accessor);
|
||||
}
|
||||
|
||||
private static HttpContext CreateHttpContext(
|
||||
string path = "/api/v1/test",
|
||||
Dictionary<string, string>? headers = null,
|
||||
Dictionary<string, string>? query = null)
|
||||
{
|
||||
var context = new DefaultHttpContext();
|
||||
context.Request.Path = path;
|
||||
|
||||
if (headers != null)
|
||||
{
|
||||
foreach (var (key, value) in headers)
|
||||
{
|
||||
context.Request.Headers[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
if (query != null)
|
||||
{
|
||||
var queryString = string.Join("&", query.Select(kvp => $"{kvp.Key}={kvp.Value}"));
|
||||
context.Request.QueryString = new QueryString($"?{queryString}");
|
||||
}
|
||||
|
||||
context.Response.Body = new MemoryStream();
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_ExtractsTenantFromHeader()
|
||||
{
|
||||
// Arrange
|
||||
var nextCalled = false;
|
||||
var (middleware, accessor) = CreateMiddleware(next: ctx =>
|
||||
{
|
||||
nextCalled = true;
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
var context = CreateHttpContext(headers: new Dictionary<string, string>
|
||||
{
|
||||
["X-StellaOps-Tenant"] = "tenant-123",
|
||||
["X-StellaOps-Actor"] = "user@test.com",
|
||||
["X-Correlation-Id"] = "corr-456"
|
||||
});
|
||||
|
||||
// Act
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
// Assert
|
||||
nextCalled.Should().BeTrue();
|
||||
// Note: Context is cleared after middleware completes
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_ReturnsBadRequest_WhenTenantMissingAndRequired()
|
||||
{
|
||||
// Arrange
|
||||
var (middleware, _) = CreateMiddleware(options: new TenantMiddlewareOptions
|
||||
{
|
||||
RequireTenant = true
|
||||
});
|
||||
|
||||
var context = CreateHttpContext();
|
||||
|
||||
// Act
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
// Assert
|
||||
context.Response.StatusCode.Should().Be(StatusCodes.Status400BadRequest);
|
||||
context.Response.Body.Seek(0, SeekOrigin.Begin);
|
||||
var body = await new StreamReader(context.Response.Body).ReadToEndAsync();
|
||||
body.Should().Contain("tenant_missing");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_ContinuesWithoutTenant_WhenNotRequired()
|
||||
{
|
||||
// Arrange
|
||||
var nextCalled = false;
|
||||
var (middleware, _) = CreateMiddleware(
|
||||
next: _ => { nextCalled = true; return Task.CompletedTask; },
|
||||
options: new TenantMiddlewareOptions { RequireTenant = false });
|
||||
|
||||
var context = CreateHttpContext();
|
||||
|
||||
// Act
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
// Assert
|
||||
nextCalled.Should().BeTrue();
|
||||
context.Response.StatusCode.Should().Be(StatusCodes.Status200OK);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_SkipsExcludedPaths()
|
||||
{
|
||||
// Arrange
|
||||
var nextCalled = false;
|
||||
var (middleware, accessor) = CreateMiddleware(
|
||||
next: _ => { nextCalled = true; return Task.CompletedTask; },
|
||||
options: new TenantMiddlewareOptions
|
||||
{
|
||||
RequireTenant = true,
|
||||
ExcludedPaths = ["/healthz", "/metrics"]
|
||||
});
|
||||
|
||||
var context = CreateHttpContext(path: "/healthz");
|
||||
|
||||
// Act
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
// Assert
|
||||
nextCalled.Should().BeTrue();
|
||||
context.Response.StatusCode.Should().Be(StatusCodes.Status200OK);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_ReturnsBadRequest_ForInvalidTenantId()
|
||||
{
|
||||
// Arrange
|
||||
var (middleware, _) = CreateMiddleware();
|
||||
|
||||
var context = CreateHttpContext(headers: new Dictionary<string, string>
|
||||
{
|
||||
["X-StellaOps-Tenant"] = "tenant@invalid#chars!"
|
||||
});
|
||||
|
||||
// Act
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
// Assert
|
||||
context.Response.StatusCode.Should().Be(StatusCodes.Status400BadRequest);
|
||||
context.Response.Body.Seek(0, SeekOrigin.Begin);
|
||||
var body = await new StreamReader(context.Response.Body).ReadToEndAsync();
|
||||
body.Should().Contain("tenant_invalid");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_RejectsTenantId_TooShort()
|
||||
{
|
||||
// Arrange
|
||||
var (middleware, _) = CreateMiddleware(options: new TenantMiddlewareOptions
|
||||
{
|
||||
MinTenantIdLength = 5
|
||||
});
|
||||
|
||||
var context = CreateHttpContext(headers: new Dictionary<string, string>
|
||||
{
|
||||
["X-StellaOps-Tenant"] = "abc"
|
||||
});
|
||||
|
||||
// Act
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
// Assert
|
||||
context.Response.StatusCode.Should().Be(StatusCodes.Status400BadRequest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_RejectsTenantId_TooLong()
|
||||
{
|
||||
// Arrange
|
||||
var (middleware, _) = CreateMiddleware(options: new TenantMiddlewareOptions
|
||||
{
|
||||
MaxTenantIdLength = 10
|
||||
});
|
||||
|
||||
var context = CreateHttpContext(headers: new Dictionary<string, string>
|
||||
{
|
||||
["X-StellaOps-Tenant"] = "very-long-tenant-id-exceeding-max"
|
||||
});
|
||||
|
||||
// Act
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
// Assert
|
||||
context.Response.StatusCode.Should().Be(StatusCodes.Status400BadRequest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_ExtractsTenantFromQueryParam_ForWebSocket()
|
||||
{
|
||||
// Arrange
|
||||
var nextCalled = false;
|
||||
var (middleware, accessor) = CreateMiddleware(next: ctx =>
|
||||
{
|
||||
nextCalled = true;
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
var context = CreateHttpContext(
|
||||
path: "/api/v2/incidents/live",
|
||||
query: new Dictionary<string, string> { ["tenant"] = "websocket-tenant" });
|
||||
|
||||
// Act
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
// Assert
|
||||
nextCalled.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_PrefersHeaderOverQueryParam()
|
||||
{
|
||||
// Arrange
|
||||
TenantContext? capturedContext = null;
|
||||
var (middleware, accessor) = CreateMiddleware(next: ctx =>
|
||||
{
|
||||
capturedContext = accessor.Context;
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
var context = CreateHttpContext(
|
||||
headers: new Dictionary<string, string> { ["X-StellaOps-Tenant"] = "header-tenant" },
|
||||
query: new Dictionary<string, string> { ["tenant"] = "query-tenant" });
|
||||
|
||||
// Act
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
// Assert
|
||||
capturedContext.Should().NotBeNull();
|
||||
capturedContext!.TenantId.Should().Be("header-tenant");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_UsesCustomHeaderNames()
|
||||
{
|
||||
// Arrange
|
||||
TenantContext? capturedContext = null;
|
||||
var (middleware, accessor) = CreateMiddleware(
|
||||
next: ctx => { capturedContext = accessor.Context; return Task.CompletedTask; },
|
||||
options: new TenantMiddlewareOptions
|
||||
{
|
||||
TenantHeader = "X-Custom-Tenant",
|
||||
ActorHeader = "X-Custom-Actor",
|
||||
CorrelationHeader = "X-Custom-Correlation"
|
||||
});
|
||||
|
||||
var context = CreateHttpContext(headers: new Dictionary<string, string>
|
||||
{
|
||||
["X-Custom-Tenant"] = "custom-tenant",
|
||||
["X-Custom-Actor"] = "custom-actor",
|
||||
["X-Custom-Correlation"] = "custom-corr"
|
||||
});
|
||||
|
||||
// Act
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
// Assert
|
||||
capturedContext.Should().NotBeNull();
|
||||
capturedContext!.TenantId.Should().Be("custom-tenant");
|
||||
capturedContext.Actor.Should().Be("custom-actor");
|
||||
capturedContext.CorrelationId.Should().Be("custom-corr");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_SetsDefaultActor_WhenNotProvided()
|
||||
{
|
||||
// Arrange
|
||||
TenantContext? capturedContext = null;
|
||||
var (middleware, accessor) = CreateMiddleware(next: ctx =>
|
||||
{
|
||||
capturedContext = accessor.Context;
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
var context = CreateHttpContext(headers: new Dictionary<string, string>
|
||||
{
|
||||
["X-StellaOps-Tenant"] = "tenant-123"
|
||||
});
|
||||
|
||||
// Act
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
// Assert
|
||||
capturedContext.Should().NotBeNull();
|
||||
capturedContext!.Actor.Should().Be("api");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_UsesTraceIdentifier_ForCorrelationId_WhenNotProvided()
|
||||
{
|
||||
// Arrange
|
||||
TenantContext? capturedContext = null;
|
||||
var (middleware, accessor) = CreateMiddleware(next: ctx =>
|
||||
{
|
||||
capturedContext = accessor.Context;
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
var context = CreateHttpContext(headers: new Dictionary<string, string>
|
||||
{
|
||||
["X-StellaOps-Tenant"] = "tenant-123"
|
||||
});
|
||||
context.TraceIdentifier = "test-trace-id";
|
||||
|
||||
// Act
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
// Assert
|
||||
capturedContext.Should().NotBeNull();
|
||||
capturedContext!.CorrelationId.Should().Be("test-trace-id");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_AddsTenantIdToResponseHeaders()
|
||||
{
|
||||
// Arrange
|
||||
var (middleware, _) = CreateMiddleware();
|
||||
|
||||
var context = CreateHttpContext(headers: new Dictionary<string, string>
|
||||
{
|
||||
["X-StellaOps-Tenant"] = "response-tenant"
|
||||
});
|
||||
|
||||
// Trigger OnStarting callbacks by starting the response
|
||||
await middleware.InvokeAsync(context);
|
||||
await context.Response.StartAsync();
|
||||
|
||||
// Assert
|
||||
context.Response.Headers["X-Tenant-Id"].ToString().Should().Be("response-tenant");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_ClearsContextAfterRequest()
|
||||
{
|
||||
// Arrange
|
||||
var (middleware, accessor) = CreateMiddleware();
|
||||
|
||||
var context = CreateHttpContext(headers: new Dictionary<string, string>
|
||||
{
|
||||
["X-StellaOps-Tenant"] = "tenant-123"
|
||||
});
|
||||
|
||||
// Act
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
// Assert
|
||||
accessor.HasContext.Should().BeFalse();
|
||||
accessor.Context.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_AllowsHyphenAndUnderscore_InTenantId()
|
||||
{
|
||||
// Arrange
|
||||
TenantContext? capturedContext = null;
|
||||
var (middleware, accessor) = CreateMiddleware(next: ctx =>
|
||||
{
|
||||
capturedContext = accessor.Context;
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
var context = CreateHttpContext(headers: new Dictionary<string, string>
|
||||
{
|
||||
["X-StellaOps-Tenant"] = "tenant-123_abc"
|
||||
});
|
||||
|
||||
// Act
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
// Assert
|
||||
capturedContext.Should().NotBeNull();
|
||||
capturedContext!.TenantId.Should().Be("tenant-123_abc");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_SetsSource_ToHttpHeader()
|
||||
{
|
||||
// Arrange
|
||||
TenantContext? capturedContext = null;
|
||||
var (middleware, accessor) = CreateMiddleware(next: ctx =>
|
||||
{
|
||||
capturedContext = accessor.Context;
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
var context = CreateHttpContext(headers: new Dictionary<string, string>
|
||||
{
|
||||
["X-StellaOps-Tenant"] = "tenant-123"
|
||||
});
|
||||
|
||||
// Act
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
// Assert
|
||||
capturedContext.Should().NotBeNull();
|
||||
capturedContext!.Source.Should().Be(TenantContextSource.HttpHeader);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_SetsSource_ToQueryParameter_ForWebSocket()
|
||||
{
|
||||
// Arrange
|
||||
TenantContext? capturedContext = null;
|
||||
var (middleware, accessor) = CreateMiddleware(next: ctx =>
|
||||
{
|
||||
capturedContext = accessor.Context;
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
var context = CreateHttpContext(
|
||||
path: "/api/live",
|
||||
query: new Dictionary<string, string> { ["tenant"] = "ws-tenant" });
|
||||
|
||||
// Act
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
// Assert
|
||||
capturedContext.Should().NotBeNull();
|
||||
capturedContext!.Source.Should().Be(TenantContextSource.QueryParameter);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class TenantMiddlewareOptionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void DefaultValues_AreCorrect()
|
||||
{
|
||||
// Arrange & Act
|
||||
var options = new TenantMiddlewareOptions();
|
||||
|
||||
// Assert
|
||||
options.TenantHeader.Should().Be("X-StellaOps-Tenant");
|
||||
options.ActorHeader.Should().Be("X-StellaOps-Actor");
|
||||
options.CorrelationHeader.Should().Be("X-Correlation-Id");
|
||||
options.RequireTenant.Should().BeTrue();
|
||||
options.MinTenantIdLength.Should().Be(1);
|
||||
options.MaxTenantIdLength.Should().Be(128);
|
||||
options.ExcludedPaths.Should().Contain("/healthz");
|
||||
options.ExcludedPaths.Should().Contain("/metrics");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,486 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Notifier.Worker.Tenancy;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Notifier.Tests.Tenancy;
|
||||
|
||||
public sealed class TenantNotificationEnricherTests
|
||||
{
|
||||
private static DefaultTenantNotificationEnricher CreateEnricher(
|
||||
ITenantContextAccessor? accessor = null,
|
||||
TenantNotificationEnricherOptions? options = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
accessor ??= new TenantContextAccessor();
|
||||
options ??= new TenantNotificationEnricherOptions();
|
||||
timeProvider ??= TimeProvider.System;
|
||||
|
||||
return new DefaultTenantNotificationEnricher(
|
||||
accessor,
|
||||
Options.Create(options),
|
||||
timeProvider);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Enrich_AddsTenanInfoToPayload()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
accessor.Context = TenantContext.FromHeaders("tenant-123", "user@test.com", "corr-456");
|
||||
var fakeTime = new FakeTimeProvider(new DateTimeOffset(2024, 1, 15, 10, 30, 0, TimeSpan.Zero));
|
||||
var enricher = CreateEnricher(accessor, timeProvider: fakeTime);
|
||||
|
||||
var payload = new JsonObject
|
||||
{
|
||||
["eventType"] = "test.event",
|
||||
["data"] = new JsonObject { ["key"] = "value" }
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = enricher.Enrich(payload);
|
||||
|
||||
// Assert
|
||||
result.Should().ContainKey("_tenant");
|
||||
var tenant = result["_tenant"]!.AsObject();
|
||||
tenant["id"]!.GetValue<string>().Should().Be("tenant-123");
|
||||
tenant["actor"]!.GetValue<string>().Should().Be("user@test.com");
|
||||
tenant["correlationId"]!.GetValue<string>().Should().Be("corr-456");
|
||||
tenant["source"]!.GetValue<string>().Should().Be("HttpHeader");
|
||||
tenant.Should().ContainKey("timestamp");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Enrich_ReturnsUnmodifiedPayloadWhenNoContext()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
var enricher = CreateEnricher(accessor);
|
||||
|
||||
var payload = new JsonObject
|
||||
{
|
||||
["eventType"] = "test.event"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = enricher.Enrich(payload);
|
||||
|
||||
// Assert
|
||||
result.Should().NotContainKey("_tenant");
|
||||
result["eventType"]!.GetValue<string>().Should().Be("test.event");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Enrich_SkipsWhenIncludeInPayloadDisabled()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
accessor.Context = TenantContext.FromHeaders("tenant-123", "user", null);
|
||||
var options = new TenantNotificationEnricherOptions { IncludeInPayload = false };
|
||||
var enricher = CreateEnricher(accessor, options);
|
||||
|
||||
var payload = new JsonObject { ["data"] = "test" };
|
||||
|
||||
// Act
|
||||
var result = enricher.Enrich(payload);
|
||||
|
||||
// Assert
|
||||
result.Should().NotContainKey("_tenant");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Enrich_UsesCustomPropertyName()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
accessor.Context = TenantContext.FromHeaders("tenant-123", "user", null);
|
||||
var options = new TenantNotificationEnricherOptions { PayloadPropertyName = "tenantContext" };
|
||||
var enricher = CreateEnricher(accessor, options);
|
||||
|
||||
var payload = new JsonObject();
|
||||
|
||||
// Act
|
||||
var result = enricher.Enrich(payload);
|
||||
|
||||
// Assert
|
||||
result.Should().ContainKey("tenantContext");
|
||||
result.Should().NotContainKey("_tenant");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Enrich_ExcludesActorWhenDisabled()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
accessor.Context = TenantContext.FromHeaders("tenant-123", "user@test.com", null);
|
||||
var options = new TenantNotificationEnricherOptions { IncludeActor = false };
|
||||
var enricher = CreateEnricher(accessor, options);
|
||||
|
||||
var payload = new JsonObject();
|
||||
|
||||
// Act
|
||||
var result = enricher.Enrich(payload);
|
||||
|
||||
// Assert
|
||||
var tenant = result["_tenant"]!.AsObject();
|
||||
tenant.Should().NotContainKey("actor");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Enrich_ExcludesCorrelationIdWhenDisabled()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
accessor.Context = TenantContext.FromHeaders("tenant-123", "user", "corr-123");
|
||||
var options = new TenantNotificationEnricherOptions { IncludeCorrelationId = false };
|
||||
var enricher = CreateEnricher(accessor, options);
|
||||
|
||||
var payload = new JsonObject();
|
||||
|
||||
// Act
|
||||
var result = enricher.Enrich(payload);
|
||||
|
||||
// Assert
|
||||
var tenant = result["_tenant"]!.AsObject();
|
||||
tenant.Should().NotContainKey("correlationId");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Enrich_ExcludesTimestampWhenDisabled()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
accessor.Context = TenantContext.FromHeaders("tenant-123", "user", null);
|
||||
var options = new TenantNotificationEnricherOptions { IncludeTimestamp = false };
|
||||
var enricher = CreateEnricher(accessor, options);
|
||||
|
||||
var payload = new JsonObject();
|
||||
|
||||
// Act
|
||||
var result = enricher.Enrich(payload);
|
||||
|
||||
// Assert
|
||||
var tenant = result["_tenant"]!.AsObject();
|
||||
tenant.Should().NotContainKey("timestamp");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Enrich_IncludesIsSystemForSystemContext()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
accessor.Context = TenantContext.System("system-tenant");
|
||||
var enricher = CreateEnricher(accessor);
|
||||
|
||||
var payload = new JsonObject();
|
||||
|
||||
// Act
|
||||
var result = enricher.Enrich(payload);
|
||||
|
||||
// Assert
|
||||
var tenant = result["_tenant"]!.AsObject();
|
||||
tenant["isSystem"]!.GetValue<bool>().Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Enrich_IncludesClaims()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
var context = TenantContext.FromHeaders("tenant-123", "user", null)
|
||||
.WithClaim("role", "admin")
|
||||
.WithClaim("department", "engineering");
|
||||
accessor.Context = context;
|
||||
var enricher = CreateEnricher(accessor);
|
||||
|
||||
var payload = new JsonObject();
|
||||
|
||||
// Act
|
||||
var result = enricher.Enrich(payload);
|
||||
|
||||
// Assert
|
||||
var tenant = result["_tenant"]!.AsObject();
|
||||
tenant.Should().ContainKey("claims");
|
||||
var claims = tenant["claims"]!.AsObject();
|
||||
claims["role"]!.GetValue<string>().Should().Be("admin");
|
||||
claims["department"]!.GetValue<string>().Should().Be("engineering");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Enrich_WithExplicitContext_UsesProvidedContext()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
accessor.Context = TenantContext.FromHeaders("accessor-tenant", "accessor-user", null);
|
||||
var enricher = CreateEnricher(accessor);
|
||||
|
||||
var explicitContext = TenantContext.FromHeaders("explicit-tenant", "explicit-user", "explicit-corr");
|
||||
var payload = new JsonObject();
|
||||
|
||||
// Act
|
||||
var result = enricher.Enrich(payload, explicitContext);
|
||||
|
||||
// Assert
|
||||
var tenant = result["_tenant"]!.AsObject();
|
||||
tenant["id"]!.GetValue<string>().Should().Be("explicit-tenant");
|
||||
tenant["actor"]!.GetValue<string>().Should().Be("explicit-user");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateHeaders_ReturnsEmptyWhenNoContext()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
var enricher = CreateEnricher(accessor);
|
||||
|
||||
// Act
|
||||
var headers = enricher.CreateHeaders();
|
||||
|
||||
// Assert
|
||||
headers.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateHeaders_ReturnsTenantHeaders()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
accessor.Context = TenantContext.FromHeaders("tenant-123", "user@test.com", "corr-456");
|
||||
var enricher = CreateEnricher(accessor);
|
||||
|
||||
// Act
|
||||
var headers = enricher.CreateHeaders();
|
||||
|
||||
// Assert
|
||||
headers.Should().ContainKey("X-StellaOps-Tenant");
|
||||
headers["X-StellaOps-Tenant"].Should().Be("tenant-123");
|
||||
headers.Should().ContainKey("X-StellaOps-Actor");
|
||||
headers["X-StellaOps-Actor"].Should().Be("user@test.com");
|
||||
headers.Should().ContainKey("X-Correlation-Id");
|
||||
headers["X-Correlation-Id"].Should().Be("corr-456");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateHeaders_ReturnsEmptyWhenIncludeInHeadersDisabled()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
accessor.Context = TenantContext.FromHeaders("tenant-123", "user", null);
|
||||
var options = new TenantNotificationEnricherOptions { IncludeInHeaders = false };
|
||||
var enricher = CreateEnricher(accessor, options);
|
||||
|
||||
// Act
|
||||
var headers = enricher.CreateHeaders();
|
||||
|
||||
// Assert
|
||||
headers.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateHeaders_UsesCustomHeaderNames()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
accessor.Context = TenantContext.FromHeaders("tenant-123", "user", null);
|
||||
var options = new TenantNotificationEnricherOptions
|
||||
{
|
||||
TenantHeader = "X-Custom-Tenant",
|
||||
ActorHeader = "X-Custom-Actor"
|
||||
};
|
||||
var enricher = CreateEnricher(accessor, options);
|
||||
|
||||
// Act
|
||||
var headers = enricher.CreateHeaders();
|
||||
|
||||
// Assert
|
||||
headers.Should().ContainKey("X-Custom-Tenant");
|
||||
headers.Should().ContainKey("X-Custom-Actor");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractContext_ReturnsNullForNullPayload()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
var enricher = CreateEnricher(accessor);
|
||||
|
||||
// Act
|
||||
var context = enricher.ExtractContext(null!);
|
||||
|
||||
// Assert
|
||||
context.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractContext_ReturnsNullForMissingTenantProperty()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
var enricher = CreateEnricher(accessor);
|
||||
var payload = new JsonObject { ["data"] = "test" };
|
||||
|
||||
// Act
|
||||
var context = enricher.ExtractContext(payload);
|
||||
|
||||
// Assert
|
||||
context.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractContext_ExtractsValidContext()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
var enricher = CreateEnricher(accessor);
|
||||
var payload = new JsonObject
|
||||
{
|
||||
["_tenant"] = new JsonObject
|
||||
{
|
||||
["id"] = "extracted-tenant",
|
||||
["actor"] = "extracted-user",
|
||||
["correlationId"] = "extracted-corr",
|
||||
["source"] = "HttpHeader"
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var context = enricher.ExtractContext(payload);
|
||||
|
||||
// Assert
|
||||
context.Should().NotBeNull();
|
||||
context!.TenantId.Should().Be("extracted-tenant");
|
||||
context.Actor.Should().Be("extracted-user");
|
||||
context.CorrelationId.Should().Be("extracted-corr");
|
||||
context.Source.Should().Be(TenantContextSource.HttpHeader);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractContext_ExtractsSystemContext()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
var enricher = CreateEnricher(accessor);
|
||||
var payload = new JsonObject
|
||||
{
|
||||
["_tenant"] = new JsonObject
|
||||
{
|
||||
["id"] = "system",
|
||||
["actor"] = "system",
|
||||
["isSystem"] = true,
|
||||
["source"] = "System"
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var context = enricher.ExtractContext(payload);
|
||||
|
||||
// Assert
|
||||
context.Should().NotBeNull();
|
||||
context!.IsSystemContext.Should().BeTrue();
|
||||
context.Source.Should().Be(TenantContextSource.System);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractContext_ExtractsClaims()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
var enricher = CreateEnricher(accessor);
|
||||
var payload = new JsonObject
|
||||
{
|
||||
["_tenant"] = new JsonObject
|
||||
{
|
||||
["id"] = "tenant-123",
|
||||
["claims"] = new JsonObject
|
||||
{
|
||||
["role"] = "admin",
|
||||
["tier"] = "premium"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var context = enricher.ExtractContext(payload);
|
||||
|
||||
// Assert
|
||||
context.Should().NotBeNull();
|
||||
context!.Claims.Should().HaveCount(2);
|
||||
context.Claims["role"].Should().Be("admin");
|
||||
context.Claims["tier"].Should().Be("premium");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractContext_UsesCustomPropertyName()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
var options = new TenantNotificationEnricherOptions { PayloadPropertyName = "tenantInfo" };
|
||||
var enricher = CreateEnricher(accessor, options);
|
||||
var payload = new JsonObject
|
||||
{
|
||||
["tenantInfo"] = new JsonObject
|
||||
{
|
||||
["id"] = "custom-tenant"
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var context = enricher.ExtractContext(payload);
|
||||
|
||||
// Assert
|
||||
context.Should().NotBeNull();
|
||||
context!.TenantId.Should().Be("custom-tenant");
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class TenantNotificationEnricherExtensionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void EnrichFromDictionary_CreatesEnrichedPayload()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
accessor.Context = TenantContext.FromHeaders("tenant-123", "user", null);
|
||||
var enricher = new DefaultTenantNotificationEnricher(
|
||||
accessor,
|
||||
Options.Create(new TenantNotificationEnricherOptions()),
|
||||
TimeProvider.System);
|
||||
|
||||
var data = new Dictionary<string, object?>
|
||||
{
|
||||
["eventType"] = "test.event",
|
||||
["value"] = 42
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = enricher.EnrichFromDictionary(data);
|
||||
|
||||
// Assert
|
||||
result.Should().ContainKey("eventType");
|
||||
result.Should().ContainKey("value");
|
||||
result.Should().ContainKey("_tenant");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnrichAndSerialize_ReturnsJsonString()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
accessor.Context = TenantContext.FromHeaders("tenant-123", "user", null);
|
||||
var enricher = new DefaultTenantNotificationEnricher(
|
||||
accessor,
|
||||
Options.Create(new TenantNotificationEnricherOptions()),
|
||||
TimeProvider.System);
|
||||
|
||||
var payload = new JsonObject { ["data"] = "test" };
|
||||
|
||||
// Act
|
||||
var result = enricher.EnrichAndSerialize(payload);
|
||||
|
||||
// Assert
|
||||
result.Should().Contain("\"_tenant\"");
|
||||
result.Should().Contain("\"tenant-123\"");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Notifier.Worker.Tenancy;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Notifier.Tests.Tenancy;
|
||||
|
||||
public sealed class TenantRlsEnforcerTests
|
||||
{
|
||||
private static DefaultTenantRlsEnforcer CreateEnforcer(
|
||||
ITenantContextAccessor? accessor = null,
|
||||
TenantRlsOptions? options = null)
|
||||
{
|
||||
accessor ??= new TenantContextAccessor();
|
||||
options ??= new TenantRlsOptions();
|
||||
|
||||
return new DefaultTenantRlsEnforcer(
|
||||
accessor,
|
||||
Options.Create(options),
|
||||
NullLogger<DefaultTenantRlsEnforcer>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAccessAsync_AllowsSameTenant()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
accessor.Context = TenantContext.FromHeaders("tenant-a", "user", null);
|
||||
var enforcer = CreateEnforcer(accessor);
|
||||
|
||||
// Act
|
||||
var result = await enforcer.ValidateAccessAsync(
|
||||
"notification", "notif-123", "tenant-a", RlsOperation.Read);
|
||||
|
||||
// Assert
|
||||
result.IsAllowed.Should().BeTrue();
|
||||
result.TenantId.Should().Be("tenant-a");
|
||||
result.ResourceTenantId.Should().Be("tenant-a");
|
||||
result.IsSystemAccess.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAccessAsync_DeniesDifferentTenant()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
accessor.Context = TenantContext.FromHeaders("tenant-a", "user", null);
|
||||
var enforcer = CreateEnforcer(accessor);
|
||||
|
||||
// Act
|
||||
var result = await enforcer.ValidateAccessAsync(
|
||||
"notification", "notif-123", "tenant-b", RlsOperation.Read);
|
||||
|
||||
// Assert
|
||||
result.IsAllowed.Should().BeFalse();
|
||||
result.DenialReason.Should().Contain("tenant-a");
|
||||
result.DenialReason.Should().Contain("tenant-b");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAccessAsync_AllowsSystemContextBypass()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
accessor.Context = TenantContext.System("system");
|
||||
var options = new TenantRlsOptions { AllowSystemBypass = true };
|
||||
var enforcer = CreateEnforcer(accessor, options);
|
||||
|
||||
// Act
|
||||
var result = await enforcer.ValidateAccessAsync(
|
||||
"notification", "notif-123", "tenant-b", RlsOperation.Read);
|
||||
|
||||
// Assert
|
||||
result.IsAllowed.Should().BeTrue();
|
||||
result.IsSystemAccess.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAccessAsync_DeniesSystemContextWhenBypassDisabled()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
accessor.Context = TenantContext.System("system");
|
||||
var options = new TenantRlsOptions { AllowSystemBypass = false };
|
||||
var enforcer = CreateEnforcer(accessor, options);
|
||||
|
||||
// Act
|
||||
var result = await enforcer.ValidateAccessAsync(
|
||||
"notification", "notif-123", "tenant-b", RlsOperation.Read);
|
||||
|
||||
// Assert
|
||||
result.IsAllowed.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAccessAsync_AllowsAdminTenant()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
accessor.Context = TenantContext.FromHeaders("admin", "admin-user", null);
|
||||
var options = new TenantRlsOptions { AdminTenantPatterns = ["^admin$"] };
|
||||
var enforcer = CreateEnforcer(accessor, options);
|
||||
|
||||
// Act
|
||||
var result = await enforcer.ValidateAccessAsync(
|
||||
"notification", "notif-123", "tenant-b", RlsOperation.Read);
|
||||
|
||||
// Assert
|
||||
result.IsAllowed.Should().BeTrue();
|
||||
result.IsSystemAccess.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAccessAsync_AllowsGlobalResourceTypes()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
accessor.Context = TenantContext.FromHeaders("tenant-a", "user", null);
|
||||
var options = new TenantRlsOptions { GlobalResourceTypes = ["system-template"] };
|
||||
var enforcer = CreateEnforcer(accessor, options);
|
||||
|
||||
// Act
|
||||
var result = await enforcer.ValidateAccessAsync(
|
||||
"system-template", "template-123", "system", RlsOperation.Read);
|
||||
|
||||
// Assert
|
||||
result.IsAllowed.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAccessAsync_AllowsAllWhenDisabled()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
accessor.Context = TenantContext.FromHeaders("tenant-a", "user", null);
|
||||
var options = new TenantRlsOptions { Enabled = false };
|
||||
var enforcer = CreateEnforcer(accessor, options);
|
||||
|
||||
// Act
|
||||
var result = await enforcer.ValidateAccessAsync(
|
||||
"notification", "notif-123", "tenant-b", RlsOperation.Read);
|
||||
|
||||
// Assert
|
||||
result.IsAllowed.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAccessAsync_DeniesWhenNoContext()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
var enforcer = CreateEnforcer(accessor);
|
||||
|
||||
// Act
|
||||
var result = await enforcer.ValidateAccessAsync(
|
||||
"notification", "notif-123", "tenant-a", RlsOperation.Read);
|
||||
|
||||
// Assert
|
||||
result.IsAllowed.Should().BeFalse();
|
||||
result.DenialReason.Should().Contain("context");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnsureAccessAsync_ThrowsOnDenial()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
accessor.Context = TenantContext.FromHeaders("tenant-a", "user", null);
|
||||
var enforcer = CreateEnforcer(accessor);
|
||||
|
||||
// Act
|
||||
var act = async () => await enforcer.EnsureAccessAsync(
|
||||
"notification", "notif-123", "tenant-b", RlsOperation.Update);
|
||||
|
||||
// Assert
|
||||
await act.Should().ThrowAsync<TenantAccessDeniedException>()
|
||||
.Where(ex => ex.TenantId == "tenant-a" &&
|
||||
ex.ResourceTenantId == "tenant-b" &&
|
||||
ex.ResourceType == "notification" &&
|
||||
ex.Operation == RlsOperation.Update);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetCurrentTenantId_ReturnsCurrentTenant()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
accessor.Context = TenantContext.FromHeaders("tenant-xyz", "user", null);
|
||||
var enforcer = CreateEnforcer(accessor);
|
||||
|
||||
// Act
|
||||
var tenantId = enforcer.GetCurrentTenantId();
|
||||
|
||||
// Assert
|
||||
tenantId.Should().Be("tenant-xyz");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasSystemAccess_ReturnsTrueForSystemContext()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
accessor.Context = TenantContext.System("system");
|
||||
var enforcer = CreateEnforcer(accessor);
|
||||
|
||||
// Act
|
||||
var hasAccess = enforcer.HasSystemAccess();
|
||||
|
||||
// Assert
|
||||
hasAccess.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasSystemAccess_ReturnsFalseForRegularContext()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
accessor.Context = TenantContext.FromHeaders("regular-tenant", "user", null);
|
||||
var enforcer = CreateEnforcer(accessor);
|
||||
|
||||
// Act
|
||||
var hasAccess = enforcer.HasSystemAccess();
|
||||
|
||||
// Assert
|
||||
hasAccess.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateScopedId_CreatesTenantPrefixedId()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
accessor.Context = TenantContext.FromHeaders("tenant-abc", "user", null);
|
||||
var enforcer = CreateEnforcer(accessor);
|
||||
|
||||
// Act
|
||||
var scopedId = enforcer.CreateScopedId("resource-123");
|
||||
|
||||
// Assert
|
||||
scopedId.Should().Be("tenant-abc:resource-123");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractResourceId_ExtractsResourcePart()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
var enforcer = CreateEnforcer(accessor);
|
||||
|
||||
// Act
|
||||
var resourceId = enforcer.ExtractResourceId("tenant-abc:resource-123");
|
||||
|
||||
// Assert
|
||||
resourceId.Should().Be("resource-123");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractResourceId_ReturnsNullForInvalidFormat()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
var enforcer = CreateEnforcer(accessor);
|
||||
|
||||
// Act
|
||||
var resourceId = enforcer.ExtractResourceId("no-separator-here");
|
||||
|
||||
// Assert
|
||||
resourceId.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateScopedId_ReturnsTrueForSameTenant()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
accessor.Context = TenantContext.FromHeaders("tenant-abc", "user", null);
|
||||
var enforcer = CreateEnforcer(accessor);
|
||||
|
||||
// Act
|
||||
var isValid = enforcer.ValidateScopedId("tenant-abc:resource-123");
|
||||
|
||||
// Assert
|
||||
isValid.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateScopedId_ReturnsFalseForDifferentTenant()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
accessor.Context = TenantContext.FromHeaders("tenant-abc", "user", null);
|
||||
var enforcer = CreateEnforcer(accessor);
|
||||
|
||||
// Act
|
||||
var isValid = enforcer.ValidateScopedId("tenant-xyz:resource-123");
|
||||
|
||||
// Assert
|
||||
isValid.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateScopedId_ReturnsTrueForSystemAccess()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
accessor.Context = TenantContext.System("system");
|
||||
var options = new TenantRlsOptions { AdminTenantPatterns = ["^system$"] };
|
||||
var enforcer = CreateEnforcer(accessor, options);
|
||||
|
||||
// Act
|
||||
var isValid = enforcer.ValidateScopedId("any-tenant:resource-123");
|
||||
|
||||
// Assert
|
||||
isValid.Should().BeTrue();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class RlsValidationResultTests
|
||||
{
|
||||
[Fact]
|
||||
public void Allowed_CreatesAllowedResult()
|
||||
{
|
||||
// Arrange & Act
|
||||
var result = RlsValidationResult.Allowed("tenant-a", "tenant-a");
|
||||
|
||||
// Assert
|
||||
result.IsAllowed.Should().BeTrue();
|
||||
result.TenantId.Should().Be("tenant-a");
|
||||
result.ResourceTenantId.Should().Be("tenant-a");
|
||||
result.DenialReason.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Denied_CreatesDeniedResult()
|
||||
{
|
||||
// Arrange & Act
|
||||
var result = RlsValidationResult.Denied("tenant-a", "tenant-b", "Cross-tenant access denied");
|
||||
|
||||
// Assert
|
||||
result.IsAllowed.Should().BeFalse();
|
||||
result.TenantId.Should().Be("tenant-a");
|
||||
result.ResourceTenantId.Should().Be("tenant-b");
|
||||
result.DenialReason.Should().Be("Cross-tenant access denied");
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class TenantAccessDeniedExceptionTests
|
||||
{
|
||||
[Fact]
|
||||
public void Constructor_SetsAllProperties()
|
||||
{
|
||||
// Arrange & Act
|
||||
var exception = new TenantAccessDeniedException(
|
||||
"tenant-a", "tenant-b", "notification", "notif-123", RlsOperation.Update);
|
||||
|
||||
// Assert
|
||||
exception.TenantId.Should().Be("tenant-a");
|
||||
exception.ResourceTenantId.Should().Be("tenant-b");
|
||||
exception.ResourceType.Should().Be("notification");
|
||||
exception.ResourceId.Should().Be("notif-123");
|
||||
exception.Operation.Should().Be(RlsOperation.Update);
|
||||
exception.Message.Should().Contain("tenant-a");
|
||||
exception.Message.Should().Contain("tenant-b");
|
||||
exception.Message.Should().Contain("notification/notif-123");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user