9c488122a1
- AuthController::resetPassword через Password::reset() (callback пишет password_hash)
- ResetPasswordRequest: token + email + password (min 10 по ТЗ §22.4.1) + confirmed
- Rate-limit auth:reset:{sha256(token)[0..16]}|{ip} (5/15мин)
- ResetPasswordView для deep-link /reset/:token?email=...; pre-fill email из query; success → redirect /login через 3 сек
- Vue Router /reset/:token (guestOnly); web.php /reset SPA-path
- DB FIX: config/database.php pgsql.timezone=UTC — без него PG TIMESTAMPTZ +03 терялся при Carbon::parse и tokenExpired ошибочно срабатывал
- Pest +6 ResetPasswordTest (85/85 за 11.50с, 291 assertions)
- Vitest +7 (160/160 за 11.02с)
- Регресс: lint+type+format OK; build 784ms; story:build 21/28 за 30.74с; Pint+Stan passed
- CLAUDE.md v1.37→v1.38, реестр v1.46→v1.47
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
107 lines
4.1 KiB
TypeScript
107 lines
4.1 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';
|
||
|
||
vi.mock('../../resources/js/api/auth', () => ({
|
||
login: vi.fn(),
|
||
register: vi.fn(),
|
||
me: vi.fn(),
|
||
logout: vi.fn(),
|
||
verifyTwoFactor: vi.fn(),
|
||
forgotPassword: vi.fn(),
|
||
resetPassword: 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 ResetPasswordView from '../../resources/js/views/auth/ResetPasswordView.vue';
|
||
|
||
const mountReset = async (path = '/reset/abc-token-xyz?email=user@example.ru') => {
|
||
const pinia = createPinia();
|
||
setActivePinia(pinia);
|
||
const router = createRouter({
|
||
history: createMemoryHistory(),
|
||
routes: [
|
||
{ path: '/reset/:token', name: 'reset-password', component: ResetPasswordView },
|
||
{ path: '/login', name: 'login', component: { template: '<div>stub</div>' } },
|
||
],
|
||
});
|
||
await router.push(path);
|
||
await router.isReady();
|
||
return mount(ResetPasswordView, {
|
||
global: { plugins: [pinia, createVuetify(), router] },
|
||
});
|
||
};
|
||
|
||
describe('ResetPasswordView.vue', () => {
|
||
beforeEach(() => {
|
||
vi.clearAllMocks();
|
||
vi.useFakeTimers();
|
||
});
|
||
|
||
it('монтируется и содержит заголовок «Новый пароль»', async () => {
|
||
const wrapper = await mountReset();
|
||
expect(wrapper.text()).toContain('Новый пароль');
|
||
});
|
||
|
||
it('предзаполняет email из query-параметра', async () => {
|
||
const wrapper = await mountReset('/reset/some-token?email=preset@example.ru');
|
||
const emailInput = wrapper.find('input[type="email"]');
|
||
expect((emailInput.element as HTMLInputElement).value).toBe('preset@example.ru');
|
||
});
|
||
|
||
it('содержит поля пароля + подтверждения с autocomplete=new-password', async () => {
|
||
const wrapper = await mountReset();
|
||
const passwordInputs = wrapper.findAll('input[type="password"]');
|
||
expect(passwordInputs.length).toBe(2);
|
||
passwordInputs.forEach((input) => {
|
||
expect(input.attributes('autocomplete')).toBe('new-password');
|
||
});
|
||
});
|
||
|
||
it('после успешного submit показывает success-state и скрывает форму', async () => {
|
||
vi.mocked(authApi.resetPassword).mockResolvedValue({ message: 'Пароль успешно изменён.' });
|
||
|
||
const wrapper = await mountReset('/reset/valid-token?email=user@example.ru');
|
||
|
||
await wrapper.findAll('input[type="password"]')[0].setValue('new-strong-pass-1234');
|
||
await wrapper.findAll('input[type="password"]')[1].setValue('new-strong-pass-1234');
|
||
await wrapper.find('form').trigger('submit.prevent');
|
||
|
||
await wrapper.vm.$nextTick();
|
||
await wrapper.vm.$nextTick();
|
||
|
||
expect(wrapper.find('[data-testid="reset-success"]').exists()).toBe(true);
|
||
expect(wrapper.find('form').exists()).toBe(false);
|
||
expect(authApi.resetPassword).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
token: 'valid-token',
|
||
email: 'user@example.ru',
|
||
password: 'new-strong-pass-1234',
|
||
password_confirmation: 'new-strong-pass-1234',
|
||
}),
|
||
);
|
||
});
|
||
|
||
it('при 429 показывает lockout-alert через auth.lockoutSeconds', async () => {
|
||
const wrapper = await mountReset();
|
||
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 мин');
|
||
});
|
||
});
|