From 2fd0f206bec7ab4e53a4ddd1439b4a334d090384 Mon Sep 17 00:00:00 2001 From: lanyuanxiaoyao Date: Wed, 13 May 2026 00:02:04 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20HTTP=20=E6=8E=A2=E9=92=88=E5=A2=9E?= =?UTF-8?q?=E5=BC=BA=20=E2=80=94=20ignoreSSL=E3=80=81=E7=B2=BE=E7=A1=AE?= =?UTF-8?q?=E9=87=8D=E5=AE=9A=E5=90=91=E6=8E=A7=E5=88=B6=E3=80=81=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E7=A0=81=E8=8C=83=E5=9B=B4=E5=8C=B9=E9=85=8D=E3=80=81?= =?UTF-8?q?=E7=BC=96=E7=A0=81=E8=87=AA=E5=8A=A8=E6=A3=80=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 6 +- .../specs/checker-runner-abstraction/spec.md | 8 + openspec/specs/expect-body-checkers/spec.md | 27 ++ openspec/specs/probe-config/spec.md | 46 +++- openspec/specs/probe-data-store/spec.md | 2 +- openspec/specs/probe-engine/spec.md | 40 ++- probes.example.yaml | 23 ++ src/server/checker/runner/http/expect.ts | 10 +- src/server/checker/runner/http/runner.ts | 96 ++++++- src/server/checker/types.ts | 6 +- tests/server/app.test.ts | 2 + tests/server/checker/config-loader.test.ts | 71 ++++- tests/server/checker/engine.test.ts | 2 + .../server/checker/runner/http/expect.test.ts | 43 ++- .../server/checker/runner/http/runner.test.ts | 249 +++++++++++++++++- tests/server/checker/store.test.ts | 33 ++- 16 files changed, 642 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index c93c918..7856ceb 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,7 @@ targets: - `interval`: 拨测间隔,默认 `30s` - `timeout`: 超时时间,默认 `10s` - `http`: HTTP 类型默认值 - - `method`: HTTP 方法,默认 `GET` + - `method`: HTTP 方法,默认 `GET`,支持 `GET`、`HEAD`、`POST`、`PUT`、`PATCH`、`DELETE`、`OPTIONS` - `maxBodyBytes`: 响应体最大字节数,默认 `100MB` - `command`: Command 类型默认值 - `maxOutputBytes`: 输出最大字节数,默认 `100MB` @@ -111,6 +111,8 @@ targets: - `http`: HTTP 拨测配置(type 为 http 时必填) - `url`: 目标 URL - `method`、`headers`、`body`: 请求参数 + - `ignoreSSL`: 是否忽略 HTTPS 证书校验,默认 `false`,用于自签名或私有证书服务 + - `maxRedirects`: 最大重定向跟随次数,默认 `0`(不跟随重定向) - `command`: 命令行拨测配置(type 为 command 时必填) - `exec`: 可执行文件名或路径 - `args`: 命令行参数列表 @@ -118,7 +120,7 @@ targets: - `cwd`: 工作目录(可选,相对于配置文件所在目录解析,默认 `.`) - `interval`、`timeout`: 覆盖全局默认值 - `expect`: 期望校验 - - `status`: 可接受的状态码列表(HTTP) + - `status`: 可接受的状态码列表(HTTP),支持精确状态码和范围模式(如 `"2xx"`)混合配置 - `exitCode`: 可接受的退出码列表(Command) - `headers`: 响应头校验(HTTP,支持 `equals`、`contains` 等操作符) - `maxDurationMs`: 最大耗时阈值(毫秒) diff --git a/openspec/specs/checker-runner-abstraction/spec.md b/openspec/specs/checker-runner-abstraction/spec.md index eb846e5..ca37793 100644 --- a/openspec/specs/checker-runner-abstraction/spec.md +++ b/openspec/specs/checker-runner-abstraction/spec.md @@ -60,6 +60,14 @@ - **WHEN** YAML 配置中 HTTP target 缺少 `http.url` - **THEN** HttpChecker 的 `resolve()` SHALL 抛出校验错误,提示缺少必填字段 +#### Scenario: HTTP method 非法校验 +- **WHEN** YAML 配置中 HTTP target 的 `http.method` 不在合法方法列表中 +- **THEN** HttpChecker 的 `resolve()` SHALL 抛出校验错误,提示 method 不合法 + +#### Scenario: URL 格式校验 +- **WHEN** YAML 配置中 HTTP target 的 `http.url` 不以 `http://` 或 `https://` 开头 +- **THEN** HttpChecker 的 `resolve()` SHALL 抛出校验错误,提示 URL 格式不合法 + ### Requirement: 存储序列化通过 registry 获取展示格式 系统 SHALL 在 `ProbeStore.syncTargets()` 中通过 `checkerRegistry.get(t.type).serialize(t)` 获取每个 target 的展示摘要(`target` 列)和配置 JSON(`config` 列),替代 `buildTargetDisplay()` / `buildTargetConfig()` 中的类型分支。 diff --git a/openspec/specs/expect-body-checkers/spec.md b/openspec/specs/expect-body-checkers/spec.md index d24f635..1169965 100644 --- a/openspec/specs/expect-body-checkers/spec.md +++ b/openspec/specs/expect-body-checkers/spec.md @@ -126,3 +126,30 @@ #### Scenario: actual 值截断 - **WHEN** 失败规则的实际值超过系统允许记录的摘要长度 - **THEN** 系统 MUST 截断 actual 摘要,而不是持久化完整响应体或命令输出 + +### Requirement: 状态码范围匹配 +系统 SHALL 支持在 `expect.status` 数组中使用范围模式字符串(如 `"2xx"`、`"3xx"`、`"4xx"`、`"5xx"`),与精确数字混合使用。范围模式 SHALL 匹配对应百位段内的所有状态码。 + +#### Scenario: 2xx 范围匹配 +- **WHEN** HTTP target 配置 `expect.status: ["2xx"]`,且响应状态码为 200 +- **THEN** 系统 SHALL 判定状态码匹配 + +#### Scenario: 2xx 范围匹配 204 +- **WHEN** HTTP target 配置 `expect.status: ["2xx"]`,且响应状态码为 204 +- **THEN** 系统 SHALL 判定状态码匹配 + +#### Scenario: 2xx 范围不匹配 301 +- **WHEN** HTTP target 配置 `expect.status: ["2xx"]`,且响应状态码为 301 +- **THEN** 系统 SHALL 判定状态码不匹配 + +#### Scenario: 混合精确值与范围模式 +- **WHEN** HTTP target 配置 `expect.status: ["2xx", 301]`,且响应状态码为 301 +- **THEN** 系统 SHALL 判定状态码匹配(精确值 301 匹配) + +#### Scenario: 混合精确值与范围模式范围命中 +- **WHEN** HTTP target 配置 `expect.status: ["2xx", 301]`,且响应状态码为 204 +- **THEN** 系统 SHALL 判定状态码匹配(2xx 范围命中) + +#### Scenario: 5xx 范围匹配 +- **WHEN** HTTP target 配置 `expect.status: ["5xx"]`,且响应状态码为 503 +- **THEN** 系统 SHALL 判定状态码匹配 diff --git a/openspec/specs/probe-config/spec.md b/openspec/specs/probe-config/spec.md index 5095acd..c856a96 100644 --- a/openspec/specs/probe-config/spec.md +++ b/openspec/specs/probe-config/spec.md @@ -5,7 +5,7 @@ ## Requirements ### Requirement: YAML 配置文件格式 -系统 SHALL 支持通过 YAML 配置文件定义全部运行参数,包括 server 配置、runtime 配置、checker 默认值和 typed target 列表(含可选 group 字段)。target MUST 使用 `type` 字段声明 checker 类型,HTTP 领域字段 MUST 放在 `http` 分组,command 领域字段 MUST 放在 `command` 分组。 +系统 SHALL 支持通过 YAML 配置文件定义全部运行参数,包括 server 配置、runtime 配置、checker 默认值和 typed target 列表(含可选 group 字段)。target MUST 使用 `type` 字段声明 checker 类型,HTTP 领域字段 MUST 放在 `http` 分组,command 领域字段 MUST 放在 `command` 分组。HTTP target 的 `http` 分组 SHALL 支持可选的 `ignoreSSL`(布尔值)和 `maxRedirects`(非负整数)字段。 #### Scenario: 完整配置文件解析 - **WHEN** 系统启动并读取包含 server、runtime、defaults、targets(含 group 字段)的 YAML 配置文件 @@ -13,7 +13,7 @@ #### Scenario: 最简 HTTP 配置文件解析 - **WHEN** 系统读取只包含一个 `type: http` target 和 `http.url` 的 YAML 配置文件(省略 server、runtime、defaults 和 expect) -- **THEN** 系统 SHALL 使用内置默认值填充未指定的字段(host=127.0.0.1, port=3000, dir=./data, interval=30s, timeout=10s, runtime.maxConcurrentChecks=20, http.method=GET, http.maxBodyBytes=100MB, group="default") +- **THEN** 系统 SHALL 使用内置默认值填充未指定的字段(host=127.0.0.1, port=3000, dir=./data, interval=30s, timeout=10s, runtime.maxConcurrentChecks=20, http.method=GET, http.maxBodyBytes=100MB, http.ignoreSSL=false, http.maxRedirects=0, group="default") #### Scenario: 最简 command 配置文件解析 - **WHEN** 系统读取只包含一个 `type: command` target 和 `command.exec` 的 YAML 配置文件 @@ -23,6 +23,14 @@ - **WHEN** 某个 target 指定 interval、timeout 或对应领域分组中的默认字段 - **THEN** 该 target SHALL 使用其自身的值,不受 defaults 中对应字段影响 +#### Scenario: HTTP target 配置 ignoreSSL +- **WHEN** YAML 配置中 HTTP target 设置 `http.ignoreSSL: true` +- **THEN** 系统 SHALL 解析该字段并在执行时跳过 SSL 证书校验 + +#### Scenario: HTTP target 配置 maxRedirects +- **WHEN** YAML 配置中 HTTP target 设置 `http.maxRedirects: 5` +- **THEN** 系统 SHALL 解析该字段并在执行时允许最多跟随 5 次重定向 + ### Requirement: CLI 参数 系统 SHALL 通过单一命令行参数接受 YAML 配置文件路径。 @@ -77,6 +85,30 @@ - **WHEN** maxBodyBytes 或 maxOutputBytes 值不是有效的 size 格式 - **THEN** 系统 SHALL 以错误退出并提示支持 B、KB、MB、GB 格式 +#### Scenario: HTTP method 非法 +- **WHEN** YAML 中某个 HTTP target 的 `http.method` 不是 GET、HEAD、POST、PUT、PATCH、DELETE、OPTIONS 之一 +- **THEN** 系统 SHALL 以错误退出,提示该 target 的 method 不合法 + +#### Scenario: URL 格式非法 +- **WHEN** YAML 中某个 HTTP target 的 `http.url` 不以 `http://` 或 `https://` 开头 +- **THEN** 系统 SHALL 以错误退出,提示该 target 的 URL 格式不合法 + +#### Scenario: maxRedirects 非法 +- **WHEN** YAML 中某个 HTTP target 的 `http.maxRedirects` 为负数 +- **THEN** 系统 SHALL 以错误退出,提示该 target 的 maxRedirects 必须为非负整数 + +#### Scenario: maxRedirects 非整数非法 +- **WHEN** YAML 中某个 HTTP target 的 `http.maxRedirects` 不是非负整数(如 `1.5` 或 `"5"`) +- **THEN** 系统 SHALL 以错误退出,提示该 target 的 maxRedirects 必须为非负整数 + +#### Scenario: ignoreSSL 类型非法 +- **WHEN** YAML 中某个 HTTP target 的 `http.ignoreSSL` 不是布尔值 +- **THEN** 系统 SHALL 以错误退出,提示该 target 的 ignoreSSL 必须为布尔值 + +#### Scenario: status 模式非法 +- **WHEN** YAML 中某个 HTTP target 的 `expect.status` 包含不符合 `数字xx` 格式的字符串(如 `"abc"`、`"2x"`) +- **THEN** 系统 SHALL 以错误退出,提示该 target 的 status 模式不合法 + ### Requirement: size 配置解析 系统 SHALL 支持使用单位字符串配置读取上限,单位包括 `B`、`KB`、`MB` 和 `GB`。 @@ -107,7 +139,7 @@ - **THEN** 系统 SHALL 调用 `Bun.YAML.parse()` 将内容解析为配置对象 ### Requirement: expect 配置增强 -系统 SHALL 支持 typed target 的领域专用 expect 配置,包括 HTTP 的 `status`、`headers`、`body` 和 command 的 `exitCode`、`stdout`、`stderr`。内容类 expect MUST 使用数组表达配置顺序。 +系统 SHALL 支持 typed target 的领域专用 expect 配置,包括 HTTP 的 `status`(支持精确数字和范围模式)、`headers`、`body` 和 command 的 `exitCode`、`stdout`、`stderr`。内容类 expect MUST 使用数组表达配置顺序。 #### Scenario: 解析 HTTP expect 配置 - **WHEN** YAML 配置文件中 HTTP target 的 expect 包含 status、headers、body 规则数组及内部方法 @@ -125,6 +157,14 @@ - **WHEN** HTTP target 未配置 `expect.status` - **THEN** 系统 SHALL 在执行 expect 时使用默认 `status: [200]` 语义 +#### Scenario: 配置 HTTP status 范围模式 +- **WHEN** HTTP target 配置 `expect.status: ["2xx"]` +- **THEN** 系统 SHALL 在执行 expect 时匹配所有 200-299 状态码 + +#### Scenario: 配置 HTTP status 混合模式 +- **WHEN** HTTP target 配置 `expect.status: ["2xx", 301]` +- **THEN** 系统 SHALL 在执行 expect 时匹配所有 200-299 状态码或精确匹配 301 + #### Scenario: 不配置 command exitCode - **WHEN** command target 未配置 `expect.exitCode` - **THEN** 系统 SHALL 在执行 expect 时使用默认 `exitCode: [0]` 语义 diff --git a/openspec/specs/probe-data-store/spec.md b/openspec/specs/probe-data-store/spec.md index e4af086..535717f 100644 --- a/openspec/specs/probe-data-store/spec.md +++ b/openspec/specs/probe-data-store/spec.md @@ -121,7 +121,7 @@ #### Scenario: HTTP target config 序列化 - **WHEN** 同步 HTTP target -- **THEN** targets.config SHALL 存储 JSON,包含 url、method、headers、body、maxBodyBytes +- **THEN** targets.config SHALL 存储 JSON,包含 url、method、headers、body、maxBodyBytes、ignoreSSL、maxRedirects #### Scenario: command target config 序列化 - **WHEN** 同步 command target diff --git a/openspec/specs/probe-engine/spec.md b/openspec/specs/probe-engine/spec.md index a191c10..8bf9111 100644 --- a/openspec/specs/probe-engine/spec.md +++ b/openspec/specs/probe-engine/spec.md @@ -31,7 +31,7 @@ - **THEN** 系统 MUST 同时最多运行 3 个检查,其余检查等待并发槽位释放 ### Requirement: HTTP 拨测执行 -系统 SHALL 对 `type: http` 的目标执行 HTTP 请求,支持 GET、POST、PUT、DELETE、PATCH、HEAD 方法,并携带 `http.headers` 和 `http.body`。 +系统 SHALL 对 `type: http` 的目标执行 HTTP 请求,支持 GET、POST、PUT、DELETE、PATCH、HEAD、OPTIONS 方法,并携带 `http.headers` 和 `http.body`。系统 SHALL 支持 `http.ignoreSSL` 配置跳过 SSL 证书校验,支持 `http.maxRedirects` 配置控制重定向行为。 #### Scenario: 执行 GET 请求 - **WHEN** 目标配置 method 为 GET @@ -49,6 +49,34 @@ - **WHEN** HTTP response body 超过该 target 的 maxBodyBytes - **THEN** 系统 MUST 停止读取并记录 `matched=false` 和结构化输出超限错误 +#### Scenario: 忽略 SSL 证书校验 +- **WHEN** 目标配置 `http.ignoreSSL: true` 且目标 URL 为 HTTPS +- **THEN** 系统 SHALL 跳过 SSL 证书校验,即使证书无效也正常完成请求 + +#### Scenario: 不忽略 SSL 证书校验 +- **WHEN** 目标未配置 `http.ignoreSSL` 或配置为 `false`,且目标 URL 使用自签名证书 +- **THEN** 系统 SHALL 因 SSL 证书校验失败而记录请求错误 + +#### Scenario: 默认不跟随重定向 +- **WHEN** 目标未配置 `http.maxRedirects` 或配置为 0,且服务端返回 301/302 +- **THEN** 系统 SHALL 不跟随重定向,直接返回 301/302 的响应状态码和响应头 + +#### Scenario: 配置跟随重定向 +- **WHEN** 目标配置 `http.maxRedirects: 5` 且服务端返回重定向 +- **THEN** 系统 SHALL 跟随重定向,最多跟随 5 次 + +#### Scenario: 超过最大重定向次数 +- **WHEN** 目标配置 `http.maxRedirects: 1` 且服务端连续返回两次重定向 +- **THEN** 系统 SHALL 只跟随第一次重定向,并返回第二次重定向响应的状态码和响应头 + +#### Scenario: 响应体编码自动检测 +- **WHEN** HTTP 响应的 `Content-Type` header 包含 `charset=gbk` +- **THEN** 系统 SHALL 使用 GBK 编码解码响应体,而非硬编码 UTF-8 + +#### Scenario: 响应体编码回退 UTF-8 +- **WHEN** HTTP 响应的 `Content-Type` header 未指定 charset +- **THEN** 系统 SHALL 使用 UTF-8 编码解码响应体 + ### Requirement: 请求超时控制 系统 SHALL 对每次 checker 执行实施超时控制,超时时间使用目标配置的 timeout 值。 @@ -71,10 +99,18 @@ - **WHEN** HTTP target 未配置 `expect.status` - **THEN** 系统 SHALL 按默认 `status: [200]` 校验响应状态码 -#### Scenario: 校验 HTTP 状态码 +#### Scenario: 校验 HTTP 状态码精确值 - **WHEN** HTTP target 配置了 `expect.status: [200, 201]` - **THEN** 系统 SHALL 检查响应状态码是否在列表中,将匹配结果记录到 matched 字段 +#### Scenario: 校验 HTTP 状态码范围模式 +- **WHEN** HTTP target 配置了 `expect.status: ["2xx"]` +- **THEN** 系统 SHALL 检查响应状态码是否在 200-299 范围内 + +#### Scenario: 校验 HTTP 状态码混合模式 +- **WHEN** HTTP target 配置了 `expect.status: ["2xx", 301]`,且响应状态码为 204 +- **THEN** 系统 SHALL 判定状态码匹配(204 属于 2xx 范围) + #### Scenario: 校验 HTTP 响应头 - **WHEN** HTTP target 配置了 `expect.headers: {"Content-Type": {contains: "application/json"}}` - **THEN** 系统 SHALL 检查响应头是否符合指定规则,全部匹配时继续后续阶段 diff --git a/probes.example.yaml b/probes.example.yaml index e0d83c7..efb96f7 100644 --- a/probes.example.yaml +++ b/probes.example.yaml @@ -126,6 +126,29 @@ targets: expect: status: [200, 201, 204] + - name: "状态码范围匹配" + type: http + http: + url: "https://httpbin.org/status/204" + expect: + status: ["2xx"] + + - name: "自签名证书跳过 SSL" + type: http + http: + url: "https://internal.local/health" + ignoreSSL: true + expect: + status: ["2xx"] + + - name: "跟随重定向" + type: http + http: + url: "https://httpbin.org/redirect/1" + maxRedirects: 5 + expect: + status: [200] + # ========== Command targets ========== - name: "uname 输出匹配" diff --git a/src/server/checker/runner/http/expect.ts b/src/server/checker/runner/http/expect.ts index a98a634..b2d44de 100644 --- a/src/server/checker/runner/http/expect.ts +++ b/src/server/checker/runner/http/expect.ts @@ -79,8 +79,14 @@ export function checkHttpExpect( return { failure: null, matched: true }; } -export function checkStatus(statusCode: number, allowed: number[]): ExpectResult { - if (!allowed.includes(statusCode)) { +export function checkStatus(statusCode: number, allowed: Array): ExpectResult { + const matched = allowed.some((pattern) => { + if (typeof pattern === "number") return statusCode === pattern; + const base = parseInt(pattern[0]!, 10) * 100; + return statusCode >= base && statusCode < base + 100; + }); + + if (!matched) { return { failure: mismatchFailure( "status", diff --git a/src/server/checker/runner/http/runner.ts b/src/server/checker/runner/http/runner.ts index ea63954..1e745e0 100644 --- a/src/server/checker/runner/http/runner.ts +++ b/src/server/checker/runner/http/runner.ts @@ -7,6 +7,12 @@ import { parseSize } from "../../size"; import { errorFailure } from "../shared/failure"; import { checkHttpExpect } from "./expect"; +const ALLOWED_METHODS = new Set(["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"]); +const CHARSET_RE = /charset=([^\s;]+)/i; +const STATUS_RANGE_RE = /^\dxx$/; +const URL_RE = /^https?:\/\/.+/; +const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]); + export class HttpChecker implements Checker { readonly type = "http"; @@ -17,11 +23,13 @@ export class HttpChecker implements Checker { try { const start = performance.now(); - const response = await fetch(t.http.url, { + const response = await fetchWithRedirects(t.http.url, t.http.maxRedirects, { body: t.http.method !== "GET" && t.http.method !== "HEAD" ? t.http.body : undefined, headers: t.http.headers, method: t.http.method, + redirect: "manual", signal: ctx.signal, + ...(t.http.ignoreSSL ? { tls: { rejectUnauthorized: false } } : {}), }); const durationMs = Math.round(performance.now() - start); @@ -60,7 +68,10 @@ export class HttpChecker implements Checker { }; } - const body = new TextDecoder().decode(bodyBuffer); + const contentType = response.headers.get("content-type") ?? ""; + const charsetMatch = CHARSET_RE.exec(contentType); + const encoding = charsetMatch?.[1] ?? "utf-8"; + const body = new TextDecoder(encoding).decode(bodyBuffer); const fullResult = checkHttpExpect(statusCode, responseHeaders, body, durationMs, t.expect); return { @@ -93,10 +104,58 @@ export class HttpChecker implements Checker { const t = target as TargetConfig & { http: HttpTargetConfig; type: "http" }; const httpDefaults = context.defaults.http; - if (!t.http.url || t.http.url.trim() === "") { + if (!t.http || typeof t.http !== "object") { throw new Error(`target "${t.name}" 缺少 http.url 字段`); } + if (typeof t.http.url !== "string" || t.http.url.trim() === "") { + throw new Error(`target "${t.name}" 缺少 http.url 字段`); + } + + const rawMethod = t.http.method ?? httpDefaults?.method ?? "GET"; + if (typeof rawMethod !== "string") { + throw new Error(`target "${t.name}" 的 http.method 必须为字符串`); + } + + const method = rawMethod.toUpperCase(); + if (!ALLOWED_METHODS.has(method)) { + throw new Error( + `target "${t.name}" 的 http.method "${method}" 不合法,合法值: ${[...ALLOWED_METHODS].join(", ")}`, + ); + } + + if (!URL_RE.test(t.http.url)) { + throw new Error(`target "${t.name}" 的 http.url "${t.http.url}" 格式不合法,必须以 http:// 或 https:// 开头`); + } + + if (t.http.ignoreSSL !== undefined && typeof t.http.ignoreSSL !== "boolean") { + throw new Error(`target "${t.name}" 的 http.ignoreSSL 必须为布尔值`); + } + + if ( + t.http.maxRedirects !== undefined && + (typeof t.http.maxRedirects !== "number" || !Number.isInteger(t.http.maxRedirects) || t.http.maxRedirects < 0) + ) { + throw new Error(`target "${t.name}" 的 http.maxRedirects 必须为非负整数`); + } + + const statusPatterns = target.expect && "status" in target.expect ? target.expect.status : undefined; + if (statusPatterns) { + if (!Array.isArray(statusPatterns)) { + throw new Error(`target "${t.name}" 的 expect.status 必须为数组`); + } + for (const p of statusPatterns) { + if (typeof p !== "number" && typeof p !== "string") { + throw new Error(`target "${t.name}" 的 expect.status 只能包含数字或范围模式字符串`); + } + if (typeof p === "string" && !STATUS_RANGE_RE.test(p)) { + throw new Error( + `target "${t.name}" 的 expect.status 模式 "${p}" 不合法,字符串必须为 "Nxx" 格式(如 "2xx")`, + ); + } + } + } + const maxBodyBytes = parseSize(t.http.maxBodyBytes ?? httpDefaults?.maxBodyBytes ?? "100MB"); return { @@ -105,8 +164,10 @@ export class HttpChecker implements Checker { http: { body: t.http.body, headers: { ...(httpDefaults?.headers ?? {}), ...(t.http.headers ?? {}) }, + ignoreSSL: t.http.ignoreSSL ?? false, maxBodyBytes, - method: t.http.method ?? httpDefaults?.method ?? "GET", + maxRedirects: t.http.maxRedirects ?? 0, + method, url: t.http.url, }, intervalMs: context.defaultIntervalMs, @@ -122,7 +183,9 @@ export class HttpChecker implements Checker { config: JSON.stringify({ body: t.http.body, headers: t.http.headers, + ignoreSSL: t.http.ignoreSSL, maxBodyBytes: t.http.maxBodyBytes, + maxRedirects: t.http.maxRedirects, method: t.http.method, url: t.http.url, }), @@ -130,3 +193,28 @@ export class HttpChecker implements Checker { }; } } + +function buildRedirectInit(init: RequestInit, statusCode: number): RequestInit { + const method = init.method?.toUpperCase(); + if (statusCode === 303 || ((statusCode === 301 || statusCode === 302) && method === "POST")) { + return { ...init, body: undefined, method: "GET" }; + } + + return init; +} + +async function fetchWithRedirects(url: string, maxRedirects: number, init: RequestInit): Promise { + let currentUrl = url; + let currentInit = init; + + for (let followed = 0; ; followed++) { + const response = await fetch(currentUrl, currentInit); + if (!REDIRECT_STATUSES.has(response.status)) return response; + + const location = response.headers.get("location"); + if (!location || followed >= maxRedirects) return response; + + currentUrl = new URL(location, currentUrl).toString(); + currentInit = buildRedirectInit(currentInit, response.status); + } +} diff --git a/src/server/checker/types.ts b/src/server/checker/types.ts index cc043d5..c8fea5c 100644 --- a/src/server/checker/types.ts +++ b/src/server/checker/types.ts @@ -72,13 +72,15 @@ export interface HttpExpectConfig { body?: BodyRule[]; headers?: Record; maxDurationMs?: number; - status?: number[]; + status?: Array; } export interface HttpTargetConfig { body?: string; headers?: Record; + ignoreSSL?: boolean; maxBodyBytes?: string; + maxRedirects?: number; method?: string; url: string; } @@ -113,7 +115,9 @@ export interface ResolvedCommandTarget { export interface ResolvedHttpConfig { body?: string; headers: Record; + ignoreSSL: boolean; maxBodyBytes: number; + maxRedirects: number; method: string; url: string; } diff --git a/tests/server/app.test.ts b/tests/server/app.test.ts index e9f5288..09c089c 100644 --- a/tests/server/app.test.ts +++ b/tests/server/app.test.ts @@ -46,7 +46,9 @@ describe("API 路由", () => { group: "default", http: { headers: {}, + ignoreSSL: false, maxBodyBytes: 104857600, + maxRedirects: 0, method: "GET", url: "http://a.com", }, diff --git a/tests/server/checker/config-loader.test.ts b/tests/server/checker/config-loader.test.ts index 9a7977f..549da05 100644 --- a/tests/server/checker/config-loader.test.ts +++ b/tests/server/checker/config-loader.test.ts @@ -95,7 +95,9 @@ describe("loadConfig", () => { expect(t.http.url).toBe("http://example.com"); expect(t.http.method).toBe("GET"); expect(t.http.headers).toEqual({}); + expect(t.http.ignoreSSL).toBe(false); expect(t.http.maxBodyBytes).toBe(104857600); + expect(t.http.maxRedirects).toBe(0); expect(t.intervalMs).toBe(30000); expect(t.timeoutMs).toBe(10000); } @@ -157,8 +159,10 @@ targets: interval: "1m" http: url: "http://example.com" + ignoreSSL: true + maxRedirects: 5 expect: - status: [200] + status: ["2xx", 301] body: - contains: "ok" - name: "cmd-target" @@ -184,7 +188,10 @@ targets: expect(http.http.url).toBe("http://example.com"); expect(http.http.method).toBe("POST"); expect(http.http.headers).toEqual({ Authorization: "Bearer token" }); + expect(http.http.ignoreSSL).toBe(true); expect(http.http.maxBodyBytes).toBe(52428800); + expect(http.http.maxRedirects).toBe(5); + expect(http.expect?.status).toEqual(["2xx", 301]); expect(http.intervalMs).toBe(60000); expect(http.timeoutMs).toBe(5000); } @@ -277,6 +284,68 @@ targets: await expect(loadConfig(configPath)).rejects.toThrow("缺少 http.url 字段"); }); + test("HTTP target 缺少 http 分组抛出清晰错误", async () => { + const configPath = join(tempDir, "no-http-group.yaml"); + await writeFile( + configPath, + `targets: + - name: "test" + type: http +`, + ); + // eslint-disable-next-line @typescript-eslint/await-thenable + await expect(loadConfig(configPath)).rejects.toThrow("缺少 http.url 字段"); + }); + + test("HTTP target ignoreSSL 非布尔值抛出错误", async () => { + const configPath = join(tempDir, "bad-ignore-ssl.yaml"); + await writeFile( + configPath, + `targets: + - name: "test" + type: http + http: + url: "http://example.com" + ignoreSSL: "true" +`, + ); + // eslint-disable-next-line @typescript-eslint/await-thenable + await expect(loadConfig(configPath)).rejects.toThrow("ignoreSSL 必须为布尔值"); + }); + + test("HTTP target maxRedirects 非负整数校验", async () => { + const configPath = join(tempDir, "bad-max-redirects.yaml"); + await writeFile( + configPath, + `targets: + - name: "test" + type: http + http: + url: "http://example.com" + maxRedirects: 1.5 +`, + ); + // eslint-disable-next-line @typescript-eslint/await-thenable + await expect(loadConfig(configPath)).rejects.toThrow("maxRedirects 必须为非负整数"); + }); + + test("HTTP target status 模式非法抛出错误", async () => { + const configPath = join(tempDir, "bad-status-pattern.yaml"); + await writeFile( + configPath, + `targets: + - name: "test" + type: http + http: + url: "http://example.com" + expect: + status: ["abc"] +`, + ); + // eslint-disable-next-line @typescript-eslint/await-thenable + await expect(loadConfig(configPath)).rejects.toThrow("status 模式"); + }); + test("command target 缺少 exec 抛出错误", async () => { const configPath = join(tempDir, "no-exec.yaml"); await writeFile( diff --git a/tests/server/checker/engine.test.ts b/tests/server/checker/engine.test.ts index 9d57498..e2655a7 100644 --- a/tests/server/checker/engine.test.ts +++ b/tests/server/checker/engine.test.ts @@ -190,7 +190,9 @@ describe("ProbeEngine", () => { group: "default", http: { headers: {}, + ignoreSSL: false, maxBodyBytes: 1024 * 1024, + maxRedirects: 0, method: "GET", url: `http://localhost:${httpServer.port}/`, }, diff --git a/tests/server/checker/runner/http/expect.test.ts b/tests/server/checker/runner/http/expect.test.ts index 3ed139f..d7cc1ed 100644 --- a/tests/server/checker/runner/http/expect.test.ts +++ b/tests/server/checker/runner/http/expect.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; -import { checkHttpExpect } from "../../../../../src/server/checker/runner/http/expect"; +import { checkHttpExpect, checkStatus } from "../../../../../src/server/checker/runner/http/expect"; function obs( overrides: { body?: null | string; durationMs?: number; headers?: Record; statusCode?: number } = {}, @@ -145,3 +145,44 @@ describe("checkHttpExpect", () => { expect(r.failure!.phase).toBe("body"); }); }); + +describe("checkStatus 范围匹配", () => { + test("2xx 范围匹配 200", () => { + expect(checkStatus(200, ["2xx"]).matched).toBe(true); + }); + + test("2xx 范围匹配 204", () => { + expect(checkStatus(204, ["2xx"]).matched).toBe(true); + }); + + test("2xx 范围不匹配 301", () => { + expect(checkStatus(301, ["2xx"]).matched).toBe(false); + }); + + test("5xx 范围匹配 503", () => { + expect(checkStatus(503, ["5xx"]).matched).toBe(true); + }); + + test("混合精确值与范围模式 — 精确命中", () => { + expect(checkStatus(301, ["2xx", 301]).matched).toBe(true); + }); + + test("混合精确值与范围模式 — 范围命中", () => { + expect(checkStatus(204, ["2xx", 301]).matched).toBe(true); + }); + + test("混合模式都不匹配", () => { + expect(checkStatus(404, ["2xx", 301]).matched).toBe(false); + }); + + test("纯精确值仍正常工作", () => { + expect(checkStatus(200, [200, 201]).matched).toBe(true); + expect(checkStatus(404, [200, 201]).matched).toBe(false); + }); + + test("范围匹配失败返回 phase=status 的 failure", () => { + const r = checkStatus(404, ["2xx"]); + expect(r.matched).toBe(false); + expect(r.failure!.phase).toBe("status"); + }); +}); diff --git a/tests/server/checker/runner/http/runner.test.ts b/tests/server/checker/runner/http/runner.test.ts index dc8572e..ec747af 100644 --- a/tests/server/checker/runner/http/runner.test.ts +++ b/tests/server/checker/runner/http/runner.test.ts @@ -1,11 +1,58 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test"; -import type { CheckerContext } from "../../../../../src/server/checker/runner/types"; +import type { CheckerContext, ResolveContext } from "../../../../../src/server/checker/runner/types"; import type { ResolvedHttpTarget } from "../../../../../src/server/checker/types"; import { HttpChecker } from "../../../../../src/server/checker/runner/http/runner"; const checker = new HttpChecker(); +const SELF_SIGNED_CERT = `-----BEGIN CERTIFICATE----- +MIIDJTCCAg2gAwIBAgIUTwQU8FzvnvxNYR7mMO0DLcnq+wQwDQYJKoZIhvcNAQEL +BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI2MDUxMjE1NDAyOFoXDTM2MDUw +OTE1NDAyOFowFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF +AAOCAQ8AMIIBCgKCAQEArC0G46EXF8qPsCS2mtNwHzGFQvFQNcU8k7cZkCTwt4Cp +DlLOA2DbzR02LiVk/TA+d9qMUABAiXMndwebKv8EYxoKjwTY0jbVLKfEIIFxQS3F +uvKDgkYJz8P675p8fhR0Xa21+13b0/T8fperYC7fBZZsAqyo8+aF9QOUjy+kWRjr +lTTL1ez5L1nX0QCczTRaUDe51NTmcUYHJoiLqdKI2ZjXds7wnsaAfAgh7H9qr4wl +sUhCHV/Pg1LzBtfyLKZcImUJWWkj/KlgFgZ6aRyJHoGFmlZtXyaKhf3rEa+ZvKOy +MhcRmWC694PF+QjhrWS7oODLuY3XC5WKnLKxlBgfAwIDAQABo28wbTAdBgNVHQ4E +FgQUrHJEbBSDHOx/HAQ5nQp35v0Ljw8wHwYDVR0jBBgwFoAUrHJEbBSDHOx/HAQ5 +nQp35v0Ljw8wDwYDVR0TAQH/BAUwAwEB/zAaBgNVHREEEzARgglsb2NhbGhvc3SH +BH8AAAEwDQYJKoZIhvcNAQELBQADggEBAEJ0s/FJ6KZalSM0ntHxlMOB9taUa60I +A6zqrEMauU8BqZO3QLmX6a821geZntQtz77kGtW6rQWxELBNjN3rXTbUKfKXN/Au +ZLftNJLsQOjKF+1uFOF49D4/5Le9PGvwl79Qua/l6JO5HRJL9Dh545/zEr9W5Erb +l4JoKKfyCEYjrPg5tl7d2PrHUmzk+sGlxEqNeKIl272+3UMVCbkVHI/v6rtb4F7p +u77O0UYLNIRFZQOVqvE7A7rfYy93J8EEQcADKH/Nhx8clFxC5X187EakcVAfkeKX +SL7R1kmUiLPiHbVCqGyS2m3RH2XDM3MbA9WCCczbwXn5Lwp5HEz0wb0= +-----END CERTIFICATE-----`; +const SELF_SIGNED_KEY = `-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCsLQbjoRcXyo+w +JLaa03AfMYVC8VA1xTyTtxmQJPC3gKkOUs4DYNvNHTYuJWT9MD532oxQAECJcyd3 +B5sq/wRjGgqPBNjSNtUsp8QggXFBLcW68oOCRgnPw/rvmnx+FHRdrbX7XdvT9Px+ +l6tgLt8FlmwCrKjz5oX1A5SPL6RZGOuVNMvV7PkvWdfRAJzNNFpQN7nU1OZxRgcm +iIup0ojZmNd2zvCexoB8CCHsf2qvjCWxSEIdX8+DUvMG1/IsplwiZQlZaSP8qWAW +BnppHIkegYWaVm1fJoqF/esRr5m8o7IyFxGZYLr3g8X5COGtZLug4Mu5jdcLlYqc +srGUGB8DAgMBAAECggEAO91wDsedIu2QZlttjonD62SphCwpinio5md8oOznMbav +kUZjUTNlWX01sHfaFFqo7b10mgBscB4086MWZa3D1b1hPHcf+H+OQXeXrwGy4knK +/YSDC1HU6YOoBZV+gcwU5dmXc+4fmCQPguizcr75VpUFuyxTlnJp01ZKWjrjdwKs +IMU8a1CxHMT5clFf/3rU4U5o90cktsiRzjc83QFNpvRsF0rAn98Z70ocDWAATxUu +efLonMur5t2wlu2CLjZSXHvkkwwFQ2u7XUXuudDRAKeg77+RGuUGk8Z5269cHs22 +Ff4cej7vOnoU0CuDaXL37vzUXkfImB6pSFTblfiHgQKBgQDX+s0MqtTZeho6F6Iy +qHFWqkEItfrTErMEVjgBrMl42+EfzsAKa+910NPdV3z5Z/u7fAp3ComtxJ1pjiNj +bVah4/xobsHIIS1/XfPuxOaqkdOhhgYvCe8IIC6Z4yCPdRD5pW6dN18fK338YF4s +lVll+E/DJx7R08tFwSLGYNt5QQKBgQDMFFn1vT4GMHeeF2/kVNYE2U1Lntsy/swT +VLCgaOJuUvbJiKMa+J1jdjAsudAmOJgjTkR4sco0Rpsen+x7StaGBzMbXKHONUf3 +OLzQsP06JnA9oAftxsjg0IDH8JCAuQsQ2xKMN+f0d0+pggOzS/z7336a3bm1Zeee +wYqjtLOjQwKBgQCRoTzt06qd0aUpkpH9knKJu1cKppowBKXMwM4W4wkegzRzHBeF +b24RhPO2ha1xBlpI+sSbq/FVyANUD1FxU2Jc2rtxN21WonhpL1KxpvbaAGYwvYwh +35LbacfCX9GuqYL+sju5qoJrJApZSCl36mRTS3GM5y3y0dp4eFgYZ2rVgQKBgCLq +tH2cFFmgv0aYQfeyIDASMexnUJ/IAoioK9Q2Pc+ceEcBDs8VjHAxD4sHe7qeYkFg +KczwtmT9U5sIx8BMjKm/35ml3rVWXmrJFV0rexgQ7ZFNqS2gnkwAwJf06/RqNJ98 +rA67nf8wzrt02Ec8EBvUIGhE2XpU5i0+dgcOatHbAoGBAJIutK961t5lJVF1g1M9 +KC4rmCCMCTvJSbruQWDpoxYa7Cl6+TopU+xu4537FCzHUJ3EPg3KsYCeCM0LEtR4 +GQjRzFM3qqWabzoAV3KLaONWbK1rI9mHZf8KyWYiJ9cRXwTJ4rGYNMM/6QIUGQSx +agwJojCQqS4f6AfCNdUOzaRp +-----END PRIVATE KEY-----`; describe("HttpChecker", () => { let server: ReturnType; @@ -20,6 +67,10 @@ describe("HttpChecker", () => { return new Response(JSON.stringify({ body: req.body ? "present" : "empty", method: req.method }), { headers: { "content-type": "application/json" }, }); + case "/gbk": { + const gbkBytes = new Uint8Array([0xc4, 0xe3, 0xba, 0xc3]); + return new Response(gbkBytes, { headers: { "content-type": "text/plain; charset=gbk" } }); + } case "/json": return new Response(JSON.stringify({ status: "ok" }), { headers: { "content-type": "application/json" }, @@ -32,6 +83,12 @@ describe("HttpChecker", () => { return new Response("hello world", { headers: { "content-type": "text/plain", "x-custom": "test-value" }, }); + case "/redirect": + return new Response(null, { headers: { location: `${baseUrl}/ok` }, status: 301 }); + case "/redirect-chain-1": + return new Response(null, { headers: { location: `${baseUrl}/redirect-chain-2` }, status: 302 }); + case "/redirect-chain-2": + return new Response(null, { headers: { location: `${baseUrl}/ok` }, status: 302 }); default: return new Response("ok"); } @@ -49,7 +106,9 @@ describe("HttpChecker", () => { body?: string; expect?: Record; headers?: Record; + ignoreSSL?: boolean; maxBodyBytes?: number; + maxRedirects?: number; method?: string; timeoutMs?: number; url?: string; @@ -60,7 +119,9 @@ describe("HttpChecker", () => { http: { body: overrides.body, headers: overrides.headers ?? {}, + ignoreSSL: overrides.ignoreSSL ?? false, maxBodyBytes: overrides.maxBodyBytes ?? 1024 * 1024, + maxRedirects: overrides.maxRedirects ?? 0, method: overrides.method ?? "GET", url: overrides.url ?? `${baseUrl}/ok`, }, @@ -214,8 +275,192 @@ describe("HttpChecker", () => { const target = makeTarget({}); const s = checker.serialize(target); expect(s.target).toBe(target.http.url); - const config = JSON.parse(s.config) as { method: string; url: string }; + const config = JSON.parse(s.config) as { ignoreSSL: boolean; maxRedirects: number; method: string; url: string }; expect(config.url).toBe(target.http.url); expect(config.method).toBe("GET"); + expect(config.ignoreSSL).toBe(false); + expect(config.maxRedirects).toBe(0); + }); + + test("maxRedirects=0 不跟随重定向", async () => { + const result = await checker.execute(makeTarget({ maxRedirects: 0, url: `${baseUrl}/redirect` }), makeCtx()); + expect(result.matched).toBe(false); + expect(result.statusDetail).toBe("HTTP 301"); + }); + + test("maxRedirects>0 跟随重定向", async () => { + const result = await checker.execute(makeTarget({ maxRedirects: 5, url: `${baseUrl}/redirect` }), makeCtx()); + expect(result.matched).toBe(true); + expect(result.statusDetail).toBe("HTTP 200"); + }); + + test("maxRedirects 精确限制跟随次数", async () => { + const result = await checker.execute( + makeTarget({ maxRedirects: 1, url: `${baseUrl}/redirect-chain-1` }), + makeCtx(), + ); + expect(result.matched).toBe(false); + expect(result.statusDetail).toBe("HTTP 302"); + }); + + test("maxRedirects 允许足够次数时到达最终目标", async () => { + const result = await checker.execute( + makeTarget({ maxRedirects: 2, url: `${baseUrl}/redirect-chain-1` }), + makeCtx(), + ); + expect(result.matched).toBe(true); + expect(result.statusDetail).toBe("HTTP 200"); + }); + + test("ignoreSSL 跳过自签名证书校验", async () => { + const httpsServer = Bun.serve({ + fetch() { + return new Response("secure ok"); + }, + port: 0, + tls: { cert: SELF_SIGNED_CERT, key: SELF_SIGNED_KEY }, + }); + + try { + const strictResult = await checker.execute( + makeTarget({ ignoreSSL: false, url: `https://localhost:${httpsServer.port}/` }), + makeCtx(), + ); + expect(strictResult.matched).toBe(false); + expect(strictResult.statusDetail).toBeNull(); + + const ignoredResult = await checker.execute( + makeTarget({ ignoreSSL: true, url: `https://localhost:${httpsServer.port}/` }), + makeCtx(), + ); + expect(ignoredResult.matched).toBe(true); + expect(ignoredResult.statusDetail).toBe("HTTP 200"); + } finally { + void httpsServer.stop(); + } + }); + + test("响应体编码自动检测 GBK", async () => { + const result = await checker.execute( + makeTarget({ expect: { body: [{ contains: "你好" }] }, url: `${baseUrl}/gbk` }), + makeCtx(), + ); + expect(result.matched).toBe(true); + }); + + test("响应体编码回退 UTF-8", async () => { + const result = await checker.execute( + makeTarget({ expect: { body: [{ contains: "hello" }] }, url: `${baseUrl}/ok` }), + makeCtx(), + ); + expect(result.matched).toBe(true); + }); +}); + +describe("HttpChecker.resolve", () => { + function makeResolveContext(): ResolveContext { + return { + configDir: ".", + defaultIntervalMs: 30000, + defaults: {}, + defaultTimeoutMs: 10000, + }; + } + + test("method 非法抛出错误", () => { + expect(() => + checker.resolve( + { http: { method: "INVALID", url: "https://example.com" }, name: "test", type: "http" }, + makeResolveContext(), + ), + ).toThrow("不合法"); + }); + + test("URL 不以 http(s):// 开头抛出错误", () => { + expect(() => + checker.resolve({ http: { url: "ftp://example.com" }, name: "test", type: "http" }, makeResolveContext()), + ).toThrow("格式不合法"); + }); + + test("maxRedirects 为负数抛出错误", () => { + expect(() => + checker.resolve( + { http: { maxRedirects: -1, url: "https://example.com" }, name: "test", type: "http" }, + makeResolveContext(), + ), + ).toThrow("非负整数"); + }); + + test("maxRedirects 非整数抛出错误", () => { + const target = { + http: { maxRedirects: 1.5, url: "https://example.com" }, + name: "test", + type: "http", + } as unknown as Parameters[0]; + expect(() => checker.resolve(target, makeResolveContext())).toThrow("非负整数"); + }); + + test("ignoreSSL 非布尔值抛出错误", () => { + const target = { + http: { ignoreSSL: "true", url: "https://example.com" }, + name: "test", + type: "http", + } as unknown as Parameters[0]; + expect(() => checker.resolve(target, makeResolveContext())).toThrow("ignoreSSL 必须为布尔值"); + }); + + test("缺少 http 分组抛出清晰错误", () => { + const target = { name: "test", type: "http" } as unknown as Parameters[0]; + expect(() => checker.resolve(target, makeResolveContext())).toThrow("缺少 http.url 字段"); + }); + + test("expect.status 非法模式抛出错误", () => { + expect(() => + checker.resolve( + { expect: { status: ["abc"] }, http: { url: "https://example.com" }, name: "test", type: "http" }, + makeResolveContext(), + ), + ).toThrow("不合法"); + }); + + test("ignoreSSL 默认值为 false", () => { + const result = checker.resolve( + { http: { url: "https://example.com" }, name: "test", type: "http" }, + makeResolveContext(), + ); + expect((result as ResolvedHttpTarget).http.ignoreSSL).toBe(false); + }); + + test("maxRedirects 默认值为 0", () => { + const result = checker.resolve( + { http: { url: "https://example.com" }, name: "test", type: "http" }, + makeResolveContext(), + ); + expect((result as ResolvedHttpTarget).http.maxRedirects).toBe(0); + }); + + test("method 统一转大写", () => { + const result = checker.resolve( + { http: { method: "get", url: "https://example.com" }, name: "test", type: "http" }, + makeResolveContext(), + ); + expect((result as ResolvedHttpTarget).http.method).toBe("GET"); + }); + + test("合法 status 范围模式通过校验", () => { + const result = checker.resolve( + { expect: { status: ["2xx", 301] }, http: { url: "https://example.com" }, name: "test", type: "http" }, + makeResolveContext(), + ); + expect((result as ResolvedHttpTarget).expect?.status).toEqual(["2xx", 301]); + }); + + test("显式 ignoreSSL 和 maxRedirects 正确解析", () => { + const result = checker.resolve( + { http: { ignoreSSL: true, maxRedirects: 3, url: "https://example.com" }, name: "test", type: "http" }, + makeResolveContext(), + ); + expect((result as ResolvedHttpTarget).http.ignoreSSL).toBe(true); + expect((result as ResolvedHttpTarget).http.maxRedirects).toBe(3); }); }); diff --git a/tests/server/checker/store.test.ts b/tests/server/checker/store.test.ts index 0fa249f..67a2b26 100644 --- a/tests/server/checker/store.test.ts +++ b/tests/server/checker/store.test.ts @@ -27,7 +27,9 @@ const httpTarget: ResolvedTarget = { group: "default", http: { headers: { Accept: "application/json" }, + ignoreSSL: false, maxBodyBytes: 104857600, + maxRedirects: 0, method: "GET", url: "https://example.com/health", }, @@ -85,14 +87,18 @@ describe("ProbeStore", () => { expect(t.target).toBe("https://example.com/health"); const config = JSON.parse(t.config) as { headers: Record; + ignoreSSL: boolean; maxBodyBytes: number; + maxRedirects: number; method: string; url: string; }; expect(config.url).toBe("https://example.com/health"); expect(config.method).toBe("GET"); expect(config.headers).toEqual({ Accept: "application/json" }); + expect(config.ignoreSSL).toBe(false); expect(config.maxBodyBytes).toBe(104857600); + expect(config.maxRedirects).toBe(0); expect(t.interval_ms).toBe(30000); expect(t.timeout_ms).toBe(10000); expect(JSON.parse(t.expect!)).toEqual({ maxDurationMs: 3000, status: [200] }); @@ -283,7 +289,14 @@ describe("ProbeStore", () => { const cascadeStore = new ProbeStore(join(tempDir, "cascade.db")); const cascadeTarget: ResolvedTarget = { group: "default", - http: { headers: {}, maxBodyBytes: 104857600, method: "GET", url: "http://cascade.test" }, + http: { + headers: {}, + ignoreSSL: false, + maxBodyBytes: 104857600, + maxRedirects: 0, + method: "GET", + url: "http://cascade.test", + }, intervalMs: 30000, name: "cascade-test", timeoutMs: 10000, @@ -338,7 +351,14 @@ describe("ProbeStore", () => { freshStore.syncTargets([ { group: "default", - http: { headers: {}, maxBodyBytes: 104857600, method: "GET", url: "http://no.records" }, + http: { + headers: {}, + ignoreSSL: false, + maxBodyBytes: 104857600, + maxRedirects: 0, + method: "GET", + url: "http://no.records", + }, intervalMs: 30000, name: "no-records", timeoutMs: 10000, @@ -377,7 +397,14 @@ describe("ProbeStore", () => { freshStore.syncTargets([ { group: "default", - http: { headers: {}, maxBodyBytes: 104857600, method: "GET", url: "http://no.stats" }, + http: { + headers: {}, + ignoreSSL: false, + maxBodyBytes: 104857600, + maxRedirects: 0, + method: "GET", + url: "http://no.stats", + }, intervalMs: 30000, name: "no-stats", timeoutMs: 10000,