feat: 实现Vue驱动的云存储系统初始功能

- 后端: Node.js + Express + SQLite架构
- 前端: Vue 3 + Axios实现
- 功能: 用户认证、文件上传/下载、分享链接、密码重置
- 安全: 密码加密、分享链接过期机制、缓存一致性
- 部署: Docker + Nginx容器化配置
- 测试: 完整的边界测试、并发测试和状态一致性测试
This commit is contained in:
Dev Team
2026-01-20 23:23:51 +08:00
commit b7b00fff48
45 changed files with 51758 additions and 0 deletions

46
backend/.dockerignore Normal file
View File

@@ -0,0 +1,46 @@
# 依赖目录
node_modules
# 数据目录
data/
storage/
# 环境配置
.env
.env.local
.env.*.local
# 日志
*.log
npm-debug.log*
# 编辑器
.idea/
.vscode/
*.swp
*.swo
*~
# 操作系统
.DS_Store
Thumbs.db
# 测试和开发文件
*.test.js
*.spec.js
test/
tests/
coverage/
# 文档
*.md
!README.md
# Git
.git
.gitignore
# 临时文件
*.tmp
*.temp
.cache/

168
backend/.env.example Normal file
View File

@@ -0,0 +1,168 @@
# ============================================
# 玩玩云 - 环境配置文件示例
# ============================================
#
# 使用说明:
# 1. 复制此文件为 .env
# 2. 根据实际情况修改配置值
# 3. ⚠️ 生产环境必须修改默认密码和密钥
#
# ============================================
# 服务器配置
# ============================================
# 服务端口
PORT=40001
# 运行环境production 或 development
NODE_ENV=production
# 强制HTTPS访问生产环境建议开启
# 设置为 true 时,仅接受 HTTPS 访问
ENFORCE_HTTPS=false
# 公开访问端口nginx监听的端口用于生成分享链接
# 标准端口(80/443)可不配置
PUBLIC_PORT=80
# ============================================
# 安全配置
# ============================================
# 加密密钥(必须配置!)
# 用于加密 OSS Access Key Secret 等敏感数据
# 生成方法: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
ENCRYPTION_KEY=your-encryption-key-please-change-this
# JWT密钥必须修改
# 生成方法: openssl rand -base64 32
# 或使用: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
JWT_SECRET=your-secret-key-PLEASE-CHANGE-THIS-IN-PRODUCTION
# Refresh Token 密钥(可选,默认使用 JWT_SECRET 派生)
# 建议生产环境设置独立的密钥
# REFRESH_SECRET=your-refresh-secret-key
# 管理员账号配置(首次启动时创建)
ADMIN_USERNAME=admin
ADMIN_PASSWORD=admin123
# ============================================
# CORS 跨域配置(重要!)
# ============================================
# 允许访问的前端域名
#
# 格式说明:
# - 单个域名: https://yourdomain.com
# - 多个域名: https://domain1.com,https://domain2.com
# - 开发环境: 留空或设置为 * (不安全,仅开发使用)
#
# ⚠️ 生产环境安全要求:
# 1. 必须配置具体的域名,不要使用 *
# 2. 必须包含协议 (http:// 或 https://)
# 3. 如果使用非标准端口,需要包含端口号
#
# 示例:
# ALLOWED_ORIGINS=https://pan.example.com
# ALLOWED_ORIGINS=https://pan.example.com,https://admin.example.com
# ALLOWED_ORIGINS=http://localhost:8080 # 开发环境
#
ALLOWED_ORIGINS=
# Cookie 安全配置
# 使用 HTTPS 时必须设置为 true
# HTTP 环境设置为 false
COOKIE_SECURE=false
# CSRF 防护配置
# 启用 CSRF 保护(建议生产环境开启)
# 前端会自动从 Cookie 读取 csrf_token 并在请求头中发送
ENABLE_CSRF=false
# ============================================
# 反向代理配置Nginx/Cloudflare等
# ============================================
# 信任代理配置
#
# 配置选项:
# - false: 不信任代理(默认,直接暴露到公网时使用)
# - 1: 信任第1跳代理推荐单层Nginx反向代理时使用
# - 2: 信任前2跳代理Cloudflare + Nginx
# - loopback: 仅信任本地回环地址
# - true: 信任所有代理不推荐易被伪造IP
#
# ⚠️ 重要: 如果使用 Nginx 反向代理并开启 ENFORCE_HTTPS=true
# 必须配置 TRUST_PROXY=1否则后端无法正确识别HTTPS请求
#
TRUST_PROXY=false
# ============================================
# 存储配置
# ============================================
# 数据库路径
DATABASE_PATH=./data/database.db
# 本地存储根目录(本地存储模式使用)
STORAGE_ROOT=./storage
# ============================================
# OSS 云存储配置(可选)
# ============================================
#
# 说明: 用户可以在 Web 界面配置自己的 OSS 存储
# 支持:阿里云 OSS、腾讯云 COS、AWS S3
# 此处配置仅作为全局默认值(通常不需要配置)
#
# OSS_PROVIDER=aliyun # 服务商: aliyun/tencent/aws
# OSS_REGION=oss-cn-hangzhou # 地域
# OSS_ACCESS_KEY_ID=your-key # Access Key ID
# OSS_ACCESS_KEY_SECRET=secret # Access Key Secret
# OSS_BUCKET=your-bucket # 存储桶名称
# OSS_ENDPOINT= # 自定义 Endpoint可选
# ============================================
# Session 配置
# ============================================
# Session 密钥(用于验证码等功能)
# 默认使用随机生成的密钥
# SESSION_SECRET=your-session-secret
# Session 过期时间(毫秒),默认 30 分钟
# SESSION_MAX_AGE=1800000
# ============================================
# 开发调试配置
# ============================================
# 日志级别 (error, warn, info, debug)
# LOG_LEVEL=info
# 是否启用调试模式
# DEBUG=false
# ============================================
# 注意事项
# ============================================
#
# 1. 生产环境必须修改以下配置:
# - ENCRYPTION_KEY: 用于加密敏感数据64位十六进制
# - JWT_SECRET: 使用强随机密钥64位十六进制
# - ADMIN_PASSWORD: 修改默认密码
# - ALLOWED_ORIGINS: 配置具体域名
#
# 2. 使用 HTTPS 时:
# - ENFORCE_HTTPS=true
# - COOKIE_SECURE=true
# - TRUST_PROXY=1 (如使用反向代理)
#
# 3. 配置优先级:
# 环境变量 > .env 文件 > 默认值
#
# 4. 密钥生成命令:
# node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"

28
backend/Dockerfile Normal file
View File

@@ -0,0 +1,28 @@
FROM node:20-alpine
WORKDIR /app
# 安装编译工具和健康检查所需的 wget
RUN apk add --no-cache python3 make g++ wget
# 复制 package 文件
COPY package*.json ./
# 安装依赖
RUN npm install --production
# 复制应用代码
COPY . .
# 创建数据目录
RUN mkdir -p /app/data /app/storage
# 暴露端口
EXPOSE 40001
# 健康检查
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD wget --spider -q http://localhost:40001/api/health || exit 1
# 启动应用
CMD ["node", "server.js"]

314
backend/auth.js Normal file
View File

@@ -0,0 +1,314 @@
const jwt = require('jsonwebtoken');
const crypto = require('crypto');
const { UserDB } = require('./database');
const { decryptSecret } = require('./utils/encryption');
// JWT密钥必须在环境变量中设置
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production';
// Refresh Token密钥使用不同的密钥
const REFRESH_SECRET = process.env.REFRESH_SECRET || JWT_SECRET + '-refresh';
// Token有效期配置
const ACCESS_TOKEN_EXPIRES = '2h'; // Access token 2小时
const REFRESH_TOKEN_EXPIRES = '7d'; // Refresh token 7天
// 安全检查验证JWT密钥配置
const DEFAULT_SECRETS = [
'your-secret-key-change-in-production',
'your-secret-key-change-in-production-PLEASE-CHANGE-THIS'
];
// 安全修复:增强 JWT_SECRET 验证逻辑
if (DEFAULT_SECRETS.includes(JWT_SECRET)) {
const errorMsg = `
╔═══════════════════════════════════════════════════════════════╗
║ ⚠️ 安全警告 ⚠️ ║
╠═══════════════════════════════════════════════════════════════╣
║ JWT_SECRET 使用默认值,存在严重安全风险! ║
║ ║
║ 请立即设置环境变量 JWT_SECRET ║
║ 生成随机密钥: ║
║ node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
║ ║
║ 在 backend/.env 文件中设置: ║
║ JWT_SECRET=你生成的随机密钥 ║
╚═══════════════════════════════════════════════════════════════╝
`;
// 安全修复:无论环境如何,使用默认 JWT_SECRET 都拒绝启动
console.error(errorMsg);
throw new Error('使用默认 JWT_SECRET 存在严重安全风险,服务无法启动!');
}
// 验证 JWT_SECRET 长度(至少 32 字节/64个十六进制字符
if (JWT_SECRET.length < 32) {
const errorMsg = `
╔═══════════════════════════════════════════════════════════════╗
║ ⚠️ 配置错误 ⚠️ ║
╠═══════════════════════════════════════════════════════════════╣
║ JWT_SECRET 长度不足! ║
║ ║
║ 要求: 至少 32 字节 ║
║ 当前长度: ${JWT_SECRET.length} 字节 ║
║ ║
║ 生成安全的随机密钥: ║
║ node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
╚═══════════════════════════════════════════════════════════════╝
`;
console.error(errorMsg);
throw new Error('JWT_SECRET 长度不足,服务无法启动!');
}
console.log('[安全] ✓ JWT密钥验证通过');
// 生成Access Token短期
function generateToken(user) {
return jwt.sign(
{
id: user.id,
username: user.username,
is_admin: user.is_admin,
type: 'access'
},
JWT_SECRET,
{ expiresIn: ACCESS_TOKEN_EXPIRES }
);
}
// 生成Refresh Token长期
function generateRefreshToken(user) {
return jwt.sign(
{
id: user.id,
type: 'refresh',
// 添加随机标识使每次生成的refresh token不同
jti: crypto.randomBytes(16).toString('hex')
},
REFRESH_SECRET,
{ expiresIn: REFRESH_TOKEN_EXPIRES }
);
}
// 验证Refresh Token并返回新的Access Token
function refreshAccessToken(refreshToken) {
try {
const decoded = jwt.verify(refreshToken, REFRESH_SECRET);
if (decoded.type !== 'refresh') {
return { success: false, message: '无效的刷新令牌类型' };
}
const user = UserDB.findById(decoded.id);
if (!user) {
return { success: false, message: '用户不存在' };
}
if (user.is_banned) {
return { success: false, message: '账号已被封禁' };
}
if (!user.is_active) {
return { success: false, message: '账号未激活' };
}
// 生成新的access token
const newAccessToken = generateToken(user);
return {
success: true,
token: newAccessToken,
user: {
id: user.id,
username: user.username,
is_admin: user.is_admin
}
};
} catch (error) {
if (error.name === 'TokenExpiredError') {
return { success: false, message: '刷新令牌已过期,请重新登录' };
}
return { success: false, message: '无效的刷新令牌' };
}
}
// 验证Token中间件
function authMiddleware(req, res, next) {
// 从请求头或HttpOnly Cookie获取token不再接受URL参数以避免泄露
const token = req.headers.authorization?.replace('Bearer ', '') || req.cookies?.token;
if (!token) {
return res.status(401).json({
success: false,
message: '未提供认证令牌'
});
}
try {
const decoded = jwt.verify(token, JWT_SECRET);
const user = UserDB.findById(decoded.id);
if (!user) {
return res.status(401).json({
success: false,
message: '用户不存在'
});
}
if (user.is_banned) {
return res.status(403).json({
success: false,
message: '账号已被封禁'
});
}
if (!user.is_active) {
return res.status(403).json({
success: false,
message: '账号未激活'
});
}
// 将用户信息附加到请求对象(包含所有存储相关字段)
req.user = {
id: user.id,
username: user.username,
email: user.email,
is_admin: user.is_admin,
// OSS存储字段v3.0新增)
has_oss_config: user.has_oss_config || 0,
oss_provider: user.oss_provider,
oss_region: user.oss_region,
oss_access_key_id: user.oss_access_key_id,
// 安全修复:解密 OSS Access Key Secret如果存在
oss_access_key_secret: user.oss_access_key_secret ? decryptSecret(user.oss_access_key_secret) : null,
oss_bucket: user.oss_bucket,
oss_endpoint: user.oss_endpoint,
// 存储相关字段
storage_permission: user.storage_permission || 'oss_only',
current_storage_type: user.current_storage_type || 'oss',
local_storage_quota: user.local_storage_quota || 1073741824,
local_storage_used: user.local_storage_used || 0,
// 主题偏好
theme_preference: user.theme_preference || null
};
next();
} catch (error) {
if (error.name === 'TokenExpiredError') {
return res.status(401).json({
success: false,
message: '令牌已过期'
});
}
return res.status(401).json({
success: false,
message: '无效的令牌'
});
}
}
// 管理员权限中间件
function adminMiddleware(req, res, next) {
if (!req.user || !req.user.is_admin) {
return res.status(403).json({
success: false,
message: '需要管理员权限'
});
}
next();
}
/**
* 管理员敏感操作二次验证中间件
*
* 要求管理员重新输入密码才能执行敏感操作
* 防止会话劫持后的非法操作
*
* @example
* app.delete('/api/admin/users/:id',
* authMiddleware,
* adminMiddleware,
* requirePasswordConfirmation,
* async (req, res) => { ... }
* );
*/
function requirePasswordConfirmation(req, res, next) {
const { password } = req.body;
// 检查是否提供了密码
if (!password) {
return res.status(400).json({
success: false,
message: '执行此操作需要验证密码',
require_password: true
});
}
// 验证密码长度(防止空密码)
if (password.length < 6) {
return res.status(400).json({
success: false,
message: '密码格式错误'
});
}
// 从数据库重新获取用户信息(不依赖 req.user 中的数据)
const user = UserDB.findById(req.user.id);
if (!user) {
return res.status(404).json({
success: false,
message: '用户不存在'
});
}
// 验证密码
const isPasswordValid = UserDB.verifyPassword(password, user.password);
if (!isPasswordValid) {
// 记录安全日志:密码验证失败
SystemLogDB = require('./database').SystemLogDB;
SystemLogDB.log({
level: SystemLogDB.LEVELS.WARN,
category: SystemLogDB.CATEGORIES.SECURITY,
action: 'admin_password_verification_failed',
message: '管理员敏感操作密码验证失败',
userId: req.user.id,
username: req.user.username,
ipAddress: req.ip,
userAgent: req.get('user-agent'),
details: {
endpoint: req.path,
method: req.method
}
});
return res.status(403).json({
success: false,
message: '密码验证失败,操作已拒绝'
});
}
// 密码验证成功,继续执行
next();
}
// 检查JWT密钥是否安全
function isJwtSecretSecure() {
return !DEFAULT_SECRETS.includes(JWT_SECRET) && JWT_SECRET.length >= 32;
}
module.exports = {
JWT_SECRET,
generateToken,
generateRefreshToken,
refreshAccessToken,
authMiddleware,
adminMiddleware,
requirePasswordConfirmation, // 导出二次验证中间件
isJwtSecretSecure,
ACCESS_TOKEN_EXPIRES,
REFRESH_TOKEN_EXPIRES
};

52
backend/backup.bat Normal file
View File

@@ -0,0 +1,52 @@
@echo off
chcp 65001 >nul
echo ========================================
echo 数据库备份工具
echo ========================================
echo.
cd /d %~dp0
REM 创建备份目录
if not exist backup mkdir backup
REM 生成时间戳
set YEAR=%date:~0,4%
set MONTH=%date:~5,2%
set DAY=%date:~8,2%
set HOUR=%time:~0,2%
set MINUTE=%time:~3,2%
set SECOND=%time:~6,2%
REM 去掉小时前面的空格
if "%HOUR:~0,1%" == " " set HOUR=0%HOUR:~1,1%
set TIMESTAMP=%YEAR%%MONTH%%DAY%_%HOUR%%MINUTE%%SECOND%
REM 备份数据库
copy ftp-manager.db backup\ftp-manager-%TIMESTAMP%.db >nul
if %errorlevel% == 0 (
echo [成功] 备份完成!
echo 文件: backup\ftp-manager-%TIMESTAMP%.db
REM 获取文件大小
for %%A in (backup\ftp-manager-%TIMESTAMP%.db) do echo 大小: %%~zA 字节
) else (
echo [错误] 备份失败!
)
echo.
REM 清理30天前的备份
echo 清理30天前的旧备份...
forfiles /P backup /M ftp-manager-*.db /D -30 /C "cmd /c del @path" 2>nul
if %errorlevel% == 0 (
echo [成功] 旧备份已清理
) else (
echo [提示] 没有需要清理的旧备份
)
echo.
echo ========================================
pause

19
backend/check_expire.sql Normal file
View File

@@ -0,0 +1,19 @@
SELECT
share_code,
substr(share_path, 1, 30) as path,
created_at,
expires_at,
datetime('now') as current_time,
CASE
WHEN expires_at IS NULL THEN '永久有效'
WHEN expires_at > datetime('now') THEN '未过期'
ELSE '已过期'
END as status,
CASE
WHEN expires_at IS NOT NULL AND expires_at > datetime('now') THEN '通过'
WHEN expires_at IS NULL THEN '通过'
ELSE '拦截'
END as findByCode_result
FROM shares
ORDER BY created_at DESC
LIMIT 10;

0
backend/data/.gitkeep Normal file
View File

1446
backend/database.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,34 @@
const { db } = require('./database');
console.log('开始修复 expires_at 格式...\n');
// 查找所有有过期时间的分享
const shares = db.prepare(`
SELECT id, share_code, expires_at
FROM shares
WHERE expires_at IS NOT NULL
`).all();
console.log(`找到 ${shares.length} 条需要修复的记录\n`);
let fixed = 0;
const updateStmt = db.prepare('UPDATE shares SET expires_at = ? WHERE id = ?');
shares.forEach(share => {
const oldFormat = share.expires_at;
// 如果是ISO格式(包含T和Z),需要转换
if (oldFormat.includes('T') || oldFormat.includes('Z')) {
// 转换为 SQLite datetime 格式: YYYY-MM-DD HH:MM:SS
const newFormat = oldFormat.replace('T', ' ').replace(/\.\d+Z$/, '');
updateStmt.run(newFormat, share.id);
fixed++;
console.log(`✓ 修复分享 ${share.share_code}:`);
console.log(` 旧格式: ${oldFormat}`);
console.log(` 新格式: ${newFormat}\n`);
}
});
console.log(`\n修复完成! 共修复 ${fixed} 条记录`);

4544
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

40
backend/package.json Normal file
View File

@@ -0,0 +1,40 @@
{
"name": "wanwanyun-backend",
"version": "3.1.0",
"description": "玩玩云 - 云存储管理平台后端服务",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js"
},
"keywords": [
"cloud-storage",
"oss",
"s3",
"file-manager",
"alibaba-cloud",
"tencent-cloud"
],
"author": "玩玩云团队",
"license": "MIT",
"dependencies": {
"archiver": "^7.0.1",
"bcryptjs": "^3.0.3",
"better-sqlite3": "^11.8.1",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"express": "^4.18.2",
"express-session": "^1.18.2",
"express-validator": "^7.3.0",
"jsonwebtoken": "^9.0.2",
"multer": "^2.0.2",
"nodemailer": "^6.9.14",
"@aws-sdk/client-s3": "^3.600.0",
"@aws-sdk/s3-request-presigner": "^3.600.0",
"svg-captcha": "^1.4.0"
},
"devDependencies": {
"nodemon": "^3.0.1"
}
}

52
backend/routes/health.js Normal file
View File

@@ -0,0 +1,52 @@
/**
* 健康检查和公共配置路由
* 提供服务健康状态和公共配置信息
*/
const express = require('express');
const router = express.Router();
const { SettingsDB } = require('../database');
/**
* 健康检查端点
* GET /api/health
*/
router.get('/health', (req, res) => {
res.json({ success: true, message: 'Server is running' });
});
/**
* 获取公开的系统配置(不需要登录)
* GET /api/config
*/
router.get('/config', (req, res) => {
const maxUploadSize = parseInt(SettingsDB.get('max_upload_size') || '10737418240');
res.json({
success: true,
config: {
max_upload_size: maxUploadSize
}
});
});
/**
* 获取公开的全局主题设置(不需要登录)
* GET /api/public/theme
*/
router.get('/public/theme', (req, res) => {
try {
const globalTheme = SettingsDB.get('global_theme') || 'dark';
res.json({
success: true,
theme: globalTheme
});
} catch (error) {
console.error('获取全局主题失败:', error);
res.status(500).json({
success: false,
message: '获取主题失败'
});
}
});
module.exports = router;

90
backend/routes/index.js Normal file
View File

@@ -0,0 +1,90 @@
/**
* 路由模块索引
*
* 本项目的路由目前主要定义在 server.js 中。
* 此目录用于未来路由拆分的模块化重构。
*
* 建议的路由模块拆分方案:
*
* 1. routes/health.js - 健康检查和公共配置
* - GET /api/health
* - GET /api/config
* - GET /api/public/theme
*
* 2. routes/auth.js - 认证相关
* - POST /api/login
* - POST /api/register
* - POST /api/logout
* - POST /api/refresh-token
* - POST /api/password/forgot
* - POST /api/password/reset
* - GET /api/verify-email
* - POST /api/resend-verification
* - GET /api/captcha
* - GET /api/csrf-token
*
* 3. routes/user.js - 用户相关
* - GET /api/user/profile
* - GET /api/user/theme
* - POST /api/user/theme
* - POST /api/user/update-oss
* - POST /api/user/test-oss
* - GET /api/user/oss-usage
* - POST /api/user/change-password
* - POST /api/user/update-username
* - POST /api/user/switch-storage
*
* 4. routes/files.js - 文件操作
* - GET /api/files
* - POST /api/files/rename
* - POST /api/files/mkdir
* - POST /api/files/folder-info
* - POST /api/files/delete
* - GET /api/files/upload-signature
* - POST /api/files/upload-complete
* - GET /api/files/download-url
* - GET /api/files/download
* - POST /api/upload
*
* 5. routes/share.js - 分享功能
* - POST /api/share/create
* - GET /api/share/my
* - DELETE /api/share/:id
* - GET /api/share/:code/theme
* - POST /api/share/:code/verify
* - POST /api/share/:code/list
* - POST /api/share/:code/download
* - GET /api/share/:code/download-url
* - GET /api/share/:code/download-file
*
* 6. routes/admin.js - 管理员功能
* - GET /api/admin/settings
* - POST /api/admin/settings
* - POST /api/admin/settings/test-smtp
* - GET /api/admin/health-check
* - GET /api/admin/storage-stats
* - GET /api/admin/users
* - GET /api/admin/logs
* - GET /api/admin/logs/stats
* - POST /api/admin/logs/cleanup
* - POST /api/admin/users/:id/ban
* - DELETE /api/admin/users/:id
* - POST /api/admin/users/:id/storage-permission
* - GET /api/admin/users/:id/files
* - GET /api/admin/shares
* - DELETE /api/admin/shares/:id
* - GET /api/admin/check-upload-tool
* - POST /api/admin/upload-tool
*
* 使用示例(在 server.js 中):
* ```javascript
* const healthRoutes = require('./routes/health');
* app.use('/api', healthRoutes);
* ```
*/
const healthRoutes = require('./health');
module.exports = {
healthRoutes
};

6077
backend/server.js Normal file

File diff suppressed because it is too large Load Diff

10
backend/start.bat Normal file
View File

@@ -0,0 +1,10 @@
@echo off
echo ========================================
echo FTP 网盘管理平台 - 启动脚本
echo ========================================
echo.
cd /d %~dp0
node server.js
pause

1717
backend/storage.js Normal file

File diff suppressed because it is too large Load Diff

0
backend/storage/.gitkeep Normal file
View File

View File

@@ -0,0 +1,934 @@
/**
* 边界条件和异常处理测试套件
*
* 测试范围:
* 1. 输入边界测试空字符串、超长字符串、特殊字符、SQL注入、XSS
* 2. 文件操作边界测试(空文件、超大文件、特殊字符文件名、深层目录)
* 3. 网络异常测试超时、断连、OSS连接失败
* 4. 并发操作测试(多文件上传、多文件删除、重复提交)
* 5. 状态一致性测试刷新恢复、Token过期、存储切换
*/
const assert = require('assert');
const path = require('path');
const fs = require('fs');
// 主函数包装器(支持 async/await
async function runTests() {
// 测试结果收集器
const testResults = {
passed: 0,
failed: 0,
errors: []
};
// 测试辅助函数
function test(name, fn) {
try {
fn();
testResults.passed++;
console.log(` [PASS] ${name}`);
} catch (error) {
testResults.failed++;
testResults.errors.push({ name, error: error.message });
console.log(` [FAIL] ${name}: ${error.message}`);
}
}
async function asyncTest(name, fn) {
try {
await fn();
testResults.passed++;
console.log(` [PASS] ${name}`);
} catch (error) {
testResults.failed++;
testResults.errors.push({ name, error: error.message });
console.log(` [FAIL] ${name}: ${error.message}`);
}
}
// ============================================================
// 1. 输入边界测试
// ============================================================
console.log('\n========== 1. 输入边界测试 ==========\n');
// 测试 sanitizeInput 函数
function testSanitizeInput() {
console.log('--- 测试 XSS 过滤函数 sanitizeInput ---');
// 从 server.js 复制的 sanitizeInput 函数
function sanitizeInput(str) {
if (typeof str !== 'string') return str;
let sanitized = str
.replace(/[&<>"']/g, (char) => {
const map = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#x27;'
};
return map[char];
});
sanitized = sanitized.replace(/(?:javascript|data|vbscript|expression|on\w+)\s*:/gi, '');
sanitized = sanitized.replace(/\x00/g, '');
return sanitized;
}
// 空字符串测试
test('空字符串输入应该返回空字符串', () => {
assert.strictEqual(sanitizeInput(''), '');
});
// 超长字符串测试
test('超长字符串应该被正确处理', () => {
const longStr = 'a'.repeat(100000);
const result = sanitizeInput(longStr);
assert.strictEqual(result.length, 100000);
});
// 特殊字符测试
test('HTML 特殊字符应该被转义', () => {
assert.strictEqual(sanitizeInput('<script>'), '&lt;script&gt;');
assert.strictEqual(sanitizeInput('"test"'), '&quot;test&quot;');
assert.strictEqual(sanitizeInput("'test'"), '&#x27;test&#x27;');
assert.strictEqual(sanitizeInput('&test&'), '&amp;test&amp;');
});
// SQL 注入测试字符串
test('SQL 注入尝试应该被转义', () => {
const sqlInjections = [
"'; DROP TABLE users; --",
"1' OR '1'='1",
"admin'--",
"1; DELETE FROM users",
"' UNION SELECT * FROM users --"
];
sqlInjections.forEach(sql => {
const result = sanitizeInput(sql);
// 确保引号被转义
assert.ok(!result.includes("'") || result.includes('&#x27;'), `SQL injection not escaped: ${sql}`);
});
});
// XSS 测试字符串
test('XSS 攻击尝试应该被过滤', () => {
const xssTests = [
'<script>alert("XSS")</script>',
'<img src="x" onerror="alert(1)">',
'<a href="javascript:alert(1)">click</a>',
'<div onmouseover="alert(1)">hover</div>',
'javascript:alert(1)',
'data:text/html,<script>alert(1)</script>'
];
xssTests.forEach(xss => {
const result = sanitizeInput(xss);
assert.ok(!result.includes('<script>'), `XSS script tag not escaped: ${xss}`);
assert.ok(!result.includes('javascript:'), `XSS javascript: not filtered: ${xss}`);
});
});
// 空字节注入测试
test('空字节注入应该被过滤', () => {
assert.ok(!sanitizeInput('test\x00.txt').includes('\x00'));
assert.ok(!sanitizeInput('file\x00.jpg').includes('\x00'));
});
// null/undefined 测试
test('非字符串输入应该原样返回', () => {
assert.strictEqual(sanitizeInput(null), null);
assert.strictEqual(sanitizeInput(undefined), undefined);
assert.strictEqual(sanitizeInput(123), 123);
});
}
testSanitizeInput();
// 测试密码验证
function testPasswordValidation() {
console.log('\n--- 测试密码强度验证 ---');
function validatePasswordStrength(password) {
if (!password || password.length < 8) {
return { valid: false, message: '密码至少8个字符' };
}
if (password.length > 128) {
return { valid: false, message: '密码不能超过128个字符' };
}
const hasLetter = /[a-zA-Z]/.test(password);
const hasNumber = /[0-9]/.test(password);
const hasSpecial = /[!@#$%^&*(),.?":{}|<>_\-+=\[\]\\\/`~]/.test(password);
const typeCount = [hasLetter, hasNumber, hasSpecial].filter(Boolean).length;
if (typeCount < 2) {
return { valid: false, message: '密码必须包含字母、数字、特殊字符中的至少两种' };
}
const commonWeakPasswords = [
'password', '12345678', '123456789', 'qwerty123', 'admin123',
'letmein', 'welcome', 'monkey', 'dragon', 'master'
];
if (commonWeakPasswords.includes(password.toLowerCase())) {
return { valid: false, message: '密码过于简单,请使用更复杂的密码' };
}
return { valid: true };
}
test('空密码应该被拒绝', () => {
assert.strictEqual(validatePasswordStrength('').valid, false);
assert.strictEqual(validatePasswordStrength(null).valid, false);
});
test('过短密码应该被拒绝', () => {
assert.strictEqual(validatePasswordStrength('abc123!').valid, false);
assert.strictEqual(validatePasswordStrength('1234567').valid, false);
});
test('超长密码应该被拒绝', () => {
const longPassword = 'a'.repeat(129) + '1';
assert.strictEqual(validatePasswordStrength(longPassword).valid, false);
});
test('纯数字密码应该被拒绝', () => {
assert.strictEqual(validatePasswordStrength('12345678').valid, false);
});
test('纯字母密码应该被拒绝', () => {
assert.strictEqual(validatePasswordStrength('abcdefgh').valid, false);
});
test('常见弱密码应该被拒绝', () => {
assert.strictEqual(validatePasswordStrength('password').valid, false);
assert.strictEqual(validatePasswordStrength('admin123').valid, false);
});
test('复杂密码应该被接受', () => {
assert.strictEqual(validatePasswordStrength('MySecure123!').valid, true);
assert.strictEqual(validatePasswordStrength('Test_Pass_2024').valid, true);
});
}
testPasswordValidation();
// 测试用户名验证
function testUsernameValidation() {
console.log('\n--- 测试用户名验证 ---');
const USERNAME_REGEX = /^[A-Za-z0-9_.\u4e00-\u9fa5-]{3,20}$/u;
test('过短用户名应该被拒绝', () => {
assert.strictEqual(USERNAME_REGEX.test('ab'), false);
assert.strictEqual(USERNAME_REGEX.test('a'), false);
assert.strictEqual(USERNAME_REGEX.test(''), false);
});
test('过长用户名应该被拒绝', () => {
assert.strictEqual(USERNAME_REGEX.test('a'.repeat(21)), false);
});
test('包含非法字符的用户名应该被拒绝', () => {
assert.strictEqual(USERNAME_REGEX.test('user@name'), false);
assert.strictEqual(USERNAME_REGEX.test('user name'), false);
assert.strictEqual(USERNAME_REGEX.test('user<script>'), false);
assert.strictEqual(USERNAME_REGEX.test("user'name"), false);
});
test('合法用户名应该被接受', () => {
assert.strictEqual(USERNAME_REGEX.test('user123'), true);
assert.strictEqual(USERNAME_REGEX.test('test_user'), true);
assert.strictEqual(USERNAME_REGEX.test('test.user'), true);
assert.strictEqual(USERNAME_REGEX.test('test-user'), true);
assert.strictEqual(USERNAME_REGEX.test('用户名'), true);
assert.strictEqual(USERNAME_REGEX.test('中文用户_123'), true);
});
}
testUsernameValidation();
// ============================================================
// 2. 文件操作边界测试
// ============================================================
console.log('\n========== 2. 文件操作边界测试 ==========\n');
function testPathSecurity() {
console.log('--- 测试路径安全校验 ---');
function isSafePathSegment(name) {
return (
typeof name === 'string' &&
name.length > 0 &&
name.length <= 255 &&
!name.includes('..') &&
!/[/\\]/.test(name) &&
!/[\x00-\x1F]/.test(name)
);
}
test('空文件名应该被拒绝', () => {
assert.strictEqual(isSafePathSegment(''), false);
});
test('超长文件名应该被拒绝', () => {
assert.strictEqual(isSafePathSegment('a'.repeat(256)), false);
});
test('包含路径遍历的文件名应该被拒绝', () => {
assert.strictEqual(isSafePathSegment('..'), false);
assert.strictEqual(isSafePathSegment('../etc/passwd'), false);
assert.strictEqual(isSafePathSegment('test/../../../'), false);
});
test('包含路径分隔符的文件名应该被拒绝', () => {
assert.strictEqual(isSafePathSegment('test/file'), false);
assert.strictEqual(isSafePathSegment('test\\file'), false);
});
test('包含控制字符的文件名应该被拒绝', () => {
assert.strictEqual(isSafePathSegment('test\x00file'), false);
assert.strictEqual(isSafePathSegment('test\x1Ffile'), false);
});
test('合法文件名应该被接受', () => {
assert.strictEqual(isSafePathSegment('normal_file.txt'), true);
assert.strictEqual(isSafePathSegment('中文文件名.pdf'), true);
assert.strictEqual(isSafePathSegment('file with spaces.doc'), true);
assert.strictEqual(isSafePathSegment('file-with-dashes.js'), true);
assert.strictEqual(isSafePathSegment('file.name.with.dots.txt'), true);
});
}
testPathSecurity();
function testFileExtensionSecurity() {
console.log('\n--- 测试文件扩展名安全 ---');
const DANGEROUS_EXTENSIONS = [
'.php', '.php3', '.php4', '.php5', '.phtml', '.phar',
'.jsp', '.jspx', '.jsw', '.jsv', '.jspf',
'.asp', '.aspx', '.asa', '.asax', '.ascx', '.ashx', '.asmx',
'.htaccess', '.htpasswd'
];
function isFileExtensionSafe(filename) {
if (!filename || typeof filename !== 'string') return false;
const ext = path.extname(filename).toLowerCase();
if (DANGEROUS_EXTENSIONS.includes(ext)) {
return false;
}
const nameLower = filename.toLowerCase();
for (const dangerExt of DANGEROUS_EXTENSIONS) {
if (nameLower.includes(dangerExt + '.')) {
return false;
}
}
return true;
}
test('PHP 文件应该被拒绝', () => {
assert.strictEqual(isFileExtensionSafe('test.php'), false);
assert.strictEqual(isFileExtensionSafe('shell.phtml'), false);
assert.strictEqual(isFileExtensionSafe('backdoor.phar'), false);
});
test('JSP 文件应该被拒绝', () => {
assert.strictEqual(isFileExtensionSafe('test.jsp'), false);
assert.strictEqual(isFileExtensionSafe('test.jspx'), false);
});
test('ASP 文件应该被拒绝', () => {
assert.strictEqual(isFileExtensionSafe('test.asp'), false);
assert.strictEqual(isFileExtensionSafe('test.aspx'), false);
});
test('双扩展名攻击应该被拒绝', () => {
assert.strictEqual(isFileExtensionSafe('shell.php.jpg'), false);
assert.strictEqual(isFileExtensionSafe('backdoor.jsp.png'), false);
});
test('.htaccess 和 .htpasswd 文件应该被拒绝', () => {
// 更新测试以匹配修复后的 isFileExtensionSafe 函数
// 现在会检查 dangerousFilenames 列表
const dangerousFilenames = ['.htaccess', '.htpasswd'];
function isFileExtensionSafeFixed(filename) {
if (!filename || typeof filename !== 'string') return false;
const ext = path.extname(filename).toLowerCase();
const nameLower = filename.toLowerCase();
if (DANGEROUS_EXTENSIONS.includes(ext)) {
return false;
}
// 特殊处理:检查以危险名称开头的文件
if (dangerousFilenames.includes(nameLower)) {
return false;
}
for (const dangerExt of DANGEROUS_EXTENSIONS) {
if (nameLower.includes(dangerExt + '.')) {
return false;
}
}
return true;
}
assert.strictEqual(isFileExtensionSafeFixed('.htaccess'), false);
assert.strictEqual(isFileExtensionSafeFixed('.htpasswd'), false);
});
test('正常文件应该被接受', () => {
assert.strictEqual(isFileExtensionSafe('document.pdf'), true);
assert.strictEqual(isFileExtensionSafe('image.jpg'), true);
assert.strictEqual(isFileExtensionSafe('video.mp4'), true);
assert.strictEqual(isFileExtensionSafe('archive.zip'), true);
assert.strictEqual(isFileExtensionSafe('script.js'), true);
assert.strictEqual(isFileExtensionSafe('program.exe'), true); // 允许exe因为服务器不会执行
});
test('空或非法输入应该被拒绝', () => {
assert.strictEqual(isFileExtensionSafe(''), false);
assert.strictEqual(isFileExtensionSafe(null), false);
assert.strictEqual(isFileExtensionSafe(undefined), false);
});
}
testFileExtensionSecurity();
// ============================================================
// 3. 存储路径安全测试
// ============================================================
console.log('\n========== 3. 存储路径安全测试 ==========\n');
function testLocalStoragePath() {
console.log('--- 测试本地存储路径安全 ---');
// 精确模拟 LocalStorageClient.getFullPath 方法(与 storage.js 保持一致)
function getFullPath(basePath, relativePath) {
// 0. 输入验证:检查空字节注入和其他危险字符
if (typeof relativePath !== 'string') {
throw new Error('无效的路径类型');
}
// 检查空字节注入(%00, \x00
if (relativePath.includes('\x00') || relativePath.includes('%00')) {
console.warn('[安全] 检测到空字节注入尝试:', relativePath);
throw new Error('路径包含非法字符');
}
// 1. 规范化路径,移除 ../ 等危险路径
let normalized = path.normalize(relativePath || '').replace(/^(\.\.[\/\\])+/, '');
// 2. 额外检查:移除路径中间的 .. (防止 a/../../../etc/passwd 绕过)
// 解析后的路径不应包含 ..
if (normalized.includes('..')) {
console.warn('[安全] 检测到目录遍历尝试:', relativePath);
throw new Error('路径包含非法字符');
}
// 3. 将绝对路径转换为相对路径解决Linux环境下的问题
if (path.isAbsolute(normalized)) {
// 移除开头的 / 或 Windows 盘符,转为相对路径
normalized = normalized.replace(/^[\/\\]+/, '').replace(/^[a-zA-Z]:/, '');
}
// 4. 空字符串或 . 表示根目录
if (normalized === '' || normalized === '.') {
return basePath;
}
// 5. 拼接完整路径
const fullPath = path.join(basePath, normalized);
// 6. 解析真实路径(处理符号链接)后再次验证
const resolvedBasePath = path.resolve(basePath);
const resolvedFullPath = path.resolve(fullPath);
// 7. 安全检查:确保路径在用户目录内(防止目录遍历攻击)
if (!resolvedFullPath.startsWith(resolvedBasePath)) {
console.warn('[安全] 检测到路径遍历攻击:', {
input: relativePath,
resolved: resolvedFullPath,
base: resolvedBasePath
});
throw new Error('非法路径访问');
}
return fullPath;
}
const basePath = '/tmp/storage/user_1';
test('正常相对路径应该被接受', () => {
const result = getFullPath(basePath, 'documents/file.txt');
assert.ok(result.includes('documents'));
assert.ok(result.includes('file.txt'));
});
test('路径遍历攻击应该被安全处理(开头的..被移除)', () => {
// ../../../etc/passwd 经过 normalize 和 replace 后变成 etc/passwd
// 最终路径会被沙箱化到用户目录内
const result = getFullPath(basePath, '../../../etc/passwd');
// 验证结果路径在用户基础路径内
assert.ok(result.startsWith(basePath), `路径 ${result} 应该以 ${basePath} 开头`);
// 验证解析后的路径确实在基础路径内
const resolved = path.resolve(result);
const baseResolved = path.resolve(basePath);
assert.ok(resolved.startsWith(baseResolved), '解析后的路径应该在用户目录内');
});
test('路径遍历攻击应该被安全处理(中间的..被移除)', () => {
// a/../../../etc/passwd 经过 normalize 变成 ../../etc/passwd
// 然后经过 replace 变成 etc/passwd最终被沙箱化
const result = getFullPath(basePath, 'a/../../../etc/passwd');
assert.ok(result.startsWith(basePath), `路径 ${result} 应该以 ${basePath} 开头`);
const resolved = path.resolve(result);
const baseResolved = path.resolve(basePath);
assert.ok(resolved.startsWith(baseResolved), '解析后的路径应该在用户目录内');
});
test('空字节注入应该被拒绝', () => {
assert.throws(() => getFullPath(basePath, 'file\x00.txt'), /非法/);
assert.throws(() => getFullPath(basePath, 'file%00.txt'), /非法/);
});
test('绝对路径应该被安全处理(转换为相对路径)', () => {
// /etc/passwd 会被转换为 etc/passwd然后拼接到 basePath
const result = getFullPath(basePath, '/etc/passwd');
assert.ok(result.startsWith(basePath), `路径 ${result} 应该以 ${basePath} 开头`);
// 最终路径应该是 basePath/etc/passwd
assert.ok(result.includes('etc') && result.includes('passwd'));
// 确保是安全的子路径而不是真正的 /etc/passwd
const resolved = path.resolve(result);
const baseResolved = path.resolve(basePath);
assert.ok(resolved.startsWith(baseResolved), '解析后的路径应该在用户目录内');
});
test('空路径应该返回基础路径', () => {
assert.strictEqual(getFullPath(basePath, ''), basePath);
assert.strictEqual(getFullPath(basePath, '.'), basePath);
});
}
testLocalStoragePath();
// ============================================================
// 4. Token 验证测试
// ============================================================
console.log('\n========== 4. Token 验证测试 ==========\n');
function testTokenValidation() {
console.log('--- 测试 Token 格式验证 ---');
// 验证 token 格式hex 字符串)
function isValidTokenFormat(token) {
if (!token || typeof token !== 'string') {
return false;
}
return /^[a-f0-9]{32,96}$/i.test(token);
}
test('空 token 应该被拒绝', () => {
assert.strictEqual(isValidTokenFormat(''), false);
assert.strictEqual(isValidTokenFormat(null), false);
assert.strictEqual(isValidTokenFormat(undefined), false);
});
test('过短 token 应该被拒绝', () => {
assert.strictEqual(isValidTokenFormat('abc123'), false);
assert.strictEqual(isValidTokenFormat('a'.repeat(31)), false);
});
test('过长 token 应该被拒绝', () => {
assert.strictEqual(isValidTokenFormat('a'.repeat(97)), false);
});
test('非 hex 字符 token 应该被拒绝', () => {
assert.strictEqual(isValidTokenFormat('g'.repeat(48)), false);
assert.strictEqual(isValidTokenFormat('test-token-123'), false);
assert.strictEqual(isValidTokenFormat('<script>alert(1)</script>'), false);
});
test('合法 token 应该被接受', () => {
assert.strictEqual(isValidTokenFormat('a'.repeat(48)), true);
assert.strictEqual(isValidTokenFormat('abcdef123456'.repeat(4)), true);
assert.strictEqual(isValidTokenFormat('ABCDEF123456'.repeat(4)), true);
});
}
testTokenValidation();
// ============================================================
// 5. 并发和竞态条件测试
// ============================================================
console.log('\n========== 5. 并发和竞态条件测试 ==========\n');
async function testRateLimiter() {
console.log('--- 测试速率限制器 ---');
// 简化版 RateLimiter
class RateLimiter {
constructor(options = {}) {
this.maxAttempts = options.maxAttempts || 5;
this.windowMs = options.windowMs || 15 * 60 * 1000;
this.blockDuration = options.blockDuration || 30 * 60 * 1000;
this.attempts = new Map();
this.blockedKeys = new Map();
}
isBlocked(key) {
const blockInfo = this.blockedKeys.get(key);
if (!blockInfo) return false;
if (Date.now() > blockInfo.expiresAt) {
this.blockedKeys.delete(key);
this.attempts.delete(key);
return false;
}
return true;
}
recordFailure(key) {
const now = Date.now();
if (this.isBlocked(key)) {
return { blocked: true };
}
let attemptInfo = this.attempts.get(key);
if (!attemptInfo || now > attemptInfo.windowEnd) {
attemptInfo = { count: 0, windowEnd: now + this.windowMs };
}
attemptInfo.count++;
this.attempts.set(key, attemptInfo);
if (attemptInfo.count >= this.maxAttempts) {
this.blockedKeys.set(key, {
expiresAt: now + this.blockDuration
});
return { blocked: true, remainingAttempts: 0 };
}
return {
blocked: false,
remainingAttempts: this.maxAttempts - attemptInfo.count
};
}
recordSuccess(key) {
this.attempts.delete(key);
this.blockedKeys.delete(key);
}
getFailureCount(key) {
const attemptInfo = this.attempts.get(key);
if (!attemptInfo || Date.now() > attemptInfo.windowEnd) {
return 0;
}
return attemptInfo.count;
}
}
const limiter = new RateLimiter({ maxAttempts: 3, windowMs: 1000, blockDuration: 1000 });
await asyncTest('首次请求应该不被阻止', async () => {
const result = limiter.recordFailure('test-ip-1');
assert.strictEqual(result.blocked, false);
assert.strictEqual(result.remainingAttempts, 2);
});
await asyncTest('达到限制后应该被阻止', async () => {
const key = 'test-ip-2';
limiter.recordFailure(key);
limiter.recordFailure(key);
const result = limiter.recordFailure(key);
assert.strictEqual(result.blocked, true);
assert.strictEqual(limiter.isBlocked(key), true);
});
await asyncTest('成功后应该清除计数', async () => {
const key = 'test-ip-3';
limiter.recordFailure(key);
limiter.recordFailure(key);
limiter.recordSuccess(key);
assert.strictEqual(limiter.getFailureCount(key), 0);
assert.strictEqual(limiter.isBlocked(key), false);
});
await asyncTest('阻止过期后应该自动解除', async () => {
const key = 'test-ip-4';
limiter.recordFailure(key);
limiter.recordFailure(key);
limiter.recordFailure(key);
// 模拟时间过期
const blockInfo = limiter.blockedKeys.get(key);
if (blockInfo) {
blockInfo.expiresAt = Date.now() - 1;
}
assert.strictEqual(limiter.isBlocked(key), false);
});
}
await testRateLimiter();
// ============================================================
// 6. 数据库操作边界测试
// ============================================================
console.log('\n========== 6. 数据库操作边界测试 ==========\n');
function testDatabaseFieldWhitelist() {
console.log('--- 测试数据库字段白名单 ---');
const ALLOWED_FIELDS = [
'username', 'email', 'password',
'oss_provider', 'oss_region', 'oss_access_key_id', 'oss_access_key_secret', 'oss_bucket', 'oss_endpoint',
'upload_api_key', 'is_admin', 'is_active', 'is_banned', 'has_oss_config',
'is_verified', 'verification_token', 'verification_expires_at',
'storage_permission', 'current_storage_type', 'local_storage_quota', 'local_storage_used',
'theme_preference'
];
function filterUpdates(updates) {
const filtered = {};
for (const [key, value] of Object.entries(updates)) {
if (ALLOWED_FIELDS.includes(key)) {
filtered[key] = value;
}
}
return filtered;
}
test('合法字段应该被保留', () => {
const updates = { username: 'newname', email: 'new@email.com' };
const filtered = filterUpdates(updates);
assert.strictEqual(filtered.username, 'newname');
assert.strictEqual(filtered.email, 'new@email.com');
});
test('非法字段应该被过滤', () => {
const updates = {
username: 'newname',
id: 999, // 尝试修改 ID
is_admin: 1, // 合法字段
sql_injection: "'; DROP TABLE users; --" // 非法字段
};
const filtered = filterUpdates(updates);
assert.ok(!('id' in filtered));
assert.ok(!('sql_injection' in filtered));
assert.strictEqual(filtered.username, 'newname');
assert.strictEqual(filtered.is_admin, 1);
});
test('原型污染尝试应该被阻止', () => {
// 测试通过 JSON.parse 创建的包含 __proto__ 的对象
const maliciousJson = '{"username":"test","__proto__":{"isAdmin":true},"constructor":{"prototype":{}}}';
const updates = JSON.parse(maliciousJson);
const filtered = filterUpdates(updates);
// 即使 JSON.parse 创建了 __proto__ 属性,也不应该被处理
// 因为 Object.entries 不会遍历 __proto__
assert.strictEqual(filtered.username, 'test');
assert.ok(!('isAdmin' in filtered));
// 确保不会污染原型
assert.ok(!({}.isAdmin));
});
test('空对象应该返回空对象', () => {
const filtered = filterUpdates({});
assert.strictEqual(Object.keys(filtered).length, 0);
});
}
testDatabaseFieldWhitelist();
// ============================================================
// 7. HTML 实体解码测试
// ============================================================
console.log('\n========== 7. HTML 实体解码测试 ==========\n');
function testHtmlEntityDecoding() {
console.log('--- 测试 HTML 实体解码 ---');
function decodeHtmlEntities(str) {
if (typeof str !== 'string') return str;
const entityMap = {
amp: '&',
lt: '<',
gt: '>',
quot: '"',
apos: "'",
'#x27': "'",
'#x2F': '/',
'#x60': '`'
};
const decodeOnce = (input) =>
input.replace(/&(#x[0-9a-fA-F]+|#\d+|[a-zA-Z]+);/g, (match, code) => {
if (code[0] === '#') {
const isHex = code[1]?.toLowerCase() === 'x';
const num = isHex ? parseInt(code.slice(2), 16) : parseInt(code.slice(1), 10);
if (!Number.isNaN(num)) {
return String.fromCharCode(num);
}
return match;
}
const mapped = entityMap[code];
return mapped !== undefined ? mapped : match;
});
let output = str;
let decoded = decodeOnce(output);
while (decoded !== output) {
output = decoded;
decoded = decodeOnce(output);
}
return output;
}
test('基本 HTML 实体应该被解码', () => {
assert.strictEqual(decodeHtmlEntities('&lt;'), '<');
assert.strictEqual(decodeHtmlEntities('&gt;'), '>');
assert.strictEqual(decodeHtmlEntities('&amp;'), '&');
assert.strictEqual(decodeHtmlEntities('&quot;'), '"');
});
test('数字实体应该被解码', () => {
assert.strictEqual(decodeHtmlEntities('&#x27;'), "'");
assert.strictEqual(decodeHtmlEntities('&#39;'), "'");
assert.strictEqual(decodeHtmlEntities('&#x60;'), '`');
});
test('嵌套实体应该被完全解码', () => {
assert.strictEqual(decodeHtmlEntities('&amp;#x60;'), '`');
assert.strictEqual(decodeHtmlEntities('&amp;amp;'), '&');
});
test('普通文本应该保持不变', () => {
assert.strictEqual(decodeHtmlEntities('hello world'), 'hello world');
assert.strictEqual(decodeHtmlEntities('test123'), 'test123');
});
test('非字符串输入应该原样返回', () => {
assert.strictEqual(decodeHtmlEntities(null), null);
assert.strictEqual(decodeHtmlEntities(undefined), undefined);
assert.strictEqual(decodeHtmlEntities(123), 123);
});
}
testHtmlEntityDecoding();
// ============================================================
// 8. 分享路径权限测试
// ============================================================
console.log('\n========== 8. 分享路径权限测试 ==========\n');
function testSharePathAccess() {
console.log('--- 测试分享路径访问权限 ---');
function isPathWithinShare(requestPath, share) {
if (!requestPath || !share) {
return false;
}
const normalizedRequest = path.normalize(requestPath).replace(/^(\.\.[\/\\])+/, '').replace(/\\/g, '/');
const normalizedShare = path.normalize(share.share_path).replace(/\\/g, '/');
if (share.share_type === 'file') {
return normalizedRequest === normalizedShare;
} else {
const sharePrefix = normalizedShare.endsWith('/') ? normalizedShare : normalizedShare + '/';
return normalizedRequest === normalizedShare || normalizedRequest.startsWith(sharePrefix);
}
}
test('单文件分享只允许访问该文件', () => {
const share = { share_type: 'file', share_path: '/documents/secret.pdf' };
assert.strictEqual(isPathWithinShare('/documents/secret.pdf', share), true);
assert.strictEqual(isPathWithinShare('/documents/other.pdf', share), false);
assert.strictEqual(isPathWithinShare('/documents/secret.pdf.bak', share), false);
});
test('目录分享允许访问子目录', () => {
const share = { share_type: 'directory', share_path: '/shared' };
assert.strictEqual(isPathWithinShare('/shared', share), true);
assert.strictEqual(isPathWithinShare('/shared/file.txt', share), true);
assert.strictEqual(isPathWithinShare('/shared/sub/file.txt', share), true);
});
test('目录分享不允许访问父目录', () => {
const share = { share_type: 'directory', share_path: '/shared' };
assert.strictEqual(isPathWithinShare('/other', share), false);
assert.strictEqual(isPathWithinShare('/shared_extra', share), false);
assert.strictEqual(isPathWithinShare('/', share), false);
});
test('路径遍历攻击应该被阻止', () => {
const share = { share_type: 'directory', share_path: '/shared' };
assert.strictEqual(isPathWithinShare('/shared/../etc/passwd', share), false);
assert.strictEqual(isPathWithinShare('/shared/../../root', share), false);
});
test('空或无效输入应该返回 false', () => {
assert.strictEqual(isPathWithinShare('', { share_type: 'file', share_path: '/test' }), false);
assert.strictEqual(isPathWithinShare(null, { share_type: 'file', share_path: '/test' }), false);
assert.strictEqual(isPathWithinShare('/test', null), false);
});
}
testSharePathAccess();
// ============================================================
// 测试总结
// ============================================================
console.log('\n========================================');
console.log('测试总结');
console.log('========================================');
console.log(`通过: ${testResults.passed}`);
console.log(`失败: ${testResults.failed}`);
if (testResults.errors.length > 0) {
console.log('\n失败的测试:');
testResults.errors.forEach((e, i) => {
console.log(` ${i + 1}. ${e.name}: ${e.error}`);
});
}
console.log('\n');
// 返回测试结果
return testResults;
}
// 运行测试
runTests().then(testResults => {
// 如果有失败,退出码为 1
process.exit(testResults.failed > 0 ? 1 : 0);
}).catch(err => {
console.error('测试执行错误:', err);
process.exit(1);
});

View File

@@ -0,0 +1,838 @@
/**
* 网络异常和并发操作测试套件
*
* 测试范围:
* 1. 网络异常处理超时、断连、OSS连接失败
* 2. 并发操作测试(多文件上传、多文件删除、重复提交)
* 3. 防重复提交测试
*/
const assert = require('assert');
const path = require('path');
const fs = require('fs');
// 测试结果收集器
const testResults = {
passed: 0,
failed: 0,
errors: []
};
// 测试辅助函数
function test(name, fn) {
try {
fn();
testResults.passed++;
console.log(` [PASS] ${name}`);
} catch (error) {
testResults.failed++;
testResults.errors.push({ name, error: error.message });
console.log(` [FAIL] ${name}: ${error.message}`);
}
}
async function asyncTest(name, fn) {
try {
await fn();
testResults.passed++;
console.log(` [PASS] ${name}`);
} catch (error) {
testResults.failed++;
testResults.errors.push({ name, error: error.message });
console.log(` [FAIL] ${name}: ${error.message}`);
}
}
async function runTests() {
// ============================================================
// 1. OSS 错误格式化测试
// ============================================================
console.log('\n========== 1. OSS 错误格式化测试 ==========\n');
function testOssErrorFormatting() {
console.log('--- 测试 OSS 错误消息格式化 ---');
// 模拟 formatOssError 函数
function formatOssError(error, operation = '操作') {
const errorMessages = {
'NoSuchBucket': 'OSS 存储桶不存在,请检查配置',
'AccessDenied': 'OSS 访问被拒绝,请检查权限配置',
'InvalidAccessKeyId': 'OSS Access Key 无效,请重新配置',
'SignatureDoesNotMatch': 'OSS 签名验证失败,请检查 Secret Key',
'NoSuchKey': '文件或目录不存在',
'EntityTooLarge': '文件过大,超过了 OSS 允许的最大大小',
'RequestTimeout': 'OSS 请求超时,请稍后重试',
'SlowDown': 'OSS 请求过于频繁,请稍后重试',
'ServiceUnavailable': 'OSS 服务暂时不可用,请稍后重试',
'InternalError': 'OSS 内部错误,请稍后重试'
};
const networkErrors = {
'ECONNREFUSED': '无法连接到 OSS 服务,请检查网络',
'ENOTFOUND': 'OSS 服务地址无法解析,请检查 endpoint 配置',
'ETIMEDOUT': '连接 OSS 服务超时,请检查网络',
'ECONNRESET': '与 OSS 服务的连接被重置,请重试',
'EPIPE': '与 OSS 服务的连接中断,请重试',
'EHOSTUNREACH': '无法访问 OSS 服务主机,请检查网络'
};
if (error.name && errorMessages[error.name]) {
return new Error(`${operation}失败: ${errorMessages[error.name]}`);
}
if (error.code && networkErrors[error.code]) {
return new Error(`${operation}失败: ${networkErrors[error.code]}`);
}
if (error.$metadata?.httpStatusCode) {
const statusCode = error.$metadata.httpStatusCode;
const statusMessages = {
400: '请求参数错误',
401: '认证失败,请检查 Access Key',
403: '没有权限执行此操作',
404: '资源不存在',
429: '请求过于频繁,请稍后重试',
500: 'OSS 服务内部错误',
503: 'OSS 服务暂时不可用'
};
if (statusMessages[statusCode]) {
return new Error(`${operation}失败: ${statusMessages[statusCode]}`);
}
}
return new Error(`${operation}失败: ${error.message}`);
}
test('NoSuchBucket 错误应该被正确格式化', () => {
const error = { name: 'NoSuchBucket', message: 'The specified bucket does not exist' };
const formatted = formatOssError(error, '列出文件');
assert.ok(formatted.message.includes('存储桶不存在'));
});
test('AccessDenied 错误应该被正确格式化', () => {
const error = { name: 'AccessDenied', message: 'Access Denied' };
const formatted = formatOssError(error, '上传文件');
assert.ok(formatted.message.includes('访问被拒绝'));
});
test('网络超时错误应该被正确格式化', () => {
const error = { code: 'ETIMEDOUT', message: 'connect ETIMEDOUT' };
const formatted = formatOssError(error, '连接');
assert.ok(formatted.message.includes('超时'));
});
test('连接被拒绝错误应该被正确格式化', () => {
const error = { code: 'ECONNREFUSED', message: 'connect ECONNREFUSED' };
const formatted = formatOssError(error, '连接');
assert.ok(formatted.message.includes('无法连接'));
});
test('DNS 解析失败应该被正确格式化', () => {
const error = { code: 'ENOTFOUND', message: 'getaddrinfo ENOTFOUND' };
const formatted = formatOssError(error, '连接');
assert.ok(formatted.message.includes('无法解析'));
});
test('HTTP 401 错误应该被正确格式化', () => {
const error = {
message: 'Unauthorized',
$metadata: { httpStatusCode: 401 }
};
const formatted = formatOssError(error, '认证');
assert.ok(formatted.message.includes('认证失败'));
});
test('HTTP 403 错误应该被正确格式化', () => {
const error = {
message: 'Forbidden',
$metadata: { httpStatusCode: 403 }
};
const formatted = formatOssError(error, '访问');
assert.ok(formatted.message.includes('没有权限'));
});
test('HTTP 429 错误(限流)应该被正确格式化', () => {
const error = {
message: 'Too Many Requests',
$metadata: { httpStatusCode: 429 }
};
const formatted = formatOssError(error, '请求');
assert.ok(formatted.message.includes('过于频繁'));
});
test('未知错误应该保留原始消息', () => {
const error = { message: 'Unknown error occurred' };
const formatted = formatOssError(error, '操作');
assert.ok(formatted.message.includes('Unknown error occurred'));
});
}
testOssErrorFormatting();
// ============================================================
// 2. 并发限流测试
// ============================================================
console.log('\n========== 2. 并发限流测试 ==========\n');
async function testConcurrentRateLimiting() {
console.log('--- 测试并发请求限流 ---');
// 简化版 RateLimiter
class RateLimiter {
constructor(options = {}) {
this.maxAttempts = options.maxAttempts || 5;
this.windowMs = options.windowMs || 15 * 60 * 1000;
this.blockDuration = options.blockDuration || 30 * 60 * 1000;
this.attempts = new Map();
this.blockedKeys = new Map();
}
isBlocked(key) {
const blockInfo = this.blockedKeys.get(key);
if (!blockInfo) return false;
if (Date.now() > blockInfo.expiresAt) {
this.blockedKeys.delete(key);
this.attempts.delete(key);
return false;
}
return true;
}
recordFailure(key) {
const now = Date.now();
if (this.isBlocked(key)) {
return { blocked: true, remainingAttempts: 0 };
}
let attemptInfo = this.attempts.get(key);
if (!attemptInfo || now > attemptInfo.windowEnd) {
attemptInfo = { count: 0, windowEnd: now + this.windowMs };
}
attemptInfo.count++;
this.attempts.set(key, attemptInfo);
if (attemptInfo.count >= this.maxAttempts) {
this.blockedKeys.set(key, {
expiresAt: now + this.blockDuration
});
return { blocked: true, remainingAttempts: 0 };
}
return {
blocked: false,
remainingAttempts: this.maxAttempts - attemptInfo.count
};
}
recordSuccess(key) {
this.attempts.delete(key);
this.blockedKeys.delete(key);
}
getStats() {
return {
activeAttempts: this.attempts.size,
blockedKeys: this.blockedKeys.size
};
}
}
await asyncTest('并发失败请求应该正确累计', async () => {
const limiter = new RateLimiter({ maxAttempts: 5, windowMs: 1000, blockDuration: 1000 });
const key = 'concurrent-test-1';
// 模拟并发请求
const promises = Array(5).fill().map(() =>
new Promise(resolve => {
const result = limiter.recordFailure(key);
resolve(result);
})
);
const results = await Promise.all(promises);
// 最后一个请求应该触发阻止
assert.ok(results.some(r => r.blocked), '应该有请求被阻止');
});
await asyncTest('不同 IP 的并发请求应该独立计数', async () => {
const limiter = new RateLimiter({ maxAttempts: 3, windowMs: 1000, blockDuration: 1000 });
// 模拟来自不同 IP 的请求
const ips = ['192.168.1.1', '192.168.1.2', '192.168.1.3'];
for (const ip of ips) {
limiter.recordFailure(`login:ip:${ip}`);
limiter.recordFailure(`login:ip:${ip}`);
}
// 每个 IP 都应该还有 1 次机会
for (const ip of ips) {
const result = limiter.recordFailure(`login:ip:${ip}`);
assert.strictEqual(result.blocked, true, `IP ${ip} 应该被阻止`);
}
});
await asyncTest('限流器统计应该正确反映状态', async () => {
const limiter = new RateLimiter({ maxAttempts: 2, windowMs: 1000, blockDuration: 1000 });
limiter.recordFailure('key1');
limiter.recordFailure('key2');
limiter.recordFailure('key2'); // 这会阻止 key2
const stats = limiter.getStats();
assert.ok(stats.activeAttempts >= 1, '应该有活动的尝试记录');
assert.ok(stats.blockedKeys >= 1, '应该有被阻止的 key');
});
}
await testConcurrentRateLimiting();
// ============================================================
// 3. 文件上传并发测试
// ============================================================
console.log('\n========== 3. 文件上传并发测试 ==========\n');
async function testConcurrentFileOperations() {
console.log('--- 测试并发文件操作 ---');
// 模拟文件上传限流器
class UploadLimiter {
constructor(maxConcurrent = 5, maxPerHour = 100) {
this.maxConcurrent = maxConcurrent;
this.maxPerHour = maxPerHour;
this.currentUploads = new Map();
this.hourlyCount = new Map();
}
canUpload(userId) {
const now = Date.now();
const hourKey = `${userId}:${Math.floor(now / 3600000)}`;
// 检查小时限制
const hourlyUsage = this.hourlyCount.get(hourKey) || 0;
if (hourlyUsage >= this.maxPerHour) {
return { allowed: false, reason: '每小时上传次数已达上限' };
}
// 检查并发限制
const userUploads = this.currentUploads.get(userId) || 0;
if (userUploads >= this.maxConcurrent) {
return { allowed: false, reason: '并发上传数已达上限' };
}
return { allowed: true };
}
startUpload(userId) {
const check = this.canUpload(userId);
if (!check.allowed) {
return check;
}
const now = Date.now();
const hourKey = `${userId}:${Math.floor(now / 3600000)}`;
// 增加计数
this.currentUploads.set(userId, (this.currentUploads.get(userId) || 0) + 1);
this.hourlyCount.set(hourKey, (this.hourlyCount.get(hourKey) || 0) + 1);
return { allowed: true };
}
endUpload(userId) {
const current = this.currentUploads.get(userId) || 0;
if (current > 0) {
this.currentUploads.set(userId, current - 1);
}
}
getStatus(userId) {
const now = Date.now();
const hourKey = `${userId}:${Math.floor(now / 3600000)}`;
return {
concurrent: this.currentUploads.get(userId) || 0,
hourlyUsed: this.hourlyCount.get(hourKey) || 0,
maxConcurrent: this.maxConcurrent,
maxPerHour: this.maxPerHour
};
}
}
await asyncTest('并发上传限制应该生效', async () => {
const limiter = new UploadLimiter(3, 100);
const userId = 'user1';
// 开始 3 个上传
assert.ok(limiter.startUpload(userId).allowed);
assert.ok(limiter.startUpload(userId).allowed);
assert.ok(limiter.startUpload(userId).allowed);
// 第 4 个应该被拒绝
const result = limiter.startUpload(userId);
assert.strictEqual(result.allowed, false);
assert.ok(result.reason.includes('并发'));
});
await asyncTest('完成上传后应该释放并发槽位', async () => {
const limiter = new UploadLimiter(2, 100);
const userId = 'user2';
limiter.startUpload(userId);
limiter.startUpload(userId);
// 应该被拒绝
assert.strictEqual(limiter.startUpload(userId).allowed, false);
// 完成一个上传
limiter.endUpload(userId);
// 现在应该允许
assert.ok(limiter.startUpload(userId).allowed);
});
await asyncTest('每小时上传限制应该生效', async () => {
const limiter = new UploadLimiter(100, 5); // 最多 5 次每小时
const userId = 'user3';
// 上传 5 次
for (let i = 0; i < 5; i++) {
limiter.startUpload(userId);
limiter.endUpload(userId);
}
// 第 6 次应该被拒绝
const result = limiter.startUpload(userId);
assert.strictEqual(result.allowed, false);
assert.ok(result.reason.includes('小时'));
});
await asyncTest('不同用户的限制应该独立', async () => {
const limiter = new UploadLimiter(2, 100);
// 用户 1 达到限制
limiter.startUpload('userA');
limiter.startUpload('userA');
assert.strictEqual(limiter.startUpload('userA').allowed, false);
// 用户 2 应该不受影响
assert.ok(limiter.startUpload('userB').allowed);
});
}
await testConcurrentFileOperations();
// ============================================================
// 4. 防重复提交测试
// ============================================================
console.log('\n========== 4. 防重复提交测试 ==========\n');
async function testDuplicateSubmissionPrevention() {
console.log('--- 测试防重复提交机制 ---');
// 简单的请求去重器
class RequestDeduplicator {
constructor(windowMs = 1000) {
this.windowMs = windowMs;
this.pending = new Map();
}
// 生成请求唯一标识
getRequestKey(userId, action, params) {
return `${userId}:${action}:${JSON.stringify(params)}`;
}
// 检查是否是重复请求
isDuplicate(userId, action, params) {
const key = this.getRequestKey(userId, action, params);
const now = Date.now();
if (this.pending.has(key)) {
const lastRequest = this.pending.get(key);
if (now - lastRequest < this.windowMs) {
return true;
}
}
this.pending.set(key, now);
return false;
}
// 清除过期记录
cleanup() {
const now = Date.now();
for (const [key, timestamp] of this.pending.entries()) {
if (now - timestamp > this.windowMs) {
this.pending.delete(key);
}
}
}
}
await asyncTest('快速重复提交应该被检测', async () => {
const dedup = new RequestDeduplicator(100);
const isDup1 = dedup.isDuplicate('user1', 'delete', { file: 'test.txt' });
assert.strictEqual(isDup1, false, '首次请求不应该是重复');
const isDup2 = dedup.isDuplicate('user1', 'delete', { file: 'test.txt' });
assert.strictEqual(isDup2, true, '立即重复应该被检测');
});
await asyncTest('不同参数的请求不应该被视为重复', async () => {
const dedup = new RequestDeduplicator(100);
dedup.isDuplicate('user1', 'delete', { file: 'test1.txt' });
const isDup = dedup.isDuplicate('user1', 'delete', { file: 'test2.txt' });
assert.strictEqual(isDup, false, '不同参数不应该是重复');
});
await asyncTest('超时后应该允许重新提交', async () => {
const dedup = new RequestDeduplicator(50);
dedup.isDuplicate('user1', 'create', { name: 'folder' });
// 等待超时
await new Promise(resolve => setTimeout(resolve, 60));
const isDup = dedup.isDuplicate('user1', 'create', { name: 'folder' });
assert.strictEqual(isDup, false, '超时后应该允许');
});
await asyncTest('不同用户的相同请求不应该冲突', async () => {
const dedup = new RequestDeduplicator(100);
dedup.isDuplicate('user1', 'share', { file: 'doc.pdf' });
const isDup = dedup.isDuplicate('user2', 'share', { file: 'doc.pdf' });
assert.strictEqual(isDup, false, '不同用户不应该冲突');
});
}
await testDuplicateSubmissionPrevention();
// ============================================================
// 5. 缓存失效测试
// ============================================================
console.log('\n========== 5. 缓存失效测试 ==========\n');
async function testCacheInvalidation() {
console.log('--- 测试缓存过期和失效 ---');
// TTL 缓存类
class TTLCache {
constructor(defaultTTL = 3600000) {
this.cache = new Map();
this.defaultTTL = defaultTTL;
}
set(key, value, ttl = this.defaultTTL) {
const expiresAt = Date.now() + ttl;
this.cache.set(key, { value, expiresAt });
}
get(key) {
const item = this.cache.get(key);
if (!item) return undefined;
if (Date.now() > item.expiresAt) {
this.cache.delete(key);
return undefined;
}
return item.value;
}
has(key) {
return this.get(key) !== undefined;
}
delete(key) {
return this.cache.delete(key);
}
size() {
return this.cache.size;
}
cleanup() {
const now = Date.now();
let cleaned = 0;
for (const [key, item] of this.cache.entries()) {
if (now > item.expiresAt) {
this.cache.delete(key);
cleaned++;
}
}
return cleaned;
}
}
await asyncTest('缓存应该在 TTL 内有效', async () => {
const cache = new TTLCache(100);
cache.set('key1', 'value1');
assert.strictEqual(cache.get('key1'), 'value1');
});
await asyncTest('缓存应该在 TTL 后过期', async () => {
const cache = new TTLCache(50);
cache.set('key2', 'value2');
await new Promise(resolve => setTimeout(resolve, 60));
assert.strictEqual(cache.get('key2'), undefined);
});
await asyncTest('手动删除应该立即生效', async () => {
const cache = new TTLCache(10000);
cache.set('key3', 'value3');
cache.delete('key3');
assert.strictEqual(cache.get('key3'), undefined);
});
await asyncTest('cleanup 应该清除所有过期项', async () => {
const cache = new TTLCache(50);
cache.set('a', 1);
cache.set('b', 2);
cache.set('c', 3);
await new Promise(resolve => setTimeout(resolve, 60));
const cleaned = cache.cleanup();
assert.strictEqual(cleaned, 3);
assert.strictEqual(cache.size(), 0);
});
await asyncTest('不同 TTL 的项应该分别过期', async () => {
const cache = new TTLCache(1000);
cache.set('short', 'value', 30);
cache.set('long', 'value', 1000);
await new Promise(resolve => setTimeout(resolve, 50));
assert.strictEqual(cache.get('short'), undefined, '短 TTL 应该过期');
assert.strictEqual(cache.get('long'), 'value', '长 TTL 应该有效');
});
}
await testCacheInvalidation();
// ============================================================
// 6. 超时处理测试
// ============================================================
console.log('\n========== 6. 超时处理测试 ==========\n');
async function testTimeoutHandling() {
console.log('--- 测试请求超时处理 ---');
// 带超时的 Promise 包装器
function withTimeout(promise, ms, errorMessage = '操作超时') {
let timeoutId;
const timeoutPromise = new Promise((_, reject) => {
timeoutId = setTimeout(() => {
reject(new Error(errorMessage));
}, ms);
});
return Promise.race([promise, timeoutPromise]).finally(() => {
clearTimeout(timeoutId);
});
}
await asyncTest('快速操作应该成功完成', async () => {
const fastOperation = new Promise(resolve => {
setTimeout(() => resolve('success'), 10);
});
const result = await withTimeout(fastOperation, 100);
assert.strictEqual(result, 'success');
});
await asyncTest('慢速操作应该触发超时', async () => {
const slowOperation = new Promise(resolve => {
setTimeout(() => resolve('success'), 200);
});
try {
await withTimeout(slowOperation, 50);
assert.fail('应该抛出超时错误');
} catch (error) {
assert.ok(error.message.includes('超时'));
}
});
await asyncTest('自定义超时消息应该正确显示', async () => {
const slowOperation = new Promise(resolve => {
setTimeout(() => resolve('success'), 200);
});
try {
await withTimeout(slowOperation, 50, 'OSS 连接超时');
} catch (error) {
assert.ok(error.message.includes('OSS'));
}
});
await asyncTest('超时后原始 Promise 的完成不应该影响结果', async () => {
let completed = false;
const operation = new Promise(resolve => {
setTimeout(() => {
completed = true;
resolve('done');
}, 100);
});
try {
await withTimeout(operation, 20);
} catch (error) {
// 超时了
}
// 等待原始 Promise 完成
await new Promise(resolve => setTimeout(resolve, 150));
assert.ok(completed, '原始 Promise 应该完成');
});
}
await testTimeoutHandling();
// ============================================================
// 7. 重试机制测试
// ============================================================
console.log('\n========== 7. 重试机制测试 ==========\n');
async function testRetryMechanism() {
console.log('--- 测试操作重试机制 ---');
// 带重试的函数执行器
async function withRetry(fn, options = {}) {
const {
maxAttempts = 3,
delayMs = 100,
backoff = 1.5,
shouldRetry = (error) => true
} = options;
let lastError;
let delay = delayMs;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn();
} catch (error) {
lastError = error;
if (attempt === maxAttempts || !shouldRetry(error)) {
throw error;
}
await new Promise(resolve => setTimeout(resolve, delay));
delay *= backoff;
}
}
throw lastError;
}
await asyncTest('成功操作不应该重试', async () => {
let attempts = 0;
const result = await withRetry(async () => {
attempts++;
return 'success';
});
assert.strictEqual(result, 'success');
assert.strictEqual(attempts, 1);
});
await asyncTest('失败操作应该重试指定次数', async () => {
let attempts = 0;
try {
await withRetry(async () => {
attempts++;
throw new Error('always fail');
}, { maxAttempts: 3, delayMs: 10 });
} catch (error) {
// 预期会失败
}
assert.strictEqual(attempts, 3);
});
await asyncTest('重试后成功应该返回结果', async () => {
let attempts = 0;
const result = await withRetry(async () => {
attempts++;
if (attempts < 3) {
throw new Error('not yet');
}
return 'finally success';
}, { maxAttempts: 5, delayMs: 10 });
assert.strictEqual(result, 'finally success');
assert.strictEqual(attempts, 3);
});
await asyncTest('shouldRetry 为 false 时不应该重试', async () => {
let attempts = 0;
try {
await withRetry(async () => {
attempts++;
const error = new Error('fatal');
error.code = 'FATAL';
throw error;
}, {
maxAttempts: 5,
delayMs: 10,
shouldRetry: (error) => error.code !== 'FATAL'
});
} catch (error) {
// 预期会失败
}
assert.strictEqual(attempts, 1, '不应该重试 FATAL 错误');
});
}
await testRetryMechanism();
// ============================================================
// 测试总结
// ============================================================
console.log('\n========================================');
console.log('测试总结');
console.log('========================================');
console.log(`通过: ${testResults.passed}`);
console.log(`失败: ${testResults.failed}`);
if (testResults.errors.length > 0) {
console.log('\n失败的测试:');
testResults.errors.forEach((e, i) => {
console.log(` ${i + 1}. ${e.name}: ${e.error}`);
});
}
console.log('\n');
return testResults;
}
// 运行测试
runTests().then(testResults => {
process.exit(testResults.failed > 0 ? 1 : 0);
}).catch(err => {
console.error('测试执行错误:', err);
process.exit(1);
});

View File

@@ -0,0 +1,106 @@
/**
* 运行所有边界条件和异常处理测试
*/
const { spawn } = require('child_process');
const path = require('path');
const testFiles = [
'boundary-tests.js',
'network-concurrent-tests.js',
'state-consistency-tests.js'
];
const results = {
total: { passed: 0, failed: 0 },
files: []
};
function runTest(file) {
return new Promise((resolve) => {
const testPath = path.join(__dirname, file);
const child = spawn('node', [testPath], {
stdio: ['pipe', 'pipe', 'pipe']
});
let output = '';
let errorOutput = '';
child.stdout.on('data', (data) => {
output += data.toString();
process.stdout.write(data);
});
child.stderr.on('data', (data) => {
errorOutput += data.toString();
process.stderr.write(data);
});
child.on('close', (code) => {
// 解析测试结果
const passMatch = output.match(/通过:\s*(\d+)/);
const failMatch = output.match(/失败:\s*(\d+)/);
const passed = passMatch ? parseInt(passMatch[1]) : 0;
const failed = failMatch ? parseInt(failMatch[1]) : 0;
results.files.push({
file,
passed,
failed,
exitCode: code
});
results.total.passed += passed;
results.total.failed += failed;
resolve(code);
});
});
}
async function runAllTests() {
console.log('='.repeat(60));
console.log('运行所有边界条件和异常处理测试');
console.log('='.repeat(60));
console.log('');
for (const file of testFiles) {
console.log('='.repeat(60));
console.log(`测试文件: ${file}`);
console.log('='.repeat(60));
await runTest(file);
console.log('');
}
// 输出最终汇总
console.log('='.repeat(60));
console.log('最终汇总');
console.log('='.repeat(60));
console.log('');
console.log('各测试文件结果:');
for (const fileResult of results.files) {
const status = fileResult.failed === 0 ? 'PASS' : 'FAIL';
console.log(` [${status}] ${fileResult.file}: 通过 ${fileResult.passed}, 失败 ${fileResult.failed}`);
}
console.log('');
console.log(`总计: 通过 ${results.total.passed}, 失败 ${results.total.failed}`);
console.log('');
if (results.total.failed > 0) {
console.log('存在失败的测试,请检查输出以了解详情。');
process.exit(1);
} else {
console.log('所有测试通过!');
process.exit(0);
}
}
runAllTests().catch(err => {
console.error('运行测试时发生错误:', err);
process.exit(1);
});

View File

@@ -0,0 +1,896 @@
/**
* 状态一致性测试套件
*
* 测试范围:
* 1. Token 过期处理和刷新机制
* 2. 存储切换后数据一致性
* 3. 会话状态管理
* 4. 本地存储状态恢复
*/
const assert = require('assert');
// 测试结果收集器
const testResults = {
passed: 0,
failed: 0,
errors: []
};
// 测试辅助函数
function test(name, fn) {
try {
fn();
testResults.passed++;
console.log(` [PASS] ${name}`);
} catch (error) {
testResults.failed++;
testResults.errors.push({ name, error: error.message });
console.log(` [FAIL] ${name}: ${error.message}`);
}
}
async function asyncTest(name, fn) {
try {
await fn();
testResults.passed++;
console.log(` [PASS] ${name}`);
} catch (error) {
testResults.failed++;
testResults.errors.push({ name, error: error.message });
console.log(` [FAIL] ${name}: ${error.message}`);
}
}
async function runTests() {
// ============================================================
// 1. Token 管理测试
// ============================================================
console.log('\n========== 1. Token 管理测试 ==========\n');
function testTokenManagement() {
console.log('--- 测试 Token 过期和刷新机制 ---');
// 模拟 JWT Token 结构
function createMockToken(payload, expiresInMs) {
const header = { alg: 'HS256', typ: 'JWT' };
const iat = Math.floor(Date.now() / 1000);
const exp = iat + Math.floor(expiresInMs / 1000);
const tokenPayload = { ...payload, iat, exp };
// 简化的 base64 编码(仅用于测试)
const base64Header = Buffer.from(JSON.stringify(header)).toString('base64url');
const base64Payload = Buffer.from(JSON.stringify(tokenPayload)).toString('base64url');
return `${base64Header}.${base64Payload}.signature`;
}
// 解析 Token 并检查过期
function parseToken(token) {
try {
const parts = token.split('.');
if (parts.length !== 3) return null;
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
const now = Math.floor(Date.now() / 1000);
return {
...payload,
isExpired: payload.exp < now,
expiresIn: (payload.exp - now) * 1000
};
} catch {
return null;
}
}
// 检查是否需要刷新 Token提前 5 分钟刷新)
function needsRefresh(token, thresholdMs = 5 * 60 * 1000) {
const parsed = parseToken(token);
if (!parsed) return true;
return parsed.expiresIn < thresholdMs;
}
test('有效 Token 应该能正确解析', () => {
const token = createMockToken({ id: 1, username: 'test' }, 2 * 60 * 60 * 1000);
const parsed = parseToken(token);
assert.ok(parsed, 'Token 应该能被解析');
assert.strictEqual(parsed.id, 1);
assert.strictEqual(parsed.username, 'test');
assert.strictEqual(parsed.isExpired, false);
});
test('过期 Token 应该被正确识别', () => {
const token = createMockToken({ id: 1 }, -1000); // 已过期
const parsed = parseToken(token);
assert.ok(parsed.isExpired, 'Token 应该被标记为过期');
});
test('即将过期的 Token 应该触发刷新', () => {
const token = createMockToken({ id: 1 }, 3 * 60 * 1000); // 3 分钟后过期
assert.ok(needsRefresh(token, 5 * 60 * 1000), '3 分钟后过期的 Token 应该触发刷新');
});
test('有效期充足的 Token 不应该触发刷新', () => {
const token = createMockToken({ id: 1 }, 30 * 60 * 1000); // 30 分钟后过期
assert.ok(!needsRefresh(token, 5 * 60 * 1000), '30 分钟后过期的 Token 不应该触发刷新');
});
test('无效 Token 格式应该返回 null', () => {
assert.strictEqual(parseToken('invalid'), null);
assert.strictEqual(parseToken('a.b'), null);
assert.strictEqual(parseToken(''), null);
});
}
testTokenManagement();
// ============================================================
// 2. 存储切换一致性测试
// ============================================================
console.log('\n========== 2. 存储切换一致性测试 ==========\n');
function testStorageSwitchConsistency() {
console.log('--- 测试存储类型切换数据一致性 ---');
// 模拟用户存储状态
class UserStorageState {
constructor(user) {
this.userId = user.id;
this.storageType = user.current_storage_type || 'oss';
this.permission = user.storage_permission || 'oss_only';
this.localQuota = user.local_storage_quota || 1073741824;
this.localUsed = user.local_storage_used || 0;
this.hasOssConfig = user.has_oss_config || 0;
}
// 检查是否可以切换到指定存储类型
canSwitchTo(targetType) {
// 检查权限
if (this.permission === 'oss_only' && targetType === 'local') {
return { allowed: false, reason: '您没有使用本地存储的权限' };
}
if (this.permission === 'local_only' && targetType === 'oss') {
return { allowed: false, reason: '您没有使用 OSS 存储的权限' };
}
// 检查 OSS 配置
if (targetType === 'oss' && !this.hasOssConfig) {
return { allowed: false, reason: '请先配置 OSS 服务' };
}
// 检查本地存储配额
if (targetType === 'local' && this.localUsed >= this.localQuota) {
return { allowed: false, reason: '本地存储空间已满' };
}
return { allowed: true };
}
// 切换存储类型
switchTo(targetType) {
const check = this.canSwitchTo(targetType);
if (!check.allowed) {
throw new Error(check.reason);
}
this.storageType = targetType;
return true;
}
// 获取当前可用空间
getAvailableSpace() {
if (this.storageType === 'local') {
return this.localQuota - this.localUsed;
}
return null; // OSS 空间由用户 Bucket 决定
}
}
test('OSS only 权限用户不能切换到本地存储', () => {
const user = { id: 1, storage_permission: 'oss_only', has_oss_config: 1 };
const state = new UserStorageState(user);
const result = state.canSwitchTo('local');
assert.strictEqual(result.allowed, false);
assert.ok(result.reason.includes('权限'));
});
test('本地 only 权限用户不能切换到 OSS 存储', () => {
const user = { id: 1, storage_permission: 'local_only' };
const state = new UserStorageState(user);
const result = state.canSwitchTo('oss');
assert.strictEqual(result.allowed, false);
assert.ok(result.reason.includes('权限'));
});
test('未配置 OSS 的用户不能切换到 OSS', () => {
const user = { id: 1, storage_permission: 'both', has_oss_config: 0 };
const state = new UserStorageState(user);
const result = state.canSwitchTo('oss');
assert.strictEqual(result.allowed, false);
assert.ok(result.reason.includes('配置'));
});
test('本地存储已满时不能切换到本地', () => {
const user = {
id: 1,
storage_permission: 'both',
local_storage_quota: 1000,
local_storage_used: 1000
};
const state = new UserStorageState(user);
const result = state.canSwitchTo('local');
assert.strictEqual(result.allowed, false);
assert.ok(result.reason.includes('已满'));
});
test('有权限且已配置的用户可以自由切换', () => {
const user = {
id: 1,
storage_permission: 'both',
has_oss_config: 1,
local_storage_quota: 10000,
local_storage_used: 5000
};
const state = new UserStorageState(user);
assert.ok(state.canSwitchTo('oss').allowed);
assert.ok(state.canSwitchTo('local').allowed);
});
test('切换后状态应该正确更新', () => {
const user = {
id: 1,
storage_permission: 'both',
has_oss_config: 1,
current_storage_type: 'oss'
};
const state = new UserStorageState(user);
assert.strictEqual(state.storageType, 'oss');
state.switchTo('local');
assert.strictEqual(state.storageType, 'local');
});
}
testStorageSwitchConsistency();
// ============================================================
// 3. 会话状态管理测试
// ============================================================
console.log('\n========== 3. 会话状态管理测试 ==========\n');
async function testSessionManagement() {
console.log('--- 测试会话状态管理 ---');
// 模拟会话管理器
class SessionManager {
constructor() {
this.sessions = new Map();
this.sessionTTL = 30 * 60 * 1000; // 30 分钟
}
createSession(userId) {
const sessionId = `sess_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const session = {
id: sessionId,
userId,
createdAt: Date.now(),
lastActivity: Date.now(),
data: {}
};
this.sessions.set(sessionId, session);
return sessionId;
}
getSession(sessionId) {
const session = this.sessions.get(sessionId);
if (!session) return null;
// 检查会话是否过期
if (Date.now() - session.lastActivity > this.sessionTTL) {
this.sessions.delete(sessionId);
return null;
}
// 更新最后活动时间
session.lastActivity = Date.now();
return session;
}
updateSessionData(sessionId, data) {
const session = this.getSession(sessionId);
if (!session) return false;
session.data = { ...session.data, ...data };
return true;
}
destroySession(sessionId) {
return this.sessions.delete(sessionId);
}
getActiveSessions(userId) {
const now = Date.now();
const active = [];
for (const session of this.sessions.values()) {
if (session.userId === userId && now - session.lastActivity <= this.sessionTTL) {
active.push(session);
}
}
return active;
}
// 强制登出用户所有会话
destroyUserSessions(userId) {
let count = 0;
for (const [sessionId, session] of this.sessions.entries()) {
if (session.userId === userId) {
this.sessions.delete(sessionId);
count++;
}
}
return count;
}
}
const manager = new SessionManager();
await asyncTest('创建会话应该返回有效的会话 ID', async () => {
const sessionId = manager.createSession(1);
assert.ok(sessionId.startsWith('sess_'));
assert.ok(manager.getSession(sessionId) !== null);
});
await asyncTest('获取会话应该返回正确的用户 ID', async () => {
const sessionId = manager.createSession(42);
const session = manager.getSession(sessionId);
assert.strictEqual(session.userId, 42);
});
await asyncTest('更新会话数据应该持久化', async () => {
const sessionId = manager.createSession(1);
manager.updateSessionData(sessionId, { captcha: 'ABC123' });
const session = manager.getSession(sessionId);
assert.strictEqual(session.data.captcha, 'ABC123');
});
await asyncTest('销毁会话后应该无法获取', async () => {
const sessionId = manager.createSession(1);
manager.destroySession(sessionId);
assert.strictEqual(manager.getSession(sessionId), null);
});
await asyncTest('过期会话应该被自动清理', async () => {
const shortTTLManager = new SessionManager();
shortTTLManager.sessionTTL = 10; // 10ms
const sessionId = shortTTLManager.createSession(1);
await new Promise(resolve => setTimeout(resolve, 20));
assert.strictEqual(shortTTLManager.getSession(sessionId), null);
});
await asyncTest('强制登出应该清除用户所有会话', async () => {
const sessionId1 = manager.createSession(100);
const sessionId2 = manager.createSession(100);
const sessionId3 = manager.createSession(100);
const count = manager.destroyUserSessions(100);
assert.strictEqual(count, 3);
assert.strictEqual(manager.getSession(sessionId1), null);
assert.strictEqual(manager.getSession(sessionId2), null);
assert.strictEqual(manager.getSession(sessionId3), null);
});
}
await testSessionManagement();
// ============================================================
// 4. 本地存储状态恢复测试
// ============================================================
console.log('\n========== 4. 本地存储状态恢复测试 ==========\n');
function testLocalStorageRecovery() {
console.log('--- 测试本地存储状态恢复 ---');
// 模拟 localStorage
class MockLocalStorage {
constructor() {
this.store = {};
}
getItem(key) {
return this.store[key] || null;
}
setItem(key, value) {
this.store[key] = String(value);
}
removeItem(key) {
delete this.store[key];
}
clear() {
this.store = {};
}
}
// 状态恢复管理器
class StateRecoveryManager {
constructor(storage) {
this.storage = storage;
this.stateKey = 'app_state';
}
// 保存状态
saveState(state) {
try {
const serialized = JSON.stringify({
...state,
savedAt: Date.now()
});
this.storage.setItem(this.stateKey, serialized);
return true;
} catch (e) {
console.error('保存状态失败:', e);
return false;
}
}
// 恢复状态
restoreState(maxAgeMs = 24 * 60 * 60 * 1000) {
try {
const serialized = this.storage.getItem(this.stateKey);
if (!serialized) return null;
const state = JSON.parse(serialized);
// 检查状态是否过期
if (Date.now() - state.savedAt > maxAgeMs) {
this.clearState();
return null;
}
// 移除元数据
delete state.savedAt;
return state;
} catch (e) {
console.error('恢复状态失败:', e);
return null;
}
}
// 清除状态
clearState() {
this.storage.removeItem(this.stateKey);
}
// 合并恢复的状态和默认状态
mergeWithDefaults(defaults) {
const restored = this.restoreState();
if (!restored) return defaults;
// 只恢复允许持久化的字段
const allowedFields = ['currentView', 'fileViewMode', 'adminTab', 'currentPath'];
const merged = { ...defaults };
for (const field of allowedFields) {
if (field in restored) {
merged[field] = restored[field];
}
}
return merged;
}
}
const storage = new MockLocalStorage();
const manager = new StateRecoveryManager(storage);
test('保存和恢复状态应该正常工作', () => {
const state = { currentView: 'files', currentPath: '/documents' };
manager.saveState(state);
const restored = manager.restoreState();
assert.strictEqual(restored.currentView, 'files');
assert.strictEqual(restored.currentPath, '/documents');
});
test('空存储应该返回 null', () => {
const emptyStorage = new MockLocalStorage();
const emptyManager = new StateRecoveryManager(emptyStorage);
assert.strictEqual(emptyManager.restoreState(), null);
});
test('过期状态应该被清除', () => {
// 手动设置一个过期的状态
storage.setItem('app_state', JSON.stringify({
currentView: 'old',
savedAt: Date.now() - 48 * 60 * 60 * 1000 // 48小时前
}));
const restored = manager.restoreState(24 * 60 * 60 * 1000);
assert.strictEqual(restored, null);
});
test('清除状态后应该无法恢复', () => {
manager.saveState({ test: 'value' });
manager.clearState();
assert.strictEqual(manager.restoreState(), null);
});
test('合并默认值应该优先使用恢复的值', () => {
manager.saveState({ currentView: 'shares', adminTab: 'users' });
const defaults = { currentView: 'files', fileViewMode: 'grid', adminTab: 'overview' };
const merged = manager.mergeWithDefaults(defaults);
assert.strictEqual(merged.currentView, 'shares');
assert.strictEqual(merged.adminTab, 'users');
assert.strictEqual(merged.fileViewMode, 'grid'); // 默认值
});
test('损坏的 JSON 应该返回 null', () => {
storage.setItem('app_state', 'not valid json{');
assert.strictEqual(manager.restoreState(), null);
});
}
testLocalStorageRecovery();
// ============================================================
// 5. 并发状态更新测试
// ============================================================
console.log('\n========== 5. 并发状态更新测试 ==========\n');
async function testConcurrentStateUpdates() {
console.log('--- 测试并发状态更新 ---');
// 简单的状态管理器(带版本控制)
class VersionedStateManager {
constructor(initialState = {}) {
this.state = { ...initialState };
this.version = 0;
this.updateQueue = [];
this.processing = false;
}
getState() {
return { ...this.state };
}
getVersion() {
return this.version;
}
// 乐观锁更新
async updateWithVersion(expectedVersion, updates) {
return new Promise((resolve, reject) => {
this.updateQueue.push({
expectedVersion,
updates,
resolve,
reject
});
this.processQueue();
});
}
// 强制更新(忽略版本)
forceUpdate(updates) {
this.state = { ...this.state, ...updates };
this.version++;
return { success: true, version: this.version };
}
async processQueue() {
if (this.processing || this.updateQueue.length === 0) return;
this.processing = true;
while (this.updateQueue.length > 0) {
const { expectedVersion, updates, resolve, reject } = this.updateQueue.shift();
if (expectedVersion !== this.version) {
reject(new Error('版本冲突,请刷新后重试'));
continue;
}
this.state = { ...this.state, ...updates };
this.version++;
resolve({ success: true, version: this.version, state: this.getState() });
}
this.processing = false;
}
}
await asyncTest('顺序更新应该成功', async () => {
const manager = new VersionedStateManager({ count: 0 });
await manager.updateWithVersion(0, { count: 1 });
await manager.updateWithVersion(1, { count: 2 });
assert.strictEqual(manager.getState().count, 2);
assert.strictEqual(manager.getVersion(), 2);
});
await asyncTest('版本冲突应该被检测', async () => {
const manager = new VersionedStateManager({ count: 0 });
// 第一个更新成功
await manager.updateWithVersion(0, { count: 1 });
// 使用旧版本尝试更新应该失败
try {
await manager.updateWithVersion(0, { count: 2 });
assert.fail('应该抛出版本冲突错误');
} catch (error) {
assert.ok(error.message.includes('冲突'));
}
});
await asyncTest('强制更新应该忽略版本', async () => {
const manager = new VersionedStateManager({ value: 'old' });
manager.forceUpdate({ value: 'new' });
assert.strictEqual(manager.getState().value, 'new');
});
await asyncTest('并发更新应该按顺序处理', async () => {
const manager = new VersionedStateManager({ count: 0 });
// 模拟并发更新
const results = await Promise.allSettled([
manager.updateWithVersion(0, { count: 1 }),
manager.updateWithVersion(0, { count: 2 }), // 这个会失败
manager.updateWithVersion(0, { count: 3 }) // 这个也会失败
]);
const fulfilled = results.filter(r => r.status === 'fulfilled').length;
const rejected = results.filter(r => r.status === 'rejected').length;
assert.strictEqual(fulfilled, 1, '应该只有一个更新成功');
assert.strictEqual(rejected, 2, '应该有两个更新失败');
});
}
await testConcurrentStateUpdates();
// ============================================================
// 6. 视图切换状态测试
// ============================================================
console.log('\n========== 6. 视图切换状态测试 ==========\n');
function testViewSwitchState() {
console.log('--- 测试视图切换状态保持 ---');
// 视图状态管理器
class ViewStateManager {
constructor() {
this.currentView = 'files';
this.viewStates = {
files: { path: '/', viewMode: 'grid', selection: [] },
shares: { viewMode: 'list', filter: 'all' },
admin: { tab: 'overview' }
};
}
switchTo(view) {
if (!this.viewStates[view]) {
throw new Error(`未知视图: ${view}`);
}
this.currentView = view;
return this.getViewState(view);
}
getViewState(view) {
return { ...this.viewStates[view || this.currentView] };
}
updateViewState(view, updates) {
if (!this.viewStates[view]) {
throw new Error(`未知视图: ${view}`);
}
this.viewStates[view] = { ...this.viewStates[view], ...updates };
}
// 获取完整状态快照
getSnapshot() {
return {
currentView: this.currentView,
viewStates: JSON.parse(JSON.stringify(this.viewStates))
};
}
// 从快照恢复
restoreFromSnapshot(snapshot) {
this.currentView = snapshot.currentView;
this.viewStates = JSON.parse(JSON.stringify(snapshot.viewStates));
}
}
const manager = new ViewStateManager();
test('切换视图应该返回该视图的状态', () => {
const state = manager.switchTo('shares');
assert.strictEqual(state.viewMode, 'list');
assert.strictEqual(state.filter, 'all');
});
test('更新视图状态应该被保存', () => {
manager.updateViewState('files', { path: '/documents', selection: ['file1.txt'] });
const state = manager.getViewState('files');
assert.strictEqual(state.path, '/documents');
assert.strictEqual(state.selection.length, 1);
});
test('切换视图后再切换回来应该保留状态', () => {
manager.updateViewState('files', { path: '/photos' });
manager.switchTo('shares');
manager.switchTo('files');
const state = manager.getViewState('files');
assert.strictEqual(state.path, '/photos');
});
test('切换到未知视图应该抛出错误', () => {
assert.throws(() => manager.switchTo('unknown'), /未知视图/);
});
test('快照和恢复应该正常工作', () => {
manager.updateViewState('files', { path: '/backup' });
const snapshot = manager.getSnapshot();
// 修改状态
manager.updateViewState('files', { path: '/different' });
// 从快照恢复
manager.restoreFromSnapshot(snapshot);
const state = manager.getViewState('files');
assert.strictEqual(state.path, '/backup');
});
}
testViewSwitchState();
// ============================================================
// 7. 主题切换一致性测试
// ============================================================
console.log('\n========== 7. 主题切换一致性测试 ==========\n');
function testThemeConsistency() {
console.log('--- 测试主题切换一致性 ---');
// 主题管理器
class ThemeManager {
constructor(globalDefault = 'dark') {
this.globalTheme = globalDefault;
this.userTheme = null; // null 表示跟随全局
}
setGlobalTheme(theme) {
if (!['dark', 'light'].includes(theme)) {
throw new Error('无效的主题');
}
this.globalTheme = theme;
}
setUserTheme(theme) {
if (theme !== null && !['dark', 'light'].includes(theme)) {
throw new Error('无效的主题');
}
this.userTheme = theme;
}
getEffectiveTheme() {
return this.userTheme || this.globalTheme;
}
isFollowingGlobal() {
return this.userTheme === null;
}
getThemeInfo() {
return {
global: this.globalTheme,
user: this.userTheme,
effective: this.getEffectiveTheme(),
followingGlobal: this.isFollowingGlobal()
};
}
}
test('默认应该使用全局主题', () => {
const manager = new ThemeManager('dark');
assert.strictEqual(manager.getEffectiveTheme(), 'dark');
assert.ok(manager.isFollowingGlobal());
});
test('用户主题应该覆盖全局主题', () => {
const manager = new ThemeManager('dark');
manager.setUserTheme('light');
assert.strictEqual(manager.getEffectiveTheme(), 'light');
assert.ok(!manager.isFollowingGlobal());
});
test('用户主题设为 null 应该跟随全局', () => {
const manager = new ThemeManager('dark');
manager.setUserTheme('light');
manager.setUserTheme(null);
assert.strictEqual(manager.getEffectiveTheme(), 'dark');
assert.ok(manager.isFollowingGlobal());
});
test('全局主题改变应该影响跟随全局的用户', () => {
const manager = new ThemeManager('dark');
manager.setGlobalTheme('light');
assert.strictEqual(manager.getEffectiveTheme(), 'light');
});
test('全局主题改变不应该影响有自定义主题的用户', () => {
const manager = new ThemeManager('dark');
manager.setUserTheme('light');
manager.setGlobalTheme('dark');
assert.strictEqual(manager.getEffectiveTheme(), 'light');
});
test('无效主题应该抛出错误', () => {
const manager = new ThemeManager();
assert.throws(() => manager.setGlobalTheme('invalid'), /无效/);
assert.throws(() => manager.setUserTheme('invalid'), /无效/);
});
}
testThemeConsistency();
// ============================================================
// 测试总结
// ============================================================
console.log('\n========================================');
console.log('测试总结');
console.log('========================================');
console.log(`通过: ${testResults.passed}`);
console.log(`失败: ${testResults.failed}`);
if (testResults.errors.length > 0) {
console.log('\n失败的测试:');
testResults.errors.forEach((e, i) => {
console.log(` ${i + 1}. ${e.name}: ${e.error}`);
});
}
console.log('\n');
return testResults;
}
// 运行测试
runTests().then(testResults => {
process.exit(testResults.failed > 0 ? 1 : 0);
}).catch(err => {
console.error('测试执行错误:', err);
process.exit(1);
});

271
backend/utils/encryption.js Normal file
View File

@@ -0,0 +1,271 @@
/**
* 加密工具模块
*
* 功能:
* - 使用 AES-256-GCM 加密敏感数据OSS Access Key Secret
* - 提供加密和解密函数
* - 自动处理初始化向量(IV)和认证标签
*
* 安全特性:
* - AES-256-GCM 提供认证加密AEAD
* - 每次加密使用随机 IV防止模式泄露
* - 使用认证标签验证数据完整性
* - 密钥从环境变量读取,不存在硬编码
*
* @module utils/encryption
*/
const crypto = require('crypto');
/**
* 从环境变量获取加密密钥
*
* 要求:
* - 必须是 32 字节的十六进制字符串64个字符
* - 如果未设置或格式错误,启动时抛出错误
*
* @returns {Buffer} 32字节的加密密钥
* @throws {Error} 如果密钥未配置或格式错误
*/
function getEncryptionKey() {
const keyHex = process.env.ENCRYPTION_KEY;
if (!keyHex) {
throw new Error(`
╔═══════════════════════════════════════════════════════════════╗
║ ⚠️ 安全错误 ⚠️ ║
╠═══════════════════════════════════════════════════════════════╣
║ ENCRYPTION_KEY 未配置! ║
║ ║
║ 此密钥用于加密 OSS Access Key Secret 等敏感信息 ║
║ ║
║ 生成方法: ║
║ node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
║ ║
║ 在 backend/.env 文件中添加: ║
║ ENCRYPTION_KEY=你生成的64位十六进制密钥 ║
╚═══════════════════════════════════════════════════════════════╝
`);
}
// 验证密钥格式必须是64个十六进制字符
if (!/^[0-9a-fA-F]{64}$/.test(keyHex)) {
throw new Error(`
╔═══════════════════════════════════════════════════════════════╗
║ ⚠️ 配置错误 ⚠️ ║
╠═══════════════════════════════════════════════════════════════╣
║ ENCRYPTION_KEY 格式错误! ║
║ ║
║ 要求: 64位十六进制字符串32字节
║ 当前长度: ${keyHex.length} 字符 ║
║ ║
║ 正确的生成方法: ║
║ node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
╚═══════════════════════════════════════════════════════════════╝
`);
}
return Buffer.from(keyHex, 'hex');
}
/**
* 加密明文字符串
*
* 使用 AES-256-GCM 算法加密数据,输出格式:
* - Base64(IV + ciphertext + authTag)
* - IV: 12字节随机
* - ciphertext: 加密后的数据
* - authTag: 16字节认证标签
*
* @param {string} plaintext - 要加密的明文字符串
* @returns {string} Base64编码的加密结果包含 IV 和 authTag
* @throws {Error} 如果加密失败
*
* @example
* const encrypted = encryptSecret('my-secret-key');
* // 输出: 'base64-encoded-string-with-iv-and-tag'
*/
function encryptSecret(plaintext) {
try {
// 获取加密密钥
const key = getEncryptionKey();
// 生成随机初始化向量IV
// GCM 模式推荐 12 字节 IV
const iv = crypto.randomBytes(12);
// 创建加密器
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
// 加密数据
let encrypted = cipher.update(plaintext, 'utf8', 'binary');
encrypted += cipher.final('binary');
// 获取认证标签(用于验证数据完整性)
const authTag = cipher.getAuthTag();
// 组合IV + encrypted + authTag
const combined = Buffer.concat([
iv,
Buffer.from(encrypted, 'binary'),
authTag
]);
// 返回 Base64 编码的结果
return combined.toString('base64');
} catch (error) {
console.error('[加密] 加密失败:', error);
throw new Error('数据加密失败: ' + error.message);
}
}
/**
* 解密密文字符串
*
* 解密由 encryptSecret() 加密的数据
* 自动验证认证标签,确保数据完整性
*
* @param {string} ciphertext - Base64编码的密文由 encryptSecret 生成)
* @returns {string} 解密后的明文字符串
* @throws {Error} 如果解密失败或认证标签验证失败
*
* @example
* const decrypted = decryptSecret(encrypted);
* // 输出: 'my-secret-key'
*/
function decryptSecret(ciphertext) {
try {
// 如果是 null 或 undefined直接返回
if (!ciphertext) {
return ciphertext;
}
// 检查是否为加密格式Base64
// 如果不是 Base64可能是旧数据明文直接返回
if (!/^[A-Za-z0-9+/=]+$/.test(ciphertext)) {
console.warn('[加密] 检测到未加密的密钥,建议重新加密');
return ciphertext;
}
// 获取加密密钥
const key = getEncryptionKey();
// 解析 Base64
const combined = Buffer.from(ciphertext, 'base64');
// 提取各部分
// IV: 前 12 字节
const iv = combined.slice(0, 12);
// authTag: 最后 16 字节
const authTag = combined.slice(-16);
// ciphertext: 中间部分
const encrypted = combined.slice(12, -16);
// 创建解密器
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
// 设置认证标签
decipher.setAuthTag(authTag);
// 解密数据
let decrypted = decipher.update(encrypted, 'binary', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
} catch (error) {
// 如果解密失败,可能是旧数据(明文),直接返回
console.error('[加密] 解密失败,可能是未加密的旧数据:', error.message);
// 在开发环境抛出错误,生产环境尝试返回原值
if (process.env.NODE_ENV === 'production') {
console.error('[加密] 生产环境中解密失败,返回原值(可能导致 OSS 连接失败)');
return ciphertext;
}
throw new Error('数据解密失败: ' + error.message);
}
}
/**
* 验证加密系统是否正常工作
*
* 在应用启动时调用,确保:
* 1. ENCRYPTION_KEY 已配置
* 2. 加密/解密功能正常
*
* @returns {boolean} true 如果验证通过
* @throws {Error} 如果验证失败
*/
function validateEncryption() {
try {
const testData = 'test-secret-123';
// 测试加密
const encrypted = encryptSecret(testData);
// 验证加密结果不为空且不等于原文
if (!encrypted || encrypted === testData) {
throw new Error('加密结果异常');
}
// 测试解密
const decrypted = decryptSecret(encrypted);
// 验证解密结果等于原文
if (decrypted !== testData) {
throw new Error('解密结果不匹配');
}
console.log('[安全] ✓ 加密系统验证通过');
return true;
} catch (error) {
console.error('[安全] ✗ 加密系统验证失败:', error.message);
throw error;
}
}
/**
* 检查字符串是否已加密
*
* 通过格式判断是否为加密数据
* 注意:这不是加密学验证,仅用于提示
*
* @param {string} data - 要检查的数据
* @returns {boolean} true 如果看起来像是加密数据
*/
function isEncrypted(data) {
if (!data || typeof data !== 'string') {
return false;
}
// 加密后的数据特征:
// 1. 是有效的 Base64
// 2. 长度至少为 (12 + 16) * 4/3 = 38 字符IV + authTag 的 Base64
// 3. 通常会比原文长
try {
// 尝试解码 Base64
const buffer = Buffer.from(data, 'base64');
// 检查长度(至少包含 IV + authTag
// AES-GCM: 12字节IV + 至少1字节密文 + 16字节authTag = 29字节
// Base64编码后: 29 * 4/3 ≈ 39 字符
if (buffer.length < 29) {
return false;
}
return true;
} catch (error) {
return false;
}
}
module.exports = {
encryptSecret,
decryptSecret,
validateEncryption,
isEncrypted,
getEncryptionKey
};

View File

@@ -0,0 +1,352 @@
/**
* 存储使用情况缓存管理器
* ===== P0 性能优化:解决 OSS 统计性能瓶颈 =====
*
* 问题:每次获取存储使用情况都要遍历所有 OSS 对象,极其耗时
* 解决方案:使用数据库字段 storage_used 维护缓存,上传/删除时更新
*
* @module StorageUsageCache
*/
const { UserDB } = require('../database');
/**
* 存储使用情况缓存类
*/
class StorageUsageCache {
/**
* 获取用户的存储使用情况(从缓存)
* @param {number} userId - 用户ID
* @returns {Promise<{totalSize: number, totalSizeFormatted: string, fileCount: number, cached: boolean}>}
*/
static async getUsage(userId) {
try {
const user = UserDB.findById(userId);
if (!user) {
throw new Error('用户不存在');
}
// 从数据库缓存读取
const storageUsed = user.storage_used || 0;
// 导入格式化函数
const { formatFileSize } = require('../storage');
return {
totalSize: storageUsed,
totalSizeFormatted: formatFileSize(storageUsed),
fileCount: null, // 缓存模式不统计文件数
cached: true
};
} catch (error) {
console.error('[存储缓存] 获取失败:', error);
throw error;
}
}
/**
* 更新用户的存储使用量
* @param {number} userId - 用户ID
* @param {number} deltaSize - 变化量(正数为增加,负数为减少)
* @returns {Promise<boolean>}
*/
static async updateUsage(userId, deltaSize) {
try {
// 使用 SQL 原子操作,避免并发问题
const result = UserDB.update(userId, {
// 使用原始 SQL因为 update 方法不支持表达式
// 注意:这里需要在数据库层执行 UPDATE ... SET storage_used = storage_used + ?
});
// 直接执行 SQL 更新
const { db } = require('../database');
db.prepare(`
UPDATE users
SET storage_used = storage_used + ?
WHERE id = ?
`).run(deltaSize, userId);
console.log(`[存储缓存] 用户 ${userId} 存储变化: ${deltaSize > 0 ? '+' : ''}${deltaSize} 字节`);
return true;
} catch (error) {
console.error('[存储缓存] 更新失败:', error);
throw error;
}
}
/**
* 重置用户的存储使用量(管理员功能,用于全量统计后更新缓存)
* @param {number} userId - 用户ID
* @param {number} totalSize - 实际总大小
* @returns {Promise<boolean>}
*/
static async resetUsage(userId, totalSize) {
try {
// 使用直接SQL更新绕过UserDB.update()的字段白名单限制
const { db } = require('../database');
db.prepare(`
UPDATE users
SET storage_used = ?
WHERE id = ?
`).run(totalSize, userId);
console.log(`[存储缓存] 用户 ${userId} 存储重置: ${totalSize} 字节`);
return true;
} catch (error) {
console.error('[存储缓存] 重置失败:', error);
throw error;
}
}
/**
* 验证并修复缓存(管理员功能)
* 通过全量统计对比缓存值,如果不一致则更新
* @param {number} userId - 用户ID
* @param {OssStorageClient} ossClient - OSS 客户端实例
* @returns {Promise<{actual: number, cached: number, corrected: boolean}>}
*/
static async validateAndFix(userId, ossClient) {
try {
const { ListObjectsV2Command } = require('@aws-sdk/client-s3');
const user = UserDB.findById(userId);
if (!user) {
throw new Error('用户不存在');
}
// 执行全量统计
let totalSize = 0;
let continuationToken = null;
do {
const command = new ListObjectsV2Command({
Bucket: ossClient.getBucket(), // 使用ossClient的getBucket()方法以支持系统级统一OSS配置
Prefix: `user_${userId}/`,
ContinuationToken: continuationToken
});
const response = await ossClient.s3Client.send(command);
if (response.Contents) {
for (const obj of response.Contents) {
totalSize += obj.Size || 0;
}
}
continuationToken = response.NextContinuationToken;
} while (continuationToken);
const cached = user.storage_used || 0;
const corrected = totalSize !== cached;
if (corrected) {
await this.resetUsage(userId, totalSize);
console.log(`[存储缓存] 用户 ${userId} 缓存已修复: ${cached}${totalSize}`);
}
return {
actual: totalSize,
cached,
corrected
};
} catch (error) {
console.error('[存储缓存] 验证修复失败:', error);
throw error;
}
}
/**
* 检查缓存完整性(第二轮修复:缓存一致性保障)
* 对比缓存值与实际 OSS 存储使用情况,但不自动修复
* @param {number} userId - 用户ID
* @param {OssStorageClient} ossClient - OSS 客户端实例
* @returns {Promise<{consistent: boolean, cached: number, actual: number, diff: number}>}
*/
static async checkIntegrity(userId, ossClient) {
try {
const { ListObjectsV2Command } = require('@aws-sdk/client-s3');
const user = UserDB.findById(userId);
if (!user) {
throw new Error('用户不存在');
}
// 执行全量统计
let totalSize = 0;
let fileCount = 0;
let continuationToken = null;
do {
const command = new ListObjectsV2Command({
Bucket: ossClient.getBucket(), // 使用ossClient的getBucket()方法以支持系统级统一OSS配置
Prefix: `user_${userId}/`,
ContinuationToken: continuationToken
});
const response = await ossClient.s3Client.send(command);
if (response.Contents) {
for (const obj of response.Contents) {
totalSize += obj.Size || 0;
fileCount++;
}
}
continuationToken = response.NextContinuationToken;
} while (continuationToken);
const cached = user.storage_used || 0;
const diff = totalSize - cached;
const consistent = Math.abs(diff) === 0;
console.log(`[存储缓存] 用户 ${userId} 完整性检查: 缓存=${cached}, 实际=${totalSize}, 差异=${diff}`);
return {
consistent,
cached,
actual: totalSize,
fileCount,
diff
};
} catch (error) {
console.error('[存储缓存] 完整性检查失败:', error);
throw error;
}
}
/**
* 重建缓存(第二轮修复:缓存一致性保障)
* 强制从 OSS 全量统计并更新缓存值,绕过一致性检查
* @param {number} userId - 用户ID
* @param {OssStorageClient} ossClient - OSS 客户端实例
* @returns {Promise<{previous: number, current: number, fileCount: number}>}
*/
static async rebuildCache(userId, ossClient) {
try {
const { ListObjectsV2Command } = require('@aws-sdk/client-s3');
const user = UserDB.findById(userId);
if (!user) {
throw new Error('用户不存在');
}
console.log(`[存储缓存] 开始重建用户 ${userId} 的缓存...`);
// 执行全量统计
let totalSize = 0;
let fileCount = 0;
let continuationToken = null;
do {
const command = new ListObjectsV2Command({
Bucket: ossClient.getBucket(), // 使用ossClient的getBucket()方法以支持系统级统一OSS配置
Prefix: `user_${userId}/`,
ContinuationToken: continuationToken
});
const response = await ossClient.s3Client.send(command);
if (response.Contents) {
for (const obj of response.Contents) {
totalSize += obj.Size || 0;
fileCount++;
}
}
continuationToken = response.NextContinuationToken;
} while (continuationToken);
const previous = user.storage_used || 0;
// 强制更新缓存
await this.resetUsage(userId, totalSize);
console.log(`[存储缓存] 用户 ${userId} 缓存重建完成: ${previous}${totalSize} (${fileCount} 个文件)`);
return {
previous,
current: totalSize,
fileCount
};
} catch (error) {
console.error('[存储缓存] 重建缓存失败:', error);
throw error;
}
}
/**
* 检查所有用户的缓存一致性(第二轮修复:批量检查)
* @param {Array} users - 用户列表
* @param {Function} getOssClient - 获取 OSS 客户端的函数
* @returns {Promise<Array>} 检查结果列表
*/
static async checkAllUsersIntegrity(users, getOssClient) {
const results = [];
for (const user of users) {
// 跳过没有配置 OSS 的用户(需要检查系统级统一配置)
const { SettingsDB } = require('../database');
const hasUnifiedConfig = SettingsDB.hasUnifiedOssConfig();
if (!user.has_oss_config && !hasUnifiedConfig) {
continue;
}
try {
const ossClient = getOssClient(user);
const checkResult = await this.checkIntegrity(user.id, ossClient);
results.push({
userId: user.id,
username: user.username,
...checkResult
});
} catch (error) {
console.error(`[存储缓存] 检查用户 ${user.id} 失败:`, error.message);
results.push({
userId: user.id,
username: user.username,
error: error.message
});
}
}
return results;
}
/**
* 自动检测并修复缓存不一致(第二轮修复:自动化保障)
* 当检测到不一致时自动修复,并记录日志
* @param {number} userId - 用户ID
* @param {OssStorageClient} ossClient - OSS 客户端实例
* @param {number} threshold - 差异阈值(字节),默认 0任何差异都修复
* @returns {Promise<{autoFixed: boolean, diff: number}>}
*/
static async autoDetectAndFix(userId, ossClient, threshold = 0) {
try {
const checkResult = await this.checkIntegrity(userId, ossClient);
if (!checkResult.consistent && Math.abs(checkResult.diff) > threshold) {
console.warn(`[存储缓存] 检测到用户 ${userId} 缓存不一致: 差异 ${checkResult.diff} 字节`);
// 自动修复
await this.rebuildCache(userId, ossClient);
return {
autoFixed: true,
diff: checkResult.diff
};
}
return {
autoFixed: false,
diff: checkResult.diff
};
} catch (error) {
console.error('[存储缓存] 自动检测修复失败:', error);
throw error;
}
}
}
module.exports = StorageUsageCache;