This commit is contained in:
2026-07-20 22:01:44 +08:00 Unverified
parent 76a1b66f53
commit 1287544c41
17 changed files with 777 additions and 21 deletions
+45 -3
View File
@@ -8,9 +8,11 @@ import { createDatabase, relationalTables } from '../database.mjs';
import { createBaseDatabase } from '../src/data/base.mjs';
import { createSeedDatabase } from '../src/data/seed.mjs';
import { mysqlSchema } from '../src/database/schema.mjs';
import { totpAtStep } from '../src/security/totp.mjs';
import { buildCenterMaterialsWorkbook, buildWorkbook } from '../excel.mjs';
const root = resolve(process.cwd());
assert.equal(totpAtStep('GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ', 1), '287082', 'TOTP 实现应符合 RFC 6238 SHA-1 测试向量的 6 位结果');
const port = 4182;
const baseUrl = `http://127.0.0.1:${port}`;
const testDb = resolve(root, 'data', 'test-db.sqlite');
@@ -37,7 +39,7 @@ assert.doesNotMatch(mysqlAdapterSource, /ADD\s+COLUMN\s+IF\s+NOT\s+EXISTS/i, 'My
assert.match(mysqlAdapterSource, /for \(const statement of mysqlSchema\) await pool\.query\(statement\)/, 'MySQL DDL 应使用文本协议执行');
assert.match(mysqlAdapterSource, /existingResultLockTriggers\.has\(name\)\) await pool\.query\(statement\)/, 'MySQL 触发器不得通过预处理协议创建');
assert.doesNotMatch(mysqlAdapterSource, /\.execute\(\s*['"`]\s*(?:CREATE|ALTER|DROP|SHOW)\b/i, 'MySQL DDL 和 SHOW 语句不得通过预处理协议执行');
assert.match(mysqlAdapterSource, /existingAppTables\.length && \(!hasSchemaMetadata \|\| !\[15, 16\]\.includes\(existingSchemaVersion\)\)/, 'MySQL 应保留可迁移的 v15 结构并重建更旧或未完成的开发结构');
assert.match(mysqlAdapterSource, /existingAppTables\.length && \(!hasSchemaMetadata \|\| !\[15, 16, 17\]\.includes\(existingSchemaVersion\)\)/, 'MySQL 应保留可迁移的 v15-v17 结构并重建更旧或未完成的开发结构');
assert.match(mysqlAdapterSource, /\[\.\.\.mysqlTableNames\]\.reverse\(\)/, 'MySQL 半成品表应按外键依赖逆序清理');
const serverSource = await readFile(resolve(root, 'server.mjs'), 'utf8');
assert.doesNotMatch(serverSource, /src\/data\/seed\.mjs|createSeedDatabase/, '服务启动不得引用测试数据生成器');
@@ -89,7 +91,7 @@ await seededTestDatabase.close();
const server = spawn(process.execPath, ['server.mjs'], {
cwd: root,
env: { ...process.env, NODE_ENV: 'test', DATABASE_CLIENT: 'sqlite', REDIS_URL: '', PORT: String(port), SQLITE_PATH: testDb, PUBLIC_SITE_NAME: '环境变量考试中心', PUBLIC_SITE_CODE: 'ENV-TEST', PUBLIC_SITE_PHONE: '0518-1234 5678', PUBLIC_SITE_ADDRESS: '测试地址 1 号', PUBLIC_SITE_EMAIL: 'service@example.test', PUBLIC_SITE_HERO_TITLE: '一次配置,', PUBLIC_SITE_HERO_HIGHLIGHT: '统一首页文案。', PUBLIC_SITE_FOOTER_NOTICE: '环境变量页脚提示' },
env: { ...process.env, NODE_ENV: 'test', DATABASE_CLIENT: 'sqlite', REDIS_URL: '', TOTP_ENCRYPTION_KEY: 'test-only-totp-encryption-key-32-characters', PORT: String(port), SQLITE_PATH: testDb, PUBLIC_SITE_NAME: '环境变量考试中心', PUBLIC_SITE_CODE: 'ENV-TEST', PUBLIC_SITE_PHONE: '0518-1234 5678', PUBLIC_SITE_ADDRESS: '测试地址 1 号', PUBLIC_SITE_EMAIL: 'service@example.test', PUBLIC_SITE_HERO_TITLE: '一次配置,', PUBLIC_SITE_HERO_HIGHLIGHT: '统一首页文案。', PUBLIC_SITE_FOOTER_NOTICE: '环境变量页脚提示' },
stdio: ['ignore', 'pipe', 'pipe']
});
@@ -219,7 +221,7 @@ try {
inspector.close();
assert.ok(relationalTables.every(table => tableNames.includes(table)), '所有关系模型总表与分表登记表都必须存在');
assert.ok(!tableNames.includes('app_state'), '不得使用单表 JSON 状态存储');
assert.equal(schemaVersion, 16, '考试与学校物理分表应使用 v16 数据结构');
assert.equal(schemaVersion, 17, 'TOTP 账户安全应使用 v17 数据结构');
assert.ok(examPartitions.length > 0, '每场考试都应登记一组专属物理表');
assert.equal(examPartitions.length, seededExamCount, '考试分表登记不得缺漏');
assert.ok(examPartitionCoverage.every(item => item.candidates === item.expectedCandidates && item.admissions === item.expectedAdmissions && item.results === item.expectedResults), '考试专属表应与该场考试的考生、准考信息和成绩数据一致');
@@ -229,6 +231,7 @@ try {
assert.ok(['archived_at', 'archived_by'].every(column => examColumns.includes(column)), '考试应保存不可逆归档时间和超级管理员');
assert.equal(resultLockTriggers.length, 3, '数据库应从插入、更新、删除三个方向永久锁定归档成绩');
assert.ok(['archived_at', 'archived_by'].every(column => userColumns.includes(column)), '账户应保存独立归档状态和校方操作人');
assert.ok(['totp_enabled', 'totp_secret_encrypted', 'totp_recovery_codes', 'totp_last_used_step'].every(column => userColumns.includes(column)), '账户应保存加密 TOTP 状态、恢复码哈希和防重放时间片');
assert.ok(['payment_status', 'paid_at', 'paid_by'].every(column => registrationColumns.includes(column)), '报名应保存缴费状态、确认时间和班级负责人');
assert.ok(seededSchoolCount >= 4, '独立测试数据应覆盖至少四所学校');
assert.ok(seededCandidateCount >= 360, '独立测试数据应包含数百名考生');
@@ -264,6 +267,45 @@ try {
const loginAdmin = await admin.request('/api/auth/login', { method: 'POST', body: { username: 'admin', password: '12345678' } });
assert.equal(loginAdmin.data.user.role, 'admin');
assert.equal(loginAdmin.data.user.adminLevel, 'super', '默认管理员应为超级管理员');
assert.equal((await admin.request('/api/auth/totp')).data.enabled, false, '账号应默认关闭 TOTP');
const totpSetup = await admin.request('/api/auth/totp/setup', { method: 'POST', body: { currentPassword: '12345678' } });
assert.equal(totpSetup.response.status, 200, '当前密码验证通过后应可开始绑定 TOTP');
assert.match(totpSetup.data.qrCode, /^data:image\/png;base64,/, 'TOTP 二维码应在服务端本地生成为 PNG data URL');
assert.match(totpSetup.data.uri, /^otpauth:\/\/totp\//, '绑定响应应提供标准 otpauth URI');
const setupStep = Math.floor(Date.now() / 1000 / 30);
const totpEnable = await admin.request('/api/auth/totp/enable', { method: 'POST', body: { code: totpAtStep(totpSetup.data.secret, setupStep) } });
assert.equal(totpEnable.response.status, 200, '正确动态验证码应完成 TOTP 绑定');
assert.equal(totpEnable.data.recoveryCodes.length, 8, '启用后应一次性签发 8 个恢复码');
assert.equal(totpEnable.data.user.totpEnabled, true, '安全用户信息应公开 TOTP 开启状态但不公开密钥');
assert.equal(totpEnable.data.user.totpSecretEncrypted, undefined, 'API 不得返回加密后的 TOTP 密钥');
const originalRecoveryCodes = totpEnable.data.recoveryCodes;
await admin.request('/api/auth/logout', { method: 'POST' });
const totpPasswordLogin = await admin.request('/api/auth/login', { method: 'POST', body: { username: 'admin', password: '12345678' } });
assert.equal(totpPasswordLogin.data.requiresTotp, true, '启用后密码验证不得直接创建登录会话');
assert.equal((await admin.request('/api/auth/login/totp', { method: 'POST', body: { challenge: totpPasswordLogin.data.challenge, code: '000000' } })).response.status, 401, '错误动态验证码应被拒绝');
const loginStep = Math.floor(Date.now() / 1000 / 30) + 1;
const totpLogin = await admin.request('/api/auth/login/totp', { method: 'POST', body: { challenge: totpPasswordLogin.data.challenge, code: totpAtStep(totpSetup.data.secret, loginStep) } });
assert.equal(totpLogin.response.status, 200, '验证器动态验证码应完成第二步登录');
await admin.request('/api/auth/logout', { method: 'POST' });
const recoveryPasswordLogin = await admin.request('/api/auth/login', { method: 'POST', body: { username: 'admin', password: '12345678' } });
const recoveryLogin = await admin.request('/api/auth/login/totp', { method: 'POST', body: { challenge: recoveryPasswordLogin.data.challenge, code: originalRecoveryCodes[0] } });
assert.equal(recoveryLogin.response.status, 200, '恢复码应可替代动态验证码登录');
assert.equal(recoveryLogin.data.usedRecoveryCode, true, '恢复码登录应明确提醒用户');
assert.equal((await admin.request('/api/auth/totp')).data.recoveryCodesRemaining, 7, '使用后的恢复码应立即失效并减少剩余数量');
await admin.request('/api/auth/logout', { method: 'POST' });
const reusedRecoveryPassword = await admin.request('/api/auth/login', { method: 'POST', body: { username: 'admin', password: '12345678' } });
assert.equal((await admin.request('/api/auth/login/totp', { method: 'POST', body: { challenge: reusedRecoveryPassword.data.challenge, code: originalRecoveryCodes[0] } })).response.status, 401, '恢复码不得重复使用');
assert.equal((await admin.request('/api/auth/login/totp', { method: 'POST', body: { challenge: reusedRecoveryPassword.data.challenge, code: originalRecoveryCodes[1] } })).response.status, 200, '同一挑战剩余尝试次数内应允许改用有效恢复码');
const regeneratedRecovery = await admin.request('/api/auth/totp/recovery-codes', { method: 'POST', body: { currentPassword: '12345678', code: originalRecoveryCodes[2] } });
assert.equal(regeneratedRecovery.data.recoveryCodes.length, 8, '通过二次验证后应可轮换全部恢复码');
const disableTotp = await admin.request('/api/auth/totp/disable', { method: 'POST', body: { currentPassword: '12345678', code: regeneratedRecovery.data.recoveryCodes[0] } });
assert.equal(disableTotp.response.status, 200, '当前密码和第二因素均通过后应可关闭 TOTP');
assert.equal((await admin.request('/api/auth/totp')).data.enabled, false, '关闭后应清空 TOTP 状态');
await admin.request('/api/auth/logout', { method: 'POST' });
assert.equal((await admin.request('/api/auth/login', { method: 'POST', body: { username: 'admin', password: '12345678' } })).data.user.role, 'admin', '关闭后密码登录应恢复为单步会话');
const loginLegacyAdmin = await legacyAdmin.request('/api/auth/login', { method: 'POST', body: { username: 'legacy_super', password: '12345678' } });
assert.equal(loginLegacyAdmin.data.user.adminLevel, 'super', '未保存层级的旧版管理员登录后应规范化为超级管理员');
assert.equal((await schoolAdmin.request('/api/auth/login', { method: 'POST', body: { username: 'school_admin', password: '12345678' } })).data.user.adminLevel, 'school');