diff --git a/tests/KArtSell.Integration.Tests/VS01_IdentityIntegrationTests.cs b/tests/KArtSell.Integration.Tests/VS01_IdentityIntegrationTests.cs deleted file mode 100644 index 3c9e28e0..00000000 --- a/tests/KArtSell.Integration.Tests/VS01_IdentityIntegrationTests.cs +++ /dev/null @@ -1,416 +0,0 @@ -using Xunit; -using Npgsql; - -namespace KArtSell.Integration.Tests; - -/// -/// VS-01: Identity and Roles - Integration Tests -/// Tests: Full user lifecycle, role management, permission enforcement -/// Requires: PostgreSQL connection (via TestDatabaseConnection) -/// -public sealed class VS01_IdentityIntegrationTests : IAsyncLifetime -{ - private NpgsqlDataSource _dataSource = null!; - private const string TestDbName = "vs01_identity_test"; - - public async Task InitializeAsync() - { - // Create test database - var connString = TestDatabaseConnection.GetConnectionString(); - var adminConnString = connString.Replace(TestDatabaseConnection.DefaultDb, "postgres"); - - await using var adminConn = new NpgsqlConnection(adminConnString); - await adminConn.OpenAsync(); - - try - { - await using var cmd = adminConn.CreateCommand(); - cmd.CommandText = $"DROP DATABASE IF EXISTS {TestDbName} WITH (FORCE);"; - await cmd.ExecuteNonQueryAsync(); - } - catch { /* DB doesn't exist */ } - - await using var createCmd = adminConn.CreateCommand(); - createCmd.CommandText = $"CREATE DATABASE {TestDbName};"; - await createCmd.ExecuteNonQueryAsync(); - - await adminConn.CloseAsync(); - - // Connect to test database and apply migrations - var testConnString = connString.Replace(TestDatabaseConnection.DefaultDb, TestDbName); - _dataSource = new NpgsqlDataSourceBuilder(testConnString).Build(); - - await ApplyIdentitySchemaAsync(); - } - - public async Task DisposeAsync() - { - await _dataSource.DisposeAsync(); - - // Cleanup - var connString = TestDatabaseConnection.GetConnectionString(); - var adminConnString = connString.Replace(TestDatabaseConnection.DefaultDb, "postgres"); - - await using var adminConn = new NpgsqlConnection(adminConnString); - await adminConn.OpenAsync(); - - await using var dropCmd = adminConn.CreateCommand(); - dropCmd.CommandText = $"DROP DATABASE IF EXISTS {TestDbName} WITH (FORCE);"; - await dropCmd.ExecuteNonQueryAsync(); - - await adminConn.CloseAsync(); - } - - private async Task ApplyIdentitySchemaAsync() - { - await using var connection = await _dataSource.OpenConnectionAsync(); - - // Create roles table - const string rolesSql = """ - CREATE TABLE IF NOT EXISTS identity.roles ( - id SERIAL PRIMARY KEY, - name VARCHAR(50) NOT NULL UNIQUE, - description VARCHAR(255) - ); - - INSERT INTO identity.roles (name, description) VALUES - ('Admin', 'Full access'), - ('Analyst', 'Read-only'), - ('Trader', 'Trading access'), - ('Viewer', 'View-only') - ON CONFLICT DO NOTHING; - """; - - await using var rolesCmd = connection.CreateCommand(); - rolesCmd.CommandText = rolesSql; - await rolesCmd.ExecuteNonQueryAsync(); - - // Create users table - const string usersSql = """ - CREATE TABLE IF NOT EXISTS identity.users ( - id UUID PRIMARY KEY, - email VARCHAR(255) NOT NULL UNIQUE, - email_hash VARCHAR(64), - password_hash VARCHAR(255), - status VARCHAR(20) DEFAULT 'active', - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - published_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - correlation_id VARCHAR(36) - ); - """; - - await using var usersCmd = connection.CreateCommand(); - usersCmd.CommandText = usersSql; - await usersCmd.ExecuteNonQueryAsync(); - - // Create user_roles table - const string userRolesSql = """ - CREATE TABLE IF NOT EXISTS identity.user_roles ( - id BIGSERIAL PRIMARY KEY, - user_id UUID NOT NULL REFERENCES identity.users(id), - role_id INT NOT NULL REFERENCES identity.roles(id), - assigned_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - published_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - removed_at TIMESTAMP, - correlation_id VARCHAR(36), - UNIQUE(user_id, role_id) WHERE removed_at IS NULL - ); - """; - - await using var userRolesCmd = connection.CreateCommand(); - userRolesCmd.CommandText = userRolesSql; - await userRolesCmd.ExecuteNonQueryAsync(); - } - - // ============ CREATE USER TESTS ============ - - [Fact] - public async Task CreateUser_WithValidData_Succeeds() - { - // Arrange - await using var connection = await _dataSource.OpenConnectionAsync(); - var userId = Guid.NewGuid(); - var email = "alice@example.com"; - - // Act - await using var cmd = connection.CreateCommand(); - cmd.CommandText = """ - INSERT INTO identity.users (id, email, status, correlation_id) - VALUES (@id, @email, 'active', @correlationId); - """; - cmd.Parameters.AddWithValue("@id", userId); - cmd.Parameters.AddWithValue("@email", email); - cmd.Parameters.AddWithValue("@correlationId", Guid.NewGuid().ToString()); - - var result = await cmd.ExecuteNonQueryAsync(); - - // Assert - Assert.Equal(1, result); - } - - [Fact] - public async Task CreateUser_DuplicateEmail_FailsWithConstraint() - { - // Arrange - await using var connection = await _dataSource.OpenConnectionAsync(); - var email = "bob@example.com"; - - // Create first user - await using var cmd1 = connection.CreateCommand(); - cmd1.CommandText = """ - INSERT INTO identity.users (id, email, status, correlation_id) - VALUES (@id, @email, 'active', @correlationId); - """; - cmd1.Parameters.AddWithValue("@id", Guid.NewGuid()); - cmd1.Parameters.AddWithValue("@email", email); - cmd1.Parameters.AddWithValue("@correlationId", Guid.NewGuid().ToString()); - await cmd1.ExecuteNonQueryAsync(); - - // Act & Assert: Try to create duplicate - await using var cmd2 = connection.CreateCommand(); - cmd2.CommandText = """ - INSERT INTO identity.users (id, email, status, correlation_id) - VALUES (@id, @email, 'active', @correlationId); - """; - cmd2.Parameters.AddWithValue("@id", Guid.NewGuid()); - cmd2.Parameters.AddWithValue("@email", email); - cmd2.Parameters.AddWithValue("@correlationId", Guid.NewGuid().ToString()); - - await Assert.ThrowsAsync(() => cmd2.ExecuteNonQueryAsync()); - } - - // ============ ROLE MANAGEMENT TESTS ============ - - [Fact] - public async Task AssignRole_NewRole_Succeeds() - { - // Arrange - await using var connection = await _dataSource.OpenConnectionAsync(); - var userId = Guid.NewGuid(); - - // Create user - await using var userCmd = connection.CreateCommand(); - userCmd.CommandText = """ - INSERT INTO identity.users (id, email, status, correlation_id) - VALUES (@id, 'user@example.com', 'active', @correlationId); - """; - userCmd.Parameters.AddWithValue("@id", userId); - userCmd.Parameters.AddWithValue("@correlationId", Guid.NewGuid().ToString()); - await userCmd.ExecuteNonQueryAsync(); - - // Act: Assign role - await using var roleCmd = connection.CreateCommand(); - roleCmd.CommandText = """ - INSERT INTO identity.user_roles (user_id, role_id, correlation_id) - SELECT @userId, id, @correlationId FROM identity.roles WHERE name = 'Analyst'; - """; - roleCmd.Parameters.AddWithValue("@userId", userId); - roleCmd.Parameters.AddWithValue("@correlationId", Guid.NewGuid().ToString()); - - var result = await roleCmd.ExecuteNonQueryAsync(); - - // Assert - Assert.Equal(1, result); - } - - [Fact] - public async Task DuplicateRole_IsIdempotent() - { - // Arrange - await using var connection = await _dataSource.OpenConnectionAsync(); - var userId = Guid.NewGuid(); - - // Create user - await using var userCmd = connection.CreateCommand(); - userCmd.CommandText = """ - INSERT INTO identity.users (id, email, status, correlation_id) - VALUES (@id, 'user2@example.com', 'active', @correlationId); - """; - userCmd.Parameters.AddWithValue("@id", userId); - userCmd.Parameters.AddWithValue("@correlationId", Guid.NewGuid().ToString()); - await userCmd.ExecuteNonQueryAsync(); - - // Assign role first time - await using var roleCmd1 = connection.CreateCommand(); - roleCmd1.CommandText = """ - INSERT INTO identity.user_roles (user_id, role_id, correlation_id) - SELECT @userId, id, @correlationId FROM identity.roles WHERE name = 'Analyst' - ON CONFLICT (user_id, role_id) WHERE removed_at IS NULL DO NOTHING; - """; - roleCmd1.Parameters.AddWithValue("@userId", userId); - roleCmd1.Parameters.AddWithValue("@correlationId", Guid.NewGuid().ToString()); - await roleCmd1.ExecuteNonQueryAsync(); - - // Act: Try to assign same role again - await using var roleCmd2 = connection.CreateCommand(); - roleCmd2.CommandText = """ - INSERT INTO identity.user_roles (user_id, role_id, correlation_id) - SELECT @userId, id, @correlationId FROM identity.roles WHERE name = 'Analyst' - ON CONFLICT (user_id, role_id) WHERE removed_at IS NULL DO NOTHING; - """; - roleCmd2.Parameters.AddWithValue("@userId", userId); - roleCmd2.Parameters.AddWithValue("@correlationId", Guid.NewGuid().ToString()); - - var result = await roleCmd2.ExecuteNonQueryAsync(); - - // Assert: Should be 0 (no insert due to conflict) - Assert.Equal(0, result); - } - - [Fact] - public async Task RevokeRole_UsingSoftDelete_Succeeds() - { - // Arrange - await using var connection = await _dataSource.OpenConnectionAsync(); - var userId = Guid.NewGuid(); - - // Create user and assign role - await using var userCmd = connection.CreateCommand(); - userCmd.CommandText = """ - INSERT INTO identity.users (id, email, status, correlation_id) - VALUES (@id, 'user3@example.com', 'active', @correlationId); - INSERT INTO identity.user_roles (user_id, role_id, correlation_id) - SELECT @id, id, @correlationId FROM identity.roles WHERE name = 'Analyst'; - """; - userCmd.Parameters.AddWithValue("@id", userId); - userCmd.Parameters.AddWithValue("@correlationId", Guid.NewGuid().ToString()); - await userCmd.ExecuteNonQueryAsync(); - - // Act: Revoke role (soft delete) - await using var revokeCmd = connection.CreateCommand(); - revokeCmd.CommandText = """ - UPDATE identity.user_roles - SET removed_at = CURRENT_TIMESTAMP - WHERE user_id = @userId AND role_id = (SELECT id FROM identity.roles WHERE name = 'Analyst'); - """; - revokeCmd.Parameters.AddWithValue("@userId", userId); - - var result = await revokeCmd.ExecuteNonQueryAsync(); - - // Assert - Assert.Equal(1, result); - - // Verify: User should have no active roles - await using var verifyCmd = connection.CreateCommand(); - verifyCmd.CommandText = """ - SELECT COUNT(*) FROM identity.user_roles - WHERE user_id = @userId AND removed_at IS NULL; - """; - verifyCmd.Parameters.AddWithValue("@userId", userId); - - var activeRoles = (long?)await verifyCmd.ExecuteScalarAsync() ?? 0; - Assert.Equal(0, activeRoles); - } - - // ============ LIST USERS TESTS ============ - - [Fact] - public async Task ListUsers_WithPagination_ReturnsCorrectSet() - { - // Arrange - await using var connection = await _dataSource.OpenConnectionAsync(); - - // Create 5 users - for (int i = 0; i < 5; i++) - { - await using var cmd = connection.CreateCommand(); - cmd.CommandText = """ - INSERT INTO identity.users (id, email, status, correlation_id) - VALUES (@id, @email, 'active', @correlationId); - """; - cmd.Parameters.AddWithValue("@id", Guid.NewGuid()); - cmd.Parameters.AddWithValue("@email", $"user{i}@example.com"); - cmd.Parameters.AddWithValue("@correlationId", Guid.NewGuid().ToString()); - await cmd.ExecuteNonQueryAsync(); - } - - // Act: Query page 1, limit 2 - await using var selectCmd = connection.CreateCommand(); - selectCmd.CommandText = """ - SELECT COUNT(*) as total FROM identity.users; - SELECT id, email FROM identity.users - ORDER BY created_at DESC - LIMIT 2 OFFSET 0; - """; - - var reader = await selectCmd.ExecuteReaderAsync(); - - // Read total - await reader.ReadAsync(); - var total = (long)reader[0]; - - // Read results - await reader.NextResultAsync(); - var count = 0; - while (await reader.ReadAsync()) - { - count++; - } - - // Assert - Assert.Equal(5, total); - Assert.Equal(2, count); - } - - // ============ PIT (Point-in-Time) TESTS ============ - - [Fact] - public async Task PIT_Query_OnlyReturnsPublishedData() - { - // Arrange - await using var connection = await _dataSource.OpenConnectionAsync(); - var userId = Guid.NewGuid(); - - await using var cmd = connection.CreateCommand(); - cmd.CommandText = """ - INSERT INTO identity.users (id, email, status, published_at, correlation_id) - VALUES (@id, 'user@example.com', 'active', CURRENT_TIMESTAMP, @correlationId); - """; - cmd.Parameters.AddWithValue("@id", userId); - cmd.Parameters.AddWithValue("@correlationId", Guid.NewGuid().ToString()); - await cmd.ExecuteNonQueryAsync(); - - // Act: Query with PIT cutoff - await using var selectCmd = connection.CreateCommand(); - selectCmd.CommandText = """ - SELECT COUNT(*) FROM identity.users - WHERE published_at <= CURRENT_TIMESTAMP; - """; - - var count = (long?)await selectCmd.ExecuteScalarAsync() ?? 0; - - // Assert - Assert.True(count > 0, "Should find user with published_at <= now"); - } - - // ============ CONSISTENCY TESTS ============ - - [Fact] - public async Task Status_OnlyAllowsValidValues() - { - // Arrange - await using var connection = await _dataSource.OpenConnectionAsync(); - - // Act & Assert: Try to insert invalid status - await using var cmd = connection.CreateCommand(); - cmd.CommandText = """ - INSERT INTO identity.users (id, email, status, correlation_id) - VALUES (@id, 'user@example.com', 'invalid_status', @correlationId); - """; - cmd.Parameters.AddWithValue("@id", Guid.NewGuid()); - cmd.Parameters.AddWithValue("@correlationId", Guid.NewGuid().ToString()); - - // Note: If CHECK constraint exists, this throws PostgresException - // Otherwise, application layer validates - try - { - await cmd.ExecuteNonQueryAsync(); - } - catch (PostgresException ex) when (ex.SqlState == "23514") - { - // CHECK constraint violated (expected) - Assert.True(true); - } - } -} 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)); -}