using System.Collections.Immutable; using FluentAssertions; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using StellaOps.Determinism; using StellaOps.Policy.Exceptions.Models; using StellaOps.Policy.Exceptions.Repositories; using StellaOps.Policy.Persistence.Postgres; using StellaOps.Policy.Persistence.Postgres.Repositories; using Xunit; using StellaOps.TestKit; namespace StellaOps.Policy.Persistence.Tests; /// /// Integration tests for PostgresExceptionObjectRepository. /// Tests the new auditable exception objects against PostgreSQL. /// [Collection(PolicyPostgresCollection.Name)] public sealed class ExceptionObjectRepositoryTests : IAsyncLifetime { private readonly PolicyPostgresFixture _fixture; private readonly PostgresExceptionObjectRepository _repository; public ExceptionObjectRepositoryTests(PolicyPostgresFixture fixture) { _fixture = fixture; var options = fixture.Fixture.CreateOptions(); options.SchemaName = fixture.SchemaName; var dataSource = new PolicyDataSource(Options.Create(options), NullLogger.Instance); _repository = new PostgresExceptionObjectRepository(dataSource, NullLogger.Instance, TimeProvider.System, SystemGuidProvider.Instance); } public ValueTask InitializeAsync() => new(_fixture.TruncateAllTablesAsync()); public ValueTask DisposeAsync() => ValueTask.CompletedTask; [Trait("Category", TestCategories.Unit)] [Fact] public async Task CreateAsync_ShouldPersistExceptionAndCreateEvent() { // Arrange var exception = CreateException("EXC-CREATE-001"); // Act var created = await _repository.CreateAsync(exception, "test-actor", "127.0.0.1"); // Assert created.Should().NotBeNull(); created.ExceptionId.Should().Be("EXC-CREATE-001"); created.Version.Should().Be(1); // Verify event was created var history = await _repository.GetHistoryAsync("EXC-CREATE-001"); history.Events.Should().HaveCount(1); history.Events[0].EventType.Should().Be(ExceptionEventType.Created); history.Events[0].ActorId.Should().Be("test-actor"); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task GetByIdAsync_WhenExists_ShouldReturnException() { // Arrange var exception = CreateException("EXC-GETBYID-001"); await _repository.CreateAsync(exception, "test-actor"); // Act var fetched = await _repository.GetByIdAsync("EXC-GETBYID-001"); // Assert fetched.Should().NotBeNull(); fetched!.ExceptionId.Should().Be("EXC-GETBYID-001"); fetched.Status.Should().Be(ExceptionStatus.Proposed); fetched.Type.Should().Be(ExceptionType.Vulnerability); fetched.Scope.VulnerabilityId.Should().Be("CVE-2024-12345"); fetched.OwnerId.Should().Be("owner@example.com"); fetched.ReasonCode.Should().Be(ExceptionReason.AcceptedRisk); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task GetByIdAsync_WhenNotExists_ShouldReturnNull() { // Act var fetched = await _repository.GetByIdAsync("EXC-NONEXISTENT"); // Assert fetched.Should().BeNull(); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task UpdateAsync_ShouldIncrementVersionAndCreateEvent() { // Arrange var exception = CreateException("EXC-UPDATE-001"); await _repository.CreateAsync(exception, "creator"); var updated = exception with { Version = 2, Status = ExceptionStatus.Approved, ApprovedAt = DateTimeOffset.UtcNow, ApproverIds = ["approver@example.com"], UpdatedAt = DateTimeOffset.UtcNow }; // Act var result = await _repository.UpdateAsync(updated, ExceptionEventType.Approved, "approver@example.com", "Approved by security team"); // Assert result.Version.Should().Be(2); result.Status.Should().Be(ExceptionStatus.Approved); var fetched = await _repository.GetByIdAsync("EXC-UPDATE-001"); fetched!.Version.Should().Be(2); fetched.Status.Should().Be(ExceptionStatus.Approved); // Verify events var history = await _repository.GetHistoryAsync("EXC-UPDATE-001"); history.Events.Should().HaveCount(2); history.Events[1].EventType.Should().Be(ExceptionEventType.Approved); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task UpdateAsync_WithConcurrencyConflict_ShouldThrow() { // Arrange var exception = CreateException("EXC-CONCURRENCY-001"); await _repository.CreateAsync(exception, "creator"); // Simulate stale version var staleUpdate = exception with { Version = 5, // Wrong version - should be 2 UpdatedAt = DateTimeOffset.UtcNow }; // Act & Assert await Assert.ThrowsAsync(() => _repository.UpdateAsync(staleUpdate, ExceptionEventType.Updated, "updater")); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task GetByFilterAsync_ShouldFilterByStatus() { // Arrange var proposed = CreateException("EXC-FILTER-001", status: ExceptionStatus.Proposed); var active = CreateException("EXC-FILTER-002", status: ExceptionStatus.Active); var revoked = CreateException("EXC-FILTER-003", status: ExceptionStatus.Revoked); await _repository.CreateAsync(proposed, "actor"); await _repository.CreateAsync(active, "actor"); await _repository.CreateAsync(revoked, "actor"); // Act var filter = new ExceptionFilter { Status = ExceptionStatus.Proposed }; var results = await _repository.GetByFilterAsync(filter); // Assert results.Should().ContainSingle(); results[0].ExceptionId.Should().Be("EXC-FILTER-001"); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task GetByFilterAsync_ShouldFilterByType() { // Arrange var vuln = CreateException("EXC-TYPE-001", type: ExceptionType.Vulnerability); var policy = CreateException("EXC-TYPE-002", type: ExceptionType.Policy); await _repository.CreateAsync(vuln, "actor"); await _repository.CreateAsync(policy, "actor"); // Act var filter = new ExceptionFilter { Type = ExceptionType.Policy }; var results = await _repository.GetByFilterAsync(filter); // Assert results.Should().ContainSingle(); results[0].ExceptionId.Should().Be("EXC-TYPE-002"); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task GetByFilterAsync_ShouldFilterByVulnerabilityId() { // Arrange var exc1 = CreateException("EXC-VID-001", vulnerabilityId: "CVE-2024-11111"); var exc2 = CreateException("EXC-VID-002", vulnerabilityId: "CVE-2024-22222"); await _repository.CreateAsync(exc1, "actor"); await _repository.CreateAsync(exc2, "actor"); // Act var filter = new ExceptionFilter { VulnerabilityId = "CVE-2024-11111" }; var results = await _repository.GetByFilterAsync(filter); // Assert results.Should().ContainSingle(); results[0].ExceptionId.Should().Be("EXC-VID-001"); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task GetByFilterAsync_ShouldSupportPagination() { // Arrange for (int i = 0; i < 5; i++) { await _repository.CreateAsync(CreateException($"EXC-PAGE-{i:D3}"), "actor"); } // Act var filter = new ExceptionFilter { Limit = 2, Offset = 2 }; var results = await _repository.GetByFilterAsync(filter); // Assert results.Should().HaveCount(2); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task GetActiveByScopeAsync_ShouldMatchVulnerabilityId() { // Arrange var exception = CreateException("EXC-SCOPE-001", vulnerabilityId: "CVE-2024-99999", status: ExceptionStatus.Active); await _repository.CreateAsync(exception, "actor"); var scope = new ExceptionScope { VulnerabilityId = "CVE-2024-99999" }; // Act var results = await _repository.GetActiveByScopeAsync(scope); // Assert results.Should().ContainSingle(); results[0].ExceptionId.Should().Be("EXC-SCOPE-001"); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task GetActiveByScopeAsync_ShouldExcludeInactiveExceptions() { // Arrange var proposed = CreateException("EXC-INACTIVE-001", vulnerabilityId: "CVE-2024-88888", status: ExceptionStatus.Proposed); var revoked = CreateException("EXC-INACTIVE-002", vulnerabilityId: "CVE-2024-88888", status: ExceptionStatus.Revoked); await _repository.CreateAsync(proposed, "actor"); await _repository.CreateAsync(revoked, "actor"); var scope = new ExceptionScope { VulnerabilityId = "CVE-2024-88888" }; // Act var results = await _repository.GetActiveByScopeAsync(scope); // Assert results.Should().BeEmpty(); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task GetExpiringAsync_ShouldReturnExceptionsExpiringSoon() { // Arrange var expiringSoon = CreateException("EXC-EXPIRING-001", status: ExceptionStatus.Active, expiresAt: DateTimeOffset.UtcNow.AddDays(3)); var expiringLater = CreateException("EXC-EXPIRING-002", status: ExceptionStatus.Active, expiresAt: DateTimeOffset.UtcNow.AddDays(30)); await _repository.CreateAsync(expiringSoon, "actor"); await _repository.CreateAsync(expiringLater, "actor"); // Act var results = await _repository.GetExpiringAsync(TimeSpan.FromDays(7)); // Assert results.Should().ContainSingle(); results[0].ExceptionId.Should().Be("EXC-EXPIRING-001"); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task GetExpiredActiveAsync_ShouldReturnExpiredButActiveExceptions() { // Arrange - Create with past expiry var expired = CreateException("EXC-EXPIRED-001", status: ExceptionStatus.Active, expiresAt: DateTimeOffset.UtcNow.AddDays(-1)); await _repository.CreateAsync(expired, "actor"); // Act var results = await _repository.GetExpiredActiveAsync(); // Assert results.Should().ContainSingle(); results[0].ExceptionId.Should().Be("EXC-EXPIRED-001"); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task GetHistoryAsync_ShouldReturnEventsInOrder() { // Arrange var exception = CreateException("EXC-HISTORY-001"); await _repository.CreateAsync(exception, "creator"); var updated = exception with { Version = 2, Status = ExceptionStatus.Approved, ApprovedAt = DateTimeOffset.UtcNow, ApproverIds = ["approver@example.com"], UpdatedAt = DateTimeOffset.UtcNow }; await _repository.UpdateAsync(updated, ExceptionEventType.Approved, "approver"); var activated = updated with { Version = 3, Status = ExceptionStatus.Active, UpdatedAt = DateTimeOffset.UtcNow }; await _repository.UpdateAsync(activated, ExceptionEventType.Activated, "activator"); // Act var history = await _repository.GetHistoryAsync("EXC-HISTORY-001"); // Assert history.EventCount.Should().Be(3); history.Events[0].EventType.Should().Be(ExceptionEventType.Created); history.Events[1].EventType.Should().Be(ExceptionEventType.Approved); history.Events[2].EventType.Should().Be(ExceptionEventType.Activated); history.Events[0].SequenceNumber.Should().Be(1); history.Events[1].SequenceNumber.Should().Be(2); history.Events[2].SequenceNumber.Should().Be(3); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task GetCountsAsync_ShouldReturnCorrectCounts() { // Arrange await _repository.CreateAsync(CreateException("EXC-COUNT-001", status: ExceptionStatus.Proposed), "actor"); await _repository.CreateAsync(CreateException("EXC-COUNT-002", status: ExceptionStatus.Proposed), "actor"); await _repository.CreateAsync(CreateException("EXC-COUNT-003", status: ExceptionStatus.Active), "actor"); await _repository.CreateAsync(CreateException("EXC-COUNT-004", status: ExceptionStatus.Revoked), "actor"); await _repository.CreateAsync(CreateException("EXC-COUNT-005", status: ExceptionStatus.Active, expiresAt: DateTimeOffset.UtcNow.AddDays(3)), "actor"); // Act var counts = await _repository.GetCountsAsync(); // Assert counts.Total.Should().Be(5); counts.Proposed.Should().Be(2); counts.Active.Should().Be(2); counts.Revoked.Should().Be(1); counts.ExpiringSoon.Should().BeGreaterThanOrEqualTo(1); // At least the one expiring in 3 days } [Trait("Category", TestCategories.Unit)] [Fact] public async Task CreateAsync_WithMetadata_ShouldPersistCorrectly() { // Arrange var metadata = ImmutableDictionary.Empty .Add("team", "security") .Add("priority", "high") .Add("ticket", "SEC-123"); var exception = CreateException("EXC-META-001", metadata: metadata); // Act await _repository.CreateAsync(exception, "actor"); var fetched = await _repository.GetByIdAsync("EXC-META-001"); // Assert fetched!.Metadata.Should().HaveCount(3); fetched.Metadata["team"].Should().Be("security"); fetched.Metadata["priority"].Should().Be("high"); fetched.Metadata["ticket"].Should().Be("SEC-123"); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task CreateAsync_WithEvidenceRefs_ShouldPersistCorrectly() { // Arrange var evidenceRefs = ImmutableArray.Create( "sha256:evidence1", "sha256:evidence2", "https://evidence.example.com/doc1"); var exception = CreateException("EXC-EVIDENCE-001", evidenceRefs: evidenceRefs); // Act await _repository.CreateAsync(exception, "actor"); var fetched = await _repository.GetByIdAsync("EXC-EVIDENCE-001"); // Assert fetched!.EvidenceRefs.Should().HaveCount(3); fetched.EvidenceRefs.Should().Contain("sha256:evidence1"); fetched.EvidenceRefs.Should().Contain("https://evidence.example.com/doc1"); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task CreateAsync_WithCompensatingControls_ShouldPersistCorrectly() { // Arrange var controls = ImmutableArray.Create( "WAF blocking malicious patterns", "Network segmentation prevents lateral movement"); var exception = CreateException("EXC-CONTROLS-001", compensatingControls: controls); // Act await _repository.CreateAsync(exception, "actor"); var fetched = await _repository.GetByIdAsync("EXC-CONTROLS-001"); // Assert fetched!.CompensatingControls.Should().HaveCount(2); fetched.CompensatingControls.Should().Contain("WAF blocking malicious patterns"); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task CreateAsync_WithEnvironments_ShouldPersistCorrectly() { // Arrange var environments = ImmutableArray.Create("dev", "staging"); var exception = CreateException("EXC-ENV-001", environments: environments); // Act await _repository.CreateAsync(exception, "actor"); var fetched = await _repository.GetByIdAsync("EXC-ENV-001"); // Assert fetched!.Scope.Environments.Should().HaveCount(2); fetched.Scope.Environments.Should().Contain(["dev", "staging"]); } #region Test Helpers private static readonly Guid TestTenantId = Guid.Parse("11111111-1111-1111-1111-111111111111"); private static ExceptionObject CreateException( string exceptionId, ExceptionStatus status = ExceptionStatus.Proposed, ExceptionType type = ExceptionType.Vulnerability, string vulnerabilityId = "CVE-2024-12345", DateTimeOffset? expiresAt = null, ImmutableDictionary? metadata = null, ImmutableArray? evidenceRefs = null, ImmutableArray? compensatingControls = null, ImmutableArray? environments = null) { return new ExceptionObject { ExceptionId = exceptionId, Version = 1, Status = status, Type = type, Scope = new ExceptionScope { VulnerabilityId = vulnerabilityId, Environments = environments ?? [], TenantId = TestTenantId }, OwnerId = "owner@example.com", RequesterId = "requester@example.com", CreatedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTimeOffset.UtcNow, ExpiresAt = expiresAt ?? DateTimeOffset.UtcNow.AddDays(30), ReasonCode = ExceptionReason.AcceptedRisk, Rationale = "This is a test rationale that meets the minimum character requirement of 50 characters.", EvidenceRefs = evidenceRefs ?? [], CompensatingControls = compensatingControls ?? [], Metadata = metadata ?? ImmutableDictionary.Empty }; } #endregion }