diff --git a/src/app/admin/security/page.test.tsx b/src/app/admin/security/page.test.tsx new file mode 100644 index 0000000..ed6e1d8 --- /dev/null +++ b/src/app/admin/security/page.test.tsx @@ -0,0 +1,148 @@ +import { describe, it, expect, jest, beforeAll, afterEach } from '@jest/globals'; +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import SecurityDashboard from './page'; + +jest.mock('lucide-react', () => ({ + Shield: () => , + AlertTriangle: () => , + Activity: () => , + Lock: () => , + RefreshCw: () => , + TrendingUp: () => , + TrendingDown: () => , +})); + +jest.mock('@/components/ui/button', () => ({ + Button: ({ children, disabled, ...props }: any) => ( + + ), +})); + +jest.mock('@/components/ui/card', () => ({ + Card: ({ children }: any) =>
{children}
, + CardHeader: ({ children }: any) =>
{children}
, + CardTitle: ({ children }: any) =>

{children}

, + CardContent: ({ children }: any) =>
{children}
, +})); + +global.fetch = jest.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ + success: true, + logs: [ + { + id: '1', + timestamp: Date.now(), + type: 'captcha', + severity: 'high', + message: '验证码验证失败', + ip: '192.168.1.1', + }, + ], + stats: { + totalRequests: 100, + blockedRequests: 5, + captchaAttempts: 10, + rateLimitHits: 3, + maliciousContentDetected: 2, + successRate: 95, + }, + }), + } as Response) +); + +describe('SecurityDashboard', () => { + beforeAll(() => { + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('Rendering', () => { + it('should render security dashboard', () => { + render(); + expect(screen.getByText('安全监控仪表板')).toBeInTheDocument(); + expect(screen.getByText('实时监控网站安全状态和威胁检测')).toBeInTheDocument(); + }); + + it('should render all stat cards', () => { + render(); + expect(screen.getByText('总请求数')).toBeInTheDocument(); + expect(screen.getByText('已拦截请求')).toBeInTheDocument(); + expect(screen.getByText('验证码尝试')).toBeInTheDocument(); + expect(screen.getByText('频率限制命中')).toBeInTheDocument(); + expect(screen.getByText('恶意内容检测')).toBeInTheDocument(); + expect(screen.getByText('成功率')).toBeInTheDocument(); + }); + + it('should display stats values', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('100')).toBeInTheDocument(); + expect(screen.getByText('5')).toBeInTheDocument(); + expect(screen.getByText('10')).toBeInTheDocument(); + expect(screen.getByText('3')).toBeInTheDocument(); + expect(screen.getByText('2')).toBeInTheDocument(); + expect(screen.getByText('95%')).toBeInTheDocument(); + }); + }); + }); + + describe('Security Logs', () => { + it('should render security logs section', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('安全日志')).toBeInTheDocument(); + }); + }); + + it('should display log entries', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('验证码验证失败')).toBeInTheDocument(); + expect(screen.getByText('IP: 192.168.1.1')).toBeInTheDocument(); + }); + }); + + it('should have filter buttons', () => { + render(); + expect(screen.getByText('全部')).toBeInTheDocument(); + expect(screen.getByText('高危')).toBeInTheDocument(); + expect(screen.getByText('中危')).toBeInTheDocument(); + expect(screen.getByText('低危')).toBeInTheDocument(); + }); + }); + + describe('Refresh Functionality', () => { + it('should have refresh button', async () => { + render(); + + await waitFor(() => { + expect(screen.getByTestId('refresh-cw-icon')).toBeInTheDocument(); + }); + }); + + it('should call fetch when refresh is clicked', async () => { + render(); + + await waitFor(() => { + const refreshButton = screen.getAllByRole('button')[0]; + expect(refreshButton).not.toBeDisabled(); + + refreshButton.click(); + + expect(global.fetch).toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/src/app/admin/security/page.tsx b/src/app/admin/security/page.tsx new file mode 100644 index 0000000..6f35c7e --- /dev/null +++ b/src/app/admin/security/page.tsx @@ -0,0 +1,271 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { Shield, AlertTriangle, Activity, Lock, RefreshCw, TrendingUp, TrendingDown } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; + +interface SecurityLog { + id: string; + timestamp: number; + type: 'captcha' | 'rate_limit' | 'sanitization' | 'malicious_content'; + severity: 'low' | 'medium' | 'high'; + message: string; + ip?: string; + email?: string; +} + +interface SecurityStats { + totalRequests: number; + blockedRequests: number; + captchaAttempts: number; + rateLimitHits: number; + maliciousContentDetected: number; + successRate: number; +} + +export default function SecurityDashboard() { + const [logs, setLogs] = useState([]); + const [stats, setStats] = useState({ + totalRequests: 0, + blockedRequests: 0, + captchaAttempts: 0, + rateLimitHits: 0, + maliciousContentDetected: 0, + successRate: 100, + }); + const [loading, setLoading] = useState(true); + const [filter, setFilter] = useState<'all' | 'high' | 'medium' | 'low'>('all'); + + useEffect(() => { + fetchSecurityData(); + }, []); + + const fetchSecurityData = async () => { + setLoading(true); + try { + const response = await fetch('/api/admin/security'); + if (response.ok) { + const data = await response.json(); + setLogs(data.logs || []); + setStats(data.stats || { + totalRequests: 0, + blockedRequests: 0, + captchaAttempts: 0, + rateLimitHits: 0, + maliciousContentDetected: 0, + successRate: 100, + }); + } + } catch (error) { + console.error('Failed to fetch security data:', error); + } finally { + setLoading(false); + } + }; + + const getSeverityColor = (severity: string) => { + switch (severity) { + case 'high': + return 'text-red-600 bg-red-50'; + case 'medium': + return 'text-yellow-600 bg-yellow-50'; + case 'low': + return 'text-blue-600 bg-blue-50'; + default: + return 'text-gray-600 bg-gray-50'; + } + }; + + const getTypeIcon = (type: string) => { + switch (type) { + case 'captcha': + return ; + case 'rate_limit': + return ; + case 'sanitization': + return ; + case 'malicious_content': + return ; + default: + return null; + } + }; + + const filteredLogs = filter === 'all' + ? logs + : logs.filter(log => log.severity === filter); + + return ( +
+
+
+
+

安全监控仪表板

+

实时监控网站安全状态和威胁检测

+
+ +
+ +
+ + + 总请求数 + + +
{stats.totalRequests}
+
+ + 实时统计 +
+
+
+ + + + 已拦截请求 + + +
{stats.blockedRequests}
+
+ 安全防护生效 +
+
+
+ + + + 验证码尝试 + + +
{stats.captchaAttempts}
+
+ 人机验证 +
+
+
+ + + + 频率限制命中 + + +
{stats.rateLimitHits}
+
+ 防刷机制 +
+
+
+ + + + 恶意内容检测 + + +
{stats.maliciousContentDetected}
+
+ 内容过滤 +
+
+
+ + + + 成功率 + + +
{stats.successRate}%
+
+ + 正常请求比例 +
+
+
+
+ + + +
+ 安全日志 +
+ + + + +
+
+
+ + {loading ? ( +
+ +
+ ) : filteredLogs.length === 0 ? ( +
+ +

暂无安全日志

+
+ ) : ( +
+ {filteredLogs.map((log) => ( +
+
+ {getTypeIcon(log.type)} +
+
+
+ {log.message} + + {log.severity === 'high' ? '高危' : log.severity === 'medium' ? '中危' : '低危'} + +
+
+ {new Date(log.timestamp).toLocaleString('zh-CN')} + {log.ip && IP: {log.ip}} + {log.email && 邮箱: {log.email}} +
+
+
+ ))} +
+ )} +
+
+
+
+ ); +} diff --git a/src/app/api/admin/security/route.ts b/src/app/api/admin/security/route.ts new file mode 100644 index 0000000..b54b5b6 --- /dev/null +++ b/src/app/api/admin/security/route.ts @@ -0,0 +1,24 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { SecurityLogger } from '@/lib/security/logger'; + +export async function GET(request: NextRequest) { + try { + const logs = SecurityLogger.getRecentLogs(100); + const stats = SecurityLogger.getStats(); + + return NextResponse.json({ + success: true, + logs, + stats, + }); + } catch (error) { + console.error('Error fetching security data:', error); + return NextResponse.json( + { + success: false, + error: 'Failed to fetch security data' + }, + { status: 500 } + ); + } +} diff --git a/src/lib/security/logger.ts b/src/lib/security/logger.ts index f53c908..359efaf 100644 --- a/src/lib/security/logger.ts +++ b/src/lib/security/logger.ts @@ -104,4 +104,40 @@ export class SecurityLogger { clear(): void { this.logger.clear(); } + + getStats() { + const logs = this.getRecentLogs(); + const totalRequests = logs.length; + const blockedRequests = logs.filter(log => + log.type === SecurityEventType.RATE_LIMIT_EXCEEDED || + log.type === SecurityEventType.BLOCKED_REQUEST || + log.type === SecurityEventType.MALICIOUS_CONTENT || + log.type === SecurityEventType.XSS_ATTEMPT || + log.type === SecurityEventType.SQL_INJECTION_ATTEMPT + ).length; + const captchaAttempts = logs.filter(log => + log.type === SecurityEventType.CAPTCHA_FAILED || + log.type === SecurityEventType.CAPTCHA_EXPIRED + ).length; + const rateLimitHits = logs.filter(log => + log.type === SecurityEventType.RATE_LIMIT_EXCEEDED + ).length; + const maliciousContentDetected = logs.filter(log => + log.type === SecurityEventType.MALICIOUS_CONTENT || + log.type === SecurityEventType.XSS_ATTEMPT || + log.type === SecurityEventType.SQL_INJECTION_ATTEMPT + ).length; + const successRate = totalRequests > 0 + ? ((totalRequests - blockedRequests) / totalRequests * 100).toFixed(2) + : 100; + + return { + totalRequests, + blockedRequests, + captchaAttempts, + rateLimitHits, + maliciousContentDetected, + successRate: parseFloat(successRate), + }; + } }