Files
mihomo-patchbay/server/config-store.test.ts
T
im0o a40bd6a01b 历史记录:每条可展开 diff,连续保存合并进 5 秒窗
备份存的是「改动前」,所以一条记录的 diff 两端是它自己和更新一份的
状态(最新一条对比当前文件)。行级 diff 先掐公共前后缀再做 LCS,一次
改动通常只碰几行,表撑得住;大面积不同时退化成整段删增。

合并窗做在服务端而不是前端防抖:普通保存(opts.merge)发现最新备份
还在窗内就不新建,连点删除共享一条历史、一步撤回。窗锚定在这一串的
第一下,不滚雪球;还原、导入、撤回重做不合并,各占一条。合并时没有
备份文件,重载失败的还原改用刚读出的原文写回。顺带消除了备份名只精
确到秒、同秒保存互相覆盖的隐患。
2026-08-12 13:17:11 +08:00

337 lines
12 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { afterAll, describe, expect, it } from 'bun:test'
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import {
applyConfig,
backupDiff,
importConfig,
listBackups,
listRedo,
readConfig,
redoConfig,
restoreBackup,
undoConfig,
} from './config-store.ts'
import type { Env } from './env.ts'
/**
* 落盘保护链路的测试,跑在一个假内核上:/version 报在线,PUT /configs 默认放行。
* mihomo -t 那条离线路径这里测不了(需要真的内核二进制),离线行为只能靠人工验证。
*/
let rejectNextReload = false
let reloadCount = 0
const kernel = Bun.serve({
port: 0,
fetch(req) {
const url = new URL(req.url)
if (url.pathname === '/version') return Response.json({ version: 'test' })
if (url.pathname === '/configs' && req.method === 'PUT') {
reloadCount += 1
if (rejectNextReload) {
rejectNextReload = false
return new Response('parse error: something wrong', { status: 400 })
}
return new Response(null, { status: 204 })
}
return new Response(null, { status: 404 })
},
})
afterAll(() => kernel.stop())
const ORIGINAL = 'mixed-port: 7890\n'
function makeEnv(): Env {
const dir = mkdtempSync(join(tmpdir(), 'patchbay-store-'))
const configPath = join(dir, 'config.yaml')
writeFileSync(configPath, ORIGINAL, 'utf8')
return {
mihomoApi: `http://127.0.0.1:${String(kernel.port)}`,
mihomoSecret: 'x',
configPath,
mihomoBin: join(dir, '不存在的内核'),
mihomoHome: dir,
host: '127.0.0.1',
port: 0,
}
}
describe('落盘保护链路(内核在线)', () => {
it('正常保存:写盘、留一份改动前的备份、通知重载', async () => {
const env = makeEnv()
const { data, revision } = await readConfig(env)
const before = reloadCount
const result = await applyConfig(env, { ...data, 'mixed-port': 7891 }, revision)
expect(result.ok).toBe(true)
if (result.ok) expect(result.reloaded).toBe(true)
expect(readFileSync(env.configPath, 'utf8')).toContain('mixed-port: 7891')
expect(reloadCount).toBe(before + 1)
const backups = await listBackups(env)
expect(backups).toHaveLength(1)
const backupBody = readFileSync(
join(env.configPath, '..', '.patchbay-backups', backups[0]!),
'utf8',
)
expect(backupBody).toBe(ORIGINAL)
})
it('基准过时被拦住,文件一个字节不动', async () => {
const env = makeEnv()
const { data } = await readConfig(env)
const result = await applyConfig(env, { ...data, 'mixed-port': 7891 }, '基准已过时')
expect(result.ok).toBe(false)
if (!result.ok) expect(result.stage).toBe('stale')
expect(readFileSync(env.configPath, 'utf8')).toBe(ORIGINAL)
expect(await listBackups(env)).toHaveLength(0)
})
it('内容没变直接返回,不备份也不重载', async () => {
const env = makeEnv()
// 先落一次盘让文件带上 Patchbay 标记——没有标记的文件第一次保存必有
// 「补标记」这一处内容变化,短路只发生在稳态
const first = await readConfig(env)
await applyConfig(env, first.data, first.revision)
const { data, revision } = await readConfig(env)
const before = reloadCount
const backupsBefore = (await listBackups(env)).length
const result = await applyConfig(env, data, revision)
expect(result.ok).toBe(true)
if (result.ok) {
expect(result.reloaded).toBe(false)
expect(result.reloadMessage).toBe('内容没有变化')
}
expect(reloadCount).toBe(before)
expect((await listBackups(env)).length).toBe(backupsBefore)
})
it('自检拦下断引用:入站的 proxy 指向不存在的出站', async () => {
// mihomo -t 和重载都不检查这种引用(实测 v1.19.29),只有这层自检拦得住
const env = makeEnv()
const { data, revision } = await readConfig(env)
const next = {
...data,
listeners: [{ name: 'x', type: 'mixed', port: 1080, proxy: '不存在的组' }],
}
const result = await applyConfig(env, next, revision)
expect(result.ok).toBe(false)
if (!result.ok) expect(result.stage).toBe('self-check')
expect(readFileSync(env.configPath, 'utf8')).toBe(ORIGINAL)
})
it('内核拒绝重载时把文件还原,并让内核载回原样', async () => {
const env = makeEnv()
const { data, revision } = await readConfig(env)
rejectNextReload = true
const result = await applyConfig(env, { ...data, 'mixed-port': 7891 }, revision)
expect(result.ok).toBe(false)
if (!result.ok) {
expect(result.stage).toBe('reload')
expect(result.restored).toBe(true)
expect(result.message).toContain('parse error')
}
expect(readFileSync(env.configPath, 'utf8')).toBe(ORIGINAL)
})
it('恢复备份走同一条保护链路', async () => {
const env = makeEnv()
const first = await readConfig(env)
await applyConfig(env, { ...first.data, 'mixed-port': 7891 }, first.revision)
const [backup] = await listBackups(env)
const result = await restoreBackup(env, backup!)
expect(result.ok).toBe(true)
// 还原也走落盘链路,所以会重新打上 Patchbay 标记——内容回去了,但不逐字节等于旧文件
const body = readFileSync(env.configPath, 'utf8')
expect(body).toContain('mixed-port: 7890')
expect(body).not.toContain('7891')
expect(body).toContain('patchbay:')
})
it('覆盖导入整份替换,兼容导入只搬接管段落', async () => {
const env = makeEnv()
const uploaded = 'mixed-port: 9999\nrules:\n - MATCH,DIRECT\n'
const compat = await importConfig(env, uploaded, 'compatible')
expect(compat.ok).toBe(true)
let body = readFileSync(env.configPath, 'utf8')
// rules 是接管段落被搬过来,mixed-port 属于机器底座维持当前值
expect(body).toContain('MATCH,DIRECT')
expect(body).toContain('mixed-port: 7890')
expect(body).not.toContain('9999')
const over = await importConfig(env, uploaded, 'override')
expect(over.ok).toBe(true)
body = readFileSync(env.configPath, 'utf8')
expect(body).toContain('mixed-port: 9999')
})
it('导入解析不过的文件时一个字节都不动', async () => {
const env = makeEnv()
const result = await importConfig(env, '{{{ 不是 yaml', 'override')
expect(result.ok).toBe(false)
if (!result.ok) expect(result.stage).toBe('self-check')
expect(readFileSync(env.configPath, 'utf8')).toBe(ORIGINAL)
})
it('恢复只认列表里的名字,路径穿越进不来', async () => {
const env = makeEnv()
const result = await restoreBackup(env, '../config.yaml')
expect(result.ok).toBe(false)
if (!result.ok) expect(result.message).toContain('没有这个备份')
})
it('撤回回到改动前,重做拿回改动,备份链不被撤回污染', async () => {
const env = makeEnv()
const first = await readConfig(env)
await applyConfig(env, { ...first.data, 'mixed-port': 7891 }, first.revision)
const undone = await undoConfig(env)
expect(undone.ok).toBe(true)
expect(readFileSync(env.configPath, 'utf8')).toContain('mixed-port: 7890')
// 备份被消费、当前状态进了重做栈
expect(await listBackups(env)).toHaveLength(0)
expect(await listRedo(env)).toHaveLength(1)
const redone = await redoConfig(env)
expect(redone.ok).toBe(true)
expect(readFileSync(env.configPath, 'utf8')).toContain('mixed-port: 7891')
expect(await listRedo(env)).toHaveLength(0)
expect(await listBackups(env)).toHaveLength(1)
})
it('栈空时撤回和重做被拦住,文件不动', async () => {
const env = makeEnv()
const undone = await undoConfig(env)
expect(undone.ok).toBe(false)
if (!undone.ok) expect(undone.message).toContain('没有可以撤回')
const redone = await redoConfig(env)
expect(redone.ok).toBe(false)
expect(readFileSync(env.configPath, 'utf8')).toBe(ORIGINAL)
})
it('新的保存清空重做栈', async () => {
const env = makeEnv()
const first = await readConfig(env)
await applyConfig(env, { ...first.data, 'mixed-port': 7891 }, first.revision)
await undoConfig(env)
expect(await listRedo(env)).toHaveLength(1)
const { data, revision } = await readConfig(env)
await applyConfig(env, { ...data, 'mixed-port': 7892 }, revision)
expect(await listRedo(env)).toHaveLength(0)
})
it('备份最多留 10 份,旧的被清掉', async () => {
const env = makeEnv()
const dir = join(env.configPath, '..', '.patchbay-backups')
mkdirSync(dir, { recursive: true })
for (let i = 10; i < 22; i += 1) {
writeFileSync(join(dir, `config.yaml.bak-20200101-0000${String(i)}`), 'old', 'utf8')
}
const { data, revision } = await readConfig(env)
await applyConfig(env, { ...data, 'mixed-port': 7891 }, revision)
const backups = await listBackups(env)
expect(backups).toHaveLength(10)
// 名字按时间戳排序,留下的是最新的:刚生成的那份在最前,2020 年的只剩最近的九份
expect(backups[0]!.startsWith('config.yaml.bak-2020')).toBe(false)
expect(backups).not.toContain('config.yaml.bak-20200101-000010')
})
})
describe('连续保存的合并窗(opts.merge)', () => {
const stampName = (at: Date) => {
const p = (n: number) => String(n).padStart(2, '0')
return (
`config.yaml.bak-${String(at.getFullYear())}${p(at.getMonth() + 1)}${p(at.getDate())}` +
`-${p(at.getHours())}${p(at.getMinutes())}${p(at.getSeconds())}`
)
}
it('窗内的第二次保存不新建备份,一步撤回回到这一串之前', async () => {
const env = makeEnv()
const first = await readConfig(env)
await applyConfig(env, { ...first.data, 'mixed-port': 7891 }, first.revision, { merge: true })
const second = await readConfig(env)
await applyConfig(env, { ...second.data, 'mixed-port': 7892 }, second.revision, { merge: true })
expect(await listBackups(env)).toHaveLength(1)
const undone = await undoConfig(env)
expect(undone.ok).toBe(true)
expect(readFileSync(env.configPath, 'utf8')).toContain('mixed-port: 7890')
})
it('最新备份出了窗就正常新建', async () => {
const env = makeEnv()
const dir = join(env.configPath, '..', '.patchbay-backups')
mkdirSync(dir, { recursive: true })
writeFileSync(join(dir, stampName(new Date(Date.now() - 60_000))), 'old', 'utf8')
const { data, revision } = await readConfig(env)
await applyConfig(env, { ...data, 'mixed-port': 7891 }, revision, { merge: true })
expect(await listBackups(env)).toHaveLength(2)
})
it('不带 merge 的保存(还原、导入)不合并,窗内照样各留一条', async () => {
const env = makeEnv()
const first = await readConfig(env)
await applyConfig(env, { ...first.data, 'mixed-port': 7891 }, first.revision, { merge: true })
const [backup] = await listBackups(env)
const result = await restoreBackup(env, backup!)
expect(result.ok).toBe(true)
// 还原自己也备份了「还原前」的状态。名字按秒生成,和上一份同秒时会覆盖而不是
// 新增,所以这里断言的是「至少还有一份、且文件回到了原样」
expect((await listBackups(env)).length).toBeGreaterThanOrEqual(1)
expect(readFileSync(env.configPath, 'utf8')).toContain('mixed-port: 7890')
})
})
describe('历史记录的对比原文', () => {
it('最新一条对比当前文件,改动前后各归各', async () => {
const env = makeEnv()
const { data, revision } = await readConfig(env)
await applyConfig(env, { ...data, 'mixed-port': 7891 }, revision)
const [newest] = await listBackups(env)
const pair = await backupDiff(env, newest!)
expect(pair).not.toBeNull()
expect(pair!.before).toBe(ORIGINAL)
expect(pair!.after).toContain('mixed-port: 7891')
})
it('更老的一条对比它前面那份备份', async () => {
const env = makeEnv()
const dir = join(env.configPath, '..', '.patchbay-backups')
mkdirSync(dir, { recursive: true })
writeFileSync(join(dir, 'config.yaml.bak-20200101-000001'), '第一份\n', 'utf8')
writeFileSync(join(dir, 'config.yaml.bak-20200101-000002'), '第二份\n', 'utf8')
const pair = await backupDiff(env, 'config.yaml.bak-20200101-000001')
expect(pair).toEqual({ before: '第一份\n', after: '第二份\n' })
})
it('名字不在备份列表里返回 null', async () => {
const env = makeEnv()
expect(await backupDiff(env, '../../etc/passwd')).toBeNull()
})
})