From 7559def96986669426ecbe4bddb217db68af3921 Mon Sep 17 00:00:00 2001 From: Niklas Berglund Date: Tue, 30 Apr 2024 13:00:55 +0200 Subject: [PATCH] Implement basic leak tests --- ios/Configurations/UITests.xcconfig.template | 5 +- ios/MullvadVPN.xcodeproj/project.pbxproj | 47 ++- .../xcshareddata/swiftpm/Package.resolved | 22 -- .../Base/BaseUITestCase.swift | 62 +++- ios/MullvadVPNUITests/ConnectivityTests.swift | 2 +- ios/MullvadVPNUITests/Info.plist | 4 + ios/MullvadVPNUITests/LeakTests.swift | 88 +++++ ...llAPIClient.swift => FirewallClient.swift} | 50 +-- .../Networking/FirewallRule.swift | 16 +- .../Networking/LeakCheck.swift | 45 +++ .../Networking/Networking.swift | 36 +- .../Networking/PacketCapture.swift | 333 ++++++++++++++++++ .../Networking/TestRouterAPIClient.swift | 56 +++ .../Networking/TrafficGenerator.swift | 74 ++++ ios/MullvadVPNUITests/RelayTests.swift | 10 +- ios/MullvadVPNUITests/tests.json | 1 + 16 files changed, 743 insertions(+), 108 deletions(-) delete mode 100644 ios/MullvadVPN.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 ios/MullvadVPNUITests/LeakTests.swift rename ios/MullvadVPNUITests/Networking/{FirewallAPIClient.swift => FirewallClient.swift} (68%) create mode 100644 ios/MullvadVPNUITests/Networking/LeakCheck.swift create mode 100644 ios/MullvadVPNUITests/Networking/PacketCapture.swift create mode 100644 ios/MullvadVPNUITests/Networking/TestRouterAPIClient.swift create mode 100644 ios/MullvadVPNUITests/Networking/TrafficGenerator.swift diff --git a/ios/Configurations/UITests.xcconfig.template b/ios/Configurations/UITests.xcconfig.template index af31a89b2664..dc0a19be2216 100644 --- a/ios/Configurations/UITests.xcconfig.template +++ b/ios/Configurations/UITests.xcconfig.template @@ -24,7 +24,7 @@ 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 +// 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 // URL for Mullvad provided JSON data with information about the connection. https://am.i.mullvad.net/json for production, https://am.i.stagemole.eu/json for staging. @@ -32,3 +32,6 @@ AM_I_JSON_URL = https:/${}/am.i.stagemole.eu/json // Specify whether app logs should be extracted and attached to test report for failing tests ATTACH_APP_LOGS_ON_FAILURE = 0 + +// Base URL for the packet capture 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 +PACKET_CAPTURE_BASE_URL = http:/${}/8.8.8.8 diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj index fe09f04dafcd..b24b9659d635 100644 --- a/ios/MullvadVPN.xcodeproj/project.pbxproj +++ b/ios/MullvadVPN.xcodeproj/project.pbxproj @@ -659,10 +659,10 @@ 7AF9BE902A39F26000DBFEDB /* Collection+Sorting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AF9BE8F2A39F26000DBFEDB /* Collection+Sorting.swift */; }; 7AF9BE952A40461100DBFEDB /* RelayFilterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AF9BE942A40461100DBFEDB /* RelayFilterView.swift */; }; 7AF9BE972A41C71F00DBFEDB /* ChipViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AF9BE962A41C71F00DBFEDB /* ChipViewCell.swift */; }; - 7AFBE38B2D09AAFF002335FC /* SinglehopPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AFBE38A2D09AAFF002335FC /* SinglehopPicker.swift */; }; - 7AFBE38D2D09AB2E002335FC /* MultihopPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AFBE38C2D09AB2E002335FC /* MultihopPicker.swift */; }; 7AFBE3872D084C9D002335FC /* ActivityIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AFBE3862D084C96002335FC /* ActivityIndicator.swift */; }; 7AFBE3892D089163002335FC /* FI_TunnelViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AFBE3882D08915D002335FC /* FI_TunnelViewController.swift */; }; + 7AFBE38B2D09AAFF002335FC /* SinglehopPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AFBE38A2D09AAFF002335FC /* SinglehopPicker.swift */; }; + 7AFBE38D2D09AB2E002335FC /* MultihopPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AFBE38C2D09AB2E002335FC /* MultihopPicker.swift */; }; 850201DB2B503D7700EF8C96 /* RelayTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 850201DA2B503D7700EF8C96 /* RelayTests.swift */; }; 850201DD2B503D8C00EF8C96 /* SelectLocationPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 850201DC2B503D8C00EF8C96 /* SelectLocationPage.swift */; }; 850201DF2B5040A500EF8C96 /* TunnelControlPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 850201DE2B5040A500EF8C96 /* TunnelControlPage.swift */; }; @@ -680,21 +680,25 @@ 8532E6872B8CCED600ACECD1 /* ProblemReportSubmittedPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8532E6862B8CCED600ACECD1 /* ProblemReportSubmittedPage.swift */; }; 8542CE242B95F7B9006FCA14 /* VPNSettingsPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8542CE232B95F7B9006FCA14 /* VPNSettingsPage.swift */; }; 8542F7532BCFBD050035C042 /* SelectLocationFilterPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8542F7522BCFBD050035C042 /* SelectLocationFilterPage.swift */; }; - 85557B0E2B591B2600795FE1 /* FirewallAPIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85557B0D2B591B2600795FE1 /* FirewallAPIClient.swift */; }; + 85557B0E2B591B2600795FE1 /* FirewallClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85557B0D2B591B2600795FE1 /* FirewallClient.swift */; }; 85557B102B59215F00795FE1 /* FirewallRule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85557B0F2B59215F00795FE1 /* FirewallRule.swift */; }; 85557B122B594FC900795FE1 /* ConnectivityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85557B112B594FC900795FE1 /* ConnectivityTests.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 */; }; + 8555C6602D1030040092DAD0 /* LeakCheck.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8555C65F2D102FFE0092DAD0 /* LeakCheck.swift */; }; 8556EB522B9A1C6900D26DD4 /* MullvadApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8556EB512B9A1C6900D26DD4 /* MullvadApi.swift */; }; 8556EB542B9A1D7100D26DD4 /* BridgingHeader.h in Headers */ = {isa = PBXBuildFile; fileRef = 8556EB532B9A1D7100D26DD4 /* BridgingHeader.h */; }; 8556EB562B9B0AC500D26DD4 /* RevokedDevicePage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8556EB552B9B0AC500D26DD4 /* RevokedDevicePage.swift */; }; 855D9F5B2B63E56B00D7C64D /* ProblemReportPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 855D9F5A2B63E56B00D7C64D /* ProblemReportPage.swift */; }; + 85607C892D131CD500037E34 /* TestRouterAPIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85607C882D131CCD00037E34 /* TestRouterAPIClient.swift */; }; 856952DC2BD2922A008C1F84 /* PartnerAPIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 856952DB2BD2922A008C1F84 /* PartnerAPIClient.swift */; }; 856952E22BD6B04C008C1F84 /* XCUIElement+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 856952E12BD6B04C008C1F84 /* XCUIElement+Extensions.swift */; }; 8585CBE32BC684180015B6A4 /* EditAccessMethodPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8585CBE22BC684180015B6A4 /* EditAccessMethodPage.swift */; }; 8587A05D2B84D43100152938 /* ChangeLogAlert.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8587A05C2B84D43100152938 /* ChangeLogAlert.swift */; }; 8590896F2B61763B003AF5F5 /* LoggedOutUITestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8590896B2B61763B003AF5F5 /* LoggedOutUITestCase.swift */; }; + 8590A5442C2AF43400B9BF7B /* TrafficGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8590A5432C2AF43400B9BF7B /* TrafficGenerator.swift */; }; + 85978A542BE0F10E00F999A7 /* PacketCapture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85978A532BE0F10E00F999A7 /* PacketCapture.swift */; }; 85A42B882BB44D31007BABF7 /* DeviceManagementPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85A42B872BB44D31007BABF7 /* DeviceManagementPage.swift */; }; 85B267612B849ADB0098E3CD /* mullvad-api.h in Headers */ = {isa = PBXBuildFile; fileRef = 85B267602B849ADB0098E3CD /* mullvad-api.h */; }; 85C7A2E92B89024B00035D5A /* SettingsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85C7A2E82B89024B00035D5A /* SettingsTests.swift */; }; @@ -702,6 +706,7 @@ 85D2B0B12B6BD32400DF9DA7 /* BaseUITestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8590896A2B61763B003AF5F5 /* BaseUITestCase.swift */; }; 85E3BDE52B70E18C00FA71FD /* Networking.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85E3BDE42B70E18C00FA71FD /* Networking.swift */; }; 85EC620C2B838D10005AFFB5 /* MullvadAPIWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85557B132B5983CF00795FE1 /* MullvadAPIWrapper.swift */; }; + 85F1E17E2C0A256200DB8F55 /* LeakTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85F1E17D2C0A256200DB8F55 /* LeakTests.swift */; }; 85FB5A0C2B6903990015DCED /* WelcomePage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85FB5A0B2B6903990015DCED /* WelcomePage.swift */; }; 85FB5A102B6960A30015DCED /* AccountDeletionPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85FB5A0F2B6960A30015DCED /* AccountDeletionPage.swift */; }; A90763B02B2857D50045ADF0 /* Socks5ConnectCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = A90763A02B2857D50045ADF0 /* Socks5ConnectCommand.swift */; }; @@ -2020,10 +2025,10 @@ 7AF9BE8F2A39F26000DBFEDB /* Collection+Sorting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Collection+Sorting.swift"; sourceTree = ""; }; 7AF9BE942A40461100DBFEDB /* RelayFilterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayFilterView.swift; sourceTree = ""; }; 7AF9BE962A41C71F00DBFEDB /* ChipViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChipViewCell.swift; sourceTree = ""; }; - 7AFBE38A2D09AAFF002335FC /* SinglehopPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SinglehopPicker.swift; sourceTree = ""; }; - 7AFBE38C2D09AB2E002335FC /* MultihopPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultihopPicker.swift; sourceTree = ""; }; 7AFBE3862D084C96002335FC /* ActivityIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityIndicator.swift; sourceTree = ""; }; 7AFBE3882D08915D002335FC /* FI_TunnelViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FI_TunnelViewController.swift; sourceTree = ""; }; + 7AFBE38A2D09AAFF002335FC /* SinglehopPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SinglehopPicker.swift; sourceTree = ""; }; + 7AFBE38C2D09AB2E002335FC /* MultihopPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultihopPicker.swift; sourceTree = ""; }; 85006A8E2B73EF67004AD8FB /* MullvadVPNUITestsSmoke.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = MullvadVPNUITestsSmoke.xctestplan; sourceTree = ""; }; 850201DA2B503D7700EF8C96 /* RelayTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayTests.swift; sourceTree = ""; }; 850201DC2B503D8C00EF8C96 /* SelectLocationPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectLocationPage.swift; sourceTree = ""; }; @@ -2046,17 +2051,19 @@ 8532E6862B8CCED600ACECD1 /* ProblemReportSubmittedPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProblemReportSubmittedPage.swift; sourceTree = ""; }; 8542CE232B95F7B9006FCA14 /* VPNSettingsPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VPNSettingsPage.swift; sourceTree = ""; }; 8542F7522BCFBD050035C042 /* SelectLocationFilterPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectLocationFilterPage.swift; sourceTree = ""; }; - 85557B0D2B591B2600795FE1 /* FirewallAPIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirewallAPIClient.swift; sourceTree = ""; }; + 85557B0D2B591B2600795FE1 /* FirewallClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirewallClient.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 = ""; }; + 8555C65F2D102FFE0092DAD0 /* LeakCheck.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LeakCheck.swift; sourceTree = ""; }; 8556EB512B9A1C6900D26DD4 /* MullvadApi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MullvadApi.swift; path = MullvadVPNUITests/MullvadApi.swift; sourceTree = ""; }; 8556EB532B9A1D7100D26DD4 /* BridgingHeader.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = BridgingHeader.h; sourceTree = ""; }; 8556EB552B9B0AC500D26DD4 /* RevokedDevicePage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RevokedDevicePage.swift; sourceTree = ""; }; 855D9F5A2B63E56B00D7C64D /* ProblemReportPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProblemReportPage.swift; sourceTree = ""; }; + 85607C882D131CCD00037E34 /* TestRouterAPIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestRouterAPIClient.swift; sourceTree = ""; }; 856952DB2BD2922A008C1F84 /* PartnerAPIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PartnerAPIClient.swift; sourceTree = ""; }; 856952E12BD6B04C008C1F84 /* XCUIElement+Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "XCUIElement+Extensions.swift"; sourceTree = ""; }; 8585CBE22BC684180015B6A4 /* EditAccessMethodPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditAccessMethodPage.swift; sourceTree = ""; }; @@ -2064,11 +2071,14 @@ 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 = ""; }; + 8590A5432C2AF43400B9BF7B /* TrafficGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrafficGenerator.swift; sourceTree = ""; }; + 85978A532BE0F10E00F999A7 /* PacketCapture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PacketCapture.swift; sourceTree = ""; }; 85A42B872BB44D31007BABF7 /* DeviceManagementPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceManagementPage.swift; sourceTree = ""; }; 85B267602B849ADB0098E3CD /* mullvad-api.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = "mullvad-api.h"; path = "../../mullvad-api/include/mullvad-api.h"; sourceTree = ""; }; 85C7A2E82B89024B00035D5A /* SettingsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsTests.swift; sourceTree = ""; }; 85D039972BA4711800940E7F /* SettingsMigrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsMigrationTests.swift; sourceTree = ""; }; 85E3BDE42B70E18C00FA71FD /* Networking.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Networking.swift; sourceTree = ""; }; + 85F1E17D2C0A256200DB8F55 /* LeakTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LeakTests.swift; sourceTree = ""; }; 85FB5A0B2B6903990015DCED /* WelcomePage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomePage.swift; sourceTree = ""; }; 85FB5A0F2B6960A30015DCED /* AccountDeletionPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountDeletionPage.swift; sourceTree = ""; }; A900E9B72ACC5C2B00C95F67 /* AccountsProxy+Stubs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AccountsProxy+Stubs.swift"; sourceTree = ""; }; @@ -3928,7 +3938,6 @@ 7A0EAE982D01B29E00D3EB8B /* Recovered References */ = { isa = PBXGroup; children = ( - 7AA1309C2D0072F900640DF9 /* View+Size.swift */, ); name = "Recovered References"; sourceTree = ""; @@ -4145,21 +4154,22 @@ 852969262B4D9C1F007EAD4C /* MullvadVPNUITests */ = { isa = PBXGroup; children = ( - 85557B0C2B591B0F00795FE1 /* Networking */, - 852969312B4E9220007EAD4C /* Pages */, - 7A45CFCD2C08697100D80B21 /* Screenshots */, - 852969372B4ED20E007EAD4C /* Info.plist */, 8556EB532B9A1D7100D26DD4 /* BridgingHeader.h */, 85B267602B849ADB0098E3CD /* mullvad-api.h */, + 852969372B4ED20E007EAD4C /* Info.plist */, 852969272B4D9C1F007EAD4C /* AccountTests.swift */, 85557B112B594FC900795FE1 /* ConnectivityTests.swift */, A9BFAFFE2BD004ED00F2BCA1 /* CustomListsTests.swift */, + 85F1E17D2C0A256200DB8F55 /* LeakTests.swift */, 850201DA2B503D7700EF8C96 /* RelayTests.swift */, 85D039972BA4711800940E7F /* SettingsMigrationTests.swift */, 85C7A2E82B89024B00035D5A /* SettingsTests.swift */, - 8518F6392B601910009EB113 /* Base */, 856952E12BD6B04C008C1F84 /* XCUIElement+Extensions.swift */, 85557B152B5ABBBE00795FE1 /* XCUIElementQuery+Extensions.swift */, + 8518F6392B601910009EB113 /* Base */, + 85557B0C2B591B0F00795FE1 /* Networking */, + 852969312B4E9220007EAD4C /* Pages */, + 7A45CFCD2C08697100D80B21 /* Screenshots */, ); path = MullvadVPNUITests; sourceTree = ""; @@ -4205,11 +4215,15 @@ 85557B0C2B591B0F00795FE1 /* Networking */ = { isa = PBXGroup; children = ( - 85557B0D2B591B2600795FE1 /* FirewallAPIClient.swift */, + 85607C882D131CCD00037E34 /* TestRouterAPIClient.swift */, + 8555C65F2D102FFE0092DAD0 /* LeakCheck.swift */, + 85557B0D2B591B2600795FE1 /* FirewallClient.swift */, 85557B0F2B59215F00795FE1 /* FirewallRule.swift */, 85557B132B5983CF00795FE1 /* MullvadAPIWrapper.swift */, 85E3BDE42B70E18C00FA71FD /* Networking.swift */, 856952DB2BD2922A008C1F84 /* PartnerAPIClient.swift */, + 85978A532BE0F10E00F999A7 /* PacketCapture.swift */, + 8590A5432C2AF43400B9BF7B /* TrafficGenerator.swift */, ); path = Networking; sourceTree = ""; @@ -6389,6 +6403,7 @@ 8556EB522B9A1C6900D26DD4 /* MullvadApi.swift in Sources */, 85EC620C2B838D10005AFFB5 /* MullvadAPIWrapper.swift in Sources */, A9DF789D2B7D1E8B0094E4AD /* LoggedInWithTimeUITestCase.swift in Sources */, + 85607C892D131CD500037E34 /* TestRouterAPIClient.swift in Sources */, 85D2B0B12B6BD32400DF9DA7 /* BaseUITestCase.swift in Sources */, F09084682C6E88ED001CD36E /* DaitaPromptAlert.swift in Sources */, 8529693C2B4F0257007EAD4C /* Alert.swift in Sources */, @@ -6409,20 +6424,24 @@ 852969352B4E9270007EAD4C /* LoginPage.swift in Sources */, A998DA832BD2B055001D61A2 /* EditCustomListLocationsPage.swift in Sources */, 7A8A19242CF4C9BF000BCB5B /* MultihopPage.swift in Sources */, + 8590A5442C2AF43400B9BF7B /* TrafficGenerator.swift in Sources */, 7ACD79392C0DAADD00DBEE14 /* AddCustomListLocationsPage.swift in Sources */, 8556EB562B9B0AC500D26DD4 /* RevokedDevicePage.swift in Sources */, A9BFAFFF2BD004ED00F2BCA1 /* CustomListsTests.swift in Sources */, 85557B102B59215F00795FE1 /* FirewallRule.swift in Sources */, - 85557B0E2B591B2600795FE1 /* FirewallAPIClient.swift in Sources */, + 85557B0E2B591B2600795FE1 /* FirewallClient.swift in Sources */, 852969282B4D9C1F007EAD4C /* AccountTests.swift in Sources */, 8587A05D2B84D43100152938 /* ChangeLogAlert.swift in Sources */, 85FB5A102B6960A30015DCED /* AccountDeletionPage.swift in Sources */, 7A45CFC72C071DD400D80B21 /* SnapshotHelper.swift in Sources */, 7A8A19262CF4D37B000BCB5B /* DAITAPage.swift in Sources */, 856952DC2BD2922A008C1F84 /* PartnerAPIClient.swift in Sources */, + 85F1E17E2C0A256200DB8F55 /* LeakTests.swift in Sources */, 85557B162B5ABBBE00795FE1 /* XCUIElementQuery+Extensions.swift in Sources */, 855D9F5B2B63E56B00D7C64D /* ProblemReportPage.swift in Sources */, 8529693A2B4F0238007EAD4C /* TermsOfServicePage.swift in Sources */, + 85978A542BE0F10E00F999A7 /* PacketCapture.swift in Sources */, + 8555C6602D1030040092DAD0 /* LeakCheck.swift in Sources */, 85A42B882BB44D31007BABF7 /* DeviceManagementPage.swift in Sources */, 8532E6872B8CCED600ACECD1 /* ProblemReportSubmittedPage.swift in Sources */, 85FB5A0C2B6903990015DCED /* WelcomePage.swift in Sources */, diff --git a/ios/MullvadVPN.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/MullvadVPN.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved deleted file mode 100644 index 0995ea8187b5..000000000000 --- a/ios/MullvadVPN.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ /dev/null @@ -1,22 +0,0 @@ -{ - "pins" : [ - { - "identity" : "swift-log", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-log.git", - "state" : { - "revision" : "173f567a2dfec11d74588eea82cecea555bdc0bc", - "version" : "1.4.0" - } - }, - { - "identity" : "wireguard-apple", - "kind" : "remoteSourceControl", - "location" : "https://github.com/mullvad/wireguard-apple.git", - "state" : { - "revision" : "ee90a96a20d42d231b878277d0a3fe4dfb63d93d" - } - } - ], - "version" : 2 -} diff --git a/ios/MullvadVPNUITests/Base/BaseUITestCase.swift b/ios/MullvadVPNUITests/Base/BaseUITestCase.swift index 9eea1dd7bacb..2b6d56de3c4e 100644 --- a/ios/MullvadVPNUITests/Base/BaseUITestCase.swift +++ b/ios/MullvadVPNUITests/Base/BaseUITestCase.swift @@ -31,6 +31,13 @@ class BaseUITestCase: XCTestCase { /// Default relay to use in tests static let testsDefaultRelayName = "se-got-wg-001" + /// True when the current test case is capturing packets + private var currentTestCaseShouldCapturePackets = false + + /// True when a packet capture session is active + private var packetCaptureSessionIsActive = false + private var packetCaptureSession: PacketCaptureSession? + // swiftlint:disable force_cast let displayName = Bundle(for: BaseUITestCase.self) .infoDictionary?["DisplayName"] as! String @@ -136,7 +143,7 @@ class BaseUITestCase: XCTestCase { let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard") if springboard.buttons["Allow"].waitForExistence(timeout: Self.shortTimeout) { - let alertAllowButton = springboard.buttons.element(boundBy: 0) + let alertAllowButton = springboard.buttons["Allow"] if alertAllowButton.waitForExistence(timeout: Self.defaultTimeout) { alertAllowButton.tap() } @@ -160,6 +167,29 @@ class BaseUITestCase: XCTestCase { } } + /// Start packet capture for this test case + func startPacketCapture() { + currentTestCaseShouldCapturePackets = true + packetCaptureSessionIsActive = true + let packetCaptureClient = PacketCaptureClient() + packetCaptureSession = packetCaptureClient.startCapture() + } + + /// Stop the current packet capture and return captured traffic + func stopPacketCapture() -> [Stream] { + packetCaptureSessionIsActive = false + guard let packetCaptureSession else { + XCTFail("Trying to stop capture when there is no active capture") + return [] + } + + let packetCaptureAPIClient = PacketCaptureClient() + packetCaptureAPIClient.stopCapture(session: packetCaptureSession) + let capturedData = packetCaptureAPIClient.getParsedCaptureObjects(session: packetCaptureSession) + + return capturedData + } + // MARK: - Setup & teardown /// Override this class function to change the uninstall behaviour in suite level teardown @@ -176,12 +206,42 @@ class BaseUITestCase: XCTestCase { /// Test level setup override func setUp() { + currentTestCaseShouldCapturePackets = false // Reset for each test case run continueAfterFailure = false app.launch() } /// Test level teardown override func tearDown() { + if currentTestCaseShouldCapturePackets { + guard let packetCaptureSession = packetCaptureSession else { + XCTFail("Packet capture session unexpectedly not set up") + return + } + + let packetCaptureClient = PacketCaptureClient() + + // If there's a an active session due to cancelled/failed test run make sure to end it + if packetCaptureSessionIsActive { + packetCaptureSessionIsActive = false + packetCaptureClient.stopCapture(session: packetCaptureSession) + } + + let pcap = packetCaptureClient.getPCAP(session: packetCaptureSession) + let parsedCapture = packetCaptureClient.getParsedCapture(session: packetCaptureSession) + self.packetCaptureSession = nil + + let pcapAttachment = XCTAttachment(data: pcap) + pcapAttachment.name = self.name + ".pcap" + pcapAttachment.lifetime = .keepAlways + self.add(pcapAttachment) + + let jsonAttachment = XCTAttachment(data: parsedCapture) + jsonAttachment.name = self.name + ".json" + jsonAttachment.lifetime = .keepAlways + self.add(jsonAttachment) + } + app.terminate() if let testRun = self.testRun, testRun.failureCount > 0, attachAppLogsOnFailure == true { diff --git a/ios/MullvadVPNUITests/ConnectivityTests.swift b/ios/MullvadVPNUITests/ConnectivityTests.swift index 909cdce159da..b5d95487fe3a 100644 --- a/ios/MullvadVPNUITests/ConnectivityTests.swift +++ b/ios/MullvadVPNUITests/ConnectivityTests.swift @@ -11,7 +11,7 @@ import Network import XCTest class ConnectivityTests: LoggedOutUITestCase { - let firewallAPIClient = FirewallAPIClient() + let firewallAPIClient = FirewallClient() /// Verifies that the app still functions when API has been blocked func testAPIConnectionViaBridges() throws { diff --git a/ios/MullvadVPNUITests/Info.plist b/ios/MullvadVPNUITests/Info.plist index 229e9483278b..2bdf415ada1c 100644 --- a/ios/MullvadVPNUITests/Info.plist +++ b/ios/MullvadVPNUITests/Info.plist @@ -24,10 +24,14 @@ $(IOS_DEVICE_PIN_CODE) NoTimeAccountNumber $(NO_TIME_ACCOUNT_NUMBER) + PacketCaptureAPIBaseURL + $(PACKET_CAPTURE_BASE_URL) PartnerApiToken $(PARTNER_API_TOKEN) ShouldBeReachableDomain $(SHOULD_BE_REACHABLE_DOMAIN) + ShouldBeReachableIPAddress + $(SHOULD_BE_REACHABLE_IP_ADDRESS) TestDeviceIdentifier $(TEST_DEVICE_IDENTIFIER_UUID) TestDeviceIsIPad diff --git a/ios/MullvadVPNUITests/LeakTests.swift b/ios/MullvadVPNUITests/LeakTests.swift new file mode 100644 index 000000000000..a6daffcff3d6 --- /dev/null +++ b/ios/MullvadVPNUITests/LeakTests.swift @@ -0,0 +1,88 @@ +// +// LeakTests.swift +// MullvadVPNUITests +// +// Created by Niklas Berglund on 2024-05-31. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import XCTest + +class LeakTests: LoggedInWithTimeUITestCase { + override func tearDown() { + FirewallClient().removeRules() + super.tearDown() + } + + /// Send UDP traffic to a host, connect to relay and make sure while connected to relay no traffic leaked went directly to the host + func testNoLeak() throws { + let targetIPAddress = Networking.getAlwaysReachableIPAddress() + startPacketCapture() + let trafficGenerator = TrafficGenerator(destinationHost: targetIPAddress, port: 80) + trafficGenerator.startGeneratingUDPTraffic(interval: 30.0) + + TunnelControlPage(app) + .tapSecureConnectionButton() + + allowAddVPNConfigurationsIfAsked() + + TunnelControlPage(app) + .waitForSecureConnectionLabel() + + // Keep the tunnel connection for a while + Thread.sleep(forTimeInterval: 30.0) + + TunnelControlPage(app) + .tapDisconnectButton() + + trafficGenerator.stopGeneratingUDPTraffic() + + var capturedStreams = stopPacketCapture() + // For now cut the beginning and and end of the stream to trim out the part where the tunnel connection was not up + capturedStreams = PacketCaptureClient.trimPackets(streams: capturedStreams, secondsStart: 8, secondsEnd: 3) + LeakCheck.assertNoLeaks(streams: capturedStreams, rules: [NoTrafficToHostLeakRule(host: targetIPAddress)]) + } + + /// Send UDP traffic to a host, connect to relay and then disconnect to intentionally leak traffic and make sure that the test catches the leak + func testShouldLeak() throws { + let targetIPAddress = Networking.getAlwaysReachableIPAddress() + startPacketCapture() + let trafficGenerator = TrafficGenerator(destinationHost: targetIPAddress, port: 80) + trafficGenerator.startGeneratingUDPTraffic(interval: 1.0) + + TunnelControlPage(app) + .tapSecureConnectionButton() + + allowAddVPNConfigurationsIfAsked() + + TunnelControlPage(app) + .waitForSecureConnectionLabel() + + Thread.sleep(forTimeInterval: 2.0) + + TunnelControlPage(app) + .tapDisconnectButton() + + // Give it some time to generate traffic outside of tunnel + Thread.sleep(forTimeInterval: 5.0) + + TunnelControlPage(app) + .tapSecureConnectionButton() + + // Keep the tunnel connection for a while + Thread.sleep(forTimeInterval: 5.0) + + app.launch() + TunnelControlPage(app) + .tapDisconnectButton() + + // Keep the capture open for a while + Thread.sleep(forTimeInterval: 15.0) + trafficGenerator.stopGeneratingUDPTraffic() + + var capturedStreams = stopPacketCapture() + // For now cut the beginning and and end of the stream to trim out the part where the tunnel connection was not up + capturedStreams = PacketCaptureClient.trimPackets(streams: capturedStreams, secondsStart: 8, secondsEnd: 3) + LeakCheck.assertLeaks(streams: capturedStreams, rules: [NoTrafficToHostLeakRule(host: targetIPAddress)]) + } +} diff --git a/ios/MullvadVPNUITests/Networking/FirewallAPIClient.swift b/ios/MullvadVPNUITests/Networking/FirewallClient.swift similarity index 68% rename from ios/MullvadVPNUITests/Networking/FirewallAPIClient.swift rename to ios/MullvadVPNUITests/Networking/FirewallClient.swift index da7f2482acaa..4d1e9aefc4cc 100644 --- a/ios/MullvadVPNUITests/Networking/FirewallAPIClient.swift +++ b/ios/MullvadVPNUITests/Networking/FirewallClient.swift @@ -11,20 +11,16 @@ import SystemConfiguration import UIKit import XCTest -class FirewallAPIClient { +class FirewallClient: TestRouterAPIClient { // 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 + let testDeviceIdentifier = Bundle(for: FirewallClient.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") + let createRuleURL = TestRouterAPIClient.baseURL.appendingPathComponent("rule") var request = URLRequest(url: createRuleURL) request.httpMethod = "POST" @@ -64,7 +60,9 @@ class FirewallAPIClient { } else { if let response = requestResponse as? HTTPURLResponse { if response.statusCode != 201 { - XCTFail("Failed to create firewall rule - unexpected server response") + XCTFail( + "Failed to create firewall rule - unexpected response status code \(response.statusCode)" + ) } } @@ -77,43 +75,9 @@ class FirewallAPIClient { } } - /// Gets the IP address of the device under test - public func getDeviceIPAddress() throws -> String { - let deviceIPURL = baseURL.appendingPathComponent("own-ip") - let request = URLRequest(url: deviceIPURL) - let completionHandlerInvokedExpectation = XCTestExpectation( - description: "Completion handler for the request is invoked" - ) - var deviceIPAddress = "" - var requestError: Error? - - let dataTask = URLSession.shared.dataTask(with: request) { data, _, _ in - defer { completionHandlerInvokedExpectation.fulfill() } - guard let data else { - requestError = NetworkingError.internalError(reason: "Could not get device IP") - return - } - - deviceIPAddress = String(data: data, encoding: .utf8)! - } - - dataTask.resume() - - let waitResult = XCTWaiter.wait(for: [completionHandlerInvokedExpectation], timeout: 30) - if waitResult != .completed { - XCTFail("Failed to get device IP address - timeout") - } - - if let requestError { - throw requestError - } - - return deviceIPAddress - } - /// Remove all firewall rules associated to this device under test public func removeRules() { - let removeRulesURL = baseURL.appendingPathComponent("remove-rules/\(sessionIdentifier)") + let removeRulesURL = TestRouterAPIClient.baseURL.appendingPathComponent("remove-rules/\(sessionIdentifier)") var request = URLRequest(url: removeRulesURL) request.httpMethod = "DELETE" diff --git a/ios/MullvadVPNUITests/Networking/FirewallRule.swift b/ios/MullvadVPNUITests/Networking/FirewallRule.swift index 02f1811e8746..66e93929db7d 100644 --- a/ios/MullvadVPNUITests/Networking/FirewallRule.swift +++ b/ios/MullvadVPNUITests/Networking/FirewallRule.swift @@ -9,22 +9,16 @@ 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] + let protocols: [NetworkTransportProtocol] /// - 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]) { + private init(fromIPAddress: String, toIPAddress: String, protocols: [NetworkTransportProtocol]) { self.fromIPAddress = fromIPAddress self.toIPAddress = toIPAddress self.protocols = protocols @@ -36,7 +30,7 @@ struct FirewallRule { /// Make a firewall rule blocking API access for the current device under test public static func makeBlockAPIAccessFirewallRule() throws -> FirewallRule { - let deviceIPAddress = try FirewallAPIClient().getDeviceIPAddress() + let deviceIPAddress = try FirewallClient().getDeviceIPAddress() let apiIPAddress = try MullvadAPIWrapper.getAPIIPAddress() return FirewallRule( fromIPAddress: deviceIPAddress, @@ -46,7 +40,7 @@ struct FirewallRule { } public static func makeBlockAllTrafficRule(toIPAddress: String) throws -> FirewallRule { - let deviceIPAddress = try FirewallAPIClient().getDeviceIPAddress() + let deviceIPAddress = try FirewallClient().getDeviceIPAddress() return FirewallRule( fromIPAddress: deviceIPAddress, @@ -56,7 +50,7 @@ struct FirewallRule { } public static func makeBlockUDPTrafficRule(toIPAddress: String) throws -> FirewallRule { - let deviceIPAddress = try FirewallAPIClient().getDeviceIPAddress() + let deviceIPAddress = try FirewallClient().getDeviceIPAddress() return FirewallRule( fromIPAddress: deviceIPAddress, diff --git a/ios/MullvadVPNUITests/Networking/LeakCheck.swift b/ios/MullvadVPNUITests/Networking/LeakCheck.swift new file mode 100644 index 000000000000..5c751527ef38 --- /dev/null +++ b/ios/MullvadVPNUITests/Networking/LeakCheck.swift @@ -0,0 +1,45 @@ +// +// LeakCheck.swift +// MullvadVPN +// +// Created by Niklas Berglund on 2024-12-16. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import XCTest + +class LeakCheck { + static func assertNoLeaks(streams: [Stream], rules: [LeakRule]) { + XCTAssertFalse(streams.isEmpty, "No streams to leak check") + XCTAssertFalse(rules.isEmpty, "No leak rules to check") + + for rule in rules where rule.isViolated(streams: streams) { + XCTFail("Leak rule violated") + } + } + + static func assertLeaks(streams: [Stream], rules: [LeakRule]) { + XCTAssertFalse(streams.isEmpty, "No streams to leak check") + XCTAssertFalse(rules.isEmpty, "No leak rules to check") + + for rule in rules where rule.isViolated(streams: streams) == false { + XCTFail("Leak rule unexpectedly not violated when asserting leak") + } + } +} + +protocol LeakRule { + func isViolated(streams: [Stream]) -> Bool +} + +class NoTrafficToHostLeakRule: LeakRule { + let host: String + + init(host: String) { + self.host = host + } + + func isViolated(streams: [Stream]) -> Bool { + streams.filter { $0.destinationAddress == host }.isEmpty == false + } +} diff --git a/ios/MullvadVPNUITests/Networking/Networking.swift b/ios/MullvadVPNUITests/Networking/Networking.swift index 9e00f48d02d1..7190c35fc576 100644 --- a/ios/MullvadVPNUITests/Networking/Networking.swift +++ b/ios/MullvadVPNUITests/Networking/Networking.swift @@ -10,6 +10,12 @@ import Foundation import Network import XCTest +enum NetworkTransportProtocol: String, Codable { + case TCP = "tcp" + case UDP = "udp" + case ICMP = "icmp" +} + enum NetworkingError: Error { case notConfiguredError case internalError(reason: String) @@ -32,16 +38,6 @@ class Networking { return adServingDomain } - /// 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 shouldBeReachableDomain - } - /// Check whether host and port is reachable by attempting to connect a socket private static func canConnectSocket(host: String, port: String) throws -> Bool { let socketHost = NWEndpoint.Host(host) @@ -79,6 +75,26 @@ class Networking { return true } + /// Get configured domain to use for Internet connectivity checks + public static func getAlwaysReachableDomain() throws -> String { + guard let shouldBeReachableDomain = Bundle(for: Networking.self) + .infoDictionary?["ShouldBeReachableDomain"] as? String else { + throw NetworkingError.notConfiguredError + } + + return shouldBeReachableDomain + } + + public static func getAlwaysReachableIPAddress() -> String { + guard let shouldBeReachableIPAddress = Bundle(for: Networking.self) + .infoDictionary?["ShouldBeReachableIPAddress"] as? String else { + XCTFail("Should be reachable IP address not configured") + return String() + } + + return shouldBeReachableIPAddress + } + /// 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() diff --git a/ios/MullvadVPNUITests/Networking/PacketCapture.swift b/ios/MullvadVPNUITests/Networking/PacketCapture.swift new file mode 100644 index 000000000000..dd80f065805a --- /dev/null +++ b/ios/MullvadVPNUITests/Networking/PacketCapture.swift @@ -0,0 +1,333 @@ +// +// PacketCapture.swift +// MullvadVPNUITests +// +// Created by Niklas Berglund on 2024-04-30. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import Foundation +import XCTest + +struct PacketCaptureSession { + var identifier = UUID().uuidString +} + +/// Represents a stream in packet capture +class Stream: Codable, Equatable { + static func == (lhs: Stream, rhs: Stream) -> Bool { + return lhs.sourceAddress == rhs.sourceAddress && + lhs.destinationAddress == rhs.destinationAddress && + lhs.flowID == rhs.flowID && + lhs.transportProtocol == rhs.transportProtocol + } + + let sourceAddress: String + let sourcePort: Int + let destinationAddress: String + let destinationPort: Int + let flowID: String? + let transportProtocol: NetworkTransportProtocol + var packets: [Packet] { + didSet { + determineDateInterval() + } + } + + /// Date interval from first to last packet of this stream + var dateInterval: DateInterval + + /// Date interval from first to last tx(sent from test device) packet of this stream + var txInterval: DateInterval? + + /// Date interval from frist to last rx(sent to test device) packet of this stream + var rxInterval: DateInterval? + + enum CodingKeys: String, CodingKey { + case sourceAddress = "peer_addr" + case destinationAddress = "other_addr" + case flowID = "flow_id" + case transportProtocol = "transport_protocol" + case packets + } + + required init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.flowID = try container.decodeIfPresent(String.self, forKey: .flowID) + self.transportProtocol = try container.decode(NetworkTransportProtocol.self, forKey: .transportProtocol) + self.packets = try container.decode([Packet].self, forKey: .packets) + dateInterval = DateInterval() + + // Separate source address and port + let sourceValue = try container.decode(String.self, forKey: .sourceAddress) + let sourceSplit = sourceValue.components(separatedBy: ":") + self.sourceAddress = try XCTUnwrap(sourceSplit.first) + self.sourcePort = try XCTUnwrap(Int(try XCTUnwrap(sourceSplit.last))) + + // Separate destination address and port + let destinationValue = try container.decode(String.self, forKey: .destinationAddress) + let destinationSplit = destinationValue.components(separatedBy: ":") + self.destinationAddress = try XCTUnwrap(destinationSplit.first) + self.destinationPort = try XCTUnwrap(Int(try XCTUnwrap(destinationSplit.last))) + + // Set date interval based on packets' time window + determineDateInterval() + } + + /// Determine the stream's date interval from the time between first to the last packet + private func determineDateInterval() { + guard packets.isEmpty == false else { + XCTFail("Stream unexpectedly have no packets") + return + } + + // Identify first tx and rx packets to set as initial values + let txPackets = packets.filter { $0.fromPeer == true }.sorted { $0.date < $1.date } + let rxPackets = packets.filter { $0.fromPeer == false }.sorted { $0.date < $1.date } + let allPackets = packets.sorted { $0.date < $1.date } + + if let firstTxPacket = txPackets.first, let lastTxPacket = txPackets.last { + txInterval = DateInterval(start: firstTxPacket.date, end: lastTxPacket.date) + } + + if let firstRxPacket = rxPackets.first, let lastRxPacket = rxPackets.last { + rxInterval = DateInterval(start: firstRxPacket.date, end: lastRxPacket.date) + } + + if let firstPacket = allPackets.first, let lastPacket = allPackets.last { + dateInterval = DateInterval(start: firstPacket.date, end: lastPacket.date) + } + } +} + +/// Represents a packet in packet capture +class Packet: Codable, Equatable { + /// True when packet is sent from device under test, false if from another host + public let fromPeer: Bool + + /// Timestamp in microseconds + private var timestamp: Int64 + + public var date: Date + + enum CodingKeys: String, CodingKey { + case fromPeer = "from_peer" + case timestamp + } + + required init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + fromPeer = try container.decode(Bool.self, forKey: .fromPeer) + timestamp = try container.decode(Int64.self, forKey: .timestamp) / 1000000 + date = Date(timeIntervalSince1970: TimeInterval(timestamp)) + } + + static func == (lhs: Packet, rhs: Packet) -> Bool { + return lhs.fromPeer == rhs.fromPeer && + lhs.timestamp == rhs.timestamp && + lhs.date == rhs.date + } +} + +class PacketCaptureClient: TestRouterAPIClient { + /// Start a new capture session + func startCapture() -> PacketCaptureSession { + let session = PacketCaptureSession() + + let jsonDictionary = [ + "label": session.identifier, + ] + + _ = sendRequest( + httpMethod: "POST", + endpoint: "capture", + contentType: "application/json", + jsonData: jsonDictionary + ) + + return session + } + + /// Stop capture for session + func stopCapture(session: PacketCaptureSession) { + _ = sendJSONRequest(httpMethod: "POST", endpoint: "stop-capture/\(session.identifier)", jsonData: nil) + } + + /// Cut specified number of seconds from the beginning and end of data capture + static func trimPackets(streams: [Stream], secondsStart: Double, secondsEnd: Double) -> [Stream] { + var collectionStartDate: Date? + var collectionEndDate: Date? + + for stream in streams { + if collectionStartDate != nil { + collectionStartDate = min(collectionStartDate!, stream.dateInterval.start) + } else { + collectionStartDate = stream.dateInterval.start + } + + if collectionEndDate != nil { + collectionEndDate = max(collectionEndDate!, stream.dateInterval.end) + } else { + collectionEndDate = stream.dateInterval.end + } + } + + let cutStartDate = collectionStartDate!.addingTimeInterval(secondsStart) + let cutEndDate = collectionEndDate!.addingTimeInterval(-secondsEnd) + + var trimmedStreams: [Stream] = [] + for stream in streams { + let packetsWithinTimeframe = stream.packets.filter { packet in + return packet.date >= cutStartDate && packet.date <= cutEndDate + } + + if packetsWithinTimeframe.isEmpty == false { + stream.packets = packetsWithinTimeframe + trimmedStreams.append(stream) + } + } + + return trimmedStreams + } + + /// Get captured traffic from this session parsed to objects + func getParsedCaptureObjects(session: PacketCaptureSession) -> [Stream] { + let parsedData = getParsedCapture(session: session) + let decoder = JSONDecoder() + + do { + let streams = try decoder.decode([Stream].self, from: parsedData) + return streams + } catch { + XCTFail("Failed to decode parsed capture") + return [] + } + } + + /// Get captured traffic from this session parsed to JSON + func getParsedCapture(session: PacketCaptureSession) -> Data { + var deviceIPAddress: String + + do { + deviceIPAddress = try getDeviceIPAddress() + } catch { + XCTFail("Failed to get device IP address") + return Data() + } + + let responseData = sendJSONRequest( + httpMethod: "PUT", + endpoint: "parse-capture/\(session.identifier)", + jsonData: [deviceIPAddress] + ) + + return responseData + } + + /// Get PCAP file contents for the capture of this session + func getPCAP(session: PacketCaptureSession) -> Data { + let response = sendPCAPRequest(httpMethod: "GET", endpoint: "last-capture/\(session.identifier)", jsonData: nil) + return response + } + + private func sendJSONRequest(httpMethod: String, endpoint: String, jsonData: Any?) -> Data { + let responseData = sendRequest( + httpMethod: httpMethod, + endpoint: endpoint, + contentType: "application/json", + jsonData: jsonData + ) + + guard let responseData else { + XCTFail("Unexpectedly didn't get any data from JSON request") + return Data() + } + + return responseData + } + + private func sendPCAPRequest(httpMethod: String, endpoint: String, jsonData: Any?) -> Data { + let responseData = sendRequest( + httpMethod: httpMethod, + endpoint: endpoint, + contentType: "application/pcap", + jsonData: jsonData + ) + + guard let responseData else { + XCTFail("Unexpectedly didn't get any data from response") + return Data() + } + + XCTAssertFalse(responseData.isEmpty, "PCAP response data should not be empty") + + return responseData + } + + private func sendRequest(httpMethod: String, endpoint: String, contentType: String?, jsonData: Any?) -> Data? { + let url = TestRouterAPIClient.baseURL.appendingPathComponent(endpoint) + + var request = URLRequest(url: url) + request.httpMethod = httpMethod + + if let contentType { + request.setValue(contentType, forHTTPHeaderField: "Content-Type") + } + + if let jsonData = jsonData { + do { + request.httpBody = try JSONSerialization.data(withJSONObject: jsonData) + } catch { + XCTFail("Failed to serialize JSON data") + } + } + + var requestResponse: URLResponse? + var requestError: Error? + var responseData: Data? + + let completionHandlerInvokedExpectation = XCTestExpectation( + description: "Completion handler for the request is invoked" + ) + + let dataTask = URLSession.shared.dataTask(with: request) { data, response, error in + requestResponse = response + requestError = error + + guard let data = data, + let response = response as? HTTPURLResponse, + error == nil else { + XCTFail("Error: \(error?.localizedDescription ?? "Unknown error")") + return + } + + if 200 ... 204 ~= response.statusCode && error == nil { + responseData = data + } else { + XCTFail("Request failed") + } + + completionHandlerInvokedExpectation.fulfill() + } + + dataTask.resume() + + let waitResult = XCTWaiter.wait(for: [completionHandlerInvokedExpectation], timeout: 30) + + if waitResult != .completed { + XCTFail("Failed to send packet capture API request - timeout") + } else { + if let response = requestResponse as? HTTPURLResponse { + if (200 ... 201 ~= response.statusCode) == false { + XCTFail("Packet capture API request failed - unexpected server response") + } + } + + if let error = requestError { + XCTFail("Packet capture API request failed - encountered error \(error.localizedDescription)") + } + } + + return responseData + } +} diff --git a/ios/MullvadVPNUITests/Networking/TestRouterAPIClient.swift b/ios/MullvadVPNUITests/Networking/TestRouterAPIClient.swift new file mode 100644 index 000000000000..7eb1f535dd0c --- /dev/null +++ b/ios/MullvadVPNUITests/Networking/TestRouterAPIClient.swift @@ -0,0 +1,56 @@ +// +// TestRouterAPIClient.swift +// MullvadVPN +// +// Created by Niklas Berglund on 2024-12-18. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import XCTest + +class TestRouterAPIClient { + // swiftlint:disable force_cast + static let baseURL = URL( + string: + Bundle(for: FirewallClient.self).infoDictionary?["FirewallApiBaseURL"] as! String + )! + // swiftlint:enable force_cast + + static func getIPAddress() throws -> String { + return "" + } + + /// Gets the IP address of the device under test + public func getDeviceIPAddress() throws -> String { + let deviceIPURL = TestRouterAPIClient.baseURL.appendingPathComponent("own-ip") + let request = URLRequest(url: deviceIPURL) + let completionHandlerInvokedExpectation = XCTestExpectation( + description: "Completion handler for the request is invoked" + ) + var deviceIPAddress = "" + var requestError: Error? + + let dataTask = URLSession.shared.dataTask(with: request) { data, _, _ in + defer { completionHandlerInvokedExpectation.fulfill() } + guard let data else { + requestError = NetworkingError.internalError(reason: "Could not get device IP") + return + } + + deviceIPAddress = String(data: data, encoding: .utf8)! + } + + dataTask.resume() + + let waitResult = XCTWaiter.wait(for: [completionHandlerInvokedExpectation], timeout: 30) + if waitResult != .completed { + XCTFail("Failed to get device IP address - timeout") + } + + if let requestError { + throw requestError + } + + return deviceIPAddress + } +} diff --git a/ios/MullvadVPNUITests/Networking/TrafficGenerator.swift b/ios/MullvadVPNUITests/Networking/TrafficGenerator.swift new file mode 100644 index 000000000000..b339e328195a --- /dev/null +++ b/ios/MullvadVPNUITests/Networking/TrafficGenerator.swift @@ -0,0 +1,74 @@ +// +// TrafficGenerator.swift +// MullvadVPNUITests +// +// Created by Niklas Berglund on 2024-06-25. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import Network +import XCTest + +class TrafficGenerator { + let destinationHost: String + let port: Int + let connection: NWConnection + let dispatchQueue = DispatchQueue(label: "TrafficGeneratorDispatchQueue", qos: .unspecified) + var timer: DispatchSourceTimer + + init(destinationHost: String, port: Int) { + self.destinationHost = destinationHost + self.port = port + connection = NWConnection( + host: NWEndpoint.Host(destinationHost), + port: NWEndpoint.Port(integerLiteral: UInt16(port)), + using: .udp + ) + + timer = DispatchSource.makeTimerSource(queue: dispatchQueue) + + connect() + } + + func connect() { + let doneAttemptingConnectExpecation = XCTestExpectation(description: "Done attemping to connect") + + connection.stateUpdateHandler = { state in + switch state { + case .ready: + print("Ready") + doneAttemptingConnectExpecation.fulfill() + case let .failed(error): + print("Failed to connect: \(error)") + doneAttemptingConnectExpecation.fulfill() + default: + break + } + } + + connection.start(queue: dispatchQueue) + + XCTWaiter().wait(for: [doneAttemptingConnectExpecation], timeout: 10.0) + } + + public func startGeneratingUDPTraffic(interval: TimeInterval) { + timer.schedule(deadline: .now(), repeating: interval) + + timer.setEventHandler { + let data = "dGhpcyBpcyBqdXN0IHNvbWUgZHVtbXkgZGF0YSB0aGlzIGlzIGp1c3Qgc29tZSBkdW".data(using: .utf8) + self.connection.send(content: data, completion: .contentProcessed { error in + if let error = error { + print("Failed to send data: \(error)") + } else { + print("Data sent") + } + }) + } + + timer.activate() + } + + public func stopGeneratingUDPTraffic() { + timer.cancel() + } +} diff --git a/ios/MullvadVPNUITests/RelayTests.swift b/ios/MullvadVPNUITests/RelayTests.swift index 9bf9c790421f..f58aa4df59b2 100644 --- a/ios/MullvadVPNUITests/RelayTests.swift +++ b/ios/MullvadVPNUITests/RelayTests.swift @@ -27,7 +27,7 @@ class RelayTests: LoggedInWithTimeUITestCase { super.tearDown() if removeFirewallRulesInTearDown { - FirewallAPIClient().removeRules() + FirewallClient().removeRules() } } @@ -102,7 +102,7 @@ class RelayTests: LoggedInWithTimeUITestCase { } func testConnectionRetryLogic() throws { - FirewallAPIClient().removeRules() + FirewallClient().removeRules() removeFirewallRulesInTearDown = true addTeardownBlock { @@ -113,7 +113,7 @@ class RelayTests: LoggedInWithTimeUITestCase { let relayInfo = getDefaultRelayInfo() // Run actual test - try FirewallAPIClient().createRule( + try FirewallClient().createRule( FirewallRule.makeBlockAllTrafficRule(toIPAddress: relayInfo.ipAddress) ) @@ -214,7 +214,7 @@ class RelayTests: LoggedInWithTimeUITestCase { /// 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 { - FirewallAPIClient().removeRules() + FirewallClient().removeRules() removeFirewallRulesInTearDown = true addTeardownBlock { @@ -225,7 +225,7 @@ class RelayTests: LoggedInWithTimeUITestCase { let relayInfo = getDefaultRelayInfo() // Run actual test - try FirewallAPIClient().createRule( + try FirewallClient().createRule( FirewallRule.makeBlockUDPTrafficRule(toIPAddress: relayInfo.ipAddress) ) diff --git a/ios/MullvadVPNUITests/tests.json b/ios/MullvadVPNUITests/tests.json index 19f5ec287a82..d8b642cc2c44 100644 --- a/ios/MullvadVPNUITests/tests.json +++ b/ios/MullvadVPNUITests/tests.json @@ -4,6 +4,7 @@ "AccountTests", "ConnectivityTests", "CustomListsTests", + "LeakTests", "RelayTests", "SettingsTests" ],