diff options
| author | Steffen Ernst <steffen.ernst@mullvad.net> | 2025-02-26 14:25:47 +0100 |
|---|---|---|
| committer | Bug Magnet <marco.nikic@mullvad.net> | 2025-04-22 11:57:25 +0200 |
| commit | 5616f687e685cf430f2fe6a97a851f5d2b0c6874 (patch) | |
| tree | d8bb1ec8700a564fd1296cf3ac0a09131c42d70d | |
| parent | 1e4b35f1dd7386a710b6b5bf9b2543fdeaa9c0e6 (diff) | |
| download | mullvadvpn-5616f687e685cf430f2fe6a97a851f5d2b0c6874.tar.xz mullvadvpn-5616f687e685cf430f2fe6a97a851f5d2b0c6874.zip | |
Add test for mullvad api
| -rw-r--r-- | Cargo.lock | 1 | ||||
| -rw-r--r-- | ios/MullvadRESTTests/MullvadApiTests.swift | 197 | ||||
| -rw-r--r-- | ios/MullvadRustRuntime/MullvadApiContext.swift | 9 | ||||
| -rw-r--r-- | ios/MullvadRustRuntime/include/mullvad_rust_runtime.h | 66 | ||||
| -rw-r--r-- | ios/MullvadVPN.xcodeproj/project.pbxproj | 6 | ||||
| -rw-r--r-- | mullvad-ios/Cargo.toml | 1 | ||||
| -rw-r--r-- | mullvad-ios/src/api_client/mock.rs | 104 | ||||
| -rw-r--r-- | mullvad-ios/src/api_client/mod.rs | 35 |
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, }; |
