1
0

feat: 新增 TCP checker,支持端口可达性探测与 banner 读取

- 新增 src/server/checker/runner/tcp/ 自包含目录(types/schema/validate/execute/expect)
- 注册 TcpChecker 到 checkerRegistry,schema/engine/store/config-loader 自动委托
- 支持 expect.connected 正反向语义(默认期待可达,可配置期待不可达)
- 支持 readBanner opt-in banner 读取,受 bannerReadTimeout + maxBannerBytes 双重限制
- 复用电有 expect/operator/duration/failure 基础设施
- 新增 3 个测试文件 51 条用例(execute/validate/expect),全量 634 测试通过
- 更新 README/DEVELOPMENT/probes.example.yaml,新增 tcp-checker capability spec
This commit is contained in:
2026-05-17 23:53:37 +08:00
parent 31fd3a2a43
commit 0a9a9016be
18 changed files with 1841 additions and 8 deletions

View File

@@ -5,6 +5,7 @@ import { join } from "node:path";
import type { ResolvedCommandTarget } from "../../../src/server/checker/runner/cmd/types";
import type { ResolvedHttpTarget } from "../../../src/server/checker/runner/http/types";
import type { ResolvedTcpTarget } from "../../../src/server/checker/runner/tcp/types";
import { loadConfig, parseDuration } from "../../../src/server/checker/config-loader";
import { checkerRegistry } from "../../../src/server/checker/runner";
@@ -1778,4 +1779,199 @@ targets:
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("name");
});
test("解析最简 tcp 配置", async () => {
const configPath = join(tempDir, "minimal-tcp.yaml");
await writeFile(
configPath,
`targets:
- id: "redis-port"
type: tcp
tcp:
host: "127.0.0.1"
port: 6379
`,
);
const config = await loadConfig(configPath);
expect(config.targets).toHaveLength(1);
const t = config.targets[0]! as ResolvedTcpTarget;
expect(t.type).toBe("tcp");
expect(t.id).toBe("redis-port");
expect(t.name).toBeNull();
expect(t.tcp.host).toBe("127.0.0.1");
expect(t.tcp.port).toBe(6379);
expect(t.tcp.readBanner).toBe(false);
expect(t.tcp.bannerReadTimeout).toBe(2000);
expect(t.tcp.maxBannerBytes).toBe(4096);
expect(t.group).toBe("default");
expect(t.intervalMs).toBe(30000);
expect(t.timeoutMs).toBe(10000);
});
test("tcp 缺少 host 抛出错误", async () => {
await expectConfigError(
"tcp-no-host.yaml",
`targets:
- id: "t"
type: tcp
tcp:
port: 80
`,
"tcp.host",
);
});
test("tcp 缺少 port 抛出错误", async () => {
await expectConfigError(
"tcp-no-port.yaml",
`targets:
- id: "t"
type: tcp
tcp:
host: "127.0.0.1"
`,
"tcp.port",
);
});
test("tcp 非法端口范围抛出错误", async () => {
await expectConfigError(
"tcp-bad-port.yaml",
`targets:
- id: "t"
type: tcp
tcp:
host: "127.0.0.1"
port: 99999
`,
"tcp.port",
);
});
test("tcp 未知分组字段抛出错误", async () => {
await expectConfigError(
"tcp-unknown-field.yaml",
`targets:
- id: "t"
type: tcp
tcp:
host: "127.0.0.1"
port: 80
tls: true
`,
"是未知字段",
);
});
test("tcp readBanner 开启并配置 expect.banner", async () => {
const configPath = join(tempDir, "tcp-banner.yaml");
await writeFile(
configPath,
`targets:
- id: "smtp-check"
type: tcp
tcp:
host: "127.0.0.1"
port: 25
readBanner: true
expect:
banner:
contains: "ESMTP"
`,
);
const config = await loadConfig(configPath);
const t = config.targets[0]! as ResolvedTcpTarget;
expect(t.tcp.readBanner).toBe(true);
expect(t.expect?.banner).toEqual({ contains: "ESMTP" });
});
test("tcp expect.banner 未开启 readBanner 抛出错误", async () => {
await expectConfigError(
"tcp-banner-no-read.yaml",
`targets:
- id: "t"
type: tcp
tcp:
host: "127.0.0.1"
port: 25
expect:
banner:
contains: "ESMTP"
`,
"banner 断言需要启用 tcp.readBanner",
);
});
test("tcp defaults 覆盖 banner 参数", async () => {
const configPath = join(tempDir, "tcp-defaults.yaml");
await writeFile(
configPath,
`defaults:
tcp:
bannerReadTimeout: 1000
maxBannerBytes: "8KB"
targets:
- id: "t1"
type: tcp
tcp:
host: "127.0.0.1"
port: 80
- id: "t2"
type: tcp
tcp:
host: "127.0.0.1"
port: 81
bannerReadTimeout: 3000
`,
);
const config = await loadConfig(configPath);
const t1 = config.targets[0]! as ResolvedTcpTarget;
expect(t1.tcp.bannerReadTimeout).toBe(1000);
expect(t1.tcp.maxBannerBytes).toBe(8192);
const t2 = config.targets[1]! as ResolvedTcpTarget;
expect(t2.tcp.bannerReadTimeout).toBe(3000);
expect(t2.tcp.maxBannerBytes).toBe(8192);
});
test("tcp expect 未知字段抛出错误", async () => {
await expectConfigError(
"tcp-unknown-expect.yaml",
`targets:
- id: "t"
type: tcp
tcp:
host: "127.0.0.1"
port: 80
expect:
status: [200]
`,
"是未知字段",
);
});
test("tcp expect connected 和 maxDurationMs", async () => {
const configPath = join(tempDir, "tcp-expect-connected.yaml");
await writeFile(
configPath,
`targets:
- id: "t"
type: tcp
tcp:
host: "127.0.0.1"
port: 80
expect:
connected: false
maxDurationMs: 5000
`,
);
const config = await loadConfig(configPath);
const t = config.targets[0]! as ResolvedTcpTarget;
expect(t.expect?.connected).toBe(false);
expect(t.expect?.maxDurationMs).toBe(5000);
});
});

View File

@@ -66,8 +66,8 @@ describe("CheckerRegistry", () => {
const second = createDefaultCheckerRegistry();
first.register(createChecker("custom"));
expect(first.supportedTypes).toEqual(["http", "cmd", "db", "custom"]);
expect(second.supportedTypes).toEqual(["http", "cmd", "db"]);
expect(first.supportedTypes).toEqual(["http", "cmd", "db", "tcp", "custom"]);
expect(second.supportedTypes).toEqual(["http", "cmd", "db", "tcp"]);
expect(
first.definitions.every(
(checker) => checker.schemas.config && checker.schemas.defaults && checker.schemas.expect,

View File

@@ -0,0 +1,367 @@
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.statusDetail).toMatch(/^connected in \d+ms$/);
});
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.statusDetail).toBeTruthy();
});
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("maxDurationMs 超时返回失败", async () => {
const result = await checker.execute(makeTarget({}, { expect: { maxDurationMs: -1 } }), 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);
expect(result.statusDetail).toContain("banner:");
expect(result.statusDetail).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");
});
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.statusDetail).not.toContain("banner:");
});
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, maxDurationMs: 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, maxDurationMs: 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");
});
});

View File

@@ -0,0 +1,65 @@
import { describe, expect, test } from "bun:test";
import { checkBanner, checkConnected } from "../../../../../src/server/checker/runner/tcp/expect";
describe("checkConnected", () => {
test("connected=true 期望 true 匹配", () => {
const result = checkConnected(true, true);
expect(result.matched).toBe(true);
expect(result.failure).toBeNull();
});
test("connected=false 期望 false 匹配", () => {
const result = checkConnected(false, false);
expect(result.matched).toBe(true);
expect(result.failure).toBeNull();
});
test("connected=false 期望 true 不匹配", () => {
const result = checkConnected(false, true);
expect(result.matched).toBe(false);
expect(result.failure!.kind).toBe("mismatch");
expect(result.failure!.phase).toBe("connected");
});
test("connected=true 期望 false 不匹配", () => {
const result = checkConnected(true, false);
expect(result.matched).toBe(false);
expect(result.failure!.kind).toBe("mismatch");
expect(result.failure!.phase).toBe("connected");
});
});
describe("checkBanner", () => {
test("contains 匹配", () => {
const result = checkBanner("220 smtp.example.com ESMTP", { contains: "ESMTP" });
expect(result.matched).toBe(true);
});
test("contains 不匹配", () => {
const result = checkBanner("220 smtp.example.com ESMTP", { contains: "POSTFIX" });
expect(result.matched).toBe(false);
expect(result.failure!.kind).toBe("mismatch");
expect(result.failure!.phase).toBe("banner");
});
test("match 正则匹配", () => {
const result = checkBanner("220 smtp.example.com ESMTP", { match: "^220" });
expect(result.matched).toBe(true);
});
test("空 banner 与 contains 空字符串", () => {
const result = checkBanner("", { contains: "" });
expect(result.matched).toBe(true);
});
test("多 operator 同时匹配", () => {
const result = checkBanner("220 ESMTP", { contains: "ESMTP", match: "^220" });
expect(result.matched).toBe(true);
});
test("多 operator 部分不匹配", () => {
const result = checkBanner("220 ESMTP", { contains: "ESMTP", match: "^250" });
expect(result.matched).toBe(false);
});
});

View File

@@ -0,0 +1,191 @@
import { describe, expect, test } from "bun:test";
import type { CheckerValidationInput } from "../../../../../src/server/checker/runner/types";
import { validateTcpConfig } from "../../../../../src/server/checker/runner/tcp/validate";
function makeInput(targets: unknown[], defaults?: Record<string, unknown>): CheckerValidationInput {
return {
defaults: defaults ?? {},
targets: targets as CheckerValidationInput["targets"],
};
}
describe("validateTcpConfig", () => {
test("合法 tcp target 无错误", () => {
const issues = validateTcpConfig(makeInput([{ id: "t1", tcp: { host: "127.0.0.1", port: 80 }, type: "tcp" }]));
expect(issues).toHaveLength(0);
});
test("缺少 tcp 分组", () => {
const issues = validateTcpConfig(makeInput([{ id: "t1", type: "tcp" }]));
expect(issues.length).toBeGreaterThan(0);
expect(issues.some((i) => i.message.includes("tcp"))).toBe(true);
});
test("缺少 host", () => {
const issues = validateTcpConfig(makeInput([{ id: "t1", tcp: { port: 80 }, type: "tcp" }]));
expect(issues.some((i) => i.path.includes("host"))).toBe(true);
});
test("缺少 port", () => {
const issues = validateTcpConfig(makeInput([{ id: "t1", tcp: { host: "127.0.0.1" }, type: "tcp" }]));
expect(issues.some((i) => i.path.includes("port"))).toBe(true);
});
test("端口超范围", () => {
const issues = validateTcpConfig(makeInput([{ id: "t1", tcp: { host: "127.0.0.1", port: 99999 }, type: "tcp" }]));
expect(issues.some((i) => i.path.includes("port"))).toBe(true);
});
test("端口为 0", () => {
const issues = validateTcpConfig(makeInput([{ id: "t1", tcp: { host: "127.0.0.1", port: 0 }, type: "tcp" }]));
expect(issues.some((i) => i.path.includes("port"))).toBe(true);
});
test("readBanner 非布尔值", () => {
const issues = validateTcpConfig(
makeInput([{ id: "t1", tcp: { host: "127.0.0.1", port: 80, readBanner: "yes" }, type: "tcp" }]),
);
expect(issues.some((i) => i.path.includes("readBanner"))).toBe(true);
});
test("bannerReadTimeout 非数字", () => {
const issues = validateTcpConfig(
makeInput([{ id: "t1", tcp: { bannerReadTimeout: "slow", host: "127.0.0.1", port: 80 }, type: "tcp" }]),
);
expect(issues.some((i) => i.path.includes("bannerReadTimeout"))).toBe(true);
});
test("tcp 分组未知字段", () => {
const issues = validateTcpConfig(
makeInput([{ id: "t1", tcp: { host: "127.0.0.1", port: 80, tls: true }, type: "tcp" }]),
);
expect(issues.some((i) => i.message.includes("未知字段"))).toBe(true);
});
test("expect.banner 未开启 readBanner", () => {
const issues = validateTcpConfig(
makeInput([
{
expect: { banner: { contains: "ESMTP" } },
id: "t1",
tcp: { host: "127.0.0.1", port: 25 },
type: "tcp",
},
]),
);
expect(issues.some((i) => i.message.includes("readBanner"))).toBe(true);
});
test("expect.banner 开启 readBanner 无错误", () => {
const issues = validateTcpConfig(
makeInput([
{
expect: { banner: { contains: "ESMTP" } },
id: "t1",
tcp: { host: "127.0.0.1", port: 25, readBanner: true },
type: "tcp",
},
]),
);
expect(issues).toHaveLength(0);
});
test("expect connected 非布尔值", () => {
const issues = validateTcpConfig(
makeInput([
{
expect: { connected: "yes" },
id: "t1",
tcp: { host: "127.0.0.1", port: 80 },
type: "tcp",
},
]),
);
expect(issues.some((i) => i.path.includes("connected"))).toBe(true);
});
test("expect maxDurationMs 非数字", () => {
const issues = validateTcpConfig(
makeInput([
{
expect: { maxDurationMs: "slow" },
id: "t1",
tcp: { host: "127.0.0.1", port: 80 },
type: "tcp",
},
]),
);
expect(issues.some((i) => i.path.includes("maxDurationMs"))).toBe(true);
});
test("expect 未知字段", () => {
const issues = validateTcpConfig(
makeInput([
{
expect: { status: [200] },
id: "t1",
tcp: { host: "127.0.0.1", port: 80 },
type: "tcp",
},
]),
);
expect(issues.some((i) => i.message.includes("未知字段"))).toBe(true);
});
test("expect.banner match 正则非法", () => {
const issues = validateTcpConfig(
makeInput([
{
expect: { banner: { match: "[invalid" } },
id: "t1",
tcp: { host: "127.0.0.1", port: 25, readBanner: true },
type: "tcp",
},
]),
);
expect(issues.some((i) => i.message.includes("正则"))).toBe(true);
});
test("非 tcp 类型 target 跳过", () => {
const issues = validateTcpConfig(makeInput([{ http: { url: "http://example.com" }, id: "t1", type: "http" }]));
expect(issues).toHaveLength(0);
});
test("defaults.tcp 合法字段无错误", () => {
const issues = validateTcpConfig(
makeInput([{ id: "t1", tcp: { host: "127.0.0.1", port: 80 }, type: "tcp" }], {
tcp: { bannerReadTimeout: 1000, maxBannerBytes: "8KB" },
}),
);
expect(issues).toHaveLength(0);
});
test("defaults.tcp 未知字段", () => {
const issues = validateTcpConfig(
makeInput([{ id: "t1", tcp: { host: "127.0.0.1", port: 80 }, type: "tcp" }], {
tcp: { bannerReadTimeout: 1000, host: "127.0.0.1" },
}),
);
expect(issues.some((i) => i.message.includes("未知字段"))).toBe(true);
});
test("defaults.tcp bannerReadTimeout 非法", () => {
const issues = validateTcpConfig(
makeInput([{ id: "t1", tcp: { host: "127.0.0.1", port: 80 }, type: "tcp" }], {
tcp: { bannerReadTimeout: "slow" },
}),
);
expect(issues.some((i) => i.path.includes("bannerReadTimeout"))).toBe(true);
});
test("defaults.tcp maxBannerBytes 非法", () => {
const issues = validateTcpConfig(
makeInput([{ id: "t1", tcp: { host: "127.0.0.1", port: 80 }, type: "tcp" }], {
tcp: { maxBannerBytes: true },
}),
);
expect(issues.some((i) => i.path.includes("maxBannerBytes"))).toBe(true);
});
});