From 6320b6c2152a8e95f6d860aec265043fbf40074d Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 22 May 2026 20:50:37 +0700 Subject: [PATCH 1/2] fix(import): restore TablePlus password and SSH key import --- CHANGELOG.md | 3 + .../ForeignApp/BeekeeperStudioImporter.swift | 1 + .../Export/ForeignApp/DBeaverImporter.swift | 1 + .../Export/ForeignApp/DataGripImporter.swift | 1 + .../ForeignApp/ForeignAppImporter.swift | 3 + .../Export/ForeignApp/SequelAceImporter.swift | 1 + .../Export/ForeignApp/TablePlusImporter.swift | 28 +++- .../ImportFromApp/ImportFromAppSheet.swift | 2 +- .../ForeignAppImporterRegistryTests.swift | 9 ++ .../ForeignApp/TablePlusImporterTests.swift | 121 +++++++++++++++++- 10 files changed, 159 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f2a1feb6..795cd7da0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Importing connections from TablePlus brings over saved passwords again. A recent release looked under the wrong keychain name, so connections imported with no passwords and no warning. +- Importing an SSH connection from TablePlus no longer fills in a fake private key path such as `~/.ssh/Import a private key...` when no key was selected. Empty TLS certificate paths are skipped too. +- Importing from DBeaver no longer shows an unnecessary keychain permission warning. DBeaver stores passwords in its own file, so macOS never prompts. - Raw SQL filter now suggests columns and keywords at every position in the expression, including after AND and OR, instead of only the first column. (#1346) - Plugins left incompatible after a TablePro update now update quietly in the background instead of showing a premature "could not be loaded" alert. You are only notified when no compatible version exists yet, and the message tells you what to do. (#1322) - A plugin you download and install by hand is no longer blocked by macOS Gatekeeper once its signature is verified. (#1322) diff --git a/TablePro/Core/Services/Export/ForeignApp/BeekeeperStudioImporter.swift b/TablePro/Core/Services/Export/ForeignApp/BeekeeperStudioImporter.swift index 139215268..965826f68 100644 --- a/TablePro/Core/Services/Export/ForeignApp/BeekeeperStudioImporter.swift +++ b/TablePro/Core/Services/Export/ForeignApp/BeekeeperStudioImporter.swift @@ -26,6 +26,7 @@ struct BeekeeperStudioImporter: ForeignAppImporter { let displayName = "Beekeeper Studio" let symbolName = "ant" let appBundleIdentifier = "io.beekeeperstudio.desktop" + let readsPasswordsFromKeychain = false var dataDirectoryURL: URL = FileManager.default.homeDirectoryForCurrentUser .appendingPathComponent("Library/Application Support/beekeeper-studio") diff --git a/TablePro/Core/Services/Export/ForeignApp/DBeaverImporter.swift b/TablePro/Core/Services/Export/ForeignApp/DBeaverImporter.swift index 94da91b1f..9e220b1ec 100644 --- a/TablePro/Core/Services/Export/ForeignApp/DBeaverImporter.swift +++ b/TablePro/Core/Services/Export/ForeignApp/DBeaverImporter.swift @@ -16,6 +16,7 @@ struct DBeaverImporter: ForeignAppImporter { let displayName = "DBeaver" let symbolName = "bird" let appBundleIdentifier = "org.jkiss.dbeaver.core.product" + let readsPasswordsFromKeychain = false /// All known DBeaver Eclipse product identifiers. Community, Enterprise, /// Ultimate, and Lite variants each register a different bundle ID, but diff --git a/TablePro/Core/Services/Export/ForeignApp/DataGripImporter.swift b/TablePro/Core/Services/Export/ForeignApp/DataGripImporter.swift index c0af9c53e..45b9b05f0 100644 --- a/TablePro/Core/Services/Export/ForeignApp/DataGripImporter.swift +++ b/TablePro/Core/Services/Export/ForeignApp/DataGripImporter.swift @@ -15,6 +15,7 @@ struct DataGripImporter: ForeignAppImporter { let displayName = "DataGrip" let symbolName = "cylinder.split.1x2" let appBundleIdentifier = "com.jetbrains.datagrip" + let readsPasswordsFromKeychain = true /// Root holding versioned IDE config dirs (`DataGrip2024.3`, ...). Injectable for tests. var jetBrainsRoot: URL = FileManager.default.homeDirectoryForCurrentUser diff --git a/TablePro/Core/Services/Export/ForeignApp/ForeignAppImporter.swift b/TablePro/Core/Services/Export/ForeignApp/ForeignAppImporter.swift index 418417371..0d9d5a471 100644 --- a/TablePro/Core/Services/Export/ForeignApp/ForeignAppImporter.swift +++ b/TablePro/Core/Services/Export/ForeignApp/ForeignAppImporter.swift @@ -18,6 +18,7 @@ protocol ForeignAppImporter { /// app ships in multiple editions (e.g. DBeaver Community / Enterprise) /// should override `installedAppURL()` to look those up as well. var appBundleIdentifier: String { get } + var readsPasswordsFromKeychain: Bool { get } func installedAppURL() -> URL? func connectionCount() -> Int func importConnections(includePasswords: Bool) throws -> ForeignAppImportResult @@ -103,6 +104,8 @@ enum KeychainReadResult { case cancelled } +typealias ForeignKeychainRead = (_ service: String, _ account: String) -> KeychainReadResult + enum ForeignKeychainReader { private static let logger = Logger(subsystem: "com.TablePro", category: "ForeignKeychainReader") diff --git a/TablePro/Core/Services/Export/ForeignApp/SequelAceImporter.swift b/TablePro/Core/Services/Export/ForeignApp/SequelAceImporter.swift index 10fb91a99..89ee6db67 100644 --- a/TablePro/Core/Services/Export/ForeignApp/SequelAceImporter.swift +++ b/TablePro/Core/Services/Export/ForeignApp/SequelAceImporter.swift @@ -13,6 +13,7 @@ struct SequelAceImporter: ForeignAppImporter { let displayName = "Sequel Ace" let symbolName = "cylinder.split.1x2" let appBundleIdentifier = "com.sequel-ace.sequel-ace" + let readsPasswordsFromKeychain = true var favoritesFileURL: URL = FileManager.default.homeDirectoryForCurrentUser .appendingPathComponent( diff --git a/TablePro/Core/Services/Export/ForeignApp/TablePlusImporter.swift b/TablePro/Core/Services/Export/ForeignApp/TablePlusImporter.swift index a72892d9f..500d3d184 100644 --- a/TablePro/Core/Services/Export/ForeignApp/TablePlusImporter.swift +++ b/TablePro/Core/Services/Export/ForeignApp/TablePlusImporter.swift @@ -14,6 +14,11 @@ struct TablePlusImporter: ForeignAppImporter { let displayName = "TablePlus" let symbolName = "rectangle.stack" let appBundleIdentifier = "com.tinyapp.TablePlus" + let readsPasswordsFromKeychain = true + + static let keychainService = "com.tableplus.TablePlus" + + var readKeychain: ForeignKeychainRead = ForeignKeychainReader.readPassword var connectionsFileURL: URL = FileManager.default.homeDirectoryForCurrentUser .appendingPathComponent("Library/Application Support/com.tinyapp.TablePlus/Data/Connections.plist") @@ -182,8 +187,7 @@ struct TablePlusImporter: ForeignAppImporter { let port = (entry["ServerPort"] as? String).flatMap(Int.init) let username = entry["ServerUser"] as? String ?? "" let useKey = entry["isUsePrivateKey"] as? Bool ?? false - let rawKeyPath = entry["ServerPrivateKeyName"] as? String ?? "" - let keyPath = ForeignAppPathHelper.resolveKeyPath(rawKeyPath) + let keyPath = useKey ? existingKeyPath(entry["ServerPrivateKeyName"] as? String ?? "") : "" return ExportableSSHConfig( enabled: true, @@ -191,7 +195,7 @@ struct TablePlusImporter: ForeignAppImporter { port: port, username: username, authMethod: useKey ? "Private Key" : "Password", - privateKeyPath: useKey ? keyPath : "", + privateKeyPath: keyPath, agentSocketPath: "", jumpHosts: nil, totpMode: nil, @@ -201,6 +205,12 @@ struct TablePlusImporter: ForeignAppImporter { ) } + private func existingKeyPath(_ rawName: String) -> String { + let resolved = ForeignAppPathHelper.resolveKeyPath(rawName) + guard !resolved.isEmpty else { return "" } + return FileManager.default.fileExists(atPath: PathPortability.expandHome(resolved)) ? resolved : "" + } + private func parseSSLConfig(_ entry: [String: Any]) -> ExportableSSLConfig? { guard entry.keys.contains("tLSMode") else { return nil } let tlsMode = entry["tLSMode"] as? Int ?? 0 @@ -215,18 +225,22 @@ struct TablePlusImporter: ForeignAppImporter { } let paths = entry["TlsKeyPaths"] as? [String] ?? [] + func certPath(_ index: Int) -> String? { + guard index < paths.count, !paths[index].isEmpty else { return nil } + return paths[index] + } return ExportableSSLConfig( mode: mode, - caCertificatePath: !paths.isEmpty ? paths[0] : nil, - clientCertificatePath: paths.count > 1 ? paths[1] : nil, - clientKeyPath: paths.count > 2 ? paths[2] : nil + caCertificatePath: certPath(0), + clientCertificatePath: certPath(1), + clientKeyPath: certPath(2) ) } private func readCredentials(for connectionId: String, abortFlag: inout Bool) -> ExportableCredentials { func read(_ account: String) -> String? { guard !abortFlag else { return nil } - switch ForeignKeychainReader.readPassword(service: "com.tinyapp.TablePlus", account: account) { + switch readKeychain(Self.keychainService, account) { case .found(let value): return value case .notFound: diff --git a/TablePro/Views/Connection/ImportFromApp/ImportFromAppSheet.swift b/TablePro/Views/Connection/ImportFromApp/ImportFromAppSheet.swift index 5bcbccd16..03d517a01 100644 --- a/TablePro/Views/Connection/ImportFromApp/ImportFromAppSheet.swift +++ b/TablePro/Views/Connection/ImportFromApp/ImportFromAppSheet.swift @@ -102,7 +102,7 @@ struct ImportFromAppSheet: View { // MARK: - Actions private func beginImport(importer: any ForeignAppImporter, includePasswords: Bool) { - if includePasswords, !confirmKeychainPrompts(for: importer) { + if includePasswords, importer.readsPasswordsFromKeychain, !confirmKeychainPrompts(for: importer) { return } startImport(importer: importer, includePasswords: includePasswords) diff --git a/TableProTests/Core/Services/ForeignApp/ForeignAppImporterRegistryTests.swift b/TableProTests/Core/Services/ForeignApp/ForeignAppImporterRegistryTests.swift index 3369ff9dd..5d6c5d026 100644 --- a/TableProTests/Core/Services/ForeignApp/ForeignAppImporterRegistryTests.swift +++ b/TableProTests/Core/Services/ForeignApp/ForeignAppImporterRegistryTests.swift @@ -85,4 +85,13 @@ struct ForeignAppImporterRegistryTests { #expect(importer.displayName == "Beekeeper Studio") #expect(importer.appBundleIdentifier == "io.beekeeperstudio.desktop") } + + @Test("Importers declare whether passwords are read from the keychain") + func testReadsPasswordsFromKeychainFlags() { + #expect(TablePlusImporter().readsPasswordsFromKeychain == true) + #expect(SequelAceImporter().readsPasswordsFromKeychain == true) + #expect(DataGripImporter().readsPasswordsFromKeychain == true) + #expect(DBeaverImporter().readsPasswordsFromKeychain == false) + #expect(BeekeeperStudioImporter().readsPasswordsFromKeychain == false) + } } diff --git a/TableProTests/Core/Services/ForeignApp/TablePlusImporterTests.swift b/TableProTests/Core/Services/ForeignApp/TablePlusImporterTests.swift index c9c510a89..8cf6b40a8 100644 --- a/TableProTests/Core/Services/ForeignApp/TablePlusImporterTests.swift +++ b/TableProTests/Core/Services/ForeignApp/TablePlusImporterTests.swift @@ -168,8 +168,11 @@ struct TablePlusImporterTests { } } - @Test("importConnections parses SSH config") + @Test("importConnections parses SSH config and keeps a key path that exists on disk") func testImportConnections_parsesSSHConfig() throws { + let keyFile = tempDir.appendingPathComponent("id_rsa") + try Data("key".utf8).write(to: keyFile) + try writeConnections([ makeConnection( name: "SSH DB", @@ -179,7 +182,7 @@ struct TablePlusImporterTests { sshPort: "2222", sshUser: "deploy", usePrivateKey: true, - privateKeyPath: "~/.ssh/id_rsa" + privateKeyPath: keyFile.path ) ]) @@ -193,7 +196,28 @@ struct TablePlusImporterTests { #expect(ssh?.port == 2222) #expect(ssh?.username == "deploy") #expect(ssh?.authMethod == "Private Key") - #expect(ssh?.privateKeyPath == "~/.ssh/id_rsa") + #expect(ssh?.privateKeyPath == keyFile.path) + } + + @Test("importConnections drops the empty-key placeholder instead of building a fake path") + func testImportConnections_placeholderPrivateKey_producesNoPath() throws { + try writeConnections([ + makeConnection( + name: "SSH Placeholder", + id: "ssh-placeholder", + isOverSSH: true, + sshHost: "bastion.example.com", + sshUser: "deploy", + usePrivateKey: true, + privateKeyPath: "Import a private key..." + ) + ]) + + let result = try importer.importConnections(includePasswords: false) + let ssh = result.envelope.connections[0].sshConfig + + #expect(ssh?.authMethod == "Private Key") + #expect(ssh?.privateKeyPath == "") } @Test("importConnections parses SSH config with password auth") @@ -296,6 +320,21 @@ struct TablePlusImporterTests { #expect(result.envelope.connections[0].sslConfig == nil) } + @Test("importConnections treats empty TablePlus TLS paths as none") + func testImportConnections_emptyTLSPaths_areNil() throws { + var entry = makeConnection(name: "Empty TLS", id: "tls-empty", tlsMode: 1) + entry["TlsKeyPaths"] = ["", "", ""] + try writeConnections([entry]) + + let result = try importer.importConnections(includePasswords: false) + let ssl = result.envelope.connections[0].sslConfig + + #expect(ssl != nil) + #expect(ssl?.caCertificatePath == nil) + #expect(ssl?.clientCertificatePath == nil) + #expect(ssl?.clientKeyPath == nil) + } + @Test("importConnections preserves groups") func testImportConnections_preservesGroups() throws { try writeGroups([ @@ -444,4 +483,80 @@ struct TablePlusImporterTests { #expect(result.envelope.appVersion == "TablePlus Import") #expect(result.envelope.tags == nil) } + + // MARK: - Password Import + + @Test("importConnections reads the database password from the correct keychain service") + func testImportConnections_readsCorrectKeychainServiceAndAccount() throws { + try writeConnections([makeConnection(name: "DB", id: "conn-1")]) + let spy = KeychainSpy() + spy.responses["conn-1_database"] = .found("s3cret") + + var imp = importer + imp.readKeychain = spy.read + + let result = try imp.importConnections(includePasswords: true) + + #expect(spy.calls.contains { $0.service == "com.tableplus.TablePlus" && $0.account == "conn-1_database" }) + #expect(result.envelope.credentials?["0"]?.password == "s3cret") + #expect(result.credentialsAborted == false) + } + + @Test("importConnections queries database, SSH, and key-passphrase accounts") + func testImportConnections_queriesAllCredentialAccounts() throws { + try writeConnections([makeConnection(name: "DB", id: "conn-1")]) + let spy = KeychainSpy() + + var imp = importer + imp.readKeychain = spy.read + + _ = try imp.importConnections(includePasswords: true) + + let accounts = Set(spy.calls.map(\.account)) + #expect(accounts == ["conn-1_database", "conn-1_server", "conn-1_server_key"]) + #expect(spy.calls.allSatisfy { $0.service == "com.tableplus.TablePlus" }) + } + + @Test("importConnections leaves credentials empty and does not abort when nothing is stored") + func testImportConnections_noStoredPasswords_emptyCredentialsNoAbort() throws { + try writeConnections([makeConnection(name: "DB", id: "conn-1")]) + let spy = KeychainSpy() + + var imp = importer + imp.readKeychain = spy.read + + let result = try imp.importConnections(includePasswords: true) + + #expect(result.envelope.credentials == nil) + #expect(result.credentialsAborted == false) + } + + @Test("importConnections aborts and stops reading after a cancelled keychain prompt") + func testImportConnections_cancelledPrompt_abortsAndStops() throws { + try writeConnections([ + makeConnection(name: "A", id: "c1"), + makeConnection(name: "B", id: "c2") + ]) + let spy = KeychainSpy() + spy.responses["c1_database"] = .cancelled + + var imp = importer + imp.readKeychain = spy.read + + let result = try imp.importConnections(includePasswords: true) + + #expect(result.credentialsAborted == true) + #expect(spy.calls.count == 1) + #expect(spy.calls.first?.account == "c1_database") + } +} + +private final class KeychainSpy { + var calls: [(service: String, account: String)] = [] + var responses: [String: KeychainReadResult] = [:] + + func read(_ service: String, _ account: String) -> KeychainReadResult { + calls.append((service: service, account: account)) + return responses[account] ?? .notFound + } } From 7564c2494379856f0646f96cdced9a157943af77 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 22 May 2026 21:02:11 +0700 Subject: [PATCH 2/2] refactor(import): preserve explicit TablePlus key paths and add test seams --- .../ForeignApp/ForeignAppImporter.swift | 3 ++ .../Export/ForeignApp/TablePlusImporter.swift | 11 +++-- .../ImportFromApp/ImportFromAppSheet.swift | 7 ++- .../ForeignAppImporterRegistryTests.swift | 7 +++ .../ForeignApp/TablePlusImporterTests.swift | 45 ++++++++++++++----- 5 files changed, 57 insertions(+), 16 deletions(-) diff --git a/TablePro/Core/Services/Export/ForeignApp/ForeignAppImporter.swift b/TablePro/Core/Services/Export/ForeignApp/ForeignAppImporter.swift index 0d9d5a471..9c28d17f7 100644 --- a/TablePro/Core/Services/Export/ForeignApp/ForeignAppImporter.swift +++ b/TablePro/Core/Services/Export/ForeignApp/ForeignAppImporter.swift @@ -18,6 +18,9 @@ protocol ForeignAppImporter { /// app ships in multiple editions (e.g. DBeaver Community / Enterprise) /// should override `installedAppURL()` to look those up as well. var appBundleIdentifier: String { get } + /// True when importing passwords reads the macOS keychain, which makes the + /// system show a per-item access prompt. Importers that read passwords from + /// a file (DBeaver, Beekeeper Studio) return false so no prompt is promised. var readsPasswordsFromKeychain: Bool { get } func installedAppURL() -> URL? func connectionCount() -> Int diff --git a/TablePro/Core/Services/Export/ForeignApp/TablePlusImporter.swift b/TablePro/Core/Services/Export/ForeignApp/TablePlusImporter.swift index 500d3d184..b3030a6e7 100644 --- a/TablePro/Core/Services/Export/ForeignApp/TablePlusImporter.swift +++ b/TablePro/Core/Services/Export/ForeignApp/TablePlusImporter.swift @@ -19,6 +19,7 @@ struct TablePlusImporter: ForeignAppImporter { static let keychainService = "com.tableplus.TablePlus" var readKeychain: ForeignKeychainRead = ForeignKeychainReader.readPassword + var keyFileExists: (_ path: String) -> Bool = { FileManager.default.fileExists(atPath: $0) } var connectionsFileURL: URL = FileManager.default.homeDirectoryForCurrentUser .appendingPathComponent("Library/Application Support/com.tinyapp.TablePlus/Data/Connections.plist") @@ -187,7 +188,7 @@ struct TablePlusImporter: ForeignAppImporter { let port = (entry["ServerPort"] as? String).flatMap(Int.init) let username = entry["ServerUser"] as? String ?? "" let useKey = entry["isUsePrivateKey"] as? Bool ?? false - let keyPath = useKey ? existingKeyPath(entry["ServerPrivateKeyName"] as? String ?? "") : "" + let keyPath = useKey ? importedKeyPath(entry["ServerPrivateKeyName"] as? String ?? "") : "" return ExportableSSHConfig( enabled: true, @@ -205,10 +206,12 @@ struct TablePlusImporter: ForeignAppImporter { ) } - private func existingKeyPath(_ rawName: String) -> String { - let resolved = ForeignAppPathHelper.resolveKeyPath(rawName) + private func importedKeyPath(_ rawName: String) -> String { + let trimmed = rawName.trimmingCharacters(in: .whitespaces) + let resolved = ForeignAppPathHelper.resolveKeyPath(trimmed) guard !resolved.isEmpty else { return "" } - return FileManager.default.fileExists(atPath: PathPortability.expandHome(resolved)) ? resolved : "" + if trimmed.hasPrefix("/") || trimmed.hasPrefix("~/") { return resolved } + return keyFileExists(PathPortability.expandHome(resolved)) ? resolved : "" } private func parseSSLConfig(_ entry: [String: Any]) -> ExportableSSLConfig? { diff --git a/TablePro/Views/Connection/ImportFromApp/ImportFromAppSheet.swift b/TablePro/Views/Connection/ImportFromApp/ImportFromAppSheet.swift index 03d517a01..aeb222b26 100644 --- a/TablePro/Views/Connection/ImportFromApp/ImportFromAppSheet.swift +++ b/TablePro/Views/Connection/ImportFromApp/ImportFromAppSheet.swift @@ -101,8 +101,13 @@ struct ImportFromAppSheet: View { // MARK: - Actions + static func requiresKeychainConfirmation(includePasswords: Bool, importer: any ForeignAppImporter) -> Bool { + includePasswords && importer.readsPasswordsFromKeychain + } + private func beginImport(importer: any ForeignAppImporter, includePasswords: Bool) { - if includePasswords, importer.readsPasswordsFromKeychain, !confirmKeychainPrompts(for: importer) { + if Self.requiresKeychainConfirmation(includePasswords: includePasswords, importer: importer), + !confirmKeychainPrompts(for: importer) { return } startImport(importer: importer, includePasswords: includePasswords) diff --git a/TableProTests/Core/Services/ForeignApp/ForeignAppImporterRegistryTests.swift b/TableProTests/Core/Services/ForeignApp/ForeignAppImporterRegistryTests.swift index 5d6c5d026..cda7cb174 100644 --- a/TableProTests/Core/Services/ForeignApp/ForeignAppImporterRegistryTests.swift +++ b/TableProTests/Core/Services/ForeignApp/ForeignAppImporterRegistryTests.swift @@ -94,4 +94,11 @@ struct ForeignAppImporterRegistryTests { #expect(DBeaverImporter().readsPasswordsFromKeychain == false) #expect(BeekeeperStudioImporter().readsPasswordsFromKeychain == false) } + + @Test("Keychain confirmation applies only to keychain-based importers when importing passwords") + func testRequiresKeychainConfirmation() { + #expect(ImportFromAppSheet.requiresKeychainConfirmation(includePasswords: true, importer: TablePlusImporter())) + #expect(!ImportFromAppSheet.requiresKeychainConfirmation(includePasswords: true, importer: DBeaverImporter())) + #expect(!ImportFromAppSheet.requiresKeychainConfirmation(includePasswords: false, importer: TablePlusImporter())) + } } diff --git a/TableProTests/Core/Services/ForeignApp/TablePlusImporterTests.swift b/TableProTests/Core/Services/ForeignApp/TablePlusImporterTests.swift index 8cf6b40a8..93235ad07 100644 --- a/TableProTests/Core/Services/ForeignApp/TablePlusImporterTests.swift +++ b/TableProTests/Core/Services/ForeignApp/TablePlusImporterTests.swift @@ -168,11 +168,8 @@ struct TablePlusImporterTests { } } - @Test("importConnections parses SSH config and keeps a key path that exists on disk") + @Test("importConnections parses SSH config and keeps an explicit key path even when the file is missing") func testImportConnections_parsesSSHConfig() throws { - let keyFile = tempDir.appendingPathComponent("id_rsa") - try Data("key".utf8).write(to: keyFile) - try writeConnections([ makeConnection( name: "SSH DB", @@ -182,21 +179,23 @@ struct TablePlusImporterTests { sshPort: "2222", sshUser: "deploy", usePrivateKey: true, - privateKeyPath: keyFile.path + privateKeyPath: "/Users/test/.ssh/id_rsa" ) ]) - let result = try importer.importConnections(includePasswords: false) - let conn = result.envelope.connections[0] - let ssh = conn.sshConfig + var imp = importer + imp.keyFileExists = { _ in false } + + let result = try imp.importConnections(includePasswords: false) + let ssh = result.envelope.connections[0].sshConfig #expect(ssh != nil) #expect(ssh?.enabled == true) #expect(ssh?.host == "bastion.example.com") - #expect(ssh?.port == 2222) + #expect(ssh?.port == 2_222) #expect(ssh?.username == "deploy") #expect(ssh?.authMethod == "Private Key") - #expect(ssh?.privateKeyPath == keyFile.path) + #expect(ssh?.privateKeyPath == "/Users/test/.ssh/id_rsa") } @Test("importConnections drops the empty-key placeholder instead of building a fake path") @@ -213,13 +212,37 @@ struct TablePlusImporterTests { ) ]) - let result = try importer.importConnections(includePasswords: false) + var imp = importer + imp.keyFileExists = { _ in false } + + let result = try imp.importConnections(includePasswords: false) let ssh = result.envelope.connections[0].sshConfig #expect(ssh?.authMethod == "Private Key") #expect(ssh?.privateKeyPath == "") } + @Test("importConnections keeps a bare key name when the file exists in ~/.ssh") + func testImportConnections_bareKeyName_keptWhenFileExists() throws { + try writeConnections([ + makeConnection( + name: "SSH Bare Key", + id: "ssh-bare", + isOverSSH: true, + sshHost: "bastion.example.com", + sshUser: "deploy", + usePrivateKey: true, + privateKeyPath: "id_rsa" + ) + ]) + + var imp = importer + imp.keyFileExists = { _ in true } + + let result = try imp.importConnections(includePasswords: false) + #expect(result.envelope.connections[0].sshConfig?.privateKeyPath == "~/.ssh/id_rsa") + } + @Test("importConnections parses SSH config with password auth") func testImportConnections_parsesSSHConfigPasswordAuth() throws { try writeConnections([