565 lines
19 KiB
C#
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;
|
|
}
|