|
| 1 | +import { render, fireEvent, waitFor, screen } from '@testing-library/preact'; |
| 2 | +import { describe, it, expect, vi, beforeEach } from 'vitest'; |
| 3 | + |
| 4 | +// Mock utils before importing component to avoid side effects |
| 5 | +vi.mock('../../utils', () => ({ |
| 6 | + fetchClient: { POST: vi.fn(), GET: vi.fn() }, |
| 7 | + get_salt_for_user: vi.fn(), |
| 8 | + generate_hash: vi.fn(), |
| 9 | + storeSecretKeyInServiceWorker: vi.fn(), |
| 10 | + AppState: { Loading: 0, LoggedIn: 1, LoggedOut: 2, Recovery: 3 }, |
| 11 | + loggedIn: { value: 0 }, |
| 12 | + bc: { postMessage: vi.fn() }, |
| 13 | +})); |
| 14 | + |
| 15 | +// Subpath mock for Form used as default import replicating global test-setup structure |
| 16 | +vi.mock('react-bootstrap/Form', () => { |
| 17 | + const { h } = require('preact'); |
| 18 | + const Form = ({ children, onSubmit }: { children?: unknown; onSubmit?: (e: Event) => void }) => h('form', { onSubmit }, children); |
| 19 | + Form.Group = ({ children, controlId }: { children?: any; controlId?: string }) => { |
| 20 | + if (children && Array.isArray(children)) { |
| 21 | + children = children.map((child) => { |
| 22 | + if (typeof child !== 'object') return child; |
| 23 | + child.props = { ...child.props, controlId }; |
| 24 | + return child; |
| 25 | + }); |
| 26 | + } |
| 27 | + return h('div', {}, children); |
| 28 | + }; |
| 29 | + Form.Label = ({ children, controlId }: { children?: unknown; controlId?: string }) => h('label', { htmlFor: controlId }, children); |
| 30 | + Form.Control = ({ type, value, onChange, isInvalid, controlId }: { type: string; value?: string; onChange?: (e: Event) => void; isInvalid?: boolean; controlId?: string }) => |
| 31 | + h('input', { id: controlId, type, value, onChange, 'data-testid': `${type}-input`, className: isInvalid ? 'invalid' : '' }); |
| 32 | + return { default: Form }; |
| 33 | +}); |
| 34 | + |
| 35 | +import { Login } from '../Login'; |
| 36 | + |
| 37 | +// Mock Alert directly to capture calls (in addition to global test-setup mock) |
| 38 | +vi.mock('../Alert', () => ({ |
| 39 | + showAlert: vi.fn(), |
| 40 | +})); |
| 41 | + |
| 42 | +// i18n mock |
| 43 | +vi.mock('react-i18next', () => ({ |
| 44 | + useTranslation: () => ({ |
| 45 | + t: (key: string) => key, |
| 46 | + }), |
| 47 | +})); |
| 48 | + |
| 49 | + |
| 50 | +describe('Login Component', () => { |
| 51 | + // eslint-disable-next-line @typescript-eslint/no-explicit-any |
| 52 | + let mockUtils: any; |
| 53 | + // eslint-disable-next-line @typescript-eslint/no-explicit-any |
| 54 | + let mockAlert: any; |
| 55 | + // eslint-disable-next-line @typescript-eslint/no-explicit-any |
| 56 | + let mockBase64: any; |
| 57 | + |
| 58 | + beforeEach(async () => { |
| 59 | + vi.clearAllMocks(); |
| 60 | + mockUtils = await import('../../utils'); |
| 61 | + mockAlert = await import('../Alert'); |
| 62 | + mockBase64 = await import('js-base64'); |
| 63 | + |
| 64 | + // default happy path mocks |
| 65 | + mockUtils.get_salt_for_user.mockResolvedValue(new Uint8Array([1, 2, 3])); |
| 66 | + mockUtils.generate_hash.mockResolvedValue(new Uint8Array([9, 10, 11])); |
| 67 | + mockUtils.fetchClient.POST.mockResolvedValue({ response: { status: 200 }, error: null }); |
| 68 | + mockUtils.fetchClient.GET.mockResolvedValue({ data: { secret_salt: [5, 6, 7] }, response: { status: 200 }, error: null }); |
| 69 | + }); |
| 70 | + |
| 71 | + function fillAndSubmit(email = '[email protected]', password = 'ValidPass123!') { |
| 72 | + render(<Login />); |
| 73 | + const emailInput = screen.getByRole('textbox', { name: 'email' }); |
| 74 | + const passwordInput = screen.getByRole('textbox', { name: 'password' }); |
| 75 | + fireEvent.change(emailInput, { target: { value: email } }); |
| 76 | + fireEvent.change(passwordInput, { target: { value: password } }); |
| 77 | + fireEvent.click(screen.getByRole('button', { name: 'login' })); |
| 78 | + } |
| 79 | + |
| 80 | + it('renders form fields', () => { |
| 81 | + render(<Login />); |
| 82 | + expect(screen.getByRole('textbox', { name: 'email' })).toBeTruthy(); |
| 83 | + expect(screen.getByRole('textbox', { name: 'password' })).toBeTruthy(); |
| 84 | + expect(screen.getByRole('button', { name: 'login' })).toBeTruthy(); |
| 85 | + }); |
| 86 | + |
| 87 | + it('sets credentials_wrong when get_salt_for_user fails', async () => { |
| 88 | + mockUtils.get_salt_for_user.mockRejectedValue('no user'); |
| 89 | + fillAndSubmit(); |
| 90 | + |
| 91 | + await waitFor(() => { |
| 92 | + expect(mockUtils.fetchClient.POST).not.toHaveBeenCalled(); |
| 93 | + expect(screen.getByRole('textbox', { name: 'email' })).toHaveClass('invalid'); |
| 94 | + }); |
| 95 | + }); |
| 96 | + |
| 97 | + it('shows verification required alert on 403 login', async () => { |
| 98 | + mockUtils.fetchClient.POST.mockResolvedValue({ response: { status: 403 }, error: null }); |
| 99 | + fillAndSubmit(); |
| 100 | + |
| 101 | + await waitFor(() => { |
| 102 | + expect(mockAlert.showAlert).toHaveBeenCalledWith('login.verify_before_login', 'danger', 'login', 'login.verify_before_login_heading'); |
| 103 | + expect(mockUtils.fetchClient.GET).not.toHaveBeenCalled(); |
| 104 | + }); |
| 105 | + }); |
| 106 | + |
| 107 | + it('marks credentials wrong when POST returns error', async () => { |
| 108 | + mockUtils.fetchClient.POST.mockResolvedValue({ response: { status: 500 }, error: 'err' }); |
| 109 | + fillAndSubmit(); |
| 110 | + |
| 111 | + await waitFor(() => { |
| 112 | + expect(screen.getByRole('textbox', { name: 'email' })).toHaveClass('invalid'); |
| 113 | + }); |
| 114 | + }); |
| 115 | + |
| 116 | + it('alerts when secret retrieval fails (non-200)', async () => { |
| 117 | + mockUtils.fetchClient.GET.mockResolvedValue({ data: null, response: { status: 500 }, error: 'boom' }); |
| 118 | + fillAndSubmit(); |
| 119 | + |
| 120 | + await waitFor(() => { |
| 121 | + expect(mockAlert.showAlert).toHaveBeenCalledWith('Failed with status 500: boom', 'danger'); |
| 122 | + }); |
| 123 | + }); |
| 124 | + |
| 125 | + it('successful login flow stores key, updates state and posts broadcast', async () => { |
| 126 | + fillAndSubmit(); |
| 127 | + |
| 128 | + await waitFor(() => { |
| 129 | + expect(window.localStorage.setItem).toHaveBeenCalledWith('loginSalt', 'encoded'); |
| 130 | + expect(mockUtils.generate_hash).toHaveBeenCalledTimes(2); |
| 131 | + expect(mockUtils.storeSecretKeyInServiceWorker).toHaveBeenCalledWith('encoded'); |
| 132 | + expect(mockUtils.loggedIn.value).toBe(mockUtils.AppState.LoggedIn); |
| 133 | + expect(mockUtils.bc.postMessage).toHaveBeenCalledWith('login'); |
| 134 | + }); |
| 135 | + }); |
| 136 | + |
| 137 | + it('opens and submits password recovery modal success', async () => { |
| 138 | + render(<Login />); |
| 139 | + fireEvent.click(screen.getByText('password_recovery')); |
| 140 | + expect(screen.getByTestId('modal')).toBeTruthy(); |
| 141 | + |
| 142 | + mockUtils.fetchClient.GET.mockResolvedValueOnce({ response: { status: 200 } }); |
| 143 | + const emailInput = screen.getAllByRole('textbox', { name: 'email' }).find(i => (i as HTMLInputElement).id === 'startRecoveryEmail')!; |
| 144 | + fireEvent.change(emailInput, { target: { value: '[email protected]' } }); |
| 145 | + |
| 146 | + const sendBtn = screen.getByRole('button', { name: 'send' }); |
| 147 | + fireEvent.click(sendBtn); |
| 148 | + |
| 149 | + await waitFor(() => { |
| 150 | + expect(mockAlert.showAlert).toHaveBeenCalledWith('success_alert_text', 'success', 'login', 'success_alert_heading'); |
| 151 | + }); |
| 152 | + }); |
| 153 | + |
| 154 | + it('password recovery handles error', async () => { |
| 155 | + render(<Login />); |
| 156 | + fireEvent.click(screen.getByText('password_recovery')); |
| 157 | + expect(screen.getByTestId('modal')).toBeTruthy(); |
| 158 | + |
| 159 | + mockUtils.fetchClient.GET.mockResolvedValueOnce({ response: { status: 500 }, error: 'fail' }); |
| 160 | + const emailInput = screen.getAllByRole('textbox', { name: 'email' }).find(i => (i as HTMLInputElement).id === 'startRecoveryEmail')!; |
| 161 | + fireEvent.change(emailInput, { target: { value: '[email protected]' } }); |
| 162 | + |
| 163 | + const sendBtn = screen.getByRole('button', { name: 'send' }); |
| 164 | + fireEvent.click(sendBtn); |
| 165 | + |
| 166 | + await waitFor(() => { |
| 167 | + expect(mockAlert.showAlert).toHaveBeenCalled(); |
| 168 | + }); |
| 169 | + }); |
| 170 | +}); |
0 commit comments