Files
Notes/tests/unit/settings-schema.test.js
2026-09-12 14:15:26 +08:00

447 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Stage 7 tests: shared/settings-schema.js
//
// 覆盖:
// - DEFAULT_SETTINGS导出 + 含必要字段
// - coerceLoadedSettings未知字段剥除 / 已知字段合并默认值
// - validateKeyenum / boolean / number clamp / nullable-path
// - validateAndSanitize批量校验遇到非法字段提前失败
//
// shared/ 是 CommonJS直接 require 即可(不像 main/ 需要 _setApp 注入)。
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const SCHEMA_PATH = resolve(__dirname, '../../shared/settings-schema.js');
const schema = require('../../shared/settings-schema.js');
const {
DEFAULT_SETTINGS,
coerceLoadedSettings,
validateKey,
validateAndSanitize,
SETTINGS_SCHEMA,
} = schema;
describe('DEFAULT_SETTINGS', () => {
it('导出且包含核心字段', () => {
expect(DEFAULT_SETTINGS).toBeDefined();
expect(DEFAULT_SETTINGS.theme).toBeDefined();
expect(DEFAULT_SETTINGS.themePalette).toBeDefined();
expect(DEFAULT_SETTINGS.dataDir).toBeDefined();
expect(DEFAULT_SETTINGS.alwaysOnTop).toBe(false);
});
it('AI 默认值provider = openaibaseUrl / apiKey / model / systemPrompt 空串', () => {
expect(DEFAULT_SETTINGS.aiProvider).toBe('openai');
expect(DEFAULT_SETTINGS.aiBaseUrl).toBe('');
expect(DEFAULT_SETTINGS.aiApiKey).toBe('');
expect(DEFAULT_SETTINGS.aiModel).toBe('');
expect(DEFAULT_SETTINGS.aiSystemPrompt).toBe('');
});
});
describe('coerceLoadedSettings', () => {
it('非对象 → 默认值', () => {
expect(coerceLoadedSettings(null)).toEqual({ ...DEFAULT_SETTINGS });
expect(coerceLoadedSettings('str')).toEqual({ ...DEFAULT_SETTINGS });
});
it('已知字段被 raw 覆盖', () => {
const out = coerceLoadedSettings({ theme: 'light', readerFontSize: 19 });
expect(out.theme).toBe('light');
expect(out.readerFontSize).toBe(19);
});
it('未知字段被剥除', () => {
const out = coerceLoadedSettings({ theme: 'light', bogus: 'x' });
expect(out.theme).toBe('light');
expect(out).not.toHaveProperty('bogus');
});
it('缺省字段用默认值', () => {
const out = coerceLoadedSettings({ theme: 'light' });
expect(out.theme).toBe('light');
expect(out.themePalette).toBe(DEFAULT_SETTINGS.themePalette);
});
it('旧 autoSaveIntervalSec (0) → 迁移到 autoSaveDebounceMs (0)', () => {
// 旧版 0 = 关闭;新版 0 = 关闭 —— 直接 1:1 映射
const out = coerceLoadedSettings({ autoSaveIntervalSec: 0 });
expect(out.autoSaveDebounceMs).toBe(0);
expect(out).not.toHaveProperty('autoSaveIntervalSec');
});
it('旧 autoSaveIntervalSec (3 / 10) → 迁移到 autoSaveDebounceMs (500)', () => {
// 旧版「开启」档3 秒、10 秒轮询)→ 新版「500ms 防抖」
// 语义从「每隔 X 秒轮询」变成「停打 X ms 后保存」;统一映射到 500ms 默认值。
expect(coerceLoadedSettings({ autoSaveIntervalSec: 3 }).autoSaveDebounceMs).toBe(500);
expect(coerceLoadedSettings({ autoSaveIntervalSec: 10 }).autoSaveDebounceMs).toBe(500);
});
it('autoSaveDebounceMs 越界 → clamp 到边界', () => {
// 验证 number 类型规则生效:超界值被 clamp不被丢弃
expect(coerceLoadedSettings({ autoSaveDebounceMs: 99999 }).autoSaveDebounceMs).toBe(60000);
expect(coerceLoadedSettings({ autoSaveDebounceMs: -100 }).autoSaveDebounceMs).toBe(0);
});
it('string 字段超长 → 丢弃回默认(同步路径用 rule.max', () => {
// 同步路径coerceLoadedSettings / sanitizeSync一直用 rule.max本次 audit
// 修复的是 async validateKey 误写 opts.max 的问题。两条路径必须保持一致。
const out = coerceLoadedSettings({ aiModel: 'a'.repeat(10_000) });
expect(out.aiModel).toBe(DEFAULT_SETTINGS.aiModel);
});
it('Phase N 修复:下划线前缀的元数据键(如 _hasAiKey保留', () => {
// 主进程 get-settings / save-settings 都会注入 _hasAiKey 作为「是否配置过 API Key」
// 的只读元数据标记。旧实现只迭代 Object.keys(DEFAULT_SETTINGS),未知键全 drop
// 导致 renderer settingsStore.load() 后拿不到 _hasAiKey「显示已填 Key」+ reveal
// 流程全失效。下划线前缀约定为「只读元数据、不写盘」coerce 阶段需保留。
const out = coerceLoadedSettings({ theme: 'dark', _hasAiKey: true });
expect(out.theme).toBe('dark');
expect(out._hasAiKey).toBe(true);
});
it('非下划线前缀的未知键仍被剥除(确保下划线特判不会过度放行)', () => {
// 防御:下划线特判只放行 _ 前缀,普通 unknown 字段仍走原剥离路径,
// 不让攻击者用 _privateName 这种「看上去像元数据」的字段把脏数据灌进内存。
const out = coerceLoadedSettings({ theme: 'dark', _privateNote: 'secret', bogus: 42 });
expect(out).not.toHaveProperty('_privateNote');
expect(out).not.toHaveProperty('bogus');
});
// fix(audit 2026-08)同步路径sanitizeSync / coerceLoadedSettings之前
// 不校验 format 字段,导致手改 settings.json 写 aiBaseUrl: "ftp://x" /
// "not-a-url" 会被静默接受,渲染端拿到无效 URL真正 fetch 时才报 TypeError
// 错误链很难定位到 settings。
it('aiBaseUrl 非法 URL同步路径 → 丢弃回默认空串', () => {
expect(coerceLoadedSettings({ aiBaseUrl: 'ftp://x.y' }).aiBaseUrl).toBe('');
expect(coerceLoadedSettings({ aiBaseUrl: 'not-a-url' }).aiBaseUrl).toBe('');
expect(coerceLoadedSettings({ aiBaseUrl: 'javascript:alert(1)' }).aiBaseUrl).toBe('');
// http:/missing-slash.com —— URL 构造器能解析但 fetch 行为不一致,必须拒绝
expect(coerceLoadedSettings({ aiBaseUrl: 'http:/missing-slash.com' }).aiBaseUrl).toBe('');
});
it('aiBaseUrl 合法 http(s) URL同步路径 → 保留', () => {
expect(coerceLoadedSettings({ aiBaseUrl: 'https://api.openai.com/v1' }).aiBaseUrl).toBe(
'https://api.openai.com/v1',
);
expect(coerceLoadedSettings({ aiBaseUrl: 'http://10.0.0.1:11434/v1' }).aiBaseUrl).toBe(
'http://10.0.0.1:11434/v1',
);
});
// fix(audit 2026-08)Boolean 输入不应被 Number() 强转。
// Number(true) === 1、Number(false) === 0旧版会让 autoSaveDebounceMs: true
// 静默变 1ms激进到每个 keystroke 都存splitRatio: false 变 0无预览
it('number 字段 Boolean 输入 → 丢弃回默认', () => {
expect(coerceLoadedSettings({ autoSaveDebounceMs: true }).autoSaveDebounceMs)
.toBe(DEFAULT_SETTINGS.autoSaveDebounceMs);
expect(coerceLoadedSettings({ splitRatio: false }).splitRatio)
.toBe(DEFAULT_SETTINGS.splitRatio);
expect(coerceLoadedSettings({ readerFontSize: true }).readerFontSize)
.toBe(DEFAULT_SETTINGS.readerFontSize);
});
// fix(audit 2026-08)round:1 会把 1.85 静默四舍五入到 1.9。UI 选项列出
// 的是 [1.5, 1.7, 1.85, 2.0] 两位小数round:2 才能保留用户的选择。
it('readerLineHeight 1.85 不会被四舍五入到 1.9', () => {
expect(coerceLoadedSettings({ readerLineHeight: 1.85 }).readerLineHeight).toBe(1.85);
expect(coerceLoadedSettings({ readerLineHeight: 1.7 }).readerLineHeight).toBe(1.7);
expect(coerceLoadedSettings({ readerLineHeight: 2.0 }).readerLineHeight).toBe(2.0);
});
});
describe('validateKey', () => {
it('enum 合法值 → ok', async () => {
const r = await validateKey('theme', 'light');
expect(r.ok).toBe(true);
expect(r.value).toBe('light');
});
it('enum 非法值 → 报错', async () => {
const r = await validateKey('theme', 'rainbow');
expect(r.ok).toBe(false);
expect(r.error).toMatch(/theme 取值非法/);
});
it('boolean 非法类型 → 报错', async () => {
const r = await validateKey('alwaysOnTop', 'yes');
expect(r.ok).toBe(false);
});
it('number clamp 越界 → clamp 到边界', async () => {
const tooBig = await validateKey('readerFontSize', 99);
expect(tooBig.ok).toBe(true);
expect(tooBig.value).toBe(SETTINGS_SCHEMA.readerFontSize.max);
const tooSmall = await validateKey('readerFontSize', 1);
expect(tooSmall.ok).toBe(true);
expect(tooSmall.value).toBe(SETTINGS_SCHEMA.readerFontSize.min);
});
it('number 非数字 → 报错', async () => {
const r = await validateKey('readerFontSize', 'big');
expect(r.ok).toBe(false);
});
it('nullable-path 空字符串 → null', async () => {
const r = await validateKey('dataDir', ' ');
expect(r.ok).toBe(true);
expect(r.value).toBe(null);
});
// audit fix (Round 9)nullable-number 与 number 分支对称拒绝空串。
// 旧版 `case 'nullable-number'` 直接 `Number(raw)` → Number('') === 0 →
// clamp 到 minsidebarWidth='' 会被静默改成 200默认 min
it('nullable-number 空字符串 → 报错(不是 0', async () => {
const r = await validateKey('sidebarWidth', '');
expect(r.ok).toBe(false);
});
it('nullable-number 全空白字符串 → 报错', async () => {
const r = await validateKey('sidebarWidth', ' ');
expect(r.ok).toBe(false);
});
it('nullable-number null → null合法', async () => {
const r = await validateKey('sidebarWidth', null);
expect(r.ok).toBe(true);
expect(r.value).toBe(null);
});
it('nullable-number undefined → 视为 null与同步路径一致', async () => {
// 审计修复 (Round 11 deep-fix P2-3)nullable 字段的 undefined 与 null 同义。
// 旧版 async 路径把 undefined 当作错误(与同步 sanitizeSync 行为不一致),
// 导致 load() 接受 + update() 拒绝同一字段。
const r = await validateKey('sidebarWidth', undefined);
expect(r.ok).toBe(true);
expect(r.value).toBeNull();
});
it('nullable-path 非字符串 → 报错', async () => {
const r = await validateKey('dataDir', 123);
expect(r.ok).toBe(false);
});
it('未知字段 → 报错', async () => {
const r = await validateKey('unknown', 'anything');
expect(r.ok).toBe(false);
expect(r.error).toMatch(/未知设置项/);
});
it('autoSaveDebounceMs 合法值 0 / 500 / 60000', async () => {
expect((await validateKey('autoSaveDebounceMs', 0)).ok).toBe(true);
expect((await validateKey('autoSaveDebounceMs', 500)).ok).toBe(true);
expect((await validateKey('autoSaveDebounceMs', 60000)).ok).toBe(true);
});
it('autoSaveDebounceMs 越界 → clamp', async () => {
// -1 → 099999 → 60000clamp 行为)
expect((await validateKey('autoSaveDebounceMs', -1)).value).toBe(0);
expect((await validateKey('autoSaveDebounceMs', 99999)).value).toBe(60000);
});
it('autoSaveDebounceMs 非数字 → 报错', async () => {
const r = await validateKey('autoSaveDebounceMs', 'soon');
expect(r.ok).toBe(false);
});
it('aiProvider openai / anthropic → ok', async () => {
expect((await validateKey('aiProvider', 'openai')).value).toBe('openai');
expect((await validateKey('aiProvider', 'anthropic')).value).toBe('anthropic');
});
it('aiProvider 非法值 → 报错', async () => {
const r = await validateKey('aiProvider', 'gemini');
expect(r.ok).toBe(false);
expect(r.error).toMatch(/aiProvider 取值非法/);
});
it('string 字段超过 rule.max → 报错', async () => {
// audit fix之前误用 opts.max导致 aiApiKey/aiBaseUrl/aiModel/aiSystemPrompt
// 声明的字符上限从未生效。这里锁定 rule.max 路径。
expect((await validateKey('aiModel', 'a'.repeat(257))).ok).toBe(false);
expect((await validateKey('aiBaseUrl', 'a'.repeat(4097))).ok).toBe(false);
expect((await validateKey('aiApiKey', 'k'.repeat(4097))).ok).toBe(false);
expect((await validateKey('aiSystemPrompt', 'x'.repeat(200_001))).ok).toBe(false);
});
it('string 字段在 rule.max 范围内 → ok', async () => {
expect((await validateKey('aiModel', 'a'.repeat(256))).ok).toBe(true);
// aiBaseUrl 现在带 url-https 格式校验 —— 用合法 URL 串代替 'a'.repeat
// 否则 URL 校验会拒绝非 URL 字符。最大长度限制的覆盖由上面的「超长」
// 测试用例保证。12 字符 scheme/host + 4084 字符 path = 4096 总长。
expect((await validateKey('aiBaseUrl', 'https://a.aa/' + 'a'.repeat(4083))).ok).toBe(true);
expect((await validateKey('aiApiKey', 'k'.repeat(4096))).ok).toBe(true);
expect((await validateKey('aiSystemPrompt', 'x'.repeat(200_000))).ok).toBe(true);
});
it('string 字段超长错误信息含上限值', async () => {
const r = await validateKey('aiModel', 'a'.repeat(257));
expect(r.error).toMatch(/超过 256 字符/);
});
});
describe('validateAndSanitize', () => {
it('合法 batch → 返回 sanitized 对象', async () => {
const r = await validateAndSanitize({ theme: 'light', readerFontSize: 18 });
expect(r.ok).toBe(true);
expect(r.sanitized.theme).toBe('light');
expect(r.sanitized.readerFontSize).toBe(18);
});
it('任一字段非法 → 整个失败', async () => {
const r = await validateAndSanitize({ theme: 'light', readerFontSize: 'huge' });
expect(r.ok).toBe(false);
});
it('非对象 → 报错', async () => {
expect((await validateAndSanitize(null)).ok).toBe(false);
expect((await validateAndSanitize('str')).ok).toBe(false);
expect((await validateAndSanitize([])).ok).toBe(false);
});
it('空对象 → 空 sanitized', async () => {
const r = await validateAndSanitize({});
expect(r.ok).toBe(true);
expect(r.sanitized).toEqual({});
});
});
// ------------------------------------------------------------------
// AI 字段的「空串是合法值」契约
//
// settings-dialog.js:303-326「清空 AI 配置」按钮依赖此契约:按钮设
// aiKeyInput.value = '',保存时 diff 检测 '' !== initial.aiApiKey → patch 含
// aiApiKey: '',走 settingsStore.update → settings-schema 校验 → 写盘。
// 若未来 schema 把 '' 改成 null / undefined 或拒绝空串,清空按钮会无声失效。
// ------------------------------------------------------------------
describe('AI 字段空串是合法值(清空按钮依赖此契约)', () => {
it('aiApiKey: "" → 接受且保留为空串', async () => {
const r = await validateAndSanitize({ aiApiKey: '' });
expect(r.ok).toBe(true);
expect(r.sanitized.aiApiKey).toBe('');
});
it('aiBaseUrl / aiModel / aiSystemPrompt 同时清空 → 全部接受', async () => {
const r = await validateAndSanitize({
aiApiKey: '',
aiBaseUrl: '',
aiModel: '',
aiSystemPrompt: '',
});
expect(r.ok).toBe(true);
expect(r.sanitized.aiApiKey).toBe('');
expect(r.sanitized.aiBaseUrl).toBe('');
expect(r.sanitized.aiModel).toBe('');
expect(r.sanitized.aiSystemPrompt).toBe('');
});
it('aiApiKey: 任意非空串 → 接受且原样保留', async () => {
const r = await validateAndSanitize({ aiApiKey: 'sk-abc-123' });
expect(r.ok).toBe(true);
expect(r.sanitized.aiApiKey).toBe('sk-abc-123');
});
});
// ------------------------------------------------------------------
// nullable-string 类型支持
//
// audit fixvalidateKey / sanitizeSync 的 switch 之前没有 nullable-string
// case遇到该类型的字符串值会落到 default → 「类型未定义」错误。
// 当前 schema 没有 nullable-string 字段aiApiKey 等用 string + 主进程判空),
// 这里用代码存在性测试锁定修复,避免未来回归。
// ------------------------------------------------------------------
describe('nullable-string 类型支持audit fix', () => {
const source = readFileSync(SCHEMA_PATH, 'utf8');
it('validateKey 的 switch 含 nullable-string case', () => {
// 提取 validateKey 函数体的 switch 段,断言其中含 case 'nullable-string'
const match = source.match(/async function validateKey[\s\S]*?^}/m);
expect(match, 'validateKey 函数必须存在').toBeTruthy();
expect(match[0]).toMatch(/case\s+['"]nullable-string['"]\s*:/);
});
it('sanitizeSync 的 switch 含 nullable-string case', () => {
// sanitizeSync 没有 async/function 关键字前缀 —— 直接匹配函数签名
const match = source.match(/function sanitizeSync[\s\S]*?^}/m);
expect(match, 'sanitizeSync 函数必须存在').toBeTruthy();
expect(match[0]).toMatch(/case\s+['"]nullable-string['"]\s*:/);
});
it('nullable-string 校验含 max 长度检查(与 string 一致)', () => {
const validateMatch = source.match(/async function validateKey[\s\S]*?^}/m);
expect(validateMatch).toBeTruthy();
// nullable-string case 内必须包含 rule.max 长度检查
const nullableCase = validateMatch[0].match(/case\s+['"]nullable-string['"]\s*:[\s\S]*?(?=\n\s+case\s|\n\s+default|\n\s+\})/);
expect(nullableCase, 'validateKey 必须有 nullable-string case 体').toBeTruthy();
expect(nullableCase[0]).toMatch(/rule\.max/);
});
});
// ------------------------------------------------------------------
// Round 4 P1-1nullable-path 同步路径空串归一为 null
//
// 之前 sanitizeSync 直接返回 '',与 validateKey 异步路径归一为 null 不同步。
// coerceLoadedSettings 把磁盘上残留的 "dataDir": "" 保留为 '',但 validateKey
// 会归一为 null → 任何依赖 dataDir === null 判断的代码失配。
// ------------------------------------------------------------------
describe('nullable-path 同步路径空串归一为 nullRound 4 P1-1', () => {
it('coerceLoadedSettings 把 dataDir: "" 归一为 null', () => {
const result = coerceLoadedSettings({ ...DEFAULT_SETTINGS, dataDir: '' });
expect(result.dataDir).toBe(null);
});
it('coerceLoadedSettings 把 dataDir: " "(纯空白)归一为 null', () => {
const result = coerceLoadedSettings({ ...DEFAULT_SETTINGS, dataDir: ' ' });
expect(result.dataDir).toBe(null);
});
it('coerceLoadedSettings 把 dataDir: "/some/path" 原样保留', () => {
const result = coerceLoadedSettings({ ...DEFAULT_SETTINGS, dataDir: '/some/path' });
expect(result.dataDir).toBe('/some/path');
});
it('dataDir: null 仍是 null不破坏既有契约', () => {
const result = coerceLoadedSettings({ ...DEFAULT_SETTINGS, dataDir: null });
expect(result.dataDir).toBe(null);
});
});
// ------------------------------------------------------------------
// Round 4 P2-3nullable-number typeof 守卫对称
//
// 原版无 typeof 守卫Number(true) === 1 隐式通过 isFinite。
// 当前 schema 用 nullable-number 的字段sidebarWidth / aiWidthmin 检查
// 会拦下 1但语义与 number 不一致 —— 加 typeof 守卫保持两条路径对称。
// ------------------------------------------------------------------
describe('nullable-number typeof 守卫对称Round 4 P2-3', () => {
it('sidebarWidth: true → 拒绝typeof 不匹配)', () => {
const r = coerceLoadedSettings({ ...DEFAULT_SETTINGS, sidebarWidth: true });
// sidebarWidth 不在 coerce 结果里(或保留 default绝不能是 1
expect(r.sidebarWidth).not.toBe(1);
});
it('sidebarWidth: false → 拒绝', () => {
const r = coerceLoadedSettings({ ...DEFAULT_SETTINGS, sidebarWidth: false });
expect(r.sidebarWidth).not.toBe(0);
});
it('sidebarWidth: 250 → 接受', () => {
const r = coerceLoadedSettings({ ...DEFAULT_SETTINGS, sidebarWidth: 250 });
expect(r.sidebarWidth).toBe(250);
});
it('sidebarWidth: "300" → 接受(字符串数字合法)', () => {
const r = coerceLoadedSettings({ ...DEFAULT_SETTINGS, sidebarWidth: '300' });
expect(r.sidebarWidth).toBe(300);
});
it('sidebarWidth: null → 仍是 null', () => {
const r = coerceLoadedSettings({ ...DEFAULT_SETTINGS, sidebarWidth: null });
expect(r.sidebarWidth).toBe(null);
});
});