fix: harden backend stability and test production utilities
This commit is contained in:
31
backend/tests/archive-tests.js
Normal file
31
backend/tests/archive-tests.js
Normal file
@@ -0,0 +1,31 @@
|
||||
const assert = require('assert');
|
||||
const { PassThrough } = require('stream');
|
||||
const { finished } = require('stream/promises');
|
||||
const { createZipArchive } = require('../utils/archive');
|
||||
|
||||
async function run() {
|
||||
const archive = await createZipArchive({ store: true });
|
||||
const output = new PassThrough();
|
||||
const chunks = [];
|
||||
output.on('data', (chunk) => chunks.push(chunk));
|
||||
archive.pipe(output);
|
||||
archive.append(Buffer.from('wanwanyun archive smoke test'), { name: 'smoke.txt' });
|
||||
|
||||
await archive.finalize();
|
||||
await finished(output);
|
||||
|
||||
const zip = Buffer.concat(chunks);
|
||||
assert.ok(zip.length > 30, 'ZIP output should not be empty');
|
||||
assert.strictEqual(zip.subarray(0, 2).toString('ascii'), 'PK');
|
||||
assert.ok(zip.includes(Buffer.from('smoke.txt')), 'ZIP should contain the requested entry');
|
||||
|
||||
console.log('通过: 1');
|
||||
console.log('失败: 0');
|
||||
}
|
||||
|
||||
run().catch((error) => {
|
||||
console.error(error.stack || error.message);
|
||||
console.log('通过: 0');
|
||||
console.log('失败: 1');
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -12,6 +12,12 @@
|
||||
const assert = require('assert');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const {
|
||||
sanitizeInput,
|
||||
decodeHtmlEntities,
|
||||
isSafePathSegment,
|
||||
isFileExtensionSafe
|
||||
} = require('../utils/input-security');
|
||||
|
||||
// 主函数包装器(支持 async/await)
|
||||
async function runTests() {
|
||||
@@ -58,28 +64,6 @@ console.log('\n========== 1. 输入边界测试 ==========\n');
|
||||
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 = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
};
|
||||
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(''), '');
|
||||
@@ -263,17 +247,6 @@ 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);
|
||||
});
|
||||
@@ -312,32 +285,6 @@ 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);
|
||||
@@ -360,36 +307,8 @@ function testFileExtensionSecurity() {
|
||||
});
|
||||
|
||||
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);
|
||||
assert.strictEqual(isFileExtensionSafe('.htaccess'), false);
|
||||
assert.strictEqual(isFileExtensionSafe('.htpasswd'), false);
|
||||
});
|
||||
|
||||
test('正常文件应该被接受', () => {
|
||||
@@ -771,43 +690,6 @@ 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('<'), '<');
|
||||
assert.strictEqual(decodeHtmlEntities('>'), '>');
|
||||
|
||||
@@ -199,6 +199,17 @@ async function run() {
|
||||
assert.ok(jar.get('csrf_token'));
|
||||
});
|
||||
|
||||
test('malformed JSON receives the global JSON error response', async () => {
|
||||
const malformed = await request(baseUrl, new CookieJar(), 'POST', '/api/login', {
|
||||
csrf: false,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: '{"invalid":}'
|
||||
});
|
||||
assert.strictEqual(malformed.status, 400);
|
||||
assert.strictEqual(malformed.data.success, false);
|
||||
assert.strictEqual(typeof malformed.data.message, 'string');
|
||||
});
|
||||
|
||||
test('auth endpoints login with real cookies and enforce CSRF after authentication', async () => {
|
||||
const login = await request(baseUrl, jar, 'POST', '/api/login', {
|
||||
json: { username: 'admin', password: adminPassword }
|
||||
|
||||
244
backend/tests/production-utils-tests.js
Normal file
244
backend/tests/production-utils-tests.js
Normal file
@@ -0,0 +1,244 @@
|
||||
const assert = require('assert');
|
||||
const {
|
||||
parseDateTimeValue,
|
||||
formatDateTimeForSqlite,
|
||||
getDateKeyFromDate,
|
||||
getRecentDateKeys,
|
||||
getNextDownloadResetTime,
|
||||
normalizeTimeHHmm,
|
||||
isCurrentTimeInWindow
|
||||
} = require('../utils/datetime');
|
||||
const {
|
||||
MAX_DOWNLOAD_TRAFFIC_BYTES,
|
||||
normalizeDownloadTrafficQuota,
|
||||
normalizeDownloadTrafficUsed,
|
||||
getDownloadTrafficState,
|
||||
resolveDownloadTrafficPolicyUpdates
|
||||
} = require('../utils/download-quota');
|
||||
const { expressErrorHandler } = require('../middleware/error-handler');
|
||||
|
||||
const results = { passed: 0, failed: 0 };
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
results.passed += 1;
|
||||
console.log(` [PASS] ${name}`);
|
||||
} catch (error) {
|
||||
results.failed += 1;
|
||||
console.error(` [FAIL] ${name}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function createResponse(headersSent = false) {
|
||||
return {
|
||||
headersSent,
|
||||
statusCode: null,
|
||||
payload: null,
|
||||
status(code) {
|
||||
this.statusCode = code;
|
||||
return this;
|
||||
},
|
||||
json(payload) {
|
||||
this.payload = payload;
|
||||
return this;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
console.log('\n========== 生产工具模块测试 ==========\n');
|
||||
|
||||
test('SQLite 日期时间可以被解析', () => {
|
||||
const parsed = parseDateTimeValue('2026-07-27 12:34:56');
|
||||
assert.ok(parsed instanceof Date);
|
||||
assert.strictEqual(parsed.getFullYear(), 2026);
|
||||
assert.strictEqual(parsed.getMonth(), 6);
|
||||
assert.strictEqual(parsed.getDate(), 27);
|
||||
});
|
||||
|
||||
test('非法日期时间返回 null', () => {
|
||||
assert.strictEqual(parseDateTimeValue('not-a-date'), null);
|
||||
assert.strictEqual(parseDateTimeValue(null), null);
|
||||
});
|
||||
|
||||
test('日期时间按 SQLite 格式输出', () => {
|
||||
const value = new Date(2026, 6, 27, 3, 4, 5);
|
||||
assert.strictEqual(formatDateTimeForSqlite(value), '2026-07-27 03:04:05');
|
||||
});
|
||||
|
||||
test('日期键校验有效和无效日期', () => {
|
||||
assert.strictEqual(getDateKeyFromDate(new Date(2026, 0, 2)), '2026-01-02');
|
||||
assert.strictEqual(getDateKeyFromDate('invalid'), null);
|
||||
});
|
||||
|
||||
test('最近日期键按时间顺序生成', () => {
|
||||
const keys = getRecentDateKeys(3, new Date(2026, 0, 2, 12, 0, 0));
|
||||
assert.deepStrictEqual(keys, ['2025-12-31', '2026-01-01', '2026-01-02']);
|
||||
});
|
||||
|
||||
test('下载配额重置时间支持日周月周期', () => {
|
||||
assert.strictEqual(
|
||||
formatDateTimeForSqlite(getNextDownloadResetTime('2026-01-01 00:00:00', 'daily')),
|
||||
'2026-01-02 00:00:00'
|
||||
);
|
||||
assert.strictEqual(
|
||||
formatDateTimeForSqlite(getNextDownloadResetTime('2026-01-01 00:00:00', 'weekly')),
|
||||
'2026-01-08 00:00:00'
|
||||
);
|
||||
assert.strictEqual(
|
||||
formatDateTimeForSqlite(getNextDownloadResetTime('2026-01-01 00:00:00', 'monthly')),
|
||||
'2026-02-01 00:00:00'
|
||||
);
|
||||
assert.strictEqual(getNextDownloadResetTime('2026-01-01 00:00:00', 'none'), null);
|
||||
});
|
||||
|
||||
test('访问时间格式被严格规范化', () => {
|
||||
assert.strictEqual(normalizeTimeHHmm(' 09:05 '), '09:05');
|
||||
assert.strictEqual(normalizeTimeHHmm('9:05'), null);
|
||||
assert.strictEqual(normalizeTimeHHmm('24:00'), null);
|
||||
});
|
||||
|
||||
test('同日访问时间窗口正确判断', () => {
|
||||
assert.strictEqual(isCurrentTimeInWindow('09:00', '18:00', new Date(2026, 0, 1, 12, 0)), true);
|
||||
assert.strictEqual(isCurrentTimeInWindow('09:00', '18:00', new Date(2026, 0, 1, 18, 0)), false);
|
||||
});
|
||||
|
||||
test('跨日访问时间窗口正确判断', () => {
|
||||
assert.strictEqual(isCurrentTimeInWindow('22:00', '06:00', new Date(2026, 0, 1, 23, 0)), true);
|
||||
assert.strictEqual(isCurrentTimeInWindow('22:00', '06:00', new Date(2026, 0, 1, 12, 0)), false);
|
||||
});
|
||||
|
||||
test('非法下载配额被禁止而负数统一为不限', () => {
|
||||
assert.strictEqual(normalizeDownloadTrafficQuota('invalid'), 0);
|
||||
assert.strictEqual(normalizeDownloadTrafficQuota(-99), -1);
|
||||
});
|
||||
|
||||
test('下载配额被取整并限制在 10TB', () => {
|
||||
assert.strictEqual(normalizeDownloadTrafficQuota(10.9), 10);
|
||||
assert.strictEqual(normalizeDownloadTrafficQuota(MAX_DOWNLOAD_TRAFFIC_BYTES + 1), MAX_DOWNLOAD_TRAFFIC_BYTES);
|
||||
});
|
||||
|
||||
test('已用流量按有限配额封顶', () => {
|
||||
assert.strictEqual(normalizeDownloadTrafficUsed(150, 100), 100);
|
||||
assert.strictEqual(normalizeDownloadTrafficUsed(150, -1), 150);
|
||||
assert.strictEqual(normalizeDownloadTrafficUsed(-1, 100), 0);
|
||||
});
|
||||
|
||||
test('不限流量状态保留无限剩余额度', () => {
|
||||
const state = getDownloadTrafficState({ download_traffic_quota: -1, download_traffic_used: 123 });
|
||||
assert.strictEqual(state.isUnlimited, true);
|
||||
assert.strictEqual(state.used, 123);
|
||||
assert.strictEqual(state.remaining, Number.POSITIVE_INFINITY);
|
||||
});
|
||||
|
||||
test('空用户不会产生配额更新', () => {
|
||||
assert.deepStrictEqual(resolveDownloadTrafficPolicyUpdates(null), {
|
||||
updates: {},
|
||||
hasUpdates: false,
|
||||
expired: false,
|
||||
resetApplied: false
|
||||
});
|
||||
});
|
||||
|
||||
test('非法重置周期会被规范化', () => {
|
||||
const result = resolveDownloadTrafficPolicyUpdates({
|
||||
download_traffic_quota: 100,
|
||||
download_traffic_used: 0,
|
||||
download_traffic_reset_cycle: 'yearly'
|
||||
});
|
||||
assert.strictEqual(result.updates.download_traffic_reset_cycle, 'none');
|
||||
});
|
||||
|
||||
test('周期配额首次运行时记录重置时间', () => {
|
||||
const now = new Date(2026, 6, 27, 12, 0, 0);
|
||||
const result = resolveDownloadTrafficPolicyUpdates({
|
||||
download_traffic_quota: 100,
|
||||
download_traffic_used: 10,
|
||||
download_traffic_reset_cycle: 'daily',
|
||||
download_traffic_last_reset_at: null
|
||||
}, now);
|
||||
assert.strictEqual(result.updates.download_traffic_last_reset_at, '2026-07-27 12:00:00');
|
||||
});
|
||||
|
||||
test('到达周期时已用流量归零', () => {
|
||||
const result = resolveDownloadTrafficPolicyUpdates({
|
||||
download_traffic_quota: 100,
|
||||
download_traffic_used: 90,
|
||||
download_traffic_reset_cycle: 'daily',
|
||||
download_traffic_last_reset_at: '2026-07-26 12:00:00'
|
||||
}, new Date(2026, 6, 27, 12, 0, 0));
|
||||
assert.strictEqual(result.resetApplied, true);
|
||||
assert.strictEqual(result.updates.download_traffic_used, 0);
|
||||
});
|
||||
|
||||
test('限时配额到期后恢复为不限流量', () => {
|
||||
const result = resolveDownloadTrafficPolicyUpdates({
|
||||
download_traffic_quota: 100,
|
||||
download_traffic_used: 90,
|
||||
download_traffic_quota_expires_at: '2026-07-26 12:00:00',
|
||||
download_traffic_reset_cycle: 'daily',
|
||||
download_traffic_last_reset_at: '2026-07-26 12:00:00'
|
||||
}, new Date(2026, 6, 27, 12, 0, 0));
|
||||
assert.strictEqual(result.expired, true);
|
||||
assert.strictEqual(result.updates.download_traffic_quota, -1);
|
||||
assert.strictEqual(result.updates.download_traffic_used, 0);
|
||||
assert.strictEqual(result.updates.download_traffic_quota_expires_at, null);
|
||||
});
|
||||
|
||||
test('Express 错误处理器保留四参数签名', () => {
|
||||
assert.strictEqual(expressErrorHandler.length, 4);
|
||||
});
|
||||
|
||||
test('Express 错误处理器返回客户端错误 JSON', () => {
|
||||
const response = createResponse();
|
||||
const originalConsoleError = console.error;
|
||||
console.error = () => {};
|
||||
try {
|
||||
expressErrorHandler(
|
||||
Object.assign(new Error('请求 JSON 无效'), { status: 400 }),
|
||||
{ method: 'POST', originalUrl: '/api/login' },
|
||||
response,
|
||||
() => assert.fail('不应调用 next')
|
||||
);
|
||||
} finally {
|
||||
console.error = originalConsoleError;
|
||||
}
|
||||
assert.strictEqual(response.statusCode, 400);
|
||||
assert.deepStrictEqual(response.payload, { success: false, message: '请求 JSON 无效' });
|
||||
});
|
||||
|
||||
test('Express 错误处理器隐藏服务端异常详情', () => {
|
||||
const response = createResponse();
|
||||
const originalConsoleError = console.error;
|
||||
console.error = () => {};
|
||||
try {
|
||||
expressErrorHandler(
|
||||
new Error('sensitive detail'),
|
||||
{ method: 'GET', originalUrl: '/api/example' },
|
||||
response,
|
||||
() => assert.fail('不应调用 next')
|
||||
);
|
||||
} finally {
|
||||
console.error = originalConsoleError;
|
||||
}
|
||||
assert.strictEqual(response.statusCode, 500);
|
||||
assert.deepStrictEqual(response.payload, { success: false, message: '服务器内部错误' });
|
||||
});
|
||||
|
||||
test('响应头已发送时错误处理器继续交给 Express', () => {
|
||||
const response = createResponse(true);
|
||||
const error = new Error('stream failed');
|
||||
let forwarded = null;
|
||||
expressErrorHandler(error, {}, response, (received) => {
|
||||
forwarded = received;
|
||||
});
|
||||
assert.strictEqual(forwarded, error);
|
||||
});
|
||||
|
||||
console.log('\n========================================');
|
||||
console.log('测试总结');
|
||||
console.log('========================================');
|
||||
console.log(`通过: ${results.passed}`);
|
||||
console.log(`失败: ${results.failed}`);
|
||||
|
||||
process.exit(results.failed > 0 ? 1 : 0);
|
||||
@@ -7,6 +7,8 @@ const path = require('path');
|
||||
|
||||
const testFiles = [
|
||||
'boundary-tests.js',
|
||||
'production-utils-tests.js',
|
||||
'archive-tests.js',
|
||||
'network-concurrent-tests.js',
|
||||
'state-consistency-tests.js'
|
||||
];
|
||||
@@ -42,7 +44,9 @@ function runTest(file) {
|
||||
const failMatch = output.match(/失败:\s*(\d+)/);
|
||||
|
||||
const passed = passMatch ? parseInt(passMatch[1]) : 0;
|
||||
const failed = failMatch ? parseInt(failMatch[1]) : 0;
|
||||
const parsedFailed = failMatch ? parseInt(failMatch[1]) : 0;
|
||||
// A syntax error or early crash may not print the normal summary.
|
||||
const failed = code !== 0 && parsedFailed === 0 ? 1 : parsedFailed;
|
||||
|
||||
results.files.push({
|
||||
file,
|
||||
@@ -91,7 +95,8 @@ async function runAllTests() {
|
||||
console.log(`总计: 通过 ${results.total.passed}, 失败 ${results.total.failed}`);
|
||||
console.log('');
|
||||
|
||||
if (results.total.failed > 0) {
|
||||
const hasProcessFailure = results.files.some(file => file.exitCode !== 0);
|
||||
if (results.total.failed > 0 || hasProcessFailure) {
|
||||
console.log('存在失败的测试,请检查输出以了解详情。');
|
||||
process.exit(1);
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user