From 3118e0d617ed670740299013be527d402584de0f Mon Sep 17 00:00:00 2001 From: jearhe <0verme7753@gmail.com> Date: Sat, 22 Aug 2026 19:19:10 +0800 Subject: [PATCH] feat(config): support optional plaintext connection exports --- .../config/ConfigPassphraseDialog.vue | 12 +- .../__tests__/ConfigPassphraseDialog.spec.ts | 122 +++++++++++++++++ .../src/components/layout/AppDialogs.vue | 14 ++ .../__tests__/useDialogSources.spec.ts | 96 +++++++++++++- .../src/composables/useDialogSources.ts | 58 ++++++++- apps/desktop/src/i18n/locales/en.ts | 7 +- apps/desktop/src/i18n/locales/es.ts | 8 +- apps/desktop/src/i18n/locales/it.ts | 8 +- apps/desktop/src/i18n/locales/ja.ts | 7 +- apps/desktop/src/i18n/locales/ko.ts | 7 +- apps/desktop/src/i18n/locales/pt-BR.ts | 8 +- apps/desktop/src/i18n/locales/zh-CN.ts | 7 +- apps/desktop/src/i18n/locales/zh-TW.ts | 7 +- .../connection/connectionConfigTransfer.ts | 2 + .../__tests__/connectionStore.timeout.spec.ts | 28 +++- apps/desktop/src/stores/connectionStore.ts | 30 ++++- docs/content/docs/config-export.cn.mdx | 17 +-- docs/content/docs/config-export.mdx | 34 ++--- .../connectionImportRoundTrip.test.ts | 123 +++++++++++++++++- 19 files changed, 546 insertions(+), 49 deletions(-) create mode 100644 apps/desktop/src/components/config/__tests__/ConfigPassphraseDialog.spec.ts diff --git a/apps/desktop/src/components/config/ConfigPassphraseDialog.vue b/apps/desktop/src/components/config/ConfigPassphraseDialog.vue index 7b4c1541e7..eeb91607de 100644 --- a/apps/desktop/src/components/config/ConfigPassphraseDialog.vue +++ b/apps/desktop/src/components/config/ConfigPassphraseDialog.vue @@ -11,11 +11,13 @@ const props = defineProps<{ open: boolean; mode: "export" | "import"; externalError?: string; + busy?: boolean; }>(); const emit = defineEmits<{ "update:open": [value: boolean]; confirm: [passphrase: string]; + requestUnencrypted: []; }>(); const { t } = useI18n(); @@ -41,6 +43,7 @@ watch( ); function confirm() { + if (props.busy) return; if (!passphrase.value) { error.value = t("configExport.passphraseRequired"); return; @@ -76,19 +79,22 @@ const displayError = computed(() => error.value || props.externalError || "");
- +
- +

{{ displayError }}

- + diff --git a/apps/desktop/src/components/config/__tests__/ConfigPassphraseDialog.spec.ts b/apps/desktop/src/components/config/__tests__/ConfigPassphraseDialog.spec.ts new file mode 100644 index 0000000000..f322a0d1ea --- /dev/null +++ b/apps/desktop/src/components/config/__tests__/ConfigPassphraseDialog.spec.ts @@ -0,0 +1,122 @@ +// @vitest-environment happy-dom + +import { createApp, defineComponent, h, nextTick, type App } from "vue"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import i18n from "@/i18n"; + +vi.mock("@/components/ui/dialog", async () => { + const { defineComponent, h } = await import("vue"); + const passthrough = defineComponent({ + setup(_props, { slots }) { + return () => h("div", slots.default?.()); + }, + }); + return { Dialog: passthrough, DialogContent: passthrough, DialogFooter: passthrough, DialogHeader: passthrough, DialogTitle: passthrough }; +}); + +vi.mock("@/components/ui/button", async () => { + const { defineComponent, h } = await import("vue"); + return { + Button: defineComponent({ + props: { disabled: Boolean }, + emits: ["click"], + setup(props, { slots, emit }) { + return () => h("button", { disabled: props.disabled, onClick: () => emit("click") }, slots.default?.()); + }, + }), + }; +}); + +vi.mock("@/components/ui/label", async () => { + const { defineComponent, h } = await import("vue"); + return { + Label: defineComponent({ + setup(_props, { slots }) { + return () => h("label", slots.default?.()); + }, + }), + }; +}); + +vi.mock("@/components/ui/PasswordInput.vue", async () => { + const { defineComponent, h } = await import("vue"); + return { + default: defineComponent({ + props: { modelValue: String, disabled: Boolean }, + emits: ["update:modelValue"], + setup(props, { emit }) { + return () => h("input", { value: props.modelValue, disabled: props.disabled, onInput: (event: Event) => emit("update:modelValue", (event.target as HTMLInputElement).value) }); + }, + }), + }; +}); + +vi.mock("@lucide/vue", async () => { + const { defineComponent, h } = await import("vue"); + return { + Lock: defineComponent({ + setup() { + return () => h("span"); + }, + }), + }; +}); + +import ConfigPassphraseDialog from "@/components/config/ConfigPassphraseDialog.vue"; + +const mountedApps: App[] = []; + +afterEach(() => { + for (const app of mountedApps.splice(0)) app.unmount(); + while (document.body.firstChild) document.body.removeChild(document.body.firstChild); +}); + +async function mountDialog(props: Record) { + i18n.global.locale.value = "en"; + const container = document.createElement("div"); + document.body.append(container); + const app = createApp( + defineComponent({ + setup() { + return () => h(ConfigPassphraseDialog, props); + }, + }), + ); + mountedApps.push(app); + app.use(i18n); + app.mount(container); + await nextTick(); +} + +describe("ConfigPassphraseDialog", () => { + it("offers unencrypted export as an explicit secondary action", async () => { + const requestUnencrypted = vi.fn(); + const confirm = vi.fn(); + await mountDialog({ open: true, mode: "export", onRequestUnencrypted: requestUnencrypted, onConfirm: confirm }); + + const buttons = [...document.body.querySelectorAll("button")]; + const unencrypted = buttons.find((button) => button.textContent?.includes("Export without encryption")); + expect(unencrypted).toBeTruthy(); + unencrypted?.click(); + expect(requestUnencrypted).toHaveBeenCalledOnce(); + expect(confirm).not.toHaveBeenCalled(); + }); + + it("does not allow an empty encrypted passphrase", async () => { + const confirm = vi.fn(); + await mountDialog({ open: true, mode: "export", onConfirm: confirm }); + + const encrypted = [...document.body.querySelectorAll("button")].find((button) => button.textContent?.includes("Export encrypted")); + encrypted?.click(); + await nextTick(); + expect(confirm).not.toHaveBeenCalled(); + expect(document.body.textContent).toContain("Passphrase is required"); + }); + + it("keeps import mode free of the unencrypted export action", async () => { + await mountDialog({ open: true, mode: "import", onConfirm: vi.fn() }); + + expect(document.body.textContent).not.toContain("Export without encryption"); + expect(document.body.textContent).toContain("Decrypt"); + }); +}); diff --git a/apps/desktop/src/components/layout/AppDialogs.vue b/apps/desktop/src/components/layout/AppDialogs.vue index 0ce01d03e6..8c33c2bfae 100644 --- a/apps/desktop/src/components/layout/AppDialogs.vue +++ b/apps/desktop/src/components/layout/AppDialogs.vue @@ -290,9 +290,23 @@ watch( :open="dialogs.showConfigPassphraseDialog.value" :mode="dialogs.configPassphraseMode.value" :external-error="dialogs.configPassphraseError.value" + :busy="dialogs.configExportBusy.value" @update:open="dialogs.onConfigPassphraseOpenChange" + @request-unencrypted="dialogs.onRequestUnencryptedExport" @confirm="dialogs.configPassphraseMode.value === 'export' ? dialogs.onExportConfirm($event) : dialogs.onImportConfirm($event)" /> + + + + {{ t("configExport.unencryptedWarningTitle") }} + +

{{ t("configExport.unencryptedWarningDescription") }}

+ + + + +
+
diff --git a/apps/desktop/src/composables/__tests__/useDialogSources.spec.ts b/apps/desktop/src/composables/__tests__/useDialogSources.spec.ts index 3e9cd59227..4c97095d47 100644 --- a/apps/desktop/src/composables/__tests__/useDialogSources.spec.ts +++ b/apps/desktop/src/composables/__tests__/useDialogSources.spec.ts @@ -1,7 +1,7 @@ // @vitest-environment happy-dom import { createApp, defineComponent, h, nextTick, type App } from "vue"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import i18n from "@/i18n"; import type { ConnectionConfig } from "@/types/database"; @@ -35,6 +35,11 @@ import { useDialogSources } from "@/composables/useDialogSources"; const mountedApps: App[] = []; +beforeEach(() => { + mocks.store.connections = []; + mocks.store.exportConnectionsToFile.mockReset().mockResolvedValue("saved"); +}); + afterEach(() => { for (const app of mountedApps.splice(0)) app.unmount(); vi.clearAllMocks(); @@ -84,4 +89,93 @@ describe("useDialogSources", () => { await applyPromise; await nextTick(); }); + + it("requires confirmation before an unencrypted export and preserves the flow when cancelled", async () => { + mocks.store.connections = [conn("selected")]; + const dialogs = await mountDialogs(); + + dialogs.onExportClick(); + dialogs.onConfigConnectionSelectConfirm(["selected"]); + expect(dialogs.showConfigPassphraseDialog.value).toBe(true); + + dialogs.onRequestUnencryptedExport(); + expect(dialogs.showConfigPassphraseDialog.value).toBe(false); + expect(dialogs.showConfigUnencryptedExportConfirm.value).toBe(true); + expect(mocks.store.exportConnectionsToFile).not.toHaveBeenCalled(); + + dialogs.onConfigUnencryptedExportOpenChange(false); + expect(dialogs.showConfigUnencryptedExportConfirm.value).toBe(false); + expect(dialogs.showConfigPassphraseDialog.value).toBe(true); + expect(mocks.store.exportConnectionsToFile).not.toHaveBeenCalled(); + + dialogs.onRequestUnencryptedExport(); + await dialogs.onConfigUnencryptedExportConfirm(); + expect(mocks.store.exportConnectionsToFile).toHaveBeenCalledWith({ mode: "plaintext" }, ["selected"]); + }); + + it("keeps encrypted export state when the native save dialog is cancelled", async () => { + mocks.store.connections = [conn("selected")]; + mocks.store.exportConnectionsToFile.mockResolvedValueOnce("cancelled").mockResolvedValueOnce("saved"); + const dialogs = await mountDialogs(); + + dialogs.onExportClick(); + dialogs.onConfigConnectionSelectConfirm(["selected"]); + await dialogs.onExportConfirm("passphrase"); + + expect(dialogs.showConfigPassphraseDialog.value).toBe(true); + expect(mocks.toast).not.toHaveBeenCalled(); + + await dialogs.onExportConfirm("passphrase"); + expect(mocks.store.exportConnectionsToFile.mock.calls).toEqual([ + [{ mode: "encrypted", passphrase: "passphrase" }, ["selected"]], + [{ mode: "encrypted", passphrase: "passphrase" }, ["selected"]], + ]); + expect(dialogs.showConfigPassphraseDialog.value).toBe(false); + expect(mocks.toast).toHaveBeenCalledOnce(); + }); + + it("keeps plaintext export state when the native save dialog is cancelled", async () => { + mocks.store.connections = [conn("selected")]; + mocks.store.exportConnectionsToFile.mockResolvedValueOnce("cancelled").mockResolvedValueOnce("saved"); + const dialogs = await mountDialogs(); + + dialogs.onExportClick(); + dialogs.onConfigConnectionSelectConfirm(["selected"]); + dialogs.onRequestUnencryptedExport(); + await dialogs.onConfigUnencryptedExportConfirm(); + + expect(dialogs.showConfigUnencryptedExportConfirm.value).toBe(true); + expect(mocks.toast).not.toHaveBeenCalled(); + + await dialogs.onConfigUnencryptedExportConfirm(); + expect(mocks.store.exportConnectionsToFile.mock.calls).toEqual([ + [{ mode: "plaintext" }, ["selected"]], + [{ mode: "plaintext" }, ["selected"]], + ]); + expect(dialogs.showConfigUnencryptedExportConfirm.value).toBe(false); + expect(mocks.toast).toHaveBeenCalledOnce(); + }); + + it("prevents duplicate unencrypted exports while the file is being written", async () => { + let resolveExport!: () => void; + const exportPromise = new Promise((resolve) => { + resolveExport = resolve; + }); + mocks.store.connections = [conn("selected")]; + mocks.store.exportConnectionsToFile.mockReturnValue(exportPromise); + const dialogs = await mountDialogs(); + + dialogs.onExportClick(); + dialogs.onConfigConnectionSelectConfirm(["selected"]); + dialogs.onRequestUnencryptedExport(); + const first = dialogs.onConfigUnencryptedExportConfirm(); + const second = dialogs.onConfigUnencryptedExportConfirm(); + + expect(dialogs.configExportBusy.value).toBe(true); + expect(mocks.store.exportConnectionsToFile).toHaveBeenCalledTimes(1); + resolveExport(); + await first; + await second; + expect(dialogs.configExportBusy.value).toBe(false); + }); }); diff --git a/apps/desktop/src/composables/useDialogSources.ts b/apps/desktop/src/composables/useDialogSources.ts index 5e464b94b7..425b9a4f22 100644 --- a/apps/desktop/src/composables/useDialogSources.ts +++ b/apps/desktop/src/composables/useDialogSources.ts @@ -22,6 +22,8 @@ const pendingImportLayout = ref(null); const showConfigPassphraseDialog = ref(false); const configPassphraseMode = ref<"export" | "import">("export"); const configPassphraseError = ref(""); +const showConfigUnencryptedExportConfirm = ref(false); +const configExportBusy = ref(false); const pendingImportContent = ref(""); const showConfigConnectionSelectDialog = ref(false); const configConnectionSelectMode = ref<"export" | "import">("export"); @@ -330,16 +332,57 @@ export function useDialogSources() { } async function onExportConfirm(passphrase: string) { + if (configExportBusy.value) return; + configExportBusy.value = true; try { - await connectionStore.exportConnectionsToFile(passphrase, pendingExportConnectionIds.value); + const result = await connectionStore.exportConnectionsToFile({ mode: "encrypted", passphrase }, pendingExportConnectionIds.value); + if (result === "cancelled") return; showConfigPassphraseDialog.value = false; clearPendingExportState(); toast(t("configExport.exportSuccess"), 2000); } catch (e: any) { - configPassphraseError.value = e?.message === "crypto_unavailable" ? t("configExport.cryptoUnavailable") : e?.message || String(e); + configPassphraseError.value = e?.message === "crypto_unavailable" ? t("configExport.cryptoUnavailable") : e?.message === "passphrase_required" ? t("configExport.passphraseRequired") : t("configExport.exportFailed"); + } finally { + configExportBusy.value = false; } } + function onRequestUnencryptedExport() { + if (configExportBusy.value) return; + showConfigPassphraseDialog.value = false; + showConfigUnencryptedExportConfirm.value = true; + } + + async function onConfigUnencryptedExportConfirm() { + if (configExportBusy.value) return; + configExportBusy.value = true; + try { + const result = await connectionStore.exportConnectionsToFile({ mode: "plaintext" }, pendingExportConnectionIds.value); + if (result === "cancelled") return; + showConfigUnencryptedExportConfirm.value = false; + clearPendingExportState(); + toast(t("configExport.exportSuccess"), 2000); + } catch { + showConfigUnencryptedExportConfirm.value = false; + configPassphraseError.value = t("configExport.exportFailed"); + showConfigPassphraseDialog.value = true; + } finally { + configExportBusy.value = false; + } + } + + function onConfigUnencryptedExportCancel() { + if (configExportBusy.value) return; + showConfigUnencryptedExportConfirm.value = false; + showConfigPassphraseDialog.value = true; + } + + function onConfigUnencryptedExportOpenChange(open: boolean) { + if (configExportBusy.value) return; + showConfigUnencryptedExportConfirm.value = open; + if (!open) showConfigPassphraseDialog.value = true; + } + async function onImportClick(source: "dbx" | "navicat" | "dbeaver" | "datagrip" = "dbx") { try { const result = await connectionStore.readImportFile(source); @@ -414,8 +457,9 @@ export function useDialogSources() { function onConfigPassphraseOpenChange(open: boolean) { showConfigPassphraseDialog.value = open; if (open) return; - if (configPassphraseMode.value === "export") clearPendingExportState(); - else if (!pendingImportPreview.value) clearPendingImportState(); + if (configPassphraseMode.value === "export") { + if (!showConfigUnencryptedExportConfirm.value) clearPendingExportState(); + } else if (!pendingImportPreview.value) clearPendingImportState(); } return { @@ -435,6 +479,8 @@ export function useDialogSources() { showConfigPassphraseDialog, configPassphraseMode, configPassphraseError, + showConfigUnencryptedExportConfirm, + configExportBusy, pendingImportContent, showConfigConnectionSelectDialog, applyingImportSelection, @@ -489,6 +535,10 @@ export function useDialogSources() { databaseExportAllDatabases, onExportClick, onExportConfirm, + onRequestUnencryptedExport, + onConfigUnencryptedExportConfirm, + onConfigUnencryptedExportCancel, + onConfigUnencryptedExportOpenChange, onImportClick, onImportConfirm, onConfigConnectionSelectConfirm, diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 98c6c4648f..7e18ae8567 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -2342,10 +2342,15 @@ export default { passphraseRequired: "Passphrase is required", passphraseMismatch: "Passphrases do not match", passphraseTooShort: "Passphrase must be at least 4 characters", - exportEncrypted: "Export", + exportEncrypted: "Export encrypted", + exportUnencrypted: "Export without encryption", + unencryptedWarningTitle: "Export without encryption?", + unencryptedWarningDescription: "An unencrypted configuration file may contain database passwords, SSH Tunnel credentials, and other sensitive information. Anyone who can access the file may read these credentials. Use this only for temporary migration in a trusted environment.", + confirmUnencryptedExport: "Export without encryption", decryptImport: "Decrypt", wrongPassphrase: "Wrong passphrase or corrupted file", cryptoUnavailable: "Encrypted config import is unavailable in this browser context. Use HTTPS, localhost, or DBX Desktop.", + exportFailed: "Failed to export connections", exportSuccess: "Connections exported successfully", importSuccess: "Imported {count} connection(s)", importNavicatSuccess: "Imported {count} Navicat connection(s). Fill in any connection whose password is still empty before testing.", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 6af0ba820d..455cdcb054 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -2277,10 +2277,16 @@ export default withEnglishFallback({ passphraseRequired: "La frase de paso es obligatoria", passphraseMismatch: "Las frases de paso no coinciden", passphraseTooShort: "La frase de paso debe tener al menos 4 caracteres", - exportEncrypted: "Exportar", + exportEncrypted: "Exportar cifrado", + exportUnencrypted: "Exportar sin cifrado", + unencryptedWarningTitle: "¿Exportar sin cifrado?", + unencryptedWarningDescription: + "El archivo de configuración sin cifrar puede contener contraseñas de bases de datos, credenciales de SSH Tunnel y otra información sensible. Cualquiera que acceda al archivo podría leer estas credenciales. Úsalo solo para una migración temporal en un entorno de confianza.", + confirmUnencryptedExport: "Exportar sin cifrado", decryptImport: "Descifrar", wrongPassphrase: "Frase de paso incorrecta o archivo corrupto", cryptoUnavailable: "La importación cifrada no está disponible en este navegador. Usa HTTPS, localhost o DBX Desktop.", + exportFailed: "No se pudieron exportar las conexiones", exportSuccess: "Conexiones exportadas exitosamente", importSuccess: "Se importaron {count} conexión(es)", importNavicatSuccess: "Se importaron {count} conexión(es) de Navicat. Completa la contraseña de las conexiones que aún la tengan vacía antes de probarlas.", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index ba24e0fcc3..a28bf2f35b 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -2275,10 +2275,16 @@ export default withEnglishFallback({ passphraseRequired: "La passphrase è richiesta", passphraseMismatch: "Le passphrase non corrispondono", passphraseTooShort: "La passphrase deve essere di almeno 4 caratteri", - exportEncrypted: "Esporta", + exportEncrypted: "Esporta crittografato", + exportUnencrypted: "Esporta senza crittografia", + unencryptedWarningTitle: "Esportare senza crittografia?", + unencryptedWarningDescription: + "Il file di configurazione non crittografato può contenere password del database, credenziali SSH Tunnel e altre informazioni sensibili. Chiunque possa accedere al file potrebbe leggere queste credenziali. Usalo solo per una migrazione temporanea in un ambiente fidato.", + confirmUnencryptedExport: "Esporta senza crittografia", decryptImport: "Decrittografa", wrongPassphrase: "Passphrase errata o file corrotto", cryptoUnavailable: "L'importazione cifrata non è disponibile in questo contesto del browser. Usa HTTPS, localhost o DBX Desktop.", + exportFailed: "Impossibile esportare le connessioni", exportSuccess: "Connessioni esportate con successo", importSuccess: "Importata/e {count} connessione/i", importNavicatSuccess: "Importata/e {count} connessione/i Navicat. Inserisci le password mancanti prima di testare la connessione.", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index a2097b00bd..1058760b5f 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -2302,10 +2302,15 @@ export default withEnglishFallback({ passphraseRequired: "パスフレーズは必須です", passphraseMismatch: "パスフレーズが一致しません", passphraseTooShort: "パスフレーズは4文字以上必要です", - exportEncrypted: "エクスポート", + exportEncrypted: "暗号化してエクスポート", + exportUnencrypted: "暗号化せずにエクスポート", + unencryptedWarningTitle: "暗号化せずにエクスポートしますか?", + unencryptedWarningDescription: "暗号化されていない設定ファイルには、データベースパスワード、SSH Tunnel の認証情報などの機密情報が含まれる場合があります。ファイルにアクセスできる人はこれらの認証情報を読めます。信頼できる環境での一時的な移行にのみ使用してください。", + confirmUnencryptedExport: "暗号化せずにエクスポート", decryptImport: "復号", wrongPassphrase: "パスフレーズが間違っているか、ファイルが破損しています", cryptoUnavailable: "暗号化設定のインポートはこのブラウザ環境では利用できません。HTTPS、localhost、またはDBX Desktopを使用してください。", + exportFailed: "接続のエクスポートに失敗しました", exportSuccess: "接続を正常にエクスポートしました", importSuccess: "{count}件の接続をインポートしました", importNavicatSuccess: "{count}件のNavicat接続をインポートしました。テスト前にパスワードが空の接続を入力してください。", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 3f1174ec4e..c8fbdd7201 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -2193,10 +2193,15 @@ export default withEnglishFallback({ passphraseRequired: "암호구는 필수입니다", passphraseMismatch: "암호구가 일치하지 않습니다", passphraseTooShort: "암호구는 최소 4자 이상이어야 합니다", - exportEncrypted: "내보내기", + exportEncrypted: "암호화하여 내보내기", + exportUnencrypted: "암호화하지 않고 내보내기", + unencryptedWarningTitle: "암호화하지 않고 내보내시겠습니까?", + unencryptedWarningDescription: "암호화되지 않은 구성 파일에는 데이터베이스 비밀번호, SSH Tunnel 자격 증명 및 기타 민감한 정보가 포함될 수 있습니다. 파일에 접근할 수 있는 사람은 이 자격 증명을 읽을 수 있습니다. 신뢰할 수 있는 환경에서 임시 마이그레이션을 할 때만 사용하세요.", + confirmUnencryptedExport: "암호화하지 않고 내보내기", decryptImport: "복호화", wrongPassphrase: "암호구가 잘못되었거나 파일이 손상되었습니다", cryptoUnavailable: "이 브라우저 환경에서는 암호화된 설정 가져오기를 사용할 수 없습니다. HTTPS, localhost 또는 DBX 데스크톱을 사용하세요.", + exportFailed: "연결을 내보내지 못했습니다", exportSuccess: "연결을 성공적으로 내보냈습니다", importSuccess: "연결 {count}개를 가져왔습니다", importNavicatSuccess: "Navicat 연결 {count}개를 가져왔습니다. 테스트하기 전에 비밀번호가 비어 있는 연결의 비밀번호를 입력하세요.", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 7d7efb8282..bd9a66b42f 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -2277,10 +2277,16 @@ export default withEnglishFallback({ passphraseRequired: "A frase secreta é obrigatória", passphraseMismatch: "As frases secretas não coincidem", passphraseTooShort: "A frase secreta deve ter pelo menos 4 caracteres", - exportEncrypted: "Exportar", + exportEncrypted: "Exportar criptografado", + exportUnencrypted: "Exportar sem criptografia", + unencryptedWarningTitle: "Exportar sem criptografia?", + unencryptedWarningDescription: + "O arquivo de configuração sem criptografia pode conter senhas de banco de dados, credenciais de SSH Tunnel e outras informações sensíveis. Qualquer pessoa com acesso ao arquivo poderá ler essas credenciais. Use apenas para uma migração temporária em um ambiente confiável.", + confirmUnencryptedExport: "Exportar sem criptografia", decryptImport: "Descriptografar", wrongPassphrase: "Frase secreta incorreta ou arquivo corrompido", cryptoUnavailable: "A importação criptografada não está disponível neste contexto do navegador. Use HTTPS, localhost ou DBX Desktop.", + exportFailed: "Falha ao exportar as conexões", exportSuccess: "Conexões exportadas com sucesso", importSuccess: "{count} conexão(ões) importada(s)", importNavicatSuccess: "{count} conexão(ões) do Navicat importada(s). Preencha qualquer conexão cuja senha ainda esteja vazia antes de testar.", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 150d82fa3c..ba77948900 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -2266,10 +2266,15 @@ export default withEnglishFallback({ passphraseRequired: "请输入密码短语", passphraseMismatch: "两次输入的密码短语不一致", passphraseTooShort: "密码短语至少需要 4 个字符", - exportEncrypted: "导出", + exportEncrypted: "加密导出", + exportUnencrypted: "不加密导出", + unencryptedWarningTitle: "确认不加密导出?", + unencryptedWarningDescription: "未加密的配置文件可能包含数据库密码、SSH Tunnel 等敏感信息。任何能够访问该文件的人都可能读取这些凭据。仅建议在可信环境下用于临时迁移。", + confirmUnencryptedExport: "仍然不加密导出", decryptImport: "解密", wrongPassphrase: "密码短语错误或文件已损坏", cryptoUnavailable: "当前浏览器环境不支持加密配置导入,请使用 HTTPS、localhost 或 DBX 桌面版。", + exportFailed: "连接配置导出失败", exportSuccess: "连接配置导出成功", importSuccess: "已导入 {count} 个连接", importNavicatSuccess: "已导入 {count} 个 Navicat 连接,若个别连接密码为空请补填后测试连接", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 70df83358e..81f9cabe3d 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -2276,10 +2276,15 @@ export default withEnglishFallback({ passphraseRequired: "請輸入加密密碼", passphraseMismatch: "兩次輸入的加密密碼不一致", passphraseTooShort: "加密密碼至少需要 4 個字元", - exportEncrypted: "匯出", + exportEncrypted: "加密匯出", + exportUnencrypted: "不加密匯出", + unencryptedWarningTitle: "確認不加密匯出?", + unencryptedWarningDescription: "未加密的設定檔可能包含資料庫密碼、SSH Tunnel 等敏感資訊。任何能存取該檔案的人都可能讀取這些憑據。僅建議在可信任環境中用於臨時遷移。", + confirmUnencryptedExport: "仍然不加密匯出", decryptImport: "解密", wrongPassphrase: "加密密碼錯誤或檔案已損壞", cryptoUnavailable: "目前瀏覽器環境不支援加密設定匯入,請使用 HTTPS、localhost 或 DBX 桌面版。", + exportFailed: "連線設定匯出失敗", exportSuccess: "連線設定匯出成功", importSuccess: "已匯入 {count} 個連線", importNavicatSuccess: "已匯入 {count} 個 Navicat 連線,若個別連線密碼為空請補填後測試連線", diff --git a/apps/desktop/src/lib/connection/connectionConfigTransfer.ts b/apps/desktop/src/lib/connection/connectionConfigTransfer.ts index 7d0695d28f..c2f463dae9 100644 --- a/apps/desktop/src/lib/connection/connectionConfigTransfer.ts +++ b/apps/desktop/src/lib/connection/connectionConfigTransfer.ts @@ -1,6 +1,8 @@ import type { ConnectionConfig, SidebarLayout, TunnelProfile } from "@/types/database"; import { filterSidebarLayoutByConnectionIds as filterLayoutByConnectionIds } from "@/lib/sidebar/sidebarLayout"; +export type ConnectionExportProtection = { mode: "encrypted"; passphrase: string } | { mode: "plaintext" }; + export interface ConnectionConfigBundle { connections: ConnectionConfig[]; layout?: SidebarLayout; diff --git a/apps/desktop/src/stores/__tests__/connectionStore.timeout.spec.ts b/apps/desktop/src/stores/__tests__/connectionStore.timeout.spec.ts index c60a605b82..eb7ff81621 100644 --- a/apps/desktop/src/stores/__tests__/connectionStore.timeout.spec.ts +++ b/apps/desktop/src/stores/__tests__/connectionStore.timeout.spec.ts @@ -344,7 +344,7 @@ describe("connectionStore timeout recovery", () => { const { useConnectionStore } = await import("@/stores/connectionStore"); const store = useConnectionStore(); await store.initFromDisk(); - await store.exportConnectionsToFile("test-passphrase"); + await store.exportConnectionsToFile({ mode: "encrypted", passphrase: "test-passphrase" }); const exported = JSON.parse(encryptConfig.mock.calls[0]?.[0] as string); expect(exported.connections[0]).toMatchObject({ @@ -356,6 +356,32 @@ describe("connectionStore timeout recovery", () => { expect(click).toHaveBeenCalledOnce(); }); + it("reports cancellation when the native export save dialog is dismissed", async () => { + const save = vi.fn().mockResolvedValue(null); + const writeTextFile = vi.fn().mockResolvedValue(undefined); + vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => true })); + vi.doMock("@tauri-apps/plugin-dialog", () => ({ save })); + vi.doMock("@tauri-apps/plugin-fs", () => ({ writeTextFile })); + vi.doMock("@/lib/backend/api", () => ({ + deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined), + loadEditorSettings: vi.fn().mockResolvedValue(null), + loadConnections: vi.fn().mockResolvedValue([postgresConnection()]), + loadPinnedTreeNodeIds: vi.fn().mockResolvedValue([]), + loadSidebarLayout: vi.fn().mockResolvedValue(null), + loadTunnelProfiles: vi.fn().mockResolvedValue([]), + saveConnections: vi.fn().mockResolvedValue(undefined), + saveEditorSettings: vi.fn().mockResolvedValue(undefined), + saveSidebarLayout: vi.fn().mockResolvedValue(undefined), + })); + + const { useConnectionStore } = await import("@/stores/connectionStore"); + const store = useConnectionStore(); + await store.initFromDisk(); + + await expect(store.exportConnectionsToFile({ mode: "plaintext" })).resolves.toBe("cancelled"); + expect(writeTextFile).not.toHaveBeenCalled(); + }); + it("clears connection node loading when health check timeout forces reconnect failure", async () => { const checkConnectionHealth = vi.fn(() => new Promise(() => undefined)); const connectDb = vi.fn().mockRejectedValue(new Error("reconnect failed")); diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index 62b77a5c45..8f6fae45ab 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -62,7 +62,17 @@ import { type DropPosition, type ReorderEntriesOptions, } from "@/lib/sidebar/sidebarLayout"; -import { buildConnectionConfigBundle, filterSidebarLayoutByConnectionIds, filterTunnelProfilesByIds, parseConnectionConfigObject, referencedTunnelProfileIds, selectConnectionConfigBundle, snapshotConnectionsForExport, type ConnectionConfigBundle } from "@/lib/connection/connectionConfigTransfer"; +import { + buildConnectionConfigBundle, + filterSidebarLayoutByConnectionIds, + filterTunnelProfilesByIds, + parseConnectionConfigObject, + referencedTunnelProfileIds, + selectConnectionConfigBundle, + snapshotConnectionsForExport, + type ConnectionConfigBundle, + type ConnectionExportProtection, +} from "@/lib/connection/connectionConfigTransfer"; import type { SqlCompletionColumn, SqlCompletionForeignKey, SqlCompletionObject, SqlCompletionTable } from "@/lib/sql/sqlCompletion"; import { mergeSqlObjectNavigationType, sqlObjectNavigationTypeFromTableType } from "@/lib/sql/sqlNavigation"; import * as api from "@/lib/backend/api"; @@ -7962,8 +7972,9 @@ export const useConnectionStore = defineStore("connection", () => { await refreshNodes(treeNodes.value); } - async function exportConnectionsToFile(passphrase: string, selectedConnectionIds?: Iterable) { - const { encryptConfig } = await import("@/lib/backend/configCrypto"); + async function exportConnectionsToFile(protection: ConnectionExportProtection, selectedConnectionIds?: Iterable) { + if (protection.mode === "encrypted" && !protection.passphrase) throw new Error("passphrase_required"); + const tunnelProfileStore = useTunnelProfileStore(); await tunnelProfileStore.init(); // Older DBX versions ignore inheritance flags, so always include the @@ -7974,8 +7985,14 @@ export const useConnectionStore = defineStore("connection", () => { }); const exportData = buildConnectionConfigBundle(exportedConnections, sidebarLayout.value, tunnelProfileStore.profiles, selectedConnectionIds); const json = JSON.stringify(exportData); - const payload = await encryptConfig(json, passphrase); - const content = JSON.stringify(payload, null, 2); + let content: string; + if (protection.mode === "encrypted") { + const { encryptConfig } = await import("@/lib/backend/configCrypto"); + const payload = await encryptConfig(json, protection.passphrase); + content = JSON.stringify(payload, null, 2); + } else { + content = JSON.stringify(exportData, null, 2); + } if (isTauriRuntime()) { const { save } = await import("@tauri-apps/plugin-dialog"); @@ -7984,7 +8001,7 @@ export const useConnectionStore = defineStore("connection", () => { filters: [{ name: "JSON", extensions: ["json"] }], defaultPath: "dbx-connections.json", }); - if (!path) return; + if (!path) return "cancelled" as const; await writeTextFile(path, content); } else { const blob = new Blob([content], { type: "application/json" }); @@ -7995,6 +8012,7 @@ export const useConnectionStore = defineStore("connection", () => { a.click(); URL.revokeObjectURL(url); } + return "saved" as const; } function bytesToBase64(bytes: Uint8Array) { diff --git a/docs/content/docs/config-export.cn.mdx b/docs/content/docs/config-export.cn.mdx index 562956c1fe..e1164e2e46 100644 --- a/docs/content/docs/config-export.cn.mdx +++ b/docs/content/docs/config-export.cn.mdx @@ -3,7 +3,7 @@ title: 配置导出/导入 description: 导出数据库连接配置(AES-256-GCM 加密),安全地在不同电脑之间迁移。 --- -导出你的数据库连接配置(含加密密码),用 AES-256-GCM 加密保护,安全地在不同电脑之间迁移。 +导出你的数据库连接配置(含加密密码),用 AES-256-GCM 加密保护,安全地在不同电脑之间迁移。默认使用加密导出。 这里导出的是 DBX 连接配置,不包含业务数据库的表结构或数据,也不是数据库备份。数据库内容请使用 [数据库导出](/cn/docs/database-export) 或 [数据库备份](/cn/docs/database-backup)。 @@ -12,15 +12,15 @@ description: 导出数据库连接配置(AES-256-GCM 加密),安全地在 ### 发起导出 点击侧边栏顶部的 **导出** 按钮。 ### 选择连接 勾选一个或多个连接。默认全选。最终只会导出已选连接、相关的侧边栏分组和排序,以及引用的隧道配置(Tunnel Profile)。 - ### 设置加密密码 输入密码短语来加密连接密码。 - ### 保存文件 选择保存位置。导出文件只包含已选连接及密码,已加密。 + ### 选择导出保护 输入密码短语来加密连接密码。仅在可信环境下临时迁移时,也可以选择**不加密导出**并确认风险。明文文件可能包含数据库密码、SSH Tunnel 凭据和其他敏感信息。 + ### 保存文件 选择保存位置。导出文件只包含已选连接、相关布局和引用的 Tunnel Profile。推荐使用加密导出。 ## 导入 ### 发起导入 点击侧边栏顶部的 **导入** 按钮。 - ### 选择文件并解密 选择导出的文件。加密文件会先要求输入密码短语,DBX 会先解密并预览,此时不会写入本地连接。 + ### 选择文件并解密 选择导出的文件。DBX 会自动识别加密和明文配置文件;加密文件会先要求输入密码短语,DBX 会先解密并预览,此时不会写入本地连接。 ### 选择连接 勾选文件中要导入的连接。默认全选。 ### 完成 只有已选连接会被导入,密码自动恢复。按 名称 + 主机 + 端口 自动去重。已选连接的侧边栏分组和排序仍可在导入后选择应用。 @@ -28,14 +28,15 @@ description: 导出数据库连接配置(AES-256-GCM 加密),安全地在 ## 安全性 - 密码使用 **AES-256-GCM** 加密,密钥通过 **PBKDF2** 派生 -- 没有密码短语无法解密文件 +- 加密文件没有密码短语无法解密 +- 明文导出未加密,可能暴露数据库密码、SSH Tunnel 凭据和其他敏感信息;仅建议在可信环境下用于临时迁移 - 导出文件是标准 JSON 格式,可以通过 U 盘、网盘等方式传输 -- DBX 会把普通连接字段与数据库密码、SSH 密码、SSH 密钥密码、完整连接字符串等密钥信息分开存储;导出时只会在加密文件中重新组合。 +- DBX 会把普通连接字段与数据库密码、SSH 密码、SSH 密钥密码、完整连接字符串等密钥信息分开存储。加密导出会保护组合后的数据;明文导出会把这些敏感信息写成可直接读取的 JSON。 - 请把导出口令当作密钥管理。任何人同时拿到导出文件和口令,都可以恢复其中的连接。 ## 向后兼容 -导入同样支持旧版本导出的明文 JSON 文件(不含密码)和原始的连接配置数组。 +导入会自动识别加密或明文配置文件,也支持旧版本导出的明文 JSON 文件(不含密码)和原始的连接配置数组。 ## 包含哪些内容 @@ -43,7 +44,7 @@ description: 导出数据库连接配置(AES-256-GCM 加密),安全地在 | ---------------------------------------------- | ---------------------------------------------------- | | 连接名称、类型、主机、端口、数据库、颜色 | 包含 | | URL 参数、SSL、SSH、代理、可见数据库设置 | 包含 | -| 数据库密码、SSH 密码、SSH 密钥密码、连接字符串 | 包含,并在导出文件中加密 | +| 数据库密码、SSH 密码、SSH 密钥密码、连接字符串 | 包含;加密导出中加密,明文导出中可直接读取 | | 厂商 JDBC 驱动 JAR 文件 | 不包含;目标机器需要重新安装或导入 | | 已选连接的侧边栏分组和排序 | 包含;导入连接后可选择是否应用 | | 已保存 SQL 和界面/编辑器设置 | 不包含;需要迁移时使用 [云同步](/cn/docs/cloud-sync) | diff --git a/docs/content/docs/config-export.mdx b/docs/content/docs/config-export.mdx index f384fe1a85..dd8225e48e 100644 --- a/docs/content/docs/config-export.mdx +++ b/docs/content/docs/config-export.mdx @@ -3,7 +3,7 @@ title: Config Export/Import description: Export database connections with AES-256-GCM encrypted passwords. Migrate securely between machines. --- -Export your database connections with encrypted passwords (AES-256-GCM). Securely migrate between machines. +Export your database connections with encrypted passwords (AES-256-GCM). Securely migrate between machines. Encrypted export remains the default. This exports DBX connection configuration, not business database schema or data, and it is not a database backup. Use [Database Export](/en/docs/database-export) or [Database Backup](/en/docs/database-backup) for database contents. @@ -12,15 +12,18 @@ description: Export database connections with AES-256-GCM encrypted passwords. M ### Start Export Click the **Export** button in the sidebar header. ### Select Connections Choose one or more connections. All connections are selected by default. Only the selected connections, their sidebar groups and ordering, and referenced tunnel profiles are exported. - ### Set Passphrase Enter a passphrase to encrypt your passwords. - ### Save File Choose a save location. The exported file contains the selected connections with passwords, encrypted. + + ### Choose Export Protection Enter a passphrase to encrypt your passwords. For a one-time migration in a trusted environment, you may instead choose **Export without encryption** and confirm the warning. The plaintext file may contain database passwords, SSH Tunnel credentials, and other + sensitive information. + + ### Save File Choose a save location. The exported file contains only the selected connections, their related layout, and referenced tunnel profiles. Encrypted export is recommended. ## Import ### Start Import Click the **Import** button in the sidebar header. - ### Select File and Decrypt Select the exported file. Encrypted files ask for the passphrase first so DBX can decrypt and preview them without writing anything yet. + ### Select File and Decrypt Select the exported file. DBX automatically detects encrypted and plain configuration files. Encrypted files ask for the passphrase first so DBX can decrypt and preview them without writing anything yet. ### Select Connections Choose which connections from the file to import. All connections are selected by default. ### Done Only the selected connections are imported, with passwords restored. Deduplicated by name + host + port. Sidebar groups and ordering for the selected connections can still be applied after import. @@ -28,24 +31,25 @@ description: Export database connections with AES-256-GCM encrypted passwords. M ## Security - Passwords are encrypted using **AES-256-GCM** with **PBKDF2** key derivation -- Without the passphrase, the file cannot be decrypted +- Without the passphrase, an encrypted file cannot be decrypted +- Plaintext export is not encrypted and may expose database passwords, SSH Tunnel credentials, and other secrets; use it only for temporary migration in a trusted environment - The export file is standard JSON, transferable via USB drive, cloud storage, etc. -- DBX stores ordinary connection fields separately from secrets such as database passwords, SSH passwords, SSH key passphrases, and full connection strings; export brings them together only inside the encrypted export file. +- DBX stores ordinary connection fields separately from secrets such as database passwords, SSH passwords, SSH key passphrases, and full connection strings. Encrypted export protects the combined data; plaintext export writes those secrets as readable JSON. - Treat the passphrase like a secret. Anyone with both the export file and passphrase can restore the saved connections. ## Backward Compatibility -Import also supports plain JSON files from older versions (without passwords) and raw connection config arrays. +Import automatically detects encrypted or plain configuration files. It also supports plain JSON files from older versions (without passwords) and raw connection config arrays. ## What Is Included -| Item | Included | -| ---------------------------------------------------------------------- | --------------------------------------------------------- | -| Connection name, type, host, port, database, color | Yes | -| URL parameters, SSL, SSH, proxy, visible database settings | Yes | -| Database password, SSH password, SSH key passphrase, connection string | Yes, encrypted in the export file | -| Vendor JDBC driver JAR files | No; reinstall or re-import drivers on the target machine | -| Sidebar groups and ordering for selected connections | Yes; choose whether to apply them after import | -| Saved SQL and UI/editor settings | No; use [Cloud Sync](/en/docs/cloud-sync) to migrate them | +| Item | Included | +| ---------------------------------------------------------------------- | --------------------------------------------------------------------- | +| Connection name, type, host, port, database, color | Yes | +| URL parameters, SSL, SSH, proxy, visible database settings | Yes | +| Database password, SSH password, SSH key passphrase, connection string | Yes; encrypted in encrypted exports and readable in plaintext exports | +| Vendor JDBC driver JAR files | No; reinstall or re-import drivers on the target machine | +| Sidebar groups and ordering for selected connections | Yes; choose whether to apply them after import | +| Saved SQL and UI/editor settings | No; use [Cloud Sync](/en/docs/cloud-sync) to migrate them | For JDBC migrations, export the connection settings here, then use [JDBC Plugin](/en/docs/plugins) to install the plugin and import driver JARs on the destination machine. diff --git a/packages/app-tests/connectionImportRoundTrip.test.ts b/packages/app-tests/connectionImportRoundTrip.test.ts index 9337651091..4ac145aad7 100644 --- a/packages/app-tests/connectionImportRoundTrip.test.ts +++ b/packages/app-tests/connectionImportRoundTrip.test.ts @@ -4,6 +4,7 @@ import { createPinia, setActivePinia } from "pinia"; import { useConnectionStore } from "../../apps/desktop/src/stores/connectionStore.ts"; import { hasSidebarLayoutEntries } from "../../apps/desktop/src/lib/sidebar/sidebarLayout.ts"; import { decryptConfig } from "../../apps/desktop/src/lib/backend/configCrypto.ts"; +import type { ConnectionExportProtection } from "../../apps/desktop/src/lib/connection/connectionConfigTransfer.ts"; import type { ConnectionConfig, SidebarLayout, TreeNode, TunnelProfile } from "../../apps/desktop/src/types/database.ts"; const PASSPHRASE = "round-trip-pass"; @@ -114,7 +115,7 @@ function outline(nodes: TreeNode[]): string[] { } /** Build "machine A" through the real store API and export it through the real export path. */ -async function exportFromMachineA(): Promise { +async function exportFromMachineA(protection: ConnectionExportProtection = { mode: "encrypted", passphrase: PASSPHRASE }): Promise { const backend = installBackend([], null); const capture = installExportCapture(); const storage = installMemoryStorage(); @@ -132,7 +133,7 @@ async function exportFromMachineA(): Promise { assert.deepEqual(outline(store.treeNodes), ["[Prod]", "Prod/Prod DB 1", "Prod/Prod DB 2", "[Dev]", "Dev/Dev DB 1", "Scratch"]); - await store.exportConnectionsToFile(PASSPHRASE); + await store.exportConnectionsToFile(protection); return await capture.content(); } finally { storage.restore(); @@ -228,7 +229,7 @@ test("selective encrypted export keeps only chosen connections, layout, and tunn await store.addConnection(conn("a-4", "Scratch", 3309), null); const selected = store.connections.filter((connection) => connection.name === "Prod DB 1" || connection.name === "Dev DB 1").map((connection) => connection.id); - await store.exportConnectionsToFile(PASSPHRASE, selected); + await store.exportConnectionsToFile({ mode: "encrypted", passphrase: PASSPHRASE }, selected); const decrypted = await decryptExport(await capture.content()); assert.deepEqual( @@ -263,6 +264,59 @@ test("selective encrypted export keeps only chosen connections, layout, and tunn } }); +test("selective plaintext export keeps only chosen connections, layout, and tunnel profiles", async () => { + const backend = installBackend([], null, [ + { type: "ssh", id: "tunnel-1", host: "bastion-1", port: 22, user: "root" }, + { type: "ssh", id: "tunnel-2", host: "bastion-2", port: 22, user: "root" }, + ]); + const capture = installExportCapture(); + const storage = installMemoryStorage(); + try { + setActivePinia(createPinia()); + const store = useConnectionStore(); + await store.initFromDisk(); + + const prod = store.createConnectionGroup("Prod"); + const dev = store.createConnectionGroup("Dev"); + await store.addConnection({ ...conn("a-1", "Prod DB 1", 3306), transport_layers: [tunnelLayer("layer-a", "tunnel-1")] }, prod); + await store.addConnection({ ...conn("a-2", "Prod DB 2", 3307), transport_layers: [tunnelLayer("layer-b", "tunnel-2")] }, prod); + await store.addConnection({ ...conn("a-3", "Dev DB 1", 3308), transport_layers: [tunnelLayer("layer-c", "tunnel-1")] }, dev); + await store.addConnection(conn("a-4", "Scratch", 3309), null); + + const selected = store.connections.filter((connection) => connection.name === "Prod DB 1" || connection.name === "Dev DB 1").map((connection) => connection.id); + await store.exportConnectionsToFile({ mode: "plaintext" }, selected); + const plain = JSON.parse(await capture.content()); + + assert.equal(plain.format, undefined); + assert.equal(plain.salt, undefined); + assert.deepEqual( + plain.connections.map((connection: ConnectionConfig) => connection.name), + ["Prod DB 1", "Dev DB 1"], + ); + assert.deepEqual( + plain.tunnelProfiles.map((profile: TunnelProfile) => profile.id), + ["tunnel-1"], + ); + assert.deepEqual( + plain.layout.groups.map((group: { name: string }) => group.name), + ["Prod", "Dev"], + ); + const layoutIds: string[] = []; + const walk = (entries: Array<{ type: string; id: string; children?: typeof entries }>) => { + for (const entry of entries) { + if (entry.type === "connection") layoutIds.push(entry.id); + else if (entry.children) walk(entry.children); + } + }; + walk(plain.layout.order); + assert.deepEqual(layoutIds.sort(), selected.slice().sort()); + } finally { + storage.restore(); + capture.restore(); + backend.restore(); + } +}); + test("selective encrypted import applies only chosen connections and later remaining ones", async () => { const exported = await exportFromMachineA(); const backend = installBackend([], null); @@ -351,6 +405,69 @@ test("wrong passphrase and preview-only parse do not mutate local connections", } }); +test("plaintext dbx export round-trips through the password-free import path", async () => { + const exported = await exportFromMachineA({ mode: "plaintext" }); + const backend = installBackend([], null); + const storage = installMemoryStorage(); + try { + setActivePinia(createPinia()); + const store = useConnectionStore(); + await store.initFromDisk(); + + const preview = await store.parseConnectionsImport(exported, null); + assert.equal(preview.connections.length, 4); + assert.ok(preview.layout); + const selectedIds = preview.connections.filter((connection) => connection.name === "Prod DB 1" || connection.name === "Dev DB 1").map((connection) => connection.id); + const result = await store.applyConnectionsImport(preview, selectedIds); + + assert.equal(result.count, 2); + assert.ok(result.layout); + assert.deepEqual(store.connections.map((connection) => connection.name).sort(), ["Dev DB 1", "Prod DB 1"]); + } finally { + storage.restore(); + backend.restore(); + } +}); + +test("encrypted export rejects an empty passphrase instead of selecting plaintext", async () => { + const backend = installBackend([], null); + const storage = installMemoryStorage(); + try { + setActivePinia(createPinia()); + const store = useConnectionStore(); + await store.initFromDisk(); + + await assert.rejects(() => store.exportConnectionsToFile({ mode: "encrypted", passphrase: "" }), /passphrase_required/); + } finally { + storage.restore(); + backend.restore(); + } +}); + +test("encrypted export fails closed when Web Crypto is unavailable", async () => { + const backend = installBackend([], null); + const capture = installExportCapture(); + const storage = installMemoryStorage(); + const originalCrypto = globalThis.crypto; + Object.defineProperty(globalThis, "crypto", { + configurable: true, + value: { getRandomValues: originalCrypto.getRandomValues.bind(originalCrypto) }, + }); + try { + setActivePinia(createPinia()); + const store = useConnectionStore(); + await store.initFromDisk(); + + await assert.rejects(() => store.exportConnectionsToFile({ mode: "encrypted", passphrase: PASSPHRASE }), /crypto_unavailable/); + await assert.rejects(() => capture.content(), /export did not produce a file blob/); + } finally { + Object.defineProperty(globalThis, "crypto", { configurable: true, value: originalCrypto }); + storage.restore(); + capture.restore(); + backend.restore(); + } +}); + test("plain legacy dbx config still imports a selected subset", async () => { const backend = installBackend([], null); const storage = installMemoryStorage();