Files
git.stella-ops.org/src/Policy/__Tests/StellaOps.Policy.Persistence.Tests/ExceptionObjectRepositoryTests.cs

488 lines
18 KiB
C#

using System.Collections.Immutable;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
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;
/// <summary>
/// Integration tests for PostgresExceptionObjectRepository.
/// Tests the new auditable exception objects against PostgreSQL.
/// </summary>
[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<PolicyDataSource>.Instance);
_repository = new PostgresExceptionObjectRepository(dataSource, NullLogger<PostgresExceptionObjectRepository>.Instance);
}
public Task InitializeAsync() => _fixture.TruncateAllTablesAsync();
public Task DisposeAsync() => Task.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<ConcurrencyException>(() =>
_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<string, string>.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 ExceptionObject CreateException(
string exceptionId,
ExceptionStatus status = ExceptionStatus.Proposed,
ExceptionType type = ExceptionType.Vulnerability,
string vulnerabilityId = "CVE-2024-12345",
DateTimeOffset? expiresAt = null,
ImmutableDictionary<string, string>? metadata = null,
ImmutableArray<string>? evidenceRefs = null,
ImmutableArray<string>? compensatingControls = null,
ImmutableArray<string>? environments = null)
{
return new ExceptionObject
{
ExceptionId = exceptionId,
Version = 1,
Status = status,
Type = type,
Scope = new ExceptionScope
{
VulnerabilityId = vulnerabilityId,
Environments = environments ?? []
},
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<string, string>.Empty
};
}
#endregion
}