diff --git a/apple/Packages/TableProCore/Sources/TableProImport/Bundle/ConnectionBundle.swift b/apple/Packages/TableProCore/Sources/TableProImport/Bundle/ConnectionBundle.swift index 3587a68cd7..fb8c4e7352 100644 --- a/apple/Packages/TableProCore/Sources/TableProImport/Bundle/ConnectionBundle.swift +++ b/apple/Packages/TableProCore/Sources/TableProImport/Bundle/ConnectionBundle.swift @@ -128,6 +128,7 @@ public struct BundleSavedQuery: Codable, Sendable, Equatable { } public struct ConnectionBundle: Sendable, Equatable { + /// The newest format this build reads. A file is written in it only when format 1 cannot hold the bundle. public static let formatVersion = 2 public let exportedAt: Date diff --git a/apple/Packages/TableProCore/Sources/TableProImport/Bundle/ConnectionBundleCodec.swift b/apple/Packages/TableProCore/Sources/TableProImport/Bundle/ConnectionBundleCodec.swift index 50ab3f9363..4ec2eeadf0 100644 --- a/apple/Packages/TableProCore/Sources/TableProImport/Bundle/ConnectionBundleCodec.swift +++ b/apple/Packages/TableProCore/Sources/TableProImport/Bundle/ConnectionBundleCodec.swift @@ -16,8 +16,10 @@ public enum ConnectionBundleCodec { let json: Data do { json = try await ConnectionExportCrypto.decrypt(data: data, passphrase: passphrase) + } catch ConnectionExportCryptoError.invalidPassphrase { + throw ConnectionBundleError.decryptionFailed(ConnectionExportCryptoError.invalidPassphrase.localizedDescription) } catch { - throw ConnectionBundleError.decryptionFailed(error.localizedDescription) + throw ConnectionBundleError.decodingFailed(error.localizedDescription) } return try decodeJSON(json) } @@ -50,7 +52,7 @@ public enum ConnectionBundleCodec { guard probe.formatVersion <= ConnectionBundle.formatVersion else { throw ConnectionBundleError.unsupportedVersion(probe.formatVersion) } - if probe.formatVersion == 1 { + if probe.formatVersion == ConnectionBundleV1.formatVersion { let envelope = try decodeBody(ConnectionBundleV1.Envelope.self, from: data) return try envelope.upgraded().sanitizedForImport() } @@ -68,11 +70,15 @@ public enum ConnectionBundleCodec { } } + /// Format 2 only when the bundle needs it, so a file without saved queries or nested groups opens in older builds. private static func encodeJSON(_ bundle: ConnectionBundle) throws -> Data { let encoder = JSONEncoder() encoder.outputFormatting = [.prettyPrinted, .sortedKeys] encoder.dateEncodingStrategy = .iso8601 do { + if let envelope = ConnectionBundleV1.Envelope(bundle) { + return try encoder.encode(envelope) + } return try encoder.encode(bundle) } catch { throw ConnectionBundleError.encodingFailed diff --git a/apple/Packages/TableProCore/Sources/TableProImport/Bundle/ConnectionBundleError.swift b/apple/Packages/TableProCore/Sources/TableProImport/Bundle/ConnectionBundleError.swift index 168a0cd8bf..33242805dc 100644 --- a/apple/Packages/TableProCore/Sources/TableProImport/Bundle/ConnectionBundleError.swift +++ b/apple/Packages/TableProCore/Sources/TableProImport/Bundle/ConnectionBundleError.swift @@ -6,6 +6,7 @@ public enum ConnectionBundleError: LocalizedError, Equatable { case decodingFailed(String) case invalidBundle(String) case requiresPassphrase + /// The passphrase does not open the file. A file that cannot be decrypted for any other reason is `decodingFailed`. case decryptionFailed(String) case encodingFailed case credentialsRequireEncryption diff --git a/apple/Packages/TableProCore/Sources/TableProImport/Bundle/ConnectionBundleV1.swift b/apple/Packages/TableProCore/Sources/TableProImport/Bundle/ConnectionBundleV1.swift index b478788c14..1e5180c97a 100644 --- a/apple/Packages/TableProCore/Sources/TableProImport/Bundle/ConnectionBundleV1.swift +++ b/apple/Packages/TableProCore/Sources/TableProImport/Bundle/ConnectionBundleV1.swift @@ -1,8 +1,11 @@ import Foundation -/// The format 1 file shape, kept frozen: the only place a v1 file is read. +/// The format 1 file shape, kept frozen: the only place a v1 file is read or written. Builds before format 2 +/// read nothing else, so a bundle that fits it is written in it. enum ConnectionBundleV1 { - struct Envelope: Decodable { + static let formatVersion = 1 + + struct Envelope: Codable { let formatVersion: Int let exportedAt: Date let appVersion: String @@ -54,7 +57,7 @@ enum ConnectionBundleV1 { } } - struct Connection: Decodable { + struct Connection: Codable { let settings: ExportableConnection let groupName: String? let tagName: String? @@ -65,6 +68,15 @@ enum ConnectionBundleV1 { case groupName, tagName, tagNames, credentialProfileName } + /// `tagName` repeats the first tag for builds that read only it. + init(settings: ExportableConnection, groupName: String?, tagNames: [String], credentialProfileName: String?) { + self.settings = settings + self.groupName = groupName + self.tagName = tagNames.first + self.tagNames = tagNames.isEmpty ? nil : tagNames + self.credentialProfileName = credentialProfileName + } + init(from decoder: any Decoder) throws { settings = try ExportableConnection(from: decoder) let container = try decoder.container(keyedBy: CodingKeys.self) @@ -73,20 +85,29 @@ enum ConnectionBundleV1 { tagNames = try container.decodeIfPresent([String].self, forKey: .tagNames) credentialProfileName = try container.decodeIfPresent(String.self, forKey: .credentialProfileName) } + + func encode(to encoder: any Encoder) throws { + try settings.encode(to: encoder) + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeIfPresent(groupName, forKey: .groupName) + try container.encodeIfPresent(tagName, forKey: .tagName) + try container.encodeIfPresent(tagNames, forKey: .tagNames) + try container.encodeIfPresent(credentialProfileName, forKey: .credentialProfileName) + } } - struct Group: Decodable { + struct Group: Codable { let name: String let color: String? let iconName: String? } - struct Tag: Decodable { + struct Tag: Codable { let name: String let color: String? } - struct CredentialProfile: Decodable { + struct CredentialProfile: Codable { let name: String let username: String let passwordMode: String @@ -106,3 +127,58 @@ enum ConnectionBundleV1 { return values } } + +extension ConnectionBundleV1.Envelope { + /// Nil unless the envelope reads back as the same bundle, connection `i` as ref "i". Saved queries, query + /// folders, a group inside a group, and names that differ only in case fail that, and so does any field + /// format 2 gains later, so format 1 never drops what format 2 keeps. + init?(_ bundle: ConnectionBundle) { + var credentials: [String: ExportableCredentials] = [:] + for (index, connection) in bundle.connections.enumerated() { + credentials[String(index)] = bundle.credentials[connection.ref] + } + let envelope = Self( + formatVersion: ConnectionBundleV1.formatVersion, + exportedAt: bundle.exportedAt, + appVersion: bundle.appVersion, + connections: bundle.connections.map { connection in + ConnectionBundleV1.Connection( + settings: connection.settings, + groupName: bundle.groupChain(connection.groupRef).last?.name, + tagNames: connection.tagNames, + credentialProfileName: bundle.credentialProfile(connection.credentialProfileRef)?.name + ) + }, + groups: bundle.groups.isEmpty ? nil : bundle.groups.map { + ConnectionBundleV1.Group(name: $0.name, color: $0.color, iconName: $0.iconName) + }, + tags: bundle.tags.isEmpty ? nil : bundle.tags.map { ConnectionBundleV1.Tag(name: $0.name, color: $0.color) }, + credentials: credentials.isEmpty ? nil : credentials, + credentialProfiles: bundle.credentialProfiles.isEmpty ? nil : bundle.credentialProfiles.map { + ConnectionBundleV1.CredentialProfile( + name: $0.name, + username: $0.username, + passwordMode: $0.passwordMode.rawValue, + secureFieldIds: $0.secureFieldIds.isEmpty ? nil : $0.secureFieldIds + ) + } + ) + let renumbered = ConnectionBundle( + unchecked: bundle.exportedAt, + appVersion: bundle.appVersion, + connections: bundle.connections.enumerated().map { index, connection in + var copy = connection + copy.ref = BundleRef(String(index)) + return copy + }, + groups: bundle.groups, + tags: bundle.tags, + credentialProfiles: bundle.credentialProfiles, + credentials: Dictionary(uniqueKeysWithValues: credentials.map { (BundleRef($0.key), $0.value) }), + queryFolders: bundle.queryFolders, + savedQueries: bundle.savedQueries + ) + guard (try? envelope.upgraded()) == renumbered else { return nil } + self = envelope + } +} diff --git a/apple/Packages/TableProCore/Tests/TableProImportTests/ConnectionBundleCodecTests.swift b/apple/Packages/TableProCore/Tests/TableProImportTests/ConnectionBundleCodecTests.swift index e53805b03c..aa6148d605 100644 --- a/apple/Packages/TableProCore/Tests/TableProImportTests/ConnectionBundleCodecTests.swift +++ b/apple/Packages/TableProCore/Tests/TableProImportTests/ConnectionBundleCodecTests.swift @@ -37,7 +37,7 @@ struct ConnectionBundleCodecTests { let object = try Self.jsonObject(ConnectionBundleCodec.encode(bundle)) - #expect(object["formatVersion"] as? Int == 2) + #expect(object["formatVersion"] as? Int == 1) #expect(object["exportedAt"] as? String == "2023-11-14T22:13:20Z") #expect(object["appVersion"] as? String == "0.70.0") #expect((object["connections"] as? [Any])?.isEmpty == true) @@ -56,6 +56,89 @@ struct ConnectionBundleCodecTests { #expect(credentials["c1"]?["password"] as? String == "hunter2") } + @Test("A bundle without saved queries or nested groups is written in format 1, linked by name") + func flatBundleIsWrittenInFormatOne() async throws { + let sealed = try await ConnectionBundleCodec.encode(Self.flatBundle(), passphrase: Self.passphrase) + let object = try await Self.jsonObject(ConnectionExportCrypto.decrypt(data: sealed, passphrase: Self.passphrase)) + let connections = try #require(object["connections"] as? [[String: Any]]) + let orders = try #require(connections.first) + + #expect(object["formatVersion"] as? Int == 1) + #expect(orders["name"] as? String == "Orders") + #expect(orders["groupName"] as? String == "Production") + #expect(orders["tagName"] as? String == "prod") + #expect(orders["tagNames"] as? [String] == ["prod", "team"]) + #expect(orders["credentialProfileName"] as? String == "reader") + #expect(connections.allSatisfy { $0["ref"] == nil && $0["groupRef"] == nil && $0["credentialProfileRef"] == nil }) + #expect((object["groups"] as? [[String: Any]])?.first?["ref"] == nil) + #expect((object["credentialProfiles"] as? [[String: Any]])?.first?["passwordMode"] as? String == "pgpass") + let credentials = try #require(object["credentials"] as? [String: Any]) + #expect(Set(credentials.keys) == ["1"]) + } + + @Test("A format 1 file reads back as the bundle it was written from") + func formatOneRoundTrips() async throws { + let bundle = try Self.flatBundle() + + let sealed = try await ConnectionBundleCodec.encode(bundle, passphrase: Self.passphrase) + let decoded = try await ConnectionBundleCodec.decode(sealed, passphrase: Self.passphrase) + + #expect(decoded == bundle) + #expect(decoded.credentials["1"]?.password == "hunter2") + } + + @Test("Saved queries, a query folder or a nested group keep format 2") + func formatTwoOnlyWhenNeeded() throws { + let settings = BundleConnection(ref: "c1", settings: Self.settings()) + let bundles = try [ + ConnectionBundle( + exportedAt: Self.exportedAt, + appVersion: "0.70.0", + connections: [settings], + savedQueries: [BundleSavedQuery(ref: "q1", name: "Locks", sql: "SELECT 1")] + ), + ConnectionBundle( + exportedAt: Self.exportedAt, + appVersion: "0.70.0", + connections: [settings], + queryFolders: [BundleQueryFolder(ref: "f1", name: "Reports")] + ), + ConnectionBundle( + exportedAt: Self.exportedAt, + appVersion: "0.70.0", + connections: [BundleConnection(ref: "c1", settings: Self.settings(), groupRef: "g2")], + groups: [BundleGroup(ref: "g1", name: "Client A"), BundleGroup(ref: "g2", name: "Production", parentRef: "g1")] + ) + ] + + for bundle in bundles { + let data = try ConnectionBundleCodec.encode(bundle) + #expect(try Self.jsonObject(data)["formatVersion"] as? Int == 2) + #expect(try ConnectionBundleCodec.decode(data) == bundle) + } + } + + @Test("Names format 1 would merge keep format 2") + func caseOnlyNamesKeepFormatTwo() throws { + let bundle = try ConnectionBundle( + exportedAt: Self.exportedAt, + appVersion: "0.70.0", + connections: [ + BundleConnection(ref: "c1", settings: Self.settings(), credentialProfileRef: "p1"), + BundleConnection(ref: "c2", settings: Self.settings(name: "Second"), credentialProfileRef: "p2") + ], + credentialProfiles: [ + BundleCredentialProfile(ref: "p1", name: "reader", username: "alice", passwordMode: .prompt), + BundleCredentialProfile(ref: "p2", name: "Reader", username: "bob", passwordMode: .pgpass) + ] + ) + + let data = try ConnectionBundleCodec.encode(bundle) + + #expect(try Self.jsonObject(data)["formatVersion"] as? Int == 2) + #expect(try ConnectionBundleCodec.decode(data) == bundle) + } + @Test("A plain file never carries credentials into the app") func plainDecodeDropsCredentials() throws { let json = Data(""" @@ -123,6 +206,31 @@ struct ConnectionBundleCodecTests { } } + @Test("An encrypted file in a newer format reports the version once the passphrase opens it") + func encryptedNewerFormatIsUnsupported() async throws { + let json = Data(#"{"formatVersion":3,"exportedAt":"2023-11-14T22:13:20Z","appVersion":"9.0","connections":[]}"#.utf8) + let sealed = try await ConnectionExportCrypto.encrypt(data: json, passphrase: Self.passphrase) + + await #expect(throws: ConnectionBundleError.unsupportedVersion(3)) { + _ = try await ConnectionBundleCodec.decode(sealed, passphrase: Self.passphrase) + } + } + + @Test("An encrypted file no passphrase can open fails to parse instead of asking again") + func unreadableContainerIsNotAPassphraseFailure() async { + let saltAndNonce = Data(repeating: 0, count: 44) + let cases: [(container: Data, reason: ConnectionExportCryptoError)] = [ + (Data("TPRO".utf8) + Data([1]) + saltAndNonce + Data(repeating: 0, count: 10), .corruptData), + (Data("TPRO".utf8) + Data([2]) + saltAndNonce + Data(repeating: 0, count: 40), .unsupportedVersion(2)) + ] + for entry in cases { + #expect(ConnectionBundleCodec.isEncrypted(entry.container)) + await #expect(throws: ConnectionBundleError.decodingFailed(entry.reason.localizedDescription)) { + _ = try await ConnectionBundleCodec.decode(entry.container, passphrase: Self.passphrase) + } + } + } + @Test("A newer format is unsupported whatever its shape") func newerFormatIsUnsupported() { let json = Data(#"{"formatVersion":3,"bundle":{"anything":[1,2,3]}}"#.utf8) @@ -308,6 +416,7 @@ struct ConnectionBundleCodecTests { ) } + /// The saved query keeps it in format 2, where credentials are keyed by connection ref. private static func bundleWithCredentials() throws -> ConnectionBundle { try ConnectionBundle( exportedAt: exportedAt, @@ -320,7 +429,8 @@ struct ConnectionBundleCodecTests { sslClientKeyPassphrase: nil, totpSecret: nil, pluginSecureFields: ["token": "secret"] - )] + )], + savedQueries: [BundleSavedQuery(ref: "q1", name: "Locks", sql: "SELECT 1", connectionRef: "c1")] ) } @@ -358,6 +468,33 @@ struct ConnectionBundleCodecTests { ) } + /// Built the way a format 1 file is read back: connection `i` has ref "i". + static func flatBundle() throws -> ConnectionBundle { + var builder = ConnectionBundleBuilder(appVersion: "0.70.0", exportedAt: exportedAt) + builder.addConnection( + fullSettings(), + ref: "0", + groupPath: [.init(name: "Production", color: "Blue", iconName: "person.3")], + tags: [BundleTag(name: "prod", color: "Red"), BundleTag(name: "team")], + credentialProfile: .init(name: "reader", username: "ro", passwordMode: .pgpass, secureFieldIds: ["token"]) + ) + builder.addConnection( + settings(name: "Cache"), + ref: "1", + groupPath: [.init(name: "Production")], + credentialProfile: .init(name: "reader", username: "ro", passwordMode: .pgpass), + credentials: ExportableCredentials( + password: "hunter2", + sshPassword: "ssh", + keyPassphrase: nil, + sslClientKeyPassphrase: "ssl", + totpSecret: "totp", + pluginSecureFields: ["token": "secret"] + ) + ) + return try builder.build() + } + static func fullBundle() throws -> ConnectionBundle { try ConnectionBundle( exportedAt: exportedAt, diff --git a/apple/Packages/TableProCore/Tests/TableProImportTests/ConnectionBundleOldBuildCompatibilityTests.swift b/apple/Packages/TableProCore/Tests/TableProImportTests/ConnectionBundleOldBuildCompatibilityTests.swift index cd1d50fed7..37ce169cd6 100644 --- a/apple/Packages/TableProCore/Tests/TableProImportTests/ConnectionBundleOldBuildCompatibilityTests.swift +++ b/apple/Packages/TableProCore/Tests/TableProImportTests/ConnectionBundleOldBuildCompatibilityTests.swift @@ -3,9 +3,43 @@ import TableProImport import Testing /// The frozen copy of the format 1 reader references no live type, so editing a live type cannot make this pass -/// while an older build would fail to decode a new file instead of reporting its version. -@Suite("An older build reports a format 2 file as needing a newer version") +/// while an older build would fail to decode a new file instead of opening it or reporting its version. +@Suite("An older build opens a format 1 file and reports a format 2 file as needing a newer version") struct ConnectionBundleOldBuildCompatibilityTests { + @Test("A file without saved queries or nested groups opens in an older build, linked by name") + func flatFileOpens() throws { + let data = try ConnectionBundleCodec.encode(ConnectionBundleCodecTests.flatBundle().withoutCredentials()) + + let envelope = try OldBuild.decode(data) + + #expect(envelope.formatVersion == 1) + #expect(envelope.appVersion == "0.70.0") + #expect(envelope.connections.map(\.name) == ["Orders", "Cache"]) + #expect(envelope.connections.map(\.groupName) == ["Production", "Production"]) + #expect(envelope.connections.first?.tagName == "prod") + #expect(envelope.connections.first?.tagNames == ["prod", "team"]) + #expect(envelope.connections.map(\.credentialProfileName) == ["reader", "reader"]) + #expect(envelope.connections.first?.sshConfig?.jumpHosts?.first?.host == "jump") + #expect(envelope.groups?.map(\.name) == ["Production"]) + #expect(envelope.groups?.first?.color == "Blue") + #expect(envelope.tags?.map(\.name) == ["prod", "team"]) + #expect(envelope.credentialProfiles?.first?.passwordMode == "pgpass") + #expect(envelope.credentialProfiles?.first?.secureFieldIds == ["token"]) + } + + @Test("An encrypted format 1 file opens in an older build with each password under its connection's position") + func encryptedFlatFileOpens() async throws { + let sealed = try await ConnectionBundleCodec.encode(ConnectionBundleCodecTests.flatBundle(), passphrase: "pw") + + let json = try await ConnectionExportCrypto.decrypt(data: sealed, passphrase: "pw") + let envelope = try OldBuild.decode(json) + + #expect(envelope.formatVersion == 1) + #expect(envelope.credentials?.keys.sorted() == ["1"]) + #expect(envelope.credentials?["1"]?.password == "hunter2") + #expect(envelope.credentials?["1"]?.pluginSecureFields == ["token": "secret"]) + } + @Test("A plain format 2 file decodes with the format 1 types and reads version 2") func plainFileDecodesAsVersionTwo() throws { let data = try ConnectionBundleCodec.encode(ConnectionBundleCodecTests.fullBundle()) @@ -34,13 +68,13 @@ struct ConnectionBundleOldBuildCompatibilityTests { #expect(orders.tunnelCommand?.kubernetesResource == "svc/postgres") } - @Test("A format 2 file with no connections still has every key a format 1 build requires") - func emptyFileDecodes() throws { + @Test("A file with no connections opens in an older build") + func emptyFileOpens() throws { let bundle = try ConnectionBundle(appVersion: "0.70.0", connections: []) - let envelope = try OldBuild.decodeWithoutGate(ConnectionBundleCodec.encode(bundle)) + let envelope = try OldBuild.decode(ConnectionBundleCodec.encode(bundle)) - #expect(envelope.formatVersion == 2) + #expect(envelope.formatVersion == 1) #expect(envelope.connections.isEmpty) } @@ -57,7 +91,8 @@ struct ConnectionBundleOldBuildCompatibilityTests { func encryptedFileDecodesAsVersionTwo() async throws { let bundle = try ConnectionBundle( appVersion: "0.70.0", - connections: [BundleConnection(ref: "c1", settings: ConnectionBundleCodecTests.fullSettings())], + connections: [BundleConnection(ref: "c1", settings: ConnectionBundleCodecTests.fullSettings(), groupRef: "g2")], + groups: [BundleGroup(ref: "g1", name: "Client A"), BundleGroup(ref: "g2", name: "Production", parentRef: "g1")], credentials: ["c1": ExportableCredentials( password: "hunter2", sshPassword: nil, diff --git a/apple/TablePro/Views/Connection/ConnectionImportSheet.swift b/apple/TablePro/Views/Connection/ConnectionImportSheet.swift index 4955884de8..52bc4aceba 100644 --- a/apple/TablePro/Views/Connection/ConnectionImportSheet.swift +++ b/apple/TablePro/Views/Connection/ConnectionImportSheet.swift @@ -148,8 +148,13 @@ struct ConnectionImportSheet: View { passphrase = "" await showReview(of: bundle) } catch { - passphraseError = error.localizedDescription passphrase = "" + // Only a failed decryption can be fixed by another passphrase; a file that decrypts but cannot be read cannot. + guard case .decryptionFailed? = error as? ConnectionBundleError else { + phase = .failed(error.localizedDescription) + return + } + passphraseError = error.localizedDescription } } diff --git a/apple/TableProMobile/TableProMobile/Views/MobileConnectionImportSheet.swift b/apple/TableProMobile/TableProMobile/Views/MobileConnectionImportSheet.swift index ac42e781a0..0c3b333b21 100644 --- a/apple/TableProMobile/TableProMobile/Views/MobileConnectionImportSheet.swift +++ b/apple/TableProMobile/TableProMobile/Views/MobileConnectionImportSheet.swift @@ -265,8 +265,13 @@ struct MobileConnectionImportSheet: View { } catch is CancellationError { return } catch { - passphraseError = error.localizedDescription passphrase = "" + // Only a failed decryption can be fixed by another passphrase; a file that decrypts but cannot be read cannot. + guard case .decryptionFailed? = error as? ConnectionBundleError else { + phase = .failed(error.localizedDescription) + return + } + passphraseError = error.localizedDescription } } diff --git a/apple/TableProMobile/TableProMobileTests/IOSConnectionExportServiceTests.swift b/apple/TableProMobile/TableProMobileTests/IOSConnectionExportServiceTests.swift index 01d03ecea5..2c436875e1 100644 --- a/apple/TableProMobile/TableProMobileTests/IOSConnectionExportServiceTests.swift +++ b/apple/TableProMobile/TableProMobileTests/IOSConnectionExportServiceTests.swift @@ -75,12 +75,12 @@ struct IOSConnectionExportServiceTests { #expect(!text.contains(keyMarker)) } - @Test("An export writes the current file format and no saved queries") - func exportWritesCurrentFormatWithoutQueries() async throws { + @Test("An export without nested groups writes the format older builds open, and no saved queries") + func exportWritesFormatOneWithoutQueries() async throws { let text = try await exportedText(includeCredentials: false) let object = try #require(try JSONSerialization.jsonObject(with: Data(text.utf8)) as? [String: Any]) - #expect(object["formatVersion"] as? Int == ConnectionBundle.formatVersion) + #expect(object["formatVersion"] as? Int == 1) #expect(!object.keys.contains("savedQueries")) #expect(!object.keys.contains("queryFolders")) } diff --git a/apple/TableProMobile/TableProMobileTests/IOSConnectionImportServiceTests.swift b/apple/TableProMobile/TableProMobileTests/IOSConnectionImportServiceTests.swift index 01d07edd48..4011ec5334 100644 --- a/apple/TableProMobile/TableProMobileTests/IOSConnectionImportServiceTests.swift +++ b/apple/TableProMobile/TableProMobileTests/IOSConnectionImportServiceTests.swift @@ -31,6 +31,11 @@ struct IOSConnectionImportServiceTests { try await analyzed(ConnectionBundleCodec.encode(bundle)) } + // A format 1 file numbers its connections by position, so the refs a test built do not survive the file. + private func ref(of name: String, in preview: ImportPreview) throws -> BundleRef { + try #require(preview.connections.first { $0.settings.name == name }).ref + } + @discardableResult private func apply(_ preview: ImportPreview, _ selection: ImportSelection? = nil) async -> ImportOutcome { let plan = ImportPlanner.plan(preview, selection: selection ?? .defaults(for: preview)) @@ -214,8 +219,9 @@ struct IOSConnectionImportServiceTests { tags: [BundleTag(name: "skipped-tag")] ) let preview = try await analyzed(try builder.build()) + let skipped = try ref(of: "Skipped", in: preview) var selection = ImportSelection.defaults(for: preview) - selection.setSelected(false, connection: "c2", in: preview) + selection.setSelected(false, connection: skipped, in: preview) let outcome = await apply(preview, selection) @@ -264,12 +270,14 @@ struct IOSConnectionImportServiceTests { BundleConnection(ref: "c2", settings: settings(name: "Second")) ]) let preview = try await analyzed(bundle) + let first = try ref(of: "First", in: preview) + let second = try ref(of: "Second", in: preview) var selection = ImportSelection.defaults(for: preview) - selection.setSelected(true, connection: "c1", in: preview) - selection.setSelected(true, connection: "c2", in: preview) + selection.setSelected(true, connection: first, in: preview) + selection.setSelected(true, connection: second, in: preview) - let firstReplace = selection.resolve("c1", as: .replace(existing.id), in: preview) - let secondReplace = selection.resolve("c2", as: .replace(existing.id), in: preview) + let firstReplace = selection.resolve(first, as: .replace(existing.id), in: preview) + let secondReplace = selection.resolve(second, as: .replace(existing.id), in: preview) #expect(firstReplace) #expect(!secondReplace) @@ -309,9 +317,10 @@ struct IOSConnectionImportServiceTests { imported.username = "" let bundle = try ConnectionBundle(appVersion: "Tests", connections: [BundleConnection(ref: "c1", settings: imported)]) let preview = try await analyzed(bundle) + let row = try ref(of: existing.name, in: preview) var selection = ImportSelection.defaults(for: preview) - selection.setSelected(true, connection: "c1", in: preview) - let replaces = selection.resolve("c1", as: .replace(existing.id), in: preview) + selection.setSelected(true, connection: row, in: preview) + let replaces = selection.resolve(row, as: .replace(existing.id), in: preview) #expect(replaces) let outcome = await apply(preview, selection) diff --git a/apple/TableProTests/Core/Services/Export/ConnectionBundleExporterTests.swift b/apple/TableProTests/Core/Services/Export/ConnectionBundleExporterTests.swift index 466b80db22..8229063982 100644 --- a/apple/TableProTests/Core/Services/Export/ConnectionBundleExporterTests.swift +++ b/apple/TableProTests/Core/Services/Export/ConnectionBundleExporterTests.swift @@ -85,8 +85,9 @@ struct ConnectionBundleExporterTests { #expect(ConnectionBundleCodec.isEncrypted(data)) let bundle = try await ConnectionBundleCodec.decode(data, passphrase: "correct horse") - #expect(bundle.connections.map(\.settings.name) == ["Secret"]) - #expect(bundle.credentials["c1"]?.password == "hunter2") + let connection = try #require(bundle.connections.first) + #expect(connection.settings.name == "Secret") + #expect(bundle.credentials[connection.ref]?.password == "hunter2") } @Test("An encrypted file does not open with the wrong passphrase") diff --git a/apple/TableProTests/Core/Services/TeamCatalogPublisherTests.swift b/apple/TableProTests/Core/Services/TeamCatalogPublisherTests.swift index 10bc8b14d1..69e05a8227 100644 --- a/apple/TableProTests/Core/Services/TeamCatalogPublisherTests.swift +++ b/apple/TableProTests/Core/Services/TeamCatalogPublisherTests.swift @@ -68,6 +68,39 @@ struct TeamCatalogPublisherTests { #expect(!json.contains("hunter2")) } + @Test("A catalog file opens in older builds unless its connection sits in a nested group") + func catalogFileFormatFollowsGroupDepth() throws { + let folder = try makeTempDirectory() + let library = try ImportLibraryFixture() + defer { + try? FileManager.default.removeItem(at: folder) + library.cleanUp() + } + let client = ConnectionGroup(name: "Client A") + let production = ConnectionGroup(name: "Production", parentId: client.id) + for group in [client, production] { + try library.groups.addGroup(group) + } + var flat = DatabaseConnection(name: "Flat DB") + flat.groupId = client.id + var nested = DatabaseConnection(name: "Nested DB") + nested.groupId = production.id + + let written = try TeamCatalogPublisher.publish([flat, nested], to: folder, exporter: library.exporter) + + let objects = try written.map { url in + try #require(JSONSerialization.jsonObject(with: Data(contentsOf: url)) as? [String: Any]) + } + let flatEntry = try #require((objects[0]["connections"] as? [[String: Any]])?.first) + #expect(objects[0]["formatVersion"] as? Int == 1) + #expect(flatEntry["groupName"] as? String == "Client A") + #expect(flatEntry["ref"] == nil) + #expect(objects[1]["formatVersion"] as? Int == 2) + let nestedBundle = try ConnectionBundleCodec.decode(Data(contentsOf: written[1])) + let nestedGroup = nestedBundle.groupChain(nestedBundle.connections.first?.groupRef) + #expect(nestedGroup.map(\.name) == ["Client A", "Production"]) + } + @Test("Published file cannot carry a command password source (RCE guard)") func publishStripsPasswordSource() throws { let folder = try makeTempDirectory() diff --git a/apple/changelog.d/3685.fixed.1.md b/apple/changelog.d/3685.fixed.1.md new file mode 100644 index 0000000000..971903d3ff --- /dev/null +++ b/apple/changelog.d/3685.fixed.1.md @@ -0,0 +1 @@ +An encrypted connection file that needs a newer TablePro reported the error as a wrong passphrase and cleared it. diff --git a/apple/changelog.d/3685.fixed.md b/apple/changelog.d/3685.fixed.md new file mode 100644 index 0000000000..ccf7776e93 --- /dev/null +++ b/apple/changelog.d/3685.fixed.md @@ -0,0 +1 @@ +Connection files without saved queries or nested groups did not open in TablePro for iPhone or in versions before 0.80.