a3e8911deb
- 新增作息报时器独立模块,包含完整的日程管理、语音播报和待机功能 - 短链接正式前缀由/l/改为/to/,解决手机小屏下与I、i、1字形混淆问题 - 保留旧前缀/l/、/L/、/I/、/i/兼容已分发的旧短链 - 新增短码大小写兜底匹配,仅唯一匹配时生效避免歧义 - 新增防爆紧急替换功能,可轮换首页鉴权并替换短链接 - 新增多个调试和集成测试脚本,保障数据安全和功能回归
235 lines
12 KiB
JavaScript
235 lines
12 KiB
JavaScript
/**
|
||
* 「首页导航防爆紧急替换」页签 · 前端界面验证
|
||
*
|
||
* 目的:验证页签位置、两个按钮、滚动日志、最终 token / 短链接展示都正常工作。
|
||
*
|
||
* ⚠️ 本脚本不真实触发换密钥:对 /api/token_lab/nav/emergency/* 做请求拦截,
|
||
* 返回预置的假响应,只验证前端渲染与交互,绝不改动生产的密钥与短链库。
|
||
*
|
||
* 运行:node dev_test_scripts/debug/debug_token_lab_emergency_ui.js
|
||
*/
|
||
const fs = require('fs')
|
||
const puppeteer = require('puppeteer')
|
||
|
||
const BASE = process.env.TL_BASE || 'http://127.0.0.1:8976'
|
||
|
||
const CHROME_CANDIDATES = [
|
||
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
|
||
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
|
||
'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe',
|
||
'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe',
|
||
]
|
||
const findChrome = () => CHROME_CANDIDATES.find(p => fs.existsSync(p)) || undefined
|
||
const sleep = ms => new Promise(r => setTimeout(r, ms))
|
||
|
||
let passed = 0
|
||
let failed = 0
|
||
const check = (name, ok, detail) => {
|
||
if (ok) { passed += 1; console.log(` ✔ ${name}`) }
|
||
else { failed += 1; console.log(` ✘ ${name}${detail ? ' → ' + detail : ''}`) }
|
||
}
|
||
|
||
const FAKE_TOKEN = 'eyJmYWtlLXRva2VuLWZvci11aS10ZXN0.' + 'A'.repeat(120) + '.signature'
|
||
const FAKE_SHORT = 'https://maise.pro/to/UiTest1'
|
||
const ROTATE_LOGS = [
|
||
'【第 1 步】轮换导航密钥 + 签发新 token',
|
||
'目标有效期:30 天',
|
||
'已备份原有文件 → config/_nav-key-backup-2026-09-30T16-00-00/(nav-private.jwk.json、nav.jwk.json、short_link.db)',
|
||
'已生成新的 RSA-2048 密钥对(公钥指纹 abcdef1234567890)',
|
||
'已写入 config/nav-private.jwk.json(含私钥,签发用)',
|
||
'已写入 config/nav.jwk.json(仅公钥,信任池用)',
|
||
'两处已换成同一把新钥匙 → 旧公钥彻底离开信任池,旧 token 自此刻起失效',
|
||
'已用新私钥签发 token(iss=TRAE-NAV,jti=fake-jti)',
|
||
'自校验:签名=通过|有效期=正常|iss=通过',
|
||
'完成 ✅ 新 token 过期时间:2026/10/30 16:00:00(北京时间)',
|
||
]
|
||
const RELINK_LOGS = [
|
||
'【第 2 步】更换首页短链接',
|
||
'正在操作短链接数据库 data/short_link.db',
|
||
'token 校验通过(jti=fake-jti,过期 2026/10/30 16:00:00)',
|
||
'已停用旧短链 /to/PSq6xv → 立即失效',
|
||
'已创建新短链:https://maise.pro/to/UiTest1',
|
||
'自校验:新短链返回 302,跳转目标与新 token 一致 ✅',
|
||
'完成 ✅ 最终短链接:https://maise.pro/to/UiTest1',
|
||
]
|
||
|
||
;(async () => {
|
||
const browser = await puppeteer.launch({
|
||
headless: true, executablePath: findChrome(),
|
||
args: ['--no-sandbox', '--disable-setuid-sandbox'],
|
||
})
|
||
const page = await browser.newPage()
|
||
await page.setViewport({ width: 1280, height: 1000 })
|
||
await page.setCookie(
|
||
{ name: 'nav_gate', value: '1', domain: '127.0.0.1', path: '/' },
|
||
{ name: 'token_lab_gate', value: '1', domain: '127.0.0.1', path: '/' },
|
||
)
|
||
|
||
const pageErrors = []
|
||
page.on('pageerror', e => pageErrors.push(String(e && e.message || e)))
|
||
|
||
// 拦截两个紧急接口 → 返回预置响应,绝不真实执行
|
||
await page.setRequestInterception(true)
|
||
let rotateHit = 0
|
||
let relinkHit = 0
|
||
let rotateBody = null
|
||
page.on('request', req => {
|
||
const url = req.url()
|
||
if (url.includes('/api/token_lab/nav/emergency/rotate')) {
|
||
rotateHit += 1
|
||
try { rotateBody = JSON.parse(req.postData() || '{}') } catch { rotateBody = null }
|
||
return req.respond({
|
||
status: 200, contentType: 'application/json',
|
||
body: JSON.stringify({
|
||
ok: true, token: FAKE_TOKEN, exp: Math.floor(Date.now() / 1000) + 30 * 86400,
|
||
months: (rotateBody && rotateBody.months) || 1,
|
||
days: ((rotateBody && rotateBody.months) || 1) * 30,
|
||
fingerprint: 'abcdef1234567890', backupDir: '_nav-key-backup-2026-09-30T16-00-00', logs: ROTATE_LOGS,
|
||
}),
|
||
})
|
||
}
|
||
if (url.includes('/api/token_lab/nav/emergency/relink')) {
|
||
relinkHit += 1
|
||
return req.respond({
|
||
status: 200, contentType: 'application/json',
|
||
body: JSON.stringify({
|
||
ok: true, code: 'UiTest1', shortUrl: FAKE_SHORT, disabledCodes: ['PSq6xv'], verified: true, logs: RELINK_LOGS,
|
||
}),
|
||
})
|
||
}
|
||
return req.continue()
|
||
})
|
||
|
||
console.log('=== 防爆紧急替换页签 · 前端界面验证(接口已拦截,不会真实执行) ===\n')
|
||
|
||
await page.goto(`${BASE}/tools/token_lab/index.html`, { waitUntil: 'networkidle2', timeout: 30000 })
|
||
await page.waitForFunction(() => {
|
||
const m = document.getElementById('appMain')
|
||
return m && !m.hidden
|
||
}, { timeout: 15000 })
|
||
|
||
// ---------- 1. 页签位置与文案 ----------
|
||
console.log('[1] 页签与按钮')
|
||
const tabs = await page.$$eval('.tab-btn', els => els.map(e => ({ text: e.textContent.trim(), tab: e.dataset.tab, active: e.classList.contains('active') })))
|
||
check('共有 3 个页签', tabs.length === 3, JSON.stringify(tabs.map(t => t.text)))
|
||
check('新页签排在第一位', tabs[0] && tabs[0].tab === 'emergency', JSON.stringify(tabs[0] || {}))
|
||
check('新页签名称正确', tabs[0] && tabs[0].text === '首页导航防爆紧急替换', tabs[0] && tabs[0].text)
|
||
check('原「首页导航生成器」仍在且为默认激活', tabs[1] && tabs[1].tab === 'nav' && tabs[1].active === true, JSON.stringify(tabs[1] || {}))
|
||
check('原「Tool 令牌生成器」仍在', tabs[2] && tabs[2].tab === 'tool')
|
||
|
||
const btnTexts = await page.evaluate(() => ({
|
||
b1: document.getElementById('btnEmergencyRotate') ? document.getElementById('btnEmergencyRotate').textContent.trim() : '',
|
||
b2: document.getElementById('btnEmergencyRelink') ? document.getElementById('btnEmergencyRelink').textContent.trim() : '',
|
||
}))
|
||
check('按钮 1 文案为动态月份(默认 1 个月)',
|
||
btnTexts.b1 === '自动生成向后 1 个月的鉴权,且当前鉴权立即失效', btnTexts.b1)
|
||
check('按钮 2 文案正确', btnTexts.b2 === '将新的首页鉴权token,自动生成新的短链接,且当前短链接立即失效', btnTexts.b2)
|
||
|
||
// ---------- 1b. 月份下拉 ----------
|
||
console.log('\n[1b] 有效期月份下拉(1~12,默认 1)')
|
||
const selInfo = await page.evaluate(() => {
|
||
const sel = document.getElementById('emergencyMonths')
|
||
if (!sel) return null
|
||
return {
|
||
exists: true,
|
||
value: sel.value,
|
||
options: Array.from(sel.options).map(o => o.value),
|
||
labels: Array.from(sel.options).map(o => o.textContent.trim()),
|
||
// 是否紧跟按钮 1 之后
|
||
afterButton: (() => {
|
||
const btn = document.getElementById('btnEmergencyRotate')
|
||
return !!(btn && btn.nextElementSibling === sel)
|
||
})(),
|
||
}
|
||
})
|
||
check('下拉存在', !!(selInfo && selInfo.exists))
|
||
check('默认选中 1 个月', selInfo && selInfo.value === '1', selInfo && selInfo.value)
|
||
check('共 12 个选项', !!(selInfo && selInfo.options.length === 12), selInfo && selInfo.options.length)
|
||
check('选项值为 1~12',
|
||
!!(selInfo && JSON.stringify(selInfo.options) === JSON.stringify(['1','2','3','4','5','6','7','8','9','10','11','12'])),
|
||
selInfo && JSON.stringify(selInfo.options))
|
||
check('选项文案形如「N 个月」', !!(selInfo && selInfo.labels[0] === '1 个月' && selInfo.labels[11] === '12 个月'),
|
||
selInfo && JSON.stringify(selInfo.labels))
|
||
check('下拉位于按钮 1 之后(同一行)', !!(selInfo && selInfo.afterButton))
|
||
|
||
// 切换下拉 → 按钮文案应跟着变
|
||
await page.select('#emergencyMonths', '6')
|
||
await sleep(200)
|
||
const btnAfterChange = await page.evaluate(() => document.getElementById('btnEmergencyRotate').textContent.trim())
|
||
check('切到 6 个月后按钮文案同步更新',
|
||
btnAfterChange === '自动生成向后 6 个月的鉴权,且当前鉴权立即失效', btnAfterChange)
|
||
await page.select('#emergencyMonths', '12')
|
||
await sleep(200)
|
||
const btnAfter12 = await page.evaluate(() => document.getElementById('btnEmergencyRotate').textContent.trim())
|
||
check('切到 12 个月后按钮文案同步更新',
|
||
btnAfter12 === '自动生成向后 12 个月的鉴权,且当前鉴权立即失效', btnAfter12)
|
||
|
||
// ---------- 2. 切到新页签 ----------
|
||
console.log('\n[2] 切换到新页签')
|
||
await page.evaluate(() => document.querySelector('.tab-btn[data-tab="emergency"]').click())
|
||
await sleep(250)
|
||
const panelShown = await page.evaluate(() => {
|
||
const p = document.querySelector('.tab-panel[data-panel="emergency"]')
|
||
return !!p && p.classList.contains('active') && window.getComputedStyle(p).display !== 'none'
|
||
})
|
||
check('新页签面板已显示', panelShown)
|
||
|
||
// ---------- 3. 点按钮 1 ----------
|
||
console.log('\n[3] 点击按钮 1(自动生成向后一个月的鉴权…)')
|
||
await page.click('#btnEmergencyRotate')
|
||
await sleep(2500) // 等日志逐行播放完
|
||
const after1 = await page.evaluate(() => ({
|
||
log: document.getElementById('emergencyLog').value,
|
||
token: document.getElementById('emergencyTokenOut').value,
|
||
meta: document.getElementById('emergencyMeta').innerText,
|
||
}))
|
||
check('确实调用了 rotate 接口', rotateHit === 1, `命中 ${rotateHit} 次`)
|
||
check('请求体带上了所选月份 months=12', !!(rotateBody && Number(rotateBody.months) === 12),
|
||
JSON.stringify(rotateBody))
|
||
check('日志框出现滚动内容', after1.log.length > 100, `长度 ${after1.log.length}`)
|
||
check('日志含第 1 步标题', after1.log.includes('【第 1 步】'))
|
||
check('日志含"完成"', after1.log.includes('完成 ✅'))
|
||
check('日志逐行带时间戳', /\[\d{2}:\d{2}:\d{2}\]/.test(after1.log))
|
||
check('最终 token 已展示', after1.token === FAKE_TOKEN, `长度 ${after1.token.length}`)
|
||
check('元信息展示公钥指纹', after1.meta.includes('abcdef1234567890'), after1.meta.slice(0, 80))
|
||
check('元信息展示有效期 12 个月', after1.meta.includes('12 个月'), after1.meta.slice(0, 120))
|
||
check('执行后按钮文案恢复为所选月份',
|
||
(await page.evaluate(() => document.getElementById('btnEmergencyRotate').textContent.trim())) === '自动生成向后 12 个月的鉴权,且当前鉴权立即失效')
|
||
|
||
// ---------- 4. 点按钮 2 ----------
|
||
console.log('\n[4] 点击按钮 2(将新 token 绑到新短链…)')
|
||
await page.click('#btnEmergencyRelink')
|
||
await sleep(2000)
|
||
const after2 = await page.evaluate(() => ({
|
||
log: document.getElementById('emergencyLog').value,
|
||
shortUrl: document.getElementById('emergencyShortUrlOut').value,
|
||
meta: document.getElementById('emergencyMeta').innerText,
|
||
}))
|
||
check('确实调用了 relink 接口', relinkHit === 1, `命中 ${relinkHit} 次`)
|
||
check('日志含第 2 步标题', after2.log.includes('【第 2 步】'))
|
||
check('日志含停用旧短链步骤', after2.log.includes('已停用旧短链'))
|
||
check('最终短链接已展示', after2.shortUrl === FAKE_SHORT, after2.shortUrl)
|
||
check('元信息展示已停用的旧短码', after2.meta.includes('PSq6xv'), after2.meta.slice(0, 120))
|
||
check('元信息展示自校验结果', after2.meta.includes('通过'), after2.meta.slice(0, 120))
|
||
|
||
// ---------- 5. 其它页签未受影响 ----------
|
||
console.log('\n[5] 原有功能未受影响')
|
||
for (const t of ['nav', 'tool']) {
|
||
await page.evaluate(tab => document.querySelector(`.tab-btn[data-tab="${tab}"]`).click(), t)
|
||
await sleep(150)
|
||
const ok = await page.evaluate(tab => {
|
||
const p = document.querySelector(`.tab-panel[data-panel="${tab}"]`)
|
||
return !!p && p.classList.contains('active')
|
||
}, t)
|
||
check(`原页签 ${t} 仍可切换`, ok)
|
||
}
|
||
|
||
check('页面无 JS 报错', pageErrors.length === 0, pageErrors.slice(0, 3).join(' | '))
|
||
|
||
await browser.close()
|
||
console.log(`\n=== 结果:通过 ${passed} 项,失败 ${failed} 项 ===`)
|
||
process.exit(failed === 0 ? 0 : 1)
|
||
})().catch(e => {
|
||
console.log('\n✘ 执行异常:' + String((e && e.stack) || e))
|
||
process.exit(1)
|
||
}) |