chore: 移除 CI/Actions 相关设施(workflow、ci 脚本、tests 目录),审计脚本保留在 repro/ 供本地开发使用
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
// 用户管理审计:角色 / 列表 / 新建 / 重置密码 / 删除 / 越权防护
|
||||
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, 160) : '')) }
|
||||
}
|
||||
async function req(path, method, body, token) {
|
||||
const r = await fetch(BASE + path, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: 'Bearer ' + token } : {}) },
|
||||
body: body ? JSON.stringify(body) : undefined
|
||||
})
|
||||
return { status: r.status, data: await r.json().catch(() => ({})) }
|
||||
}
|
||||
async function main() {
|
||||
const ax = await req('/api/auth/register', 'POST', { username: 'xionglin', password: 'admin123' })
|
||||
check('xionglin 注册成功', ax.status === 200, ax.data)
|
||||
const nu = await req('/api/auth/register', 'POST', { username: 'normal_u', password: 'normal123' })
|
||||
check('普通用户注册成功', nu.status === 200, nu.data)
|
||||
const listNoAuth = await req('/api/admin/users', 'GET', null, nu.data.token)
|
||||
check('非管理员访问用户列表 403', listNoAuth.status === 403, listNoAuth.status)
|
||||
const badList = await req('/api/admin/users', 'GET', null)
|
||||
check('无 token 访问用户列表 403', badList.status === 403, badList.status)
|
||||
const list = await req('/api/admin/users', 'GET', null, ax.data.token)
|
||||
check('管理员可见用户列表', list.status === 200 && ((list.data.users || []).some(u => u.username === 'normal_u') && (list.data.users.some(u => u.username === 'xionglin' && u.role === 'admin'))), list.data)
|
||||
check('列表返回 me 字段', !!list.data.me)
|
||||
const create = await req('/api/admin/users/create', 'POST', { username: 'target_u', password: 'pass111' }, ax.data.token)
|
||||
check('管理员新建用户', create.status === 200 && !!create.data.userId, create.data)
|
||||
const dupCreate = await req('/api/admin/users/create', 'POST', { username: 'target_u', password: 'pass222' }, ax.data.token)
|
||||
check('重复用户名 409', dupCreate.status === 409, dupCreate.status)
|
||||
const loginTarget = await req('/api/auth/login', 'POST', { username: 'target_u', password: 'pass111' })
|
||||
check('新用户可用初始密码登录', loginTarget.status === 200, loginTarget.status)
|
||||
const reset = await req('/api/admin/users/password', 'POST', { userId: create.data.userId, newPassword: 'pass999' }, ax.data.token)
|
||||
check('管理员重置密码 200', reset.status === 200, reset.data)
|
||||
const oldLogin = await req('/api/auth/login', 'POST', { username: 'target_u', password: 'pass111' })
|
||||
check('旧密码失效 401', oldLogin.status === 401, oldLogin.status)
|
||||
const newLogin = await req('/api/auth/login', 'POST', { username: 'target_u', password: 'pass999' })
|
||||
check('重置后新密码可登录', newLogin.status === 200, newLogin.status)
|
||||
const selfDel = await req('/api/admin/users/delete', 'POST', { userId: ax.data.userId }, ax.data.token)
|
||||
check('不能删除自己 400', selfDel.status === 400, selfDel.status)
|
||||
const del = await req('/api/admin/users/delete', 'POST', { userId: create.data.userId }, ax.data.token)
|
||||
check('删除用户 200', del.status === 200, del.data)
|
||||
const goneLogin = await req('/api/auth/login', 'POST', { username: 'target_u', password: 'pass999' })
|
||||
check('删除后登录被拒 401', goneLogin.status === 401, goneLogin.status)
|
||||
const list2 = await req('/api/admin/users', 'GET', null, ax.data.token)
|
||||
check('列表不再含已删用户', !((list2.data.users || []).some(u => u.username === 'target_u')))
|
||||
|
||||
console.log('==== 用户管理审计: PASS ' + pass + ' / FAIL ' + fail + ' ====')
|
||||
process.exit(fail > 0 ? 1 : 0)
|
||||
}
|
||||
main().catch(e => { console.error('FAILED:', e && (e.stack || e.message)); process.exit(1) })
|
||||
@@ -0,0 +1,138 @@
|
||||
// 服务端协议审计:认证 / 九张表收发持久化 / 多用户隔离 / 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) })
|
||||
Reference in New Issue
Block a user