1
0

feat: 重构配置校验为 TypeBox + Ajv + semantic validator,严格禁止未知字段

- 新增 config-contract 模块(TypeBox fragments、Ajv 契约校验、ConfigValidationIssue)
- CheckerDefinition 扩展为含 configKey、schemas、validate 的完整插件接口
- HTTP/Command 各自维护 contract.ts + validate.ts,校验从 resolve 中分离
- resolve 不再承担校验,只做默认值合并和路径/单位解析
- config-loader 流程: unknown → RawProbeConfig → ValidatedProbeConfig → ResolvedConfig
- 导出 probe-config.schema.json,新增 schema/schema:check 脚本
- 更新 DEVELOPMENT.md 新增 1.7 开发新 Checker 完整指引
- 同步更新 4 个 main specs(probe-config、command-checker、expect-body-checkers、checker-runner-abstraction)
This commit is contained in:
2026-05-13 12:19:36 +08:00
parent bce0f8e7a8
commit 7b20b59b79
38 changed files with 3034 additions and 675 deletions

View File

@@ -40,6 +40,11 @@ describe("parseDuration", () => {
expect(parseDuration("1.5s")).toBe(1500);
});
test("拒绝非正整数毫秒结果", () => {
expect(() => parseDuration("0ms")).toThrow("正整数毫秒");
expect(() => parseDuration("1.5ms")).toThrow("正整数毫秒");
});
test("无效格式抛出错误", () => {
expect(() => parseDuration("30")).toThrow("无效的时长格式");
expect(() => parseDuration("abc")).toThrow("无效的时长格式");
@@ -70,6 +75,19 @@ describe("loadConfig", () => {
await rm(tempDir, { force: true, recursive: true });
});
async function expectConfigError(fileName: string, content: string, message: string): Promise<void> {
const configPath = join(tempDir, fileName);
await writeFile(configPath, content);
let error: unknown;
try {
await loadConfig(configPath);
} catch (caught) {
error = caught;
}
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toContain(message);
}
test("解析最简 HTTP 配置", async () => {
const configPath = join(tempDir, "minimal-http.yaml");
await writeFile(
@@ -310,7 +328,7 @@ targets:
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("ignoreSSL 必须为布尔值");
await expect(loadConfig(configPath)).rejects.toThrow("http.ignoreSSL 类型不合法");
});
test("HTTP target maxRedirects 非负整数校验", async () => {
@@ -326,7 +344,7 @@ targets:
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("maxRedirects 必须为非负整数");
await expect(loadConfig(configPath)).rejects.toThrow("http.maxRedirects 类型不合法");
});
test("HTTP target status 模式非法抛出错误", async () => {
@@ -413,7 +431,7 @@ targets:
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("无效端口号");
await expect(loadConfig(configPath)).rejects.toThrow("server.port 数值范围不合法");
});
test("非法 maxConcurrentChecks 抛出错误", async () => {
@@ -430,7 +448,7 @@ targets:
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("maxConcurrentChecks 必须为正整数");
await expect(loadConfig(configPath)).rejects.toThrow("runtime.maxConcurrentChecks 数值范围不合法");
});
test("非法 size 格式抛出错误", async () => {
@@ -623,7 +641,7 @@ targets:
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("group 字段必须为字符串");
await expect(loadConfig(configPath)).rejects.toThrow("group 必须为字符串");
});
test("HTTP headers 非字符串值抛出错误", async () => {
@@ -656,7 +674,7 @@ targets:
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("http.body 必须为字符串");
await expect(loadConfig(configPath)).rejects.toThrow("http.body 类型不合法");
});
test("maxBodyBytes 负数抛出错误", async () => {
@@ -930,7 +948,7 @@ targets:
await expect(loadConfig(configPath)).rejects.toThrow("exists 必须为布尔值");
});
test("未知字段忽略不影响启动", async () => {
test("未知字段导致启动失败", async () => {
const configPath = join(tempDir, "unknown-fields.yaml");
await writeFile(
configPath,
@@ -948,12 +966,8 @@ targets:
note: "ignored"
`,
);
const config = await loadConfig(configPath);
expect(config.targets).toHaveLength(1);
const t = config.targets[0]!;
if (t.type === "http") {
expect(t.expect?.status).toEqual([200]);
}
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("unknownHttpField 是未知字段");
});
test("xpath path 非空字符串校验", async () => {
@@ -989,6 +1003,228 @@ targets:
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("expect.headers 必须为对象");
await expect(loadConfig(configPath)).rejects.toThrow("expect.headers 类型不合法");
});
test("HTTP method 小写输入失败", async () => {
await expectConfigError(
"lowercase-method.yaml",
`targets:
- name: "test"
type: http
http:
url: "http://example.com"
method: get
`,
"http.method 不在允许范围内",
);
});
test("defaults.http.method 小写输入失败", async () => {
await expectConfigError(
"lowercase-default-method.yaml",
`defaults:
http:
method: post
targets:
- name: "test"
type: http
http:
url: "http://example.com"
`,
"defaults.http.method 不在允许范围内",
);
});
test("HTTP method 大写输入通过", async () => {
const configPath = join(tempDir, "uppercase-method.yaml");
await writeFile(
configPath,
`targets:
- name: "test"
type: http
http:
url: "http://example.com"
method: POST
`,
);
const config = await loadConfig(configPath);
const target = config.targets[0]!;
expect(target.type).toBe("http");
if (target.type === "http") expect(target.http.method).toBe("POST");
});
test("动态 headers 和 env 允许任意键名", async () => {
const configPath = join(tempDir, "dynamic-maps.yaml");
await writeFile(
configPath,
`defaults:
http:
headers:
X-Default-Header: "default"
targets:
- name: "http-test"
type: http
http:
url: "http://example.com"
headers:
X-Custom-Header: "custom"
expect:
headers:
X-Response-Header:
contains: "ok"
- name: "cmd-test"
type: command
command:
exec: "true"
env:
CUSTOM_ENV_NAME: "custom"
`,
);
const config = await loadConfig(configPath);
const http = config.targets[0]!;
const command = config.targets[1]!;
expect(http.type).toBe("http");
expect(command.type).toBe("command");
if (http.type === "http") {
expect(http.http.headers["X-Default-Header"]).toBe("default");
expect(http.http.headers["X-Custom-Header"]).toBe("custom");
}
if (command.type === "command") expect(command.command.env["CUSTOM_ENV_NAME"]).toBe("custom");
});
test("command args 类型非法", async () => {
await expectConfigError(
"bad-command-args.yaml",
`targets:
- name: "cmd"
type: command
command:
exec: "echo"
args: "hello"
`,
"command.args 类型不合法",
);
});
test("command cwd 类型非法", async () => {
await expectConfigError(
"bad-command-cwd.yaml",
`targets:
- name: "cmd"
type: command
command:
exec: "echo"
cwd: 123
`,
"command.cwd 类型不合法",
);
});
test("command env 值类型非法", async () => {
await expectConfigError(
"bad-command-env.yaml",
`targets:
- name: "cmd"
type: command
command:
exec: "echo"
env:
COUNT: 123
`,
"command.env.COUNT 类型不合法",
);
});
test("command maxOutputBytes 非法", async () => {
await expectConfigError(
"bad-command-max-output.yaml",
`targets:
- name: "cmd"
type: command
command:
exec: "echo"
maxOutputBytes: "1TB"
`,
"maxOutputBytes 无效的 size 格式",
);
});
test("command expect exitCode 类型非法", async () => {
await expectConfigError(
"bad-command-exit-code.yaml",
`targets:
- name: "cmd"
type: command
command:
exec: "echo"
expect:
exitCode: [1.5]
`,
"expect.exitCode[0] 类型不合法",
);
});
test("command stdout 空 text rule 非法", async () => {
await expectConfigError(
"bad-command-stdout-empty.yaml",
`targets:
- name: "cmd"
type: command
command:
exec: "echo"
expect:
stdout:
- {}
`,
"stdout[0] 必须包含至少一个合法 operator",
);
});
test("command stderr 未知 operator 非法", async () => {
await expectConfigError(
"bad-command-stderr-operator.yaml",
`targets:
- name: "cmd"
type: command
command:
exec: "echo"
expect:
stderr:
- foo: "bar"
`,
"expect.stderr[0].foo 是未知字段",
);
});
test("command stdout match 正则非法", async () => {
await expectConfigError(
"bad-command-stdout-regex.yaml",
`targets:
- name: "cmd"
type: command
command:
exec: "echo"
expect:
stdout:
- match: "[invalid"
`,
"stdout[0].match 正则不合法",
);
});
test("command expect 未知字段失败", async () => {
await expectConfigError(
"bad-command-expect-unknown.yaml",
`targets:
- name: "cmd"
type: command
command:
exec: "echo"
expect:
status: [200]
`,
"expect.status 是未知字段",
);
});
});