From 267232c24f93c515d6fd3f7f81c0676066ab1ab8 Mon Sep 17 00:00:00 2001 From: fishzjp <105406371+fishzjp@users.noreply.github.com> Date: Mon, 7 Sep 2026 23:31:54 +0800 Subject: [PATCH] fix(security): harden password recovery with usable eight-character codes (#2477) Use eight securely random recovery-code characters with concurrency-safe online throttling. Preserve existing keys and verify recovery through browser and real SQLite integration tests. Co-authored-by: zhangjinpeng@mail.tuchong.com Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com> --- .../pkg/api/http/controller/groups/user.py | 48 ++- src/langbot/pkg/core/stages/genkeys.py | 21 +- .../api/test_recovery_password_journey.py | 85 +++++ .../api/test_user_reset_password.py | 302 ++++++++++++++++++ web/src/app/reset-password/page.tsx | 43 +-- web/tests/e2e/reset-password.spec.ts | 122 +++++++ 6 files changed, 588 insertions(+), 33 deletions(-) create mode 100644 tests/integration/api/test_recovery_password_journey.py create mode 100644 tests/unit_tests/api/test_user_reset_password.py create mode 100644 web/tests/e2e/reset-password.spec.ts diff --git a/src/langbot/pkg/api/http/controller/groups/user.py b/src/langbot/pkg/api/http/controller/groups/user.py index 03c84bf47..844be406e 100644 --- a/src/langbot/pkg/api/http/controller/groups/user.py +++ b/src/langbot/pkg/api/http/controller/groups/user.py @@ -2,6 +2,8 @@ import quart import argon2 import asyncio import datetime +import hmac +import time import uuid from urllib.parse import parse_qs, urlsplit @@ -11,6 +13,33 @@ from ...context import RequestContext from .....cloud.launch import SpaceLaunchError from ...service.user import ControlPlaneDirectoryRequiredError, PublicRegistrationClosedError +# Fixed-window admission quota for the unauthenticated reset-password endpoint (#2392). +# The admission check and slot bump share ONE synchronous critical section with no await +# points, so concurrent bursts within a single event loop cannot slip past accounting. +# Every admitted attempt consumes quota (regardless of success), which throttles both the +# legacy 24-bit keyspace exhaustion and brute-force on modern high-entropy keys. +# NOTE: this state is process-local; multi-worker deployments need a shared limiter upstream. +_MAX_RESET_ATTEMPTS_PER_WINDOW = 5 +_RESET_WINDOW_SECONDS = 15 * 60 + +_reset_password_state: dict = {'window_started_at': 0.0, 'attempts': 0} + + +def _admit_reset_attempt(now: float) -> bool: + """Atomically reserve one reset-password admission slot. + + Must stay await-free: running to completion without suspension makes the + check-and-increment atomic under the single-threaded event loop. + """ + st = _reset_password_state + if now - st['window_started_at'] >= _RESET_WINDOW_SECONDS: + st['window_started_at'] = now + st['attempts'] = 0 + if st['attempts'] >= _MAX_RESET_ATTEMPTS_PER_WINDOW: + return False + st['attempts'] += 1 + return True + @group.group_class('user', '/api/v1/user') class UserRouterGroup(group.RouterGroup): @@ -81,6 +110,12 @@ class UserRouterGroup(group.RouterGroup): @self.route('/reset-password', methods=['POST'], auth_type=group.AuthType.NONE) async def _() -> str: + # Admit (or reject) BEFORE touching the body or any service call (#2392): + # rejecting requests never reach the slow path, and quota accounting happens + # synchronously at entry, closing the post-await race of burst requests. + if not _admit_reset_attempt(time.monotonic()): + return self.http_status(429, -1, 'Too many attempts, try again later') + json_data = await quart.request.json user_email = json_data['user'] @@ -98,7 +133,18 @@ class UserRouterGroup(group.RouterGroup): if user_obj is None: return self.http_status(400, -1, 'User not found') - if recovery_key != self.ap.instance_config.data['system']['recovery_key']: + stored_key = self.ap.instance_config.data['system']['recovery_key'] + try: + key_matches = ( + isinstance(recovery_key, str) + and isinstance(stored_key, str) + and hmac.compare_digest(recovery_key.encode(), stored_key.encode()) + ) + except UnicodeEncodeError: + # JSON can contain lone surrogates, which are not valid UTF-8. + key_matches = False + + if not key_matches: return self.http_status(403, -1, 'Invalid recovery key') await self.ap.user_service.reset_password(user_email, new_password) diff --git a/src/langbot/pkg/core/stages/genkeys.py b/src/langbot/pkg/core/stages/genkeys.py index f0412b9d2..230fa91f7 100644 --- a/src/langbot/pkg/core/stages/genkeys.py +++ b/src/langbot/pkg/core/stages/genkeys.py @@ -1,9 +1,18 @@ from __future__ import annotations +import logging import secrets from .. import stage, app +# This stage runs before SetupLoggerStage, so ap.logger is still None here; +# the module logger falls back to the stderr lastResort handler. +_logger = logging.getLogger(__name__) + +# 32 symbols without 0/O or 1/I; eight independent draws provide 40 random bits. +_RECOVERY_KEY_ALPHABET = '23456789ABCDEFGHJKLMNPQRSTUVWXYZ' +_RECOVERY_KEY_LENGTH = 8 + @stage.stage_class('GenKeysStage') class GenKeysStage(stage.BootingStage): @@ -20,5 +29,15 @@ class GenKeysStage(stage.BootingStage): ap.instance_config.data['system']['recovery_key'] = '' if not ap.instance_config.data['system']['recovery_key']: - ap.instance_config.data['system']['recovery_key'] = secrets.token_hex(3).upper() + # Keep recovery practical to type. Security also requires the reset + # endpoint's concurrency-safe quota (five admissions per 15 minutes). + ap.instance_config.data['system']['recovery_key'] = ''.join( + secrets.choice(_RECOVERY_KEY_ALPHABET) for _ in range(_RECOVERY_KEY_LENGTH) + ) await ap.instance_config.dump_config() + elif len(ap.instance_config.data['system']['recovery_key']) < _RECOVERY_KEY_LENGTH: + _logger.warning( + 'Low-entropy legacy recovery key detected (length < 8); ' + 'regenerate system.recovery_key in the configuration file ' + 'with a strong random value (#2392)' + ) diff --git a/tests/integration/api/test_recovery_password_journey.py b/tests/integration/api/test_recovery_password_journey.py new file mode 100644 index 000000000..abaf3510e --- /dev/null +++ b/tests/integration/api/test_recovery_password_journey.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +import logging +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest +from quart import Quart + +from langbot.pkg.api.http.controller.groups import user as user_module +from langbot.pkg.api.http.controller.groups.user import UserRouterGroup +from langbot.pkg.api.http.service.user import UserService +from langbot.pkg.core.stages.genkeys import GenKeysStage +from langbot.pkg.persistence.mgr import PersistenceManager +from langbot.pkg.utils import constants +from langbot.pkg.workspace.collaboration import WorkspaceCollaborationService +from langbot.pkg.workspace.service import WorkspaceService + +pytestmark = [pytest.mark.integration, pytest.mark.asyncio] + + +async def test_generated_recovery_code_resets_real_sqlite_account(tmp_path, monkeypatch): + """Exercise generation, reset, and old/new password login without mocked user services.""" + monkeypatch.setattr(constants, 'instance_id', 'recovery-journey') + monkeypatch.setattr(user_module, '_reset_password_state', {'window_started_at': 0.0, 'attempts': 0}) + monkeypatch.setattr(user_module, 'asyncio', SimpleNamespace(sleep=AsyncMock())) + application = SimpleNamespace( + logger=logging.getLogger('recovery-password-journey'), + instance_config=SimpleNamespace( + data={ + 'database': {'use': 'sqlite', 'sqlite': {'path': str(tmp_path / 'recovery.db')}}, + 'system': { + 'jwt': {'secret': 'recovery-journey-test-secret-only', 'expire': 3600}, + 'recovery_key': '', + }, + }, + dump_config=AsyncMock(), + ), + ) + await GenKeysStage().run(application) + key = application.instance_config.data['system']['recovery_key'] + assert len(key) == 8 + assert set(key) <= set('23456789ABCDEFGHJKLMNPQRSTUVWXYZ') + persistence = PersistenceManager(application) + application.persistence_mgr = persistence + try: + await persistence.initialize() + application.workspace_service = WorkspaceService(application, instance_uuid='recovery-journey') + application.workspace_collaboration_service = WorkspaceCollaborationService( + application, application.workspace_service + ) + application.user_service = UserService(application) + quart_app = Quart(__name__) + await UserRouterGroup(application, quart_app).initialize() + client = quart_app.test_client() + + initial = await client.post( + '/api/v1/user/init', json={'user': 'owner@example.com', 'password': 'OriginalPass1!'} + ) + assert initial.status_code == 200 + assert (await initial.get_json())['code'] == 0 + + payload = {'user': 'owner@example.com', 'recovery_key': 'WRONG', 'new_password': 'RecoveredPass1!'} + wrong = await client.post('/api/v1/user/reset-password', json=payload) + assert wrong.status_code == 403 + unchanged = await client.post( + '/api/v1/user/auth', json={'user': 'owner@example.com', 'password': 'OriginalPass1!'} + ) + assert (await unchanged.get_json())['code'] == 0 + + reset = await client.post('/api/v1/user/reset-password', json={**payload, 'recovery_key': key}) + assert reset.status_code == 200 + assert (await reset.get_json())['code'] == 0 + old_login = await client.post( + '/api/v1/user/auth', json={'user': 'owner@example.com', 'password': 'OriginalPass1!'} + ) + assert (await old_login.get_json())['code'] != 0 + new_login = await client.post( + '/api/v1/user/auth', json={'user': 'owner@example.com', 'password': 'RecoveredPass1!'} + ) + new_data = await new_login.get_json() + assert new_data['code'] == 0 + assert new_data['data']['token'] + finally: + await persistence.get_db_engine().dispose() diff --git a/tests/unit_tests/api/test_user_reset_password.py b/tests/unit_tests/api/test_user_reset_password.py new file mode 100644 index 000000000..4ae70d90d --- /dev/null +++ b/tests/unit_tests/api/test_user_reset_password.py @@ -0,0 +1,302 @@ +"""Regression tests for recovery-key hardening (#2392). + +Covers two attack surfaces reported in GHSA-4xcp-6758-rxqv: + +1. ``genkeys.py`` generated ``system.recovery_key`` with only 24 bits of + entropy (``secrets.token_hex(3)``), making the whole keyspace brute-forceable. +2. ``POST /api/v1/user/reset-password`` (unauthenticated) checked its failure + counter across ``await`` points, so concurrent guesses all passed the gate + before any accounting happened; admission is now a synchronous fixed-window + quota consumed at entry, plus constant-time key comparison. +""" + +from __future__ import annotations + +import asyncio +import logging +import time +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest +import quart + +from langbot.pkg.api.http.controller.groups import user as user_module +from langbot.pkg.api.http.controller.groups.user import UserRouterGroup +from langbot.pkg.core.stages.genkeys import GenKeysStage + +pytestmark = pytest.mark.asyncio + +STORED_KEY = 'ABCD2345' + + +@pytest.fixture(autouse=True) +def _reset_quota_state(): + """Reset the module-level admission-quota state before each test.""" + user_module._reset_password_state['window_started_at'] = 0.0 + user_module._reset_password_state['attempts'] = 0 + yield + user_module._reset_password_state['window_started_at'] = 0.0 + user_module._reset_password_state['attempts'] = 0 + + +@pytest.fixture(autouse=True) +def _fast_sleep(monkeypatch): + """Neutralize the fixed 3s delay so tests run instantly.""" + monkeypatch.setattr(user_module, 'asyncio', SimpleNamespace(sleep=AsyncMock())) + + +# --------------------------------------------------------------------------- +# genkeys.py: recovery-key generation and compatibility +# --------------------------------------------------------------------------- + + +def _make_genkeys_ap(existing_key: str) -> SimpleNamespace: + """Build a minimal Application mock for GenKeysStage. + + Mirrors the real boot order: no ``logger`` attribute is set because + GenKeysStage runs before SetupLoggerStage. + """ + return SimpleNamespace( + instance_config=SimpleNamespace( + data={'system': {'jwt': {'secret': 'jwt-secret'}, 'recovery_key': existing_key}}, + dump_config=AsyncMock(), + ), + ) + + +async def test_recovery_key_generation_is_short_and_unambiguous(): + """Eight random base32 characters balance manual entry and online throttling.""" + ap = _make_genkeys_ap(existing_key='') + + await GenKeysStage().run(ap) + + key = ap.instance_config.data['system']['recovery_key'] + assert len(key) == 8 + assert set(key) <= set('23456789ABCDEFGHJKLMNPQRSTUVWXYZ') + assert ap.instance_config.dump_config.called + + +async def test_legacy_low_entropy_key_preserved_with_warning(caplog): + """A legacy 6-char key must keep working but emit a warning, without ap.logger.""" + ap = _make_genkeys_ap(existing_key='ABC123') + + with caplog.at_level(logging.WARNING, logger='langbot.pkg.core.stages.genkeys'): + await GenKeysStage().run(ap) + + assert ap.instance_config.data['system']['recovery_key'] == 'ABC123' + assert any('Low-entropy' in record.message for record in caplog.records) + assert not ap.instance_config.dump_config.called + + +@pytest.mark.parametrize('existing_key', ['ABC123', 'ABCD2345', 'aB-_' * 10 + 'xYz', '自定义恢复密钥']) +async def test_recovery_key_generation_preserves_existing_key(existing_key): + """An explicitly configured recovery key must not be regenerated on boot.""" + ap = _make_genkeys_ap(existing_key=existing_key) + + await GenKeysStage().run(ap) + + assert ap.instance_config.data['system']['recovery_key'] == existing_key + assert not ap.instance_config.dump_config.called + + +async def test_generated_key_is_preserved_without_legacy_warning(caplog): + """A restart must not warn about or replace the new eight-character key.""" + ap = _make_genkeys_ap(existing_key='') + await GenKeysStage().run(ap) + key = ap.instance_config.data['system']['recovery_key'] + assert len(key) == 8 + ap.instance_config.dump_config.reset_mock() + with caplog.at_level(logging.WARNING, logger='langbot.pkg.core.stages.genkeys'): + await GenKeysStage().run(ap) + assert ap.instance_config.data['system']['recovery_key'] == key + assert not caplog.records + ap.instance_config.dump_config.assert_not_awaited() + + +async def test_eight_character_key_does_not_trigger_legacy_warning(caplog): + ap = _make_genkeys_ap(existing_key='ABCD2345') + with caplog.at_level(logging.WARNING, logger='langbot.pkg.core.stages.genkeys'): + await GenKeysStage().run(ap) + assert not caplog.records + + +# --------------------------------------------------------------------------- +# POST /api/v1/user/reset-password: admission quota + constant-time compare +# --------------------------------------------------------------------------- + + +async def _create_client(stored_key: str = STORED_KEY): + """Create a Quart test client with a mocked Application.""" + quart_app = quart.Quart(__name__) + + user_obj = SimpleNamespace(uuid='user-uuid', user='admin@example.com') + reset_password = AsyncMock() + get_user_by_email = AsyncMock(return_value=user_obj) + + ap = SimpleNamespace( + user_service=SimpleNamespace( + is_initialized=AsyncMock(return_value=True), + get_user_by_email=get_user_by_email, + reset_password=reset_password, + ), + instance_config=SimpleNamespace( + data={'system': {'recovery_key': stored_key}}, + ), + ) + + router = UserRouterGroup(ap, quart_app) + await router.initialize() + + client = quart_app.test_client() + return client, reset_password, get_user_by_email + + +def _payload(key: str = STORED_KEY) -> dict: + return {'user': 'admin@example.com', 'recovery_key': key, 'new_password': 'NewPass1!'} + + +@pytest.mark.parametrize('key', [STORED_KEY, 'ABC123', 'aB-_' * 10 + 'xYz', '自定义恢复密钥']) +async def test_correct_key_resets_password(key): + """New, legacy and explicitly configured keys all remain usable verbatim.""" + client, reset_password, _ = await _create_client(stored_key=key) + + resp = await client.post('/api/v1/user/reset-password', json=_payload(key)) + + assert resp.status_code == 200 + assert (await resp.get_json())['code'] == 0 + reset_password.assert_awaited_once_with('admin@example.com', 'NewPass1!') + + +async def test_wrong_key_rejected_without_reset(): + """A wrong recovery key returns 403 and never touches the password.""" + client, reset_password, _ = await _create_client() + + resp = await client.post('/api/v1/user/reset-password', json=_payload(key='WRONG')) + + assert resp.status_code == 403 + reset_password.assert_not_awaited() + + +async def test_non_string_recovery_key_does_not_crash(): + """Malformed recovery-key payloads must be rejected, not raise a 500. + + Constant-time comparison via hmac.compare_digest on bytes requires the + input to be a str; other JSON types must fail closed. + """ + client, reset_password, _ = await _create_client() + + resp = await client.post( + '/api/v1/user/reset-password', + json={'user': 'admin@example.com', 'recovery_key': 12345, 'new_password': 'NewPass1!'}, + ) + + assert resp.status_code == 403 + reset_password.assert_not_awaited() + + +@pytest.mark.parametrize('key', ['奇数密钥不是ASCII', '\ud800', '\udfff']) +async def test_non_ascii_recovery_key_does_not_crash(key): + """Non-ASCII keys must compare safely (encode-based constant-time compare).""" + client, _, _ = await _create_client() + + resp = await client.post( + '/api/v1/user/reset-password', + json={'user': 'admin@example.com', 'recovery_key': key, 'new_password': 'NewPass1!'}, + ) + + assert resp.status_code == 403 + + +async def test_quota_exhausted_after_max_attempts(): + """After MAX admitted attempts even a correct key must be rejected with 429 (#2392). + + Every admission consumes quota regardless of outcome; the legacy endpoint + accepted every guess independently, exhausting the 24-bit keyspace via bursts. + """ + client, reset_password, _ = await _create_client() + + for _ in range(user_module._MAX_RESET_ATTEMPTS_PER_WINDOW): + resp = await client.post('/api/v1/user/reset-password', json=_payload(key='WRONG')) + assert resp.status_code == 403 + + # The very next request carries the CORRECT key but has no quota left. + resp = await client.post('/api/v1/user/reset-password', json=_payload()) + assert resp.status_code == 429 + reset_password.assert_not_awaited() + + +async def test_quota_rejects_before_touching_user_lookup(): + """An exhausted quota must reject early, before the sleep and any service calls.""" + client, _, get_user_by_email = await _create_client() + + user_module._reset_password_state['attempts'] = user_module._MAX_RESET_ATTEMPTS_PER_WINDOW + user_module._reset_password_state['window_started_at'] = time.monotonic() + + resp = await client.post('/api/v1/user/reset-password', json=_payload()) + + assert resp.status_code == 429 + get_user_by_email.assert_not_awaited() + + +async def test_window_rolls_over_and_admits_again(): + """Once the fixed window elapses, the quota resets and a correct key works again.""" + client, reset_password, _ = await _create_client() + + user_module._reset_password_state['attempts'] = user_module._MAX_RESET_ATTEMPTS_PER_WINDOW + user_module._reset_password_state['window_started_at'] = time.monotonic() - user_module._RESET_WINDOW_SECONDS - 1 + + resp = await client.post('/api/v1/user/reset-password', json=_payload()) + + assert resp.status_code == 200 + reset_password.assert_awaited_once() + + +async def test_success_does_not_restore_quota(): + """A successful reset does NOT restore quota: brute-force budget survives wins (#2392). + + The legacy clear-on-success let attackers interleave correct-looking states; + success only proves knowledge of the key once, it must not refill attempts. + """ + client, _, _ = await _create_client() + + for _ in range(user_module._MAX_RESET_ATTEMPTS_PER_WINDOW - 1): + resp = await client.post('/api/v1/user/reset-password', json=_payload(key='WRONG')) + assert resp.status_code == 403 + + # Last slot is spent on the genuine reset. + resp = await client.post('/api/v1/user/reset-password', json=_payload()) + assert resp.status_code == 200 + + # Quota is exhausted; even a correct key waits for the next window. + resp = await client.post('/api/v1/user/reset-password', json=_payload()) + assert resp.status_code == 429 + + +async def test_concurrent_burst_cannot_bypass_quota(monkeypatch): + """A 20-request burst yields exactly {403: 5, 429: 15} (#2392 regression). + + The vulnerable version accounted failures after several awaits, letting all + concurrent requests pass the gate ({403: 20}). Admission is now synchronous + and await-free, so total admissions are capped regardless of scheduling. + """ + + # Swap the AsyncMock sleep for a real cooperative yield so tasks actually + # interleave mid-handler like they do under production load. + async def _yield_sleep(_seconds): + await asyncio.sleep(0) + + monkeypatch.setattr(user_module, 'asyncio', SimpleNamespace(sleep=_yield_sleep)) + + client, reset_password, _ = await _create_client() + + responses = await asyncio.gather( + *(client.post('/api/v1/user/reset-password', json=_payload(key='WRONG')) for _ in range(20)) + ) + + status_counts: dict[int, int] = {} + for resp in responses: + status_counts[resp.status_code] = status_counts.get(resp.status_code, 0) + 1 + + assert status_counts == {403: 5, 429: 15} + reset_password.assert_not_awaited() diff --git a/web/src/app/reset-password/page.tsx b/web/src/app/reset-password/page.tsx index 321127bad..104a76da5 100644 --- a/web/src/app/reset-password/page.tsx +++ b/web/src/app/reset-password/page.tsx @@ -7,12 +7,6 @@ import { CardTitle, CardDescription, } from '@/components/ui/card'; -import { - InputOTP, - InputOTPGroup, - InputOTPSlot, - InputOTPSeparator, -} from '@/components/ui/input-otp'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import * as z from 'zod'; @@ -28,14 +22,12 @@ import { import { useState } from 'react'; import { httpClient } from '@/app/infra/http/HttpClient'; import { useNavigate } from 'react-router-dom'; -import { Mail, Lock, ArrowLeft } from 'lucide-react'; +import { Mail, Lock, ArrowLeft, KeyRound } from 'lucide-react'; import { toast } from 'sonner'; import { useTranslation } from 'react-i18next'; import { Link } from 'react-router-dom'; import { ThemeToggle } from '@/components/ui/theme-toggle'; -const REGEXP_ONLY_DIGITS_AND_CHARS = /^[0-9a-zA-Z]+$/; - const formSchema = (t: (key: string) => string) => z.object({ email: z.string().email(t('common.invalidEmail')), @@ -136,28 +128,17 @@ export default function ResetPassword() { {t('resetPassword.recoveryKeyDescription')} - { - // 将输入的值转换为大写 - const upperValue = value.toUpperCase(); - field.onChange(upperValue); - }} - > - - - - - - - - - - - - + {/* Recovery keys are case-sensitive base64url strings; send them verbatim */} +
+ + +
diff --git a/web/tests/e2e/reset-password.spec.ts b/web/tests/e2e/reset-password.spec.ts new file mode 100644 index 000000000..96170f386 --- /dev/null +++ b/web/tests/e2e/reset-password.spec.ts @@ -0,0 +1,122 @@ +import { expect, test, type Page } from '@playwright/test'; + +import { installLangBotApiMocks } from './fixtures/langbot-api'; + +const resetEndpoint = '**/api/v1/user/reset-password'; +const email = 'reset-password@example.com'; +const newPassword = 'Regression-password-2026!'; +const successMessage = 'Password reset successfully, please login'; +const failureMessage = + 'Password reset failed, please check your email and recovery key'; + +async function fillResetForm(page: Page, recoveryKey: string) { + await page.goto('/reset-password'); + await page.getByPlaceholder('Enter email address').fill(email); + const recoveryInput = page.getByPlaceholder('Enter recovery key'); + await recoveryInput.fill(recoveryKey); + await expect(recoveryInput).toHaveValue(recoveryKey); + await page.getByPlaceholder('Enter new password').fill(newPassword); +} + +test.beforeEach(async ({ page }) => { + await installLangBotApiMocks(page, { authenticated: false }); +}); + +const recoveryKeys = [ + { name: 'eight-character recovery code', value: '2A3B4C5D' }, + { name: 'six-character legacy recovery key', value: 'ABC123' }, + { + name: '43-character mixed-case base64url recovery key', + value: 'aB-_'.repeat(10) + 'xYz', + }, +]; + +for (const { name, value } of recoveryKeys) { + test(`submits the ${name} verbatim and returns to login`, async ({ + page, + }) => { + const requests: { method: string; body: unknown }[] = []; + await page.route(resetEndpoint, async (route) => { + requests.push({ + method: route.request().method(), + body: route.request().postDataJSON(), + }); + await route.fulfill({ + status: 200, + json: { code: 0, msg: 'ok', data: { user: email } }, + }); + }); + + await fillResetForm(page, value); + await page + .getByRole('button', { name: 'Reset Password', exact: true }) + .click(); + + await expect(page).toHaveURL(/\/login$/); + await expect(page.getByText(successMessage, { exact: true })).toBeVisible(); + await expect( + page.getByRole('button', { name: 'Login with password', exact: true }), + ).toBeVisible(); + expect(requests).toEqual([ + { + method: 'POST', + body: { user: email, recovery_key: value, new_password: newPassword }, + }, + ]); + await expect(page.getByText(failureMessage, { exact: true })).toHaveCount( + 0, + ); + }); +} + +test('HTTP 429 shows failure, stays on reset-password, and reenables submission', async ({ + page, +}) => { + const recoveryKey = '2A3B4C5D'; + const requests: { method: string; body: unknown }[] = []; + let releaseResponse!: () => void; + const responseGate = new Promise((resolve) => { + releaseResponse = resolve; + }); + await page.route(resetEndpoint, async (route) => { + requests.push({ + method: route.request().method(), + body: route.request().postDataJSON(), + }); + await responseGate; + await route.fulfill({ + status: 429, + json: { code: -1, msg: 'Too many attempts, try again later' }, + }); + }); + + await fillResetForm(page, recoveryKey); + const submit = page.locator('button[type="submit"]'); + await submit.click(); + try { + await expect.poll(() => requests.length).toBe(1); + await expect(submit).toBeDisabled(); + await expect(submit).toHaveText('Resetting...'); + } finally { + releaseResponse(); + } + + await expect(page.getByText(failureMessage, { exact: true })).toBeVisible(); + await expect(submit).toBeEnabled(); + await expect(submit).toHaveText('Reset Password'); + await expect(page).toHaveURL(/\/reset-password$/); + await expect(page.getByText(successMessage, { exact: true })).toHaveCount(0); + await expect(page.getByPlaceholder('Enter recovery key')).toHaveValue( + recoveryKey, + ); + expect(requests).toEqual([ + { + method: 'POST', + body: { + user: email, + recovery_key: recoveryKey, + new_password: newPassword, + }, + }, + ]); +});