import { expect, test, type Page } from '@playwright/test'; import { apiAs, ensureProbe, freshState, repeat429, type Requester, type StorageState, } from './support'; /** Префикс API инстанса: те же адреса, что у клиента. */ const API = '/api/v1'; /** * passkey.spec.ts — живая проверка passkeys (WebAuthn) на стенде * (AGENT.md 7.1, Фаза 7): регистрация ключа в настройках безопасности, * вход по нему без пароля и отзыв ключа. * * Вместо настоящего Touch ID используем виртуальный аутентификатор Chromium * (CDP `WebAuthn.enable` + `addVirtualAuthenticator`): подпись считает сам * браузер, поэтому проверяется весь путь — challenge, RP ID, origin, проверка * подписи на сервере и выдача сессии. */ const PASSWORD = 'correct-horse-battery-1'; const USERNAME = 'e2e_passkey'; /** addVirtualAuthenticator включает виртуальный ключ в контексте страницы. */ async function addVirtualAuthenticator(page: Page): Promise { const session = await page.context().newCDPSession(page); await session.send('WebAuthn.enable'); await session.send('WebAuthn.addVirtualAuthenticator', { options: { protocol: 'ctap2', transport: 'internal', hasResidentKey: true, hasUserVerification: true, isUserVerified: true, automaticPresenceSimulation: true, }, }); } test.describe('passkeys (WebAuthn)', () => { test('регистрация ключа, вход по ключу без пароля и отзыв', async ({ browser, playwright }) => { const api = await playwright.request.newContext({ baseURL: process.env.GLCHAT_URL }); await ensureProbe(api, USERNAME, PASSWORD); // Инстанс обязан объявлять поддержку passkeys: домен стенда — не IP. const meta = await repeat429(() => api.get(`${API}/meta`)); expect(meta.ok(), `GET /meta: ${await meta.text()}`).toBeTruthy(); const metaPayload = (await meta.json()) as { features: { passkeys_enabled: boolean } }; expect(metaPayload.features.passkeys_enabled).toBe(true); const state = await freshState(api, { username: USERNAME, password: PASSWORD }); const context = await browser.newContext({ storageState: state }); const page = await context.newPage(); await addVirtualAuthenticator(page); const keyName = `E2E ключ ${Date.now()}`; try { // 1. Регистрация ключа в настройках безопасности (со step-up паролем). await page.goto('/settings/account/security'); await expect(page.getByTestId('settings-content')).toBeVisible({ timeout: 30_000 }); await expect(page.getByRole('heading', { name: 'Ключи доступа (passkeys)' })).toBeVisible(); const section = page.getByTestId('settings-content'); await section.getByLabel('Название ключа').fill(keyName); await section.getByLabel('Ваш пароль').fill(PASSWORD); await section.getByRole('button', { name: 'Создать ключ' }).click(); await expect(page.getByText(`Ключ «${keyName}» добавлен.`)).toBeVisible({ timeout: 30_000, }); await expect(section.getByTestId('passkey-name').filter({ hasText: keyName })).toBeVisible(); // 2. Вход по ключу из того же браузера, но без сессии: пароль не нужен. await context.clearCookies(); await page.goto('/login'); const passkeyButton = page.getByTestId('passkey-login'); await expect(passkeyButton).toBeVisible({ timeout: 20_000 }); await passkeyButton.click(); await expect(page).toHaveURL(/\/app/u, { timeout: 30_000 }); // Сессия рабочая: профиль отдаётся сервером. const me = await page.request.get(`${API}/users/@me`); expect(me.ok(), `GET /users/@me после входа по ключу: ${await me.text()}`).toBeTruthy(); // Вход записан в события безопасности (аудит passkeys). const events = await page.request.get(`${API}/users/@me/security-events`); expect(events.ok(), `GET /users/@me/security-events: ${await events.text()}`).toBeTruthy(); expect(await events.text()).toContain('passkey_login'); // 3. Отзыв ключа: список очищается, вход этим ключом больше не проходит. await page.goto('/settings/account/security'); const again = page.getByTestId('settings-content'); await expect(again.getByText(keyName)).toBeVisible({ timeout: 30_000 }); // Отзыв требует свежего step-up: пароль берётся из формы раздела. await again.getByLabel('Ваш пароль').fill(PASSWORD); await again.getByRole('button', { name: 'Отозвать', exact: true }).first().click(); await again.getByRole('button', { name: 'Отозвать ключ' }).click(); await expect(page.getByText('Ключ отозван.')).toBeVisible({ timeout: 20_000 }); // Ключа больше нет. Виртуальный аутентификатор ещё хранит credential, // поэтому браузер подпишет challenge, а сервер откажет: важно, что в // приложение не пускает и ошибка показана человеческим текстом. await context.clearCookies(); await page.goto('/login'); await page.getByTestId('passkey-login').click(); await expect(page.getByRole('alert')).toBeVisible({ timeout: 25_000 }); await expect(page).toHaveURL(/\/login/u); } finally { await context.close(); await dropPasskeys(playwright, state); await api.dispose(); } }); }); /** dropPasskeys убирает ключи тестового аккаунта, чтобы стенд не копил данные. */ async function dropPasskeys(playwright: Requester, state: StorageState): Promise { const api = await apiAs(playwright, state); try { const response = await api.get(`${API}/auth/passkeys`); if (!response.ok()) { return; } const { passkeys } = (await response.json()) as { passkeys: { id: string }[] }; for (const passkey of passkeys) { await api.delete(`${API}/auth/passkeys/${passkey.id}`); } } finally { await api.dispose(); } }