diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj index 17c25c9e920f..683962d1d36e 100644 --- a/ios/MullvadVPN.xcodeproj/project.pbxproj +++ b/ios/MullvadVPN.xcodeproj/project.pbxproj @@ -38,6 +38,9 @@ 06799AFC28F98EE300ACD94E /* AddressCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06AC114128F8413A0037AF9A /* AddressCache.swift */; }; 0697D6E728F01513007A9E99 /* TransportMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0697D6E628F01513007A9E99 /* TransportMonitor.swift */; }; 06AC116228F94C450037AF9A /* ApplicationConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58BFA5CB22A7CE1F00A6173D /* ApplicationConfiguration.swift */; }; + 44DD7D242B6CFFD70005F67F /* StartTunnelOperationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44DD7D232B6CFFD70005F67F /* StartTunnelOperationTests.swift */; }; + 44DD7D272B6D18FB0005F67F /* MockTunnelInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44DD7D262B6D18FB0005F67F /* MockTunnelInteractor.swift */; }; + 44DD7D292B7113CA0005F67F /* MockTunnel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44DD7D282B7113CA0005F67F /* MockTunnel.swift */; }; 5803B4B02940A47300C23744 /* TunnelConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5803B4AF2940A47300C23744 /* TunnelConfiguration.swift */; }; 5803B4B22940A48700C23744 /* TunnelStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5803B4B12940A48700C23744 /* TunnelStore.swift */; }; 5807E2C02432038B00F5FF30 /* String+Split.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5807E2BF2432038B00F5FF30 /* String+Split.swift */; }; @@ -1238,6 +1241,9 @@ 06FAE67A28F83CA50033DD93 /* RESTDevicesProxy.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RESTDevicesProxy.swift; sourceTree = ""; }; 06FAE67B28F83CA50033DD93 /* REST.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = REST.swift; sourceTree = ""; }; 06FAE67D28F83CA50033DD93 /* RESTTransport.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RESTTransport.swift; sourceTree = ""; }; + 44DD7D232B6CFFD70005F67F /* StartTunnelOperationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartTunnelOperationTests.swift; sourceTree = ""; }; + 44DD7D262B6D18FB0005F67F /* MockTunnelInteractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockTunnelInteractor.swift; sourceTree = ""; }; + 44DD7D282B7113CA0005F67F /* MockTunnel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockTunnel.swift; sourceTree = ""; }; 5802EBC42A8E44AC00E5CE4C /* AppRoutes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppRoutes.swift; sourceTree = ""; }; 5802EBC62A8E457A00E5CE4C /* AppRouteProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppRouteProtocol.swift; sourceTree = ""; }; 5802EBC82A8E45BA00E5CE4C /* ApplicationRouterDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationRouterDelegate.swift; sourceTree = ""; }; @@ -2094,6 +2100,15 @@ path = MullvadREST; sourceTree = ""; }; + 44DD7D252B6D18E90005F67F /* Mocks */ = { + isa = PBXGroup; + children = ( + 44DD7D282B7113CA0005F67F /* MockTunnel.swift */, + 44DD7D262B6D18FB0005F67F /* MockTunnelInteractor.swift */, + ); + path = Mocks; + sourceTree = ""; + }; 5802EBC32A8E447000E5CE4C /* Router */ = { isa = PBXGroup; children = ( @@ -2737,6 +2752,7 @@ 58B0A2A1238EE67E00BC001D /* MullvadVPNTests */ = { isa = PBXGroup; children = ( + 44DD7D252B6D18E90005F67F /* Mocks */, A900E9BF2ACC661900C95F67 /* AccessTokenManager+Stubs.swift */, 7A6F2FA42AFA3CB2006D0856 /* AccountExpiryTests.swift */, A900E9B72ACC5C2B00C95F67 /* AccountsProxy+Stubs.swift */, @@ -2767,6 +2783,7 @@ 584B26F3237434D00073B10E /* RelaySelectorTests.swift */, A900E9B92ACC5D0600C95F67 /* RESTRequestExecutor+Stubs.swift */, A9C342C42ACC42130045F00E /* ServerRelaysResponse+Stubs.swift */, + 44DD7D232B6CFFD70005F67F /* StartTunnelOperationTests.swift */, 5807E2C1243203D000F5FF30 /* StringTests.swift */, A9A5F9A12ACB003D0083449F /* TunnelManagerTests.swift */, A9E0317B2ACBFC7E0095D843 /* TunnelStore+Stubs.swift */, @@ -4524,6 +4541,7 @@ A9A5FA3D2ACB05D90083449F /* DeviceCheck.swift in Sources */, A900E9B82ACC5C2B00C95F67 /* AccountsProxy+Stubs.swift in Sources */, A9A5FA3E2ACB05D90083449F /* DeviceCheckOperation.swift in Sources */, + 44DD7D272B6D18FB0005F67F /* MockTunnelInteractor.swift in Sources */, A9A5FA3F2ACB05D90083449F /* DeviceCheckRemoteService.swift in Sources */, A9A5FA402ACB05D90083449F /* DeviceCheckRemoteServiceProtocol.swift in Sources */, A9A5FA412ACB05D90083449F /* DeviceStateAccessor.swift in Sources */, @@ -4600,6 +4618,7 @@ A9A5FA112ACB05160083449F /* TransportMonitor.swift in Sources */, A9B6AC1A2ADE8FBB00F7802A /* InMemorySettingsStore.swift in Sources */, A9A5FA132ACB05160083449F /* LoadTunnelConfigurationOperation.swift in Sources */, + 44DD7D292B7113CA0005F67F /* MockTunnel.swift in Sources */, A9A5FA142ACB05160083449F /* MapConnectionStatusOperation.swift in Sources */, A9A5FA152ACB05160083449F /* RedeemVoucherOperation.swift in Sources */, A9A5FA162ACB05160083449F /* RotateKeyOperation.swift in Sources */, @@ -4632,6 +4651,7 @@ A9A5FA292ACB05160083449F /* AddressCacheTests.swift in Sources */, A9B6AC182ADE8F4300F7802A /* MigrationManagerTests.swift in Sources */, A9A5FA2A2ACB05160083449F /* CoordinatesTests.swift in Sources */, + 44DD7D242B6CFFD70005F67F /* StartTunnelOperationTests.swift in Sources */, A9A5FA2B2ACB05160083449F /* CustomDateComponentsFormattingTests.swift in Sources */, A9A5FA2C2ACB05160083449F /* DeviceCheckOperationTests.swift in Sources */, A9A5FA2D2ACB05160083449F /* DurationTests.swift in Sources */, diff --git a/ios/MullvadVPN/TunnelManager/Tunnel.swift b/ios/MullvadVPN/TunnelManager/Tunnel.swift index 1ff8f9179b1b..5b85473e83c4 100644 --- a/ios/MullvadVPN/TunnelManager/Tunnel.swift +++ b/ios/MullvadVPN/TunnelManager/Tunnel.swift @@ -21,11 +21,12 @@ protocol TunnelStatusObserver { } protocol TunnelProtocol: AnyObject { + associatedtype TunnelManagerProtocol: VPNTunnelProviderManagerProtocol var status: NEVPNStatus { get } var isOnDemandEnabled: Bool { get set } var startDate: Date? { get } - init(tunnelProvider: TunnelProviderManagerType) + init(tunnelProvider: TunnelManagerProtocol) func addObserver(_ observer: any TunnelStatusObserver) func removeObserver(_ observer: any TunnelStatusObserver) diff --git a/ios/MullvadVPNTests/Mocks/MockTunnel.swift b/ios/MullvadVPNTests/Mocks/MockTunnel.swift new file mode 100644 index 000000000000..b9daa63c876d --- /dev/null +++ b/ios/MullvadVPNTests/Mocks/MockTunnel.swift @@ -0,0 +1,60 @@ +// +// MockTunnel.swift +// MullvadVPNTests +// +// Created by Andrew Bulhak on 2024-02-05. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import Foundation +import NetworkExtension + +class MockTunnel: TunnelProtocol { + typealias TunnelManagerProtocol = SimulatorTunnelProviderManager + + var status: NEVPNStatus + + var isOnDemandEnabled: Bool + + var startDate: Date? + + required init(tunnelProvider: TunnelManagerProtocol) { + status = .disconnected + isOnDemandEnabled = false + startDate = nil + } + + // Observers are currently unimplemented + func addObserver(_ observer: TunnelStatusObserver) {} + + func removeObserver(_ observer: TunnelStatusObserver) {} + + func addBlockObserver( + queue: DispatchQueue?, + handler: @escaping (any TunnelProtocol, NEVPNStatus) -> Void + ) -> TunnelStatusBlockObserver { + fatalError("MockTunnel.addBlockObserver Not implemented") + } + + func logFormat() -> String { + "" + } + + func saveToPreferences(_ completion: @escaping (Error?) -> Void) { + completion(nil) + } + + func removeFromPreferences(completion: @escaping (Error?) -> Void) { + completion(nil) + } + + func setConfiguration(_ configuration: TunnelConfiguration) {} + + func start(options: [String: NSObject]?) throws { + startDate = Date() + } + + func stop() {} + + func sendProviderMessage(_ messageData: Data, responseHandler: ((Data?) -> Void)?) throws {} +} diff --git a/ios/MullvadVPNTests/Mocks/MockTunnelInteractor.swift b/ios/MullvadVPNTests/Mocks/MockTunnelInteractor.swift new file mode 100644 index 000000000000..49784143e8cc --- /dev/null +++ b/ios/MullvadVPNTests/Mocks/MockTunnelInteractor.swift @@ -0,0 +1,79 @@ +// +// MockTunnelInteractor.swift +// MullvadVPNTests +// +// Created by Andrew Bulhak on 2024-02-02. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import Foundation +import MullvadSettings +import PacketTunnelCore + +// this is still very minimal, and will be fleshed out as needed. +class MockTunnelInteractor: TunnelInteractor { + var isConfigurationLoaded: Bool + + var settings: MullvadSettings.LatestTunnelSettings + + var deviceState: MullvadSettings.DeviceState + + var onUpdateTunnelStatus: ((TunnelStatus) -> Void)? + + var tunnel: (any TunnelProtocol)? + + init( + isConfigurationLoaded: Bool, + settings: MullvadSettings.LatestTunnelSettings, + deviceState: MullvadSettings.DeviceState, + onUpdateTunnelStatus: ((TunnelStatus) -> Void)? = nil + ) { + self.isConfigurationLoaded = isConfigurationLoaded + self.settings = settings + self.deviceState = deviceState + self.onUpdateTunnelStatus = onUpdateTunnelStatus + self.tunnel = nil + self.tunnelStatus = TunnelStatus() + } + + func getPersistentTunnels() -> [any TunnelProtocol] { + return [] + } + + func createNewTunnel() -> any TunnelProtocol { + return MockTunnel(tunnelProvider: SimulatorTunnelProviderManager()) + } + + func setTunnel(_ tunnel: (any TunnelProtocol)?, shouldRefreshTunnelState: Bool) { + self.tunnel = tunnel + } + + var tunnelStatus: TunnelStatus + + func updateTunnelStatus(_ block: (inout TunnelStatus) -> Void) -> TunnelStatus { + var tunnelStatus = self.tunnelStatus + block(&tunnelStatus) + onUpdateTunnelStatus?(tunnelStatus) + return tunnelStatus + } + + func setConfigurationLoaded() {} + + func setSettings(_ settings: MullvadSettings.LatestTunnelSettings, persist: Bool) {} + + func setDeviceState(_ deviceState: MullvadSettings.DeviceState, persist: Bool) {} + + func removeLastUsedAccount() {} + + func handleRestError(_ error: Error) {} + + func startTunnel() {} + + func prepareForVPNConfigurationDeletion() {} + + struct NotImplementedError: Error {} + + func selectRelay() throws -> PacketTunnelCore.SelectedRelay { + throw NotImplementedError() + } +} diff --git a/ios/MullvadVPNTests/StartTunnelOperationTests.swift b/ios/MullvadVPNTests/StartTunnelOperationTests.swift new file mode 100644 index 000000000000..5dfd5d904f17 --- /dev/null +++ b/ios/MullvadVPNTests/StartTunnelOperationTests.swift @@ -0,0 +1,100 @@ +// +// StartTunnelOperationTests.swift +// MullvadVPNTests +// +// Created by Andrew Bulhak on 2024-02-02. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import MullvadSettings +import Network +import Operations +import WireGuardKitTypes +import XCTest + +class StartTunnelOperationTests: XCTestCase { + // MARK: utility code for setting up tests + + let testQueue = DispatchQueue(label: "StartTunnelOperationTests.testQueue") + let operationQueue = AsyncOperationQueue() + + let loggedInDeviceState = DeviceState.loggedIn( + StoredAccountData( + identifier: "", + number: "", + expiry: .distantFuture + ), + StoredDeviceData( + creationDate: Date(), + identifier: "", + name: "", + hijackDNS: false, + ipv4Address: IPAddressRange(from: "127.0.0.1/32")!, + ipv6Address: IPAddressRange(from: "::ff/64")!, + wgKeyData: StoredWgKeyData(creationDate: Date(), privateKey: PrivateKey()) + ) + ) + + func makeInteractor(deviceState: DeviceState, tunnelState: TunnelState? = nil) -> MockTunnelInteractor { + let interactor = MockTunnelInteractor( + isConfigurationLoaded: true, + settings: LatestTunnelSettings(), + deviceState: deviceState + ) + if let tunnelState { + interactor.tunnelStatus = TunnelStatus(state: tunnelState) + } + return interactor + } + + // MARK: the tests + + func testFailsIfNotLoggedIn() throws { + let expectation = expectation(description: "Start tunnel operation failed") + let operation = StartTunnelOperation( + dispatchQueue: testQueue, + interactor: makeInteractor(deviceState: .loggedOut) + ) { result in + guard case .failure = result else { + XCTFail("Operation returned \(result), not failure") + return + } + expectation.fulfill() + } + + operationQueue.addOperation(operation) + wait(for: [expectation], timeout: 1.0) + } + + func testSetsReconnectIfDisconnecting() { + let interactor = makeInteractor(deviceState: loggedInDeviceState, tunnelState: .disconnecting(.nothing)) + var tunnelStatus = TunnelStatus() + interactor.onUpdateTunnelStatus = { status in tunnelStatus = status } + let expectation = expectation(description: "Tunnel status set to reconnect") + + let operation = StartTunnelOperation( + dispatchQueue: testQueue, + interactor: interactor + ) { result in + XCTAssertEqual(tunnelStatus.state, .disconnecting(.reconnect)) + expectation.fulfill() + } + operationQueue.addOperation(operation) + wait(for: [expectation], timeout: 1.0) + } + + func testStartsTunnelIfDisconnected() { + let interactor = makeInteractor(deviceState: loggedInDeviceState, tunnelState: .disconnected) + let expectation = expectation(description: "Make tunnel provider and start tunnel") + let operation = StartTunnelOperation( + dispatchQueue: testQueue, + interactor: interactor + ) { result in + XCTAssertNotNil(interactor.tunnel) + XCTAssertNotNil(interactor.tunnel?.startDate) + expectation.fulfill() + } + operationQueue.addOperation(operation) + wait(for: [expectation], timeout: 1.0) + } +} diff --git a/ios/Shared/ApplicationTarget.swift b/ios/Shared/ApplicationTarget.swift index f46fa2c64ee5..98b0c97917f6 100644 --- a/ios/Shared/ApplicationTarget.swift +++ b/ios/Shared/ApplicationTarget.swift @@ -13,8 +13,9 @@ enum ApplicationTarget: CaseIterable { /// Returns target bundle identifier. var bundleIdentifier: String { - // swiftlint:disable:next force_cast - let mainBundleIdentifier = Bundle.main.object(forInfoDictionaryKey: "MainApplicationIdentifier") as! String + // "MainApplicationIdentifier" does not exist if running tests + let mainBundleIdentifier = Bundle.main + .object(forInfoDictionaryKey: "MainApplicationIdentifier") as? String ?? "tests" switch self { case .mainApp: return mainBundleIdentifier