- 重命名 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)
397 lines
12 KiB
TypeScript
397 lines
12 KiB
TypeScript
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||
|
||
import type {
|
||
RawTcpExpectConfig,
|
||
ResolvedTcpExpectConfig,
|
||
ResolvedTcpTarget,
|
||
} from "../../../../../src/server/checker/runner/tcp/types";
|
||
import type { CheckerContext } from "../../../../../src/server/checker/runner/types";
|
||
|
||
import { resolveContentExpectations } from "../../../../../src/server/checker/expect/content";
|
||
import { resolveValueExpectation } from "../../../../../src/server/checker/expect/value";
|
||
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?: { expect?: RawTcpExpectConfig },
|
||
): ResolvedTcpTarget {
|
||
const raw = overrides?.expect;
|
||
const resolvedExpect: ResolvedTcpExpectConfig | undefined = raw
|
||
? {
|
||
banner: resolveContentExpectations(raw.banner),
|
||
connected: raw.connected ?? true,
|
||
durationMs: resolveValueExpectation(raw.durationMs),
|
||
}
|
||
: undefined;
|
||
|
||
return {
|
||
description: null,
|
||
expect: resolvedExpect,
|
||
group: "default",
|
||
id: "test-tcp",
|
||
intervalMs: 60000,
|
||
name: "test-tcp",
|
||
rawExpect: raw,
|
||
tcp: {
|
||
bannerReadTimeout: 2000,
|
||
host: "127.0.0.1",
|
||
maxBannerBytes: 4096,
|
||
port: serverPort,
|
||
readBanner: false,
|
||
...tcp,
|
||
},
|
||
timeoutMs: 5000,
|
||
type: "tcp",
|
||
};
|
||
}
|
||
|
||
beforeAll(() => {
|
||
server = Bun.listen({
|
||
hostname: "127.0.0.1",
|
||
port: 0,
|
||
socket: {
|
||
data() {
|
||
// Bun.listen 必填 handler,echo server 不处理数据
|
||
},
|
||
end(socket) {
|
||
try {
|
||
socket.close();
|
||
} catch {
|
||
// best-effort 关闭
|
||
}
|
||
},
|
||
error() {
|
||
// Bun.listen 必填 handler,测试 server 忽略错误
|
||
},
|
||
open() {
|
||
// Bun.listen 必填 handler,open 时无需操作
|
||
},
|
||
},
|
||
});
|
||
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);
|
||
expect(target.rawExpect).toBeUndefined();
|
||
expect(target.expect).toEqual({ connected: true });
|
||
});
|
||
|
||
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: [{ kind: "value", matcher: { contains: "ESMTP" } }],
|
||
connected: false,
|
||
durationMs: { lte: 5000 },
|
||
});
|
||
expect(target.rawExpect).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");
|
||
});
|
||
});
|