diff --git a/src/Modules/IdentityAccess/ManageIdentityAndRoles/Domain/IdentityState.cs b/src/Modules/IdentityAccess/ManageIdentityAndRoles/Domain/IdentityState.cs new file mode 100644 index 00000000..f86fd5fc --- /dev/null +++ b/src/Modules/IdentityAccess/ManageIdentityAndRoles/Domain/IdentityState.cs @@ -0,0 +1,115 @@ +namespace KArtSell.Modules.IdentityAccess.ManageIdentityAndRoles.Domain; + +/// +/// Identity lifecycle state machine (AEG-VS-01-03) +/// Immutable value object for state transitions +/// +public sealed record IdentityState +{ + public const string Undefined = "UNDEFINED"; + public const string Active = "ACTIVE"; + public const string RequiresMfaSetup = "REQUIRES_MFA_SETUP"; + public const string MfaConfigured = "MFA_CONFIGURED"; + public const string MfaSuspended = "MFA_SUSPENDED"; + public const string Inactive = "INACTIVE"; + public const string Revoked = "REVOKED"; + + private static readonly HashSet ValidStates = + [ + Undefined, Active, RequiresMfaSetup, MfaConfigured, MfaSuspended, Inactive, Revoked + ]; + + public string Value { get; } + + private IdentityState(string value) + { + if (!ValidStates.Contains(value)) + throw new ArgumentException($"Invalid identity state: {value}", nameof(value)); + Value = value; + } + + // Factory methods + public static IdentityState CreateUndefined() => new(Undefined); + public static IdentityState CreateActive() => new(Active); + public static IdentityState RequireMfaSetup() => new(RequiresMfaSetup); + public static IdentityState MfaSetupComplete() => new(MfaConfigured); + public static IdentityState SuspendMfa() => new(MfaSuspended); + public static IdentityState Deactivate() => new(Inactive); + public static IdentityState Revoke() => new(Revoked); + public static IdentityState Parse(string value) => new(value); + + // State transitions (immutable - return new state) + public IdentityState Register() + { + return Value switch + { + Undefined => new(Active), + _ => throw new InvalidOperationException($"Cannot register from {Value}") + }; + } + + public IdentityState RequestMfaSetup() + { + return Value switch + { + Active => new(RequiresMfaSetup), + _ => throw new InvalidOperationException($"Cannot request MFA setup from {Value}") + }; + } + + public IdentityState CompleteMfaSetup() + { + return Value switch + { + RequiresMfaSetup => new(MfaConfigured), + _ => throw new InvalidOperationException($"Cannot complete MFA setup from {Value}") + }; + } + + public IdentityState SuspendMfaTemporarily() + { + return Value switch + { + MfaConfigured => new(MfaSuspended), + _ => throw new InvalidOperationException($"Cannot suspend MFA from {Value}") + }; + } + + public IdentityState ResumeMfa() + { + return Value switch + { + MfaSuspended => new(MfaConfigured), + _ => throw new InvalidOperationException($"Cannot resume MFA from {Value}") + }; + } + + public IdentityState Deactivate() + { + return Value switch + { + Active or RequiresMfaSetup or MfaConfigured or MfaSuspended => new(Inactive), + _ => throw new InvalidOperationException($"Cannot deactivate from {Value}") + }; + } + + public IdentityState Revoke() + { + return Value switch + { + Inactive => new(Revoked), + _ => throw new InvalidOperationException($"Cannot revoke from {Value}") + }; + } + + // State queries + public bool IsActive() => Value == Active; + public bool IsMfaRequired() => Value is RequiresMfaSetup or MfaConfigured or MfaSuspended; + public bool IsMfaConfigured() => Value == MfaConfigured; + public bool IsInactive() => Value == Inactive; + public bool IsRevoked() => Value == Revoked; + public bool CanRegister() => Value == Undefined; + public bool CanReceiveRoles() => Value is Active or RequiresMfaSetup or MfaConfigured; + + public override string ToString() => Value; +} diff --git a/src/Modules/IdentityAccess/ManageIdentityAndRoles/Domain/RoleAssignmentState.cs b/src/Modules/IdentityAccess/ManageIdentityAndRoles/Domain/RoleAssignmentState.cs new file mode 100644 index 00000000..5466a496 --- /dev/null +++ b/src/Modules/IdentityAccess/ManageIdentityAndRoles/Domain/RoleAssignmentState.cs @@ -0,0 +1,106 @@ +namespace KArtSell.Modules.IdentityAccess.ManageIdentityAndRoles.Domain; + +/// +/// Role Assignment workflow state (Maker-Checker pattern) +/// AEG-VS-01-03: Immutable value object for approval workflow +/// +public sealed record RoleAssignmentState +{ + public const string PendingApproval = "PENDING_APPROVAL"; + public const string ApprovedBy1 = "APPROVED_BY_1"; + public const string ApprovedBy2 = "APPROVED_BY_2"; + public const string Active = "ACTIVE"; + public const string Expired = "EXPIRED"; + public const string Revoked = "REVOKED"; + public const string Rejected = "REJECTED"; + + private static readonly HashSet ValidStates = + [ + PendingApproval, ApprovedBy1, ApprovedBy2, Active, Expired, Revoked, Rejected + ]; + + public string Value { get; } + + private RoleAssignmentState(string value) + { + if (!ValidStates.Contains(value)) + throw new ArgumentException($"Invalid role assignment state: {value}", nameof(value)); + Value = value; + } + + // Factory methods + public static RoleAssignmentState CreatePending() => new(PendingApproval); + public static RoleAssignmentState Activate() => new(Active); + public static RoleAssignmentState Expire() => new(Expired); + public static RoleAssignmentState Revoke() => new(Revoked); + public static RoleAssignmentState Reject() => new(Rejected); + public static RoleAssignmentState Parse(string value) => new(value); + + // State transitions + public RoleAssignmentState ApproveByFirst() + { + return Value switch + { + PendingApproval => new(ApprovedBy1), + _ => throw new InvalidOperationException($"Cannot approve from {Value}") + }; + } + + public RoleAssignmentState ApproveBySecond() + { + return Value switch + { + ApprovedBy1 => new(ApprovedBy2), + _ => throw new InvalidOperationException($"Cannot approve second from {Value}") + }; + } + + public RoleAssignmentState ActivateAfterApproval() + { + return Value switch + { + ApprovedBy2 => new(Active), + _ => throw new InvalidOperationException($"Cannot activate from {Value}") + }; + } + + public RoleAssignmentState ExpireTimebound() + { + return Value switch + { + Active => new(Expired), + _ => throw new InvalidOperationException($"Cannot expire from {Value}") + }; + } + + public RoleAssignmentState RevokeActive() + { + return Value switch + { + Active or Expired => new(Revoked), + _ => throw new InvalidOperationException($"Cannot revoke from {Value}") + }; + } + + public RoleAssignmentState RejectRequest() + { + return Value switch + { + PendingApproval or ApprovedBy1 => new(Rejected), + _ => throw new InvalidOperationException($"Cannot reject from {Value}") + }; + } + + // State queries + public bool IsPending() => Value == PendingApproval; + public bool IsAwaitingSecondApproval() => Value == ApprovedBy1; + public bool IsApproved() => Value == ApprovedBy2; + public bool IsActive() => Value == Active; + public bool IsExpired() => Value == Expired; + public bool IsRevoked() => Value == Revoked; + public bool IsRejected() => Value == Rejected; + public bool CanApprove() => Value is PendingApproval or ApprovedBy1; + public bool RequiresSecondApproval() => Value == ApprovedBy1; + + public override string ToString() => Value; +} diff --git a/tests/KArtSell.IdentityAccess.UnitTests/ManageIdentityAndRoles/IdentityStateTests.cs b/tests/KArtSell.IdentityAccess.UnitTests/ManageIdentityAndRoles/IdentityStateTests.cs new file mode 100644 index 00000000..2549c475 --- /dev/null +++ b/tests/KArtSell.IdentityAccess.UnitTests/ManageIdentityAndRoles/IdentityStateTests.cs @@ -0,0 +1,142 @@ +using Xunit; +using KArtSell.Modules.IdentityAccess.ManageIdentityAndRoles.Domain; + +namespace KArtSell.IdentityAccess.UnitTests.ManageIdentityAndRoles; + +/// +/// AEG-VS-01-03: Domain policy tests for Identity state machine +/// Pure domain logic (no infrastructure dependencies) +/// +public class IdentityStateTests +{ + [Fact] + public void CanTransitionFromUndefinedToActive() + { + var state = IdentityState.CreateUndefined(); + var nextState = state.Register(); + + Assert.Equal(IdentityState.Active, nextState.Value); + } + + [Fact] + public void CanTransitionFromActiveToRequiresMfaSetup() + { + var state = IdentityState.CreateActive(); + var nextState = state.RequestMfaSetup(); + + Assert.Equal(IdentityState.RequiresMfaSetup, nextState.Value); + } + + [Fact] + public void CanTransitionFromRequiresMfaSetupToMfaConfigured() + { + var state = IdentityState.Parse(IdentityState.RequiresMfaSetup); + var nextState = state.CompleteMfaSetup(); + + Assert.Equal(IdentityState.MfaConfigured, nextState.Value); + } + + [Fact] + public void CanSuspendAndResumeMfa() + { + var state = IdentityState.Parse(IdentityState.MfaConfigured); + var suspended = state.SuspendMfaTemporarily(); + var resumed = suspended.ResumeMfa(); + + Assert.Equal(IdentityState.MfaSuspended, suspended.Value); + Assert.Equal(IdentityState.MfaConfigured, resumed.Value); + } + + [Fact] + public void CanDeactivateFromMultipleStates() + { + var states = new[] + { + IdentityState.CreateActive(), + IdentityState.Parse(IdentityState.RequiresMfaSetup), + IdentityState.Parse(IdentityState.MfaConfigured), + IdentityState.Parse(IdentityState.MfaSuspended) + }; + + foreach (var state in states) + { + var deactivated = state.Deactivate(); + Assert.Equal(IdentityState.Inactive, deactivated.Value); + } + } + + [Fact] + public void CanRevokeFromInactive() + { + var state = IdentityState.Parse(IdentityState.Inactive); + var revoked = state.Revoke(); + + Assert.Equal(IdentityState.Revoked, revoked.Value); + } + + [Fact] + public void InvalidTransitionThrowsException() + { + var state = IdentityState.CreateUndefined(); + + Assert.Throws(() => state.RequestMfaSetup()); + Assert.Throws(() => state.Deactivate()); + } + + [Fact] + public void CanQueryStateProperties() + { + var active = IdentityState.CreateActive(); + Assert.True(active.IsActive()); + Assert.False(active.IsInactive()); + + var mfaRequired = IdentityState.Parse(IdentityState.RequiresMfaSetup); + Assert.True(mfaRequired.IsMfaRequired()); + + var revoked = IdentityState.Parse(IdentityState.Revoked); + Assert.True(revoked.IsRevoked()); + } + + [Fact] + public void CanCheckCapabilities() + { + var undefined = IdentityState.CreateUndefined(); + Assert.True(undefined.CanRegister()); + + var active = IdentityState.CreateActive(); + Assert.True(active.CanReceiveRoles()); + Assert.False(active.CanRegister()); + + var revoked = IdentityState.Parse(IdentityState.Revoked); + Assert.False(revoked.CanReceiveRoles()); + } + + [Theory] + [InlineData(IdentityState.Undefined)] + [InlineData(IdentityState.Active)] + [InlineData(IdentityState.RequiresMfaSetup)] + [InlineData(IdentityState.MfaConfigured)] + [InlineData(IdentityState.MfaSuspended)] + [InlineData(IdentityState.Inactive)] + [InlineData(IdentityState.Revoked)] + public void CanParseAllValidStates(string stateValue) + { + var state = IdentityState.Parse(stateValue); + Assert.Equal(stateValue, state.Value); + } + + [Fact] + public void InvalidStateThrowsException() + { + Assert.Throws(() => IdentityState.Parse("INVALID_STATE")); + } + + [Fact] + public void StateIsValueObject() + { + var state1 = IdentityState.CreateActive(); + var state2 = IdentityState.Parse(IdentityState.Active); + + Assert.Equal(state1, state2); + } +}