Skip to content

Commit 130bfa1

Browse files
committed
frontend: add tests for login component
1 parent f9c55d9 commit 130bfa1

File tree

2 files changed

+186
-2
lines changed

2 files changed

+186
-2
lines changed
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
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+
});

frontend/src/test-setup.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -261,7 +261,7 @@ vi.mock('react-bootstrap/Alert', () => {
261261
dismissible?: boolean;
262262
onClose?: () => void;
263263
}
264-
264+
265265
interface AlertComponent {
266266
(props: MockAlertProps): ReturnType<typeof h>;
267267
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -278,6 +278,14 @@ vi.mock('react-bootstrap/Alert', () => {
278278
return { default: Alert };
279279
});
280280

281+
// Direct import path mock for Button (components import from 'react-bootstrap/Button')
282+
vi.mock('react-bootstrap/Button', () => {
283+
return {
284+
default: ({ children, type, disabled, onClick }: { children?: ComponentChildren; type?: string; disabled?: boolean; onClick?: () => void }) =>
285+
h('button', { type, disabled, onClick, 'data-testid': 'submit-button' }, children),
286+
};
287+
});
288+
281289
// Mock react-feather icons
282290
vi.mock('react-feather', () => ({
283291
ChevronDown: () => h('svg', { 'data-testid': 'chevron-down' }),
@@ -329,7 +337,8 @@ vi.mock('base58', () => ({
329337
vi.mock('js-base64', () => ({
330338
Base64: {
331339
toUint8Array: vi.fn(),
332-
fromUint8Array: vi.fn(),
340+
// Provide deterministic return; tests can override with mockReturnValueOnce
341+
fromUint8Array: vi.fn(() => 'encoded'),
333342
},
334343
}));
335344

@@ -350,6 +359,11 @@ vi.mock('./utils', () => ({
350359
get_salt: vi.fn(),
351360
get_salt_for_user: vi.fn(),
352361
concat_salts: vi.fn(),
362+
// App/login related state & helpers for Login component tests
363+
AppState: { Loading: 0, LoggedIn: 1, LoggedOut: 2, Recovery: 3 },
364+
loggedIn: { value: 0 },
365+
storeSecretKeyInServiceWorker: vi.fn(),
366+
bc: { postMessage: vi.fn() },
353367
isDebugMode: { value: false },
354368
}));
355369

0 commit comments

Comments
 (0)