1
0

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:
2026-05-16 09:00:15 +08:00
parent c36df94e59
commit 146cef982e
16 changed files with 1344 additions and 7 deletions

View File

@@ -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`

View 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 在启动期配置校验失败,而不是延迟到运行期抛错

View File

@@ -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 配置文件路径。

View File

@@ -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"
}
}
}
}
}
]
}

View File

@@ -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"

View 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);
}
}

View 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 };
}

View File

@@ -0,0 +1 @@
export { DbChecker } from "./execute";

View 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",
]);

View 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";
}

View 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;
}

View File

@@ -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();

View 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");
});
});

View 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);
});
});

View 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();
});
});

View File

@@ -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,