Files
daywork/tests/server/protocol.mjs
T
root 9a91c4c85d
CI / 测试套件(协议/用户管理/单测/双设备/实时链路/构建) (push) Failing after 30s
CI / 依赖漏洞扫描(软性检查) (push) Failing after 30s
ci: 统一测试入口 scripts/ci.mjs(协议/用户管理/单测/双设备/实时链路/构建/依赖审计)+ Gitea Actions 工作流
2026-10-16 21:03:54 +08:00

139 lines
9.4 KiB
JavaScript

// 服务端协议审计:认证 / 九张表收发持久化 / 多用户隔离 / LWW / 墓碑 / SSE / 备份
const BASE = process.env.SYNC_BASE || 'http://127.0.0.1:3780'
let pass = 0, fail = 0
function check(name, ok, detail) {
if (ok) { pass++; console.log(' PASS ' + name) }
else { fail++; console.log(' FAIL ' + name + (detail ? ' -> ' + String(JSON.stringify(detail)).slice(0, 180) : '')) }
}
async function post(path, body, token) {
const r = await fetch(BASE + path, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: 'Bearer ' + token } : {}) },
body: JSON.stringify(body || {})
})
return { status: r.status, body: await r.json().catch(() => ({})) }
}
const rowsOf = (resp) => resp.body.tables || {}
const tombsOf = (resp) => resp.body.tombstones || []
const row = (_, o) => Object.assign({ uuid: o.uuid, updated_at: o.updated_at || '2026-01-01T00:00:00' }, o)
async function main() {
const reg = await post('/api/auth/register', { username: 'audit_a', password: 'aapass123' })
check('注册成功返回 token', reg.status === 200 && !!reg.body.token, reg.body)
const dup = await post('/api/auth/register', { username: 'audit_a', password: 'other123' })
check('重复用户名被拒 409', dup.status === 409, dup.status)
const bad = await post('/api/auth/login', { username: 'audit_a', password: 'wrong!!' })
check('错误密码登录 401', bad.status === 401, bad.status)
const ok = await post('/api/auth/login', { username: 'audit_a', password: 'aapass123' })
check('正确密码登录 200', ok.status === 200 && !!ok.body.token, ok.status)
const tokA = reg.body.token, tokA2 = ok.body.token
const noAuth = await post('/api/sync', { tables: {} })
check('无 token 同步被拒 401', noAuth.status === 401, noAuth.status)
const regB = await post('/api/auth/register', { username: 'audit_b', password: 'bbpass123' })
check('第二用户注册成功', regB.status === 200, regB.body)
const all9 = {
project: [row('project', { uuid: 'pu1', name: '项目X', archived: 0 })],
work_log: [row('work_log', { uuid: 'wu1', date: '2026-02-10' })],
log_item: [row('log_item', { uuid: 'iu1', wuuid: 'wu1', puuid: 'pu1', content: '条目1', category: 'dev', minutes: 45, is_milestone: 0, needs_followup: 0, sort_order: 0 })],
attendance: [row('attendance', { uuid: 'au1', date: '2026-02-10', kind: 'normal', clock_in_at: '2026-02-10T09:00:00', clock_out_at: '2026-02-10T18:00:00' })],
weekly_note: [row('weekly_note', { uuid: 'vu1', week_start: '2026-02-09', content: '周记' })],
setting: [row('setting', { uuid: 'su1', key: 'work_start', value: '09:00' })],
memo: [row('memo', { uuid: 'mu1', title: '备忘', content: '内容' })],
credential: [row('credential', { uuid: 'cu1', title: '站点', account: 'user1' })],
transaction: [row('transaction', { uuid: 'tu1', kind: 'expense', amount_cents: 12500, category: 'traffic', happened_on: '2026-02-10' })]
}
const push1 = await post('/api/sync', { tables: all9 }, tokA)
check('九张表推送 200', push1.status === 200, push1.status)
let allEcho = true
for (const t of Object.keys(all9)) {
const got = rowsOf(push1)[t] || []
if (!got.some(x => x.uuid === all9[t][0].uuid)) { allEcho = false; console.log(' 缺表:', t) }
}
check('九张表数据全部回读', allEcho)
const pushEmpty = await post('/api/sync', { tables: {} }, tokA)
let persisted = true
for (const t of Object.keys(all9)) persisted = persisted && (rowsOf(pushEmpty)[t] || []).some(x => x.uuid === all9[t][0].uuid)
check('数据持久化(二次同步仍在)', persisted)
check('回传 serverTime 字段', typeof push1.body.serverTime === 'string')
const bPush = await post('/api/sync', { tables: { project: [row('project', { uuid: 'pb1', name: 'B的私有项目', archived: 0 })] } }, regB.body.token)
check('用户B 可推送自己的数据', bPush.status === 200 && (rowsOf(bPush).project || []).some(x => x.uuid === 'pb1'))
const againA = await post('/api/sync', { tables: {} }, tokA)
check('用户A 看不到 B 的数据', !((rowsOf(againA).project || []).some(x => x.uuid === 'pb1')))
const againB = await post('/api/sync', { tables: {} }, regB.body.token)
check('用户B 空库干净', Object.keys(all9).every(t => !((rowsOf(againB)[t] || []).some(x => x.uuid === all9[t][0].uuid))))
await post('/api/sync', { tables: { setting: [row('setting', { uuid: 'lww-new', key: 'work_end', value: '18:30', updated_at: '2026-03-01T10:00:00' })] } }, tokA)
await post('/api/sync', { tables: { setting: [row('setting', { uuid: 'lww-old', key: 'work_end', value: '17:30', updated_at: '2026-03-01T09:00:00' })] } }, tokA2)
const lwwR = await post('/api/sync', { tables: {} }, tokA)
check('LWW: 保留较新值', (rowsOf(lwwR).setting || []).some(x => x.uuid === 'lww-new' && x.value === '18:30'))
check('LWW: 旧副本被墓碑', tombsOf(lwwR).some(t => t.table === 'setting' && t.uuid === 'lww-old'))
await post('/api/sync', { tables: { setting: [row('setting', { uuid: 'rev-new', key: 'work_start', value: '08:50', updated_at: '2026-03-02T11:00:00' })] } }, tokA2)
const lwwR2 = await post('/api/sync', { tables: {} }, tokA)
check('LWW 反向: 旧 work_start 被墓碑', tombsOf(lwwR2).some(t => t.table === 'setting' && t.uuid === 'su1'))
await post('/api/sync', { tables: { memo: [row('memo', { uuid: 'mt1', title: '待删', content: 'x', updated_at: '2026-04-01T00:00:00' }), row('memo', { uuid: 'ms2', title: '更迟', content: 'y', updated_at: '2030-01-01T00:00:00' })] } }, tokA)
const delPush = await post('/api/sync', { tombstones: [{ table: 'memo', uuid: 'mt1', deleted_at: '2026-04-02T00:00:00' }] }, tokA)
check('墓碑同步后行被清除', !((rowsOf(delPush).memo || []).some(x => x.uuid === 'mt1')))
check('新于删除时间的行保留(防误删)', (rowsOf(delPush).memo || []).some(x => x.uuid === 'ms2'))
check('墓碑记录回传', tombsOf(delPush).some(t => t.table === 'memo' && t.uuid === 'mt1'))
const chg = await post('/api/auth/password', { oldPassword: 'aapass123', newPassword: 'aapass456' }, tokA)
check('修改登录密码 200', chg.status === 200, chg.body)
const oldLogin = await post('/api/auth/login', { username: 'audit_a', password: 'aapass123' })
check('旧密码立即失效 401', oldLogin.status === 401, oldLogin.status)
const newLogin = await post('/api/auth/login', { username: 'audit_a', password: 'aapass456' })
check('新密码登录 200', newLogin.status === 200, newLogin.status)
const chgWrong = await post('/api/auth/password', { oldPassword: 'zzz', newPassword: 'whatever1' }, tokA)
check('错误旧密码改密被拒 400', chgWrong.status === 400, chgWrong.status)
const ac = new AbortController()
const sse = await fetch(BASE + '/api/events?token=' + encodeURIComponent(tokA), { signal: ac.signal })
const reader = sse.body.getReader()
check('SSE 连接 200 + text/event-stream', sse.status === 200 && String(sse.headers.get('content-type')).includes('text/event-stream'))
let changeCount = 0
const readerPromise = (async () => {
const dec = new TextDecoder()
let buf = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buf += dec.decode(value)
while (buf.includes('event: change')) { buf = buf.slice(buf.indexOf('event: change') + 13); changeCount++ }
if (changeCount >= 1) return changeCount
}
return changeCount
})()
await new Promise(r => globalThis.setTimeout(r, 300))
await post('/api/sync', { tables: {} }, tokA)
await new Promise(r => globalThis.setTimeout(r, 400))
check('空同步不误广播', changeCount === 0, changeCount)
const trigger = await post('/api/sync', { tables: { memo: [row('memo', { uuid: 'sse-trigger', title: '实时', content: 'sse' })] } }, newLogin.body.token)
check('触发推送 200', trigger.status === 200)
const withEvent = await Promise.race([readerPromise, (async () => { await new Promise(r => globalThis.setTimeout(r, 5000)); return 0 })()])
check('SSE 收到 change 实时事件', withEvent >= 1, withEvent)
ac.abort()
const badSse = await fetch(BASE + '/api/events?token=garbage')
check('伪造 token 的 SSE 被拒 401', badSse.status === 401, badSse.status)
const fsMod = await import('node:fs')
const regJson = JSON.parse(fsMod.readFileSync('server/data/registry.json', 'utf-8'))
const aRec = regJson.users.find(u => u.username === 'audit_a')
check('注册表仅存哈希不存明文密码', !!aRec && !JSON.stringify(aRec).includes('aapass'))
await new Promise(r => globalThis.setTimeout(r, 400))
check('启动自动备份已生成', fsMod.existsSync('server/data/backups/' + new Date().toISOString().slice(0, 10) + '/registry.json'))
const d1 = await post('/api/auth/delete', {}, tokA)
check('删除账号 200', d1.status === 200, d1.body)
const afterDel = await post('/api/auth/login', { username: 'audit_a', password: 'aapass456' })
check('删除后登录被拒 401', afterDel.status === 401, afterDel.status)
check('删除后用户数据文件移除', !fsMod.existsSync('server/data/users/' + reg.body.userId + '.json'))
const b2 = await post('/api/sync', { tables: {} }, regB.body.token)
check('其他用户不受删除影响', b2.status === 200 && (rowsOf(b2).project || []).some(x => x.uuid === 'pb1'))
console.log('==== 服务端协议审计: PASS ' + pass + ' / FAIL ' + fail + ' ====')
process.exit(fail > 0 ? 1 : 0)
}
main().catch(e => { console.error('AUDIT FAILED:', (e && (e.stack || e.message)) || e); process.exit(1) })