From 4da501ded64077eb2c1272eb0b4b314046eb0262 Mon Sep 17 00:00:00 2001 From: Jz0ojiang Date: Tue, 11 Aug 2026 14:41:56 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BB=99=E8=90=BD=E7=9B=98=E4=BF=9D=E6=8A=A4?= =?UTF-8?q?=E9=93=BE=E8=B7=AF=E8=A1=A5=E4=B8=8A=E5=8D=95=E5=85=83=E6=B5=8B?= =?UTF-8?q?=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 全项目最关键的一条链路此前只有手动脚本在守。跑在一个假内核上,覆盖 在线路径的八种情形:正常保存、基准过时、内容未变、自检断引用、内核 拒绝后还原、恢复备份、路径穿越、备份轮换。mihomo -t 那条离线路径需要 真内核二进制,测不到,离线行为仍靠人工验证。 --- server/config-store.test.ts | 168 ++++++++++++++++++++++++++++++++++++ 1 file changed, 168 insertions(+) create mode 100644 server/config-store.test.ts diff --git a/server/config-store.test.ts b/server/config-store.test.ts new file mode 100644 index 0000000..699ea75 --- /dev/null +++ b/server/config-store.test.ts @@ -0,0 +1,168 @@ +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, listBackups, readConfig, restoreBackup } 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() + const { data, revision } = await readConfig(env) + const before = reloadCount + + 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)).toHaveLength(0) + }) + + 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) + 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('备份最多留 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') + }) +})