diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e566c07a..e6975574c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- SSL/TLS always being enabled for MongoDB, Redis, and ClickHouse connections due to case mismatch in SSL mode string comparison (#249) +- Redis sidebar click showing data briefly then going empty due to double-navigation race condition (#251) +- MongoDB showing "Invalid database name: ''" when connecting without a database name + ### Added - Safe mode levels: per-connection setting with 6 levels (Silent, Alert, Alert Full, Safe Mode, Safe Mode Full, Read-Only) replacing the boolean read-only toggle, with confirmation dialogs and Touch ID/password authentication for stricter levels diff --git a/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift b/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift index 5caab6a9c..99d61c9c7 100644 --- a/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift +++ b/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift @@ -78,8 +78,8 @@ final class ClickHousePluginDriver: PluginDatabaseDriver, @unchecked Sendable { func connect() async throws { let useTLS = config.additionalFields["sslMode"] != nil - && config.additionalFields["sslMode"] != "disable" - let skipVerification = config.additionalFields["sslMode"] == "required" + && config.additionalFields["sslMode"] != "Disabled" + let skipVerification = config.additionalFields["sslMode"] == "Required" let urlConfig = URLSessionConfiguration.default urlConfig.timeoutIntervalForRequest = 30 @@ -533,7 +533,7 @@ final class ClickHousePluginDriver: PluginDatabaseDriver, @unchecked Sendable { private func buildRequest(query: String, database: String, queryId: String? = nil) throws -> URLRequest { let useTLS = config.additionalFields["sslMode"] != nil - && config.additionalFields["sslMode"] != "disable" + && config.additionalFields["sslMode"] != "Disabled" var components = URLComponents() components.scheme = useTLS ? "https" : "http" diff --git a/Plugins/MongoDBDriverPlugin/MongoDBConnection.swift b/Plugins/MongoDBDriverPlugin/MongoDBConnection.swift index d93c2f5fb..9280ec4c4 100644 --- a/Plugins/MongoDBDriverPlugin/MongoDBConnection.swift +++ b/Plugins/MongoDBDriverPlugin/MongoDBConnection.swift @@ -104,7 +104,7 @@ final class MongoDBConnection: @unchecked Sendable { user: String, password: String?, database: String, - sslMode: String = "disabled", + sslMode: String = "Disabled", sslCACertPath: String = "", sslClientCertPath: String = "", readPreference: String? = nil, @@ -169,10 +169,10 @@ final class MongoDBConnection: @unchecked Sendable { "authSource=admin" ] - let sslEnabled = sslMode != "disabled" && !sslMode.isEmpty + let sslEnabled = ["Preferred", "Required", "Verify CA", "Verify Identity"].contains(sslMode) if sslEnabled { params.append("tls=true") - let verifiesCert = sslMode == "verify_ca" || sslMode == "verify_identity" + let verifiesCert = sslMode == "Verify CA" || sslMode == "Verify Identity" if !verifiesCert { params.append("tlsAllowInvalidCertificates=true") } diff --git a/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift b/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift index 02ea8e5cd..9686b4ccc 100644 --- a/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift +++ b/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift @@ -28,6 +28,8 @@ final class MongoDBPluginDriver: PluginDatabaseDriver { self.currentDb = config.database } + private static let systemDatabases: Set = ["admin", "local", "config"] + // MARK: - Connection Management func connect() async throws { @@ -37,7 +39,7 @@ final class MongoDBPluginDriver: PluginDatabaseDriver { user: config.username, password: config.password, database: currentDb, - sslMode: config.additionalFields["sslMode"] ?? "disabled", + sslMode: config.additionalFields["sslMode"] ?? "Disabled", sslCACertPath: config.additionalFields["sslCACertPath"] ?? "", sslClientCertPath: config.additionalFields["sslClientCertPath"] ?? "", readPreference: config.additionalFields["mongoReadPreference"], @@ -45,6 +47,17 @@ final class MongoDBPluginDriver: PluginDatabaseDriver { ) try await conn.connect() + + if currentDb.isEmpty { + do { + let dbs = try await conn.listDatabases() + currentDb = dbs.first { !Self.systemDatabases.contains($0) } ?? dbs.first ?? "" + } catch { + conn.disconnect() + throw error + } + } + mongoConnection = conn } diff --git a/Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift b/Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift index fb88fc165..7b150d898 100644 --- a/Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift +++ b/Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift @@ -55,11 +55,11 @@ struct MariaDBPluginQueryResult { struct MySQLSSLConfig { enum Mode: String { - case disabled - case preferred - case required - case verifyCa = "verify_ca" - case verifyIdentity = "verify_identity" + case disabled = "Disabled" + case preferred = "Preferred" + case required = "Required" + case verifyCa = "Verify CA" + case verifyIdentity = "Verify Identity" } let mode: Mode @@ -68,7 +68,7 @@ struct MySQLSSLConfig { let clientKeyPath: String init(from fields: [String: String]) { - self.mode = Mode(rawValue: fields["sslMode"] ?? "disabled") ?? .disabled + self.mode = Mode(rawValue: fields["sslMode"] ?? "Disabled") ?? .disabled self.caCertificatePath = fields["sslCaCertPath"] ?? "" self.clientCertificatePath = fields["sslClientCertPath"] ?? "" self.clientKeyPath = fields["sslClientKeyPath"] ?? "" diff --git a/Plugins/PostgreSQLDriverPlugin/LibPQPluginConnection.swift b/Plugins/PostgreSQLDriverPlugin/LibPQPluginConnection.swift index 8ad0b68bd..7efc79a07 100644 --- a/Plugins/PostgreSQLDriverPlugin/LibPQPluginConnection.swift +++ b/Plugins/PostgreSQLDriverPlugin/LibPQPluginConnection.swift @@ -16,7 +16,7 @@ private let logger = Logger(subsystem: "com.TablePro.PostgreSQLDriver", category // MARK: - SSL Configuration struct PQSSLConfig { - var mode: String = "disable" + var mode: String = "Disabled" var caCertificatePath: String = "" var clientCertificatePath: String = "" var clientKeyPath: String = "" @@ -24,7 +24,7 @@ struct PQSSLConfig { init() {} init(additionalFields: [String: String]) { - self.mode = additionalFields["sslMode"] ?? "disable" + self.mode = additionalFields["sslMode"] ?? "Disabled" self.caCertificatePath = additionalFields["sslCaCertPath"] ?? "" self.clientCertificatePath = additionalFields["sslClientCertPath"] ?? "" self.clientKeyPath = additionalFields["sslClientKeyPath"] ?? "" diff --git a/Plugins/RedisDriverPlugin/RedisPluginConnection.swift b/Plugins/RedisDriverPlugin/RedisPluginConnection.swift index df42963ee..399cd5b65 100644 --- a/Plugins/RedisDriverPlugin/RedisPluginConnection.swift +++ b/Plugins/RedisDriverPlugin/RedisPluginConnection.swift @@ -26,8 +26,8 @@ struct RedisSSLConfig { init() {} init(additionalFields: [String: String]) { - let sslMode = additionalFields["sslMode"] ?? "disable" - self.isEnabled = sslMode != "disable" + let sslMode = additionalFields["sslMode"] ?? "Disabled" + self.isEnabled = sslMode != "Disabled" self.caCertificatePath = additionalFields["sslCaCertPath"] ?? "" self.clientCertificatePath = additionalFields["sslClientCertPath"] ?? "" self.clientKeyPath = additionalFields["sslClientKeyPath"] ?? "" diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift index 30db4805c..dcc42fc3a 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift @@ -89,7 +89,13 @@ extension MainContentCoordinator { AppState.shared.isCurrentTabEditable = !isView && tableName.isEmpty == false toolbarState.isTableTab = true } - runQuery() + // Redis needs selectRedisDatabaseAndQuery to ensure the correct + // database is SELECTed and session state is updated before querying. + if connection.type == .redis, let dbIndex = Int(currentDatabase) { + selectRedisDatabaseAndQuery(dbIndex) + } else { + runQuery() + } return } diff --git a/TablePro/Views/Main/MainContentView.swift b/TablePro/Views/Main/MainContentView.swift index b8a433d9a..eec55e02f 100644 --- a/TablePro/Views/Main/MainContentView.swift +++ b/TablePro/Views/Main/MainContentView.swift @@ -733,6 +733,10 @@ struct MainContentView: View { target = [] } if sidebarState.selectedTables != target { + // Don't clear sidebar selection while the table list is still loading. + // Clearing it prematurely triggers SidebarSyncAction to re-select on + // tables load, causing a double-navigation race condition. + if target.isEmpty && tables.isEmpty { return } sidebarState.selectedTables = target } } diff --git a/TableProTests/Core/Plugins/SSLModeStringTests.swift b/TableProTests/Core/Plugins/SSLModeStringTests.swift new file mode 100644 index 000000000..e7ba94aee --- /dev/null +++ b/TableProTests/Core/Plugins/SSLModeStringTests.swift @@ -0,0 +1,162 @@ +// +// SSLModeStringTests.swift +// TableProTests +// +// Tests that plugin SSL config structs correctly parse SSLMode raw values. +// Plugin types are bundle targets and cannot be imported directly, so we +// duplicate the config parsing logic here as private test helpers. +// + +import Foundation +import Testing +@testable import TablePro + +// MARK: - Test Helpers (mirror plugin SSL config structs) + +/// Mirror of MySQLSSLConfig.Mode from MariaDBPluginConnection.swift +private enum TestMySQLSSLMode: String { + case disabled = "Disabled" + case preferred = "Preferred" + case required = "Required" + case verifyCa = "Verify CA" + case verifyIdentity = "Verify Identity" +} + +/// Mirror of RedisSSLConfig init from RedisPluginConnection.swift +private struct TestRedisSSLConfig { + var isEnabled: Bool + + init(additionalFields: [String: String]) { + let sslMode = additionalFields["sslMode"] ?? "Disabled" + self.isEnabled = sslMode != "Disabled" + } +} + +/// Mirror of PQSSLConfig from LibPQPluginConnection.swift +private struct TestPQSSLConfig { + var mode: String = "Disabled" + + init() {} + + init(additionalFields: [String: String]) { + self.mode = additionalFields["sslMode"] ?? "Disabled" + } + + var libpqSslMode: String { + switch mode { + case "Disabled": return "disable" + case "Preferred": return "prefer" + case "Required": return "require" + case "Verify CA": return "verify-ca" + case "Verify Identity": return "verify-full" + default: return "disable" + } + } +} + +// MARK: - SSLMode Raw Values Match Plugin Expectations + +@Suite("SSL Mode String Consistency") +struct SSLModeStringTests { + @Test("SSLMode.disabled.rawValue matches plugin disabled check") + func disabledRawValue() { + #expect(SSLMode.disabled.rawValue == "Disabled") + } + + @Test("SSLMode.required.rawValue matches plugin required check") + func requiredRawValue() { + #expect(SSLMode.required.rawValue == "Required") + } + + @Test("SSLMode.verifyCa.rawValue matches plugin verify CA check") + func verifyCaRawValue() { + #expect(SSLMode.verifyCa.rawValue == "Verify CA") + } + + @Test("SSLMode.verifyIdentity.rawValue matches plugin verify identity check") + func verifyIdentityRawValue() { + #expect(SSLMode.verifyIdentity.rawValue == "Verify Identity") + } + + @Test("All SSLMode cases round-trip through MySQL Mode enum") + func mysqlModeRoundTrip() { + for sslMode in SSLMode.allCases { + let parsed = TestMySQLSSLMode(rawValue: sslMode.rawValue) + #expect(parsed != nil, "MySQLSSLMode failed to parse '\(sslMode.rawValue)'") + } + } + + @Test("MySQL Mode parses each SSLMode raw value to the correct case") + func mysqlModeParsesCorrectCase() { + #expect(TestMySQLSSLMode(rawValue: "Disabled") == .disabled) + #expect(TestMySQLSSLMode(rawValue: "Preferred") == .preferred) + #expect(TestMySQLSSLMode(rawValue: "Required") == .required) + #expect(TestMySQLSSLMode(rawValue: "Verify CA") == .verifyCa) + #expect(TestMySQLSSLMode(rawValue: "Verify Identity") == .verifyIdentity) + } + + @Test("Redis SSL disabled when sslMode is Disabled") + func redisSSLDisabled() { + let config = TestRedisSSLConfig(additionalFields: ["sslMode": "Disabled"]) + #expect(!config.isEnabled) + } + + @Test("Redis SSL enabled when sslMode is Required") + func redisSSLEnabled() { + let config = TestRedisSSLConfig(additionalFields: ["sslMode": "Required"]) + #expect(config.isEnabled) + } + + @Test("Redis SSL defaults to disabled when sslMode key is absent") + func redisSSLDefaultDisabled() { + let config = TestRedisSSLConfig(additionalFields: [:]) + #expect(!config.isEnabled) + } + + @Test("PostgreSQL maps all SSLMode raw values to correct libpq modes") + func pqSSLModeMapping() { + #expect(TestPQSSLConfig(additionalFields: ["sslMode": "Disabled"]).libpqSslMode == "disable") + #expect(TestPQSSLConfig(additionalFields: ["sslMode": "Preferred"]).libpqSslMode == "prefer") + #expect(TestPQSSLConfig(additionalFields: ["sslMode": "Required"]).libpqSslMode == "require") + #expect(TestPQSSLConfig(additionalFields: ["sslMode": "Verify CA"]).libpqSslMode == "verify-ca") + #expect(TestPQSSLConfig(additionalFields: ["sslMode": "Verify Identity"]).libpqSslMode == "verify-full") + } + + @Test("PostgreSQL default init uses Disabled") + func pqDefaultInit() { + let config = TestPQSSLConfig() + #expect(config.mode == "Disabled") + #expect(config.libpqSslMode == "disable") + } + + @Test("MongoDB SSL mode string comparisons use correct case") + func mongoDBSSLModeStrings() { + // These mirror the comparisons in MongoDBConnection.buildUri() + let disabled = SSLMode.disabled.rawValue + let verifyCa = SSLMode.verifyCa.rawValue + let verifyIdentity = SSLMode.verifyIdentity.rawValue + + #expect(disabled == "Disabled") + let sslEnabled = disabled != "Disabled" && !disabled.isEmpty + #expect(!sslEnabled) + + let required = SSLMode.required.rawValue + let sslEnabledRequired = required != "Disabled" && !required.isEmpty + #expect(sslEnabledRequired) + + let verifiesCert = verifyCa == "Verify CA" || verifyIdentity == "Verify Identity" + #expect(verifiesCert) + } + + @Test("ClickHouse SSL mode string comparisons use correct case") + func clickHouseSSLModeStrings() { + // These mirror the comparisons in ClickHousePlugin.connect() / buildRequest() + let disabled = SSLMode.disabled.rawValue + let useTLS = disabled != "Disabled" + #expect(!useTLS) + + let required = SSLMode.required.rawValue + let skipVerification = required == "Required" + #expect(skipVerification) + } +}