sprints work
This commit is contained in:
@@ -0,0 +1,358 @@
|
||||
// <copyright file="ActionExecutorTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using StellaOps.AdvisoryAI.Actions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Tests.Actions;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for ActionExecutor.
|
||||
/// Sprint: SPRINT_20260109_011_004_BE Task PACT-008
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ActionExecutorTests
|
||||
{
|
||||
private readonly Mock<IActionPolicyGate> _policyGateMock;
|
||||
private readonly ActionRegistry _actionRegistry;
|
||||
private readonly Mock<IIdempotencyHandler> _idempotencyMock;
|
||||
private readonly Mock<IApprovalWorkflowAdapter> _approvalMock;
|
||||
private readonly Mock<IActionAuditLedger> _auditLedgerMock;
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly FakeGuidGenerator _guidGenerator;
|
||||
private readonly ActionExecutor _sut;
|
||||
|
||||
public ActionExecutorTests()
|
||||
{
|
||||
_policyGateMock = new Mock<IActionPolicyGate>();
|
||||
_actionRegistry = new ActionRegistry();
|
||||
_idempotencyMock = new Mock<IIdempotencyHandler>();
|
||||
_approvalMock = new Mock<IApprovalWorkflowAdapter>();
|
||||
_auditLedgerMock = new Mock<IActionAuditLedger>();
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 10, 12, 0, 0, TimeSpan.Zero));
|
||||
_guidGenerator = new FakeGuidGenerator();
|
||||
|
||||
var options = Options.Create(new ActionExecutorOptions
|
||||
{
|
||||
EnableIdempotency = true,
|
||||
EnableAuditLogging = true
|
||||
});
|
||||
|
||||
_sut = new ActionExecutor(
|
||||
_policyGateMock.Object,
|
||||
_actionRegistry,
|
||||
_idempotencyMock.Object,
|
||||
_approvalMock.Object,
|
||||
_auditLedgerMock.Object,
|
||||
_timeProvider,
|
||||
_guidGenerator,
|
||||
options,
|
||||
NullLogger<ActionExecutor>.Instance);
|
||||
|
||||
// Default idempotency behavior: not executed
|
||||
_idempotencyMock
|
||||
.Setup(x => x.CheckAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(IdempotencyResult.NotExecuted);
|
||||
_idempotencyMock
|
||||
.Setup(x => x.GenerateKey(It.IsAny<ActionProposal>(), It.IsAny<ActionContext>()))
|
||||
.Returns("test-idempotency-key");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_ExecutesImmediately_WhenPolicyAllows()
|
||||
{
|
||||
// Arrange
|
||||
var proposal = CreateProposal("defer");
|
||||
var context = CreateContext();
|
||||
var decision = CreateAllowDecision();
|
||||
|
||||
// Act
|
||||
var result = await _sut.ExecuteAsync(proposal, decision, context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Outcome.Should().Be(ActionExecutionOutcome.Success);
|
||||
result.ExecutionId.Should().NotBeNullOrEmpty();
|
||||
|
||||
_auditLedgerMock.Verify(
|
||||
x => x.RecordAsync(
|
||||
It.Is<ActionAuditEntry>(e => e.Outcome == ActionAuditOutcome.Executed),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_ReturnsPendingApproval_WhenApprovalRequired()
|
||||
{
|
||||
// Arrange
|
||||
var proposal = CreateProposal("approve");
|
||||
var context = CreateContext();
|
||||
var decision = CreateApprovalRequiredDecision();
|
||||
|
||||
_approvalMock
|
||||
.Setup(x => x.CreateApprovalRequestAsync(
|
||||
It.IsAny<ActionProposal>(),
|
||||
It.IsAny<ActionPolicyDecision>(),
|
||||
It.IsAny<ActionContext>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ApprovalRequest
|
||||
{
|
||||
RequestId = "approval-123",
|
||||
WorkflowId = "high-risk-approval",
|
||||
TenantId = context.TenantId,
|
||||
RequesterId = context.UserId,
|
||||
RequiredApprovers = ImmutableArray.Create(
|
||||
new RequiredApprover { Type = ApproverType.Role, Identifier = "security-lead" }),
|
||||
Timeout = TimeSpan.FromHours(4),
|
||||
Payload = new ApprovalPayload
|
||||
{
|
||||
ActionType = proposal.ActionType,
|
||||
ActionLabel = proposal.Label,
|
||||
Parameters = proposal.Parameters
|
||||
},
|
||||
CreatedAt = _timeProvider.GetUtcNow()
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _sut.ExecuteAsync(proposal, decision, context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Outcome.Should().Be(ActionExecutionOutcome.PendingApproval);
|
||||
result.OutputData.Should().ContainKey("approvalRequestId");
|
||||
result.OutputData["approvalRequestId"].Should().Be("approval-123");
|
||||
|
||||
_auditLedgerMock.Verify(
|
||||
x => x.RecordAsync(
|
||||
It.Is<ActionAuditEntry>(e => e.Outcome == ActionAuditOutcome.ApprovalRequested),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_ReturnsFailed_WhenPolicyDenies()
|
||||
{
|
||||
// Arrange
|
||||
var proposal = CreateProposal("quarantine");
|
||||
var context = CreateContext();
|
||||
var decision = CreateDenyDecision("Missing required role");
|
||||
|
||||
// Act
|
||||
var result = await _sut.ExecuteAsync(proposal, decision, context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Outcome.Should().Be(ActionExecutionOutcome.Failed);
|
||||
result.Error.Should().NotBeNull();
|
||||
result.Error!.Code.Should().Be("POLICY_DENIED");
|
||||
|
||||
_auditLedgerMock.Verify(
|
||||
x => x.RecordAsync(
|
||||
It.Is<ActionAuditEntry>(e => e.Outcome == ActionAuditOutcome.DeniedByPolicy),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_SkipsExecution_WhenIdempotent()
|
||||
{
|
||||
// Arrange
|
||||
var proposal = CreateProposal("defer");
|
||||
var context = CreateContext();
|
||||
var decision = CreateAllowDecision();
|
||||
var previousResult = new ActionExecutionResult
|
||||
{
|
||||
ExecutionId = "previous-exec-123",
|
||||
Outcome = ActionExecutionOutcome.Success,
|
||||
Message = "Previously executed",
|
||||
StartedAt = _timeProvider.GetUtcNow().AddHours(-1),
|
||||
CompletedAt = _timeProvider.GetUtcNow().AddHours(-1),
|
||||
CanRollback = false
|
||||
};
|
||||
|
||||
_idempotencyMock
|
||||
.Setup(x => x.CheckAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new IdempotencyResult
|
||||
{
|
||||
AlreadyExecuted = true,
|
||||
PreviousResult = previousResult,
|
||||
ExecutedAt = _timeProvider.GetUtcNow().AddHours(-1)
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _sut.ExecuteAsync(proposal, decision, context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().BeSameAs(previousResult);
|
||||
|
||||
_auditLedgerMock.Verify(
|
||||
x => x.RecordAsync(
|
||||
It.Is<ActionAuditEntry>(e => e.Outcome == ActionAuditOutcome.IdempotentSkipped),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_RecordsIdempotency_OnSuccess()
|
||||
{
|
||||
// Arrange
|
||||
var proposal = CreateProposal("defer");
|
||||
var context = CreateContext();
|
||||
var decision = CreateAllowDecision();
|
||||
|
||||
// Act
|
||||
var result = await _sut.ExecuteAsync(proposal, decision, context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Outcome.Should().Be(ActionExecutionOutcome.Success);
|
||||
|
||||
_idempotencyMock.Verify(
|
||||
x => x.RecordExecutionAsync(
|
||||
It.IsAny<string>(),
|
||||
It.Is<ActionExecutionResult>(r => r.Outcome == ActionExecutionOutcome.Success),
|
||||
It.IsAny<ActionContext>(),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_IncludesOverrideInfo_ForDenyWithOverride()
|
||||
{
|
||||
// Arrange
|
||||
var proposal = CreateProposal("approve");
|
||||
var context = CreateContext();
|
||||
var decision = new ActionPolicyDecision
|
||||
{
|
||||
Decision = PolicyDecisionKind.DenyWithOverride,
|
||||
PolicyId = "high-risk-check",
|
||||
Reason = "Additional approval needed"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _sut.ExecuteAsync(proposal, decision, context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Outcome.Should().Be(ActionExecutionOutcome.Failed);
|
||||
result.Error!.Code.Should().Be("POLICY_DENIED_OVERRIDE_AVAILABLE");
|
||||
result.OutputData.Should().ContainKey("overrideAvailable");
|
||||
result.OutputData["overrideAvailable"].Should().Be("true");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSupportedActionTypes_ReturnsAllActions()
|
||||
{
|
||||
// Act
|
||||
var actionTypes = _sut.GetSupportedActionTypes();
|
||||
|
||||
// Assert
|
||||
actionTypes.Should().NotBeEmpty();
|
||||
actionTypes.Should().Contain(a => a.Type == "approve");
|
||||
actionTypes.Should().Contain(a => a.Type == "quarantine");
|
||||
actionTypes.Should().Contain(a => a.Type == "defer");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSupportedActionTypes_IncludesMetadata()
|
||||
{
|
||||
// Act
|
||||
var actionTypes = _sut.GetSupportedActionTypes();
|
||||
var approveAction = actionTypes.Single(a => a.Type == "approve");
|
||||
|
||||
// Assert
|
||||
approveAction.DisplayName.Should().Be("Approve Risk");
|
||||
approveAction.RequiredPermission.Should().NotBeNullOrEmpty();
|
||||
approveAction.SupportsRollback.Should().BeTrue();
|
||||
approveAction.IsDestructive.Should().BeTrue(); // High risk
|
||||
}
|
||||
|
||||
private static ActionProposal CreateProposal(string actionType)
|
||||
{
|
||||
var parameters = actionType switch
|
||||
{
|
||||
"approve" => new Dictionary<string, string>
|
||||
{
|
||||
["cve_id"] = "CVE-2023-44487",
|
||||
["justification"] = "Risk accepted"
|
||||
},
|
||||
"quarantine" => new Dictionary<string, string>
|
||||
{
|
||||
["image_digest"] = "sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
|
||||
["reason"] = "Critical vulnerability"
|
||||
},
|
||||
"defer" => new Dictionary<string, string>
|
||||
{
|
||||
["finding_id"] = "finding-123",
|
||||
["defer_days"] = "30",
|
||||
["reason"] = "Scheduled for next sprint"
|
||||
},
|
||||
_ => new Dictionary<string, string>()
|
||||
};
|
||||
|
||||
return new ActionProposal
|
||||
{
|
||||
ProposalId = Guid.NewGuid().ToString(),
|
||||
ActionType = actionType,
|
||||
Label = $"Test {actionType}",
|
||||
Parameters = parameters.ToImmutableDictionary(),
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
private static ActionContext CreateContext()
|
||||
{
|
||||
return new ActionContext
|
||||
{
|
||||
TenantId = "test-tenant",
|
||||
UserId = "test-user",
|
||||
UserRoles = ImmutableArray.Create("security-analyst"),
|
||||
Environment = "development"
|
||||
};
|
||||
}
|
||||
|
||||
private static ActionPolicyDecision CreateAllowDecision()
|
||||
{
|
||||
return new ActionPolicyDecision
|
||||
{
|
||||
Decision = PolicyDecisionKind.Allow,
|
||||
PolicyId = "test-policy",
|
||||
Reason = "Allowed"
|
||||
};
|
||||
}
|
||||
|
||||
private static ActionPolicyDecision CreateApprovalRequiredDecision()
|
||||
{
|
||||
return new ActionPolicyDecision
|
||||
{
|
||||
Decision = PolicyDecisionKind.AllowWithApproval,
|
||||
PolicyId = "high-risk-approval",
|
||||
Reason = "High-risk action requires approval",
|
||||
ApprovalWorkflowId = "action-approval-high",
|
||||
RequiredApprovers = ImmutableArray.Create(
|
||||
new RequiredApprover { Type = ApproverType.Role, Identifier = "security-lead" }),
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddHours(4)
|
||||
};
|
||||
}
|
||||
|
||||
private static ActionPolicyDecision CreateDenyDecision(string reason)
|
||||
{
|
||||
return new ActionPolicyDecision
|
||||
{
|
||||
Decision = PolicyDecisionKind.Deny,
|
||||
PolicyId = "role-check",
|
||||
Reason = reason
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fake GUID generator for deterministic testing.
|
||||
/// </summary>
|
||||
internal sealed class FakeGuidGenerator : IGuidGenerator
|
||||
{
|
||||
private int _counter;
|
||||
|
||||
public Guid NewGuid() => new($"00000000-0000-0000-0000-{_counter++:D12}");
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
// <copyright file="ActionPolicyGateTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.AdvisoryAI.Actions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Tests.Actions;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for ActionPolicyGate.
|
||||
/// Sprint: SPRINT_20260109_011_004_BE Task PACT-008
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ActionPolicyGateTests
|
||||
{
|
||||
private readonly ActionPolicyGate _sut;
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
|
||||
public ActionPolicyGateTests()
|
||||
{
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 10, 12, 0, 0, TimeSpan.Zero));
|
||||
var options = Options.Create(new ActionPolicyOptions
|
||||
{
|
||||
DefaultTimeoutHours = 4,
|
||||
CriticalTimeoutHours = 24
|
||||
});
|
||||
|
||||
_sut = new ActionPolicyGate(
|
||||
new ActionRegistry(),
|
||||
_timeProvider,
|
||||
options,
|
||||
NullLogger<ActionPolicyGate>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_AllowsLowRiskAction_WithAnyRole()
|
||||
{
|
||||
// Arrange
|
||||
var proposal = CreateProposal("defer", new Dictionary<string, string>
|
||||
{
|
||||
["finding_id"] = "finding-123",
|
||||
["defer_days"] = "30",
|
||||
["reason"] = "Scheduled for next sprint"
|
||||
});
|
||||
var context = CreateContext("security-analyst");
|
||||
|
||||
// Act
|
||||
var result = await _sut.EvaluateAsync(proposal, context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Decision.Should().Be(PolicyDecisionKind.Allow);
|
||||
result.PolicyId.Should().Be("low-risk-auto-allow");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_RequiresApproval_ForMediumRiskWithoutElevatedRole()
|
||||
{
|
||||
// Arrange
|
||||
var proposal = CreateProposal("create_vex", new Dictionary<string, string>
|
||||
{
|
||||
["cve_id"] = "CVE-2023-44487",
|
||||
["status"] = "not_affected",
|
||||
["justification"] = "Component not in use"
|
||||
});
|
||||
var context = CreateContext("security-analyst");
|
||||
|
||||
// Act
|
||||
var result = await _sut.EvaluateAsync(proposal, context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Decision.Should().Be(PolicyDecisionKind.AllowWithApproval);
|
||||
result.RequiredApprovers.Should().Contain(a => a.Identifier == "team-lead");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_AllowsMediumRiskAction_ForSecurityLead()
|
||||
{
|
||||
// Arrange
|
||||
var proposal = CreateProposal("create_vex", new Dictionary<string, string>
|
||||
{
|
||||
["cve_id"] = "CVE-2023-44487",
|
||||
["status"] = "not_affected",
|
||||
["justification"] = "Component not in use"
|
||||
});
|
||||
var context = CreateContext("security-lead");
|
||||
|
||||
// Act
|
||||
var result = await _sut.EvaluateAsync(proposal, context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Decision.Should().Be(PolicyDecisionKind.Allow);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_RequiresApproval_ForHighRiskWithoutAdminRole()
|
||||
{
|
||||
// Arrange
|
||||
var proposal = CreateProposal("approve", new Dictionary<string, string>
|
||||
{
|
||||
["cve_id"] = "CVE-2023-44487",
|
||||
["justification"] = "Risk accepted"
|
||||
});
|
||||
var context = CreateContext("security-analyst");
|
||||
|
||||
// Act
|
||||
var result = await _sut.EvaluateAsync(proposal, context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Decision.Should().Be(PolicyDecisionKind.AllowWithApproval);
|
||||
result.RequiredApprovers.Should().Contain(a => a.Identifier == "security-lead");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_AllowsHighRiskAction_ForAdmin()
|
||||
{
|
||||
// Arrange
|
||||
var proposal = CreateProposal("approve", new Dictionary<string, string>
|
||||
{
|
||||
["cve_id"] = "CVE-2023-44487",
|
||||
["justification"] = "Risk accepted"
|
||||
});
|
||||
var context = CreateContext("admin");
|
||||
|
||||
// Act
|
||||
var result = await _sut.EvaluateAsync(proposal, context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Decision.Should().Be(PolicyDecisionKind.Allow);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_RequiresMultiPartyApproval_ForCriticalActionInProduction()
|
||||
{
|
||||
// Arrange
|
||||
var proposal = CreateProposal("quarantine", new Dictionary<string, string>
|
||||
{
|
||||
["image_digest"] = "sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
|
||||
["reason"] = "Critical vulnerability"
|
||||
});
|
||||
var context = CreateContext("security-lead", environment: "production");
|
||||
|
||||
// Act
|
||||
var result = await _sut.EvaluateAsync(proposal, context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Decision.Should().Be(PolicyDecisionKind.AllowWithApproval);
|
||||
result.RequiredApprovers.Should().HaveCountGreaterThan(1);
|
||||
result.RequiredApprovers.Should().Contain(a => a.Identifier == "ciso");
|
||||
result.RequiredApprovers.Should().Contain(a => a.Identifier == "security-lead");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_RequiresSingleApproval_ForCriticalActionInNonProduction()
|
||||
{
|
||||
// Arrange
|
||||
var proposal = CreateProposal("quarantine", new Dictionary<string, string>
|
||||
{
|
||||
["image_digest"] = "sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
|
||||
["reason"] = "Critical vulnerability"
|
||||
});
|
||||
var context = CreateContext("security-lead", environment: "staging");
|
||||
|
||||
// Act
|
||||
var result = await _sut.EvaluateAsync(proposal, context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Decision.Should().Be(PolicyDecisionKind.AllowWithApproval);
|
||||
result.RequiredApprovers.Should().HaveCount(1);
|
||||
result.RequiredApprovers.Should().Contain(a => a.Identifier == "security-lead");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_DeniesAction_ForMissingRole()
|
||||
{
|
||||
// Arrange
|
||||
var proposal = CreateProposal("quarantine", new Dictionary<string, string>
|
||||
{
|
||||
["image_digest"] = "sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
|
||||
["reason"] = "Critical vulnerability"
|
||||
});
|
||||
var context = CreateContext("viewer"); // Not a security-lead
|
||||
|
||||
// Act
|
||||
var result = await _sut.EvaluateAsync(proposal, context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Decision.Should().Be(PolicyDecisionKind.Deny);
|
||||
result.Reason.Should().Contain("role");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_DeniesAction_ForUnknownActionType()
|
||||
{
|
||||
// Arrange
|
||||
var proposal = CreateProposal("unknown-action", new Dictionary<string, string>());
|
||||
var context = CreateContext("admin");
|
||||
|
||||
// Act
|
||||
var result = await _sut.EvaluateAsync(proposal, context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Decision.Should().Be(PolicyDecisionKind.Deny);
|
||||
result.Reason.Should().Contain("Unknown action type");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_DeniesAction_ForInvalidParameters()
|
||||
{
|
||||
// Arrange
|
||||
var proposal = CreateProposal("approve", new Dictionary<string, string>
|
||||
{
|
||||
["cve_id"] = "invalid-cve"
|
||||
// Missing required justification
|
||||
});
|
||||
var context = CreateContext("admin");
|
||||
|
||||
// Act
|
||||
var result = await _sut.EvaluateAsync(proposal, context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Decision.Should().Be(PolicyDecisionKind.Deny);
|
||||
result.Reason.Should().Contain("Invalid parameters");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExplainAsync_ReturnsSummary_ForAllowDecision()
|
||||
{
|
||||
// Arrange
|
||||
var decision = new ActionPolicyDecision
|
||||
{
|
||||
Decision = PolicyDecisionKind.Allow,
|
||||
PolicyId = "low-risk-auto-allow",
|
||||
Reason = "Low-risk action allowed automatically"
|
||||
};
|
||||
|
||||
// Act
|
||||
var explanation = await _sut.ExplainAsync(decision, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
explanation.Summary.Should().Contain("allowed");
|
||||
explanation.Details.Should().NotBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExplainAsync_IncludesApprovers_ForApprovalDecision()
|
||||
{
|
||||
// Arrange
|
||||
var decision = new ActionPolicyDecision
|
||||
{
|
||||
Decision = PolicyDecisionKind.AllowWithApproval,
|
||||
PolicyId = "high-risk-approval",
|
||||
Reason = "High-risk action requires approval",
|
||||
RequiredApprovers = ImmutableArray.Create(
|
||||
new RequiredApprover { Type = ApproverType.Role, Identifier = "security-lead" })
|
||||
};
|
||||
|
||||
// Act
|
||||
var explanation = await _sut.ExplainAsync(decision, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
explanation.Summary.Should().Contain("approval");
|
||||
explanation.SuggestedActions.Should().Contain(a => a.Contains("security-lead"));
|
||||
}
|
||||
|
||||
private static ActionProposal CreateProposal(string actionType, Dictionary<string, string> parameters)
|
||||
{
|
||||
return new ActionProposal
|
||||
{
|
||||
ProposalId = Guid.NewGuid().ToString(),
|
||||
ActionType = actionType,
|
||||
Label = $"Test {actionType}",
|
||||
Parameters = parameters.ToImmutableDictionary(),
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
private static ActionContext CreateContext(string role, string environment = "development")
|
||||
{
|
||||
return new ActionContext
|
||||
{
|
||||
TenantId = "test-tenant",
|
||||
UserId = "test-user",
|
||||
UserRoles = ImmutableArray.Create(role),
|
||||
Environment = environment
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fake TimeProvider for testing.
|
||||
/// </summary>
|
||||
internal sealed class FakeTimeProvider : TimeProvider
|
||||
{
|
||||
private DateTimeOffset _utcNow;
|
||||
|
||||
public FakeTimeProvider(DateTimeOffset utcNow)
|
||||
{
|
||||
_utcNow = utcNow;
|
||||
}
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _utcNow;
|
||||
|
||||
public void Advance(TimeSpan duration) => _utcNow = _utcNow.Add(duration);
|
||||
|
||||
public void SetUtcNow(DateTimeOffset utcNow) => _utcNow = utcNow;
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
// <copyright file="ActionRegistryTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using StellaOps.AdvisoryAI.Actions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Tests.Actions;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for ActionRegistry.
|
||||
/// Sprint: SPRINT_20260109_011_004_BE Task PACT-008
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ActionRegistryTests
|
||||
{
|
||||
private readonly ActionRegistry _sut = new();
|
||||
|
||||
[Fact]
|
||||
public void GetAction_ReturnsDefinition_ForApproveAction()
|
||||
{
|
||||
// Act
|
||||
var action = _sut.GetAction("approve");
|
||||
|
||||
// Assert
|
||||
action.Should().NotBeNull();
|
||||
action!.ActionType.Should().Be("approve");
|
||||
action.DisplayName.Should().Be("Approve Risk");
|
||||
action.RiskLevel.Should().Be(ActionRiskLevel.High);
|
||||
action.IsIdempotent.Should().BeTrue();
|
||||
action.HasCompensation.Should().BeTrue();
|
||||
action.CompensationActionType.Should().Be("revoke_approval");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAction_ReturnsDefinition_ForQuarantineAction()
|
||||
{
|
||||
// Act
|
||||
var action = _sut.GetAction("quarantine");
|
||||
|
||||
// Assert
|
||||
action.Should().NotBeNull();
|
||||
action!.ActionType.Should().Be("quarantine");
|
||||
action.RiskLevel.Should().Be(ActionRiskLevel.Critical);
|
||||
action.RequiredRole.Should().Be("security-lead");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAction_ReturnsNull_ForUnknownAction()
|
||||
{
|
||||
// Act
|
||||
var action = _sut.GetAction("unknown-action");
|
||||
|
||||
// Assert
|
||||
action.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAction_IsCaseInsensitive()
|
||||
{
|
||||
// Act
|
||||
var lower = _sut.GetAction("approve");
|
||||
var upper = _sut.GetAction("APPROVE");
|
||||
var mixed = _sut.GetAction("Approve");
|
||||
|
||||
// Assert
|
||||
lower.Should().NotBeNull();
|
||||
upper.Should().NotBeNull();
|
||||
mixed.Should().NotBeNull();
|
||||
lower!.ActionType.Should().Be(upper!.ActionType);
|
||||
lower.ActionType.Should().Be(mixed!.ActionType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAllActions_ReturnsAllBuiltInActions()
|
||||
{
|
||||
// Act
|
||||
var actions = _sut.GetAllActions();
|
||||
|
||||
// Assert
|
||||
actions.Should().NotBeEmpty();
|
||||
actions.Should().Contain(a => a.ActionType == "approve");
|
||||
actions.Should().Contain(a => a.ActionType == "quarantine");
|
||||
actions.Should().Contain(a => a.ActionType == "defer");
|
||||
actions.Should().Contain(a => a.ActionType == "create_vex");
|
||||
actions.Should().Contain(a => a.ActionType == "generate_manifest");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetActionsByRiskLevel_ReturnsCorrectActions_ForLowRisk()
|
||||
{
|
||||
// Act
|
||||
var actions = _sut.GetActionsByRiskLevel(ActionRiskLevel.Low);
|
||||
|
||||
// Assert
|
||||
actions.Should().NotBeEmpty();
|
||||
actions.Should().AllSatisfy(a => a.RiskLevel.Should().Be(ActionRiskLevel.Low));
|
||||
actions.Should().Contain(a => a.ActionType == "defer");
|
||||
actions.Should().Contain(a => a.ActionType == "generate_manifest");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetActionsByRiskLevel_ReturnsCorrectActions_ForCriticalRisk()
|
||||
{
|
||||
// Act
|
||||
var actions = _sut.GetActionsByRiskLevel(ActionRiskLevel.Critical);
|
||||
|
||||
// Assert
|
||||
actions.Should().NotBeEmpty();
|
||||
actions.Should().AllSatisfy(a => a.RiskLevel.Should().Be(ActionRiskLevel.Critical));
|
||||
actions.Should().Contain(a => a.ActionType == "quarantine");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetActionsByTag_ReturnsCorrectActions_ForCveTag()
|
||||
{
|
||||
// Act
|
||||
var actions = _sut.GetActionsByTag("cve");
|
||||
|
||||
// Assert
|
||||
actions.Should().NotBeEmpty();
|
||||
actions.Should().Contain(a => a.ActionType == "approve");
|
||||
actions.Should().Contain(a => a.ActionType == "revoke_approval");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetActionsByTag_ReturnsCorrectActions_ForVexTag()
|
||||
{
|
||||
// Act
|
||||
var actions = _sut.GetActionsByTag("vex");
|
||||
|
||||
// Assert
|
||||
actions.Should().NotBeEmpty();
|
||||
actions.Should().Contain(a => a.ActionType == "create_vex");
|
||||
actions.Should().Contain(a => a.ActionType == "approve");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateParameters_ReturnsSuccess_WithValidParameters()
|
||||
{
|
||||
// Arrange
|
||||
var parameters = new Dictionary<string, string>
|
||||
{
|
||||
["cve_id"] = "CVE-2023-44487",
|
||||
["justification"] = "Risk accepted for internal service"
|
||||
}.ToImmutableDictionary();
|
||||
|
||||
// Act
|
||||
var result = _sut.ValidateParameters("approve", parameters);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeTrue();
|
||||
result.Errors.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateParameters_ReturnsFailure_ForMissingRequiredParameter()
|
||||
{
|
||||
// Arrange
|
||||
var parameters = new Dictionary<string, string>
|
||||
{
|
||||
["cve_id"] = "CVE-2023-44487"
|
||||
// Missing required "justification" parameter
|
||||
}.ToImmutableDictionary();
|
||||
|
||||
// Act
|
||||
var result = _sut.ValidateParameters("approve", parameters);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.Contains("justification"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateParameters_ReturnsFailure_ForInvalidCveIdPattern()
|
||||
{
|
||||
// Arrange
|
||||
var parameters = new Dictionary<string, string>
|
||||
{
|
||||
["cve_id"] = "invalid-cve",
|
||||
["justification"] = "Test"
|
||||
}.ToImmutableDictionary();
|
||||
|
||||
// Act
|
||||
var result = _sut.ValidateParameters("approve", parameters);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.Contains("cve_id"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateParameters_ReturnsFailure_ForUnknownActionType()
|
||||
{
|
||||
// Arrange
|
||||
var parameters = ImmutableDictionary<string, string>.Empty;
|
||||
|
||||
// Act
|
||||
var result = _sut.ValidateParameters("unknown-action", parameters);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.Contains("Unknown action type"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateParameters_AcceptsValidImageDigest()
|
||||
{
|
||||
// Arrange
|
||||
var parameters = new Dictionary<string, string>
|
||||
{
|
||||
["image_digest"] = "sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
|
||||
["reason"] = "Critical vulnerability",
|
||||
["cve_ids"] = "CVE-2023-44487"
|
||||
}.ToImmutableDictionary();
|
||||
|
||||
// Act
|
||||
var result = _sut.ValidateParameters("quarantine", parameters);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateParameters_RejectsInvalidImageDigest()
|
||||
{
|
||||
// Arrange
|
||||
var parameters = new Dictionary<string, string>
|
||||
{
|
||||
["image_digest"] = "invalid-digest",
|
||||
["reason"] = "Critical vulnerability"
|
||||
}.ToImmutableDictionary();
|
||||
|
||||
// Act
|
||||
var result = _sut.ValidateParameters("quarantine", parameters);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.Contains("image_digest"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
// <copyright file="IdempotencyHandlerTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.AdvisoryAI.Actions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Tests.Actions;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for IdempotencyHandler.
|
||||
/// Sprint: SPRINT_20260109_011_004_BE Task PACT-008
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class IdempotencyHandlerTests
|
||||
{
|
||||
private readonly IdempotencyHandler _sut;
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
|
||||
public IdempotencyHandlerTests()
|
||||
{
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 10, 12, 0, 0, TimeSpan.Zero));
|
||||
var options = Options.Create(new IdempotencyOptions { TtlDays = 30 });
|
||||
|
||||
_sut = new IdempotencyHandler(
|
||||
_timeProvider,
|
||||
options,
|
||||
NullLogger<IdempotencyHandler>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateKey_IsDeterministic()
|
||||
{
|
||||
// Arrange
|
||||
var proposal = CreateProposal("approve", new Dictionary<string, string>
|
||||
{
|
||||
["cve_id"] = "CVE-2023-44487"
|
||||
});
|
||||
var context = CreateContext("tenant-1");
|
||||
|
||||
// Act
|
||||
var key1 = _sut.GenerateKey(proposal, context);
|
||||
var key2 = _sut.GenerateKey(proposal, context);
|
||||
|
||||
// Assert
|
||||
key1.Should().Be(key2);
|
||||
key1.Should().HaveLength(64); // SHA-256 hex length
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateKey_DiffersByTenant()
|
||||
{
|
||||
// Arrange
|
||||
var proposal = CreateProposal("approve", new Dictionary<string, string>
|
||||
{
|
||||
["cve_id"] = "CVE-2023-44487"
|
||||
});
|
||||
var context1 = CreateContext("tenant-1");
|
||||
var context2 = CreateContext("tenant-2");
|
||||
|
||||
// Act
|
||||
var key1 = _sut.GenerateKey(proposal, context1);
|
||||
var key2 = _sut.GenerateKey(proposal, context2);
|
||||
|
||||
// Assert
|
||||
key1.Should().NotBe(key2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateKey_DiffersByActionType()
|
||||
{
|
||||
// Arrange
|
||||
var proposal1 = CreateProposal("approve", new Dictionary<string, string>
|
||||
{
|
||||
["cve_id"] = "CVE-2023-44487"
|
||||
});
|
||||
var proposal2 = CreateProposal("defer", new Dictionary<string, string>
|
||||
{
|
||||
["cve_id"] = "CVE-2023-44487"
|
||||
});
|
||||
var context = CreateContext("tenant-1");
|
||||
|
||||
// Act
|
||||
var key1 = _sut.GenerateKey(proposal1, context);
|
||||
var key2 = _sut.GenerateKey(proposal2, context);
|
||||
|
||||
// Assert
|
||||
key1.Should().NotBe(key2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateKey_DiffersByCveId()
|
||||
{
|
||||
// Arrange
|
||||
var proposal1 = CreateProposal("approve", new Dictionary<string, string>
|
||||
{
|
||||
["cve_id"] = "CVE-2023-44487"
|
||||
});
|
||||
var proposal2 = CreateProposal("approve", new Dictionary<string, string>
|
||||
{
|
||||
["cve_id"] = "CVE-2024-12345"
|
||||
});
|
||||
var context = CreateContext("tenant-1");
|
||||
|
||||
// Act
|
||||
var key1 = _sut.GenerateKey(proposal1, context);
|
||||
var key2 = _sut.GenerateKey(proposal2, context);
|
||||
|
||||
// Assert
|
||||
key1.Should().NotBe(key2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateKey_DiffersByImageDigest()
|
||||
{
|
||||
// Arrange
|
||||
var proposal1 = CreateProposal("quarantine", new Dictionary<string, string>
|
||||
{
|
||||
["image_digest"] = "sha256:aaaa"
|
||||
});
|
||||
var proposal2 = CreateProposal("quarantine", new Dictionary<string, string>
|
||||
{
|
||||
["image_digest"] = "sha256:bbbb"
|
||||
});
|
||||
var context = CreateContext("tenant-1");
|
||||
|
||||
// Act
|
||||
var key1 = _sut.GenerateKey(proposal1, context);
|
||||
var key2 = _sut.GenerateKey(proposal2, context);
|
||||
|
||||
// Assert
|
||||
key1.Should().NotBe(key2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateKey_IncludesExplicitIdempotencyKey()
|
||||
{
|
||||
// Arrange
|
||||
var proposal1 = CreateProposal("approve", new Dictionary<string, string>
|
||||
{
|
||||
["cve_id"] = "CVE-2023-44487"
|
||||
}, idempotencyKey: "key-1");
|
||||
var proposal2 = CreateProposal("approve", new Dictionary<string, string>
|
||||
{
|
||||
["cve_id"] = "CVE-2023-44487"
|
||||
}, idempotencyKey: "key-2");
|
||||
var context = CreateContext("tenant-1");
|
||||
|
||||
// Act
|
||||
var key1 = _sut.GenerateKey(proposal1, context);
|
||||
var key2 = _sut.GenerateKey(proposal2, context);
|
||||
|
||||
// Assert
|
||||
key1.Should().NotBe(key2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckAsync_ReturnsNotExecuted_WhenNoRecord()
|
||||
{
|
||||
// Arrange
|
||||
var key = "non-existent-key";
|
||||
|
||||
// Act
|
||||
var result = await _sut.CheckAsync(key, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.AlreadyExecuted.Should().BeFalse();
|
||||
result.PreviousResult.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckAsync_ReturnsExecuted_WhenRecorded()
|
||||
{
|
||||
// Arrange
|
||||
var proposal = CreateProposal("approve", new Dictionary<string, string>
|
||||
{
|
||||
["cve_id"] = "CVE-2023-44487"
|
||||
});
|
||||
var context = CreateContext("tenant-1");
|
||||
var key = _sut.GenerateKey(proposal, context);
|
||||
var executionResult = CreateExecutionResult("exec-123");
|
||||
|
||||
await _sut.RecordExecutionAsync(key, executionResult, context, CancellationToken.None);
|
||||
|
||||
// Act
|
||||
var result = await _sut.CheckAsync(key, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.AlreadyExecuted.Should().BeTrue();
|
||||
result.PreviousResult.Should().NotBeNull();
|
||||
result.PreviousResult!.ExecutionId.Should().Be("exec-123");
|
||||
result.ExecutedBy.Should().Be("test-user");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckAsync_ReturnsNotExecuted_WhenExpired()
|
||||
{
|
||||
// Arrange
|
||||
var proposal = CreateProposal("approve", new Dictionary<string, string>
|
||||
{
|
||||
["cve_id"] = "CVE-2023-44487"
|
||||
});
|
||||
var context = CreateContext("tenant-1");
|
||||
var key = _sut.GenerateKey(proposal, context);
|
||||
var executionResult = CreateExecutionResult("exec-123");
|
||||
|
||||
await _sut.RecordExecutionAsync(key, executionResult, context, CancellationToken.None);
|
||||
|
||||
// Advance past TTL
|
||||
_timeProvider.Advance(TimeSpan.FromDays(31));
|
||||
|
||||
// Act
|
||||
var result = await _sut.CheckAsync(key, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.AlreadyExecuted.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveAsync_DeletesRecord()
|
||||
{
|
||||
// Arrange
|
||||
var proposal = CreateProposal("approve", new Dictionary<string, string>
|
||||
{
|
||||
["cve_id"] = "CVE-2023-44487"
|
||||
});
|
||||
var context = CreateContext("tenant-1");
|
||||
var key = _sut.GenerateKey(proposal, context);
|
||||
var executionResult = CreateExecutionResult("exec-123");
|
||||
|
||||
await _sut.RecordExecutionAsync(key, executionResult, context, CancellationToken.None);
|
||||
|
||||
// Act
|
||||
await _sut.RemoveAsync(key, CancellationToken.None);
|
||||
var result = await _sut.CheckAsync(key, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.AlreadyExecuted.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CleanupExpired_RemovesExpiredRecords()
|
||||
{
|
||||
// Arrange - synchronous setup with async results
|
||||
var proposal = CreateProposal("approve", new Dictionary<string, string>
|
||||
{
|
||||
["cve_id"] = "CVE-2023-44487"
|
||||
});
|
||||
var context = CreateContext("tenant-1");
|
||||
var key = _sut.GenerateKey(proposal, context);
|
||||
var executionResult = CreateExecutionResult("exec-123");
|
||||
|
||||
_sut.RecordExecutionAsync(key, executionResult, context, CancellationToken.None).Wait();
|
||||
|
||||
// Advance past TTL
|
||||
_timeProvider.Advance(TimeSpan.FromDays(31));
|
||||
|
||||
// Act
|
||||
_sut.CleanupExpired();
|
||||
var result = _sut.CheckAsync(key, CancellationToken.None).Result;
|
||||
|
||||
// Assert
|
||||
result.AlreadyExecuted.Should().BeFalse();
|
||||
}
|
||||
|
||||
private static ActionProposal CreateProposal(
|
||||
string actionType,
|
||||
Dictionary<string, string> parameters,
|
||||
string? idempotencyKey = null)
|
||||
{
|
||||
return new ActionProposal
|
||||
{
|
||||
ProposalId = Guid.NewGuid().ToString(),
|
||||
ActionType = actionType,
|
||||
Label = $"Test {actionType}",
|
||||
Parameters = parameters.ToImmutableDictionary(),
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
IdempotencyKey = idempotencyKey
|
||||
};
|
||||
}
|
||||
|
||||
private static ActionContext CreateContext(string tenantId)
|
||||
{
|
||||
return new ActionContext
|
||||
{
|
||||
TenantId = tenantId,
|
||||
UserId = "test-user",
|
||||
UserRoles = ImmutableArray.Create("security-analyst"),
|
||||
Environment = "development"
|
||||
};
|
||||
}
|
||||
|
||||
private ActionExecutionResult CreateExecutionResult(string executionId)
|
||||
{
|
||||
return new ActionExecutionResult
|
||||
{
|
||||
ExecutionId = executionId,
|
||||
Outcome = ActionExecutionOutcome.Success,
|
||||
Message = "Success",
|
||||
StartedAt = _timeProvider.GetUtcNow(),
|
||||
CompletedAt = _timeProvider.GetUtcNow(),
|
||||
CanRollback = false
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
// <copyright file="InMemoryRunStoreTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.AdvisoryAI.Runs;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="InMemoryRunStore"/>.
|
||||
/// Sprint: SPRINT_20260109_011_003_BE Task: RUN-009
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class InMemoryRunStoreTests
|
||||
{
|
||||
private readonly InMemoryRunStore _store = new();
|
||||
|
||||
[Fact]
|
||||
public async Task SaveAsync_And_GetAsync_RoundTrip()
|
||||
{
|
||||
// Arrange
|
||||
var run = CreateTestRun("run-1", "tenant-1");
|
||||
|
||||
// Act
|
||||
await _store.SaveAsync(run);
|
||||
var retrieved = await _store.GetAsync("tenant-1", "run-1");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(retrieved);
|
||||
Assert.Equal(run.RunId, retrieved.RunId);
|
||||
Assert.Equal(run.TenantId, retrieved.TenantId);
|
||||
Assert.Equal(run.Title, retrieved.Title);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAsync_DifferentTenant_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var run = CreateTestRun("run-1", "tenant-1");
|
||||
await _store.SaveAsync(run);
|
||||
|
||||
// Act
|
||||
var retrieved = await _store.GetAsync("tenant-2", "run-1");
|
||||
|
||||
// Assert
|
||||
Assert.Null(retrieved);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteAsync_ExistingRun_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var run = CreateTestRun("run-1", "tenant-1");
|
||||
await _store.SaveAsync(run);
|
||||
|
||||
// Act
|
||||
var deleted = await _store.DeleteAsync("tenant-1", "run-1");
|
||||
|
||||
// Assert
|
||||
Assert.True(deleted);
|
||||
Assert.Null(await _store.GetAsync("tenant-1", "run-1"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteAsync_NonExistentRun_ReturnsFalse()
|
||||
{
|
||||
// Act
|
||||
var deleted = await _store.DeleteAsync("tenant-1", "non-existent");
|
||||
|
||||
// Assert
|
||||
Assert.False(deleted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_FiltersByTenant()
|
||||
{
|
||||
// Arrange
|
||||
await _store.SaveAsync(CreateTestRun("run-1", "tenant-1"));
|
||||
await _store.SaveAsync(CreateTestRun("run-2", "tenant-1"));
|
||||
await _store.SaveAsync(CreateTestRun("run-3", "tenant-2"));
|
||||
|
||||
// Act
|
||||
var (runs, count) = await _store.QueryAsync(new RunQuery { TenantId = "tenant-1" });
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, count);
|
||||
Assert.All(runs, r => Assert.Equal("tenant-1", r.TenantId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_FiltersByStatus()
|
||||
{
|
||||
// Arrange
|
||||
await _store.SaveAsync(CreateTestRun("run-1", "tenant-1") with { Status = RunStatus.Active });
|
||||
await _store.SaveAsync(CreateTestRun("run-2", "tenant-1") with { Status = RunStatus.Completed });
|
||||
await _store.SaveAsync(CreateTestRun("run-3", "tenant-1") with { Status = RunStatus.Active });
|
||||
|
||||
// Act
|
||||
var (runs, count) = await _store.QueryAsync(new RunQuery
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
Statuses = [RunStatus.Active]
|
||||
});
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, count);
|
||||
Assert.All(runs, r => Assert.Equal(RunStatus.Active, r.Status));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_FiltersByInitiator()
|
||||
{
|
||||
// Arrange
|
||||
await _store.SaveAsync(CreateTestRun("run-1", "tenant-1", initiatedBy: "user-1"));
|
||||
await _store.SaveAsync(CreateTestRun("run-2", "tenant-1", initiatedBy: "user-2"));
|
||||
await _store.SaveAsync(CreateTestRun("run-3", "tenant-1", initiatedBy: "user-1"));
|
||||
|
||||
// Act
|
||||
var (runs, count) = await _store.QueryAsync(new RunQuery
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
InitiatedBy = "user-1"
|
||||
});
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, count);
|
||||
Assert.All(runs, r => Assert.Equal("user-1", r.InitiatedBy));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_FiltersByCveId()
|
||||
{
|
||||
// Arrange
|
||||
await _store.SaveAsync(CreateTestRun("run-1", "tenant-1", cveId: "CVE-2024-1111"));
|
||||
await _store.SaveAsync(CreateTestRun("run-2", "tenant-1", cveId: "CVE-2024-2222"));
|
||||
await _store.SaveAsync(CreateTestRun("run-3", "tenant-1", cveId: "CVE-2024-1111"));
|
||||
|
||||
// Act
|
||||
var (runs, count) = await _store.QueryAsync(new RunQuery
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
CveId = "CVE-2024-1111"
|
||||
});
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, count);
|
||||
Assert.All(runs, r => Assert.Equal("CVE-2024-1111", r.Context.FocusedCveId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_Pagination_WorksCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
await _store.SaveAsync(CreateTestRun($"run-{i}", "tenant-1"));
|
||||
}
|
||||
|
||||
// Act
|
||||
var (page1, total1) = await _store.QueryAsync(new RunQuery
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
Skip = 0,
|
||||
Take = 3
|
||||
});
|
||||
|
||||
var (page2, total2) = await _store.QueryAsync(new RunQuery
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
Skip = 3,
|
||||
Take = 3
|
||||
});
|
||||
|
||||
// Assert
|
||||
Assert.Equal(10, total1);
|
||||
Assert.Equal(10, total2);
|
||||
Assert.Equal(3, page1.Length);
|
||||
Assert.Equal(3, page2.Length);
|
||||
Assert.True(page1.All(r => !page2.Contains(r)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByStatusAsync_ReturnsCorrectRuns()
|
||||
{
|
||||
// Arrange
|
||||
await _store.SaveAsync(CreateTestRun("run-1", "tenant-1") with { Status = RunStatus.Active });
|
||||
await _store.SaveAsync(CreateTestRun("run-2", "tenant-1") with { Status = RunStatus.PendingApproval });
|
||||
await _store.SaveAsync(CreateTestRun("run-3", "tenant-1") with { Status = RunStatus.Completed });
|
||||
|
||||
// Act
|
||||
var runs = await _store.GetByStatusAsync(
|
||||
"tenant-1", [RunStatus.Active, RunStatus.PendingApproval]);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, runs.Length);
|
||||
Assert.DoesNotContain(runs, r => r.Status == RunStatus.Completed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetActiveForUserAsync_ReturnsUserRuns()
|
||||
{
|
||||
// Arrange
|
||||
await _store.SaveAsync(CreateTestRun("run-1", "tenant-1", initiatedBy: "user-1") with { Status = RunStatus.Active });
|
||||
await _store.SaveAsync(CreateTestRun("run-2", "tenant-1", initiatedBy: "user-2") with { Status = RunStatus.Active });
|
||||
await _store.SaveAsync(CreateTestRun("run-3", "tenant-1", initiatedBy: "user-1") with { Status = RunStatus.Completed });
|
||||
|
||||
// Act
|
||||
var runs = await _store.GetActiveForUserAsync("tenant-1", "user-1");
|
||||
|
||||
// Assert
|
||||
Assert.Single(runs);
|
||||
Assert.Equal("user-1", runs[0].InitiatedBy);
|
||||
Assert.Equal(RunStatus.Active, runs[0].Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPendingApprovalAsync_ReturnsAllPending()
|
||||
{
|
||||
// Arrange
|
||||
await _store.SaveAsync(CreateTestRun("run-1", "tenant-1") with
|
||||
{
|
||||
Status = RunStatus.PendingApproval,
|
||||
Approval = new ApprovalInfo { Required = true, Approvers = ["approver-1"] }
|
||||
});
|
||||
await _store.SaveAsync(CreateTestRun("run-2", "tenant-1") with
|
||||
{
|
||||
Status = RunStatus.PendingApproval,
|
||||
Approval = new ApprovalInfo { Required = true, Approvers = ["approver-2"] }
|
||||
});
|
||||
|
||||
// Act - no filter
|
||||
var allPending = await _store.GetPendingApprovalAsync("tenant-1");
|
||||
|
||||
// Act - with filter
|
||||
var approver1Pending = await _store.GetPendingApprovalAsync("tenant-1", "approver-1");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, allPending.Length);
|
||||
Assert.Single(approver1Pending);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateStatusAsync_UpdatesStatus()
|
||||
{
|
||||
// Arrange
|
||||
var run = CreateTestRun("run-1", "tenant-1") with { Status = RunStatus.Active };
|
||||
await _store.SaveAsync(run);
|
||||
|
||||
// Act
|
||||
var updated = await _store.UpdateStatusAsync("tenant-1", "run-1", RunStatus.Completed);
|
||||
|
||||
// Assert
|
||||
Assert.True(updated);
|
||||
var retrieved = await _store.GetAsync("tenant-1", "run-1");
|
||||
Assert.Equal(RunStatus.Completed, retrieved!.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateStatusAsync_NonExistent_ReturnsFalse()
|
||||
{
|
||||
// Act
|
||||
var updated = await _store.UpdateStatusAsync("tenant-1", "non-existent", RunStatus.Active);
|
||||
|
||||
// Assert
|
||||
Assert.False(updated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Clear_RemovesAllRuns()
|
||||
{
|
||||
// Arrange
|
||||
_store.SaveAsync(CreateTestRun("run-1", "tenant-1")).Wait();
|
||||
_store.SaveAsync(CreateTestRun("run-2", "tenant-1")).Wait();
|
||||
|
||||
// Act
|
||||
_store.Clear();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(0, _store.Count);
|
||||
}
|
||||
|
||||
private static Run CreateTestRun(
|
||||
string runId,
|
||||
string tenantId,
|
||||
string initiatedBy = "test-user",
|
||||
string? cveId = null) => new()
|
||||
{
|
||||
RunId = runId,
|
||||
TenantId = tenantId,
|
||||
InitiatedBy = initiatedBy,
|
||||
Title = $"Test Run {runId}",
|
||||
Status = RunStatus.Created,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
Context = new RunContext
|
||||
{
|
||||
FocusedCveId = cveId
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,671 @@
|
||||
// <copyright file="RunServiceIntegrationTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.AdvisoryAI.Runs;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Tests.Integration;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for RunService covering full lifecycle scenarios.
|
||||
/// Sprint: SPRINT_20260109_011_003_BE Task: RUN-009
|
||||
/// </summary>
|
||||
[Trait("Category", "Integration")]
|
||||
public sealed class RunServiceIntegrationTests : IAsyncLifetime
|
||||
{
|
||||
private readonly InMemoryRunStore _store = new();
|
||||
private readonly FakeTimeProvider _timeProvider = new();
|
||||
private readonly DeterministicGuidGenerator _guidGenerator = new();
|
||||
private readonly RunService _service;
|
||||
|
||||
private readonly string _testTenantId = "test-tenant";
|
||||
|
||||
public RunServiceIntegrationTests()
|
||||
{
|
||||
_timeProvider.SetUtcNow(new DateTimeOffset(2026, 1, 10, 10, 0, 0, TimeSpan.Zero));
|
||||
_service = new RunService(
|
||||
_store,
|
||||
_timeProvider,
|
||||
_guidGenerator,
|
||||
NullLogger<RunService>.Instance);
|
||||
}
|
||||
|
||||
public ValueTask InitializeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
_store.Clear();
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FullConversationToAttestationFlow_Succeeds()
|
||||
{
|
||||
// Phase 1: Create Run
|
||||
var run = await _service.CreateAsync(new CreateRunRequest
|
||||
{
|
||||
TenantId = _testTenantId,
|
||||
InitiatedBy = "alice@example.com",
|
||||
Title = "CVE-2024-1234 Investigation",
|
||||
Objective = "Determine if vulnerable code path is reachable",
|
||||
Context = new RunContext
|
||||
{
|
||||
FocusedCveId = "CVE-2024-1234",
|
||||
FocusedComponent = "pkg:npm/express@4.17.1",
|
||||
Tags = ["critical", "production"]
|
||||
}
|
||||
});
|
||||
|
||||
run.Should().NotBeNull();
|
||||
run.Status.Should().Be(RunStatus.Created);
|
||||
run.Events.Should().HaveCount(1);
|
||||
run.Events[0].Type.Should().Be(RunEventType.Created);
|
||||
|
||||
// Phase 2: Conversation (User turn -> Assistant turn -> User turn -> Assistant turn)
|
||||
_timeProvider.Advance(TimeSpan.FromSeconds(1));
|
||||
var userTurn1 = await _service.AddUserTurnAsync(
|
||||
_testTenantId, run.RunId,
|
||||
"Can you analyze CVE-2024-1234 and tell me if we're affected?",
|
||||
"alice@example.com",
|
||||
[new EvidenceLink { Uri = "sbom:digest/sha256:abc123", Type = "sbom", Label = "Application SBOM" }]);
|
||||
|
||||
userTurn1.Type.Should().Be(RunEventType.UserTurn);
|
||||
userTurn1.EvidenceLinks.Should().HaveCount(1);
|
||||
|
||||
// Run should be active now
|
||||
var activeRun = await _service.GetAsync(_testTenantId, run.RunId);
|
||||
activeRun!.Status.Should().Be(RunStatus.Active);
|
||||
|
||||
_timeProvider.Advance(TimeSpan.FromSeconds(2));
|
||||
var assistantTurn1 = await _service.AddAssistantTurnAsync(
|
||||
_testTenantId, run.RunId,
|
||||
"Based on the SBOM analysis, express@4.17.1 is present. I found that the vulnerable function `parseQuery` is not called in your codebase.",
|
||||
[new EvidenceLink { Uri = "reach:analysis/CVE-2024-1234", Type = "reachability" }]);
|
||||
|
||||
assistantTurn1.Type.Should().Be(RunEventType.AssistantTurn);
|
||||
|
||||
_timeProvider.Advance(TimeSpan.FromSeconds(1));
|
||||
await _service.AddUserTurnAsync(
|
||||
_testTenantId, run.RunId,
|
||||
"Great, can you propose a VEX statement for this?",
|
||||
"alice@example.com");
|
||||
|
||||
// Phase 3: Action Proposal
|
||||
_timeProvider.Advance(TimeSpan.FromSeconds(2));
|
||||
var proposalEvent = await _service.ProposeActionAsync(_testTenantId, run.RunId, new ProposeActionRequest
|
||||
{
|
||||
ActionType = "vex:publish",
|
||||
Subject = "CVE-2024-1234",
|
||||
Rationale = "Vulnerable function parseQuery is not reachable in application code paths",
|
||||
RequiresApproval = true,
|
||||
Parameters = new Dictionary<string, string>
|
||||
{
|
||||
["vex_status"] = "not_affected",
|
||||
["vex_justification"] = "vulnerable_code_not_in_execute_path"
|
||||
}.ToImmutableDictionary(),
|
||||
EvidenceLinks = [new EvidenceLink
|
||||
{
|
||||
Uri = "reach:analysis/CVE-2024-1234",
|
||||
Type = "reachability",
|
||||
Label = "Reachability Analysis"
|
||||
}]
|
||||
});
|
||||
|
||||
proposalEvent.Type.Should().Be(RunEventType.ActionProposed);
|
||||
|
||||
// Phase 4: Request Approval
|
||||
_timeProvider.Advance(TimeSpan.FromSeconds(1));
|
||||
var pendingRun = await _service.RequestApprovalAsync(
|
||||
_testTenantId, run.RunId,
|
||||
["bob@example.com", "charlie@example.com"],
|
||||
"Please review VEX statement for CVE-2024-1234");
|
||||
|
||||
pendingRun.Status.Should().Be(RunStatus.PendingApproval);
|
||||
pendingRun.Approval.Should().NotBeNull();
|
||||
pendingRun.Approval!.Approvers.Should().Contain("bob@example.com");
|
||||
|
||||
// Phase 5: Approval
|
||||
_timeProvider.Advance(TimeSpan.FromMinutes(5));
|
||||
var approvedRun = await _service.ApproveAsync(
|
||||
_testTenantId, run.RunId,
|
||||
approved: true,
|
||||
approverId: "bob@example.com",
|
||||
reason: "Reachability analysis is sound, VEX statement approved");
|
||||
|
||||
approvedRun.Status.Should().Be(RunStatus.Approved);
|
||||
approvedRun.Approval!.Approved.Should().BeTrue();
|
||||
approvedRun.Approval.ApprovedBy.Should().Be("bob@example.com");
|
||||
|
||||
// Phase 6: Execute Action
|
||||
_timeProvider.Advance(TimeSpan.FromSeconds(1));
|
||||
var executedEvent = await _service.ExecuteActionAsync(
|
||||
_testTenantId, run.RunId, proposalEvent.EventId);
|
||||
|
||||
executedEvent.Type.Should().Be(RunEventType.ActionExecuted);
|
||||
|
||||
// Phase 7: Add VEX Artifact
|
||||
_timeProvider.Advance(TimeSpan.FromSeconds(1));
|
||||
var vexArtifact = new RunArtifact
|
||||
{
|
||||
ArtifactId = "vex-001",
|
||||
Type = ArtifactType.VexStatement,
|
||||
Name = "VEX Statement for CVE-2024-1234",
|
||||
CreatedAt = _timeProvider.GetUtcNow(),
|
||||
ContentDigest = "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
MediaType = "application/vnd.openvex+json",
|
||||
StorageUri = "stellaops://artifacts/vex/vex-001.json"
|
||||
};
|
||||
|
||||
var withArtifact = await _service.AddArtifactAsync(_testTenantId, run.RunId, vexArtifact);
|
||||
withArtifact.Artifacts.Should().HaveCount(1);
|
||||
withArtifact.Artifacts[0].ArtifactId.Should().Be("vex-001");
|
||||
|
||||
// Phase 8: Complete Run
|
||||
_timeProvider.Advance(TimeSpan.FromSeconds(1));
|
||||
var completedRun = await _service.CompleteAsync(
|
||||
_testTenantId, run.RunId,
|
||||
"Investigation complete: CVE-2024-1234 not affected, VEX published");
|
||||
|
||||
completedRun.Status.Should().Be(RunStatus.Completed);
|
||||
completedRun.CompletedAt.Should().NotBeNull();
|
||||
completedRun.ContentDigest.Should().NotBeNullOrEmpty();
|
||||
|
||||
// Phase 9: Attest Run
|
||||
_timeProvider.Advance(TimeSpan.FromSeconds(1));
|
||||
var attestedRun = await _service.AttestAsync(_testTenantId, run.RunId);
|
||||
|
||||
attestedRun.Attestation.Should().NotBeNull();
|
||||
attestedRun.Attestation!.AttestationId.Should().StartWith("att-");
|
||||
attestedRun.Attestation.ContentDigest.Should().Be(completedRun.ContentDigest);
|
||||
|
||||
// Verify final timeline
|
||||
var timeline = await _service.GetTimelineAsync(_testTenantId, run.RunId);
|
||||
timeline.Length.Should().BeGreaterThanOrEqualTo(8);
|
||||
|
||||
// Verify event sequence is properly ordered
|
||||
for (var i = 1; i < timeline.Length; i++)
|
||||
{
|
||||
timeline[i].SequenceNumber.Should().BeGreaterThan(timeline[i - 1].SequenceNumber);
|
||||
timeline[i].Timestamp.Should().BeOnOrAfter(timeline[i - 1].Timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TimelinePersistence_EventsAreAppendOnly()
|
||||
{
|
||||
// Arrange
|
||||
var run = await _service.CreateAsync(new CreateRunRequest
|
||||
{
|
||||
TenantId = _testTenantId,
|
||||
InitiatedBy = "user-1",
|
||||
Title = "Timeline Test"
|
||||
});
|
||||
|
||||
var initialEventCount = run.Events.Length;
|
||||
|
||||
// Act - Add multiple events
|
||||
_timeProvider.Advance(TimeSpan.FromSeconds(1));
|
||||
await _service.AddUserTurnAsync(_testTenantId, run.RunId, "Message 1", "user-1");
|
||||
|
||||
_timeProvider.Advance(TimeSpan.FromSeconds(1));
|
||||
await _service.AddAssistantTurnAsync(_testTenantId, run.RunId, "Response 1");
|
||||
|
||||
_timeProvider.Advance(TimeSpan.FromSeconds(1));
|
||||
await _service.AddUserTurnAsync(_testTenantId, run.RunId, "Message 2", "user-1");
|
||||
|
||||
_timeProvider.Advance(TimeSpan.FromSeconds(1));
|
||||
await _service.AddAssistantTurnAsync(_testTenantId, run.RunId, "Response 2");
|
||||
|
||||
// Assert
|
||||
var finalRun = await _service.GetAsync(_testTenantId, run.RunId);
|
||||
finalRun!.Events.Length.Should().Be(initialEventCount + 4);
|
||||
|
||||
// Events should have monotonically increasing sequence numbers
|
||||
var timeline = await _service.GetTimelineAsync(_testTenantId, run.RunId);
|
||||
var sequenceNumbers = timeline.Select(e => e.SequenceNumber).ToList();
|
||||
sequenceNumbers.Should().BeInAscendingOrder();
|
||||
|
||||
// Event timestamps should not go backwards
|
||||
var timestamps = timeline.Select(e => e.Timestamp).ToList();
|
||||
for (var i = 1; i < timestamps.Count; i++)
|
||||
{
|
||||
timestamps[i].Should().BeOnOrAfter(timestamps[i - 1]);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ArtifactStorageAndRetrieval_MultipleArtifactTypes()
|
||||
{
|
||||
// Arrange
|
||||
var run = await _service.CreateAsync(new CreateRunRequest
|
||||
{
|
||||
TenantId = _testTenantId,
|
||||
InitiatedBy = "user-1",
|
||||
Title = "Artifact Test"
|
||||
});
|
||||
|
||||
var artifacts = new[]
|
||||
{
|
||||
new RunArtifact
|
||||
{
|
||||
ArtifactId = "vex-001",
|
||||
Type = ArtifactType.VexStatement,
|
||||
Name = "VEX Statement",
|
||||
CreatedAt = _timeProvider.GetUtcNow(),
|
||||
ContentDigest = "sha256:vex123",
|
||||
MediaType = "application/vnd.openvex+json"
|
||||
},
|
||||
new RunArtifact
|
||||
{
|
||||
ArtifactId = "report-001",
|
||||
Type = ArtifactType.Report,
|
||||
Name = "Investigation Report",
|
||||
CreatedAt = _timeProvider.GetUtcNow(),
|
||||
ContentDigest = "sha256:report456",
|
||||
MediaType = "application/pdf"
|
||||
},
|
||||
new RunArtifact
|
||||
{
|
||||
ArtifactId = "evidence-001",
|
||||
Type = ArtifactType.EvidencePack,
|
||||
Name = "Evidence Bundle",
|
||||
CreatedAt = _timeProvider.GetUtcNow(),
|
||||
ContentDigest = "sha256:evidence789",
|
||||
MediaType = "application/zip"
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
foreach (var artifact in artifacts)
|
||||
{
|
||||
_timeProvider.Advance(TimeSpan.FromSeconds(1));
|
||||
await _service.AddArtifactAsync(_testTenantId, run.RunId, artifact);
|
||||
}
|
||||
|
||||
// Assert
|
||||
var finalRun = await _service.GetAsync(_testTenantId, run.RunId);
|
||||
finalRun!.Artifacts.Should().HaveCount(3);
|
||||
|
||||
// Verify all artifact types are present
|
||||
finalRun.Artifacts.Should().Contain(a => a.Type == ArtifactType.VexStatement);
|
||||
finalRun.Artifacts.Should().Contain(a => a.Type == ArtifactType.Report);
|
||||
finalRun.Artifacts.Should().Contain(a => a.Type == ArtifactType.EvidencePack);
|
||||
|
||||
// Verify artifact details preserved
|
||||
var vexArtifact = finalRun.Artifacts.Single(a => a.ArtifactId == "vex-001");
|
||||
vexArtifact.ContentDigest.Should().Be("sha256:vex123");
|
||||
vexArtifact.MediaType.Should().Be("application/vnd.openvex+json");
|
||||
|
||||
// Verify artifact events in timeline
|
||||
var timeline = await _service.GetTimelineAsync(_testTenantId, run.RunId);
|
||||
timeline.Where(e => e.Type == RunEventType.ArtifactProduced).Should().HaveCount(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvidencePackAttachment_TracksAllPacks()
|
||||
{
|
||||
// Arrange
|
||||
var run = await _service.CreateAsync(new CreateRunRequest
|
||||
{
|
||||
TenantId = _testTenantId,
|
||||
InitiatedBy = "user-1",
|
||||
Title = "Evidence Pack Test"
|
||||
});
|
||||
|
||||
var evidencePacks = new[]
|
||||
{
|
||||
new EvidencePackReference
|
||||
{
|
||||
PackId = "pack-001",
|
||||
Digest = "sha256:pack001hash",
|
||||
AttachedAt = _timeProvider.GetUtcNow(),
|
||||
PackType = "vulnerability-analysis"
|
||||
},
|
||||
new EvidencePackReference
|
||||
{
|
||||
PackId = "pack-002",
|
||||
Digest = "sha256:pack002hash",
|
||||
AttachedAt = _timeProvider.GetUtcNow(),
|
||||
PackType = "reachability-evidence"
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
foreach (var pack in evidencePacks)
|
||||
{
|
||||
_timeProvider.Advance(TimeSpan.FromSeconds(1));
|
||||
await _service.AttachEvidencePackAsync(_testTenantId, run.RunId, pack);
|
||||
}
|
||||
|
||||
// Assert
|
||||
var finalRun = await _service.GetAsync(_testTenantId, run.RunId);
|
||||
finalRun!.EvidencePacks.Should().HaveCount(2);
|
||||
finalRun.EvidencePacks.Should().Contain(p => p.PackId == "pack-001");
|
||||
finalRun.EvidencePacks.Should().Contain(p => p.PackId == "pack-002");
|
||||
|
||||
// Verify evidence events in timeline
|
||||
var timeline = await _service.GetTimelineAsync(_testTenantId, run.RunId);
|
||||
timeline.Where(e => e.Type == RunEventType.EvidenceAttached).Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunContentDigest_IsDeterministic()
|
||||
{
|
||||
// Create two identical runs
|
||||
_guidGenerator.Reset();
|
||||
var run1 = await _service.CreateAsync(new CreateRunRequest
|
||||
{
|
||||
TenantId = _testTenantId,
|
||||
InitiatedBy = "user-1",
|
||||
Title = "Determinism Test"
|
||||
});
|
||||
|
||||
await _service.AddUserTurnAsync(_testTenantId, run1.RunId, "Test message", "user-1");
|
||||
await _service.AddAssistantTurnAsync(_testTenantId, run1.RunId, "Test response");
|
||||
var completed1 = await _service.CompleteAsync(_testTenantId, run1.RunId);
|
||||
|
||||
// Reset state and create identical run
|
||||
_store.Clear();
|
||||
_guidGenerator.Reset();
|
||||
_timeProvider.SetUtcNow(new DateTimeOffset(2026, 1, 10, 10, 0, 0, TimeSpan.Zero));
|
||||
|
||||
var run2 = await _service.CreateAsync(new CreateRunRequest
|
||||
{
|
||||
TenantId = _testTenantId,
|
||||
InitiatedBy = "user-1",
|
||||
Title = "Determinism Test"
|
||||
});
|
||||
|
||||
await _service.AddUserTurnAsync(_testTenantId, run2.RunId, "Test message", "user-1");
|
||||
await _service.AddAssistantTurnAsync(_testTenantId, run2.RunId, "Test response");
|
||||
var completed2 = await _service.CompleteAsync(_testTenantId, run2.RunId);
|
||||
|
||||
// Assert - digests should be identical for identical content
|
||||
completed1.ContentDigest.Should().Be(completed2.ContentDigest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TenantIsolation_RunsAreIsolatedByTenant()
|
||||
{
|
||||
// Arrange - Create runs in different tenants
|
||||
var tenant1Run = await _service.CreateAsync(new CreateRunRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
InitiatedBy = "user-1",
|
||||
Title = "Tenant 1 Run"
|
||||
});
|
||||
|
||||
var tenant2Run = await _service.CreateAsync(new CreateRunRequest
|
||||
{
|
||||
TenantId = "tenant-2",
|
||||
InitiatedBy = "user-1",
|
||||
Title = "Tenant 2 Run"
|
||||
});
|
||||
|
||||
// Act & Assert - Cannot access other tenant's run
|
||||
var crossTenantAccess = await _service.GetAsync("tenant-1", tenant2Run.RunId);
|
||||
crossTenantAccess.Should().BeNull();
|
||||
|
||||
// Query only returns own tenant's runs
|
||||
var tenant1Query = await _service.QueryAsync(new RunQuery { TenantId = "tenant-1" });
|
||||
tenant1Query.Runs.Should().HaveCount(1);
|
||||
tenant1Query.Runs[0].RunId.Should().Be(tenant1Run.RunId);
|
||||
|
||||
var tenant2Query = await _service.QueryAsync(new RunQuery { TenantId = "tenant-2" });
|
||||
tenant2Query.Runs.Should().HaveCount(1);
|
||||
tenant2Query.Runs[0].RunId.Should().Be(tenant2Run.RunId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandoffWorkflow_TransfersOwnershipCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var run = await _service.CreateAsync(new CreateRunRequest
|
||||
{
|
||||
TenantId = _testTenantId,
|
||||
InitiatedBy = "alice@example.com",
|
||||
Title = "Handoff Test"
|
||||
});
|
||||
|
||||
await _service.AddUserTurnAsync(_testTenantId, run.RunId, "Starting investigation", "alice@example.com");
|
||||
await _service.AddAssistantTurnAsync(_testTenantId, run.RunId, "Initial analysis complete");
|
||||
|
||||
// Act - Hand off to another user
|
||||
_timeProvider.Advance(TimeSpan.FromMinutes(10));
|
||||
var handedOff = await _service.HandOffAsync(
|
||||
_testTenantId, run.RunId,
|
||||
"bob@example.com",
|
||||
"Please continue this investigation - I need to focus on another issue");
|
||||
|
||||
// Assert
|
||||
handedOff.Metadata["current_owner"].Should().Be("bob@example.com");
|
||||
|
||||
// Verify handoff event in timeline
|
||||
var timeline = await _service.GetTimelineAsync(_testTenantId, run.RunId);
|
||||
var handoffEvent = timeline.FirstOrDefault(e => e.Type == RunEventType.HandedOff);
|
||||
handoffEvent.Should().NotBeNull();
|
||||
handoffEvent!.Metadata["to_user"].Should().Be("bob@example.com");
|
||||
|
||||
// New owner can continue the run
|
||||
_timeProvider.Advance(TimeSpan.FromSeconds(1));
|
||||
var continuedEvent = await _service.AddUserTurnAsync(
|
||||
_testTenantId, run.RunId,
|
||||
"Continuing investigation from Alice",
|
||||
"bob@example.com");
|
||||
|
||||
continuedEvent.Should().NotBeNull();
|
||||
continuedEvent.ActorId.Should().Be("bob@example.com");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RejectionWorkflow_StopsRunOnRejection()
|
||||
{
|
||||
// Arrange
|
||||
var run = await _service.CreateAsync(new CreateRunRequest
|
||||
{
|
||||
TenantId = _testTenantId,
|
||||
InitiatedBy = "user-1",
|
||||
Title = "Rejection Test"
|
||||
});
|
||||
|
||||
await _service.AddUserTurnAsync(_testTenantId, run.RunId, "Question", "user-1");
|
||||
await _service.ProposeActionAsync(_testTenantId, run.RunId, new ProposeActionRequest
|
||||
{
|
||||
ActionType = "vex:publish",
|
||||
RequiresApproval = true
|
||||
});
|
||||
await _service.RequestApprovalAsync(_testTenantId, run.RunId, ["approver-1"]);
|
||||
|
||||
// Act - Reject
|
||||
var rejected = await _service.ApproveAsync(
|
||||
_testTenantId, run.RunId,
|
||||
approved: false,
|
||||
approverId: "approver-1",
|
||||
reason: "Insufficient evidence for not_affected status");
|
||||
|
||||
// Assert
|
||||
rejected.Status.Should().Be(RunStatus.Rejected);
|
||||
rejected.Approval!.Approved.Should().BeFalse();
|
||||
rejected.CompletedAt.Should().NotBeNull();
|
||||
|
||||
// Cannot add more events to rejected run
|
||||
await _service.Invoking(s => s.AddUserTurnAsync(
|
||||
_testTenantId, run.RunId, "More questions", "user-1"))
|
||||
.Should().ThrowAsync<InvalidOperationException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CancellationWorkflow_PreservesHistory()
|
||||
{
|
||||
// Arrange
|
||||
var run = await _service.CreateAsync(new CreateRunRequest
|
||||
{
|
||||
TenantId = _testTenantId,
|
||||
InitiatedBy = "user-1",
|
||||
Title = "Cancellation Test"
|
||||
});
|
||||
|
||||
await _service.AddUserTurnAsync(_testTenantId, run.RunId, "Question 1", "user-1");
|
||||
await _service.AddAssistantTurnAsync(_testTenantId, run.RunId, "Answer 1");
|
||||
await _service.AddUserTurnAsync(_testTenantId, run.RunId, "Question 2", "user-1");
|
||||
|
||||
var eventCountBeforeCancel = (await _service.GetAsync(_testTenantId, run.RunId))!.Events.Length;
|
||||
|
||||
// Act
|
||||
_timeProvider.Advance(TimeSpan.FromMinutes(30));
|
||||
var cancelled = await _service.CancelAsync(
|
||||
_testTenantId, run.RunId,
|
||||
"Investigation no longer needed - issue resolved by upstream fix");
|
||||
|
||||
// Assert
|
||||
cancelled.Status.Should().Be(RunStatus.Cancelled);
|
||||
cancelled.CompletedAt.Should().NotBeNull();
|
||||
|
||||
// History is preserved
|
||||
cancelled.Events.Length.Should().Be(eventCountBeforeCancel + 1);
|
||||
cancelled.Events.Last().Type.Should().Be(RunEventType.Cancelled);
|
||||
|
||||
// Timeline still accessible
|
||||
var timeline = await _service.GetTimelineAsync(_testTenantId, run.RunId);
|
||||
timeline.Length.Should().Be(eventCountBeforeCancel + 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AttestAsync_FailsForNonCompletedRun()
|
||||
{
|
||||
// Arrange
|
||||
var run = await _service.CreateAsync(new CreateRunRequest
|
||||
{
|
||||
TenantId = _testTenantId,
|
||||
InitiatedBy = "user-1",
|
||||
Title = "Attest Validation Test"
|
||||
});
|
||||
|
||||
// Act & Assert - Cannot attest non-completed run
|
||||
await _service.Invoking(s => s.AttestAsync(_testTenantId, run.RunId))
|
||||
.Should().ThrowAsync<InvalidOperationException>()
|
||||
.WithMessage("*not completed*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AttestAsync_FailsForAlreadyAttestedRun()
|
||||
{
|
||||
// Arrange
|
||||
var run = await _service.CreateAsync(new CreateRunRequest
|
||||
{
|
||||
TenantId = _testTenantId,
|
||||
InitiatedBy = "user-1",
|
||||
Title = "Double Attest Test"
|
||||
});
|
||||
|
||||
await _service.AddUserTurnAsync(_testTenantId, run.RunId, "Q", "user-1");
|
||||
await _service.CompleteAsync(_testTenantId, run.RunId);
|
||||
await _service.AttestAsync(_testTenantId, run.RunId);
|
||||
|
||||
// Act & Assert - Cannot attest twice
|
||||
await _service.Invoking(s => s.AttestAsync(_testTenantId, run.RunId))
|
||||
.Should().ThrowAsync<InvalidOperationException>()
|
||||
.WithMessage("*already attested*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_PaginationWorks()
|
||||
{
|
||||
// Arrange - Create 10 runs
|
||||
for (var i = 0; i < 10; i++)
|
||||
{
|
||||
_timeProvider.Advance(TimeSpan.FromMinutes(1));
|
||||
await _service.CreateAsync(new CreateRunRequest
|
||||
{
|
||||
TenantId = _testTenantId,
|
||||
InitiatedBy = "user-1",
|
||||
Title = $"Run {i + 1}"
|
||||
});
|
||||
}
|
||||
|
||||
// Act - Page 1
|
||||
var page1 = await _service.QueryAsync(new RunQuery
|
||||
{
|
||||
TenantId = _testTenantId,
|
||||
Skip = 0,
|
||||
Take = 3
|
||||
});
|
||||
|
||||
// Act - Page 2
|
||||
var page2 = await _service.QueryAsync(new RunQuery
|
||||
{
|
||||
TenantId = _testTenantId,
|
||||
Skip = 3,
|
||||
Take = 3
|
||||
});
|
||||
|
||||
// Assert
|
||||
page1.TotalCount.Should().Be(10);
|
||||
page1.Runs.Should().HaveCount(3);
|
||||
page1.HasMore.Should().BeTrue();
|
||||
|
||||
page2.Runs.Should().HaveCount(3);
|
||||
page2.HasMore.Should().BeTrue();
|
||||
|
||||
// No overlap between pages
|
||||
page1.Runs.Select(r => r.RunId)
|
||||
.Should().NotIntersectWith(page2.Runs.Select(r => r.RunId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTimelineAsync_PaginationWorks()
|
||||
{
|
||||
// Arrange
|
||||
var run = await _service.CreateAsync(new CreateRunRequest
|
||||
{
|
||||
TenantId = _testTenantId,
|
||||
InitiatedBy = "user-1",
|
||||
Title = "Timeline Pagination Test"
|
||||
});
|
||||
|
||||
// Add 20 events
|
||||
for (var i = 0; i < 10; i++)
|
||||
{
|
||||
_timeProvider.Advance(TimeSpan.FromSeconds(1));
|
||||
await _service.AddUserTurnAsync(_testTenantId, run.RunId, $"Message {i}", "user-1");
|
||||
_timeProvider.Advance(TimeSpan.FromSeconds(1));
|
||||
await _service.AddAssistantTurnAsync(_testTenantId, run.RunId, $"Response {i}");
|
||||
}
|
||||
|
||||
// Act
|
||||
var page1 = await _service.GetTimelineAsync(_testTenantId, run.RunId, skip: 0, take: 5);
|
||||
var page2 = await _service.GetTimelineAsync(_testTenantId, run.RunId, skip: 5, take: 5);
|
||||
var page3 = await _service.GetTimelineAsync(_testTenantId, run.RunId, skip: 10, take: 5);
|
||||
|
||||
// Assert
|
||||
page1.Should().HaveCount(5);
|
||||
page2.Should().HaveCount(5);
|
||||
page3.Should().HaveCount(5);
|
||||
|
||||
// Verify sequence continuity
|
||||
page1.Last().SequenceNumber.Should().BeLessThan(page2.First().SequenceNumber);
|
||||
page2.Last().SequenceNumber.Should().BeLessThan(page3.First().SequenceNumber);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic GUID generator for testing.
|
||||
/// </summary>
|
||||
private sealed class DeterministicGuidGenerator : IGuidGenerator
|
||||
{
|
||||
private int _counter;
|
||||
|
||||
public Guid NewGuid()
|
||||
{
|
||||
var bytes = new byte[16];
|
||||
BitConverter.GetBytes(_counter++).CopyTo(bytes, 0);
|
||||
return new Guid(bytes);
|
||||
}
|
||||
|
||||
public void Reset() => _counter = 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,464 @@
|
||||
// <copyright file="RunServiceTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.AdvisoryAI.Runs;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="RunService"/>.
|
||||
/// Sprint: SPRINT_20260109_011_003_BE Task: RUN-009
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class RunServiceTests
|
||||
{
|
||||
private readonly InMemoryRunStore _store = new();
|
||||
private readonly FakeTimeProvider _timeProvider = new();
|
||||
private readonly NullLogger<RunService> _logger = NullLogger<RunService>.Instance;
|
||||
private readonly RunService _service;
|
||||
|
||||
public RunServiceTests()
|
||||
{
|
||||
_timeProvider.SetUtcNow(new DateTimeOffset(2026, 1, 10, 12, 0, 0, TimeSpan.Zero));
|
||||
_service = new RunService(_store, _timeProvider, new DefaultGuidGenerator(), _logger);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_GeneratesRunWithCorrectProperties()
|
||||
{
|
||||
// Arrange
|
||||
var request = new CreateRunRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
InitiatedBy = "user-1",
|
||||
Title = "Test Run",
|
||||
Objective = "Investigate CVE-2024-1234"
|
||||
};
|
||||
|
||||
// Act
|
||||
var run = await _service.CreateAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(run);
|
||||
Assert.NotEmpty(run.RunId);
|
||||
Assert.Equal("tenant-1", run.TenantId);
|
||||
Assert.Equal("user-1", run.InitiatedBy);
|
||||
Assert.Equal("Test Run", run.Title);
|
||||
Assert.Equal("Investigate CVE-2024-1234", run.Objective);
|
||||
Assert.Equal(RunStatus.Created, run.Status);
|
||||
Assert.Equal(_timeProvider.GetUtcNow(), run.CreatedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_WithContext_StoresContextCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var request = new CreateRunRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
InitiatedBy = "user-1",
|
||||
Title = "CVE Investigation",
|
||||
Context = new RunContext
|
||||
{
|
||||
FocusedCveId = "CVE-2024-5678",
|
||||
FocusedComponent = "pkg:npm/express@4.17.1",
|
||||
Tags = ["critical", "urgent"]
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var run = await _service.CreateAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("CVE-2024-5678", run.Context.FocusedCveId);
|
||||
Assert.Equal("pkg:npm/express@4.17.1", run.Context.FocusedComponent);
|
||||
Assert.Contains("critical", run.Context.Tags);
|
||||
Assert.Contains("urgent", run.Context.Tags);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAsync_ExistingRun_ReturnsRun()
|
||||
{
|
||||
// Arrange
|
||||
var request = new CreateRunRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
InitiatedBy = "user-1",
|
||||
Title = "Test Run"
|
||||
};
|
||||
var created = await _service.CreateAsync(request);
|
||||
|
||||
// Act
|
||||
var retrieved = await _service.GetAsync("tenant-1", created.RunId);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(retrieved);
|
||||
Assert.Equal(created.RunId, retrieved.RunId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAsync_NonExistentRun_ReturnsNull()
|
||||
{
|
||||
// Act
|
||||
var retrieved = await _service.GetAsync("tenant-1", "non-existent");
|
||||
|
||||
// Assert
|
||||
Assert.Null(retrieved);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddUserTurnAsync_AddsEventAndActivatesRun()
|
||||
{
|
||||
// Arrange
|
||||
var run = await _service.CreateAsync(new CreateRunRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
InitiatedBy = "user-1",
|
||||
Title = "Test Run"
|
||||
});
|
||||
|
||||
// Act
|
||||
var evt = await _service.AddUserTurnAsync(
|
||||
"tenant-1", run.RunId, "What vulnerabilities affect this component?", "user-1");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(evt);
|
||||
Assert.Equal(RunEventType.UserTurn, evt.Type);
|
||||
Assert.Equal("user-1", evt.ActorId);
|
||||
var turnContent = Assert.IsType<TurnContent>(evt.Content);
|
||||
Assert.Contains("vulnerabilities", turnContent.Message);
|
||||
|
||||
var updated = await _service.GetAsync("tenant-1", run.RunId);
|
||||
Assert.Equal(RunStatus.Active, updated!.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddAssistantTurnAsync_AddsEventCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var run = await _service.CreateAsync(new CreateRunRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
InitiatedBy = "user-1",
|
||||
Title = "Test Run"
|
||||
});
|
||||
await _service.AddUserTurnAsync("tenant-1", run.RunId, "Question?", "user-1");
|
||||
|
||||
// Act
|
||||
var evt = await _service.AddAssistantTurnAsync(
|
||||
"tenant-1", run.RunId, "Here is my analysis of the vulnerability...");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(evt);
|
||||
Assert.Equal(RunEventType.AssistantTurn, evt.Type);
|
||||
Assert.Equal("assistant", evt.ActorId);
|
||||
var assistantContent = Assert.IsType<TurnContent>(evt.Content);
|
||||
Assert.Contains("analysis", assistantContent.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProposeActionAsync_AddsActionProposal()
|
||||
{
|
||||
// Arrange
|
||||
var run = await _service.CreateAsync(new CreateRunRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
InitiatedBy = "user-1",
|
||||
Title = "Test Run"
|
||||
});
|
||||
await _service.AddUserTurnAsync("tenant-1", run.RunId, "Fix this CVE", "user-1");
|
||||
|
||||
// Act
|
||||
var evt = await _service.ProposeActionAsync("tenant-1", run.RunId, new ProposeActionRequest
|
||||
{
|
||||
ActionType = "vex:publish",
|
||||
Subject = "CVE-2024-1234",
|
||||
Rationale = "Component is not affected due to usage context",
|
||||
RequiresApproval = true
|
||||
});
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(evt);
|
||||
Assert.Equal(RunEventType.ActionProposed, evt.Type);
|
||||
var actionContent = Assert.IsType<ActionProposedContent>(evt.Content);
|
||||
Assert.Equal("vex:publish", actionContent.ActionType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RequestApprovalAsync_ChangesStatusToPendingApproval()
|
||||
{
|
||||
// Arrange
|
||||
var run = await _service.CreateAsync(new CreateRunRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
InitiatedBy = "user-1",
|
||||
Title = "Test Run"
|
||||
});
|
||||
await _service.AddUserTurnAsync("tenant-1", run.RunId, "Question", "user-1");
|
||||
await _service.ProposeActionAsync("tenant-1", run.RunId, new ProposeActionRequest
|
||||
{
|
||||
ActionType = "vex:publish",
|
||||
RequiresApproval = true
|
||||
});
|
||||
|
||||
// Act
|
||||
var updated = await _service.RequestApprovalAsync(
|
||||
"tenant-1", run.RunId, ["approver-1", "approver-2"], "Please review VEX decision");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(RunStatus.PendingApproval, updated.Status);
|
||||
Assert.NotNull(updated.Approval);
|
||||
Assert.True(updated.Approval.Required);
|
||||
Assert.Contains("approver-1", updated.Approval.Approvers);
|
||||
Assert.Contains("approver-2", updated.Approval.Approvers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApproveAsync_Approved_ChangesStatusToApproved()
|
||||
{
|
||||
// Arrange
|
||||
var run = await _service.CreateAsync(new CreateRunRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
InitiatedBy = "user-1",
|
||||
Title = "Test Run"
|
||||
});
|
||||
await _service.AddUserTurnAsync("tenant-1", run.RunId, "Question", "user-1");
|
||||
await _service.ProposeActionAsync("tenant-1", run.RunId, new ProposeActionRequest
|
||||
{
|
||||
ActionType = "vex:publish",
|
||||
RequiresApproval = true
|
||||
});
|
||||
await _service.RequestApprovalAsync("tenant-1", run.RunId, ["approver-1"], "Review");
|
||||
|
||||
// Act
|
||||
var updated = await _service.ApproveAsync(
|
||||
"tenant-1", run.RunId, approved: true, "approver-1", "Looks good");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(RunStatus.Approved, updated.Status);
|
||||
Assert.NotNull(updated.Approval);
|
||||
Assert.True(updated.Approval.Approved);
|
||||
Assert.Equal("approver-1", updated.Approval.ApprovedBy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApproveAsync_Rejected_ChangesStatusToRejected()
|
||||
{
|
||||
// Arrange
|
||||
var run = await _service.CreateAsync(new CreateRunRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
InitiatedBy = "user-1",
|
||||
Title = "Test Run"
|
||||
});
|
||||
await _service.AddUserTurnAsync("tenant-1", run.RunId, "Question", "user-1");
|
||||
await _service.ProposeActionAsync("tenant-1", run.RunId, new ProposeActionRequest
|
||||
{
|
||||
ActionType = "vex:publish",
|
||||
RequiresApproval = true
|
||||
});
|
||||
await _service.RequestApprovalAsync("tenant-1", run.RunId, ["approver-1"], "Review");
|
||||
|
||||
// Act
|
||||
var updated = await _service.ApproveAsync(
|
||||
"tenant-1", run.RunId, approved: false, "approver-1", "Needs more justification");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(RunStatus.Rejected, updated.Status);
|
||||
Assert.False(updated.Approval!.Approved);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompleteAsync_SetsCompletedStatusAndTimestamp()
|
||||
{
|
||||
// Arrange
|
||||
var run = await _service.CreateAsync(new CreateRunRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
InitiatedBy = "user-1",
|
||||
Title = "Test Run"
|
||||
});
|
||||
await _service.AddUserTurnAsync("tenant-1", run.RunId, "Question", "user-1");
|
||||
await _service.AddAssistantTurnAsync("tenant-1", run.RunId, "Answer");
|
||||
|
||||
// Act
|
||||
var completed = await _service.CompleteAsync("tenant-1", run.RunId, "Investigation complete");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(RunStatus.Completed, completed.Status);
|
||||
Assert.NotNull(completed.CompletedAt);
|
||||
Assert.Equal(_timeProvider.GetUtcNow(), completed.CompletedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CancelAsync_SetsCancelledStatus()
|
||||
{
|
||||
// Arrange
|
||||
var run = await _service.CreateAsync(new CreateRunRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
InitiatedBy = "user-1",
|
||||
Title = "Test Run"
|
||||
});
|
||||
|
||||
// Act
|
||||
var cancelled = await _service.CancelAsync("tenant-1", run.RunId, "No longer needed");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(RunStatus.Cancelled, cancelled.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddArtifactAsync_AddsArtifactToRun()
|
||||
{
|
||||
// Arrange
|
||||
var run = await _service.CreateAsync(new CreateRunRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
InitiatedBy = "user-1",
|
||||
Title = "Test Run"
|
||||
});
|
||||
|
||||
var artifact = new RunArtifact
|
||||
{
|
||||
ArtifactId = "artifact-1",
|
||||
Type = ArtifactType.VexStatement,
|
||||
Name = "VEX for CVE-2024-1234",
|
||||
CreatedAt = _timeProvider.GetUtcNow(),
|
||||
ContentDigest = "sha256:abc123",
|
||||
MediaType = "application/vnd.openvex+json"
|
||||
};
|
||||
|
||||
// Act
|
||||
var updated = await _service.AddArtifactAsync("tenant-1", run.RunId, artifact);
|
||||
|
||||
// Assert
|
||||
Assert.Single(updated.Artifacts);
|
||||
Assert.Equal("artifact-1", updated.Artifacts[0].ArtifactId);
|
||||
Assert.Equal(ArtifactType.VexStatement, updated.Artifacts[0].Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_FiltersCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
await _service.CreateAsync(new CreateRunRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
InitiatedBy = "user-1",
|
||||
Title = "Run 1",
|
||||
Context = new RunContext { FocusedCveId = "CVE-2024-1111" }
|
||||
});
|
||||
await _service.CreateAsync(new CreateRunRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
InitiatedBy = "user-2",
|
||||
Title = "Run 2",
|
||||
Context = new RunContext { FocusedCveId = "CVE-2024-2222" }
|
||||
});
|
||||
await _service.CreateAsync(new CreateRunRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
InitiatedBy = "user-1",
|
||||
Title = "Run 3",
|
||||
Context = new RunContext { FocusedCveId = "CVE-2024-1111" }
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _service.QueryAsync(new RunQuery
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
InitiatedBy = "user-1",
|
||||
CveId = "CVE-2024-1111"
|
||||
});
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, result.TotalCount);
|
||||
Assert.All(result.Runs, r =>
|
||||
{
|
||||
Assert.Equal("user-1", r.InitiatedBy);
|
||||
Assert.Equal("CVE-2024-1111", r.Context.FocusedCveId);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTimelineAsync_ReturnsEventsInOrder()
|
||||
{
|
||||
// Arrange
|
||||
var run = await _service.CreateAsync(new CreateRunRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
InitiatedBy = "user-1",
|
||||
Title = "Test Run"
|
||||
});
|
||||
|
||||
await _service.AddUserTurnAsync("tenant-1", run.RunId, "Question 1", "user-1");
|
||||
_timeProvider.Advance(TimeSpan.FromSeconds(1));
|
||||
await _service.AddAssistantTurnAsync("tenant-1", run.RunId, "Answer 1");
|
||||
_timeProvider.Advance(TimeSpan.FromSeconds(1));
|
||||
await _service.AddUserTurnAsync("tenant-1", run.RunId, "Question 2", "user-1");
|
||||
|
||||
// Act
|
||||
var timeline = await _service.GetTimelineAsync("tenant-1", run.RunId);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(3, timeline.Length);
|
||||
Assert.Equal(RunEventType.UserTurn, timeline[0].Type);
|
||||
Assert.Equal(RunEventType.AssistantTurn, timeline[1].Type);
|
||||
Assert.Equal(RunEventType.UserTurn, timeline[2].Type);
|
||||
|
||||
// Verify sequence numbers are ordered
|
||||
Assert.True(timeline[0].SequenceNumber < timeline[1].SequenceNumber);
|
||||
Assert.True(timeline[1].SequenceNumber < timeline[2].SequenceNumber);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandOffAsync_TransfersOwnership()
|
||||
{
|
||||
// Arrange
|
||||
var run = await _service.CreateAsync(new CreateRunRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
InitiatedBy = "user-1",
|
||||
Title = "Test Run"
|
||||
});
|
||||
|
||||
// Act
|
||||
var updated = await _service.HandOffAsync("tenant-1", run.RunId, "user-2", "Please continue");
|
||||
|
||||
// Assert
|
||||
Assert.Equal("user-2", updated.Metadata["current_owner"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddEventAsync_NonExistentRun_ThrowsInvalidOperation()
|
||||
{
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
_service.AddUserTurnAsync("tenant-1", "non-existent", "Message", "user-1"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompleteAsync_AlreadyCompleted_ThrowsInvalidOperation()
|
||||
{
|
||||
// Arrange
|
||||
var run = await _service.CreateAsync(new CreateRunRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
InitiatedBy = "user-1",
|
||||
Title = "Test Run"
|
||||
});
|
||||
await _service.AddUserTurnAsync("tenant-1", run.RunId, "Q", "user-1");
|
||||
await _service.CompleteAsync("tenant-1", run.RunId);
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
_service.CompleteAsync("tenant-1", run.RunId));
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,8 @@
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="Moq" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\StellaOps.AdvisoryAI\StellaOps.AdvisoryAI.csproj" />
|
||||
|
||||
Reference in New Issue
Block a user