1
0
Files
DiAL/tests/server/checker/runner/http/runner.test.ts

467 lines
17 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 { 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<typeof Bun.serve>;
let baseUrl: string;
beforeAll(() => {
server = Bun.serve({
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 "/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" },
});
case "/large":
return new Response("x".repeat(2000));
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-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");
}
},
port: 0,
});
baseUrl = `http://localhost:${server.port}`;
});
afterAll(() => {
void server.stop();
});
function makeTarget(overrides: {
body?: string;
expect?: Record<string, unknown>;
headers?: Record<string, string>;
ignoreSSL?: boolean;
maxBodyBytes?: number;
maxRedirects?: number;
method?: string;
timeoutMs?: number;
url?: string;
}): ResolvedHttpTarget {
return {
expect: overrides.expect,
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`,
},
intervalMs: 60000,
name: "test-http",
timeoutMs: overrides.timeoutMs ?? 5000,
type: "http",
};
}
function makeCtx(timeoutMs = 5000): CheckerContext {
const controller = new AbortController();
setTimeout(() => controller.abort(), timeoutMs);
return { signal: controller.signal };
}
test("成功请求 200", async () => {
const result = await checker.execute(makeTarget({ url: `${baseUrl}/ok` }), makeCtx());
expect(result.matched).toBe(true);
expect(result.statusDetail).toBe("HTTP 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.statusDetail).toBe("HTTP 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.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 result = await checker.execute(
makeTarget({ expect: { body: [{ contains: "something" }], status: [200] }, url: `${baseUrl}/notfound` }),
makeCtx(),
);
expect(result.matched).toBe(false);
expect(result.failure!.phase).toBe("status");
});
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.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<HttpChecker["resolve"]>[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<HttpChecker["resolve"]>[0];
expect(() => checker.resolve(target, makeResolveContext())).toThrow("ignoreSSL 必须为布尔值");
});
test("缺少 http 分组抛出清晰错误", () => {
const target = { name: "test", type: "http" } as unknown as Parameters<HttpChecker["resolve"]>[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);
});
});