1
0
Files
DiAL/tests/server/checker/runner/http/runner.test.ts
lanyuanxiaoyao 60a54b483f refactor: expect 类型模型重构,Raw/Resolved 双层分离与断言基础设施内聚
- 重命名 ContentRules→ContentExpectations, KeyValueExpect→KeyedExpectations
- 新增 Raw/Resolved 双层模型:resolve 阶段物化为执行计划,store 持久化 Raw 快照
- HTTP body 按需读取:status/headers 失败或无 body expectation 时不读取 body
- 新增 displayValueExpectation() 解包 failure.expected 用户可读展示
- 修复 checkEarlyTimeout 独立 lte/lt 检查,修复 KeyedExpectations JSON Schema
- 新增 expect/value.ts(resolve/check/display)、keyed.ts、content.ts、headers.ts、status.ts
- 删除旧 normalize.ts/matcher.ts/validate-matcher.ts/key-value.ts
- 更新 DEVELOPMENT.md:expect 五层管线表、displayValueExpectation、1.7↔1.10 交叉引用
- 同步 13 个 main specs,归档 refactor-expect-type-model 变更(62/62 tasks)
2026-05-20 16:12:48 +08:00

1019 lines
35 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import type {
RawHttpExpectConfig,
ResolvedHttpExpectConfig,
ResolvedHttpTarget,
} from "../../../../../src/server/checker/runner/http/types";
import type { CheckerContext, ResolveContext } from "../../../../../src/server/checker/runner/types";
import { resolveContentExpectations } from "../../../../../src/server/checker/expect/content";
import { resolveKeyedExpectations } from "../../../../../src/server/checker/expect/keyed";
import { checkStatusCode } from "../../../../../src/server/checker/expect/status";
import { resolveValueExpectation } from "../../../../../src/server/checker/expect/value";
import { HttpChecker } from "../../../../../src/server/checker/runner/http/execute";
import { formatConfigIssues } from "../../../../../src/server/checker/schema/issues";
const checker = new HttpChecker();
const SLOW_BODY_DELAY_MS = 1000;
const FAST_RESPONSE_LIMIT_MS = 500;
function validateHttpTarget(target: unknown): string {
return formatConfigIssues(checker.validate({ defaults: {}, targets: [target as never] }));
}
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<typeof Bun.serve>;
let baseUrl: string;
beforeAll(() => {
server = Bun.serve({
async fetch(req) {
const url = new URL(req.url);
switch (url.pathname) {
case "/echo":
return new Response(JSON.stringify({ body: req.body ? "present" : "empty", method: req.method }), {
headers: { "content-type": "application/json" },
});
case "/echo-actual":
return new Response(JSON.stringify({ body: await req.text(), 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 "/gbk-quoted": {
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" },
});
case "/large":
return new Response("x".repeat(2000));
case "/mixed":
return new Response(JSON.stringify({ html: "<span>OK</span>", status: "ok" }), {
headers: { "content-type": "application/json" },
});
case "/notfound":
return new Response("not found", { status: 404 });
case "/ok":
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-303":
return new Response(null, { headers: { location: `${baseUrl}/echo-actual` }, status: 303 });
case "/redirect-307":
return new Response(null, { headers: { location: `${baseUrl}/echo-actual` }, status: 307 });
case "/redirect-308":
return new Response(null, { headers: { location: `${baseUrl}/echo-actual` }, status: 308 });
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 });
case "/redirect-cross": {
const port = parseInt(url.searchParams.get("port") ?? "0");
return new Response(null, {
headers: { location: `http://127.0.0.1:${port}/ok` },
status: 302,
});
}
case "/redirect-post":
return new Response(null, { headers: { location: `${baseUrl}/echo` }, status: 301 });
case "/redirect-relative":
return new Response(null, { headers: { location: "/ok" }, status: 302 });
case "/slow-body":
return new Response("x".repeat(2000));
case "/unknown-charset": {
return new Response("test", { headers: { "content-type": "text/plain; charset=bogus-encoding" } });
}
default:
return new Response("ok");
}
},
port: 0,
});
baseUrl = `http://localhost:${server.port}`;
});
afterAll(() => {
void server.stop();
});
function makeTarget(overrides: {
body?: string;
expect?: RawHttpExpectConfig;
headers?: Record<string, string>;
ignoreSSL?: boolean;
maxBodyBytes?: number;
maxRedirects?: number;
method?: string;
timeoutMs?: number;
url?: string;
}): ResolvedHttpTarget {
const raw = overrides.expect;
const resolvedExpect: ResolvedHttpExpectConfig | undefined = raw
? {
body: resolveContentExpectations(raw.body),
durationMs: resolveValueExpectation(raw.durationMs),
headers: resolveKeyedExpectations(raw.headers),
status: raw.status ?? [200],
}
: undefined;
return {
description: null,
expect: resolvedExpect,
group: "default",
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`,
},
id: "test-http",
intervalMs: 60000,
name: "test-http",
rawExpect: raw,
timeoutMs: overrides.timeoutMs ?? 5000,
type: "http",
};
}
function makeCtx(timeoutMs = 5000): CheckerContext {
const controller = new AbortController();
setTimeout(() => controller.abort(), timeoutMs);
return { signal: controller.signal };
}
function startSlowBodyServer(init: ResponseInit = {}) {
return Bun.serve({
fetch() {
let sentFirstChunk = false;
return new Response(
new ReadableStream<Uint8Array>({
async pull(controller) {
if (!sentFirstChunk) {
sentFirstChunk = true;
controller.enqueue(new TextEncoder().encode("slow body"));
return;
}
await new Promise((resolve) => setTimeout(resolve, SLOW_BODY_DELAY_MS));
controller.close();
},
}),
init,
);
},
port: 0,
});
}
test("成功请求 200", async () => {
const result = await checker.execute(makeTarget({ url: `${baseUrl}/ok` }), makeCtx());
expect(result.matched).toBe(true);
expect(result.observation).toMatchObject({ statusCode: 200 });
expect(result.durationMs).not.toBeNull();
expect(result.failure).toBeNull();
});
test("404 不匹配默认 status [200]", async () => {
const result = await checker.execute(makeTarget({ url: `${baseUrl}/notfound` }), makeCtx());
expect(result.matched).toBe(false);
expect(result.observation).toMatchObject({ bodyPreview: null, statusCode: 404 });
expect(result.failure!.phase).toBe("status");
});
test("404 匹配自定义 status [404]", async () => {
const result = await checker.execute(
makeTarget({ expect: { status: [404] }, url: `${baseUrl}/notfound` }),
makeCtx(),
);
expect(result.matched).toBe(true);
});
test("headers 检查通过", async () => {
const result = await checker.execute(
makeTarget({ expect: { headers: { "x-custom": "test-value" } }, url: `${baseUrl}/ok` }),
makeCtx(),
);
expect(result.matched).toBe(true);
});
test("headers 检查失败", async () => {
const result = await checker.execute(
makeTarget({ expect: { headers: { "x-custom": "wrong-value" } }, url: `${baseUrl}/ok` }),
makeCtx(),
);
expect(result.matched).toBe(false);
expect(result.observation).toMatchObject({ bodyPreview: null, statusCode: 200 });
expect(result.failure!.phase).toBe("headers");
});
test("body contains 检查", async () => {
const result = await checker.execute(
makeTarget({ expect: { body: [{ contains: "hello" }] }, url: `${baseUrl}/ok` }),
makeCtx(),
);
expect(result.matched).toBe(true);
});
test("body contains 失败", async () => {
const result = await checker.execute(
makeTarget({ expect: { body: [{ contains: "nonexistent" }] }, url: `${baseUrl}/ok` }),
makeCtx(),
);
expect(result.matched).toBe(false);
expect(result.failure!.phase).toBe("body");
});
test("body json 检查", async () => {
const result = await checker.execute(
makeTarget({ expect: { body: [{ json: { equals: "ok", path: "$.status" } }] }, url: `${baseUrl}/json` }),
makeCtx(),
);
expect(result.matched).toBe(true);
});
test("响应体超过 maxBodyBytes", async () => {
const result = await checker.execute(
makeTarget({ expect: { body: [{ contains: "x" }] }, maxBodyBytes: 100, url: `${baseUrl}/large` }),
makeCtx(),
);
expect(result.matched).toBe(false);
expect(result.failure!.phase).toBe("body");
expect(result.failure!.message).toContain("超过限制");
});
test("请求超时", async () => {
const timeoutServer = Bun.serve({
async fetch() {
await new Promise((resolve) => setTimeout(resolve, 10000));
return new Response("late");
},
port: 0,
});
try {
const result = await checker.execute(
makeTarget({ timeoutMs: 100, url: `http://localhost:${timeoutServer.port}/` }),
makeCtx(100),
);
expect(result.matched).toBe(false);
expect(result.failure!.message).toContain("超时");
} finally {
void timeoutServer.stop();
}
});
test("快速失败status 失败时不读取 body", async () => {
const slowBodyServer = startSlowBodyServer({ status: 404 });
try {
const start = performance.now();
const result = await checker.execute(
makeTarget({
expect: { body: [{ contains: "something" }], status: [200] },
url: `http://localhost:${slowBodyServer.port}/`,
}),
makeCtx(),
);
expect(performance.now() - start).toBeLessThan(FAST_RESPONSE_LIMIT_MS);
expect(result.matched).toBe(false);
expect(result.failure!.phase).toBe("status");
expect(result.observation?.["bodyPreview"]).toBeNull();
} finally {
void slowBodyServer.stop();
}
});
test("快速失败headers 失败时不读取 body", async () => {
const slowBodyServer = startSlowBodyServer({ headers: { "x-custom": "actual" }, status: 200 });
try {
const start = performance.now();
const result = await checker.execute(
makeTarget({
expect: { body: [{ contains: "something" }], headers: { "x-custom": "expected" }, status: [200] },
url: `http://localhost:${slowBodyServer.port}/`,
}),
makeCtx(),
);
expect(performance.now() - start).toBeLessThan(FAST_RESPONSE_LIMIT_MS);
expect(result.matched).toBe(false);
expect(result.failure!.phase).toBe("headers");
expect(result.observation?.["bodyPreview"]).toBeNull();
} finally {
void slowBodyServer.stop();
}
});
test("status 通过但 body 失败", async () => {
const result = await checker.execute(
makeTarget({ expect: { body: [{ contains: "not-in-body" }], status: [200] }, url: `${baseUrl}/ok` }),
makeCtx(),
);
expect(result.matched).toBe(false);
expect(result.failure!.phase).toBe("body");
});
test("无 expect 时默认检查 status 200", async () => {
const result = await checker.execute(makeTarget({ expect: undefined, url: `${baseUrl}/ok` }), makeCtx());
expect(result.matched).toBe(true);
});
test("POST 请求携带 body", async () => {
const result = await checker.execute(
makeTarget({
body: "test-body",
expect: { body: [{ json: { equals: "present", path: "$.body" } }], status: [200] },
headers: { "content-type": "text/plain" },
method: "POST",
url: `${baseUrl}/echo`,
}),
makeCtx(),
);
expect(result.matched).toBe(true);
});
test("serialize 返回 URL 和 config JSON", () => {
const target = makeTarget({});
const s = checker.serialize(target);
expect(s.target).toBe(target.http.url);
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.observation).toMatchObject({ statusCode: 301 });
});
test("maxRedirects>0 跟随重定向", async () => {
const result = await checker.execute(makeTarget({ maxRedirects: 5, url: `${baseUrl}/redirect` }), makeCtx());
expect(result.matched).toBe(true);
expect(result.observation).toMatchObject({ statusCode: 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.observation).toMatchObject({ statusCode: 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.observation).toMatchObject({ statusCode: 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.observation).toBeNull();
const ignoredResult = await checker.execute(
makeTarget({ ignoreSSL: true, url: `https://localhost:${httpsServer.port}/` }),
makeCtx(),
);
expect(ignoredResult.matched).toBe(true);
expect(ignoredResult.observation).toMatchObject({ statusCode: 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);
});
test("响应体编码 quoted charset", async () => {
const result = await checker.execute(
makeTarget({ expect: { body: [{ contains: "你好" }] }, url: `${baseUrl}/gbk-quoted` }),
makeCtx(),
);
expect(result.matched).toBe(true);
});
test("响应体不支持的编码返回结构化 body 错误", async () => {
const result = await checker.execute(
makeTarget({ expect: { body: [{ contains: "test" }] }, url: `${baseUrl}/unknown-charset` }),
makeCtx(),
);
expect(result.matched).toBe(false);
expect(result.failure!.phase).toBe("body");
expect(result.failure!.kind).toBe("error");
expect(result.failure!.message).toContain("不支持的字符编码");
});
test("流式 body 等于上限允许通过", async () => {
const bodyLen = "hello world".length;
const result = await checker.execute(
makeTarget({ expect: { body: [{ contains: "hello" }] }, maxBodyBytes: bodyLen, url: `${baseUrl}/ok` }),
makeCtx(),
);
expect(result.matched).toBe(true);
});
test("maxBodyBytes 为 0 且响应体非空时返回超限错误", async () => {
const result = await checker.execute(
makeTarget({ expect: { body: [{ contains: "hello" }] }, maxBodyBytes: 0, url: `${baseUrl}/ok` }),
makeCtx(),
);
expect(result.matched).toBe(false);
expect(result.failure!.phase).toBe("body");
expect(result.failure!.kind).toBe("error");
expect(result.failure!.message).toContain("超过限制");
});
test("body 超限时错误归属 body phase", async () => {
const result = await checker.execute(
makeTarget({ expect: { body: [{ contains: "x" }] }, maxBodyBytes: 100, url: `${baseUrl}/large` }),
makeCtx(),
);
expect(result.matched).toBe(false);
expect(result.failure!.phase).toBe("body");
expect(result.failure!.kind).toBe("error");
});
test("请求错误归属 request phase", async () => {
const result = await checker.execute(makeTarget({ url: "http://localhost:1/" }), makeCtx(500));
expect(result.matched).toBe(false);
expect(result.failure!.phase).toBe("request");
expect(result.failure!.kind).toBe("error");
});
test("超时错误归属 request phase", async () => {
const timeoutServer = Bun.serve({
async fetch() {
await new Promise((resolve) => setTimeout(resolve, 10000));
return new Response("late");
},
port: 0,
});
try {
const result = await checker.execute(
makeTarget({ timeoutMs: 100, url: `http://localhost:${timeoutServer.port}/` }),
makeCtx(100),
);
expect(result.matched).toBe(false);
expect(result.failure!.phase).toBe("request");
expect(result.failure!.message).toContain("超时");
} finally {
void timeoutServer.stop();
}
});
test("durationMs 包含 body 读取耗时", async () => {
const result = await checker.execute(
makeTarget({ expect: { body: [{ contains: "x" }] }, maxBodyBytes: 10240, url: `${baseUrl}/large` }),
makeCtx(),
);
expect(result.matched).toBe(true);
expect(result.durationMs).not.toBeNull();
expect(result.durationMs!).toBeGreaterThanOrEqual(0);
});
test("expect.durationMs 使用完整耗时", async () => {
const slowServer = Bun.serve({
async fetch() {
await new Promise((resolve) => setTimeout(resolve, 50));
return new Response("x".repeat(500));
},
port: 0,
});
try {
const result = await checker.execute(
makeTarget({
expect: { body: [{ contains: "x" }], durationMs: { lte: 10 } },
url: `http://localhost:${slowServer.port}/`,
}),
makeCtx(),
);
expect(result.matched).toBe(false);
expect(result.failure!.phase).toBe("duration");
} finally {
void slowServer.stop();
}
});
test("无 body expectations 时不读取 body", async () => {
const slowBodyServer = startSlowBodyServer({ status: 200 });
try {
const start = performance.now();
const result = await checker.execute(
makeTarget({ expect: { status: [200] }, maxBodyBytes: 1, url: `http://localhost:${slowBodyServer.port}/` }),
makeCtx(),
);
expect(performance.now() - start).toBeLessThan(FAST_RESPONSE_LIMIT_MS);
expect(result.matched).toBe(true);
expect(result.observation?.["bodyPreview"]).toBeNull();
} finally {
void slowBodyServer.stop();
}
});
test("进入 body 前 durationMs 上界已失败时不读取 body", async () => {
const slowBodyServer = startSlowBodyServer({ status: 200 });
try {
const start = performance.now();
const result = await checker.execute(
makeTarget({
expect: { body: [{ contains: "slow" }], durationMs: { lte: 0 }, status: [200] },
url: `http://localhost:${slowBodyServer.port}/`,
}),
makeCtx(),
);
expect(performance.now() - start).toBeLessThan(FAST_RESPONSE_LIMIT_MS);
expect(result.matched).toBe(false);
expect(result.failure!.phase).toBe("duration");
expect(result.observation?.["bodyPreview"]).toBeNull();
} finally {
void slowBodyServer.stop();
}
});
test("body 失败优先于 duration 检查", async () => {
const result = await checker.execute(
makeTarget({
expect: { body: [{ contains: "nonexistent" }], durationMs: { lte: 999999 } },
url: `${baseUrl}/ok`,
}),
makeCtx(),
);
expect(result.matched).toBe(false);
expect(result.failure!.phase).toBe("body");
expect(result.durationMs).not.toBeNull();
expect(result.durationMs!).toBeGreaterThanOrEqual(0);
});
test("POST 重定向改 GET 清理 body headers", async () => {
const result = await checker.execute(
makeTarget({
body: "test-body",
expect: { status: [200] },
headers: { authorization: "Bearer token123", "content-type": "text/plain" },
maxRedirects: 5,
method: "POST",
url: `${baseUrl}/redirect-post`,
}),
makeCtx(),
);
expect(result.matched).toBe(true);
});
test("303 重定向将 method 转为 GET 且清空 body", async () => {
const result = await checker.execute(
makeTarget({
body: "payload",
expect: {
body: [{ json: { equals: "GET", path: "$.method" } }, { json: { equals: "", path: "$.body" } }],
status: [200],
},
headers: { "content-type": "text/plain" },
maxRedirects: 1,
method: "POST",
url: `${baseUrl}/redirect-303`,
}),
makeCtx(),
);
expect(result.matched).toBe(true);
});
test("307/308 重定向保持原始 method 和 body", async () => {
for (const statusCode of [307, 308]) {
const result = await checker.execute(
makeTarget({
body: `payload-${statusCode}`,
expect: {
body: [
{ json: { equals: "POST", path: "$.method" } },
{ json: { equals: `payload-${statusCode}`, path: "$.body" } },
],
status: [200],
},
headers: { "content-type": "text/plain" },
maxRedirects: 1,
method: "POST",
url: `${baseUrl}/redirect-${statusCode}`,
}),
makeCtx(),
);
expect(result.matched).toBe(true);
}
});
test("相对路径 Location header 重定向", async () => {
const result = await checker.execute(
makeTarget({ maxRedirects: 1, url: `${baseUrl}/redirect-relative` }),
makeCtx(),
);
expect(result.matched).toBe(true);
expect(result.observation).toMatchObject({ statusCode: 200 });
});
test("混合 body expectations 集成检查", async () => {
const result = await checker.execute(
makeTarget({
expect: {
body: [
{ contains: '"status":"ok"' },
{ json: { equals: "ok", path: "$.status" } },
{ css: { equals: "OK", selector: "span" } },
],
status: [200],
},
url: `${baseUrl}/mixed`,
}),
makeCtx(),
);
expect(result.matched).toBe(true);
});
test("跨 origin 重定向剥离敏感 headers", async () => {
const targetServer = Bun.serve({
fetch(req) {
const url = new URL(req.url);
if (url.pathname === "/ok") {
const auth = req.headers.get("authorization");
if (auth) {
return new Response("auth leaked", { status: 200 });
}
return new Response("safe", { headers: { "content-type": "text/plain" } });
}
return new Response("ok");
},
port: 0,
});
try {
const result = await checker.execute(
makeTarget({
expect: { body: [{ contains: "safe" }] },
headers: { authorization: "Bearer secret" },
maxRedirects: 5,
url: `${baseUrl}/redirect-cross?port=${targetServer.port}`,
}),
makeCtx(),
);
expect(result.matched).toBe(true);
} finally {
void targetServer.stop();
}
});
test("1xx 范围模式匹配 101", () => {
const r = checkStatusCode(101, ["1xx"]);
expect(r.matched).toBe(true);
});
test("6xx 范围模式启动校验失败", () => {
const errors = validateHttpTarget({
expect: { status: ["6xx"] },
http: { url: "https://example.com" },
name: "test",
type: "http",
});
expect(errors).toContain("status 模式必须为 1xx 到 5xx");
});
test("status 数字 99 启动校验失败", () => {
const errors = validateHttpTarget({
expect: { status: [99] },
http: { url: "https://example.com" },
name: "test",
type: "http",
});
expect(errors).toContain("100-599");
});
test("body rule 未知字段启动失败", () => {
const errors = validateHttpTarget({
expect: { body: [{ contains: "ok", note: "ignored" }], status: [200] },
http: { url: "https://example.com" },
name: "test",
type: "http",
});
expect(errors).toContain("note 是未知字段");
});
test("body rule 使用 match 字段启动失败", () => {
const errors = validateHttpTarget({
expect: { body: [{ match: "ok" }] },
http: { url: "https://example.com" },
name: "test",
type: "http",
});
expect(errors).toContain("match 是未知字段");
});
test("非法 regex 启动校验失败", () => {
const errors = validateHttpTarget({
expect: { body: [{ regex: "[invalid" }] },
http: { url: "https://example.com" },
name: "test",
type: "http",
});
expect(errors).toContain("regex 正则不合法");
});
test("ReDoS regex body rule 启动校验失败", () => {
const errors = validateHttpTarget({
expect: { body: [{ regex: "(a+)+$" }] },
http: { url: "https://example.com" },
name: "test",
type: "http",
});
expect(errors).toContain("正则存在 ReDoS 风险");
});
test("非法 JSONPath 启动校验失败", () => {
const errors = validateHttpTarget({
expect: { body: [{ json: { equals: "ok", path: "status" } }] },
http: { url: "https://example.com" },
name: "test",
type: "http",
});
expect(errors).toContain("json.path");
});
test("旧 match matcher 启动校验失败", () => {
const errors = validateHttpTarget({
expect: { headers: { "x-test": { match: "[invalid" } } },
http: { url: "https://example.com" },
name: "test",
type: "http",
});
expect(errors).toContain("match 是未知 matcher");
});
test("ReDoS regex matcher 启动校验失败", () => {
const errors = validateHttpTarget({
expect: { headers: { "x-test": { regex: "(\\d+)*x" } } },
http: { url: "https://example.com" },
name: "test",
type: "http",
});
expect(errors).toContain("正则存在 ReDoS 风险");
});
test("非法 operator gte 类型启动失败", () => {
const errors = validateHttpTarget({
expect: { body: [{ json: { gte: "abc", path: "$.count" } }] },
http: { url: "https://example.com" },
name: "test",
type: "http",
});
expect(errors).toContain("gte 必须为有限数字");
});
test("非法 operator exists 类型启动失败", () => {
const errors = validateHttpTarget({
expect: { body: [{ json: { exists: "yes", path: "$.status" } }] },
http: { url: "https://example.com" },
name: "test",
type: "http",
});
expect(errors).toContain("exists 必须为布尔值");
});
test("纯 operator 空对象启动失败", () => {
const errors = validateHttpTarget({
expect: { headers: { "x-test": {} } },
http: { url: "https://example.com" },
name: "test",
type: "http",
});
expect(errors).toContain("必须包含至少一个合法 matcher");
});
test("body rule 多个支持字段启动失败", () => {
const errors = validateHttpTarget({
expect: { body: [{ contains: "ok", json: { path: "$.status" } }] },
http: { url: "https://example.com" },
name: "test",
type: "http",
});
expect(errors).toContain("直接 matcher 不能与 extractor 混用");
});
test("body rule 缺少支持字段启动失败", () => {
const errors = validateHttpTarget({
expect: { body: [{ foo: "bar" }] },
http: { url: "https://example.com" },
name: "test",
type: "http",
});
expect(errors).toContain("foo 是未知字段");
});
test("css selector 为空启动失败", () => {
const errors = validateHttpTarget({
expect: { body: [{ css: { selector: "" } }] },
http: { url: "https://example.com" },
name: "test",
type: "http",
});
expect(errors).toContain("css.selector 必须为非空字符串");
});
test("xpath path 为空启动失败", () => {
const errors = validateHttpTarget({
expect: { body: [{ xpath: { path: "" } }] },
http: { url: "https://example.com" },
name: "test",
type: "http",
});
expect(errors).toContain("xpath.path 必须为非空字符串");
});
test("json rule 允许存在性语义", () => {
const errors = validateHttpTarget({
expect: { body: [{ json: { path: "$.status" } }] },
http: { url: "https://example.com" },
name: "test",
type: "http",
});
expect(errors).toBe("");
});
test("equals 支持对象和数组", () => {
const errors = validateHttpTarget({
expect: {
body: [
{ json: { equals: { status: "ok" }, path: "$.payload" } },
{ json: { equals: ["a", "b"], path: "$.items" } },
],
},
http: { url: "https://example.com" },
name: "test",
type: "http",
});
expect(errors).toBe("");
});
});
describe("HttpChecker.resolve", () => {
function makeResolveContext(): ResolveContext {
return {
configDir: ".",
defaultIntervalMs: 30000,
defaults: {},
defaultTimeoutMs: 10000,
};
}
test("expect.status 非法模式抛出错误", () => {
const errors = validateHttpTarget({
expect: { status: ["abc"] },
http: { url: "https://example.com" },
id: "test",
name: "test",
type: "http",
});
expect(errors).toContain("status 模式必须为 1xx 到 5xx");
});
test("ignoreSSL 默认值为 false", () => {
const result = checker.resolve(
{ http: { url: "https://example.com" }, id: "test", name: "test", type: "http" },
makeResolveContext(),
);
expect(result.http.ignoreSSL).toBe(false);
});
test("未配置 expect 时在 Resolved 模型物化默认 status", () => {
const result = checker.resolve(
{ http: { url: "https://example.com" }, id: "test", name: "test", type: "http" },
makeResolveContext(),
);
expect(result.rawExpect).toBeUndefined();
expect(result.expect).toEqual({ status: [200] });
});
test("maxRedirects 默认值为 0", () => {
const result = checker.resolve(
{ http: { url: "https://example.com" }, id: "test", name: "test", type: "http" },
makeResolveContext(),
);
expect(result.http.maxRedirects).toBe(0);
});
test("合法 status 范围模式通过校验", () => {
const result = checker.resolve(
{
expect: { status: ["2xx", 301] },
http: { url: "https://example.com" },
id: "test",
name: "test",
type: "http",
},
makeResolveContext(),
);
expect(result.expect?.status).toEqual(["2xx", 301]);
});
test("显式 ignoreSSL 和 maxRedirects 正确解析", () => {
const result = checker.resolve(
{
http: { ignoreSSL: true, maxRedirects: 3, url: "https://example.com" },
id: "test",
name: "test",
type: "http",
},
makeResolveContext(),
);
expect(result.http.ignoreSSL).toBe(true);
expect(result.http.maxRedirects).toBe(3);
});
});