using System.Collections.Immutable; using FluentAssertions; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Moq; using StellaOps.Policy.Engine.Adapters; using StellaOps.Policy.Engine.Evaluation; using StellaOps.Policy.Exceptions.Models; using StellaOps.Policy.Exceptions.Repositories; using Xunit; namespace StellaOps.Policy.Engine.Tests.Adapters; /// /// Unit tests for ExceptionAdapter. /// public sealed class ExceptionAdapterTests : IDisposable { private readonly Mock _repositoryMock; private readonly IExceptionEffectRegistry _effectRegistry; private readonly IMemoryCache _cache; private readonly ExceptionAdapterOptions _options; private readonly ExceptionAdapter _adapter; private readonly Guid _tenantId; public ExceptionAdapterTests() { _repositoryMock = new Mock(); _effectRegistry = new ExceptionEffectRegistry(); _cache = new MemoryCache(new MemoryCacheOptions()); _options = new ExceptionAdapterOptions { CacheTtl = TimeSpan.FromSeconds(60), EnableCaching = true, MaxExceptionsPerTenant = 10000 }; _tenantId = Guid.NewGuid(); _adapter = new ExceptionAdapter( _repositoryMock.Object, _effectRegistry, _cache, Microsoft.Extensions.Options.Options.Create(_options), TimeProvider.System, NullLogger.Instance); } public void Dispose() { _cache.Dispose(); } [Fact] public async Task LoadExceptionsAsync_ReturnsEmpty_WhenNoExceptionsExist() { // Arrange _repositoryMock .Setup(r => r.GetActiveByScopeAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(Array.Empty()); // Act var result = await _adapter.LoadExceptionsAsync(_tenantId, DateTimeOffset.UtcNow); // Assert result.Should().NotBeNull(); result.IsEmpty.Should().BeTrue(); result.Instances.Should().BeEmpty(); result.Effects.Should().BeEmpty(); } [Fact] public async Task LoadExceptionsAsync_FiltersExpiredExceptions() { // Arrange var now = DateTimeOffset.UtcNow; var activeException = CreateException("EXC-001", ExceptionStatus.Active, now.AddDays(30)); var expiredException = CreateException("EXC-002", ExceptionStatus.Active, now.AddDays(-1)); // Expired _repositoryMock .Setup(r => r.GetActiveByScopeAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(new[] { activeException, expiredException }); // Act var result = await _adapter.LoadExceptionsAsync(_tenantId, now); // Assert result.Instances.Should().HaveCount(1); result.Instances[0].Id.Should().Be("EXC-001"); } [Fact] public async Task LoadExceptionsAsync_FiltersNonActiveExceptions() { // Arrange var now = DateTimeOffset.UtcNow; var activeException = CreateException("EXC-001", ExceptionStatus.Active, now.AddDays(30)); var proposedException = CreateException("EXC-002", ExceptionStatus.Proposed, now.AddDays(30)); var revokedException = CreateException("EXC-003", ExceptionStatus.Revoked, now.AddDays(30)); _repositoryMock .Setup(r => r.GetActiveByScopeAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(new[] { activeException, proposedException, revokedException }); // Act var result = await _adapter.LoadExceptionsAsync(_tenantId, now); // Assert result.Instances.Should().HaveCount(1); result.Instances[0].Id.Should().Be("EXC-001"); } [Fact] public async Task LoadExceptionsAsync_MapsExceptionTypeAndReasonToEffect() { // Arrange var now = DateTimeOffset.UtcNow; var exception = CreateException( "EXC-001", ExceptionStatus.Active, now.AddDays(30), ExceptionType.Vulnerability, ExceptionReason.FalsePositive); _repositoryMock .Setup(r => r.GetActiveByScopeAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(new[] { exception }); // Act var result = await _adapter.LoadExceptionsAsync(_tenantId, now); // Assert result.Instances.Should().HaveCount(1); result.Effects.Should().ContainKey("suppress"); // FalsePositive maps to Suppress result.Instances[0].EffectId.Should().Be("suppress"); } [Fact] public async Task LoadExceptionsAsync_MapsScopeCorrectly() { // Arrange var now = DateTimeOffset.UtcNow; var exception = CreateException( "EXC-001", ExceptionStatus.Active, now.AddDays(30), scope: new ExceptionScope { PolicyRuleId = "block_critical", VulnerabilityId = "CVE-2024-1234", PurlPattern = "pkg:npm/lodash@*", ArtifactDigest = "sha256:abc123", Environments = ["production", "staging"] }); _repositoryMock .Setup(r => r.GetActiveByScopeAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(new[] { exception }); // Act var result = await _adapter.LoadExceptionsAsync(_tenantId, now); // Assert result.Instances.Should().HaveCount(1); var instance = result.Instances[0]; // Policy rule ID maps to RuleNames instance.Scope.RuleNames.Should().Contain("block_critical"); // Vulnerability ID maps to Sources instance.Scope.Sources.Should().Contain("CVE-2024-1234"); // PURL pattern maps to Tags with prefix instance.Scope.Tags.Should().Contain("purl:pkg:npm/lodash@*"); // Artifact digest maps to Tags with prefix instance.Scope.Tags.Should().Contain("digest:sha256:abc123"); // Environments map to Tags with prefix instance.Scope.Tags.Should().Contain("env:production"); instance.Scope.Tags.Should().Contain("env:staging"); } [Fact] public async Task LoadExceptionsAsync_BuildsMetadataCorrectly() { // Arrange var now = DateTimeOffset.UtcNow; var exception = CreateException( "EXC-001", ExceptionStatus.Active, now.AddDays(30), ticketRef: "JIRA-1234", evidenceRefs: new[] { "sha256:evidence1", "sha256:evidence2" }, compensatingControls: new[] { "WAF", "Rate-limiting" }); _repositoryMock .Setup(r => r.GetActiveByScopeAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(new[] { exception }); // Act var result = await _adapter.LoadExceptionsAsync(_tenantId, now); // Assert var instance = result.Instances[0]; instance.Metadata.Should().ContainKey("exception.type"); instance.Metadata.Should().ContainKey("exception.reason"); instance.Metadata.Should().ContainKey("exception.owner"); instance.Metadata.Should().ContainKey("exception.requester"); instance.Metadata.Should().ContainKey("exception.rationale"); instance.Metadata.Should().ContainKey("exception.ticketRef"); instance.Metadata["exception.ticketRef"].Should().Be("JIRA-1234"); instance.Metadata.Should().ContainKey("exception.evidenceRefs"); instance.Metadata.Should().ContainKey("exception.compensatingControls"); } [Fact] public async Task LoadExceptionsAsync_UsesCacheOnSecondCall() { // Arrange var now = DateTimeOffset.UtcNow; var exception = CreateException("EXC-001", ExceptionStatus.Active, now.AddDays(30)); _repositoryMock .Setup(r => r.GetActiveByScopeAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(new[] { exception }); // Act - First call var result1 = await _adapter.LoadExceptionsAsync(_tenantId, now); // Act - Second call (should hit cache) var result2 = await _adapter.LoadExceptionsAsync(_tenantId, now); // Assert result1.Should().Be(result2); _repositoryMock.Verify( r => r.GetActiveByScopeAsync(It.IsAny(), It.IsAny()), Times.Once); // Should only call repository once } [Fact] public async Task LoadExceptionsAsync_BypassesCache_WhenCachingDisabled() { // Arrange var disabledCacheOptions = new ExceptionAdapterOptions { EnableCaching = false }; var adapter = new ExceptionAdapter( _repositoryMock.Object, _effectRegistry, _cache, Microsoft.Extensions.Options.Options.Create(disabledCacheOptions), TimeProvider.System, NullLogger.Instance); var now = DateTimeOffset.UtcNow; var exception = CreateException("EXC-001", ExceptionStatus.Active, now.AddDays(30)); _repositoryMock .Setup(r => r.GetActiveByScopeAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(new[] { exception }); // Act await adapter.LoadExceptionsAsync(_tenantId, now); await adapter.LoadExceptionsAsync(_tenantId, now); // Assert _repositoryMock.Verify( r => r.GetActiveByScopeAsync(It.IsAny(), It.IsAny()), Times.Exactly(2)); // Should call repository twice } [Fact] public void InvalidateCache_RemovesCacheEntry() { // Arrange - pre-populate cache var cacheKey = $"exception_adapter:{_tenantId:N}"; _cache.Set(cacheKey, PolicyEvaluationExceptions.Empty); // Act _adapter.InvalidateCache(_tenantId); // Assert _cache.TryGetValue(cacheKey, out _).Should().BeFalse(); } [Fact] public async Task LoadExceptionsAsync_RespectsMaxExceptionsLimit() { // Arrange var limitedOptions = new ExceptionAdapterOptions { MaxExceptionsPerTenant = 2 }; var adapter = new ExceptionAdapter( _repositoryMock.Object, _effectRegistry, _cache, Microsoft.Extensions.Options.Options.Create(limitedOptions), TimeProvider.System, NullLogger.Instance); var now = DateTimeOffset.UtcNow; var exceptions = Enumerable.Range(1, 10) .Select(i => CreateException($"EXC-{i:000}", ExceptionStatus.Active, now.AddDays(30))) .ToArray(); _repositoryMock .Setup(r => r.GetActiveByScopeAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(exceptions); // Act var result = await adapter.LoadExceptionsAsync(_tenantId, now); // Assert result.Instances.Should().HaveCount(2); } private static ExceptionObject CreateException( string exceptionId, ExceptionStatus status, DateTimeOffset expiresAt, ExceptionType type = ExceptionType.Vulnerability, ExceptionReason reason = ExceptionReason.AcceptedRisk, ExceptionScope? scope = null, string? ticketRef = null, string[]? evidenceRefs = null, string[]? compensatingControls = null) { return new ExceptionObject { ExceptionId = exceptionId, Version = 1, Status = status, Type = type, Scope = scope ?? new ExceptionScope { TenantId = Guid.NewGuid() }, OwnerId = "owner-001", RequesterId = "requester-001", CreatedAt = DateTimeOffset.UtcNow.AddDays(-7), UpdatedAt = DateTimeOffset.UtcNow, ExpiresAt = expiresAt, ReasonCode = reason, Rationale = "This is a test rationale that meets the minimum character requirement for exception objects.", TicketRef = ticketRef, EvidenceRefs = evidenceRefs?.ToImmutableArray() ?? [], CompensatingControls = compensatingControls?.ToImmutableArray() ?? [] }; } }