feat: v0.7.4 时序语义修正 · 防线实效补漏 · 全量测试翻倍 — 2406 用例 + jsdom 组件测试全量回归
P1 修复面收口: - 超时三态区分(aborted→USER_INTERRUPT / ETIMEDOUT→TIMEOUT / 其余→ERROR), 根治"真实网络超时被误报为用户中断" - 流空闲超时统一(SSE/Ollama/Anthropic 读循环 60s 无数据抛 504 进重试通道) - 同会话并发 sendMessage 防重入(isRunning 守卫)+ 会话存在性预检 + 前置调用移入 try(ERROR+DONE 双事件保证,根治 isStreaming 假死) - 清空审计后 resetChainCache(根治 verifyChain 误报 TAMPERED) - DONE 不再提前清理 TRACE(TERMINATED 统一收尾,补全最终迭代录制) - IME 合成回车不发送(普通 Enter + Cmd/Ctrl+Enter 双分支)+ handleSend 闭包修复 P2 安全纵深: - preload 移除原始 electronAPI 暴露(渲染层零使用,关掉 XSS invoke 任意通道单点风险) - CORS 同源回显根治(仅当前浏览页面 Origin,did-navigate 同步) - MEMORY.md 命令保护正则扩展(括号/$/反引号/< 重定向边界 + 前导路径) - write_file append TOCTOU 统一(open 后 realpath 校验,新文件分支补漏) - 敏感键归一化(authKey 驼峰/连字符命中)+ MCP headers 鉴权值加密落库 - ReDoS 检测共享化(search_files/file_editor 统一拦截) - run_tests/lint_code 升风险 + 需确认 + npx --no-install(执行边界对齐 run_command) - MCP/SearXNG/llm.baseURL/updateFeedUrl 配置类 URL 高危目标校验(IPv6 去括号 + 十六进制映射解析 + 尾点剥离) P3 架构还债: - temperature/maxTokens 热生效(引擎/编排器/SubAgent 三处接线)+ setBatch 单事务落盘 - SessionRecorder flush 竞态根治(flushPromise 等待 + 超限内联落盘 + stopRecording async) - 内存收口(lastConsolidationBySession LRU / subTraces 清理 / 会话删除 disposeEngine) - i18n 全量收口(28 组件 + 353 key 双字典,状态标签改渲染时函数) - 死代码清理(updateTraceStep/HEADER_HEIGHT/void preA/失实注释) - 斜杠菜单 MUI 化 + 删除逻辑收敛 resetSessionState + Blob URL 统一释放 + 用户消息"仅保存"落库(saveMessage 透传前端 id 修复 id 错位) P4 能力演进: - 死循环检测拆分(驻留前置 + 乒乓后置带进度信号,合法交替不误报) - run-lock 30s 超时强制 abort(旧 run 卡死不无限排队) - RETRY 双通道 stream_reset(前端按 run 归属精确清空,根治重试文本重复) - FTS5 trigram 中文子串搜索(迁移 9 版本化 SCHEMA_VERSION=2,≤2 字符 LIKE 回退) - getContextWindow 兜底 1M→128K(未知模型防 413) 测试: - 855 → 2406 用例(+1551,2.8 倍):服务层 +325(含 MemoryManager 51 新用例)、 工具实体 +483、IPC/适配器 +390(含 OpenAI/Anthropic/Ollama 独立套件)、 纯函数表格化 +330;引入 jsdom + @testing-library(14 组件测试文件 249 用例) - 修复 R1(saveMessage id 透传)/ R2(stream_reset 精确归属)两个回归缺陷 - 遗留低危项清零:git-tools 顺序耦合 / web-fetch 真实时间退避 / slo 内存断言 / mcp-security 多余 skipIf / deepseek-balance 命名误导 / 组件 mock 注入脆弱性 版本: 0.7.4; README 同步(工具风险表/版本徽章); 依赖: 移除 @electron-toolkit/preload, 新增 jsdom/@testing-library(devDependencies 不打包) 回归: typecheck 双端 0 错误; ESLint 0/0; Electron ABI 全量 2406/2406 零跳过; 系统 Node 2110 通过 296 跳过(better-sqlite3 ABI)
This commit is contained in:
@@ -14,6 +14,7 @@ const sessionMocks = vi.hoisted(() => ({
|
||||
defaultSetProxy: vi.fn(async (..._args: unknown[]) => undefined),
|
||||
partitionSetProxy: vi.fn(async (..._args: unknown[]) => undefined),
|
||||
throwOnDefaultSetProxy: false,
|
||||
throwOnPartitionSetProxy: false,
|
||||
}));
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
@@ -27,7 +28,12 @@ vi.mock('electron', () => ({
|
||||
},
|
||||
},
|
||||
fromPartition: () => ({
|
||||
setProxy: (...args: unknown[]) => sessionMocks.partitionSetProxy(...args),
|
||||
setProxy: (...args: unknown[]) => {
|
||||
if (sessionMocks.throwOnPartitionSetProxy) {
|
||||
return Promise.reject(new Error('partition setProxy exploded'));
|
||||
}
|
||||
return sessionMocks.partitionSetProxy(...args);
|
||||
},
|
||||
}),
|
||||
},
|
||||
}));
|
||||
@@ -56,12 +62,13 @@ vi.mock('undici', () => ({
|
||||
setGlobalDispatcher: (...args: unknown[]) => undiciMocks.setGlobalDispatcher(...args),
|
||||
}));
|
||||
|
||||
import { applySessionProxy } from '../network-proxy';
|
||||
import { applySessionProxy, isProxyActive } from '../network-proxy';
|
||||
|
||||
beforeEach(() => {
|
||||
sessionMocks.defaultSetProxy.mockClear();
|
||||
sessionMocks.partitionSetProxy.mockClear();
|
||||
sessionMocks.throwOnDefaultSetProxy = false;
|
||||
sessionMocks.throwOnPartitionSetProxy = false;
|
||||
undiciMocks.setGlobalDispatcher.mockClear();
|
||||
undiciMocks.proxyAgentCalls.length = 0;
|
||||
undiciMocks.agentCalls = 0;
|
||||
@@ -127,6 +134,23 @@ describe('applySessionProxy — 环境变量回退链', () => {
|
||||
proxyRules: 'http://env-fallback:1',
|
||||
});
|
||||
});
|
||||
|
||||
it('proxyUrl 与 HTTPS_PROXY 同时存在 → proxyUrl 优先(显式配置覆盖环境变量)', async () => {
|
||||
process.env['HTTPS_PROXY'] = 'http://env-ignored:3128';
|
||||
await applySessionProxy('http://explicit:8080');
|
||||
expect(sessionMocks.defaultSetProxy).toHaveBeenCalledWith({
|
||||
proxyRules: 'http://explicit:8080',
|
||||
});
|
||||
});
|
||||
|
||||
it('HTTPS_PROXY 为空串时回退 HTTP_PROXY', async () => {
|
||||
process.env['HTTPS_PROXY'] = '';
|
||||
process.env['HTTP_PROXY'] = 'http://fallback:3128';
|
||||
await applySessionProxy(null);
|
||||
expect(sessionMocks.defaultSetProxy).toHaveBeenCalledWith({
|
||||
proxyRules: 'http://fallback:3128',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('applySessionProxy — 失败语义(绝不阻断主流程)', () => {
|
||||
@@ -135,4 +159,44 @@ describe('applySessionProxy — 失败语义(绝不阻断主流程)', () =>
|
||||
await expect(applySessionProxy('http://proxy:1')).resolves.toBeUndefined();
|
||||
expect(undiciMocks.proxyAgentCalls).toEqual(['http://proxy:1']);
|
||||
});
|
||||
|
||||
it('agent-browser 分区失败 → 仅 WARN,default 与 undici 不受影响', async () => {
|
||||
sessionMocks.throwOnPartitionSetProxy = true;
|
||||
await expect(applySessionProxy('http://proxy:2')).resolves.toBeUndefined();
|
||||
expect(sessionMocks.defaultSetProxy).toHaveBeenCalledWith({
|
||||
proxyRules: 'http://proxy:2',
|
||||
});
|
||||
expect(undiciMocks.proxyAgentCalls).toEqual(['http://proxy:2']);
|
||||
});
|
||||
|
||||
it('双通道均失败 → 不抛错(resolve undefined)', async () => {
|
||||
sessionMocks.throwOnDefaultSetProxy = true;
|
||||
sessionMocks.throwOnPartitionSetProxy = true;
|
||||
await expect(applySessionProxy('http://proxy:3')).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isProxyActive — 代理激活标志(P2-1)', () => {
|
||||
it('配置代理后标志为 true', async () => {
|
||||
await applySessionProxy('http://proxy:8080');
|
||||
expect(isProxyActive()).toBe(true);
|
||||
});
|
||||
|
||||
it('回退环境变量后标志为 true', async () => {
|
||||
process.env['HTTPS_PROXY'] = 'http://env:3128';
|
||||
await applySessionProxy(null);
|
||||
expect(isProxyActive()).toBe(true);
|
||||
});
|
||||
|
||||
it('双空直连后标志为 false', async () => {
|
||||
await applySessionProxy(null);
|
||||
expect(isProxyActive()).toBe(false);
|
||||
});
|
||||
|
||||
it('先激活再切回直连 → 标志复位为 false', async () => {
|
||||
await applySessionProxy('http://proxy:1');
|
||||
expect(isProxyActive()).toBe(true);
|
||||
await applySessionProxy(null);
|
||||
expect(isProxyActive()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -67,4 +67,101 @@ describe('buildSafeChildEnv', () => {
|
||||
const env = buildSafeChildEnv({ runtime: { NODE_ENV: 'production' } });
|
||||
expect(env['NODE_ENV']).toBe('production');
|
||||
});
|
||||
|
||||
it('E7: 精确名单包含的 key 即使值非空也剔除', () => {
|
||||
const env = buildSafeChildEnv({
|
||||
source: { DEEPSEEK_API_KEY: 'sk-d', AGNES_API_KEY: 'sk-a', MIMO_API_KEY: 'sk-m' },
|
||||
});
|
||||
expect(env['DEEPSEEK_API_KEY']).toBeUndefined();
|
||||
expect(env['AGNES_API_KEY']).toBeUndefined();
|
||||
expect(env['MIMO_API_KEY']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('E8: 后缀匹配大小写不敏感(小写后缀同样剔除)', () => {
|
||||
const env = buildSafeChildEnv({
|
||||
source: {
|
||||
my_api_key: 'k1',
|
||||
service_token: 't',
|
||||
db_password: 'p',
|
||||
api_secret: 's',
|
||||
},
|
||||
});
|
||||
expect(env['my_api_key']).toBeUndefined();
|
||||
expect(env['service_token']).toBeUndefined();
|
||||
expect(env['db_password']).toBeUndefined();
|
||||
expect(env['api_secret']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('E9: 后缀匹配要求 key 以"_后缀"结尾(非后缀含子串不误伤)', () => {
|
||||
const env = buildSafeChildEnv({
|
||||
source: {
|
||||
API_KEYSTORE_PATH: '/tmp/keys', // 含 KEYSTORE 但非 _API_KEY 结尾 → 保留
|
||||
TOKENIZER_MODEL: 'bert', // 含 TOKEN 但非 _TOKEN 结尾 → 保留
|
||||
SECRETARY_NAME: 'x', // 含 SECRET 但非 _SECRET 结尾 → 保留
|
||||
},
|
||||
});
|
||||
expect(env['API_KEYSTORE_PATH']).toBe('/tmp/keys');
|
||||
expect(env['TOKENIZER_MODEL']).toBe('bert');
|
||||
expect(env['SECRETARY_NAME']).toBe('x');
|
||||
});
|
||||
|
||||
it('E10: _PRIVATE_KEY / _CREDENTIALS 等长后缀精确匹配', () => {
|
||||
const env = buildSafeChildEnv({
|
||||
source: {
|
||||
SSH_PRIVATE_KEY: 'pk',
|
||||
AWS_CREDENTIALS: 'creds',
|
||||
DB_CREDENTIAL: 'cred',
|
||||
GITHUB_PASSWORD: 'pw',
|
||||
},
|
||||
});
|
||||
expect(env['SSH_PRIVATE_KEY']).toBeUndefined();
|
||||
expect(env['AWS_CREDENTIALS']).toBeUndefined();
|
||||
expect(env['DB_CREDENTIAL']).toBeUndefined();
|
||||
expect(env['GITHUB_PASSWORD']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('E11: runtime 注入的敏感变量绕过净化(调用方显式控制,覆盖黑名单)', () => {
|
||||
const env = buildSafeChildEnv({
|
||||
source: { API_KEY_EXTERNAL: 'secret' },
|
||||
runtime: { API_KEY_EXTERNAL: 'override' },
|
||||
});
|
||||
expect(env['API_KEY_EXTERNAL']).toBe('override');
|
||||
});
|
||||
|
||||
it('E12: 返回对象是普通对象且不含原型链自有属性泄漏', () => {
|
||||
const env = buildSafeChildEnv({ source: { HAS_OWN_PROP: 'x' } });
|
||||
expect(Object.getPrototypeOf(env)).toBe(Object.prototype);
|
||||
// toString 等原型方法不是 env 的自有属性
|
||||
expect(Object.prototype.hasOwnProperty.call(env, 'toString')).toBe(false);
|
||||
expect(Object.prototype.hasOwnProperty.call(env, 'constructor')).toBe(false);
|
||||
});
|
||||
|
||||
it('E13: runtime 注入后源中同名敏感变量被覆盖且不再触发剔除', () => {
|
||||
const env = buildSafeChildEnv({
|
||||
source: { DEEPSEEK_API_KEY: 'sk-raw' },
|
||||
runtime: { DEEPSEEK_API_KEY: 'sk-safe-runtime' },
|
||||
});
|
||||
expect(env['DEEPSEEK_API_KEY']).toBe('sk-safe-runtime');
|
||||
});
|
||||
|
||||
it('E14: 大量变量混合场景整体净化结果正确(端到端快照)', () => {
|
||||
const env = buildSafeChildEnv({
|
||||
source: {
|
||||
PATH: '/bin',
|
||||
HOME: '/home/u',
|
||||
OPENAI_API_KEY: 'sk-openai',
|
||||
ANTHROPIC_AUTH_TOKEN: 'tok',
|
||||
GITEA_PASSWORD: 'g',
|
||||
HTTPS_PROXY: 'http://p:1',
|
||||
LANG: 'en',
|
||||
},
|
||||
});
|
||||
expect(env['PATH']).toBe('/bin');
|
||||
expect(env['HOME']).toBe('/home/u');
|
||||
expect(env['HTTPS_PROXY']).toBe('http://p:1');
|
||||
expect(env['LANG']).toBe('en');
|
||||
expect(env['OPENAI_API_KEY']).toBeUndefined();
|
||||
expect(env['ANTHROPIC_AUTH_TOKEN']).toBeUndefined();
|
||||
expect(env['GITEA_PASSWORD']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -52,6 +52,11 @@ describe('isSensitiveConfigKey — 敏感 key 判定', () => {
|
||||
['llm.apiKey', true],
|
||||
['llm.api_key', true],
|
||||
['searxng.auth_key', true],
|
||||
// v0.7.4 P2-5: 驼峰/连字符/点分隔的 authKey 类命名全部命中(去分隔符归一化)
|
||||
['searxng.authKey', true],
|
||||
['searxng.auth-key', true],
|
||||
['mcp.server.authToken', true],
|
||||
['db.api-key', true],
|
||||
['llm.fallbackApiKey', true],
|
||||
['proxy.token', true],
|
||||
['db.secret', true],
|
||||
@@ -60,6 +65,7 @@ describe('isSensitiveConfigKey — 敏感 key 判定', () => {
|
||||
['llm.model', false],
|
||||
['agent.maxIterations', false],
|
||||
['network.proxyUrl', false],
|
||||
['searxng.language', false],
|
||||
])('%s → %j', (key, expected) => {
|
||||
expect(isSensitiveConfigKey(key)).toBe(expected);
|
||||
});
|
||||
@@ -113,4 +119,65 @@ describe('加密降级与失败语义', () => {
|
||||
const encrypted = encryptConfigValue('sk-x') as string;
|
||||
expect(decryptConfigValue(encrypted)).toBe(''); // 失败 → 空串(createAdapter 判定未配置,引导重录)
|
||||
});
|
||||
|
||||
it('safeStorage 不可用时已加密值仍可识别且不被二次"加密"', () => {
|
||||
const once = encryptConfigValue('sk-again') as string;
|
||||
mockState.encryptionAvailable = false;
|
||||
// 已加密值幂等:不重新走明文降级
|
||||
expect(encryptConfigValue(once)).toBe(once);
|
||||
});
|
||||
|
||||
it('前缀残缺的加密值(metona-enc:v1: 后无 base64)解密返回空串', () => {
|
||||
expect(decryptConfigValue('metona-enc:v1:')).toBe('');
|
||||
});
|
||||
|
||||
it('非 base64 内容的加密值解密失败返回空串(不抛错)', () => {
|
||||
expect(decryptConfigValue('metona-enc:v1:@@@not-base64@@@')).toBe('');
|
||||
});
|
||||
|
||||
it('不同版本前缀(metona-enc:v2:)不被识别为加密值(原样返回)', () => {
|
||||
expect(isEncryptedValue('metona-enc:v2:abc')).toBe(false);
|
||||
expect(decryptConfigValue('metona-enc:v2:abc')).toBe('metona-enc:v2:abc');
|
||||
});
|
||||
|
||||
it('含非敏感子串的 key 不加密(大写化归一后仍不命中)', () => {
|
||||
expect(isSensitiveConfigKey('ui.theme')).toBe(false);
|
||||
expect(isSensitiveConfigKey('network.proxyUrl')).toBe(false);
|
||||
expect(isSensitiveConfigKey('logging.traceEnabled')).toBe(false);
|
||||
});
|
||||
|
||||
it('敏感 key 判定覆盖 token 类与 secret 类更多形态', () => {
|
||||
expect(isSensitiveConfigKey('mcp.githubToken')).toBe(true);
|
||||
expect(isSensitiveConfigKey('db.clientSecret')).toBe(true);
|
||||
expect(isSensitiveConfigKey('registry.authToken')).toBe(true);
|
||||
expect(isSensitiveConfigKey('SMTP_PASSWORD')).toBe(true);
|
||||
expect(isSensitiveConfigKey('sshPrivateKey')).toBe(false); // 无分隔符且不含模式
|
||||
expect(isSensitiveConfigKey('keyboard.layout')).toBe(false);
|
||||
});
|
||||
|
||||
it('encrypt/decrypt 往返多种敏感字符串(含特殊字符/中文/emoji)', () => {
|
||||
const samples = [
|
||||
'sk-hello world',
|
||||
'含中文密钥值',
|
||||
'with,comma"quotes"',
|
||||
'emoji-🔑-key',
|
||||
'a'.repeat(5000),
|
||||
];
|
||||
for (const s of samples) {
|
||||
const encrypted = encryptConfigValue(s) as string;
|
||||
expect(isEncryptedValue(encrypted)).toBe(true);
|
||||
expect(decryptConfigValue(encrypted)).toBe(s);
|
||||
}
|
||||
});
|
||||
|
||||
it('decrypt 对 undefined/null 原样透传', () => {
|
||||
expect(decryptConfigValue(undefined)).toBeUndefined();
|
||||
expect(decryptConfigValue(null)).toBeNull();
|
||||
});
|
||||
|
||||
it('encrypt 对 0/空串/false 等 falsy 非字符串原样透传', () => {
|
||||
expect(encryptConfigValue(0)).toBe(0);
|
||||
expect(encryptConfigValue('')).toBe('');
|
||||
expect(encryptConfigValue(false)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
/**
|
||||
* SLOMonitor + HealthChecker 测试(electron/utils/slo.ts —— 此前零测试)
|
||||
*
|
||||
* SLOMonitor 契约:
|
||||
* 1. 滑动窗口内错误率统计(窗口外记录被清理/忽略)
|
||||
* 2. 吞吐量 = 窗口内请求数 / 窗口秒数
|
||||
* 3. 延迟分位数 P50/P95/P99(空数据返回 0)
|
||||
* 4. 燃烧速率 = 错误率 / 错误预算;>1 触发 violated
|
||||
* 5. 低流量语义:窗口内少量请求也按真实比例计算
|
||||
* 6. reset / getConfig
|
||||
*
|
||||
* HealthChecker 契约:database(DB ping / 文件存在)/ free_memory(>200MB)/
|
||||
* memory_usage(heap < 512MB)三项检查聚合为 healthy。
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll } from 'vitest';
|
||||
import { mkdtempSync, rmSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
|
||||
// electron.app.getPath 指向一个真实存在的临时目录(checkFreeMemory 依赖)
|
||||
const mockState = vi.hoisted(() => ({ userDataDir: '' }));
|
||||
vi.mock('electron', async () => {
|
||||
const { mkdtempSync } = await import('fs');
|
||||
const { tmpdir } = await import('os');
|
||||
const { join } = await import('path');
|
||||
mockState.userDataDir = mkdtempSync(join(tmpdir(), 'metona-slo-'));
|
||||
return { app: { getPath: () => mockState.userDataDir } };
|
||||
});
|
||||
|
||||
import { SLOMonitor, HealthChecker } from '../slo';
|
||||
|
||||
describe('SLOMonitor — 基础统计', () => {
|
||||
let monitor: SLOMonitor;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(1_000_000);
|
||||
monitor = new SLOMonitor({ windowMs: 60_000 });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('空监控器:errorRate=0、throughput=0、percentile=0、violated=false', () => {
|
||||
const status = monitor.getStatus();
|
||||
expect(status.errorRate).toBe(0);
|
||||
expect(status.throughput).toBe(0);
|
||||
expect(status.avgLatencyMs).toBe(0);
|
||||
expect(status.percentiles).toEqual({ P50: 0, P95: 0, P99: 0 });
|
||||
expect(status.burnRate).toBe(0);
|
||||
expect(status.violated).toBe(false);
|
||||
expect(status.totalRequests).toBe(0);
|
||||
expect(status.errorRequests).toBe(0);
|
||||
expect(status.target).toBe(0.999);
|
||||
});
|
||||
|
||||
it('全部成功:errorRate=0、violated=false、burnRate=0', () => {
|
||||
for (let i = 0; i < 10; i++) monitor.recordRequest(100, true);
|
||||
const status = monitor.getStatus();
|
||||
expect(status.totalRequests).toBe(10);
|
||||
expect(status.errorRequests).toBe(0);
|
||||
expect(status.errorRate).toBe(0);
|
||||
expect(status.burnRate).toBe(0);
|
||||
expect(status.violated).toBe(false);
|
||||
});
|
||||
|
||||
it('全部失败:errorRate=1、burnRate=1/errorBudget、violated=true', () => {
|
||||
for (let i = 0; i < 5; i++) monitor.recordRequest(10, false);
|
||||
const status = monitor.getStatus();
|
||||
expect(status.errorRate).toBe(1);
|
||||
expect(status.errorRequests).toBe(5);
|
||||
// errorBudget = 1 - 0.999 = 0.001
|
||||
expect(status.errorBudget).toBeCloseTo(0.001);
|
||||
expect(status.burnRate).toBeCloseTo(1000);
|
||||
expect(status.violated).toBe(true);
|
||||
});
|
||||
|
||||
it('混合请求:errorRate = 错误数/总数', () => {
|
||||
monitor.recordRequest(10, true);
|
||||
monitor.recordRequest(20, true);
|
||||
monitor.recordRequest(30, false);
|
||||
monitor.recordRequest(40, true);
|
||||
const status = monitor.getStatus();
|
||||
expect(status.totalRequests).toBe(4);
|
||||
expect(status.errorRate).toBeCloseTo(0.25);
|
||||
});
|
||||
|
||||
it('吞吐量 = 窗口内请求数 / 窗口秒数', () => {
|
||||
for (let i = 0; i < 30; i++) monitor.recordRequest(5, true);
|
||||
const status = monitor.getStatus();
|
||||
expect(status.throughput).toBeCloseTo(30 / 60); // 0.5 req/s
|
||||
});
|
||||
|
||||
it('平均延迟 = 窗口内延迟总和 / 请求数', () => {
|
||||
monitor.recordRequest(100, true);
|
||||
monitor.recordRequest(300, true);
|
||||
monitor.recordRequest(200, true);
|
||||
expect(monitor.getStatus().avgLatencyMs).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SLOMonitor — 分位数', () => {
|
||||
let monitor: SLOMonitor;
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(1_000_000);
|
||||
monitor = new SLOMonitor({ windowMs: 60_000 });
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('P50/P95/P99 边界:升序延迟的 ceil 索引取数', () => {
|
||||
// [1,2,3,4]:P50=2(idx1),P95=4(idx3),P99=4(idx3)
|
||||
for (const lat of [4, 1, 3, 2]) monitor.recordRequest(lat, true);
|
||||
const status = monitor.getStatus();
|
||||
expect(status.percentiles).toEqual({ P50: 2, P95: 4, P99: 4 });
|
||||
});
|
||||
|
||||
it('单元素:所有分位数均为该值', () => {
|
||||
monitor.recordRequest(77, true);
|
||||
expect(monitor.getStatus().percentiles).toEqual({ P50: 77, P95: 77, P99: 77 });
|
||||
});
|
||||
|
||||
it('自定义分位数数组([0.5,0.9])生成对应 key', () => {
|
||||
const m = new SLOMonitor({ latencyPercentiles: [0.5, 0.9], windowMs: 60_000 });
|
||||
m.recordRequest(1, true);
|
||||
m.recordRequest(2, true);
|
||||
m.recordRequest(3, true);
|
||||
m.recordRequest(4, true);
|
||||
m.recordRequest(5, true);
|
||||
const status = m.getStatus();
|
||||
expect(status.percentiles).toEqual({ P50: 3, P90: 5 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('SLOMonitor — 滑动窗口', () => {
|
||||
let monitor: SLOMonitor;
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(1_000_000);
|
||||
monitor = new SLOMonitor({ windowMs: 60_000 });
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('窗口外旧记录被 getStatus 忽略(不参与统计)', () => {
|
||||
for (let i = 0; i < 3; i++) monitor.recordRequest(10, false); // 全部失败
|
||||
// 推进 60 秒以上 —— 旧记录全部过期
|
||||
vi.advanceTimersByTime(61_000);
|
||||
const status = monitor.getStatus();
|
||||
expect(status.totalRequests).toBe(0);
|
||||
expect(status.errorRate).toBe(0);
|
||||
expect(status.violated).toBe(false);
|
||||
});
|
||||
|
||||
it('窗口内保留新记录:混合新旧后只统计新记录', () => {
|
||||
monitor.recordRequest(10, false); // 旧失败
|
||||
vi.advanceTimersByTime(30_000);
|
||||
monitor.recordRequest(20, true); // 新成功(仍在窗口内)
|
||||
const status = monitor.getStatus();
|
||||
expect(status.totalRequests).toBe(2); // 两条都还在窗口内
|
||||
vi.advanceTimersByTime(30_001); // 第一条(30 秒前的)过期
|
||||
const status2 = monitor.getStatus();
|
||||
expect(status2.totalRequests).toBe(1);
|
||||
expect(status2.errorRate).toBe(0);
|
||||
});
|
||||
|
||||
it('recordRequest 内部清理:推进时间后旧记录被移除(内存不膨胀)', () => {
|
||||
for (let i = 0; i < 5; i++) monitor.recordRequest(1, true);
|
||||
vi.advanceTimersByTime(120_000);
|
||||
// 再次 recordRequest 触发 cleanup → 旧记录全清
|
||||
monitor.recordRequest(2, true);
|
||||
expect(monitor.getStatus().totalRequests).toBe(1);
|
||||
});
|
||||
|
||||
it('窗口边界:恰在窗口起始点的记录被保留(>= 比较)', () => {
|
||||
monitor.recordRequest(1, true);
|
||||
vi.advanceTimersByTime(60_000); // 恰满窗口
|
||||
const status = monitor.getStatus();
|
||||
expect(status.totalRequests).toBe(1);
|
||||
});
|
||||
|
||||
it('低流量误报语义:少量请求也按真实比例(单条失败即 100% 错误率 → violated)', () => {
|
||||
// target 0.999 预算极低 —— 单条失败即触发 violated(设计语义:低流量不掩盖问题)
|
||||
monitor.recordRequest(5, false);
|
||||
const status = monitor.getStatus();
|
||||
expect(status.errorRate).toBe(1);
|
||||
expect(status.violated).toBe(true);
|
||||
});
|
||||
|
||||
it('低流量不误报:单条成功请求 errorRate=0 不触发 violated', () => {
|
||||
monitor.recordRequest(5, true);
|
||||
const status = monitor.getStatus();
|
||||
expect(status.errorRate).toBe(0);
|
||||
expect(status.violated).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SLOMonitor — 配置与重置', () => {
|
||||
it('自定义 target 影响错误预算与燃烧速率', () => {
|
||||
vi.useFakeTimers();
|
||||
const m = new SLOMonitor({ target: 0.9, windowMs: 60_000 });
|
||||
m.recordRequest(1, false);
|
||||
m.recordRequest(1, true);
|
||||
const status = m.getStatus();
|
||||
expect(status.errorBudget).toBeCloseTo(0.1);
|
||||
expect(status.burnRate).toBeCloseTo(5); // 0.5 / 0.1
|
||||
expect(status.violated).toBe(true);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('目标 0.5 时 50% 错误率为边界(violated=false)', () => {
|
||||
vi.useFakeTimers();
|
||||
const m = new SLOMonitor({ target: 0.5, windowMs: 60_000 });
|
||||
m.recordRequest(1, true);
|
||||
m.recordRequest(1, false);
|
||||
const status = m.getStatus();
|
||||
expect(status.errorRate).toBeCloseTo(0.5);
|
||||
expect(status.burnRate).toBeCloseTo(1);
|
||||
expect(status.violated).toBe(false); // burnRate > 1 才触发
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('reset 清空全部记录', () => {
|
||||
vi.useFakeTimers();
|
||||
const m = new SLOMonitor({ windowMs: 60_000 });
|
||||
m.recordRequest(1, false);
|
||||
m.recordRequest(2, false);
|
||||
m.reset();
|
||||
const status = m.getStatus();
|
||||
expect(status.totalRequests).toBe(0);
|
||||
expect(status.errorRate).toBe(0);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('getConfig 返回默认配置的副本(修改副本不影响实例)', () => {
|
||||
const m = new SLOMonitor();
|
||||
const config = m.getConfig();
|
||||
expect(config.target).toBe(0.999);
|
||||
expect(config.windowMs).toBe(5 * 60 * 1000);
|
||||
expect(config.latencyPercentiles).toEqual([0.5, 0.95, 0.99]);
|
||||
expect(config.latencyThresholdMs).toBe(5000);
|
||||
config.target = 0.5;
|
||||
expect(m.getConfig().target).toBe(0.999);
|
||||
});
|
||||
|
||||
it('getStatus 返回 timestamp 为 Date 实例', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(1_234_567);
|
||||
const m = new SLOMonitor();
|
||||
m.recordRequest(1, true);
|
||||
const status = m.getStatus();
|
||||
expect(status.timestamp).toBeInstanceOf(Date);
|
||||
expect(status.timestamp.getTime()).toBe(1_234_567);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe('HealthChecker — 健康检查聚合', () => {
|
||||
let dir: string;
|
||||
let db: any;
|
||||
|
||||
// 模块级 ABI 探测(it.skipIf 在注册期求值,不能依赖 beforeAll)
|
||||
let dbAvailable = true;
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const Probe = require('better-sqlite3');
|
||||
new Probe(':memory:').close();
|
||||
} catch {
|
||||
dbAvailable = false;
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
if (dbAvailable) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
db = new (require('better-sqlite3'))(':memory:');
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
try {
|
||||
db?.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'metona-health-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
try {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
});
|
||||
|
||||
it.skipIf(!dbAvailable)('database 检查:注入 DB 成功执行 ping → healthy', async () => {
|
||||
const checker = new HealthChecker(() => db);
|
||||
const report = await checker.check();
|
||||
const dbCheck = report.checks.find((c) => c.name === 'database');
|
||||
expect(dbCheck?.healthy).toBe(true);
|
||||
expect(typeof dbCheck?.latencyMs).toBe('number');
|
||||
});
|
||||
|
||||
it('database 检查:DB ping 抛错 → unhealthy 且带错误信息', async () => {
|
||||
const broken = {
|
||||
prepare: () => {
|
||||
throw new Error('db closed');
|
||||
},
|
||||
};
|
||||
const checker = new HealthChecker(() => broken as never);
|
||||
const report = await checker.check();
|
||||
const dbCheck = report.checks.find((c) => c.name === 'database');
|
||||
expect(dbCheck?.healthy).toBe(false);
|
||||
expect(dbCheck?.error).toContain('db closed');
|
||||
});
|
||||
|
||||
it('database 检查:无 DB 注入但 dbPath 存在 → healthy(文件存在后备)', async () => {
|
||||
const filePath = join(dir, 'agent.db');
|
||||
const { writeFileSync } = await import('fs');
|
||||
writeFileSync(filePath, 'sqlite', 'utf-8');
|
||||
const checker = new HealthChecker(undefined, filePath);
|
||||
const report = await checker.check();
|
||||
const dbCheck = report.checks.find((c) => c.name === 'database');
|
||||
expect(dbCheck?.healthy).toBe(true);
|
||||
});
|
||||
|
||||
it('database 检查:无 DB 且 dbPath 不存在 → unhealthy', async () => {
|
||||
const checker = new HealthChecker(undefined, join(dir, 'missing.db'));
|
||||
const report = await checker.check();
|
||||
const dbCheck = report.checks.find((c) => c.name === 'database');
|
||||
expect(dbCheck?.healthy).toBe(false);
|
||||
expect(dbCheck?.error).toBe('Database not available');
|
||||
});
|
||||
|
||||
it('free_memory 检查:userData 存在 → 返回布尔健康态(v0.7.4 降级,不断言 true)', async () => {
|
||||
// 回归修复: 原断言 healthy===true 依赖测试机空闲内存 >200MB,低内存 CI 假失败。
|
||||
// 降级为断言"检查存在 + healthy 为布尔"。
|
||||
const checker = new HealthChecker();
|
||||
const report = await checker.check();
|
||||
const memCheck = report.checks.find((c) => c.name === 'free_memory');
|
||||
expect(memCheck).toBeDefined();
|
||||
expect(typeof memCheck?.healthy).toBe('boolean');
|
||||
});
|
||||
|
||||
it('memory_usage 检查:返回布尔健康态(v0.7.4 降级,不断言 true)', async () => {
|
||||
const checker = new HealthChecker();
|
||||
const report = await checker.check();
|
||||
const usageCheck = report.checks.find((c) => c.name === 'memory_usage');
|
||||
expect(usageCheck).toBeDefined();
|
||||
expect(typeof usageCheck?.healthy).toBe('boolean');
|
||||
});
|
||||
|
||||
it('check 返回三项检查且 healthy 为聚合结果(全健康 → true)', async () => {
|
||||
const checker = new HealthChecker();
|
||||
const report = await checker.check();
|
||||
expect(report.checks.map((c) => c.name)).toEqual(['database', 'free_memory', 'memory_usage']);
|
||||
// 无 DB 无路径 → database 不健康 → 聚合 unhealthy
|
||||
expect(report.healthy).toBe(false);
|
||||
expect(report.timestamp).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('所有检查健康时聚合 healthy=true', async () => {
|
||||
const filePath = join(dir, 'agent.db');
|
||||
const { writeFileSync } = await import('fs');
|
||||
writeFileSync(filePath, 'sqlite', 'utf-8');
|
||||
const checker = new HealthChecker(undefined, filePath);
|
||||
const report = await checker.check();
|
||||
expect(report.healthy).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -18,11 +18,28 @@ import log from 'electron-log';
|
||||
const ENCRYPTION_PREFIX = 'metona-enc:v1:';
|
||||
|
||||
/** 敏感配置 key 匹配模式(与 IPC 层审计脱敏规则保持一致) */
|
||||
const SENSITIVE_KEY_PATTERNS = ['apikey', 'api_key', 'apitoken', 'token', 'secret', 'password', 'auth_key'];
|
||||
const SENSITIVE_KEY_PATTERNS = [
|
||||
'apikey',
|
||||
'api_key',
|
||||
'apitoken',
|
||||
'token',
|
||||
'secret',
|
||||
'password',
|
||||
'auth_key',
|
||||
];
|
||||
|
||||
/** 判断配置 key 是否为敏感项(需要加密存储) */
|
||||
/**
|
||||
* v0.7.4 P2-5 根治: 判断配置 key 是否为敏感项(需要加密存储)。
|
||||
*
|
||||
* 旧实现直接 `key.toLowerCase().includes(pattern)` —— `searxng.authKey` 小写为
|
||||
* 'searxng.authkey',与模式 'auth_key'(含下划线)不匹配 → 该 key 既不加密落盘、
|
||||
* 也不在导出脱敏/审计脱敏中掩码,API Key 可经 data:export 明文泄露。
|
||||
* 现改为"去分隔符归一化"匹配:key 小写后移除 `_`/`-`/`.` 再与模式(同样归一化)
|
||||
* 比较,`authKey`/`auth-key`/`auth.key` 均命中 'authkey'。
|
||||
*/
|
||||
export function isSensitiveConfigKey(key: string): boolean {
|
||||
return SENSITIVE_KEY_PATTERNS.some((p) => key.toLowerCase().includes(p));
|
||||
const normalized = key.toLowerCase().replace(/[_.-]/g, '');
|
||||
return SENSITIVE_KEY_PATTERNS.some((p) => normalized.includes(p.replace(/[_.-]/g, '')));
|
||||
}
|
||||
|
||||
/** 判断值是否已是加密格式 */
|
||||
|
||||
@@ -210,7 +210,8 @@ export class SLOMonitor {
|
||||
*/
|
||||
private cleanup(): void {
|
||||
const cutoff = Date.now() - this.config.windowMs;
|
||||
// 保留最近 10 分钟的数据(2 倍窗口),避免边界效应
|
||||
// v0.7.4 P3-3: 修正失实注释 —— 保留窗口长度(windowMs,5 分钟),
|
||||
// 不额外多留窗口外的数据(getStatus 只统计窗口内记录)
|
||||
this.records = this.records.filter((r) => r.timestamp >= cutoff);
|
||||
}
|
||||
|
||||
@@ -231,9 +232,8 @@ export class SLOMonitor {
|
||||
const throughput = totalRequests / (this.config.windowMs / 1000);
|
||||
|
||||
const latencies = windowRecords.map((r) => r.latencyMs).sort((a, b) => a - b);
|
||||
const avgLatencyMs = latencies.length > 0
|
||||
? latencies.reduce((sum, l) => sum + l, 0) / latencies.length
|
||||
: 0;
|
||||
const avgLatencyMs =
|
||||
latencies.length > 0 ? latencies.reduce((sum, l) => sum + l, 0) / latencies.length : 0;
|
||||
|
||||
const percentiles: Record<string, number> = {};
|
||||
for (const p of this.config.latencyPercentiles) {
|
||||
|
||||
Reference in New Issue
Block a user