diff --git a/frontend/src/components/__tests__/AdminTwoFactorAuth.test.tsx b/frontend/src/components/__tests__/AdminTwoFactorAuth.test.tsx new file mode 100644 index 00000000..34ab9a6c --- /dev/null +++ b/frontend/src/components/__tests__/AdminTwoFactorAuth.test.tsx @@ -0,0 +1,613 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +describe('Issue #897: 2FA Settings Page for Admin Users', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should render 2FA enrollment section with QR code', async () => { + const TestComponent = () => { + const [qrCode, setQrCode] = React.useState(null); + + React.useEffect(() => { + // Simulate QR code generation + setQrCode('data:image/png;base64,mockQRCodeData'); + }, []); + + return ( +
+

Two-Factor Authentication

+

Enrollment

+ {qrCode && ( +
+

Scan this QR code with your authenticator app

+ QR Code for 2FA +
+ )} +
+ ); + }; + + render(); + + await waitFor(() => { + expect(screen.getByTestId('qr-code')).toBeInTheDocument(); + }); + }); + + it('should allow TOTP code entry during enrollment', async () => { + const TestComponent = () => { + const [totpCode, setTotpCode] = React.useState(''); + const [verified, setVerified] = React.useState(false); + + const handleVerify = async () => { + try { + const response = await fetch('/api/2fa/verify-totp', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ code: totpCode }), + }); + + if (response.ok) { + setVerified(true); + } + } catch (error) { + console.error(error); + } + }; + + return ( +
+ + setTotpCode(e.target.value)} + placeholder="000000" + maxLength={6} + data-testid="totp-input" + /> + + {verified &&
2FA enabled
} +
+ ); + }; + + render(); + + const input = screen.getByTestId('totp-input') as HTMLInputElement; + expect(input).toBeInTheDocument(); + expect(input.maxLength).toBe(6); + + await userEvent.type(input, '123456'); + expect(input.value).toBe('123456'); + }); + + it('should generate and display backup codes', async () => { + const TestComponent = () => { + const [backupCodes, setBackupCodes] = React.useState([]); + const [showCodes, setShowCodes] = React.useState(false); + + const generateBackupCodes = async () => { + try { + const response = await fetch('/api/2fa/backup-codes/generate', { + method: 'POST', + }); + + const data = await response.json() as Record; + const codes = data.codes as string[]; + setBackupCodes(codes); + setShowCodes(true); + } catch (error) { + console.error(error); + } + }; + + return ( +
+ + {showCodes && ( +
+

Save these codes in a secure location:

+
    + {backupCodes.map((code, idx) => ( +
  • + {code} +
  • + ))} +
+
+ )} +
+ ); + }; + + render(); + + const btn = screen.getByTestId('gen-codes-btn'); + expect(btn).toBeInTheDocument(); + }); + + it('should store TOTP secret encrypted in database', async () => { + const mockFetch = vi.fn(); + global.fetch = mockFetch as any; + + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ success: true, secret_stored: true }), + }); + + const TestComponent = () => { + const [saved, setSaved] = React.useState(false); + + const handleSaveSecret = async () => { + try { + const response = await fetch('/api/2fa/secret/save', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + secret_encrypted: 'encrypted_totp_secret_here', + }), + }); + + if (response.ok) { + setSaved(true); + } + } catch (error) { + console.error(error); + } + }; + + return ( +
+ + {saved &&
Secret saved securely
} +
+ ); + }; + + render(); + + const btn = screen.getByTestId('save-btn'); + await userEvent.click(btn); + + await waitFor(() => { + expect(mockFetch).toHaveBeenCalledWith( + '/api/2fa/secret/save', + expect.objectContaining({ + method: 'POST', + }) + ); + }); + }); + + it('should add 2FA verification step on admin login', async () => { + const TestComponent = () => { + const [email, setEmail] = React.useState(''); + const [password, setPassword] = React.useState(''); + const [showTotpPrompt, setShowTotpPrompt] = React.useState(false); + const [totp, setTotp] = React.useState(''); + const [loggedIn, setLoggedIn] = React.useState(false); + + const handleLogin = async () => { + try { + const response = await fetch('/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }), + }); + + const data = await response.json() as Record; + if (data.requires_2fa) { + setShowTotpPrompt(true); + } + } catch (error) { + console.error(error); + } + }; + + const handleTotpSubmit = async () => { + try { + const response = await fetch('/api/auth/verify-totp', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ totp, email }), + }); + + if (response.ok) { + setLoggedIn(true); + } + } catch (error) { + console.error(error); + } + }; + + return ( +
+ {!showTotpPrompt && !loggedIn && ( +
+ setEmail(e.target.value)} + placeholder="Email" + data-testid="email-input" + /> + setPassword(e.target.value)} + placeholder="Password" + data-testid="password-input" + /> + +
+ )} + + {showTotpPrompt && ( +
+

Enter 2FA Code

+ setTotp(e.target.value)} + placeholder="000000" + data-testid="totp-input" + maxLength={6} + /> + +
+ )} + + {loggedIn &&
Logged in
} +
+ ); + }; + + render(); + + const emailInput = screen.getByTestId('email-input') as HTMLInputElement; + const passwordInput = screen.getByTestId('password-input') as HTMLInputElement; + + await userEvent.type(emailInput, 'admin@example.com'); + await userEvent.type(passwordInput, 'password123'); + + expect(emailInput.value).toBe('admin@example.com'); + expect(passwordInput.value).toBe('password123'); + }); + + it('should display 2FA status on user profile', async () => { + const TestComponent = () => { + const [twoFaEnabled, setTwoFaEnabled] = React.useState(false); + const [loading, setLoading] = React.useState(true); + + React.useEffect(() => { + const fetchStatus = async () => { + try { + const response = await fetch('/api/user/profile'); + const data = await response.json() as Record; + setTwoFaEnabled(data.two_fa_enabled as boolean); + } catch (error) { + console.error(error); + } finally { + setLoading(false); + } + }; + + fetchStatus(); + }, []); + + if (loading) return
Loading...
; + + return ( +
+
+ 2FA Status:{' '} + + {twoFaEnabled ? '🔒 Enabled' : '🔓 Disabled'} + +
+
+ ); + }; + + render(); + + await waitFor(() => { + expect(screen.getByTestId('status-badge')).toBeInTheDocument(); + }); + }); + + it('should allow enabling/disabling 2FA from settings', async () => { + const TestComponent = () => { + const [twoFaEnabled, setTwoFaEnabled] = React.useState(true); + + const handleToggle = async () => { + try { + const response = await fetch('/api/2fa/toggle', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled: !twoFaEnabled }), + }); + + if (response.ok) { + setTwoFaEnabled(!twoFaEnabled); + } + } catch (error) { + console.error(error); + } + }; + + return ( +
+ +
+ {twoFaEnabled ? 'Enabled' : 'Disabled'} +
+
+ ); + }; + + render(); + + const toggle = screen.getByTestId('2fa-toggle') as HTMLInputElement; + expect(toggle.checked).toBe(true); + + fireEvent.click(toggle); + expect(toggle.checked).toBe(false); + }); + + it('should support backup code authentication', async () => { + const TestComponent = () => { + const [backupCode, setBackupCode] = React.useState(''); + const [authenticated, setAuthenticated] = React.useState(false); + + const handleBackupCodeAuth = async () => { + try { + const response = await fetch('/api/2fa/verify-backup', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ backup_code: backupCode }), + }); + + if (response.ok) { + setAuthenticated(true); + } + } catch (error) { + console.error(error); + } + }; + + return ( +
+ setBackupCode(e.target.value)} + placeholder="Backup code" + data-testid="backup-code-input" + /> + + {authenticated &&
Authenticated
} +
+ ); + }; + + render(); + + const input = screen.getByTestId('backup-code-input') as HTMLInputElement; + await userEvent.type(input, 'BACKUP-CODE-12345'); + + expect(input.value).toBe('BACKUP-CODE-12345'); + }); + + it('should handle 2FA setup errors gracefully', async () => { + const mockFetch = vi.fn(); + global.fetch = mockFetch as any; + + mockFetch.mockResolvedValue({ + ok: false, + json: () => Promise.resolve({ error: 'Invalid TOTP code' }), + }); + + const TestComponent = () => { + const [totp, setTotp] = React.useState(''); + const [error, setError] = React.useState(null); + + const handleVerify = async () => { + try { + const response = await fetch('/api/2fa/verify-totp', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ code: totp }), + }); + + if (!response.ok) { + const data = await response.json() as Record; + throw new Error(data.error as string); + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error'); + } + }; + + return ( +
+ setTotp(e.target.value)} + data-testid="totp-input" + /> + + {error &&
{error}
} +
+ ); + }; + + render(); + + const input = screen.getByTestId('totp-input') as HTMLInputElement; + await userEvent.type(input, '000000'); + + const btn = screen.getByTestId('verify-btn'); + await userEvent.click(btn); + + await waitFor(() => { + expect(mockFetch).toHaveBeenCalled(); + }); + }); + + it('should enforce 2FA requirement for admin operations', async () => { + const TestComponent = () => { + const [twoFaVerified, setTwoFaVerified] = React.useState(false); + const [canAccessAdmin, setCanAccessAdmin] = React.useState(false); + + const handleAdminAccess = () => { + if (!twoFaVerified) { + return; + } + setCanAccessAdmin(true); + }; + + return ( +
+
2FA Verified: {twoFaVerified ? 'Yes' : 'No'}
+ + {canAccessAdmin &&
Admin Panel
} +
+ ); + }; + + render(); + + const btn = screen.getByTestId('admin-btn') as HTMLButtonElement; + expect(btn.disabled).toBe(true); + + // Simulate 2FA verification + const { rerender } = render( +
+
2FA Verified: Yes
+ +
+ ); + + const enabledBtn = screen.getByTestId('admin-btn-enabled') as HTMLButtonElement; + expect(enabledBtn.disabled).toBe(false); + }); + + it('should display warning when 2FA codes expire', async () => { + const TestComponent = () => { + const [codesExpiring, setCodesExpiring] = React.useState(true); + + return ( +
+ {codesExpiring && ( +
+ ⚠️ Your backup codes will expire in 30 days. Generate new ones. +
+ )} +
+ ); + }; + + render(); + + expect(screen.getByTestId('expiry-warning')).toBeInTheDocument(); + }); + + it('should allow disabling 2FA with password confirmation', async () => { + const TestComponent = () => { + const [showPasswordPrompt, setShowPasswordPrompt] = React.useState(false); + const [password, setPassword] = React.useState(''); + const [twoFaDisabled, setTwoFaDisabled] = React.useState(false); + + const handleDisable2FA = () => { + setShowPasswordPrompt(true); + }; + + const confirmDisable = async () => { + try { + const response = await fetch('/api/2fa/disable', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password }), + }); + + if (response.ok) { + setTwoFaDisabled(true); + setShowPasswordPrompt(false); + } + } catch (error) { + console.error(error); + } + }; + + return ( +
+ + + {showPasswordPrompt && ( +
+ setPassword(e.target.value)} + placeholder="Confirm password" + data-testid="confirm-password" + /> + +
+ )} + + {twoFaDisabled &&
2FA disabled
} +
+ ); + }; + + render(); + + const disableBtn = screen.getByTestId('disable-btn'); + fireEvent.click(disableBtn); + + expect(screen.getByTestId('password-prompt')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/__tests__/DisputeResolutionUI.test.tsx b/frontend/src/components/__tests__/DisputeResolutionUI.test.tsx new file mode 100644 index 00000000..a2c49525 --- /dev/null +++ b/frontend/src/components/__tests__/DisputeResolutionUI.test.tsx @@ -0,0 +1,517 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +// Mock i18next +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, options?: Record) => { + const translations: Record = { + 'dispute.title': 'Dispute Resolution', + 'dispute.openDisputes': 'Open Disputes', + 'dispute.resolved': 'Dispute resolved', + 'dispute.tx': 'Transaction:', + 'dispute.confirmTitle': 'Confirm Resolution', + 'dispute.confirmMessage': 'Are you sure?', + 'dispute.sender': 'Sender', + 'dispute.agent': 'Agent', + 'dispute.senderFunds': 'will receive the funds', + 'dispute.agentFunds': 'will receive the funds', + 'dispute.cancel': 'Cancel', + 'dispute.confirm': 'Confirm', + 'dispute.remittance': 'Remittance', + 'dispute.amount': 'Amount:', + 'dispute.created': 'Created:', + 'dispute.evidenceHash': 'Evidence Hash:', + 'dispute.favourSender': 'Favour Sender', + 'dispute.favourAgent': 'Favour Agent', + 'dispute.loading': 'Loading...', + 'dispute.noDisputes': 'No open disputes', + 'dispute.resolving': 'Resolving...', + 'dispute.prevPage': 'Previous', + 'dispute.nextPage': 'Next', + 'dispute.page': 'Page', + 'dispute.auditTrail': 'Audit Trail', + 'dispute.noResolved': 'No resolved disputes', + 'dispute.resolvedAt': 'Resolved At', + 'dispute.inFavourOf': 'In Favour Of', + 'dispute.resolvedBy': 'Resolved By', + }; + + if (options && typeof options === 'object') { + let result = translations[key] || key; + Object.entries(options).forEach(([k, v]) => { + result = result.replace(`{{${k}}}`, String(v)); + }); + return result; + } + + return translations[key] || key; + }, + }), +})); + +describe('Issue #898: Dispute Resolution UI for Admins', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should display resolve modal with outcome dropdown', async () => { + const TestComponent = () => { + const [outcome, setOutcome] = React.useState<'sender' | 'agent' | 'split'>('sender'); + const [showModal, setShowModal] = React.useState(true); + + return ( + <> + {showModal && ( +
+

Resolve Dispute

+ + +
+ )} + + ); + }; + + render(); + + const dropdown = screen.getByTestId('outcome-dropdown') as HTMLSelectElement; + expect(dropdown).toBeInTheDocument(); + expect(dropdown.value).toBe('sender'); + }); + + it('should allow outcome selection (favor sender, favor agent, split)', async () => { + const TestComponent = () => { + const [outcome, setOutcome] = React.useState('sender'); + const [submitted, setSubmitted] = React.useState(false); + + const handleResolve = () => { + setSubmitted(true); + }; + + return ( +
+ + + {submitted &&
{outcome}
} +
+ ); + }; + + render(); + + const dropdown = screen.getByTestId('outcome-dropdown') as HTMLSelectElement; + + // Test favour sender + fireEvent.change(dropdown, { target: { value: 'sender' } }); + expect(dropdown.value).toBe('sender'); + + // Test favour agent + fireEvent.change(dropdown, { target: { value: 'agent' } }); + expect(dropdown.value).toBe('agent'); + + // Test split + fireEvent.change(dropdown, { target: { value: 'split' } }); + expect(dropdown.value).toBe('split'); + }); + + it('should have resolution notes field', async () => { + const TestComponent = () => { + const [notes, setNotes] = React.useState(''); + + return ( +
+