Add unit tests for ExceptionEvaluator, ExceptionEvent, ExceptionHistory, and ExceptionObject models
- Implemented comprehensive unit tests for the ExceptionEvaluator service, covering various scenarios including matching exceptions, environment checks, and evidence references. - Created tests for the ExceptionEvent model to validate event creation methods and ensure correct event properties. - Developed tests for the ExceptionHistory model to verify event count, order, and timestamps. - Added tests for the ExceptionObject domain model to ensure validity checks and property preservation for various fields.
This commit is contained in:
@@ -0,0 +1,466 @@
|
||||
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.Storage.Postgres.Repositories;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Storage.Postgres.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;
|
||||
|
||||
[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");
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByIdAsync_WhenNotExists_ShouldReturnNull()
|
||||
{
|
||||
// Act
|
||||
var fetched = await _repository.GetByIdAsync("EXC-NONEXISTENT");
|
||||
|
||||
// Assert
|
||||
fetched.Should().BeNull();
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
[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"));
|
||||
}
|
||||
|
||||
[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");
|
||||
}
|
||||
|
||||
[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");
|
||||
}
|
||||
|
||||
[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");
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
[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");
|
||||
}
|
||||
|
||||
[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();
|
||||
}
|
||||
|
||||
[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");
|
||||
}
|
||||
|
||||
[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");
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
[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().BeGreaterOrEqualTo(1); // At least the one expiring in 3 days
|
||||
}
|
||||
|
||||
[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");
|
||||
}
|
||||
|
||||
[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");
|
||||
}
|
||||
|
||||
[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");
|
||||
}
|
||||
|
||||
[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
|
||||
}
|
||||
@@ -0,0 +1,509 @@
|
||||
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.Storage.Postgres.Repositories;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Storage.Postgres.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for PostgresExceptionObjectRepository.
|
||||
/// Tests the new auditable exception objects with event sourcing.
|
||||
/// </summary>
|
||||
[Collection(PolicyPostgresCollection.Name)]
|
||||
public sealed class PostgresExceptionObjectRepositoryTests : IAsyncLifetime
|
||||
{
|
||||
private readonly PolicyPostgresFixture _fixture;
|
||||
private readonly PostgresExceptionObjectRepository _repository;
|
||||
private readonly Guid _tenantId = Guid.NewGuid();
|
||||
|
||||
public PostgresExceptionObjectRepositoryTests(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;
|
||||
|
||||
#region Create Tests
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_WithValidException_PersistsException()
|
||||
{
|
||||
// Arrange
|
||||
var exception = CreateVulnerabilityException("CVE-2024-12345");
|
||||
|
||||
// Act
|
||||
var created = await _repository.CreateAsync(exception, "creator@example.com");
|
||||
|
||||
// Assert
|
||||
created.Should().NotBeNull();
|
||||
created.ExceptionId.Should().Be(exception.ExceptionId);
|
||||
created.Version.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_RecordsCreatedEvent()
|
||||
{
|
||||
// Arrange
|
||||
var exception = CreateVulnerabilityException("CVE-2024-12345");
|
||||
|
||||
// Act
|
||||
await _repository.CreateAsync(exception, "creator@example.com");
|
||||
var history = await _repository.GetHistoryAsync(exception.ExceptionId);
|
||||
|
||||
// Assert
|
||||
history.Events.Should().HaveCount(1);
|
||||
history.Events[0].EventType.Should().Be(ExceptionEventType.Created);
|
||||
history.Events[0].ActorId.Should().Be("creator@example.com");
|
||||
history.Events[0].NewStatus.Should().Be(ExceptionStatus.Proposed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_WithClientInfo_IncludesInEvent()
|
||||
{
|
||||
// Arrange
|
||||
var exception = CreateVulnerabilityException("CVE-2024-12345");
|
||||
|
||||
// Act
|
||||
await _repository.CreateAsync(exception, "creator@example.com", "192.168.1.1");
|
||||
var history = await _repository.GetHistoryAsync(exception.ExceptionId);
|
||||
|
||||
// Assert
|
||||
history.Events[0].ClientInfo.Should().Be("192.168.1.1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_WithWrongVersion_ThrowsException()
|
||||
{
|
||||
// Arrange
|
||||
var exception = CreateVulnerabilityException("CVE-2024-12345") with { Version = 2 };
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ArgumentException>(
|
||||
() => _repository.CreateAsync(exception, "creator@example.com"));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetById Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetByIdAsync_WithExistingException_ReturnsException()
|
||||
{
|
||||
// Arrange
|
||||
var exception = CreateVulnerabilityException("CVE-2024-12345");
|
||||
await _repository.CreateAsync(exception, "creator@example.com");
|
||||
|
||||
// Act
|
||||
var fetched = await _repository.GetByIdAsync(exception.ExceptionId);
|
||||
|
||||
// Assert
|
||||
fetched.Should().NotBeNull();
|
||||
fetched!.ExceptionId.Should().Be(exception.ExceptionId);
|
||||
fetched.Scope.VulnerabilityId.Should().Be("CVE-2024-12345");
|
||||
fetched.Status.Should().Be(ExceptionStatus.Proposed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByIdAsync_WithNonExistingException_ReturnsNull()
|
||||
{
|
||||
// Act
|
||||
var fetched = await _repository.GetByIdAsync("EXC-NONEXISTENT");
|
||||
|
||||
// Assert
|
||||
fetched.Should().BeNull();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Update Tests
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_WithValidVersion_UpdatesException()
|
||||
{
|
||||
// Arrange
|
||||
var exception = CreateVulnerabilityException("CVE-2024-12345");
|
||||
await _repository.CreateAsync(exception, "creator@example.com");
|
||||
|
||||
var updated = exception with
|
||||
{
|
||||
Version = 2,
|
||||
Status = ExceptionStatus.Approved,
|
||||
UpdatedAt = DateTimeOffset.UtcNow,
|
||||
ApprovedAt = DateTimeOffset.UtcNow,
|
||||
ApproverIds = ["approver@example.com"]
|
||||
};
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_RecordsEvent()
|
||||
{
|
||||
// Arrange
|
||||
var exception = CreateVulnerabilityException("CVE-2024-12345");
|
||||
await _repository.CreateAsync(exception, "creator@example.com");
|
||||
|
||||
var updated = exception with
|
||||
{
|
||||
Version = 2,
|
||||
Status = ExceptionStatus.Approved,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
// Act
|
||||
await _repository.UpdateAsync(
|
||||
updated, ExceptionEventType.Approved, "approver@example.com", "Approved");
|
||||
|
||||
var history = await _repository.GetHistoryAsync(exception.ExceptionId);
|
||||
|
||||
// Assert
|
||||
history.Events.Should().HaveCount(2);
|
||||
history.Events[1].EventType.Should().Be(ExceptionEventType.Approved);
|
||||
history.Events[1].PreviousStatus.Should().Be(ExceptionStatus.Proposed);
|
||||
history.Events[1].NewStatus.Should().Be(ExceptionStatus.Approved);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_WithWrongVersion_ThrowsConcurrencyException()
|
||||
{
|
||||
// Arrange
|
||||
var exception = CreateVulnerabilityException("CVE-2024-12345");
|
||||
await _repository.CreateAsync(exception, "creator@example.com");
|
||||
|
||||
// Try to update with wrong version
|
||||
var updated = exception with
|
||||
{
|
||||
Version = 99, // Wrong version
|
||||
Status = ExceptionStatus.Approved,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ConcurrencyException>(
|
||||
() => _repository.UpdateAsync(updated, ExceptionEventType.Approved, "approver@example.com"));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Query Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetByFilterAsync_FiltersByStatus()
|
||||
{
|
||||
// Arrange
|
||||
var proposed = CreateVulnerabilityException("CVE-2024-001");
|
||||
var active = CreateVulnerabilityException("CVE-2024-002") with
|
||||
{
|
||||
ExceptionId = "EXC-ACTIVE",
|
||||
Status = ExceptionStatus.Active
|
||||
};
|
||||
|
||||
await _repository.CreateAsync(proposed, "creator");
|
||||
await _repository.CreateAsync(active, "creator");
|
||||
|
||||
// Act
|
||||
var results = await _repository.GetByFilterAsync(
|
||||
new ExceptionFilter { Status = ExceptionStatus.Proposed });
|
||||
|
||||
// Assert
|
||||
results.Should().HaveCount(1);
|
||||
results[0].ExceptionId.Should().Be(proposed.ExceptionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByFilterAsync_FiltersByVulnerabilityId()
|
||||
{
|
||||
// Arrange
|
||||
await _repository.CreateAsync(CreateVulnerabilityException("CVE-2024-001"), "creator");
|
||||
await _repository.CreateAsync(CreateVulnerabilityException("CVE-2024-002") with
|
||||
{
|
||||
ExceptionId = "EXC-002"
|
||||
}, "creator");
|
||||
|
||||
// Act
|
||||
var results = await _repository.GetByFilterAsync(
|
||||
new ExceptionFilter { VulnerabilityId = "CVE-2024-001" });
|
||||
|
||||
// Assert
|
||||
results.Should().HaveCount(1);
|
||||
results[0].Scope.VulnerabilityId.Should().Be("CVE-2024-001");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByFilterAsync_SupportsPagination()
|
||||
{
|
||||
// Arrange
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
await _repository.CreateAsync(CreateVulnerabilityException($"CVE-2024-{i:000}") with
|
||||
{
|
||||
ExceptionId = $"EXC-{i:000}"
|
||||
}, "creator");
|
||||
}
|
||||
|
||||
// Act
|
||||
var page1 = await _repository.GetByFilterAsync(new ExceptionFilter { Limit = 2, Offset = 0 });
|
||||
var page2 = await _repository.GetByFilterAsync(new ExceptionFilter { Limit = 2, Offset = 2 });
|
||||
|
||||
// Assert
|
||||
page1.Should().HaveCount(2);
|
||||
page2.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetActiveByScopeAsync_FindsMatchingActiveExceptions()
|
||||
{
|
||||
// Arrange
|
||||
var exception = CreateVulnerabilityException("CVE-2024-12345") with
|
||||
{
|
||||
Status = ExceptionStatus.Active
|
||||
};
|
||||
await _repository.CreateAsync(exception, "creator");
|
||||
|
||||
// Act
|
||||
var scope = new ExceptionScope { VulnerabilityId = "CVE-2024-12345" };
|
||||
var results = await _repository.GetActiveByScopeAsync(scope);
|
||||
|
||||
// Assert
|
||||
results.Should().HaveCount(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetActiveByScopeAsync_ExcludesExpiredExceptions()
|
||||
{
|
||||
// Arrange
|
||||
var expiredException = CreateVulnerabilityException("CVE-2024-12345") with
|
||||
{
|
||||
Status = ExceptionStatus.Active,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddDays(-1) // Already expired
|
||||
};
|
||||
await _repository.CreateAsync(expiredException, "creator");
|
||||
|
||||
// Act
|
||||
var scope = new ExceptionScope { VulnerabilityId = "CVE-2024-12345" };
|
||||
var results = await _repository.GetActiveByScopeAsync(scope);
|
||||
|
||||
// Assert
|
||||
results.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetExpiringAsync_FindsExceptionsWithinHorizon()
|
||||
{
|
||||
// Arrange
|
||||
var expiringSoon = CreateVulnerabilityException("CVE-2024-001") with
|
||||
{
|
||||
ExceptionId = "EXC-EXPIRING",
|
||||
Status = ExceptionStatus.Active,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddDays(3) // Within 7-day horizon
|
||||
};
|
||||
var expiresLater = CreateVulnerabilityException("CVE-2024-002") with
|
||||
{
|
||||
ExceptionId = "EXC-LATER",
|
||||
Status = ExceptionStatus.Active,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddDays(30) // Outside horizon
|
||||
};
|
||||
|
||||
await _repository.CreateAsync(expiringSoon, "creator");
|
||||
await _repository.CreateAsync(expiresLater, "creator");
|
||||
|
||||
// Act
|
||||
var results = await _repository.GetExpiringAsync(TimeSpan.FromDays(7));
|
||||
|
||||
// Assert
|
||||
results.Should().HaveCount(1);
|
||||
results[0].ExceptionId.Should().Be("EXC-EXPIRING");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetExpiredActiveAsync_FindsExpiredActiveExceptions()
|
||||
{
|
||||
// Arrange
|
||||
var expiredActive = CreateVulnerabilityException("CVE-2024-001") with
|
||||
{
|
||||
ExceptionId = "EXC-EXPIRED-ACTIVE",
|
||||
Status = ExceptionStatus.Active,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddDays(-1)
|
||||
};
|
||||
var validActive = CreateVulnerabilityException("CVE-2024-002") with
|
||||
{
|
||||
ExceptionId = "EXC-VALID-ACTIVE",
|
||||
Status = ExceptionStatus.Active,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddDays(30)
|
||||
};
|
||||
|
||||
await _repository.CreateAsync(expiredActive, "creator");
|
||||
await _repository.CreateAsync(validActive, "creator");
|
||||
|
||||
// Act
|
||||
var results = await _repository.GetExpiredActiveAsync();
|
||||
|
||||
// Assert
|
||||
results.Should().HaveCount(1);
|
||||
results[0].ExceptionId.Should().Be("EXC-EXPIRED-ACTIVE");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region History and Counts Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetHistoryAsync_ReturnsChronologicalEvents()
|
||||
{
|
||||
// Arrange
|
||||
var exception = CreateVulnerabilityException("CVE-2024-12345");
|
||||
await _repository.CreateAsync(exception, "creator");
|
||||
|
||||
// Approve
|
||||
var approved = exception with
|
||||
{
|
||||
Version = 2,
|
||||
Status = ExceptionStatus.Approved,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
await _repository.UpdateAsync(approved, ExceptionEventType.Approved, "approver");
|
||||
|
||||
// Activate
|
||||
var activated = approved with
|
||||
{
|
||||
Version = 3,
|
||||
Status = ExceptionStatus.Active,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
await _repository.UpdateAsync(activated, ExceptionEventType.Activated, "system");
|
||||
|
||||
// Act
|
||||
var history = await _repository.GetHistoryAsync(exception.ExceptionId);
|
||||
|
||||
// Assert
|
||||
history.Events.Should().HaveCount(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);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetHistoryAsync_ForNonExistent_ReturnsEmptyHistory()
|
||||
{
|
||||
// Act
|
||||
var history = await _repository.GetHistoryAsync("EXC-NONEXISTENT");
|
||||
|
||||
// Assert
|
||||
history.ExceptionId.Should().Be("EXC-NONEXISTENT");
|
||||
history.Events.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCountsAsync_ReturnsCorrectCounts()
|
||||
{
|
||||
// Arrange
|
||||
await _repository.CreateAsync(CreateVulnerabilityException("CVE-001") with
|
||||
{
|
||||
ExceptionId = "EXC-1",
|
||||
Status = ExceptionStatus.Proposed
|
||||
}, "creator");
|
||||
|
||||
await _repository.CreateAsync(CreateVulnerabilityException("CVE-002") with
|
||||
{
|
||||
ExceptionId = "EXC-2",
|
||||
Status = ExceptionStatus.Active,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddDays(30)
|
||||
}, "creator");
|
||||
|
||||
await _repository.CreateAsync(CreateVulnerabilityException("CVE-003") with
|
||||
{
|
||||
ExceptionId = "EXC-3",
|
||||
Status = ExceptionStatus.Active,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddDays(3) // Expiring soon
|
||||
}, "creator");
|
||||
|
||||
// Act
|
||||
var counts = await _repository.GetCountsAsync();
|
||||
|
||||
// Assert
|
||||
counts.Total.Should().Be(3);
|
||||
counts.Proposed.Should().Be(1);
|
||||
counts.Active.Should().Be(2);
|
||||
counts.ExpiringSoon.Should().Be(1);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Concurrent Update Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ConcurrentUpdates_FailsWithConcurrencyException()
|
||||
{
|
||||
// Arrange
|
||||
var exception = CreateVulnerabilityException("CVE-2024-12345");
|
||||
await _repository.CreateAsync(exception, "creator");
|
||||
|
||||
// Simulate concurrent updates by updating twice with same version
|
||||
var update1 = exception with
|
||||
{
|
||||
Version = 2,
|
||||
Status = ExceptionStatus.Approved,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
// First update succeeds
|
||||
await _repository.UpdateAsync(update1, ExceptionEventType.Approved, "approver1");
|
||||
|
||||
// Second update with same expected version should fail
|
||||
var update2 = exception with
|
||||
{
|
||||
Version = 2, // Still expecting version 1
|
||||
Status = ExceptionStatus.Revoked,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ConcurrencyException>(
|
||||
() => _repository.UpdateAsync(update2, ExceptionEventType.Revoked, "approver2"));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Test Helpers
|
||||
|
||||
private ExceptionObject CreateVulnerabilityException(string vulnerabilityId) => new()
|
||||
{
|
||||
ExceptionId = $"EXC-{Guid.NewGuid():N}"[..20],
|
||||
Version = 1,
|
||||
Status = ExceptionStatus.Proposed,
|
||||
Type = ExceptionType.Vulnerability,
|
||||
Scope = new ExceptionScope
|
||||
{
|
||||
VulnerabilityId = vulnerabilityId,
|
||||
TenantId = _tenantId
|
||||
},
|
||||
OwnerId = "security-team",
|
||||
RequesterId = "developer@example.com",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddDays(90),
|
||||
ReasonCode = ExceptionReason.AcceptedRisk,
|
||||
Rationale = "This vulnerability is accepted due to compensating controls in place that mitigate the risk."
|
||||
};
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -27,6 +27,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Policy.Storage.Postgres\StellaOps.Policy.Storage.Postgres.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Policy.Exceptions\StellaOps.Policy.Exceptions.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Infrastructure.Postgres.Testing\StellaOps.Infrastructure.Postgres.Testing.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.Policy.Scoring\StellaOps.Policy.Scoring.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
Reference in New Issue
Block a user