feat(zookeeper): support cluster authentication setup

This commit is contained in:
Jevin authored and GitHub committed 2026-08-03 16:58:47 +08:00
1 parent 50cd9a8f95
commit db791b5aed
13 files changed
+242 -3

No files matched your search

@@ -2,7 +2,56 @@ import { describe, expect, it } from "vitest";
import { parseConnectionUrl } from "@/lib/connection/connectionUrl";
describe("ZooKeeper connection URLs", () => {
it("parses a two-node ensemble and uses the first endpoint for host and port", () => {
const parsed = parseConnectionUrl("zookeeper://zk-1:2181,zk-2:2182");
expect(parsed.host).toBe("zk-1");
expect(parsed.port).toBe(2181);
expect(parsed.connectionString).toBe("zk-1:2181,zk-2:2182");
});
it("parses three nodes and preserves query parameters except name", () => {
const parsed = parseConnectionUrl("zookeeper://zk-1:2181,zk-2:2182,zk-3:2183?session_timeout=30000&name=Production");
expect(parsed.name).toBe("Production");
expect(parsed.urlParams).toBe("session_timeout=30000");
expect(parsed.connectionString).toBe("zk-1:2181,zk-2:2182,zk-3:2183");
});
it("preserves a shared chroot separately from query parameters", () => {
const parsed = parseConnectionUrl("zookeeper://zk-1:2181,zk-2:2181/services/app?connect_timeout=5000");
expect(parsed.connectionString).toBe("zk-1:2181,zk-2:2181/services/app");
expect(parsed.urlParams).toBe("connect_timeout=5000");
});
it("decodes credentials that apply to the ensemble", () => {
const parsed = parseConnectionUrl("zookeeper://dbx%40ops:p%40ss@zk-1:2181,zk-2:2181/app");
expect(parsed.username).toBe("dbx@ops");
expect(parsed.password).toBe("p@ss");
expect(parsed.connectionString).toBe("zk-1:2181,zk-2:2181/app");
});
it("supports bracketed IPv6 endpoints without confusing address colons with separators", () => {
const parsed = parseConnectionUrl("zookeeper://[2001:db8::1]:2181,[2001:db8::2]:2281/app");
expect(parsed.host).toBe("2001:db8::1");
expect(parsed.port).toBe(2181);
expect(parsed.connectionString).toBe("[2001:db8::1]:2181,[2001:db8::2]:2281/app");
});
it("rejects malformed or empty ensemble endpoints", () => {
expect(() => parseConnectionUrl("zookeeper://zk-1:2181,,zk-2:2181")).toThrow("Invalid connection URL");
expect(() => parseConnectionUrl("zookeeper://zk-1:not-a-port,zk-2:2181")).toThrow("Invalid connection URL");
expect(() => parseConnectionUrl("zookeeper://2001:db8::1:2181,zk-2:2181")).toThrow("Invalid connection URL");
});
it("preserves the host:port chroot path as a ZooKeeper connect string", () => {
expect(parseConnectionUrl("zookeeper://zk-main:2181/app").connectionString).toBe("zk-main:2181/app");
const parsed = parseConnectionUrl("zookeeper://zk-main:2181/app");
expect(parsed.host).toBe("zk-main");
expect(parsed.port).toBe(2181);
expect(parsed.connectionString).toBe("zk-main:2181/app");
});
});
@@ -0,0 +1,32 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const dialogSource = readFileSync(new URL("../../../components/connection/ConnectionDialog.vue", import.meta.url), "utf8");
const enSource = readFileSync(new URL("../../../i18n/locales/en.ts", import.meta.url), "utf8");
const zhCnSource = readFileSync(new URL("../../../i18n/locales/zh-CN.ts", import.meta.url), "utf8");
const fallbackLocaleSources = ["es", "it", "ja", "ko", "pt-BR", "zh-TW"].map((locale) => readFileSync(new URL(`../../../i18n/locales/${locale}.ts`, import.meta.url), "utf8"));
describe("ZooKeeper connection dialog", () => {
it("binds the existing Select component to the ZooKeeper auth_scheme helper", () => {
expect(dialogSource).toContain("setZooKeeperAuthScheme");
expect(dialogSource).toContain("resolveZooKeeperAuthScheme");
expect(dialogSource).toContain("const zookeeperAuthScheme = computed<ZooKeeperAuthScheme>");
expect(dialogSource).toContain('<Select v-model="zookeeperAuthScheme">');
expect(dialogSource).toContain('<SelectItem value="digest">');
expect(dialogSource).toContain('<SelectItem value="sasl_digest">');
});
it("uses localized auth labels and tells users both cluster input locations", () => {
for (const source of [enSource, zhCnSource]) {
expect(source).toContain("zookeeperAuthMethod:");
expect(source).toContain("zookeeperAuthDigest:");
expect(source).toContain("zookeeperAuthSaslDigest:");
expect(source).toContain("zookeeperClusterInputHint:");
}
for (const source of fallbackLocaleSources) {
expect(source).toContain("withEnglishFallback");
}
expect(dialogSource).toContain('t("connection.zookeeperAuthMethod")');
expect(dialogSource).toContain('t("connection.zookeeperClusterInputHint")');
});
});
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { setZooKeeperAuthScheme, zooKeeperAuthScheme } from "@/lib/zookeeper/zookeeperConnectionOptions";
describe("ZooKeeper connection options", () => {
it("defaults to digest when auth_scheme is absent", () => {
expect(zooKeeperAuthScheme("connect_timeout=10")).toBe("digest");
});
it("reads the supported SASL DIGEST-MD5 scheme", () => {
expect(zooKeeperAuthScheme("connect_timeout=10&auth_scheme=sasl_digest")).toBe("sasl_digest");
});
it("writes sasl_digest without losing unrelated URL parameters", () => {
expect(setZooKeeperAuthScheme("connect_timeout=10&session_timeout=20", "sasl_digest")).toBe("connect_timeout=10&session_timeout=20&auth_scheme=sasl_digest");
});
it("switches back to digest by removing only auth_scheme", () => {
expect(setZooKeeperAuthScheme("connect_timeout=10&auth_scheme=sasl_digest&session_timeout=20", "digest")).toBe("connect_timeout=10&session_timeout=20");
});
it("reads legacy semicolon-separated parameters and case-insensitive keys", () => {
expect(zooKeeperAuthScheme("connect_timeout=10;AUTH_SCHEME=sasl_digest;session_timeout=20")).toBe("sasl_digest");
});
it("removes every auth_scheme alias while preserving unrelated legacy parameters", () => {
expect(setZooKeeperAuthScheme("connect_timeout=10;AUTH_SCHEME=digest&auth_scheme=sasl_digest;session_timeout=20", "digest")).toBe("connect_timeout=10&session_timeout=20");
});
});
@@ -171,6 +171,56 @@ function databaseFromPath(pathname: string): string | undefined {
return decodeUrlPart(value.split("/")[0]);
}
function parseZooKeeperUrl(source: string): ParsedConnectionUrl | null {
const match = source.match(/^zookeeper:\/\/([^/?#]+)(\/[^?#]*)?(\?[^#]*)?$/i);
if (!match) return null;
const profile = SCHEME_PROFILES.zookeeper;
const authority = match[1];
const userInfoEnd = authority.lastIndexOf("@");
const userInfo = userInfoEnd >= 0 ? authority.slice(0, userInfoEnd) : "";
const endpointList = userInfoEnd >= 0 ? authority.slice(userInfoEnd + 1) : authority;
const [rawUsername, ...rawPasswordParts] = userInfo.split(":");
const username = userInfo ? decodeUrlPart(rawUsername) : "";
const password = userInfo ? decodeUrlPart(rawPasswordParts.join(":")) : "";
const endpoints = endpointList.split(",").map((endpoint) => endpoint.trim());
if (endpoints.some((endpoint) => !endpoint)) throw new Error("Invalid connection URL");
const normalizedEndpoints = endpoints.map((endpoint) => {
let endpointUrl: URL;
try {
endpointUrl = new URL(`zookeeper://${endpoint}`);
} catch {
throw new Error("Invalid connection URL");
}
if (endpointUrl.username || endpointUrl.password || (endpointUrl.pathname && endpointUrl.pathname !== "/") || endpointUrl.search || endpointUrl.hash) {
throw new Error("Invalid connection URL");
}
const rawHost = endpointUrl.hostname.replace(/^\[(.*)]$/, "$1");
const host = rawHost.includes(":") ? `[${rawHost}]` : rawHost;
const port = endpointUrl.port ? Number(endpointUrl.port) : profile.defaultPort;
return { host: rawHost, port, connectString: `${host}:${port}` };
});
const chroot = match[2] && match[2] !== "/" ? match[2] : "";
const urlParams = (match[3] || "").replace(/^\?/, "");
const name = queryParamValue(urlParams, "name")?.trim();
return {
...(name ? { name } : {}),
dbType: profile.type,
driverProfile: profile.profile,
driverLabel: profile.label,
host: normalizedEndpoints[0].host,
port: normalizedEndpoints[0].port,
username,
password,
database: undefined,
urlParams: stripConnectionNameParam(urlParams),
ssl: false,
connectionString: `${normalizedEndpoints.map((endpoint) => endpoint.connectString).join(",")}${chroot}`,
};
}
function queryParamValue(params: string, key: string): string | undefined {
for (const part of params.split(/[&;]/)) {
if (!part) continue;
@@ -524,6 +574,9 @@ export function parseConnectionUrl(value: string, preferredProfile?: string): Pa
const mongoResult = parseMongoUrl(source);
if (mongoResult) return mongoResult;
const zooKeeperResult = parseZooKeeperUrl(source);
if (zooKeeperResult) return zooKeeperResult;
let parsed: URL;
try {
parsed = new URL(source);
@@ -0,0 +1,25 @@
export type ZooKeeperAuthScheme = "digest" | "sasl_digest";
const AUTH_SCHEME_PARAM = "auth_scheme";
function parseParams(params?: string): URLSearchParams {
return new URLSearchParams((params || "").trim().replace(/^\?/, "").replace(/;/g, "&"));
}
function isAuthSchemeParam(key: string): boolean {
return key.trim().toLowerCase() === AUTH_SCHEME_PARAM;
}
export function zooKeeperAuthScheme(params?: string): ZooKeeperAuthScheme {
const configured = Array.from(parseParams(params).entries()).find(([key]) => isAuthSchemeParam(key))?.[1];
return configured?.toLowerCase() === "sasl_digest" ? "sasl_digest" : "digest";
}
export function setZooKeeperAuthScheme(params: string | undefined, scheme: ZooKeeperAuthScheme): string {
const parsed = parseParams(params);
for (const key of Array.from(parsed.keys())) {
if (isAuthSchemeParam(key)) parsed.delete(key);
}
if (scheme === "sasl_digest") parsed.set(AUTH_SCHEME_PARAM, scheme);
return parsed.toString();
}