improve security: back port, base port, login guard, scripts
This commit is contained in:
+20
-5
@@ -13,8 +13,22 @@ server {
|
||||
}
|
||||
|
||||
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_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
@@ -25,11 +39,12 @@ server {
|
||||
proxy_read_timeout 650s;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 3000;
|
||||
listen 3100;
|
||||
server_name localhost;
|
||||
location / {
|
||||
proxy_pass http://backend:3000/;
|
||||
proxy_set_header Host \$http_host;
|
||||
proxy_pass http://backend:3100/;
|
||||
proxy_set_header Host $http_host;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Logger } from './utils/logger';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/api',
|
||||
withCredentials: true, // Отправлять cookies
|
||||
});
|
||||
|
||||
// Interceptor для добавления токена к каждому запросу
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import React, { createContext, useContext, useState } from 'react';
|
||||
import api from '../api';
|
||||
import { Logger } from '../utils/logger';
|
||||
|
||||
interface AuthContextType {
|
||||
token: string | null;
|
||||
isAuthenticated: boolean;
|
||||
login: (token: string) => void;
|
||||
logout: () => void;
|
||||
logout: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | null>(null);
|
||||
@@ -20,17 +22,53 @@ export const useAuth = () => {
|
||||
|
||||
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
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) => {
|
||||
Logger.debug('login() called', 'AuthContext', {
|
||||
tokenLength: newToken.length,
|
||||
});
|
||||
// Сохраняем токен в localStorage для обратной совместимости
|
||||
// Основной токен теперь в httpOnly cookie
|
||||
localStorage.setItem('token', 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');
|
||||
setToken(null);
|
||||
Logger.debug('Local auth state cleared', 'AuthContext');
|
||||
|
||||
// Редирект на страницу входа
|
||||
Logger.debug('Redirecting to /login after logout', 'AuthContext');
|
||||
window.location.href = '/login';
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import api from '../api';
|
||||
import { useAuth } from './AuthContext';
|
||||
@@ -8,19 +8,50 @@ export function AxiosInterceptor() {
|
||||
const { logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const logoutRef = useRef(logout);
|
||||
const navigateRef = useRef(navigate);
|
||||
const pathnameRef = useRef(location.pathname);
|
||||
|
||||
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(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
async (error) => {
|
||||
if (error.response && error.response.status === 401) {
|
||||
Logger.warn('401 Unauthorized detected → logging out and redirecting to /login', 'AxiosInterceptor');
|
||||
// Не делаем logout если уже на странице логина
|
||||
if (location.pathname !== '/login') {
|
||||
Logger.debug('Calling logout()', 'AxiosInterceptor');
|
||||
logout();
|
||||
Logger.debug('Navigating to /login...', 'AxiosInterceptor');
|
||||
navigate('/login');
|
||||
if (pathnameRef.current !== '/login') {
|
||||
try {
|
||||
Logger.debug('Calling logout()', 'AxiosInterceptor');
|
||||
await logoutRef.current();
|
||||
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);
|
||||
@@ -28,9 +59,10 @@ export function AxiosInterceptor() {
|
||||
);
|
||||
|
||||
return () => {
|
||||
Logger.debug('Ejecting axios response interceptor', 'AxiosInterceptor');
|
||||
api.interceptors.response.eject(interceptor);
|
||||
};
|
||||
}, [logout, navigate, location.pathname]);
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { useThemeContext } from '../ThemeContext';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { Menu as MenuIcon } from '@mui/icons-material';
|
||||
import { APP_VERSION } from '../utils/version';
|
||||
import { Logger } from '../utils/logger';
|
||||
|
||||
interface HeaderProps {
|
||||
onMenuClick?: () => void;
|
||||
@@ -27,11 +28,14 @@ export default function Header({ onMenuClick, isMobile }: HeaderProps) {
|
||||
const [confirmDialog, setConfirmDialog] = useState({ open: false, title: '', onConfirm: () => {} });
|
||||
|
||||
const handleLogout = () => {
|
||||
Logger.debug('Opening logout confirmation dialog', 'Header');
|
||||
setConfirmDialog({
|
||||
open: true,
|
||||
title: 'Вы действительно хотите выйти?',
|
||||
onConfirm: () => {
|
||||
logout();
|
||||
onConfirm: async () => {
|
||||
Logger.debug('Logout confirmed by user', 'Header');
|
||||
await logout();
|
||||
Logger.debug('logout() resolved in Header', 'Header');
|
||||
navigate('/login');
|
||||
}
|
||||
});
|
||||
@@ -154,12 +158,26 @@ export default function Header({ onMenuClick, isMobile }: HeaderProps) {
|
||||
<Typography>{confirmDialog.title}</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setConfirmDialog({ ...confirmDialog, open: false })}>Отмена</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
confirmDialog.onConfirm();
|
||||
Logger.debug('Logout canceled by user', 'Header');
|
||||
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"
|
||||
color="error"
|
||||
>
|
||||
@@ -169,4 +187,4 @@ export default function Header({ onMenuClick, isMobile }: HeaderProps) {
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
import api from '../api';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { Logger } from '../utils/logger';
|
||||
import { getApiErrorMessage } from '../utils/errorHandlers';
|
||||
import { getApiErrorMessage, getApiErrorStatus } from '../utils/errorHandlers';
|
||||
import { APP_VERSION } from '../utils/version';
|
||||
|
||||
export default function LoginPage() {
|
||||
@@ -15,7 +15,10 @@ export default function LoginPage() {
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
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 {
|
||||
const res = await api.post('/auth/login', creds);
|
||||
|
||||
@@ -23,11 +26,27 @@ export default function LoginPage() {
|
||||
Logger.debug(`Success → token received, calling login()`, 'Login');
|
||||
login(token);
|
||||
|
||||
Logger.debug('Navigating to / after successful login', 'Login');
|
||||
navigate('/');
|
||||
} catch (error) {
|
||||
} catch (error: unknown) {
|
||||
const status = getApiErrorStatus(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 { AuthProvider, useAuth } from '@/auth/AuthContext'
|
||||
import { ReactNode } from 'react'
|
||||
import api from '@/api'
|
||||
|
||||
// Мокаем localStorage
|
||||
const localStorageMock = (() => {
|
||||
@@ -24,25 +25,54 @@ Object.defineProperty(window, 'localStorage', {
|
||||
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 }) => (
|
||||
<AuthProvider>{children}</AuthProvider>
|
||||
)
|
||||
|
||||
describe('AuthContext', () => {
|
||||
const originalLocation = window.location
|
||||
|
||||
beforeEach(() => {
|
||||
localStorageMock.clear()
|
||||
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', () => {
|
||||
it('должен выбрасывать ошибку при использовании вне AuthProvider', () => {
|
||||
// Отключаем console.error для этого теста
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
|
||||
expect(() => {
|
||||
renderHook(() => useAuth())
|
||||
}).toThrow('useAuth must be used within an AuthProvider')
|
||||
|
||||
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
@@ -50,9 +80,9 @@ describe('AuthContext', () => {
|
||||
describe('initial state', () => {
|
||||
it('должен инициализироваться с token из localStorage', () => {
|
||||
localStorageMock.getItem.mockReturnValue('test-token-123')
|
||||
|
||||
|
||||
const { result } = renderHook(() => useAuth(), { wrapper })
|
||||
|
||||
|
||||
expect(result.current.token).toBe('test-token-123')
|
||||
expect(result.current.isAuthenticated).toBe(true)
|
||||
expect(localStorageMock.getItem).toHaveBeenCalledWith('token')
|
||||
@@ -60,9 +90,9 @@ describe('AuthContext', () => {
|
||||
|
||||
it('должен инициализироваться с null если token отсутствует в localStorage', () => {
|
||||
localStorageMock.getItem.mockReturnValue(null)
|
||||
|
||||
|
||||
const { result } = renderHook(() => useAuth(), { wrapper })
|
||||
|
||||
|
||||
expect(result.current.token).toBe(null)
|
||||
expect(result.current.isAuthenticated).toBe(false)
|
||||
})
|
||||
@@ -71,11 +101,11 @@ describe('AuthContext', () => {
|
||||
describe('login', () => {
|
||||
it('должен сохранять токен в localStorage и state', () => {
|
||||
const { result } = renderHook(() => useAuth(), { wrapper })
|
||||
|
||||
|
||||
act(() => {
|
||||
result.current.login('new-token-456')
|
||||
})
|
||||
|
||||
|
||||
expect(result.current.token).toBe('new-token-456')
|
||||
expect(result.current.isAuthenticated).toBe(true)
|
||||
expect(localStorageMock.setItem).toHaveBeenCalledWith('token', 'new-token-456')
|
||||
@@ -83,41 +113,42 @@ describe('AuthContext', () => {
|
||||
|
||||
it('должен обновлять isAuthenticated после login', () => {
|
||||
const { result } = renderHook(() => useAuth(), { wrapper })
|
||||
|
||||
|
||||
expect(result.current.isAuthenticated).toBe(false)
|
||||
|
||||
|
||||
act(() => {
|
||||
result.current.login('another-token')
|
||||
})
|
||||
|
||||
|
||||
expect(result.current.isAuthenticated).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('logout', () => {
|
||||
it('должен удалять токен из localStorage и state', () => {
|
||||
it('должен удалять токен из localStorage и state', async () => {
|
||||
localStorageMock.getItem.mockReturnValue('existing-token')
|
||||
|
||||
|
||||
const { result } = renderHook(() => useAuth(), { wrapper })
|
||||
|
||||
|
||||
expect(result.current.token).toBe('existing-token')
|
||||
|
||||
act(() => {
|
||||
result.current.logout()
|
||||
|
||||
await act(async () => {
|
||||
await result.current.logout()
|
||||
})
|
||||
|
||||
|
||||
expect(result.current.token).toBe(null)
|
||||
expect(result.current.isAuthenticated).toBe(false)
|
||||
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 })
|
||||
|
||||
act(() => {
|
||||
result.current.logout()
|
||||
|
||||
await act(async () => {
|
||||
await result.current.logout()
|
||||
})
|
||||
|
||||
|
||||
expect(result.current.token).toBe(null)
|
||||
expect(result.current.isAuthenticated).toBe(false)
|
||||
expect(localStorageMock.removeItem).toHaveBeenCalledWith('token')
|
||||
@@ -127,25 +158,25 @@ describe('AuthContext', () => {
|
||||
describe('isAuthenticated', () => {
|
||||
it('должен возвращать true когда token существует', () => {
|
||||
localStorageMock.getItem.mockReturnValue('valid-token')
|
||||
|
||||
|
||||
const { result } = renderHook(() => useAuth(), { wrapper })
|
||||
|
||||
|
||||
expect(result.current.isAuthenticated).toBe(true)
|
||||
})
|
||||
|
||||
it('должен возвращать false когда token null', () => {
|
||||
localStorageMock.getItem.mockReturnValue(null)
|
||||
|
||||
|
||||
const { result } = renderHook(() => useAuth(), { wrapper })
|
||||
|
||||
|
||||
expect(result.current.isAuthenticated).toBe(false)
|
||||
})
|
||||
|
||||
it('должен возвращать false когда token пустая строка', () => {
|
||||
localStorageMock.getItem.mockReturnValue('')
|
||||
|
||||
|
||||
const { result } = renderHook(() => useAuth(), { wrapper })
|
||||
|
||||
|
||||
expect(result.current.isAuthenticated).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -153,45 +184,45 @@ describe('AuthContext', () => {
|
||||
describe('context methods', () => {
|
||||
it('должен предоставлять метод login', () => {
|
||||
const { result } = renderHook(() => useAuth(), { wrapper })
|
||||
|
||||
|
||||
expect(result.current.login).toBeDefined()
|
||||
expect(typeof result.current.login).toBe('function')
|
||||
})
|
||||
|
||||
it('должен предоставлять метод logout', () => {
|
||||
const { result } = renderHook(() => useAuth(), { wrapper })
|
||||
|
||||
|
||||
expect(result.current.logout).toBeDefined()
|
||||
expect(typeof result.current.logout).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
describe('multiple login/logout cycles', () => {
|
||||
it('должен корректно обрабатывать несколько циклов login/logout', () => {
|
||||
it('должен корректно обрабатывать несколько циклов login/logout', async () => {
|
||||
const { result } = renderHook(() => useAuth(), { wrapper })
|
||||
|
||||
|
||||
// Первый цикл
|
||||
act(() => {
|
||||
result.current.login('token-1')
|
||||
})
|
||||
expect(result.current.token).toBe('token-1')
|
||||
|
||||
act(() => {
|
||||
result.current.logout()
|
||||
|
||||
await act(async () => {
|
||||
await result.current.logout()
|
||||
})
|
||||
expect(result.current.token).toBe(null)
|
||||
|
||||
|
||||
// Второй цикл
|
||||
act(() => {
|
||||
result.current.login('token-2')
|
||||
})
|
||||
expect(result.current.token).toBe('token-2')
|
||||
|
||||
act(() => {
|
||||
result.current.logout()
|
||||
|
||||
await act(async () => {
|
||||
await result.current.logout()
|
||||
})
|
||||
expect(result.current.token).toBe(null)
|
||||
|
||||
|
||||
// Третий цикл
|
||||
act(() => {
|
||||
result.current.login('token-3')
|
||||
|
||||
@@ -24,6 +24,8 @@ beforeAll(() => {
|
||||
message.includes('[Settings]') ||
|
||||
message.includes('[API]') ||
|
||||
message.includes('[Login]') ||
|
||||
message.includes('[AuthContext]') ||
|
||||
message.includes('[AxiosInterceptor]') ||
|
||||
message.includes('[Rotation]') ||
|
||||
message.includes('[Domains]') ||
|
||||
message.includes('[Subs]') ||
|
||||
@@ -40,6 +42,8 @@ beforeAll(() => {
|
||||
if (
|
||||
message.includes('[Tunnels]') ||
|
||||
message.includes('[Settings]') ||
|
||||
message.includes('[AuthContext]') ||
|
||||
message.includes('[AxiosInterceptor]') ||
|
||||
message.includes('[Domains]') ||
|
||||
message.includes('[Subs]') ||
|
||||
message.includes('[Scanner]')
|
||||
@@ -56,8 +60,11 @@ beforeAll(() => {
|
||||
if (
|
||||
message.includes('act(...)') ||
|
||||
message.includes('An update to') ||
|
||||
message.includes('Not implemented: navigation to another Document') ||
|
||||
message.includes('[Login]') ||
|
||||
message.includes('[Settings]') ||
|
||||
message.includes('[AuthContext]') ||
|
||||
message.includes('[AxiosInterceptor]') ||
|
||||
message.includes('[Subs]')
|
||||
) {
|
||||
return
|
||||
|
||||
@@ -31,6 +31,7 @@ services:
|
||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
|
||||
PORT: ${PORT}
|
||||
LOG_LEVEL: ${LOG_LEVEL}
|
||||
ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-}
|
||||
ports:
|
||||
- "${PORT}:${PORT}"
|
||||
networks:
|
||||
|
||||
+45
-10
@@ -246,6 +246,16 @@ DB_PASS=$(openssl rand -base64 12)
|
||||
JWT_SECRET=$(openssl rand -base64 32)
|
||||
ADMIN_USER=$(openssl rand -base64 8)
|
||||
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."
|
||||
|
||||
#################################
|
||||
@@ -334,6 +344,9 @@ DB_PASSWORD=${DB_PASS}
|
||||
DB_NAME=3dp_manager
|
||||
ADMIN_LOGIN=${ADMIN_USER}
|
||||
ADMIN_PASSWORD=${ADMIN_PASS}
|
||||
PORT=3100
|
||||
LOG_LEVEL=error
|
||||
ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-}
|
||||
EOF
|
||||
|
||||
if [[ "$USE_SSL" == "true" ]]; then
|
||||
@@ -355,7 +368,16 @@ server {
|
||||
try_files \$uri \$uri/ /index.html;
|
||||
}
|
||||
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 X-Real-IP \$remote_addr;
|
||||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||
@@ -365,7 +387,7 @@ server {
|
||||
}
|
||||
}
|
||||
server {
|
||||
listen 3000 ssl;
|
||||
listen 3100 ssl;
|
||||
server_name $UI_HOST;
|
||||
client_max_body_size 50M;
|
||||
|
||||
@@ -373,7 +395,7 @@ server {
|
||||
ssl_certificate_key /etc/nginx/certs/privkey.pem;
|
||||
|
||||
location / {
|
||||
proxy_pass http://backend:3000/;
|
||||
proxy_pass http://backend:3100/;
|
||||
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;
|
||||
@@ -419,7 +441,7 @@ services:
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
ADMIN_LOGIN: ${ADMIN_USER}
|
||||
ADMIN_PASSWORD: ${ADMIN_PASS}
|
||||
PORT: 3000
|
||||
PORT: 3100
|
||||
volumes:
|
||||
- /etc/hysteria/config.yaml:/etc/hysteria/config.yaml:ro
|
||||
networks:
|
||||
@@ -433,7 +455,7 @@ services:
|
||||
- backend
|
||||
ports:
|
||||
- "${FINAL_PORT}:443"
|
||||
- "3000:3000"
|
||||
- "3100:3100"
|
||||
volumes:
|
||||
- ./client/nginx-client.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
- ${CERT_PATH}:/etc/nginx/certs/fullchain.pem:ro
|
||||
@@ -465,7 +487,20 @@ server {
|
||||
try_files \$uri \$uri/ /index.html;
|
||||
}
|
||||
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_set_header Upgrade \$http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
@@ -479,10 +514,10 @@ server {
|
||||
}
|
||||
}
|
||||
server {
|
||||
listen 3000;
|
||||
listen 3100;
|
||||
server_name localhost;
|
||||
location / {
|
||||
proxy_pass http://backend:3000/;
|
||||
proxy_pass http://backend:3100/;
|
||||
proxy_set_header Host \$http_host;
|
||||
}
|
||||
}
|
||||
@@ -525,7 +560,7 @@ services:
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
ADMIN_LOGIN: ${ADMIN_USER}
|
||||
ADMIN_PASSWORD: ${ADMIN_PASS}
|
||||
PORT: 3000
|
||||
PORT: 3100
|
||||
volumes:
|
||||
- /etc/hysteria/config.yaml:/etc/hysteria/config.yaml:ro
|
||||
networks:
|
||||
@@ -539,7 +574,7 @@ services:
|
||||
- backend
|
||||
ports:
|
||||
- "${FINAL_PORT}:80"
|
||||
- "3000:3000"
|
||||
- "3100:3100"
|
||||
volumes:
|
||||
- ./client/nginx-client.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
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
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
ENV PORT=3100
|
||||
|
||||
EXPOSE 3000
|
||||
EXPOSE 3100
|
||||
|
||||
CMD ["node", "dist/main"]
|
||||
|
||||
Generated
+32
@@ -18,12 +18,14 @@
|
||||
"@nestjs/passport": "^11.0.5",
|
||||
"@nestjs/platform-express": "^11.0.1",
|
||||
"@nestjs/schedule": "^6.1.0",
|
||||
"@nestjs/throttler": "^6.5.0",
|
||||
"@nestjs/typeorm": "^11.0.0",
|
||||
"axios": "^1.13.2",
|
||||
"bcrypt": "^6.0.0",
|
||||
"cache-manager": "^7.2.8",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.15.1",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.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": {
|
||||
"version": "11.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/typeorm/-/typeorm-11.0.0.tgz",
|
||||
@@ -4825,6 +4838,25 @@
|
||||
"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": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
|
||||
|
||||
@@ -29,12 +29,14 @@
|
||||
"@nestjs/passport": "^11.0.5",
|
||||
"@nestjs/platform-express": "^11.0.1",
|
||||
"@nestjs/schedule": "^6.1.0",
|
||||
"@nestjs/throttler": "^6.5.0",
|
||||
"@nestjs/typeorm": "^11.0.0",
|
||||
"axios": "^1.13.2",
|
||||
"bcrypt": "^6.0.0",
|
||||
"cache-manager": "^7.2.8",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.15.1",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"pg": "^8.17.1",
|
||||
@@ -94,6 +96,9 @@
|
||||
"moduleNameMapper": {
|
||||
"^src/(.*)$": "<rootDir>/src/$1"
|
||||
},
|
||||
"setupFilesAfterEnv": [
|
||||
"<rootDir>/test/jest.setup.ts"
|
||||
],
|
||||
"collectCoverageFrom": [
|
||||
"src/**/*.ts",
|
||||
"!src/**/*.module.ts",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
|
||||
@@ -27,6 +28,12 @@ import { SessionModule } from './session/session.module';
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
}),
|
||||
ThrottlerModule.forRoot([
|
||||
{
|
||||
ttl: 60000,
|
||||
limit: 5,
|
||||
},
|
||||
]),
|
||||
TypeOrmModule.forRoot({
|
||||
type: 'postgres',
|
||||
host: process.env.DB_HOST,
|
||||
@@ -51,6 +58,10 @@ import { SessionModule } from './session/session.module';
|
||||
controllers: [AppController],
|
||||
providers: [
|
||||
AppService,
|
||||
{
|
||||
provide: APP_GUARD,
|
||||
useClass: ThrottlerGuard,
|
||||
},
|
||||
{
|
||||
provide: APP_GUARD,
|
||||
useClass: JwtAuthGuard,
|
||||
|
||||
@@ -4,9 +4,15 @@ import {
|
||||
Body,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Res,
|
||||
Logger,
|
||||
Req,
|
||||
} from '@nestjs/common';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
import { AuthService } from './auth.service';
|
||||
import { Public } from './public.decorator';
|
||||
import type { Request, Response } from 'express';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
interface LoginDto {
|
||||
login: string;
|
||||
@@ -15,19 +21,73 @@ interface LoginDto {
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private authService: AuthService) {}
|
||||
private readonly logger = new Logger(AuthController.name);
|
||||
|
||||
constructor(
|
||||
private authService: AuthService,
|
||||
private configService: ConfigService,
|
||||
) {}
|
||||
|
||||
@Public()
|
||||
@Throttle({
|
||||
default: { limit: 5, ttl: 60000 },
|
||||
})
|
||||
@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);
|
||||
if (!user) {
|
||||
this.logger.warn(`Login failed for user: ${req.login}`);
|
||||
throw new HttpException(
|
||||
'Неверный логин или пароль',
|
||||
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')
|
||||
|
||||
@@ -30,13 +30,9 @@ export class AuthService {
|
||||
where: { key: 'admin_password' },
|
||||
});
|
||||
|
||||
if (!dbLogin) {
|
||||
this.logger.error('Пользователь admin_login не найден в базе данных!');
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!dbPass) {
|
||||
this.logger.error('Пароль admin_password не найден в базе данных!');
|
||||
// Проверяем наличие учётных данных (без деталей для безопасности)
|
||||
if (!dbLogin || !dbPass) {
|
||||
this.logger.error('Учётные данные не найдены в базе данных');
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -55,6 +51,7 @@ export class AuthService {
|
||||
|
||||
login(user: { login: string }) {
|
||||
const payload = { username: user.login };
|
||||
this.logger.debug(`Генерация access token для пользователя: ${user.login}`);
|
||||
return {
|
||||
access_token: this.jwtService.sign(payload),
|
||||
};
|
||||
|
||||
@@ -8,6 +8,10 @@ import { AuthGuard } from '@nestjs/passport';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { Request } from 'express';
|
||||
|
||||
type RequestWithCookies = Request & {
|
||||
cookies?: unknown;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {
|
||||
private readonly logger = new Logger(JwtAuthGuard.name);
|
||||
@@ -17,7 +21,11 @@ export class JwtAuthGuard extends AuthGuard('jwt') {
|
||||
}
|
||||
|
||||
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(
|
||||
`canActivate called for: ${request.url} ${request.method}`,
|
||||
);
|
||||
@@ -33,6 +41,23 @@ export class JwtAuthGuard extends AuthGuard('jwt') {
|
||||
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)
|
||||
const tokenFromQuery = request.query.token as string | undefined;
|
||||
if (tokenFromQuery && !request.headers.authorization) {
|
||||
@@ -40,6 +65,14 @@ export class JwtAuthGuard extends AuthGuard('jwt') {
|
||||
`Token found in query parameter, adding to Authorization header`,
|
||||
);
|
||||
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()`);
|
||||
|
||||
+46
-4
@@ -3,7 +3,8 @@ import { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import { AppModule } from './app.module';
|
||||
import { AuthService } from './auth/auth.service';
|
||||
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 { ConfigService } from '@nestjs/config';
|
||||
|
||||
@@ -23,12 +24,53 @@ async function bootstrap() {
|
||||
|
||||
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);
|
||||
|
||||
// Cookie parser для работы с httpOnly cookies
|
||||
const cookieParserFactory = cookieParser as unknown as () => RequestHandler;
|
||||
app.use(cookieParserFactory());
|
||||
|
||||
const authService = app.get(AuthService);
|
||||
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.setGlobalPrefix('api', {
|
||||
exclude: [
|
||||
@@ -37,8 +79,8 @@ async function bootstrap() {
|
||||
],
|
||||
});
|
||||
|
||||
const port = configService.get<number>('PORT', 3000);
|
||||
await app.listen(port);
|
||||
const port = configService.get<number>('PORT', 3100);
|
||||
await app.listen(port, '0.0.0.0');
|
||||
logger.log(`Application started on port ${port}`);
|
||||
}
|
||||
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 */
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { HttpException } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { AuthController } from 'src/auth/auth.controller';
|
||||
import { AuthService } from 'src/auth/auth.service';
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
describe('AuthController', () => {
|
||||
let controller: AuthController;
|
||||
@@ -17,6 +19,7 @@ describe('AuthController', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
imports: [ConfigModule.forRoot()],
|
||||
controllers: [AuthController],
|
||||
providers: [
|
||||
{
|
||||
@@ -35,6 +38,18 @@ describe('AuthController', () => {
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const loginDto = { login: 'admin', password: 'password' };
|
||||
const mockUser = { login: 'admin' };
|
||||
@@ -43,7 +58,10 @@ describe('AuthController', () => {
|
||||
mockAuthService.validateUser.mockResolvedValue(mockUser);
|
||||
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(authService.validateUser).toHaveBeenCalledWith(
|
||||
@@ -51,6 +69,16 @@ describe('AuthController', () => {
|
||||
'password',
|
||||
);
|
||||
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 () => {
|
||||
@@ -58,11 +86,135 @@ describe('AuthController', () => {
|
||||
|
||||
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 { 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', () => {
|
||||
let guard: JwtAuthGuard;
|
||||
let reflector: Reflector;
|
||||
let mockContext: ExecutionContext;
|
||||
let mockRequest: any;
|
||||
let parentCanActivateSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
reflector = new Reflector();
|
||||
guard = new JwtAuthGuard(reflector);
|
||||
|
||||
mockRequest = {
|
||||
url: '/api/test',
|
||||
method: 'GET',
|
||||
headers: {},
|
||||
query: {},
|
||||
const parentPrototype = Object.getPrototypeOf(JwtAuthGuard.prototype) as {
|
||||
canActivate: (context: ExecutionContext) => boolean | Promise<boolean>;
|
||||
};
|
||||
|
||||
mockContext = {
|
||||
switchToHttp: jest.fn().mockReturnValue({
|
||||
getRequest: jest.fn().mockReturnValue(mockRequest),
|
||||
getResponse: jest.fn(),
|
||||
}),
|
||||
getHandler: jest.fn(),
|
||||
getClass: jest.fn(),
|
||||
} as any;
|
||||
parentCanActivateSpy = jest
|
||||
.spyOn(parentPrototype, 'canActivate')
|
||||
.mockReturnValue(true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
parentCanActivateSpy.mockRestore();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('canActivate', () => {
|
||||
it('должен вернуть true для публичного маршрута', () => {
|
||||
const request: TestRequest = {
|
||||
url: '/api/test',
|
||||
method: 'GET',
|
||||
headers: {},
|
||||
query: {},
|
||||
};
|
||||
const context = createContext(request);
|
||||
jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(true);
|
||||
|
||||
const result = guard.canActivate(mockContext);
|
||||
const result = guard.canActivate(context);
|
||||
|
||||
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);
|
||||
|
||||
const superCanActivate = jest
|
||||
.spyOn(JwtAuthGuard.prototype, 'canActivate' as any)
|
||||
.mockImplementation(() => true);
|
||||
await guard.canActivate(context);
|
||||
|
||||
guard.canActivate(mockContext);
|
||||
|
||||
expect(superCanActivate).toHaveBeenCalled();
|
||||
expect(parentCanActivateSpy).toHaveBeenCalledWith(context);
|
||||
});
|
||||
|
||||
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);
|
||||
mockRequest.query.token = 'test-jwt-token';
|
||||
mockRequest.headers.authorization = 'Bearer existing-token';
|
||||
|
||||
const _superCanActivate = jest
|
||||
.spyOn(JwtAuthGuard.prototype, 'canActivate' as any)
|
||||
.mockImplementation(() => true);
|
||||
await guard.canActivate(context);
|
||||
|
||||
guard.canActivate(mockContext);
|
||||
|
||||
expect(mockRequest.headers.authorization).toBe('Bearer existing-token');
|
||||
expect(request.headers.authorization).toBe('Bearer cookie-jwt-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);
|
||||
|
||||
const _superCanActivate = jest
|
||||
.spyOn(JwtAuthGuard.prototype, 'canActivate' as any)
|
||||
.mockImplementation(() => 'PENDING_RESULT');
|
||||
await guard.canActivate(context);
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
it('должен бросить ошибку при наличии ошибки', () => {
|
||||
const _error = new Error('Invalid token');
|
||||
it('должен пробрасывать Error как есть', () => {
|
||||
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 и нет ошибки', () => {
|
||||
expect(() => guard.handleRequest(null, null, null)).toThrow(
|
||||
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 {
|
||||
it('должен бросать UnauthorizedException, если нет user и err', () => {
|
||||
expect(() => {
|
||||
guard.handleRequest(null, null, null);
|
||||
} catch (_e: any) {
|
||||
// UnauthorizedException имеет пустое сообщение по умолчанию
|
||||
expect(_e).toBeInstanceOf(UnauthorizedException);
|
||||
}
|
||||
}).toThrow(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', () => {
|
||||
it('должен вернуть все настройки как объект', async () => {
|
||||
const mockSettings = [
|
||||
{ key: 'xui_url', value: 'http://localhost:3000' },
|
||||
{ key: 'xui_url', value: 'http://localhost:3100' },
|
||||
{ key: 'xui_login', value: 'admin' },
|
||||
{ key: 'xui_password', value: 'password' },
|
||||
];
|
||||
@@ -60,7 +60,7 @@ describe('SettingsController', () => {
|
||||
const result = await controller.findAll();
|
||||
|
||||
expect(result).toEqual({
|
||||
xui_url: 'http://localhost:3000',
|
||||
xui_url: 'http://localhost:3100',
|
||||
xui_login: 'admin',
|
||||
xui_password: 'password',
|
||||
});
|
||||
@@ -79,7 +79,7 @@ describe('SettingsController', () => {
|
||||
describe('checkConnection', () => {
|
||||
it('должен проверить подключение к 3x-ui', async () => {
|
||||
const body = {
|
||||
xui_url: 'http://localhost:3000',
|
||||
xui_url: 'http://localhost:3100',
|
||||
xui_login: 'admin',
|
||||
xui_password: 'password',
|
||||
};
|
||||
@@ -98,7 +98,7 @@ describe('SettingsController', () => {
|
||||
|
||||
it('должен вернуть false при неудачном подключении', async () => {
|
||||
const body = {
|
||||
xui_url: 'http://localhost:3000',
|
||||
xui_url: 'http://localhost:3100',
|
||||
xui_login: 'admin',
|
||||
xui_password: 'wrong',
|
||||
};
|
||||
|
||||
@@ -70,7 +70,7 @@ describe('XuiService', () => {
|
||||
|
||||
it('должен вернуть true при успешном логине', async () => {
|
||||
mockSettingsRepo.find.mockResolvedValue([
|
||||
{ key: 'xui_url', value: 'http://localhost:3000' },
|
||||
{ key: 'xui_url', value: 'http://localhost:3100' },
|
||||
{ key: 'xui_login', value: 'admin' },
|
||||
{ key: 'xui_password', value: 'password' },
|
||||
]);
|
||||
@@ -92,7 +92,7 @@ describe('XuiService', () => {
|
||||
|
||||
it('должен вернуть false при ошибке логина', async () => {
|
||||
mockSettingsRepo.find.mockResolvedValue([
|
||||
{ key: 'xui_url', value: 'http://localhost:3000' },
|
||||
{ key: 'xui_url', value: 'http://localhost:3100' },
|
||||
{ key: 'xui_login', value: 'admin' },
|
||||
{ key: 'xui_password', value: 'password' },
|
||||
]);
|
||||
@@ -106,7 +106,7 @@ describe('XuiService', () => {
|
||||
|
||||
it('должен вернуть false, если нет cookie в ответе', async () => {
|
||||
mockSettingsRepo.find.mockResolvedValue([
|
||||
{ key: 'xui_url', value: 'http://localhost:3000' },
|
||||
{ key: 'xui_url', value: 'http://localhost:3100' },
|
||||
{ key: 'xui_login', value: 'admin' },
|
||||
{ key: 'xui_password', value: 'password' },
|
||||
]);
|
||||
@@ -126,7 +126,7 @@ describe('XuiService', () => {
|
||||
mockAxiosInstance.post.mockRejectedValue(new Error('Connection failed'));
|
||||
|
||||
const result = await service.checkConnection(
|
||||
'http://localhost:3000',
|
||||
'http://localhost:3100',
|
||||
'admin',
|
||||
'password',
|
||||
);
|
||||
|
||||
@@ -61,26 +61,25 @@ check_containers_running() {
|
||||
|
||||
check_and_fix_credentials() {
|
||||
log "Проверка учётных данных на безопасность..."
|
||||
|
||||
|
||||
local env_file=".env"
|
||||
local compose_file="docker-compose.yml"
|
||||
local credentials_changed=0
|
||||
|
||||
|
||||
# Проверяем, существует ли .env файл
|
||||
if [[ ! -f "$env_file" ]]; then
|
||||
log "Создание .env файла с безопасными учётными данными..."
|
||||
|
||||
|
||||
# Генерируем случайные пароли
|
||||
local db_pass
|
||||
local jwt_secret
|
||||
local admin_login
|
||||
local admin_pass
|
||||
|
||||
|
||||
db_pass=$(openssl rand -base64 12 | tr -dc 'A-Za-z0-9' | cut -c1-12)
|
||||
jwt_secret=$(openssl rand -base64 32)
|
||||
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)
|
||||
|
||||
|
||||
# Создаём .env файл
|
||||
cat > "$env_file" <<EOF
|
||||
POSTGRES_USER=admin
|
||||
@@ -89,15 +88,18 @@ POSTGRES_DB=3dp_manager
|
||||
JWT_SECRET=${jwt_secret}
|
||||
ADMIN_LOGIN=${admin_login}
|
||||
ADMIN_PASSWORD=${admin_pass}
|
||||
PORT=3100
|
||||
LOG_LEVEL=error
|
||||
ALLOWED_ORIGINS=
|
||||
EOF
|
||||
|
||||
|
||||
log "Сгенерированы новые учётные данные:"
|
||||
log " ADMIN_LOGIN: ${admin_login}"
|
||||
log " ADMIN_PASSWORD: ${admin_pass}"
|
||||
log " POSTGRES_PASSWORD: ${db_pass}"
|
||||
log " JWT_SECRET: ${jwt_secret}"
|
||||
log "⚠️ Сохраните эти данные в безопасном месте!"
|
||||
|
||||
|
||||
credentials_changed=1
|
||||
else
|
||||
# Проверяем, не используются ли дефолтные значения
|
||||
@@ -105,52 +107,52 @@ EOF
|
||||
local admin_pass_val
|
||||
local jwt_secret_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_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 "")
|
||||
db_pass_val=$(grep -E "^POSTGRES_PASSWORD=" "$env_file" 2>/dev/null | cut -d'=' -f2 | tr -d '"' | tr -d "'" || echo "")
|
||||
|
||||
|
||||
local needs_update=0
|
||||
|
||||
|
||||
if [[ "$admin_login_val" == "admin" ]] || [[ -z "$admin_login_val" ]]; then
|
||||
warn "Обнаружен дефолтный ADMIN_LOGIN=admin"
|
||||
needs_update=1
|
||||
fi
|
||||
|
||||
|
||||
if [[ "$admin_pass_val" == "admin" ]] || [[ -z "$admin_pass_val" ]]; then
|
||||
warn "Обнаружен дефолтный ADMIN_PASSWORD=admin"
|
||||
needs_update=1
|
||||
fi
|
||||
|
||||
|
||||
if [[ "$jwt_secret_val" == "secretKey" ]] || [[ -z "$jwt_secret_val" ]]; then
|
||||
warn "Обнаружен дефолтный JWT_SECRET=secretKey"
|
||||
needs_update=1
|
||||
fi
|
||||
|
||||
|
||||
if [[ "$db_pass_val" == "admin" ]] || [[ -z "$db_pass_val" ]]; then
|
||||
warn "Обнаружен дефолтный POSTGRES_PASSWORD=admin"
|
||||
needs_update=1
|
||||
fi
|
||||
|
||||
|
||||
if [[ $needs_update -eq 1 ]]; then
|
||||
log "Генерация новых безопасных учётных данных..."
|
||||
|
||||
|
||||
# Генерируем новые пароли
|
||||
local new_db_pass
|
||||
local new_jwt_secret
|
||||
local new_admin_login
|
||||
local new_admin_pass
|
||||
|
||||
|
||||
new_db_pass=$(openssl rand -base64 12 | tr -dc 'A-Za-z0-9' | cut -c1-12)
|
||||
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_pass=$(openssl rand -base64 12 | tr -dc 'A-Za-z0-9' | cut -c1-12)
|
||||
|
||||
|
||||
# Сохраняем существующие значения, которые не нужно менять
|
||||
local existing_postgres_user
|
||||
existing_postgres_user=$(grep -E "^POSTGRES_USER=" "$env_file" 2>/dev/null | cut -d'=' -f2 || echo "admin")
|
||||
|
||||
|
||||
# Создаём новый .env файл
|
||||
cat > "$env_file" <<EOF
|
||||
POSTGRES_USER=${existing_postgres_user:-admin}
|
||||
@@ -160,23 +162,108 @@ JWT_SECRET=${new_jwt_secret}
|
||||
ADMIN_LOGIN=${new_admin_login}
|
||||
ADMIN_PASSWORD=${new_admin_pass}
|
||||
EOF
|
||||
|
||||
|
||||
log "Сгенерированы новые учётные данные:"
|
||||
log " ADMIN_LOGIN: ${new_admin_login}"
|
||||
log " ADMIN_PASSWORD: ${new_admin_pass}"
|
||||
log " POSTGRES_PASSWORD: ${new_db_pass}"
|
||||
log " JWT_SECRET: ${new_jwt_secret}"
|
||||
log "⚠️ Сохраните эти данные в безопасном месте!"
|
||||
|
||||
|
||||
credentials_changed=1
|
||||
else
|
||||
log "Учётные данные безопасны ✅"
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
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() {
|
||||
[[ $EUID -eq 0 ]] || die "Запускать только от root"
|
||||
}
|
||||
@@ -209,6 +296,12 @@ log "Compose команда: ${COMPOSE_CMD[*]}"
|
||||
#################################
|
||||
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
|
||||
#################################
|
||||
@@ -222,6 +315,9 @@ fi
|
||||
log "Пересоздание контейнеров..."
|
||||
"${COMPOSE_CMD[@]}" up -d
|
||||
|
||||
# Перезапуск frontend для применения nginx.conf
|
||||
"${COMPOSE_CMD[@]}" restart frontend
|
||||
|
||||
# Проверка: все ли контейнеры запустились
|
||||
if ! check_containers_running 60; then
|
||||
error "Не удалось запустить контейнеры. Логи:"
|
||||
|
||||
Reference in New Issue
Block a user