1
0

refactor: HTTP checker 质量加固

- failure actual 截断格式改为 …(共 N 字符),标量不序列化直接返回
- 新增 redos.ts 实现 ReDoS 静态检测(嵌套量词/重叠交替),启动期拒绝危险正则
- JSON body rules 共享同一次 JSON.parse 结果,避免重复解析
- checkCssRule 重构为线性流程,消除 exist:true 与无 operator 的冗余分支
- extract checkEarlyTimeout 辅助函数,明确提前 duration 检查意图
- 补充 303/307/308 重定向、相对路径 Location、混合 body rules 集成测试
This commit is contained in:
2026-05-13 21:35:05 +08:00
parent 31aeee6d60
commit bcfac52112
18 changed files with 426 additions and 342 deletions

View File

@@ -8,11 +8,20 @@ import type { BodyRule, CssRule, JsonRule, XpathRule } from "./types";
import { errorFailure, mismatchFailure } from "../../expect/failure";
import { applyOperator, evaluateJsonPath } from "../../expect/operator";
type ParsedJsonResult = { error: string; ok: false } | { ok: true; value: unknown };
export function checkBodyExpect(body: string, rules?: BodyRule[]): ExpectResult {
if (!rules || rules.length === 0) return { failure: null, matched: true };
let parsedJson: ParsedJsonResult | undefined;
for (let i = 0; i < rules.length; i++) {
const result = checkSingleBodyRule(body, rules[i]!, i);
const rule = rules[i]!;
if ("json" in rule && parsedJson === undefined) {
parsedJson = parseJsonBody(body);
}
const result = checkSingleBodyRule(body, rule, i, parsedJson);
if (!result.matched) return result;
}
@@ -34,36 +43,7 @@ function checkCssRule(body: string, rule: CssRule, rulePath: string): ExpectResu
}
const el = $(selector);
const opKeys = Object.keys(operators);
if (opKeys.length === 0) {
if (attr !== undefined) {
if (el.attr(attr) === undefined) {
return {
failure: mismatchFailure("body", fullPath, "attr present", undefined, `attribute ${attr} not found`),
matched: false,
};
}
return { failure: null, matched: true };
}
if (el.length === 0) {
return {
failure: mismatchFailure("body", fullPath, "element found", "no match", `selector ${selector} not found`),
matched: false,
};
}
return { failure: null, matched: true };
}
if (operators.exists === true) {
if (el.length === 0) {
return {
failure: mismatchFailure("body", fullPath, true, false, `selector ${selector} not found`),
matched: false,
};
}
return { failure: null, matched: true };
}
if (operators.exists === false) {
if (el.length > 0) {
return {
@@ -75,13 +55,28 @@ function checkCssRule(body: string, rule: CssRule, rulePath: string): ExpectResu
}
if (el.length === 0) {
const expected = operators.exists === true ? true : "element found";
const actual = operators.exists === true ? false : "no match";
return {
failure: mismatchFailure("body", fullPath, "element found", "no match", `selector ${selector} not found`),
failure: mismatchFailure("body", fullPath, expected, actual, `selector ${selector} not found`),
matched: false,
};
}
if (operators.exists === true) return { failure: null, matched: true };
const actual = attr ? el.attr(attr) : el.text();
const opKeys = Object.keys(operators);
if (opKeys.length === 0) {
if (actual === undefined) {
return {
failure: mismatchFailure("body", fullPath, "attr present", undefined, `attribute ${attr} not found`),
matched: false,
};
}
return { failure: null, matched: true };
}
const matched = applyOperator(actual ?? "", operators);
if (!matched) {
return {
@@ -92,21 +87,19 @@ function checkCssRule(body: string, rule: CssRule, rulePath: string): ExpectResu
return { failure: null, matched: true };
}
function checkJsonRule(body: string, rule: JsonRule, rulePath: string): ExpectResult {
function checkJsonRule(body: string, rule: JsonRule, rulePath: string, parsedJson?: ParsedJsonResult): ExpectResult {
const { path, ...operators } = rule;
const fullPath = `${rulePath}.json(${path})`;
let json: unknown;
try {
json = JSON.parse(body);
} catch {
const jsonResult = parsedJson ?? parseJsonBody(body);
if (!jsonResult.ok) {
return {
failure: errorFailure("body", fullPath, "body is not valid JSON"),
failure: errorFailure("body", fullPath, jsonResult.error),
matched: false,
};
}
const actual = evaluateJsonPath(json, path);
const actual = evaluateJsonPath(jsonResult.value, path);
const opKeys = Object.keys(operators);
if (opKeys.length === 0) {
@@ -129,7 +122,7 @@ function checkJsonRule(body: string, rule: JsonRule, rulePath: string): ExpectRe
return { failure: null, matched: true };
}
function checkSingleBodyRule(body: string, rule: BodyRule, index: number): ExpectResult {
function checkSingleBodyRule(body: string, rule: BodyRule, index: number, parsedJson?: ParsedJsonResult): ExpectResult {
const rulePath = `body[${index}]`;
if ("contains" in rule) {
@@ -155,7 +148,7 @@ function checkSingleBodyRule(body: string, rule: BodyRule, index: number): Expec
}
if ("json" in rule) {
return checkJsonRule(body, rule.json, rulePath);
return checkJsonRule(body, rule.json, rulePath, parsedJson);
}
if ("css" in rule) {
@@ -208,3 +201,11 @@ function checkXpathRule(body: string, rule: XpathRule, rulePath: string): Expect
}
return { failure: null, matched: true };
}
function parseJsonBody(body: string): ParsedJsonResult {
try {
return { ok: true, value: JSON.parse(body) as unknown };
} catch {
return { error: "body is not valid JSON", ok: false };
}
}

View File

@@ -53,24 +53,9 @@ export class HttpChecker implements CheckerDefinition<ResolvedHttpTarget> {
const hasBodyRules = !!(expect?.body && expect.body.length > 0);
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,
);
}
const earlyTimeout = hasBodyRules ? checkEarlyTimeout(start, expect?.maxDurationMs) : null;
if (earlyTimeout) {
return makeResult(t, timestamp, earlyTimeout.elapsed, earlyTimeout.failure, statusCode);
}
if (hasBodyRules) {
@@ -203,6 +188,28 @@ function buildRedirectInit(init: RequestInit, statusCode: number, fromUrl: strin
return newInit;
}
function checkEarlyTimeout(
start: number,
maxDurationMs: number | undefined,
): null | { elapsed: number; failure: CheckResult["failure"] } {
if (maxDurationMs === undefined) return null;
const elapsed = performance.now() - start;
if (elapsed <= maxDurationMs) return null;
const durationMs = Math.round(elapsed);
return {
elapsed,
failure: mismatchFailure(
"duration",
"duration",
`<=${maxDurationMs}ms`,
durationMs,
`duration ${durationMs}ms > ${maxDurationMs}ms`,
),
};
}
function decodeBody(
data: Uint8Array,
headers: Headers,

View File

@@ -4,6 +4,7 @@ import * as xpath from "xpath";
import type { ConfigValidationIssue } from "../../schema/issues";
import type { CheckerValidationInput } from "../types";
import { isUnsafeRegex } from "../../expect/redos";
import { isPlainRecord, validateOperatorObject } from "../../expect/validate-operator";
import { BodyRuleTypeKeys, OperatorKeys } from "../../schema/fragments";
import { issue, joinPath } from "../../schema/issues";
@@ -188,10 +189,10 @@ function validateRegexRule(rule: unknown, path: string, targetName?: string): Co
if (typeof rule !== "string") return [issue("invalid-type", path, "必须为字符串", targetName)];
try {
new RegExp(rule);
return [];
} catch {
return [issue("invalid-regex", path, "正则不合法", targetName)];
}
return isUnsafeRegex(rule) ? [issue("unsafe-regex", path, "正则存在 ReDoS 风险", targetName)] : [];
}
function validateSingleBodyRule(rule: unknown, path: string, targetName?: string): ConfigValidationIssue[] {