summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAndrej Mihajlov <and@mullvad.net>2023-08-15 13:12:48 +0200
committerAndrej Mihajlov <and@mullvad.net>2023-08-17 14:44:09 +0200
commitf0694bf653c8caf920e27715fa0821670cc67908 (patch)
tree766b5bc8fef291f130401399d3d1fd3c7c9dbc05
parent24fff949d10a7a865ae29029453298f56cc2f6db (diff)
downloadmullvadvpn-f0694bf653c8caf920e27715fa0821670cc67908.tar.xz
mullvadvpn-f0694bf653c8caf920e27715fa0821670cc67908.zip
TunnelMonitor: add initial connection test
-rw-r--r--ios/MullvadVPN.xcodeproj/project.pbxproj28
-rw-r--r--ios/PacketTunnelCore/Pinger/Pinger.swift2
-rw-r--r--ios/PacketTunnelCore/Pinger/PingerProtocol.swift2
-rw-r--r--ios/PacketTunnelCoreTests/Mocks/MockDefaultPathObserver.swift48
-rw-r--r--ios/PacketTunnelCoreTests/Mocks/MockPinger.swift126
-rw-r--r--ios/PacketTunnelCoreTests/Mocks/MockTunnelDeviceInfo.swift26
-rw-r--r--ios/PacketTunnelCoreTests/Mocks/NetworkCounters.swift54
-rw-r--r--ios/PacketTunnelCoreTests/TunnelMonitorTests.swift119
8 files changed, 403 insertions, 2 deletions
diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj
index c8b8755373..f905c92b0c 100644
--- a/ios/MullvadVPN.xcodeproj/project.pbxproj
+++ b/ios/MullvadVPN.xcodeproj/project.pbxproj
@@ -68,6 +68,7 @@
580810E82A30E15500B74552 /* DeviceCheckRemoteServiceProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 580810E72A30E15500B74552 /* DeviceCheckRemoteServiceProtocol.swift */; };
580810E92A30E17300B74552 /* DeviceCheckRemoteServiceProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 580810E72A30E15500B74552 /* DeviceCheckRemoteServiceProtocol.swift */; };
580909D32876D09A0078138D /* RevokedDeviceViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 580909D22876D09A0078138D /* RevokedDeviceViewController.swift */; };
+ 58092E542A8B832E00C3CC72 /* TunnelMonitorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58092E532A8B832E00C3CC72 /* TunnelMonitorTests.swift */; };
580F8B8328197881002E0998 /* TunnelSettingsV2.swift in Sources */ = {isa = PBXBuildFile; fileRef = 580F8B8228197881002E0998 /* TunnelSettingsV2.swift */; };
580F8B8428197884002E0998 /* TunnelSettingsV2.swift in Sources */ = {isa = PBXBuildFile; fileRef = 580F8B8228197881002E0998 /* TunnelSettingsV2.swift */; };
5811DE50239014550011EB53 /* NEVPNStatus+Debug.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5811DE4F239014550011EB53 /* NEVPNStatus+Debug.swift */; };
@@ -78,6 +79,8 @@
581DA2732A1E227D0046ED47 /* RESTTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 581DA2722A1E227D0046ED47 /* RESTTypes.swift */; };
581DA2752A1E283E0046ED47 /* WgKeyRotation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 581DA2742A1E283E0046ED47 /* WgKeyRotation.swift */; };
581DA2762A1E2FD10046ED47 /* WgKeyRotation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 581DA2742A1E283E0046ED47 /* WgKeyRotation.swift */; };
+ 581F23AD2A8CF92100788AB6 /* MockDefaultPathObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 581F23AC2A8CF92100788AB6 /* MockDefaultPathObserver.swift */; };
+ 581F23AF2A8CF94D00788AB6 /* MockPinger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 581F23AE2A8CF94D00788AB6 /* MockPinger.swift */; };
5820676426E771DB00655B05 /* TunnelManagerErrors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5820676326E771DB00655B05 /* TunnelManagerErrors.swift */; };
5820EDA9288FE064006BF4E4 /* DeviceManagementInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5820EDA8288FE064006BF4E4 /* DeviceManagementInteractor.swift */; };
5820EDAB288FF0D2006BF4E4 /* DeviceRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5820EDAA288FF0D2006BF4E4 /* DeviceRowView.swift */; };
@@ -373,6 +376,8 @@
58E45A5729F12C5100281ECF /* Result+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58F1311427E0B2AB007AC5BC /* Result+Extensions.swift */; };
58E511E628DDDEAC00B0BCDE /* CodingErrors+CustomErrorDescription.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58E511E528DDDEAC00B0BCDE /* CodingErrors+CustomErrorDescription.swift */; };
58E511E828DDDF2400B0BCDE /* CodingErrors+CustomErrorDescription.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58E511E528DDDEAC00B0BCDE /* CodingErrors+CustomErrorDescription.swift */; };
+ 58EC067A2A8D208D00BEB973 /* MockTunnelDeviceInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58EC06792A8D208D00BEB973 /* MockTunnelDeviceInfo.swift */; };
+ 58EC067C2A8D2A0B00BEB973 /* NetworkCounters.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58EC067B2A8D2A0B00BEB973 /* NetworkCounters.swift */; };
58EE2E3A272FF814003BFF93 /* SettingsDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58EE2E38272FF814003BFF93 /* SettingsDataSource.swift */; };
58EE2E3B272FF814003BFF93 /* SettingsDataSourceDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58EE2E39272FF814003BFF93 /* SettingsDataSourceDelegate.swift */; };
58EF580B25D69D7A00AEBA94 /* ProblemReportSubmissionOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58EF580A25D69D7A00AEBA94 /* ProblemReportSubmissionOverlayView.swift */; };
@@ -952,6 +957,7 @@
5808273B284888BC006B77A4 /* App.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = App.xcconfig; sourceTree = "<group>"; };
5808273C284888E5006B77A4 /* PacketTunnel.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = PacketTunnel.xcconfig; sourceTree = "<group>"; };
580909D22876D09A0078138D /* RevokedDeviceViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RevokedDeviceViewController.swift; sourceTree = "<group>"; };
+ 58092E532A8B832E00C3CC72 /* TunnelMonitorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelMonitorTests.swift; sourceTree = "<group>"; };
580CBFB72848D503007878F0 /* OperationConditionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OperationConditionTests.swift; sourceTree = "<group>"; };
580EE22324B3243100F9D8A1 /* AsyncBlockOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncBlockOperation.swift; sourceTree = "<group>"; };
580F8B8228197881002E0998 /* TunnelSettingsV2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelSettingsV2.swift; sourceTree = "<group>"; };
@@ -974,6 +980,8 @@
5819C2162729595500D6EC38 /* SettingsAddDNSEntryCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsAddDNSEntryCell.swift; sourceTree = "<group>"; };
581DA2722A1E227D0046ED47 /* RESTTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RESTTypes.swift; sourceTree = "<group>"; };
581DA2742A1E283E0046ED47 /* WgKeyRotation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WgKeyRotation.swift; sourceTree = "<group>"; };
+ 581F23AC2A8CF92100788AB6 /* MockDefaultPathObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockDefaultPathObserver.swift; sourceTree = "<group>"; };
+ 581F23AE2A8CF94D00788AB6 /* MockPinger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockPinger.swift; sourceTree = "<group>"; };
5820675A26E6576800655B05 /* RelayCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayCache.swift; sourceTree = "<group>"; };
5820675D26E6839900655B05 /* PresentAlertOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresentAlertOperation.swift; sourceTree = "<group>"; };
5820676326E771DB00655B05 /* TunnelManagerErrors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelManagerErrors.swift; sourceTree = "<group>"; };
@@ -1224,6 +1232,8 @@
58E511E528DDDEAC00B0BCDE /* CodingErrors+CustomErrorDescription.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CodingErrors+CustomErrorDescription.swift"; sourceTree = "<group>"; };
58E511EA28DDE18400B0BCDE /* Error+Chain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Error+Chain.swift"; sourceTree = "<group>"; };
58E973DD24850EB600096F90 /* AsyncOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncOperation.swift; sourceTree = "<group>"; };
+ 58EC06792A8D208D00BEB973 /* MockTunnelDeviceInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockTunnelDeviceInfo.swift; sourceTree = "<group>"; };
+ 58EC067B2A8D2A0B00BEB973 /* NetworkCounters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkCounters.swift; sourceTree = "<group>"; };
58ECD29123F178FD004298B6 /* Screenshots.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Screenshots.xcconfig; sourceTree = "<group>"; };
58EE2E38272FF814003BFF93 /* SettingsDataSource.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsDataSource.swift; sourceTree = "<group>"; };
58EE2E39272FF814003BFF93 /* SettingsDataSourceDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsDataSourceDelegate.swift; sourceTree = "<group>"; };
@@ -2198,6 +2208,8 @@
isa = PBXGroup;
children = (
58C7A46F2A8649ED0060C66F /* PingerTests.swift */,
+ 58092E532A8B832E00C3CC72 /* TunnelMonitorTests.swift */,
+ 58EC067D2A8D2B0700BEB973 /* Mocks */,
);
path = PacketTunnelCoreTests;
sourceTree = "<group>";
@@ -2395,6 +2407,17 @@
path = TunnelMonitor;
sourceTree = "<group>";
};
+ 58EC067D2A8D2B0700BEB973 /* Mocks */ = {
+ isa = PBXGroup;
+ children = (
+ 581F23AC2A8CF92100788AB6 /* MockDefaultPathObserver.swift */,
+ 581F23AE2A8CF94D00788AB6 /* MockPinger.swift */,
+ 58EC06792A8D208D00BEB973 /* MockTunnelDeviceInfo.swift */,
+ 58EC067B2A8D2A0B00BEB973 /* NetworkCounters.swift */,
+ );
+ path = Mocks;
+ sourceTree = "<group>";
+ };
58ECD29023F178FD004298B6 /* Configurations */ = {
isa = PBXGroup;
children = (
@@ -3436,6 +3459,11 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
+ 58EC067A2A8D208D00BEB973 /* MockTunnelDeviceInfo.swift in Sources */,
+ 58EC067C2A8D2A0B00BEB973 /* NetworkCounters.swift in Sources */,
+ 581F23AD2A8CF92100788AB6 /* MockDefaultPathObserver.swift in Sources */,
+ 58092E542A8B832E00C3CC72 /* TunnelMonitorTests.swift in Sources */,
+ 581F23AF2A8CF94D00788AB6 /* MockPinger.swift in Sources */,
58C7A4702A8649ED0060C66F /* PingerTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
diff --git a/ios/PacketTunnelCore/Pinger/Pinger.swift b/ios/PacketTunnelCore/Pinger/Pinger.swift
index c312f822a4..e9a960a102 100644
--- a/ios/PacketTunnelCore/Pinger/Pinger.swift
+++ b/ios/PacketTunnelCore/Pinger/Pinger.swift
@@ -150,7 +150,7 @@ public final class Pinger: PingerProtocol {
throw Error.sendPacket(errno)
}
- return PingerSendResult(sequenceNumber: sequenceNumber, bytesSent: UInt16(bytesSent))
+ return PingerSendResult(sequenceNumber: sequenceNumber, bytesSent: UInt(bytesSent))
}
private func nextSequenceNumber() -> UInt16 {
diff --git a/ios/PacketTunnelCore/Pinger/PingerProtocol.swift b/ios/PacketTunnelCore/Pinger/PingerProtocol.swift
index 6b7a4f9764..9df205ab6d 100644
--- a/ios/PacketTunnelCore/Pinger/PingerProtocol.swift
+++ b/ios/PacketTunnelCore/Pinger/PingerProtocol.swift
@@ -16,7 +16,7 @@ public enum PingerReply {
public struct PingerSendResult {
public var sequenceNumber: UInt16
- public var bytesSent: UInt16
+ public var bytesSent: UInt
}
public protocol PingerProtocol {
diff --git a/ios/PacketTunnelCoreTests/Mocks/MockDefaultPathObserver.swift b/ios/PacketTunnelCoreTests/Mocks/MockDefaultPathObserver.swift
new file mode 100644
index 0000000000..2e6c4feb6e
--- /dev/null
+++ b/ios/PacketTunnelCoreTests/Mocks/MockDefaultPathObserver.swift
@@ -0,0 +1,48 @@
+//
+// MockDefaultPathObserver.swift
+// PacketTunnelCoreTests
+//
+// Created by pronebird on 16/08/2023.
+// Copyright © 2023 Mullvad VPN AB. All rights reserved.
+//
+
+import Foundation
+import NetworkExtension
+import PacketTunnelCore
+
+struct MockNetworkPath: NetworkPath {
+ var status: NetworkExtension.NWPathStatus = .satisfied
+}
+
+/// Mock implementation of a default path observer.
+class MockDefaultPathObserver: DefaultPathObserverProtocol {
+ var defaultPath: NetworkPath? {
+ return stateLock.withLock { innerPath }
+ }
+
+ private var innerPath: NetworkPath = MockNetworkPath()
+ private var stateLock = NSLock()
+
+ private var defaultPathHandler: ((NetworkPath) -> Void)?
+
+ func start(_ body: @escaping (NetworkPath) -> Void) {
+ stateLock.withLock {
+ defaultPathHandler = body
+ }
+ }
+
+ func stop() {
+ stateLock.withLock {
+ defaultPathHandler = nil
+ }
+ }
+
+ /// Simulate network path update.
+ func updatePath(_ newPath: NetworkPath) {
+ let pathHandler = stateLock.withLock {
+ innerPath = newPath
+ return defaultPathHandler
+ }
+ pathHandler?(newPath)
+ }
+}
diff --git a/ios/PacketTunnelCoreTests/Mocks/MockPinger.swift b/ios/PacketTunnelCoreTests/Mocks/MockPinger.swift
new file mode 100644
index 0000000000..e75918d0ad
--- /dev/null
+++ b/ios/PacketTunnelCoreTests/Mocks/MockPinger.swift
@@ -0,0 +1,126 @@
+//
+// MockPinger.swift
+// PacketTunnelCoreTests
+//
+// Created by pronebird on 16/08/2023.
+// Copyright © 2023 Mullvad VPN AB. All rights reserved.
+//
+
+import Foundation
+import Network
+@testable import PacketTunnelCore
+
+/// Ping client mock implementation that can be used to simulate network transmission errors and delays.
+class MockPinger: PingerProtocol {
+ typealias OutcomeDecider = (IPv4Address, UInt16) -> Outcome
+
+ private let decideOutcome: OutcomeDecider
+ private let networkStatsReporting: NetworkStatsReporting
+ private let stateLock = NSLock()
+ private var state = State()
+
+ var onReply: ((PingerReply) -> Void)? {
+ get {
+ stateLock.withLock { state.onReply }
+ }
+ set {
+ stateLock.withLock { state.onReply = newValue }
+ }
+ }
+
+ init(networkStatsReporting: NetworkStatsReporting, decideOutcome: @escaping OutcomeDecider) {
+ self.networkStatsReporting = networkStatsReporting
+ self.decideOutcome = decideOutcome
+ }
+
+ func openSocket(bindTo interfaceName: String?) throws {
+ stateLock.withLock {
+ state.isSocketOpen = true
+ }
+ }
+
+ func closeSocket() {
+ stateLock.withLock {
+ state.isSocketOpen = false
+ }
+ }
+
+ func send(to address: IPv4Address) throws -> PingerSendResult {
+ // Used for simulation. In reality can be any number.
+ // But for realism it is: IPv4 header (20 bytes) + ICMP header (8 bytes)
+ let icmpPacketSize: UInt = 28
+
+ let nextSequenceId = try stateLock.withLock {
+ guard state.isSocketOpen else { throw POSIXError(.ENOTCONN) }
+
+ return state.incrementSequenceId()
+ }
+
+ switch decideOutcome(address, nextSequenceId) {
+ case let .sendReply(reply, delay):
+ DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
+ guard let self else { return }
+
+ networkStatsReporting.reportBytesReceived(UInt64(icmpPacketSize))
+
+ switch reply {
+ case .normal:
+ onReply?(.success(address, nextSequenceId))
+ case .malformed:
+ onReply?(.parseError(ParseError()))
+ }
+ }
+
+ case .ignore:
+ break
+
+ case .sendFailure:
+ throw POSIXError(.ECONNREFUSED)
+ }
+
+ networkStatsReporting.reportBytesSent(UInt64(icmpPacketSize))
+
+ return PingerSendResult(sequenceNumber: nextSequenceId, bytesSent: icmpPacketSize)
+ }
+
+ // MARK: - Types
+
+ /// Internal state
+ private struct State {
+ var sequenceId: UInt16 = 0
+ var isSocketOpen = false
+ var onReply: ((PingerReply) -> Void)?
+
+ mutating func incrementSequenceId() -> UInt16 {
+ sequenceId += 1
+ return sequenceId
+ }
+ }
+
+ /// Simulated ICMP reply.
+ enum Reply {
+ /// Simulate normal ping reply.
+ case normal
+
+ /// Simulate malformed ping reply.
+ case malformed
+ }
+
+ /// The outcome of ping request simulation.
+ enum Outcome {
+ /// Simulate ping reply transmission.
+ case sendReply(reply: Reply = .normal, afterDelay: TimeInterval = 0.1)
+
+ /// Simulate packet that was lost or left unanswered.
+ case ignore
+
+ /// Simulate failure to send ICMP packet (i.e `sendto()` error).
+ case sendFailure
+ }
+
+ struct ParseError: LocalizedError {
+ var errorDescription: String? {
+ return "ICMP response parse error"
+ }
+ }
+}
diff --git a/ios/PacketTunnelCoreTests/Mocks/MockTunnelDeviceInfo.swift b/ios/PacketTunnelCoreTests/Mocks/MockTunnelDeviceInfo.swift
new file mode 100644
index 0000000000..bb61dfb443
--- /dev/null
+++ b/ios/PacketTunnelCoreTests/Mocks/MockTunnelDeviceInfo.swift
@@ -0,0 +1,26 @@
+//
+// MockTunnelDeviceInfo.swift
+// PacketTunnelCoreTests
+//
+// Created by pronebird on 16/08/2023.
+// Copyright © 2023 Mullvad VPN AB. All rights reserved.
+//
+
+import Foundation
+import PacketTunnelCore
+
+/// Mock implementation of a tunnel device.
+struct MockTunnelDeviceInfo: TunnelDeviceInfoProtocol {
+ let networkStatsProviding: NetworkStatsProviding
+
+ var interfaceName: String? {
+ return "utun0"
+ }
+
+ func getStats() throws -> WgStats {
+ return WgStats(
+ bytesReceived: networkStatsProviding.bytesReceived,
+ bytesSent: networkStatsProviding.bytesSent
+ )
+ }
+}
diff --git a/ios/PacketTunnelCoreTests/Mocks/NetworkCounters.swift b/ios/PacketTunnelCoreTests/Mocks/NetworkCounters.swift
new file mode 100644
index 0000000000..d20a1ef20f
--- /dev/null
+++ b/ios/PacketTunnelCoreTests/Mocks/NetworkCounters.swift
@@ -0,0 +1,54 @@
+//
+// NetworkCounters.swift
+// PacketTunnelCoreTests
+//
+// Created by pronebird on 16/08/2023.
+// Copyright © 2023 Mullvad VPN AB. All rights reserved.
+//
+
+import Foundation
+
+/// Protocol describing a type capable of receiving and updating network counters.
+protocol NetworkStatsReporting {
+ /// Increment number of bytes sent.
+ func reportBytesSent(_ byteCount: UInt64)
+
+ /// Increment number of bytes received.
+ func reportBytesReceived(_ byteCount: UInt64)
+}
+
+/// Protocol describing a type providing network statistics.
+protocol NetworkStatsProviding {
+ /// Returns number of bytes sent.
+ var bytesSent: UInt64 { get }
+
+ /// Returns number of bytes received.
+ var bytesReceived: UInt64 { get }
+}
+
+/// Class that holds network statistics (bytes sent and received) for a simulated network adapter.
+final class NetworkCounters: NetworkStatsProviding, NetworkStatsReporting {
+ private let stateLock = NSLock()
+ private var _bytesSent: UInt64 = 0
+ private var _bytesReceived: UInt64 = 0
+
+ var bytesSent: UInt64 {
+ stateLock.withLock { _bytesSent }
+ }
+
+ var bytesReceived: UInt64 {
+ stateLock.withLock { _bytesReceived }
+ }
+
+ func reportBytesSent(_ byteCount: UInt64) {
+ stateLock.withLock {
+ _bytesSent += byteCount
+ }
+ }
+
+ func reportBytesReceived(_ byteCount: UInt64) {
+ stateLock.withLock {
+ _bytesReceived += byteCount
+ }
+ }
+}
diff --git a/ios/PacketTunnelCoreTests/TunnelMonitorTests.swift b/ios/PacketTunnelCoreTests/TunnelMonitorTests.swift
new file mode 100644
index 0000000000..462c2f1476
--- /dev/null
+++ b/ios/PacketTunnelCoreTests/TunnelMonitorTests.swift
@@ -0,0 +1,119 @@
+//
+// TunnelMonitorTests.swift
+// PacketTunnelCoreTests
+//
+// Created by pronebird on 15/08/2023.
+// Copyright © 2023 Mullvad VPN AB. All rights reserved.
+//
+
+import Network
+@testable import PacketTunnelCore
+import XCTest
+
+final class TunnelMonitorTests: XCTestCase {
+ let networkCounters = NetworkCounters()
+
+ func testShouldDetermineConnectionEstablished() throws {
+ let connectedExpectation = expectation(description: "Should report connected.")
+ let connectionLostExpectation = expectation(description: "Should not report connection loss")
+ connectionLostExpectation.isInverted = true
+
+ let pinger = MockPinger(networkStatsReporting: networkCounters) { destination, sequence in
+ return .sendReply()
+ }
+
+ let tunnelMonitor = TunnelMonitor(
+ eventQueue: .main,
+ pinger: pinger,
+ tunnelDeviceInfo: MockTunnelDeviceInfo(networkStatsProviding: networkCounters),
+ defaultPathObserver: MockDefaultPathObserver()
+ )
+
+ tunnelMonitor.onEvent = { event in
+ switch event {
+ case .connectionEstablished:
+ connectedExpectation.fulfill()
+
+ case .connectionLost:
+ connectionLostExpectation.fulfill()
+
+ case .networkReachabilityChanged:
+ break
+ }
+ }
+
+ tunnelMonitor.start(probeAddress: .loopback)
+
+ waitForExpectations(timeout: 1)
+ }
+
+ func testInitialConnectionTimings() {
+ // Setup pinger so that it never receives any replies.
+ let pinger = MockPinger(networkStatsReporting: networkCounters) { destination, sequence in
+ return .ignore
+ }
+
+ let tunnelMonitor = TunnelMonitor(
+ eventQueue: .main,
+ pinger: pinger,
+ tunnelDeviceInfo: MockTunnelDeviceInfo(networkStatsProviding: networkCounters),
+ defaultPathObserver: MockDefaultPathObserver()
+ )
+
+ /*
+ Tunnel monitor uses shorter timeout intervals during the initial connection sequence and picks next relay more
+ aggressively in order to reduce connection time.
+
+ First connection attempt starts at 4 second timeout, then doubles with each subsequent attempt, while being
+ capped at 15s max.
+ */
+ var expectedTimings = [4, 8, 15, 15]
+
+ // Calculate the amount of time necessary to perform the test adding some leeway.
+ let timeout = expectedTimings.reduce(1, +)
+
+ let expectation = self.expectation(description: "Should respect all timings.")
+ expectation.expectedFulfillmentCount = expectedTimings.count
+
+ // This date will be used to measure the amount of time elapsed between `.connectionLost` events.
+ var startDate = Date()
+
+ tunnelMonitor.onEvent = { [weak tunnelMonitor] event in
+ guard case .connectionLost = event else { return }
+
+ switch event {
+ case .connectionLost:
+ let expectedDuration = expectedTimings.removeFirst()
+
+ // Compute amount of time elapsed between `.connectionLost` events rounding it down towards zero.
+ let timeElapsed = Int(Date().timeIntervalSince(startDate).rounded(.down))
+
+ XCTAssertEqual(
+ timeElapsed,
+ expectedDuration,
+ "Expected to report connection loss after \(expectedDuration)s, instead reported it after \(timeElapsed)s."
+ )
+
+ expectation.fulfill()
+
+ if !expectedTimings.isEmpty {
+ startDate = Date()
+
+ // Continue monitoring by calling start() again.
+ tunnelMonitor?.start(probeAddress: .loopback)
+ }
+
+ case .connectionEstablished:
+ XCTFail()
+
+ case .networkReachabilityChanged:
+ break
+ }
+ }
+
+ // Start monitoring.
+ tunnelMonitor.start(probeAddress: .loopback)
+
+ waitForExpectations(timeout: TimeInterval(timeout))
+ }
+}