1
0

feat: 增强 HTTP checker 鲁棒性 — 严格配置校验、完整耗时、流式body、重定向与编码完善

启动期校验: 新增 validate.ts 对 HTTP config/expect/body rule/operator 全方位严格校验
执行语义: body 改为 Web Stream 流式超限中止,durationMs 覆盖完整执行
错误归属: status/header 失败不读 body,phase 分层 request/body,early duration skip body
重定向: 跟随前释放 body,POST/303 改 GET 清理 header,跨 origin 剥离敏感 header
编码: 支持 quoted charset,未知编码返回结构化解码错误
文档: README match→regex+durationMs,DEVELOPMENT 执行流程与错误归属
测试: +63 测试覆盖全部新增场景,325 pass 0 fail
规格: 同步 probe-config/probe-engine/expect-body-checkers 3 个 delta spec
This commit is contained in:
2026-05-13 08:00:05 +08:00
parent 2fd0f206be
commit bce0f8e7a8
14 changed files with 1543 additions and 104 deletions

View File

@@ -59,9 +59,6 @@ export function checkHttpExpect(
const statusResult = checkStatus(statusCode, expect.status ?? [200]);
if (!statusResult.matched) return statusResult;
const durationResult = checkDuration(durationMs, expect.maxDurationMs);
if (!durationResult.matched) return durationResult;
const headersResult = checkHeaders(headers, expect.headers);
if (!headersResult.matched) return headersResult;
@@ -76,6 +73,9 @@ export function checkHttpExpect(
if (!bodyResult.matched) return bodyResult;
}
const durationResult = checkDuration(durationMs, expect.maxDurationMs);
if (!durationResult.matched) return durationResult;
return { failure: null, matched: true };
}

View File

@@ -4,14 +4,17 @@ import type { CheckResult, HttpTargetConfig, ResolvedHttpTarget, ResolvedTarget,
import type { Checker, CheckerContext, ResolveContext } from "../types";
import { parseSize } from "../../size";
import { errorFailure } from "../shared/failure";
import { checkHttpExpect } from "./expect";
import { checkBodyExpect } from "../shared/body";
import { checkDuration } from "../shared/duration";
import { errorFailure, mismatchFailure } from "../shared/failure";
import { checkHeaders, checkStatus } from "./expect";
import { validateHttpConfig, validateHttpExpect } from "./validate";
const ALLOWED_METHODS = new Set(["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"]);
const CHARSET_RE = /charset=([^\s;]+)/i;
const STATUS_RANGE_RE = /^\dxx$/;
const CHARSET_RE = /charset="?([^";\s]+)"?/i;
const URL_RE = /^https?:\/\/.+/;
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
const SENSITIVE_HEADERS = new Set(["authorization", "cookie"]);
export class HttpChecker implements Checker {
readonly type = "http";
@@ -19,10 +22,10 @@ export class HttpChecker implements Checker {
async execute(target: ResolvedTarget, ctx: CheckerContext): Promise<CheckResult> {
const t = target as ResolvedHttpTarget;
const timestamp = new Date().toISOString();
const expect = t.expect;
const start = performance.now();
try {
const start = performance.now();
const response = await fetchWithRedirects(t.http.url, t.http.maxRedirects, {
body: t.http.method !== "GET" && t.http.method !== "HEAD" ? t.http.body : undefined,
headers: t.http.headers,
@@ -32,63 +35,73 @@ export class HttpChecker implements Checker {
...(t.http.ignoreSSL ? { tls: { rejectUnauthorized: false } } : {}),
});
const durationMs = Math.round(performance.now() - start);
const statusCode = response.status;
const responseHeaders = Object.fromEntries(response.headers);
const hasBodyRules = !!(t.expect?.body && t.expect.body.length > 0);
const preBodyExpect = t.expect
? { headers: t.expect.headers, maxDurationMs: t.expect.maxDurationMs, status: t.expect.status }
: undefined;
const preBodyResult = checkHttpExpect(statusCode, responseHeaders, null, durationMs, preBodyExpect);
if (!hasBodyRules || !preBodyResult.matched) {
return {
durationMs,
failure: preBodyResult.failure,
matched: preBodyResult.matched,
statusDetail: `HTTP ${statusCode}`,
targetName: t.name,
timestamp,
};
const statusResult = checkStatus(statusCode, expect?.status ?? [200]);
if (!statusResult.matched) {
return makeResult(t, timestamp, performance.now() - start, statusResult.failure, statusCode);
}
const bodyBuffer = await response.arrayBuffer();
if (bodyBuffer.byteLength > t.http.maxBodyBytes) {
return {
durationMs,
failure: errorFailure("body", "body", `响应体大小 ${bodyBuffer.byteLength} 超过限制 ${t.http.maxBodyBytes}`),
matched: false,
statusDetail: `HTTP ${statusCode}`,
targetName: t.name,
timestamp,
};
const headersResult = checkHeaders(responseHeaders, expect?.headers);
if (!headersResult.matched) {
return makeResult(t, timestamp, performance.now() - start, headersResult.failure, statusCode);
}
const 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);
const hasBodyRules = !!(expect?.body && expect.body.length > 0);
return {
durationMs,
failure: fullResult.failure,
matched: fullResult.matched,
statusDetail: `HTTP ${statusCode}`,
targetName: t.name,
timestamp,
};
if (hasBodyRules && expect?.maxDurationMs !== undefined) {
const elapsed = performance.now() - start;
if (elapsed > expect.maxDurationMs) {
const durationMs = Math.round(elapsed);
return makeResult(
t,
timestamp,
elapsed,
mismatchFailure(
"duration",
"duration",
`<=${expect.maxDurationMs}ms`,
durationMs,
`duration ${durationMs}ms > ${expect.maxDurationMs}ms`,
),
statusCode,
);
}
}
if (hasBodyRules) {
const bodyReadResult = await readBodyStream(response, t.http.maxBodyBytes);
if (!bodyReadResult.ok) {
return makeResult(t, timestamp, performance.now() - start, bodyReadResult.failure, statusCode);
}
const decodeResult = decodeBody(bodyReadResult.data, response.headers);
if (!decodeResult.ok) {
return makeResult(t, timestamp, performance.now() - start, decodeResult.failure, statusCode);
}
const bodyResult = checkBodyExpect(decodeResult.text, expect.body);
if (!bodyResult.matched) {
return makeResult(t, timestamp, performance.now() - start, bodyResult.failure, statusCode);
}
}
const durationMs = Math.round(performance.now() - start);
const durationResult = checkDuration(durationMs, expect?.maxDurationMs);
if (!durationResult.matched) {
return makeResult(t, timestamp, durationMs, durationResult.failure, statusCode);
}
return makeResult(t, timestamp, durationMs, null, statusCode);
} catch (error) {
const durationMs = Math.round(performance.now() - start);
const isTimeout = ctx.signal.aborted || (error instanceof DOMException && error.name === "AbortError");
return {
durationMs: null,
durationMs,
failure: errorFailure(
"status",
"request",
"request",
isTimeout ? `请求超时 (${t.timeoutMs}ms)` : isError(error) ? error.message : String(error),
),
@@ -108,6 +121,8 @@ export class HttpChecker implements Checker {
throw new Error(`target "${t.name}" 缺少 http.url 字段`);
}
validateHttpConfig(t.http, t.name);
if (typeof t.http.url !== "string" || t.http.url.trim() === "") {
throw new Error(`target "${t.name}" 缺少 http.url 字段`);
}
@@ -139,22 +154,7 @@ export class HttpChecker implements Checker {
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"`,
);
}
}
}
validateHttpExpect(target.expect, t.name);
const maxBodyBytes = parseSize(t.http.maxBodyBytes ?? httpDefaults?.maxBodyBytes ?? "100MB");
@@ -194,13 +194,62 @@ export class HttpChecker implements Checker {
}
}
function buildRedirectInit(init: RequestInit, statusCode: number): RequestInit {
function buildRedirectInit(init: RequestInit, statusCode: number, fromUrl: string, toUrl: string): RequestInit {
let newInit = { ...init };
const method = init.method?.toUpperCase();
if (statusCode === 303 || ((statusCode === 301 || statusCode === 302) && method === "POST")) {
return { ...init, body: undefined, method: "GET" };
const headers =
typeof init.headers === "object" && init.headers !== null
? { ...(init.headers as Record<string, string>) }
: undefined;
if (headers) {
for (const key of Object.keys(headers)) {
const lower = key.toLowerCase();
if (lower === "content-type" || lower === "content-length") {
delete headers[key];
}
}
}
newInit = { ...newInit, body: undefined, headers, method: "GET" };
}
return init;
try {
const fromOrigin = new URL(fromUrl).origin;
const toOrigin = new URL(toUrl).origin;
if (fromOrigin !== toOrigin && newInit.headers && typeof newInit.headers === "object") {
const headers = { ...(newInit.headers as Record<string, string>) };
for (const key of Object.keys(headers)) {
if (SENSITIVE_HEADERS.has(key.toLowerCase())) {
delete headers[key];
}
}
newInit.headers = headers;
}
} catch {
/* URL parsing failed, keep headers */
}
return newInit;
}
function decodeBody(
data: Uint8Array,
headers: Headers,
): { failure: CheckResult["failure"]; ok: false } | { ok: true; text: string } {
const contentType = headers.get("content-type") ?? "";
const charsetMatch = CHARSET_RE.exec(contentType);
const encoding = charsetMatch?.[1]?.toLowerCase() ?? "utf-8";
try {
const text = new TextDecoder(encoding).decode(data);
return { ok: true, text };
} catch {
return {
failure: errorFailure("body", "body", `不支持的字符编码: ${encoding}`),
ok: false,
};
}
}
async function fetchWithRedirects(url: string, maxRedirects: number, init: RequestInit): Promise<Response> {
@@ -214,7 +263,77 @@ async function fetchWithRedirects(url: string, maxRedirects: number, init: Reque
const location = response.headers.get("location");
if (!location || followed >= maxRedirects) return response;
currentUrl = new URL(location, currentUrl).toString();
currentInit = buildRedirectInit(currentInit, response.status);
try {
await response.arrayBuffer();
} catch {
/* ignore body drain error */
}
const nextUrl = new URL(location, currentUrl).toString();
currentInit = buildRedirectInit(currentInit, response.status, currentUrl, nextUrl);
currentUrl = nextUrl;
}
}
function makeResult(
t: ResolvedHttpTarget,
timestamp: string,
elapsed: number,
failure: CheckResult["failure"],
statusCode: number,
): CheckResult {
return {
durationMs: Math.round(elapsed),
failure,
matched: failure === null,
statusDetail: `HTTP ${statusCode}`,
targetName: t.name,
timestamp,
};
}
async function readBodyStream(
response: Response,
maxBodyBytes: number,
): Promise<{ data: Uint8Array; ok: true } | { failure: CheckResult["failure"]; ok: false }> {
const reader = response.body?.getReader();
if (!reader) {
return { data: new Uint8Array(0), ok: true };
}
const chunks: Uint8Array[] = [];
let totalBytes = 0;
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
totalBytes += value.byteLength;
if (totalBytes > maxBodyBytes) {
try {
await reader.cancel();
} catch {
/* ignore cancel error */
}
return {
failure: errorFailure("body", "body", `响应体大小超过限制 ${maxBodyBytes}`),
ok: false,
};
}
chunks.push(value);
}
} finally {
reader.releaseLock();
}
const result = new Uint8Array(totalBytes);
let offset = 0;
for (const chunk of chunks) {
result.set(chunk, offset);
offset += chunk.byteLength;
}
return { data: result, ok: true };
}

View File

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