From 755b733d8cb105bd76df65cb156f6774b3156d95 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 12 Oct 2026 19:52:10 +0700 Subject: [PATCH] refactor(connections)!: remove the clipboard banner and Copy as JSON --- CHANGELOG.md | 2 + .../Services/Export/ConnectionShareLink.swift | 11 - .../ClipboardConnectionCandidate.swift | 24 --- TablePro/Resources/Localizable.xcstrings | 2 + .../ViewModels/WelcomeViewModel+Library.swift | 3 - .../ClipboardConnectionBanner.swift | 86 -------- .../ConnectionFormCoordinator.swift | 42 ---- .../ConnectionFormWindowController.swift | 1 - .../Panes/GeneralPaneView.swift | 11 - TablePro/Views/Welcome/WelcomeMenuSpec.swift | 3 - .../Services/ConnectionSharingIconTests.swift | 5 +- .../Services/ConnectionSharingTests.swift | 66 +----- .../Export/ConnectionShareLinkTests.swift | 21 -- .../ConnectionFormClipboardTests.swift | 202 ------------------ .../ConnectionFormTransportTests.swift | 16 -- .../ConnectionFormURLImportTests.swift | 86 ++++++++ 16 files changed, 94 insertions(+), 487 deletions(-) delete mode 100644 TablePro/Core/Utilities/Connection/ClipboardConnectionCandidate.swift delete mode 100644 TablePro/Views/ConnectionForm/Components/ClipboardConnectionBanner.swift delete mode 100644 TableProTests/ViewModels/ConnectionFormClipboardTests.swift create mode 100644 TableProTests/ViewModels/ConnectionFormURLImportTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 3944d16251..dc253e2657 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Interface, Sidebar and Toolbar color groups from the theme editor and the theme file. - Fonts from the theme file. +- Clipboard URL banner in the New Connection form. Import from URL fills the form from a pasted link. +- Share > Copy as JSON in the connection list. Copy as JSON in the data grid stays. ### Fixed diff --git a/TablePro/Core/Services/Export/ConnectionShareLink.swift b/TablePro/Core/Services/Export/ConnectionShareLink.swift index dc040d6e7f..38efed2e9c 100644 --- a/TablePro/Core/Services/Export/ConnectionShareLink.swift +++ b/TablePro/Core/Services/Export/ConnectionShareLink.swift @@ -89,17 +89,6 @@ internal enum ConnectionShareLink { return link } - static func compactJSON(for connection: DatabaseConnection, exporter: ConnectionBundleExporter = .init()) -> String { - let encoder = JSONEncoder() - encoder.outputFormatting = .sortedKeys - guard let data = try? encoder.encode(exporter.portableSettings(for: connection)), - let json = String(data: data, encoding: .utf8) else { - logger.warning("Could not encode '\(connection.name, privacy: .private)' as JSON") - return "{}" - } - return json - } - private static func sshQueryItems(_ ssh: ExportableSSHConfig) -> [URLQueryItem] { var items = [ URLQueryItem(name: "ssh", value: "1"), diff --git a/TablePro/Core/Utilities/Connection/ClipboardConnectionCandidate.swift b/TablePro/Core/Utilities/Connection/ClipboardConnectionCandidate.swift deleted file mode 100644 index eb2790afee..0000000000 --- a/TablePro/Core/Utilities/Connection/ClipboardConnectionCandidate.swift +++ /dev/null @@ -1,24 +0,0 @@ -// -// ClipboardConnectionCandidate.swift -// TablePro -// - -import Foundation - -struct ClipboardConnectionCandidate { - let scheme: String - let parsed: ParsedConnectionURL - - init?(clipboardText: String) { - let firstLine = clipboardText - .components(separatedBy: .newlines) - .first? - .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - guard let schemeEnd = firstLine.range(of: "://"), - case .success(let parsed) = ConnectionURLParser.parse(firstLine), - !parsed.host.isEmpty - else { return nil } - self.scheme = firstLine[firstLine.startIndex.. Void - let onDismiss: () -> Void - - var body: some View { - HStack(spacing: 6) { - Image(systemName: "doc.on.clipboard") - .font(.caption) - .foregroundStyle(.secondary) - - Text(String(localized: "Use clipboard URL")) - .font(.callout) - .foregroundStyle(.primary) - - Text(Self.summary(for: candidate)) - .font(.callout.monospaced()) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.middle) - .layoutPriority(0) - - Spacer(minLength: 8) - - Button(action: onUse) { - Text(String(localized: "Use")) - } - .buttonStyle(.link) - .controlSize(.small) - - Button(action: onDismiss) { - Image(systemName: "xmark") - .font(.caption2.weight(.medium)) - .foregroundStyle(.secondary) - .frame(width: 16, height: 16) - .contentShape(Rectangle()) - } - .buttonStyle(.borderless) - .accessibilityLabel(String(localized: "Dismiss")) - } - .padding(.horizontal, 14) - .padding(.vertical, 8) - .frame(maxWidth: .infinity) - .background(.quaternary.opacity(0.4)) - .overlay(alignment: .bottom) { - Rectangle() - .frame(height: 0.5) - .foregroundStyle(.separator) - } - } - - static func summary(for candidate: ClipboardConnectionCandidate) -> String { - let parsed = candidate.parsed - var rendered = candidate.scheme + "://" - if !parsed.username.isEmpty { - rendered += parsed.username - if !parsed.password.isEmpty { - rendered += ":***" - } - rendered += "@" - } - rendered += parsed.host - if !parsed.useSrv, parsed.localSocketPath == nil, parsed.resolvedPort > 0 { - rendered += ":\(parsed.resolvedPort)" - } - if !parsed.database.isEmpty { - rendered += "/\(parsed.database)" - } - if let socketPath = parsed.localSocketPath { - rendered += "?socket=\(socketPath)" - } - if rendered.count > 60 { - let prefix = rendered.prefix(48) - let suffix = rendered.suffix(8) - rendered = "\(prefix)…\(suffix)" - } - return rendered - } -} diff --git a/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift b/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift index f11e2b5e3c..50a9c56592 100644 --- a/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift +++ b/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift @@ -53,9 +53,6 @@ final class ConnectionFormCoordinator: ObservableObject { @Published var saveError: String? - @Published var clipboardCandidate: ClipboardConnectionCandidate? - @Published var clipboardBannerDismissed: Bool = false - @Published var isChoosingType: Bool = false @@ -1046,43 +1043,4 @@ final class ConnectionFormCoordinator: ObservableObject { advanced.additionalFieldValues[fieldId] = value } } - - // MARK: - Clipboard - - func detectClipboardConnectionStringIfNeeded( - connectionStorage: ConnectionStorage = .shared, - pasteboard: NSPasteboard = .general - ) { - guard isNew, !clipboardBannerDismissed, clipboardCandidate == nil else { return } - guard let raw = pasteboard.string(forType: .string), - let candidate = ClipboardConnectionCandidate(clipboardText: raw), - !matchesExistingConnection(candidate.parsed, connectionStorage: connectionStorage) - else { return } - clipboardCandidate = candidate - } - - func applyClipboardCandidate(_ candidate: ClipboardConnectionCandidate) { - applyParsed(candidate.parsed) - auth.promptForPassword = false - normalizeTransport() - clipboardCandidate = nil - } - - func dismissClipboardCandidate() { - clipboardCandidate = nil - clipboardBannerDismissed = true - } - - private func matchesExistingConnection( - _ parsed: ParsedConnectionURL, - connectionStorage: ConnectionStorage - ) -> Bool { - connectionStorage.loadConnections().contains { saved in - guard saved.username == parsed.username, saved.localSocketPath == parsed.localSocketPath else { - return false - } - return parsed.localSocketPath != nil - || (saved.host == parsed.host && saved.port == parsed.resolvedPort) - } - } } diff --git a/TablePro/Views/ConnectionForm/ConnectionFormWindowController.swift b/TablePro/Views/ConnectionForm/ConnectionFormWindowController.swift index 858cf42694..e60cc7456e 100644 --- a/TablePro/Views/ConnectionForm/ConnectionFormWindowController.swift +++ b/TablePro/Views/ConnectionForm/ConnectionFormWindowController.swift @@ -45,7 +45,6 @@ internal final class ConnectionFormWindowController: NSWindowController, NSWindo /// form's save, cancel and delete paths need an explicit way to close the window. coordinator.dismissAction = { ConnectionFormWindowController.close(request) } coordinator.start() - coordinator.detectClipboardConnectionStringIfNeeded() let split = ConnectionFormSplitViewController(coordinator: coordinator) diff --git a/TablePro/Views/ConnectionForm/Panes/GeneralPaneView.swift b/TablePro/Views/ConnectionForm/Panes/GeneralPaneView.swift index 0da6206ba8..d6b448153f 100644 --- a/TablePro/Views/ConnectionForm/Panes/GeneralPaneView.swift +++ b/TablePro/Views/ConnectionForm/Panes/GeneralPaneView.swift @@ -27,17 +27,6 @@ struct GeneralPaneView: View { var body: some View { Form { - if let candidate = coordinator.clipboardCandidate { - Section { - ClipboardConnectionBanner( - candidate: candidate, - onUse: { coordinator.applyClipboardCandidate(candidate) }, - onDismiss: { coordinator.dismissClipboardCandidate() } - ) - .listRowInsets(EdgeInsets()) - } - } - identitySection connectionSection authenticationSection diff --git a/TablePro/Views/Welcome/WelcomeMenuSpec.swift b/TablePro/Views/Welcome/WelcomeMenuSpec.swift index 76bbc1b3cd..01b8f53d1d 100644 --- a/TablePro/Views/Welcome/WelcomeMenuSpec.swift +++ b/TablePro/Views/Welcome/WelcomeMenuSpec.swift @@ -19,7 +19,6 @@ internal enum WelcomeMenuCommand: Equatable { case setFavorite([UUID], Bool) case copyConnectionString(UUID) case copyTableProLink(UUID) - case copyJSON(UUID) case exportToFile([UUID]) case publishToTeamCatalog([UUID]) case publishToTeamLibrary([UUID]) @@ -234,7 +233,6 @@ internal enum WelcomeMenuSpec { WelcomeMenuSection([ .command(Titles.copyConnectionString, .copyConnectionString(id)), .command(Titles.copyTableProLink, .copyTableProLink(id)), - .command(Titles.copyJSON, .copyJSON(id)), ]), WelcomeMenuSection(publish), ]) @@ -378,7 +376,6 @@ internal extension WelcomeMenuSpec { static var share: String { String(localized: "Share") } static var copyConnectionString: String { String(localized: "Copy Connection String") } static var copyTableProLink: String { String(localized: "Copy TablePro Link") } - static var copyJSON: String { String(localized: "Copy as JSON") } static var moveToGroup: String { String(localized: "Move to Group") } static var removeFromGroup: String { String(localized: "Remove from Group") } static var includeInSync: String { String(localized: "Include in iCloud Sync") } diff --git a/TableProTests/Core/Services/ConnectionSharingIconTests.swift b/TableProTests/Core/Services/ConnectionSharingIconTests.swift index 7aec60745f..8a0b1d61bf 100644 --- a/TableProTests/Core/Services/ConnectionSharingIconTests.swift +++ b/TableProTests/Core/Services/ConnectionSharingIconTests.swift @@ -49,7 +49,7 @@ struct ConnectionSharingIconTests { // MARK: - Connection icon - @Test("A file export and Copy as JSON carry the connection's icon") + @Test("A file export carries the connection's icon") func exportCarriesIcon() throws { let library = try ImportLibraryFixture() defer { library.cleanUp() } @@ -57,11 +57,8 @@ struct ConnectionSharingIconTests { connection.iconName = "server.rack" let bundle = try library.exporter.connectionsOnlyBundle(for: [connection]) - let json = ConnectionShareLink.compactJSON(for: connection, exporter: library.exporter) - let fromJSON = try JSONDecoder().decode(ExportableConnection.self, from: Data(json.utf8)) #expect(bundle.connections.first?.settings.iconName == "server.rack") - #expect(fromJSON.iconName == "server.rack") } @Test("Copy TablePro Link carries the icon and the import keeps it") diff --git a/TableProTests/Core/Services/ConnectionSharingTests.swift b/TableProTests/Core/Services/ConnectionSharingTests.swift index 550816a2f9..fb93e1590d 100644 --- a/TableProTests/Core/Services/ConnectionSharingTests.swift +++ b/TableProTests/Core/Services/ConnectionSharingTests.swift @@ -355,70 +355,10 @@ struct ConnectionSharingTests { } } - // MARK: - Copy as JSON - - @Suite("Copy as JSON") - struct BuildCompactJSONTests { - @Test("Returns valid JSON") - @MainActor - func testReturnsValidJSON() throws { - let conn = DatabaseConnection( - name: "Dev", host: "localhost", port: 3_306, - database: "mydb", username: "root", type: .mysql - ) - let json = ConnectionShareLink.compactJSON(for: conn) - let data = try #require(json.data(using: .utf8)) - let decoded = try? JSONDecoder().decode(ExportableConnection.self, from: data) - #expect(decoded != nil) - #expect(decoded?.name == "Dev") - #expect(decoded?.host == "localhost") - #expect(decoded?.type == "MySQL") - } - - @Test("Excludes SSH profile ID") - @MainActor - func testExcludesSSHProfileId() throws { - let conn = DatabaseConnection( - name: "Dev", host: "localhost", port: 3_306, - database: "", username: "", type: .mysql - ) - let json = ConnectionShareLink.compactJSON(for: conn) - let data = try #require(json.data(using: .utf8)) - let decoded = try JSONDecoder().decode(ExportableConnection.self, from: data) - #expect(decoded.sshProfileId == nil) - } - - @Test("Is compact (not pretty printed)") - @MainActor - func testIsCompact() { - let conn = DatabaseConnection( - name: "Dev", host: "localhost", port: 3_306, - database: "", username: "", type: .mysql - ) - let json = ConnectionShareLink.compactJSON(for: conn) - #expect(!json.contains("\n ")) - } - - @Test("Includes SSH config when enabled") - @MainActor - func testIncludesSSHInJSON() throws { - var ssh = SSHConfiguration() - ssh.enabled = true - ssh.host = "bastion.com" - ssh.port = 22 - ssh.username = "admin" - let conn = DatabaseConnection( - name: "SSH", host: "db.com", port: 5_432, - database: "", username: "", type: .postgresql, - sshConfig: ssh - ) - let json = ConnectionShareLink.compactJSON(for: conn) - let data = try #require(json.data(using: .utf8)) - let decoded = try JSONDecoder().decode(ExportableConnection.self, from: data) - #expect(decoded.sshConfig != nil) - #expect(decoded.sshConfig?.host == "bastion.com") - } + // MARK: - Export envelope + @Suite("Export envelope") + struct ExportEnvelopeTests { @Test("Export files and the Team Library leave out the fields every importer drops") @MainActor func testEnvelopeLeavesOutImportBlockedFields() throws { diff --git a/TableProTests/Core/Services/Export/ConnectionShareLinkTests.swift b/TableProTests/Core/Services/Export/ConnectionShareLinkTests.swift index ec1f8472c6..a004b68fc5 100644 --- a/TableProTests/Core/Services/Export/ConnectionShareLinkTests.swift +++ b/TableProTests/Core/Services/Export/ConnectionShareLinkTests.swift @@ -88,25 +88,4 @@ struct ConnectionShareLinkTests { #expect(URLComponents(string: link)?.queryItems?.first { $0.name == "sslMode" }?.value == "Verify Identity") } - - @Test("Copy as JSON is the settings object alone") - func compactJSONIsSettingsOnly() throws { - let library = try ImportLibraryFixture() - defer { library.cleanUp() } - let group = ConnectionGroup(name: "Production") - try library.groups.addGroup(group) - var connection = DatabaseConnection(name: "Orders", host: "db.example.com", port: 5_432, type: .postgresql) - connection.groupId = group.id - - let json = ConnectionShareLink.compactJSON(for: connection, exporter: library.exporter) - - let object = try #require( - try JSONSerialization.jsonObject(with: Data(json.utf8)) as? [String: Any] - ) - #expect(object["name"] as? String == "Orders") - #expect(object["groupName"] == nil) - #expect(object["groupRef"] == nil) - #expect(object["ref"] == nil) - #expect(!json.contains("\n")) - } } diff --git a/TableProTests/ViewModels/ConnectionFormClipboardTests.swift b/TableProTests/ViewModels/ConnectionFormClipboardTests.swift deleted file mode 100644 index 39b82df5d6..0000000000 --- a/TableProTests/ViewModels/ConnectionFormClipboardTests.swift +++ /dev/null @@ -1,202 +0,0 @@ -// -// ConnectionFormClipboardTests.swift -// TableProTests -// - -import Foundation -import TableProPluginKit -import Testing - -@testable import TablePro - -@MainActor -struct ConnectionFormClipboardTests { - private func formApplyingClipboard(_ text: String) throws -> ConnectionFormCoordinator { - let candidate = try #require(ClipboardConnectionCandidate(clipboardText: text)) - let coordinator = ConnectionFormCoordinator(connectionId: nil) - coordinator.start() - coordinator.applyClipboardCandidate(candidate) - return coordinator - } - - private func formImporting(_ url: String) throws -> ConnectionFormCoordinator { - guard case .success(let parsed) = ConnectionURLParser.parse(url) else { - throw ConnectionURLParseError.invalidURL - } - let coordinator = ConnectionFormCoordinator(connectionId: nil, initialParsedURL: parsed) - coordinator.start() - return coordinator - } - - @Test("sslmode=verify-full on the clipboard opens the form on Verify Identity") - func verifyFullKeepsIdentityVerification() throws { - let coordinator = try formApplyingClipboard("postgresql://u@db.example.com/app?sslmode=verify-full") - #expect(coordinator.ssl.mode == .verifyIdentity) - } - - @Test("sslmode=verify-ca on the clipboard opens the form on Verify CA") - func verifyCaKeepsCertificateVerification() throws { - let coordinator = try formApplyingClipboard("postgresql://u@db.example.com/app?sslmode=verify-ca") - #expect(coordinator.ssl.mode == .verifyCa) - } - - @Test("sslmode=disable on the clipboard opens the form on Disabled") - func disableTurnsTLSOff() throws { - let coordinator = try formApplyingClipboard("postgresql://u@db.example.com/app?sslmode=disable") - #expect(coordinator.ssl.mode == .disabled) - } - - @Test( - "The clipboard banner and Import from URL fill the same TLS mode", - arguments: [ - "postgresql://u@db.example.com/app", - "postgresql://u@db.example.com/app?sslmode=require", - "postgresql://u@db.example.com/app?sslmode=verify-full", - "mysql://root@db.example.com/app?ssl=true", - "mysql://root@db.example.com/app?sslmode=verify-identity", - "rediss://cache.example.com", - "mongodb+srv://u:p@cluster0.example.net/test" - ] - ) - func clipboardMatchesImportFromURL(_ url: String) throws { - let clipboard = try formApplyingClipboard(url) - let imported = try formImporting(url) - #expect(clipboard.network.type == imported.network.type) - #expect(clipboard.network.port == imported.network.port) - #expect(clipboard.ssl.mode == imported.ssl.mode) - } - - @Test( - "ssl=1 and ssl=require on the clipboard turn TLS on as ssl=true does", - arguments: [ - "postgresql://u@db.example.com/app", - "mysql://root@db.example.com/app", - "redis://cache.example.com" - ], - ["1", "require"] - ) - func sslFlagSpellingsTurnTLSOn(_ base: String, _ value: String) throws { - let spelled = try formApplyingClipboard("\(base)?ssl=\(value)") - let canonical = try formApplyingClipboard("\(base)?ssl=true") - #expect(spelled.ssl.mode == canonical.ssl.mode) - #expect(spelled.ssl.mode == .required) - } - - @Test("Host, credentials and database from the clipboard reach the form") - func connectionFieldsReachTheForm() throws { - let coordinator = try formApplyingClipboard("postgres://alice:p%40ss@db.example.com:6000/sales") - #expect(coordinator.network.type == .postgresql) - #expect(coordinator.network.host == "db.example.com") - #expect(coordinator.network.port == "6000") - #expect(coordinator.network.database == "sales") - #expect(coordinator.auth.username == "alice") - #expect(coordinator.auth.password == "p@ss") - #expect(coordinator.clipboardCandidate == nil) - } - - @Test( - "Clipboard text that names no server is not offered", - arguments: [ - "just a sentence", - " ", - "mysqlx://localhost:33060/test", - "postgres://", - "mysql:///dbonly" - ] - ) - func textWithoutAServerIsNotOffered(_ text: String) { - #expect(ClipboardConnectionCandidate(clipboardText: text) == nil) - } - - @Test("Only the first line of the clipboard is read, and its scheme is kept as typed") - func firstLineIsTheCandidate() throws { - let candidate = try #require( - ClipboardConnectionCandidate(clipboardText: " Postgres://alice@db.example.com/sales\nsecond line") - ) - #expect(candidate.scheme == "postgres") - #expect(candidate.parsed.host == "db.example.com") - #expect(candidate.parsed.database == "sales") - } - - @Test("The banner summary hides the password") - func summaryMasksThePassword() throws { - let candidate = try #require( - ClipboardConnectionCandidate(clipboardText: "postgres://alice:secret@db.example.com/sales") - ) - #expect(ClipboardConnectionBanner.summary(for: candidate) == "postgres://alice:***@db.example.com:5432/sales") - } - - @Test( - "The banner summary leaves out a port the engine does not use", - arguments: [ - "libsql://my-db-org.turso.io", - "d1://0123456789abcdef/analytics" - ] - ) - func summaryOmitsAnUnusedPort(_ url: String) throws { - let candidate = try #require(ClipboardConnectionCandidate(clipboardText: url)) - #expect(ClipboardConnectionBanner.summary(for: candidate) == url) - } - - @Test( - "A socket URL opens the form on Socket with its path, from the clipboard or Import from URL", - arguments: [ - "mysql://root@localhost/local?socket=%2Ftmp%2Fmysql.sock", - "mariadb://root@/local?unix_socket=/tmp/mysql.sock" - ] - ) - func socketURLSelectsSocket(_ url: String) throws { - for coordinator in [try formApplyingClipboard(url), try formImporting(url)] { - #expect(coordinator.network.endpoint == .localSocket) - #expect(coordinator.network.localSocketPath == "/tmp/mysql.sock") - #expect(coordinator.transport == nil) - #expect(coordinator.advanced.additionalFieldValues[MySQLLocalSocket.fieldKey] == nil) - - let edits = coordinator.buildEdits() - #expect(edits.additionalFields[MySQLLocalSocket.fieldKey] == "/tmp/mysql.sock") - #expect(edits.applied(to: DatabaseConnection(name: "")).localSocketPath == "/tmp/mysql.sock") - } - } - - @Test("A socket URL pasted over an SSH form drops the tunnel") - func socketURLClearsTheTransport() throws { - let candidate = try #require( - ClipboardConnectionCandidate(clipboardText: "mysql://root@localhost/local?socket=/tmp/mysql.sock") - ) - let coordinator = ConnectionFormCoordinator(connectionId: nil) - coordinator.start() - coordinator.transport = .ssh - coordinator.ssh.state.host = "bastion.example.com" - - coordinator.applyClipboardCandidate(candidate) - - #expect(coordinator.network.endpoint == .localSocket) - #expect(coordinator.transport == nil) - #expect(coordinator.availableTransports == [nil]) - } - - @Test("A TCP URL pasted after a socket one goes back to Host and Port") - func tcpURLLeavesSocket() throws { - let socket = try #require( - ClipboardConnectionCandidate(clipboardText: "mysql://root@localhost/local?socket=/tmp/mysql.sock") - ) - let tcp = try #require(ClipboardConnectionCandidate(clipboardText: "mysql://root@db.example.com/shop")) - let coordinator = ConnectionFormCoordinator(connectionId: nil) - coordinator.start() - - coordinator.applyClipboardCandidate(socket) - coordinator.applyClipboardCandidate(tcp) - - #expect(coordinator.network.endpoint == .hostAndPort) - #expect(coordinator.network.host == "db.example.com") - #expect(coordinator.buildEdits().additionalFields[MySQLLocalSocket.fieldKey] == nil) - } - - @Test("The banner summary names the socket and no port") - func summaryShowsTheSocket() throws { - let candidate = try #require( - ClipboardConnectionCandidate(clipboardText: "mysql://root:pw@localhost/db?socket=/tmp/my.sock") - ) - #expect(ClipboardConnectionBanner.summary(for: candidate) == "mysql://root:***@localhost/db?socket=/tmp/my.sock") - } -} diff --git a/TableProTests/ViewModels/ConnectionFormTransportTests.swift b/TableProTests/ViewModels/ConnectionFormTransportTests.swift index 09c184bcb7..e6fb959dd6 100644 --- a/TableProTests/ViewModels/ConnectionFormTransportTests.swift +++ b/TableProTests/ViewModels/ConnectionFormTransportTests.swift @@ -342,22 +342,6 @@ struct ConnectionFormTransportTests { #expect(coordinator.buildEdits().additionalFields[MySQLLocalSocket.fieldKey] == nil) } - @Test("a URL pasted onto a Socket form returns it to Host and Port with the URL's SSH server") - func urlImportLeavesSocketMode() throws { - let candidate = try #require(ClipboardConnectionCandidate( - clipboardText: "mysql+ssh://deploy@bastion.example.com:22/dbuser@db.internal:3306/app" - )) - let coordinator = coordinator() - coordinator.start() - coordinator.selectEndpoint(.localSocket) - - coordinator.applyClipboardCandidate(candidate) - - #expect(coordinator.network.endpoint == .hostAndPort) - #expect(coordinator.transport == .ssh) - #expect(coordinator.ssh.state.host == "bastion.example.com") - } - @Test("a socket connection saves its path and no transport") func socketEditsCarryThePath() { let coordinator = coordinator() diff --git a/TableProTests/ViewModels/ConnectionFormURLImportTests.swift b/TableProTests/ViewModels/ConnectionFormURLImportTests.swift new file mode 100644 index 0000000000..6059654d86 --- /dev/null +++ b/TableProTests/ViewModels/ConnectionFormURLImportTests.swift @@ -0,0 +1,86 @@ +// +// ConnectionFormURLImportTests.swift +// TableProTests +// + +import Foundation +import TableProPluginKit +import Testing + +@testable import TablePro + +@MainActor +struct ConnectionFormURLImportTests { + private func formImporting(_ url: String) throws -> ConnectionFormCoordinator { + guard case .success(let parsed) = ConnectionURLParser.parse(url) else { + throw ConnectionURLParseError.invalidURL + } + let coordinator = ConnectionFormCoordinator(connectionId: nil, initialParsedURL: parsed) + coordinator.start() + return coordinator + } + + @Test("sslmode=verify-full opens the form on Verify Identity") + func verifyFullKeepsIdentityVerification() throws { + let coordinator = try formImporting("postgresql://u@db.example.com/app?sslmode=verify-full") + #expect(coordinator.ssl.mode == .verifyIdentity) + } + + @Test("sslmode=verify-ca opens the form on Verify CA") + func verifyCaKeepsCertificateVerification() throws { + let coordinator = try formImporting("postgresql://u@db.example.com/app?sslmode=verify-ca") + #expect(coordinator.ssl.mode == .verifyCa) + } + + @Test("sslmode=disable opens the form on Disabled") + func disableTurnsTLSOff() throws { + let coordinator = try formImporting("postgresql://u@db.example.com/app?sslmode=disable") + #expect(coordinator.ssl.mode == .disabled) + } + + @Test( + "ssl=1 and ssl=require turn TLS on as ssl=true does", + arguments: [ + "postgresql://u@db.example.com/app", + "mysql://root@db.example.com/app", + "redis://cache.example.com" + ], + ["1", "require"] + ) + func sslFlagSpellingsTurnTLSOn(_ base: String, _ value: String) throws { + let spelled = try formImporting("\(base)?ssl=\(value)") + let canonical = try formImporting("\(base)?ssl=true") + #expect(spelled.ssl.mode == canonical.ssl.mode) + #expect(spelled.ssl.mode == .required) + } + + @Test("Host, credentials and database from the URL reach the form") + func connectionFieldsReachTheForm() throws { + let coordinator = try formImporting("postgres://alice:p%40ss@db.example.com:6000/sales") + #expect(coordinator.network.type == .postgresql) + #expect(coordinator.network.host == "db.example.com") + #expect(coordinator.network.port == "6000") + #expect(coordinator.network.database == "sales") + #expect(coordinator.auth.username == "alice") + #expect(coordinator.auth.password == "p@ss") + } + + @Test( + "A socket URL opens the form on Socket with its path", + arguments: [ + "mysql://root@localhost/local?socket=%2Ftmp%2Fmysql.sock", + "mariadb://root@/local?unix_socket=/tmp/mysql.sock" + ] + ) + func socketURLSelectsSocket(_ url: String) throws { + let coordinator = try formImporting(url) + #expect(coordinator.network.endpoint == .localSocket) + #expect(coordinator.network.localSocketPath == "/tmp/mysql.sock") + #expect(coordinator.transport == nil) + #expect(coordinator.advanced.additionalFieldValues[MySQLLocalSocket.fieldKey] == nil) + + let edits = coordinator.buildEdits() + #expect(edits.additionalFields[MySQLLocalSocket.fieldKey] == "/tmp/mysql.sock") + #expect(edits.applied(to: DatabaseConnection(name: "")).localSocketPath == "/tmp/mysql.sock") + } +}