improve security: back port, base port, login guard, scripts
This commit is contained in:
+20
-5
@@ -13,8 +13,22 @@ server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass http://server:3000;
|
proxy_pass http://backend:3100;
|
||||||
|
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host $http_host;
|
||||||
|
proxy_set_header Authorization $http_authorization;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
proxy_connect_timeout 10s;
|
||||||
|
proxy_send_timeout 650s;
|
||||||
|
proxy_read_timeout 650s;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /bus/ {
|
||||||
|
proxy_pass http://backend:3100;
|
||||||
|
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
proxy_set_header Connection 'upgrade';
|
proxy_set_header Connection 'upgrade';
|
||||||
@@ -25,11 +39,12 @@ server {
|
|||||||
proxy_read_timeout 650s;
|
proxy_read_timeout 650s;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 3000;
|
listen 3100;
|
||||||
server_name localhost;
|
server_name localhost;
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://backend:3000/;
|
proxy_pass http://backend:3100/;
|
||||||
proxy_set_header Host \$http_host;
|
proxy_set_header Host $http_host;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Logger } from './utils/logger';
|
|||||||
|
|
||||||
const api = axios.create({
|
const api = axios.create({
|
||||||
baseURL: '/api',
|
baseURL: '/api',
|
||||||
|
withCredentials: true, // Отправлять cookies
|
||||||
});
|
});
|
||||||
|
|
||||||
// Interceptor для добавления токена к каждому запросу
|
// Interceptor для добавления токена к каждому запросу
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
/* eslint-disable react-refresh/only-export-components */
|
/* eslint-disable react-refresh/only-export-components */
|
||||||
import React, { createContext, useContext, useState } from 'react';
|
import React, { createContext, useContext, useState } from 'react';
|
||||||
|
import api from '../api';
|
||||||
|
import { Logger } from '../utils/logger';
|
||||||
|
|
||||||
interface AuthContextType {
|
interface AuthContextType {
|
||||||
token: string | null;
|
token: string | null;
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
login: (token: string) => void;
|
login: (token: string) => void;
|
||||||
logout: () => void;
|
logout: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthContext = createContext<AuthContextType | null>(null);
|
const AuthContext = createContext<AuthContextType | null>(null);
|
||||||
@@ -20,17 +22,53 @@ export const useAuth = () => {
|
|||||||
|
|
||||||
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
const [token, setToken] = useState<string | null>(() => {
|
const [token, setToken] = useState<string | null>(() => {
|
||||||
return localStorage.getItem('token');
|
const initialToken = localStorage.getItem('token');
|
||||||
|
Logger.debug('AuthProvider initialized', 'AuthContext', {
|
||||||
|
hasToken: Boolean(initialToken),
|
||||||
|
});
|
||||||
|
return initialToken;
|
||||||
});
|
});
|
||||||
|
|
||||||
const login = (newToken: string) => {
|
const login = (newToken: string) => {
|
||||||
|
Logger.debug('login() called', 'AuthContext', {
|
||||||
|
tokenLength: newToken.length,
|
||||||
|
});
|
||||||
|
// Сохраняем токен в localStorage для обратной совместимости
|
||||||
|
// Основной токен теперь в httpOnly cookie
|
||||||
localStorage.setItem('token', newToken);
|
localStorage.setItem('token', newToken);
|
||||||
setToken(newToken);
|
setToken(newToken);
|
||||||
|
Logger.debug('Token persisted to localStorage and auth state updated', 'AuthContext');
|
||||||
};
|
};
|
||||||
|
|
||||||
const logout = () => {
|
const logout = async () => {
|
||||||
|
Logger.debug('logout() called', 'AuthContext');
|
||||||
|
try {
|
||||||
|
// Вызываем backend для очистки httpOnly cookie
|
||||||
|
await api.post('/auth/logout');
|
||||||
|
Logger.debug('Backend logout request succeeded', 'AuthContext');
|
||||||
|
} catch (error) {
|
||||||
|
const status =
|
||||||
|
typeof error === 'object' &&
|
||||||
|
error !== null &&
|
||||||
|
'response' in error &&
|
||||||
|
typeof (error as { response?: unknown }).response === 'object' &&
|
||||||
|
(error as { response?: unknown }).response !== null
|
||||||
|
? ((error as { response?: { status?: number } }).response?.status ?? null)
|
||||||
|
: null;
|
||||||
|
Logger.warn(
|
||||||
|
'Backend logout request failed, continuing local cleanup',
|
||||||
|
'AuthContext',
|
||||||
|
{ status },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
localStorage.removeItem('token');
|
localStorage.removeItem('token');
|
||||||
setToken(null);
|
setToken(null);
|
||||||
|
Logger.debug('Local auth state cleared', 'AuthContext');
|
||||||
|
|
||||||
|
// Редирект на страницу входа
|
||||||
|
Logger.debug('Redirecting to /login after logout', 'AuthContext');
|
||||||
|
window.location.href = '/login';
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { useNavigate, useLocation } from 'react-router-dom';
|
import { useNavigate, useLocation } from 'react-router-dom';
|
||||||
import api from '../api';
|
import api from '../api';
|
||||||
import { useAuth } from './AuthContext';
|
import { useAuth } from './AuthContext';
|
||||||
@@ -8,19 +8,50 @@ export function AxiosInterceptor() {
|
|||||||
const { logout } = useAuth();
|
const { logout } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const logoutRef = useRef(logout);
|
||||||
|
const navigateRef = useRef(navigate);
|
||||||
|
const pathnameRef = useRef(location.pathname);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
logoutRef.current = logout;
|
||||||
|
}, [logout]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
navigateRef.current = navigate;
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
pathnameRef.current = location.pathname;
|
||||||
|
}, [location.pathname]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Logger.debug('Registering axios response interceptor', 'AxiosInterceptor');
|
||||||
const interceptor = api.interceptors.response.use(
|
const interceptor = api.interceptors.response.use(
|
||||||
(response) => response,
|
(response) => response,
|
||||||
(error) => {
|
async (error) => {
|
||||||
if (error.response && error.response.status === 401) {
|
if (error.response && error.response.status === 401) {
|
||||||
Logger.warn('401 Unauthorized detected → logging out and redirecting to /login', 'AxiosInterceptor');
|
Logger.warn('401 Unauthorized detected → logging out and redirecting to /login', 'AxiosInterceptor');
|
||||||
// Не делаем logout если уже на странице логина
|
// Не делаем logout если уже на странице логина
|
||||||
if (location.pathname !== '/login') {
|
if (pathnameRef.current !== '/login') {
|
||||||
Logger.debug('Calling logout()', 'AxiosInterceptor');
|
try {
|
||||||
logout();
|
Logger.debug('Calling logout()', 'AxiosInterceptor');
|
||||||
Logger.debug('Navigating to /login...', 'AxiosInterceptor');
|
await logoutRef.current();
|
||||||
navigate('/login');
|
Logger.debug('Navigating to /login...', 'AxiosInterceptor');
|
||||||
|
navigateRef.current('/login');
|
||||||
|
} catch (logoutError) {
|
||||||
|
Logger.error(
|
||||||
|
'logout() failed inside interceptor',
|
||||||
|
'AxiosInterceptor',
|
||||||
|
{
|
||||||
|
message:
|
||||||
|
logoutError instanceof Error
|
||||||
|
? logoutError.message
|
||||||
|
: 'unknown error',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Logger.debug('Already on /login, skipping auto-logout flow', 'AxiosInterceptor');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
@@ -28,9 +59,10 @@ export function AxiosInterceptor() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
Logger.debug('Ejecting axios response interceptor', 'AxiosInterceptor');
|
||||||
api.interceptors.response.eject(interceptor);
|
api.interceptors.response.eject(interceptor);
|
||||||
};
|
};
|
||||||
}, [logout, navigate, location.pathname]);
|
}, []);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { useThemeContext } from '../ThemeContext';
|
|||||||
import { useAuth } from '../auth/AuthContext';
|
import { useAuth } from '../auth/AuthContext';
|
||||||
import { Menu as MenuIcon } from '@mui/icons-material';
|
import { Menu as MenuIcon } from '@mui/icons-material';
|
||||||
import { APP_VERSION } from '../utils/version';
|
import { APP_VERSION } from '../utils/version';
|
||||||
|
import { Logger } from '../utils/logger';
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
onMenuClick?: () => void;
|
onMenuClick?: () => void;
|
||||||
@@ -27,11 +28,14 @@ export default function Header({ onMenuClick, isMobile }: HeaderProps) {
|
|||||||
const [confirmDialog, setConfirmDialog] = useState({ open: false, title: '', onConfirm: () => {} });
|
const [confirmDialog, setConfirmDialog] = useState({ open: false, title: '', onConfirm: () => {} });
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
|
Logger.debug('Opening logout confirmation dialog', 'Header');
|
||||||
setConfirmDialog({
|
setConfirmDialog({
|
||||||
open: true,
|
open: true,
|
||||||
title: 'Вы действительно хотите выйти?',
|
title: 'Вы действительно хотите выйти?',
|
||||||
onConfirm: () => {
|
onConfirm: async () => {
|
||||||
logout();
|
Logger.debug('Logout confirmed by user', 'Header');
|
||||||
|
await logout();
|
||||||
|
Logger.debug('logout() resolved in Header', 'Header');
|
||||||
navigate('/login');
|
navigate('/login');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -154,12 +158,26 @@ export default function Header({ onMenuClick, isMobile }: HeaderProps) {
|
|||||||
<Typography>{confirmDialog.title}</Typography>
|
<Typography>{confirmDialog.title}</Typography>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
<Button onClick={() => setConfirmDialog({ ...confirmDialog, open: false })}>Отмена</Button>
|
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
confirmDialog.onConfirm();
|
Logger.debug('Logout canceled by user', 'Header');
|
||||||
setConfirmDialog({ ...confirmDialog, open: false });
|
setConfirmDialog({ ...confirmDialog, open: false });
|
||||||
}}
|
}}
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await confirmDialog.onConfirm();
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error('Logout confirmation action failed', 'Header', {
|
||||||
|
message: error instanceof Error ? error.message : 'unknown error',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setConfirmDialog({ ...confirmDialog, open: false });
|
||||||
|
}
|
||||||
|
}}
|
||||||
variant="contained"
|
variant="contained"
|
||||||
color="error"
|
color="error"
|
||||||
>
|
>
|
||||||
@@ -169,4 +187,4 @@ export default function Header({ onMenuClick, isMobile }: HeaderProps) {
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useNavigate } from 'react-router-dom';
|
|||||||
import api from '../api';
|
import api from '../api';
|
||||||
import { useAuth } from '../auth/AuthContext';
|
import { useAuth } from '../auth/AuthContext';
|
||||||
import { Logger } from '../utils/logger';
|
import { Logger } from '../utils/logger';
|
||||||
import { getApiErrorMessage } from '../utils/errorHandlers';
|
import { getApiErrorMessage, getApiErrorStatus } from '../utils/errorHandlers';
|
||||||
import { APP_VERSION } from '../utils/version';
|
import { APP_VERSION } from '../utils/version';
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
@@ -15,7 +15,10 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
Logger.debug(`Form submit → POST /api/auth/login`, 'Login', { login: creds.login });
|
Logger.debug(`Form submit → POST /api/auth/login`, 'Login', {
|
||||||
|
login: creds.login,
|
||||||
|
hasPassword: Boolean(creds.password),
|
||||||
|
});
|
||||||
try {
|
try {
|
||||||
const res = await api.post('/auth/login', creds);
|
const res = await api.post('/auth/login', creds);
|
||||||
|
|
||||||
@@ -23,11 +26,27 @@ export default function LoginPage() {
|
|||||||
Logger.debug(`Success → token received, calling login()`, 'Login');
|
Logger.debug(`Success → token received, calling login()`, 'Login');
|
||||||
login(token);
|
login(token);
|
||||||
|
|
||||||
|
Logger.debug('Navigating to / after successful login', 'Login');
|
||||||
navigate('/');
|
navigate('/');
|
||||||
} catch (error) {
|
} catch (error: unknown) {
|
||||||
|
const status = getApiErrorStatus(error);
|
||||||
const message = getApiErrorMessage(error, 'Неверный логин или пароль');
|
const message = getApiErrorMessage(error, 'Неверный логин или пароль');
|
||||||
Logger.error(`Error: ${message}`, 'Login');
|
|
||||||
setError('Неверный логин или пароль');
|
// Rate limit error
|
||||||
|
if (status === 429) {
|
||||||
|
Logger.warn('Too many login attempts. Please try again later.', 'Login', {
|
||||||
|
status,
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
setError('Слишком много попыток входа. Попробуйте позже.');
|
||||||
|
} else {
|
||||||
|
const logMethod = status === 401 ? Logger.warn : Logger.error;
|
||||||
|
logMethod('Login failed', 'Login', {
|
||||||
|
status: status ?? 'unknown',
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
setError('Неверный логин или пароль');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||||
import { renderHook, act } from '@testing-library/react'
|
import { renderHook, act } from '@testing-library/react'
|
||||||
import { AuthProvider, useAuth } from '@/auth/AuthContext'
|
import { AuthProvider, useAuth } from '@/auth/AuthContext'
|
||||||
import { ReactNode } from 'react'
|
import { ReactNode } from 'react'
|
||||||
|
import api from '@/api'
|
||||||
|
|
||||||
// Мокаем localStorage
|
// Мокаем localStorage
|
||||||
const localStorageMock = (() => {
|
const localStorageMock = (() => {
|
||||||
@@ -24,25 +25,54 @@ Object.defineProperty(window, 'localStorage', {
|
|||||||
value: localStorageMock,
|
value: localStorageMock,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Мокаем api.post для logout
|
||||||
|
vi.mock('@/api', () => ({
|
||||||
|
default: {
|
||||||
|
post: vi.fn().mockResolvedValue({ data: { success: true } }),
|
||||||
|
interceptors: {
|
||||||
|
request: { use: vi.fn(), eject: vi.fn() },
|
||||||
|
response: { use: vi.fn(), eject: vi.fn() },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||||
<AuthProvider>{children}</AuthProvider>
|
<AuthProvider>{children}</AuthProvider>
|
||||||
)
|
)
|
||||||
|
|
||||||
describe('AuthContext', () => {
|
describe('AuthContext', () => {
|
||||||
|
const originalLocation = window.location
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
localStorageMock.clear()
|
localStorageMock.clear()
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
|
|
||||||
|
Object.defineProperty(window, 'location', {
|
||||||
|
value: {
|
||||||
|
href: 'http://localhost/',
|
||||||
|
},
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
Object.defineProperty(window, 'location', {
|
||||||
|
value: originalLocation,
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('useAuth', () => {
|
describe('useAuth', () => {
|
||||||
it('должен выбрасывать ошибку при использовании вне AuthProvider', () => {
|
it('должен выбрасывать ошибку при использовании вне AuthProvider', () => {
|
||||||
// Отключаем console.error для этого теста
|
// Отключаем console.error для этого теста
|
||||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
|
|
||||||
expect(() => {
|
expect(() => {
|
||||||
renderHook(() => useAuth())
|
renderHook(() => useAuth())
|
||||||
}).toThrow('useAuth must be used within an AuthProvider')
|
}).toThrow('useAuth must be used within an AuthProvider')
|
||||||
|
|
||||||
consoleSpy.mockRestore()
|
consoleSpy.mockRestore()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -50,9 +80,9 @@ describe('AuthContext', () => {
|
|||||||
describe('initial state', () => {
|
describe('initial state', () => {
|
||||||
it('должен инициализироваться с token из localStorage', () => {
|
it('должен инициализироваться с token из localStorage', () => {
|
||||||
localStorageMock.getItem.mockReturnValue('test-token-123')
|
localStorageMock.getItem.mockReturnValue('test-token-123')
|
||||||
|
|
||||||
const { result } = renderHook(() => useAuth(), { wrapper })
|
const { result } = renderHook(() => useAuth(), { wrapper })
|
||||||
|
|
||||||
expect(result.current.token).toBe('test-token-123')
|
expect(result.current.token).toBe('test-token-123')
|
||||||
expect(result.current.isAuthenticated).toBe(true)
|
expect(result.current.isAuthenticated).toBe(true)
|
||||||
expect(localStorageMock.getItem).toHaveBeenCalledWith('token')
|
expect(localStorageMock.getItem).toHaveBeenCalledWith('token')
|
||||||
@@ -60,9 +90,9 @@ describe('AuthContext', () => {
|
|||||||
|
|
||||||
it('должен инициализироваться с null если token отсутствует в localStorage', () => {
|
it('должен инициализироваться с null если token отсутствует в localStorage', () => {
|
||||||
localStorageMock.getItem.mockReturnValue(null)
|
localStorageMock.getItem.mockReturnValue(null)
|
||||||
|
|
||||||
const { result } = renderHook(() => useAuth(), { wrapper })
|
const { result } = renderHook(() => useAuth(), { wrapper })
|
||||||
|
|
||||||
expect(result.current.token).toBe(null)
|
expect(result.current.token).toBe(null)
|
||||||
expect(result.current.isAuthenticated).toBe(false)
|
expect(result.current.isAuthenticated).toBe(false)
|
||||||
})
|
})
|
||||||
@@ -71,11 +101,11 @@ describe('AuthContext', () => {
|
|||||||
describe('login', () => {
|
describe('login', () => {
|
||||||
it('должен сохранять токен в localStorage и state', () => {
|
it('должен сохранять токен в localStorage и state', () => {
|
||||||
const { result } = renderHook(() => useAuth(), { wrapper })
|
const { result } = renderHook(() => useAuth(), { wrapper })
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.login('new-token-456')
|
result.current.login('new-token-456')
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(result.current.token).toBe('new-token-456')
|
expect(result.current.token).toBe('new-token-456')
|
||||||
expect(result.current.isAuthenticated).toBe(true)
|
expect(result.current.isAuthenticated).toBe(true)
|
||||||
expect(localStorageMock.setItem).toHaveBeenCalledWith('token', 'new-token-456')
|
expect(localStorageMock.setItem).toHaveBeenCalledWith('token', 'new-token-456')
|
||||||
@@ -83,41 +113,42 @@ describe('AuthContext', () => {
|
|||||||
|
|
||||||
it('должен обновлять isAuthenticated после login', () => {
|
it('должен обновлять isAuthenticated после login', () => {
|
||||||
const { result } = renderHook(() => useAuth(), { wrapper })
|
const { result } = renderHook(() => useAuth(), { wrapper })
|
||||||
|
|
||||||
expect(result.current.isAuthenticated).toBe(false)
|
expect(result.current.isAuthenticated).toBe(false)
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.login('another-token')
|
result.current.login('another-token')
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(result.current.isAuthenticated).toBe(true)
|
expect(result.current.isAuthenticated).toBe(true)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('logout', () => {
|
describe('logout', () => {
|
||||||
it('должен удалять токен из localStorage и state', () => {
|
it('должен удалять токен из localStorage и state', async () => {
|
||||||
localStorageMock.getItem.mockReturnValue('existing-token')
|
localStorageMock.getItem.mockReturnValue('existing-token')
|
||||||
|
|
||||||
const { result } = renderHook(() => useAuth(), { wrapper })
|
const { result } = renderHook(() => useAuth(), { wrapper })
|
||||||
|
|
||||||
expect(result.current.token).toBe('existing-token')
|
expect(result.current.token).toBe('existing-token')
|
||||||
|
|
||||||
act(() => {
|
await act(async () => {
|
||||||
result.current.logout()
|
await result.current.logout()
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(result.current.token).toBe(null)
|
expect(result.current.token).toBe(null)
|
||||||
expect(result.current.isAuthenticated).toBe(false)
|
expect(result.current.isAuthenticated).toBe(false)
|
||||||
expect(localStorageMock.removeItem).toHaveBeenCalledWith('token')
|
expect(localStorageMock.removeItem).toHaveBeenCalledWith('token')
|
||||||
|
expect(api.post).toHaveBeenCalledWith('/auth/logout')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('должен корректно работать logout когда token уже null', () => {
|
it('должен корректно работать logout когда token уже null', async () => {
|
||||||
const { result } = renderHook(() => useAuth(), { wrapper })
|
const { result } = renderHook(() => useAuth(), { wrapper })
|
||||||
|
|
||||||
act(() => {
|
await act(async () => {
|
||||||
result.current.logout()
|
await result.current.logout()
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(result.current.token).toBe(null)
|
expect(result.current.token).toBe(null)
|
||||||
expect(result.current.isAuthenticated).toBe(false)
|
expect(result.current.isAuthenticated).toBe(false)
|
||||||
expect(localStorageMock.removeItem).toHaveBeenCalledWith('token')
|
expect(localStorageMock.removeItem).toHaveBeenCalledWith('token')
|
||||||
@@ -127,25 +158,25 @@ describe('AuthContext', () => {
|
|||||||
describe('isAuthenticated', () => {
|
describe('isAuthenticated', () => {
|
||||||
it('должен возвращать true когда token существует', () => {
|
it('должен возвращать true когда token существует', () => {
|
||||||
localStorageMock.getItem.mockReturnValue('valid-token')
|
localStorageMock.getItem.mockReturnValue('valid-token')
|
||||||
|
|
||||||
const { result } = renderHook(() => useAuth(), { wrapper })
|
const { result } = renderHook(() => useAuth(), { wrapper })
|
||||||
|
|
||||||
expect(result.current.isAuthenticated).toBe(true)
|
expect(result.current.isAuthenticated).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('должен возвращать false когда token null', () => {
|
it('должен возвращать false когда token null', () => {
|
||||||
localStorageMock.getItem.mockReturnValue(null)
|
localStorageMock.getItem.mockReturnValue(null)
|
||||||
|
|
||||||
const { result } = renderHook(() => useAuth(), { wrapper })
|
const { result } = renderHook(() => useAuth(), { wrapper })
|
||||||
|
|
||||||
expect(result.current.isAuthenticated).toBe(false)
|
expect(result.current.isAuthenticated).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('должен возвращать false когда token пустая строка', () => {
|
it('должен возвращать false когда token пустая строка', () => {
|
||||||
localStorageMock.getItem.mockReturnValue('')
|
localStorageMock.getItem.mockReturnValue('')
|
||||||
|
|
||||||
const { result } = renderHook(() => useAuth(), { wrapper })
|
const { result } = renderHook(() => useAuth(), { wrapper })
|
||||||
|
|
||||||
expect(result.current.isAuthenticated).toBe(false)
|
expect(result.current.isAuthenticated).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -153,45 +184,45 @@ describe('AuthContext', () => {
|
|||||||
describe('context methods', () => {
|
describe('context methods', () => {
|
||||||
it('должен предоставлять метод login', () => {
|
it('должен предоставлять метод login', () => {
|
||||||
const { result } = renderHook(() => useAuth(), { wrapper })
|
const { result } = renderHook(() => useAuth(), { wrapper })
|
||||||
|
|
||||||
expect(result.current.login).toBeDefined()
|
expect(result.current.login).toBeDefined()
|
||||||
expect(typeof result.current.login).toBe('function')
|
expect(typeof result.current.login).toBe('function')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('должен предоставлять метод logout', () => {
|
it('должен предоставлять метод logout', () => {
|
||||||
const { result } = renderHook(() => useAuth(), { wrapper })
|
const { result } = renderHook(() => useAuth(), { wrapper })
|
||||||
|
|
||||||
expect(result.current.logout).toBeDefined()
|
expect(result.current.logout).toBeDefined()
|
||||||
expect(typeof result.current.logout).toBe('function')
|
expect(typeof result.current.logout).toBe('function')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('multiple login/logout cycles', () => {
|
describe('multiple login/logout cycles', () => {
|
||||||
it('должен корректно обрабатывать несколько циклов login/logout', () => {
|
it('должен корректно обрабатывать несколько циклов login/logout', async () => {
|
||||||
const { result } = renderHook(() => useAuth(), { wrapper })
|
const { result } = renderHook(() => useAuth(), { wrapper })
|
||||||
|
|
||||||
// Первый цикл
|
// Первый цикл
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.login('token-1')
|
result.current.login('token-1')
|
||||||
})
|
})
|
||||||
expect(result.current.token).toBe('token-1')
|
expect(result.current.token).toBe('token-1')
|
||||||
|
|
||||||
act(() => {
|
await act(async () => {
|
||||||
result.current.logout()
|
await result.current.logout()
|
||||||
})
|
})
|
||||||
expect(result.current.token).toBe(null)
|
expect(result.current.token).toBe(null)
|
||||||
|
|
||||||
// Второй цикл
|
// Второй цикл
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.login('token-2')
|
result.current.login('token-2')
|
||||||
})
|
})
|
||||||
expect(result.current.token).toBe('token-2')
|
expect(result.current.token).toBe('token-2')
|
||||||
|
|
||||||
act(() => {
|
await act(async () => {
|
||||||
result.current.logout()
|
await result.current.logout()
|
||||||
})
|
})
|
||||||
expect(result.current.token).toBe(null)
|
expect(result.current.token).toBe(null)
|
||||||
|
|
||||||
// Третий цикл
|
// Третий цикл
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.login('token-3')
|
result.current.login('token-3')
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ beforeAll(() => {
|
|||||||
message.includes('[Settings]') ||
|
message.includes('[Settings]') ||
|
||||||
message.includes('[API]') ||
|
message.includes('[API]') ||
|
||||||
message.includes('[Login]') ||
|
message.includes('[Login]') ||
|
||||||
|
message.includes('[AuthContext]') ||
|
||||||
|
message.includes('[AxiosInterceptor]') ||
|
||||||
message.includes('[Rotation]') ||
|
message.includes('[Rotation]') ||
|
||||||
message.includes('[Domains]') ||
|
message.includes('[Domains]') ||
|
||||||
message.includes('[Subs]') ||
|
message.includes('[Subs]') ||
|
||||||
@@ -40,6 +42,8 @@ beforeAll(() => {
|
|||||||
if (
|
if (
|
||||||
message.includes('[Tunnels]') ||
|
message.includes('[Tunnels]') ||
|
||||||
message.includes('[Settings]') ||
|
message.includes('[Settings]') ||
|
||||||
|
message.includes('[AuthContext]') ||
|
||||||
|
message.includes('[AxiosInterceptor]') ||
|
||||||
message.includes('[Domains]') ||
|
message.includes('[Domains]') ||
|
||||||
message.includes('[Subs]') ||
|
message.includes('[Subs]') ||
|
||||||
message.includes('[Scanner]')
|
message.includes('[Scanner]')
|
||||||
@@ -56,8 +60,11 @@ beforeAll(() => {
|
|||||||
if (
|
if (
|
||||||
message.includes('act(...)') ||
|
message.includes('act(...)') ||
|
||||||
message.includes('An update to') ||
|
message.includes('An update to') ||
|
||||||
|
message.includes('Not implemented: navigation to another Document') ||
|
||||||
message.includes('[Login]') ||
|
message.includes('[Login]') ||
|
||||||
message.includes('[Settings]') ||
|
message.includes('[Settings]') ||
|
||||||
|
message.includes('[AuthContext]') ||
|
||||||
|
message.includes('[AxiosInterceptor]') ||
|
||||||
message.includes('[Subs]')
|
message.includes('[Subs]')
|
||||||
) {
|
) {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ services:
|
|||||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
|
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
|
||||||
PORT: ${PORT}
|
PORT: ${PORT}
|
||||||
LOG_LEVEL: ${LOG_LEVEL}
|
LOG_LEVEL: ${LOG_LEVEL}
|
||||||
|
ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-}
|
||||||
ports:
|
ports:
|
||||||
- "${PORT}:${PORT}"
|
- "${PORT}:${PORT}"
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
+45
-10
@@ -246,6 +246,16 @@ DB_PASS=$(openssl rand -base64 12)
|
|||||||
JWT_SECRET=$(openssl rand -base64 32)
|
JWT_SECRET=$(openssl rand -base64 32)
|
||||||
ADMIN_USER=$(openssl rand -base64 8)
|
ADMIN_USER=$(openssl rand -base64 8)
|
||||||
ADMIN_PASS=$(openssl rand -base64 12)
|
ADMIN_PASS=$(openssl rand -base64 12)
|
||||||
|
# Определяем ALLOWED_ORIGINS из домена или IP
|
||||||
|
if [[ -n "${UI_HOST:-}" ]]; then
|
||||||
|
if [[ "$UI_HOST" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||||
|
ALLOWED_ORIGINS="http://${UI_HOST}"
|
||||||
|
else
|
||||||
|
ALLOWED_ORIGINS="https://${UI_HOST}"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
ALLOWED_ORIGINS=""
|
||||||
|
fi
|
||||||
log "Сгенерированы секретные ключи для БД и JWT."
|
log "Сгенерированы секретные ключи для БД и JWT."
|
||||||
|
|
||||||
#################################
|
#################################
|
||||||
@@ -334,6 +344,9 @@ DB_PASSWORD=${DB_PASS}
|
|||||||
DB_NAME=3dp_manager
|
DB_NAME=3dp_manager
|
||||||
ADMIN_LOGIN=${ADMIN_USER}
|
ADMIN_LOGIN=${ADMIN_USER}
|
||||||
ADMIN_PASSWORD=${ADMIN_PASS}
|
ADMIN_PASSWORD=${ADMIN_PASS}
|
||||||
|
PORT=3100
|
||||||
|
LOG_LEVEL=error
|
||||||
|
ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-}
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
if [[ "$USE_SSL" == "true" ]]; then
|
if [[ "$USE_SSL" == "true" ]]; then
|
||||||
@@ -355,7 +368,16 @@ server {
|
|||||||
try_files \$uri \$uri/ /index.html;
|
try_files \$uri \$uri/ /index.html;
|
||||||
}
|
}
|
||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass http://backend:3000/api/;
|
proxy_pass http://backend:3100/api/;
|
||||||
|
proxy_set_header Host \$http_host;
|
||||||
|
proxy_set_header X-Real-IP \$remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||||
|
proxy_connect_timeout 10s;
|
||||||
|
proxy_send_timeout 650s;
|
||||||
|
proxy_read_timeout 650s;
|
||||||
|
}
|
||||||
|
location /bus/ {
|
||||||
|
proxy_pass http://backend:3100/bus/;
|
||||||
proxy_set_header Host \$http_host;
|
proxy_set_header Host \$http_host;
|
||||||
proxy_set_header X-Real-IP \$remote_addr;
|
proxy_set_header X-Real-IP \$remote_addr;
|
||||||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||||
@@ -365,7 +387,7 @@ server {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
server {
|
server {
|
||||||
listen 3000 ssl;
|
listen 3100 ssl;
|
||||||
server_name $UI_HOST;
|
server_name $UI_HOST;
|
||||||
client_max_body_size 50M;
|
client_max_body_size 50M;
|
||||||
|
|
||||||
@@ -373,7 +395,7 @@ server {
|
|||||||
ssl_certificate_key /etc/nginx/certs/privkey.pem;
|
ssl_certificate_key /etc/nginx/certs/privkey.pem;
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://backend:3000/;
|
proxy_pass http://backend:3100/;
|
||||||
proxy_set_header Host \$http_host;
|
proxy_set_header Host \$http_host;
|
||||||
proxy_set_header X-Real-IP \$remote_addr;
|
proxy_set_header X-Real-IP \$remote_addr;
|
||||||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||||
@@ -419,7 +441,7 @@ services:
|
|||||||
JWT_SECRET: ${JWT_SECRET}
|
JWT_SECRET: ${JWT_SECRET}
|
||||||
ADMIN_LOGIN: ${ADMIN_USER}
|
ADMIN_LOGIN: ${ADMIN_USER}
|
||||||
ADMIN_PASSWORD: ${ADMIN_PASS}
|
ADMIN_PASSWORD: ${ADMIN_PASS}
|
||||||
PORT: 3000
|
PORT: 3100
|
||||||
volumes:
|
volumes:
|
||||||
- /etc/hysteria/config.yaml:/etc/hysteria/config.yaml:ro
|
- /etc/hysteria/config.yaml:/etc/hysteria/config.yaml:ro
|
||||||
networks:
|
networks:
|
||||||
@@ -433,7 +455,7 @@ services:
|
|||||||
- backend
|
- backend
|
||||||
ports:
|
ports:
|
||||||
- "${FINAL_PORT}:443"
|
- "${FINAL_PORT}:443"
|
||||||
- "3000:3000"
|
- "3100:3100"
|
||||||
volumes:
|
volumes:
|
||||||
- ./client/nginx-client.conf:/etc/nginx/conf.d/default.conf:ro
|
- ./client/nginx-client.conf:/etc/nginx/conf.d/default.conf:ro
|
||||||
- ${CERT_PATH}:/etc/nginx/certs/fullchain.pem:ro
|
- ${CERT_PATH}:/etc/nginx/certs/fullchain.pem:ro
|
||||||
@@ -465,7 +487,20 @@ server {
|
|||||||
try_files \$uri \$uri/ /index.html;
|
try_files \$uri \$uri/ /index.html;
|
||||||
}
|
}
|
||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass http://backend:3000/api/;
|
proxy_pass http://backend:3100/api/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade \$http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host \$http_host;
|
||||||
|
proxy_cache_bypass \$http_upgrade;
|
||||||
|
proxy_set_header X-Real-IP \$remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||||
|
proxy_connect_timeout 10s;
|
||||||
|
proxy_send_timeout 650s;
|
||||||
|
proxy_read_timeout 650s;
|
||||||
|
}
|
||||||
|
location /bus/ {
|
||||||
|
proxy_pass http://backend:3100/bus/;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Upgrade \$http_upgrade;
|
proxy_set_header Upgrade \$http_upgrade;
|
||||||
proxy_set_header Connection 'upgrade';
|
proxy_set_header Connection 'upgrade';
|
||||||
@@ -479,10 +514,10 @@ server {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
server {
|
server {
|
||||||
listen 3000;
|
listen 3100;
|
||||||
server_name localhost;
|
server_name localhost;
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://backend:3000/;
|
proxy_pass http://backend:3100/;
|
||||||
proxy_set_header Host \$http_host;
|
proxy_set_header Host \$http_host;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -525,7 +560,7 @@ services:
|
|||||||
JWT_SECRET: ${JWT_SECRET}
|
JWT_SECRET: ${JWT_SECRET}
|
||||||
ADMIN_LOGIN: ${ADMIN_USER}
|
ADMIN_LOGIN: ${ADMIN_USER}
|
||||||
ADMIN_PASSWORD: ${ADMIN_PASS}
|
ADMIN_PASSWORD: ${ADMIN_PASS}
|
||||||
PORT: 3000
|
PORT: 3100
|
||||||
volumes:
|
volumes:
|
||||||
- /etc/hysteria/config.yaml:/etc/hysteria/config.yaml:ro
|
- /etc/hysteria/config.yaml:/etc/hysteria/config.yaml:ro
|
||||||
networks:
|
networks:
|
||||||
@@ -539,7 +574,7 @@ services:
|
|||||||
- backend
|
- backend
|
||||||
ports:
|
ports:
|
||||||
- "${FINAL_PORT}:80"
|
- "${FINAL_PORT}:80"
|
||||||
- "3000:3000"
|
- "3100:3100"
|
||||||
volumes:
|
volumes:
|
||||||
- ./client/nginx-client.conf:/etc/nginx/conf.d/default.conf:ro
|
- ./client/nginx-client.conf:/etc/nginx/conf.d/default.conf:ro
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
+2
-2
@@ -50,8 +50,8 @@ COPY --from=scanner-builder /out/RealiTLScanner-linux-64 /usr/local/bin/RealiTLS
|
|||||||
RUN chmod +x /usr/local/bin/RealiTLScanner-linux-64
|
RUN chmod +x /usr/local/bin/RealiTLScanner-linux-64
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV PORT=3000
|
ENV PORT=3100
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3100
|
||||||
|
|
||||||
CMD ["node", "dist/main"]
|
CMD ["node", "dist/main"]
|
||||||
|
|||||||
Generated
+32
@@ -18,12 +18,14 @@
|
|||||||
"@nestjs/passport": "^11.0.5",
|
"@nestjs/passport": "^11.0.5",
|
||||||
"@nestjs/platform-express": "^11.0.1",
|
"@nestjs/platform-express": "^11.0.1",
|
||||||
"@nestjs/schedule": "^6.1.0",
|
"@nestjs/schedule": "^6.1.0",
|
||||||
|
"@nestjs/throttler": "^6.5.0",
|
||||||
"@nestjs/typeorm": "^11.0.0",
|
"@nestjs/typeorm": "^11.0.0",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"cache-manager": "^7.2.8",
|
"cache-manager": "^7.2.8",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.15.1",
|
"class-validator": "^0.15.1",
|
||||||
|
"cookie-parser": "^1.4.7",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
"pg": "^8.17.1",
|
"pg": "^8.17.1",
|
||||||
@@ -2461,6 +2463,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@nestjs/throttler": {
|
||||||
|
"version": "6.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@nestjs/throttler/-/throttler-6.5.0.tgz",
|
||||||
|
"integrity": "sha512-9j0ZRfH0QE1qyrj9JjIRDz5gQLPqq9yVC2nHsrosDVAfI5HHw08/aUAWx9DZLSdQf4HDkmhTTEGLrRFHENvchQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0",
|
||||||
|
"@nestjs/core": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0",
|
||||||
|
"reflect-metadata": "^0.1.13 || ^0.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@nestjs/typeorm": {
|
"node_modules/@nestjs/typeorm": {
|
||||||
"version": "11.0.0",
|
"version": "11.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@nestjs/typeorm/-/typeorm-11.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@nestjs/typeorm/-/typeorm-11.0.0.tgz",
|
||||||
@@ -4825,6 +4838,25 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cookie-parser": {
|
||||||
|
"version": "1.4.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
|
||||||
|
"integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cookie": "0.7.2",
|
||||||
|
"cookie-signature": "1.0.6"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cookie-parser/node_modules/cookie-signature": {
|
||||||
|
"version": "1.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
|
||||||
|
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/cookie-signature": {
|
"node_modules/cookie-signature": {
|
||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
|
||||||
|
|||||||
@@ -29,12 +29,14 @@
|
|||||||
"@nestjs/passport": "^11.0.5",
|
"@nestjs/passport": "^11.0.5",
|
||||||
"@nestjs/platform-express": "^11.0.1",
|
"@nestjs/platform-express": "^11.0.1",
|
||||||
"@nestjs/schedule": "^6.1.0",
|
"@nestjs/schedule": "^6.1.0",
|
||||||
|
"@nestjs/throttler": "^6.5.0",
|
||||||
"@nestjs/typeorm": "^11.0.0",
|
"@nestjs/typeorm": "^11.0.0",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"cache-manager": "^7.2.8",
|
"cache-manager": "^7.2.8",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.15.1",
|
"class-validator": "^0.15.1",
|
||||||
|
"cookie-parser": "^1.4.7",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
"pg": "^8.17.1",
|
"pg": "^8.17.1",
|
||||||
@@ -94,6 +96,9 @@
|
|||||||
"moduleNameMapper": {
|
"moduleNameMapper": {
|
||||||
"^src/(.*)$": "<rootDir>/src/$1"
|
"^src/(.*)$": "<rootDir>/src/$1"
|
||||||
},
|
},
|
||||||
|
"setupFilesAfterEnv": [
|
||||||
|
"<rootDir>/test/jest.setup.ts"
|
||||||
|
],
|
||||||
"collectCoverageFrom": [
|
"collectCoverageFrom": [
|
||||||
"src/**/*.ts",
|
"src/**/*.ts",
|
||||||
"!src/**/*.module.ts",
|
"!src/**/*.module.ts",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { ConfigModule } from '@nestjs/config';
|
import { ConfigModule } from '@nestjs/config';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
||||||
import { AppController } from './app.controller';
|
import { AppController } from './app.controller';
|
||||||
import { AppService } from './app.service';
|
import { AppService } from './app.service';
|
||||||
|
|
||||||
@@ -27,6 +28,12 @@ import { SessionModule } from './session/session.module';
|
|||||||
ConfigModule.forRoot({
|
ConfigModule.forRoot({
|
||||||
isGlobal: true,
|
isGlobal: true,
|
||||||
}),
|
}),
|
||||||
|
ThrottlerModule.forRoot([
|
||||||
|
{
|
||||||
|
ttl: 60000,
|
||||||
|
limit: 5,
|
||||||
|
},
|
||||||
|
]),
|
||||||
TypeOrmModule.forRoot({
|
TypeOrmModule.forRoot({
|
||||||
type: 'postgres',
|
type: 'postgres',
|
||||||
host: process.env.DB_HOST,
|
host: process.env.DB_HOST,
|
||||||
@@ -51,6 +58,10 @@ import { SessionModule } from './session/session.module';
|
|||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [
|
providers: [
|
||||||
AppService,
|
AppService,
|
||||||
|
{
|
||||||
|
provide: APP_GUARD,
|
||||||
|
useClass: ThrottlerGuard,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
provide: APP_GUARD,
|
provide: APP_GUARD,
|
||||||
useClass: JwtAuthGuard,
|
useClass: JwtAuthGuard,
|
||||||
|
|||||||
@@ -4,9 +4,15 @@ import {
|
|||||||
Body,
|
Body,
|
||||||
HttpException,
|
HttpException,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
|
Res,
|
||||||
|
Logger,
|
||||||
|
Req,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import { Throttle } from '@nestjs/throttler';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { Public } from './public.decorator';
|
import { Public } from './public.decorator';
|
||||||
|
import type { Request, Response } from 'express';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
|
||||||
interface LoginDto {
|
interface LoginDto {
|
||||||
login: string;
|
login: string;
|
||||||
@@ -15,19 +21,73 @@ interface LoginDto {
|
|||||||
|
|
||||||
@Controller('auth')
|
@Controller('auth')
|
||||||
export class AuthController {
|
export class AuthController {
|
||||||
constructor(private authService: AuthService) {}
|
private readonly logger = new Logger(AuthController.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private authService: AuthService,
|
||||||
|
private configService: ConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Public()
|
@Public()
|
||||||
|
@Throttle({
|
||||||
|
default: { limit: 5, ttl: 60000 },
|
||||||
|
})
|
||||||
@Post('login')
|
@Post('login')
|
||||||
async login(@Body() req: LoginDto) {
|
async login(
|
||||||
|
@Body() req: LoginDto,
|
||||||
|
@Res({ passthrough: true }) res: Response,
|
||||||
|
) {
|
||||||
|
this.logger.debug(`Login request received for user: ${req.login}`);
|
||||||
const user = await this.authService.validateUser(req.login, req.password);
|
const user = await this.authService.validateUser(req.login, req.password);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
|
this.logger.warn(`Login failed for user: ${req.login}`);
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
'Неверный логин или пароль',
|
'Неверный логин или пароль',
|
||||||
HttpStatus.UNAUTHORIZED,
|
HttpStatus.UNAUTHORIZED,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return this.authService.login(user as { login: string });
|
const { access_token } = this.authService.login(user as { login: string });
|
||||||
|
|
||||||
|
// Устанавливаем httpOnly cookie
|
||||||
|
const isProduction =
|
||||||
|
this.configService.get<string>('NODE_ENV') === 'production';
|
||||||
|
this.logger.debug(
|
||||||
|
`Login succeeded for user: ${req.login}. Setting auth cookie (secure=${isProduction}, sameSite=lax, maxAgeMs=86400000)`,
|
||||||
|
);
|
||||||
|
res.cookie('access_token', access_token, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: isProduction, // HTTPS только в production
|
||||||
|
sameSite: 'lax',
|
||||||
|
maxAge: 24 * 60 * 60 * 1000, // 24 часа
|
||||||
|
path: '/',
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.debug(`Login response prepared for user: ${req.login}`);
|
||||||
|
return { access_token };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Post('logout')
|
||||||
|
logout(@Req() req: Request, @Res({ passthrough: true }) res: Response) {
|
||||||
|
const hadCookie = Boolean(
|
||||||
|
(req.cookies as Record<string, unknown> | undefined)?.access_token,
|
||||||
|
);
|
||||||
|
this.logger.debug(`Logout request received. Cookie present: ${hadCookie}`);
|
||||||
|
|
||||||
|
// Очищаем httpOnly cookie
|
||||||
|
const isProduction =
|
||||||
|
this.configService.get<string>('NODE_ENV') === 'production';
|
||||||
|
res.clearCookie('access_token', {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: isProduction,
|
||||||
|
sameSite: 'lax',
|
||||||
|
path: '/',
|
||||||
|
});
|
||||||
|
this.logger.debug(
|
||||||
|
`Auth cookie cleared (secure=${isProduction}, sameSite=lax, path=/)`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('change-password')
|
@Post('change-password')
|
||||||
|
|||||||
@@ -30,13 +30,9 @@ export class AuthService {
|
|||||||
where: { key: 'admin_password' },
|
where: { key: 'admin_password' },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!dbLogin) {
|
// Проверяем наличие учётных данных (без деталей для безопасности)
|
||||||
this.logger.error('Пользователь admin_login не найден в базе данных!');
|
if (!dbLogin || !dbPass) {
|
||||||
return null;
|
this.logger.error('Учётные данные не найдены в базе данных');
|
||||||
}
|
|
||||||
|
|
||||||
if (!dbPass) {
|
|
||||||
this.logger.error('Пароль admin_password не найден в базе данных!');
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,6 +51,7 @@ export class AuthService {
|
|||||||
|
|
||||||
login(user: { login: string }) {
|
login(user: { login: string }) {
|
||||||
const payload = { username: user.login };
|
const payload = { username: user.login };
|
||||||
|
this.logger.debug(`Генерация access token для пользователя: ${user.login}`);
|
||||||
return {
|
return {
|
||||||
access_token: this.jwtService.sign(payload),
|
access_token: this.jwtService.sign(payload),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ import { AuthGuard } from '@nestjs/passport';
|
|||||||
import { Reflector } from '@nestjs/core';
|
import { Reflector } from '@nestjs/core';
|
||||||
import { Request } from 'express';
|
import { Request } from 'express';
|
||||||
|
|
||||||
|
type RequestWithCookies = Request & {
|
||||||
|
cookies?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class JwtAuthGuard extends AuthGuard('jwt') {
|
export class JwtAuthGuard extends AuthGuard('jwt') {
|
||||||
private readonly logger = new Logger(JwtAuthGuard.name);
|
private readonly logger = new Logger(JwtAuthGuard.name);
|
||||||
@@ -17,7 +21,11 @@ export class JwtAuthGuard extends AuthGuard('jwt') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
canActivate(context: ExecutionContext) {
|
canActivate(context: ExecutionContext) {
|
||||||
const request = context.switchToHttp().getRequest<Request>();
|
const request = context.switchToHttp().getRequest<RequestWithCookies>();
|
||||||
|
const cookies: Record<string, unknown> =
|
||||||
|
request.cookies && typeof request.cookies === 'object'
|
||||||
|
? (request.cookies as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`canActivate called for: ${request.url} ${request.method}`,
|
`canActivate called for: ${request.url} ${request.method}`,
|
||||||
);
|
);
|
||||||
@@ -33,6 +41,23 @@ export class JwtAuthGuard extends AuthGuard('jwt') {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Поддержка токена из cookie (httpOnly)
|
||||||
|
const tokenFromCookieValue = cookies.access_token;
|
||||||
|
const tokenFromCookie =
|
||||||
|
typeof tokenFromCookieValue === 'string'
|
||||||
|
? tokenFromCookieValue
|
||||||
|
: undefined;
|
||||||
|
if (tokenFromCookie && !request.headers.authorization) {
|
||||||
|
this.logger.debug(
|
||||||
|
`Token found in cookie, adding to Authorization header`,
|
||||||
|
);
|
||||||
|
request.headers.authorization = `Bearer ${tokenFromCookie}`;
|
||||||
|
} else if (tokenFromCookie) {
|
||||||
|
this.logger.debug(
|
||||||
|
`Token found in cookie, but Authorization header already exists`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Support token from query parameter (for SSE connections)
|
// Support token from query parameter (for SSE connections)
|
||||||
const tokenFromQuery = request.query.token as string | undefined;
|
const tokenFromQuery = request.query.token as string | undefined;
|
||||||
if (tokenFromQuery && !request.headers.authorization) {
|
if (tokenFromQuery && !request.headers.authorization) {
|
||||||
@@ -40,6 +65,14 @@ export class JwtAuthGuard extends AuthGuard('jwt') {
|
|||||||
`Token found in query parameter, adding to Authorization header`,
|
`Token found in query parameter, adding to Authorization header`,
|
||||||
);
|
);
|
||||||
request.headers.authorization = `Bearer ${tokenFromQuery}`;
|
request.headers.authorization = `Bearer ${tokenFromQuery}`;
|
||||||
|
} else if (tokenFromQuery) {
|
||||||
|
this.logger.debug(
|
||||||
|
`Token found in query parameter, but Authorization header already exists`,
|
||||||
|
);
|
||||||
|
} else if (!request.headers.authorization) {
|
||||||
|
this.logger.debug(
|
||||||
|
`No token found in Authorization header, cookie, or query`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.debug(`Calling super.canActivate()`);
|
this.logger.debug(`Calling super.canActivate()`);
|
||||||
|
|||||||
+46
-4
@@ -3,7 +3,8 @@ import { NestExpressApplication } from '@nestjs/platform-express';
|
|||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
import { AuthService } from './auth/auth.service';
|
import { AuthService } from './auth/auth.service';
|
||||||
import { RequestMethod, Logger, LogLevel } from '@nestjs/common';
|
import { RequestMethod, Logger, LogLevel } from '@nestjs/common';
|
||||||
import { Request, Response } from 'express';
|
import { Request, Response, NextFunction, RequestHandler } from 'express';
|
||||||
|
import cookieParser from 'cookie-parser';
|
||||||
import { HttpExceptionFilter } from './client/client.exception-filter';
|
import { HttpExceptionFilter } from './client/client.exception-filter';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
|
||||||
@@ -23,12 +24,53 @@ async function bootstrap() {
|
|||||||
|
|
||||||
app.useLogger(logLevels);
|
app.useLogger(logLevels);
|
||||||
|
|
||||||
|
app.use((req: Request, res: Response, next: NextFunction) => {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
res.on('finish', () => {
|
||||||
|
logger.debug(
|
||||||
|
`${req.method} ${req.originalUrl} -> ${res.statusCode} (${Date.now() - startedAt}ms)`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
app.set('trust proxy', 1);
|
app.set('trust proxy', 1);
|
||||||
|
|
||||||
|
// Cookie parser для работы с httpOnly cookies
|
||||||
|
const cookieParserFactory = cookieParser as unknown as () => RequestHandler;
|
||||||
|
app.use(cookieParserFactory());
|
||||||
|
|
||||||
const authService = app.get(AuthService);
|
const authService = app.get(AuthService);
|
||||||
await authService.seedAdmin();
|
await authService.seedAdmin();
|
||||||
|
|
||||||
app.enableCors();
|
const allowedOrigins = (
|
||||||
|
configService.get<string>('ALLOWED_ORIGINS', '') || ''
|
||||||
|
)
|
||||||
|
.split(',')
|
||||||
|
.map((origin) => origin.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
logger.log(
|
||||||
|
`CORS origins: ${
|
||||||
|
allowedOrigins.length > 0
|
||||||
|
? allowedOrigins.join(', ')
|
||||||
|
: 'all origins allowed'
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
app.enableCors({
|
||||||
|
origin: (origin, callback) => {
|
||||||
|
// Разрешаем запросы без origin (например, из мобильных приложений или curl)
|
||||||
|
if (!origin) return callback(null, true);
|
||||||
|
if (allowedOrigins.length === 0 || allowedOrigins.includes(origin)) {
|
||||||
|
return callback(null, true);
|
||||||
|
}
|
||||||
|
logger.warn(`CORS blocked origin: ${origin}`);
|
||||||
|
return callback(new Error('Not allowed by CORS'), false);
|
||||||
|
},
|
||||||
|
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
|
||||||
|
allowedHeaders: ['Content-Type', 'Authorization'],
|
||||||
|
credentials: true,
|
||||||
|
});
|
||||||
app.useGlobalFilters(new HttpExceptionFilter());
|
app.useGlobalFilters(new HttpExceptionFilter());
|
||||||
app.setGlobalPrefix('api', {
|
app.setGlobalPrefix('api', {
|
||||||
exclude: [
|
exclude: [
|
||||||
@@ -37,8 +79,8 @@ async function bootstrap() {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
const port = configService.get<number>('PORT', 3000);
|
const port = configService.get<number>('PORT', 3100);
|
||||||
await app.listen(port);
|
await app.listen(port, '0.0.0.0');
|
||||||
logger.log(`Application started on port ${port}`);
|
logger.log(`Application started on port ${port}`);
|
||||||
}
|
}
|
||||||
void bootstrap();
|
void bootstrap();
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { APP_GUARD } from '@nestjs/core';
|
||||||
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
|
import { MODULE_METADATA } from '@nestjs/common/constants';
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import {
|
||||||
|
getOptionsToken,
|
||||||
|
ThrottlerGuard,
|
||||||
|
ThrottlerModule,
|
||||||
|
} from '@nestjs/throttler';
|
||||||
|
import { JwtAuthGuard } from 'src/auth/jwt-auth.guard';
|
||||||
|
import { AppModule } from 'src/app.module';
|
||||||
|
|
||||||
|
type DynamicModuleLike = {
|
||||||
|
module?: unknown;
|
||||||
|
global?: boolean;
|
||||||
|
providers?: Array<{
|
||||||
|
provide?: unknown;
|
||||||
|
useClass?: unknown;
|
||||||
|
useValue?: unknown;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AppImportEntry = DynamicModuleLike | Promise<DynamicModuleLike>;
|
||||||
|
|
||||||
|
describe('AppModule конфигурация', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
delete process.env.ALLOWED_ORIGINS;
|
||||||
|
delete process.env.JWT_SECRET;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('должен регистрировать ThrottlerModule с лимитом 5/60000ms', () => {
|
||||||
|
const imports = (Reflect.getMetadata(MODULE_METADATA.IMPORTS, AppModule) ??
|
||||||
|
[]) as DynamicModuleLike[];
|
||||||
|
const throttlerDynamicModule = imports.find(
|
||||||
|
(entry) => entry.module === ThrottlerModule,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(throttlerDynamicModule).toBeDefined();
|
||||||
|
const throttlerOptionsProvider = throttlerDynamicModule?.providers?.find(
|
||||||
|
(provider) => provider.provide === getOptionsToken(),
|
||||||
|
);
|
||||||
|
expect(throttlerOptionsProvider).toBeDefined();
|
||||||
|
expect(throttlerOptionsProvider?.useValue).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
limit: 5,
|
||||||
|
ttl: 60000,
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('должен регистрировать ThrottlerGuard и JwtAuthGuard как глобальные guards', () => {
|
||||||
|
const providers = (Reflect.getMetadata(
|
||||||
|
MODULE_METADATA.PROVIDERS,
|
||||||
|
AppModule,
|
||||||
|
) ?? []) as DynamicModuleLike['providers'];
|
||||||
|
|
||||||
|
expect(providers).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
provide: APP_GUARD,
|
||||||
|
useClass: ThrottlerGuard,
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
provide: APP_GUARD,
|
||||||
|
useClass: JwtAuthGuard,
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('должен подключать ConfigModule как глобальный', async () => {
|
||||||
|
const imports = (Reflect.getMetadata(MODULE_METADATA.IMPORTS, AppModule) ??
|
||||||
|
[]) as AppImportEntry[];
|
||||||
|
const resolvedImports = await Promise.all(
|
||||||
|
imports.map((entry) =>
|
||||||
|
entry instanceof Promise ? entry : Promise.resolve(entry),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const configDynamicModule = resolvedImports.find(
|
||||||
|
(entry) => entry.module === ConfigModule,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(configDynamicModule).toBeDefined();
|
||||||
|
expect(configDynamicModule?.global).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('должен делать ConfigService доступным и читать ALLOWED_ORIGINS из env', async () => {
|
||||||
|
process.env.ALLOWED_ORIGINS = 'http://test.local:8080,http://localhost';
|
||||||
|
|
||||||
|
const moduleRef = await Test.createTestingModule({
|
||||||
|
imports: [
|
||||||
|
ConfigModule.forRoot({
|
||||||
|
isGlobal: true,
|
||||||
|
ignoreEnvFile: true,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
const configService = moduleRef.get<ConfigService>(ConfigService);
|
||||||
|
expect(configService).toBeDefined();
|
||||||
|
expect(configService.get<string>('ALLOWED_ORIGINS')).toBe(
|
||||||
|
'http://test.local:8080,http://localhost',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('должен использовать достаточно длинный JWT_SECRET в тестовых env', () => {
|
||||||
|
process.env.JWT_SECRET = 'test-secret-key-for-jwt-signing-1234567890';
|
||||||
|
|
||||||
|
const jwtSecret = process.env.JWT_SECRET;
|
||||||
|
expect(jwtSecret).toBeDefined();
|
||||||
|
expect(jwtSecret?.length).toBeGreaterThanOrEqual(32);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
/* eslint-disable @typescript-eslint/unbound-method */
|
/* eslint-disable @typescript-eslint/unbound-method */
|
||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
import { HttpException } from '@nestjs/common';
|
import { HttpException } from '@nestjs/common';
|
||||||
|
import { ConfigModule } from '@nestjs/config';
|
||||||
import { AuthController } from 'src/auth/auth.controller';
|
import { AuthController } from 'src/auth/auth.controller';
|
||||||
import { AuthService } from 'src/auth/auth.service';
|
import { AuthService } from 'src/auth/auth.service';
|
||||||
|
import type { Request, Response } from 'express';
|
||||||
|
|
||||||
describe('AuthController', () => {
|
describe('AuthController', () => {
|
||||||
let controller: AuthController;
|
let controller: AuthController;
|
||||||
@@ -17,6 +19,7 @@ describe('AuthController', () => {
|
|||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
imports: [ConfigModule.forRoot()],
|
||||||
controllers: [AuthController],
|
controllers: [AuthController],
|
||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
@@ -35,6 +38,18 @@ describe('AuthController', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('login', () => {
|
describe('login', () => {
|
||||||
|
const mockResponse = {
|
||||||
|
cookie: jest
|
||||||
|
.fn<ReturnType<Response['cookie']>, Parameters<Response['cookie']>>()
|
||||||
|
.mockReturnThis(),
|
||||||
|
clearCookie: jest
|
||||||
|
.fn<
|
||||||
|
ReturnType<Response['clearCookie']>,
|
||||||
|
Parameters<Response['clearCookie']>
|
||||||
|
>()
|
||||||
|
.mockReturnThis(),
|
||||||
|
} satisfies Pick<Response, 'cookie' | 'clearCookie'>;
|
||||||
|
|
||||||
it('должен вернуть access_token при успешной аутентификации', async () => {
|
it('должен вернуть access_token при успешной аутентификации', async () => {
|
||||||
const loginDto = { login: 'admin', password: 'password' };
|
const loginDto = { login: 'admin', password: 'password' };
|
||||||
const mockUser = { login: 'admin' };
|
const mockUser = { login: 'admin' };
|
||||||
@@ -43,7 +58,10 @@ describe('AuthController', () => {
|
|||||||
mockAuthService.validateUser.mockResolvedValue(mockUser);
|
mockAuthService.validateUser.mockResolvedValue(mockUser);
|
||||||
mockAuthService.login.mockReturnValue(mockToken);
|
mockAuthService.login.mockReturnValue(mockToken);
|
||||||
|
|
||||||
const result = await controller.login(loginDto);
|
const result = await controller.login(
|
||||||
|
loginDto,
|
||||||
|
mockResponse as unknown as Response,
|
||||||
|
);
|
||||||
|
|
||||||
expect(result).toEqual(mockToken);
|
expect(result).toEqual(mockToken);
|
||||||
expect(authService.validateUser).toHaveBeenCalledWith(
|
expect(authService.validateUser).toHaveBeenCalledWith(
|
||||||
@@ -51,6 +69,16 @@ describe('AuthController', () => {
|
|||||||
'password',
|
'password',
|
||||||
);
|
);
|
||||||
expect(authService.login).toHaveBeenCalledWith(mockUser);
|
expect(authService.login).toHaveBeenCalledWith(mockUser);
|
||||||
|
expect(mockResponse.cookie).toHaveBeenCalledWith(
|
||||||
|
'access_token',
|
||||||
|
'jwt-token',
|
||||||
|
expect.objectContaining({
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'lax',
|
||||||
|
maxAge: 86400000,
|
||||||
|
path: '/',
|
||||||
|
}),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('должен бросить HttpException при неверных учётных данных', async () => {
|
it('должен бросить HttpException при неверных учётных данных', async () => {
|
||||||
@@ -58,11 +86,135 @@ describe('AuthController', () => {
|
|||||||
|
|
||||||
mockAuthService.validateUser.mockResolvedValue(null);
|
mockAuthService.validateUser.mockResolvedValue(null);
|
||||||
|
|
||||||
await expect(controller.login(loginDto)).rejects.toThrow(HttpException);
|
await expect(
|
||||||
|
controller.login(loginDto, mockResponse as unknown as Response),
|
||||||
|
).rejects.toThrow(HttpException);
|
||||||
|
|
||||||
await expect(controller.login(loginDto)).rejects.toThrow(
|
await expect(
|
||||||
'Неверный логин или пароль',
|
controller.login(loginDto, mockResponse as unknown as Response),
|
||||||
|
).rejects.toThrow('Неверный логин или пароль');
|
||||||
|
|
||||||
|
expect(mockResponse.cookie).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('должен установить secure=true cookie в production режиме', async () => {
|
||||||
|
process.env.NODE_ENV = 'production';
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
imports: [ConfigModule.forRoot()],
|
||||||
|
controllers: [AuthController],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: AuthService,
|
||||||
|
useValue: mockAuthService,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
const prodController = module.get<AuthController>(AuthController);
|
||||||
|
const loginDto = { login: 'admin', password: 'password' };
|
||||||
|
const mockUser = { login: 'admin' };
|
||||||
|
const mockToken = { access_token: 'jwt-token' };
|
||||||
|
|
||||||
|
mockAuthService.validateUser.mockResolvedValue(mockUser);
|
||||||
|
mockAuthService.login.mockReturnValue(mockToken);
|
||||||
|
|
||||||
|
await prodController.login(loginDto, mockResponse as unknown as Response);
|
||||||
|
|
||||||
|
expect(mockResponse.cookie).toHaveBeenCalledWith(
|
||||||
|
'access_token',
|
||||||
|
'jwt-token',
|
||||||
|
expect.objectContaining({
|
||||||
|
secure: true,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
delete process.env.NODE_ENV;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('logout', () => {
|
||||||
|
const mockResponse = {
|
||||||
|
cookie: jest
|
||||||
|
.fn<ReturnType<Response['cookie']>, Parameters<Response['cookie']>>()
|
||||||
|
.mockReturnThis(),
|
||||||
|
clearCookie: jest
|
||||||
|
.fn<
|
||||||
|
ReturnType<Response['clearCookie']>,
|
||||||
|
Parameters<Response['clearCookie']>
|
||||||
|
>()
|
||||||
|
.mockReturnThis(),
|
||||||
|
} satisfies Pick<Response, 'cookie' | 'clearCookie'>;
|
||||||
|
|
||||||
|
it('должен очистить access_token cookie и вернуть success', () => {
|
||||||
|
const mockRequest = {
|
||||||
|
cookies: { access_token: 'some-token' },
|
||||||
|
} as unknown as Request;
|
||||||
|
|
||||||
|
const result = controller.logout(
|
||||||
|
mockRequest,
|
||||||
|
mockResponse as unknown as Response,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual({ success: true });
|
||||||
|
expect(mockResponse.clearCookie).toHaveBeenCalledWith(
|
||||||
|
'access_token',
|
||||||
|
expect.objectContaining({
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'lax',
|
||||||
|
path: '/',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('должен очистить cookie даже если cookie не было в запросе', () => {
|
||||||
|
const mockRequest = {
|
||||||
|
cookies: {},
|
||||||
|
} as unknown as Request;
|
||||||
|
|
||||||
|
const result = controller.logout(
|
||||||
|
mockRequest,
|
||||||
|
mockResponse as unknown as Response,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual({ success: true });
|
||||||
|
expect(mockResponse.clearCookie).toHaveBeenCalledWith(
|
||||||
|
'access_token',
|
||||||
|
expect.objectContaining({
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'lax',
|
||||||
|
path: '/',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('должен установить secure=true для clearCookie в production режиме', async () => {
|
||||||
|
process.env.NODE_ENV = 'production';
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
imports: [ConfigModule.forRoot()],
|
||||||
|
controllers: [AuthController],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: AuthService,
|
||||||
|
useValue: mockAuthService,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
const prodController = module.get<AuthController>(AuthController);
|
||||||
|
const mockRequest = {
|
||||||
|
cookies: { access_token: 'some-token' },
|
||||||
|
} as unknown as Request;
|
||||||
|
|
||||||
|
prodController.logout(mockRequest, mockResponse as unknown as Response);
|
||||||
|
|
||||||
|
expect(mockResponse.clearCookie).toHaveBeenCalledWith(
|
||||||
|
'access_token',
|
||||||
|
expect.objectContaining({
|
||||||
|
secure: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
delete process.env.NODE_ENV;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,90 +1,143 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
||||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
|
||||||
|
|
||||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
|
||||||
/* eslint-disable @typescript-eslint/no-unsafe-return */
|
|
||||||
/* eslint-disable @typescript-eslint/no-floating-promises */
|
|
||||||
|
|
||||||
import { JwtAuthGuard } from 'src/auth/jwt-auth.guard';
|
|
||||||
import { Reflector } from '@nestjs/core';
|
|
||||||
import { ExecutionContext, UnauthorizedException } from '@nestjs/common';
|
import { ExecutionContext, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import { JwtAuthGuard } from 'src/auth/jwt-auth.guard';
|
||||||
|
|
||||||
|
type TestRequest = {
|
||||||
|
url: string;
|
||||||
|
method: string;
|
||||||
|
headers: Record<string, string | undefined>;
|
||||||
|
query: Record<string, unknown>;
|
||||||
|
cookies?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
function createContext(request: TestRequest): ExecutionContext {
|
||||||
|
return {
|
||||||
|
switchToHttp: () =>
|
||||||
|
({
|
||||||
|
getRequest: () => request,
|
||||||
|
}) as ReturnType<ExecutionContext['switchToHttp']>,
|
||||||
|
getHandler: () => ({}),
|
||||||
|
getClass: () => class TestController {},
|
||||||
|
} as ExecutionContext;
|
||||||
|
}
|
||||||
|
|
||||||
describe('JwtAuthGuard', () => {
|
describe('JwtAuthGuard', () => {
|
||||||
let guard: JwtAuthGuard;
|
let guard: JwtAuthGuard;
|
||||||
let reflector: Reflector;
|
let reflector: Reflector;
|
||||||
let mockContext: ExecutionContext;
|
let parentCanActivateSpy: jest.SpyInstance;
|
||||||
let mockRequest: any;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
reflector = new Reflector();
|
reflector = new Reflector();
|
||||||
guard = new JwtAuthGuard(reflector);
|
guard = new JwtAuthGuard(reflector);
|
||||||
|
|
||||||
mockRequest = {
|
const parentPrototype = Object.getPrototypeOf(JwtAuthGuard.prototype) as {
|
||||||
url: '/api/test',
|
canActivate: (context: ExecutionContext) => boolean | Promise<boolean>;
|
||||||
method: 'GET',
|
|
||||||
headers: {},
|
|
||||||
query: {},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
mockContext = {
|
parentCanActivateSpy = jest
|
||||||
switchToHttp: jest.fn().mockReturnValue({
|
.spyOn(parentPrototype, 'canActivate')
|
||||||
getRequest: jest.fn().mockReturnValue(mockRequest),
|
.mockReturnValue(true);
|
||||||
getResponse: jest.fn(),
|
|
||||||
}),
|
|
||||||
getHandler: jest.fn(),
|
|
||||||
getClass: jest.fn(),
|
|
||||||
} as any;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
jest.clearAllMocks();
|
parentCanActivateSpy.mockRestore();
|
||||||
|
jest.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('canActivate', () => {
|
describe('canActivate', () => {
|
||||||
it('должен вернуть true для публичного маршрута', () => {
|
it('должен вернуть true для публичного маршрута', () => {
|
||||||
|
const request: TestRequest = {
|
||||||
|
url: '/api/test',
|
||||||
|
method: 'GET',
|
||||||
|
headers: {},
|
||||||
|
query: {},
|
||||||
|
};
|
||||||
|
const context = createContext(request);
|
||||||
jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(true);
|
jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(true);
|
||||||
|
|
||||||
const result = guard.canActivate(mockContext);
|
const result = guard.canActivate(context);
|
||||||
|
|
||||||
expect(result).toBe(true);
|
expect(result).toBe(true);
|
||||||
|
expect(parentCanActivateSpy).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('должен вызвать super.canActivate для защищенного маршрута', () => {
|
it('должен вызывать super.canActivate для защищенного маршрута', async () => {
|
||||||
|
const request: TestRequest = {
|
||||||
|
url: '/api/test',
|
||||||
|
method: 'GET',
|
||||||
|
headers: {},
|
||||||
|
query: {},
|
||||||
|
};
|
||||||
|
const context = createContext(request);
|
||||||
jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(false);
|
jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(false);
|
||||||
|
|
||||||
const superCanActivate = jest
|
await guard.canActivate(context);
|
||||||
.spyOn(JwtAuthGuard.prototype, 'canActivate' as any)
|
|
||||||
.mockImplementation(() => true);
|
|
||||||
|
|
||||||
guard.canActivate(mockContext);
|
expect(parentCanActivateSpy).toHaveBeenCalledWith(context);
|
||||||
|
|
||||||
expect(superCanActivate).toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('НЕ должен добавлять токен, если authorization уже есть', () => {
|
it('должен добавлять токен из cookie в authorization header', async () => {
|
||||||
|
const request: TestRequest = {
|
||||||
|
url: '/api/test',
|
||||||
|
method: 'GET',
|
||||||
|
headers: {},
|
||||||
|
query: {},
|
||||||
|
cookies: { access_token: 'cookie-jwt-token' },
|
||||||
|
};
|
||||||
|
const context = createContext(request);
|
||||||
jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(false);
|
jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(false);
|
||||||
mockRequest.query.token = 'test-jwt-token';
|
|
||||||
mockRequest.headers.authorization = 'Bearer existing-token';
|
|
||||||
|
|
||||||
const _superCanActivate = jest
|
await guard.canActivate(context);
|
||||||
.spyOn(JwtAuthGuard.prototype, 'canActivate' as any)
|
|
||||||
.mockImplementation(() => true);
|
|
||||||
|
|
||||||
guard.canActivate(mockContext);
|
expect(request.headers.authorization).toBe('Bearer cookie-jwt-token');
|
||||||
|
|
||||||
expect(mockRequest.headers.authorization).toBe('Bearer existing-token');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('должен вернуть результат super.canActivate', () => {
|
it('не должен перезаписывать существующий authorization header', async () => {
|
||||||
|
const request: TestRequest = {
|
||||||
|
url: '/api/test',
|
||||||
|
method: 'GET',
|
||||||
|
headers: { authorization: 'Bearer existing-token' },
|
||||||
|
query: { token: 'query-jwt-token' },
|
||||||
|
cookies: { access_token: 'cookie-jwt-token' },
|
||||||
|
};
|
||||||
|
const context = createContext(request);
|
||||||
jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(false);
|
jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(false);
|
||||||
|
|
||||||
const _superCanActivate = jest
|
await guard.canActivate(context);
|
||||||
.spyOn(JwtAuthGuard.prototype, 'canActivate' as any)
|
|
||||||
.mockImplementation(() => 'PENDING_RESULT');
|
|
||||||
|
|
||||||
const result = guard.canActivate(mockContext);
|
expect(request.headers.authorization).toBe('Bearer existing-token');
|
||||||
|
});
|
||||||
|
|
||||||
expect(result).toBe('PENDING_RESULT');
|
it('должен добавлять токен из query параметра в authorization header', async () => {
|
||||||
|
const request: TestRequest = {
|
||||||
|
url: '/api/test',
|
||||||
|
method: 'GET',
|
||||||
|
headers: {},
|
||||||
|
query: { token: 'query-jwt-token' },
|
||||||
|
cookies: {},
|
||||||
|
};
|
||||||
|
const context = createContext(request);
|
||||||
|
jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(false);
|
||||||
|
|
||||||
|
await guard.canActivate(context);
|
||||||
|
|
||||||
|
expect(request.headers.authorization).toBe('Bearer query-jwt-token');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('не должен падать при невалидном формате cookies', async () => {
|
||||||
|
const request: TestRequest = {
|
||||||
|
url: '/api/test',
|
||||||
|
method: 'GET',
|
||||||
|
headers: {},
|
||||||
|
query: {},
|
||||||
|
cookies: 'invalid-cookies',
|
||||||
|
};
|
||||||
|
const context = createContext(request);
|
||||||
|
jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(false);
|
||||||
|
|
||||||
|
await guard.canActivate(context);
|
||||||
|
|
||||||
|
expect(request.headers.authorization).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -97,53 +150,18 @@ describe('JwtAuthGuard', () => {
|
|||||||
expect(result).toEqual(user);
|
expect(result).toEqual(user);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('должен бросить ошибку при наличии ошибки', () => {
|
it('должен пробрасывать Error как есть', () => {
|
||||||
const _error = new Error('Invalid token');
|
const authError = new Error('Invalid token');
|
||||||
|
|
||||||
expect(() => guard.handleRequest(_error, null, null)).toThrow(Error);
|
expect(() => {
|
||||||
|
guard.handleRequest(authError, null, null);
|
||||||
|
}).toThrow('Invalid token');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('должен бросить UnauthorizedException, если пользователь null и нет ошибки', () => {
|
it('должен бросать UnauthorizedException, если нет user и err', () => {
|
||||||
expect(() => guard.handleRequest(null, null, null)).toThrow(
|
expect(() => {
|
||||||
UnauthorizedException,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('должен бросить ошибку с сообщением из Error', () => {
|
|
||||||
const _error = new Error('Custom error message');
|
|
||||||
|
|
||||||
try {
|
|
||||||
guard.handleRequest(_error, null, null);
|
|
||||||
} catch (e) {
|
|
||||||
expect((e as Error).message).toContain('Custom error message');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('должен бросить ошибку с сообщением из строки', () => {
|
|
||||||
const _error = 'String error message';
|
|
||||||
|
|
||||||
// handleRequest пробрасывает строковую ошибку как есть через throw
|
|
||||||
expect(() => guard.handleRequest(_error as any, null, null)).toThrow(
|
|
||||||
'String error message',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('должен бросить ошибку с JSON сообщением', () => {
|
|
||||||
const _error = { message: 'JSON error' };
|
|
||||||
|
|
||||||
// Для объекта берётся message поле
|
|
||||||
expect(() => guard.handleRequest(_error as any, null, null)).toThrow(
|
|
||||||
'JSON error',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('должен бросить ошибку с сообщением "null", если error=null и user=null', () => {
|
|
||||||
try {
|
|
||||||
guard.handleRequest(null, null, null);
|
guard.handleRequest(null, null, null);
|
||||||
} catch (_e: any) {
|
}).toThrow(UnauthorizedException);
|
||||||
// UnauthorizedException имеет пустое сообщение по умолчанию
|
|
||||||
expect(_e).toBeInstanceOf(UnauthorizedException);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { Logger } from '@nestjs/common';
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
// Keep successful test runs quiet by silencing Nest logger output.
|
||||||
|
Logger.overrideLogger(false);
|
||||||
|
|
||||||
|
jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
|
||||||
|
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||||
|
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||||
|
jest.spyOn(Logger.prototype, 'debug').mockImplementation(() => undefined);
|
||||||
|
jest.spyOn(Logger.prototype, 'verbose').mockImplementation(() => undefined);
|
||||||
|
});
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-unsafe-call */
|
||||||
|
|
||||||
|
import cookieParser from 'cookie-parser';
|
||||||
|
import type { Response, NextFunction } from 'express';
|
||||||
|
|
||||||
|
describe('main.ts - CORS и middleware', () => {
|
||||||
|
describe('CORS конфигурация', () => {
|
||||||
|
it('должен разрешить origin из ALLOWED_ORIGINS', () => {
|
||||||
|
process.env.ALLOWED_ORIGINS = 'http://localhost:8080,http://localhost';
|
||||||
|
|
||||||
|
const allowedOrigins = (process.env.ALLOWED_ORIGINS || '')
|
||||||
|
.split(',')
|
||||||
|
.map((origin) => origin.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
expect(allowedOrigins).toContain('http://localhost:8080');
|
||||||
|
expect(allowedOrigins).toContain('http://localhost');
|
||||||
|
|
||||||
|
delete process.env.ALLOWED_ORIGINS;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('должен вернуть пустой массив если ALLOWED_ORIGINS не задан', () => {
|
||||||
|
process.env.ALLOWED_ORIGINS = '';
|
||||||
|
|
||||||
|
const allowedOrigins = (process.env.ALLOWED_ORIGINS || '')
|
||||||
|
.split(',')
|
||||||
|
.map((origin) => origin.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
expect(allowedOrigins).toHaveLength(0);
|
||||||
|
|
||||||
|
delete process.env.ALLOWED_ORIGINS;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('должен разрешить все origin если ALLOWED_ORIGINS пустой', () => {
|
||||||
|
const allowedOrigins: string[] = [];
|
||||||
|
|
||||||
|
const result = allowedOrigins.length === 0 ? 'all' : 'restricted';
|
||||||
|
|
||||||
|
expect(result).toBe('all');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('должен заблокировать origin не из ALLOWED_ORIGINS', () => {
|
||||||
|
process.env.ALLOWED_ORIGINS = 'http://localhost:8080';
|
||||||
|
|
||||||
|
const allowedOrigins = (process.env.ALLOWED_ORIGINS || '')
|
||||||
|
.split(',')
|
||||||
|
.map((origin) => origin.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const testOrigin = 'http://evil.com';
|
||||||
|
const isAllowed = allowedOrigins.includes(testOrigin);
|
||||||
|
|
||||||
|
expect(isAllowed).toBe(false);
|
||||||
|
|
||||||
|
delete process.env.ALLOWED_ORIGINS;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('должен разрешить запросы без origin (mobile apps, curl)', () => {
|
||||||
|
const callback = jest.fn();
|
||||||
|
const origin = undefined;
|
||||||
|
|
||||||
|
// Симуляция CORS callback для запросов без origin
|
||||||
|
if (!origin) {
|
||||||
|
callback(null, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(callback).toHaveBeenCalledWith(null, true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Request tracing middleware', () => {
|
||||||
|
it('должен логировать время выполнения запроса', () => {
|
||||||
|
const mockResponse = {
|
||||||
|
statusCode: 200,
|
||||||
|
on: jest.fn((event: string, handler: () => void) => {
|
||||||
|
if (event === 'finish') {
|
||||||
|
handler();
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
} as unknown as Response;
|
||||||
|
|
||||||
|
const next = jest.fn<
|
||||||
|
ReturnType<NextFunction>,
|
||||||
|
Parameters<NextFunction>
|
||||||
|
>();
|
||||||
|
|
||||||
|
// Симуляция middleware
|
||||||
|
const startedAt = Date.now();
|
||||||
|
mockResponse.on('finish', () => {
|
||||||
|
const duration = Date.now() - startedAt;
|
||||||
|
expect(duration).toBeGreaterThanOrEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
next();
|
||||||
|
|
||||||
|
expect(next).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('должен вызвать next() для продолжения обработки', () => {
|
||||||
|
const next = jest.fn<
|
||||||
|
ReturnType<NextFunction>,
|
||||||
|
Parameters<NextFunction>
|
||||||
|
>();
|
||||||
|
|
||||||
|
// Симуляция middleware
|
||||||
|
next();
|
||||||
|
|
||||||
|
expect(next).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('cookie-parser middleware', () => {
|
||||||
|
it('должен быть импортирован и использован как функция', () => {
|
||||||
|
// Проверяем что cookie-parser может быть вызван как функция
|
||||||
|
// Реальная проверка происходит в runtime через main.ts
|
||||||
|
expect(typeof cookieParser).toBe('function');
|
||||||
|
expect(typeof cookieParser()).toBe('function');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('app.listen конфигурация', () => {
|
||||||
|
it('должен слушать на 0.0.0.0 для работы в Docker', () => {
|
||||||
|
const port = 3100;
|
||||||
|
const host = '0.0.0.0';
|
||||||
|
|
||||||
|
// Проверка что хост задан правильно
|
||||||
|
expect(host).toBe('0.0.0.0');
|
||||||
|
expect(port).toBe(3100);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ConfigService использование', () => {
|
||||||
|
it('должен получить ALLOWED_ORIGINS из ConfigService', () => {
|
||||||
|
const mockConfigService = {
|
||||||
|
get: jest.fn((key: string, defaultValue?: string) => {
|
||||||
|
if (key === 'ALLOWED_ORIGINS') {
|
||||||
|
return 'http://localhost:8080';
|
||||||
|
}
|
||||||
|
return defaultValue;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const allowedOrigins = mockConfigService.get('ALLOWED_ORIGINS', '');
|
||||||
|
|
||||||
|
expect(allowedOrigins).toBe('http://localhost:8080');
|
||||||
|
expect(mockConfigService.get).toHaveBeenCalledWith('ALLOWED_ORIGINS', '');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('должен вернуть пустую строку если ALLOWED_ORIGINS не задан', () => {
|
||||||
|
const mockConfigService = {
|
||||||
|
get: jest.fn((key: string, defaultValue?: string) => {
|
||||||
|
if (key === 'ALLOWED_ORIGINS') {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
return defaultValue;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const allowedOrigins = mockConfigService.get('ALLOWED_ORIGINS', '');
|
||||||
|
|
||||||
|
expect(allowedOrigins).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('должен получить NODE_ENV для определения production режима', () => {
|
||||||
|
process.env.NODE_ENV = 'production';
|
||||||
|
|
||||||
|
const mockConfigService = {
|
||||||
|
get: jest.fn((key: string, defaultValue?: string) => {
|
||||||
|
return process.env[key] ?? defaultValue;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const nodeEnv = mockConfigService.get('NODE_ENV');
|
||||||
|
|
||||||
|
expect(nodeEnv).toBe('production');
|
||||||
|
|
||||||
|
delete process.env.NODE_ENV;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -50,7 +50,7 @@ describe('SettingsController', () => {
|
|||||||
describe('findAll', () => {
|
describe('findAll', () => {
|
||||||
it('должен вернуть все настройки как объект', async () => {
|
it('должен вернуть все настройки как объект', async () => {
|
||||||
const mockSettings = [
|
const mockSettings = [
|
||||||
{ key: 'xui_url', value: 'http://localhost:3000' },
|
{ key: 'xui_url', value: 'http://localhost:3100' },
|
||||||
{ key: 'xui_login', value: 'admin' },
|
{ key: 'xui_login', value: 'admin' },
|
||||||
{ key: 'xui_password', value: 'password' },
|
{ key: 'xui_password', value: 'password' },
|
||||||
];
|
];
|
||||||
@@ -60,7 +60,7 @@ describe('SettingsController', () => {
|
|||||||
const result = await controller.findAll();
|
const result = await controller.findAll();
|
||||||
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
xui_url: 'http://localhost:3000',
|
xui_url: 'http://localhost:3100',
|
||||||
xui_login: 'admin',
|
xui_login: 'admin',
|
||||||
xui_password: 'password',
|
xui_password: 'password',
|
||||||
});
|
});
|
||||||
@@ -79,7 +79,7 @@ describe('SettingsController', () => {
|
|||||||
describe('checkConnection', () => {
|
describe('checkConnection', () => {
|
||||||
it('должен проверить подключение к 3x-ui', async () => {
|
it('должен проверить подключение к 3x-ui', async () => {
|
||||||
const body = {
|
const body = {
|
||||||
xui_url: 'http://localhost:3000',
|
xui_url: 'http://localhost:3100',
|
||||||
xui_login: 'admin',
|
xui_login: 'admin',
|
||||||
xui_password: 'password',
|
xui_password: 'password',
|
||||||
};
|
};
|
||||||
@@ -98,7 +98,7 @@ describe('SettingsController', () => {
|
|||||||
|
|
||||||
it('должен вернуть false при неудачном подключении', async () => {
|
it('должен вернуть false при неудачном подключении', async () => {
|
||||||
const body = {
|
const body = {
|
||||||
xui_url: 'http://localhost:3000',
|
xui_url: 'http://localhost:3100',
|
||||||
xui_login: 'admin',
|
xui_login: 'admin',
|
||||||
xui_password: 'wrong',
|
xui_password: 'wrong',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ describe('XuiService', () => {
|
|||||||
|
|
||||||
it('должен вернуть true при успешном логине', async () => {
|
it('должен вернуть true при успешном логине', async () => {
|
||||||
mockSettingsRepo.find.mockResolvedValue([
|
mockSettingsRepo.find.mockResolvedValue([
|
||||||
{ key: 'xui_url', value: 'http://localhost:3000' },
|
{ key: 'xui_url', value: 'http://localhost:3100' },
|
||||||
{ key: 'xui_login', value: 'admin' },
|
{ key: 'xui_login', value: 'admin' },
|
||||||
{ key: 'xui_password', value: 'password' },
|
{ key: 'xui_password', value: 'password' },
|
||||||
]);
|
]);
|
||||||
@@ -92,7 +92,7 @@ describe('XuiService', () => {
|
|||||||
|
|
||||||
it('должен вернуть false при ошибке логина', async () => {
|
it('должен вернуть false при ошибке логина', async () => {
|
||||||
mockSettingsRepo.find.mockResolvedValue([
|
mockSettingsRepo.find.mockResolvedValue([
|
||||||
{ key: 'xui_url', value: 'http://localhost:3000' },
|
{ key: 'xui_url', value: 'http://localhost:3100' },
|
||||||
{ key: 'xui_login', value: 'admin' },
|
{ key: 'xui_login', value: 'admin' },
|
||||||
{ key: 'xui_password', value: 'password' },
|
{ key: 'xui_password', value: 'password' },
|
||||||
]);
|
]);
|
||||||
@@ -106,7 +106,7 @@ describe('XuiService', () => {
|
|||||||
|
|
||||||
it('должен вернуть false, если нет cookie в ответе', async () => {
|
it('должен вернуть false, если нет cookie в ответе', async () => {
|
||||||
mockSettingsRepo.find.mockResolvedValue([
|
mockSettingsRepo.find.mockResolvedValue([
|
||||||
{ key: 'xui_url', value: 'http://localhost:3000' },
|
{ key: 'xui_url', value: 'http://localhost:3100' },
|
||||||
{ key: 'xui_login', value: 'admin' },
|
{ key: 'xui_login', value: 'admin' },
|
||||||
{ key: 'xui_password', value: 'password' },
|
{ key: 'xui_password', value: 'password' },
|
||||||
]);
|
]);
|
||||||
@@ -126,7 +126,7 @@ describe('XuiService', () => {
|
|||||||
mockAxiosInstance.post.mockRejectedValue(new Error('Connection failed'));
|
mockAxiosInstance.post.mockRejectedValue(new Error('Connection failed'));
|
||||||
|
|
||||||
const result = await service.checkConnection(
|
const result = await service.checkConnection(
|
||||||
'http://localhost:3000',
|
'http://localhost:3100',
|
||||||
'admin',
|
'admin',
|
||||||
'password',
|
'password',
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -61,26 +61,25 @@ check_containers_running() {
|
|||||||
|
|
||||||
check_and_fix_credentials() {
|
check_and_fix_credentials() {
|
||||||
log "Проверка учётных данных на безопасность..."
|
log "Проверка учётных данных на безопасность..."
|
||||||
|
|
||||||
local env_file=".env"
|
local env_file=".env"
|
||||||
local compose_file="docker-compose.yml"
|
|
||||||
local credentials_changed=0
|
local credentials_changed=0
|
||||||
|
|
||||||
# Проверяем, существует ли .env файл
|
# Проверяем, существует ли .env файл
|
||||||
if [[ ! -f "$env_file" ]]; then
|
if [[ ! -f "$env_file" ]]; then
|
||||||
log "Создание .env файла с безопасными учётными данными..."
|
log "Создание .env файла с безопасными учётными данными..."
|
||||||
|
|
||||||
# Генерируем случайные пароли
|
# Генерируем случайные пароли
|
||||||
local db_pass
|
local db_pass
|
||||||
local jwt_secret
|
local jwt_secret
|
||||||
local admin_login
|
local admin_login
|
||||||
local admin_pass
|
local admin_pass
|
||||||
|
|
||||||
db_pass=$(openssl rand -base64 12 | tr -dc 'A-Za-z0-9' | cut -c1-12)
|
db_pass=$(openssl rand -base64 12 | tr -dc 'A-Za-z0-9' | cut -c1-12)
|
||||||
jwt_secret=$(openssl rand -base64 32)
|
jwt_secret=$(openssl rand -base64 32)
|
||||||
admin_login=$(openssl rand -base64 8 | tr -dc 'A-Za-z0-9' | cut -c1-8)
|
admin_login=$(openssl rand -base64 8 | tr -dc 'A-Za-z0-9' | cut -c1-8)
|
||||||
admin_pass=$(openssl rand -base64 12 | tr -dc 'A-Za-z0-9' | cut -c1-12)
|
admin_pass=$(openssl rand -base64 12 | tr -dc 'A-Za-z0-9' | cut -c1-12)
|
||||||
|
|
||||||
# Создаём .env файл
|
# Создаём .env файл
|
||||||
cat > "$env_file" <<EOF
|
cat > "$env_file" <<EOF
|
||||||
POSTGRES_USER=admin
|
POSTGRES_USER=admin
|
||||||
@@ -89,15 +88,18 @@ POSTGRES_DB=3dp_manager
|
|||||||
JWT_SECRET=${jwt_secret}
|
JWT_SECRET=${jwt_secret}
|
||||||
ADMIN_LOGIN=${admin_login}
|
ADMIN_LOGIN=${admin_login}
|
||||||
ADMIN_PASSWORD=${admin_pass}
|
ADMIN_PASSWORD=${admin_pass}
|
||||||
|
PORT=3100
|
||||||
|
LOG_LEVEL=error
|
||||||
|
ALLOWED_ORIGINS=
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
log "Сгенерированы новые учётные данные:"
|
log "Сгенерированы новые учётные данные:"
|
||||||
log " ADMIN_LOGIN: ${admin_login}"
|
log " ADMIN_LOGIN: ${admin_login}"
|
||||||
log " ADMIN_PASSWORD: ${admin_pass}"
|
log " ADMIN_PASSWORD: ${admin_pass}"
|
||||||
log " POSTGRES_PASSWORD: ${db_pass}"
|
log " POSTGRES_PASSWORD: ${db_pass}"
|
||||||
log " JWT_SECRET: ${jwt_secret}"
|
log " JWT_SECRET: ${jwt_secret}"
|
||||||
log "⚠️ Сохраните эти данные в безопасном месте!"
|
log "⚠️ Сохраните эти данные в безопасном месте!"
|
||||||
|
|
||||||
credentials_changed=1
|
credentials_changed=1
|
||||||
else
|
else
|
||||||
# Проверяем, не используются ли дефолтные значения
|
# Проверяем, не используются ли дефолтные значения
|
||||||
@@ -105,52 +107,52 @@ EOF
|
|||||||
local admin_pass_val
|
local admin_pass_val
|
||||||
local jwt_secret_val
|
local jwt_secret_val
|
||||||
local db_pass_val
|
local db_pass_val
|
||||||
|
|
||||||
admin_login_val=$(grep -E "^ADMIN_LOGIN=" "$env_file" 2>/dev/null | cut -d'=' -f2 | tr -d '"' | tr -d "'" || echo "")
|
admin_login_val=$(grep -E "^ADMIN_LOGIN=" "$env_file" 2>/dev/null | cut -d'=' -f2 | tr -d '"' | tr -d "'" || echo "")
|
||||||
admin_pass_val=$(grep -E "^ADMIN_PASSWORD=" "$env_file" 2>/dev/null | cut -d'=' -f2 | tr -d '"' | tr -d "'" || echo "")
|
admin_pass_val=$(grep -E "^ADMIN_PASSWORD=" "$env_file" 2>/dev/null | cut -d'=' -f2 | tr -d '"' | tr -d "'" || echo "")
|
||||||
jwt_secret_val=$(grep -E "^JWT_SECRET=" "$env_file" 2>/dev/null | cut -d'=' -f2 | tr -d '"' | tr -d "'" || echo "")
|
jwt_secret_val=$(grep -E "^JWT_SECRET=" "$env_file" 2>/dev/null | cut -d'=' -f2 | tr -d '"' | tr -d "'" || echo "")
|
||||||
db_pass_val=$(grep -E "^POSTGRES_PASSWORD=" "$env_file" 2>/dev/null | cut -d'=' -f2 | tr -d '"' | tr -d "'" || echo "")
|
db_pass_val=$(grep -E "^POSTGRES_PASSWORD=" "$env_file" 2>/dev/null | cut -d'=' -f2 | tr -d '"' | tr -d "'" || echo "")
|
||||||
|
|
||||||
local needs_update=0
|
local needs_update=0
|
||||||
|
|
||||||
if [[ "$admin_login_val" == "admin" ]] || [[ -z "$admin_login_val" ]]; then
|
if [[ "$admin_login_val" == "admin" ]] || [[ -z "$admin_login_val" ]]; then
|
||||||
warn "Обнаружен дефолтный ADMIN_LOGIN=admin"
|
warn "Обнаружен дефолтный ADMIN_LOGIN=admin"
|
||||||
needs_update=1
|
needs_update=1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ "$admin_pass_val" == "admin" ]] || [[ -z "$admin_pass_val" ]]; then
|
if [[ "$admin_pass_val" == "admin" ]] || [[ -z "$admin_pass_val" ]]; then
|
||||||
warn "Обнаружен дефолтный ADMIN_PASSWORD=admin"
|
warn "Обнаружен дефолтный ADMIN_PASSWORD=admin"
|
||||||
needs_update=1
|
needs_update=1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ "$jwt_secret_val" == "secretKey" ]] || [[ -z "$jwt_secret_val" ]]; then
|
if [[ "$jwt_secret_val" == "secretKey" ]] || [[ -z "$jwt_secret_val" ]]; then
|
||||||
warn "Обнаружен дефолтный JWT_SECRET=secretKey"
|
warn "Обнаружен дефолтный JWT_SECRET=secretKey"
|
||||||
needs_update=1
|
needs_update=1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ "$db_pass_val" == "admin" ]] || [[ -z "$db_pass_val" ]]; then
|
if [[ "$db_pass_val" == "admin" ]] || [[ -z "$db_pass_val" ]]; then
|
||||||
warn "Обнаружен дефолтный POSTGRES_PASSWORD=admin"
|
warn "Обнаружен дефолтный POSTGRES_PASSWORD=admin"
|
||||||
needs_update=1
|
needs_update=1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ $needs_update -eq 1 ]]; then
|
if [[ $needs_update -eq 1 ]]; then
|
||||||
log "Генерация новых безопасных учётных данных..."
|
log "Генерация новых безопасных учётных данных..."
|
||||||
|
|
||||||
# Генерируем новые пароли
|
# Генерируем новые пароли
|
||||||
local new_db_pass
|
local new_db_pass
|
||||||
local new_jwt_secret
|
local new_jwt_secret
|
||||||
local new_admin_login
|
local new_admin_login
|
||||||
local new_admin_pass
|
local new_admin_pass
|
||||||
|
|
||||||
new_db_pass=$(openssl rand -base64 12 | tr -dc 'A-Za-z0-9' | cut -c1-12)
|
new_db_pass=$(openssl rand -base64 12 | tr -dc 'A-Za-z0-9' | cut -c1-12)
|
||||||
new_jwt_secret=$(openssl rand -base64 32)
|
new_jwt_secret=$(openssl rand -base64 32)
|
||||||
new_admin_login=$(openssl rand -base64 8 | tr -dc 'A-Za-z0-9' | cut -c1-8)
|
new_admin_login=$(openssl rand -base64 8 | tr -dc 'A-Za-z0-9' | cut -c1-8)
|
||||||
new_admin_pass=$(openssl rand -base64 12 | tr -dc 'A-Za-z0-9' | cut -c1-12)
|
new_admin_pass=$(openssl rand -base64 12 | tr -dc 'A-Za-z0-9' | cut -c1-12)
|
||||||
|
|
||||||
# Сохраняем существующие значения, которые не нужно менять
|
# Сохраняем существующие значения, которые не нужно менять
|
||||||
local existing_postgres_user
|
local existing_postgres_user
|
||||||
existing_postgres_user=$(grep -E "^POSTGRES_USER=" "$env_file" 2>/dev/null | cut -d'=' -f2 || echo "admin")
|
existing_postgres_user=$(grep -E "^POSTGRES_USER=" "$env_file" 2>/dev/null | cut -d'=' -f2 || echo "admin")
|
||||||
|
|
||||||
# Создаём новый .env файл
|
# Создаём новый .env файл
|
||||||
cat > "$env_file" <<EOF
|
cat > "$env_file" <<EOF
|
||||||
POSTGRES_USER=${existing_postgres_user:-admin}
|
POSTGRES_USER=${existing_postgres_user:-admin}
|
||||||
@@ -160,23 +162,108 @@ JWT_SECRET=${new_jwt_secret}
|
|||||||
ADMIN_LOGIN=${new_admin_login}
|
ADMIN_LOGIN=${new_admin_login}
|
||||||
ADMIN_PASSWORD=${new_admin_pass}
|
ADMIN_PASSWORD=${new_admin_pass}
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
log "Сгенерированы новые учётные данные:"
|
log "Сгенерированы новые учётные данные:"
|
||||||
log " ADMIN_LOGIN: ${new_admin_login}"
|
log " ADMIN_LOGIN: ${new_admin_login}"
|
||||||
log " ADMIN_PASSWORD: ${new_admin_pass}"
|
log " ADMIN_PASSWORD: ${new_admin_pass}"
|
||||||
log " POSTGRES_PASSWORD: ${new_db_pass}"
|
log " POSTGRES_PASSWORD: ${new_db_pass}"
|
||||||
log " JWT_SECRET: ${new_jwt_secret}"
|
log " JWT_SECRET: ${new_jwt_secret}"
|
||||||
log "⚠️ Сохраните эти данные в безопасном месте!"
|
log "⚠️ Сохраните эти данные в безопасном месте!"
|
||||||
|
|
||||||
credentials_changed=1
|
credentials_changed=1
|
||||||
else
|
else
|
||||||
log "Учётные данные безопасны ✅"
|
log "Учётные данные безопасны ✅"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
return $credentials_changed
|
return $credentials_changed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ensure_nginx_api_timeouts() {
|
||||||
|
local nginx_conf="$1"
|
||||||
|
[[ -f "$nginx_conf" ]] || return 0
|
||||||
|
|
||||||
|
local tmp_file
|
||||||
|
tmp_file="$(mktemp)"
|
||||||
|
|
||||||
|
awk '
|
||||||
|
BEGIN { in_api = 0; in_bus = 0; injected = 0 }
|
||||||
|
{
|
||||||
|
line = $0
|
||||||
|
|
||||||
|
if (line ~ /^[[:space:]]*location[[:space:]]+\/api\/[[:space:]]*\{/) {
|
||||||
|
in_api = 1
|
||||||
|
in_bus = 0
|
||||||
|
injected = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line ~ /^[[:space:]]*location[[:space:]]+\/bus\/[[:space:]]*\{/) {
|
||||||
|
in_bus = 1
|
||||||
|
in_api = 0
|
||||||
|
injected = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((in_api || in_bus) && line ~ /proxy_(connect|send|read)_timeout[[:space:]]+[0-9]+s;/) {
|
||||||
|
next
|
||||||
|
}
|
||||||
|
|
||||||
|
print line
|
||||||
|
|
||||||
|
if ((in_api || in_bus) && line ~ /proxy_set_header[[:space:]]+X-Forwarded-For[[:space:]]+/ && injected == 0) {
|
||||||
|
print " proxy_connect_timeout 10s;"
|
||||||
|
print " proxy_send_timeout 650s;"
|
||||||
|
print " proxy_read_timeout 650s;"
|
||||||
|
injected = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((in_api || in_bus) && line ~ /^[[:space:]]*}/) {
|
||||||
|
in_api = 0
|
||||||
|
in_bus = 0
|
||||||
|
injected = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
' "$nginx_conf" > "$tmp_file"
|
||||||
|
|
||||||
|
mv "$tmp_file" "$nginx_conf"
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_bus_location() {
|
||||||
|
local nginx_conf="$1"
|
||||||
|
[[ -f "$nginx_conf" ]] || return 0
|
||||||
|
|
||||||
|
# Проверяем, есть ли уже location /bus/
|
||||||
|
if grep -q "location /bus/" "$nginx_conf"; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
local tmp_file
|
||||||
|
tmp_file="$(mktemp)"
|
||||||
|
|
||||||
|
awk '
|
||||||
|
{
|
||||||
|
print $0
|
||||||
|
if ($0 ~ /^[[:space:]]*location[[:space:]]+\/api\//) {
|
||||||
|
found_api = 1
|
||||||
|
}
|
||||||
|
if (found_api && $0 ~ /^[[:space:]]*\}/) {
|
||||||
|
print ""
|
||||||
|
print " location /bus/ {"
|
||||||
|
print " proxy_pass http://backend:3100/bus/;"
|
||||||
|
print " proxy_set_header Host $http_host;"
|
||||||
|
print " proxy_set_header X-Real-IP $remote_addr;"
|
||||||
|
print " proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;"
|
||||||
|
print " proxy_connect_timeout 10s;"
|
||||||
|
print " proxy_send_timeout 650s;"
|
||||||
|
print " proxy_read_timeout 650s;"
|
||||||
|
print " }"
|
||||||
|
found_api = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
' "$nginx_conf" > "$tmp_file"
|
||||||
|
|
||||||
|
mv "$tmp_file" "$nginx_conf"
|
||||||
|
}
|
||||||
|
|
||||||
need_root() {
|
need_root() {
|
||||||
[[ $EUID -eq 0 ]] || die "Запускать только от root"
|
[[ $EUID -eq 0 ]] || die "Запускать только от root"
|
||||||
}
|
}
|
||||||
@@ -209,6 +296,12 @@ log "Compose команда: ${COMPOSE_CMD[*]}"
|
|||||||
#################################
|
#################################
|
||||||
check_and_fix_credentials || true
|
check_and_fix_credentials || true
|
||||||
|
|
||||||
|
#################################
|
||||||
|
# FIX NGINX CONFIG
|
||||||
|
#################################
|
||||||
|
ensure_nginx_api_timeouts "$PROJECT_DIR/client/nginx-client.conf"
|
||||||
|
ensure_bus_location "$PROJECT_DIR/client/nginx-client.conf"
|
||||||
|
|
||||||
#################################
|
#################################
|
||||||
# REBUILD BACKEND
|
# REBUILD BACKEND
|
||||||
#################################
|
#################################
|
||||||
@@ -222,6 +315,9 @@ fi
|
|||||||
log "Пересоздание контейнеров..."
|
log "Пересоздание контейнеров..."
|
||||||
"${COMPOSE_CMD[@]}" up -d
|
"${COMPOSE_CMD[@]}" up -d
|
||||||
|
|
||||||
|
# Перезапуск frontend для применения nginx.conf
|
||||||
|
"${COMPOSE_CMD[@]}" restart frontend
|
||||||
|
|
||||||
# Проверка: все ли контейнеры запустились
|
# Проверка: все ли контейнеры запустились
|
||||||
if ! check_containers_running 60; then
|
if ! check_containers_running 60; then
|
||||||
error "Не удалось запустить контейнеры. Логи:"
|
error "Не удалось запустить контейнеры. Логи:"
|
||||||
|
|||||||
Reference in New Issue
Block a user