1
0
Files
DiAL/tests/server/checker/runner/tcp/execute.test.ts
lanyuanxiaoyao 375dd3492b feat: 结构化 observation 替代 statusDetail,API 层动态构造 detail
- CheckResult: statusDetail -> observation (持久化) + detail (API 动态派生)
- 存储: status_detail 列 -> observation TEXT (JSON)
- CheckerDefinition: 新增 buildDetail(observation) 方法
- 各 checker 返回结构化 observation,API 层通过 registry 调用 buildDetail
- HTTP: bodyPreview 在 status/header 失败时也提前采集
- UDP: observation 包含 durationMs,未响应归为 error failure
- CMD: 超时/输出超限时保留已收集 observation
- TCP: connectTimeMs 仅含连接建立耗时,不含 banner 等待
- 新增 buildDetail 单测和 mapCheckResult 覆盖测试
- 同步 openspec 主规范,归档 checker-observation 变更
2026-05-19 22:49:00 +08:00

371 lines
11 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 { ResolvedTcpTarget } from "../../../../../src/server/checker/runner/tcp/types";
import type { CheckerContext } from "../../../../../src/server/checker/runner/types";
import { TcpChecker } from "../../../../../src/server/checker/runner/tcp/execute";
const checker = new TcpChecker();
let server: Bun.TCPSocketListener;
let serverPort: number;
let bannerServer: Bun.TCPSocketListener;
let bannerServerPort: number;
let largeBannerServer: Bun.TCPSocketListener;
let largeBannerServerPort: number;
function makeCtx(timeoutMs = 5000): CheckerContext {
const controller = new AbortController();
setTimeout(() => controller.abort(), timeoutMs);
return { signal: controller.signal };
}
function makeTarget(tcp: Partial<ResolvedTcpTarget["tcp"]>, overrides?: Partial<ResolvedTcpTarget>): ResolvedTcpTarget {
return {
description: null,
group: "default",
id: "test-tcp",
intervalMs: 60000,
name: "test-tcp",
tcp: {
bannerReadTimeout: 2000,
host: "127.0.0.1",
maxBannerBytes: 4096,
port: serverPort,
readBanner: false,
...tcp,
},
timeoutMs: 5000,
type: "tcp",
...overrides,
};
}
beforeAll(() => {
server = Bun.listen({
hostname: "127.0.0.1",
port: 0,
socket: {
data() {
// Bun.listen 必填 handlerecho server 不处理数据
},
end(socket) {
try {
socket.close();
} catch {
// best-effort 关闭
}
},
error() {
// Bun.listen 必填 handler测试 server 忽略错误
},
open() {
// Bun.listen 必填 handleropen 时无需操作
},
},
});
serverPort = server.port;
bannerServer = Bun.listen({
hostname: "127.0.0.1",
port: 0,
socket: {
data() {
// Bun.listen 必填 handler
},
end(socket) {
try {
socket.close();
} catch {
// best-effort 关闭
}
},
error() {
// Bun.listen 必填 handler
},
open(socket) {
socket.write("220 smtp.example.com ESMTP\r\n");
},
},
});
bannerServerPort = bannerServer.port;
largeBannerServer = Bun.listen({
hostname: "127.0.0.1",
port: 0,
socket: {
data() {
// Bun.listen 必填 handler
},
end(socket) {
try {
socket.close();
} catch {
// best-effort 关闭
}
},
error() {
// Bun.listen 必填 handler
},
open(socket) {
socket.write("X".repeat(8192));
},
},
});
largeBannerServerPort = largeBannerServer.port;
});
afterAll(() => {
server.stop();
bannerServer.stop();
largeBannerServer.stop();
});
describe("TcpChecker execute", () => {
test("TCP 连接成功", async () => {
const result = await checker.execute(makeTarget({}), makeCtx());
expect(result.matched).toBe(true);
expect(result.failure).toBeNull();
expect(result.durationMs).toBeGreaterThanOrEqual(0);
expect(result.observation).toMatchObject({ connected: true });
});
test("TCP 连接失败", async () => {
const result = await checker.execute(makeTarget({ host: "127.0.0.1", port: 1 }), makeCtx());
expect(result.matched).toBe(false);
expect(result.failure!.kind).toBe("error");
expect(result.failure!.phase).toBe("connect");
expect(result.failure!.message).toBeTruthy();
});
test("期望端口不可达且连接失败", async () => {
const result = await checker.execute(
makeTarget({ host: "127.0.0.1", port: 1 }, { expect: { connected: false } }),
makeCtx(),
);
expect(result.matched).toBe(true);
expect(result.failure).toBeNull();
expect(result.observation).toMatchObject({ connected: false });
});
test("期望端口不可达但连接成功", async () => {
const result = await checker.execute(makeTarget({}, { expect: { connected: false } }), makeCtx());
expect(result.matched).toBe(false);
expect(result.failure!.kind).toBe("mismatch");
expect(result.failure!.phase).toBe("connected");
});
test("durationMs 超时返回失败", async () => {
const result = await checker.execute(makeTarget({}, { expect: { durationMs: { lt: 0 } } }), makeCtx());
expect(result.matched).toBe(false);
expect(result.failure!.phase).toBe("duration");
});
test("读取服务端 banner 成功", async () => {
const result = await checker.execute(
makeTarget({
host: "127.0.0.1",
port: bannerServerPort,
readBanner: true,
}),
makeCtx(),
);
expect(result.matched).toBe(true);
const obs = result.observation!;
expect(obs).toMatchObject({
connected: true,
});
expect(obs["banner"]).toContain("220 smtp.example.com ESMTP");
});
test("banner operator 校验通过", async () => {
const result = await checker.execute(
makeTarget(
{ host: "127.0.0.1", port: bannerServerPort, readBanner: true },
{ expect: { banner: [{ contains: "ESMTP" }] } },
),
makeCtx(),
);
expect(result.matched).toBe(true);
});
test("banner operator 校验失败", async () => {
const result = await checker.execute(
makeTarget(
{ host: "127.0.0.1", port: bannerServerPort, readBanner: true },
{ expect: { banner: [{ contains: "POSTFIX" }] } },
),
makeCtx(),
);
expect(result.matched).toBe(false);
expect(result.failure!.kind).toBe("mismatch");
expect(result.failure!.phase).toBe("banner");
expect(result.failure!.path).toBe("banner[0]");
});
test("默认不读取 banner", async () => {
const result = await checker.execute(
makeTarget({ host: "127.0.0.1", port: bannerServerPort, readBanner: false }),
makeCtx(),
);
expect(result.matched).toBe(true);
expect(result.observation?.["banner"]).toBeFalsy();
});
test("banner 超时空字符串继续执行", async () => {
const result = await checker.execute(
makeTarget({
bannerReadTimeout: 100,
host: "127.0.0.1",
port: serverPort,
readBanner: true,
}),
makeCtx(),
);
expect(result.matched).toBe(true);
});
test("banner 读取超过最大字节数", async () => {
const result = await checker.execute(
makeTarget({
bannerReadTimeout: 2000,
host: "127.0.0.1",
maxBannerBytes: 1024,
port: largeBannerServerPort,
readBanner: true,
}),
makeCtx(),
);
expect(result.matched).toBe(false);
expect(result.failure!.kind).toBe("error");
expect(result.failure!.phase).toBe("banner");
expect(result.failure!.message).toContain("字节限制");
});
test("TCP 执行超时(预 abort", async () => {
const controller = new AbortController();
controller.abort();
const result = await checker.execute(makeTarget({ host: "127.0.0.1", port: serverPort }), {
signal: controller.signal,
});
expect(result.matched).toBe(false);
expect(result.failure!.phase).toBe("connect");
});
test("banner 读取过程中 abort", async () => {
const controller = new AbortController();
setTimeout(() => controller.abort(), 10);
const result = await checker.execute(
makeTarget({
bannerReadTimeout: 5000,
host: "127.0.0.1",
port: serverPort,
readBanner: true,
}),
{ signal: controller.signal },
);
expect(result.matched).toBe(false);
expect(result.failure!.kind).toBe("error");
expect(["connect", "banner"]).toContain(result.failure!.phase);
});
test("serialize 返回 host:port 和 config JSON", () => {
const target = makeTarget({ host: "10.0.0.1", port: 8080 });
const s = checker.serialize(target);
expect(s.target).toBe("10.0.0.1:8080");
const config = JSON.parse(s.config) as Record<string, unknown>;
expect(config["host"]).toBe("10.0.0.1");
expect(config["port"]).toBe(8080);
expect(config["readBanner"]).toBe(false);
});
});
describe("TcpChecker resolve", () => {
test("最简 tcp 配置解析默认值", () => {
const target = checker.resolve(
{ id: "t", tcp: { host: "127.0.0.1", port: 6379 }, type: "tcp" },
{ configDir: "/tmp", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
);
expect(target.tcp.host).toBe("127.0.0.1");
expect(target.tcp.port).toBe(6379);
expect(target.tcp.readBanner).toBe(false);
expect(target.tcp.bannerReadTimeout).toBe(2000);
expect(target.tcp.maxBannerBytes).toBe(4096);
expect(target.group).toBe("default");
expect(target.name).toBeNull();
expect(target.intervalMs).toBe(30000);
expect(target.timeoutMs).toBe(10000);
});
test("bannerReadTimeout 和 maxBannerBytes 支持 per-target 覆盖", () => {
const target = checker.resolve(
{
id: "t",
tcp: { bannerReadTimeout: 5000, host: "127.0.0.1", maxBannerBytes: "1KB", port: 80, readBanner: true },
type: "tcp",
},
{ configDir: "/tmp", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
);
expect(target.tcp.bannerReadTimeout).toBe(5000);
expect(target.tcp.maxBannerBytes).toBe(1024);
expect(target.tcp.readBanner).toBe(true);
});
test("defaults.tcp 合并到 target", () => {
const target = checker.resolve(
{ id: "t", tcp: { host: "127.0.0.1", port: 80 }, type: "tcp" },
{
configDir: "/tmp",
defaultIntervalMs: 30000,
defaults: { tcp: { bannerReadTimeout: 1000, maxBannerBytes: "8KB" } },
defaultTimeoutMs: 10000,
},
);
expect(target.tcp.bannerReadTimeout).toBe(1000);
expect(target.tcp.maxBannerBytes).toBe(8192);
});
test("per-target 覆盖 defaults.tcp", () => {
const target = checker.resolve(
{ id: "t", tcp: { bannerReadTimeout: 5000, host: "127.0.0.1", port: 80 }, type: "tcp" },
{
configDir: "/tmp",
defaultIntervalMs: 30000,
defaults: { tcp: { bannerReadTimeout: 1000 } },
defaultTimeoutMs: 10000,
},
);
expect(target.tcp.bannerReadTimeout).toBe(5000);
});
test("maxBannerBytes 整数默认值解析", () => {
const target = checker.resolve(
{ id: "t", tcp: { host: "127.0.0.1", maxBannerBytes: 2048, port: 80 }, type: "tcp" },
{ configDir: "/tmp", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
);
expect(target.tcp.maxBannerBytes).toBe(2048);
});
test("expect 配置解析", () => {
const target = checker.resolve(
{
expect: { banner: [{ contains: "ESMTP" }], connected: false, durationMs: { lte: 5000 } },
id: "t",
tcp: { host: "127.0.0.1", port: 80, readBanner: true },
type: "tcp",
},
{ configDir: "/tmp", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
);
expect(target.expect).toEqual({ banner: [{ contains: "ESMTP" }], connected: false, durationMs: { lte: 5000 } });
});
test("name 和 group 解析", () => {
const target = checker.resolve(
{ group: "infra", id: "t", name: "redis", tcp: { host: "127.0.0.1", port: 80 }, type: "tcp" },
{ configDir: "/tmp", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
);
expect(target.name).toBe("redis");
expect(target.group).toBe("infra");
});
});