checklistauthenticationplaywrightautomationsecurityqa

Чек-лист тестирования регистрации и логина — и автотест на Playwright под каждый пункт

Логин — первый экран, который видит юзер, и одновременно любимое место продакшн-инцидентов: «не могу войти» — это всегда p1, потому что за дверью остаётся вся ценность продукта. При этом тестируют auth обычно по остаточному принципу: «ну логин же работает». Продолжаю формат прошлого поста про поиск: каждый пункт чек-листа — и сразу как его автоматизировать на Playwright. Auth для этого формата подходит идеально: флоу стабильный, меняется редко, гоняется на каждый релиз — идеальный кандидат в автоматизацию.

1. Валидация формы: границы и мусор

Классика из чек-листа форм применительно к регистрации: пустые поля, пробелы вместо email, email без домена, пароль короче минимума ровно на один символ, юникод и эмодзи в пароле (допустимы? одинаково валидируются при регистрации и логине?).

Автоматизируется параметризацией — один тест, массив кейсов:

const invalid = ['', '   ', 'no-at-sign', 'a@b', '<script>@x.com'];
for (const email of invalid) {
  test(`регистрация отклоняет email: "${email}"`, async ({ page }) => {
    await page.goto('/signup');
    await page.getByLabel('Email').fill(email);
    await page.getByLabel('Пароль').fill('Valid-Pass-123');
    await page.getByRole('button', { name: 'Создать аккаунт' }).click();
    await expect(page.getByRole('alert')).toBeVisible();
    await expect(page).toHaveURL(/signup/); // не пустили дальше
  });
}

2. Ошибка логина не выдаёт, что именно неверно

Если на несуществующий email система говорит «пользователь не найден», а на существующий с неверным паролем — «неверный пароль», вы подарили атакующему перебор базы юзеров (user enumeration). OWASP Authentication Cheat Sheet прямо требует одинаковый ответ в обоих случаях. То же на регистрации («email уже занят» — тоже enumeration, тут обычно компромисс) и на password reset («если аккаунт существует, мы отправили письмо»).

Автотест — сравнение двух сообщений:

test('логин не раскрывает существование аккаунта', async ({ page }) => {
  const msgFor = async (email: string) => {
    await page.goto('/login');
    await page.getByLabel('Email').fill(email);
    await page.getByLabel('Пароль').fill('definitely-wrong');
    await page.getByRole('button', { name: 'Войти' }).click();
    return page.getByRole('alert').textContent();
  };
  const existing = await msgFor('[email protected]');
  const missing = await msgFor('[email protected]');
  expect(existing).toBe(missing); // одинаковый текст — нечего перебирать
});

3. Password reset: токен работает один раз

Самый недотестированный флоу в auth — потому что в нём участвует почта. Проверить надо: письмо приходит на существующий аккаунт, токен открывает форму смены, использованный токен недействителен (открыть ссылку второй раз), истёкший токен отклоняется, старый пароль после смены не подходит, новый — подходит, а по правилам OWASP Forgot Password Cheat Sheet активные сессии после смены пароля стоит завершать.

В e2e почту не тащим — на тестовом окружении письма шлются в перехватчик (MailHog/Mailpit), у которого есть API. Тест забирает ссылку оттуда:

test('reset-токен одноразовый', async ({ page, request }) => {
  await page.goto('/forgot');
  await page.getByLabel('Email').fill(user.email);
  await page.getByRole('button', { name: 'Сбросить пароль' }).click();

  const mail = await request.get('http://mailpit:8025/api/v1/messages');
  const link = extractResetLink(await mail.json());

  await page.goto(link);
  await page.getByLabel('Новый пароль').fill('New-Pass-456');
  await page.getByRole('button', { name: 'Сохранить' }).click();
  await expect(page).toHaveURL(/login/);

  await page.goto(link); // вторая попытка тем же токеном
  await expect(page.getByText(/ссылка недействительна/i)).toBeVisible();
});

4. Logout: сессия умирает по-настоящему

Кнопка «выйти» есть у всех, а вот проверяют ли, что сессия реально инвалидирована? Два обязательных кейса: после logout кнопка «назад» в браузере не показывает приватные данные (из кэша или, хуже, по живой сессии), и приватный URL напрямую редиректит на логин.

test('после logout приватное недоступно', async ({ page }) => {
  await login(page);
  await page.goto('/settings');
  await page.getByRole('button', { name: 'Выйти' }).click();
  await expect(page).toHaveURL(/login/);

  await page.goBack(); // кнопка «назад»
  await expect(page).toHaveURL(/login/);

  await page.goto('/settings'); // прямой заход
  await expect(page).toHaveURL(/login/);
});

5. Сессии: две вкладки, remember me, истечение

Поведение сессий между вкладками и устройствами — место, где «работает у меня» особенно коварно. Проверяем: логин во второй вкладке не убивает первую (или убивает — если такова политика, но осознанно); remember me переживает закрытие браузера, его отсутствие — нет; истечение сессии посреди работы ведёт на логин с понятным сообщением и, в идеале, возвратом туда же после входа.

Две вкладки в Playwright — это два контекста или две страницы одного контекста, в зависимости от того, что моделируем (контексты не делят cookies — это «два браузера»; страницы одного контекста делят — это «две вкладки»):

test('логин во второй вкладке не ломает первую', async ({ context }) => {
  const tab1 = await context.newPage();
  await login(tab1);
  const tab2 = await context.newPage();
  await tab2.goto('/dashboard');           // уже залогинен — сессия общая
  await expect(tab2.getByTestId('user-menu')).toBeVisible();
  await tab1.reload();
  await expect(tab1.getByTestId('user-menu')).toBeVisible(); // первая жива
});

А протухшую сессию детерминированно устраиваем сами — чистим cookies и смотрим, как UI переживает следующий запрос:

test('истёкшая сессия → логин, не белый экран', async ({ page, context }) => {
  await login(page);
  await context.clearCookies();            // «сессия истекла»
  await page.getByRole('button', { name: 'Сохранить' }).click();
  await expect(page).toHaveURL(/login/);
  await expect(page.getByText(/сессия истекла/i)).toBeVisible();
});

6. Блокировка перебора: UI при 429

Сам rate limiting в e2e лучше не тестировать — реальные N попыток делают тест медленным и флаки (лимит общий на окружение, параллельные воркеры друг друга блокируют). Разделяем: что лимит существует — проверяется на уровне API-тестов или конфига стенда; что UI вменяемо показывает блокировку — мокаем ответ, как в посте про поиск:

test('UI показывает блокировку, а не молчит', async ({ page }) => {
  await page.route('**/api/login', r => r.fulfill({
    status: 429,
    json: { error: 'too_many_attempts', retryAfter: 300 },
  }));
  await page.goto('/login');
  await page.getByLabel('Email').fill(user.email);
  await page.getByLabel('Пароль').fill('wrong');
  await page.getByRole('button', { name: 'Войти' }).click();
  await expect(page.getByText(/слишком много попыток/i)).toBeVisible();
});

7. Cookies и мелкая гигиена безопасности

Не пентест, но дешёвые проверки, которые ловят регрессии конфигурации: сессионная cookie с HttpOnly (JS её не читает), Secure, SameSite; пароль не оказывается в URL и не логируется; поле пароля разрешает paste (запрет paste — антипаттерн, ломает менеджеры паролей) и имеет show/hide.

Атрибуты cookie автотест достаёт напрямую:

test('сессионная cookie защищена', async ({ page, context }) => {
  await login(page);
  const session = (await context.cookies()).find(c => c.name === 'session');
  expect(session?.httpOnly).toBe(true);
  expect(session?.secure).toBe(true);
  expect(session?.sameSite).toBe('Lax');
});

8. Бонус формата: не логиньтесь в каждом тесте

Паттерн, который окупает статью: логин через UI должен быть тестом, а не сетапом всех тестов. Прогонять полный UI-логин перед каждым из двухсот тестов — медленно и флаки. У Playwright для этого есть штатный механизм storageState: отдельный setup-проект логинится один раз и сохраняет состояние, остальные тесты стартуют уже залогиненными:

// auth.setup.ts — выполняется один раз
setup('авторизация', async ({ page }) => {
  await login(page);
  await page.context().storageState({ path: '.auth/user.json' });
});
// playwright.config.ts: use: { storageState: '.auth/user.json' }

Сам логин-флоу при этом остаётся покрыт своими явными тестами — из пунктов выше.

Что в auth НЕ стоит тащить в e2e

  • Реальную почтовую доставку — дойдёт ли письмо до Gmail и не попадёт ли в спам, e2e не проверит; это мониторинг доставляемости, отдельная дисциплина.
  • Сторонний OAuth («войти через Google/Apple») — их UI вы не контролируете, тесты через реальные провайдеры отлично флакают и упираются в их анти-бот защиту. Провайдер мокается на границе; живой прогон — руками перед релизом.
  • CAPTCHA — она существует ровно затем, чтобы вас не автоматизировали. На тестовом окружении отключается флагом, а вот что на проде она включена — проверяется руками (и этот пункт забывают чаще, чем кажется).
  • Timing-атаки и брутфорс — инструменты безопасности, не Playwright.

Полный чек-лист

Регистрация: границы и мусор в полях · дубликат email → без enumeration (или осознанный компромисс) · письмо подтверждения и его повторная отправка · пароль: paste разрешён, show/hide, unicode. Логин: верные/неверные креды · одинаковая ошибка для «нет юзера» и «не тот пароль» · регистр и пробелы в email · redirect на исходную страницу после входа. Password reset: токен одноразовый · истёкший отклоняется · старый пароль умирает, сессии завершаются · «письмо отправлено» одинаково для любых email. Сессии: logout убивает сессию (back + прямой URL) · две вкладки/устройства · remember me · истечение посреди действия — с сообщением. Безопасность-минимум: cookies HttpOnly/Secure/SameSite · пароль не в URL/логах · rate limit существует (API-уровень) и UI его показывает (мок 429) · CAPTCHA на проде включена. Инфраструктура тестов: storageState вместо логина в каждом тесте · почта через перехватчик с API.

Auth — редкий случай, когда почти весь чек-лист честно автоматизируется: флоу детерминированный, меняется редко, а цена регрессии максимальная. Если в проекте есть автотесты вообще — логин должен быть покрыт первым.