summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorSteffen Ernst <steffen.ernst@mullvad.net>2025-02-26 14:25:47 +0100
committerBug Magnet <marco.nikic@mullvad.net>2025-04-22 11:57:25 +0200
commit5616f687e685cf430f2fe6a97a851f5d2b0c6874 (patch)
treed8bb1ec8700a564fd1296cf3ac0a09131c42d70d
parent1e4b35f1dd7386a710b6b5bf9b2543fdeaa9c0e6 (diff)
downloadmullvadvpn-5616f687e685cf430f2fe6a97a851f5d2b0c6874.tar.xz
mullvadvpn-5616f687e685cf430f2fe6a97a851f5d2b0c6874.zip
Add test for mullvad api
-rw-r--r--Cargo.lock1
-rw-r--r--ios/MullvadRESTTests/MullvadApiTests.swift197
-rw-r--r--ios/MullvadRustRuntime/MullvadApiContext.swift9
-rw-r--r--ios/MullvadRustRuntime/include/mullvad_rust_runtime.h66
-rw-r--r--ios/MullvadVPN.xcodeproj/project.pbxproj6
-rw-r--r--mullvad-ios/Cargo.toml1
-rw-r--r--mullvad-ios/src/api_client/mock.rs104
-rw-r--r--mullvad-ios/src/api_client/mod.rs35
8 files changed, 415 insertions, 4 deletions
diff --git a/Cargo.lock b/Cargo.lock
index d5cd685851..6d9abdc891 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2790,6 +2790,7 @@ dependencies = [
"hyper-util",
"libc",
"log",
+ "mockito",
"mullvad-api",
"mullvad-encrypted-dns-proxy",
"oslog",
diff --git a/ios/MullvadRESTTests/MullvadApiTests.swift b/ios/MullvadRESTTests/MullvadApiTests.swift
new file mode 100644
index 0000000000..ab218623d2
--- /dev/null
+++ b/ios/MullvadRESTTests/MullvadApiTests.swift
@@ -0,0 +1,197 @@
+//
+// MullvadApiTests.swift
+// MullvadVPN
+//
+// Created by Steffen Ernst on 2025-02-27.
+// Copyright © 2025 Mullvad VPN AB. All rights reserved.
+//
+
+@testable import MullvadREST
+import MullvadRustRuntime
+import MullvadTypes
+import Network
+import XCTest
+
+/// This tests main purpose is to test the functionallity of the FFI rather than every function of the proxy itself.
+/// It makes sure the response and errors are parsed correctly.
+
+class MullvadApiTests: XCTestCase {
+ let encoder = JSONEncoder()
+
+ func makeApiProxy(port: UInt16) throws -> APIQuerying {
+ let context = try MullvadApiContext(host: "localhost", address: .ipv4(
+ .init(ip: IPv4Address.loopback, port: port)
+ ), disable_tls: true)
+ let proxy = REST.MullvadAPIProxy(
+ transportProvider: APITransportProvider(
+ requestFactory: .init(apiContext: context)
+ ),
+ dispatchQueue: .main,
+ responseDecoder: REST.Coding.makeJSONDecoder()
+ )
+
+ return proxy
+ }
+
+ func testSuccessfulResponse() async throws {
+ let expectedEndpoints: [AnyIPEndpoint] = [AnyIPEndpoint(string: "12.34.56.78:80")!]
+
+ let bodyData = try encoder.encode(expectedEndpoints)
+ let body = String(data: bodyData, encoding: .utf8)!
+ let responseCode: UInt = 200
+ let mock = mullvad_api_mock_get(
+ "/app/v1/api-addrs",
+ UInt(responseCode),
+ body
+ )
+ defer { mullvad_api_mock_drop(mock) }
+ let apiProxy = try makeApiProxy(port: mock.port)
+
+ let result: Result<[AnyIPEndpoint], Error> = await withCheckedContinuation { continuation in
+ _ = apiProxy
+ .getAddressList(
+ retryStrategy: .noRetry
+ ) { result in
+ continuation.resume(returning: result)
+ }
+ }
+
+ guard let receivedEndpoints = result.value else {
+ XCTFail(result.error!.localizedDescription)
+ return
+ }
+
+ XCTAssertEqual(receivedEndpoints, expectedEndpoints)
+ }
+
+ func testHTTPError() async throws {
+ let expectedResponseCode = 500
+ let mock = mullvad_api_mock_get(
+ "/app/v1/api-addrs",
+ UInt(expectedResponseCode),
+ ""
+ )
+ defer { mullvad_api_mock_drop(mock) }
+ let apiProxy = try makeApiProxy(port: mock.port)
+
+ let result: Result<[AnyIPEndpoint], Error> = await withCheckedContinuation { continuation in
+ _ = apiProxy
+ .getAddressList(
+ retryStrategy: .noRetry
+ ) { result in
+ continuation.resume(returning: result)
+ }
+ }
+
+ let error = try XCTUnwrap(result.error as? REST.Error)
+
+ switch error {
+ case let .unhandledResponse(responseCode, _):
+ XCTAssertEqual(responseCode, expectedResponseCode)
+ default:
+ XCTFail("GetAddressList failed with the wrong error: \(error)")
+ }
+ }
+
+ func testInvalidBody() async throws {
+ let expectedResponseCode = 200
+ let mock = mullvad_api_mock_get(
+ "/app/v1/api-addrs",
+ UInt(expectedResponseCode),
+ "This is an invalid JSON"
+ )
+ defer { mullvad_api_mock_drop(mock) }
+ let apiProxy = try makeApiProxy(port: mock.port)
+
+ let result: Result<[AnyIPEndpoint], Error> = await withCheckedContinuation { continuation in
+ _ = apiProxy
+ .getAddressList(
+ retryStrategy: .noRetry
+ ) { result in
+ continuation.resume(returning: result)
+ }
+ }
+
+ let error = try XCTUnwrap(result.error as? REST.Error)
+
+ switch error {
+ case let .unhandledResponse(_, response):
+ XCTAssertEqual(response?.code, REST.ServerResponseCode.parsingError)
+ default:
+ XCTFail("GetAddressList failed with the wrong error: \(error)")
+ }
+ }
+
+ func testCustomErrorCode() async throws {
+ let expectedResponseCode = 400
+ let expectedErrorCode = 123
+ let mock = mullvad_api_mock_get(
+ "/app/v1/api-addrs",
+ UInt(expectedResponseCode),
+ """
+ {"code": "\(expectedErrorCode)",
+ "error": "A magical error occured"
+ }
+ """
+ )
+ defer { mullvad_api_mock_drop(mock) }
+ let apiProxy = try makeApiProxy(port: mock.port)
+
+ let result: Result<[AnyIPEndpoint], Error> = await withCheckedContinuation { continuation in
+ _ = apiProxy
+ .getAddressList(
+ retryStrategy: .noRetry
+ ) { result in
+ continuation.resume(returning: result)
+ }
+ }
+
+ let error = try XCTUnwrap(result.error as? REST.Error)
+
+ switch error {
+ case let .unhandledResponse(responseCode, response):
+ XCTAssertEqual(responseCode, expectedResponseCode)
+ guard let response else {
+ XCTFail("Expected error response object, but got nil")
+ return
+ }
+ XCTAssertEqual(response.code.rawValue, String(expectedErrorCode))
+ default:
+ XCTFail("GetAddressList failed with the wrong error: \(error)")
+ }
+ }
+
+ // This test makes sure the body gets encoded correct.
+ func testSuccessfulPostRequest() async throws {
+ let problemReportRequest = ProblemReportRequest(
+ address: "test@email.com",
+ message: "This test should succeed",
+ log: "A long log string",
+ metadata: [:]
+ )
+
+ // The mock server will only responde to requests with `matchBodyString` as body.
+ let matchBodyString = String(data: try encoder.encode(problemReportRequest), encoding: .utf8)!
+ let expectedResponseCode: UInt = 204
+ let mock = mullvad_api_mock_post(
+ "/app/v1/problem-report",
+ UInt(expectedResponseCode),
+ matchBodyString
+ )
+ defer { mullvad_api_mock_drop(mock) }
+ let apiProxy = try makeApiProxy(port: mock.port)
+
+ let result: Result<Void, Error> = await withCheckedContinuation { continuation in
+ _ = apiProxy
+ .sendProblemReport(
+ problemReportRequest,
+ retryStrategy:
+ .noRetry
+ ) { result in
+ continuation.resume(returning: result)
+ }
+ }
+
+ XCTAssertNil(result.error)
+ }
+}
diff --git a/ios/MullvadRustRuntime/MullvadApiContext.swift b/ios/MullvadRustRuntime/MullvadApiContext.swift
index f637590612..2cdcb7b728 100644
--- a/ios/MullvadRustRuntime/MullvadApiContext.swift
+++ b/ios/MullvadRustRuntime/MullvadApiContext.swift
@@ -15,8 +15,13 @@ public struct MullvadApiContext: Sendable {
public let context: SwiftApiContext
- public init(host: String, address: AnyIPEndpoint) throws {
- context = mullvad_api_init_new(host, address.description)
+ public init(host: String, address: AnyIPEndpoint, disable_tls: Bool = false) throws {
+ context = switch disable_tls {
+ case true:
+ mullvad_api_init_new_tls_disabled(host, address.description)
+ case false:
+ mullvad_api_init_new(host, address.description)
+ }
if context._0 == nil {
throw MullvadApiContextError.failedToConstructApiClient
diff --git a/ios/MullvadRustRuntime/include/mullvad_rust_runtime.h b/ios/MullvadRustRuntime/include/mullvad_rust_runtime.h
index 36774f2d4e..4de4a02b37 100644
--- a/ios/MullvadRustRuntime/include/mullvad_rust_runtime.h
+++ b/ios/MullvadRustRuntime/include/mullvad_rust_runtime.h
@@ -56,6 +56,12 @@ typedef struct CompletionCookie {
void *inner;
} CompletionCookie;
+typedef struct SwiftServerMock {
+ const void *server_ptr;
+ const void *mock_ptr;
+ uint16_t port;
+} SwiftServerMock;
+
typedef struct ProblemReportMetadata {
struct Map *inner;
} ProblemReportMetadata;
@@ -108,6 +114,23 @@ extern const uint16_t CONFIG_SERVICE_PORT;
*
* This function is safe.
*/
+struct SwiftApiContext mullvad_api_init_new_tls_disabled(const uint8_t *host,
+ const uint8_t *address);
+
+/**
+ * # Safety
+ *
+ * `host` must be a pointer to a null terminated string representing a hostname for Mullvad API host.
+ * This hostname will be used for TLS validation but not used for domain name resolution.
+ *
+ * `address` must be a pointer to a null terminated string representing a socket address through which
+ * the Mullvad API can be reached directly.
+ *
+ * If a context cannot be constructed this function will panic since the call site would not be able
+ * to proceed in a meaningful way anyway.
+ *
+ * This function is safe.
+ */
struct SwiftApiContext mullvad_api_init_new(const uint8_t *host,
const uint8_t *address);
@@ -238,6 +261,49 @@ extern void mullvad_api_completion_finish(struct SwiftMullvadApiResponse respons
struct CompletionCookie completion_cookie);
/**
+ * # Safety
+ *
+ * `method` must be a pointer to a null terminated string representing the http method.
+ *
+ * `path` must be a pointer to a null terminated string representing the url path.
+ *
+ * `response_code` must be a usize representing the http response code.
+ *
+ * `response_body` must be a pointer to a null terminated string representing the body.
+ *
+ * This function is safe.
+ */
+struct SwiftServerMock mullvad_api_mock_get(const char *path,
+ uintptr_t response_code,
+ const uint8_t *response_body);
+
+/**
+ * # Safety
+ *
+ * `path` must be a pointer to a null terminated string representing the url path.
+ *
+ * `response_code` must be a usize representing the http response code.
+ *
+ * `match_body` must be a pointer to a null terminated json string representing the body the server expects.
+ *
+ * This function is safe.
+ */
+struct SwiftServerMock mullvad_api_mock_post(const char *path,
+ uintptr_t response_code,
+ const char *match_body);
+
+/**
+ * Called by the Swift side to signal that the Rust `SwiftServerMock` can be safely
+ * dropped from memory.
+ *
+ * # Safety
+ *
+ * `mock_ptr` must be pointing to a valid instance of `SwiftServerMock`. This function
+ * is not safe to call multiple times with the same `SwiftServerMock`.
+ */
+void mullvad_api_mock_drop(struct SwiftServerMock mock_ptr);
+
+/**
* Send a problem report via the Mullvad API client.
*
* # Safety
diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj
index 672799a067..3e2407ccac 100644
--- a/ios/MullvadVPN.xcodeproj/project.pbxproj
+++ b/ios/MullvadVPN.xcodeproj/project.pbxproj
@@ -1103,6 +1103,7 @@
F910A43A2D4A283D002FF3BB /* InAppPurchaseViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F910A4392D4A2839002FF3BB /* InAppPurchaseViewController.swift */; };
F910A8572D523812002FF3BB /* TunnelSettingsV7.swift in Sources */ = {isa = PBXBuildFile; fileRef = F910A8562D523812002FF3BB /* TunnelSettingsV7.swift */; };
F924C65F2DAE4554001F4660 /* ServerRelayTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F924C65E2DAE4554001F4660 /* ServerRelayTests.swift */; };
+ F924C4532D70692E001F4660 /* MullvadApiTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F924C4522D706929001F4660 /* MullvadApiTests.swift */; };
F998EFF82D359C4600D88D01 /* SKProduct+Formatting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FD5BEF24238EB300112C88 /* SKProduct+Formatting.swift */; };
F998EFFA2D3656BA00D88D01 /* SKProduct+Sorting.swift in Sources */ = {isa = PBXBuildFile; fileRef = F998EFF92D3656B100D88D01 /* SKProduct+Sorting.swift */; };
/* End PBXBuildFile section */
@@ -2502,6 +2503,7 @@
F910A4392D4A2839002FF3BB /* InAppPurchaseViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppPurchaseViewController.swift; sourceTree = "<group>"; };
F910A8562D523812002FF3BB /* TunnelSettingsV7.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelSettingsV7.swift; sourceTree = "<group>"; };
F924C65E2DAE4554001F4660 /* ServerRelayTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerRelayTests.swift; sourceTree = "<group>"; };
+ F924C4522D706929001F4660 /* MullvadApiTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MullvadApiTests.swift; sourceTree = "<group>"; };
F998EFF92D3656B100D88D01 /* SKProduct+Sorting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SKProduct+Sorting.swift"; sourceTree = "<group>"; };
/* End PBXFileReference section */
@@ -4145,6 +4147,7 @@
isa = PBXGroup;
children = (
F924C65E2DAE4554001F4660 /* ServerRelayTests.swift */,
+ F924C4522D706929001F4660 /* MullvadApiTests.swift */,
58FBFBE8291622580020E046 /* ExponentialBackoffTests.swift */,
A932D9F22B5EB61100999395 /* HeadRequestTests.swift */,
58BDEB9E2A98F6B400F578F2 /* Mocks */,
@@ -5723,7 +5726,7 @@
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
- shellScript = "CARGO_TARGET_DIR=${PROJECT_DIR}/../target bash ${PROJECT_DIR}/build-rust-library.sh mullvad-ios\n";
+ shellScript = "CARGO_TARGET_DIR=${PROJECT_DIR}/../target bash ${PROJECT_DIR}/build-rust-library.sh mullvad-ios api-override\n";
};
F05F39962B21C704006E60A7 /* Prebuild relays */ = {
isa = PBXShellScriptBuildPhase;
@@ -6722,6 +6725,7 @@
58B465702A98C53300467203 /* RequestExecutorTests.swift in Sources */,
A917352129FAAA5200D5DCFD /* TransportStrategyTests.swift in Sources */,
58FBFBE9291622580020E046 /* ExponentialBackoffTests.swift in Sources */,
+ F924C4532D70692E001F4660 /* MullvadApiTests.swift in Sources */,
F0164EC32B4C49D30020268D /* ShadowsocksLoaderStub.swift in Sources */,
F924C65F2DAE4554001F4660 /* ServerRelayTests.swift in Sources */,
58BDEB9D2A98F69E00F578F2 /* MemoryCache.swift in Sources */,
diff --git a/mullvad-ios/Cargo.toml b/mullvad-ios/Cargo.toml
index aa7ee661f8..7a87c03079 100644
--- a/mullvad-ios/Cargo.toml
+++ b/mullvad-ios/Cargo.toml
@@ -30,6 +30,7 @@ talpid-tunnel-config-client = { path = "../talpid-tunnel-config-client" }
mullvad-encrypted-dns-proxy = { path = "../mullvad-encrypted-dns-proxy" }
mullvad-api = { path = "../mullvad-api", default-features = false }
serde_json = { workspace = true }
+mockito = "1.6.1"
shadowsocks-service = { workspace = true, features = [
"local",
diff --git a/mullvad-ios/src/api_client/mock.rs b/mullvad-ios/src/api_client/mock.rs
new file mode 100644
index 0000000000..1c8dd1a1e1
--- /dev/null
+++ b/mullvad-ios/src/api_client/mock.rs
@@ -0,0 +1,104 @@
+use mockito::{Mock, ServerGuard};
+use std::ffi::{c_char, c_void};
+
+#[repr(C)]
+pub struct SwiftServerMock {
+ server_ptr: *const c_void,
+ mock_ptr: *const c_void,
+ port: u16,
+}
+
+impl SwiftServerMock {
+ pub fn new(server: ServerGuard, mock: Mock) -> SwiftServerMock {
+ let port = server.socket_address().port();
+ let server_ptr = Box::into_raw(Box::new(server)) as *const c_void;
+ let mock_ptr = Box::into_raw(Box::new(mock)) as *const c_void;
+
+ SwiftServerMock {
+ server_ptr,
+ mock_ptr,
+ port,
+ }
+ }
+}
+
+/// # Safety
+///
+/// `method` must be a pointer to a null terminated string representing the http method.
+///
+/// `path` must be a pointer to a null terminated string representing the url path.
+///
+/// `response_code` must be a usize representing the http response code.
+///
+/// `response_body` must be a pointer to a null terminated string representing the body.
+///
+/// This function is safe.
+#[unsafe(no_mangle)]
+pub unsafe extern "C" fn mullvad_api_mock_get(
+ path: *const c_char,
+ response_code: usize,
+ response_body: *const u8,
+) -> SwiftServerMock {
+ let path = unsafe { std::ffi::CStr::from_ptr(path.cast()) }
+ .to_str()
+ .unwrap();
+ let response_body = unsafe { std::ffi::CStr::from_ptr(response_body.cast()) }
+ .to_str()
+ .unwrap();
+ let mut server = mockito::Server::new();
+ let mock = server
+ .mock("GET", path)
+ .with_header("content-type", "application/json")
+ .with_status(response_code)
+ .with_body(response_body)
+ .create();
+ SwiftServerMock::new(server, mock)
+}
+
+/// # Safety
+///
+/// `path` must be a pointer to a null terminated string representing the url path.
+///
+/// `response_code` must be a usize representing the http response code.
+///
+/// `match_body` must be a pointer to a null terminated json string representing the body the server expects.
+///
+/// This function is safe.
+#[unsafe(no_mangle)]
+pub unsafe extern "C" fn mullvad_api_mock_post(
+ path: *const c_char,
+ response_code: usize,
+ match_body: *const c_char,
+) -> SwiftServerMock {
+ let path = unsafe { std::ffi::CStr::from_ptr(path.cast()) }
+ .to_str()
+ .unwrap();
+ let match_body = unsafe { std::ffi::CStr::from_ptr(match_body.cast()) }
+ .to_str()
+ .unwrap();
+ let mut server = mockito::Server::new();
+ let mock = server
+ .mock("POST", path)
+ .with_header("content-type", "application/json")
+ .with_status(response_code)
+ .match_body(mockito::Matcher::JsonString(match_body.to_string()))
+ .create();
+ SwiftServerMock::new(server, mock)
+}
+
+/// Called by the Swift side to signal that the Rust `SwiftServerMock` can be safely
+/// dropped from memory.
+///
+/// # Safety
+///
+/// `mock_ptr` must be pointing to a valid instance of `SwiftServerMock`. This function
+/// is not safe to call multiple times with the same `SwiftServerMock`.
+#[unsafe(no_mangle)]
+extern "C" fn mullvad_api_mock_drop(mock_ptr: SwiftServerMock) {
+ if !mock_ptr.mock_ptr.is_null() {
+ unsafe { drop(Box::from_raw(mock_ptr.mock_ptr as *mut Mock)) };
+ }
+ if !mock_ptr.server_ptr.is_null() {
+ unsafe { drop(Box::from_raw(mock_ptr.server_ptr as *mut ServerGuard)) };
+ }
+}
diff --git a/mullvad-ios/src/api_client/mod.rs b/mullvad-ios/src/api_client/mod.rs
index cb31134ed0..9ab6eef199 100644
--- a/mullvad-ios/src/api_client/mod.rs
+++ b/mullvad-ios/src/api_client/mod.rs
@@ -13,6 +13,7 @@ mod account;
mod api;
mod cancellation;
mod completion;
+mod mock;
mod problem_report;
mod response;
mod retry_strategy;
@@ -52,8 +53,40 @@ impl ApiContext {
/// to proceed in a meaningful way anyway.
///
/// This function is safe.
+#[cfg(feature = "api-override")]
+#[no_mangle]
+pub extern "C" fn mullvad_api_init_new_tls_disabled(
+ host: *const u8,
+ address: *const u8,
+) -> SwiftApiContext {
+ mullvad_api_init_inner(host, address, true)
+}
+
+/// # Safety
+///
+/// `host` must be a pointer to a null terminated string representing a hostname for Mullvad API host.
+/// This hostname will be used for TLS validation but not used for domain name resolution.
+///
+/// `address` must be a pointer to a null terminated string representing a socket address through which
+/// the Mullvad API can be reached directly.
+///
+/// If a context cannot be constructed this function will panic since the call site would not be able
+/// to proceed in a meaningful way anyway.
+///
+/// This function is safe.
#[no_mangle]
pub extern "C" fn mullvad_api_init_new(host: *const u8, address: *const u8) -> SwiftApiContext {
+ #[cfg(feature = "api-override")]
+ return mullvad_api_init_inner(host, address, false);
+ #[cfg(not(feature = "api-override"))]
+ return mullvad_api_init_inner(host, address);
+}
+
+fn mullvad_api_init_inner(
+ host: *const u8,
+ address: *const u8,
+ #[cfg(feature = "api-override")] disable_tls: bool,
+) -> SwiftApiContext {
let host = unsafe { CStr::from_ptr(host.cast()) };
let address = unsafe { CStr::from_ptr(address.cast()) };
@@ -64,7 +97,7 @@ pub extern "C" fn mullvad_api_init_new(host: *const u8, address: *const u8) -> S
host: Some(String::from(host)),
address: Some(address.parse().unwrap()),
#[cfg(feature = "api-override")]
- disable_tls: false,
+ disable_tls,
#[cfg(feature = "api-override")]
force_direct: false,
};