test(web): e2e голоса переиспользует тестового пользователя и терпит лимиты
- постоянный аккаунт `voiceprobe` вместо регистрации нового на каждый прогон (API удаления пользователей пока нет — это Фаза 5); - `postWithRetry` и повтор входа в браузере учитывают `rate_limited` и `retry_after_ms`: защита от флуда входа обязана работать, а тест не должен падать из-за неё; - проверено два прогона подряд: оба зелёные, в базе стенда остаются только аккаунт администратора, тестовый пользователь и серверы владельца.
This commit is contained in:
+82
-26
@@ -22,6 +22,8 @@ const enabled = process.env.GLCHAT_E2E_VOICE === '1';
|
|||||||
const ADMIN_EMAIL = process.env.GLCHAT_ADMIN_EMAIL ?? '';
|
const ADMIN_EMAIL = process.env.GLCHAT_ADMIN_EMAIL ?? '';
|
||||||
const ADMIN_PASSWORD = process.env.GLCHAT_ADMIN_PASSWORD ?? '';
|
const ADMIN_PASSWORD = process.env.GLCHAT_ADMIN_PASSWORD ?? '';
|
||||||
const ADMIN_TOTP = process.env.GLCHAT_ADMIN_TOTP ?? '';
|
const ADMIN_TOTP = process.env.GLCHAT_ADMIN_TOTP ?? '';
|
||||||
|
/** Постоянный тестовый пользователь: создаётся один раз и переиспользуется. */
|
||||||
|
const PROBE_LOGIN = 'voiceprobe';
|
||||||
|
|
||||||
/** totp генерирует шестизначный код из секрета (RFC 6238, SHA-1, 30 секунд). */
|
/** totp генерирует шестизначный код из секрета (RFC 6238, SHA-1, 30 секунд). */
|
||||||
function totp(secret: string, now = Date.now()): string {
|
function totp(secret: string, now = Date.now()): string {
|
||||||
@@ -106,8 +108,10 @@ async function finishOnboarding(api: APIRequestContext): Promise<void> {
|
|||||||
* не переключают на другого пользователя.
|
* не переключают на другого пользователя.
|
||||||
*/
|
*/
|
||||||
async function loginAsAdmin(api: APIRequestContext): Promise<{ id: string }> {
|
async function loginAsAdmin(api: APIRequestContext): Promise<{ id: string }> {
|
||||||
const response = await api.post('/api/v1/auth/login', {
|
const response = await postWithRetry(api, '/api/v1/auth/login', {
|
||||||
data: { email: ADMIN_EMAIL, password: ADMIN_PASSWORD, totp_code: totp(ADMIN_TOTP) },
|
email: ADMIN_EMAIL,
|
||||||
|
password: ADMIN_PASSWORD,
|
||||||
|
totp_code: totp(ADMIN_TOTP),
|
||||||
});
|
});
|
||||||
expect(response.ok(), `вход администратора: ${await response.text()}`).toBeTruthy();
|
expect(response.ok(), `вход администратора: ${await response.text()}`).toBeTruthy();
|
||||||
const payload = (await response.json()) as { user: { id: string } };
|
const payload = (await response.json()) as { user: { id: string } };
|
||||||
@@ -115,8 +119,61 @@ async function loginAsAdmin(api: APIRequestContext): Promise<{ id: string }> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* prepare создаёт сервер, голосовую комнату и второго пользователя по
|
* postWithRetry повторяет запрос, если сработал лимит частоты: сервер отвечает
|
||||||
* приглашению. `admin` остаётся сессией администратора (модерация и уборка),
|
* `rate_limited` и `retry_after_ms`, а тест не должен падать из-за защиты,
|
||||||
|
* которая как раз и обязана работать (AGENT.md 9.4).
|
||||||
|
*/
|
||||||
|
async function postWithRetry(
|
||||||
|
api: APIRequestContext,
|
||||||
|
path: string,
|
||||||
|
data: Record<string, unknown>,
|
||||||
|
attempts = 4,
|
||||||
|
): Promise<Awaited<ReturnType<APIRequestContext['post']>>> {
|
||||||
|
let response = await api.post(path, { data });
|
||||||
|
for (let attempt = 1; attempt < attempts && response.status() === 429; attempt += 1) {
|
||||||
|
const payload = (await response.json().catch(() => ({}))) as { retry_after_ms?: number };
|
||||||
|
const wait = Math.min(Math.max(payload.retry_after_ms ?? 1_000, 500), 15_000);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, wait + 200));
|
||||||
|
response = await api.post(path, { data });
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ensureProbeGuest логинит постоянного тестового пользователя, а если его ещё
|
||||||
|
* нет — регистрирует. Один и тот же аккаунт переиспользуется между прогонами:
|
||||||
|
* иначе каждый запуск оставлял бы в базе нового пользователя, а удалять
|
||||||
|
* пользователей API пока не умеет (это задача админ-панели Фазы 5).
|
||||||
|
*/
|
||||||
|
async function ensureProbeGuest(
|
||||||
|
guest: APIRequestContext,
|
||||||
|
): Promise<{ id: string; email: string; password: string }> {
|
||||||
|
const email = `${PROBE_LOGIN}@gl.mhspx.su`;
|
||||||
|
const password = 'Voice-Probe-Password-9x';
|
||||||
|
const login = await postWithRetry(guest, '/api/v1/auth/login', { email, password });
|
||||||
|
if (login.ok()) {
|
||||||
|
const payload = (await login.json()) as { user: { id: string } };
|
||||||
|
await finishOnboarding(guest);
|
||||||
|
return { id: payload.user.id, email, password };
|
||||||
|
}
|
||||||
|
const registered = await postWithRetry(guest, '/api/v1/auth/register', {
|
||||||
|
username: PROBE_LOGIN,
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
registered.ok(),
|
||||||
|
`тестовый пользователь ${PROBE_LOGIN} не найден и не зарегистрирован ` +
|
||||||
|
`(регистрация выключена?): ${await registered.text()}`,
|
||||||
|
).toBeTruthy();
|
||||||
|
const payload = (await registered.json()) as { user: { id: string } };
|
||||||
|
await finishOnboarding(guest);
|
||||||
|
return { id: payload.user.id, email, password };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* prepare создаёт сервер и голосовую комнату, приглашает постоянного тестового
|
||||||
|
* пользователя. `admin` остаётся сессией администратора (модерация и уборка),
|
||||||
* `guest` — сессией приглашённого пользователя.
|
* `guest` — сессией приглашённого пользователя.
|
||||||
*/
|
*/
|
||||||
async function prepare(admin: APIRequestContext, guest: APIRequestContext): Promise<Fixture> {
|
async function prepare(admin: APIRequestContext, guest: APIRequestContext): Promise<Fixture> {
|
||||||
@@ -142,20 +199,7 @@ async function prepare(admin: APIRequestContext, guest: APIRequestContext): Prom
|
|||||||
expect(invite.ok(), `создание приглашения: ${await invite.text()}`).toBeTruthy();
|
expect(invite.ok(), `создание приглашения: ${await invite.text()}`).toBeTruthy();
|
||||||
const code = ((await invite.json()) as { invite: { code: string } }).invite.code;
|
const code = ((await invite.json()) as { invite: { code: string } }).invite.code;
|
||||||
|
|
||||||
const guestLogin = `voice${stamp}`;
|
const probe = await ensureProbeGuest(guest);
|
||||||
const guestEmail = `${guestLogin}@gl.mhspx.su`;
|
|
||||||
const guestPassword = 'Voice-Probe-Password-9x';
|
|
||||||
const guestContext = await guest.post('/api/v1/auth/register', {
|
|
||||||
data: {
|
|
||||||
username: guestLogin,
|
|
||||||
email: guestEmail,
|
|
||||||
password: guestPassword,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(guestContext.ok(), `регистрация гостя: ${await guestContext.text()}`).toBeTruthy();
|
|
||||||
const guestId = ((await guestContext.json()) as { user: { id: string } }).user.id;
|
|
||||||
await finishOnboarding(guest);
|
|
||||||
|
|
||||||
const accept = await guest.post(`/api/v1/invites/${code}`);
|
const accept = await guest.post(`/api/v1/invites/${code}`);
|
||||||
expect(accept.ok(), `принятие приглашения: ${await accept.text()}`).toBeTruthy();
|
expect(accept.ok(), `принятие приглашения: ${await accept.text()}`).toBeTruthy();
|
||||||
|
|
||||||
@@ -163,10 +207,10 @@ async function prepare(admin: APIRequestContext, guest: APIRequestContext): Prom
|
|||||||
guildId,
|
guildId,
|
||||||
guildName,
|
guildName,
|
||||||
channelId,
|
channelId,
|
||||||
guestEmail,
|
guestEmail: probe.email,
|
||||||
guestPassword,
|
guestPassword: probe.password,
|
||||||
adminId: adminUser.id,
|
adminId: adminUser.id,
|
||||||
guestId,
|
guestId: probe.id,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,12 +261,24 @@ async function signIn(
|
|||||||
await page.goto('/login');
|
await page.goto('/login');
|
||||||
await page.getByLabel('Почта', { exact: true }).fill(options.email);
|
await page.getByLabel('Почта', { exact: true }).fill(options.email);
|
||||||
await page.getByLabel('Пароль', { exact: true }).fill(options.password);
|
await page.getByLabel('Пароль', { exact: true }).fill(options.password);
|
||||||
if (options.totp !== undefined) {
|
// Лимит частоты входов (5/мин) может сработать на серии прогонов: тогда
|
||||||
await page.getByLabel('Код 2FA или резервный код').fill(totp(options.totp));
|
// форма показывает ошибку — ждём и пробуем снова со свежим кодом 2FA.
|
||||||
|
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
||||||
|
if (options.totp !== undefined) {
|
||||||
|
await page.getByLabel('Код 2FA или резервный код').fill(totp(options.totp));
|
||||||
|
}
|
||||||
|
await page.getByRole('button', { name: /Войти/u }).click();
|
||||||
|
try {
|
||||||
|
await expect(page).toHaveURL(/\/app/u, { timeout: 20_000 });
|
||||||
|
return { context, page };
|
||||||
|
} catch (error) {
|
||||||
|
if (attempt === 3) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
await page.waitForTimeout(6_000);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
await page.getByRole('button', { name: /Войти/u }).click();
|
throw new Error('вход не удался');
|
||||||
await expect(page).toHaveURL(/\/app/u, { timeout: 30_000 });
|
|
||||||
return { context, page };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user