Files
portal/app/tests/Frontend/auth-store.spec.ts
T
Дмитрий bacc7c5e24 feat: G1/SP3a фронт входа — регистрация + подтверждение почты
Переработка register под новый бэкенд SP1 (код на почту), новый ConfirmEmailView, капча-шов, роут /confirm-email. Проверено Playwright: register→код→confirm→dashboard, негатив, fallback email. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 23:33:26 +03:00

343 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 ставит pendingEmail, НЕ user (G1/SP3a)', async () => {
vi.mocked(authApi.register).mockResolvedValue({
status: 'pending_email_confirm',
email: 'new@example.ru',
expires_at: '2026-06-18T12:00:00+00:00',
_dev_plain_code: '123456',
});
const auth = useAuthStore();
await auth.register({
email: 'new@example.ru',
password: 'pass1234',
accept_offer: true,
accept_pdn: true,
captcha_token: 'tok',
});
// Новый поток: сессия создаётся только при confirm-email, не при register.
expect(auth.user).toBeNull();
expect(auth.isAuthenticated).toBe(false);
expect(auth.pendingEmail).toBe('new@example.ru');
expect(auth.pendingDevCode).toBe('123456');
});
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);
});
});