feat: 新增 UDP checker,支持自定义 payload 请求-响应探测与断言
基于 Bun connected UDP socket 实现通用 UDP 拨测能力: - 支持 text/hex/base64 payload 编码与独立 responseEncoding 响应视图 - 支持 responded、response、responseSize、sourceHost、sourcePort、maxDurationMs 专属 expect - 单 datagram 发送,仅断言首个 UDP 响应 datagram - 通过 maxResponseBytes 和 flags.truncated 进行响应大小限制与截断保护 - payload 可选,省略时发送空 datagram - 自包含模块结构(types/schema/validate/expect/encoding/execute) - 新增 741 tests(含 unit、execute 集成、expect 和编码 roundtrip),全部通过
This commit is contained in:
@@ -66,8 +66,8 @@ describe("CheckerRegistry", () => {
|
||||
const second = createDefaultCheckerRegistry();
|
||||
first.register(createChecker("custom"));
|
||||
|
||||
expect(first.supportedTypes).toEqual(["http", "cmd", "db", "tcp", "ping", "custom"]);
|
||||
expect(second.supportedTypes).toEqual(["http", "cmd", "db", "tcp", "ping"]);
|
||||
expect(first.supportedTypes).toEqual(["http", "cmd", "db", "tcp", "ping", "udp", "custom"]);
|
||||
expect(second.supportedTypes).toEqual(["http", "cmd", "db", "tcp", "ping", "udp"]);
|
||||
expect(
|
||||
first.definitions.every(
|
||||
(checker) => checker.schemas.config && checker.schemas.defaults && checker.schemas.expect,
|
||||
|
||||
76
tests/server/checker/runner/udp/encoding.test.ts
Normal file
76
tests/server/checker/runner/udp/encoding.test.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
|
||||
import { decodePayload, encodeResponse } from "../../../../../src/server/checker/runner/udp/encoding";
|
||||
|
||||
describe("decodePayload", () => {
|
||||
it("text: 解码 ASCII 字符串", () => {
|
||||
const bytes = decodePayload("PING", "text");
|
||||
expect(bytes).toEqual(new Uint8Array([0x50, 0x49, 0x4e, 0x47]));
|
||||
});
|
||||
|
||||
it("hex: 解码十六进制字符串", () => {
|
||||
const bytes = decodePayload("50494e47", "hex");
|
||||
expect(bytes).toEqual(new Uint8Array([0x50, 0x49, 0x4e, 0x47]));
|
||||
});
|
||||
|
||||
it("base64: 解码 Base64 字符串", () => {
|
||||
const bytes = decodePayload("UElORw==", "base64");
|
||||
expect(bytes).toEqual(new Uint8Array([0x50, 0x49, 0x4e, 0x47]));
|
||||
});
|
||||
|
||||
it("text: 空字符串返回空 Uint8Array", () => {
|
||||
const bytes = decodePayload("", "text");
|
||||
expect(bytes).toEqual(new Uint8Array(0));
|
||||
});
|
||||
|
||||
it("hex: 空字符串返回空 Uint8Array", () => {
|
||||
const bytes = decodePayload("", "hex");
|
||||
expect(bytes).toEqual(new Uint8Array(0));
|
||||
});
|
||||
|
||||
it("base64: 空字符串返回空 Uint8Array", () => {
|
||||
const bytes = decodePayload("", "base64");
|
||||
expect(bytes).toEqual(new Uint8Array(0));
|
||||
});
|
||||
|
||||
it("text: 多字节 UTF-8 字符", () => {
|
||||
const bytes = decodePayload("你好", "text");
|
||||
expect(encodeResponse(bytes, "text")).toBe("你好");
|
||||
});
|
||||
|
||||
it("hex: 奇数长度字符串抛出错误", () => {
|
||||
expect(() => decodePayload("abc", "hex")).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("encodeResponse", () => {
|
||||
it("text: 编码为 UTF-8 字符串", () => {
|
||||
const bytes = new Uint8Array([0x50, 0x49, 0x4e, 0x47]);
|
||||
expect(encodeResponse(bytes, "text")).toBe("PING");
|
||||
});
|
||||
|
||||
it("hex: 编码为小写十六进制字符串", () => {
|
||||
const bytes = new Uint8Array([0x50, 0x49, 0x4e, 0x47]);
|
||||
expect(encodeResponse(bytes, "hex")).toBe("50494e47");
|
||||
});
|
||||
|
||||
it("base64: 编码为 Base64 字符串", () => {
|
||||
const bytes = new Uint8Array([0x50, 0x49, 0x4e, 0x47]);
|
||||
expect(encodeResponse(bytes, "base64")).toBe("UElORw==");
|
||||
});
|
||||
});
|
||||
|
||||
describe("roundtrip", () => {
|
||||
it("hex: [0x00, 0xff, 0x0a] 往返一致", () => {
|
||||
const original = new Uint8Array([0x00, 0xff, 0x0a]);
|
||||
const hex = encodeResponse(original, "hex");
|
||||
expect(hex).toBe("00ff0a");
|
||||
expect(decodePayload(hex, "hex")).toEqual(original);
|
||||
});
|
||||
|
||||
it("base64: 任意字节往返一致", () => {
|
||||
const original = new Uint8Array([0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef]);
|
||||
const b64 = encodeResponse(original, "base64");
|
||||
expect(decodePayload(b64, "base64")).toEqual(original);
|
||||
});
|
||||
});
|
||||
364
tests/server/checker/runner/udp/execute.test.ts
Normal file
364
tests/server/checker/runner/udp/execute.test.ts
Normal file
@@ -0,0 +1,364 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
|
||||
import type { ResolvedUdpTarget, UdpExpectConfig } from "../../../../../src/server/checker/runner/udp/types";
|
||||
|
||||
import { UdpChecker } from "../../../../../src/server/checker/runner/udp/execute";
|
||||
|
||||
async function createEchoServer(): Promise<{ close: () => void; port: number }> {
|
||||
const socket = await Bun.udpSocket({
|
||||
socket: {
|
||||
data(sock, buf, port, addr) {
|
||||
sock.send(buf, port, addr);
|
||||
},
|
||||
drain() {
|
||||
// Bun UDP socket handler 必填项
|
||||
},
|
||||
error() {
|
||||
// Bun UDP socket handler 必填项
|
||||
},
|
||||
},
|
||||
});
|
||||
return { close: () => socket.close(), port: socket.port };
|
||||
}
|
||||
|
||||
function makeSignal(timeoutMs: number): { cleanup: () => void; signal: AbortSignal } {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
return { cleanup: () => clearTimeout(timer), signal: controller.signal };
|
||||
}
|
||||
|
||||
function makeTarget(overrides: Partial<ResolvedUdpTarget["udp"]> = {}, expect?: UdpExpectConfig): ResolvedUdpTarget {
|
||||
return {
|
||||
description: null,
|
||||
expect,
|
||||
group: "default",
|
||||
id: "test-udp",
|
||||
intervalMs: 30000,
|
||||
name: null,
|
||||
timeoutMs: 10000,
|
||||
type: "udp",
|
||||
udp: {
|
||||
encoding: "text",
|
||||
host: "127.0.0.1",
|
||||
maxResponseBytes: 4096,
|
||||
payload: "PING",
|
||||
port: 0,
|
||||
responseEncoding: "text",
|
||||
...overrides,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("UdpChecker execute", () => {
|
||||
it("should resolve and respond successfully", async () => {
|
||||
const server = await createEchoServer();
|
||||
try {
|
||||
const checker = new UdpChecker();
|
||||
const target = makeTarget({ port: server.port });
|
||||
const { cleanup, signal } = makeSignal(5000);
|
||||
const result = await checker.execute(target, { signal });
|
||||
cleanup();
|
||||
expect(result.matched).toBe(true);
|
||||
expect(result.failure).toBeNull();
|
||||
expect(result.statusDetail).toContain("responded");
|
||||
expect(result.durationMs).toBeGreaterThanOrEqual(0);
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("should work with localhost hostname", async () => {
|
||||
const server = await createEchoServer();
|
||||
try {
|
||||
const checker = new UdpChecker();
|
||||
const target = makeTarget({ host: "localhost", port: server.port });
|
||||
const { cleanup, signal } = makeSignal(5000);
|
||||
const result = await checker.execute(target, { signal });
|
||||
cleanup();
|
||||
expect(result.matched).toBe(true);
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("should fail when no response and default expect.responded=true", async () => {
|
||||
const server = await Bun.udpSocket({
|
||||
socket: {
|
||||
data() {
|
||||
// sink - 不回包以模拟无响应
|
||||
},
|
||||
drain() {
|
||||
// Bun UDP socket handler 必填项
|
||||
},
|
||||
error() {
|
||||
// Bun UDP socket handler 必填项
|
||||
},
|
||||
},
|
||||
});
|
||||
try {
|
||||
const checker = new UdpChecker();
|
||||
const target = makeTarget({ port: server.port });
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 200);
|
||||
const result = await checker.execute(target, { signal: controller.signal });
|
||||
clearTimeout(timer);
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.failure).not.toBeNull();
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("should match when expect.responded=false and no response", async () => {
|
||||
const server = await Bun.udpSocket({
|
||||
socket: {
|
||||
data() {
|
||||
// sink - 不回包以模拟无响应
|
||||
},
|
||||
drain() {
|
||||
// Bun UDP socket handler 必填项
|
||||
},
|
||||
error() {
|
||||
// Bun UDP socket handler 必填项
|
||||
},
|
||||
},
|
||||
});
|
||||
try {
|
||||
const checker = new UdpChecker();
|
||||
const target = makeTarget({ port: server.port }, { responded: false });
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 200);
|
||||
const result = await checker.execute(target, { signal: controller.signal });
|
||||
clearTimeout(timer);
|
||||
expect(result.matched).toBe(true);
|
||||
expect(result.statusDetail).toContain("no response");
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("should fail when expect.responded=false but response received", async () => {
|
||||
const server = await createEchoServer();
|
||||
try {
|
||||
const checker = new UdpChecker();
|
||||
const target = makeTarget({ port: server.port }, { responded: false });
|
||||
const { cleanup, signal } = makeSignal(5000);
|
||||
const result = await checker.execute(target, { signal });
|
||||
cleanup();
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.failure?.phase).toBe("responded");
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("should validate response content with expect.response", async () => {
|
||||
const server = await createEchoServer();
|
||||
try {
|
||||
const checker = new UdpChecker();
|
||||
const target = makeTarget({ port: server.port }, { response: [{ contains: "PING" }] });
|
||||
const { cleanup, signal } = makeSignal(5000);
|
||||
const result = await checker.execute(target, { signal });
|
||||
cleanup();
|
||||
expect(result.matched).toBe(true);
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("should fail response content mismatch", async () => {
|
||||
const server = await createEchoServer();
|
||||
try {
|
||||
const checker = new UdpChecker();
|
||||
const target = makeTarget({ port: server.port }, { response: [{ contains: "PONG" }] });
|
||||
const { cleanup, signal } = makeSignal(5000);
|
||||
const result = await checker.execute(target, { signal });
|
||||
cleanup();
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.failure?.phase).toBe("response");
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("should validate responseSize", async () => {
|
||||
const server = await createEchoServer();
|
||||
try {
|
||||
const checker = new UdpChecker();
|
||||
const target = makeTarget({ port: server.port }, { responseSize: { gte: 1 } });
|
||||
const { cleanup, signal } = makeSignal(5000);
|
||||
const result = await checker.execute(target, { signal });
|
||||
cleanup();
|
||||
expect(result.matched).toBe(true);
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("should only use the first datagram when server sends multiple", async () => {
|
||||
const server = await Bun.udpSocket({
|
||||
socket: {
|
||||
data(sock, buf, port, addr) {
|
||||
sock.send(Buffer.from("FIRST"), port, addr);
|
||||
setTimeout(() => {
|
||||
try {
|
||||
sock.send(Buffer.from("SECOND"), port, addr);
|
||||
} catch {
|
||||
// checker 已关闭 connected socket,第二次发送可能失败
|
||||
}
|
||||
}, 30);
|
||||
},
|
||||
drain() {
|
||||
// Bun UDP socket handler 必填项
|
||||
},
|
||||
error() {
|
||||
// Bun UDP socket handler 必填项
|
||||
},
|
||||
},
|
||||
});
|
||||
try {
|
||||
const checker = new UdpChecker();
|
||||
const target = makeTarget({ payload: "PING", port: server.port }, { response: [{ contains: "FIRST" }] });
|
||||
const { cleanup, signal } = makeSignal(5000);
|
||||
const result = await checker.execute(target, { signal });
|
||||
cleanup();
|
||||
expect(result.matched).toBe(true);
|
||||
expect(result.statusDetail).toContain("5 bytes");
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("should fail when response exceeds maxResponseBytes", async () => {
|
||||
const server = await createEchoServer();
|
||||
try {
|
||||
const checker = new UdpChecker();
|
||||
const longPayload = "X".repeat(100);
|
||||
const target = makeTarget({
|
||||
maxResponseBytes: 10,
|
||||
payload: longPayload,
|
||||
port: server.port,
|
||||
});
|
||||
const { cleanup, signal } = makeSignal(5000);
|
||||
const result = await checker.execute(target, { signal });
|
||||
cleanup();
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.failure?.phase).toBe("response");
|
||||
expect(result.failure?.kind).toBe("error");
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("should fail when duration exceeds maxDurationMs", async () => {
|
||||
const server = await Bun.udpSocket({
|
||||
socket: {
|
||||
data() {
|
||||
// sink - 不回包,通过 abort 触发 no-response 路径
|
||||
},
|
||||
drain() {
|
||||
// Bun UDP socket handler 必填项
|
||||
},
|
||||
error() {
|
||||
// Bun UDP socket handler 必填项
|
||||
},
|
||||
},
|
||||
});
|
||||
try {
|
||||
const checker = new UdpChecker();
|
||||
const target = makeTarget({ port: server.port }, { maxDurationMs: 1, responded: false });
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 200);
|
||||
const result = await checker.execute(target, { signal: controller.signal });
|
||||
clearTimeout(timer);
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.failure?.phase).toBe("duration");
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("should match sourceHost assertion", async () => {
|
||||
const server = await createEchoServer();
|
||||
try {
|
||||
const checker = new UdpChecker();
|
||||
const target = makeTarget({ port: server.port }, { sourceHost: { equals: "127.0.0.1" } });
|
||||
const { cleanup, signal } = makeSignal(5000);
|
||||
const result = await checker.execute(target, { signal });
|
||||
cleanup();
|
||||
expect(result.matched).toBe(true);
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("should match sourcePort assertion", async () => {
|
||||
const server = await createEchoServer();
|
||||
try {
|
||||
const checker = new UdpChecker();
|
||||
const target = makeTarget({ port: server.port }, { sourcePort: { equals: server.port } });
|
||||
const { cleanup, signal } = makeSignal(5000);
|
||||
const result = await checker.execute(target, { signal });
|
||||
cleanup();
|
||||
expect(result.matched).toBe(true);
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("UdpChecker resolve", () => {
|
||||
it("should fill defaults for minimal config", () => {
|
||||
const checker = new UdpChecker();
|
||||
const target = checker.resolve(
|
||||
{ id: "test", type: "udp", udp: { host: "127.0.0.1", port: 9000 } },
|
||||
{ configDir: "/tmp", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
|
||||
);
|
||||
expect(target.udp.payload).toBe("");
|
||||
expect(target.udp.encoding).toBe("text");
|
||||
expect(target.udp.responseEncoding).toBe("text");
|
||||
expect(target.udp.maxResponseBytes).toBe(4096);
|
||||
});
|
||||
|
||||
it("should use defaults.udp for missing fields", () => {
|
||||
const checker = new UdpChecker();
|
||||
const target = checker.resolve(
|
||||
{ id: "test", type: "udp", udp: { host: "127.0.0.1", port: 9000 } },
|
||||
{
|
||||
configDir: "/tmp",
|
||||
defaultIntervalMs: 30000,
|
||||
defaults: { udp: { encoding: "hex", maxResponseBytes: "8KB", responseEncoding: "hex" } },
|
||||
defaultTimeoutMs: 10000,
|
||||
},
|
||||
);
|
||||
expect(target.udp.encoding).toBe("hex");
|
||||
expect(target.udp.responseEncoding).toBe("hex");
|
||||
expect(target.udp.maxResponseBytes).toBe(8192);
|
||||
});
|
||||
|
||||
it("should override defaults with target-level config", () => {
|
||||
const checker = new UdpChecker();
|
||||
const target = checker.resolve(
|
||||
{ id: "test", type: "udp", udp: { encoding: "base64", host: "127.0.0.1", port: 9000 } },
|
||||
{
|
||||
configDir: "/tmp",
|
||||
defaultIntervalMs: 30000,
|
||||
defaults: { udp: { encoding: "hex" } },
|
||||
defaultTimeoutMs: 10000,
|
||||
},
|
||||
);
|
||||
expect(target.udp.encoding).toBe("base64");
|
||||
});
|
||||
});
|
||||
|
||||
describe("UdpChecker serialize", () => {
|
||||
it("should produce udp host:port target summary", () => {
|
||||
const checker = new UdpChecker();
|
||||
const target = makeTarget({ host: "10.0.0.1", port: 9000 });
|
||||
const { config, target: display } = checker.serialize(target);
|
||||
expect(display).toBe("udp 10.0.0.1:9000");
|
||||
const parsed = JSON.parse(config) as Record<string, unknown>;
|
||||
expect(parsed["host"]).toBe("10.0.0.1");
|
||||
expect(parsed["port"]).toBe(9000);
|
||||
});
|
||||
});
|
||||
120
tests/server/checker/runner/udp/expect.test.ts
Normal file
120
tests/server/checker/runner/udp/expect.test.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
|
||||
import {
|
||||
checkResponded,
|
||||
checkResponseSize,
|
||||
checkResponseText,
|
||||
checkSourceHost,
|
||||
checkSourcePort,
|
||||
} from "../../../../../src/server/checker/runner/udp/expect";
|
||||
|
||||
describe("checkResponded", () => {
|
||||
it("responded=true 期望 true → 匹配", () => {
|
||||
const result = checkResponded(true, true);
|
||||
expect(result.matched).toBe(true);
|
||||
expect(result.failure).toBeNull();
|
||||
});
|
||||
|
||||
it("responded=false 期望 true → 不匹配", () => {
|
||||
const result = checkResponded(false, true);
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.failure!.kind).toBe("mismatch");
|
||||
expect(result.failure!.phase).toBe("responded");
|
||||
});
|
||||
|
||||
it("responded=false 期望 false → 匹配", () => {
|
||||
const result = checkResponded(false, false);
|
||||
expect(result.matched).toBe(true);
|
||||
expect(result.failure).toBeNull();
|
||||
});
|
||||
|
||||
it("responded=true 期望 false → 不匹配", () => {
|
||||
const result = checkResponded(true, false);
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.failure!.kind).toBe("mismatch");
|
||||
expect(result.failure!.phase).toBe("responded");
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkResponseSize", () => {
|
||||
it("size=4 gte=4 → 匹配", () => {
|
||||
const result = checkResponseSize(4, { gte: 4 });
|
||||
expect(result.matched).toBe(true);
|
||||
expect(result.failure).toBeNull();
|
||||
});
|
||||
|
||||
it("size=2 gte=4 → 不匹配,phase=responseSize", () => {
|
||||
const result = checkResponseSize(2, { gte: 4 });
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.failure!.kind).toBe("mismatch");
|
||||
expect(result.failure!.phase).toBe("responseSize");
|
||||
});
|
||||
|
||||
it("size=10 lt=20 → 匹配", () => {
|
||||
const result = checkResponseSize(10, { lt: 20 });
|
||||
expect(result.matched).toBe(true);
|
||||
});
|
||||
|
||||
it("size=5 equals=5 → 匹配", () => {
|
||||
const result = checkResponseSize(5, { equals: 5 });
|
||||
expect(result.matched).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkResponseText", () => {
|
||||
it("单条 contains 匹配", () => {
|
||||
const result = checkResponseText("PONG", [{ contains: "PONG" }]);
|
||||
expect(result.matched).toBe(true);
|
||||
expect(result.failure).toBeNull();
|
||||
});
|
||||
|
||||
it("单条 contains 不匹配,phase=response", () => {
|
||||
const result = checkResponseText("PING", [{ contains: "PONG" }]);
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.failure!.kind).toBe("mismatch");
|
||||
expect(result.failure!.phase).toBe("response");
|
||||
});
|
||||
|
||||
it("多条规则全部匹配", () => {
|
||||
const result = checkResponseText("hello world", [{ contains: "hello" }, { match: "^hello" }]);
|
||||
expect(result.matched).toBe(true);
|
||||
expect(result.failure).toBeNull();
|
||||
});
|
||||
|
||||
it("多条规则第二条失败 → 不匹配", () => {
|
||||
const result = checkResponseText("hello world", [{ contains: "hello" }, { match: "^world" }]);
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.failure!.phase).toBe("response");
|
||||
expect(result.failure!.path).toBe("response[1]");
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkSourceHost", () => {
|
||||
it("equals 匹配", () => {
|
||||
const result = checkSourceHost("127.0.0.1", { equals: "127.0.0.1" });
|
||||
expect(result.matched).toBe(true);
|
||||
expect(result.failure).toBeNull();
|
||||
});
|
||||
|
||||
it("equals 不匹配", () => {
|
||||
const result = checkSourceHost("10.0.0.1", { equals: "127.0.0.1" });
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.failure!.kind).toBe("mismatch");
|
||||
expect(result.failure!.phase).toBe("sourceHost");
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkSourcePort", () => {
|
||||
it("equals 匹配", () => {
|
||||
const result = checkSourcePort(9000, { equals: 9000 });
|
||||
expect(result.matched).toBe(true);
|
||||
expect(result.failure).toBeNull();
|
||||
});
|
||||
|
||||
it("equals 不匹配", () => {
|
||||
const result = checkSourcePort(8080, { equals: 9000 });
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.failure!.kind).toBe("mismatch");
|
||||
expect(result.failure!.phase).toBe("sourcePort");
|
||||
});
|
||||
});
|
||||
262
tests/server/checker/runner/udp/validate.test.ts
Normal file
262
tests/server/checker/runner/udp/validate.test.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
|
||||
import type { CheckerValidationInput } from "../../../../../src/server/checker/runner/types";
|
||||
|
||||
import { validateUdpConfig } from "../../../../../src/server/checker/runner/udp/validate";
|
||||
|
||||
describe("validateUdpConfig", () => {
|
||||
const makeInput = (overrides: {
|
||||
defaults?: Record<string, unknown>;
|
||||
targets?: Array<Record<string, unknown>>;
|
||||
}): CheckerValidationInput => ({
|
||||
defaults: overrides.defaults ?? {},
|
||||
targets: (overrides.targets ?? []) as CheckerValidationInput["targets"],
|
||||
});
|
||||
|
||||
it("accepts minimal valid UDP target", () => {
|
||||
const issues = validateUdpConfig(
|
||||
makeInput({
|
||||
targets: [{ id: "test", type: "udp", udp: { host: "127.0.0.1", port: 53 } }],
|
||||
}),
|
||||
);
|
||||
expect(issues).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("reports required when udp.host is missing", () => {
|
||||
const issues = validateUdpConfig(
|
||||
makeInput({
|
||||
targets: [{ id: "test", type: "udp", udp: { port: 53 } }],
|
||||
}),
|
||||
);
|
||||
expect(issues).toHaveLength(1);
|
||||
expect(issues[0]!.code).toBe("required");
|
||||
expect(issues[0]!.path).toContain("host");
|
||||
});
|
||||
|
||||
it("reports required when udp.port is missing", () => {
|
||||
const issues = validateUdpConfig(
|
||||
makeInput({
|
||||
targets: [{ id: "test", type: "udp", udp: { host: "127.0.0.1" } }],
|
||||
}),
|
||||
);
|
||||
expect(issues).toHaveLength(1);
|
||||
expect(issues[0]!.code).toBe("required");
|
||||
expect(issues[0]!.path).toContain("port");
|
||||
});
|
||||
|
||||
it("rejects invalid port values", () => {
|
||||
for (const port of [0, -1, 65536, 1.5]) {
|
||||
const issues = validateUdpConfig(
|
||||
makeInput({
|
||||
targets: [{ id: "test", type: "udp", udp: { host: "127.0.0.1", port } }],
|
||||
}),
|
||||
);
|
||||
expect(issues.length).toBeGreaterThanOrEqual(1);
|
||||
expect(issues.some((i) => i.code === "invalid-value" && i.path.includes("port"))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("reports required when udp section is missing", () => {
|
||||
const issues = validateUdpConfig(
|
||||
makeInput({
|
||||
targets: [{ id: "test", type: "udp" }],
|
||||
}),
|
||||
);
|
||||
expect(issues).toHaveLength(1);
|
||||
expect(issues[0]!.code).toBe("required");
|
||||
expect(issues[0]!.path).toContain("udp");
|
||||
});
|
||||
|
||||
it("accepts valid defaults.udp with encoding, responseEncoding, maxResponseBytes", () => {
|
||||
const issues = validateUdpConfig(
|
||||
makeInput({
|
||||
defaults: {
|
||||
udp: { encoding: "hex", maxResponseBytes: 1024, responseEncoding: "text" },
|
||||
},
|
||||
targets: [{ id: "test", type: "udp", udp: { host: "127.0.0.1", port: 53 } }],
|
||||
}),
|
||||
);
|
||||
expect(issues).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("reports unknown-field in defaults.udp", () => {
|
||||
const issues = validateUdpConfig(
|
||||
makeInput({
|
||||
defaults: { udp: { unknownField: true } },
|
||||
targets: [{ id: "test", type: "udp", udp: { host: "127.0.0.1", port: 53 } }],
|
||||
}),
|
||||
);
|
||||
expect(issues).toHaveLength(1);
|
||||
expect(issues[0]!.code).toBe("unknown-field");
|
||||
});
|
||||
|
||||
it("reports invalid-value for udp.encoding with bad value", () => {
|
||||
const issues = validateUdpConfig(
|
||||
makeInput({
|
||||
targets: [
|
||||
{
|
||||
id: "test",
|
||||
type: "udp",
|
||||
udp: { encoding: "json", host: "127.0.0.1", port: 53 },
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(issues.some((i) => i.code === "invalid-value" && i.path.includes("encoding"))).toBe(true);
|
||||
});
|
||||
|
||||
it("reports invalid-value for udp.responseEncoding with bad value", () => {
|
||||
const issues = validateUdpConfig(
|
||||
makeInput({
|
||||
targets: [
|
||||
{
|
||||
id: "test",
|
||||
type: "udp",
|
||||
udp: { host: "127.0.0.1", port: 53, responseEncoding: "binary" },
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(issues.some((i) => i.code === "invalid-value" && i.path.includes("responseEncoding"))).toBe(true);
|
||||
});
|
||||
|
||||
it("reports invalid-value for hex payload that is not valid hex", () => {
|
||||
const issues = validateUdpConfig(
|
||||
makeInput({
|
||||
targets: [
|
||||
{
|
||||
id: "test",
|
||||
type: "udp",
|
||||
udp: { encoding: "hex", host: "127.0.0.1", payload: "ZZZZ", port: 53 },
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(issues.some((i) => i.code === "invalid-value" && i.path.includes("payload"))).toBe(true);
|
||||
});
|
||||
|
||||
it("reports invalid-value for base64 payload that is not valid base64", () => {
|
||||
const issues = validateUdpConfig(
|
||||
makeInput({
|
||||
targets: [
|
||||
{
|
||||
id: "test",
|
||||
type: "udp",
|
||||
udp: { encoding: "base64", host: "127.0.0.1", payload: "!!!notbase64", port: 53 },
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(issues.some((i) => i.code === "invalid-value" && i.path.includes("payload"))).toBe(true);
|
||||
});
|
||||
|
||||
it("reports unknown-field for unknown key in udp group", () => {
|
||||
const issues = validateUdpConfig(
|
||||
makeInput({
|
||||
targets: [
|
||||
{
|
||||
id: "test",
|
||||
type: "udp",
|
||||
udp: { bogus: true, host: "127.0.0.1", port: 53 },
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(issues.some((i) => i.code === "unknown-field" && i.path.includes("bogus"))).toBe(true);
|
||||
});
|
||||
|
||||
it("reports unknown-field for unknown key in expect", () => {
|
||||
const issues = validateUdpConfig(
|
||||
makeInput({
|
||||
targets: [
|
||||
{
|
||||
expect: { unknownExpect: true },
|
||||
id: "test",
|
||||
type: "udp",
|
||||
udp: { host: "127.0.0.1", port: 53 },
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(issues.some((i) => i.code === "unknown-field" && i.path.includes("unknownExpect"))).toBe(true);
|
||||
});
|
||||
|
||||
it("reports conflict when expect.responded=false with expect.response", () => {
|
||||
const issues = validateUdpConfig(
|
||||
makeInput({
|
||||
targets: [
|
||||
{
|
||||
expect: { responded: false, response: [{ type: "contains", value: "ok" }] },
|
||||
id: "test",
|
||||
type: "udp",
|
||||
udp: { host: "127.0.0.1", port: 53 },
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(issues.some((i) => i.path.includes("responded") && i.message.includes("响应内容"))).toBe(true);
|
||||
});
|
||||
|
||||
it("reports conflict when expect.responded=false with expect.sourceHost", () => {
|
||||
const issues = validateUdpConfig(
|
||||
makeInput({
|
||||
targets: [
|
||||
{
|
||||
expect: { responded: false, sourceHost: { eq: "1.2.3.4" } },
|
||||
id: "test",
|
||||
type: "udp",
|
||||
udp: { host: "127.0.0.1", port: 53 },
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(issues.some((i) => i.path.includes("responded") && i.message.includes("响应来源"))).toBe(true);
|
||||
});
|
||||
|
||||
it("reports invalid-type for negative expect.maxDurationMs", () => {
|
||||
const issues = validateUdpConfig(
|
||||
makeInput({
|
||||
targets: [
|
||||
{
|
||||
expect: { maxDurationMs: -100 },
|
||||
id: "test",
|
||||
type: "udp",
|
||||
udp: { host: "127.0.0.1", port: 53 },
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(issues.some((i) => i.code === "invalid-type" && i.path.includes("maxDurationMs"))).toBe(true);
|
||||
});
|
||||
|
||||
it("reports invalid-type for non-boolean expect.responded", () => {
|
||||
const issues = validateUdpConfig(
|
||||
makeInput({
|
||||
targets: [
|
||||
{
|
||||
expect: { responded: "yes" },
|
||||
id: "test",
|
||||
type: "udp",
|
||||
udp: { host: "127.0.0.1", port: 53 },
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(issues.some((i) => i.code === "invalid-type" && i.path.includes("responded"))).toBe(true);
|
||||
});
|
||||
|
||||
it("reports invalid-value for negative maxResponseBytes", () => {
|
||||
const issues = validateUdpConfig(
|
||||
makeInput({
|
||||
targets: [
|
||||
{
|
||||
id: "test",
|
||||
type: "udp",
|
||||
udp: { host: "127.0.0.1", maxResponseBytes: -1, port: 53 },
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(issues.some((i) => i.code === "invalid-value" && i.path.includes("maxResponseBytes"))).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user