Files
glchat/web/e2e/passkey.spec.ts
T

136 lines
6.6 KiB
TypeScript
Raw Normal View History

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<void> {
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<void> {
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();
}
}