feat: 新增 DB checker — 支持 PostgreSQL/MySQL/SQLite 连接测试与 SQL 查询断言
- 实现 db 类型 checker,使用 Bun 内置 SQL 类 - 支持 db.url 连接字符串和可选 db.query 查询语句 - expect 支持 maxDurationMs、rowCount、rows 逐列校验 - 凭据屏蔽序列化输出 - SQLite 内存数据库测试覆盖
This commit is contained in:
20
README.md
20
README.md
@@ -91,6 +91,17 @@ targets:
|
||||
exitCode: [0]
|
||||
stdout:
|
||||
- contains: "ok"
|
||||
|
||||
- name: "SQLite 数据库检查"
|
||||
type: db
|
||||
db:
|
||||
url: "sqlite:///path/to/db.sqlite"
|
||||
query: "SELECT COUNT(*) as cnt FROM users WHERE status = 'active'"
|
||||
expect:
|
||||
maxDurationMs: 5000
|
||||
rowCount: { gte: 1 }
|
||||
rows:
|
||||
- cnt: { gte: 0 }
|
||||
```
|
||||
|
||||
### 配置说明
|
||||
@@ -114,7 +125,7 @@ targets:
|
||||
- `cwd`: 默认工作目录(相对于配置文件所在目录解析,默认 `.`)
|
||||
- **targets**: 拨测目标列表(必填)
|
||||
- `name`: 目标名称(必填,唯一)
|
||||
- `type`: 目标类型,`http` 或 `cmd`(必填)
|
||||
- `type`: 目标类型,`http`、`cmd` 或 `db`(必填)
|
||||
- `group`: 分组名称(可选,默认 `"default"`)
|
||||
- `http`: HTTP 拨测配置(type 为 http 时必填)
|
||||
- `url`: 目标 URL
|
||||
@@ -126,6 +137,9 @@ targets:
|
||||
- `args`: 命令行参数列表
|
||||
- `env`: 环境变量覆盖(可选,继承进程环境变量并合并覆盖)
|
||||
- `cwd`: 工作目录(可选,相对于配置文件所在目录解析,默认 `.`)
|
||||
- `db`: 数据库拨测配置(type 为 db 时必填)
|
||||
- `url`: 数据库连接字符串,支持 `postgres://`、`mysql://`、`sqlite://` 协议
|
||||
- `query`: SQL 查询语句(可选,不配置时仅测试连接)
|
||||
- `interval`、`timeout`: 覆盖全局默认值
|
||||
- `expect`: 期望校验
|
||||
- `status`: 可接受的状态码列表(HTTP),支持精确状态码和范围模式(如 `"2xx"`)混合配置;未指定时默认 `[200]`
|
||||
@@ -148,6 +162,8 @@ targets:
|
||||
- `path`: XPath 表达式(必填,如 `/html/body/h1/text()`)
|
||||
- 比较操作符(可选,无操作符时仅检查节点是否存在)
|
||||
- `stdout` / `stderr`: Cmd 输出校验(数组,每项为一个操作符对象)
|
||||
- `rowCount`: DB 查询返回行数校验(支持操作符对象)
|
||||
- `rows`: DB 查询结果逐行校验(数组,每项为列名→操作符映射)
|
||||
- 比较操作符:`equals`(默认)、`contains`、`match`(正则,启动期会拒绝存在 ReDoS 风险的模式)、`empty`、`exists`、`gte`、`lte`、`gt`、`lt`
|
||||
|
||||
大小说明:`maxBodyBytes` 和 `maxOutputBytes` 支持单位 `KB`、`MB`、`GB`,也可直接使用数字(非负安全整数字节数)。
|
||||
@@ -178,7 +194,7 @@ JSON Schema:仓库根目录导出 `probe-config.schema.json`,可在 YAML 文
|
||||
|
||||
**MetaResponse**: `checkerTypes`(已注册 checker 类型标识符列表)
|
||||
|
||||
**TargetStatus**: `id`、`name`、`type`(checker 类型,如 http/cmd)、`target`(URL 或命令摘要)、`group`、`interval`、`latestCheck`、`stats`、`currentStreak`、`recentSamples`
|
||||
**TargetStatus**: `id`、`name`、`type`(checker 类型,如 http/cmd/db)、`target`(URL、命令摘要或数据库连接摘要)、`group`、`interval`、`latestCheck`、`stats`、`currentStreak`、`recentSamples`
|
||||
|
||||
**RecentSample**: `timestamp`、`durationMs`、`up`
|
||||
|
||||
|
||||
141
openspec/specs/db-checker/spec.md
Normal file
141
openspec/specs/db-checker/spec.md
Normal file
@@ -0,0 +1,141 @@
|
||||
## Purpose
|
||||
|
||||
定义 db 类型拨测目标的配置格式、执行逻辑、expect 断言规则和启动期校验。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: db target 配置
|
||||
系统 SHALL 支持 `type: db` 的 target 配置,通过 `db.url` 描述数据库连接字符串(遵循 Bun SQL 支持的格式),通过可选的 `db.query` 描述待执行的 SQL 语句。
|
||||
|
||||
#### Scenario: 解析仅连接的 db target
|
||||
- **WHEN** YAML 中 target 配置 `type: db` 和 `db.url: "postgres://user:pass@localhost:5432/mydb"`,未配置 `db.query`
|
||||
- **THEN** 系统 SHALL 将其解析为 db checker,仅执行连通性检测
|
||||
|
||||
#### Scenario: 解析带查询的 db target
|
||||
- **WHEN** YAML 中 target 配置 `type: db`、`db.url: "mysql://user:pass@host:3306/app"` 和 `db.query: "SELECT count(*) as cnt FROM users"`
|
||||
- **THEN** 系统 SHALL 将其解析为 db checker,执行连通性检测后执行指定 SQL 并进入 expect 校验
|
||||
|
||||
#### Scenario: db target 缺少 url
|
||||
- **WHEN** YAML 中 target 配置 `type: db` 但缺少 `db.url`
|
||||
- **THEN** 系统 SHALL 以配置错误退出,并提示该 target 缺少 db.url 字段
|
||||
|
||||
#### Scenario: db.url 为空字符串
|
||||
- **WHEN** YAML 中 target 配置 `db.url: ""`
|
||||
- **THEN** 系统 SHALL 以配置错误退出,并提示 db.url 不能为空
|
||||
|
||||
#### Scenario: db.query 为空字符串
|
||||
- **WHEN** YAML 中 target 配置 `db.query: ""`
|
||||
- **THEN** 系统 SHALL 以配置错误退出,并提示 db.query 不能为空字符串(如不需要查询则不配置该字段)
|
||||
|
||||
#### Scenario: db 分组未知字段失败
|
||||
- **WHEN** YAML 中 db target 的 `db` 分组包含 `timeout: 5` 等未知字段
|
||||
- **THEN** 系统 SHALL 以配置错误退出,提示 db 分组包含未知字段
|
||||
|
||||
#### Scenario: SQLite 连接字符串
|
||||
- **WHEN** YAML 中 target 配置 `db.url: "sqlite:///data/app.db"`
|
||||
- **THEN** 系统 SHALL 将其解析为 db checker,使用 SQLite 文件数据库
|
||||
|
||||
#### Scenario: url 格式由 Bun 运行时校验
|
||||
- **WHEN** YAML 中 target 配置 `db.url` 为 Bun 不支持的格式
|
||||
- **THEN** 系统 SHALL 在执行阶段捕获连接错误并作为 phase="connect" 的 failure 返回,而非在启动期校验 URL 格式
|
||||
|
||||
### Requirement: db checker 执行
|
||||
系统 SHALL 按 db target 配置连接数据库并执行查询,每次执行都新建连接并在完成后关闭。连接能力本身作为监控指标。
|
||||
|
||||
#### Scenario: 仅连接测试成功
|
||||
- **WHEN** db target 未配置 `db.query` 且数据库连接成功
|
||||
- **THEN** 系统 SHALL 内部执行 `SELECT 1` 验证连通性,记录 `matched=true` 和 `durationMs`
|
||||
|
||||
#### Scenario: 连接失败
|
||||
- **WHEN** db target 的数据库连接失败(网络不通、认证错误、数据库不存在等)
|
||||
- **THEN** 系统 SHALL 记录 `matched=false`,failure 的 phase 为 `"connect"`,message 包含可读错误信息
|
||||
|
||||
#### Scenario: 查询执行成功
|
||||
- **WHEN** db target 配置了 `db.query` 且 SQL 执行成功返回结果集
|
||||
- **THEN** 系统 SHALL 记录 `durationMs`(从连接开始到查询完成),并进入 expect 校验
|
||||
|
||||
#### Scenario: 查询执行失败
|
||||
- **WHEN** db target 配置了 `db.query` 且 SQL 执行报错(语法错误、权限不足、表不存在等)
|
||||
- **THEN** 系统 SHALL 记录 `matched=false`,failure 的 phase 为 `"query"`,message 包含数据库返回的错误信息
|
||||
|
||||
#### Scenario: 执行超时
|
||||
- **WHEN** db target 在 timeout 时间内未完成(连接或查询)
|
||||
- **THEN** 系统 SHALL 关闭连接,记录 `matched=false`,failure 的 phase 为 `"connect"` 或 `"query"`(取决于超时发生的阶段),message 包含超时信息
|
||||
|
||||
#### Scenario: 每次执行新建连接
|
||||
- **WHEN** db target 被引擎调度执行
|
||||
- **THEN** 系统 SHALL 创建新的 SQL 连接实例(max: 1),执行完成后立即关闭连接(close timeout: 0)
|
||||
|
||||
#### Scenario: 使用 unsafe 执行用户 SQL
|
||||
- **WHEN** db target 配置了 `db.query`
|
||||
- **THEN** 系统 SHALL 使用 `sql.unsafe(query)` 执行用户配置的 SQL 文本,不限制 SQL 类型
|
||||
|
||||
#### Scenario: 响应 abort signal
|
||||
- **WHEN** 引擎注入的 `ctx.signal` 被 abort
|
||||
- **THEN** 系统 SHALL 立即关闭数据库连接
|
||||
|
||||
### Requirement: db expect 校验
|
||||
系统 SHALL 支持 db 专用 expect,包括 `maxDurationMs`、`rowCount` 和 `rows`,按 duration、rowCount、rows 的阶段顺序快速失败。
|
||||
|
||||
#### Scenario: maxDurationMs 校验
|
||||
- **WHEN** db target 配置 `expect.maxDurationMs: 3000` 且实际执行耗时 4000ms
|
||||
- **THEN** 系统 SHALL 返回 `matched=false`,failure 的 phase 为 `"duration"`
|
||||
|
||||
#### Scenario: rowCount 校验通过
|
||||
- **WHEN** db target 配置 `expect.rowCount: { gte: 1 }` 且查询返回 5 行
|
||||
- **THEN** 系统 SHALL 判定 rowCount 阶段通过,继续后续 expect 阶段
|
||||
|
||||
#### Scenario: rowCount 校验失败
|
||||
- **WHEN** db target 配置 `expect.rowCount: { gte: 1 }` 且查询返回 0 行
|
||||
- **THEN** 系统 SHALL 返回 `matched=false`,failure 的 phase 为 `"rowCount"`,path 为 `"rowCount"`,expected 为 `{ gte: 1 }`,actual 为 0
|
||||
|
||||
#### Scenario: rows 按索引匹配列值(operator 形式)
|
||||
- **WHEN** db target 配置 `expect.rows: [{ cnt: { gte: 100 } }]` 且查询首行 cnt 列值为 50
|
||||
- **THEN** 系统 SHALL 返回 `matched=false`,failure 的 phase 为 `"row"`,path 为 `"rows[0].cnt"`
|
||||
|
||||
#### Scenario: rows 按索引匹配列值(字面量形式)
|
||||
- **WHEN** db target 配置 `expect.rows: [{ status: "active" }]` 且查询首行 status 列值为 `"active"`
|
||||
- **THEN** 系统 SHALL 判定该行该列通过(字面量等价于 `{ equals: "active" }`)
|
||||
|
||||
#### Scenario: rows 只检查声明的列
|
||||
- **WHEN** db target 配置 `expect.rows: [{ cnt: { gte: 1 } }]` 且查询首行包含 cnt、name、age 三列
|
||||
- **THEN** 系统 SHALL 仅检查 cnt 列,忽略 name 和 age 列
|
||||
|
||||
#### Scenario: rows 结果行数不足
|
||||
- **WHEN** db target 配置 `expect.rows` 包含 3 个元素但查询仅返回 2 行
|
||||
- **THEN** 系统 SHALL 返回 `matched=false`,failure 的 phase 为 `"row"`,message 说明结果行数不足
|
||||
|
||||
#### Scenario: 无 query 时 expect 被忽略
|
||||
- **WHEN** db target 未配置 `db.query` 但配置了 `expect.rowCount`
|
||||
- **THEN** 系统 SHALL 忽略 expect 中的 rowCount 和 rows 断言(仅 maxDurationMs 生效)
|
||||
|
||||
#### Scenario: 快速失败顺序
|
||||
- **WHEN** db target 同时配置 maxDurationMs、rowCount 和 rows
|
||||
- **THEN** 系统 SHALL 按 duration → rowCount → rows 顺序校验,任一阶段失败立即返回
|
||||
|
||||
### Requirement: db checker 启动期配置校验
|
||||
系统 SHALL 在启动期对 db checker 的配置契约和语义执行严格校验。Db target 的 `db` 分组 SHALL 只允许 `url` 和 `query` 字段。Db expect SHALL 只允许 `maxDurationMs`、`rowCount` 和 `rows` 字段。
|
||||
|
||||
#### Scenario: db expect maxDurationMs 非法
|
||||
- **WHEN** YAML 中 db target 配置 `expect.maxDurationMs` 不是非负有限数字
|
||||
- **THEN** 系统 SHALL 以配置错误退出,提示 expect.maxDurationMs 格式错误
|
||||
|
||||
#### Scenario: db expect rowCount 非法
|
||||
- **WHEN** YAML 中 db target 配置 `expect.rowCount` 不是合法的 operator 对象
|
||||
- **THEN** 系统 SHALL 以配置错误退出,提示 expect.rowCount 格式错误
|
||||
|
||||
#### Scenario: db expect rows 非法
|
||||
- **WHEN** YAML 中 db target 配置 `expect.rows` 不是对象数组
|
||||
- **THEN** 系统 SHALL 以配置错误退出,提示 expect.rows 必须为对象数组
|
||||
|
||||
#### Scenario: db expect rows 元素列值非法
|
||||
- **WHEN** YAML 中 db target 配置 `expect.rows: [{ cnt: { foo: 1 } }]`,其中 foo 不是合法 operator
|
||||
- **THEN** 系统 SHALL 以配置错误退出,提示 rows 中包含未知 operator
|
||||
|
||||
#### Scenario: db expect 未知字段失败
|
||||
- **WHEN** YAML 中 db target 的 expect 包含 `status: [200]` 或其他非 db expect 字段
|
||||
- **THEN** 系统 SHALL 以配置错误退出,提示 expect 包含未知字段
|
||||
|
||||
#### Scenario: db expect rows 中 match 正则非法
|
||||
- **WHEN** YAML 中 db target 配置 `expect.rows: [{ name: { match: "[invalid" } }]`
|
||||
- **THEN** 系统 SHALL 在启动期配置校验失败,而不是延迟到运行期抛错
|
||||
@@ -1,11 +1,11 @@
|
||||
## Purpose
|
||||
|
||||
定义 HTTP 拨测工具的 YAML 配置文件格式、解析校验规则和 CLI 启动流程。
|
||||
定义拨测工具的 YAML 配置文件格式、解析校验规则和 CLI 启动流程。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: YAML 配置文件格式
|
||||
系统 SHALL 支持通过 YAML 配置文件定义全部运行参数,包括 server 配置、runtime 配置、checker 默认值和 typed target 列表(含可选 group 字段)。target MUST 使用 `type` 字段声明 checker 类型,HTTP 领域字段 MUST 放在 `http` 分组,cmd 领域字段 MUST 放在 `cmd` 分组。HTTP target 的 `http` 分组 SHALL 支持可选的 `ignoreSSL`(布尔值)和 `maxRedirects`(非负整数)字段。
|
||||
系统 SHALL 支持通过 YAML 配置文件定义全部运行参数,包括 server 配置、runtime 配置、checker 默认值和 typed target 列表(含可选 group 字段)。target MUST 使用 `type` 字段声明 checker 类型,HTTP 领域字段 MUST 放在 `http` 分组,cmd 领域字段 MUST 放在 `cmd` 分组,db 领域字段 MUST 放在 `db` 分组。HTTP target 的 `http` 分组 SHALL 支持可选的 `ignoreSSL`(布尔值)和 `maxRedirects`(非负整数)字段。Db target 的 `db` 分组 SHALL 支持 `url`(必填)和 `query`(可选)字段。
|
||||
|
||||
#### Scenario: 完整配置文件解析
|
||||
- **WHEN** 系统启动并读取包含 server、runtime、defaults、targets(含 group 字段)的 YAML 配置文件
|
||||
@@ -31,6 +31,10 @@
|
||||
- **WHEN** YAML 配置中 HTTP target 设置 `http.maxRedirects: 5`
|
||||
- **THEN** 系统 SHALL 解析该字段并在执行时允许最多跟随 5 次重定向
|
||||
|
||||
#### Scenario: 最简 db 配置文件解析
|
||||
- **WHEN** 系统读取只包含一个 `type: db` target 和 `db.url` 的 YAML 配置文件
|
||||
- **THEN** 系统 SHALL 使用内置默认值填充未指定的字段(interval=30s, timeout=10s, group="default")
|
||||
|
||||
### Requirement: CLI 参数
|
||||
系统 SHALL 通过单一命令行参数接受 YAML 配置文件路径。
|
||||
|
||||
|
||||
@@ -89,6 +89,11 @@
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"db": {
|
||||
"additionalProperties": false,
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -716,6 +721,209 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"type": "object",
|
||||
"required": [
|
||||
"name",
|
||||
"type",
|
||||
"db"
|
||||
],
|
||||
"properties": {
|
||||
"expect": {
|
||||
"additionalProperties": false,
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"maxDurationMs": {
|
||||
"minimum": 0,
|
||||
"type": "number"
|
||||
},
|
||||
"rowCount": {
|
||||
"additionalProperties": false,
|
||||
"minProperties": 1,
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"contains": {
|
||||
"type": "string"
|
||||
},
|
||||
"empty": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"equals": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"items": {},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"additionalProperties": {},
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
"exists": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"gt": {
|
||||
"type": "number"
|
||||
},
|
||||
"gte": {
|
||||
"type": "number"
|
||||
},
|
||||
"lt": {
|
||||
"type": "number"
|
||||
},
|
||||
"lte": {
|
||||
"type": "number"
|
||||
},
|
||||
"match": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"rows": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"additionalProperties": false,
|
||||
"minProperties": 1,
|
||||
"type": "object",
|
||||
"patternProperties": {
|
||||
"^(.*)$": {
|
||||
"anyOf": [
|
||||
{
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"items": {},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"additionalProperties": {},
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"minProperties": 1,
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"contains": {
|
||||
"type": "string"
|
||||
},
|
||||
"empty": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"equals": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"items": {},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"additionalProperties": {},
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
"exists": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"gt": {
|
||||
"type": "number"
|
||||
},
|
||||
"gte": {
|
||||
"type": "number"
|
||||
},
|
||||
"lt": {
|
||||
"type": "number"
|
||||
},
|
||||
"lte": {
|
||||
"type": "number"
|
||||
},
|
||||
"match": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"group": {
|
||||
"type": "string"
|
||||
},
|
||||
"interval": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
"timeout": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"const": "db",
|
||||
"type": "string"
|
||||
},
|
||||
"db": {
|
||||
"additionalProperties": false,
|
||||
"type": "object",
|
||||
"required": [
|
||||
"url"
|
||||
],
|
||||
"properties": {
|
||||
"query": {
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
"url": {
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -232,3 +232,39 @@ targets:
|
||||
exitCode: [1]
|
||||
stderr:
|
||||
- contains: "simulated error"
|
||||
|
||||
# ========== DB targets ==========
|
||||
|
||||
- name: "SQLite 内存数据库连接测试"
|
||||
type: db
|
||||
group: "数据库"
|
||||
db:
|
||||
url: "sqlite://:memory:"
|
||||
expect:
|
||||
maxDurationMs: 1000
|
||||
|
||||
- name: "SQLite 内存数据库查询行数"
|
||||
type: db
|
||||
db:
|
||||
url: "sqlite://:memory:"
|
||||
query: "SELECT 1 as cnt"
|
||||
expect:
|
||||
maxDurationMs: 1000
|
||||
rowCount:
|
||||
gte: 1
|
||||
|
||||
- name: "SQLite 内存数据库多列结果校验"
|
||||
type: db
|
||||
db:
|
||||
url: "sqlite://:memory:"
|
||||
query: "SELECT 1 as id, 'Alice' as name, 'engineer' as role"
|
||||
expect:
|
||||
rowCount:
|
||||
equals: 1
|
||||
rows:
|
||||
- id:
|
||||
gte: 1
|
||||
name:
|
||||
exists: true
|
||||
role:
|
||||
contains: "engineer"
|
||||
|
||||
206
src/server/checker/runner/db/execute.ts
Normal file
206
src/server/checker/runner/db/execute.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import { SQL } from "bun";
|
||||
import { isError } from "es-toolkit";
|
||||
|
||||
import type { CheckResult, RawTargetConfig } from "../../types";
|
||||
import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types";
|
||||
import type { DbExpectConfig, DbTargetConfig, ResolvedDbTarget } from "./types";
|
||||
|
||||
import { checkDuration } from "../../expect/duration";
|
||||
import { errorFailure } from "../../expect/failure";
|
||||
import { checkRowCount, checkRows } from "./expect";
|
||||
import { dbCheckerSchemas } from "./schema";
|
||||
import { validateDbConfig } from "./validate";
|
||||
|
||||
const PROBE_QUERY = "SELECT 1";
|
||||
|
||||
export class DbChecker implements CheckerDefinition<ResolvedDbTarget> {
|
||||
readonly configKey = "db";
|
||||
|
||||
readonly schemas = dbCheckerSchemas;
|
||||
|
||||
readonly type = "db";
|
||||
|
||||
async execute(t: ResolvedDbTarget, ctx: CheckerContext): Promise<CheckResult> {
|
||||
const timestamp = new Date().toISOString();
|
||||
const start = performance.now();
|
||||
let db: SQL | undefined;
|
||||
|
||||
try {
|
||||
// 创建连接(SQLite 不需要 max 选项)
|
||||
db = new SQL(t.db.url);
|
||||
|
||||
// 监听 abort signal
|
||||
ctx.signal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
void db?.close({ timeout: 0 }).catch(() => {
|
||||
/* best-effort close */
|
||||
});
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
|
||||
// 连接测试(Bun SQL 是 lazy 的,首次查询才真正连接)
|
||||
try {
|
||||
await db.unsafe(PROBE_QUERY);
|
||||
} catch (error) {
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
return {
|
||||
durationMs,
|
||||
failure: errorFailure("connect", "connect", isError(error) ? error.message : String(error)),
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
targetName: t.name,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
// 无 query 时仅测试连接
|
||||
if (!t.db.query) {
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
const durationResult = checkDuration(durationMs, t.expect?.maxDurationMs);
|
||||
if (!durationResult.matched) {
|
||||
return {
|
||||
durationMs,
|
||||
failure: durationResult.failure,
|
||||
matched: false,
|
||||
statusDetail: "connected",
|
||||
targetName: t.name,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
return {
|
||||
durationMs,
|
||||
failure: null,
|
||||
matched: true,
|
||||
statusDetail: "connected",
|
||||
targetName: t.name,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
// 执行用户 SQL
|
||||
let rows: unknown[];
|
||||
try {
|
||||
rows = await db.unsafe(t.db.query);
|
||||
} catch (error) {
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
return {
|
||||
durationMs,
|
||||
failure: errorFailure("query", "query", isError(error) ? error.message : String(error)),
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
targetName: t.name,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
|
||||
// 检查是否超时
|
||||
if (ctx.signal.aborted) {
|
||||
return {
|
||||
durationMs,
|
||||
failure: errorFailure("query", "timeout", `查询超时 (${t.timeoutMs}ms)`),
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
targetName: t.name,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
// duration 断言
|
||||
const durationResult = checkDuration(durationMs, t.expect?.maxDurationMs);
|
||||
if (!durationResult.matched) {
|
||||
return {
|
||||
durationMs,
|
||||
failure: durationResult.failure,
|
||||
matched: false,
|
||||
statusDetail: `${Array.isArray(rows) ? rows.length : 0} rows`,
|
||||
targetName: t.name,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
// rowCount 断言
|
||||
if (t.expect?.rowCount) {
|
||||
const rowCountResult = checkRowCount(rows, t.expect.rowCount);
|
||||
if (!rowCountResult.matched) {
|
||||
return {
|
||||
durationMs,
|
||||
failure: rowCountResult.failure,
|
||||
matched: false,
|
||||
statusDetail: `${Array.isArray(rows) ? rows.length : 0} rows`,
|
||||
targetName: t.name,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// rows 断言
|
||||
if (t.expect?.rows && t.expect.rows.length > 0) {
|
||||
const rowsResult = checkRows(rows, t.expect.rows);
|
||||
if (!rowsResult.matched) {
|
||||
return {
|
||||
durationMs,
|
||||
failure: rowsResult.failure,
|
||||
matched: false,
|
||||
statusDetail: `${Array.isArray(rows) ? rows.length : 0} rows`,
|
||||
targetName: t.name,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
durationMs,
|
||||
failure: null,
|
||||
matched: true,
|
||||
statusDetail: `${Array.isArray(rows) ? rows.length : 0} rows`,
|
||||
targetName: t.name,
|
||||
timestamp,
|
||||
};
|
||||
} finally {
|
||||
if (db) {
|
||||
try {
|
||||
await db.close({ timeout: 0 });
|
||||
} catch {
|
||||
/* best-effort close */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resolve(target: RawTargetConfig, context: ResolveContext): ResolvedDbTarget {
|
||||
const t = target as RawTargetConfig & { db: DbTargetConfig; type: "db" };
|
||||
|
||||
return {
|
||||
db: {
|
||||
query: t.db.query,
|
||||
url: t.db.url,
|
||||
},
|
||||
expect: target.expect as DbExpectConfig | undefined,
|
||||
group: target.group ?? "default",
|
||||
intervalMs: context.defaultIntervalMs,
|
||||
name: t.name,
|
||||
timeoutMs: context.defaultTimeoutMs,
|
||||
type: "db",
|
||||
} satisfies ResolvedDbTarget;
|
||||
}
|
||||
|
||||
serialize(t: ResolvedDbTarget): { config: string; target: string } {
|
||||
// 屏蔽凭据:postgres://user:pass@host → postgres://***:***@host
|
||||
const masked = t.db.url.replace(/:\/\/([^@]+)@/, "://***:***@");
|
||||
return {
|
||||
config: JSON.stringify({
|
||||
query: t.db.query ?? null,
|
||||
url: masked,
|
||||
}),
|
||||
target: masked,
|
||||
};
|
||||
}
|
||||
|
||||
validate(input: CheckerValidationInput) {
|
||||
return validateDbConfig(input);
|
||||
}
|
||||
}
|
||||
57
src/server/checker/runner/db/expect.ts
Normal file
57
src/server/checker/runner/db/expect.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { ExpectResult } from "../../expect/types";
|
||||
import type { ExpectOperator, ExpectValue } from "../../types";
|
||||
|
||||
import { mismatchFailure } from "../../expect/failure";
|
||||
import { checkExpectValue } from "../../expect/operator";
|
||||
|
||||
export function checkRowCount(rows: unknown, op: ExpectOperator): ExpectResult {
|
||||
const actual = Array.isArray(rows) ? rows.length : 0;
|
||||
const matched = checkExpectValue(actual, op);
|
||||
if (!matched) {
|
||||
return {
|
||||
failure: mismatchFailure("rowCount", "rowCount", op, actual, `rowCount ${actual} 不满足条件`),
|
||||
matched: false,
|
||||
};
|
||||
}
|
||||
return { failure: null, matched: true };
|
||||
}
|
||||
|
||||
export function checkRows(rows: unknown, rules: Array<Record<string, ExpectValue>>): ExpectResult {
|
||||
if (!Array.isArray(rows)) {
|
||||
return {
|
||||
failure: mismatchFailure("row", "rows", rules, rows, "查询结果不是数组"),
|
||||
matched: false,
|
||||
};
|
||||
}
|
||||
|
||||
for (let i = 0; i < rules.length; i++) {
|
||||
const rule = rules[i]!;
|
||||
if (i >= rows.length) {
|
||||
return {
|
||||
failure: mismatchFailure("row", `rows[${i}]`, "expected row", undefined, `结果行数不足,需要第 ${i + 1} 行`),
|
||||
matched: false,
|
||||
};
|
||||
}
|
||||
|
||||
const row = rows[i]! as null | Record<string, unknown> | undefined;
|
||||
if (!row || typeof row !== "object" || Array.isArray(row)) {
|
||||
return {
|
||||
failure: mismatchFailure("row", `rows[${i}]`, "object", row, `第 ${i + 1} 行不是对象`),
|
||||
matched: false,
|
||||
};
|
||||
}
|
||||
|
||||
for (const [col, expected] of Object.entries(rule)) {
|
||||
const actual = row[col];
|
||||
const matched = checkExpectValue(actual, expected);
|
||||
if (!matched) {
|
||||
return {
|
||||
failure: mismatchFailure("row", `rows[${i}].${col}`, expected, actual, `rows[${i}].${col} mismatch`),
|
||||
matched: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { failure: null, matched: true };
|
||||
}
|
||||
1
src/server/checker/runner/db/index.ts
Normal file
1
src/server/checker/runner/db/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { DbChecker } from "./execute";
|
||||
52
src/server/checker/runner/db/schema.ts
Normal file
52
src/server/checker/runner/db/schema.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { Type } from "@sinclair/typebox";
|
||||
|
||||
import type { CheckerSchemas } from "../types";
|
||||
|
||||
import { createPureOperatorSchema, jsonValueSchema, operatorProperties } from "../../schema/fragments";
|
||||
|
||||
// Db expect 允许行对象中的列值为字面量或 operator
|
||||
const dbRowValueSchema = Type.Union([jsonValueSchema, createPureOperatorSchema()]);
|
||||
|
||||
export const dbCheckerSchemas: CheckerSchemas = {
|
||||
config: Type.Object(
|
||||
{
|
||||
query: Type.Optional(
|
||||
Type.String({
|
||||
minLength: 1,
|
||||
}),
|
||||
),
|
||||
url: Type.String({ minLength: 1 }),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
defaults: Type.Object({}, { additionalProperties: false }),
|
||||
expect: Type.Object(
|
||||
{
|
||||
maxDurationMs: Type.Optional(Type.Number({ minimum: 0 })),
|
||||
rowCount: Type.Optional(createPureOperatorSchema()),
|
||||
rows: Type.Optional(
|
||||
Type.Array(
|
||||
Type.Record(Type.String(), dbRowValueSchema, {
|
||||
additionalProperties: false,
|
||||
minProperties: 1,
|
||||
}),
|
||||
),
|
||||
),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
};
|
||||
|
||||
// 导出用于 validate 的辅助类型
|
||||
export const DbOperatorKeys = new Set<string>([
|
||||
...Object.keys(operatorProperties()),
|
||||
"contains",
|
||||
"empty",
|
||||
"equals",
|
||||
"exists",
|
||||
"gt",
|
||||
"gte",
|
||||
"lt",
|
||||
"lte",
|
||||
"match",
|
||||
]);
|
||||
27
src/server/checker/runner/db/types.ts
Normal file
27
src/server/checker/runner/db/types.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { ExpectOperator, ExpectValue, ResolvedTargetBase } from "../../types";
|
||||
|
||||
export interface DbExpectConfig {
|
||||
maxDurationMs?: number;
|
||||
rowCount?: ExpectOperator;
|
||||
rows?: Array<Record<string, ExpectValue>>;
|
||||
}
|
||||
|
||||
export interface DbTargetConfig {
|
||||
query?: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface ResolvedDbConfig {
|
||||
query?: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface ResolvedDbTarget extends ResolvedTargetBase {
|
||||
db: ResolvedDbConfig;
|
||||
expect?: DbExpectConfig;
|
||||
group: string;
|
||||
intervalMs: number;
|
||||
name: string;
|
||||
timeoutMs: number;
|
||||
type: "db";
|
||||
}
|
||||
142
src/server/checker/runner/db/validate.ts
Normal file
142
src/server/checker/runner/db/validate.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import type { ConfigValidationIssue } from "../../schema/issues";
|
||||
import type { CheckerValidationInput } from "../types";
|
||||
|
||||
import { isUnsafeRegex } from "../../expect/redos";
|
||||
import { validateOperatorObject } from "../../expect/validate-operator";
|
||||
import { issue, joinPath } from "../../schema/issues";
|
||||
|
||||
export function validateDbConfig(input: CheckerValidationInput): ConfigValidationIssue[] {
|
||||
const issues: ConfigValidationIssue[] = [];
|
||||
|
||||
for (let i = 0; i < input.targets.length; i++) {
|
||||
const target = input.targets[i] as unknown;
|
||||
if (!isRecord(target)) continue;
|
||||
if (target["type"] !== "db") continue;
|
||||
issues.push(...validateDbTarget(target, `targets[${i}]`));
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
function collectRowOperators(rows: unknown[], path: string, targetName?: string): ConfigValidationIssue[] {
|
||||
const issues: ConfigValidationIssue[] = [];
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i]!;
|
||||
if (!isRecord(row)) {
|
||||
issues.push(issue("invalid-type", `${path}[${i}]`, "必须为对象", targetName));
|
||||
continue;
|
||||
}
|
||||
for (const [col, value] of Object.entries(row)) {
|
||||
const colPath = `${path}[${i}].${col}`;
|
||||
if (isRecord(value) && Object.keys(value).some((k) => k === "match")) {
|
||||
// 检查 match 正则
|
||||
const match = value["match"];
|
||||
if (typeof match === "string") {
|
||||
try {
|
||||
new RegExp(match);
|
||||
} catch {
|
||||
issues.push(issue("invalid-regex", colPath, "正则不合法", targetName));
|
||||
}
|
||||
if (typeof match === "string" && isUnsafeRegex(match)) {
|
||||
issues.push(issue("unsafe-regex", colPath, "正则存在 ReDoS 风险", targetName));
|
||||
}
|
||||
}
|
||||
}
|
||||
// 校验 operator 对象
|
||||
if (isRecord(value)) {
|
||||
issues.push(...validateOperatorObject(value, colPath, targetName, { requireAtLeastOne: false }));
|
||||
}
|
||||
}
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
function getTargetName(target: Record<string, unknown>): string | undefined {
|
||||
return typeof target["name"] === "string" ? target["name"] : undefined;
|
||||
}
|
||||
|
||||
function isNonNegativeFiniteNumber(value: unknown): boolean {
|
||||
return typeof value === "number" && Number.isFinite(value) && value >= 0;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function validateDbExpect(target: Record<string, unknown>, path: string): ConfigValidationIssue[] {
|
||||
const targetName = getTargetName(target);
|
||||
const expect = target["expect"];
|
||||
if (expect === undefined || expect === null || !isRecord(expect)) return [];
|
||||
const issues: ConfigValidationIssue[] = [];
|
||||
const expectPath = joinPath(path, "expect");
|
||||
|
||||
if (expect["maxDurationMs"] !== undefined && !isNonNegativeFiniteNumber(expect["maxDurationMs"])) {
|
||||
issues.push(issue("invalid-type", joinPath(expectPath, "maxDurationMs"), "必须为非负有限数字", targetName));
|
||||
}
|
||||
|
||||
if (expect["rowCount"] !== undefined) {
|
||||
issues.push(...validateOperatorObject(expect["rowCount"], joinPath(expectPath, "rowCount"), targetName));
|
||||
}
|
||||
|
||||
if (expect["rows"] !== undefined) {
|
||||
if (!Array.isArray(expect["rows"])) {
|
||||
issues.push(issue("invalid-type", joinPath(expectPath, "rows"), "必须为数组", targetName));
|
||||
} else {
|
||||
issues.push(...collectRowOperators(expect["rows"], joinPath(expectPath, "rows"), targetName));
|
||||
}
|
||||
}
|
||||
|
||||
// 检查未知字段
|
||||
const allowedKeys = new Set(["maxDurationMs", "rowCount", "rows"]);
|
||||
for (const key of Object.keys(expect)) {
|
||||
if (!allowedKeys.has(key)) {
|
||||
issues.push(issue("unknown-field", joinPath(expectPath, key), "是未知字段", targetName));
|
||||
}
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
function validateDbTarget(target: Record<string, unknown>, path: string): ConfigValidationIssue[] {
|
||||
const issues: ConfigValidationIssue[] = [];
|
||||
const targetName = getTargetName(target);
|
||||
const db = target["db"];
|
||||
|
||||
if (!isRecord(db)) {
|
||||
issues.push(issue("required", joinPath(path, "db"), "缺少 db.url 字段", targetName));
|
||||
issues.push(...validateDbExpect(target, path));
|
||||
return issues;
|
||||
}
|
||||
|
||||
// url 必填
|
||||
if (typeof db["url"] !== "string" || db["url"].trim() === "") {
|
||||
issues.push(issue("required", joinPath(joinPath(path, "db"), "url"), "缺少 db.url 字段", targetName));
|
||||
}
|
||||
|
||||
// query 可选但不能为空字符串
|
||||
if (db["query"] !== undefined) {
|
||||
if (typeof db["query"] !== "string") {
|
||||
issues.push(issue("invalid-type", joinPath(joinPath(path, "db"), "query"), "必须为字符串", targetName));
|
||||
} else if (db["query"].trim() === "") {
|
||||
issues.push(
|
||||
issue(
|
||||
"invalid-value",
|
||||
joinPath(joinPath(path, "db"), "query"),
|
||||
"不能为空字符串(如不需要查询则不配置该字段)",
|
||||
targetName,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 检查未知字段
|
||||
const allowedDbKeys = new Set(["query", "url"]);
|
||||
for (const key of Object.keys(db)) {
|
||||
if (!allowedDbKeys.has(key)) {
|
||||
issues.push(issue("unknown-field", joinPath(joinPath(path, "db"), key), "是未知字段", targetName));
|
||||
}
|
||||
}
|
||||
|
||||
issues.push(...validateDbExpect(target, path));
|
||||
return issues;
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { CommandChecker } from "./cmd";
|
||||
import { DbChecker } from "./db";
|
||||
import { HttpChecker } from "./http";
|
||||
import { CheckerRegistry } from "./registry";
|
||||
|
||||
const checkers = [new HttpChecker(), new CommandChecker()];
|
||||
const checkers = [new HttpChecker(), new CommandChecker(), new DbChecker()];
|
||||
|
||||
export function createDefaultCheckerRegistry(): CheckerRegistry {
|
||||
const registry = new CheckerRegistry();
|
||||
|
||||
158
tests/server/checker/runner/db/execute.test.ts
Normal file
158
tests/server/checker/runner/db/execute.test.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import type { ResolvedDbTarget } from "../../../../../src/server/checker/runner/db/types";
|
||||
import type { CheckerContext } from "../../../../../src/server/checker/runner/types";
|
||||
|
||||
import { DbChecker } from "../../../../../src/server/checker/runner/db/execute";
|
||||
|
||||
const checker = new DbChecker();
|
||||
|
||||
function makeCtx(timeoutMs = 5000): CheckerContext {
|
||||
const controller = new AbortController();
|
||||
setTimeout(() => controller.abort(), timeoutMs);
|
||||
return { signal: controller.signal };
|
||||
}
|
||||
|
||||
function makeTarget(db: Partial<ResolvedDbTarget["db"]>, overrides?: Partial<ResolvedDbTarget>): ResolvedDbTarget {
|
||||
return {
|
||||
db: {
|
||||
url: "sqlite://:memory:",
|
||||
...db,
|
||||
},
|
||||
group: "default",
|
||||
intervalMs: 60000,
|
||||
name: "test-db",
|
||||
timeoutMs: 5000,
|
||||
type: "db",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("DbChecker", () => {
|
||||
test("无 query 时仅测试连接成功", async () => {
|
||||
const result = await checker.execute(makeTarget({}), makeCtx());
|
||||
expect(result.matched).toBe(true);
|
||||
expect(result.statusDetail).toBe("connected");
|
||||
expect(result.failure).toBeNull();
|
||||
});
|
||||
|
||||
test("执行查询成功", async () => {
|
||||
const result = await checker.execute(makeTarget({ query: "SELECT 1 as num, 'hello' as str" }), makeCtx());
|
||||
expect(result.matched).toBe(true);
|
||||
expect(result.statusDetail).toBe("1 rows");
|
||||
expect(result.durationMs).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
test("查询返回多行", async () => {
|
||||
const result = await checker.execute(
|
||||
makeTarget({ query: "SELECT 1 as n UNION ALL SELECT 2 UNION ALL SELECT 3" }),
|
||||
makeCtx(),
|
||||
);
|
||||
expect(result.matched).toBe(true);
|
||||
expect(result.statusDetail).toBe("3 rows");
|
||||
});
|
||||
|
||||
test("查询返回空结果", async () => {
|
||||
const result = await checker.execute(makeTarget({ query: "SELECT 1 as n WHERE 1=0" }), makeCtx());
|
||||
expect(result.matched).toBe(true);
|
||||
expect(result.statusDetail).toBe("0 rows");
|
||||
});
|
||||
|
||||
test("连接失败返回 connect phase 错误", async () => {
|
||||
const result = await checker.execute(makeTarget({ url: "sqlite:///nonexistent/path/db.db" }), makeCtx());
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.failure!.phase).toBe("connect");
|
||||
expect(result.failure!.message).toBeTruthy();
|
||||
});
|
||||
|
||||
test("SQL 语法错误返回 query phase 错误", async () => {
|
||||
const result = await checker.execute(makeTarget({ query: "SELECT INVALID SQL" }), makeCtx());
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.failure!.phase).toBe("query");
|
||||
expect(result.failure!.message).toBeTruthy();
|
||||
});
|
||||
|
||||
test("maxDurationMs 超时返回失败", async () => {
|
||||
const result = await checker.execute(
|
||||
makeTarget({ query: "SELECT 1" }, { expect: { maxDurationMs: -1 } }),
|
||||
makeCtx(),
|
||||
);
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.failure!.phase).toBe("duration");
|
||||
});
|
||||
|
||||
test("rowCount 断言通过", async () => {
|
||||
const result = await checker.execute(
|
||||
makeTarget({ query: "SELECT 1 UNION ALL SELECT 2" }, { expect: { rowCount: { gte: 2 } } }),
|
||||
makeCtx(),
|
||||
);
|
||||
expect(result.matched).toBe(true);
|
||||
});
|
||||
|
||||
test("rowCount 断言失败", async () => {
|
||||
const result = await checker.execute(
|
||||
makeTarget({ query: "SELECT 1 UNION ALL SELECT 2" }, { expect: { rowCount: { gte: 5 } } }),
|
||||
makeCtx(),
|
||||
);
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.failure!.phase).toBe("rowCount");
|
||||
});
|
||||
|
||||
test("rows 断言通过(字面量形式)", async () => {
|
||||
const result = await checker.execute(
|
||||
makeTarget({ query: "SELECT 100 as cnt" }, { expect: { rows: [{ cnt: 100 }] } }),
|
||||
makeCtx(),
|
||||
);
|
||||
expect(result.matched).toBe(true);
|
||||
});
|
||||
|
||||
test("rows 断言通过(operator 形式)", async () => {
|
||||
const result = await checker.execute(
|
||||
makeTarget({ query: "SELECT 150 as cnt" }, { expect: { rows: [{ cnt: { gte: 100 } }] } }),
|
||||
makeCtx(),
|
||||
);
|
||||
expect(result.matched).toBe(true);
|
||||
});
|
||||
|
||||
test("rows 断言失败", async () => {
|
||||
const result = await checker.execute(
|
||||
makeTarget({ query: "SELECT 50 as cnt" }, { expect: { rows: [{ cnt: { gte: 100 } }] } }),
|
||||
makeCtx(),
|
||||
);
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.failure!.phase).toBe("row");
|
||||
expect(result.failure!.path).toBe("rows[0].cnt");
|
||||
});
|
||||
|
||||
test("rows 结果行数不足", async () => {
|
||||
const result = await checker.execute(
|
||||
makeTarget({ query: "SELECT 1 as n" }, { expect: { rows: [{ n: 1 }, { n: 2 }] } }),
|
||||
makeCtx(),
|
||||
);
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.failure!.phase).toBe("row");
|
||||
expect(result.failure!.message).toContain("行数不足");
|
||||
});
|
||||
|
||||
test("rows 只检查声明的列", async () => {
|
||||
const result = await checker.execute(
|
||||
makeTarget({ query: "SELECT 1 as cnt, 'ignored' as other" }, { expect: { rows: [{ cnt: { gte: 1 } }] } }),
|
||||
makeCtx(),
|
||||
);
|
||||
expect(result.matched).toBe(true);
|
||||
});
|
||||
|
||||
test("serialize 屏蔽凭据", () => {
|
||||
const target = makeTarget({ url: "postgres://user:pass@host:5432/db" });
|
||||
const s = checker.serialize(target);
|
||||
expect(s.target).toBe("postgres://***:***@host:5432/db");
|
||||
const config = JSON.parse(s.config) as { url: string };
|
||||
expect(config.url).toBe("postgres://***:***@host:5432/db");
|
||||
});
|
||||
|
||||
test("serialize 无凭据的 url 保持原样", () => {
|
||||
const target = makeTarget({ url: "sqlite:///data/app.db" });
|
||||
const s = checker.serialize(target);
|
||||
expect(s.target).toBe("sqlite:///data/app.db");
|
||||
});
|
||||
});
|
||||
134
tests/server/checker/runner/db/expect.test.ts
Normal file
134
tests/server/checker/runner/db/expect.test.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { checkRowCount, checkRows } from "../../../../../src/server/checker/runner/db/expect";
|
||||
|
||||
describe("checkRowCount", () => {
|
||||
test("空数组通过 rowCount gte 0", () => {
|
||||
const result = checkRowCount([], { gte: 0 });
|
||||
expect(result.matched).toBe(true);
|
||||
expect(result.failure).toBeNull();
|
||||
});
|
||||
|
||||
test("非数组视为 0 行", () => {
|
||||
const result = checkRowCount(null, { gte: 0 });
|
||||
expect(result.matched).toBe(true);
|
||||
});
|
||||
|
||||
test("rowCount gte 通过", () => {
|
||||
const result = checkRowCount([1, 2, 3], { gte: 3 });
|
||||
expect(result.matched).toBe(true);
|
||||
});
|
||||
|
||||
test("rowCount gte 失败", () => {
|
||||
const result = checkRowCount([1, 2], { gte: 3 });
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.failure!.phase).toBe("rowCount");
|
||||
expect(result.failure!.path).toBe("rowCount");
|
||||
});
|
||||
|
||||
test("rowCount equals 通过", () => {
|
||||
const result = checkRowCount([1, 2, 3], { equals: 3 });
|
||||
expect(result.matched).toBe(true);
|
||||
});
|
||||
|
||||
test("rowCount equals 失败", () => {
|
||||
const result = checkRowCount([1, 2, 3], { equals: 5 });
|
||||
expect(result.matched).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkRows", () => {
|
||||
test("非数组返回失败", () => {
|
||||
const result = checkRows(null, [{ col: 1 }]);
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.failure!.phase).toBe("row");
|
||||
expect(result.failure!.path).toBe("rows");
|
||||
});
|
||||
|
||||
test("空规则通过", () => {
|
||||
const result = checkRows([], []);
|
||||
expect(result.matched).toBe(true);
|
||||
});
|
||||
|
||||
test("单行单列匹配(字面量)", () => {
|
||||
const result = checkRows([{ col: "value" }], [{ col: "value" }]);
|
||||
expect(result.matched).toBe(true);
|
||||
});
|
||||
|
||||
test("单行单列匹配(operator)", () => {
|
||||
const result = checkRows([{ col: 100 }], [{ col: { gte: 50 } }]);
|
||||
expect(result.matched).toBe(true);
|
||||
});
|
||||
|
||||
test("单行单列不匹配", () => {
|
||||
const result = checkRows([{ col: 10 }], [{ col: { gte: 50 } }]);
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.failure!.phase).toBe("row");
|
||||
expect(result.failure!.path).toBe("rows[0].col");
|
||||
});
|
||||
|
||||
test("多行多列全部匹配", () => {
|
||||
const result = checkRows(
|
||||
[
|
||||
{ id: 1, name: "Alice" },
|
||||
{ id: 2, name: "Bob" },
|
||||
],
|
||||
[{ id: { gte: 1 } }, { name: "Bob" }],
|
||||
);
|
||||
expect(result.matched).toBe(true);
|
||||
});
|
||||
|
||||
test("多行中有一行不匹配", () => {
|
||||
const result = checkRows([{ col: 1 }, { col: 2 }], [{ col: { gte: 2 } }, { col: { gte: 3 } }]);
|
||||
expect(result.matched).toBe(false);
|
||||
// 第一行 { col: 1 } 不满足 { gte: 2 },所以失败在第一行
|
||||
expect(result.failure!.path).toBe("rows[0].col");
|
||||
});
|
||||
|
||||
test("结果行数不足", () => {
|
||||
const result = checkRows([{ col: 1 }], [{ col: 1 }, { col: 2 }]);
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.failure!.message).toContain("行数不足");
|
||||
});
|
||||
|
||||
test("只检查声明的列", () => {
|
||||
const result = checkRows([{ col: 1, other: "ignored" }], [{ col: { gte: 0 } }]);
|
||||
expect(result.matched).toBe(true);
|
||||
});
|
||||
|
||||
test("行不是对象返回失败", () => {
|
||||
const result = checkRows(["not-an-object"] as unknown[], [{ col: 1 }]);
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.failure!.path).toBe("rows[0]");
|
||||
});
|
||||
|
||||
test("列不存在视为 undefined", () => {
|
||||
const result = checkRows([{}], [{ col: { exists: false } }]);
|
||||
expect(result.matched).toBe(true);
|
||||
});
|
||||
|
||||
test("列存在且值为 null", () => {
|
||||
const result = checkRows([{ col: null }], [{ col: { empty: true } }]);
|
||||
expect(result.matched).toBe(true);
|
||||
});
|
||||
|
||||
test("contains 匹配字符串", () => {
|
||||
const result = checkRows([{ text: "hello world" }], [{ text: { contains: "hello" } }]);
|
||||
expect(result.matched).toBe(true);
|
||||
});
|
||||
|
||||
test("match 正则匹配", () => {
|
||||
const result = checkRows([{ code: "ABC-123" }], [{ code: { match: "^ABC-" } }]);
|
||||
expect(result.matched).toBe(true);
|
||||
});
|
||||
|
||||
test("多个断言同时满足", () => {
|
||||
const result = checkRows([{ val: 50 }], [{ val: { gte: 10, lte: 100 } }]);
|
||||
expect(result.matched).toBe(true);
|
||||
});
|
||||
|
||||
test("多个断言中有一个不满足", () => {
|
||||
const result = checkRows([{ val: 50 }], [{ val: { gte: 10, lte: 30 } }]);
|
||||
expect(result.matched).toBe(false);
|
||||
});
|
||||
});
|
||||
154
tests/server/checker/runner/db/validate.test.ts
Normal file
154
tests/server/checker/runner/db/validate.test.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { validateDbConfig } from "../../../../../src/server/checker/runner/db/validate";
|
||||
|
||||
describe("validateDbConfig", () => {
|
||||
test("空配置无问题", () => {
|
||||
const result = validateDbConfig({ defaults: {}, targets: [] });
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("缺少 db.url 返回错误", () => {
|
||||
const result = validateDbConfig({
|
||||
defaults: {},
|
||||
targets: [{ name: "test", type: "db" }],
|
||||
});
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
const dbError = result.find((e) => e.path.includes("db"));
|
||||
expect(dbError).toBeDefined();
|
||||
expect(dbError!.code).toBe("required");
|
||||
});
|
||||
|
||||
test("db.url 为空字符串返回错误", () => {
|
||||
const result = validateDbConfig({
|
||||
defaults: {},
|
||||
targets: [{ db: { url: "" }, name: "test", type: "db" }],
|
||||
});
|
||||
const urlError = result.find((e) => e.path.includes("db.url"));
|
||||
expect(urlError).toBeDefined();
|
||||
expect(urlError!.code).toBe("required");
|
||||
});
|
||||
|
||||
test("db.query 为空字符串返回错误", () => {
|
||||
const result = validateDbConfig({
|
||||
defaults: {},
|
||||
targets: [{ db: { query: "", url: "sqlite://:memory:" }, name: "test", type: "db" }],
|
||||
});
|
||||
const queryError = result.find((e) => e.path.includes("db.query"));
|
||||
expect(queryError).toBeDefined();
|
||||
expect(queryError!.code).toBe("invalid-value");
|
||||
});
|
||||
|
||||
test("db 分组未知字段返回错误", () => {
|
||||
const result = validateDbConfig({
|
||||
defaults: {},
|
||||
targets: [{ db: { timeout: 5, url: "sqlite://:memory:" }, name: "test", type: "db" }],
|
||||
});
|
||||
const unknownError = result.find((e) => e.path.includes("db.timeout"));
|
||||
expect(unknownError).toBeDefined();
|
||||
expect(unknownError!.code).toBe("unknown-field");
|
||||
});
|
||||
|
||||
test("expect.maxDurationMs 非数字返回错误", () => {
|
||||
const result = validateDbConfig({
|
||||
defaults: {},
|
||||
targets: [{ db: { url: "sqlite://:memory:" }, expect: { maxDurationMs: "invalid" }, name: "test", type: "db" }],
|
||||
});
|
||||
const durationError = result.find((e) => e.path.includes("expect.maxDurationMs"));
|
||||
expect(durationError).toBeDefined();
|
||||
expect(durationError!.code).toBe("invalid-type");
|
||||
});
|
||||
|
||||
test("expect.rowCount 非法 operator 返回错误", () => {
|
||||
const result = validateDbConfig({
|
||||
defaults: {},
|
||||
targets: [{ db: { url: "sqlite://:memory:" }, expect: { rowCount: { foo: 1 } }, name: "test", type: "db" }],
|
||||
});
|
||||
const rowCountError = result.find((e) => e.path.includes("expect.rowCount"));
|
||||
expect(rowCountError).toBeDefined();
|
||||
expect(rowCountError!.code).toBe("unknown-operator");
|
||||
});
|
||||
|
||||
test("expect.rows 不是数组返回错误", () => {
|
||||
const result = validateDbConfig({
|
||||
defaults: {},
|
||||
targets: [{ db: { url: "sqlite://:memory:" }, expect: { rows: "not-array" }, name: "test", type: "db" }],
|
||||
});
|
||||
const rowsError = result.find((e) => e.path.includes("expect.rows"));
|
||||
expect(rowsError).toBeDefined();
|
||||
expect(rowsError!.code).toBe("invalid-type");
|
||||
});
|
||||
|
||||
test("expect.rows 元素不是对象返回错误", () => {
|
||||
const result = validateDbConfig({
|
||||
defaults: {},
|
||||
targets: [{ db: { url: "sqlite://:memory:" }, expect: { rows: ["not-object"] }, name: "test", type: "db" }],
|
||||
});
|
||||
const rowError = result.find((e) => e.path.includes("expect.rows[0]"));
|
||||
expect(rowError).toBeDefined();
|
||||
expect(rowError!.code).toBe("invalid-type");
|
||||
});
|
||||
|
||||
test("expect.rows 中 match 正则非法返回错误", () => {
|
||||
const result = validateDbConfig({
|
||||
defaults: {},
|
||||
targets: [
|
||||
{
|
||||
db: { url: "sqlite://:memory:" },
|
||||
expect: { rows: [{ name: { match: "[invalid" } }] },
|
||||
name: "test",
|
||||
type: "db",
|
||||
},
|
||||
],
|
||||
});
|
||||
const matchError = result.find((e) => e.path.includes("expect.rows[0].name"));
|
||||
expect(matchError).toBeDefined();
|
||||
expect(matchError!.code).toBe("invalid-regex");
|
||||
});
|
||||
|
||||
test("expect 未知字段返回错误", () => {
|
||||
const result = validateDbConfig({
|
||||
defaults: {},
|
||||
targets: [{ db: { url: "sqlite://:memory:" }, expect: { status: [200] }, name: "test", type: "db" }],
|
||||
});
|
||||
const unknownError = result.find((e) => e.path.includes("expect.status"));
|
||||
expect(unknownError).toBeDefined();
|
||||
expect(unknownError!.code).toBe("unknown-field");
|
||||
});
|
||||
|
||||
test("有效配置无错误", () => {
|
||||
const result = validateDbConfig({
|
||||
defaults: {},
|
||||
targets: [
|
||||
{
|
||||
db: { query: "SELECT 1", url: "sqlite://:memory:" },
|
||||
expect: { maxDurationMs: 5000, rowCount: { gte: 1 }, rows: [{ cnt: { gte: 1 } }] },
|
||||
name: "test",
|
||||
type: "db",
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("忽略非 db 类型 target", () => {
|
||||
const result = validateDbConfig({
|
||||
defaults: {},
|
||||
targets: [{ name: "test", type: "http" }],
|
||||
});
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("多个 db target 分别校验", () => {
|
||||
const result = validateDbConfig({
|
||||
defaults: {},
|
||||
targets: [
|
||||
{ db: { url: "sqlite://:memory:" }, name: "db1", type: "db" },
|
||||
{ db: { url: "" }, name: "db2", type: "db" },
|
||||
],
|
||||
});
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
const db2Error = result.find((e) => e.targetName === "db2");
|
||||
expect(db2Error).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -66,8 +66,8 @@ describe("CheckerRegistry", () => {
|
||||
const second = createDefaultCheckerRegistry();
|
||||
first.register(createChecker("custom"));
|
||||
|
||||
expect(first.supportedTypes).toEqual(["http", "cmd", "custom"]);
|
||||
expect(second.supportedTypes).toEqual(["http", "cmd"]);
|
||||
expect(first.supportedTypes).toEqual(["http", "cmd", "db", "custom"]);
|
||||
expect(second.supportedTypes).toEqual(["http", "cmd", "db"]);
|
||||
expect(
|
||||
first.definitions.every(
|
||||
(checker) => checker.schemas.config && checker.schemas.defaults && checker.schemas.expect,
|
||||
|
||||
Reference in New Issue
Block a user