Files
glchat/web/tests/login.test.tsx
T

185 lines
7.5 KiB
TypeScript
Raw Normal View History

import { describe, expect, it } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import {
apiError,
findRequest,
installFetch,
json,
makeUser,
recordedRequests,
renderApp,
} from './helpers';
const instanceOpen = {
name: 'glchat-test',
version: 'v0.1.0-test',
registration_enabled: true,
allow_guild_creation: true,
max_guilds_per_user: 5,
max_members_per_guild: 100,
max_message_length: 2000,
main_guild_id: 'g-main',
user_count: 1,
guild_count: 1,
};
const totpLabel = 'Код 2FA или резервный код';
/** Ответы для входа: логин и оболочка приложения. */
function loginRoutes(extra: Parameters<typeof installFetch>[0] = []) {
const user = makeUser();
return installFetch([
{ match: '/api/v1/instance', response: () => json({ instance: instanceOpen }) },
{ match: '/api/v1/auth/login', method: 'POST', response: () => json({ user }) },
{ match: '/api/v1/users/@me', response: () => json({ user }) },
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) },
...extra,
]);
}
function loginBodies(fetchMock: ReturnType<typeof installFetch>) {
return recordedRequests(fetchMock).filter(
(request) => request.url.includes('/auth/login') && request.method === 'POST',
);
}
describe('страница входа', () => {
it('поле кода 2FA показано сразу и принимает резервные коды', async () => {
installFetch([{ match: '/api/v1/instance', response: () => json({ instance: instanceOpen }) }]);
renderApp('/login');
const field = await screen.findByLabelText(totpLabel);
expect(field).toBeVisible();
expect(field).toHaveAttribute('autocomplete', 'one-time-code');
expect(field).toHaveAttribute('inputmode', 'text');
// Резервный код вида `a8eh-pshp-t8st` должен влезать целиком.
expect(Number(field.getAttribute('maxlength'))).toBeGreaterThanOrEqual(20);
expect(field).not.toHaveAttribute('pattern');
expect(screen.getByText(/резервный код вида xxxx-xxxx-xxxx/)).toBeVisible();
});
it('успешный вход переводит в /app и уходит без totp_code при пустом поле', async () => {
const fetchMock = loginRoutes([
{ match: '/api/v1/guilds/g-main/join', method: 'POST', response: () => json({ ok: true }) },
]);
const { router } = renderApp('/login');
const visitor = userEvent.setup();
await visitor.type(await screen.findByLabelText('Почта'), 'alice@example.com');
await visitor.type(screen.getByLabelText('Пароль'), 'super-secret-1');
await visitor.click(screen.getByRole('button', { name: 'Войти' }));
await waitFor(() => {
expect(router.state.location.pathname).toBe('/app/empty');
});
expect(await screen.findByRole('heading', { name: 'У вас пока нет серверов' })).toBeVisible();
// Автовступления в «главный сервер» больше нет: вход ведёт на пустой экран.
expect(findRequest(fetchMock, { url: '/guilds/g-main/join', method: 'POST' })).toBeUndefined();
expect(findRequest(fetchMock, { url: '/auth/login', method: 'POST' })?.body).toEqual({
email: 'alice@example.com',
password: 'super-secret-1',
});
});
it('код 2FA уходит вместе с логином в первом же запросе', async () => {
const fetchMock = loginRoutes([
{ match: '/api/v1/guilds/g-main/join', method: 'POST', response: () => json({ ok: true }) },
]);
renderApp('/login');
const visitor = userEvent.setup();
await visitor.type(await screen.findByLabelText('Почта'), 'alice@example.com');
await visitor.type(screen.getByLabelText('Пароль'), 'super-secret-1');
await visitor.type(screen.getByLabelText(totpLabel), '123456');
await visitor.click(screen.getByRole('button', { name: 'Войти' }));
await waitFor(() => {
expect(loginBodies(fetchMock)).toHaveLength(1);
});
expect(loginBodies(fetchMock)[0]?.body).toEqual({
email: 'alice@example.com',
password: 'super-secret-1',
totp_code: '123456',
});
});
it('резервный код из 14 символов уходит на сервер целиком', async () => {
const fetchMock = loginRoutes();
const { router } = renderApp('/login');
const visitor = userEvent.setup();
await visitor.type(await screen.findByLabelText('Почта'), 'alice@example.com');
await visitor.type(screen.getByLabelText('Пароль'), 'super-secret-1');
await visitor.type(screen.getByLabelText(totpLabel), 'a8eh-pshp-t8st');
// Поле не обрезает значение: 14 символов с дефисами видны целиком.
expect(screen.getByLabelText(totpLabel)).toHaveValue('a8eh-pshp-t8st');
await visitor.click(screen.getByRole('button', { name: 'Войти' }));
await waitFor(() => {
expect(loginBodies(fetchMock)[0]?.body).toEqual({
email: 'alice@example.com',
password: 'super-secret-1',
totp_code: 'a8eh-pshp-t8st',
});
});
// Вход не требует предварительной попытки без кода.
expect(loginBodies(fetchMock)).toHaveLength(1);
await waitFor(() => {
expect(router.state.location.pathname).toBe('/app/empty');
});
});
it('при auth.2fa_required подсказывает про код и сохраняет логин с паролем', async () => {
const user = makeUser();
let loginAttempts = 0;
const fetchMock = installFetch([
{ match: '/api/v1/instance', response: () => json({ instance: instanceOpen }) },
{
match: '/api/v1/auth/login',
method: 'POST',
response: () => {
loginAttempts += 1;
return loginAttempts === 1 ? apiError('auth.2fa_required', 401) : json({ user });
},
},
{ match: '/api/v1/users/@me', response: () => json({ user }) },
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) },
]);
const { router } = renderApp('/login');
const visitor = userEvent.setup();
await visitor.type(await screen.findByLabelText('Почта'), 'alice@example.com');
await visitor.type(screen.getByLabelText('Пароль'), 'super-secret-1');
await visitor.click(screen.getByRole('button', { name: 'Войти' }));
// Подсказка понятная, а введённые логин и пароль остались на месте.
expect(await screen.findByText(/Введите код 2FA или резервный код/)).toBeVisible();
expect(screen.getByLabelText('Почта')).toHaveValue('alice@example.com');
expect(screen.getByLabelText('Пароль')).toHaveValue('super-secret-1');
// Код, введённый после подсказки, не теряется при повторной отправке.
await visitor.type(screen.getByLabelText(totpLabel), 'a8eh-pshp-t8st');
await visitor.click(screen.getByRole('button', { name: 'Войти' }));
await waitFor(() => {
expect(router.state.location.pathname).toBe('/app/empty');
});
expect(loginBodies(fetchMock)).toHaveLength(2);
expect(loginBodies(fetchMock)[1]?.body).toEqual({
email: 'alice@example.com',
password: 'super-secret-1',
totp_code: 'a8eh-pshp-t8st',
});
});
});