http warning
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import {
|
||||
Toolbar, Drawer, List, ListItem,
|
||||
ListItemButton, ListItemIcon, ListItemText, Box, useMediaQuery, useTheme
|
||||
import {
|
||||
Toolbar, Drawer, List, ListItem,
|
||||
ListItemButton, ListItemIcon, ListItemText, Box, useMediaQuery, useTheme
|
||||
} from '@mui/material';
|
||||
import { People, Settings, Dns, SwapHoriz } from '@mui/icons-material';
|
||||
import { useNavigate, useLocation, Outlet } from 'react-router-dom';
|
||||
@@ -8,6 +8,8 @@ import { useState } from 'react';
|
||||
|
||||
import Header from './Header';
|
||||
import Footer from './Footer';
|
||||
import SecurityWarning from './SecurityWarning';
|
||||
import { useSecureConnection } from '../utils/useSecureConnection';
|
||||
|
||||
const drawerWidth = 240;
|
||||
|
||||
@@ -17,6 +19,7 @@ export default function Layout() {
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const { isSecure } = useSecureConnection();
|
||||
|
||||
const handleDrawerToggle = () => {
|
||||
setMobileOpen(!mobileOpen);
|
||||
@@ -81,6 +84,7 @@ export default function Layout() {
|
||||
}}
|
||||
>
|
||||
<Toolbar />
|
||||
{!isSecure && <SecurityWarning />}
|
||||
<Box sx={{ flexGrow: 1, p: { xs: 2, md: 3 } }}>
|
||||
<Outlet />
|
||||
</Box>
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { Alert, AlertTitle, Box, Button, Collapse, IconButton, Snackbar, useMediaQuery, useTheme } from '@mui/material';
|
||||
import { Close, ContentCopy } from '@mui/icons-material';
|
||||
import { useState } from 'react';
|
||||
|
||||
const INSTALL_COMMAND = 'bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/install.sh)';
|
||||
|
||||
export default function SecurityWarning() {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(INSTALL_COMMAND);
|
||||
setCopied(true);
|
||||
} catch {
|
||||
// Fallback для старых браузеров
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = INSTALL_COMMAND;
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textArea);
|
||||
setCopied(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Collapse in={true}>
|
||||
<Alert
|
||||
severity="warning"
|
||||
variant="filled"
|
||||
sx={{
|
||||
borderRadius: 0,
|
||||
borderBottom: '1px solid rgba(0, 0, 0, 0.1)',
|
||||
}}
|
||||
>
|
||||
<AlertTitle sx={{ fontWeight: 'bold', mb: 1, fontSize: { xs: '1rem', sm: '1.1rem' } }}>
|
||||
3DP-MANAGER работает в небезопасном режиме (HTTP)
|
||||
</AlertTitle>
|
||||
<Box
|
||||
component="p"
|
||||
sx={{
|
||||
mb: 2,
|
||||
fontSize: { xs: '0.875rem', sm: '0.95rem' },
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
<strong>Не вводите реальные пароли от 3x-ui панели и не меняйте пароль администратора в режиме работы по HTTP!</strong>{' '}
|
||||
Для безопасной работы переустановите 3DP-MANAGER с SSL-сертификатами. Все ваши настройки сохранятся.
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.1)',
|
||||
p: 1,
|
||||
borderRadius: 1,
|
||||
flexWrap: { xs: 'wrap', sm: 'nowrap' },
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="code"
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
fontSize: { xs: '0.75rem', sm: '0.85rem' },
|
||||
wordBreak: 'break-all',
|
||||
fontFamily: 'monospace',
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
{INSTALL_COMMAND}
|
||||
</Box>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
startIcon={!isMobile && <ContentCopy />}
|
||||
onClick={handleCopy}
|
||||
sx={{
|
||||
color: 'inherit',
|
||||
borderColor: 'currentColor',
|
||||
whiteSpace: 'nowrap',
|
||||
flexShrink: 0,
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.2)',
|
||||
},
|
||||
minWidth: { xs: 'auto', sm: '140px' },
|
||||
px: { xs: 1, sm: 2 },
|
||||
}}
|
||||
>
|
||||
{isMobile ? <ContentCopy fontSize="small" /> : 'Копировать'}
|
||||
</Button>
|
||||
</Box>
|
||||
</Alert>
|
||||
</Collapse>
|
||||
|
||||
<Snackbar
|
||||
open={copied}
|
||||
autoHideDuration={2000}
|
||||
onClose={() => setCopied(false)}
|
||||
message="Скопировано"
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||
action={
|
||||
<IconButton size="small" color="inherit" onClick={() => setCopied(false)}>
|
||||
<Close fontSize="small" />
|
||||
</IconButton>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
/**
|
||||
* Хук для определения безопасного соединения (HTTPS)
|
||||
* @returns {isSecure: boolean} - true если соединение по HTTPS
|
||||
*/
|
||||
export function useSecureConnection() {
|
||||
const isSecure = useMemo(() => {
|
||||
// Проверка в браузере
|
||||
if (typeof window !== 'undefined' && window.location) {
|
||||
return window.location.protocol === 'https:';
|
||||
}
|
||||
// SSR fallback - считаем небезопасным
|
||||
return false;
|
||||
}, []);
|
||||
|
||||
return { isSecure };
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import SecurityWarning from '../../src/components/SecurityWarning'
|
||||
|
||||
// Мок для navigator.clipboard
|
||||
const mockWriteText = vi.fn()
|
||||
Object.assign(navigator, {
|
||||
clipboard: {
|
||||
writeText: mockWriteText,
|
||||
},
|
||||
})
|
||||
|
||||
// Мок для document.execCommand (fallback для старых браузеров)
|
||||
const mockExecCommand = vi.fn()
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
document.execCommand = mockExecCommand
|
||||
})
|
||||
|
||||
describe('SecurityWarning', () => {
|
||||
const INSTALL_COMMAND = 'bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/install.sh)'
|
||||
|
||||
const renderWarning = () => {
|
||||
return render(<SecurityWarning />)
|
||||
}
|
||||
|
||||
describe('рендеринг', () => {
|
||||
it('должен рендериться с заголовком предупреждения', () => {
|
||||
renderWarning()
|
||||
expect(
|
||||
screen.getByText(/3DP-MANAGER работает в небезопасном режиме/i)
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('должен отображать предупреждение о паролях', () => {
|
||||
renderWarning()
|
||||
expect(
|
||||
screen.getByText(/Не вводите реальные пароли от 3x-ui панели/i)
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('должен отображать инструкцию по переустановке', () => {
|
||||
renderWarning()
|
||||
expect(
|
||||
screen.getByText(/Для безопасной работы переустановите 3DP-MANAGER с SSL-сертификатами/i)
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('должен отображать команду установки', () => {
|
||||
renderWarning()
|
||||
expect(screen.getByText(INSTALL_COMMAND)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('должен отображать кнопку копирования команды', () => {
|
||||
renderWarning()
|
||||
expect(screen.getByText('Копировать')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('должен отображать иконку копирования', () => {
|
||||
renderWarning()
|
||||
expect(screen.getByTestId('icon-ContentCopy')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('должен иметь семантически правильный Alert', () => {
|
||||
renderWarning()
|
||||
const alert = screen.getByRole('alert')
|
||||
expect(alert).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('копирование команды', () => {
|
||||
it('должен копировать команду в буфер обмена при клике', async () => {
|
||||
mockWriteText.mockResolvedValue(undefined)
|
||||
|
||||
renderWarning()
|
||||
const copyButton = screen.getByText('Копировать')
|
||||
fireEvent.click(copyButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockWriteText).toHaveBeenCalledWith(INSTALL_COMMAND)
|
||||
})
|
||||
})
|
||||
|
||||
it('должен показывать уведомление "Скопировано" после копирования', async () => {
|
||||
mockWriteText.mockResolvedValue(undefined)
|
||||
|
||||
renderWarning()
|
||||
const copyButton = screen.getByText('Копировать')
|
||||
fireEvent.click(copyButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Скопировано')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('должен использовать fallback при отсутствии navigator.clipboard', async () => {
|
||||
// Мок ошибки clipboard API
|
||||
mockWriteText.mockRejectedValue(new Error('Not supported'))
|
||||
|
||||
renderWarning()
|
||||
const copyButton = screen.getByText('Копировать')
|
||||
fireEvent.click(copyButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockExecCommand).toHaveBeenCalledWith('copy')
|
||||
})
|
||||
})
|
||||
|
||||
it('должен показывать уведомление "Скопировано" при использовании fallback', async () => {
|
||||
mockWriteText.mockRejectedValue(new Error('Not supported'))
|
||||
mockExecCommand.mockReturnValue(true)
|
||||
|
||||
renderWarning()
|
||||
const copyButton = screen.getByText('Копировать')
|
||||
fireEvent.click(copyButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Скопировано')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Snackbar уведомление', () => {
|
||||
it('должен показывать уведомление после копирования', async () => {
|
||||
mockWriteText.mockResolvedValue(undefined)
|
||||
|
||||
renderWarning()
|
||||
const copyButton = screen.getByText('Копировать')
|
||||
fireEvent.click(copyButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Скопировано')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('должен закрывать уведомление при клике на кнопку закрытия', async () => {
|
||||
mockWriteText.mockResolvedValue(undefined)
|
||||
|
||||
renderWarning()
|
||||
const copyButton = screen.getByText('Копировать')
|
||||
fireEvent.click(copyButton)
|
||||
|
||||
// Ждём появления уведомления
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Скопировано')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Находим кнопку закрытия по иконке Close
|
||||
const closeButton = screen.getByTestId('icon-Close').closest('button')
|
||||
if (closeButton) {
|
||||
fireEvent.click(closeButton)
|
||||
}
|
||||
|
||||
// Уведомление закрыто
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Скопировано')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('стили компонента', () => {
|
||||
it('должен иметь variant="filled"', () => {
|
||||
renderWarning()
|
||||
const alert = screen.getByRole('alert')
|
||||
// Проверяем что Alert имеет filled стиль (проверка через классы MUI)
|
||||
expect(alert).toHaveClass('MuiAlert-filledWarning')
|
||||
})
|
||||
|
||||
it('должен отображать код в monospace шрифте', () => {
|
||||
renderWarning()
|
||||
const codeBlock = screen.getByText(INSTALL_COMMAND)
|
||||
expect(codeBlock.tagName).toBe('CODE')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,91 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { useSecureConnection } from '../../src/utils/useSecureConnection'
|
||||
|
||||
describe('useSecureConnection', () => {
|
||||
const originalLocation = window.location
|
||||
|
||||
beforeEach(() => {
|
||||
// Очищаем моки перед каждым тестом
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
// Восстанавливаем оригинальный location
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: originalLocation,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
})
|
||||
|
||||
describe('при HTTPS соединении', () => {
|
||||
it('должен возвращать isSecure: true когда protocol https:', () => {
|
||||
// Мок для HTTPS
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: {
|
||||
protocol: 'https:',
|
||||
},
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useSecureConnection())
|
||||
|
||||
expect(result.current.isSecure).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('при HTTP соединении', () => {
|
||||
it('должен возвращать isSecure: false когда protocol http:', () => {
|
||||
// Мок для HTTP
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: {
|
||||
protocol: 'http:',
|
||||
},
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useSecureConnection())
|
||||
|
||||
expect(result.current.isSecure).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('при localhost', () => {
|
||||
it('должен возвращать isSecure: false для localhost без HTTPS', () => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: {
|
||||
protocol: 'http:',
|
||||
hostname: 'localhost',
|
||||
},
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useSecureConnection())
|
||||
|
||||
expect(result.current.isSecure).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('мемозация', () => {
|
||||
it('должен возвращать одно и то же значение при повторных рендерах', () => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: {
|
||||
protocol: 'https:',
|
||||
},
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
|
||||
const { result, rerender } = renderHook(() => useSecureConnection())
|
||||
const firstValue = result.current.isSecure
|
||||
|
||||
rerender()
|
||||
|
||||
expect(result.current.isSecure).toBe(firstValue)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user