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:
StellaOps Bot
2025-12-21 00:34:35 +02:00
parent 6928124d33
commit b7b27c8740
32 changed files with 8687 additions and 64 deletions

View File

@@ -0,0 +1,300 @@
// <copyright file="ApprovalWorkflowServiceTests.cs" company="StellaOps">
// Copyright (c) StellaOps. All rights reserved.
// Licensed under the AGPL-3.0-or-later license.
// </copyright>
using System.Collections.Immutable;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using MsOptions = Microsoft.Extensions.Options.Options;
using Moq;
using StellaOps.Policy.Exceptions.Models;
using StellaOps.Policy.Gateway.Services;
using Xunit;
namespace StellaOps.Policy.Gateway.Tests.Services;
public class ApprovalWorkflowServiceTests
{
private readonly FakeTimeProvider _timeProvider;
private readonly Mock<IExceptionNotificationService> _notificationMock;
private readonly ApprovalWorkflowOptions _options;
private readonly ApprovalWorkflowService _service;
private readonly DateTimeOffset _now = new(2025, 1, 15, 12, 0, 0, TimeSpan.Zero);
public ApprovalWorkflowServiceTests()
{
_timeProvider = new FakeTimeProvider(_now);
_notificationMock = new Mock<IExceptionNotificationService>();
_options = new ApprovalWorkflowOptions();
_service = new ApprovalWorkflowService(
MsOptions.Create(_options),
_timeProvider,
_notificationMock.Object,
NullLogger<ApprovalWorkflowService>.Instance);
}
[Theory]
[InlineData("dev")]
[InlineData("Dev")]
[InlineData("DEV")]
public void GetPolicyForEnvironment_Dev_ReturnsDevPolicy(string env)
{
// Act
var policy = _service.GetPolicyForEnvironment(env);
// Assert
policy.RequiredApprovers.Should().Be(0);
policy.RequesterCanApprove.Should().BeTrue();
policy.AutoApprove.Should().BeTrue();
}
[Fact]
public void GetPolicyForEnvironment_Staging_ReturnsStagingPolicy()
{
// Act
var policy = _service.GetPolicyForEnvironment("staging");
// Assert
policy.RequiredApprovers.Should().Be(1);
policy.RequesterCanApprove.Should().BeFalse();
policy.AutoApprove.Should().BeFalse();
}
[Fact]
public void GetPolicyForEnvironment_Prod_ReturnsProdPolicy()
{
// Act
var policy = _service.GetPolicyForEnvironment("prod");
// Assert
policy.RequiredApprovers.Should().Be(2);
policy.RequesterCanApprove.Should().BeFalse();
policy.AllowedApproverRoles.Should().Contain("security-lead");
}
[Fact]
public void GetPolicyForEnvironment_Unknown_ReturnsDefaultPolicy()
{
// Act
var policy = _service.GetPolicyForEnvironment("unknown-env");
// Assert
policy.Should().Be(_options.DefaultPolicy);
}
[Fact]
public void ValidateApproval_SelfApprovalInProd_ReturnsInvalid()
{
// Arrange
var exception = CreateException("requester-123", "prod");
// Act
var result = _service.ValidateApproval(exception, "requester-123");
// Assert
result.IsValid.Should().BeFalse();
result.Error.Should().Contain("cannot approve their own");
}
[Fact]
public void ValidateApproval_SelfApprovalInDev_ReturnsValid()
{
// Arrange
var exception = CreateException("requester-123", "dev");
// Act
var result = _service.ValidateApproval(exception, "requester-123");
// Assert
result.IsValid.Should().BeTrue();
result.IsComplete.Should().BeTrue(); // Dev requires 0 approvers
}
[Fact]
public void ValidateApproval_AlreadyApproved_ReturnsInvalid()
{
// Arrange
var exception = CreateException("requester-123", "staging") with
{
ApproverIds = ["approver-456"]
};
// Act
var result = _service.ValidateApproval(exception, "approver-456");
// Assert
result.IsValid.Should().BeFalse();
result.Error.Should().Contain("already approved");
}
[Fact]
public void ValidateApproval_MissingRequiredRole_ReturnsInvalid()
{
// Arrange
var exception = CreateException("requester-123", "prod");
var approverRoles = new List<string> { "developer" };
// Act
var result = _service.ValidateApproval(exception, "approver-456", approverRoles);
// Assert
result.IsValid.Should().BeFalse();
result.Error.Should().Contain("security-lead");
}
[Fact]
public void ValidateApproval_WithRequiredRole_ReturnsValid()
{
// Arrange
var exception = CreateException("requester-123", "prod");
var approverRoles = new List<string> { "security-lead" };
// Act
var result = _service.ValidateApproval(exception, "approver-456", approverRoles);
// Assert
result.IsValid.Should().BeTrue();
result.IsComplete.Should().BeFalse(); // Prod requires 2 approvers
result.ApprovalsRemaining.Should().Be(1);
}
[Fact]
public void ValidateApproval_SecondApprovalInProd_ReturnsComplete()
{
// Arrange
var exception = CreateException("requester-123", "prod") with
{
ApproverIds = ["approver-111"]
};
var approverRoles = new List<string> { "security-admin" };
// Act
var result = _service.ValidateApproval(exception, "approver-222", approverRoles);
// Assert
result.IsValid.Should().BeTrue();
result.IsComplete.Should().BeTrue();
result.ApprovalsRemaining.Should().Be(0);
}
[Fact]
public void ValidateApproval_ExpiredDeadline_ReturnsInvalid()
{
// Arrange
var exception = CreateException("requester-123", "staging") with
{
CreatedAt = _now.AddDays(-30) // Way past staging deadline of 14 days
};
// Act
var result = _service.ValidateApproval(exception, "approver-456");
// Assert
result.IsValid.Should().BeFalse();
result.Error.Should().Contain("deadline has passed");
}
[Fact]
public void ShouldAutoApprove_DevEnvironment_ReturnsTrue()
{
// Arrange
var exception = CreateException("requester-123", "dev");
// Act
var result = _service.ShouldAutoApprove(exception);
// Assert
result.Should().BeTrue();
}
[Fact]
public void ShouldAutoApprove_ProdEnvironment_ReturnsFalse()
{
// Arrange
var exception = CreateException("requester-123", "prod");
// Act
var result = _service.ShouldAutoApprove(exception);
// Assert
result.Should().BeFalse();
}
[Fact]
public void IsApprovalExpired_WithinDeadline_ReturnsFalse()
{
// Arrange
var exception = CreateException("requester-123", "staging") with
{
CreatedAt = _now.AddDays(-5)
};
// Act
var result = _service.IsApprovalExpired(exception);
// Assert
result.Should().BeFalse();
}
[Fact]
public void IsApprovalExpired_PastDeadline_ReturnsTrue()
{
// Arrange
var exception = CreateException("requester-123", "staging") with
{
CreatedAt = _now.AddDays(-20)
};
// Act
var result = _service.IsApprovalExpired(exception);
// Assert
result.Should().BeTrue();
}
[Fact]
public void GetApprovalDeadline_ReturnsCorrectDeadline()
{
// Arrange
var createdAt = _now.AddDays(-5);
var exception = CreateException("requester-123", "staging") with
{
CreatedAt = createdAt
};
// Act
var deadline = _service.GetApprovalDeadline(exception);
// Assert
deadline.Should().Be(createdAt.AddDays(14)); // Staging deadline is 14 days
}
#region Helpers
private ExceptionObject CreateException(string requesterId, string environment) => new()
{
ExceptionId = "EXC-123",
Version = 1,
Status = ExceptionStatus.Proposed,
Type = ExceptionType.Vulnerability,
Scope = new ExceptionScope
{
VulnerabilityId = "CVE-2024-12345",
Environments = [environment]
},
OwnerId = "owner-team",
RequesterId = requesterId,
CreatedAt = _now.AddDays(-1),
UpdatedAt = _now.AddDays(-1),
ExpiresAt = _now.AddDays(30),
ReasonCode = ExceptionReason.FalsePositive,
Rationale = "Test rationale",
EvidenceRefs = [],
CompensatingControls = [],
Metadata = ImmutableDictionary<string, string>.Empty
};
#endregion
}

View File

@@ -0,0 +1,337 @@
// <copyright file="ExceptionServiceTests.cs" company="StellaOps">
// Copyright (c) StellaOps. All rights reserved.
// Licensed under the AGPL-3.0-or-later license.
// </copyright>
using System.Collections.Immutable;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using Moq;
using StellaOps.Policy.Exceptions.Models;
using StellaOps.Policy.Exceptions.Repositories;
using StellaOps.Policy.Gateway.Services;
using Xunit;
namespace StellaOps.Policy.Gateway.Tests.Services;
public class ExceptionServiceTests
{
private readonly Mock<IExceptionRepository> _repositoryMock;
private readonly Mock<IExceptionNotificationService> _notificationMock;
private readonly FakeTimeProvider _timeProvider;
private readonly ExceptionService _service;
private readonly DateTimeOffset _now = new(2025, 1, 15, 12, 0, 0, TimeSpan.Zero);
public ExceptionServiceTests()
{
_repositoryMock = new Mock<IExceptionRepository>();
_notificationMock = new Mock<IExceptionNotificationService>();
_timeProvider = new FakeTimeProvider(_now);
_service = new ExceptionService(
_repositoryMock.Object,
_notificationMock.Object,
_timeProvider,
NullLogger<ExceptionService>.Instance);
}
[Fact]
public async Task CreateAsync_WithValidRequest_CreatesException()
{
// Arrange
var command = CreateValidCommand();
_repositoryMock
.Setup(r => r.CreateAsync(It.IsAny<ExceptionObject>(), It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((ExceptionObject ex, string _, string? _, CancellationToken _) => ex);
// Act
var result = await _service.CreateAsync(command, "user-123", "client-info");
// Assert
result.IsSuccess.Should().BeTrue();
result.Exception.Should().NotBeNull();
result.Exception!.Status.Should().Be(ExceptionStatus.Proposed);
result.Exception.RequesterId.Should().Be("user-123");
result.Exception.OwnerId.Should().Be(command.OwnerId);
result.Exception.Rationale.Should().Be(command.Rationale);
_notificationMock.Verify(n => n.NotifyExceptionCreatedAsync(It.IsAny<ExceptionObject>(), It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task CreateAsync_WithEmptyScope_ReturnsValidationError()
{
// Arrange
var command = CreateValidCommand() with
{
Scope = new ExceptionScope()
};
// Act
var result = await _service.CreateAsync(command, "user-123");
// Assert
result.IsSuccess.Should().BeFalse();
result.ErrorCode.Should().Be(ExceptionErrorCode.ScopeNotSpecific);
result.Error.Should().Contain("scope must specify at least one");
}
[Fact]
public async Task CreateAsync_WithPastExpiry_ReturnsValidationError()
{
// Arrange
var command = CreateValidCommand() with
{
ExpiresAt = _now.AddDays(-1)
};
// Act
var result = await _service.CreateAsync(command, "user-123");
// Assert
result.IsSuccess.Should().BeFalse();
result.ErrorCode.Should().Be(ExceptionErrorCode.ExpiryInvalid);
result.Error.Should().Contain("future");
}
[Fact]
public async Task CreateAsync_WithExpiryTooFar_ReturnsValidationError()
{
// Arrange
var command = CreateValidCommand() with
{
ExpiresAt = _now.AddDays(400) // More than 365 days
};
// Act
var result = await _service.CreateAsync(command, "user-123");
// Assert
result.IsSuccess.Should().BeFalse();
result.ErrorCode.Should().Be(ExceptionErrorCode.ExpiryInvalid);
result.Error.Should().Contain("365 days");
}
[Fact]
public async Task CreateAsync_WithShortRationale_ReturnsValidationError()
{
// Arrange
var command = CreateValidCommand() with
{
Rationale = "Too short"
};
// Act
var result = await _service.CreateAsync(command, "user-123");
// Assert
result.IsSuccess.Should().BeFalse();
result.ErrorCode.Should().Be(ExceptionErrorCode.RationaleTooShort);
result.Error.Should().Contain("50 characters");
}
[Fact]
public async Task ApproveAsync_WhenSelfApproval_ReturnsSelfApprovalError()
{
// Arrange
var exception = CreateExceptionObject(ExceptionStatus.Proposed, "requester-123");
_repositoryMock.Setup(r => r.GetByIdAsync("EXC-123", It.IsAny<CancellationToken>()))
.ReturnsAsync(exception);
// Act
var result = await _service.ApproveAsync("EXC-123", null, "requester-123");
// Assert
result.IsSuccess.Should().BeFalse();
result.ErrorCode.Should().Be(ExceptionErrorCode.SelfApprovalNotAllowed);
}
[Fact]
public async Task ApproveAsync_WhenNotProposed_ReturnsInvalidStateError()
{
// Arrange
var exception = CreateExceptionObject(ExceptionStatus.Active, "requester-123");
_repositoryMock.Setup(r => r.GetByIdAsync("EXC-123", It.IsAny<CancellationToken>()))
.ReturnsAsync(exception);
// Act
var result = await _service.ApproveAsync("EXC-123", null, "approver-456");
// Assert
result.IsSuccess.Should().BeFalse();
result.ErrorCode.Should().Be(ExceptionErrorCode.InvalidStateTransition);
}
[Fact]
public async Task ApproveAsync_WithValidApprover_ApprovesException()
{
// Arrange
var exception = CreateExceptionObject(ExceptionStatus.Proposed, "requester-123");
_repositoryMock.Setup(r => r.GetByIdAsync("EXC-123", It.IsAny<CancellationToken>()))
.ReturnsAsync(exception);
_repositoryMock.Setup(r => r.UpdateAsync(It.IsAny<ExceptionObject>(), It.IsAny<ExceptionEventType>(), It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((ExceptionObject ex, ExceptionEventType _, string _, string? _, string? _, CancellationToken _) => ex);
// Act
var result = await _service.ApproveAsync("EXC-123", "Looks good", "approver-456");
// Assert
result.IsSuccess.Should().BeTrue();
result.Exception!.Status.Should().Be(ExceptionStatus.Approved);
result.Exception.ApproverIds.Should().Contain("approver-456");
_notificationMock.Verify(n => n.NotifyExceptionApprovedAsync(It.IsAny<ExceptionObject>(), "approver-456", It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task ActivateAsync_WhenNotApproved_ReturnsInvalidStateError()
{
// Arrange
var exception = CreateExceptionObject(ExceptionStatus.Proposed, "requester-123");
_repositoryMock.Setup(r => r.GetByIdAsync("EXC-123", It.IsAny<CancellationToken>()))
.ReturnsAsync(exception);
// Act
var result = await _service.ActivateAsync("EXC-123", "user-123");
// Assert
result.IsSuccess.Should().BeFalse();
result.ErrorCode.Should().Be(ExceptionErrorCode.InvalidStateTransition);
}
[Fact]
public async Task ActivateAsync_WhenApproved_ActivatesException()
{
// Arrange
var exception = CreateExceptionObject(ExceptionStatus.Approved, "requester-123");
_repositoryMock.Setup(r => r.GetByIdAsync("EXC-123", It.IsAny<CancellationToken>()))
.ReturnsAsync(exception);
_repositoryMock.Setup(r => r.UpdateAsync(It.IsAny<ExceptionObject>(), It.IsAny<ExceptionEventType>(), It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((ExceptionObject ex, ExceptionEventType _, string _, string? _, string? _, CancellationToken _) => ex);
// Act
var result = await _service.ActivateAsync("EXC-123", "user-123");
// Assert
result.IsSuccess.Should().BeTrue();
result.Exception!.Status.Should().Be(ExceptionStatus.Active);
}
[Fact]
public async Task RevokeAsync_WhenAlreadyRevoked_ReturnsInvalidStateError()
{
// Arrange
var exception = CreateExceptionObject(ExceptionStatus.Revoked, "requester-123");
_repositoryMock.Setup(r => r.GetByIdAsync("EXC-123", It.IsAny<CancellationToken>()))
.ReturnsAsync(exception);
// Act
var result = await _service.RevokeAsync("EXC-123", "Not needed anymore", "user-123");
// Assert
result.IsSuccess.Should().BeFalse();
result.ErrorCode.Should().Be(ExceptionErrorCode.InvalidStateTransition);
}
[Fact]
public async Task RevokeAsync_WithShortReason_ReturnsValidationError()
{
// Arrange
var exception = CreateExceptionObject(ExceptionStatus.Active, "requester-123");
_repositoryMock.Setup(r => r.GetByIdAsync("EXC-123", It.IsAny<CancellationToken>()))
.ReturnsAsync(exception);
// Act
var result = await _service.RevokeAsync("EXC-123", "Too short", "user-123");
// Assert
result.IsSuccess.Should().BeFalse();
result.ErrorCode.Should().Be(ExceptionErrorCode.ValidationFailed);
}
[Fact]
public async Task ExtendAsync_WhenNotActive_ReturnsInvalidStateError()
{
// Arrange
var exception = CreateExceptionObject(ExceptionStatus.Proposed, "requester-123");
_repositoryMock.Setup(r => r.GetByIdAsync("EXC-123", It.IsAny<CancellationToken>()))
.ReturnsAsync(exception);
// Act
var result = await _service.ExtendAsync("EXC-123", _now.AddDays(90), "Need more time to remediate", "user-123");
// Assert
result.IsSuccess.Should().BeFalse();
result.ErrorCode.Should().Be(ExceptionErrorCode.InvalidStateTransition);
}
[Fact]
public async Task ExtendAsync_WhenNewExpiryBeforeCurrent_ReturnsInvalidExpiryError()
{
// Arrange
var exception = CreateExceptionObject(ExceptionStatus.Active, "requester-123") with
{
ExpiresAt = _now.AddDays(30)
};
_repositoryMock.Setup(r => r.GetByIdAsync("EXC-123", It.IsAny<CancellationToken>()))
.ReturnsAsync(exception);
// Act
var result = await _service.ExtendAsync("EXC-123", _now.AddDays(15), "Need more time to remediate", "user-123");
// Assert
result.IsSuccess.Should().BeFalse();
result.ErrorCode.Should().Be(ExceptionErrorCode.ExpiryInvalid);
}
[Fact]
public async Task GetByIdAsync_WhenNotFound_ReturnsNull()
{
// Arrange
_repositoryMock.Setup(r => r.GetByIdAsync("EXC-999", It.IsAny<CancellationToken>()))
.ReturnsAsync((ExceptionObject?)null);
// Act
var result = await _service.GetByIdAsync("EXC-999");
// Assert
result.Should().BeNull();
}
#region Helpers
private CreateExceptionCommand CreateValidCommand() => new()
{
Type = ExceptionType.Vulnerability,
Scope = new ExceptionScope
{
VulnerabilityId = "CVE-2024-12345"
},
OwnerId = "owner-team",
ReasonCode = ExceptionReason.FalsePositive,
Rationale = "This vulnerability is a false positive because the vulnerable code path is not reachable in our deployment configuration.",
ExpiresAt = _now.AddDays(30)
};
private ExceptionObject CreateExceptionObject(ExceptionStatus status, string requesterId) => new()
{
ExceptionId = "EXC-123",
Version = 1,
Status = status,
Type = ExceptionType.Vulnerability,
Scope = new ExceptionScope { VulnerabilityId = "CVE-2024-12345" },
OwnerId = "owner-team",
RequesterId = requesterId,
CreatedAt = _now.AddDays(-1),
UpdatedAt = _now.AddDays(-1),
ExpiresAt = _now.AddDays(30),
ReasonCode = ExceptionReason.FalsePositive,
Rationale = "Test rationale that is long enough to pass validation requirements.",
EvidenceRefs = [],
CompensatingControls = [],
Metadata = ImmutableDictionary<string, string>.Empty
};
#endregion
}

View File

@@ -4,9 +4,26 @@
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../StellaOps.Policy.Gateway/StellaOps.Policy.Gateway.csproj" />
</ItemGroup>
</Project>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="FluentAssertions" Version="8.0.1" />
<PackageReference Include="coverlet.collector" Version="6.0.4">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
</Project>