170382878b
- AuthController::forgotPassword использует Password::sendResetLink (anti-enumeration: всегда 200)
- AUTH_PASSWORD_RESET_TOKEN_TABLE=password_resets — указывает на нашу таблицу из schema v8.7
- Rate-limit 5/15мин по auth:forgot:{email}|{ip} — hit ставится ДО sendResetLink (защита перебора через unknown email)
- Frontend: authApi.forgotPassword, auth-store.requestPasswordReset, ForgotPasswordView success-state
- Pest +6 в ForgotPasswordTest (79/79 за 10.55с, 273 assertions)
- Vitest +4 (153/153 за 11.11с)
- TODO: POST /api/auth/reset-password + UI-форма new_password (deep-link)
- Регресс: lint+type+format OK; build 862ms; story:build 21/28 за 32с; Pint+Stan passed
- CLAUDE.md v1.36→v1.37, реестр v1.45→v1.46
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
99 lines
3.8 KiB
TypeScript
99 lines
3.8 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||
import { mount } from '@vue/test-utils';
|
||
import { createPinia, setActivePinia } from 'pinia';
|
||
import { createVuetify } from 'vuetify';
|
||
import { createRouter, createMemoryHistory } from 'vue-router';
|
||
|
||
// Мокаем api/auth до import'а ForgotPasswordView (внутри useAuthStore).
|
||
vi.mock('../../resources/js/api/auth', () => ({
|
||
login: vi.fn(),
|
||
register: vi.fn(),
|
||
me: vi.fn(),
|
||
logout: vi.fn(),
|
||
verifyTwoFactor: vi.fn(),
|
||
forgotPassword: vi.fn(),
|
||
}));
|
||
|
||
vi.mock('../../resources/js/api/client', () => ({
|
||
extractRateLimitRetry: vi.fn(() => null),
|
||
extractValidationErrors: vi.fn(() => null),
|
||
extractErrorMessage: vi.fn(() => 'Ошибка'),
|
||
apiClient: {},
|
||
ensureCsrfCookie: vi.fn(),
|
||
}));
|
||
|
||
import * as authApi from '../../resources/js/api/auth';
|
||
import { useAuthStore } from '../../resources/js/stores/auth';
|
||
import ForgotPasswordView from '../../resources/js/views/auth/ForgotPasswordView.vue';
|
||
|
||
const mountForgot = async () => {
|
||
const pinia = createPinia();
|
||
setActivePinia(pinia);
|
||
const router = createRouter({
|
||
history: createMemoryHistory(),
|
||
routes: [
|
||
{ path: '/forgot', component: ForgotPasswordView },
|
||
{ path: '/login', name: 'login', component: { template: '<div>stub</div>' } },
|
||
],
|
||
});
|
||
await router.push('/forgot');
|
||
await router.isReady();
|
||
return mount(ForgotPasswordView, {
|
||
global: { plugins: [pinia, createVuetify(), router] },
|
||
});
|
||
};
|
||
|
||
describe('ForgotPasswordView.vue', () => {
|
||
beforeEach(() => {
|
||
vi.clearAllMocks();
|
||
});
|
||
|
||
it('монтируется и содержит заголовок «Сброс пароля»', async () => {
|
||
const wrapper = await mountForgot();
|
||
expect(wrapper.text()).toContain('Сброс пароля');
|
||
});
|
||
|
||
it('содержит rate-limit alert с лимитом 5 попыток / 15 минут', async () => {
|
||
const wrapper = await mountForgot();
|
||
const text = wrapper.text();
|
||
expect(text).toContain('5 попыток в 15 минут');
|
||
});
|
||
|
||
it('содержит email-input и кнопку «Отправить ссылку»', async () => {
|
||
const wrapper = await mountForgot();
|
||
expect(wrapper.find('input[type="email"]').exists()).toBe(true);
|
||
expect(wrapper.text()).toContain('Отправить ссылку');
|
||
});
|
||
|
||
it('после успешного submit показывает success-state и скрывает форму', async () => {
|
||
vi.mocked(authApi.forgotPassword).mockResolvedValue({
|
||
message: 'Если такой email зарегистрирован — мы отправили ссылку для сброса пароля.',
|
||
});
|
||
|
||
const wrapper = await mountForgot();
|
||
const input = wrapper.find('input[type="email"]');
|
||
await input.setValue('user@example.ru');
|
||
await wrapper.find('form').trigger('submit.prevent');
|
||
|
||
// Дожидаемся async submit.
|
||
await wrapper.vm.$nextTick();
|
||
await wrapper.vm.$nextTick();
|
||
|
||
expect(wrapper.find('[data-testid="forgot-success"]').exists()).toBe(true);
|
||
// Форма скрыта после submit.
|
||
expect(wrapper.find('form').exists()).toBe(false);
|
||
expect(authApi.forgotPassword).toHaveBeenCalledWith('user@example.ru');
|
||
});
|
||
|
||
it('при 429 показывает lockout-alert через auth.lockoutSeconds', async () => {
|
||
const wrapper = await mountForgot();
|
||
const auth = useAuthStore();
|
||
auth.lockoutSeconds = 600;
|
||
await wrapper.vm.$nextTick();
|
||
|
||
const alert = wrapper.find('[data-testid="lockout-alert"]');
|
||
expect(alert.exists()).toBe(true);
|
||
expect(alert.text()).toContain('10 мин');
|
||
});
|
||
});
|