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,
+ },
+ },
+ ]);
+});