diff --git a/CHANGELOG.md b/CHANGELOG.md index 56fdef005a..f3028ab188 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -86,6 +86,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Literal backticks in cloudflared, cloud-sql-proxy, SSH config, remote command and dump tool install messages. - Tunnel command preview showing port 0 or the wrong host when Port is blank or the connection uses a host list. - SSH tab host-list warning naming replica set failover for Redis and Kafka, and implying Sentinel works through a tunnel. +- MongoDB restore into a database with a different name restoring nothing and reporting success. +- MongoDB Backup Dump and Restore ignoring the connection's Auth Database, Hosts list, SRV and TLS options. +- Restore confirmation claiming existing objects are overwritten on PostgreSQL, MongoDB, SQLite, SQL Server and DuckDB. - iPhone and iPad reading a Safe Mode level they do not recognize from iCloud as Off. ### Security diff --git a/Packages/TableProCore/Tests/TableProSyncTests/SyncRecordMapperTests.swift b/Packages/TableProCore/Tests/TableProSyncTests/SyncRecordMapperTests.swift index 0463cdcad4..2fa2afdfb4 100644 --- a/Packages/TableProCore/Tests/TableProSyncTests/SyncRecordMapperTests.swift +++ b/Packages/TableProCore/Tests/TableProSyncTests/SyncRecordMapperTests.swift @@ -86,4 +86,25 @@ struct SyncRecordMapperTests { let decoded = try #require(SyncRecordMapper.toConnection(record)) #expect(decoded.safeModeLevel == .confirmWrites) } + + @Test("An unrecognized wire value keeps the legacy read-only restriction") + func unknownWireValuePreservesReadOnly() throws { + let record = makeRawRecord(safeModeLevelRaw: "someFutureLevel", isReadOnly: true) + let decoded = try #require(SyncRecordMapper.toConnection(record)) + #expect(decoded.safeModeLevel == .readOnly) + } + + @Test("A rename preserves an unrecognized wire value and requires confirmation") + func renamePreservesUnknownWireValue() throws { + let record = makeRawRecord(safeModeLevelRaw: "someFutureLevel") + var connection = try #require(SyncRecordMapper.toConnection(record)) + connection.name = "Renamed" + + SyncRecordMapper.updateRecord(record, with: connection) + + #expect(record["safeModeLevel"] as? String == "someFutureLevel") + let decoded = try #require(SyncRecordMapper.toConnection(record)) + #expect(decoded.name == "Renamed") + #expect(decoded.safeModeLevel == .confirmWrites) + } } diff --git a/TablePro/Core/Database/MongoToolsConnectionString.swift b/TablePro/Core/Database/MongoToolsConnectionString.swift new file mode 100644 index 0000000000..0d8d7c1b03 --- /dev/null +++ b/TablePro/Core/Database/MongoToolsConnectionString.swift @@ -0,0 +1,130 @@ +// +// MongoToolsConnectionString.swift +// TablePro +// + +import Foundation +import TableProPluginKit + +internal enum MongoToolsConnectionString { + private static let defaultAuthenticationDatabase = "admin" + + private static let queryValueAllowed = CharacterSet.urlQueryAllowed.subtracting(CharacterSet(charactersIn: "&=+#")) + + private static let fieldOwnedParameterKeys: Set = [ + "authSource", "authMechanism", "replicaSet", + "tls", "tlsAllowInvalidCertificates", "tlsAllowInvalidHostnames", "tlsCAFile", "tlsCertificateKeyFile" + ] + + static func make(for connection: DatabaseConnection, host: String) -> String { + let scheme = connection.usesMongoSrv ? "mongodb+srv" : "mongodb" + let base = "\(scheme)://\(userInfo(connection))\(hosts(connection, host: host))/" + let query = parameters(connection) + return query.isEmpty ? base : "\(base)?\(query.joined(separator: "&"))" + } + + static func authenticationDatabase(for connection: DatabaseConnection) -> String { + if let explicit = connection.mongoAuthSource { + return explicit + } + guard !connection.usesMongoSrv, !connection.database.isEmpty else { + return defaultAuthenticationDatabase + } + return connection.database + } + + static func tlsParameters(for connection: DatabaseConnection) -> [String] { + let ssl = connection.sslConfig + guard ssl.isEnabled || connection.usesMongoSrv else { return [] } + var parameters = ["tls=true"] + if ssl.mode == .preferred || ssl.mode == .required { + parameters.append("tlsInsecure=true") + } + if ssl.verifiesCertificate, !ssl.caCertificatePath.isEmpty { + parameters.append("tlsCAFile=\(encodedValue(ssl.caCertificatePath))") + } + if ssl.isEnabled, !ssl.clientCertificatePath.isEmpty { + parameters.append("tlsCertificateKeyFile=\(encodedValue(ssl.clientCertificatePath))") + } + return parameters + } + + private static func userInfo(_ connection: DatabaseConnection) -> String { + guard !connection.username.isEmpty else { return "" } + let encoded = connection.username.addingPercentEncoding(withAllowedCharacters: .urlUserAllowed) + ?? connection.username + return "\(encoded)@" + } + + private static func hosts(_ connection: DatabaseConnection, host: String) -> String { + let listed = hostEntries(connection.additionalFields["mongoHosts"] ?? "") + let entries = listed.isEmpty ? hostEntries(host) : listed + if connection.usesMongoSrv, let srvName = entries.first { + return encodedHost(srvName.host) + } + return entries + .map { "\(encodedHost($0.host)):\($0.port ?? String(connection.port))" } + .joined(separator: ",") + } + + private static func hostEntries(_ value: String) -> [(host: String, port: String?)] { + value + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + .map(splitHostAndPort) + } + + private static func splitHostAndPort(_ entry: String) -> (host: String, port: String?) { + if entry.hasPrefix("["), let closing = entry.firstIndex(of: "]") { + let remainder = entry[entry.index(after: closing)...] + let port = remainder.hasPrefix(":") ? String(remainder.dropFirst()) : "" + return (String(entry[...closing]), port.isEmpty ? nil : port) + } + guard let colon = entry.lastIndex(of: ":") else { return (entry, nil) } + let port = String(entry[entry.index(after: colon)...]) + return (String(entry[.. [String] { + var parameters: [String] = [] + if !connection.username.isEmpty || connection.mongoAuthMechanism != nil { + parameters.append("authSource=\(encodedValue(authenticationDatabase(for: connection)))") + } + if let mechanism = connection.mongoAuthMechanism { + parameters.append("authMechanism=\(encodedValue(mechanism))") + } + if let replicaSet = connection.mongoReplicaSet { + parameters.append("replicaSet=\(encodedValue(replicaSet))") + } + if let readPreference = connection.mongoReadPreference { + parameters.append("readPreference=\(encodedValue(readPreference))") + } + if let writeConcern = connection.mongoWriteConcern { + parameters.append("w=\(encodedValue(writeConcern))") + } + parameters.append(contentsOf: tlsParameters(for: connection)) + let setKeys = Set(parameters.compactMap { $0.split(separator: "=", maxSplits: 1).first.map(String.init) }) + return parameters + extraParameters(connection, excluding: setKeys.union(fieldOwnedParameterKeys)) + } + + private static func extraParameters(_ connection: DatabaseConnection, excluding keys: Set) -> [String] { + connection.additionalFields + .compactMap { key, value -> (String, String)? in + guard key.hasPrefix("mongoParam_") else { return nil } + let name = String(key.dropFirst("mongoParam_".count)) + guard !name.isEmpty, !keys.contains(name) else { return nil } + return (name, value) + } + .sorted { $0.0 < $1.0 } + .map { "\(encodedValue($0.0))=\(encodedValue($0.1))" } + } + + private static func encodedHost(_ host: String) -> String { + host.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? host + } + + private static func encodedValue(_ value: String) -> String { + value.addingPercentEncoding(withAllowedCharacters: queryValueAllowed) ?? value + } +} diff --git a/TablePro/Core/Database/NativeDumpArgumentQuoting.swift b/TablePro/Core/Database/NativeDumpArgumentQuoting.swift index f4459a93d5..67f85f0359 100644 --- a/TablePro/Core/Database/NativeDumpArgumentQuoting.swift +++ b/TablePro/Core/Database/NativeDumpArgumentQuoting.swift @@ -82,4 +82,8 @@ enum NativeDumpArgumentQuoting { static func mongoNamespace(database: String, collection: String) -> String { "\(database).\(collection)" } + + static func mongoRestoreRenaming(into database: String) -> [String] { + ["--nsFrom=$db$.$coll$", "--nsTo=\(database).$coll$"] + } } diff --git a/TablePro/Core/Database/NativeDumpDescriptor.swift b/TablePro/Core/Database/NativeDumpDescriptor.swift index 1484c3447c..4274cbfa88 100644 --- a/TablePro/Core/Database/NativeDumpDescriptor.swift +++ b/TablePro/Core/Database/NativeDumpDescriptor.swift @@ -102,6 +102,11 @@ struct NativeDumpDescriptor: Sendable { } } + internal struct ConfigurationEntry: Sendable, Equatable { + let key: String + let value: String + } + /// A tool on the user's Mac that the app spawns. struct CommandLineTool: Sendable { /// The candidate names, in the order they are tried. More than one because a tool can ship @@ -123,10 +128,7 @@ struct NativeDumpDescriptor: Sendable { /// such a tool say so rather than leaving the user to find out. let exposesPasswordInArguments: Bool - /// True when the tool reads a password from neither the environment nor standard input, so - /// the only channel left is a file written at mode `0600`. Declared rather than inferred - /// from the binary's name, which is what `buildCommand` used to do. - let needsCredentialsFile: Bool + let configurationFileEntries: @Sendable (Request) -> [ConfigurationEntry] internal let restoreExitPolicy: NativeDumpExitPolicy @@ -153,14 +155,14 @@ struct NativeDumpDescriptor: Sendable { backupDelivery: OutputDelivery, restoreDelivery: OutputDelivery, exposesPasswordInArguments: Bool = false, - needsCredentialsFile: Bool = false, restoreExitPolicy: NativeDumpExitPolicy = .zeroExitOnly, requiresUntranslatedMessages: Bool = false, toolForServer: (@Sendable (_ binary: String, _ serverVersion: String?) -> NativeDumpToolSelection)? = nil, identifyExecutable: (@Sendable (_ name: String, _ path: String) -> NativeDumpResolvedTool)? = nil, backupArguments: @escaping @Sendable (Request, NativeDumpResolvedTool) throws -> [String], restoreArguments: @escaping @Sendable (Request, NativeDumpResolvedTool) throws -> [String], - environment: @escaping @Sendable (Request) -> [String: String] = { _ in [:] } + environment: @escaping @Sendable (Request) -> [String: String] = { _ in [:] }, + configurationFileEntries: @escaping @Sendable (Request) -> [ConfigurationEntry] = { _ in [] } ) { self.backupBinaries = backupBinaries self.restoreBinaries = restoreBinaries @@ -168,7 +170,6 @@ struct NativeDumpDescriptor: Sendable { self.backupDelivery = backupDelivery self.restoreDelivery = restoreDelivery self.exposesPasswordInArguments = exposesPasswordInArguments - self.needsCredentialsFile = needsCredentialsFile self.restoreExitPolicy = restoreExitPolicy self.requiresUntranslatedMessages = requiresUntranslatedMessages self.toolForServer = toolForServer @@ -176,6 +177,7 @@ struct NativeDumpDescriptor: Sendable { self.backupArguments = backupArguments self.restoreArguments = restoreArguments self.environment = environment + self.configurationFileEntries = configurationFileEntries } func binaries(for kind: NativeDumpKind) -> [String] { @@ -230,9 +232,17 @@ struct NativeDumpDescriptor: Sendable { case engineStatements(EngineStatements) } + internal enum RestoreSemantics: Sendable, Equatable { + case replacesObjects + case addsToExistingObjects + case stopsAtExistingObjects + case requiresEmptyDatabase + } + let mechanism: Mechanism let archiveFormat: ArchiveFormat let objectScope: NativeDumpObjectScope + let restoreSemantics: RestoreSemantics /// True when the tool opens the database file itself and so cannot reach a connection that has /// no local file. libSQL claims the SQLite descriptor and reaches either a file or a Turso URL, @@ -243,11 +253,13 @@ struct NativeDumpDescriptor: Sendable { mechanism: Mechanism, archiveFormat: ArchiveFormat, objectScope: NativeDumpObjectScope, + restoreSemantics: RestoreSemantics, requiresLocalFile: Bool = false ) { self.mechanism = mechanism self.archiveFormat = archiveFormat self.objectScope = objectScope + self.restoreSemantics = restoreSemantics self.requiresLocalFile = requiresLocalFile } diff --git a/TablePro/Core/Database/NativeDumpRegistry.swift b/TablePro/Core/Database/NativeDumpRegistry.swift index 099279a3bb..d3df2e0ec5 100644 --- a/TablePro/Core/Database/NativeDumpRegistry.swift +++ b/TablePro/Core/Database/NativeDumpRegistry.swift @@ -121,7 +121,8 @@ enum NativeDumpRegistry { A dump of chosen tables may not restore on its own. Sequences, types, schemas \ and tables it references are left out. """) - ) + ), + restoreSemantics: .addsToExistingObjects ) } @@ -223,7 +224,8 @@ enum NativeDumpRegistry { Views and tables the chosen tables reference are left out. The dump turns \ foreign key checks off, so it restores with those references dangling. """) - ) + ), + restoreSemantics: .replacesObjects ) } @@ -269,27 +271,22 @@ enum NativeDumpRegistry { installHint: String(localized: "Install it with “brew install mongodb-database-tools”."), backupDelivery: .toolWritesFile, restoreDelivery: .toolWritesFile, - needsCredentialsFile: true, backupArguments: { request, _ in - mongoConnectionFlags(request) + mongoNamespaceFlags(request) + [ - "--gzip", - "--archive=\(request.fileURL.path)" - ] + mongoNamespaceFlags(request) + ["--gzip", "--archive=\(request.fileURL.path)"] }, restoreArguments: { request, _ in - mongoConnectionFlags(request) + [ - "--nsInclude=\(request.database).*", - "--gzip", - "--archive=\(request.fileURL.path)" - ] - } + NativeDumpArgumentQuoting.mongoRestoreRenaming(into: request.database) + + ["--gzip", "--archive=\(request.fileURL.path)"] + }, + configurationFileEntries: { request in mongoConfigurationEntries(request) } ) ), archiveFormat: NativeDumpDescriptor.ArchiveFormat( fileExtension: "archive", contentDescription: String(localized: "MongoDB gzipped archive") ), - objectScope: .collections + objectScope: .collections, + restoreSemantics: .addsToExistingObjects ) } @@ -302,16 +299,19 @@ enum NativeDumpRegistry { } } - private static func mongoConnectionFlags(_ request: NativeDumpDescriptor.Request) -> [String] { - var flags = ["--host=\(request.host)", "--port=\(request.connection.port)"] - if !request.connection.username.isEmpty { - flags.append("--username=\(request.connection.username)") - flags.append("--authenticationDatabase=\(request.connection.database.isEmpty ? "admin" : request.connection.database)") - } - if request.connection.sslConfig.isEnabled { - flags.append("--ssl") + private static func mongoConfigurationEntries( + _ request: NativeDumpDescriptor.Request + ) -> [NativeDumpDescriptor.ConfigurationEntry] { + var entries = [ + NativeDumpDescriptor.ConfigurationEntry( + key: "uri", + value: MongoToolsConnectionString.make(for: request.connection, host: request.host) + ) + ] + if let password = request.password, !password.isEmpty, !request.connection.username.isEmpty { + entries.append(NativeDumpDescriptor.ConfigurationEntry(key: "password", value: password)) } - return flags + return entries } // MARK: - SQL Server @@ -356,7 +356,8 @@ enum NativeDumpRegistry { The .bacpac always carries the whole schema. Only the chosen tables' data is \ narrowed, and tables they reference by foreign key have to be chosen too. """) - ) + ), + restoreSemantics: .requiresEmptyDatabase ) } @@ -420,6 +421,7 @@ enum NativeDumpRegistry { contentDescription: String(localized: "SQL statements") ), objectScope: .tables(caveat: nil), + restoreSemantics: .addsToExistingObjects, requiresLocalFile: true ) } @@ -477,7 +479,8 @@ enum NativeDumpRegistry { archiveFormat: duckDBFileFormat, objectScope: .unsupported( reason: String(localized: "DuckDB copies the whole database. There is no table filter.") - ) + ), + restoreSemantics: .stopsAtExistingObjects ) } @@ -500,7 +503,8 @@ enum NativeDumpRegistry { archiveFormat: duckDBParquetFormat, objectScope: .unsupported( reason: String(localized: "EXPORT DATABASE writes the whole database. There is no table filter.") - ) + ), + restoreSemantics: .stopsAtExistingObjects ) } diff --git a/TablePro/Core/Database/NativeDumpService.swift b/TablePro/Core/Database/NativeDumpService.swift index 4ceaccc975..e85d3908e0 100644 --- a/TablePro/Core/Database/NativeDumpService.swift +++ b/TablePro/Core/Database/NativeDumpService.swift @@ -499,10 +499,9 @@ final class NativeDumpService: ObservableObject { } var credentialsFileURL: URL? - if tool.needsCredentialsFile, - let password = request.password, !password.isEmpty, - !request.connection.username.isEmpty { - let file = try writeMongoCredentialsFile(password: password) + let configurationEntries = tool.configurationFileEntries(request) + if !configurationEntries.isEmpty { + let file = try writeMongoConfigurationFile(configurationEntries) credentialsFileURL = file arguments.append("--config=\(file.path)") } @@ -524,10 +523,12 @@ final class NativeDumpService: ObservableObject { /// `mongodump` and `mongorestore` read a password from neither the environment nor standard /// input, and one in `argv` is readable by every process on the machine. Their `--config` file /// is the remaining channel, so it is written owner-only and removed when the process exits. - nonisolated static func writeMongoCredentialsFile(password: String) throws -> URL { + nonisolated static func writeMongoConfigurationFile( + _ entries: [NativeDumpDescriptor.ConfigurationEntry] + ) throws -> URL { let url = FileManager.default.temporaryDirectory .appendingPathComponent("tablepro-mongo-\(UUID().uuidString).yaml") - let contents = "password: \(mongoYAMLQuoted(password))\n" + let contents = entries.map { "\($0.key): \(mongoYAMLQuoted($0.value))\n" }.joined() guard let data = contents.data(using: .utf8) else { throw NativeDumpError.sourceUnreadable } diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 0535a15bcc..9f5d79a312 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -159099,9 +159099,18 @@ } } } + }, + "The dump is copied into this database, and the change cannot be undone. If an object it holds already exists, the restore stops before copying anything." : { + + }, + "The dump is imported into this database, and the change cannot be undone. The import needs an empty database and stops if this one already holds objects." : { + }, "The dump is replayed into this database. Objects it names are overwritten and the change cannot be undone." : { + }, + "The dump is replayed into this database. Objects that already exist are kept, not replaced, and the dump's data is added to them. The change cannot be undone." : { + }, "The encrypted file is corrupt or incomplete" : { "extractionState" : "stale", diff --git a/TablePro/Views/Backup/RestoreConfirmationText.swift b/TablePro/Views/Backup/RestoreConfirmationText.swift new file mode 100644 index 0000000000..34798d4436 --- /dev/null +++ b/TablePro/Views/Backup/RestoreConfirmationText.swift @@ -0,0 +1,42 @@ +// +// RestoreConfirmationText.swift +// TablePro +// + +import Foundation +import TableProPluginKit + +internal enum RestoreConfirmationText { + static func message(for type: DatabaseType, formatId: String? = nil) -> String? { + NativeDumpRegistry.descriptor(for: type, formatId: formatId).map { message(for: $0.restoreSemantics) } + } + + static func message(for semantics: NativeDumpDescriptor.RestoreSemantics) -> String { + switch semantics { + case .replacesObjects: + return String( + localized: """ + The dump is replayed into this database. Objects it names are overwritten and \ + the change cannot be undone. + """) + case .addsToExistingObjects: + return String( + localized: """ + The dump is replayed into this database. Objects that already exist are kept, \ + not replaced, and the dump's data is added to them. The change cannot be undone. + """) + case .stopsAtExistingObjects: + return String( + localized: """ + The dump is copied into this database, and the change cannot be undone. If an \ + object it holds already exists, the restore stops before copying anything. + """) + case .requiresEmptyDatabase: + return String( + localized: """ + The dump is imported into this database, and the change cannot be undone. The \ + import needs an empty database and stops if this one already holds objects. + """) + } + } +} diff --git a/TablePro/Views/Backup/RestoreDatabaseFlow.swift b/TablePro/Views/Backup/RestoreDatabaseFlow.swift index c71b871bab..6011fc74a2 100644 --- a/TablePro/Views/Backup/RestoreDatabaseFlow.swift +++ b/TablePro/Views/Backup/RestoreDatabaseFlow.swift @@ -174,14 +174,15 @@ struct RestoreDatabaseFlow: View { /// A restore replays a dump into a database that already has contents, and the tools it drives /// do not ask. Picking a database in the list used to be the last step before the first write. private func startRestore(database: String) async { + guard let message = RestoreConfirmationText.message(for: connection.type, formatId: formatId) else { + phase = .failed( + message: NativeDumpError.unsupportedDatabase.localizedDescription, targetMayBeModified: false) + return + } guard await AlertHelper.confirmDestructive( title: String( format: String(localized: "Restore into \u{201C}%@\u{201D}?"), database), - message: String( - localized: """ - The dump is replayed into this database. Objects it names are overwritten and \ - the change cannot be undone. - """), + message: message, confirmButton: String(localized: "Restore"), window: hostWindow ) else { diff --git a/TableProTests/Database/MongoToolsConnectionStringTests.swift b/TableProTests/Database/MongoToolsConnectionStringTests.swift new file mode 100644 index 0000000000..12f79f5cb7 --- /dev/null +++ b/TableProTests/Database/MongoToolsConnectionStringTests.swift @@ -0,0 +1,166 @@ +// +// MongoToolsConnectionStringTests.swift +// TableProTests +// + +import Foundation +import TableProPluginKit +import Testing + +@testable import TablePro + +struct MongoToolsConnectionStringTests { + private func connection( + host: String = "db.example.com", + port: Int = 27_017, + database: String = "shop", + username: String = "alice", + sslMode: SSLMode = .disabled + ) -> DatabaseConnection { + DatabaseConnection( + name: "Test", + host: host, + port: port, + database: database, + username: username, + type: .mongodb, + sshConfig: SSHConfiguration(), + sslConfig: SSLConfiguration(mode: sslMode) + ) + } + + private func uri(_ connection: DatabaseConnection) -> String { + MongoToolsConnectionString.make(for: connection, host: connection.host) + } + + @Test("The auth database follows the driver's rule for every combination") + func authenticationDatabaseMatchesTheDriver() { + for explicit in [nil, "", "accounts", "$external"] { + for database in ["", "shop"] { + for useSrv in [false, true] { + var subject = connection(database: database) + subject.mongoAuthSource = explicit + subject.mongoUseSrv = useSrv + let driver = MongoDBAuthSourceResolver.resolve( + explicitAuthSource: explicit, + configuredDatabase: database, + useSrv: useSrv + ) + #expect( + MongoToolsConnectionString.authenticationDatabase(for: subject) == driver, + "explicit \(explicit ?? "nil"), database \(database), srv \(useSrv)" + ) + } + } + } + } + + @Test("An Atlas host is SRV without the toggle, as the driver treats it") + func atlasHostImpliesSrv() { + let atlas = uri(connection(host: "cluster0.abcde.mongodb.net")) + #expect(atlas.hasPrefix("mongodb+srv://alice@cluster0.abcde.mongodb.net/?authSource=admin")) + #expect(atlas.contains("tls=true")) + } + + @Test("An SRV name loses the port the Hosts list gave it") + func srvDropsThePort() { + var subject = connection(host: "cluster0.example.com") + subject.mongoUseSrv = true + subject.additionalFields["mongoHosts"] = "cluster0.example.com:27017" + #expect(uri(subject).hasPrefix("mongodb+srv://alice@cluster0.example.com/?")) + } + + @Test("Hosts without a port take the connection's port, and an IPv6 literal keeps its brackets") + func hostListPorts() { + var subject = connection(port: 27_018) + subject.additionalFields["mongoHosts"] = "a.example.com, [::1], b.example.com:27019, [fe80::1]:27020" + #expect(uri(subject).hasPrefix( + "mongodb://alice@a.example.com:27018,[::1]:27018,b.example.com:27019,[fe80::1]:27020/?" + )) + } + + @Test("A connection without a username sends no auth database, which the tools would try to use") + func anonymousSendsNoAuthDatabase() { + #expect(uri(connection(username: "")) == "mongodb://db.example.com:27017/") + } + + @Test("Reserved characters in the username are percent-encoded") + func usernameIsEncoded() { + #expect(uri(connection(username: "pl+us@x:y/z%")).hasPrefix("mongodb://pl+us%40x%3Ay%2Fz%25@db.example.com")) + } + + @Test("The connection's mechanism, replica set, read preference and write concern reach the tool") + func connectionOptionsReachTheTool() { + var subject = connection() + subject.mongoAuthMechanism = "SCRAM-SHA-256" + subject.mongoReplicaSet = "rs0" + subject.mongoReadPreference = "secondaryPreferred" + subject.mongoWriteConcern = "majority" + #expect(uri(subject) == """ + mongodb://alice@db.example.com:27017/?authSource=shop&authMechanism=SCRAM-SHA-256\ + &replicaSet=rs0&readPreference=secondaryPreferred&w=majority + """) + } + + @Test("Extra URI parameters pass through unless a connection field owns them") + func extraParametersPassThrough() { + var subject = connection() + subject.additionalFields["mongoParam_directConnection"] = "true" + subject.additionalFields["mongoParam_authSource"] = "elsewhere" + subject.additionalFields["mongoParam_readPreference"] = "nearest" + subject.additionalFields["mongoParam_appName"] = "a&b=c+d" + let plain = uri(subject) + #expect(plain.contains("directConnection=true")) + #expect(plain.contains("readPreference=nearest")) + #expect(plain.contains("appName=a%26b%3Dc%2Bd")) + #expect(!plain.contains("authSource=elsewhere")) + + subject.mongoReadPreference = "secondary" + let owned = uri(subject) + #expect(owned.contains("readPreference=secondary")) + #expect(!owned.contains("readPreference=nearest")) + } + + @Test("SSL off sends no TLS options and no certificate the form still holds") + func tlsDisabled() { + var subject = connection() + subject.sslConfig.caCertificatePath = "/certs/ca.pem" + subject.sslConfig.clientCertificatePath = "/certs/client.pem" + #expect(MongoToolsConnectionString.tlsParameters(for: subject).isEmpty) + } + + @Test("Preferred and Required skip verification with the one option the tools honor") + func tlsWithoutVerification() { + for mode in [SSLMode.preferred, .required] { + var subject = connection(sslMode: mode) + subject.sslConfig.caCertificatePath = "/certs/ca.pem" + #expect( + MongoToolsConnectionString.tlsParameters(for: subject) == ["tls=true", "tlsInsecure=true"], + "\(mode.rawValue)" + ) + } + } + + @Test("The verifying modes send their CA and never switch verification off") + func tlsWithVerification() { + for mode in [SSLMode.verifyCa, .verifyIdentity] { + var subject = connection(sslMode: mode) + subject.sslConfig.caCertificatePath = "/certs/my ca.pem" + subject.sslConfig.clientCertificatePath = "/certs/client.pem" + #expect( + MongoToolsConnectionString.tlsParameters(for: subject) == [ + "tls=true", "tlsCAFile=/certs/my%20ca.pem", "tlsCertificateKeyFile=/certs/client.pem" + ], + "\(mode.rawValue)" + ) + } + } + + @Test("SRV turns TLS on with full verification when SSL is left off") + func srvForcesTls() { + var subject = connection() + subject.mongoUseSrv = true + subject.sslConfig.clientCertificatePath = "/certs/client.pem" + #expect(MongoToolsConnectionString.tlsParameters(for: subject) == ["tls=true"]) + } +} diff --git a/TableProTests/Database/NativeDumpRegistryTests.swift b/TableProTests/Database/NativeDumpRegistryTests.swift index 2febb996a6..b138d15b49 100644 --- a/TableProTests/Database/NativeDumpRegistryTests.swift +++ b/TableProTests/Database/NativeDumpRegistryTests.swift @@ -69,6 +69,18 @@ struct NativeDumpRegistryTests { ) } + private func configuration(of built: NativeDumpCommand) throws -> String { + let url = try #require(built.temporaryCredentialsFileURL) + defer { try? FileManager.default.removeItem(at: url) } + return try String(contentsOf: url, encoding: .utf8) + } + + private func configurationValue(_ key: String, in contents: String) throws -> String { + let prefix = "\(key): \"" + let line = try #require(contents.split(separator: "\n").first { $0.hasPrefix(prefix) }) + return String(line.dropFirst(prefix.count).dropLast()) + } + @Test("The engines with client-side tools are the ones the menu offers") func supportedEngines() { for type in [DatabaseType.postgresql, .redshift, .mysql, .mariadb, .mongodb, .sqlite] { @@ -171,18 +183,80 @@ struct NativeDumpRegistryTests { #expect(built.environment["MONGO_PASSWORD"] == nil) } - @Test("MongoDB writes no credentials file without a username") + @Test("MongoDB writes no password and no auth database without a username") func mongoSkipsCredentialsWithoutUser() throws { let anonymous = connection(type: .mongodb, username: "") - let built = try command(.mongodb, connection: anonymous) - #expect(built.temporaryCredentialsFileURL == nil) - #expect(!built.arguments.contains { $0.hasPrefix("--config=") }) + let contents = try configuration(of: command(.mongodb, connection: anonymous)) + #expect(!contents.contains("password:")) + let uri = try configurationValue("uri", in: contents) + #expect(uri == "mongodb://db.example.com:5432/") + } + + @Test("MongoDB authenticates against the Auth Database field before the connection's database") + func mongoAuthenticatesAgainstTheAuthDatabase() throws { + var shop = connection(type: .mongodb, database: "shop") + shop.mongoAuthSource = "admin" + let explicit = try configurationValue("uri", in: configuration(of: command(.mongodb, connection: shop))) + #expect(explicit.contains("authSource=admin")) + #expect(!explicit.contains("authSource=shop")) + + shop.mongoAuthSource = nil + let fallback = try configurationValue("uri", in: configuration(of: command(.mongodb, connection: shop))) + #expect(fallback.contains("authSource=shop")) + } + + @Test("An SRV connection reaches the tool as a mongodb+srv connection string with TLS on", arguments: [ + NativeDumpKind.backup, .restore + ]) + func mongoSrvUsesAConnectionString(kind: NativeDumpKind) throws { + var atlas = connection(type: .mongodb, host: "cluster0.example.mongodb.net", port: 27_017) + atlas.mongoUseSrv = true + let built = try command(.mongodb, kind: kind, connection: atlas) + let uri = try configurationValue("uri", in: configuration(of: built)) + #expect(uri.hasPrefix("mongodb+srv://alice@cluster0.example.mongodb.net/?")) + #expect(uri.contains("authSource=admin")) + #expect(uri.contains("tls=true")) + #expect(!built.arguments.contains { $0.hasPrefix("--host") || $0.hasPrefix("--port") }) + } + + @Test("A host list and its replica set reach the tool whole, not as the first host") + func mongoHostListUsesAConnectionString() throws { + var replicaSet = connection(type: .mongodb, host: "a.example.com", port: 27_017) + replicaSet.additionalFields["mongoHosts"] = "a.example.com:27017,b.example.com:27018" + replicaSet.mongoReplicaSet = "rs0" + let built = try command(.mongodb, connection: replicaSet) + let uri = try configurationValue("uri", in: configuration(of: built)) + #expect(uri.hasPrefix("mongodb://alice@a.example.com:27017,b.example.com:27018/?")) + #expect(uri.contains("replicaSet=rs0")) + #expect(!built.arguments.contains { $0.hasPrefix("--host") }) } - @Test("MongoDB names the database on backup and scopes the namespace on restore") + @Test("A Required MongoDB connection reaches the tool without verifying, as it does in the app") + func mongoRequiredSkipsVerification() throws { + var secured = connection(type: .mongodb, sslMode: .required, sslEnabled: true) + secured.sslConfig.clientCertificatePath = "/certs/client.pem" + let built = try command(.mongodb, connection: secured) + let uri = try configurationValue("uri", in: configuration(of: built)) + #expect(uri.contains("tls=true&tlsInsecure=true")) + #expect(uri.contains("tlsCertificateKeyFile=/certs/client.pem")) + #expect(!built.arguments.contains("--ssl")) + } + + @Test("MongoDB names the database on backup and renames the archive into the target on restore") func mongoScopesItsDatabase() throws { #expect(try command(.mongodb, kind: .backup).arguments.contains("--db=sales")) - #expect(try command(.mongodb, kind: .restore).arguments.contains("--nsInclude=sales.*")) + let restore = try command(.mongodb, kind: .restore).arguments + #expect(restore.contains("--nsFrom=$db$.$coll$")) + #expect(restore.contains("--nsTo=sales.$coll$")) + #expect(!restore.contains { $0.hasPrefix("--nsInclude") }) + } + + @Test("A restore target keeps an asterisk literal, which mongorestore reads as part of the name") + func mongoRestoreTargetIsLiteral() { + #expect( + NativeDumpArgumentQuoting.mongoRestoreRenaming(into: "we*ird") + == ["--nsFrom=$db$.$coll$", "--nsTo=we*ird.$coll$"] + ) } /// The database is a file the tool opens, so there is nothing to authenticate to. @@ -229,7 +303,7 @@ struct NativeDumpRegistryTests { #expect(mysql.arguments.contains("127.0.0.1")) let mongo = try command(.mongodb, connection: connection(type: .mongodb, host: "")) - #expect(mongo.arguments.contains("--host=127.0.0.1")) + #expect(try configurationValue("uri", in: configuration(of: mongo)).hasPrefix("mongodb://alice@127.0.0.1:5432/?")) } /// Measured, MariaDB 12.3.3 answers any `--ssl-mode` with `unknown variable` and exit 7, and diff --git a/TableProTests/Views/Backup/RestoreConfirmationTextTests.swift b/TableProTests/Views/Backup/RestoreConfirmationTextTests.swift new file mode 100644 index 0000000000..71b577ef44 --- /dev/null +++ b/TableProTests/Views/Backup/RestoreConfirmationTextTests.swift @@ -0,0 +1,59 @@ +// +// RestoreConfirmationTextTests.swift +// TableProTests +// + +import Foundation +import TableProPluginKit +import Testing + +@testable import TablePro + +struct RestoreConfirmationTextTests { + @Test("Only MySQL and MariaDB, whose dump drops and recreates each table, say objects are overwritten") + func onlyMySQLOverwrites() throws { + for type in [DatabaseType.mysql, .mariadb] { + let message = try #require(RestoreConfirmationText.message(for: type)) + #expect(message.contains("overwritten"), "\(type.rawValue)") + } + for type in [DatabaseType.postgresql, .redshift, .mongodb, .sqlite, .libsql, .mssql, .duckdb] { + let message = try #require(RestoreConfirmationText.message(for: type)) + #expect(!message.contains("overwritten"), "\(type.rawValue)") + } + } + + @Test("pg_restore, mongorestore and sqlite3 say existing objects are kept and added to") + func appendingEnginesKeepExistingObjects() throws { + for type in [DatabaseType.postgresql, .redshift, .mongodb, .sqlite, .libsql] { + let message = try #require(RestoreConfirmationText.message(for: type)) + #expect(message.contains("kept"), "\(type.rawValue)") + } + } + + @Test("DuckDB says the restore stops at an object that already exists, in both formats") + func duckDBStopsAtExistingObjects() throws { + for format in NativeDumpRegistry.formats(for: .duckdb) { + let message = try #require(RestoreConfirmationText.message(for: .duckdb, formatId: format.id)) + #expect(message.contains("stops"), "\(format.id)") + } + } + + @Test("SQL Server says the import needs an empty database") + func sqlServerNeedsAnEmptyDatabase() throws { + let message = try #require(RestoreConfirmationText.message(for: .mssql)) + #expect(message.contains("empty database")) + } + + @Test("Every message says the change cannot be undone") + func everyMessageWarnsItCannotBeUndone() throws { + for type in [DatabaseType.mysql, .postgresql, .mongodb, .sqlite, .mssql, .duckdb] { + let message = try #require(RestoreConfirmationText.message(for: type)) + #expect(message.contains("cannot be undone"), "\(type.rawValue)") + } + } + + @Test("An engine with no dump has no confirmation to show") + func unsupportedEngineHasNoMessage() { + #expect(RestoreConfirmationText.message(for: .clickhouse) == nil) + } +}