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,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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user