188 lines
8.5 KiB
TypeScript
188 lines
8.5 KiB
TypeScript
// tests/unit/main/settings-store-read-failure.test.ts
|
|
//
|
|
// 守卫:settings.json 的「读失败」与「解析失败」必须**分开处置**。
|
|
//
|
|
// audit r200 (2026-10-20) 修的 bug(r199 atomic-file 同一 bug 类的上层版):
|
|
// src/main/settings/store.ts 的 initSettings 旧实现把
|
|
// `readFileCapped(...)` 与 `JSON.parse(...)` 裹在**同一个 try** 里:
|
|
// catch → 非 ENOENT / 非 IO_ERROR ⇒ 一律当「文件损坏」处理
|
|
// → `fs.rename(settings.json, settings.json.corrupt-<ts>.json)`
|
|
// → loaded 保持 {} → currentSettings = DEFAULT_SETTINGS
|
|
// ⇒ 除 ENOENT / size-cap 外的**任何读错误**(EACCES 权限 / EPERM /
|
|
// EBUSY 被同步盘或杀毒占用 / EIO / EISDIR 路径其实是目录)都会:
|
|
// 1. 把用户的 settings.json **改名走**(内容可能完好,只是此刻读不到);
|
|
// 2. 让应用带着**默认设置**启动 —— 主题 / 语言 / 布局 / 导入根全丢;
|
|
// 3. 零错误提示(用户只看到「设置怎么变回默认了」)。
|
|
// 即:一次瞬时读失败 = 用户设置被重置。与本函数自己对 size-cap 的
|
|
// 策略注释「让首启崩比『静默接受 defaults 覆盖用户设置』更安全」自相矛盾。
|
|
//
|
|
// 本文件的 7 例锁住新契约:
|
|
// 1. 读失败(EACCES)→ initSettings **抛错**,原文件内容一字不动,
|
|
// **不产生 .corrupt-***,不写出新的 settings.json(最关键的一条);
|
|
// 2. 读失败(EISDIR:路径其实是目录)→ 抛错,目录原样保留,不被改名走;
|
|
// 3. 读失败 → 错误码是 SETTINGS_UNREADABLE(且该 code 在
|
|
// IPC_ERROR_CODES union 里 —— 否则 wrap.ts 会 fallback INTERNAL
|
|
// **并擦掉 message**,自救指引就传不到用户眼前);
|
|
// 4. **parse 失败(真·JSON 损坏)仍然 quarantine**(老契约不能被改坏):
|
|
// 坏文件改名保留,内容完整,不抛错;
|
|
// 5. ENOENT(首启)→ 走默认值,不产生 .corrupt-*;
|
|
// 6. 正常加载 → 用户设置(theme='dark')**原样保留**在盘上;
|
|
// 7. 读阶段的瞬时 EBUSY 会被退避重试(而不是被当成「文件损坏」)。
|
|
import { mkdtemp, rm, readFile, readdir, stat, writeFile, mkdir } from 'fs/promises';
|
|
import { promises as fsPromises } from 'fs';
|
|
import { tmpdir } from 'os';
|
|
import { join } from 'path';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
// electron mock —— store.ts 取 app.getVersion(),safe-write 取 shell,
|
|
// location 取 app.getPath('userData')。三个都在模块作用域被 import,
|
|
// mock 工厂必须把用到的那几个都摆出来(缺一个就会在 import 期炸)。
|
|
vi.mock('electron', () => ({
|
|
app: {
|
|
getVersion: () => '9.9.9-test',
|
|
getPath: () => 'C:\\fake-user-data',
|
|
getName: () => 'rkms-test',
|
|
},
|
|
shell: {
|
|
trashItem: async () => undefined,
|
|
},
|
|
}));
|
|
|
|
const { initSettings, getSettings } = await import('../../../src/main/settings/store');
|
|
const { setRootPath } = await import('../../../src/main/utils/safe-write');
|
|
|
|
/** 造一个带 errno code 的错误(模拟 EACCES / EBUSY / EISDIR) */
|
|
function fsError(code: string, msg: string): NodeJS.ErrnoException {
|
|
const e = new Error(msg) as NodeJS.ErrnoException;
|
|
e.code = code;
|
|
return e;
|
|
}
|
|
|
|
/** dataPath 下的 settings.json 路径 */
|
|
function settingsFileOf(dataPath: string): string {
|
|
return join(dataPath, 'settings.json');
|
|
}
|
|
|
|
describe('settings store — 读失败 ≠ 文件损坏(r200)', () => {
|
|
let tmpRoot: string;
|
|
let dataPath: string;
|
|
|
|
beforeEach(async () => {
|
|
tmpRoot = await mkdtemp(join(tmpdir(), 'settings-store-test-'));
|
|
dataPath = join(tmpRoot, 'data');
|
|
await mkdir(dataPath, { recursive: true });
|
|
// safe-write 的 rootPath 单例:initSettings 末尾的 saveSettings 走
|
|
// safeWriteFileAtomic → resolveInsideDataPath → getRootPath()
|
|
setRootPath(dataPath);
|
|
});
|
|
|
|
afterEach(async () => {
|
|
vi.restoreAllMocks();
|
|
await rm(tmpRoot, { recursive: true, force: true });
|
|
});
|
|
|
|
it('读失败(EACCES)→ 抛错 + 原文件内容一字不动 + 不 quarantine + 不写出新 settings.json', async () => {
|
|
const filePath = settingsFileOf(dataPath);
|
|
const original = JSON.stringify({ theme: 'dark', language: 'en-US' }, null, 2);
|
|
await writeFile(filePath, original, 'utf8');
|
|
|
|
const spy = vi
|
|
.spyOn(fsPromises, 'open')
|
|
.mockImplementationOnce(() => Promise.reject(fsError('EACCES', 'permission denied')));
|
|
|
|
await expect(initSettings(dataPath)).rejects.toThrow();
|
|
spy.mockRestore();
|
|
|
|
// 用户的设置**原封不动**留在盘上
|
|
expect(await readFile(filePath, 'utf8')).toBe(original);
|
|
// 没有被当成「损坏文件」改名走
|
|
expect((await readdir(dataPath)).filter((n) => n.includes('.corrupt-'))).toHaveLength(0);
|
|
// 没有被「用默认值写一份新的」顶掉
|
|
const siblings = await readdir(dataPath);
|
|
expect(siblings.filter((n) => n === 'settings.json')).toHaveLength(1);
|
|
});
|
|
|
|
it('读失败(EISDIR:路径其实是目录)→ 抛错 + 目录原样保留 + 不被改名走', async () => {
|
|
const filePath = settingsFileOf(dataPath);
|
|
await mkdir(filePath); // 目录占位
|
|
|
|
await expect(initSettings(dataPath)).rejects.toThrow();
|
|
|
|
// 目录还在原地,类型没变(旧实现会把它 rename 成 .corrupt-*)
|
|
expect((await stat(filePath)).isDirectory()).toBe(true);
|
|
expect((await readdir(dataPath)).filter((n) => n.includes('.corrupt-'))).toHaveLength(0);
|
|
});
|
|
|
|
it('读失败 → 错误码是 SETTINGS_UNREADABLE,且该 code 在 IPC_ERROR_CODES union 里', async () => {
|
|
const filePath = settingsFileOf(dataPath);
|
|
await writeFile(filePath, JSON.stringify({ theme: 'dark' }), 'utf8');
|
|
|
|
const spy = vi
|
|
.spyOn(fsPromises, 'open')
|
|
.mockImplementationOnce(() => Promise.reject(fsError('EACCES', 'permission denied')));
|
|
|
|
const err = await initSettings(dataPath).then(
|
|
() => null,
|
|
(e: unknown) => e,
|
|
);
|
|
spy.mockRestore();
|
|
|
|
expect(err).toBeInstanceOf(Error);
|
|
const code = (err as { code?: unknown }).code;
|
|
expect(code).toBe('SETTINGS_UNREADABLE');
|
|
// union 里的 code 才有意义:wrap.ts 对 union 外的 code 会 fallback
|
|
// INTERNAL **并擦掉 message** —— 那条自救指引就传不到用户眼前
|
|
const { IPC_ERROR_CODES } = await import('../../../src/shared/types');
|
|
expect(IPC_ERROR_CODES as readonly string[]).toContain('SETTINGS_UNREADABLE');
|
|
// 消息要能指导用户自救(关掉占用它的程序 / 修权限),而不是「删掉文件」
|
|
expect((err as Error).message).toMatch(/left untouched/i);
|
|
});
|
|
|
|
it('parse 失败(真·JSON 损坏)仍然 quarantine(老契约不能被改坏):坏文件改名保留,不抛错', async () => {
|
|
const filePath = settingsFileOf(dataPath);
|
|
const garbage = '{ this is not json';
|
|
await writeFile(filePath, garbage, 'utf8');
|
|
|
|
// parse 失败**不抛错**(启动继续),而是 quarantine + 走默认值
|
|
await expect(initSettings(dataPath)).resolves.toBeDefined();
|
|
|
|
const siblings = await readdir(dataPath);
|
|
const quarantined = siblings.filter((n) => n.startsWith('settings.json.corrupt-'));
|
|
expect(quarantined).toHaveLength(1);
|
|
// 原件已被改名走,内容完整保留在 .corrupt-* 里
|
|
expect(await readFile(join(dataPath, quarantined[0]), 'utf8')).toBe(garbage);
|
|
});
|
|
|
|
it('ENOENT(首启)→ 走默认值,不产生 .corrupt-*', async () => {
|
|
await expect(initSettings(dataPath)).resolves.toBeDefined();
|
|
expect((await readdir(dataPath)).filter((n) => n.includes('.corrupt-'))).toHaveLength(0);
|
|
});
|
|
|
|
it('正常加载 → 用户设置(theme)原样保留在盘上', async () => {
|
|
const filePath = settingsFileOf(dataPath);
|
|
await writeFile(filePath, JSON.stringify({ theme: 'dark' }, null, 2), 'utf8');
|
|
|
|
await initSettings(dataPath);
|
|
|
|
expect(getSettings().theme).toBe('dark');
|
|
const onDisk = JSON.parse(await readFile(filePath, 'utf8')) as { theme?: string };
|
|
expect(onDisk.theme).toBe('dark');
|
|
expect((await readdir(dataPath)).filter((n) => n.includes('.corrupt-'))).toHaveLength(0);
|
|
});
|
|
|
|
it('读阶段的瞬时 EBUSY 被退避重试(而不是被当成「文件损坏」)', async () => {
|
|
const filePath = settingsFileOf(dataPath);
|
|
await writeFile(filePath, JSON.stringify({ theme: 'dark' }), 'utf8');
|
|
|
|
const spy = vi
|
|
.spyOn(fsPromises, 'open')
|
|
.mockImplementationOnce(() => Promise.reject(fsError('EBUSY', 'file is busy')));
|
|
|
|
await expect(initSettings(dataPath)).resolves.toBeDefined();
|
|
spy.mockRestore();
|
|
|
|
expect(getSettings().theme).toBe('dark');
|
|
expect((await readdir(dataPath)).filter((n) => n.includes('.corrupt-'))).toHaveLength(0);
|
|
});
|
|
});
|