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() // 先落一次盘让文件带上 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 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') }) })