// 服务端协议审计:认证 / 九张表收发持久化 / 多用户隔离 / 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) })