1
0

Compare commits

...

3 Commits

Author SHA1 Message Date
bce0f8e7a8 feat: 增强 HTTP checker 鲁棒性 — 严格配置校验、完整耗时、流式body、重定向与编码完善
启动期校验: 新增 validate.ts 对 HTTP config/expect/body rule/operator 全方位严格校验
执行语义: body 改为 Web Stream 流式超限中止,durationMs 覆盖完整执行
错误归属: status/header 失败不读 body,phase 分层 request/body,early duration skip body
重定向: 跟随前释放 body,POST/303 改 GET 清理 header,跨 origin 剥离敏感 header
编码: 支持 quoted charset,未知编码返回结构化解码错误
文档: README match→regex+durationMs,DEVELOPMENT 执行流程与错误归属
测试: +63 测试覆盖全部新增场景,325 pass 0 fail
规格: 同步 probe-config/probe-engine/expect-body-checkers 3 个 delta spec
2026-05-13 08:00:05 +08:00
2fd0f206be feat: HTTP 探针增强 — ignoreSSL、精确重定向控制、状态码范围匹配、编码自动检测 2026-05-13 00:02:04 +08:00
87d946a441 fix: 修复 Windows 平台测试兼容性问题
- 新增 tests/helpers.ts 的 rmRetry 工具函数,解决 SQLite 文件句柄未及时释放导致 afterAll 清理时 EBUSY 错误
- 修改通配符测试用例,使用 bun -e 替代 echo 命令,确保跨平台行为一致
2026-05-12 22:12:32 +08:00
25 changed files with 2200 additions and 101 deletions

View File

@@ -52,6 +52,7 @@ src/
http/ HTTP Checker 子包
runner.ts HttpCheckerresolve/execute/serialize
expect.ts HTTP 专用断言status/headers
validate.ts HTTP 配置与 expect 启动期校验
command/ Command Checker 子包
runner.ts CommandCheckerresolve/execute/serialize
expect.ts Command 专用断言exitCode
@@ -183,11 +184,13 @@ export function handleXxx(params, store: ProbeStore, method: string, mode: Runti
**HTTP 校验流程**
```
runHttpCheck → 收集观测(statusCode/headers/body/durationMs)
checkHttpExpect → status → duration → headers → body(可选)
runHttpCheck → 收集观测(statusCode/headers)
status → headers → (early duration)body(按需) → (final duration)
→ 首个失败即停止,返回 CheckFailure
```
HTTP checker 的 `durationMs` 覆盖完整执行(含重定向、响应体读取、解码和 expect 校验。status 或 headers 失败时不读取 body进入 body 前若已超过 `maxDurationMs`,直接返回 duration failure。
**Command 校验流程**
```
@@ -199,7 +202,7 @@ runCommandCheck → 收集观测(exitCode/stdout/stderr/durationMs)
**Body 规则类型**
- `contains`:文本包含匹配
- `regex`:正则表达式匹配
- `regex`:正则表达式匹配注意body 正则字段为 `regex`,不是 `match`
- `json`JSONPath 提取 + 操作符比较(使用 `es-toolkit/isPlainObject` 区分纯值和操作符)
- `css`cheerio CSS 选择器 + 操作符比较
- `xpath`XPath 节点提取 + 操作符比较
@@ -210,7 +213,7 @@ runCommandCheck → 收集观测(exitCode/stdout/stderr/durationMs)
- **API 错误**`{ error: "描述", status: <code> }`,状态码 400/404/405/503
- **CheckFailure**`{ kind: "error"|"mismatch", phase, path, expected?, actual?, message }`
- **错误处理**expect 校验失败记录首个失败原因;网络/超时/进程崩溃统一为 `kind:"error"`
- **错误处理**expect 校验失败记录首个失败原因;网络/超时/进程崩溃统一为 `kind:"error"`,请求/TLS/timeout 错误归属 `phase:"request"`body 超限/解码/解析错误归属 `phase:"body"`
- **日志**:解析失败等非致命异常用 `console.warn`,启动失败用 `console.error` + `process.exit(1)`
### 1.10 测试规范

View File

@@ -100,7 +100,7 @@ targets:
- `interval`: 拨测间隔,默认 `30s`
- `timeout`: 超时时间,默认 `10s`
- `http`: HTTP 类型默认值
- `method`: HTTP 方法,默认 `GET`
- `method`: HTTP 方法,默认 `GET`,支持 `GET``HEAD``POST``PUT``PATCH``DELETE``OPTIONS`
- `maxBodyBytes`: 响应体最大字节数,默认 `100MB`
- `command`: Command 类型默认值
- `maxOutputBytes`: 输出最大字节数,默认 `100MB`
@@ -111,6 +111,8 @@ targets:
- `http`: HTTP 拨测配置type 为 http 时必填)
- `url`: 目标 URL
- `method``headers``body`: 请求参数
- `ignoreSSL`: 是否忽略 HTTPS 证书校验,默认 `false`,用于自签名或私有证书服务
- `maxRedirects`: 最大重定向跟随次数,默认 `0`(不跟随重定向)
- `command`: 命令行拨测配置type 为 command 时必填)
- `exec`: 可执行文件名或路径
- `args`: 命令行参数列表
@@ -118,20 +120,22 @@ targets:
- `cwd`: 工作目录(可选,相对于配置文件所在目录解析,默认 `.`
- `interval``timeout`: 覆盖全局默认值
- `expect`: 期望校验
- `status`: 可接受的状态码列表HTTP
- `status`: 可接受的状态码列表HTTP,支持精确状态码和范围模式(如 `"2xx"`)混合配置
- `exitCode`: 可接受的退出码列表Command
- `headers`: 响应头校验HTTP支持 `equals``contains` 等操作符)
- `maxDurationMs`: 最大耗时阈值(毫秒)
- `maxDurationMs`: 最大耗时阈值(毫秒)HTTP 类型覆盖完整执行(含重定向、响应体读取和 expect 校验)
- `body`: HTTP 响应体校验(数组,可组合使用)
- `contains`: 响应体包含的文本
- `match`: 响应体匹配的正则表达式
- `regex`: 响应体匹配的正则表达式
- `json`: JSONPath 提取值比较(`path` + 比较操作符)
- `css`: CSS 选择器提取 HTML 元素比较
- `xpath`: XPath 提取 XML/HTML 节点比较
- `stdout` / `stderr`: Command 输出校验(数组,同 body 格式)
- 比较操作符:`equals`(默认)、`contains``match`(正则)、`empty``exists``gte``lte``gt``lt`
大小说明:`maxBodyBytes``maxOutputBytes` 支持单位 `KB``MB``GB`,也可直接使用数字(字节数)。
大小说明:`maxBodyBytes``maxOutputBytes` 支持单位 `KB``MB``GB`,也可直接使用数字(非负安全整数字节数)。
配置校验:系统启动时严格校验所有已支持字段的类型和格式,非法配置会阻止启动并输出清晰的错误信息。未知字段会被忽略,不影响启动和运行。
时长格式支持:`30s``5m``500ms`
@@ -189,7 +193,7 @@ CLI 只接受一个参数YAML 配置文件路径。
单层判定模型,适用于 HTTP 和 Command 两种类型:
- **matched**: 是否符合 expect 规则(无 expect 时默认为 true
- **matched**: 是否符合 expect 规则(HTTP 无 expect 时默认检查 status 200
- **UP** = matched
- **DOWN** = NOT matched

View File

@@ -9,9 +9,9 @@ context: |
- 新增的逻辑必须编写完善的测试,并保证测试的正确性,不允许跳过任何测试
- 这是基于bun实现的前端后一体化项目使用bun作为唯一包管理器严禁使用pnpm、npm使用bunx运行工具严禁使用npx、pnpx
- src/server目录下是基于bun实现的后端代码
- src/web目录下是基于vite、react、TDesign实现的前端代码
- 后端库使用优先级Bun 内置 API > es-toolkit > 主流三方库 > 项目公共工具 > 自行实现
- 前端样式开发优先级TDesign组件 > 组件props > TDesign CSS tokens(--td-*) > styles.css CSS类 > 自行开发组件
- src/web目录下是基于vite、react、TDesign实现的前端代码
- 前端样式开发优先级TDesign组件 > 组件props > TDesign CSS tokens(--td-*) > styles.css CSS类 > 自行开发组件
- 前端严禁组件内联style属性、CSS覆盖TD内部类名、使用!important、硬编码色值
- Git提交: 仅中文; 格式"类型: 简短描述", 类型: feat/fix/refactor/docs/style/test/chore; 多行描述空行后写详细说明
- 禁止创建git操作task

View File

@@ -60,6 +60,14 @@
- **WHEN** YAML 配置中 HTTP target 缺少 `http.url`
- **THEN** HttpChecker 的 `resolve()` SHALL 抛出校验错误,提示缺少必填字段
#### Scenario: HTTP method 非法校验
- **WHEN** YAML 配置中 HTTP target 的 `http.method` 不在合法方法列表中
- **THEN** HttpChecker 的 `resolve()` SHALL 抛出校验错误,提示 method 不合法
#### Scenario: URL 格式校验
- **WHEN** YAML 配置中 HTTP target 的 `http.url` 不以 `http://``https://` 开头
- **THEN** HttpChecker 的 `resolve()` SHALL 抛出校验错误,提示 URL 格式不合法
### Requirement: 存储序列化通过 registry 获取展示格式
系统 SHALL 在 `ProbeStore.syncTargets()` 中通过 `checkerRegistry.get(t.type).serialize(t)` 获取每个 target 的展示摘要(`target` 列)和配置 JSON`config` 列),替代 `buildTargetDisplay()` / `buildTargetConfig()` 中的类型分支。

View File

@@ -126,3 +126,88 @@
#### Scenario: actual 值截断
- **WHEN** 失败规则的实际值超过系统允许记录的摘要长度
- **THEN** 系统 MUST 截断 actual 摘要,而不是持久化完整响应体或命令输出
### Requirement: 状态码范围匹配
系统 SHALL 支持在 `expect.status` 数组中使用范围模式字符串(`"1xx"``"2xx"``"3xx"``"4xx"``"5xx"`),与精确数字混合使用。范围模式 SHALL 匹配对应百位段内的所有状态码。其他范围模式 SHALL 在启动期配置校验失败。
#### Scenario: 1xx 范围匹配
- **WHEN** HTTP target 配置 `expect.status: ["1xx"]`,且响应状态码为 101
- **THEN** 系统 SHALL 判定状态码匹配
#### Scenario: 2xx 范围匹配
- **WHEN** HTTP target 配置 `expect.status: ["2xx"]`,且响应状态码为 200
- **THEN** 系统 SHALL 判定状态码匹配
#### Scenario: 2xx 范围匹配 204
- **WHEN** HTTP target 配置 `expect.status: ["2xx"]`,且响应状态码为 204
- **THEN** 系统 SHALL 判定状态码匹配
#### Scenario: 2xx 范围不匹配 301
- **WHEN** HTTP target 配置 `expect.status: ["2xx"]`,且响应状态码为 301
- **THEN** 系统 SHALL 判定状态码不匹配
#### Scenario: 混合精确值与范围模式
- **WHEN** HTTP target 配置 `expect.status: ["2xx", 301]`,且响应状态码为 301
- **THEN** 系统 SHALL 判定状态码匹配(精确值 301 匹配)
#### Scenario: 混合精确值与范围模式范围命中
- **WHEN** HTTP target 配置 `expect.status: ["2xx", 301]`,且响应状态码为 204
- **THEN** 系统 SHALL 判定状态码匹配2xx 范围命中)
#### Scenario: 5xx 范围匹配
- **WHEN** HTTP target 配置 `expect.status: ["5xx"]`,且响应状态码为 503
- **THEN** 系统 SHALL 判定状态码匹配
#### Scenario: 非 HTTP 范围模式启动失败
- **WHEN** HTTP target 配置 `expect.status: ["6xx"]`
- **THEN** 系统 SHALL 在启动期配置校验失败
### Requirement: HTTP expect 规则启动期校验
系统 SHALL 在启动期校验 HTTP expect 中已支持字段的类型、格式和可编译表达式。未知字段 SHALL 被忽略,但每个规则对象 MUST 至少包含可产生有效断言的支持字段。
#### Scenario: body rule 使用 regex 字段
- **WHEN** HTTP target 配置 `expect.body: [{regex: "ok|healthy"}]` 且 regex 可编译
- **THEN** 系统 SHALL 接受该配置,并在运行期按 regex body 规则匹配响应体
#### Scenario: body rule 不支持 match 字段
- **WHEN** HTTP target 配置 `expect.body: [{match: "ok"}]` 且该规则没有 contains、regex、json、css、xpath 任一支持字段
- **THEN** 系统 SHALL 在启动期配置校验失败
#### Scenario: body rule 忽略未知字段
- **WHEN** HTTP target 配置 `expect.body: [{contains: "ok", note: "ignored"}]`
- **THEN** 系统 SHALL 忽略 note 字段并按 contains 规则校验响应体
#### Scenario: body rule 多支持字段非法
- **WHEN** HTTP target 的同一条 body rule 同时配置 contains 和 regex
- **THEN** 系统 SHALL 在启动期配置校验失败
#### Scenario: operator match 正则非法
- **WHEN** HTTP target 的 expect.headers、json、css 或 xpath operator 配置了不可编译的 match 正则
- **THEN** 系统 SHALL 在启动期配置校验失败
#### Scenario: operator 数值比较类型非法
- **WHEN** HTTP target 的 expect operator 配置 gt、gte、lt 或 lte且对应值不是有限数字
- **THEN** 系统 SHALL 在启动期配置校验失败
#### Scenario: operator 布尔类型非法
- **WHEN** HTTP target 的 expect operator 配置 empty 或 exists且对应值不是布尔值
- **THEN** 系统 SHALL 在启动期配置校验失败
#### Scenario: JSONPath 子集非法
- **WHEN** HTTP target 的 json body rule path 不符合系统支持的 JSONPath 子集
- **THEN** 系统 SHALL 在启动期配置校验失败
### Requirement: HTTP body 运行期失败结构化
系统 SHALL 将 HTTP body 运行期失败记录为结构化 CheckFailure并保留与具体规则相关的 phase 和 path。响应内容不符合配置 SHALL 记录为 mismatch响应内容无法按配置解析或解码 SHALL 记录为 error。
#### Scenario: JSON 响应不是合法 JSON
- **WHEN** HTTP target 配置 json body rule但响应体不是合法 JSON
- **THEN** 系统 SHALL 记录 `failure.kind="error"``failure.phase="body"`,且 failure.path SHALL 指向对应 json 规则
#### Scenario: CSS selector 无匹配元素
- **WHEN** HTTP target 配置 css body rule但响应 HTML 中无匹配元素
- **THEN** 系统 SHALL 记录 `failure.kind="mismatch"``failure.phase="body"`,且 failure.path SHALL 指向对应 css 规则
#### Scenario: XPath 无匹配节点
- **WHEN** HTTP target 配置 xpath body rule但响应 XML/HTML 中无匹配节点
- **THEN** 系统 SHALL 记录 `failure.kind="mismatch"``failure.phase="body"`,且 failure.path SHALL 指向对应 xpath 规则

View File

@@ -5,7 +5,7 @@
## Requirements
### Requirement: YAML 配置文件格式
系统 SHALL 支持通过 YAML 配置文件定义全部运行参数,包括 server 配置、runtime 配置、checker 默认值和 typed target 列表(含可选 group 字段。target MUST 使用 `type` 字段声明 checker 类型HTTP 领域字段 MUST 放在 `http` 分组command 领域字段 MUST 放在 `command` 分组。
系统 SHALL 支持通过 YAML 配置文件定义全部运行参数,包括 server 配置、runtime 配置、checker 默认值和 typed target 列表(含可选 group 字段。target MUST 使用 `type` 字段声明 checker 类型HTTP 领域字段 MUST 放在 `http` 分组command 领域字段 MUST 放在 `command` 分组。HTTP target 的 `http` 分组 SHALL 支持可选的 `ignoreSSL`(布尔值)和 `maxRedirects`(非负整数)字段。
#### Scenario: 完整配置文件解析
- **WHEN** 系统启动并读取包含 server、runtime、defaults、targets含 group 字段)的 YAML 配置文件
@@ -13,7 +13,7 @@
#### Scenario: 最简 HTTP 配置文件解析
- **WHEN** 系统读取只包含一个 `type: http` target 和 `http.url` 的 YAML 配置文件(省略 server、runtime、defaults 和 expect
- **THEN** 系统 SHALL 使用内置默认值填充未指定的字段host=127.0.0.1, port=3000, dir=./data, interval=30s, timeout=10s, runtime.maxConcurrentChecks=20, http.method=GET, http.maxBodyBytes=100MB, group="default"
- **THEN** 系统 SHALL 使用内置默认值填充未指定的字段host=127.0.0.1, port=3000, dir=./data, interval=30s, timeout=10s, runtime.maxConcurrentChecks=20, http.method=GET, http.maxBodyBytes=100MB, http.ignoreSSL=false, http.maxRedirects=0, group="default"
#### Scenario: 最简 command 配置文件解析
- **WHEN** 系统读取只包含一个 `type: command` target 和 `command.exec` 的 YAML 配置文件
@@ -23,6 +23,14 @@
- **WHEN** 某个 target 指定 interval、timeout 或对应领域分组中的默认字段
- **THEN** 该 target SHALL 使用其自身的值,不受 defaults 中对应字段影响
#### Scenario: HTTP target 配置 ignoreSSL
- **WHEN** YAML 配置中 HTTP target 设置 `http.ignoreSSL: true`
- **THEN** 系统 SHALL 解析该字段并在执行时跳过 SSL 证书校验
#### Scenario: HTTP target 配置 maxRedirects
- **WHEN** YAML 配置中 HTTP target 设置 `http.maxRedirects: 5`
- **THEN** 系统 SHALL 解析该字段并在执行时允许最多跟随 5 次重定向
### Requirement: CLI 参数
系统 SHALL 通过单一命令行参数接受 YAML 配置文件路径。
@@ -39,7 +47,7 @@
- **THEN** 系统 SHALL 以错误退出并提示文件不存在
### Requirement: 配置校验
系统 SHALL 在启动时对 YAML 配置进行完整校验,校验失败时以非零状态退出并输出清晰的错误信息。
系统 SHALL 在启动时对 YAML 配置进行完整校验,校验失败时以非零状态退出并输出清晰的错误信息。HTTP checker SHALL 对已支持字段执行严格启动期校验;未知字段 SHALL 被忽略,不触发启动失败且不影响运行行为。
#### Scenario: target 缺少必填字段
- **WHEN** YAML 中某个 target 缺少 name 或 type 字段
@@ -77,6 +85,90 @@
- **WHEN** maxBodyBytes 或 maxOutputBytes 值不是有效的 size 格式
- **THEN** 系统 SHALL 以错误退出并提示支持 B、KB、MB、GB 格式
#### Scenario: HTTP method 非法
- **WHEN** YAML 中某个 HTTP target 的 `http.method` 不是 GET、HEAD、POST、PUT、PATCH、DELETE、OPTIONS 之一
- **THEN** 系统 SHALL 以错误退出,提示该 target 的 method 不合法
#### Scenario: URL 格式非法
- **WHEN** YAML 中某个 HTTP target 的 `http.url` 不以 `http://``https://` 开头
- **THEN** 系统 SHALL 以错误退出,提示该 target 的 URL 格式不合法
#### Scenario: maxRedirects 非法
- **WHEN** YAML 中某个 HTTP target 的 `http.maxRedirects` 为负数
- **THEN** 系统 SHALL 以错误退出,提示该 target 的 maxRedirects 必须为非负整数
#### Scenario: maxRedirects 非整数非法
- **WHEN** YAML 中某个 HTTP target 的 `http.maxRedirects` 不是非负整数(如 `1.5``"5"`
- **THEN** 系统 SHALL 以错误退出,提示该 target 的 maxRedirects 必须为非负整数
#### Scenario: ignoreSSL 类型非法
- **WHEN** YAML 中某个 HTTP target 的 `http.ignoreSSL` 不是布尔值
- **THEN** 系统 SHALL 以错误退出,提示该 target 的 ignoreSSL 必须为布尔值
#### Scenario: HTTP headers 类型非法
- **WHEN** YAML 中某个 HTTP target 的 `http.headers` 不是对象,或任一 header 名和值不能作为字符串使用
- **THEN** 系统 SHALL 以错误退出,提示该 target 的 http.headers 格式错误
#### Scenario: HTTP body 类型非法
- **WHEN** YAML 中某个 HTTP target 的 `http.body` 已配置但不是字符串
- **THEN** 系统 SHALL 以错误退出,提示该 target 的 http.body 必须为字符串
#### Scenario: maxBodyBytes 数字非法
- **WHEN** YAML 中某个 HTTP target 的 `http.maxBodyBytes` 或 defaults.http.maxBodyBytes 是负数、非整数或非安全整数
- **THEN** 系统 SHALL 以错误退出,提示 maxBodyBytes 必须为非负安全整数字节数或合法 size 字符串
#### Scenario: status 模式非法
- **WHEN** YAML 中某个 HTTP target 的 `expect.status` 包含不符合 `1xx``5xx` 格式的字符串(如 `"abc"``"2x"``"6xx"`
- **THEN** 系统 SHALL 以错误退出,提示该 target 的 status 模式不合法
#### Scenario: status 数字非法
- **WHEN** YAML 中某个 HTTP target 的 `expect.status` 包含非整数或不在 100-599 范围内的数字
- **THEN** 系统 SHALL 以错误退出,提示该 target 的 status 数字不合法
#### Scenario: maxDurationMs 非法
- **WHEN** YAML 中某个 target 的 `expect.maxDurationMs` 不是非负有限数字
- **THEN** 系统 SHALL 以错误退出,提示该 target 的 expect.maxDurationMs 格式错误
#### Scenario: HTTP expect headers 非法
- **WHEN** YAML 中某个 HTTP target 的 `expect.headers` 不是对象,或某个 header 期望既不是字符串也不是合法 operator
- **THEN** 系统 SHALL 以错误退出,提示该 target 的 expect.headers 格式错误
#### Scenario: HTTP expect body 必须为数组
- **WHEN** YAML 中某个 HTTP target 的 `expect.body` 已配置但不是数组
- **THEN** 系统 SHALL 以错误退出,提示该 target 的 expect.body 必须为数组
#### Scenario: HTTP body rule 缺少支持字段
- **WHEN** YAML 中某个 HTTP target 的 `expect.body` 数组项未包含 contains、regex、json、css、xpath 任一支持字段
- **THEN** 系统 SHALL 以错误退出,提示该 body rule 缺少支持的规则类型
#### Scenario: HTTP body rule 同时配置多个支持字段
- **WHEN** YAML 中某个 HTTP target 的同一条 body rule 同时包含 contains、regex、json、css、xpath 中的多个支持字段
- **THEN** 系统 SHALL 以错误退出,提示每条 body rule 只能配置一种规则类型
#### Scenario: HTTP body regex 非法
- **WHEN** YAML 中某个 HTTP target 的 body regex 规则不是字符串或不是可编译正则表达式
- **THEN** 系统 SHALL 以错误退出,提示该 body regex 不合法
#### Scenario: HTTP body json path 非法
- **WHEN** YAML 中某个 HTTP target 的 body json 规则缺少 path或 path 不符合系统支持的 JSONPath 子集
- **THEN** 系统 SHALL 以错误退出,提示该 body json path 不合法
#### Scenario: HTTP body css selector 非法
- **WHEN** YAML 中某个 HTTP target 的 body css 规则缺少 selector或 selector 不是非空字符串
- **THEN** 系统 SHALL 以错误退出,提示该 body css selector 不合法
#### Scenario: HTTP body xpath path 非法
- **WHEN** YAML 中某个 HTTP target 的 body xpath 规则缺少 path或 path 不是非空字符串,或可被现有 XPath 库静态判定为语法错误
- **THEN** 系统 SHALL 以错误退出,提示该 body xpath path 不合法
#### Scenario: expect operator 类型非法
- **WHEN** YAML 中某个 HTTP expect operator 的 match 不是可编译正则字符串empty/exists 不是布尔值,或 gt/gte/lt/lte 不是有限数字
- **THEN** 系统 SHALL 以错误退出,提示对应 operator 配置不合法
#### Scenario: unknown 字段忽略
- **WHEN** YAML 中某个 HTTP target、expect 或 rule 对象包含未知字段,且所有已支持字段均合法
- **THEN** 系统 SHALL 忽略未知字段并正常启动
### Requirement: size 配置解析
系统 SHALL 支持使用单位字符串配置读取上限,单位包括 `B``KB``MB``GB`
@@ -107,7 +199,7 @@
- **THEN** 系统 SHALL 调用 `Bun.YAML.parse()` 将内容解析为配置对象
### Requirement: expect 配置增强
系统 SHALL 支持 typed target 的领域专用 expect 配置,包括 HTTP 的 `status``headers``body` 和 command 的 `exitCode``stdout``stderr`。内容类 expect MUST 使用数组表达配置顺序。
系统 SHALL 支持 typed target 的领域专用 expect 配置,包括 HTTP 的 `status`(支持精确数字和范围模式)`headers``body` 和 command 的 `exitCode``stdout``stderr`。内容类 expect MUST 使用数组表达配置顺序。
#### Scenario: 解析 HTTP expect 配置
- **WHEN** YAML 配置文件中 HTTP target 的 expect 包含 status、headers、body 规则数组及内部方法
@@ -125,6 +217,14 @@
- **WHEN** HTTP target 未配置 `expect.status`
- **THEN** 系统 SHALL 在执行 expect 时使用默认 `status: [200]` 语义
#### Scenario: 配置 HTTP status 范围模式
- **WHEN** HTTP target 配置 `expect.status: ["2xx"]`
- **THEN** 系统 SHALL 在执行 expect 时匹配所有 200-299 状态码
#### Scenario: 配置 HTTP status 混合模式
- **WHEN** HTTP target 配置 `expect.status: ["2xx", 301]`
- **THEN** 系统 SHALL 在执行 expect 时匹配所有 200-299 状态码或精确匹配 301
#### Scenario: 不配置 command exitCode
- **WHEN** command target 未配置 `expect.exitCode`
- **THEN** 系统 SHALL 在执行 expect 时使用默认 `exitCode: [0]` 语义

View File

@@ -121,7 +121,7 @@
#### Scenario: HTTP target config 序列化
- **WHEN** 同步 HTTP target
- **THEN** targets.config SHALL 存储 JSON包含 url、method、headers、body、maxBodyBytes
- **THEN** targets.config SHALL 存储 JSON包含 url、method、headers、body、maxBodyBytes、ignoreSSL、maxRedirects
#### Scenario: command target config 序列化
- **WHEN** 同步 command target

View File

@@ -31,7 +31,7 @@
- **THEN** 系统 MUST 同时最多运行 3 个检查,其余检查等待并发槽位释放
### Requirement: HTTP 拨测执行
系统 SHALL 对 `type: http` 的目标执行 HTTP 请求,支持 GET、POST、PUT、DELETE、PATCH、HEAD 方法,并携带 `http.headers``http.body`
系统 SHALL 对 `type: http` 的目标执行 HTTP 请求,支持 GET、POST、PUT、DELETE、PATCH、HEAD、OPTIONS 方法,并携带 `http.headers``http.body`系统 SHALL 支持 `http.ignoreSSL` 配置跳过 SSL 证书校验,支持 `http.maxRedirects` 配置控制重定向行为。HTTP response body 读取 SHALL 受 `http.maxBodyBytes` 流式限制,重定向跟随 SHALL 释放被跟随响应的 body。
#### Scenario: 执行 GET 请求
- **WHEN** 目标配置 method 为 GET
@@ -47,7 +47,55 @@
#### Scenario: HTTP body 读取上限
- **WHEN** HTTP response body 超过该 target 的 maxBodyBytes
- **THEN** 系统 MUST 停止读取并记录 `matched=false`结构化输出超限错误
- **THEN** 系统 MUST 停止继续读取 response body并记录 `matched=false``failure.kind="error"``failure.phase="body"`结构化输出超限错误
#### Scenario: HTTP body 大小等于上限
- **WHEN** HTTP response body 的字节数等于该 target 的 maxBodyBytes
- **THEN** 系统 SHALL 允许该 body 进入后续解码和 expect 校验
#### Scenario: HTTP body 上限为 0
- **WHEN** HTTP target 配置 maxBodyBytes 为 0 且响应体非空
- **THEN** 系统 SHALL 停止读取并记录 body 超限错误
#### Scenario: 忽略 SSL 证书校验
- **WHEN** 目标配置 `http.ignoreSSL: true` 且目标 URL 为 HTTPS
- **THEN** 系统 SHALL 跳过 SSL 证书校验,即使证书无效也正常完成请求
#### Scenario: 不忽略 SSL 证书校验
- **WHEN** 目标未配置 `http.ignoreSSL` 或配置为 `false`,且目标 URL 使用自签名证书
- **THEN** 系统 SHALL 因 SSL 证书校验失败而记录请求错误
#### Scenario: 默认不跟随重定向
- **WHEN** 目标未配置 `http.maxRedirects` 或配置为 0且服务端返回 301/302
- **THEN** 系统 SHALL 不跟随重定向,直接返回 301/302 的响应状态码和响应头
#### Scenario: 配置跟随重定向
- **WHEN** 目标配置 `http.maxRedirects: 5` 且服务端返回重定向
- **THEN** 系统 SHALL 跟随重定向,最多跟随 5 次,并在跟随前释放当前重定向响应的 body
#### Scenario: 超过最大重定向次数
- **WHEN** 目标配置 `http.maxRedirects: 1` 且服务端连续返回两次重定向
- **THEN** 系统 SHALL 只跟随第一次重定向,并返回第二次重定向响应的状态码和响应头
#### Scenario: POST 重定向改 GET
- **WHEN** POST 请求遇到 301/302 或任意方法请求遇到 303且系统决定按 GET 跟随重定向
- **THEN** 系统 SHALL 移除请求 body并清理 content-type、content-length 等 body 相关 headers 后发起后续 GET 请求
#### Scenario: 跨 origin 重定向敏感 header
- **WHEN** HTTP 请求跟随重定向到不同 origin
- **THEN** 系统 SHALL NOT 将 authorization、cookie 等敏感 headers 转发到新的 origin
#### Scenario: 响应体编码自动检测
- **WHEN** HTTP 响应的 `Content-Type` header 包含 `charset=gbk``charset="gbk"`
- **THEN** 系统 SHALL 使用 GBK 编码解码响应体,而非硬编码 UTF-8
#### Scenario: 响应体编码回退 UTF-8
- **WHEN** HTTP 响应的 `Content-Type` header 未指定 charset
- **THEN** 系统 SHALL 使用 UTF-8 编码解码响应体
#### Scenario: 响应体编码不支持
- **WHEN** HTTP 响应的 `Content-Type` header 指定了当前运行时不支持的 charset
- **THEN** 系统 SHALL 记录 `matched=false``failure.kind="error"``failure.phase="body"` 的解码失败结果
### Requirement: 请求超时控制
系统 SHALL 对每次 checker 执行实施超时控制,超时时间使用目标配置的 timeout 值。
@@ -65,16 +113,24 @@
- **THEN** 系统 SHALL 正常记录执行结果并进入 expect 校验
### Requirement: expect 校验
系统 SHALL 在 checker 执行完成后根据目标类型的 expect 配置校验观测结果,校验结果和首个失败原因记入 check result。
系统 SHALL 在 checker 执行完成后根据目标类型的 expect 配置校验观测结果,校验结果和首个失败原因记入 check result。HTTP checker 的 `durationMs` SHALL 表示完整 checker 执行耗时,包括重定向、响应体读取、响应体解码和 expect 校验。
#### Scenario: HTTP 默认状态码
- **WHEN** HTTP target 未配置 `expect.status`
- **THEN** 系统 SHALL 按默认 `status: [200]` 校验响应状态码
#### Scenario: 校验 HTTP 状态码
#### Scenario: 校验 HTTP 状态码精确值
- **WHEN** HTTP target 配置了 `expect.status: [200, 201]`
- **THEN** 系统 SHALL 检查响应状态码是否在列表中,将匹配结果记录到 matched 字段
#### Scenario: 校验 HTTP 状态码范围模式
- **WHEN** HTTP target 配置了 `expect.status: ["2xx"]`
- **THEN** 系统 SHALL 检查响应状态码是否在 200-299 范围内
#### Scenario: 校验 HTTP 状态码混合模式
- **WHEN** HTTP target 配置了 `expect.status: ["2xx", 301]`,且响应状态码为 204
- **THEN** 系统 SHALL 判定状态码匹配204 属于 2xx 范围)
#### Scenario: 校验 HTTP 响应头
- **WHEN** HTTP target 配置了 `expect.headers: {"Content-Type": {contains: "application/json"}}`
- **THEN** 系统 SHALL 检查响应头是否符合指定规则,全部匹配时继续后续阶段
@@ -83,6 +139,26 @@
- **WHEN** HTTP target 配置了有序 `expect.body` 规则数组
- **THEN** 系统 SHALL 按数组顺序执行 body 规则,任一失败立即记录 failure 并停止后续规则
#### Scenario: 校验 HTTP 完整耗时阈值
- **WHEN** 目标配置了 `expect.maxDurationMs`,且 HTTP checker 完整执行含重定向、body 读取、解码和 expect后的 durationMs 超过阈值
- **THEN** 系统 SHALL 判定 duration 不匹配,记录完整 durationMs 和 duration failure
#### Scenario: HTTP body 前耗时已超阈值
- **WHEN** HTTP target 配置了 body 校验和 `expect.maxDurationMs`,且进入 body 读取前的已耗时已超过阈值
- **THEN** 系统 SHALL 直接返回 duration failure且 MUST NOT 读取 response body
#### Scenario: HTTP body 失败优先于后续 duration 检查
- **WHEN** HTTP target 配置了 body 校验和 `expect.maxDurationMs`body 阶段存在失败,且完整执行后 duration 也超过阈值
- **THEN** 系统 SHALL 返回 body 阶段的失败首个失败为准durationMs SHALL 记录完整耗时
#### Scenario: HTTP 慢响应体计入耗时
- **WHEN** HTTP target 配置了 body 校验和 `expect.maxDurationMs`,且响应头很快返回但响应体读取导致完整执行耗时超过阈值
- **THEN** 系统 SHALL 判定 duration 不匹配并记录完整 durationMs
#### Scenario: 多条 expect 规则
- **WHEN** 目标同时配置状态、duration、元数据和内容规则
- **THEN** 系统 SHALL 所有规则全部通过时 matched 为 true任一不通过则为 false 并记录首个失败原因
#### Scenario: command 默认 exitCode
- **WHEN** command target 未配置 `expect.exitCode`
- **THEN** 系统 SHALL 按默认 `exitCode: [0]` 校验命令退出码
@@ -91,21 +167,21 @@
- **WHEN** command target 配置了有序 `expect.stdout` 规则数组
- **THEN** 系统 SHALL 按数组顺序执行 stdout 规则,任一失败立即记录 failure 并停止后续规则
#### Scenario: 校验耗时阈值
- **WHEN** 目标配置了 `expect.maxDurationMs`
- **THEN** 系统 SHALL 检查实际 durationMs 是否超过阈值,将匹配结果记录到 matched 字段
#### Scenario: 多条 expect 规则
- **WHEN** 目标同时配置状态、duration、元数据和内容规则
- **THEN** 系统 SHALL 所有规则全部通过时 matched 为 true任一不通过则为 false 并记录首个失败原因
### Requirement: Body 校验按需解析
系统 SHALL 仅在 HTTP target 配置了 body 校验且 status、duration、headers 阶段均通过时才读取并解析响应体,避免不必要的读取和解析开销
系统 SHALL 仅在 HTTP target 配置了 body 校验且 status、headers 阶段均通过,并且进入 body 前未确定 duration 已失败时才读取并解析响应体避免不必要的读取和解析开销。HTTP target 未配置 body 校验时,系统 SHALL NOT 读取 response body
#### Scenario: status 失败时不读取 body
- **WHEN** HTTP target 的 status 阶段不匹配
- **THEN** 系统 SHALL 立即返回 matched=false且 MUST NOT 读取 response body
#### Scenario: headers 失败时不读取 body
- **WHEN** HTTP target 的 status 阶段匹配但 headers 阶段不匹配
- **THEN** 系统 SHALL 立即返回 matched=false且 MUST NOT 读取 response body
#### Scenario: 进入 body 前 duration 已失败时不读取 body
- **WHEN** HTTP target 已配置 `expect.maxDurationMs`,且进入 body 读取前的已耗时已经超过阈值
- **THEN** 系统 SHALL 返回 duration failure且 MUST NOT 读取 response body
#### Scenario: 仅配置 contains 时不解析 JSON
- **WHEN** HTTP target 仅配置 body contains 规则而未配置 json/css/xpath 规则
- **THEN** 系统 SHALL 不执行 JSON.parse 或 HTML/XML 解析
@@ -114,6 +190,21 @@
- **WHEN** HTTP target 配置了 body json 规则但响应体不是合法 JSON
- **THEN** 系统 SHALL 判定 matched 为 false并记录 json 规则对应的 failure.path
### Requirement: HTTP 运行期错误归属
HTTP checker SHALL 将运行期失败归属到实际失败阶段。请求、网络、TLS 和 timeout 错误 SHALL 记录为 request 阶段错误body 超限、响应体解码失败、响应内容解析失败 SHALL 记录为 body 阶段错误expect 不匹配 SHALL 记录为对应 mismatch 阶段。
#### Scenario: 请求错误归属 request
- **WHEN** HTTP 请求因为网络、TLS 或 timeout 失败
- **THEN** 系统 SHALL 记录 `matched=false``failure.kind="error"``failure.phase="request"`
#### Scenario: body 超限归属 body
- **WHEN** HTTP response body 超过 maxBodyBytes
- **THEN** 系统 SHALL 记录 `failure.kind="error"``failure.phase="body"``failure.path="body"`
#### Scenario: body 解析错误归属 body
- **WHEN** HTTP response body 已读取但解码、JSON 解析、CSS 解析或 XPath 解析失败
- **THEN** 系统 SHALL 记录 `failure.phase="body"`,且 SHALL NOT 将该失败记录为 request 错误
### Requirement: 拨测结果记录
系统 SHALL 在每次 checker 完成后,将结果写入 SQLite 数据存储,包含 target_id、timestamp、matched、duration_ms、status_detail、failure 字段。

View File

@@ -0,0 +1,25 @@
# Capability: windows-test-compat
## Purpose
确保测试在 Windows 平台上的兼容性,包括文件句柄释放后的目录清理重试机制和跨平台命令测试约定。
## Requirements
### Requirement: 测试临时目录清理 SHALL 支持重试
使用 SQLite 数据库的测试 SHALL 在 `afterAll` 中使用带重试的目录删除机制,确保在 Windows 上文件句柄未及时释放时不会导致测试失败。
#### Scenario: Windows 上 SQLite 文件句柄延迟释放
- **WHEN** 测试在 Windows 上运行,`store.close()` 后立即尝试删除临时目录
- **THEN** 删除操作 SHALL 自动重试(最多 3 次,间隔 200ms直到成功或耗尽重试次数
### Requirement: 命令检测器测试 SHALL 使用跨平台命令
命令检测器的测试 SHALL 使用 `bun -e` 脚本替代系统 `echo` 命令,确保测试断言在所有平台上行为一致。
#### Scenario: 验证非 shell 模式下特殊字符不被展开
- **WHEN** 通过 `Bun.spawn` 执行 `bun -e "console.log('*')"` 并检查 stdout 包含 `*`
- **THEN** 测试 SHALL 在 Windows 和 Linux 上均返回 `matched: true`

View File

@@ -126,6 +126,29 @@ targets:
expect:
status: [200, 201, 204]
- name: "状态码范围匹配"
type: http
http:
url: "https://httpbin.org/status/204"
expect:
status: ["2xx"]
- name: "自签名证书跳过 SSL"
type: http
http:
url: "https://internal.local/health"
ignoreSSL: true
expect:
status: ["2xx"]
- name: "跟随重定向"
type: http
http:
url: "https://httpbin.org/redirect/1"
maxRedirects: 5
expect:
status: [200]
# ========== Command targets ==========
- name: "uname 输出匹配"

View File

@@ -59,9 +59,6 @@ export function checkHttpExpect(
const statusResult = checkStatus(statusCode, expect.status ?? [200]);
if (!statusResult.matched) return statusResult;
const durationResult = checkDuration(durationMs, expect.maxDurationMs);
if (!durationResult.matched) return durationResult;
const headersResult = checkHeaders(headers, expect.headers);
if (!headersResult.matched) return headersResult;
@@ -76,11 +73,20 @@ export function checkHttpExpect(
if (!bodyResult.matched) return bodyResult;
}
const durationResult = checkDuration(durationMs, expect.maxDurationMs);
if (!durationResult.matched) return durationResult;
return { failure: null, matched: true };
}
export function checkStatus(statusCode: number, allowed: number[]): ExpectResult {
if (!allowed.includes(statusCode)) {
export function checkStatus(statusCode: number, allowed: Array<number | string>): ExpectResult {
const matched = allowed.some((pattern) => {
if (typeof pattern === "number") return statusCode === pattern;
const base = parseInt(pattern[0]!, 10) * 100;
return statusCode >= base && statusCode < base + 100;
});
if (!matched) {
return {
failure: mismatchFailure(
"status",

View File

@@ -4,8 +4,17 @@ import type { CheckResult, HttpTargetConfig, ResolvedHttpTarget, ResolvedTarget,
import type { Checker, CheckerContext, ResolveContext } from "../types";
import { parseSize } from "../../size";
import { errorFailure } from "../shared/failure";
import { checkHttpExpect } from "./expect";
import { checkBodyExpect } from "../shared/body";
import { checkDuration } from "../shared/duration";
import { errorFailure, mismatchFailure } from "../shared/failure";
import { checkHeaders, checkStatus } from "./expect";
import { validateHttpConfig, validateHttpExpect } from "./validate";
const ALLOWED_METHODS = new Set(["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"]);
const CHARSET_RE = /charset="?([^";\s]+)"?/i;
const URL_RE = /^https?:\/\/.+/;
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
const SENSITIVE_HEADERS = new Set(["authorization", "cookie"]);
export class HttpChecker implements Checker {
readonly type = "http";
@@ -13,71 +22,86 @@ export class HttpChecker implements Checker {
async execute(target: ResolvedTarget, ctx: CheckerContext): Promise<CheckResult> {
const t = target as ResolvedHttpTarget;
const timestamp = new Date().toISOString();
const expect = t.expect;
const start = performance.now();
try {
const start = performance.now();
const response = await fetch(t.http.url, {
const response = await fetchWithRedirects(t.http.url, t.http.maxRedirects, {
body: t.http.method !== "GET" && t.http.method !== "HEAD" ? t.http.body : undefined,
headers: t.http.headers,
method: t.http.method,
redirect: "manual",
signal: ctx.signal,
...(t.http.ignoreSSL ? { tls: { rejectUnauthorized: false } } : {}),
});
const durationMs = Math.round(performance.now() - start);
const statusCode = response.status;
const responseHeaders = Object.fromEntries(response.headers);
const hasBodyRules = !!(t.expect?.body && t.expect.body.length > 0);
const preBodyExpect = t.expect
? { headers: t.expect.headers, maxDurationMs: t.expect.maxDurationMs, status: t.expect.status }
: undefined;
const preBodyResult = checkHttpExpect(statusCode, responseHeaders, null, durationMs, preBodyExpect);
if (!hasBodyRules || !preBodyResult.matched) {
return {
durationMs,
failure: preBodyResult.failure,
matched: preBodyResult.matched,
statusDetail: `HTTP ${statusCode}`,
targetName: t.name,
timestamp,
};
const statusResult = checkStatus(statusCode, expect?.status ?? [200]);
if (!statusResult.matched) {
return makeResult(t, timestamp, performance.now() - start, statusResult.failure, statusCode);
}
const bodyBuffer = await response.arrayBuffer();
if (bodyBuffer.byteLength > t.http.maxBodyBytes) {
return {
durationMs,
failure: errorFailure("body", "body", `响应体大小 ${bodyBuffer.byteLength} 超过限制 ${t.http.maxBodyBytes}`),
matched: false,
statusDetail: `HTTP ${statusCode}`,
targetName: t.name,
timestamp,
};
const headersResult = checkHeaders(responseHeaders, expect?.headers);
if (!headersResult.matched) {
return makeResult(t, timestamp, performance.now() - start, headersResult.failure, statusCode);
}
const body = new TextDecoder().decode(bodyBuffer);
const fullResult = checkHttpExpect(statusCode, responseHeaders, body, durationMs, t.expect);
const hasBodyRules = !!(expect?.body && expect.body.length > 0);
return {
durationMs,
failure: fullResult.failure,
matched: fullResult.matched,
statusDetail: `HTTP ${statusCode}`,
targetName: t.name,
timestamp,
};
if (hasBodyRules && expect?.maxDurationMs !== undefined) {
const elapsed = performance.now() - start;
if (elapsed > expect.maxDurationMs) {
const durationMs = Math.round(elapsed);
return makeResult(
t,
timestamp,
elapsed,
mismatchFailure(
"duration",
"duration",
`<=${expect.maxDurationMs}ms`,
durationMs,
`duration ${durationMs}ms > ${expect.maxDurationMs}ms`,
),
statusCode,
);
}
}
if (hasBodyRules) {
const bodyReadResult = await readBodyStream(response, t.http.maxBodyBytes);
if (!bodyReadResult.ok) {
return makeResult(t, timestamp, performance.now() - start, bodyReadResult.failure, statusCode);
}
const decodeResult = decodeBody(bodyReadResult.data, response.headers);
if (!decodeResult.ok) {
return makeResult(t, timestamp, performance.now() - start, decodeResult.failure, statusCode);
}
const bodyResult = checkBodyExpect(decodeResult.text, expect.body);
if (!bodyResult.matched) {
return makeResult(t, timestamp, performance.now() - start, bodyResult.failure, statusCode);
}
}
const durationMs = Math.round(performance.now() - start);
const durationResult = checkDuration(durationMs, expect?.maxDurationMs);
if (!durationResult.matched) {
return makeResult(t, timestamp, durationMs, durationResult.failure, statusCode);
}
return makeResult(t, timestamp, durationMs, null, statusCode);
} catch (error) {
const durationMs = Math.round(performance.now() - start);
const isTimeout = ctx.signal.aborted || (error instanceof DOMException && error.name === "AbortError");
return {
durationMs: null,
durationMs,
failure: errorFailure(
"status",
"request",
"request",
isTimeout ? `请求超时 (${t.timeoutMs}ms)` : isError(error) ? error.message : String(error),
),
@@ -93,10 +117,45 @@ export class HttpChecker implements Checker {
const t = target as TargetConfig & { http: HttpTargetConfig; type: "http" };
const httpDefaults = context.defaults.http;
if (!t.http.url || t.http.url.trim() === "") {
if (!t.http || typeof t.http !== "object") {
throw new Error(`target "${t.name}" 缺少 http.url 字段`);
}
validateHttpConfig(t.http, t.name);
if (typeof t.http.url !== "string" || t.http.url.trim() === "") {
throw new Error(`target "${t.name}" 缺少 http.url 字段`);
}
const rawMethod = t.http.method ?? httpDefaults?.method ?? "GET";
if (typeof rawMethod !== "string") {
throw new Error(`target "${t.name}" 的 http.method 必须为字符串`);
}
const method = rawMethod.toUpperCase();
if (!ALLOWED_METHODS.has(method)) {
throw new Error(
`target "${t.name}" 的 http.method "${method}" 不合法,合法值: ${[...ALLOWED_METHODS].join(", ")}`,
);
}
if (!URL_RE.test(t.http.url)) {
throw new Error(`target "${t.name}" 的 http.url "${t.http.url}" 格式不合法,必须以 http:// 或 https:// 开头`);
}
if (t.http.ignoreSSL !== undefined && typeof t.http.ignoreSSL !== "boolean") {
throw new Error(`target "${t.name}" 的 http.ignoreSSL 必须为布尔值`);
}
if (
t.http.maxRedirects !== undefined &&
(typeof t.http.maxRedirects !== "number" || !Number.isInteger(t.http.maxRedirects) || t.http.maxRedirects < 0)
) {
throw new Error(`target "${t.name}" 的 http.maxRedirects 必须为非负整数`);
}
validateHttpExpect(target.expect, t.name);
const maxBodyBytes = parseSize(t.http.maxBodyBytes ?? httpDefaults?.maxBodyBytes ?? "100MB");
return {
@@ -105,8 +164,10 @@ export class HttpChecker implements Checker {
http: {
body: t.http.body,
headers: { ...(httpDefaults?.headers ?? {}), ...(t.http.headers ?? {}) },
ignoreSSL: t.http.ignoreSSL ?? false,
maxBodyBytes,
method: t.http.method ?? httpDefaults?.method ?? "GET",
maxRedirects: t.http.maxRedirects ?? 0,
method,
url: t.http.url,
},
intervalMs: context.defaultIntervalMs,
@@ -122,7 +183,9 @@ export class HttpChecker implements Checker {
config: JSON.stringify({
body: t.http.body,
headers: t.http.headers,
ignoreSSL: t.http.ignoreSSL,
maxBodyBytes: t.http.maxBodyBytes,
maxRedirects: t.http.maxRedirects,
method: t.http.method,
url: t.http.url,
}),
@@ -130,3 +193,147 @@ export class HttpChecker implements Checker {
};
}
}
function buildRedirectInit(init: RequestInit, statusCode: number, fromUrl: string, toUrl: string): RequestInit {
let newInit = { ...init };
const method = init.method?.toUpperCase();
if (statusCode === 303 || ((statusCode === 301 || statusCode === 302) && method === "POST")) {
const headers =
typeof init.headers === "object" && init.headers !== null
? { ...(init.headers as Record<string, string>) }
: undefined;
if (headers) {
for (const key of Object.keys(headers)) {
const lower = key.toLowerCase();
if (lower === "content-type" || lower === "content-length") {
delete headers[key];
}
}
}
newInit = { ...newInit, body: undefined, headers, method: "GET" };
}
try {
const fromOrigin = new URL(fromUrl).origin;
const toOrigin = new URL(toUrl).origin;
if (fromOrigin !== toOrigin && newInit.headers && typeof newInit.headers === "object") {
const headers = { ...(newInit.headers as Record<string, string>) };
for (const key of Object.keys(headers)) {
if (SENSITIVE_HEADERS.has(key.toLowerCase())) {
delete headers[key];
}
}
newInit.headers = headers;
}
} catch {
/* URL parsing failed, keep headers */
}
return newInit;
}
function decodeBody(
data: Uint8Array,
headers: Headers,
): { failure: CheckResult["failure"]; ok: false } | { ok: true; text: string } {
const contentType = headers.get("content-type") ?? "";
const charsetMatch = CHARSET_RE.exec(contentType);
const encoding = charsetMatch?.[1]?.toLowerCase() ?? "utf-8";
try {
const text = new TextDecoder(encoding).decode(data);
return { ok: true, text };
} catch {
return {
failure: errorFailure("body", "body", `不支持的字符编码: ${encoding}`),
ok: false,
};
}
}
async function fetchWithRedirects(url: string, maxRedirects: number, init: RequestInit): Promise<Response> {
let currentUrl = url;
let currentInit = init;
for (let followed = 0; ; followed++) {
const response = await fetch(currentUrl, currentInit);
if (!REDIRECT_STATUSES.has(response.status)) return response;
const location = response.headers.get("location");
if (!location || followed >= maxRedirects) return response;
try {
await response.arrayBuffer();
} catch {
/* ignore body drain error */
}
const nextUrl = new URL(location, currentUrl).toString();
currentInit = buildRedirectInit(currentInit, response.status, currentUrl, nextUrl);
currentUrl = nextUrl;
}
}
function makeResult(
t: ResolvedHttpTarget,
timestamp: string,
elapsed: number,
failure: CheckResult["failure"],
statusCode: number,
): CheckResult {
return {
durationMs: Math.round(elapsed),
failure,
matched: failure === null,
statusDetail: `HTTP ${statusCode}`,
targetName: t.name,
timestamp,
};
}
async function readBodyStream(
response: Response,
maxBodyBytes: number,
): Promise<{ data: Uint8Array; ok: true } | { failure: CheckResult["failure"]; ok: false }> {
const reader = response.body?.getReader();
if (!reader) {
return { data: new Uint8Array(0), ok: true };
}
const chunks: Uint8Array[] = [];
let totalBytes = 0;
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
totalBytes += value.byteLength;
if (totalBytes > maxBodyBytes) {
try {
await reader.cancel();
} catch {
/* ignore cancel error */
}
return {
failure: errorFailure("body", "body", `响应体大小超过限制 ${maxBodyBytes}`),
ok: false,
};
}
chunks.push(value);
}
} finally {
reader.releaseLock();
}
const result = new Uint8Array(totalBytes);
let offset = 0;
for (const chunk of chunks) {
result.set(chunk, offset);
offset += chunk.byteLength;
}
return { data: result, ok: true };
}

View File

@@ -0,0 +1,251 @@
import { DOMParser } from "@xmldom/xmldom";
import * as xpath from "xpath";
const BODY_RULE_TYPES = ["contains", "regex", "json", "css", "xpath"];
const OPERATOR_KEYS = new Set(["contains", "empty", "equals", "exists", "gt", "gte", "lt", "lte", "match"]);
export function validateHttpConfig(http: unknown, targetName: string): void {
if (!http || typeof http !== "object") {
throw new Error(`target "${targetName}" 缺少 http 配置`);
}
const h = http as Record<string, unknown>;
if ("headers" in h && h["headers"] !== undefined) {
if (typeof h["headers"] !== "object" || h["headers"] === null || Array.isArray(h["headers"])) {
throw new Error(`target "${targetName}" 的 http.headers 必须为对象`);
}
for (const [key, value] of Object.entries(h["headers"] as Record<string, unknown>)) {
if (typeof value !== "string") {
throw new Error(`target "${targetName}" 的 http.headers.${key} 必须为字符串`);
}
}
}
if ("body" in h && h["body"] !== undefined) {
if (typeof h["body"] !== "string") {
throw new Error(`target "${targetName}" 的 http.body 必须为字符串`);
}
}
}
export function validateHttpExpect(expect: unknown, targetName: string): void {
if (expect === undefined || expect === null) return;
if (typeof expect !== "object" || Array.isArray(expect)) {
throw new Error(`target "${targetName}" 的 expect 必须为对象`);
}
const e = expect as Record<string, unknown>;
if ("status" in e) validateStatus(e["status"], targetName);
if ("maxDurationMs" in e) validateMaxDurationMs(e["maxDurationMs"], targetName);
if ("headers" in e) validateExpectHeaders(e["headers"], targetName);
if ("body" in e) validateBodyRules(e["body"], targetName);
}
function validateBodyRules(body: unknown, targetName: string): void {
if (!Array.isArray(body)) {
throw new Error(`target "${targetName}" 的 expect.body 必须为数组`);
}
for (let i = 0; i < body.length; i++) {
validateSingleBodyRule(body[i], i, targetName);
}
}
function validateExpectHeaders(headers: unknown, targetName: string): void {
if (typeof headers !== "object" || headers === null || Array.isArray(headers)) {
throw new Error(`target "${targetName}" 的 expect.headers 必须为对象`);
}
for (const [key, value] of Object.entries(headers as Record<string, unknown>)) {
if (typeof value === "string") continue;
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
validateOperators(value as Record<string, unknown>, targetName, `expect.headers.${key}`);
} else {
throw new Error(`target "${targetName}" 的 expect.headers.${key} 必须为字符串或操作符对象`);
}
}
}
function validateJsonPath(path: string, targetName: string, rulePath: string): void {
const segments = path.slice(2).split(".");
for (const seg of segments) {
if (seg === "") {
throw new Error(`target "${targetName}" 的 ${rulePath}.path 包含空段`);
}
const bracketMatch = /^(.+?)\[(\d+)\]$/.exec(seg);
if (bracketMatch?.[1]!.trim() === "") {
throw new Error(`target "${targetName}" 的 ${rulePath}.path 数组访问缺少属性名`);
}
}
}
function validateMaxDurationMs(value: unknown, targetName: string): void {
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
throw new Error(`target "${targetName}" 的 expect.maxDurationMs 必须为非负有限数字`);
}
}
function validateOperators(ops: Record<string, unknown>, targetName: string, path: string): void {
for (const [key, value] of Object.entries(ops)) {
if (!OPERATOR_KEYS.has(key)) continue;
switch (key) {
case "contains":
if (typeof value !== "string") {
throw new Error(`target "${targetName}" 的 ${path}.contains 必须为字符串`);
}
break;
case "empty":
case "exists":
if (typeof value !== "boolean") {
throw new Error(`target "${targetName}" 的 ${path}.${key} 必须为布尔值`);
}
break;
case "equals":
if (typeof value !== "boolean" && typeof value !== "number" && typeof value !== "string" && value !== null) {
throw new Error(`target "${targetName}" 的 ${path}.equals 类型不合法`);
}
if (typeof value === "number" && !Number.isFinite(value)) {
throw new Error(`target "${targetName}" 的 ${path}.equals 不能为 NaN 或 Infinity`);
}
break;
case "gt":
case "gte":
case "lt":
case "lte":
if (typeof value !== "number" || !Number.isFinite(value)) {
throw new Error(`target "${targetName}" 的 ${path}.${key} 必须为有限数字`);
}
break;
case "match":
if (typeof value !== "string") {
throw new Error(`target "${targetName}" 的 ${path}.match 必须为字符串`);
}
try {
new RegExp(value);
} catch {
throw new Error(`target "${targetName}" 的 ${path}.match 正则不合法`);
}
break;
}
}
}
function validateSingleBodyRule(rule: unknown, index: number, targetName: string): void {
if (typeof rule !== "object" || rule === null) {
throw new Error(`target "${targetName}" 的 expect.body[${index}] 必须为对象`);
}
const ruleObj = rule as Record<string, unknown>;
const found: string[] = [];
for (const type of BODY_RULE_TYPES) {
if (type in ruleObj) found.push(type);
}
if (found.length === 0) {
throw new Error(
`target "${targetName}" 的 expect.body[${index}] 缺少支持的规则类型contains/regex/json/css/xpath`,
);
}
if (found.length > 1) {
throw new Error(
`target "${targetName}" 的 expect.body[${index}] 只能配置一种规则类型,当前包含: ${found.join(", ")}`,
);
}
const ruleType = found[0]!;
const rulePath = `expect.body[${index}]`;
switch (ruleType) {
case "contains":
if (typeof ruleObj["contains"] !== "string") {
throw new Error(`target "${targetName}" 的 ${rulePath}.contains 必须为字符串`);
}
break;
case "css": {
const cssRule = ruleObj["css"];
if (typeof cssRule !== "object" || cssRule === null) {
throw new Error(`target "${targetName}" 的 ${rulePath}.css 必须为对象`);
}
const cr = cssRule as Record<string, unknown>;
if (typeof cr["selector"] !== "string" || cr["selector"].trim() === "") {
throw new Error(`target "${targetName}" 的 ${rulePath}.css.selector 必须为非空字符串`);
}
const cssOps: Record<string, unknown> = {};
for (const [k, v] of Object.entries(cr)) {
if (k !== "selector" && k !== "attr") cssOps[k] = v;
}
validateOperators(cssOps, targetName, `${rulePath}.css`);
break;
}
case "json": {
const jsonRule = ruleObj["json"];
if (typeof jsonRule !== "object" || jsonRule === null) {
throw new Error(`target "${targetName}" 的 ${rulePath}.json 必须为对象`);
}
const jr = jsonRule as Record<string, unknown>;
if (typeof jr["path"] !== "string" || !jr["path"].startsWith("$.") || jr["path"].length <= 2) {
throw new Error(`target "${targetName}" 的 ${rulePath}.json.path 必须为以 "$." 开头的有效 JSONPath`);
}
validateJsonPath(jr["path"], targetName, `${rulePath}.json`);
const jsonOps: Record<string, unknown> = {};
for (const [k, v] of Object.entries(jr)) {
if (k !== "path") jsonOps[k] = v;
}
validateOperators(jsonOps, targetName, `${rulePath}.json`);
break;
}
case "regex":
if (typeof ruleObj["regex"] !== "string") {
throw new Error(`target "${targetName}" 的 ${rulePath}.regex 必须为字符串`);
}
try {
new RegExp(ruleObj["regex"]);
} catch {
throw new Error(`target "${targetName}" 的 ${rulePath}.regex 正则不合法`);
}
break;
case "xpath": {
const xpathRule = ruleObj["xpath"];
if (typeof xpathRule !== "object" || xpathRule === null) {
throw new Error(`target "${targetName}" 的 ${rulePath}.xpath 必须为对象`);
}
const xr = xpathRule as Record<string, unknown>;
if (typeof xr["path"] !== "string" || xr["path"].trim() === "") {
throw new Error(`target "${targetName}" 的 ${rulePath}.xpath.path 必须为非空字符串`);
}
try {
const doc = new DOMParser().parseFromString("<x/>", "text/xml");
xpath.select(xr["path"], doc as unknown as Node);
} catch {
throw new Error(`target "${targetName}" 的 ${rulePath}.xpath.path xpath 不合法`);
}
const xpathOps: Record<string, unknown> = {};
for (const [k, v] of Object.entries(xr)) {
if (k !== "path") xpathOps[k] = v;
}
validateOperators(xpathOps, targetName, `${rulePath}.xpath`);
break;
}
}
}
function validateStatus(status: unknown, targetName: string): void {
if (!Array.isArray(status)) {
throw new Error(`target "${targetName}" 的 expect.status 必须为数组`);
}
for (const p of status) {
if (typeof p === "number") {
if (!Number.isInteger(p) || p < 100 || p > 599) {
throw new Error(`target "${targetName}" 的 expect.status 数字 ${p} 不合法,必须为 100-599 之间的整数`);
}
} else if (typeof p === "string") {
if (!/^[1-5]xx$/.test(p)) {
throw new Error(`target "${targetName}" 的 expect.status 模式 "${p}" 不合法,字符串必须为 "1xx" 到 "5xx" 格式`);
}
} else {
throw new Error(`target "${targetName}" 的 expect.status 只能包含数字或范围模式字符串`);
}
}
}

View File

@@ -1,7 +1,12 @@
const SIZE_REGEX = /^(\d+(?:\.\d+)?)(B|KB|MB|GB)$/;
export function parseSize(value: number | string): number {
if (typeof value === "number") return value;
if (typeof value === "number") {
if (!Number.isInteger(value) || value < 0 || !Number.isSafeInteger(value)) {
throw new Error(`无效的 size 数值: ${value},必须为非负安全整数`);
}
return value;
}
const match = SIZE_REGEX.exec(value);
if (!match) {

View File

@@ -72,13 +72,15 @@ export interface HttpExpectConfig {
body?: BodyRule[];
headers?: Record<string, HeaderExpect>;
maxDurationMs?: number;
status?: number[];
status?: Array<number | string>;
}
export interface HttpTargetConfig {
body?: string;
headers?: Record<string, string>;
ignoreSSL?: boolean;
maxBodyBytes?: string;
maxRedirects?: number;
method?: string;
url: string;
}
@@ -113,7 +115,9 @@ export interface ResolvedCommandTarget {
export interface ResolvedHttpConfig {
body?: string;
headers: Record<string, string>;
ignoreSSL: boolean;
maxBodyBytes: number;
maxRedirects: number;
method: string;
url: string;
}

13
tests/helpers.ts Normal file
View File

@@ -0,0 +1,13 @@
import { rm } from "node:fs/promises";
export async function rmRetry(dir: string, retries = 10, delayMs = 500) {
for (let i = 0; i < retries; i++) {
try {
await rm(dir, { force: true, recursive: true });
return;
} catch (e) {
if (i === retries - 1) throw e;
await new Promise((r) => setTimeout(r, delayMs));
}
}
}

View File

@@ -1,5 +1,5 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { mkdir, rm } from "node:fs/promises";
import { mkdir } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
@@ -10,6 +10,7 @@ import { checkerRegistry } from "../../src/server/checker/runner";
import { CommandChecker } from "../../src/server/checker/runner/command/runner";
import { HttpChecker } from "../../src/server/checker/runner/http/runner";
import { ProbeStore } from "../../src/server/checker/store";
import { rmRetry } from "../helpers";
function ensureRegistered() {
if (!checkerRegistry.supportedTypes.includes("http")) {
@@ -45,7 +46,9 @@ describe("API 路由", () => {
group: "default",
http: {
headers: {},
ignoreSSL: false,
maxBodyBytes: 104857600,
maxRedirects: 0,
method: "GET",
url: "http://a.com",
},
@@ -100,7 +103,7 @@ describe("API 路由", () => {
afterAll(async () => {
store.close();
await rm(tempDir, { force: true, recursive: true });
await rmRetry(tempDir);
});
test("/health 返回健康检查", async () => {

View File

@@ -95,7 +95,9 @@ describe("loadConfig", () => {
expect(t.http.url).toBe("http://example.com");
expect(t.http.method).toBe("GET");
expect(t.http.headers).toEqual({});
expect(t.http.ignoreSSL).toBe(false);
expect(t.http.maxBodyBytes).toBe(104857600);
expect(t.http.maxRedirects).toBe(0);
expect(t.intervalMs).toBe(30000);
expect(t.timeoutMs).toBe(10000);
}
@@ -157,8 +159,10 @@ targets:
interval: "1m"
http:
url: "http://example.com"
ignoreSSL: true
maxRedirects: 5
expect:
status: [200]
status: ["2xx", 301]
body:
- contains: "ok"
- name: "cmd-target"
@@ -184,7 +188,10 @@ targets:
expect(http.http.url).toBe("http://example.com");
expect(http.http.method).toBe("POST");
expect(http.http.headers).toEqual({ Authorization: "Bearer token" });
expect(http.http.ignoreSSL).toBe(true);
expect(http.http.maxBodyBytes).toBe(52428800);
expect(http.http.maxRedirects).toBe(5);
expect(http.expect?.status).toEqual(["2xx", 301]);
expect(http.intervalMs).toBe(60000);
expect(http.timeoutMs).toBe(5000);
}
@@ -277,6 +284,68 @@ targets:
await expect(loadConfig(configPath)).rejects.toThrow("缺少 http.url 字段");
});
test("HTTP target 缺少 http 分组抛出清晰错误", async () => {
const configPath = join(tempDir, "no-http-group.yaml");
await writeFile(
configPath,
`targets:
- name: "test"
type: http
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("缺少 http.url 字段");
});
test("HTTP target ignoreSSL 非布尔值抛出错误", async () => {
const configPath = join(tempDir, "bad-ignore-ssl.yaml");
await writeFile(
configPath,
`targets:
- name: "test"
type: http
http:
url: "http://example.com"
ignoreSSL: "true"
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("ignoreSSL 必须为布尔值");
});
test("HTTP target maxRedirects 非负整数校验", async () => {
const configPath = join(tempDir, "bad-max-redirects.yaml");
await writeFile(
configPath,
`targets:
- name: "test"
type: http
http:
url: "http://example.com"
maxRedirects: 1.5
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("maxRedirects 必须为非负整数");
});
test("HTTP target status 模式非法抛出错误", async () => {
const configPath = join(tempDir, "bad-status-pattern.yaml");
await writeFile(
configPath,
`targets:
- name: "test"
type: http
http:
url: "http://example.com"
expect:
status: ["abc"]
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("status 模式");
});
test("command target 缺少 exec 抛出错误", async () => {
const configPath = join(tempDir, "no-exec.yaml");
await writeFile(
@@ -556,4 +625,370 @@ targets:
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("group 字段必须为字符串");
});
test("HTTP headers 非字符串值抛出错误", async () => {
const configPath = join(tempDir, "bad-headers-val.yaml");
await writeFile(
configPath,
`targets:
- name: "test"
type: http
http:
url: "http://example.com"
headers:
X-Custom: 123
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("http.headers");
});
test("HTTP body 非字符串抛出错误", async () => {
const configPath = join(tempDir, "bad-body.yaml");
await writeFile(
configPath,
`targets:
- name: "test"
type: http
http:
url: "http://example.com"
body: 123
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("http.body 必须为字符串");
});
test("maxBodyBytes 负数抛出错误", async () => {
const configPath = join(tempDir, "neg-bodybytes.yaml");
await writeFile(
configPath,
`targets:
- name: "test"
type: http
http:
url: "http://example.com"
maxBodyBytes: -1
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("非负安全整数");
});
test("maxBodyBytes 非整数抛出错误", async () => {
const configPath = join(tempDir, "float-bodybytes.yaml");
await writeFile(
configPath,
`targets:
- name: "test"
type: http
http:
url: "http://example.com"
maxBodyBytes: 1.5
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("非负安全整数");
});
test("expect.status 数字不在 100-599 范围抛出错误", async () => {
const configPath = join(tempDir, "bad-status-num.yaml");
await writeFile(
configPath,
`targets:
- name: "test"
type: http
http:
url: "http://example.com"
expect:
status: [999]
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("100-599");
});
test("expect.status 范围 6xx 抛出错误", async () => {
const configPath = join(tempDir, "bad-status-6xx.yaml");
await writeFile(
configPath,
`targets:
- name: "test"
type: http
http:
url: "http://example.com"
expect:
status: ["6xx"]
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("5xx");
});
test("expect.maxDurationMs 负数抛出错误", async () => {
const configPath = join(tempDir, "neg-duration.yaml");
await writeFile(
configPath,
`targets:
- name: "test"
type: http
http:
url: "http://example.com"
expect:
maxDurationMs: -100
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("maxDurationMs 必须为非负有限数字");
});
test("expect.body 非数组抛出错误", async () => {
const configPath = join(tempDir, "bad-expect-body.yaml");
await writeFile(
configPath,
`targets:
- name: "test"
type: http
http:
url: "http://example.com"
expect:
body: "not-array"
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("expect.body 必须为数组");
});
test("body rule 缺少支持字段抛出错误", async () => {
const configPath = join(tempDir, "bad-body-rule-nofield.yaml");
await writeFile(
configPath,
`targets:
- name: "test"
type: http
http:
url: "http://example.com"
expect:
body:
- foo: "bar"
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("缺少支持的规则类型");
});
test("body rule 使用 match 字段(非支持)抛出错误", async () => {
const configPath = join(tempDir, "bad-body-rule-match.yaml");
await writeFile(
configPath,
`targets:
- name: "test"
type: http
http:
url: "http://example.com"
expect:
body:
- match: "ok"
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("缺少支持的规则类型");
});
test("body rule 多个支持字段抛出错误", async () => {
const configPath = join(tempDir, "bad-body-rule-multi.yaml");
await writeFile(
configPath,
`targets:
- name: "test"
type: http
http:
url: "http://example.com"
expect:
body:
- contains: "ok"
regex: "ok"
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("只能配置一种规则类型");
});
test("body regex 非法正则抛出错误", async () => {
const configPath = join(tempDir, "bad-body-regex.yaml");
await writeFile(
configPath,
`targets:
- name: "test"
type: http
http:
url: "http://example.com"
expect:
body:
- regex: "[invalid"
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("regex 正则不合法");
});
test("body json path 不以 $. 开头抛出错误", async () => {
const configPath = join(tempDir, "bad-json-path.yaml");
await writeFile(
configPath,
`targets:
- name: "test"
type: http
http:
url: "http://example.com"
expect:
body:
- json:
path: "status"
equals: "ok"
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("json.path");
});
test("body css selector 为空抛出错误", async () => {
const configPath = join(tempDir, "bad-css-sel.yaml");
await writeFile(
configPath,
`targets:
- name: "test"
type: http
http:
url: "http://example.com"
expect:
body:
- css:
selector: ""
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("css.selector 必须为非空字符串");
});
test("operator match 非法正则抛出错误", async () => {
const configPath = join(tempDir, "bad-op-match.yaml");
await writeFile(
configPath,
`targets:
- name: "test"
type: http
http:
url: "http://example.com"
expect:
headers:
X-Test:
match: "[invalid"
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("match 正则不合法");
});
test("operator gte 非数字抛出错误", async () => {
const configPath = join(tempDir, "bad-op-gte.yaml");
await writeFile(
configPath,
`targets:
- name: "test"
type: http
http:
url: "http://example.com"
expect:
body:
- json:
path: "$.count"
gte: "abc"
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("gte 必须为有限数字");
});
test("operator exists 非布尔值抛出错误", async () => {
const configPath = join(tempDir, "bad-op-exists.yaml");
await writeFile(
configPath,
`targets:
- name: "test"
type: http
http:
url: "http://example.com"
expect:
body:
- json:
path: "$.status"
exists: "yes"
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("exists 必须为布尔值");
});
test("未知字段忽略不影响启动", async () => {
const configPath = join(tempDir, "unknown-fields.yaml");
await writeFile(
configPath,
`targets:
- name: "test"
type: http
http:
url: "http://example.com"
unknownHttpField: "value"
expect:
status: [200]
unknownExpectField: "value"
body:
- contains: "ok"
note: "ignored"
`,
);
const config = await loadConfig(configPath);
expect(config.targets).toHaveLength(1);
const t = config.targets[0]!;
if (t.type === "http") {
expect(t.expect?.status).toEqual([200]);
}
});
test("xpath path 非空字符串校验", async () => {
const configPath = join(tempDir, "bad-xpath-path.yaml");
await writeFile(
configPath,
`targets:
- name: "test"
type: http
http:
url: "http://example.com"
expect:
body:
- xpath:
path: ""
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("xpath.path 必须为非空字符串");
});
test("expect headers 非对象抛出错误", async () => {
const configPath = join(tempDir, "bad-expect-headers.yaml");
await writeFile(
configPath,
`targets:
- name: "test"
type: http
http:
url: "http://example.com"
expect:
headers: "invalid"
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("expect.headers 必须为对象");
});
});

View File

@@ -190,7 +190,9 @@ describe("ProbeEngine", () => {
group: "default",
http: {
headers: {},
ignoreSSL: false,
maxBodyBytes: 1024 * 1024,
maxRedirects: 0,
method: "GET",
url: `http://localhost:${httpServer.port}/`,
},

View File

@@ -119,7 +119,7 @@ describe("CommandChecker", () => {
test("不使用 shell通配符不被展开", async () => {
const result = await checker.execute(
makeTarget({ args: ["*"], exec: "echo" }, { expect: { stdout: [{ contains: "*" }] } }),
makeTarget({ args: ["-e", "console.log('*')"], exec: "bun" }, { expect: { stdout: [{ contains: "*" }] } }),
makeCtx(),
);
expect(result.matched).toBe(true);

View File

@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test";
import { checkHttpExpect } from "../../../../../src/server/checker/runner/http/expect";
import { checkHttpExpect, checkStatus } from "../../../../../src/server/checker/runner/http/expect";
function obs(
overrides: { body?: null | string; durationMs?: number; headers?: Record<string, string>; statusCode?: number } = {},
@@ -107,7 +107,7 @@ describe("checkHttpExpect", () => {
expect(r.failure!.kind).toBe("error");
});
test("完整流水线 status->duration->headers->body 全部通过", () => {
test("完整流水线 status->headers->body->duration 全部通过", () => {
const r = checkHttpExpect(200, { "content-type": "application/json" }, JSON.stringify({ status: "healthy" }), 50, {
body: [{ json: { equals: "healthy", path: "$.status" } }],
headers: { "content-type": { contains: "json" } },
@@ -118,13 +118,13 @@ describe("checkHttpExpect", () => {
expect(r.failure).toBeNull();
});
test("完整流水线 status 通过但 duration 失败", () => {
test("完整流水线 status 和 headers 通过但 duration 失败", () => {
const r = checkHttpExpect(200, {}, "", 500, { maxDurationMs: 100, status: [200] });
expect(r.matched).toBe(false);
expect(r.failure!.phase).toBe("duration");
});
test("完整流水线 status 和 duration 通过但 headers 失败", () => {
test("完整流水线 status 通过但 headers 失败", () => {
const r = checkHttpExpect(200, { "x-api": "v1" }, "", 50, {
headers: { "x-api": "v2" },
maxDurationMs: 100,
@@ -134,7 +134,7 @@ describe("checkHttpExpect", () => {
expect(r.failure!.phase).toBe("headers");
});
test("完整流水线 status/duration/headers 通过但 body 失败", () => {
test("完整流水线 status/headers 通过但 body 失败", () => {
const r = checkHttpExpect(200, { "content-type": "text/plain" }, "error occurred", 50, {
body: [{ contains: "success" }],
headers: { "content-type": "text/plain" },
@@ -145,3 +145,56 @@ describe("checkHttpExpect", () => {
expect(r.failure!.phase).toBe("body");
});
});
describe("checkStatus 范围匹配", () => {
test("2xx 范围匹配 200", () => {
expect(checkStatus(200, ["2xx"]).matched).toBe(true);
});
test("2xx 范围匹配 204", () => {
expect(checkStatus(204, ["2xx"]).matched).toBe(true);
});
test("2xx 范围不匹配 301", () => {
expect(checkStatus(301, ["2xx"]).matched).toBe(false);
});
test("5xx 范围匹配 503", () => {
expect(checkStatus(503, ["5xx"]).matched).toBe(true);
});
test("混合精确值与范围模式 — 精确命中", () => {
expect(checkStatus(301, ["2xx", 301]).matched).toBe(true);
});
test("混合精确值与范围模式 — 范围命中", () => {
expect(checkStatus(204, ["2xx", 301]).matched).toBe(true);
});
test("混合模式都不匹配", () => {
expect(checkStatus(404, ["2xx", 301]).matched).toBe(false);
});
test("纯精确值仍正常工作", () => {
expect(checkStatus(200, [200, 201]).matched).toBe(true);
expect(checkStatus(404, [200, 201]).matched).toBe(false);
});
test("范围匹配失败返回 phase=status 的 failure", () => {
const r = checkStatus(404, ["2xx"]);
expect(r.matched).toBe(false);
expect(r.failure!.phase).toBe("status");
});
test("1xx 范围匹配 101", () => {
expect(checkStatus(101, ["1xx"]).matched).toBe(true);
});
test("3xx 范围匹配 301", () => {
expect(checkStatus(301, ["3xx"]).matched).toBe(true);
});
test("4xx 范围匹配 404", () => {
expect(checkStatus(404, ["4xx"]).matched).toBe(true);
});
});

View File

@@ -1,11 +1,59 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import type { CheckerContext } from "../../../../../src/server/checker/runner/types";
import type { CheckerContext, ResolveContext } from "../../../../../src/server/checker/runner/types";
import type { ResolvedHttpTarget } from "../../../../../src/server/checker/types";
import { checkStatus } from "../../../../../src/server/checker/runner/http/expect";
import { HttpChecker } from "../../../../../src/server/checker/runner/http/runner";
const checker = new HttpChecker();
const SELF_SIGNED_CERT = `-----BEGIN CERTIFICATE-----
MIIDJTCCAg2gAwIBAgIUTwQU8FzvnvxNYR7mMO0DLcnq+wQwDQYJKoZIhvcNAQEL
BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI2MDUxMjE1NDAyOFoXDTM2MDUw
OTE1NDAyOFowFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF
AAOCAQ8AMIIBCgKCAQEArC0G46EXF8qPsCS2mtNwHzGFQvFQNcU8k7cZkCTwt4Cp
DlLOA2DbzR02LiVk/TA+d9qMUABAiXMndwebKv8EYxoKjwTY0jbVLKfEIIFxQS3F
uvKDgkYJz8P675p8fhR0Xa21+13b0/T8fperYC7fBZZsAqyo8+aF9QOUjy+kWRjr
lTTL1ez5L1nX0QCczTRaUDe51NTmcUYHJoiLqdKI2ZjXds7wnsaAfAgh7H9qr4wl
sUhCHV/Pg1LzBtfyLKZcImUJWWkj/KlgFgZ6aRyJHoGFmlZtXyaKhf3rEa+ZvKOy
MhcRmWC694PF+QjhrWS7oODLuY3XC5WKnLKxlBgfAwIDAQABo28wbTAdBgNVHQ4E
FgQUrHJEbBSDHOx/HAQ5nQp35v0Ljw8wHwYDVR0jBBgwFoAUrHJEbBSDHOx/HAQ5
nQp35v0Ljw8wDwYDVR0TAQH/BAUwAwEB/zAaBgNVHREEEzARgglsb2NhbGhvc3SH
BH8AAAEwDQYJKoZIhvcNAQELBQADggEBAEJ0s/FJ6KZalSM0ntHxlMOB9taUa60I
A6zqrEMauU8BqZO3QLmX6a821geZntQtz77kGtW6rQWxELBNjN3rXTbUKfKXN/Au
ZLftNJLsQOjKF+1uFOF49D4/5Le9PGvwl79Qua/l6JO5HRJL9Dh545/zEr9W5Erb
l4JoKKfyCEYjrPg5tl7d2PrHUmzk+sGlxEqNeKIl272+3UMVCbkVHI/v6rtb4F7p
u77O0UYLNIRFZQOVqvE7A7rfYy93J8EEQcADKH/Nhx8clFxC5X187EakcVAfkeKX
SL7R1kmUiLPiHbVCqGyS2m3RH2XDM3MbA9WCCczbwXn5Lwp5HEz0wb0=
-----END CERTIFICATE-----`;
const SELF_SIGNED_KEY = `-----BEGIN PRIVATE KEY-----
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCsLQbjoRcXyo+w
JLaa03AfMYVC8VA1xTyTtxmQJPC3gKkOUs4DYNvNHTYuJWT9MD532oxQAECJcyd3
B5sq/wRjGgqPBNjSNtUsp8QggXFBLcW68oOCRgnPw/rvmnx+FHRdrbX7XdvT9Px+
l6tgLt8FlmwCrKjz5oX1A5SPL6RZGOuVNMvV7PkvWdfRAJzNNFpQN7nU1OZxRgcm
iIup0ojZmNd2zvCexoB8CCHsf2qvjCWxSEIdX8+DUvMG1/IsplwiZQlZaSP8qWAW
BnppHIkegYWaVm1fJoqF/esRr5m8o7IyFxGZYLr3g8X5COGtZLug4Mu5jdcLlYqc
srGUGB8DAgMBAAECggEAO91wDsedIu2QZlttjonD62SphCwpinio5md8oOznMbav
kUZjUTNlWX01sHfaFFqo7b10mgBscB4086MWZa3D1b1hPHcf+H+OQXeXrwGy4knK
/YSDC1HU6YOoBZV+gcwU5dmXc+4fmCQPguizcr75VpUFuyxTlnJp01ZKWjrjdwKs
IMU8a1CxHMT5clFf/3rU4U5o90cktsiRzjc83QFNpvRsF0rAn98Z70ocDWAATxUu
efLonMur5t2wlu2CLjZSXHvkkwwFQ2u7XUXuudDRAKeg77+RGuUGk8Z5269cHs22
Ff4cej7vOnoU0CuDaXL37vzUXkfImB6pSFTblfiHgQKBgQDX+s0MqtTZeho6F6Iy
qHFWqkEItfrTErMEVjgBrMl42+EfzsAKa+910NPdV3z5Z/u7fAp3ComtxJ1pjiNj
bVah4/xobsHIIS1/XfPuxOaqkdOhhgYvCe8IIC6Z4yCPdRD5pW6dN18fK338YF4s
lVll+E/DJx7R08tFwSLGYNt5QQKBgQDMFFn1vT4GMHeeF2/kVNYE2U1Lntsy/swT
VLCgaOJuUvbJiKMa+J1jdjAsudAmOJgjTkR4sco0Rpsen+x7StaGBzMbXKHONUf3
OLzQsP06JnA9oAftxsjg0IDH8JCAuQsQ2xKMN+f0d0+pggOzS/z7336a3bm1Zeee
wYqjtLOjQwKBgQCRoTzt06qd0aUpkpH9knKJu1cKppowBKXMwM4W4wkegzRzHBeF
b24RhPO2ha1xBlpI+sSbq/FVyANUD1FxU2Jc2rtxN21WonhpL1KxpvbaAGYwvYwh
35LbacfCX9GuqYL+sju5qoJrJApZSCl36mRTS3GM5y3y0dp4eFgYZ2rVgQKBgCLq
tH2cFFmgv0aYQfeyIDASMexnUJ/IAoioK9Q2Pc+ceEcBDs8VjHAxD4sHe7qeYkFg
KczwtmT9U5sIx8BMjKm/35ml3rVWXmrJFV0rexgQ7ZFNqS2gnkwAwJf06/RqNJ98
rA67nf8wzrt02Ec8EBvUIGhE2XpU5i0+dgcOatHbAoGBAJIutK961t5lJVF1g1M9
KC4rmCCMCTvJSbruQWDpoxYa7Cl6+TopU+xu4537FCzHUJ3EPg3KsYCeCM0LEtR4
GQjRzFM3qqWabzoAV3KLaONWbK1rI9mHZf8KyWYiJ9cRXwTJ4rGYNMM/6QIUGQSx
agwJojCQqS4f6AfCNdUOzaRp
-----END PRIVATE KEY-----`;
describe("HttpChecker", () => {
let server: ReturnType<typeof Bun.serve>;
@@ -20,6 +68,14 @@ describe("HttpChecker", () => {
return new Response(JSON.stringify({ body: req.body ? "present" : "empty", method: req.method }), {
headers: { "content-type": "application/json" },
});
case "/gbk": {
const gbkBytes = new Uint8Array([0xc4, 0xe3, 0xba, 0xc3]);
return new Response(gbkBytes, { headers: { "content-type": "text/plain; charset=gbk" } });
}
case "/gbk-quoted": {
const gbkBytes = new Uint8Array([0xc4, 0xe3, 0xba, 0xc3]);
return new Response(gbkBytes, { headers: { "content-type": 'text/plain; charset="gbk"' } });
}
case "/json":
return new Response(JSON.stringify({ status: "ok" }), {
headers: { "content-type": "application/json" },
@@ -32,6 +88,26 @@ describe("HttpChecker", () => {
return new Response("hello world", {
headers: { "content-type": "text/plain", "x-custom": "test-value" },
});
case "/redirect":
return new Response(null, { headers: { location: `${baseUrl}/ok` }, status: 301 });
case "/redirect-chain-1":
return new Response(null, { headers: { location: `${baseUrl}/redirect-chain-2` }, status: 302 });
case "/redirect-chain-2":
return new Response(null, { headers: { location: `${baseUrl}/ok` }, status: 302 });
case "/redirect-cross": {
const port = parseInt(url.searchParams.get("port") ?? "0");
return new Response(null, {
headers: { location: `http://127.0.0.1:${port}/ok` },
status: 302,
});
}
case "/redirect-post":
return new Response(null, { headers: { location: `${baseUrl}/echo` }, status: 301 });
case "/slow-body":
return new Response("x".repeat(2000));
case "/unknown-charset": {
return new Response("test", { headers: { "content-type": "text/plain; charset=bogus-encoding" } });
}
default:
return new Response("ok");
}
@@ -49,7 +125,9 @@ describe("HttpChecker", () => {
body?: string;
expect?: Record<string, unknown>;
headers?: Record<string, string>;
ignoreSSL?: boolean;
maxBodyBytes?: number;
maxRedirects?: number;
method?: string;
timeoutMs?: number;
url?: string;
@@ -60,7 +138,9 @@ describe("HttpChecker", () => {
http: {
body: overrides.body,
headers: overrides.headers ?? {},
ignoreSSL: overrides.ignoreSSL ?? false,
maxBodyBytes: overrides.maxBodyBytes ?? 1024 * 1024,
maxRedirects: overrides.maxRedirects ?? 0,
method: overrides.method ?? "GET",
url: overrides.url ?? `${baseUrl}/ok`,
},
@@ -214,8 +294,631 @@ describe("HttpChecker", () => {
const target = makeTarget({});
const s = checker.serialize(target);
expect(s.target).toBe(target.http.url);
const config = JSON.parse(s.config) as { method: string; url: string };
const config = JSON.parse(s.config) as { ignoreSSL: boolean; maxRedirects: number; method: string; url: string };
expect(config.url).toBe(target.http.url);
expect(config.method).toBe("GET");
expect(config.ignoreSSL).toBe(false);
expect(config.maxRedirects).toBe(0);
});
test("maxRedirects=0 不跟随重定向", async () => {
const result = await checker.execute(makeTarget({ maxRedirects: 0, url: `${baseUrl}/redirect` }), makeCtx());
expect(result.matched).toBe(false);
expect(result.statusDetail).toBe("HTTP 301");
});
test("maxRedirects>0 跟随重定向", async () => {
const result = await checker.execute(makeTarget({ maxRedirects: 5, url: `${baseUrl}/redirect` }), makeCtx());
expect(result.matched).toBe(true);
expect(result.statusDetail).toBe("HTTP 200");
});
test("maxRedirects 精确限制跟随次数", async () => {
const result = await checker.execute(
makeTarget({ maxRedirects: 1, url: `${baseUrl}/redirect-chain-1` }),
makeCtx(),
);
expect(result.matched).toBe(false);
expect(result.statusDetail).toBe("HTTP 302");
});
test("maxRedirects 允许足够次数时到达最终目标", async () => {
const result = await checker.execute(
makeTarget({ maxRedirects: 2, url: `${baseUrl}/redirect-chain-1` }),
makeCtx(),
);
expect(result.matched).toBe(true);
expect(result.statusDetail).toBe("HTTP 200");
});
test("ignoreSSL 跳过自签名证书校验", async () => {
const httpsServer = Bun.serve({
fetch() {
return new Response("secure ok");
},
port: 0,
tls: { cert: SELF_SIGNED_CERT, key: SELF_SIGNED_KEY },
});
try {
const strictResult = await checker.execute(
makeTarget({ ignoreSSL: false, url: `https://localhost:${httpsServer.port}/` }),
makeCtx(),
);
expect(strictResult.matched).toBe(false);
expect(strictResult.statusDetail).toBeNull();
const ignoredResult = await checker.execute(
makeTarget({ ignoreSSL: true, url: `https://localhost:${httpsServer.port}/` }),
makeCtx(),
);
expect(ignoredResult.matched).toBe(true);
expect(ignoredResult.statusDetail).toBe("HTTP 200");
} finally {
void httpsServer.stop();
}
});
test("响应体编码自动检测 GBK", async () => {
const result = await checker.execute(
makeTarget({ expect: { body: [{ contains: "你好" }] }, url: `${baseUrl}/gbk` }),
makeCtx(),
);
expect(result.matched).toBe(true);
});
test("响应体编码回退 UTF-8", async () => {
const result = await checker.execute(
makeTarget({ expect: { body: [{ contains: "hello" }] }, url: `${baseUrl}/ok` }),
makeCtx(),
);
expect(result.matched).toBe(true);
});
test("响应体编码 quoted charset", async () => {
const result = await checker.execute(
makeTarget({ expect: { body: [{ contains: "你好" }] }, url: `${baseUrl}/gbk-quoted` }),
makeCtx(),
);
expect(result.matched).toBe(true);
});
test("响应体不支持的编码返回结构化 body 错误", async () => {
const result = await checker.execute(
makeTarget({ expect: { body: [{ contains: "test" }] }, url: `${baseUrl}/unknown-charset` }),
makeCtx(),
);
expect(result.matched).toBe(false);
expect(result.failure!.phase).toBe("body");
expect(result.failure!.kind).toBe("error");
expect(result.failure!.message).toContain("不支持的字符编码");
});
test("流式 body 等于上限允许通过", async () => {
const bodyLen = "hello world".length;
const result = await checker.execute(
makeTarget({ expect: { body: [{ contains: "hello" }] }, maxBodyBytes: bodyLen, url: `${baseUrl}/ok` }),
makeCtx(),
);
expect(result.matched).toBe(true);
});
test("maxBodyBytes 为 0 且响应体非空时返回超限错误", async () => {
const result = await checker.execute(
makeTarget({ expect: { body: [{ contains: "hello" }] }, maxBodyBytes: 0, url: `${baseUrl}/ok` }),
makeCtx(),
);
expect(result.matched).toBe(false);
expect(result.failure!.phase).toBe("body");
expect(result.failure!.kind).toBe("error");
expect(result.failure!.message).toContain("超过限制");
});
test("body 超限时错误归属 body phase", async () => {
const result = await checker.execute(
makeTarget({ expect: { body: [{ contains: "x" }] }, maxBodyBytes: 100, url: `${baseUrl}/large` }),
makeCtx(),
);
expect(result.matched).toBe(false);
expect(result.failure!.phase).toBe("body");
expect(result.failure!.kind).toBe("error");
});
test("请求错误归属 request phase", async () => {
const result = await checker.execute(makeTarget({ url: "http://localhost:1/" }), makeCtx(500));
expect(result.matched).toBe(false);
expect(result.failure!.phase).toBe("request");
expect(result.failure!.kind).toBe("error");
});
test("超时错误归属 request phase", async () => {
const timeoutServer = Bun.serve({
async fetch() {
await new Promise((resolve) => setTimeout(resolve, 10000));
return new Response("late");
},
port: 0,
});
try {
const result = await checker.execute(
makeTarget({ timeoutMs: 100, url: `http://localhost:${timeoutServer.port}/` }),
makeCtx(100),
);
expect(result.matched).toBe(false);
expect(result.failure!.phase).toBe("request");
expect(result.failure!.message).toContain("超时");
} finally {
void timeoutServer.stop();
}
});
test("durationMs 包含 body 读取耗时", async () => {
const result = await checker.execute(
makeTarget({ expect: { body: [{ contains: "x" }] }, maxBodyBytes: 10240, url: `${baseUrl}/large` }),
makeCtx(),
);
expect(result.matched).toBe(true);
expect(result.durationMs).not.toBeNull();
expect(result.durationMs!).toBeGreaterThanOrEqual(0);
});
test("expect.maxDurationMs 使用完整耗时", async () => {
const slowServer = Bun.serve({
async fetch() {
await new Promise((resolve) => setTimeout(resolve, 50));
return new Response("x".repeat(500));
},
port: 0,
});
try {
const result = await checker.execute(
makeTarget({
expect: { body: [{ contains: "x" }], maxDurationMs: 10 },
url: `http://localhost:${slowServer.port}/`,
}),
makeCtx(),
);
expect(result.matched).toBe(false);
expect(result.failure!.phase).toBe("duration");
} finally {
void slowServer.stop();
}
});
test("无 body rules 时不读取 body", async () => {
const result = await checker.execute(
makeTarget({ expect: { status: [200] }, maxBodyBytes: 1, url: `${baseUrl}/large` }),
makeCtx(),
);
expect(result.matched).toBe(true);
});
test("body 失败优先于 duration 检查", async () => {
const result = await checker.execute(
makeTarget({
expect: { body: [{ contains: "nonexistent" }], maxDurationMs: 999999 },
url: `${baseUrl}/ok`,
}),
makeCtx(),
);
expect(result.matched).toBe(false);
expect(result.failure!.phase).toBe("body");
expect(result.durationMs).not.toBeNull();
expect(result.durationMs!).toBeGreaterThanOrEqual(0);
});
test("POST 重定向改 GET 清理 body headers", async () => {
const result = await checker.execute(
makeTarget({
body: "test-body",
expect: { status: [200] },
headers: { authorization: "Bearer token123", "content-type": "text/plain" },
maxRedirects: 5,
method: "POST",
url: `${baseUrl}/redirect-post`,
}),
makeCtx(),
);
expect(result.matched).toBe(true);
});
test("跨 origin 重定向剥离敏感 headers", async () => {
const targetServer = Bun.serve({
fetch(req) {
const url = new URL(req.url);
if (url.pathname === "/ok") {
const auth = req.headers.get("authorization");
if (auth) {
return new Response("auth leaked", { status: 200 });
}
return new Response("safe", { headers: { "content-type": "text/plain" } });
}
return new Response("ok");
},
port: 0,
});
try {
const result = await checker.execute(
makeTarget({
expect: { body: [{ contains: "safe" }] },
headers: { authorization: "Bearer secret" },
maxRedirects: 5,
url: `${baseUrl}/redirect-cross?port=${targetServer.port}`,
}),
makeCtx(),
);
expect(result.matched).toBe(true);
} finally {
void targetServer.stop();
}
});
test("1xx 范围模式匹配 101", () => {
const r = checkStatus(101, ["1xx"]);
expect(r.matched).toBe(true);
});
test("6xx 范围模式启动校验失败", () => {
expect(() =>
checker.resolve(
{ expect: { status: ["6xx"] }, http: { url: "https://example.com" }, name: "test", type: "http" },
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("5xx");
});
test("status 数字 99 启动校验失败", () => {
expect(() =>
checker.resolve(
{ expect: { status: [99] }, http: { url: "https://example.com" }, name: "test", type: "http" },
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("100-599");
});
test("body rule 忽略未知字段", () => {
const result = checker.resolve(
{
expect: { body: [{ contains: "ok", note: "ignored" }], status: [200] },
http: { url: "https://example.com" },
name: "test",
type: "http",
} as unknown as Parameters<HttpChecker["resolve"]>[0],
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
);
expect((result as ResolvedHttpTarget).expect?.body).toEqual([
{ contains: "ok", note: "ignored" },
] as unknown as Array<{ contains: string }>);
});
test("body rule 使用 match 字段启动失败", () => {
expect(() =>
checker.resolve(
{
expect: { body: [{ match: "ok" }] },
http: { url: "https://example.com" },
name: "test",
type: "http",
} as unknown as Parameters<HttpChecker["resolve"]>[0],
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("缺少支持的规则类型");
});
test("非法 regex 启动校验失败", () => {
expect(() =>
checker.resolve(
{
expect: { body: [{ regex: "[invalid" }] },
http: { url: "https://example.com" },
name: "test",
type: "http",
} as unknown as Parameters<HttpChecker["resolve"]>[0],
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("regex 正则不合法");
});
test("非法 JSONPath 启动校验失败", () => {
expect(() =>
checker.resolve(
{
expect: { body: [{ json: { equals: "ok", path: "status" } }] },
http: { url: "https://example.com" },
name: "test",
type: "http",
},
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("json.path");
});
test("非法 operator match 启动校验失败", () => {
expect(() =>
checker.resolve(
{
expect: { headers: { "x-test": { match: "[invalid" } } },
http: { url: "https://example.com" },
name: "test",
type: "http",
},
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("match 正则不合法");
});
test("非法 operator gte 类型启动失败", () => {
expect(() =>
checker.resolve(
{
expect: { body: [{ json: { gte: "abc", path: "$.count" } }] },
http: { url: "https://example.com" },
name: "test",
type: "http",
} as unknown as Parameters<HttpChecker["resolve"]>[0],
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("gte 必须为有限数字");
});
test("非法 operator exists 类型启动失败", () => {
expect(() =>
checker.resolve(
{
expect: { body: [{ json: { exists: "yes", path: "$.status" } }] },
http: { url: "https://example.com" },
name: "test",
type: "http",
} as unknown as Parameters<HttpChecker["resolve"]>[0],
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("exists 必须为布尔值");
});
test("body rule 多个支持字段启动失败", () => {
expect(() =>
checker.resolve(
{
expect: { body: [{ contains: "ok", regex: "ok" }] },
http: { url: "https://example.com" },
name: "test",
type: "http",
} as unknown as Parameters<HttpChecker["resolve"]>[0],
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("只能配置一种规则类型");
});
test("body rule 缺少支持字段启动失败", () => {
expect(() =>
checker.resolve(
{
expect: { body: [{ foo: "bar" }] },
http: { url: "https://example.com" },
name: "test",
type: "http",
} as unknown as Parameters<HttpChecker["resolve"]>[0],
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("缺少支持的规则类型");
});
test("css selector 为空启动失败", () => {
expect(() =>
checker.resolve(
{
expect: { body: [{ css: { selector: "" } }] },
http: { url: "https://example.com" },
name: "test",
type: "http",
} as unknown as Parameters<HttpChecker["resolve"]>[0],
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("css.selector 必须为非空字符串");
});
test("xpath path 为空启动失败", () => {
expect(() =>
checker.resolve(
{
expect: { body: [{ xpath: { path: "" } }] },
http: { url: "https://example.com" },
name: "test",
type: "http",
} as unknown as Parameters<HttpChecker["resolve"]>[0],
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("xpath.path 必须为非空字符串");
});
test("expect.headers 非对象启动失败", () => {
expect(() =>
checker.resolve(
{
expect: { headers: "invalid" },
http: { url: "https://example.com" },
name: "test",
type: "http",
} as unknown as Parameters<HttpChecker["resolve"]>[0],
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("expect.headers 必须为对象");
});
test("expect.body 非数组启动失败", () => {
expect(() =>
checker.resolve(
{
expect: { body: "not-array" },
http: { url: "https://example.com" },
name: "test",
type: "http",
} as unknown as Parameters<HttpChecker["resolve"]>[0],
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("expect.body 必须为数组");
});
test("maxDurationMs 负数启动失败", () => {
expect(() =>
checker.resolve(
{
expect: { maxDurationMs: -100 },
http: { url: "https://example.com" },
name: "test",
type: "http",
},
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("maxDurationMs 必须为非负有限数字");
});
test("http.body 非字符串启动失败", () => {
expect(() =>
checker.resolve(
{
http: { body: 123, url: "https://example.com" },
name: "test",
type: "http",
} as unknown as Parameters<HttpChecker["resolve"]>[0],
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("http.body 必须为字符串");
});
test("http.headers 非字符串值启动失败", () => {
expect(() =>
checker.resolve(
{
http: { headers: { "X-Test": 123 }, url: "https://example.com" },
name: "test",
type: "http",
} as unknown as Parameters<HttpChecker["resolve"]>[0],
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("http.headers");
});
test("http.headers 非对象启动失败", () => {
expect(() =>
checker.resolve(
{
http: { headers: "invalid", url: "https://example.com" },
name: "test",
type: "http",
} as unknown as Parameters<HttpChecker["resolve"]>[0],
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("http.headers 必须为对象");
});
});
describe("HttpChecker.resolve", () => {
function makeResolveContext(): ResolveContext {
return {
configDir: ".",
defaultIntervalMs: 30000,
defaults: {},
defaultTimeoutMs: 10000,
};
}
test("method 非法抛出错误", () => {
expect(() =>
checker.resolve(
{ http: { method: "INVALID", url: "https://example.com" }, name: "test", type: "http" },
makeResolveContext(),
),
).toThrow("不合法");
});
test("URL 不以 http(s):// 开头抛出错误", () => {
expect(() =>
checker.resolve({ http: { url: "ftp://example.com" }, name: "test", type: "http" }, makeResolveContext()),
).toThrow("格式不合法");
});
test("maxRedirects 为负数抛出错误", () => {
expect(() =>
checker.resolve(
{ http: { maxRedirects: -1, url: "https://example.com" }, name: "test", type: "http" },
makeResolveContext(),
),
).toThrow("非负整数");
});
test("maxRedirects 非整数抛出错误", () => {
const target = {
http: { maxRedirects: 1.5, url: "https://example.com" },
name: "test",
type: "http",
} as unknown as Parameters<HttpChecker["resolve"]>[0];
expect(() => checker.resolve(target, makeResolveContext())).toThrow("非负整数");
});
test("ignoreSSL 非布尔值抛出错误", () => {
const target = {
http: { ignoreSSL: "true", url: "https://example.com" },
name: "test",
type: "http",
} as unknown as Parameters<HttpChecker["resolve"]>[0];
expect(() => checker.resolve(target, makeResolveContext())).toThrow("ignoreSSL 必须为布尔值");
});
test("缺少 http 分组抛出清晰错误", () => {
const target = { name: "test", type: "http" } as unknown as Parameters<HttpChecker["resolve"]>[0];
expect(() => checker.resolve(target, makeResolveContext())).toThrow("缺少 http.url 字段");
});
test("expect.status 非法模式抛出错误", () => {
expect(() =>
checker.resolve(
{ expect: { status: ["abc"] }, http: { url: "https://example.com" }, name: "test", type: "http" },
makeResolveContext(),
),
).toThrow("不合法");
});
test("ignoreSSL 默认值为 false", () => {
const result = checker.resolve(
{ http: { url: "https://example.com" }, name: "test", type: "http" },
makeResolveContext(),
);
expect((result as ResolvedHttpTarget).http.ignoreSSL).toBe(false);
});
test("maxRedirects 默认值为 0", () => {
const result = checker.resolve(
{ http: { url: "https://example.com" }, name: "test", type: "http" },
makeResolveContext(),
);
expect((result as ResolvedHttpTarget).http.maxRedirects).toBe(0);
});
test("method 统一转大写", () => {
const result = checker.resolve(
{ http: { method: "get", url: "https://example.com" }, name: "test", type: "http" },
makeResolveContext(),
);
expect((result as ResolvedHttpTarget).http.method).toBe("GET");
});
test("合法 status 范围模式通过校验", () => {
const result = checker.resolve(
{ expect: { status: ["2xx", 301] }, http: { url: "https://example.com" }, name: "test", type: "http" },
makeResolveContext(),
);
expect((result as ResolvedHttpTarget).expect?.status).toEqual(["2xx", 301]);
});
test("显式 ignoreSSL 和 maxRedirects 正确解析", () => {
const result = checker.resolve(
{ http: { ignoreSSL: true, maxRedirects: 3, url: "https://example.com" }, name: "test", type: "http" },
makeResolveContext(),
);
expect((result as ResolvedHttpTarget).http.ignoreSSL).toBe(true);
expect((result as ResolvedHttpTarget).http.maxRedirects).toBe(3);
});
});

View File

@@ -136,4 +136,42 @@ describe("checkBodyExpect (BodyRule[])", () => {
expect(r.matched).toBe(false);
expect(r.failure!.path).toContain("body[1]");
});
test("JSON 响应不是合法 JSON 返回 error kind", () => {
const r = checkBodyExpect("not json", [{ json: { equals: "ok", path: "$.status" } }]);
expect(r.matched).toBe(false);
expect(r.failure!.kind).toBe("error");
expect(r.failure!.phase).toBe("body");
expect(r.failure!.path).toContain("json");
});
test("CSS selector 无匹配元素返回 mismatch kind", () => {
const html = "<div>no match</div>";
const r = checkBodyExpect(html, [{ css: { equals: "test", selector: "span.missing" } }]);
expect(r.matched).toBe(false);
expect(r.failure!.kind).toBe("mismatch");
expect(r.failure!.phase).toBe("body");
expect(r.failure!.path).toContain("css");
});
test("XPath 无匹配节点返回 mismatch kind", () => {
const xml = "<root><status>ok</status></root>";
const r = checkBodyExpect(xml, [{ xpath: { equals: "ok", path: "/root/missing/text()" } }]);
expect(r.matched).toBe(false);
expect(r.failure!.kind).toBe("mismatch");
expect(r.failure!.phase).toBe("body");
expect(r.failure!.path).toContain("xpath");
});
test("regex 规则使用 regex 字段", () => {
const r = checkBodyExpect("status: ok", [{ regex: "^status:" }]);
expect(r.matched).toBe(true);
});
test("regex 规则失败返回 body phase", () => {
const r = checkBodyExpect("status: error", [{ regex: "^ok$" }]);
expect(r.matched).toBe(false);
expect(r.failure!.phase).toBe("body");
expect(r.failure!.path).toBe("body[0]");
});
});

View File

@@ -30,6 +30,18 @@ describe("parseSize", () => {
expect(parseSize(2048)).toBe(2048);
});
test("数字 0 返回 0", () => {
expect(parseSize(0)).toBe(0);
});
test("数字负数抛出错误", () => {
expect(() => parseSize(-1)).toThrow("非负安全整数");
});
test("数字非整数抛出错误", () => {
expect(() => parseSize(1.5)).toThrow("非负安全整数");
});
test("无效格式抛出错误", () => {
expect(() => parseSize("100")).toThrow("无效的 size 格式");
expect(() => parseSize("100MBB")).toThrow("无效的 size 格式");

View File

@@ -1,5 +1,5 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { mkdir, rm } from "node:fs/promises";
import { mkdir } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
@@ -9,6 +9,7 @@ import { checkerRegistry } from "../../../src/server/checker/runner";
import { CommandChecker } from "../../../src/server/checker/runner/command/runner";
import { HttpChecker } from "../../../src/server/checker/runner/http/runner";
import { ProbeStore } from "../../../src/server/checker/store";
import { rmRetry } from "../../helpers";
function ensureRegistered() {
if (!checkerRegistry.supportedTypes.includes("http")) {
@@ -26,7 +27,9 @@ const httpTarget: ResolvedTarget = {
group: "default",
http: {
headers: { Accept: "application/json" },
ignoreSSL: false,
maxBodyBytes: 104857600,
maxRedirects: 0,
method: "GET",
url: "https://example.com/health",
},
@@ -63,7 +66,7 @@ describe("ProbeStore", () => {
afterAll(async () => {
store.close();
await rm(tempDir, { force: true, recursive: true });
await rmRetry(tempDir);
});
test("初始化后无 targets", () => {
@@ -84,14 +87,18 @@ describe("ProbeStore", () => {
expect(t.target).toBe("https://example.com/health");
const config = JSON.parse(t.config) as {
headers: Record<string, string>;
ignoreSSL: boolean;
maxBodyBytes: number;
maxRedirects: number;
method: string;
url: string;
};
expect(config.url).toBe("https://example.com/health");
expect(config.method).toBe("GET");
expect(config.headers).toEqual({ Accept: "application/json" });
expect(config.ignoreSSL).toBe(false);
expect(config.maxBodyBytes).toBe(104857600);
expect(config.maxRedirects).toBe(0);
expect(t.interval_ms).toBe(30000);
expect(t.timeout_ms).toBe(10000);
expect(JSON.parse(t.expect!)).toEqual({ maxDurationMs: 3000, status: [200] });
@@ -282,7 +289,14 @@ describe("ProbeStore", () => {
const cascadeStore = new ProbeStore(join(tempDir, "cascade.db"));
const cascadeTarget: ResolvedTarget = {
group: "default",
http: { headers: {}, maxBodyBytes: 104857600, method: "GET", url: "http://cascade.test" },
http: {
headers: {},
ignoreSSL: false,
maxBodyBytes: 104857600,
maxRedirects: 0,
method: "GET",
url: "http://cascade.test",
},
intervalMs: 30000,
name: "cascade-test",
timeoutMs: 10000,
@@ -337,7 +351,14 @@ describe("ProbeStore", () => {
freshStore.syncTargets([
{
group: "default",
http: { headers: {}, maxBodyBytes: 104857600, method: "GET", url: "http://no.records" },
http: {
headers: {},
ignoreSSL: false,
maxBodyBytes: 104857600,
maxRedirects: 0,
method: "GET",
url: "http://no.records",
},
intervalMs: 30000,
name: "no-records",
timeoutMs: 10000,
@@ -376,7 +397,14 @@ describe("ProbeStore", () => {
freshStore.syncTargets([
{
group: "default",
http: { headers: {}, maxBodyBytes: 104857600, method: "GET", url: "http://no.stats" },
http: {
headers: {},
ignoreSSL: false,
maxBodyBytes: 104857600,
maxRedirects: 0,
method: "GET",
url: "http://no.stats",
},
intervalMs: 30000,
name: "no-stats",
timeoutMs: 10000,