diff --git a/docs/本轮上线审查清单.md b/docs/本轮上线审查清单.md index 29b4882..bdf8534 100644 --- a/docs/本轮上线审查清单.md +++ b/docs/本轮上线审查清单.md @@ -9,9 +9,9 @@ - [✓] 核对工作区及分支,保留原有未跟踪产物。 - [✓] 修改前提交中文检查点,推送认证失败后重试成功。 - [✓] 使用密码及严格主机密钥校验连接目标服务器。 -- [~] 修复旧回归测试与当前权限、协议密码验证、分组表结构不一致的问题,不放宽生产授权。 -- [ ] 执行后端全量回归,核对前端与部署回归。 -- [ ] 核对运行服务、监听、部署摘要及数据库角色认证。 +- [✓] 修复旧回归测试与当前权限、协议密码验证、分组表结构不一致的问题,不放宽生产授权。 +- [x] 后端串行全量回归及前端回归通过,部署本地回归通过;Linux 专属用例仍待原生执行。 +- [!] 已核对运行服务、监听、部署摘要;数据库角色使用指定密码认证失败,需全新部署时同步。 - [ ] 根据构建结果进行 Linux 全新部署;清理限于已确认属于 Smail 的旧数据和备份。 - [ ] 页面完成三角色登录、权限、分组、中继开关与配置管理回归。 - [ ] 页面实测安全码显示时限与关闭清理,不以常量检查替代计时。 @@ -25,3 +25,16 @@ 本轮复现三个后端测试失败。协议操作用例未提供现行入口要求的本人密码;插件用例将超管专属通知设置误作普通插件设置;用户列表用例缺少账号分组表,并沿用未分组前的可见范围。修复测试时保留拒绝越权、跨账号隔离与分页断言。以上为本地回归,不代表线上验收通过。 前一轮前端回归 235 项通过且生产构建成功,但尚不能据此认定服务器已运行该构建,或安全码在浏览器中完成了完整计时验收。 + +## 安全码需求与本轮回归 + +确认要求:生成或验证密码后再次查看安全码,从明文显示起计时,60 秒后清除页面中的明文;隐藏后保存和复制入口不能继续使用旧值。不撤销仍有效的安全码,用户可重新验证密码后查看。页面隐藏、离开设置或关闭管理弹窗时提前清理。 + +- [✓] 当前首次强制设置入口使用个人安全设置页面,复用同一显示计时,不把未挂载的旧引导组件当作线上入口。 +- [✓] 新增 20 项执行实际页面逻辑的模拟时钟回归,覆盖三角色、生成与查看、59.999 秒与 60 秒边界、重新计时、保存与复制、切页、卸载、晚到响应及切换用户。 +- [x] 前端共 255 项通过,记录:测试目录下本轮前端日志。 +- [x] 后端串行全量通过;此前并行运行出现过一次 Windows 原子改名访问拒绝,同用例单独连续三次通过,尚未证明该瞬态问题的根因。 +- [x] 部署测试四项通过、一项 Linux 专属用例在 Windows 跳过,不能计作 Linux 全通过。 +- [ ] 最新 Linux 构建、全新部署和浏览器完整 60 秒实测。 + +本轮本地测试仅为隔离源码回归,未清理或使用线上数据库,未通过内部业务接口替代页面验收。凭据和安全码明文不记录到验收文档。 diff --git a/test/frontend/recovery-code-timeout.test.mjs b/test/frontend/recovery-code-timeout.test.mjs new file mode 100644 index 0000000..54af2cb --- /dev/null +++ b/test/frontend/recovery-code-timeout.test.mjs @@ -0,0 +1,224 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { readFileSync } from 'node:fs' +import { compileFunction } from 'node:vm' +import { computed, reactive, ref, watch } from './vue.mjs' +import { RECOVERY_CODE_DISPLAY_MS } from '../../frontend/src/utils/recoveryCodeDisplay.js' + +// 直接执行页面逻辑并注入时钟;该回归不替代部署后的浏览器完整计时验收。 +function createClock() { + let now = 0 + let nextID = 0 + const timers = new Map() + return { + setTimeout(callback, delay) { + const id = ++nextID + timers.set(id, { callback, deadline: now + delay }) + return id + }, + clearTimeout(id) { timers.delete(id) }, + advance(milliseconds) { + now += milliseconds + for (const [id, timer] of timers) { + if (timer.deadline <= now) { + timers.delete(id) + timer.callback() + } + } + }, + get size() { return timers.size }, + } +} + +async function fixture(t, kind = 'personal', role = 'user') { + const clock = createClock() + const mounted = [] + const unmounted = [] + const stops = [] + const listeners = new Map() + const saved = [] + const copied = [] + let authentications = 0 + let post = async () => ({ errorNo: 0, data: { codes: ['TEST-ONLY-CODE'] } }) + const store = reactive({ + role, + userInfos: { account: 'fixture', securitySetupRequired: true }, + securityActiveSection: 'recovery', + hasCapability: () => false, + refresh: async () => {}, + }) + const props = reactive({ modelValue: true, user: { ID: 1, RecoveryCodeConfigured: true } }) + const document = { + hidden: false, + addEventListener(name, callback) { listeners.set(name, callback) }, + removeEventListener(name) { listeners.delete(name) }, + } + const environment = { + computed, reactive, ref, + watch(...args) { + const stop = watch(...args) + stops.push(stop) + return stop + }, + onMounted(callback) { mounted.push(callback) }, + onUnmounted(callback) { unmounted.push(callback) }, + http: { + get: async () => ({ errorNo: 0, data: { configured: true } }), + post: (...args) => post(...args), + }, + useGlobalStatusStore: () => store, + webAuthnSupported: () => false, + lang: {}, + ElNotification: () => {}, + ElMessage: { success() {} }, + ElMessageBox: { confirm: async () => {} }, + confirmRecentAuthentication: async () => { authentications += 1 }, + requestWithRecentAuthentication: action => action(), + isRecentAuthenticationCancelled: () => false, + downloadRecoveryCodes: codes => saved.push([...codes]), + RECOVERY_CODE_DISPLAY_MS, + setTimeout: clock.setTimeout, + clearTimeout: clock.clearTimeout, + clearInterval: () => {}, + document, + navigator: { clipboard: { writeText: async value => copied.push(value) } }, + props, + emit: (name, value) => { if (name === 'update:modelValue') props.modelValue = value }, + } + const relative = kind === 'personal' + ? 'SecuritySettings/SecuritySettings.js' + : 'UserManagement/RecoveryCodeDialog/RecoveryCodeDialog.js' + const source = readFileSync(new URL('../../frontend/src/views/components/' + relative, import.meta.url), 'utf8') + const code = source.replace(/^import[\s\S]*?from .*$/gm, '').replace(/^export /gm, '') + const entry = kind === 'personal' ? 'usePage()' : 'useRecoveryCodeDialog(props, emit)' + const page = compileFunction(code + '\nreturn ' + entry, Object.keys(environment))(...Object.values(environment)) + let disposed = false + const dispose = () => { + if (disposed) return + disposed = true + unmounted.forEach(callback => callback()) + stops.forEach(stop => stop()) + } + t.after(dispose) + for (const callback of mounted) await callback() + // 管理弹窗的立即侦听会异步读取状态,等待该次微任务完成。 + await new Promise(resolve => setImmediate(resolve)) + return { + page, clock, store, props, saved, copied, dispose, + codes: kind === 'personal' ? page.generatedCodes : page.codes, + generate: kind === 'personal' ? page.generateRecoveryCodes : page.generateCodes, + reveal: kind === 'personal' ? page.revealRecoveryCodes : page.revealCodes, + exportCodes: kind === 'personal' ? page.saveRecoveryCodes : page.copyCodes, + get authentications() { return authentications }, + setPost(callback) { post = callback }, + hidePage() { + document.hidden = true + listeners.get('visibilitychange')?.() + }, + } +} + +for (const role of ['super_admin', 'admin', 'user']) { + for (const action of ['generate', 'reveal']) { + test('个人安全码完整显示六十秒后清除且不能继续保存:' + role + '/' + action, async t => { + const f = await fixture(t, 'personal', role) + await f[action]() + assert.equal(f.codes.value.length, 1) + f.clock.advance(59999) + assert.equal(f.codes.value.length, 1) + await f.exportCodes() + assert.equal(f.saved.length, 1) + f.clock.advance(1) + assert.equal(f.codes.value.length, 0) + await f.exportCodes() + assert.equal(f.saved.length, 1) + assert.equal(f.clock.size, 0) + assert.equal(f.page.recoveryConfigured.value, true) + }) + } +} + +for (const action of ['generate', 'reveal']) { + test('管理员弹窗在六十秒边界清除安全码与复制来源:' + action, async t => { + const f = await fixture(t, 'managed') + await f[action]() + assert.equal(f.codes.value.length, 1) + f.clock.advance(59999) + assert.equal(f.codes.value.length, 1) + await f.exportCodes() + assert.equal(f.copied.length, 1) + f.clock.advance(1) + assert.equal(f.codes.value.length, 0) + assert.equal(f.page.revealed.value, false) + await f.exportCodes() + assert.equal(f.copied.length, 1) + assert.equal(f.page.configured.value, true) + }) +} + +for (const kind of ['personal', 'managed']) { + test('重新验证查看安全码会重启计时且旧计时不会提前清除:' + kind, async t => { + const f = await fixture(t, kind) + await f.reveal() + f.clock.advance(30000) + await f.reveal() + assert.equal(f.authentications, 2) + assert.equal(f.clock.size, 1) + f.clock.advance(30000) + assert.equal(f.codes.value.length, 1) + f.clock.advance(29999) + assert.equal(f.codes.value.length, 1) + f.clock.advance(1) + assert.equal(f.codes.value.length, 0) + }) + + for (const action of ['hidePage', 'dispose']) { + test('页面隐藏或卸载立即清理安全码和计时:' + kind + '/' + action, async t => { + const f = await fixture(t, kind) + await f.reveal() + f[action]() + assert.equal(f.codes.value.length, 0) + assert.equal(f.clock.size, 0) + await f.exportCodes() + assert.equal(f.saved.length + f.copied.length, 0) + }) + + test('页面离开后晚到的响应不能重新显示安全码:' + kind + '/' + action, async t => { + const f = await fixture(t, kind) + let finish + f.setPost(() => new Promise(resolve => { finish = resolve })) + const pending = f.reveal() + await new Promise(resolve => setImmediate(resolve)) + assert.equal(typeof finish, 'function') + f[action]() + finish({ errorNo: 0, data: { codes: ['LATE-TEST-CODE'] } }) + await pending + assert.equal(f.codes.value.length, 0) + assert.equal(f.clock.size, 0) + }) + } +} + +test('切换个人设置分区立即清除安全码', async t => { + const f = await fixture(t) + await f.reveal() + f.store.securityActiveSection = 'external-email' + await Promise.resolve() + assert.equal(f.codes.value.length, 0) + assert.equal(f.clock.size, 0) +}) + +test('管理员关闭弹窗或切换用户立即清除安全码', async t => { + const f = await fixture(t, 'managed') + await f.reveal() + f.props.user = { ID: 2, RecoveryCodeConfigured: true } + assert.equal(f.codes.value.length, 0) + assert.equal(f.clock.size, 0) + await new Promise(resolve => setImmediate(resolve)) + await f.reveal() + assert.equal(f.codes.value.length, 1) + f.page.close() + assert.equal(f.props.modelValue, false) + assert.equal(f.codes.value.length, 0) + assert.equal(f.clock.size, 0) +})