diff options
20 files changed, 429 insertions, 18 deletions
diff --git a/ios/Configurations/UITests.xcconfig.template b/ios/Configurations/UITests.xcconfig.template index b55222dac2..c2a0fe821b 100644 --- a/ios/Configurations/UITests.xcconfig.template +++ b/ios/Configurations/UITests.xcconfig.template @@ -15,6 +15,9 @@ 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 + +// A domain which should be reachable. Used to verify Internet connectivity. Must be running a server on port 80. +SHOULD_BE_REACHABLE_DOMAIN = mullvad.net // 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 4047f906ac..f1ee25f1a1 100644 --- a/ios/MullvadVPN.xcodeproj/project.pbxproj +++ b/ios/MullvadVPN.xcodeproj/project.pbxproj @@ -618,6 +618,7 @@ 8529693A2B4F0238007EAD4C /* TermsOfServicePage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 852969392B4F0238007EAD4C /* TermsOfServicePage.swift */; }; 8529693C2B4F0257007EAD4C /* Alert.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8529693B2B4F0257007EAD4C /* Alert.swift */; }; 8532E6872B8CCED600ACECD1 /* ProblemReportSubmittedPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8532E6862B8CCED600ACECD1 /* ProblemReportSubmittedPage.swift */; }; + 8542CE242B95F7B9006FCA14 /* VPNSettingsPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8542CE232B95F7B9006FCA14 /* VPNSettingsPage.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 */; }; @@ -1861,6 +1862,7 @@ 852969392B4F0238007EAD4C /* TermsOfServicePage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TermsOfServicePage.swift; sourceTree = "<group>"; }; 8529693B2B4F0257007EAD4C /* Alert.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Alert.swift; sourceTree = "<group>"; }; 8532E6862B8CCED600ACECD1 /* ProblemReportSubmittedPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProblemReportSubmittedPage.swift; sourceTree = "<group>"; }; + 8542CE232B95F7B9006FCA14 /* VPNSettingsPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VPNSettingsPage.swift; sourceTree = "<group>"; }; 85557B0D2B591B2600795FE1 /* FirewallAPIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirewallAPIClient.swift; sourceTree = "<group>"; }; 85557B0F2B59215F00795FE1 /* FirewallRule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirewallRule.swift; sourceTree = "<group>"; }; 85557B112B594FC900795FE1 /* ConnectivityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectivityTests.swift; sourceTree = "<group>"; }; @@ -3609,6 +3611,7 @@ 852969392B4F0238007EAD4C /* TermsOfServicePage.swift */, 850201DE2B5040A500EF8C96 /* TunnelControlPage.swift */, 85FB5A0B2B6903990015DCED /* WelcomePage.swift */, + 8542CE232B95F7B9006FCA14 /* VPNSettingsPage.swift */, ); path = Pages; sourceTree = "<group>"; @@ -5598,6 +5601,7 @@ 8532E6872B8CCED600ACECD1 /* ProblemReportSubmittedPage.swift in Sources */, 85FB5A0C2B6903990015DCED /* WelcomePage.swift in Sources */, 850201DF2B5040A500EF8C96 /* TunnelControlPage.swift in Sources */, + 8542CE242B95F7B9006FCA14 /* VPNSettingsPage.swift in Sources */, 85557B1E2B5FB8C700795FE1 /* HeaderBar.swift in Sources */, 85557B122B594FC900795FE1 /* ConnectivityTests.swift in Sources */, 852969332B4E9232007EAD4C /* Page.swift in Sources */, diff --git a/ios/MullvadVPN/Classes/AccessbilityIdentifier.swift b/ios/MullvadVPN/Classes/AccessbilityIdentifier.swift index 48651c39f3..9ee844b906 100644 --- a/ios/MullvadVPN/Classes/AccessbilityIdentifier.swift +++ b/ios/MullvadVPN/Classes/AccessbilityIdentifier.swift @@ -35,6 +35,8 @@ public enum AccessibilityIdentifier: String { case startUsingTheAppButton case problemReportAppLogsButton case problemReportSendButton + case relayStatusCollapseButton + case settingsDoneButton // Cells case vpnSettingsCell @@ -44,11 +46,16 @@ public enum AccessibilityIdentifier: String { case apiAccessCell case relayFilterOwnershipCell case relayFilterProviderCell + case wireGuardPortsCell + case wireGuardObfuscationCell + case udpOverTCPPortCell + case quantumResistantTunnelCell // Labels case headerDeviceNameLabel case connectionStatusLabel case welcomeAccountNumberLabel + case connectionPanelDetailLabel // Views case accountView @@ -62,17 +69,20 @@ public enum AccessibilityIdentifier: String { case selectLocationView case selectLocationTableView case settingsTableView + case vpnSettingsTableView case tunnelControlView case problemReportView case problemReportSubmittedView case revokedDeviceView case welcomeView case deleteAccountView + case settingsContainerView // Other UI elements case connectionPanelInAddressRow case connectionPanelOutAddressRow case customSwitch + case customWireGuardPortTextField case dnsContentBlockersHeaderView case loginTextField case selectLocationSearchTextField diff --git a/ios/MullvadVPN/Coordinators/ApplicationCoordinator.swift b/ios/MullvadVPN/Coordinators/ApplicationCoordinator.swift index ff113f5803..627183b1f7 100644 --- a/ios/MullvadVPN/Coordinators/ApplicationCoordinator.swift +++ b/ios/MullvadVPN/Coordinators/ApplicationCoordinator.swift @@ -777,6 +777,7 @@ final class ApplicationCoordinator: Coordinator, Presenting, RootContainerViewCo ) let navigationController = CustomNavigationController() + navigationController.view.accessibilityIdentifier = .settingsContainerView let configurationTester = ProxyConfigurationTester(transportProvider: configuredTransportProvider) diff --git a/ios/MullvadVPN/View controllers/Settings/SettingsViewController.swift b/ios/MullvadVPN/View controllers/Settings/SettingsViewController.swift index ed964a8dbe..27e89a883b 100644 --- a/ios/MullvadVPN/View controllers/Settings/SettingsViewController.swift +++ b/ios/MullvadVPN/View controllers/Settings/SettingsViewController.swift @@ -45,7 +45,8 @@ class SettingsViewController: UITableViewController, SettingsDataSourceDelegate value: "Settings", comment: "" ) - navigationItem.rightBarButtonItem = UIBarButtonItem( + + let doneButton = UIBarButtonItem( systemItem: .done, primaryAction: UIAction(handler: { [weak self] _ in guard let self else { return } @@ -53,6 +54,8 @@ class SettingsViewController: UITableViewController, SettingsDataSourceDelegate delegate?.settingsViewControllerDidFinish(self) }) ) + doneButton.accessibilityIdentifier = .settingsDoneButton + navigationItem.rightBarButtonItem = doneButton tableView.accessibilityIdentifier = .settingsTableView tableView.backgroundColor = .secondaryColor diff --git a/ios/MullvadVPN/View controllers/Tunnel/ConnectionPanelView.swift b/ios/MullvadVPN/View controllers/Tunnel/ConnectionPanelView.swift index 2e975c9c38..5389d5dee4 100644 --- a/ios/MullvadVPN/View controllers/Tunnel/ConnectionPanelView.swift +++ b/ios/MullvadVPN/View controllers/Tunnel/ConnectionPanelView.swift @@ -29,6 +29,7 @@ class ConnectionPanelView: UIView { var connectedRelayName = "" { didSet { + collapseButton.accessibilityIdentifier = .relayStatusCollapseButton collapseButton.setTitle(connectedRelayName, for: .normal) collapseButton.accessibilityLabel = NSLocalizedString( "RELAY_ACCESSIBILITY_LABEL", @@ -185,6 +186,7 @@ class ConnectionPanelAddressRow: UIView { private let detailTextLabel: UILabel = { let detailTextLabel = UILabel() + detailTextLabel.accessibilityIdentifier = .connectionPanelDetailLabel detailTextLabel.font = .systemFont(ofSize: 17) detailTextLabel.textColor = .white detailTextLabel.translatesAutoresizingMaskIntoConstraints = false diff --git a/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsCellFactory.swift b/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsCellFactory.swift index 8e6ed59018..198aca720c 100644 --- a/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsCellFactory.swift +++ b/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsCellFactory.swift @@ -97,6 +97,7 @@ final class VPNSettingsCellFactory: CellFactoryProtocol { comment: "" ) + cell.textField.accessibilityIdentifier = .customWireGuardPortTextField cell.accessibilityIdentifier = item.accessibilityIdentifier cell.applySubCellStyling() diff --git a/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsDataSource.swift b/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsDataSource.swift index f42d0d343b..a87f2ebd96 100644 --- a/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsDataSource.swift +++ b/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsDataSource.swift @@ -446,6 +446,7 @@ final class VPNSettingsDataSource: UITableViewDiffableDataSource< comment: "" ) + header.accessibilityIdentifier = .wireGuardPortsCell header.titleLabel.text = title header.accessibilityCustomActionName = title header.infoButtonHandler = { [weak self] in @@ -488,6 +489,7 @@ final class VPNSettingsDataSource: UITableViewDiffableDataSource< comment: "" ) + header.accessibilityIdentifier = .wireGuardObfuscationCell header.titleLabel.text = title header.accessibilityCustomActionName = title header.didCollapseHandler = { [weak self] header in @@ -516,6 +518,7 @@ final class VPNSettingsDataSource: UITableViewDiffableDataSource< comment: "" ) + header.accessibilityIdentifier = .udpOverTCPPortCell header.titleLabel.text = title header.accessibilityCustomActionName = title header.didCollapseHandler = { [weak self] header in @@ -545,6 +548,7 @@ final class VPNSettingsDataSource: UITableViewDiffableDataSource< comment: "" ) + header.accessibilityIdentifier = .quantumResistantTunnelCell header.titleLabel.text = title header.accessibilityCustomActionName = title header.didCollapseHandler = { [weak self] header in diff --git a/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsViewController.swift b/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsViewController.swift index 3be523ef2a..3d55f6fd0e 100644 --- a/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsViewController.swift +++ b/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsViewController.swift @@ -38,6 +38,7 @@ class VPNSettingsViewController: UITableViewController, VPNSettingsDataSourceDel override func viewDidLoad() { super.viewDidLoad() + tableView.accessibilityIdentifier = .vpnSettingsTableView tableView.backgroundColor = .secondaryColor tableView.separatorColor = .secondaryColor tableView.rowHeight = UITableView.automaticDimension diff --git a/ios/MullvadVPNUITests/Info.plist b/ios/MullvadVPNUITests/Info.plist index dd7e65bae2..0bed909d3f 100644 --- a/ios/MullvadVPNUITests/Info.plist +++ b/ios/MullvadVPNUITests/Info.plist @@ -20,6 +20,8 @@ <string>$(IOS_DEVICE_PIN_CODE)</string> <key>NoTimeAccountNumber</key> <string>$(NO_TIME_ACCOUNT_NUMBER)</string> + <key>ShouldBeReachableDomain</key> + <string>$(SHOULD_BE_REACHABLE_DOMAIN)</string> <key>TestDeviceIdentifier</key> <string>$(TEST_DEVICE_IDENTIFIER_UUID</string> </dict> diff --git a/ios/MullvadVPNUITests/Networking/FirewallAPIClient.swift b/ios/MullvadVPNUITests/Networking/FirewallAPIClient.swift index 49496f3d2e..0577e58e4e 100644 --- a/ios/MullvadVPNUITests/Networking/FirewallAPIClient.swift +++ b/ios/MullvadVPNUITests/Networking/FirewallAPIClient.swift @@ -34,6 +34,7 @@ class FirewallAPIClient { "label": sessionIdentifier, "from": firewallRule.fromIPAddress, "to": firewallRule.toIPAddress, + "protocols": firewallRule.protocolsAsStringArray(), ] var requestError: Error? @@ -80,7 +81,6 @@ class FirewallAPIClient { var request = URLRequest(url: removeRulesURL) request.httpMethod = "DELETE" - request.setValue("application/json", forHTTPHeaderField: "Content-Type") var requestResponse: URLResponse? var requestError: Error? diff --git a/ios/MullvadVPNUITests/Networking/FirewallRule.swift b/ios/MullvadVPNUITests/Networking/FirewallRule.swift index d89dbc6853..1b18baa91c 100644 --- a/ios/MullvadVPNUITests/Networking/FirewallRule.swift +++ b/ios/MullvadVPNUITests/Networking/FirewallRule.swift @@ -30,6 +30,10 @@ struct FirewallRule { self.protocols = protocols } + public func protocolsAsStringArray() -> [String] { + return protocols.map { $0.rawValue } + } + /// Make a firewall rule blocking API access for the current device under test public static func makeBlockAPIAccessFirewallRule() throws -> FirewallRule { let deviceIPAddress = try Networking.getIPAddress() @@ -37,7 +41,17 @@ struct FirewallRule { return FirewallRule( fromIPAddress: deviceIPAddress, toIPAddress: apiIPAddress, - protocols: [NetworkingProtocol.TCP] + protocols: [.TCP] + ) + } + + public static func makeBlockUDPTrafficRule(toIPAddress: String) throws -> FirewallRule { + let deviceIPAddress = try Networking.getIPAddress() + + return FirewallRule( + fromIPAddress: deviceIPAddress, + toIPAddress: toIPAddress, + protocols: [.UDP] ) } } diff --git a/ios/MullvadVPNUITests/Networking/Networking.swift b/ios/MullvadVPNUITests/Networking/Networking.swift index e8a038b4a2..67e2156603 100644 --- a/ios/MullvadVPNUITests/Networking/Networking.swift +++ b/ios/MullvadVPNUITests/Networking/Networking.swift @@ -63,24 +63,24 @@ class Networking { 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 + /// Get configured ad serving domain + private static func getAdServingDomain() throws -> String { + guard let adServingDomain = Bundle(for: Networking.self) + .infoDictionary?["AdServingDomain"] as? String else { + throw NetworkingError.notConfiguredError } - return adServingDomainURL + return adServingDomain } - private static func getAdServingDomain() throws -> String { - guard let adServingDomain = Bundle(for: BaseUITestCase.self) - .infoDictionary?["AdServingDomain"] as? String else { + /// Get configured domain to use for Internet connectivity checks + private static func getAlwaysReachableDomain() throws -> String { + guard let shouldBeReachableDomain = Bundle(for: Networking.self) + .infoDictionary?["ShouldBeReachableDomain"] as? String else { throw NetworkingError.notConfiguredError } - return adServingDomain + return shouldBeReachableDomain } /// Check whether host and port is reachable by attempting to connect a socket @@ -134,6 +134,16 @@ class Networking { XCTAssertFalse(try canConnectSocket(host: apiIPAddress, port: apiPort)) } + /// Verify that the device has Internet connectivity + public static func verifyCanAccessInternet() throws { + XCTAssertTrue(try canConnectSocket(host: getAlwaysReachableDomain(), port: "80")) + } + + /// Verify that the device does not have Internet connectivity + public static func verifyCannotAccessInternet() throws { + XCTAssertFalse(try canConnectSocket(host: getAlwaysReachableDomain(), port: "80")) + } + /// Verify that an ad serving domain is reachable by making sure a connection can be established on port 80 public static func verifyCanReachAdServingDomain() throws { XCTAssertTrue(try Self.canConnectSocket(host: try Self.getAdServingDomain(), port: "80")) diff --git a/ios/MullvadVPNUITests/Pages/Page.swift b/ios/MullvadVPNUITests/Pages/Page.swift index 453bd57375..77fbebadae 100644 --- a/ios/MullvadVPNUITests/Pages/Page.swift +++ b/ios/MullvadVPNUITests/Pages/Page.swift @@ -31,6 +31,11 @@ class Page { return self } + @discardableResult func dismissKeyboard() -> Self { + self.enterText("\n") + return self + } + /// Fast swipe down action to dismiss a modal view. Will swipe on the middle of the screen. @discardableResult func swipeDownToDismissModal() -> Self { app.swipeDown(velocity: .fast) diff --git a/ios/MullvadVPNUITests/Pages/SelectLocationPage.swift b/ios/MullvadVPNUITests/Pages/SelectLocationPage.swift index c5c54325ad..ba15a7323f 100644 --- a/ios/MullvadVPNUITests/Pages/SelectLocationPage.swift +++ b/ios/MullvadVPNUITests/Pages/SelectLocationPage.swift @@ -22,7 +22,7 @@ class SelectLocationPage: Page { return self } - @discardableResult func tapLocationCellExpandButton(withName name: String) -> Self { + @discardableResult func tapLocationCellExpandCollapseButton(withName name: String) -> Self { let table = app.tables[AccessibilityIdentifier.selectLocationTableView] let matchingCells = table.cells.containing(.any, identifier: name) let buttons = matchingCells.buttons diff --git a/ios/MullvadVPNUITests/Pages/SettingsPage.swift b/ios/MullvadVPNUITests/Pages/SettingsPage.swift index 43ad3dedd5..b65ca02f81 100644 --- a/ios/MullvadVPNUITests/Pages/SettingsPage.swift +++ b/ios/MullvadVPNUITests/Pages/SettingsPage.swift @@ -13,10 +13,17 @@ class SettingsPage: Page { @discardableResult override init(_ app: XCUIApplication) { super.init(app) - self.pageAccessibilityIdentifier = .settingsTableView + self.pageAccessibilityIdentifier = .settingsContainerView waitForPageToBeShown() } + @discardableResult func tapDoneButton() -> Self { + app.buttons[AccessibilityIdentifier.settingsDoneButton] + .tap() + + return self + } + @discardableResult func tapVPNSettingsCell() -> Self { app.tables[AccessibilityIdentifier.settingsTableView] .cells[AccessibilityIdentifier.vpnSettingsCell] diff --git a/ios/MullvadVPNUITests/Pages/TunnelControlPage.swift b/ios/MullvadVPNUITests/Pages/TunnelControlPage.swift index 37813b36c4..daef1a30e7 100644 --- a/ios/MullvadVPNUITests/Pages/TunnelControlPage.swift +++ b/ios/MullvadVPNUITests/Pages/TunnelControlPage.swift @@ -10,6 +10,61 @@ import Foundation import XCTest class TunnelControlPage: Page { + private struct ConnectionAttempt: Hashable { + let ipAddress: String + let port: String + let protocolName: String + } + + /// Poll the "in address row" label for its updated values and output an array of ConnectionAttempt objects representing the connection attempts that have been communicated through the UI. + /// - Parameters: + /// - attemptsCount: number of connection attempts to look for + /// - timeout: timeout after this many seconds if attemptsCount haven't been reached yet + private func waitForConnectionAttempts(_ attemptsCount: Int, timeout: TimeInterval) -> [ConnectionAttempt] { + var connectionAttempts: [ConnectionAttempt] = [] + let startTime = Date() + let pollingInterval = TimeInterval(0.5) // How often to check for changes + + let inAddressRow = app.otherElements[AccessibilityIdentifier.connectionPanelInAddressRow] + + while Date().timeIntervalSince(startTime) < timeout { + let expectation = XCTestExpectation(description: "Wait for connection attempts") + + DispatchQueue.global().asyncAfter(deadline: .now() + pollingInterval) { + expectation.fulfill() + } + + _ = XCTWaiter.wait(for: [expectation], timeout: pollingInterval + 0.5) + + if let currentText = inAddressRow.value as? String { + // Skip initial label value with IP address only - no port or protocol + guard currentText.contains(" ") == true else { + continue + } + + let addressPortComponent = currentText.components(separatedBy: " ")[0] + let ipAddress = addressPortComponent.components(separatedBy: ":")[0] + let port = addressPortComponent.components(separatedBy: ":")[1] + let protocolName = currentText.components(separatedBy: " ")[1] + let connectionAttempt = ConnectionAttempt( + ipAddress: ipAddress, + port: port, + protocolName: protocolName + ) + + if connectionAttempts.contains(connectionAttempt) == false { + connectionAttempts.append(connectionAttempt) + + if connectionAttempts.count == attemptsCount { + break + } + } + } + } + + return connectionAttempts + } + @discardableResult override init(_ app: XCUIApplication) { super.init(app) @@ -27,9 +82,71 @@ class TunnelControlPage: Page { return self } + @discardableResult func tapDisconnectButton() -> Self { + app.buttons[AccessibilityIdentifier.disconnectButton].tap() + return self + } + @discardableResult func waitForSecureConnectionLabel() -> Self { _ = app.staticTexts[AccessibilityIdentifier.connectionStatusLabel] .waitForExistence(timeout: BaseUITestCase.defaultTimeout) return self } + + @discardableResult func tapRelayStatusExpandCollapseButton() -> Self { + app.buttons[AccessibilityIdentifier.relayStatusCollapseButton].tap() + return self + } + + /// Verify that the app attempts to connect over UDP before switching to TCP. For testing blocked UDP traffic. + @discardableResult func verifyConnectingOverTCPAfterUDPAttempts() -> Self { + let connectionAttempts = waitForConnectionAttempts(3, timeout: 15) + + // Should do three connection attempts but due to UI bug sometimes only two are displayed, sometimes all three + if connectionAttempts.count == 3 { // Expected retries flow + for (attemptIndex, attempt) in connectionAttempts.enumerated() { + if attemptIndex == 0 || attemptIndex == 1 { + XCTAssertEqual(attempt.protocolName, "UDP") + } else if attemptIndex == 2 { + XCTAssertEqual(attempt.protocolName, "TCP") + } else { + XCTFail("Unexpected connection attempt") + } + } + } else if connectionAttempts.count == 2 { // Most of the times this incorrect flow is shown + for (attemptIndex, attempt) in connectionAttempts.enumerated() { + if attemptIndex == 0 { + XCTAssertEqual(attempt.protocolName, "UDP") + } else if attemptIndex == 1 { + XCTAssertEqual(attempt.protocolName, "TCP") + } else { + XCTFail("Unexpected connection attempt") + } + } + } else { + XCTFail("Unexpected number of connection attempts") + } + + return self + } + + @discardableResult func verifyConnectingToPort(_ port: String) -> Self { + let connectionAttempts = waitForConnectionAttempts(1, timeout: 10) + XCTAssertEqual(connectionAttempts.count, 1) + XCTAssertEqual(connectionAttempts.first!.port, port) + + return self + } + + func getInIPAddressFromConnectionStatus() -> String { + let inAddressRow = app.otherElements[AccessibilityIdentifier.connectionPanelInAddressRow] + + if let textValue = inAddressRow.value as? String { + let ipAddress = textValue.components(separatedBy: ":")[0] + return ipAddress + } else { + XCTFail("Failed to read relay IP address from status label") + return String() + } + } } diff --git a/ios/MullvadVPNUITests/Pages/VPNSettingsPage.swift b/ios/MullvadVPNUITests/Pages/VPNSettingsPage.swift new file mode 100644 index 0000000000..22ad20540b --- /dev/null +++ b/ios/MullvadVPNUITests/Pages/VPNSettingsPage.swift @@ -0,0 +1,79 @@ +// +// VPNSettingsPage.swift +// MullvadVPNUITests +// +// Created by Niklas Berglund on 2024-03-04. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import Foundation +import XCTest + +class VPNSettingsPage: Page { + @discardableResult override init(_ app: XCUIApplication) { + super.init(app) + } + + private func cellExpandCollapseButton(_ cellAccessiblityIdentifier: AccessibilityIdentifier) -> XCUIElement { + let table = app.tables[AccessibilityIdentifier.vpnSettingsTableView] + let matchingCells = table.otherElements.containing(.any, identifier: cellAccessiblityIdentifier.rawValue) + let expandButton = matchingCells.buttons[AccessibilityIdentifier.collapseButton] + + return expandButton + } + + @discardableResult func tapBackButton() -> Self { + // Workaround for setting accessibility identifier on navigation bar button being non-trivial + app.buttons.matching(identifier: "Settings").allElementsBoundByIndex.last?.tap() + return self + } + + @discardableResult func tapWireGuardPortsExpandButton() -> Self { + cellExpandCollapseButton(AccessibilityIdentifier.wireGuardPortsCell).tap() + + return self + } + + @discardableResult func tapWireGuardObfuscationExpandButton() -> Self { + cellExpandCollapseButton(AccessibilityIdentifier.wireGuardObfuscationCell).tap() + + return self + } + + @discardableResult func tapUDPOverTCPPortExpandButton() -> Self { + cellExpandCollapseButton(AccessibilityIdentifier.udpOverTCPPortCell).tap() + + return self + } + + @discardableResult func tapQuantumResistantTunnelExpandButton() -> Self { + cellExpandCollapseButton(AccessibilityIdentifier.quantumResistantTunnelCell).tap() + + return self + } + + @discardableResult func tapWireGuardObfuscationAutomaticCell() -> Self { + app.cells[AccessibilityIdentifier.wireGuardObfuscationAutomatic] + .tap() + + return self + } + + @discardableResult func tapWireGuardObfuscationOnCell() -> Self { + app.cells[AccessibilityIdentifier.wireGuardObfuscationOn].tap() + + return self + } + + @discardableResult func tapWireGuardObfuscationOffCell() -> Self { + app.cells[AccessibilityIdentifier.wireGuardObfuscationOff].tap() + + return self + } + + @discardableResult func tapCustomWireGuardPortTextField() -> Self { + app.textFields[AccessibilityIdentifier.customWireGuardPortTextField].tap() + + return self + } +} diff --git a/ios/MullvadVPNUITests/RelayTests.swift b/ios/MullvadVPNUITests/RelayTests.swift index a186bd7548..b90d158ba7 100644 --- a/ios/MullvadVPNUITests/RelayTests.swift +++ b/ios/MullvadVPNUITests/RelayTests.swift @@ -10,6 +10,22 @@ import Foundation import XCTest class RelayTests: LoggedInWithTimeUITestCase { + var removeFirewallRulesInTearDown = false + + override func setUp() { + super.setUp() + + removeFirewallRulesInTearDown = false + } + + override func tearDown() { + super.tearDown() + + if removeFirewallRulesInTearDown { + FirewallAPIClient().removeRules() + } + } + func testAdBlockingViaDNS() throws { HeaderBar(app) .tapSettingsButton() @@ -30,5 +46,117 @@ class RelayTests: LoggedInWithTimeUITestCase { .waitForSecureConnectionLabel() try Networking.verifyCannotReachAdServingDomain() + + TunnelControlPage(app) + .tapDisconnectButton() + } + + func testWireGuardOverTCPManually() throws { + HeaderBar(app) + .tapSettingsButton() + + SettingsPage(app) + .tapVPNSettingsCell() + + VPNSettingsPage(app) + .tapWireGuardObfuscationExpandButton() + .tapWireGuardObfuscationOnCell() + .tapBackButton() + + SettingsPage(app) + .tapDoneButton() + + TunnelControlPage(app) + .tapSecureConnectionButton() + + allowAddVPNConfigurationsIfAsked() + + TunnelControlPage(app) + .waitForSecureConnectionLabel() + + try Networking.verifyCanAccessInternet() + + TunnelControlPage(app) + .tapDisconnectButton() + } + + /// Test automatic switching to TCP is functioning when UDP traffic to relay is blocked. This test first connects to a realy to get the IP address of it, in order to block UDP traffic to this relay. + func testWireGuardOverTCPAutomatically() throws { + let wireGuardGot001RelayName = "se-got-wg-001" + + FirewallAPIClient().removeRules() + removeFirewallRulesInTearDown = true + + // First get relay IP address + TunnelControlPage(app) + .tapSelectLocationButton() + + SelectLocationPage(app) + .tapLocationCellExpandCollapseButton(withName: "Sweden") + .tapLocationCellExpandCollapseButton(withName: "Gothenburg") + .tapLocationCell(withName: wireGuardGot001RelayName) + + allowAddVPNConfigurationsIfAsked() + + let relayIPAddress = TunnelControlPage(app) + .waitForSecureConnectionLabel() + .tapRelayStatusExpandCollapseButton() + .getInIPAddressFromConnectionStatus() + + TunnelControlPage(app) + .tapDisconnectButton() + + // Run actual test + try FirewallAPIClient().createRule( + FirewallRule.makeBlockUDPTrafficRule(toIPAddress: relayIPAddress) + ) + + HeaderBar(app) + .tapSettingsButton() + + SettingsPage(app) + .tapVPNSettingsCell() + + VPNSettingsPage(app) + .tapWireGuardObfuscationExpandButton() + .tapWireGuardObfuscationAutomaticCell() + .tapBackButton() + + SettingsPage(app) + .tapDoneButton() + + TunnelControlPage(app) + .tapSecureConnectionButton() + + // Should be two UDP connection attempts but sometimes only one is shown in the UI + TunnelControlPage(app) + .verifyConnectingOverTCPAfterUDPAttempts() + .waitForSecureConnectionLabel() + .tapDisconnectButton() + } + + func testWireGuardPortSettings() throws { + HeaderBar(app) + .tapSettingsButton() + + SettingsPage(app) + .tapVPNSettingsCell() + + VPNSettingsPage(app) + .tapWireGuardPortsExpandButton() + .tapCustomWireGuardPortTextField() + .enterText("4001") + .dismissKeyboard() + .swipeDownToDismissModal() + + TunnelControlPage(app) + .tapSecureConnectionButton() + + allowAddVPNConfigurationsIfAsked() + + TunnelControlPage(app) + .tapRelayStatusExpandCollapseButton() + .verifyConnectingToPort("4001") + .tapDisconnectButton() } } diff --git a/ios/MullvadVPNUITests/Test base classes/BaseUITestCase.swift b/ios/MullvadVPNUITests/Test base classes/BaseUITestCase.swift index 2e4a252c76..be7d517d54 100644 --- a/ios/MullvadVPNUITests/Test base classes/BaseUITestCase.swift +++ b/ios/MullvadVPNUITests/Test base classes/BaseUITestCase.swift @@ -12,6 +12,7 @@ import XCTest class BaseUITestCase: XCTestCase { let app = XCUIApplication() static let defaultTimeout = 5.0 + static let shortTimeout = 1.0 // swiftlint:disable force_cast let displayName = Bundle(for: BaseUITestCase.self) @@ -41,6 +42,15 @@ class BaseUITestCase: XCTestCase { } } + /// Handle iOS add VPN configuration permission alert if presented, otherwise ignore + func allowAddVPNConfigurationsIfAsked() { + let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard") + + if springboard.buttons["Allow"].waitForExistence(timeout: Self.shortTimeout) { + allowAddVPNConfigurations() + } + } + // MARK: - Setup & teardown /// Suite level teardown ran after test have executed @@ -62,10 +72,16 @@ class BaseUITestCase: XCTestCase { /// Check if currently logged on to an account. Note that it is assumed that we are logged in if login view isn't currently shown. func isLoggedIn() -> Bool { return !app - .otherElements[AccessibilityIdentifier.loginView.rawValue] + .otherElements[AccessibilityIdentifier.loginView] .waitForExistence(timeout: 1.0) } + func isPresentingSettings() -> Bool { + return app + .otherElements[AccessibilityIdentifier.settingsContainerView] + .exists + } + func agreeToTermsOfServiceIfShown() { let termsOfServiceIsShown = app.otherElements[ AccessibilityIdentifier @@ -101,10 +117,14 @@ class BaseUITestCase: XCTestCase { func logoutIfLoggedIn() { if isLoggedIn() { + if isPresentingSettings() { + SettingsPage(app) + .swipeDownToDismissModal() + } + if app.buttons[AccessibilityIdentifier.accountButton].exists { HeaderBar(app) .tapAccountButton() - AccountPage(app) .tapLogOutButton() } else { |
