feat(zookeeper): support cluster authentication setup
This commit is contained in:
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();
|
||||
}
|
||||
Reference in new issue
Block a user