555133d245
Phase 2 Batch 1 - No Dependencies (Start Immediately) ├─ VS-01: ManageIdentityAndRoles │ ├─ GOV: VS-01_SLICE_SPEC.md (Policy/Scope/Failure/Acceptance) │ ├─ DATA: VS-01_DATA_CONTRACT.md (3NF schema, PIT, CDC events) │ └─ DOMAIN: VS01_IdentityPolicyTests.cs (15 tests, pure logic) └─ VS-02: SynchronizeSecurityMaster (🔜 Next) ### VS-01 GOV Component - User Management (CRUD, soft-delete) - Role & Permission Model (Admin/Analyst/Trader/Viewer) - Data Integrity (PIT compliance, immutable email) - API Contracts (POST/GET/PATCH endpoints) - UI/UX Acceptance Criteria - Security Model - Failure Modes & Recovery ### VS-01 DATA Component - Schema (3NF): identity.users, identity.roles, identity.user_roles, identity.user_permissions - Constraints: Email UNIQUE, status ENUM, PIT temporal ordering - Immutability: Email/UserID/Roles cannot change post-creation - Soft-delete: removed_at pattern (append-only) - PIT Queries: published_at <= cutoff validation - CDC Events: UserCreated, RoleAssigned, RoleRevoked - Idempotency: Email-based dedup, role assignment idempotent ### VS-01 DOMAIN Component - 15 Domain Policy Tests (NO database, pure logic) ✅ Email validation (format, normalization, case-insensitivity) ✅ Password validation (length ≥12 chars) ✅ Role management (assign, revoke, idempotency) ✅ Permission hierarchy (role-based access control) ✅ User status transitions (active/inactive/suspended) ✅ Admin-only operations (user creation, role modification) ✅ Immutability (email, user ID) ✅ Soft-delete (inactive users filtered out) ✅ Consistency (every user must have role) Execution Timeline (Per Slice): - GOV: 1-2 hours ✅ COMPLETE - DATA: 2-3 hours ✅ COMPLETE - DOMAIN: 2-3 hours ✅ COMPLETE - BE: 3-4 hours (next) - ASYNC: 2-3 hours - FE: 3-4 hours - TESTOPS: 2-3 hours Total VS-01: ~18-22 hours (wall-clock ~3 days) Phase 2 Status: - Batch 1: 3/14 components COMPLETE (VS-01: 3/7, VS-02: 0/7) - Batch 2-3: 🔜 Queued (after Batch 1 deps satisfied) - 56 items total, 8 parallel batches AGENTS.md v16.0 Compliance: ✅ Necessity: User goal/non-goal/acceptance criteria specified ✅ Pattern: Vertical Slice (GOV → DATA → DOMAIN → BE → ASYNC → FE → TESTOPS) ✅ Traceability: VS-01 specs linked to Phase 2 plan ✅ Safety: Pure logic tests (no side effects) ✅ Maturity: Contracts before implementation Next: VS-01 BE (API/Handler/SQL) OR continue parallel VS-02 Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
449 lines
13 KiB
C#
449 lines
13 KiB
C#
using Xunit;
|
|
|
|
namespace KArtSell.ModelOperations.UnitTests;
|
|
|
|
/// <summary>
|
|
/// VS-01: Identity and Roles - Domain Policy Tests
|
|
/// Pure logic validation (no database, no infrastructure)
|
|
/// Covers: Role hierarchy, permission grant, email validation
|
|
/// </summary>
|
|
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<ArgumentException>(() => 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<string, string[]> 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<string> 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));
|
|
}
|