This commit is contained in:
2026-09-12 13:55:57 +08:00
commit 30f1cedd39
95 changed files with 42057 additions and 0 deletions

299
scripts/check-backup.mjs Normal file
View File

@@ -0,0 +1,299 @@
import { check, ok, bad, summary, printSummary } from './_lib/check.mjs';
import { makeStore } from './_lib/store-fixture.mjs';
import { installApiStub } from './_lib/api-stub.mjs';
// 每日备份开关 + SettingsStore 输入校验的回归测试。
//
// 触发 bug 的姿势与 check-settings.mjs 同源 —— 脏值穿过校验污染内存 + 配置文件:
//
// 1. update({ backupEnabled: 'true' })
// 旧实现下 theme / filter / sortBy / fontSize 走 VALID_VALUES 字符串枚举,
// 唯独 backupEnabled 是 boolean —— 如果 isValidValue 没单独分支处理,
// 字符串 'true' 就直接被吞掉变成 settings.backupEnabled === 'true'
// 下次保存把脏值固化进 config.json。
//
// 2. 用户手工编辑 config.json写成 backupEnabled: 1
// 加载端只校验 key 是否在 DEFAULT_SETTINGS 里 —— 数字 1 会进内存,
// 后续 update / persist 把它当合法值反复写入。
//
// 这份脚本同样构造 SettingsStore + 桩 api验证以上姿势都被拦截
// 并补一个 happy-path 验证持久化走的字段lastBackupDate不在
// DEFAULT_SETTINGS 里 —— 主进程直接通过 saveConfig() 写入,
// 不应该被渲染进程的 SettingsStore.update 路径覆盖。
installApiStub();
const { SettingsStore } = await import('../src/settings-store.js');
// ============================================
// 默认值
// ============================================
console.log('[1] 默认值');
{
const s = new SettingsStore();
if (s.get('backupEnabled') === true) ok('backupEnabled 默认值是 true开箱即用');
else bad('backupEnabled 默认值错误', `当前 ${JSON.stringify(s.get('backupEnabled'))}`);
}
// ============================================
// update() 输入校验
// ============================================
console.log('\n[2] update() 拒绝非 boolean 值');
// 默认是 true —— 任何非法输入必须被拒绝,且不污染内存(保持默认 true
{
const s = new SettingsStore();
await s.update({ backupEnabled: 'true' });
if (s.get('backupEnabled') === true) ok("update({backupEnabled: 'true'}) 字符串被拒绝,默认 true 未被污染");
else bad("backupEnabled 被字符串 'true' 污染", `当前 ${JSON.stringify(s.get('backupEnabled'))}`);
}
{
const s = new SettingsStore();
await s.update({ backupEnabled: 1 });
if (s.get('backupEnabled') === true) ok('update({backupEnabled: 1}) 数字被拒绝,默认 true 未被污染');
else bad('backupEnabled 被数字污染', `当前 ${JSON.stringify(s.get('backupEnabled'))}`);
}
{
const s = new SettingsStore();
await s.update({ backupEnabled: null });
if (s.get('backupEnabled') === true) ok('update({backupEnabled: null}) null 被拒绝,默认 true 未被污染');
else bad('backupEnabled 被 null 污染', `当前 ${JSON.stringify(s.get('backupEnabled'))}`);
}
{
const s = new SettingsStore();
await s.update({ backupEnabled: undefined });
if (s.get('backupEnabled') === true) ok('update({backupEnabled: undefined}) undefined 被拒绝,默认 true 未被污染');
else bad('backupEnabled 被 undefined 污染', `当前 ${JSON.stringify(s.get('backupEnabled'))}`);
}
{
const s = new SettingsStore();
await s.update({ backupEnabled: { evil: true } });
if (s.get('backupEnabled') === true) ok('update({backupEnabled: object}) 对象被拒绝,默认 true 未被污染');
else bad('backupEnabled 被对象污染', `当前 ${JSON.stringify(s.get('backupEnabled'))}`);
}
// ============================================
// update() happy path —— 默认 true所以「设 false」才有持久化意义
// ============================================
console.log('\n[3] update() 接受合法 boolean');
{
const s = new SettingsStore();
// 先切到 false 再切回 true默认 true 直接发 true 会因「值未变」返回 false
await s.update({ backupEnabled: false });
const result = await s.update({ backupEnabled: true });
if (s.get('backupEnabled') === true && result === true) {
ok('update({backupEnabled: true}) 从 false 切回 true 生效');
} else {
bad('合法 true 未生效', JSON.stringify({ value: s.get('backupEnabled'), result }));
}
}
{
const s = new SettingsStore();
await s.update({ backupEnabled: true });
const result = await s.update({ backupEnabled: false });
if (s.get('backupEnabled') === false && result === true) {
ok('update({backupEnabled: false}) 从 true 切回 false 生效');
} else {
bad('切回 false 未生效', JSON.stringify({ value: s.get('backupEnabled'), result }));
}
}
{
const s = new SettingsStore();
// 同样的值再发一次update() 应该返回 false不触发持久化节省 IO
await s.update({ backupEnabled: true });
const result = await s.update({ backupEnabled: true });
if (result === false) ok('update({backupEnabled: true}) 同值再发返回 false');
else bad('同值再发仍触发持久化', `返回 ${result}`);
}
// ============================================
// load() 输入校验 —— 模拟配置文件被外部改成脏值
// ============================================
console.log('\n[4] load() 从脏配置恢复默认值');
{
const s = new SettingsStore();
const orig = globalThis.window.api.getSettings;
globalThis.window.api.getSettings = async () => ({
backupEnabled: 1, // 数字:必须被拒绝(回退到默认 true
theme: 'light', // 合法值:应保留
fontSize: 'huge' // 非法字符串:应回退到 medium
});
try {
await s.load();
const cur = s.all;
if (cur.backupEnabled === true && cur.theme === 'light' && cur.fontSize === 'medium') {
ok('脏 backupEnabled 回退到默认 true合法 theme 保留;非法 fontSize 回退');
} else {
bad('load 污染或误伤', JSON.stringify(cur));
}
} finally {
globalThis.window.api.getSettings = orig;
}
}
{
const s = new SettingsStore();
const orig = globalThis.window.api.getSettings;
globalThis.window.api.getSettings = async () => ({
backupEnabled: true,
lastBackupDate: '2026-08-22' // 主进程维护的字段,不该被 load 路径意外处理
});
try {
await s.load();
const cur = s.all;
if (cur.backupEnabled === true) {
ok('合法 backupEnabled: true 被加载');
} else {
bad('backupEnabled: true 未生效', JSON.stringify(cur));
}
// lastBackupDate 不在 DEFAULT_SETTINGS 里 —— 即便持久化文件里有load 也不该把它
// 暴露到 settings 对象上(主进程直接从 appConfig 读,不依赖 SettingsStore
if (cur.lastBackupDate === undefined) {
ok('lastBackupDate 未泄漏到 settings 对象(保留主进程独立访问)');
} else {
bad('lastBackupDate 泄漏到 settings', JSON.stringify(cur));
}
} finally {
globalThis.window.api.getSettings = orig;
}
}
// ============================================
// update() 字段白名单 —— 未知字段被忽略
// ============================================
console.log('\n[5] 未知字段被忽略');
{
const s = new SettingsStore();
await s.update({ lastBackupDate: '2026-08-22', unknownField: 'x' });
if (s.get('lastBackupDate') === undefined && s.get('unknownField') === undefined) {
ok('update({lastBackupDate, unknownField}) 都被忽略');
} else {
bad('未知字段被写入', JSON.stringify({ lastBackupDate: s.get('lastBackupDate'), unknownField: s.get('unknownField') }));
}
}
// ============================================
// update() 与其它字段联动 —— 模拟设置对话框「完成」一次性提交多字段
// ============================================
console.log('\n[6] 与其它字段联动');
{
const s = new SettingsStore();
// 同时改 backupEnabled + theme + fontSize模拟对话框 finish() 把多个 pending 值打包提交
const result = await s.update({ backupEnabled: true, theme: 'light', fontSize: 'large' });
const cur = s.all;
if (result === true &&
cur.backupEnabled === true &&
cur.theme === 'light' &&
cur.fontSize === 'large') {
ok('update({backupEnabled, theme, fontSize}) 多字段同时生效');
} else {
bad('多字段联动异常', JSON.stringify(cur));
}
}
{
const s = new SettingsStore();
// 与非法字段混合:合法字段应生效,非法字段应被丢弃
await s.update({ backupEnabled: true, theme: 'rainbow', fontSize: 'huge' });
const cur = s.all;
if (cur.backupEnabled === true && cur.theme === 'dark' && cur.fontSize === 'medium') {
ok('合法 backupEnabled 生效;非法 theme/fontSize 回退到默认');
} else {
bad('联动失败', JSON.stringify(cur));
}
}
// ============================================
// change 事件 —— 验证 update 触发的事件包含正确字段列表
// ============================================
console.log('\n[7] change 事件');
{
const s = new SettingsStore();
let captured = null;
s.on('change', (e) => { captured = e; });
// 默认 true —— 先切到 false默认值改变 → 必触发 change才能验证事件字段
await s.update({ backupEnabled: false });
captured = null; // 清掉上一次的事件
await s.update({ backupEnabled: true });
if (captured && Array.isArray(captured.changed) &&
captured.changed.length === 1 && captured.changed[0] === 'backupEnabled') {
ok('change 事件 reported backupEnabled');
} else {
bad('change 事件字段错误', JSON.stringify(captured));
}
}
{
const s = new SettingsStore();
let captured = null;
s.on('change', (e) => { captured = e; });
// 全部非法 → updates 为空 → 不触发 change 事件
await s.update({ backupEnabled: 'true', backupEnabled_evil: 1 });
if (captured === null) {
ok('全部非法时 change 事件不触发(避免无效 IO');
} else {
bad('change 事件不该触发', JSON.stringify(captured));
}
}
// ============================================
// 首次启动:主进程返回 {}config.json 不存在)—— 兜底默认 true
//
// 复现的 bug 路径loadConfig() 在磁盘上没 config.json 时返回 {}
// main 把 {} 透传给 renderer SettingsStore.load()。如果 renderer 端不
// 兜底backupEnabled 就保持 undefinedfalsy结果主进程
// maybeBackupToday 的 truthy 检查把 undefined 当成「禁用」——
// 新用户的备份机制永远不工作。
//
// 这里验的是 renderer 端的兜底DEFAULT_SETTINGS 在 load 时填充),
// main 端的兜底DEFAULT_CONFIG由 code review 保证 —— main.js 依赖
// Electron API 不能直接 import 测试。
// ============================================
console.log('\n[8] 首次启动兜底');
{
const s = new SettingsStore();
const orig = globalThis.window.api.getSettings;
globalThis.window.api.getSettings = async () => ({}); // 主进程config.json 不存在
try {
await s.load();
if (s.get('backupEnabled') === true) {
ok('主进程返回 {} 时 backupEnabled 兜底到默认 true保证备份机制激活');
} else {
bad('首次启动兜底失败', `当前 ${JSON.stringify(s.get('backupEnabled'))}`);
}
} finally {
globalThis.window.api.getSettings = orig;
}
}
{
const s = new SettingsStore();
const orig = globalThis.window.api.getSettings;
globalThis.window.api.getSettings = async () => ({ alwaysOnTop: true }); // 只有 alwaysOnTop没 backupEnabled
try {
await s.load();
if (s.get('backupEnabled') === true && s.get('alwaysOnTop') === true) {
ok('部分字段缺失时 backupEnabled 仍兜底到 true已设的 alwaysOnTop 保留');
} else {
bad('部分兜底失败', JSON.stringify({ backupEnabled: s.get('backupEnabled'), alwaysOnTop: s.get('alwaysOnTop') }));
}
} finally {
globalThis.window.api.getSettings = orig;
}
}
console.log(`\n${'─'.repeat(48)}`);
console.log(`通过 ${summary.pass} / 失败 ${summary.fail}`);
process.exit(summary.fail === 0 ? 0 : 1);