c39d555e6f
- AuthController::useRecoveryCode перебирает unused codes через Hash::check, нормализация (lowercase + remove dash/space)
- UserRecoveryCode Eloquent (UPDATED_AT=null — schema без updated_at)
- Rate-limit auth:recovery:{pending_user_id}|{ip} (5/15мин)
- Returns recovery_codes_remaining для UI-warning'а (sessionStorage на frontend)
- UseRecoveryCodeView.vue → POST /api/auth/2fa/recovery-use, /recovery-use route, autocomplete=one-time-code
- TwoFactorView "резервный код" ссылка /recovery → /recovery-use
- Pest +6 RecoveryCodeTest (91/91 за 12.77с, 319 assertions)
- Vitest +6 (166/166 за 11.47с)
- TODO: #3 2FA setup wizard (после этого /recovery view получит реальный source данных)
- Регресс: lint+type+format OK; build 849ms; story:build 21/28 за 30.36с; Pint+Stan passed
- CLAUDE.md v1.38→v1.39, реестр v1.47→v1.48
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
345 lines
12 KiB
TypeScript
345 lines
12 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||
import { createPinia, setActivePinia } from 'pinia';
|
||
|
||
// Мокаем api/auth до import'а auth-store.
|
||
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(),
|
||
useRecoveryCode: vi.fn(),
|
||
}));
|
||
|
||
// Мокаем client (extractRateLimitRetry) — иначе axios.isAxiosError в jsdom возвращает false для plain Error'ов.
|
||
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 * as apiClient from '../../resources/js/api/client';
|
||
import { useAuthStore } from '../../resources/js/stores/auth';
|
||
|
||
describe('useAuthStore', () => {
|
||
beforeEach(() => {
|
||
setActivePinia(createPinia());
|
||
vi.clearAllMocks();
|
||
});
|
||
|
||
it('initial state: user=null, isAuthenticated=false, requires2fa=false', () => {
|
||
const auth = useAuthStore();
|
||
expect(auth.user).toBeNull();
|
||
expect(auth.isAuthenticated).toBe(false);
|
||
expect(auth.requires2fa).toBe(false);
|
||
expect(auth.loading).toBe(false);
|
||
});
|
||
|
||
it('login() при requires_2fa=true НЕ ставит user (pending state) — isAuthenticated остаётся false', async () => {
|
||
vi.mocked(authApi.login).mockResolvedValue({
|
||
user: {
|
||
id: 1,
|
||
email: 'test@example.ru',
|
||
first_name: 'T',
|
||
last_name: 'U',
|
||
tenant_id: 1,
|
||
totp_enabled: true,
|
||
last_login_at: '2026-05-08T12:00:00Z',
|
||
},
|
||
requires_2fa: true,
|
||
});
|
||
|
||
const auth = useAuthStore();
|
||
await auth.login({ email: 'test@example.ru', password: 'pass1234' });
|
||
|
||
// user НЕ ставится при 2FA — backend ещё не сделал Auth::login.
|
||
expect(auth.user).toBeNull();
|
||
expect(auth.isAuthenticated).toBe(false);
|
||
expect(auth.requires2fa).toBe(true);
|
||
});
|
||
|
||
it('login() при requires_2fa=false ставит user — isAuthenticated=true', async () => {
|
||
vi.mocked(authApi.login).mockResolvedValue({
|
||
user: {
|
||
id: 2,
|
||
email: 'no2fa@example.ru',
|
||
first_name: 'N',
|
||
last_name: 'U',
|
||
tenant_id: 1,
|
||
totp_enabled: false,
|
||
last_login_at: null,
|
||
},
|
||
requires_2fa: false,
|
||
});
|
||
|
||
const auth = useAuthStore();
|
||
await auth.login({ email: 'no2fa@example.ru', password: 'pass1234' });
|
||
|
||
expect(auth.user?.email).toBe('no2fa@example.ru');
|
||
expect(auth.isAuthenticated).toBe(true);
|
||
expect(auth.requires2fa).toBe(false);
|
||
});
|
||
|
||
it('verifyTwoFactor() с правильным кодом ставит user + сбрасывает requires2fa', async () => {
|
||
vi.mocked(authApi.verifyTwoFactor).mockResolvedValue({
|
||
user: {
|
||
id: 5,
|
||
email: '2fa-done@example.ru',
|
||
first_name: '2',
|
||
last_name: 'F',
|
||
tenant_id: 1,
|
||
totp_enabled: true,
|
||
last_login_at: '2026-05-08T12:00:00Z',
|
||
},
|
||
requires_2fa: false,
|
||
});
|
||
|
||
const auth = useAuthStore();
|
||
// Эмулируем pending-state после login.
|
||
auth.requires2fa = true;
|
||
await auth.verifyTwoFactor('123456');
|
||
|
||
expect(auth.user?.email).toBe('2fa-done@example.ru');
|
||
expect(auth.isAuthenticated).toBe(true);
|
||
expect(auth.requires2fa).toBe(false);
|
||
expect(authApi.verifyTwoFactor).toHaveBeenCalledWith('123456');
|
||
});
|
||
|
||
it('verifyTwoFactor() при reject пробрасывает + НЕ ставит user', async () => {
|
||
vi.mocked(authApi.verifyTwoFactor).mockRejectedValue(new Error('Invalid code'));
|
||
|
||
const auth = useAuthStore();
|
||
auth.requires2fa = true;
|
||
await expect(auth.verifyTwoFactor('000000')).rejects.toThrow();
|
||
|
||
expect(auth.user).toBeNull();
|
||
expect(auth.requires2fa).toBe(true);
|
||
});
|
||
|
||
it('login() → reject пробрасывает ошибку и оставляет user=null', async () => {
|
||
vi.mocked(authApi.login).mockRejectedValue(new Error('Invalid credentials'));
|
||
|
||
const auth = useAuthStore();
|
||
await expect(auth.login({ email: 'bad@example.ru', password: 'wrong' })).rejects.toThrow();
|
||
|
||
expect(auth.user).toBeNull();
|
||
expect(auth.isAuthenticated).toBe(false);
|
||
});
|
||
|
||
it('register() → success ставит user', async () => {
|
||
vi.mocked(authApi.register).mockResolvedValue({
|
||
user: {
|
||
id: 2,
|
||
email: 'new@example.ru',
|
||
first_name: 'N',
|
||
last_name: 'U',
|
||
tenant_id: 1,
|
||
totp_enabled: false,
|
||
last_login_at: null,
|
||
},
|
||
requires_2fa: false,
|
||
});
|
||
|
||
const auth = useAuthStore();
|
||
await auth.register({
|
||
email: 'new@example.ru',
|
||
password: 'pass1234',
|
||
accept_offer: true,
|
||
accept_pdn: true,
|
||
});
|
||
|
||
expect(auth.user?.email).toBe('new@example.ru');
|
||
expect(auth.isAuthenticated).toBe(true);
|
||
});
|
||
|
||
it('fetchMe() → success возвращает user + ставит state', async () => {
|
||
vi.mocked(authApi.me).mockResolvedValue({
|
||
id: 5,
|
||
email: 'me@example.ru',
|
||
first_name: 'M',
|
||
last_name: 'E',
|
||
tenant_id: 1,
|
||
totp_enabled: false,
|
||
last_login_at: null,
|
||
});
|
||
|
||
const auth = useAuthStore();
|
||
const result = await auth.fetchMe();
|
||
|
||
expect(result?.email).toBe('me@example.ru');
|
||
expect(auth.user?.email).toBe('me@example.ru');
|
||
});
|
||
|
||
it('fetchMe() → 401 НЕ throw, возвращает null + чистит user', async () => {
|
||
vi.mocked(authApi.me).mockRejectedValue(new Error('Unauthenticated'));
|
||
|
||
const auth = useAuthStore();
|
||
const result = await auth.fetchMe();
|
||
|
||
expect(result).toBeNull();
|
||
expect(auth.user).toBeNull();
|
||
});
|
||
|
||
it('login() при 429 кладёт retry_after в lockoutSeconds + пробрасывает ошибку', async () => {
|
||
vi.mocked(authApi.login).mockRejectedValue({ response: { status: 429, data: { retry_after: 600 } } });
|
||
vi.mocked(apiClient.extractRateLimitRetry).mockReturnValueOnce(600);
|
||
|
||
const auth = useAuthStore();
|
||
await expect(auth.login({ email: 'lock@example.ru', password: 'whatever-pass' })).rejects.toBeDefined();
|
||
|
||
expect(auth.lockoutSeconds).toBe(600);
|
||
expect(auth.user).toBeNull();
|
||
});
|
||
|
||
it('verifyTwoFactor() при 429 кладёт retry_after в lockoutSeconds', async () => {
|
||
vi.mocked(authApi.verifyTwoFactor).mockRejectedValue({ response: { status: 429 } });
|
||
vi.mocked(apiClient.extractRateLimitRetry).mockReturnValueOnce(900);
|
||
|
||
const auth = useAuthStore();
|
||
auth.requires2fa = true;
|
||
await expect(auth.verifyTwoFactor('000000')).rejects.toBeDefined();
|
||
|
||
expect(auth.lockoutSeconds).toBe(900);
|
||
});
|
||
|
||
it('успешный login() сбрасывает lockoutSeconds', async () => {
|
||
vi.mocked(authApi.login).mockResolvedValue({
|
||
user: {
|
||
id: 1,
|
||
email: 'ok@example.ru',
|
||
first_name: 'O',
|
||
last_name: 'K',
|
||
tenant_id: 1,
|
||
totp_enabled: false,
|
||
last_login_at: null,
|
||
},
|
||
requires_2fa: false,
|
||
});
|
||
|
||
const auth = useAuthStore();
|
||
// Эмулируем предыдущий lockout.
|
||
auth.lockoutSeconds = 300;
|
||
await auth.login({ email: 'ok@example.ru', password: 'pass1234' });
|
||
|
||
expect(auth.lockoutSeconds).toBeNull();
|
||
});
|
||
|
||
it('requestPasswordReset() → success возвращает message без изменения user-state', async () => {
|
||
vi.mocked(authApi.forgotPassword).mockResolvedValue({
|
||
message: 'Если такой email зарегистрирован — ссылка отправлена.',
|
||
});
|
||
|
||
const auth = useAuthStore();
|
||
const result = await auth.requestPasswordReset('forgot@example.ru');
|
||
|
||
expect(result.message).toContain('ссылка отправлена');
|
||
expect(authApi.forgotPassword).toHaveBeenCalledWith('forgot@example.ru');
|
||
// user-state не меняется.
|
||
expect(auth.user).toBeNull();
|
||
expect(auth.isAuthenticated).toBe(false);
|
||
});
|
||
|
||
it('requestPasswordReset() при 429 кладёт retry_after в lockoutSeconds', async () => {
|
||
vi.mocked(authApi.forgotPassword).mockRejectedValue({ response: { status: 429 } });
|
||
vi.mocked(apiClient.extractRateLimitRetry).mockReturnValueOnce(900);
|
||
|
||
const auth = useAuthStore();
|
||
await expect(auth.requestPasswordReset('locked@example.ru')).rejects.toBeDefined();
|
||
|
||
expect(auth.lockoutSeconds).toBe(900);
|
||
});
|
||
|
||
it('resetPassword() success возвращает message без auth-state', async () => {
|
||
vi.mocked(authApi.resetPassword).mockResolvedValue({ message: 'Пароль успешно изменён.' });
|
||
|
||
const auth = useAuthStore();
|
||
const result = await auth.resetPassword({
|
||
token: 'abc',
|
||
email: 'a@b.ru',
|
||
password: 'new-strong-pass-123',
|
||
password_confirmation: 'new-strong-pass-123',
|
||
});
|
||
|
||
expect(result.message).toContain('успешно');
|
||
expect(auth.user).toBeNull();
|
||
});
|
||
|
||
it('resetPassword() при 429 кладёт retry_after в lockoutSeconds', async () => {
|
||
vi.mocked(authApi.resetPassword).mockRejectedValue({ response: { status: 429 } });
|
||
vi.mocked(apiClient.extractRateLimitRetry).mockReturnValueOnce(900);
|
||
|
||
const auth = useAuthStore();
|
||
await expect(
|
||
auth.resetPassword({
|
||
token: 'bad',
|
||
email: 'a@b.ru',
|
||
password: 'new-strong-pass-123',
|
||
password_confirmation: 'new-strong-pass-123',
|
||
}),
|
||
).rejects.toBeDefined();
|
||
|
||
expect(auth.lockoutSeconds).toBe(900);
|
||
});
|
||
|
||
it('useRecoveryCode() success ставит user + сбрасывает requires2fa', async () => {
|
||
vi.mocked(authApi.useRecoveryCode).mockResolvedValue({
|
||
user: {
|
||
id: 7,
|
||
email: 'recovery@example.ru',
|
||
first_name: 'R',
|
||
last_name: 'C',
|
||
tenant_id: 1,
|
||
totp_enabled: true,
|
||
last_login_at: '2026-05-09T12:00:00Z',
|
||
},
|
||
requires_2fa: false,
|
||
recovery_codes_remaining: 3,
|
||
});
|
||
|
||
const auth = useAuthStore();
|
||
auth.requires2fa = true;
|
||
const result = await auth.useRecoveryCode('ABCD-1234');
|
||
|
||
expect(result.recovery_codes_remaining).toBe(3);
|
||
expect(auth.user?.email).toBe('recovery@example.ru');
|
||
expect(auth.requires2fa).toBe(false);
|
||
});
|
||
|
||
it('useRecoveryCode() при reject пробрасывает + НЕ ставит user', async () => {
|
||
vi.mocked(authApi.useRecoveryCode).mockRejectedValue(new Error('Invalid code'));
|
||
|
||
const auth = useAuthStore();
|
||
auth.requires2fa = true;
|
||
await expect(auth.useRecoveryCode('WRONG-1234')).rejects.toThrow();
|
||
|
||
expect(auth.user).toBeNull();
|
||
expect(auth.requires2fa).toBe(true);
|
||
});
|
||
|
||
it('logout() очищает user даже если API упал', async () => {
|
||
vi.mocked(authApi.logout).mockRejectedValue(new Error('Network error'));
|
||
|
||
const auth = useAuthStore();
|
||
// Сначала задаём user.
|
||
auth.user = {
|
||
id: 1,
|
||
email: 'x@example.ru',
|
||
first_name: 'X',
|
||
last_name: 'X',
|
||
tenant_id: 1,
|
||
totp_enabled: false,
|
||
last_login_at: null,
|
||
};
|
||
|
||
await auth.logout();
|
||
|
||
expect(auth.user).toBeNull();
|
||
expect(auth.requires2fa).toBe(false);
|
||
});
|
||
});
|