// ----------------------------------------------------------------------------- // CeremonyOrchestratorIntegrationTests.cs // Sprint: SPRINT_20260112_018_SIGNER_dual_control_ceremonies // Task: DUAL-012 // Description: Integration tests for multi-approver ceremony workflows. // ----------------------------------------------------------------------------- using System; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Moq; using StellaOps.Signer.Core.Ceremonies; using Xunit; namespace StellaOps.Signer.Tests.Ceremonies; /// /// Integration tests for dual-control ceremony workflows. /// Tests full ceremony lifecycle including multi-approver scenarios. /// [Trait("Category", "Integration")] public sealed class CeremonyOrchestratorIntegrationTests : IAsyncLifetime { private readonly Mock _mockRepository; private readonly Mock _mockAuditSink; private readonly Mock _mockApproverValidator; private readonly MockTimeProvider _mockTimeProvider; private readonly CeremonyOrchestrator _orchestrator; private readonly Dictionary _ceremoniesStore; private readonly List _auditEvents; public CeremonyOrchestratorIntegrationTests() { _mockRepository = new Mock(); _mockAuditSink = new Mock(); _mockApproverValidator = new Mock(); _mockTimeProvider = new MockTimeProvider(); _ceremoniesStore = new Dictionary(); _auditEvents = new List(); var options = Options.Create(new CeremonyOptions { Enabled = true, DefaultThreshold = 2, DefaultExpirationMinutes = 60, ValidApproverGroups = new List { "signing-officers", "key-custodians" } }); var logger = Mock.Of>(); SetupRepositoryMock(); SetupAuditSinkMock(); SetupApproverValidatorMock(); _orchestrator = new CeremonyOrchestrator( _mockRepository.Object, _mockAuditSink.Object, _mockApproverValidator.Object, _mockTimeProvider, options, logger); } public Task InitializeAsync() => Task.CompletedTask; public Task DisposeAsync() => Task.CompletedTask; #region Full Workflow Tests [Fact] public async Task FullWorkflow_TwoOfTwo_CompletesSuccessfully() { // Arrange var request = new CreateCeremonyRequest { OperationType = CeremonyOperationType.KeyRotation, OperationPayload = "{ \"keyId\": \"signing-key-001\" }", ThresholdOverride = 2 }; // Act - Create ceremony var createResult = await _orchestrator.CreateCeremonyAsync(request, "initiator@example.com"); Assert.True(createResult.Success); var ceremonyId = createResult.Ceremony!.CeremonyId; // Verify initial state var ceremony = await _orchestrator.GetCeremonyAsync(ceremonyId); Assert.NotNull(ceremony); Assert.Equal(CeremonyState.Pending, ceremony.State); // Act - First approval var approval1Result = await _orchestrator.ApproveCeremonyAsync( ceremonyId, new CeremonyApprovalRequest { ApproverIdentity = "approver1@example.com", ApprovalReason = "Reviewed and approved", ApprovalSignature = "sig1_base64", SigningKeyId = "approver1-key" }); Assert.True(approval1Result.Success); Assert.Equal(CeremonyState.PartiallyApproved, approval1Result.Ceremony!.State); // Act - Second approval var approval2Result = await _orchestrator.ApproveCeremonyAsync( ceremonyId, new CeremonyApprovalRequest { ApproverIdentity = "approver2@example.com", ApprovalReason = "LGTM", ApprovalSignature = "sig2_base64", SigningKeyId = "approver2-key" }); Assert.True(approval2Result.Success); Assert.Equal(CeremonyState.Approved, approval2Result.Ceremony!.State); // Act - Execute var executeResult = await _orchestrator.ExecuteCeremonyAsync(ceremonyId, "executor@example.com"); Assert.True(executeResult.Success); Assert.Equal(CeremonyState.Executed, executeResult.Ceremony!.State); // Verify audit trail Assert.Contains(_auditEvents, e => e.GetType().Name.Contains("Initiated")); Assert.Contains(_auditEvents, e => e.GetType().Name.Contains("Approved")); Assert.Contains(_auditEvents, e => e.GetType().Name.Contains("Executed")); } [Fact] public async Task FullWorkflow_ThreeOfFive_CompletesAfterThirdApproval() { // Arrange var request = new CreateCeremonyRequest { OperationType = CeremonyOperationType.KeyGeneration, OperationPayload = "{ \"algorithm\": \"ed25519\" }", ThresholdOverride = 3 }; // Act - Create ceremony var createResult = await _orchestrator.CreateCeremonyAsync(request, "initiator@example.com"); Assert.True(createResult.Success); var ceremonyId = createResult.Ceremony!.CeremonyId; // First two approvals should keep in PartiallyApproved for (int i = 1; i <= 2; i++) { var result = await _orchestrator.ApproveCeremonyAsync( ceremonyId, new CeremonyApprovalRequest { ApproverIdentity = $"approver{i}@example.com", ApprovalReason = $"Approval {i}", ApprovalSignature = $"sig{i}_base64", SigningKeyId = $"approver{i}-key" }); Assert.True(result.Success); Assert.Equal(CeremonyState.PartiallyApproved, result.Ceremony!.State); } // Third approval should move to Approved var finalApproval = await _orchestrator.ApproveCeremonyAsync( ceremonyId, new CeremonyApprovalRequest { ApproverIdentity = "approver3@example.com", ApprovalReason = "Final approval", ApprovalSignature = "sig3_base64", SigningKeyId = "approver3-key" }); Assert.True(finalApproval.Success); Assert.Equal(CeremonyState.Approved, finalApproval.Ceremony!.State); Assert.Equal(3, finalApproval.Ceremony.Approvals.Count); } [Fact] public async Task FullWorkflow_SingleApprover_ApprovedImmediately() { // Arrange - threshold of 1 var request = new CreateCeremonyRequest { OperationType = CeremonyOperationType.KeyRotation, OperationPayload = "{ \"keyId\": \"minor-key\" }", ThresholdOverride = 1 }; // Create var createResult = await _orchestrator.CreateCeremonyAsync(request, "initiator@example.com"); Assert.True(createResult.Success); var ceremonyId = createResult.Ceremony!.CeremonyId; // Single approval should immediately move to Approved var approvalResult = await _orchestrator.ApproveCeremonyAsync( ceremonyId, new CeremonyApprovalRequest { ApproverIdentity = "approver@example.com", ApprovalReason = "Approved", ApprovalSignature = "sig_base64", SigningKeyId = "approver-key" }); Assert.True(approvalResult.Success); Assert.Equal(CeremonyState.Approved, approvalResult.Ceremony!.State); } #endregion #region Duplicate Approval Tests [Fact] public async Task DuplicateApproval_SameApprover_IsRejected() { // Arrange var request = new CreateCeremonyRequest { OperationType = CeremonyOperationType.KeyRotation, OperationPayload = "{}", ThresholdOverride = 2 }; var createResult = await _orchestrator.CreateCeremonyAsync(request, "initiator@example.com"); var ceremonyId = createResult.Ceremony!.CeremonyId; // First approval succeeds var approval1 = await _orchestrator.ApproveCeremonyAsync( ceremonyId, new CeremonyApprovalRequest { ApproverIdentity = "approver@example.com", ApprovalReason = "First", ApprovalSignature = "sig1", SigningKeyId = "key1" }); Assert.True(approval1.Success); // Second approval from same approver should fail var approval2 = await _orchestrator.ApproveCeremonyAsync( ceremonyId, new CeremonyApprovalRequest { ApproverIdentity = "approver@example.com", ApprovalReason = "Second", ApprovalSignature = "sig2", SigningKeyId = "key1" }); Assert.False(approval2.Success); Assert.Equal(CeremonyErrorCode.DuplicateApproval, approval2.ErrorCode); } #endregion #region Expiration Tests [Fact] public async Task ExpiredCeremony_CannotBeApproved() { // Arrange var request = new CreateCeremonyRequest { OperationType = CeremonyOperationType.KeyRotation, OperationPayload = "{}", ExpirationMinutesOverride = 30 }; var createResult = await _orchestrator.CreateCeremonyAsync(request, "initiator@example.com"); var ceremonyId = createResult.Ceremony!.CeremonyId; // Advance time past expiration _mockTimeProvider.Advance(TimeSpan.FromMinutes(31)); // Process expirations await _orchestrator.ProcessExpiredCeremoniesAsync(); // Attempt approval should fail var approval = await _orchestrator.ApproveCeremonyAsync( ceremonyId, new CeremonyApprovalRequest { ApproverIdentity = "approver@example.com", ApprovalReason = "Late approval", ApprovalSignature = "sig", SigningKeyId = "key" }); Assert.False(approval.Success); Assert.Equal(CeremonyErrorCode.InvalidState, approval.ErrorCode); } [Fact] public async Task ExpiredCeremony_CannotBeExecuted() { // Arrange - create and fully approve var request = new CreateCeremonyRequest { OperationType = CeremonyOperationType.KeyRotation, OperationPayload = "{}", ThresholdOverride = 1, ExpirationMinutesOverride = 30 }; var createResult = await _orchestrator.CreateCeremonyAsync(request, "initiator@example.com"); var ceremonyId = createResult.Ceremony!.CeremonyId; await _orchestrator.ApproveCeremonyAsync( ceremonyId, new CeremonyApprovalRequest { ApproverIdentity = "approver@example.com", ApprovalReason = "Approved", ApprovalSignature = "sig", SigningKeyId = "key" }); // Advance time past expiration _mockTimeProvider.Advance(TimeSpan.FromMinutes(31)); await _orchestrator.ProcessExpiredCeremoniesAsync(); // Attempt execution should fail var executeResult = await _orchestrator.ExecuteCeremonyAsync(ceremonyId, "executor@example.com"); Assert.False(executeResult.Success); } #endregion #region Cancellation Tests [Fact] public async Task CancelledCeremony_CannotBeApproved() { // Arrange var request = new CreateCeremonyRequest { OperationType = CeremonyOperationType.KeyRotation, OperationPayload = "{}" }; var createResult = await _orchestrator.CreateCeremonyAsync(request, "initiator@example.com"); var ceremonyId = createResult.Ceremony!.CeremonyId; // Cancel var cancelResult = await _orchestrator.CancelCeremonyAsync(ceremonyId, "admin@example.com", "Cancelled for testing"); Assert.True(cancelResult.Success); // Attempt approval should fail var approval = await _orchestrator.ApproveCeremonyAsync( ceremonyId, new CeremonyApprovalRequest { ApproverIdentity = "approver@example.com", ApprovalReason = "Too late", ApprovalSignature = "sig", SigningKeyId = "key" }); Assert.False(approval.Success); Assert.Equal(CeremonyErrorCode.InvalidState, approval.ErrorCode); } [Fact] public async Task PartiallyApprovedCeremony_CanBeCancelled() { // Arrange var request = new CreateCeremonyRequest { OperationType = CeremonyOperationType.KeyRotation, OperationPayload = "{}", ThresholdOverride = 2 }; var createResult = await _orchestrator.CreateCeremonyAsync(request, "initiator@example.com"); var ceremonyId = createResult.Ceremony!.CeremonyId; // Add one approval await _orchestrator.ApproveCeremonyAsync( ceremonyId, new CeremonyApprovalRequest { ApproverIdentity = "approver@example.com", ApprovalReason = "First approval", ApprovalSignature = "sig", SigningKeyId = "key" }); // Cancel should succeed var cancelResult = await _orchestrator.CancelCeremonyAsync(ceremonyId, "admin@example.com", "Changed plans"); Assert.True(cancelResult.Success); Assert.Equal(CeremonyState.Cancelled, cancelResult.Ceremony!.State); } #endregion #region Audit Trail Tests [Fact] public async Task FullWorkflow_GeneratesCompleteAuditTrail() { // Arrange _auditEvents.Clear(); var request = new CreateCeremonyRequest { OperationType = CeremonyOperationType.KeyRotation, OperationPayload = "{}", ThresholdOverride = 2 }; // Act - full workflow var createResult = await _orchestrator.CreateCeremonyAsync(request, "initiator@example.com"); var ceremonyId = createResult.Ceremony!.CeremonyId; await _orchestrator.ApproveCeremonyAsync(ceremonyId, new CeremonyApprovalRequest { ApproverIdentity = "approver1@example.com", ApprovalReason = "OK", ApprovalSignature = "sig1", SigningKeyId = "key1" }); await _orchestrator.ApproveCeremonyAsync(ceremonyId, new CeremonyApprovalRequest { ApproverIdentity = "approver2@example.com", ApprovalReason = "OK", ApprovalSignature = "sig2", SigningKeyId = "key2" }); await _orchestrator.ExecuteCeremonyAsync(ceremonyId, "executor@example.com"); // Assert - verify audit events count // Should have: initiated + 2 approved + executed = 4 events Assert.True(_auditEvents.Count >= 4, $"Expected at least 4 audit events, got {_auditEvents.Count}"); } #endregion #region Approver Validation Tests [Fact] public async Task InvalidApprover_IsRejected() { // Arrange - set up validator to reject specific approver _mockApproverValidator .Setup(v => v.ValidateApproverAsync( It.Is(s => s == "invalid@example.com"), It.IsAny(), It.IsAny())) .ReturnsAsync(new ApproverValidationResult { IsValid = false, Error = "Approver not in signing-officers group" }); var request = new CreateCeremonyRequest { OperationType = CeremonyOperationType.KeyRotation, OperationPayload = "{}" }; var createResult = await _orchestrator.CreateCeremonyAsync(request, "initiator@example.com"); var ceremonyId = createResult.Ceremony!.CeremonyId; // Act var approval = await _orchestrator.ApproveCeremonyAsync( ceremonyId, new CeremonyApprovalRequest { ApproverIdentity = "invalid@example.com", ApprovalReason = "Unauthorized", ApprovalSignature = "sig", SigningKeyId = "key" }); // Assert Assert.False(approval.Success); Assert.Equal(CeremonyErrorCode.UnauthorizedApprover, approval.ErrorCode); } #endregion #region Setup Helpers private void SetupRepositoryMock() { _mockRepository .Setup(r => r.CreateAsync(It.IsAny(), It.IsAny())) .Returns((Ceremony c, CancellationToken _) => { _ceremoniesStore[c.CeremonyId] = c; return Task.FromResult(c); }); _mockRepository .Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) .Returns((Guid id, CancellationToken _) => { _ceremoniesStore.TryGetValue(id, out var ceremony); return Task.FromResult(ceremony); }); _mockRepository .Setup(r => r.UpdateAsync(It.IsAny(), It.IsAny())) .Returns((Ceremony c, CancellationToken _) => { _ceremoniesStore[c.CeremonyId] = c; return Task.FromResult(c); }); _mockRepository .Setup(r => r.ListAsync(It.IsAny(), It.IsAny())) .Returns((CeremonyFilter filter, CancellationToken _) => { var query = _ceremoniesStore.Values.AsEnumerable(); if (filter?.States != null && filter.States.Any()) query = query.Where(c => filter.States.Contains(c.State)); if (filter?.OperationType != null) query = query.Where(c => c.OperationType == filter.OperationType); return Task.FromResult(query.ToList() as IReadOnlyList); }); } private void SetupAuditSinkMock() { _mockAuditSink .Setup(a => a.WriteAsync(It.IsAny(), It.IsAny())) .Returns((object evt, CancellationToken _) => { _auditEvents.Add(evt); return Task.CompletedTask; }); } private void SetupApproverValidatorMock() { // Default: all approvers valid _mockApproverValidator .Setup(v => v.ValidateApproverAsync( It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new ApproverValidationResult { IsValid = true }); } #endregion } /// /// Mock time provider for testing time-dependent behavior. /// internal sealed class MockTimeProvider : TimeProvider { private DateTimeOffset _now = DateTimeOffset.UtcNow; public override DateTimeOffset GetUtcNow() => _now; public void Advance(TimeSpan duration) => _now = _now.Add(duration); public void SetNow(DateTimeOffset now) => _now = now; }