From 146cef982e7363286ed8f9c830b755a87a087100 Mon Sep 17 00:00:00 2001 From: lanyuanxiaoyao Date: Sat, 16 May 2026 09:00:15 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=20DB=20checker=20?= =?UTF-8?q?=E2=80=94=20=E6=94=AF=E6=8C=81=20PostgreSQL/MySQL/SQLite=20?= =?UTF-8?q?=E8=BF=9E=E6=8E=A5=E6=B5=8B=E8=AF=95=E4=B8=8E=20SQL=20=E6=9F=A5?= =?UTF-8?q?=E8=AF=A2=E6=96=AD=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现 db 类型 checker,使用 Bun 内置 SQL 类 - 支持 db.url 连接字符串和可选 db.query 查询语句 - expect 支持 maxDurationMs、rowCount、rows 逐列校验 - 凭据屏蔽序列化输出 - SQLite 内存数据库测试覆盖 --- README.md | 20 +- openspec/specs/db-checker/spec.md | 141 ++++++++++++ openspec/specs/probe-config/spec.md | 8 +- probe-config.schema.json | 208 ++++++++++++++++++ probes.example.yaml | 36 +++ src/server/checker/runner/db/execute.ts | 206 +++++++++++++++++ src/server/checker/runner/db/expect.ts | 57 +++++ src/server/checker/runner/db/index.ts | 1 + src/server/checker/runner/db/schema.ts | 52 +++++ src/server/checker/runner/db/types.ts | 27 +++ src/server/checker/runner/db/validate.ts | 142 ++++++++++++ src/server/checker/runner/index.ts | 3 +- .../server/checker/runner/db/execute.test.ts | 158 +++++++++++++ tests/server/checker/runner/db/expect.test.ts | 134 +++++++++++ .../server/checker/runner/db/validate.test.ts | 154 +++++++++++++ tests/server/checker/runner/registry.test.ts | 4 +- 16 files changed, 1344 insertions(+), 7 deletions(-) create mode 100644 openspec/specs/db-checker/spec.md create mode 100644 src/server/checker/runner/db/execute.ts create mode 100644 src/server/checker/runner/db/expect.ts create mode 100644 src/server/checker/runner/db/index.ts create mode 100644 src/server/checker/runner/db/schema.ts create mode 100644 src/server/checker/runner/db/types.ts create mode 100644 src/server/checker/runner/db/validate.ts create mode 100644 tests/server/checker/runner/db/execute.test.ts create mode 100644 tests/server/checker/runner/db/expect.test.ts create mode 100644 tests/server/checker/runner/db/validate.test.ts diff --git a/README.md b/README.md index e354164..7f7219f 100644 --- a/README.md +++ b/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` diff --git a/openspec/specs/db-checker/spec.md b/openspec/specs/db-checker/spec.md new file mode 100644 index 0000000..2200db5 --- /dev/null +++ b/openspec/specs/db-checker/spec.md @@ -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 在启动期配置校验失败,而不是延迟到运行期抛错 diff --git a/openspec/specs/probe-config/spec.md b/openspec/specs/probe-config/spec.md index 8dfc060..324b0ac 100644 --- a/openspec/specs/probe-config/spec.md +++ b/openspec/specs/probe-config/spec.md @@ -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 配置文件路径。 diff --git a/probe-config.schema.json b/probe-config.schema.json index 833891a..1d4e583 100644 --- a/probe-config.schema.json +++ b/probe-config.schema.json @@ -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" + } + } + } + } } ] } diff --git a/probes.example.yaml b/probes.example.yaml index 7745791..74f4f72 100644 --- a/probes.example.yaml +++ b/probes.example.yaml @@ -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" diff --git a/src/server/checker/runner/db/execute.ts b/src/server/checker/runner/db/execute.ts new file mode 100644 index 0000000..8babd5a --- /dev/null +++ b/src/server/checker/runner/db/execute.ts @@ -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 { + readonly configKey = "db"; + + readonly schemas = dbCheckerSchemas; + + readonly type = "db"; + + async execute(t: ResolvedDbTarget, ctx: CheckerContext): Promise { + 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); + } +} diff --git a/src/server/checker/runner/db/expect.ts b/src/server/checker/runner/db/expect.ts new file mode 100644 index 0000000..9b94dbd --- /dev/null +++ b/src/server/checker/runner/db/expect.ts @@ -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>): 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 | 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 }; +} diff --git a/src/server/checker/runner/db/index.ts b/src/server/checker/runner/db/index.ts new file mode 100644 index 0000000..aadad73 --- /dev/null +++ b/src/server/checker/runner/db/index.ts @@ -0,0 +1 @@ +export { DbChecker } from "./execute"; diff --git a/src/server/checker/runner/db/schema.ts b/src/server/checker/runner/db/schema.ts new file mode 100644 index 0000000..e478fff --- /dev/null +++ b/src/server/checker/runner/db/schema.ts @@ -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([ + ...Object.keys(operatorProperties()), + "contains", + "empty", + "equals", + "exists", + "gt", + "gte", + "lt", + "lte", + "match", +]); diff --git a/src/server/checker/runner/db/types.ts b/src/server/checker/runner/db/types.ts new file mode 100644 index 0000000..cc2e596 --- /dev/null +++ b/src/server/checker/runner/db/types.ts @@ -0,0 +1,27 @@ +import type { ExpectOperator, ExpectValue, ResolvedTargetBase } from "../../types"; + +export interface DbExpectConfig { + maxDurationMs?: number; + rowCount?: ExpectOperator; + rows?: Array>; +} + +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"; +} diff --git a/src/server/checker/runner/db/validate.ts b/src/server/checker/runner/db/validate.ts new file mode 100644 index 0000000..3811ba2 --- /dev/null +++ b/src/server/checker/runner/db/validate.ts @@ -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 | 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 { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function validateDbExpect(target: Record, 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, 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; +} diff --git a/src/server/checker/runner/index.ts b/src/server/checker/runner/index.ts index 5ce0415..b983015 100644 --- a/src/server/checker/runner/index.ts +++ b/src/server/checker/runner/index.ts @@ -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(); diff --git a/tests/server/checker/runner/db/execute.test.ts b/tests/server/checker/runner/db/execute.test.ts new file mode 100644 index 0000000..868189d --- /dev/null +++ b/tests/server/checker/runner/db/execute.test.ts @@ -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, overrides?: Partial): 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"); + }); +}); diff --git a/tests/server/checker/runner/db/expect.test.ts b/tests/server/checker/runner/db/expect.test.ts new file mode 100644 index 0000000..48da2bc --- /dev/null +++ b/tests/server/checker/runner/db/expect.test.ts @@ -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); + }); +}); diff --git a/tests/server/checker/runner/db/validate.test.ts b/tests/server/checker/runner/db/validate.test.ts new file mode 100644 index 0000000..964f309 --- /dev/null +++ b/tests/server/checker/runner/db/validate.test.ts @@ -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(); + }); +}); diff --git a/tests/server/checker/runner/registry.test.ts b/tests/server/checker/runner/registry.test.ts index 14b5be0..d137f92 100644 --- a/tests/server/checker/runner/registry.test.ts +++ b/tests/server/checker/runner/registry.test.ts @@ -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,