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() }) })