Skip to content

E2E: управление PHP-сессиями в Playwright-тестах

Последнее обновление: 2026-03-29 (DEV-213)

Проблема

PHP-бэкенд вызывает session_regenerate_id(true) при логине и смене пароля. Параметр true удаляет старый session-файл на сервере. Playwright E2E-тесты сохраняют session-куки в файлы (SELLER_STATE, ADMIN_STATE), которые переиспользуются параллельными воркерами. Если один тест вызывает regenerate — куки в файле указывают на мёртвую сессию, и все зависимые тесты скипаются.

Архитектура сессий

PHP (бэкенд)

  • Множественные сессии поддерживаются (UF-16 — список активных сессий)
  • LoginAction создаёт НОВУЮ сессию, НЕ удаляя старые
  • session_regenerate_id(true) → удаляет файл старой сессии, создаёт новую
  • AuthMiddleware проверяет fingerprint: hash('sha256', IP + UserAgent). Несовпадение → session_destroy() + 401

Playwright (фронтенд)

seed.setup.ts    → отдельный API-контекст (pw.request.newContext)
auth.setup.ts    → loginViaApi через page.request → сохраняет SELLER_STATE / ADMIN_STATE
functional tests → test.use({ storageState: SELLER_STATE }) → каждый тест получает свой browser context

Ключевое различие:

  • page.request.post('/api/auth/login') → использует куки текущего контекста → regenerate() уничтожает сессию из файла
  • pw.request.newContext() + login → отдельный cookie jar → НЕ затрагивает SELLER_STATE

Цепочка инвалидации SELLER_STATE

auth.setup      → создаёт session A → сохраняет в SELLER_STATE

change-password → PUT /vendor/me с password → regenerate(true) → session A МЁРТВ
                → cleanup: re-login → session B → сохраняет в SELLER_STATE

sessions        → page.request.post('/auth/login') → regenerate(true) → session B МЁРТВ
                → cleanup: re-login → session C → сохраняет в SELLER_STATE

seller/create-product → читает SELLER_STATE (session C) → OK

Если тест на другом воркере стартует между regenerate и cleanup — он читает мёртвую сессию → skip.

Механизм восстановления (gotoCabinet)

tests/e2e/helpers/ensure-auth.ts::gotoCabinet() перехватывает /api/auth/me:

СтатусДействие
200Продолжить
429Retry с backoff (rate limit) — до 6 попыток × 5с
401Сессия мертва → re-login как seller → сохранить SELLER_STATE → повторить навигацию
typescript
if (/\/auth\/login/.test(page.url()) && authMeStatus === 401) {
  await loginViaApi(null, page, getSellerUser())
  await page.context().storageState({ path: SELLER_STATE })
  await page.goto(path, { waitUntil: 'networkidle' })
}

Правила для E2E-тестов

1. Тест вызывает page.request.post('/api/auth/login') → обязан re-save SELLER_STATE в cleanup

typescript
test('cleanup: восстановить SELLER_STATE', async ({ page }) => {
  await loginViaApi(null, page, getSellerUser())
  await page.context().storageState({ path: SELLER_STATE })
})

Кто это делает сейчас:

  • change-password.spec.ts — cleanup test
  • sessions.spec.ts — cleanup test

2. Для создания API-контекстов без влияния на SELLER_STATE → использовать pw.request.newContext()

typescript
// ✅ Безопасно — отдельный cookie jar
const ctx = await playwright.request.newContext({ baseURL, httpCredentials })
await ctx.post('/api/auth/login', { data: { email, password } })
// ... работа с API ...
await ctx.dispose()

// ❌ Опасно — уничтожает текущую сессию в browser context
await page.request.post('/api/auth/login', { data: { email, password } })

3. Навигация в кабинет → через gotoCabinet(), НЕ raw page.goto()

typescript
// ✅ С retry на 429 и recovery на 401
await gotoCabinet(page, '/cabinet/products/new', 'seller create-product')

// ❌ Без защиты от rate limit и мёртвых сессий
await page.goto('/cabinet/products/new', { waitUntil: 'networkidle' })

Текущее покрытие (CI job #1808, 2026-03-29)

  • 101 passed, 3 skipped, 0 failed, 0 flaky
  • Оставшиеся скипы: admin/moderation (admin-сессия), cabinet/profile:97 + :141 (serial cascade от race condition с change-password)