/** * 「首页导航防爆紧急替换」两个按钮 · 端到端验证 * * 覆盖:/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) })