mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-02 02:34:42 +08:00
@@ -619,4 +619,140 @@ describe("connectionStore plugin password prompt", () => {
|
||||
|
||||
await expect(store.connect(connection)).rejects.toThrow(CONNECTION_PASSWORD_REQUIRED_MESSAGE);
|
||||
});
|
||||
|
||||
it("reconnects an open plugin with its saved credential after runtime config changes", async () => {
|
||||
const connectDb = vi.fn().mockResolvedValue("ssh-1");
|
||||
const savedPassword = ["stored", "pw"].join("-");
|
||||
installApiMocks({ connectDb, listPlugins: vi.fn().mockResolvedValue([sshInstalledPlugin()]) });
|
||||
installPasswordPromptMock();
|
||||
const { useConnectionStore } = await import("@/stores/connectionStore");
|
||||
const store = useConnectionStore();
|
||||
const connection = pluginConnection({ save_password: true, password: savedPassword, external_config: { authentication: "password" } });
|
||||
store.connections = [connection];
|
||||
store.connectedIds.add(connection.id);
|
||||
|
||||
await store.updateConnection({ ...connection, host: "new.example.com" });
|
||||
|
||||
expect(connectDb).toHaveBeenCalledWith(expect.objectContaining({ id: "ssh-1", host: "new.example.com", password: savedPassword }), expect.any(Number));
|
||||
expect(requestPassword).not.toHaveBeenCalled();
|
||||
expect(store.connectedIds.has(connection.id)).toBe(true);
|
||||
});
|
||||
|
||||
it("reconnects an open plugin through its session credential without prompting", async () => {
|
||||
const connectDb = vi.fn().mockResolvedValue("ssh-1");
|
||||
const sessionCredentialStatus = vi.fn().mockResolvedValue(true);
|
||||
installApiMocks({ connectDb, sessionCredentialStatus, listPlugins: vi.fn().mockResolvedValue([sshInstalledPlugin()]) });
|
||||
installPasswordPromptMock();
|
||||
const { useConnectionStore } = await import("@/stores/connectionStore");
|
||||
const store = useConnectionStore();
|
||||
const connection = pluginConnection({ external_config: { authentication: "password" } });
|
||||
store.connections = [connection];
|
||||
store.connectedIds.add(connection.id);
|
||||
|
||||
await store.updateConnection({ ...connection, host: "new.example.com" });
|
||||
|
||||
expect(sessionCredentialStatus).toHaveBeenCalledWith(connection.id);
|
||||
expect(requestPassword).not.toHaveBeenCalled();
|
||||
expect(connectDb).toHaveBeenCalledWith(expect.objectContaining({ id: "ssh-1", host: "new.example.com", password: "" }), expect.any(Number));
|
||||
expect(store.connectedIds.has(connection.id)).toBe(true);
|
||||
});
|
||||
|
||||
it("disconnects an open plugin instead of prompting when no credential is available", async () => {
|
||||
const connectDb = vi.fn().mockResolvedValue("ssh-1");
|
||||
const disconnectDb = vi.fn().mockResolvedValue(undefined);
|
||||
const sessionCredentialStatus = vi.fn().mockResolvedValue(false);
|
||||
installApiMocks({ connectDb, disconnectDb, sessionCredentialStatus, listPlugins: vi.fn().mockResolvedValue([sshInstalledPlugin()]) });
|
||||
installPasswordPromptMock();
|
||||
const { useConnectionStore } = await import("@/stores/connectionStore");
|
||||
const store = useConnectionStore();
|
||||
const connection = pluginConnection({ external_config: { authentication: "password" } });
|
||||
store.connections = [connection];
|
||||
store.connectedIds.add(connection.id);
|
||||
|
||||
await store.updateConnection({ ...connection, host: "new.example.com" });
|
||||
|
||||
expect(requestPassword).not.toHaveBeenCalled();
|
||||
expect(connectDb).not.toHaveBeenCalled();
|
||||
expect(disconnectDb).toHaveBeenCalledWith(connection.id, undefined);
|
||||
expect(store.connectedIds.has(connection.id)).toBe(false);
|
||||
expect(store.connectionErrors[connection.id]).toContain("Reconnect manually");
|
||||
expect(store.getConfig(connection.id)?.host).toBe("new.example.com");
|
||||
});
|
||||
|
||||
it("does not connect a plugin that was offline when its config was saved", async () => {
|
||||
const connectDb = vi.fn().mockResolvedValue("ssh-1");
|
||||
const disconnectDb = vi.fn().mockResolvedValue(undefined);
|
||||
const sessionCredentialStatus = vi.fn().mockResolvedValue(true);
|
||||
installApiMocks({ connectDb, disconnectDb, sessionCredentialStatus, listPlugins: vi.fn().mockResolvedValue([sshInstalledPlugin()]) });
|
||||
installPasswordPromptMock();
|
||||
const { useConnectionStore } = await import("@/stores/connectionStore");
|
||||
const store = useConnectionStore();
|
||||
const connection = pluginConnection({ external_config: { authentication: "password" } });
|
||||
store.connections = [connection];
|
||||
|
||||
await store.updateConnection({ ...connection, host: "new.example.com" });
|
||||
|
||||
expect(connectDb).not.toHaveBeenCalled();
|
||||
expect(disconnectDb).not.toHaveBeenCalled();
|
||||
expect(sessionCredentialStatus).not.toHaveBeenCalled();
|
||||
expect(requestPassword).not.toHaveBeenCalled();
|
||||
expect(store.connectedIds.has(connection.id)).toBe(false);
|
||||
});
|
||||
|
||||
it("reconnects an open plugin when its display name changes", async () => {
|
||||
const connectDb = vi.fn().mockResolvedValue("ssh-1");
|
||||
const disconnectDb = vi.fn().mockResolvedValue(undefined);
|
||||
installApiMocks({ connectDb, disconnectDb, listPlugins: vi.fn().mockResolvedValue([sshInstalledPlugin()]) });
|
||||
installPasswordPromptMock();
|
||||
const { useConnectionStore } = await import("@/stores/connectionStore");
|
||||
const store = useConnectionStore();
|
||||
const connection = pluginConnection();
|
||||
store.connections = [connection];
|
||||
store.connectedIds.add(connection.id);
|
||||
|
||||
await store.updateConnection({ ...connection, name: "Renamed SSH" });
|
||||
|
||||
expect(connectDb).toHaveBeenCalledWith(expect.objectContaining({ id: connection.id, name: "Renamed SSH" }), expect.any(Number));
|
||||
expect(disconnectDb).not.toHaveBeenCalled();
|
||||
expect(store.connectedIds.has(connection.id)).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves native connection invalidation without reconnecting it", async () => {
|
||||
const connectDb = vi.fn().mockResolvedValue("pg-1");
|
||||
const disconnectDb = vi.fn().mockResolvedValue(undefined);
|
||||
installApiMocks({ connectDb, disconnectDb });
|
||||
installPasswordPromptMock();
|
||||
const { useConnectionStore } = await import("@/stores/connectionStore");
|
||||
const store = useConnectionStore();
|
||||
const connection = postgresConnection({ password: "" });
|
||||
store.connections = [connection];
|
||||
store.connectedIds.add(connection.id);
|
||||
|
||||
await store.updateConnection({ ...connection, host: "new.example.com" });
|
||||
|
||||
expect(connectDb).not.toHaveBeenCalled();
|
||||
expect(disconnectDb).not.toHaveBeenCalled();
|
||||
expect(store.connectedIds.has(connection.id)).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps saved plugin settings and an offline error when automatic reconnect fails", async () => {
|
||||
const connectDb = vi.fn().mockRejectedValue(new Error("sidecar rejected config"));
|
||||
const saveConnections = vi.fn().mockResolvedValue(undefined);
|
||||
const savedPassword = ["stored", "pw"].join("-");
|
||||
installApiMocks({ connectDb, saveConnections, listPlugins: vi.fn().mockResolvedValue([sshInstalledPlugin()]) });
|
||||
installPasswordPromptMock();
|
||||
const { useConnectionStore } = await import("@/stores/connectionStore");
|
||||
const store = useConnectionStore();
|
||||
const connection = pluginConnection({ save_password: true, password: savedPassword, external_config: { authentication: "password" } });
|
||||
store.connections = [connection];
|
||||
store.connectedIds.add(connection.id);
|
||||
|
||||
await expect(store.updateConnection({ ...connection, host: "new.example.com" })).resolves.toBeUndefined();
|
||||
|
||||
expect(saveConnections).toHaveBeenCalledWith([expect.objectContaining({ id: connection.id, host: "new.example.com" })]);
|
||||
expect(store.getConfig(connection.id)?.host).toBe("new.example.com");
|
||||
expect(store.connectedIds.has(connection.id)).toBe(false);
|
||||
expect(store.connectionErrors[connection.id]).toContain("settings were saved");
|
||||
expect(store.connectionErrors[connection.id]).toContain("sidecar rejected config");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -432,6 +432,7 @@ type BeforeConnectHandler = (config: ConnectionConfig) => Promise<void>;
|
||||
export const CONNECTION_ATTEMPT_CANCELLED_MESSAGE = "Connection attempt was cancelled";
|
||||
/** Thrown when a no-save-password connection is connected without a typed password. */
|
||||
export const CONNECTION_PASSWORD_REQUIRED_MESSAGE = "Password is required for this connection";
|
||||
const PLUGIN_CONFIG_RECONNECT_PASSWORD_MESSAGE = "Plugin settings were saved, but the connection was disconnected because its password is not available. Reconnect manually to apply the new settings.";
|
||||
|
||||
function metadataDriverProfile(config?: ConnectionConfig): string | undefined {
|
||||
return config?.driver_profile || config?.db_type;
|
||||
@@ -4029,7 +4030,9 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
if (config.save_password === false) config.password = "";
|
||||
const idx = connections.value.findIndex((c) => c.id === config.id);
|
||||
if (idx < 0) return;
|
||||
const runtimeConfigChanged = connectionConfigFingerprint(connections.value[idx]) !== connectionConfigFingerprint(config);
|
||||
const previousConfig = normalizeConnection(connections.value[idx]);
|
||||
const runtimeConfigChanged = connectionConfigFingerprint(previousConfig) !== connectionConfigFingerprint(config);
|
||||
const shouldReconnectPlugin = runtimeConfigChanged && previousConfig.db_type === "plugin" && config.db_type === "plugin" && connectedIds.value.has(config.id);
|
||||
const nextConnections = [...connections.value];
|
||||
nextConnections[idx] = config;
|
||||
await persistTimeoutInheritance(config.id, config.connect_timeout_inherit === true, config.query_timeout_inherit === true);
|
||||
@@ -4052,6 +4055,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
if (node?.isExpanded) {
|
||||
await reloadConnectionDatabaseChildren(config.id);
|
||||
}
|
||||
if (shouldReconnectPlugin) await reconnectPluginConnectionAfterConfigUpdate(config);
|
||||
}
|
||||
|
||||
async function updateRedisKeyGrouping(connectionId: string, grouping: import("@/lib/redis/redisKeyGrouping").RedisKeyGrouping) {
|
||||
@@ -4767,7 +4771,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
invalidateConnectionMetadataLifetime(connectionId, database);
|
||||
}
|
||||
|
||||
async function ensureConnected(connectionId: string, options: { activate?: boolean; verifyHealth?: boolean; forceReconnect?: boolean } = {}) {
|
||||
async function ensureConnected(connectionId: string, options: { activate?: boolean; verifyHealth?: boolean; forceReconnect?: boolean; allowPasswordPrompt?: boolean } = {}) {
|
||||
if (!options.forceReconnect && connectedIds.value.has(connectionId)) {
|
||||
// Pure navigation can safely trust the existing connected state. Its
|
||||
// destination will perform the real API request, while blocking here on
|
||||
@@ -4824,6 +4828,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
// in-flight dedup above keeps its exact microtask cadence; only await the
|
||||
// interactive prompt when the connection actually needs a typed password.
|
||||
if (connectionNeedsPasswordPrompt(config) && (await pluginConnectionPasswordPromptNeeded(config)) && !(await hasSessionCredential(connectionId))) {
|
||||
if (options.allowPasswordPrompt === false) throw new Error(CONNECTION_PASSWORD_REQUIRED_MESSAGE);
|
||||
const prompted = await ensureConnectionPassword(config);
|
||||
config = prompted.config;
|
||||
rememberPassword = prompted.rememberPassword;
|
||||
@@ -4886,6 +4891,31 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function canReconnectPluginConnectionWithoutPrompt(config: ConnectionConfig): Promise<boolean> {
|
||||
if (!connectionNeedsPasswordPrompt(config)) return true;
|
||||
if (!(await pluginConnectionPasswordPromptNeeded(config))) return true;
|
||||
return hasSessionCredential(config.id);
|
||||
}
|
||||
|
||||
async function reconnectPluginConnectionAfterConfigUpdate(config: ConnectionConfig): Promise<void> {
|
||||
if (!(await canReconnectPluginConnectionWithoutPrompt(config))) {
|
||||
try {
|
||||
await startDisconnectRequest(config.id);
|
||||
setConnectionError(config.id, PLUGIN_CONFIG_RECONNECT_PASSWORD_MESSAGE);
|
||||
} catch (error) {
|
||||
setConnectionError(config.id, `Plugin settings were saved, but DBX could not disconnect the stale plugin connection: ${connectionErrorMessage(error)}. Reconnect manually to apply the new settings.`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await ensureConnected(config.id, { activate: false, forceReconnect: true, allowPasswordPrompt: false });
|
||||
} catch (error) {
|
||||
connectedIds.value.delete(config.id);
|
||||
setConnectionError(config.id, `Plugin settings were saved, but automatic reconnection failed: ${connectionErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-push an already-open plugin connection's config (credentials included)
|
||||
* to its sidecar through the same connection/connect path used when opening
|
||||
@@ -4908,18 +4938,15 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
// plugin's first `ready`. The 2s in-memory TTL dies with the frontend, so
|
||||
// every realistic reload path still re-pushes.
|
||||
if (hasRecentConnectionHealthCheck(connectionId)) return;
|
||||
if (connectionNeedsPasswordPrompt(config) && (await pluginConnectionPasswordPromptNeeded(config)) && !(await hasSessionCredential(connectionId))) return;
|
||||
await ensureConnected(connectionId, { activate: false, forceReconnect: true });
|
||||
if (!(await canReconnectPluginConnectionWithoutPrompt(config))) return;
|
||||
await ensureConnected(connectionId, { activate: false, forceReconnect: true, allowPasswordPrompt: false });
|
||||
}
|
||||
|
||||
/**
|
||||
* Explicit, user-triggered reconnect of a plugin connection (the plugin's own
|
||||
* "reconnect" button). Unlike repushPluginConnection — the silent background
|
||||
* heal — this runs the full connect flow and MAY show the interactive
|
||||
* password prompt, which is appropriate for a deliberate user action. Editing
|
||||
* a connection drops it from connectedIds without notifying plugins, so the
|
||||
* sidecar's config goes stale; this is the plugin's way to request a fresh
|
||||
* connection/connect with the updated config.
|
||||
* password prompt, which is appropriate for a deliberate user action.
|
||||
*/
|
||||
async function reopenPluginConnection(connectionId: string, pluginId: string): Promise<void> {
|
||||
const config = getConfig(connectionId);
|
||||
|
||||
Reference in New Issue
Block a user