// 服务端存储加固冒烟:空同步不落盘 / 墓碑GC / 大批删除前置快照 / 损坏隔离 const 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 ? ' -> ' + detail : '')) } } const sleep = ms => new Promise(r => setTimeout(r, ms)) async function j(path, body, token) { const r = await fetch(BASE + path, { method: 'POST', headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: 'Bearer ' + token } : {}) }, body: body ? JSON.stringify(body) : undefined }) return { status: r.status, json: await r.json().catch(() => ({})) } } async function main() { const fsp = await import('fs') let reg = await j('/api/auth/register', { username: 'storagetest', password: 'test123456' }) if (reg.status === 409) reg = await j('/api/auth/login', { username: 'storagetest', password: 'test123456' }) console.log('auth status:', reg.status) const tok = reg.json.token const file = 'server/data/users/' + reg.json.userId + '.json' const row = { uuid: 'm1', title: 't', content: 'c', created_at: '2026-10-26T00:00:00', updated_at: '2026-10-26T00:00:00' } const push = tables => j('/api/sync', { device: 't', tables, tombstones: [] }, tok) const p1 = await push({ memo: [row] }) console.log('push1 status:', p1.status) check('首次同步落盘(200+文件存在)', p1.status === 200 && fsp.existsSync(file)) const m1 = fsp.statSync(file).mtimeMs await sleep(30) const p2 = await push({ memo: [row] }) await sleep(30) const m2 = fsp.statSync(file).mtimeMs check('空推送不重写文件(脏检查)', p2.status === 200 && m2 === m1, 'mtime ' + m1 + ' -> ' + m2) await j('/api/sync', { device: 't', tables: {}, tombstones: [{ table: 'memo', uuid: 'old1', deleted_at: '2026-09-01T00:00:00' }] }, tok) const d = JSON.parse(fsp.readFileSync(file, 'utf8')) check('过期墓碑(>14天)自动回收', !d.tombstones.memo || !d.tombstones.memo.old1, JSON.stringify(d.tombstones)) const dirBefore = fsp.readdirSync('server/data/backups').filter(x => /^\d{4}-\d{2}-\d{2}/.test(x)) const tombs = Array.from({ length: 20 }, (_, i) => ({ table: 'memo', uuid: 'ghost' + i, deleted_at: '2026-10-26T23:59:00' })) tombs.push({ table: 'memo', uuid: 'm1', deleted_at: '2026-10-26T23:59:00' }) // m1 本尊:应被删除 await sleep(1100) // 错开秒级时间戳,避免 _t 目录同名覆盖 const bigResp = await j('/api/sync', { device: 't', tables: {}, tombstones: tombs }, tok) console.log('big-tombstone status:', bigResp.status) const dirAfter = fsp.readdirSync('server/data/backups').filter(x => /^\d{4}-\d{2}-\d{2}/.test(x)) console.log('backup dirs:', dirAfter.join(' | ')) check('大批删除触发前置快照', dirAfter.length > dirBefore.length, dirBefore.length + '->' + dirAfter.length) const d2 = JSON.parse(fsp.readFileSync(file, 'utf8')) check('墓碑应用后行已删除', !d2.tables.memo || !d2.tables.memo.m1, JSON.stringify(Object.keys(d2.tables.memo || {}))) check('墓碑已记录(待客户端收敛)', Object.keys(d2.tombstones.memo || {}).length >= 21, JSON.stringify(d2.tombstones.memo || {})) fsp.writeFileSync(file, '{ this is not json !!!') const r5 = await push({ memo: [{ uuid: 'm2', title: 'x', content: '', created_at: '2026-10-26T00:00:00', updated_at: '2026-10-26T00:00:00' }] }) check('损坏文件不再静默当空(同步仍200)', r5.status === 200, 'status=' + r5.status) const quarantined = fsp.readdirSync('server/data/users').some(f => f.includes('.corrupt-')) check('损坏文件已隔离保留原件', quarantined, fsp.readdirSync('server/data/users').join(',')) const d3 = JSON.parse(fsp.readFileSync('server/data/users/' + reg.json.userId + '.json', 'utf8')) check('新纪元已写入(created_at 非空)', !!d3.created_at) console.log('RESULT: ' + (fail === 0 ? 'ALL PASS ' + pass : 'FAIL ' + fail + '/' + (pass + fail))) if (fail) process.exit(1) } main().catch(e => { console.error('SMOKE ERROR', e); process.exit(1) })