diff --git a/CHANGELOG.md b/CHANGELOG.md index b7b6f2824..553ae5ded 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Oracle connections no longer crash the app when the server sends a backend message the driver cannot decode; the query fails with a clear error and the connection reconnects. (#483) - MongoDB TLS handshake failures now report the actual cause instead of always blaming a cipher or protocol mismatch. (#1418) +- The External Clients access level no longer reverts to Read Only after saving and reopening a connection, so MCP clients keep the write access you granted. (#1730) - Typing fast with the autocomplete window open no longer stalls each keystroke; the live refilter is debounced, cancelable, and moved off the main thread. ## [0.52.0] - 2026-06-19 diff --git a/TablePro/Core/Storage/StoredConnection.swift b/TablePro/Core/Storage/StoredConnection.swift index d5d3e7918..da96497ad 100644 --- a/TablePro/Core/Storage/StoredConnection.swift +++ b/TablePro/Core/Storage/StoredConnection.swift @@ -35,6 +35,8 @@ struct StoredConnection: Codable { let safeModeLevel: String + let externalAccess: String + let aiPolicy: String? // AI rules text included in the system prompt for this connection @@ -115,6 +117,8 @@ struct StoredConnection: Codable { self.safeModeLevel = connection.safeModeLevel.rawValue + self.externalAccess = connection.externalAccess.rawValue + self.aiPolicy = connection.aiPolicy?.rawValue self.aiRules = connection.aiRules self.aiAlwaysAllowedTools = connection.aiAlwaysAllowedTools.isEmpty @@ -163,6 +167,7 @@ struct StoredConnection: Codable { case sslMode, sslCaCertificatePath, sslClientCertificatePath, sslClientKeyPath case color, tagId, groupId, sshProfileId case safeModeLevel + case externalAccess case isReadOnly // Legacy key for migration reading only case aiPolicy case aiRules @@ -207,6 +212,7 @@ struct StoredConnection: Codable { try container.encodeIfPresent(groupId, forKey: .groupId) try container.encodeIfPresent(sshProfileId, forKey: .sshProfileId) try container.encode(safeModeLevel, forKey: .safeModeLevel) + try container.encode(externalAccess, forKey: .externalAccess) try container.encodeIfPresent(aiPolicy, forKey: .aiPolicy) try container.encodeIfPresent(aiRules, forKey: .aiRules) try container.encodeIfPresent(aiAlwaysAllowedTools, forKey: .aiAlwaysAllowedTools) @@ -272,6 +278,9 @@ struct StoredConnection: Codable { let wasReadOnly = try container.decodeIfPresent(Bool.self, forKey: .isReadOnly) ?? false safeModeLevel = wasReadOnly ? SafeModeLevel.readOnly.rawValue : SafeModeLevel.silent.rawValue } + externalAccess = try container.decodeIfPresent( + String.self, forKey: .externalAccess + ) ?? ExternalAccessLevel.readOnly.rawValue aiPolicy = try container.decodeIfPresent(String.self, forKey: .aiPolicy) aiRules = try container.decodeIfPresent(String.self, forKey: .aiRules) aiAlwaysAllowedTools = try container.decodeIfPresent([String].self, forKey: .aiAlwaysAllowedTools) @@ -388,6 +397,7 @@ struct StoredConnection: Codable { aiPolicy: parsedAIPolicy, aiRules: aiRules, aiAlwaysAllowedTools: Set(aiAlwaysAllowedTools ?? []), + externalAccess: ExternalAccessLevel(rawValue: externalAccess) ?? .readOnly, redisDatabase: redisDatabase, startupCommands: startupCommands, sortOrder: sortOrder, diff --git a/TableProTests/Core/Storage/ConnectionStorageExternalAccessTests.swift b/TableProTests/Core/Storage/ConnectionStorageExternalAccessTests.swift new file mode 100644 index 000000000..74f7608fb --- /dev/null +++ b/TableProTests/Core/Storage/ConnectionStorageExternalAccessTests.swift @@ -0,0 +1,118 @@ +// +// ConnectionStorageExternalAccessTests.swift +// TableProTests +// + +import Foundation +import TableProPluginKit +@testable import TablePro +import Testing + +@Suite("ConnectionStorage External Access") +@MainActor +struct ConnectionStorageExternalAccessTests { + private let storage: ConnectionStorage + + init() { + let unique = UUID().uuidString + let fileURL = FileManager.default.temporaryDirectory + .appendingPathComponent("tablepro-tests") + .appendingPathComponent("connections_\(unique).json") + try? FileManager.default.createDirectory( + at: fileURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + let defaultsName = "com.TablePro.tests.ConnectionStorage.ExternalAccess.\(unique)" + let syncName = "com.TablePro.tests.Sync.ExternalAccess.\(unique)" + guard let defaults = UserDefaults(suiteName: defaultsName), + let syncDefaults = UserDefaults(suiteName: syncName) else { + fatalError("UserDefaults suite creation failed in test setup") + } + let metadata = SyncMetadataStorage(userDefaults: syncDefaults) + let tracker = SyncChangeTracker(metadataStorage: metadata) + self.storage = ConnectionStorage( + fileURL: fileURL, + userDefaults: defaults, + syncTracker: tracker + ) + } + + @Test("round-trip preserves external access", arguments: ExternalAccessLevel.allCases) + func roundTripExternalAccess(_ level: ExternalAccessLevel) { + let id = UUID() + let connection = DatabaseConnection( + id: id, + name: "Test", + type: .postgresql, + externalAccess: level + ) + + storage.addConnection(connection) + defer { storage.deleteConnection(connection) } + + let loaded = storage.loadConnections().first { $0.id == id } + #expect(loaded?.externalAccess == level) + } + + @Test("external access survives mutate-and-update cycle") + func updateExternalAccess() { + let id = UUID() + let connection = DatabaseConnection( + id: id, + name: "Test", + type: .mysql, + externalAccess: .readOnly + ) + + storage.addConnection(connection) + defer { storage.deleteConnection(connection) } + + var updated = connection + updated.externalAccess = .readWrite + storage.updateConnection(updated) + + let loaded = storage.loadConnections().first { $0.id == id } + #expect(loaded?.externalAccess == .readWrite) + } + + @Test("legacy records without externalAccess default to readOnly") + func legacyDecodeDefaultsToReadOnly() throws { + let stored = try JSONDecoder().decode( + StoredConnection.self, + from: Data(Self.legacyJSONWithoutExternalAccess.utf8) + ) + #expect(stored.toConnection().externalAccess == .readOnly) + } + + private static let legacyJSONWithoutExternalAccess = """ + { + "id": "11111111-2222-3333-4444-555555555555", + "name": "Legacy", + "host": "localhost", + "port": 3306, + "database": "test", + "username": "root", + "type": "MySQL", + "sshEnabled": false, + "sshHost": "", + "sshUsername": "", + "sshAuthMethod": "password", + "sshPrivateKeyPath": "", + "sshAgentSocketPath": "", + "sslMode": "disabled", + "sslCaCertificatePath": "", + "sslClientCertificatePath": "", + "sslClientKeyPath": "", + "color": "None", + "safeModeLevel": "silent", + "sortOrder": 0, + "localOnly": false, + "isSample": false, + "isFavorite": false, + "totpMode": "none", + "totpAlgorithm": "sha1", + "totpDigits": 6, + "totpPeriod": 30 + } + """ +} diff --git a/TableProTests/Views/ConnectionForm/ViewModels/AdvancedPaneViewModelTests.swift b/TableProTests/Views/ConnectionForm/ViewModels/AdvancedPaneViewModelTests.swift new file mode 100644 index 000000000..51479df67 --- /dev/null +++ b/TableProTests/Views/ConnectionForm/ViewModels/AdvancedPaneViewModelTests.swift @@ -0,0 +1,34 @@ +// +// AdvancedPaneViewModelTests.swift +// TableProTests +// + +import Foundation +import TableProPluginKit +@testable import TablePro +import Testing + +@Suite("Advanced pane external access") +@MainActor +struct AdvancedPaneViewModelTests { + @Test("Loads external access from the connection") + func loadsExternalAccessFromConnection() { + let connection = DatabaseConnection(name: "Test", externalAccess: .readWrite) + let viewModel = AdvancedPaneViewModel() + + viewModel.load(from: connection) + + #expect(viewModel.externalAccess == .readWrite) + } + + @Test("Does not leak external access into plugin additional fields") + func doesNotWriteExternalAccessIntoAdditionalFields() { + let viewModel = AdvancedPaneViewModel() + viewModel.externalAccess = .readWrite + + var fields: [String: String] = [:] + viewModel.write(into: &fields) + + #expect(fields["externalAccess"] == nil) + } +}