Files
git.stella-ops.org/src/Signer/StellaOps.Signer/StellaOps.Signer.Tests/Ceremonies/CeremonyOrchestratorIntegrationTests.cs

565 lines
19 KiB
C#

// -----------------------------------------------------------------------------
// 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;
/// <summary>
/// Integration tests for dual-control ceremony workflows.
/// Tests full ceremony lifecycle including multi-approver scenarios.
/// </summary>
[Trait("Category", "Integration")]
public sealed class CeremonyOrchestratorIntegrationTests : IAsyncLifetime
{
private readonly Mock<ICeremonyRepository> _mockRepository;
private readonly Mock<ICeremonyAuditSink> _mockAuditSink;
private readonly Mock<ICeremonyApproverValidator> _mockApproverValidator;
private readonly MockTimeProvider _mockTimeProvider;
private readonly CeremonyOrchestrator _orchestrator;
private readonly Dictionary<Guid, Ceremony> _ceremoniesStore;
private readonly List<object> _auditEvents;
public CeremonyOrchestratorIntegrationTests()
{
_mockRepository = new Mock<ICeremonyRepository>();
_mockAuditSink = new Mock<ICeremonyAuditSink>();
_mockApproverValidator = new Mock<ICeremonyApproverValidator>();
_mockTimeProvider = new MockTimeProvider();
_ceremoniesStore = new Dictionary<Guid, Ceremony>();
_auditEvents = new List<object>();
var options = Options.Create(new CeremonyOptions
{
Enabled = true,
DefaultThreshold = 2,
DefaultExpirationMinutes = 60,
ValidApproverGroups = new List<string> { "signing-officers", "key-custodians" }
});
var logger = Mock.Of<ILogger<CeremonyOrchestrator>>();
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<string>(s => s == "invalid@example.com"),
It.IsAny<CeremonyOperationType>(),
It.IsAny<CancellationToken>()))
.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<Ceremony>(), It.IsAny<CancellationToken>()))
.Returns((Ceremony c, CancellationToken _) =>
{
_ceremoniesStore[c.CeremonyId] = c;
return Task.FromResult(c);
});
_mockRepository
.Setup(r => r.GetByIdAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>()))
.Returns((Guid id, CancellationToken _) =>
{
_ceremoniesStore.TryGetValue(id, out var ceremony);
return Task.FromResult(ceremony);
});
_mockRepository
.Setup(r => r.UpdateAsync(It.IsAny<Ceremony>(), It.IsAny<CancellationToken>()))
.Returns((Ceremony c, CancellationToken _) =>
{
_ceremoniesStore[c.CeremonyId] = c;
return Task.FromResult(c);
});
_mockRepository
.Setup(r => r.ListAsync(It.IsAny<CeremonyFilter>(), It.IsAny<CancellationToken>()))
.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<Ceremony>);
});
}
private void SetupAuditSinkMock()
{
_mockAuditSink
.Setup(a => a.WriteAsync(It.IsAny<object>(), It.IsAny<CancellationToken>()))
.Returns((object evt, CancellationToken _) =>
{
_auditEvents.Add(evt);
return Task.CompletedTask;
});
}
private void SetupApproverValidatorMock()
{
// Default: all approvers valid
_mockApproverValidator
.Setup(v => v.ValidateApproverAsync(
It.IsAny<string>(),
It.IsAny<CeremonyOperationType>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new ApproverValidationResult { IsValid = true });
}
#endregion
}
/// <summary>
/// Mock time provider for testing time-dependent behavior.
/// </summary>
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;
}