sprints work

This commit is contained in:
master
2026-01-10 20:32:13 +02:00
parent 0d5eda86fc
commit 17d0631b8e
189 changed files with 40667 additions and 497 deletions

View File

@@ -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}");
}

View File

@@ -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;
}

View File

@@ -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"));
}
}

View File

@@ -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
};
}
}

View File

@@ -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
}
};
}

View File

@@ -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;
}
}

View File

@@ -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));
}
}

View File

@@ -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" />