Skip to content

Commit 943f2e9

Browse files
committed
Update tests for refactored MutationRequest model
1 parent 0829eba commit 943f2e9

7 files changed

Lines changed: 238 additions & 44 deletions

File tree

Tests/ModularityKit.Mutator.Governance.Redis.Tests/Keys/RedisMutationRequestKeyspaceTests.cs

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -49,11 +49,17 @@ public void Enumerate_indexes_includes_pending_indexes_only_for_pending_requests
4949
var request = new MutationRequest
5050
{
5151
RequestId = "req-42",
52-
StateId = "tenant-42",
53-
StateType = "IamRoleState",
54-
MutationType = "GrantRoleMutation",
55-
Status = MutationRequestStatus.Pending,
56-
PendingReason = PendingMutationReason.Approval
52+
Scope = new MutationRequestScopeDetails
53+
{
54+
StateId = "tenant-42",
55+
StateType = "IamRoleState",
56+
MutationType = "GrantRoleMutation"
57+
},
58+
Lifecycle = new MutationRequestLifecycleDetails
59+
{
60+
Status = MutationRequestStatus.Pending,
61+
PendingReason = PendingMutationReason.Approval
62+
}
5763
};
5864

5965
var keys = keyspace.EnumerateIndexes(request).Select(key => key.ToString()).ToArray();

Tests/ModularityKit.Mutator.Governance.Redis.Tests/Serialization/Converters/RedisMutationRequestSerializerTests.cs

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using ModularityKit.Mutator.Abstractions.Policies;
55
using ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model;
66
using ModularityKit.Mutator.Governance.Abstractions.Requests.Factory;
7+
using ModularityKit.Mutator.Governance.Abstractions.Requests.Model;
78
using ModularityKit.Mutator.Governance.Redis.Serialization;
89
using Xunit;
910

@@ -61,8 +62,13 @@ public void Roundtrip_preserves_request_shape_needed_by_governance_runtime()
6162
})
6263
with
6364
{
64-
CreatedAt = new DateTimeOffset(2026, 6, 25, 9, 0, 0, TimeSpan.Zero),
65-
UpdatedAt = new DateTimeOffset(2026, 6, 25, 9, 5, 0, TimeSpan.Zero),
65+
Lifecycle = new MutationRequestLifecycleDetails
66+
{
67+
Status = MutationRequestStatus.Pending,
68+
PendingReason = PendingMutationReason.Approval,
69+
CreatedAt = new DateTimeOffset(2026, 6, 25, 9, 0, 0, TimeSpan.Zero),
70+
UpdatedAt = new DateTimeOffset(2026, 6, 25, 9, 5, 0, TimeSpan.Zero)
71+
},
6672
SideEffects =
6773
[
6874
SideEffect.Critical(
@@ -95,6 +101,8 @@ public void Roundtrip_preserves_request_shape_needed_by_governance_runtime()
95101
Assert.Single(roundtrip.ApprovalRequirements);
96102
Assert.Equal("security-lead", roundtrip.ApprovalRequirements[0].ApproverId);
97103
Assert.Equal(3, roundtrip.Decisions.Count);
104+
Assert.Equal(request.Lifecycle.CreatedAt, roundtrip.Lifecycle.CreatedAt);
105+
Assert.Equal(request.Lifecycle.UpdatedAt, roundtrip.Lifecycle.UpdatedAt);
98106
}
99107

100108
[SideEffectDataContract("redis.governance.side-effect", 1)]

Tests/ModularityKit.Mutator.Governance.Tests/Execution/GovernanceExecutionManagerTests.cs

Lines changed: 157 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
using ModularityKit.Mutator.Abstractions.Results;
1111
using ModularityKit.Mutator.Governance.Abstractions.Execution.Contracts;
1212
using ModularityKit.Mutator.Governance.Abstractions.Execution.Model;
13+
using ModularityKit.Mutator.Governance.Abstractions.Execution.Model.Compensation;
14+
using ModularityKit.Mutator.Governance.Abstractions.Execution.Model.Links;
1315
using ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model;
1416
using ModularityKit.Mutator.Governance.Abstractions.Requests.Factory;
1517
using ModularityKit.Mutator.Governance.Abstractions.Requests.Decisions;
@@ -75,9 +77,9 @@ public async Task ExecuteApproved_executes_request_persists_resulting_version_an
7577
Assert.NotNull(result.MutationResult);
7678
Assert.Equal("v11", result.ResultingStateVersion);
7779
Assert.Equal(MutationRequestStatus.Executed, result.Request.Status);
78-
Assert.Equal("v11", result.Request.ResultingStateVersion);
79-
Assert.Equal("v11", result.Request.ExpectedStateVersion);
80-
Assert.NotNull(result.Request.ExecutedAt);
80+
Assert.Equal("v11", result.Request.Versioning.ResultingStateVersion);
81+
Assert.Equal("v11", result.Request.Versioning.ExpectedStateVersion);
82+
Assert.NotNull(result.Request.Versioning.ExecutedAt);
8183
Assert.Single(result.Request.SideEffects);
8284
Assert.Equal("RoleElevated", result.Request.SideEffects[0].Type);
8385
Assert.Equal("governance.execution-effect", result.Request.SideEffects[0].DataContractType);
@@ -180,7 +182,7 @@ public async Task ExecuteApproved_requires_renewed_approval_before_execution_whe
180182
Assert.Null(result.MutationResult);
181183
Assert.Equal(MutationRequestStatus.Pending, result.Request.Status);
182184
Assert.Equal(PendingMutationReason.Approval, result.Request.PendingReason);
183-
Assert.Equal("v15", result.Request.ExpectedStateVersion);
185+
Assert.Equal("v15", result.Request.Versioning.ExpectedStateVersion);
184186
Assert.Equal(MutationRequestVersionResolutionOutcome.RequiresRenewedApproval, result.Resolution.Outcome);
185187
}
186188

@@ -223,8 +225,8 @@ public async Task ExecuteApproved_revalidates_and_executes_against_latest_state_
223225
Assert.Equal(MutationRequestVersionResolutionOutcome.RevalidateOnLatestState, result.Resolution.Outcome);
224226
Assert.Equal(MutationRequestStatus.Executed, result.Request.Status);
225227
Assert.Equal("v16", result.ResultingStateVersion);
226-
Assert.Equal("v16", result.Request.ResultingStateVersion);
227-
Assert.Equal("v16", result.Request.ExpectedStateVersion);
228+
Assert.Equal("v16", result.Request.Versioning.ResultingStateVersion);
229+
Assert.Equal("v16", result.Request.Versioning.ExpectedStateVersion);
228230
Assert.Equal(
229231
MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Executed),
230232
result.Request.Decisions[^1].Type);
@@ -233,6 +235,110 @@ public async Task ExecuteApproved_revalidates_and_executes_against_latest_state_
233235
decision => decision.Type == MutationRequestDecisionType.VersionResolution(MutationRequestVersionResolutionDecisionType.RevalidationRequired));
234236
}
235237

238+
[Fact]
239+
public async Task ExecuteApproved_executes_operator_rollback_compensation_and_links_execution_history()
240+
{
241+
var services = new ServiceCollection();
242+
services.AddMutators(MutationEngineOptions.Strict);
243+
await using var provider = services.BuildServiceProvider();
244+
245+
var engine = provider.GetRequiredService<IMutationEngine>();
246+
var auditor = provider.GetRequiredService<IMutationAuditor>();
247+
var historyStore = provider.GetRequiredService<IMutationHistoryStore>();
248+
var requestStore = new InMemoryMutationRequestStore();
249+
var resolutionManager = new MutationRequestVersionResolutionManager(requestStore, new MutationRequestVersionResolver());
250+
var executionManager = new GovernanceExecutionManager(requestStore, resolutionManager, engine);
251+
252+
var originalRequest = await requestStore.Create(MutationRequestFactory.Approved<RoleState, PromoteRoleMutation>(
253+
stateId: "tenant-42:roles",
254+
intent: new MutationIntent
255+
{
256+
OperationName = "GrantRole",
257+
Category = "Security",
258+
Description = "Grant elevated access",
259+
IsReversible = true
260+
},
261+
context: MutationContext.User("requester", "Requester", "Need access"),
262+
expectedStateVersion: "v10"));
263+
264+
var originalState = RoleState.Create("tenant-42:roles", role: "Reader", version: "v10");
265+
var originalMutation = new PromoteRoleMutation(
266+
MutationContext.User("operator-1", "Operator One", "Execute approved role promotion"),
267+
nextVersion: "v11");
268+
269+
var originalResult = await executionManager.ExecuteApproved(
270+
originalRequest.RequestId,
271+
originalMutation,
272+
originalState,
273+
governanceContext: MutationContext.Service("governance-runtime", "Execute approved request"),
274+
strategy: VersionedRequestResolutionStrategy.RejectStale);
275+
276+
var compensationPlan = new GovernedCompensationPlan
277+
{
278+
OriginalRequestId = originalRequest.RequestId,
279+
Kind = GovernedCompensationKind.Rollback,
280+
Trigger = GovernedCompensationTrigger.OperatorRollback,
281+
Reason = "Operator reverted the elevated role after incident review."
282+
};
283+
284+
var compensationRequest = await requestStore.Create(CompensationMutationRequestFactory.Approved<RoleState, RollbackRoleMutation>(
285+
stateId: "tenant-42:roles",
286+
intent: new MutationIntent
287+
{
288+
OperationName = "RollbackRole",
289+
Category = "Security",
290+
Description = "Restore the previous tenant role"
291+
},
292+
context: MutationContext.User("operator-2", "Operator Two", "Rollback approved role mutation"),
293+
compensation: compensationPlan,
294+
expectedStateVersion: "v11"));
295+
296+
var compensationMutation = new RollbackRoleMutation(
297+
MutationContext.User("operator-2", "Operator Two", "Rollback approved role mutation"),
298+
nextVersion: "v12");
299+
300+
var compensationResult = await executionManager.ExecuteApproved(
301+
compensationRequest.RequestId,
302+
compensationMutation,
303+
originalResult.MutationResult!.NewState!,
304+
governanceContext: MutationContext.Service("governance-runtime", "Execute operator rollback"),
305+
strategy: VersionedRequestResolutionStrategy.RejectStale);
306+
307+
Assert.True(compensationResult.WasExecuted);
308+
Assert.Equal(GovernedExecutionKind.Compensation, compensationResult.ExecutionKind);
309+
Assert.NotNull(compensationResult.Compensation);
310+
Assert.Equal(originalRequest.RequestId, compensationResult.Compensation!.OriginalRequestId);
311+
Assert.Equal(GovernedCompensationKind.Rollback, compensationResult.Compensation.Kind);
312+
Assert.Contains(
313+
compensationResult.Request.Execution.RelatedExecutions,
314+
link => link.RequestId == originalRequest.RequestId &&
315+
link.Type == GovernedExecutionLinkType.Compensates);
316+
317+
var compensatedOriginalRequest = await requestStore.Get(originalRequest.RequestId);
318+
Assert.NotNull(compensatedOriginalRequest);
319+
Assert.Contains(
320+
compensatedOriginalRequest!.Execution.RelatedExecutions,
321+
link => link.RequestId == compensationRequest.RequestId &&
322+
link.Type == GovernedExecutionLinkType.CompensatedBy &&
323+
link.ExecutionKind == GovernedExecutionKind.Compensation);
324+
Assert.Contains(
325+
compensatedOriginalRequest.Decisions,
326+
decision => decision.Type == MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Compensated));
327+
328+
var auditEntries = await auditor.GetAuditLogAsync(originalRequest.StateId);
329+
var history = await historyStore.GetHistoryAsync(originalRequest.StateId);
330+
331+
Assert.Equal(2, auditEntries.Count);
332+
Assert.Equal(2, history.Entries.Count);
333+
Assert.Equal("Compensation", auditEntries[1].Context.Metadata["GovernanceExecutionKind"]);
334+
Assert.Equal("Compensation", history.Entries[1].Context.Metadata["GovernanceExecutionKind"]);
335+
336+
var auditCompensation = Assert.IsAssignableFrom<IReadOnlyDictionary<string, object>>(
337+
auditEntries[1].Context.Metadata["GovernanceCompensation"]);
338+
Assert.Equal(originalRequest.RequestId, auditCompensation["OriginalRequestId"]);
339+
Assert.Equal("Rollback", auditCompensation["Kind"]);
340+
}
341+
236342
private sealed record RoleState(string StateId, string Role, string Version) : IVersionedState
237343
{
238344
public static RoleState Create(string stateId, string role, string version) => new(stateId, role, version);
@@ -282,6 +388,51 @@ public ValidationResult Validate(RoleState state)
282388
public MutationResult<RoleState> Simulate(RoleState state) => Apply(state);
283389
}
284390

391+
private sealed class RollbackRoleMutation(MutationContext context, string nextVersion) : IMutation<RoleState>
392+
{
393+
public MutationIntent Intent { get; } = new()
394+
{
395+
OperationName = "RollbackRole",
396+
Category = "Security",
397+
Description = "Rollback tenant role to Reader",
398+
IsReversible = false
399+
};
400+
401+
public MutationContext Context { get; } = context;
402+
403+
public MutationResult<RoleState> Apply(RoleState state)
404+
{
405+
var newState = state with
406+
{
407+
Role = "Reader",
408+
Version = nextVersion
409+
};
410+
411+
return MutationResult<RoleState>.Success(
412+
newState,
413+
ChangeSet.Single(StateChange.Modified("Role", state.Role, newState.Role)),
414+
[
415+
SideEffect.Create(
416+
type: "RoleRollback",
417+
description: "Governed compensation restored the previous role",
418+
data: new GovernanceExecutionSideEffectData
419+
{
420+
RequestStateId = state.StateId,
421+
NewRole = newState.Role
422+
})
423+
]);
424+
}
425+
426+
public ValidationResult Validate(RoleState state)
427+
{
428+
return state.Role == "Reader"
429+
? ValidationResult.WithError("Role", "Role is already Reader.")
430+
: ValidationResult.Success();
431+
}
432+
433+
public MutationResult<RoleState> Simulate(RoleState state) => Apply(state);
434+
}
435+
285436
[SideEffectDataContract("governance.execution-effect", 1)]
286437
private sealed record GovernanceExecutionSideEffectData
287438
{

Tests/ModularityKit.Mutator.Governance.Tests/Lifecycle/MutationRequestStoreContractTests.cs

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,11 @@ public async Task TryStore_rejects_stale_revision_and_preserves_current_state()
3434

3535
var firstUpdate = created with
3636
{
37-
Status = MutationRequestStatus.Approved,
38-
PendingReason = null,
37+
Lifecycle = created.Lifecycle with
38+
{
39+
Status = MutationRequestStatus.Approved,
40+
PendingReason = null
41+
},
3942
Decisions =
4043
[
4144
.. created.Decisions,
@@ -50,8 +53,11 @@ public async Task TryStore_rejects_stale_revision_and_preserves_current_state()
5053

5154
var staleUpdate = created with
5255
{
53-
Status = MutationRequestStatus.Canceled,
54-
PendingReason = null,
56+
Lifecycle = created.Lifecycle with
57+
{
58+
Status = MutationRequestStatus.Canceled,
59+
PendingReason = null
60+
},
5561
Decisions =
5662
[
5763
.. created.Decisions,

Tests/ModularityKit.Mutator.Governance.Tests/Queries/MutationRequestQueryStoreTests.cs

Lines changed: 48 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -393,10 +393,13 @@ private static MutationRequest CreateSimpleRequest(
393393
with
394394
{
395395
RequestId = requestId,
396-
Status = status,
397-
PendingReason = pendingReason,
398-
CreatedAt = createdAt,
399-
UpdatedAt = createdAt
396+
Lifecycle = new MutationRequestLifecycleDetails
397+
{
398+
Status = status,
399+
PendingReason = pendingReason,
400+
CreatedAt = createdAt,
401+
UpdatedAt = createdAt
402+
}
400403
};
401404

402405
private static MutationRequest CreateApprovedRequest(
@@ -423,10 +426,13 @@ private static MutationRequest CreateApprovedRequest(
423426
with
424427
{
425428
RequestId = requestId,
426-
Status = MutationRequestStatus.Approved,
427-
PendingReason = null,
428-
CreatedAt = createdAt,
429-
UpdatedAt = updatedAt,
429+
Lifecycle = new MutationRequestLifecycleDetails
430+
{
431+
Status = MutationRequestStatus.Approved,
432+
PendingReason = null,
433+
CreatedAt = createdAt,
434+
UpdatedAt = updatedAt
435+
},
430436
Decisions =
431437
[
432438
MutationRequestDecision.Create(
@@ -495,8 +501,11 @@ private static MutationRequest CreateApprovalViewRequest(
495501
with
496502
{
497503
RequestId = requestId,
498-
Status = MutationRequestStatus.Pending,
499-
PendingReason = PendingMutationReason.Approval,
504+
Lifecycle = new MutationRequestLifecycleDetails
505+
{
506+
Status = MutationRequestStatus.Pending,
507+
PendingReason = PendingMutationReason.Approval
508+
},
500509
ApprovalRequirements =
501510
[
502511
new MutationApprovalRequirement
@@ -528,7 +537,12 @@ private static MutationRequest CreateDecisionViewRequest(
528537
{
529538
RequestId = requestId,
530539
Decisions = decisions,
531-
UpdatedAt = decisions.Max(decision => decision.Timestamp)
540+
Lifecycle = new MutationRequestLifecycleDetails
541+
{
542+
Status = MutationRequestStatus.Pending,
543+
PendingReason = PendingMutationReason.ExternalCheck,
544+
UpdatedAt = decisions.Max(decision => decision.Timestamp)
545+
}
532546
};
533547

534548
private static MutationRequest CreateGovernedRequest(
@@ -552,22 +566,31 @@ private static MutationRequest CreateGovernedRequest(
552566
=> new MutationRequest
553567
{
554568
RequestId = requestId,
555-
StateId = stateId,
556-
StateType = stateType,
557-
MutationType = mutationType,
558-
Intent = new MutationIntent
569+
Scope = new MutationRequestScopeDetails
559570
{
560-
OperationName = mutationType,
561-
Category = category,
562-
Tags = tags,
563-
Metadata = intentMetadata,
564-
EstimatedBlastRadius = blastRadius
571+
StateId = stateId,
572+
StateType = stateType,
573+
MutationType = mutationType
574+
},
575+
Payload = new MutationRequestPayloadDetails
576+
{
577+
Intent = new MutationIntent
578+
{
579+
OperationName = mutationType,
580+
Category = category,
581+
Tags = tags,
582+
Metadata = intentMetadata,
583+
EstimatedBlastRadius = blastRadius
584+
},
585+
Context = MutationContext.User(actorId, actorName, "Query test")
586+
},
587+
Lifecycle = new MutationRequestLifecycleDetails
588+
{
589+
Status = status,
590+
PendingReason = pendingReason,
591+
CreatedAt = createdAt,
592+
UpdatedAt = updatedAt
565593
},
566-
Context = MutationContext.User(actorId, actorName, "Query test"),
567-
Status = status,
568-
PendingReason = pendingReason,
569-
CreatedAt = createdAt,
570-
UpdatedAt = updatedAt,
571594
Decisions = decisions,
572595
Metadata = requestMetadata,
573596
SideEffects = sideEffects ?? []

0 commit comments

Comments
 (0)