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

This commit is contained in:
master
2025-11-27 15:05:48 +02:00
parent 4831c7fcb0
commit e950474a77
278 changed files with 81498 additions and 672 deletions

View File

@@ -0,0 +1,330 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Time.Testing;
using Moq;
using StellaOps.Notifier.Worker.Correlation;
using StellaOps.Notifier.Worker.Escalation;
using Xunit;
namespace StellaOps.Notifier.WebService.Tests.Escalation;
/// <summary>
/// Tests for acknowledgment bridge.
/// </summary>
public sealed class AckBridgeTests
{
private readonly FakeTimeProvider _timeProvider;
private readonly Mock<IEscalationEngine> _escalationEngine;
private readonly Mock<IIncidentManager> _incidentManager;
private readonly AckBridgeOptions _options;
private readonly AckBridge _bridge;
public AckBridgeTests()
{
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 1, 15, 10, 0, 0, TimeSpan.Zero));
_escalationEngine = new Mock<IEscalationEngine>();
_incidentManager = new Mock<IIncidentManager>();
_options = new AckBridgeOptions
{
AckBaseUrl = "https://notify.example.com",
SigningKey = "test-signing-key-for-unit-tests",
DefaultTokenExpiry = TimeSpan.FromHours(24)
};
_bridge = new AckBridge(
_escalationEngine.Object,
_incidentManager.Object,
null,
Options.Create(_options),
_timeProvider,
NullLogger<AckBridge>.Instance);
}
[Fact]
public async Task GenerateAckLink_CreatesValidUrl()
{
var link = await _bridge.GenerateAckLinkAsync(
"tenant-1",
"incident-1",
"user-1",
TimeSpan.FromHours(1));
link.Should().StartWith("https://notify.example.com/ack?token=");
link.Should().Contain("token=");
}
[Fact]
public async Task ValidateToken_WithValidToken_ReturnsValid()
{
var link = await _bridge.GenerateAckLinkAsync(
"tenant-1",
"incident-1",
"user-1",
TimeSpan.FromHours(1));
var token = ExtractToken(link);
var result = await _bridge.ValidateTokenAsync(token);
result.IsValid.Should().BeTrue();
result.TenantId.Should().Be("tenant-1");
result.IncidentId.Should().Be("incident-1");
result.TargetId.Should().Be("user-1");
}
[Fact]
public async Task ValidateToken_WithExpiredToken_ReturnsInvalid()
{
var link = await _bridge.GenerateAckLinkAsync(
"tenant-1",
"incident-1",
"user-1",
TimeSpan.FromMinutes(30));
var token = ExtractToken(link);
// Advance time past expiry
_timeProvider.Advance(TimeSpan.FromHours(1));
var result = await _bridge.ValidateTokenAsync(token);
result.IsValid.Should().BeFalse();
result.Error.Should().Contain("expired");
}
[Fact]
public async Task ValidateToken_WithTamperedToken_ReturnsInvalid()
{
var link = await _bridge.GenerateAckLinkAsync(
"tenant-1",
"incident-1",
"user-1");
var token = ExtractToken(link);
var tamperedToken = token.Substring(0, token.Length - 5) + "XXXXX";
var result = await _bridge.ValidateTokenAsync(tamperedToken);
result.IsValid.Should().BeFalse();
result.Error.Should().NotBeNullOrEmpty();
}
[Fact]
public async Task ValidateToken_WithMalformedToken_ReturnsInvalid()
{
var result = await _bridge.ValidateTokenAsync("not-a-valid-token");
result.IsValid.Should().BeFalse();
result.Error.Should().Contain("Invalid token format");
}
[Fact]
public async Task ProcessAck_WithSignedLink_ProcessesSuccessfully()
{
var escalationState = new EscalationState
{
TenantId = "tenant-1",
IncidentId = "incident-1",
PolicyId = "policy-1",
Status = EscalationStatus.Acknowledged
};
_escalationEngine.Setup(x => x.ProcessAcknowledgmentAsync(
"tenant-1", "incident-1", "user-1", It.IsAny<CancellationToken>()))
.ReturnsAsync(escalationState);
_incidentManager.Setup(x => x.AcknowledgeAsync(
"tenant-1", "incident-1", "user-1", It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
var link = await _bridge.GenerateAckLinkAsync(
"tenant-1",
"incident-1",
"user-1");
var token = ExtractToken(link);
var request = new AckBridgeRequest
{
Source = AckSource.SignedLink,
Token = token,
AcknowledgedBy = "user-1"
};
var result = await _bridge.ProcessAckAsync(request);
result.Success.Should().BeTrue();
result.TenantId.Should().Be("tenant-1");
result.IncidentId.Should().Be("incident-1");
}
[Fact]
public async Task ProcessAck_WithInvalidToken_ReturnsFailed()
{
var request = new AckBridgeRequest
{
Source = AckSource.SignedLink,
Token = "invalid-token",
AcknowledgedBy = "user-1"
};
var result = await _bridge.ProcessAckAsync(request);
result.Success.Should().BeFalse();
result.Error.Should().NotBeNullOrEmpty();
}
[Fact]
public async Task ProcessAck_WithDirectIds_ProcessesSuccessfully()
{
var escalationState = new EscalationState
{
TenantId = "tenant-1",
IncidentId = "incident-1",
PolicyId = "policy-1",
Status = EscalationStatus.Acknowledged
};
_escalationEngine.Setup(x => x.ProcessAcknowledgmentAsync(
"tenant-1", "incident-1", "user-1", It.IsAny<CancellationToken>()))
.ReturnsAsync(escalationState);
_incidentManager.Setup(x => x.AcknowledgeAsync(
"tenant-1", "incident-1", "user-1", It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
var request = new AckBridgeRequest
{
Source = AckSource.Api,
TenantId = "tenant-1",
IncidentId = "incident-1",
AcknowledgedBy = "user-1"
};
var result = await _bridge.ProcessAckAsync(request);
result.Success.Should().BeTrue();
}
[Fact]
public async Task ProcessAck_WithExternalId_ResolvesMapping()
{
var escalationState = new EscalationState
{
TenantId = "tenant-1",
IncidentId = "incident-1",
PolicyId = "policy-1",
Status = EscalationStatus.Acknowledged
};
_escalationEngine.Setup(x => x.ProcessAcknowledgmentAsync(
"tenant-1", "incident-1", "pagerduty", It.IsAny<CancellationToken>()))
.ReturnsAsync(escalationState);
_incidentManager.Setup(x => x.AcknowledgeAsync(
"tenant-1", "incident-1", "pagerduty", It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
// Register the external ID mapping
_bridge.RegisterExternalId(AckSource.PagerDuty, "pd-alert-123", "tenant-1", "incident-1");
var request = new AckBridgeRequest
{
Source = AckSource.PagerDuty,
ExternalId = "pd-alert-123",
AcknowledgedBy = "pagerduty"
};
var result = await _bridge.ProcessAckAsync(request);
result.Success.Should().BeTrue();
result.TenantId.Should().Be("tenant-1");
result.IncidentId.Should().Be("incident-1");
}
[Fact]
public async Task ProcessAck_WithUnknownExternalId_ReturnsFailed()
{
var request = new AckBridgeRequest
{
Source = AckSource.PagerDuty,
ExternalId = "unknown-external-id",
AcknowledgedBy = "pagerduty"
};
var result = await _bridge.ProcessAckAsync(request);
result.Success.Should().BeFalse();
result.Error.Should().Contain("Unknown external ID");
}
[Fact]
public async Task ProcessAck_WithMissingIds_ReturnsFailed()
{
var request = new AckBridgeRequest
{
Source = AckSource.Api,
AcknowledgedBy = "user-1"
};
var result = await _bridge.ProcessAckAsync(request);
result.Success.Should().BeFalse();
result.Error.Should().Contain("Could not resolve");
}
[Fact]
public async Task GenerateAckLink_UsesDefaultExpiry()
{
var link = await _bridge.GenerateAckLinkAsync(
"tenant-1",
"incident-1",
"user-1");
var token = ExtractToken(link);
var result = await _bridge.ValidateTokenAsync(token);
result.IsValid.Should().BeTrue();
result.ExpiresAt.Should().BeCloseTo(
_timeProvider.GetUtcNow().Add(_options.DefaultTokenExpiry),
TimeSpan.FromSeconds(1));
}
[Fact]
public void RegisterExternalId_AllowsMultipleMappings()
{
_bridge.RegisterExternalId(AckSource.PagerDuty, "pd-1", "tenant-1", "incident-1");
_bridge.RegisterExternalId(AckSource.OpsGenie, "og-1", "tenant-1", "incident-2");
_bridge.RegisterExternalId(AckSource.PagerDuty, "pd-2", "tenant-2", "incident-3");
// Verify by trying to resolve (indirectly through ProcessAckAsync)
// This is validated by the ProcessAck_WithExternalId_ResolvesMapping test
}
[Fact]
public async Task ValidateToken_ReturnsExpiresAt()
{
var expiry = TimeSpan.FromHours(2);
var link = await _bridge.GenerateAckLinkAsync(
"tenant-1",
"incident-1",
"user-1",
expiry);
var token = ExtractToken(link);
var result = await _bridge.ValidateTokenAsync(token);
result.IsValid.Should().BeTrue();
result.ExpiresAt.Should().BeCloseTo(
_timeProvider.GetUtcNow().Add(expiry),
TimeSpan.FromSeconds(1));
}
private static string ExtractToken(string link)
{
var uri = new Uri(link);
var query = System.Web.HttpUtility.ParseQueryString(uri.Query);
return query["token"] ?? throw new InvalidOperationException("Token not found in URL");
}
}

View File

@@ -0,0 +1,317 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using Moq;
using StellaOps.Notifier.Worker.Correlation;
using StellaOps.Notifier.Worker.Escalation;
using Xunit;
namespace StellaOps.Notifier.WebService.Tests.Escalation;
/// <summary>
/// Tests for escalation engine.
/// </summary>
public sealed class EscalationEngineTests
{
private readonly FakeTimeProvider _timeProvider;
private readonly Mock<IEscalationPolicyService> _policyService;
private readonly Mock<IOnCallScheduleService> _scheduleService;
private readonly Mock<IIncidentManager> _incidentManager;
private readonly EscalationEngine _engine;
public EscalationEngineTests()
{
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 1, 15, 10, 0, 0, TimeSpan.Zero));
_policyService = new Mock<IEscalationPolicyService>();
_scheduleService = new Mock<IOnCallScheduleService>();
_incidentManager = new Mock<IIncidentManager>();
_engine = new EscalationEngine(
_policyService.Object,
_scheduleService.Object,
_incidentManager.Object,
null,
_timeProvider,
NullLogger<EscalationEngine>.Instance);
}
[Fact]
public async Task StartEscalation_WithValidPolicy_ReturnsStartedState()
{
var policy = CreateTestPolicy("tenant-1", "policy-1");
_policyService.Setup(x => x.GetPolicyAsync("tenant-1", "policy-1", It.IsAny<CancellationToken>()))
.ReturnsAsync(policy);
_scheduleService.Setup(x => x.GetOnCallAsync("tenant-1", "schedule-1", It.IsAny<DateTimeOffset?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync([new OnCallUser { UserId = "user-1", UserName = "Test User" }]);
var result = await _engine.StartEscalationAsync("tenant-1", "incident-1", "policy-1");
result.Should().NotBeNull();
result.TenantId.Should().Be("tenant-1");
result.IncidentId.Should().Be("incident-1");
result.PolicyId.Should().Be("policy-1");
result.Status.Should().Be(EscalationStatus.InProgress);
result.CurrentLevel.Should().Be(1);
}
[Fact]
public async Task StartEscalation_WithNonexistentPolicy_ReturnsFailedState()
{
_policyService.Setup(x => x.GetPolicyAsync("tenant-1", "nonexistent", It.IsAny<CancellationToken>()))
.ReturnsAsync((EscalationPolicy?)null);
var result = await _engine.StartEscalationAsync("tenant-1", "incident-1", "nonexistent");
result.Status.Should().Be(EscalationStatus.Failed);
result.ErrorMessage.Should().Contain("not found");
}
[Fact]
public async Task GetEscalationState_AfterStart_ReturnsState()
{
var policy = CreateTestPolicy("tenant-1", "policy-1");
_policyService.Setup(x => x.GetPolicyAsync("tenant-1", "policy-1", It.IsAny<CancellationToken>()))
.ReturnsAsync(policy);
_scheduleService.Setup(x => x.GetOnCallAsync("tenant-1", "schedule-1", It.IsAny<DateTimeOffset?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync([new OnCallUser { UserId = "user-1", UserName = "Test User" }]);
await _engine.StartEscalationAsync("tenant-1", "incident-1", "policy-1");
var result = await _engine.GetEscalationStateAsync("tenant-1", "incident-1");
result.Should().NotBeNull();
result!.IncidentId.Should().Be("incident-1");
}
[Fact]
public async Task GetEscalationState_WhenNotStarted_ReturnsNull()
{
var result = await _engine.GetEscalationStateAsync("tenant-1", "nonexistent");
result.Should().BeNull();
}
[Fact]
public async Task ProcessAcknowledgment_StopsEscalation()
{
var policy = CreateTestPolicy("tenant-1", "policy-1");
_policyService.Setup(x => x.GetPolicyAsync("tenant-1", "policy-1", It.IsAny<CancellationToken>()))
.ReturnsAsync(policy);
_scheduleService.Setup(x => x.GetOnCallAsync("tenant-1", "schedule-1", It.IsAny<DateTimeOffset?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync([new OnCallUser { UserId = "user-1", UserName = "Test User" }]);
await _engine.StartEscalationAsync("tenant-1", "incident-1", "policy-1");
var result = await _engine.ProcessAcknowledgmentAsync("tenant-1", "incident-1", "user-1");
result.Should().NotBeNull();
result!.Status.Should().Be(EscalationStatus.Acknowledged);
result.AcknowledgedBy.Should().Be("user-1");
result.AcknowledgedAt.Should().Be(_timeProvider.GetUtcNow());
}
[Fact]
public async Task ProcessAcknowledgment_WhenNotFound_ReturnsNull()
{
var result = await _engine.ProcessAcknowledgmentAsync("tenant-1", "nonexistent", "user-1");
result.Should().BeNull();
}
[Fact]
public async Task Escalate_MovesToNextLevel()
{
var policy = CreateTestPolicy("tenant-1", "policy-1");
_policyService.Setup(x => x.GetPolicyAsync("tenant-1", "policy-1", It.IsAny<CancellationToken>()))
.ReturnsAsync(policy);
_scheduleService.Setup(x => x.GetOnCallAsync("tenant-1", "schedule-1", It.IsAny<DateTimeOffset?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync([new OnCallUser { UserId = "user-1", UserName = "Test User" }]);
await _engine.StartEscalationAsync("tenant-1", "incident-1", "policy-1");
var result = await _engine.EscalateAsync("tenant-1", "incident-1", "Manual escalation", "admin");
result.Should().NotBeNull();
result!.CurrentLevel.Should().Be(2);
}
[Fact]
public async Task Escalate_AtMaxLevel_StartsNewCycle()
{
var policy = CreateTestPolicy("tenant-1", "policy-1");
_policyService.Setup(x => x.GetPolicyAsync("tenant-1", "policy-1", It.IsAny<CancellationToken>()))
.ReturnsAsync(policy);
_scheduleService.Setup(x => x.GetOnCallAsync("tenant-1", "schedule-1", It.IsAny<DateTimeOffset?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync([new OnCallUser { UserId = "user-1", UserName = "Test User" }]);
await _engine.StartEscalationAsync("tenant-1", "incident-1", "policy-1");
await _engine.EscalateAsync("tenant-1", "incident-1", "First escalation", "admin");
var result = await _engine.EscalateAsync("tenant-1", "incident-1", "Second escalation", "admin");
result.Should().NotBeNull();
result!.CurrentLevel.Should().Be(1);
result.CycleCount.Should().Be(2);
}
[Fact]
public async Task StopEscalation_SetsResolvedStatus()
{
var policy = CreateTestPolicy("tenant-1", "policy-1");
_policyService.Setup(x => x.GetPolicyAsync("tenant-1", "policy-1", It.IsAny<CancellationToken>()))
.ReturnsAsync(policy);
_scheduleService.Setup(x => x.GetOnCallAsync("tenant-1", "schedule-1", It.IsAny<DateTimeOffset?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync([new OnCallUser { UserId = "user-1", UserName = "Test User" }]);
await _engine.StartEscalationAsync("tenant-1", "incident-1", "policy-1");
var result = await _engine.StopEscalationAsync("tenant-1", "incident-1", "Incident resolved", "admin");
result.Should().BeTrue();
var state = await _engine.GetEscalationStateAsync("tenant-1", "incident-1");
state!.Status.Should().Be(EscalationStatus.Resolved);
}
[Fact]
public async Task ListActiveEscalations_ReturnsOnlyInProgress()
{
var policy = CreateTestPolicy("tenant-1", "policy-1");
_policyService.Setup(x => x.GetPolicyAsync("tenant-1", "policy-1", It.IsAny<CancellationToken>()))
.ReturnsAsync(policy);
_scheduleService.Setup(x => x.GetOnCallAsync("tenant-1", "schedule-1", It.IsAny<DateTimeOffset?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync([new OnCallUser { UserId = "user-1", UserName = "Test User" }]);
await _engine.StartEscalationAsync("tenant-1", "incident-1", "policy-1");
await _engine.StartEscalationAsync("tenant-1", "incident-2", "policy-1");
await _engine.ProcessAcknowledgmentAsync("tenant-1", "incident-1", "user-1");
var result = await _engine.ListActiveEscalationsAsync("tenant-1");
result.Should().HaveCount(1);
result[0].IncidentId.Should().Be("incident-2");
}
[Fact]
public async Task ProcessPendingEscalations_EscalatesOverdueItems()
{
var policy = CreateTestPolicy("tenant-1", "policy-1");
_policyService.Setup(x => x.GetPolicyAsync("tenant-1", "policy-1", It.IsAny<CancellationToken>()))
.ReturnsAsync(policy);
_scheduleService.Setup(x => x.GetOnCallAsync("tenant-1", "schedule-1", It.IsAny<DateTimeOffset?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync([new OnCallUser { UserId = "user-1", UserName = "Test User" }]);
await _engine.StartEscalationAsync("tenant-1", "incident-1", "policy-1");
// Advance time past the escalation delay (5 minutes)
_timeProvider.Advance(TimeSpan.FromMinutes(6));
var actions = await _engine.ProcessPendingEscalationsAsync();
actions.Should().NotBeEmpty();
actions[0].ActionType.Should().Be("Escalate");
actions[0].IncidentId.Should().Be("incident-1");
}
[Fact]
public async Task ProcessPendingEscalations_DoesNotEscalateBeforeDelay()
{
var policy = CreateTestPolicy("tenant-1", "policy-1");
_policyService.Setup(x => x.GetPolicyAsync("tenant-1", "policy-1", It.IsAny<CancellationToken>()))
.ReturnsAsync(policy);
_scheduleService.Setup(x => x.GetOnCallAsync("tenant-1", "schedule-1", It.IsAny<DateTimeOffset?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync([new OnCallUser { UserId = "user-1", UserName = "Test User" }]);
await _engine.StartEscalationAsync("tenant-1", "incident-1", "policy-1");
// Advance time but not past the delay (5 minutes)
_timeProvider.Advance(TimeSpan.FromMinutes(3));
var actions = await _engine.ProcessPendingEscalationsAsync();
actions.Should().BeEmpty();
}
[Fact]
public async Task StartEscalation_ResolvesOnCallTargets()
{
var policy = CreateTestPolicy("tenant-1", "policy-1");
_policyService.Setup(x => x.GetPolicyAsync("tenant-1", "policy-1", It.IsAny<CancellationToken>()))
.ReturnsAsync(policy);
_scheduleService.Setup(x => x.GetOnCallAsync("tenant-1", "schedule-1", It.IsAny<DateTimeOffset?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync([
new OnCallUser { UserId = "user-1", UserName = "User One", Email = "user1@example.com" },
new OnCallUser { UserId = "user-2", UserName = "User Two", Email = "user2@example.com" }
]);
var result = await _engine.StartEscalationAsync("tenant-1", "incident-1", "policy-1");
result.ResolvedTargets.Should().HaveCount(2);
result.ResolvedTargets.Should().Contain(t => t.UserId == "user-1");
result.ResolvedTargets.Should().Contain(t => t.UserId == "user-2");
}
[Fact]
public async Task StartEscalation_RecordsHistoryEntry()
{
var policy = CreateTestPolicy("tenant-1", "policy-1");
_policyService.Setup(x => x.GetPolicyAsync("tenant-1", "policy-1", It.IsAny<CancellationToken>()))
.ReturnsAsync(policy);
_scheduleService.Setup(x => x.GetOnCallAsync("tenant-1", "schedule-1", It.IsAny<DateTimeOffset?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync([new OnCallUser { UserId = "user-1", UserName = "Test User" }]);
var result = await _engine.StartEscalationAsync("tenant-1", "incident-1", "policy-1");
result.History.Should().HaveCount(1);
result.History[0].Action.Should().Be("Started");
result.History[0].Level.Should().Be(1);
}
private static EscalationPolicy CreateTestPolicy(string tenantId, string policyId) => new()
{
PolicyId = policyId,
TenantId = tenantId,
Name = "Default Escalation",
IsDefault = true,
Levels =
[
new EscalationLevel
{
Order = 1,
DelayMinutes = 5,
Targets =
[
new EscalationTarget
{
TargetType = EscalationTargetType.OnCallSchedule,
TargetId = "schedule-1"
}
]
},
new EscalationLevel
{
Order = 2,
DelayMinutes = 15,
Targets =
[
new EscalationTarget
{
TargetType = EscalationTargetType.User,
TargetId = "manager-1"
}
]
}
]
};
}

View File

@@ -0,0 +1,254 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Notifier.Worker.Escalation;
using Xunit;
namespace StellaOps.Notifier.WebService.Tests.Escalation;
/// <summary>
/// Tests for escalation policy service.
/// </summary>
public sealed class EscalationPolicyServiceTests
{
private readonly FakeTimeProvider _timeProvider;
private readonly InMemoryEscalationPolicyService _service;
public EscalationPolicyServiceTests()
{
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 1, 15, 10, 0, 0, TimeSpan.Zero));
_service = new InMemoryEscalationPolicyService(
null,
_timeProvider,
NullLogger<InMemoryEscalationPolicyService>.Instance);
}
[Fact]
public async Task ListPolicies_WhenEmpty_ReturnsEmptyList()
{
var result = await _service.ListPoliciesAsync("tenant-1");
result.Should().BeEmpty();
}
[Fact]
public async Task UpsertPolicy_CreatesNewPolicy()
{
var policy = CreateTestPolicy("tenant-1", "policy-1");
var result = await _service.UpsertPolicyAsync(policy, "admin");
result.PolicyId.Should().Be("policy-1");
result.TenantId.Should().Be("tenant-1");
result.Name.Should().Be("Default Escalation");
result.Levels.Should().HaveCount(2);
}
[Fact]
public async Task UpsertPolicy_UpdatesExistingPolicy()
{
var policy = CreateTestPolicy("tenant-1", "policy-1");
await _service.UpsertPolicyAsync(policy, "admin");
var updated = policy with { Name = "Updated Policy" };
var result = await _service.UpsertPolicyAsync(updated, "admin");
result.Name.Should().Be("Updated Policy");
var retrieved = await _service.GetPolicyAsync("tenant-1", "policy-1");
retrieved!.Name.Should().Be("Updated Policy");
}
[Fact]
public async Task GetPolicy_WhenExists_ReturnsPolicy()
{
var policy = CreateTestPolicy("tenant-1", "policy-1");
await _service.UpsertPolicyAsync(policy, "admin");
var result = await _service.GetPolicyAsync("tenant-1", "policy-1");
result.Should().NotBeNull();
result!.PolicyId.Should().Be("policy-1");
}
[Fact]
public async Task GetPolicy_WhenNotExists_ReturnsNull()
{
var result = await _service.GetPolicyAsync("tenant-1", "nonexistent");
result.Should().BeNull();
}
[Fact]
public async Task DeletePolicy_WhenExists_ReturnsTrue()
{
var policy = CreateTestPolicy("tenant-1", "policy-1");
await _service.UpsertPolicyAsync(policy, "admin");
var result = await _service.DeletePolicyAsync("tenant-1", "policy-1", "admin");
result.Should().BeTrue();
var retrieved = await _service.GetPolicyAsync("tenant-1", "policy-1");
retrieved.Should().BeNull();
}
[Fact]
public async Task DeletePolicy_WhenNotExists_ReturnsFalse()
{
var result = await _service.DeletePolicyAsync("tenant-1", "nonexistent", "admin");
result.Should().BeFalse();
}
[Fact]
public async Task GetDefaultPolicy_ReturnsFirstDefaultPolicy()
{
var policy1 = CreateTestPolicy("tenant-1", "policy-1") with { IsDefault = false };
var policy2 = CreateTestPolicy("tenant-1", "policy-2") with { IsDefault = true };
var policy3 = CreateTestPolicy("tenant-1", "policy-3") with { IsDefault = true };
await _service.UpsertPolicyAsync(policy1, "admin");
await _service.UpsertPolicyAsync(policy2, "admin");
await _service.UpsertPolicyAsync(policy3, "admin");
var result = await _service.GetDefaultPolicyAsync("tenant-1");
result.Should().NotBeNull();
result!.IsDefault.Should().BeTrue();
}
[Fact]
public async Task GetDefaultPolicy_WhenNoneDefault_ReturnsNull()
{
var policy = CreateTestPolicy("tenant-1", "policy-1") with { IsDefault = false };
await _service.UpsertPolicyAsync(policy, "admin");
var result = await _service.GetDefaultPolicyAsync("tenant-1");
result.Should().BeNull();
}
[Fact]
public async Task FindMatchingPolicies_FiltersByEventKind()
{
var policy1 = CreateTestPolicy("tenant-1", "policy-1") with
{
EventKindFilter = ["scan.*", "vulnerability.*"]
};
var policy2 = CreateTestPolicy("tenant-1", "policy-2") with
{
EventKindFilter = ["compliance.*"]
};
await _service.UpsertPolicyAsync(policy1, "admin");
await _service.UpsertPolicyAsync(policy2, "admin");
var result = await _service.FindMatchingPoliciesAsync("tenant-1", "scan.completed", null);
result.Should().HaveCount(1);
result[0].PolicyId.Should().Be("policy-1");
}
[Fact]
public async Task FindMatchingPolicies_FiltersBySeverity()
{
var policy1 = CreateTestPolicy("tenant-1", "policy-1") with
{
SeverityFilter = ["critical", "high"]
};
var policy2 = CreateTestPolicy("tenant-1", "policy-2") with
{
SeverityFilter = ["low"]
};
await _service.UpsertPolicyAsync(policy1, "admin");
await _service.UpsertPolicyAsync(policy2, "admin");
var result = await _service.FindMatchingPoliciesAsync("tenant-1", "incident.created", "critical");
result.Should().HaveCount(1);
result[0].PolicyId.Should().Be("policy-1");
}
[Fact]
public async Task FindMatchingPolicies_ReturnsAllWhenNoFilters()
{
var policy1 = CreateTestPolicy("tenant-1", "policy-1");
var policy2 = CreateTestPolicy("tenant-1", "policy-2");
await _service.UpsertPolicyAsync(policy1, "admin");
await _service.UpsertPolicyAsync(policy2, "admin");
var result = await _service.FindMatchingPoliciesAsync("tenant-1", "any.event", null);
result.Should().HaveCount(2);
}
[Fact]
public async Task ListPolicies_IsolatesByTenant()
{
var policy1 = CreateTestPolicy("tenant-1", "policy-1");
var policy2 = CreateTestPolicy("tenant-2", "policy-2");
await _service.UpsertPolicyAsync(policy1, "admin");
await _service.UpsertPolicyAsync(policy2, "admin");
var tenant1Policies = await _service.ListPoliciesAsync("tenant-1");
var tenant2Policies = await _service.ListPoliciesAsync("tenant-2");
tenant1Policies.Should().HaveCount(1);
tenant1Policies[0].PolicyId.Should().Be("policy-1");
tenant2Policies.Should().HaveCount(1);
tenant2Policies[0].PolicyId.Should().Be("policy-2");
}
[Fact]
public async Task UpsertPolicy_SetsTimestamps()
{
var policy = CreateTestPolicy("tenant-1", "policy-1");
var result = await _service.UpsertPolicyAsync(policy, "admin");
result.CreatedAt.Should().Be(_timeProvider.GetUtcNow());
result.UpdatedAt.Should().Be(_timeProvider.GetUtcNow());
}
private static EscalationPolicy CreateTestPolicy(string tenantId, string policyId) => new()
{
PolicyId = policyId,
TenantId = tenantId,
Name = "Default Escalation",
IsDefault = true,
Levels =
[
new EscalationLevel
{
Order = 1,
DelayMinutes = 5,
Targets =
[
new EscalationTarget
{
TargetType = EscalationTargetType.OnCallSchedule,
TargetId = "schedule-1"
}
]
},
new EscalationLevel
{
Order = 2,
DelayMinutes = 15,
Targets =
[
new EscalationTarget
{
TargetType = EscalationTargetType.User,
TargetId = "manager-1"
}
]
}
]
};
}

View File

@@ -0,0 +1,356 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Notifier.Worker.Escalation;
using Xunit;
namespace StellaOps.Notifier.WebService.Tests.Escalation;
/// <summary>
/// Tests for inbox channel adapters.
/// </summary>
public sealed class InboxChannelTests
{
private readonly FakeTimeProvider _timeProvider;
private readonly InAppInboxChannel _inboxChannel;
private readonly CliNotificationChannel _cliChannel;
public InboxChannelTests()
{
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 1, 15, 10, 0, 0, TimeSpan.Zero));
_inboxChannel = new InAppInboxChannel(
null,
_timeProvider,
NullLogger<InAppInboxChannel>.Instance);
_cliChannel = new CliNotificationChannel(
_timeProvider,
NullLogger<CliNotificationChannel>.Instance);
}
[Fact]
public async Task InApp_SendAsync_CreatesNotification()
{
var notification = CreateTestNotification("tenant-1", "user-1", "notif-1");
var result = await _inboxChannel.SendAsync(notification);
result.Success.Should().BeTrue();
result.NotificationId.Should().Be("notif-1");
}
[Fact]
public async Task InApp_ListAsync_ReturnsNotifications()
{
var notification = CreateTestNotification("tenant-1", "user-1", "notif-1");
await _inboxChannel.SendAsync(notification);
var result = await _inboxChannel.ListAsync("tenant-1", "user-1");
result.Should().HaveCount(1);
result[0].NotificationId.Should().Be("notif-1");
}
[Fact]
public async Task InApp_ListAsync_FiltersUnread()
{
var notif1 = CreateTestNotification("tenant-1", "user-1", "notif-1");
var notif2 = CreateTestNotification("tenant-1", "user-1", "notif-2");
await _inboxChannel.SendAsync(notif1);
await _inboxChannel.SendAsync(notif2);
await _inboxChannel.MarkReadAsync("tenant-1", "user-1", "notif-1");
var result = await _inboxChannel.ListAsync("tenant-1", "user-1", new InboxQuery { IsRead = false });
result.Should().HaveCount(1);
result[0].NotificationId.Should().Be("notif-2");
}
[Fact]
public async Task InApp_ListAsync_FiltersByType()
{
var incident = CreateTestNotification("tenant-1", "user-1", "notif-1") with
{
Type = InboxNotificationType.Incident
};
var system = CreateTestNotification("tenant-1", "user-1", "notif-2") with
{
Type = InboxNotificationType.System
};
await _inboxChannel.SendAsync(incident);
await _inboxChannel.SendAsync(system);
var result = await _inboxChannel.ListAsync("tenant-1", "user-1",
new InboxQuery { Type = InboxNotificationType.Incident });
result.Should().HaveCount(1);
result[0].NotificationId.Should().Be("notif-1");
}
[Fact]
public async Task InApp_ListAsync_FiltersByMinPriority()
{
var low = CreateTestNotification("tenant-1", "user-1", "notif-1") with { Priority = InboxPriority.Low };
var high = CreateTestNotification("tenant-1", "user-1", "notif-2") with { Priority = InboxPriority.High };
var urgent = CreateTestNotification("tenant-1", "user-1", "notif-3") with { Priority = InboxPriority.Urgent };
await _inboxChannel.SendAsync(low);
await _inboxChannel.SendAsync(high);
await _inboxChannel.SendAsync(urgent);
var result = await _inboxChannel.ListAsync("tenant-1", "user-1",
new InboxQuery { MinPriority = InboxPriority.High });
result.Should().HaveCount(2);
result.Should().OnlyContain(n => n.Priority >= InboxPriority.High);
}
[Fact]
public async Task InApp_ListAsync_ExcludesExpired()
{
var active = CreateTestNotification("tenant-1", "user-1", "notif-1") with
{
ExpiresAt = _timeProvider.GetUtcNow().AddHours(1)
};
var expired = CreateTestNotification("tenant-1", "user-1", "notif-2") with
{
ExpiresAt = _timeProvider.GetUtcNow().AddHours(-1)
};
await _inboxChannel.SendAsync(active);
await _inboxChannel.SendAsync(expired);
var result = await _inboxChannel.ListAsync("tenant-1", "user-1");
result.Should().HaveCount(1);
result[0].NotificationId.Should().Be("notif-1");
}
[Fact]
public async Task InApp_ListAsync_IncludesExpiredWhenRequested()
{
var active = CreateTestNotification("tenant-1", "user-1", "notif-1") with
{
ExpiresAt = _timeProvider.GetUtcNow().AddHours(1)
};
var expired = CreateTestNotification("tenant-1", "user-1", "notif-2") with
{
ExpiresAt = _timeProvider.GetUtcNow().AddHours(-1)
};
await _inboxChannel.SendAsync(active);
await _inboxChannel.SendAsync(expired);
var result = await _inboxChannel.ListAsync("tenant-1", "user-1",
new InboxQuery { IncludeExpired = true });
result.Should().HaveCount(2);
}
[Fact]
public async Task InApp_ListAsync_RespectsLimit()
{
for (int i = 0; i < 10; i++)
{
await _inboxChannel.SendAsync(CreateTestNotification("tenant-1", "user-1", $"notif-{i}"));
}
var result = await _inboxChannel.ListAsync("tenant-1", "user-1",
new InboxQuery { Limit = 5 });
result.Should().HaveCount(5);
}
[Fact]
public async Task InApp_MarkReadAsync_MarksNotificationAsRead()
{
var notification = CreateTestNotification("tenant-1", "user-1", "notif-1");
await _inboxChannel.SendAsync(notification);
var result = await _inboxChannel.MarkReadAsync("tenant-1", "user-1", "notif-1");
result.Should().BeTrue();
var list = await _inboxChannel.ListAsync("tenant-1", "user-1");
list[0].IsRead.Should().BeTrue();
list[0].ReadAt.Should().Be(_timeProvider.GetUtcNow());
}
[Fact]
public async Task InApp_MarkReadAsync_ReturnsFalseForNonexistent()
{
var result = await _inboxChannel.MarkReadAsync("tenant-1", "user-1", "nonexistent");
result.Should().BeFalse();
}
[Fact]
public async Task InApp_MarkAllReadAsync_MarksAllAsRead()
{
await _inboxChannel.SendAsync(CreateTestNotification("tenant-1", "user-1", "notif-1"));
await _inboxChannel.SendAsync(CreateTestNotification("tenant-1", "user-1", "notif-2"));
await _inboxChannel.SendAsync(CreateTestNotification("tenant-1", "user-1", "notif-3"));
var result = await _inboxChannel.MarkAllReadAsync("tenant-1", "user-1");
result.Should().Be(3);
var unread = await _inboxChannel.GetUnreadCountAsync("tenant-1", "user-1");
unread.Should().Be(0);
}
[Fact]
public async Task InApp_GetUnreadCountAsync_ReturnsCorrectCount()
{
await _inboxChannel.SendAsync(CreateTestNotification("tenant-1", "user-1", "notif-1"));
await _inboxChannel.SendAsync(CreateTestNotification("tenant-1", "user-1", "notif-2"));
await _inboxChannel.SendAsync(CreateTestNotification("tenant-1", "user-1", "notif-3"));
await _inboxChannel.MarkReadAsync("tenant-1", "user-1", "notif-1");
var result = await _inboxChannel.GetUnreadCountAsync("tenant-1", "user-1");
result.Should().Be(2);
}
[Fact]
public async Task InApp_GetUnreadCountAsync_ExcludesExpired()
{
var active = CreateTestNotification("tenant-1", "user-1", "notif-1") with
{
ExpiresAt = _timeProvider.GetUtcNow().AddHours(1)
};
var expired = CreateTestNotification("tenant-1", "user-1", "notif-2") with
{
ExpiresAt = _timeProvider.GetUtcNow().AddHours(-1)
};
await _inboxChannel.SendAsync(active);
await _inboxChannel.SendAsync(expired);
var result = await _inboxChannel.GetUnreadCountAsync("tenant-1", "user-1");
result.Should().Be(1);
}
[Fact]
public async Task InApp_IsolatesByTenantAndUser()
{
await _inboxChannel.SendAsync(CreateTestNotification("tenant-1", "user-1", "notif-1"));
await _inboxChannel.SendAsync(CreateTestNotification("tenant-1", "user-2", "notif-2"));
await _inboxChannel.SendAsync(CreateTestNotification("tenant-2", "user-1", "notif-3"));
var tenant1User1 = await _inboxChannel.ListAsync("tenant-1", "user-1");
var tenant1User2 = await _inboxChannel.ListAsync("tenant-1", "user-2");
var tenant2User1 = await _inboxChannel.ListAsync("tenant-2", "user-1");
tenant1User1.Should().HaveCount(1).And.Contain(n => n.NotificationId == "notif-1");
tenant1User2.Should().HaveCount(1).And.Contain(n => n.NotificationId == "notif-2");
tenant2User1.Should().HaveCount(1).And.Contain(n => n.NotificationId == "notif-3");
}
[Fact]
public async Task InApp_ListAsync_SortsByPriorityAndCreatedAt()
{
var low = CreateTestNotification("tenant-1", "user-1", "notif-low") with { Priority = InboxPriority.Low };
await _inboxChannel.SendAsync(low);
_timeProvider.Advance(TimeSpan.FromMinutes(1));
var high = CreateTestNotification("tenant-1", "user-1", "notif-high") with { Priority = InboxPriority.High };
await _inboxChannel.SendAsync(high);
_timeProvider.Advance(TimeSpan.FromMinutes(1));
var urgent = CreateTestNotification("tenant-1", "user-1", "notif-urgent") with { Priority = InboxPriority.Urgent };
await _inboxChannel.SendAsync(urgent);
var result = await _inboxChannel.ListAsync("tenant-1", "user-1");
result[0].NotificationId.Should().Be("notif-urgent");
result[1].NotificationId.Should().Be("notif-high");
result[2].NotificationId.Should().Be("notif-low");
}
[Fact]
public async Task Cli_SendAsync_CreatesNotification()
{
var notification = CreateTestNotification("tenant-1", "user-1", "notif-1");
var result = await _cliChannel.SendAsync(notification);
result.Success.Should().BeTrue();
result.NotificationId.Should().Be("notif-1");
}
[Fact]
public async Task Cli_ListAsync_ReturnsNotifications()
{
var notification = CreateTestNotification("tenant-1", "user-1", "notif-1");
await _cliChannel.SendAsync(notification);
var result = await _cliChannel.ListAsync("tenant-1", "user-1");
result.Should().HaveCount(1);
}
[Fact]
public void Cli_FormatForCli_FormatsCorrectly()
{
var notification = new InboxNotification
{
NotificationId = "notif-1",
TenantId = "tenant-1",
UserId = "user-1",
Type = InboxNotificationType.Incident,
Title = "Critical Alert",
Body = "Server down",
Priority = InboxPriority.Urgent,
IsRead = false,
CreatedAt = new DateTimeOffset(2025, 1, 15, 10, 0, 0, TimeSpan.Zero)
};
var formatted = CliNotificationChannel.FormatForCli(notification);
formatted.Should().Contain("[!!!]");
formatted.Should().Contain("●");
formatted.Should().Contain("Critical Alert");
formatted.Should().Contain("Server down");
formatted.Should().Contain("2025-01-15");
}
[Fact]
public void Cli_FormatForCli_ShowsReadMarker()
{
var notification = new InboxNotification
{
NotificationId = "notif-1",
TenantId = "tenant-1",
UserId = "user-1",
Type = InboxNotificationType.Incident,
Title = "Alert",
Body = "Details",
Priority = InboxPriority.Normal,
IsRead = true,
CreatedAt = _timeProvider.GetUtcNow()
};
var formatted = CliNotificationChannel.FormatForCli(notification);
formatted.Should().NotContain("●");
formatted.Should().Contain("[*]");
}
private static InboxNotification CreateTestNotification(string tenantId, string userId, string notificationId) => new()
{
NotificationId = notificationId,
TenantId = tenantId,
UserId = userId,
Type = InboxNotificationType.Incident,
Title = "Test Alert",
Body = "This is a test notification",
Priority = InboxPriority.Normal
};
}