Files
Toolbox/dev_test_scripts/integration/test_nav_emergency_replace.js
T
yangxiangyuan a3e8911deb feat: 新增作息报时器功能并替换短链接前缀为/to/
- 新增作息报时器独立模块,包含完整的日程管理、语音播报和待机功能
- 短链接正式前缀由/l/改为/to/,解决手机小屏下与I、i、1字形混淆问题
- 保留旧前缀/l/、/L/、/I/、/i/兼容已分发的旧短链
- 新增短码大小写兜底匹配,仅唯一匹配时生效避免歧义
- 新增防爆紧急替换功能,可轮换首页鉴权并替换短链接
- 新增多个调试和集成测试脚本,保障数据安全和功能回归
2026-10-09 14:05:36 +08:00

259 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 「首页导航防爆紧急替换」两个按钮 · 端到端验证
*
* 覆盖:/api/token_lab/nav/emergency/rotate 与 /relink 两个真实接口。
*
* ⚠️ 这两个接口会真实改动密钥文件与短链接库。为了让验证不污染生产状态,
* 本脚本会在执行前把「导航密钥文件 + 短链库里相关行」快照下来,
* 验证结束后**完整还原**,并在最后再验一次「旧 token 又能用了」以证明还原成功。
*
* 用法(需目标服务已启动):
* node dev_test_scripts/integration/test_nav_emergency_replace.js
* node dev_test_scripts/integration/test_nav_emergency_replace.js http://localhost:8976
*/
const fs = require('fs')
const path = require('path')
const Database = require('better-sqlite3')
const BASE = String(process.argv[2] || 'http://localhost:8976').replace(/\/+$/, '')
// token_lab 门禁与导航门禁都是标记型 cookie;本地验证直接带齐即可
const COOKIE = 'nav_gate=1; token_lab_gate=1'
const CONFIG_DIR = path.join(process.cwd(), 'config')
const DB_PATH = path.join(process.cwd(), 'data', 'short_link.db')
const NAV_KEY_FILES = ['nav-private.jwk.json', 'nav.jwk.json']
const SNAPSHOT_DIR = path.join(CONFIG_DIR, '_nav-emergency-test-snapshot')
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 post = async (urlPath, body) => {
const res = await fetch(BASE + urlPath, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Cookie: COOKIE },
body: JSON.stringify(body || {}),
})
const text = await res.text()
let json = null
try { json = JSON.parse(text) } catch (_) { /* 非 JSON */ }
return { status: res.status, json }
}
/** 只看一小段,避免把整枚 token 打进日志 */
const head = (s, n = 40) => String(s || '').slice(0, n)
const probeToken = async token => {
const res = await fetch(`${BASE}/?token=${encodeURIComponent(token)}`, { redirect: 'manual' })
return res.status
}
const probeShort = async code => {
const res = await fetch(`${BASE}/to/${code}`, { redirect: 'manual' })
return { status: res.status, location: res.headers.get('location') || '' }
}
const currentHomepageCode = () => {
const db = new Database(DB_PATH, { readonly: true })
const row = db.prepare('SELECT code, target_url FROM short_links WHERE is_active = 1').all()
.find(r => {
try {
const u = new URL(String(r.target_url))
return (u.pathname === '/' || u.pathname === '') && u.searchParams.has('token')
} catch { return false }
})
db.close()
if (!row) throw new Error('当前没有启用中的首页短链,无法作为基线')
const m = /[?&]token=([^&\s]+)/.exec(String(row.target_url))
return { code: row.code, targetUrl: row.target_url, token: m ? decodeURIComponent(m[1]) : '' }
}
const snapshot = () => {
fs.mkdirSync(SNAPSHOT_DIR, { recursive: true })
NAV_KEY_FILES.forEach(name => {
const src = path.join(CONFIG_DIR, name)
if (fs.existsSync(src)) fs.copyFileSync(src, path.join(SNAPSHOT_DIR, name))
})
// 短链库用逻辑快照(整表导出 JSON),比复制文件更安全(服务端持有连接)
const db = new Database(DB_PATH, { readonly: true })
const links = db.prepare('SELECT * FROM short_links').all()
const clicks = db.prepare('SELECT * FROM short_link_clicks').all()
db.close()
fs.writeFileSync(path.join(SNAPSHOT_DIR, 'links.json'), JSON.stringify({ links, clicks }))
}
/** 记录测试开始前已存在的备份目录,便于事后只清理本次新增的 */
const listBackupDirs = () => {
try {
return new Set(fs.readdirSync(CONFIG_DIR)
.filter(n => n.startsWith('_nav-key-backup-'))
.map(n => n))
} catch { return new Set() }
}
const cleanupNewBackupDirs = beforeSet => {
const removed = []
try {
fs.readdirSync(CONFIG_DIR)
.filter(n => n.startsWith('_nav-key-backup-') && !beforeSet.has(n))
.forEach(n => {
try { fs.rmSync(path.join(CONFIG_DIR, n), { recursive: true, force: true }); removed.push(n) } catch (_) { /* 静默 */ }
})
} catch (_) { /* 静默 */ }
return removed
}
const restore = () => {
NAV_KEY_FILES.forEach(name => {
const src = path.join(SNAPSHOT_DIR, name)
if (fs.existsSync(src)) fs.copyFileSync(src, path.join(CONFIG_DIR, name))
})
const snap = JSON.parse(fs.readFileSync(path.join(SNAPSHOT_DIR, 'links.json'), 'utf-8'))
const db = new Database(DB_PATH)
db.exec('DELETE FROM short_link_clicks; DELETE FROM short_links;')
const insLink = db.prepare(`INSERT INTO short_links
(id, code, target_url, title, source_type, source_ref, created_at, expires_at, max_clicks, click_count, is_active, created_by)
VALUES (@id, @code, @target_url, @title, @source_type, @source_ref, @created_at, @expires_at, @max_clicks, @click_count, @is_active, @created_by)`)
snap.links.forEach(r => insLink.run({
id: r.id, code: r.code, target_url: r.target_url, title: r.title || '',
source_type: r.source_type || 'manual', source_ref: r.source_ref || '',
created_at: r.created_at, expires_at: r.expires_at, max_clicks: r.max_clicks,
click_count: r.click_count || 0, is_active: r.is_active, created_by: r.created_by || 'admin',
}))
const insClick = db.prepare(`INSERT INTO short_link_clicks (id, code, clicked_at, ip, user_agent, referer)
VALUES (@id, @code, @clicked_at, @ip, @user_agent, @referer)`)
snap.clicks.forEach(c => insClick.run({
id: c.id, code: c.code, clicked_at: c.clicked_at,
ip: c.ip, user_agent: c.user_agent, referer: c.referer,
}))
db.close()
}
;(async () => {
console.log('=== 首页导航防爆紧急替换 · 端到端验证 ===')
console.log(`目标:${BASE}\n`)
const before = currentHomepageCode()
console.log(`[基线] 当前首页短链:/to/${before.code},其 token 现存 ${before.token.length} 字符`)
check('轮换前旧 token 可用(基线)', (await probeToken(before.token)) === 302)
snapshot()
const backupDirsBefore = listBackupDirs()
console.log(' 已快照导航密钥与短链库(用于验证后还原)\n')
let newToken = ''
let newCode = ''
try {
// ---------- 1. 未授权必须被拦 ----------
console.log('[1] 未授权访问必须被拦(不能被匿名触发换密钥)')
const noAuth = await fetch(`${BASE}/api/token_lab/nav/emergency/rotate`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}',
})
check('无 cookie 调用 rotate → 401', noAuth.status === 401, `实际 ${noAuth.status}`)
// ---------- 2. 第 1 步:换密钥 + 签新 token ----------
console.log('\n[2] 第 1 步:自动生成向后 X 个月的鉴权,且当前鉴权立即失效')
const r1 = await post('/api/token_lab/nav/emergency/rotate', { months: 3 })
check('接口返回 200', r1.status === 200, `实际 ${r1.status} ${head(JSON.stringify(r1.json))}`)
check('返回 ok=true', !!(r1.json && r1.json.ok === true))
newToken = String((r1.json && r1.json.token) || '')
check('返回了新 token', newToken.length > 100, `长度 ${newToken.length}`)
check('回显 months=3', Number(r1.json && r1.json.months) === 3, r1.json && r1.json.months)
check('回显 days=90(3 个月 ×30 天)', Number(r1.json && r1.json.days) === 90, r1.json && r1.json.days)
check('logs 写明「3 个月」', JSON.stringify(r1.json.logs).includes('3 个月'))
check('执行过程 logs 非空', Array.isArray(r1.json.logs) && r1.json.logs.length >= 5, `条数 ${r1.json && r1.json.logs && r1.json.logs.length}`)
check('logs 含"备份"字样', JSON.stringify(r1.json.logs).includes('备份'))
check('logs 含"完成"字样', JSON.stringify(r1.json.logs).includes('完成'))
check('返回公钥指纹', !!(r1.json && r1.json.fingerprint), r1.json && r1.json.fingerprint)
check('返回备份目录名', !!(r1.json && r1.json.backupDir), r1.json && r1.json.backupDir)
// 生效期应约为 90 天(允许 1 分钟误差)
const expDeltaDays = (Number(r1.json.exp) - Math.floor(Date.now() / 1000)) / 86400
check('token 有效期约 90 天', Math.abs(expDeltaDays - 90) < 0.01, `实际 ${expDeltaDays.toFixed(4)} 天`)
check('旧 token 立即失效(401)', (await probeToken(before.token)) === 401)
check('新 token 可用(302)', (await probeToken(newToken)) === 302)
// ---------- 3. 第 2 步:换短链接 ----------
console.log('\n[3] 第 2 步:将新 token 绑到新短链,且当前短链立即失效')
const r2 = await post('/api/token_lab/nav/emergency/relink', { token: newToken })
check('接口返回 200', r2.status === 200, `实际 ${r2.status} ${head(JSON.stringify(r2.json))}`)
check('返回 ok=true', !!(r2.json && r2.json.ok === true))
newCode = String((r2.json && r2.json.code) || '')
check('返回新短码', !!newCode, newCode)
check('返回新短链接', !!(r2.json && r2.json.shortUrl), r2.json && r2.json.shortUrl)
check('新短链接形如 <域名>/to/<短码>', /\/to\/[A-Za-z0-9]+$/.test(String(r2.json && r2.json.shortUrl)))
check('已停用旧短码清单包含原短码',
Array.isArray(r2.json.disabledCodes) && r2.json.disabledCodes.includes(before.code),
JSON.stringify(r2.json && r2.json.disabledCodes))
check('跳转自校验通过', !!(r2.json && r2.json.verified === true))
check('logs 非空', Array.isArray(r2.json.logs) && r2.json.logs.length >= 4)
// ---------- 4. 短链行为验证 ----------
console.log('\n[4] 短链行为验证')
const oldShort = await probeShort(before.code)
check('旧短链已失效(404)', oldShort.status === 404, `实际 ${oldShort.status}`)
const newShort = await probeShort(newCode)
check('新短链可用(302)', newShort.status === 302, `实际 ${newShort.status}`)
check('新短链跳转目标包含新 token', newShort.location.includes(newToken.slice(0, 24)), head(newShort.location, 60))
// ---------- 5. 只影响首页,不影响其它 ----------
console.log('\n[5] 影响面:只动首页 token / 首页短链')
const go = await fetch(`${BASE}/go?systemId=Tools-wall-`, { redirect: 'manual' })
check('/go 签发工具入口仍正常(302)', go.status === 302, `实际 ${go.status}`)
const goLoc = go.headers.get('location') || ''
const wallProbe = await fetch(`${BASE}${goLoc.startsWith('http') ? '' : ''}${goLoc}`, { redirect: 'manual' })
check('工具入口 token 仍被工具接受(302)', wallProbe.status === 302, `实际 ${wallProbe.status}`)
const db = new Database(DB_PATH, { readonly: true })
const aliases = db.prepare("SELECT COUNT(1) AS c FROM short_links WHERE target_url LIKE '/tools/%'").get().c
db.close()
check('工具别名短链未被波及', aliases >= 0) // 别名走配置,不在库里;此项仅作记录
// ---------- 6. 第 2 步负向:坏 token 必须被拒 ----------
console.log('\n[6] 第 2 步负向:坏 token 不得绑进短链')
const bad = await post('/api/token_lab/nav/emergency/relink', { token: 'not_a_valid_jwt' })
check('无效 token → 400', bad.status === 400, `实际 ${bad.status}`)
check('无效 token 返回 invalid_token', String(bad.json && bad.json.error) === 'invalid_token', bad.json && bad.json.error)
// ---------- 6b. 月份入参边界 ----------
console.log('\n[6b] 月份入参边界(合法性夹取)')
const over = await post('/api/token_lab/nav/emergency/rotate', { months: 99 })
check('months=99 → 夹到 12(不报错)', over.status === 200 && Number(over.json && over.json.months) === 12,
`status=${over.status} months=${over.json && over.json.months}`)
const legacy = await post('/api/token_lab/nav/emergency/rotate', { days: 45 })
check('仅传 days=45(旧调用)→ 仍可用,days 回显 45',
legacy.status === 200 && Number(legacy.json && legacy.json.days) === 45,
`status=${legacy.status} days=${legacy.json && legacy.json.days}`)
check('旧调用口径下不返回 months', !(legacy.json && legacy.json.months), JSON.stringify(legacy.json && legacy.json.months))
} finally {
// ---------- 7. 还原 ----------
console.log('\n[7] 还原快照(把密钥与短链库恢复为验证前的状态)')
restore()
NAV_KEY_FILES.forEach(name => {
const ok = fs.existsSync(path.join(CONFIG_DIR, name))
check(`${name} 已还原`, ok)
})
const after = currentHomepageCode()
check(`首页短链恢复为原短码 /to/${before.code}`, after.code === before.code, `实际 ${after.code}`)
check('还原后旧 token 重新可用(302)', (await probeToken(before.token)) === 302)
if (newCode) {
const stale = await probeShort(newCode)
check('验证期间创建的临时短链已不存在(404)', stale.status === 404, `实际 ${stale.status}`)
}
// 接口每次执行都会真实创建备份目录;只清理本次测试新增的,保留用户原有的备份
const removedBackups = cleanupNewBackupDirs(backupDirsBefore)
console.log(` 已清理本次测试新增的备份目录 ${removedBackups.length} 个(保留原有 ${backupDirsBefore.size} 个)`)
try { fs.rmSync(SNAPSHOT_DIR, { recursive: true, force: true }) } catch (_) { /* 静默 */ }
}
console.log(`\n=== 结果:通过 ${passed} 项,失败 ${failed} 项 ===`)
process.exit(failed === 0 ? 0 : 1)
})().catch(e => {
console.log('\n✘ 执行异常:' + String((e && e.stack) || e))
try { restore() } catch (_) { /* 尽力还原 */ }
console.log('已尝试还原快照,请检查 config/ 与短链库。')
process.exit(1)
})