From 0cf792cc640bc4c1e9834588ad723074e8df70f8 Mon Sep 17 00:00:00 2001 From: Gregor Longariva <gregor.longariva@fau.de> Date: Mon, 24 Mar 2025 16:33:31 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=8E=A8=20a=20few=20improvements=20to=20mo?= =?UTF-8?q?unter.swift=20and=20tst=20szenarios=20for=20mounter=20class?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Network Share Mounter/model/Mounter.swift | 69 +++- .../ManagerTests/MounterTests.swift | 344 ++++++++++++++++++ 2 files changed, 400 insertions(+), 13 deletions(-) create mode 100644 Network Share MounterTests/ManagerTests/MounterTests.swift diff --git a/Network Share Mounter/model/Mounter.swift b/Network Share Mounter/model/Mounter.swift index d8dd8c3..1814ba8 100644 --- a/Network Share Mounter/model/Mounter.swift +++ b/Network Share Mounter/model/Mounter.swift @@ -25,10 +25,29 @@ class Mounter: ObservableObject { // static let mounter = Mounter.init() /// define locks to protect `shares`-array from race conditions private let lock = NSLock() + private let taskLock = NSLock() + + /// Set to store active mount tasks + private var mountTasks = Set<Task<Void, Never>>() + /// get home direcotry for the user running the app let userHomeDirectory: String = FileManager.default.homeDirectoryForCurrentUser.path - var errorStatus: MounterError = .noError + private let errorStatusLock = NSLock() + private var _errorStatus: MounterError = .noError + + var errorStatus: MounterError { + get { + errorStatusLock.lock() + defer { errorStatusLock.unlock() } + return _errorStatus + } + set { + errorStatusLock.lock() + _errorStatus = newValue + errorStatusLock.unlock() + } + } private var localizedFolder = Defaults.translation["en"]! var defaultMountPath: String = Defaults.defaultMountPath @@ -133,6 +152,9 @@ class Mounter: ObservableObject { /// - Parameter mountStatus: new MountStatus /// - Parameter for: share to be updated func updateShare(mountStatus: MountStatus, for share: Share) async { + lock.lock() + defer { lock.unlock() } + if let index = await shareManager.allShares.firstIndex(where: { $0.networkShare == share.networkShare }) { do { try await shareManager.updateMountStatus(at: index, to: mountStatus) @@ -151,6 +173,9 @@ class Mounter: ObservableObject { /// - Parameter actualMountPoint: an optional `String` definig where the share is mounted (or not, if not defined) /// - Parameter for: share to be updated func updateShare(actualMountPoint: String?, for share: Share) async { + lock.lock() + defer { lock.unlock() } + if let index = await shareManager.allShares.firstIndex(where: { $0.networkShare == share.networkShare }) { do { try await shareManager.updateActualMountPoint(at: index, to: actualMountPoint) @@ -191,6 +216,15 @@ class Mounter: ObservableObject { task.launch() } + /// Safely escapes a path for use in shell commands + /// - Parameter path: The path to escape + /// - Returns: A properly escaped path string safe for use in shell commands + private func escapePath(_ path: String) -> String { + // Use single quotes which handle most special characters + // But escape single quotes within the path by replacing ' with '\'' + return "'\(path.replacingOccurrences(of: "'", with: "'\\''"))'" + } + /// function to delete a directory via system shell `rmdir` /// - Paramater atPath: full path of the directory func removeDirectory(atPath: String) { @@ -200,9 +234,8 @@ class Mounter: ObservableObject { } else { let task = Process() task.launchPath = "/bin/rmdir" - // Escape spaces in the path for shell command - let escapedPath = atPath.replacingOccurrences(of: " ", with: "\\ ") - task.arguments = ["\(escapedPath)"] + // Use the safe path escaping function + task.arguments = [atPath] // Process handles argument escaping let pipe = Pipe() task.standardOutput = pipe // @@ -443,6 +476,13 @@ class Mounter: ObservableObject { /// - shareID: Optional ID of specific share to mount. If nil, mounts all configured shares /// - Note: Requires active network connection to perform mount operations func mountGivenShares(userTriggered: Bool = false, forShare shareID: String? = nil) async { + // Safely manage task collection + taskLock.lock() + // Cancel any existing mount tasks + mountTasks.forEach { $0.cancel() } + mountTasks.removeAll() + taskLock.unlock() + // Verify network connectivity before attempting mount operations let netConnection = Monitor.shared @@ -477,7 +517,7 @@ class Mounter: ObservableObject { } // Create concurrent mount tasks for each share - var mountTasks: [Task<Void, Never>] = [] + var localMountTasks: [Task<Void, Never>] = [] for share in sharesToMount { let mountTask = Task { do { @@ -518,12 +558,17 @@ class Mounter: ObservableObject { } } } - mountTasks.append(mountTask) + localMountTasks.append(mountTask) } + // Safely store the tasks for lifecycle management + taskLock.lock() + mountTasks = Set(localMountTasks) + taskLock.unlock() + // Wait for all mount tasks to complete await withTaskGroup(of: Void.self) { group in - for task in mountTasks { + for task in localMountTasks { group.addTask { await task.value } @@ -641,9 +686,8 @@ class Mounter: ObservableObject { // (or if failed, the directory will be removed later) // apparently there is no way t oset the `hidden` attribute via FileManager `setAttributes` // https://developer.apple.com/documentation/foundation/filemanager/1413667-setattributes - // Escape path for shell command - let escapedPath = mountDirectory.replacingOccurrences(of: " ", with: "\\ ") - try? await cliTask("/usr/bin/chflags hidden \(escapedPath)") + // Use the safe path escaping function + try? await cliTask("/usr/bin/chflags hidden \(escapePath(mountDirectory))") } if share.authType == .guest { openOptions = Defaults.openOptionsGuest @@ -661,9 +705,8 @@ class Mounter: ObservableObject { case 0: Logger.mounter.info("✅ \(url, privacy: .public): successfully mounted on \(mountDirectory, privacy: .public)") // unhide the directory for the fresh mounted share - // Escape path for shell command - let escapedPath = mountDirectory.replacingOccurrences(of: " ", with: "\\ ") - try? await cliTask("/usr/bin/chflags nohidden \(escapedPath)") + // Use the safe path escaping function + try? await cliTask("/usr/bin/chflags nohidden \(escapePath(mountDirectory))") return mountDirectory case 2: Logger.mounter.info("❌ \(url, privacy: .public): does not exist") diff --git a/Network Share MounterTests/ManagerTests/MounterTests.swift b/Network Share MounterTests/ManagerTests/MounterTests.swift new file mode 100644 index 0000000..61f7a26 --- /dev/null +++ b/Network Share MounterTests/ManagerTests/MounterTests.swift @@ -0,0 +1,344 @@ +import XCTest +@testable import Network_Share_Mounter + +/** + Tests für die Mounter-Klasse + + Diese Tests verifizieren die Kernfunktionalität der Mounter-Klasse, die für das + Mounten und Unmounten von Netzwerk-Shares verantwortlich ist. + + Hinweise: + - Dies sind hauptsächlich Integrationstests, die mit der tatsächlichen Mounter-Klasse arbeiten + - Alle Testmethoden sind als async markiert, da Mounter mit async/await arbeitet + - Wir fokussieren uns auf testbare Funktionalitäten ohne die Originalklasse zu ändern + */ +final class MounterTests: XCTestCase { + + // MARK: - Properties + + /// Das zu testende System + var sut: Mounter! + + // Testkonstanten + let testShare1URL = "smb://testserver.example.com/testshare1" + let testShare2URL = "smb://testserver.example.com/testshare2" + let testUsername = "testuser" + let testPassword = "testpassword" + let testMountPoint = "/Users/testuser/Network" + + // MARK: - Setup & Teardown + + override func setUpWithError() throws { + // Mounter initialisieren + sut = Mounter() + + // FakeURLProtocol für Netzwerk-Mocking (falls benötigt) + URLProtocol.registerClass(FakeURLProtocol.self) + } + + override func tearDownWithError() throws { + // Aufräumen + sut = nil + URLProtocol.unregisterClass(FakeURLProtocol.self) + } + + // MARK: - Hilfsmethoden + + /// Erstellt einen Test-Share zum Testen + private func createTestShare( + networkShare: String = "smb://testserver.example.com/testshare", + authType: AuthType = .krb, + username: String? = "testuser", + password: String? = nil, + mountStatus: MountStatus = .unmounted, + mountPoint: String? = nil, + managed: Bool = false + ) -> Share { + return Share.createShare( + networkShare: networkShare, + authType: authType, + mountStatus: mountStatus, + username: username, + password: password, + mountPoint: mountPoint, + managed: managed + ) + } + + /// Fügt dem ShareManager einen Testshare hinzu + private func addTestShareToManager() async -> Share { + let testShare = createTestShare() + await sut.shareManager.addShare(testShare) + return testShare + } + + // MARK: - Tests: Grundfunktionen + + /// Test: Initialisierung des Mounter + func testInit() async throws { + // Given/When: Der Mounter wurde im Setup erstellt + + // Then: Der Mounter sollte existieren und ShareManager enthalten + XCTAssertNotNil(sut, "Mounter sollte initialisiert werden") + XCTAssertNotNil(sut.shareManager, "Mounter sollte einen ShareManager haben") + XCTAssertEqual(sut.defaultMountPath, Defaults.defaultMountPath, "Standardpfad sollte gesetzt sein") + } + + /// Test: AsyncInit-Methode + func testAsyncInit() async throws { + // Given + let prefs = PreferenceManager() + let useLocalized = false + let useNewLocation = true + + // Setzen der Präferenzen im UserDefaults + let defaults = UserDefaults.standard + defaults.set(useLocalized, forKey: PreferenceKeys.useLocalizedMountDirectories.rawValue) + defaults.set(useNewLocation, forKey: PreferenceKeys.useNewDefaultLocation.rawValue) + + // When + await sut.asyncInit() + + // Then + // Standardpfad sollte bei useNewDefaultLocation = true auf /Volumes gesetzt sein + XCTAssertEqual(sut.defaultMountPath, Defaults.defaultMountPath, "defaultMountPath sollte auf Defaults.defaultMountPath gesetzt sein") + + // Aufräumen + defaults.removeObject(forKey: PreferenceKeys.useLocalizedMountDirectories.rawValue) + defaults.removeObject(forKey: PreferenceKeys.useNewDefaultLocation.rawValue) + } + + /// Test: AsyncInit mit benutzerdefinierten lokalen Ordnern + func testAsyncInitWithLocalizedFolders() async throws { + // Given + let useLocalized = true + let useNewLocation = false + + // Setzen der Präferenzen im UserDefaults + let defaults = UserDefaults.standard + defaults.set(useLocalized, forKey: PreferenceKeys.useLocalizedMountDirectories.rawValue) + defaults.set(useNewLocation, forKey: PreferenceKeys.useNewDefaultLocation.rawValue) + + // Speichere den ursprünglichen Sprachcode, um ihn später wiederherzustellen + let originalLanguageCode = Locale.current.languageCode + + // When + await sut.asyncInit() + + // Then + if useNewLocation { + XCTAssertEqual(sut.defaultMountPath, Defaults.defaultMountPath, "defaultMountPath sollte auf Defaults.defaultMountPath gesetzt sein") + } else { + let expectedLocalizedFolder = Defaults.translation[Locale.current.languageCode ?? "en"] ?? Defaults.translation["en"]! + let expectedPath = NSString(string: "~/\(expectedLocalizedFolder)").expandingTildeInPath + XCTAssertEqual(sut.defaultMountPath, expectedPath, "defaultMountPath sollte auf den lokalisierten Pfad gesetzt sein") + } + + // Aufräumen + defaults.removeObject(forKey: PreferenceKeys.useLocalizedMountDirectories.rawValue) + defaults.removeObject(forKey: PreferenceKeys.useNewDefaultLocation.rawValue) + } + + /// Test: Hinzufügen eines Shares + func testAddShare() async throws { + // Given + let testShare = createTestShare() + + // When + await sut.addShare(testShare) + + // Then + let shares = await sut.shareManager.allShares + XCTAssertEqual(shares.count, 1, "Ein Share sollte hinzugefügt worden sein") + XCTAssertEqual(shares[0].networkShare, testShare.networkShare, "Der hinzugefügte Share sollte im ShareManager sein") + } + + /// Test: Entfernen eines Shares + func testRemoveShare() async throws { + // Given + let testShare = await addTestShareToManager() + let shares = await sut.shareManager.allShares + XCTAssertEqual(shares.count, 1, "Setup: Ein Share sollte hinzugefügt worden sein") + + // When + await sut.removeShare(for: testShare) + + // Then + let updatedShares = await sut.shareManager.allShares + XCTAssertEqual(updatedShares.count, 0, "Der Share sollte entfernt worden sein") + } + + /// Test: Aktualisieren des Mount-Status eines Shares + func testUpdateShareMountStatus() async throws { + // Given + let testShare = await addTestShareToManager() + + // When + await sut.updateShare(mountStatus: .mounted, for: testShare) + + // Then + let shares = await sut.shareManager.allShares + if let index = shares.firstIndex(where: { $0.id == testShare.id }) { + XCTAssertEqual(shares[index].mountStatus, .mounted, "Der Mount-Status sollte auf 'mounted' gesetzt sein") + } else { + XCTFail("Der Share sollte im ShareManager sein") + } + } + + /// Test: Aktualisieren des Mount-Punkts eines Shares + func testUpdateShareMountPoint() async throws { + // Given + let testShare = await addTestShareToManager() + let newMountPoint = "/Volumes/TestShare" + + // When + await sut.updateShare(actualMountPoint: newMountPoint, for: testShare) + + // Then + let shares = await sut.shareManager.allShares + if let index = shares.firstIndex(where: { $0.id == testShare.id }) { + XCTAssertEqual(shares[index].actualMountPoint, newMountPoint, "Der Mount-Punkt sollte aktualisiert werden") + } else { + XCTFail("Der Share sollte im ShareManager sein") + } + } + + /// Test: Erstellen eines Verzeichnisses für Shares + func testCreateMountFolder() throws { + // Given + let tempDirectoryURL = FileManager.default.temporaryDirectory + let testDirURL = tempDirectoryURL.appendingPathComponent(UUID().uuidString) + let testDirPath = testDirURL.path + + // Sicherstellen, dass das Verzeichnis nicht existiert + try? FileManager.default.removeItem(at: testDirURL) + + // When + sut.createMountFolder(atPath: testDirPath) + + // Then + XCTAssertTrue(FileManager.default.fileExists(atPath: testDirPath), "Verzeichnis sollte erstellt worden sein") + + // Aufräumen + try? FileManager.default.removeItem(at: testDirURL) + } + + /// Test: Abrufen eines Shares anhand der Netzwerk-URL + func testGetShareForNetworkShare() async throws { + // Given + let testShare = await addTestShareToManager() + + // When + let foundShare = await sut.getShare(forNetworkShare: testShare.networkShare) + + // Then + XCTAssertNotNil(foundShare, "Ein Share sollte gefunden werden") + XCTAssertEqual(foundShare?.networkShare, testShare.networkShare, "Der gefundene Share sollte korrekt sein") + } + + /// Test: Setzen des Mount-Status für alle Shares + func testSetAllMountStatus() async throws { + // Given + let testShare1 = createTestShare(networkShare: testShare1URL) + let testShare2 = createTestShare(networkShare: testShare2URL) + await sut.shareManager.addShare(testShare1) + await sut.shareManager.addShare(testShare2) + + // When + await sut.setAllMountStatus(to: .mounted) + + // Then + let shares = await sut.shareManager.allShares + for share in shares { + XCTAssertEqual(share.mountStatus, .mounted, "Alle Shares sollten den Status 'mounted' haben") + } + } + + /// Test: Aktualisieren eines Shares + func testUpdateShare() async throws { + // Given + let originalShare = createTestShare(networkShare: testShare1URL) + await sut.shareManager.addShare(originalShare) + + let updatedShare = createTestShare( + networkShare: testShare1URL, + authType: .pwd, + username: "newuser", + password: "newpassword" + ) + + // When + await sut.updateShare(for: updatedShare) + + // Then + let shares = await sut.shareManager.allShares + XCTAssertEqual(shares[0].authType, .pwd, "Die Authentifizierungsart sollte aktualisiert sein") + XCTAssertEqual(shares[0].username, "newuser", "Der Benutzername sollte aktualisiert sein") + } + + /// Test: Path-Escaping-Funktion (indirekt) + func testEscapePath() async throws { + // Da die Methode private ist, testen wir sie indirekt durch eine andere Methode + // Wir erstellen temporär ein Verzeichnis, das Sonderzeichen enthält + + // Given + let tempDirectoryURL = FileManager.default.temporaryDirectory + let specialDirName = "test'dir with spaces" + let testDirURL = tempDirectoryURL.appendingPathComponent(specialDirName) + let testDirPath = testDirURL.path + + // Sicherstellen, dass das Verzeichnis existiert + try? FileManager.default.createDirectory(at: testDirURL, withIntermediateDirectories: true, attributes: nil) + + // Zum Testen: Wir können nicht direkt testen, aber wir können überprüfen, ob ein Verzeichnis + // mit Sonderzeichen ohne Fehler entfernt werden kann + XCTAssertNoThrow(sut.removeDirectory(atPath: testDirPath), "Das Verzeichnis mit Sonderzeichen sollte ohne Fehler entfernt werden können") + + // Aufräumen - für den Fall, dass etwas schiefgeht + try? FileManager.default.removeItem(at: testDirURL) + } + + /// Test: Entfernen eines Verzeichnisses in /Volumes + func testRemoveDirectoryInVolumes() async throws { + // Given + let testDirPath = "/Volumes/TestDir" // Diese sollte nicht entfernt werden + + // When/Then - die rmdir-Operation sollte keine Auswirkungen haben + // Da wir nicht tatsächlich ein Verzeichnis in /Volumes erstellen können, testen wir nur, + // dass die Funktion keine Exception wirft + XCTAssertNoThrow(sut.removeDirectory(atPath: testDirPath)) + } +} + +// MARK: - Hilfsklassen + +/// Fake URLProtocol für Netzwerktests +class FakeURLProtocol: URLProtocol { + static var requestHandler: ((URLRequest) throws -> (HTTPURLResponse, Data))? + + override class func canInit(with request: URLRequest) -> Bool { + return true + } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { + return request + } + + override func startLoading() { + guard let handler = FakeURLProtocol.requestHandler else { + client?.urlProtocolDidFinishLoading(self) + return + } + + do { + let (response, data) = try handler(request) + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: data) + client?.urlProtocolDidFinishLoading(self) + } catch { + client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() {} +} \ No newline at end of file -- GitLab