补充安全码六十秒隐藏行为回归与审查记录
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-10-06 07:39:36 +08:00
parent 7e007ea891
commit b149a0de08
2 changed files with 240 additions and 3 deletions

View File

@ -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 秒实测。
本轮本地测试仅为隔离源码回归,未清理或使用线上数据库,未通过内部业务接口替代页面验收。凭据和安全码明文不记录到验收文档。

View File

@ -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)
})