Files
git.stella-ops.org/src/Policy/__Tests/StellaOps.Policy.Engine.Tests/Adapters/ExceptionAdapterTests.cs
StellaOps Bot 5146204f1b feat: add security sink detection patterns for JavaScript/TypeScript
- Introduced `sink-detect.js` with various security sink detection patterns categorized by type (e.g., command injection, SQL injection, file operations).
- Implemented functions to build a lookup map for fast sink detection and to match sink calls against known patterns.
- Added `package-lock.json` for dependency management.
2025-12-22 23:21:21 +02:00

348 lines
12 KiB
C#

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;
/// <summary>
/// Unit tests for ExceptionAdapter.
/// </summary>
public sealed class ExceptionAdapterTests : IDisposable
{
private readonly Mock<IExceptionRepository> _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<IExceptionRepository>();
_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<ExceptionAdapter>.Instance);
}
public void Dispose()
{
_cache.Dispose();
}
[Fact]
public async Task LoadExceptionsAsync_ReturnsEmpty_WhenNoExceptionsExist()
{
// Arrange
_repositoryMock
.Setup(r => r.GetActiveByScopeAsync(It.IsAny<ExceptionScope>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Array.Empty<ExceptionObject>());
// 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<ExceptionScope>(), It.IsAny<CancellationToken>()))
.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<ExceptionScope>(), It.IsAny<CancellationToken>()))
.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<ExceptionScope>(), It.IsAny<CancellationToken>()))
.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<ExceptionScope>(), It.IsAny<CancellationToken>()))
.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<ExceptionScope>(), It.IsAny<CancellationToken>()))
.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<ExceptionScope>(), It.IsAny<CancellationToken>()))
.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<ExceptionScope>(), It.IsAny<CancellationToken>()),
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<ExceptionAdapter>.Instance);
var now = DateTimeOffset.UtcNow;
var exception = CreateException("EXC-001", ExceptionStatus.Active, now.AddDays(30));
_repositoryMock
.Setup(r => r.GetActiveByScopeAsync(It.IsAny<ExceptionScope>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new[] { exception });
// Act
await adapter.LoadExceptionsAsync(_tenantId, now);
await adapter.LoadExceptionsAsync(_tenantId, now);
// Assert
_repositoryMock.Verify(
r => r.GetActiveByScopeAsync(It.IsAny<ExceptionScope>(), It.IsAny<CancellationToken>()),
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<ExceptionAdapter>.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<ExceptionScope>(), It.IsAny<CancellationToken>()))
.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() ?? []
};
}
}