feat: 新增作息报时器功能并替换短链接前缀为/to/
- 新增作息报时器独立模块,包含完整的日程管理、语音播报和待机功能 - 短链接正式前缀由/l/改为/to/,解决手机小屏下与I、i、1字形混淆问题 - 保留旧前缀/l/、/L/、/I/、/i/兼容已分发的旧短链 - 新增短码大小写兜底匹配,仅唯一匹配时生效避免歧义 - 新增防爆紧急替换功能,可轮换首页鉴权并替换短链接 - 新增多个调试和集成测试脚本,保障数据安全和功能回归
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* 导航鉴权绕过修复 · 未授权回归测试
|
||||
*
|
||||
* 背景(2026-09-30 发现并修复的真实漏洞):
|
||||
* express.static 注册在首页鉴权路由之前,会把 GET / 当作目录请求直接返回
|
||||
* public/index.html,导致九宫格首页完全绕过 nav_gate。
|
||||
* 同时 /api/tools、/api/nav_bookmarks/* 也完全没鉴权,可被公网匿名读取甚至篡改。
|
||||
*
|
||||
* 本脚本对该漏洞做回归:所有"未授权一律拒绝"、所有"公开路径仍公开"、
|
||||
* 以及"带 token 能正确握手种 cookie"都要成立。
|
||||
*
|
||||
* 用法(需目标服务已启动):
|
||||
* node dev_test_scripts/integration/test_nav_auth_bypass.js
|
||||
* node dev_test_scripts/integration/test_nav_auth_bypass.js http://localhost:8976
|
||||
*
|
||||
* 说明:脚本不硬编码任何 token,token 在运行时从 data/short_link.db 里读,
|
||||
* 避免把真实凭据写进仓库(项目规则 13)。
|
||||
*/
|
||||
const path = require('path')
|
||||
const Database = require('better-sqlite3')
|
||||
|
||||
const BASE = String(process.argv[2] || 'http://localhost:8976').replace(/\/+$/, '')
|
||||
|
||||
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 : ''}`) }
|
||||
}
|
||||
|
||||
/** 发起请求;cookie 传 'nav_gate=1' 表示已过门禁 */
|
||||
const req = async (method, urlPath, cookie) => {
|
||||
const headers = {}
|
||||
if (cookie) headers.Cookie = cookie
|
||||
const res = await fetch(BASE + urlPath, { method, headers, redirect: 'manual' })
|
||||
const text = await res.text().catch(() => '')
|
||||
let json = null
|
||||
try { json = JSON.parse(text) } catch (_) { /* 非 JSON */ }
|
||||
return { status: res.status, text, json, location: res.headers.get('location'), setCookie: res.headers.get('set-cookie') }
|
||||
}
|
||||
|
||||
const readTokenFromDb = () => {
|
||||
try {
|
||||
const db = new Database(path.join(process.cwd(), 'data', 'short_link.db'), { readonly: true })
|
||||
const row = db.prepare("SELECT target_url FROM short_links WHERE target_url LIKE '%token=%' ORDER BY id DESC LIMIT 1").get()
|
||||
db.close()
|
||||
if (!row) return ''
|
||||
const m = /[?&]token=([^&]+)/.exec(String(row.target_url || ''))
|
||||
return m ? decodeURIComponent(m[1]) : ''
|
||||
} catch (e) { return '' }
|
||||
}
|
||||
|
||||
;(async () => {
|
||||
console.log(`=== 导航鉴权绕过 · 未授权回归测试 ===`)
|
||||
console.log(`目标:${BASE}\n`)
|
||||
|
||||
// ---------- 1. 修复前被绕过的路径:未授权必须全部拒绝 ----------
|
||||
console.log('[1] 未授权必须拒绝(修复前这里全部是 200 泄漏)')
|
||||
const root = await req('GET', '/')
|
||||
check('GET / 未授权 → 401', root.status === 401, `实际 ${root.status},body ${root.text.slice(0, 60)}`)
|
||||
const idx = await req('GET', '/index.html')
|
||||
check('GET /index.html 未授权 → 401', idx.status === 401, `实际 ${idx.status}`)
|
||||
const toolsDir = await req('GET', '/tools/')
|
||||
check('GET /tools/ 未授权 → 401', toolsDir.status === 401, `实际 ${toolsDir.status}`)
|
||||
const apiTools = await req('GET', '/api/tools')
|
||||
check('GET /api/tools 未授权 → 401', apiTools.status === 401, `实际 ${apiTools.status},${apiTools.text.slice(0, 60)}`)
|
||||
const nbState = await req('GET', '/api/nav_bookmarks/state')
|
||||
check('GET /api/nav_bookmarks/state 未授权 → 401', nbState.status === 401, `实际 ${nbState.status}`)
|
||||
const nbCats = await req('GET', '/api/nav_bookmarks/categories')
|
||||
check('GET /api/nav_bookmarks/categories 未授权 → 401', nbCats.status === 401, `实际 ${nbCats.status}`)
|
||||
const nbBooks = await req('GET', '/api/nav_bookmarks/bookmarks')
|
||||
check('GET /api/nav_bookmarks/bookmarks 未授权 → 401', nbBooks.status === 401, `实际 ${nbBooks.status}`)
|
||||
|
||||
// 写接口必须同样被挡住(用不存在的 code 做 DELETE,即便漏挡也不会破坏真实数据)
|
||||
const nbDel = await req('DELETE', '/api/nav_bookmarks/categories/__auth_probe_not_exist__')
|
||||
check('DELETE /api/nav_bookmarks/categories/* 未授权 → 401', nbDel.status === 401, `实际 ${nbDel.status}`)
|
||||
|
||||
// ---------- 2. 公开路径必须保持公开 ----------
|
||||
console.log('\n[2] 公开路径保持公开(不能误伤)')
|
||||
const shortLink = await req('GET', '/to/__no_such_code__')
|
||||
check('GET /to/<不存在的短码> → 404(而不是 401,说明短链跳转层未被门禁挡)',
|
||||
shortLink.status === 404, `实际 ${shortLink.status},${shortLink.text.slice(0, 40)}`)
|
||||
const legacyShortLink = await req('GET', '/l/__no_such_code__')
|
||||
check('GET /l/<不存在的短码> → 404(旧前缀仍公开可用)',
|
||||
legacyShortLink.status === 404, `实际 ${legacyShortLink.status}`)
|
||||
|
||||
// ---------- 3. 带 token 的握手必须真正种下 cookie ----------
|
||||
console.log('\n[3] token 握手(修复前被 static 吞掉、cookie 永远种不下)')
|
||||
const token = readTokenFromDb()
|
||||
if (!token) {
|
||||
console.log(' ⚠ data/short_link.db 里没找到带 token 的短链接,跳过握手测试')
|
||||
} else {
|
||||
const hs = await req('GET', '/?token=' + encodeURIComponent(token))
|
||||
check('GET /?token=<有效> → 302(进入握手流程,而非被 static 直接返页面)',
|
||||
hs.status === 302, `实际 ${hs.status},body ${hs.text.slice(0, 60)}`)
|
||||
check('握手响应种下 nav_gate cookie',
|
||||
!!(hs.setCookie && hs.setCookie.indexOf('nav_gate=1') >= 0), String(hs.setCookie || '').slice(0, 80))
|
||||
|
||||
const bad = await req('GET', '/?token=not_a_valid_jwt')
|
||||
check('GET /?token=<无效> → 401', bad.status === 401, `实际 ${bad.status}`)
|
||||
}
|
||||
|
||||
// ---------- 4. 已过门禁(带 nav_gate)时必须正常放行 ----------
|
||||
console.log('\n[4] 带 nav_gate 时必须正常放行')
|
||||
const okRoot = await req('GET', '/', 'nav_gate=1')
|
||||
check('GET / 带门禁 → 200', okRoot.status === 200, `实际 ${okRoot.status}`)
|
||||
check('GET / 带门禁返回的是九宫格首页', okRoot.text.indexOf("Yang's Toolbox") >= 0, okRoot.text.slice(0, 60))
|
||||
const okTools = await req('GET', '/api/tools', 'nav_gate=1')
|
||||
check('GET /api/tools 带门禁 → 200', okTools.status === 200, `实际 ${okTools.status}`)
|
||||
const okNb = await req('GET', '/api/nav_bookmarks/state', 'nav_gate=1')
|
||||
check('GET /api/nav_bookmarks/state 带门禁 → 200', okNb.status === 200, `实际 ${okNb.status}`)
|
||||
|
||||
console.log(`\n=== 结果:通过 ${passed} 项,失败 ${failed} 项 ===`)
|
||||
process.exit(failed === 0 ? 0 : 1)
|
||||
})()
|
||||
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* 「首页导航防爆紧急替换」两个按钮 · 端到端验证
|
||||
*
|
||||
* 覆盖:/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)
|
||||
})
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* 作息报时器(schedule_reminder)后端接口集成测试
|
||||
* 独立起一个临时 express 实例(8978),只挂载 schedule_reminder 的路由,
|
||||
* 不依赖 8976 主服务,也不影响运行中的服务。
|
||||
*
|
||||
* 数据库隔离策略:脚本开始时若 data/app_order_box_schedule_reminder.db 不存在,
|
||||
* 结束时把测试期间产生的 db / wal / shm 一并删除,保证不留测试脏数据。
|
||||
*
|
||||
* 运行:node dev_test_scripts/integration/test_schedule_reminder_integration.js
|
||||
*/
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const express = require('express')
|
||||
|
||||
const DB_FILE = path.join(process.cwd(), 'data', 'app_order_box_schedule_reminder.db')
|
||||
const DB_EXISTED_BEFORE = fs.existsSync(DB_FILE)
|
||||
|
||||
const { bindRoutes } = require('../../src/server/app_order_box/kits/schedule_reminder')
|
||||
|
||||
const PORT = 8978
|
||||
const BASE = `http://127.0.0.1:${PORT}`
|
||||
|
||||
let passed = 0
|
||||
let failed = 0
|
||||
|
||||
const check = (name, ok, extra) => {
|
||||
if (ok) { passed += 1; console.log(` ✔ ${name}`) }
|
||||
else { failed += 1; console.log(` ✘ ${name}${extra ? ' → ' + extra : ''}`) }
|
||||
}
|
||||
|
||||
const request = async (method, url, body) => {
|
||||
const res = await fetch(BASE + url, {
|
||||
method,
|
||||
headers: body === undefined ? undefined : { 'Content-Type': 'application/json' },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
})
|
||||
const text = await res.text()
|
||||
let json = null
|
||||
try { json = JSON.parse(text) } catch (_) { /* 非 JSON */ }
|
||||
return { status: res.status, json, text }
|
||||
}
|
||||
|
||||
const cleanupDb = () => {
|
||||
if (DB_EXISTED_BEFORE) return
|
||||
;['', '-wal', '-shm'].forEach(suffix => {
|
||||
const p = DB_FILE + suffix
|
||||
try { if (fs.existsSync(p)) fs.unlinkSync(p) } catch (_) { /* 静默 */ }
|
||||
})
|
||||
console.log('\n(测试库为本次新建,已清理:' + path.basename(DB_FILE) + ')')
|
||||
}
|
||||
|
||||
const run = async () => {
|
||||
console.log('=== 作息报时器 · 后端接口集成测试 ===\n')
|
||||
|
||||
// ---------- 1. 校时接口 ----------
|
||||
console.log('[1] GET /time 校时')
|
||||
const timeRes = await request('GET', '/api/app_order_box/kits/schedule_reminder/time')
|
||||
check('HTTP 200', timeRes.status === 200, timeRes.status)
|
||||
check('返回 ok=true', !!timeRes.json && timeRes.json.ok === true)
|
||||
const epochMs = timeRes.json && timeRes.json.data ? Number(timeRes.json.data.epochMs) : NaN
|
||||
check('epochMs 是有效时间戳', Number.isFinite(epochMs) && Math.abs(Date.now() - epochMs) < 60 * 1000, epochMs)
|
||||
|
||||
// ---------- 2. 日程校验 ----------
|
||||
console.log('\n[2] POST /schedules 参数校验')
|
||||
const noTitle = await request('POST', '/api/app_order_box/kits/schedule_reminder/schedules', { items: [{ time: '07:30', name: '起床' }] })
|
||||
check('缺 title → 400', noTitle.status === 400, noTitle.status)
|
||||
const noItem = await request('POST', '/api/app_order_box/kits/schedule_reminder/schedules', { title: '周末', weekdays: [6, 7] })
|
||||
check('缺 items → 400', noItem.status === 400, noItem.status)
|
||||
const noWeekday = await request('POST', '/api/app_order_box/kits/schedule_reminder/schedules', { title: '周末', items: [{ time: '07:30', name: '起床' }] })
|
||||
check('缺 weekdays → 400', noWeekday.status === 400, noWeekday.status)
|
||||
|
||||
// ---------- 3. 新建日程 ----------
|
||||
console.log('\n[3] POST /schedules 新建(含清洗规则)')
|
||||
const createRes = await request('POST', '/api/app_order_box/kits/schedule_reminder/schedules', {
|
||||
title: ' 周末在家 ',
|
||||
weekdays: [7, 6, 6, 99, 0], // 期望清洗为 [6,7]
|
||||
items: [
|
||||
{ time: '11:00', name: '做午饭', repeatTimes: 5, intervalSec: 30 },
|
||||
{ time: '07:30', name: '起床', repeatTimes: 99, intervalSec: 1 }, // 期望被夹到 20 / 5
|
||||
{ time: '25:99', name: '非法时刻' }, // 期望被过滤
|
||||
],
|
||||
})
|
||||
check('HTTP 200', createRes.status === 200, createRes.status + ' ' + createRes.text.slice(0, 120))
|
||||
const created = createRes.json && createRes.json.data
|
||||
check('title 已 trim', created && created.title === '周末在家', created && created.title)
|
||||
check('weekdays 清洗为 [6,7]', created && JSON.stringify(created.weekdays) === '[6,7]', created && JSON.stringify(created.weekdays))
|
||||
check('非法时刻被过滤(剩 2 条)', created && created.items.length === 2, created && created.items.length)
|
||||
check('items 按时刻升序', created && created.items[0].time === '07:30' && created.items[1].time === '11:00',
|
||||
created && created.items.map(i => i.time).join(','))
|
||||
const first = created && created.items[0]
|
||||
check('repeatTimes 夹到 20', first && first.repeatTimes === 20, first && first.repeatTimes)
|
||||
check('intervalSec 夹到 5', first && first.intervalSec === 5, first && first.intervalSec)
|
||||
check('默认 repeatTimes=5 / intervalSec=30', created && created.items[1].repeatTimes === 5 && created.items[1].intervalSec === 30,
|
||||
created && (created.items[1].repeatTimes + '/' + created.items[1].intervalSec))
|
||||
const scheduleId = created && created.id
|
||||
|
||||
// ---------- 4. 列表 ----------
|
||||
console.log('\n[4] GET /schedules 列表')
|
||||
const listRes = await request('GET', '/api/app_order_box/kits/schedule_reminder/schedules')
|
||||
check('HTTP 200', listRes.status === 200, listRes.status)
|
||||
check('列表含新建日程', Array.isArray(listRes.json.data) && listRes.json.data.some(s => s.id === scheduleId))
|
||||
|
||||
// ---------- 5. 更新 ----------
|
||||
console.log('\n[5] PUT /schedules 更新')
|
||||
const updateRes = await request('PUT', '/api/app_order_box/kits/schedule_reminder/schedules', {
|
||||
id: scheduleId,
|
||||
title: '周末在家 v2',
|
||||
weekdays: [6, 7],
|
||||
items: [{ time: '07:30', name: '起床', repeatTimes: 5, intervalSec: 30 }],
|
||||
})
|
||||
check('HTTP 200', updateRes.status === 200, updateRes.status + ' ' + updateRes.text.slice(0, 120))
|
||||
check('title 已更新', updateRes.json && updateRes.json.data.title === '周末在家 v2')
|
||||
check('items 已更新为 1 条', updateRes.json && updateRes.json.data.items.length === 1)
|
||||
const missingUpdate = await request('PUT', '/api/app_order_box/kits/schedule_reminder/schedules', {
|
||||
id: 'not-exist-id', title: 'x', weekdays: [1], items: [{ time: '08:00', name: 'a' }],
|
||||
})
|
||||
check('更新不存在的日程 → 404', missingUpdate.status === 404, missingUpdate.status)
|
||||
|
||||
// ---------- 6. 播报日志 ----------
|
||||
console.log('\n[6] 播报日志 logs / logs/ack')
|
||||
const logRes = await request('POST', '/api/app_order_box/kits/schedule_reminder/logs', {
|
||||
scheduleId: scheduleId,
|
||||
scheduleTitle: '周末在家 v2',
|
||||
itemId: 'item-1',
|
||||
itemName: '起床',
|
||||
plannedAt: '2026-09-30T07:30:00+08:00',
|
||||
status: 'fired',
|
||||
})
|
||||
check('写日志 HTTP 200', logRes.status === 200, logRes.status + ' ' + logRes.text.slice(0, 120))
|
||||
const logId = logRes.json && logRes.json.data && logRes.json.data.id
|
||||
check('返回日志 id', Number.isFinite(Number(logId)) && Number(logId) > 0, logId)
|
||||
check('状态为 fired', logRes.json && logRes.json.data.status === 'fired')
|
||||
|
||||
const missingLog = await request('POST', '/api/app_order_box/kits/schedule_reminder/logs', { itemName: 'x' })
|
||||
check('缺 itemId → 400', missingLog.status === 400, missingLog.status)
|
||||
|
||||
const ackRes = await request('POST', '/api/app_order_box/kits/schedule_reminder/logs/ack', { id: logId })
|
||||
check('确认 HTTP 200', ackRes.status === 200, ackRes.status)
|
||||
check('状态变为 acked', ackRes.json && ackRes.json.data && ackRes.json.data.status === 'acked',
|
||||
ackRes.json && ackRes.json.data && ackRes.json.data.status)
|
||||
|
||||
const logsRes = await request('GET', '/api/app_order_box/kits/schedule_reminder/logs')
|
||||
check('日志列表 HTTP 200', logsRes.status === 200, logsRes.status)
|
||||
check('列表含刚写入的记录', Array.isArray(logsRes.json.data) && logsRes.json.data.some(l => Number(l.id) === Number(logId)))
|
||||
|
||||
// ---------- 7. 语音预合成参数校验 ----------
|
||||
console.log('\n[7] POST /tts/prepare 参数校验(不做真实合成)')
|
||||
const ttsBad = await request('POST', '/api/app_order_box/kits/schedule_reminder/tts/prepare', { schedule: {} })
|
||||
check('空 schedule → 400', ttsBad.status === 400, ttsBad.status)
|
||||
|
||||
// ---------- 8. 删除 ----------
|
||||
console.log('\n[8] DELETE /schedules 删除')
|
||||
const delRes = await request('DELETE', `/api/app_order_box/kits/schedule_reminder/schedules?id=${encodeURIComponent(scheduleId)}`)
|
||||
check('HTTP 200', delRes.status === 200, delRes.status)
|
||||
const afterDel = await request('GET', '/api/app_order_box/kits/schedule_reminder/schedules')
|
||||
check('删除后列表不再包含', Array.isArray(afterDel.json.data) && !afterDel.json.data.some(s => s.id === scheduleId))
|
||||
|
||||
console.log(`\n=== 结果:通过 ${passed} 项,失败 ${failed} 项 ===`)
|
||||
return failed === 0
|
||||
}
|
||||
|
||||
const app = express()
|
||||
app.use(express.json())
|
||||
bindRoutes(app)
|
||||
const server = app.listen(PORT, async () => {
|
||||
let ok = false
|
||||
try {
|
||||
ok = await run()
|
||||
} catch (e) {
|
||||
console.log('\n✘ 测试执行异常:' + String((e && e.stack) || e))
|
||||
failed += 1
|
||||
}
|
||||
server.close(() => {
|
||||
cleanupDb()
|
||||
process.exit(ok ? 0 : 1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* 短链接前缀与短码大小写容忍 · 回归测试
|
||||
*
|
||||
* 背景(2026-09-30 定位的真实问题):
|
||||
* iPhone 6s 用户手输短链接时,把小写 l 打成了大写 I(字形几乎一样),
|
||||
* 请求 /I/PSq6xv 命中不到路由 /l/:code,落到兜底 404 → 浏览器显示
|
||||
* {"ok":false,"error":"not_found"}。nginx 日志里这类错误请求累计 20 次。
|
||||
*
|
||||
* 处置:
|
||||
* ① 正式前缀由 /l/ 改为 /to/("to" 字形差异明显,且不像 go 那样易被猜到/重名)
|
||||
* ② 旧前缀 /l /L /I /i 保留兼容,已分发的旧短链不失效
|
||||
* ③ 短码匹配增加"忽略大小写"兜底,但仅当唯一匹配时生效(避免 Base62 歧义)
|
||||
* ④ Express 路由大小写不敏感,/to/ 天然接受 /TO/ /To/ /tO/
|
||||
*
|
||||
* ⚠️ 数据安全:脚本不使用用户已有短链接做点击测试(点击会累加 click_count、
|
||||
* 写入 clicks 表)。改为自建一条一次性短链接,测完删除,并对比用户数据前后无变化。
|
||||
*
|
||||
* 用法(需目标服务已启动):
|
||||
* node dev_test_scripts/integration/test_short_link_prefix_tolerance.js
|
||||
* node dev_test_scripts/integration/test_short_link_prefix_tolerance.js http://localhost:8976
|
||||
*/
|
||||
const path = require('path')
|
||||
const crypto = require('crypto')
|
||||
const Database = require('better-sqlite3')
|
||||
|
||||
const BASE = String(process.argv[2] || 'http://localhost:8976').replace(/\/+$/, '')
|
||||
const DB_PATH = path.join(process.cwd(), 'data', 'short_link.db')
|
||||
const IPHONE_6S_UA = 'Mozilla/5.0 (iPhone; CPU iPhone OS 12_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/92.0.4515.90 Mobile/15E148 Safari/604.1'
|
||||
|
||||
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 get = async (urlPath) => {
|
||||
const res = await fetch(BASE + urlPath, { headers: { 'User-Agent': IPHONE_6S_UA }, redirect: 'manual' })
|
||||
const text = await res.text().catch(() => '')
|
||||
return { status: res.status, text, location: res.headers.get('location') }
|
||||
}
|
||||
|
||||
/** 生成一条大小写明确的一次性测试短链接(形如 Ab3Xy9) */
|
||||
const makeMixedCaseCode = () => {
|
||||
const letters = 'ABCDEFGHJKLMNPQRSTUVWXYZ'
|
||||
const lower = 'abcdefghijkmnpqrstuvwxyz'
|
||||
const digits = '23456789'
|
||||
let code = ''
|
||||
code += lower[crypto.randomInt(lower.length)]
|
||||
code += letters[crypto.randomInt(letters.length)]
|
||||
code += digits[crypto.randomInt(digits.length)]
|
||||
code += letters[crypto.randomInt(letters.length)]
|
||||
code += lower[crypto.randomInt(lower.length)]
|
||||
code += digits[crypto.randomInt(digits.length)]
|
||||
return code
|
||||
}
|
||||
|
||||
const beijingNow = () => {
|
||||
try {
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false,
|
||||
}).format(new Date()).replace(/\//g, '-').replace(/\u200E/g, '')
|
||||
} catch { return new Date().toISOString() }
|
||||
}
|
||||
|
||||
;(async () => {
|
||||
console.log('=== 短链接前缀 / 短码大小写容忍 · 回归测试 ===')
|
||||
console.log(`目标:${BASE}\n`)
|
||||
|
||||
const db = new Database(DB_PATH)
|
||||
const testCode = makeMixedCaseCode()
|
||||
const testTarget = 'https://example.com/prefix-tolerance-test'
|
||||
|
||||
// 记录用户既有数据,测试结束后用于确认未被改动
|
||||
const userCode = 'PSq6xv'
|
||||
const userBefore = db.prepare('SELECT click_count FROM short_links WHERE code = ?').get(userCode)
|
||||
|
||||
console.log(`[准备] 自建一次性短码 ${testCode} → ${testTarget}`)
|
||||
db.prepare(`INSERT INTO short_links (code, target_url, title, source_type, created_at) VALUES (?, ?, ?, ?, ?)`)
|
||||
.run(testCode, testTarget, '前缀容忍回归测试', 'manual', beijingNow())
|
||||
|
||||
let allOk = false
|
||||
try {
|
||||
const L = testCode.toLowerCase()
|
||||
const U = testCode.toUpperCase()
|
||||
|
||||
// ---------- 1. 新前缀 /to/ 是正式写法 ----------
|
||||
console.log('\n[1] 新前缀 /to/(正式写法)')
|
||||
const okRes = await get(`/to/${testCode}`)
|
||||
check(`GET /to/${testCode} → 302`, okRes.status === 302, `实际 ${okRes.status}`)
|
||||
check('302 且 Location 指向目标链接', String(okRes.location || '').indexOf(testTarget) >= 0, String(okRes.location || '').slice(0, 80))
|
||||
// Express 路由大小写不敏感:/to/ 应天然接受大写变体
|
||||
for (const p of ['TO', 'To', 'tO']) {
|
||||
const r = await get(`/${p}/${testCode}`)
|
||||
check(`GET /${p}/${testCode}(前缀大小写变体)→ 302`, r.status === 302, `实际 ${r.status}`)
|
||||
}
|
||||
|
||||
// ---------- 2. 旧前缀必须仍兼容(已分发的旧短链不能失效) ----------
|
||||
console.log('\n[2] 旧前缀 /l /L /I /i 保留兼容')
|
||||
for (const p of ['l', 'L', 'I', 'i']) {
|
||||
const r = await get(`/${p}/${testCode}`)
|
||||
check(`GET /${p}/${testCode}(旧前缀)→ 302`, r.status === 302, `实际 ${r.status}`)
|
||||
}
|
||||
const iphoneFail = await get(`/I/${testCode}`)
|
||||
check('大写 I 不再返回 not_found JSON(本次故障的直接原因已消除)',
|
||||
iphoneFail.text.indexOf('not_found') < 0, iphoneFail.text.slice(0, 60))
|
||||
|
||||
// ---------- 3. 短码大小写兜底 ----------
|
||||
console.log('\n[3] 短码大小写兜底')
|
||||
const lowerCode = await get(`/to/${L}`)
|
||||
check(`GET /to/${L}(短码全小写)→ 302`, lowerCode.status === 302, `实际 ${lowerCode.status},${lowerCode.text.slice(0, 60)}`)
|
||||
const upperCode = await get(`/to/${U}`)
|
||||
check(`GET /to/${U}(短码全大写)→ 302`, upperCode.status === 302, `实际 ${upperCode.status}`)
|
||||
const bothWrong = await get(`/I/${L}`)
|
||||
check(`GET /I/${L}(旧前缀 + 短码大小写都错,即 /i/psq6xv 那类)→ 302`, bothWrong.status === 302, `实际 ${bothWrong.status}`)
|
||||
|
||||
// ---------- 4. 不该放行的仍然拦住 ----------
|
||||
console.log('\n[4] 负向:不存在的短码仍拒绝(且不是谎报成功)')
|
||||
const notExist = await get('/to/ZZZ999xx')
|
||||
check('GET /to/<不存在的短码> → 404', notExist.status === 404, `实际 ${notExist.status}`)
|
||||
const emptyCode = await get('/to/')
|
||||
check('GET /to/ 不报成功(4xx)', emptyCode.status >= 400 && emptyCode.status < 500, `实际 ${emptyCode.status}`)
|
||||
|
||||
// ---------- 5. 记账用规范短码(click_count 真的加上去了) ----------
|
||||
console.log('\n[5] 点击记账(大小写兜底命中时也要记到规范短码上)')
|
||||
const rowAfter = db.prepare('SELECT click_count, code FROM short_links WHERE code = ?').get(testCode)
|
||||
check('测试短码 click_count > 0(点击被正确累加)', Number(rowAfter.click_count) > 0, `click_count=${rowAfter.click_count}`)
|
||||
const clickRows = db.prepare('SELECT COUNT(1) AS c FROM short_link_clicks WHERE code = ?').get(testCode)
|
||||
check('clicks 表记录了规范短码的点击', Number(clickRows.c) > 0, `记录数=${clickRows.c}`)
|
||||
const strayRows = db.prepare('SELECT COUNT(1) AS c FROM short_link_clicks WHERE code = ? AND code != ?').get(L, testCode)
|
||||
check('没有把错误大小写的短码写进 clicks 表', Number(strayRows.c) === 0, `脏记录=${strayRows.c}`)
|
||||
|
||||
allOk = failed === 0
|
||||
} finally {
|
||||
// ---------- 清理:只删本次自建的短链接与其点击记录 ----------
|
||||
console.log('\n[清理] 删除本次自建短链接及其点击记录')
|
||||
db.prepare('DELETE FROM short_link_clicks WHERE code = ?').run(testCode)
|
||||
db.prepare('DELETE FROM short_links WHERE code = ?').run(testCode)
|
||||
const stillThere = db.prepare('SELECT COUNT(1) AS c FROM short_links WHERE code = ?').get(testCode)
|
||||
check('自建测试短链接已删除', Number(stillThere.c) === 0)
|
||||
|
||||
const userAfter = db.prepare('SELECT click_count FROM short_links WHERE code = ?').get(userCode)
|
||||
if (userBefore && userAfter) {
|
||||
check(`用户既有短码 ${userCode} 未被本次测试改动`,
|
||||
Number(userBefore.click_count) === Number(userAfter.click_count),
|
||||
`前 ${userBefore.click_count} → 后 ${userAfter.click_count}`)
|
||||
}
|
||||
db.close()
|
||||
}
|
||||
|
||||
console.log(`\n=== 结果:通过 ${passed} 项,失败 ${failed} 项 ===`)
|
||||
process.exit(allOk && failed === 0 ? 0 : 1)
|
||||
})()
|
||||
Reference in New Issue
Block a user