This commit is contained in:
parent
7e007ea891
commit
b149a0de08
@ -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 秒实测。
|
||||
|
||||
本轮本地测试仅为隔离源码回归,未清理或使用线上数据库,未通过内部业务接口替代页面验收。凭据和安全码明文不记录到验收文档。
|
||||
|
||||
224
test/frontend/recovery-code-timeout.test.mjs
Normal file
224
test/frontend/recovery-code-timeout.test.mjs
Normal 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)
|
||||
})
|
||||
Loading…
Reference in New Issue
Block a user