diff --git a/ios/Configurations/UITests.xcconfig.template b/ios/Configurations/UITests.xcconfig.template index 2ed40f1624e7..0584179b3d37 100644 --- a/ios/Configurations/UITests.xcconfig.template +++ b/ios/Configurations/UITests.xcconfig.template @@ -1,19 +1,18 @@ -// -// MullvadVPNUITests.xcconfig -// MullvadVPN -// -// Created by Niklas Berglund on 2024-01-10. -// Copyright © 2024 Mullvad VPN AB. All rights reserved. -// +#include "Base.xcconfig" // Pin code of the iOS device under test -MULLVAD_IOS_DEVICE_PIN_CODE = +IOS_DEVICE_PIN_CODE = -// Ad serving domain used when testing ad blocking. Not that we are assuming there's an HTTP server running on the host. -MULLVAD_AD_SERVING_DOMAIN = +// UUID to identify test runs. Should be unique per test device. Generate with for example uuidgen on macOS. +TEST_DEVICE_IDENTIFIER_UUID = // Mullvad accounts used by UI tests -MULLVAD_NO_TIME_ACCOUNT_NUMBER = -MULLVAD_HAS_TIME_ACCOUNT_NUMBER = -MULLVAD_FIVE_WIREGUARD_KEYS_ACCOUNT_NUMBER = +NO_TIME_ACCOUNT_NUMBER = +HAS_TIME_ACCOUNT_NUMBER = +FIVE_WIREGUARD_KEYS_ACCOUNT_NUMBER = + +// Ad serving domain used when testing ad blocking. Note that we are assuming there's an HTTP server running on the host. +AD_SERVING_DOMAIN = vpnlist.to +// Base URL for the firewall API, Note that // will be treated as a comment, therefor you need to insert a ${} between the slashes for example http:/${}/8.8.8.8 +FIREWALL_API_BASE_URL = http:/${}/8.8.8.8 diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj index 5036a52bbdb5..4ef38718aa13 100644 --- a/ios/MullvadVPN.xcodeproj/project.pbxproj +++ b/ios/MullvadVPN.xcodeproj/project.pbxproj @@ -582,13 +582,19 @@ 852969362B4E9724007EAD4C /* AccessbilityIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A0B311D2B303A0D004B12E0 /* AccessbilityIdentifier.swift */; }; 8529693A2B4F0238007EAD4C /* TermsOfServicePage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 852969392B4F0238007EAD4C /* TermsOfServicePage.swift */; }; 8529693C2B4F0257007EAD4C /* Alert.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8529693B2B4F0257007EAD4C /* Alert.swift */; }; + 85557B0E2B591B2600795FE1 /* FirewallAPIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85557B0D2B591B2600795FE1 /* FirewallAPIClient.swift */; }; + 85557B102B59215F00795FE1 /* FirewallRule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85557B0F2B59215F00795FE1 /* FirewallRule.swift */; }; + 85557B122B594FC900795FE1 /* ConnectivityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85557B112B594FC900795FE1 /* ConnectivityTests.swift */; }; + 85557B142B5983CF00795FE1 /* MullvadAPIWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85557B132B5983CF00795FE1 /* MullvadAPIWrapper.swift */; }; 85557B162B5ABBBE00795FE1 /* XCUIElementQuery+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85557B152B5ABBBE00795FE1 /* XCUIElementQuery+Extensions.swift */; }; 85557B1E2B5FB8C700795FE1 /* HeaderBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85557B1D2B5FB8C700795FE1 /* HeaderBar.swift */; }; 85557B202B5FBBD700795FE1 /* AccountPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85557B1F2B5FBBD700795FE1 /* AccountPage.swift */; }; + 855D9F5B2B63E56B00D7C64D /* ProblemReportPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 855D9F5A2B63E56B00D7C64D /* ProblemReportPage.swift */; }; 8590896C2B61763B003AF5F5 /* LoggedInWithoutTimeUITestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 859089682B61763B003AF5F5 /* LoggedInWithoutTimeUITestCase.swift */; }; 8590896D2B61763B003AF5F5 /* LoggedInWithTimeUITestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 859089692B61763B003AF5F5 /* LoggedInWithTimeUITestCase.swift */; }; - 8590896E2B61763B003AF5F5 /* BaseUITestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8590896A2B61763B003AF5F5 /* BaseUITestCase.swift */; }; 8590896F2B61763B003AF5F5 /* LoggedOutUITestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8590896B2B61763B003AF5F5 /* LoggedOutUITestCase.swift */; }; + 85D2B0B12B6BD32400DF9DA7 /* BaseUITestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8590896A2B61763B003AF5F5 /* BaseUITestCase.swift */; }; + 85E3BDE52B70E18C00FA71FD /* Networking.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85E3BDE42B70E18C00FA71FD /* Networking.swift */; }; A900E9B82ACC5C2B00C95F67 /* AccountsProxy+Stubs.swift in Sources */ = {isa = PBXBuildFile; fileRef = A900E9B72ACC5C2B00C95F67 /* AccountsProxy+Stubs.swift */; }; A900E9BA2ACC5D0600C95F67 /* RESTRequestExecutor+Stubs.swift in Sources */ = {isa = PBXBuildFile; fileRef = A900E9B92ACC5D0600C95F67 /* RESTRequestExecutor+Stubs.swift */; }; A900E9BC2ACC609200C95F67 /* DevicesProxy+Stubs.swift in Sources */ = {isa = PBXBuildFile; fileRef = A900E9BB2ACC609200C95F67 /* DevicesProxy+Stubs.swift */; }; @@ -1755,13 +1761,19 @@ 852969382B4ED818007EAD4C /* UITests.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = UITests.xcconfig; sourceTree = ""; }; 852969392B4F0238007EAD4C /* TermsOfServicePage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TermsOfServicePage.swift; sourceTree = ""; }; 8529693B2B4F0257007EAD4C /* Alert.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Alert.swift; sourceTree = ""; }; + 85557B0D2B591B2600795FE1 /* FirewallAPIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirewallAPIClient.swift; sourceTree = ""; }; + 85557B0F2B59215F00795FE1 /* FirewallRule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirewallRule.swift; sourceTree = ""; }; + 85557B112B594FC900795FE1 /* ConnectivityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectivityTests.swift; sourceTree = ""; }; + 85557B132B5983CF00795FE1 /* MullvadAPIWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MullvadAPIWrapper.swift; sourceTree = ""; }; 85557B152B5ABBBE00795FE1 /* XCUIElementQuery+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "XCUIElementQuery+Extensions.swift"; sourceTree = ""; }; 85557B1D2B5FB8C700795FE1 /* HeaderBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeaderBar.swift; sourceTree = ""; }; 85557B1F2B5FBBD700795FE1 /* AccountPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountPage.swift; sourceTree = ""; }; + 855D9F5A2B63E56B00D7C64D /* ProblemReportPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProblemReportPage.swift; sourceTree = ""; }; 859089682B61763B003AF5F5 /* LoggedInWithoutTimeUITestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoggedInWithoutTimeUITestCase.swift; sourceTree = ""; }; 859089692B61763B003AF5F5 /* LoggedInWithTimeUITestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoggedInWithTimeUITestCase.swift; sourceTree = ""; }; 8590896A2B61763B003AF5F5 /* BaseUITestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BaseUITestCase.swift; sourceTree = ""; }; 8590896B2B61763B003AF5F5 /* LoggedOutUITestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoggedOutUITestCase.swift; sourceTree = ""; }; + 85E3BDE42B70E18C00FA71FD /* Networking.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Networking.swift; sourceTree = ""; }; A900E9B72ACC5C2B00C95F67 /* AccountsProxy+Stubs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AccountsProxy+Stubs.swift"; sourceTree = ""; }; A900E9B92ACC5D0600C95F67 /* RESTRequestExecutor+Stubs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RESTRequestExecutor+Stubs.swift"; sourceTree = ""; }; A900E9BB2ACC609200C95F67 /* DevicesProxy+Stubs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DevicesProxy+Stubs.swift"; sourceTree = ""; }; @@ -3351,12 +3363,14 @@ 852969262B4D9C1F007EAD4C /* MullvadVPNUITests */ = { isa = PBXGroup; children = ( - 8518F6392B601910009EB113 /* Test base classes */, - 852969312B4E9220007EAD4C /* Pages */, 852969272B4D9C1F007EAD4C /* AccountTests.swift */, + 85557B112B594FC900795FE1 /* ConnectivityTests.swift */, + 852969372B4ED20E007EAD4C /* Info.plist */, + 85557B0C2B591B0F00795FE1 /* Networking */, + 852969312B4E9220007EAD4C /* Pages */, 850201DA2B503D7700EF8C96 /* RelayTests.swift */, + 8518F6392B601910009EB113 /* Test base classes */, 85557B152B5ABBBE00795FE1 /* XCUIElementQuery+Extensions.swift */, - 852969372B4ED20E007EAD4C /* Info.plist */, ); path = MullvadVPNUITests; sourceTree = ""; @@ -3364,19 +3378,31 @@ 852969312B4E9220007EAD4C /* Pages */ = { isa = PBXGroup; children = ( + 85557B1F2B5FBBD700795FE1 /* AccountPage.swift */, 8529693B2B4F0257007EAD4C /* Alert.swift */, + 85557B1D2B5FB8C700795FE1 /* HeaderBar.swift */, 852969342B4E9270007EAD4C /* LoginPage.swift */, 852969322B4E9232007EAD4C /* Page.swift */, + 855D9F5A2B63E56B00D7C64D /* ProblemReportPage.swift */, 850201DC2B503D8C00EF8C96 /* SelectLocationPage.swift */, 850201E22B51A93C00EF8C96 /* SettingsPage.swift */, - 850201DE2B5040A500EF8C96 /* TunnelControlPage.swift */, - 85557B1D2B5FB8C700795FE1 /* HeaderBar.swift */, 852969392B4F0238007EAD4C /* TermsOfServicePage.swift */, - 85557B1F2B5FBBD700795FE1 /* AccountPage.swift */, + 850201DE2B5040A500EF8C96 /* TunnelControlPage.swift */, ); path = Pages; sourceTree = ""; }; + 85557B0C2B591B0F00795FE1 /* Networking */ = { + isa = PBXGroup; + children = ( + 85557B0D2B591B2600795FE1 /* FirewallAPIClient.swift */, + 85557B0F2B59215F00795FE1 /* FirewallRule.swift */, + 85557B132B5983CF00795FE1 /* MullvadAPIWrapper.swift */, + 85E3BDE42B70E18C00FA71FD /* Networking.swift */, + ); + path = Networking; + sourceTree = ""; + }; A907639F2B2857D50045ADF0 /* Socks5 */ = { isa = PBXGroup; children = ( @@ -5239,23 +5265,30 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 85D2B0B12B6BD32400DF9DA7 /* BaseUITestCase.swift in Sources */, 8590896D2B61763B003AF5F5 /* LoggedInWithTimeUITestCase.swift in Sources */, 8529693C2B4F0257007EAD4C /* Alert.swift in Sources */, 850201DD2B503D8C00EF8C96 /* SelectLocationPage.swift in Sources */, 850201DB2B503D7700EF8C96 /* RelayTests.swift in Sources */, + 85557B142B5983CF00795FE1 /* MullvadAPIWrapper.swift in Sources */, 852969362B4E9724007EAD4C /* AccessbilityIdentifier.swift in Sources */, + 85E3BDE52B70E18C00FA71FD /* Networking.swift in Sources */, 8590896C2B61763B003AF5F5 /* LoggedInWithoutTimeUITestCase.swift in Sources */, 8590896F2B61763B003AF5F5 /* LoggedOutUITestCase.swift in Sources */, 85557B202B5FBBD700795FE1 /* AccountPage.swift in Sources */, 852969352B4E9270007EAD4C /* LoginPage.swift in Sources */, - 8590896E2B61763B003AF5F5 /* BaseUITestCase.swift in Sources */, 850201E32B51A93C00EF8C96 /* SettingsPage.swift in Sources */, 8518F6382B60157E009EB113 /* LoggedInWithoutTimeUITestCase.swift in Sources */, + 85557B102B59215F00795FE1 /* FirewallRule.swift in Sources */, + 850201E32B51A93C00EF8C96 /* SettingsPage.swift in Sources */, + 85557B0E2B591B2600795FE1 /* FirewallAPIClient.swift in Sources */, 852969282B4D9C1F007EAD4C /* AccountTests.swift in Sources */, 85557B162B5ABBBE00795FE1 /* XCUIElementQuery+Extensions.swift in Sources */, + 855D9F5B2B63E56B00D7C64D /* ProblemReportPage.swift in Sources */, 8529693A2B4F0238007EAD4C /* TermsOfServicePage.swift in Sources */, 850201DF2B5040A500EF8C96 /* TunnelControlPage.swift in Sources */, 85557B1E2B5FB8C700795FE1 /* HeaderBar.swift in Sources */, + 85557B122B594FC900795FE1 /* ConnectivityTests.swift in Sources */, 852969332B4E9232007EAD4C /* Page.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/ios/MullvadVPN.xcodeproj/xcshareddata/xcschemes/MullvadVPNUITests.xcscheme b/ios/MullvadVPN.xcodeproj/xcshareddata/xcschemes/MullvadVPNUITests.xcscheme index 45ec07ec859f..7c65faea3b98 100644 --- a/ios/MullvadVPN.xcodeproj/xcshareddata/xcschemes/MullvadVPNUITests.xcscheme +++ b/ios/MullvadVPN.xcodeproj/xcshareddata/xcschemes/MullvadVPNUITests.xcscheme @@ -40,7 +40,8 @@ debugDocumentVersioning = "YES" debugServiceExtension = "internal" allowLocationSimulation = "YES"> - + - + + + + + diff --git a/ios/MullvadVPN/Classes/AccessbilityIdentifier.swift b/ios/MullvadVPN/Classes/AccessbilityIdentifier.swift index 4f31461e5e70..3d3a6bdb6be8 100644 --- a/ios/MullvadVPN/Classes/AccessbilityIdentifier.swift +++ b/ios/MullvadVPN/Classes/AccessbilityIdentifier.swift @@ -27,9 +27,12 @@ public enum AccessibilityIdentifier: String { case purchaseButton case redeemVoucherButton case restorePurchasesButton + case secureConnectionButton case selectLocationButton case settingsButton case startUsingTheAppButton + case problemReportAppLogsButton + case problemReportSendButton // Cells case preferencesCell @@ -43,6 +46,7 @@ public enum AccessibilityIdentifier: String { // Labels case headerDeviceNameLabel + case connectionStatusLabel // Views case accountView @@ -55,6 +59,7 @@ public enum AccessibilityIdentifier: String { case selectLocationTableView case settingsTableView case tunnelControlView + case problemReportView // Other UI elements case connectionPanelInAddressRow @@ -63,6 +68,8 @@ public enum AccessibilityIdentifier: String { case dnsContentBlockersHeaderView case loginTextField case selectLocationSearchTextField + case problemReportEmailTextField + case problemReportMessageTextView // DNS settings case dnsSettings diff --git a/ios/MullvadVPN/View controllers/ProblemReport/ProblemReportViewController.swift b/ios/MullvadVPN/View controllers/ProblemReport/ProblemReportViewController.swift index 1bbd63f101a3..5debf6704857 100644 --- a/ios/MullvadVPN/View controllers/ProblemReport/ProblemReportViewController.swift +++ b/ios/MullvadVPN/View controllers/ProblemReport/ProblemReportViewController.swift @@ -136,6 +136,7 @@ final class ProblemReportViewController: UIViewController, UITextFieldDelegate { private lazy var viewLogsButton: AppButton = { let button = AppButton(style: .default) button.translatesAutoresizingMaskIntoConstraints = false + button.accessibilityIdentifier = .problemReportAppLogsButton button.setTitle(NSLocalizedString( "VIEW_APP_LOGS_BUTTON_TITLE", tableName: "ProblemReport", @@ -149,6 +150,7 @@ final class ProblemReportViewController: UIViewController, UITextFieldDelegate { private lazy var sendButton: AppButton = { let button = AppButton(style: .success) button.translatesAutoresizingMaskIntoConstraints = false + button.accessibilityIdentifier = .problemReportSendButton button.setTitle(NSLocalizedString( "SEND_BUTTON_TITLE", tableName: "ProblemReport", @@ -210,6 +212,7 @@ final class ProblemReportViewController: UIViewController, UITextFieldDelegate { super.viewDidLoad() view.backgroundColor = .secondaryColor + view.accessibilityIdentifier = .problemReportView navigationItem.title = NSLocalizedString( "NAVIGATION_TITLE", @@ -230,6 +233,9 @@ final class ProblemReportViewController: UIViewController, UITextFieldDelegate { messageTextView.setContentHuggingPriority(.defaultLow, for: .vertical) messageTextView.setContentCompressionResistancePriority(.defaultLow, for: .vertical) + emailTextField.accessibilityIdentifier = .problemReportEmailTextField + messageTextView.accessibilityIdentifier = .problemReportMessageTextView + addConstraints() registerForNotifications() loadPersistentViewModel() diff --git a/ios/MullvadVPN/View controllers/Tunnel/TunnelControlView.swift b/ios/MullvadVPN/View controllers/Tunnel/TunnelControlView.swift index e1f393b1b78a..bd991142e662 100644 --- a/ios/MullvadVPN/View controllers/Tunnel/TunnelControlView.swift +++ b/ios/MullvadVPN/View controllers/Tunnel/TunnelControlView.swift @@ -64,6 +64,7 @@ final class TunnelControlView: UIView { private let connectButton: AppButton = { let button = AppButton(style: .success) + button.accessibilityIdentifier = .secureConnectionButton button.translatesAutoresizingMaskIntoConstraints = false return button }() @@ -120,6 +121,8 @@ final class TunnelControlView: UIView { accessibilityContainerType = .semanticGroup accessibilityIdentifier = .tunnelControlView + secureLabel.accessibilityIdentifier = .connectionStatusLabel + addSubviews() addButtonHandlers() } diff --git a/ios/MullvadVPNUITests/ConnectivityTests.swift b/ios/MullvadVPNUITests/ConnectivityTests.swift new file mode 100644 index 000000000000..1c4e46ac141d --- /dev/null +++ b/ios/MullvadVPNUITests/ConnectivityTests.swift @@ -0,0 +1,52 @@ +// +// ConnectivityTests.swift +// MullvadVPNUITests +// +// Created by Niklas Berglund on 2024-01-18. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import Foundation +import Network +import XCTest + +class ConnectivityTests: LoggedOutUITestCase { + let firewallAPIClient = FirewallAPIClient() + + override func setUpWithError() throws { + super.setUp() + } + + override func tearDownWithError() throws { + super.tearDown() + firewallAPIClient.removeRules() + } + + /// Verifies that the app still functions when API has been blocked + func testAPIConnectionViaBridges() throws { + let app = XCUIApplication() + app.launch() + + try Networking.verifyCanAccessAPI() // Just to make sure there's no old firewall rule still active + firewallAPIClient.createRule(try FirewallRule.makeBlockAPIAccessFirewallRule()) + try Networking.verifyCannotAccessAPI() + + LoginPage(app) + .tapAccountNumberTextField() + .enterText(self.hasTimeAccountNumber) + .tapAccountNumberSubmitButton() + + // After creating firewall rule first login attempt might fail. One more attempt is allowed since the app is cycling between two methods. + if isLoggedIn() { + LoginPage(app) + .verifySuccessIconShown() + .verifyDeviceLabelShown() + } else { + LoginPage(app) + .verifyFailIconShown() + .tapAccountNumberSubmitButton() + .verifySuccessIconShown() + .verifyDeviceLabelShown() + } + } +} diff --git a/ios/MullvadVPNUITests/Info.plist b/ios/MullvadVPNUITests/Info.plist index 85eb161b49f5..dd7e65bae2e5 100644 --- a/ios/MullvadVPNUITests/Info.plist +++ b/ios/MullvadVPNUITests/Info.plist @@ -2,17 +2,25 @@ - MullvadAdServingDomain - $(MULLVAD_AD_SERVING_DOMAIN) - MullvadDisplayName + AdServingDomain + $(AD_SERVING_DOMAIN) + ApiEndpoint + $(API_ENDPOINT) + ApiHostName + $(API_HOST_NAME) + DisplayName $(DISPLAY_NAME) - MullvadFiveWireGuardKeysAccountNumber - $(MULLVAD_FIVE_WIREGUARD_KEYS_ACCOUNT_NUMBER) - MullvadHasTimeAccountNumber - $(MULLVAD_HAS_TIME_ACCOUNT_NUMBER) - MullvadIOSDevicePinCode - $(MULLVAD_IOS_DEVICE_PIN_CODE) - MullvadNoTimeAccountNumber - $(MULLVAD_NO_TIME_ACCOUNT_NUMBER) + FirewallApiBaseURL + $(FIREWALL_API_BASE_URL) + FiveWireGuardKeysAccountNumber + $(FIVE_WIREGUARD_KEYS_ACCOUNT_NUMBER) + HasTimeAccountNumber + $(HAS_TIME_ACCOUNT_NUMBER) + IOSDevicePinCode + $(IOS_DEVICE_PIN_CODE) + NoTimeAccountNumber + $(NO_TIME_ACCOUNT_NUMBER) + TestDeviceIdentifier + $(TEST_DEVICE_IDENTIFIER_UUID diff --git a/ios/MullvadVPNUITests/Networking/FirewallAPIClient.swift b/ios/MullvadVPNUITests/Networking/FirewallAPIClient.swift new file mode 100644 index 000000000000..49496f3d2eb9 --- /dev/null +++ b/ios/MullvadVPNUITests/Networking/FirewallAPIClient.swift @@ -0,0 +1,113 @@ +// +// FirewallClient.swift +// MullvadVPNUITests +// +// Created by Niklas Berglund on 2024-01-18. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import Foundation +import SystemConfiguration +import UIKit +import XCTest + +class FirewallAPIClient { + // swiftlint:disable force_cast + let baseURL = URL( + string: + Bundle(for: FirewallAPIClient.self).infoDictionary?["FirewallApiBaseURL"] as! String + )! + let testDeviceIdentifier = Bundle(for: FirewallAPIClient.self).infoDictionary?["TestDeviceIdentifier"] as! String + // swiftlint:enable force_cast + + lazy var sessionIdentifier = "urn:uuid:" + testDeviceIdentifier + + /// Create a new rule associated to the device under test + public func createRule(_ firewallRule: FirewallRule) { + let createRuleURL = baseURL.appendingPathComponent("rule") + + var request = URLRequest(url: createRuleURL) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + let dataDictionary: [String: Any] = [ + "label": sessionIdentifier, + "from": firewallRule.fromIPAddress, + "to": firewallRule.toIPAddress, + ] + + var requestError: Error? + var requestResponse: URLResponse? + let completionHandlerInvokedExpectation = XCTestExpectation( + description: "Completion handler for the request is invoked" + ) + + do { + let jsonData = try JSONSerialization.data(withJSONObject: dataDictionary) + request.httpBody = jsonData + + let dataTask = URLSession.shared.dataTask(with: request) { _, response, error in + requestError = error + requestResponse = response + completionHandlerInvokedExpectation.fulfill() + } + + dataTask.resume() + + let waitResult = XCTWaiter.wait(for: [completionHandlerInvokedExpectation], timeout: 30) + + if waitResult != .completed { + XCTFail("Failed to create firewall rule - timeout") + } else { + if let response = requestResponse as? HTTPURLResponse { + if response.statusCode != 201 { + XCTFail("Failed to create firewall rule - unexpected server response") + } + } + + if let error = requestError { + XCTFail("Failed to create firewall rule - encountered error \(error.localizedDescription)") + } + } + } catch { + XCTFail("Failed to create firewall rule - couldn't serialize JSON") + } + } + + /// Remove all firewall rules associated to this device under test + public func removeRules() { + let removeRulesURL = baseURL.appendingPathComponent("remove-rules/\(sessionIdentifier)") + + var request = URLRequest(url: removeRulesURL) + request.httpMethod = "DELETE" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + var requestResponse: URLResponse? + var requestError: Error? + let completionHandlerInvokedExpectation = XCTestExpectation( + description: "Completion handler for the request is invoked" + ) + + let dataTask = URLSession.shared.dataTask(with: request) { _, response, error in + requestResponse = response + requestError = error + completionHandlerInvokedExpectation.fulfill() + } + + dataTask.resume() + + let waitResult = XCTWaiter.wait(for: [completionHandlerInvokedExpectation], timeout: 30) + + if waitResult != .completed { + XCTFail("Failed to remove firewall rules - timeout") + } else { + if let response = requestResponse as? HTTPURLResponse, response.statusCode != 200 { + XCTFail("Failed to remove firewall rules - unexpected server response") + } + + if let error = requestError { + XCTFail("Failed to remove firewall rules - encountered error \(error.localizedDescription)") + } + } + } +} diff --git a/ios/MullvadVPNUITests/Networking/FirewallRule.swift b/ios/MullvadVPNUITests/Networking/FirewallRule.swift new file mode 100644 index 000000000000..d89dbc6853ab --- /dev/null +++ b/ios/MullvadVPNUITests/Networking/FirewallRule.swift @@ -0,0 +1,43 @@ +// +// FirewallRule.swift +// MullvadVPNUITests +// +// Created by Niklas Berglund on 2024-01-18. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import Foundation +import XCTest + +enum NetworkingProtocol: String { + case TCP = "tcp" + case UDP = "udp" + case ICMP = "icmp" +} + +struct FirewallRule { + let fromIPAddress: String + let toIPAddress: String + let protocols: [NetworkingProtocol] + + /// - Parameters: + /// - fromIPAddress: Block traffic originating from this source IP address. + /// - toIPAddress: Block traffic to this destination IP address. + /// - protocols: Protocols which should be blocked. If none is specified all will be blocked. + private init(fromIPAddress: String, toIPAddress: String, protocols: [NetworkingProtocol]) { + self.fromIPAddress = fromIPAddress + self.toIPAddress = toIPAddress + self.protocols = protocols + } + + /// Make a firewall rule blocking API access for the current device under test + public static func makeBlockAPIAccessFirewallRule() throws -> FirewallRule { + let deviceIPAddress = try Networking.getIPAddress() + let apiIPAddress = try MullvadAPIWrapper.getAPIIPAddress() + return FirewallRule( + fromIPAddress: deviceIPAddress, + toIPAddress: apiIPAddress, + protocols: [NetworkingProtocol.TCP] + ) + } +} diff --git a/ios/MullvadVPNUITests/Networking/MullvadAPIWrapper.swift b/ios/MullvadVPNUITests/Networking/MullvadAPIWrapper.swift new file mode 100644 index 000000000000..71343dd4f39a --- /dev/null +++ b/ios/MullvadVPNUITests/Networking/MullvadAPIWrapper.swift @@ -0,0 +1,45 @@ +// +// AppAPI.swift +// MullvadVPNUITests +// +// Created by Niklas Berglund on 2024-01-18. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import Foundation +import XCTest + +enum MullvadAPIError: Error { + case incorrectConfigurationFormat +} + +class MullvadAPIWrapper { + // swiftlint:disable force_cast + static let hostName = Bundle(for: MullvadAPIWrapper.self) + .infoDictionary?["ApiHostName"] as! String + + /// API endpoint configuration value in the format : + static let endpoint = Bundle(for: MullvadAPIWrapper.self) + .infoDictionary?["ApiEndpoint"] as! String + // swiftlint:enable force_cast + + public static func getAPIHostname() -> String { + return hostName + } + + public static func getAPIIPAddress() throws -> String { + guard let ipAddress = endpoint.components(separatedBy: ":").first else { + throw MullvadAPIError.incorrectConfigurationFormat + } + + return ipAddress + } + + public static func getAPIPort() throws -> String { + guard let port = endpoint.components(separatedBy: ":").last else { + throw MullvadAPIError.incorrectConfigurationFormat + } + + return port + } +} diff --git a/ios/MullvadVPNUITests/Networking/Networking.swift b/ios/MullvadVPNUITests/Networking/Networking.swift new file mode 100644 index 000000000000..3a25fe064b58 --- /dev/null +++ b/ios/MullvadVPNUITests/Networking/Networking.swift @@ -0,0 +1,146 @@ +// +// Networking.swift +// MullvadVPNUITests +// +// Created by Niklas Berglund on 2024-02-05. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import Foundation +import Network +import XCTest + +enum NetworkingError: Error { + case notConfiguredError + case internalError(reason: String) +} + +/// Class with methods for verifying network connectivity +class Networking { + /// Get IP address of the iOS device under test + static func getIPAddress() throws -> String { + var ipAddress: String + // Get list of all interfaces on the local machine: + var interfaceList: UnsafeMutablePointer? + guard getifaddrs(&interfaceList) == 0, let firstInterfaceAddress = interfaceList else { + throw NetworkingError.internalError(reason: "Failed to locate local networking interface") + } + + // For each interface + for interfacePointer in sequence(first: firstInterfaceAddress, next: { $0.pointee.ifa_next }) { + let flags = Int32(interfacePointer.pointee.ifa_flags) + let interfaceAddress = interfacePointer.pointee.ifa_addr.pointee + + // Check for running IPv4 interfaces. Skip the loopback interface. + if ( + flags & + (IFF_UP | IFF_RUNNING | IFF_LOOPBACK) + ) == (IFF_UP | IFF_RUNNING), + interfaceAddress.sa_family == UInt8(AF_INET) { + // Check if interface is en0 which is the WiFi connection on the iPhone + let name = String(cString: interfacePointer.pointee.ifa_name) + if name == "en0" { + // Convert interface address to a human readable string: + var hostname = [CChar](repeating: 0, count: Int(NI_MAXHOST)) + if getnameinfo( + interfacePointer.pointee.ifa_addr, + socklen_t(interfaceAddress.sa_len), + &hostname, + socklen_t(hostname.count), + nil, + socklen_t(0), + NI_NUMERICHOST + ) == 0 { + ipAddress = String(cString: hostname) + return ipAddress + } + } + } + } + + freeifaddrs(interfaceList) + + throw NetworkingError.internalError(reason: "Failed to determine device's IP address") + } + + private static func getAdServingDomainURL() -> URL? { + guard let adServingDomain = Bundle(for: BaseUITestCase.self) + .infoDictionary?["AdServingDomain"] as? String, + let adServingDomainURL = URL(string: adServingDomain) else { + XCTFail("Ad serving domain not configured") + return nil + } + + return adServingDomainURL + } + + private static func getAdServingDomain() throws -> String { + guard let adServingDomain = Bundle(for: BaseUITestCase.self) + .infoDictionary?["AdServingDomain"] as? String else { + throw NetworkingError.notConfiguredError + } + + return adServingDomain + } + + /// Check whether host and port is reachable by attempting to connect a socket + private static func canConnectSocket(host: String, port: String) -> Bool { + let socketHost = NWEndpoint.Host(host) + let socketPort = NWEndpoint.Port(port)! + let connection = NWConnection(host: socketHost, port: socketPort, using: .tcp) + var connectionError: Error? + + let connectionStateDeterminedExpectation = XCTestExpectation( + description: "Completion handler for the reach ad serving domain request is invoked" + ) + + connection.stateUpdateHandler = { state in + print("State: \(state)") + + switch state { + case let .failed(error): + connection.cancel() + connectionError = error + connectionStateDeterminedExpectation.fulfill() + case .ready: + connection.cancel() + connectionStateDeterminedExpectation.fulfill() + default: + break + } + } + + connection.start(queue: .global()) + let waitResult = XCTWaiter.wait(for: [connectionStateDeterminedExpectation], timeout: 15) + + if waitResult != .completed || connectionError != nil { + return false + } + + return true + } + + /// Verify API can be accessed by attempting to connect a socket to the configured API host and port + public static func verifyCanAccessAPI() throws { + let apiIPAddress = try MullvadAPIWrapper.getAPIIPAddress() + let apiPort = try MullvadAPIWrapper.getAPIPort() + XCTAssertTrue(canConnectSocket(host: apiIPAddress, port: apiPort)) + } + + /// Verify API cannot be accessed by attempting to connect a socket to the configured API host and port + public static func verifyCannotAccessAPI() throws { + let apiIPAddress = try MullvadAPIWrapper.getAPIIPAddress() + let apiPort = try MullvadAPIWrapper.getAPIPort() + XCTAssertFalse(canConnectSocket(host: apiIPAddress, port: apiPort)) + } + + /// Verify that an ad serving domain is reachable by making sure a connection can be established on port 80 + public static func verifyCanReachAdServingDomain() { + XCTAssertTrue(Self.canConnectSocket(host: try Self.getAdServingDomain(), port: "80")) + } + + /// Verify that an ad serving domain is NOT reachable by making sure a connection can not be established on port 80 + public static func verifyCannotReachAdServingDomain() { + XCTAssertFalse(Self.canConnectSocket(host: try Self.getAdServingDomain(), port: "80")) + } +} diff --git a/ios/MullvadVPNUITests/Pages/LoginPage.swift b/ios/MullvadVPNUITests/Pages/LoginPage.swift index 18a44b5e74f7..399943fe527c 100644 --- a/ios/MullvadVPNUITests/Pages/LoginPage.swift +++ b/ios/MullvadVPNUITests/Pages/LoginPage.swift @@ -37,12 +37,12 @@ class LoginPage: Page { } @discardableResult public func verifySuccessIconShown() -> Self { - app.images.element(matching: .image, identifier: "IconSuccess") + _ = app.images.element(matching: .image, identifier: "IconSuccess") return self } @discardableResult public func verifyFailIconShown() -> Self { - app.images.element(matching: .image, identifier: "IconFail") + _ = app.images.element(matching: .image, identifier: "IconFail").waitForExistence(timeout: 15) return self } } diff --git a/ios/MullvadVPNUITests/Pages/Page.swift b/ios/MullvadVPNUITests/Pages/Page.swift index 5a11e586e5db..453bd5737599 100644 --- a/ios/MullvadVPNUITests/Pages/Page.swift +++ b/ios/MullvadVPNUITests/Pages/Page.swift @@ -17,7 +17,7 @@ class Page { self.app = app } - public func waitForPageToBeShown() { + func waitForPageToBeShown() { if let pageAccessibilityIdentifier = self.pageAccessibilityIdentifier { XCTAssert( self.app.otherElements[pageAccessibilityIdentifier] @@ -26,7 +26,7 @@ class Page { } } - @discardableResult public func enterText(_ text: String) -> Self { + @discardableResult func enterText(_ text: String) -> Self { app.typeText(text) return self } @@ -36,4 +36,9 @@ class Page { app.swipeDown(velocity: .fast) return self } + + @discardableResult func tapKeyboardDoneButton() -> Self { + app.toolbars.buttons["Done"].tap() + return self + } } diff --git a/ios/MullvadVPNUITests/Pages/ProblemReportPage.swift b/ios/MullvadVPNUITests/Pages/ProblemReportPage.swift new file mode 100644 index 000000000000..4bb2407cecf5 --- /dev/null +++ b/ios/MullvadVPNUITests/Pages/ProblemReportPage.swift @@ -0,0 +1,48 @@ +// +// ProblemReportPage.swift +// MullvadVPNUITests +// +// Created by Niklas Berglund on 2024-01-26. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import Foundation +import XCTest + +class ProblemReportPage: Page { + @discardableResult override init(_ app: XCUIApplication) { + super.init(app) + + pageAccessibilityIdentifier = .problemReportView + + waitForPageToBeShown() + } + + @discardableResult func tapEmailTextField() -> Self { + app.textFields[AccessibilityIdentifier.problemReportEmailTextField] + .tap() + + return self + } + + @discardableResult func tapMessageTextView() -> Self { + app.textViews[AccessibilityIdentifier.problemReportMessageTextView] + .tap() + + return self + } + + @discardableResult func tapViewAppLogsButton() -> Self { + app.otherElements[AccessibilityIdentifier.problemReportAppLogsButton] + .tap() + + return self + } + + @discardableResult func tapSendButton() -> Self { + app.otherElements[AccessibilityIdentifier.problemReportSendButton] + .tap() + + return self + } +} diff --git a/ios/MullvadVPNUITests/Pages/SettingsPage.swift b/ios/MullvadVPNUITests/Pages/SettingsPage.swift index c57fd3ff0421..72acac4019a6 100644 --- a/ios/MullvadVPNUITests/Pages/SettingsPage.swift +++ b/ios/MullvadVPNUITests/Pages/SettingsPage.swift @@ -24,6 +24,14 @@ class SettingsPage: Page { return self } + @discardableResult func tapReportAProblemCell() -> Self { + app.tables[AccessibilityIdentifier.settingsTableView] + .cells[AccessibilityIdentifier.problemReportCell] + .tap() + + return self + } + @discardableResult func tapDNSSettingsCell() -> Self { app.tables .cells[AccessibilityIdentifier.dnsSettings] diff --git a/ios/MullvadVPNUITests/Pages/TunnelControlPage.swift b/ios/MullvadVPNUITests/Pages/TunnelControlPage.swift index 6469ed2232fd..860b9280240c 100644 --- a/ios/MullvadVPNUITests/Pages/TunnelControlPage.swift +++ b/ios/MullvadVPNUITests/Pages/TunnelControlPage.swift @@ -20,4 +20,15 @@ class TunnelControlPage: Page { app.buttons[AccessibilityIdentifier.selectLocationButton].tap() return self } + + @discardableResult func tapSecureConnectionButton() -> Self { + app.buttons[AccessibilityIdentifier.secureConnectionButton].tap() + return self + } + + @discardableResult func waitForSecureConnectionLabel() -> Self { + _ = app.staticTexts[AccessibilityIdentifier.connectionStatusLabel] + .waitForExistence(timeout: BaseUITestCase.defaultTimeout) + return self + } } diff --git a/ios/MullvadVPNUITests/RelayTests.swift b/ios/MullvadVPNUITests/RelayTests.swift index cb1454c80198..a91ec6c2c123 100644 --- a/ios/MullvadVPNUITests/RelayTests.swift +++ b/ios/MullvadVPNUITests/RelayTests.swift @@ -22,58 +22,13 @@ class RelayTests: LoggedInWithTimeUITestCase { .swipeDownToDismissModal() TunnelControlPage(app) - .tapSelectLocationButton() - - SelectLocationPage(app) - .tapLocationCellExpandButton(withName: "Sweden") - .tapLocationCellExpandButton(withName: "Gothenburg") - .tapLocationCell(withName: "se-got-wg-001") + .tapSecureConnectionButton() allowAddVPNConfigurations() // Allow adding VPN configurations iOS permission - TunnelControlPage(app) // Make sure we're taken back to tunnel control page again - - verifyCannotReachAdServingDomain() - } - - /// Verify that an ad serving domain is reachable by making sure the host can be found when sending HTTP request to it - func verifyCanReachAdServingDomain() { - XCTAssertTrue(canReachAdServingDomain()) - } - - /// Verify that an ad serving domain is NOT reachable by making sure the host cannot be found when sending HTTP request to it - func verifyCannotReachAdServingDomain() { - XCTAssertFalse(canReachAdServingDomain()) - } - - /// Attempt to reach HTTP server on an ad serving domain - /// - Returns: `true` if host can be resolved, otherwise `false` - private func canReachAdServingDomain() -> Bool { - guard let url = URL(string: "http://\(adServingDomain)") else { return false } - - var requestError: Error? - var requestResponse: URLResponse? - - let completionHandlerInvokedExpectation = expectation( - description: "Completion handler for the request is invoked" - ) - - let task = URLSession.shared.dataTask(with: url) { _, response, error in - requestError = error - requestResponse = response - completionHandlerInvokedExpectation.fulfill() - } - - task.resume() - - wait(for: [completionHandlerInvokedExpectation], timeout: 30) - - if let urlError = requestError as? URLError { - if urlError.code == .cannotFindHost && requestResponse == nil { - return false - } - } + TunnelControlPage(app) + .waitForSecureConnectionLabel() - return true + Networking.verifyCannotReachAdServingDomain() } } diff --git a/ios/MullvadVPNUITests/Test base classes/BaseUITestCase.swift b/ios/MullvadVPNUITests/Test base classes/BaseUITestCase.swift index cedba952dcdd..eae33b08050f 100644 --- a/ios/MullvadVPNUITests/Test base classes/BaseUITestCase.swift +++ b/ios/MullvadVPNUITests/Test base classes/BaseUITestCase.swift @@ -15,17 +15,15 @@ class BaseUITestCase: XCTestCase { // swiftlint:disable force_cast let displayName = Bundle(for: BaseUITestCase.self) - .infoDictionary?["MullvadDisplayName"] as! String + .infoDictionary?["DisplayName"] as! String let noTimeAccountNumber = Bundle(for: BaseUITestCase.self) - .infoDictionary?["MullvadNoTimeAccountNumber"] as! String + .infoDictionary?["NoTimeAccountNumber"] as! String let hasTimeAccountNumber = Bundle(for: BaseUITestCase.self) - .infoDictionary?["MullvadHasTimeAccountNumber"] as! String + .infoDictionary?["HasTimeAccountNumber"] as! String let fiveWireGuardKeysAccountNumber = Bundle(for: BaseUITestCase.self) - .infoDictionary?["MullvadFiveWireGuardKeysAccountNumber"] as! String + .infoDictionary?["FiveWireGuardKeysAccountNumber"] as! String let iOSDevicePinCode = Bundle(for: BaseUITestCase.self) - .infoDictionary?["MullvadIOSDevicePinCode"] as! String - let adServingDomain = Bundle(for: BaseUITestCase.self) - .infoDictionary?["MullvadAdServingDomain"] as! String + .infoDictionary?["IOSDevicePinCode"] as! String // swiftlint:enable force_cast /// Handle iOS add VPN configuration permission alert - allow and enter device PIN code