Add integration tests for migration categories and execution
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled

- Implemented MigrationCategoryTests to validate migration categorization for startup, release, seed, and data migrations.
- Added tests for edge cases, including null, empty, and whitespace migration names.
- Created StartupMigrationHostTests to verify the behavior of the migration host with real PostgreSQL instances using Testcontainers.
- Included tests for migration execution, schema creation, and handling of pending release migrations.
- Added SQL migration files for testing: creating a test table, adding a column, a release migration, and seeding data.
This commit is contained in:
master
2025-12-04 19:10:54 +02:00
parent 600f3a7a3c
commit 75f6942769
301 changed files with 32810 additions and 1128 deletions

View File

@@ -0,0 +1,544 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Notify.Storage.Postgres.Models;
using StellaOps.Notify.Storage.Postgres.Repositories;
using Xunit;
namespace StellaOps.Notify.Storage.Postgres.Tests;
/// <summary>
/// End-to-end tests for digest aggregation (PG-T3.10.4).
/// Tests the complete digest lifecycle: collection → aggregation → sending → cleanup.
/// </summary>
[Collection(NotifyPostgresCollection.Name)]
public sealed class DigestAggregationTests : IAsyncLifetime
{
private readonly NotifyPostgresFixture _fixture;
private readonly DigestRepository _digestRepository;
private readonly ChannelRepository _channelRepository;
private readonly QuietHoursRepository _quietHoursRepository;
private readonly MaintenanceWindowRepository _maintenanceRepository;
private readonly string _tenantId = Guid.NewGuid().ToString();
public DigestAggregationTests(NotifyPostgresFixture fixture)
{
_fixture = fixture;
var options = fixture.Fixture.CreateOptions();
options.SchemaName = fixture.SchemaName;
var dataSource = new NotifyDataSource(Options.Create(options), NullLogger<NotifyDataSource>.Instance);
_digestRepository = new DigestRepository(dataSource, NullLogger<DigestRepository>.Instance);
_channelRepository = new ChannelRepository(dataSource, NullLogger<ChannelRepository>.Instance);
_quietHoursRepository = new QuietHoursRepository(dataSource, NullLogger<QuietHoursRepository>.Instance);
_maintenanceRepository = new MaintenanceWindowRepository(dataSource, NullLogger<MaintenanceWindowRepository>.Instance);
}
public Task InitializeAsync() => _fixture.TruncateAllTablesAsync();
public Task DisposeAsync() => Task.CompletedTask;
[Fact]
public async Task Digest_CompleteLifecycle_CollectingToSent()
{
// Arrange - Create channel
var channel = new ChannelEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = "daily-digest-channel",
ChannelType = ChannelType.Email,
Enabled = true
};
await _channelRepository.CreateAsync(channel);
// Create digest in collecting state
var digest = new DigestEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
ChannelId = channel.Id,
Recipient = "user@example.com",
DigestKey = "daily-vulnerabilities",
EventCount = 0,
Events = "[]",
Status = DigestStatus.Collecting,
CollectUntil = DateTimeOffset.UtcNow.AddHours(1)
};
await _digestRepository.UpsertAsync(digest);
// Act - Add events to digest
await _digestRepository.AddEventAsync(_tenantId, digest.Id, """{"type": "vuln.detected", "cve": "CVE-2025-0001"}""");
await _digestRepository.AddEventAsync(_tenantId, digest.Id, """{"type": "vuln.detected", "cve": "CVE-2025-0002"}""");
await _digestRepository.AddEventAsync(_tenantId, digest.Id, """{"type": "vuln.detected", "cve": "CVE-2025-0003"}""");
var afterEvents = await _digestRepository.GetByIdAsync(_tenantId, digest.Id);
afterEvents!.EventCount.Should().Be(3);
afterEvents.Events.Should().Contain("CVE-2025-0001");
afterEvents.Events.Should().Contain("CVE-2025-0002");
afterEvents.Events.Should().Contain("CVE-2025-0003");
// Transition to sending
await _digestRepository.MarkSendingAsync(_tenantId, digest.Id);
var sending = await _digestRepository.GetByIdAsync(_tenantId, digest.Id);
sending!.Status.Should().Be(DigestStatus.Sending);
// Transition to sent
await _digestRepository.MarkSentAsync(_tenantId, digest.Id);
var sent = await _digestRepository.GetByIdAsync(_tenantId, digest.Id);
sent!.Status.Should().Be(DigestStatus.Sent);
sent.SentAt.Should().NotBeNull();
}
[Fact]
public async Task Digest_GetReadyToSend_ReturnsExpiredCollectingDigests()
{
// Arrange
var channel = new ChannelEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = "ready-channel",
ChannelType = ChannelType.Email,
Enabled = true
};
await _channelRepository.CreateAsync(channel);
// Create digest that's ready (collect window passed)
var readyDigest = new DigestEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
ChannelId = channel.Id,
Recipient = "ready@example.com",
DigestKey = "ready-digest",
EventCount = 5,
Status = DigestStatus.Collecting,
CollectUntil = DateTimeOffset.UtcNow.AddMinutes(-5) // Past the collection window
};
// Create digest that's not ready (still collecting)
var notReadyDigest = new DigestEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
ChannelId = channel.Id,
Recipient = "notready@example.com",
DigestKey = "notready-digest",
EventCount = 3,
Status = DigestStatus.Collecting,
CollectUntil = DateTimeOffset.UtcNow.AddHours(1) // Still collecting
};
await _digestRepository.UpsertAsync(readyDigest);
await _digestRepository.UpsertAsync(notReadyDigest);
// Act
var ready = await _digestRepository.GetReadyToSendAsync();
// Assert
ready.Should().Contain(d => d.Id == readyDigest.Id);
ready.Should().NotContain(d => d.Id == notReadyDigest.Id);
}
[Fact]
public async Task Digest_GetByKey_ReturnsExistingDigest()
{
// Arrange
var channel = new ChannelEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = "key-channel",
ChannelType = ChannelType.Email,
Enabled = true
};
await _channelRepository.CreateAsync(channel);
var digestKey = "hourly-alerts";
var recipient = "alerts@example.com";
var digest = new DigestEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
ChannelId = channel.Id,
Recipient = recipient,
DigestKey = digestKey,
EventCount = 2,
Status = DigestStatus.Collecting,
CollectUntil = DateTimeOffset.UtcNow.AddHours(1)
};
await _digestRepository.UpsertAsync(digest);
// Act
var fetched = await _digestRepository.GetByKeyAsync(_tenantId, channel.Id, recipient, digestKey);
// Assert
fetched.Should().NotBeNull();
fetched!.Id.Should().Be(digest.Id);
fetched.DigestKey.Should().Be(digestKey);
}
[Fact]
public async Task Digest_Upsert_UpdatesExistingDigest()
{
// Arrange
var channel = new ChannelEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = "upsert-channel",
ChannelType = ChannelType.Email,
Enabled = true
};
await _channelRepository.CreateAsync(channel);
var digest = new DigestEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
ChannelId = channel.Id,
Recipient = "upsert@example.com",
DigestKey = "upsert-key",
EventCount = 1,
Events = """[{"event": 1}]""",
Status = DigestStatus.Collecting,
CollectUntil = DateTimeOffset.UtcNow.AddHours(1)
};
await _digestRepository.UpsertAsync(digest);
// Act - Upsert with updated collect window
var updated = new DigestEntity
{
Id = digest.Id,
TenantId = _tenantId,
ChannelId = channel.Id,
Recipient = "upsert@example.com",
DigestKey = "upsert-key",
EventCount = digest.EventCount,
Events = digest.Events,
Status = DigestStatus.Collecting,
CollectUntil = DateTimeOffset.UtcNow.AddHours(2) // Extended
};
await _digestRepository.UpsertAsync(updated);
// Assert
var fetched = await _digestRepository.GetByIdAsync(_tenantId, digest.Id);
fetched!.CollectUntil.Should().BeCloseTo(DateTimeOffset.UtcNow.AddHours(2), TimeSpan.FromMinutes(1));
}
[Fact]
public async Task Digest_DeleteOld_RemovesSentDigests()
{
// Arrange
var channel = new ChannelEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = "cleanup-channel",
ChannelType = ChannelType.Email,
Enabled = true
};
await _channelRepository.CreateAsync(channel);
// Create old sent digest
var oldDigest = new DigestEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
ChannelId = channel.Id,
Recipient = "old@example.com",
DigestKey = "old-digest",
Status = DigestStatus.Sent,
CollectUntil = DateTimeOffset.UtcNow.AddDays(-10)
};
await _digestRepository.UpsertAsync(oldDigest);
await _digestRepository.MarkSentAsync(_tenantId, oldDigest.Id);
// Create recent digest
var recentDigest = new DigestEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
ChannelId = channel.Id,
Recipient = "recent@example.com",
DigestKey = "recent-digest",
Status = DigestStatus.Collecting,
CollectUntil = DateTimeOffset.UtcNow.AddHours(1)
};
await _digestRepository.UpsertAsync(recentDigest);
// Act - Delete digests older than 7 days
var cutoff = DateTimeOffset.UtcNow.AddDays(-7);
var deleted = await _digestRepository.DeleteOldAsync(cutoff);
// Assert
deleted.Should().BeGreaterThanOrEqualTo(1);
var oldFetch = await _digestRepository.GetByIdAsync(_tenantId, oldDigest.Id);
oldFetch.Should().BeNull();
var recentFetch = await _digestRepository.GetByIdAsync(_tenantId, recentDigest.Id);
recentFetch.Should().NotBeNull();
}
[Fact]
public async Task Digest_MultipleRecipients_SeparateDigests()
{
// Arrange
var channel = new ChannelEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = "multi-recipient-channel",
ChannelType = ChannelType.Email,
Enabled = true
};
await _channelRepository.CreateAsync(channel);
var digestKey = "shared-key";
var digest1 = new DigestEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
ChannelId = channel.Id,
Recipient = "user1@example.com",
DigestKey = digestKey,
Status = DigestStatus.Collecting,
CollectUntil = DateTimeOffset.UtcNow.AddHours(1)
};
var digest2 = new DigestEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
ChannelId = channel.Id,
Recipient = "user2@example.com",
DigestKey = digestKey,
Status = DigestStatus.Collecting,
CollectUntil = DateTimeOffset.UtcNow.AddHours(1)
};
await _digestRepository.UpsertAsync(digest1);
await _digestRepository.UpsertAsync(digest2);
// Act
var fetched1 = await _digestRepository.GetByKeyAsync(_tenantId, channel.Id, "user1@example.com", digestKey);
var fetched2 = await _digestRepository.GetByKeyAsync(_tenantId, channel.Id, "user2@example.com", digestKey);
// Assert
fetched1.Should().NotBeNull();
fetched2.Should().NotBeNull();
fetched1!.Id.Should().NotBe(fetched2!.Id);
}
[Fact]
public async Task Digest_EventAccumulation_AppendsToArray()
{
// Arrange
var channel = new ChannelEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = "accumulate-channel",
ChannelType = ChannelType.Email,
Enabled = true
};
await _channelRepository.CreateAsync(channel);
var digest = new DigestEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
ChannelId = channel.Id,
Recipient = "accumulate@example.com",
DigestKey = "accumulate-key",
EventCount = 0,
Events = "[]",
Status = DigestStatus.Collecting,
CollectUntil = DateTimeOffset.UtcNow.AddHours(1)
};
await _digestRepository.UpsertAsync(digest);
// Act - Add 10 events
for (int i = 1; i <= 10; i++)
{
await _digestRepository.AddEventAsync(_tenantId, digest.Id, $$$"""{"id": {{{i}}}, "type": "scan.finding"}""");
}
// Assert
var fetched = await _digestRepository.GetByIdAsync(_tenantId, digest.Id);
fetched!.EventCount.Should().Be(10);
// Parse events JSON to verify all events are there
for (int i = 1; i <= 10; i++)
{
fetched.Events.Should().Contain($"\"id\": {i}");
}
}
[Fact]
public async Task Digest_QuietHoursIntegration_RespectsSilencePeriod()
{
// Arrange - Create quiet hours config
var userId = Guid.NewGuid();
var quietHours = new QuietHoursEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
UserId = userId,
StartTime = new TimeOnly(22, 0), // 10 PM
EndTime = new TimeOnly(7, 0), // 7 AM
Timezone = "UTC",
DaysOfWeek = [1, 2, 3, 4, 5], // Weekdays
Enabled = true
};
await _quietHoursRepository.CreateAsync(quietHours);
// Act
var fetched = await _quietHoursRepository.GetForUserAsync(_tenantId, userId);
// Assert
fetched.Should().ContainSingle();
fetched[0].StartTime.Should().Be(new TimeOnly(22, 0));
fetched[0].EndTime.Should().Be(new TimeOnly(7, 0));
fetched[0].Enabled.Should().BeTrue();
}
[Fact]
public async Task Digest_MaintenanceWindowIntegration_RespectsWindow()
{
// Arrange - Create maintenance window
var suppressChannel = Guid.NewGuid();
var window = new MaintenanceWindowEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = "weekly-maintenance",
Description = "Weekly system maintenance window",
StartAt = DateTimeOffset.UtcNow.AddDays(1),
EndAt = DateTimeOffset.UtcNow.AddDays(1).AddHours(2),
SuppressChannels = [suppressChannel],
SuppressEventTypes = ["scan.completed", "vulnerability.detected"]
};
await _maintenanceRepository.CreateAsync(window);
// Act
var active = await _maintenanceRepository.GetActiveAsync(_tenantId);
// Assert - No active windows right now since it's scheduled for tomorrow
active.Should().BeEmpty();
var all = await _maintenanceRepository.ListAsync(_tenantId);
all.Should().ContainSingle(w => w.Id == window.Id);
}
[Fact]
public async Task Digest_DeterministicOrdering_ConsistentResults()
{
// Arrange
var channel = new ChannelEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = "determinism-channel",
ChannelType = ChannelType.Email,
Enabled = true
};
await _channelRepository.CreateAsync(channel);
// Create multiple digests
for (int i = 0; i < 10; i++)
{
var digest = new DigestEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
ChannelId = channel.Id,
Recipient = $"user{i}@example.com",
DigestKey = $"key-{i}",
Status = DigestStatus.Collecting,
CollectUntil = DateTimeOffset.UtcNow.AddMinutes(-i) // All ready
};
await _digestRepository.UpsertAsync(digest);
}
// Act
var results1 = await _digestRepository.GetReadyToSendAsync(limit: 100);
var results2 = await _digestRepository.GetReadyToSendAsync(limit: 100);
var results3 = await _digestRepository.GetReadyToSendAsync(limit: 100);
// Assert
var ids1 = results1.Select(d => d.Id).ToList();
var ids2 = results2.Select(d => d.Id).ToList();
var ids3 = results3.Select(d => d.Id).ToList();
ids1.Should().Equal(ids2);
ids2.Should().Equal(ids3);
}
[Fact]
public async Task Digest_MultiTenantIsolation_NoLeakage()
{
// Arrange
var tenant1 = Guid.NewGuid().ToString();
var tenant2 = Guid.NewGuid().ToString();
var channel1 = new ChannelEntity
{
Id = Guid.NewGuid(),
TenantId = tenant1,
Name = "tenant1-channel",
ChannelType = ChannelType.Email,
Enabled = true
};
var channel2 = new ChannelEntity
{
Id = Guid.NewGuid(),
TenantId = tenant2,
Name = "tenant2-channel",
ChannelType = ChannelType.Email,
Enabled = true
};
await _channelRepository.CreateAsync(channel1);
await _channelRepository.CreateAsync(channel2);
var digest1 = new DigestEntity
{
Id = Guid.NewGuid(),
TenantId = tenant1,
ChannelId = channel1.Id,
Recipient = "user@tenant1.com",
DigestKey = "shared-key",
Status = DigestStatus.Collecting,
CollectUntil = DateTimeOffset.UtcNow.AddHours(1)
};
var digest2 = new DigestEntity
{
Id = Guid.NewGuid(),
TenantId = tenant2,
ChannelId = channel2.Id,
Recipient = "user@tenant2.com",
DigestKey = "shared-key",
Status = DigestStatus.Collecting,
CollectUntil = DateTimeOffset.UtcNow.AddHours(1)
};
await _digestRepository.UpsertAsync(digest1);
await _digestRepository.UpsertAsync(digest2);
// Act
var tenant1Fetch = await _digestRepository.GetByKeyAsync(tenant1, channel1.Id, "user@tenant1.com", "shared-key");
var tenant2Fetch = await _digestRepository.GetByKeyAsync(tenant2, channel2.Id, "user@tenant2.com", "shared-key");
// Cross-tenant attempts should fail
var crossFetch1 = await _digestRepository.GetByIdAsync(tenant1, digest2.Id);
var crossFetch2 = await _digestRepository.GetByIdAsync(tenant2, digest1.Id);
// Assert
tenant1Fetch.Should().NotBeNull();
tenant1Fetch!.TenantId.Should().Be(tenant1);
tenant2Fetch.Should().NotBeNull();
tenant2Fetch!.TenantId.Should().Be(tenant2);
crossFetch1.Should().BeNull();
crossFetch2.Should().BeNull();
}
}

View File

@@ -0,0 +1,469 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Notify.Storage.Postgres.Models;
using StellaOps.Notify.Storage.Postgres.Repositories;
using Xunit;
namespace StellaOps.Notify.Storage.Postgres.Tests;
/// <summary>
/// End-to-end tests for escalation handling (PG-T3.10.3).
/// Tests the complete escalation lifecycle: policy creation → state tracking → escalation progression → resolution.
/// </summary>
[Collection(NotifyPostgresCollection.Name)]
public sealed class EscalationHandlingTests : IAsyncLifetime
{
private readonly NotifyPostgresFixture _fixture;
private readonly EscalationPolicyRepository _policyRepository;
private readonly EscalationStateRepository _stateRepository;
private readonly OnCallScheduleRepository _onCallRepository;
private readonly IncidentRepository _incidentRepository;
private readonly string _tenantId = Guid.NewGuid().ToString();
public EscalationHandlingTests(NotifyPostgresFixture fixture)
{
_fixture = fixture;
var options = fixture.Fixture.CreateOptions();
options.SchemaName = fixture.SchemaName;
var dataSource = new NotifyDataSource(Options.Create(options), NullLogger<NotifyDataSource>.Instance);
_policyRepository = new EscalationPolicyRepository(dataSource, NullLogger<EscalationPolicyRepository>.Instance);
_stateRepository = new EscalationStateRepository(dataSource, NullLogger<EscalationStateRepository>.Instance);
_onCallRepository = new OnCallScheduleRepository(dataSource, NullLogger<OnCallScheduleRepository>.Instance);
_incidentRepository = new IncidentRepository(dataSource, NullLogger<IncidentRepository>.Instance);
}
public Task InitializeAsync() => _fixture.TruncateAllTablesAsync();
public Task DisposeAsync() => Task.CompletedTask;
[Fact]
public async Task Escalation_CompleteLifecycle_ActiveToResolved()
{
// Arrange - Create escalation policy with multiple steps
var policy = new EscalationPolicyEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = "critical-incident-policy",
Description = "Escalation policy for critical incidents",
Enabled = true,
Steps = """
[
{"step": 1, "delay_minutes": 5, "channels": ["email"], "targets": ["oncall-primary"]},
{"step": 2, "delay_minutes": 10, "channels": ["email", "sms"], "targets": ["oncall-secondary"]},
{"step": 3, "delay_minutes": 15, "channels": ["phone"], "targets": ["manager"]}
]
""",
RepeatCount = 2
};
await _policyRepository.CreateAsync(policy);
// Create incident
var incident = new IncidentEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Title = "Critical vulnerability detected",
Severity = "critical",
Status = IncidentStatus.Open,
CorrelationId = Guid.NewGuid().ToString()
};
await _incidentRepository.CreateAsync(incident);
// Act - Start escalation
var escalationState = new EscalationStateEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
PolicyId = policy.Id,
IncidentId = incident.Id,
CorrelationId = incident.CorrelationId,
CurrentStep = 1,
Status = EscalationStatus.Active,
StartedAt = DateTimeOffset.UtcNow,
NextEscalationAt = DateTimeOffset.UtcNow.AddMinutes(5)
};
await _stateRepository.CreateAsync(escalationState);
// Verify active
var active = await _stateRepository.GetActiveAsync();
active.Should().Contain(s => s.Id == escalationState.Id);
// Escalate to step 2
await _stateRepository.EscalateAsync(
_tenantId,
escalationState.Id,
newStep: 2,
nextEscalationAt: DateTimeOffset.UtcNow.AddMinutes(10));
var afterStep2 = await _stateRepository.GetByIdAsync(_tenantId, escalationState.Id);
afterStep2!.CurrentStep.Should().Be(2);
afterStep2.Status.Should().Be(EscalationStatus.Active);
// Acknowledge
await _stateRepository.AcknowledgeAsync(_tenantId, escalationState.Id, "oncall@example.com");
var acknowledged = await _stateRepository.GetByIdAsync(_tenantId, escalationState.Id);
acknowledged!.Status.Should().Be(EscalationStatus.Acknowledged);
acknowledged.AcknowledgedBy.Should().Be("oncall@example.com");
acknowledged.AcknowledgedAt.Should().NotBeNull();
// Resolve
await _stateRepository.ResolveAsync(_tenantId, escalationState.Id, "responder@example.com");
var resolved = await _stateRepository.GetByIdAsync(_tenantId, escalationState.Id);
resolved!.Status.Should().Be(EscalationStatus.Resolved);
resolved.ResolvedBy.Should().Be("responder@example.com");
resolved.ResolvedAt.Should().NotBeNull();
// No longer in active list
var finalActive = await _stateRepository.GetActiveAsync();
finalActive.Should().NotContain(s => s.Id == escalationState.Id);
}
[Fact]
public async Task Escalation_MultiStepProgression_TracksCorrectly()
{
// Arrange
var policy = new EscalationPolicyEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = "multi-step-policy",
Enabled = true,
Steps = """[{"step": 1}, {"step": 2}, {"step": 3}, {"step": 4}]""",
RepeatCount = 0
};
await _policyRepository.CreateAsync(policy);
var state = new EscalationStateEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
PolicyId = policy.Id,
CorrelationId = Guid.NewGuid().ToString(),
CurrentStep = 1,
Status = EscalationStatus.Active,
StartedAt = DateTimeOffset.UtcNow
};
await _stateRepository.CreateAsync(state);
// Act - Progress through all steps
for (int step = 2; step <= 4; step++)
{
await _stateRepository.EscalateAsync(
_tenantId,
state.Id,
newStep: step,
nextEscalationAt: DateTimeOffset.UtcNow.AddMinutes(step * 5));
var current = await _stateRepository.GetByIdAsync(_tenantId, state.Id);
current!.CurrentStep.Should().Be(step);
}
// Assert final state
var final = await _stateRepository.GetByIdAsync(_tenantId, state.Id);
final!.CurrentStep.Should().Be(4);
final.Status.Should().Be(EscalationStatus.Active);
}
[Fact]
public async Task Escalation_GetByCorrelation_RetrievesCorrectState()
{
// Arrange
var policy = new EscalationPolicyEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = "correlation-test-policy",
Enabled = true
};
await _policyRepository.CreateAsync(policy);
var correlationId = $"incident-{Guid.NewGuid():N}";
var state = new EscalationStateEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
PolicyId = policy.Id,
CorrelationId = correlationId,
CurrentStep = 1,
Status = EscalationStatus.Active,
StartedAt = DateTimeOffset.UtcNow
};
await _stateRepository.CreateAsync(state);
// Act
var fetched = await _stateRepository.GetByCorrelationIdAsync(_tenantId, correlationId);
// Assert
fetched.Should().NotBeNull();
fetched!.Id.Should().Be(state.Id);
fetched.CorrelationId.Should().Be(correlationId);
}
[Fact]
public async Task Escalation_OnCallScheduleIntegration_FindsCorrectResponder()
{
// Arrange - Create on-call schedules
var primarySchedule = new OnCallScheduleEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = "primary-oncall",
RotationType = RotationType.Weekly,
Participants = """["alice@example.com", "bob@example.com"]""",
Timezone = "UTC"
};
var secondarySchedule = new OnCallScheduleEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = "secondary-oncall",
RotationType = RotationType.Weekly,
Participants = """["charlie@example.com", "diana@example.com"]""",
Timezone = "UTC"
};
await _onCallRepository.CreateAsync(primarySchedule);
await _onCallRepository.CreateAsync(secondarySchedule);
// Act
var primary = await _onCallRepository.GetByNameAsync(_tenantId, "primary-oncall");
var secondary = await _onCallRepository.GetByNameAsync(_tenantId, "secondary-oncall");
// Assert
primary.Should().NotBeNull();
primary!.Participants.Should().Contain("alice@example.com");
secondary.Should().NotBeNull();
secondary!.Participants.Should().Contain("charlie@example.com");
}
[Fact]
public async Task Escalation_MultipleActiveStates_AllTracked()
{
// Arrange
var policy = new EscalationPolicyEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = "multi-active-policy",
Enabled = true
};
await _policyRepository.CreateAsync(policy);
// Create multiple active escalations
var states = new List<EscalationStateEntity>();
for (int i = 0; i < 5; i++)
{
var state = new EscalationStateEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
PolicyId = policy.Id,
CorrelationId = $"incident-{i}-{Guid.NewGuid():N}",
CurrentStep = 1,
Status = EscalationStatus.Active,
StartedAt = DateTimeOffset.UtcNow.AddMinutes(-i)
};
await _stateRepository.CreateAsync(state);
states.Add(state);
}
// Act
var active = await _stateRepository.GetActiveAsync(limit: 100);
// Assert
foreach (var state in states)
{
active.Should().Contain(s => s.Id == state.Id);
}
}
[Fact]
public async Task Escalation_PolicyDisabled_NotUsed()
{
// Arrange
var enabledPolicy = new EscalationPolicyEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = "enabled-policy",
Enabled = true
};
var disabledPolicy = new EscalationPolicyEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = "disabled-policy",
Enabled = false
};
await _policyRepository.CreateAsync(enabledPolicy);
await _policyRepository.CreateAsync(disabledPolicy);
// Act
var allPolicies = await _policyRepository.ListAsync(_tenantId);
var enabledOnly = allPolicies.Where(p => p.Enabled).ToList();
// Assert
allPolicies.Should().HaveCount(2);
enabledOnly.Should().ContainSingle(p => p.Id == enabledPolicy.Id);
}
[Fact]
public async Task Escalation_IncidentLinking_TracksAssociation()
{
// Arrange
var policy = new EscalationPolicyEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = "incident-linked-policy",
Enabled = true
};
await _policyRepository.CreateAsync(policy);
var incident = new IncidentEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Title = "Security Breach",
Severity = "critical",
Status = IncidentStatus.Open,
CorrelationId = Guid.NewGuid().ToString()
};
await _incidentRepository.CreateAsync(incident);
var state = new EscalationStateEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
PolicyId = policy.Id,
IncidentId = incident.Id,
CorrelationId = incident.CorrelationId,
CurrentStep = 1,
Status = EscalationStatus.Active,
StartedAt = DateTimeOffset.UtcNow
};
await _stateRepository.CreateAsync(state);
// Act
var fetched = await _stateRepository.GetByIdAsync(_tenantId, state.Id);
// Assert
fetched.Should().NotBeNull();
fetched!.IncidentId.Should().Be(incident.Id);
}
[Fact]
public async Task Escalation_RepeatIteration_TracksRepeats()
{
// Arrange
var policy = new EscalationPolicyEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = "repeating-policy",
Enabled = true,
RepeatCount = 3
};
await _policyRepository.CreateAsync(policy);
// Act - Create state at repeat iteration 2
var state = new EscalationStateEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
PolicyId = policy.Id,
CorrelationId = Guid.NewGuid().ToString(),
CurrentStep = 1,
RepeatIteration = 2,
Status = EscalationStatus.Active,
StartedAt = DateTimeOffset.UtcNow
};
await _stateRepository.CreateAsync(state);
// Assert
var fetched = await _stateRepository.GetByIdAsync(_tenantId, state.Id);
fetched!.RepeatIteration.Should().Be(2);
}
[Fact]
public async Task Escalation_DeterministicOrdering_ConsistentResults()
{
// Arrange
var policy = new EscalationPolicyEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = "determinism-policy",
Enabled = true
};
await _policyRepository.CreateAsync(policy);
// Create multiple active escalations
for (int i = 0; i < 10; i++)
{
await _stateRepository.CreateAsync(new EscalationStateEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
PolicyId = policy.Id,
CorrelationId = $"det-{i}-{Guid.NewGuid():N}",
CurrentStep = 1,
Status = EscalationStatus.Active,
StartedAt = DateTimeOffset.UtcNow.AddSeconds(i)
});
}
// Act
var results1 = await _stateRepository.GetActiveAsync(limit: 100);
var results2 = await _stateRepository.GetActiveAsync(limit: 100);
var results3 = await _stateRepository.GetActiveAsync(limit: 100);
// Assert
var ids1 = results1.Select(s => s.Id).ToList();
var ids2 = results2.Select(s => s.Id).ToList();
var ids3 = results3.Select(s => s.Id).ToList();
ids1.Should().Equal(ids2);
ids2.Should().Equal(ids3);
}
[Fact]
public async Task Escalation_Metadata_PreservedThroughLifecycle()
{
// Arrange
var policy = new EscalationPolicyEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = "metadata-policy",
Enabled = true,
Metadata = """{"severity_levels": ["low", "medium", "high", "critical"]}"""
};
await _policyRepository.CreateAsync(policy);
var state = new EscalationStateEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
PolicyId = policy.Id,
CorrelationId = Guid.NewGuid().ToString(),
CurrentStep = 1,
Status = EscalationStatus.Active,
StartedAt = DateTimeOffset.UtcNow,
Metadata = """{"original_severity": "critical", "source": "scanner"}"""
};
await _stateRepository.CreateAsync(state);
// Act
await _stateRepository.EscalateAsync(_tenantId, state.Id, 2, DateTimeOffset.UtcNow.AddMinutes(5));
await _stateRepository.AcknowledgeAsync(_tenantId, state.Id, "responder");
await _stateRepository.ResolveAsync(_tenantId, state.Id, "responder");
// Assert
var final = await _stateRepository.GetByIdAsync(_tenantId, state.Id);
final!.Metadata.Should().Contain("original_severity");
final.Metadata.Should().Contain("scanner");
}
}

View File

@@ -0,0 +1,405 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Notify.Storage.Postgres.Models;
using StellaOps.Notify.Storage.Postgres.Repositories;
using Xunit;
namespace StellaOps.Notify.Storage.Postgres.Tests;
/// <summary>
/// End-to-end tests for notification delivery flow (PG-T3.10.2).
/// Tests the complete lifecycle: channel creation → rule matching → template rendering → delivery tracking.
/// </summary>
[Collection(NotifyPostgresCollection.Name)]
public sealed class NotificationDeliveryFlowTests : IAsyncLifetime
{
private readonly NotifyPostgresFixture _fixture;
private readonly ChannelRepository _channelRepository;
private readonly RuleRepository _ruleRepository;
private readonly TemplateRepository _templateRepository;
private readonly DeliveryRepository _deliveryRepository;
private readonly NotifyAuditRepository _auditRepository;
private readonly string _tenantId = Guid.NewGuid().ToString();
public NotificationDeliveryFlowTests(NotifyPostgresFixture fixture)
{
_fixture = fixture;
var options = fixture.Fixture.CreateOptions();
options.SchemaName = fixture.SchemaName;
var dataSource = new NotifyDataSource(Options.Create(options), NullLogger<NotifyDataSource>.Instance);
_channelRepository = new ChannelRepository(dataSource, NullLogger<ChannelRepository>.Instance);
_ruleRepository = new RuleRepository(dataSource, NullLogger<RuleRepository>.Instance);
_templateRepository = new TemplateRepository(dataSource, NullLogger<TemplateRepository>.Instance);
_deliveryRepository = new DeliveryRepository(dataSource, NullLogger<DeliveryRepository>.Instance);
_auditRepository = new NotifyAuditRepository(dataSource, NullLogger<NotifyAuditRepository>.Instance);
}
public Task InitializeAsync() => _fixture.TruncateAllTablesAsync();
public Task DisposeAsync() => Task.CompletedTask;
[Fact]
public async Task DeliveryFlow_CompleteLifecycle_PendingToDelivered()
{
// Arrange - Create channel
var channel = new ChannelEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = "email-notifications",
ChannelType = ChannelType.Email,
Config = """{"smtp_host": "smtp.example.com", "from": "noreply@example.com"}""",
Enabled = true
};
await _channelRepository.CreateAsync(channel);
// Create template
var template = new TemplateEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = "scan-complete",
ChannelType = ChannelType.Email,
SubjectTemplate = "Scan Complete: {{scan_name}}",
BodyTemplate = "Your scan {{scan_name}} has completed with {{finding_count}} findings.",
Locale = "en"
};
await _templateRepository.CreateAsync(template);
// Create rule
var rule = new RuleEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = "scan-completed-rule",
ChannelIds = [channel.Id],
TemplateId = template.Id,
EventTypes = ["scan.completed"],
Filter = """{"severity": ["high", "critical"]}""",
Enabled = true,
Priority = 100
};
await _ruleRepository.CreateAsync(rule);
// Act - Create delivery
var delivery = new DeliveryEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
ChannelId = channel.Id,
RuleId = rule.Id,
TemplateId = template.Id,
Recipient = "security@example.com",
EventType = "scan.completed",
EventPayload = "{\"scan_name\": \"weekly-scan\", \"finding_count\": 42}",
Subject = "Scan Complete: weekly-scan",
Body = "Your scan weekly-scan has completed with 42 findings.",
CorrelationId = Guid.NewGuid().ToString(),
Status = DeliveryStatus.Pending
};
await _deliveryRepository.CreateAsync(delivery);
// Verify pending
var pending = await _deliveryRepository.GetPendingAsync(_tenantId);
pending.Should().ContainSingle(d => d.Id == delivery.Id);
// Progress through lifecycle: Pending → Queued
await _deliveryRepository.MarkQueuedAsync(_tenantId, delivery.Id);
var queued = await _deliveryRepository.GetByIdAsync(_tenantId, delivery.Id);
queued!.Status.Should().Be(DeliveryStatus.Queued);
queued.QueuedAt.Should().NotBeNull();
// Queued → Sent
await _deliveryRepository.MarkSentAsync(_tenantId, delivery.Id, "smtp-msg-12345");
var sent = await _deliveryRepository.GetByIdAsync(_tenantId, delivery.Id);
sent!.Status.Should().Be(DeliveryStatus.Sent);
sent.ExternalId.Should().Be("smtp-msg-12345");
sent.SentAt.Should().NotBeNull();
// Sent → Delivered
await _deliveryRepository.MarkDeliveredAsync(_tenantId, delivery.Id);
var delivered = await _deliveryRepository.GetByIdAsync(_tenantId, delivery.Id);
delivered!.Status.Should().Be(DeliveryStatus.Delivered);
delivered.DeliveredAt.Should().NotBeNull();
// Verify no longer in pending queue
var finalPending = await _deliveryRepository.GetPendingAsync(_tenantId);
finalPending.Should().NotContain(d => d.Id == delivery.Id);
}
[Fact]
public async Task DeliveryFlow_FailureAndRetry_TracksErrorState()
{
// Arrange
var channel = new ChannelEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = "slack-channel",
ChannelType = ChannelType.Slack,
Config = """{"webhook_url": "https://hooks.slack.com/services/xxx"}""",
Enabled = true
};
await _channelRepository.CreateAsync(channel);
var delivery = new DeliveryEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
ChannelId = channel.Id,
Recipient = "#security-alerts",
EventType = "vulnerability.detected",
Status = DeliveryStatus.Pending
};
await _deliveryRepository.CreateAsync(delivery);
// Act - Mark as failed with retry
await _deliveryRepository.MarkFailedAsync(_tenantId, delivery.Id, "Connection refused", TimeSpan.FromMinutes(5));
// Assert
var failed = await _deliveryRepository.GetByIdAsync(_tenantId, delivery.Id);
failed!.Status.Should().Be(DeliveryStatus.Failed);
failed.ErrorMessage.Should().Be("Connection refused");
failed.FailedAt.Should().NotBeNull();
failed.NextRetryAt.Should().NotBeNull();
failed.Attempt.Should().BeGreaterThanOrEqualTo(1);
}
[Fact]
public async Task DeliveryFlow_MultipleChannels_IndependentDeliveries()
{
// Arrange - Create two channels
var emailChannel = new ChannelEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = "email",
ChannelType = ChannelType.Email,
Enabled = true
};
var slackChannel = new ChannelEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = "slack",
ChannelType = ChannelType.Slack,
Enabled = true
};
await _channelRepository.CreateAsync(emailChannel);
await _channelRepository.CreateAsync(slackChannel);
var correlationId = Guid.NewGuid().ToString();
// Create deliveries for both channels with same correlation
var emailDelivery = new DeliveryEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
ChannelId = emailChannel.Id,
Recipient = "user@example.com",
EventType = "alert",
CorrelationId = correlationId,
Status = DeliveryStatus.Pending
};
var slackDelivery = new DeliveryEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
ChannelId = slackChannel.Id,
Recipient = "#alerts",
EventType = "alert",
CorrelationId = correlationId,
Status = DeliveryStatus.Pending
};
await _deliveryRepository.CreateAsync(emailDelivery);
await _deliveryRepository.CreateAsync(slackDelivery);
// Act - Email succeeds, Slack fails
await _deliveryRepository.MarkSentAsync(_tenantId, emailDelivery.Id);
await _deliveryRepository.MarkDeliveredAsync(_tenantId, emailDelivery.Id);
await _deliveryRepository.MarkFailedAsync(_tenantId, slackDelivery.Id, "Rate limited");
// Assert - Both tracked via correlation
var correlated = await _deliveryRepository.GetByCorrelationIdAsync(_tenantId, correlationId);
correlated.Should().HaveCount(2);
var email = correlated.First(d => d.ChannelId == emailChannel.Id);
var slack = correlated.First(d => d.ChannelId == slackChannel.Id);
email.Status.Should().Be(DeliveryStatus.Delivered);
slack.Status.Should().Be(DeliveryStatus.Failed);
}
[Fact]
public async Task DeliveryFlow_StatsAccumulation_CorrectAggregates()
{
// Arrange
var channel = new ChannelEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = "stats-channel",
ChannelType = ChannelType.Email,
Enabled = true
};
await _channelRepository.CreateAsync(channel);
// Create multiple deliveries in different states
// Note: DeliveryStats tracks Pending, Sent, Delivered, Failed, Bounced (no Queued)
var deliveries = new[]
{
(DeliveryStatus.Pending, (string?)null),
(DeliveryStatus.Pending, (string?)null),
(DeliveryStatus.Pending, (string?)null),
(DeliveryStatus.Sent, (string?)null),
(DeliveryStatus.Sent, (string?)null),
(DeliveryStatus.Sent, (string?)null),
(DeliveryStatus.Delivered, (string?)null),
(DeliveryStatus.Delivered, (string?)null),
(DeliveryStatus.Failed, "Error 1"),
(DeliveryStatus.Failed, "Error 2")
};
foreach (var (status, error) in deliveries)
{
var d = new DeliveryEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
ChannelId = channel.Id,
Recipient = "user@example.com",
EventType = "test",
Status = status,
ErrorMessage = error
};
await _deliveryRepository.CreateAsync(d);
}
// Act
var from = DateTimeOffset.UtcNow.AddHours(-1);
var to = DateTimeOffset.UtcNow.AddHours(1);
var stats = await _deliveryRepository.GetStatsAsync(_tenantId, from, to);
// Assert
stats.Total.Should().Be(10);
stats.Pending.Should().Be(3);
stats.Sent.Should().Be(3);
stats.Delivered.Should().Be(2);
stats.Failed.Should().Be(2);
}
[Fact]
public async Task DeliveryFlow_DeterministicOrdering_ConsistentResults()
{
// Arrange
var channel = new ChannelEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = "determinism-channel",
ChannelType = ChannelType.Email,
Enabled = true
};
await _channelRepository.CreateAsync(channel);
// Create multiple pending deliveries
for (int i = 0; i < 10; i++)
{
await _deliveryRepository.CreateAsync(new DeliveryEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
ChannelId = channel.Id,
Recipient = $"user{i}@example.com",
EventType = "test",
Status = DeliveryStatus.Pending
});
}
// Act - Query multiple times
var results1 = await _deliveryRepository.GetPendingAsync(_tenantId);
var results2 = await _deliveryRepository.GetPendingAsync(_tenantId);
var results3 = await _deliveryRepository.GetPendingAsync(_tenantId);
// Assert - Order should be deterministic
var ids1 = results1.Select(d => d.Id).ToList();
var ids2 = results2.Select(d => d.Id).ToList();
var ids3 = results3.Select(d => d.Id).ToList();
ids1.Should().Equal(ids2);
ids2.Should().Equal(ids3);
}
[Fact]
public async Task DeliveryFlow_AuditTrail_RecordsActions()
{
// Arrange
var channel = new ChannelEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = "audited-channel",
ChannelType = ChannelType.Email,
Enabled = true
};
await _channelRepository.CreateAsync(channel);
// Act - Record audit events
await _auditRepository.CreateAsync(new NotifyAuditEntity
{
TenantId = _tenantId,
Action = "channel.created",
ResourceType = "channel",
ResourceId = channel.Id.ToString(),
UserId = null,
Details = "{\"name\": \"audited-channel\"}"
});
await _auditRepository.CreateAsync(new NotifyAuditEntity
{
TenantId = _tenantId,
Action = "delivery.sent",
ResourceType = "delivery",
ResourceId = Guid.NewGuid().ToString(),
Details = "{\"recipient\": \"user@example.com\"}"
});
// Assert
var audits = await _auditRepository.GetByResourceAsync(_tenantId, "channel", channel.Id.ToString());
audits.Should().ContainSingle();
audits[0].Action.Should().Be("channel.created");
}
[Fact]
public async Task DeliveryFlow_DisabledChannel_NotQueried()
{
// Arrange
var enabledChannel = new ChannelEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = "enabled",
ChannelType = ChannelType.Email,
Enabled = true
};
var disabledChannel = new ChannelEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = "disabled",
ChannelType = ChannelType.Email,
Enabled = false
};
await _channelRepository.CreateAsync(enabledChannel);
await _channelRepository.CreateAsync(disabledChannel);
// Act - Get all channels filtered by enabled=true
var enabled = await _channelRepository.GetAllAsync(_tenantId, enabled: true);
// Assert
enabled.Should().ContainSingle(c => c.Id == enabledChannel.Id);
enabled.Should().NotContain(c => c.Id == disabledChannel.Id);
}
}