diff --git a/frontend/src/features/models/composables/__tests__/useModelListLogic.spec.ts b/frontend/src/features/models/composables/__tests__/useModelListLogic.spec.ts new file mode 100644 index 00000000..8203f248 --- /dev/null +++ b/frontend/src/features/models/composables/__tests__/useModelListLogic.spec.ts @@ -0,0 +1,131 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { useModelListLogic, formatDate, formatPercentage } from '../useModelListLogic' + +describe('useModelListLogic', () => { + let logic: ReturnType + + beforeEach(() => { + logic = useModelListLogic() + }) + + describe('initialization', () => { + it('should initialize with default state', () => { + expect(logic.models.value).toHaveLength(3) + expect(logic.selectedModelId.value).toBe('1') + expect(logic.filters.search).toBe('') + expect(logic.filters.phase).toBe('') + }) + + it('should set screen state to LOADING on mount', () => { + expect(logic.screenState.value).toBe('LOADING') + }) + }) + + describe('filtering', () => { + it('should filter models by search term', () => { + logic.filters.search = 'Alpha' + expect(logic.filteredModels.value).toHaveLength(1) + expect(logic.filteredModels.value[0].name).toBe('Hawkeye-Alpha') + }) + + it('should filter models by phase', () => { + logic.filters.phase = 'Mature' + expect(logic.filteredModels.value).toHaveLength(1) + expect(logic.filteredModels.value[0].phase).toBe('Mature') + }) + + it('should filter by both search and phase', () => { + logic.filters.search = 'Hawk' + logic.filters.phase = 'Validate' + expect(logic.filteredModels.value).toHaveLength(1) + }) + + it('should return all models when filters are empty', () => { + expect(logic.filteredModels.value).toHaveLength(3) + }) + + it('should be case-insensitive for search', () => { + logic.filters.search = 'alpha' + expect(logic.filteredModels.value).toHaveLength(1) + }) + }) + + describe('model selection', () => { + it('should select model by id', () => { + logic.selectModel('2') + expect(logic.selectedModelId.value).toBe('2') + }) + + it('should return selected model', () => { + logic.selectModel('3') + expect(logic.selectedModel.value?.name).toBe('Gamma Arbitrage') + }) + + it('should return undefined for invalid model id', () => { + logic.selectModel('invalid') + expect(logic.selectedModel.value).toBeUndefined() + }) + }) + + describe('search handling', () => { + it('should set isSearching flag', async () => { + expect(logic.isSearching.value).toBe(false) + const searchPromise = logic.handleSearch() + expect(logic.isSearching.value).toBe(true) + await searchPromise + expect(logic.isSearching.value).toBe(false) + }) + + it('should set screen state to LOADING during search', async () => { + expect(logic.screenState.value).not.toBe('LOADING') + const searchPromise = logic.handleSearch() + expect(logic.screenState.value).toBe('LOADING') + await searchPromise + expect(logic.screenState.value).toBe('READY') + }) + }) + + describe('retry handling', () => { + it('should set screen state to LOADING on retry', async () => { + logic.screenState.value = 'ERROR' + const retryPromise = logic.handleRetry() + expect(logic.screenState.value).toBe('LOADING') + await retryPromise + expect(logic.screenState.value).toBe('READY') + }) + }) +}) + +describe('formatters', () => { + describe('formatDate', () => { + it('should format date to ko-KR locale', () => { + const date = '2026-06-15' + const result = formatDate(date) + expect(result).toMatch(/2026.*06.*15/) + }) + + it('should handle ISO date strings', () => { + const date = '2026-06-15T12:30:00Z' + const result = formatDate(date) + expect(result).toMatch(/2026.*06.*15/) + }) + }) + + describe('formatPercentage', () => { + it('should format number as percentage', () => { + expect(formatPercentage(15.2)).toBe('15.20%') + }) + + it('should handle zero', () => { + expect(formatPercentage(0)).toBe('0.00%') + }) + + it('should handle decimal values', () => { + expect(formatPercentage(98.123)).toBe('98.12%') + }) + + it('should handle undefined/null as 0', () => { + expect(formatPercentage(null as any)).toBe('0.00%') + }) + }) +}) diff --git a/frontend/src/features/models/composables/useModelListLogic.ts b/frontend/src/features/models/composables/useModelListLogic.ts new file mode 100644 index 00000000..2d1ebadb --- /dev/null +++ b/frontend/src/features/models/composables/useModelListLogic.ts @@ -0,0 +1,170 @@ +import { computed, reactive, ref, onMounted } from 'vue' +import type { StandardScreenState } from '../../../shared/ui/contracts/screenContract' + +export interface Model { + modelId: string + name: string + phase: string + active: boolean + pbo: number + dsr: number + returnMtd: number + createdAt: string +} + +export interface ModelFilters { + search: string + phase: string +} + +/** + * useModelListLogic - Encapsulates all business logic for ModelList + * Extracted from God Component for testability and reusability + */ +export function useModelListLogic() { + // Screen state management + const screenState = ref('READY') + const evidence = reactive({ + asOf: new Date().toISOString(), + version: 'v60-T04-Contract', + }) + + // Mock data (replace with API call) + const mockModels: Model[] = [ + { + modelId: '1', + name: 'Hawkeye-Alpha', + phase: 'Validate', + active: false, + pbo: 15.2, + dsr: 96.5, + returnMtd: 12.5, + createdAt: '2026-06-15', + }, + { + modelId: '2', + name: 'Falcon-Beta', + phase: 'Review', + active: false, + pbo: 18.3, + dsr: 94.2, + returnMtd: 8.3, + createdAt: '2026-07-01', + }, + { + modelId: '3', + name: 'Gamma Arbitrage', + phase: 'Mature', + active: true, + pbo: 8.5, + dsr: 98.1, + returnMtd: 18.7, + createdAt: '2026-05-10', + }, + ] + + // Models data + const models = ref(mockModels) + const selectedModelId = ref(mockModels[0]?.modelId ?? '') + + // Filters + const filters = reactive({ + search: '', + phase: '', + }) + + // UI state + const isSearching = ref(false) + + // Computed properties + const filteredModels = computed(() => { + return models.value.filter(m => { + const matchesSearch = m.name.toLowerCase().includes(filters.search.toLowerCase()) + const matchesPhase = !filters.phase || m.phase === filters.phase + return matchesSearch && matchesPhase + }) + }) + + const selectedModel = computed(() => + models.value.find(m => m.modelId === selectedModelId.value) + ) + + // Methods + const selectModel = (id: string) => { + selectedModelId.value = id + } + + const handleSearch = async () => { + isSearching.value = true + screenState.value = 'LOADING' + try { + // Simulate API call delay + await new Promise(resolve => setTimeout(resolve, 300)) + screenState.value = 'READY' + } finally { + isSearching.value = false + } + } + + const handleRetry = async () => { + screenState.value = 'LOADING' + try { + // Simulate retry delay + await new Promise(resolve => setTimeout(resolve, 400)) + screenState.value = 'READY' + } catch { + screenState.value = 'ERROR' + } + } + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'F3') { + e.preventDefault() + handleSearch() + } + } + + // Initialization + onMounted(() => { + screenState.value = 'LOADING' + setTimeout(() => { + screenState.value = 'READY' + }, 500) + window.addEventListener('keydown', handleKeyDown) + }) + + // Return public API + return { + // State + screenState, + evidence, + models, + selectedModelId, + filters, + isSearching, + + // Computed + filteredModels, + selectedModel, + + // Methods + selectModel, + handleSearch, + handleRetry, + } +} + +/** + * Format utilities (can be extracted to separate formatter.ts) + */ +export function formatDate(dateString: string): string { + return new Date(dateString).toLocaleDateString('ko-KR', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + }) +} + +export function formatPercentage(value: number): string { + return (value || 0).toFixed(2) + '%' +} diff --git a/src/KArtSell.Host/Security/RoleConstants.cs b/src/KArtSell.Host/Security/RoleConstants.cs new file mode 100644 index 00000000..cd3412d3 --- /dev/null +++ b/src/KArtSell.Host/Security/RoleConstants.cs @@ -0,0 +1,38 @@ +namespace KArtSell.Host.Security; + +/// +/// Role-Based Access Control (RBAC) constants +/// Used in JWT claims and endpoint authorization +/// +public static class RoleConstants +{ + /// + /// System administrator - full access to all operations + /// + public const string Admin = "Admin"; + + /// + /// Security officer - access to security/audit operations + /// + public const string SecurityOfficer = "SecurityOfficer"; + + /// + /// Standard user - access to general operations + /// + public const string User = "User"; + + /// + /// Read-only access - view operations only + /// + public const string Viewer = "Viewer"; + + /// + /// All valid roles array (for validation/seeding) + /// + public static readonly string[] AllRoles = [Admin, SecurityOfficer, User, Viewer]; + + /// + /// Roles with audit log access + /// + public static readonly string[] AuditAccessRoles = [Admin, SecurityOfficer]; +} diff --git a/tests/KArtSell.Host.UnitTests/Security/RoleBasedAccessControlTests.cs b/tests/KArtSell.Host.UnitTests/Security/RoleBasedAccessControlTests.cs new file mode 100644 index 00000000..be43de6d --- /dev/null +++ b/tests/KArtSell.Host.UnitTests/Security/RoleBasedAccessControlTests.cs @@ -0,0 +1,110 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using KArtSell.Host.Security; +using Microsoft.IdentityModel.Tokens; +using Xunit; + +namespace KArtSell.Host.UnitTests.Security; + +public sealed class RoleBasedAccessControlTests +{ + private const string TestJwtKey = "test-secret-key-with-minimum-256-bits-length-requirement"; + private const string TestIssuer = "test-issuer"; + private const string TestAudience = "test-audience"; + + [Theory] + [InlineData(RoleConstants.Admin)] + [InlineData(RoleConstants.SecurityOfficer)] + [InlineData(RoleConstants.User)] + [InlineData(RoleConstants.Viewer)] + public void GenerateToken_WithValidRole_CreatesClaim(string role) + { + // Arrange + var username = "testuser"; + + // Act + var token = GenerateTestToken(username, role); + var handler = new JwtSecurityTokenHandler(); + var jwtToken = handler.ReadToken(token) as JwtSecurityToken; + + // Assert + var roleClaim = jwtToken?.Claims.FirstOrDefault(c => c.Type == ClaimTypes.Role); + Assert.NotNull(roleClaim); + Assert.Equal(role, roleClaim!.Value); + } + + [Fact] + public void RoleConstants_ContainsAllExpectedRoles() + { + // Act & Assert + Assert.Contains(RoleConstants.Admin, RoleConstants.AllRoles); + Assert.Contains(RoleConstants.SecurityOfficer, RoleConstants.AllRoles); + Assert.Contains(RoleConstants.User, RoleConstants.AllRoles); + Assert.Contains(RoleConstants.Viewer, RoleConstants.AllRoles); + Assert.Equal(4, RoleConstants.AllRoles.Length); + } + + [Fact] + public void AuditAccessRoles_IncludesAdminAndSecurityOfficer() + { + // Act & Assert + Assert.Contains(RoleConstants.Admin, RoleConstants.AuditAccessRoles); + Assert.Contains(RoleConstants.SecurityOfficer, RoleConstants.AuditAccessRoles); + Assert.Equal(2, RoleConstants.AuditAccessRoles.Length); + } + + [Theory] + [InlineData(RoleConstants.Admin, true)] + [InlineData(RoleConstants.SecurityOfficer, true)] + [InlineData(RoleConstants.User, false)] + [InlineData(RoleConstants.Viewer, false)] + public void IsAuditAccessAllowed_ChecksRoleMembership(string role, bool expectedAccess) + { + // Act + var hasAccess = RoleConstants.AuditAccessRoles.Contains(role); + + // Assert + Assert.Equal(expectedAccess, hasAccess); + } + + [Fact] + public void ExtractRoleFromToken_ReturnsCorrectRole() + { + // Arrange + var username = "testuser"; + var expectedRole = RoleConstants.Admin; + var token = GenerateTestToken(username, expectedRole); + + // Act + var handler = new JwtSecurityTokenHandler(); + var jwtToken = handler.ReadToken(token) as JwtSecurityToken; + var actualRole = jwtToken?.Claims.FirstOrDefault(c => c.Type == ClaimTypes.Role)?.Value; + + // Assert + Assert.Equal(expectedRole, actualRole); + } + + private static string GenerateTestToken(string username, string role) + { + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(TestJwtKey)); + var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + + var claims = new[] + { + new Claim(ClaimTypes.NameIdentifier, username), + new Claim(ClaimTypes.Name, username), + new Claim(ClaimTypes.Role, role), + new Claim("auth_mode", "jwt") + }; + + var token = new JwtSecurityToken( + issuer: TestIssuer, + audience: TestAudience, + claims: claims, + expires: DateTime.UtcNow.AddMinutes(60), + signingCredentials: credentials); + + return new JwtSecurityTokenHandler().WriteToken(token); + } +}