From d66242508e99a1c11cbfc031c2cd0627a7e464b3 Mon Sep 17 00:00:00 2001 From: Klinge <34301334+klinge1011@users.noreply.github.com> Date: Mon, 21 Sep 2026 01:41:44 +0800 Subject: [PATCH] feat(connection): update saved connections by ID through deep links --- apps/desktop/src/App.vue | 23 +- .../connection/ConnectionDialog.vue | 76 +++- .../ConnectionDialog.deepLinkEdit.spec.ts | 382 ++++++++++++++++++ .../src/components/layout/AppDialogs.vue | 4 +- .../connectionDeepLinkUpdate.spec.ts | 81 ++++ .../src/lib/connection/connectionDeepLink.ts | 74 ++++ .../connection/connectionDeepLinkUpdate.ts | 35 ++ docs/content/docs/cli.cn.mdx | 32 +- docs/content/docs/cli.mdx | 32 +- packages/app-tests/connectionDeepLink.test.ts | 92 ++++- 10 files changed, 818 insertions(+), 13 deletions(-) create mode 100644 apps/desktop/src/components/connection/__tests__/ConnectionDialog.deepLinkEdit.spec.ts create mode 100644 apps/desktop/src/lib/__tests__/connection/connectionDeepLinkUpdate.spec.ts create mode 100644 apps/desktop/src/lib/connection/connectionDeepLinkUpdate.ts diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index e18c9dc51e..a97b0bb1cd 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -79,7 +79,8 @@ import { externalSqlFileOpenErrorMessage, externalSqlEditorMaxBytes, isSqlFilePa import type { ConnectionConfig, DatabaseType, ObjectBrowserFilter, ObjectSourceKind, QueryTab, TabOutputView, TreeNode } from "@/types/database"; import type { PluginCenterFocus } from "@/lib/plugins/pluginCenterNavigation"; import { parsePluginInstallDeepLink } from "@/lib/plugins/pluginInstallDeepLink"; -import { parseConnectionDeepLink, type ConnectionDeepLinkDraft } from "@/lib/connection/connectionDeepLink"; +import { parseConnectionDeepLink, parseConnectionDeepLinkUpdate, type ConnectionDeepLinkDraft, type ConnectionDeepLinkUpdate } from "@/lib/connection/connectionDeepLink"; +import { resolveConnectionDeepLinkUpdate } from "@/lib/connection/connectionDeepLinkUpdate"; import { parseAiConfigDeepLink, type AiConfigDeepLinkDraft } from "@/lib/ai/aiConfigDeepLink"; import { activeDesktopAiRuns, blockingDesktopAiRunsForQuit } from "@/lib/ai/desktopAiRunRegistry"; import { @@ -331,6 +332,7 @@ const setupRequired = ref(false); const showConnectionDialog = ref(false); const connectionDialogPrefill = ref(null); +const connectionDialogUpdate = ref(null); const connectionDialogInitialTab = ref(undefined); const settingsPageTabOpen = ref(false); const settingsInitialTab = ref("appearance"); @@ -2493,8 +2495,23 @@ async function openPendingDbFiles() { async function openConnectionDeepLink(url: string) { await connectionStore.initFromDisk(); try { + const update = parseConnectionDeepLinkUpdate(url); + if (update) { + const config = resolveConnectionDeepLinkUpdate(update, connectionStore.connections, showConnectionDialog.value || !!connectionStore.editingConnectionId); + connectionDialogPrefill.value = null; + connectionDialogUpdate.value = update; + connectionPluginProvider.value = null; + connectionDialogInitialTab.value = "connection"; + connectionStore.stopCreatingConnectionInGroup(); + connectionStore.startEditing(config.id); + showConnectionDialog.value = true; + return; + } const draft = parseConnectionDeepLink(url); if (!draft) return; + // Do not let another external URL replace an unconfirmed update draft. + if (connectionDialogUpdate.value && showConnectionDialog.value) throw new Error("Close the current connection dialog before opening another connection link."); + connectionDialogUpdate.value = null; connectionStore.stopEditing(); connectionStore.stopCreatingConnectionInGroup(); connectionPluginProvider.value = null; @@ -2586,6 +2603,7 @@ function setConnectionDialogOpen(value: boolean) { showConnectionDialog.value = value; if (!value) { connectionDialogPrefill.value = null; + connectionDialogUpdate.value = null; connectionPluginProvider.value = null; connectionDialogInitialTab.value = undefined; } @@ -2593,6 +2611,8 @@ function setConnectionDialogOpen(value: boolean) { function openConnectionSettings(connectionId: string, initialTab: ConfigTab = "connection") { if (!connectionStore.getConfig(connectionId)) return; + connectionDialogUpdate.value = null; + connectionDialogPrefill.value = null; connectionPluginProvider.value = null; connectionDialogInitialTab.value = initialTab; connectionStore.startEditing(connectionId); @@ -4354,6 +4374,7 @@ onUnmounted(() => { (); @@ -2626,15 +2628,42 @@ function switchGbaseProfile(profile: "gbase8a" | "gbase8s") { resetTestState(); } +let applyingConnectionUpdate = false; +let appliedConnectionUpdate: ConnectionDeepLinkUpdate | null = null; + +function finishApplyingConnectionUpdate() { + void nextTick(() => { + applyingConnectionUpdate = false; + }); +} + +// The external_config shaped by an update link must survive submit verbatim +// only while the submitted values are still the ones the link patched: once +// the user edits the port away from the patched value, or the patch targeted +// another connection, the flag is re-derived from the form like any edit. +function connectionUpdateExternalConfigPreserved(config: Pick): boolean { + if (!appliedConnectionUpdate || appliedConnectionUpdate.connectionId !== config.id) return false; + const patchedPort = appliedConnectionUpdate.patch.port; + return patchedPort === undefined || patchedPort === config.port; +} + watch( [() => props.editConfig, open], - ([config, isOpen]) => { - const syncAction = connectionEditDraftSyncAction(config?.id ?? null, isOpen, editingId.value); + ([savedConfig, isOpen]) => { + const syncAction = connectionEditDraftSyncAction(savedConfig?.id ?? null, isOpen, editingId.value); if (syncAction === "preserve") return; + // Hydrate a detached edit draft before form watchers observe it. Do not + // mutate the saved record or apply create defaults to an ID update. Only + // flag the update as applied when the prefill really targeted this + // connection, so an ID mismatch cannot freeze port-flag recomputation. + const connectionUpdate = savedConfig && props.updatePrefill?.connectionId === savedConfig.id ? props.updatePrefill : null; + const config = connectionUpdate && savedConfig ? applyConnectionDeepLinkUpdate(savedConfig, connectionUpdate) : savedConfig; resetConnectionNoteVisibilityDraft(connectionNoteVisibilityDraft, settingsStore.editorSettings.sidebarShowConnectionNotes); editGlobalConnectTimeoutSecs.value = settingsStore.editorSettings.globalConnectTimeoutSecs; editGlobalQueryTimeoutSecs.value = settingsStore.editorSettings.globalQueryTimeoutSecs; if (syncAction === "hydrate" && config) { + appliedConnectionUpdate = connectionUpdate; + if (connectionUpdate) applyingConnectionUpdate = true; clearSavedDatabaseInfo(); const legacyConfig = config as LegacyConnectionConfig; const profile = profileForConfig(config); @@ -2777,13 +2806,15 @@ watch( customDriverName.value = isCustomCompatibleProfile() ? config.driver_label || "" : ""; dialogStep.value = "config"; configTab.value = initialConfigTab(); + if (props.updatePrefill) finishApplyingConnectionUpdate(); // Form/profile watchers normalize derived fields in this flush. Capture // the saved baseline afterwards so those initial changes are not treated // as user edits that invalidate persisted database metadata. void nextTick(() => { - if (open.value && props.editConfig?.id === config.id) applySavedDatabaseInfo(config); + if (open.value && props.editConfig?.id === config.id && !props.updatePrefill) applySavedDatabaseInfo(config); }); } else { + appliedConnectionUpdate = null; clearSavedDatabaseInfo(); editingId.value = null; selectedConnectionGroupId.value = initialConnectionGroupId(); @@ -3930,6 +3961,18 @@ function clearEditedConnectionErrorAfterSuccessfulTest() { function applyConnectionUrlToForm(input: string): boolean { try { + const update = parseConnectionDeepLinkUpdate(input); + if (update) { + if (update.connectionId !== editingId.value || props.editConfig?.one_time) throw new Error("Open the saved connection specified by this update link before applying it."); + const draft = applyConnectionDeepLinkUpdate(form.value, update); + applyingConnectionUpdate = true; + appliedConnectionUpdate = update; + form.value = draft; + finishApplyingConnectionUpdate(); + resetTestState(); + appliedConnectionUrlInput.value = input.trim(); + return true; + } const draft = parseConnectionDeepLink(input) ?? parseServiceConnectionUrl(input); if (draft) { applyConnectionDraftToForm({ ...draft, oneTime: undefined }); @@ -4009,6 +4052,11 @@ function ensureConnectionHostResolvedFromUrl(): boolean { function formValueForSubmit(): Omit { const url = connectionUrlInput.value.trim(); if (url && url !== appliedConnectionUrlInput.value) { + const update = parseConnectionDeepLinkUpdate(url); + if (update) { + if (update.connectionId !== editingId.value || props.editConfig?.one_time) throw new Error("Open the saved connection specified by this update link before applying it."); + return applyConnectionDeepLinkUpdate(form.value, update); + } const draft = parseConnectionDeepLink(url); if (draft) { return applyConnectionDraftToConfig(form.value, { ...draft, oneTime: undefined }); @@ -4384,7 +4432,7 @@ function connectionConfigForSubmit(id: string, generatedName = "", validatePlugi config.password = config.password.trim(); config.database = undefined; } else if (config.db_type === "sqlserver") { - config.external_config = sqlServerPortExplicitFromConfig(config) ? { portExplicit: true } : undefined; + if (!connectionUpdateExternalConfigPreserved(config)) config.external_config = sqlServerPortExplicitFromConfig(config) ? { portExplicit: true } : undefined; } else if (supportsGaussdbIdentifierQuoteStyle(config)) { const style = gaussdbIdentifierQuoteStyle(config); const targetServerType = gaussdbTargetServerType(config); @@ -4397,7 +4445,7 @@ function connectionConfigForSubmit(id: string, generatedName = "", validatePlugi // Plugin connections keep `external_config`: the manifest-driven form // fields land there via buildPluginConnectionConfig. Only the built-in // drivers without an external-config payload get wiped here. - config.external_config = undefined; + if (!connectionUpdateExternalConfigPreserved(config)) config.external_config = undefined; } if (config.db_type === "mongodb" && !mongoUseUrl.value) { config.connection_string = undefined; @@ -5581,7 +5629,9 @@ watch( }); // Preload database names so the summary count is accurate right away. void nextTick(() => { - if (canChooseVisibleDatabases.value && hasVisibleDatabaseFilter.value) { + // An external update may change the endpoint while retaining its saved + // password. Do not send credentials until the user tests or saves it. + if (!props.updatePrefill && canChooseVisibleDatabases.value && hasVisibleDatabaseFilter.value) { void preloadVisibleDatabaseNames(); } }); @@ -5618,7 +5668,7 @@ watch([() => form.value.db_type, () => form.value.username], () => { watch( () => connectionConfigSnapshotForVisibleDatabases(), (current, previous) => { - if (!previous || !visibleObjectFiltersNeedReset(previous, current)) return; + if (applyingConnectionUpdate || !previous || !visibleObjectFiltersNeedReset(previous, current)) return; form.value.visible_databases = undefined; form.value.visible_schemas = undefined; resetVisibleDatabaseDraftState(); @@ -5865,6 +5915,14 @@ function startSavedConnection(config: ConnectionConfig) { }); } +function validateConnectionUpdateTarget() { + const update = props.updatePrefill ?? appliedConnectionUpdate; + if (!update) return; + const target = store.getConfig(update.connectionId); + resolveConnectionDeepLinkUpdate(update, target ? [target] : [], false); + if (editingId.value !== update.connectionId) throw new Error("The connection specified by the update link is no longer being edited."); +} + async function save(options: SaveConnectionOptions = {}): Promise { if (!ensureConnectionHostResolvedFromUrl()) return false; if (isSaving.value) return false; @@ -5881,10 +5939,12 @@ async function save(options: SaveConnectionOptions = {}): Promise { let connectionSaved = false; try { let savedConfig: ConnectionConfig; + validateConnectionUpdateTarget(); if (editingId.value) { const updated = withSavedDatabaseInfo(connectionConfigForSubmit(editingId.value), databaseInfoForSave); await ensureRequiredAgentDriverInstalled(updated); await ensureRequiredGaussdbMJdbcRuntime(updated); + validateConnectionUpdateTarget(); await persistGlobalTimeoutDrafts(); await store.updateConnection(updated); savedConfig = updated; diff --git a/apps/desktop/src/components/connection/__tests__/ConnectionDialog.deepLinkEdit.spec.ts b/apps/desktop/src/components/connection/__tests__/ConnectionDialog.deepLinkEdit.spec.ts new file mode 100644 index 0000000000..3b77a65f8b --- /dev/null +++ b/apps/desktop/src/components/connection/__tests__/ConnectionDialog.deepLinkEdit.spec.ts @@ -0,0 +1,382 @@ +// @vitest-environment happy-dom + +import { createApp, defineComponent, h, nextTick, reactive, type App } from "vue"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import i18n from "@/i18n"; +import type { ConnectionConfig } from "@/types/database"; +import { parseConnectionDeepLinkUpdate } from "@/lib/connection/connectionDeepLink"; + +const { store, settings, backend } = vi.hoisted(() => ({ + store: { + connectionGroupOptions: [], + newConnectionGroupId: null, + selectedConnectionGroupId: null, + addConnection: vi.fn(), + updateConnection: vi.fn(), + connect: vi.fn(), + ensureConnected: vi.fn(), + addEphemeralConnection: vi.fn(), + removeConnection: vi.fn(), + startEditing: vi.fn(), + stopEditing: vi.fn(), + clearConnectionError: vi.fn(), + updateConnectionDatabaseInfo: vi.fn(), + applyGlobalTimeouts: vi.fn((config) => config), + getConfig: vi.fn(), + }, + settings: { + editorSettings: { sidebarShowConnectionNotes: false, globalConnectTimeoutSecs: 10, globalQueryTimeoutSecs: 0 }, + rememberedDatabaseForConnection: vi.fn(() => ""), + persistEditorSettings: vi.fn(), + updateEditorSettings: vi.fn(), + updateEditorSettingsAndPersist: vi.fn(), + }, + backend: { + connectDb: vi.fn(), + disconnectDb: vi.fn(), + testConnectionWithInfo: vi.fn(), + listDatabases: vi.fn(), + listPlugins: vi.fn(), + listJdbcDrivers: vi.fn(), + listJdbcMavenBundles: vi.fn(), + listJdbcLocalBundles: vi.fn(), + listSshConfigHosts: vi.fn(), + listInstalledAgentsLocal: vi.fn(), + listenAgentInstallProgress: vi.fn(), + }, +})); + +vi.mock("@/stores/connectionStore", () => ({ useConnectionStore: () => store, CONNECTION_ATTEMPT_CANCELLED_MESSAGE: "cancelled" })); +vi.mock("@/stores/settingsStore", async (original) => ({ ...(await original()), useSettingsStore: () => settings })); +vi.mock("@/stores/tunnelProfileStore", () => ({ useTunnelProfileStore: () => ({ profiles: [], profileById: () => undefined, init: vi.fn() }) })); +vi.mock("@/lib/backend/api", async (original) => ({ ...(await original()), ...backend })); +vi.mock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); +vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: vi.fn() }) })); + +vi.mock("@/components/ui/dialog", async () => { + const { defineComponent, h } = await import("vue"); + const passthrough = defineComponent({ + setup: + (_props, { slots }) => + () => + h("div", slots.default?.()), + }); + const Dialog = defineComponent({ + props: { open: Boolean }, + emits: ["update:open"], + setup: + (props, { slots, emit }) => + () => + props.open ? h("section", [h("button", { "data-testid": "dismiss-dialog", onClick: () => emit("update:open", false) }, "Dismiss"), slots.default?.()]) : null, + }); + return { Dialog, DialogContent: passthrough, DialogHeader: passthrough, DialogTitle: passthrough, DialogFooter: passthrough }; +}); +vi.mock("@/components/ui/button", async () => { + const { defineComponent, h } = await import("vue"); + return { + Button: defineComponent({ + inheritAttrs: false, + setup: + (_props, { attrs, slots }) => + () => + h("button", attrs, slots.default?.()), + }), + }; +}); +vi.mock("@/components/ui/input", async () => { + const { defineComponent, h } = await import("vue"); + return { + Input: defineComponent({ + props: ["modelValue"], + emits: ["update:modelValue"], + setup: + (props, { attrs, emit }) => + () => + h("input", { ...attrs, value: props.modelValue, onInput: (event: Event) => emit("update:modelValue", (event.target as HTMLInputElement).value) }), + }), + }; +}); +vi.mock("@/components/ui/PasswordInput.vue", async () => { + const { defineComponent, h } = await import("vue"); + return { + default: defineComponent({ + props: ["modelValue"], + setup: + (props, { attrs }) => + () => + h("input", { ...attrs, type: "password", value: props.modelValue }), + }), + }; +}); +vi.mock("@/components/ui/tabs", async () => { + const { defineComponent, h } = await import("vue"); + const pass = defineComponent({ + setup: + (_props, { slots }) => + () => + h("div", slots.default?.()), + }); + const TabsContent = defineComponent({ + props: ["value"], + setup: + (props, { slots }) => + () => + props.value === "connection" ? h("div", slots.default?.()) : null, + }); + return { Tabs: pass, TabsContent, TabsList: pass, TabsTrigger: pass }; +}); +vi.mock("@/components/ui/tooltip", async () => { + const { defineComponent, h } = await import("vue"); + const pass = defineComponent({ + setup: + (_props, { slots }) => + () => + h("span", slots.default?.()), + }); + return { HelpTooltip: pass, Tooltip: pass, TooltipContent: pass, TooltipTrigger: pass }; +}); + +import ConnectionDialog from "@/components/connection/ConnectionDialog.vue"; + +const mountedApps: App[] = []; + +function savedConnection(overrides: Partial = {}): ConnectionConfig { + return { + id: "saved-production", + name: "Saved production", + db_type: "mysql", + driver_profile: "mysql", + driver_label: "MySQL", + host: "old.example.test", + port: 3307, + username: "old-user", + password: "old-password", + database: "orders", + ssl: true, + read_only: true, + is_production: true, + production_databases: ["orders"], + save_password: true, + visible_databases: ["orders"], + url_params: "connectTimeout=25", + ...overrides, + }; +} + +async function settle() { + for (let i = 0; i < 8; i++) await nextTick(); +} + +async function mountDialog(config: ConnectionConfig, url: string, initiallyOpen = true) { + const props = reactive({ open: initiallyOpen, editConfig: config, updatePrefill: parseConnectionDeepLinkUpdate(url) }); + store.getConfig.mockReturnValue(config); + const container = document.createElement("div"); + document.body.append(container); + const app = createApp( + defineComponent({ + setup: () => () => + h(ConnectionDialog, { + ...props, + "onUpdate:open": (value: boolean) => { + props.open = value; + }, + }), + }), + ); + app.use(i18n); + mountedApps.push(app); + app.mount(container); + await settle(); + return props; +} + +beforeEach(() => { + vi.clearAllMocks(); + i18n.global.locale.value = "en"; + for (const fn of Object.values(backend)) fn.mockResolvedValue([]); + backend.listenAgentInstallProgress.mockResolvedValue(() => {}); + store.updateConnection.mockResolvedValue(undefined); + store.addConnection.mockResolvedValue(undefined); +}); + +afterEach(() => { + for (const app of mountedApps.splice(0)) app.unmount(); + document.body.innerHTML = ""; +}); + +describe("ConnectionDialog deep-link edit confirmation", () => { + it("does not save, connect, or send retained credentials before confirmation", async () => { + const saved = savedConnection(); + await mountDialog(saved, "dbx://connection/new?id=saved-production&host=new.example.test"); + + expect([...document.querySelectorAll("input")].some((input) => input.value === "new.example.test")).toBe(true); + expect(saved.host).toBe("old.example.test"); + expect(store.addConnection).not.toHaveBeenCalled(); + expect(store.updateConnection).not.toHaveBeenCalled(); + expect(store.connect).not.toHaveBeenCalled(); + expect(backend.connectDb).not.toHaveBeenCalled(); + expect(backend.testConnectionWithInfo).not.toHaveBeenCalled(); + expect(backend.listDatabases).not.toHaveBeenCalled(); + }); + + it("dismisses the draft without persisting or connecting", async () => { + const saved = savedConnection(); + const props = await mountDialog(saved, "dbx://connection/new?id=saved-production&password=new-password"); + (document.querySelector('[data-testid="dismiss-dialog"]') as HTMLButtonElement).click(); + await settle(); + + expect(props.open).toBe(false); + expect(saved.password).toBe("old-password"); + expect(store.updateConnection).not.toHaveBeenCalled(); + expect(store.addConnection).not.toHaveBeenCalled(); + expect(store.connect).not.toHaveBeenCalled(); + expect(backend.connectDb).not.toHaveBeenCalled(); + }); + + it("updates the existing ID only after Save and preserves omitted fields", async () => { + await mountDialog(savedConnection(), "dbx://connection/new?id=saved-production&password=new-password"); + const save = [...document.querySelectorAll("button")].find((button) => button.textContent?.trim() === "Save"); + expect(save).toBeTruthy(); + save!.click(); + await settle(); + + expect(store.updateConnection).toHaveBeenCalledTimes(1); + expect(store.updateConnection).toHaveBeenCalledWith( + expect.objectContaining({ + id: "saved-production", + name: "Saved production", + host: "old.example.test", + port: 3307, + username: "old-user", + password: "new-password", + database: "orders", + ssl: true, + read_only: true, + is_production: true, + production_databases: ["orders"], + visible_databases: ["orders"], + url_params: "connectTimeout=25", + }), + ); + expect(store.addConnection).not.toHaveBeenCalled(); + expect(store.connect).not.toHaveBeenCalled(); + }); + + it("applies explicit empty passwords and false TLS values without create defaults", async () => { + await mountDialog(savedConnection(), "dbx://connection/new?id=saved-production&password=&ssl=false"); + const save = [...document.querySelectorAll("button")].find((button) => button.textContent?.trim() === "Save"); + expect(save).toBeTruthy(); + save!.click(); + await settle(); + + expect(store.updateConnection).toHaveBeenCalledWith( + expect.objectContaining({ + id: "saved-production", + password: "", + ssl: false, + port: 3307, + username: "old-user", + database: "orders", + read_only: true, + is_production: true, + }), + ); + expect(store.addConnection).not.toHaveBeenCalled(); + expect(store.connect).not.toHaveBeenCalled(); + }); + + it("keeps unsaved field edits when the saved config is refreshed", async () => { + const props = await mountDialog(savedConnection(), "dbx://connection/new?id=saved-production&password=new-password"); + const username = [...document.querySelectorAll("input")].find((input) => input.value === "old-user"); + expect(username).toBeTruthy(); + username!.value = "manually-edited-user"; + username!.dispatchEvent(new Event("input", { bubbles: true })); + await settle(); + + props.editConfig = savedConnection({ username: "background-refresh-user" }); + await settle(); + expect([...document.querySelectorAll("input")].some((input) => input.value === "manually-edited-user")).toBe(true); + expect(store.updateConnection).not.toHaveBeenCalled(); + expect(store.addConnection).not.toHaveBeenCalled(); + }); + + it("hydrates a later-opened update without clearing omitted database or schema filters", async () => { + const saved = savedConnection({ db_type: "postgres", driver_profile: "postgres", driver_label: "PostgreSQL", url_params: "sslmode=require", visible_schemas: ["approved_schema"] }); + const props = await mountDialog(saved, "dbx://connection/new?id=saved-production&user=new-user&password=new-password", false); + props.open = true; + await settle(); + + expect([...document.querySelectorAll("input")].some((input) => input.value === "new-user")).toBe(true); + expect([...document.querySelectorAll("input")].some((input) => input.value === "new-password")).toBe(true); + expect(store.updateConnection).not.toHaveBeenCalled(); + expect(backend.connectDb).not.toHaveBeenCalled(); + expect(backend.testConnectionWithInfo).not.toHaveBeenCalled(); + + const save = [...document.querySelectorAll("button")].find((button) => button.textContent?.trim() === "Save"); + expect(save).toBeTruthy(); + save!.click(); + await settle(); + + expect(store.updateConnection).toHaveBeenCalledWith( + expect.objectContaining({ + id: "saved-production", + username: "new-user", + password: "new-password", + visible_databases: ["orders"], + visible_schemas: ["approved_schema"], + }), + ); + expect(store.addConnection).not.toHaveBeenCalled(); + expect(store.connect).not.toHaveBeenCalled(); + }); + + it("does not recreate a target removed while its update is awaiting confirmation", async () => { + await mountDialog(savedConnection(), "dbx://connection/new?id=saved-production&password=new-password"); + store.getConfig.mockReturnValue(undefined); + const save = [...document.querySelectorAll("button")].find((button) => button.textContent?.trim() === "Save"); + expect(save).toBeTruthy(); + save!.click(); + await settle(); + + expect(store.updateConnection).not.toHaveBeenCalled(); + expect(store.addConnection).not.toHaveBeenCalled(); + expect(store.connect).not.toHaveBeenCalled(); + expect(backend.connectDb).not.toHaveBeenCalled(); + }); + + it.each([ + { title: "marks an explicit SQL Server port without an existing external config", dbType: "sqlserver", patch: "port=1444", external: undefined, expectedExternal: { portExplicit: true }, expectedPort: 1444 }, + { title: "marks an explicit SQL Server port and retains unrelated external fields", dbType: "sqlserver", patch: "port=1444", external: { customSetting: "retained", port_explicit: false }, expectedExternal: { customSetting: "retained", portExplicit: true }, expectedPort: 1444 }, + { title: "retains MySQL external config on a credential-only update", dbType: "mysql", patch: "password=new-password", external: { customSetting: "retained" }, expectedExternal: { customSetting: "retained" }, expectedPort: 3307 }, + { title: "retains PostgreSQL external config on a credential-only update", dbType: "postgres", patch: "password=new-password", external: { customSetting: "retained" }, expectedExternal: { customSetting: "retained" }, expectedPort: 3307 }, + { title: "retains the SQL Server explicit-port flag on a credential-only update", dbType: "sqlserver", patch: "password=new-password", external: { customSetting: "retained", portExplicit: true }, expectedExternal: { customSetting: "retained", portExplicit: true }, expectedPort: 3307 }, + ] as const)("$title", async ({ dbType, patch, external, expectedExternal, expectedPort }) => { + await mountDialog(savedConnection({ db_type: dbType, driver_profile: dbType, external_config: external }), `dbx://connection/new?id=saved-production&${patch}`); + const save = [...document.querySelectorAll("button")].find((button) => button.textContent?.trim() === "Save"); + expect(save).toBeTruthy(); + save!.click(); + await settle(); + + expect(store.updateConnection).toHaveBeenCalledWith(expect.objectContaining({ id: "saved-production", port: expectedPort, external_config: expectedExternal })); + expect(store.addConnection).not.toHaveBeenCalled(); + expect(store.connect).not.toHaveBeenCalled(); + expect(backend.connectDb).not.toHaveBeenCalled(); + }); + + it("recomputes the SQL Server explicit-port flag after a manual port edit in an update session", async () => { + await mountDialog(savedConnection({ db_type: "sqlserver", driver_profile: "sqlserver", driver_label: "SQL Server", port: 1433, external_config: { customSetting: "retained" } }), "dbx://connection/new?id=saved-production&port=1444"); + const port = [...document.querySelectorAll("input")].find((input) => input.value === "1444"); + expect(port).toBeTruthy(); + port!.value = "1445"; + port!.dispatchEvent(new Event("input", { bubbles: true })); + await settle(); + + const save = [...document.querySelectorAll("button")].find((button) => button.textContent?.trim() === "Save"); + expect(save).toBeTruthy(); + save!.click(); + await settle(); + + expect(store.updateConnection).toHaveBeenCalledWith(expect.objectContaining({ id: "saved-production", port: 1445, external_config: { portExplicit: true } })); + expect(store.addConnection).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/desktop/src/components/layout/AppDialogs.vue b/apps/desktop/src/components/layout/AppDialogs.vue index 98e09582a6..3117e99658 100644 --- a/apps/desktop/src/components/layout/AppDialogs.vue +++ b/apps/desktop/src/components/layout/AppDialogs.vue @@ -28,7 +28,7 @@ import { useSqlExecutionDangerStore } from "@/stores/sqlExecutionDangerStore"; import { useProductionSafetyStore } from "@/stores/productionSafetyStore"; import { useReadOnlyUnlockStore, WRITE_UNLOCK_FIVE_MINUTES_SECS, WRITE_UNLOCK_ONE_MINUTE_SECS, type WriteUnlockDurationSecs } from "@/stores/readOnlyUnlockStore"; import { useDialogSources } from "@/composables/useDialogSources"; -import type { ConnectionDeepLinkDraft } from "@/lib/connection/connectionDeepLink"; +import type { ConnectionDeepLinkDraft, ConnectionDeepLinkUpdate } from "@/lib/connection/connectionDeepLink"; import type { DriverStoreFocus } from "@/lib/connection/agentDriverInstallHint"; import type { SqlParameterDescriptor, SqlParameterSyntax } from "@/lib/sql/sqlParameters"; import type { ConfigTab } from "@/components/connection/ConnectionDialog.vue"; @@ -38,6 +38,7 @@ import type { PluginCenterFocus } from "@/lib/plugins/pluginCenterNavigation"; const props = defineProps<{ showConnectionDialog: boolean; connectionPrefill?: ConnectionDeepLinkDraft | null; + connectionUpdate?: ConnectionDeepLinkUpdate | null; connectionPluginProvider?: PluginCenterFocus | null; connectionInitialTab?: ConfigTab; showDangerDialog: boolean; @@ -171,6 +172,7 @@ watch( :open="shouldShowConnectionDialog" :edit-config="editConfig" :prefill-config="connectionPrefill" + :update-prefill="connectionUpdate" :plugin-provider="connectionPluginProvider" :initial-tab="connectionInitialTab" @update:open="emit('update:showConnectionDialog', $event)" diff --git a/apps/desktop/src/lib/__tests__/connection/connectionDeepLinkUpdate.spec.ts b/apps/desktop/src/lib/__tests__/connection/connectionDeepLinkUpdate.spec.ts new file mode 100644 index 0000000000..5284feef1b --- /dev/null +++ b/apps/desktop/src/lib/__tests__/connection/connectionDeepLinkUpdate.spec.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "vitest"; +import { parseConnectionDeepLinkUpdate } from "@/lib/connection/connectionDeepLink"; +import { applyConnectionDeepLinkUpdate, resolveConnectionDeepLinkUpdate } from "@/lib/connection/connectionDeepLinkUpdate"; +import type { ConnectionConfig } from "@/types/database"; + +const saved: ConnectionConfig = { + id: "saved-id", + name: "Shared name", + db_type: "postgres", + driver_profile: "postgres", + host: "database.example.test", + port: 15432, + username: "old-user", + password: "old-password", + database: "orders", + ssl: true, + read_only: true, + is_production: true, + production_databases: ["orders"], + visible_databases: ["orders"], + ca_cert_path: "/certs/custom-ca.pem", + save_password: false, + query_timeout_secs: 45, +}; + +describe("saved connection update links", () => { + it("selects the exact saved ID, even when another connection has the same name", () => { + const other = { ...saved, id: "other-id" }; + const update = parseConnectionDeepLinkUpdate("dbx://connection/new?id=saved-id&password=new")!; + expect(resolveConnectionDeepLinkUpdate(update, [other, saved], false)).toBe(saved); + }); + + it("rejects missing or temporary targets without selecting a same-name fallback", () => { + const update = parseConnectionDeepLinkUpdate("dbx://connection/new?id=missing&name=Shared+name")!; + expect(() => resolveConnectionDeepLinkUpdate(update, [saved], false)).toThrow(/not found/); + expect(() => resolveConnectionDeepLinkUpdate({ ...update, connectionId: saved.id }, [{ ...saved, one_time: true }], false)).toThrow(/saved connection/); + }); + + it("refuses to replace an open editor or creation draft", () => { + const update = parseConnectionDeepLinkUpdate("dbx://connection/new?id=saved-id&password=new")!; + expect(() => resolveConnectionDeepLinkUpdate(update, [saved], true)).toThrow(/current connection dialog/); + }); + + it.each([{ db_type: "mongodb", driver_profile: "mongodb" }, { db_type: "mysql", driver_profile: "tidb" }, { connection_string: "postgres://original.example.test/orders" }] as Partial[])("rejects a target with unsupported configuration %j", (overrides) => { + const update = parseConnectionDeepLinkUpdate("dbx://connection/new?id=saved-id&password=new")!; + const config = { ...saved, ...overrides }; + expect(() => resolveConnectionDeepLinkUpdate(update, [config], false)).toThrow(/field-based/); + expect(() => applyConnectionDeepLinkUpdate(config, update)).toThrow(/field-based/); + }); + + it("patches credentials without changing IDs, endpoint, SSL, policy or unsupplied fields", () => { + const original = structuredClone(saved); + const update = parseConnectionDeepLinkUpdate("dbx://connection/new?id=saved-id&user=+new-user+&password=%20token%2B%26%3D%20")!; + const result = applyConnectionDeepLinkUpdate(Object.freeze(original), update); + expect(result).toEqual({ ...saved, username: " new-user ", password: " token+&= " }); + expect(original).toEqual(saved); + expect(result).not.toBe(original); + }); + + it("honors explicit clears and false values without applying create defaults", () => { + const update = parseConnectionDeepLinkUpdate("dbx://connection/new?id=saved-id&database=&password=&url_params=&ssl=false")!; + expect(applyConnectionDeepLinkUpdate({ ...saved, url_params: "old-options" }, update)).toEqual({ ...saved, database: "", password: "", url_params: "", ssl: false }); + }); + + it("an ID-only link leaves the entire saved configuration intact", () => { + const update = parseConnectionDeepLinkUpdate("dbx://connection/new?id=saved-id")!; + expect(applyConnectionDeepLinkUpdate(saved, update)).toEqual(saved); + }); + + it("rejects oversized parameter values while accepting values at the bound", () => { + expect(parseConnectionDeepLinkUpdate(`dbx://connection/new?id=saved-id&name=${"n".repeat(4096)}`)!.patch.name).toBe("n".repeat(4096)); + for (const key of ["name", "host", "password", "url_params"]) { + expect(() => parseConnectionDeepLinkUpdate(`dbx://connection/new?id=saved-id&${key}=${"x".repeat(4097)}`)).toThrow(`Connection update ${key} is too long`); + } + }); + + it("rejects an oversized update URL without rejecting other long deep links", () => { + expect(() => parseConnectionDeepLinkUpdate(`dbx://connection/new?id=saved-id&url_params=${"k=v&".repeat(5000)}`)).toThrow("Connection update URL is too long"); + expect(parseConnectionDeepLinkUpdate(`dbx://query/open?sql=${"x".repeat(20000)}`)).toBe(null); + }); +}); diff --git a/apps/desktop/src/lib/connection/connectionDeepLink.ts b/apps/desktop/src/lib/connection/connectionDeepLink.ts index 6c2d2d4bd5..d9b78f9f07 100644 --- a/apps/desktop/src/lib/connection/connectionDeepLink.ts +++ b/apps/desktop/src/lib/connection/connectionDeepLink.ts @@ -32,6 +32,22 @@ export interface ConnectionDeepLinkDraft { serviceConfig?: ConnectionDeepLinkServiceConfig; } +export interface ConnectionDeepLinkUpdatePatch { + name?: string; + host?: string; + port?: number; + username?: string; + password?: string; + database?: string; + urlParams?: string; + ssl?: boolean; +} + +export interface ConnectionDeepLinkUpdate { + connectionId: string; + patch: ConnectionDeepLinkUpdatePatch; +} + export function connectionDeepLinkServiceHydrationValue(config: ConnectionDeepLinkServiceConfig): Record { if (config.kind === "consul") return { serverAddr: config.serverAddr }; if (config.profile === "rnacos") { @@ -51,11 +67,68 @@ export function connectionDeepLinkServiceHydrationValue(config: ConnectionDeepLi } const CONNECTION_DEEP_LINK_TARGET = "connection/new"; +const CONNECTION_UPDATE_PARAMS = new Set(["id", "v", "name", "host", "port", "user", "password", "database", "url_params", "ssl"]); +// Untrusted pages build these URLs; cap them so a deep link cannot prefill +// unbounded strings into a saved connection's fields. +const CONNECTION_UPDATE_MAX_URL_LENGTH = 16384; +const CONNECTION_UPDATE_MAX_PARAM_LENGTH = 4096; function normalizePath(url: URL): string { return [url.hostname, url.pathname.replace(/^\/+/, "")].filter(Boolean).join("/").replace(/\/+$/, ""); } +/** Parse an explicit-field update without applying the defaults used by new connections. */ +export function parseConnectionDeepLinkUpdate(value: string): ConnectionDeepLinkUpdate | null { + let url: URL; + try { + url = new URL(value); + } catch { + return null; + } + if (url.protocol !== "dbx:" || normalizePath(url) !== CONNECTION_DEEP_LINK_TARGET || !url.searchParams.has("id")) return null; + if (value.length > CONNECTION_UPDATE_MAX_URL_LENGTH) throw new Error("Connection update URL is too long"); + if (url.username || url.password || url.port || url.hash) throw new Error("Invalid connection update URL"); + + const params = url.searchParams; + for (const key of params.keys()) { + if (!CONNECTION_UPDATE_PARAMS.has(key)) throw new Error("Unsupported connection update parameter"); + if (params.getAll(key).length !== 1) throw new Error("Duplicate connection update parameter"); + if (params.get(key)!.length > CONNECTION_UPDATE_MAX_PARAM_LENGTH) throw new Error(`Connection update ${key} is too long`); + } + if (params.has("v") && params.get("v")?.trim() !== "1") throw new Error("Unsupported connection update version"); + + const rawConnectionId = params.get("id")!; + const connectionId = rawConnectionId.trim(); + if (!connectionId || /[\u0000-\u001f\u007f]/.test(rawConnectionId)) throw new Error("A valid connection ID is required for updates"); + + const patch: ConnectionDeepLinkUpdatePatch = {}; + for (const key of ["name", "host"] as const) { + if (!params.has(key)) continue; + const text = params.get(key)!.trim(); + if (!text) throw new Error(`Connection update ${key} cannot be empty`); + patch[key] = text; + } + if (params.has("port")) { + const port = params.get("port")!.trim(); + const number = Number(port); + if (!/^\d+$/.test(port) || !Number.isInteger(number) || number < 1 || number > 65535) throw new Error("Invalid connection update port"); + patch.port = number; + } + // Presence matters: an empty value explicitly clears a field, while omission preserves it. + // Do not trim credentials; spaces can be part of a username or password. + if (params.has("user")) patch.username = params.get("user")!; + if (params.has("password")) patch.password = params.get("password")!; + if (params.has("database")) patch.database = params.get("database")!; + if (params.has("url_params")) patch.urlParams = params.get("url_params")!; + if (params.has("ssl")) { + const ssl = params.get("ssl")!.trim().toLowerCase(); + if (["true", "1", "yes", "on"].includes(ssl)) patch.ssl = true; + else if (["false", "0", "no", "off"].includes(ssl)) patch.ssl = false; + else throw new Error("Invalid connection update SSL value"); + } + return { connectionId, patch }; +} + function optionalParam(params: URLSearchParams, ...keys: string[]): string | undefined { for (const key of keys) { const value = params.get(key)?.trim(); @@ -209,6 +282,7 @@ export function parseConnectionDeepLink(value: string): ConnectionDeepLinkDraft if (normalizePath(url) !== CONNECTION_DEEP_LINK_TARGET) return null; const params = url.searchParams; + if (params.has("id")) throw new Error("Connection update link cannot create a new connection"); validateProtocolVersion(params); const preferredProfile = optionalParam(params, "type")?.toLowerCase(); const rawConnectionUrl = optionalParam(params, "url"); diff --git a/apps/desktop/src/lib/connection/connectionDeepLinkUpdate.ts b/apps/desktop/src/lib/connection/connectionDeepLinkUpdate.ts new file mode 100644 index 0000000000..88c5815427 --- /dev/null +++ b/apps/desktop/src/lib/connection/connectionDeepLinkUpdate.ts @@ -0,0 +1,35 @@ +import type { ConnectionConfig } from "@/types/database"; +import type { ConnectionDeepLinkUpdate } from "./connectionDeepLink"; + +function validateUpdateConfig(config: Omit) { + if (!["mysql", "postgres", "sqlserver"].includes(config.db_type) || (config.driver_profile && config.driver_profile !== config.db_type) || config.connection_string?.trim()) { + throw new Error("Update links support built-in MySQL, PostgreSQL and SQL Server field-based connections only."); + } +} + +/** Resolve only a saved connection. An invalid update must never become a create. */ +export function resolveConnectionDeepLinkUpdate(update: ConnectionDeepLinkUpdate, connections: readonly ConnectionConfig[], dialogOpen: boolean): ConnectionConfig { + if (dialogOpen) throw new Error("Close the current connection dialog before opening an update link."); + const config = connections.find((connection) => connection.id === update.connectionId); + if (!config) throw new Error("The connection specified by the update link was not found."); + if (config.one_time) throw new Error("Update links require a saved connection, not a one-time connection."); + validateUpdateConfig(config); + return config; +} + +/** Apply explicit URL fields to a detached draft, leaving saved data untouched. */ +export function applyConnectionDeepLinkUpdate>(config: T, update: ConnectionDeepLinkUpdate): T { + validateUpdateConfig(config); + const { urlParams, ...fields } = update.patch; + const result = { + ...config, + ...fields, + ...(urlParams !== undefined ? { url_params: urlParams } : {}), + }; + if (config.db_type === "sqlserver" && update.patch.port !== undefined) { + const external = config.external_config && typeof config.external_config === "object" && !Array.isArray(config.external_config) ? { ...config.external_config } : {}; + delete (external as Record).port_explicit; + result.external_config = { ...external, portExplicit: true }; + } + return result; +} diff --git a/docs/content/docs/cli.cn.mdx b/docs/content/docs/cli.cn.mdx index 4fca3a1fc6..deec31dad2 100644 --- a/docs/content/docs/cli.cn.mdx +++ b/docs/content/docs/cli.cn.mdx @@ -182,7 +182,7 @@ open 'dbx://open' 使用 DBX 打开 ``` -使用 `dbx://connection/new` 可以打开新建连接窗口并预填连接信息。 +使用不带 `id` 的 `dbx://connection/new` 可以打开新建连接窗口并预填连接信息。 DSN 模式: @@ -228,6 +228,36 @@ open 'dbx://connection/new?type=r-nacos&url=http%3A%2F%2F127.0.0.1%3A8848%2Fnaco `nacos` 是 `nacos-v2` 的别名,`rnacos` 是 `r-nacos` 的别名。URL 模式会保留服务端点路径,顶层 `host`、`port`、`user`、`password`、`ssl` 会覆盖 `url` 中的对应值。Consul 的 `password` 表示 ACL token;Nacos 传入 `password` 时必须同时传入 `user`。省略 `v` 或使用 `v=1` 表示当前深链协议,其他版本会被拒绝。 +### 按 ID 更新已保存连接 + +在同一个 `dbx://connection/new` 路由上增加 `id`,即可打开指定的已保存连接进行编辑。例如,堡垒机可以为已有连接传入新的登录凭据: + +```bash +open 'dbx://connection/new?id=saved-connection-id&user=fresh-sso-token&password=fresh-sso-token' +``` + +将 `saved-connection-id` 替换为 MCP 工具 `dbx_list_connections` 返回的连接 ID。更新按 ID 精确匹配,不按显示名称查找。CLI 命令 `dbx connections list --json` 目前只列出名称和端点等信息,不返回连接 ID。 + +收到更新链接后,DBX 只打开预填好的编辑窗口。检查修改内容并点击**保存**后,才会以原连接 ID 和原侧栏分组持久化修改。链接不会自动保存、测试或连接数据库;取消编辑不会修改已保存配置。ID 为空或不存在、目标是一次性连接、已有其他连接窗口打开时,更新会被拒绝,不会退回新建连接。 + +更新支持使用内置 MySQL、PostgreSQL、SQL Server 配置的已保存字段式连接;存在非空 `connection_string` 的连接不支持此更新流程。只有链接中明确提供的字段才会修改: + +| 参数 | 更新行为 | +| --- | --- | +| `name`、`host` | 替换当前值,不接受空值 | +| `port` | 替换为 1 到 65535 之间的整数端口 | +| `user`、`password` | 预填凭据并保留首尾空白字符;传空值表示清空 | +| `database`、`url_params` | 替换当前值;传空值表示清空 | +| `ssl` | 更新 SSL 开关;最终 TLS 行为遵循驱动和 URL 参数规则 | + +省略的字段保留原值,包括端口和 SSL 设置。需要清空字段时,保留参数名并传空值,例如 `database=&url_params=`。参数值应进行 URL 编码,例如密码中的字面量 `+` 应编码为 `%2B`。 + +保存时仍遵循现有的凭据清理规则和该连接的“保存密码”设置。 + +除 `id` 和上述字段外,只接受可选的协议版本 `v=1`。更新链接不接受 `type`、`url`、`one_time`(包括 `one_time=false`)、未知参数或重复参数名,也不能更改驱动配置或要求自动操作。 + +保存新的 SSO 凭据不会延长其有效期。MCP 可以读取保存后的连接配置,但仍会独立建立数据库会话,需要凭据仍然有效;更新不会把桌面端正在使用的会话转交给 MCP。 + 测试系统级 `dbx://` 唤起前,请先安装并启动一次 DBX 桌面端,让系统注册 URL 协议。macOS 可使用 `open 'dbx://open'` 或 `open 'dbx://connection/new?...'` 测试。 深链可能在浏览器或终端历史中暴露数据库密码或 Consul ACL token。不要将携带凭据的链接写入共享日志、Shell 历史、工单或聊天消息。 diff --git a/docs/content/docs/cli.mdx b/docs/content/docs/cli.mdx index d80e83d68d..1365eaea4a 100644 --- a/docs/content/docs/cli.mdx +++ b/docs/content/docs/cli.mdx @@ -184,7 +184,7 @@ A web button can link to the protocol directly: Open in DBX ``` -Use `dbx://connection/new` to open the new connection dialog with connection fields prefilled. +Use `dbx://connection/new` without an `id` to open the new connection dialog with connection fields prefilled. DSN mode: @@ -230,6 +230,36 @@ open 'dbx://connection/new?type=r-nacos&url=http%3A%2F%2F127.0.0.1%3A8848%2Fnaco `nacos` is an alias for `nacos-v2`, and `rnacos` is an alias for `r-nacos`. In URL mode, service endpoint paths are preserved; top-level `host`, `port`, `user`, `password`, and `ssl` fields override values from `url`. For Consul, `password` is the ACL token. Nacos requires `user` when `password` is present. Omitting `v` or using `v=1` selects the current deep-link protocol; other versions are rejected. +### Update a saved connection by ID + +Add `id` to the same `dbx://connection/new` route to open an existing saved connection for editing. For example, a bastion host can provide fresh credentials for a saved connection: + +```bash +open 'dbx://connection/new?id=saved-connection-id&user=fresh-sso-token&password=fresh-sso-token' +``` + +Replace `saved-connection-id` with the connection ID returned by the MCP `dbx_list_connections` tool. Matching uses the ID, never the display name. The CLI command `dbx connections list --json` currently lists names and endpoints but does not return connection IDs. + +Receiving an update link only opens a prefilled edit dialog. Review the changes and click **Save** to persist them under the same connection ID and sidebar group. The link does not automatically save, test, or connect to the database. Canceling leaves the saved connection unchanged. An update is rejected if the ID is empty or unknown, the target is a one-time connection, or another connection dialog is already open; it never falls back to creating a connection. + +Updates support saved, field-based connections using the built-in MySQL, PostgreSQL, or SQL Server profile. Connections with a nonempty `connection_string` are not supported by this update flow. Only explicitly supplied fields change: + +| Parameter | Update behavior | +| --- | --- | +| `name`, `host` | Replace the current value; empty values are rejected | +| `port` | Replace the port with an integer from 1 to 65535 | +| `user`, `password` | Prefill credentials without trimming whitespace; an empty value clears the field | +| `database`, `url_params` | Replace the current value; an empty value clears the field | +| `ssl` | Update the SSL switch; effective TLS behavior follows the driver and URL parameters | + +Omitted fields retain their saved values, including port and SSL settings. To clear a field, include the parameter with an empty value, such as `database=&url_params=`. URL-encode parameter values; for example, use `%2B` for a literal `+` in a password. + +The usual Save behavior still applies, including credential sanitization and the connection’s Save password setting. + +Besides `id` and the fields above, only the optional protocol version `v=1` is accepted. Update links reject `type`, `url`, `one_time` (including `one_time=false`), unknown parameters, and duplicate parameter keys. They cannot change the driver profile or request automatic actions. + +Saving a refreshed SSO credential does not extend its lifetime. MCP can discover the saved profile, but it establishes its own database session and still needs a valid credential; the update does not transfer the desktop's active session to MCP. + Before testing system-level `dbx://` launches, install and open DBX Desktop once so the operating system can register the URL scheme. On macOS, use `open 'dbx://open'` or `open 'dbx://connection/new?...'` to test it. A deep link may expose a database password or Consul ACL token in browser and terminal history. Do not put credential-bearing links in shared logs, shell history, tickets, or chat messages. diff --git a/packages/app-tests/connectionDeepLink.test.ts b/packages/app-tests/connectionDeepLink.test.ts index 27237f6511..881b8adb10 100644 --- a/packages/app-tests/connectionDeepLink.test.ts +++ b/packages/app-tests/connectionDeepLink.test.ts @@ -1,6 +1,6 @@ import { strict as assert } from "node:assert"; import { test } from "vitest"; -import { parseConnectionDeepLink } from "../../apps/desktop/src/lib/connection/connectionDeepLink.ts"; +import { parseConnectionDeepLink, parseConnectionDeepLinkUpdate } from "../../apps/desktop/src/lib/connection/connectionDeepLink.ts"; test("parses dbx connection deep link query fields", () => { const draft = parseConnectionDeepLink("dbx://connection/new?type=postgres&host=db.internal&port=15432&user=app&database=orders&name=Orders"); @@ -84,3 +84,93 @@ test("ignores unsupported dbx deep link targets", () => { assert.equal(parseConnectionDeepLink("dbx://query/open?sql=select%201"), null); assert.equal(parseConnectionDeepLink("dbx://connections/new?type=postgres"), null); }); + +test("credential updates contain only explicit fields and do not apply new-connection defaults", () => { + const update = parseConnectionDeepLinkUpdate("dbx://connection/new?id=saved-connection&user=new-token&password=new-token"); + + assert.deepEqual(update, { + connectionId: "saved-connection", + patch: { username: "new-token", password: "new-token" }, + }); + assert.deepEqual(parseConnectionDeepLinkUpdate("dbx://connection/new?id=saved-connection"), { + connectionId: "saved-connection", + patch: {}, + }); +}); + +test("update links preserve encoded credentials and explicit empty fields", () => { + const link = new URL("dbx://connection/new"); + const username = " user + &=#/%中文 "; + const password = " password + &=#/%中文\t "; + for (const [key, value] of Object.entries({ id: "saved-connection", v: "1", name: " Orders ", host: " db.internal ", port: "15432", user: username, password, database: "", url_params: "", ssl: "false" })) { + link.searchParams.set(key, value); + } + + assert.deepEqual(parseConnectionDeepLinkUpdate(link.toString()), { + connectionId: "saved-connection", + patch: { name: "Orders", host: "db.internal", port: 15432, username, password, database: "", urlParams: "", ssl: false }, + }); + assert.deepEqual(parseConnectionDeepLinkUpdate("dbx://connection/new?id=saved-connection&user=&password="), { + connectionId: "saved-connection", + patch: { username: "", password: "" }, + }); +}); + +test("update parser leaves links without an ID on the existing create path", () => { + assert.equal(parseConnectionDeepLinkUpdate("dbx://connection/new?type=mysql&host=db.internal&one_time=true"), null); + assert.equal(parseConnectionDeepLink("dbx://connection/new?type=mysql&host=db.internal&one_time=true")?.oneTime, true); +}); + +test("ID-bearing links can never silently create new connections", () => { + for (const query of ["id=saved-connection", "id=", "id=%20%20", "id=a&id=b", "id=%00"]) { + assert.throws(() => parseConnectionDeepLink(`dbx://connection/new?${query}`), /cannot create a new connection/); + } + for (const query of ["id=", "id=%20%20", "id=%00", "id=%0Asaved-connection"]) { + assert.throws(() => parseConnectionDeepLinkUpdate(`dbx://connection/new?${query}`), /valid connection ID/); + } +}); + +test("update links reject ambiguous parameters and profile or automatic-action controls", () => { + for (const query of ["id=another", "password=one&password=two", "port=3306&port=3307", "ssl=true&ssl=false"]) { + assert.throws(() => parseConnectionDeepLinkUpdate(`dbx://connection/new?id=saved-connection&${query}`), /Duplicate connection update parameter/); + } + for (const query of ["type=mysql", "url=mysql%3A%2F%2Froot%40other.internal", "one_time=true", "one_time=false", "auto_save=true", "auto_connect=true", "save_password=true", "read_only=false", "unknown=value"]) { + assert.throws(() => parseConnectionDeepLinkUpdate(`dbx://connection/new?id=saved-connection&${query}`), /Unsupported connection update parameter/); + } +}); + +test("update links validate explicit port, SSL, version, and required display fields", () => { + for (const port of ["", "0", "65536", "-1", "3306.5", "1e3", "0xff", "invalid"]) { + assert.throws(() => parseConnectionDeepLinkUpdate(`dbx://connection/new?id=saved-connection&port=${port}`), /Invalid connection update port/); + } + for (const ssl of ["", "maybe", "2"]) { + assert.throws(() => parseConnectionDeepLinkUpdate(`dbx://connection/new?id=saved-connection&ssl=${ssl}`), /Invalid connection update SSL/); + } + for (const version of ["", "2"]) { + assert.throws(() => parseConnectionDeepLinkUpdate(`dbx://connection/new?id=saved-connection&v=${version}`), /Unsupported connection update version/); + } + for (const key of ["name", "host"]) { + assert.throws(() => parseConnectionDeepLinkUpdate(`dbx://connection/new?id=saved-connection&${key}=%20`), /cannot be empty/); + } + assert.equal(parseConnectionDeepLinkUpdate("dbx://connection/new?id=saved-connection&ssl=YES")?.patch.ssl, true); + assert.equal(parseConnectionDeepLinkUpdate("dbx://connection/new?id=saved-connection&ssl=off")?.patch.ssl, false); +}); + +test("update links enforce the protocol target and reject ambiguous authority or fragments", () => { + for (const link of ["not-a-url", "https://connection/new?id=saved-connection", "dbx://connection/edit?id=saved-connection", "dbx://connection/newer?id=saved-connection", "dbx://connections/new?id=saved-connection"]) { + assert.equal(parseConnectionDeepLinkUpdate(link), null); + } + for (const link of ["dbx://user:secret@connection/new?id=saved-connection", "dbx://connection:123/new?id=saved-connection", "dbx://connection/new?id=saved-connection#password=secret"]) { + assert.throws(() => parseConnectionDeepLinkUpdate(link), /Invalid connection update URL/); + } +}); + +test("update validation errors do not expose supplied parameter values", () => { + const secret = "do-not-log-this-secret"; + for (const key of ["port", "ssl", "v", "type"]) { + assert.throws( + () => parseConnectionDeepLinkUpdate(`dbx://connection/new?id=saved-connection&${key}=${secret}`), + (error: unknown) => error instanceof Error && !error.message.includes(secret), + ); + } +});