409 lines
14 KiB
TypeScript
409 lines
14 KiB
TypeScript
import { describe, expect, test } from "bun:test";
|
||
|
||
import { extractVariables, resolveVariables } from "../../../src/server/checker/variables";
|
||
|
||
describe("config variables", () => {
|
||
test("提取合法 variables 类型", () => {
|
||
const result = extractVariables({ variables: { enabled: true, host: "example.com", port: 5432 } });
|
||
|
||
expect(result.issues).toHaveLength(0);
|
||
expect(Object.fromEntries(result.variables)).toEqual({ enabled: true, host: "example.com", port: 5432 });
|
||
});
|
||
|
||
test("拒绝非法 variables value 和 key", () => {
|
||
const result = extractVariables({
|
||
variables: {
|
||
"123start": "value",
|
||
empty: null,
|
||
list: [1, 2, 3],
|
||
obj: { a: 1 },
|
||
},
|
||
});
|
||
|
||
expect(result.issues.map((item) => [item.code, item.path, item.message])).toEqual([
|
||
["invalid-format", "variables.123start", "变量名不符合命名规则"],
|
||
["invalid-type", "variables.empty", "变量值不允许为 null"],
|
||
["invalid-type", "variables.list", "变量值不允许为 array"],
|
||
["invalid-type", "variables.obj", "变量值不允许为 object"],
|
||
]);
|
||
});
|
||
|
||
test("解析简单引用、默认值、转义、多变量拼接和无引用字符串", () => {
|
||
const result = resolveVariables({
|
||
targets: [
|
||
{
|
||
http: {
|
||
body: "Hello $${name}",
|
||
headers: {
|
||
Authorization: "Bearer ${token}",
|
||
Pattern: "${PATTERN|foo|bar}",
|
||
},
|
||
url: "${protocol}://${host}:${port}/api",
|
||
},
|
||
id: "api-health",
|
||
name: "API",
|
||
type: "http",
|
||
},
|
||
],
|
||
variables: { host: "example.com", port: 443, protocol: "https", token: "abc" },
|
||
});
|
||
|
||
expect(result.issues).toHaveLength(0);
|
||
const target = (
|
||
result.config as { targets: Array<{ http: { body: string; headers: Record<string, string>; url: string } }> }
|
||
).targets[0]!;
|
||
expect(target.http.url).toBe("https://example.com:443/api");
|
||
expect(target.http.headers["Authorization"]).toBe("Bearer abc");
|
||
expect(target.http.headers["Pattern"]).toBe("foo|bar");
|
||
expect(target.http.body).toBe("Hello ${name}");
|
||
});
|
||
|
||
test("完整引用保留类型,部分引用强制为字符串", () => {
|
||
const result = resolveVariables({
|
||
targets: [
|
||
{
|
||
http: {
|
||
body: "port: ${port}",
|
||
ignoreSSL: "${ssl}",
|
||
maxRedirects: "${port}",
|
||
url: "${host}",
|
||
},
|
||
id: "typed-http",
|
||
type: "http",
|
||
},
|
||
],
|
||
variables: { host: "https://example.com", port: 5, ssl: true },
|
||
});
|
||
|
||
expect(result.issues).toHaveLength(0);
|
||
const http = (result.config as { targets: Array<{ http: Record<string, unknown> }> }).targets[0]!.http;
|
||
expect(http["url"]).toBe("https://example.com");
|
||
expect(http["maxRedirects"]).toBe(5);
|
||
expect(http["ignoreSSL"]).toBe(true);
|
||
expect(http["body"]).toBe("port: 5");
|
||
});
|
||
|
||
test("环境变量和默认值在完整引用时做类型推断", () => {
|
||
const originalMaxRedirects = process.env["MAX_REDIRECTS"];
|
||
const originalIgnoreSsl = process.env["IGNORE_SSL"];
|
||
process.env["MAX_REDIRECTS"] = "5";
|
||
process.env["IGNORE_SSL"] = "false";
|
||
|
||
try {
|
||
const result = resolveVariables({
|
||
targets: [
|
||
{
|
||
http: {
|
||
ignoreSSL: "${IGNORE_SSL}",
|
||
maxRedirects: "${MAX_REDIRECTS}",
|
||
url: "${HOST|localhost}",
|
||
},
|
||
id: "env-http",
|
||
type: "http",
|
||
},
|
||
],
|
||
});
|
||
|
||
expect(result.issues).toHaveLength(0);
|
||
const http = (result.config as { targets: Array<{ http: Record<string, unknown> }> }).targets[0]!.http;
|
||
expect(http["maxRedirects"]).toBe(5);
|
||
expect(http["ignoreSSL"]).toBe(false);
|
||
expect(http["url"]).toBe("localhost");
|
||
} finally {
|
||
restoreEnv("MAX_REDIRECTS", originalMaxRedirects);
|
||
restoreEnv("IGNORE_SSL", originalIgnoreSsl);
|
||
}
|
||
});
|
||
|
||
test("解析优先级为 variables、环境变量、默认值", () => {
|
||
const originalPort = process.env["PORT"];
|
||
const originalHost = process.env["HOST"];
|
||
process.env["PORT"] = "3000";
|
||
process.env["HOST"] = "env-host";
|
||
|
||
try {
|
||
const result = resolveVariables({
|
||
targets: [
|
||
{
|
||
http: {
|
||
body: "${MISSING|fallback}",
|
||
headers: { Host: "${HOST}" },
|
||
maxRedirects: "${PORT}",
|
||
url: "${HOST_FROM_VARIABLES}",
|
||
},
|
||
id: "priority-http",
|
||
type: "http",
|
||
},
|
||
],
|
||
variables: { HOST_FROM_VARIABLES: "config-host", PORT: 5432 },
|
||
});
|
||
|
||
expect(result.issues).toHaveLength(0);
|
||
const http = (result.config as { targets: Array<{ http: Record<string, unknown> }> }).targets[0]!.http;
|
||
expect(http["maxRedirects"]).toBe(5432);
|
||
expect((http["headers"] as Record<string, string>)["Host"]).toBe("env-host");
|
||
expect(http["body"]).toBe("fallback");
|
||
expect(http["url"]).toBe("config-host");
|
||
} finally {
|
||
restoreEnv("PORT", originalPort);
|
||
restoreEnv("HOST", originalHost);
|
||
}
|
||
});
|
||
|
||
test("替换范围覆盖 server、probes、targets,并跳过 variables 和 target 顶层 id/type", () => {
|
||
const result = resolveVariables({
|
||
defaults: { interval: "${interval}" },
|
||
probes: { execution: { maxConcurrentChecks: "${maxConcurrentChecks}" } },
|
||
server: { host: "${host}", logging: { level: "${logLevel}" }, storage: { retention: "${retention}" } },
|
||
targets: [
|
||
{
|
||
cmd: {
|
||
args: ["--host", "${host}"],
|
||
env: { "${TOKEN_KEY}": "${token}" },
|
||
exec: "echo",
|
||
},
|
||
expect: { rows: [{ id: { equals: "${expectedId}" }, type: { equals: "${expectedType}" } }] },
|
||
id: "${id}",
|
||
type: "${type}",
|
||
},
|
||
],
|
||
variables: {
|
||
expectedId: 1,
|
||
expectedType: "service",
|
||
host: "localhost",
|
||
id: "resolved",
|
||
interval: "30s",
|
||
logLevel: "debug",
|
||
maxConcurrentChecks: 20,
|
||
retention: "24h",
|
||
token: "abc",
|
||
TOKEN_KEY: "TOKEN",
|
||
type: "cmd",
|
||
},
|
||
});
|
||
|
||
expect(result.issues).toHaveLength(0);
|
||
const config = result.config as {
|
||
defaults: { interval: string };
|
||
probes: { execution: { maxConcurrentChecks: number } };
|
||
server: { host: string; logging: { level: string }; storage: { retention: string } };
|
||
targets: Array<{
|
||
cmd: { args: string[]; env: Record<string, string> };
|
||
expect: { rows: Array<{ id: { equals: number }; type: { equals: string } }> };
|
||
id: string;
|
||
type: string;
|
||
}>;
|
||
variables: { host: string };
|
||
};
|
||
expect(config.server.host).toBe("localhost");
|
||
expect(config.server.logging.level).toBe("debug");
|
||
expect(config.server.storage.retention).toBe("24h");
|
||
expect(config.probes.execution.maxConcurrentChecks).toBe(20);
|
||
expect(config.variables.host).toBe("localhost");
|
||
expect(config.defaults.interval).toBe("${interval}");
|
||
expect(config.targets[0]!.id).toBe("${id}");
|
||
expect(config.targets[0]!.type).toBe("${type}");
|
||
expect(config.targets[0]!.cmd.args[1]).toBe("localhost");
|
||
expect(config.targets[0]!.cmd.env["${TOKEN_KEY}"]).toBe("abc");
|
||
expect(config.targets[0]!.expect.rows[0]!.id.equals).toBe(1);
|
||
expect(config.targets[0]!.expect.rows[0]!.type.equals).toBe("service");
|
||
});
|
||
|
||
test("默认值推断为 boolean(true/false)", () => {
|
||
const result = resolveVariables({
|
||
targets: [
|
||
{
|
||
http: {
|
||
ignoreSSL: "${DUMMY_SSL|false}",
|
||
url: "${HOST|localhost}",
|
||
},
|
||
id: "default-bool",
|
||
type: "http",
|
||
},
|
||
],
|
||
});
|
||
|
||
expect(result.issues).toHaveLength(0);
|
||
const http = (result.config as { targets: Array<{ http: Record<string, unknown> }> }).targets[0]!.http;
|
||
expect(http["ignoreSSL"]).toBe(false);
|
||
expect(typeof http["ignoreSSL"]).toBe("boolean");
|
||
|
||
const result2 = resolveVariables({
|
||
targets: [
|
||
{
|
||
http: {
|
||
ignoreSSL: "${DUMMY_SSL|true}",
|
||
url: "${HOST|localhost}",
|
||
},
|
||
id: "default-bool-true",
|
||
type: "http",
|
||
},
|
||
],
|
||
});
|
||
|
||
expect(result2.issues).toHaveLength(0);
|
||
const http2 = (result2.config as { targets: Array<{ http: Record<string, unknown> }> }).targets[0]!.http;
|
||
expect(http2["ignoreSSL"]).toBe(true);
|
||
expect(typeof http2["ignoreSSL"]).toBe("boolean");
|
||
});
|
||
|
||
test("空默认值和空环境变量保持字符串空值", () => {
|
||
const original = process.env["EMPTY_ENV_VALUE"];
|
||
process.env["EMPTY_ENV_VALUE"] = "";
|
||
|
||
try {
|
||
const result = resolveVariables({
|
||
server: { listen: { host: "${EMPTY_ENV_VALUE}" }, logging: { file: { path: "${OPTIONAL_LOG_PATH|}" } } },
|
||
targets: [
|
||
{
|
||
http: { body: "${OPTIONAL_BODY|}", url: "${HOST|localhost}" },
|
||
id: "empty-values",
|
||
type: "http",
|
||
},
|
||
],
|
||
});
|
||
|
||
expect(result.issues).toHaveLength(0);
|
||
const config = result.config as {
|
||
server: { listen: { host: unknown }; logging: { file: { path: unknown } } };
|
||
targets: Array<{ http: { body: unknown } }>;
|
||
};
|
||
expect(config.server.listen.host).toBe("");
|
||
expect(typeof config.server.listen.host).toBe("string");
|
||
expect(config.server.logging.file.path).toBe("");
|
||
expect(typeof config.server.logging.file.path).toBe("string");
|
||
expect(config.targets[0]!.http.body).toBe("");
|
||
expect(typeof config.targets[0]!.http.body).toBe("string");
|
||
} finally {
|
||
restoreEnv("EMPTY_ENV_VALUE", original);
|
||
}
|
||
});
|
||
|
||
test("server.storage 段支持替换", () => {
|
||
const result = resolveVariables({
|
||
server: { storage: { retention: "${retention}" } },
|
||
targets: [
|
||
{
|
||
http: { url: "${host}" },
|
||
id: "rt-no-replace",
|
||
type: "http",
|
||
},
|
||
],
|
||
variables: { host: "https://example.com", retention: "24h" },
|
||
});
|
||
|
||
expect(result.issues).toHaveLength(0);
|
||
const config = result.config as { server: { storage: { retention: string } } };
|
||
expect(config.server.storage.retention).toBe("24h");
|
||
});
|
||
|
||
test("probes 段支持替换", () => {
|
||
const result = resolveVariables({
|
||
probes: { execution: { maxConcurrentChecks: "${maxConcurrentChecks}" } },
|
||
targets: [
|
||
{
|
||
http: { url: "${host}" },
|
||
id: "probes-no-replace",
|
||
type: "http",
|
||
},
|
||
],
|
||
variables: { host: "https://example.com", maxConcurrentChecks: 20 },
|
||
});
|
||
|
||
expect(result.issues).toHaveLength(0);
|
||
const config = result.config as { probes: { execution: { maxConcurrentChecks: number } } };
|
||
expect(config.probes.execution.maxConcurrentChecks).toBe(20);
|
||
});
|
||
|
||
test("server.logging 段支持替换", () => {
|
||
const result = resolveVariables({
|
||
server: { logging: { level: "${logLevel}" } },
|
||
targets: [
|
||
{
|
||
http: { url: "${host}" },
|
||
id: "logging-no-replace",
|
||
type: "http",
|
||
},
|
||
],
|
||
variables: { host: "https://example.com", logLevel: "debug" },
|
||
});
|
||
|
||
expect(result.issues).toHaveLength(0);
|
||
const config = result.config as { server: { logging: { level: string } } };
|
||
expect(config.server.logging.level).toBe("debug");
|
||
});
|
||
|
||
test("variables 段为非对象时报错", () => {
|
||
const strResult = extractVariables({ variables: "invalid" });
|
||
expect(strResult.issues).toHaveLength(1);
|
||
expect(strResult.issues[0]!.code).toBe("invalid-type");
|
||
|
||
const numResult = extractVariables({ variables: 123 });
|
||
expect(numResult.issues).toHaveLength(1);
|
||
expect(numResult.issues[0]!.code).toBe("invalid-type");
|
||
|
||
const nullResult = extractVariables({ variables: null });
|
||
expect(nullResult.issues).toHaveLength(1);
|
||
expect(nullResult.issues[0]!.code).toBe("invalid-type");
|
||
});
|
||
|
||
test("无 variables 段时环境变量仍可引用", () => {
|
||
const original = process.env["DIAL_TEST_ENV_ONLY"];
|
||
process.env["DIAL_TEST_ENV_ONLY"] = "env-value";
|
||
try {
|
||
const result = resolveVariables({
|
||
targets: [
|
||
{
|
||
http: { url: "${DIAL_TEST_ENV_ONLY}" },
|
||
id: "env-only",
|
||
type: "http",
|
||
},
|
||
],
|
||
});
|
||
|
||
expect(result.issues).toHaveLength(0);
|
||
const http = (result.config as { targets: Array<{ http: { url: string } }> }).targets[0]!.http;
|
||
expect(http.url).toBe("env-value");
|
||
} finally {
|
||
restoreEnv("DIAL_TEST_ENV_ONLY", original);
|
||
}
|
||
});
|
||
|
||
test("缺失变量收集所有 unresolved-variable issue", () => {
|
||
const result = resolveVariables({
|
||
targets: [
|
||
{
|
||
http: {
|
||
headers: { Authorization: "${missing_token}" },
|
||
url: "${missing_base_url}/health/${missing_path}",
|
||
},
|
||
id: "api-health",
|
||
type: "http",
|
||
},
|
||
],
|
||
});
|
||
|
||
expect(result.issues).toHaveLength(3);
|
||
expect(result.issues.map((item) => item.code)).toEqual([
|
||
"unresolved-variable",
|
||
"unresolved-variable",
|
||
"unresolved-variable",
|
||
]);
|
||
expect(result.issues.map((item) => item.path)).toEqual([
|
||
"targets[0].http.headers.Authorization",
|
||
"targets[0].http.url",
|
||
"targets[0].http.url",
|
||
]);
|
||
expect(result.issues.every((item) => item.targetId === "api-health")).toBe(true);
|
||
expect(result.issues.map((item) => item.message).join("\n")).toContain('变量 "missing_base_url"');
|
||
});
|
||
});
|
||
|
||
function restoreEnv(key: string, value: string | undefined): void {
|
||
if (value === undefined) {
|
||
delete process.env[key];
|
||
return;
|
||
}
|
||
process.env[key] = value;
|
||
}
|