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 <zhangjinpeng@mail.tuchong.com>
Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
This commit is contained in:
fishzjp
2026-09-07 23:31:54 +08:00
committed by GitHub
parent d6443b10bc
commit 267232c24f
6 changed files with 588 additions and 33 deletions
@@ -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)
+20 -1
View File
@@ -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)'
)
@@ -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()
@@ -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()
+12 -31
View File
@@ -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')}
</FormDescription>
<FormControl>
<InputOTP
maxLength={6}
value={field.value}
pattern={REGEXP_ONLY_DIGITS_AND_CHARS.source}
onChange={(value) => {
// 将输入的值转换为大写
const upperValue = value.toUpperCase();
field.onChange(upperValue);
}}
>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
</InputOTPGroup>
<InputOTPSeparator />
<InputOTPGroup>
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>
{/* Recovery keys are case-sensitive base64url strings; send them verbatim */}
<div className="relative">
<KeyRound className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
<Input
placeholder={t('resetPassword.enterRecoveryKey')}
className="pl-10 font-mono"
autoComplete="off"
spellCheck={false}
{...field}
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
+122
View File
@@ -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<void>((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,
},
},
]);
});