diff --git a/docs/contracts/architecture/VS-01_SLICE_SPEC.md b/docs/contracts/architecture/VS-01_SLICE_SPEC.md new file mode 100644 index 00000000..c60547eb --- /dev/null +++ b/docs/contracts/architecture/VS-01_SLICE_SPEC.md @@ -0,0 +1,164 @@ +# VS-01: Manage Identity and Roles - Vertical Slice Specification + +**Slice ID:** VS-01 +**Batch:** 1 (no dependencies) +**Status:** 📋 SPECIFICATION +**Created:** 2026-08-04 + +--- + +## Executive Summary + +Establish centralized **Identity and Role Management (IAM)** system for K-ArtSell platform. + +**User Goal:** Administrators can manage user accounts, roles, and permissions from a single dashboard without manual database operations. + +**Non-Goal:** +- SSO/LDAP integration (Phase 3) +- MFA implementation (Phase 3) +- Audit trail (separate feature) +- Password reset workflow (Phase 3) + +--- + +## Acceptance Criteria + +### 1. User Management ✅ + +- [ ] **Create User:** Endpoint creates new user record with UUID, email, hashed password, roles +- [ ] **Read Users:** Paginated list, filterable by role/status +- [ ] **Update User:** Change email, roles (no password update here) +- [ ] **Soft Delete:** Mark user as inactive (no hard delete) +- [ ] **Validation:** Email unique per environment, password ≥12 chars + +### 2. Role & Permission Model ✅ + +- [ ] **Predefined Roles:** Admin, Analyst, Trader, Viewer (immutable) +- [ ] **Permissions:** Read, Write, Approve, Execute (scoped to domain) +- [ ] **User-Role Mapping:** Many-to-many with assigned_at timestamp +- [ ] **Permission Enforcement:** Checked on every endpoint (via PermissionGuard) + +### 3. Data Integrity ✅ + +- [ ] **PIT Compliance:** created_at (never future), updated_at, published_at (for CDC) +- [ ] **Immutable:** user_id, email_hash cannot change post-creation +- [ ] **Revision Tracking:** Each role change creates new record (append-only) +- [ ] **Schema-Qualified:** All queries use `identity.users`, `identity.roles` + +### 4. API Contracts ✅ + +**Endpoint: POST /api/users** +``` +Request: { email: string, password: string, roles: ["Admin", "Analyst"] } +Response: 201 Created { userId: UUID, email: string, roles: [string] } +Errors: 400 (invalid), 409 (exists), 422 (validation) +Idempotency: IdempotencyKey header +``` + +**Endpoint: GET /api/users?page=1&limit=20&role=Admin** +``` +Response: 200 { items: [User], total: int, page: int, limit: int } +Errors: 401, 403 (insufficient permissions) +``` + +**Endpoint: PATCH /api/users/:id** +``` +Request: { roles: ["Analyst", "Viewer"], status: "active" } +Response: 200 { userId: UUID, roles: [string], updated_at: timestamp } +``` + +### 5. UI/UX Acceptance Criteria ✅ + +- [ ] **User List Page:** Table with columns (Email, Roles, Status, Actions) +- [ ] **Create Dialog:** Form with email + password + role multi-select +- [ ] **Edit Dialog:** Change roles inline +- [ ] **Delete Dialog:** Confirm soft-delete with warning +- [ ] **Accessibility:** ARIA labels, keyboard nav, error messages + +### 6. Security Acceptance Criteria ✅ + +- [ ] **Password Hashing:** bcrypt or argon2, never plaintext +- [ ] **Auth Check:** Every endpoint requires role (no anonymous) +- [ ] **Authorization:** Only Admin can modify users +- [ ] **Audit Logging:** User changes logged with correlationId +- [ ] **No PII in Logs:** Email, password NEVER logged + +--- + +## Failure Modes & Recovery + +### Scenario 1: Duplicate Email + +**Trigger:** POST /api/users with existing email +**Expected:** 409 Conflict { error: "Email already exists" } +**Recovery:** User retries with different email + +### Scenario 2: Invalid Role + +**Trigger:** POST /api/users with role="SuperAdmin" (not in predefined list) +**Expected:** 422 Unprocessable { error: "Invalid role: SuperAdmin" } +**Recovery:** User selects from dropdown of valid roles + +### Scenario 3: Concurrent Role Update + +**Trigger:** 2 admins modify same user's roles simultaneously +**Expected:** Last-write-wins (UPDATE WHERE version = @version, increment version) +**Recovery:** Second request gets 409 Conflict, user retries with fresh data + +--- + +## Success Metrics + +| Metric | Target | Verification | +|--------|--------|--------------| +| Create latency | <200ms | Load test | +| List latency | <500ms (1000 users) | Stress test | +| Auth check latency | <50ms | Endpoint latency trace | +| Test coverage | ≥95% | Code coverage report | +| Uptime | ≥99.9% | Monitoring dashboard | + +--- + +## Dependencies + +### Inbound (Block VS-01) + +- ✅ **VS-00:** Platform foundation (complete) +- ✅ **Authentication:** DevelopmentHeader + FailClosed (Phase 1) + +### Outbound (Unblock) + +- 🔄 **VS-07:** ManageClientIPS (depends on VS-01 for User/Role APIs) +- 🔄 **VS-02~08:** All slices use VS-01's permission model + +--- + +## Component Breakdown (7 items per slice) + +| Component | Owner | Duration | Status | +|-----------|-------|----------|--------| +| **GOV** (this doc) | Architect | 1-2 hrs | 📋 | +| **DATA** | Data Architect | 2-3 hrs | ⏳ Ready | +| **DOMAIN** | Quant Lead | 2-3 hrs | ⏳ Ready | +| **BE** | BE Lead | 3-4 hrs | ⏳ Ready | +| **ASYNC** | SRE | 2-3 hrs | ⏳ Ready | +| **FE** | FE Architect | 3-4 hrs | ⏳ Ready | +| **TESTOPS** | QA Lead | 2-3 hrs | ⏳ Ready | + +**Total Duration:** ~18-22 hours (wall-clock ~3 days) + +--- + +## Sign-Off + +| Role | Name | Status | Date | +|------|------|--------|------| +| Product Owner | User | ⏳ Approval | TBD | +| Architect | Claude Code | ✅ Draft | 2026-08-04 | +| Security | Team | ⏳ Review | TBD | + +--- + +**Status:** 📋 **READY FOR DATA/DOMAIN/BE COMPONENTS** + +Next: VS-01_DATA_CONTRACT.md diff --git a/docs/contracts/data/VS-01_DATA_CONTRACT.md b/docs/contracts/data/VS-01_DATA_CONTRACT.md new file mode 100644 index 00000000..7040e541 --- /dev/null +++ b/docs/contracts/data/VS-01_DATA_CONTRACT.md @@ -0,0 +1,374 @@ +# VS-01: Identity and Roles Data Contract + +**Slice:** VS-01 (ManageIdentityAndRoles) +**Status:** 📋 SPECIFICATION +**Version:** 1.0 +**Created:** 2026-08-04 + +--- + +## Schema (3NF Write Model) + +### identity.users (User Accounts) + +**Purpose:** Immutable user record (append-only, PIT envelope) + +```sql +CREATE TABLE identity.users ( + -- Primary Key + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Business Keys (immutable) + email VARCHAR(255) NOT NULL UNIQUE, + email_hash VARCHAR(64) NOT NULL UNIQUE, -- SHA-256 of email + + -- Authentication (write-once) + password_hash VARCHAR(255) NOT NULL, -- bcrypt, never changed after creation + + -- State + status VARCHAR(20) NOT NULL DEFAULT 'active' + CHECK (status IN ('active', 'inactive', 'suspended')), + + -- Temporal (PIT) + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + published_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + + -- Revision Tracking + revision INT NOT NULL DEFAULT 1, + content_hash VARCHAR(64) NOT NULL, -- SHA-256 of (email, status, updated_at) + + -- Audit + created_by_user_id UUID REFERENCES identity.users(id), + correlation_id VARCHAR(36) NOT NULL, + + -- Indexing + CONSTRAINT email_lowercase CHECK (email = LOWER(email)), + CONSTRAINT valid_email CHECK (email ~ '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}$') +); + +CREATE INDEX idx_users_email ON identity.users(email); +CREATE INDEX idx_users_status ON identity.users(status); +CREATE INDEX idx_users_published_at ON identity.users(published_at); +CREATE INDEX idx_users_created_by ON identity.users(created_by_user_id); +``` + +**Constraints:** +- ✅ email UNIQUE: Only one account per email per environment +- ✅ status IN ('active', 'inactive', 'suspended'): Enum validation +- ✅ published_at ≤ CURRENT_TIMESTAMP: Never future-dated +- ✅ created_at ≤ updated_at: Temporal order + +**PIT (Point-in-Time) Query:** +```sql +SELECT * FROM identity.users +WHERE published_at <= @cutoff + AND status = 'active' +ORDER BY created_at DESC; +``` + +--- + +### identity.roles (Role Definitions) + +**Purpose:** Immutable, predefined roles (reference data) + +```sql +CREATE TABLE identity.roles ( + id SERIAL PRIMARY KEY, + name VARCHAR(50) NOT NULL UNIQUE, + description VARCHAR(255), + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +INSERT INTO identity.roles (name, description) VALUES + ('Admin', 'Full system access'), + ('Analyst', 'Read-only analysis'), + ('Trader', 'Execute trades'), + ('Viewer', 'Dashboard read-only'); + +-- Prevent deletion (immutable reference data) +CREATE TRIGGER prevent_role_deletion +BEFORE DELETE ON identity.roles +FOR EACH ROW +EXECUTE FUNCTION raise_immutability_error(); +``` + +**Constraints:** +- ✅ name UNIQUE: One role per name +- ✅ Immutable: No INSERT/UPDATE/DELETE after initial load +- ✅ Predefined: Only 4 roles (Admin, Analyst, Trader, Viewer) + +--- + +### identity.user_roles (User-Role Assignment) + +**Purpose:** Many-to-many junction table (append-only) + +```sql +CREATE TABLE identity.user_roles ( + id BIGSERIAL PRIMARY KEY, + + -- Foreign Keys + user_id UUID NOT NULL REFERENCES identity.users(id), + role_id INT NOT NULL REFERENCES identity.roles(id), + + -- Temporal + assigned_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + published_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + removed_at TIMESTAMP, -- NULL = still assigned, NOT NULL = removed + + -- Audit + assigned_by_user_id UUID REFERENCES identity.users(id), + correlation_id VARCHAR(36) NOT NULL, + + -- Versioning (for CDC) + revision INT NOT NULL DEFAULT 1, + + -- Constraints + CONSTRAINT active_assignment CHECK (assigned_at <= published_at), + CONSTRAINT valid_removal CHECK (removed_at IS NULL OR removed_at >= assigned_at), + CONSTRAINT unique_active_role UNIQUE (user_id, role_id) WHERE removed_at IS NULL +); + +CREATE INDEX idx_user_roles_user ON identity.user_roles(user_id); +CREATE INDEX idx_user_roles_role ON identity.user_roles(role_id); +CREATE INDEX idx_user_roles_active ON identity.user_roles(user_id, removed_at); +CREATE INDEX idx_user_roles_published ON identity.user_roles(published_at); +``` + +**Constraints:** +- ✅ UNIQUE (user_id, role_id) WHERE removed_at IS NULL: No duplicate active roles +- ✅ assigned_at ≤ published_at: Temporal ordering +- ✅ removed_at IS NULL: Active assignment tracking + +**PIT Query (Get current roles for user):** +```sql +SELECT ur.user_id, r.name AS role +FROM identity.user_roles ur +JOIN identity.roles r ON ur.role_id = r.id +WHERE ur.user_id = @userId + AND ur.published_at <= @cutoff + AND ur.removed_at IS NULL; +``` + +--- + +### identity.user_permissions (Permission Grant) + +**Purpose:** Fine-grained permission model (append-only) + +```sql +CREATE TABLE identity.user_permissions ( + id BIGSERIAL PRIMARY KEY, + + -- Foreign Keys + user_id UUID NOT NULL REFERENCES identity.users(id), + + -- Permission (domain-scoped) + resource VARCHAR(50) NOT NULL, -- e.g., 'users', 'portfolios', 'trades' + action VARCHAR(20) NOT NULL, -- 'read', 'write', 'approve', 'execute' + + -- Temporal + granted_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + published_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + revoked_at TIMESTAMP, -- NULL = active, NOT NULL = revoked + + -- Audit + granted_by_user_id UUID REFERENCES identity.users(id), + correlation_id VARCHAR(36) NOT NULL, + + -- Constraints + CONSTRAINT valid_resource CHECK (resource IN ('users', 'portfolios', 'trades', 'models', 'signals')), + CONSTRAINT valid_action CHECK (action IN ('read', 'write', 'approve', 'execute')), + CONSTRAINT unique_active_permission UNIQUE (user_id, resource, action) WHERE revoked_at IS NULL +); + +CREATE INDEX idx_permissions_user ON identity.user_permissions(user_id); +CREATE INDEX idx_permissions_resource ON identity.user_permissions(resource, action); +``` + +--- + +## Data Integrity Rules + +### Rule 1: Email Immutability +**Constraint:** email CANNOT be updated after creation +**Verification:** +```sql +-- Test: Email update should fail +UPDATE identity.users SET email = 'newemail@example.com' +WHERE id = @userId; +-- Expected: CONSTRAINT VIOLATION (or trigger prevents update) +``` + +### Rule 2: Password Hash Never Logged +**Constraint:** password_hash column exists but NEVER appears in SELECT without WHERE +**Verification:** +```sql +-- Bad (never do this): +SELECT * FROM identity.users; -- ❌ Exposes password_hash + +-- Good (always explicit): +SELECT id, email, status FROM identity.users; -- ✅ No password +``` + +### Rule 3: PIT (Point-in-Time) Queries Must Include Cutoff +**Constraint:** All reads include `WHERE published_at <= @cutoff` +**Verification:** +```sql +-- Correct: +SELECT * FROM identity.users WHERE published_at <= @cutoff AND status = 'active'; + +-- Wrong (time-machine unsafe): +SELECT * FROM identity.users WHERE status = 'active'; -- ❌ No cutoff +``` + +### Rule 4: No Direct Email Mutations +**Constraint:** Email cannot be part of UPDATE statement +**Verification (trigger):** +```sql +CREATE TRIGGER prevent_email_update +BEFORE UPDATE ON identity.users +FOR EACH ROW +WHEN (OLD.email IS DISTINCT FROM NEW.email) +EXECUTE FUNCTION raise_immutability_error('email'); +``` + +### Rule 5: Role Removal via Soft Delete +**Constraint:** Set removed_at timestamp instead of DELETE +**Verification:** +```sql +-- Correct: +UPDATE identity.user_roles SET removed_at = CURRENT_TIMESTAMP +WHERE user_id = @userId AND role_id = @roleId; + +-- Wrong (no DELETE): +DELETE FROM identity.user_roles WHERE user_id = @userId; -- ❌ Banned +``` + +--- + +## Event Contracts (CDC) + +### UserCreated Event + +```json +{ + "eventId": "UUID", + "eventType": "UserCreated", + "userId": "UUID", + "email": "user@example.com", + "roles": ["Admin", "Analyst"], + "createdAt": "2026-08-04T12:00:00Z", + "correlationId": "req-001" +} +``` + +**When:** INSERT into identity.users +**Consumer:** ApprovalQueue (if user requires approval) + +### RoleAssigned Event + +```json +{ + "eventId": "UUID", + "eventType": "RoleAssigned", + "userId": "UUID", + "roleName": "Analyst", + "assignedAt": "2026-08-04T12:00:00Z", + "correlationId": "req-001" +} +``` + +**When:** INSERT into identity.user_roles with removed_at IS NULL +**Consumer:** PermissionCache (invalidate user's permission set) + +### RoleRevoked Event + +```json +{ + "eventId": "UUID", + "eventType": "RoleRevoked", + "userId": "UUID", + "roleName": "Analyst", + "revokedAt": "2026-08-04T12:00:00Z", + "correlationId": "req-001" +} +``` + +**When:** UPDATE identity.user_roles SET removed_at = now() +**Consumer:** PermissionCache (invalidate user's permission set) + +--- + +## Idempotency & Replay Safety + +### Create User Idempotency + +**Input:** IdempotencyKey = `create-user-alice-20260804` +**First Run:** +```sql +INSERT INTO identity.users (email, password_hash, correlation_id) +VALUES ('alice@example.com', 'bcrypt(...)', 'req-001') +RETURNING id; +-- Result: UUID = 12345678-1234-1234-1234-123456789012 +``` + +**Replay (same IdempotencyKey):** +```sql +-- Check if already created +SELECT id FROM identity.users WHERE email = 'alice@example.com'; +-- Result: 12345678-1234-1234-1234-123456789012 (same) +-- Action: Return existing record (no duplicate INSERT) +``` + +### Assign Role Idempotency + +**Input:** IdempotencyKey = `assign-alice-analyst-20260804` +**First Run:** +```sql +INSERT INTO identity.user_roles (user_id, role_id, assigned_by_user_id) +VALUES (uuid-alice, 2, admin-user-id) +RETURNING id; +-- Result: ID = 1001 +``` + +**Replay:** +```sql +-- Check if already assigned +SELECT id FROM identity.user_roles +WHERE user_id = uuid-alice AND role_id = 2 AND removed_at IS NULL; +-- Result: 1001 (same) +-- Action: Return existing record (no duplicate) +``` + +--- + +## Acceptance Criteria Checklist + +- [ ] All tables created with 3NF normalization +- [ ] PIT queries tested (published_at ≤ cutoff) +- [ ] Append-only verified (no direct UPDATE on business keys) +- [ ] Immutability enforced (email, roles) +- [ ] Soft-delete working (removed_at pattern) +- [ ] Idempotency verified (replay tests passing) +- [ ] CDC events defined (UserCreated, RoleAssigned, RoleRevoked) +- [ ] Indexes created for performance +- [ ] Constraints enforced (CHECK, UNIQUE, FK) + +--- + +## Sign-Off + +| Role | Approval | Date | +|------|----------|------| +| Data Architect | ✅ Draft | 2026-08-04 | +| DBA | ⏳ Review | TBD | +| Security | ⏳ Review | TBD | + +--- + +**Status:** 📋 **READY FOR DOMAIN TESTS & BE IMPLEMENTATION** + +Next: DomainPolicyTests (identity rules validation) diff --git a/tests/KArtSell.ModelOperations.UnitTests/VS01_IdentityPolicyTests.cs b/tests/KArtSell.ModelOperations.UnitTests/VS01_IdentityPolicyTests.cs new file mode 100644 index 00000000..d85b0f64 --- /dev/null +++ b/tests/KArtSell.ModelOperations.UnitTests/VS01_IdentityPolicyTests.cs @@ -0,0 +1,448 @@ +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)); +}