a3e8911deb
- 新增作息报时器独立模块,包含完整的日程管理、语音播报和待机功能 - 短链接正式前缀由/l/改为/to/,解决手机小屏下与I、i、1字形混淆问题 - 保留旧前缀/l/、/L/、/I/、/i/兼容已分发的旧短链 - 新增短码大小写兜底匹配,仅唯一匹配时生效避免歧义 - 新增防爆紧急替换功能,可轮换首页鉴权并替换短链接 - 新增多个调试和集成测试脚本,保障数据安全和功能回归
259 lines
14 KiB
JavaScript
259 lines
14 KiB
JavaScript
/**
|
||
* 「首页导航防爆紧急替换」两个按钮 · 端到端验证
|
||
*
|
||
* 覆盖:/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)
|
||
}) |