Skip to content

Commit 7559def

Browse files
Implement basic leak tests
1 parent 936ab8f commit 7559def

16 files changed

+743
-108
lines changed

ios/Configurations/UITests.xcconfig.template

+4-1
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,14 @@ AD_SERVING_DOMAIN = vpnlist.to
2424
// A domain which should be reachable. Used to verify Internet connectivity. Must be running a server on port 80.
2525
SHOULD_BE_REACHABLE_DOMAIN = mullvad.net
2626

27-
// 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
27+
// 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
2828
FIREWALL_API_BASE_URL = http:/${}/8.8.8.8
2929

3030
// 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.
3131
AM_I_JSON_URL = https:/${}/am.i.stagemole.eu/json
3232

3333
// Specify whether app logs should be extracted and attached to test report for failing tests
3434
ATTACH_APP_LOGS_ON_FAILURE = 0
35+
36+
// 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
37+
PACKET_CAPTURE_BASE_URL = http:/${}/8.8.8.8

ios/MullvadVPN.xcodeproj/project.pbxproj

+33-14
Large diffs are not rendered by default.

ios/MullvadVPN.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved

-22
This file was deleted.

ios/MullvadVPNUITests/Base/BaseUITestCase.swift

+61-1
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,13 @@ class BaseUITestCase: XCTestCase {
3131
/// Default relay to use in tests
3232
static let testsDefaultRelayName = "se-got-wg-001"
3333

34+
/// True when the current test case is capturing packets
35+
private var currentTestCaseShouldCapturePackets = false
36+
37+
/// True when a packet capture session is active
38+
private var packetCaptureSessionIsActive = false
39+
private var packetCaptureSession: PacketCaptureSession?
40+
3441
// swiftlint:disable force_cast
3542
let displayName = Bundle(for: BaseUITestCase.self)
3643
.infoDictionary?["DisplayName"] as! String
@@ -136,7 +143,7 @@ class BaseUITestCase: XCTestCase {
136143
let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
137144

138145
if springboard.buttons["Allow"].waitForExistence(timeout: Self.shortTimeout) {
139-
let alertAllowButton = springboard.buttons.element(boundBy: 0)
146+
let alertAllowButton = springboard.buttons["Allow"]
140147
if alertAllowButton.waitForExistence(timeout: Self.defaultTimeout) {
141148
alertAllowButton.tap()
142149
}
@@ -160,6 +167,29 @@ class BaseUITestCase: XCTestCase {
160167
}
161168
}
162169

170+
/// Start packet capture for this test case
171+
func startPacketCapture() {
172+
currentTestCaseShouldCapturePackets = true
173+
packetCaptureSessionIsActive = true
174+
let packetCaptureClient = PacketCaptureClient()
175+
packetCaptureSession = packetCaptureClient.startCapture()
176+
}
177+
178+
/// Stop the current packet capture and return captured traffic
179+
func stopPacketCapture() -> [Stream] {
180+
packetCaptureSessionIsActive = false
181+
guard let packetCaptureSession else {
182+
XCTFail("Trying to stop capture when there is no active capture")
183+
return []
184+
}
185+
186+
let packetCaptureAPIClient = PacketCaptureClient()
187+
packetCaptureAPIClient.stopCapture(session: packetCaptureSession)
188+
let capturedData = packetCaptureAPIClient.getParsedCaptureObjects(session: packetCaptureSession)
189+
190+
return capturedData
191+
}
192+
163193
// MARK: - Setup & teardown
164194

165195
/// Override this class function to change the uninstall behaviour in suite level teardown
@@ -176,12 +206,42 @@ class BaseUITestCase: XCTestCase {
176206

177207
/// Test level setup
178208
override func setUp() {
209+
currentTestCaseShouldCapturePackets = false // Reset for each test case run
179210
continueAfterFailure = false
180211
app.launch()
181212
}
182213

183214
/// Test level teardown
184215
override func tearDown() {
216+
if currentTestCaseShouldCapturePackets {
217+
guard let packetCaptureSession = packetCaptureSession else {
218+
XCTFail("Packet capture session unexpectedly not set up")
219+
return
220+
}
221+
222+
let packetCaptureClient = PacketCaptureClient()
223+
224+
// If there's a an active session due to cancelled/failed test run make sure to end it
225+
if packetCaptureSessionIsActive {
226+
packetCaptureSessionIsActive = false
227+
packetCaptureClient.stopCapture(session: packetCaptureSession)
228+
}
229+
230+
let pcap = packetCaptureClient.getPCAP(session: packetCaptureSession)
231+
let parsedCapture = packetCaptureClient.getParsedCapture(session: packetCaptureSession)
232+
self.packetCaptureSession = nil
233+
234+
let pcapAttachment = XCTAttachment(data: pcap)
235+
pcapAttachment.name = self.name + ".pcap"
236+
pcapAttachment.lifetime = .keepAlways
237+
self.add(pcapAttachment)
238+
239+
let jsonAttachment = XCTAttachment(data: parsedCapture)
240+
jsonAttachment.name = self.name + ".json"
241+
jsonAttachment.lifetime = .keepAlways
242+
self.add(jsonAttachment)
243+
}
244+
185245
app.terminate()
186246

187247
if let testRun = self.testRun, testRun.failureCount > 0, attachAppLogsOnFailure == true {

ios/MullvadVPNUITests/ConnectivityTests.swift

+1-1
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import Network
1111
import XCTest
1212

1313
class ConnectivityTests: LoggedOutUITestCase {
14-
let firewallAPIClient = FirewallAPIClient()
14+
let firewallAPIClient = FirewallClient()
1515

1616
/// Verifies that the app still functions when API has been blocked
1717
func testAPIConnectionViaBridges() throws {

ios/MullvadVPNUITests/Info.plist

+4
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,14 @@
2424
<string>$(IOS_DEVICE_PIN_CODE)</string>
2525
<key>NoTimeAccountNumber</key>
2626
<string>$(NO_TIME_ACCOUNT_NUMBER)</string>
27+
<key>PacketCaptureAPIBaseURL</key>
28+
<string>$(PACKET_CAPTURE_BASE_URL)</string>
2729
<key>PartnerApiToken</key>
2830
<string>$(PARTNER_API_TOKEN)</string>
2931
<key>ShouldBeReachableDomain</key>
3032
<string>$(SHOULD_BE_REACHABLE_DOMAIN)</string>
33+
<key>ShouldBeReachableIPAddress</key>
34+
<string>$(SHOULD_BE_REACHABLE_IP_ADDRESS)</string>
3135
<key>TestDeviceIdentifier</key>
3236
<string>$(TEST_DEVICE_IDENTIFIER_UUID)</string>
3337
<key>TestDeviceIsIPad</key>

ios/MullvadVPNUITests/LeakTests.swift

+88
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
//
2+
// LeakTests.swift
3+
// MullvadVPNUITests
4+
//
5+
// Created by Niklas Berglund on 2024-05-31.
6+
// Copyright © 2024 Mullvad VPN AB. All rights reserved.
7+
//
8+
9+
import XCTest
10+
11+
class LeakTests: LoggedInWithTimeUITestCase {
12+
override func tearDown() {
13+
FirewallClient().removeRules()
14+
super.tearDown()
15+
}
16+
17+
/// Send UDP traffic to a host, connect to relay and make sure while connected to relay no traffic leaked went directly to the host
18+
func testNoLeak() throws {
19+
let targetIPAddress = Networking.getAlwaysReachableIPAddress()
20+
startPacketCapture()
21+
let trafficGenerator = TrafficGenerator(destinationHost: targetIPAddress, port: 80)
22+
trafficGenerator.startGeneratingUDPTraffic(interval: 30.0)
23+
24+
TunnelControlPage(app)
25+
.tapSecureConnectionButton()
26+
27+
allowAddVPNConfigurationsIfAsked()
28+
29+
TunnelControlPage(app)
30+
.waitForSecureConnectionLabel()
31+
32+
// Keep the tunnel connection for a while
33+
Thread.sleep(forTimeInterval: 30.0)
34+
35+
TunnelControlPage(app)
36+
.tapDisconnectButton()
37+
38+
trafficGenerator.stopGeneratingUDPTraffic()
39+
40+
var capturedStreams = stopPacketCapture()
41+
// For now cut the beginning and and end of the stream to trim out the part where the tunnel connection was not up
42+
capturedStreams = PacketCaptureClient.trimPackets(streams: capturedStreams, secondsStart: 8, secondsEnd: 3)
43+
LeakCheck.assertNoLeaks(streams: capturedStreams, rules: [NoTrafficToHostLeakRule(host: targetIPAddress)])
44+
}
45+
46+
/// 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
47+
func testShouldLeak() throws {
48+
let targetIPAddress = Networking.getAlwaysReachableIPAddress()
49+
startPacketCapture()
50+
let trafficGenerator = TrafficGenerator(destinationHost: targetIPAddress, port: 80)
51+
trafficGenerator.startGeneratingUDPTraffic(interval: 1.0)
52+
53+
TunnelControlPage(app)
54+
.tapSecureConnectionButton()
55+
56+
allowAddVPNConfigurationsIfAsked()
57+
58+
TunnelControlPage(app)
59+
.waitForSecureConnectionLabel()
60+
61+
Thread.sleep(forTimeInterval: 2.0)
62+
63+
TunnelControlPage(app)
64+
.tapDisconnectButton()
65+
66+
// Give it some time to generate traffic outside of tunnel
67+
Thread.sleep(forTimeInterval: 5.0)
68+
69+
TunnelControlPage(app)
70+
.tapSecureConnectionButton()
71+
72+
// Keep the tunnel connection for a while
73+
Thread.sleep(forTimeInterval: 5.0)
74+
75+
app.launch()
76+
TunnelControlPage(app)
77+
.tapDisconnectButton()
78+
79+
// Keep the capture open for a while
80+
Thread.sleep(forTimeInterval: 15.0)
81+
trafficGenerator.stopGeneratingUDPTraffic()
82+
83+
var capturedStreams = stopPacketCapture()
84+
// For now cut the beginning and and end of the stream to trim out the part where the tunnel connection was not up
85+
capturedStreams = PacketCaptureClient.trimPackets(streams: capturedStreams, secondsStart: 8, secondsEnd: 3)
86+
LeakCheck.assertLeaks(streams: capturedStreams, rules: [NoTrafficToHostLeakRule(host: targetIPAddress)])
87+
}
88+
}

ios/MullvadVPNUITests/Networking/FirewallAPIClient.swift ios/MullvadVPNUITests/Networking/FirewallClient.swift

+7-43
Original file line numberDiff line numberDiff line change
@@ -11,20 +11,16 @@ import SystemConfiguration
1111
import UIKit
1212
import XCTest
1313

14-
class FirewallAPIClient {
14+
class FirewallClient: TestRouterAPIClient {
1515
// swiftlint:disable force_cast
16-
let baseURL = URL(
17-
string:
18-
Bundle(for: FirewallAPIClient.self).infoDictionary?["FirewallApiBaseURL"] as! String
19-
)!
20-
let testDeviceIdentifier = Bundle(for: FirewallAPIClient.self).infoDictionary?["TestDeviceIdentifier"] as! String
16+
let testDeviceIdentifier = Bundle(for: FirewallClient.self).infoDictionary?["TestDeviceIdentifier"] as! String
2117
// swiftlint:enable force_cast
2218

2319
lazy var sessionIdentifier = "urn:uuid:" + testDeviceIdentifier
2420

2521
/// Create a new rule associated to the device under test
2622
public func createRule(_ firewallRule: FirewallRule) {
27-
let createRuleURL = baseURL.appendingPathComponent("rule")
23+
let createRuleURL = TestRouterAPIClient.baseURL.appendingPathComponent("rule")
2824

2925
var request = URLRequest(url: createRuleURL)
3026
request.httpMethod = "POST"
@@ -64,7 +60,9 @@ class FirewallAPIClient {
6460
} else {
6561
if let response = requestResponse as? HTTPURLResponse {
6662
if response.statusCode != 201 {
67-
XCTFail("Failed to create firewall rule - unexpected server response")
63+
XCTFail(
64+
"Failed to create firewall rule - unexpected response status code \(response.statusCode)"
65+
)
6866
}
6967
}
7068

@@ -77,43 +75,9 @@ class FirewallAPIClient {
7775
}
7876
}
7977

80-
/// Gets the IP address of the device under test
81-
public func getDeviceIPAddress() throws -> String {
82-
let deviceIPURL = baseURL.appendingPathComponent("own-ip")
83-
let request = URLRequest(url: deviceIPURL)
84-
let completionHandlerInvokedExpectation = XCTestExpectation(
85-
description: "Completion handler for the request is invoked"
86-
)
87-
var deviceIPAddress = ""
88-
var requestError: Error?
89-
90-
let dataTask = URLSession.shared.dataTask(with: request) { data, _, _ in
91-
defer { completionHandlerInvokedExpectation.fulfill() }
92-
guard let data else {
93-
requestError = NetworkingError.internalError(reason: "Could not get device IP")
94-
return
95-
}
96-
97-
deviceIPAddress = String(data: data, encoding: .utf8)!
98-
}
99-
100-
dataTask.resume()
101-
102-
let waitResult = XCTWaiter.wait(for: [completionHandlerInvokedExpectation], timeout: 30)
103-
if waitResult != .completed {
104-
XCTFail("Failed to get device IP address - timeout")
105-
}
106-
107-
if let requestError {
108-
throw requestError
109-
}
110-
111-
return deviceIPAddress
112-
}
113-
11478
/// Remove all firewall rules associated to this device under test
11579
public func removeRules() {
116-
let removeRulesURL = baseURL.appendingPathComponent("remove-rules/\(sessionIdentifier)")
80+
let removeRulesURL = TestRouterAPIClient.baseURL.appendingPathComponent("remove-rules/\(sessionIdentifier)")
11781

11882
var request = URLRequest(url: removeRulesURL)
11983
request.httpMethod = "DELETE"

ios/MullvadVPNUITests/Networking/FirewallRule.swift

+5-11
Original file line numberDiff line numberDiff line change
@@ -9,22 +9,16 @@
99
import Foundation
1010
import XCTest
1111

12-
enum NetworkingProtocol: String {
13-
case TCP = "tcp"
14-
case UDP = "udp"
15-
case ICMP = "icmp"
16-
}
17-
1812
struct FirewallRule {
1913
let fromIPAddress: String
2014
let toIPAddress: String
21-
let protocols: [NetworkingProtocol]
15+
let protocols: [NetworkTransportProtocol]
2216

2317
/// - Parameters:
2418
/// - fromIPAddress: Block traffic originating from this source IP address.
2519
/// - toIPAddress: Block traffic to this destination IP address.
2620
/// - protocols: Protocols which should be blocked. If none is specified all will be blocked.
27-
private init(fromIPAddress: String, toIPAddress: String, protocols: [NetworkingProtocol]) {
21+
private init(fromIPAddress: String, toIPAddress: String, protocols: [NetworkTransportProtocol]) {
2822
self.fromIPAddress = fromIPAddress
2923
self.toIPAddress = toIPAddress
3024
self.protocols = protocols
@@ -36,7 +30,7 @@ struct FirewallRule {
3630

3731
/// Make a firewall rule blocking API access for the current device under test
3832
public static func makeBlockAPIAccessFirewallRule() throws -> FirewallRule {
39-
let deviceIPAddress = try FirewallAPIClient().getDeviceIPAddress()
33+
let deviceIPAddress = try FirewallClient().getDeviceIPAddress()
4034
let apiIPAddress = try MullvadAPIWrapper.getAPIIPAddress()
4135
return FirewallRule(
4236
fromIPAddress: deviceIPAddress,
@@ -46,7 +40,7 @@ struct FirewallRule {
4640
}
4741

4842
public static func makeBlockAllTrafficRule(toIPAddress: String) throws -> FirewallRule {
49-
let deviceIPAddress = try FirewallAPIClient().getDeviceIPAddress()
43+
let deviceIPAddress = try FirewallClient().getDeviceIPAddress()
5044

5145
return FirewallRule(
5246
fromIPAddress: deviceIPAddress,
@@ -56,7 +50,7 @@ struct FirewallRule {
5650
}
5751

5852
public static func makeBlockUDPTrafficRule(toIPAddress: String) throws -> FirewallRule {
59-
let deviceIPAddress = try FirewallAPIClient().getDeviceIPAddress()
53+
let deviceIPAddress = try FirewallClient().getDeviceIPAddress()
6054

6155
return FirewallRule(
6256
fromIPAddress: deviceIPAddress,

0 commit comments

Comments
 (0)