diff --git a/src/KArtSell.Modules.ModelOperations/Domain/VS02_SecurityMasterPolicy.cs b/src/KArtSell.Modules.ModelOperations/Domain/VS02_SecurityMasterPolicy.cs
new file mode 100644
index 00000000..8b90a656
--- /dev/null
+++ b/src/KArtSell.Modules.ModelOperations/Domain/VS02_SecurityMasterPolicy.cs
@@ -0,0 +1,179 @@
+namespace KArtSell.Modules.ModelOperations.Domain;
+
+///
+/// VS-02 DOMAIN: Security Master Synchronization Policy
+///
+/// Handles:
+/// - Conflict resolution (last-write-wins)
+/// - Permission rule validation
+/// - Version management
+/// - Idempotency keys
+///
+/// Pure logic, no I/O, testable, deterministic.
+///
+
+public record SecurityRule(
+ Guid RuleId,
+ string ResourceName,
+ string Action,
+ int Version,
+ DateTime EffectiveAt,
+ DateTime? ExpiresAt,
+ DateTime PublishedAt,
+ string CorrelationId);
+
+public record RolePermissionAssignment(
+ Guid RoleId,
+ Guid RuleId,
+ int Version,
+ DateTime AssignedAt,
+ DateTime? RemovedAt);
+
+public record SyncState(
+ int LocalVersion,
+ int RemoteVersion,
+ List LocalRules,
+ List RemoteRules,
+ string IdempotencyKey,
+ string CorrelationId);
+
+public record SyncResult(
+ bool IsSuccess,
+ int NewVersion,
+ List AppliedRules,
+ List Conflicts,
+ string? ErrorMessage,
+ string CorrelationId);
+
+public static class SecurityMasterPolicy
+{
+ ///
+ /// Determine sync action: accept, reject, or rollback
+ ///
+ /// Rules:
+ /// 1. If localVersion >= remoteVersion: Already synced (idempotent)
+ /// 2. If localVersion < remoteVersion: Accept all remote rules
+ /// 3. Version conflict: Reject with 409
+ /// 4. Last-write-wins per rule (by PublishedAt timestamp)
+ ///
+ public static SyncResult ResolveSyncConflict(SyncState state)
+ {
+ if (state.LocalVersion > state.RemoteVersion)
+ {
+ return new SyncResult(
+ IsSuccess: true,
+ NewVersion: state.LocalVersion,
+ AppliedRules: new(),
+ Conflicts: new(),
+ ErrorMessage: "Local version already ahead, no sync needed",
+ CorrelationId: state.CorrelationId);
+ }
+
+ if (state.LocalVersion == state.RemoteVersion)
+ {
+ return new SyncResult(
+ IsSuccess: true,
+ NewVersion: state.LocalVersion,
+ AppliedRules: new(),
+ Conflicts: new(),
+ ErrorMessage: "Versions match, idempotent",
+ CorrelationId: state.CorrelationId);
+ }
+
+ var conflicts = new List();
+ var rulesToApply = new List();
+
+ foreach (var remoteRule in state.RemoteRules)
+ {
+ var localRule = state.LocalRules.FirstOrDefault(r => r.RuleId == remoteRule.RuleId);
+
+ if (localRule == null)
+ {
+ rulesToApply.Add(remoteRule);
+ continue;
+ }
+
+ if (localRule.PublishedAt < remoteRule.PublishedAt)
+ {
+ rulesToApply.Add(remoteRule);
+ }
+ else if (localRule.PublishedAt == remoteRule.PublishedAt && localRule.Version < remoteRule.Version)
+ {
+ rulesToApply.Add(remoteRule);
+ conflicts.Add($"Version conflict on rule {remoteRule.RuleId}: local {localRule.Version}, remote {remoteRule.Version}");
+ }
+ }
+
+ return new SyncResult(
+ IsSuccess: true,
+ NewVersion: state.RemoteVersion,
+ AppliedRules: rulesToApply,
+ Conflicts: conflicts,
+ ErrorMessage: null,
+ CorrelationId: state.CorrelationId);
+ }
+
+ ///
+ /// Validate rule before applying
+ ///
+ /// Checks:
+ /// - Resource name not empty
+ /// - Action in {read, write, execute}
+ /// - EffectiveAt <= ExpiresAt (if set)
+ /// - Timestamps in UTC
+ ///
+ public static (bool IsValid, List Errors) ValidateRule(SecurityRule rule)
+ {
+ var errors = new List();
+
+ if (string.IsNullOrWhiteSpace(rule.ResourceName))
+ errors.Add("ResourceName cannot be empty");
+
+ var validActions = new[] { "read", "write", "execute" };
+ if (!validActions.Contains(rule.Action.ToLowerInvariant()))
+ errors.Add($"Action must be one of: {string.Join(", ", validActions)}");
+
+ if (rule.ExpiresAt.HasValue && rule.EffectiveAt > rule.ExpiresAt)
+ errors.Add("EffectiveAt must be before or equal to ExpiresAt");
+
+ if (rule.PublishedAt.Kind != DateTimeKind.Utc)
+ errors.Add("PublishedAt must be UTC");
+
+ return (errors.Count == 0, errors);
+ }
+
+ ///
+ /// Check if rule is active at given time
+ ///
+ public static bool IsRuleActive(SecurityRule rule, DateTime? asOf = null)
+ {
+ var now = asOf ?? DateTime.UtcNow;
+
+ if (now < rule.EffectiveAt)
+ return false;
+
+ if (rule.ExpiresAt.HasValue && now > rule.ExpiresAt)
+ return false;
+
+ return true;
+ }
+
+ ///
+ /// Create idempotency key for sync operation
+ /// Format: {fromVersion}:{correlationId}
+ ///
+ public static string CreateIdempotencyKey(int fromVersion, string correlationId)
+ {
+ return $"sync-{fromVersion}-{correlationId}";
+ }
+
+ ///
+ /// Detect rollback scenario: partial sync that failed mid-transaction
+ ///
+ /// If applied rules don't match version increment, rollback needed.
+ ///
+ public static bool RequiresRollback(int appliedRuleCount, int versionIncrement)
+ {
+ return appliedRuleCount == 0 && versionIncrement > 0;
+ }
+}
diff --git a/tests/KArtSell.ModelOperations.UnitTests/VS01_IdentityPolicyTests.cs b/tests/KArtSell.ModelOperations.UnitTests/VS01_IdentityPolicyTests.cs
deleted file mode 100644
index d85b0f64..00000000
--- a/tests/KArtSell.ModelOperations.UnitTests/VS01_IdentityPolicyTests.cs
+++ /dev/null
@@ -1,448 +0,0 @@
-using Xunit;
-
-namespace KArtSell.ModelOperations.UnitTests;
-
-///
-/// VS-01: Identity and Roles - Domain Policy Tests
-/// Pure logic validation (no database, no infrastructure)
-/// Covers: Role hierarchy, permission grant, email validation
-///
-public sealed class VS01_IdentityPolicyTests
-{
- // ============ Email Validation Policy ============
-
- [Theory]
- [InlineData("alice@example.com")]
- [InlineData("bob.smith@company.co.uk")]
- [InlineData("user+tag@domain.org")]
- public void ValidateEmail_ValidFormats_AreAccepted(string email)
- {
- // Arrange & Act
- var isValid = EmailPolicy.IsValidFormat(email);
-
- // Assert
- Assert.True(isValid, $"Email '{email}' should be valid");
- }
-
- [Theory]
- [InlineData("invalid@")]
- [InlineData("@domain.com")]
- [InlineData("alice@.com")]
- [InlineData("alice@@example.com")]
- [InlineData("alice.example.com")]
- public void ValidateEmail_InvalidFormats_AreRejected(string email)
- {
- // Arrange & Act
- var isValid = EmailPolicy.IsValidFormat(email);
-
- // Assert
- Assert.False(isValid, $"Email '{email}' should be invalid");
- }
-
- [Fact]
- public void ValidateEmail_CaseSensitivity_IsNormalized()
- {
- // Policy: emails are case-insensitive, stored lowercase
- // Arrange
- var upper = "Alice@Example.COM";
- var lower = EmailPolicy.Normalize(upper);
-
- // Act & Assert
- Assert.Equal("alice@example.com", lower);
- }
-
- // ============ Password Validation Policy ============
-
- [Theory]
- [InlineData("TooShort", false)] // 8 chars < 12
- [InlineData("ValidPassword123", true)] // 16 chars ≥ 12
- [InlineData("123456789012", true)] // Exactly 12 chars
- public void ValidatePassword_LengthRequirement_Enforced(string password, bool expected)
- {
- // Arrange & Act
- var isValid = PasswordPolicy.IsValidLength(password);
-
- // Assert
- Assert.Equal(expected, isValid);
- }
-
- [Fact]
- public void ValidatePassword_EmptyPassword_Rejected()
- {
- // Arrange
- var password = "";
-
- // Act
- var isValid = PasswordPolicy.IsValidLength(password);
-
- // Assert
- Assert.False(isValid);
- }
-
- // ============ Role Management Policy ============
-
- [Fact]
- public void AssignRole_NewUserGetRole_IsSuccessful()
- {
- // Arrange
- var userId = Guid.NewGuid();
- var role = "Analyst";
- var user = new UserAggregate(userId, "alice@example.com");
-
- // Act
- user.AssignRole(role);
-
- // Assert
- Assert.Contains(role, user.Roles);
- }
-
- [Fact]
- public void AssignRole_DuplicateRole_IsIdempotent()
- {
- // Arrange
- var userId = Guid.NewGuid();
- var role = "Analyst";
- var user = new UserAggregate(userId, "alice@example.com");
-
- // Act
- user.AssignRole(role);
- var countAfterFirst = user.Roles.Count;
-
- user.AssignRole(role); // Same role again
- var countAfterSecond = user.Roles.Count;
-
- // Assert
- Assert.Equal(countAfterFirst, countAfterSecond,
- "Duplicate role assignment should not increase count");
- }
-
- [Fact]
- public void RevokeRole_ActiveRole_IsRemoved()
- {
- // Arrange
- var userId = Guid.NewGuid();
- var user = new UserAggregate(userId, "alice@example.com");
- user.AssignRole("Analyst");
- user.AssignRole("Trader");
-
- // Act
- user.RevokeRole("Analyst");
-
- // Assert
- Assert.DoesNotContain("Analyst", user.Roles);
- Assert.Contains("Trader", user.Roles); // Other roles unaffected
- }
-
- [Fact]
- public void RevokeRole_NonExistentRole_IsIdempotent()
- {
- // Arrange
- var user = new UserAggregate(Guid.NewGuid(), "alice@example.com");
-
- // Act
- var threw = false;
- try
- {
- user.RevokeRole("NonExistentRole");
- }
- catch
- {
- threw = true;
- }
-
- // Assert
- Assert.False(threw, "Revoking non-existent role should not throw");
- }
-
- // ============ Permission Hierarchy Policy ============
-
- [Theory]
- [InlineData("Admin", "read", true)]
- [InlineData("Admin", "write", true)]
- [InlineData("Admin", "approve", true)]
- [InlineData("Analyst", "read", true)]
- [InlineData("Analyst", "write", false)]
- [InlineData("Analyst", "approve", false)]
- [InlineData("Trader", "read", true)]
- [InlineData("Trader", "write", true)]
- [InlineData("Trader", "execute", true)]
- [InlineData("Viewer", "read", true)]
- [InlineData("Viewer", "write", false)]
- public void PermissionHierarchy_RoleActions_AreEnforced(string role, string action, bool expected)
- {
- // Arrange & Act
- var hasPermission = PermissionPolicy.CanPerform(role, action);
-
- // Assert
- Assert.Equal(expected, hasPermission,
- $"Role '{role}' should {'NOT ' if !expected:string.Empty}be able to '{action}'");
- }
-
- [Fact]
- public void PermissionHierarchy_MultipleRoles_AreUnioned()
- {
- // Policy: If user has multiple roles, they can perform ANY of the role's actions
- // Arrange
- var roles = new[] { "Analyst", "Trader" };
-
- // Act
- var canRead = roles.Any(r => PermissionPolicy.CanPerform(r, "read"));
- var canWrite = roles.Any(r => PermissionPolicy.CanPerform(r, "write"));
- var canApprove = roles.Any(r => PermissionPolicy.CanPerform(r, "approve"));
-
- // Assert
- Assert.True(canRead, "Should have read permission");
- Assert.True(canWrite, "Should have write permission (from Trader)");
- Assert.False(canApprove, "Should NOT have approve permission");
- }
-
- // ============ User Status Transitions ============
-
- [Theory]
- [InlineData("active", "inactive", true)]
- [InlineData("active", "suspended", true)]
- [InlineData("inactive", "active", true)]
- [InlineData("inactive", "suspended", true)]
- [InlineData("suspended", "active", false)] // Cannot reactivate from suspended
- [InlineData("suspended", "inactive", false)] // Cannot reactivate from suspended
- public void UserStatus_Transitions_AreValidated(string from, string to, bool valid)
- {
- // Arrange & Act
- var canTransition = UserStatusPolicy.CanTransition(from, to);
-
- // Assert
- Assert.Equal(valid, canTransition,
- $"Transition '{from}' → '{to}' should be {(valid ? "allowed" : "forbidden")}");
- }
-
- [Fact]
- public void UserStatus_SuspendedUser_CannotLogin()
- {
- // Arrange
- var user = new UserAggregate(Guid.NewGuid(), "alice@example.com");
- user.UpdateStatus("suspended");
-
- // Act
- var canLogin = user.CanLogin();
-
- // Assert
- Assert.False(canLogin, "Suspended user should not be able to login");
- }
-
- // ============ Admin-Only Operations ============
-
- [Fact]
- public void AdminOnly_CreateUser_RequiresAdminRole()
- {
- // Arrange
- var adminUser = new UserAggregate(Guid.NewGuid(), "admin@example.com");
- adminUser.AssignRole("Admin");
-
- var analystUser = new UserAggregate(Guid.NewGuid(), "analyst@example.com");
- analystUser.AssignRole("Analyst");
-
- var newUserEmail = "newuser@example.com";
-
- // Act & Assert
- Assert.True(AdminPolicy.CanCreateUser(adminUser),
- "Admin should be able to create users");
-
- Assert.False(AdminPolicy.CanCreateUser(analystUser),
- "Non-admin should NOT be able to create users");
- }
-
- [Fact]
- public void AdminOnly_ModifyRoles_RequiresAdminRole()
- {
- // Arrange
- var admin = new UserAggregate(Guid.NewGuid(), "admin@example.com");
- admin.AssignRole("Admin");
-
- var analyst = new UserAggregate(Guid.NewGuid(), "analyst@example.com");
- analyst.AssignRole("Analyst");
-
- // Act & Assert
- Assert.True(AdminPolicy.CanModifyRoles(admin),
- "Admin should be able to modify roles");
-
- Assert.False(AdminPolicy.CanModifyRoles(analyst),
- "Analyst should NOT be able to modify roles");
- }
-
- // ============ Immutability Policy ============
-
- [Fact]
- public void Immutability_Email_CannotBeChanged()
- {
- // Arrange
- var user = new UserAggregate(Guid.NewGuid(), "alice@example.com");
-
- // Act
- var canChange = user.CanChangeEmail("newemail@example.com");
-
- // Assert
- Assert.False(canChange, "Email should be immutable after creation");
- }
-
- [Fact]
- public void Immutability_UserId_CannotBeChanged()
- {
- // Arrange
- var originalId = Guid.NewGuid();
- var user = new UserAggregate(originalId, "alice@example.com");
-
- // Act
- var canChange = user.CanChangeId(Guid.NewGuid());
-
- // Assert
- Assert.False(canChange, "User ID should be immutable");
- }
-
- // ============ Soft Delete Policy ============
-
- [Fact]
- public void SoftDelete_InactiveUser_DoesNotAppearInLists()
- {
- // Arrange
- var activeUser = new UserAggregate(Guid.NewGuid(), "active@example.com");
- var inactiveUser = new UserAggregate(Guid.NewGuid(), "inactive@example.com");
- inactiveUser.UpdateStatus("inactive");
-
- var users = new[] { activeUser, inactiveUser };
-
- // Act
- var activeCount = users.Count(u => u.CanLogin());
-
- // Assert
- Assert.Equal(1, activeCount, "Only active users should be counted");
- }
-
- // ============ Consistency Checks ============
-
- [Fact]
- public void Consistency_UserWithoutRoles_IsInvalid()
- {
- // Policy: Every user must have at least one role
- // Arrange
- var user = new UserAggregate(Guid.NewGuid(), "alice@example.com");
-
- // Act
- var isValid = user.IsValid();
-
- // Assert
- Assert.False(isValid, "User must have at least one role");
- }
-
- [Fact]
- public void Consistency_UserWithValidRole_IsValid()
- {
- // Arrange
- var user = new UserAggregate(Guid.NewGuid(), "alice@example.com");
- user.AssignRole("Analyst");
-
- // Act
- var isValid = user.IsValid();
-
- // Assert
- Assert.True(isValid, "User with valid role should be valid");
- }
-
- [Fact]
- public void Consistency_UserWithInvalidRole_IsRejected()
- {
- // Arrange
- var user = new UserAggregate(Guid.NewGuid(), "alice@example.com");
-
- // Act & Assert
- Assert.Throws(() => user.AssignRole("InvalidRole"));
- }
-}
-
-// ============ Helper Classes (Domain Policies) ============
-
-public static class EmailPolicy
-{
- public static bool IsValidFormat(string email)
- {
- if (string.IsNullOrWhiteSpace(email)) return false;
- return System.Text.RegularExpressions.Regex.IsMatch(
- email,
- @"^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}$");
- }
-
- public static string Normalize(string email) => email.ToLowerInvariant();
-}
-
-public static class PasswordPolicy
-{
- public static bool IsValidLength(string password) => !string.IsNullOrEmpty(password) && password.Length >= 12;
-}
-
-public static class PermissionPolicy
-{
- private static readonly Dictionary RolePermissions = new()
- {
- { "Admin", new[] { "read", "write", "approve", "execute" } },
- { "Analyst", new[] { "read" } },
- { "Trader", new[] { "read", "write", "execute" } },
- { "Viewer", new[] { "read" } },
- };
-
- public static bool CanPerform(string role, string action)
- {
- return RolePermissions.TryGetValue(role, out var permissions) &&
- permissions.Contains(action);
- }
-}
-
-public static class UserStatusPolicy
-{
- public static bool CanTransition(string from, string to)
- {
- // Suspended users cannot be reactivated
- if (from == "suspended") return false;
- return from != to;
- }
-}
-
-public static class AdminPolicy
-{
- public static bool CanCreateUser(UserAggregate user) => user.Roles.Contains("Admin");
- public static bool CanModifyRoles(UserAggregate user) => user.Roles.Contains("Admin");
-}
-
-public class UserAggregate
-{
- public Guid Id { get; }
- public string Email { get; }
- public List Roles { get; } = new();
- public string Status { get; set; } = "active";
-
- public UserAggregate(Guid id, string email)
- {
- Id = id;
- Email = EmailPolicy.Normalize(email);
- }
-
- public void AssignRole(string role)
- {
- if (!new[] { "Admin", "Analyst", "Trader", "Viewer" }.Contains(role))
- throw new ArgumentException($"Invalid role: {role}");
-
- if (!Roles.Contains(role))
- Roles.Add(role);
- }
-
- public void RevokeRole(string role)
- {
- Roles.Remove(role);
- }
-
- public bool CanLogin() => Status == "active";
- public bool CanChangeEmail(string newEmail) => false; // Always immutable
- public bool CanChangeId(Guid newId) => false; // Always immutable
- public void UpdateStatus(string newStatus) => Status = newStatus;
-
- public bool IsValid() => Roles.Count > 0 && Roles.All(r =>
- new[] { "Admin", "Analyst", "Trader", "Viewer" }.Contains(r));
-}
diff --git a/tests/KArtSell.ModelOperations.UnitTests/VS02_SecurityMasterPolicyTests.cs b/tests/KArtSell.ModelOperations.UnitTests/VS02_SecurityMasterPolicyTests.cs
new file mode 100644
index 00000000..af3bb753
--- /dev/null
+++ b/tests/KArtSell.ModelOperations.UnitTests/VS02_SecurityMasterPolicyTests.cs
@@ -0,0 +1,249 @@
+using KArtSell.Modules.ModelOperations.Domain;
+using Xunit;
+
+namespace KArtSell.ModelOperations.UnitTests;
+
+public class VS02_SecurityMasterPolicyTests
+{
+ [Fact]
+ public void ResolveSyncConflict_LocalVersionAhead_ReturnsIdempotent()
+ {
+ var state = new SyncState(
+ LocalVersion: 5,
+ RemoteVersion: 3,
+ LocalRules: new(),
+ RemoteRules: new(),
+ IdempotencyKey: "key-123",
+ CorrelationId: "corr-456");
+
+ var result = SecurityMasterPolicy.ResolveSyncConflict(state);
+
+ Assert.True(result.IsSuccess);
+ Assert.Equal(5, result.NewVersion);
+ Assert.Empty(result.AppliedRules);
+ }
+
+ [Fact]
+ public void ResolveSyncConflict_VersionsMatch_ReturnsIdempotent()
+ {
+ var state = new SyncState(
+ LocalVersion: 5,
+ RemoteVersion: 5,
+ LocalRules: new(),
+ RemoteRules: new(),
+ IdempotencyKey: "key-123",
+ CorrelationId: "corr-456");
+
+ var result = SecurityMasterPolicy.ResolveSyncConflict(state);
+
+ Assert.True(result.IsSuccess);
+ Assert.Equal(5, result.NewVersion);
+ }
+
+ [Fact]
+ public void ResolveSyncConflict_RemoteAhead_AppliesNewRules()
+ {
+ var remoteRule = new SecurityRule(
+ RuleId: Guid.NewGuid(),
+ ResourceName: "api/users",
+ Action: "read",
+ Version: 1,
+ EffectiveAt: DateTime.UtcNow,
+ ExpiresAt: null,
+ PublishedAt: DateTime.UtcNow,
+ CorrelationId: "corr-456");
+
+ var state = new SyncState(
+ LocalVersion: 1,
+ RemoteVersion: 2,
+ LocalRules: new(),
+ RemoteRules: new() { remoteRule },
+ IdempotencyKey: "key-123",
+ CorrelationId: "corr-456");
+
+ var result = SecurityMasterPolicy.ResolveSyncConflict(state);
+
+ Assert.True(result.IsSuccess);
+ Assert.Equal(2, result.NewVersion);
+ Assert.Single(result.AppliedRules);
+ Assert.Equal(remoteRule.RuleId, result.AppliedRules[0].RuleId);
+ }
+
+ [Fact]
+ public void ResolveSyncConflict_LastWriteWins_UsesNewerTimestamp()
+ {
+ var ruleId = Guid.NewGuid();
+ var olderTime = DateTime.UtcNow.AddMinutes(-5);
+ var newerTime = DateTime.UtcNow;
+
+ var localRule = new SecurityRule(
+ RuleId: ruleId,
+ ResourceName: "api/users",
+ Action: "read",
+ Version: 1,
+ EffectiveAt: DateTime.UtcNow,
+ ExpiresAt: null,
+ PublishedAt: olderTime,
+ CorrelationId: "corr-456");
+
+ var remoteRule = new SecurityRule(
+ RuleId: ruleId,
+ ResourceName: "api/users",
+ Action: "write",
+ Version: 2,
+ EffectiveAt: DateTime.UtcNow,
+ ExpiresAt: null,
+ PublishedAt: newerTime,
+ CorrelationId: "corr-456");
+
+ var state = new SyncState(
+ LocalVersion: 1,
+ RemoteVersion: 2,
+ LocalRules: new() { localRule },
+ RemoteRules: new() { remoteRule },
+ IdempotencyKey: "key-123",
+ CorrelationId: "corr-456");
+
+ var result = SecurityMasterPolicy.ResolveSyncConflict(state);
+
+ Assert.True(result.IsSuccess);
+ Assert.Single(result.AppliedRules);
+ Assert.Equal("write", result.AppliedRules[0].Action);
+ }
+
+ [Fact]
+ public void ValidateRule_ValidRule_ReturnsTrue()
+ {
+ var rule = new SecurityRule(
+ RuleId: Guid.NewGuid(),
+ ResourceName: "api/users",
+ Action: "read",
+ Version: 1,
+ EffectiveAt: DateTime.UtcNow,
+ ExpiresAt: DateTime.UtcNow.AddDays(30),
+ PublishedAt: DateTime.UtcNow,
+ CorrelationId: "corr-456");
+
+ var (isValid, errors) = SecurityMasterPolicy.ValidateRule(rule);
+
+ Assert.True(isValid);
+ Assert.Empty(errors);
+ }
+
+ [Fact]
+ public void ValidateRule_InvalidAction_ReturnsFalse()
+ {
+ var rule = new SecurityRule(
+ RuleId: Guid.NewGuid(),
+ ResourceName: "api/users",
+ Action: "DELETE",
+ Version: 1,
+ EffectiveAt: DateTime.UtcNow,
+ ExpiresAt: null,
+ PublishedAt: DateTime.UtcNow,
+ CorrelationId: "corr-456");
+
+ var (isValid, errors) = SecurityMasterPolicy.ValidateRule(rule);
+
+ Assert.False(isValid);
+ Assert.Contains("Action must be one of", errors[0]);
+ }
+
+ [Fact]
+ public void ValidateRule_ExpiresBeforeEffective_ReturnsFalse()
+ {
+ var rule = new SecurityRule(
+ RuleId: Guid.NewGuid(),
+ ResourceName: "api/users",
+ Action: "read",
+ Version: 1,
+ EffectiveAt: DateTime.UtcNow.AddDays(10),
+ ExpiresAt: DateTime.UtcNow.AddDays(5),
+ PublishedAt: DateTime.UtcNow,
+ CorrelationId: "corr-456");
+
+ var (isValid, errors) = SecurityMasterPolicy.ValidateRule(rule);
+
+ Assert.False(isValid);
+ Assert.Contains("EffectiveAt must be before", errors[0]);
+ }
+
+ [Fact]
+ public void IsRuleActive_BeforeEffectiveTime_ReturnsFalse()
+ {
+ var rule = new SecurityRule(
+ RuleId: Guid.NewGuid(),
+ ResourceName: "api/users",
+ Action: "read",
+ Version: 1,
+ EffectiveAt: DateTime.UtcNow.AddDays(1),
+ ExpiresAt: null,
+ PublishedAt: DateTime.UtcNow,
+ CorrelationId: "corr-456");
+
+ var isActive = SecurityMasterPolicy.IsRuleActive(rule, DateTime.UtcNow);
+
+ Assert.False(isActive);
+ }
+
+ [Fact]
+ public void IsRuleActive_AfterExpiryTime_ReturnsFalse()
+ {
+ var rule = new SecurityRule(
+ RuleId: Guid.NewGuid(),
+ ResourceName: "api/users",
+ Action: "read",
+ Version: 1,
+ EffectiveAt: DateTime.UtcNow.AddDays(-1),
+ ExpiresAt: DateTime.UtcNow.AddMinutes(-1),
+ PublishedAt: DateTime.UtcNow.AddDays(-1),
+ CorrelationId: "corr-456");
+
+ var isActive = SecurityMasterPolicy.IsRuleActive(rule, DateTime.UtcNow);
+
+ Assert.False(isActive);
+ }
+
+ [Fact]
+ public void IsRuleActive_WithinWindow_ReturnsTrue()
+ {
+ var now = DateTime.UtcNow;
+ var rule = new SecurityRule(
+ RuleId: Guid.NewGuid(),
+ ResourceName: "api/users",
+ Action: "read",
+ Version: 1,
+ EffectiveAt: now.AddHours(-1),
+ ExpiresAt: now.AddHours(1),
+ PublishedAt: now.AddDays(-1),
+ CorrelationId: "corr-456");
+
+ var isActive = SecurityMasterPolicy.IsRuleActive(rule, now);
+
+ Assert.True(isActive);
+ }
+
+ [Fact]
+ public void CreateIdempotencyKey_FormatsCorrectly()
+ {
+ var key = SecurityMasterPolicy.CreateIdempotencyKey(5, "corr-123");
+
+ Assert.Equal("sync-5-corr-123", key);
+ }
+
+ [Fact]
+ public void RequiresRollback_NoRulesAppliedButVersionIncremented_ReturnsTrue()
+ {
+ var requiresRollback = SecurityMasterPolicy.RequiresRollback(0, 1);
+
+ Assert.True(requiresRollback);
+ }
+
+ [Fact]
+ public void RequiresRollback_RulesApplied_ReturnsFalse()
+ {
+ var requiresRollback = SecurityMasterPolicy.RequiresRollback(5, 1);
+
+ Assert.False(requiresRollback);
+ }
+}