Files
portal/app/tests/Frontend/auth-store.spec.ts
T
Дмитрий c39d555e6f phase2(recovery-code): POST /api/auth/2fa/recovery-use + UseRecoveryCodeView
- 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>
2026-05-09 03:43:58 +03:00

345 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
});
});