2026-05-08 19:59:43 +03:00
|
|
|
|
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(),
|
2026-05-08 20:14:33 +03:00
|
|
|
|
verifyTwoFactor: vi.fn(),
|
2026-05-08 21:10:28 +03:00
|
|
|
|
forgotPassword: vi.fn(),
|
2026-05-09 03:36:27 +03:00
|
|
|
|
resetPassword: vi.fn(),
|
2026-05-09 03:43:58 +03:00
|
|
|
|
useRecoveryCode: vi.fn(),
|
2026-05-08 19:59:43 +03:00
|
|
|
|
}));
|
|
|
|
|
|
|
2026-05-08 20:49:47 +03:00
|
|
|
|
// Мокаем 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(),
|
|
|
|
|
|
}));
|
|
|
|
|
|
|
2026-05-08 19:59:43 +03:00
|
|
|
|
import * as authApi from '../../resources/js/api/auth';
|
2026-05-08 20:49:47 +03:00
|
|
|
|
import * as apiClient from '../../resources/js/api/client';
|
2026-05-08 19:59:43 +03:00
|
|
|
|
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);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-08 20:14:33 +03:00
|
|
|
|
it('login() при requires_2fa=true НЕ ставит user (pending state) — isAuthenticated остаётся false', async () => {
|
2026-05-08 19:59:43 +03:00
|
|
|
|
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' });
|
|
|
|
|
|
|
2026-05-08 20:14:33 +03:00
|
|
|
|
// 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');
|
2026-05-08 19:59:43 +03:00
|
|
|
|
expect(auth.isAuthenticated).toBe(true);
|
2026-05-08 20:14:33 +03:00
|
|
|
|
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();
|
2026-05-08 19:59:43 +03:00
|
|
|
|
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();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-08 20:49:47 +03:00
|
|
|
|
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();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-08 21:10:28 +03:00
|
|
|
|
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);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-09 03:36:27 +03:00
|
|
|
|
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);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-09 03:43:58 +03:00
|
|
|
|
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);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-08 19:59:43 +03:00
|
|
|
|
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);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|